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
12use super::directory::DirectoryComparison;
13
14/// Diff generator for creating visual diffs
15pub struct DiffGenerator;
16
17impl Default for DiffGenerator {
18    fn default() -> Self {
19        Self::new()
20    }
21}
22
23impl DiffGenerator {
24    /// Create a new diff generator
25    #[must_use]
26    pub const fn new() -> Self {
27        Self
28    }
29
30    /// Generate a color-coded unified diff between two files
31    ///
32    /// # Errors
33    ///
34    /// Returns an error if files cannot be read.
35    pub fn generate(source: &Path, destination: &Path) -> Result<String> {
36        let source_content = fs::read_to_string(source)
37            .with_context(|| format!("Failed to read source file: {}", source.display()))?;
38
39        let dest_content = fs::read_to_string(destination).with_context(|| {
40            format!("Failed to read destination file: {}", destination.display())
41        })?;
42
43        Ok(Self::generate_from_content(
44            &source_content,
45            &dest_content,
46            source,
47            destination,
48        ))
49    }
50
51    /// Generate a diff from string contents
52    #[must_use]
53    pub fn generate_from_content(
54        source_content: &str,
55        dest_content: &str,
56        source_path: &Path,
57        dest_path: &Path,
58    ) -> String {
59        const DIFF_CONTEXT_LINES: usize = 3;
60
61        let diff = TextDiff::from_lines(dest_content, source_content);
62
63        let mut output = String::new();
64
65        writeln!(output, "\x1b[1m--- {}\x1b[0m", dest_path.display())
66            .expect("Writing to String should never fail");
67        writeln!(output, "\x1b[1m+++ {}\x1b[0m", source_path.display())
68            .expect("Writing to String should never fail");
69
70        for (idx, group) in diff.grouped_ops(DIFF_CONTEXT_LINES).iter().enumerate() {
71            if idx > 0 {
72                output.push_str("...\n");
73            }
74
75            for op in group {
76                for change in diff.iter_changes(op) {
77                    let (sign, color) = match change.tag() {
78                        ChangeTag::Delete => ("-", "\x1b[31m"), // Red
79                        ChangeTag::Insert => ("+", "\x1b[32m"), // Green
80                        ChangeTag::Equal => (" ", "\x1b[0m"),   // No color
81                    };
82
83                    let newline = if change.value().ends_with('\n') {
84                        ""
85                    } else {
86                        "\n"
87                    };
88
89                    write!(output, "{color}{sign}{}{newline}\x1b[0m", change.value())
90                        .expect("Writing to String should never fail");
91                }
92            }
93        }
94
95        output
96    }
97
98    /// Generate a simple line-by-line diff without colors (for testing)
99    ///
100    /// # Errors
101    ///
102    /// Returns an error if files cannot be read.
103    pub fn generate_plain(source: &Path, destination: &Path) -> Result<String> {
104        let source_content = fs::read_to_string(source)
105            .with_context(|| format!("Failed to read source file: {}", source.display()))?;
106
107        let dest_content = fs::read_to_string(destination).with_context(|| {
108            format!("Failed to read destination file: {}", destination.display())
109        })?;
110
111        let diff = TextDiff::from_lines(&dest_content, &source_content);
112        let mut output = String::new();
113
114        for change in diff.iter_all_changes() {
115            let sign = match change.tag() {
116                ChangeTag::Delete => "-",
117                ChangeTag::Insert => "+",
118                ChangeTag::Equal => " ",
119            };
120
121            write!(output, "{sign}{}", change.value())
122                .expect("Writing to String should never fail");
123        }
124
125        Ok(output)
126    }
127
128    /// Generate a summary diff for directories
129    ///
130    /// Shows files to add, modify, and remove with line count information
131    /// for modified files.
132    ///
133    /// # Errors
134    ///
135    /// Returns an error if file I/O operations fail.
136    pub fn generate_directory_summary(
137        comparison: &DirectoryComparison,
138        source_dir: &Path,
139        dest_dir: &Path,
140        skill_name: &str,
141    ) -> Result<String> {
142        let mut output = String::new();
143
144        writeln!(
145            output,
146            "\x1b[1mπŸ“Š Skill directory diff: {skill_name}\x1b[0m\n"
147        )
148        .expect("Writing to String should never fail");
149
150        if !comparison.added.is_empty() {
151            writeln!(output, "\x1b[32mFiles to add:\x1b[0m")
152                .expect("Writing to String should never fail");
153            for file in &comparison.added {
154                writeln!(output, "  \x1b[32m+\x1b[0m {}", file.display())
155                    .expect("Writing to String should never fail");
156            }
157            output.push('\n');
158        }
159
160        if !comparison.modified.is_empty() {
161            writeln!(output, "\x1b[33mFiles to modify:\x1b[0m")
162                .expect("Writing to String should never fail");
163            for file in &comparison.modified {
164                let src_file = source_dir.join(file);
165                let dst_file = dest_dir.join(file);
166
167                // Try to count lines changed
168                let lines_info = match Self::count_changes(&src_file, &dst_file) {
169                    Ok((added, removed)) => format!(" (+{added} -{removed} lines)"),
170                    Err(_) => String::new(),
171                };
172
173                writeln!(
174                    output,
175                    "  \x1b[33m~\x1b[0m {}{lines_info}",
176                    file.display()
177                )
178                .expect("Writing to String should never fail");
179            }
180            output.push('\n');
181        }
182
183        if !comparison.removed.is_empty() {
184            writeln!(output, "\x1b[31mFiles to remove:\x1b[0m")
185                .expect("Writing to String should never fail");
186            for file in &comparison.removed {
187                writeln!(output, "  \x1b[31m-\x1b[0m {}", file.display())
188                    .expect("Writing to String should never fail");
189            }
190            output.push('\n');
191        }
192
193        if comparison.is_identical() {
194            writeln!(output, "\x1b[32mDirectories are identical\x1b[0m")
195                .expect("Writing to String should never fail");
196        } else if !comparison.modified.is_empty() {
197            writeln!(
198                output,
199                "\x1b[2m(Press 'c' at the prompt to see line-by-line content diffs for modified files)\x1b[0m"
200            )
201            .expect("Writing to String should never fail");
202        }
203
204        Ok(output)
205    }
206
207    /// Count added and removed lines in a file diff
208    fn count_changes(source: &Path, destination: &Path) -> Result<(usize, usize)> {
209        let source_content = fs::read_to_string(source)?;
210        let dest_content = fs::read_to_string(destination)?;
211
212        let diff = TextDiff::from_lines(&dest_content, &source_content);
213
214        let mut added = 0;
215        let mut removed = 0;
216
217        for change in diff.iter_all_changes() {
218            match change.tag() {
219                ChangeTag::Insert => added += 1,
220                ChangeTag::Delete => removed += 1,
221                ChangeTag::Equal => {}
222            }
223        }
224
225        Ok((added, removed))
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use std::fs;
233    use tempfile::TempDir;
234
235    #[test]
236    fn test_diff_identical_files() {
237        let tmp = TempDir::new().unwrap();
238        let file1 = tmp.path().join("file1.txt");
239        let file2 = tmp.path().join("file2.txt");
240
241        let content = "line 1\nline 2\nline 3\n";
242        fs::write(&file1, content).unwrap();
243        fs::write(&file2, content).unwrap();
244
245        let _generator = DiffGenerator::new();
246        let diff = DiffGenerator::generate_plain(&file1, &file2).unwrap();
247
248        // All lines should be equal (prefixed with space)
249        assert!(diff.lines().all(|line| line.starts_with(' ')));
250    }
251
252    #[test]
253    fn test_diff_different_files() {
254        let tmp = TempDir::new().unwrap();
255        let source = tmp.path().join("source.txt");
256        let dest = tmp.path().join("dest.txt");
257
258        fs::write(&dest, "line 1\nline 2\nline 3\n").unwrap();
259        fs::write(&source, "line 1\nmodified line 2\nline 3\n").unwrap();
260
261        let _generator = DiffGenerator::new();
262        let diff = DiffGenerator::generate_plain(&source, &dest).unwrap();
263
264        // Should contain deletions and insertions
265        assert!(diff.contains("-line 2"));
266        assert!(diff.contains("+modified line 2"));
267    }
268
269    #[test]
270    fn test_diff_with_colors() {
271        let tmp = TempDir::new().unwrap();
272        let source = tmp.path().join("source.txt");
273        let dest = tmp.path().join("dest.txt");
274
275        fs::write(&dest, "old line\n").unwrap();
276        fs::write(&source, "new line\n").unwrap();
277
278        let _generator = DiffGenerator::new();
279        let diff = DiffGenerator::generate(&source, &dest).unwrap();
280
281        // Should contain ANSI color codes
282        assert!(diff.contains("\x1b[31m")); // Red for deletions
283        assert!(diff.contains("\x1b[32m")); // Green for insertions
284        assert!(diff.contains("\x1b[0m")); // Reset
285    }
286
287    #[test]
288    fn test_diff_added_lines() {
289        let tmp = TempDir::new().unwrap();
290        let source = tmp.path().join("source.txt");
291        let dest = tmp.path().join("dest.txt");
292
293        fs::write(&dest, "line 1\n").unwrap();
294        fs::write(&source, "line 1\nline 2\nline 3\n").unwrap();
295
296        let _generator = DiffGenerator::new();
297        let diff = DiffGenerator::generate_plain(&source, &dest).unwrap();
298
299        assert!(diff.contains("+line 2"));
300        assert!(diff.contains("+line 3"));
301    }
302
303    #[test]
304    fn test_diff_removed_lines() {
305        let tmp = TempDir::new().unwrap();
306        let source = tmp.path().join("source.txt");
307        let dest = tmp.path().join("dest.txt");
308
309        fs::write(&dest, "line 1\nline 2\nline 3\n").unwrap();
310        fs::write(&source, "line 1\n").unwrap();
311
312        let _generator = DiffGenerator::new();
313        let diff = DiffGenerator::generate_plain(&source, &dest).unwrap();
314
315        assert!(diff.contains("-line 2"));
316        assert!(diff.contains("-line 3"));
317    }
318
319    #[test]
320    fn test_diff_unicode_content() {
321        let tmp = TempDir::new().unwrap();
322        let source = tmp.path().join("source.txt");
323        let dest = tmp.path().join("dest.txt");
324
325        fs::write(&dest, "Hello δΈ–η•Œ\n").unwrap();
326        fs::write(&source, "Hello World\n").unwrap();
327
328        let _generator = DiffGenerator::new();
329        let diff = DiffGenerator::generate_plain(&source, &dest);
330
331        assert!(diff.is_ok());
332    }
333
334    #[test]
335    fn test_diff_empty_files() {
336        let tmp = TempDir::new().unwrap();
337        let source = tmp.path().join("source.txt");
338        let dest = tmp.path().join("dest.txt");
339
340        fs::write(&source, "").unwrap();
341        fs::write(&dest, "").unwrap();
342
343        let _generator = DiffGenerator::new();
344        let diff = DiffGenerator::generate_plain(&source, &dest).unwrap();
345
346        // Empty files should have empty diff
347        assert!(diff.is_empty() || diff.trim().is_empty());
348    }
349}