Skip to main content

ossctl_core/dist/
mod.rs

1//! The deterministic cargo-dist config generator (issue
2//! `release-engine-dist-config-generator`).
3//!
4//! Renders a downstream project's `dist-workspace.toml` from the contract's
5//! [`Distribution`] block — the deterministic half of `ossctl dist generate`.
6//! The `ossctl-cli` handler writes the rendered text to the repo root and then
7//! invokes `dist generate` (the cargo-dist tool) to produce the tag-triggered
8//! `.github/workflows/release.yml` from it; this module never shells out and
9//! never touches the filesystem, so it is a pure, fully unit-testable function.
10//!
11//! ## What maps where (the contract → cargo-dist mapping)
12//!
13//! The mapping is the binding cross-platform default documented in the
14//! `/oss-release` skill and `AGENTS.md` ("Cross-platform is a hard requirement —
15//! macOS AND Linux"):
16//!
17//! - **`distribution.platforms` → `[dist] targets`.** Copied verbatim (Rust
18//!   target-triple syntax). The normalizer guarantees the set is non-empty and
19//!   defaults an omitted `platforms` to the cross-platform macOS + Linux-musl
20//!   set ([`DEFAULT_CROSS_PLATFORM_TARGETS`](crate::contract::schema::DEFAULT_CROSS_PLATFORM_TARGETS)),
21//!   so a repo that never thinks about platforms still ships Linux binaries.
22//!   This generator NEVER narrows the set — a macOS-only matrix is a release gap.
23//! - **`distribution.installers` → `[dist] installers`.** Mapped through, with
24//!   two deliberate rules that mirror ossctl's own `dist-workspace.toml`:
25//!   1. `shell` is always ensured, so the generated curl-installer covers the
26//!      Unix side (macOS AND Linux) even when the contract omitted it; and
27//!   2. `homebrew` is EXCLUDED from cargo-dist's installer set — ossctl publishes
28//!      the Homebrew formula through its own tap adapter (post-tag, needing the
29//!      tarball sha256 that only exists after the release), exactly as the
30//!      reference config does ("Homebrew auto-publish is deliberately NOT enabled
31//!      here"). The tap itself is threaded elsewhere (`distribution.homebrew_tap`
32//!      in [`crate::release::plan`]).
33//!
34//! The rest of the `[dist]` table is the fixed reference shape: a pinned
35//! [`PINNED_CARGO_DIST_VERSION`], `ci = "github"`, `hosting = "github"`,
36//! `github-attestations = true`, and `pr-run-mode = "skip"` (tag-triggered only).
37//! The personal `[dist.github-custom-runners]` override in ossctl's own config is
38//! repo-local infra and is deliberately NOT emitted for downstream projects.
39//!
40//! Determinism: the output is a pure function of the [`Distribution`] — no clock,
41//! no environment, no map iteration — so the same block always renders the same
42//! bytes (proven in tests).
43
44use std::fmt::Write as _;
45
46use crate::contract::schema::{Distribution, Installer};
47
48/// The cargo-dist version pinned into every generated `dist-workspace.toml`, so a
49/// regenerated workflow and a locally-installed `dist` stay in lockstep. Kept in
50/// step with ossctl's own reference `dist-workspace.toml` at the repo root.
51pub const PINNED_CARGO_DIST_VERSION: &str = "0.28.2";
52
53/// The result of rendering a `dist-workspace.toml` from a [`Distribution`].
54///
55/// Carries the rendered [`toml`](Self::toml) plus the resolved decisions
56/// (`targets` / `installers` after the shell-ensure + homebrew-exclude rules) and
57/// any non-fatal [`warnings`](Self::warnings), so the CLI handler can report what
58/// it decided without re-deriving it.
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct GeneratedDistConfig {
61    /// The full `dist-workspace.toml` text, ready to write to the repo root.
62    pub toml: String,
63    /// The `[dist] targets` set (verbatim from `distribution.platforms`).
64    pub targets: Vec<String>,
65    /// The `[dist] installers` set actually emitted (shell ensured, homebrew
66    /// excluded).
67    pub installers: Vec<String>,
68    /// The pinned cargo-dist version emitted ([`PINNED_CARGO_DIST_VERSION`]).
69    pub cargo_dist_version: &'static str,
70    /// Non-fatal notes about decisions the generator made (added `shell`,
71    /// excluded `homebrew`, a Linux-less target set).
72    pub warnings: Vec<String>,
73}
74
75/// Render a `dist-workspace.toml` from a normalized [`Distribution`] block.
76///
77/// Assumes the block has already been through the normalizer (so `platforms` is
78/// non-empty and every triple is well-formed, and `installers` is canonically
79/// ordered and de-duplicated). Reads only `platforms` and `installers`; the
80/// `adapter` gate (cargo-dist vs goreleaser/manual) is the caller's, since this
81/// renderer only knows how to emit cargo-dist config.
82#[must_use]
83pub fn generate(dist: &Distribution) -> GeneratedDistConfig {
84    let mut warnings = Vec::new();
85
86    // targets: verbatim from the contract's platform set. Never narrowed.
87    let targets: Vec<String> = dist.platforms.clone();
88    if !targets.iter().any(|t| t.contains("linux")) {
89        warnings.push(
90            "distribution.platforms lists no Linux target — the cross-platform install \
91             requirement (macOS AND Linux) is not met; add an '…-unknown-linux-musl' triple"
92                .to_string(),
93        );
94    }
95
96    // installers: map through, excluding homebrew (owned by the tap adapter,
97    // post-tag) and ensuring shell (the Unix curl-installer covering Mac+Linux).
98    // The match is EXHAUSTIVE (no `_ =>` catch-all) on purpose: adding an
99    // `Installer` variant must force a conscious decision here about whether
100    // cargo-dist understands it, not silently pass an unknown name through.
101    let mut installers: Vec<String> = Vec::new();
102    let mut excluded_homebrew = false;
103    for installer in &dist.installers {
104        let name = match installer {
105            Installer::Homebrew => {
106                excluded_homebrew = true;
107                continue;
108            }
109            Installer::Shell => Installer::Shell.as_str(),
110            Installer::Powershell => Installer::Powershell.as_str(),
111            Installer::Msi => Installer::Msi.as_str(),
112            Installer::Npm => Installer::Npm.as_str(),
113        };
114        // The normalizer already de-duplicates `installers`; this guard is only
115        // belt-and-suspenders so a hand-built `Distribution` cannot emit a
116        // duplicate installer line.
117        if !installers.iter().any(|s| s == name) {
118            installers.push(name.to_string());
119        }
120    }
121    if excluded_homebrew {
122        warnings.push(
123            "the 'homebrew' installer is published by ossctl's Homebrew tap adapter (post-tag, \
124             once the release tarball sha256 exists), not cargo-dist — it is excluded from [dist] \
125             installers, mirroring ossctl's own dist-workspace.toml"
126                .to_string(),
127        );
128    }
129    let shell = Installer::Shell.as_str().to_string();
130    if !installers.contains(&shell) {
131        // Prepend so the ensured shell keeps the canonical shell-first order.
132        installers.insert(0, shell);
133        warnings.push(
134            "added the 'shell' installer so the generated curl-installer covers macOS and Linux \
135             (the Unix cross-platform install path)"
136                .to_string(),
137        );
138    }
139
140    let toml = render_toml(&targets, &installers);
141    GeneratedDistConfig {
142        toml,
143        targets,
144        installers,
145        cargo_dist_version: PINNED_CARGO_DIST_VERSION,
146        warnings,
147    }
148}
149
150/// Render the `dist-workspace.toml` text for a resolved `targets` + `installers`
151/// set. Values are simple, closed-vocabulary tokens (target triples and installer
152/// names — `[a-z0-9-]`), so in practice they need no escaping; every interpolated
153/// string nonetheless goes through [`toml_basic_string`] so a future variant or a
154/// hand-built `Distribution` can never emit syntactically-broken TOML.
155fn render_toml(targets: &[String], installers: &[String]) -> String {
156    let mut out = String::new();
157    // Header: mark the file generated and name the round-trip so a human does not
158    // hand-edit the workflow it feeds.
159    out.push_str(
160        "# Generated by `ossctl dist generate` from OSS-RELEASE.md `distribution`.\n\
161         # Edit distribution.* in OSS-RELEASE.md, then re-run `ossctl dist generate`.\n\
162         # The tag-triggered `.github/workflows/release.yml` is produced from the\n\
163         # [dist] section below via `dist generate` — never hand-edit the workflow.\n\
164         [workspace]\n\
165         members = [\"cargo:.\"]\n\
166         \n\
167         [dist]\n",
168    );
169    let _ = writeln!(out, "cargo-dist-version = \"{PINNED_CARGO_DIST_VERSION}\"");
170    out.push_str("ci = \"github\"\n");
171    let _ = writeln!(out, "installers = {}", inline_string_array(installers));
172    out.push_str("targets = [\n");
173    for target in targets {
174        let _ = writeln!(out, "    {},", toml_basic_string(target));
175    }
176    out.push_str("]\n");
177    out.push_str("hosting = \"github\"\n");
178    out.push_str("github-attestations = true\n");
179    out.push_str("pr-run-mode = \"skip\"\n");
180    out
181}
182
183/// Render a slice of tokens as an inline TOML string array (`["a", "b"]`).
184fn inline_string_array(items: &[String]) -> String {
185    let quoted: Vec<String> = items.iter().map(|s| toml_basic_string(s)).collect();
186    format!("[{}]", quoted.join(", "))
187}
188
189/// Quote `value` as a TOML basic string, escaping the characters TOML requires
190/// (`"`, `\`, control chars). For the closed-vocabulary tokens this module emits
191/// this is a no-op beyond the surrounding quotes, but it makes the renderer robust
192/// against a value that ever carries a special character rather than silently
193/// producing invalid TOML.
194fn toml_basic_string(value: &str) -> String {
195    let mut out = String::with_capacity(value.len() + 2);
196    out.push('"');
197    for ch in value.chars() {
198        match ch {
199            '"' => out.push_str("\\\""),
200            '\\' => out.push_str("\\\\"),
201            '\n' => out.push_str("\\n"),
202            '\r' => out.push_str("\\r"),
203            '\t' => out.push_str("\\t"),
204            c if c.is_control() => {
205                let _ = write!(out, "\\u{:04X}", c as u32);
206            }
207            c => out.push(c),
208        }
209    }
210    out.push('"');
211    out
212}
213
214#[cfg(test)]
215mod tests;