Skip to main content

lean_ctx/doctor/
mod.rs

1//! Environment diagnostics for lean-ctx installation and integration.
2
3mod checks;
4mod common;
5mod deprecations;
6mod fix;
7mod integrations;
8mod migrate;
9mod overhead;
10mod workspace_scope;
11
12#[allow(clippy::wildcard_imports)]
13use checks::*;
14#[allow(clippy::wildcard_imports)]
15use common::*;
16
17pub(super) const GREEN: &str = "\x1b[32m";
18
19pub(super) const RED: &str = "\x1b[31m";
20
21pub(super) const BOLD: &str = "\x1b[1m";
22
23pub(super) const RST: &str = "\x1b[0m";
24
25pub(super) const DIM: &str = "\x1b[2m";
26
27pub(super) const WHITE: &str = "\x1b[97m";
28
29pub(super) const YELLOW: &str = "\x1b[33m";
30
31pub(super) struct Outcome {
32    pub ok: bool,
33    pub line: String,
34}
35
36/// Run diagnostic checks and print colored results to stdout.
37pub fn run() {
38    let mut passed = 0u32;
39    let total = 10u32;
40
41    println!("{BOLD}{WHITE}lean-ctx doctor{RST}  {DIM}diagnostics{RST}\n");
42
43    // 1) Binary on PATH
44    let path_bin = resolve_lean_ctx_binary();
45    let also_in_path_dirs = path_in_path_env();
46    let bin_ok = path_bin.is_some() || also_in_path_dirs;
47    if bin_ok {
48        passed += 1;
49    }
50    let bin_line = if let Some(p) = path_bin {
51        format!("{BOLD}lean-ctx in PATH{RST}  {WHITE}{}{RST}", p.display())
52    } else if also_in_path_dirs {
53        format!(
54            "{BOLD}lean-ctx in PATH{RST}  {YELLOW}found via PATH walk (not resolved by `command -v`){RST}"
55        )
56    } else {
57        format!("{BOLD}lean-ctx in PATH{RST}  {RED}not found{RST}")
58    };
59    print_check(&Outcome {
60        ok: bin_ok,
61        line: bin_line,
62    });
63
64    // 2) Version from PATH binary
65    let ver = if bin_ok {
66        lean_ctx_version_from_path()
67    } else {
68        Outcome {
69            ok: false,
70            line: format!("{BOLD}lean-ctx version{RST}  {RED}skipped (binary not in PATH){RST}"),
71        }
72    };
73    if ver.ok {
74        passed += 1;
75    }
76    print_check(&ver);
77
78    // 3) data directory (respects LEAN_CTX_DATA_DIR)
79    let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
80    let dir_outcome = match &lean_dir {
81        Some(p) if p.is_dir() => {
82            passed += 1;
83            Outcome {
84                ok: true,
85                line: format!(
86                    "{BOLD}data dir{RST}  {GREEN}exists{RST}  {DIM}{}{RST}",
87                    p.display()
88                ),
89            }
90        }
91        Some(p) => Outcome {
92            ok: false,
93            line: format!(
94                "{BOLD}data dir{RST}  {RED}missing or not a directory{RST}  {DIM}{}{RST}",
95                p.display()
96            ),
97        },
98        None => Outcome {
99            ok: false,
100            line: format!("{BOLD}data dir{RST}  {RED}could not resolve data directory{RST}"),
101        },
102    };
103    print_check(&dir_outcome);
104
105    // 4) stats.json + size
106    let stats_path = lean_dir.as_ref().map(|d| d.join("stats.json"));
107    let stats_outcome = match stats_path.as_ref().and_then(|p| std::fs::metadata(p).ok()) {
108        Some(m) if m.is_file() => {
109            passed += 1;
110            let size = m.len();
111            let path_display = if let Some(p) = stats_path.as_ref() {
112                p.display().to_string()
113            } else {
114                String::new()
115            };
116            Outcome {
117                ok: true,
118                line: format!(
119                    "{BOLD}stats.json{RST}  {GREEN}exists{RST}  {WHITE}{size} bytes{RST}  {DIM}{path_display}{RST}",
120                ),
121            }
122        }
123        Some(_m) => {
124            let path_display = if let Some(p) = stats_path.as_ref() {
125                p.display().to_string()
126            } else {
127                String::new()
128            };
129            Outcome {
130                ok: false,
131                line: format!(
132                    "{BOLD}stats.json{RST}  {RED}not a file{RST}  {DIM}{path_display}{RST}",
133                ),
134            }
135        }
136        None => {
137            passed += 1;
138            Outcome {
139                ok: true,
140                line: match &stats_path {
141                    Some(p) => format!(
142                        "{BOLD}stats.json{RST}  {YELLOW}not yet created{RST}  {DIM}(will appear after first use) {}{RST}",
143                        p.display()
144                    ),
145                    None => format!("{BOLD}stats.json{RST}  {RED}could not resolve path{RST}"),
146                },
147            }
148        }
149    };
150    print_check(&stats_outcome);
151
152    let split_dirs = crate::core::data_dir::all_data_dirs_with_stats();
153    if split_dirs.len() >= 2 {
154        let dirs_str = split_dirs
155            .iter()
156            .map(|d| d.display().to_string())
157            .collect::<Vec<_>>()
158            .join(", ");
159        print_check(&Outcome {
160            ok: false,
161            line: format!(
162                "{BOLD}data dir split{RST}  {RED}stats.json found in {count} locations{RST}: {dirs_str}  {DIM}(run: lean-ctx setup to auto-merge){RST}",
163                count = split_dirs.len(),
164            ),
165        });
166    }
167
168    // XDG layout (GH #408): a legacy/mixed single-dir install mixes config with
169    // data/state/cache, which blocks a read-only config sandbox. Informational
170    // (not scored) — `doctor --fix` splits it into the four typed XDG dirs.
171    if let Some((src, n)) = crate::core::xdg_migrate::pending() {
172        print_check(&Outcome {
173            ok: false,
174            line: format!(
175                "{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}",
176                src.display()
177            ),
178        });
179    }
180
181    // 5) config.toml (missing is OK)
182    let config_path = lean_dir.as_ref().map(|d| d.join("config.toml"));
183    let config_outcome = match &config_path {
184        Some(p) => match std::fs::metadata(p) {
185            Ok(m) if m.is_file() => {
186                passed += 1;
187                Outcome {
188                    ok: true,
189                    line: format!(
190                        "{BOLD}config.toml{RST}  {GREEN}exists{RST}  {DIM}{}{RST}",
191                        p.display()
192                    ),
193                }
194            }
195            Ok(_) => Outcome {
196                ok: false,
197                line: format!(
198                    "{BOLD}config.toml{RST}  {RED}exists but is not a regular file{RST}  {DIM}{}{RST}",
199                    p.display()
200                ),
201            },
202            Err(_) => {
203                passed += 1;
204                Outcome {
205                    ok: true,
206                    line: format!(
207                        "{BOLD}config.toml{RST}  {YELLOW}not found, using defaults{RST}  {DIM}(expected at {}){RST}",
208                        p.display()
209                    ),
210                }
211            }
212        },
213        None => Outcome {
214            ok: false,
215            line: format!("{BOLD}config.toml{RST}  {RED}could not resolve path{RST}"),
216        },
217    };
218    print_check(&config_outcome);
219
220    // 5b) Shell allowlist (effective runtime view + silent-parse-error trap, #341)
221    let allowlist_outcome = shell_allowlist_outcome();
222    if allowlist_outcome.ok {
223        passed += 1;
224    }
225    print_check(&allowlist_outcome);
226
227    // 5b2) Path jail (effective state + dead allow_paths entries, GH #392)
228    let path_jail = path_jail_outcome();
229    if path_jail.ok {
230        passed += 1;
231    }
232    print_check(&path_jail);
233
234    // 5c) Compact-format passthrough (preserve already-compact TOON output, #342)
235    let passthrough_outcome = compact_format_passthrough_outcome();
236    if passthrough_outcome.ok {
237        passed += 1;
238    }
239    print_check(&passthrough_outcome);
240
241    // 5d) IDE permission inheritance (mirror host IDE bash/rm rules onto ctx_*)
242    let perm_inherit_outcome = permission_inheritance_outcome();
243    if perm_inherit_outcome.ok {
244        passed += 1;
245    }
246    print_check(&perm_inherit_outcome);
247
248    // 6) Proxy upstreams
249    let proxy_outcome = proxy_upstream_outcome();
250    if proxy_outcome.ok {
251        passed += 1;
252    }
253    print_check(&proxy_outcome);
254
255    // 7) Shell aliases
256    let aliases = shell_aliases_outcome();
257    if aliases.ok {
258        passed += 1;
259    }
260    print_check(&aliases);
261
262    // 7) MCP
263    let mcp = mcp_config_outcome();
264    if mcp.ok {
265        passed += 1;
266    }
267    print_check(&mcp);
268
269    // 8) Workspace-scope MCP (optional; only when a project-local config exists)
270    let workspace_scope = workspace_scope::workspace_scope_outcome(mcp.ok);
271    if let Some(ref ws) = workspace_scope {
272        if ws.ok {
273            passed += 1;
274        }
275        print_check(ws);
276    }
277
278    // 9) SKILL.md
279    let skill = skill_files_outcome();
280    if skill.ok {
281        passed += 1;
282    }
283    print_check(&skill);
284
285    // 10) Port
286    let port = port_3333_outcome();
287    if port.ok {
288        passed += 1;
289    }
290    print_check(&port);
291
292    // Daemon status
293    #[cfg(unix)]
294    let daemon_outcome = {
295        let autostart = crate::daemon_autostart::is_installed();
296        // GH #394: surface the exact service file so users can audit/edit it
297        // and know the unit name for systemctl/launchctl without searching.
298        let autostart_tag = if autostart {
299            match crate::daemon_autostart::service_file_path() {
300                Some(p) => format!("  {DIM}[autostart: on — {}]{RST}", p.display()),
301                None => format!("  {DIM}[autostart: on]{RST}"),
302            }
303        } else {
304            String::new()
305        };
306        if crate::daemon::is_daemon_running() {
307            let pid_path = crate::daemon::daemon_pid_path();
308            let pid_str = std::fs::read_to_string(&pid_path).unwrap_or_default();
309            Outcome {
310                ok: true,
311                line: format!(
312                    "{BOLD}Daemon{RST}  {GREEN}running (PID {}){RST}{autostart_tag}",
313                    pid_str.trim()
314                ),
315            }
316        } else {
317            let hint = if autostart {
318                format!("{DIM}(autostart enabled, will restart){RST}")
319            } else {
320                format!("{DIM}(run: lean-ctx daemon start  or: lean-ctx daemon enable){RST}")
321            };
322            Outcome {
323                ok: true,
324                line: format!("{BOLD}Daemon{RST}  {YELLOW}not running{RST}  {hint}"),
325            }
326        }
327    };
328    #[cfg(not(unix))]
329    let daemon_outcome = Outcome {
330        ok: true,
331        line: format!("{BOLD}Daemon{RST}  {DIM}not supported on this platform{RST}"),
332    };
333    if daemon_outcome.ok {
334        passed += 1;
335    }
336    print_check(&daemon_outcome);
337
338    // Daemon diagnostics: systemctl is-active, linger, crash-loop log
339    #[cfg(target_os = "linux")]
340    {
341        if let Ok(o) = std::process::Command::new("systemctl")
342            .args(["--user", "is-active", "lean-ctx-daemon.service"])
343            .output()
344        {
345            let state = String::from_utf8_lossy(&o.stdout).trim().to_string();
346            if state != "active" {
347                println!(
348                    "  {DIM}  systemd unit state: {YELLOW}{state}{RST}{DIM} (expected: active){RST}"
349                );
350            }
351        }
352        let username = std::env::var("USER")
353            .or_else(|_| std::env::var("LOGNAME"))
354            .unwrap_or_else(|_| "$(whoami)".to_string());
355        if let Ok(o) = std::process::Command::new("loginctl")
356            .args(["show-user", &username, "-p", "Linger", "--value"])
357            .output()
358        {
359            let val = String::from_utf8_lossy(&o.stdout).trim().to_string();
360            if val != "yes" {
361                println!(
362                    "  {YELLOW}⚠{RST}  Linger not enabled — daemon won't start at boot without login"
363                );
364                println!("     {DIM}Fix: loginctl enable-linger {username}{RST}");
365            }
366        }
367    }
368    if let Some(log_path) = crate::core::startup_guard::crash_loop_log_path(
369        crate::core::startup_guard::MCP_PROCESS_NAME,
370    ) {
371        if log_path.exists() {
372            if let Ok(contents) = std::fs::read_to_string(&log_path) {
373                let lines: Vec<&str> = contents.lines().collect();
374                if lines.len() >= 5 {
375                    println!(
376                        "  {YELLOW}⚠{RST}  Crash-loop log: {} recent restarts  {DIM}({}){RST}",
377                        lines.len(),
378                        log_path.display()
379                    );
380                }
381            }
382        }
383    }
384
385    // Providers
386    let provider_outcome = provider_outcome();
387    print_check(&provider_outcome);
388
389    // MCP Bridges
390    let bridge_outcomes = mcp_bridge_outcomes();
391    for bridge_check in &bridge_outcomes {
392        print_check(bridge_check);
393    }
394
395    // Plan mode
396    let plan_outcomes = plan_mode_outcomes();
397    for plan_check in &plan_outcomes {
398        print_check(plan_check);
399    }
400
401    // 9) Session state (project_root + shell_cwd)
402    let session_outcome = session_state_outcome();
403    if session_outcome.ok {
404        passed += 1;
405    }
406    print_check(&session_outcome);
407
408    // 10) Docker env vars (optional, only in containers)
409    let docker_outcomes = docker_env_outcomes();
410    for docker_check in &docker_outcomes {
411        if docker_check.ok {
412            passed += 1;
413        }
414        print_check(docker_check);
415    }
416
417    // 11) Pi Coding Agent (optional)
418    let pi = pi_outcome();
419    if let Some(ref pi_check) = pi {
420        if pi_check.ok {
421            passed += 1;
422        }
423        print_check(pi_check);
424    }
425
426    // 12) Build integrity (canary / origin check)
427    let integrity = crate::core::integrity::check();
428    let integrity_ok = integrity.seed_ok && integrity.origin_ok;
429    if integrity_ok {
430        passed += 1;
431    }
432    let integrity_line = if integrity_ok {
433        format!(
434            "{BOLD}Build origin{RST}  {GREEN}official{RST}  {DIM}{}{RST}",
435            integrity.repo
436        )
437    } else {
438        format!(
439            "{BOLD}Build origin{RST}  {RED}MODIFIED REDISTRIBUTION{RST}  {YELLOW}pkg={}, repo={}{RST}",
440            integrity.pkg_name, integrity.repo
441        )
442    };
443    print_check(&Outcome {
444        ok: integrity_ok,
445        line: integrity_line,
446    });
447
448    // 13) Cache safety
449    let cache_safety = cache_safety_outcome();
450    if cache_safety.ok {
451        passed += 1;
452    }
453    print_check(&cache_safety);
454
455    // 14) Claude Code instruction truncation guard
456    let claude_truncation = claude_truncation_outcome();
457    if let Some(ref ct) = claude_truncation {
458        if ct.ok {
459            passed += 1;
460        }
461        print_check(ct);
462    }
463
464    // 15) BM25 cache health
465    let bm25_health = bm25_cache_health_outcome();
466    if bm25_health.ok {
467        passed += 1;
468    }
469    print_check(&bm25_health);
470
471    // 15a) Semantic index runtime status (state/timing/persistence) for the
472    // active project — surfaces a stuck "warming" index (issue #249).
473    let semantic_index = semantic_index_outcome();
474    if let Some(ref check) = semantic_index {
475        if check.ok {
476            passed += 1;
477        }
478        print_check(check);
479    }
480
481    // 15b) Archive FTS footprint
482    let archive_footprint = archive_footprint_outcome();
483    if archive_footprint.ok {
484        passed += 1;
485    }
486    print_check(&archive_footprint);
487
488    // 16) Memory profile
489    let mem_profile = memory_profile_outcome();
490    passed += 1;
491    print_check(&mem_profile);
492
493    // 17) Memory cleanup
494    let mem_cleanup = memory_cleanup_outcome();
495    passed += 1;
496    print_check(&mem_cleanup);
497
498    // 18) RAM Guardian
499    let ram_outcome = ram_guardian_outcome();
500    if ram_outcome.ok {
501        passed += 1;
502    }
503    print_check(&ram_outcome);
504
505    // 19) Capacity warnings (memory stores near limits)
506    let cap_warnings = capacity_warnings();
507    for cw in &cap_warnings {
508        if cw.ok {
509            passed += 1;
510        }
511        print_check(cw);
512    }
513
514    // 20) Proxy health
515    let proxy_health = proxy_health_outcome();
516    if proxy_health.ok {
517        passed += 1;
518    }
519    print_check(&proxy_health);
520
521    // 20) Stale proxy env (ANTHROPIC_BASE_URL pointing to local proxy while proxy is not enabled)
522    let stale_env = stale_proxy_env_outcome();
523    if let Some(ref check) = stale_env {
524        if check.ok {
525            passed += 1;
526        }
527        print_check(check);
528    }
529
530    // 21) Claude Pro/Max subscription routed through the proxy without an API key
531    let subscription_conflict = proxy_subscription_conflict_outcome();
532    if let Some(ref check) = subscription_conflict {
533        if check.ok {
534            passed += 1;
535        }
536        print_check(check);
537    }
538
539    // 22) Deprecation register (CONTRACTS.md policy, GL #394): warn about
540    // every surface this build deprecates, with replacement and removal floor.
541    let deprecation_check = deprecations::deprecations_outcome();
542    if deprecation_check.ok {
543        passed += 1;
544    }
545    print_check(&deprecation_check);
546
547    // LSP servers (optional, informational)
548    println!("\n  {BOLD}{WHITE}LSP (optional — for ctx_refactor):{RST}");
549    let lsp_outcomes = lsp_server_outcomes();
550    for lsp_check in &lsp_outcomes {
551        print_check(lsp_check);
552    }
553
554    let mut effective_total = total + 10; // session_state + integrity + cache_safety + bm25_health + archive_footprint + daemon + mem_profile + mem_cleanup + ram_guardian + proxy_health
555    effective_total += 1; // shell_allowlist (#341)
556    effective_total += 1; // path_jail (GH #392)
557    effective_total += 1; // compact_format_passthrough (#342)
558    effective_total += 1; // permission_inheritance
559    effective_total += 1; // deprecations (#394)
560    effective_total += cap_warnings.len() as u32;
561    effective_total += docker_outcomes.len() as u32;
562    if pi.is_some() {
563        effective_total += 1;
564    }
565    if claude_truncation.is_some() {
566        effective_total += 1;
567    }
568    if stale_env.is_some() {
569        effective_total += 1;
570    }
571    if subscription_conflict.is_some() {
572        effective_total += 1;
573    }
574    if workspace_scope.is_some() {
575        effective_total += 1;
576    }
577    if semantic_index.is_some() {
578        effective_total += 1;
579    }
580    // Shadow mode status
581    let cfg = crate::core::config::Config::load();
582    let shadow_line = if cfg.shadow_mode {
583        format!("{BOLD}Shadow mode{RST}  {GREEN}active{RST}  {DIM}(native tools intercepted → ctx_*){RST}")
584    } else {
585        format!("{BOLD}Shadow mode{RST}  {DIM}disabled{RST}  {DIM}(enable: lean-ctx config set shadow_mode true){RST}")
586    };
587    println!("  {shadow_line}");
588
589    // Tool-schema footprint (informational, not scored). The active profile now
590    // authoritatively determines the advertised set, so its description reflects
591    // exactly what the MCP client sees (plus the always-on ctx_call gateway).
592    let tool_profile = crate::core::tool_profiles::ToolProfile::from_config(&cfg);
593    println!(
594        "  {BOLD}Tool profile{RST}  {WHITE}{tool_profile}{RST}  {DIM}{} + ctx_call gateway{RST}",
595        tool_profile.description()
596    );
597
598    // Session cache health (#361): answer "is the cache actually engaging?"
599    // without external instrumentation. CEP sessions + the cross-call hit ratio
600    // come from the persistent stats store; `verify-cache` proves it live.
601    let cep = &crate::core::stats::load().cep;
602    let hit_ratio = if cep.total_cache_reads > 0 {
603        (cep.total_cache_hits as f64 / cep.total_cache_reads as f64) * 100.0
604    } else {
605        0.0
606    };
607    println!(
608        "  {BOLD}Session cache{RST}  {WHITE}{} sessions{RST}  {DIM}{}/{} reads cached ({hit_ratio:.0}% hit) · prove: lean-ctx verify-cache{RST}",
609        cep.sessions, cep.total_cache_hits, cep.total_cache_reads
610    );
611
612    let needs_attention = effective_total.saturating_sub(passed);
613    println!();
614    println!("  {BOLD}{WHITE}Summary:{RST}  {GREEN}{passed}{RST}{DIM}/{effective_total}{RST} checks passed");
615    if needs_attention > 0 {
616        println!(
617            "  {YELLOW}{needs_attention} check(s) need attention.{RST}  Auto-repair what's fixable:  {BOLD}lean-ctx doctor --fix{RST}"
618        );
619    } else {
620        println!("  {GREEN}Everything looks good.{RST}");
621    }
622    println!("  {DIM}LSP servers are optional enhancements (not counted in score){RST}");
623    println!("  {DIM}{}{RST}", crate::core::integrity::origin_line());
624}
625
626pub fn run_compact() {
627    let (passed, total) = compact_score();
628    print_compact_status(passed, total);
629}
630
631pub fn run_cli(args: &[String]) -> i32 {
632    let (sub, rest) = match args.first().map(String::as_str) {
633        Some("integrations") => ("integrations", &args[1..]),
634        Some("overhead") => ("overhead", &args[1..]),
635        _ => ("", args),
636    };
637
638    let fix = rest.iter().any(|a| a == "--fix");
639    let json = rest.iter().any(|a| a == "--json");
640    let migrate_check = rest.iter().any(|a| a == "--migrate-check");
641    let help = rest.iter().any(|a| a == "--help" || a == "-h");
642
643    if help {
644        println!("Usage:");
645        println!("  lean-ctx doctor");
646        println!("  lean-ctx doctor overhead [--json]   Fixed context cost per session");
647        println!("  lean-ctx doctor integrations [--json]");
648        println!("  lean-ctx doctor --fix [--json]");
649        println!("  lean-ctx doctor --migrate-check [--json]");
650        return 0;
651    }
652
653    if sub == "overhead" {
654        return overhead::run_overhead(json);
655    }
656
657    if migrate_check {
658        return migrate::run_migrate_check(json);
659    }
660
661    if sub == "integrations" {
662        if fix {
663            let _ = fix::run_fix(&fix::DoctorFixOptions { json: false });
664        }
665        return integrations::run_integrations(&integrations::IntegrationsOptions { json });
666    }
667
668    if !fix {
669        run();
670        return 0;
671    }
672
673    match fix::run_fix(&fix::DoctorFixOptions { json }) {
674        Ok(code) => code,
675        Err(e) => {
676            tracing::error!("doctor --fix failed: {e}");
677            2
678        }
679    }
680}
681
682pub fn compact_score() -> (u32, u32) {
683    let mut passed = 0u32;
684    let total = 6u32;
685
686    if resolve_lean_ctx_binary().is_some() || path_in_path_env() {
687        passed += 1;
688    }
689    let lean_dir = crate::core::data_dir::lean_ctx_data_dir().ok();
690    if lean_dir.as_ref().is_some_and(|p| p.is_dir()) {
691        passed += 1;
692    }
693    if lean_dir
694        .as_ref()
695        .map(|d| d.join("stats.json"))
696        .and_then(|p| std::fs::metadata(p).ok())
697        .is_some_and(|m| m.is_file())
698    {
699        passed += 1;
700    }
701    if shell_aliases_outcome().ok {
702        passed += 1;
703    }
704    if mcp_config_outcome().ok {
705        passed += 1;
706    }
707    if skill_files_outcome().ok {
708        passed += 1;
709    }
710
711    (passed, total)
712}
713
714pub(super) fn print_compact_status(passed: u32, total: u32) {
715    let status = if passed == total {
716        format!("{GREEN}✓ All {total} checks passed{RST}")
717    } else {
718        format!("{YELLOW}{passed}/{total} passed{RST} — run {BOLD}lean-ctx doctor{RST} for details")
719    };
720    println!("  {status}");
721}
722
723#[cfg(test)]
724mod tests {
725    use super::is_active_shell_impl;
726
727    // Mirrors the inline classification in `checks::capacity_warnings`: a store at
728    // or below its cap is at most a WARN (healthy, eviction keeps it there); only
729    // a store *over* cap is CRIT (eviction is not keeping up).
730    fn make_capacity_check(name: &str, current: usize, limit: usize) -> Option<(bool, String)> {
731        if limit == 0 {
732            return None;
733        }
734        let pct = (current as f64 / limit as f64 * 100.0) as u32;
735        if pct > 100 {
736            Some((true, format!("{name}: {current}/{limit} ({pct}%)")))
737        } else if pct >= 80 {
738            Some((false, format!("{name}: {current}/{limit} ({pct}%)")))
739        } else {
740            None
741        }
742    }
743
744    #[test]
745    fn capacity_below_80_no_warning() {
746        assert!(make_capacity_check("facts", 100, 200).is_none());
747        assert!(make_capacity_check("facts", 159, 200).is_none());
748    }
749
750    #[test]
751    fn capacity_at_80_yellow_warning() {
752        let result = make_capacity_check("facts", 160, 200);
753        assert!(result.is_some());
754        let (critical, msg) = result.unwrap();
755        assert!(!critical);
756        assert!(msg.contains("160/200"));
757        assert!(msg.contains("80%"));
758    }
759
760    #[test]
761    fn capacity_at_92_yellow_warning() {
762        let result = make_capacity_check("facts", 185, 200);
763        assert!(result.is_some());
764        let (critical, msg) = result.unwrap();
765        assert!(!critical);
766        assert!(msg.contains("185/200"));
767        assert!(msg.contains("92%"));
768    }
769
770    #[test]
771    fn capacity_at_95_is_warning_not_critical() {
772        let result = make_capacity_check("facts", 190, 200);
773        assert!(result.is_some());
774        let (critical, msg) = result.unwrap();
775        assert!(!critical, "95% is full-but-healthy, not over cap");
776        assert!(msg.contains("190/200"));
777        assert!(msg.contains("95%"));
778    }
779
780    #[test]
781    fn capacity_at_100_is_warning_not_critical() {
782        // A store exactly at its cap is healthy — eviction keeps it there.
783        let result = make_capacity_check("facts", 200, 200);
784        assert!(result.is_some());
785        let (critical, _) = result.unwrap();
786        assert!(!critical);
787    }
788
789    #[test]
790    fn capacity_over_100_is_critical() {
791        // Genuinely over cap => eviction is not keeping up (regression guard for
792        // the 206/200 "CRIT" that fired before lifecycle eviction was fixed).
793        let result = make_capacity_check("facts", 206, 200);
794        assert!(result.is_some());
795        let (critical, msg) = result.unwrap();
796        assert!(critical);
797        assert!(msg.contains("206/200"));
798        assert!(msg.contains("103%"));
799    }
800
801    #[test]
802    fn capacity_zero_limit_skipped() {
803        assert!(make_capacity_check("facts", 50, 0).is_none());
804    }
805
806    #[test]
807    fn bashrc_active_on_non_windows_when_shell_empty() {
808        assert!(is_active_shell_impl("~/.bashrc", "", false, false));
809    }
810
811    #[test]
812    fn bashrc_not_active_on_windows_when_shell_empty() {
813        assert!(!is_active_shell_impl("~/.bashrc", "", true, false));
814    }
815
816    #[test]
817    fn bashrc_active_when_shell_contains_bash_on_linux() {
818        assert!(is_active_shell_impl(
819            "~/.bashrc",
820            "/usr/bin/bash",
821            false,
822            false
823        ));
824    }
825
826    #[test]
827    fn bashrc_not_active_on_windows_even_with_bash_in_shell_env() {
828        // Issue #214: On Windows, Git Bash sets $SHELL globally to bash.exe.
829        // .bashrc should NOT be flagged on Windows unless actually inside bash.
830        std::env::remove_var("BASH_VERSION");
831        assert!(!is_active_shell_impl(
832            "~/.bashrc",
833            "C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe",
834            true,
835            false,
836        ));
837    }
838
839    #[test]
840    fn bashrc_not_active_on_windows_powershell_even_with_bash_in_shell() {
841        assert!(!is_active_shell_impl(
842            "~/.bashrc",
843            "C:\\\\Program Files\\\\Git\\\\bin\\\\bash.exe",
844            true,
845            true,
846        ));
847    }
848
849    #[test]
850    fn bashrc_not_active_on_windows_powershell_with_empty_shell() {
851        assert!(!is_active_shell_impl("~/.bashrc", "", true, true));
852    }
853
854    #[test]
855    fn zshrc_unaffected_by_powershell_flag() {
856        assert!(is_active_shell_impl("~/.zshrc", "/bin/zsh", false, false));
857        assert!(is_active_shell_impl("~/.zshrc", "/bin/zsh", true, true));
858    }
859
860    #[test]
861    fn bashrc_not_active_on_windows_without_powershell_detection() {
862        // Windows + $SHELL=bash but NOT in actual bash session (no BASH_VERSION).
863        // This is the exact scenario from issue #214: Git Bash sets $SHELL globally.
864        std::env::remove_var("BASH_VERSION");
865        assert!(!is_active_shell_impl(
866            "~/.bashrc",
867            "/usr/bin/bash",
868            true,
869            false,
870        ));
871    }
872
873    #[test]
874    fn bashrc_active_on_linux() {
875        assert!(is_active_shell_impl("~/.bashrc", "/bin/bash", false, false));
876        assert!(is_active_shell_impl("~/.bashrc", "", false, false));
877    }
878}