quick_search/crates/quicksearch-core/src/cli.rs

284 lines
9.3 KiB
Rust
Raw Normal View History

//! 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<i64> 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<String>,
}
/// 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<String>,
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<FileStatus, String> {
let conn = open_and_migrate(db_path, "trigram")?;
let row: Option<(i64, i64, Option<String>)> = 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<u32>) -> Result<Vec<FailedEntry>, 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::<Result<Vec<_>, _>>()
.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<SizeReport, String> {
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<i64, String> {
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<bool, String> {
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();
}
}