use serde::Serialize;
use crate::packs::orchestration::ExecutionContext;
use crate::preprocessing::baseline::hex_sha256;
use crate::preprocessing::divergence::collect_baselines;
use crate::safety_lock::{scope_to_root, OsPathProbe, OutOfRootReason, RootIdentity, ScopeOutcome};
use crate::Result;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RefreshAction {
Clean,
Touched,
MissingDeployed,
OutOfRoot,
MissingSource,
StaleSource,
UnresolvableSource,
}
impl RefreshAction {
fn out_of_root(reason: OutOfRootReason) -> Self {
match reason {
OutOfRootReason::OutsideRoot => RefreshAction::OutOfRoot,
OutOfRootReason::Missing => RefreshAction::MissingSource,
OutOfRootReason::Stale => RefreshAction::StaleSource,
OutOfRootReason::Uncanonicalizable => RefreshAction::UnresolvableSource,
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct RefreshEntry {
pub pack: String,
pub handler: String,
pub filename: String,
pub source_path: String,
pub action: RefreshAction,
}
#[derive(Debug, Clone, Serialize)]
pub struct RefreshResult {
pub entries: Vec<RefreshEntry>,
pub touched_any: bool,
pub mode: RefreshMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RefreshMode {
Report,
Quiet,
ListPaths,
}
pub fn refresh(
ctx: &ExecutionContext,
mode: RefreshMode,
authorized_root: &RootIdentity,
) -> Result<RefreshResult> {
let baselines = collect_baselines(ctx.fs.as_ref(), ctx.paths.as_ref())?;
let sources: Vec<std::path::PathBuf> = baselines
.iter()
.map(|(_, _, _, baseline)| baseline.source_path.clone())
.collect();
let scope = scope_to_root(authorized_root, &sources, &OsPathProbe);
let mut entries = Vec::with_capacity(baselines.len());
let mut touched_any = false;
for ((pack, handler, filename, baseline), outcome) in
baselines.into_iter().zip(scope.outcomes())
{
let source_path = baseline.source_path.clone();
let deployed_path = ctx
.paths
.data_dir()
.join("packs")
.join(&pack)
.join(&handler)
.join(&filename);
let authorized = match outcome {
ScopeOutcome::InRoot(canonical) => canonical,
ScopeOutcome::OutOfRoot(target) => {
entries.push(RefreshEntry {
pack,
handler,
filename,
source_path: source_path.display().to_string(),
action: RefreshAction::out_of_root(target.reason),
});
continue;
}
};
let action = if !ctx.fs.exists(&deployed_path) {
RefreshAction::MissingDeployed
} else {
let bytes = ctx.fs.read_file(&deployed_path)?;
if hex_sha256(&bytes) == baseline.rendered_hash {
RefreshAction::Clean
} else {
if mode != RefreshMode::ListPaths {
let deployed_mtime = ctx.fs.modified(&deployed_path)?;
let source_mtime = ctx.fs.modified(authorized)?;
let target = if deployed_mtime == source_mtime {
deployed_mtime + std::time::Duration::from_secs(1)
} else {
deployed_mtime
};
ctx.fs.set_modified(authorized, target)?;
}
touched_any = true;
RefreshAction::Touched
}
};
entries.push(RefreshEntry {
pack,
handler,
filename,
source_path: source_path.display().to_string(),
action,
});
}
Ok(RefreshResult {
entries,
touched_any,
mode,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::Fs;
use crate::paths::Pather;
use crate::preprocessing::baseline::Baseline;
use crate::testing::TempEnvironment;
fn make_ctx(env: &TempEnvironment) -> ExecutionContext {
use crate::config::ConfigManager;
use crate::datastore::{CommandOutput, CommandRunner, FilesystemDataStore};
use std::sync::Arc;
struct NoopRunner;
impl CommandRunner for NoopRunner {
fn run(&self, _e: &str, _a: &[String]) -> Result<CommandOutput> {
Ok(CommandOutput {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
})
}
}
let runner: Arc<dyn CommandRunner> = Arc::new(NoopRunner);
let datastore = Arc::new(FilesystemDataStore::new(
env.fs.clone(),
env.paths.clone(),
runner.clone(),
));
let config_manager = Arc::new(ConfigManager::new(&env.dotfiles_root).unwrap());
ExecutionContext {
fs: env.fs.clone() as Arc<dyn Fs>,
datastore,
paths: env.paths.clone() as Arc<dyn Pather>,
config_manager,
syntax_checker: Arc::new(crate::shell::NoopSyntaxChecker),
command_runner: runner,
dry_run: false,
no_provision: true,
provision_rerun: false,
force: false,
check_drift: false,
show_diff: false,
view_mode: crate::commands::ViewMode::Full,
group_mode: crate::commands::GroupMode::Name,
verbose: false,
host_facts: Arc::new(crate::gates::HostFacts::detect()),
env_stamp: Default::default(),
tty: false,
shell_probe: crate::shell::ProbePolicy::Never,
shell_env: crate::shell::ShellEnv::default(),
}
}
fn write_file(env: &TempEnvironment, path: &std::path::Path, body: &[u8]) {
env.fs.mkdir_all(path.parent().unwrap()).unwrap();
env.fs.write_file(path, body).unwrap();
}
fn root(env: &TempEnvironment) -> RootIdentity {
canonical_root(&env.dotfiles_root)
}
fn canonical_root(path: &std::path::Path) -> RootIdentity {
RootIdentity::new(std::fs::canonicalize(path).unwrap()).unwrap()
}
fn second_root_dir(env: &TempEnvironment) -> std::path::PathBuf {
let dir = env.home.join("other-dotfiles");
env.fs.mkdir_all(&dir).unwrap();
dir
}
fn stage_one(
env: &TempEnvironment,
pack: &str,
template_name: &str,
rendered: &[u8],
source: &[u8],
) -> (std::path::PathBuf, std::path::PathBuf) {
let root = env.dotfiles_root.clone();
stage_under(env, &root, pack, template_name, rendered, source)
}
fn stage_under(
env: &TempEnvironment,
root: &std::path::Path,
pack: &str,
template_name: &str,
rendered: &[u8],
source: &[u8],
) -> (std::path::PathBuf, std::path::PathBuf) {
let src = root.join(pack).join(template_name);
write_file(env, &src, source);
let stripped = template_name.strip_suffix(".tmpl").unwrap_or(template_name);
let deployed = env
.paths
.data_dir()
.join("packs")
.join(pack)
.join("preprocessed")
.join(stripped);
write_file(env, &deployed, rendered);
let baseline = Baseline::build(&src, rendered, source, Some(""), None);
baseline
.write(
env.fs.as_ref(),
env.paths.as_ref(),
pack,
"preprocessed",
stripped,
)
.unwrap();
(src, deployed)
}
#[test]
fn empty_cache_yields_empty_report() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
let r = refresh(&ctx, RefreshMode::Report, &root(&env)).unwrap();
assert!(r.entries.is_empty());
assert!(!r.touched_any);
}
#[test]
fn clean_state_is_a_noop() {
let env = TempEnvironment::builder().build();
let (src, _) = stage_one(&env, "app", "cfg.toml.tmpl", b"rendered", b"src");
let before = env.fs.modified(&src).unwrap();
let ctx = make_ctx(&env);
let r = refresh(&ctx, RefreshMode::Report, &root(&env)).unwrap();
assert_eq!(r.entries.len(), 1);
assert!(matches!(r.entries[0].action, RefreshAction::Clean));
assert!(!r.touched_any);
assert_eq!(env.fs.modified(&src).unwrap(), before);
}
#[test]
fn divergent_deployed_touches_source_mtime() {
let env = TempEnvironment::builder().build();
let (src, deployed) = stage_one(&env, "app", "cfg.toml.tmpl", b"rendered", b"src");
std::thread::sleep(std::time::Duration::from_millis(20));
env.fs.write_file(&deployed, b"rendered EDITED").unwrap();
let deployed_mtime = env.fs.modified(&deployed).unwrap();
let ctx = make_ctx(&env);
let r = refresh(&ctx, RefreshMode::Report, &root(&env)).unwrap();
assert_eq!(r.entries.len(), 1);
assert!(matches!(r.entries[0].action, RefreshAction::Touched));
assert!(r.touched_any);
let new_src_mtime = env.fs.modified(&src).unwrap();
assert_eq!(new_src_mtime, deployed_mtime);
}
#[test]
fn list_paths_mode_does_not_write_mtimes() {
let env = TempEnvironment::builder().build();
let (src, deployed) = stage_one(&env, "app", "cfg.toml.tmpl", b"rendered", b"src");
let before_src = env.fs.modified(&src).unwrap();
std::thread::sleep(std::time::Duration::from_millis(20));
env.fs.write_file(&deployed, b"rendered EDITED").unwrap();
let ctx = make_ctx(&env);
let r = refresh(&ctx, RefreshMode::ListPaths, &root(&env)).unwrap();
assert_eq!(r.entries.len(), 1);
assert!(matches!(r.entries[0].action, RefreshAction::Touched));
assert!(r.touched_any);
assert_eq!(env.fs.modified(&src).unwrap(), before_src);
}
#[test]
fn quiet_mode_still_writes_mtimes() {
let env = TempEnvironment::builder().build();
let (src, deployed) = stage_one(&env, "app", "cfg.toml.tmpl", b"rendered", b"src");
std::thread::sleep(std::time::Duration::from_millis(20));
env.fs.write_file(&deployed, b"rendered EDITED").unwrap();
let deployed_mtime = env.fs.modified(&deployed).unwrap();
let ctx = make_ctx(&env);
let r = refresh(&ctx, RefreshMode::Quiet, &root(&env)).unwrap();
assert!(matches!(r.entries[0].action, RefreshAction::Touched));
assert_eq!(env.fs.modified(&src).unwrap(), deployed_mtime);
}
#[test]
fn missing_source_is_reported_not_an_error() {
let env = TempEnvironment::builder().build();
let baseline = Baseline::build(
&env.dotfiles_root.join("app/missing.toml.tmpl"),
b"rendered",
b"src",
Some(""),
None,
);
baseline
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"missing.toml",
)
.unwrap();
let deployed = env
.paths
.data_dir()
.join("packs/app/preprocessed/missing.toml");
write_file(&env, &deployed, b"rendered");
let ctx = make_ctx(&env);
let r = refresh(&ctx, RefreshMode::Report, &root(&env)).unwrap();
assert_eq!(r.entries.len(), 1);
assert!(matches!(r.entries[0].action, RefreshAction::MissingSource));
assert!(!r.touched_any);
}
#[test]
fn missing_deployed_is_reported_not_an_error() {
let env = TempEnvironment::builder().build();
let src = env.dotfiles_root.join("app/cfg.toml.tmpl");
write_file(&env, &src, b"src");
let baseline = Baseline::build(&src, b"rendered", b"src", Some(""), None);
baseline
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"cfg.toml",
)
.unwrap();
let ctx = make_ctx(&env);
let r = refresh(&ctx, RefreshMode::Report, &root(&env)).unwrap();
assert!(matches!(
r.entries[0].action,
RefreshAction::MissingDeployed
));
assert!(!r.touched_any);
}
#[test]
fn pure_data_edit_is_still_treated_as_divergent() {
let env = TempEnvironment::builder().build();
let (_src, deployed) = stage_one(
&env,
"app",
"greet.tmpl",
b"hello Alice",
b"hello {{ name }}",
);
std::thread::sleep(std::time::Duration::from_millis(20));
env.fs.write_file(&deployed, b"hello Bob").unwrap();
let ctx = make_ctx(&env);
let r = refresh(&ctx, RefreshMode::Report, &root(&env)).unwrap();
assert!(matches!(r.entries[0].action, RefreshAction::Touched));
assert!(r.touched_any);
}
#[test]
fn divergent_with_equal_mtimes_still_bumps_source() {
let env = TempEnvironment::builder().build();
let (src, deployed) = stage_one(&env, "app", "cfg.toml.tmpl", b"rendered", b"src");
let pinned = env.fs.modified(&src).unwrap();
env.fs.write_file(&deployed, b"rendered EDITED").unwrap();
env.fs.set_modified(&deployed, pinned).unwrap();
assert_eq!(env.fs.modified(&deployed).unwrap(), pinned);
let ctx = make_ctx(&env);
let r = refresh(&ctx, RefreshMode::Report, &root(&env)).unwrap();
assert!(matches!(r.entries[0].action, RefreshAction::Touched));
let after = env.fs.modified(&src).unwrap();
assert!(
after > pinned,
"source mtime should strictly increase even when deployed mtime equals source mtime"
);
}
#[test]
fn entries_are_sorted_by_pack_handler_filename() {
let env = TempEnvironment::builder().build();
for (pack, name) in [
("zebra", "z.tmpl"),
("alpha", "b.tmpl"),
("alpha", "a.tmpl"),
] {
stage_one(&env, pack, name, b"rendered", b"src");
}
let ctx = make_ctx(&env);
let r = refresh(&ctx, RefreshMode::Report, &root(&env)).unwrap();
let order: Vec<_> = r
.entries
.iter()
.map(|e| (e.pack.clone(), e.filename.clone()))
.collect();
assert_eq!(
order,
vec![
("alpha".into(), "a".into()),
("alpha".into(), "b".into()),
("zebra".into(), "z".into()),
]
);
}
#[test]
fn authorizing_one_root_never_touches_the_other_roots_sources() {
let env = TempEnvironment::builder().build();
let other_root = second_root_dir(&env);
let (here, here_deployed) = stage_one(&env, "app", "cfg.toml.tmpl", b"rendered", b"src");
let (there, there_deployed) = stage_under(
&env,
&other_root,
"other",
"cfg.toml.tmpl",
b"rendered",
b"src",
);
std::thread::sleep(std::time::Duration::from_millis(20));
env.fs
.write_file(&here_deployed, b"rendered EDITED")
.unwrap();
env.fs
.write_file(&there_deployed, b"rendered EDITED")
.unwrap();
let here_before = env.fs.modified(&here).unwrap();
let there_before = env.fs.modified(&there).unwrap();
let ctx = make_ctx(&env);
let r = refresh(&ctx, RefreshMode::Report, &root(&env)).unwrap();
let actions: Vec<(&str, &RefreshAction)> = r
.entries
.iter()
.map(|e| (e.pack.as_str(), &e.action))
.collect();
assert!(
matches!(
actions.as_slice(),
[
("app", RefreshAction::Touched),
("other", RefreshAction::OutOfRoot)
]
),
"unexpected actions: {actions:?}"
);
assert_ne!(
env.fs.modified(&here).unwrap(),
here_before,
"the authorized root's own source was not touched"
);
assert_eq!(
env.fs.modified(&there).unwrap(),
there_before,
"another root's source was touched"
);
let r = refresh(&ctx, RefreshMode::Report, &canonical_root(&other_root)).unwrap();
let actions: Vec<(&str, &RefreshAction)> = r
.entries
.iter()
.map(|e| (e.pack.as_str(), &e.action))
.collect();
assert!(
matches!(
actions.as_slice(),
[
("app", RefreshAction::OutOfRoot),
("other", RefreshAction::Touched)
]
),
"unexpected actions: {actions:?}"
);
assert_ne!(env.fs.modified(&there).unwrap(), there_before);
}
#[test]
fn a_source_symlinked_out_of_the_root_is_reported_not_touched() {
let env = TempEnvironment::builder().build();
let other_root = second_root_dir(&env);
let outside = other_root.join("real.toml.tmpl");
write_file(&env, &outside, b"src");
let link = env.dotfiles_root.join("app/cfg.toml.tmpl");
env.fs.mkdir_all(link.parent().unwrap()).unwrap();
std::os::unix::fs::symlink(&outside, &link).unwrap();
let deployed = env.paths.data_dir().join("packs/app/preprocessed/cfg.toml");
write_file(&env, &deployed, b"rendered EDITED");
Baseline::build(&link, b"rendered", b"src", Some(""), None)
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"cfg.toml",
)
.unwrap();
let before = env.fs.modified(&outside).unwrap();
let ctx = make_ctx(&env);
std::thread::sleep(std::time::Duration::from_millis(20));
let r = refresh(&ctx, RefreshMode::Report, &root(&env)).unwrap();
assert!(
matches!(r.entries[0].action, RefreshAction::OutOfRoot),
"unexpected action: {:?}",
r.entries[0].action
);
assert!(!r.touched_any);
assert_eq!(env.fs.modified(&outside).unwrap(), before);
}
#[test]
fn a_baseline_without_a_source_path_is_reported_as_stale() {
let env = TempEnvironment::builder().build();
Baseline::build(
std::path::Path::new(""),
b"rendered",
b"src",
Some(""),
None,
)
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"cfg.toml",
)
.unwrap();
let deployed = env.paths.data_dir().join("packs/app/preprocessed/cfg.toml");
write_file(&env, &deployed, b"rendered");
let ctx = make_ctx(&env);
let r = refresh(&ctx, RefreshMode::Report, &root(&env)).unwrap();
assert!(
matches!(r.entries[0].action, RefreshAction::StaleSource),
"unexpected action: {:?}",
r.entries[0].action
);
assert!(!r.touched_any);
}
#[test]
fn list_paths_mode_reports_only_authorized_sources() {
let env = TempEnvironment::builder().build();
let other_root = second_root_dir(&env);
let (_here, here_deployed) = stage_one(&env, "app", "cfg.toml.tmpl", b"rendered", b"src");
let (_there, there_deployed) = stage_under(
&env,
&other_root,
"other",
"cfg.toml.tmpl",
b"rendered",
b"src",
);
env.fs
.write_file(&here_deployed, b"rendered EDITED")
.unwrap();
env.fs
.write_file(&there_deployed, b"rendered EDITED")
.unwrap();
let ctx = make_ctx(&env);
let r = refresh(&ctx, RefreshMode::ListPaths, &root(&env)).unwrap();
let touched: Vec<&str> = r
.entries
.iter()
.filter(|e| matches!(e.action, RefreshAction::Touched))
.map(|e| e.source_path.as_str())
.collect();
assert_eq!(touched.len(), 1, "unexpected touched set: {touched:?}");
assert!(touched[0].contains("/dotfiles/app/"), "{touched:?}");
}
}