ccsync_core/comparison/
diff.rs1use 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
14pub struct DiffGenerator;
16
17impl Default for DiffGenerator {
18 fn default() -> Self {
19 Self::new()
20 }
21}
22
23impl DiffGenerator {
24 #[must_use]
26 pub const fn new() -> Self {
27 Self
28 }
29
30 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 #[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"), ChangeTag::Insert => ("+", "\x1b[32m"), ChangeTag::Equal => (" ", "\x1b[0m"), };
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 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 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 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 {
197 writeln!(
198 output,
199 "\x1b[2mPress 'c' to see content diff, or any other key to return...\x1b[0m"
200 )
201 .expect("Writing to String should never fail");
202 }
203
204 Ok(output)
205 }
206
207 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 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 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 assert!(diff.contains("\x1b[31m")); assert!(diff.contains("\x1b[32m")); assert!(diff.contains("\x1b[0m")); }
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 assert!(diff.is_empty() || diff.trim().is_empty());
348 }
349}