Skip to main content

aaai_core/diff/
engine.rs

1//! Folder diff engine with optional ignore-pattern support.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5
6use sha2::{Digest, Sha256};
7use walkdir::WalkDir;
8
9use super::entry::{DiffEntry, DiffType};
10use super::ignore::IgnoreRules;
11
12pub struct DiffEngine;
13
14impl DiffEngine {
15    /// Compare two directory trees.
16    /// Paths matching `ignore` rules are excluded from the result.
17    pub fn compare(before_root: &Path, after_root: &Path) -> anyhow::Result<Vec<DiffEntry>> {
18        Self::compare_with_ignore(before_root, after_root, &IgnoreRules::default())
19    }
20
21    /// Compare with explicit ignore rules.
22    pub fn compare_with_ignore(
23        before_root: &Path,
24        after_root: &Path,
25        ignore: &IgnoreRules,
26    ) -> anyhow::Result<Vec<DiffEntry>> {
27        let before_map = collect_paths(before_root)?;
28        let after_map  = collect_paths(after_root)?;
29
30        let all_paths: BTreeSet<String> = before_map.keys()
31            .chain(after_map.keys())
32            .cloned()
33            .collect();
34
35        let mut entries = Vec::new();
36        for rel_path in all_paths {
37            if ignore.is_ignored(&rel_path) {
38                log::debug!("ignored: {rel_path}");
39                continue;
40            }
41            let entry = match (before_map.get(&rel_path), after_map.get(&rel_path)) {
42                (None,    Some(a)) => build_added(rel_path, a),
43                (Some(b), None)    => build_removed(rel_path, b),
44                (Some(b), Some(a)) => build_compared(rel_path, b, a),
45                (None,    None)    => unreachable!(),
46            };
47            entries.push(entry);
48        }
49
50        entries.sort_by(|a, b| a.path.cmp(&b.path));
51        Ok(entries)
52    }
53}
54
55fn collect_paths(root: &Path) -> anyhow::Result<BTreeMap<String, PathBuf>> {
56    if !root.is_dir() {
57        anyhow::bail!("Not a directory: {}", root.display());
58    }
59    let mut map = BTreeMap::new();
60    for entry in WalkDir::new(root).into_iter() {
61        let entry = entry.map_err(|e| anyhow::anyhow!("Walk error: {e}"))?;
62        if entry.path() == root { continue; }
63        let rel = entry.path()
64            .strip_prefix(root).unwrap()
65            .to_string_lossy()
66            .replace('\\', "/");
67        map.insert(rel, entry.path().to_path_buf());
68    }
69    Ok(map)
70}
71
72fn build_added(rel: String, after: &Path) -> DiffEntry {
73    let is_dir = after.is_dir();
74    let (after_text, after_sha256, error_detail) =
75        if is_dir { (None, None, None) } else { read_text_and_hash(after) };
76    DiffEntry { path: rel, diff_type: DiffType::Added, is_dir,
77                before_text: None, after_text, after_sha256, error_detail }
78}
79
80fn build_removed(rel: String, before: &Path) -> DiffEntry {
81    let is_dir = before.is_dir();
82    let (before_text, _, error_detail) =
83        if is_dir { (None, None, None) } else { read_text_and_hash(before) };
84    DiffEntry { path: rel, diff_type: DiffType::Removed, is_dir,
85                before_text, after_text: None, after_sha256: None, error_detail }
86}
87
88fn build_compared(rel: String, before: &Path, after: &Path) -> DiffEntry {
89    let before_is_dir = before.is_dir();
90    let after_is_dir  = after.is_dir();
91    if before_is_dir != after_is_dir {
92        return DiffEntry {
93            path: rel, diff_type: DiffType::TypeChanged, is_dir: false,
94            before_text: None, after_text: None, after_sha256: None,
95            error_detail: Some("Path kind changed (file ↔ directory).".into()),
96        };
97    }
98    if before_is_dir {
99        return DiffEntry { path: rel, diff_type: DiffType::Unchanged, is_dir: true,
100                           before_text: None, after_text: None, after_sha256: None,
101                           error_detail: None };
102    }
103
104    let before_bytes = match std::fs::read(before) {
105        Ok(b) => b,
106        Err(e) => return DiffEntry {
107            path: rel, diff_type: DiffType::Unreadable, is_dir: false,
108            before_text: None, after_text: None, after_sha256: None,
109            error_detail: Some(format!("Cannot read before-file: {e}")),
110        },
111    };
112    let after_bytes = match std::fs::read(after) {
113        Ok(b) => b,
114        Err(e) => return DiffEntry {
115            path: rel, diff_type: DiffType::Unreadable, is_dir: false,
116            before_text: None, after_text: None, after_sha256: None,
117            error_detail: Some(format!("Cannot read after-file: {e}")),
118        },
119    };
120
121    let after_sha256 = hex::encode(Sha256::digest(&after_bytes));
122    let diff_type = if before_bytes == after_bytes { DiffType::Unchanged } else { DiffType::Modified };
123    DiffEntry {
124        path: rel, diff_type, is_dir: false,
125        before_text: String::from_utf8(before_bytes).ok(),
126        after_text: String::from_utf8(after_bytes).ok(),
127        after_sha256: Some(after_sha256),
128        error_detail: None,
129    }
130}
131
132fn read_text_and_hash(path: &Path) -> (Option<String>, Option<String>, Option<String>) {
133    match std::fs::read(path) {
134        Ok(bytes) => {
135            let sha = hex::encode(Sha256::digest(&bytes));
136            (String::from_utf8(bytes).ok(), Some(sha), None)
137        }
138        Err(e) => (None, None, Some(format!("Cannot read file: {e}"))),
139    }
140}