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