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