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