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