1use std::path::Path;
5
6use bhtune_core::DcsTemplate;
7use bhtune_db::SqlitePool;
8use bhtune_db::models::TemplateOrigin;
9
10pub 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
57fn 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 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 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 ensure_parent_dir(Path::new("bhtune.db")).unwrap();
196 }
197}