Skip to main content

dodot_lib/commands/
git_alias.rs

1//! `dodot git-show-alias` and `dodot git-install-alias` — the
2//! Tier 2 shell-side glue for the template-magic flow.
3//!
4//! Tier 1 (R4) gets you commit-time correctness via the pre-commit
5//! hook. Tier 2 makes interactive `git status` and `git diff` show
6//! the truth between commits too, by wrapping git in a shell alias
7//! that runs `dodot refresh --quiet` first. The clean filter (R6)
8//! does the heavy lifting once the source mtime is fresh; this
9//! alias is what nudges the mtime on every interactive git
10//! invocation.
11//!
12//! Two forms:
13//!
14//! - `dodot git-show-alias` — print the alias for the user's shell
15//!   (detected from `$SHELL`) so they can copy-paste into their rc
16//!   file. No filesystem mutation.
17//! - `dodot git-install-alias` — write the alias to the user's rc
18//!   file (`~/.bashrc` or `~/.zshrc`) with an idempotent guard
19//!   block, mirroring the pre-commit hook installer's pattern.
20//!
21//! Only affects interactive shells. Scripts, editors, and CI that
22//! shell out to `git` directly are unaffected — exactly what we
23//! want: non-interactive callers get predictable behaviour,
24//! interactive use gets the magic.
25
26use serde::Serialize;
27use std::path::PathBuf;
28
29use crate::packs::orchestration::ExecutionContext;
30use crate::{DodotError, Result};
31
32/// The guard line that opens our managed alias block in a shell rc
33/// file. Detection of this string is what makes `install_alias`
34/// idempotent.
35pub(crate) const ALIAS_GUARD_START: &str =
36    "# >>> dodot git alias (managed by `dodot git-install-alias`) >>>";
37
38/// The guard line that closes our managed alias block.
39pub(crate) const ALIAS_GUARD_END: &str = "# <<< dodot git alias <<<";
40
41/// Which shell we're targeting. Detected from `$SHELL` for the
42/// bare `show`/`install` flow; the user can override with `--shell`
43/// at the CLI layer.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
45#[serde(rename_all = "lowercase")]
46pub enum Shell {
47    Bash,
48    Zsh,
49}
50
51impl Shell {
52    /// Detect from `$SHELL`. Returns `None` when `$SHELL` is unset
53    /// or names a shell we don't support — the caller (typically
54    /// [`resolve_shell`]) surfaces a clear error rather than
55    /// silently writing a bashrc snippet for a fish/nu user.
56    ///
57    /// Why fail explicitly: silently falling back to `Bash` means
58    /// `dodot git-install-alias` happily writes `~/.bashrc` for a
59    /// fish user, who then never sees the alias take effect.
60    /// Better to refuse with a message that points at `--shell`.
61    pub fn detect() -> Option<Self> {
62        std::env::var("SHELL").ok().and_then(|s| {
63            if s.ends_with("/zsh") || s == "zsh" {
64                Some(Shell::Zsh)
65            } else if s.ends_with("/bash") || s == "bash" {
66                Some(Shell::Bash)
67            } else {
68                None
69            }
70        })
71    }
72
73    /// Parse a CLI `--shell` value. Returns `None` for unknown
74    /// shells; the CLI layer surfaces a clear error.
75    pub fn from_str_opt(s: &str) -> Option<Self> {
76        match s.to_ascii_lowercase().as_str() {
77            "bash" => Some(Shell::Bash),
78            "zsh" => Some(Shell::Zsh),
79            _ => None,
80        }
81    }
82
83    /// Path to the rc file we write to for this shell, relative to
84    /// `$HOME`. Used by [`install_alias`] to compute the absolute
85    /// path. We pick the most universally-sourced file: `.bashrc`
86    /// on bash, `.zshrc` on zsh. Users with non-standard setups can
87    /// run `git-show-alias` and paste manually.
88    pub fn rc_relative_path(self) -> &'static str {
89        match self {
90            Shell::Bash => ".bashrc",
91            Shell::Zsh => ".zshrc",
92        }
93    }
94
95    /// The alias line for this shell. Both bash and zsh accept the
96    /// same `alias` syntax; we pin them per-shell anyway because
97    /// future shells (fish, nu) will need different forms and
98    /// keeping the dispatch on a single match is the cleanest
99    /// extension point.
100    pub fn alias_line(self) -> &'static str {
101        match self {
102            Shell::Bash | Shell::Zsh => "alias git='dodot refresh --quiet && command git'",
103        }
104    }
105}
106
107/// The full guarded block that `install_alias` writes (or that
108/// `show_alias` prints for copy-paste). Mirrors the pre-commit
109/// hook's `managed_block` shape.
110pub fn managed_block(shell: Shell) -> String {
111    format!(
112        "{guard_start}\n\
113         # Wraps `git` to run `dodot refresh` first, so `git status` and\n\
114         # `git diff` show deployed-side template edits between commits.\n\
115         # Only affects interactive shells. Remove this block to opt out.\n\
116         {alias}\n\
117         {guard_end}\n",
118        guard_start = ALIAS_GUARD_START,
119        guard_end = ALIAS_GUARD_END,
120        alias = shell.alias_line(),
121    )
122}
123
124// ── show ────────────────────────────────────────────────────────
125
126/// Result of `dodot git-show-alias` — the alias body the user can
127/// paste, plus the rc file we'd write it to (so the rendered output
128/// can show "add this to ~/.zshrc").
129#[derive(Debug, Clone, Serialize)]
130pub struct ShowAliasResult {
131    pub shell: Shell,
132    pub alias_block: String,
133    /// `alias_block` split by line, so the template can iterate
134    /// directly without calling `.split` (which minijinja doesn't
135    /// expose). Same trick `git-filters` uses for the same reason.
136    pub alias_block_lines: Vec<String>,
137    pub rc_path_display: String,
138    /// True iff the rc file currently contains our managed block.
139    /// Drives the rendered output: "you've already installed this"
140    /// vs "add this to your rc file".
141    pub already_installed: bool,
142}
143
144pub fn show_alias(ctx: &ExecutionContext, shell: Shell) -> Result<ShowAliasResult> {
145    let rc_path = ctx.paths.home_dir().join(shell.rc_relative_path());
146    let already_installed = if ctx.fs.exists(&rc_path) {
147        ctx.fs
148            .read_to_string(&rc_path)
149            .map(|s| s.contains(ALIAS_GUARD_START))
150            .unwrap_or(false)
151    } else {
152        false
153    };
154    let alias_block = managed_block(shell);
155    let alias_block_lines: Vec<String> = alias_block
156        .lines()
157        .filter(|l| !l.is_empty())
158        .map(str::to_string)
159        .collect();
160    Ok(ShowAliasResult {
161        shell,
162        alias_block,
163        alias_block_lines,
164        rc_path_display: render_home_relative(&rc_path, ctx.paths.home_dir()),
165        already_installed,
166    })
167}
168
169// ── install ─────────────────────────────────────────────────────
170
171/// Outcome of `dodot git-install-alias`. Mirrors `InstallHookOutcome`.
172#[derive(Debug, Clone, Serialize)]
173#[serde(rename_all = "snake_case")]
174pub enum InstallAliasOutcome {
175    /// rc file did not exist; we created it with our block.
176    Created,
177    /// rc file existed; we appended our block to it. Existing
178    /// content preserved.
179    Appended,
180    /// rc file already contains the current managed block — no
181    /// change.
182    AlreadyInstalled,
183    /// rc file had an older managed block; we replaced it in place.
184    /// Existing non-managed content preserved.
185    Updated,
186}
187
188#[derive(Debug, Clone, Serialize)]
189pub struct InstallAliasResult {
190    pub shell: Shell,
191    pub outcome: InstallAliasOutcome,
192    pub rc_path: String,
193    pub rc_path_display: String,
194    /// The shell sourcing command the user needs to pick up the
195    /// alias *now* (without restarting the shell). Surfaced so the
196    /// rendered message can suggest e.g. `source ~/.zshrc`.
197    pub source_command: String,
198}
199
200pub fn install_alias(ctx: &ExecutionContext, shell: Shell) -> Result<InstallAliasResult> {
201    let rc_path = ctx.paths.home_dir().join(shell.rc_relative_path());
202    let block = managed_block(shell);
203
204    let outcome = if ctx.fs.exists(&rc_path) {
205        let existing = ctx.fs.read_to_string(&rc_path)?;
206        if let Some((start_byte, end_byte)) = find_managed_block(&existing) {
207            let current_block = &existing[start_byte..end_byte];
208            if current_block == block {
209                InstallAliasOutcome::AlreadyInstalled
210            } else {
211                let mut new_content = String::with_capacity(existing.len() + block.len());
212                new_content.push_str(&existing[..start_byte]);
213                new_content.push_str(&block);
214                new_content.push_str(&existing[end_byte..]);
215                ctx.fs.write_file(&rc_path, new_content.as_bytes())?;
216                InstallAliasOutcome::Updated
217            }
218        } else {
219            // Append, preserving existing rc content. Add a leading
220            // blank line so the block is visually separate from
221            // whatever the user has above.
222            let mut new_content = existing.clone();
223            if !new_content.ends_with('\n') {
224                new_content.push('\n');
225            }
226            if !new_content.ends_with("\n\n") {
227                new_content.push('\n');
228            }
229            new_content.push_str(&block);
230            ctx.fs.write_file(&rc_path, new_content.as_bytes())?;
231            InstallAliasOutcome::Appended
232        }
233    } else {
234        // Create the rc file with just our block. Most users will
235        // already have one; this branch covers the rare empty-home
236        // setup or a truly fresh shell install.
237        ctx.fs.write_file(&rc_path, block.as_bytes())?;
238        InstallAliasOutcome::Created
239    };
240
241    Ok(InstallAliasResult {
242        shell,
243        outcome,
244        rc_path: rc_path.display().to_string(),
245        rc_path_display: render_home_relative(&rc_path, ctx.paths.home_dir()),
246        source_command: format!(
247            "source {}",
248            render_home_relative(&rc_path, ctx.paths.home_dir())
249        ),
250    })
251}
252
253// ── helpers ─────────────────────────────────────────────────────
254
255fn render_home_relative(p: &std::path::Path, home: &std::path::Path) -> String {
256    if let Ok(rel) = p.strip_prefix(home) {
257        format!("~/{}", rel.display())
258    } else {
259        p.display().to_string()
260    }
261}
262
263/// Locate the byte range of our managed block inside `text`. Same
264/// shape as the pre-commit hook's `find_managed_block` — find the
265/// first guard-start, then the first guard-end after it, return the
266/// inclusive byte range (with the trailing newline if any). Returns
267/// `None` if either guard is missing.
268fn find_managed_block(text: &str) -> Option<(usize, usize)> {
269    let start = text.find(ALIAS_GUARD_START)?;
270    let after_start = start + ALIAS_GUARD_START.len();
271    let end_rel = text[after_start..].find(ALIAS_GUARD_END)?;
272    let end_guard_start = after_start + end_rel;
273    let end_byte = end_guard_start + ALIAS_GUARD_END.len();
274    let end_byte = if text.as_bytes().get(end_byte) == Some(&b'\n') {
275        end_byte + 1
276    } else {
277        end_byte
278    };
279    Some((start, end_byte))
280}
281
282/// Diagnostic helper for the CLI: detect or validate a shell from
283/// the `--shell` CLI value, surfacing a clear error for unknown
284/// shells. Returns the resolved [`Shell`] or a `DodotError::Other`.
285///
286/// When `explicit` is `None` and `Shell::detect()` returns `None`
287/// (unsupported `$SHELL`), errors out asking the user to pass
288/// `--shell bash` or `--shell zsh`. Better than silently falling
289/// back to bash on a fish/nu setup, which would produce an alias
290/// that never fires.
291pub fn resolve_shell(explicit: Option<&str>) -> Result<Shell> {
292    if let Some(name) = explicit {
293        return Shell::from_str_opt(name).ok_or_else(|| {
294            DodotError::Other(format!(
295                "unsupported shell {name:?}: dodot can install the git alias for `bash` or `zsh`. \
296                 For other shells, run `dodot git-show-alias --shell bash` and adapt the snippet."
297            ))
298        });
299    }
300    Shell::detect().ok_or_else(|| {
301        let detected = std::env::var("SHELL").unwrap_or_default();
302        if detected.is_empty() {
303            DodotError::Other(
304                "$SHELL is unset; pass `--shell bash` or `--shell zsh` so dodot knows which \
305                 rc file to write."
306                    .into(),
307            )
308        } else {
309            DodotError::Other(format!(
310                "could not detect shell from $SHELL ({detected:?}): dodot can install the git \
311                 alias for `bash` or `zsh`. Pass `--shell bash` or `--shell zsh` explicitly, or \
312                 run `dodot git-show-alias --shell bash` and adapt the snippet for your shell."
313            ))
314        }
315    })
316}
317
318/// Cheap "is this rc file already wrapping git via our alias?"
319/// check, used by the future post-`up` prompt. Reads the rc file
320/// and looks for the guard. Doesn't error out if the file is
321/// missing — that's a normal "not installed" state.
322pub fn is_installed(ctx: &ExecutionContext, shell: Shell) -> bool {
323    let rc_path = ctx.paths.home_dir().join(shell.rc_relative_path());
324    if !ctx.fs.exists(&rc_path) {
325        return false;
326    }
327    ctx.fs
328        .read_to_string(&rc_path)
329        .map(|s| s.contains(ALIAS_GUARD_START))
330        .unwrap_or(false)
331}
332
333// Mirror of crate's PathBuf re-export; localised to keep the
334// `use` block at the top of the file readable.
335#[allow(dead_code)]
336fn _path_buf_anchor(_: PathBuf) {}
337
338#[cfg(test)]
339mod tests {
340    use super::*;
341    use crate::fs::Fs;
342    use crate::paths::Pather;
343    use crate::testing::TempEnvironment;
344
345    fn make_ctx(env: &TempEnvironment) -> ExecutionContext {
346        use crate::config::ConfigManager;
347        use crate::datastore::{CommandOutput, CommandRunner, FilesystemDataStore};
348        use crate::fs::Fs;
349        use crate::paths::Pather;
350        use std::sync::Arc;
351
352        struct NoopRunner;
353        impl CommandRunner for NoopRunner {
354            fn run(&self, _e: &str, _a: &[String]) -> Result<CommandOutput> {
355                Ok(CommandOutput {
356                    exit_code: 0,
357                    stdout: String::new(),
358                    stderr: String::new(),
359                })
360            }
361        }
362        let runner: Arc<dyn CommandRunner> = Arc::new(NoopRunner);
363        let datastore = Arc::new(FilesystemDataStore::new(
364            env.fs.clone(),
365            env.paths.clone(),
366            runner.clone(),
367        ));
368        let config_manager = Arc::new(ConfigManager::new(&env.dotfiles_root).unwrap());
369        ExecutionContext {
370            fs: env.fs.clone() as Arc<dyn Fs>,
371            datastore,
372            paths: env.paths.clone() as Arc<dyn Pather>,
373            config_manager,
374            syntax_checker: Arc::new(crate::shell::NoopSyntaxChecker),
375            command_runner: runner,
376            dry_run: false,
377            no_provision: true,
378            provision_rerun: false,
379            force: false,
380            view_mode: crate::commands::ViewMode::Full,
381            group_mode: crate::commands::GroupMode::Name,
382            verbose: false,
383        }
384    }
385
386    // ── Shell detection / parsing ───────────────────────────────
387
388    #[test]
389    fn from_str_opt_recognises_known_shells() {
390        assert_eq!(Shell::from_str_opt("bash"), Some(Shell::Bash));
391        assert_eq!(Shell::from_str_opt("BASH"), Some(Shell::Bash));
392        assert_eq!(Shell::from_str_opt("zsh"), Some(Shell::Zsh));
393        assert_eq!(Shell::from_str_opt("fish"), None);
394        assert_eq!(Shell::from_str_opt("Powershell"), None);
395    }
396
397    #[test]
398    fn rc_paths_match_shell_conventions() {
399        assert_eq!(Shell::Bash.rc_relative_path(), ".bashrc");
400        assert_eq!(Shell::Zsh.rc_relative_path(), ".zshrc");
401    }
402
403    #[test]
404    fn alias_line_runs_refresh_then_command_git() {
405        // Both shells use the same alias body for now; pin the
406        // exact form so a stray edit doesn't break the wrap.
407        for sh in [Shell::Bash, Shell::Zsh] {
408            assert_eq!(
409                sh.alias_line(),
410                "alias git='dodot refresh --quiet && command git'"
411            );
412        }
413    }
414
415    #[test]
416    fn resolve_shell_explicit_unknown_returns_error() {
417        let err = resolve_shell(Some("fish")).unwrap_err();
418        let msg = format!("{err}");
419        assert!(msg.contains("fish"), "msg: {msg}");
420        assert!(
421            msg.contains("bash"),
422            "msg should suggest supported shells: {msg}"
423        );
424    }
425
426    #[test]
427    fn resolve_shell_explicit_known_returns_match() {
428        assert_eq!(resolve_shell(Some("bash")).unwrap(), Shell::Bash);
429        assert_eq!(resolve_shell(Some("Zsh")).unwrap(), Shell::Zsh);
430    }
431
432    // Env-driven detect/resolve_shell tests use the shared
433    // `ShellEnvGuard` from `crate::testing`. The guard is RAII
434    // (restores `$SHELL` on drop, including on panic) AND holds a
435    // process-wide mutex, so any test in the binary touching
436    // `$SHELL` is serialised. We can split these into one
437    // `#[test]` per scenario again, since the guard handles both
438    // the panic-safety and cross-test-races concerns Copilot
439    // raised on R8.
440
441    use crate::testing::ShellEnvGuard;
442
443    #[test]
444    fn detect_returns_some_for_bash() {
445        let _g = ShellEnvGuard::set("/bin/bash");
446        assert_eq!(Shell::detect(), Some(Shell::Bash));
447    }
448
449    #[test]
450    fn detect_returns_some_for_zsh() {
451        let _g = ShellEnvGuard::set("/usr/local/bin/zsh");
452        assert_eq!(Shell::detect(), Some(Shell::Zsh));
453    }
454
455    #[test]
456    fn detect_returns_none_for_unknown_shell() {
457        // fish/nu/etc. don't auto-detect — the caller must `--shell`.
458        let _g = ShellEnvGuard::set("/usr/bin/fish");
459        assert_eq!(Shell::detect(), None);
460    }
461
462    #[test]
463    fn resolve_shell_no_explicit_unsupported_shell_errors() {
464        // The PR-review fix from R7: a fish/nu user running
465        // `dodot git-show-alias` (no --shell) gets a clear error
466        // pointing at `--shell bash|zsh`, NOT a silent fall-
467        // through to bash that writes a useless ~/.bashrc.
468        let _g = ShellEnvGuard::set("/usr/bin/fish");
469        let err = resolve_shell(None).unwrap_err();
470        let msg = format!("{err}");
471        assert!(msg.contains("fish"), "msg: {msg}");
472        assert!(msg.contains("--shell"), "msg should suggest --shell: {msg}");
473    }
474
475    #[test]
476    fn resolve_shell_no_explicit_unset_shell_errors() {
477        // $SHELL unset entirely. Same disposition: clear pointer
478        // at --shell.
479        let _g = ShellEnvGuard::unset();
480        let err = resolve_shell(None).unwrap_err();
481        let msg = format!("{err}");
482        assert!(msg.contains("$SHELL"), "msg: {msg}");
483        assert!(msg.contains("--shell"), "msg: {msg}");
484    }
485
486    // ── managed_block + find_managed_block ──────────────────────
487
488    #[test]
489    fn managed_block_is_self_contained_and_grep_detectable() {
490        let block = managed_block(Shell::Bash);
491        assert!(block.starts_with(ALIAS_GUARD_START));
492        assert!(block.trim_end().ends_with(ALIAS_GUARD_END));
493        assert!(block.contains(Shell::Bash.alias_line()));
494    }
495
496    #[test]
497    fn find_managed_block_locates_byte_range() {
498        let block = managed_block(Shell::Bash);
499        let text = format!("# rc preamble\n{block}# rc postamble\n");
500        let (start, end) = find_managed_block(&text).expect("must find block");
501        assert_eq!(&text[start..end], block);
502    }
503
504    #[test]
505    fn find_managed_block_returns_none_when_absent() {
506        assert!(find_managed_block("nothing here").is_none());
507        let only_start = format!("{ALIAS_GUARD_START}\nstuff\n");
508        assert!(find_managed_block(&only_start).is_none());
509    }
510
511    // ── install_alias ───────────────────────────────────────────
512
513    #[test]
514    fn install_alias_creates_rc_file_when_absent() {
515        // Fresh home, no rc file. install_alias must create it
516        // with just our block (rare but possible — empty-home or
517        // truly fresh shell setup).
518        let env = TempEnvironment::builder().build();
519        let ctx = make_ctx(&env);
520        let rc_path = env.paths.home_dir().join(".zshrc");
521        assert!(!env.fs.exists(&rc_path));
522
523        let r = install_alias(&ctx, Shell::Zsh).unwrap();
524        assert!(matches!(r.outcome, InstallAliasOutcome::Created));
525        assert!(env.fs.exists(&rc_path));
526        let body = env.fs.read_to_string(&rc_path).unwrap();
527        assert!(body.contains(ALIAS_GUARD_START));
528        assert!(body.contains(Shell::Zsh.alias_line()));
529    }
530
531    #[test]
532    fn install_alias_appends_to_existing_rc() {
533        let env = TempEnvironment::builder().build();
534        let rc_path = env.paths.home_dir().join(".bashrc");
535        let existing = "export PATH=\"/usr/local/bin:$PATH\"\nalias ll='ls -l'\n";
536        env.fs.mkdir_all(rc_path.parent().unwrap()).unwrap();
537        env.fs.write_file(&rc_path, existing.as_bytes()).unwrap();
538
539        let ctx = make_ctx(&env);
540        let r = install_alias(&ctx, Shell::Bash).unwrap();
541        assert!(matches!(r.outcome, InstallAliasOutcome::Appended));
542
543        let body = env.fs.read_to_string(&rc_path).unwrap();
544        assert!(body.starts_with(existing), "user content lost: {body:?}");
545        assert!(body.contains(Shell::Bash.alias_line()));
546    }
547
548    #[test]
549    fn install_alias_is_idempotent_on_current_block() {
550        let env = TempEnvironment::builder().build();
551        let ctx = make_ctx(&env);
552
553        let r1 = install_alias(&ctx, Shell::Zsh).unwrap();
554        assert!(matches!(r1.outcome, InstallAliasOutcome::Created));
555
556        let body_after_first = env
557            .fs
558            .read_to_string(&env.paths.home_dir().join(".zshrc"))
559            .unwrap();
560
561        let r2 = install_alias(&ctx, Shell::Zsh).unwrap();
562        assert!(matches!(r2.outcome, InstallAliasOutcome::AlreadyInstalled));
563
564        let body_after_second = env
565            .fs
566            .read_to_string(&env.paths.home_dir().join(".zshrc"))
567            .unwrap();
568        assert_eq!(body_after_first, body_after_second);
569    }
570
571    #[test]
572    fn install_alias_updates_a_stale_block() {
573        // If the block exists but doesn't match `managed_block(...)`
574        // (e.g. an older form before we added a comment line, or
575        // shell-specific changes), install_alias rewrites it in
576        // place. Same upgrade path the hook installer uses.
577        let env = TempEnvironment::builder().build();
578        let rc_path = env.paths.home_dir().join(".zshrc");
579        let stale = format!(
580            "export PATH=\"/usr/local/bin:$PATH\"\n\
581             \n\
582             {start}\n\
583             # An old, simpler form of the alias block.\n\
584             alias git='dodot refresh && git'\n\
585             {end}\n\
586             alias ll='ls -l'\n",
587            start = ALIAS_GUARD_START,
588            end = ALIAS_GUARD_END,
589        );
590        env.fs.mkdir_all(rc_path.parent().unwrap()).unwrap();
591        env.fs.write_file(&rc_path, stale.as_bytes()).unwrap();
592
593        let ctx = make_ctx(&env);
594        let r = install_alias(&ctx, Shell::Zsh).unwrap();
595        assert!(matches!(r.outcome, InstallAliasOutcome::Updated));
596
597        let body = env.fs.read_to_string(&rc_path).unwrap();
598        // New shape:
599        assert!(body.contains(Shell::Zsh.alias_line()));
600        // User content (before AND after the block) survived:
601        assert!(body.contains("export PATH"));
602        assert!(body.contains("alias ll='ls -l'"));
603        // Exactly one managed block.
604        assert_eq!(body.matches(ALIAS_GUARD_START).count(), 1);
605    }
606
607    #[test]
608    fn is_installed_reflects_state() {
609        let env = TempEnvironment::builder().build();
610        let ctx = make_ctx(&env);
611        assert!(!is_installed(&ctx, Shell::Bash));
612        install_alias(&ctx, Shell::Bash).unwrap();
613        assert!(is_installed(&ctx, Shell::Bash));
614        // The other shell's state is independent.
615        assert!(!is_installed(&ctx, Shell::Zsh));
616    }
617
618    // ── show_alias ──────────────────────────────────────────────
619
620    #[test]
621    fn show_alias_renders_block_without_writing() {
622        let env = TempEnvironment::builder().build();
623        let ctx = make_ctx(&env);
624        let rc_path = env.paths.home_dir().join(".zshrc");
625        assert!(!env.fs.exists(&rc_path));
626
627        let r = show_alias(&ctx, Shell::Zsh).unwrap();
628        assert!(r.alias_block.contains(Shell::Zsh.alias_line()));
629        assert_eq!(r.rc_path_display, "~/.zshrc");
630        assert!(!r.already_installed);
631        // Critically: file must NOT have been created.
632        assert!(!env.fs.exists(&rc_path));
633    }
634
635    #[test]
636    fn show_alias_reports_already_installed_when_block_present() {
637        let env = TempEnvironment::builder().build();
638        let ctx = make_ctx(&env);
639        install_alias(&ctx, Shell::Bash).unwrap();
640        let r = show_alias(&ctx, Shell::Bash).unwrap();
641        assert!(r.already_installed);
642    }
643}