use burgertocow::{generate_diff_with_markers_opts, ConflictMarkers, DiffOptions, TrackedRender};
use diffy::Patch;
use std::io::{Read, Write};
use std::ops::Range;
use std::path::Path;
use crate::fs::Fs;
use crate::paths::Pather;
use crate::preprocessing::baseline::{hex_sha256, SecretsSidecar};
use crate::preprocessing::conflict::{MARKER_END, MARKER_MID, MARKER_START};
use crate::preprocessing::divergence::find_baseline_for_source;
use crate::preprocessing::no_reverse::is_no_reverse;
use crate::Result;
pub fn template_clean(
fs: &dyn Fs,
paths: &dyn Pather,
template_src: &str,
source_path: &Path,
no_reverse_patterns: &[String],
) -> Result<String> {
let Some((_pack, _handler, _filename, baseline)) =
find_baseline_for_source(fs, paths, source_path)?
else {
return Ok(template_src.to_string());
};
let (pack, handler, filename, baseline) = (_pack, _handler, _filename, baseline);
let deployed_path = paths
.data_dir()
.join("packs")
.join(&pack)
.join(&handler)
.join(&filename);
if !fs.exists(&deployed_path) {
return Ok(template_src.to_string());
}
let deployed_bytes = fs.read_file(&deployed_path)?;
if hex_sha256(&deployed_bytes) == baseline.rendered_hash {
return Ok(template_src.to_string());
}
if is_no_reverse(source_path, no_reverse_patterns) {
return Ok(template_src.to_string());
}
if baseline.tracked_render.is_empty() {
return Ok(template_src.to_string());
}
let tracked = TrackedRender::from_tracked_string(baseline.tracked_render.clone());
let deployed_str = String::from_utf8_lossy(&deployed_bytes);
let start = format!("{MARKER_START}\n");
let mid = format!("\n{MARKER_MID}\n");
let end = format!("\n{MARKER_END}\n");
let markers = ConflictMarkers::new(&start, &mid, &end);
let secret_ranges = SecretsSidecar::load(fs, paths, &pack, &handler, &filename)?
.map(|s| s.secret_line_ranges)
.unwrap_or_default();
let mask: Vec<Range<usize>> = secret_ranges.iter().map(|r| r.start..r.end).collect();
let opts = DiffOptions::new(&markers).with_mask(&mask);
let diff = generate_diff_with_markers_opts(template_src, &tracked, &deployed_str, &opts);
if diff.is_empty() {
return Ok(template_src.to_string());
}
if diff.starts_with(MARKER_START) {
let mut out = template_src.to_string();
if !out.ends_with('\n') {
out.push('\n');
}
out.push_str(&diff);
return Ok(out);
}
let patch = match Patch::from_str(&diff) {
Ok(p) => p,
Err(_) => return Ok(template_src.to_string()),
};
match diffy::apply(template_src, &patch) {
Ok(patched) => Ok(patched),
Err(_) => Ok(template_src.to_string()),
}
}
pub fn template_clean_stdio(
fs: &dyn Fs,
paths: &dyn Pather,
source_path: &Path,
no_reverse_patterns: &[String],
stdin: &mut dyn Read,
stdout: &mut dyn Write,
) -> Result<()> {
let mut buf = Vec::new();
stdin
.read_to_end(&mut buf)
.map_err(|e| crate::DodotError::Other(format!("template clean: stdin read: {e}")))?;
let src = String::from_utf8_lossy(&buf).into_owned();
let out = match template_clean(fs, paths, &src, source_path, no_reverse_patterns) {
Ok(o) => o,
Err(e) => {
eprintln!(
"dodot template clean: degraded to echo for {}: {e}",
source_path.display()
);
src
}
};
stdout
.write_all(out.as_bytes())
.map_err(|e| crate::DodotError::Other(format!("template clean: stdout write: {e}")))?;
stdout
.flush()
.map_err(|e| crate::DodotError::Other(format!("template clean: stdout flush: {e}")))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::preprocessing::baseline::Baseline;
use crate::testing::TempEnvironment;
use burgertocow::Tracker;
fn render(src: &str, ctx: serde_json::Value) -> (String, String) {
let mut tracker = Tracker::new();
tracker.add_template("t", src).unwrap();
let tracked = tracker.render("t", &ctx).unwrap();
(tracked.output().to_string(), tracked.tracked().to_string())
}
fn stage(
env: &TempEnvironment,
pack: &str,
template_name: &str,
template_body: &str,
ctx: serde_json::Value,
) -> (std::path::PathBuf, std::path::PathBuf, String) {
let src = env.dotfiles_root.join(pack).join(template_name);
env.fs.mkdir_all(src.parent().unwrap()).unwrap();
env.fs.write_file(&src, template_body.as_bytes()).unwrap();
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);
env.fs.mkdir_all(deployed.parent().unwrap()).unwrap();
let (rendered, tracked) = render(template_body, ctx);
env.fs.write_file(&deployed, rendered.as_bytes()).unwrap();
let baseline = Baseline::build(
&src,
rendered.as_bytes(),
template_body.as_bytes(),
Some(&tracked),
None,
);
baseline
.write(
env.fs.as_ref(),
env.paths.as_ref(),
pack,
"preprocessed",
stripped,
)
.unwrap();
(src, deployed, rendered)
}
#[test]
fn fast_path_echoes_stdin_when_deployed_matches_baseline() {
let env = TempEnvironment::builder().build();
let template = "name = {{ name }}\nport = 5432\n";
let (src, _deployed, _) = stage(
&env,
"app",
"cfg.tmpl",
template,
serde_json::json!({"name": "Alice"}),
);
let out = template_clean(env.fs.as_ref(), env.paths.as_ref(), template, &src, &[]).unwrap();
assert_eq!(out, template, "fast path must echo stdin verbatim");
}
#[test]
fn slow_path_patches_static_line_edit() {
let env = TempEnvironment::builder().build();
let template = "name = {{ name }}\nport = 5432\n";
let (src, deployed, _) = stage(
&env,
"app",
"cfg.tmpl",
template,
serde_json::json!({"name": "Alice"}),
);
env.fs
.write_file(&deployed, b"name = Alice\nport = 9999\n")
.unwrap();
let out = template_clean(env.fs.as_ref(), env.paths.as_ref(), template, &src, &[]).unwrap();
assert!(
out.contains("port = 9999"),
"expected patched static line, got: {out:?}"
);
assert!(
out.contains("name = {{ name }}"),
"var must survive, got: {out:?}"
);
}
#[test]
fn no_reverse_pattern_match_skips_slow_path() {
let env = TempEnvironment::builder().build();
let template = "name = {{ name }}\nport = 5432\n";
let (src, deployed, _) = stage(
&env,
"app",
"cfg.tmpl",
template,
serde_json::json!({"name": "Alice"}),
);
env.fs
.write_file(&deployed, b"name = Alice\nport = 9999\n")
.unwrap();
let out = template_clean(
env.fs.as_ref(),
env.paths.as_ref(),
template,
&src,
&["cfg.tmpl".to_string()],
)
.unwrap();
assert_eq!(
out, template,
"no_reverse match must echo stdin (no patched output)"
);
}
#[test]
fn no_reverse_glob_match_skips_slow_path() {
let env = TempEnvironment::builder().build();
let template = "name = {{ name }}\nport = 5432\n";
let (src, deployed, _) = stage(
&env,
"app",
"foo.gen.tmpl",
template,
serde_json::json!({"name": "Alice"}),
);
env.fs
.write_file(&deployed, b"name = Alice\nport = 9999\n")
.unwrap();
let out = template_clean(
env.fs.as_ref(),
env.paths.as_ref(),
template,
&src,
&["*.gen.tmpl".to_string()],
)
.unwrap();
assert_eq!(out, template);
}
#[test]
fn no_reverse_does_not_block_fast_path() {
let env = TempEnvironment::builder().build();
let template = "name = {{ name }}\n";
let (src, _deployed, _) = stage(
&env,
"app",
"cfg.tmpl",
template,
serde_json::json!({"name": "Alice"}),
);
let out = template_clean(
env.fs.as_ref(),
env.paths.as_ref(),
template,
&src,
&["cfg.tmpl".to_string()],
)
.unwrap();
assert_eq!(out, template);
}
#[test]
fn slow_path_pure_data_edit_echoes_stdin() {
let env = TempEnvironment::builder().build();
let template = "name = {{ name }}\n";
let (src, deployed, _) = stage(
&env,
"app",
"cfg.tmpl",
template,
serde_json::json!({"name": "Alice"}),
);
env.fs.write_file(&deployed, b"name = Bob\n").unwrap();
let out = template_clean(env.fs.as_ref(), env.paths.as_ref(), template, &src, &[]).unwrap();
assert_eq!(out, template);
}
#[test]
fn slow_path_conflict_appends_marker_block_to_template() {
let env = TempEnvironment::builder().build();
let template = "{% for i in items %}- {{ i }}\n{% endfor %}";
let (src, deployed, _) = stage(
&env,
"app",
"list.tmpl",
template,
serde_json::json!({"items": ["a", "b", "c"]}),
);
env.fs.write_file(&deployed, b"* a\n+ b\n- c\n").unwrap();
let out = template_clean(env.fs.as_ref(), env.paths.as_ref(), template, &src, &[]).unwrap();
assert!(
out.contains("{% for i in items %}"),
"original template must be retained: {out:?}"
);
assert!(
out.contains(MARKER_START),
"conflict block missing: {out:?}"
);
assert!(
out.contains(MARKER_END),
"conflict block missing end: {out:?}"
);
}
#[test]
fn unknown_source_path_echoes_stdin() {
let env = TempEnvironment::builder().build();
let stranger = env.dotfiles_root.join("not-a-pack/random.tmpl");
env.fs.mkdir_all(stranger.parent().unwrap()).unwrap();
let body = "hello {{ x }}\n";
env.fs.write_file(&stranger, body.as_bytes()).unwrap();
let out =
template_clean(env.fs.as_ref(), env.paths.as_ref(), body, &stranger, &[]).unwrap();
assert_eq!(out, body);
}
#[test]
fn missing_deployed_echoes_stdin() {
let env = TempEnvironment::builder().build();
let template = "name = {{ name }}\n";
let (src, deployed, _) = stage(
&env,
"app",
"cfg.tmpl",
template,
serde_json::json!({"name": "Alice"}),
);
env.fs.remove_file(&deployed).unwrap();
let out = template_clean(env.fs.as_ref(), env.paths.as_ref(), template, &src, &[]).unwrap();
assert_eq!(out, template);
}
#[test]
fn empty_tracked_render_falls_back_to_echo() {
let env = TempEnvironment::builder().build();
let template = "name = {{ name }}\n";
let src = env.dotfiles_root.join("app/cfg.tmpl");
env.fs.mkdir_all(src.parent().unwrap()).unwrap();
env.fs.write_file(&src, template.as_bytes()).unwrap();
let deployed = env.paths.data_dir().join("packs/app/preprocessed/cfg");
env.fs.mkdir_all(deployed.parent().unwrap()).unwrap();
env.fs.write_file(&deployed, b"name = EDITED\n").unwrap();
let baseline = Baseline::build(&src, b"name = Alice\n", template.as_bytes(), None, None);
baseline
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"cfg",
)
.unwrap();
let out = template_clean(env.fs.as_ref(), env.paths.as_ref(), template, &src, &[]).unwrap();
assert_eq!(out, template);
}
#[test]
fn stdio_passthrough_writes_filter_output_to_stdout() {
let env = TempEnvironment::builder().build();
let template = "name = {{ name }}\n";
let (src, _deployed, _) = stage(
&env,
"app",
"cfg.tmpl",
template,
serde_json::json!({"name": "Alice"}),
);
let mut stdin = std::io::Cursor::new(template.as_bytes().to_vec());
let mut stdout: Vec<u8> = Vec::new();
template_clean_stdio(
env.fs.as_ref(),
env.paths.as_ref(),
&src,
&[],
&mut stdin,
&mut stdout,
)
.unwrap();
assert_eq!(stdout, template.as_bytes());
}
#[test]
fn stdio_soft_fails_when_inner_clean_errors() {
let env = TempEnvironment::builder().build();
let src = env.dotfiles_root.join("app/cfg.tmpl");
env.fs.mkdir_all(src.parent().unwrap()).unwrap();
let template = "name = {{ name }}\n";
env.fs.write_file(&src, template.as_bytes()).unwrap();
let cache_path = env
.paths
.preprocessor_baseline_path("app", "preprocessed", "cfg");
env.fs.mkdir_all(cache_path.parent().unwrap()).unwrap();
env.fs.write_file(&cache_path, b"{not json").unwrap();
let mut stdin = std::io::Cursor::new(template.as_bytes().to_vec());
let mut stdout: Vec<u8> = Vec::new();
template_clean_stdio(
env.fs.as_ref(),
env.paths.as_ref(),
&src,
&[],
&mut stdin,
&mut stdout,
)
.expect("stdio must soft-fail to echo, not propagate Err");
assert_eq!(stdout, template.as_bytes());
}
#[test]
fn filter_never_fails_on_baseline_disagreement() {
let env = TempEnvironment::builder().build();
let template = "name = {{ name }}\n";
let src = env.dotfiles_root.join("app/cfg.tmpl");
env.fs.mkdir_all(src.parent().unwrap()).unwrap();
env.fs.write_file(&src, template.as_bytes()).unwrap();
let deployed = env.paths.data_dir().join("packs/app/preprocessed/cfg");
env.fs.mkdir_all(deployed.parent().unwrap()).unwrap();
env.fs
.write_file(&deployed, b"unrelated content\n")
.unwrap();
let baseline = Baseline::build(
&src,
b"different",
template.as_bytes(),
Some("\u{1e}wrong\u{1f}"),
None,
);
baseline
.write(
env.fs.as_ref(),
env.paths.as_ref(),
"app",
"preprocessed",
"cfg",
)
.unwrap();
let _ = template_clean(env.fs.as_ref(), env.paths.as_ref(), template, &src, &[]).unwrap();
}
}