1use crate::EngineError;
5use crate::model::{DiffView, Disposition, Hunk};
6use crate::ports::{ObjectReader, ObjectWriter, RecountSource, TreeBuilder, TreeResolver};
7use crate::tree::build_tree;
8
9#[derive(Debug, Clone, serde::Serialize)]
10pub struct InvariantReport {
11 pub files_total: usize,
12 pub applier_total: usize,
14 pub applier_ok: usize,
15 pub applier_mismatches: Vec<String>,
16 pub binary_oid_checked: usize,
18 pub hunks_total: usize,
19 pub accounting_ok: bool,
20 pub built_tree: Option<String>,
21 pub head_tree: String,
22 pub tree_ok: bool,
23 pub recount: usize,
24 pub recount_ok: bool,
25}
26
27impl InvariantReport {
28 pub fn all_ok(&self) -> bool {
29 self.applier_mismatches.is_empty()
30 && self.applier_ok == self.applier_total
31 && self.accounting_ok
32 && self.tree_ok
33 && self.recount_ok
34 }
35
36 pub fn applier_exact(&self) -> String {
38 format!("{}/{}", self.applier_ok, self.applier_total)
39 }
40}
41
42impl std::fmt::Display for InvariantReport {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
49 let verdict = |ok: bool| if ok { "PASS" } else { "FAIL" };
50 writeln!(
51 f,
52 "files {} ({} binary, checked by oid only — tree assertion is tautological for those)",
53 self.files_total, self.binary_oid_checked
54 )?;
55 writeln!(f, "hunks {}", self.hunks_total)?;
56 writeln!(
57 f,
58 "inv1 applier fidelity {} {}",
59 self.applier_exact(),
60 verdict(self.applier_mismatches.is_empty())
61 )?;
62 for m in &self.applier_mismatches {
63 writeln!(f, " mismatch: {m}")?;
64 }
65 writeln!(f, "inv2 hunk accounting {}", verdict(self.accounting_ok))?;
66 writeln!(
67 f,
68 "inv3 tree assertion {} built {} head {}",
69 verdict(self.tree_ok),
70 self.built_tree.as_deref().unwrap_or("(not built)"),
71 self.head_tree
72 )?;
73 writeln!(
74 f,
75 "inv4 independent recount {} of {} {}",
76 self.recount,
77 self.hunks_total,
78 verdict(self.recount_ok)
79 )?;
80 write!(
81 f,
82 "note: tree building writes unreferenced loose objects into the odb (gc-able)"
83 )
84 }
85}
86
87pub fn check_all<G>(
90 git: &G,
91 base: &str,
92 head: &str,
93 view: &DiffView,
94) -> Result<InvariantReport, EngineError>
95where
96 G: ObjectReader + ObjectWriter + TreeResolver + TreeBuilder + RecountSource,
97{
98 let mut applier_total = 0usize;
100 let mut applier_ok = 0usize;
101 let mut mismatches = Vec::new();
102 let mut binary_checked = 0usize;
103
104 for f in &view.files {
105 match fidelity(f) {
106 Fidelity::Skip => continue,
107 Fidelity::ByOid(oid) => {
108 git.require_object(oid)?;
110 binary_checked += 1;
111 continue;
112 }
113 Fidelity::NoOid => {
114 binary_checked += 1;
115 continue;
116 }
117 Fidelity::Reconstruct => {}
118 }
119 applier_total += 1;
120 let hunks: Vec<&Hunk> = f.hunks.iter().map(|&i| &view.hunks[i]).collect();
121 let base_content = git.blob(base, &f.path)?;
122 let got = crate::apply::apply_hunks(base_content.as_deref(), &hunks);
123 let want = if f.disposition == Disposition::Deleted {
124 Vec::new()
125 } else {
126 git.blob(head, &f.path)?.unwrap_or_default()
127 };
128 if got == want {
129 applier_ok += 1;
130 } else {
131 mismatches.push(format!(
132 "{}: reconstructed {}B, expected {}B",
133 String::from_utf8_lossy(&f.path),
134 got.len(),
135 want.len()
136 ));
137 }
138 }
139
140 let accounting_ok = check_accounting(view);
142
143 let head_tree = git.tree_of(head)?;
144
145 let (built_tree, tree_ok) = if may_build_tree(applier_total, applier_ok, &mismatches) {
148 let built = build_tree(git, base, view)?;
149 let ok = built == head_tree;
150 (Some(built), ok)
151 } else {
152 (None, false)
153 };
154
155 let (recount, recount_ok) = match &built_tree {
159 Some(t) => {
160 let out = git.recount_patch(base, t.as_str())?;
163 let n = dumb_hunk_count(&out);
164 (n, n == view.hunks.len())
165 }
166 None => (0, false),
167 };
168
169 Ok(InvariantReport {
170 files_total: view.files.len(),
171 applier_total,
172 applier_ok,
173 applier_mismatches: mismatches,
174 binary_oid_checked: binary_checked,
175 hunks_total: view.hunks.len(),
176 accounting_ok,
177 built_tree,
178 head_tree,
179 tree_ok,
180 recount,
181 recount_ok,
182 })
183}
184
185#[derive(Debug, Clone, Copy, PartialEq, Eq)]
187enum Fidelity<'a> {
188 Skip,
190 ByOid(&'a str),
192 NoOid,
194 Reconstruct,
196}
197
198fn fidelity(f: &crate::model::FileChange) -> Fidelity<'_> {
199 if f.submodule.is_some() {
200 return Fidelity::Skip;
201 }
202 if f.binary {
203 return match f.new_oid.as_deref() {
204 Some(oid) => Fidelity::ByOid(oid),
205 None => Fidelity::NoOid,
206 };
207 }
208 Fidelity::Reconstruct
209}
210
211fn check_accounting(view: &DiffView) -> bool {
217 let mut seen = vec![false; view.hunks.len()];
218 let mut ok = true;
219 let mut carried = 0usize;
220 for (fi, f) in view.files.iter().enumerate() {
221 for &hi in &f.hunks {
222 if hi >= seen.len() || seen[hi] || view.hunks[hi].file != fi {
223 ok = false;
224 continue;
225 }
226 seen[hi] = true;
227 carried += 1;
228 }
229 }
230 ok && carried == view.hunks.len()
231}
232
233fn may_build_tree(applier_total: usize, applier_ok: usize, mismatches: &[String]) -> bool {
236 mismatches.is_empty() && applier_ok == applier_total
237}
238
239pub fn dumb_hunk_count(patch: &[u8]) -> usize {
242 patch
243 .split(|&b| b == b'\n')
244 .filter(|l| l.starts_with(b"@@ -"))
245 .count()
246}
247
248#[cfg(test)]
249mod tests {
250 use super::{Fidelity, check_accounting, dumb_hunk_count, fidelity, may_build_tree};
251 use crate::model::{DiffView, Disposition, FileChange, Hunk};
252
253 fn hunk(file: usize) -> Hunk {
254 Hunk {
255 file,
256 old_start: 1,
257 old_count: 1,
258 new_start: 1,
259 new_count: 1,
260 removed: vec![],
261 added: vec![],
262 nonl_old: false,
263 nonl_new: false,
264 }
265 }
266
267 fn file(hunks: Vec<usize>) -> FileChange {
268 FileChange {
269 path: b"f".to_vec(),
270 disposition: Disposition::Modified,
271 new_mode: Some("100644".into()),
272 old_mode: None,
273 binary: false,
274 submodule: None,
275 old_oid: None,
276 new_oid: None,
277 hunks,
278 rename_similarity: None,
279 rename_from: None,
280 rename_to: None,
281 generated: None,
282 }
283 }
284
285 #[test]
288 fn accounting_holds_when_every_hunk_belongs_to_exactly_one_file() {
289 let view = DiffView {
290 files: vec![file(vec![0, 1]), file(vec![2])],
291 hunks: vec![hunk(0), hunk(0), hunk(1)],
292 };
293 assert!(check_accounting(&view));
294 }
295
296 #[test]
297 fn accounting_catches_a_hunk_claimed_twice() {
298 let view = DiffView {
299 files: vec![file(vec![0]), file(vec![0])],
300 hunks: vec![hunk(0)],
301 };
302 assert!(!check_accounting(&view), "one hunk in two files");
303 }
304
305 #[test]
306 fn accounting_catches_a_hunk_no_file_claims() {
307 let view = DiffView {
308 files: vec![file(vec![0])],
309 hunks: vec![hunk(0), hunk(0)],
310 };
311 assert!(!check_accounting(&view), "h1 is carried by nothing");
312 }
313
314 #[test]
315 fn accounting_catches_a_file_claiming_another_files_hunk() {
316 let view = DiffView {
318 files: vec![file(vec![0]), file(vec![1])],
319 hunks: vec![hunk(0), hunk(0)],
320 };
321 assert!(!check_accounting(&view));
322 }
323
324 #[test]
325 fn accounting_catches_an_out_of_range_index() {
326 let view = DiffView {
327 files: vec![file(vec![7])],
328 hunks: vec![hunk(0)],
329 };
330 assert!(!check_accounting(&view));
331 }
332
333 #[test]
334 fn binary_and_submodule_files_are_verified_differently_from_text() {
335 let mut f = file(vec![]);
336 assert_eq!(fidelity(&f), Fidelity::Reconstruct);
337
338 f.binary = true;
339 f.new_oid = Some("abc".into());
340 assert_eq!(fidelity(&f), Fidelity::ByOid("abc"));
341
342 f.new_oid = None;
343 assert_eq!(fidelity(&f), Fidelity::NoOid);
344
345 f.binary = false;
346 f.submodule = Some((None, Some("s".into())));
347 assert_eq!(fidelity(&f), Fidelity::Skip);
348 }
349
350 #[test]
353 fn a_broken_applier_stops_the_tree_from_being_built() {
354 assert!(may_build_tree(3, 3, &[]));
355 assert!(!may_build_tree(3, 2, &[]));
356 assert!(!may_build_tree(3, 3, &["f: mismatch".to_string()]));
357 }
358
359 #[test]
360 fn dumb_counter_counts_headers_only() {
361 let patch = b"diff --git a/f b/f\n@@ -1,2 +1,2 @@\n-a\n+b\n@@ -9 +9 @@\n-x\n+y\n";
362 assert_eq!(dumb_hunk_count(patch), 2);
363 }
364
365 #[test]
366 fn dumb_counter_ignores_content_lines_that_mention_hunks() {
367 let patch = b"@@ -1,0 +1,1 @@\n+@@ -5,5 +5,5 @@\n";
370 assert_eq!(dumb_hunk_count(patch), 1);
371 }
372}