Skip to main content

kaptein_viewmodel/
diff.rs

1//! Unified diff — "diff before apply" (M1.3).
2//!
3//! A dependency-free, renderer-agnostic unified diff over lines. The edit flow compares
4//! the live object's YAML against the edited manifest and shows what would change; this
5//! is the *semantic* "before/after" the frontend renders (additions, removals, context).
6//! It is deliberately small — the full three-way merge lives in Phase 2 with GitOps.
7
8/// A single diff line: its kind (`+` added, `-` removed, ` ` context) and the line text.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct DiffLine {
11    /// `+`, `-`, or ` ` (context).
12    pub tag: char,
13    pub text: String,
14}
15
16/// A complete unified diff, split into hunks.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub struct UnifiedDiff {
19    /// The hunks (each a contiguous run of changes plus surrounding context).
20    pub hunks: Vec<Vec<DiffLine>>,
21    /// Total lines added.
22    pub added: usize,
23    /// Total lines removed.
24    pub removed: usize,
25}
26
27/// Compute a unified diff between `old` and `new` (as whole strings). Uses a
28/// longest-common-subsequence algorithm over lines; `context` is the number of unchanged
29/// lines to keep around each change (0 = minimal diff).
30pub fn unified_diff(old: &str, new: &str, context: usize) -> UnifiedDiff {
31    let old_lines: Vec<&str> = old.lines().collect();
32    let new_lines: Vec<&str> = new.lines().collect();
33
34    // LCS DP over lines.
35    let (n, m) = (old_lines.len(), new_lines.len());
36    let mut dp = vec![vec![0usize; m + 1]; n + 1];
37    for i in (0..n).rev() {
38        for j in (0..m).rev() {
39            dp[i][j] = if old_lines[i] == new_lines[j] {
40                dp[i + 1][j + 1] + 1
41            } else {
42                dp[i + 1][j].max(dp[i][j + 1])
43            };
44        }
45    }
46
47    // Backtrack to produce the edit script.
48    #[derive(PartialEq)]
49    enum Op {
50        Keep,
51        Remove,
52        Add,
53    }
54    let mut ops: Vec<Op> = Vec::new();
55    let (mut i, mut j) = (0, 0);
56    while i < n && j < m {
57        if old_lines[i] == new_lines[j] {
58            ops.push(Op::Keep);
59            i += 1;
60            j += 1;
61        } else if dp[i + 1][j] >= dp[i][j + 1] {
62            ops.push(Op::Remove);
63            i += 1;
64        } else {
65            ops.push(Op::Add);
66            j += 1;
67        }
68    }
69    while i < n {
70        ops.push(Op::Remove);
71        i += 1;
72    }
73    while j < m {
74        ops.push(Op::Add);
75        j += 1;
76    }
77
78    // Build tagged lines.
79    let mut tagged: Vec<DiffLine> = Vec::new();
80    let (mut oi, mut ni) = (0usize, 0usize);
81    let (mut added, mut removed) = (0usize, 0usize);
82    for op in &ops {
83        match op {
84            Op::Keep => {
85                tagged.push(DiffLine {
86                    tag: ' ',
87                    text: old_lines[oi].to_string(),
88                });
89                oi += 1;
90                ni += 1;
91            }
92            Op::Remove => {
93                tagged.push(DiffLine {
94                    tag: '-',
95                    text: old_lines[oi].to_string(),
96                });
97                removed += 1;
98                oi += 1;
99            }
100            Op::Add => {
101                tagged.push(DiffLine {
102                    tag: '+',
103                    text: new_lines[ni].to_string(),
104                });
105                added += 1;
106                ni += 1;
107            }
108        }
109    }
110
111    // Group into hunks with context.
112    let hunks = group_hunks(&tagged, context);
113
114    UnifiedDiff {
115        hunks,
116        added,
117        removed,
118    }
119}
120
121/// Render the diff as a string in unified-diff format (for the CLI).
122pub fn render_unified(diff: &UnifiedDiff, old_label: &str, new_label: &str) -> String {
123    let mut out = String::new();
124    out.push_str(&format!("--- {old_label}\n+++ {new_label}\n"));
125    for (i, hunk) in diff.hunks.iter().enumerate() {
126        out.push_str(&format!("@@ hunk {} @@\n", i + 1));
127        for line in hunk {
128            out.push(line.tag);
129            out.push(' ');
130            out.push_str(&line.text);
131            out.push('\n');
132        }
133    }
134    out
135}
136
137fn group_hunks(tagged: &[DiffLine], context: usize) -> Vec<Vec<DiffLine>> {
138    // Identify the indices of changed lines (`+` or `-`).
139    let is_changed = |l: &DiffLine| l.tag == '+' || l.tag == '-';
140
141    let mut hunks: Vec<Vec<DiffLine>> = Vec::new();
142    let mut i = 0usize;
143    let n = tagged.len();
144
145    while i < n {
146        if !is_changed(&tagged[i]) {
147            i += 1;
148            continue;
149        }
150        // Start of a change run: include `context` lines before.
151        let start = i.saturating_sub(context);
152        // Extend to include `context` lines after the last change in this run.
153        let mut end = i;
154        while end < n && is_changed(&tagged[end]) {
155            end += 1;
156        }
157        // Include trailing context after the change run.
158        let mut trailing = end;
159        let mut count = 0;
160        while trailing < n && count < context {
161            if is_changed(&tagged[trailing]) {
162                break;
163            }
164            trailing += 1;
165            count += 1;
166        }
167        let hunk: Vec<DiffLine> = tagged[start..trailing].to_vec();
168        if !hunk.is_empty() {
169            hunks.push(hunk);
170        }
171        i = trailing.max(end).max(i + 1);
172    }
173
174    hunks
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn identical_strings_produce_no_changes() {
183        let d = unified_diff("a\nb\nc", "a\nb\nc", 0);
184        assert_eq!(d.added, 0);
185        assert_eq!(d.removed, 0);
186        assert!(d.hunks.is_empty());
187    }
188
189    #[test]
190    fn single_line_change_is_detected() {
191        let d = unified_diff("a\nb\nc", "a\nx\nc", 0);
192        assert_eq!(d.added, 1);
193        assert_eq!(d.removed, 1);
194        // The hunk contains the removed 'b' and added 'x'.
195        let hunk = &d.hunks[0];
196        assert!(hunk.iter().any(|l| l.tag == '-' && l.text == "b"));
197        assert!(hunk.iter().any(|l| l.tag == '+' && l.text == "x"));
198    }
199
200    #[test]
201    fn context_lines_are_included() {
202        let d = unified_diff("a\nb\nc\nd\ne", "a\nb\nX\nd\ne", 1);
203        // With context 1, the hunk includes 'b' (before) and 'd' (after) as context.
204        let hunk = &d.hunks[0];
205        assert!(hunk.iter().any(|l| l.tag == ' ' && l.text == "b"));
206        assert!(hunk.iter().any(|l| l.tag == ' ' && l.text == "d"));
207        assert!(hunk.iter().any(|l| l.tag == '+' && l.text == "X"));
208    }
209
210    #[test]
211    fn render_unified_has_header_and_hunks() {
212        let d = unified_diff("a\nb", "a\nc", 0);
213        let s = render_unified(&d, "old.yaml", "new.yaml");
214        assert!(s.contains("--- old.yaml"));
215        assert!(s.contains("+++ new.yaml"));
216        assert!(s.contains("- b"));
217        assert!(s.contains("+ c"));
218    }
219}