1use imara_diff::{Algorithm, BasicLineDiffPrinter, Diff, InternedInput, UnifiedDiffConfig};
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
5pub enum ColorMode {
6 #[default]
8 Auto,
9 Always,
11 Never,
13}
14
15impl ColorMode {
16 pub fn should_colorize(self, is_tty: bool) -> bool {
18 match self {
19 ColorMode::Always => true,
20 ColorMode::Never => false,
21 ColorMode::Auto => is_tty,
22 }
23 }
24}
25
26pub fn colorize_unified_diff(diff: &str) -> String {
31 if diff.is_empty() {
32 return String::new();
33 }
34 let mut output = String::with_capacity(diff.len() + 32);
35 for line in diff.lines() {
36 if line.starts_with("--- ") || line.starts_with("+++ ") {
37 output.push_str("\x1b[1m");
38 output.push_str(line);
39 output.push_str("\x1b[0m\n");
40 } else if line.starts_with("@@") {
41 output.push_str("\x1b[36m");
42 output.push_str(line);
43 output.push_str("\x1b[0m\n");
44 } else if line.starts_with('+') {
45 output.push_str("\x1b[32m");
46 output.push_str(line);
47 output.push_str("\x1b[0m\n");
48 } else if line.starts_with('-') {
49 output.push_str("\x1b[31m");
50 output.push_str(line);
51 output.push_str("\x1b[0m\n");
52 } else {
53 output.push_str(line);
54 output.push('\n');
55 }
56 }
57 if diff.ends_with('\n') && !output.ends_with('\n') {
58 output.push('\n');
59 }
60 if !diff.ends_with('\n') && output.ends_with('\n') {
63 output.pop();
64 }
65 output
66}
67
68pub fn unified_diff(path: &str, original: &str, formatted: &str) -> String {
73 let input = InternedInput::new(original, formatted);
74 let mut diff = Diff::compute(Algorithm::Histogram, &input);
75 diff.postprocess_lines(&input);
76
77 let mut config = UnifiedDiffConfig::default();
78 config.context_len(3);
79
80 let body = diff
81 .unified_diff(&BasicLineDiffPrinter(&input.interner), config, &input)
82 .to_string();
83
84 if body.is_empty() {
85 return String::new();
86 }
87 format!("--- a/{}\n+++ b/{}\n{}", path, path, body)
88}
89
90pub fn print_diff(path: &str, original: &str, formatted: &str, color: bool) {
94 let diff = unified_diff(path, original, formatted);
95 if diff.is_empty() {
96 return;
97 }
98 if color {
99 print!("{}", colorize_unified_diff(&diff));
100 } else {
101 print!("{diff}");
102 }
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
110 fn identical_texts_produce_empty_diff() {
111 let text = "Hello world.\nLine two.\n";
112 assert!(unified_diff("test.md", text, text).is_empty());
113 }
114
115 #[test]
116 fn split_lines_show_correctly() {
117 let old = "First.\nSecond line. Third line.\nFourth.\n";
118 let new = "First.\nSecond line.\nThird line.\nFourth.\n";
119 let diff = unified_diff("test.md", old, new);
120 assert!(diff.contains("-Second line. Third line."));
121 assert!(diff.contains("+Second line."));
122 assert!(diff.contains("+Third line."));
123 assert!(diff.contains(" First."));
124 assert!(diff.contains(" Fourth."));
125 }
126
127 #[test]
128 fn preserves_structural_context() {
129 let old = "# Heading\n\nHello world. This is a test.\n\n# Other\n";
130 let new = "# Heading\n\nHello world.\nThis is a test.\n\n# Other\n";
131 let diff = unified_diff("test.md", old, new);
132 assert!(diff.contains(" # Heading"));
133 assert!(diff.contains("-Hello world. This is a test."));
134 assert!(diff.contains("+Hello world."));
135 }
136
137 #[test]
138 fn file_headers_present() {
139 let diff = unified_diff("foo.org", "a\n", "b\n");
140 assert!(diff.starts_with("--- a/foo.org\n+++ b/foo.org\n"));
141 }
142
143 #[test]
144 fn color_mode_resolution() {
145 assert!(ColorMode::Always.should_colorize(false));
146 assert!(ColorMode::Always.should_colorize(true));
147 assert!(!ColorMode::Never.should_colorize(false));
148 assert!(!ColorMode::Never.should_colorize(true));
149 assert!(!ColorMode::Auto.should_colorize(false));
150 assert!(ColorMode::Auto.should_colorize(true));
151 }
152
153 #[test]
154 fn colorize_adds_ansi_for_headers_hunks_and_changes() {
155 let plain = "--- a/t.md\n+++ b/t.md\n@@ -1 +1 @@\n-old line\n+new line\n context\n";
156 let colored = colorize_unified_diff(plain);
157 assert!(
158 colored.contains("\x1b[1m--- a/t.md\x1b[0m"),
159 "headers should be bold: {colored:?}"
160 );
161 assert!(
162 colored.contains("\x1b[1m+++ b/t.md\x1b[0m"),
163 "headers should be bold: {colored:?}"
164 );
165 assert!(
166 colored.contains("\x1b[36m@@ -1 +1 @@\x1b[0m"),
167 "hunk headers should be cyan: {colored:?}"
168 );
169 assert!(
170 colored.contains("\x1b[31m-old line\x1b[0m"),
171 "removals should be red: {colored:?}"
172 );
173 assert!(
174 colored.contains("\x1b[32m+new line\x1b[0m"),
175 "additions should be green: {colored:?}"
176 );
177 assert!(
178 colored.contains(" context\n"),
179 "context lines stay plain: {colored:?}"
180 );
181 assert!(
182 colored.contains("\x1b["),
183 "colored output must contain ANSI escapes"
184 );
185 }
186
187 #[test]
188 fn colorize_empty_is_empty() {
189 assert!(colorize_unified_diff("").is_empty());
190 }
191
192 #[test]
193 fn plain_unified_diff_has_no_ansi() {
194 let diff = unified_diff("foo.md", "a\n", "b\n");
195 assert!(
196 !diff.contains('\x1b'),
197 "raw unified_diff must not emit ANSI: {diff:?}"
198 );
199 assert!(!colorize_unified_diff(&diff).is_empty());
200 }
201}