use std::fmt::Write;
use std::path::{Path, PathBuf};
use crate::fs::Fs;
use crate::paths::Pather;
use crate::Result;
fn append_empty_notice(script: &mut String) {
writeln!(script, "# No shell scripts or PATH additions to load.").unwrap();
writeln!(
script,
"# Run `dodot up` to deploy packs, or `dodot status` to see available packs."
)
.unwrap();
}
pub fn generate_init_script(
fs: &dyn Fs,
paths: &dyn Pather,
profiling_enabled: bool,
) -> Result<String> {
let mut script = String::new();
writeln!(script, "#!/bin/sh").unwrap();
writeln!(script, "# Generated by dodot — do not edit manually.").unwrap();
writeln!(script, "# Regenerated on every `dodot up` / `dodot down`.").unwrap();
writeln!(script).unwrap();
let packs_dir = paths.data_dir().join("packs");
if !fs.exists(&packs_dir) {
append_empty_notice(&mut script);
return Ok(script);
}
let pack_entries = fs.read_dir(&packs_dir)?;
let mut shell_sources: Vec<(String, PathBuf)> = Vec::new(); let mut path_additions: Vec<(String, PathBuf)> = Vec::new();
for pack_entry in &pack_entries {
if !pack_entry.is_dir {
continue;
}
let pack_name = &pack_entry.name;
let shell_dir = paths.handler_data_dir(pack_name, "shell");
if fs.is_dir(&shell_dir) {
if let Ok(entries) = fs.read_dir(&shell_dir) {
for entry in entries {
if !entry.is_symlink {
continue;
}
let target = fs.readlink(&entry.path)?;
shell_sources.push((pack_name.clone(), target));
}
}
}
let path_dir = paths.handler_data_dir(pack_name, "path");
if fs.is_dir(&path_dir) {
if let Ok(entries) = fs.read_dir(&path_dir) {
for entry in entries {
if !entry.is_symlink {
continue;
}
let target = fs.readlink(&entry.path)?;
path_additions.push((pack_name.clone(), target));
}
}
}
}
if path_additions.is_empty() && shell_sources.is_empty() {
append_empty_notice(&mut script);
return Ok(script);
}
let profiling_active = profiling_enabled;
if profiling_active {
emit_profiling_preamble(
&mut script,
&paths.probes_shell_init_dir(),
&paths.init_script_path(),
);
}
if !path_additions.is_empty() {
writeln!(script, "# PATH additions").unwrap();
for (pack, target) in &path_additions {
writeln!(script, "# [{pack}]").unwrap();
if profiling_active {
emit_timed_path(&mut script, pack, target);
} else {
writeln!(script, "export PATH=\"{}:$PATH\"", target.display()).unwrap();
}
}
writeln!(script).unwrap();
}
if !shell_sources.is_empty() {
writeln!(script, "# Shell scripts").unwrap();
for (pack, target) in &shell_sources {
writeln!(script, "# [{pack}]").unwrap();
if profiling_active {
emit_timed_source(&mut script, pack, target);
} else {
writeln!(
script,
"[ -f \"{}\" ] && . \"{}\"",
target.display(),
target.display()
)
.unwrap();
}
}
writeln!(script).unwrap();
}
if profiling_active {
emit_profiling_epilogue(&mut script);
}
Ok(script)
}
pub fn write_init_script(
fs: &dyn Fs,
paths: &dyn Pather,
profiling_enabled: bool,
) -> Result<PathBuf> {
let script_content = generate_init_script(fs, paths, profiling_enabled)?;
let script_path = paths.init_script_path();
fs.mkdir_all(paths.shell_dir())?;
fs.write_file(&script_path, script_content.as_bytes())?;
fs.set_permissions(&script_path, 0o755)?;
Ok(script_path)
}
fn emit_profiling_preamble(script: &mut String, profiles_dir: &Path, init_script_path: &Path) {
let dir = sh_quote(&profiles_dir.display().to_string());
let init_script = sh_quote(&init_script_path.display().to_string());
writeln!(script, "# ── dodot shell-init profiling (Phase 2) ──").unwrap();
writeln!(script, "_dodot_prof=0").unwrap();
writeln!(
script,
"if [ -n \"${{BASH_VERSION:-}}\" ] || [ -n \"${{ZSH_VERSION:-}}\" ]; then"
)
.unwrap();
writeln!(
script,
" [ -n \"${{ZSH_VERSION:-}}\" ] && zmodload zsh/datetime 2>/dev/null"
)
.unwrap();
writeln!(script, " if [ -n \"${{EPOCHREALTIME:-}}\" ]; then").unwrap();
writeln!(script, " _dodot_prof_dir={dir}").unwrap();
writeln!(
script,
" _dodot_prof_file=\"$_dodot_prof_dir/profile-${{EPOCHSECONDS:-0}}-$$-${{RANDOM}}.tsv\""
)
.unwrap();
writeln!(
script,
" if mkdir -p \"$_dodot_prof_dir\" 2>/dev/null; then"
)
.unwrap();
writeln!(script, " _dodot_prof_t0=$EPOCHREALTIME").unwrap();
writeln!(script, " {{").unwrap();
writeln!(script, " printf '# dodot shell-init profile v1\\n'").unwrap();
writeln!(
script,
" printf '# shell\\t%s\\n' \"${{BASH_VERSION:+bash $BASH_VERSION}}${{ZSH_VERSION:+zsh $ZSH_VERSION}}\""
)
.unwrap();
writeln!(
script,
" printf '# start_t\\t%s\\n' \"$_dodot_prof_t0\""
)
.unwrap();
writeln!(
script,
" printf '# init_script\\t%s\\n' {init_script}"
)
.unwrap();
writeln!(
script,
" printf '# columns\\tphase\\tpack\\thandler\\ttarget\\tstart_t\\tend_t\\texit_status\\n'"
)
.unwrap();
writeln!(
script,
" }} > \"$_dodot_prof_file\" 2>/dev/null && _dodot_prof=1"
)
.unwrap();
writeln!(script, " fi").unwrap();
writeln!(script, " fi").unwrap();
writeln!(script, "fi").unwrap();
writeln!(script).unwrap();
}
fn emit_timed_path(script: &mut String, pack: &str, target: &Path) {
let target_str = target.display().to_string();
let target_q = sh_quote(&target_str);
writeln!(script, "if [ \"$_dodot_prof\" = \"1\" ]; then").unwrap();
writeln!(
script,
" _dodot_t0=$EPOCHREALTIME; export PATH=\"{target_str}:$PATH\"; _dodot_t1=$EPOCHREALTIME"
)
.unwrap();
writeln!(
script,
" 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"
)
.unwrap();
writeln!(script, "else").unwrap();
writeln!(script, " export PATH=\"{target_str}:$PATH\"").unwrap();
writeln!(script, "fi").unwrap();
}
fn emit_timed_source(script: &mut String, pack: &str, target: &Path) {
let target_str = target.display().to_string();
let target_q = sh_quote(&target_str);
writeln!(script, "if [ \"$_dodot_prof\" = \"1\" ]; then").unwrap();
writeln!(
script,
" _dodot_t0=$EPOCHREALTIME; [ -f \"{target_str}\" ] && . \"{target_str}\"; _dodot_rc=$?; _dodot_t1=$EPOCHREALTIME"
)
.unwrap();
writeln!(
script,
" 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"
)
.unwrap();
writeln!(script, "else").unwrap();
writeln!(script, " [ -f \"{target_str}\" ] && . \"{target_str}\"").unwrap();
writeln!(script, "fi").unwrap();
}
fn emit_profiling_epilogue(script: &mut String) {
writeln!(script, "# ── dodot shell-init profiling epilogue ──").unwrap();
writeln!(script, "if [ \"$_dodot_prof\" = \"1\" ]; then").unwrap();
writeln!(
script,
" printf '# end_t\\t%s\\n' \"$EPOCHREALTIME\" >> \"$_dodot_prof_file\" 2>/dev/null"
)
.unwrap();
writeln!(script, "fi").unwrap();
writeln!(
script,
"unset _dodot_prof _dodot_prof_dir _dodot_prof_file _dodot_prof_t0 _dodot_t0 _dodot_t1 _dodot_rc 2>/dev/null"
)
.unwrap();
}
fn sh_quote(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('\'');
for c in s.chars() {
if c == '\'' {
out.push_str("'\\''");
} else {
out.push(c);
}
}
out.push('\'');
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::datastore::{CommandOutput, CommandRunner, DataStore, FilesystemDataStore};
use crate::testing::TempEnvironment;
use std::sync::Arc;
struct NoopRunner;
impl CommandRunner for NoopRunner {
fn run(&self, _: &str, _: &[String]) -> Result<CommandOutput> {
Ok(CommandOutput {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
})
}
}
fn make_datastore(env: &TempEnvironment) -> FilesystemDataStore {
FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), Arc::new(NoopRunner))
}
#[test]
fn empty_datastore_produces_helpful_script() {
let env = TempEnvironment::builder().build();
let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
assert!(script.starts_with("#!/bin/sh"));
assert!(script.contains("Generated by dodot"));
assert!(script.contains("No shell scripts or PATH additions"));
assert!(script.contains("dodot up"));
assert!(script.contains("dodot status"));
assert!(!script.contains("export PATH"));
assert!(!script.contains(". \""));
}
#[test]
fn shell_handler_state_produces_source_lines() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.done()
.build();
let ds = make_datastore(&env);
let source = env.dotfiles_root.join("vim/aliases.sh");
ds.create_data_link("vim", "shell", &source).unwrap();
let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
assert!(script.contains("# Shell scripts"), "script:\n{script}");
assert!(script.contains("# [vim]"), "script:\n{script}");
assert!(
script.contains(&format!(
"[ -f \"{}\" ] && . \"{}\"",
source.display(),
source.display()
)),
"script:\n{script}"
);
}
#[test]
fn path_handler_state_produces_path_lines() {
let env = TempEnvironment::builder()
.pack("vim")
.file("bin/myscript", "#!/bin/sh")
.done()
.build();
let ds = make_datastore(&env);
let source = env.dotfiles_root.join("vim/bin");
ds.create_data_link("vim", "path", &source).unwrap();
let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
assert!(script.contains("# PATH additions"), "script:\n{script}");
assert!(script.contains("# [vim]"), "script:\n{script}");
assert!(
script.contains(&format!("export PATH=\"{}:$PATH\"", source.display())),
"script:\n{script}"
);
}
#[test]
fn multiple_packs_combined() {
let env = TempEnvironment::builder()
.pack("git")
.file("aliases.sh", "alias gs='git status'")
.done()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.file("bin/vimrun", "#!/bin/sh")
.done()
.build();
let ds = make_datastore(&env);
ds.create_data_link("git", "shell", &env.dotfiles_root.join("git/aliases.sh"))
.unwrap();
ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
.unwrap();
ds.create_data_link("vim", "path", &env.dotfiles_root.join("vim/bin"))
.unwrap();
let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
assert!(script.contains("# [git]"), "script:\n{script}");
assert!(script.contains("# [vim]"), "script:\n{script}");
assert!(script.contains("export PATH="), "script:\n{script}");
let source_count = script.matches(". \"").count();
assert_eq!(
source_count, 2,
"expected 2 source lines, script:\n{script}"
);
}
#[test]
fn write_init_script_creates_executable_file() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.done()
.build();
let ds = make_datastore(&env);
ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
.unwrap();
let script_path = write_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
assert_eq!(script_path, env.paths.init_script_path());
env.assert_exists(&script_path);
let content = env.fs.read_to_string(&script_path).unwrap();
assert!(content.starts_with("#!/bin/sh"));
assert!(content.contains("aliases.sh"));
let meta = std::fs::metadata(&script_path).unwrap();
use std::os::unix::fs::PermissionsExt;
assert_eq!(meta.permissions().mode() & 0o111, 0o111);
}
#[test]
fn script_regenerated_reflects_current_state() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.done()
.build();
let ds = make_datastore(&env);
let script1 = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
assert!(!script1.contains("aliases.sh"));
ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
.unwrap();
let script2 = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
assert!(script2.contains("aliases.sh"));
ds.remove_state("vim", "shell").unwrap();
let script3 = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
assert!(!script3.contains("aliases.sh"));
}
#[test]
fn ignores_non_symlink_files_in_handler_dirs() {
let env = TempEnvironment::builder().build();
let shell_dir = env.paths.handler_data_dir("vim", "shell");
env.fs.mkdir_all(&shell_dir).unwrap();
env.fs
.write_file(&shell_dir.join("not-a-symlink"), b"noise")
.unwrap();
let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
assert!(!script.contains("not-a-symlink"));
}
#[test]
fn path_additions_come_before_shell_sources() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.file("bin/myscript", "#!/bin/sh")
.done()
.build();
let ds = make_datastore(&env);
ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
.unwrap();
ds.create_data_link("vim", "path", &env.dotfiles_root.join("vim/bin"))
.unwrap();
let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
let path_pos = script.find("# PATH additions").unwrap();
let shell_pos = script.find("# Shell scripts").unwrap();
assert!(
path_pos < shell_pos,
"PATH additions should come before shell sources"
);
}
#[test]
fn profiling_disabled_matches_phase1_byte_for_byte() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.done()
.build();
let ds = make_datastore(&env);
ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
.unwrap();
let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
assert!(!script.contains("_dodot_prof"));
assert!(!script.contains("EPOCHREALTIME"));
assert!(!script.contains("dodot shell-init profile"));
}
#[test]
fn profiling_enabled_emits_runtime_gated_preamble() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.done()
.build();
let ds = make_datastore(&env);
ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
.unwrap();
let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
assert!(script.contains("BASH_VERSION"));
assert!(script.contains("ZSH_VERSION"));
assert!(script.contains("EPOCHREALTIME"));
assert!(script.contains(env.paths.probes_shell_init_dir().to_str().unwrap()));
assert!(script.contains("$$"));
assert!(script.contains("RANDOM"));
assert!(script.contains("# dodot shell-init profile v1"));
assert!(script.contains("columns\\tphase\\tpack\\thandler\\ttarget"));
}
#[test]
fn profiling_enabled_wraps_each_source_with_else_path() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "")
.file("bin/tool", "#!/bin/sh")
.done()
.build();
let ds = make_datastore(&env);
ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
.unwrap();
ds.create_data_link("vim", "path", &env.dotfiles_root.join("vim/bin"))
.unwrap();
let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
let else_count = script.matches("else").count();
assert_eq!(
else_count, 2,
"expected one else-branch per entry; script:\n{script}"
);
assert!(script.contains("printf 'source\\tvim\\tshell\\t"));
assert!(script.contains("printf 'path\\tvim\\tpath\\t"));
assert!(script.contains("\"$_dodot_rc\""));
}
#[test]
fn profiling_epilogue_writes_end_marker_and_unsets_state() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "")
.done()
.build();
let ds = make_datastore(&env);
ds.create_data_link("vim", "shell", &env.dotfiles_root.join("vim/aliases.sh"))
.unwrap();
let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
assert!(script.contains("# end_t"));
assert!(script.contains("unset _dodot_prof"));
assert!(script.contains("_dodot_prof_file"));
}
#[test]
fn profiling_enabled_with_empty_datastore_skips_preamble() {
let env = TempEnvironment::builder().build();
let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
assert!(script.contains("No shell scripts or PATH additions"));
assert!(!script.contains("_dodot_prof"));
}
#[test]
fn shell_quoting_handles_paths_with_single_quotes() {
assert_eq!(sh_quote("plain"), "'plain'");
assert_eq!(sh_quote("it's"), "'it'\\''s'");
assert_eq!(sh_quote(""), "''");
}
}