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!(
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
294fn 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
315fn 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 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
354fn 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
373fn 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 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 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 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 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 assert!(script.contains("# [git]"), "script:\n{script}");
503 assert!(script.contains("# [vim]"), "script:\n{script}");
504 assert!(script.contains("export PATH="), "script:\n{script}");
506 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 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 let script1 = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
553 assert!(!script1.contains("aliases.sh"));
554
555 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 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 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 #[test]
612 fn profiling_disabled_matches_phase1_byte_for_byte() {
613 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 assert!(script.contains("BASH_VERSION"));
648 assert!(script.contains("ZSH_VERSION"));
649 assert!(script.contains("EPOCHREALTIME"));
650 assert!(script.contains(env.paths.probes_shell_init_dir().to_str().unwrap()));
652 assert!(script.contains("$$"));
654 assert!(script.contains("RANDOM"));
655 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 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 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 assert!(script.contains("# end_t"));
707 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 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 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 assert!(
744 script.contains("_dodot_rc=0;"),
745 "profiled branch must seed _dodot_rc=0 before the source attempt:\n{script}"
746 );
747 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 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 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 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 assert_eq!(sh_quote("plain"), "'plain'");
799 assert_eq!(sh_quote("it's"), "'it'\\''s'");
800 assert_eq!(sh_quote(""), "''");
801 }
802}