use tracing::{debug, info};
use crate::handlers::HANDLER_PATH;
use crate::operations::{HandlerIntent, Operation, OperationResult};
use crate::Result;
use super::Executor;
impl<'a> Executor<'a> {
pub(super) fn execute_stage(&self, intent: &HandlerIntent) -> Result<Vec<OperationResult>> {
let HandlerIntent::Stage {
pack,
handler,
source,
} = intent
else {
unreachable!("execute_stage called with non-Stage intent");
};
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)
}
pub(super) fn simulate_stage(&self, intent: &HandlerIntent) -> Vec<OperationResult> {
let HandlerIntent::Stage {
pack,
handler,
source,
} = intent
else {
unreachable!("simulate_stage called with non-Stage intent");
};
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
}
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
}
}
#[cfg(test)]
mod tests {
use super::super::test_support::make_datastore;
use super::super::Executor;
use crate::fs::Fs;
use crate::operations::HandlerIntent;
use crate::paths::Pather;
use crate::testing::TempEnvironment;
#[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(),
env.paths.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 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(),
env.paths.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(),
env.paths.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(),
env.paths.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(),
env.paths.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(),
env.paths.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(),
env.paths.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(),
env.paths.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");
}
}
}