use std::fmt::Write;
use std::path::PathBuf;
use crate::fs::Fs;
use crate::paths::Pather;
use crate::Result;
pub fn generate_init_script(fs: &dyn Fs, paths: &dyn Pather) -> 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) {
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() {
writeln!(script, "# PATH additions").unwrap();
for (pack, target) in &path_additions {
writeln!(script, "# [{pack}]").unwrap();
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();
writeln!(
script,
"[ -f \"{}\" ] && . \"{}\"",
target.display(),
target.display()
)
.unwrap();
}
writeln!(script).unwrap();
}
Ok(script)
}
pub fn write_init_script(fs: &dyn Fs, paths: &dyn Pather) -> Result<PathBuf> {
let script_content = generate_init_script(fs, paths)?;
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)
}
#[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_minimal_script() {
let env = TempEnvironment::builder().build();
let script = generate_init_script(env.fs.as_ref(), env.paths.as_ref()).unwrap();
assert!(script.starts_with("#!/bin/sh"));
assert!(script.contains("Generated by dodot"));
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()).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()).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()).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()).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()).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()).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()).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()).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()).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"
);
}
}