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"),
)
});
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 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(),
)
})
}
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 !root.exists() {
continue; }
any = true;
match std::fs::read_dir(&root) {
Ok(_) => checks.push(Check::ok(
&format!("store: {}", adapter.name()),
format!("present at {}", root.display()),
)),
Err(e) => checks.push(Check::warn(
&format!("store: {}", adapter.name()),
format!("present but unreadable: {e}"),
)),
}
}
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::*;
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);",
)
.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, 8);
let schema = checks.iter().find(|c| c.name == "index schema").unwrap();
assert_eq!(schema.status, Status::Warn, "v7 vs expected v8 is a warn");
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, 8)
.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", 8i64).unwrap();
let schema = index_checks(&c, 8)
.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());
}
}