Skip to main content

dodot_lib/shell/
mod.rs

1//! Shell integration — generates `dodot-init.sh`.
2//!
3//! Unlike the Go implementation which ships a ~400-line shell script
4//! that re-discovers the datastore layout at runtime, we generate a
5//! flat, declarative script from the actual datastore state. This
6//! means:
7//!
8//! - Zero logic duplication between Rust and shell
9//! - The script is just `source` and `PATH=` lines — trivially fast
10//! - Changes to the datastore layout only need to happen in Rust
11//!
12//! The generated script is written to `data_dir/shell/dodot-init.sh`.
13//! Users source it from their shell profile:
14//!
15//! ```sh
16//! [ -f ~/.local/share/dodot/shell/dodot-init.sh ] && . ~/.local/share/dodot/shell/dodot-init.sh
17//! ```
18//!
19//! In the future, this can also be exposed as `dodot init-sh` or
20//! a minimal standalone binary for even faster shell startup.
21//!
22//! # Profiling wrapper (Phase 2 of profiling.lex)
23//!
24//! When the caller passes `profiling_enabled = true`, the generator
25//! wraps every `source` and PATH line with an inline `EPOCHREALTIME`
26//! capture and writes one `profile-*.tsv` per shell start under
27//! `<data_dir>/probes/shell-init/`. The wrapper is gated on a runtime
28//! check (`bash 5+` / `zsh` with `EPOCHREALTIME` available); shells
29//! without the variable fall through to the unchanged source/PATH
30//! path with a single `[ "$_dodot_prof" = "1" ]` test of overhead.
31//! When `profiling_enabled = false`, the generated script is
32//! byte-identical to the pre-Phase-2 form.
33//!
34//! Sources are *not* wrapped in a shell function: in zsh, `source`
35//! inside a function changes scoping for plain variable assignments
36//! in the sourced file, which is a behavioural surprise nobody asked
37//! for. We pay the price of a slightly longer script in exchange for
38//! semantic equivalence with the un-instrumented form.
39
40use std::fmt::Write;
41use std::path::{Path, PathBuf};
42
43use crate::fs::Fs;
44use crate::paths::Pather;
45use crate::Result;
46
47pub mod validate;
48pub use validate::{
49    error_sidecar_path, validate_shell_sources, NoopSyntaxChecker, ShellValidationFailure,
50    ShellValidationReport, SyntaxCheckResult, SyntaxChecker, SystemSyntaxChecker, ERRORS_SUBDIR,
51};
52
53/// Append the "nothing to do" notice for an empty init script.
54fn append_empty_notice(script: &mut String) {
55    writeln!(script, "# No shell scripts or PATH additions to load.").unwrap();
56    writeln!(
57        script,
58        "# Run `dodot up` to deploy packs, or `dodot status` to see available packs."
59    )
60    .unwrap();
61}
62
63/// Generate the shell init script content from the current datastore state.
64///
65/// Scans the datastore for:
66/// - `packs/*/shell/*` — symlinks to shell scripts → `source` lines
67/// - `packs/*/path/*` — symlinks to directories → `PATH=` lines
68///
69/// When `profiling_enabled` is true and there is at least one entry to
70/// emit, the script also carries the per-line timing wrapper described
71/// in the module docs.
72pub fn generate_init_script(
73    fs: &dyn Fs,
74    paths: &dyn Pather,
75    profiling_enabled: bool,
76) -> Result<String> {
77    let mut script = String::new();
78
79    writeln!(script, "#!/bin/sh").unwrap();
80    writeln!(script, "# Generated by dodot — do not edit manually.").unwrap();
81    writeln!(script, "# Regenerated on every `dodot up` / `dodot down`.").unwrap();
82    writeln!(script).unwrap();
83
84    // Discover all packs with state
85    let packs_dir = paths.data_dir().join("packs");
86    if !fs.exists(&packs_dir) {
87        append_empty_notice(&mut script);
88        return Ok(script);
89    }
90
91    let pack_entries = fs.read_dir(&packs_dir)?;
92
93    // Collect shell sources and path additions separately so we can
94    // group them in the output for readability.
95    let mut shell_sources: Vec<(String, PathBuf)> = Vec::new(); // (pack, target)
96    let mut path_additions: Vec<(String, PathBuf)> = Vec::new(); // (pack, target)
97
98    for pack_entry in &pack_entries {
99        if !pack_entry.is_dir {
100            continue;
101        }
102        // The datastore subtree is keyed by the on-disk directory
103        // name (e.g. `010-nvim`), but the comment we emit in the
104        // generated init script uses the pack's display name
105        // (`nvim`) — that's what the user sees in `dodot status` and
106        // expects to recognise here.
107        let pack_dir = &pack_entry.name;
108        let pack_display = crate::packs::display_name_for(pack_dir).to_string();
109
110        // Shell handler: source scripts
111        let shell_dir = paths.handler_data_dir(pack_dir, "shell");
112        if fs.is_dir(&shell_dir) {
113            if let Ok(entries) = fs.read_dir(&shell_dir) {
114                for entry in entries {
115                    if !entry.is_symlink {
116                        continue;
117                    }
118                    // Follow the symlink to get the actual file path
119                    let target = fs.readlink(&entry.path)?;
120                    shell_sources.push((pack_display.clone(), target));
121                }
122            }
123        }
124
125        // Path handler: add to PATH
126        let path_dir = paths.handler_data_dir(pack_dir, "path");
127        if fs.is_dir(&path_dir) {
128            if let Ok(entries) = fs.read_dir(&path_dir) {
129                for entry in entries {
130                    if !entry.is_symlink {
131                        continue;
132                    }
133                    let target = fs.readlink(&entry.path)?;
134                    path_additions.push((pack_display.clone(), target));
135                }
136            }
137        }
138    }
139
140    // If nothing is deployed, add an explanatory comment
141    if path_additions.is_empty() && shell_sources.is_empty() {
142        append_empty_notice(&mut script);
143        return Ok(script);
144    }
145
146    // Profiling preamble (only when enabled and there's at least one entry).
147    let profiling_active = profiling_enabled;
148    if profiling_active {
149        emit_profiling_preamble(
150            &mut script,
151            &paths.probes_shell_init_dir(),
152            &paths.init_script_path(),
153        );
154    }
155
156    // Emit PATH additions
157    if !path_additions.is_empty() {
158        writeln!(script, "# PATH additions").unwrap();
159        for (pack, target) in &path_additions {
160            writeln!(script, "# [{pack}]").unwrap();
161            if profiling_active {
162                emit_timed_path(&mut script, pack, target);
163            } else {
164                writeln!(script, "export PATH=\"{}:$PATH\"", target.display()).unwrap();
165            }
166        }
167        writeln!(script).unwrap();
168    }
169
170    // Emit shell sources
171    if !shell_sources.is_empty() {
172        writeln!(script, "# Shell scripts").unwrap();
173        for (pack, target) in &shell_sources {
174            writeln!(script, "# [{pack}]").unwrap();
175            if profiling_active {
176                emit_timed_source(&mut script, pack, target);
177            } else {
178                // Loud-failure wrapper: if the source command itself
179                // exits non-zero, print a dodot-attributed message to
180                // stderr alongside the shell's own error so the user
181                // can see *which* dodot-managed file failed. The
182                // shell's native message already carries the line
183                // number; we add the breadcrumb back to dodot.
184                writeln!(
185                    script,
186                    "[ -f \"{p}\" ] && {{ . \"{p}\" || echo \"dodot: shell source exited $?: {p}\" >&2; }}",
187                    p = target.display()
188                )
189                .unwrap();
190            }
191        }
192        writeln!(script).unwrap();
193    }
194
195    // Profiling epilogue (close the report, scrub our state).
196    if profiling_active {
197        emit_profiling_epilogue(&mut script);
198    }
199
200    Ok(script)
201}
202
203/// Generate and write the init script to `data_dir/shell/dodot-init.sh`.
204///
205/// Returns the path where the script was written.
206pub fn write_init_script(
207    fs: &dyn Fs,
208    paths: &dyn Pather,
209    profiling_enabled: bool,
210) -> Result<PathBuf> {
211    let script_content = generate_init_script(fs, paths, profiling_enabled)?;
212    let script_path = paths.init_script_path();
213
214    fs.mkdir_all(paths.shell_dir())?;
215    fs.write_file(&script_path, script_content.as_bytes())?;
216    fs.set_permissions(&script_path, 0o755)?;
217
218    Ok(script_path)
219}
220
221// ── Profiling wrapper emitters ───────────────────────────────────────
222
223/// The runtime-detection preamble. Sets `_dodot_prof` to `1` when the
224/// current shell is bash 5+ or zsh with `EPOCHREALTIME` available;
225/// otherwise leaves it `0` (the wrapper falls through to the no-op
226/// path). All shell variables are namespaced `_dodot_*` so we don't
227/// stomp on the user's environment.
228fn emit_profiling_preamble(script: &mut String, profiles_dir: &Path, init_script_path: &Path) {
229    let dir = sh_quote(&profiles_dir.display().to_string());
230    let init_script = sh_quote(&init_script_path.display().to_string());
231    writeln!(script, "# ── dodot shell-init profiling (Phase 2) ──").unwrap();
232    writeln!(script, "_dodot_prof=0").unwrap();
233    writeln!(
234        script,
235        "if [ -n \"${{BASH_VERSION:-}}\" ] || [ -n \"${{ZSH_VERSION:-}}\" ]; then"
236    )
237    .unwrap();
238    // zsh exposes EPOCHREALTIME only after `zmodload zsh/datetime`. Load
239    // it eagerly here; bash 5+ has the variable built in and ignores
240    // unknown commands like `zmodload` (we suppress its `command not
241    // found` error). Doing this *inside* the bash/zsh guard keeps it off
242    // hot paths in plain sh.
243    writeln!(
244        script,
245        "  [ -n \"${{ZSH_VERSION:-}}\" ] && zmodload zsh/datetime 2>/dev/null"
246    )
247    .unwrap();
248    writeln!(script, "  if [ -n \"${{EPOCHREALTIME:-}}\" ]; then").unwrap();
249    writeln!(script, "    _dodot_prof_dir={dir}").unwrap();
250    writeln!(
251        script,
252        "    _dodot_prof_file=\"$_dodot_prof_dir/profile-${{EPOCHSECONDS:-0}}-$$-${{RANDOM}}.tsv\""
253    )
254    .unwrap();
255    // Sibling errors log: one record per source whose stderr was non-empty.
256    // Format: `@@\t<target>\t<exit_status>` header line, followed by the
257    // captured stderr verbatim, followed by a trailing newline. Loaded
258    // alongside the profile by `probe::shell_init::read_recent_profiles`
259    // and parsed by `probe::shell_init::parse_errors_log`.
260    writeln!(
261        script,
262        "    _dodot_err_file=\"${{_dodot_prof_file%.tsv}}.errors.log\""
263    )
264    .unwrap();
265    // Per-shell scratch file for capturing each source's stderr. Reused
266    // across every source in this shell startup; truncated each time.
267    writeln!(script, "    _dodot_err_tmp=\"$_dodot_prof_dir/.errtmp-$$\"").unwrap();
268    writeln!(
269        script,
270        "    if mkdir -p \"$_dodot_prof_dir\" 2>/dev/null; then"
271    )
272    .unwrap();
273    writeln!(script, "      _dodot_prof_t0=$EPOCHREALTIME").unwrap();
274    writeln!(script, "      {{").unwrap();
275    writeln!(script, "        printf '# dodot shell-init profile v1\\n'").unwrap();
276    writeln!(
277        script,
278        "        printf '# shell\\t%s\\n' \"${{BASH_VERSION:+bash $BASH_VERSION}}${{ZSH_VERSION:+zsh $ZSH_VERSION}}\""
279    )
280    .unwrap();
281    writeln!(
282        script,
283        "        printf '# start_t\\t%s\\n' \"$_dodot_prof_t0\""
284    )
285    .unwrap();
286    writeln!(
287        script,
288        "        printf '# init_script\\t%s\\n' {init_script}"
289    )
290    .unwrap();
291    writeln!(
292        script,
293        "        printf '# columns\\tphase\\tpack\\thandler\\ttarget\\tstart_t\\tend_t\\texit_status\\n'"
294    )
295    .unwrap();
296    writeln!(
297        script,
298        "      }} > \"$_dodot_prof_file\" 2>/dev/null && _dodot_prof=1"
299    )
300    .unwrap();
301    // Errors log is created lazily — see `emit_timed_source`. Most shell
302    // startups have no stderr from any source, and writing an empty
303    // header file for each one would defeat the "fast path is free"
304    // claim. The first source that actually emits stderr seeds the
305    // header before appending its record.
306    writeln!(script, "    fi").unwrap();
307    writeln!(script, "  fi").unwrap();
308    writeln!(script, "fi").unwrap();
309    writeln!(script).unwrap();
310}
311
312/// One inline-timed `export PATH=…` row. The branch is one comparison
313/// at runtime — negligible on shells where the wrapper is inert.
314fn emit_timed_path(script: &mut String, pack: &str, target: &Path) {
315    let target_str = target.display().to_string();
316    let target_q = sh_quote(&target_str);
317    writeln!(script, "if [ \"$_dodot_prof\" = \"1\" ]; then").unwrap();
318    writeln!(
319        script,
320        "  _dodot_t0=$EPOCHREALTIME; export PATH=\"{target_str}:$PATH\"; _dodot_t1=$EPOCHREALTIME"
321    )
322    .unwrap();
323    writeln!(
324        script,
325        "  printf 'path\\t{pack}\\tpath\\t%s\\t%s\\t%s\\t0\\n' {target_q} \"$_dodot_t0\" \"$_dodot_t1\" >> \"$_dodot_prof_file\" 2>/dev/null"
326    )
327    .unwrap();
328    writeln!(script, "else").unwrap();
329    writeln!(script, "  export PATH=\"{target_str}:$PATH\"").unwrap();
330    writeln!(script, "fi").unwrap();
331}
332
333/// One inline-timed `[ -f X ] && . X` row, capturing the source's
334/// exit status and stderr. Same overhead profile as the PATH variant
335/// when the sourced file is silent; one extra `[ -s ]` test plus an
336/// append when stderr is non-empty.
337///
338/// Both branches (profiling-active and unprofiled fallback) emit the
339/// loud-failure message on a non-zero source exit, so users see the
340/// dodot breadcrumb whether or not their shell supports the timing
341/// path.
342fn emit_timed_source(script: &mut String, pack: &str, target: &Path) {
343    let target_str = target.display().to_string();
344    let target_q = sh_quote(&target_str);
345    writeln!(script, "if [ \"$_dodot_prof\" = \"1\" ]; then").unwrap();
346    // `_dodot_rc` is initialised to 0 *before* the source attempt so a
347    // missing file (the `[ -f … ]` test failing) does not get reported
348    // as "exited 1". The compound `&& { … }` only sets `_dodot_rc` from
349    // the actual `.` invocation; otherwise it stays 0. Stderr from the
350    // source is redirected to `_dodot_err_tmp`; if non-empty, we
351    // re-emit it to the user's stderr (preserving the shell's own
352    // error display) and append it to the per-shell errors log.
353    writeln!(
354        script,
355        "  _dodot_rc=0; : > \"$_dodot_err_tmp\" 2>/dev/null; _dodot_t0=$EPOCHREALTIME; [ -f \"{target_str}\" ] && {{ . \"{target_str}\" 2>\"$_dodot_err_tmp\"; _dodot_rc=$?; }}; _dodot_t1=$EPOCHREALTIME"
356    )
357    .unwrap();
358    writeln!(
359        script,
360        "  printf 'source\\t{pack}\\tshell\\t%s\\t%s\\t%s\\t%s\\n' {target_q} \"$_dodot_t0\" \"$_dodot_t1\" \"$_dodot_rc\" >> \"$_dodot_prof_file\" 2>/dev/null"
361    )
362    .unwrap();
363    // Stderr-handling block. Skipped entirely when the sourced file was
364    // silent (the common case). When non-empty, we print to the user's
365    // stderr and append a record to the errors log. The errors log is
366    // seeded with its `v1` header on first use — keeping creation lazy
367    // means a clean shell startup leaves no orphan `*.errors.log` on
368    // disk. The trailing `\n` after each record guarantees the next
369    // record's `@@` header starts on its own line even if the captured
370    // stderr didn't end with a newline.
371    writeln!(script, "  if [ -s \"$_dodot_err_tmp\" ]; then").unwrap();
372    writeln!(script, "    cat \"$_dodot_err_tmp\" >&2").unwrap();
373    writeln!(
374        script,
375        "    [ -f \"$_dodot_err_file\" ] || printf '# dodot shell-init errors v1\\n' > \"$_dodot_err_file\" 2>/dev/null"
376    )
377    .unwrap();
378    writeln!(script, "    {{").unwrap();
379    writeln!(
380        script,
381        "      printf '@@\\t%s\\t%s\\n' {target_q} \"$_dodot_rc\""
382    )
383    .unwrap();
384    writeln!(script, "      cat \"$_dodot_err_tmp\"").unwrap();
385    writeln!(script, "      printf '\\n'").unwrap();
386    writeln!(script, "    }} >> \"$_dodot_err_file\" 2>/dev/null").unwrap();
387    writeln!(script, "  elif [ \"$_dodot_rc\" -ne 0 ]; then").unwrap();
388    // Non-zero exit with empty stderr — still emit the loud breadcrumb
389    // so the user knows dodot saw a failure (matches prior behaviour).
390    writeln!(
391        script,
392        "    echo \"dodot: shell source exited $_dodot_rc: {target_str}\" >&2"
393    )
394    .unwrap();
395    writeln!(script, "  fi").unwrap();
396    writeln!(script, "else").unwrap();
397    writeln!(
398        script,
399        "  [ -f \"{target_str}\" ] && {{ . \"{target_str}\" || echo \"dodot: shell source exited $?: {target_str}\" >&2; }}"
400    )
401    .unwrap();
402    writeln!(script, "fi").unwrap();
403}
404
405/// Closes out the report (writes the `# end_t` marker) and clears
406/// every `_dodot_*` shell variable so we don't leak state into the
407/// user's interactive shell.
408fn emit_profiling_epilogue(script: &mut String) {
409    writeln!(script, "# ── dodot shell-init profiling epilogue ──").unwrap();
410    writeln!(script, "if [ \"$_dodot_prof\" = \"1\" ]; then").unwrap();
411    writeln!(
412        script,
413        "  printf '# end_t\\t%s\\n' \"$EPOCHREALTIME\" >> \"$_dodot_prof_file\" 2>/dev/null"
414    )
415    .unwrap();
416    // Remove the per-shell stderr scratch file. It's reused across
417    // sources within one shell startup; here at exit we tidy up.
418    writeln!(
419        script,
420        "  [ -n \"${{_dodot_err_tmp:-}}\" ] && rm -f \"$_dodot_err_tmp\" 2>/dev/null"
421    )
422    .unwrap();
423    writeln!(script, "fi").unwrap();
424    writeln!(
425        script,
426        "unset _dodot_prof _dodot_prof_dir _dodot_prof_file _dodot_err_file _dodot_err_tmp _dodot_prof_t0 _dodot_t0 _dodot_t1 _dodot_rc 2>/dev/null"
427    )
428    .unwrap();
429}
430
431/// Single-quote a string for safe use in POSIX shell. Embedded single
432/// quotes are escaped via the `'\''` idiom.
433fn sh_quote(s: &str) -> String {
434    let mut out = String::with_capacity(s.len() + 2);
435    out.push('\'');
436    for c in s.chars() {
437        if c == '\'' {
438            out.push_str("'\\''");
439        } else {
440            out.push(c);
441        }
442    }
443    out.push('\'');
444    out
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use crate::datastore::{CommandOutput, CommandRunner, DataStore, FilesystemDataStore};
451    use crate::testing::TempEnvironment;
452    use std::sync::Arc;
453
454    struct NoopRunner;
455    impl CommandRunner for NoopRunner {
456        fn run(&self, _: &str, _: &[String]) -> Result<CommandOutput> {
457            Ok(CommandOutput {
458                exit_code: 0,
459                stdout: String::new(),
460                stderr: String::new(),
461            })
462        }
463    }
464
465    fn make_datastore(env: &TempEnvironment) -> FilesystemDataStore {
466        FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), Arc::new(NoopRunner))
467    }
468
469    #[test]
470    fn empty_datastore_produces_helpful_script() {
471        let env = TempEnvironment::builder().build();
472        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
473
474        assert!(script.starts_with("#!/bin/sh"));
475        assert!(script.contains("Generated by dodot"));
476        assert!(script.contains("No shell scripts or PATH additions"));
477        assert!(script.contains("dodot up"));
478        assert!(script.contains("dodot status"));
479        // No source or PATH lines
480        assert!(!script.contains("export PATH"));
481        assert!(!script.contains(". \""));
482    }
483
484    #[test]
485    fn shell_handler_state_produces_source_lines() {
486        let env = TempEnvironment::builder()
487            .pack("vim")
488            .file("aliases.sh", "alias vi=vim")
489            .done()
490            .build();
491
492        let ds = make_datastore(&env);
493        let source = env.dotfiles_root.join("vim/aliases.sh");
494        ds.create_data_link("vim", "shell", &source).unwrap();
495
496        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
497
498        assert!(script.contains("# Shell scripts"), "script:\n{script}");
499        assert!(script.contains("# [vim]"), "script:\n{script}");
500        // Loud-failure wrapper: existence-guarded source, with a
501        // dodot-attributed echo on non-zero exit.
502        assert!(
503            script.contains(&format!(
504                "[ -f \"{p}\" ] && {{ . \"{p}\" || echo \"dodot: shell source exited $?: {p}\" >&2; }}",
505                p = source.display()
506            )),
507            "script:\n{script}"
508        );
509    }
510
511    #[test]
512    fn path_handler_state_produces_path_lines() {
513        let env = TempEnvironment::builder()
514            .pack("vim")
515            .file("bin/myscript", "#!/bin/sh")
516            .done()
517            .build();
518
519        let ds = make_datastore(&env);
520        let source = env.dotfiles_root.join("vim/bin");
521        ds.create_data_link("vim", "path", &source).unwrap();
522
523        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
524
525        assert!(script.contains("# PATH additions"), "script:\n{script}");
526        assert!(script.contains("# [vim]"), "script:\n{script}");
527        assert!(
528            script.contains(&format!("export PATH=\"{}:$PATH\"", source.display())),
529            "script:\n{script}"
530        );
531    }
532
533    #[test]
534    fn multiple_packs_combined() {
535        let env = TempEnvironment::builder()
536            .pack("git")
537            .file("aliases.sh", "alias gs='git status'")
538            .done()
539            .pack("vim")
540            .file("aliases.sh", "alias vi=vim")
541            .file("bin/vimrun", "#!/bin/sh")
542            .done()
543            .build();
544
545        let ds = make_datastore(&env);
546
547        // Shell scripts
548        ds.create_data_link("git", "shell", &env.dotfiles_root.join("git/aliases.sh"))
549            .unwrap();
550        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
551            .unwrap();
552
553        // Path
554        ds.create_data_link("vim", "path", &env.dotfiles_root.join("vim/bin"))
555            .unwrap();
556
557        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
558
559        // Should have both shell sources
560        assert!(script.contains("# [git]"), "script:\n{script}");
561        assert!(script.contains("# [vim]"), "script:\n{script}");
562        // Should have PATH addition
563        assert!(script.contains("export PATH="), "script:\n{script}");
564        // Should have source lines
565        let source_count = script.matches(". \"").count();
566        assert_eq!(
567            source_count, 2,
568            "expected 2 source lines, script:\n{script}"
569        );
570    }
571
572    #[test]
573    fn write_init_script_creates_executable_file() {
574        let env = TempEnvironment::builder()
575            .pack("vim")
576            .file("aliases.sh", "alias vi=vim")
577            .done()
578            .build();
579
580        let ds = make_datastore(&env);
581        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
582            .unwrap();
583
584        let script_path = write_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
585
586        assert_eq!(script_path, env.paths.init_script_path());
587        env.assert_exists(&script_path);
588
589        let content = env.fs.read_to_string(&script_path).unwrap();
590        assert!(content.starts_with("#!/bin/sh"));
591        assert!(content.contains("aliases.sh"));
592
593        // Check executable permission
594        let meta = std::fs::metadata(&script_path).unwrap();
595        use std::os::unix::fs::PermissionsExt;
596        assert_eq!(meta.permissions().mode() & 0o111, 0o111);
597    }
598
599    #[test]
600    fn script_regenerated_reflects_current_state() {
601        let env = TempEnvironment::builder()
602            .pack("vim")
603            .file("aliases.sh", "alias vi=vim")
604            .done()
605            .build();
606
607        let ds = make_datastore(&env);
608
609        // Initially empty
610        let script1 = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
611        assert!(!script1.contains("aliases.sh"));
612
613        // Deploy shell script
614        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
615            .unwrap();
616
617        let script2 = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
618        assert!(script2.contains("aliases.sh"));
619
620        // Remove state
621        ds.remove_state("vim", "shell").unwrap();
622
623        let script3 = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
624        assert!(!script3.contains("aliases.sh"));
625    }
626
627    #[test]
628    fn ignores_non_symlink_files_in_handler_dirs() {
629        let env = TempEnvironment::builder().build();
630
631        // Create a non-symlink file in the shell handler dir
632        let shell_dir = env.paths.handler_data_dir("vim", "shell");
633        env.fs.mkdir_all(&shell_dir).unwrap();
634        env.fs
635            .write_file(&shell_dir.join("not-a-symlink"), b"noise")
636            .unwrap();
637
638        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
639        assert!(!script.contains("not-a-symlink"));
640    }
641
642    #[test]
643    fn path_additions_come_before_shell_sources() {
644        let env = TempEnvironment::builder()
645            .pack("vim")
646            .file("aliases.sh", "alias vi=vim")
647            .file("bin/myscript", "#!/bin/sh")
648            .done()
649            .build();
650
651        let ds = make_datastore(&env);
652        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
653            .unwrap();
654        ds.create_data_link("vim", "path", &env.dotfiles_root.join("vim/bin"))
655            .unwrap();
656
657        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
658
659        let path_pos = script.find("# PATH additions").unwrap();
660        let shell_pos = script.find("# Shell scripts").unwrap();
661        assert!(
662            path_pos < shell_pos,
663            "PATH additions should come before shell sources"
664        );
665    }
666
667    // ── Phase 2: profiling wrapper ──────────────────────────────────
668
669    #[test]
670    fn profiling_disabled_matches_phase1_byte_for_byte() {
671        // The contract: when profiling is off, the script must be the
672        // exact same bytes a Phase-1 generator would have produced. This
673        // protects users who don't want any change to their init script.
674        let env = TempEnvironment::builder()
675            .pack("vim")
676            .file("aliases.sh", "alias vi=vim")
677            .done()
678            .build();
679
680        let ds = make_datastore(&env);
681        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
682            .unwrap();
683
684        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
685        assert!(!script.contains("_dodot_prof"));
686        assert!(!script.contains("EPOCHREALTIME"));
687        assert!(!script.contains("dodot shell-init profile"));
688    }
689
690    #[test]
691    fn profiling_enabled_emits_runtime_gated_preamble() {
692        let env = TempEnvironment::builder()
693            .pack("vim")
694            .file("aliases.sh", "alias vi=vim")
695            .done()
696            .build();
697
698        let ds = make_datastore(&env);
699        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
700            .unwrap();
701
702        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
703
704        // Preamble feature-detects bash 5+ / zsh + EPOCHREALTIME
705        assert!(script.contains("BASH_VERSION"));
706        assert!(script.contains("ZSH_VERSION"));
707        assert!(script.contains("EPOCHREALTIME"));
708        // The profile dir comes from Pather, so the script should embed it.
709        assert!(script.contains(env.paths.probes_shell_init_dir().to_str().unwrap()));
710        // File naming includes pid + RANDOM for collision-resistance.
711        assert!(script.contains("$$"));
712        assert!(script.contains("RANDOM"));
713        // Header lines we always emit.
714        assert!(script.contains("# dodot shell-init profile v1"));
715        assert!(script.contains("columns\\tphase\\tpack\\thandler\\ttarget"));
716    }
717
718    #[test]
719    fn profiling_enabled_wraps_each_source_with_else_path() {
720        let env = TempEnvironment::builder()
721            .pack("vim")
722            .file("aliases.sh", "")
723            .file("bin/tool", "#!/bin/sh")
724            .done()
725            .build();
726
727        let ds = make_datastore(&env);
728        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
729            .unwrap();
730        ds.create_data_link("vim", "path", &env.dotfiles_root.join("vim/bin"))
731            .unwrap();
732
733        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
734
735        // Each entry has an if/else so unprofiled shells still source / set PATH.
736        // (One else per entry; the epilogue uses an if-only form, so counting
737        // `else` keeps us focused on the entry wrappers.)
738        let else_count = script.matches("else").count();
739        assert_eq!(
740            else_count, 2,
741            "expected one else-branch per entry; script:\n{script}"
742        );
743
744        // Source row carries the captured exit status; PATH row hard-codes 0.
745        assert!(script.contains("printf 'source\\tvim\\tshell\\t"));
746        assert!(script.contains("printf 'path\\tvim\\tpath\\t"));
747        assert!(script.contains("\"$_dodot_rc\""));
748    }
749
750    #[test]
751    fn profiling_captures_source_stderr_into_errors_log() {
752        // The wrapper must redirect each source's stderr to the per-shell
753        // scratch file and append a versioned record (`@@\ttarget\texit`)
754        // to the errors.log sibling whenever stderr is non-empty.
755        let env = TempEnvironment::builder()
756            .pack("vim")
757            .file("aliases.sh", "")
758            .done()
759            .build();
760        let ds = make_datastore(&env);
761        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
762            .unwrap();
763
764        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
765
766        // Errors-log sibling is derived from the profile-file path.
767        assert!(
768            script.contains("_dodot_err_file=\"${_dodot_prof_file%.tsv}.errors.log\""),
769            "errors-log path must be a sibling of the profile TSV:\n{script}"
770        );
771        // Versioned header is seeded lazily — only on first stderr from
772        // a sourced file, guarded by `[ -f "$_dodot_err_file" ]` so an
773        // all-silent shell startup leaves no sidecar on disk.
774        assert!(
775            script.contains("[ -f \"$_dodot_err_file\" ] || printf '# dodot shell-init errors v1"),
776            "errors-log header must be seeded lazily on first stderr:\n{script}"
777        );
778        // Stderr is redirected to the per-shell scratch file during the source.
779        assert!(
780            script.contains("2>\"$_dodot_err_tmp\""),
781            "source must redirect stderr to scratch file:\n{script}"
782        );
783        // Truncation before each source so a previous source's stderr
784        // doesn't leak into the next record.
785        assert!(
786            script.contains(": > \"$_dodot_err_tmp\""),
787            "scratch file must be truncated before each source:\n{script}"
788        );
789        // The record header uses the @@ sentinel + tab-separated target/exit.
790        assert!(
791            script.contains("printf '@@\\t%s\\t%s\\n'"),
792            "errors-log records must use @@ header format:\n{script}"
793        );
794    }
795
796    #[test]
797    fn profiling_epilogue_writes_end_marker_and_unsets_state() {
798        let env = TempEnvironment::builder()
799            .pack("vim")
800            .file("aliases.sh", "")
801            .done()
802            .build();
803
804        let ds = make_datastore(&env);
805        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
806            .unwrap();
807
808        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
809        // End-of-run timestamp.
810        assert!(script.contains("# end_t"));
811        // We scrub our state to avoid leaking into the user's shell.
812        assert!(script.contains("unset _dodot_prof"));
813        assert!(script.contains("_dodot_prof_file"));
814    }
815
816    #[test]
817    fn profiling_enabled_with_empty_datastore_skips_preamble() {
818        // No deployed entries → empty notice only, no profiling boilerplate.
819        let env = TempEnvironment::builder().build();
820        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
821        assert!(script.contains("No shell scripts or PATH additions"));
822        assert!(!script.contains("_dodot_prof"));
823    }
824
825    #[test]
826    fn profiled_source_initialises_rc_so_missing_file_isnt_reported_as_failure() {
827        // Regression: previously the profiled branch was
828        //
829        //   _dodot_rc=$?  # after `[ -f X ] && . X`
830        //
831        // which captured the file-test exit (1) when the file was
832        // absent — falsely classifying "file missing" as "source
833        // exited 1". The fix initialises _dodot_rc to 0 before the
834        // attempt, and only updates it inside the `&& { . X; rc=$?; }`
835        // group when the source actually ran.
836        let env = TempEnvironment::builder()
837            .pack("vim")
838            .file("aliases.sh", "alias vi=vim")
839            .done()
840            .build();
841        let ds = make_datastore(&env);
842        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
843            .unwrap();
844
845        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
846        // Pre-attempt initialisation present.
847        assert!(
848            script.contains("_dodot_rc=0;"),
849            "profiled branch must seed _dodot_rc=0 before the source attempt:\n{script}"
850        );
851        // Source is wrapped so the rc only updates when `.` ran.
852        assert!(
853            script.contains("&& { . "),
854            "profiled branch must guard the rc update inside `&& {{ … }}`:\n{script}"
855        );
856    }
857
858    #[test]
859    fn loud_failure_wrapper_present_in_both_modes() {
860        // A non-zero exit from a sourced file must surface as a
861        // dodot-attributed message on stderr, regardless of whether
862        // profiling is on. This is the user-facing breadcrumb that
863        // says "the dodot-managed source exited non-zero" alongside
864        // the shell's own line-numbered error.
865        let env = TempEnvironment::builder()
866            .pack("vim")
867            .file("aliases.sh", "alias vi=vim")
868            .done()
869            .build();
870
871        let ds = make_datastore(&env);
872        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
873            .unwrap();
874
875        // Profiling off: inline OR-echo form.
876        let plain = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
877        assert!(
878            plain.contains("dodot: shell source exited $?:"),
879            "plain script missing loud-failure echo:\n{plain}"
880        );
881
882        // Profiling on: timed branch echoes from the elif-empty-stderr
883        // arm (silent failure case); the with-stderr arm relies on
884        // re-emitting the captured stderr to the user's TTY. Unprofiled
885        // fallback uses the OR-echo form like the plain path.
886        let timed = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
887        assert!(
888            timed.contains("echo \"dodot: shell source exited $_dodot_rc:"),
889            "timed script missing silent-failure echo:\n{timed}"
890        );
891        assert!(
892            timed.contains("dodot: shell source exited $?:"),
893            "timed script missing fallback-branch echo:\n{timed}"
894        );
895        // Captured stderr is re-emitted to the user's TTY before being
896        // appended to the errors log, so they still see it live.
897        assert!(
898            timed.contains("cat \"$_dodot_err_tmp\" >&2"),
899            "timed script must echo captured stderr to user's TTY:\n{timed}"
900        );
901    }
902
903    #[test]
904    fn shell_quoting_handles_paths_with_single_quotes() {
905        // A path with a single quote in it must round-trip safely
906        // through the printf args. Embedded `'` becomes `'\''`.
907        assert_eq!(sh_quote("plain"), "'plain'");
908        assert_eq!(sh_quote("it's"), "'it'\\''s'");
909        assert_eq!(sh_quote(""), "''");
910    }
911}