1mod checks;
4mod common;
5mod deprecations;
6mod fix;
7mod integrations;
8mod lint_context;
9mod migrate;
10mod overhead;
11mod report;
12mod workspace_scope;
13
14#[allow(clippy::wildcard_imports)]
15use checks::*;
16#[allow(clippy::wildcard_imports)]
17use common::*;
18
19pub use report::{HealthCheck, HealthLevel, HealthReport, health_report};
20
21pub fn run_fix_report() -> Result<crate::core::setup_report::SetupReport, String> {
27 fix::fix_report()
28}
29
30pub(super) const GREEN: &str = "\x1b[32m";
31
32pub(super) const RED: &str = "\x1b[31m";
33
34pub(super) const BOLD: &str = "\x1b[1m";
35
36pub(super) const RST: &str = "\x1b[0m";
37
38pub(super) const DIM: &str = "\x1b[2m";
39
40pub(super) const WHITE: &str = "\x1b[97m";
41
42pub(super) const YELLOW: &str = "\x1b[33m";
43
44pub(super) struct Outcome {
45 pub ok: bool,
46 pub line: String,
47}
48
49#[derive(Default)]
57struct Scoreboard {
58 passed: u32,
59 total: u32,
60}
61
62impl Scoreboard {
63 fn check(&mut self, outcome: &Outcome) {
65 self.total += 1;
66 if outcome.ok {
67 self.passed += 1;
68 }
69 print_check(outcome);
70 }
71
72 #[allow(clippy::unused_self)]
79 fn info(&self, outcome: &Outcome) {
80 print_check(outcome);
81 }
82}
83
84pub fn run() -> u32 {
89 let mut board = Scoreboard::default();
90
91 println!("{BOLD}{WHITE}lean-ctx doctor{RST} {DIM}diagnostics{RST}\n");
92
93 let path_bin = resolve_lean_ctx_binary();
95 let also_in_path_dirs = path_in_path_env();
96 let bin_ok = path_bin.is_some() || also_in_path_dirs;
97 let bin_line = if let Some(p) = path_bin {
98 format!("{BOLD}lean-ctx in PATH{RST} {WHITE}{}{RST}", p.display())
99 } else if also_in_path_dirs {
100 format!(
101 "{BOLD}lean-ctx in PATH{RST} {YELLOW}found via PATH walk (not resolved by `command -v`){RST}"
102 )
103 } else {
104 format!("{BOLD}lean-ctx in PATH{RST} {RED}not found{RST}")
105 };
106 board.check(&Outcome {
107 ok: bin_ok,
108 line: bin_line,
109 });
110
111 let ver = if bin_ok {
113 lean_ctx_version_from_path()
114 } else {
115 Outcome {
116 ok: false,
117 line: format!("{BOLD}lean-ctx version{RST} {RED}skipped (binary not in PATH){RST}"),
118 }
119 };
120 board.check(&ver);
121
122 let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
124 let dir_outcome = match &lean_dir {
125 Some(p) if p.is_dir() => Outcome {
126 ok: true,
127 line: format!(
128 "{BOLD}data dir{RST} {GREEN}exists{RST} {DIM}{}{RST}",
129 p.display()
130 ),
131 },
132 Some(p) => Outcome {
133 ok: false,
134 line: format!(
135 "{BOLD}data dir{RST} {RED}missing or not a directory{RST} {DIM}{}{RST}",
136 p.display()
137 ),
138 },
139 None => Outcome {
140 ok: false,
141 line: format!("{BOLD}data dir{RST} {RED}could not resolve data directory{RST}"),
142 },
143 };
144 board.check(&dir_outcome);
145
146 let stats_path = lean_dir.as_ref().map(|d| d.join("stats.json"));
148 let stats_outcome = match stats_path.as_ref().and_then(|p| std::fs::metadata(p).ok()) {
149 Some(m) if m.is_file() => {
150 let size = m.len();
151 let path_display = if let Some(p) = stats_path.as_ref() {
152 p.display().to_string()
153 } else {
154 String::new()
155 };
156 Outcome {
157 ok: true,
158 line: format!(
159 "{BOLD}stats.json{RST} {GREEN}exists{RST} {WHITE}{size} bytes{RST} {DIM}{path_display}{RST}",
160 ),
161 }
162 }
163 Some(_m) => {
164 let path_display = if let Some(p) = stats_path.as_ref() {
165 p.display().to_string()
166 } else {
167 String::new()
168 };
169 Outcome {
170 ok: false,
171 line: format!(
172 "{BOLD}stats.json{RST} {RED}not a file{RST} {DIM}{path_display}{RST}",
173 ),
174 }
175 }
176 None => Outcome {
177 ok: true,
178 line: match &stats_path {
179 Some(p) => format!(
180 "{BOLD}stats.json{RST} {YELLOW}not yet created{RST} {DIM}(will appear after first use) {}{RST}",
181 p.display()
182 ),
183 None => format!("{BOLD}stats.json{RST} {RED}could not resolve path{RST}"),
184 },
185 },
186 };
187 board.check(&stats_outcome);
188
189 let split_dirs = crate::core::data_dir::all_data_dirs_with_stats();
190 if split_dirs.len() >= 2 {
191 let dirs_str = split_dirs
192 .iter()
193 .map(|d| d.display().to_string())
194 .collect::<Vec<_>>()
195 .join(", ");
196 board.check(&Outcome {
197 ok: false,
198 line: format!(
199 "{BOLD}data dir split{RST} {RED}stats.json found in {count} locations{RST}: {dirs_str} {DIM}(run: lean-ctx doctor --fix to merge){RST}",
200 count = split_dirs.len(),
201 ),
202 });
203 }
204
205 if let Some((src, n)) = crate::core::xdg_migrate::pending() {
210 board.check(&Outcome {
211 ok: false,
212 line: format!(
213 "{BOLD}XDG layout{RST} {YELLOW}{n} item(s) in single dir{RST} {DIM}{}{RST} {DIM}(run: lean-ctx doctor --fix to split into config/data/state/cache){RST}",
214 src.display()
215 ),
216 });
217 }
218
219 if let Some(stray) = crate::core::config_heal::pending() {
224 board.check(&Outcome {
225 ok: false,
226 line: format!(
227 "{BOLD}config location{RST} {YELLOW}stray config.toml in the data dir{RST} {DIM}{}{RST} {DIM}(run: lean-ctx doctor --fix to unify CLI + MCP){RST}",
228 stray.display()
229 ),
230 });
231 }
232
233 {
237 let pinned = crate::core::layout_pin::is_xdg_pinned();
238 let residual = crate::core::xdg_migrate::residual_legacy_present();
239 let line = if pinned && residual {
240 format!(
241 "{BOLD}layout{RST} {GREEN}xdg-pinned{RST} {YELLOW}residual ~/.lean-ctx present{RST} {DIM}(auto-reclaimed on next start){RST}"
242 )
243 } else if pinned {
244 format!(
245 "{BOLD}layout{RST} {GREEN}xdg-pinned{RST} {DIM}(~/.lean-ctx can no longer hijack this install){RST}"
246 )
247 } else {
248 format!(
249 "{BOLD}layout{RST} {WHITE}single-dir / legacy{RST} {DIM}(run: lean-ctx doctor --fix to commit to XDG){RST}"
250 )
251 };
252 board.check(&Outcome { ok: true, line });
253 }
254
255 let config_path = crate::core::config::Config::path();
260 let config_outcome = match &config_path {
261 Some(p) => match std::fs::metadata(p) {
262 Ok(m) if m.is_file() => Outcome {
263 ok: true,
264 line: format!(
265 "{BOLD}config.toml{RST} {GREEN}exists{RST} {DIM}{}{RST}",
266 p.display()
267 ),
268 },
269 Ok(_) => Outcome {
270 ok: false,
271 line: format!(
272 "{BOLD}config.toml{RST} {RED}exists but is not a regular file{RST} {DIM}{}{RST}",
273 p.display()
274 ),
275 },
276 Err(_) => Outcome {
277 ok: true,
278 line: format!(
279 "{BOLD}config.toml{RST} {YELLOW}not found, using defaults{RST} {DIM}(expected at {}){RST}",
280 p.display()
281 ),
282 },
283 },
284 None => Outcome {
285 ok: false,
286 line: format!("{BOLD}config.toml{RST} {RED}could not resolve path{RST}"),
287 },
288 };
289 board.check(&config_outcome);
290
291 board.check(&config_parity_outcome());
295
296 let allowlist_outcome = shell_allowlist_outcome();
298 board.check(&allowlist_outcome);
299
300 let path_jail = path_jail_outcome();
302 board.check(&path_jail);
303
304 let workspace_trust = workspace_trust_outcome();
306 board.check(&workspace_trust);
307
308 let secret_detection = secret_detection_outcome();
311 board.check(&secret_detection);
312
313 if let Some(managed_bins) = managed_addon_binaries_outcome() {
317 board.check(&managed_bins);
318 }
319
320 if let Some(managed_ort) = managed_ort_outcome() {
323 board.check(&managed_ort);
324 }
325
326 let cognition = cognition_activity_outcome();
328 board.check(&cognition);
329
330 let passthrough_outcome = compact_format_passthrough_outcome();
332 board.check(&passthrough_outcome);
333
334 let perm_inherit_outcome = permission_inheritance_outcome();
336 board.check(&perm_inherit_outcome);
337
338 let proxy_outcome = proxy_upstream_outcome();
340 board.check(&proxy_outcome);
341
342 let aliases = shell_aliases_outcome();
344 board.check(&aliases);
345
346 let agent_aliases = skip_agent_aliases_outcome();
348 board.check(&agent_aliases);
349
350 let mcp = mcp_config_outcome();
352 board.check(&mcp);
353 let user_scope_mcp_locations = dirs::home_dir()
354 .map(|home| lean_ctx_mcp_location_names(&home))
355 .unwrap_or_default();
356
357 if let Some(wsl_hint) = wsl_vscode_mcp_outcome() {
359 board.check(&wsl_hint);
360 }
361
362 let workspace_scope = workspace_scope::workspace_scope_outcome(&user_scope_mcp_locations);
364 if let Some(ref ws) = workspace_scope {
365 board.check(ws);
366 }
367
368 let skill = skill_files_outcome();
370 board.check(&skill);
371
372 let port = port_3333_outcome();
374 board.check(&port);
375
376 #[cfg(unix)]
378 let daemon_outcome = {
379 let autostart = crate::daemon_autostart::is_installed();
380 let autostart_tag = if autostart {
383 match crate::daemon_autostart::service_file_path() {
384 Some(p) => format!(" {DIM}[autostart: on — {}]{RST}", p.display()),
385 None => format!(" {DIM}[autostart: on]{RST}"),
386 }
387 } else {
388 String::new()
389 };
390 if crate::daemon::is_daemon_running() {
391 let pid_path = crate::daemon::daemon_pid_path();
392 let pid_str = std::fs::read_to_string(&pid_path).unwrap_or_default();
393 Outcome {
394 ok: true,
395 line: format!(
396 "{BOLD}Daemon{RST} {GREEN}running (PID {}){RST}{autostart_tag}",
397 pid_str.trim()
398 ),
399 }
400 } else {
401 let hint = if autostart {
402 format!("{DIM}(autostart enabled, will restart){RST}")
403 } else {
404 format!("{DIM}(run: lean-ctx daemon start or: lean-ctx daemon enable){RST}")
405 };
406 Outcome {
407 ok: true,
408 line: format!("{BOLD}Daemon{RST} {YELLOW}not running{RST} {hint}"),
409 }
410 }
411 };
412 #[cfg(not(unix))]
413 let daemon_outcome = Outcome {
414 ok: true,
415 line: format!("{BOLD}Daemon{RST} {DIM}not supported on this platform{RST}"),
416 };
417 board.check(&daemon_outcome);
418
419 #[cfg(target_os = "linux")]
421 {
422 if let Ok(o) = std::process::Command::new("systemctl")
423 .args(["--user", "is-active", "lean-ctx-daemon.service"])
424 .output()
425 {
426 let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
427 if state != "active" {
428 println!(
429 " {DIM} systemd unit state: {YELLOW}{state}{RST}{DIM} (expected: active){RST}"
430 );
431 }
432 }
433 let username = std::env::var("USER")
434 .or_else(|_| std::env::var("LOGNAME"))
435 .unwrap_or_else(|_| "$(whoami)".to_string());
436 if let Ok(o) = std::process::Command::new("loginctl")
437 .args(["show-user", &username, "-p", "Linger", "--value"])
438 .output()
439 {
440 let val = String::from_utf8_lossy(&o.stdout).trim().to_string();
441 if val != "yes" {
442 println!(
443 " {YELLOW}⚠{RST} Linger not enabled — daemon won't start at boot without login"
444 );
445 println!(" {DIM}Fix: loginctl enable-linger {username}{RST}");
446 }
447 }
448 }
449 if let Some(log_path) = crate::core::startup_guard::crash_loop_log_path(
450 crate::core::startup_guard::MCP_PROCESS_NAME,
451 ) && log_path.exists()
452 && let Ok(contents) = std::fs::read_to_string(&log_path)
453 {
454 let lines: Vec<&str> = contents.lines().collect();
455 if lines.len() >= 5 {
456 println!(
457 " {YELLOW}⚠{RST} Crash-loop log: {} recent restarts {DIM}({}){RST}",
458 lines.len(),
459 display_user_path(&log_path)
460 );
461 }
462 }
463
464 let provider_outcome = provider_outcome();
466 board.info(&provider_outcome);
467
468 let bridge_outcomes = mcp_bridge_outcomes();
470 for bridge_check in &bridge_outcomes {
471 board.info(bridge_check);
472 }
473
474 let plan_outcomes = plan_mode_outcomes();
476 for plan_check in &plan_outcomes {
477 board.info(plan_check);
478 }
479
480 let session_outcome = session_state_outcome();
482 board.check(&session_outcome);
483
484 let docker_outcomes = docker_env_outcomes();
486 for docker_check in &docker_outcomes {
487 board.check(docker_check);
488 }
489
490 let pi = pi_outcome();
492 if let Some(ref pi_check) = pi {
493 board.check(pi_check);
494 }
495
496 let integrity = crate::core::integrity::check();
498 let integrity_ok = integrity.seed_ok && integrity.origin_ok;
499 let integrity_line = if integrity_ok {
500 format!(
501 "{BOLD}Build origin{RST} {GREEN}official{RST} {DIM}{}{RST}",
502 integrity.repo
503 )
504 } else {
505 format!(
506 "{BOLD}Build origin{RST} {RED}MODIFIED REDISTRIBUTION{RST} {YELLOW}pkg={}, repo={}{RST}",
507 integrity.pkg_name, integrity.repo
508 )
509 };
510 board.check(&Outcome {
511 ok: integrity_ok,
512 line: integrity_line,
513 });
514
515 let cache_safety = cache_safety_outcome();
517 board.check(&cache_safety);
518
519 let claude_truncation = claude_truncation_outcome();
521 if let Some(ref ct) = claude_truncation {
522 board.check(ct);
523 }
524
525 let codebuddy_truncation = codebuddy_truncation_outcome();
527 if let Some(ref cbt) = codebuddy_truncation {
528 board.check(cbt);
529 }
530
531 let bm25_health = bm25_cache_health_outcome();
533 board.check(&bm25_health);
534
535 let stats_quarantine = stats_quarantine_outcome();
538 board.check(&stats_quarantine);
539
540 let semantic_index = semantic_index_outcome();
543 if let Some(ref check) = semantic_index {
544 board.check(check);
545 }
546
547 let archive_footprint = archive_footprint_outcome();
549 board.check(&archive_footprint);
550
551 let mem_profile = memory_profile_outcome();
553 board.check(&mem_profile);
554
555 let mem_cleanup = memory_cleanup_outcome();
557 board.check(&mem_cleanup);
558
559 let ram_outcome = ram_guardian_outcome();
561 board.check(&ram_outcome);
562
563 let cap_warnings = capacity_warnings();
565 for cw in &cap_warnings {
566 board.check(cw);
567 }
568
569 let orphan_outcome = orphaned_knowledge_outcome();
571 board.check(&orphan_outcome);
572
573 let proxy_health = proxy_health_outcome();
575 board.check(&proxy_health);
576
577 let upstream_drift = proxy_upstream_drift_outcome();
581 if let Some(ref check) = upstream_drift {
582 board.check(check);
583 }
584
585 let stale_env = stale_proxy_env_outcome();
587 if let Some(ref check) = stale_env {
588 board.check(check);
589 }
590
591 let subscription_conflict = proxy_subscription_conflict_outcome();
593 if let Some(ref check) = subscription_conflict {
594 board.check(check);
595 }
596
597 let deprecation_check = deprecations::deprecations_outcome();
600 board.check(&deprecation_check);
601
602 let mcp_cwd = mcp_server_cwd_outcome();
604 board.check(&mcp_cwd);
605
606 println!("\n {BOLD}{WHITE}LSP (optional — for ctx_refactor):{RST}");
608 let lsp_outcomes = lsp_server_outcomes();
609 for lsp_check in &lsp_outcomes {
610 board.info(lsp_check);
611 }
612
613 let cfg = crate::core::config::Config::load();
615 let shadow_line = if cfg.shadow_mode {
616 format!(
617 "{BOLD}Shadow mode{RST} {GREEN}active{RST} {DIM}(native tools denied → ctx_* mandatory){RST}"
618 )
619 } else {
620 format!(
621 "{BOLD}Shadow mode{RST} {DIM}disabled{RST} {DIM}(default: on — explicitly disabled via config){RST}"
622 )
623 };
624 println!(" {shadow_line}");
625
626 let tool_profile_line = if crate::server::tool_visibility::explicit_profile(&cfg) {
632 let profile = crate::core::tool_profiles::ToolProfile::from_config(&cfg);
633 format!(
634 "{BOLD}Tool profile{RST} {WHITE}{profile}{RST} {DIM}{} + ctx_call gateway{RST}",
635 profile.description()
636 )
637 } else {
638 let lazy_count = crate::tool_defs::core_tool_names().len();
639 format!(
640 "{BOLD}Tool profile{RST} {WHITE}lean (default){RST} {DIM}{lazy_count} lazy-core tools advertised + ctx_call gateway{RST}"
641 )
642 };
643 println!(" {tool_profile_line}");
644
645 let cep = &crate::core::stats::load().cep;
649 let hit_ratio = if cep.total_cache_reads > 0 {
650 (cep.total_cache_hits as f64 / cep.total_cache_reads as f64) * 100.0
651 } else {
652 0.0
653 };
654 println!(
655 " {BOLD}Session cache{RST} {WHITE}{} sessions{RST} {DIM}{}/{} reads cached ({hit_ratio:.0}% hit) · prove: lean-ctx verify-cache{RST}",
656 cep.sessions, cep.total_cache_hits, cep.total_cache_reads
657 );
658
659 let passed = board.passed;
662 let total = board.total;
663 let needs_attention = total.saturating_sub(passed);
664 println!();
665 println!(" {BOLD}{WHITE}Summary:{RST} {GREEN}{passed}{RST}{DIM}/{total}{RST} checks passed");
666 if needs_attention > 0 {
667 println!(
668 " {YELLOW}{needs_attention} check(s) need attention.{RST} Auto-repair what's fixable: {BOLD}lean-ctx doctor --fix{RST}"
669 );
670 } else {
671 println!(" {GREEN}Everything looks good.{RST}");
672 }
673 println!(" {DIM}LSP servers are optional enhancements (not counted in score){RST}");
674 println!(" {DIM}{}{RST}", crate::core::integrity::origin_line());
675
676 crate::core::version_check::check_background();
681 if let Some(banner) = crate::core::version_check::get_update_banner() {
682 println!();
683 println!("{banner}");
684 }
685
686 needs_attention
687}
688
689pub fn run_compact() {
690 let (passed, total) = compact_score();
691 print_compact_status(passed, total);
692}
693
694pub fn run_cli(args: &[String]) -> i32 {
695 let (sub, rest) = match args.first().map(String::as_str) {
696 Some("integrations") => ("integrations", &args[1..]),
697 Some("overhead") => ("overhead", &args[1..]),
698 Some("lint-context") => ("lint-context", &args[1..]),
699 _ => ("", args),
700 };
701
702 let fix = rest.iter().any(|a| a == "--fix");
703 let json = rest.iter().any(|a| a == "--json");
704 let gate = rest.iter().any(|a| a == "--gate");
705 let migrate_check = rest.iter().any(|a| a == "--migrate-check");
706 let help = rest.iter().any(|a| a == "--help" || a == "-h");
707
708 if help {
709 println!("Usage:");
710 println!(" lean-ctx doctor");
711 println!(
712 " lean-ctx doctor overhead [--json] [--gate] Fixed context cost per session (--gate: non-zero exit when over [context] budget_tokens)"
713 );
714 println!(
715 " lean-ctx doctor lint-context [--json] Lint injected context for low-signal/dup lines"
716 );
717 println!(" lean-ctx doctor integrations [--json]");
718 println!(" lean-ctx doctor --fix [--json]");
719 println!(" lean-ctx doctor --migrate-check [--json]");
720 return 0;
721 }
722
723 if sub == "overhead" {
724 return overhead::run_overhead(json, gate);
725 }
726
727 if sub == "lint-context" {
728 return lint_context::run_lint_context(json);
729 }
730
731 if migrate_check {
732 return migrate::run_migrate_check(json);
733 }
734
735 if sub == "integrations" {
736 if fix {
737 let _ = fix::run_fix(&fix::DoctorFixOptions { json: false });
738 }
739 return integrations::run_integrations(&integrations::IntegrationsOptions { json });
740 }
741
742 if !fix {
743 return i32::from(run() > 0);
746 }
747
748 match fix::run_fix(&fix::DoctorFixOptions { json }) {
749 Ok(code) => code,
750 Err(e) => {
751 tracing::error!("doctor --fix failed: {e}");
752 2
753 }
754 }
755}
756
757pub fn compact_score() -> (u32, u32) {
758 let mut passed = 0u32;
759 let total = 6u32;
760
761 if resolve_lean_ctx_binary().is_some() || path_in_path_env() {
762 passed += 1;
763 }
764 let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
765 if lean_dir.as_ref().is_some_and(|p| p.is_dir()) {
766 passed += 1;
767 }
768 if lean_dir
769 .as_ref()
770 .map(|d| d.join("stats.json"))
771 .and_then(|p| std::fs::metadata(p).ok())
772 .is_some_and(|m| m.is_file())
773 {
774 passed += 1;
775 }
776 if shell_aliases_outcome().ok {
777 passed += 1;
778 }
779 if mcp_config_outcome().ok {
780 passed += 1;
781 }
782 if skill_files_outcome().ok {
783 passed += 1;
784 }
785
786 (passed, total)
787}
788
789pub(super) fn print_compact_status(passed: u32, total: u32) {
790 let status = if passed == total {
791 format!("{GREEN}✓ All {total} checks passed{RST}")
792 } else {
793 format!("{YELLOW}{passed}/{total} passed{RST} — run {BOLD}lean-ctx doctor{RST} for details")
794 };
795 println!(" {status}");
796}
797
798#[cfg(test)]
799mod tests {
800 use super::is_active_shell_impl;
801
802 fn make_capacity_check(name: &str, current: usize, limit: usize) -> Option<(bool, String)> {
806 if limit == 0 {
807 return None;
808 }
809 let pct = (current as f64 / limit as f64 * 100.0) as u32;
810 if pct > 100 {
811 Some((true, format!("{name}: {current}/{limit} ({pct}%)")))
812 } else if pct >= 80 {
813 Some((false, format!("{name}: {current}/{limit} ({pct}%)")))
814 } else {
815 None
816 }
817 }
818
819 #[test]
820 fn capacity_below_80_no_warning() {
821 assert!(make_capacity_check("facts", 100, 200).is_none());
822 assert!(make_capacity_check("facts", 159, 200).is_none());
823 }
824
825 #[test]
826 fn capacity_at_80_yellow_warning() {
827 let result = make_capacity_check("facts", 160, 200);
828 assert!(result.is_some());
829 let (critical, msg) = result.unwrap();
830 assert!(!critical);
831 assert!(msg.contains("160/200"));
832 assert!(msg.contains("80%"));
833 }
834
835 #[test]
836 fn capacity_at_92_yellow_warning() {
837 let result = make_capacity_check("facts", 185, 200);
838 assert!(result.is_some());
839 let (critical, msg) = result.unwrap();
840 assert!(!critical);
841 assert!(msg.contains("185/200"));
842 assert!(msg.contains("92%"));
843 }
844
845 #[test]
846 fn capacity_at_95_is_warning_not_critical() {
847 let result = make_capacity_check("facts", 190, 200);
848 assert!(result.is_some());
849 let (critical, msg) = result.unwrap();
850 assert!(!critical, "95% is full-but-healthy, not over cap");
851 assert!(msg.contains("190/200"));
852 assert!(msg.contains("95%"));
853 }
854
855 #[test]
856 fn capacity_at_100_is_warning_not_critical() {
857 let result = make_capacity_check("facts", 200, 200);
859 assert!(result.is_some());
860 let (critical, _) = result.unwrap();
861 assert!(!critical);
862 }
863
864 #[test]
865 fn capacity_over_100_is_critical() {
866 let result = make_capacity_check("facts", 206, 200);
869 assert!(result.is_some());
870 let (critical, msg) = result.unwrap();
871 assert!(critical);
872 assert!(msg.contains("206/200"));
873 assert!(msg.contains("103%"));
874 }
875
876 #[test]
877 fn capacity_zero_limit_skipped() {
878 assert!(make_capacity_check("facts", 50, 0).is_none());
879 }
880
881 #[test]
882 fn bashrc_active_on_non_windows_when_shell_empty() {
883 assert!(is_active_shell_impl("~/.bashrc", "", false, false));
884 }
885
886 #[test]
887 fn bashrc_not_active_on_windows_when_shell_empty() {
888 assert!(!is_active_shell_impl("~/.bashrc", "", true, false));
889 }
890
891 #[test]
892 fn bashrc_active_when_shell_contains_bash_on_linux() {
893 assert!(is_active_shell_impl(
894 "~/.bashrc",
895 "/usr/bin/bash",
896 false,
897 false
898 ));
899 }
900
901 #[test]
902 fn bashrc_not_active_on_windows_even_with_bash_in_shell_env() {
903 let _env_lock = crate::core::data_dir::test_env_lock();
904 crate::test_env::remove_var("BASH_VERSION");
907 assert!(!is_active_shell_impl(
908 "~/.bashrc",
909 "C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe",
910 true,
911 false,
912 ));
913 }
914
915 #[test]
916 fn bashrc_not_active_on_windows_powershell_even_with_bash_in_shell() {
917 assert!(!is_active_shell_impl(
918 "~/.bashrc",
919 "C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe",
920 true,
921 true,
922 ));
923 }
924
925 #[test]
926 fn bashrc_not_active_on_windows_powershell_with_empty_shell() {
927 assert!(!is_active_shell_impl("~/.bashrc", "", true, true));
928 }
929
930 #[test]
931 fn zshrc_unaffected_by_powershell_flag() {
932 assert!(is_active_shell_impl("~/.zshrc", "/bin/zsh", false, false));
933 assert!(is_active_shell_impl("~/.zshrc", "/bin/zsh", true, true));
934 }
935
936 #[test]
937 fn bashrc_not_active_on_windows_without_powershell_detection() {
938 let _env_lock = crate::core::data_dir::test_env_lock();
939 crate::test_env::remove_var("BASH_VERSION");
942 assert!(!is_active_shell_impl(
943 "~/.bashrc",
944 "/usr/bin/bash",
945 true,
946 false,
947 ));
948 }
949
950 #[test]
951 fn bashrc_active_on_linux() {
952 assert!(is_active_shell_impl("~/.bashrc", "/bin/bash", false, false));
953 assert!(is_active_shell_impl("~/.bashrc", "", false, false));
954 }
955}