Skip to main content

lean_ctx/
shell_hook.rs

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