Skip to main content

differential_engine/
ports.rs

1//! Ports: the traits the engine's business logic owns and adapters implement.
2//!
3//! Dependency direction only (ADR 0020). These exist so `pipeline`,
4//! `invariants`, `tree`, `worktree` and `crates/stack` never name
5//! `gitio::Repo` — each function's bound list is a reviewable statement of
6//! exactly how much git it is allowed to touch, and `invariants` can no longer
7//! so much as *express* `git log`.
8//!
9//! **Consumed by static dispatch.** There is exactly one implementation of
10//! each git port, `gitio::Repo`, and ADR 0020 forbids a second one — a fake
11//! git for tests included. Invariants 1–4 compare the engine's reconstruction
12//! against git's own answer; against a fake they would compare the fake with
13//! the fake and pass while proving nothing (ADR 0002). Tests use hermetic
14//! temporary repositories and real `git`.
15//!
16//! The two runtime-open abstractions in this crate — `llm::LlmBackend` and
17//! `lang::Language` — are deliberately NOT here. They are `dyn` because config
18//! and a plugin registry pick them at run time; nothing in this module is
19//! chosen at run time.
20//!
21//! There is no `trait Git: ObjectReader + …` convenience supertrait, and there
22//! must not be: the whole value is that a consumer's bounds name what it
23//! actually needs.
24
25use std::collections::HashMap;
26use std::path::{Path, PathBuf};
27
28use crate::EngineError;
29use crate::review_state::{Finding, ReviewState};
30
31// ---------------------------------------------------------------- objects
32
33/// Reading objects out of the odb.
34pub trait ObjectReader {
35    /// Blob content at `rev:path`, with `path` kept as raw bytes.
36    ///
37    /// `Ok(None)` when the path does not exist at that revision. Any other
38    /// failure is a real error — an absent file and a broken repository must
39    /// never render identically.
40    fn blob(&self, rev: &str, path: &[u8]) -> Result<Option<Vec<u8>>, EngineError>;
41
42    /// The same, for several specs at once, answered in the order given.
43    ///
44    /// Reading a blob costs a process, and a process costs milliseconds — so a
45    /// caller that already knows every file it is about to draw should say so
46    /// rather than paying that per file. Measured on a 120-file range: 240
47    /// one-at-a-time reads took a second, against one call for the lot
48    /// (ADR 0021's "what is left is process spawns").
49    ///
50    /// Bulk is the SAME need as `blob`, not a different one, which is why it
51    /// sits on this port rather than earning its own.
52    fn blobs(&self, specs: &[(&str, &[u8])]) -> Result<Vec<Option<Vec<u8>>>, EngineError>;
53
54    /// Assert `oid` is present in the odb.
55    ///
56    /// Invariant 1 verifies binary files this way, since they carry no hunks
57    /// to reconstruct from. Deliberately not `-> Result<bool>`: a recorded oid
58    /// missing from the odb is a broken repository, not a failed invariant.
59    fn require_object(&self, oid: &str) -> Result<(), EngineError>;
60}
61
62/// Writing objects into the odb. Loose and unreferenced (so gc-able) by
63/// design — the engine creates a ref only where `RefWriter` says so.
64pub trait ObjectWriter {
65    /// Write `content` as a blob and return its oid.
66    fn write_blob(&self, content: &[u8]) -> Result<String, EngineError>;
67}
68
69// ------------------------------------------------------------- revisions
70
71/// Turning what the user typed into diff endpoints. Consumed only by
72/// `pipeline`: nothing downstream of resolution ever re-resolves.
73pub trait RangeResolver {
74    fn merge_base(&self, a: &str, b: &str) -> Result<String, EngineError>;
75
76    /// Resolve to a commit sha, or accept a raw tree oid.
77    ///
78    /// The endpoints of an uncommitted-state review are synthesized trees
79    /// (ADR 0017) and every later stage is tree-safe.
80    fn resolve_endpoint(&self, rev: &str) -> Result<String, EngineError>;
81}
82
83/// Placing one review's head against another's.
84///
85/// Kept apart from `RangeResolver` because the question is different: not
86/// "what are this range's endpoints" but "are these two heads on one line of
87/// history". That is what tells a branch that moved on from a different
88/// branch off the same base.
89pub trait Ancestry {
90    /// The commit a spec names NOW, or `None` if it names nothing.
91    ///
92    /// A review filed against a branch that has since been deleted is not an
93    /// error — it is a candidate that cannot be placed, so it is skipped.
94    fn commit_of(&self, spec: &str) -> Result<Option<String>, EngineError>;
95
96    /// Is `older` reachable from `newer`?
97    fn is_ancestor(&self, older: &str, newer: &str) -> Result<bool, EngineError>;
98}
99
100/// Peeling an endpoint to its tree oid — the one thing invariant 3 compares
101/// against.
102///
103/// Kept apart from `RangeResolver` because its consumers (`invariants`,
104/// `crates/stack`) must never resolve ranges.
105pub trait TreeResolver {
106    fn tree_of(&self, rev: &str) -> Result<String, EngineError>;
107}
108
109// ----------------------------------------------------------- enumeration
110
111/// Canonical enumeration (ADR 0005: total, no exclusions, ever).
112///
113/// **FROZEN ARGV.** The byte format each method returns is what `parse.rs`,
114/// `rename_view.rs` and ultimately the frozen normaliser were validated
115/// against; changing a flag changes shape hashes and breaks real-corpus
116/// parity. Add a method, never edit one.
117pub trait DiffSource {
118    /// `diff-tree -r -z --raw --full-index --no-renames`: authoritative modes,
119    /// full oids, dispositions.
120    fn raw_records(&self, base: &str, head: &str) -> Result<Vec<u8>, EngineError>;
121
122    /// `diff-tree -r -U0 --no-renames --no-color --no-ext-diff`: the canonical
123    /// patch. Every hunk in the system comes from here.
124    fn canonical_patch(&self, base: &str, head: &str) -> Result<Vec<u8>, EngineError>;
125
126    /// `diff-tree -r -M -z --name-status`: rename-detected **annotations**
127    /// only (ADR 0003). Never affects what exists.
128    fn rename_records(&self, base: &str, head: &str) -> Result<Vec<u8>, EngineError>;
129}
130
131/// Invariant 4's independent patch source, and nothing else.
132///
133/// A **separate trait** from `DiffSource` on purpose. Invariant 4 recounts
134/// `@@` headers over a patch of the tree the engine built, using a counter
135/// that is deliberately not the parser. Sharing an accessor with enumeration
136/// would mean one edit to one flag silently moving both sides of the
137/// comparison together.
138///
139/// An implementation MUST call git directly and MUST NOT delegate to
140/// `DiffSource::canonical_patch` — note the argv genuinely differs today, and
141/// that duplication is the point rather than an oversight.
142///
143/// The return type is `Vec<u8>` and must stay `Vec<u8>`: the moment this port
144/// hands invariant 4 anything structured, the counter stops being independent.
145pub trait RecountSource {
146    fn recount_patch(&self, from: &str, to: &str) -> Result<Vec<u8>, EngineError>;
147}
148
149/// One `check-attr` answer for one path.
150pub struct AttrValue {
151    pub path: Vec<u8>,
152    /// git's raw answer: a value, or `unspecified` / `unset` / `true` /
153    /// `false`. What those *mean* is domain policy
154    /// (`plan::attr_marks_generated`).
155    pub value: Vec<u8>,
156}
157
158/// gitattributes lookup, for the generated-file **hint** — never enumeration.
159///
160/// Takes an attribute name the caller chose; it does not take a `Config` and
161/// iterate one itself, because a port that reads config is a port that could
162/// filter (ADR 0012).
163pub trait AttributeSource {
164    /// Note, unchanged from before this trait existed: `check-attr` consults
165    /// the worktree/index `.gitattributes`, not the reviewed revisions —
166    /// acceptable for a hint that can never remove a file from enumeration.
167    fn check_attr(&self, attr: &str, paths: &[&[u8]]) -> Result<Vec<AttrValue>, EngineError>;
168}
169
170// -------------------------------------------------------- scratch index
171
172/// One record to feed a scratch index.
173///
174/// Owned rather than borrowed: a text file's oid is produced by
175/// `ObjectWriter::write_blob` inside the staging loop and would not outlive a
176/// borrow. One allocation per changed file is free next to the subprocess it
177/// is about to be piped into.
178pub enum IndexEntry {
179    Set {
180        mode: String,
181        oid: String,
182        path: Vec<u8>,
183    },
184    Remove {
185        path: Vec<u8>,
186    },
187}
188
189/// Opening a scratch index. Never the user's index, never a checkout
190/// (ADR 0011).
191pub trait TreeBuilder {
192    /// Not object-safe, by design: an associated type makes
193    /// `Box<dyn TreeBuilder>` impossible, so runtime dispatch cannot creep
194    /// back in behind this seam.
195    type Session: IndexSession;
196
197    /// A scratch index seeded from `tree_ish`.
198    fn begin_from_tree(&self, tree_ish: &str) -> Result<Self::Session, EngineError>;
199
200    /// A scratch index seeded from the repository's CURRENT index, for the
201    /// ADR-0017 uncommitted-state snapshots.
202    ///
203    /// Errors if the index has unmerged entries — a conflicted index has no
204    /// single tree.
205    fn begin_from_current_index(&self) -> Result<Self::Session, EngineError>;
206}
207
208/// A scratch index, alive as long as the value. Dropping it removes the
209/// temporary index file; blobs it wrote stay in the odb, unreferenced.
210pub trait IndexSession {
211    /// Stage a batch in one feed: quoting-proof, and one subprocess instead
212    /// of one per file.
213    fn stage(&mut self, entries: &[IndexEntry]) -> Result<(), EngineError>;
214
215    /// Hash each path's CURRENT WORKTREE content into the odb and stage it,
216    /// admitting new files and dropping ones deleted from the worktree.
217    ///
218    /// The worktree-snapshot primitive; nothing else may call it.
219    fn stage_from_worktree(&mut self, nul_paths: &[u8]) -> Result<(), EngineError>;
220
221    /// The tree oid of the currently staged state.
222    fn write_tree(&self) -> Result<String, EngineError>;
223}
224
225/// Reading the working copy. Only the ADR-0017 snapshots use this.
226pub trait WorkingCopy {
227    /// NUL-terminated tracked paths.
228    fn tracked_paths(&self) -> Result<Vec<u8>, EngineError>;
229    /// NUL-terminated untracked-but-not-ignored paths.
230    fn untracked_paths(&self) -> Result<Vec<u8>, EngineError>;
231
232    /// Whether any tracked file differs from `HEAD`, staged or unstaged.
233    ///
234    /// Untracked files are a separate question — `untracked_paths` answers
235    /// that — because a snapshot admits them via `--add`, and the two are
236    /// detected by different plumbing.
237    ///
238    /// May answer `true` for a merely stat-dirty index, where a content
239    /// comparison would say otherwise. That is the safe direction: a spurious
240    /// `true` costs a no-op checkbox, a spurious `false` would hide an option
241    /// the reviewer needs.
242    fn has_tracked_changes(&self) -> Result<bool, EngineError>;
243}
244
245// ------------------------------------------------------ writes that publish
246
247/// Author/committer identity for a synthetic commit. Domain data: a renderer
248/// decides who its commits belong to.
249pub struct CommitIdentity<'a> {
250    pub name: &'a str,
251    pub email: &'a str,
252}
253
254/// `commit-tree`. Separate from `IndexSession` because it does not touch an
255/// index — it takes a tree oid already written.
256pub trait CommitWriter {
257    fn commit_tree(
258        &self,
259        tree: &str,
260        parent: &str,
261        message: &[u8],
262        identity: CommitIdentity<'_>,
263    ) -> Result<String, EngineError>;
264}
265
266/// `update-ref`. The only port in the engine that mutates repository state a
267/// user can see, with exactly one consumer: the shadow-branch renderer.
268pub trait RefWriter {
269    fn update_ref(&self, name: &str, target: &str) -> Result<(), EngineError>;
270}
271
272// -------------------------------------------------------------- browsing
273
274pub struct CommitSummary {
275    pub sha: String,
276    pub short: String,
277    pub subject: String,
278    pub author: String,
279}
280
281/// History browsing for the review-source picker.
282pub trait CommitHistory {
283    /// False on an unborn HEAD — there is nothing to diff against.
284    fn has_commits(&self) -> bool;
285
286    /// The most recent `max` commits reachable from `from`, newest first.
287    fn recent_commits(&self, from: &str, max: usize) -> Result<Vec<CommitSummary>, EngineError>;
288
289    /// Branch/tag/remote names by the COMMIT sha they point at, annotated tags
290    /// peeled.
291    ///
292    /// Decoration only, so an unreadable ref list costs decoration and never
293    /// the picker: the adapter returns an empty map rather than an error.
294    fn refs_by_commit(&self) -> HashMap<String, Vec<String>>;
295}
296
297/// Where this repository's differential state lives. Path *policy* is domain
298/// (`plan::grouping_cache_dir`, `plan::review_dir`); this only says where the
299/// repository keeps its shared git directory.
300pub trait RepoLayout {
301    /// The shared git directory, absolutised (worktree-safe).
302    fn common_dir(&self) -> Result<PathBuf, EngineError>;
303    fn work_root(&self) -> &Path;
304}
305
306// ----------------------------------------------------------- persistence
307
308/// The grouping cache (ADR 0009).
309///
310/// The stored value is the RAW model response, so audit and assembly stay pure
311/// functions replayed on load and their fixes apply to cached runs too.
312///
313/// Keys are opaque hex from `grouping::cache_key`. An implementation MUST
314/// treat them as opaque and MUST NOT derive, namespace or truncate them: the
315/// key composition pins every existing cache entry in every checkout.
316pub trait GroupingCache {
317    fn get(&self, key: &str) -> Result<Option<String>, EngineError>;
318    fn put(&self, key: &str, response: &str) -> Result<(), EngineError>;
319}
320
321/// Somewhere the model can read the pre-group document from (ADR 0022).
322///
323/// The grouping stage hands the model a path, not a payload, so the need is
324/// "make this readable and tell me where" — one call, because a path the
325/// caller composed itself would be a path the adapter never agreed to.
326///
327/// Keys are the grouping cache's keys. An implementation MUST treat them as
328/// opaque, exactly as `GroupingCache` must.
329pub trait ArtefactStore {
330    fn make_readable(&self, key: &str, json: &str) -> Result<PathBuf, EngineError>;
331}
332
333/// What a review was opened as. The id is a hash of this, so the id alone
334/// cannot answer what a review's endpoints mean today.
335#[derive(Debug, Clone, PartialEq, Eq)]
336pub enum ReviewIdentity {
337    /// A range: the resolved base sha, and the head endpoint as typed.
338    Range { base: String, head_spec: String },
339    /// A session the reader named. The name IS the identity, so neither
340    /// endpoint is in the key and rebasing either cannot strand it.
341    Named(String),
342}
343
344/// One review as the catalogue sees it.
345#[derive(Debug, Clone, PartialEq, Eq)]
346pub struct FiledReview {
347    pub id: String,
348    /// `None` for a review filed before identities were written, and for one
349    /// of uncommitted work. Both can be recognised, neither adopted.
350    pub opened_as: Option<ReviewIdentity>,
351}
352
353/// Every review filed in this repository, and the joins between them.
354///
355/// The need is "which reviews already exist, and does this spelling redirect
356/// to one of them" — asked once, when a spelling is first used. The reviews
357/// directory is itself the index, so there is no list file to go stale.
358pub trait ReviewCatalogue {
359    fn filed_reviews(&self) -> Result<Vec<FiledReview>, EngineError>;
360
361    /// The review `id`'s progress lives under the returned id instead.
362    fn alias_of(&self, id: &str) -> Result<Option<String>, EngineError>;
363
364    /// Record that `from` reads `to`'s progress. Permanent.
365    fn file_alias(&self, from: &str, to: &str) -> Result<(), EngineError>;
366
367    /// Record what `id` was opened as, which is what makes it adoptable.
368    fn file_identity(&self, id: &str, opened_as: &ReviewIdentity) -> Result<(), EngineError>;
369}
370
371/// One review's sidecar (ADR 0013).
372///
373/// Every read is total: a store that has never been written yields defaults,
374/// never an error. `ReviewSession` is write-through — every mutator saves
375/// before returning — so an implementation must be cheap enough for that, and
376/// crash-safe in the sense that matters here: a torn write loses at most the
377/// last action.
378pub trait ReviewStore {
379    /// Persist a plan document under its content hash and point `current` at
380    /// it. Idempotent: re-saving the same hash must not rewrite the body.
381    ///
382    /// Takes serialised JSON and the hash rather than a `PlanDocument`,
383    /// which keeps `schema` out of this module entirely — the frozen contract
384    /// stays frozen (ADR 0008, 0018).
385    fn save_plan(&self, hash: &str, json: &str) -> Result<(), EngineError>;
386
387    fn load_state(&self) -> Result<ReviewState, EngineError>;
388    fn save_state(&self, state: &ReviewState) -> Result<(), EngineError>;
389
390    fn load_findings(&self) -> Result<Vec<Finding>, EngineError>;
391    /// Rewrites the whole set (status changes, deletions, re-anchor results).
392    /// The set is small; simplicity beats cleverness.
393    fn save_findings(&self, findings: &[Finding]) -> Result<(), EngineError>;
394}
395
396/// Where configuration comes from.
397///
398/// The engine decides WHICH files to look for, what precedence they have and
399/// what their absence means; this port only says where the user's config
400/// directory is and hands back file contents.
401pub trait ConfigSource {
402    /// The user config directory. `None` when no home directory can be
403    /// determined — then the user file simply does not exist.
404    fn user_config_dir(&self) -> Option<PathBuf>;
405
406    /// Contents, or `None` when the file does not exist. Any other failure
407    /// (permissions, non-UTF-8) is an error.
408    fn read(&self, path: &Path) -> Result<Option<String>, EngineError>;
409
410    /// Contents of a file the caller named explicitly, where absence is a hard
411    /// error rather than a default.
412    ///
413    /// A separate method rather than the domain synthesising the message from
414    /// `read`'s `None`, so the error text comes from the same `std::fs` call
415    /// it always did and cannot drift.
416    fn read_required(&self, path: &Path) -> Result<String, EngineError>;
417}