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