1use anstyle::{AnsiColor, Color, Reset, RgbColor, Style};
8use std::fmt::Write;
9use vtcode_diff::{DiffDocument, format_unified_hunks};
10
11pub use vtcode_commons::diff::{DiffOptions, compute_diff};
15pub use vtcode_diff::{Chunk, DiffBundle, DiffHunk, DiffLine, DiffLineKind, compute_diff_chunks};
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum DiffTheme {
20 #[default]
22 Github,
23 Ayu,
25 Gruvbox,
27 Nord,
29 Solarized,
31 Dracula,
33}
34
35impl DiffTheme {
36 pub const ALL: [DiffTheme; 6] = [
38 DiffTheme::Github,
39 DiffTheme::Ayu,
40 DiffTheme::Gruvbox,
41 DiffTheme::Nord,
42 DiffTheme::Solarized,
43 DiffTheme::Dracula,
44 ];
45
46 #[must_use]
48 pub const fn name(self) -> &'static str {
49 match self {
50 Self::Github => "github",
51 Self::Ayu => "ayu",
52 Self::Gruvbox => "gruvbox",
53 Self::Nord => "nord",
54 Self::Solarized => "solarized",
55 Self::Dracula => "dracula",
56 }
57 }
58
59 #[must_use]
61 pub fn from_name(name: &str) -> Option<Self> {
62 Self::ALL.into_iter().find(|theme| theme.name() == name)
63 }
64
65 fn palette(self) -> DiffPalette {
66 match self {
67 Self::Github => DiffPalette {
68 background: (0xff, 0xff, 0xff),
69 header: (0x57, 0x60, 0x6a),
70 addition: (0x22, 0x86, 0x3a),
71 deletion: (0xcb, 0x24, 0x31),
72 },
73 Self::Ayu => DiffPalette {
74 background: (0x0b, 0x0e, 0x14),
75 header: (0x8b, 0x94, 0x9e),
76 addition: (0xaa, 0xd9, 0x4c),
77 deletion: (0xf0, 0x71, 0x78),
78 },
79 Self::Gruvbox => DiffPalette {
80 background: (0x28, 0x28, 0x28),
81 header: (0x83, 0xa5, 0x98),
82 addition: (0xb8, 0xbb, 0x26),
83 deletion: (0xfc, 0x6b, 0x57),
84 },
85 Self::Nord => DiffPalette {
86 background: (0x2e, 0x34, 0x40),
87 header: (0x88, 0xc0, 0xd0),
88 addition: (0xa3, 0xbe, 0x8c),
89 deletion: (0xeb, 0x8f, 0x97),
90 },
91 Self::Solarized => DiffPalette {
92 background: (0xfd, 0xf6, 0xe3),
93 header: (0x58, 0x6e, 0x75),
94 addition: (0x5b, 0x73, 0x00),
95 deletion: (0xb5, 0x37, 0x2f),
96 },
97 Self::Dracula => DiffPalette {
98 background: (0x28, 0x2a, 0x36),
99 header: (0x8b, 0x96, 0xc9),
100 addition: (0x50, 0xfa, 0x7b),
101 deletion: (0xff, 0x55, 0x55),
102 },
103 }
104 }
105}
106
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
109pub struct DiffPalette {
110 pub background: (u8, u8, u8),
112 pub header: (u8, u8, u8),
114 pub addition: (u8, u8, u8),
116 pub deletion: (u8, u8, u8),
118}
119
120impl DiffPalette {
121 fn header_style(self) -> Style {
122 rgb_style(self.header)
123 }
124
125 fn addition_style(self) -> Style {
126 rgb_style(self.addition)
127 }
128
129 fn deletion_style(self) -> Style {
130 rgb_style(self.deletion)
131 }
132}
133
134fn rgb_style((red, green, blue): (u8, u8, u8)) -> Style {
135 Style::new().fg_color(Some(Color::Rgb(RgbColor(red, green, blue))))
136}
137
138pub fn format_unified_diff(old: &str, new: &str, options: DiffOptions<'_>) -> String {
140 let mut options = shared_options(&options);
141 options.missing_newline_hint = false;
142 let document = DiffDocument::between(old, new, options.clone());
143 format_unified_hunks(&document.hunks, &options)
144}
145
146pub fn compute_diff_with_theme(old: &str, new: &str, options: DiffOptions<'_>) -> DiffBundle {
148 let shared = shared_options(&options);
149 let document = DiffDocument::between(old, new, shared);
150 let formatted = format_colored_diff(&document.hunks, &options);
151 DiffBundle {
152 hunks: document.hunks,
153 formatted,
154 is_empty: old == new,
155 }
156}
157
158fn shared_options<'a>(options: &DiffOptions<'a>) -> vtcode_diff::DiffOptions<'a> {
159 vtcode_diff::DiffOptions {
160 context_lines: options.context_lines,
161 old_label: options.old_label,
162 new_label: options.new_label,
163 missing_newline_hint: options.missing_newline_hint,
164 ..vtcode_diff::DiffOptions::default()
165 }
166}
167
168pub fn format_colored_diff(hunks: &[DiffHunk], options: &DiffOptions<'_>) -> String {
173 if hunks.is_empty() {
174 return String::new();
175 }
176
177 let cyan_style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Cyan)));
180 let addition_style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Green)));
181 let deletion_style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Red)));
182 let context_style = Style::new();
183 format_colored_diff_impl(hunks, options, cyan_style, addition_style, deletion_style, context_style)
184}
185
186pub fn format_colored_diff_with_theme(hunks: &[DiffHunk], options: &DiffOptions<'_>, theme: DiffTheme) -> String {
192 if hunks.is_empty() {
193 return String::new();
194 }
195
196 let palette = theme.palette();
197 format_colored_diff_impl(
198 hunks,
199 options,
200 palette.header_style(),
201 palette.addition_style(),
202 palette.deletion_style(),
203 Style::new(),
204 )
205}
206
207fn format_colored_diff_impl(
208 hunks: &[DiffHunk],
209 options: &DiffOptions<'_>,
210 header_style: Style,
211 addition_style: Style,
212 deletion_style: Style,
213 context_style: Style,
214) -> String {
215 let mut output = String::new();
216
217 if let (Some(old_label), Some(new_label)) = (options.old_label, options.new_label) {
218 let _ = write!(output, "{}--- {old_label}\n{}", header_style.render(), Reset.render());
219
220 let _ = write!(output, "{}+++ {new_label}\n{}", header_style.render(), Reset.render());
221 }
222
223 for hunk in hunks {
224 let _ = write!(
225 output,
226 "{}@@ -{},{} +{},{} @@\n{}",
227 header_style.render(),
228 hunk.old_start,
229 hunk.old_lines,
230 hunk.new_start,
231 hunk.new_lines,
232 Reset.render()
233 );
234
235 for line in &hunk.lines {
236 let (style, prefix) = match line.kind {
237 DiffLineKind::Addition => (&addition_style, '+'),
238 DiffLineKind::Deletion => (&deletion_style, '-'),
239 DiffLineKind::Context => (&context_style, ' '),
240 };
241
242 let mut display = String::with_capacity(line.text.len() + 2);
243 display.push(prefix);
244 display.push_str(&line.text);
245
246 let display_content = display
249 .strip_suffix("\r\n")
250 .or_else(|| display.strip_suffix('\n'))
251 .or_else(|| display.strip_suffix('\r'))
252 .unwrap_or(&display);
253
254 let _ = write!(output, "{}{} {}", style.render(), display_content, Reset.render());
255 output.push('\n');
256
257 let has_line_terminator = line.text.ends_with('\n') || line.text.ends_with('\r');
258 if options.missing_newline_hint && !has_line_terminator {
259 let eof_hint = r"\ No newline at end of file";
260 let _ = write!(output, "{}{} {}", context_style.render(), eof_hint, Reset.render());
261 output.push('\n');
262 }
263 }
264 }
265
266 output
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
274 fn computes_structured_diff() {
275 let before = "a\nb\nc\n";
276 let after = "a\nc\nd\n";
277 let bundle = compute_diff(
278 before,
279 after,
280 DiffOptions {
281 context_lines: 2,
282 old_label: Some("old"),
283 new_label: Some("new"),
284 ..Default::default()
285 },
286 format_colored_diff,
287 );
288
289 assert!(!bundle.is_empty);
290 assert_eq!(bundle.hunks.len(), 1);
291 let hunk = &bundle.hunks[0];
292 assert_eq!(hunk.old_start, 1);
293 assert_eq!(hunk.new_start, 1);
294 assert!(bundle.formatted.contains("@@"));
295 assert!(hunk.lines.iter().any(|line| matches!(line.kind, DiffLineKind::Deletion)));
296 assert!(hunk.lines.iter().any(|line| matches!(line.kind, DiffLineKind::Addition)));
297 }
298
299 #[test]
300 fn empty_hunks_returns_empty_string() {
301 let result = format_colored_diff(&[], &DiffOptions::default());
302 assert!(result.is_empty());
303 }
304
305 #[test]
306 fn format_unified_diff_has_no_ansi() {
307 let before = "hello\n";
308 let after = "world\n";
309 let result = format_unified_diff(before, after, DiffOptions::default());
310 assert!(!result.contains('\x1b'));
312 assert!(result.contains('-') || result.contains('+'));
314 }
315
316 #[test]
317 fn format_colored_diff_normalizes_crlf_and_cr_terminators() {
318 let bundle = compute_diff_with_theme("old\r\n", "new\r\n", DiffOptions::default());
319 assert!(!bundle.formatted.contains("\r"));
320
321 let bundle = compute_diff_with_theme("old\r", "new\r", DiffOptions::default());
322 assert!(!bundle.formatted.contains("\r"));
323 assert!(!bundle.formatted.contains("No newline at end"));
324 }
325
326 #[test]
327 fn theme_names_round_trip() {
328 for theme in DiffTheme::ALL {
329 assert_eq!(DiffTheme::from_name(theme.name()), Some(theme));
330 }
331 assert_eq!(DiffTheme::from_name("unknown"), None);
332 }
333
334 #[test]
335 fn every_theme_meets_wcag_aa_contrast() {
336 fn relative_luminance((red, green, blue): (u8, u8, u8)) -> f64 {
337 fn channel(value: u8) -> f64 {
338 let scaled = f64::from(value) / 255.0;
339 if scaled <= 0.039_28 {
340 scaled / 12.92
341 } else {
342 ((scaled + 0.055) / 1.055).powf(2.4)
343 }
344 }
345 0.212_6 * channel(red) + 0.715_2 * channel(green) + 0.072_2 * channel(blue)
346 }
347
348 fn contrast_ratio(foreground: (u8, u8, u8), background: (u8, u8, u8)) -> f64 {
349 let foreground_luma = relative_luminance(foreground);
350 let background_luma = relative_luminance(background);
351 let (lighter, darker) = if foreground_luma >= background_luma {
352 (foreground_luma, background_luma)
353 } else {
354 (background_luma, foreground_luma)
355 };
356 (lighter + 0.05) / (darker + 0.05)
357 }
358
359 for theme in DiffTheme::ALL {
360 let palette = theme.palette();
361 for (label, accent) in [
362 ("header", palette.header),
363 ("addition", palette.addition),
364 ("deletion", palette.deletion),
365 ] {
366 let ratio = contrast_ratio(accent, palette.background);
367 assert!(ratio >= 4.5, "{:?} {label} contrast {ratio:.2} is below WCAG AA 4.5:1", theme.name());
368 }
369 }
370 }
371
372 #[test]
373 fn themed_rendering_uses_theme_colors() {
374 let options = DiffOptions::default();
375 let shared = shared_options(&options);
376 let document = DiffDocument::between("old\n", "new\n", shared);
377 let rendered = format_colored_diff_with_theme(&document.hunks, &options, DiffTheme::Dracula);
378 assert!(rendered.contains("\x1b[38;2;255;85;85m"));
380 let default_rendered = format_colored_diff(&document.hunks, &options);
382 assert!(default_rendered.contains("\x1b[38;5;10m") || default_rendered.contains("\x1b[32m"));
383 }
384}