use rusqlite::Connection;
use serde::Serialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Status {
Ok,
Warn,
Fail,
}
#[derive(Debug, Serialize)]
pub struct Check {
pub name: String,
pub status: Status,
pub detail: String,
}
impl Check {
fn new(status: Status, name: &str, detail: String) -> Self {
Check {
name: name.into(),
status,
detail,
}
}
pub fn ok(name: &str, detail: String) -> Self {
Self::new(Status::Ok, name, detail)
}
pub fn warn(name: &str, detail: String) -> Self {
Self::new(Status::Warn, name, detail)
}
pub fn fail(name: &str, detail: String) -> Self {
Self::new(Status::Fail, name, detail)
}
}
pub fn index_checks(conn: &Connection, expected_schema: i64) -> Vec<Check> {
let mut checks = Vec::new();
let v: i64 = conn
.pragma_query_value(None, "user_version", |r| r.get(0))
.unwrap_or(-1);
checks.push(if v == expected_schema {
Check::ok("index schema", format!("v{v}, current"))
} else {
Check::warn(
"index schema",
format!("v{v}, expected v{expected_schema} - the next query rebuilds the cache"),
)
});
checks.push(match crate::index::tokenizer_spec(conn) {
Ok(spec) => Check::ok("search tokenizer", spec),
Err(e) => Check::warn("search tokenizer", format!("unavailable: {e}")),
});
checks.push(match crate::index::tool_output_mode(conn) {
Ok(mode) => Check::ok("tool output storage", mode.to_string()),
Err(e) => Check::warn("tool output storage", format!("unavailable: {e}")),
});
const CORE: &[&str] = &["files", "messages", "edits", "archive", "summaries"];
let unreadable: Vec<&str> = CORE
.iter()
.copied()
.filter(|t| {
conn.query_row(&format!("SELECT count(*) FROM {t}"), [], |r| {
r.get::<_, i64>(0)
})
.is_err()
})
.collect();
checks.push(if unreadable.is_empty() {
Check::ok("index tables", "all core tables readable".into())
} else {
Check::fail(
"index tables",
format!("unreadable: {}", unreadable.join(", ")),
)
});
match conn.query_row("SELECT count(*) FROM files WHERE kind='main'", [], |r| {
r.get::<_, i64>(0)
}) {
Ok(main) => {
let archived: rusqlite::Result<i64> = conn.query_row(
"SELECT count(*) FROM files WHERE kind='main' AND archived_at IS NOT NULL",
[],
|r| r.get(0),
);
checks.push(match archived {
Ok(n) => Check::ok(
"indexed sessions",
format!("{main} ({n} kept after the tool deleted them)"),
),
Err(e) => Check::warn(
"indexed sessions",
format!("{main}; could not count the ones kept after deletion ({e})"),
),
});
}
Err(e) => checks.push(Check::fail(
"indexed sessions",
format!("count failed: {e}"),
)),
}
checks
}
pub fn unlocatable_store(name: &str, root: Option<&std::path::Path>) -> Option<Check> {
root.is_none().then(|| {
Check::warn(
&format!("store: {name}"),
"cannot work out where its sessions would live on this machine \
(no home or data directory) - it was not checked"
.into(),
)
})
}
fn present_store(tool: &str, root: &std::path::Path) -> Option<Check> {
if !root.exists() {
return None;
}
let readable = if root.is_dir() {
std::fs::read_dir(root).map(|_| ())
} else {
std::fs::File::open(root).map(|_| ())
};
Some(match readable {
Ok(()) => Check::ok(
&format!("store: {tool}"),
format!("present at {}", root.display()),
),
Err(e) => Check::warn(
&format!("store: {tool}"),
format!("present but unreadable: {e}"),
),
})
}
pub fn store_checks() -> Vec<Check> {
let mut checks = Vec::new();
let mut any = false;
for adapter in crate::adapters::all() {
let located = adapter.root();
if let Some(c) = unlocatable_store(adapter.name(), located.as_deref()) {
checks.push(c);
any = true;
continue;
}
let root = located.expect("checked just above");
if let Some(c) = present_store(adapter.name(), &root) {
checks.push(c);
any = true;
}
}
if !any {
checks.push(Check::warn(
"stores",
"no session stores found on this machine".into(),
));
}
checks
}
pub fn run(json: bool) -> anyhow::Result<()> {
use rusqlite::OpenFlags;
let mut checks = store_checks();
match crate::index::existing_db_path() {
None => checks.push(Check::warn(
"index",
"not built yet - run `sessionwiki search` to build it".into(),
)),
Some(path) => match Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY) {
Ok(conn) => checks.extend(index_checks(&conn, crate::index::SCHEMA_VERSION)),
Err(e) => checks.push(Check::fail(
"index",
format!("present but cannot open ({}): {e}", path.display()),
)),
},
}
checks.push(Check::ok("version", env!("CARGO_PKG_VERSION").into()));
if json {
println!("{}", serde_json::to_string_pretty(&checks)?);
return Ok(());
}
for c in &checks {
let mark = match c.status {
Status::Ok => "ok ",
Status::Warn => "warn",
Status::Fail => "FAIL",
};
println!("[{mark}] {} - {}", c.name, c.detail);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_store_that_is_one_database_file_is_read_as_a_file() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("thread_history_1.sqlite");
std::fs::write(&db, b"").unwrap();
let check = present_store("codex", &db).expect("it exists");
assert_eq!(check.status, Status::Ok, "{:?}", check.detail);
let check = present_store("codex", dir.path()).expect("it exists");
assert_eq!(check.status, Status::Ok, "a directory store still reads");
assert!(present_store("codex", &dir.path().join("absent")).is_none());
}
fn conn() -> Connection {
let c = Connection::open_in_memory().unwrap();
c.execute_batch(
"CREATE TABLE files(path TEXT PRIMARY KEY, session_id TEXT NOT NULL, archived_at TEXT,
kind TEXT NOT NULL DEFAULT 'main');
CREATE TABLE messages(id INTEGER PRIMARY KEY);
CREATE TABLE edits(session_id TEXT);
CREATE TABLE archive(session_id TEXT);
CREATE TABLE summaries(session_id TEXT);
CREATE TABLE meta(key TEXT PRIMARY KEY, value TEXT NOT NULL);
INSERT INTO meta VALUES('fts_tokenizer', 'trigram');",
)
.unwrap();
c
}
#[test]
fn index_checks_flag_a_stale_schema_and_report_counts() {
let c = conn();
c.pragma_update(None, "user_version", 7i64).unwrap();
c.execute("INSERT INTO files(path, session_id) VALUES('a', 's1')", [])
.unwrap();
c.execute(
"INSERT INTO files(path, session_id, archived_at) VALUES('b', 's2', '2026-01-01')",
[],
)
.unwrap();
let checks = index_checks(&c, 9);
let schema = checks.iter().find(|c| c.name == "index schema").unwrap();
assert_eq!(schema.status, Status::Warn, "v7 vs expected v9 is a warn");
let tokenizer = checks
.iter()
.find(|c| c.name == "search tokenizer")
.unwrap();
assert_eq!(tokenizer.status, Status::Ok);
assert_eq!(tokenizer.detail, "trigram");
let tool_output = checks
.iter()
.find(|c| c.name == "tool output storage")
.unwrap();
assert_eq!(tool_output.status, Status::Ok);
assert_eq!(tool_output.detail, "summary");
let tables = checks.iter().find(|c| c.name == "index tables").unwrap();
assert_eq!(tables.status, Status::Ok, "all core tables readable");
let sessions = checks
.iter()
.find(|c| c.name == "indexed sessions")
.unwrap();
assert!(
sessions.detail.starts_with("2 "),
"2 sessions: {}",
sessions.detail
);
assert!(
sessions.detail.contains("1 "),
"1 archived: {}",
sessions.detail
);
}
#[test]
fn a_missing_core_table_is_a_fail_not_a_healthy_zero() {
let c = conn();
c.execute("DROP TABLE edits", []).unwrap();
let tables = index_checks(&c, 9)
.into_iter()
.find(|c| c.name == "index tables")
.unwrap();
assert_eq!(
tables.status,
Status::Fail,
"an unreadable core table must fail, not look like an empty index"
);
}
#[test]
fn index_checks_pass_a_current_schema() {
let c = conn();
c.pragma_update(None, "user_version", 9i64).unwrap();
let checks = index_checks(&c, 9);
let schema = checks
.into_iter()
.find(|c| c.name == "index schema")
.unwrap();
assert_eq!(schema.status, Status::Ok);
}
}
#[cfg(test)]
mod unlocatable_store_tests {
use super::*;
#[test]
fn an_unlocatable_store_is_reported_not_skipped() {
let c = unlocatable_store("gptme", None).expect("this is worth saying");
assert_eq!(c.status, Status::Warn);
assert!(c.name.contains("gptme"), "names the tool: {}", c.name);
}
#[test]
fn a_located_store_has_nothing_extra_to_say() {
let p = std::path::Path::new("/home/someone/.local/share/gptme/logs");
assert!(unlocatable_store("gptme", Some(p)).is_none());
}
}