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 / Copilot",
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 / Copilot",
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 / Copilot",
430                display: "%APPDATA%/Code/User/mcp.json".into(),
431                path: vscode_mcp,
432            });
433        }
434    }
435
436    locations.push(McpLocation {
437        name: "Hermes Agent",
438        display: "~/.hermes/config.yaml".into(),
439        path: home.join(".hermes").join("config.yaml"),
440    });
441
442    {
443        let cline_path = crate::core::editor_registry::cline_mcp_path();
444        if cline_path.to_str().is_some_and(|s| s != "/nonexistent") {
445            locations.push(McpLocation {
446                name: "Cline",
447                display: cline_path.display().to_string(),
448                path: cline_path,
449            });
450        }
451    }
452    {
453        let roo_path = crate::core::editor_registry::roo_mcp_path();
454        if roo_path.to_str().is_some_and(|s| s != "/nonexistent") {
455            locations.push(McpLocation {
456                name: "Roo Code",
457                display: roo_path.display().to_string(),
458                path: roo_path,
459            });
460        }
461    }
462
463    locations
464}
465
466fn mcp_config_outcome() -> Outcome {
467    let Some(home) = dirs::home_dir() else {
468        return Outcome {
469            ok: false,
470            line: format!("{BOLD}MCP config{RST}  {RED}could not resolve home directory{RST}"),
471        };
472    };
473
474    let locations = mcp_config_locations(&home);
475    let mut found: Vec<String> = Vec::new();
476    let mut exists_no_ref: Vec<String> = Vec::new();
477
478    for loc in &locations {
479        if let Ok(content) = std::fs::read_to_string(&loc.path) {
480            if has_lean_ctx_mcp_entry(&content) {
481                found.push(format!("{} {DIM}({}){RST}", loc.name, loc.display));
482            } else {
483                exists_no_ref.push(loc.name.to_string());
484            }
485        }
486    }
487
488    found.sort();
489    found.dedup();
490    exists_no_ref.sort();
491    exists_no_ref.dedup();
492
493    if !found.is_empty() {
494        Outcome {
495            ok: true,
496            line: format!(
497                "{BOLD}MCP config{RST}  {GREEN}lean-ctx found in: {}{RST}",
498                found.join(", ")
499            ),
500        }
501    } else if !exists_no_ref.is_empty() {
502        let has_claude = exists_no_ref.iter().any(|n| n.starts_with("Claude Code"));
503        let cause = if has_claude {
504            format!("{DIM}(Claude Code may overwrite ~/.claude.json on startup — lean-ctx entry missing from mcpServers){RST}")
505        } else {
506            String::new()
507        };
508        let hint = if has_claude {
509            format!("{DIM}(run: lean-ctx doctor --fix OR lean-ctx init --agent claude){RST}")
510        } else {
511            format!("{DIM}(run: lean-ctx doctor --fix OR lean-ctx setup){RST}")
512        };
513        Outcome {
514            ok: false,
515            line: format!(
516                "{BOLD}MCP config{RST}  {YELLOW}config exists for {} but mcpServers does not contain lean-ctx{RST}  {cause} {hint}",
517                exists_no_ref.join(", "),
518            ),
519        }
520    } else {
521        Outcome {
522            ok: false,
523            line: format!(
524                "{BOLD}MCP config{RST}  {YELLOW}no MCP config found{RST}  {DIM}(run: lean-ctx setup){RST}"
525            ),
526        }
527    }
528}
529
530fn has_lean_ctx_mcp_entry(content: &str) -> bool {
531    if let Ok(json) = serde_json::from_str::<serde_json::Value>(content) {
532        if let Some(servers) = json.get("mcpServers").and_then(|v| v.as_object()) {
533            return servers.contains_key("lean-ctx");
534        }
535        if let Some(servers) = json
536            .get("mcp")
537            .and_then(|v| v.get("servers"))
538            .and_then(|v| v.as_object())
539        {
540            return servers.contains_key("lean-ctx");
541        }
542    }
543    content.contains("lean-ctx")
544}
545
546fn port_3333_outcome() -> Outcome {
547    match TcpListener::bind("127.0.0.1:3333") {
548        Ok(_listener) => Outcome {
549            ok: true,
550            line: format!("{BOLD}Dashboard port 3333{RST}  {GREEN}available on 127.0.0.1{RST}"),
551        },
552        Err(e) => Outcome {
553            ok: false,
554            line: format!("{BOLD}Dashboard port 3333{RST}  {RED}not available: {e}{RST}"),
555        },
556    }
557}
558
559fn pi_outcome() -> Option<Outcome> {
560    let pi_result = std::process::Command::new("pi").arg("--version").output();
561
562    match pi_result {
563        Ok(output) if output.status.success() => {
564            let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
565            let has_plugin = std::process::Command::new("pi")
566                .args(["list"])
567                .output()
568                .is_ok_and(|o| {
569                    o.status.success() && String::from_utf8_lossy(&o.stdout).contains("pi-lean-ctx")
570                });
571
572            let has_mcp = dirs::home_dir()
573                .map(|h| h.join(".pi/agent/mcp.json"))
574                .and_then(|p| std::fs::read_to_string(p).ok())
575                .is_some_and(|c| c.contains("lean-ctx"));
576
577            if has_plugin && has_mcp {
578                Some(Outcome {
579                    ok: true,
580                    line: format!(
581                        "{BOLD}Pi Coding Agent{RST}  {GREEN}{version}, pi-lean-ctx + MCP configured{RST}"
582                    ),
583                })
584            } else if has_plugin {
585                Some(Outcome {
586                    ok: true,
587                    line: format!(
588                        "{BOLD}Pi Coding Agent{RST}  {GREEN}{version}, pi-lean-ctx installed{RST}  {DIM}(MCP not configured — embedded bridge active){RST}"
589                    ),
590                })
591            } else {
592                Some(Outcome {
593                    ok: false,
594                    line: format!(
595                        "{BOLD}Pi Coding Agent{RST}  {YELLOW}{version}, but pi-lean-ctx not installed{RST}  {DIM}(run: pi install npm:pi-lean-ctx){RST}"
596                    ),
597                })
598            }
599        }
600        _ => None,
601    }
602}
603
604fn session_state_outcome() -> Outcome {
605    use crate::core::session::SessionState;
606
607    match SessionState::load_latest() {
608        Some(session) => {
609            let root = session
610                .project_root
611                .as_deref()
612                .unwrap_or("(not set)");
613            let cwd = session
614                .shell_cwd
615                .as_deref()
616                .unwrap_or("(not tracked)");
617            Outcome {
618                ok: true,
619                line: format!(
620                    "{BOLD}Session state{RST}  {GREEN}active{RST}  {DIM}root: {root}, cwd: {cwd}, v{}{RST}",
621                    session.version
622                ),
623            }
624        }
625        None => Outcome {
626            ok: true,
627            line: format!(
628                "{BOLD}Session state{RST}  {YELLOW}no active session{RST}  {DIM}(will be created on first tool call){RST}"
629            ),
630        },
631    }
632}
633
634fn docker_env_outcomes() -> Vec<Outcome> {
635    if !crate::shell::is_container() {
636        return vec![];
637    }
638    let env_sh = crate::core::data_dir::lean_ctx_data_dir().map_or_else(
639        |_| "/root/.lean-ctx/env.sh".to_string(),
640        |d| d.join("env.sh").to_string_lossy().to_string(),
641    );
642
643    let mut outcomes = vec![];
644
645    let shell_name = std::env::var("SHELL").unwrap_or_default();
646    let is_bash = shell_name.contains("bash") || shell_name.is_empty();
647
648    if is_bash {
649        let has_bash_env = std::env::var("BASH_ENV").is_ok();
650        outcomes.push(if has_bash_env {
651            Outcome {
652                ok: true,
653                line: format!(
654                    "{BOLD}BASH_ENV{RST}  {GREEN}set{RST}  {DIM}({}){RST}",
655                    std::env::var("BASH_ENV").unwrap_or_default()
656                ),
657            }
658        } else {
659            Outcome {
660                ok: false,
661                line: format!(
662                    "{BOLD}BASH_ENV{RST}  {RED}not set{RST}  {YELLOW}(add to Dockerfile: ENV BASH_ENV=\"{env_sh}\"){RST}"
663                ),
664            }
665        });
666    }
667
668    let has_claude_env = std::env::var("CLAUDE_ENV_FILE").is_ok();
669    outcomes.push(if has_claude_env {
670        Outcome {
671            ok: true,
672            line: format!(
673                "{BOLD}CLAUDE_ENV_FILE{RST}  {GREEN}set{RST}  {DIM}({}){RST}",
674                std::env::var("CLAUDE_ENV_FILE").unwrap_or_default()
675            ),
676        }
677    } else {
678        Outcome {
679            ok: false,
680            line: format!(
681                "{BOLD}CLAUDE_ENV_FILE{RST}  {RED}not set{RST}  {YELLOW}(for Claude Code: ENV CLAUDE_ENV_FILE=\"{env_sh}\"){RST}"
682            ),
683        }
684    });
685
686    outcomes
687}
688
689/// Run diagnostic checks and print colored results to stdout.
690pub fn run() {
691    let mut passed = 0u32;
692    let total = 10u32;
693
694    println!("{BOLD}{WHITE}lean-ctx doctor{RST}  {DIM}diagnostics{RST}\n");
695
696    // 1) Binary on PATH
697    let path_bin = resolve_lean_ctx_binary();
698    let also_in_path_dirs = path_in_path_env();
699    let bin_ok = path_bin.is_some() || also_in_path_dirs;
700    if bin_ok {
701        passed += 1;
702    }
703    let bin_line = if let Some(p) = path_bin {
704        format!("{BOLD}lean-ctx in PATH{RST}  {WHITE}{}{RST}", p.display())
705    } else if also_in_path_dirs {
706        format!(
707            "{BOLD}lean-ctx in PATH{RST}  {YELLOW}found via PATH walk (not resolved by `command -v`){RST}"
708        )
709    } else {
710        format!("{BOLD}lean-ctx in PATH{RST}  {RED}not found{RST}")
711    };
712    print_check(&Outcome {
713        ok: bin_ok,
714        line: bin_line,
715    });
716
717    // 2) Version from PATH binary
718    let ver = if bin_ok {
719        lean_ctx_version_from_path()
720    } else {
721        Outcome {
722            ok: false,
723            line: format!("{BOLD}lean-ctx version{RST}  {RED}skipped (binary not in PATH){RST}"),
724        }
725    };
726    if ver.ok {
727        passed += 1;
728    }
729    print_check(&ver);
730
731    // 3) data directory (respects LEAN_CTX_DATA_DIR)
732    let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
733    let dir_outcome = match &lean_dir {
734        Some(p) if p.is_dir() => {
735            passed += 1;
736            Outcome {
737                ok: true,
738                line: format!(
739                    "{BOLD}data dir{RST}  {GREEN}exists{RST}  {DIM}{}{RST}",
740                    p.display()
741                ),
742            }
743        }
744        Some(p) => Outcome {
745            ok: false,
746            line: format!(
747                "{BOLD}data dir{RST}  {RED}missing or not a directory{RST}  {DIM}{}{RST}",
748                p.display()
749            ),
750        },
751        None => Outcome {
752            ok: false,
753            line: format!("{BOLD}data dir{RST}  {RED}could not resolve data directory{RST}"),
754        },
755    };
756    print_check(&dir_outcome);
757
758    // 4) stats.json + size
759    let stats_path = lean_dir.as_ref().map(|d| d.join("stats.json"));
760    let stats_outcome = match stats_path.as_ref().and_then(|p| std::fs::metadata(p).ok()) {
761        Some(m) if m.is_file() => {
762            passed += 1;
763            let size = m.len();
764            let path_display = if let Some(p) = stats_path.as_ref() {
765                p.display().to_string()
766            } else {
767                String::new()
768            };
769            Outcome {
770                ok: true,
771                line: format!(
772                    "{BOLD}stats.json{RST}  {GREEN}exists{RST}  {WHITE}{size} bytes{RST}  {DIM}{path_display}{RST}",
773                ),
774            }
775        }
776        Some(_m) => {
777            let path_display = if let Some(p) = stats_path.as_ref() {
778                p.display().to_string()
779            } else {
780                String::new()
781            };
782            Outcome {
783                ok: false,
784                line: format!(
785                    "{BOLD}stats.json{RST}  {RED}not a file{RST}  {DIM}{path_display}{RST}",
786                ),
787            }
788        }
789        None => {
790            passed += 1;
791            Outcome {
792                ok: true,
793                line: match &stats_path {
794                    Some(p) => format!(
795                        "{BOLD}stats.json{RST}  {YELLOW}not yet created{RST}  {DIM}(will appear after first use) {}{RST}",
796                        p.display()
797                    ),
798                    None => format!("{BOLD}stats.json{RST}  {RED}could not resolve path{RST}"),
799                },
800            }
801        }
802    };
803    print_check(&stats_outcome);
804
805    // 5) config.toml (missing is OK)
806    let config_path = lean_dir.as_ref().map(|d| d.join("config.toml"));
807    let config_outcome = match &config_path {
808        Some(p) => match std::fs::metadata(p) {
809            Ok(m) if m.is_file() => {
810                passed += 1;
811                Outcome {
812                    ok: true,
813                    line: format!(
814                        "{BOLD}config.toml{RST}  {GREEN}exists{RST}  {DIM}{}{RST}",
815                        p.display()
816                    ),
817                }
818            }
819            Ok(_) => Outcome {
820                ok: false,
821                line: format!(
822                    "{BOLD}config.toml{RST}  {RED}exists but is not a regular file{RST}  {DIM}{}{RST}",
823                    p.display()
824                ),
825            },
826            Err(_) => {
827                passed += 1;
828                Outcome {
829                    ok: true,
830                    line: format!(
831                        "{BOLD}config.toml{RST}  {YELLOW}not found, using defaults{RST}  {DIM}(expected at {}){RST}",
832                        p.display()
833                    ),
834                }
835            }
836        },
837        None => Outcome {
838            ok: false,
839            line: format!("{BOLD}config.toml{RST}  {RED}could not resolve path{RST}"),
840        },
841    };
842    print_check(&config_outcome);
843
844    // 6) Proxy upstreams
845    let proxy_outcome = proxy_upstream_outcome();
846    if proxy_outcome.ok {
847        passed += 1;
848    }
849    print_check(&proxy_outcome);
850
851    // 7) Shell aliases
852    let aliases = shell_aliases_outcome();
853    if aliases.ok {
854        passed += 1;
855    }
856    print_check(&aliases);
857
858    // 7) MCP
859    let mcp = mcp_config_outcome();
860    if mcp.ok {
861        passed += 1;
862    }
863    print_check(&mcp);
864
865    // 9) SKILL.md
866    let skill = skill_files_outcome();
867    if skill.ok {
868        passed += 1;
869    }
870    print_check(&skill);
871
872    // 10) Port
873    let port = port_3333_outcome();
874    if port.ok {
875        passed += 1;
876    }
877    print_check(&port);
878
879    // Daemon status
880    #[cfg(unix)]
881    let daemon_outcome = if crate::daemon::is_daemon_running() {
882        let pid_path = crate::daemon::daemon_pid_path();
883        let pid_str = std::fs::read_to_string(&pid_path).unwrap_or_default();
884        Outcome {
885            ok: true,
886            line: format!(
887                "{BOLD}Daemon{RST}  {GREEN}running (PID {}){RST}",
888                pid_str.trim()
889            ),
890        }
891    } else {
892        Outcome {
893            ok: true,
894            line: format!(
895                "{BOLD}Daemon{RST}  {YELLOW}not running{RST}  {DIM}(run: lean-ctx serve -d){RST}"
896            ),
897        }
898    };
899    #[cfg(not(unix))]
900    let daemon_outcome = Outcome {
901        ok: true,
902        line: format!("{BOLD}Daemon{RST}  {DIM}not supported on this platform{RST}"),
903    };
904    if daemon_outcome.ok {
905        passed += 1;
906    }
907    print_check(&daemon_outcome);
908
909    // 9) Session state (project_root + shell_cwd)
910    let session_outcome = session_state_outcome();
911    if session_outcome.ok {
912        passed += 1;
913    }
914    print_check(&session_outcome);
915
916    // 10) Docker env vars (optional, only in containers)
917    let docker_outcomes = docker_env_outcomes();
918    for docker_check in &docker_outcomes {
919        if docker_check.ok {
920            passed += 1;
921        }
922        print_check(docker_check);
923    }
924
925    // 11) Pi Coding Agent (optional)
926    let pi = pi_outcome();
927    if let Some(ref pi_check) = pi {
928        if pi_check.ok {
929            passed += 1;
930        }
931        print_check(pi_check);
932    }
933
934    // 12) Build integrity (canary / origin check)
935    let integrity = crate::core::integrity::check();
936    let integrity_ok = integrity.seed_ok && integrity.origin_ok;
937    if integrity_ok {
938        passed += 1;
939    }
940    let integrity_line = if integrity_ok {
941        format!(
942            "{BOLD}Build origin{RST}  {GREEN}official{RST}  {DIM}{}{RST}",
943            integrity.repo
944        )
945    } else {
946        format!(
947            "{BOLD}Build origin{RST}  {RED}MODIFIED REDISTRIBUTION{RST}  {YELLOW}pkg={}, repo={}{RST}",
948            integrity.pkg_name, integrity.repo
949        )
950    };
951    print_check(&Outcome {
952        ok: integrity_ok,
953        line: integrity_line,
954    });
955
956    // 13) Cache safety
957    let cache_safety = cache_safety_outcome();
958    if cache_safety.ok {
959        passed += 1;
960    }
961    print_check(&cache_safety);
962
963    // 14) Claude Code instruction truncation guard
964    let claude_truncation = claude_truncation_outcome();
965    if let Some(ref ct) = claude_truncation {
966        if ct.ok {
967            passed += 1;
968        }
969        print_check(ct);
970    }
971
972    // 15) BM25 cache health
973    let bm25_health = bm25_cache_health_outcome();
974    if bm25_health.ok {
975        passed += 1;
976    }
977    print_check(&bm25_health);
978
979    // 16) Memory profile
980    let mem_profile = memory_profile_outcome();
981    passed += 1;
982    print_check(&mem_profile);
983
984    // 17) Memory cleanup
985    let mem_cleanup = memory_cleanup_outcome();
986    passed += 1;
987    print_check(&mem_cleanup);
988
989    // 18) RAM Guardian
990    let ram_outcome = ram_guardian_outcome();
991    if ram_outcome.ok {
992        passed += 1;
993    }
994    print_check(&ram_outcome);
995
996    // LSP servers (optional, informational)
997    println!("\n  {BOLD}{WHITE}LSP (optional — for ctx_refactor):{RST}");
998    let lsp_outcomes = lsp_server_outcomes();
999    for lsp_check in &lsp_outcomes {
1000        print_check(lsp_check);
1001    }
1002
1003    let mut effective_total = total + 8; // session_state + integrity + cache_safety + bm25_health + daemon + mem_profile + mem_cleanup + ram_guardian
1004    effective_total += docker_outcomes.len() as u32;
1005    if pi.is_some() {
1006        effective_total += 1;
1007    }
1008    if claude_truncation.is_some() {
1009        effective_total += 1;
1010    }
1011    println!();
1012    println!("  {BOLD}{WHITE}Summary:{RST}  {GREEN}{passed}{RST}{DIM}/{effective_total}{RST} checks passed");
1013    println!("  {DIM}LSP servers are optional enhancements (not counted in score){RST}");
1014    println!("  {DIM}{}{RST}", crate::core::integrity::origin_line());
1015}
1016
1017fn skill_files_outcome() -> Outcome {
1018    let Some(home) = dirs::home_dir() else {
1019        return Outcome {
1020            ok: false,
1021            line: format!("{BOLD}SKILL.md{RST}  {RED}could not resolve home directory{RST}"),
1022        };
1023    };
1024
1025    let candidates = [
1026        ("Claude Code", home.join(".claude/skills/lean-ctx/SKILL.md")),
1027        ("Cursor", home.join(".cursor/skills/lean-ctx/SKILL.md")),
1028        (
1029            "Codex CLI",
1030            crate::core::home::resolve_codex_dir()
1031                .unwrap_or_else(|| home.join(".codex"))
1032                .join("skills/lean-ctx/SKILL.md"),
1033        ),
1034        (
1035            "GitHub Copilot",
1036            home.join(".vscode/skills/lean-ctx/SKILL.md"),
1037        ),
1038    ];
1039
1040    let mut found: Vec<&str> = Vec::new();
1041    for (name, path) in &candidates {
1042        if path.exists() {
1043            found.push(name);
1044        }
1045    }
1046
1047    if found.is_empty() {
1048        Outcome {
1049            ok: false,
1050            line: format!(
1051                "{BOLD}SKILL.md{RST}  {YELLOW}not installed{RST}  {DIM}(run: lean-ctx setup){RST}"
1052            ),
1053        }
1054    } else {
1055        Outcome {
1056            ok: true,
1057            line: format!(
1058                "{BOLD}SKILL.md{RST}  {GREEN}installed for {}{RST}",
1059                found.join(", ")
1060            ),
1061        }
1062    }
1063}
1064
1065fn proxy_upstream_outcome() -> Outcome {
1066    use crate::core::config::{is_local_proxy_url, Config, ProxyProvider};
1067
1068    let cfg = Config::load();
1069    let checks = [
1070        (
1071            "Anthropic",
1072            "proxy.anthropic_upstream",
1073            cfg.proxy.resolve_upstream(ProxyProvider::Anthropic),
1074        ),
1075        (
1076            "OpenAI",
1077            "proxy.openai_upstream",
1078            cfg.proxy.resolve_upstream(ProxyProvider::OpenAi),
1079        ),
1080        (
1081            "Gemini",
1082            "proxy.gemini_upstream",
1083            cfg.proxy.resolve_upstream(ProxyProvider::Gemini),
1084        ),
1085    ];
1086
1087    let mut custom = Vec::new();
1088    for (label, key, resolved) in &checks {
1089        if is_local_proxy_url(resolved) {
1090            return Outcome {
1091                ok: false,
1092                line: format!(
1093                    "{BOLD}Proxy upstream{RST}  {RED}{label} upstream points back to local proxy{RST}  {YELLOW}run: lean-ctx config set {key} <url>{RST}"
1094                ),
1095            };
1096        }
1097        if !resolved.starts_with("http://") && !resolved.starts_with("https://") {
1098            return Outcome {
1099                ok: false,
1100                line: format!(
1101                    "{BOLD}Proxy upstream{RST}  {RED}invalid {label} upstream{RST}  {YELLOW}set {key} to an http(s) URL{RST}"
1102                ),
1103            };
1104        }
1105        let is_default = matches!(
1106            *label,
1107            "Anthropic" if resolved == "https://api.anthropic.com"
1108        ) || matches!(
1109            *label,
1110            "OpenAI" if resolved == "https://api.openai.com"
1111        ) || matches!(
1112            *label,
1113            "Gemini" if resolved == "https://generativelanguage.googleapis.com"
1114        );
1115        if !is_default {
1116            custom.push(format!("{label}={resolved}"));
1117        }
1118    }
1119
1120    if custom.is_empty() {
1121        Outcome {
1122            ok: true,
1123            line: format!("{BOLD}Proxy upstream{RST}  {GREEN}provider defaults{RST}"),
1124        }
1125    } else {
1126        Outcome {
1127            ok: true,
1128            line: format!(
1129                "{BOLD}Proxy upstream{RST}  {GREEN}custom: {}{RST}",
1130                custom.join(", ")
1131            ),
1132        }
1133    }
1134}
1135
1136fn cache_safety_outcome() -> Outcome {
1137    use crate::core::neural::cache_alignment::CacheAlignedOutput;
1138    use crate::core::provider_cache::ProviderCacheState;
1139
1140    let mut issues = Vec::new();
1141
1142    let mut aligned = CacheAlignedOutput::new();
1143    aligned.add_stable_block("test", "stable content".into(), 1);
1144    aligned.add_variable_block("test_var", "variable content".into(), 1);
1145    let rendered = aligned.render();
1146    if rendered.find("stable content").unwrap_or(usize::MAX)
1147        > rendered.find("variable content").unwrap_or(0)
1148    {
1149        issues.push("cache_alignment: stable blocks not ordered first");
1150    }
1151
1152    let mut state = ProviderCacheState::new();
1153    let section = crate::core::provider_cache::CacheableSection::new(
1154        "doctor_test",
1155        "test content".into(),
1156        crate::core::provider_cache::SectionPriority::System,
1157        true,
1158    );
1159    state.mark_sent(&section);
1160    if state.needs_update(&section) {
1161        issues.push("provider_cache: hash tracking broken");
1162    }
1163
1164    if issues.is_empty() {
1165        Outcome {
1166            ok: true,
1167            line: format!(
1168                "{BOLD}Cache safety{RST}  {GREEN}cache_alignment + provider_cache operational{RST}"
1169            ),
1170        }
1171    } else {
1172        Outcome {
1173            ok: false,
1174            line: format!("{BOLD}Cache safety{RST}  {RED}{}{RST}", issues.join("; ")),
1175        }
1176    }
1177}
1178
1179pub(super) fn claude_binary_exists() -> bool {
1180    #[cfg(unix)]
1181    {
1182        std::process::Command::new("which")
1183            .arg("claude")
1184            .output()
1185            .is_ok_and(|o| o.status.success())
1186    }
1187    #[cfg(windows)]
1188    {
1189        std::process::Command::new("where")
1190            .arg("claude")
1191            .output()
1192            .is_ok_and(|o| o.status.success())
1193    }
1194}
1195
1196fn claude_truncation_outcome() -> Option<Outcome> {
1197    let home = dirs::home_dir()?;
1198    let claude_detected = crate::core::editor_registry::claude_mcp_json_path(&home).exists()
1199        || crate::core::editor_registry::claude_state_dir(&home).exists()
1200        || claude_binary_exists();
1201
1202    if !claude_detected {
1203        return None;
1204    }
1205
1206    let rules_path = crate::core::editor_registry::claude_rules_dir(&home).join("lean-ctx.md");
1207    let skill_path = home.join(".claude/skills/lean-ctx/SKILL.md");
1208
1209    let has_rules = rules_path.exists();
1210    let has_skill = skill_path.exists();
1211
1212    if has_rules && has_skill {
1213        Some(Outcome {
1214            ok: true,
1215            line: format!(
1216                "{BOLD}Claude Code instructions{RST}  {GREEN}rules + skill installed{RST}  {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
1217            ),
1218        })
1219    } else if has_rules {
1220        Some(Outcome {
1221            ok: true,
1222            line: format!(
1223                "{BOLD}Claude Code instructions{RST}  {GREEN}rules file installed{RST}  {DIM}(MCP instructions capped at 2048 chars — full content via rules file){RST}"
1224            ),
1225        })
1226    } else {
1227        Some(Outcome {
1228            ok: false,
1229            line: format!(
1230                "{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}"
1231            ),
1232        })
1233    }
1234}
1235
1236fn bm25_cache_health_outcome() -> Outcome {
1237    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
1238        return Outcome {
1239            ok: true,
1240            line: format!("{BOLD}BM25 cache{RST}  {DIM}skipped (no data dir){RST}"),
1241        };
1242    };
1243
1244    let vectors_dir = data_dir.join("vectors");
1245    let Ok(entries) = std::fs::read_dir(&vectors_dir) else {
1246        return Outcome {
1247            ok: true,
1248            line: format!("{BOLD}BM25 cache{RST}  {GREEN}no vector dirs{RST}"),
1249        };
1250    };
1251
1252    let max_bytes = crate::core::config::Config::load().bm25_max_cache_mb * 1024 * 1024;
1253    let warn_bytes = 100 * 1024 * 1024; // 100 MB
1254    let mut total_dirs = 0u32;
1255    let mut total_bytes = 0u64;
1256    let mut oversized: Vec<(String, u64)> = Vec::new();
1257    let mut warnings: Vec<(String, u64)> = Vec::new();
1258    let mut quarantined_count = 0u32;
1259
1260    for entry in entries.flatten() {
1261        let dir = entry.path();
1262        if !dir.is_dir() {
1263            continue;
1264        }
1265        total_dirs += 1;
1266
1267        if dir.join("bm25_index.json.quarantined").exists()
1268            || dir.join("bm25_index.bin.quarantined").exists()
1269            || dir.join("bm25_index.bin.zst.quarantined").exists()
1270        {
1271            quarantined_count += 1;
1272        }
1273
1274        let index_path = if dir.join("bm25_index.bin.zst").exists() {
1275            dir.join("bm25_index.bin.zst")
1276        } else if dir.join("bm25_index.bin").exists() {
1277            dir.join("bm25_index.bin")
1278        } else {
1279            dir.join("bm25_index.json")
1280        };
1281        if let Ok(meta) = std::fs::metadata(&index_path) {
1282            let size = meta.len();
1283            total_bytes += size;
1284            let display = index_path.display().to_string();
1285            if size > max_bytes {
1286                oversized.push((display, size));
1287            } else if size > warn_bytes {
1288                warnings.push((display, size));
1289            }
1290        }
1291    }
1292
1293    if !oversized.is_empty() {
1294        let details: Vec<String> = oversized
1295            .iter()
1296            .map(|(p, s)| format!("{p} ({:.1} GB)", *s as f64 / 1_073_741_824.0))
1297            .collect();
1298        return Outcome {
1299            ok: false,
1300            line: format!(
1301                "{BOLD}BM25 cache{RST}  {RED}{} index(es) exceed limit ({:.0} MB){RST}: {}  {DIM}(run: lean-ctx cache prune){RST}",
1302                oversized.len(),
1303                max_bytes / (1024 * 1024),
1304                details.join(", ")
1305            ),
1306        };
1307    }
1308
1309    if !warnings.is_empty() {
1310        let details: Vec<String> = warnings
1311            .iter()
1312            .map(|(p, s)| format!("{p} ({:.0} MB)", *s as f64 / 1_048_576.0))
1313            .collect();
1314        return Outcome {
1315            ok: true,
1316            line: format!(
1317                "{BOLD}BM25 cache{RST}  {YELLOW}{} large index(es) (>100 MB){RST}: {}  {DIM}(consider extra_ignore_patterns){RST}",
1318                warnings.len(),
1319                details.join(", ")
1320            ),
1321        };
1322    }
1323
1324    let quarantine_note = if quarantined_count > 0 {
1325        format!("  {YELLOW}{quarantined_count} quarantined (run: lean-ctx cache prune){RST}")
1326    } else {
1327        String::new()
1328    };
1329
1330    Outcome {
1331        ok: true,
1332        line: format!(
1333            "{BOLD}BM25 cache{RST}  {GREEN}{total_dirs} index(es), {:.1} MB total{RST}{quarantine_note}",
1334            total_bytes as f64 / 1_048_576.0
1335        ),
1336    }
1337}
1338
1339pub fn run_compact() {
1340    let (passed, total) = compact_score();
1341    print_compact_status(passed, total);
1342}
1343
1344pub fn run_cli(args: &[String]) -> i32 {
1345    let (sub, rest) = match args.first().map(String::as_str) {
1346        Some("integrations") => ("integrations", &args[1..]),
1347        _ => ("", args),
1348    };
1349
1350    let fix = rest.iter().any(|a| a == "--fix");
1351    let json = rest.iter().any(|a| a == "--json");
1352    let help = rest.iter().any(|a| a == "--help" || a == "-h");
1353
1354    if help {
1355        println!("Usage:");
1356        println!("  lean-ctx doctor");
1357        println!("  lean-ctx doctor integrations [--json]");
1358        println!("  lean-ctx doctor --fix [--json]");
1359        return 0;
1360    }
1361
1362    if sub == "integrations" {
1363        if fix {
1364            let _ = fix::run_fix(&fix::DoctorFixOptions { json: false });
1365        }
1366        return integrations::run_integrations(&integrations::IntegrationsOptions { json });
1367    }
1368
1369    if !fix {
1370        run();
1371        return 0;
1372    }
1373
1374    match fix::run_fix(&fix::DoctorFixOptions { json }) {
1375        Ok(code) => code,
1376        Err(e) => {
1377            tracing::error!("doctor --fix failed: {e}");
1378            2
1379        }
1380    }
1381}
1382
1383pub fn compact_score() -> (u32, u32) {
1384    let mut passed = 0u32;
1385    let total = 6u32;
1386
1387    if resolve_lean_ctx_binary().is_some() || path_in_path_env() {
1388        passed += 1;
1389    }
1390    let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
1391    if lean_dir.as_ref().is_some_and(|p| p.is_dir()) {
1392        passed += 1;
1393    }
1394    if lean_dir
1395        .as_ref()
1396        .map(|d| d.join("stats.json"))
1397        .and_then(|p| std::fs::metadata(p).ok())
1398        .is_some_and(|m| m.is_file())
1399    {
1400        passed += 1;
1401    }
1402    if shell_aliases_outcome().ok {
1403        passed += 1;
1404    }
1405    if mcp_config_outcome().ok {
1406        passed += 1;
1407    }
1408    if skill_files_outcome().ok {
1409        passed += 1;
1410    }
1411
1412    (passed, total)
1413}
1414
1415pub(super) fn print_compact_status(passed: u32, total: u32) {
1416    let status = if passed == total {
1417        format!("{GREEN}✓ All {total} checks passed{RST}")
1418    } else {
1419        format!("{YELLOW}{passed}/{total} passed{RST} — run {BOLD}lean-ctx doctor{RST} for details")
1420    };
1421    println!("  {status}");
1422}
1423
1424fn memory_profile_outcome() -> Outcome {
1425    let cfg = crate::core::config::Config::load();
1426    let profile = crate::core::config::MemoryProfile::effective(&cfg);
1427    let (label, detail) = match profile {
1428        crate::core::config::MemoryProfile::Low => {
1429            ("low", "embeddings+semantic cache disabled, BM25 64 MB")
1430        }
1431        crate::core::config::MemoryProfile::Balanced => {
1432            ("balanced", "default — BM25 128 MB, single embedding engine")
1433        }
1434        crate::core::config::MemoryProfile::Performance => {
1435            ("performance", "full caches, BM25 512 MB")
1436        }
1437    };
1438    let source = if crate::core::config::MemoryProfile::from_env().is_some() {
1439        "env"
1440    } else if cfg.memory_profile != crate::core::config::MemoryProfile::default() {
1441        "config"
1442    } else {
1443        "default"
1444    };
1445    Outcome {
1446        ok: true,
1447        line: format!(
1448            "{BOLD}Memory profile{RST}  {GREEN}{label}{RST}  {DIM}({source}: {detail}){RST}"
1449        ),
1450    }
1451}
1452
1453fn memory_cleanup_outcome() -> Outcome {
1454    let cfg = crate::core::config::Config::load();
1455    let cleanup = crate::core::config::MemoryCleanup::effective(&cfg);
1456    let (label, detail) = match cleanup {
1457        crate::core::config::MemoryCleanup::Aggressive => (
1458            "aggressive",
1459            "cache cleared after 5 min idle, single-IDE optimized",
1460        ),
1461        crate::core::config::MemoryCleanup::Shared => (
1462            "shared",
1463            "cache retained 30 min, multi-IDE/multi-model optimized",
1464        ),
1465    };
1466    let source = if crate::core::config::MemoryCleanup::from_env().is_some() {
1467        "env"
1468    } else if cfg.memory_cleanup != crate::core::config::MemoryCleanup::default() {
1469        "config"
1470    } else {
1471        "default"
1472    };
1473    Outcome {
1474        ok: true,
1475        line: format!(
1476            "{BOLD}Memory cleanup{RST}  {GREEN}{label}{RST}  {DIM}({source}: {detail}){RST}"
1477        ),
1478    }
1479}
1480
1481fn ram_guardian_outcome() -> Outcome {
1482    let Some(snap) = crate::core::memory_guard::MemorySnapshot::capture() else {
1483        return Outcome {
1484            ok: true,
1485            line: format!(
1486                "{BOLD}RAM Guardian{RST}  {YELLOW}not available{RST}  {DIM}(platform unsupported){RST}"
1487            ),
1488        };
1489    };
1490    let allocator = if cfg!(all(feature = "jemalloc", not(windows))) {
1491        "jemalloc"
1492    } else {
1493        "system"
1494    };
1495    let ok = snap.pressure_level == crate::core::memory_guard::PressureLevel::Normal;
1496    let color = if ok { GREEN } else { RED };
1497    Outcome {
1498        ok,
1499        line: format!(
1500            "{BOLD}RAM Guardian{RST}  {color}{:.0} MB{RST} / {:.1} GB system ({:.1}%)  {DIM}limit: {:.0} MB ({allocator}){RST}",
1501            snap.rss_bytes as f64 / 1_048_576.0,
1502            snap.system_ram_bytes as f64 / 1_073_741_824.0,
1503            snap.rss_percent,
1504            snap.rss_limit_bytes as f64 / 1_048_576.0,
1505        ),
1506    }
1507}
1508
1509fn lsp_server_outcomes() -> Vec<Outcome> {
1510    use crate::lsp::config::{find_binary_in_path, KNOWN_SERVERS};
1511
1512    KNOWN_SERVERS
1513        .iter()
1514        .map(|info| {
1515            let found = find_binary_in_path(info.binary);
1516            match found {
1517                Some(path) => Outcome {
1518                    ok: true,
1519                    line: format!(
1520                        "{BOLD}{}{RST}  {GREEN}✓ {}{RST}  {DIM}{}{RST}",
1521                        info.language,
1522                        info.binary,
1523                        path.display()
1524                    ),
1525                },
1526                None => Outcome {
1527                    ok: false,
1528                    line: format!(
1529                        "{BOLD}{}{RST}  {DIM}not installed{RST}  {YELLOW}{}{RST}",
1530                        info.language, info.install_hint
1531                    ),
1532                },
1533            }
1534        })
1535        .collect()
1536}
1537
1538#[cfg(test)]
1539mod tests {
1540    use super::is_active_shell_impl;
1541
1542    #[test]
1543    fn bashrc_active_on_non_windows_when_shell_empty() {
1544        assert!(is_active_shell_impl("~/.bashrc", "", false, false));
1545    }
1546
1547    #[test]
1548    fn bashrc_not_active_on_windows_when_shell_empty() {
1549        assert!(!is_active_shell_impl("~/.bashrc", "", true, false));
1550    }
1551
1552    #[test]
1553    fn bashrc_active_when_shell_contains_bash_on_linux() {
1554        assert!(is_active_shell_impl(
1555            "~/.bashrc",
1556            "/usr/bin/bash",
1557            false,
1558            false
1559        ));
1560    }
1561
1562    #[test]
1563    fn bashrc_not_active_on_windows_even_with_bash_in_shell_env() {
1564        // Issue #214: On Windows, Git Bash sets $SHELL globally to bash.exe.
1565        // .bashrc should NOT be flagged on Windows unless actually inside bash.
1566        std::env::remove_var("BASH_VERSION");
1567        assert!(!is_active_shell_impl(
1568            "~/.bashrc",
1569            "C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe",
1570            true,
1571            false,
1572        ));
1573    }
1574
1575    #[test]
1576    fn bashrc_not_active_on_windows_powershell_even_with_bash_in_shell() {
1577        assert!(!is_active_shell_impl(
1578            "~/.bashrc",
1579            "C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe",
1580            true,
1581            true,
1582        ));
1583    }
1584
1585    #[test]
1586    fn bashrc_not_active_on_windows_powershell_with_empty_shell() {
1587        assert!(!is_active_shell_impl("~/.bashrc", "", true, true));
1588    }
1589
1590    #[test]
1591    fn zshrc_unaffected_by_powershell_flag() {
1592        assert!(is_active_shell_impl("~/.zshrc", "/bin/zsh", false, false));
1593        assert!(is_active_shell_impl("~/.zshrc", "/bin/zsh", true, true));
1594    }
1595
1596    #[test]
1597    fn bashrc_not_active_on_windows_without_powershell_detection() {
1598        // Windows + $SHELL=bash but NOT in actual bash session (no BASH_VERSION).
1599        // This is the exact scenario from issue #214: Git Bash sets $SHELL globally.
1600        std::env::remove_var("BASH_VERSION");
1601        assert!(!is_active_shell_impl(
1602            "~/.bashrc",
1603            "/usr/bin/bash",
1604            true,
1605            false,
1606        ));
1607    }
1608
1609    #[test]
1610    fn bashrc_active_on_linux() {
1611        assert!(is_active_shell_impl("~/.bashrc", "/bin/bash", false, false));
1612        assert!(is_active_shell_impl("~/.bashrc", "", false, false));
1613    }
1614}