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
47/// Append the "nothing to do" notice for an empty init script.
48fn append_empty_notice(script: &mut String) {
49    writeln!(script, "# No shell scripts or PATH additions to load.").unwrap();
50    writeln!(
51        script,
52        "# Run `dodot up` to deploy packs, or `dodot status` to see available packs."
53    )
54    .unwrap();
55}
56
57/// Generate the shell init script content from the current datastore state.
58///
59/// Scans the datastore for:
60/// - `packs/*/shell/*` — symlinks to shell scripts → `source` lines
61/// - `packs/*/path/*` — symlinks to directories → `PATH=` lines
62///
63/// When `profiling_enabled` is true and there is at least one entry to
64/// emit, the script also carries the per-line timing wrapper described
65/// in the module docs.
66pub fn generate_init_script(
67    fs: &dyn Fs,
68    paths: &dyn Pather,
69    profiling_enabled: bool,
70) -> Result<String> {
71    let mut script = String::new();
72
73    writeln!(script, "#!/bin/sh").unwrap();
74    writeln!(script, "# Generated by dodot — do not edit manually.").unwrap();
75    writeln!(script, "# Regenerated on every `dodot up` / `dodot down`.").unwrap();
76    writeln!(script).unwrap();
77
78    // Discover all packs with state
79    let packs_dir = paths.data_dir().join("packs");
80    if !fs.exists(&packs_dir) {
81        append_empty_notice(&mut script);
82        return Ok(script);
83    }
84
85    let pack_entries = fs.read_dir(&packs_dir)?;
86
87    // Collect shell sources and path additions separately so we can
88    // group them in the output for readability.
89    let mut shell_sources: Vec<(String, PathBuf)> = Vec::new(); // (pack, target)
90    let mut path_additions: Vec<(String, PathBuf)> = Vec::new(); // (pack, target)
91
92    for pack_entry in &pack_entries {
93        if !pack_entry.is_dir {
94            continue;
95        }
96        let pack_name = &pack_entry.name;
97
98        // Shell handler: source scripts
99        let shell_dir = paths.handler_data_dir(pack_name, "shell");
100        if fs.is_dir(&shell_dir) {
101            if let Ok(entries) = fs.read_dir(&shell_dir) {
102                for entry in entries {
103                    if !entry.is_symlink {
104                        continue;
105                    }
106                    // Follow the symlink to get the actual file path
107                    let target = fs.readlink(&entry.path)?;
108                    shell_sources.push((pack_name.clone(), target));
109                }
110            }
111        }
112
113        // Path handler: add to PATH
114        let path_dir = paths.handler_data_dir(pack_name, "path");
115        if fs.is_dir(&path_dir) {
116            if let Ok(entries) = fs.read_dir(&path_dir) {
117                for entry in entries {
118                    if !entry.is_symlink {
119                        continue;
120                    }
121                    let target = fs.readlink(&entry.path)?;
122                    path_additions.push((pack_name.clone(), target));
123                }
124            }
125        }
126    }
127
128    // If nothing is deployed, add an explanatory comment
129    if path_additions.is_empty() && shell_sources.is_empty() {
130        append_empty_notice(&mut script);
131        return Ok(script);
132    }
133
134    // Profiling preamble (only when enabled and there's at least one entry).
135    let profiling_active = profiling_enabled;
136    if profiling_active {
137        emit_profiling_preamble(
138            &mut script,
139            &paths.probes_shell_init_dir(),
140            &paths.init_script_path(),
141        );
142    }
143
144    // Emit PATH additions
145    if !path_additions.is_empty() {
146        writeln!(script, "# PATH additions").unwrap();
147        for (pack, target) in &path_additions {
148            writeln!(script, "# [{pack}]").unwrap();
149            if profiling_active {
150                emit_timed_path(&mut script, pack, target);
151            } else {
152                writeln!(script, "export PATH=\"{}:$PATH\"", target.display()).unwrap();
153            }
154        }
155        writeln!(script).unwrap();
156    }
157
158    // Emit shell sources
159    if !shell_sources.is_empty() {
160        writeln!(script, "# Shell scripts").unwrap();
161        for (pack, target) in &shell_sources {
162            writeln!(script, "# [{pack}]").unwrap();
163            if profiling_active {
164                emit_timed_source(&mut script, pack, target);
165            } else {
166                writeln!(
167                    script,
168                    "[ -f \"{}\" ] && . \"{}\"",
169                    target.display(),
170                    target.display()
171                )
172                .unwrap();
173            }
174        }
175        writeln!(script).unwrap();
176    }
177
178    // Profiling epilogue (close the report, scrub our state).
179    if profiling_active {
180        emit_profiling_epilogue(&mut script);
181    }
182
183    Ok(script)
184}
185
186/// Generate and write the init script to `data_dir/shell/dodot-init.sh`.
187///
188/// Returns the path where the script was written.
189pub fn write_init_script(
190    fs: &dyn Fs,
191    paths: &dyn Pather,
192    profiling_enabled: bool,
193) -> Result<PathBuf> {
194    let script_content = generate_init_script(fs, paths, profiling_enabled)?;
195    let script_path = paths.init_script_path();
196
197    fs.mkdir_all(paths.shell_dir())?;
198    fs.write_file(&script_path, script_content.as_bytes())?;
199    fs.set_permissions(&script_path, 0o755)?;
200
201    Ok(script_path)
202}
203
204// ── Profiling wrapper emitters ───────────────────────────────────────
205
206/// The runtime-detection preamble. Sets `_dodot_prof` to `1` when the
207/// current shell is bash 5+ or zsh with `EPOCHREALTIME` available;
208/// otherwise leaves it `0` (the wrapper falls through to the no-op
209/// path). All shell variables are namespaced `_dodot_*` so we don't
210/// stomp on the user's environment.
211fn emit_profiling_preamble(script: &mut String, profiles_dir: &Path, init_script_path: &Path) {
212    let dir = sh_quote(&profiles_dir.display().to_string());
213    let init_script = sh_quote(&init_script_path.display().to_string());
214    writeln!(script, "# ── dodot shell-init profiling (Phase 2) ──").unwrap();
215    writeln!(script, "_dodot_prof=0").unwrap();
216    writeln!(
217        script,
218        "if [ -n \"${{BASH_VERSION:-}}\" ] || [ -n \"${{ZSH_VERSION:-}}\" ]; then"
219    )
220    .unwrap();
221    // zsh exposes EPOCHREALTIME only after `zmodload zsh/datetime`. Load
222    // it eagerly here; bash 5+ has the variable built in and ignores
223    // unknown commands like `zmodload` (we suppress its `command not
224    // found` error). Doing this *inside* the bash/zsh guard keeps it off
225    // hot paths in plain sh.
226    writeln!(
227        script,
228        "  [ -n \"${{ZSH_VERSION:-}}\" ] && zmodload zsh/datetime 2>/dev/null"
229    )
230    .unwrap();
231    writeln!(script, "  if [ -n \"${{EPOCHREALTIME:-}}\" ]; then").unwrap();
232    writeln!(script, "    _dodot_prof_dir={dir}").unwrap();
233    writeln!(
234        script,
235        "    _dodot_prof_file=\"$_dodot_prof_dir/profile-${{EPOCHSECONDS:-0}}-$$-${{RANDOM}}.tsv\""
236    )
237    .unwrap();
238    writeln!(
239        script,
240        "    if mkdir -p \"$_dodot_prof_dir\" 2>/dev/null; then"
241    )
242    .unwrap();
243    writeln!(script, "      _dodot_prof_t0=$EPOCHREALTIME").unwrap();
244    writeln!(script, "      {{").unwrap();
245    writeln!(script, "        printf '# dodot shell-init profile v1\\n'").unwrap();
246    writeln!(
247        script,
248        "        printf '# shell\\t%s\\n' \"${{BASH_VERSION:+bash $BASH_VERSION}}${{ZSH_VERSION:+zsh $ZSH_VERSION}}\""
249    )
250    .unwrap();
251    writeln!(
252        script,
253        "        printf '# start_t\\t%s\\n' \"$_dodot_prof_t0\""
254    )
255    .unwrap();
256    writeln!(
257        script,
258        "        printf '# init_script\\t%s\\n' {init_script}"
259    )
260    .unwrap();
261    writeln!(
262        script,
263        "        printf '# columns\\tphase\\tpack\\thandler\\ttarget\\tstart_t\\tend_t\\texit_status\\n'"
264    )
265    .unwrap();
266    writeln!(
267        script,
268        "      }} > \"$_dodot_prof_file\" 2>/dev/null && _dodot_prof=1"
269    )
270    .unwrap();
271    writeln!(script, "    fi").unwrap();
272    writeln!(script, "  fi").unwrap();
273    writeln!(script, "fi").unwrap();
274    writeln!(script).unwrap();
275}
276
277/// One inline-timed `export PATH=…` row. The branch is one comparison
278/// at runtime — negligible on shells where the wrapper is inert.
279fn emit_timed_path(script: &mut String, pack: &str, target: &Path) {
280    let target_str = target.display().to_string();
281    let target_q = sh_quote(&target_str);
282    writeln!(script, "if [ \"$_dodot_prof\" = \"1\" ]; then").unwrap();
283    writeln!(
284        script,
285        "  _dodot_t0=$EPOCHREALTIME; export PATH=\"{target_str}:$PATH\"; _dodot_t1=$EPOCHREALTIME"
286    )
287    .unwrap();
288    writeln!(
289        script,
290        "  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"
291    )
292    .unwrap();
293    writeln!(script, "else").unwrap();
294    writeln!(script, "  export PATH=\"{target_str}:$PATH\"").unwrap();
295    writeln!(script, "fi").unwrap();
296}
297
298/// One inline-timed `[ -f X ] && . X` row, capturing the source's
299/// exit status. Same overhead profile as the PATH variant.
300fn emit_timed_source(script: &mut String, pack: &str, target: &Path) {
301    let target_str = target.display().to_string();
302    let target_q = sh_quote(&target_str);
303    writeln!(script, "if [ \"$_dodot_prof\" = \"1\" ]; then").unwrap();
304    writeln!(
305        script,
306        "  _dodot_t0=$EPOCHREALTIME; [ -f \"{target_str}\" ] && . \"{target_str}\"; _dodot_rc=$?; _dodot_t1=$EPOCHREALTIME"
307    )
308    .unwrap();
309    writeln!(
310        script,
311        "  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"
312    )
313    .unwrap();
314    writeln!(script, "else").unwrap();
315    writeln!(script, "  [ -f \"{target_str}\" ] && . \"{target_str}\"").unwrap();
316    writeln!(script, "fi").unwrap();
317}
318
319/// Closes out the report (writes the `# end_t` marker) and clears
320/// every `_dodot_*` shell variable so we don't leak state into the
321/// user's interactive shell.
322fn emit_profiling_epilogue(script: &mut String) {
323    writeln!(script, "# ── dodot shell-init profiling epilogue ──").unwrap();
324    writeln!(script, "if [ \"$_dodot_prof\" = \"1\" ]; then").unwrap();
325    writeln!(
326        script,
327        "  printf '# end_t\\t%s\\n' \"$EPOCHREALTIME\" >> \"$_dodot_prof_file\" 2>/dev/null"
328    )
329    .unwrap();
330    writeln!(script, "fi").unwrap();
331    writeln!(
332        script,
333        "unset _dodot_prof _dodot_prof_dir _dodot_prof_file _dodot_prof_t0 _dodot_t0 _dodot_t1 _dodot_rc 2>/dev/null"
334    )
335    .unwrap();
336}
337
338/// Single-quote a string for safe use in POSIX shell. Embedded single
339/// quotes are escaped via the `'\''` idiom.
340fn sh_quote(s: &str) -> String {
341    let mut out = String::with_capacity(s.len() + 2);
342    out.push('\'');
343    for c in s.chars() {
344        if c == '\'' {
345            out.push_str("'\\''");
346        } else {
347            out.push(c);
348        }
349    }
350    out.push('\'');
351    out
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::datastore::{CommandOutput, CommandRunner, DataStore, FilesystemDataStore};
358    use crate::testing::TempEnvironment;
359    use std::sync::Arc;
360
361    struct NoopRunner;
362    impl CommandRunner for NoopRunner {
363        fn run(&self, _: &str, _: &[String]) -> Result<CommandOutput> {
364            Ok(CommandOutput {
365                exit_code: 0,
366                stdout: String::new(),
367                stderr: String::new(),
368            })
369        }
370    }
371
372    fn make_datastore(env: &TempEnvironment) -> FilesystemDataStore {
373        FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), Arc::new(NoopRunner))
374    }
375
376    #[test]
377    fn empty_datastore_produces_helpful_script() {
378        let env = TempEnvironment::builder().build();
379        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
380
381        assert!(script.starts_with("#!/bin/sh"));
382        assert!(script.contains("Generated by dodot"));
383        assert!(script.contains("No shell scripts or PATH additions"));
384        assert!(script.contains("dodot up"));
385        assert!(script.contains("dodot status"));
386        // No source or PATH lines
387        assert!(!script.contains("export PATH"));
388        assert!(!script.contains(". \""));
389    }
390
391    #[test]
392    fn shell_handler_state_produces_source_lines() {
393        let env = TempEnvironment::builder()
394            .pack("vim")
395            .file("aliases.sh", "alias vi=vim")
396            .done()
397            .build();
398
399        let ds = make_datastore(&env);
400        let source = env.dotfiles_root.join("vim/aliases.sh");
401        ds.create_data_link("vim", "shell", &source).unwrap();
402
403        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
404
405        assert!(script.contains("# Shell scripts"), "script:\n{script}");
406        assert!(script.contains("# [vim]"), "script:\n{script}");
407        assert!(
408            script.contains(&format!(
409                "[ -f \"{}\" ] && . \"{}\"",
410                source.display(),
411                source.display()
412            )),
413            "script:\n{script}"
414        );
415    }
416
417    #[test]
418    fn path_handler_state_produces_path_lines() {
419        let env = TempEnvironment::builder()
420            .pack("vim")
421            .file("bin/myscript", "#!/bin/sh")
422            .done()
423            .build();
424
425        let ds = make_datastore(&env);
426        let source = env.dotfiles_root.join("vim/bin");
427        ds.create_data_link("vim", "path", &source).unwrap();
428
429        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
430
431        assert!(script.contains("# PATH additions"), "script:\n{script}");
432        assert!(script.contains("# [vim]"), "script:\n{script}");
433        assert!(
434            script.contains(&format!("export PATH=\"{}:$PATH\"", source.display())),
435            "script:\n{script}"
436        );
437    }
438
439    #[test]
440    fn multiple_packs_combined() {
441        let env = TempEnvironment::builder()
442            .pack("git")
443            .file("aliases.sh", "alias gs='git status'")
444            .done()
445            .pack("vim")
446            .file("aliases.sh", "alias vi=vim")
447            .file("bin/vimrun", "#!/bin/sh")
448            .done()
449            .build();
450
451        let ds = make_datastore(&env);
452
453        // Shell scripts
454        ds.create_data_link("git", "shell", &env.dotfiles_root.join("git/aliases.sh"))
455            .unwrap();
456        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
457            .unwrap();
458
459        // Path
460        ds.create_data_link("vim", "path", &env.dotfiles_root.join("vim/bin"))
461            .unwrap();
462
463        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
464
465        // Should have both shell sources
466        assert!(script.contains("# [git]"), "script:\n{script}");
467        assert!(script.contains("# [vim]"), "script:\n{script}");
468        // Should have PATH addition
469        assert!(script.contains("export PATH="), "script:\n{script}");
470        // Should have source lines
471        let source_count = script.matches(". \"").count();
472        assert_eq!(
473            source_count, 2,
474            "expected 2 source lines, script:\n{script}"
475        );
476    }
477
478    #[test]
479    fn write_init_script_creates_executable_file() {
480        let env = TempEnvironment::builder()
481            .pack("vim")
482            .file("aliases.sh", "alias vi=vim")
483            .done()
484            .build();
485
486        let ds = make_datastore(&env);
487        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
488            .unwrap();
489
490        let script_path = write_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
491
492        assert_eq!(script_path, env.paths.init_script_path());
493        env.assert_exists(&script_path);
494
495        let content = env.fs.read_to_string(&script_path).unwrap();
496        assert!(content.starts_with("#!/bin/sh"));
497        assert!(content.contains("aliases.sh"));
498
499        // Check executable permission
500        let meta = std::fs::metadata(&script_path).unwrap();
501        use std::os::unix::fs::PermissionsExt;
502        assert_eq!(meta.permissions().mode() & 0o111, 0o111);
503    }
504
505    #[test]
506    fn script_regenerated_reflects_current_state() {
507        let env = TempEnvironment::builder()
508            .pack("vim")
509            .file("aliases.sh", "alias vi=vim")
510            .done()
511            .build();
512
513        let ds = make_datastore(&env);
514
515        // Initially empty
516        let script1 = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
517        assert!(!script1.contains("aliases.sh"));
518
519        // Deploy shell script
520        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
521            .unwrap();
522
523        let script2 = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
524        assert!(script2.contains("aliases.sh"));
525
526        // Remove state
527        ds.remove_state("vim", "shell").unwrap();
528
529        let script3 = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
530        assert!(!script3.contains("aliases.sh"));
531    }
532
533    #[test]
534    fn ignores_non_symlink_files_in_handler_dirs() {
535        let env = TempEnvironment::builder().build();
536
537        // Create a non-symlink file in the shell handler dir
538        let shell_dir = env.paths.handler_data_dir("vim", "shell");
539        env.fs.mkdir_all(&shell_dir).unwrap();
540        env.fs
541            .write_file(&shell_dir.join("not-a-symlink"), b"noise")
542            .unwrap();
543
544        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
545        assert!(!script.contains("not-a-symlink"));
546    }
547
548    #[test]
549    fn path_additions_come_before_shell_sources() {
550        let env = TempEnvironment::builder()
551            .pack("vim")
552            .file("aliases.sh", "alias vi=vim")
553            .file("bin/myscript", "#!/bin/sh")
554            .done()
555            .build();
556
557        let ds = make_datastore(&env);
558        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
559            .unwrap();
560        ds.create_data_link("vim", "path", &env.dotfiles_root.join("vim/bin"))
561            .unwrap();
562
563        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
564
565        let path_pos = script.find("# PATH additions").unwrap();
566        let shell_pos = script.find("# Shell scripts").unwrap();
567        assert!(
568            path_pos < shell_pos,
569            "PATH additions should come before shell sources"
570        );
571    }
572
573    // ── Phase 2: profiling wrapper ──────────────────────────────────
574
575    #[test]
576    fn profiling_disabled_matches_phase1_byte_for_byte() {
577        // The contract: when profiling is off, the script must be the
578        // exact same bytes a Phase-1 generator would have produced. This
579        // protects users who don't want any change to their init script.
580        let env = TempEnvironment::builder()
581            .pack("vim")
582            .file("aliases.sh", "alias vi=vim")
583            .done()
584            .build();
585
586        let ds = make_datastore(&env);
587        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
588            .unwrap();
589
590        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
591        assert!(!script.contains("_dodot_prof"));
592        assert!(!script.contains("EPOCHREALTIME"));
593        assert!(!script.contains("dodot shell-init profile"));
594    }
595
596    #[test]
597    fn profiling_enabled_emits_runtime_gated_preamble() {
598        let env = TempEnvironment::builder()
599            .pack("vim")
600            .file("aliases.sh", "alias vi=vim")
601            .done()
602            .build();
603
604        let ds = make_datastore(&env);
605        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
606            .unwrap();
607
608        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
609
610        // Preamble feature-detects bash 5+ / zsh + EPOCHREALTIME
611        assert!(script.contains("BASH_VERSION"));
612        assert!(script.contains("ZSH_VERSION"));
613        assert!(script.contains("EPOCHREALTIME"));
614        // The profile dir comes from Pather, so the script should embed it.
615        assert!(script.contains(env.paths.probes_shell_init_dir().to_str().unwrap()));
616        // File naming includes pid + RANDOM for collision-resistance.
617        assert!(script.contains("$$"));
618        assert!(script.contains("RANDOM"));
619        // Header lines we always emit.
620        assert!(script.contains("# dodot shell-init profile v1"));
621        assert!(script.contains("columns\\tphase\\tpack\\thandler\\ttarget"));
622    }
623
624    #[test]
625    fn profiling_enabled_wraps_each_source_with_else_path() {
626        let env = TempEnvironment::builder()
627            .pack("vim")
628            .file("aliases.sh", "")
629            .file("bin/tool", "#!/bin/sh")
630            .done()
631            .build();
632
633        let ds = make_datastore(&env);
634        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
635            .unwrap();
636        ds.create_data_link("vim", "path", &env.dotfiles_root.join("vim/bin"))
637            .unwrap();
638
639        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
640
641        // Each entry has an if/else so unprofiled shells still source / set PATH.
642        // (One else per entry; the epilogue uses an if-only form, so counting
643        // `else` keeps us focused on the entry wrappers.)
644        let else_count = script.matches("else").count();
645        assert_eq!(
646            else_count, 2,
647            "expected one else-branch per entry; script:\n{script}"
648        );
649
650        // Source row carries the captured exit status; PATH row hard-codes 0.
651        assert!(script.contains("printf 'source\\tvim\\tshell\\t"));
652        assert!(script.contains("printf 'path\\tvim\\tpath\\t"));
653        assert!(script.contains("\"$_dodot_rc\""));
654    }
655
656    #[test]
657    fn profiling_epilogue_writes_end_marker_and_unsets_state() {
658        let env = TempEnvironment::builder()
659            .pack("vim")
660            .file("aliases.sh", "")
661            .done()
662            .build();
663
664        let ds = make_datastore(&env);
665        ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
666            .unwrap();
667
668        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
669        // End-of-run timestamp.
670        assert!(script.contains("# end_t"));
671        // We scrub our state to avoid leaking into the user's shell.
672        assert!(script.contains("unset _dodot_prof"));
673        assert!(script.contains("_dodot_prof_file"));
674    }
675
676    #[test]
677    fn profiling_enabled_with_empty_datastore_skips_preamble() {
678        // No deployed entries → empty notice only, no profiling boilerplate.
679        let env = TempEnvironment::builder().build();
680        let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
681        assert!(script.contains("No shell scripts or PATH additions"));
682        assert!(!script.contains("_dodot_prof"));
683    }
684
685    #[test]
686    fn shell_quoting_handles_paths_with_single_quotes() {
687        // A path with a single quote in it must round-trip safely
688        // through the printf args. Embedded `'` becomes `'\''`.
689        assert_eq!(sh_quote("plain"), "'plain'");
690        assert_eq!(sh_quote("it's"), "'it'\\''s'");
691        assert_eq!(sh_quote(""), "''");
692    }
693}