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}