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