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