use serde::Serialize;
use crate::packs::orchestration::ExecutionContext;
use crate::preprocessing::conflict::find_unresolved_marker_lines;
use crate::preprocessing::divergence::{
classify_one, collect_baselines, DivergenceReport, DivergenceState,
};
use crate::preprocessing::no_reverse::is_no_reverse;
use crate::preprocessing::reverse_merge::{reverse_merge, ReverseMergeOutcome};
use crate::Result;
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum TransformAction {
Synced,
InputChanged,
Patched,
Conflict,
NeedsRebaseline,
MissingSource,
MissingDeployed,
}
#[derive(Debug, Clone, Serialize)]
pub struct TransformCheckEntry {
pub pack: String,
pub handler: String,
pub filename: String,
pub source_path: String,
pub deployed_path: String,
pub action: TransformAction,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub conflict_block: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct UnresolvedMarkerEntry {
pub source_path: String,
pub line_numbers: Vec<usize>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TransformCheckResult {
pub entries: Vec<TransformCheckEntry>,
pub unresolved_markers: Vec<UnresolvedMarkerEntry>,
pub has_findings: bool,
pub strict: bool,
}
impl TransformCheckResult {
pub fn exit_code(&self) -> i32 {
if self.has_findings {
1
} else {
0
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct TransformStatusEntry {
pub pack: String,
pub handler: String,
pub filename: String,
pub source_path: String,
pub deployed_path: String,
#[serde(rename = "state")]
pub state: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub secret_references: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct TransformStatusResult {
pub entries: Vec<TransformStatusEntry>,
pub synced_count: usize,
pub diverged_count: usize,
pub missing_count: usize,
}
pub fn status(ctx: &ExecutionContext) -> Result<TransformStatusResult> {
use crate::preprocessing::divergence::{collect_divergences, DivergenceState};
let reports = collect_divergences(ctx.fs.as_ref(), ctx.paths.as_ref())?;
let mut synced_count = 0usize;
let mut diverged_count = 0usize;
let mut missing_count = 0usize;
let entries: Vec<TransformStatusEntry> = reports
.into_iter()
.map(|r| {
let state_str = match r.state {
DivergenceState::Synced => {
synced_count += 1;
"synced"
}
DivergenceState::InputChanged => {
diverged_count += 1;
"input_changed"
}
DivergenceState::OutputChanged => {
diverged_count += 1;
"output_changed"
}
DivergenceState::BothChanged => {
diverged_count += 1;
"both_changed"
}
DivergenceState::MissingSource => {
missing_count += 1;
"missing_source"
}
DivergenceState::MissingDeployed => {
missing_count += 1;
"missing_deployed"
}
};
let secret_references = crate::preprocessing::baseline::SecretsSidecar::load(
ctx.fs.as_ref(),
ctx.paths.as_ref(),
&r.pack,
&r.handler,
&r.filename,
)
.ok()
.flatten()
.map(|s| {
s.secret_line_ranges
.into_iter()
.map(|range| range.reference)
.collect::<Vec<_>>()
})
.unwrap_or_default();
TransformStatusEntry {
pack: r.pack,
handler: r.handler,
filename: r.filename,
source_path: render_path(&r.source_path, ctx.paths.home_dir()),
deployed_path: render_path(&r.deployed_path, ctx.paths.home_dir()),
state: state_str.to_string(),
secret_references,
}
})
.collect();
Ok(TransformStatusResult {
entries,
synced_count,
diverged_count,
missing_count,
})
}
pub fn check(ctx: &ExecutionContext, strict: bool) -> Result<TransformCheckResult> {
let baselines = collect_baselines(ctx.fs.as_ref(), ctx.paths.as_ref())?;
let mut entries: Vec<TransformCheckEntry> = Vec::with_capacity(baselines.len());
let mut has_findings = false;
let mut no_reverse_cache: std::collections::HashMap<String, Vec<String>> =
std::collections::HashMap::new();
for (pack, handler, filename, baseline) in baselines {
let report = classify_one(
ctx.fs.as_ref(),
ctx.paths.as_ref(),
&pack,
&handler,
&filename,
&baseline,
);
let no_reverse_patterns = no_reverse_cache
.entry(pack.clone())
.or_insert_with(|| pack_no_reverse_patterns(ctx, &pack));
let no_reverse = is_no_reverse(&report.source_path, no_reverse_patterns);
let action = match report.state {
DivergenceState::Synced => TransformAction::Synced,
DivergenceState::InputChanged => TransformAction::InputChanged,
DivergenceState::MissingSource => {
has_findings = true;
TransformAction::MissingSource
}
DivergenceState::MissingDeployed => {
has_findings = true;
TransformAction::MissingDeployed
}
DivergenceState::OutputChanged | DivergenceState::BothChanged if no_reverse => {
TransformAction::Synced
}
DivergenceState::OutputChanged | DivergenceState::BothChanged => {
if baseline.tracked_render.is_empty() {
has_findings = true;
TransformAction::NeedsRebaseline
} else {
let template_src = ctx.fs.read_to_string(&report.source_path)?;
let deployed = ctx.fs.read_to_string(&report.deployed_path)?;
let secret_ranges = crate::preprocessing::baseline::SecretsSidecar::load(
ctx.fs.as_ref(),
ctx.paths.as_ref(),
&pack,
&handler,
&filename,
)?
.map(|s| s.secret_line_ranges)
.unwrap_or_default();
match reverse_merge(
&template_src,
&baseline.tracked_render,
&deployed,
&secret_ranges,
)? {
ReverseMergeOutcome::Unchanged => TransformAction::Synced,
ReverseMergeOutcome::Patched(patched) => {
if !ctx.dry_run {
ctx.fs.write_file(&report.source_path, patched.as_bytes())?;
}
TransformAction::Patched
}
ReverseMergeOutcome::Conflict(block) => {
has_findings = true;
return_conflict_entry(
&mut entries,
report,
block,
ctx.paths.home_dir(),
);
continue;
}
}
}
}
};
entries.push(make_entry(report, action, ctx.paths.home_dir()));
}
let mut unresolved_markers = Vec::new();
if strict {
let baselines = collect_baselines(ctx.fs.as_ref(), ctx.paths.as_ref())?;
for (_pack, _handler, _filename, baseline) in baselines {
if baseline.source_path.as_os_str().is_empty() || !ctx.fs.exists(&baseline.source_path)
{
continue;
}
let bytes = ctx.fs.read_file(&baseline.source_path)?;
let content = String::from_utf8_lossy(&bytes);
let lines = find_unresolved_marker_lines(&content);
if !lines.is_empty() {
has_findings = true;
unresolved_markers.push(UnresolvedMarkerEntry {
source_path: render_path(&baseline.source_path, ctx.paths.home_dir()),
line_numbers: lines.iter().map(|(n, _)| *n).collect(),
});
}
}
}
Ok(TransformCheckResult {
entries,
unresolved_markers,
has_findings,
strict,
})
}
fn make_entry(
report: DivergenceReport,
action: TransformAction,
home: &std::path::Path,
) -> TransformCheckEntry {
TransformCheckEntry {
pack: report.pack,
handler: report.handler,
filename: report.filename,
source_path: render_path(&report.source_path, home),
deployed_path: render_path(&report.deployed_path, home),
action,
conflict_block: String::new(),
}
}
fn return_conflict_entry(
entries: &mut Vec<TransformCheckEntry>,
report: DivergenceReport,
block: String,
home: &std::path::Path,
) {
entries.push(TransformCheckEntry {
pack: report.pack,
handler: report.handler,
filename: report.filename,
source_path: render_path(&report.source_path, home),
deployed_path: render_path(&report.deployed_path, home),
action: TransformAction::Conflict,
conflict_block: block,
});
}
pub(super) fn render_path(p: &std::path::Path, home: &std::path::Path) -> String {
if let Ok(rel) = p.strip_prefix(home) {
format!("~/{}", rel.display())
} else {
p.display().to_string()
}
}
fn pack_no_reverse_patterns(ctx: &ExecutionContext, pack: &str) -> Vec<String> {
let pack_path = ctx.paths.dotfiles_root().join(pack);
match ctx.config_manager.config_for_pack(&pack_path) {
Ok(cfg) => cfg.preprocessor.template.no_reverse.clone(),
Err(_) => Vec::new(),
}
}
mod install_hook;
#[cfg(test)]
mod test_support;
pub use install_hook::{
hook_is_installed, install_hook, managed_block, InstallHookOutcome, InstallHookResult,
};
#[cfg(test)]
mod tests {
#![allow(unused_imports)]
use super::test_support::make_ctx;
use super::*;
use crate::fs::Fs;
use crate::paths::Pather;
use crate::testing::TempEnvironment;
fn deploy_template(
env: &TempEnvironment,
pack: &str,
template_name: &str,
template_body: &str,
config_toml: &str,
) -> std::path::PathBuf {
let src_path = env.dotfiles_root.join(pack).join(template_name);
env.fs.mkdir_all(src_path.parent().unwrap()).unwrap();
env.fs
.write_file(&src_path, template_body.as_bytes())
.unwrap();
if !config_toml.is_empty() {
env.fs
.write_file(
&env.dotfiles_root.join(".dodot.toml"),
config_toml.as_bytes(),
)
.unwrap();
}
let ctx = make_ctx(env);
let _ = crate::commands::up::up(None, &ctx).unwrap();
src_path
}
fn deployed_path(env: &TempEnvironment, pack: &str, filename: &str) -> std::path::PathBuf {
env.paths
.data_dir()
.join("packs")
.join(pack)
.join("preprocessed")
.join(filename)
}
#[test]
fn empty_cache_yields_clean_no_findings() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
let result = check(&ctx, false).unwrap();
assert!(result.entries.is_empty());
assert!(!result.has_findings);
assert_eq!(result.exit_code(), 0);
}
#[test]
fn synced_files_report_synced_and_no_findings() {
let env = TempEnvironment::builder().build();
deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let ctx = make_ctx(&env);
let result = check(&ctx, false).unwrap();
assert_eq!(result.entries.len(), 1);
assert!(matches!(result.entries[0].action, TransformAction::Synced));
assert!(!result.has_findings);
}
#[test]
fn output_changed_static_edit_patches_source() {
let env = TempEnvironment::builder().build();
let src_path = deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\nport = 5432\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let deployed = deployed_path(&env, "app", "config.toml");
env.fs
.write_file(&deployed, b"name = Alice\nport = 9999\n")
.unwrap();
let ctx = make_ctx(&env);
let result = check(&ctx, false).unwrap();
assert_eq!(result.entries.len(), 1);
assert!(
matches!(result.entries[0].action, TransformAction::Patched),
"got: {:?}",
result.entries[0].action
);
assert!(!result.has_findings);
assert_eq!(result.exit_code(), 0);
let new_src = env.fs.read_to_string(&src_path).unwrap();
assert!(new_src.contains("port = 9999"), "src: {new_src:?}");
assert!(new_src.contains("name = {{ name }}"), "src: {new_src:?}");
}
#[test]
fn output_changed_pure_data_edit_yields_synced() {
let env = TempEnvironment::builder().build();
let src_path = deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let original_src = env.fs.read_to_string(&src_path).unwrap();
let deployed = deployed_path(&env, "app", "config.toml");
env.fs.write_file(&deployed, b"name = Bob\n").unwrap();
let ctx = make_ctx(&env);
let result = check(&ctx, false).unwrap();
assert_eq!(result.entries.len(), 1);
assert!(matches!(result.entries[0].action, TransformAction::Synced));
assert_eq!(env.fs.read_to_string(&src_path).unwrap(), original_src);
}
#[test]
fn no_reverse_pattern_skips_reverse_merge() {
let env = TempEnvironment::builder().build();
let src_path = deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\nport = 5432\n",
"[preprocessor.template.vars]\n\
name = \"Alice\"\n\
[preprocessor.template]\n\
no_reverse = [\"config.toml.tmpl\"]\n",
);
let original_src = env.fs.read_to_string(&src_path).unwrap();
let deployed = deployed_path(&env, "app", "config.toml");
env.fs
.write_file(&deployed, b"name = Alice\nport = 9999\n")
.unwrap();
let ctx = make_ctx(&env);
let result = check(&ctx, false).unwrap();
assert_eq!(result.entries.len(), 1);
assert!(
matches!(result.entries[0].action, TransformAction::Synced),
"no_reverse must short-circuit to Synced; got: {:?}",
result.entries[0].action
);
assert!(!result.has_findings);
assert_eq!(result.exit_code(), 0);
assert_eq!(env.fs.read_to_string(&src_path).unwrap(), original_src);
}
#[test]
fn no_reverse_glob_pattern_skips_reverse_merge() {
let env = TempEnvironment::builder().build();
let src_path = deploy_template(
&env,
"app",
"foo.gen.tmpl",
"name = {{ name }}\nport = 5432\n",
"[preprocessor.template.vars]\n\
name = \"Alice\"\n\
[preprocessor.template]\n\
no_reverse = [\"*.gen.tmpl\"]\n",
);
let original_src = env.fs.read_to_string(&src_path).unwrap();
let deployed = deployed_path(&env, "app", "foo.gen");
env.fs
.write_file(&deployed, b"name = Alice\nport = 9999\n")
.unwrap();
let ctx = make_ctx(&env);
let result = check(&ctx, false).unwrap();
assert_eq!(result.entries.len(), 1);
assert!(matches!(result.entries[0].action, TransformAction::Synced));
assert!(!result.has_findings);
assert_eq!(env.fs.read_to_string(&src_path).unwrap(), original_src);
}
#[test]
fn dry_run_does_not_write_to_source() {
let env = TempEnvironment::builder().build();
let src_path = deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\nport = 5432\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let original_src = env.fs.read_to_string(&src_path).unwrap();
let deployed = deployed_path(&env, "app", "config.toml");
env.fs
.write_file(&deployed, b"name = Alice\nport = 9999\n")
.unwrap();
let mut ctx = make_ctx(&env);
ctx.dry_run = true;
let result = check(&ctx, false).unwrap();
assert!(matches!(result.entries[0].action, TransformAction::Patched));
assert_eq!(env.fs.read_to_string(&src_path).unwrap(), original_src);
}
#[test]
fn needs_rebaseline_when_tracked_render_is_empty_and_deployed_edited() {
let env = TempEnvironment::builder().build();
let src_path = env.dotfiles_root.join("app/config.toml.tmpl");
env.fs.mkdir_all(src_path.parent().unwrap()).unwrap();
env.fs.write_file(&src_path, b"name = {{ name }}").unwrap();
let baseline = crate::preprocessing::baseline::Baseline::build(
&src_path,
b"name = Alice",
b"name = {{ name }}",
None, None,
);
baseline
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap();
let deployed = deployed_path(&env, "app", "config.toml");
env.fs.mkdir_all(deployed.parent().unwrap()).unwrap();
env.fs
.write_file(&deployed, b"name = Edited\nport = 9999")
.unwrap();
let ctx = make_ctx(&env);
let result = check(&ctx, false).unwrap();
assert_eq!(result.entries.len(), 1);
assert!(
matches!(result.entries[0].action, TransformAction::NeedsRebaseline),
"got: {:?}",
result.entries[0].action
);
assert!(
result.has_findings,
"NeedsRebaseline must count as a finding"
);
assert_eq!(result.exit_code(), 1);
let src_after = env.fs.read_to_string(&src_path).unwrap();
assert_eq!(src_after, "name = {{ name }}");
}
#[test]
fn missing_source_is_reported_with_finding() {
let env = TempEnvironment::builder().build();
let baseline = crate::preprocessing::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 = deployed_path(&env, "app", "missing.toml");
env.fs.mkdir_all(deployed.parent().unwrap()).unwrap();
env.fs.write_file(&deployed, b"rendered").unwrap();
let ctx = make_ctx(&env);
let result = check(&ctx, false).unwrap();
assert!(matches!(
result.entries[0].action,
TransformAction::MissingSource
));
assert!(result.has_findings);
}
#[test]
fn strict_mode_flags_unresolved_marker_in_source() {
let env = TempEnvironment::builder().build();
let src_path = deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let dirty = format!(
"first\n{}\nbody\n{}\n",
crate::preprocessing::conflict::MARKER_START,
crate::preprocessing::conflict::MARKER_END,
);
env.fs.write_file(&src_path, dirty.as_bytes()).unwrap();
let ctx = make_ctx(&env);
let lax = check(&ctx, false).unwrap();
assert!(lax.unresolved_markers.is_empty());
let strict = check(&ctx, true).unwrap();
assert_eq!(strict.unresolved_markers.len(), 1);
assert_eq!(strict.unresolved_markers[0].line_numbers, vec![2, 4]);
assert!(strict.has_findings);
assert_eq!(strict.exit_code(), 1);
}
#[test]
fn strict_mode_clean_repo_is_zero_findings() {
let env = TempEnvironment::builder().build();
deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let ctx = make_ctx(&env);
let result = check(&ctx, true).unwrap();
assert!(result.unresolved_markers.is_empty());
assert!(!result.has_findings);
assert_eq!(result.exit_code(), 0);
}
#[test]
fn paths_are_rendered_relative_to_home_for_display() {
let env = TempEnvironment::builder().build();
deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let ctx = make_ctx(&env);
let result = check(&ctx, false).unwrap();
let entry = &result.entries[0];
assert!(
entry.source_path.starts_with("~/") || entry.deployed_path.starts_with("~/"),
"expected ~/-relative paths in report, got source={} deployed={}",
entry.source_path,
entry.deployed_path
);
}
#[test]
fn status_on_clean_repo_reports_one_synced_row() {
let env = TempEnvironment::builder().build();
deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let ctx = make_ctx(&env);
let result = status(&ctx).unwrap();
assert_eq!(result.entries.len(), 1);
assert_eq!(result.entries[0].state, "synced");
assert_eq!(result.synced_count, 1);
assert_eq!(result.diverged_count, 0);
assert_eq!(result.missing_count, 0);
}
#[test]
fn status_surfaces_secret_references_from_sidecar() {
let env = TempEnvironment::builder().build();
deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let sidecar = crate::preprocessing::baseline::SecretsSidecar::new(vec![
crate::preprocessing::SecretLineRange {
start: 0,
end: 1,
reference: "pass:test/db_password".into(),
},
crate::preprocessing::SecretLineRange {
start: 2,
end: 3,
reference: "op://Personal/api/token".into(),
},
]);
sidecar
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"config.toml",
)
.unwrap();
let ctx = make_ctx(&env);
let result = status(&ctx).unwrap();
assert_eq!(result.entries.len(), 1);
assert_eq!(
result.entries[0].secret_references,
vec![
"pass:test/db_password".to_string(),
"op://Personal/api/token".to_string(),
]
);
}
#[test]
fn status_returns_empty_secret_references_when_no_sidecar() {
let env = TempEnvironment::builder().build();
deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let ctx = make_ctx(&env);
let result = status(&ctx).unwrap();
assert!(result.entries[0].secret_references.is_empty());
}
#[test]
fn status_classifies_output_change() {
let env = TempEnvironment::builder().build();
deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\nport = 5432\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let deployed = deployed_path(&env, "app", "config.toml");
env.fs
.write_file(&deployed, b"name = Alice\nport = 9999\n")
.unwrap();
let ctx = make_ctx(&env);
let result = status(&ctx).unwrap();
assert_eq!(result.entries[0].state, "output_changed");
assert_eq!(result.diverged_count, 1);
assert_eq!(result.synced_count, 0);
}
#[test]
fn status_does_not_mutate_anything() {
let env = TempEnvironment::builder().build();
let src = deploy_template(
&env,
"app",
"config.toml.tmpl",
"name = {{ name }}\nport = 5432\n",
"[preprocessor.template.vars]\nname = \"Alice\"\n",
);
let original_src = env.fs.read_to_string(&src).unwrap();
let deployed = deployed_path(&env, "app", "config.toml");
env.fs
.write_file(&deployed, b"name = Alice\nport = 9999\n")
.unwrap();
let ctx = make_ctx(&env);
let _ = status(&ctx).unwrap();
assert_eq!(env.fs.read_to_string(&src).unwrap(), original_src);
}
#[test]
fn status_empty_cache_yields_zero_counts() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
let result = status(&ctx).unwrap();
assert!(result.entries.is_empty());
assert_eq!(result.synced_count, 0);
assert_eq!(result.diverged_count, 0);
assert_eq!(result.missing_count, 0);
}
}