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
12pub struct DiffGenerator;
14
15impl Default for DiffGenerator {
16 fn default() -> Self {
17 Self::new()
18 }
19}
20
21impl DiffGenerator {
22 #[must_use]
24 pub const fn new() -> Self {
25 Self
26 }
27
28 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 #[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"), ChangeTag::Insert => ("+", "\x1b[32m"), ChangeTag::Equal => (" ", "\x1b[0m"), };
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 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 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 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 assert!(diff.contains("\x1b[31m")); assert!(diff.contains("\x1b[32m")); assert!(diff.contains("\x1b[0m")); }
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 assert!(diff.is_empty() || diff.trim().is_empty());
246 }
247}