1use std::fmt::Write;
41use std::path::{Path, PathBuf};
42
43use crate::fs::Fs;
44use crate::paths::Pather;
45use crate::Result;
46
47fn 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
57pub 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 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 let mut shell_sources: Vec<(String, PathBuf)> = Vec::new(); let mut path_additions: Vec<(String, PathBuf)> = Vec::new(); for pack_entry in &pack_entries {
93 if !pack_entry.is_dir {
94 continue;
95 }
96 let pack_name = &pack_entry.name;
97
98 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 let target = fs.readlink(&entry.path)?;
108 shell_sources.push((pack_name.clone(), target));
109 }
110 }
111 }
112
113 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 path_additions.is_empty() && shell_sources.is_empty() {
130 append_empty_notice(&mut script);
131 return Ok(script);
132 }
133
134 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 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 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 if profiling_active {
180 emit_profiling_epilogue(&mut script);
181 }
182
183 Ok(script)
184}
185
186pub 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
204fn 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 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
277fn 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
298fn 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
319fn 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
338fn 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 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 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 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 assert!(script.contains("# [git]"), "script:\n{script}");
467 assert!(script.contains("# [vim]"), "script:\n{script}");
468 assert!(script.contains("export PATH="), "script:\n{script}");
470 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 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 let script1 = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
517 assert!(!script1.contains("aliases.sh"));
518
519 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 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 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 #[test]
576 fn profiling_disabled_matches_phase1_byte_for_byte() {
577 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 assert!(script.contains("BASH_VERSION"));
612 assert!(script.contains("ZSH_VERSION"));
613 assert!(script.contains("EPOCHREALTIME"));
614 assert!(script.contains(env.paths.probes_shell_init_dir().to_str().unwrap()));
616 assert!(script.contains("$$"));
618 assert!(script.contains("RANDOM"));
619 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 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 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 assert!(script.contains("# end_t"));
671 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 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 assert_eq!(sh_quote("plain"), "'plain'");
690 assert_eq!(sh_quote("it's"), "'it'\\''s'");
691 assert_eq!(sh_quote(""), "''");
692 }
693}