1mod checks;
4mod common;
5mod fix;
6mod integrations;
7mod workspace_scope;
8
9#[allow(clippy::wildcard_imports)]
10use checks::*;
11#[allow(clippy::wildcard_imports)]
12use common::*;
13
14pub(super) const GREEN: &str = "\x1b[32m";
15
16pub(super) const RED: &str = "\x1b[31m";
17
18pub(super) const BOLD: &str = "\x1b[1m";
19
20pub(super) const RST: &str = "\x1b[0m";
21
22pub(super) const DIM: &str = "\x1b[2m";
23
24pub(super) const WHITE: &str = "\x1b[97m";
25
26pub(super) const YELLOW: &str = "\x1b[33m";
27
28pub(super) struct Outcome {
29 pub ok: bool,
30 pub line: String,
31}
32
33pub fn run() {
35 let mut passed = 0u32;
36 let total = 10u32;
37
38 println!("{BOLD}{WHITE}lean-ctx doctor{RST} {DIM}diagnostics{RST}\n");
39
40 let path_bin = resolve_lean_ctx_binary();
42 let also_in_path_dirs = path_in_path_env();
43 let bin_ok = path_bin.is_some() || also_in_path_dirs;
44 if bin_ok {
45 passed += 1;
46 }
47 let bin_line = if let Some(p) = path_bin {
48 format!("{BOLD}lean-ctx in PATH{RST} {WHITE}{}{RST}", p.display())
49 } else if also_in_path_dirs {
50 format!(
51 "{BOLD}lean-ctx in PATH{RST} {YELLOW}found via PATH walk (not resolved by `command -v`){RST}"
52 )
53 } else {
54 format!("{BOLD}lean-ctx in PATH{RST} {RED}not found{RST}")
55 };
56 print_check(&Outcome {
57 ok: bin_ok,
58 line: bin_line,
59 });
60
61 let ver = if bin_ok {
63 lean_ctx_version_from_path()
64 } else {
65 Outcome {
66 ok: false,
67 line: format!("{BOLD}lean-ctx version{RST} {RED}skipped (binary not in PATH){RST}"),
68 }
69 };
70 if ver.ok {
71 passed += 1;
72 }
73 print_check(&ver);
74
75 let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
77 let dir_outcome = match &lean_dir {
78 Some(p) if p.is_dir() => {
79 passed += 1;
80 Outcome {
81 ok: true,
82 line: format!(
83 "{BOLD}data dir{RST} {GREEN}exists{RST} {DIM}{}{RST}",
84 p.display()
85 ),
86 }
87 }
88 Some(p) => Outcome {
89 ok: false,
90 line: format!(
91 "{BOLD}data dir{RST} {RED}missing or not a directory{RST} {DIM}{}{RST}",
92 p.display()
93 ),
94 },
95 None => Outcome {
96 ok: false,
97 line: format!("{BOLD}data dir{RST} {RED}could not resolve data directory{RST}"),
98 },
99 };
100 print_check(&dir_outcome);
101
102 let stats_path = lean_dir.as_ref().map(|d| d.join("stats.json"));
104 let stats_outcome = match stats_path.as_ref().and_then(|p| std::fs::metadata(p).ok()) {
105 Some(m) if m.is_file() => {
106 passed += 1;
107 let size = m.len();
108 let path_display = if let Some(p) = stats_path.as_ref() {
109 p.display().to_string()
110 } else {
111 String::new()
112 };
113 Outcome {
114 ok: true,
115 line: format!(
116 "{BOLD}stats.json{RST} {GREEN}exists{RST} {WHITE}{size} bytes{RST} {DIM}{path_display}{RST}",
117 ),
118 }
119 }
120 Some(_m) => {
121 let path_display = if let Some(p) = stats_path.as_ref() {
122 p.display().to_string()
123 } else {
124 String::new()
125 };
126 Outcome {
127 ok: false,
128 line: format!(
129 "{BOLD}stats.json{RST} {RED}not a file{RST} {DIM}{path_display}{RST}",
130 ),
131 }
132 }
133 None => {
134 passed += 1;
135 Outcome {
136 ok: true,
137 line: match &stats_path {
138 Some(p) => format!(
139 "{BOLD}stats.json{RST} {YELLOW}not yet created{RST} {DIM}(will appear after first use) {}{RST}",
140 p.display()
141 ),
142 None => format!("{BOLD}stats.json{RST} {RED}could not resolve path{RST}"),
143 },
144 }
145 }
146 };
147 print_check(&stats_outcome);
148
149 let split_dirs = crate::core::data_dir::all_data_dirs_with_stats();
150 if split_dirs.len() >= 2 {
151 let dirs_str = split_dirs
152 .iter()
153 .map(|d| d.display().to_string())
154 .collect::<Vec<_>>()
155 .join(", ");
156 print_check(&Outcome {
157 ok: false,
158 line: format!(
159 "{BOLD}data dir split{RST} {RED}stats.json found in {count} locations{RST}: {dirs_str} {DIM}(run: lean-ctx setup to auto-merge){RST}",
160 count = split_dirs.len(),
161 ),
162 });
163 }
164
165 let config_path = lean_dir.as_ref().map(|d| d.join("config.toml"));
167 let config_outcome = match &config_path {
168 Some(p) => match std::fs::metadata(p) {
169 Ok(m) if m.is_file() => {
170 passed += 1;
171 Outcome {
172 ok: true,
173 line: format!(
174 "{BOLD}config.toml{RST} {GREEN}exists{RST} {DIM}{}{RST}",
175 p.display()
176 ),
177 }
178 }
179 Ok(_) => Outcome {
180 ok: false,
181 line: format!(
182 "{BOLD}config.toml{RST} {RED}exists but is not a regular file{RST} {DIM}{}{RST}",
183 p.display()
184 ),
185 },
186 Err(_) => {
187 passed += 1;
188 Outcome {
189 ok: true,
190 line: format!(
191 "{BOLD}config.toml{RST} {YELLOW}not found, using defaults{RST} {DIM}(expected at {}){RST}",
192 p.display()
193 ),
194 }
195 }
196 },
197 None => Outcome {
198 ok: false,
199 line: format!("{BOLD}config.toml{RST} {RED}could not resolve path{RST}"),
200 },
201 };
202 print_check(&config_outcome);
203
204 let allowlist_outcome = shell_allowlist_outcome();
206 if allowlist_outcome.ok {
207 passed += 1;
208 }
209 print_check(&allowlist_outcome);
210
211 let passthrough_outcome = compact_format_passthrough_outcome();
213 if passthrough_outcome.ok {
214 passed += 1;
215 }
216 print_check(&passthrough_outcome);
217
218 let perm_inherit_outcome = permission_inheritance_outcome();
220 if perm_inherit_outcome.ok {
221 passed += 1;
222 }
223 print_check(&perm_inherit_outcome);
224
225 let proxy_outcome = proxy_upstream_outcome();
227 if proxy_outcome.ok {
228 passed += 1;
229 }
230 print_check(&proxy_outcome);
231
232 let aliases = shell_aliases_outcome();
234 if aliases.ok {
235 passed += 1;
236 }
237 print_check(&aliases);
238
239 let mcp = mcp_config_outcome();
241 if mcp.ok {
242 passed += 1;
243 }
244 print_check(&mcp);
245
246 let workspace_scope = workspace_scope::workspace_scope_outcome(mcp.ok);
248 if let Some(ref ws) = workspace_scope {
249 if ws.ok {
250 passed += 1;
251 }
252 print_check(ws);
253 }
254
255 let skill = skill_files_outcome();
257 if skill.ok {
258 passed += 1;
259 }
260 print_check(&skill);
261
262 let port = port_3333_outcome();
264 if port.ok {
265 passed += 1;
266 }
267 print_check(&port);
268
269 #[cfg(unix)]
271 let daemon_outcome = {
272 let autostart = crate::daemon_autostart::is_installed();
273 let autostart_tag = if autostart {
274 format!(" {DIM}[autostart: on]{RST}")
275 } else {
276 String::new()
277 };
278 if crate::daemon::is_daemon_running() {
279 let pid_path = crate::daemon::daemon_pid_path();
280 let pid_str = std::fs::read_to_string(&pid_path).unwrap_or_default();
281 Outcome {
282 ok: true,
283 line: format!(
284 "{BOLD}Daemon{RST} {GREEN}running (PID {}){RST}{autostart_tag}",
285 pid_str.trim()
286 ),
287 }
288 } else {
289 let hint = if autostart {
290 format!("{DIM}(autostart enabled, will restart){RST}")
291 } else {
292 format!("{DIM}(run: lean-ctx daemon start or: lean-ctx daemon enable){RST}")
293 };
294 Outcome {
295 ok: true,
296 line: format!("{BOLD}Daemon{RST} {YELLOW}not running{RST} {hint}"),
297 }
298 }
299 };
300 #[cfg(not(unix))]
301 let daemon_outcome = Outcome {
302 ok: true,
303 line: format!("{BOLD}Daemon{RST} {DIM}not supported on this platform{RST}"),
304 };
305 if daemon_outcome.ok {
306 passed += 1;
307 }
308 print_check(&daemon_outcome);
309
310 #[cfg(target_os = "linux")]
312 {
313 if let Ok(o) = std::process::Command::new("systemctl")
314 .args(["--user", "is-active", "lean-ctx-daemon.service"])
315 .output()
316 {
317 let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
318 if state != "active" {
319 println!(
320 " {DIM} systemd unit state: {YELLOW}{state}{RST}{DIM} (expected: active){RST}"
321 );
322 }
323 }
324 let username = std::env::var("USER")
325 .or_else(|_| std::env::var("LOGNAME"))
326 .unwrap_or_else(|_| "$(whoami)".to_string());
327 if let Ok(o) = std::process::Command::new("loginctl")
328 .args(["show-user", &username, "-p", "Linger", "--value"])
329 .output()
330 {
331 let val = String::from_utf8_lossy(&o.stdout).trim().to_string();
332 if val != "yes" {
333 println!(
334 " {YELLOW}⚠{RST} Linger not enabled — daemon won't start at boot without login"
335 );
336 println!(" {DIM}Fix: loginctl enable-linger {username}{RST}");
337 }
338 }
339 }
340 if let Some(log_path) = crate::core::startup_guard::crash_loop_log_path(
341 crate::core::startup_guard::MCP_PROCESS_NAME,
342 ) {
343 if log_path.exists() {
344 if let Ok(contents) = std::fs::read_to_string(&log_path) {
345 let lines: Vec<&str> = contents.lines().collect();
346 if lines.len() >= 5 {
347 println!(
348 " {YELLOW}⚠{RST} Crash-loop log: {} recent restarts {DIM}({}){RST}",
349 lines.len(),
350 log_path.display()
351 );
352 }
353 }
354 }
355 }
356
357 let provider_outcome = provider_outcome();
359 print_check(&provider_outcome);
360
361 let bridge_outcomes = mcp_bridge_outcomes();
363 for bridge_check in &bridge_outcomes {
364 print_check(bridge_check);
365 }
366
367 let plan_outcomes = plan_mode_outcomes();
369 for plan_check in &plan_outcomes {
370 print_check(plan_check);
371 }
372
373 let session_outcome = session_state_outcome();
375 if session_outcome.ok {
376 passed += 1;
377 }
378 print_check(&session_outcome);
379
380 let docker_outcomes = docker_env_outcomes();
382 for docker_check in &docker_outcomes {
383 if docker_check.ok {
384 passed += 1;
385 }
386 print_check(docker_check);
387 }
388
389 let pi = pi_outcome();
391 if let Some(ref pi_check) = pi {
392 if pi_check.ok {
393 passed += 1;
394 }
395 print_check(pi_check);
396 }
397
398 let integrity = crate::core::integrity::check();
400 let integrity_ok = integrity.seed_ok && integrity.origin_ok;
401 if integrity_ok {
402 passed += 1;
403 }
404 let integrity_line = if integrity_ok {
405 format!(
406 "{BOLD}Build origin{RST} {GREEN}official{RST} {DIM}{}{RST}",
407 integrity.repo
408 )
409 } else {
410 format!(
411 "{BOLD}Build origin{RST} {RED}MODIFIED REDISTRIBUTION{RST} {YELLOW}pkg={}, repo={}{RST}",
412 integrity.pkg_name, integrity.repo
413 )
414 };
415 print_check(&Outcome {
416 ok: integrity_ok,
417 line: integrity_line,
418 });
419
420 let cache_safety = cache_safety_outcome();
422 if cache_safety.ok {
423 passed += 1;
424 }
425 print_check(&cache_safety);
426
427 let claude_truncation = claude_truncation_outcome();
429 if let Some(ref ct) = claude_truncation {
430 if ct.ok {
431 passed += 1;
432 }
433 print_check(ct);
434 }
435
436 let bm25_health = bm25_cache_health_outcome();
438 if bm25_health.ok {
439 passed += 1;
440 }
441 print_check(&bm25_health);
442
443 let semantic_index = semantic_index_outcome();
446 if let Some(ref check) = semantic_index {
447 if check.ok {
448 passed += 1;
449 }
450 print_check(check);
451 }
452
453 let archive_footprint = archive_footprint_outcome();
455 if archive_footprint.ok {
456 passed += 1;
457 }
458 print_check(&archive_footprint);
459
460 let mem_profile = memory_profile_outcome();
462 passed += 1;
463 print_check(&mem_profile);
464
465 let mem_cleanup = memory_cleanup_outcome();
467 passed += 1;
468 print_check(&mem_cleanup);
469
470 let ram_outcome = ram_guardian_outcome();
472 if ram_outcome.ok {
473 passed += 1;
474 }
475 print_check(&ram_outcome);
476
477 let cap_warnings = capacity_warnings();
479 for cw in &cap_warnings {
480 if cw.ok {
481 passed += 1;
482 }
483 print_check(cw);
484 }
485
486 let proxy_health = proxy_health_outcome();
488 if proxy_health.ok {
489 passed += 1;
490 }
491 print_check(&proxy_health);
492
493 let stale_env = stale_proxy_env_outcome();
495 if let Some(ref check) = stale_env {
496 if check.ok {
497 passed += 1;
498 }
499 print_check(check);
500 }
501
502 println!("\n {BOLD}{WHITE}LSP (optional — for ctx_refactor):{RST}");
504 let lsp_outcomes = lsp_server_outcomes();
505 for lsp_check in &lsp_outcomes {
506 print_check(lsp_check);
507 }
508
509 let mut effective_total = total + 10; effective_total += 1; effective_total += 1; effective_total += 1; effective_total += cap_warnings.len() as u32;
514 effective_total += docker_outcomes.len() as u32;
515 if pi.is_some() {
516 effective_total += 1;
517 }
518 if claude_truncation.is_some() {
519 effective_total += 1;
520 }
521 if stale_env.is_some() {
522 effective_total += 1;
523 }
524 if workspace_scope.is_some() {
525 effective_total += 1;
526 }
527 if semantic_index.is_some() {
528 effective_total += 1;
529 }
530 let cfg = crate::core::config::Config::load();
532 let shadow_line = if cfg.shadow_mode {
533 format!("{BOLD}Shadow mode{RST} {GREEN}active{RST} {DIM}(native tools intercepted → ctx_*){RST}")
534 } else {
535 format!("{BOLD}Shadow mode{RST} {DIM}disabled{RST} {DIM}(enable: lean-ctx config set shadow_mode true){RST}")
536 };
537 println!(" {shadow_line}");
538
539 let tool_profile = crate::core::tool_profiles::ToolProfile::from_config(&cfg);
543 println!(
544 " {BOLD}Tool profile{RST} {WHITE}{tool_profile}{RST} {DIM}{} + ctx_call gateway{RST}",
545 tool_profile.description()
546 );
547
548 let needs_attention = effective_total.saturating_sub(passed);
549 println!();
550 println!(" {BOLD}{WHITE}Summary:{RST} {GREEN}{passed}{RST}{DIM}/{effective_total}{RST} checks passed");
551 if needs_attention > 0 {
552 println!(
553 " {YELLOW}{needs_attention} check(s) need attention.{RST} Auto-repair what's fixable: {BOLD}lean-ctx doctor --fix{RST}"
554 );
555 } else {
556 println!(" {GREEN}Everything looks good.{RST}");
557 }
558 println!(" {DIM}LSP servers are optional enhancements (not counted in score){RST}");
559 println!(" {DIM}{}{RST}", crate::core::integrity::origin_line());
560}
561
562pub fn run_compact() {
563 let (passed, total) = compact_score();
564 print_compact_status(passed, total);
565}
566
567pub fn run_cli(args: &[String]) -> i32 {
568 let (sub, rest) = match args.first().map(String::as_str) {
569 Some("integrations") => ("integrations", &args[1..]),
570 _ => ("", args),
571 };
572
573 let fix = rest.iter().any(|a| a == "--fix");
574 let json = rest.iter().any(|a| a == "--json");
575 let help = rest.iter().any(|a| a == "--help" || a == "-h");
576
577 if help {
578 println!("Usage:");
579 println!(" lean-ctx doctor");
580 println!(" lean-ctx doctor integrations [--json]");
581 println!(" lean-ctx doctor --fix [--json]");
582 return 0;
583 }
584
585 if sub == "integrations" {
586 if fix {
587 let _ = fix::run_fix(&fix::DoctorFixOptions { json: false });
588 }
589 return integrations::run_integrations(&integrations::IntegrationsOptions { json });
590 }
591
592 if !fix {
593 run();
594 return 0;
595 }
596
597 match fix::run_fix(&fix::DoctorFixOptions { json }) {
598 Ok(code) => code,
599 Err(e) => {
600 tracing::error!("doctor --fix failed: {e}");
601 2
602 }
603 }
604}
605
606pub fn compact_score() -> (u32, u32) {
607 let mut passed = 0u32;
608 let total = 6u32;
609
610 if resolve_lean_ctx_binary().is_some() || path_in_path_env() {
611 passed += 1;
612 }
613 let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
614 if lean_dir.as_ref().is_some_and(|p| p.is_dir()) {
615 passed += 1;
616 }
617 if lean_dir
618 .as_ref()
619 .map(|d| d.join("stats.json"))
620 .and_then(|p| std::fs::metadata(p).ok())
621 .is_some_and(|m| m.is_file())
622 {
623 passed += 1;
624 }
625 if shell_aliases_outcome().ok {
626 passed += 1;
627 }
628 if mcp_config_outcome().ok {
629 passed += 1;
630 }
631 if skill_files_outcome().ok {
632 passed += 1;
633 }
634
635 (passed, total)
636}
637
638pub(super) fn print_compact_status(passed: u32, total: u32) {
639 let status = if passed == total {
640 format!("{GREEN}✓ All {total} checks passed{RST}")
641 } else {
642 format!("{YELLOW}{passed}/{total} passed{RST} — run {BOLD}lean-ctx doctor{RST} for details")
643 };
644 println!(" {status}");
645}
646
647#[cfg(test)]
648mod tests {
649 use super::is_active_shell_impl;
650
651 fn make_capacity_check(name: &str, current: usize, limit: usize) -> Option<(bool, String)> {
655 if limit == 0 {
656 return None;
657 }
658 let pct = (current as f64 / limit as f64 * 100.0) as u32;
659 if pct > 100 {
660 Some((true, format!("{name}: {current}/{limit} ({pct}%)")))
661 } else if pct >= 80 {
662 Some((false, format!("{name}: {current}/{limit} ({pct}%)")))
663 } else {
664 None
665 }
666 }
667
668 #[test]
669 fn capacity_below_80_no_warning() {
670 assert!(make_capacity_check("facts", 100, 200).is_none());
671 assert!(make_capacity_check("facts", 159, 200).is_none());
672 }
673
674 #[test]
675 fn capacity_at_80_yellow_warning() {
676 let result = make_capacity_check("facts", 160, 200);
677 assert!(result.is_some());
678 let (critical, msg) = result.unwrap();
679 assert!(!critical);
680 assert!(msg.contains("160/200"));
681 assert!(msg.contains("80%"));
682 }
683
684 #[test]
685 fn capacity_at_92_yellow_warning() {
686 let result = make_capacity_check("facts", 185, 200);
687 assert!(result.is_some());
688 let (critical, msg) = result.unwrap();
689 assert!(!critical);
690 assert!(msg.contains("185/200"));
691 assert!(msg.contains("92%"));
692 }
693
694 #[test]
695 fn capacity_at_95_is_warning_not_critical() {
696 let result = make_capacity_check("facts", 190, 200);
697 assert!(result.is_some());
698 let (critical, msg) = result.unwrap();
699 assert!(!critical, "95% is full-but-healthy, not over cap");
700 assert!(msg.contains("190/200"));
701 assert!(msg.contains("95%"));
702 }
703
704 #[test]
705 fn capacity_at_100_is_warning_not_critical() {
706 let result = make_capacity_check("facts", 200, 200);
708 assert!(result.is_some());
709 let (critical, _) = result.unwrap();
710 assert!(!critical);
711 }
712
713 #[test]
714 fn capacity_over_100_is_critical() {
715 let result = make_capacity_check("facts", 206, 200);
718 assert!(result.is_some());
719 let (critical, msg) = result.unwrap();
720 assert!(critical);
721 assert!(msg.contains("206/200"));
722 assert!(msg.contains("103%"));
723 }
724
725 #[test]
726 fn capacity_zero_limit_skipped() {
727 assert!(make_capacity_check("facts", 50, 0).is_none());
728 }
729
730 #[test]
731 fn bashrc_active_on_non_windows_when_shell_empty() {
732 assert!(is_active_shell_impl("~/.bashrc", "", false, false));
733 }
734
735 #[test]
736 fn bashrc_not_active_on_windows_when_shell_empty() {
737 assert!(!is_active_shell_impl("~/.bashrc", "", true, false));
738 }
739
740 #[test]
741 fn bashrc_active_when_shell_contains_bash_on_linux() {
742 assert!(is_active_shell_impl(
743 "~/.bashrc",
744 "/usr/bin/bash",
745 false,
746 false
747 ));
748 }
749
750 #[test]
751 fn bashrc_not_active_on_windows_even_with_bash_in_shell_env() {
752 std::env::remove_var("BASH_VERSION");
755 assert!(!is_active_shell_impl(
756 "~/.bashrc",
757 "C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe",
758 true,
759 false,
760 ));
761 }
762
763 #[test]
764 fn bashrc_not_active_on_windows_powershell_even_with_bash_in_shell() {
765 assert!(!is_active_shell_impl(
766 "~/.bashrc",
767 "C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe",
768 true,
769 true,
770 ));
771 }
772
773 #[test]
774 fn bashrc_not_active_on_windows_powershell_with_empty_shell() {
775 assert!(!is_active_shell_impl("~/.bashrc", "", true, true));
776 }
777
778 #[test]
779 fn zshrc_unaffected_by_powershell_flag() {
780 assert!(is_active_shell_impl("~/.zshrc", "/bin/zsh", false, false));
781 assert!(is_active_shell_impl("~/.zshrc", "/bin/zsh", true, true));
782 }
783
784 #[test]
785 fn bashrc_not_active_on_windows_without_powershell_detection() {
786 std::env::remove_var("BASH_VERSION");
789 assert!(!is_active_shell_impl(
790 "~/.bashrc",
791 "/usr/bin/bash",
792 true,
793 false,
794 ));
795 }
796
797 #[test]
798 fn bashrc_active_on_linux() {
799 assert!(is_active_shell_impl("~/.bashrc", "/bin/bash", false, false));
800 assert!(is_active_shell_impl("~/.bashrc", "", false, false));
801 }
802}