Skip to main content

differential_engine/
document.rs

1//! Assemble the frozen-schema plan document from the engine's outputs.
2
3use std::collections::HashSet;
4
5use crate::schema;
6
7use crate::EngineError;
8use crate::artefact::graph::ClassGraph;
9use crate::config::Config;
10use crate::invariants::InvariantReport;
11use crate::model::{DiffView, Disposition, GeneratedBy};
12use crate::plan::HunkId;
13use crate::shape::{Partition, hunk_digest};
14
15/// Built-in generated-artefact detection: lockfiles and minified/snapshot
16/// artefacts nobody reviews line by line. A hint only — never affects
17/// enumeration (ADR 0005/0012), and `not_generated` in config overrides it.
18pub fn builtin_generated(path: &[u8]) -> bool {
19    let base = path.rsplit(|&b| b == b'/').next().unwrap_or(path);
20    const NAMES: &[&[u8]] = &[
21        b"Cargo.lock",
22        b"package-lock.json",
23        b"pnpm-lock.yaml",
24        b"yarn.lock",
25        b"bun.lockb",
26        b"composer.lock",
27        b"Gemfile.lock",
28        b"poetry.lock",
29        b"uv.lock",
30        b"go.sum",
31        b"flake.lock",
32    ];
33    if NAMES.contains(&base) {
34        return true;
35    }
36    const SUFFIXES: &[&[u8]] = &[b".lock", b".snap", b".min.js", b".min.css"];
37    SUFFIXES.iter().any(|s| base.ends_with(s))
38}
39
40/// Apply generated hints with provenance. Precedence: `not_generated` clears
41/// everything; otherwise config glob > gitattributes > builtin.
42pub fn mark_generated(view: &mut DiffView, config: &Config, attr_marked: &HashSet<Vec<u8>>) {
43    for f in &mut view.files {
44        let lossy = String::from_utf8_lossy(&f.path).into_owned();
45        if config.not_generated.is_match(&lossy) {
46            f.generated = None;
47        } else if config.generated.is_match(&lossy) {
48            f.generated = Some(GeneratedBy::Config);
49        } else if attr_marked.contains(&f.path) {
50            f.generated = Some(GeneratedBy::Attr);
51        } else if builtin_generated(&f.path) {
52            f.generated = Some(GeneratedBy::Builtin);
53        } else {
54            f.generated = None;
55        }
56    }
57}
58
59pub struct SourceInfo {
60    pub kind: schema::SourceKind,
61    pub base: String,
62    pub head: String,
63    pub remote: Option<schema::Remote>,
64}
65
66/// Build the document. Fails on a non-UTF-8 path (deferred support — a hard
67/// error naming the file, never silent).
68pub fn assemble(
69    view: &DiffView,
70    partition: &Partition,
71    graph: ClassGraph,
72    source: &SourceInfo,
73    report: &InvariantReport,
74) -> Result<schema::PlanDocument, EngineError> {
75    let files = view
76        .files
77        .iter()
78        .map(|f| {
79            Ok(schema::FileEntry {
80                path: path_str(&f.path)?,
81                disposition: match f.disposition {
82                    Disposition::Added => schema::Disposition::A,
83                    Disposition::Deleted => schema::Disposition::D,
84                    Disposition::Modified => schema::Disposition::M,
85                },
86                mode: f.new_mode.clone(),
87                old_mode: f.old_mode.clone(),
88                old_path: f.rename_from.as_deref().map(path_str).transpose()?,
89                new_path: f.rename_to.as_deref().map(path_str).transpose()?,
90                rename_similarity: f.rename_similarity,
91                binary: f.binary,
92                submodule: f
93                    .submodule
94                    .as_ref()
95                    .map(|(old, new)| schema::SubmoduleChange {
96                        old: old.clone(),
97                        new: new.clone(),
98                    }),
99                generated: f.generated.is_some(),
100                generated_by: f.generated.map(|g| match g {
101                    GeneratedBy::Builtin => schema::GeneratedBy::Builtin,
102                    GeneratedBy::Attr => schema::GeneratedBy::Attr,
103                    GeneratedBy::Config => schema::GeneratedBy::Config,
104                }),
105                hunk_ids: f
106                    .hunks
107                    .iter()
108                    .map(|&i| HunkId::from_index(i).to_string())
109                    .collect(),
110            })
111        })
112        .collect::<Result<Vec<_>, EngineError>>()?;
113
114    let hunks = view
115        .hunks
116        .iter()
117        .enumerate()
118        .map(|(i, h)| {
119            Ok(schema::HunkEntry {
120                id: HunkId::from_index(i).to_string(),
121                file: path_str(&view.file_of(h).path)?,
122                old_start: h.old_start,
123                old_count: h.old_count,
124                new_start: h.new_start,
125                new_count: h.new_count,
126                class: format!("C{}", partition.class_of[i]),
127                digest: hunk_digest(h),
128                nonl_old: h.nonl_old,
129                nonl_new: h.nonl_new,
130                forge_position: schema::ForgePosition {
131                    new_line: (h.new_count > 0).then_some(h.new_start),
132                    old_line: (h.old_count > 0).then_some(h.old_start),
133                },
134            })
135        })
136        .collect::<Result<Vec<_>, EngineError>>()?;
137
138    // The graph arrives owned: its two vectors are moved into the classes
139    // rather than cloned, and a class is the only place either belongs.
140    let mut graph = graph;
141    let classes = partition
142        .classes
143        .iter()
144        .enumerate()
145        .map(|(ci, members)| schema::ClassEntry {
146            id: format!("C{ci}"),
147            hunk_ids: members
148                .iter()
149                .map(|&i| HunkId::from_index(i).to_string())
150                .collect(),
151            exemplar: HunkId::from_index(members[0]).to_string(),
152            pure_substitution: partition.pure[ci],
153            defines: std::mem::take(&mut graph.defines[ci]),
154            depends_on: std::mem::take(&mut graph.depends_on[ci]),
155        })
156        .collect::<Vec<_>>();
157
158    Ok(schema::PlanDocument {
159        schema_version: schema::SCHEMA_VERSION,
160        generator: schema::Generator::current(&["enumerate", "classify"]),
161        source: schema::Source {
162            kind: source.kind,
163            base: source.base.clone(),
164            head: source.head.clone(),
165            remote: source.remote.clone(),
166        },
167        stats: schema::Stats {
168            files: view.files.len() as u32,
169            hunks: view.hunks.len() as u32,
170            classes: partition.classes.len() as u32,
171            binary_files: view.files.iter().filter(|f| f.binary).count() as u32,
172            submodules: view.files.iter().filter(|f| f.submodule.is_some()).count() as u32,
173        },
174        files,
175        hunks,
176        classes,
177        groups: None,
178        reading_plan: None,
179        audit: schema::Audit {
180            applier_exact: report.applier_exact(),
181            // The tree half may not have run: it writes, so a caller that only
182            // reads never asks for it. "skipped" is a legal value for a frozen
183            // `String` field, and `generator.stages` is the authority a
184            // consumer must actually consult (spec/overview.md).
185            tree_assertion: tree_assertion(report),
186            hunks_carried: report.hunks_total as u32,
187            recount: report.tree.as_ref().map_or(0, |t| t.recount) as u32,
188            ..schema::Audit::default()
189        },
190        // Moved in beside the class graph, from the same extraction. `classify`
191        // produced it, so it needs no stage of its own in `generator.stages`.
192        symbols: Some(std::mem::take(&mut graph.symbols)),
193    })
194}
195
196fn tree_assertion(report: &InvariantReport) -> String {
197    match &report.tree {
198        Some(t) => if t.tree_ok { "pass" } else { "fail" }.to_string(),
199        None => "skipped".to_string(),
200    }
201}
202
203/// Fold the verify stage's result into a document the core pipeline assembled.
204///
205/// Appends `"verify"` to `generator.stages`, which is what tells a consumer the
206/// two tree fields mean anything at all. Idempotent: running verify twice does
207/// not list the stage twice.
208pub fn apply_tree_audit(doc: &mut schema::PlanDocument, tree: &crate::invariants::TreeReport) {
209    doc.audit.tree_assertion = if tree.tree_ok { "pass" } else { "fail" }.to_string();
210    doc.audit.recount = tree.recount as u32;
211    if !doc.generator.stages.iter().any(|s| s == "verify") {
212        doc.generator.stages.push("verify".to_string());
213    }
214}
215
216fn path_str(path: &[u8]) -> Result<String, EngineError> {
217    String::from_utf8(path.to_vec()).map_err(|_| EngineError::NonUtf8Path {
218        lossy: String::from_utf8_lossy(path).into_owned(),
219    })
220}
221
222#[cfg(test)]
223mod tests {
224    use super::builtin_generated;
225
226    #[test]
227    fn builtin_list_matches() {
228        assert!(builtin_generated(b"Cargo.lock"));
229        assert!(builtin_generated(b"nested/dir/package-lock.json"));
230        assert!(builtin_generated(b"ui/__snapshots__/thing.snap"));
231        assert!(builtin_generated(b"dist/app.min.js"));
232        assert!(!builtin_generated(b"src/main.rs"));
233        assert!(!builtin_generated(b"docs/lockfile-design.md"));
234    }
235}