Skip to main content

shipshape_core/protocol/
facts.rs

1//! Public wire DTO for the deterministic repo-fact report (`shipshape facts`).
2//!
3//! This is the versioned surface `/shipshape-init` (config generation) and the
4//! readiness `audit` both read, so they never disagree on maturity or the gated
5//! core (ADR-0001 §3). Its field names and shape are a faithful port of
6//! homebase's `infer-repo-facts.py` JSON — `ecosystems`, `packages`, `has_ci`,
7//! `tags`, `committers_total`/`committers_recent_year`, `inferred_maturity`, and
8//! the rest — because the prose `/shipshape-init` skill already relies on those exact
9//! names (SCHEMA.md §4 "maturity inference signals").
10//!
11//! Consumers read this document under the CLI's canonical `data` envelope:
12//! `{schema_version, data: <this shape>, warnings}` — the same envelope every
13//! `shipshape --json` command shares (`crate::SCHEMA_VERSION` versions that wire
14//! envelope). Unlike the contract document, the facts report has no
15//! source-level document version of its own (it is *derived*, never authored),
16//! so the envelope's `schema_version` is the single version consumers gate on.
17//!
18//! The report **reuses** [`Ecosystem`] and [`Maturity`] from the canonical
19//! contract model rather than restating their wire strings: facts and the
20//! contract must agree on `"rust"` / `"mvp"` down to the byte, and sharing the
21//! one enum is how that agreement is made structural instead of coincidental.
22
23use serde::Serialize;
24
25use crate::contract::schema::{Ecosystem, Maturity};
26
27/// Distribution infrastructure discovered in the repository tree.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
29pub struct DistributionSurface {
30    /// Whether cargo-dist configuration is present at the repository root.
31    pub has_cargo_dist: bool,
32    /// Root configuration files that establish cargo-dist use.
33    pub cargo_dist_evidence: Vec<String>,
34    /// GitHub workflow filenames whose `push` trigger includes tags.
35    pub tag_triggered_workflows: Vec<String>,
36    /// Tag-triggered workflows that directly run `cargo publish` or reach it
37    /// through a repository-local reusable workflow call.
38    ///
39    /// This additive evidence lets release planning warn about an ineffective
40    /// `cargo-publish-ci` delegation without treating a heuristic as a hard
41    /// refusal. Filenames are relative to `.github/workflows`.
42    pub tag_triggered_cargo_publish_workflows: Vec<String>,
43}
44
45/// What one Cargo manifest says about publishing to crates.io.
46///
47/// This is deliberately tri-state: collapsing [`Self::Unknown`] into either
48/// other value would turn an inconclusive read into a confident wrong statement.
49/// These serialized variant names are part of the additive `facts` wire contract;
50/// renaming one is a breaking change that requires a `schema_version` bump.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
52#[serde(rename_all = "snake_case")]
53pub enum CargoPublishPolicy {
54    /// The manifest permits crates.io publication: `publish` is absent or `true`,
55    /// or an allow-list names `crates-io`.
56    Allowed,
57    /// The manifest forbids it: `publish = false`, `publish = []`, or an
58    /// allow-list omits `crates-io`.
59    Forbidden,
60    /// The read is inconclusive, such as unresolved `publish.workspace = true`
61    /// inheritance or a publish value shape the textual reader does not model.
62    Unknown,
63}
64
65impl CargoPublishPolicy {
66    /// The stable spelling used in JSON and human-readable output.
67    #[must_use]
68    pub const fn as_str(self) -> &'static str {
69        match self {
70            Self::Allowed => "allowed",
71            Self::Forbidden => "forbidden",
72            Self::Unknown => "unknown",
73        }
74    }
75}
76
77/// The resolved crates.io publish evidence read from one Cargo manifest.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
79pub struct CargoPublishFlag {
80    /// Path relative to the repository root.
81    pub manifest: String,
82    /// The crate's `[package].name`, or `null` when it cannot be resolved.
83    pub package: Option<String>,
84    /// The resolved crates.io publish verdict.
85    pub policy: CargoPublishPolicy,
86}
87
88/// The complete `shipshape facts --json` data payload.
89///
90/// [`Self::cargo_publish`] is additive to the original flat facts shape. It is
91/// collected by the same detector function the contract normalizer calls for
92/// its Cargo publish hard floor, preventing the inspectable report and the
93/// enforcing decision from drifting apart.
94#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
95pub struct FactsReport {
96    /// The established flat repo-facts fields.
97    #[serde(flatten)]
98    pub facts: Facts,
99    /// Per-manifest Cargo publish evidence, with workspace inheritance resolved.
100    pub cargo_publish: Vec<CargoPublishFlag>,
101}
102
103/// The established base repo facts, flattened into [`FactsReport`] for
104/// `shipshape facts` and consumed directly by internal release and audit logic.
105///
106/// Every field is always present (an empty/unborn repo still gets a defined
107/// value for each), mirroring the Python detector's "exit 0 even for an empty
108/// repo" contract.
109// The report mirrors the Python detector's flat boolean signals; grouping them
110// into sub-structs purely to satisfy the bool-count lint would diverge the wire
111// shape from `infer-repo-facts.py` for no consumer benefit.
112#[allow(clippy::struct_excessive_bools)]
113#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
114pub struct Facts {
115    /// The canonicalized repository root the facts were gathered from.
116    pub repo_root: String,
117    /// Whether the root is inside a git work tree.
118    pub is_git: bool,
119    /// Whether the repository has at least one commit (`HEAD` resolves).
120    pub has_commits: bool,
121    /// Detected packaging ecosystems, in canonical order. `[binary]` when no
122    /// package manifest is found (a compiled-artifact-only repo).
123    pub ecosystems: Vec<Ecosystem>,
124    /// One entry per root-level package manifest that names a package (plus
125    /// `Cargo.toml`/`go.mod` always, even as a virtual workspace).
126    pub packages: Vec<Package>,
127    /// Distinct committers across all history (`git shortlog -sne --all`).
128    pub committers_total: usize,
129    /// Distinct committers within the last year — a `production` signal.
130    pub committers_recent_year: usize,
131    /// All tag names in the repository.
132    pub tags: Vec<String>,
133    /// Whether any tag parses as a `SemVer` version (a release signal).
134    pub has_semver_tag: bool,
135    /// Whether a `>=1.0` release exists — a `>=1.0` `SemVer` tag **or** a manifest
136    /// version `>=1.0`. Never probes a registry (that would need the network and
137    /// break reproducibility).
138    pub has_ge_1_0_release: bool,
139    /// Whether a CI configuration is present (`.github/workflows` holding at
140    /// least one entry, or a known single-file CI config).
141    pub has_ci: bool,
142    /// The detected dependency-update bot (`dependabot` / `renovate`), or `null`.
143    pub dependency_bot: Option<String>,
144    /// Whether an `issues/` directory is present.
145    pub has_issues_dir: bool,
146    /// `"spike"` when the README self-labels as pre-release/WIP, else `null`.
147    pub readme_self_label: Option<String>,
148    /// A short project description: the first manifest `description`, else the
149    /// first non-heading README line (both truncated to 120 characters).
150    pub description: Option<String>,
151    /// The raw truth-table signals behind [`Self::inferred_maturity`].
152    pub maturity_signals: MaturitySignals,
153    /// The inferred maturity (SCHEMA.md §4 truth table, tie → `mvp`).
154    pub inferred_maturity: Maturity,
155    /// Detected binary-distribution infrastructure and tag-triggered workflows.
156    pub distribution_surface: DistributionSurface,
157    /// The Rust workspace's publishable member graph — derived plumbing the
158    /// release planner needs, deliberately kept **off the JSON wire**
159    /// (`#[serde(skip)]`), `None` for a repo with no multi-crate Cargo workspace.
160    ///
161    /// It lets [`crate::release::plan`] derive the full, dependency-ordered publish
162    /// set for a multi-crate workspace: a downstream repo that declares only its bin
163    /// crate as a release target still gets its lib crate planned, lib-before-bin, so
164    /// `cargo publish` never hits an unindexed `=`-pinned sibling (the
165    /// `release-rust-workspace-multicrate` gap). Exposing it on the wire would perturb
166    /// the shared facts schema `/shipshape-init` and `audit` read; the plan content-addresses
167    /// the resolved *targets* it produces from this graph, so the graph itself need not
168    /// travel on the wire. Skipped fields still participate in [`PartialEq`], so it is a
169    /// faithful part of the in-process fact value.
170    #[serde(skip)]
171    pub rust_workspace: Option<RustWorkspace>,
172}
173
174/// A Rust Cargo workspace's crates.io-**publishable** members and the
175/// intra-workspace dependency edges among them — the graph the release planner
176/// derives a dependency-ordered publish set from (`release-rust-workspace-multicrate`).
177///
178/// Off-wire plumbing carried on [`Facts::rust_workspace`]; see that field for why it
179/// is not serialized. Members are listed in workspace declaration order (the planner
180/// applies the topological ordering — a dependency before its dependents); only
181/// members publishable to crates.io are included (a `publish = false` member, or one
182/// restricted to a non-crates.io registry, is dropped, matching the cargo adapter's
183/// cut-time `cargo metadata` filter).
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub struct RustWorkspace {
186    /// The publishable workspace members, in declaration order.
187    pub members: Vec<WorkspaceMember>,
188    /// Exact/local declarations owned by the root `[workspace.dependencies]` table,
189    /// keyed by resolved package name. Off-wire like the workspace graph itself.
190    pub workspace_pin_reqs: std::collections::BTreeMap<String, Vec<Option<String>>>,
191    /// Parser error encountered while gathering the sealed pin model. Planning must
192    /// refuse rather than equate this with an empty declaration set.
193    pub pin_parse_error: Option<String>,
194}
195
196/// One crates.io-publishable Cargo workspace member and its intra-workspace
197/// (publishable) dependency crate names — a node in [`RustWorkspace`].
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub struct WorkspaceMember {
200    /// The crate/package name (`[package].name`).
201    pub package: String,
202    /// The member's declared/inherited version, or `None` when unresolved.
203    pub version: Option<String>,
204    /// The names of this member's dependencies that are themselves publishable
205    /// members of the same workspace — the edges that order the publish (each of
206    /// these must be crates.io-index-visible before this member can publish). Only
207    /// normal + build dependencies gate order; dev-dependencies are excluded (they
208    /// never gate publish order and can legitimately cycle).
209    pub workspace_deps: Vec<String>,
210    /// The **literal version requirement string** this member's manifest declares
211    /// for each intra-workspace dependency that carries one, keyed by dependency
212    /// crate name (e.g. `{"octl-core": "=0.4.0"}` for `octl-core = { path = "…",
213    /// version = "=0.4.0" }`). Only local declarations whose resolved package is
214    /// another publishable member appear, and only when the manifest declares an
215    /// explicit `version` on the dependency —
216    /// a path-only or `workspace = true`-inherited edge (whose requirement is not
217    /// literally in this manifest) is **absent**, not defaulted.
218    ///
219    /// This is what makes the bump phase's pin rewrite **precise**
220    /// (`release-rust-workspace-multicrate` facet 3): the planner emits a pin rewrite
221    /// only for an edge whose requirement literally equals `=<from_version>` (the
222    /// lockstep convention), never for a caret/range/`workspace = true` edge it would
223    /// otherwise clobber. Off-wire, like the rest of [`RustWorkspace`].
224    pub dep_reqs: std::collections::BTreeMap<String, String>,
225    /// Every literal requirement declared for an intra-workspace dependency, across
226    /// normal, dev, build, and target-specific dependency tables. `None` represents a
227    /// local declaration with no literal version requirement. Unlike [`Self::dep_reqs`],
228    /// this preserves duplicate declarations so release planning can prove that every
229    /// pin it asks the bump executor to rewrite is equivalent.
230    pub pin_reqs: std::collections::BTreeMap<String, Vec<Option<String>>>,
231}
232
233/// One detected package manifest and the name/version parsed from it.
234// `package` is the field name `/shipshape-init` reads (SCHEMA.md §4); the
235// struct-name-echo lint does not apply to a fixed wire contract.
236#[allow(clippy::struct_field_names)]
237#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
238pub struct Package {
239    /// The ecosystem this manifest belongs to.
240    pub ecosystem: Ecosystem,
241    /// The manifest filename (e.g. `Cargo.toml`, `package.json`).
242    pub manifest: String,
243    /// The package/crate name, or `null` when the manifest declares none
244    /// (a virtual `Cargo.toml` workspace, or a `go.mod` without a `module`).
245    pub package: Option<String>,
246    /// The declared version, or `null` when absent.
247    pub version: Option<String>,
248}
249
250/// The two maturity truth-table outputs (SCHEMA.md §4). Both can be `false`
251/// (the tie case), which resolves to `mvp`; they are never both `true`
252/// (`production` is checked first).
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
254pub struct MaturitySignals {
255    /// `>=2` recent-year committers **and** CI **and** a release gate. The
256    /// release gate is either a `>=1.0` release **or** `ZeroVer` release evidence:
257    /// a dependency-update-bot config present **and** a release cadence of `>=2`
258    /// shipped (non-prerelease, `>=0.1.0`) `SemVer` tags. The second path lets a
259    /// deliberately-pre-1.0 (`ZeroVer`) project reach `production` without a
260    /// `>=1.0` version. The shipped-release count is not a first-class field but
261    /// is recomputable from [`Facts::tags`] with the same `SemVer` parse. These
262    /// are presence/name heuristics, not proofs — `/shipshape-init` presents them to a
263    /// human for confirmation before they land in the contract.
264    pub production: bool,
265    /// No CI **and** no `SemVer` tag **and** (single committer **or** a README
266    /// self-label).
267    pub spike: bool,
268}