use serde::Serialize;
use crate::packs::orchestration::ExecutionContext;
use crate::preprocessing::baseline::hex_sha256;
use crate::preprocessing::divergence::collect_baselines;
use crate::Result;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum RefreshAction {
Clean,
Touched,
MissingDeployed,
MissingSource,
}
#[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) -> Result<RefreshResult> {
let baselines = collect_baselines(ctx.fs.as_ref(), ctx.paths.as_ref())?;
let mut entries = Vec::with_capacity(baselines.len());
let mut touched_any = false;
for (pack, handler, filename, baseline) in baselines {
let source_path = baseline.source_path.clone();
let deployed_path = ctx
.paths
.data_dir()
.join("packs")
.join(&pack)
.join(&handler)
.join(&filename);
let action = if source_path.as_os_str().is_empty() || !ctx.fs.exists(&source_path) {
RefreshAction::MissingSource
} else 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(&source_path)?;
let target = if deployed_mtime == source_mtime {
deployed_mtime + std::time::Duration::from_secs(1)
} else {
deployed_mtime
};
ctx.fs.set_modified(&source_path, 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,
view_mode: crate::commands::ViewMode::Full,
group_mode: crate::commands::GroupMode::Name,
verbose: false,
host_facts: Arc::new(crate::gates::HostFacts::detect()),
}
}
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 stage_one(
env: &TempEnvironment,
pack: &str,
template_name: &str,
rendered: &[u8],
source: &[u8],
) -> (std::path::PathBuf, std::path::PathBuf) {
let src = env.dotfiles_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).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).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).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).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).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).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).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).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).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).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()),
]
);
}
}