Skip to main content

ctx/harness/
doctor.rs

1//! `ctx harness doctor` -- integration diagnostics.
2//!
3//! Runs a set of independent checks over the project root and returns
4//! findings; nothing here exits or prints. Severity semantics:
5//!
6//! - `error` / `warning`: something needs fixing -> exit code 1 at the CLI
7//! - `info`: informational only -> does not affect the exit code
8//!
9//! Checks never cascade: a missing index is a *finding* (with a hint), not
10//! an operational error, and the remaining checks still run.
11
12use std::fs;
13use std::path::Path;
14use std::time::UNIX_EPOCH;
15
16use serde::Serialize;
17
18use super::checksum::{content_checksum, generated_version, recorded_checksum};
19use super::templates::CTX_VERSION;
20use super::{read_lock, HOOK_NAMES, LOCAL_HOOKS_DIR, RULES_PATH};
21
22const CODEX_LOCAL_HOOKS_DIR: &str = ".codex/hooks/ctx";
23use crate::db::SCHEMA_VERSION;
24use crate::walker::{discover_files, WalkerConfig};
25
26/// Severity of a doctor finding.
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
28#[serde(rename_all = "lowercase")]
29pub enum Severity {
30    Error,
31    Warning,
32    Info,
33}
34
35/// One doctor finding.
36#[derive(Debug, Clone, Serialize)]
37pub struct Finding {
38    pub severity: Severity,
39    /// Stable machine-readable identifier (e.g. `index_missing`).
40    pub code: &'static str,
41    pub message: String,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub hint: Option<String>,
44}
45
46impl Finding {
47    fn new(severity: Severity, code: &'static str, message: String, hint: Option<&str>) -> Self {
48        Finding {
49            severity,
50            code,
51            message,
52            hint: hint.map(String::from),
53        }
54    }
55}
56
57/// Run every doctor check against `root` and collect the findings.
58pub fn run_doctor_checks(root: &Path) -> Vec<Finding> {
59    let mut findings = Vec::new();
60
61    check_binary_version(&mut findings);
62    check_scaffold(root, &mut findings);
63    check_templates_stale(root, &mut findings);
64    check_index(root, &mut findings);
65    check_rules(root, &mut findings);
66    check_hooks(root, &mut findings);
67    check_settings_wiring(root, &mut findings);
68    check_mcp(root, &mut findings);
69
70    findings
71}
72
73/// True when no finding is an error or a warning.
74pub fn is_healthy(findings: &[Finding]) -> bool {
75    findings
76        .iter()
77        .all(|f| matches!(f.severity, Severity::Info))
78}
79
80// ============================================================================
81// Individual checks
82// ============================================================================
83
84fn check_binary_version(findings: &mut Vec<Finding>) {
85    let mcp = if cfg!(feature = "mcp") {
86        "compiled in"
87    } else {
88        "not compiled in"
89    };
90    findings.push(Finding::new(
91        Severity::Info,
92        "binary_version",
93        format!("ctx v{CTX_VERSION} (mcp feature: {mcp})"),
94        None,
95    ));
96}
97
98fn local_scaffold(root: &Path) -> bool {
99    root.join(LOCAL_HOOKS_DIR).is_dir()
100}
101
102fn plugin_scaffold(root: &Path) -> bool {
103    root.join(".claude-plugin/plugin.json").exists()
104}
105
106fn codex_local_scaffold(root: &Path) -> bool {
107    root.join(CODEX_LOCAL_HOOKS_DIR).is_dir()
108}
109
110fn codex_plugin_scaffold(root: &Path) -> bool {
111    root.join(".codex-plugin/plugin.json").exists()
112}
113
114fn check_scaffold(root: &Path, findings: &mut Vec<Finding>) {
115    if !local_scaffold(root)
116        && !plugin_scaffold(root)
117        && !codex_local_scaffold(root)
118        && !codex_plugin_scaffold(root)
119        && read_lock(root).is_none()
120    {
121        findings.push(Finding::new(
122            Severity::Info,
123            "harness_not_initialized",
124            "no harness files found (nothing scaffolded)".to_string(),
125            Some("run 'ctx harness init --target claude|codex' to wire ctx into an agent"),
126        ));
127    }
128}
129
130fn check_templates_stale(root: &Path, findings: &mut Vec<Finding>) {
131    let mut stale: Vec<String> = Vec::new();
132
133    // Manifest entries record the generating version for every file,
134    // including JSON files without in-file headers.
135    if let Some(lock) = read_lock(root) {
136        for (rel, entry) in &lock.files {
137            if entry.ctx_version != CTX_VERSION && root.join(rel).exists() {
138                stale.push(format!("{rel} (v{})", entry.ctx_version));
139            }
140        }
141    }
142
143    // In-file headers cover files generated before the manifest existed (or
144    // with a deleted manifest).
145    for dir in [LOCAL_HOOKS_DIR, CODEX_LOCAL_HOOKS_DIR, "hooks"] {
146        for name in HOOK_NAMES {
147            let rel = format!("{dir}/{name}.sh");
148            if stale.iter().any(|s| s.starts_with(&rel)) {
149                continue;
150            }
151            if let Ok(content) = fs::read_to_string(root.join(&rel)) {
152                if let Some(version) = generated_version(&content) {
153                    if version != CTX_VERSION {
154                        stale.push(format!("{rel} (v{version})"));
155                    }
156                }
157            }
158        }
159    }
160
161    if !stale.is_empty() {
162        findings.push(Finding::new(
163            Severity::Warning,
164            "templates_stale",
165            format!(
166                "generated files are from a different ctx version than v{CTX_VERSION}: {}",
167                stale.join(", ")
168            ),
169            Some("rerun 'ctx harness init' to regenerate them"),
170        ));
171    }
172}
173
174fn check_index(root: &Path, findings: &mut Vec<Finding>) {
175    let db_path = root.join(".ctx/codebase.sqlite");
176    if !db_path.exists() {
177        findings.push(Finding::new(
178            Severity::Warning,
179            "index_missing",
180            "no code intelligence index (.ctx/codebase.sqlite)".to_string(),
181            Some("run 'ctx index' to build it"),
182        ));
183        return;
184    }
185
186    // Read the schema version and freshness directly (read-only) so a
187    // schema mismatch is a finding, not a hard failure.
188    let conn = match rusqlite::Connection::open_with_flags(
189        &db_path,
190        rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
191    ) {
192        Ok(conn) => conn,
193        Err(e) => {
194            findings.push(Finding::new(
195                Severity::Error,
196                "index_schema",
197                format!("cannot open .ctx/codebase.sqlite: {e}"),
198                Some("run 'ctx index --force' to rebuild the index"),
199            ));
200            return;
201        }
202    };
203
204    let user_version: i64 = conn
205        .query_row("PRAGMA user_version", [], |row| row.get(0))
206        .unwrap_or(-1);
207    if user_version != SCHEMA_VERSION {
208        findings.push(Finding::new(
209            Severity::Error,
210            "index_schema",
211            format!("index schema version is {user_version}, this ctx expects {SCHEMA_VERSION}"),
212            Some("run 'ctx index --force' to rebuild the index"),
213        ));
214        return;
215    }
216
217    // Freshness: any source file modified after the newest index entry?
218    let last_indexed: Option<i64> = conn
219        .query_row("SELECT MAX(last_indexed) FROM files", [], |row| row.get(0))
220        .unwrap_or(None);
221    let Some(last_indexed) = last_indexed else {
222        return; // empty index; index_missing semantics don't apply
223    };
224    let Ok(entries) = discover_files(root, &WalkerConfig::default()) else {
225        return;
226    };
227    let stale = entries
228        .iter()
229        .filter(|entry| {
230            entry
231                .absolute_path
232                .metadata()
233                .and_then(|m| m.modified())
234                .ok()
235                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
236                .map(|d| d.as_secs() as i64 > last_indexed)
237                .unwrap_or(false)
238        })
239        .count();
240    if stale > 0 {
241        findings.push(Finding::new(
242            Severity::Warning,
243            "index_stale",
244            format!(
245                "{stale} file{} modified after the last index update",
246                if stale == 1 { "" } else { "s" }
247            ),
248            Some("run 'ctx index' to refresh"),
249        ));
250    }
251}
252
253fn check_rules(root: &Path, findings: &mut Vec<Finding>) {
254    let rules_path = root.join(RULES_PATH);
255    let content = match fs::read_to_string(&rules_path) {
256        Ok(content) => content,
257        Err(_) => {
258            findings.push(Finding::new(
259                Severity::Info,
260                "rules_missing",
261                format!("no rules file ({RULES_PATH})"),
262                Some("'ctx harness init' writes a commented starter; see 'ctx check --help'"),
263            ));
264            return;
265        }
266    };
267
268    let compiled = toml::from_str::<crate::rules::RulesFile>(&content)
269        .map_err(|e| e.to_string())
270        .and_then(|file| crate::rules::CompiledRules::compile(file).map_err(|e| e.to_string()));
271    if let Err(e) = compiled {
272        findings.push(Finding::new(
273            Severity::Error,
274            "rules_invalid",
275            format!("{RULES_PATH} is invalid: {}", e.trim()),
276            Some("fix the rules file; 'ctx check --help' documents the format"),
277        ));
278    }
279}
280
281fn check_hooks(root: &Path, findings: &mut Vec<Finding>) {
282    let mut dirs: Vec<&str> = Vec::new();
283    if local_scaffold(root) {
284        dirs.push(LOCAL_HOOKS_DIR);
285    }
286    if plugin_scaffold(root) {
287        dirs.push("hooks");
288    }
289    if codex_local_scaffold(root) {
290        dirs.push(CODEX_LOCAL_HOOKS_DIR);
291    }
292    if codex_plugin_scaffold(root) && !dirs.contains(&"hooks") {
293        dirs.push("hooks");
294    }
295
296    let lock = read_lock(root);
297    let mut missing: Vec<String> = Vec::new();
298    let mut modified: Vec<String> = Vec::new();
299
300    for dir in dirs {
301        for name in HOOK_NAMES {
302            let rel = format!("{dir}/{name}.sh");
303            let path = root.join(&rel);
304            let Ok(bytes) = fs::read(&path) else {
305                missing.push(rel);
306                continue;
307            };
308            let actual = content_checksum(&bytes);
309            let expected = lock
310                .as_ref()
311                .and_then(|l| l.files.get(&rel))
312                .map(|e| {
313                    e.checksum
314                        .strip_prefix("sha256:")
315                        .unwrap_or(&e.checksum)
316                        .to_string()
317                })
318                .or_else(|| std::str::from_utf8(&bytes).ok().and_then(recorded_checksum));
319            match expected {
320                Some(expected) if expected == actual => {}
321                _ => modified.push(rel),
322            }
323        }
324    }
325
326    if !missing.is_empty() {
327        findings.push(Finding::new(
328            Severity::Warning,
329            "hooks_missing",
330            format!("hook scripts missing: {}", missing.join(", ")),
331            Some("rerun 'ctx harness init' to regenerate them"),
332        ));
333    }
334    if !modified.is_empty() {
335        findings.push(Finding::new(
336            Severity::Warning,
337            "hooks_modified",
338            format!(
339                "hook scripts modified since generation (checksum mismatch): {}",
340                modified.join(", ")
341            ),
342            Some("rerun 'ctx harness init --force' to restore them"),
343        ));
344    }
345}
346
347fn check_settings_wiring(root: &Path, findings: &mut Vec<Finding>) {
348    check_codex_wiring(root, findings);
349    if !local_scaffold(root) {
350        return;
351    }
352    let wired = fs::read_to_string(root.join(".claude/settings.json"))
353        .map(|content| content.contains(".claude/hooks/ctx/"))
354        .unwrap_or(false);
355    if !wired {
356        findings.push(Finding::new(
357            Severity::Warning,
358            "settings_not_wired",
359            ".claude/settings.json does not reference the generated hooks under .claude/hooks/ctx/"
360                .to_string(),
361            Some("add the settings snippet printed by 'ctx harness init' to .claude/settings.json"),
362        ));
363    }
364}
365
366fn check_codex_wiring(root: &Path, findings: &mut Vec<Finding>) {
367    if !codex_local_scaffold(root) {
368        return;
369    }
370    let path = root.join(".codex/hooks.json");
371    let wired = fs::read_to_string(&path)
372        .ok()
373        .and_then(|content| {
374            serde_json::from_str::<serde_json::Value>(&content)
375                .ok()
376                .map(|_| content)
377        })
378        .map(|content| content.contains(".codex/hooks/ctx/"))
379        .unwrap_or(false);
380    if !wired {
381        findings.push(Finding::new(
382            Severity::Warning,
383            "codex_hooks_not_wired",
384            ".codex/hooks.json is missing, invalid, or does not reference .codex/hooks/ctx/"
385                .to_string(),
386            Some("rerun 'ctx harness init --target codex' to regenerate it"),
387        ));
388    }
389}
390
391fn check_mcp(root: &Path, findings: &mut Vec<Finding>) {
392    let mcp_json = fs::read_to_string(root.join(".mcp.json")).ok();
393    let wires_ctx_serve = mcp_json
394        .as_deref()
395        .map(|content| content.contains("\"ctx\"") && content.contains("--mcp"))
396        .unwrap_or(false);
397
398    if wires_ctx_serve && !cfg!(feature = "mcp") {
399        findings.push(Finding::new(
400            Severity::Warning,
401            "mcp_unavailable",
402            ".mcp.json wires 'ctx serve --mcp' but this binary lacks the mcp feature".to_string(),
403            Some("install a build with MCP: cargo install agentis-ctx --features mcp"),
404        ));
405    }
406
407    if cfg!(feature = "mcp")
408        && (plugin_scaffold(root) || codex_plugin_scaffold(root))
409        && mcp_json.is_none()
410    {
411        findings.push(Finding::new(
412            Severity::Info,
413            "mcp_not_wired",
414            "this binary has the mcp feature, but the plugin scaffold has no .mcp.json".to_string(),
415            Some("rerun 'ctx harness init --mode plugin' to generate it"),
416        ));
417    }
418}
419
420// ============================================================================
421// Tests
422// ============================================================================
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427    use tempfile::TempDir;
428
429    fn codes(findings: &[Finding]) -> Vec<&'static str> {
430        findings.iter().map(|f| f.code).collect()
431    }
432
433    fn find<'a>(findings: &'a [Finding], code: &str) -> &'a Finding {
434        findings
435            .iter()
436            .find(|f| f.code == code)
437            .unwrap_or_else(|| panic!("no finding {code} in {:?}", codes(findings)))
438    }
439
440    #[test]
441    fn test_empty_dir_reports_missing_index_and_not_initialized() {
442        let temp = TempDir::new().unwrap();
443        let findings = run_doctor_checks(temp.path());
444
445        assert_eq!(find(&findings, "binary_version").severity, Severity::Info);
446        let missing = find(&findings, "index_missing");
447        assert_eq!(missing.severity, Severity::Warning);
448        assert!(missing.hint.as_deref().unwrap().contains("ctx index"));
449        assert_eq!(
450            find(&findings, "harness_not_initialized").severity,
451            Severity::Info
452        );
453        assert_eq!(find(&findings, "rules_missing").severity, Severity::Info);
454        assert!(!is_healthy(&findings), "index_missing is a warning");
455    }
456
457    #[test]
458    fn test_invalid_rules_is_a_distinct_error_finding() {
459        let temp = TempDir::new().unwrap();
460        std::fs::create_dir_all(temp.path().join(".ctx")).unwrap();
461        std::fs::write(temp.path().join(RULES_PATH), "[layers\nbroken = [").unwrap();
462
463        let findings = run_doctor_checks(temp.path());
464        let invalid = find(&findings, "rules_invalid");
465        assert_eq!(invalid.severity, Severity::Error);
466        assert!(invalid.message.contains(".ctx/rules.toml"));
467    }
468
469    #[test]
470    fn test_codex_scaffold_checks_hook_wiring() {
471        let temp = TempDir::new().unwrap();
472        let plan = super::super::plan_codex_local(temp.path());
473        super::super::write_plan(temp.path(), &plan, false).unwrap();
474
475        let findings = run_doctor_checks(temp.path());
476        assert!(!codes(&findings).contains(&"codex_hooks_not_wired"));
477
478        std::fs::write(temp.path().join(".codex/hooks.json"), "{}\n").unwrap();
479        let findings = run_doctor_checks(temp.path());
480        assert_eq!(
481            find(&findings, "codex_hooks_not_wired").severity,
482            Severity::Warning
483        );
484    }
485
486    #[test]
487    fn test_index_missing_and_rules_invalid_are_reported_simultaneously() {
488        // No cascading: both findings, with distinct codes, in one run.
489        let temp = TempDir::new().unwrap();
490        std::fs::create_dir_all(temp.path().join(".ctx")).unwrap();
491        std::fs::write(temp.path().join(RULES_PATH), "not toml at all [[[").unwrap();
492
493        let findings = run_doctor_checks(temp.path());
494        let all = codes(&findings);
495        assert!(all.contains(&"index_missing"), "codes: {all:?}");
496        assert!(all.contains(&"rules_invalid"), "codes: {all:?}");
497        assert_ne!("index_missing", "rules_invalid");
498        assert!(!is_healthy(&findings));
499    }
500
501    #[test]
502    fn test_healthy_scaffold_reports_only_info() {
503        let temp = TempDir::new().unwrap();
504        let root = temp.path();
505
506        // Full local scaffold + wired settings + fresh index.
507        let plan = super::super::plan_local(root);
508        super::super::write_plan(root, &plan, false).unwrap();
509        std::fs::create_dir_all(root.join(".claude")).unwrap();
510        std::fs::write(
511            root.join(".claude/settings.json"),
512            "{\"hooks\": {\"SessionStart\": [{\"hooks\": [{\"type\": \"command\", \"command\": \"\\\"$CLAUDE_PROJECT_DIR\\\"/.claude/hooks/ctx/session-start.sh\"}]}]}}",
513        )
514        .unwrap();
515        std::fs::write(root.join("src.rs"), "fn main() {}\n").unwrap();
516        let mut indexer =
517            crate::index::Indexer::with_config(root, false, WalkerConfig::default()).unwrap();
518        indexer.index().unwrap();
519
520        // The generated files postdate the index; refresh once more so the
521        // staleness check sees a fresh index. (harness files live in dot
522        // dirs the walker skips, but src.rs was just written.)
523        let mut indexer =
524            crate::index::Indexer::with_config(root, false, WalkerConfig::default()).unwrap();
525        indexer.index().unwrap();
526
527        let findings = run_doctor_checks(root);
528        for finding in &findings {
529            assert_eq!(
530                finding.severity,
531                Severity::Info,
532                "unexpected non-info finding: {:?}",
533                finding
534            );
535        }
536        assert!(is_healthy(&findings));
537    }
538
539    #[test]
540    fn test_modified_hook_is_flagged() {
541        let temp = TempDir::new().unwrap();
542        let root = temp.path();
543        let plan = super::super::plan_local(root);
544        super::super::write_plan(root, &plan, false).unwrap();
545
546        let stop = root.join(".claude/hooks/ctx/stop.sh");
547        let content = std::fs::read_to_string(&stop).unwrap() + "echo extra\n";
548        std::fs::write(&stop, content).unwrap();
549        std::fs::remove_file(root.join(".claude/hooks/ctx/session-start.sh")).unwrap();
550
551        let findings = run_doctor_checks(root);
552        let modified = find(&findings, "hooks_modified");
553        assert!(modified.message.contains("stop.sh"));
554        let missing = find(&findings, "hooks_missing");
555        assert!(missing.message.contains("session-start.sh"));
556    }
557
558    #[test]
559    fn test_severity_serializes_lowercase() {
560        let finding = Finding::new(Severity::Warning, "index_missing", "msg".into(), Some("h"));
561        let value = serde_json::to_value(&finding).unwrap();
562        assert_eq!(value["severity"], "warning");
563        assert_eq!(value["code"], "index_missing");
564        assert_eq!(value["hint"], "h");
565    }
566}