use std::path::PathBuf;
use serde::Serialize;
use crate::fs::Fs;
use crate::paths::Pather;
use crate::preprocessing::baseline::{hex_sha256, Baseline};
use crate::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum DivergenceState {
Synced,
InputChanged,
OutputChanged,
BothChanged,
MissingSource,
MissingDeployed,
}
#[derive(Debug, Clone, Serialize)]
pub struct DivergenceReport {
pub pack: String,
pub handler: String,
pub filename: String,
pub source_path: PathBuf,
pub deployed_path: PathBuf,
pub state: DivergenceState,
}
pub fn collect_baselines(
fs: &dyn Fs,
paths: &dyn Pather,
) -> Result<Vec<(String, String, String, Baseline)>> {
let root = paths.cache_dir().join("preprocessor");
if !fs.is_dir(&root) {
return Ok(Vec::new());
}
let mut out = Vec::new();
let mut packs = match fs.read_dir(&root) {
Ok(v) => v,
Err(_) => return Ok(Vec::new()),
};
packs.sort_by(|a, b| a.name.cmp(&b.name));
for pack in packs {
if !pack.is_dir {
continue;
}
let mut handlers = match fs.read_dir(&pack.path) {
Ok(v) => v,
Err(_) => continue,
};
handlers.sort_by(|a, b| a.name.cmp(&b.name));
for handler in handlers {
if !handler.is_dir {
continue;
}
let mut files = match fs.read_dir(&handler.path) {
Ok(v) => v,
Err(_) => continue,
};
files.sort_by(|a, b| a.name.cmp(&b.name));
for file in files {
if !file.is_file {
continue;
}
if file.name.ends_with(".secret.json") {
continue;
}
let Some(filename) = file.name.strip_suffix(".json").map(str::to_string) else {
continue;
};
match Baseline::load(fs, paths, &pack.name, &handler.name, &filename) {
Ok(Some(baseline)) => {
out.push((pack.name.clone(), handler.name.clone(), filename, baseline));
}
Ok(None) => {} Err(e) => return Err(e),
}
}
}
}
Ok(out)
}
pub fn classify_one(
fs: &dyn Fs,
paths: &dyn Pather,
pack: &str,
handler: &str,
filename: &str,
baseline: &Baseline,
) -> DivergenceReport {
let source_path = baseline.source_path.clone();
let deployed_path = paths
.data_dir()
.join("packs")
.join(pack)
.join(handler)
.join(filename);
let source_exists = !source_path.as_os_str().is_empty() && fs.exists(&source_path);
let deployed_exists = fs.exists(&deployed_path);
let state = if !source_exists {
DivergenceState::MissingSource
} else if !deployed_exists {
DivergenceState::MissingDeployed
} else {
let source_changed = match fs.read_file(&source_path) {
Ok(bytes) => hex_sha256(&bytes) != baseline.source_hash,
Err(_) => false,
};
let deployed_changed = match fs.read_file(&deployed_path) {
Ok(bytes) => hex_sha256(&bytes) != baseline.rendered_hash,
Err(_) => false,
};
match (source_changed, deployed_changed) {
(false, false) => DivergenceState::Synced,
(true, false) => DivergenceState::InputChanged,
(false, true) => DivergenceState::OutputChanged,
(true, true) => DivergenceState::BothChanged,
}
};
DivergenceReport {
pack: pack.to_string(),
handler: handler.to_string(),
filename: filename.to_string(),
source_path,
deployed_path,
state,
}
}
pub fn collect_divergences(fs: &dyn Fs, paths: &dyn Pather) -> Result<Vec<DivergenceReport>> {
let baselines = collect_baselines(fs, paths)?;
let reports: Vec<DivergenceReport> = baselines
.iter()
.map(|(p, h, f, b)| classify_one(fs, paths, p, h, f, b))
.collect();
Ok(reports)
}
pub fn find_baseline_for_source(
fs: &dyn Fs,
paths: &dyn Pather,
target: &std::path::Path,
) -> Result<Option<(String, String, String, Baseline)>> {
for (pack, handler, filename, baseline) in collect_baselines(fs, paths)? {
if baseline.source_path == target {
return Ok(Some((pack, handler, filename, baseline)));
}
}
Ok(None)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::TempEnvironment;
fn write_pack_template(env: &TempEnvironment, pack: &str, name: &str, body: &str) {
let path = env.dotfiles_root.join(pack).join(name);
env.fs.mkdir_all(path.parent().unwrap()).unwrap();
env.fs.write_file(&path, body.as_bytes()).unwrap();
}
fn write_deployed(env: &TempEnvironment, pack: &str, handler: &str, name: &str, body: &str) {
let path = env
.paths
.data_dir()
.join("packs")
.join(pack)
.join(handler)
.join(name);
env.fs.mkdir_all(path.parent().unwrap()).unwrap();
env.fs.write_file(&path, body.as_bytes()).unwrap();
}
fn baseline_for(source_path: &std::path::Path, rendered: &[u8], source: &[u8]) -> Baseline {
Baseline::build(source_path, rendered, source, Some(""), None)
}
#[test]
fn empty_cache_yields_empty_report() {
let env = TempEnvironment::builder().build();
let reports = collect_divergences(env.fs.as_ref(), env.paths.as_ref()).unwrap();
assert!(reports.is_empty());
}
#[test]
fn collect_baselines_skips_secret_sidecars() {
let env = TempEnvironment::builder().build();
write_pack_template(&env, "app", "config.toml.tmpl", "src");
write_deployed(&env, "app", "preprocessed", "config.toml", "rendered");
let src_path = env.dotfiles_root.join("app/config.toml.tmpl");
let baseline = baseline_for(&src_path, b"rendered", b"src");
baseline
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap();
let sidecar = crate::preprocessing::baseline::SecretsSidecar::new(vec![
crate::preprocessing::SecretLineRange {
start: 0,
end: 1,
reference: "pass:k".into(),
},
]);
sidecar
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap();
let baselines = collect_baselines(env.fs.as_ref(), env.paths.as_ref()).unwrap();
assert_eq!(baselines.len(), 1);
assert_eq!(baselines[0].2, "config.toml");
}
#[test]
fn synced_state_when_nothing_changed() {
let env = TempEnvironment::builder().build();
write_pack_template(&env, "app", "config.toml.tmpl", "src");
write_deployed(&env, "app", "preprocessed", "config.toml", "rendered");
let src_path = env.dotfiles_root.join("app/config.toml.tmpl");
let baseline = baseline_for(&src_path, b"rendered", b"src");
baseline
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap();
let reports = collect_divergences(env.fs.as_ref(), env.paths.as_ref()).unwrap();
assert_eq!(reports.len(), 1);
assert_eq!(reports[0].state, DivergenceState::Synced);
}
#[test]
fn input_changed_when_source_edited() {
let env = TempEnvironment::builder().build();
write_pack_template(&env, "app", "config.toml.tmpl", "src EDITED");
write_deployed(&env, "app", "preprocessed", "config.toml", "rendered");
let src_path = env.dotfiles_root.join("app/config.toml.tmpl");
let baseline = baseline_for(&src_path, b"rendered", b"src");
baseline
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap();
let reports = collect_divergences(env.fs.as_ref(), env.paths.as_ref()).unwrap();
assert_eq!(reports[0].state, DivergenceState::InputChanged);
}
#[test]
fn output_changed_when_deployed_edited() {
let env = TempEnvironment::builder().build();
write_pack_template(&env, "app", "config.toml.tmpl", "src");
write_deployed(&env, "app", "preprocessed", "config.toml", "rendered EDIT");
let src_path = env.dotfiles_root.join("app/config.toml.tmpl");
let baseline = baseline_for(&src_path, b"rendered", b"src");
baseline
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap();
let reports = collect_divergences(env.fs.as_ref(), env.paths.as_ref()).unwrap();
assert_eq!(reports[0].state, DivergenceState::OutputChanged);
}
#[test]
fn both_changed_when_both_edited() {
let env = TempEnvironment::builder().build();
write_pack_template(&env, "app", "config.toml.tmpl", "src EDIT");
write_deployed(&env, "app", "preprocessed", "config.toml", "rendered EDIT");
let src_path = env.dotfiles_root.join("app/config.toml.tmpl");
let baseline = baseline_for(&src_path, b"rendered", b"src");
baseline
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap();
let reports = collect_divergences(env.fs.as_ref(), env.paths.as_ref()).unwrap();
assert_eq!(reports[0].state, DivergenceState::BothChanged);
}
#[test]
fn missing_source_when_pack_file_deleted() {
let env = TempEnvironment::builder().build();
write_deployed(&env, "app", "preprocessed", "config.toml", "rendered");
let baseline = baseline_for(
&env.dotfiles_root.join("app/config.toml.tmpl"),
b"rendered",
b"src",
);
baseline
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap();
let reports = collect_divergences(env.fs.as_ref(), env.paths.as_ref()).unwrap();
assert_eq!(reports[0].state, DivergenceState::MissingSource);
}
#[test]
fn missing_deployed_when_datastore_file_gone() {
let env = TempEnvironment::builder().build();
write_pack_template(&env, "app", "config.toml.tmpl", "src");
let src_path = env.dotfiles_root.join("app/config.toml.tmpl");
let baseline = baseline_for(&src_path, b"rendered", b"src");
baseline
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap();
let reports = collect_divergences(env.fs.as_ref(), env.paths.as_ref()).unwrap();
assert_eq!(reports[0].state, DivergenceState::MissingDeployed);
}
#[test]
fn report_is_sorted_by_pack_handler_filename() {
let env = TempEnvironment::builder().build();
for (pack, name, body) in [
("zebra", "z.toml.tmpl", "z-src"),
("alpha", "b.toml.tmpl", "b-src"),
("alpha", "a.toml.tmpl", "a-src"),
] {
write_pack_template(&env, pack, name, body);
let cache_name = name.strip_suffix(".tmpl").unwrap();
write_deployed(&env, pack, "preprocessed", cache_name, "rendered");
let src_path = env.dotfiles_root.join(pack).join(name);
let baseline = baseline_for(&src_path, b"rendered", body.as_bytes());
baseline
.write(
env.fs.as_ref(),
env.paths.as_ref(),
pack,
"preprocessed",
cache_name,
)
.unwrap();
}
let reports = collect_divergences(env.fs.as_ref(), env.paths.as_ref()).unwrap();
let order: Vec<_> = reports
.iter()
.map(|r| (r.pack.clone(), r.filename.clone()))
.collect();
assert_eq!(
order,
vec![
("alpha".into(), "a.toml".into()),
("alpha".into(), "b.toml".into()),
("zebra".into(), "z.toml".into()),
]
);
}
#[test]
fn baseline_with_empty_source_path_is_classified_missing_source() {
let env = TempEnvironment::builder().build();
write_deployed(&env, "app", "preprocessed", "config.toml", "rendered");
let baseline = baseline_for(std::path::Path::new(""), b"rendered", b"src");
baseline
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap();
let reports = collect_divergences(env.fs.as_ref(), env.paths.as_ref()).unwrap();
assert_eq!(reports[0].state, DivergenceState::MissingSource);
}
#[test]
fn find_baseline_for_source_returns_match() {
let env = TempEnvironment::builder().build();
let src_a = env.dotfiles_root.join("app/a.toml.tmpl");
write_pack_template(&env, "app", "a.toml.tmpl", "src-a");
write_deployed(&env, "app", "preprocessed", "a.toml", "rendered-a");
baseline_for(&src_a, b"rendered-a", b"src-a")
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"a.toml",
)
.unwrap();
let src_b = env.dotfiles_root.join("app/b.toml.tmpl");
write_pack_template(&env, "app", "b.toml.tmpl", "src-b");
write_deployed(&env, "app", "preprocessed", "b.toml", "rendered-b");
baseline_for(&src_b, b"rendered-b", b"src-b")
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"b.toml",
)
.unwrap();
let hit = find_baseline_for_source(env.fs.as_ref(), env.paths.as_ref(), &src_a).unwrap();
let (pack, handler, filename, baseline) = hit.expect("baseline must be found");
assert_eq!(pack, "app");
assert_eq!(handler, "preprocessed");
assert_eq!(filename, "a.toml");
assert_eq!(baseline.source_path, src_a);
assert_eq!(baseline.rendered_content, "rendered-a");
}
#[test]
fn find_baseline_for_source_returns_none_when_unknown() {
let env = TempEnvironment::builder().build();
let unknown = env.dotfiles_root.join("never-cached.tmpl");
let result =
find_baseline_for_source(env.fs.as_ref(), env.paths.as_ref(), &unknown).unwrap();
assert!(result.is_none());
}
#[test]
fn find_baseline_for_source_on_empty_cache_returns_none() {
let env = TempEnvironment::builder().build();
let any = env.dotfiles_root.join("anything.tmpl");
let result = find_baseline_for_source(env.fs.as_ref(), env.paths.as_ref(), &any).unwrap();
assert!(result.is_none());
}
}