Skip to main content

axon/
version_diff.rs

1//! Version Diff — line-based source diff between flow versions.
2//!
3//! Compares source snapshots stored in the VersionRegistry to show what
4//! changed between two deployments of the same flow.
5//!
6//! Uses a longest-common-subsequence (LCS) algorithm for line-level diffs,
7//! producing a unified-style output with context lines.
8
9use std::io::IsTerminal;
10
11use crate::flow_version::VersionRegistry;
12
13// ── Diff structures ─────────────────────────────────────────────────────
14
15/// A line-level diff between two source texts.
16#[derive(Debug, Clone, serde::Serialize)]
17pub struct VersionDiff {
18    pub flow_name: String,
19    pub from_version: u32,
20    pub to_version: u32,
21    pub from_hash: String,
22    pub to_hash: String,
23    pub identical: bool,
24    pub hunks: Vec<DiffHunk>,
25    pub summary: DiffSummary,
26}
27
28/// A contiguous region of changes with surrounding context.
29#[derive(Debug, Clone, serde::Serialize)]
30pub struct DiffHunk {
31    pub old_start: usize,
32    pub old_count: usize,
33    pub new_start: usize,
34    pub new_count: usize,
35    pub lines: Vec<DiffLine>,
36}
37
38/// A single line in the diff output.
39#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
40pub struct DiffLine {
41    pub kind: LineKind,
42    pub content: String,
43}
44
45/// Kind of a diff line.
46#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
47#[serde(rename_all = "lowercase")]
48pub enum LineKind {
49    Context,
50    Added,
51    Removed,
52}
53
54/// Summary statistics for a diff.
55#[derive(Debug, Clone, serde::Serialize)]
56pub struct DiffSummary {
57    pub lines_added: usize,
58    pub lines_removed: usize,
59    pub lines_unchanged: usize,
60    pub hunks: usize,
61}
62
63// ── LCS-based diff ──────────────────────────────────────────────────────
64
65/// Compute a line-level diff between two source strings.
66pub fn diff_lines(old: &str, new: &str) -> Vec<DiffLine> {
67    let old_lines: Vec<&str> = old.lines().collect();
68    let new_lines: Vec<&str> = new.lines().collect();
69
70    let edits = lcs_diff(&old_lines, &new_lines);
71    edits
72}
73
74/// LCS-based diff producing a sequence of DiffLine entries.
75fn lcs_diff(old: &[&str], new: &[&str]) -> Vec<DiffLine> {
76    let m = old.len();
77    let n = new.len();
78
79    // Build LCS table
80    let mut table = vec![vec![0u32; n + 1]; m + 1];
81    for i in 1..=m {
82        for j in 1..=n {
83            if old[i - 1] == new[j - 1] {
84                table[i][j] = table[i - 1][j - 1] + 1;
85            } else {
86                table[i][j] = table[i - 1][j].max(table[i][j - 1]);
87            }
88        }
89    }
90
91    // Backtrack to produce diff
92    let mut result = Vec::new();
93    let mut i = m;
94    let mut j = n;
95
96    while i > 0 || j > 0 {
97        if i > 0 && j > 0 && old[i - 1] == new[j - 1] {
98            result.push(DiffLine {
99                kind: LineKind::Context,
100                content: old[i - 1].to_string(),
101            });
102            i -= 1;
103            j -= 1;
104        } else if j > 0 && (i == 0 || table[i][j - 1] >= table[i - 1][j]) {
105            result.push(DiffLine {
106                kind: LineKind::Added,
107                content: new[j - 1].to_string(),
108            });
109            j -= 1;
110        } else {
111            result.push(DiffLine {
112                kind: LineKind::Removed,
113                content: old[i - 1].to_string(),
114            });
115            i -= 1;
116        }
117    }
118
119    result.reverse();
120    result
121}
122
123/// Group diff lines into hunks with context.
124pub fn make_hunks(lines: &[DiffLine], context: usize) -> Vec<DiffHunk> {
125    if lines.is_empty() {
126        return Vec::new();
127    }
128
129    // Find ranges of changed lines
130    let mut change_positions: Vec<usize> = Vec::new();
131    for (i, line) in lines.iter().enumerate() {
132        if line.kind != LineKind::Context {
133            change_positions.push(i);
134        }
135    }
136
137    if change_positions.is_empty() {
138        return Vec::new();
139    }
140
141    // Group changes into hunks (merge if context overlaps)
142    let mut hunks: Vec<DiffHunk> = Vec::new();
143    let mut hunk_start = change_positions[0].saturating_sub(context);
144    let mut hunk_end = (change_positions[0] + context + 1).min(lines.len());
145
146    for &pos in &change_positions[1..] {
147        let this_start = pos.saturating_sub(context);
148        let this_end = (pos + context + 1).min(lines.len());
149
150        if this_start <= hunk_end {
151            // Merge with current hunk
152            hunk_end = this_end;
153        } else {
154            // Emit current hunk and start new one
155            hunks.push(build_hunk(&lines[hunk_start..hunk_end], hunk_start, lines));
156            hunk_start = this_start;
157            hunk_end = this_end;
158        }
159    }
160    hunks.push(build_hunk(&lines[hunk_start..hunk_end], hunk_start, lines));
161
162    hunks
163}
164
165fn build_hunk(hunk_lines: &[DiffLine], start_in_diff: usize, all_lines: &[DiffLine]) -> DiffHunk {
166    // Compute old/new line numbers
167    let mut old_start = 1usize;
168    let mut new_start = 1usize;
169    for line in &all_lines[..start_in_diff] {
170        match line.kind {
171            LineKind::Context => { old_start += 1; new_start += 1; }
172            LineKind::Removed => { old_start += 1; }
173            LineKind::Added => { new_start += 1; }
174        }
175    }
176
177    let mut old_count = 0;
178    let mut new_count = 0;
179    for line in hunk_lines {
180        match line.kind {
181            LineKind::Context => { old_count += 1; new_count += 1; }
182            LineKind::Removed => { old_count += 1; }
183            LineKind::Added => { new_count += 1; }
184        }
185    }
186
187    DiffHunk {
188        old_start,
189        old_count,
190        new_start,
191        new_count,
192        lines: hunk_lines.to_vec(),
193    }
194}
195
196// ── Version-aware diff ──────────────────────────────────────────────────
197
198/// Diff two versions of a flow from the registry.
199pub fn diff_versions(
200    registry: &VersionRegistry,
201    flow_name: &str,
202    from_version: u32,
203    to_version: u32,
204) -> Result<VersionDiff, String> {
205    let from = registry.get_version(flow_name, from_version)
206        .ok_or_else(|| format!("version {} not found for flow '{}'", from_version, flow_name))?;
207    let to = registry.get_version(flow_name, to_version)
208        .ok_or_else(|| format!("version {} not found for flow '{}'", to_version, flow_name))?;
209
210    let lines = diff_lines(&from.source, &to.source);
211
212    let lines_added = lines.iter().filter(|l| l.kind == LineKind::Added).count();
213    let lines_removed = lines.iter().filter(|l| l.kind == LineKind::Removed).count();
214    let lines_unchanged = lines.iter().filter(|l| l.kind == LineKind::Context).count();
215    let identical = lines_added == 0 && lines_removed == 0;
216
217    let hunks = make_hunks(&lines, 3);
218
219    Ok(VersionDiff {
220        flow_name: flow_name.to_string(),
221        from_version,
222        to_version,
223        from_hash: from.source_hash.clone(),
224        to_hash: to.source_hash.clone(),
225        identical,
226        hunks: hunks.clone(),
227        summary: DiffSummary {
228            lines_added,
229            lines_removed,
230            lines_unchanged,
231            hunks: hunks.len(),
232        },
233    })
234}
235
236// ── Display ─────────────────────────────────────────────────────────────
237
238/// Print a version diff in human-readable unified format.
239pub fn print_version_diff(diff: &VersionDiff) {
240    let use_color = std::io::stdout().is_terminal();
241
242    let bold = if use_color { "\x1b[1m" } else { "" };
243    let red = if use_color { "\x1b[31m" } else { "" };
244    let green = if use_color { "\x1b[32m" } else { "" };
245    let cyan = if use_color { "\x1b[36m" } else { "" };
246    let dim = if use_color { "\x1b[2m" } else { "" };
247    let reset = if use_color { "\x1b[0m" } else { "" };
248
249    println!("{}--- {}/v{} ({}){}",
250        bold, diff.flow_name, diff.from_version, diff.from_hash, reset);
251    println!("{}+++ {}/v{} ({}){}",
252        bold, diff.flow_name, diff.to_version, diff.to_hash, reset);
253
254    if diff.identical {
255        println!("{}(identical){}", dim, reset);
256        return;
257    }
258
259    for hunk in &diff.hunks {
260        println!("{}@@ -{},{} +{},{} @@{}",
261            cyan, hunk.old_start, hunk.old_count,
262            hunk.new_start, hunk.new_count, reset);
263
264        for line in &hunk.lines {
265            match line.kind {
266                LineKind::Context => println!(" {}", line.content),
267                LineKind::Added => println!("{}+{}{}", green, line.content, reset),
268                LineKind::Removed => println!("{}-{}{}", red, line.content, reset),
269            }
270        }
271    }
272
273    println!();
274    println!("{}{} added, {} removed, {} unchanged{}",
275        dim, diff.summary.lines_added, diff.summary.lines_removed,
276        diff.summary.lines_unchanged, reset);
277}
278
279// ── Tests ────────────────────────────────────────────────────────────────
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284    use crate::flow_version::VersionRegistry;
285
286    #[test]
287    fn diff_identical_sources() {
288        let src = "line1\nline2\nline3";
289        let lines = diff_lines(src, src);
290        assert!(lines.iter().all(|l| l.kind == LineKind::Context));
291        assert_eq!(lines.len(), 3);
292    }
293
294    #[test]
295    fn diff_added_lines() {
296        let old = "line1\nline3";
297        let new = "line1\nline2\nline3";
298        let lines = diff_lines(old, new);
299
300        let added: Vec<_> = lines.iter().filter(|l| l.kind == LineKind::Added).collect();
301        assert_eq!(added.len(), 1);
302        assert_eq!(added[0].content, "line2");
303    }
304
305    #[test]
306    fn diff_removed_lines() {
307        let old = "line1\nline2\nline3";
308        let new = "line1\nline3";
309        let lines = diff_lines(old, new);
310
311        let removed: Vec<_> = lines.iter().filter(|l| l.kind == LineKind::Removed).collect();
312        assert_eq!(removed.len(), 1);
313        assert_eq!(removed[0].content, "line2");
314    }
315
316    #[test]
317    fn diff_modified_line() {
318        let old = "line1\nold content\nline3";
319        let new = "line1\nnew content\nline3";
320        let lines = diff_lines(old, new);
321
322        let removed: Vec<_> = lines.iter().filter(|l| l.kind == LineKind::Removed).collect();
323        let added: Vec<_> = lines.iter().filter(|l| l.kind == LineKind::Added).collect();
324        assert_eq!(removed.len(), 1);
325        assert_eq!(removed[0].content, "old content");
326        assert_eq!(added.len(), 1);
327        assert_eq!(added[0].content, "new content");
328    }
329
330    #[test]
331    fn diff_empty_to_content() {
332        let lines = diff_lines("", "line1\nline2");
333        let added: Vec<_> = lines.iter().filter(|l| l.kind == LineKind::Added).collect();
334        assert_eq!(added.len(), 2);
335    }
336
337    #[test]
338    fn diff_content_to_empty() {
339        let lines = diff_lines("line1\nline2", "");
340        let removed: Vec<_> = lines.iter().filter(|l| l.kind == LineKind::Removed).collect();
341        assert_eq!(removed.len(), 2);
342    }
343
344    #[test]
345    fn diff_both_empty() {
346        let lines = diff_lines("", "");
347        assert!(lines.is_empty());
348    }
349
350    #[test]
351    fn hunks_with_context() {
352        let old = "a\nb\nc\nd\ne\nf\ng\nh";
353        let new = "a\nb\nX\nd\ne\nf\ng\nh";
354        let lines = diff_lines(old, new);
355        let hunks = make_hunks(&lines, 2);
356
357        assert_eq!(hunks.len(), 1);
358        // Context of 2 around the change at line 3
359        assert!(hunks[0].lines.len() <= 7); // 2 before + removed + added + 2 after (up to available)
360    }
361
362    #[test]
363    fn hunks_separate_changes() {
364        // Two changes far apart (more than 2*context lines between them)
365        let old = "1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12";
366        let new = "1\nX\n3\n4\n5\n6\n7\n8\n9\n10\nY\n12";
367        let lines = diff_lines(old, new);
368        let hunks = make_hunks(&lines, 1);
369
370        assert_eq!(hunks.len(), 2);
371    }
372
373    #[test]
374    fn hunks_empty_for_identical() {
375        let src = "a\nb\nc";
376        let lines = diff_lines(src, src);
377        let hunks = make_hunks(&lines, 3);
378        assert!(hunks.is_empty());
379    }
380
381    #[test]
382    fn diff_versions_from_registry() {
383        let mut reg = VersionRegistry::new();
384        let flows = vec!["F".to_string()];
385        reg.record_deploy(&flows, "line1\nline2\nline3", "f.axon", "anthropic");
386        reg.record_deploy(&flows, "line1\nmodified\nline3\nline4", "f.axon", "anthropic");
387
388        let diff = diff_versions(&reg, "F", 1, 2).unwrap();
389        assert!(!diff.identical);
390        assert_eq!(diff.flow_name, "F");
391        assert_eq!(diff.from_version, 1);
392        assert_eq!(diff.to_version, 2);
393        assert_eq!(diff.summary.lines_added, 2); // "modified" + "line4"
394        assert_eq!(diff.summary.lines_removed, 1); // "line2"
395    }
396
397    #[test]
398    fn diff_versions_identical() {
399        let mut reg = VersionRegistry::new();
400        let flows = vec!["F".to_string()];
401        let src = "same source";
402        reg.record_deploy(&flows, src, "f.axon", "anthropic");
403        reg.record_deploy(&flows, src, "f.axon", "anthropic");
404
405        let diff = diff_versions(&reg, "F", 1, 2).unwrap();
406        assert!(diff.identical);
407        assert_eq!(diff.summary.lines_added, 0);
408        assert_eq!(diff.summary.lines_removed, 0);
409        assert!(diff.hunks.is_empty());
410    }
411
412    #[test]
413    fn diff_versions_not_found() {
414        let reg = VersionRegistry::new();
415        assert!(diff_versions(&reg, "NoFlow", 1, 2).is_err());
416    }
417
418    #[test]
419    fn diff_versions_version_not_found() {
420        let mut reg = VersionRegistry::new();
421        let flows = vec!["F".to_string()];
422        reg.record_deploy(&flows, "src", "f.axon", "anthropic");
423        assert!(diff_versions(&reg, "F", 1, 99).is_err());
424    }
425
426    #[test]
427    fn diff_summary_serializes() {
428        let summary = DiffSummary {
429            lines_added: 5,
430            lines_removed: 3,
431            lines_unchanged: 10,
432            hunks: 2,
433        };
434        let json = serde_json::to_value(&summary).unwrap();
435        assert_eq!(json["lines_added"], 5);
436        assert_eq!(json["hunks"], 2);
437    }
438
439    #[test]
440    fn line_kind_serializes_lowercase() {
441        let line = DiffLine { kind: LineKind::Added, content: "x".into() };
442        let json = serde_json::to_value(&line).unwrap();
443        assert_eq!(json["kind"], "added");
444    }
445
446    #[test]
447    fn hunk_line_numbers_correct() {
448        let old = "a\nb\nc";
449        let new = "a\nX\nc";
450        let lines = diff_lines(old, new);
451        let hunks = make_hunks(&lines, 1);
452
453        assert_eq!(hunks.len(), 1);
454        assert_eq!(hunks[0].old_start, 1); // starts at line 1 (context)
455        assert_eq!(hunks[0].new_start, 1);
456    }
457}