use tracing::{debug, info};
use crate::datastore::DataStore;
use crate::fs::Fs;
use crate::handlers::HANDLER_PATH;
use crate::operations::{HandlerIntent, Operation, OperationResult};
use crate::Result;
pub struct Executor<'a> {
datastore: &'a dyn DataStore,
fs: &'a dyn Fs,
dry_run: bool,
force: bool,
provision_rerun: bool,
auto_chmod_exec: bool,
}
impl<'a> Executor<'a> {
pub fn new(
datastore: &'a dyn DataStore,
fs: &'a dyn Fs,
dry_run: bool,
force: bool,
provision_rerun: bool,
auto_chmod_exec: bool,
) -> Self {
Self {
datastore,
fs,
dry_run,
force,
provision_rerun,
auto_chmod_exec,
}
}
pub fn execute(&self, intents: Vec<HandlerIntent>) -> Result<Vec<OperationResult>> {
debug!(
count = intents.len(),
dry_run = self.dry_run,
force = self.force,
"executor starting"
);
let mut results = Vec::new();
for intent in intents {
let intent_results = if self.dry_run {
self.simulate(&intent)
} else {
self.execute_one(&intent)?
};
results.extend(intent_results);
}
let succeeded = results.iter().filter(|r| r.success).count();
let failed = results.iter().filter(|r| !r.success).count();
debug!(succeeded, failed, "executor finished");
Ok(results)
}
fn execute_one(&self, intent: &HandlerIntent) -> Result<Vec<OperationResult>> {
match intent {
HandlerIntent::Link {
pack,
handler,
source,
user_path,
} => {
debug!(
pack,
handler,
source = %source.display(),
user_path = %user_path.display(),
"executing link intent"
);
if !self.fs.is_symlink(user_path) && self.fs.exists(user_path) {
let content_equivalent =
crate::equivalence::is_equivalent(user_path, source, self.fs);
if self.force || content_equivalent {
if content_equivalent {
info!(
pack,
path = %user_path.display(),
"auto-replacing content-equivalent file with dodot symlink"
);
} else {
info!(
pack,
path = %user_path.display(),
"force-removing existing file"
);
}
if self.fs.is_dir(user_path) {
self.fs.remove_dir_all(user_path)?;
} else {
self.fs.remove_file(user_path)?;
}
} else {
info!(
pack,
path = %user_path.display(),
"conflict: file already exists"
);
let op = Operation::CreateUserLink {
pack: pack.clone(),
handler: handler.clone(),
datastore_path: Default::default(),
user_path: user_path.clone(),
};
return Ok(vec![OperationResult::fail(
op,
format!(
"conflict: {} already exists (use --force to overwrite)",
user_path.display()
),
)]);
}
}
let datastore_path = self.datastore.create_data_link(pack, handler, source)?;
debug!(
pack,
datastore_path = %datastore_path.display(),
"created data link"
);
self.datastore
.create_user_link(&datastore_path, user_path)?;
let filename = source.file_name().unwrap_or_default().to_string_lossy();
info!(
pack,
file = %filename,
target = %user_path.display(),
"created symlink"
);
let op = Operation::CreateUserLink {
pack: pack.clone(),
handler: handler.clone(),
datastore_path: datastore_path.clone(),
user_path: user_path.clone(),
};
Ok(vec![OperationResult::ok(
op,
format!("{} → {}", filename, user_path.display()),
)])
}
HandlerIntent::Stage {
pack,
handler,
source,
} => {
let filename = source.file_name().unwrap_or_default().to_string_lossy();
info!(pack, handler = handler.as_str(), file = %filename, "staging file");
self.datastore.create_data_link(pack, handler, source)?;
let op = Operation::CreateDataLink {
pack: pack.clone(),
handler: handler.clone(),
source: source.clone(),
};
let mut results = vec![OperationResult::ok(op, format!("staged {}", filename))];
if handler == HANDLER_PATH && self.auto_chmod_exec {
debug!(pack, source = %source.display(), "checking executable permissions");
results.extend(self.ensure_executable(pack, source));
}
Ok(results)
}
HandlerIntent::Run {
pack,
handler,
executable,
arguments,
sentinel,
} => {
if !self.provision_rerun {
let already_done = self.datastore.has_sentinel(pack, handler, sentinel)?;
if already_done {
info!(
pack,
handler = handler.as_str(),
sentinel,
"sentinel found, skipping"
);
let op = Operation::CheckSentinel {
pack: pack.clone(),
handler: handler.clone(),
sentinel: sentinel.clone(),
};
return Ok(vec![OperationResult::ok(op, "already completed")]);
}
}
let cmd_str = format!("{} {}", executable, arguments.join(" "));
info!(pack, handler = handler.as_str(), command = %cmd_str.trim(), "running command");
self.datastore.run_and_record(
pack,
handler,
executable,
arguments,
sentinel,
self.provision_rerun,
)?;
info!(pack, sentinel, "command completed, sentinel recorded");
let op = Operation::RunCommand {
pack: pack.clone(),
handler: handler.clone(),
executable: executable.clone(),
arguments: arguments.clone(),
sentinel: sentinel.clone(),
};
Ok(vec![OperationResult::ok(
op,
format!("executed: {}", cmd_str.trim()),
)])
}
}
}
fn ensure_executable(&self, pack: &str, dir: &std::path::Path) -> Vec<OperationResult> {
let mut results = Vec::new();
let entries = match self.fs.read_dir(dir) {
Ok(e) => e,
Err(e) => {
let op = Operation::CreateDataLink {
pack: pack.into(),
handler: HANDLER_PATH.into(),
source: dir.to_path_buf(),
};
results.push(OperationResult::ok(
op,
format!(
"warning: could not list {} for auto-chmod: {}",
dir.display(),
e
),
));
return results;
}
};
for entry in entries {
if !entry.is_file {
continue;
}
let meta = match self.fs.stat(&entry.path) {
Ok(m) => m,
Err(e) => {
let op = Operation::CreateDataLink {
pack: pack.into(),
handler: HANDLER_PATH.into(),
source: entry.path.clone(),
};
results.push(OperationResult::ok(
op,
format!("warning: could not stat {}: {}", entry.name, e),
));
continue;
}
};
let is_exec = meta.mode & 0o111 != 0;
if is_exec {
continue;
}
let new_mode = meta.mode | 0o111;
let op = Operation::CreateDataLink {
pack: pack.into(),
handler: HANDLER_PATH.into(),
source: entry.path.clone(),
};
match self.fs.set_permissions(&entry.path, new_mode) {
Ok(()) => {
info!(pack, file = %entry.name, mode = format!("{:o}", new_mode), "chmod +x");
results.push(OperationResult::ok(op, format!("chmod +x {}", entry.name)));
}
Err(e) => {
info!(pack, file = %entry.name, error = %e, "chmod +x failed");
results.push(OperationResult::ok(
op,
format!("warning: could not chmod +x {}: {}", entry.name, e),
));
}
}
}
results
}
fn report_non_executable(&self, pack: &str, dir: &std::path::Path) -> Vec<OperationResult> {
let mut results = Vec::new();
let entries = match self.fs.read_dir(dir) {
Ok(e) => e,
Err(_) => return results,
};
for entry in entries {
if !entry.is_file {
continue;
}
let meta = match self.fs.stat(&entry.path) {
Ok(m) => m,
Err(_) => continue,
};
let is_exec = meta.mode & 0o111 != 0;
if !is_exec {
let op = Operation::CreateDataLink {
pack: pack.into(),
handler: HANDLER_PATH.into(),
source: entry.path.clone(),
};
results.push(OperationResult::ok(
op,
format!("[dry-run] would chmod +x {}", entry.name),
));
}
}
results
}
fn simulate(&self, intent: &HandlerIntent) -> Vec<OperationResult> {
match intent {
HandlerIntent::Link {
pack,
handler,
source,
user_path,
} => {
if !self.fs.is_symlink(user_path) && self.fs.exists(user_path) {
if self.force {
return vec![OperationResult::ok(
Operation::CreateUserLink {
pack: pack.clone(),
handler: handler.clone(),
datastore_path: Default::default(),
user_path: user_path.clone(),
},
format!(
"[dry-run] would overwrite {} → {}",
source.file_name().unwrap_or_default().to_string_lossy(),
user_path.display()
),
)];
} else {
return vec![OperationResult::fail(
Operation::CreateUserLink {
pack: pack.clone(),
handler: handler.clone(),
datastore_path: Default::default(),
user_path: user_path.clone(),
},
format!(
"conflict: {} already exists (use --force to overwrite)",
user_path.display()
),
)];
}
}
vec![OperationResult::ok(
Operation::CreateUserLink {
pack: pack.clone(),
handler: handler.clone(),
datastore_path: Default::default(),
user_path: user_path.clone(),
},
format!(
"[dry-run] would link {} → {}",
source.file_name().unwrap_or_default().to_string_lossy(),
user_path.display()
),
)]
}
HandlerIntent::Stage {
pack,
handler,
source,
} => {
let mut results = vec![OperationResult::ok(
Operation::CreateDataLink {
pack: pack.clone(),
handler: handler.clone(),
source: source.clone(),
},
format!(
"[dry-run] would stage: {}",
source.file_name().unwrap_or_default().to_string_lossy()
),
)];
if handler == HANDLER_PATH && self.auto_chmod_exec {
results.extend(self.report_non_executable(pack, source));
}
results
}
HandlerIntent::Run {
pack,
handler,
executable,
arguments,
sentinel,
} => {
let cmd_str = format!("{} {}", executable, arguments.join(" "));
vec![OperationResult::ok(
Operation::RunCommand {
pack: pack.clone(),
handler: handler.clone(),
executable: executable.clone(),
arguments: arguments.clone(),
sentinel: sentinel.clone(),
},
format!("[dry-run] would execute: {}", cmd_str.trim()),
)]
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::datastore::{CommandOutput, CommandRunner, FilesystemDataStore};
use crate::paths::Pather;
use crate::testing::TempEnvironment;
use std::sync::{Arc, Mutex};
struct MockCommandRunner {
calls: Mutex<Vec<String>>,
}
impl MockCommandRunner {
fn new() -> Self {
Self {
calls: Mutex::new(Vec::new()),
}
}
}
impl CommandRunner for MockCommandRunner {
fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput> {
let cmd_str = format!("{} {}", executable, arguments.join(" "));
self.calls.lock().unwrap().push(cmd_str.trim().to_string());
Ok(CommandOutput {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
})
}
}
fn make_datastore(env: &TempEnvironment) -> (FilesystemDataStore, Arc<MockCommandRunner>) {
let runner = Arc::new(MockCommandRunner::new());
let ds = FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), runner.clone());
(ds, runner)
}
#[test]
fn execute_link_creates_double_link() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.done()
.build();
let (ds, _) = make_datastore(&env);
let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
let source = env.dotfiles_root.join("vim/vimrc");
let user_path = env.home.join(".vimrc");
let results = executor
.execute(vec![HandlerIntent::Link {
pack: "vim".into(),
handler: "symlink".into(),
source: source.clone(),
user_path: user_path.clone(),
}])
.unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].success);
env.assert_double_link("vim", "symlink", "vimrc", &source, &user_path);
}
#[test]
fn execute_link_conflict_returns_failed_result() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.done()
.home_file(".vimrc", "existing content")
.build();
let (ds, _) = make_datastore(&env);
let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
let source = env.dotfiles_root.join("vim/vimrc");
let user_path = env.home.join(".vimrc");
let results = executor
.execute(vec![HandlerIntent::Link {
pack: "vim".into(),
handler: "symlink".into(),
source: source.clone(),
user_path: user_path.clone(),
}])
.unwrap();
assert_eq!(results.len(), 1);
assert!(!results[0].success, "should report conflict");
assert!(
results[0].message.contains("conflict"),
"msg: {}",
results[0].message
);
assert!(
results[0].message.contains("--force"),
"msg: {}",
results[0].message
);
env.assert_no_handler_state("vim", "symlink");
env.assert_file_contents(&user_path, "existing content");
}
#[test]
fn execute_link_force_overwrites_existing_file() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.done()
.home_file(".vimrc", "existing content")
.build();
let (ds, _) = make_datastore(&env);
let executor = Executor::new(&ds, env.fs.as_ref(), false, true, false, true);
let source = env.dotfiles_root.join("vim/vimrc");
let user_path = env.home.join(".vimrc");
let results = executor
.execute(vec![HandlerIntent::Link {
pack: "vim".into(),
handler: "symlink".into(),
source: source.clone(),
user_path: user_path.clone(),
}])
.unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].success, "force should succeed");
env.assert_double_link("vim", "symlink", "vimrc", &source, &user_path);
let content = env.fs.read_to_string(&user_path).unwrap();
assert_eq!(content, "set nocompatible");
}
#[test]
fn execute_link_conflict_does_not_block_other_intents() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.file("gvimrc", "set guifont=Mono")
.done()
.home_file(".vimrc", "existing content")
.build();
let (ds, _) = make_datastore(&env);
let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
let results = executor
.execute(vec![
HandlerIntent::Link {
pack: "vim".into(),
handler: "symlink".into(),
source: env.dotfiles_root.join("vim/vimrc"),
user_path: env.home.join(".vimrc"),
},
HandlerIntent::Link {
pack: "vim".into(),
handler: "symlink".into(),
source: env.dotfiles_root.join("vim/gvimrc"),
user_path: env.home.join(".gvimrc"),
},
])
.unwrap();
assert_eq!(results.len(), 2);
assert!(!results[0].success);
assert!(results[1].success);
env.assert_double_link(
"vim",
"symlink",
"gvimrc",
&env.dotfiles_root.join("vim/gvimrc"),
&env.home.join(".gvimrc"),
);
}
#[test]
fn execute_stage_creates_data_link_only() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.done()
.build();
let (ds, _) = make_datastore(&env);
let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
let source = env.dotfiles_root.join("vim/aliases.sh");
let results = executor
.execute(vec![HandlerIntent::Stage {
pack: "vim".into(),
handler: "shell".into(),
source: source.clone(),
}])
.unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].success);
let datastore_link = env
.paths
.handler_data_dir("vim", "shell")
.join("aliases.sh");
env.assert_symlink(&datastore_link, &source);
}
#[test]
fn execute_run_creates_sentinel() {
let env = TempEnvironment::builder().build();
let (ds, runner) = make_datastore(&env);
let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
let results = executor
.execute(vec![HandlerIntent::Run {
pack: "vim".into(),
handler: "install".into(),
executable: "echo".into(),
arguments: vec!["hello".into()],
sentinel: "install.sh-abc123".into(),
}])
.unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].success);
assert_eq!(runner.calls.lock().unwrap().as_slice(), &["echo hello"]);
env.assert_sentinel("vim", "install", "install.sh-abc123");
}
#[test]
fn execute_run_skips_when_sentinel_exists() {
let env = TempEnvironment::builder().build();
let (ds, runner) = make_datastore(&env);
let sentinel_dir = env.paths.handler_data_dir("vim", "install");
env.fs.mkdir_all(&sentinel_dir).unwrap();
env.fs
.write_file(&sentinel_dir.join("install.sh-abc123"), b"completed|12345")
.unwrap();
let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
let results = executor
.execute(vec![HandlerIntent::Run {
pack: "vim".into(),
handler: "install".into(),
executable: "echo".into(),
arguments: vec!["should-not-run".into()],
sentinel: "install.sh-abc123".into(),
}])
.unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].success);
assert!(results[0].message.contains("already completed"));
assert!(runner.calls.lock().unwrap().is_empty());
}
#[test]
fn provision_rerun_ignores_sentinel() {
let env = TempEnvironment::builder().build();
let (ds, runner) = make_datastore(&env);
let sentinel_dir = env.paths.handler_data_dir("vim", "install");
env.fs.mkdir_all(&sentinel_dir).unwrap();
env.fs
.write_file(&sentinel_dir.join("install.sh-abc123"), b"completed|12345")
.unwrap();
let executor = Executor::new(&ds, env.fs.as_ref(), false, false, true, true);
let results = executor
.execute(vec![HandlerIntent::Run {
pack: "vim".into(),
handler: "install".into(),
executable: "echo".into(),
arguments: vec!["rerun".into()],
sentinel: "install.sh-abc123".into(),
}])
.unwrap();
assert_eq!(results.len(), 1);
assert!(results[0].success);
assert!(
results[0].message.contains("executed"),
"msg: {}",
results[0].message
);
assert_eq!(runner.calls.lock().unwrap().as_slice(), &["echo rerun"]);
}
#[test]
fn dry_run_does_not_modify_filesystem() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.build();
let (ds, _) = make_datastore(&env);
let executor = Executor::new(&ds, env.fs.as_ref(), true, false, false, true);
let results = executor
.execute(vec![
HandlerIntent::Link {
pack: "vim".into(),
handler: "symlink".into(),
source: env.dotfiles_root.join("vim/vimrc"),
user_path: env.home.join(".vimrc"),
},
HandlerIntent::Stage {
pack: "vim".into(),
handler: "shell".into(),
source: env.dotfiles_root.join("vim/vimrc"),
},
HandlerIntent::Run {
pack: "vim".into(),
handler: "install".into(),
executable: "echo".into(),
arguments: vec!["hi".into()],
sentinel: "s1".into(),
},
])
.unwrap();
assert_eq!(results.len(), 3); for r in &results {
assert!(r.success);
assert!(r.message.contains("[dry-run]"), "msg: {}", r.message);
}
env.assert_not_exists(&env.home.join(".vimrc"));
env.assert_no_handler_state("vim", "symlink");
env.assert_no_handler_state("vim", "shell");
env.assert_no_handler_state("vim", "install");
}
#[test]
fn dry_run_detects_conflict() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.home_file(".vimrc", "existing")
.build();
let (ds, _) = make_datastore(&env);
let executor = Executor::new(&ds, env.fs.as_ref(), true, false, false, true);
let results = executor
.execute(vec![HandlerIntent::Link {
pack: "vim".into(),
handler: "symlink".into(),
source: env.dotfiles_root.join("vim/vimrc"),
user_path: env.home.join(".vimrc"),
}])
.unwrap();
assert_eq!(results.len(), 1);
assert!(!results[0].success);
assert!(results[0].message.contains("conflict"));
}
#[test]
fn execute_multiple_intents_sequentially() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.file("gvimrc", "set guifont=Mono")
.done()
.build();
let (ds, _) = make_datastore(&env);
let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
let results = executor
.execute(vec![
HandlerIntent::Link {
pack: "vim".into(),
handler: "symlink".into(),
source: env.dotfiles_root.join("vim/vimrc"),
user_path: env.home.join(".vimrc"),
},
HandlerIntent::Link {
pack: "vim".into(),
handler: "symlink".into(),
source: env.dotfiles_root.join("vim/gvimrc"),
user_path: env.home.join(".gvimrc"),
},
])
.unwrap();
assert_eq!(results.len(), 2); assert!(results.iter().all(|r| r.success));
env.assert_double_link(
"vim",
"symlink",
"vimrc",
&env.dotfiles_root.join("vim/vimrc"),
&env.home.join(".vimrc"),
);
env.assert_double_link(
"vim",
"symlink",
"gvimrc",
&env.dotfiles_root.join("vim/gvimrc"),
&env.home.join(".gvimrc"),
);
}
#[test]
fn path_stage_adds_execute_permission() {
let env = TempEnvironment::builder()
.pack("tools")
.file("bin/mytool", "#!/bin/sh\necho hello")
.done()
.build();
let (ds, _) = make_datastore(&env);
let tool_path = env.dotfiles_root.join("tools/bin/mytool");
let meta_before = env.fs.stat(&tool_path).unwrap();
assert_eq!(
meta_before.mode & 0o111,
0,
"file should start non-executable"
);
let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
let results = executor
.execute(vec![HandlerIntent::Stage {
pack: "tools".into(),
handler: "path".into(),
source: env.dotfiles_root.join("tools/bin"),
}])
.unwrap();
assert!(results.len() >= 2, "results: {results:?}");
let chmod_result = results.iter().find(|r| r.message.contains("chmod +x"));
assert!(
chmod_result.is_some(),
"should have a chmod +x result: {results:?}"
);
assert!(chmod_result.unwrap().success);
let meta_after = env.fs.stat(&tool_path).unwrap();
assert_ne!(
meta_after.mode & 0o111,
0,
"file should be executable after up"
);
}
#[test]
fn path_stage_skips_already_executable() {
let env = TempEnvironment::builder()
.pack("tools")
.file("bin/mytool", "#!/bin/sh\necho hello")
.done()
.build();
let (ds, _) = make_datastore(&env);
let tool_path = env.dotfiles_root.join("tools/bin/mytool");
env.fs.set_permissions(&tool_path, 0o755).unwrap();
let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
let results = executor
.execute(vec![HandlerIntent::Stage {
pack: "tools".into(),
handler: "path".into(),
source: env.dotfiles_root.join("tools/bin"),
}])
.unwrap();
let chmod_results: Vec<_> = results
.iter()
.filter(|r| r.message.contains("chmod"))
.collect();
assert!(
chmod_results.is_empty(),
"already-executable file should not produce chmod result: {chmod_results:?}"
);
}
#[test]
fn path_stage_auto_chmod_disabled() {
let env = TempEnvironment::builder()
.pack("tools")
.file("bin/mytool", "#!/bin/sh\necho hello")
.done()
.build();
let (ds, _) = make_datastore(&env);
let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, false);
let results = executor
.execute(vec![HandlerIntent::Stage {
pack: "tools".into(),
handler: "path".into(),
source: env.dotfiles_root.join("tools/bin"),
}])
.unwrap();
let chmod_results: Vec<_> = results
.iter()
.filter(|r| r.message.contains("chmod"))
.collect();
assert!(
chmod_results.is_empty(),
"auto_chmod_exec=false should skip chmod: {chmod_results:?}"
);
let tool_path = env.dotfiles_root.join("tools/bin/mytool");
let meta = env.fs.stat(&tool_path).unwrap();
assert_eq!(meta.mode & 0o111, 0, "file should remain non-executable");
}
#[test]
fn path_stage_skips_directories() {
let env = TempEnvironment::builder()
.pack("tools")
.file("bin/subdir/nested", "#!/bin/sh")
.done()
.build();
let (ds, _) = make_datastore(&env);
let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
let results = executor
.execute(vec![HandlerIntent::Stage {
pack: "tools".into(),
handler: "path".into(),
source: env.dotfiles_root.join("tools/bin"),
}])
.unwrap();
let chmod_results: Vec<_> = results
.iter()
.filter(|r| r.message.contains("chmod"))
.collect();
for r in &chmod_results {
assert!(
!r.message.contains("subdir"),
"directories should not be chmod'd: {}",
r.message
);
}
}
#[test]
fn shell_stage_does_not_auto_chmod() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.done()
.build();
let (ds, _) = make_datastore(&env);
let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
let results = executor
.execute(vec![HandlerIntent::Stage {
pack: "vim".into(),
handler: "shell".into(),
source: env.dotfiles_root.join("vim/aliases.sh"),
}])
.unwrap();
let chmod_results: Vec<_> = results
.iter()
.filter(|r| r.message.contains("chmod"))
.collect();
assert!(
chmod_results.is_empty(),
"shell handler should not auto-chmod: {chmod_results:?}"
);
}
#[test]
fn dry_run_reports_non_executable_without_modifying() {
let env = TempEnvironment::builder()
.pack("tools")
.file("bin/mytool", "#!/bin/sh\necho hello")
.done()
.build();
let (ds, _) = make_datastore(&env);
let executor = Executor::new(&ds, env.fs.as_ref(), true, false, false, true);
let results = executor
.execute(vec![HandlerIntent::Stage {
pack: "tools".into(),
handler: "path".into(),
source: env.dotfiles_root.join("tools/bin"),
}])
.unwrap();
let chmod_results: Vec<_> = results
.iter()
.filter(|r| r.message.contains("chmod"))
.collect();
assert!(
!chmod_results.is_empty(),
"dry-run should report non-executable files"
);
assert!(chmod_results[0].message.contains("[dry-run]"));
let tool_path = env.dotfiles_root.join("tools/bin/mytool");
let meta = env.fs.stat(&tool_path).unwrap();
assert_eq!(
meta.mode & 0o111,
0,
"dry-run should not modify permissions"
);
}
#[test]
fn path_stage_auto_chmod_multiple_files() {
let env = TempEnvironment::builder()
.pack("tools")
.file("bin/tool-a", "#!/bin/sh\necho a")
.file("bin/tool-b", "#!/bin/sh\necho b")
.done()
.build();
let (ds, _) = make_datastore(&env);
let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
let results = executor
.execute(vec![HandlerIntent::Stage {
pack: "tools".into(),
handler: "path".into(),
source: env.dotfiles_root.join("tools/bin"),
}])
.unwrap();
let chmod_results: Vec<_> = results
.iter()
.filter(|r| r.message.contains("chmod +x"))
.collect();
assert_eq!(
chmod_results.len(),
2,
"should chmod both files: {chmod_results:?}"
);
for name in ["tool-a", "tool-b"] {
let path = env.dotfiles_root.join(format!("tools/bin/{name}"));
let meta = env.fs.stat(&path).unwrap();
assert_ne!(meta.mode & 0o111, 0, "{name} should be executable");
}
}
}