1use crate::EngineError;
16use crate::model::{DiffView, Disposition, Hunk};
17use crate::ports::{ObjectReader, ObjectWriter, RecountSource, TreeBuilder, TreeResolver};
18use crate::tree::build_tree;
19
20#[derive(Debug, Clone, serde::Serialize)]
21pub struct InvariantReport {
22 pub files_total: usize,
23 pub applier_total: usize,
25 pub applier_ok: usize,
26 pub applier_mismatches: Vec<String>,
27 pub binary_oid_checked: usize,
29 pub hunks_total: usize,
30 pub accounting_ok: bool,
31 pub tree: Option<TreeReport>,
34}
35
36#[derive(Debug, Clone, serde::Serialize)]
38pub struct TreeReport {
39 pub built_tree: Option<String>,
40 pub head_tree: String,
41 pub tree_ok: bool,
42 pub recount: usize,
43 pub recount_ok: bool,
44}
45
46impl InvariantReport {
47 pub fn fidelity_ok(&self) -> bool {
53 self.applier_mismatches.is_empty()
54 && self.applier_ok == self.applier_total
55 && self.accounting_ok
56 }
57
58 pub fn all_ok(&self) -> bool {
62 self.fidelity_ok()
63 && self
64 .tree
65 .as_ref()
66 .is_some_and(|t| t.tree_ok && t.recount_ok)
67 }
68
69 pub fn applier_exact(&self) -> String {
71 format!("{}/{}", self.applier_ok, self.applier_total)
72 }
73}
74
75impl std::fmt::Display for InvariantReport {
76 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
82 let verdict = |ok: bool| if ok { "PASS" } else { "FAIL" };
83 writeln!(
84 f,
85 "files {} ({} binary, checked by oid only — tree assertion is tautological for those)",
86 self.files_total, self.binary_oid_checked
87 )?;
88 writeln!(f, "hunks {}", self.hunks_total)?;
89 writeln!(
90 f,
91 "inv1 applier fidelity {} {}",
92 self.applier_exact(),
93 verdict(self.applier_mismatches.is_empty())
94 )?;
95 for m in &self.applier_mismatches {
96 writeln!(f, " mismatch: {m}")?;
97 }
98 writeln!(f, "inv2 hunk accounting {}", verdict(self.accounting_ok))?;
99 let Some(t) = &self.tree else {
100 writeln!(f, "inv3 tree assertion NOT RUN")?;
102 return write!(f, "inv4 independent recount NOT RUN");
103 };
104 writeln!(
105 f,
106 "inv3 tree assertion {} built {} head {}",
107 verdict(t.tree_ok),
108 t.built_tree.as_deref().unwrap_or("(not built)"),
109 t.head_tree
110 )?;
111 writeln!(
112 f,
113 "inv4 independent recount {} of {} {}",
114 t.recount,
115 self.hunks_total,
116 verdict(t.recount_ok)
117 )?;
118 write!(
119 f,
120 "note: tree building writes unreferenced loose objects into the odb (gc-able)"
121 )
122 }
123}
124
125pub fn check_fidelity<G>(
128 git: &G,
129 base: &str,
130 head: &str,
131 view: &DiffView,
132) -> Result<InvariantReport, EngineError>
133where
134 G: ObjectReader,
135{
136 let mut applier_total = 0usize;
138 let mut applier_ok = 0usize;
139 let mut mismatches = Vec::new();
140 let mut binary_checked = 0usize;
141
142 for f in &view.files {
143 match fidelity(f) {
144 Fidelity::Skip => continue,
145 Fidelity::ByOid(oid) => {
146 git.require_object(oid)?;
148 binary_checked += 1;
149 continue;
150 }
151 Fidelity::NoOid => {
152 binary_checked += 1;
153 continue;
154 }
155 Fidelity::Reconstruct => {}
156 }
157 applier_total += 1;
158 let hunks: Vec<&Hunk> = f.hunks.iter().map(|&i| &view.hunks[i]).collect();
159 let base_content = git.blob(base, &f.path)?;
160 let got = crate::apply::apply_hunks(base_content.as_deref(), &hunks);
161 let want = if f.disposition == Disposition::Deleted {
162 Vec::new()
163 } else {
164 git.blob(head, &f.path)?.unwrap_or_default()
165 };
166 if got == want {
167 applier_ok += 1;
168 } else {
169 mismatches.push(format!(
170 "{}: reconstructed {}B, expected {}B",
171 String::from_utf8_lossy(&f.path),
172 got.len(),
173 want.len()
174 ));
175 }
176 }
177
178 let accounting_ok = check_accounting(view);
180
181 Ok(InvariantReport {
182 files_total: view.files.len(),
183 applier_total,
184 applier_ok,
185 applier_mismatches: mismatches,
186 binary_oid_checked: binary_checked,
187 hunks_total: view.hunks.len(),
188 accounting_ok,
189 tree: None,
190 })
191}
192
193pub fn check_tree<G>(
200 git: &G,
201 base: &str,
202 head: &str,
203 view: &DiffView,
204 fidelity: &InvariantReport,
205) -> Result<TreeReport, EngineError>
206where
207 G: ObjectReader + ObjectWriter + TreeResolver + TreeBuilder + RecountSource,
208{
209 let head_tree = git.tree_of(head)?;
210
211 let (built_tree, tree_ok) = if may_build_tree(fidelity) {
213 let built = build_tree(git, base, view)?;
214 let ok = built == head_tree;
215 (Some(built), ok)
216 } else {
217 (None, false)
218 };
219
220 let (recount, recount_ok) = match &built_tree {
224 Some(t) => {
225 let out = git.recount_patch(base, t.as_str())?;
228 let n = dumb_hunk_count(&out);
229 (n, n == view.hunks.len())
230 }
231 None => (0, false),
232 };
233
234 Ok(TreeReport {
235 built_tree,
236 head_tree,
237 tree_ok,
238 recount,
239 recount_ok,
240 })
241}
242
243#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245enum Fidelity<'a> {
246 Skip,
248 ByOid(&'a str),
250 NoOid,
252 Reconstruct,
254}
255
256fn fidelity(f: &crate::model::FileChange) -> Fidelity<'_> {
257 if f.submodule.is_some() {
258 return Fidelity::Skip;
259 }
260 if f.binary {
261 return match f.new_oid.as_deref() {
262 Some(oid) => Fidelity::ByOid(oid),
263 None => Fidelity::NoOid,
264 };
265 }
266 Fidelity::Reconstruct
267}
268
269fn check_accounting(view: &DiffView) -> bool {
275 let mut seen = vec![false; view.hunks.len()];
276 let mut ok = true;
277 let mut carried = 0usize;
278 for (fi, f) in view.files.iter().enumerate() {
279 for &hi in &f.hunks {
280 if hi >= seen.len() || seen[hi] || view.hunks[hi].file != fi {
281 ok = false;
282 continue;
283 }
284 seen[hi] = true;
285 carried += 1;
286 }
287 }
288 ok && carried == view.hunks.len()
289}
290
291fn may_build_tree(fidelity: &InvariantReport) -> bool {
297 fidelity.applier_mismatches.is_empty() && fidelity.applier_ok == fidelity.applier_total
298}
299
300pub fn dumb_hunk_count(patch: &[u8]) -> usize {
303 patch
304 .split(|&b| b == b'\n')
305 .filter(|l| l.starts_with(b"@@ -"))
306 .count()
307}
308
309#[cfg(test)]
310mod tests {
311 use super::{
312 Fidelity, InvariantReport, TreeReport, check_accounting, dumb_hunk_count, fidelity,
313 may_build_tree,
314 };
315 use crate::model::{DiffView, Disposition, FileChange, Hunk};
316
317 fn hunk(file: usize) -> Hunk {
318 Hunk {
319 file,
320 old_start: 1,
321 old_count: 1,
322 new_start: 1,
323 new_count: 1,
324 removed: vec![],
325 added: vec![],
326 nonl_old: false,
327 nonl_new: false,
328 }
329 }
330
331 fn file(hunks: Vec<usize>) -> FileChange {
332 FileChange {
333 path: b"f".to_vec(),
334 disposition: Disposition::Modified,
335 new_mode: Some("100644".into()),
336 old_mode: None,
337 binary: false,
338 submodule: None,
339 old_oid: None,
340 new_oid: None,
341 hunks,
342 rename_similarity: None,
343 rename_from: None,
344 rename_to: None,
345 generated: None,
346 }
347 }
348
349 #[test]
352 fn accounting_holds_when_every_hunk_belongs_to_exactly_one_file() {
353 let view = DiffView {
354 files: vec![file(vec![0, 1]), file(vec![2])],
355 hunks: vec![hunk(0), hunk(0), hunk(1)],
356 };
357 assert!(check_accounting(&view));
358 }
359
360 #[test]
361 fn accounting_catches_a_hunk_claimed_twice() {
362 let view = DiffView {
363 files: vec![file(vec![0]), file(vec![0])],
364 hunks: vec![hunk(0)],
365 };
366 assert!(!check_accounting(&view), "one hunk in two files");
367 }
368
369 #[test]
370 fn accounting_catches_a_hunk_no_file_claims() {
371 let view = DiffView {
372 files: vec![file(vec![0])],
373 hunks: vec![hunk(0), hunk(0)],
374 };
375 assert!(!check_accounting(&view), "h1 is carried by nothing");
376 }
377
378 #[test]
379 fn accounting_catches_a_file_claiming_another_files_hunk() {
380 let view = DiffView {
382 files: vec![file(vec![0]), file(vec![1])],
383 hunks: vec![hunk(0), hunk(0)],
384 };
385 assert!(!check_accounting(&view));
386 }
387
388 #[test]
389 fn accounting_catches_an_out_of_range_index() {
390 let view = DiffView {
391 files: vec![file(vec![7])],
392 hunks: vec![hunk(0)],
393 };
394 assert!(!check_accounting(&view));
395 }
396
397 #[test]
398 fn binary_and_submodule_files_are_verified_differently_from_text() {
399 let mut f = file(vec![]);
400 assert_eq!(fidelity(&f), Fidelity::Reconstruct);
401
402 f.binary = true;
403 f.new_oid = Some("abc".into());
404 assert_eq!(fidelity(&f), Fidelity::ByOid("abc"));
405
406 f.new_oid = None;
407 assert_eq!(fidelity(&f), Fidelity::NoOid);
408
409 f.binary = false;
410 f.submodule = Some((None, Some("s".into())));
411 assert_eq!(fidelity(&f), Fidelity::Skip);
412 }
413
414 fn report(applier_total: usize, applier_ok: usize, mismatches: Vec<String>) -> InvariantReport {
415 InvariantReport {
416 files_total: applier_total,
417 applier_total,
418 applier_ok,
419 applier_mismatches: mismatches,
420 binary_oid_checked: 0,
421 hunks_total: 0,
422 accounting_ok: true,
423 tree: None,
424 }
425 }
426
427 #[test]
430 fn a_broken_applier_stops_the_tree_from_being_built() {
431 assert!(may_build_tree(&report(3, 3, vec![])));
432 assert!(!may_build_tree(&report(3, 2, vec![])));
433 assert!(!may_build_tree(&report(
434 3,
435 3,
436 vec!["f: mismatch".to_string()]
437 )));
438 }
439
440 #[test]
443 fn an_unverified_report_is_not_all_ok() {
444 let r = report(3, 3, vec![]);
445 assert!(r.fidelity_ok(), "invariants 1 and 2 passed");
446 assert!(!r.all_ok(), "invariants 3 and 4 never ran");
447 }
448
449 #[test]
450 fn a_verified_report_is_all_ok_only_when_both_halves_pass() {
451 let mut r = report(3, 3, vec![]);
452 r.tree = Some(TreeReport {
453 built_tree: Some("t".into()),
454 head_tree: "t".into(),
455 tree_ok: true,
456 recount: 0,
457 recount_ok: true,
458 });
459 assert!(r.all_ok());
460 r.tree.as_mut().unwrap().recount_ok = false;
461 assert!(!r.all_ok(), "invariant 4 failed");
462 assert!(r.fidelity_ok(), "but 1 and 2 still hold");
463 }
464
465 #[test]
466 fn dumb_counter_counts_headers_only() {
467 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";
468 assert_eq!(dumb_hunk_count(patch), 2);
469 }
470
471 #[test]
472 fn dumb_counter_ignores_content_lines_that_mention_hunks() {
473 let patch = b"@@ -1,0 +1,1 @@\n+@@ -5,5 +5,5 @@\n";
476 assert_eq!(dumb_hunk_count(patch), 1);
477 }
478}