Skip to main content

aaai_core/diff/
engine.rs

1//! Folder diff engine.
2//!
3//! Walks both directory trees, compares metadata first, then content for
4//! Modified candidates.  Produces a stable, sorted Vec<DiffEntry>.
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::path::{Path, PathBuf};
8
9use sha2::{Digest, Sha256};
10use walkdir::WalkDir;
11
12use super::entry::{DiffEntry, DiffType};
13
14/// Compares two directory trees and produces a sorted list of DiffEntry.
15pub struct DiffEngine;
16
17impl DiffEngine {
18    /// Run a full comparison between `before_root` and `after_root`.
19    ///
20    /// All errors on individual files are surfaced as DiffType::Unreadable
21    /// entries; the overall function only fails if a root directory is
22    /// inaccessible.
23    pub fn compare(before_root: &Path, after_root: &Path) -> anyhow::Result<Vec<DiffEntry>> {
24        let before_map = collect_paths(before_root)?;
25        let after_map = collect_paths(after_root)?;
26
27        let all_paths: BTreeSet<String> = before_map
28            .keys()
29            .chain(after_map.keys())
30            .cloned()
31            .collect();
32
33        let mut entries = Vec::new();
34
35        for rel_path in all_paths {
36            let before_abs = before_map.get(&rel_path);
37            let after_abs = after_map.get(&rel_path);
38
39            let entry = match (before_abs, after_abs) {
40                (None, Some(after)) => build_added(rel_path, after),
41                (Some(before), None) => build_removed(rel_path, before),
42                (Some(before), Some(after)) => build_compared(rel_path, before, after),
43                (None, None) => unreachable!(),
44            };
45            entries.push(entry);
46        }
47
48        entries.sort_by(|a, b| a.path.cmp(&b.path));
49        Ok(entries)
50    }
51}
52
53// ── helpers ──────────────────────────────────────────────────────────────
54
55/// Collect relative paths from a root directory.
56/// Key: unix-style relative path ("foo/bar.toml")
57/// Value: absolute PathBuf
58fn collect_paths(root: &Path) -> anyhow::Result<BTreeMap<String, PathBuf>> {
59    if !root.is_dir() {
60        anyhow::bail!("Not a directory: {}", root.display());
61    }
62    let mut map = BTreeMap::new();
63    for entry in WalkDir::new(root).into_iter() {
64        let entry = entry.map_err(|e| anyhow::anyhow!("Walk error: {e}"))?;
65        if entry.path() == root {
66            continue;
67        }
68        let rel = entry
69            .path()
70            .strip_prefix(root)
71            .unwrap()
72            .to_string_lossy()
73            .replace('\\', "/");
74        map.insert(rel, entry.path().to_path_buf());
75    }
76    Ok(map)
77}
78
79fn build_added(rel_path: String, after: &Path) -> DiffEntry {
80    let is_dir = after.is_dir();
81    let (after_text, after_sha256, error_detail) = if is_dir {
82        (None, None, None)
83    } else {
84        read_text_and_hash(after)
85    };
86    DiffEntry {
87        path: rel_path,
88        diff_type: DiffType::Added,
89        is_dir,
90        before_text: None,
91        after_text,
92        after_sha256,
93        error_detail,
94    }
95}
96
97fn build_removed(rel_path: String, before: &Path) -> DiffEntry {
98    let is_dir = before.is_dir();
99    let (before_text, _, error_detail) = if is_dir {
100        (None, None, None)
101    } else {
102        read_text_and_hash(before)
103    };
104    DiffEntry {
105        path: rel_path,
106        diff_type: DiffType::Removed,
107        is_dir,
108        before_text,
109        after_text: None,
110        after_sha256: None,
111        error_detail,
112    }
113}
114
115fn build_compared(rel_path: String, before: &Path, after: &Path) -> DiffEntry {
116    // Type mismatch?
117    let before_is_dir = before.is_dir();
118    let after_is_dir = after.is_dir();
119    if before_is_dir != after_is_dir {
120        return DiffEntry {
121            path: rel_path,
122            diff_type: DiffType::TypeChanged,
123            is_dir: false,
124            before_text: None,
125            after_text: None,
126            after_sha256: None,
127            error_detail: Some("Path kind changed (file ↔ directory).".into()),
128        };
129    }
130    if before_is_dir {
131        // Directories: exist on both sides — Unchanged at the dir level.
132        return DiffEntry {
133            path: rel_path,
134            diff_type: DiffType::Unchanged,
135            is_dir: true,
136            before_text: None,
137            after_text: None,
138            after_sha256: None,
139            error_detail: None,
140        };
141    }
142
143    // Read both files.
144    let before_bytes = match std::fs::read(before) {
145        Ok(b) => b,
146        Err(e) => {
147            return DiffEntry {
148                path: rel_path,
149                diff_type: DiffType::Unreadable,
150                is_dir: false,
151                before_text: None,
152                after_text: None,
153                after_sha256: None,
154                error_detail: Some(format!("Cannot read before-file: {e}")),
155            };
156        }
157    };
158    let after_bytes = match std::fs::read(after) {
159        Ok(b) => b,
160        Err(e) => {
161            return DiffEntry {
162                path: rel_path,
163                diff_type: DiffType::Unreadable,
164                is_dir: false,
165                before_text: None,
166                after_text: None,
167                after_sha256: None,
168                error_detail: Some(format!("Cannot read after-file: {e}")),
169            };
170        }
171    };
172
173    let after_sha256 = hex::encode(Sha256::digest(&after_bytes));
174
175    let diff_type = if before_bytes == after_bytes {
176        DiffType::Unchanged
177    } else {
178        DiffType::Modified
179    };
180
181    let before_text = String::from_utf8(before_bytes).ok();
182    let after_text = String::from_utf8(after_bytes).ok();
183
184    DiffEntry {
185        path: rel_path,
186        diff_type,
187        is_dir: false,
188        before_text,
189        after_text,
190        after_sha256: Some(after_sha256),
191        error_detail: None,
192    }
193}
194
195/// Read a file and return (text_option, sha256_option, error_option).
196fn read_text_and_hash(path: &Path) -> (Option<String>, Option<String>, Option<String>) {
197    match std::fs::read(path) {
198        Ok(bytes) => {
199            let sha = hex::encode(Sha256::digest(&bytes));
200            let text = String::from_utf8(bytes).ok();
201            (text, Some(sha), None)
202        }
203        Err(e) => (None, None, Some(format!("Cannot read file: {e}"))),
204    }
205}