1use 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
53fn 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
63pub 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 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 let mut shell_sources: Vec<(String, PathBuf)> = Vec::new(); let mut path_additions: Vec<(String, PathBuf)> = Vec::new(); for pack_entry in &pack_entries {
99 if !pack_entry.is_dir {
100 continue;
101 }
102 let pack_dir = &pack_entry.name;
108 let pack_display = crate::packs::display_name_for(pack_dir).to_string();
109
110 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 let target = fs.readlink(&entry.path)?;
120 shell_sources.push((pack_display.clone(), target));
121 }
122 }
123 }
124
125 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 path_additions.is_empty() && shell_sources.is_empty() {
142 append_empty_notice(&mut script);
143 return Ok(script);
144 }
145
146 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 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 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 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 if profiling_active {
197 emit_profiling_epilogue(&mut script);
198 }
199
200 Ok(script)
201}
202
203pub 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
221fn 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 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!(
261 script,
262 " _dodot_err_file=\"${{_dodot_prof_file%.tsv}}.errors.log\""
263 )
264 .unwrap();
265 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 writeln!(script, " fi").unwrap();
307 writeln!(script, " fi").unwrap();
308 writeln!(script, "fi").unwrap();
309 writeln!(script).unwrap();
310}
311
312fn 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
333fn 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 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 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 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
405fn 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 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
431fn 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 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 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 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 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 assert!(script.contains("# [git]"), "script:\n{script}");
561 assert!(script.contains("# [vim]"), "script:\n{script}");
562 assert!(script.contains("export PATH="), "script:\n{script}");
564 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 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 let script1 = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
611 assert!(!script1.contains("aliases.sh"));
612
613 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 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 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 #[test]
670 fn profiling_disabled_matches_phase1_byte_for_byte() {
671 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 assert!(script.contains("BASH_VERSION"));
706 assert!(script.contains("ZSH_VERSION"));
707 assert!(script.contains("EPOCHREALTIME"));
708 assert!(script.contains(env.paths.probes_shell_init_dir().to_str().unwrap()));
710 assert!(script.contains("$$"));
712 assert!(script.contains("RANDOM"));
713 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 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 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 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 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 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 assert!(
780 script.contains("2>\"$_dodot_err_tmp\""),
781 "source must redirect stderr to scratch file:\n{script}"
782 );
783 assert!(
786 script.contains(": > \"$_dodot_err_tmp\""),
787 "scratch file must be truncated before each source:\n{script}"
788 );
789 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 assert!(script.contains("# end_t"));
811 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 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 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 assert!(
848 script.contains("_dodot_rc=0;"),
849 "profiled branch must seed _dodot_rc=0 before the source attempt:\n{script}"
850 );
851 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 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 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 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 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 assert_eq!(sh_quote("plain"), "'plain'");
908 assert_eq!(sh_quote("it's"), "'it'\\''s'");
909 assert_eq!(sh_quote(""), "''");
910 }
911}