Skip to main content

lean_ctx/doctor/
mod.rs

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