use burgertocow::{generate_diff_with_markers, ConflictMarkers, TrackedRender};
use diffy::Patch;
use crate::preprocessing::conflict::{MARKER_END, MARKER_MID, MARKER_START};
use crate::{DodotError, Result};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ReverseMergeOutcome {
Unchanged,
Patched(String),
Conflict(String),
}
impl ReverseMergeOutcome {
pub fn is_actionable(&self) -> bool {
!matches!(self, ReverseMergeOutcome::Unchanged)
}
}
pub fn reverse_merge(
template_src: &str,
cached_tracked: &str,
deployed: &str,
) -> Result<ReverseMergeOutcome> {
if cached_tracked.is_empty() {
return Ok(ReverseMergeOutcome::Unchanged);
}
let tracked = TrackedRender::from_tracked_string(cached_tracked.to_string());
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 diff = generate_diff_with_markers(template_src, &tracked, deployed, &markers);
if diff.is_empty() {
return Ok(ReverseMergeOutcome::Unchanged);
}
if diff.starts_with(MARKER_START) {
return Ok(ReverseMergeOutcome::Conflict(diff));
}
let patch = Patch::from_str(&diff).map_err(|e| {
DodotError::Other(format!(
"reverse-merge produced an invalid unified diff: {e} \
({} chars, sha-256 prefix {})",
diff.len(),
short_diff_fingerprint(&diff),
))
})?;
let patched = diffy::apply(template_src, &patch).map_err(|e| {
DodotError::Other(format!(
"failed to apply reverse-merge diff to template: {e} \
({} chars, sha-256 prefix {})",
diff.len(),
short_diff_fingerprint(&diff),
))
})?;
Ok(ReverseMergeOutcome::Patched(patched))
}
fn short_diff_fingerprint(diff: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(diff.as_bytes());
let mut out = String::with_capacity(16);
for b in digest.iter().take(8) {
out.push_str(&format!("{:02x}", b));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
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())
}
#[test]
fn unchanged_when_only_variable_values_changed() {
let template = "name = {{ name }}\nport = 5432\n";
let (rendered, tracked) = render(template, serde_json::json!({"name": "Alice"}));
let _ = rendered;
let deployed = "name = Bob\nport = 5432\n";
let outcome = reverse_merge(template, &tracked, deployed).unwrap();
assert_eq!(outcome, ReverseMergeOutcome::Unchanged);
}
#[test]
fn patches_static_text_edit_outside_variables() {
let template = "name = {{ name }}\nport = 5432\n";
let (_, tracked) = render(template, serde_json::json!({"name": "Alice"}));
let deployed = "name = Alice\nport = 9999\n";
let outcome = reverse_merge(template, &tracked, deployed).unwrap();
match outcome {
ReverseMergeOutcome::Patched(patched) => {
assert!(patched.contains("port = 9999"), "patched: {patched:?}");
assert!(
patched.contains("name = {{ name }}"),
"patched: {patched:?}"
);
}
other => panic!("expected Patched, got: {other:?}"),
}
}
#[test]
fn flags_conflict_for_inconsistent_per_iteration_edits() {
let template = "{% for i in items %}- {{ i }}\n{% endfor %}";
let (_, tracked) = render(template, serde_json::json!({"items": ["a", "b", "c"]}));
let deployed = "* a\n+ b\n- c\n";
let outcome = reverse_merge(template, &tracked, deployed).unwrap();
assert!(
matches!(outcome, ReverseMergeOutcome::Conflict(_)),
"expected Conflict for inconsistent loop-iteration edits, got: {outcome:?}"
);
if let ReverseMergeOutcome::Conflict(block) = outcome {
assert!(block.starts_with(MARKER_START), "block: {block:?}");
assert!(block.contains(MARKER_MID), "block: {block:?}");
assert!(block.contains(MARKER_END), "block: {block:?}");
}
}
#[test]
fn auto_merges_consistent_edit_across_loop_iterations() {
let template = "{% for i in items %}- {{ i }}\n{% endfor %}";
let (_, tracked) = render(template, serde_json::json!({"items": ["a", "b", "c"]}));
let deployed = "* a\n* b\n* c\n";
let outcome = reverse_merge(template, &tracked, deployed).unwrap();
match outcome {
ReverseMergeOutcome::Patched(patched) => {
assert!(patched.contains("* {{ i }}"), "patched: {patched:?}");
}
other => panic!("expected Patched for consistent loop edit, got: {other:?}"),
}
}
#[test]
fn unchanged_when_cached_tracked_is_empty() {
let outcome = reverse_merge("name = {{ name }}\n", "", "name = Alice\n").unwrap();
assert_eq!(outcome, ReverseMergeOutcome::Unchanged);
}
#[test]
fn patched_outcome_is_byte_stable_across_runs() {
let template = "alpha = {{ a }}\nbeta = static\ngamma = {{ g }}\n";
let (_, tracked) = render(template, serde_json::json!({"a": "1", "g": "2"}));
let deployed = "alpha = 1\nbeta = changed\ngamma = 2\n";
let r1 = reverse_merge(template, &tracked, deployed).unwrap();
let r2 = reverse_merge(template, &tracked, deployed).unwrap();
assert_eq!(r1, r2);
}
#[test]
fn is_actionable_distinguishes_outcomes() {
assert!(!ReverseMergeOutcome::Unchanged.is_actionable());
assert!(ReverseMergeOutcome::Patched(String::new()).is_actionable());
assert!(ReverseMergeOutcome::Conflict(String::new()).is_actionable());
}
}