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