Skip to main content

differential_engine/
invariants.rs

1//! Invariants 1–4 (spec/invariants.md). All of them run before any document is
2//! emitted; every one caught a real bug during prototype validation.
3
4use crate::EngineError;
5use crate::gitio::Repo;
6use crate::model::{DiffView, Disposition, Hunk};
7use crate::tree::build_tree;
8
9#[derive(Debug, Clone, serde::Serialize)]
10pub struct InvariantReport {
11    pub files_total: usize,
12    /// Text files (non-binary, non-submodule) checked byte-exactly.
13    pub applier_total: usize,
14    pub applier_ok: usize,
15    pub applier_mismatches: Vec<String>,
16    /// Binary files verified by oid instead of by reconstruction.
17    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    /// "n/n" for the audit block.
37    pub fn applier_exact(&self) -> String {
38        format!("{}/{}", self.applier_ok, self.applier_total)
39    }
40}
41
42/// Run invariants 1–4. Invariant 1 (applier fidelity) is asserted BEFORE the
43/// tree is built; if it fails, nothing is built on top of it.
44pub fn check_all(
45    repo: &Repo,
46    base: &str,
47    head: &str,
48    view: &DiffView,
49) -> Result<InvariantReport, EngineError> {
50    // ---- Invariant 1: applier fidelity ------------------------------------
51    let mut applier_total = 0usize;
52    let mut applier_ok = 0usize;
53    let mut mismatches = Vec::new();
54    let mut binary_checked = 0usize;
55
56    for f in &view.files {
57        if f.submodule.is_some() {
58            continue;
59        }
60        if f.binary {
61            // Verified by oid: the recorded object must exist in the odb.
62            if let Some(oid) = &f.new_oid {
63                repo.run(["cat-file", "-e", oid], None)?;
64            }
65            binary_checked += 1;
66            continue;
67        }
68        applier_total += 1;
69        let hunks: Vec<&Hunk> = f.hunks.iter().map(|&i| &view.hunks[i]).collect();
70        let base_content = repo.blob(base, &f.path)?;
71        let got = crate::apply::apply_hunks(base_content.as_deref(), &hunks);
72        let want = if f.disposition == Disposition::Deleted {
73            Vec::new()
74        } else {
75            repo.blob(head, &f.path)?.unwrap_or_default()
76        };
77        if got == want {
78            applier_ok += 1;
79        } else {
80            mismatches.push(format!(
81                "{}: reconstructed {}B, expected {}B",
82                String::from_utf8_lossy(&f.path),
83                got.len(),
84                want.len()
85            ));
86        }
87    }
88
89    // ---- Invariant 2: hunk accounting --------------------------------------
90    let mut seen = vec![false; view.hunks.len()];
91    let mut accounting_ok = true;
92    let mut carried = 0usize;
93    for (fi, f) in view.files.iter().enumerate() {
94        for &hi in &f.hunks {
95            if hi >= seen.len() || seen[hi] || view.hunks[hi].file != fi {
96                accounting_ok = false;
97                continue;
98            }
99            seen[hi] = true;
100            carried += 1;
101        }
102    }
103    accounting_ok &= carried == view.hunks.len();
104
105    let head_tree = repo.rev_parse_raw(&format!("{head}^{{tree}}"))?;
106
107    // ---- Invariant 3: non-tautological tree assertion ----------------------
108    // Refuse to build on a broken applier (the prototype's rule).
109    let (built_tree, tree_ok) = if mismatches.is_empty() && applier_ok == applier_total {
110        let built = build_tree(repo, base, view)?;
111        let ok = built == head_tree;
112        (Some(built), ok)
113    } else {
114        (None, false)
115    };
116
117    // ---- Invariant 4: independent recount -----------------------------------
118    // Computed from git's own output over the BUILT tree, by a counter that is
119    // deliberately not the parser.
120    let (recount, recount_ok) = match &built_tree {
121        Some(t) => {
122            let out = repo.run(
123                ["diff-tree", "-r", "-U0", "--no-renames", base, t.as_str()],
124                None,
125            )?;
126            let n = dumb_hunk_count(&out);
127            (n, n == view.hunks.len())
128        }
129        None => (0, false),
130    };
131
132    Ok(InvariantReport {
133        files_total: view.files.len(),
134        applier_total,
135        applier_ok,
136        applier_mismatches: mismatches,
137        binary_oid_checked: binary_checked,
138        hunks_total: view.hunks.len(),
139        accounting_ok,
140        built_tree,
141        head_tree,
142        tree_ok,
143        recount,
144        recount_ok,
145    })
146}
147
148/// The deliberately dumb `@@` counter. Must never share code with `parse.rs` —
149/// a shared bug would make invariant 4 circular.
150pub(crate) fn dumb_hunk_count(patch: &[u8]) -> usize {
151    patch
152        .split(|&b| b == b'\n')
153        .filter(|l| l.starts_with(b"@@ -"))
154        .count()
155}
156
157#[cfg(test)]
158mod tests {
159    use super::dumb_hunk_count;
160
161    #[test]
162    fn dumb_counter_counts_headers_only() {
163        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";
164        assert_eq!(dumb_hunk_count(patch), 2);
165    }
166
167    #[test]
168    fn dumb_counter_ignores_content_lines_that_mention_hunks() {
169        // An added line whose content starts with "@@ -" gets a "+" prefix in
170        // the patch, so it cannot collide.
171        let patch = b"@@ -1,0 +1,1 @@\n+@@ -5,5 +5,5 @@\n";
172        assert_eq!(dumb_hunk_count(patch), 1);
173    }
174}