1use std::collections::{BTreeMap, BTreeSet};
7use std::path::{Path, PathBuf};
8
9use sha2::{Digest, Sha256};
10use walkdir::WalkDir;
11
12use super::entry::{DiffEntry, DiffType};
13
14pub struct DiffEngine;
16
17impl DiffEngine {
18 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
53fn 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 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 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 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
195fn 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}