use rusqlite::{Connection, OpenFlags};
use crate::error::TuiError;
use crate::persistence::schema::{BOOTSTRAP_SQL, MIGRATIONS};
pub struct IndexManager {
db_path: Option<String>,
write_conn: Connection,
pub schema_version: u32,
}
impl IndexManager {
pub fn open(path: &str) -> Result<Self, TuiError> {
let flags = OpenFlags::SQLITE_OPEN_READ_WRITE
| OpenFlags::SQLITE_OPEN_CREATE
| OpenFlags::SQLITE_OPEN_NO_MUTEX;
let conn =
Connection::open_with_flags(path, flags).map_err(|e| TuiError::PersistenceOpen {
path: path.to_string(),
detail: e.to_string(),
})?;
conn.pragma_update(None, "journal_mode", "WAL")
.map_err(|e| TuiError::PersistenceOpen {
path: path.to_string(),
detail: format!("WAL pragma failed: {e}"),
})?;
let mut mgr = Self {
db_path: Some(path.to_string()),
write_conn: conn,
schema_version: 0,
};
mgr.schema_version = mgr.migrate()?;
Ok(mgr)
}
pub fn open_in_memory() -> Result<Self, TuiError> {
let conn = Connection::open_in_memory().map_err(|e| TuiError::PersistenceOpen {
path: ":memory:".to_string(),
detail: e.to_string(),
})?;
let mut mgr = Self {
db_path: None,
write_conn: conn,
schema_version: 0,
};
mgr.schema_version = mgr.migrate()?;
Ok(mgr)
}
pub fn migrate(&self) -> Result<u32, TuiError> {
self.write_conn.execute_batch(BOOTSTRAP_SQL).map_err(|e| {
TuiError::PersistenceMigration {
from: 0,
to: 0,
detail: format!("bootstrap failed: {e}"),
}
})?;
let current_version: u32 = self
.write_conn
.query_row(
"SELECT value FROM schema_meta WHERE key = 'schema_version'",
[],
|row| {
let s: String = row.get(0)?;
Ok(s.parse::<u32>().unwrap_or(0))
},
)
.unwrap_or(0);
let mut version = current_version;
for (target_version, sql) in MIGRATIONS {
if *target_version <= version {
continue;
}
let sp_name = format!("migration_{target_version}");
self.write_conn
.execute_batch(&format!("SAVEPOINT {sp_name}"))
.map_err(|e| TuiError::PersistenceMigration {
from: version,
to: *target_version,
detail: format!("savepoint failed: {e}"),
})?;
match self.write_conn.execute_batch(sql) {
Ok(()) => {
self.write_conn
.execute(
"INSERT OR REPLACE INTO schema_meta (key, value) \
VALUES ('schema_version', ?1)",
rusqlite::params![target_version.to_string()],
)
.map_err(|e| TuiError::PersistenceMigration {
from: version,
to: *target_version,
detail: format!("version update failed: {e}"),
})?;
self.write_conn
.execute_batch(&format!("RELEASE {sp_name}"))
.map_err(|e| TuiError::PersistenceMigration {
from: version,
to: *target_version,
detail: format!("release savepoint failed: {e}"),
})?;
version = *target_version;
}
Err(e) => {
let _ = self
.write_conn
.execute_batch(&format!("ROLLBACK TO {sp_name}"));
return Err(TuiError::PersistenceMigration {
from: version,
to: *target_version,
detail: e.to_string(),
});
}
}
}
Ok(version)
}
pub fn read_connection(&self) -> Result<Connection, TuiError> {
match &self.db_path {
Some(path) => {
let flags = OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX;
Connection::open_with_flags(path, flags).map_err(|e| TuiError::PersistenceQuery {
detail: format!("read connection failed: {e}"),
})
}
None => {
Err(TuiError::PersistenceQuery {
detail: "in-memory database has no separate read connection".to_string(),
})
}
}
}
pub fn write_conn(&self) -> &Connection {
&self.write_conn
}
pub fn is_scan_stale(&self, workspace_path: &str, fs_mtime: i64) -> bool {
let result: rusqlite::Result<i64> = self.write_conn.query_row(
"SELECT fs_mtime_at_scan FROM workspace_scans WHERE workspace_path = ?1",
rusqlite::params![workspace_path],
|row| row.get(0),
);
match result {
Ok(cached_mtime) => fs_mtime > cached_mtime,
Err(_) => true, }
}
pub fn load_cached_scan_paths(&self) -> Vec<String> {
let mut stmt = match self
.write_conn
.prepare("SELECT workspace_path FROM workspace_scans ORDER BY workspace_path")
{
Ok(s) => s,
Err(_) => return vec![],
};
stmt.query_map([], |row| row.get::<_, String>(0))
.map(|rows| rows.filter_map(|r| r.ok()).collect())
.unwrap_or_default()
}
pub fn load_drift_history(&self) -> Vec<(String, String, String, Option<String>)> {
let mut stmt = match self.write_conn.prepare(
"SELECT workspace_path, asset_id, drift_type, resolved_at \
FROM drift_history ORDER BY id",
) {
Ok(s) => s,
Err(_) => return vec![],
};
stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, Option<String>>(3)?,
))
})
.map(|rows| rows.filter_map(|r| r.ok()).collect())
.unwrap_or_default()
}
pub fn load_coverage_scores(&self) -> Vec<(String, f64)> {
let mut stmt = match self.write_conn.prepare(
"SELECT workspace_path, coverage_score FROM coverage_cache ORDER BY workspace_path",
) {
Ok(s) => s,
Err(_) => return vec![],
};
stmt.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, f64>(1)?))
})
.map(|rows| rows.filter_map(|r| r.ok()).collect())
.unwrap_or_default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_in_memory_migrates_to_latest() {
let mgr = IndexManager::open_in_memory().expect("open_in_memory");
assert_eq!(mgr.schema_version, 4, "should migrate to version 4");
}
#[test]
fn open_in_memory_tables_exist() {
let mgr = IndexManager::open_in_memory().expect("open_in_memory");
let conn = mgr.write_conn();
conn.execute(
"INSERT INTO workspace_scans \
(workspace_path, workspace_name, last_scan_ts, scan_duration_ms, asset_count, fs_mtime_at_scan) \
VALUES ('/tmp/ws', 'ws', '2025-01-01T00:00:00Z', 100, 5, 9999)",
[],
)
.expect("insert workspace_scans");
conn.execute(
"INSERT INTO audit_log \
(timestamp, event_type, subject, details, operator, entry_hash, prev_hash) \
VALUES ('2025-01-01T00:00:00.000Z', 'policy_evaluation', 'ws', '{}', \
'system', 'h1', '')",
[],
)
.expect("insert audit_log");
conn.execute(
"INSERT INTO gate_history \
(gate_name, status, exit_code, duration_ms, timestamp, catalog_hash) \
VALUES ('lint', 'pass', 0, 800, '2025-01-01T00:00:00Z', 'ch')",
[],
)
.expect("insert gate_history");
}
#[test]
fn is_scan_stale_no_entry_returns_true() {
let mgr = IndexManager::open_in_memory().expect("open_in_memory");
assert!(mgr.is_scan_stale("/nonexistent/ws", 0));
}
#[test]
fn is_scan_stale_old_mtime_returns_false() {
let mgr = IndexManager::open_in_memory().expect("open_in_memory");
mgr.write_conn()
.execute(
"INSERT INTO workspace_scans \
(workspace_path, workspace_name, last_scan_ts, scan_duration_ms, asset_count, fs_mtime_at_scan) \
VALUES ('/ws', 'ws', '2025-01-01T00:00:00Z', 100, 0, 1000)",
[],
)
.unwrap();
assert!(!mgr.is_scan_stale("/ws", 1000));
assert!(!mgr.is_scan_stale("/ws", 999));
}
#[test]
fn is_scan_stale_newer_mtime_returns_true() {
let mgr = IndexManager::open_in_memory().expect("open_in_memory");
mgr.write_conn()
.execute(
"INSERT INTO workspace_scans \
(workspace_path, workspace_name, last_scan_ts, scan_duration_ms, asset_count, fs_mtime_at_scan) \
VALUES ('/ws', 'ws', '2025-01-01T00:00:00Z', 100, 0, 1000)",
[],
)
.unwrap();
assert!(mgr.is_scan_stale("/ws", 1001));
}
#[test]
fn migrate_is_idempotent() {
let mgr = IndexManager::open_in_memory().expect("open_in_memory");
let v2 = mgr.migrate().expect("second migrate");
assert_eq!(v2, 4);
}
#[test]
fn open_file_based_migrates_and_is_queryable() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("test.db");
let path_str = path.to_str().expect("path utf8");
let mgr = IndexManager::open(path_str).expect("open file db");
assert_eq!(mgr.schema_version, 4);
let rconn = mgr.read_connection().expect("read connection");
let v: String = rconn
.query_row(
"SELECT value FROM schema_meta WHERE key = 'schema_version'",
[],
|row| row.get(0),
)
.expect("query schema_version");
assert_eq!(v, "4");
}
#[test]
fn load_cached_scan_paths_empty_db() {
let mgr = IndexManager::open_in_memory().expect("open_in_memory");
let paths = mgr.load_cached_scan_paths();
assert!(paths.is_empty());
}
#[test]
fn load_cached_scan_paths_with_entries() {
let mgr = IndexManager::open_in_memory().expect("open_in_memory");
for ws in ["/ws/a", "/ws/b"] {
mgr.write_conn()
.execute(
"INSERT INTO workspace_scans \
(workspace_path, workspace_name, last_scan_ts, scan_duration_ms, asset_count, fs_mtime_at_scan) \
VALUES (?1, 'ws', '2025-01-01T00:00:00Z', 100, 0, 1000)",
rusqlite::params![ws],
)
.unwrap();
}
let paths = mgr.load_cached_scan_paths();
assert_eq!(paths.len(), 2);
assert!(paths.contains(&"/ws/a".to_string()));
assert!(paths.contains(&"/ws/b".to_string()));
}
}