Skip to main content

dodot_lib/preprocessing/
reverse_merge.rs

1//! Reverse-merge engine — propagates deployed-file edits back to the
2//! template source.
3//!
4//! Wraps [`burgertocow::generate_diff_with_markers`] and
5//! [`diffy::Patch`] into a single function that takes a template and
6//! the cached marker-annotated render (from the baseline cache) plus
7//! the current deployed text, and produces one of three outcomes:
8//!
9//! - [`ReverseMergeOutcome::Unchanged`] — pure data edit (only
10//!   variable values changed). The template is correct as-is.
11//! - [`ReverseMergeOutcome::Patched`] — burgertocow produced a clean
12//!   unified diff; the patched template content is returned for the
13//!   caller to write back to the source file.
14//! - [`ReverseMergeOutcome::Conflict`] — burgertocow couldn't safely
15//!   attribute every edit to a static template line (typically because
16//!   the edit overlaps a `{{ var }}` region). The conflict block
17//!   string is returned for the caller to surface to the user; the
18//!   template source is left alone.
19//!
20//! # Why we don't re-render here
21//!
22//! The whole point of caching `tracked_render` in the baseline is that
23//! `dodot transform check` can compute reverse-diffs without invoking
24//! the template engine again. Re-rendering would re-trigger any
25//! secret-provider auth prompts in the variable context — auth fatigue
26//! that the magic.lex design specifically rules out. We rehydrate the
27//! cached tracked string via
28//! [`burgertocow::TrackedRender::from_tracked_string`] (added in
29//! burgertocow 0.3) and feed it into `generate_diff_with_markers`
30//! directly.
31
32use burgertocow::{generate_diff_with_markers, ConflictMarkers, TrackedRender};
33use diffy::Patch;
34
35use crate::preprocessing::conflict::{MARKER_END, MARKER_MID, MARKER_START};
36use crate::{DodotError, Result};
37
38/// Result of a reverse-merge attempt.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub enum ReverseMergeOutcome {
41    /// No template change is needed. The deployed-file edit was
42    /// confined to variable values.
43    Unchanged,
44    /// burgertocow produced a clean unified diff; the field carries
45    /// the patched template content. Callers write this back to the
46    /// source file.
47    Patched(String),
48    /// burgertocow could not safely auto-merge. The field carries the
49    /// conflict block (as emitted by burgertocow with our markers) so
50    /// the caller can surface it to the user; the source file is not
51    /// modified by `transform check` in this case — the user resolves
52    /// it manually with their editor and `git diff`.
53    Conflict(String),
54}
55
56impl ReverseMergeOutcome {
57    /// True iff this outcome represents a template-space change that
58    /// the caller should record. `Unchanged` is "no work"; `Patched`
59    /// and `Conflict` are both "something happened".
60    pub fn is_actionable(&self) -> bool {
61        !matches!(self, ReverseMergeOutcome::Unchanged)
62    }
63}
64
65/// Compute a reverse-merge for one processed file.
66///
67/// Returns [`ReverseMergeOutcome::Conflict`] when burgertocow flags an
68/// ambiguous edit, [`ReverseMergeOutcome::Patched`] when it produces a
69/// clean unified diff that diffy successfully applies, and
70/// [`ReverseMergeOutcome::Unchanged`] when there's no template-space
71/// change to make.
72pub fn reverse_merge(
73    template_src: &str,
74    cached_tracked: &str,
75    deployed: &str,
76) -> Result<ReverseMergeOutcome> {
77    if cached_tracked.is_empty() {
78        // No tracked render in the baseline (e.g. a v1 baseline with
79        // serde-defaulted empty tracked_render, or a non-template
80        // preprocessor). We can't drive burgertocow without the
81        // marker stream — surface as Unchanged so the caller's loop
82        // moves on. The classifier already flagged the divergence;
83        // dropping in here just declines to auto-merge.
84        return Ok(ReverseMergeOutcome::Unchanged);
85    }
86
87    let tracked = TrackedRender::from_tracked_string(cached_tracked.to_string());
88    // Each marker line ends in `\n` so the conflict block sits cleanly
89    // on its own lines when burgertocow joins them. Bound to locals
90    // because `ConflictMarkers` borrows from these strings.
91    let start = format!("{MARKER_START}\n");
92    let mid = format!("\n{MARKER_MID}\n");
93    let end = format!("\n{MARKER_END}\n");
94    let markers = ConflictMarkers::new(&start, &mid, &end);
95    let diff = generate_diff_with_markers(template_src, &tracked, deployed, &markers);
96
97    if diff.is_empty() {
98        return Ok(ReverseMergeOutcome::Unchanged);
99    }
100
101    // burgertocow returns *either* a unified diff *or* a conflict-only
102    // string. We distinguish by looking at how the result starts: a
103    // unified diff begins with `--- header` (the headers we set are
104    // "template" / "modified"); a conflict block begins with our
105    // MARKER_START line.
106    if diff.starts_with(MARKER_START) {
107        return Ok(ReverseMergeOutcome::Conflict(diff));
108    }
109
110    // Unified diff path: parse and apply via diffy.
111    //
112    // Error messages deliberately do NOT include the diff body. The
113    // diff is built from the deployed file, which can carry secret
114    // values that were resolved at render time. Spilling that into
115    // stderr / CI logs would leak credentials. Callers needing to
116    // debug a parse/apply failure can grep the deployed file or the
117    // baseline cache directly — the metadata in the error (the
118    // burgertocow error string and a short fingerprint) is enough to
119    // locate the offending entry without surfacing the bytes.
120    let patch = Patch::from_str(&diff).map_err(|e| {
121        DodotError::Other(format!(
122            "reverse-merge produced an invalid unified diff: {e} \
123             ({} chars, sha-256 prefix {})",
124            diff.len(),
125            short_diff_fingerprint(&diff),
126        ))
127    })?;
128    let patched = diffy::apply(template_src, &patch).map_err(|e| {
129        DodotError::Other(format!(
130            "failed to apply reverse-merge diff to template: {e} \
131             ({} chars, sha-256 prefix {})",
132            diff.len(),
133            short_diff_fingerprint(&diff),
134        ))
135    })?;
136    Ok(ReverseMergeOutcome::Patched(patched))
137}
138
139/// Hash the diff and return the first 16 hex chars — enough to tell
140/// two failure reports apart without leaking the diff body. Used by
141/// the error paths in [`reverse_merge`].
142fn short_diff_fingerprint(diff: &str) -> String {
143    use sha2::{Digest, Sha256};
144    let digest = Sha256::digest(diff.as_bytes());
145    let mut out = String::with_capacity(16);
146    for b in digest.iter().take(8) {
147        out.push_str(&format!("{:02x}", b));
148    }
149    out
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use burgertocow::Tracker;
156
157    /// Render a template through burgertocow, returning the visible
158    /// output and the cached tracked string the way the baseline
159    /// cache would store them.
160    fn render(src: &str, ctx: serde_json::Value) -> (String, String) {
161        let mut tracker = Tracker::new();
162        tracker.add_template("t", src).unwrap();
163        let tracked = tracker.render("t", &ctx).unwrap();
164        (tracked.output().to_string(), tracked.tracked().to_string())
165    }
166
167    #[test]
168    fn unchanged_when_only_variable_values_changed() {
169        // The user didn't touch any static template content — they
170        // changed a variable's value. Reverse-merge sees this as a
171        // pure-data edit and recommends no template change.
172        let template = "name = {{ name }}\nport = 5432\n";
173        let (rendered, tracked) = render(template, serde_json::json!({"name": "Alice"}));
174        // Re-render with a different value to simulate the deployed
175        // file as it would be after the next `dodot up` (or after
176        // the user manually edited the value).
177        let _ = rendered;
178        let deployed = "name = Bob\nport = 5432\n";
179        let outcome = reverse_merge(template, &tracked, deployed).unwrap();
180        assert_eq!(outcome, ReverseMergeOutcome::Unchanged);
181    }
182
183    #[test]
184    fn patches_static_text_edit_outside_variables() {
185        // The user changed a static line that has no template
186        // expression. Reverse-merge should produce a Patched outcome
187        // whose content reflects the edit applied to the template
188        // source.
189        let template = "name = {{ name }}\nport = 5432\n";
190        let (_, tracked) = render(template, serde_json::json!({"name": "Alice"}));
191        let deployed = "name = Alice\nport = 9999\n";
192        let outcome = reverse_merge(template, &tracked, deployed).unwrap();
193        match outcome {
194            ReverseMergeOutcome::Patched(patched) => {
195                // The static-line edit propagates back to the
196                // template, but the variable-bearing line stays as
197                // `{{ name }}` (so future renders still pick up the
198                // current value).
199                assert!(patched.contains("port = 9999"), "patched: {patched:?}");
200                assert!(
201                    patched.contains("name = {{ name }}"),
202                    "patched: {patched:?}"
203                );
204            }
205            other => panic!("expected Patched, got: {other:?}"),
206        }
207    }
208
209    #[test]
210    fn flags_conflict_for_inconsistent_per_iteration_edits() {
211        // The textbook conflict case from burgertocow's README:
212        // different static edits across loop iterations. Iteration 1
213        // changes `-` to `*`; iteration 2 changes `-` to `+`.
214        // burgertocow can't pick a single template-space replacement,
215        // so it emits a conflict block. Our wrapper surfaces that as
216        // Conflict and leaves the source untouched.
217        let template = "{% for i in items %}- {{ i }}\n{% endfor %}";
218        let (_, tracked) = render(template, serde_json::json!({"items": ["a", "b", "c"]}));
219        // Inconsistent prefix edits per iteration:
220        let deployed = "* a\n+ b\n- c\n";
221        let outcome = reverse_merge(template, &tracked, deployed).unwrap();
222        assert!(
223            matches!(outcome, ReverseMergeOutcome::Conflict(_)),
224            "expected Conflict for inconsistent loop-iteration edits, got: {outcome:?}"
225        );
226        if let ReverseMergeOutcome::Conflict(block) = outcome {
227            assert!(block.starts_with(MARKER_START), "block: {block:?}");
228            assert!(block.contains(MARKER_MID), "block: {block:?}");
229            assert!(block.contains(MARKER_END), "block: {block:?}");
230        }
231    }
232
233    #[test]
234    fn auto_merges_consistent_edit_across_loop_iterations() {
235        // The companion case: the user changed `-` to `*` in *every*
236        // iteration. burgertocow's loop-iteration fallback consolidates
237        // those into a single template-space replacement, so the
238        // outcome is Patched, not Conflict. This pins that we don't
239        // pessimistically surface every loop edit as a conflict.
240        let template = "{% for i in items %}- {{ i }}\n{% endfor %}";
241        let (_, tracked) = render(template, serde_json::json!({"items": ["a", "b", "c"]}));
242        let deployed = "* a\n* b\n* c\n";
243        let outcome = reverse_merge(template, &tracked, deployed).unwrap();
244        match outcome {
245            ReverseMergeOutcome::Patched(patched) => {
246                // Template's loop body now uses `*` instead of `-`.
247                assert!(patched.contains("* {{ i }}"), "patched: {patched:?}");
248            }
249            other => panic!("expected Patched for consistent loop edit, got: {other:?}"),
250        }
251    }
252
253    #[test]
254    fn unchanged_when_cached_tracked_is_empty() {
255        // Forward-compat with v1 baselines that were serde-defaulted
256        // to an empty tracked_render. Without the marker stream we
257        // can't drive burgertocow — return Unchanged so the caller's
258        // loop simply moves on.
259        let outcome = reverse_merge("name = {{ name }}\n", "", "name = Alice\n").unwrap();
260        assert_eq!(outcome, ReverseMergeOutcome::Unchanged);
261    }
262
263    #[test]
264    fn patched_outcome_is_byte_stable_across_runs() {
265        // Determinism: identical inputs produce identical patched
266        // output. This guards against any non-determinism leaking in
267        // through diffy or burgertocow's diff machinery.
268        let template = "alpha = {{ a }}\nbeta = static\ngamma = {{ g }}\n";
269        let (_, tracked) = render(template, serde_json::json!({"a": "1", "g": "2"}));
270        let deployed = "alpha = 1\nbeta = changed\ngamma = 2\n";
271        let r1 = reverse_merge(template, &tracked, deployed).unwrap();
272        let r2 = reverse_merge(template, &tracked, deployed).unwrap();
273        assert_eq!(r1, r2);
274    }
275
276    #[test]
277    fn is_actionable_distinguishes_outcomes() {
278        assert!(!ReverseMergeOutcome::Unchanged.is_actionable());
279        assert!(ReverseMergeOutcome::Patched(String::new()).is_actionable());
280        assert!(ReverseMergeOutcome::Conflict(String::new()).is_actionable());
281    }
282}