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;