1use similar::{ChangeTag, TextDiff};
2
3pub fn unified_diff(path: &str, original: &str, modified: &str) -> String {
4 if original == modified {
5 return String::new();
6 }
7
8 let diff = TextDiff::from_lines(original, modified);
9 let mut output = String::new();
10
11 let clean = path.strip_prefix('/').unwrap_or(path);
12 output.push_str(&format!("--- a/{clean}\n"));
13 output.push_str(&format!("+++ b/{clean}\n"));
14
15 for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
16 output.push_str(&format!("{hunk}"));
17 }
18
19 output
20}
21
22pub fn colored_unified_diff(path: &str, original: &str, modified: &str) -> String {
23 if original == modified {
24 return String::new();
25 }
26
27 const BOLD: &str = "\x1b[1m";
28 const CYAN: &str = "\x1b[36m";
29 const RED_BG: &str = "\x1b[41;97m";
30 const GREEN_BG: &str = "\x1b[42;30m";
31 const RESET: &str = "\x1b[0m";
32 const DIM: &str = "\x1b[2m";
33
34 let diff = TextDiff::from_lines(original, modified);
35 let mut output = String::new();
36
37 let clean = path.strip_prefix('/').unwrap_or(path);
38 output.push_str(&format!("{BOLD}--- a/{clean}{RESET}\n"));
39 output.push_str(&format!("{BOLD}+++ b/{clean}{RESET}\n"));
40
41 for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
42 output.push_str(&format!("{CYAN}{}{RESET}\n", hunk.header()));
43 for change in hunk.iter_changes() {
44 let line = change.value();
45 let line_no_newline = line.strip_suffix('\n').unwrap_or(line);
46 match change.tag() {
47 ChangeTag::Delete => {
48 output.push_str(&format!("{RED_BG}-{line_no_newline}{RESET}\n"));
49 }
50 ChangeTag::Insert => {
51 output.push_str(&format!("{GREEN_BG}+{line_no_newline}{RESET}\n"));
52 }
53 ChangeTag::Equal => {
54 output.push_str(&format!("{DIM} {line_no_newline}{RESET}\n"));
55 }
56 }
57 }
58 }
59
60 output
61}
62
63pub fn collect_diffs(changes: Vec<(String, String, String)>) -> String {
64 let mut output = String::new();
65 for (path, original, modified) in changes {
66 let diff = unified_diff(&path, &original, &modified);
67 if !diff.is_empty() {
68 output.push_str(&diff);
69 }
70 }
71 output
72}
73
74#[cfg(test)]
75mod tests {
76 use super::*;
77
78 #[test]
79 fn no_diff_when_identical() {
80 let result = unified_diff("test.rs", "hello\n", "hello\n");
81 assert!(result.is_empty());
82 }
83
84 #[test]
85 fn produces_unified_diff() {
86 let result = unified_diff("test.rs", "aaa\nbbb\nccc\n", "aaa\nxxx\nccc\n");
87 assert!(result.contains("--- a/test.rs"));
88 assert!(result.contains("+++ b/test.rs"));
89 assert!(result.contains("-bbb"));
90 assert!(result.contains("+xxx"));
91 }
92
93 #[test]
94 fn absolute_path_does_not_double_slash() {
95 let result = unified_diff("/tmp/foo.rs", "old\n", "new\n");
96 assert!(result.contains("--- a/tmp/foo.rs"), "got:\n{result}");
97 assert!(!result.contains("a//tmp/foo.rs"), "got:\n{result}");
98 }
99
100 #[test]
101 fn collect_multiple_diffs() {
102 let changes = vec![
103 ("a.rs".into(), "old\n".into(), "new\n".into()),
104 ("b.rs".into(), "same\n".into(), "same\n".into()),
105 ];
106 let result = collect_diffs(changes);
107 assert!(result.contains("a.rs"));
108 assert!(!result.contains("b.rs"));
109 }
110
111 #[test]
112 fn colored_diff_empty_when_identical() {
113 let result = colored_unified_diff("test.rs", "hello\n", "hello\n");
114 assert!(result.is_empty());
115 }
116
117 #[test]
118 fn colored_diff_has_ansi_codes() {
119 let result = colored_unified_diff("test.rs", "aaa\nbbb\nccc\n", "aaa\nxxx\nccc\n");
120 assert!(
121 result.contains("\x1b[1m--- a/test.rs"),
122 "missing bold header"
123 );
124 assert!(result.contains("\x1b[36m@@ "), "missing cyan hunk header");
125 assert!(
126 result.contains("\x1b[41;97m-bbb"),
127 "missing red deleted line"
128 );
129 assert!(
130 result.contains("\x1b[42;30m+xxx"),
131 "missing green added line"
132 );
133 assert!(result.contains("\x1b[2m aaa"), "missing dim context line");
134 }
135}