Skip to main content

ossctl_core/contract/
schema.rs

1//! The ONE canonical serde model for `OSS-RELEASE.md` (ADR-0003 §1).
2//!
3//! These types are the single normalization model that `contract show`,
4//! `contract validate`, `audit`, the facts consumers, and release planning all
5//! use — no second parser anywhere. Their serialized form is the canonical
6//! JSON contract every `/oss-*` member reads (SCHEMA.md §4, preserved
7//! byte-for-shape by the migration rule). Public wire access goes through
8//! [`crate::protocol::contract`], which re-exports these types and owns the
9//! wire-version declaration so internals and wire can diverge under the
10//! migration rule (ADR-0001 §2).
11//!
12//! Hot file (ADR-0001): a change here ripples to every family member. Every
13//! enum's [`as_str`](Status::as_str) form is the wire string; a change to one is
14//! a `schema_version` bump, never silent.
15
16use serde::Serialize;
17
18/// The contract `schema_version` this build knows how to read.
19///
20/// A config declaring a higher version is refused rather than guessed
21/// (SCHEMA.md §2 floor 5) — skills upgrade independently of the repos they act
22/// on. Distinct from the wire-envelope [`crate::SCHEMA_VERSION`]; both are `1`
23/// today but version different things (the contract document vs. the JSON
24/// envelope). Mirrors the Python `KNOWN_SCHEMA_VERSION`.
25pub const KNOWN_SCHEMA_VERSION: u32 = 1;
26
27/// The changelog fragment directory materialized when the config omits it.
28pub const DEFAULT_FRAGMENT_DIR: &str = "changelog/fragments";
29
30/// Define a closed enum whose variants each map to a fixed wire string.
31///
32/// Generates the enum (with the standard derives), [`as_str`] (variant → wire),
33/// [`parse`] (wire → variant), the `VALID` slice of wire strings for error
34/// messages, and a `Serialize` impl that emits the wire string. `Deserialize`
35/// is intentionally not generated: the normalizer reads strings out of the
36/// parsed YAML and validates each with [`parse`] so it can collect *all* errors
37/// and substitute a default (mirroring the Python normalizer), rather than
38/// fail-fast on the first bad enum.
39macro_rules! wire_enum {
40    (
41        $(#[$emeta:meta])*
42        $name:ident { $($variant:ident => $wire:literal),+ $(,)? }
43    ) => {
44        $(#[$emeta])*
45        #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46        pub enum $name {
47            $(
48                #[doc = concat!("Wire value `", $wire, "`.")]
49                $variant,
50            )+
51        }
52
53        impl $name {
54            /// The wire string for this variant (matches SCHEMA.md §4).
55            #[must_use]
56            pub fn as_str(self) -> &'static str {
57                match self { $(Self::$variant => $wire),+ }
58            }
59
60            /// Parse a wire string into the variant, or `None` if unrecognized.
61            #[must_use]
62            pub fn parse(s: &str) -> Option<Self> {
63                match s { $($wire => Some(Self::$variant),)+ _ => None }
64            }
65
66            /// Every valid wire string, for "must be one of …" messages.
67            pub const VALID: &'static [&'static str] = &[$($wire),+];
68        }
69
70        impl serde::Serialize for $name {
71            fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
72                ser.serialize_str(self.as_str())
73            }
74        }
75    };
76}
77
78wire_enum! {
79    /// Machine-readable approval gate (SCHEMA.md §1). `/oss-init` writes `draft`
80    /// and stops; a human flips it to `approved`. Mutating members refuse a
81    /// draft (they pass `--require-approved`).
82    Status { Draft => "draft", Approved => "approved" }
83}
84
85wire_enum! {
86    /// Master maturity dial that gates every member's output (SCHEMA.md §1).
87    /// Required — inference is `/oss-init`'s job, not the normalizer's.
88    Maturity { Spike => "spike", Mvp => "mvp", Production => "production" }
89}
90
91wire_enum! {
92    /// A packaging ecosystem. `homebrew` is a distribution *target*, never an
93    /// ecosystem. Listed in the canonical order used for stable de-dup/expansion.
94    Ecosystem {
95        Rust => "rust", Node => "node", Python => "python", Go => "go", Binary => "binary"
96    }
97}
98
99wire_enum! {
100    /// Base versioning scheme (SCHEMA.md §1). A `calver:<pattern>` config splits
101    /// into `Calver` + a separate [`Contract::versioning_pattern`]; the wire form
102    /// never carries the `calver:` prefix.
103    VersioningBase { Semver => "semver", Calver => "calver", Zerover => "zerover" }
104}
105
106wire_enum! {
107    /// How the changelog is produced (SCHEMA.md §1).
108    ChangelogMode { Curated => "curated", Automated => "automated", Fragment => "fragment" }
109}
110
111wire_enum! {
112    /// The changelog's structured input (SCHEMA.md §1).
113    ChangelogSource {
114        IssuectlTrailers => "issuectl-trailers",
115        ConventionalCommits => "conventional-commits",
116        Manual => "manual"
117    }
118}
119
120wire_enum! {
121    /// Release trigger model (SCHEMA.md §1). `auto` installs an on-merge
122    /// workflow; it never publishes from a chat turn.
123    ReleaseModel { Gated => "gated", Auto => "auto" }
124}
125
126wire_enum! {
127    /// Repository release layout (SCHEMA.md §1). `monorepo` drives per-package
128    /// versions/tags and flips the node adapter default to `changesets`.
129    ReleaseLayout { Single => "single", Monorepo => "monorepo" }
130}
131
132wire_enum! {
133    /// Contributor sign-off requirement, read by `/oss-contributing`.
134    ContributionProvenance { Dco => "dco", Cla => "cla", None => "none" }
135}
136
137wire_enum! {
138    /// Build-provenance level (SCHEMA.md §1). `slsa-l3` is production-only (floor).
139    ProvenanceLevel { None => "none", Keyless => "keyless", SlsaL3 => "slsa-l3" }
140}
141
142wire_enum! {
143    /// Which dependency-update bot `/oss-ci` emits.
144    DependencyBot { Dependabot => "dependabot", Renovate => "renovate", None => "none" }
145}
146
147wire_enum! {
148    /// A README health badge (SCHEMA.md §1). Every badge needs its producer
149    /// enabled (floor 4).
150    HealthBadge {
151        Ci => "ci", Registry => "registry", License => "license",
152        Coverage => "coverage", Scorecard => "scorecard", Discord => "discord"
153    }
154}
155
156wire_enum! {
157    /// Optional documentation-site generator (SCHEMA.md §1); production-tier.
158    DocsSite {
159        None => "none", Mkdocs => "mkdocs", Vitepress => "vitepress",
160        Docusaurus => "docusaurus", Sphinx => "sphinx", Mintlify => "mintlify"
161    }
162}
163
164wire_enum! {
165    /// A publish destination for a [`Target`] (SCHEMA.md §1).
166    Registry {
167        CratesIo => "crates.io", Npm => "npm", Pypi => "pypi", TestPypi => "testpypi",
168        GhReleases => "gh-releases", ProxyGolangOrg => "proxy.golang.org",
169        Homebrew => "homebrew"
170    }
171}
172
173wire_enum! {
174    /// The release tool pinned for a [`Target`] so it is not re-inferred each cut.
175    Adapter {
176        CargoPublish => "cargo-publish", CargoDist => "cargo-dist",
177        ReleasePlease => "release-please", Changesets => "changesets",
178        GhActionPypiPublish => "gh-action-pypi-publish", Twine => "twine",
179        Goreleaser => "goreleaser", HomebrewTap => "homebrew-tap",
180        HomebrewCore => "homebrew-core", NpmPublish => "npm-publish", Manual => "manual"
181    }
182}
183
184impl Ecosystem {
185    /// The default registry for this ecosystem when `targets` is expanded
186    /// (SCHEMA.md §1 default-expansion table).
187    #[must_use]
188    pub fn default_registry(self) -> Registry {
189        match self {
190            Self::Rust => Registry::CratesIo,
191            Self::Node => Registry::Npm,
192            Self::Python => Registry::Pypi,
193            Self::Go => Registry::ProxyGolangOrg,
194            Self::Binary => Registry::GhReleases,
195        }
196    }
197
198    /// The default adapter for this ecosystem/layout when `targets` is expanded
199    /// (SCHEMA.md §1). Node's default is layout-sensitive: `single` →
200    /// `release-please`, `monorepo` → `changesets`.
201    #[must_use]
202    pub fn default_adapter(self, layout: ReleaseLayout) -> Adapter {
203        match self {
204            Self::Rust => Adapter::CargoPublish,
205            Self::Node => match layout {
206                ReleaseLayout::Monorepo => Adapter::Changesets,
207                ReleaseLayout::Single => Adapter::ReleasePlease,
208            },
209            Self::Python => Adapter::GhActionPypiPublish,
210            Self::Go => Adapter::Goreleaser,
211            Self::Binary => Adapter::Manual,
212        }
213    }
214}
215
216/// One concrete `ecosystem → package → registry` publish destination.
217///
218/// Always concrete in the canonical output: expanded from `ecosystems` when the
219/// source omitted `targets`. `package` may be `null` (the executor infers it
220/// from the manifest); every other field is present.
221#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
222pub struct Target {
223    /// The ecosystem this target publishes for.
224    pub ecosystem: Ecosystem,
225    /// The package/crate name, or `null` when inferred from the manifest.
226    pub package: Option<String>,
227    /// The publish destination.
228    pub registry: Registry,
229    /// The release tool pinned for this target.
230    pub adapter: Adapter,
231}
232
233/// The changelog block of the contract (SCHEMA.md §1). `fragment_dir` is always
234/// present, even for non-`fragment` modes.
235#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
236pub struct Changelog {
237    /// How the changelog is produced.
238    pub mode: ChangelogMode,
239    /// The changelog's structured input.
240    pub source: ChangelogSource,
241    /// Where changelog fragments live (relative path inside the repo).
242    pub fragment_dir: String,
243}
244
245/// The release block of the contract (SCHEMA.md §1).
246#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
247pub struct Release {
248    /// Release trigger model.
249    pub model: ReleaseModel,
250    /// Repository release layout.
251    pub layout: ReleaseLayout,
252}
253
254/// The canonical, fully-defaulted, `targets`-expanded `OSS-RELEASE.md` contract.
255///
256/// This is the exact shape of SCHEMA.md §4 (the stable machine contract). Every
257/// field is present and defaulted; `versioning` is the base enum with the
258/// calver pattern split into [`Self::versioning_pattern`]; `extra_fields` holds
259/// preserved unknown frontmatter keys (forward-compat); `warnings` holds the
260/// non-fatal notes. Field order matches SCHEMA.md §4 for readable output; JSON
261/// consumers key-access, so order is not part of the contract.
262#[derive(Debug, Clone, PartialEq, Serialize)]
263pub struct Contract {
264    /// Contract schema version (bounded by [`KNOWN_SCHEMA_VERSION`]).
265    pub schema_version: u32,
266    /// Approval gate.
267    pub status: Status,
268    /// Maturity dial.
269    pub maturity: Maturity,
270    /// Packaging ecosystems, de-duplicated to canonical order.
271    pub ecosystems: Vec<Ecosystem>,
272    /// Concrete publish targets (expanded from `ecosystems` when omitted).
273    pub targets: Vec<Target>,
274    /// Base versioning scheme.
275    pub versioning: VersioningBase,
276    /// The calver pattern string, or `null` for non-calver schemes.
277    pub versioning_pattern: Option<String>,
278    /// Changelog configuration.
279    pub changelog: Changelog,
280    /// Whether `/oss-release-cut` may derive the bump from commit types.
281    pub conventional_commits: bool,
282    /// Release model + layout.
283    pub release: Release,
284    /// Contributor sign-off requirement.
285    pub contribution_provenance: ContributionProvenance,
286    /// Build-provenance level.
287    pub provenance_level: ProvenanceLevel,
288    /// Dependency-update bot.
289    pub dependency_bot: DependencyBot,
290    /// README health badges.
291    pub health_badges: Vec<HealthBadge>,
292    /// SPDX license id/expression.
293    pub license: String,
294    /// Optional documentation-site generator.
295    pub docs_site: DocsSite,
296    /// Preserved unknown frontmatter keys under a known `schema_version`
297    /// (forward-compat); never dropped.
298    pub extra_fields: serde_json::Map<String, serde_json::Value>,
299    /// Non-fatal notes (aspirational draft producers, the unknown-field report).
300    pub warnings: Vec<String>,
301}