Skip to main content

sessionwiki/
doctor.rs

1//! `doctor`: a self-diagnosis of this machine's setup. sessionwiki reads a dozen
2//! drifting session formats and an on-disk index; when something looks empty or
3//! stale the cause is usually mundane (no store on this box, an index behind a
4//! schema bump, a store that will not parse). This surfaces that at a glance so a
5//! bug report starts from facts, not guesses.
6
7use 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
44/// Health of the on-disk index: schema currency, integrity, and what it holds.
45/// `expected_schema` is the binary's `SCHEMA_VERSION` (injected so this stays a
46/// pure function of the connection).
47pub 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    // Real reads of each core table: a query error is a genuine problem (missing
63    // table, lock, read-corruption), never a healthy empty index - so it must not
64    // become a silent "ok, 0". (Full PRAGMA integrity_check needs write access for
65    // the FTS5 index, so it can't run on this read-only connection.)
66    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    // Session counts (main sessions, matching the rest of the CLI's kind='main'
87    // convention). A count error is a Fail, not a healthy-looking 0.
88    match conn.query_row("SELECT count(*) FROM files WHERE kind='main'", [], |r| {
89        r.get::<_, i64>(0)
90    }) {
91        Ok(main) => {
92            // A count that FAILED is not a count of zero. The main count says so
93            // with a fail; this one turned any error - a schema without
94            // `archived_at`, a locked database - into "0 kept", which reads as
95            // the good news that nothing was lost.
96            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
121/// Which session stores exist on this machine, and how many sessions each holds.
122/// A store that is simply absent is normal (not every tool is installed) and is
123/// left off the list; the warn fires only when NONE are found.
124/// What doctor should say about an adapter whose store location could not be
125/// worked out. `None` when the location IS known - there is nothing to add.
126///
127/// Skipping it, which is what used to happen, gave the same silence as a tool
128/// that is simply not installed. Those are different facts: one is "there is
129/// nothing to look at", the other is "this machine would not tell me where to
130/// look", and only the second is a gap in what doctor can report.
131///
132/// This is defence, not a fix for something observed: on Unix `dirs` falls
133/// back to the passwd database, so unsetting HOME does not make `root()`
134/// answer None. It can on a platform or a build where that fallback is not
135/// there, and doctor should not go quiet when it does.
136pub 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; // an absent store is normal - not every tool is installed
160        }
161        any = true;
162        // Confirm the root is readable WITHOUT a full recursive walk: a large
163        // store (a 40GB codex history) would make `doctor` crawl. A shallow
164        // read_dir still catches a permission problem.
165        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
185/// Run every check and print the report (`--json` for scripts). Opens the index
186/// read-only so `doctor` never mutates it.
187pub fn run(json: bool) -> anyhow::Result<()> {
188    use rusqlite::OpenFlags;
189    let mut checks = store_checks();
190    // existing_db_path() has NO side effects (unlike open()/open_readonly, which
191    // create the data dir and can rename legacy dirs) - so `doctor` stays truly
192    // read-only. Absent index vs an index that won't open are DIFFERENT problems.
193    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    /// `store_checks` skipped an adapter whose `root()` answered None with the
306    /// same silence it uses for a tool that simply is not installed. Those are
307    /// different facts: one is "there is nothing to look at", the other is
308    /// "this machine would not tell me where to look" - and doctor exists to
309    /// report the second. Not reproduced: on Unix `dirs` falls back to the
310    /// passwd database, so even with HOME unset `root()` still answers. This
311    /// pins the behaviour for the platforms where it would not.
312    #[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}