1use rusqlite::Connection;
8use serde::Serialize;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
11#[serde(rename_all = "lowercase")]
12pub enum Status {
13 Ok,
14 Warn,
15 Fail,
16}
17
18#[derive(Debug, Serialize)]
19pub struct Check {
20 pub name: String,
21 pub status: Status,
22 pub detail: String,
23}
24
25impl Check {
26 fn new(status: Status, name: &str, detail: String) -> Self {
27 Check {
28 name: name.into(),
29 status,
30 detail,
31 }
32 }
33 pub fn ok(name: &str, detail: String) -> Self {
34 Self::new(Status::Ok, name, detail)
35 }
36 pub fn warn(name: &str, detail: String) -> Self {
37 Self::new(Status::Warn, name, detail)
38 }
39 pub fn fail(name: &str, detail: String) -> Self {
40 Self::new(Status::Fail, name, detail)
41 }
42}
43
44pub fn index_checks(conn: &Connection, expected_schema: i64) -> Vec<Check> {
48 let mut checks = Vec::new();
49
50 let v: i64 = conn
51 .pragma_query_value(None, "user_version", |r| r.get(0))
52 .unwrap_or(-1);
53 checks.push(if v == expected_schema {
54 Check::ok("index schema", format!("v{v}, current"))
55 } else {
56 Check::warn(
57 "index schema",
58 format!("v{v}, expected v{expected_schema} - the next query rebuilds the cache"),
59 )
60 });
61
62 const CORE: &[&str] = &["files", "messages", "edits", "archive", "summaries"];
67 let unreadable: Vec<&str> = CORE
68 .iter()
69 .copied()
70 .filter(|t| {
71 conn.query_row(&format!("SELECT count(*) FROM {t}"), [], |r| {
72 r.get::<_, i64>(0)
73 })
74 .is_err()
75 })
76 .collect();
77 checks.push(if unreadable.is_empty() {
78 Check::ok("index tables", "all core tables readable".into())
79 } else {
80 Check::fail(
81 "index tables",
82 format!("unreadable: {}", unreadable.join(", ")),
83 )
84 });
85
86 match conn.query_row("SELECT count(*) FROM files WHERE kind='main'", [], |r| {
89 r.get::<_, i64>(0)
90 }) {
91 Ok(main) => {
92 let archived: rusqlite::Result<i64> = conn.query_row(
97 "SELECT count(*) FROM files WHERE archived_at IS NOT NULL",
98 [],
99 |r| r.get(0),
100 );
101 checks.push(match archived {
102 Ok(n) => Check::ok(
103 "indexed sessions",
104 format!("{main} ({n} kept after the tool deleted them)"),
105 ),
106 Err(e) => Check::warn(
107 "indexed sessions",
108 format!("{main}; could not count the ones kept after deletion ({e})"),
109 ),
110 });
111 }
112 Err(e) => checks.push(Check::fail(
113 "indexed sessions",
114 format!("count failed: {e}"),
115 )),
116 }
117
118 checks
119}
120
121pub fn unlocatable_store(name: &str, root: Option<&std::path::Path>) -> Option<Check> {
137 root.is_none().then(|| {
138 Check::warn(
139 &format!("store: {name}"),
140 "cannot work out where its sessions would live on this machine \
141 (no home or data directory) - it was not checked"
142 .into(),
143 )
144 })
145}
146
147pub fn store_checks() -> Vec<Check> {
148 let mut checks = Vec::new();
149 let mut any = false;
150 for adapter in crate::adapters::all() {
151 let located = adapter.root();
152 if let Some(c) = unlocatable_store(adapter.name(), located.as_deref()) {
153 checks.push(c);
154 any = true;
155 continue;
156 }
157 let root = located.expect("checked just above");
158 if !root.exists() {
159 continue; }
161 any = true;
162 match std::fs::read_dir(&root) {
166 Ok(_) => checks.push(Check::ok(
167 &format!("store: {}", adapter.name()),
168 format!("present at {}", root.display()),
169 )),
170 Err(e) => checks.push(Check::warn(
171 &format!("store: {}", adapter.name()),
172 format!("present but unreadable: {e}"),
173 )),
174 }
175 }
176 if !any {
177 checks.push(Check::warn(
178 "stores",
179 "no session stores found on this machine".into(),
180 ));
181 }
182 checks
183}
184
185pub fn run(json: bool) -> anyhow::Result<()> {
188 use rusqlite::OpenFlags;
189 let mut checks = store_checks();
190 match crate::index::existing_db_path() {
194 None => checks.push(Check::warn(
195 "index",
196 "not built yet - run `sessionwiki search` to build it".into(),
197 )),
198 Some(path) => match Connection::open_with_flags(&path, OpenFlags::SQLITE_OPEN_READ_ONLY) {
199 Ok(conn) => checks.extend(index_checks(&conn, crate::index::SCHEMA_VERSION)),
200 Err(e) => checks.push(Check::fail(
201 "index",
202 format!("present but cannot open ({}): {e}", path.display()),
203 )),
204 },
205 }
206 checks.push(Check::ok("version", env!("CARGO_PKG_VERSION").into()));
207
208 if json {
209 println!("{}", serde_json::to_string_pretty(&checks)?);
210 return Ok(());
211 }
212 for c in &checks {
213 let mark = match c.status {
214 Status::Ok => "ok ",
215 Status::Warn => "warn",
216 Status::Fail => "FAIL",
217 };
218 println!("[{mark}] {} - {}", c.name, c.detail);
219 }
220 Ok(())
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 fn conn() -> Connection {
228 let c = Connection::open_in_memory().unwrap();
229 c.execute_batch(
230 "CREATE TABLE files(path TEXT PRIMARY KEY, session_id TEXT NOT NULL, archived_at TEXT,
231 kind TEXT NOT NULL DEFAULT 'main');
232 CREATE TABLE messages(id INTEGER PRIMARY KEY);
233 CREATE TABLE edits(session_id TEXT);
234 CREATE TABLE archive(session_id TEXT);
235 CREATE TABLE summaries(session_id TEXT);",
236 )
237 .unwrap();
238 c
239 }
240
241 #[test]
242 fn index_checks_flag_a_stale_schema_and_report_counts() {
243 let c = conn();
244 c.pragma_update(None, "user_version", 7i64).unwrap();
245 c.execute("INSERT INTO files(path, session_id) VALUES('a', 's1')", [])
246 .unwrap();
247 c.execute(
248 "INSERT INTO files(path, session_id, archived_at) VALUES('b', 's2', '2026-01-01')",
249 [],
250 )
251 .unwrap();
252
253 let checks = index_checks(&c, 8);
254 let schema = checks.iter().find(|c| c.name == "index schema").unwrap();
255 assert_eq!(schema.status, Status::Warn, "v7 vs expected v8 is a warn");
256 let tables = checks.iter().find(|c| c.name == "index tables").unwrap();
257 assert_eq!(tables.status, Status::Ok, "all core tables readable");
258 let sessions = checks
259 .iter()
260 .find(|c| c.name == "indexed sessions")
261 .unwrap();
262 assert!(
263 sessions.detail.starts_with("2 "),
264 "2 sessions: {}",
265 sessions.detail
266 );
267 assert!(
268 sessions.detail.contains("1 "),
269 "1 archived: {}",
270 sessions.detail
271 );
272 }
273
274 #[test]
275 fn a_missing_core_table_is_a_fail_not_a_healthy_zero() {
276 let c = conn();
277 c.execute("DROP TABLE edits", []).unwrap();
278 let tables = index_checks(&c, 8)
279 .into_iter()
280 .find(|c| c.name == "index tables")
281 .unwrap();
282 assert_eq!(
283 tables.status,
284 Status::Fail,
285 "an unreadable core table must fail, not look like an empty index"
286 );
287 }
288
289 #[test]
290 fn index_checks_pass_a_current_schema() {
291 let c = conn();
292 c.pragma_update(None, "user_version", 8i64).unwrap();
293 let schema = index_checks(&c, 8)
294 .into_iter()
295 .find(|c| c.name == "index schema")
296 .unwrap();
297 assert_eq!(schema.status, Status::Ok);
298 }
299}
300
301#[cfg(test)]
302mod unlocatable_store_tests {
303 use super::*;
304
305 #[test]
313 fn an_unlocatable_store_is_reported_not_skipped() {
314 let c = unlocatable_store("gptme", None).expect("this is worth saying");
315 assert_eq!(c.status, Status::Warn);
316 assert!(c.name.contains("gptme"), "names the tool: {}", c.name);
317 }
318
319 #[test]
320 fn a_located_store_has_nothing_extra_to_say() {
321 let p = std::path::Path::new("/home/someone/.local/share/gptme/logs");
322 assert!(unlocatable_store("gptme", Some(p)).is_none());
323 }
324}