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