1mod 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 if is_windows {
157 if is_powershell {
158 return false;
159 }
160 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
172fn 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
689pub 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 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 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 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 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 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 let proxy_outcome = proxy_upstream_outcome();
846 if proxy_outcome.ok {
847 passed += 1;
848 }
849 print_check(&proxy_outcome);
850
851 let aliases = shell_aliases_outcome();
853 if aliases.ok {
854 passed += 1;
855 }
856 print_check(&aliases);
857
858 let mcp = mcp_config_outcome();
860 if mcp.ok {
861 passed += 1;
862 }
863 print_check(&mcp);
864
865 let skill = skill_files_outcome();
867 if skill.ok {
868 passed += 1;
869 }
870 print_check(&skill);
871
872 let port = port_3333_outcome();
874 if port.ok {
875 passed += 1;
876 }
877 print_check(&port);
878
879 #[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 let session_outcome = session_state_outcome();
911 if session_outcome.ok {
912 passed += 1;
913 }
914 print_check(&session_outcome);
915
916 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 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 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 let cache_safety = cache_safety_outcome();
958 if cache_safety.ok {
959 passed += 1;
960 }
961 print_check(&cache_safety);
962
963 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 let bm25_health = bm25_cache_health_outcome();
974 if bm25_health.ok {
975 passed += 1;
976 }
977 print_check(&bm25_health);
978
979 let mem_profile = memory_profile_outcome();
981 passed += 1;
982 print_check(&mem_profile);
983
984 let mem_cleanup = memory_cleanup_outcome();
986 passed += 1;
987 print_check(&mem_cleanup);
988
989 let ram_outcome = ram_guardian_outcome();
991 if ram_outcome.ok {
992 passed += 1;
993 }
994 print_check(&ram_outcome);
995
996 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; 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(§ion);
1160 if state.needs_update(§ion) {
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; 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 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 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}