//! Programmatic read-only query helpers. //! //! Pure functions that open a DB, run a query, and return structured data. //! No stdout, no CLI framing — callers (GUI, future CLI binaries, Set B //! `balooctl`) format the result as they see fit. Mutating operations live //! on [`crate::indexing::IndexingService`] since they require a running //! worker thread. use rusqlite::{params, OptionalExtension}; use crate::db::open_and_migrate; use crate::db::repo::{STATE_DONE, STATE_FAILED, STATE_NA, STATE_PENDING}; /// Per-file indexing status, mirroring Baloo's multi-state reporting. #[derive(Debug, Clone, PartialEq, Eq)] pub enum IndexState { NotIndexed, Pending, Done, Failed, NotApplicable, } impl From for IndexState { fn from(v: i64) -> Self { match v { x if x == STATE_PENDING => IndexState::Pending, x if x == STATE_DONE => IndexState::Done, x if x == STATE_FAILED => IndexState::Failed, x if x == STATE_NA => IndexState::NotApplicable, _ => IndexState::Pending, } } } /// Indexing status for a single file. `basic` is the metadata row state /// (indexed or not); `content` is the extractor state. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FileStatus { pub path: String, pub basic: IndexState, pub content: IndexState, pub failure_reason: Option, } /// Per-file entry returned by [`list_failed`]. #[derive(Debug, Clone, PartialEq, Eq)] pub struct FailedEntry { pub file_id: i64, pub path: String, pub reason: Option, pub ts: i64, } /// Storage footprint report. "Partitions" correspond to SQL tables for our /// SQLite layout (Baloo's LMDB has named sub-DBs; our equivalent is per-table /// row/size counts). #[derive(Debug, Clone, PartialEq, Eq)] pub struct SizeReport { pub file_size_bytes: u64, pub files_row_count: i64, pub properties_row_count: i64, pub failed_files_row_count: i64, pub searchabletext_row_count: i64, } /// Query the per-file indexing status. Returns `FileStatus` with /// `basic == NotIndexed` if the path isn't in the database. pub fn status_for_path(db_path: &str, path: &str) -> Result { let conn = open_and_migrate(db_path, "trigram")?; let row: Option<(i64, i64, Option)> = conn .query_row( "SELECT basic_state, content_state, failure_msg FROM files WHERE path = ?1", params![path], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)), ) .optional() .map_err(|e| format!("status_for_path({}): {}", path, e))?; Ok(match row { None => FileStatus { path: path.to_string(), basic: IndexState::NotIndexed, content: IndexState::NotIndexed, failure_reason: None, }, Some((basic, content, reason)) => FileStatus { path: path.to_string(), basic: IndexState::from(basic), content: IndexState::from(content), failure_reason: reason, }, }) } /// Return every file that failed content extraction, newest first. pub fn list_failed(db_path: &str, limit: Option) -> Result, String> { let conn = open_and_migrate(db_path, "trigram")?; let limit_sql = match limit { Some(n) => format!(" LIMIT {}", n), None => String::new(), }; let sql = format!( "SELECT ff.file_id, f.path, ff.reason, ff.ts \ FROM failed_files ff \ JOIN files f ON f.id = ff.file_id \ ORDER BY ff.ts DESC{}", limit_sql ); let mut stmt = conn .prepare(&sql) .map_err(|e| format!("list_failed prepare: {}", e))?; let rows = stmt .query_map([], |r| { Ok(FailedEntry { file_id: r.get(0)?, path: r.get(1)?, reason: r.get(2)?, ts: r.get(3)?, }) }) .map_err(|e| format!("list_failed query: {}", e))?; rows.collect::, _>>() .map_err(|e| format!("list_failed row: {}", e)) } /// Return a rough size breakdown of the database on disk and by table. pub fn index_size_breakdown(db_path: &str) -> Result { let file_size_bytes = std::fs::metadata(db_path) .map(|m| m.len()) .unwrap_or(0); let conn = open_and_migrate(db_path, "trigram")?; let count = |table: &str| -> Result { conn.query_row(&format!("SELECT COUNT(*) FROM {}", table), [], |r| r.get(0)) .map_err(|e| format!("count {}: {}", table, e)) }; Ok(SizeReport { file_size_bytes, files_row_count: count("files")?, properties_row_count: count("properties")?, failed_files_row_count: count("failed_files")?, searchabletext_row_count: count("searchabletext")?, }) } /// Remove a single file from the index. Returns whether a row was deleted. /// Keeps FTS/documents/properties in sync via the repo helpers. pub fn clear_path(db_path: &str, path: &str) -> Result { let mut conn = open_and_migrate(db_path, "trigram")?; let tx = conn .transaction() .map_err(|e| format!("clear_path begin tx: {}", e))?; let removed = crate::db::repo::delete_file_by_path(&tx, path)?; tx.commit() .map_err(|e| format!("clear_path commit: {}", e))?; Ok(removed) } #[cfg(test)] mod tests { use super::*; use crate::db::repo::{insert_file, set_content_done, set_content_failed, NewFile}; use crate::mime::FileType; fn tmp_path() -> std::path::PathBuf { let mut p = std::env::temp_dir(); p.push(format!( "qs-cli-test-{}-{}.sqlite", std::process::id(), std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap() .as_nanos() )); p } fn seed_fixture(db_path: &str) -> (i64, i64) { let mut conn = open_and_migrate(db_path, "trigram").unwrap(); let (a, b) = { let tx = conn.transaction().unwrap(); let a = insert_file( &tx, &NewFile { name: "a.txt", path: "/tmp/a.txt", parent: "/tmp", size: 1, mtime: 1, inode: None, device_id: None, mime: Some("text/plain"), ftype: FileType::TEXT, hash: None, }, ) .unwrap() .expect("unique path"); set_content_done(&tx, a, "a.txt", "hello", &[]).unwrap(); let b = insert_file( &tx, &NewFile { name: "b.bin", path: "/tmp/b.bin", parent: "/tmp", size: 1, mtime: 1, inode: None, device_id: None, mime: None, ftype: FileType::EMPTY, hash: None, }, ) .unwrap() .expect("unique path"); set_content_failed(&tx, b, "bad extract").unwrap(); tx.commit().unwrap(); (a, b) }; drop(conn); (a, b) } #[test] fn status_for_path_returns_states() { let p = tmp_path(); let (_a, _b) = seed_fixture(p.to_str().unwrap()); let st_a = status_for_path(p.to_str().unwrap(), "/tmp/a.txt").unwrap(); assert_eq!(st_a.basic, IndexState::Done); assert_eq!(st_a.content, IndexState::Done); let st_b = status_for_path(p.to_str().unwrap(), "/tmp/b.bin").unwrap(); assert_eq!(st_b.basic, IndexState::Done); assert_eq!(st_b.content, IndexState::Failed); assert_eq!(st_b.failure_reason.as_deref(), Some("bad extract")); let st_missing = status_for_path(p.to_str().unwrap(), "/tmp/never.txt").unwrap(); assert_eq!(st_missing.basic, IndexState::NotIndexed); std::fs::remove_file(&p).ok(); } #[test] fn list_failed_returns_failed_rows() { let p = tmp_path(); let (_a, b) = seed_fixture(p.to_str().unwrap()); let failed = list_failed(p.to_str().unwrap(), None).unwrap(); assert_eq!(failed.len(), 1); assert_eq!(failed[0].file_id, b); assert_eq!(failed[0].path, "/tmp/b.bin"); assert_eq!(failed[0].reason.as_deref(), Some("bad extract")); std::fs::remove_file(&p).ok(); } #[test] fn index_size_breakdown_counts_rows() { let p = tmp_path(); let _ = seed_fixture(p.to_str().unwrap()); let r = index_size_breakdown(p.to_str().unwrap()).unwrap(); assert!(r.file_size_bytes > 0); assert_eq!(r.files_row_count, 2); assert_eq!(r.failed_files_row_count, 1); std::fs::remove_file(&p).ok(); } #[test] fn clear_path_removes_file_and_returns_true() { let p = tmp_path(); let _ = seed_fixture(p.to_str().unwrap()); assert!(clear_path(p.to_str().unwrap(), "/tmp/a.txt").unwrap()); let st = status_for_path(p.to_str().unwrap(), "/tmp/a.txt").unwrap(); assert_eq!(st.basic, IndexState::NotIndexed); assert!(!clear_path(p.to_str().unwrap(), "/tmp/a.txt").unwrap()); std::fs::remove_file(&p).ok(); } }