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 std::ops::Range;
33
34use burgertocow::{generate_diff_with_markers_opts, ConflictMarkers, DiffOptions, TrackedRender};
35use diffy::Patch;
36
37use crate::preprocessing::conflict::{MARKER_END, MARKER_MID, MARKER_START};
38use crate::preprocessing::SecretLineRange;
39use crate::{DodotError, Result};
40
41/// Result of a reverse-merge attempt.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum ReverseMergeOutcome {
44    /// No template change is needed. The deployed-file edit was
45    /// confined to variable values.
46    Unchanged,
47    /// burgertocow produced a clean unified diff; the field carries
48    /// the patched template content. Callers write this back to the
49    /// source file.
50    Patched(String),
51    /// burgertocow could not safely auto-merge. The field carries the
52    /// conflict block (as emitted by burgertocow with our markers) so
53    /// the caller can surface it to the user; the source file is not
54    /// modified by `transform check` in this case — the user resolves
55    /// it manually with their editor and `git diff`.
56    Conflict(String),
57}
58
59impl ReverseMergeOutcome {
60    /// True iff this outcome represents a template-space change that
61    /// the caller should record. `Unchanged` is "no work"; `Patched`
62    /// and `Conflict` are both "something happened".
63    pub fn is_actionable(&self) -> bool {
64        !matches!(self, ReverseMergeOutcome::Unchanged)
65    }
66}
67
68/// Compute a reverse-merge for one processed file.
69///
70/// Returns [`ReverseMergeOutcome::Conflict`] when burgertocow flags an
71/// ambiguous edit, [`ReverseMergeOutcome::Patched`] when it produces a
72/// clean unified diff that diffy successfully applies, and
73/// [`ReverseMergeOutcome::Unchanged`] when there's no template-space
74/// change to make.
75///
76/// `secret_ranges` is the per-render secrets sidecar
77/// (`<baseline>.secret.json`) for this file, if one was loaded.
78/// Burgertocow's mask treats each listed range as if its deployed
79/// content always matched the cached render, regardless of actual
80/// bytes — so a rotated `{{ secret(...) }}` value (or a hand-edit
81/// to a secret line) does not propagate into a template-space diff
82/// that would replace the `secret(...)` expression with the literal
83/// rotated value. See `secrets.lex` §3.3 / burgertocow#13.
84///
85/// The legacy three-arg shape is preserved as
86/// [`reverse_merge_no_mask`] for callers that don't have a sidecar
87/// loaded yet (every existing call site keeps the same behavior).
88pub fn reverse_merge(
89    template_src: &str,
90    cached_tracked: &str,
91    deployed: &str,
92    secret_ranges: &[SecretLineRange],
93) -> Result<ReverseMergeOutcome> {
94    if cached_tracked.is_empty() {
95        // No tracked render in the baseline (e.g. a v1 baseline with
96        // serde-defaulted empty tracked_render, or a non-template
97        // preprocessor). We can't drive burgertocow without the
98        // marker stream — surface as Unchanged so the caller's loop
99        // moves on. The classifier already flagged the divergence;
100        // dropping in here just declines to auto-merge.
101        return Ok(ReverseMergeOutcome::Unchanged);
102    }
103
104    let tracked = TrackedRender::from_tracked_string(cached_tracked.to_string());
105    // Each marker line ends in `\n` so the conflict block sits cleanly
106    // on its own lines when burgertocow joins them. Bound to locals
107    // because `ConflictMarkers` borrows from these strings.
108    let start = format!("{MARKER_START}\n");
109    let mid = format!("\n{MARKER_MID}\n");
110    let end = format!("\n{MARKER_END}\n");
111    let markers = ConflictMarkers::new(&start, &mid, &end);
112    // Convert `SecretLineRange { start, end, .. }` to the
113    // `Range<usize>` shape `DiffOptions` consumes. Bound to a local
114    // `Vec` because `with_mask` takes a borrowed slice.
115    let mask: Vec<Range<usize>> = secret_ranges.iter().map(|r| r.start..r.end).collect();
116    let opts = DiffOptions::new(&markers).with_mask(&mask);
117    let diff = generate_diff_with_markers_opts(template_src, &tracked, deployed, &opts);
118
119    if diff.is_empty() {
120        return Ok(ReverseMergeOutcome::Unchanged);
121    }
122
123    // burgertocow returns *either* a unified diff *or* a conflict-only
124    // string. We distinguish by looking at how the result starts: a
125    // unified diff begins with `--- header` (the headers we set are
126    // "template" / "modified"); a conflict block begins with our
127    // MARKER_START line.
128    if diff.starts_with(MARKER_START) {
129        return Ok(ReverseMergeOutcome::Conflict(diff));
130    }
131
132    // Unified diff path: parse and apply via diffy.
133    //
134    // Error messages deliberately do NOT include the diff body. The
135    // diff is built from the deployed file, which can carry secret
136    // values that were resolved at render time. Spilling that into
137    // stderr / CI logs would leak credentials. Callers needing to
138    // debug a parse/apply failure can grep the deployed file or the
139    // baseline cache directly — the metadata in the error (the
140    // burgertocow error string and a short fingerprint) is enough to
141    // locate the offending entry without surfacing the bytes.
142    let patch = Patch::from_str(&diff).map_err(|e| {
143        DodotError::Other(format!(
144            "reverse-merge produced an invalid unified diff: {e} \
145             ({} chars, sha-256 prefix {})",
146            diff.len(),
147            short_diff_fingerprint(&diff),
148        ))
149    })?;
150    let patched = diffy::apply(template_src, &patch).map_err(|e| {
151        DodotError::Other(format!(
152            "failed to apply reverse-merge diff to template: {e} \
153             ({} chars, sha-256 prefix {})",
154            diff.len(),
155            short_diff_fingerprint(&diff),
156        ))
157    })?;
158    Ok(ReverseMergeOutcome::Patched(patched))
159}
160
161/// Convenience for callers that haven't loaded a secrets sidecar
162/// for this file (or are computing reverse-merge for a file that
163/// has no `secret(...)` calls). Equivalent to
164/// [`reverse_merge`] with an empty mask — burgertocow then behaves
165/// byte-identically to the pre-0.4 single-mask-less entry point.
166pub fn reverse_merge_no_mask(
167    template_src: &str,
168    cached_tracked: &str,
169    deployed: &str,
170) -> Result<ReverseMergeOutcome> {
171    reverse_merge(template_src, cached_tracked, deployed, &[])
172}
173
174/// Hash the diff and return the first 16 hex chars — enough to tell
175/// two failure reports apart without leaking the diff body. Used by
176/// the error paths in [`reverse_merge`].
177fn short_diff_fingerprint(diff: &str) -> String {
178    use sha2::{Digest, Sha256};
179    let digest = Sha256::digest(diff.as_bytes());
180    let mut out = String::with_capacity(16);
181    for b in digest.iter().take(8) {
182        out.push_str(&format!("{:02x}", b));
183    }
184    out
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190    use burgertocow::Tracker;
191
192    /// Render a template through burgertocow, returning the visible
193    /// output and the cached tracked string the way the baseline
194    /// cache would store them.
195    fn render(src: &str, ctx: serde_json::Value) -> (String, String) {
196        let mut tracker = Tracker::new();
197        tracker.add_template("t", src).unwrap();
198        let tracked = tracker.render("t", &ctx).unwrap();
199        (tracked.output().to_string(), tracked.tracked().to_string())
200    }
201
202    #[test]
203    fn unchanged_when_only_variable_values_changed() {
204        // The user didn't touch any static template content — they
205        // changed a variable's value. Reverse-merge sees this as a
206        // pure-data edit and recommends no template change.
207        let template = "name = {{ name }}\nport = 5432\n";
208        let (rendered, tracked) = render(template, serde_json::json!({"name": "Alice"}));
209        // Re-render with a different value to simulate the deployed
210        // file as it would be after the next `dodot up` (or after
211        // the user manually edited the value).
212        let _ = rendered;
213        let deployed = "name = Bob\nport = 5432\n";
214        let outcome = reverse_merge(template, &tracked, deployed, &[]).unwrap();
215        assert_eq!(outcome, ReverseMergeOutcome::Unchanged);
216    }
217
218    #[test]
219    fn patches_static_text_edit_outside_variables() {
220        // The user changed a static line that has no template
221        // expression. Reverse-merge should produce a Patched outcome
222        // whose content reflects the edit applied to the template
223        // source.
224        let template = "name = {{ name }}\nport = 5432\n";
225        let (_, tracked) = render(template, serde_json::json!({"name": "Alice"}));
226        let deployed = "name = Alice\nport = 9999\n";
227        let outcome = reverse_merge(template, &tracked, deployed, &[]).unwrap();
228        match outcome {
229            ReverseMergeOutcome::Patched(patched) => {
230                // The static-line edit propagates back to the
231                // template, but the variable-bearing line stays as
232                // `{{ name }}` (so future renders still pick up the
233                // current value).
234                assert!(patched.contains("port = 9999"), "patched: {patched:?}");
235                assert!(
236                    patched.contains("name = {{ name }}"),
237                    "patched: {patched:?}"
238                );
239            }
240            other => panic!("expected Patched, got: {other:?}"),
241        }
242    }
243
244    #[test]
245    fn flags_conflict_for_inconsistent_per_iteration_edits() {
246        // The textbook conflict case from burgertocow's README:
247        // different static edits across loop iterations. Iteration 1
248        // changes `-` to `*`; iteration 2 changes `-` to `+`.
249        // burgertocow can't pick a single template-space replacement,
250        // so it emits a conflict block. Our wrapper surfaces that as
251        // Conflict and leaves the source untouched.
252        let template = "{% for i in items %}- {{ i }}\n{% endfor %}";
253        let (_, tracked) = render(template, serde_json::json!({"items": ["a", "b", "c"]}));
254        // Inconsistent prefix edits per iteration:
255        let deployed = "* a\n+ b\n- c\n";
256        let outcome = reverse_merge(template, &tracked, deployed, &[]).unwrap();
257        assert!(
258            matches!(outcome, ReverseMergeOutcome::Conflict(_)),
259            "expected Conflict for inconsistent loop-iteration edits, got: {outcome:?}"
260        );
261        if let ReverseMergeOutcome::Conflict(block) = outcome {
262            assert!(block.starts_with(MARKER_START), "block: {block:?}");
263            assert!(block.contains(MARKER_MID), "block: {block:?}");
264            assert!(block.contains(MARKER_END), "block: {block:?}");
265        }
266    }
267
268    #[test]
269    fn auto_merges_consistent_edit_across_loop_iterations() {
270        // The companion case: the user changed `-` to `*` in *every*
271        // iteration. burgertocow's loop-iteration fallback consolidates
272        // those into a single template-space replacement, so the
273        // outcome is Patched, not Conflict. This pins that we don't
274        // pessimistically surface every loop edit as a conflict.
275        let template = "{% for i in items %}- {{ i }}\n{% endfor %}";
276        let (_, tracked) = render(template, serde_json::json!({"items": ["a", "b", "c"]}));
277        let deployed = "* a\n* b\n* c\n";
278        let outcome = reverse_merge(template, &tracked, deployed, &[]).unwrap();
279        match outcome {
280            ReverseMergeOutcome::Patched(patched) => {
281                // Template's loop body now uses `*` instead of `-`.
282                assert!(patched.contains("* {{ i }}"), "patched: {patched:?}");
283            }
284            other => panic!("expected Patched for consistent loop edit, got: {other:?}"),
285        }
286    }
287
288    #[test]
289    fn unchanged_when_cached_tracked_is_empty() {
290        // Forward-compat with v1 baselines that were serde-defaulted
291        // to an empty tracked_render. Without the marker stream we
292        // can't drive burgertocow — return Unchanged so the caller's
293        // loop simply moves on.
294        let outcome = reverse_merge("name = {{ name }}\n", "", "name = Alice\n", &[]).unwrap();
295        assert_eq!(outcome, ReverseMergeOutcome::Unchanged);
296    }
297
298    #[test]
299    fn patched_outcome_is_byte_stable_across_runs() {
300        // Determinism: identical inputs produce identical patched
301        // output. This guards against any non-determinism leaking in
302        // through diffy or burgertocow's diff machinery.
303        let template = "alpha = {{ a }}\nbeta = static\ngamma = {{ g }}\n";
304        let (_, tracked) = render(template, serde_json::json!({"a": "1", "g": "2"}));
305        let deployed = "alpha = 1\nbeta = changed\ngamma = 2\n";
306        let r1 = reverse_merge(template, &tracked, deployed, &[]).unwrap();
307        let r2 = reverse_merge(template, &tracked, deployed, &[]).unwrap();
308        assert_eq!(r1, r2);
309    }
310
311    #[test]
312    fn is_actionable_distinguishes_outcomes() {
313        assert!(!ReverseMergeOutcome::Unchanged.is_actionable());
314        assert!(ReverseMergeOutcome::Patched(String::new()).is_actionable());
315        assert!(ReverseMergeOutcome::Conflict(String::new()).is_actionable());
316    }
317
318    /// Helper for the masking tests below — build a single-line
319    /// `SecretLineRange` covering the given 0-indexed line.
320    fn mask_line(line: usize, reference: &str) -> SecretLineRange {
321        SecretLineRange {
322            start: line,
323            end: line + 1,
324            reference: reference.to_string(),
325        }
326    }
327
328    #[test]
329    fn mask_makes_secret_line_rotation_invisible_to_reverse_diff() {
330        // The motivating case from `secrets.lex` §3.3: the deployed
331        // file's secret value rotated (the user re-`up`'d after
332        // their vault changed), but the *template* still says
333        // `{{ secret("...") }}` and the source-of-truth is the
334        // vault, not the deployed bytes. Without the mask, the
335        // reverse-merge would propose rewriting the secret() call
336        // to the literal new value. With the mask, the change is
337        // invisible — Unchanged.
338        //
339        // The bare burgertocow `Tracker` doesn't have `secret()`
340        // installed (that's TemplatePreprocessor's job), so we
341        // simulate by rendering a template whose line 1 is a static
342        // "password = OLD" baseline; the deployed file shows the
343        // rotated "password = NEW_ROTATED". With line 1 masked, the
344        // change is invisible.
345        let template = "user = {{ name }}\npassword = OLD\n";
346        let (rendered, tracked) = render(template, serde_json::json!({"name": "Ada"}));
347        assert_eq!(rendered, "user = Ada\npassword = OLD\n");
348        let deployed = "user = Ada\npassword = NEW_ROTATED\n";
349
350        // Without mask: a unified diff propagates the rotated value
351        // back to the template (or surfaces a conflict — either way,
352        // *not* Unchanged).
353        let unmasked = reverse_merge(template, &tracked, deployed, &[]).unwrap();
354        assert_ne!(unmasked, ReverseMergeOutcome::Unchanged);
355
356        // With the line-1 mask: byte change on the masked line is
357        // invisible → Unchanged.
358        let masked =
359            reverse_merge(template, &tracked, deployed, &[mask_line(1, "pass:db")]).unwrap();
360        assert_eq!(masked, ReverseMergeOutcome::Unchanged);
361    }
362
363    #[test]
364    fn mask_does_not_hide_unmasked_static_line_edits() {
365        // Mask one line; edit a different (unmasked) line. The
366        // unmasked edit must still surface as Patched — masking is
367        // a per-line opt-out, not a "disable reverse-merge" switch.
368        let template = "user = {{ name }}\nport = 5432\nsecret_line = OLD\n";
369        let (_, tracked) = render(template, serde_json::json!({"name": "Ada"}));
370        let deployed = "user = Ada\nport = 9999\nsecret_line = NEW\n";
371
372        let outcome = reverse_merge(
373            template,
374            &tracked,
375            deployed,
376            &[mask_line(2, "pass:secret_line")],
377        )
378        .unwrap();
379        match outcome {
380            ReverseMergeOutcome::Patched(patched) => {
381                assert!(patched.contains("port = 9999"), "patched: {patched:?}");
382                // The masked line stays at "OLD" in the source.
383                assert!(
384                    patched.contains("secret_line = OLD"),
385                    "masked line must not propagate: {patched:?}"
386                );
387            }
388            other => panic!("expected Patched, got: {other:?}"),
389        }
390    }
391
392    #[test]
393    fn empty_mask_is_byte_equivalent_to_unmasked() {
394        // Pin the back-compat property burgertocow 0.4 documents:
395        // an empty mask makes the call behave byte-identical to the
396        // pre-mask entry point. `reverse_merge_no_mask` is the wrapper.
397        let template = "name = {{ name }}\nport = 5432\n";
398        let (_, tracked) = render(template, serde_json::json!({"name": "Ada"}));
399        let deployed = "name = Ada\nport = 9999\n";
400
401        let with_empty = reverse_merge(template, &tracked, deployed, &[]).unwrap();
402        let no_mask = reverse_merge_no_mask(template, &tracked, deployed).unwrap();
403        assert_eq!(with_empty, no_mask);
404    }
405}