use std::fmt::Write;
use std::path::{Path, PathBuf};
use crate::fs::Fs;
use crate::paths::Pather;
use crate::Result;
pub mod activation;
pub mod homebrew;
pub mod probe;
pub mod rc;
pub mod trace;
pub mod validate;
pub use activation::{ActivationNotice, ActivationState, INIT_GEN_ENV, INIT_VERSION_ENV};
pub use homebrew::{
BrewBlocks, BrewBootstrapMode, BrewCapture, BrewHost, CaptureFailure, PersistedCapture,
};
pub use probe::ProbePolicy;
pub use rc::ShellEnv;
pub use validate::{
error_sidecar_path, validate_shell_sources, NoopSyntaxChecker, ShellValidationFailure,
ShellValidationReport, SyntaxCheckResult, SyntaxChecker, SystemSyntaxChecker, ERRORS_SUBDIR,
};
pub const EMPTY_SCRIPT_MARKER: &str = "# No shell scripts or PATH additions to load.";
pub fn script_has_contributions(script: &str) -> bool {
!script
.lines()
.any(|line| line.trim() == EMPTY_SCRIPT_MARKER)
}
fn append_empty_notice(script: &mut String) {
writeln!(script, "{EMPTY_SCRIPT_MARKER}").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,
generation: u64,
homebrew: Option<&BrewBlocks>,
) -> 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();
emit_activation_evidence(&mut script, generation, &paths.hookup_heartbeat_path());
if let Some(blocks) = homebrew {
homebrew::emit_homebrew_block(&mut script, blocks);
}
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,
homebrew: Option<&BrewBlocks>,
) -> Result<PathBuf> {
let generation = activation::current_generation();
let script_content = generate_init_script(fs, paths, profiling_enabled, generation, homebrew)?;
let script_path = paths.init_script_path();
fs.mkdir_all(&paths.probes_hookup_dir())?;
fs.mkdir_all(paths.shell_dir())?;
fs.write_atomic_with_mode(&script_path, script_content.as_bytes(), 0o755)?;
Ok(script_path)
}
fn emit_activation_evidence(script: &mut String, generation: u64, heartbeat_path: &Path) {
let heartbeat = sh_quote(&heartbeat_path.display().to_string());
let version = activation::running_version();
writeln!(script, "# ── dodot activation evidence ──").unwrap();
writeln!(script, "export {}={generation}", activation::INIT_GEN_ENV).unwrap();
writeln!(script, "export {}={version}", activation::INIT_VERSION_ENV).unwrap();
writeln!(
script,
"echo {generation} {version} >| {heartbeat} 2>/dev/null || :"
)
.unwrap();
writeln!(script).unwrap();
}
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;
const TEST_GEN: u64 = 1_755_200_000;
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, TEST_GEN, None)
.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, TEST_GEN, None)
.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, TEST_GEN, None)
.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, TEST_GEN, None)
.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, None).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 init_script_is_replaced_by_rename_not_truncated_in_place() {
let env = TempEnvironment::builder().build();
let path = write_init_script(env.fs.as_ref(), env.paths.as_ref(), false, None).unwrap();
let first = env.fs.read_to_string(&path).unwrap();
let witness = path.parent().unwrap().join("witness.sh");
std::fs::hard_link(&path, &witness).unwrap();
let blocks = BrewBlocks {
prefix: PathBuf::from("/opt/homebrew"),
sh: "export HOMEBREW_PREFIX=/opt/homebrew;\n".to_string(),
zsh: "export HOMEBREW_PREFIX=/opt/homebrew;\n".to_string(),
};
write_init_script(env.fs.as_ref(), env.paths.as_ref(), false, Some(&blocks)).unwrap();
let second = env.fs.read_to_string(&path).unwrap();
assert_ne!(
first, second,
"the two writes must differ for the witness to prove anything"
);
assert_eq!(
env.fs.read_to_string(&witness).unwrap(),
first,
"the old inode was rewritten: the init script is being \
truncated in place, so a shell starting mid-write can \
source a prefix of it"
);
let leftovers: Vec<String> = env
.fs
.read_dir(path.parent().unwrap())
.unwrap()
.into_iter()
.map(|entry| entry.name)
.filter(|name| name.ends_with(".tmp"))
.collect();
assert_eq!(leftovers, Vec::<String>::new());
}
#[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, TEST_GEN, None)
.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, TEST_GEN, None)
.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, TEST_GEN, None)
.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, TEST_GEN, None)
.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, TEST_GEN, None)
.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"
);
}
fn sample_brew_blocks() -> BrewBlocks {
BrewBlocks {
prefix: PathBuf::from("/opt/homebrew"),
sh: "export HOMEBREW_PREFIX=\"/opt/homebrew\";\n\
eval \"$(/usr/bin/env PATH_HELPER_ROOT=\"/opt/homebrew\" /usr/libexec/path_helper -s)\"\n"
.to_string(),
zsh: "export HOMEBREW_PREFIX=\"/opt/homebrew\";\n\
fpath[1,0]=\"/opt/homebrew/share/zsh/site-functions\";\n\
export FPATH;\n"
.to_string(),
}
}
#[test]
fn homebrew_block_precedes_the_first_pack_path_addition() {
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,
TEST_GEN,
Some(&sample_brew_blocks()),
)
.unwrap();
let brew_pos = script.find("# ── Homebrew environment ──").unwrap();
let path_pos = script.find("# PATH additions").unwrap();
let export_pos = script.find("export PATH=\"").unwrap();
let source_pos = script.find("# Shell scripts").unwrap();
assert!(
brew_pos < path_pos && brew_pos < export_pos && brew_pos < source_pos,
"Homebrew block must come first, script:\n{script}"
);
}
#[test]
fn the_evidence_block_precedes_the_homebrew_block_precedes_the_packs() {
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,
TEST_GEN,
Some(&sample_brew_blocks()),
)
.unwrap();
let positions = [
("evidence header", "# ── dodot activation evidence ──"),
("generation export", "export DODOT_INIT_GEN="),
("version export", "export DODOT_INIT_VERSION="),
("heartbeat redirect", "echo "),
("Homebrew block", "# ── Homebrew environment ──"),
("PATH additions", "# PATH additions"),
("shell sources", "# Shell scripts"),
]
.map(|(label, needle)| {
(
label,
script
.find(needle)
.unwrap_or_else(|| panic!("missing {label} ({needle}), script:\n{script}")),
)
});
for pair in positions.windows(2) {
let [(before, at), (after, then)] = pair else {
unreachable!()
};
assert!(
at < then,
"{before} must precede {after}, script:\n{script}"
);
}
}
#[test]
fn homebrew_block_survives_an_empty_datastore() {
let env = TempEnvironment::builder().build();
let script = generate_init_script(
env.fs.as_ref(),
env.paths.as_ref(),
false,
TEST_GEN,
Some(&sample_brew_blocks()),
)
.unwrap();
assert!(
script.contains("# ── Homebrew environment ──"),
"script:\n{script}"
);
assert!(script.contains("HOMEBREW_PREFIX"), "script:\n{script}");
assert!(script.contains("No shell scripts or PATH additions"));
}
#[test]
fn no_capture_means_no_homebrew_lines_at_all() {
let env = TempEnvironment::builder().build();
let script =
generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false, TEST_GEN, None)
.unwrap();
assert!(!script.contains("Homebrew"), "script:\n{script}");
assert!(!script.contains("HOMEBREW"), "script:\n{script}");
assert!(!script.contains("brew"), "script:\n{script}");
}
#[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, TEST_GEN, None)
.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, TEST_GEN, None)
.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, TEST_GEN, None)
.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, TEST_GEN, None)
.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, TEST_GEN, None)
.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, TEST_GEN, None)
.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, TEST_GEN, None)
.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, TEST_GEN, None)
.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, TEST_GEN, None)
.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 evidence_is_emitted_unconditionally_in_every_script_shape() {
let empty = TempEnvironment::builder().build();
let populated = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.done()
.build();
let ds = make_datastore(&populated);
ds.create_data_link(
"vim",
"shell",
&populated.dotfiles_root.join("vim/aliases.sh"),
)
.unwrap();
let shapes = [
("empty datastore", &empty, false),
("empty datastore, profiled", &empty, true),
("populated", &populated, false),
("populated, profiled", &populated, true),
];
for (label, env, profiling) in shapes {
let script = generate_init_script(
env.fs.as_ref(),
env.paths.as_ref(),
profiling,
TEST_GEN,
None,
)
.unwrap();
assert!(
script.contains(&format!("export DODOT_INIT_GEN={TEST_GEN}")),
"{label}: missing generation stamp:\n{script}"
);
assert!(
script.contains(&format!(
"export DODOT_INIT_VERSION={}",
activation::running_version()
)),
"{label}: missing version stamp:\n{script}"
);
assert!(
script.contains(&format!(
"echo {TEST_GEN} {} >| '{}' 2>/dev/null || :",
activation::running_version(),
env.paths.hookup_heartbeat_path().display()
)),
"{label}: missing heartbeat write:\n{script}"
);
assert_eq!(
activation::parse_script_generation(&script),
Some(TEST_GEN),
"{label}: generation must round-trip out of the script"
);
}
}
#[test]
fn the_heartbeat_write_survives_noclobber_in_every_shell() {
let env = TempEnvironment::builder().build();
let heartbeat = env.paths.hookup_heartbeat_path();
env.fs.mkdir_all(&env.paths.probes_hookup_dir()).unwrap();
let script =
generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false, TEST_GEN, None)
.unwrap();
let script_path = env.home.join("dodot-init.sh");
env.fs.write_file(&script_path, script.as_bytes()).unwrap();
for shell in ["/bin/sh", "/bin/bash", "/bin/zsh"] {
if !Path::new(shell).exists() {
continue;
}
env.fs.write_file(&heartbeat, b"1 0.0.0\n").unwrap();
let status = std::process::Command::new(shell)
.arg("-c")
.arg(format!("set -C; . '{}'", script_path.display()))
.status()
.expect("the shell runs");
assert!(status.success(), "{shell}: sourcing the script failed");
assert_eq!(
env.fs.read_to_string(&heartbeat).unwrap().trim(),
format!("{TEST_GEN} {}", activation::running_version()),
"{shell}: noclobber must not freeze the heartbeat"
);
}
}
#[test]
fn evidence_costs_two_exports_and_one_redirect() {
let env = TempEnvironment::builder().build();
let script =
generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false, TEST_GEN, None)
.unwrap();
let evidence: Vec<&str> = script
.lines()
.filter(|l| l.contains("DODOT_INIT") || l.contains("heartbeat"))
.collect();
assert_eq!(evidence.len(), 3, "evidence block: {evidence:?}");
assert!(evidence[0].starts_with("export DODOT_INIT_GEN="));
assert!(evidence[1].starts_with("export DODOT_INIT_VERSION="));
assert!(evidence[2].starts_with("echo "));
for forbidden in ["mkdir", "dodot ", "date", "$(", "`"] {
assert!(
!evidence.iter().any(|l| l.contains(forbidden)),
"evidence must not run `{forbidden}`: {evidence:?}"
);
}
}
#[test]
fn write_init_script_stamps_generation_and_creates_heartbeat_dir() {
let env = TempEnvironment::builder().build();
let path = write_init_script(env.fs.as_ref(), env.paths.as_ref(), false, None).unwrap();
assert!(
env.fs.is_dir(&env.paths.probes_hookup_dir()),
"heartbeat dir must exist before any shell writes the marker"
);
let gen = activation::read_script_generation(env.fs.as_ref(), env.paths.as_ref())
.expect("written script must carry a generation");
assert!(gen > 1_700_000_000, "generation should be a unix ts: {gen}");
let content = env.fs.read_to_string(&path).unwrap();
assert!(content.contains(&format!("export DODOT_INIT_GEN={gen}")));
assert!(!env.fs.exists(&env.paths.hookup_heartbeat_path()));
}
#[test]
fn generated_script_parses_in_sh_bash_and_zsh() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.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 brew = sample_brew_blocks();
for profiling in [false, true] {
for homebrew in [None, Some(&brew)] {
let path =
write_init_script(env.fs.as_ref(), env.paths.as_ref(), profiling, homebrew)
.unwrap();
for shell in ["sh", "bash", "zsh"] {
let out = std::process::Command::new(shell)
.arg("-n")
.arg(&path)
.output();
let Ok(out) = out else {
continue; };
assert!(
out.status.success(),
"{shell} -n rejected the generated script (profiling={profiling}, homebrew={}): {}",
homebrew.is_some(),
String::from_utf8_lossy(&out.stderr)
);
}
}
}
}
#[test]
fn sourcing_the_script_exports_the_stamp_and_writes_the_heartbeat() {
let env = TempEnvironment::builder().build();
let path = write_init_script(env.fs.as_ref(), env.paths.as_ref(), false, None).unwrap();
let gen = activation::read_script_generation(env.fs.as_ref(), env.paths.as_ref()).unwrap();
let version = activation::running_version();
let out = std::process::Command::new("sh")
.arg("-c")
.arg(format!(
". '{}'; printf '%s %s' \"$DODOT_INIT_GEN\" \"$DODOT_INIT_VERSION\"",
path.display()
))
.env_remove(activation::INIT_GEN_ENV)
.env_remove(activation::INIT_VERSION_ENV)
.output()
.expect("sh is required to run dodot's own init script");
assert!(out.status.success(), "sourcing failed: {out:?}");
assert_eq!(
String::from_utf8_lossy(&out.stdout),
format!("{gen} {version}"),
"sourcing must export both the generation and the version"
);
let heartbeat = activation::read_heartbeat(env.fs.as_ref(), env.paths.as_ref())
.expect("sourcing must leave a heartbeat");
assert_eq!(heartbeat.generation, gen);
assert_eq!(heartbeat.version.as_deref(), Some(version));
}
#[test]
fn an_empty_script_is_readable_as_having_no_contributions() {
let env = TempEnvironment::builder().build();
let empty =
generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false, 100, None).unwrap();
assert!(!script_has_contributions(&empty), "{empty}");
let env = TempEnvironment::builder().build();
let shell_dir = env.paths.handler_data_dir("vim", "shell");
env.fs.mkdir_all(&shell_dir).unwrap();
let target = env.home.join("aliases.sh");
env.fs.write_file(&target, b"alias v=vim").unwrap();
env.fs
.symlink(&target, &shell_dir.join("aliases.sh"))
.unwrap();
let deployed =
generate_init_script(env.fs.as_ref(), env.paths.as_ref(), false, 100, None).unwrap();
assert!(script_has_contributions(&deployed), "{deployed}");
}
#[test]
fn sourcing_under_sh_takes_the_sh_branch_of_the_homebrew_block() {
let env = TempEnvironment::builder().build();
let blocks = BrewBlocks {
prefix: PathBuf::from("/fake/brew"),
sh: "export HOMEBREW_PREFIX=\"/fake/brew\";\n".to_string(),
zsh: "export HOMEBREW_PREFIX=\"/fake/brew\";\n\
fpath[1,0]=\"/fake/brew/share/zsh/site-functions\";\n\
export FPATH;\n"
.to_string(),
};
let path =
write_init_script(env.fs.as_ref(), env.paths.as_ref(), false, Some(&blocks)).unwrap();
let out = std::process::Command::new("sh")
.arg("-c")
.arg(format!(
". '{}'; printf '%s|%s' \"$HOMEBREW_PREFIX\" \"${{FPATH:-}}\"",
path.display()
))
.env_remove("HOMEBREW_PREFIX")
.env_remove("FPATH")
.output()
.expect("sh is required to run dodot's own init script");
assert!(out.status.success(), "sourcing failed: {out:?}");
assert_eq!(String::from_utf8_lossy(&out.stdout), "/fake/brew|");
assert!(
out.stderr.is_empty(),
"the sh branch must not touch zsh-only lines: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn concurrent_sources_leave_an_intact_heartbeat() {
let env = TempEnvironment::builder().build();
let path = write_init_script(env.fs.as_ref(), env.paths.as_ref(), false, None).unwrap();
let gen = activation::read_script_generation(env.fs.as_ref(), env.paths.as_ref()).unwrap();
let children: Vec<_> = (0..8)
.filter_map(|_| {
std::process::Command::new("sh")
.arg("-c")
.arg(format!(". '{}'", path.display()))
.spawn()
.ok()
})
.collect();
for mut child in children {
let _ = child.wait();
}
assert_eq!(
activation::read_heartbeat(env.fs.as_ref(), env.paths.as_ref()).map(|h| h.generation),
Some(gen)
);
}
#[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(""), "''");
}
}