Skip to main content

bhtune_cli/
db.rs

1//! Opens the CLI's database, seeds the built-in and user-catalog DCS/PLC templates, and runs
2//! the `history-retention` sweep -- all on every startup.
3
4use std::path::Path;
5
6use bhtune_core::DcsTemplate;
7use bhtune_db::SqlitePool;
8use bhtune_db::models::TemplateOrigin;
9
10/// Opens (creating if necessary) the database at `path`, running migrations, then upserts the
11/// built-in templates via [`bhtune_db::seed_builtin_templates`] so a fresh database is
12/// immediately usable without a separate setup step. If `user_templates` is `Some` (the
13/// caller found and parsed a user catalog file -- see `crate::config::load_user_templates`,
14/// `template-user-catalog`), those templates are additionally upserted with
15/// [`TemplateOrigin::Catalog`] via [`bhtune_db::seed_templates`]. `None` means no user
16/// catalog file was found at all, which is not an error and simply skips this second seed
17/// pass -- the common case, since most installs never create `templates.toml`.
18///
19/// If `retention_days` is `Some` (see `crate::config::resolve_retention_days`), also runs
20/// [`crate::retention::sweep_retention`] once before returning -- the "on startup" half of
21/// `history-retention`'s policy, shared by both binaries since both call this function.
22/// `None` (the default) skips the sweep entirely: no query, no log line, nothing -- matching
23/// "ships disabled by default (retain forever)". A sweep failure is propagated (`?`) rather
24/// than logged-and-ignored: unlike `bhtune-server`'s periodic re-sweep (which must not crash
25/// a process that may be mid-tune just because a housekeeping query failed), this runs before
26/// any command has done anything yet, so failing fast with a clear error is strictly better
27/// than silently proceeding on what might be a genuinely broken database.
28///
29/// Creates `path`'s parent directory tree first: `bhtune_db::connect`'s
30/// `SqliteConnectOptions::create_if_missing(true)` only creates the database *file*, not any
31/// missing parent directories -- necessary now that the default database path (see
32/// `crate::config::default_db_path_from`) is a nested, not-yet-existing platform directory
33/// (e.g. `~/.local/share/bhtune/`) on a genuinely fresh install.
34pub async fn open(
35    path: &Path,
36    user_templates: Option<Vec<DcsTemplate>>,
37    retention_days: Option<u32>,
38) -> anyhow::Result<SqlitePool> {
39    tracing::info!(db_path = %path.display(), "opening database");
40    ensure_parent_dir(path)?;
41    let pool = bhtune_db::connect(path).await?;
42    let now = chrono::Utc::now();
43    let seeded = bhtune_db::seed_builtin_templates(&pool, now).await?;
44    tracing::debug!(templates = seeded.len(), "seeded built-in DCS templates");
45    if let Some(templates) = user_templates {
46        let seeded =
47            bhtune_db::seed_templates(&pool, templates, TemplateOrigin::Catalog, now).await?;
48        let count = seeded.len();
49        tracing::debug!(templates = count, "seeded user catalog DCS templates");
50    }
51    if let Some(days) = retention_days {
52        crate::retention::sweep_retention(&pool, days, now).await?;
53    }
54    Ok(pool)
55}
56
57/// Creates `path`'s parent directory tree if it doesn't already exist. A no-op (not an
58/// error) for a bare filename with no directory component at all -- `Path::parent()` returns
59/// `Some("")` in that case, and `std::fs::create_dir_all("")` is a documented no-op success,
60/// so this never needs to special-case "no parent" separately from "empty parent".
61fn ensure_parent_dir(path: &Path) -> anyhow::Result<()> {
62    path.parent()
63        .map(|parent| {
64            std::fs::create_dir_all(parent)
65                .map_err(|e| anyhow::anyhow!("failed to create database directory {parent:?}: {e}"))
66        })
67        .transpose()
68        .map(|_| ())
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74    use bhtune_db::models::DcsTemplateRow;
75
76    fn sample_catalog_template() -> DcsTemplate {
77        let mut template = bhtune_core::built_in_templates().remove(0);
78        template.name = "Test Catalog Template".to_string();
79        template
80    }
81
82    #[tokio::test]
83    async fn open_seeds_builtin_templates_on_a_fresh_database() {
84        let dir = tempfile::tempdir().unwrap();
85        let path = dir.path().join("bhtune.db");
86        let pool = open(&path, None, None).await.unwrap();
87        let templates = bhtune_db::models::DcsTemplateRow::list(&pool)
88            .await
89            .unwrap();
90        assert_eq!(templates.len(), 4);
91    }
92
93    #[tokio::test]
94    async fn open_is_idempotent_across_repeated_calls() {
95        let dir = tempfile::tempdir().unwrap();
96        let path = dir.path().join("bhtune.db");
97        open(&path, None, None).await.unwrap();
98        let pool = open(&path, None, None).await.unwrap();
99        let templates = bhtune_db::models::DcsTemplateRow::list(&pool)
100            .await
101            .unwrap();
102        assert_eq!(templates.len(), 4);
103    }
104
105    #[tokio::test]
106    async fn open_creates_missing_nested_parent_directories() {
107        let dir = tempfile::tempdir().unwrap();
108        let path = dir.path().join("nested").join("deeper").join("bhtune.db");
109        assert!(!path.parent().unwrap().exists());
110        let pool = open(&path, None, None).await.unwrap();
111        assert!(path.exists());
112        let templates = bhtune_db::models::DcsTemplateRow::list(&pool)
113            .await
114            .unwrap();
115        assert_eq!(templates.len(), 4);
116    }
117
118    #[tokio::test]
119    async fn open_seeds_user_catalog_templates_with_catalog_origin_when_provided() {
120        let dir = tempfile::tempdir().unwrap();
121        let path = dir.path().join("bhtune.db");
122        let pool = open(&path, Some(vec![sample_catalog_template()]), None)
123            .await
124            .unwrap();
125        let templates = DcsTemplateRow::list(&pool).await.unwrap();
126        assert_eq!(templates.len(), 5);
127        let seeded = templates
128            .iter()
129            .find(|t| t.template.name == "Test Catalog Template")
130            .expect("user catalog template should have been seeded");
131        assert_eq!(seeded.origin, TemplateOrigin::Catalog);
132    }
133
134    #[tokio::test]
135    async fn open_reseeding_the_same_user_catalog_is_idempotent() {
136        let dir = tempfile::tempdir().unwrap();
137        let path = dir.path().join("bhtune.db");
138        open(&path, Some(vec![sample_catalog_template()]), None)
139            .await
140            .unwrap();
141        let pool = open(&path, Some(vec![sample_catalog_template()]), None)
142            .await
143            .unwrap();
144        let templates = DcsTemplateRow::list(&pool).await.unwrap();
145        assert_eq!(templates.len(), 5);
146    }
147
148    #[tokio::test]
149    async fn open_sweeps_retention_on_startup_when_a_policy_is_configured() {
150        use bhtune_core::{ControllerType, LoopConfig, LoopTags, ProcessType};
151        use bhtune_db::models::{TuneDriver, TuneRunRow};
152
153        let dir = tempfile::tempdir().unwrap();
154        let path = dir.path().join("bhtune.db");
155        // First open with no retention policy, so the old run survives long enough to be
156        // created; a policy of `None` must never delete anything.
157        let pool = open(&path, None, None).await.unwrap();
158        let template = bhtune_core::built_in_templates().remove(0);
159        let tags = LoopTags::derive_from_pv_tag("Unit1.LIC101.PV", &template);
160        let config = LoopConfig {
161            process_type: ProcessType::Flow,
162            controller_type: ControllerType::Pi,
163            relay_amp_percent: 5.0,
164            num_cycles_skip: 1,
165            num_cycles_count: 2,
166            noise_protection_secs: 3,
167            mrft_delay_secs: 0,
168        };
169        let old_started_at = chrono::Utc::now() - chrono::Duration::days(400);
170        let old_run = TuneRunRow::start(
171            &pool,
172            None,
173            "LIC-X",
174            TuneDriver::Simulator,
175            config,
176            TemplateOrigin::Builtin,
177            &template,
178            &tags,
179            old_started_at,
180        )
181        .await
182        .unwrap();
183        pool.close().await;
184
185        // Reopening with a 30-day retention policy must delete the 400-day-old run.
186        let pool = open(&path, None, Some(30)).await.unwrap();
187        assert!(TuneRunRow::get(&pool, old_run.id).await.unwrap().is_none());
188    }
189
190    #[test]
191    fn ensure_parent_dir_is_a_no_op_for_a_bare_filename() {
192        // Doesn't touch the real filesystem at all -- `Path::new("bhtune.db").parent()` is
193        // `Some("")`, and `create_dir_all("")` is a documented no-op success -- so this is
194        // safe to call regardless of the test process's current working directory.
195        ensure_parent_dir(Path::new("bhtune.db")).unwrap();
196    }
197}