Skip to main content

vfa_tui/persistence/
index.rs

1//! SQLite index manager — WAL mode, single-writer pattern, schema migrations.
2//!
3//! Implements Requirement 19 (all 9 criteria):
4//! - WAL mode + `SQLITE_OPEN_NO_MUTEX` (Req 19.7, 19.9)
5//! - Single-writer task via mpsc channel (Req 19.8)
6//! - Sequential schema migrations preserving data (Req 19.4)
7//! - In-memory fallback when file is inaccessible (Req 19.5)
8//! - Stale-scan detection via `fs_mtime_at_scan` (Req 19.3)
9
10use rusqlite::{Connection, OpenFlags};
11
12use crate::error::TuiError;
13use crate::persistence::schema::{BOOTSTRAP_SQL, MIGRATIONS};
14
15// ---------------------------------------------------------------------------
16// IndexManager
17// ---------------------------------------------------------------------------
18
19/// Manages the SQLite persistence layer.
20///
21/// Owns one write `Connection` (used by the writer task) and exposes
22/// `read_connection()` to open additional per-thread read-only connections.
23pub struct IndexManager {
24    /// Path passed at open time — `None` for in-memory databases.
25    db_path: Option<String>,
26    /// Write connection (owned here; writer task borrows via the mpsc channel).
27    write_conn: Connection,
28    /// Current schema version after migration.
29    pub schema_version: u32,
30}
31
32impl IndexManager {
33    // -----------------------------------------------------------------------
34    // Constructors
35    // -----------------------------------------------------------------------
36
37    /// Open (or create) the SQLite index at `path` with WAL mode.
38    ///
39    /// Uses `SQLITE_OPEN_NO_MUTEX` — thread safety is handled by Rust's
40    /// ownership model (Req 19.9).  Runs schema migrations on every open
41    /// (Req 19.4).
42    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        // Enable WAL mode (Req 19.7).
52        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    /// Open an in-memory database — useful for tests and as a fallback when
67    /// the on-disk file is corrupt or inaccessible (Req 19.5).
68    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    // -----------------------------------------------------------------------
83    // Migration
84    // -----------------------------------------------------------------------
85
86    /// Run pending schema migrations sequentially, preserving existing data.
87    ///
88    /// Detects the current version from `schema_meta` (0 if the table doesn't
89    /// yet exist), applies each migration whose target version is higher, and
90    /// updates `schema_version` afterward.  Each migration runs inside its own
91    /// `SAVEPOINT` so a failure leaves the database at a clean prior version
92    /// rather than in an unknown intermediate state (Req 19.4).
93    pub fn migrate(&self) -> Result<u32, TuiError> {
94        // Bootstrap: ensure schema_meta exists before querying it.
95        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        // Read current version (0 = fresh database).
104        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            // Apply this migration inside a savepoint for clean rollback.
123            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                    // Update the stored version.
135                    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    // -----------------------------------------------------------------------
172    // Read connections (Req 19.8 — separate Connection per thread)
173    // -----------------------------------------------------------------------
174
175    /// Open a fresh **read-only** `Connection` for use in a caller thread.
176    ///
177    /// Each call returns an independent `Connection` instance, satisfying
178    /// the requirement that every thread or task gets its own connection
179    /// (Req 19.8, 19.9).
180    ///
181    /// In-memory managers share the write connection for reads (the in-memory
182    /// database is not accessible from a second connection object).
183    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                // For in-memory databases there is only one logical connection;
193                // return an error so callers fall back to the write connection.
194                Err(TuiError::PersistenceQuery {
195                    detail: "in-memory database has no separate read connection".to_string(),
196                })
197            }
198        }
199    }
200
201    /// Borrow the write connection for direct use (e.g. from tests or the
202    /// writer task before the channel is set up).
203    pub fn write_conn(&self) -> &Connection {
204        &self.write_conn
205    }
206
207    // -----------------------------------------------------------------------
208    // Stale-scan detection (Req 19.3)
209    // -----------------------------------------------------------------------
210
211    /// Returns `true` if the cached scan for `workspace_path` is older than
212    /// `fs_mtime` (Unix seconds), meaning a re-scan is needed.
213    ///
214    /// Returns `true` when there is no cached entry (first scan).
215    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, // No cached entry → stale by definition.
224        }
225    }
226
227    // -----------------------------------------------------------------------
228    // Cached scan loading (Req 19.2)
229    // -----------------------------------------------------------------------
230
231    /// Return all workspace paths that have cached scan entries.
232    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    // -----------------------------------------------------------------------
246    // Drift history (Req 10.x)
247    // -----------------------------------------------------------------------
248
249    /// Load all drift-history rows as
250    /// `(workspace_path, asset_id, drift_type, resolved_at)` tuples, ordered by
251    /// id. `resolved_at` is `None` for still-active drift.
252    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    // -----------------------------------------------------------------------
273    // Coverage cache (Req 3.6)
274    // -----------------------------------------------------------------------
275
276    /// Load cached per-workspace coverage scores as `(workspace_path, score)`
277    /// tuples, ordered by workspace path.
278    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// ---------------------------------------------------------------------------
294// Tests
295// ---------------------------------------------------------------------------
296
297#[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        // workspace_scans
313        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        // audit_log
322        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        // gate_history
332        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        // fs_mtime == 1000, same as cached → not stale
359        assert!(!mgr.is_scan_stale("/ws", 1000));
360        // fs_mtime == 999, older than cached → not stale
361        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        // fs_mtime == 1001 > 1000 → stale
376        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        // Calling migrate() again should return the same version without error.
383        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        // Read connection should be openable.
397        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}