use std::io::Read;
use std::path::Path;
use sha2::{Digest, Sha256};
use crate::datastore::{CommandRunner, DataStore};
use crate::fs::Fs;
use crate::handlers::{ExecutionPhase, Handler, HandlerConfig, HandlerStatus};
use crate::operations::HandlerIntent;
use crate::paths::Pather;
use crate::rules::RuleMatch;
use crate::Result;
pub trait RunOnceCommand: Send + Sync {
fn handler_name(&self) -> &str;
fn phase(&self) -> ExecutionPhase;
fn command_for(&self, path: &Path) -> (String, Vec<String>);
fn validate(&self, _fs: &dyn Fs, _runner: &dyn CommandRunner, _path: &Path) -> Result<()> {
Ok(())
}
fn status_deployed(&self) -> &str {
"ran"
}
fn status_pending(&self) -> &str {
"never ran"
}
fn status_ran_different(&self) -> &str {
"older version"
}
}
pub struct RunOnceHandler<'a, C: RunOnceCommand> {
fs: &'a dyn Fs,
runner: &'a dyn CommandRunner,
cmd: C,
}
impl<'a, C: RunOnceCommand> RunOnceHandler<'a, C> {
pub fn new(fs: &'a dyn Fs, runner: &'a dyn CommandRunner, cmd: C) -> Self {
Self { fs, runner, cmd }
}
pub fn command(&self) -> &C {
&self.cmd
}
}
impl<C: RunOnceCommand> Handler for RunOnceHandler<'_, C> {
fn name(&self) -> &str {
self.cmd.handler_name()
}
fn phase(&self) -> ExecutionPhase {
self.cmd.phase()
}
fn to_intents(
&self,
matches: &[RuleMatch],
_config: &HandlerConfig,
_paths: &dyn Pather,
_fs: &dyn Fs,
) -> Result<Vec<HandlerIntent>> {
let mut intents = Vec::new();
for m in matches {
if m.is_dir {
continue;
}
let has_rendered = m.rendered_bytes.is_some();
let has_disk = self.fs.exists(&m.absolute_path);
if !has_rendered && !has_disk {
tracing::debug!(
pack = %m.pack,
file = %m.absolute_path.display(),
handler = self.cmd.handler_name(),
"skipping run-once intent — no rendered bytes and no on-disk file \
(first-time-pack passive placeholder)"
);
continue;
}
self.cmd.validate(self.fs, self.runner, &m.absolute_path)?;
let checksum = match m.rendered_bytes.as_deref() {
Some(bytes) => file_checksum_bytes(bytes),
None => file_checksum(self.fs, &m.absolute_path)?,
};
let filename = m
.relative_path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let sentinel = format!("{filename}-{checksum}");
let (executable, arguments) = self.cmd.command_for(&m.absolute_path);
intents.push(HandlerIntent::Run {
pack: m.pack.clone(),
handler: self.cmd.handler_name().into(),
executable,
arguments,
sentinel,
filename,
content_hash: checksum,
});
}
Ok(intents)
}
fn check_status(
&self,
file: &Path,
pack: &str,
datastore: &dyn DataStore,
) -> Result<HandlerStatus> {
let checksum = file_checksum(self.fs, file)?;
let filename = file
.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned();
let status = datastore.did_run(pack, self.cmd.handler_name(), &filename, &checksum)?;
let (deployed, message) = match status {
crate::datastore::DidRunStatus::NeverRan => (false, self.cmd.status_pending().into()),
crate::datastore::DidRunStatus::RanCurrent => (true, self.cmd.status_deployed().into()),
crate::datastore::DidRunStatus::RanDifferent { .. } => (
true,
format!(
"{} (older version — run `dodot up --provision-rerun` to apply current)",
self.cmd.status_deployed()
),
),
};
Ok(HandlerStatus {
file: file.to_string_lossy().into_owned(),
handler: self.cmd.handler_name().into(),
deployed,
message,
})
}
}
pub struct RunOnceStatusMessages {
pub pending: String,
pub deployed: String,
pub ran_different: String,
}
pub fn status_messages_for<C: RunOnceCommand>(cmd: &C) -> RunOnceStatusMessages {
RunOnceStatusMessages {
pending: cmd.status_pending().to_string(),
deployed: cmd.status_deployed().to_string(),
ran_different: cmd.status_ran_different().to_string(),
}
}
pub fn run_once_status_messages(handler: &str) -> RunOnceStatusMessages {
use crate::handlers::{HANDLER_HOMEBREW, HANDLER_INSTALL, HANDLER_NIX};
if handler == HANDLER_INSTALL {
return status_messages_for(&crate::handlers::install::InstallCommand);
}
if handler == HANDLER_HOMEBREW {
return status_messages_for(&crate::handlers::homebrew::BrewfileCommand);
}
if handler == HANDLER_NIX {
return status_messages_for(&crate::handlers::nix::NixCommand);
}
RunOnceStatusMessages {
pending: "never ran".into(),
deployed: "ran".into(),
ran_different: "older version".into(),
}
}
pub(crate) fn file_checksum(fs: &dyn Fs, path: &Path) -> Result<String> {
let mut reader = fs.open_read(path)?;
let mut hasher = Sha256::new();
let mut buf = [0u8; 8192];
loop {
let n = reader.read(&mut buf).map_err(|e| crate::DodotError::Fs {
path: path.to_path_buf(),
source: e,
})?;
if n == 0 {
break;
}
hasher.update(&buf[..n]);
}
let hash = hasher.finalize();
Ok(hex_encode(&hash[..8]))
}
pub(crate) fn file_checksum_bytes(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
let hash = hasher.finalize();
hex_encode(&hash[..8])
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::datastore::{CommandOutput, CommandRunner, FilesystemDataStore};
use crate::testing::TempEnvironment;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
#[allow(dead_code)]
fn assert_object_safe(_: &dyn RunOnceCommand) {}
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))
}
struct FakeCommand {
name: &'static str,
phase: ExecutionPhase,
executable: String,
args_template: Vec<String>,
validate_fails: bool,
deployed_msg: &'static str,
pending_msg: &'static str,
}
impl FakeCommand {
fn new(name: &'static str) -> Self {
Self {
name,
phase: ExecutionPhase::Setup,
executable: "test-cmd".into(),
args_template: Vec::new(),
validate_fails: false,
deployed_msg: "ran",
pending_msg: "never ran",
}
}
}
impl RunOnceCommand for FakeCommand {
fn handler_name(&self) -> &str {
self.name
}
fn phase(&self) -> ExecutionPhase {
self.phase
}
fn command_for(&self, path: &Path) -> (String, Vec<String>) {
let mut args = self.args_template.clone();
args.push(path.to_string_lossy().into_owned());
(self.executable.clone(), args)
}
fn validate(&self, _fs: &dyn Fs, _runner: &dyn CommandRunner, path: &Path) -> Result<()> {
if self.validate_fails {
Err(crate::DodotError::Fs {
path: path.to_path_buf(),
source: std::io::Error::other("synthetic validation failure"),
})
} else {
Ok(())
}
}
fn status_deployed(&self) -> &str {
self.deployed_msg
}
fn status_pending(&self) -> &str {
self.pending_msg
}
}
fn make_match(
pack: &str,
relative: &str,
absolute: PathBuf,
rendered: Option<Vec<u8>>,
) -> RuleMatch {
RuleMatch {
relative_path: relative.into(),
absolute_path: absolute,
pack: pack.into(),
handler: "fake".into(),
is_dir: false,
options: HashMap::new(),
preprocessor_source: None,
rendered_bytes: rendered.map(Arc::from),
}
}
fn pather(env: &TempEnvironment) -> crate::paths::XdgPather {
crate::paths::XdgPather::builder()
.home(&env.home)
.dotfiles_root(&env.dotfiles_root)
.build()
.unwrap()
}
#[test]
fn handler_exposes_command_identity() {
let env = TempEnvironment::builder().build();
let handler = RunOnceHandler::new(
env.fs.as_ref(),
&NoopRunner,
FakeCommand {
phase: ExecutionPhase::Provision,
..FakeCommand::new("widget")
},
);
assert_eq!(handler.name(), "widget");
assert_eq!(handler.phase(), ExecutionPhase::Provision);
}
#[test]
fn to_intents_emits_run_with_shared_shape() {
let env = TempEnvironment::builder()
.pack("vim")
.file("setup.sh", "echo hi")
.done()
.build();
let cmd = FakeCommand {
executable: "bash".into(),
args_template: vec!["--".into()],
..FakeCommand::new("fake")
};
let handler = RunOnceHandler::new(env.fs.as_ref(), &NoopRunner, cmd);
let abs = env.dotfiles_root.join("vim/setup.sh");
let matches = vec![make_match("vim", "setup.sh", abs.clone(), None)];
let intents = handler
.to_intents(
&matches,
&HandlerConfig::default(),
&pather(&env),
env.fs.as_ref(),
)
.unwrap();
assert_eq!(intents.len(), 1);
match &intents[0] {
HandlerIntent::Run {
pack,
handler: h,
executable,
arguments,
sentinel,
filename,
content_hash,
} => {
assert_eq!(pack, "vim");
assert_eq!(h, "fake");
assert_eq!(executable, "bash");
assert_eq!(arguments[0], "--");
assert!(arguments[1].ends_with("vim/setup.sh"));
assert!(sentinel.starts_with("setup.sh-"));
assert_eq!(sentinel.len(), "setup.sh-".len() + 16);
assert_eq!(filename, "setup.sh");
assert_eq!(content_hash.len(), 16);
assert_eq!(*sentinel, format!("{filename}-{content_hash}"));
}
other => panic!("expected Run, got {other:?}"),
}
}
#[test]
fn to_intents_prefers_rendered_bytes_over_disk_read() {
let env = TempEnvironment::builder()
.pack("vim")
.file("setup.sh", "on-disk content")
.done()
.build();
let abs = env.dotfiles_root.join("vim/setup.sh");
let handler = RunOnceHandler::new(env.fs.as_ref(), &NoopRunner, FakeCommand::new("fake"));
let rendered = b"rendered content".to_vec();
let expected_checksum = file_checksum_bytes(&rendered);
let matches = vec![make_match("vim", "setup.sh", abs.clone(), Some(rendered))];
let intents = handler
.to_intents(
&matches,
&HandlerConfig::default(),
&pather(&env),
env.fs.as_ref(),
)
.unwrap();
match &intents[0] {
HandlerIntent::Run { sentinel, .. } => {
assert_eq!(*sentinel, format!("setup.sh-{expected_checksum}"));
}
other => panic!("expected Run, got {other:?}"),
}
}
#[test]
fn to_intents_falls_back_to_disk_when_no_rendered_bytes() {
let env = TempEnvironment::builder()
.pack("vim")
.file("setup.sh", "disk content")
.done()
.build();
let abs = env.dotfiles_root.join("vim/setup.sh");
let handler = RunOnceHandler::new(env.fs.as_ref(), &NoopRunner, FakeCommand::new("fake"));
let expected_checksum = file_checksum(env.fs.as_ref(), &abs).unwrap();
let matches = vec![make_match("vim", "setup.sh", abs, None)];
let intents = handler
.to_intents(
&matches,
&HandlerConfig::default(),
&pather(&env),
env.fs.as_ref(),
)
.unwrap();
match &intents[0] {
HandlerIntent::Run { sentinel, .. } => {
assert_eq!(*sentinel, format!("setup.sh-{expected_checksum}"));
}
other => panic!("expected Run, got {other:?}"),
}
}
#[test]
fn validate_does_not_fire_on_placeholder_match() {
let env = TempEnvironment::builder().build();
let cmd = FakeCommand {
validate_fails: true,
..FakeCommand::new("fake")
};
let handler = RunOnceHandler::new(env.fs.as_ref(), &NoopRunner, cmd);
let ghost = env.dotfiles_root.join("ghost/install.sh"); let matches = vec![make_match("ghost", "install.sh", ghost, None)];
let intents = handler
.to_intents(
&matches,
&HandlerConfig::default(),
&pather(&env),
env.fs.as_ref(),
)
.expect("placeholder match should skip cleanly without invoking validate");
assert!(intents.is_empty());
}
#[test]
fn to_intents_skips_first_time_pack_passive_placeholder() {
let env = TempEnvironment::builder().build();
let handler = RunOnceHandler::new(env.fs.as_ref(), &NoopRunner, FakeCommand::new("fake"));
let ghost = env.dotfiles_root.join("ghost/install.sh"); let matches = vec![make_match("ghost", "install.sh", ghost, None)];
let intents = handler
.to_intents(
&matches,
&HandlerConfig::default(),
&pather(&env),
env.fs.as_ref(),
)
.unwrap();
assert!(intents.is_empty());
}
#[test]
fn to_intents_propagates_validation_error() {
let env = TempEnvironment::builder()
.pack("vim")
.file("setup.sh", "content")
.done()
.build();
let abs = env.dotfiles_root.join("vim/setup.sh");
let cmd = FakeCommand {
validate_fails: true,
..FakeCommand::new("fake")
};
let handler = RunOnceHandler::new(env.fs.as_ref(), &NoopRunner, cmd);
let matches = vec![make_match("vim", "setup.sh", abs, None)];
let result = handler.to_intents(
&matches,
&HandlerConfig::default(),
&pather(&env),
env.fs.as_ref(),
);
assert!(
result.is_err(),
"expected validate failure to propagate, got {result:?}"
);
}
#[test]
fn to_intents_skips_directories() {
let env = TempEnvironment::builder()
.pack("vim")
.file("scripts/run", "x")
.done()
.build();
let handler = RunOnceHandler::new(env.fs.as_ref(), &NoopRunner, FakeCommand::new("fake"));
let dir_match = RuleMatch {
is_dir: true,
..make_match(
"vim",
"scripts",
env.dotfiles_root.join("vim/scripts"),
None,
)
};
let intents = handler
.to_intents(
&[dir_match],
&HandlerConfig::default(),
&pather(&env),
env.fs.as_ref(),
)
.unwrap();
assert!(intents.is_empty());
}
#[test]
fn to_intents_emits_one_intent_per_match() {
let env = TempEnvironment::builder()
.pack("vim")
.file("a.sh", "alpha")
.file("b.sh", "beta")
.done()
.build();
let handler = RunOnceHandler::new(env.fs.as_ref(), &NoopRunner, FakeCommand::new("fake"));
let matches = vec![
make_match("vim", "a.sh", env.dotfiles_root.join("vim/a.sh"), None),
make_match("vim", "b.sh", env.dotfiles_root.join("vim/b.sh"), None),
];
let intents = handler
.to_intents(
&matches,
&HandlerConfig::default(),
&pather(&env),
env.fs.as_ref(),
)
.unwrap();
assert_eq!(intents.len(), 2);
}
#[test]
fn check_status_reports_deployed_when_sentinel_exists() {
let env = TempEnvironment::builder()
.pack("vim")
.file("setup.sh", "content")
.done()
.build();
let abs = env.dotfiles_root.join("vim/setup.sh");
let checksum = file_checksum(env.fs.as_ref(), &abs).unwrap();
let sentinel = format!("setup.sh-{checksum}");
let sentinel_dir = env.paths.handler_data_dir("vim", "fake");
env.fs.mkdir_all(&sentinel_dir).unwrap();
env.fs
.write_file(&sentinel_dir.join(&sentinel), b"completed|0")
.unwrap();
let datastore = make_datastore(&env);
let cmd = FakeCommand {
deployed_msg: "all set",
..FakeCommand::new("fake")
};
let handler = RunOnceHandler::new(env.fs.as_ref(), &NoopRunner, cmd);
let status = handler.check_status(&abs, "vim", &datastore).unwrap();
assert!(status.deployed);
assert_eq!(status.message, "all set");
assert_eq!(status.handler, "fake");
}
#[test]
fn check_status_reports_older_version_when_hash_differs() {
let env = TempEnvironment::builder()
.pack("vim")
.file("setup.sh", "new content")
.done()
.build();
let abs = env.dotfiles_root.join("vim/setup.sh");
let sentinel_dir = env.paths.handler_data_dir("vim", "fake");
env.fs.mkdir_all(&sentinel_dir).unwrap();
env.fs
.write_file(
&sentinel_dir.join("setup.sh-aaaaaaaaaaaaaaaa"),
b"completed|100",
)
.unwrap();
let datastore = make_datastore(&env);
let cmd = FakeCommand {
deployed_msg: "ran",
..FakeCommand::new("fake")
};
let handler = RunOnceHandler::new(env.fs.as_ref(), &NoopRunner, cmd);
let status = handler.check_status(&abs, "vim", &datastore).unwrap();
assert!(status.deployed, "older version still counts as deployed");
assert!(
status.message.contains("older version"),
"message should flag older version, got: {}",
status.message
);
assert!(
status.message.contains("--provision-rerun"),
"message should mention --provision-rerun, got: {}",
status.message
);
}
#[test]
fn check_status_reports_pending_when_no_sentinel() {
let env = TempEnvironment::builder()
.pack("vim")
.file("setup.sh", "content")
.done()
.build();
let abs = env.dotfiles_root.join("vim/setup.sh");
let datastore = make_datastore(&env);
let cmd = FakeCommand {
pending_msg: "needs attention",
..FakeCommand::new("fake")
};
let handler = RunOnceHandler::new(env.fs.as_ref(), &NoopRunner, cmd);
let status = handler.check_status(&abs, "vim", &datastore).unwrap();
assert!(!status.deployed);
assert_eq!(status.message, "needs attention");
}
#[test]
fn file_checksum_and_bytes_agree() {
let env = TempEnvironment::builder()
.pack("vim")
.file("file.txt", "consistent content")
.done()
.build();
let abs = env.dotfiles_root.join("vim/file.txt");
let disk = file_checksum(env.fs.as_ref(), &abs).unwrap();
let in_mem = file_checksum_bytes(b"consistent content");
assert_eq!(disk, in_mem);
assert_eq!(disk.len(), 16);
}
#[test]
fn file_checksum_changes_with_content() {
let a = file_checksum_bytes(b"version 1");
let b = file_checksum_bytes(b"version 2");
assert_ne!(a, b);
}
}