Skip to main content

vtcode_design/
diff.rs

1//! Unified diff formatting with ANSI colors.
2//!
3//! Provides `format_colored_diff` as the single canonical implementation
4//! for rendering diff hunks with terminal colors. Previously duplicated
5//! in `vtcode-core` and `vtcode-tui`.
6
7use anstyle::{AnsiColor, Color, Reset, Style};
8use std::fmt::Write;
9
10// Re-export the core diff types from vtcode-commons.
11pub use vtcode_commons::diff::{
12    Chunk, DiffBundle, DiffHunk, DiffLine, DiffLineKind, DiffOptions, compute_diff,
13    compute_diff_chunks,
14};
15
16/// Format a unified diff without ANSI color codes.
17pub fn format_unified_diff(old: &str, new: &str, options: DiffOptions<'_>) -> String {
18    let mut options = options;
19    options.missing_newline_hint = false;
20    let bundle = compute_diff(old, new, options, format_colored_diff);
21    vtcode_commons::ansi::strip_ansi(&bundle.formatted)
22}
23
24/// Compute a structured diff bundle using the default theme-aware formatter.
25pub fn compute_diff_with_theme(old: &str, new: &str, options: DiffOptions<'_>) -> DiffBundle {
26    compute_diff(old, new, options, format_colored_diff)
27}
28
29/// Format diff hunks with standard ANSI colors for terminal display.
30///
31/// This is the single canonical implementation. Both `vtcode-core` and
32/// `vtcode-tui` delegate to this function.
33pub fn format_colored_diff(hunks: &[DiffHunk], options: &DiffOptions<'_>) -> String {
34    if hunks.is_empty() {
35        return String::new();
36    }
37
38    let cyan_style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Cyan)));
39    let addition_style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Green)));
40    let deletion_style = Style::new().fg_color(Some(Color::Ansi(AnsiColor::Red)));
41    let context_style = Style::new();
42
43    let mut output = String::new();
44
45    if let (Some(old_label), Some(new_label)) = (options.old_label, options.new_label) {
46        let _ = write!(
47            output,
48            "{}--- {old_label}\n{}",
49            cyan_style.render(),
50            Reset.render()
51        );
52
53        let _ = write!(
54            output,
55            "{}+++ {new_label}\n{}",
56            cyan_style.render(),
57            Reset.render()
58        );
59    }
60
61    for hunk in hunks {
62        let _ = write!(
63            output,
64            "{}@@ -{},{} +{},{} @@\n{}",
65            cyan_style.render(),
66            hunk.old_start,
67            hunk.old_lines,
68            hunk.new_start,
69            hunk.new_lines,
70            Reset.render()
71        );
72
73        for line in &hunk.lines {
74            let (style, prefix) = match line.kind {
75                DiffLineKind::Addition => (&addition_style, '+'),
76                DiffLineKind::Deletion => (&deletion_style, '-'),
77                DiffLineKind::Context => (&context_style, ' '),
78            };
79
80            let mut display = String::with_capacity(line.text.len() + 2);
81            display.push(prefix);
82            display.push_str(&line.text);
83
84            // CRITICAL: Apply Reset before newline to prevent color bleeding
85            let has_newline = display.ends_with('\n');
86            let display_content = if has_newline {
87                &display[..display.len() - 1]
88            } else {
89                &display
90            };
91
92            let _ = write!(
93                output,
94                "{}{} {}",
95                style.render(),
96                display_content,
97                Reset.render()
98            );
99            output.push('\n');
100
101            if options.missing_newline_hint && !line.text.ends_with('\n') {
102                let eof_hint = r"\ No newline at end of file";
103                let _ = write!(
104                    output,
105                    "{}{} {}",
106                    context_style.render(),
107                    eof_hint,
108                    Reset.render()
109                );
110                output.push('\n');
111            }
112        }
113    }
114
115    output
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn computes_structured_diff() {
124        let before = "a\nb\nc\n";
125        let after = "a\nc\nd\n";
126        let bundle = compute_diff(
127            before,
128            after,
129            DiffOptions {
130                context_lines: 2,
131                old_label: Some("old"),
132                new_label: Some("new"),
133                ..Default::default()
134            },
135            format_colored_diff,
136        );
137
138        assert!(!bundle.is_empty);
139        assert_eq!(bundle.hunks.len(), 1);
140        let hunk = &bundle.hunks[0];
141        assert_eq!(hunk.old_start, 1);
142        assert_eq!(hunk.new_start, 1);
143        assert!(bundle.formatted.contains("@@"));
144        assert!(
145            hunk.lines
146                .iter()
147                .any(|line| matches!(line.kind, DiffLineKind::Deletion))
148        );
149        assert!(
150            hunk.lines
151                .iter()
152                .any(|line| matches!(line.kind, DiffLineKind::Addition))
153        );
154    }
155
156    #[test]
157    fn empty_hunks_returns_empty_string() {
158        let result = format_colored_diff(&[], &DiffOptions::default());
159        assert!(result.is_empty());
160    }
161
162    #[test]
163    fn format_unified_diff_has_no_ansi() {
164        let before = "hello\n";
165        let after = "world\n";
166        let result = format_unified_diff(before, after, DiffOptions::default());
167        // The result should not contain ANSI escape sequences
168        assert!(!result.contains('\x1b'));
169        // But should contain the diff content
170        assert!(result.contains('-') || result.contains('+'));
171    }
172}