Skip to main content

shipshape_core/
ports.rs

1//! Injected effect ports — the seam that keeps every domain testable in
2//! isolation without touching the real filesystem, git, network, or clock
3//! (ADR-0001 §2).
4//!
5//! Domain code (`contract`, `facts`, `audit`, `release`) takes these traits by
6//! reference instead of calling `std::process`, `std::time`, or the network
7//! directly. Production supplies real implementations in `shipshape-cli`; tests
8//! supply deterministic fakes. At founding these are the **trait shapes** only;
9//! concrete implementations land with their consuming units.
10
11use std::io;
12
13/// Output of a subprocess run through a [`CommandRunner`].
14#[derive(Debug, Clone)]
15pub struct CommandOutput {
16    /// Process exit status code (`None` if terminated by a signal).
17    pub status: Option<i32>,
18    /// Captured standard output.
19    pub stdout: String,
20    /// Captured standard error.
21    pub stderr: String,
22}
23
24/// Runs external commands (git, package-manager, registry CLIs) on behalf of a
25/// domain, capturing their output. The single seam for shelling out — nothing
26/// in `shipshape-core` calls `std::process::Command` directly.
27pub trait CommandRunner {
28    /// Run `program` with `args` in `cwd`, capturing stdout/stderr.
29    fn run(&self, program: &str, args: &[&str], cwd: &std::path::Path)
30        -> io::Result<CommandOutput>;
31}
32
33/// Supplies the current time. Injected so time-dependent logic (journal
34/// timestamps, run ages) is deterministic under test.
35pub trait Clock {
36    /// Current time as whole seconds since the Unix epoch.
37    fn now_unix(&self) -> u64;
38
39    /// Block for `dur` before returning — the passage of real time, injected so
40    /// waits are deterministic and instant under test.
41    ///
42    /// The release engine's crates.io index-wait (the multi-crate workspace
43    /// publish path, [`crate::release::adapters::cargo`]) polls between attempts
44    /// through this method. The default performs a genuine
45    /// [`std::thread::sleep`], so the production [`Clock`] waits for real without
46    /// implementing anything extra; a deterministic test fake overrides it to
47    /// advance a virtual clock instead of sleeping, so a bounded-wait loop
48    /// terminates instantly and without a real delay.
49    fn sleep(&self, dur: std::time::Duration) {
50        std::thread::sleep(dur);
51    }
52}
53
54/// Generates opaque, unique, non-deterministic identifiers — run ids and the
55/// like. Injected so id-dependent output is deterministic under test.
56///
57/// Note: this is **not** the source of `plan_id`. A release plan id is
58/// *content-addressed* — derived deterministically from the sealed plan's
59/// canonical bytes (ADR-0002), not generated here. Do not route plan sealing
60/// through this port.
61pub trait IdGen {
62    /// Produce a fresh, unique identifier.
63    fn new_id(&self) -> String;
64}
65
66/// Read/write access to the filesystem — the `Fs` half of the `Fs/Git` seam
67/// (ADR-0001 §2). Domain code (contract loading, journal persistence, sealed
68/// plans) goes through this port rather than calling `std::fs` directly, so it
69/// is testable against an in-memory fake. At founding this is a deliberately
70/// small surface; it grows (atomic writes, dir listing, metadata) as the
71/// journal and plan-sealing units land.
72pub trait Fs {
73    /// Read a file's full contents as bytes.
74    fn read(&self, path: &std::path::Path) -> io::Result<Vec<u8>>;
75    /// Whether `path` exists.
76    fn exists(&self, path: &std::path::Path) -> bool;
77    /// Whether `path` exists and is a directory (used for the contract's
78    /// fragment-dir producer check, which is a *directory*, not a file).
79    fn is_dir(&self, path: &std::path::Path) -> bool;
80    /// Whether `path` exists and is a *regular file* — not a directory, FIFO,
81    /// socket, or device (mirrors `os.path.isfile`). The facts detector gates
82    /// every manifest/config read on this so a non-regular node named
83    /// `Cargo.toml` neither marks an ecosystem nor blocks [`Self::read`] (a
84    /// blocking `read` on a FIFO is a real hang the `exists && !is_dir`
85    /// approximation would not prevent).
86    fn is_file(&self, path: &std::path::Path) -> bool;
87    /// List the immediate entry *names* (not full paths) within `dir` — the
88    /// facts detector's CI probe needs to know whether `.github/workflows`
89    /// holds at least one entry, not merely that the directory exists.
90    /// `Ok(vec![])` for an empty directory; `Err` when `dir` is absent or
91    /// unreadable (the detector treats that as "no entries").
92    fn read_dir(&self, dir: &std::path::Path) -> io::Result<Vec<String>>;
93}
94
95/// Queries a package registry for already-published state — the "remote is
96/// ground truth" source the release reconciler consults (ADR-0003).
97pub trait RegistryQuery {
98    /// Perform a bounded read-only HTTP GET through the production registry
99    /// client's existing HTTP seam. This also serves destination observers such
100    /// as Homebrew formula verification without spawning `curl`.
101    fn http_get(&self, url: &str) -> io::Result<(u16, Vec<u8>)> {
102        let _ = url;
103        Err(io::Error::new(
104            io::ErrorKind::Unsupported,
105            "this registry query does not expose raw HTTP GET",
106        ))
107    }
108
109    /// Versions of `package` already published to `ecosystem`'s registry.
110    fn published_versions(&self, ecosystem: &str, package: &str) -> io::Result<Vec<String>>;
111
112    /// The registry-recorded content digest of `package@version` — for crates.io
113    /// the sparse-index `cksum`, the lowercase-hex SHA-256 of the published
114    /// `.crate` tarball.
115    ///
116    /// Used to **authenticate an idempotency skip**: a resumed publish that finds
117    /// the version already on the registry trusts that "already published" answer
118    /// only when this digest matches the digest of the artifact the cut would
119    /// upload, so the registry's crate is proven byte-identical to the intended
120    /// one before the publish is skipped (see
121    /// [`crate::release::adapters::cargo`]). Name + version existence alone is not
122    /// enough — a *different* artifact could occupy the version.
123    ///
124    /// **Fail-closed, like [`Self::published_versions`].** `Ok(<hex>)` is the
125    /// recorded digest; an outage, a version that is not on the index, or a
126    /// registry that exposes no digest for the version is an `Err` — never a
127    /// fabricated value that could mask a mismatch as a match. The default
128    /// implementation errors ([`io::ErrorKind::Unsupported`]): a backend wires this
129    /// only for the ecosystems whose skip path is digest-authenticated (crates.io
130    /// today), and the sole caller is the cargo adapter's resume skip.
131    fn published_checksum(
132        &self,
133        ecosystem: &str,
134        package: &str,
135        version: &str,
136    ) -> io::Result<String> {
137        let _ = (ecosystem, package, version);
138        Err(io::Error::new(
139            io::ErrorKind::Unsupported,
140            "this registry query does not expose published checksums",
141        ))
142    }
143}
144
145/// Read-only view of the git repository under audit/release. The detector and
146/// release engine read repo facts through this port rather than shelling out
147/// to `git` themselves.
148///
149/// The detector's queries are deliberately *best-effort*: an unborn or
150/// non-repository root makes several of these fail, and the detector treats
151/// every such failure as "absent" (no committers, no tags) rather than an
152/// error — mirroring the read-only, never-mutating `infer-repo-facts.py`.
153pub trait GitRepo {
154    /// The full commit hash of `HEAD`. `Err` on an unborn repository (no
155    /// commits yet) or a non-repository root — the detector reads this as
156    /// "the repo has no commits".
157    fn head_commit(&self) -> io::Result<String>;
158    /// Whether the root is inside a git work tree (`git rev-parse
159    /// --is-inside-work-tree` succeeds). `false` for a non-repository root or
160    /// on any git error.
161    fn is_work_tree(&self) -> bool;
162    /// Raw `git shortlog -sne --all HEAD` output — one line per distinct
163    /// committer. When `since` is set (a git date expression such as
164    /// `"1 year ago"`), the log is limited to commits at or after it. `Err` on
165    /// any git failure (an unborn/empty repo); the detector counts that as zero
166    /// committers.
167    fn shortlog(&self, since: Option<&str>) -> io::Result<String>;
168    /// The repository's tag names (`git tag --list`), trimmed and with empty
169    /// lines dropped. `Err` on any git failure; the detector reads that as "no
170    /// tags".
171    fn tags(&self) -> io::Result<Vec<String>>;
172    /// The **common** git directory (`git rev-parse --git-common-dir`), resolved
173    /// to an absolute path. The release journal roots its state under here so all
174    /// linked worktrees of one repo share a single release-state root, and
175    /// submodules / bare repos / `GIT_DIR` overrides resolve correctly — a
176    /// literal `.git/` concatenation is *not* a portable substitute (ADR-0003
177    /// §3, the panel's repeated correctness landmine). `Err` on any git failure.
178    fn git_common_dir(&self) -> io::Result<std::path::PathBuf>;
179}
180
181/// Durable, atomic, lockable storage for the release journal — the seam that
182/// keeps the event-sourced journal (ADR-0003) testable without touching the real
183/// filesystem, while pinning down the atomicity discipline its production impl
184/// **must** honor.
185///
186/// The append-then-apply contract (ADR-0003 §2, borrowed from `octl-core`) maps
187/// onto these operations:
188///
189/// 1. [`Self::append_line`] fsyncs the event so it is durable **before** the
190///    reducer applies it — a crash between append and apply replays as a clean
191///    no-op-or-apply, because the journal (read back by [`Self::read_lines`]) is
192///    the single source of truth.
193/// 2. [`Self::write_atomic`] persists the derived manifest via temp-file → flush
194///    → atomic rename → directory fsync, so a torn write can never leave a
195///    half-written manifest (it is disposable and rebuildable regardless).
196/// 3. [`Self::lock_exclusive`] enforces a single active cut per repo (a `flock`
197///    on the releases-dir `.lock`): a concurrent cut/resume fails fast rather
198///    than corrupting a run.
199///
200/// The port is deliberately path-driven (the journal computes paths via
201/// [`crate::release::journal::JournalPaths`]); the impl adds no policy, only the
202/// durability guarantees documented per method.
203pub trait JournalStore {
204    /// Take the single-active-cut exclusive lock at `lock_path` (creating parent
205    /// directories as needed). The returned guard holds the lock until dropped.
206    /// `Err` with [`io::ErrorKind::WouldBlock`] when another holder is active, so
207    /// the caller can fail fast and name the active run.
208    fn lock_exclusive(&self, lock_path: &std::path::Path) -> io::Result<Box<dyn JournalLock>>;
209    /// Append `line` (one serialized event, no embedded newline — the store adds
210    /// the single trailing `\n`) to the JSONL file at `path`, creating the file
211    /// and parent directories if absent, and **fsync** so it is durable before
212    /// returning. This is the append half of append-then-apply.
213    ///
214    /// The write must be **atomic at line granularity**: on return the line is
215    /// either fully present or not present, never truncated. The production impl
216    /// opens with `O_APPEND`, `write_all`s the line + newline, and fsyncs the file
217    /// **and** — when it created the file or a parent directory — the containing
218    /// directory, so a newly created `RunCreated` survives power loss (fsyncing
219    /// only the file leaves the new directory entry non-durable). A torn *final*
220    /// line from a hard kill mid-write is still possible in theory; recovering it
221    /// (truncate-to-last-good under the lock) is a documented follow-up, not part
222    /// of this port yet — [`crate::release::journal::read_events`] currently
223    /// rejects any malformed line.
224    fn append_line(&self, path: &std::path::Path, line: &str) -> io::Result<()>;
225    /// Read every line of the JSONL file at `path`. `Ok(vec![])` when the file is
226    /// absent (a not-yet-written journal is empty, not an error).
227    fn read_lines(&self, path: &std::path::Path) -> io::Result<Vec<String>>;
228    /// Read the full contents of the (atomically-written) file at `path`, or
229    /// `Ok(None)` when it is absent. Used for the torn-free fast-path read of the
230    /// `manifest.json` cache; unlike [`Self::read_lines`] it returns raw bytes.
231    fn read(&self, path: &std::path::Path) -> io::Result<Option<Vec<u8>>>;
232    /// Atomically replace the file at `path` with `bytes`: write a temp file in
233    /// the same directory, flush + fsync it, rename it over `path`, then fsync the
234    /// directory. Creates parent directories as needed.
235    fn write_atomic(&self, path: &std::path::Path, bytes: &[u8]) -> io::Result<()>;
236    /// The immediate entry *names* (not full paths) within `dir`, or `Ok(vec![])`
237    /// when `dir` is absent — used to enumerate run-id subdirectories for
238    /// `release list`.
239    fn list_dir(&self, dir: &std::path::Path) -> io::Result<Vec<String>>;
240}
241
242/// An opaque RAII guard for the single-active-cut lock taken by
243/// [`JournalStore::lock_exclusive`]. Dropping it releases the lock; there are no
244/// methods — its lifetime *is* the contract.
245pub trait JournalLock {}
246
247/// Creates and publishes the **one** shared release tag + GitHub Release for a
248/// cut — the external side of the coordinator's coordinator-only tag phase
249/// (ADR-0002 §2).
250///
251/// Tagging is deliberately **not** on the [`crate::release::adapters::ReleaseAdapter`]
252/// trait: no per-ecosystem adapter can create the shared tag, which is what makes
253/// "tag once, after every publish succeeds" a structural guarantee. The
254/// coordinator drives these three steps in order and journals each as its own
255/// resumable event (`tag_created_local` → `tag_pushed_remote` →
256/// `github_release_created`), so an interrupted tag phase resumes from the first
257/// incomplete step rather than re-tagging.
258///
259/// Every method is **idempotent-friendly**: the coordinator only calls a step
260/// whose journalled fact is not yet present, but a production impl should still
261/// treat "already exists" as success (a pushed tag that is already on the remote,
262/// a Release that already exists) rather than an error, so a resume after a crash
263/// *between* the external action and its journal write reconciles cleanly.
264pub trait Tagger {
265    /// Create the annotated tag `tag` (message `message`) pointing at the sealed
266    /// `commit` in the local repository.
267    ///
268    /// `commit` is the plan's sealed `HEAD` (not whatever `HEAD` happens to be at
269    /// tag time), so the tag authenticates the approved commit even if `HEAD`
270    /// moved during the cut. Idempotent-friendly: if the tag already exists **at
271    /// `commit`** this is success (a resumed cut re-reaching this step after a
272    /// crash between the tag and its journal write); a tag that exists pointing
273    /// **elsewhere** is a genuine conflict (`Err`), never silently overwritten.
274    fn create_tag(&self, tag: &str, commit: &str, message: &str) -> io::Result<()>;
275    /// Push the already-created tag `tag` to the remote. Idempotent-friendly: an
276    /// already-present identical remote tag is success; `Err` on a real push
277    /// failure (network, auth, a conflicting remote ref).
278    fn push_tag(&self, tag: &str) -> io::Result<()>;
279    /// Resolve the branch currently advertised as the remote's default. The
280    /// coordinator journals this selection before mutation so resume never selects
281    /// a second branch after a crash.
282    fn default_branch(&self) -> io::Result<String>;
283    /// Fast-forward the selected remote `branch` to `commit`. This is the final cut
284    /// step, after destination verification. An implementation must treat a branch
285    /// that already contains `commit` as success, and must never force-push or
286    /// overwrite a divergent branch.
287    fn advance_branch(&self, branch: &str, commit: &str) -> io::Result<()>;
288    /// Create the GitHub Release for `tag` (titled `title`), returning its URL
289    /// when the host reports one. Idempotent-friendly: an already-existing Release
290    /// is success (returning its URL); `Err` on a real creation failure.
291    fn create_github_release(&self, tag: &str, title: &str) -> io::Result<Option<String>>;
292}