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