Skip to main content

lean_ctx/doctor/
mod.rs

1//! Environment diagnostics for lean-ctx installation and integration.
2
3mod checks;
4mod common;
5mod deprecations;
6mod fix;
7mod integrations;
8mod migrate;
9mod overhead;
10mod report;
11mod workspace_scope;
12
13#[allow(clippy::wildcard_imports)]
14use checks::*;
15#[allow(clippy::wildcard_imports)]
16use common::*;
17
18pub use report::{HealthCheck, HealthLevel, HealthReport, health_report};
19
20/// Run `doctor --fix` and return the structured `SetupReport` without printing
21/// anything — the in-process entry point for the dashboard fix route (#466).
22///
23/// # Errors
24/// Propagates any repair-step failure (e.g. an unwritable data directory).
25pub fn run_fix_report() -> Result<crate::core::setup_report::SetupReport, String> {
26    fix::fix_report()
27}
28
29pub(super) const GREEN: &str = "\x1b[32m";
30
31pub(super) const RED: &str = "\x1b[31m";
32
33pub(super) const BOLD: &str = "\x1b[1m";
34
35pub(super) const RST: &str = "\x1b[0m";
36
37pub(super) const DIM: &str = "\x1b[2m";
38
39pub(super) const WHITE: &str = "\x1b[97m";
40
41pub(super) const YELLOW: &str = "\x1b[33m";
42
43pub(super) struct Outcome {
44    pub ok: bool,
45    pub line: String,
46}
47
48/// Accumulates doctor checks so the rendered ✓/✗ list and the summary tally can
49/// never diverge (#433): every scored check is counted exactly once via
50/// [`Scoreboard::check`]; only explicitly-optional advisories (LSP, "not
51/// configured" notes) use [`Scoreboard::info`], which renders without counting.
52/// The old hand-maintained `passed`/`effective_total` pair drifted whenever a
53/// check was added without bumping the total — routing every render through the
54/// board makes that class of bug structurally impossible.
55#[derive(Default)]
56struct Scoreboard {
57    passed: u32,
58    total: u32,
59}
60
61impl Scoreboard {
62    /// A scored health check: render it and count it (pass iff `ok`).
63    fn check(&mut self, outcome: &Outcome) {
64        self.total += 1;
65        if outcome.ok {
66            self.passed += 1;
67        }
68        print_check(outcome);
69    }
70
71    /// An optional/advisory line: render it but never count it toward the score
72    /// (LSP servers, "no providers configured", MCP bridges, plan-mode presence).
73    ///
74    /// Deliberately a method (not a free function) so every rendered line flows
75    /// through the board and each call site has to choose `check` vs `info` — the
76    /// `&self` is unused by design, which is the whole point.
77    #[allow(clippy::unused_self)]
78    fn info(&self, outcome: &Outcome) {
79        print_check(outcome);
80    }
81}
82
83/// Run diagnostic checks and print colored results to stdout.
84pub fn run() {
85    let mut board = Scoreboard::default();
86
87    println!("{BOLD}{WHITE}lean-ctx doctor{RST}  {DIM}diagnostics{RST}\n");
88
89    // 1) Binary on PATH
90    let path_bin = resolve_lean_ctx_binary();
91    let also_in_path_dirs = path_in_path_env();
92    let bin_ok = path_bin.is_some() || also_in_path_dirs;
93    let bin_line = if let Some(p) = path_bin {
94        format!("{BOLD}lean-ctx in PATH{RST}  {WHITE}{}{RST}", p.display())
95    } else if also_in_path_dirs {
96        format!(
97            "{BOLD}lean-ctx in PATH{RST}  {YELLOW}found via PATH walk (not resolved by `command -v`){RST}"
98        )
99    } else {
100        format!("{BOLD}lean-ctx in PATH{RST}  {RED}not found{RST}")
101    };
102    board.check(&Outcome {
103        ok: bin_ok,
104        line: bin_line,
105    });
106
107    // 2) Version from PATH binary
108    let ver = if bin_ok {
109        lean_ctx_version_from_path()
110    } else {
111        Outcome {
112            ok: false,
113            line: format!("{BOLD}lean-ctx version{RST}  {RED}skipped (binary not in PATH){RST}"),
114        }
115    };
116    board.check(&ver);
117
118    // 3) data directory (respects LEAN_CTX_DATA_DIR)
119    let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
120    let dir_outcome = match &lean_dir {
121        Some(p) if p.is_dir() => Outcome {
122            ok: true,
123            line: format!(
124                "{BOLD}data dir{RST}  {GREEN}exists{RST}  {DIM}{}{RST}",
125                p.display()
126            ),
127        },
128        Some(p) => Outcome {
129            ok: false,
130            line: format!(
131                "{BOLD}data dir{RST}  {RED}missing or not a directory{RST}  {DIM}{}{RST}",
132                p.display()
133            ),
134        },
135        None => Outcome {
136            ok: false,
137            line: format!("{BOLD}data dir{RST}  {RED}could not resolve data directory{RST}"),
138        },
139    };
140    board.check(&dir_outcome);
141
142    // 4) stats.json + size
143    let stats_path = lean_dir.as_ref().map(|d| d.join("stats.json"));
144    let stats_outcome = match stats_path.as_ref().and_then(|p| std::fs::metadata(p).ok()) {
145        Some(m) if m.is_file() => {
146            let size = m.len();
147            let path_display = if let Some(p) = stats_path.as_ref() {
148                p.display().to_string()
149            } else {
150                String::new()
151            };
152            Outcome {
153                ok: true,
154                line: format!(
155                    "{BOLD}stats.json{RST}  {GREEN}exists{RST}  {WHITE}{size} bytes{RST}  {DIM}{path_display}{RST}",
156                ),
157            }
158        }
159        Some(_m) => {
160            let path_display = if let Some(p) = stats_path.as_ref() {
161                p.display().to_string()
162            } else {
163                String::new()
164            };
165            Outcome {
166                ok: false,
167                line: format!(
168                    "{BOLD}stats.json{RST}  {RED}not a file{RST}  {DIM}{path_display}{RST}",
169                ),
170            }
171        }
172        None => Outcome {
173            ok: true,
174            line: match &stats_path {
175                Some(p) => format!(
176                    "{BOLD}stats.json{RST}  {YELLOW}not yet created{RST}  {DIM}(will appear after first use) {}{RST}",
177                    p.display()
178                ),
179                None => format!("{BOLD}stats.json{RST}  {RED}could not resolve path{RST}"),
180            },
181        },
182    };
183    board.check(&stats_outcome);
184
185    let split_dirs = crate::core::data_dir::all_data_dirs_with_stats();
186    if split_dirs.len() >= 2 {
187        let dirs_str = split_dirs
188            .iter()
189            .map(|d| d.display().to_string())
190            .collect::<Vec<_>>()
191            .join(", ");
192        board.check(&Outcome {
193            ok: false,
194            line: format!(
195                "{BOLD}data dir split{RST}  {RED}stats.json found in {count} locations{RST}: {dirs_str}  {DIM}(run: lean-ctx doctor --fix to merge){RST}",
196                count = split_dirs.len(),
197            ),
198        });
199    }
200
201    // XDG layout (GH #408): a legacy/mixed single-dir install mixes config with
202    // data/state/cache, which blocks a read-only config sandbox. Scored as a
203    // failure while present (#433) — `doctor --fix` splits it into the four typed
204    // XDG dirs, after which this check disappears.
205    if let Some((src, n)) = crate::core::xdg_migrate::pending() {
206        board.check(&Outcome {
207            ok: false,
208            line: format!(
209                "{BOLD}XDG layout{RST}  {YELLOW}{n} item(s) in single dir{RST}  {DIM}{}{RST}  {DIM}(run: lean-ctx doctor --fix to split into config/data/state/cache){RST}",
210                src.display()
211            ),
212        });
213    }
214
215    // Layout commitment (GL #623): a pinned XDG install can no longer be
216    // hijacked by a stray ~/.lean-ctx. Surface the mode and flag a residual dir
217    // (heal reclaims it on the next start / `doctor --fix`).
218    {
219        let pinned = crate::core::layout_pin::is_xdg_pinned();
220        let residual = crate::core::xdg_migrate::residual_legacy_present();
221        let line = if pinned && residual {
222            format!(
223                "{BOLD}layout{RST}  {GREEN}xdg-pinned{RST}  {YELLOW}residual ~/.lean-ctx present{RST}  {DIM}(auto-reclaimed on next start){RST}"
224            )
225        } else if pinned {
226            format!(
227                "{BOLD}layout{RST}  {GREEN}xdg-pinned{RST}  {DIM}(~/.lean-ctx can no longer hijack this install){RST}"
228            )
229        } else {
230            format!(
231                "{BOLD}layout{RST}  {WHITE}single-dir / legacy{RST}  {DIM}(run: lean-ctx doctor --fix to commit to XDG){RST}"
232            )
233        };
234        board.check(&Outcome { ok: true, line });
235    }
236
237    // 5) config.toml (missing is OK). It lives in the CONFIG dir
238    // ($XDG_CONFIG_HOME/lean-ctx after a split), not the data dir — resolve it
239    // through the same path as the loader so the report matches reality
240    // post-migration instead of pointing at the old location (#435).
241    let config_path = crate::core::config::Config::path();
242    let config_outcome = match &config_path {
243        Some(p) => match std::fs::metadata(p) {
244            Ok(m) if m.is_file() => Outcome {
245                ok: true,
246                line: format!(
247                    "{BOLD}config.toml{RST}  {GREEN}exists{RST}  {DIM}{}{RST}",
248                    p.display()
249                ),
250            },
251            Ok(_) => Outcome {
252                ok: false,
253                line: format!(
254                    "{BOLD}config.toml{RST}  {RED}exists but is not a regular file{RST}  {DIM}{}{RST}",
255                    p.display()
256                ),
257            },
258            Err(_) => Outcome {
259                ok: true,
260                line: format!(
261                    "{BOLD}config.toml{RST}  {YELLOW}not found, using defaults{RST}  {DIM}(expected at {}){RST}",
262                    p.display()
263                ),
264            },
265        },
266        None => Outcome {
267            ok: false,
268            line: format!("{BOLD}config.toml{RST}  {RED}could not resolve path{RST}"),
269        },
270    };
271    board.check(&config_outcome);
272
273    // 5b) Shell allowlist (effective runtime view + silent-parse-error trap, #341)
274    let allowlist_outcome = shell_allowlist_outcome();
275    board.check(&allowlist_outcome);
276
277    // 5b2) Path jail (effective state + dead allow_paths entries, GH #392)
278    let path_jail = path_jail_outcome();
279    board.check(&path_jail);
280
281    // 5b3) Workspace trust for project-local .lean-ctx.toml (security audit #4)
282    let workspace_trust = workspace_trust_outcome();
283    board.check(&workspace_trust);
284
285    // 5b3b) Secret/.env redaction — the exfiltration-defense plane, independent
286    // of the jail + shell gating above (#507).
287    let secret_detection = secret_detection_outcome();
288    board.check(&secret_detection);
289
290    // 5b4) Cognition v2 activation (science subsystems wired + active)
291    let cognition = cognition_activity_outcome();
292    board.check(&cognition);
293
294    // 5c) Compact-format passthrough (preserve already-compact TOON output, #342)
295    let passthrough_outcome = compact_format_passthrough_outcome();
296    board.check(&passthrough_outcome);
297
298    // 5d) IDE permission inheritance (mirror host IDE bash/rm rules onto ctx_*)
299    let perm_inherit_outcome = permission_inheritance_outcome();
300    board.check(&perm_inherit_outcome);
301
302    // 6) Proxy upstreams
303    let proxy_outcome = proxy_upstream_outcome();
304    board.check(&proxy_outcome);
305
306    // 7) Shell aliases
307    let aliases = shell_aliases_outcome();
308    board.check(&aliases);
309
310    // 7) MCP
311    let mcp = mcp_config_outcome();
312    board.check(&mcp);
313
314    // 8) Workspace-scope MCP (optional; only when a project-local config exists)
315    let workspace_scope = workspace_scope::workspace_scope_outcome(mcp.ok);
316    if let Some(ref ws) = workspace_scope {
317        board.check(ws);
318    }
319
320    // 9) SKILL.md
321    let skill = skill_files_outcome();
322    board.check(&skill);
323
324    // 10) Port
325    let port = port_3333_outcome();
326    board.check(&port);
327
328    // Daemon status
329    #[cfg(unix)]
330    let daemon_outcome = {
331        let autostart = crate::daemon_autostart::is_installed();
332        // GH #394: surface the exact service file so users can audit/edit it
333        // and know the unit name for systemctl/launchctl without searching.
334        let autostart_tag = if autostart {
335            match crate::daemon_autostart::service_file_path() {
336                Some(p) => format!("  {DIM}[autostart: on — {}]{RST}", p.display()),
337                None => format!("  {DIM}[autostart: on]{RST}"),
338            }
339        } else {
340            String::new()
341        };
342        if crate::daemon::is_daemon_running() {
343            let pid_path = crate::daemon::daemon_pid_path();
344            let pid_str = std::fs::read_to_string(&pid_path).unwrap_or_default();
345            Outcome {
346                ok: true,
347                line: format!(
348                    "{BOLD}Daemon{RST}  {GREEN}running (PID {}){RST}{autostart_tag}",
349                    pid_str.trim()
350                ),
351            }
352        } else {
353            let hint = if autostart {
354                format!("{DIM}(autostart enabled, will restart){RST}")
355            } else {
356                format!("{DIM}(run: lean-ctx daemon start  or: lean-ctx daemon enable){RST}")
357            };
358            Outcome {
359                ok: true,
360                line: format!("{BOLD}Daemon{RST}  {YELLOW}not running{RST}  {hint}"),
361            }
362        }
363    };
364    #[cfg(not(unix))]
365    let daemon_outcome = Outcome {
366        ok: true,
367        line: format!("{BOLD}Daemon{RST}  {DIM}not supported on this platform{RST}"),
368    };
369    board.check(&daemon_outcome);
370
371    // Daemon diagnostics: systemctl is-active, linger, crash-loop log
372    #[cfg(target_os = "linux")]
373    {
374        if let Ok(o) = std::process::Command::new("systemctl")
375            .args(["--user", "is-active", "lean-ctx-daemon.service"])
376            .output()
377        {
378            let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
379            if state != "active" {
380                println!(
381                    "  {DIM}  systemd unit state: {YELLOW}{state}{RST}{DIM} (expected: active){RST}"
382                );
383            }
384        }
385        let username = std::env::var("USER")
386            .or_else(|_| std::env::var("LOGNAME"))
387            .unwrap_or_else(|_| "$(whoami)".to_string());
388        if let Ok(o) = std::process::Command::new("loginctl")
389            .args(["show-user", &username, "-p", "Linger", "--value"])
390            .output()
391        {
392            let val = String::from_utf8_lossy(&o.stdout).trim().to_string();
393            if val != "yes" {
394                println!(
395                    "  {YELLOW}⚠{RST}  Linger not enabled — daemon won't start at boot without login"
396                );
397                println!("     {DIM}Fix: loginctl enable-linger {username}{RST}");
398            }
399        }
400    }
401    if let Some(log_path) = crate::core::startup_guard::crash_loop_log_path(
402        crate::core::startup_guard::MCP_PROCESS_NAME,
403    ) && log_path.exists()
404        && let Ok(contents) = std::fs::read_to_string(&log_path)
405    {
406        let lines: Vec<&str> = contents.lines().collect();
407        if lines.len() >= 5 {
408            println!(
409                "  {YELLOW}⚠{RST}  Crash-loop log: {} recent restarts  {DIM}({}){RST}",
410                lines.len(),
411                display_user_path(&log_path)
412            );
413        }
414    }
415
416    // Providers (advisory — presence/health varies per environment, not scored)
417    let provider_outcome = provider_outcome();
418    board.info(&provider_outcome);
419
420    // MCP Bridges (advisory)
421    let bridge_outcomes = mcp_bridge_outcomes();
422    for bridge_check in &bridge_outcomes {
423        board.info(bridge_check);
424    }
425
426    // Plan mode (advisory)
427    let plan_outcomes = plan_mode_outcomes();
428    for plan_check in &plan_outcomes {
429        board.info(plan_check);
430    }
431
432    // 9) Session state (project_root + shell_cwd)
433    let session_outcome = session_state_outcome();
434    board.check(&session_outcome);
435
436    // 10) Docker env vars (optional, only in containers)
437    let docker_outcomes = docker_env_outcomes();
438    for docker_check in &docker_outcomes {
439        board.check(docker_check);
440    }
441
442    // 11) Pi Coding Agent (optional)
443    let pi = pi_outcome();
444    if let Some(ref pi_check) = pi {
445        board.check(pi_check);
446    }
447
448    // 12) Build integrity (canary / origin check)
449    let integrity = crate::core::integrity::check();
450    let integrity_ok = integrity.seed_ok && integrity.origin_ok;
451    let integrity_line = if integrity_ok {
452        format!(
453            "{BOLD}Build origin{RST}  {GREEN}official{RST}  {DIM}{}{RST}",
454            integrity.repo
455        )
456    } else {
457        format!(
458            "{BOLD}Build origin{RST}  {RED}MODIFIED REDISTRIBUTION{RST}  {YELLOW}pkg={}, repo={}{RST}",
459            integrity.pkg_name, integrity.repo
460        )
461    };
462    board.check(&Outcome {
463        ok: integrity_ok,
464        line: integrity_line,
465    });
466
467    // 13) Cache safety
468    let cache_safety = cache_safety_outcome();
469    board.check(&cache_safety);
470
471    // 14) Claude Code instruction truncation guard
472    let claude_truncation = claude_truncation_outcome();
473    if let Some(ref ct) = claude_truncation {
474        board.check(ct);
475    }
476
477    // 14a) CodeBuddy instruction truncation guard
478    let codebuddy_truncation = codebuddy_truncation_outcome();
479    if let Some(ref cbt) = codebuddy_truncation {
480        board.check(cbt);
481    }
482
483    // 15) BM25 cache health
484    let bm25_health = bm25_cache_health_outcome();
485    board.check(&bm25_health);
486
487    // 15a) Semantic index runtime status (state/timing/persistence) for the
488    // active project — surfaces a stuck "warming" index (issue #249).
489    let semantic_index = semantic_index_outcome();
490    if let Some(ref check) = semantic_index {
491        board.check(check);
492    }
493
494    // 15b) Archive FTS footprint
495    let archive_footprint = archive_footprint_outcome();
496    board.check(&archive_footprint);
497
498    // 16) Memory profile
499    let mem_profile = memory_profile_outcome();
500    board.check(&mem_profile);
501
502    // 17) Memory cleanup
503    let mem_cleanup = memory_cleanup_outcome();
504    board.check(&mem_cleanup);
505
506    // 18) RAM Guardian
507    let ram_outcome = ram_guardian_outcome();
508    board.check(&ram_outcome);
509
510    // 19) Capacity warnings (memory stores near limits)
511    let cap_warnings = capacity_warnings();
512    for cw in &cap_warnings {
513        board.check(cw);
514    }
515
516    // 19b) Orphaned knowledge stores (deleted projects — reclaimable bloat, #615)
517    let orphan_outcome = orphaned_knowledge_outcome();
518    board.check(&orphan_outcome);
519
520    // 20) Proxy health
521    let proxy_health = proxy_health_outcome();
522    board.check(&proxy_health);
523
524    // 20a) Proxy upstream drift (#449): running proxy serves a different upstream
525    // than config.toml resolves to (env override masking config). Only surfaces
526    // when the proxy is up and actually drifting.
527    let upstream_drift = proxy_upstream_drift_outcome();
528    if let Some(ref check) = upstream_drift {
529        board.check(check);
530    }
531
532    // 20) Stale proxy env (ANTHROPIC_BASE_URL pointing to local proxy while proxy is not enabled)
533    let stale_env = stale_proxy_env_outcome();
534    if let Some(ref check) = stale_env {
535        board.check(check);
536    }
537
538    // 21) Claude Pro/Max subscription routed through the proxy without an API key
539    let subscription_conflict = proxy_subscription_conflict_outcome();
540    if let Some(ref check) = subscription_conflict {
541        board.check(check);
542    }
543
544    // 22) Deprecation register (CONTRACTS.md policy, GL #394): warn about
545    // every surface this build deprecates, with replacement and removal floor.
546    let deprecation_check = deprecations::deprecations_outcome();
547    board.check(&deprecation_check);
548
549    // MCP server CWD warning (informational, only fires when running as MCP)
550    let mcp_cwd = mcp_server_cwd_outcome();
551    board.check(&mcp_cwd);
552
553    // LSP servers (optional, informational)
554    println!("\n  {BOLD}{WHITE}LSP (optional — for ctx_refactor):{RST}");
555    let lsp_outcomes = lsp_server_outcomes();
556    for lsp_check in &lsp_outcomes {
557        board.info(lsp_check);
558    }
559
560    // Shadow mode status
561    let cfg = crate::core::config::Config::load();
562    let shadow_line = if cfg.shadow_mode {
563        format!(
564            "{BOLD}Shadow mode{RST}  {GREEN}active{RST}  {DIM}(native tools denied → ctx_* mandatory){RST}"
565        )
566    } else {
567        format!(
568            "{BOLD}Shadow mode{RST}  {DIM}disabled{RST}  {DIM}(enable: lean-ctx config set shadow_mode true){RST}"
569        )
570    };
571    println!("  {shadow_line}");
572
573    // Tool-schema footprint (informational, not scored). With no profile pinned
574    // the server runs in lean mode — only the lazy core is advertised and every
575    // tool stays reachable via ctx_call — so report that, not the internal
576    // `power` call-gate fallback that `from_config` returns for an empty config
577    // (otherwise `doctor` claimed "power" right after the wizard chose lean, #415).
578    let tool_profile_line = if crate::server::tool_visibility::explicit_profile(&cfg) {
579        let profile = crate::core::tool_profiles::ToolProfile::from_config(&cfg);
580        format!(
581            "{BOLD}Tool profile{RST}  {WHITE}{profile}{RST}  {DIM}{} + ctx_call gateway{RST}",
582            profile.description()
583        )
584    } else {
585        let lazy_count = crate::tool_defs::core_tool_names().len();
586        format!(
587            "{BOLD}Tool profile{RST}  {WHITE}lean (default){RST}  {DIM}{lazy_count} lazy-core tools advertised + ctx_call gateway{RST}"
588        )
589    };
590    println!("  {tool_profile_line}");
591
592    // Session cache health (#361): answer "is the cache actually engaging?"
593    // without external instrumentation. CEP sessions + the cross-call hit ratio
594    // come from the persistent stats store; `verify-cache` proves it live.
595    let cep = &crate::core::stats::load().cep;
596    let hit_ratio = if cep.total_cache_reads > 0 {
597        (cep.total_cache_hits as f64 / cep.total_cache_reads as f64) * 100.0
598    } else {
599        0.0
600    };
601    println!(
602        "  {BOLD}Session cache{RST}  {WHITE}{} sessions{RST}  {DIM}{}/{} reads cached ({hit_ratio:.0}% hit) · prove: lean-ctx verify-cache{RST}",
603        cep.sessions, cep.total_cache_hits, cep.total_cache_reads
604    );
605
606    // The board counted exactly what it rendered — the displayed ✓/✗ list and
607    // this tally can no longer drift apart (#433).
608    let passed = board.passed;
609    let total = board.total;
610    let needs_attention = total.saturating_sub(passed);
611    println!();
612    println!("  {BOLD}{WHITE}Summary:{RST}  {GREEN}{passed}{RST}{DIM}/{total}{RST} checks passed");
613    if needs_attention > 0 {
614        println!(
615            "  {YELLOW}{needs_attention} check(s) need attention.{RST}  Auto-repair what's fixable:  {BOLD}lean-ctx doctor --fix{RST}"
616        );
617    } else {
618        println!("  {GREEN}Everything looks good.{RST}");
619    }
620    println!("  {DIM}LSP servers are optional enhancements (not counted in score){RST}");
621    println!("  {DIM}{}{RST}", crate::core::integrity::origin_line());
622}
623
624pub fn run_compact() {
625    let (passed, total) = compact_score();
626    print_compact_status(passed, total);
627}
628
629pub fn run_cli(args: &[String]) -> i32 {
630    let (sub, rest) = match args.first().map(String::as_str) {
631        Some("integrations") => ("integrations", &args[1..]),
632        Some("overhead") => ("overhead", &args[1..]),
633        _ => ("", args),
634    };
635
636    let fix = rest.iter().any(|a| a == "--fix");
637    let json = rest.iter().any(|a| a == "--json");
638    let migrate_check = rest.iter().any(|a| a == "--migrate-check");
639    let help = rest.iter().any(|a| a == "--help" || a == "-h");
640
641    if help {
642        println!("Usage:");
643        println!("  lean-ctx doctor");
644        println!("  lean-ctx doctor overhead [--json]   Fixed context cost per session");
645        println!("  lean-ctx doctor integrations [--json]");
646        println!("  lean-ctx doctor --fix [--json]");
647        println!("  lean-ctx doctor --migrate-check [--json]");
648        return 0;
649    }
650
651    if sub == "overhead" {
652        return overhead::run_overhead(json);
653    }
654
655    if migrate_check {
656        return migrate::run_migrate_check(json);
657    }
658
659    if sub == "integrations" {
660        if fix {
661            let _ = fix::run_fix(&fix::DoctorFixOptions { json: false });
662        }
663        return integrations::run_integrations(&integrations::IntegrationsOptions { json });
664    }
665
666    if !fix {
667        run();
668        return 0;
669    }
670
671    match fix::run_fix(&fix::DoctorFixOptions { json }) {
672        Ok(code) => code,
673        Err(e) => {
674            tracing::error!("doctor --fix failed: {e}");
675            2
676        }
677    }
678}
679
680pub fn compact_score() -> (u32, u32) {
681    let mut passed = 0u32;
682    let total = 6u32;
683
684    if resolve_lean_ctx_binary().is_some() || path_in_path_env() {
685        passed += 1;
686    }
687    let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
688    if lean_dir.as_ref().is_some_and(|p| p.is_dir()) {
689        passed += 1;
690    }
691    if lean_dir
692        .as_ref()
693        .map(|d| d.join("stats.json"))
694        .and_then(|p| std::fs::metadata(p).ok())
695        .is_some_and(|m| m.is_file())
696    {
697        passed += 1;
698    }
699    if shell_aliases_outcome().ok {
700        passed += 1;
701    }
702    if mcp_config_outcome().ok {
703        passed += 1;
704    }
705    if skill_files_outcome().ok {
706        passed += 1;
707    }
708
709    (passed, total)
710}
711
712pub(super) fn print_compact_status(passed: u32, total: u32) {
713    let status = if passed == total {
714        format!("{GREEN}✓ All {total} checks passed{RST}")
715    } else {
716        format!("{YELLOW}{passed}/{total} passed{RST} — run {BOLD}lean-ctx doctor{RST} for details")
717    };
718    println!("  {status}");
719}
720
721#[cfg(test)]
722mod tests {
723    use super::is_active_shell_impl;
724
725    // Mirrors the inline classification in `checks::capacity_warnings`: a store at
726    // or below its cap is at most a WARN (healthy, eviction keeps it there); only
727    // a store *over* cap is CRIT (eviction is not keeping up).
728    fn make_capacity_check(name: &str, current: usize, limit: usize) -> Option<(bool, String)> {
729        if limit == 0 {
730            return None;
731        }
732        let pct = (current as f64 / limit as f64 * 100.0) as u32;
733        if pct > 100 {
734            Some((true, format!("{name}: {current}/{limit} ({pct}%)")))
735        } else if pct >= 80 {
736            Some((false, format!("{name}: {current}/{limit} ({pct}%)")))
737        } else {
738            None
739        }
740    }
741
742    #[test]
743    fn capacity_below_80_no_warning() {
744        assert!(make_capacity_check("facts", 100, 200).is_none());
745        assert!(make_capacity_check("facts", 159, 200).is_none());
746    }
747
748    #[test]
749    fn capacity_at_80_yellow_warning() {
750        let result = make_capacity_check("facts", 160, 200);
751        assert!(result.is_some());
752        let (critical, msg) = result.unwrap();
753        assert!(!critical);
754        assert!(msg.contains("160/200"));
755        assert!(msg.contains("80%"));
756    }
757
758    #[test]
759    fn capacity_at_92_yellow_warning() {
760        let result = make_capacity_check("facts", 185, 200);
761        assert!(result.is_some());
762        let (critical, msg) = result.unwrap();
763        assert!(!critical);
764        assert!(msg.contains("185/200"));
765        assert!(msg.contains("92%"));
766    }
767
768    #[test]
769    fn capacity_at_95_is_warning_not_critical() {
770        let result = make_capacity_check("facts", 190, 200);
771        assert!(result.is_some());
772        let (critical, msg) = result.unwrap();
773        assert!(!critical, "95% is full-but-healthy, not over cap");
774        assert!(msg.contains("190/200"));
775        assert!(msg.contains("95%"));
776    }
777
778    #[test]
779    fn capacity_at_100_is_warning_not_critical() {
780        // A store exactly at its cap is healthy — eviction keeps it there.
781        let result = make_capacity_check("facts", 200, 200);
782        assert!(result.is_some());
783        let (critical, _) = result.unwrap();
784        assert!(!critical);
785    }
786
787    #[test]
788    fn capacity_over_100_is_critical() {
789        // Genuinely over cap => eviction is not keeping up (regression guard for
790        // the 206/200 "CRIT" that fired before lifecycle eviction was fixed).
791        let result = make_capacity_check("facts", 206, 200);
792        assert!(result.is_some());
793        let (critical, msg) = result.unwrap();
794        assert!(critical);
795        assert!(msg.contains("206/200"));
796        assert!(msg.contains("103%"));
797    }
798
799    #[test]
800    fn capacity_zero_limit_skipped() {
801        assert!(make_capacity_check("facts", 50, 0).is_none());
802    }
803
804    #[test]
805    fn bashrc_active_on_non_windows_when_shell_empty() {
806        assert!(is_active_shell_impl("~/.bashrc", "", false, false));
807    }
808
809    #[test]
810    fn bashrc_not_active_on_windows_when_shell_empty() {
811        assert!(!is_active_shell_impl("~/.bashrc", "", true, false));
812    }
813
814    #[test]
815    fn bashrc_active_when_shell_contains_bash_on_linux() {
816        assert!(is_active_shell_impl(
817            "~/.bashrc",
818            "/usr/bin/bash",
819            false,
820            false
821        ));
822    }
823
824    #[test]
825    fn bashrc_not_active_on_windows_even_with_bash_in_shell_env() {
826        // Issue #214: On Windows, Git Bash sets $SHELL globally to bash.exe.
827        // .bashrc should NOT be flagged on Windows unless actually inside bash.
828        crate::test_env::remove_var("BASH_VERSION");
829        assert!(!is_active_shell_impl(
830            "~/.bashrc",
831            "C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe",
832            true,
833            false,
834        ));
835    }
836
837    #[test]
838    fn bashrc_not_active_on_windows_powershell_even_with_bash_in_shell() {
839        assert!(!is_active_shell_impl(
840            "~/.bashrc",
841            "C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe",
842            true,
843            true,
844        ));
845    }
846
847    #[test]
848    fn bashrc_not_active_on_windows_powershell_with_empty_shell() {
849        assert!(!is_active_shell_impl("~/.bashrc", "", true, true));
850    }
851
852    #[test]
853    fn zshrc_unaffected_by_powershell_flag() {
854        assert!(is_active_shell_impl("~/.zshrc", "/bin/zsh", false, false));
855        assert!(is_active_shell_impl("~/.zshrc", "/bin/zsh", true, true));
856    }
857
858    #[test]
859    fn bashrc_not_active_on_windows_without_powershell_detection() {
860        // Windows + $SHELL=bash but NOT in actual bash session (no BASH_VERSION).
861        // This is the exact scenario from issue #214: Git Bash sets $SHELL globally.
862        crate::test_env::remove_var("BASH_VERSION");
863        assert!(!is_active_shell_impl(
864            "~/.bashrc",
865            "/usr/bin/bash",
866            true,
867            false,
868        ));
869    }
870
871    #[test]
872    fn bashrc_active_on_linux() {
873        assert!(is_active_shell_impl("~/.bashrc", "/bin/bash", false, false));
874        assert!(is_active_shell_impl("~/.bashrc", "", false, false));
875    }
876}