use std::fmt::Write;
use std::path::{Path, PathBuf};
use crate::fs::Fs;
use crate::paths::Pather;
use crate::Result;
pub mod validate;
pub use validate::{
error_sidecar_path, validate_shell_sources, NoopSyntaxChecker, ShellValidationFailure,
ShellValidationReport, SyntaxCheckResult, SyntaxChecker, SystemSyntaxChecker, ERRORS_SUBDIR,
};
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_dir = &pack_entry.name;
let pack_display = crate::packs::display_name_for(pack_dir).to_string();
let shell_dir = paths.handler_data_dir(pack_dir, "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_display.clone(), target));
}
}
}
let path_dir = paths.handler_data_dir(pack_dir, "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_display.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 \"{p}\" ] && {{ . \"{p}\" || echo \"dodot: shell source exited $?: {p}\" >&2; }}",
p = 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,
" _dodot_err_file=\"${{_dodot_prof_file%.tsv}}.errors.log\""
)
.unwrap();
writeln!(script, " _dodot_err_tmp=\"$_dodot_prof_dir/.errtmp-$$\"").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_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"
)
.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, " if [ -s \"$_dodot_err_tmp\" ]; then").unwrap();
writeln!(script, " cat \"$_dodot_err_tmp\" >&2").unwrap();
writeln!(
script,
" [ -f \"$_dodot_err_file\" ] || printf '# dodot shell-init errors v1\\n' > \"$_dodot_err_file\" 2>/dev/null"
)
.unwrap();
writeln!(script, " {{").unwrap();
writeln!(
script,
" printf '@@\\t%s\\t%s\\n' {target_q} \"$_dodot_rc\""
)
.unwrap();
writeln!(script, " cat \"$_dodot_err_tmp\"").unwrap();
writeln!(script, " printf '\\n'").unwrap();
writeln!(script, " }} >> \"$_dodot_err_file\" 2>/dev/null").unwrap();
writeln!(script, " elif [ \"$_dodot_rc\" -ne 0 ]; then").unwrap();
writeln!(
script,
" echo \"dodot: shell source exited $_dodot_rc: {target_str}\" >&2"
)
.unwrap();
writeln!(script, " fi").unwrap();
writeln!(script, "else").unwrap();
writeln!(
script,
" [ -f \"{target_str}\" ] && {{ . \"{target_str}\" || echo \"dodot: shell source exited $?: {target_str}\" >&2; }}"
)
.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,
" [ -n \"${{_dodot_err_tmp:-}}\" ] && rm -f \"$_dodot_err_tmp\" 2>/dev/null"
)
.unwrap();
writeln!(script, "fi").unwrap();
writeln!(
script,
"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"
)
.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 \"{p}\" ] && {{ . \"{p}\" || echo \"dodot: shell source exited $?: {p}\" >&2; }}",
p = 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_captures_source_stderr_into_errors_log() {
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("_dodot_err_file=\"${_dodot_prof_file%.tsv}.errors.log\""),
"errors-log path must be a sibling of the profile TSV:\n{script}"
);
assert!(
script.contains("[ -f \"$_dodot_err_file\" ] || printf '# dodot shell-init errors v1"),
"errors-log header must be seeded lazily on first stderr:\n{script}"
);
assert!(
script.contains("2>\"$_dodot_err_tmp\""),
"source must redirect stderr to scratch file:\n{script}"
);
assert!(
script.contains(": > \"$_dodot_err_tmp\""),
"scratch file must be truncated before each source:\n{script}"
);
assert!(
script.contains("printf '@@\\t%s\\t%s\\n'"),
"errors-log records must use @@ header format:\n{script}"
);
}
#[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 profiled_source_initialises_rc_so_missing_file_isnt_reported_as_failure() {
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("_dodot_rc=0;"),
"profiled branch must seed _dodot_rc=0 before the source attempt:\n{script}"
);
assert!(
script.contains("&& { . "),
"profiled branch must guard the rc update inside `&& {{ … }}`:\n{script}"
);
}
#[test]
fn loud_failure_wrapper_present_in_both_modes() {
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 plain = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false).unwrap();
assert!(
plain.contains("dodot: shell source exited $?:"),
"plain script missing loud-failure echo:\n{plain}"
);
let timed = generate_init_script(env.fs.as_ref(), env.paths.as_ref(), true).unwrap();
assert!(
timed.contains("echo \"dodot: shell source exited $_dodot_rc:"),
"timed script missing silent-failure echo:\n{timed}"
);
assert!(
timed.contains("dodot: shell source exited $?:"),
"timed script missing fallback-branch echo:\n{timed}"
);
assert!(
timed.contains("cat \"$_dodot_err_tmp\" >&2"),
"timed script must echo captured stderr to user's TTY:\n{timed}"
);
}
#[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(""), "''");
}
}