Skip to main content

lean_ctx/
shell_hook.rs

1use std::path::{Path, PathBuf};
2
3use crate::{dropin, marked_block};
4
5const MARKER_START: &str = "# >>> lean-ctx shell hook >>>";
6const MARKER_END: &str = "# <<< lean-ctx shell hook <<<";
7const ALIAS_START: &str = "# >>> lean-ctx agent aliases >>>";
8const ALIAS_END: &str = "# <<< lean-ctx agent aliases <<<";
9
10/// File name we use inside `.d/` directories. Stable so install / migration /
11/// uninstall can find it again without parsing. `00-` prefix sorts it ahead
12/// of other drop-ins so the agent intercept fires before any tool init.
13const DROPIN_ZSH: &str = "00-lean-ctx.zsh";
14const DROPIN_SH: &str = "00-lean-ctx.sh";
15
16const KNOWN_AGENT_ENV_VARS: &[&str] = &[
17    "LEAN_CTX_AGENT",
18    "CLAUDECODE",
19    "CODEBUDDY",
20    "CODEX_CLI_SESSION",
21    "GEMINI_SESSION",
22];
23
24const AGENT_ALIASES: &[(&str, &str)] = &[
25    ("claude", "claude"),
26    ("codebuddy", "codebuddy"),
27    ("codex", "codex"),
28    ("gemini", "gemini"),
29];
30
31/// The `source <rc>` command for a given login-shell path, or `None` when the
32/// shell is unknown/unsupported (callers should fall back to "restart your
33/// shell"). Kept pure so it is deterministic to unit-test without mutating the
34/// process environment.
35fn source_command_for_shell(shell: &str) -> Option<&'static str> {
36    if shell.contains("zsh") {
37        Some("source ~/.zshrc")
38    } else if shell.contains("fish") {
39        Some("source ~/.config/fish/config.fish")
40    } else if shell.contains("bash") {
41        Some("source ~/.bashrc")
42    } else {
43        None
44    }
45}
46
47/// The `source <rc>` command for the user's current login shell (`$SHELL`), or
48/// `None` when it cannot be determined. Single source of truth so post-`setup`
49/// and post-`update` hints stay in sync and never advise sourcing a shell the
50/// user does not have (e.g. `~/.zshrc` on a bash-only system — see #321).
51pub fn shell_source_command() -> Option<&'static str> {
52    source_command_for_shell(&std::env::var("SHELL").unwrap_or_default())
53}
54
55/// The rc file path for a given login-shell path (pure, testable).
56fn rc_file_for_shell(shell: &str) -> &'static str {
57    if shell.contains("zsh") {
58        "~/.zshrc"
59    } else if shell.contains("fish") {
60        "~/.config/fish/config.fish"
61    } else if shell.contains("bash") {
62        "~/.bashrc"
63    } else {
64        "your shell config"
65    }
66}
67
68/// The rc file path for the user's current login shell (`$SHELL`), or a
69/// generic fallback when it cannot be determined. Used in help text and
70/// troubleshooting hints so they never hardcode a single shell's rc file.
71pub fn shell_rc_file() -> &'static str {
72    rc_file_for_shell(&std::env::var("SHELL").unwrap_or_default())
73}
74
75/// Human-facing one-liner telling the user how to load the refreshed aliases,
76/// tailored to their login shell. Used after `lean-ctx update`.
77pub fn reload_aliases_hint() -> String {
78    match shell_source_command() {
79        Some(cmd) => format!("Run '{cmd}' (or restart terminal) for updated shell aliases."),
80        None => "Restart your terminal to load updated shell aliases.".to_string(),
81    }
82}
83
84/// Installation style for the shell hook + agent aliases.
85///
86/// `Auto` (default) inspects each rc file to decide: if the file references
87/// an adjacent `.d/` directory from a non-comment line and that directory
88/// exists, install as a drop-in; otherwise fall back to an inline fenced
89/// block in the rc file itself.
90#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
91pub enum Style {
92    /// Force inline marked-block install in the parent rc file.
93    Inline,
94    /// Force drop-in file install in the adjacent `.d/` directory.
95    /// Falls back to `Inline` if no `.d/` source loop is configured.
96    DropIn,
97    /// Auto-detect per file.
98    #[default]
99    Auto,
100}
101
102/// Static description of a single install slot: which rc file, which
103/// adjacent drop-in directory + filename, and the marker pair for the
104/// inline form.
105#[derive(Debug, Clone, Copy)]
106struct Slot {
107    rc_file: &'static str,
108    dropin_dir: &'static str,
109    dropin_file: &'static str,
110    marker_start: &'static str,
111    marker_end: &'static str,
112}
113
114const SLOT_ZSHENV: Slot = Slot {
115    rc_file: ".zshenv",
116    dropin_dir: ".zshenv.d",
117    dropin_file: DROPIN_ZSH,
118    marker_start: MARKER_START,
119    marker_end: MARKER_END,
120};
121
122const SLOT_BASHENV: Slot = Slot {
123    rc_file: ".bashenv",
124    dropin_dir: ".bashenv.d",
125    dropin_file: DROPIN_SH,
126    marker_start: MARKER_START,
127    marker_end: MARKER_END,
128};
129
130const SLOT_ZSHRC: Slot = Slot {
131    rc_file: ".zshrc",
132    dropin_dir: ".zshrc.d",
133    dropin_file: DROPIN_ZSH,
134    marker_start: ALIAS_START,
135    marker_end: ALIAS_END,
136};
137
138const SLOT_BASHRC: Slot = Slot {
139    rc_file: ".bashrc",
140    dropin_dir: ".bashrc.d",
141    dropin_file: DROPIN_SH,
142    marker_start: ALIAS_START,
143    marker_end: ALIAS_END,
144};
145
146/// Resolved destination for a single install slot.
147enum InstallTarget {
148    Marked {
149        path: PathBuf,
150        start: &'static str,
151        end: &'static str,
152    },
153    DropIn {
154        dir: PathBuf,
155        filename: &'static str,
156    },
157}
158
159impl InstallTarget {
160    fn upsert(&self, content: &str, quiet: bool, label: &str) {
161        match self {
162            Self::Marked { path, start, end } => {
163                marked_block::upsert(path, start, end, content, quiet, label);
164            }
165            Self::DropIn { dir, filename } => dropin::write(dir, filename, content, quiet, label),
166        }
167    }
168}
169
170/// Decide where a particular hook should live.
171fn pick_target(home: &Path, slot: &Slot, style: Style) -> InstallTarget {
172    let inline = InstallTarget::Marked {
173        path: home.join(slot.rc_file),
174        start: slot.marker_start,
175        end: slot.marker_end,
176    };
177    match style {
178        Style::Inline => inline,
179        // DropIn and Auto both prefer dropin when available; only difference
180        // is whether we fall back silently (Auto) or could be made to warn
181        // (DropIn). Today they behave identically; the distinction lets
182        // callers express intent in the CLI surface later.
183        Style::DropIn | Style::Auto => match dropin::detect(home, slot.rc_file, slot.dropin_dir) {
184            Some(dir) => InstallTarget::DropIn {
185                dir,
186                filename: slot.dropin_file,
187            },
188            None => inline,
189        },
190    }
191}
192
193/// Pre-formatted timestamp suffix for migration backups.
194///
195/// Created **once per install run** and threaded through every per-slot
196/// install function, so all backups produced by a single
197/// `install_all_with_style` invocation share the same suffix. This
198/// rules out the "two near-simultaneous `Utc::now()` calls drifted by
199/// 1 ms across a second boundary" bug class, and makes the backups
200/// produced by one logical migration trivially groupable for the user
201/// (e.g. `ls ~ | grep lean-ctx-20260511T203845Z`).
202///
203/// Tests construct one via `BackupStamp::at(...)` to get deterministic
204/// filenames without touching the system clock.
205struct BackupStamp(String);
206
207impl BackupStamp {
208    /// Capture the current UTC time. Call this **once** at the top of
209    /// an install run.
210    fn now() -> Self {
211        Self::at(chrono::Utc::now())
212    }
213
214    /// Inject a specific moment in time. Used by tests; can also be
215    /// used in future to align migration backups with a user-supplied
216    /// release marker.
217    fn at(stamp: chrono::DateTime<chrono::Utc>) -> Self {
218        Self(stamp.format("%Y%m%dT%H%M%SZ").to_string())
219    }
220
221    /// Compose the full backup path for a given original file.
222    fn backup_path_for(&self, path: &Path) -> Option<PathBuf> {
223        let file_name = path.file_name().and_then(|n| n.to_str())?;
224        Some(path.with_file_name(format!("{file_name}.lean-ctx-{}.bak", self.0)))
225    }
226}
227
228/// Save a *timestamped* sibling backup of `path` before a destructive
229/// migration step. Filename pattern: `<basename>.lean-ctx-<UTC>.bak`,
230/// e.g. `.zshenv.lean-ctx-20260511T203845Z.bak`.
231///
232/// The block content owned by lean-ctx is normally treated as ours to
233/// rewrite — `marked_block::upsert` already strips and replaces it on
234/// every reinstall. That convention is acceptable for *idempotent
235/// reinstalls* (the canonical content is always the same) but loses
236/// information during a *style migration* if the user has hand-edited
237/// anywhere in the file, including inside our fenced region.
238///
239/// Deliberate divergence from the elsewhere-in-the-codebase convention
240/// (`cli::shell_init::backup_shell_config`, `config_io.rs`), which
241/// writes a single `<file>.lean-ctx.bak` and clobbers it on every
242/// invocation. That single-generation scheme is fine for "I backed
243/// this up moments ago before this exact reinstall" use cases, but
244/// risky for migration backups: a second migration event would
245/// silently overwrite the first, destroying potentially-unrecoverable
246/// user state. Timestamped names are append-only and let us migrate
247/// repeatedly (e.g. across multiple `lean-ctx update` runs over
248/// months) without ever losing a snapshot.
249fn save_migration_backup(path: &Path, quiet: bool, stamp: &BackupStamp) {
250    if !path.exists() {
251        return;
252    }
253    let Some(bak) = stamp.backup_path_for(path) else {
254        return;
255    };
256    match std::fs::copy(path, &bak) {
257        Ok(_) => {
258            if !quiet {
259                eprintln!("  Backup: {} -> {}", path.display(), bak.display());
260            }
261        }
262        Err(e) => {
263            tracing::warn!("Failed to back up {}: {e}", path.display());
264        }
265    }
266}
267
268/// When we install one style, sweep away any prior install of the *other*
269/// style so users transparently migrate (and so re-running setup never
270/// leaves the hook in two places).
271///
272/// Whenever a migration would clobber pre-existing user content (a
273/// fenced block in the rc file, or a hand-tweaked drop-in file), the
274/// affected file is copied to `<filename>.lean-ctx-<stamp>.bak` first
275/// (see `save_migration_backup`). The backup is only created when there
276/// is something to migrate AWAY from, so clean installs and idempotent
277/// reinstalls don't generate noise. `stamp` is taken by reference so
278/// all migrations within one `install_all` invocation share the same
279/// suffix.
280fn strip_other_style(
281    home: &Path,
282    slot: &Slot,
283    target: &InstallTarget,
284    quiet: bool,
285    label: &str,
286    stamp: &BackupStamp,
287) {
288    match target {
289        InstallTarget::Marked { .. } => {
290            // Installing inline: remove any drop-in file we previously wrote.
291            let dropin_dir = home.join(slot.dropin_dir);
292            let dropin_path = dropin_dir.join(slot.dropin_file);
293            if dropin_path.exists() {
294                // Hand-edits to the drop-in file would otherwise be lost.
295                // The backup lands next to the original; the `.bak`
296                // suffix keeps it out of any `*.zsh` source glob.
297                save_migration_backup(&dropin_path, quiet, stamp);
298                dropin::remove(&dropin_dir, slot.dropin_file, quiet, label);
299            }
300        }
301        InstallTarget::DropIn { .. } => {
302            // Installing drop-in: remove any prior inline fenced block.
303            // Back up the whole rc file first so anything between the
304            // markers (and any unrelated user edits to the same file)
305            // is recoverable from `<rc>.lean-ctx-<stamp>.bak`.
306            let rc_path = home.join(slot.rc_file);
307            if let Ok(existing) = std::fs::read_to_string(&rc_path)
308                && existing.contains(slot.marker_start)
309            {
310                save_migration_backup(&rc_path, quiet, stamp);
311            }
312            marked_block::remove_from_file(
313                &rc_path,
314                slot.marker_start,
315                slot.marker_end,
316                quiet,
317                label,
318            );
319        }
320    }
321}
322
323/// Public entrypoint: install with auto-detected style. Preserves the
324/// previous signature so existing callers (setup.rs, cli/shell_init.rs)
325/// don't need to change.
326pub fn install_all(quiet: bool) {
327    install_all_with_style(quiet, Style::Auto);
328}
329
330/// Explicit style entrypoint for callers that want to honour a `--style=`
331/// CLI flag.
332///
333/// Captures a single `BackupStamp` here so every migration backup
334/// produced by this invocation shares one suffix, even if the wall
335/// clock ticks over while we're walking the slots.
336pub fn install_all_with_style(quiet: bool, style: Style) {
337    let cfg = crate::core::config::Config::load();
338    if cfg.shell_hook_disabled_effective() {
339        if !quiet {
340            eprintln!(
341                "lean-ctx: shell hook disabled (shell_hook_disabled=true or LEAN_CTX_NO_HOOK).                  Skipping hook installation."
342            );
343        }
344        return;
345    }
346
347    let Some(home) = dirs::home_dir() else {
348        tracing::error!("Cannot resolve home directory");
349        return;
350    };
351
352    let stamp = BackupStamp::now();
353    if shell_available("zsh") {
354        install_zshenv(&home, quiet, style, &stamp);
355    }
356    if shell_available("bash") {
357        install_bashenv(&home, quiet, style, &stamp);
358    }
359    let cfg = crate::core::config::Config::load();
360    if cfg.skip_agent_aliases {
361        remove_agent_aliases(&home, quiet);
362    } else {
363        install_aliases(&home, quiet, style, &stamp);
364    }
365}
366
367/// Returns `true` if the given shell binary is installed on the system.
368/// Checks common installation paths without spawning a subprocess.
369///
370/// `LEAN_CTX_SHELL_HOOK_FORCE` overrides detection for environments where the
371/// shell lives in a non-standard path or is provisioned after install (minimal
372/// containers, custom images): set it to `1`/`true`/`all` to force every shell,
373/// or to a comma-separated list (e.g. `zsh,bash`) to force specific ones.
374#[cfg(unix)]
375fn shell_available(shell: &str) -> bool {
376    if let Ok(forced) = std::env::var("LEAN_CTX_SHELL_HOOK_FORCE") {
377        let forced = forced.trim();
378        if forced == "1"
379            || forced.eq_ignore_ascii_case("true")
380            || forced.eq_ignore_ascii_case("all")
381        {
382            return true;
383        }
384        if forced
385            .split(',')
386            .any(|s| s.trim().eq_ignore_ascii_case(shell))
387        {
388            return true;
389        }
390    }
391
392    let candidates: &[&str] = match shell {
393        "zsh" => &[
394            "/bin/zsh",
395            "/usr/bin/zsh",
396            "/usr/local/bin/zsh",
397            "/opt/homebrew/bin/zsh",
398        ],
399        "bash" => &[
400            "/bin/bash",
401            "/usr/bin/bash",
402            "/usr/local/bin/bash",
403            "/opt/homebrew/bin/bash",
404        ],
405        _ => return false,
406    };
407    candidates.iter().any(|p| Path::new(p).exists())
408}
409
410#[cfg(not(unix))]
411fn shell_available(_shell: &str) -> bool {
412    // On non-Unix platforms (Windows), shell hooks are not applicable.
413    false
414}
415
416pub fn uninstall_all(quiet: bool) {
417    let Some(home) = dirs::home_dir() else { return };
418
419    // Try both styles unconditionally for each slot. marked_block::remove
420    // and dropin::remove are both no-ops when their target is absent.
421    let slots: &[(Slot, &str)] = &[
422        (SLOT_ZSHENV, "shell hook for ~/.zshenv"),
423        (SLOT_BASHENV, "shell hook for ~/.bashenv"),
424        (SLOT_ZSHRC, "agent aliases for ~/.zshrc"),
425        (SLOT_BASHRC, "agent aliases for ~/.bashrc"),
426    ];
427
428    for (slot, label) in slots {
429        marked_block::remove_from_file(
430            &home.join(slot.rc_file),
431            slot.marker_start,
432            slot.marker_end,
433            quiet,
434            label,
435        );
436        let dir_path = home.join(slot.dropin_dir);
437        if dir_path.exists() {
438            dropin::remove(&dir_path, slot.dropin_file, quiet, label);
439        }
440    }
441}
442
443/// Substrings that flag a host's agent-exec *sandbox wrapper* — which reports
444/// the real command's exit status over a dedicated fd (e.g. Cursor's
445/// `dump_zsh_state >&4; builtin exit $?`) and passes the command as a positional
446/// arg — or lean-ctx's *own* hook invocations. The non-interactive
447/// `.zshenv`/`.bashenv` redirect must never `exec lean-ctx -c` over these:
448/// doing so discards the wrapper's fd handshake and positional command, so the
449/// IDE reports "no exit status" for every command (and lean-ctx's 120s/8MB cap
450/// truncates long output). Inside such hosts the lean-ctx editor extension +
451/// hooks already provide integration, so the redirect is moot there anyway.
452const REDIRECT_SKIP_MARKERS: &[&str] = &[
453    "__CURSOR_SANDBOX", // Cursor/VSCode agent-exec sandbox wrapper (shell-agnostic)
454    "dump_zsh_state",   // Cursor zsh exit-code fd handshake
455    "lean-ctx hook ",   // lean-ctx's own preToolUse hook commands
456];
457
458/// Render the guarded non-interactive redirect shared by the zsh and bash
459/// installers. `exec_var` is the host's command variable
460/// (`ZSH_EXECUTION_STRING` / `BASH_EXECUTION_STRING`) and `env_check` is the
461/// agent-detection clause from [`build_env_check`]. The emitted `if` fires only
462/// for genuine agent commands — never for sandbox wrappers or lean-ctx hooks
463/// (see [`REDIRECT_SKIP_MARKERS`]).
464fn redirect_block(exec_var: &str, env_check: &str) -> String {
465    let mut lines = vec![format!(
466        "if [[ -z \"$LEAN_CTX_ACTIVE\" && -z \"$LEAN_CTX_NO_HOOK\" && -n \"${exec_var}\" ]] \\"
467    )];
468    for marker in REDIRECT_SKIP_MARKERS {
469        lines.push(format!("  && [[ \"${exec_var}\" != *\"{marker}\"* ]] \\"));
470    }
471    lines.push("  && command -v lean-ctx &>/dev/null; then".to_string());
472    lines.push(format!("  if {env_check}; then"));
473    lines.push("    export LEAN_CTX_ACTIVE=1".to_string());
474    lines.push(format!("    exec lean-ctx -c \"${exec_var}\""));
475    lines.push("  fi".to_string());
476    lines.push("fi".to_string());
477    lines.join("\n")
478}
479
480fn install_zshenv(home: &Path, quiet: bool, style: Style, stamp: &BackupStamp) {
481    let redirect = redirect_block("ZSH_EXECUTION_STRING", &build_env_check());
482    let hook = format!(
483        r#"{MARKER_START}
484# Passthrough stubs: ensure _lc/_lc_compress exist in ALL zsh contexts
485# (non-interactive subshells, eval, agent harnesses) so aliases that
486# reference them degrade gracefully instead of "command not found".
487# The full shell-hook.zsh overrides these when loaded via .zshrc.
488_lc()          {{ command "$@"; }}
489_lc_compress() {{ command "$@"; }}
490{redirect}
491{MARKER_END}"#
492    );
493
494    let label = "shell hook in ~/.zshenv";
495    let target = pick_target(home, &SLOT_ZSHENV, style);
496    strip_other_style(home, &SLOT_ZSHENV, &target, quiet, label, stamp);
497    target.upsert(&hook, quiet, label);
498}
499
500fn install_bashenv(home: &Path, quiet: bool, style: Style, stamp: &BackupStamp) {
501    let redirect = redirect_block("BASH_EXECUTION_STRING", &build_env_check());
502    let hook = format!(
503        r#"{MARKER_START}
504_lc()          {{ command "$@"; }}
505_lc_compress() {{ command "$@"; }}
506{redirect}
507{MARKER_END}"#
508    );
509
510    let label = "shell hook in ~/.bashenv";
511    let target = pick_target(home, &SLOT_BASHENV, style);
512    strip_other_style(home, &SLOT_BASHENV, &target, quiet, label, stamp);
513    target.upsert(&hook, quiet, label);
514}
515
516fn install_aliases(home: &Path, quiet: bool, style: Style, stamp: &BackupStamp) {
517    let mut lines = Vec::new();
518    lines.push(ALIAS_START.to_string());
519    for (alias_name, bin_name) in AGENT_ALIASES {
520        lines.push(format!(
521            "alias {alias_name}='LEAN_CTX_AGENT=1 BASH_ENV=\"$HOME/.bashenv\" {bin_name}'"
522        ));
523    }
524    lines.push(ALIAS_END.to_string());
525    let block = lines.join("\n");
526
527    for slot in &[SLOT_ZSHRC, SLOT_BASHRC] {
528        // Only act on rc files the user actually has. (Drop-in mode keys off
529        // the parent rc anyway — see `dropin::detect`.)
530        if !home.join(slot.rc_file).exists() {
531            continue;
532        }
533        let label = format!("agent aliases in ~/{}", slot.rc_file);
534        let target = pick_target(home, slot, style);
535        strip_other_style(home, slot, &target, quiet, &label, stamp);
536        target.upsert(&block, quiet, &label);
537    }
538}
539
540/// Remove agent alias blocks from rc files without touching the env hook.
541/// Called when `skip_agent_aliases = true` to clean up previously installed blocks.
542fn remove_agent_aliases(home: &Path, quiet: bool) {
543    for slot in &[SLOT_ZSHRC, SLOT_BASHRC] {
544        let rc = home.join(slot.rc_file);
545        if !rc.exists() {
546            continue;
547        }
548        if let Ok(content) = std::fs::read_to_string(&rc)
549            && content.contains(ALIAS_START)
550        {
551            let filtered: Vec<&str> = content
552                .lines()
553                .scan(false, |inside, line| {
554                    if line.trim() == ALIAS_START {
555                        *inside = true;
556                        return Some(None);
557                    }
558                    if *inside && line.trim() == ALIAS_END {
559                        *inside = false;
560                        return Some(None);
561                    }
562                    if *inside {
563                        Some(None)
564                    } else {
565                        Some(Some(line))
566                    }
567                })
568                .flatten()
569                .collect();
570            let _ = std::fs::write(&rc, filtered.join("\n") + "\n");
571            if !quiet {
572                println!(
573                    "  \x1b[33m⊖\x1b[0m Removed agent aliases from ~/{}",
574                    slot.rc_file
575                );
576            }
577        }
578        // Remove drop-in file
579        let dropin = home.join(slot.dropin_dir).join(slot.dropin_file);
580        if dropin.exists() {
581            let _ = std::fs::remove_file(&dropin);
582            if !quiet {
583                println!(
584                    "  \x1b[33m⊖\x1b[0m Removed drop-in ~/{}/{}",
585                    slot.dropin_dir, slot.dropin_file
586                );
587            }
588        }
589    }
590}
591
592fn build_env_check() -> String {
593    let checks: Vec<String> = KNOWN_AGENT_ENV_VARS
594        .iter()
595        .map(|v| format!("-n \"${v}\""))
596        .collect();
597    format!("[[ {} ]]", checks.join(" || "))
598}
599
600#[cfg(test)]
601pub mod test_helpers {
602    use super::*;
603    pub fn redirect_block_for_test(exec_var: &str, env_check: &str) -> String {
604        redirect_block(exec_var, env_check)
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611
612    /// Fixed deterministic stamp for tests that don't care about
613    /// distinguishing migration generations. Tests that *do* care
614    /// (e.g. the no-clobber regression) construct their own.
615    fn test_stamp() -> BackupStamp {
616        BackupStamp::at(
617            chrono::DateTime::parse_from_rfc3339("2026-05-11T20:38:45Z")
618                .unwrap()
619                .with_timezone(&chrono::Utc),
620        )
621    }
622
623    #[test]
624    fn env_check_format() {
625        let check = build_env_check();
626        assert!(check.contains("LEAN_CTX_AGENT"));
627        assert!(check.contains("CLAUDECODE"));
628        assert!(check.contains("CODEBUDDY"));
629        assert!(check.contains("||"));
630    }
631
632    #[test]
633    fn source_command_matches_login_shell() {
634        // Bash-only users must never be told to source ~/.zshrc (#321).
635        assert_eq!(
636            source_command_for_shell("/usr/bin/bash"),
637            Some("source ~/.bashrc")
638        );
639        assert_eq!(
640            source_command_for_shell("/bin/zsh"),
641            Some("source ~/.zshrc")
642        );
643        assert_eq!(
644            source_command_for_shell("/usr/local/bin/fish"),
645            Some("source ~/.config/fish/config.fish")
646        );
647        // Unknown / unset shell → no rc suggestion (caller falls back).
648        assert_eq!(source_command_for_shell(""), None);
649        assert_eq!(source_command_for_shell("/bin/false"), None);
650    }
651
652    #[test]
653    fn rc_file_matches_login_shell() {
654        // #321: hints must name the right rc file for the user's shell.
655        assert_eq!(rc_file_for_shell("/usr/bin/bash"), "~/.bashrc");
656        assert_eq!(rc_file_for_shell("/bin/zsh"), "~/.zshrc");
657        assert_eq!(
658            rc_file_for_shell("/usr/local/bin/fish"),
659            "~/.config/fish/config.fish"
660        );
661        assert_eq!(rc_file_for_shell(""), "your shell config");
662        assert_eq!(rc_file_for_shell("/bin/false"), "your shell config");
663    }
664
665    #[test]
666    fn pick_target_inline_when_forced() {
667        let tmp = tempfile::tempdir().unwrap();
668        // Even with a .d/ loop, Style::Inline must force the marked target.
669        std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
670        std::fs::write(
671            tmp.path().join(".zshenv"),
672            "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
673        )
674        .unwrap();
675        let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Inline);
676        assert!(matches!(t, InstallTarget::Marked { .. }));
677    }
678
679    #[test]
680    fn pick_target_dropin_when_detected_under_auto() {
681        let tmp = tempfile::tempdir().unwrap();
682        std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
683        std::fs::write(
684            tmp.path().join(".zshenv"),
685            "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
686        )
687        .unwrap();
688        let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Auto);
689        assert!(matches!(t, InstallTarget::DropIn { .. }));
690    }
691
692    #[test]
693    fn pick_target_inline_under_auto_when_no_dropin() {
694        let tmp = tempfile::tempdir().unwrap();
695        std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
696        let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::Auto);
697        assert!(matches!(t, InstallTarget::Marked { .. }));
698    }
699
700    #[test]
701    fn pick_target_dropin_falls_back_to_inline_when_no_directory() {
702        // User asked for DropIn but the layout isn't set up. Don't error —
703        // fall back to inline so the install still works.
704        let tmp = tempfile::tempdir().unwrap();
705        std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
706        let t = pick_target(tmp.path(), &SLOT_ZSHENV, Style::DropIn);
707        assert!(matches!(t, InstallTarget::Marked { .. }));
708    }
709
710    #[test]
711    fn install_zshenv_writes_inline_block() {
712        let tmp = tempfile::tempdir().unwrap();
713        install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
714        let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
715        assert!(body.contains(MARKER_START));
716        assert!(body.contains(MARKER_END));
717        assert!(body.contains("ZSH_EXECUTION_STRING"));
718    }
719
720    #[test]
721    fn install_zshenv_writes_dropin_when_loop_present() {
722        let tmp = tempfile::tempdir().unwrap();
723        std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
724        std::fs::write(
725            tmp.path().join(".zshenv"),
726            "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
727        )
728        .unwrap();
729        install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
730
731        let dropin_file = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
732        assert!(dropin_file.exists(), "expected drop-in file");
733        let dropin_body = std::fs::read_to_string(&dropin_file).unwrap();
734        assert!(dropin_body.contains("ZSH_EXECUTION_STRING"));
735
736        let zshenv_body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
737        assert!(
738            !zshenv_body.contains(MARKER_START),
739            "drop-in install must not also leave the inline block"
740        );
741    }
742
743    /// List sibling files of `path` whose name matches
744    /// `<basename>.lean-ctx-<timestamp>.bak`.
745    fn find_migration_backups(path: &Path) -> Vec<PathBuf> {
746        let Some(parent) = path.parent() else {
747            return Vec::new();
748        };
749        let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
750            return Vec::new();
751        };
752        let prefix = format!("{name}.lean-ctx-");
753        let mut out: Vec<PathBuf> = std::fs::read_dir(parent)
754            .into_iter()
755            .flatten()
756            .flatten()
757            .map(|e| e.path())
758            .filter(|p| {
759                p.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
760                    n.starts_with(&prefix)
761                        && std::path::Path::new(n)
762                            .extension()
763                            .is_some_and(|ext| ext.eq_ignore_ascii_case("bak"))
764                })
765            })
766            .collect();
767        out.sort();
768        out
769    }
770
771    #[test]
772    fn migration_inline_to_dropin_preserves_hand_edits_via_backup() {
773        let tmp = tempfile::tempdir().unwrap();
774        std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
775        // Existing install with a hand-edit *inside* our fenced region —
776        // the bit a maintainer might worry about losing silently.
777        let edited_zshenv = format!(
778            "export PATH=/usr/bin\n\
779             \n\
780             {MARKER_START}\n\
781             # USER CUSTOM: bump zsh history size for this workstation\n\
782             export HISTSIZE=99999\n\
783             # original lean-ctx hook content lived here\n\
784             {MARKER_END}\n\
785             \n\
786             for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
787        );
788        std::fs::write(tmp.path().join(".zshenv"), &edited_zshenv).unwrap();
789
790        install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
791
792        // Backup must exist and contain the user's exact pre-migration file.
793        let baks = find_migration_backups(&tmp.path().join(".zshenv"));
794        assert_eq!(baks.len(), 1, "expected one timestamped backup");
795        let bak_body = std::fs::read_to_string(&baks[0]).unwrap();
796        assert_eq!(bak_body, edited_zshenv);
797        assert!(bak_body.contains("USER CUSTOM"));
798        assert!(bak_body.contains("HISTSIZE=99999"));
799    }
800
801    #[test]
802    fn migration_dropin_to_inline_preserves_hand_edits_via_backup() {
803        let tmp = tempfile::tempdir().unwrap();
804        let dropin_dir = tmp.path().join(".zshenv.d");
805        std::fs::create_dir_all(&dropin_dir).unwrap();
806        // Pre-stage a drop-in file with user customisation.
807        let edited_dropin = "# USER CUSTOM addition to lean-ctx drop-in\nexport FAVOURITE_EDITOR=helix\n# canonical lean-ctx content would follow\n";
808        std::fs::write(dropin_dir.join(DROPIN_ZSH), edited_dropin).unwrap();
809        // No source loop -> Style::Auto resolves to inline (so we migrate
810        // *away* from the drop-in).
811        std::fs::write(tmp.path().join(".zshenv"), "# plain zshenv\n").unwrap();
812
813        install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
814
815        let baks = find_migration_backups(&dropin_dir.join(DROPIN_ZSH));
816        assert_eq!(baks.len(), 1, "expected one timestamped backup");
817        let bak_body = std::fs::read_to_string(&baks[0]).unwrap();
818        assert_eq!(bak_body, edited_dropin);
819        assert!(bak_body.contains("USER CUSTOM"));
820        // The original drop-in is gone, replaced by an inline block in .zshenv.
821        assert!(!dropin_dir.join(DROPIN_ZSH).exists());
822        let zshenv = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
823        assert!(zshenv.contains(MARKER_START));
824    }
825
826    #[test]
827    fn migration_skips_backup_when_no_prior_block_exists() {
828        // Clean install (no prior lean-ctx artifacts) should not litter
829        // the home dir with empty `.lean-ctx-<ts>.bak` files.
830        let tmp = tempfile::tempdir().unwrap();
831        std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
832        std::fs::write(
833            tmp.path().join(".zshenv"),
834            "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
835        )
836        .unwrap();
837
838        install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
839
840        assert!(
841            find_migration_backups(&tmp.path().join(".zshenv")).is_empty(),
842            "clean install should not create a .bak file"
843        );
844    }
845
846    #[test]
847    fn idempotent_dropin_reinstall_does_not_create_backup() {
848        // Once installed in drop-in mode, a second `install` (e.g. via
849        // `lean-ctx update` re-wiring) should not start producing backups
850        // every run. The strip-other-style path only fires when there IS
851        // an inline block to remove.
852        let tmp = tempfile::tempdir().unwrap();
853        std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
854        std::fs::write(
855            tmp.path().join(".zshenv"),
856            "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
857        )
858        .unwrap();
859
860        install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
861        install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
862
863        assert!(find_migration_backups(&tmp.path().join(".zshenv")).is_empty());
864    }
865
866    #[test]
867    fn backup_filename_handles_dotfile_correctly() {
868        // `.zshenv` has no extension; Path::with_extension would replace
869        // ".zshenv" wholesale. Using with_file_name produces the right
870        // sibling path. Timestamp is appended between basename and `.bak`.
871        let tmp = tempfile::tempdir().unwrap();
872        std::fs::write(tmp.path().join(".zshenv"), "content\n").unwrap();
873        save_migration_backup(&tmp.path().join(".zshenv"), true, &test_stamp());
874        let baks = find_migration_backups(&tmp.path().join(".zshenv"));
875        assert_eq!(baks.len(), 1);
876        // The full filename must start with the original basename so it
877        // sits as a sibling, not at the parent root.
878        let name = baks[0].file_name().unwrap().to_str().unwrap();
879        assert!(name.starts_with(".zshenv.lean-ctx-"), "got: {name}");
880        assert!(
881            std::path::Path::new(name)
882                .extension()
883                .is_some_and(|ext| ext.eq_ignore_ascii_case("bak"))
884        );
885        // Sanity-check the timestamp is in the YYYYMMDDTHHMMSSZ slot.
886        let stamp = name
887            .trim_start_matches(".zshenv.lean-ctx-")
888            .trim_end_matches(".bak");
889        assert_eq!(stamp.len(), 16, "stamp should be YYYYMMDDTHHMMSSZ: {stamp}");
890        assert!(stamp.contains('T'));
891        assert!(stamp.ends_with('Z'));
892    }
893
894    #[test]
895    fn repeated_migrations_never_clobber_prior_backups() {
896        // Regression test for the convention upgrade: two migration
897        // events on the same slot must produce two distinct backups,
898        // not silently overwrite each other. We pin two different
899        // stamps directly instead of sleeping past a second boundary.
900        let stamp_first = BackupStamp::at(
901            chrono::DateTime::parse_from_rfc3339("2026-05-11T20:38:45Z")
902                .unwrap()
903                .with_timezone(&chrono::Utc),
904        );
905        let stamp_later = BackupStamp::at(
906            chrono::DateTime::parse_from_rfc3339("2026-05-12T09:00:00Z")
907                .unwrap()
908                .with_timezone(&chrono::Utc),
909        );
910        let tmp = tempfile::tempdir().unwrap();
911        std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
912
913        let with_block_v1 = format!(
914            "{MARKER_START}\n# first-era custom content\n{MARKER_END}\n\nfor f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
915        );
916        std::fs::write(tmp.path().join(".zshenv"), &with_block_v1).unwrap();
917        install_zshenv(tmp.path(), true, Style::Auto, &stamp_first);
918        let baks_after_first = find_migration_backups(&tmp.path().join(".zshenv"));
919        assert_eq!(baks_after_first.len(), 1);
920
921        // User hand-puts a NEW inline block back (perhaps via a manual
922        // edit or a partial reinstall in a tool we don't know about).
923        let with_block_v2 = format!(
924            "{}{MARKER_START}\n# second-era custom content\n{MARKER_END}\n",
925            std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap(),
926        );
927        std::fs::write(tmp.path().join(".zshenv"), &with_block_v2).unwrap();
928        install_zshenv(tmp.path(), true, Style::Auto, &stamp_later);
929        let baks_after_second = find_migration_backups(&tmp.path().join(".zshenv"));
930
931        assert_eq!(
932            baks_after_second.len(),
933            2,
934            "second migration should leave a second backup, not overwrite"
935        );
936        // First backup unchanged from after the first migration.
937        assert_eq!(baks_after_second[0], baks_after_first[0]);
938        let first_body = std::fs::read_to_string(&baks_after_second[0]).unwrap();
939        let second_body = std::fs::read_to_string(&baks_after_second[1]).unwrap();
940        assert!(first_body.contains("first-era custom"));
941        assert!(second_body.contains("second-era custom"));
942    }
943
944    #[test]
945    fn install_migrates_inline_to_dropin() {
946        let tmp = tempfile::tempdir().unwrap();
947        // Simulate an existing install: .zshenv with the old fenced block.
948        std::fs::write(
949            tmp.path().join(".zshenv"),
950            format!(
951                "export PATH=/usr/bin\n\n{MARKER_START}\n# old hook\n{MARKER_END}\n\nfor f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
952            ),
953        )
954        .unwrap();
955        std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
956
957        install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
958
959        let zshenv_body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
960        assert!(
961            !zshenv_body.contains(MARKER_START),
962            "old inline block should be stripped after migration"
963        );
964        assert!(
965            zshenv_body.contains(".zshenv.d"),
966            "source loop must be preserved"
967        );
968        let dropin_file = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
969        assert!(dropin_file.exists(), "new drop-in file should be present");
970    }
971
972    #[test]
973    fn install_migrates_dropin_to_inline() {
974        let tmp = tempfile::tempdir().unwrap();
975        // No source loop → Style::Inline forces inline. Pre-stage a
976        // leftover drop-in file as if the user previously had the layout.
977        std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
978        std::fs::write(
979            tmp.path().join(".zshenv.d").join(DROPIN_ZSH),
980            "# stale lean-ctx drop-in\n",
981        )
982        .unwrap();
983        std::fs::write(tmp.path().join(".zshenv"), "export PATH=/usr/bin\n").unwrap();
984
985        install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
986
987        assert!(
988            !tmp.path().join(".zshenv.d").join(DROPIN_ZSH).exists(),
989            "drop-in file should be removed when installing inline"
990        );
991        let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
992        assert!(body.contains(MARKER_START));
993    }
994
995    #[test]
996    fn install_is_idempotent_in_dropin_mode() {
997        let tmp = tempfile::tempdir().unwrap();
998        std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
999        std::fs::write(
1000            tmp.path().join(".zshenv"),
1001            "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
1002        )
1003        .unwrap();
1004
1005        install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
1006        let after_first = std::fs::read(tmp.path().join(".zshenv.d").join(DROPIN_ZSH)).unwrap();
1007
1008        install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
1009        let after_second = std::fs::read(tmp.path().join(".zshenv.d").join(DROPIN_ZSH)).unwrap();
1010
1011        assert_eq!(after_first, after_second);
1012    }
1013
1014    #[test]
1015    fn install_is_idempotent_in_inline_mode() {
1016        let tmp = tempfile::tempdir().unwrap();
1017        std::fs::write(tmp.path().join(".zshenv"), "# top\n").unwrap();
1018
1019        install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1020        let after_first = std::fs::read(tmp.path().join(".zshenv")).unwrap();
1021
1022        install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1023        let after_second = std::fs::read(tmp.path().join(".zshenv")).unwrap();
1024
1025        assert_eq!(after_first, after_second);
1026    }
1027
1028    #[test]
1029    fn install_aliases_skips_when_rc_missing() {
1030        let tmp = tempfile::tempdir().unwrap();
1031        // No .zshrc, no .bashrc — nothing should be created.
1032        install_aliases(tmp.path(), true, Style::Auto, &test_stamp());
1033        assert!(!tmp.path().join(".zshrc").exists());
1034        assert!(!tmp.path().join(".bashrc").exists());
1035    }
1036
1037    #[test]
1038    fn install_aliases_writes_dropin_when_zshrc_d_configured() {
1039        let tmp = tempfile::tempdir().unwrap();
1040        std::fs::create_dir_all(tmp.path().join(".zshrc.d")).unwrap();
1041        std::fs::write(
1042            tmp.path().join(".zshrc"),
1043            "for f in $HOME/.zshrc.d/*.zsh; do source $f; done\n",
1044        )
1045        .unwrap();
1046
1047        install_aliases(tmp.path(), true, Style::Auto, &test_stamp());
1048
1049        let dropin_file = tmp.path().join(".zshrc.d").join(DROPIN_ZSH);
1050        assert!(dropin_file.exists());
1051        let body = std::fs::read_to_string(&dropin_file).unwrap();
1052        assert!(body.contains("LEAN_CTX_AGENT=1"));
1053    }
1054
1055    // --- #255: Passthrough stubs for non-interactive subshells ---
1056
1057    #[test]
1058    fn zshenv_hook_contains_lc_passthrough_stubs() {
1059        let tmp = tempfile::tempdir().unwrap();
1060        install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1061        let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
1062        assert!(
1063            body.contains(r#"_lc()          { command "$@"; }"#),
1064            "zshenv must contain _lc passthrough stub"
1065        );
1066        assert!(
1067            body.contains(r#"_lc_compress() { command "$@"; }"#),
1068            "zshenv must contain _lc_compress passthrough stub"
1069        );
1070    }
1071
1072    #[test]
1073    fn bashenv_hook_contains_lc_passthrough_stubs() {
1074        let tmp = tempfile::tempdir().unwrap();
1075        install_bashenv(tmp.path(), true, Style::Inline, &test_stamp());
1076        let body = std::fs::read_to_string(tmp.path().join(".bashenv")).unwrap();
1077        assert!(
1078            body.contains(r#"_lc()          { command "$@"; }"#),
1079            "bashenv must contain _lc passthrough stub"
1080        );
1081        assert!(
1082            body.contains(r#"_lc_compress() { command "$@"; }"#),
1083            "bashenv must contain _lc_compress passthrough stub"
1084        );
1085    }
1086
1087    #[test]
1088    fn stubs_appear_before_exec_guard() {
1089        let tmp = tempfile::tempdir().unwrap();
1090        install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1091        let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
1092        let stub_pos = body.find("_lc()").expect("_lc stub must exist");
1093        let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
1094        assert!(
1095            stub_pos < exec_pos,
1096            "stubs must be defined BEFORE the exec guard"
1097        );
1098    }
1099
1100    #[test]
1101    fn bash_stubs_appear_before_exec_guard() {
1102        // git-bash on Windows runs the agent's commands non-interactively, where
1103        // `.bashenv` is the only startup file bash sources (and only when BASH_ENV
1104        // points at it). The `_lc`/`_lc_compress` stubs must therefore be defined
1105        // BEFORE the exec guard so a residual aliased token never breaks with
1106        // `_lc: command not found` even if the guard's env-check bails (#589).
1107        let tmp = tempfile::tempdir().unwrap();
1108        install_bashenv(tmp.path(), true, Style::Inline, &test_stamp());
1109        let body = std::fs::read_to_string(tmp.path().join(".bashenv")).unwrap();
1110        let stub_pos = body.find("_lc()").expect("_lc stub must exist");
1111        let compress_pos = body
1112            .find("_lc_compress()")
1113            .expect("_lc_compress stub must exist");
1114        let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
1115        assert!(
1116            stub_pos < exec_pos && compress_pos < exec_pos,
1117            "bash stubs must be defined BEFORE the exec guard"
1118        );
1119    }
1120
1121    // --- IDE agent-exec sandbox + lean-ctx hook guard ---
1122    // The non-interactive redirect must never `exec lean-ctx -c` over a host's
1123    // agent-exec sandbox wrapper (which reports the exit code via a dedicated fd:
1124    // `dump_zsh_state >&4; builtin exit $?`) or lean-ctx's own hooks: doing so
1125    // breaks the fd handshake so the IDE reports "no exit status" for every
1126    // command (and the 120s/8MB cap truncates output). See REDIRECT_SKIP_MARKERS.
1127
1128    #[test]
1129    fn redirect_block_guards_every_skip_marker() {
1130        let block = redirect_block("ZSH_EXECUTION_STRING", "[[ -n \"$LEAN_CTX_AGENT\" ]]");
1131        let exec_pos = block
1132            .find("exec lean-ctx")
1133            .expect("redirect must exec lean-ctx");
1134        for marker in REDIRECT_SKIP_MARKERS {
1135            let guard = format!("[[ \"$ZSH_EXECUTION_STRING\" != *\"{marker}\"* ]]");
1136            let guard_pos = block
1137                .find(&guard)
1138                .unwrap_or_else(|| panic!("redirect must guard against {marker:?}:\n{block}"));
1139            assert!(
1140                guard_pos < exec_pos,
1141                "guard for {marker:?} must precede the exec redirect"
1142            );
1143        }
1144    }
1145
1146    #[test]
1147    fn zshenv_redirect_skips_ide_sandbox_and_hooks() {
1148        let tmp = tempfile::tempdir().unwrap();
1149        install_zshenv(tmp.path(), true, Style::Inline, &test_stamp());
1150        let body = std::fs::read_to_string(tmp.path().join(".zshenv")).unwrap();
1151        let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
1152        for marker in REDIRECT_SKIP_MARKERS {
1153            let guard = format!("[[ \"$ZSH_EXECUTION_STRING\" != *\"{marker}\"* ]]");
1154            let pos = body
1155                .find(&guard)
1156                .unwrap_or_else(|| panic!(".zshenv must guard against {marker:?}"));
1157            assert!(
1158                pos < exec_pos,
1159                "zshenv guard {marker:?} must precede the exec redirect"
1160            );
1161        }
1162    }
1163
1164    #[test]
1165    fn bashenv_redirect_skips_ide_sandbox_and_hooks() {
1166        let tmp = tempfile::tempdir().unwrap();
1167        install_bashenv(tmp.path(), true, Style::Inline, &test_stamp());
1168        let body = std::fs::read_to_string(tmp.path().join(".bashenv")).unwrap();
1169        let exec_pos = body.find("exec lean-ctx").expect("exec guard must exist");
1170        for marker in REDIRECT_SKIP_MARKERS {
1171            let guard = format!("[[ \"$BASH_EXECUTION_STRING\" != *\"{marker}\"* ]]");
1172            let pos = body
1173                .find(&guard)
1174                .unwrap_or_else(|| panic!(".bashenv must guard against {marker:?}"));
1175            assert!(
1176                pos < exec_pos,
1177                "bashenv guard {marker:?} must precede the exec redirect"
1178            );
1179        }
1180    }
1181
1182    #[test]
1183    fn dropin_zshenv_also_contains_stubs() {
1184        let tmp = tempfile::tempdir().unwrap();
1185        std::fs::create_dir_all(tmp.path().join(".zshenv.d")).unwrap();
1186        std::fs::write(
1187            tmp.path().join(".zshenv"),
1188            "for f in $HOME/.zshenv.d/*.zsh; do source $f; done\n",
1189        )
1190        .unwrap();
1191        install_zshenv(tmp.path(), true, Style::Auto, &test_stamp());
1192
1193        let dropin = tmp.path().join(".zshenv.d").join(DROPIN_ZSH);
1194        let body = std::fs::read_to_string(&dropin).unwrap();
1195        assert!(body.contains("_lc()"), "drop-in must also contain stubs");
1196    }
1197
1198    // --- #309: shell_available guards ---
1199
1200    /// Serialises the env-sensitive `shell_available` tests so one setting
1201    /// `LEAN_CTX_SHELL_HOOK_FORCE` can't race the filesystem-match assertions.
1202    #[cfg(unix)]
1203    static SHELL_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
1204
1205    #[cfg(unix)]
1206    #[test]
1207    fn shell_available_rejects_unknown_shell() {
1208        let _env_lock = crate::core::data_dir::test_env_lock();
1209        let _g = SHELL_ENV_LOCK
1210            .lock()
1211            .unwrap_or_else(std::sync::PoisonError::into_inner);
1212        crate::test_env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1213        assert!(!shell_available("fish"));
1214        assert!(!shell_available("nushell"));
1215        assert!(!shell_available(""));
1216    }
1217
1218    #[cfg(unix)]
1219    #[test]
1220    fn shell_available_finds_installed_shells() {
1221        let _env_lock = crate::core::data_dir::test_env_lock();
1222        let _g = SHELL_ENV_LOCK
1223            .lock()
1224            .unwrap_or_else(std::sync::PoisonError::into_inner);
1225        crate::test_env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1226        // On any Unix CI/dev machine at least one of bash/zsh should exist.
1227        let has_bash = Path::new("/bin/bash").exists() || Path::new("/usr/bin/bash").exists();
1228        let has_zsh = Path::new("/bin/zsh").exists() || Path::new("/usr/bin/zsh").exists();
1229        assert!(
1230            shell_available("bash") == has_bash,
1231            "shell_available(bash) should match filesystem"
1232        );
1233        assert!(
1234            shell_available("zsh") == has_zsh,
1235            "shell_available(zsh) should match filesystem"
1236        );
1237    }
1238
1239    #[cfg(unix)]
1240    #[test]
1241    fn shell_hook_force_overrides_detection() {
1242        let _env_lock = crate::core::data_dir::test_env_lock();
1243        let _g = SHELL_ENV_LOCK
1244            .lock()
1245            .unwrap_or_else(std::sync::PoisonError::into_inner);
1246
1247        // `all` forces every shell, even ones not on disk.
1248        crate::test_env::set_var("LEAN_CTX_SHELL_HOOK_FORCE", "all");
1249        assert!(shell_available("zsh"));
1250        assert!(shell_available("bash"));
1251
1252        // A comma list forces only the named shells.
1253        crate::test_env::set_var("LEAN_CTX_SHELL_HOOK_FORCE", "zsh");
1254        assert!(shell_available("zsh"));
1255        // `bash` falls back to filesystem detection here; assert only the
1256        // forced-on guarantee to stay host-independent.
1257
1258        crate::test_env::remove_var("LEAN_CTX_SHELL_HOOK_FORCE");
1259    }
1260}