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}