Skip to main content

gen_types/
adapter.rs

1//! Canonical Adapter trait — the cross-ecosystem SDLC interface.
2//!
3//! Every ecosystem adapter (`gen-cargo`, `gen-npm`, `gen-bundler`,
4//! `gen-pip`, `gen-gomod`, `gen-helm`, ...) implements one trait;
5//! substrate's build wrappers and operator-facing CLIs both code to
6//! this trait. Adding a new ecosystem gives every downstream consumer
7//! + every renderer the same verb surface for free.
8//!
9//! ## Verbs
10//!
11//! - `lock`    — author's manifest → freshly resolved lockfile
12//! - `build`   — lockfile + manifest → typed build-spec (hermetic;
13//!               substrate calls this via IFD inside the nix sandbox)
14//! - `plan`    — diff "what would change if I bump <input>"
15//! - `confirm` — verify spec invariants (hashes valid, no orphans,
16//!               features unify, source/lockfile match)
17//! - `diff`    — human-readable diff between two states
18//! - `sbom`    — emit ecosystem-flavored SBOM (CycloneDX / SPDX)
19//!
20//! ## Hermetic contract
21//!
22//! The `build` verb MUST run without network access — it's called
23//! from inside a nix sandbox via IFD by substrate's `mkBuildSpec`.
24//! Implementations that read from `Cargo.lock` / `package-lock.json`
25//! / `Gemfile.lock` directly (without invoking the ecosystem's
26//! native resolver) satisfy this. Implementations that need to
27//! resolve transitively can do so during `lock`, never `build`.
28
29use std::path::PathBuf;
30
31use serde::{Deserialize, Serialize};
32
33/// Result alias for adapter operations.
34pub type AdapterResult<T> = Result<T, AdapterError>;
35
36/// The canonical SDLC trait. Each ecosystem adapter implements one.
37pub trait Adapter: Send + Sync {
38    /// Short ecosystem identifier (e.g. `"cargo"`, `"npm"`, `"bundler"`).
39    /// Used for routing operator commands + matching manifests.
40    fn name(&self) -> &'static str;
41
42    /// Manifest filenames this adapter recognizes — the first one
43    /// found in the workspace dispatches to this adapter. Examples:
44    /// - cargo: `["Cargo.toml"]`
45    /// - npm:   `["package.json"]`
46    /// - bundler: `["Gemfile"]`
47    fn manifest_files(&self) -> &'static [&'static str];
48
49    /// `gen lock <path>` — resolve the manifest to a fresh lockfile.
50    /// May read the network (this is the resolver invocation).
51    /// Idempotent: re-running with no manifest changes is a no-op.
52    fn lock(&self, ctx: &AdapterCtx) -> AdapterResult<LockOutcome>;
53
54    /// `gen build <path>` — manifest + lockfile → typed build-spec.
55    ///
56    /// MUST be hermetic. Reads from disk only. Substrate's
57    /// `mkBuildSpec` wrapper invokes this from inside the nix
58    /// sandbox; any network call breaks IFD.
59    fn build(&self, ctx: &AdapterCtx) -> AdapterResult<BuildSpec>;
60
61    /// `gen plan <path>` — given an intent (bump X, enable feature Y),
62    /// produce the diff of what would change. Read-only. Used by
63    /// pre-bump confirmation flows.
64    fn plan(&self, ctx: &AdapterCtx, intent: &PlanIntent) -> AdapterResult<Plan>;
65
66    /// `gen confirm <path>` — verify spec invariants:
67    /// - every lockfile entry has a manifest declaration
68    /// - every transitive dep has a hash
69    /// - features resolve consistently across the workspace
70    /// - source URLs are valid (no `?branch=` query leaks, etc.)
71    /// Returns a typed report rather than panicking.
72    fn confirm(&self, ctx: &AdapterCtx) -> AdapterResult<ConfirmReport>;
73
74    /// `gen diff <path> <ref>` — diff current state vs a reference
75    /// (file path, git rev, or "previous").
76    fn diff(&self, ctx: &AdapterCtx, against: &DiffRef) -> AdapterResult<DiffReport>;
77
78    /// `gen sbom <path>` — software bill of materials in the given
79    /// format. Pure function of the build-spec.
80    fn sbom(&self, ctx: &AdapterCtx, format: SbomFormat) -> AdapterResult<Sbom>;
81
82    /// `gen quirks list` — typed registry of upstream third-party
83    /// package quirks the adapter knows about. Each adapter's quirks
84    /// shape is its own (CrateQuirk for Cargo, future NpmQuirk for
85    /// npm, GemQuirk for Bundler) — the trait surface stays uniform
86    /// via the opaque `serde_json::Value` envelope.
87    ///
88    /// Default `quirks_registry` returns empty — adapters with a
89    /// registry override to expose it. Substrate consumers can
90    /// introspect every ecosystem's quirks through a single call.
91    ///
92    /// The CANONICAL surface for adding a new quirk is the adapter's
93    /// own typed registry; this trait method is the operator-facing
94    /// + tooling-discovery surface.
95    fn quirks_registry(&self) -> Vec<AdapterQuirkEntry> {
96        Vec::new()
97    }
98
99    /// Reflection over the adapter's typed quirk enum — kebab-case
100    /// serde tags + per-variant field names. Surfaces what
101    /// `#[derive(TypedDispatcher)]` knows mechanically without
102    /// exposing the concrete enum type through the trait.
103    ///
104    /// Used by `gen dispatchers list` (operator visibility) and by
105    /// substrate-side coverage tests that assert every kind in this
106    /// reflection has a matching helpers arm in the matching
107    /// `substrate/lib/build/<ecosystem>/quirk-apply.nix`. Default
108    /// implementation returns an empty Vec — adapters whose Quirk
109    /// enum hasn't yet adopted `#[derive(TypedDispatcher)]` simply
110    /// disappear from the catalog (no false coverage claims).
111    fn dispatcher_reflection(&self) -> Vec<DispatcherVariant> {
112        Vec::new()
113    }
114}
115
116/// One variant of a typed Adapter quirk enum, surfaced via
117/// `Adapter::dispatcher_reflection`. Mirrors `TypedDispatcher`'s
118/// per-variant reflection without forcing the trait to carry a
119/// generic Quirk associated type.
120#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
121pub struct DispatcherVariant {
122    /// Kebab-case serde tag the runtime emits.
123    pub kind: String,
124    /// Field names declared on the variant (named fields only).
125    /// Unit variants serialize as an empty Vec.
126    pub fields: Vec<String>,
127}
128
129/// One entry in an Adapter's quirks registry: a package name plus
130/// the ecosystem-specific typed-quirk payloads. The `quirks` field is
131/// `serde_json::Value` so each adapter can carry its own typed shape
132/// without polluting the trait with a generic parameter — the JSON
133/// envelope is what substrate's dispatch layer reads anyway.
134#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
135pub struct AdapterQuirkEntry {
136    /// Package name (`crate.name` for Cargo, `package.json` `name`
137    /// for npm, gemspec `name` for Bundler).
138    pub package: String,
139    /// Quirk payloads, opaque to the trait. Each adapter shapes
140    /// these as its own typed enum (gen-cargo: `CrateQuirk`); the
141    /// JSON form is what substrate dispatches on.
142    pub quirks: Vec<serde_json::Value>,
143}
144
145/// Context every adapter verb receives. Captures workspace root +
146/// optional target filter. Extending this struct is non-breaking
147/// because verbs take it by reference.
148#[derive(Debug, Clone)]
149pub struct AdapterCtx {
150    /// Workspace root directory. The manifest_files entry lives at
151    /// `${workspace_root}/${manifest_files()[i]}`.
152    pub workspace_root: PathBuf,
153
154    /// Optional target predicate (e.g. `aarch64-apple-darwin`). When
155    /// `Some`, the adapter restricts the resolve graph to deps
156    /// active for that target.
157    pub target: Option<String>,
158}
159
160/// Outcome of a `lock` invocation.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct LockOutcome {
163    /// Absolute path to the written lockfile.
164    pub lockfile_path: PathBuf,
165    /// True if the lockfile was created from scratch (no prior
166    /// lockfile existed). False = update of existing lockfile.
167    pub created: bool,
168    /// Per-dependency bumps applied. Empty when manifest didn't
169    /// change. Useful for changelog generation.
170    pub bumped: Vec<DependencyBump>,
171}
172
173/// One dep that changed version during a lock invocation.
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct DependencyBump {
176    pub name: String,
177    pub from: Option<String>,
178    pub to: String,
179}
180
181/// Output of a `build` invocation. The JSON shape is
182/// ecosystem-specific — Cargo.build-spec.json / package-lock.spec.json
183/// / etc. — but every adapter wraps it in this typed envelope so
184/// substrate can read uniformly.
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct BuildSpec {
187    /// Ecosystem name (mirrors `Adapter::name()`).
188    pub ecosystem: String,
189    /// Spec schema version. Bumped on breaking changes to the JSON
190    /// shape; consumers can refuse-or-warn.
191    pub schema_version: u32,
192    /// Ecosystem-shaped JSON payload.
193    pub data: serde_json::Value,
194}
195
196/// Operator-supplied intent for `gen plan`.
197#[derive(Debug, Clone, Default, Serialize, Deserialize)]
198pub struct PlanIntent {
199    /// "What if I bump this dep to latest" (one entry per dep).
200    pub bumps: Vec<String>,
201    /// "What if I enable these features" (root crate / workspace).
202    pub enable_features: Vec<String>,
203    /// "What if I disable these features"
204    pub disable_features: Vec<String>,
205}
206
207/// Result of `plan`. The diff describes the resulting state minus
208/// the current state; warnings flag anything risky (yanked, MSRV
209/// bump, semver-major, license change, ...).
210#[derive(Debug, Clone, Serialize, Deserialize)]
211pub struct Plan {
212    pub diff: DiffReport,
213    pub warnings: Vec<PlanWarning>,
214}
215
216/// One warning emitted during planning.
217#[derive(Debug, Clone, Serialize, Deserialize)]
218pub struct PlanWarning {
219    pub severity: PlanWarningSeverity,
220    pub message: String,
221    pub dep: Option<String>,
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
225#[serde(rename_all = "snake_case")]
226pub enum PlanWarningSeverity {
227    Info,
228    Warn,
229    Block,
230}
231
232/// Result of `confirm`. Each invariant is named so failures point
233/// at the exact rule that broke; operators get actionable output
234/// instead of a stack trace.
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct ConfirmReport {
237    pub invariants_held: Vec<String>,
238    pub invariants_broken: Vec<InvariantBreak>,
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
242pub struct InvariantBreak {
243    /// Stable name of the invariant (e.g. `"hash-present"`).
244    pub name: String,
245    /// Human-readable explanation.
246    pub message: String,
247    /// Optional pointer to the offending dep / file.
248    pub locus: Option<String>,
249}
250
251/// Reference for `diff` — what to compare current state against.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253#[serde(rename_all = "snake_case", tag = "kind")]
254pub enum DiffRef {
255    /// Previous committed state (git HEAD or HEAD~1).
256    Previous,
257    /// Specific git revision.
258    GitRev { rev: String },
259    /// Spec file on disk (operator supplied).
260    Path { path: PathBuf },
261}
262
263/// Diff output for `plan` / `diff`. Three buckets: added,
264/// removed, version-changed. Consumers (PR comment renderer, CI
265/// gate, ...) pick what to surface.
266#[derive(Debug, Clone, Serialize, Deserialize)]
267pub struct DiffReport {
268    pub added: Vec<DepEdge>,
269    pub removed: Vec<DepEdge>,
270    pub changed: Vec<DepChange>,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
274pub struct DepEdge {
275    pub name: String,
276    pub version: String,
277    pub kind: String,
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct DepChange {
282    pub name: String,
283    pub from_version: String,
284    pub to_version: String,
285    pub kind: String,
286}
287
288/// SBOM output format.
289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
290#[serde(rename_all = "snake_case")]
291pub enum SbomFormat {
292    CycloneDx,
293    Spdx,
294}
295
296/// Generated SBOM. Format-flavored JSON; consumers route by `format`.
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct Sbom {
299    pub format: SbomFormat,
300    pub data: serde_json::Value,
301}
302
303/// Adapter-level errors. Variants stay narrow so callers can
304/// pattern-match on recoverable cases.
305#[derive(Debug, thiserror::Error)]
306pub enum AdapterError {
307    #[error("manifest not found at {0}")]
308    ManifestNotFound(PathBuf),
309
310    #[error("lockfile not found at {0} — run `gen lock` first")]
311    LockfileNotFound(PathBuf),
312
313    #[error("hermetic constraint violated: {0}")]
314    NonHermetic(String),
315
316    #[error("invariant broken: {name}: {message}")]
317    InvariantBroken { name: String, message: String },
318
319    #[error("unsupported operation: {0}")]
320    Unsupported(String),
321
322    #[error("io: {0}")]
323    Io(#[from] std::io::Error),
324
325    #[error("internal: {0}")]
326    Internal(String),
327}
328
329/// Inventory entry registered by every adapter crate. gen-cli's
330/// `quirks` / `adapters` / future cross-ecosystem verbs iterate
331/// this distributed slice to discover every adapter at link time —
332/// no hard-coded match arms, no per-ecosystem edits to gen-cli when
333/// a new adapter lands.
334///
335/// Adapters register via `inventory::submit!`. See
336/// `gen-cargo::adapter` for the canonical example.
337pub struct AdapterRegistration {
338    /// Constructs a fresh instance of the adapter. `&dyn Adapter`
339    /// can be obtained by `(&*reg.make())`.
340    pub make: fn() -> Box<dyn Adapter>,
341    /// Adapter name (mirror of `Adapter::name`). Allows filtering
342    /// without instantiating the adapter — used by
343    /// `gen quirks --adapter <name>`.
344    pub name: &'static str,
345}
346
347inventory::collect!(AdapterRegistration);
348
349/// Iterate every adapter the binary was linked against. Returns
350/// freshly-constructed instances so callers can hold them
351/// independently. Per-call O(N) — N = number of linked adapters,
352/// typically small (3-20).
353#[must_use]
354pub fn registered_adapters() -> Vec<Box<dyn Adapter>> {
355    inventory::iter::<AdapterRegistration>()
356        .map(|r| (r.make)())
357        .collect()
358}
359
360/// Get one adapter by name, freshly constructed. Returns None if
361/// no adapter registered under that name.
362#[must_use]
363pub fn adapter_by_name(name: &str) -> Option<Box<dyn Adapter>> {
364    inventory::iter::<AdapterRegistration>()
365        .find(|r| r.name == name)
366        .map(|r| (r.make)())
367}
368
369/// Every registered adapter's name. Used for `gen adapters` listing
370/// + introspection without constructing the adapters.
371#[must_use]
372pub fn registered_adapter_names() -> Vec<&'static str> {
373    inventory::iter::<AdapterRegistration>()
374        .map(|r| r.name)
375        .collect()
376}