1use rusqlite::{Connection, OpenFlags};
11
12use crate::error::TuiError;
13use crate::persistence::schema::{BOOTSTRAP_SQL, MIGRATIONS};
14
15pub struct IndexManager {
24 db_path: Option<String>,
26 write_conn: Connection,
28 pub schema_version: u32,
30}
31
32impl IndexManager {
33 pub fn open(path: &str) -> Result<Self, TuiError> {
43 let flags = OpenFlags::SQLITE_OPEN_READ_WRITE
44 | OpenFlags::SQLITE_OPEN_CREATE
45 | OpenFlags::SQLITE_OPEN_NO_MUTEX;
46 let conn =
47 Connection::open_with_flags(path, flags).map_err(|e| TuiError::PersistenceOpen {
48 path: path.to_string(),
49 detail: e.to_string(),
50 })?;
51 conn.pragma_update(None, "journal_mode", "WAL")
53 .map_err(|e| TuiError::PersistenceOpen {
54 path: path.to_string(),
55 detail: format!("WAL pragma failed: {e}"),
56 })?;
57 let mut mgr = Self {
58 db_path: Some(path.to_string()),
59 write_conn: conn,
60 schema_version: 0,
61 };
62 mgr.schema_version = mgr.migrate()?;
63 Ok(mgr)
64 }
65
66 pub fn open_in_memory() -> Result<Self, TuiError> {
69 let conn = Connection::open_in_memory().map_err(|e| TuiError::PersistenceOpen {
70 path: ":memory:".to_string(),
71 detail: e.to_string(),
72 })?;
73 let mut mgr = Self {
74 db_path: None,
75 write_conn: conn,
76 schema_version: 0,
77 };
78 mgr.schema_version = mgr.migrate()?;
79 Ok(mgr)
80 }
81
82 pub fn migrate(&self) -> Result<u32, TuiError> {
94 self.write_conn.execute_batch(BOOTSTRAP_SQL).map_err(|e| {
96 TuiError::PersistenceMigration {
97 from: 0,
98 to: 0,
99 detail: format!("bootstrap failed: {e}"),
100 }
101 })?;
102
103 let current_version: u32 = self
105 .write_conn
106 .query_row(
107 "SELECT value FROM schema_meta WHERE key = 'schema_version'",
108 [],
109 |row| {
110 let s: String = row.get(0)?;
111 Ok(s.parse::<u32>().unwrap_or(0))
112 },
113 )
114 .unwrap_or(0);
115
116 let mut version = current_version;
117
118 for (target_version, sql) in MIGRATIONS {
119 if *target_version <= version {
120 continue;
121 }
122 let sp_name = format!("migration_{target_version}");
124 self.write_conn
125 .execute_batch(&format!("SAVEPOINT {sp_name}"))
126 .map_err(|e| TuiError::PersistenceMigration {
127 from: version,
128 to: *target_version,
129 detail: format!("savepoint failed: {e}"),
130 })?;
131
132 match self.write_conn.execute_batch(sql) {
133 Ok(()) => {
134 self.write_conn
136 .execute(
137 "INSERT OR REPLACE INTO schema_meta (key, value) \
138 VALUES ('schema_version', ?1)",
139 rusqlite::params![target_version.to_string()],
140 )
141 .map_err(|e| TuiError::PersistenceMigration {
142 from: version,
143 to: *target_version,
144 detail: format!("version update failed: {e}"),
145 })?;
146 self.write_conn
147 .execute_batch(&format!("RELEASE {sp_name}"))
148 .map_err(|e| TuiError::PersistenceMigration {
149 from: version,
150 to: *target_version,
151 detail: format!("release savepoint failed: {e}"),
152 })?;
153 version = *target_version;
154 }
155 Err(e) => {
156 let _ = self
157 .write_conn
158 .execute_batch(&format!("ROLLBACK TO {sp_name}"));
159 return Err(TuiError::PersistenceMigration {
160 from: version,
161 to: *target_version,
162 detail: e.to_string(),
163 });
164 }
165 }
166 }
167
168 Ok(version)
169 }
170
171 pub fn read_connection(&self) -> Result<Connection, TuiError> {
184 match &self.db_path {
185 Some(path) => {
186 let flags = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX;
187 Connection::open_with_flags(path, flags).map_err(|e| TuiError::PersistenceQuery {
188 detail: format!("read connection failed: {e}"),
189 })
190 }
191 None => {
192 Err(TuiError::PersistenceQuery {
195 detail: "in-memory database has no separate read connection".to_string(),
196 })
197 }
198 }
199 }
200
201 pub fn write_conn(&self) -> &Connection {
204 &self.write_conn
205 }
206
207 pub fn is_scan_stale(&self, workspace_path: &str, fs_mtime: i64) -> bool {
216 let result: rusqlite::Result<i64> = self.write_conn.query_row(
217 "SELECT fs_mtime_at_scan FROM workspace_scans WHERE workspace_path = ?1",
218 rusqlite::params![workspace_path],
219 |row| row.get(0),
220 );
221 match result {
222 Ok(cached_mtime) => fs_mtime > cached_mtime,
223 Err(_) => true, }
225 }
226
227 pub fn load_cached_scan_paths(&self) -> Vec<String> {
233 let mut stmt = match self
234 .write_conn
235 .prepare("SELECT workspace_path FROM workspace_scans ORDER BY workspace_path")
236 {
237 Ok(s) => s,
238 Err(_) => return vec![],
239 };
240 stmt.query_map([], |row| row.get::<_, String>(0))
241 .map(|rows| rows.filter_map(|r| r.ok()).collect())
242 .unwrap_or_default()
243 }
244
245 pub fn load_drift_history(&self) -> Vec<(String, String, String, Option<String>)> {
253 let mut stmt = match self.write_conn.prepare(
254 "SELECT workspace_path, asset_id, drift_type, resolved_at \
255 FROM drift_history ORDER BY id",
256 ) {
257 Ok(s) => s,
258 Err(_) => return vec![],
259 };
260 stmt.query_map([], |row| {
261 Ok((
262 row.get::<_, String>(0)?,
263 row.get::<_, String>(1)?,
264 row.get::<_, String>(2)?,
265 row.get::<_, Option<String>>(3)?,
266 ))
267 })
268 .map(|rows| rows.filter_map(|r| r.ok()).collect())
269 .unwrap_or_default()
270 }
271
272 pub fn load_coverage_scores(&self) -> Vec<(String, f64)> {
279 let mut stmt = match self.write_conn.prepare(
280 "SELECT workspace_path, coverage_score FROM coverage_cache ORDER BY workspace_path",
281 ) {
282 Ok(s) => s,
283 Err(_) => return vec![],
284 };
285 stmt.query_map([], |row| {
286 Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?))
287 })
288 .map(|rows| rows.filter_map(|r| r.ok()).collect())
289 .unwrap_or_default()
290 }
291}
292
293#[cfg(test)]
298mod tests {
299 use super::*;
300
301 #[test]
302 fn open_in_memory_migrates_to_latest() {
303 let mgr = IndexManager::open_in_memory().expect("open_in_memory");
304 assert_eq!(mgr.schema_version, 4, "should migrate to version 4");
305 }
306
307 #[test]
308 fn open_in_memory_tables_exist() {
309 let mgr = IndexManager::open_in_memory().expect("open_in_memory");
310 let conn = mgr.write_conn();
311
312 conn.execute(
314 "INSERT INTO workspace_scans \
315 (workspace_path, workspace_name, last_scan_ts, scan_duration_ms, asset_count, fs_mtime_at_scan) \
316 VALUES ('/tmp/ws', 'ws', '2025-01-01T00:00:00Z', 100, 5, 9999)",
317 [],
318 )
319 .expect("insert workspace_scans");
320
321 conn.execute(
323 "INSERT INTO audit_log \
324 (timestamp, event_type, subject, details, operator, entry_hash, prev_hash) \
325 VALUES ('2025-01-01T00:00:00.000Z', 'policy_evaluation', 'ws', '{}', \
326 'system', 'h1', '')",
327 [],
328 )
329 .expect("insert audit_log");
330
331 conn.execute(
333 "INSERT INTO gate_history \
334 (gate_name, status, exit_code, duration_ms, timestamp, catalog_hash) \
335 VALUES ('lint', 'pass', 0, 800, '2025-01-01T00:00:00Z', 'ch')",
336 [],
337 )
338 .expect("insert gate_history");
339 }
340
341 #[test]
342 fn is_scan_stale_no_entry_returns_true() {
343 let mgr = IndexManager::open_in_memory().expect("open_in_memory");
344 assert!(mgr.is_scan_stale("/nonexistent/ws", 0));
345 }
346
347 #[test]
348 fn is_scan_stale_old_mtime_returns_false() {
349 let mgr = IndexManager::open_in_memory().expect("open_in_memory");
350 mgr.write_conn()
351 .execute(
352 "INSERT INTO workspace_scans \
353 (workspace_path, workspace_name, last_scan_ts, scan_duration_ms, asset_count, fs_mtime_at_scan) \
354 VALUES ('/ws', 'ws', '2025-01-01T00:00:00Z', 100, 0, 1000)",
355 [],
356 )
357 .unwrap();
358 assert!(!mgr.is_scan_stale("/ws", 1000));
360 assert!(!mgr.is_scan_stale("/ws", 999));
362 }
363
364 #[test]
365 fn is_scan_stale_newer_mtime_returns_true() {
366 let mgr = IndexManager::open_in_memory().expect("open_in_memory");
367 mgr.write_conn()
368 .execute(
369 "INSERT INTO workspace_scans \
370 (workspace_path, workspace_name, last_scan_ts, scan_duration_ms, asset_count, fs_mtime_at_scan) \
371 VALUES ('/ws', 'ws', '2025-01-01T00:00:00Z', 100, 0, 1000)",
372 [],
373 )
374 .unwrap();
375 assert!(mgr.is_scan_stale("/ws", 1001));
377 }
378
379 #[test]
380 fn migrate_is_idempotent() {
381 let mgr = IndexManager::open_in_memory().expect("open_in_memory");
382 let v2 = mgr.migrate().expect("second migrate");
384 assert_eq!(v2, 4);
385 }
386
387 #[test]
388 fn open_file_based_migrates_and_is_queryable() {
389 let dir = tempfile::tempdir().expect("tempdir");
390 let path = dir.path().join("test.db");
391 let path_str = path.to_str().expect("path utf8");
392
393 let mgr = IndexManager::open(path_str).expect("open file db");
394 assert_eq!(mgr.schema_version, 4);
395
396 let rconn = mgr.read_connection().expect("read connection");
398 let v: String = rconn
399 .query_row(
400 "SELECT value FROM schema_meta WHERE key = 'schema_version'",
401 [],
402 |row| row.get(0),
403 )
404 .expect("query schema_version");
405 assert_eq!(v, "4");
406 }
407
408 #[test]
409 fn load_cached_scan_paths_empty_db() {
410 let mgr = IndexManager::open_in_memory().expect("open_in_memory");
411 let paths = mgr.load_cached_scan_paths();
412 assert!(paths.is_empty());
413 }
414
415 #[test]
416 fn load_cached_scan_paths_with_entries() {
417 let mgr = IndexManager::open_in_memory().expect("open_in_memory");
418 for ws in ["/ws/a", "/ws/b"] {
419 mgr.write_conn()
420 .execute(
421 "INSERT INTO workspace_scans \
422 (workspace_path, workspace_name, last_scan_ts, scan_duration_ms, asset_count, fs_mtime_at_scan) \
423 VALUES (?1, 'ws', '2025-01-01T00:00:00Z', 100, 0, 1000)",
424 rusqlite::params![ws],
425 )
426 .unwrap();
427 }
428 let paths = mgr.load_cached_scan_paths();
429 assert_eq!(paths.len(), 2);
430 assert!(paths.contains(&"/ws/a".to_string()));
431 assert!(paths.contains(&"/ws/b".to_string()));
432 }
433}