1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
28#[serde(rename_all = "lowercase")]
29pub enum Severity {
30 Error,
31 Warning,
32 Info,
33}
34
35#[derive(Debug, Clone, Serialize)]
37pub struct Finding {
38 pub severity: Severity,
39 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
57pub 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
73pub fn is_healthy(findings: &[Finding]) -> bool {
75 findings
76 .iter()
77 .all(|f| matches!(f.severity, Severity::Info))
78}
79
80fn 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 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 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 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 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; };
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#[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 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 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 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}