Skip to main content

lean_ctx/
doctor.rs

1//! Environment diagnostics for lean-ctx installation and integration.
2
3use std::net::TcpListener;
4use std::path::PathBuf;
5
6const GREEN: &str = "\x1b[32m";
7const RED: &str = "\x1b[31m";
8const BOLD: &str = "\x1b[1m";
9const RST: &str = "\x1b[0m";
10const DIM: &str = "\x1b[2m";
11const WHITE: &str = "\x1b[97m";
12const YELLOW: &str = "\x1b[33m";
13
14const VERSION: &str = env!("CARGO_PKG_VERSION");
15
16struct Outcome {
17    ok: bool,
18    line: String,
19}
20
21fn print_check(outcome: &Outcome) {
22    let mark = if outcome.ok {
23        format!("{GREEN}✓{RST}")
24    } else {
25        format!("{RED}✗{RST}")
26    };
27    println!("  {mark}  {}", outcome.line);
28}
29
30fn path_in_path_env() -> bool {
31    if let Ok(path) = std::env::var("PATH") {
32        for dir in std::env::split_paths(&path) {
33            if dir.join("lean-ctx").is_file() {
34                return true;
35            }
36            if cfg!(windows)
37                && (dir.join("lean-ctx.exe").is_file() || dir.join("lean-ctx.cmd").is_file())
38            {
39                return true;
40            }
41        }
42    }
43    false
44}
45
46fn resolve_lean_ctx_binary() -> Option<PathBuf> {
47    #[cfg(unix)]
48    {
49        let output = std::process::Command::new("/bin/sh")
50            .arg("-c")
51            .arg("command -v lean-ctx")
52            .env("LEAN_CTX_ACTIVE", "1")
53            .output()
54            .ok()?;
55        if !output.status.success() {
56            return None;
57        }
58        let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
59        if s.is_empty() {
60            None
61        } else {
62            Some(PathBuf::from(s))
63        }
64    }
65
66    #[cfg(windows)]
67    {
68        let output = std::process::Command::new("where.exe")
69            .arg("lean-ctx")
70            .env("LEAN_CTX_ACTIVE", "1")
71            .output()
72            .ok()?;
73        if !output.status.success() {
74            return None;
75        }
76        let stdout = String::from_utf8_lossy(&output.stdout);
77        let lines: Vec<&str> = stdout
78            .lines()
79            .map(|l| l.trim())
80            .filter(|l| !l.is_empty())
81            .collect();
82        let exe_line = lines.iter().find(|l| l.ends_with(".exe"));
83        let best = exe_line.or(lines.first()).map(|s| s.to_string());
84        best.map(PathBuf::from)
85    }
86}
87
88fn lean_ctx_version_from_path() -> Outcome {
89    let resolved = resolve_lean_ctx_binary();
90    let bin = resolved
91        .clone()
92        .unwrap_or_else(|| std::env::current_exe().unwrap_or_else(|_| "lean-ctx".into()));
93
94    let try_run = |cmd: &std::path::Path| -> Result<String, String> {
95        let output = std::process::Command::new(cmd)
96            .args(["--version"])
97            .env("LEAN_CTX_ACTIVE", "1")
98            .output()
99            .map_err(|e| e.to_string())?;
100        if !output.status.success() {
101            return Err(format!(
102                "exited with {}",
103                output.status.code().unwrap_or(-1)
104            ));
105        }
106        let text = String::from_utf8_lossy(&output.stdout).trim().to_string();
107        if text.is_empty() {
108            return Err("empty output".to_string());
109        }
110        Ok(text)
111    };
112
113    match try_run(&bin) {
114        Ok(text) => Outcome {
115            ok: true,
116            line: format!("{BOLD}lean-ctx version{RST}  {WHITE}{text}{RST}"),
117        },
118        Err(_first_err) => {
119            #[cfg(windows)]
120            {
121                let candidates = [
122                    bin.with_extension("exe"),
123                    bin.parent()
124                        .unwrap_or(std::path::Path::new("."))
125                        .join("node_modules")
126                        .join("lean-ctx-bin")
127                        .join("bin")
128                        .join("lean-ctx.exe"),
129                ];
130                for candidate in &candidates {
131                    if candidate.is_file() {
132                        if let Ok(text) = try_run(candidate) {
133                            return Outcome {
134                                ok: true,
135                                line: format!(
136                                    "{BOLD}lean-ctx version{RST}  {WHITE}{text}{RST}  {DIM}(via {}){RST}",
137                                    candidate.display()
138                                ),
139                            };
140                        }
141                    }
142                }
143            }
144
145            let current_exe_result = std::env::current_exe();
146            if let Ok(ref exe) = current_exe_result {
147                if exe != &bin {
148                    if let Ok(text) = try_run(exe) {
149                        return Outcome {
150                            ok: true,
151                            line: format!("{BOLD}lean-ctx version{RST}  {WHITE}{text}{RST}  {DIM}(this binary){RST}"),
152                        };
153                    }
154                }
155            }
156
157            Outcome {
158                ok: false,
159                line: format!(
160                    "{BOLD}lean-ctx version{RST}  {RED}failed to run `lean-ctx --version`: {_first_err}{RST}  {DIM}(resolved: {}){RST}",
161                    bin.display()
162                ),
163            }
164        }
165    }
166}
167
168fn rc_contains_lean_ctx(path: &PathBuf) -> bool {
169    match std::fs::read_to_string(path) {
170        Ok(s) => s.contains("lean-ctx"),
171        Err(_) => false,
172    }
173}
174
175fn shell_aliases_outcome() -> Outcome {
176    let home = match dirs::home_dir() {
177        Some(h) => h,
178        None => {
179            return Outcome {
180                ok: false,
181                line: format!(
182                    "{BOLD}Shell aliases{RST}  {RED}could not resolve home directory{RST}"
183                ),
184            };
185        }
186    };
187
188    let mut parts = Vec::new();
189
190    let zsh = home.join(".zshrc");
191    if rc_contains_lean_ctx(&zsh) {
192        parts.push(format!("{DIM}~/.zshrc{RST}"));
193    }
194    let bash = home.join(".bashrc");
195    if rc_contains_lean_ctx(&bash) {
196        parts.push(format!("{DIM}~/.bashrc{RST}"));
197    }
198
199    #[cfg(windows)]
200    {
201        let ps_profile = home
202            .join("Documents")
203            .join("PowerShell")
204            .join("Microsoft.PowerShell_profile.ps1");
205        let ps_profile_legacy = home
206            .join("Documents")
207            .join("WindowsPowerShell")
208            .join("Microsoft.PowerShell_profile.ps1");
209        if rc_contains_lean_ctx(&ps_profile) {
210            parts.push(format!("{DIM}PowerShell profile{RST}"));
211        } else if rc_contains_lean_ctx(&ps_profile_legacy) {
212            parts.push(format!("{DIM}WindowsPowerShell profile{RST}"));
213        }
214    }
215
216    if parts.is_empty() {
217        let hint = if cfg!(windows) {
218            "no \"lean-ctx\" in PowerShell profile, ~/.zshrc or ~/.bashrc"
219        } else {
220            "no \"lean-ctx\" in ~/.zshrc or ~/.bashrc"
221        };
222        Outcome {
223            ok: false,
224            line: format!("{BOLD}Shell aliases{RST}  {RED}{hint}{RST}"),
225        }
226    } else {
227        Outcome {
228            ok: true,
229            line: format!(
230                "{BOLD}Shell aliases{RST}  {GREEN}lean-ctx referenced in {}{RST}",
231                parts.join(", ")
232            ),
233        }
234    }
235}
236
237struct McpLocation {
238    name: &'static str,
239    display: &'static str,
240    path: PathBuf,
241}
242
243fn mcp_config_locations(home: &std::path::Path) -> Vec<McpLocation> {
244    let mut locations = vec![
245        McpLocation {
246            name: "Cursor",
247            display: "~/.cursor/mcp.json",
248            path: home.join(".cursor").join("mcp.json"),
249        },
250        McpLocation {
251            name: "Claude Code",
252            display: "~/.claude.json",
253            path: home.join(".claude.json"),
254        },
255        McpLocation {
256            name: "Windsurf",
257            display: "~/.codeium/windsurf/mcp_config.json",
258            path: home
259                .join(".codeium")
260                .join("windsurf")
261                .join("mcp_config.json"),
262        },
263        McpLocation {
264            name: "Codex",
265            display: "~/.codex/config.toml",
266            path: home.join(".codex").join("config.toml"),
267        },
268        McpLocation {
269            name: "Gemini CLI",
270            display: "~/.gemini/settings/mcp.json",
271            path: home.join(".gemini").join("settings").join("mcp.json"),
272        },
273        McpLocation {
274            name: "Antigravity",
275            display: "~/.gemini/antigravity/mcp_config.json",
276            path: home
277                .join(".gemini")
278                .join("antigravity")
279                .join("mcp_config.json"),
280        },
281    ];
282
283    #[cfg(unix)]
284    {
285        let zed_cfg = home.join(".config").join("zed").join("settings.json");
286        locations.push(McpLocation {
287            name: "Zed",
288            display: "~/.config/zed/settings.json",
289            path: zed_cfg,
290        });
291    }
292
293    locations.push(McpLocation {
294        name: "Qwen Code",
295        display: "~/.qwen/mcp.json",
296        path: home.join(".qwen").join("mcp.json"),
297    });
298    locations.push(McpLocation {
299        name: "Trae",
300        display: "~/.trae/mcp.json",
301        path: home.join(".trae").join("mcp.json"),
302    });
303    locations.push(McpLocation {
304        name: "Amazon Q",
305        display: "~/.aws/amazonq/mcp.json",
306        path: home.join(".aws").join("amazonq").join("mcp.json"),
307    });
308    locations.push(McpLocation {
309        name: "JetBrains",
310        display: "~/.jb-mcp.json",
311        path: home.join(".jb-mcp.json"),
312    });
313
314    {
315        #[cfg(unix)]
316        let opencode_cfg = home.join(".config").join("opencode").join("opencode.json");
317        #[cfg(unix)]
318        let opencode_display = "~/.config/opencode/opencode.json";
319
320        #[cfg(windows)]
321        let opencode_cfg = if let Ok(appdata) = std::env::var("APPDATA") {
322            std::path::PathBuf::from(appdata)
323                .join("opencode")
324                .join("opencode.json")
325        } else {
326            home.join(".config").join("opencode").join("opencode.json")
327        };
328        #[cfg(windows)]
329        let opencode_display = "%APPDATA%/opencode/opencode.json";
330
331        locations.push(McpLocation {
332            name: "OpenCode",
333            display: opencode_display,
334            path: opencode_cfg,
335        });
336    }
337
338    #[cfg(target_os = "macos")]
339    {
340        let vscode_mcp = home.join("Library/Application Support/Code/User/mcp.json");
341        locations.push(McpLocation {
342            name: "VS Code / Copilot",
343            display: "~/Library/Application Support/Code/User/mcp.json",
344            path: vscode_mcp,
345        });
346    }
347    #[cfg(target_os = "linux")]
348    {
349        let vscode_mcp = home.join(".config/Code/User/mcp.json");
350        locations.push(McpLocation {
351            name: "VS Code / Copilot",
352            display: "~/.config/Code/User/mcp.json",
353            path: vscode_mcp,
354        });
355    }
356    #[cfg(target_os = "windows")]
357    {
358        if let Ok(appdata) = std::env::var("APPDATA") {
359            let vscode_mcp = std::path::PathBuf::from(appdata).join("Code/User/mcp.json");
360            locations.push(McpLocation {
361                name: "VS Code / Copilot",
362                display: "%APPDATA%/Code/User/mcp.json",
363                path: vscode_mcp,
364            });
365        }
366    }
367
368    locations
369}
370
371fn mcp_config_outcome() -> Outcome {
372    let home = match dirs::home_dir() {
373        Some(h) => h,
374        None => {
375            return Outcome {
376                ok: false,
377                line: format!("{BOLD}MCP config{RST}  {RED}could not resolve home directory{RST}"),
378            };
379        }
380    };
381
382    let locations = mcp_config_locations(&home);
383    let mut found: Vec<String> = Vec::new();
384    let mut exists_no_ref: Vec<String> = Vec::new();
385
386    for loc in &locations {
387        match std::fs::read_to_string(&loc.path) {
388            Ok(content) if content.contains("lean-ctx") => {
389                found.push(format!("{} {DIM}({}){RST}", loc.name, loc.display));
390            }
391            Ok(_) => {
392                exists_no_ref.push(loc.name.to_string());
393            }
394            Err(_) => {}
395        }
396    }
397
398    if !found.is_empty() {
399        Outcome {
400            ok: true,
401            line: format!(
402                "{BOLD}MCP config{RST}  {GREEN}lean-ctx found in: {}{RST}",
403                found.join(", ")
404            ),
405        }
406    } else if !exists_no_ref.is_empty() {
407        Outcome {
408            ok: false,
409            line: format!(
410                "{BOLD}MCP config{RST}  {YELLOW}config exists for {} but does not reference lean-ctx{RST}  {DIM}(run: lean-ctx init --agent <editor>){RST}",
411                exists_no_ref.join(", ")
412            ),
413        }
414    } else {
415        Outcome {
416            ok: false,
417            line: format!(
418                "{BOLD}MCP config{RST}  {YELLOW}no MCP config found{RST}  {DIM}(checked: Cursor, Claude, Windsurf, Codex, Gemini, Antigravity, Zed){RST}"
419            ),
420        }
421    }
422}
423
424fn port_3333_outcome() -> Outcome {
425    match TcpListener::bind("127.0.0.1:3333") {
426        Ok(_listener) => Outcome {
427            ok: true,
428            line: format!("{BOLD}Dashboard port 3333{RST}  {GREEN}available on 127.0.0.1{RST}"),
429        },
430        Err(e) => Outcome {
431            ok: false,
432            line: format!("{BOLD}Dashboard port 3333{RST}  {RED}not available: {e}{RST}"),
433        },
434    }
435}
436
437fn pi_outcome() -> Option<Outcome> {
438    let pi_result = std::process::Command::new("pi").arg("--version").output();
439
440    match pi_result {
441        Ok(output) if output.status.success() => {
442            let version = String::from_utf8_lossy(&output.stdout).trim().to_string();
443            let has_plugin = std::process::Command::new("pi")
444                .args(["list"])
445                .output()
446                .map(|o| String::from_utf8_lossy(&o.stdout).contains("pi-lean-ctx"))
447                .unwrap_or(false);
448
449            if has_plugin {
450                Some(Outcome {
451                    ok: true,
452                    line: format!(
453                        "{BOLD}Pi Coding Agent{RST}  {GREEN}{version}, pi-lean-ctx installed{RST}"
454                    ),
455                })
456            } else {
457                Some(Outcome {
458                    ok: false,
459                    line: format!(
460                        "{BOLD}Pi Coding Agent{RST}  {YELLOW}{version}, but pi-lean-ctx not installed{RST}  {DIM}(run: pi install npm:pi-lean-ctx){RST}"
461                    ),
462                })
463            }
464        }
465        _ => None,
466    }
467}
468
469/// Run diagnostic checks and print colored results to stdout.
470pub fn run() {
471    let mut passed = 0u32;
472    let total = 8u32;
473
474    println!("{BOLD}{WHITE}lean-ctx doctor{RST}  {DIM}diagnostics{RST}\n");
475
476    // 1) Binary on PATH
477    let path_bin = resolve_lean_ctx_binary();
478    let also_in_path_dirs = path_in_path_env();
479    let bin_ok = path_bin.is_some() || also_in_path_dirs;
480    if bin_ok {
481        passed += 1;
482    }
483    let bin_line = if let Some(p) = path_bin {
484        format!("{BOLD}lean-ctx in PATH{RST}  {WHITE}{}{RST}", p.display())
485    } else if also_in_path_dirs {
486        format!(
487            "{BOLD}lean-ctx in PATH{RST}  {YELLOW}found via PATH walk (not resolved by `command -v`){RST}"
488        )
489    } else {
490        format!("{BOLD}lean-ctx in PATH{RST}  {RED}not found{RST}")
491    };
492    print_check(&Outcome {
493        ok: bin_ok,
494        line: bin_line,
495    });
496
497    // 2) Version from PATH binary
498    let ver = if bin_ok {
499        lean_ctx_version_from_path()
500    } else {
501        Outcome {
502            ok: false,
503            line: format!("{BOLD}lean-ctx version{RST}  {RED}skipped (binary not in PATH){RST}"),
504        }
505    };
506    if ver.ok {
507        passed += 1;
508    }
509    print_check(&ver);
510
511    // 3) ~/.lean-ctx directory
512    let lean_dir = dirs::home_dir().map(|h| h.join(".lean-ctx"));
513    let dir_outcome = match &lean_dir {
514        Some(p) if p.is_dir() => {
515            passed += 1;
516            Outcome {
517                ok: true,
518                line: format!(
519                    "{BOLD}~/.lean-ctx/{RST}  {GREEN}exists{RST}  {DIM}{}{RST}",
520                    p.display()
521                ),
522            }
523        }
524        Some(p) => Outcome {
525            ok: false,
526            line: format!(
527                "{BOLD}~/.lean-ctx/{RST}  {RED}missing or not a directory{RST}  {DIM}{}{RST}",
528                p.display()
529            ),
530        },
531        None => Outcome {
532            ok: false,
533            line: format!("{BOLD}~/.lean-ctx/{RST}  {RED}could not resolve home directory{RST}"),
534        },
535    };
536    print_check(&dir_outcome);
537
538    // 4) stats.json + size
539    let stats_path = lean_dir.as_ref().map(|d| d.join("stats.json"));
540    let stats_outcome = match stats_path.as_ref().and_then(|p| std::fs::metadata(p).ok()) {
541        Some(m) if m.is_file() => {
542            passed += 1;
543            let size = m.len();
544            Outcome {
545                ok: true,
546                line: format!(
547                    "{BOLD}stats.json{RST}  {GREEN}exists{RST}  {WHITE}{size} bytes{RST}  {DIM}{}{RST}",
548                    stats_path.as_ref().unwrap().display()
549                ),
550            }
551        }
552        Some(_m) => Outcome {
553            ok: false,
554            line: format!(
555                "{BOLD}stats.json{RST}  {RED}not a file{RST}  {DIM}{}{RST}",
556                stats_path.as_ref().unwrap().display()
557            ),
558        },
559        None => {
560            passed += 1;
561            Outcome {
562                ok: true,
563                line: match &stats_path {
564                    Some(p) => format!(
565                        "{BOLD}stats.json{RST}  {YELLOW}not yet created{RST}  {DIM}(will appear after first use) {}{RST}",
566                        p.display()
567                    ),
568                    None => format!("{BOLD}stats.json{RST}  {RED}could not resolve path{RST}"),
569                },
570            }
571        }
572    };
573    print_check(&stats_outcome);
574
575    // 5) config.toml (missing is OK)
576    let config_path = lean_dir.as_ref().map(|d| d.join("config.toml"));
577    let config_outcome = match &config_path {
578        Some(p) => match std::fs::metadata(p) {
579            Ok(m) if m.is_file() => {
580                passed += 1;
581                Outcome {
582                    ok: true,
583                    line: format!(
584                        "{BOLD}config.toml{RST}  {GREEN}exists{RST}  {DIM}{}{RST}",
585                        p.display()
586                    ),
587                }
588            }
589            Ok(_) => Outcome {
590                ok: false,
591                line: format!(
592                    "{BOLD}config.toml{RST}  {RED}exists but is not a regular file{RST}  {DIM}{}{RST}",
593                    p.display()
594                ),
595            },
596            Err(_) => {
597                passed += 1;
598                Outcome {
599                    ok: true,
600                    line: format!(
601                        "{BOLD}config.toml{RST}  {YELLOW}not found, using defaults{RST}  {DIM}(expected at {}){RST}",
602                        p.display()
603                    ),
604                }
605            }
606        },
607        None => Outcome {
608            ok: false,
609            line: format!("{BOLD}config.toml{RST}  {RED}could not resolve path{RST}"),
610        },
611    };
612    print_check(&config_outcome);
613
614    // 6) Shell aliases
615    let aliases = shell_aliases_outcome();
616    if aliases.ok {
617        passed += 1;
618    }
619    print_check(&aliases);
620
621    // 7) MCP
622    let mcp = mcp_config_outcome();
623    if mcp.ok {
624        passed += 1;
625    }
626    print_check(&mcp);
627
628    // 8) Port
629    let port = port_3333_outcome();
630    if port.ok {
631        passed += 1;
632    }
633    print_check(&port);
634
635    // 9) Pi Coding Agent (optional)
636    let pi = pi_outcome();
637    if let Some(ref pi_check) = pi {
638        if pi_check.ok {
639            passed += 1;
640        }
641        print_check(pi_check);
642    }
643
644    let effective_total = if pi.is_some() { total + 1 } else { total };
645    println!();
646    println!("  {BOLD}{WHITE}Summary:{RST}  {GREEN}{passed}{RST}{DIM}/{effective_total}{RST} checks passed");
647    println!("  {DIM}This binary: lean-ctx {VERSION} (Cargo package version){RST}");
648}