Skip to main content

aaai_core/diff/
engine.rs

1//! Folder diff engine — Phase 4: parallel processing + binary detection + diff stats.
2
3use 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
14/// Parallel folder diff engine.
15///
16/// Walks two directory trees concurrently (using [`rayon`]) and produces a
17/// sorted list of [`DiffEntry`] items — one per file that differs between
18/// `before` and `after`.
19///
20/// # Example
21///
22/// ```rust,no_run
23/// use aaai_core::DiffEngine;
24/// use std::path::Path;
25///
26/// let entries = DiffEngine::compare(Path::new("./before"), Path::new("./after")).unwrap();
27/// for e in &entries {
28///     println!("{} — {}", e.path, e.diff_type);
29/// }
30/// ```
31pub struct DiffEngine;
32
33impl DiffEngine {
34    /// Compare two directory trees (sequential — for small trees).
35    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    /// Compare with ignore rules.
40    /// Uses parallel processing for the per-file comparison step.
41    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    /// Compare with ignore rules and a progress sink.
50    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        // Filter ignored paths eagerly.
65        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        // ── Parallel per-file comparison ───────────────────────────────────
74        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        // Restore deterministic sort (parallel iter may reorder).
95        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
102// ── Path collection ───────────────────────────────────────────────────────
103
104fn 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
121// ── Per-file builders ─────────────────────────────────────────────────────
122
123fn 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, // no before to diff against
136        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    // Compute line stats for text-Modified files.
189    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
208// ── Helpers ───────────────────────────────────────────────────────────────
209
210fn 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
232/// Read a file returning (bytes, sha256_hex, size_bytes, error).
233fn 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
244/// Classify bytes as text or binary.
245/// Returns (text_content, is_binary).
246fn classify_bytes(bytes: &[u8]) -> (Option<String>, bool) {
247    if bytes.is_empty() {
248        return (Some(String::new()), false);
249    }
250    // Heuristic: if any of the first 8 KB contains a null byte, treat as binary.
251    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}