Skip to main content

differential_engine/
pipeline.rs

1//! End-to-end core pipeline: enumerate → annotate → classify → verify → emit.
2
3use std::collections::HashSet;
4
5use crate::schema;
6
7use crate::EngineError;
8use crate::config::Config;
9use crate::document::{SourceInfo, assemble};
10use crate::invariants::{InvariantReport, check_all};
11use crate::lang::LanguageRegistry;
12use crate::plan;
13use crate::ports::{
14    AttributeSource, DiffSource, ObjectReader, ObjectWriter, RangeResolver, RecountSource,
15    TreeBuilder, TreeResolver, WorkingCopy,
16};
17
18pub struct PipelineOutput {
19    pub base: String,
20    pub head: String,
21    pub report: InvariantReport,
22    /// `None` iff an invariant failed — no document is emitted on a violation.
23    pub document: Option<schema::PlanDocument>,
24    /// The canonical diff view (hunk content). Renderers that display bytes —
25    /// the stack builder and the TUI — read from here; the schema document
26    /// deliberately never carries content (ADR 0008).
27    pub view: crate::model::DiffView,
28}
29
30/// Resolve a revision-range spec into a review source.
31///
32/// Accepts `a..b`, `a...b` (base = merge-base, what an MR/PR diff is), or two
33/// separate revs. The spec is parsed once, in `plan::parse_range`, so the
34/// endpoints and the review's identity cannot disagree about which side is the
35/// head.
36pub fn resolve_range<G: RangeResolver>(
37    git: &G,
38    spec: &[&str],
39) -> Result<plan::ReviewSource, EngineError> {
40    let parsed = plan::parse_range(spec)?;
41    let head_spec = parsed.head_spec().to_string();
42    let (base, head) = match &parsed {
43        plan::RangeSpec::Direct { base, head } => (base.clone(), head.clone()),
44        plan::RangeSpec::MergeBase { a, b } => (git.merge_base(a, b)?, b.clone()),
45    };
46    Ok(plan::ReviewSource::range(base, head, head_spec))
47}
48
49/// Resolve the review picker's answer: a base commit, plus whether uncommitted
50/// work is included (ADR 0017).
51///
52/// With it, the head is a snapshot of the worktree and the review is filed
53/// under the base sha plus a stable literal, so it survives that snapshot tree
54/// churning under it on every edit. Without it, the head is `HEAD`.
55pub fn resolve_picked<G>(
56    git: &G,
57    base: String,
58    include_worktree: bool,
59) -> Result<plan::ReviewSource, EngineError>
60where
61    G: TreeBuilder + WorkingCopy,
62{
63    if !include_worktree {
64        return Ok(plan::ReviewSource::range(
65            base,
66            HEAD_SPEC.to_string(),
67            HEAD_SPEC.to_string(),
68        ));
69    }
70    let head = crate::worktree::worktree_tree(git)?;
71    Ok(plan::ReviewSource {
72        identity_base: Some(base.clone()),
73        base,
74        head,
75        kind: schema::SourceKind::Worktree,
76        head_spec: WORKTREE_SPEC.to_string(),
77    })
78}
79
80/// Identity literals for picked sources (ADR 0017). Not endpoints — they name
81/// what the review is *of* while its synthesized endpoints move.
82const HEAD_SPEC: &str = "HEAD";
83const WORKTREE_SPEC: &str = "WORKTREE";
84
85/// Run the core pipeline (stages: enumerate, classify) over `base..head`.
86///
87/// Config is consulted ONLY for classification hints; enumeration is total and
88/// runs before config is even looked at (ADR 0012). Languages (ADR 0015) only
89/// influence classification, never enumeration.
90pub fn run_pipeline<G>(
91    git: &G,
92    base_rev: &str,
93    head_rev: &str,
94    kind: schema::SourceKind,
95    config: &Config,
96    langs: &LanguageRegistry,
97) -> Result<PipelineOutput, EngineError>
98where
99    G: RangeResolver
100        + DiffSource
101        + AttributeSource
102        + ObjectReader
103        + ObjectWriter
104        + TreeResolver
105        + TreeBuilder
106        + RecountSource,
107{
108    run_core(git, base_rev, head_rev, kind, config, langs)
109}
110
111/// Core pipeline + the grouping stage (stages: enumerate, classify, group).
112///
113/// The engine is the single producer of the final document renderers consume;
114/// grouping runs in-process with internal access to the diff view. On any
115/// invariant failure the grouping stage is skipped and `document` is `None`,
116/// exactly like the core pipeline.
117pub fn run_grouped_pipeline<G, C>(
118    git: &G,
119    base_rev: &str,
120    head_rev: &str,
121    kind: schema::SourceKind,
122    config: &Config,
123    langs: &LanguageRegistry,
124    grouping: &crate::grouping::GroupingOptions<C>,
125) -> Result<PipelineOutput, EngineError>
126where
127    C: crate::ports::GroupingCache,
128    G: RangeResolver
129        + DiffSource
130        + AttributeSource
131        + ObjectReader
132        + ObjectWriter
133        + TreeResolver
134        + TreeBuilder
135        + RecountSource,
136{
137    let mut out = run_core_with_progress(
138        git,
139        base_rev,
140        head_rev,
141        kind,
142        config,
143        langs,
144        grouping.progress,
145    )?;
146
147    if let Some(core_doc) = &out.document {
148        let mut grouped = crate::grouping::run(
149            core_doc,
150            &out.view,
151            grouping.backend,
152            grouping.cache,
153            &langs.fingerprint(),
154            grouping.progress,
155        )?;
156        // Ordering is deterministic and model-free: always runs after grouping.
157        if let Some(f) = grouping.progress {
158            f(crate::grouping::Progress::Ordering);
159        }
160        crate::ordering::apply(&mut grouped, &out.view, langs);
161        out.document = Some(grouped);
162    }
163    if let Some(f) = grouping.progress {
164        f(crate::grouping::Progress::Done);
165    }
166    Ok(out)
167}
168
169fn run_core<G>(
170    git: &G,
171    base_rev: &str,
172    head_rev: &str,
173    kind: schema::SourceKind,
174    config: &Config,
175    langs: &LanguageRegistry,
176) -> Result<PipelineOutput, EngineError>
177where
178    G: RangeResolver
179        + DiffSource
180        + AttributeSource
181        + ObjectReader
182        + ObjectWriter
183        + TreeResolver
184        + TreeBuilder
185        + RecountSource,
186{
187    run_core_with_progress(git, base_rev, head_rev, kind, config, langs, None)
188}
189
190fn run_core_with_progress<G>(
191    git: &G,
192    base_rev: &str,
193    head_rev: &str,
194    kind: schema::SourceKind,
195    config: &Config,
196    langs: &LanguageRegistry,
197    progress: Option<&(dyn Fn(crate::grouping::Progress) + Send + Sync)>,
198) -> Result<PipelineOutput, EngineError>
199where
200    G: RangeResolver
201        + DiffSource
202        + AttributeSource
203        + ObjectReader
204        + ObjectWriter
205        + TreeResolver
206        + TreeBuilder
207        + RecountSource,
208{
209    if let Some(f) = progress {
210        f(crate::grouping::Progress::Enumerating);
211    }
212    // Commits normally; raw tree oids for uncommitted-state reviews
213    // (ADR 0017) — every later stage treats the endpoints as trees anyway.
214    let base = git.resolve_endpoint(base_rev)?;
215    let head = git.resolve_endpoint(head_rev)?;
216
217    // The argv for these three is FROZEN and lives in the adapter, where a
218    // reviewer can see all of it at once (ADR 0002).
219    let raw_records = git.raw_records(&base, &head)?;
220    let canonical_patch = git.canonical_patch(&base, &head)?;
221    let rename_records = git.rename_records(&base, &head)?;
222
223    // Enumeration is total and knows nothing about config (ADR 0012) — see
224    // `plan::build_view`'s parameter list, which is where that is enforced.
225    let mut view = plan::build_view(&plan::Enumeration {
226        raw_records: &raw_records,
227        canonical_patch: &canonical_patch,
228        rename_records: &rename_records,
229    })?;
230
231    // Only now do config and languages get a say, and only over description.
232    let attr_marked = attr_marked_paths(git, config, &view)?;
233    if let Some(f) = progress {
234        f(crate::grouping::Progress::Classifying);
235    }
236    let part = plan::classify(&mut view, config, &attr_marked, langs);
237
238    // Invariants 1–4; no document on violation.
239    let report = check_all(git, &base, &head, &view)?;
240    let document = if report.all_ok() {
241        Some(assemble(
242            &view,
243            &part,
244            &SourceInfo {
245                kind,
246                base: base.clone(),
247                head: head.clone(),
248            },
249            &report,
250        )?)
251    } else {
252        None
253    };
254
255    Ok(PipelineOutput {
256        base,
257        head,
258        report,
259        document,
260        view,
261    })
262}
263
264/// Paths marked generated by any of the configured gitattributes names.
265/// Note: `check-attr` consults the worktree/index `.gitattributes`, not the
266/// reviewed revisions — acceptable for a hint that never affects enumeration.
267fn attr_marked_paths<G: AttributeSource>(
268    git: &G,
269    config: &Config,
270    view: &crate::model::DiffView,
271) -> Result<HashSet<Vec<u8>>, EngineError> {
272    let mut marked = HashSet::new();
273    let paths: Vec<&[u8]> = view.files.iter().map(|f| f.path.as_slice()).collect();
274    for attr in &config.attributes {
275        for answer in git.check_attr(attr, &paths)? {
276            // Which attributes to ask about is config's business; what the
277            // answers mean is domain policy.
278            if plan::attr_marks_generated(&answer.value) {
279                marked.insert(answer.path);
280            }
281        }
282    }
283    Ok(marked)
284}