1use std::io::IsTerminal;
10
11use crate::flow_version::VersionRegistry;
12
13#[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#[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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
40pub struct DiffLine {
41 pub kind: LineKind,
42 pub content: String,
43}
44
45#[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#[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
63pub 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
74fn lcs_diff(old: &[&str], new: &[&str]) -> Vec<DiffLine> {
76 let m = old.len();
77 let n = new.len();
78
79 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 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
123pub fn make_hunks(lines: &[DiffLine], context: usize) -> Vec<DiffHunk> {
125 if lines.is_empty() {
126 return Vec::new();
127 }
128
129 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 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 hunk_end = this_end;
153 } else {
154 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 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
196pub 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
236pub 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#[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 assert!(hunks[0].lines.len() <= 7); }
361
362 #[test]
363 fn hunks_separate_changes() {
364 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(®, "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); assert_eq!(diff.summary.lines_removed, 1); }
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(®, "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(®, "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(®, "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); assert_eq!(hunks[0].new_start, 1);
456 }
457}