Skip to main content

lean_ctx/doctor/
mod.rs

1//! Environment diagnostics for lean-ctx installation and integration.
2
3mod fix;
4mod integrations;
5
6use std::net::TcpListener;
7use std::path::PathBuf;
8
9pub(super) const GREEN: &str = "\x1b[32m";
10const RED: &str = "\x1b[31m";
11pub(super) const BOLD: &str = "\x1b[1m";
12pub(super) const RST: &str = "\x1b[0m";
13pub(super) const DIM: &str = "\x1b[2m";
14pub(super) const WHITE: &str = "\x1b[97m";
15pub(super) const YELLOW: &str = "\x1b[33m";
16
17pub(super) struct Outcome {
18    pub ok: bool,
19    pub line: String,
20}
21
22fn print_check(outcome: &Outcome) {
23    let mark = if outcome.ok {
24        format!("{GREEN}✓{RST}")
25    } else {
26        format!("{RED}✗{RST}")
27    };
28    println!("  {mark}  {}", outcome.line);
29}
30
31fn path_in_path_env() -> bool {
32    if let Ok(path) = std::env::var("PATH") {
33        for dir in std::env::split_paths(&path) {
34            if dir.join("lean-ctx").is_file() {
35                return true;
36            }
37            if cfg!(windows)
38                && (dir.join("lean-ctx.exe").is_file() || dir.join("lean-ctx.cmd").is_file())
39            {
40                return true;
41            }
42        }
43    }
44    false
45}
46
47pub(super) fn resolve_lean_ctx_binary() -> Option<PathBuf> {
48    if let Ok(path) = std::env::var("PATH") {
49        for dir in std::env::split_paths(&path) {
50            if cfg!(windows) {
51                let exe = dir.join("lean-ctx.exe");
52                if exe.is_file() {
53                    return Some(exe);
54                }
55                let cmd = dir.join("lean-ctx.cmd");
56                if cmd.is_file() {
57                    return Some(cmd);
58                }
59            } else {
60                let bin = dir.join("lean-ctx");
61                if bin.is_file() {
62                    return Some(bin);
63                }
64            }
65        }
66    }
67    None
68}
69
70fn lean_ctx_version_from_path() -> Outcome {
71    let resolved = resolve_lean_ctx_binary();
72    let bin = resolved
73        .clone()
74        .unwrap_or_else(|| std::env::current_exe().unwrap_or_else(|_| "lean-ctx".into()));
75
76    let v = env!("CARGO_PKG_VERSION");
77    let note = match std::env::current_exe() {
78        Ok(exe) if exe == bin => format!("{DIM}(this binary){RST}"),
79        Ok(_) | Err(_) => format!("{DIM}(resolved: {}){RST}", bin.display()),
80    };
81    Outcome {
82        ok: true,
83        line: format!("{BOLD}lean-ctx version{RST}  {WHITE}lean-ctx {v}{RST}  {note}"),
84    }
85}
86
87fn rc_contains_lean_ctx(path: &PathBuf) -> bool {
88    match std::fs::read_to_string(path) {
89        Ok(s) => s.contains("lean-ctx"),
90        Err(_) => false,
91    }
92}
93
94fn has_pipe_guard_in_content(content: &str) -> bool {
95    content.contains("! -t 1")
96        || content.contains("isatty stdout")
97        || content.contains("IsOutputRedirected")
98}
99
100fn rc_references_shell_hook(content: &str) -> bool {
101    content.contains("lean-ctx/shell-hook.") || content.contains("lean-ctx\\shell-hook.")
102}
103
104fn rc_has_pipe_guard(path: &PathBuf) -> bool {
105    match std::fs::read_to_string(path) {
106        Ok(s) => {
107            if has_pipe_guard_in_content(&s) {
108                return true;
109            }
110            if rc_references_shell_hook(&s) {
111                let dirs_to_check = hook_dirs();
112                for dir in &dirs_to_check {
113                    for ext in &["zsh", "bash", "fish", "ps1"] {
114                        let hook = dir.join(format!("shell-hook.{ext}"));
115                        if let Ok(h) = std::fs::read_to_string(&hook) {
116                            if has_pipe_guard_in_content(&h) {
117                                return true;
118                            }
119                        }
120                    }
121                }
122            }
123            false
124        }
125        Err(_) => false,
126    }
127}
128
129fn hook_dirs() -> Vec<std::path::PathBuf> {
130    let mut dirs = Vec::new();
131    if let Ok(d) = crate::core::data_dir::lean_ctx_data_dir() {
132        dirs.push(d);
133    }
134    if let Some(home) = dirs::home_dir() {
135        let legacy = home.join(".lean-ctx");
136        if !dirs.iter().any(|d| d == &legacy) {
137            dirs.push(legacy);
138        }
139        let xdg = home.join(".config").join("lean-ctx");
140        if !dirs.iter().any(|d| d == &xdg) {
141            dirs.push(xdg);
142        }
143    }
144    dirs
145}
146
147fn is_active_shell_impl(rc_name: &str, shell: &str, is_windows: bool, is_powershell: bool) -> bool {
148    match rc_name {
149        "~/.zshrc" => shell.contains("zsh"),
150        "~/.bashrc" => {
151            // On Windows, .bashrc is only relevant when explicitly running
152            // inside Git Bash (not PowerShell, cmd, or other Windows shells).
153            // Git Bash sets $SHELL to bash.exe system-wide, which makes $SHELL
154            // unreliable on Windows. We also check that the user is NOT in
155            // PowerShell (PSModulePath) and NOT in plain cmd (PROMPT).
156            if is_windows {
157                if is_powershell {
158                    return false;
159                }
160                // Even without PSModulePath, $SHELL containing "bash" on Windows
161                // is unreliable (Git Bash sets it globally). Only flag if running
162                // from an actual bash interactive session (BASH_VERSION is set).
163                return std::env::var("BASH_VERSION").is_ok();
164            }
165            shell.contains("bash") || shell.is_empty()
166        }
167        "~/.config/fish/config.fish" => shell.contains("fish"),
168        _ => true,
169    }
170}
171
172/// Detect whether we are running inside a PowerShell session on Windows.
173/// Git Bash may set `$SHELL` to bash.exe system-wide, so `$SHELL` alone
174/// is not sufficient — we also need to rule out PowerShell as the actual
175/// running host process.
176fn is_powershell_session() -> bool {
177    std::env::var("PSModulePath").is_ok()
178}
179
180fn is_active_shell(rc_name: &str) -> bool {
181    let shell = std::env::var("SHELL").unwrap_or_default();
182    is_active_shell_impl(rc_name, &shell, cfg!(windows), is_powershell_session())
183}
184
185pub(super) fn shell_aliases_outcome() -> Outcome {
186    let Some(home) = dirs::home_dir() else {
187        return Outcome {
188            ok: false,
189            line: format!("{BOLD}Shell aliases{RST}  {RED}could not resolve home directory{RST}"),
190        };
191    };
192
193    let mut parts = Vec::new();
194    let mut needs_update = Vec::new();
195
196    let zsh = home.join(".zshrc");
197    if rc_contains_lean_ctx(&zsh) {
198        parts.push(format!("{DIM}~/.zshrc{RST}"));
199        if !rc_has_pipe_guard(&zsh) && is_active_shell("~/.zshrc") {
200            needs_update.push("~/.zshrc");
201        }
202    }
203    let bash = home.join(".bashrc");
204    if rc_contains_lean_ctx(&bash) {
205        parts.push(format!("{DIM}~/.bashrc{RST}"));
206        if !rc_has_pipe_guard(&bash) && is_active_shell("~/.bashrc") {
207            needs_update.push("~/.bashrc");
208        }
209    }
210
211    let fish = home.join(".config").join("fish").join("config.fish");
212    if rc_contains_lean_ctx(&fish) {
213        parts.push(format!("{DIM}~/.config/fish/config.fish{RST}"));
214        if !rc_has_pipe_guard(&fish) && is_active_shell("~/.config/fish/config.fish") {
215            needs_update.push("~/.config/fish/config.fish");
216        }
217    }
218
219    #[cfg(windows)]
220    {
221        let ps_profile = home
222            .join("Documents")
223            .join("PowerShell")
224            .join("Microsoft.PowerShell_profile.ps1");
225        let ps_profile_legacy = home
226            .join("Documents")
227            .join("WindowsPowerShell")
228            .join("Microsoft.PowerShell_profile.ps1");
229        if rc_contains_lean_ctx(&ps_profile) {
230            parts.push(format!("{DIM}PowerShell profile{RST}"));
231            if !rc_has_pipe_guard(&ps_profile) {
232                needs_update.push("PowerShell profile");
233            }
234        } else if rc_contains_lean_ctx(&ps_profile_legacy) {
235            parts.push(format!("{DIM}WindowsPowerShell profile{RST}"));
236            if !rc_has_pipe_guard(&ps_profile_legacy) {
237                needs_update.push("WindowsPowerShell profile");
238            }
239        }
240    }
241
242    if parts.is_empty() {
243        let hint = if cfg!(windows) {
244            "no \"lean-ctx\" in PowerShell profile, ~/.zshrc or ~/.bashrc"
245        } else {
246            "no \"lean-ctx\" in ~/.zshrc, ~/.bashrc, or ~/.config/fish/config.fish"
247        };
248        Outcome {
249            ok: false,
250            line: format!("{BOLD}Shell aliases{RST}  {RED}{hint}{RST}"),
251        }
252    } else if !needs_update.is_empty() {
253        Outcome {
254            ok: false,
255            line: format!(
256                "{BOLD}Shell aliases{RST}  {YELLOW}outdated hook in {} — run {BOLD}lean-ctx init --global{RST}{YELLOW} to fix (pipe guard missing){RST}",
257                needs_update.join(", ")
258            ),
259        }
260    } else {
261        Outcome {
262            ok: true,
263            line: format!(
264                "{BOLD}Shell aliases{RST}  {GREEN}lean-ctx referenced in {}{RST}",
265                parts.join(", ")
266            ),
267        }
268    }
269}
270
271struct McpLocation {
272    name: &'static str,
273    display: String,
274    path: PathBuf,
275}
276
277fn mcp_config_locations(home: &std::path::Path) -> Vec<McpLocation> {
278    let mut locations = vec![
279        McpLocation {
280            name: "Cursor",
281            display: "~/.cursor/mcp.json".into(),
282            path: home.join(".cursor").join("mcp.json"),
283        },
284        McpLocation {
285            name: "Claude Code",
286            display: format!(
287                "{}",
288                crate::core::editor_registry::claude_mcp_json_path(home).display()
289            ),
290            path: crate::core::editor_registry::claude_mcp_json_path(home),
291        },
292        McpLocation {
293            name: "Windsurf",
294            display: "~/.codeium/windsurf/mcp_config.json".into(),
295            path: home
296                .join(".codeium")
297                .join("windsurf")
298                .join("mcp_config.json"),
299        },
300        McpLocation {
301            name: "Codex",
302            display: {
303                let codex_dir =
304                    crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
305                format!("{}/config.toml", codex_dir.display())
306            },
307            path: crate::core::home::resolve_codex_dir()
308                .unwrap_or_else(|| home.join(".codex"))
309                .join("config.toml"),
310        },
311        McpLocation {
312            name: "Gemini CLI",
313            display: "~/.gemini/settings.json".into(),
314            path: home.join(".gemini").join("settings.json"),
315        },
316        McpLocation {
317            name: "Antigravity",
318            display: "~/.gemini/antigravity/mcp_config.json".into(),
319            path: home
320                .join(".gemini")
321                .join("antigravity")
322                .join("mcp_config.json"),
323        },
324    ];
325
326    #[cfg(unix)]
327    {
328        let zed_cfg = home.join(".config").join("zed").join("settings.json");
329        locations.push(McpLocation {
330            name: "Zed",
331            display: "~/.config/zed/settings.json".into(),
332            path: zed_cfg,
333        });
334    }
335
336    locations.push(McpLocation {
337        name: "Qwen Code",
338        display: "~/.qwen/settings.json".into(),
339        path: home.join(".qwen").join("settings.json"),
340    });
341    locations.push(McpLocation {
342        name: "Trae",
343        display: "~/.trae/mcp.json".into(),
344        path: home.join(".trae").join("mcp.json"),
345    });
346    locations.push(McpLocation {
347        name: "Amazon Q",
348        display: "~/.aws/amazonq/default.json".into(),
349        path: home.join(".aws").join("amazonq").join("default.json"),
350    });
351    locations.push(McpLocation {
352        name: "JetBrains",
353        display: "~/.jb-mcp.json".into(),
354        path: home.join(".jb-mcp.json"),
355    });
356    locations.push(McpLocation {
357        name: "AWS Kiro",
358        display: "~/.kiro/settings/mcp.json".into(),
359        path: home.join(".kiro").join("settings").join("mcp.json"),
360    });
361    locations.push(McpLocation {
362        name: "Verdent",
363        display: "~/.verdent/mcp.json".into(),
364        path: home.join(".verdent").join("mcp.json"),
365    });
366    locations.push(McpLocation {
367        name: "Crush",
368        display: "~/.config/crush/crush.json".into(),
369        path: home.join(".config").join("crush").join("crush.json"),
370    });
371    locations.push(McpLocation {
372        name: "Pi",
373        display: "~/.pi/agent/mcp.json".into(),
374        path: home.join(".pi").join("agent").join("mcp.json"),
375    });
376    locations.push(McpLocation {
377        name: "Amp",
378        display: "~/.config/amp/settings.json".into(),
379        path: home.join(".config").join("amp").join("settings.json"),
380    });
381
382    {
383        #[cfg(unix)]
384        let opencode_cfg = home.join(".config").join("opencode").join("opencode.json");
385        #[cfg(unix)]
386        let opencode_display = "~/.config/opencode/opencode.json";
387
388        #[cfg(windows)]
389        let opencode_cfg = if let Ok(appdata) = std::env::var("APPDATA") {
390            std::path::PathBuf::from(appdata)
391                .join("opencode")
392                .join("opencode.json")
393        } else {
394            home.join(".config").join("opencode").join("opencode.json")
395        };
396        #[cfg(windows)]
397        let opencode_display = "%APPDATA%/opencode/opencode.json";
398
399        locations.push(McpLocation {
400            name: "OpenCode",
401            display: opencode_display.into(),
402            path: opencode_cfg,
403        });
404    }
405
406    #[cfg(target_os = "macos")]
407    {
408        let vscode_mcp = home.join("Library/Application Support/Code/User/mcp.json");
409        locations.push(McpLocation {
410            name: "VS Code",
411            display: "~/Library/Application Support/Code/User/mcp.json".into(),
412            path: vscode_mcp,
413        });
414    }
415    #[cfg(target_os = "linux")]
416    {
417        let vscode_mcp = home.join(".config/Code/User/mcp.json");
418        locations.push(McpLocation {
419            name: "VS Code",
420            display: "~/.config/Code/User/mcp.json".into(),
421            path: vscode_mcp,
422        });
423    }
424    #[cfg(target_os = "windows")]
425    {
426        if let Ok(appdata) = std::env::var("APPDATA") {
427            let vscode_mcp = std::path::PathBuf::from(appdata).join("Code/User/mcp.json");
428            locations.push(McpLocation {
429                name: "VS Code",
430                display: "%APPDATA%/Code/User/mcp.json".into(),
431                path: vscode_mcp,
432            });
433        }
434    }
435
436    locations.push(McpLocation {
437        name: "Copilot CLI",
438        display: "~/.copilot/mcp-config.json".into(),
439        path: home.join(".copilot/mcp-config.json"),
440    });
441
442    locations.push(McpLocation {
443        name: "Hermes Agent",
444        display: "~/.hermes/config.yaml".into(),
445        path: home.join(".hermes").join("config.yaml"),
446    });
447
448    {
449        let cline_path = crate::core::editor_registry::cline_mcp_path();
450        if cline_path.to_str().is_some_and(|s| s != "/nonexistent") {
451            locations.push(McpLocation {
452                name: "Cline",
453                display: cline_path.display().to_string(),
454                path: cline_path,
455            });
456        }
457    }
458    {
459        let roo_path = crate::core::editor_registry::roo_mcp_path();
460        if roo_path.to_str().is_some_and(|s| s != "/nonexistent") {
461            locations.push(McpLocation {
462                name: "Roo Code",
463                display: roo_path.display().to_string(),
464                path: roo_path,
465            });
466        }
467    }
468
469    locations
470}
471
472fn mcp_config_outcome() -> Outcome {
473    let Some(home) = dirs::home_dir() else {
474        return Outcome {
475            ok: false,
476            line: format!("{BOLD}MCP config{RST}  {RED}could not resolve home directory{RST}"),
477        };
478    };
479
480    let locations = mcp_config_locations(&home);
481    let mut found: Vec<String> = Vec::new();
482    let mut exists_no_ref: Vec<String> = Vec::new();
483
484    for loc in &locations {
485        if let Ok(content) = std::fs::read_to_string(&loc.path) {
486            if has_lean_ctx_mcp_entry(&content) {
487                found.push(format!("{} {DIM}({}){RST}", loc.name, loc.display));
488            } else {
489                exists_no_ref.push(loc.name.to_string());
490            }
491        }
492    }
493
494    found.sort();
495    found.dedup();
496    exists_no_ref.sort();
497    exists_no_ref.dedup();
498
499    if !found.is_empty() {
500        Outcome {
501            ok: true,
502            line: format!(
503                "{BOLD}MCP config{RST}  {GREEN}lean-ctx found in: {}{RST}",
504                found.join(", ")
505            ),
506        }
507    } else if !exists_no_ref.is_empty() {
508        let has_claude = exists_no_ref.iter().any(|n| n.starts_with("Claude Code"));
509        let cause = if has_claude {
510            format!("{DIM}(Claude Code may overwrite ~/.claude.json on startup — lean-ctx entry missing from mcpServers){RST}")
511        } else {
512            String::new()
513        };
514        let hint = if has_claude {
515            format!("{DIM}(run: lean-ctx doctor --fix OR lean-ctx init --agent claude){RST}")
516        } else {
517            format!("{DIM}(run: lean-ctx doctor --fix OR lean-ctx setup){RST}")
518        };
519        Outcome {
520            ok: false,
521            line: format!(
522                "{BOLD}MCP config{RST}  {YELLOW}config exists for {} but mcpServers does not contain lean-ctx{RST}  {cause} {hint}",
523                exists_no_ref.join(", "),
524            ),
525        }
526    } else {
527        Outcome {
528            ok: false,
529            line: format!(
530                "{BOLD}MCP config{RST}  {YELLOW}no MCP config found{RST}  {DIM}(run: lean-ctx setup){RST}"
531            ),
532        }
533    }
534}
535
536fn has_lean_ctx_mcp_entry(content: &str) -> bool {
537    if let Ok(json) = serde_json::from_str::<serde_json::Value>(content) {
538        if let Some(servers) = json.get("mcpServers").and_then(|v| v.as_object()) {
539            return servers.contains_key("lean-ctx");
540        }
541        if let Some(servers) = json
542            .get("mcp")
543            .and_then(|v| v.get("servers"))
544            .and_then(|v| v.as_object())
545        {
546            return servers.contains_key("lean-ctx");
547        }
548    }
549    content.contains("lean-ctx")
550}
551
552fn port_3333_outcome() -> Outcome {
553    match TcpListener::bind("127.0.0.1:3333") {
554        Ok(_listener) => Outcome {
555            ok: true,
556            line: format!("{BOLD}Dashboard port 3333{RST}  {GREEN}available on 127.0.0.1{RST}"),
557        },
558        Err(e) => Outcome {
559            ok: false,
560            line: format!("{BOLD}Dashboard port 3333{RST}  {RED}not available: {e}{RST}"),
561        },
562    }
563}
564
565fn pi_outcome() -> Option<Outcome> {
566    let pi_result = std::process::Command::new("pi").arg("--version").output();
567
568    match pi_result {
569        Ok(output) if output.status.success() => {
570            let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
571            let has_plugin = std::process::Command::new("pi")
572                .args(["list"])
573                .output()
574                .is_ok_and(|o| {
575                    o.status.success() && String::from_utf8_lossy(&o.stdout).contains("pi-lean-ctx")
576                });
577
578            let has_mcp = dirs::home_dir()
579                .map(|h| h.join(".pi/agent/mcp.json"))
580                .and_then(|p| std::fs::read_to_string(p).ok())
581                .is_some_and(|c| c.contains("lean-ctx"));
582
583            if has_plugin && has_mcp {
584                Some(Outcome {
585                    ok: true,
586                    line: format!(
587                        "{BOLD}Pi Coding Agent{RST}  {GREEN}{version}, pi-lean-ctx + MCP configured{RST}"
588                    ),
589                })
590            } else if has_plugin {
591                Some(Outcome {
592                    ok: true,
593                    line: format!(
594                        "{BOLD}Pi Coding Agent{RST}  {GREEN}{version}, pi-lean-ctx installed{RST}  {DIM}(MCP not configured — embedded bridge active){RST}"
595                    ),
596                })
597            } else {
598                Some(Outcome {
599                    ok: false,
600                    line: format!(
601                        "{BOLD}Pi Coding Agent{RST}  {YELLOW}{version}, but pi-lean-ctx not installed{RST}  {DIM}(run: pi install npm:pi-lean-ctx){RST}"
602                    ),
603                })
604            }
605        }
606        _ => None,
607    }
608}
609
610fn provider_outcome() -> Outcome {
611    let registry = crate::core::providers::global_registry();
612    let ids = registry.available_provider_ids();
613    if ids.is_empty() {
614        return Outcome {
615            ok: true,
616            line: format!(
617                "{BOLD}Providers{RST}  {DIM}none configured (enable via [providers] in config.toml){RST}"
618            ),
619        };
620    }
621    let labels: Vec<String> = ids
622        .iter()
623        .map(|id| {
624            if let Some(p) = registry.get(id) {
625                if p.is_available() {
626                    format!("{GREEN}{id}{RST}")
627                } else {
628                    format!("{YELLOW}{id}(no auth){RST}")
629                }
630            } else {
631                format!("{RED}{id}(missing){RST}")
632            }
633        })
634        .collect();
635    Outcome {
636        ok: true,
637        line: format!("{BOLD}Providers{RST}  {}", labels.join(", ")),
638    }
639}
640
641fn mcp_bridge_outcomes() -> Vec<Outcome> {
642    let cfg = crate::core::config::Config::load();
643    let bridges = &cfg.providers.mcp_bridges;
644    if bridges.is_empty() {
645        return Vec::new();
646    }
647
648    let mut results = Vec::new();
649
650    let auto_idx = if cfg.providers.auto_index {
651        format!("{GREEN}auto_index=true{RST}")
652    } else {
653        format!("{YELLOW}auto_index=false (provider data won't be indexed into BM25/Graph/Knowledge){RST}")
654    };
655    results.push(Outcome {
656        ok: cfg.providers.auto_index,
657        line: format!("{BOLD}Provider indexing{RST}  {auto_idx}"),
658    });
659
660    for (name, entry) in bridges {
661        let url = entry.url.as_deref().unwrap_or("");
662        let cmd = entry.command.as_deref().unwrap_or("");
663        let source = if !url.is_empty() {
664            format!("url={url}")
665        } else if !cmd.is_empty() {
666            format!("cmd={cmd}")
667        } else {
668            "no url/command".to_string()
669        };
670
671        let ok = !url.is_empty() || !cmd.is_empty();
672        let status = if ok {
673            format!("{GREEN}configured{RST}")
674        } else {
675            format!("{RED}missing url/command{RST}")
676        };
677
678        results.push(Outcome {
679            ok,
680            line: format!("{BOLD}MCP Bridge{RST}  mcp:{name} ({source}) [{status}]"),
681        });
682    }
683
684    results
685}
686
687fn plan_mode_outcomes() -> Vec<Outcome> {
688    let status = crate::core::editor_registry::plan_mode::check_plan_mode_status();
689    let mut results = Vec::new();
690
691    if let Some(configured) = status.vscode_configured {
692        if configured {
693            results.push(Outcome {
694                ok: true,
695                line: format!(
696                    "{BOLD}Plan mode{RST}  VS Code  {GREEN}planAgent tools configured{RST}"
697                ),
698            });
699        } else {
700            results.push(Outcome {
701                ok: false,
702                line: format!(
703                    "{BOLD}Plan mode{RST}  VS Code  {YELLOW}not configured{RST}  {DIM}(run: lean-ctx setup){RST}"
704                ),
705            });
706        }
707    }
708
709    if let Some(configured) = status.claude_configured {
710        if configured {
711            results.push(Outcome {
712                ok: true,
713                line: format!("{BOLD}Plan mode{RST}  Claude Code  {GREEN}permissions present{RST}"),
714            });
715        } else {
716            results.push(Outcome {
717                ok: false,
718                line: format!(
719                    "{BOLD}Plan mode{RST}  Claude Code  {YELLOW}not configured{RST}  {DIM}(run: lean-ctx setup){RST}"
720                ),
721            });
722        }
723    }
724
725    results
726}
727
728fn session_state_outcome() -> Outcome {
729    use crate::core::session::SessionState;
730
731    match SessionState::load_latest() {
732        Some(session) => {
733            let root = session
734                .project_root
735                .as_deref()
736                .unwrap_or("(not set)");
737            let cwd = session
738                .shell_cwd
739                .as_deref()
740                .unwrap_or("(not tracked)");
741            Outcome {
742                ok: true,
743                line: format!(
744                    "{BOLD}Session state{RST}  {GREEN}active{RST}  {DIM}root: {root}, cwd: {cwd}, v{}{RST}",
745                    session.version
746                ),
747            }
748        }
749        None => Outcome {
750            ok: true,
751            line: format!(
752                "{BOLD}Session state{RST}  {YELLOW}no active session{RST}  {DIM}(will be created on first tool call){RST}"
753            ),
754        },
755    }
756}
757
758fn docker_env_outcomes() -> Vec<Outcome> {
759    if !crate::shell::is_container() {
760        return vec![];
761    }
762    let env_sh = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
763        |_| "/root/.lean-ctx/env.sh".to_string(),
764        |d| d.join("env.sh").to_string_lossy().to_string(),
765    );
766
767    let mut outcomes = vec![];
768
769    let shell_name = std::env::var("SHELL").unwrap_or_default();
770    let is_bash = shell_name.contains("bash") || shell_name.is_empty();
771
772    if is_bash {
773        let has_bash_env = std::env::var("BASH_ENV").is_ok();
774        outcomes.push(if has_bash_env {
775            Outcome {
776                ok: true,
777                line: format!(
778                    "{BOLD}BASH_ENV{RST}  {GREEN}set{RST}  {DIM}({}){RST}",
779                    std::env::var("BASH_ENV").unwrap_or_default()
780                ),
781            }
782        } else {
783            Outcome {
784                ok: false,
785                line: format!(
786                    "{BOLD}BASH_ENV{RST}  {RED}not set{RST}  {YELLOW}(add to Dockerfile: ENV BASH_ENV=\"{env_sh}\"){RST}"
787                ),
788            }
789        });
790    }
791
792    let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
793    outcomes.push(if has_claude_env {
794        Outcome {
795            ok: true,
796            line: format!(
797                "{BOLD}CLAUDE_ENV_FILE{RST}  {GREEN}set{RST}  {DIM}({}){RST}",
798                std::env::var("CLAUDE_ENV_FILE").unwrap_or_default()
799            ),
800        }
801    } else {
802        Outcome {
803            ok: false,
804            line: format!(
805                "{BOLD}CLAUDE_ENV_FILE{RST}  {RED}not set{RST}  {YELLOW}(for Claude Code: ENV CLAUDE_ENV_FILE=\"{env_sh}\"){RST}"
806            ),
807        }
808    });
809
810    outcomes
811}
812
813/// Run diagnostic checks and print colored results to stdout.
814pub fn run() {
815    let mut passed = 0u32;
816    let total = 10u32;
817
818    println!("{BOLD}{WHITE}lean-ctx doctor{RST}  {DIM}diagnostics{RST}\n");
819
820    // 1) Binary on PATH
821    let path_bin = resolve_lean_ctx_binary();
822    let also_in_path_dirs = path_in_path_env();
823    let bin_ok = path_bin.is_some() || also_in_path_dirs;
824    if bin_ok {
825        passed += 1;
826    }
827    let bin_line = if let Some(p) = path_bin {
828        format!("{BOLD}lean-ctx in PATH{RST}  {WHITE}{}{RST}", p.display())
829    } else if also_in_path_dirs {
830        format!(
831            "{BOLD}lean-ctx in PATH{RST}  {YELLOW}found via PATH walk (not resolved by `command -v`){RST}"
832        )
833    } else {
834        format!("{BOLD}lean-ctx in PATH{RST}  {RED}not found{RST}")
835    };
836    print_check(&Outcome {
837        ok: bin_ok,
838        line: bin_line,
839    });
840
841    // 2) Version from PATH binary
842    let ver = if bin_ok {
843        lean_ctx_version_from_path()
844    } else {
845        Outcome {
846            ok: false,
847            line: format!("{BOLD}lean-ctx version{RST}  {RED}skipped (binary not in PATH){RST}"),
848        }
849    };
850    if ver.ok {
851        passed += 1;
852    }
853    print_check(&ver);
854
855    // 3) data directory (respects LEAN_CTX_DATA_DIR)
856    let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
857    let dir_outcome = match &lean_dir {
858        Some(p) if p.is_dir() => {
859            passed += 1;
860            Outcome {
861                ok: true,
862                line: format!(
863                    "{BOLD}data dir{RST}  {GREEN}exists{RST}  {DIM}{}{RST}",
864                    p.display()
865                ),
866            }
867        }
868        Some(p) => Outcome {
869            ok: false,
870            line: format!(
871                "{BOLD}data dir{RST}  {RED}missing or not a directory{RST}  {DIM}{}{RST}",
872                p.display()
873            ),
874        },
875        None => Outcome {
876            ok: false,
877            line: format!("{BOLD}data dir{RST}  {RED}could not resolve data directory{RST}"),
878        },
879    };
880    print_check(&dir_outcome);
881
882    // 4) stats.json + size
883    let stats_path = lean_dir.as_ref().map(|d| d.join("stats.json"));
884    let stats_outcome = match stats_path.as_ref().and_then(|p| std::fs::metadata(p).ok()) {
885        Some(m) if m.is_file() => {
886            passed += 1;
887            let size = m.len();
888            let path_display = if let Some(p) = stats_path.as_ref() {
889                p.display().to_string()
890            } else {
891                String::new()
892            };
893            Outcome {
894                ok: true,
895                line: format!(
896                    "{BOLD}stats.json{RST}  {GREEN}exists{RST}  {WHITE}{size} bytes{RST}  {DIM}{path_display}{RST}",
897                ),
898            }
899        }
900        Some(_m) => {
901            let path_display = if let Some(p) = stats_path.as_ref() {
902                p.display().to_string()
903            } else {
904                String::new()
905            };
906            Outcome {
907                ok: false,
908                line: format!(
909                    "{BOLD}stats.json{RST}  {RED}not a file{RST}  {DIM}{path_display}{RST}",
910                ),
911            }
912        }
913        None => {
914            passed += 1;
915            Outcome {
916                ok: true,
917                line: match &stats_path {
918                    Some(p) => format!(
919                        "{BOLD}stats.json{RST}  {YELLOW}not yet created{RST}  {DIM}(will appear after first use) {}{RST}",
920                        p.display()
921                    ),
922                    None => format!("{BOLD}stats.json{RST}  {RED}could not resolve path{RST}"),
923                },
924            }
925        }
926    };
927    print_check(&stats_outcome);
928
929    let split_dirs = crate::core::data_dir::all_data_dirs_with_stats();
930    if split_dirs.len() >= 2 {
931        let dirs_str = split_dirs
932            .iter()
933            .map(|d| d.display().to_string())
934            .collect::<Vec<_>>()
935            .join(", ");
936        print_check(&Outcome {
937            ok: false,
938            line: format!(
939                "{BOLD}data dir split{RST}  {RED}stats.json found in {count} locations{RST}: {dirs_str}  {DIM}(run: lean-ctx setup to auto-merge){RST}",
940                count = split_dirs.len(),
941            ),
942        });
943    }
944
945    // 5) config.toml (missing is OK)
946    let config_path = lean_dir.as_ref().map(|d| d.join("config.toml"));
947    let config_outcome = match &config_path {
948        Some(p) => match std::fs::metadata(p) {
949            Ok(m) if m.is_file() => {
950                passed += 1;
951                Outcome {
952                    ok: true,
953                    line: format!(
954                        "{BOLD}config.toml{RST}  {GREEN}exists{RST}  {DIM}{}{RST}",
955                        p.display()
956                    ),
957                }
958            }
959            Ok(_) => Outcome {
960                ok: false,
961                line: format!(
962                    "{BOLD}config.toml{RST}  {RED}exists but is not a regular file{RST}  {DIM}{}{RST}",
963                    p.display()
964                ),
965            },
966            Err(_) => {
967                passed += 1;
968                Outcome {
969                    ok: true,
970                    line: format!(
971                        "{BOLD}config.toml{RST}  {YELLOW}not found, using defaults{RST}  {DIM}(expected at {}){RST}",
972                        p.display()
973                    ),
974                }
975            }
976        },
977        None => Outcome {
978            ok: false,
979            line: format!("{BOLD}config.toml{RST}  {RED}could not resolve path{RST}"),
980        },
981    };
982    print_check(&config_outcome);
983
984    // 6) Proxy upstreams
985    let proxy_outcome = proxy_upstream_outcome();
986    if proxy_outcome.ok {
987        passed += 1;
988    }
989    print_check(&proxy_outcome);
990
991    // 7) Shell aliases
992    let aliases = shell_aliases_outcome();
993    if aliases.ok {
994        passed += 1;
995    }
996    print_check(&aliases);
997
998    // 7) MCP
999    let mcp = mcp_config_outcome();
1000    if mcp.ok {
1001        passed += 1;
1002    }
1003    print_check(&mcp);
1004
1005    // 9) SKILL.md
1006    let skill = skill_files_outcome();
1007    if skill.ok {
1008        passed += 1;
1009    }
1010    print_check(&skill);
1011
1012    // 10) Port
1013    let port = port_3333_outcome();
1014    if port.ok {
1015        passed += 1;
1016    }
1017    print_check(&port);
1018
1019    // Daemon status
1020    #[cfg(unix)]
1021    let daemon_outcome = {
1022        let autostart = crate::daemon_autostart::is_installed();
1023        let autostart_tag = if autostart {
1024            format!("  {DIM}[autostart: on]{RST}")
1025        } else {
1026            String::new()
1027        };
1028        if crate::daemon::is_daemon_running() {
1029            let pid_path = crate::daemon::daemon_pid_path();
1030            let pid_str = std::fs::read_to_string(&pid_path).unwrap_or_default();
1031            Outcome {
1032                ok: true,
1033                line: format!(
1034                    "{BOLD}Daemon{RST}  {GREEN}running (PID {}){RST}{autostart_tag}",
1035                    pid_str.trim()
1036                ),
1037            }
1038        } else {
1039            let hint = if autostart {
1040                format!("{DIM}(autostart enabled, will restart){RST}")
1041            } else {
1042                format!("{DIM}(run: lean-ctx daemon start  or: lean-ctx daemon enable){RST}")
1043            };
1044            Outcome {
1045                ok: true,
1046                line: format!("{BOLD}Daemon{RST}  {YELLOW}not running{RST}  {hint}"),
1047            }
1048        }
1049    };
1050    #[cfg(not(unix))]
1051    let daemon_outcome = Outcome {
1052        ok: true,
1053        line: format!("{BOLD}Daemon{RST}  {DIM}not supported on this platform{RST}"),
1054    };
1055    if daemon_outcome.ok {
1056        passed += 1;
1057    }
1058    print_check(&daemon_outcome);
1059
1060    // Daemon diagnostics: systemctl is-active, linger, crash-loop log
1061    #[cfg(target_os = "linux")]
1062    {
1063        if let Ok(o) = std::process::Command::new("systemctl")
1064            .args(["--user", "is-active", "lean-ctx-daemon.service"])
1065            .output()
1066        {
1067            let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
1068            if state != "active" {
1069                println!(
1070                    "  {DIM}  systemd unit state: {YELLOW}{state}{RST}{DIM} (expected: active){RST}"
1071                );
1072            }
1073        }
1074        let username = std::env::var("USER")
1075            .or_else(|_| std::env::var("LOGNAME"))
1076            .unwrap_or_else(|_| "$(whoami)".to_string());
1077        if let Ok(o) = std::process::Command::new("loginctl")
1078            .args(["show-user", &username, "-p", "Linger", "--value"])
1079            .output()
1080        {
1081            let val = String::from_utf8_lossy(&o.stdout).trim().to_string();
1082            if val != "yes" {
1083                println!(
1084                    "  {YELLOW}⚠{RST}  Linger not enabled — daemon won't start at boot without login"
1085                );
1086                println!("     {DIM}Fix: loginctl enable-linger {username}{RST}");
1087            }
1088        }
1089    }
1090    if let Some(log_path) = crate::core::startup_guard::crash_loop_log_path(
1091        crate::core::startup_guard::MCP_PROCESS_NAME,
1092    ) {
1093        if log_path.exists() {
1094            if let Ok(contents) = std::fs::read_to_string(&log_path) {
1095                let lines: Vec<&str> = contents.lines().collect();
1096                if lines.len() >= 5 {
1097                    println!(
1098                        "  {YELLOW}⚠{RST}  Crash-loop log: {} recent restarts  {DIM}({}){RST}",
1099                        lines.len(),
1100                        log_path.display()
1101                    );
1102                }
1103            }
1104        }
1105    }
1106
1107    // Providers
1108    let provider_outcome = provider_outcome();
1109    print_check(&provider_outcome);
1110
1111    // MCP Bridges
1112    let bridge_outcomes = mcp_bridge_outcomes();
1113    for bridge_check in &bridge_outcomes {
1114        print_check(bridge_check);
1115    }
1116
1117    // Plan mode
1118    let plan_outcomes = plan_mode_outcomes();
1119    for plan_check in &plan_outcomes {
1120        print_check(plan_check);
1121    }
1122
1123    // 9) Session state (project_root + shell_cwd)
1124    let session_outcome = session_state_outcome();
1125    if session_outcome.ok {
1126        passed += 1;
1127    }
1128    print_check(&session_outcome);
1129
1130    // 10) Docker env vars (optional, only in containers)
1131    let docker_outcomes = docker_env_outcomes();
1132    for docker_check in &docker_outcomes {
1133        if docker_check.ok {
1134            passed += 1;
1135        }
1136        print_check(docker_check);
1137    }
1138
1139    // 11) Pi Coding Agent (optional)
1140    let pi = pi_outcome();
1141    if let Some(ref pi_check) = pi {
1142        if pi_check.ok {
1143            passed += 1;
1144        }
1145        print_check(pi_check);
1146    }
1147
1148    // 12) Build integrity (canary / origin check)
1149    let integrity = crate::core::integrity::check();
1150    let integrity_ok = integrity.seed_ok && integrity.origin_ok;
1151    if integrity_ok {
1152        passed += 1;
1153    }
1154    let integrity_line = if integrity_ok {
1155        format!(
1156            "{BOLD}Build origin{RST}  {GREEN}official{RST}  {DIM}{}{RST}",
1157            integrity.repo
1158        )
1159    } else {
1160        format!(
1161            "{BOLD}Build origin{RST}  {RED}MODIFIED REDISTRIBUTION{RST}  {YELLOW}pkg={}, repo={}{RST}",
1162            integrity.pkg_name, integrity.repo
1163        )
1164    };
1165    print_check(&Outcome {
1166        ok: integrity_ok,
1167        line: integrity_line,
1168    });
1169
1170    // 13) Cache safety
1171    let cache_safety = cache_safety_outcome();
1172    if cache_safety.ok {
1173        passed += 1;
1174    }
1175    print_check(&cache_safety);
1176
1177    // 14) Claude Code instruction truncation guard
1178    let claude_truncation = claude_truncation_outcome();
1179    if let Some(ref ct) = claude_truncation {
1180        if ct.ok {
1181            passed += 1;
1182        }
1183        print_check(ct);
1184    }
1185
1186    // 15) BM25 cache health
1187    let bm25_health = bm25_cache_health_outcome();
1188    if bm25_health.ok {
1189        passed += 1;
1190    }
1191    print_check(&bm25_health);
1192
1193    // 16) Memory profile
1194    let mem_profile = memory_profile_outcome();
1195    passed += 1;
1196    print_check(&mem_profile);
1197
1198    // 17) Memory cleanup
1199    let mem_cleanup = memory_cleanup_outcome();
1200    passed += 1;
1201    print_check(&mem_cleanup);
1202
1203    // 18) RAM Guardian
1204    let ram_outcome = ram_guardian_outcome();
1205    if ram_outcome.ok {
1206        passed += 1;
1207    }
1208    print_check(&ram_outcome);
1209
1210    // 19) Capacity warnings (memory stores near limits)
1211    let cap_warnings = capacity_warnings();
1212    for cw in &cap_warnings {
1213        if cw.ok {
1214            passed += 1;
1215        }
1216        print_check(cw);
1217    }
1218
1219    // 20) Proxy health
1220    let proxy_health = proxy_health_outcome();
1221    if proxy_health.ok {
1222        passed += 1;
1223    }
1224    print_check(&proxy_health);
1225
1226    // 20) Stale proxy env (ANTHROPIC_BASE_URL pointing to local proxy while proxy is not enabled)
1227    let stale_env = stale_proxy_env_outcome();
1228    if let Some(ref check) = stale_env {
1229        if check.ok {
1230            passed += 1;
1231        }
1232        print_check(check);
1233    }
1234
1235    // LSP servers (optional, informational)
1236    println!("\n  {BOLD}{WHITE}LSP (optional — for ctx_refactor):{RST}");
1237    let lsp_outcomes = lsp_server_outcomes();
1238    for lsp_check in &lsp_outcomes {
1239        print_check(lsp_check);
1240    }
1241
1242    let mut effective_total = total + 9; // session_state + integrity + cache_safety + bm25_health + daemon + mem_profile + mem_cleanup + ram_guardian + proxy_health
1243    effective_total += cap_warnings.len() as u32;
1244    effective_total += docker_outcomes.len() as u32;
1245    if pi.is_some() {
1246        effective_total += 1;
1247    }
1248    if claude_truncation.is_some() {
1249        effective_total += 1;
1250    }
1251    if stale_env.is_some() {
1252        effective_total += 1;
1253    }
1254    println!();
1255    println!("  {BOLD}{WHITE}Summary:{RST}  {GREEN}{passed}{RST}{DIM}/{effective_total}{RST} checks passed");
1256    println!("  {DIM}LSP servers are optional enhancements (not counted in score){RST}");
1257    println!("  {DIM}{}{RST}", crate::core::integrity::origin_line());
1258}
1259
1260fn skill_files_outcome() -> Outcome {
1261    let Some(home) = dirs::home_dir() else {
1262        return Outcome {
1263            ok: false,
1264            line: format!("{BOLD}SKILL.md{RST}  {RED}could not resolve home directory{RST}"),
1265        };
1266    };
1267
1268    let candidates = [
1269        ("Claude Code", home.join(".claude/skills/lean-ctx/SKILL.md")),
1270        ("Cursor", home.join(".cursor/skills/lean-ctx/SKILL.md")),
1271        (
1272            "Codex CLI",
1273            crate::core::home::resolve_codex_dir()
1274                .unwrap_or_else(|| home.join(".codex"))
1275                .join("skills/lean-ctx/SKILL.md"),
1276        ),
1277        (
1278            "GitHub Copilot",
1279            home.join(".copilot/skills/lean-ctx/SKILL.md"),
1280        ),
1281    ];
1282
1283    let mut found: Vec<&str> = Vec::new();
1284    for (name, path) in &candidates {
1285        if path.exists() {
1286            found.push(name);
1287        }
1288    }
1289
1290    if found.is_empty() {
1291        Outcome {
1292            ok: false,
1293            line: format!(
1294                "{BOLD}SKILL.md{RST}  {YELLOW}not installed{RST}  {DIM}(run: lean-ctx setup){RST}"
1295            ),
1296        }
1297    } else {
1298        Outcome {
1299            ok: true,
1300            line: format!(
1301                "{BOLD}SKILL.md{RST}  {GREEN}installed for {}{RST}",
1302                found.join(", ")
1303            ),
1304        }
1305    }
1306}
1307
1308fn proxy_auth_probe(port: u16) -> bool {
1309    use std::io::{Read, Write};
1310    use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
1311
1312    let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
1313    let token = crate::core::session_token::resolve_proxy_token("LEAN_CTX_PROXY_TOKEN");
1314
1315    let Ok(mut stream) = TcpStream::connect_timeout(&addr, crate::proxy_setup::proxy_timeout())
1316    else {
1317        return false;
1318    };
1319    let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(3)));
1320
1321    let req = format!(
1322        "GET /health HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nAuthorization: Bearer {token}\r\nConnection: close\r\n\r\n"
1323    );
1324    if stream.write_all(req.as_bytes()).is_err() {
1325        return false;
1326    }
1327
1328    let mut buf = [0u8; 128];
1329    let Ok(n) = stream.read(&mut buf) else {
1330        return false;
1331    };
1332    let response = String::from_utf8_lossy(&buf[..n]);
1333    response.contains("200") || response.contains("ok")
1334}
1335
1336fn proxy_health_outcome() -> Outcome {
1337    use crate::core::config::Config;
1338
1339    let cfg = Config::load();
1340    let port = crate::proxy_setup::default_port();
1341
1342    match cfg.proxy_enabled {
1343        Some(true) => {
1344            let installed = crate::proxy_autostart::is_installed();
1345            let reachable = {
1346                use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
1347                let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
1348                TcpStream::connect_timeout(&addr, crate::proxy_setup::proxy_timeout()).is_ok()
1349            };
1350
1351            if installed && reachable {
1352                // Verify auth works: probe /health (no auth needed) to confirm HTTP layer
1353                let auth_ok = proxy_auth_probe(port);
1354                if auth_ok {
1355                    Outcome {
1356                        ok: true,
1357                        line: format!(
1358                            "{BOLD}Proxy{RST}  {GREEN}enabled, running on port {port}{RST}"
1359                        ),
1360                    }
1361                } else {
1362                    Outcome {
1363                        ok: false,
1364                        line: format!(
1365                            "{BOLD}Proxy{RST}  {YELLOW}running on port {port} but auth probe failed{RST}  {YELLOW}fix: lean-ctx proxy restart{RST}"
1366                        ),
1367                    }
1368                }
1369            } else if installed && !reachable {
1370                Outcome {
1371                    ok: false,
1372                    line: format!(
1373                        "{BOLD}Proxy{RST}  {RED}enabled but not reachable on port {port}{RST}  {YELLOW}fix: lean-ctx proxy start{RST}"
1374                    ),
1375                }
1376            } else {
1377                Outcome {
1378                    ok: false,
1379                    line: format!(
1380                        "{BOLD}Proxy{RST}  {RED}enabled but autostart not installed{RST}  {YELLOW}fix: lean-ctx proxy enable{RST}"
1381                    ),
1382                }
1383            }
1384        }
1385        Some(false) => Outcome {
1386            ok: true,
1387            line: format!(
1388                "{BOLD}Proxy{RST}  {DIM}disabled (optional feature){RST}  {DIM}enable: lean-ctx proxy enable{RST}"
1389            ),
1390        },
1391        None => Outcome {
1392            ok: true,
1393            line: format!(
1394                "{BOLD}Proxy{RST}  {DIM}not configured{RST}  {DIM}enable: lean-ctx proxy enable{RST}"
1395            ),
1396        },
1397    }
1398}
1399
1400/// Detects stale `ANTHROPIC_BASE_URL` in Claude Code settings pointing to the local
1401/// lean-ctx proxy when the proxy is not enabled. Returns `None` when no mismatch exists
1402/// (no check needed), `Some(Outcome)` when a stale URL is found.
1403fn stale_proxy_env_outcome() -> Option<Outcome> {
1404    use crate::core::config::Config;
1405
1406    let home = dirs::home_dir()?;
1407    let cfg = Config::load();
1408    let port = crate::proxy_setup::default_port();
1409
1410    if cfg.proxy_enabled == Some(true) {
1411        return None;
1412    }
1413
1414    let settings_dir = crate::core::editor_registry::claude_state_dir(&home);
1415    let settings_path = settings_dir.join("settings.json");
1416    let content = std::fs::read_to_string(&settings_path).ok()?;
1417    let doc: serde_json::Value = serde_json::from_str(&content).ok()?;
1418
1419    let base_url = doc
1420        .get("env")
1421        .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
1422        .and_then(|v| v.as_str())
1423        .unwrap_or("");
1424
1425    if base_url.is_empty() {
1426        return None;
1427    }
1428
1429    let local_proxy = format!("http://127.0.0.1:{port}");
1430    let is_local = base_url == local_proxy
1431        || base_url == format!("http://localhost:{port}")
1432        || base_url.starts_with("http://127.0.0.1:")
1433        || base_url.starts_with("http://localhost:");
1434
1435    if !is_local {
1436        return None;
1437    }
1438
1439    let state = if cfg.proxy_enabled == Some(false) {
1440        "disabled"
1441    } else {
1442        "not configured"
1443    };
1444
1445    Some(Outcome {
1446        ok: false,
1447        line: format!(
1448            "{BOLD}Proxy env{RST}  {RED}ANTHROPIC_BASE_URL → {base_url} but proxy is {state}{RST}\n\
1449             {DIM}         Claude Code routes API traffic to lean-ctx, but lean-ctx proxy is {state}.{RST}\n\
1450             {DIM}         This causes 401 auth failures. Fix:{RST}\n\
1451             {YELLOW}           lean-ctx proxy cleanup    {DIM}(remove stale URL){RST}\n\
1452             {YELLOW}           lean-ctx proxy enable     {DIM}(enable the proxy){RST}"
1453        ),
1454    })
1455}
1456
1457fn proxy_upstream_outcome() -> Outcome {
1458    use crate::core::config::{is_local_proxy_url, Config, ProxyProvider};
1459
1460    let cfg = Config::load();
1461    let checks = [
1462        (
1463            "Anthropic",
1464            "proxy.anthropic_upstream",
1465            cfg.proxy.resolve_upstream(ProxyProvider::Anthropic),
1466        ),
1467        (
1468            "OpenAI",
1469            "proxy.openai_upstream",
1470            cfg.proxy.resolve_upstream(ProxyProvider::OpenAi),
1471        ),
1472        (
1473            "Gemini",
1474            "proxy.gemini_upstream",
1475            cfg.proxy.resolve_upstream(ProxyProvider::Gemini),
1476        ),
1477    ];
1478
1479    let mut custom = Vec::new();
1480    for (label, key, resolved) in &checks {
1481        if is_local_proxy_url(resolved) {
1482            return Outcome {
1483                ok: false,
1484                line: format!(
1485                    "{BOLD}Proxy upstream{RST}  {RED}{label} upstream points back to local proxy{RST}  {YELLOW}run: lean-ctx config set {key} <url>{RST}"
1486                ),
1487            };
1488        }
1489        if !resolved.starts_with("http://") && !resolved.starts_with("https://") {
1490            return Outcome {
1491                ok: false,
1492                line: format!(
1493                    "{BOLD}Proxy upstream{RST}  {RED}invalid {label} upstream{RST}  {YELLOW}set {key} to an http(s) URL{RST}"
1494                ),
1495            };
1496        }
1497        let is_default = matches!(
1498            *label,
1499            "Anthropic" if resolved == "https://api.anthropic.com"
1500        ) || matches!(
1501            *label,
1502            "OpenAI" if resolved == "https://api.openai.com"
1503        ) || matches!(
1504            *label,
1505            "Gemini" if resolved == "https://generativelanguage.googleapis.com"
1506        );
1507        if !is_default {
1508            custom.push(format!("{label}={resolved}"));
1509        }
1510    }
1511
1512    if custom.is_empty() {
1513        Outcome {
1514            ok: true,
1515            line: format!("{BOLD}Proxy upstream{RST}  {GREEN}provider defaults{RST}"),
1516        }
1517    } else {
1518        Outcome {
1519            ok: true,
1520            line: format!(
1521                "{BOLD}Proxy upstream{RST}  {GREEN}custom: {}{RST}",
1522                custom.join(", ")
1523            ),
1524        }
1525    }
1526}
1527
1528fn cache_safety_outcome() -> Outcome {
1529    use crate::core::neural::cache_alignment::CacheAlignedOutput;
1530    use crate::core::provider_cache::ProviderCacheState;
1531
1532    let mut issues = Vec::new();
1533
1534    let mut aligned = CacheAlignedOutput::new();
1535    aligned.add_stable_block("test", "stable content".into(), 1);
1536    aligned.add_variable_block("test_var", "variable content".into(), 1);
1537    let rendered = aligned.render();
1538    if rendered.find("stable content").unwrap_or(usize::MAX)
1539        > rendered.find("variable content").unwrap_or(0)
1540    {
1541        issues.push("cache_alignment: stable blocks not ordered first");
1542    }
1543
1544    let mut state = ProviderCacheState::new();
1545    let section = crate::core::provider_cache::CacheableSection::new(
1546        "doctor_test",
1547        "test content".into(),
1548        crate::core::provider_cache::SectionPriority::System,
1549        true,
1550    );
1551    state.mark_sent(&section);
1552    if state.needs_update(&section) {
1553        issues.push("provider_cache: hash tracking broken");
1554    }
1555
1556    if issues.is_empty() {
1557        Outcome {
1558            ok: true,
1559            line: format!(
1560                "{BOLD}Cache safety{RST}  {GREEN}cache_alignment + provider_cache operational{RST}"
1561            ),
1562        }
1563    } else {
1564        Outcome {
1565            ok: false,
1566            line: format!("{BOLD}Cache safety{RST}  {RED}{}{RST}", issues.join("; ")),
1567        }
1568    }
1569}
1570
1571pub(super) fn claude_binary_exists() -> bool {
1572    #[cfg(unix)]
1573    {
1574        std::process::Command::new("which")
1575            .arg("claude")
1576            .output()
1577            .is_ok_and(|o| o.status.success())
1578    }
1579    #[cfg(windows)]
1580    {
1581        std::process::Command::new("where")
1582            .arg("claude")
1583            .output()
1584            .is_ok_and(|o| o.status.success())
1585    }
1586}
1587
1588fn claude_truncation_outcome() -> Option<Outcome> {
1589    let home = dirs::home_dir()?;
1590    let claude_detected = crate::core::editor_registry::claude_mcp_json_path(&home).exists()
1591        || crate::core::editor_registry::claude_state_dir(&home).exists()
1592        || claude_binary_exists();
1593
1594    if !claude_detected {
1595        return None;
1596    }
1597
1598    let rules_path = crate::core::editor_registry::claude_rules_dir(&home).join("lean-ctx.md");
1599    let skill_path = home.join(".claude/skills/lean-ctx/SKILL.md");
1600
1601    let has_rules = rules_path.exists();
1602    let has_skill = skill_path.exists();
1603
1604    if has_rules && has_skill {
1605        Some(Outcome {
1606            ok: true,
1607            line: format!(
1608                "{BOLD}Claude Code instructions{RST}  {GREEN}rules + skill installed{RST}  {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
1609            ),
1610        })
1611    } else if has_rules {
1612        Some(Outcome {
1613            ok: true,
1614            line: format!(
1615                "{BOLD}Claude Code instructions{RST}  {GREEN}rules file installed{RST}  {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
1616            ),
1617        })
1618    } else {
1619        Some(Outcome {
1620            ok: false,
1621            line: format!(
1622                "{BOLD}Claude Code instructions{RST}  {YELLOW}MCP instructions truncated at 2048 chars, no rules file found{RST}  {DIM}(run: lean-ctx init --agent claude){RST}"
1623            ),
1624        })
1625    }
1626}
1627
1628fn bm25_cache_health_outcome() -> Outcome {
1629    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
1630        return Outcome {
1631            ok: true,
1632            line: format!("{BOLD}BM25 cache{RST}  {DIM}skipped (no data dir){RST}"),
1633        };
1634    };
1635
1636    let vectors_dir = data_dir.join("vectors");
1637    let Ok(entries) = std::fs::read_dir(&vectors_dir) else {
1638        return Outcome {
1639            ok: true,
1640            line: format!("{BOLD}BM25 cache{RST}  {GREEN}no vector dirs{RST}"),
1641        };
1642    };
1643
1644    let cfg = crate::core::config::Config::load();
1645    let profile = crate::core::config::MemoryProfile::effective(&cfg);
1646    let effective_mb = if cfg.bm25_max_cache_mb == crate::core::config::default_bm25_max_cache_mb()
1647    {
1648        profile.bm25_max_cache_mb()
1649    } else {
1650        cfg.bm25_max_cache_mb
1651    };
1652    let max_bytes = effective_mb * 1024 * 1024;
1653    let warn_bytes = max_bytes * 80 / 100; // 80% of effective limit
1654    let mut total_dirs = 0u32;
1655    let mut total_bytes = 0u64;
1656    let mut oversized: Vec<(String, u64)> = Vec::new();
1657    let mut warnings: Vec<(String, u64)> = Vec::new();
1658    let mut quarantined_count = 0u32;
1659
1660    for entry in entries.flatten() {
1661        let dir = entry.path();
1662        if !dir.is_dir() {
1663            continue;
1664        }
1665        total_dirs += 1;
1666
1667        if dir.join("bm25_index.json.quarantined").exists()
1668            || dir.join("bm25_index.bin.quarantined").exists()
1669            || dir.join("bm25_index.bin.zst.quarantined").exists()
1670        {
1671            quarantined_count += 1;
1672        }
1673
1674        let index_path = if dir.join("bm25_index.bin.zst").exists() {
1675            dir.join("bm25_index.bin.zst")
1676        } else if dir.join("bm25_index.bin").exists() {
1677            dir.join("bm25_index.bin")
1678        } else {
1679            dir.join("bm25_index.json")
1680        };
1681        if let Ok(meta) = std::fs::metadata(&index_path) {
1682            let size = meta.len();
1683            total_bytes += size;
1684            let display = index_path.display().to_string();
1685            if size > max_bytes {
1686                oversized.push((display, size));
1687            } else if size > warn_bytes {
1688                warnings.push((display, size));
1689            }
1690        }
1691    }
1692
1693    if !oversized.is_empty() {
1694        let details: Vec<String> = oversized
1695            .iter()
1696            .map(|(p, s)| format!("{p} ({:.1} GB)", *s as f64 / 1_073_741_824.0))
1697            .collect();
1698        return Outcome {
1699            ok: false,
1700            line: format!(
1701                "{BOLD}BM25 cache{RST}  {RED}{} index(es) exceed limit ({:.0} MB){RST}: {}  {DIM}(run: lean-ctx cache prune){RST}",
1702                oversized.len(),
1703                max_bytes / (1024 * 1024),
1704                details.join(", ")
1705            ),
1706        };
1707    }
1708
1709    if !warnings.is_empty() {
1710        let details: Vec<String> = warnings
1711            .iter()
1712            .map(|(p, s)| format!("{p} ({:.0} MB)", *s as f64 / 1_048_576.0))
1713            .collect();
1714        return Outcome {
1715            ok: true,
1716            line: format!(
1717                "{BOLD}BM25 cache{RST}  {YELLOW}{} index(es) >80% of {effective_mb} MB limit{RST}: {}  {DIM}(consider extra_ignore_patterns){RST}",
1718                warnings.len(),
1719                details.join(", ")
1720            ),
1721        };
1722    }
1723
1724    let quarantine_note = if quarantined_count > 0 {
1725        format!("  {YELLOW}{quarantined_count} quarantined (run: lean-ctx cache prune){RST}")
1726    } else {
1727        String::new()
1728    };
1729
1730    Outcome {
1731        ok: true,
1732        line: format!(
1733            "{BOLD}BM25 cache{RST}  {GREEN}{total_dirs} index(es), {:.1} MB total{RST}{quarantine_note}",
1734            total_bytes as f64 / 1_048_576.0
1735        ),
1736    }
1737}
1738
1739pub fn run_compact() {
1740    let (passed, total) = compact_score();
1741    print_compact_status(passed, total);
1742}
1743
1744pub fn run_cli(args: &[String]) -> i32 {
1745    let (sub, rest) = match args.first().map(String::as_str) {
1746        Some("integrations") => ("integrations", &args[1..]),
1747        _ => ("", args),
1748    };
1749
1750    let fix = rest.iter().any(|a| a == "--fix");
1751    let json = rest.iter().any(|a| a == "--json");
1752    let help = rest.iter().any(|a| a == "--help" || a == "-h");
1753
1754    if help {
1755        println!("Usage:");
1756        println!("  lean-ctx doctor");
1757        println!("  lean-ctx doctor integrations [--json]");
1758        println!("  lean-ctx doctor --fix [--json]");
1759        return 0;
1760    }
1761
1762    if sub == "integrations" {
1763        if fix {
1764            let _ = fix::run_fix(&fix::DoctorFixOptions { json: false });
1765        }
1766        return integrations::run_integrations(&integrations::IntegrationsOptions { json });
1767    }
1768
1769    if !fix {
1770        run();
1771        return 0;
1772    }
1773
1774    match fix::run_fix(&fix::DoctorFixOptions { json }) {
1775        Ok(code) => code,
1776        Err(e) => {
1777            tracing::error!("doctor --fix failed: {e}");
1778            2
1779        }
1780    }
1781}
1782
1783pub fn compact_score() -> (u32, u32) {
1784    let mut passed = 0u32;
1785    let total = 6u32;
1786
1787    if resolve_lean_ctx_binary().is_some() || path_in_path_env() {
1788        passed += 1;
1789    }
1790    let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
1791    if lean_dir.as_ref().is_some_and(|p| p.is_dir()) {
1792        passed += 1;
1793    }
1794    if lean_dir
1795        .as_ref()
1796        .map(|d| d.join("stats.json"))
1797        .and_then(|p| std::fs::metadata(p).ok())
1798        .is_some_and(|m| m.is_file())
1799    {
1800        passed += 1;
1801    }
1802    if shell_aliases_outcome().ok {
1803        passed += 1;
1804    }
1805    if mcp_config_outcome().ok {
1806        passed += 1;
1807    }
1808    if skill_files_outcome().ok {
1809        passed += 1;
1810    }
1811
1812    (passed, total)
1813}
1814
1815pub(super) fn print_compact_status(passed: u32, total: u32) {
1816    let status = if passed == total {
1817        format!("{GREEN}✓ All {total} checks passed{RST}")
1818    } else {
1819        format!("{YELLOW}{passed}/{total} passed{RST} — run {BOLD}lean-ctx doctor{RST} for details")
1820    };
1821    println!("  {status}");
1822}
1823
1824fn memory_profile_outcome() -> Outcome {
1825    let cfg = crate::core::config::Config::load();
1826    let profile = crate::core::config::MemoryProfile::effective(&cfg);
1827    let (label, detail) = match profile {
1828        crate::core::config::MemoryProfile::Low => {
1829            ("low", "embeddings+semantic cache disabled, BM25 64 MB")
1830        }
1831        crate::core::config::MemoryProfile::Balanced => {
1832            ("balanced", "default — BM25 128 MB, single embedding engine")
1833        }
1834        crate::core::config::MemoryProfile::Performance => {
1835            ("performance", "full caches, BM25 512 MB")
1836        }
1837    };
1838    let source = if crate::core::config::MemoryProfile::from_env().is_some() {
1839        "env"
1840    } else if cfg.memory_profile != crate::core::config::MemoryProfile::default() {
1841        "config"
1842    } else {
1843        "default"
1844    };
1845    Outcome {
1846        ok: true,
1847        line: format!(
1848            "{BOLD}Memory profile{RST}  {GREEN}{label}{RST}  {DIM}({source}: {detail}){RST}"
1849        ),
1850    }
1851}
1852
1853fn memory_cleanup_outcome() -> Outcome {
1854    let cfg = crate::core::config::Config::load();
1855    let cleanup = crate::core::config::MemoryCleanup::effective(&cfg);
1856    let (label, detail) = match cleanup {
1857        crate::core::config::MemoryCleanup::Aggressive => (
1858            "aggressive",
1859            "cache cleared after 5 min idle, single-IDE optimized",
1860        ),
1861        crate::core::config::MemoryCleanup::Shared => (
1862            "shared",
1863            "cache retained 30 min, multi-IDE/multi-model optimized",
1864        ),
1865    };
1866    let source = if crate::core::config::MemoryCleanup::from_env().is_some() {
1867        "env"
1868    } else if cfg.memory_cleanup != crate::core::config::MemoryCleanup::default() {
1869        "config"
1870    } else {
1871        "default"
1872    };
1873    Outcome {
1874        ok: true,
1875        line: format!(
1876            "{BOLD}Memory cleanup{RST}  {GREEN}{label}{RST}  {DIM}({source}: {detail}){RST}"
1877        ),
1878    }
1879}
1880
1881fn ram_guardian_outcome() -> Outcome {
1882    let Some(snap) = crate::core::memory_guard::MemorySnapshot::capture() else {
1883        return Outcome {
1884            ok: true,
1885            line: format!(
1886                "{BOLD}RAM Guardian{RST}  {YELLOW}not available{RST}  {DIM}(platform unsupported){RST}"
1887            ),
1888        };
1889    };
1890    let allocator = if cfg!(all(feature = "jemalloc", not(windows))) {
1891        "jemalloc"
1892    } else {
1893        "system"
1894    };
1895    let ok = snap.pressure_level == crate::core::memory_guard::PressureLevel::Normal;
1896    let color = if ok { GREEN } else { RED };
1897    let pressure_hint = match snap.pressure_level {
1898        crate::core::memory_guard::PressureLevel::Normal => String::new(),
1899        level => {
1900            format!(
1901                "  {YELLOW}pressure={level:?} — consider: memory_profile=\"low\" or increase max_ram_percent{RST}"
1902            )
1903        }
1904    };
1905    Outcome {
1906        ok,
1907        line: format!(
1908            "{BOLD}RAM Guardian{RST}  {color}{:.0} MB{RST} / {:.1} GB system ({:.1}%)  {DIM}limit: {:.0} MB ({allocator}){RST}{pressure_hint}",
1909            snap.rss_bytes as f64 / 1_048_576.0,
1910            snap.system_ram_bytes as f64 / 1_073_741_824.0,
1911            snap.rss_percent,
1912            snap.rss_limit_bytes as f64 / 1_048_576.0,
1913        ),
1914    }
1915}
1916
1917fn capacity_warnings() -> Vec<Outcome> {
1918    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
1919        return vec![];
1920    };
1921
1922    let cfg = crate::core::config::Config::load();
1923    let policy = cfg.memory_policy_effective().unwrap_or_default();
1924
1925    let knowledge_dir = data_dir.join("knowledge");
1926    let Ok(entries) = std::fs::read_dir(&knowledge_dir) else {
1927        return vec![Outcome {
1928            ok: true,
1929            line: format!("{BOLD}Capacity{RST} {GREEN}no memory stores{RST}"),
1930        }];
1931    };
1932
1933    let mut results = Vec::new();
1934
1935    for entry in entries.flatten() {
1936        let hash_dir = entry.path();
1937        if !hash_dir.is_dir() {
1938            continue;
1939        }
1940        let hash = hash_dir
1941            .file_name()
1942            .unwrap_or_default()
1943            .to_string_lossy()
1944            .to_string();
1945        let short_hash = &hash[..hash.len().min(8)];
1946
1947        let mut checks: Vec<(String, usize, usize)> = Vec::new();
1948
1949        if let Ok(content) = std::fs::read_to_string(hash_dir.join("knowledge.json")) {
1950            if let Ok(k) =
1951                serde_json::from_str::<crate::core::knowledge::ProjectKnowledge>(&content)
1952            {
1953                checks.push((
1954                    "facts".to_string(),
1955                    k.facts.len(),
1956                    policy.knowledge.max_facts,
1957                ));
1958                checks.push((
1959                    "patterns".to_string(),
1960                    k.patterns.len(),
1961                    policy.knowledge.max_patterns,
1962                ));
1963                checks.push((
1964                    "history".to_string(),
1965                    k.history.len(),
1966                    policy.knowledge.max_history,
1967                ));
1968            }
1969        }
1970
1971        if let Ok(content) = std::fs::read_to_string(hash_dir.join("embeddings.json")) {
1972            if let Ok(idx) = serde_json::from_str::<
1973                crate::core::knowledge_embedding::KnowledgeEmbeddingIndex,
1974            >(&content)
1975            {
1976                checks.push((
1977                    "embeddings".to_string(),
1978                    idx.entries.len(),
1979                    policy.embeddings.max_facts,
1980                ));
1981            }
1982        }
1983
1984        if let Ok(content) = std::fs::read_to_string(hash_dir.join("gotchas.json")) {
1985            if let Ok(g) =
1986                serde_json::from_str::<crate::core::gotcha_tracker::GotchaStore>(&content)
1987            {
1988                checks.push((
1989                    "gotchas".to_string(),
1990                    g.gotchas.len(),
1991                    policy.gotcha.max_gotchas_per_project,
1992                ));
1993            }
1994        }
1995
1996        let episodes_path = data_dir
1997            .join("memory")
1998            .join("episodes")
1999            .join(format!("{hash}.json"));
2000        if let Ok(content) = std::fs::read_to_string(&episodes_path) {
2001            if let Ok(e) =
2002                serde_json::from_str::<crate::core::episodic_memory::EpisodicStore>(&content)
2003            {
2004                checks.push((
2005                    "episodes".to_string(),
2006                    e.episodes.len(),
2007                    policy.episodic.max_episodes,
2008                ));
2009            }
2010        }
2011
2012        let procedures_path = data_dir
2013            .join("memory")
2014            .join("procedures")
2015            .join(format!("{hash}.json"));
2016        if let Ok(content) = std::fs::read_to_string(&procedures_path) {
2017            if let Ok(p) =
2018                serde_json::from_str::<crate::core::procedural_memory::ProceduralStore>(&content)
2019            {
2020                checks.push((
2021                    "procedures".to_string(),
2022                    p.procedures.len(),
2023                    policy.procedural.max_procedures,
2024                ));
2025            }
2026        }
2027
2028        let mut warnings: Vec<String> = Vec::new();
2029        let mut critical = false;
2030
2031        for (name, current, limit) in &checks {
2032            if *limit == 0 {
2033                continue;
2034            }
2035            let pct = (*current as f64 / *limit as f64 * 100.0) as u32;
2036            if pct >= 95 {
2037                critical = true;
2038                warnings.push(format!("{name}: {current}/{limit} ({pct}%)"));
2039            } else if pct >= 80 {
2040                warnings.push(format!("{name}: {current}/{limit} ({pct}%)"));
2041            }
2042        }
2043
2044        if !warnings.is_empty() {
2045            let color = if critical { RED } else { YELLOW };
2046            let label = if critical { "CRIT" } else { "WARN" };
2047            results.push(Outcome {
2048                ok: !critical,
2049                line: format!(
2050                    "{BOLD}Capacity [{short_hash}]{RST} {color}{label}: {}{RST}",
2051                    warnings.join(", ")
2052                ),
2053            });
2054        }
2055    }
2056
2057    // Global checks (not per project hash)
2058
2059    // Archive disk usage vs limit
2060    let archive_limit_bytes = cfg.archive_max_disk_mb_effective() * 1_048_576;
2061    if archive_limit_bytes > 0 {
2062        let archive_used = crate::core::archive::disk_usage_bytes();
2063        let pct = (archive_used as f64 / archive_limit_bytes as f64 * 100.0) as u32;
2064        if pct >= 95 {
2065            results.push(Outcome {
2066                ok: false,
2067                line: format!(
2068                    "{BOLD}Capacity [archive]{RST} {RED}CRIT: disk {}/{}MB ({pct}%){RST}",
2069                    archive_used / 1_048_576,
2070                    archive_limit_bytes / 1_048_576
2071                ),
2072            });
2073        } else if pct >= 80 {
2074            results.push(Outcome {
2075                ok: true,
2076                line: format!(
2077                    "{BOLD}Capacity [archive]{RST} {YELLOW}WARN: disk {}/{}MB ({pct}%){RST}",
2078                    archive_used / 1_048_576,
2079                    archive_limit_bytes / 1_048_576
2080                ),
2081            });
2082        }
2083    }
2084
2085    // Graph index file count vs limit
2086    let graph_max_files = cfg.graph_index_max_files;
2087    if graph_max_files > 0 {
2088        if let Some(session) = crate::core::session::SessionState::load_latest() {
2089            if let Some(ref project_root) = session.project_root {
2090                let disk_status = crate::core::index_orchestrator::disk_status(project_root);
2091                if let Some(graph_files) = disk_status.graph_index.file_count {
2092                    let pct = (graph_files as f64 / graph_max_files as f64 * 100.0) as u32;
2093                    if pct >= 95 {
2094                        results.push(Outcome {
2095                            ok: false,
2096                            line: format!(
2097                                "{BOLD}Capacity [graph]{RST} {RED}CRIT: files {graph_files}/{graph_max_files} ({pct}%){RST}"
2098                            ),
2099                        });
2100                    } else if pct >= 80 {
2101                        results.push(Outcome {
2102                            ok: true,
2103                            line: format!(
2104                                "{BOLD}Capacity [graph]{RST} {YELLOW}WARN: files {graph_files}/{graph_max_files} ({pct}%){RST}"
2105                            ),
2106                        });
2107                    }
2108                }
2109            }
2110        }
2111    }
2112
2113    if results.is_empty() {
2114        results.push(Outcome {
2115            ok: true,
2116            line: format!("{BOLD}Capacity{RST} {GREEN}all stores within limits{RST}"),
2117        });
2118    }
2119
2120    results
2121}
2122
2123fn lsp_server_outcomes() -> Vec<Outcome> {
2124    use crate::lsp::config::{find_binary_in_path, KNOWN_SERVERS};
2125
2126    KNOWN_SERVERS
2127        .iter()
2128        .map(|info| {
2129            let found = find_binary_in_path(info.binary);
2130            match found {
2131                Some(path) => Outcome {
2132                    ok: true,
2133                    line: format!(
2134                        "{BOLD}{}{RST}  {GREEN}✓ {}{RST}  {DIM}{}{RST}",
2135                        info.language,
2136                        info.binary,
2137                        path.display()
2138                    ),
2139                },
2140                None => Outcome {
2141                    ok: false,
2142                    line: format!(
2143                        "{BOLD}{}{RST}  {DIM}not installed{RST}  {YELLOW}{}{RST}",
2144                        info.language, info.install_hint
2145                    ),
2146                },
2147            }
2148        })
2149        .collect()
2150}
2151
2152#[cfg(test)]
2153mod tests {
2154    use super::is_active_shell_impl;
2155
2156    fn make_capacity_check(name: &str, current: usize, limit: usize) -> Option<(bool, String)> {
2157        if limit == 0 {
2158            return None;
2159        }
2160        let pct = (current as f64 / limit as f64 * 100.0) as u32;
2161        if pct >= 95 {
2162            Some((true, format!("{name}: {current}/{limit} ({pct}%)")))
2163        } else if pct >= 80 {
2164            Some((false, format!("{name}: {current}/{limit} ({pct}%)")))
2165        } else {
2166            None
2167        }
2168    }
2169
2170    #[test]
2171    fn capacity_below_80_no_warning() {
2172        assert!(make_capacity_check("facts", 100, 200).is_none());
2173        assert!(make_capacity_check("facts", 159, 200).is_none());
2174    }
2175
2176    #[test]
2177    fn capacity_at_80_yellow_warning() {
2178        let result = make_capacity_check("facts", 160, 200);
2179        assert!(result.is_some());
2180        let (critical, msg) = result.unwrap();
2181        assert!(!critical);
2182        assert!(msg.contains("160/200"));
2183        assert!(msg.contains("80%"));
2184    }
2185
2186    #[test]
2187    fn capacity_at_92_yellow_warning() {
2188        let result = make_capacity_check("facts", 185, 200);
2189        assert!(result.is_some());
2190        let (critical, msg) = result.unwrap();
2191        assert!(!critical);
2192        assert!(msg.contains("185/200"));
2193        assert!(msg.contains("92%"));
2194    }
2195
2196    #[test]
2197    fn capacity_at_95_critical() {
2198        let result = make_capacity_check("facts", 190, 200);
2199        assert!(result.is_some());
2200        let (critical, msg) = result.unwrap();
2201        assert!(critical);
2202        assert!(msg.contains("190/200"));
2203        assert!(msg.contains("95%"));
2204    }
2205
2206    #[test]
2207    fn capacity_at_100_critical() {
2208        let result = make_capacity_check("facts", 200, 200);
2209        assert!(result.is_some());
2210        let (critical, _) = result.unwrap();
2211        assert!(critical);
2212    }
2213
2214    #[test]
2215    fn capacity_zero_limit_skipped() {
2216        assert!(make_capacity_check("facts", 50, 0).is_none());
2217    }
2218
2219    #[test]
2220    fn bashrc_active_on_non_windows_when_shell_empty() {
2221        assert!(is_active_shell_impl("~/.bashrc", "", false, false));
2222    }
2223
2224    #[test]
2225    fn bashrc_not_active_on_windows_when_shell_empty() {
2226        assert!(!is_active_shell_impl("~/.bashrc", "", true, false));
2227    }
2228
2229    #[test]
2230    fn bashrc_active_when_shell_contains_bash_on_linux() {
2231        assert!(is_active_shell_impl(
2232            "~/.bashrc",
2233            "/usr/bin/bash",
2234            false,
2235            false
2236        ));
2237    }
2238
2239    #[test]
2240    fn bashrc_not_active_on_windows_even_with_bash_in_shell_env() {
2241        // Issue #214: On Windows, Git Bash sets $SHELL globally to bash.exe.
2242        // .bashrc should NOT be flagged on Windows unless actually inside bash.
2243        std::env::remove_var("BASH_VERSION");
2244        assert!(!is_active_shell_impl(
2245            "~/.bashrc",
2246            "C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe",
2247            true,
2248            false,
2249        ));
2250    }
2251
2252    #[test]
2253    fn bashrc_not_active_on_windows_powershell_even_with_bash_in_shell() {
2254        assert!(!is_active_shell_impl(
2255            "~/.bashrc",
2256            "C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe",
2257            true,
2258            true,
2259        ));
2260    }
2261
2262    #[test]
2263    fn bashrc_not_active_on_windows_powershell_with_empty_shell() {
2264        assert!(!is_active_shell_impl("~/.bashrc", "", true, true));
2265    }
2266
2267    #[test]
2268    fn zshrc_unaffected_by_powershell_flag() {
2269        assert!(is_active_shell_impl("~/.zshrc", "/bin/zsh", false, false));
2270        assert!(is_active_shell_impl("~/.zshrc", "/bin/zsh", true, true));
2271    }
2272
2273    #[test]
2274    fn bashrc_not_active_on_windows_without_powershell_detection() {
2275        // Windows + $SHELL=bash but NOT in actual bash session (no BASH_VERSION).
2276        // This is the exact scenario from issue #214: Git Bash sets $SHELL globally.
2277        std::env::remove_var("BASH_VERSION");
2278        assert!(!is_active_shell_impl(
2279            "~/.bashrc",
2280            "/usr/bin/bash",
2281            true,
2282            false,
2283        ));
2284    }
2285
2286    #[test]
2287    fn bashrc_active_on_linux() {
2288        assert!(is_active_shell_impl("~/.bashrc", "/bin/bash", false, false));
2289        assert!(is_active_shell_impl("~/.bashrc", "", false, false));
2290    }
2291}