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}