1use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5
6use rayon::prelude::*;
7use sha2::{Digest, Sha256};
8use walkdir::WalkDir;
9
10use super::entry::{DiffEntry, DiffStats, DiffType};
11use super::ignore::IgnoreRules;
12use super::progress::{DiffProgress, NullProgress, ProgressSink};
13
14pub struct DiffEngine;
32
33impl DiffEngine {
34 pub fn compare(before_root: &Path, after_root: &Path) -> anyhow::Result<Vec<DiffEntry>> {
36 Self::compare_with_ignore(before_root, after_root, &IgnoreRules::default())
37 }
38
39 pub fn compare_with_ignore(
42 before_root: &Path,
43 after_root: &Path,
44 ignore: &IgnoreRules,
45 ) -> anyhow::Result<Vec<DiffEntry>> {
46 Self::compare_with_progress(before_root, after_root, ignore, &NullProgress)
47 }
48
49 pub fn compare_with_progress(
51 before_root: &Path,
52 after_root: &Path,
53 ignore: &IgnoreRules,
54 progress: &dyn ProgressSink,
55 ) -> anyhow::Result<Vec<DiffEntry>> {
56 let before_map = collect_paths(before_root)?;
57 let after_map = collect_paths(after_root)?;
58
59 let all_paths: BTreeSet<String> = before_map.keys()
60 .chain(after_map.keys())
61 .cloned()
62 .collect();
63
64 let paths_to_compare: Vec<String> = all_paths
66 .into_iter()
67 .filter(|p| !ignore.is_ignored(p))
68 .collect();
69
70 let total = paths_to_compare.len();
71 progress.emit(DiffProgress::Started { total });
72
73 use std::sync::atomic::{AtomicUsize, Ordering};
75 let processed = AtomicUsize::new(0);
76
77 let mut entries: Vec<DiffEntry> = paths_to_compare
78 .into_par_iter()
79 .map(|rel_path| {
80 let diff_entry = match (before_map.get(&rel_path), after_map.get(&rel_path)) {
81 (None, Some(a)) => build_added(rel_path, a),
82 (Some(b), None) => build_removed(rel_path, b),
83 (Some(b), Some(a)) => build_compared(rel_path, b, a),
84 (None, None) => unreachable!(),
85 };
86 let n = processed.fetch_add(1, Ordering::Relaxed) + 1;
87 progress.emit(DiffProgress::File {
88 path: diff_entry.path.clone(), processed: n, total,
89 });
90 diff_entry
91 })
92 .collect();
93
94 progress.emit(DiffProgress::Sorting);
96 entries.sort_by(|a, b| a.path.cmp(&b.path));
97 progress.emit(DiffProgress::Done { total_files: entries.len() });
98 Ok(entries)
99 }
100}
101
102fn collect_paths(root: &Path) -> anyhow::Result<BTreeMap<String, PathBuf>> {
105 if !root.is_dir() {
106 anyhow::bail!("Not a directory: {}", root.display());
107 }
108 let mut map = BTreeMap::new();
109 for entry in WalkDir::new(root).into_iter() {
110 let entry = entry.map_err(|e| anyhow::anyhow!("Walk error: {e}"))?;
111 if entry.path() == root { continue; }
112 let rel = entry.path()
113 .strip_prefix(root).unwrap()
114 .to_string_lossy()
115 .replace('\\', "/");
116 map.insert(rel, entry.path().to_path_buf());
117 }
118 Ok(map)
119}
120
121fn build_added(rel: String, after: &Path) -> DiffEntry {
124 if after.is_dir() {
125 return dir_entry(rel, DiffType::Added);
126 }
127 let (bytes, sha, size, error) = read_file(after);
128 let (text, is_binary) = classify_bytes(&bytes);
129 DiffEntry {
130 path: rel, diff_type: DiffType::Added, is_dir: false,
131 before_text: None, after_text: text.clone(),
132 is_binary,
133 before_size: None, after_size: size,
134 before_sha256: None, after_sha256: sha,
135 stats: None, error_detail: error,
137 }
138}
139
140fn build_removed(rel: String, before: &Path) -> DiffEntry {
141 if before.is_dir() {
142 return dir_entry(rel, DiffType::Removed);
143 }
144 let (bytes, sha, size, error) = read_file(before);
145 let (text, is_binary) = classify_bytes(&bytes);
146 DiffEntry {
147 path: rel, diff_type: DiffType::Removed, is_dir: false,
148 before_text: text, after_text: None,
149 is_binary,
150 before_size: size, after_size: None,
151 before_sha256: sha, after_sha256: None,
152 stats: None,
153 error_detail: error,
154 }
155}
156
157fn build_compared(rel: String, before: &Path, after: &Path) -> DiffEntry {
158 if before.is_dir() != after.is_dir() {
159 return DiffEntry {
160 path: rel, diff_type: DiffType::TypeChanged, is_dir: false,
161 before_text: None, after_text: None,
162 is_binary: false,
163 before_size: None, after_size: None,
164 before_sha256: None, after_sha256: None,
165 stats: None,
166 error_detail: Some("Path kind changed (file ↔ directory).".into()),
167 };
168 }
169 if before.is_dir() {
170 return dir_entry(rel, DiffType::Unchanged);
171 }
172
173 let (before_bytes, before_sha, before_size, before_err) = read_file(before);
174 if let Some(e) = before_err {
175 return unreadable(rel, format!("Cannot read before-file: {e}"));
176 }
177 let (after_bytes, after_sha, after_size, after_err) = read_file(after);
178 if let Some(e) = after_err {
179 return unreadable(rel, format!("Cannot read after-file: {e}"));
180 }
181
182 let diff_type = if before_bytes == after_bytes { DiffType::Unchanged } else { DiffType::Modified };
183
184 let (before_text, before_is_binary) = classify_bytes(&before_bytes);
185 let (after_text, after_is_binary) = classify_bytes(&after_bytes);
186 let is_binary = before_is_binary || after_is_binary;
187
188 let stats = if diff_type == DiffType::Modified && !is_binary {
190 let bt = before_text.as_deref().unwrap_or("");
191 let at = after_text.as_deref().unwrap_or("");
192 Some(DiffStats::compute(bt, at))
193 } else {
194 None
195 };
196
197 DiffEntry {
198 path: rel, diff_type, is_dir: false,
199 before_text, after_text,
200 is_binary,
201 before_size, after_size,
202 before_sha256: before_sha, after_sha256: after_sha,
203 stats,
204 error_detail: None,
205 }
206}
207
208fn dir_entry(rel: String, diff_type: DiffType) -> DiffEntry {
211 DiffEntry {
212 path: rel, diff_type, is_dir: true,
213 before_text: None, after_text: None,
214 is_binary: false,
215 before_size: None, after_size: None,
216 before_sha256: None, after_sha256: None,
217 stats: None, error_detail: None,
218 }
219}
220
221fn unreadable(rel: String, detail: String) -> DiffEntry {
222 DiffEntry {
223 path: rel, diff_type: DiffType::Unreadable, is_dir: false,
224 before_text: None, after_text: None,
225 is_binary: false,
226 before_size: None, after_size: None,
227 before_sha256: None, after_sha256: None,
228 stats: None, error_detail: Some(detail),
229 }
230}
231
232fn read_file(path: &Path) -> (Vec<u8>, Option<String>, Option<u64>, Option<String>) {
234 match std::fs::read(path) {
235 Ok(bytes) => {
236 let sha = hex::encode(Sha256::digest(&bytes));
237 let size = bytes.len() as u64;
238 (bytes, Some(sha), Some(size), None)
239 }
240 Err(e) => (Vec::new(), None, None, Some(e.to_string())),
241 }
242}
243
244fn classify_bytes(bytes: &[u8]) -> (Option<String>, bool) {
247 if bytes.is_empty() {
248 return (Some(String::new()), false);
249 }
250 let sample = &bytes[..bytes.len().min(8192)];
252 if sample.contains(&0u8) {
253 return (None, true);
254 }
255 match String::from_utf8(bytes.to_vec()) {
256 Ok(text) => (Some(text), false),
257 Err(_) => (None, true),
258 }
259}