ccsync_core/comparison/
diff.rs

1//! Diff generation with color-coded output
2
3use std::fmt::Write;
4use std::fs;
5use std::path::Path;
6
7use anyhow::Context;
8use similar::{ChangeTag, TextDiff};
9
10use crate::error::Result;
11
12/// Diff generator for creating visual diffs
13pub struct DiffGenerator;
14
15impl Default for DiffGenerator {
16    fn default() -> Self {
17        Self::new()
18    }
19}
20
21impl DiffGenerator {
22    /// Create a new diff generator
23    #[must_use]
24    pub const fn new() -> Self {
25        Self
26    }
27
28    /// Generate a color-coded unified diff between two files
29    ///
30    /// # Errors
31    ///
32    /// Returns an error if files cannot be read.
33    pub fn generate(source: &Path, destination: &Path) -> Result<String> {
34        let source_content = fs::read_to_string(source)
35            .with_context(|| format!("Failed to read source file: {}", source.display()))?;
36
37        let dest_content = fs::read_to_string(destination).with_context(|| {
38            format!("Failed to read destination file: {}", destination.display())
39        })?;
40
41        Ok(Self::generate_from_content(
42            &source_content,
43            &dest_content,
44            source,
45            destination,
46        ))
47    }
48
49    /// Generate a diff from string contents
50    #[must_use]
51    pub fn generate_from_content(
52        source_content: &str,
53        dest_content: &str,
54        source_path: &Path,
55        dest_path: &Path,
56    ) -> String {
57        const DIFF_CONTEXT_LINES: usize = 3;
58
59        let diff = TextDiff::from_lines(dest_content, source_content);
60
61        let mut output = String::new();
62
63        writeln!(output, "\x1b[1m--- {}\x1b[0m", dest_path.display())
64            .expect("Writing to String should never fail");
65        writeln!(output, "\x1b[1m+++ {}\x1b[0m", source_path.display())
66            .expect("Writing to String should never fail");
67
68        for (idx, group) in diff.grouped_ops(DIFF_CONTEXT_LINES).iter().enumerate() {
69            if idx > 0 {
70                output.push_str("...\n");
71            }
72
73            for op in group {
74                for change in diff.iter_changes(op) {
75                    let (sign, color) = match change.tag() {
76                        ChangeTag::Delete => ("-", "\x1b[31m"), // Red
77                        ChangeTag::Insert => ("+", "\x1b[32m"), // Green
78                        ChangeTag::Equal => (" ", "\x1b[0m"),   // No color
79                    };
80
81                    let newline = if change.value().ends_with('\n') {
82                        ""
83                    } else {
84                        "\n"
85                    };
86
87                    write!(output, "{color}{sign}{}{newline}\x1b[0m", change.value())
88                        .expect("Writing to String should never fail");
89                }
90            }
91        }
92
93        output
94    }
95
96    /// Generate a simple line-by-line diff without colors (for testing)
97    ///
98    /// # Errors
99    ///
100    /// Returns an error if files cannot be read.
101    pub fn generate_plain(source: &Path, destination: &Path) -> Result<String> {
102        let source_content = fs::read_to_string(source)
103            .with_context(|| format!("Failed to read source file: {}", source.display()))?;
104
105        let dest_content = fs::read_to_string(destination).with_context(|| {
106            format!("Failed to read destination file: {}", destination.display())
107        })?;
108
109        let diff = TextDiff::from_lines(&dest_content, &source_content);
110        let mut output = String::new();
111
112        for change in diff.iter_all_changes() {
113            let sign = match change.tag() {
114                ChangeTag::Delete => "-",
115                ChangeTag::Insert => "+",
116                ChangeTag::Equal => " ",
117            };
118
119            write!(output, "{sign}{}", change.value())
120                .expect("Writing to String should never fail");
121        }
122
123        Ok(output)
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use std::fs;
131    use tempfile::TempDir;
132
133    #[test]
134    fn test_diff_identical_files() {
135        let tmp = TempDir::new().unwrap();
136        let file1 = tmp.path().join("file1.txt");
137        let file2 = tmp.path().join("file2.txt");
138
139        let content = "line 1\nline 2\nline 3\n";
140        fs::write(&file1, content).unwrap();
141        fs::write(&file2, content).unwrap();
142
143        let _generator = DiffGenerator::new();
144        let diff = DiffGenerator::generate_plain(&file1, &file2).unwrap();
145
146        // All lines should be equal (prefixed with space)
147        assert!(diff.lines().all(|line| line.starts_with(' ')));
148    }
149
150    #[test]
151    fn test_diff_different_files() {
152        let tmp = TempDir::new().unwrap();
153        let source = tmp.path().join("source.txt");
154        let dest = tmp.path().join("dest.txt");
155
156        fs::write(&dest, "line 1\nline 2\nline 3\n").unwrap();
157        fs::write(&source, "line 1\nmodified line 2\nline 3\n").unwrap();
158
159        let _generator = DiffGenerator::new();
160        let diff = DiffGenerator::generate_plain(&source, &dest).unwrap();
161
162        // Should contain deletions and insertions
163        assert!(diff.contains("-line 2"));
164        assert!(diff.contains("+modified line 2"));
165    }
166
167    #[test]
168    fn test_diff_with_colors() {
169        let tmp = TempDir::new().unwrap();
170        let source = tmp.path().join("source.txt");
171        let dest = tmp.path().join("dest.txt");
172
173        fs::write(&dest, "old line\n").unwrap();
174        fs::write(&source, "new line\n").unwrap();
175
176        let _generator = DiffGenerator::new();
177        let diff = DiffGenerator::generate(&source, &dest).unwrap();
178
179        // Should contain ANSI color codes
180        assert!(diff.contains("\x1b[31m")); // Red for deletions
181        assert!(diff.contains("\x1b[32m")); // Green for insertions
182        assert!(diff.contains("\x1b[0m")); // Reset
183    }
184
185    #[test]
186    fn test_diff_added_lines() {
187        let tmp = TempDir::new().unwrap();
188        let source = tmp.path().join("source.txt");
189        let dest = tmp.path().join("dest.txt");
190
191        fs::write(&dest, "line 1\n").unwrap();
192        fs::write(&source, "line 1\nline 2\nline 3\n").unwrap();
193
194        let _generator = DiffGenerator::new();
195        let diff = DiffGenerator::generate_plain(&source, &dest).unwrap();
196
197        assert!(diff.contains("+line 2"));
198        assert!(diff.contains("+line 3"));
199    }
200
201    #[test]
202    fn test_diff_removed_lines() {
203        let tmp = TempDir::new().unwrap();
204        let source = tmp.path().join("source.txt");
205        let dest = tmp.path().join("dest.txt");
206
207        fs::write(&dest, "line 1\nline 2\nline 3\n").unwrap();
208        fs::write(&source, "line 1\n").unwrap();
209
210        let _generator = DiffGenerator::new();
211        let diff = DiffGenerator::generate_plain(&source, &dest).unwrap();
212
213        assert!(diff.contains("-line 2"));
214        assert!(diff.contains("-line 3"));
215    }
216
217    #[test]
218    fn test_diff_unicode_content() {
219        let tmp = TempDir::new().unwrap();
220        let source = tmp.path().join("source.txt");
221        let dest = tmp.path().join("dest.txt");
222
223        fs::write(&dest, "Hello 世界\n").unwrap();
224        fs::write(&source, "Hello World\n").unwrap();
225
226        let _generator = DiffGenerator::new();
227        let diff = DiffGenerator::generate_plain(&source, &dest);
228
229        assert!(diff.is_ok());
230    }
231
232    #[test]
233    fn test_diff_empty_files() {
234        let tmp = TempDir::new().unwrap();
235        let source = tmp.path().join("source.txt");
236        let dest = tmp.path().join("dest.txt");
237
238        fs::write(&source, "").unwrap();
239        fs::write(&dest, "").unwrap();
240
241        let _generator = DiffGenerator::new();
242        let diff = DiffGenerator::generate_plain(&source, &dest).unwrap();
243
244        // Empty files should have empty diff
245        assert!(diff.is_empty() || diff.trim().is_empty());
246    }
247}