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