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    writeln!(
256        script,
257        "    if mkdir -p \"$_dodot_prof_dir\" 2>/dev/null; then"
258    )
259    .unwrap();
260    writeln!(script, "      _dodot_prof_t0=$EPOCHREALTIME").unwrap();
261    writeln!(script, "      {{").unwrap();
262    writeln!(script, "        printf '# dodot shell-init profile v1\\n'").unwrap();
263    writeln!(
264        script,
265        "        printf '# shell\\t%s\\n' \"${{BASH_VERSION:+bash $BASH_VERSION}}${{ZSH_VERSION:+zsh $ZSH_VERSION}}\""
266    )
267    .unwrap();
268    writeln!(
269        script,
270        "        printf '# start_t\\t%s\\n' \"$_dodot_prof_t0\""
271    )
272    .unwrap();
273    writeln!(
274        script,
275        "        printf '# init_script\\t%s\\n' {init_script}"
276    )
277    .unwrap();
278    writeln!(
279        script,
280        "        printf '# columns\\tphase\\tpack\\thandler\\ttarget\\tstart_t\\tend_t\\texit_status\\n'"
281    )
282    .unwrap();
283    writeln!(
284        script,
285        "      }} > \"$_dodot_prof_file\" 2>/dev/null && _dodot_prof=1"
286    )
287    .unwrap();
288    writeln!(script, "    fi").unwrap();
289    writeln!(script, "  fi").unwrap();
290    writeln!(script, "fi").unwrap();
291    writeln!(script).unwrap();
292}
293
294/// One inline-timed `export PATH=…` row. The branch is one comparison
295/// at runtime — negligible on shells where the wrapper is inert.
296fn emit_timed_path(script: &mut String, pack: &str, target: &Path) {
297    let target_str = target.display().to_string();
298    let target_q = sh_quote(&target_str);
299    writeln!(script, "if [ \"$_dodot_prof\" = \"1\" ]; then").unwrap();
300    writeln!(
301        script,
302        "  _dodot_t0=$EPOCHREALTIME; export PATH=\"{target_str}:$PATH\"; _dodot_t1=$EPOCHREALTIME"
303    )
304    .unwrap();
305    writeln!(
306        script,
307        "  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"
308    )
309    .unwrap();
310    writeln!(script, "else").unwrap();
311    writeln!(script, "  export PATH=\"{target_str}:$PATH\"").unwrap();
312    writeln!(script, "fi").unwrap();
313}
314
315/// One inline-timed `[ -f X ] && . X` row, capturing the source's
316/// exit status. Same overhead profile as the PATH variant.
317///
318/// Both branches (profiling-active and unprofiled fallback) emit the
319/// loud-failure message on a non-zero source exit, so users see the
320/// dodot breadcrumb whether or not their shell supports the timing
321/// path.
322fn emit_timed_source(script: &mut String, pack: &str, target: &Path) {
323    let target_str = target.display().to_string();
324    let target_q = sh_quote(&target_str);
325    writeln!(script, "if [ \"$_dodot_prof\" = \"1\" ]; then").unwrap();
326    // `_dodot_rc` is initialised to 0 *before* the source attempt so a
327    // missing file (the `[ -f … ]` test failing) does not get reported
328    // as "exited 1". The compound `&& { … }` only sets `_dodot_rc` from
329    // the actual `.` invocation; otherwise it stays 0.
330    writeln!(
331        script,
332        "  _dodot_rc=0; _dodot_t0=$EPOCHREALTIME; [ -f \"{target_str}\" ] && {{ . \"{target_str}\"; _dodot_rc=$?; }}; _dodot_t1=$EPOCHREALTIME"
333    )
334    .unwrap();
335    writeln!(
336        script,
337        "  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"
338    )
339    .unwrap();
340    writeln!(
341        script,
342        "  [ \"$_dodot_rc\" -ne 0 ] && echo \"dodot: shell source exited $_dodot_rc: {target_str}\" >&2"
343    )
344    .unwrap();
345    writeln!(script, "else").unwrap();
346    writeln!(
347        script,
348        "  [ -f \"{target_str}\" ] && {{ . \"{target_str}\" || echo \"dodot: shell source exited $?: {target_str}\" >&2; }}"
349    )
350    .unwrap();
351    writeln!(script, "fi").unwrap();
352}
353
354/// Closes out the report (writes the `# end_t` marker) and clears
355/// every `_dodot_*` shell variable so we don't leak state into the
356/// user's interactive shell.
357fn emit_profiling_epilogue(script: &mut String) {
358    writeln!(script, "# ── dodot shell-init profiling epilogue ──").unwrap();
359    writeln!(script, "if [ \"$_dodot_prof\" = \"1\" ]; then").unwrap();
360    writeln!(
361        script,
362        "  printf '# end_t\\t%s\\n' \"$EPOCHREALTIME\" >> \"$_dodot_prof_file\" 2>/dev/null"
363    )
364    .unwrap();
365    writeln!(script, "fi").unwrap();
366    writeln!(
367        script,
368        "unset _dodot_prof _dodot_prof_dir _dodot_prof_file _dodot_prof_t0 _dodot_t0 _dodot_t1 _dodot_rc 2>/dev/null"
369    )
370    .unwrap();
371}
372
373/// Single-quote a string for safe use in POSIX shell. Embedded single
374/// quotes are escaped via the `'\''` idiom.
375fn sh_quote(s: &str) -> String {
376    let mut out = String::with_capacity(s.len() + 2);
377    out.push('\'');
378    for c in s.chars() {
379        if c == '\'' {
380            out.push_str("'\\''");
381        } else {
382            out.push(c);
383        }
384    }
385    out.push('\'');
386    out
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use crate::datastore::{CommandOutput, CommandRunner, DataStore, FilesystemDataStore};
393    use crate::testing::TempEnvironment;
394    use std::sync::Arc;
395
396    struct NoopRunner;
397    impl CommandRunner for NoopRunner {
398        fn run(&self, _: &str, _: &[String]) -> Result<CommandOutput> {
399            Ok(CommandOutput {
400                exit_code: 0,
401                stdout: String::new(),
402                stderr: String::new(),
403            })
404        }
405    }
406
407    fn make_datastore(env: &TempEnvironment) -> FilesystemDataStore {
408        FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), Arc::new(NoopRunner))
409    }
410
411    #[test]
412    fn empty_datastore_produces_helpful_script() {
413        let env = TempEnvironment::builder().build();
414        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
415
416        assert!(script.starts_with("#!/bin/sh"));
417        assert!(script.contains("Generated by dodot"));
418        assert!(script.contains("No shell scripts or PATH additions"));
419        assert!(script.contains("dodot up"));
420        assert!(script.contains("dodot status"));
421        // No source or PATH lines
422        assert!(!script.contains("export PATH"));
423        assert!(!script.contains(". \""));
424    }
425
426    #[test]
427    fn shell_handler_state_produces_source_lines() {
428        let env = TempEnvironment::builder()
429            .pack("vim")
430            .file("aliases.sh", "alias vi=vim")
431            .done()
432            .build();
433
434        let ds = make_datastore(&env);
435        let source = env.dotfiles_root.join("vim/aliases.sh");
436        ds.create_data_link("vim", "shell", &source).unwrap();
437
438        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
439
440        assert!(script.contains("# Shell scripts"), "script:\n{script}");
441        assert!(script.contains("# [vim]"), "script:\n{script}");
442        // Loud-failure wrapper: existence-guarded source, with a
443        // dodot-attributed echo on non-zero exit.
444        assert!(
445            script.contains(&format!(
446                "[ -f \"{p}\" ] && {{ . \"{p}\" || echo \"dodot: shell source exited $?: {p}\" >&2; }}",
447                p = source.display()
448            )),
449            "script:\n{script}"
450        );
451    }
452
453    #[test]
454    fn path_handler_state_produces_path_lines() {
455        let env = TempEnvironment::builder()
456            .pack("vim")
457            .file("bin/myscript", "#!/bin/sh")
458            .done()
459            .build();
460
461        let ds = make_datastore(&env);
462        let source = env.dotfiles_root.join("vim/bin");
463        ds.create_data_link("vim", "path", &source).unwrap();
464
465        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
466
467        assert!(script.contains("# PATH additions"), "script:\n{script}");
468        assert!(script.contains("# [vim]"), "script:\n{script}");
469        assert!(
470            script.contains(&format!("export PATH=\"{}:$PATH\"", source.display())),
471            "script:\n{script}"
472        );
473    }
474
475    #[test]
476    fn multiple_packs_combined() {
477        let env = TempEnvironment::builder()
478            .pack("git")
479            .file("aliases.sh", "alias gs='git status'")
480            .done()
481            .pack("vim")
482            .file("aliases.sh", "alias vi=vim")
483            .file("bin/vimrun", "#!/bin/sh")
484            .done()
485            .build();
486
487        let ds = make_datastore(&env);
488
489        // Shell scripts
490        ds.create_data_link("git", "shell", &env.dotfiles_root.join("git/aliases.sh"))
491            .unwrap();
492        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
493            .unwrap();
494
495        // Path
496        ds.create_data_link("vim", "path", &env.dotfiles_root.join("vim/bin"))
497            .unwrap();
498
499        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
500
501        // Should have both shell sources
502        assert!(script.contains("# [git]"), "script:\n{script}");
503        assert!(script.contains("# [vim]"), "script:\n{script}");
504        // Should have PATH addition
505        assert!(script.contains("export PATH="), "script:\n{script}");
506        // Should have source lines
507        let source_count = script.matches(". \"").count();
508        assert_eq!(
509            source_count, 2,
510            "expected 2 source lines, script:\n{script}"
511        );
512    }
513
514    #[test]
515    fn write_init_script_creates_executable_file() {
516        let env = TempEnvironment::builder()
517            .pack("vim")
518            .file("aliases.sh", "alias vi=vim")
519            .done()
520            .build();
521
522        let ds = make_datastore(&env);
523        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
524            .unwrap();
525
526        let script_path = write_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
527
528        assert_eq!(script_path, env.paths.init_script_path());
529        env.assert_exists(&script_path);
530
531        let content = env.fs.read_to_string(&script_path).unwrap();
532        assert!(content.starts_with("#!/bin/sh"));
533        assert!(content.contains("aliases.sh"));
534
535        // Check executable permission
536        let meta = std::fs::metadata(&script_path).unwrap();
537        use std::os::unix::fs::PermissionsExt;
538        assert_eq!(meta.permissions().mode() & 0o111, 0o111);
539    }
540
541    #[test]
542    fn script_regenerated_reflects_current_state() {
543        let env = TempEnvironment::builder()
544            .pack("vim")
545            .file("aliases.sh", "alias vi=vim")
546            .done()
547            .build();
548
549        let ds = make_datastore(&env);
550
551        // Initially empty
552        let script1 = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
553        assert!(!script1.contains("aliases.sh"));
554
555        // Deploy shell script
556        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
557            .unwrap();
558
559        let script2 = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
560        assert!(script2.contains("aliases.sh"));
561
562        // Remove state
563        ds.remove_state("vim", "shell").unwrap();
564
565        let script3 = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
566        assert!(!script3.contains("aliases.sh"));
567    }
568
569    #[test]
570    fn ignores_non_symlink_files_in_handler_dirs() {
571        let env = TempEnvironment::builder().build();
572
573        // Create a non-symlink file in the shell handler dir
574        let shell_dir = env.paths.handler_data_dir("vim", "shell");
575        env.fs.mkdir_all(&shell_dir).unwrap();
576        env.fs
577            .write_file(&shell_dir.join("not-a-symlink"), b"noise")
578            .unwrap();
579
580        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
581        assert!(!script.contains("not-a-symlink"));
582    }
583
584    #[test]
585    fn path_additions_come_before_shell_sources() {
586        let env = TempEnvironment::builder()
587            .pack("vim")
588            .file("aliases.sh", "alias vi=vim")
589            .file("bin/myscript", "#!/bin/sh")
590            .done()
591            .build();
592
593        let ds = make_datastore(&env);
594        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
595            .unwrap();
596        ds.create_data_link("vim", "path", &env.dotfiles_root.join("vim/bin"))
597            .unwrap();
598
599        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
600
601        let path_pos = script.find("# PATH additions").unwrap();
602        let shell_pos = script.find("# Shell scripts").unwrap();
603        assert!(
604            path_pos < shell_pos,
605            "PATH additions should come before shell sources"
606        );
607    }
608
609    // ── Phase 2: profiling wrapper ──────────────────────────────────
610
611    #[test]
612    fn profiling_disabled_matches_phase1_byte_for_byte() {
613        // The contract: when profiling is off, the script must be the
614        // exact same bytes a Phase-1 generator would have produced. This
615        // protects users who don't want any change to their init script.
616        let env = TempEnvironment::builder()
617            .pack("vim")
618            .file("aliases.sh", "alias vi=vim")
619            .done()
620            .build();
621
622        let ds = make_datastore(&env);
623        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
624            .unwrap();
625
626        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
627        assert!(!script.contains("_dodot_prof"));
628        assert!(!script.contains("EPOCHREALTIME"));
629        assert!(!script.contains("dodot shell-init profile"));
630    }
631
632    #[test]
633    fn profiling_enabled_emits_runtime_gated_preamble() {
634        let env = TempEnvironment::builder()
635            .pack("vim")
636            .file("aliases.sh", "alias vi=vim")
637            .done()
638            .build();
639
640        let ds = make_datastore(&env);
641        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
642            .unwrap();
643
644        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
645
646        // Preamble feature-detects bash 5+ / zsh + EPOCHREALTIME
647        assert!(script.contains("BASH_VERSION"));
648        assert!(script.contains("ZSH_VERSION"));
649        assert!(script.contains("EPOCHREALTIME"));
650        // The profile dir comes from Pather, so the script should embed it.
651        assert!(script.contains(env.paths.probes_shell_init_dir().to_str().unwrap()));
652        // File naming includes pid + RANDOM for collision-resistance.
653        assert!(script.contains("$$"));
654        assert!(script.contains("RANDOM"));
655        // Header lines we always emit.
656        assert!(script.contains("# dodot shell-init profile v1"));
657        assert!(script.contains("columns\\tphase\\tpack\\thandler\\ttarget"));
658    }
659
660    #[test]
661    fn profiling_enabled_wraps_each_source_with_else_path() {
662        let env = TempEnvironment::builder()
663            .pack("vim")
664            .file("aliases.sh", "")
665            .file("bin/tool", "#!/bin/sh")
666            .done()
667            .build();
668
669        let ds = make_datastore(&env);
670        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
671            .unwrap();
672        ds.create_data_link("vim", "path", &env.dotfiles_root.join("vim/bin"))
673            .unwrap();
674
675        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
676
677        // Each entry has an if/else so unprofiled shells still source / set PATH.
678        // (One else per entry; the epilogue uses an if-only form, so counting
679        // `else` keeps us focused on the entry wrappers.)
680        let else_count = script.matches("else").count();
681        assert_eq!(
682            else_count, 2,
683            "expected one else-branch per entry; script:\n{script}"
684        );
685
686        // Source row carries the captured exit status; PATH row hard-codes 0.
687        assert!(script.contains("printf 'source\\tvim\\tshell\\t"));
688        assert!(script.contains("printf 'path\\tvim\\tpath\\t"));
689        assert!(script.contains("\"$_dodot_rc\""));
690    }
691
692    #[test]
693    fn profiling_epilogue_writes_end_marker_and_unsets_state() {
694        let env = TempEnvironment::builder()
695            .pack("vim")
696            .file("aliases.sh", "")
697            .done()
698            .build();
699
700        let ds = make_datastore(&env);
701        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
702            .unwrap();
703
704        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
705        // End-of-run timestamp.
706        assert!(script.contains("# end_t"));
707        // We scrub our state to avoid leaking into the user's shell.
708        assert!(script.contains("unset _dodot_prof"));
709        assert!(script.contains("_dodot_prof_file"));
710    }
711
712    #[test]
713    fn profiling_enabled_with_empty_datastore_skips_preamble() {
714        // No deployed entries → empty notice only, no profiling boilerplate.
715        let env = TempEnvironment::builder().build();
716        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
717        assert!(script.contains("No shell scripts or PATH additions"));
718        assert!(!script.contains("_dodot_prof"));
719    }
720
721    #[test]
722    fn profiled_source_initialises_rc_so_missing_file_isnt_reported_as_failure() {
723        // Regression: previously the profiled branch was
724        //
725        //   _dodot_rc=$?  # after `[ -f X ] && . X`
726        //
727        // which captured the file-test exit (1) when the file was
728        // absent — falsely classifying "file missing" as "source
729        // exited 1". The fix initialises _dodot_rc to 0 before the
730        // attempt, and only updates it inside the `&& { . X; rc=$?; }`
731        // group when the source actually ran.
732        let env = TempEnvironment::builder()
733            .pack("vim")
734            .file("aliases.sh", "alias vi=vim")
735            .done()
736            .build();
737        let ds = make_datastore(&env);
738        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
739            .unwrap();
740
741        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
742        // Pre-attempt initialisation present.
743        assert!(
744            script.contains("_dodot_rc=0;"),
745            "profiled branch must seed _dodot_rc=0 before the source attempt:\n{script}"
746        );
747        // Source is wrapped so the rc only updates when `.` ran.
748        assert!(
749            script.contains("&& { . "),
750            "profiled branch must guard the rc update inside `&& {{ … }}`:\n{script}"
751        );
752    }
753
754    #[test]
755    fn loud_failure_wrapper_present_in_both_modes() {
756        // A non-zero exit from a sourced file must surface as a
757        // dodot-attributed message on stderr, regardless of whether
758        // profiling is on. This is the user-facing breadcrumb that
759        // says "the dodot-managed source exited non-zero" alongside
760        // the shell's own line-numbered error.
761        let env = TempEnvironment::builder()
762            .pack("vim")
763            .file("aliases.sh", "alias vi=vim")
764            .done()
765            .build();
766
767        let ds = make_datastore(&env);
768        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
769            .unwrap();
770
771        // Profiling off: inline OR-echo form.
772        let plain = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
773        assert!(
774            plain.contains("dodot: shell source exited $?:"),
775            "plain script missing loud-failure echo:\n{plain}"
776        );
777
778        // Profiling on: timed branch echoes after the printf when
779        // _dodot_rc != 0; unprofiled fallback uses the same OR-echo
780        // form as the plain path.
781        let timed = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
782        assert!(
783            timed.contains(
784                "[ \"$_dodot_rc\" -ne 0 ] && echo \"dodot: shell source exited $_dodot_rc:"
785            ),
786            "timed script missing profiled-branch echo:\n{timed}"
787        );
788        assert!(
789            timed.contains("dodot: shell source exited $?:"),
790            "timed script missing fallback-branch echo:\n{timed}"
791        );
792    }
793
794    #[test]
795    fn shell_quoting_handles_paths_with_single_quotes() {
796        // A path with a single quote in it must round-trip safely
797        // through the printf args. Embedded `'` becomes `'\''`.
798        assert_eq!(sh_quote("plain"), "'plain'");
799        assert_eq!(sh_quote("it's"), "'it'\\''s'");
800        assert_eq!(sh_quote(""), "''");
801    }
802}