Skip to main content

ossctl_core/contract/
normalize.rs

1//! Normalization pipeline: validate every field/enum/cross-field floor,
2//! materialize all defaults, and expand `targets` from `ecosystems`.
3//!
4//! A faithful port of `check-oss-release.py`'s `normalize`. `contract show`
5//! emits the canonical (normalized) form; `contract validate` runs the identical
6//! pipeline and discards the document, emitting only pass/fail (ADR-0001 §1).
7//!
8//! Every field is validated independently and **all** problems are collected —
9//! an invalid enum records an error *and* substitutes a default so the pass
10//! continues to surface every other problem (mirroring the Python `Problems`
11//! collector). The built [`Contract`] is only meaningful when
12//! [`Normalized::is_valid`] holds; callers gate on that (or, in the CLI, on the
13//! process exit code), never on parsing a document that failed.
14
15use std::io;
16use std::path::{Component, Path, PathBuf};
17
18use serde_yaml::{Mapping, Value};
19
20use crate::contract::schema::{
21    Adapter, Changelog, ChangelogMode, ChangelogSource, Contract, ContributionProvenance,
22    DependencyBot, Distribution, DistributionAdapter, DocsSite, Ecosystem, HealthBadge, Installer,
23    Maturity, ProvenanceLevel, Registry, Release, ReleaseLayout, ReleaseModel, Status, Target,
24    VersioningBase, DEFAULT_CROSS_PLATFORM_TARGETS, DEFAULT_FRAGMENT_DIR, KNOWN_SCHEMA_VERSION,
25};
26use crate::contract::spdx::spdx_valid;
27use crate::ports::Fs;
28
29/// The contract file the normalizer reads, relative to the repo root.
30pub const CONTRACT_FILENAME: &str = "OSS-RELEASE.md";
31
32/// Canonical ecosystem order — used to de-duplicate and stably order the
33/// `ecosystems` list (mirrors the Python `VALID_ECOSYSTEMS` ordered list).
34const ECOSYSTEM_ORDER: [Ecosystem; 5] = [
35    Ecosystem::Rust,
36    Ecosystem::Node,
37    Ecosystem::Python,
38    Ecosystem::Go,
39    Ecosystem::Binary,
40];
41
42/// Known top-level frontmatter keys; anything else is preserved under
43/// [`Contract::extra_fields`] (forward-compat).
44///
45/// **Invariant:** this list MUST stay in sync with the [`Contract`] struct
46/// fields — every parsed field has its source key here. A field added to
47/// [`Contract`] without its key here would be captured as an "unknown" field on
48/// input; the `all_known_keys_*` tests guard against that drift.
49///
50/// The two trailing entries — `extra_fields` and `warnings` — are the canonical
51/// *output* metadata keys, reserved here so canonical JSON (which carries them)
52/// re-fed to the normalizer as YAML does NOT re-capture them into a nested
53/// `extra_fields.extra_fields` on each pass. They are handled asymmetrically:
54/// `extra_fields`'s mapping contents are merged back into the captured map (see
55/// [`capture_unknown_fields`]) so the block round-trips losslessly; `warnings` is
56/// derived diagnostic output, regenerated every pass, so any input value under it
57/// is intentionally ignored (not preserved — it is not user contract data).
58const KNOWN_KEYS: &[&str] = &[
59    "schema_version",
60    "status",
61    "maturity",
62    "ecosystems",
63    "targets",
64    // Both distribution input keys are known: `distribution` (a single mapping,
65    // v1 back-compat) and `distributions` (a sequence, the monorepo shape). See
66    // [`parse_distributions`]; declaring both is an error, not an unknown-field.
67    "distribution",
68    "distributions",
69    "versioning",
70    "changelog",
71    "conventional_commits",
72    "release",
73    "contribution_provenance",
74    "provenance_level",
75    "dependency_bot",
76    "health_badges",
77    "license",
78    "docs_site",
79    // Reserved canonical-output metadata keys (not parsed) — see doc above.
80    "extra_fields",
81    "warnings",
82];
83
84/// Collected fatal errors and non-fatal warnings from a normalization pass.
85#[derive(Debug, Default)]
86pub struct Problems {
87    /// Fatal validation errors; a non-empty list means the config would not
88    /// normalize (the CLI exits non-zero with the §10 error envelope).
89    pub errors: Vec<String>,
90    /// Non-fatal notes (aspirational draft producers, the unknown-field report).
91    pub warnings: Vec<String>,
92}
93
94impl Problems {
95    fn err(&mut self, msg: String) {
96        self.errors.push(msg);
97    }
98
99    fn warn(&mut self, msg: String) {
100        self.warnings.push(msg);
101    }
102}
103
104/// The result of a normalization pass: the canonical [`Contract`] plus the
105/// [`Problems`] gathered while building it.
106#[derive(Debug)]
107pub struct Normalized {
108    /// The canonical contract. Only meaningful when [`Self::is_valid`] holds.
109    pub contract: Contract,
110    /// Errors and warnings gathered during normalization.
111    pub problems: Problems,
112}
113
114impl Normalized {
115    /// Whether the config normalized cleanly (no fatal errors).
116    #[must_use]
117    pub fn is_valid(&self) -> bool {
118        self.problems.errors.is_empty()
119    }
120}
121
122/// Why the contract file could not be loaded (distinct from a *validation*
123/// failure, which is carried by [`Problems`]). Maps to a §2 exit-2 system error.
124#[derive(Debug)]
125pub enum LoadError {
126    /// No `OSS-RELEASE.md` at the expected path.
127    NotFound(PathBuf),
128    /// The file exists but could not be read.
129    Io(PathBuf, io::Error),
130    /// The file is not valid UTF-8.
131    Utf8(PathBuf),
132}
133
134impl std::fmt::Display for LoadError {
135    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        match self {
137            Self::NotFound(p) => write!(
138                f,
139                "no {CONTRACT_FILENAME} at {} (run /oss-init to generate one)",
140                p.display()
141            ),
142            Self::Io(p, e) => write!(f, "cannot read {}: {e}", p.display()),
143            Self::Utf8(p) => write!(f, "{} is not valid UTF-8", p.display()),
144        }
145    }
146}
147
148/// Read `<repo_root>/OSS-RELEASE.md` through the [`Fs`] port and normalize it.
149///
150/// # Errors
151/// Returns [`LoadError`] when the file is missing, unreadable, or not UTF-8. A
152/// *validation* failure is not an error here — it is carried in the returned
153/// [`Normalized::problems`]; check [`Normalized::is_valid`].
154pub fn normalize(repo_root: &Path, fs: &dyn Fs) -> Result<Normalized, LoadError> {
155    let path = repo_root.join(CONTRACT_FILENAME);
156    let bytes = fs.read(&path).map_err(|e| match e.kind() {
157        io::ErrorKind::NotFound => LoadError::NotFound(path.clone()),
158        _ => LoadError::Io(path.clone(), e),
159    })?;
160    let text = String::from_utf8(bytes).map_err(|_| LoadError::Utf8(path.clone()))?;
161    Ok(normalize_str(&text, repo_root, fs))
162}
163
164/// Normalize the full text of an `OSS-RELEASE.md` (frontmatter + body).
165///
166/// Split from [`normalize`] so tests can exercise the pipeline on a string
167/// without a real file. `repo_root` and `fs` are still needed for the
168/// filesystem-dependent floors (the fragment-dir path floor and its advisory
169/// existence check).
170#[must_use]
171pub fn normalize_str(text: &str, repo_root: &Path, fs: &dyn Fs) -> Normalized {
172    let mut p = Problems::default();
173    let map = match split_frontmatter(text, &mut p) {
174        Some(fm) => parse_frontmatter(&fm, &mut p),
175        None => Mapping::new(),
176    };
177    let contract = build(&map, &mut p, repo_root, fs);
178    Normalized {
179        contract,
180        problems: p,
181    }
182}
183
184/// Read a required enum field, or record an error and fall back to `default`.
185/// Absent → `default` silently; present-but-invalid → error + `default`
186/// (matching the Python default-substitution behavior).
187macro_rules! enum_field {
188    ($map:expr, $key:expr, $ty:ty, $default:expr, $p:expr) => {{
189        match $map.get($key) {
190            None => $default,
191            Some(v) => match v.as_str().and_then(<$ty>::parse) {
192                Some(x) => x,
193                None => {
194                    $p.err(format!(
195                        "{} {} invalid — must be one of {:?}",
196                        $key,
197                        yaml_display(v),
198                        <$ty>::VALID
199                    ));
200                    $default
201                }
202            },
203        }
204    }};
205}
206
207#[allow(clippy::too_many_lines)]
208fn build(map: &Mapping, p: &mut Problems, repo_root: &Path, fs: &dyn Fs) -> Contract {
209    // schema_version — validate the DECLARED version (a too-new config is a hard
210    // stop, a sub-1 or non-integer is an error), but do NOT echo it: the canonical
211    // output is ALWAYS the current shape, so the emitted `schema_version` is
212    // KNOWN_SCHEMA_VERSION regardless of what the (older, still-readable) document
213    // declared. Echoing the declared version would stamp a canonical v2 body with a
214    // v1 number — a mislabeled, self-inconsistent shape a strict consumer cannot
215    // trust. The tool reads a v1 `distribution:` mapping and emits the v2
216    // `distributions: [...]` shape under `schema_version: 2`.
217    match map.get("schema_version") {
218        None => {}
219        Some(v) => match v.as_i64() {
220            Some(n) if n > i64::from(KNOWN_SCHEMA_VERSION) => p.err(format!(
221                "schema_version {n} exceeds what this tool knows ({KNOWN_SCHEMA_VERSION}); \
222                 upgrade the OSS-release skills before reading this config (refusing rather \
223                 than guessing)."
224            )),
225            Some(n) if n < 1 => p.err(format!("schema_version {n} is invalid (must be >= 1)")),
226            Some(_) => {}
227            None => p.err(format!(
228                "schema_version must be an integer, got {}",
229                yaml_display(v)
230            )),
231        },
232    }
233    let schema_version = KNOWN_SCHEMA_VERSION;
234
235    let status = enum_field!(map, "status", Status, Status::Draft, p);
236
237    // maturity — required (inference is /oss-init's job, not the normalizer's).
238    let maturity = match map.get("maturity") {
239        None => {
240            p.err("maturity is required (spike|mvp|production) — /oss-init infers it".to_string());
241            Maturity::Mvp
242        }
243        Some(v) => {
244            if let Some(m) = v.as_str().and_then(Maturity::parse) {
245                m
246            } else {
247                p.err(format!(
248                    "maturity {} invalid — must be one of {:?}",
249                    yaml_display(v),
250                    Maturity::VALID
251                ));
252                Maturity::Mvp
253            }
254        }
255    };
256
257    // ecosystems — validate, then de-dup into canonical order.
258    let mut parsed_ecos: Vec<Ecosystem> = Vec::new();
259    for item in as_list(map.get("ecosystems")) {
260        match item.as_str().and_then(Ecosystem::parse) {
261            Some(e) => parsed_ecos.push(e),
262            None => p.err(format!(
263                "ecosystems: {} invalid — must be one of {:?}",
264                yaml_display(&item),
265                Ecosystem::VALID
266            )),
267        }
268    }
269    let ecosystems: Vec<Ecosystem> = ECOSYSTEM_ORDER
270        .into_iter()
271        .filter(|e| parsed_ecos.contains(e))
272        .collect();
273
274    // versioning — split the base enum from the calver pattern.
275    let (versioning, versioning_pattern) = parse_versioning(map.get("versioning"), p);
276
277    // release (model + layout + optional bump_hook).
278    let (model, layout, bump_hook) = match map.get("release") {
279        None | Some(Value::Null) => (ReleaseModel::Gated, ReleaseLayout::Single, None),
280        Some(Value::Mapping(m)) => (
281            enum_field!(m, "model", ReleaseModel, ReleaseModel::Gated, p),
282            enum_field!(m, "layout", ReleaseLayout, ReleaseLayout::Single, p),
283            parse_bump_hook(m, p),
284        ),
285        Some(_) => {
286            p.err("release must be a mapping (model / layout / bump_hook)".to_string());
287            (ReleaseModel::Gated, ReleaseLayout::Single, None)
288        }
289    };
290
291    // targets — expand from ecosystems when the key is OMITTED; but an explicit
292    // empty list is the author's authoritative "never publish anywhere" and is
293    // honored as-is (not re-expanded). Distinguishing *absent* from *explicit
294    // empty* is the whole point: a version-tracked/changelogged repo with no
295    // registry publish (a private service deployed by its own script) must be
296    // expressible. An empty target set is a valid, honored state — every floor
297    // and downstream consumer already treats "no targets" gracefully (no
298    // registry-license floor, no `registry` health badge, "nothing to publish"
299    // in the release engine).
300    let targets = match map.get("targets") {
301        None | Some(Value::Null) => expand_targets(&ecosystems, layout),
302        Some(Value::Sequence(seq)) if seq.is_empty() => Vec::new(),
303        Some(Value::Sequence(seq)) => validate_targets(seq, &ecosystems, layout, p),
304        Some(_) => {
305            p.err(
306                "targets must be a list of {ecosystem, package?, registry, adapter?} maps"
307                    .to_string(),
308            );
309            Vec::new()
310        }
311    };
312
313    // distributions — the binary-distribution blocks (cargo-dist/goreleaser); a
314    // registry-only repo has none (→ empty list), leaving its contract shape
315    // unchanged. The homebrew cross-field truth table (tap ↔ installer-producer ↔
316    // target-producer) is enforced afterwards by [`check_homebrew_configuration`],
317    // once both `targets` and `distributions` are resolved.
318    let distributions = parse_distributions(map, &targets, schema_version, p);
319
320    // changelog (mode + source + fragment_dir).
321    let changelog = match map.get("changelog") {
322        None | Some(Value::Null) => Changelog {
323            mode: ChangelogMode::Curated,
324            source: ChangelogSource::Manual,
325            fragment_dir: DEFAULT_FRAGMENT_DIR.to_string(),
326        },
327        Some(Value::Mapping(m)) => {
328            let mode = enum_field!(m, "mode", ChangelogMode, ChangelogMode::Curated, p);
329            let source = enum_field!(m, "source", ChangelogSource, ChangelogSource::Manual, p);
330            let fragment_dir = match m.get("fragment_dir") {
331                None => DEFAULT_FRAGMENT_DIR.to_string(),
332                Some(v) => {
333                    if let Some(s) = v.as_str() {
334                        s.to_string()
335                    } else {
336                        p.err("changelog.fragment_dir must be a string path".to_string());
337                        DEFAULT_FRAGMENT_DIR.to_string()
338                    }
339                }
340            };
341            Changelog {
342                mode,
343                source,
344                fragment_dir,
345            }
346        }
347        Some(_) => {
348            p.err("changelog must be a mapping with mode/source".to_string());
349            Changelog {
350                mode: ChangelogMode::Curated,
351                source: ChangelogSource::Manual,
352                fragment_dir: DEFAULT_FRAGMENT_DIR.to_string(),
353            }
354        }
355    };
356    // fragment_dir must be a relative path inside the repo (floor 6).
357    if !path_inside_repo(&changelog.fragment_dir) {
358        p.err(format!(
359            "floor: changelog.fragment_dir {} must be a relative path inside the repo (an \
360             absolute or '../'-escaping path is refused)",
361            quote_for_diagnostic(&changelog.fragment_dir)
362        ));
363    }
364
365    // conventional_commits.
366    let conventional_commits = match map.get("conventional_commits") {
367        None => false,
368        Some(Value::Bool(b)) => *b,
369        Some(v) => {
370            p.err(format!(
371                "conventional_commits must be true|false, got {}",
372                yaml_display(v)
373            ));
374            false
375        }
376    };
377
378    let contribution_provenance = enum_field!(
379        map,
380        "contribution_provenance",
381        ContributionProvenance,
382        ContributionProvenance::None,
383        p
384    );
385    let provenance_level = enum_field!(
386        map,
387        "provenance_level",
388        ProvenanceLevel,
389        ProvenanceLevel::None,
390        p
391    );
392
393    let dep_default = if maturity == Maturity::Spike {
394        DependencyBot::None
395    } else {
396        DependencyBot::Dependabot
397    };
398    let dependency_bot = enum_field!(map, "dependency_bot", DependencyBot, dep_default, p);
399
400    // license — a valid SPDX expression when set (default MIT).
401    let license = match map.get("license") {
402        None => "MIT".to_string(),
403        Some(v) => match v.as_str() {
404            Some(s) if !s.trim().is_empty() => {
405                if !spdx_valid(s) {
406                    p.err(format!(
407                        "license {} is not a valid SPDX expression (unknown id or malformed \
408                         AND/OR/WITH grammar)",
409                        quote_for_diagnostic(s)
410                    ));
411                }
412                s.to_string()
413            }
414            _ => {
415                p.err("license must be a non-empty SPDX id/expression (default MIT)".to_string());
416                "MIT".to_string()
417            }
418        },
419    };
420
421    let docs_site = enum_field!(map, "docs_site", DocsSite, DocsSite::None, p);
422
423    // health_badges — validate when present (key-presence, per Python), else
424    // materialize a floor-clean default (maturity/target aware).
425    let health_badges = if map.contains_key("health_badges") {
426        let mut out = Vec::new();
427        for item in as_list(map.get("health_badges")) {
428            match item.as_str().and_then(HealthBadge::parse) {
429                Some(hb) => out.push(hb),
430                None => p.err(format!(
431                    "health_badges: {} invalid — must be one of {:?}",
432                    yaml_display(&item),
433                    HealthBadge::VALID
434                )),
435            }
436        }
437        out
438    } else {
439        default_health_badges(maturity, &targets)
440    };
441
442    // ── Cross-field floors (§2) — config-internal, ALWAYS hard errors ────────
443    if model == ReleaseModel::Auto && maturity == Maturity::Spike {
444        p.err(
445            "floor: release.model 'auto' is not allowed on maturity 'spike' — a spike is not \
446             being published; raise maturity or set release.model: gated"
447                .to_string(),
448        );
449    }
450    if provenance_level == ProvenanceLevel::SlsaL3 && maturity != Maturity::Production {
451        p.err(format!(
452            "floor: provenance_level 'slsa-l3' is production-only — current maturity is '{}'",
453            maturity.as_str()
454        ));
455    }
456    // A target with a registry requires a valid SPDX license. Every expanded
457    // target carries a registry, so "any registry" reduces to "any target".
458    if !targets.is_empty() && !spdx_valid(&license) {
459        p.err(format!(
460            "floor: a target has a registry (crates.io/npm/PyPI/… require a license) but license \
461             {} is not a valid SPDX expression",
462            quote_for_diagnostic(&license)
463        ));
464    }
465    check_badge_producers(&health_badges, maturity, &targets, p);
466    // Homebrew cross-field consistency: missing-tap (either producer), the
467    // double-publish collision, and the dead-tap advisory — the full truth table.
468    check_homebrew_configuration(&targets, &distributions, p);
469    // A distribution block ships public binaries (GH-Release artifacts, a curl-pipe
470    // installer, a Homebrew tap PR) — that is publishing, and a spike is not being
471    // published. Mirrors the `release.model: auto` floor: raise maturity or drop the
472    // block. (No blocks → no constraint; registry-only spikes are unaffected.)
473    if !distributions.is_empty() && maturity == Maturity::Spike {
474        p.err(
475            "floor: a distribution block ships public binaries (installer + tap) — not allowed on \
476             maturity 'spike' (a spike is not being published); raise maturity or drop distribution"
477                .to_string(),
478        );
479    }
480
481    // ── Filesystem/producer-existence semantic check — ADVISORY, never fatal ─
482    if changelog.mode == ChangelogMode::Fragment
483        && path_inside_repo(&changelog.fragment_dir)
484        && !fs.is_dir(&repo_root.join(&changelog.fragment_dir))
485    {
486        p.warn(format!(
487            "changelog.mode 'fragment' but the fragment dir {} does not exist yet under {} — \
488             /oss-changelog creates it; /oss-readiness reports it as a gap until then",
489            quote_for_diagnostic(&changelog.fragment_dir),
490            repo_root.display()
491        ));
492    }
493
494    // ── Forward-compat: preserve unknown fields, report once ─────────────────
495    let extra_fields =
496        capture_unknown_fields(map, KNOWN_KEYS, CaptureScope::TopLevel, schema_version, p);
497
498    let warnings = p.warnings.clone();
499    Contract {
500        schema_version,
501        status,
502        maturity,
503        ecosystems,
504        targets,
505        distributions,
506        versioning,
507        versioning_pattern,
508        changelog,
509        conventional_commits,
510        release: Release {
511            model,
512            layout,
513            bump_hook,
514        },
515        contribution_provenance,
516        provenance_level,
517        dependency_bot,
518        health_badges,
519        license,
520        docs_site,
521        extra_fields,
522        warnings,
523    }
524}
525
526/// Parse the optional `release.bump_hook` command string.
527///
528/// Absent/`null` → `None` (the default, no hook). A present value must be a
529/// **non-empty** string (the engine runs it verbatim in the clean checkout during
530/// the bump phase); an empty string or a non-string is a fatal error, substituting
531/// `None` so the built contract never carries a malformed hook (the "placeholders
532/// keep the strong type" error-path rule the rest of the normalizer follows).
533fn parse_bump_hook(m: &Mapping, p: &mut Problems) -> Option<String> {
534    match m.get("bump_hook") {
535        None | Some(Value::Null) => None,
536        Some(v) => match v.as_str() {
537            Some(s) if !s.trim().is_empty() => Some(s.to_string()),
538            Some(_) => {
539                p.err(
540                    "release.bump_hook must be a non-empty command string (or omit it for no hook)"
541                        .to_string(),
542                );
543                None
544            }
545            None => {
546                p.err("release.bump_hook must be a command string".to_string());
547                None
548            }
549        },
550    }
551}
552
553fn parse_versioning(value: Option<&Value>, p: &mut Problems) -> (VersioningBase, Option<String>) {
554    let Some(v) = value else {
555        return (VersioningBase::Semver, None);
556    };
557    let Some(s) = v.as_str() else {
558        p.err(format!(
559            "versioning {} invalid — must be semver | calver:<pattern> | zerover",
560            yaml_display(v)
561        ));
562        return (VersioningBase::Semver, None);
563    };
564    if let Some(rest) = s.strip_prefix("calver:") {
565        let pattern = rest.trim();
566        if pattern.is_empty() {
567            p.err(
568                "versioning 'calver:' carries no pattern — e.g. calver:YYYY.MM.MICRO".to_string(),
569            );
570        }
571        (VersioningBase::Calver, Some(pattern.to_string()))
572    } else if s == "calver" {
573        p.err(
574            "versioning 'calver' must carry its pattern (calver:YYYY.MM.MICRO), not a bare label"
575                .to_string(),
576        );
577        (VersioningBase::Calver, None)
578    } else if let Some(base) = VersioningBase::parse(s) {
579        (base, None)
580    } else {
581        p.err(format!(
582            "versioning {} invalid — must be semver | calver:<pattern> | zerover",
583            quote_for_diagnostic(s)
584        ));
585        (VersioningBase::Semver, None)
586    }
587}
588
589/// Derive one target per ecosystem with default registry + adapter.
590fn expand_targets(ecosystems: &[Ecosystem], layout: ReleaseLayout) -> Vec<Target> {
591    ecosystems
592        .iter()
593        .map(|&e| Target {
594            ecosystem: e,
595            package: None,
596            registry: e.default_registry(),
597            adapter: e.default_adapter(layout),
598        })
599        .collect()
600}
601
602fn validate_targets(
603    seq: &[Value],
604    ecosystems: &[Ecosystem],
605    layout: ReleaseLayout,
606    p: &mut Problems,
607) -> Vec<Target> {
608    let mut out = Vec::new();
609    for (idx, item) in seq.iter().enumerate() {
610        let Value::Mapping(m) = item else {
611            p.err(format!(
612                "targets[{idx}] must be a map with at least {{ecosystem, registry}}"
613            ));
614            continue;
615        };
616
617        let ecosystem = if let Some(s) = m.get("ecosystem").and_then(Value::as_str) {
618            if let Some(e) = Ecosystem::parse(s) {
619                if !ecosystems.is_empty() && !ecosystems.contains(&e) {
620                    p.err(format!(
621                        "targets[{idx}].ecosystem {} is not in ecosystems {:?}",
622                        quote_for_diagnostic(s),
623                        ecosystems.iter().map(|e| e.as_str()).collect::<Vec<_>>()
624                    ));
625                }
626                Some(e)
627            } else {
628                p.err(format!(
629                    "targets[{idx}].ecosystem {} invalid — one of {:?}",
630                    quote_for_diagnostic(s),
631                    Ecosystem::VALID
632                ));
633                None
634            }
635        } else {
636            p.err(format!(
637                "targets[{idx}].ecosystem invalid — one of {:?}",
638                Ecosystem::VALID
639            ));
640            None
641        };
642
643        let registry = match m.get("registry").and_then(Value::as_str) {
644            None => {
645                p.err(format!(
646                    "targets[{idx}] has no registry (required — the publish destination)"
647                ));
648                None
649            }
650            Some(s) => {
651                if let Some(r) = Registry::parse(s) {
652                    Some(r)
653                } else {
654                    p.err(format!(
655                        "targets[{idx}].registry {} invalid — one of {:?}",
656                        quote_for_diagnostic(s),
657                        Registry::VALID
658                    ));
659                    None
660                }
661            }
662        };
663
664        let adapter = match m.get("adapter") {
665            None => ecosystem.map(|e| e.default_adapter(layout)),
666            Some(v) => {
667                if let Some(a) = v.as_str().and_then(Adapter::parse) {
668                    Some(a)
669                } else {
670                    p.err(format!(
671                        "targets[{idx}].adapter {} invalid — one of {:?}",
672                        yaml_display(v),
673                        Adapter::VALID
674                    ));
675                    None
676                }
677            }
678        };
679
680        // Floor: registry/adapter compatibility. A `homebrew`-registry target is
681        // served only by a homebrew adapter — `homebrew-tap` (push a formula to a
682        // personal tap) or `homebrew-core` (bump the central formula). Any other
683        // adapter (e.g. the ecosystem default `cargo-publish`, or `manual`) has no
684        // homebrew formula path, so the target would silently do nothing at cut
685        // time. Reject it here rather than at release time. Only checked once both
686        // are well-formed (a parse error already reported its own problem).
687        if let (Some(Registry::Homebrew), Some(a)) = (registry, adapter) {
688            if !matches!(a, Adapter::HomebrewTap | Adapter::HomebrewCore) {
689                p.err(format!(
690                    "floor: targets[{idx}] has registry 'homebrew' but adapter {} — a \
691                     homebrew-registry target requires adapter 'homebrew-tap' (personal tap) \
692                     or 'homebrew-core' (central formula)",
693                    quote_for_diagnostic(a.as_str())
694                ));
695            }
696        }
697
698        // On the error path, placeholders keep the strong type; the document is
699        // never emitted when problems.errors is non-empty.
700        out.push(Target {
701            ecosystem: ecosystem.unwrap_or(Ecosystem::Binary),
702            package: m.get("package").and_then(Value::as_str).map(str::to_string),
703            registry: registry.unwrap_or(Registry::GhReleases),
704            adapter: adapter.unwrap_or(Adapter::Manual),
705        });
706    }
707    out
708}
709
710/// Known `distribution`-block keys; anything else is preserved under
711/// [`Distribution::extra_fields`] (forward-compat), the nested analogue of
712/// [`KNOWN_KEYS`].
713///
714/// **Invariant:** this list MUST stay in sync with the [`Distribution`] struct
715/// fields (see the [`KNOWN_KEYS`] note). The trailing `extra_fields` entry is the
716/// reserved canonical-output metadata key — its contents are merged back rather
717/// than nested (see [`capture_unknown_fields`]). A [`Distribution`] carries no
718/// `warnings` (those live only at the top level), so only `extra_fields` needs
719/// reserving here.
720const KNOWN_DISTRIBUTION_KEYS: &[&str] = &[
721    "package",
722    "adapter",
723    "gh_releases",
724    "installers",
725    "homebrew_tap",
726    "platforms",
727    // Reserved canonical-output metadata key (not parsed) — see doc above.
728    "extra_fields",
729];
730
731/// Canonical installer order — used to de-duplicate and stably order the
732/// `distribution.installers` list (mirrors [`ECOSYSTEM_ORDER`]'s role).
733const INSTALLER_ORDER: [Installer; 5] = [
734    Installer::Shell,
735    Installer::Powershell,
736    Installer::Homebrew,
737    Installer::Msi,
738    Installer::Npm,
739];
740
741/// Parse the optional distribution layer, accepting BOTH input spellings:
742/// `distribution:` (a single mapping — v1 back-compat, the overwhelmingly common
743/// case) and `distributions:` (a sequence of mappings — a monorepo shipping
744/// several independently-distributed binaries). A registry-only repo declares
745/// neither (or a bare/null key) and gets an empty list, leaving its contract
746/// shape unchanged. Declaring BOTH keys at once is ambiguous and is an error.
747///
748/// Each element is parsed by [`parse_one_distribution`]; the collection-level
749/// floor (a monorepo's `package` must be present and unique) lives here.
750fn parse_distributions(
751    map: &Mapping,
752    targets: &[Target],
753    schema_version: u32,
754    p: &mut Problems,
755) -> Vec<Distribution> {
756    let single = map.get("distribution");
757    let many = map.get("distributions");
758    // Distinguish "absent" from "present-but-null": a bare `distribution:` /
759    // `distributions:` (null value) reads as absent, exactly like the sibling keys.
760    let single_present = matches!(single, Some(v) if !v.is_null());
761    let many_present = matches!(many, Some(v) if !v.is_null());
762    if single_present && many_present {
763        p.err(
764            "declare either `distribution` (one block) or `distributions` (a list), not both — \
765             they are the singular and plural spellings of the same field"
766                .to_string(),
767        );
768        // Fall through parsing the plural so the rest of the pass still surfaces
769        // problems; the document is never emitted while `errors` is non-empty.
770    }
771
772    let distributions = match (single, many) {
773        // `distributions:` — a sequence of mappings (the monorepo shape). Wins
774        // over a stray singular key (already flagged above).
775        (_, Some(Value::Sequence(seq))) => {
776            let mut out = Vec::with_capacity(seq.len());
777            for (idx, item) in seq.iter().enumerate() {
778                match item {
779                    Value::Mapping(m) => {
780                        out.push(parse_one_distribution(m, schema_version, p));
781                    }
782                    _ => p.err(format!(
783                        "distributions[{idx}] must be a mapping with {{package, adapter, \
784                         gh_releases?, installers?, homebrew_tap?, platforms?}}"
785                    )),
786                }
787            }
788            out
789        }
790        (_, Some(v)) if !v.is_null() => {
791            p.err(format!(
792                "distributions must be a list of distribution mappings, got {}",
793                yaml_display(v)
794            ));
795            Vec::new()
796        }
797        // `distribution:` — a single mapping (v1 back-compat) → a one-element list.
798        (Some(Value::Mapping(m)), _) => {
799            vec![parse_one_distribution(m, schema_version, p)]
800        }
801        (Some(v), _) if !v.is_null() => {
802            p.err(
803                "distribution must be a mapping with {adapter?, gh_releases?, installers?, \
804                 homebrew_tap?, platforms?} (or use `distributions:` for a list)"
805                    .to_string(),
806            );
807            Vec::new()
808        }
809        // Neither key (or both null) → a registry-only repo.
810        _ => Vec::new(),
811    };
812
813    // Collection floor: a monorepo (≥2 distributions) must tag each entry with a
814    // non-null, UNIQUE `package` — otherwise its distributions are
815    // indistinguishable and the association is meaningless. A single distribution
816    // may leave `package` null (the bare `distribution:` back-compat case).
817    if distributions.len() >= 2 {
818        let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
819        for (idx, d) in distributions.iter().enumerate() {
820            match d.package.as_deref() {
821                None => p.err(format!(
822                    "floor: distributions[{idx}] has no `package` — with two or more \
823                     distributions each must name the package it builds (the monorepo \
824                     association key), so they can be told apart"
825                )),
826                Some(pkg) if !seen.insert(pkg) => p.err(format!(
827                    "floor: distributions[{idx}].package {} is used by more than one \
828                     distribution — each distribution must name a distinct package",
829                    quote_for_diagnostic(pkg)
830                )),
831                Some(_) => {}
832            }
833        }
834
835        // Typo guard (advisory): once a monorepo names packages, a distribution
836        // whose `package` matches NO `targets[].package` is very likely a typo — a
837        // distribution should build a package the contract also tracks as a target.
838        // A warning, not a floor: a binary-only package legitimately need not appear
839        // in the registry `targets`, and `targets` may be empty (a version-tracked,
840        // unpublished repo) — so it fires only when there ARE named target packages
841        // to compare against.
842        let target_pkgs: std::collections::BTreeSet<&str> = targets
843            .iter()
844            .filter_map(|t| t.package.as_deref())
845            .collect();
846        if !target_pkgs.is_empty() {
847            for (idx, d) in distributions.iter().enumerate() {
848                if let Some(pkg) = d.package.as_deref() {
849                    if !target_pkgs.contains(pkg) {
850                        p.warn(format!(
851                            "distributions[{idx}].package {} matches no targets[].package \
852                             ({target_pkgs:?}) — likely a typo; a distribution should build a \
853                             package the contract also lists as a target",
854                            quote_for_diagnostic(pkg)
855                        ));
856                    }
857                }
858            }
859        }
860    }
861
862    distributions
863}
864
865/// Parse ONE distribution mapping (an element of `distributions`, or the sole
866/// `distribution:` block) into a [`Distribution`]. On the error path it records
867/// problems and returns a placeholder — the document is never emitted while
868/// `problems.errors` is non-empty.
869#[allow(clippy::too_many_lines)]
870fn parse_one_distribution(m: &Mapping, schema_version: u32, p: &mut Problems) -> Distribution {
871    // package — the monorepo association key. Optional (null for the sole/bare
872    // distribution); the collection-level floor in `parse_distributions` requires
873    // it once there are two or more distributions.
874    let package = match m.get("package") {
875        None | Some(Value::Null) => None,
876        Some(v) => match v.as_str() {
877            // Store the TRIMMED value: surrounding whitespace would otherwise make
878            // `" alpha"` and `"alpha"` distinct to the uniqueness floor and to the
879            // per-package association/audit keying, silently breaking both.
880            Some(s) if !s.trim().is_empty() => Some(s.trim().to_string()),
881            _ => {
882                p.err(
883                    "distribution.package must be a non-empty string (the package this \
884                     distribution builds)"
885                        .to_string(),
886                );
887                None
888            }
889        },
890    };
891
892    // adapter — required when the block is present. Which tool OWNS the existing
893    // tag-triggered release workflow is not the normalizer's to guess (it renames
894    // release semantics and picks a Rust-specific default); inference is
895    // /oss-init's job, exactly as for `maturity`. A bare `distribution: {}` is
896    // therefore an error, not a silent "cargo-dist owns this repo".
897    let adapter = match m.get("adapter") {
898        None => {
899            p.err(
900                "distribution.adapter is required when a distribution block is present \
901                 (cargo-dist|goreleaser|manual) — /oss-init infers it"
902                    .to_string(),
903            );
904            DistributionAdapter::CargoDist
905        }
906        Some(v) => {
907            if let Some(a) = v.as_str().and_then(DistributionAdapter::parse) {
908                a
909            } else {
910                p.err(format!(
911                    "distribution.adapter {} invalid — must be one of {:?}",
912                    yaml_display(v),
913                    DistributionAdapter::VALID
914                ));
915                DistributionAdapter::CargoDist
916            }
917        }
918    };
919
920    let gh_releases = match m.get("gh_releases") {
921        // cargo-dist/goreleaser attach per-platform binaries by default.
922        None => true,
923        Some(Value::Bool(b)) => *b,
924        Some(v) => {
925            p.err(format!(
926                "distribution.gh_releases must be true|false, got {}",
927                yaml_display(v)
928            ));
929            true
930        }
931    };
932
933    // installers — validate each, then de-dup into canonical order.
934    let mut parsed_installers: Vec<Installer> = Vec::new();
935    for item in as_list(m.get("installers")) {
936        match item.as_str().and_then(Installer::parse) {
937            Some(i) => parsed_installers.push(i),
938            None => p.err(format!(
939                "distribution.installers: {} invalid — must be one of {:?}",
940                yaml_display(&item),
941                Installer::VALID
942            )),
943        }
944    }
945    let installers: Vec<Installer> = INSTALLER_ORDER
946        .into_iter()
947        .filter(|i| parsed_installers.contains(i))
948        .collect();
949
950    let homebrew_tap = match m.get("homebrew_tap") {
951        None | Some(Value::Null) => None,
952        Some(v) => match v.as_str() {
953            Some(s) if is_tap_slug(s) => Some(s.to_string()),
954            // An invalid slug substitutes `None` (not the bad value) so the
955            // built `Distribution` never carries a malformed tap — matching the
956            // "placeholders keep the strong type" error-path rule the rest of the
957            // normalizer follows, and letting the homebrew-needs-tap floor below
958            // still fire (a present-but-invalid tap is no tap).
959            Some(s) => {
960                p.err(format!(
961                    "distribution.homebrew_tap {} invalid — must be an 'owner/repo' slug",
962                    quote_for_diagnostic(s)
963                ));
964                None
965            }
966            None => {
967                p.err("distribution.homebrew_tap must be an 'owner/repo' string".to_string());
968                None
969            }
970        },
971    };
972
973    let wants_homebrew = installers.contains(&Installer::Homebrew);
974    // Floor: a `homebrew` installer needs a tap to push the generated formula to.
975    // This is a PER-BLOCK check — cargo-dist pushes the formula to the tap
976    // configured in this same distribution, so the tap must live here, not in a
977    // sibling distribution. (The target-side missing-tap floor, the double-publish
978    // collision, and the dead-tap advisory are cross-field and aggregate over all
979    // distributions + targets — they live in [`check_homebrew_configuration`].)
980    if wants_homebrew && homebrew_tap.is_none() {
981        p.err(
982            "floor: distribution.installers includes 'homebrew' but no distribution.homebrew_tap \
983             is set — the generated formula has nowhere to be pushed"
984                .to_string(),
985        );
986    }
987
988    // platforms — the binary target-triple set. Omitted/null → the cross-platform
989    // default (macOS + Linux musl), so a distribution that doesn't specify
990    // platforms covers Linux by default (the cross-platform install requirement).
991    // An explicit list is validated per triple and de-duplicated, preserving the
992    // author's order (like the sibling `targets` list — there is no canonical
993    // triple ordering to impose). An explicit *empty* list is NOT the same as
994    // omitted: it is a mistake, and silently defaulting it would surprise the
995    // author with targets they never listed and erase the intent the downstream
996    // cross-platform audit needs — so it is a hard error.
997    let platforms = match m.get("platforms") {
998        None | Some(Value::Null) => default_cross_platform_targets(),
999        Some(Value::Sequence(seq)) if seq.is_empty() => {
1000            // Default fallback keeps error-collection going; the contract is never
1001            // emitted while `problems.errors` is non-empty.
1002            p.err(
1003                "distribution.platforms is an empty list — omit the key to accept the \
1004                 cross-platform default (macOS + Linux) or list explicit target-triples; a \
1005                 distribution with no platforms builds nothing"
1006                    .to_string(),
1007            );
1008            default_cross_platform_targets()
1009        }
1010        Some(Value::Sequence(seq)) => {
1011            let mut out: Vec<String> = Vec::new();
1012            for item in seq {
1013                match item.as_str() {
1014                    Some(s) if looks_like_target_triple(s) => {
1015                        let triple = s.to_string();
1016                        if !out.contains(&triple) {
1017                            out.push(triple);
1018                        }
1019                    }
1020                    Some(s) => p.err(format!(
1021                        "distribution.platforms: {} is not a well-formed target-triple \
1022                         (e.g. x86_64-unknown-linux-musl, aarch64-apple-darwin) — structural \
1023                         check only; the toolchain is the final authority on what builds",
1024                        quote_for_diagnostic(s)
1025                    )),
1026                    None => p.err(format!(
1027                        "distribution.platforms: {} invalid — each entry must be a \
1028                         target-triple string",
1029                        yaml_display(item)
1030                    )),
1031                }
1032            }
1033            out
1034        }
1035        Some(v) => {
1036            p.err(format!(
1037                "distribution.platforms must be a list of target-triple strings, got {}",
1038                yaml_display(v)
1039            ));
1040            default_cross_platform_targets()
1041        }
1042    };
1043
1044    // Cross-check: an OS-specific installer whose target OS is absent from the
1045    // resolved `platforms` set is dead config — the generated installer points at
1046    // a binary the release never builds ("the installer has nothing to install").
1047    // A warning, not a floor (mirrors the `homebrew_tap`-without-consumer advisory
1048    // above): the contract is internally consistent, just wasteful. Only the
1049    // OS-specific installers constrain the set — see [`installer_os_need`] for the
1050    // full installer→OS table; npm/shell/powershell are not cross-checked.
1051    //
1052    // Gated on a clean parse: this is a cross-field semantic advisory, so it must
1053    // read only well-formed triples. A malformed triple (rejected above) that
1054    // happens to contain an OS keyword must neither satisfy nor spuriously fail
1055    // the coverage check — otherwise the warning would flip as the author fixes an
1056    // unrelated error. Errors already block emission, so gating here loses nothing.
1057    if p.errors.is_empty() {
1058        let has_windows = platforms.iter().any(|t| is_windows_triple(t));
1059        let has_macos = platforms.iter().any(|t| is_macos_triple(t));
1060        let has_linux = platforms.iter().any(|t| is_linux_triple(t));
1061        for &installer in &installers {
1062            let unmet = match installer_os_need(installer) {
1063                OsNeed::Unchecked => None,
1064                OsNeed::Windows => (!has_windows).then_some(
1065                    "distribution.installers includes 'msi' but the resolved \
1066                     distribution.platforms set has no Windows (*-windows-*) target — the MSI \
1067                     installer has nothing to install",
1068                ),
1069                // Homebrew serves macOS natively AND Linux via Linuxbrew, so a
1070                // single Linux triple satisfies it just as a darwin triple does;
1071                // the warning fires only when NEITHER is present (the issue's stated
1072                // intent when the darwin-vs-linux question is ambiguous).
1073                OsNeed::MacosOrLinux => (!has_macos && !has_linux).then_some(
1074                    "distribution.installers includes 'homebrew' but the resolved \
1075                     distribution.platforms set has no macOS (*-apple-darwin) or Linux \
1076                     (*-linux-*) target — the Homebrew formula has nothing to install",
1077                ),
1078            };
1079            if let Some(msg) = unmet {
1080                p.warn(msg.to_string());
1081            }
1082        }
1083    }
1084
1085    // Forward-compat: preserve unknown distribution sub-keys (the nested analogue
1086    // of the top-level `extra_fields` scan), so an older reader round-trips a
1087    // newer contract's distribution keys rather than dropping them. Reported once,
1088    // scoped to the block via the `Distribution` scope, mirroring the top-level
1089    // unknown-field warning — the shared helper keeps the two from drifting.
1090    let extra_fields = capture_unknown_fields(
1091        m,
1092        KNOWN_DISTRIBUTION_KEYS,
1093        CaptureScope::Distribution,
1094        schema_version,
1095        p,
1096    );
1097
1098    Distribution {
1099        package,
1100        adapter,
1101        gh_releases,
1102        installers,
1103        homebrew_tap,
1104        platforms,
1105        extra_fields,
1106    }
1107}
1108
1109/// The cross-platform default `distribution.platforms` set as owned strings —
1110/// materialized when the block omits `platforms` (or gives an empty list). Always
1111/// contains at least one Linux triple (the cross-platform install requirement).
1112fn default_cross_platform_targets() -> Vec<String> {
1113    DEFAULT_CROSS_PLATFORM_TARGETS
1114        .iter()
1115        .map(|&s| s.to_string())
1116        .collect()
1117}
1118
1119/// The OS coverage an installer needs from `distribution.platforms` to install
1120/// anything — the small installer→OS spec behind the installer↔platform
1121/// cross-check warning. Kept as one table (see [`installer_os_need`]) rather than
1122/// scattered conditionals so the mapping stays inspectable in one place.
1123enum OsNeed {
1124    /// Not cross-checked — this installer never constrains `platforms`.
1125    Unchecked,
1126    /// Needs at least one Windows triple.
1127    Windows,
1128    /// Needs at least one macOS OR Linux triple.
1129    MacosOrLinux,
1130}
1131
1132/// The OS an installer's generated artifact can actually install onto — the spec
1133/// that lets the normalizer flag an installer whose target OS is absent from
1134/// `platforms`. Only `msi` and `homebrew` are OS-gated; the rest are deliberately
1135/// left `Unchecked` (a scoping choice, not a claim that they run everywhere):
1136///
1137/// | installer    | need              | rationale                                          |
1138/// |--------------|-------------------|----------------------------------------------------|
1139/// | `msi`        | Windows           | an `.msi` installs only on Windows                 |
1140/// | `homebrew`   | macOS **or** Linux| Homebrew serves macOS natively and Linux (Linuxbrew) |
1141/// | `shell`      | — (not checked)   | a POSIX script; only msi/homebrew are gated for now |
1142/// | `powershell` | — (not checked)   | Windows-oriented; only msi/homebrew are gated for now |
1143/// | `npm`        | — (not checked)   | published to a registry, not tied to one OS's artifact |
1144fn installer_os_need(i: Installer) -> OsNeed {
1145    match i {
1146        Installer::Msi => OsNeed::Windows,
1147        Installer::Homebrew => OsNeed::MacosOrLinux,
1148        Installer::Shell | Installer::Powershell | Installer::Npm => OsNeed::Unchecked,
1149    }
1150}
1151
1152/// The OS ("system") component of a target-triple — the 3rd `-`-separated field
1153/// in the `<arch>-<vendor>-<os>[-<env>]` shape the shipped desktop triples use
1154/// (`x86_64-pc-windows-msvc`, `aarch64-apple-darwin`, `x86_64-unknown-linux-musl`).
1155/// `None` for a 2-component triple that names no vendor (`wasm32-wasip1`). Matching
1156/// the OS *positionally* (rather than "any component equals …") is what keeps
1157/// `aarch64-linux-android` out of the Linux bucket: its `linux` sits in the vendor
1158/// slot and the real OS component is `android`.
1159fn triple_os(s: &str) -> Option<&str> {
1160    s.split('-').nth(2)
1161}
1162
1163/// Whether a target-triple targets Windows — OS component `windows` (covering
1164/// both `-windows-msvc` and `-windows-gnu`).
1165fn is_windows_triple(s: &str) -> bool {
1166    triple_os(s) == Some("windows")
1167}
1168
1169/// Whether a target-triple targets macOS — OS component `darwin` (e.g.
1170/// `aarch64-apple-darwin`). Apple's non-macOS triples (`*-apple-ios`, `-tvos`, …)
1171/// carry a different OS component and are correctly excluded.
1172fn is_macos_triple(s: &str) -> bool {
1173    triple_os(s) == Some("darwin")
1174}
1175
1176/// Whether a target-triple targets Linux — OS component `linux` (e.g.
1177/// `x86_64-unknown-linux-musl`), covering the Linuxbrew case for `homebrew`.
1178/// Android (`aarch64-linux-android`) has `android` as its OS component and does
1179/// not count.
1180fn is_linux_triple(s: &str) -> bool {
1181    triple_os(s) == Some("linux")
1182}
1183
1184/// Whether `s` is a *structurally* plausible target-triple — 2–4 `-`-separated
1185/// components, each a non-empty run of `[a-z0-9_.]`. Deliberately LEXICAL, not
1186/// semantic: the real triple set is open and rustc-defined, so this is a
1187/// well-formedness gate, not a whitelist. It rejects what could never be a triple
1188/// (empty parts, uppercase, whitespace, punctuation, injection chars, wrong shape)
1189/// and accepts real triples including dotted arch names like
1190/// `thumbv8m.main-none-eabi` — but it also accepts structurally-valid nonsense like
1191/// `aa-bb`, because the toolchain is the final authority on whether a triple
1192/// actually builds. The OS component stays intact and inspectable so the
1193/// cross-platform `audit` can classify a set downstream.
1194fn looks_like_target_triple(s: &str) -> bool {
1195    let parts: Vec<&str> = s.split('-').collect();
1196    (2..=4).contains(&parts.len())
1197        && parts.iter().all(|part| {
1198            !part.is_empty()
1199                && part.bytes().all(|b| {
1200                    b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.')
1201                })
1202        })
1203}
1204
1205/// Whether `s` is a plausible `owner/repo` tap slug — exactly one `/`, and each
1206/// part a non-empty run of the GitHub-name character set (ASCII alphanumeric plus
1207/// `-`, `_`, `.`), with `.`/`..` rejected. Lexical only — existence is not
1208/// checked. Deliberately strict: this value flows into `brew tap` and repo URLs
1209/// downstream, so arbitrary punctuation, whitespace, or path traversal
1210/// (`owner/..`) must not pass.
1211fn is_tap_slug(s: &str) -> bool {
1212    fn valid_part(part: &str) -> bool {
1213        !part.is_empty()
1214            && part != "."
1215            && part != ".."
1216            && part
1217                .bytes()
1218                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
1219    }
1220    match s.split_once('/') {
1221        Some((owner, repo)) => valid_part(owner) && valid_part(repo) && !repo.contains('/'),
1222        None => false,
1223    }
1224}
1225
1226/// A floor-clean default badge set: `ci` at mvp+, `registry` when a publishable
1227/// target exists, `license` always.
1228fn default_health_badges(maturity: Maturity, targets: &[Target]) -> Vec<HealthBadge> {
1229    let mut badges = Vec::new();
1230    if matches!(maturity, Maturity::Mvp | Maturity::Production) {
1231        badges.push(HealthBadge::Ci);
1232    }
1233    if !targets.is_empty() {
1234        badges.push(HealthBadge::Registry);
1235    }
1236    badges.push(HealthBadge::License);
1237    badges
1238}
1239
1240/// Every enabled badge must have its producer enabled (floor 4).
1241fn check_badge_producers(
1242    badges: &[HealthBadge],
1243    maturity: Maturity,
1244    targets: &[Target],
1245    p: &mut Problems,
1246) {
1247    let has_registry_target = !targets.is_empty();
1248    for b in badges {
1249        match b {
1250            HealthBadge::Ci if maturity == Maturity::Spike => p.err(
1251                "floor: health_badge 'ci' has no producer at maturity 'spike' (no CI until mvp) — \
1252                 drop it or raise maturity"
1253                    .to_string(),
1254            ),
1255            HealthBadge::Registry if !has_registry_target => p.err(
1256                "floor: health_badge 'registry' has no producer — no target has a registry to \
1257                 publish to"
1258                    .to_string(),
1259            ),
1260            HealthBadge::Coverage if maturity != Maturity::Production => p.err(format!(
1261                "floor: health_badge 'coverage' has no producer — the coverage gate is a \
1262                 production-tier /oss-ci output; current maturity is '{}'",
1263                maturity.as_str()
1264            )),
1265            HealthBadge::Scorecard if maturity != Maturity::Production => p.err(format!(
1266                "floor: health_badge 'scorecard' has no producer — the Scorecard action is a \
1267                 production-tier output; current maturity is '{}'",
1268                maturity.as_str()
1269            )),
1270            _ => {}
1271        }
1272    }
1273}
1274
1275/// Cross-field Homebrew consistency — the full truth table over three aggregate
1276/// signals: a **tap** configured on any distribution, an installer-side formula
1277/// **producer** (a `homebrew` installer on any distribution — cargo-dist), and a
1278/// target-side formula **producer** (a `homebrew`-registry target whose adapter is
1279/// `homebrew-tap`, i.e. the release engine pushes a formula to the personal tap).
1280///
1281/// A `homebrew-core` target is deliberately NOT a tap-producer: it bumps the
1282/// central formula via a PR and needs no personal tap, so it neither requires a
1283/// `homebrew_tap` nor collides with the installer's tap push.
1284///
1285/// The floors (all hard errors, per the AI-first fail-fast contract):
1286/// - **missing-tap (target side):** a `homebrew-tap` target with no `homebrew_tap`
1287///   anywhere — the engine's `dist` phase has nowhere to push the formula. (The
1288///   installer side is floored per-block in [`parse_one_distribution`].)
1289/// - **double-publish:** a `homebrew` installer AND a `homebrew-tap` target both
1290///   generate + push a formula to the personal tap — a guaranteed collision.
1291///
1292/// Plus one **advisory** (warning, not a floor): a configured tap with no producer
1293/// of either kind is dead config — nothing ever writes to it.
1294///
1295/// **Why aggregate, not per-package (deliberate).** The three signals are OR-ed
1296/// across all distributions/targets rather than grouped by the monorepo `package`
1297/// key. This matches the release engine's actual homebrew model: a cut carries a
1298/// SINGLE tap (`ReleasePlan::homebrew_tap` is the first-found tap, see
1299/// `release::plan`), and the CLI's `ensure_single_distribution` rejects a
1300/// multi-distribution monorepo BEFORE it can be planned — a per-package multi-tap
1301/// monorepo is an explicit deferred follow-up, not a shape the engine can cut. For
1302/// everything the engine supports (≤1 distribution), aggregate == per-package, and
1303/// crucially the aggregate view is what lets a bare `package: null` distribution's
1304/// tap serve a named-package `homebrew-tap` target (ossctl's OWN contract shape) —
1305/// a strict `target.package == distribution.package` grouping would wrongly reject
1306/// it. Revisit this only alongside the engine's per-package-tap follow-up.
1307fn check_homebrew_configuration(
1308    targets: &[Target],
1309    distributions: &[Distribution],
1310    p: &mut Problems,
1311) {
1312    let has_tap = distributions.iter().any(|d| d.homebrew_tap.is_some());
1313    let installer_producer = distributions
1314        .iter()
1315        .any(|d| d.installers.contains(&Installer::Homebrew));
1316    // Narrowed to `homebrew-tap` (not merely `registry == homebrew`): only the tap
1317    // adapter pushes a formula to the personal tap. `validate_targets` already
1318    // floors any other adapter on a `homebrew` registry, so a `homebrew-core`
1319    // target is the only other well-formed case, and it is not a tap-producer.
1320    let tap_target_producer = targets
1321        .iter()
1322        .any(|t| t.registry == Registry::Homebrew && t.adapter == Adapter::HomebrewTap);
1323
1324    // Floor: a homebrew-tap target needs a tap destination for its formula.
1325    if tap_target_producer && !has_tap {
1326        p.err(
1327            "floor: a 'homebrew'-registry target with adapter 'homebrew-tap' generates a formula \
1328             but no distribution sets homebrew_tap — the formula has nowhere to be pushed (set \
1329             distribution.homebrew_tap to the 'owner/repo' tap)"
1330                .to_string(),
1331        );
1332    }
1333
1334    // Floor: the double-publish collision — two mechanisms push a formula to the
1335    // personal tap (cargo-dist's installer AND the engine's homebrew-tap adapter).
1336    if installer_producer && tap_target_producer {
1337        p.err(
1338            "floor: both a 'homebrew' installer (distribution.installers) and a 'homebrew'-registry \
1339             target with adapter 'homebrew-tap' generate + push a formula to the tap — they would \
1340             collide; keep exactly one homebrew formula producer, not both"
1341                .to_string(),
1342        );
1343    }
1344
1345    // Advisory: a tap nobody writes to (no installer producer, no tap target).
1346    if has_tap && !installer_producer && !tap_target_producer {
1347        p.warn(
1348            "distribution.homebrew_tap is set but there is neither a 'homebrew' installer in \
1349             distribution.installers nor a 'homebrew'-registry target with adapter 'homebrew-tap' \
1350             — no formula is generated, so the tap will never be updated"
1351                .to_string(),
1352        );
1353    }
1354}
1355
1356// ── Frontmatter extraction + parse ───────────────────────────────────────────
1357
1358/// A `---` fence line (exactly three dashes plus optional trailing whitespace).
1359fn is_fence(line: &str) -> bool {
1360    let t = line.trim_end();
1361    t == "---" || (t.starts_with("---") && t[3..].chars().all(char::is_whitespace))
1362}
1363
1364/// Split the YAML frontmatter block out of the document. Returns the frontmatter
1365/// text (body discarded — the normalizer never reads it), or `None` on a
1366/// structural error (recorded on `p`).
1367fn split_frontmatter(text: &str, p: &mut Problems) -> Option<String> {
1368    let mut lines = text.lines();
1369    match lines.next() {
1370        Some(first) if is_fence(first) => {}
1371        _ => {
1372            p.err("frontmatter missing: file must begin with a '---' YAML block".to_string());
1373            return None;
1374        }
1375    }
1376    let mut fm = String::new();
1377    for line in lines {
1378        if is_fence(line) {
1379            return Some(fm);
1380        }
1381        fm.push_str(line);
1382        fm.push('\n');
1383    }
1384    p.err("frontmatter not closed: no terminating '---' line found".to_string());
1385    None
1386}
1387
1388/// Parse the frontmatter into a YAML mapping. `serde_yaml` rejects duplicate
1389/// keys natively; a non-mapping top level or any YAML error is recorded on `p`.
1390fn parse_frontmatter(fm: &str, p: &mut Problems) -> Mapping {
1391    if fm.trim().is_empty() {
1392        return Mapping::new();
1393    }
1394    match serde_yaml::from_str::<Value>(fm) {
1395        Ok(Value::Null) => Mapping::new(),
1396        Ok(Value::Mapping(m)) => m,
1397        Ok(_) => {
1398            p.err("frontmatter: top level must be a mapping".to_string());
1399            Mapping::new()
1400        }
1401        Err(e) => {
1402            p.err(format!("frontmatter: invalid YAML — {e}"));
1403            Mapping::new()
1404        }
1405    }
1406}
1407
1408// ── Helpers ──────────────────────────────────────────────────────────────────
1409
1410/// Coerce a value to a list: a sequence stays; absent/null → empty; a scalar
1411/// becomes a one-element list (mirrors the Python `_as_list`).
1412fn as_list(v: Option<&Value>) -> Vec<Value> {
1413    match v {
1414        None | Some(Value::Null) => Vec::new(),
1415        Some(Value::Sequence(seq)) => seq.clone(),
1416        Some(other) => vec![other.clone()],
1417    }
1418}
1419
1420/// A compact display of a YAML scalar for error messages (strings are quoted).
1421fn yaml_display(v: &Value) -> String {
1422    match v {
1423        Value::String(s) => quote_for_diagnostic(s),
1424        Value::Bool(b) => b.to_string(),
1425        Value::Number(n) => n.to_string(),
1426        Value::Null => "null".to_string(),
1427        Value::Sequence(_) => "<list>".to_string(),
1428        Value::Mapping(_) => "<map>".to_string(),
1429        Value::Tagged(t) => yaml_display(&t.value),
1430    }
1431}
1432
1433/// Quote a user-controlled string for safe embedding in a warning/error message.
1434///
1435/// Diagnostics interleave user-controlled text (unknown field keys, rejected enum
1436/// values, package/tap/path strings) into a single line that lands in the §10
1437/// error envelope and the JSONL log. Wrapping such a value in bare single quotes
1438/// (`'{s}'`) lets a value carrying a quote, newline, or control character forge a
1439/// second diagnostic line or corrupt the log — a log-injection vector. JSON string
1440/// encoding escapes `"`, `\`, newlines, and C0 control characters (and leaves
1441/// ordinary text readable), so `foo` renders as `"foo"` and a hostile
1442/// `a"\ninjected` renders as `"a\"\ninjected"` on one intact line. Infallible:
1443/// serializing a string to JSON never fails.
1444fn quote_for_diagnostic(s: &str) -> String {
1445    serde_json::Value::String(s.to_owned()).to_string()
1446}
1447
1448/// Whether `rel` is a relative path that stays inside the repo — no absolute
1449/// path, no `../` escape — the fragment-dir floor. Lexical, so the path need not
1450/// exist. The check is purely on `rel`'s own component depth, so it holds
1451/// whether the repo root is absolute or relative (notably `--repo-root .`,
1452/// where `repo_root` normalizes to an empty path): a `..` is an escape the
1453/// moment it would pop above the repo root, exactly the Python
1454/// `_path_inside_repo` verdict (which rejects any `rel` that normalizes to an
1455/// escaping path). Joining `rel` onto a relative root and testing containment —
1456/// the previous approach — silently accepted `../etc` under a `.` root, because
1457/// an empty normalized root is a prefix of every path.
1458fn path_inside_repo(rel: &str) -> bool {
1459    let mut depth: usize = 0;
1460    for comp in Path::new(rel).components() {
1461        match comp {
1462            Component::CurDir => {}
1463            Component::Normal(_) => depth += 1,
1464            Component::ParentDir => {
1465                // An escape above the repo root the instant depth would go < 0.
1466                if depth == 0 {
1467                    return false;
1468                }
1469                depth -= 1;
1470            }
1471            // An absolute path (or a Windows drive prefix) never stays inside a
1472            // relative repo root.
1473            Component::RootDir | Component::Prefix(_) => return false,
1474        }
1475    }
1476    true
1477}
1478
1479/// Which mapping the [`capture_unknown_fields`] scan is running over — scopes the
1480/// forward-compat warning text and error messages. An enum (rather than a bare
1481/// string prefix) so a new call site cannot silently pass a mis-spaced label and
1482/// produce `unknown distributionfield(s)`.
1483#[derive(Clone, Copy)]
1484enum CaptureScope {
1485    /// The top-level frontmatter mapping ([`KNOWN_KEYS`]).
1486    TopLevel,
1487    /// The nested `distribution` block ([`KNOWN_DISTRIBUTION_KEYS`]).
1488    Distribution,
1489}
1490
1491impl CaptureScope {
1492    /// The infix woven into the warning/error text — `""` for the top level,
1493    /// `"distribution "` for the block — so a message reads `unknown field(s) …`
1494    /// vs `unknown distribution field(s) …`.
1495    fn label(self) -> &'static str {
1496        match self {
1497            Self::TopLevel => "",
1498            Self::Distribution => "distribution ",
1499        }
1500    }
1501}
1502
1503/// Scan a YAML mapping for keys outside `known` and preserve them under a
1504/// forward-compat `extra_fields` map, warning once when any were captured. The
1505/// single implementation behind BOTH the top-level ([`KNOWN_KEYS`]) and nested
1506/// `distribution` ([`KNOWN_DISTRIBUTION_KEYS`]) scans, so the two cannot drift
1507/// (the nested warning once silently omitted `schema_version`).
1508///
1509/// The guarantee is that an unknown **string** key is never dropped, never
1510/// double-captured, and round-trips predictably:
1511/// - A string key not in `known` is captured verbatim.
1512/// - A known string key is skipped (parsed as its field, not double-captured).
1513/// - The reserved canonical-output key `extra_fields` (in `known`) is not
1514///   re-captured into a nested `extra_fields.extra_fields`; instead its mapping
1515///   contents are **merged back** into the returned map (see
1516///   [`merge_reserved_extra_fields`]) so a hand-authored — or, defensively, a
1517///   re-fed canonical — `extra_fields` block round-trips losslessly rather than
1518///   being silently dropped. A key present both in that block and as a sibling
1519///   unknown field is an ambiguity error, not a silent overwrite.
1520///
1521/// Non-string keys (`42:`, `true:`, a list/map key — legal YAML) are a **structural
1522/// error**, not silently coerced: they can never be a forward-compatible schema
1523/// field (canonical JSON object keys are strings), and coercing them through the
1524/// display formatter would collapse distinct keys onto the same string (`42` and
1525/// `"42"`; every list key onto `<list>`) and silently drop a value — the opposite
1526/// of the never-drop intent. Rejecting keeps the invariant vacuously (an invalid
1527/// contract's output is never consumed) and matches the normalizer's
1528/// error-collection style.
1529fn capture_unknown_fields(
1530    m: &Mapping,
1531    known: &[&str],
1532    scope: CaptureScope,
1533    schema_version: u32,
1534    p: &mut Problems,
1535) -> serde_json::Map<String, serde_json::Value> {
1536    let label = scope.label();
1537    let mut extra_fields = serde_json::Map::new();
1538    // Merge an explicit `extra_fields` block first (reserved metadata key), so a
1539    // sibling unknown key colliding with it is detected below rather than
1540    // silently overwriting it.
1541    if let Some(v) = m.get("extra_fields") {
1542        merge_reserved_extra_fields(v, scope, &mut extra_fields, p);
1543    }
1544    for (k, v) in m {
1545        match k {
1546            Value::String(key) => {
1547                if known.contains(&key.as_str()) {
1548                    continue;
1549                }
1550                if extra_fields.contains_key(key) {
1551                    p.err(format!(
1552                        "{label}field '{key}' appears both as an unknown top-level key and inside \
1553                         the reserved '{label}extra_fields' block — refusing to drop either value; \
1554                         remove one"
1555                    ));
1556                } else {
1557                    extra_fields.insert(key.clone(), yaml_to_json(v));
1558                }
1559            }
1560            other => p.err(format!(
1561                "{label}field key {} must be a string — a non-string key is not a \
1562                 forward-compatible schema shape and cannot be preserved losslessly (distinct \
1563                 non-string keys collapse onto the same JSON key)",
1564                yaml_display(other)
1565            )),
1566        }
1567    }
1568    if !extra_fields.is_empty() {
1569        // serde_json::Map is ordered (BTreeMap, no `preserve_order`) → keys already
1570        // sorted. Each key is a user-controlled map key, so JSON-encode it (rather
1571        // than bare single-quoting) to keep a hostile key from forging a diagnostic
1572        // line — see [`quote_for_diagnostic`].
1573        let keys = extra_fields
1574            .keys()
1575            .map(|k| quote_for_diagnostic(k))
1576            .collect::<Vec<_>>()
1577            .join(", ");
1578        p.warn(format!(
1579            "unknown {label}field(s) preserved under schema_version {schema_version} \
1580             (forward-compat): [{keys}]"
1581        ));
1582    }
1583    extra_fields
1584}
1585
1586/// Merge the contents of a reserved `extra_fields` block (a hand-authored, or
1587/// defensively a re-fed canonical, mapping under the reserved `extra_fields` key)
1588/// into `out`, upholding the never-drop invariant for that block rather than
1589/// silently discarding it now that the key is reserved in `known`. A non-mapping
1590/// value, or a non-string key inside it, is a structural error (same rationale as
1591/// the sibling scan in [`capture_unknown_fields`]). Sibling-key collisions are
1592/// detected back in the caller, after this has seeded `out`.
1593fn merge_reserved_extra_fields(
1594    v: &Value,
1595    scope: CaptureScope,
1596    out: &mut serde_json::Map<String, serde_json::Value>,
1597    p: &mut Problems,
1598) {
1599    let label = scope.label();
1600    match v {
1601        Value::Null => {}
1602        Value::Mapping(inner) => {
1603            for (k, val) in inner {
1604                match k {
1605                    Value::String(key) => {
1606                        out.insert(key.clone(), yaml_to_json(val));
1607                    }
1608                    other => p.err(format!(
1609                        "reserved '{label}extra_fields' block has a non-string key {} — its keys \
1610                         must be strings",
1611                        yaml_display(other)
1612                    )),
1613                }
1614            }
1615        }
1616        other => p.err(format!(
1617            "reserved '{label}extra_fields' must be a mapping when present, got {}",
1618            yaml_display(other)
1619        )),
1620    }
1621}
1622
1623/// Convert an arbitrary YAML value to JSON, for `extra_fields` preservation.
1624fn yaml_to_json(v: &Value) -> serde_json::Value {
1625    use serde_json::Value as J;
1626    match v {
1627        Value::Null => J::Null,
1628        Value::Bool(b) => J::Bool(*b),
1629        Value::Number(n) => {
1630            if let Some(i) = n.as_i64() {
1631                J::from(i)
1632            } else if let Some(u) = n.as_u64() {
1633                J::from(u)
1634            } else if let Some(f) = n.as_f64() {
1635                serde_json::Number::from_f64(f).map_or(J::Null, J::Number)
1636            } else {
1637                J::Null
1638            }
1639        }
1640        Value::String(s) => J::String(s.clone()),
1641        Value::Sequence(seq) => J::Array(seq.iter().map(yaml_to_json).collect()),
1642        Value::Mapping(m) => {
1643            let mut obj = serde_json::Map::new();
1644            for (k, val) in m {
1645                let key = match k {
1646                    Value::String(s) => s.clone(),
1647                    other => yaml_display(other),
1648                };
1649                obj.insert(key, yaml_to_json(val));
1650            }
1651            J::Object(obj)
1652        }
1653        Value::Tagged(t) => yaml_to_json(&t.value),
1654    }
1655}
1656
1657#[cfg(test)]
1658mod tests {
1659    use super::*;
1660    use std::collections::HashSet;
1661
1662    /// A fake `Fs`: `normalize_str` never `read`s, so only the directory set
1663    /// matters (for the fragment-dir advisory check).
1664    struct FakeFs {
1665        dirs: HashSet<PathBuf>,
1666    }
1667
1668    impl FakeFs {
1669        fn empty() -> Self {
1670            Self {
1671                dirs: HashSet::new(),
1672            }
1673        }
1674
1675        fn with_dirs<const N: usize>(dirs: [&str; N]) -> Self {
1676            Self {
1677                dirs: dirs.iter().map(PathBuf::from).collect(),
1678            }
1679        }
1680    }
1681
1682    impl Fs for FakeFs {
1683        fn read(&self, _path: &Path) -> io::Result<Vec<u8>> {
1684            Err(io::Error::from(io::ErrorKind::NotFound))
1685        }
1686        fn exists(&self, path: &Path) -> bool {
1687            self.dirs.contains(path)
1688        }
1689        fn is_dir(&self, path: &Path) -> bool {
1690            self.dirs.contains(path)
1691        }
1692        fn is_file(&self, _path: &Path) -> bool {
1693            // The contract normalizer models only directories (fragment-dir).
1694            false
1695        }
1696        fn read_dir(&self, _dir: &Path) -> io::Result<Vec<String>> {
1697            // The contract normalizer never lists directories.
1698            Ok(Vec::new())
1699        }
1700    }
1701
1702    fn repo() -> &'static Path {
1703        Path::new("/repo")
1704    }
1705
1706    fn norm(text: &str) -> Normalized {
1707        normalize_str(text, repo(), &FakeFs::empty())
1708    }
1709
1710    fn norm_with(text: &str, fs: &dyn Fs) -> Normalized {
1711        normalize_str(text, repo(), fs)
1712    }
1713
1714    fn assert_error_contains(n: &Normalized, needle: &str) {
1715        assert!(
1716            !n.is_valid(),
1717            "expected invalid, got clean normalize: {:?}",
1718            n.contract
1719        );
1720        assert!(
1721            n.problems.errors.iter().any(|e| e.contains(needle)),
1722            "no error contained {needle:?}; errors were {:?}",
1723            n.problems.errors
1724        );
1725    }
1726
1727    const MINIMAL: &str = "---\nstatus: approved\nmaturity: mvp\n---\n";
1728
1729    #[test]
1730    fn materializes_all_defaults() {
1731        let c = norm(MINIMAL).contract;
1732        // Pinned to the literal (not KNOWN_SCHEMA_VERSION) so a future bump is an
1733        // explicit, visible test change rather than silently tracking the constant.
1734        assert_eq!(c.schema_version, 2);
1735        assert_eq!(c.status, Status::Approved);
1736        assert_eq!(c.maturity, Maturity::Mvp);
1737        assert!(c.ecosystems.is_empty());
1738        assert!(c.targets.is_empty());
1739        assert_eq!(c.versioning, VersioningBase::Semver);
1740        assert_eq!(c.versioning_pattern, None);
1741        assert_eq!(c.changelog.mode, ChangelogMode::Curated);
1742        assert_eq!(c.changelog.source, ChangelogSource::Manual);
1743        assert_eq!(c.changelog.fragment_dir, DEFAULT_FRAGMENT_DIR);
1744        assert!(!c.conventional_commits);
1745        assert_eq!(c.release.model, ReleaseModel::Gated);
1746        assert_eq!(c.release.layout, ReleaseLayout::Single);
1747        assert_eq!(c.release.bump_hook, None); // optional, absent by default
1748        assert_eq!(c.contribution_provenance, ContributionProvenance::None);
1749        assert_eq!(c.provenance_level, ProvenanceLevel::None);
1750        assert_eq!(c.dependency_bot, DependencyBot::Dependabot); // mvp default
1751        assert_eq!(c.license, "MIT");
1752        assert_eq!(c.docs_site, DocsSite::None);
1753        // mvp, no publishable target → [ci, license].
1754        assert_eq!(c.health_badges, vec![HealthBadge::Ci, HealthBadge::License]);
1755        assert!(c.extra_fields.is_empty());
1756    }
1757
1758    #[test]
1759    fn parses_a_declared_bump_hook() {
1760        // `release.bump_hook` (facet 3) — the command the engine runs during the bump
1761        // phase to regenerate version-embedding artifacts (e.g. insta snapshots).
1762        let c = norm(
1763            "---\nstatus: approved\nmaturity: mvp\n\
1764             release:\n  model: gated\n  bump_hook: \"cargo insta test --accept\"\n---\n",
1765        )
1766        .contract;
1767        assert_eq!(
1768            c.release.bump_hook.as_deref(),
1769            Some("cargo insta test --accept")
1770        );
1771        // Additive: it round-trips through the canonical JSON when present.
1772        let json = serde_json::to_value(&c).unwrap();
1773        assert_eq!(
1774            json["release"]["bump_hook"],
1775            serde_json::json!("cargo insta test --accept")
1776        );
1777    }
1778
1779    #[test]
1780    fn an_absent_bump_hook_is_omitted_from_canonical_json() {
1781        // The additive superset guarantee: a contract with no hook serializes exactly
1782        // as before — no `bump_hook` key in the release block.
1783        let c = norm(MINIMAL).contract;
1784        let json = serde_json::to_value(&c).unwrap();
1785        assert!(
1786            json["release"].get("bump_hook").is_none(),
1787            "an absent hook must not appear in canonical JSON, got {:?}",
1788            json["release"]
1789        );
1790    }
1791
1792    #[test]
1793    fn an_empty_bump_hook_is_rejected() {
1794        // A present-but-empty command is a configuration error (fail closed), not a
1795        // silently-ignored no-op.
1796        assert_error_contains(
1797            &norm(
1798                "---\nstatus: approved\nmaturity: mvp\n\
1799                 release:\n  model: gated\n  bump_hook: \"   \"\n---\n",
1800            ),
1801            "release.bump_hook must be a non-empty",
1802        );
1803    }
1804
1805    #[test]
1806    fn a_non_string_bump_hook_is_rejected() {
1807        assert_error_contains(
1808            &norm(
1809                "---\nstatus: approved\nmaturity: mvp\n\
1810                 release:\n  model: gated\n  bump_hook: [not, a, string]\n---\n",
1811            ),
1812            "release.bump_hook must be a command string",
1813        );
1814    }
1815
1816    #[test]
1817    fn spike_defaults_no_bot_no_ci_badge() {
1818        let c = norm("---\nstatus: approved\nmaturity: spike\n---\n").contract;
1819        assert_eq!(c.dependency_bot, DependencyBot::None);
1820        assert_eq!(c.health_badges, vec![HealthBadge::License]);
1821    }
1822
1823    #[test]
1824    fn maturity_is_required() {
1825        assert_error_contains(
1826            &norm("---\nstatus: approved\n---\n"),
1827            "maturity is required",
1828        );
1829    }
1830
1831    #[test]
1832    fn expands_targets_from_ecosystems() {
1833        let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [python]\n---\n").contract;
1834        assert_eq!(c.targets.len(), 1);
1835        assert_eq!(c.targets[0].ecosystem, Ecosystem::Python);
1836        assert_eq!(c.targets[0].package, None);
1837        assert_eq!(c.targets[0].registry, Registry::Pypi);
1838        assert_eq!(c.targets[0].adapter, Adapter::GhActionPypiPublish);
1839    }
1840
1841    /// Option B (publish-target-none): an explicit empty `targets: []` is the
1842    /// author's authoritative "never publish" and is honored as an empty set —
1843    /// NOT re-expanded into the ecosystem default. This is the whole fix: a
1844    /// version-tracked repo with a registry ecosystem but no publish must be
1845    /// expressible.
1846    #[test]
1847    fn explicit_empty_targets_is_honored_not_expanded() {
1848        let n =
1849            norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n---\n");
1850        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1851        let c = n.contract;
1852        // The rust ecosystem is still recorded …
1853        assert_eq!(c.ecosystems, vec![Ecosystem::Rust]);
1854        // … but NO crates.io target is force-expanded: the empty set is honored.
1855        assert!(
1856            c.targets.is_empty(),
1857            "explicit targets:[] must stay empty, got {:?}",
1858            c.targets
1859        );
1860    }
1861
1862    /// The counterpart to the above: OMITTING `targets` keeps the unchanged
1863    /// ecosystem-default expansion. Absent ≠ explicit-empty.
1864    #[test]
1865    fn omitted_targets_still_expands_to_ecosystem_default() {
1866        let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract;
1867        assert_eq!(c.targets.len(), 1);
1868        assert_eq!(c.targets[0].ecosystem, Ecosystem::Rust);
1869        assert_eq!(c.targets[0].registry, Registry::CratesIo);
1870        assert_eq!(c.targets[0].adapter, Adapter::CargoPublish);
1871    }
1872
1873    /// A YAML `targets:` with a null value (not a list) is treated as *absent*,
1874    /// not as an explicit empty set — it still expands. Only a genuine empty
1875    /// sequence `[]` is the authoritative "never publish".
1876    #[test]
1877    fn null_targets_expands_like_omitted() {
1878        let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n---\n")
1879            .contract;
1880        assert_eq!(c.targets.len(), 1);
1881        assert_eq!(c.targets[0].registry, Registry::CratesIo);
1882    }
1883
1884    /// An empty-targets contract round-trips through canonical JSON unchanged:
1885    /// `targets` serializes as an empty array `[]` (faithfully reporting the
1886    /// never-publish intent, not omitting or defaulting it), and re-feeding that
1887    /// canonical `targets` value back through the normalizer preserves the empty
1888    /// set — the intent survives a normalize→serialize→normalize cycle.
1889    #[test]
1890    fn empty_targets_round_trips_through_canonical_json() {
1891        let n =
1892            norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n---\n");
1893        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1894        let json = serde_json::to_value(&n.contract).unwrap();
1895        // The canonical output faithfully reports the empty set as `[]`.
1896        assert_eq!(json["targets"], serde_json::json!([]));
1897
1898        // Re-feed the canonical `targets` value as frontmatter; the empty set is
1899        // preserved (still no expansion), proving the round-trip is stable.
1900        let targets_yaml = serde_yaml::to_string(&json["targets"]).unwrap();
1901        let refed = format!(
1902            "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: {}\n---\n",
1903            targets_yaml.trim()
1904        );
1905        let n2 = norm(&refed);
1906        assert!(n2.is_valid(), "errors: {:?}", n2.problems.errors);
1907        assert_eq!(n2.contract.targets, n.contract.targets);
1908        assert!(n2.contract.targets.is_empty());
1909    }
1910
1911    /// Cross-field: an explicit empty `targets: []` skips the registry-license
1912    /// floor (no target → no registry that requires an SPDX license), while a
1913    /// genuinely invalid license is still caught by its OWN check. Locks in that
1914    /// the `!targets.is_empty()` gate on the floor keeps honoring an empty set.
1915    #[test]
1916    fn explicit_empty_targets_skips_registry_license_floor() {
1917        let n = norm(
1918            "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n\
1919             license: not-a-real-spdx-id\n---\n",
1920        );
1921        // The bad license is still invalid on its own …
1922        assert!(!n.is_valid());
1923        // … but the registry-requires-license FLOOR must NOT fire — there is no
1924        // registry target to trigger it.
1925        assert!(
1926            !n.problems
1927                .errors
1928                .iter()
1929                .any(|e| e.contains("floor: a target has a registry")),
1930            "registry-license floor fired despite empty targets: {:?}",
1931            n.problems.errors
1932        );
1933    }
1934
1935    /// Cross-field: forcing a `registry` health badge while declaring `targets: []`
1936    /// is a floor error — the badge has no producer (no registry to publish to).
1937    /// The empty set is honored, and the badge/target consistency floor still
1938    /// guards against a badge with nothing behind it.
1939    #[test]
1940    fn registry_badge_with_explicit_empty_targets_fails() {
1941        let n = norm(
1942            "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n\
1943             health_badges: [registry, license]\n---\n",
1944        );
1945        assert_error_contains(&n, "health_badge 'registry' has no producer");
1946    }
1947
1948    /// The expansion-skip is independent of the `ecosystems` list: an explicit
1949    /// `targets: []` with NO ecosystems is still an honored empty set (and, like
1950    /// the minimal contract, defaults its badges to [ci, license] — no registry
1951    /// badge without a target).
1952    #[test]
1953    fn explicit_empty_targets_with_no_ecosystems() {
1954        let n = norm("---\nstatus: approved\nmaturity: mvp\ntargets: []\n---\n");
1955        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1956        assert!(n.contract.targets.is_empty());
1957        assert_eq!(
1958            n.contract.health_badges,
1959            vec![HealthBadge::Ci, HealthBadge::License]
1960        );
1961    }
1962
1963    #[test]
1964    fn node_monorepo_adapter_is_changesets() {
1965        let text = "---\nstatus: approved\nmaturity: production\necosystems: [node]\n\
1966                    release:\n  model: gated\n  layout: monorepo\n---\n";
1967        let c = norm(text).contract;
1968        assert_eq!(c.targets[0].adapter, Adapter::Changesets);
1969    }
1970
1971    #[test]
1972    fn ecosystems_dedup_to_canonical_order() {
1973        let c =
1974            norm("---\nstatus: approved\nmaturity: mvp\necosystems: [python, rust, python]\n---\n")
1975                .contract;
1976        assert_eq!(c.ecosystems, vec![Ecosystem::Rust, Ecosystem::Python]);
1977    }
1978
1979    #[test]
1980    fn calver_splits_base_and_pattern() {
1981        let c = norm(
1982            "---\nstatus: approved\nmaturity: mvp\nversioning: \"calver:YYYY.MM.MICRO\"\n---\n",
1983        )
1984        .contract;
1985        assert_eq!(c.versioning, VersioningBase::Calver);
1986        assert_eq!(c.versioning_pattern.as_deref(), Some("YYYY.MM.MICRO"));
1987    }
1988
1989    #[test]
1990    fn bare_calver_is_rejected() {
1991        assert_error_contains(
1992            &norm("---\nstatus: approved\nmaturity: mvp\nversioning: calver\n---\n"),
1993            "must carry its pattern",
1994        );
1995    }
1996
1997    #[test]
1998    fn floor_auto_on_spike() {
1999        let text = "---\nstatus: approved\nmaturity: spike\n\
2000                    release:\n  model: auto\n  layout: single\nhealth_badges: [license]\n---\n";
2001        assert_error_contains(&norm(text), "release.model 'auto' is not allowed");
2002    }
2003
2004    #[test]
2005    fn floor_slsa_l3_production_only() {
2006        assert_error_contains(
2007            &norm("---\nstatus: approved\nmaturity: mvp\nprovenance_level: slsa-l3\n---\n"),
2008            "slsa-l3' is production-only",
2009        );
2010    }
2011
2012    #[test]
2013    fn floor_registry_requires_valid_license() {
2014        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
2015                    license: Proprietary-Acme\nhealth_badges: [ci, registry, license]\n---\n";
2016        let n = norm(text);
2017        // Both the SPDX-validity error and the registry-needs-license floor fire.
2018        assert_error_contains(&n, "not a valid SPDX expression");
2019        assert!(n
2020            .problems
2021            .errors
2022            .iter()
2023            .any(|e| e.contains("floor: a target has a registry")));
2024    }
2025
2026    #[test]
2027    fn floor_badge_without_producer() {
2028        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [python]\n\
2029                    health_badges: [ci, coverage]\n---\n";
2030        assert_error_contains(&norm(text), "health_badge 'coverage' has no producer");
2031    }
2032
2033    #[test]
2034    fn floor_schema_version_too_new() {
2035        assert_error_contains(
2036            &norm("---\nschema_version: 99\nstatus: approved\nmaturity: mvp\n---\n"),
2037            "exceeds what this tool knows",
2038        );
2039    }
2040
2041    #[test]
2042    fn floor_fragment_dir_escape() {
2043        let text = "---\nstatus: approved\nmaturity: mvp\n\
2044                    changelog:\n  mode: fragment\n  source: manual\n  fragment_dir: /etc\n---\n";
2045        assert_error_contains(&norm(text), "must be a relative path inside the repo");
2046    }
2047
2048    #[test]
2049    fn floor_fragment_dir_escape_relative_root() {
2050        // Regression: with a *relative* repo root (the CLI's `--repo-root .`),
2051        // a `../`-escaping fragment_dir must still be rejected. The earlier
2052        // join-then-contain check accepted it because a `.` root normalizes to
2053        // an empty path that prefixes everything.
2054        let text = "---\nstatus: approved\nmaturity: mvp\n\
2055                    changelog:\n  mode: fragment\n  source: manual\n  fragment_dir: ../etc\n---\n";
2056        let n = normalize_str(text, Path::new("."), &FakeFs::empty());
2057        assert_error_contains(&n, "must be a relative path inside the repo");
2058    }
2059
2060    #[test]
2061    fn path_inside_repo_verdicts() {
2062        // Inside — plain and `.`/`..`-collapsing relative paths that stay in.
2063        assert!(path_inside_repo("changelog/fragments"));
2064        assert!(path_inside_repo("./changelog/fragments"));
2065        assert!(path_inside_repo("a/../fragments"));
2066        assert!(path_inside_repo("")); // the repo root itself
2067                                       // Escapes — absolute, leading `..`, and mid-path `..` that pops out.
2068        assert!(!path_inside_repo("/etc"));
2069        assert!(!path_inside_repo("../etc"));
2070        assert!(!path_inside_repo("a/../../etc"));
2071    }
2072
2073    #[test]
2074    fn unknown_fields_preserved_and_warned() {
2075        let text =
2076            "---\nstatus: approved\nmaturity: mvp\nroadmap_url: https://example.com/x\n---\n";
2077        let n = norm(text);
2078        assert!(n.is_valid());
2079        assert_eq!(
2080            n.contract
2081                .extra_fields
2082                .get("roadmap_url")
2083                .and_then(|v| v.as_str()),
2084            Some("https://example.com/x")
2085        );
2086        assert!(n
2087            .problems
2088            .warnings
2089            .iter()
2090            .any(|w| w.contains("roadmap_url") && w.contains("forward-compat")));
2091    }
2092
2093    #[test]
2094    fn duplicate_key_is_rejected() {
2095        assert_error_contains(
2096            &norm("---\nstatus: approved\nstatus: draft\nmaturity: mvp\n---\n"),
2097            "invalid YAML",
2098        );
2099    }
2100
2101    #[test]
2102    fn missing_frontmatter_is_rejected() {
2103        assert_error_contains(&norm("no frontmatter here\n"), "frontmatter missing");
2104    }
2105
2106    #[test]
2107    fn unclosed_frontmatter_is_rejected() {
2108        assert_error_contains(&norm("---\nstatus: approved\n"), "frontmatter not closed");
2109    }
2110
2111    #[test]
2112    fn invalid_enum_records_error_and_continues() {
2113        // A bad status AND a bad maturity: both surface (multi-error collection).
2114        let n = norm("---\nstatus: bogus\nmaturity: alsobogus\n---\n");
2115        assert!(n.problems.errors.iter().any(|e| e.contains("status")));
2116        assert!(n.problems.errors.iter().any(|e| e.contains("maturity")));
2117    }
2118
2119    #[test]
2120    fn fragment_dir_present_suppresses_advisory() {
2121        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
2122                    changelog:\n  mode: fragment\n  source: manual\n---\n";
2123        // The default fragment dir exists → no advisory warning.
2124        let fs = FakeFs::with_dirs(["/repo/changelog/fragments"]);
2125        let n = norm_with(text, &fs);
2126        assert!(n.is_valid());
2127        assert!(
2128            !n.problems
2129                .warnings
2130                .iter()
2131                .any(|w| w.contains("does not exist yet")),
2132            "advisory should be suppressed when the dir exists: {:?}",
2133            n.problems.warnings
2134        );
2135    }
2136
2137    #[test]
2138    fn serializes_to_schema_v4_shape() {
2139        let json =
2140            serde_json::to_value(&norm("---\nstatus: approved\nmaturity: mvp\n---\n").contract)
2141                .unwrap();
2142        // Spot-check the §4 top-level keys that consumers read.
2143        for key in [
2144            "schema_version",
2145            "status",
2146            "maturity",
2147            "ecosystems",
2148            "targets",
2149            "distributions",
2150            "versioning",
2151            "versioning_pattern",
2152            "changelog",
2153            "conventional_commits",
2154            "release",
2155            "contribution_provenance",
2156            "provenance_level",
2157            "dependency_bot",
2158            "health_badges",
2159            "license",
2160            "docs_site",
2161            "warnings",
2162        ] {
2163            assert!(json.get(key).is_some(), "missing §4 key {key}");
2164        }
2165        assert!(json["versioning_pattern"].is_null());
2166        // A registry-only contract carries an explicit empty `distributions: []` —
2167        // the collection is always a JSON array (v2 canonical shape).
2168        assert_eq!(json["distributions"], serde_json::json!([]));
2169        // An EMPTY `extra_fields` is OMITTED from canonical JSON (Option A,
2170        // `skip_serializing_if`): a contract with no unknown keys carries no
2171        // `extra_fields` key at all. It reappears only when populated — see
2172        // [`empty_extra_fields_absent_populated_present`].
2173        assert!(
2174            json.get("extra_fields").is_none(),
2175            "empty extra_fields must be absent, got {:?}",
2176            json.get("extra_fields")
2177        );
2178    }
2179
2180    /// Option A (omit-when-empty), asserted SYMMETRICALLY on both the top-level
2181    /// [`Contract::extra_fields`] and the nested [`Distribution::extra_fields`]:
2182    /// an empty map is ABSENT from canonical JSON, a populated map is PRESENT and
2183    /// byte-for-shape unchanged from before the `skip_serializing_if`.
2184    #[test]
2185    fn empty_extra_fields_absent_populated_present() {
2186        // Empty (both levels): a contract with a distribution but no unknown keys.
2187        let empty = serde_json::to_value(
2188            norm(
2189                "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2190                 distribution:\n  adapter: cargo-dist\n---\n",
2191            )
2192            .contract,
2193        )
2194        .unwrap();
2195        assert!(
2196            empty.get("extra_fields").is_none(),
2197            "empty top-level extra_fields must be absent"
2198        );
2199        assert!(
2200            empty["distributions"][0].get("extra_fields").is_none(),
2201            "empty nested extra_fields must be absent"
2202        );
2203
2204        // Populated (both levels): an unknown top-level key and an unknown
2205        // distribution key are preserved and PRESENT.
2206        let populated = serde_json::to_value(
2207            norm(
2208                "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2209                 roadmap_url: https://example.com/roadmap\n\
2210                 distribution:\n  adapter: cargo-dist\n  future_x: 1\n---\n",
2211            )
2212            .contract,
2213        )
2214        .unwrap();
2215        assert_eq!(
2216            populated["extra_fields"]["roadmap_url"],
2217            "https://example.com/roadmap"
2218        );
2219        assert_eq!(populated["distributions"][0]["extra_fields"]["future_x"], 1);
2220    }
2221
2222    // ── distribution (cargo-dist binary layer) ───────────────────────────────
2223
2224    /// A registry-only contract has no distribution: it normalizes clean and
2225    /// `distributions` is empty.
2226    #[test]
2227    fn registry_only_contract_has_no_distribution() {
2228        let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract;
2229        assert!(c.distributions.is_empty());
2230        assert_eq!(c.targets.len(), 1);
2231        assert_eq!(c.targets[0].registry, Registry::CratesIo);
2232    }
2233
2234    /// A cargo-dist repo: a `distribution` block (binaries + shell/Homebrew
2235    /// installers + a tap) coexisting with a crates.io registry target.
2236    #[test]
2237    fn cargo_dist_distribution_coexists_with_registry() {
2238        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2239                    targets:\n  - {ecosystem: rust, package: issuectl, registry: crates.io, adapter: cargo-publish}\n\
2240                    distribution:\n  adapter: cargo-dist\n  installers: [shell, homebrew]\n  \
2241                    homebrew_tap: jarimustonen/homebrew-issuectl\n---\n";
2242        let n = norm(text);
2243        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2244        let c = n.contract;
2245        // The registry publish is still a Target.
2246        assert_eq!(c.targets.len(), 1);
2247        assert_eq!(c.targets[0].registry, Registry::CratesIo);
2248        assert_eq!(c.targets[0].adapter, Adapter::CargoPublish);
2249        // The binary layer is the Distribution block.
2250        let d = c
2251            .distributions
2252            .into_iter()
2253            .next()
2254            .expect("distribution present");
2255        assert_eq!(d.adapter, DistributionAdapter::CargoDist);
2256        assert!(d.gh_releases); // default true
2257        assert_eq!(d.installers, vec![Installer::Shell, Installer::Homebrew]);
2258        assert_eq!(
2259            d.homebrew_tap.as_deref(),
2260            Some("jarimustonen/homebrew-issuectl")
2261        );
2262    }
2263
2264    /// Round-trip: the serialized JSON shape a downstream `/oss-*` member reads.
2265    #[test]
2266    fn distribution_json_round_trip_shape() {
2267        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2268                    distribution:\n  adapter: cargo-dist\n  gh_releases: true\n  \
2269                    installers: [shell, homebrew]\n  homebrew_tap: jarimustonen/homebrew-issuectl\n---\n";
2270        let json = serde_json::to_value(&norm(text).contract).unwrap();
2271        let d = &json["distributions"][0];
2272        assert_eq!(d["adapter"], "cargo-dist");
2273        assert_eq!(d["gh_releases"], true);
2274        assert_eq!(d["installers"], serde_json::json!(["shell", "homebrew"]));
2275        assert_eq!(d["homebrew_tap"], "jarimustonen/homebrew-issuectl");
2276        // A bare (singular) block carries a `null` association key.
2277        assert!(d["package"].is_null());
2278    }
2279
2280    // ── homebrew cross-field consistency floors (truth table) ────────────────
2281
2282    /// Build a production contract exercising the three homebrew signals: a
2283    /// configured `tap`, an `installer` producer (a `homebrew` installer), and a
2284    /// `tap_target` producer (a `homebrew`-registry target with adapter
2285    /// `homebrew-tap`). A crates.io target is always present so the contract has a
2286    /// licensed publishable target.
2287    fn hb_case(tap: bool, installer: bool, tap_target: bool) -> String {
2288        let mut fm = String::from(
2289            "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
2290             - {ecosystem: rust, package: ossctl, registry: crates.io, adapter: cargo-publish}\n",
2291        );
2292        if tap_target {
2293            fm.push_str(
2294                "  - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: homebrew-tap}\n",
2295            );
2296        }
2297        // A distribution block exists whenever we need to express an installer
2298        // producer or a configured tap; otherwise the contract is registry-only.
2299        if installer || tap {
2300            fm.push_str("distribution:\n  adapter: cargo-dist\n");
2301            if installer {
2302                fm.push_str("  installers: [homebrew]\n");
2303            } else {
2304                fm.push_str("  installers: [shell]\n");
2305            }
2306            if tap {
2307                fm.push_str("  homebrew_tap: owner/tap\n");
2308            }
2309        }
2310        fm.push_str("---\n");
2311        fm
2312    }
2313
2314    /// The full 8-row truth table (tap × installer-producer × tap-target-producer).
2315    /// Each row asserts the accept/reject verdict; the floor/advisory messages are
2316    /// pinned in the focused tests below.
2317    #[test]
2318    fn homebrew_truth_table_all_eight_rows() {
2319        // (tap, installer, tap_target, expect_valid)
2320        let rows = [
2321            (false, false, false, true), // 1: nothing homebrew → clean
2322            (false, false, true, false), // 2: tap-target, no tap → missing-tap floor
2323            (false, true, false, false), // 3: installer, no tap → per-block floor
2324            (false, true, true, false),  // 4: both producers, no tap → floors
2325            (true, false, false, true),  // 5: tap, no producer → dead-tap advisory (valid)
2326            (true, false, true, true),   // 6: tap + tap-target → well-formed (ossctl's case)
2327            (true, true, false, true),   // 7: tap + installer → well-formed (cargo-dist)
2328            (true, true, true, false),   // 8: tap + both producers → double-publish floor
2329        ];
2330        for (tap, installer, tap_target, expect_valid) in rows {
2331            let n = norm(&hb_case(tap, installer, tap_target));
2332            assert_eq!(
2333                n.is_valid(),
2334                expect_valid,
2335                "row (tap={tap}, installer={installer}, tap_target={tap_target}) expected \
2336                 valid={expect_valid}; errors were {:?}",
2337                n.problems.errors
2338            );
2339        }
2340    }
2341
2342    /// Row 2: a `homebrew-tap` target with no tap anywhere is a hard error (the
2343    /// target-side counterpart of the per-block installer-without-tap floor).
2344    #[test]
2345    fn homebrew_tap_target_without_tap_is_a_floor() {
2346        assert_error_contains(
2347            &norm(&hb_case(false, false, true)),
2348            "generates a formula but no distribution sets homebrew_tap",
2349        );
2350    }
2351
2352    /// Row 8: an installer producer AND a `homebrew-tap` target both push a formula
2353    /// to the tap — the double-publish collision is a hard error.
2354    #[test]
2355    fn homebrew_double_publish_is_a_floor() {
2356        assert_error_contains(
2357            &norm(&hb_case(true, true, true)),
2358            "they would collide; keep exactly one homebrew formula producer",
2359        );
2360    }
2361
2362    /// Row 5: a configured tap with neither producer is dead config — an advisory
2363    /// warning, and the contract still normalizes clean.
2364    #[test]
2365    fn homebrew_dead_tap_is_an_advisory_not_a_floor() {
2366        let n = norm(&hb_case(true, false, false));
2367        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2368        assert!(
2369            n.problems
2370                .warnings
2371                .iter()
2372                .any(|w| w.contains("the tap will never be updated")),
2373            "expected dead-tap advisory, warnings were {:?}",
2374            n.problems.warnings
2375        );
2376    }
2377
2378    /// Row 6: a `homebrew-tap` target with a configured tap and no installer
2379    /// producer is the well-formed case (ossctl's own shape) — clean, no advisory.
2380    #[test]
2381    fn homebrew_tap_target_with_tap_is_clean() {
2382        let n = norm(&hb_case(true, false, true));
2383        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2384        assert!(
2385            !n.problems
2386                .warnings
2387                .iter()
2388                .any(|w| w.contains("the tap will never be updated")),
2389            "unexpected dead-tap advisory: {:?}",
2390            n.problems.warnings
2391        );
2392    }
2393
2394    /// registry/adapter compatibility: a `homebrew`-registry target with a
2395    /// non-homebrew adapter (here the ecosystem default via an explicit `manual`)
2396    /// is a hard error — it has no homebrew formula path.
2397    #[test]
2398    fn homebrew_registry_requires_homebrew_adapter() {
2399        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
2400                    - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: manual}\n\
2401                    distribution:\n  adapter: cargo-dist\n  homebrew_tap: owner/tap\n---\n";
2402        assert_error_contains(
2403            &norm(text),
2404            "requires adapter 'homebrew-tap' (personal tap) or 'homebrew-core'",
2405        );
2406    }
2407
2408    /// A `homebrew-core` target is a valid homebrew adapter and needs NO personal
2409    /// tap (it bumps the central formula) — it is neither a missing-tap floor nor a
2410    /// dead-tap advisory, and does not collide with a `homebrew` installer.
2411    #[test]
2412    fn homebrew_core_target_needs_no_tap() {
2413        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
2414                    - {ecosystem: rust, package: ossctl, registry: crates.io, adapter: cargo-publish}\n  \
2415                    - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: homebrew-core}\n---\n";
2416        let n = norm(text);
2417        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2418    }
2419
2420    /// registry/adapter compat, OMITTED adapter: a `homebrew`-registry target with
2421    /// no adapter resolves to the ecosystem default (`cargo-publish` for rust),
2422    /// which is non-homebrew — so it hits the same floor. The normalizer never
2423    /// registry-defaults a homebrew target to `homebrew-tap` (that would silently
2424    /// choose personal-tap publication over a homebrew-core PR); the author must
2425    /// spell the adapter. This locks the omitted-adapter path, not just explicit
2426    /// `manual`.
2427    #[test]
2428    fn homebrew_registry_omitted_adapter_is_a_floor() {
2429        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
2430                    - {ecosystem: rust, package: ossctl, registry: homebrew}\n---\n";
2431        assert_error_contains(
2432            &norm(text),
2433            "requires adapter 'homebrew-tap' (personal tap) or 'homebrew-core'",
2434        );
2435    }
2436
2437    /// The homebrew cross-field check reads the plural `distributions:` (Vec) path,
2438    /// not only the singular back-compat mapping: a one-entry `distributions:` list
2439    /// carrying the tap satisfies a `homebrew-tap` target (row 6 via the Vec shape).
2440    #[test]
2441    fn homebrew_tap_target_satisfied_via_plural_distributions() {
2442        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n  \
2443                    - {ecosystem: rust, package: ossctl, registry: crates.io, adapter: cargo-publish}\n  \
2444                    - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: homebrew-tap}\n\
2445                    distributions:\n  \
2446                    - {package: ossctl, adapter: cargo-dist, installers: [shell], homebrew_tap: owner/tap}\n---\n";
2447        let n = norm(text);
2448        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2449        assert!(
2450            !n.problems
2451                .warnings
2452                .iter()
2453                .any(|w| w.contains("the tap will never be updated")),
2454            "unexpected dead-tap advisory: {:?}",
2455            n.problems.warnings
2456        );
2457    }
2458
2459    // ── monorepo: Vec<Distribution> + per-package association ─────────────────
2460
2461    /// Back-compat: a bare singular `distribution:` mapping deserializes as a
2462    /// one-element `distributions` list with a `null` package — the v1 author
2463    /// changes nothing.
2464    #[test]
2465    fn singular_distribution_parses_as_one_element_list() {
2466        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2467                    distribution:\n  adapter: cargo-dist\n---\n";
2468        let c = norm(text).contract;
2469        assert_eq!(c.distributions.len(), 1);
2470        assert_eq!(c.distributions[0].package, None);
2471        assert_eq!(c.distributions[0].adapter, DistributionAdapter::CargoDist);
2472    }
2473
2474    /// A monorepo: a plural `distributions:` sequence, each entry tagged with the
2475    /// package it builds, parses with the per-package association preserved in
2476    /// order (each distribution keeps its own installers/tap).
2477    #[test]
2478    fn plural_distributions_parse_with_per_package_association() {
2479        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2480                    targets:\n  - {ecosystem: rust, package: alpha, registry: crates.io}\n  \
2481                    - {ecosystem: rust, package: beta, registry: crates.io}\n\
2482                    distributions:\n  - {package: alpha, adapter: cargo-dist, installers: [shell]}\n  \
2483                    - {package: beta, adapter: cargo-dist, installers: [homebrew], homebrew_tap: owner/tap}\n---\n";
2484        let n = norm(text);
2485        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2486        let d = n.contract.distributions;
2487        assert_eq!(d.len(), 2);
2488        assert_eq!(d[0].package.as_deref(), Some("alpha"));
2489        assert_eq!(d[0].installers, vec![Installer::Shell]);
2490        assert_eq!(d[1].package.as_deref(), Some("beta"));
2491        assert_eq!(d[1].homebrew_tap.as_deref(), Some("owner/tap"));
2492    }
2493
2494    /// Canonical JSON round-trips for BOTH shapes: the emitted `distributions`
2495    /// array re-feeds as YAML frontmatter and normalizes to the same list — the
2496    /// single (bare `distribution:`) and the monorepo (`distributions:`) cases.
2497    #[test]
2498    fn distributions_canonical_json_round_trip() {
2499        for text in [
2500            "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2501             distribution:\n  adapter: cargo-dist\n  installers: [shell]\n---\n",
2502            "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2503             targets:\n  - {ecosystem: rust, package: a, registry: crates.io}\n  \
2504             - {ecosystem: rust, package: b, registry: crates.io}\n\
2505             distributions:\n  - {package: a, adapter: cargo-dist}\n  \
2506             - {package: b, adapter: goreleaser}\n---\n",
2507        ] {
2508            let first = norm(text).contract;
2509            assert!(!first.distributions.is_empty());
2510            // Re-feed the canonical JSON as the frontmatter of a fresh document.
2511            let json = serde_json::to_value(&first).unwrap();
2512            let refed = format!("---\n{}---\n", serde_yaml::to_string(&json).unwrap());
2513            let second = norm(&refed).contract;
2514            assert_eq!(
2515                first.distributions, second.distributions,
2516                "round-trip drift for: {text}"
2517            );
2518        }
2519    }
2520
2521    /// Declaring BOTH `distribution:` and `distributions:` is ambiguous → error.
2522    #[test]
2523    fn both_distribution_keys_is_an_error() {
2524        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2525                    distribution:\n  adapter: cargo-dist\n\
2526                    distributions:\n  - {package: a, adapter: cargo-dist}\n---\n";
2527        assert_error_contains(&norm(text), "not both");
2528    }
2529
2530    /// A monorepo (≥2 distributions) with an entry missing `package` → floor error
2531    /// (the entries would be indistinguishable).
2532    #[test]
2533    fn multi_distribution_missing_package_is_a_floor_error() {
2534        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2535                    targets:\n  - {ecosystem: rust, package: a, registry: crates.io}\n\
2536                    distributions:\n  - {package: a, adapter: cargo-dist}\n  \
2537                    - {adapter: cargo-dist}\n---\n";
2538        assert_error_contains(&norm(text), "must name the package it builds");
2539    }
2540
2541    /// A monorepo with a duplicate `package` across distributions → floor error.
2542    #[test]
2543    fn multi_distribution_duplicate_package_is_a_floor_error() {
2544        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2545                    distributions:\n  - {package: dup, adapter: cargo-dist}\n  \
2546                    - {package: dup, adapter: goreleaser}\n---\n";
2547        assert_error_contains(&norm(text), "distinct package");
2548    }
2549
2550    /// A v1 document (explicit `schema_version: 1`, singular `distribution:`)
2551    /// normalizes to the v2 canonical shape AND is re-labeled `schema_version: 2` —
2552    /// never a v2 body stamped with a v1 number. The tool reads v1, emits v2.
2553    #[test]
2554    fn v1_document_is_relabeled_to_current_schema_version_on_emit() {
2555        let text = "---\nschema_version: 1\nstatus: approved\nmaturity: production\n\
2556                    ecosystems: [rust]\n\
2557                    distribution:\n  adapter: cargo-dist\n---\n";
2558        let n = norm(text);
2559        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2560        // Emitted version is the current one, not the declared 1.
2561        assert_eq!(n.contract.schema_version, 2);
2562        let json = serde_json::to_value(&n.contract).unwrap();
2563        assert_eq!(json["schema_version"], 2);
2564        // …and the shape is the v2 `distributions` array (the singular key parsed).
2565        assert_eq!(json["distributions"].as_array().map(Vec::len), Some(1));
2566    }
2567
2568    /// A whitespace-padded `package` is trimmed before storing — so `"  alpha "`
2569    /// and `"alpha"` are the SAME package to the uniqueness floor and association,
2570    /// not two distinct ones that would slip past the dup-check.
2571    #[test]
2572    fn distribution_package_is_trimmed() {
2573        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2574                    distributions:\n  - {package: '  alpha ', adapter: cargo-dist}\n  \
2575                    - {package: alpha, adapter: goreleaser}\n---\n";
2576        // The two trimmed packages collide → the duplicate-package floor fires.
2577        assert_error_contains(&norm(text), "distinct package");
2578    }
2579
2580    /// A single distribution MAY carry a `package` (no floor below the ≥2
2581    /// threshold) — the association key is optional, not forbidden, for one block.
2582    #[test]
2583    fn single_distribution_may_carry_a_package() {
2584        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2585                    targets:\n  - {ecosystem: rust, package: solo, registry: crates.io}\n\
2586                    distributions:\n  - {package: solo, adapter: cargo-dist}\n---\n";
2587        let n = norm(text);
2588        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2589        assert_eq!(n.contract.distributions[0].package.as_deref(), Some("solo"));
2590    }
2591
2592    /// Forward-compat: an unknown key inside the `distribution` block is preserved
2593    /// under `distribution.extra_fields` (not dropped) and survives a
2594    /// parse→serialize round-trip, mirroring the top-level `extra_fields` capture.
2595    /// A warning reports it once; the known distribution keys are unaffected.
2596    #[test]
2597    fn distribution_unknown_subkey_preserved_and_warned() {
2598        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2599                    distribution:\n  adapter: cargo-dist\n  gh_releases: true\n  \
2600                    future_signing: {enabled: true, kms_key: alias/oss}\n---\n";
2601        let n = norm(text);
2602        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2603        let d = n
2604            .contract
2605            .clone()
2606            .distributions
2607            .into_iter()
2608            .next()
2609            .expect("distribution present");
2610        // The unknown sub-key is captured, with its nested value intact.
2611        assert_eq!(
2612            d.extra_fields
2613                .get("future_signing")
2614                .and_then(|v| v.get("kms_key"))
2615                .and_then(|v| v.as_str()),
2616            Some("alias/oss")
2617        );
2618        // Known keys are untouched by the capture.
2619        assert_eq!(d.adapter, DistributionAdapter::CargoDist);
2620        assert!(d.gh_releases);
2621        // It round-trips through the serialized JSON downstream members read.
2622        let json = serde_json::to_value(&n.contract).unwrap();
2623        assert_eq!(
2624            json["distributions"][0]["extra_fields"]["future_signing"]["enabled"],
2625            serde_json::json!(true)
2626        );
2627        // Reported once, scoped to the block, naming the key.
2628        assert!(
2629            n.problems.warnings.iter().any(|w| {
2630                w.contains("unknown distribution field(s) preserved")
2631                    && w.contains("future_signing")
2632            }),
2633            "expected a scoped forward-compat warning: {:?}",
2634            n.problems.warnings
2635        );
2636    }
2637
2638    // ── installer ↔ platform cross-check (warning, not a floor) ──────────────
2639
2640    /// `installers: [msi]` with no Windows triple in `platforms` warns — the MSI
2641    /// installer points at a binary the release never builds. Still valid (warning,
2642    /// not error): the contract is internally consistent, just wasteful.
2643    #[test]
2644    fn msi_installer_without_windows_platform_warns() {
2645        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2646                    distribution:\n  adapter: cargo-dist\n  installers: [msi]\n  \
2647                    platforms: [x86_64-apple-darwin, x86_64-unknown-linux-musl]\n---\n";
2648        let n = norm(text);
2649        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2650        assert!(
2651            n.problems
2652                .warnings
2653                .iter()
2654                .any(|w| w.contains("includes 'msi'") && w.contains("no Windows")),
2655            "expected an msi/Windows cross-check warning: {:?}",
2656            n.problems.warnings
2657        );
2658    }
2659
2660    /// `installers: [msi]` WITH a Windows triple present → no cross-check warning.
2661    #[test]
2662    fn msi_installer_with_windows_platform_no_warning() {
2663        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2664                    distribution:\n  adapter: cargo-dist\n  installers: [msi]\n  \
2665                    platforms: [x86_64-pc-windows-msvc]\n---\n";
2666        let n = norm(text);
2667        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2668        assert!(
2669            !n.problems.warnings.iter().any(|w| w.contains("'msi'")),
2670            "unexpected msi cross-check warning: {:?}",
2671            n.problems.warnings
2672        );
2673    }
2674
2675    /// `installers: [homebrew]` with NEITHER a macOS nor a Linux triple warns —
2676    /// the generated formula has nothing to install. (A Windows-only platform set
2677    /// is the only way to strand a `homebrew` installer, since Homebrew serves
2678    /// both macOS and Linux.)
2679    #[test]
2680    fn homebrew_installer_without_darwin_or_linux_warns() {
2681        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2682                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
2683                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
2684                    platforms: [x86_64-pc-windows-msvc]\n---\n";
2685        let n = norm(text);
2686        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2687        assert!(
2688            n.problems
2689                .warnings
2690                .iter()
2691                .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
2692            "expected a homebrew/(macOS|Linux) cross-check warning: {:?}",
2693            n.problems.warnings
2694        );
2695    }
2696
2697    /// `installers: [homebrew]` is satisfied by a LINUX triple alone (Linuxbrew) —
2698    /// no darwin triple required. The chosen interpretation: homebrew needs macOS
2699    /// OR Linux, so a Linux-only platform set is coherent, not a warning.
2700    #[test]
2701    fn homebrew_installer_with_linux_only_no_warning() {
2702        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2703                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
2704                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
2705                    platforms: [x86_64-unknown-linux-musl]\n---\n";
2706        let n = norm(text);
2707        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2708        assert!(
2709            !n.problems.warnings.iter().any(|w| w.contains("'homebrew'")),
2710            "unexpected homebrew cross-check warning for a Linux-only set: {:?}",
2711            n.problems.warnings
2712        );
2713    }
2714
2715    /// npm and shell installers are OS-agnostic: even a platform set that would
2716    /// strand an msi (no Windows) never warns for them.
2717    #[test]
2718    fn npm_and_shell_installers_never_cross_check_warn() {
2719        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust, node]\n\
2720                    distribution:\n  adapter: cargo-dist\n  installers: [shell, npm]\n  \
2721                    platforms: [x86_64-apple-darwin]\n---\n";
2722        let n = norm(text);
2723        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2724        assert!(
2725            !n.problems
2726                .warnings
2727                .iter()
2728                .any(|w| w.contains("nothing to install")),
2729            "OS-agnostic installers must not cross-check warn: {:?}",
2730            n.problems.warnings
2731        );
2732    }
2733
2734    /// A coherent installer/platform set (msi + Windows, homebrew + darwin) emits
2735    /// no cross-check warning.
2736    #[test]
2737    fn coherent_installer_platform_set_no_warning() {
2738        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2739                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew, msi]\n  \
2740                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
2741                    platforms: [aarch64-apple-darwin, x86_64-pc-windows-msvc]\n---\n";
2742        let n = norm(text);
2743        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2744        assert!(
2745            !n.problems
2746                .warnings
2747                .iter()
2748                .any(|w| w.contains("nothing to install")),
2749            "coherent set must not warn: {:?}",
2750            n.problems.warnings
2751        );
2752    }
2753
2754    /// ossctl's own contract shape — installers `[shell, powershell]` with a
2755    /// platform set spanning Windows + macOS + Linux — produces no cross-check
2756    /// warning (both installers are agnostic here, and every OS is covered anyway).
2757    #[test]
2758    fn ossctl_own_contract_shape_no_cross_check_warning() {
2759        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2760                    distribution:\n  adapter: cargo-dist\n  installers: [shell, powershell]\n  \
2761                    platforms: [aarch64-apple-darwin, x86_64-apple-darwin, \
2762                    x86_64-unknown-linux-musl, aarch64-unknown-linux-musl, \
2763                    x86_64-pc-windows-msvc]\n---\n";
2764        let n = norm(text);
2765        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2766        assert!(
2767            !n.problems
2768                .warnings
2769                .iter()
2770                .any(|w| w.contains("nothing to install")),
2771            "ossctl's own shape must not cross-check warn: {:?}",
2772            n.problems.warnings
2773        );
2774    }
2775
2776    /// `installers: [msi]` with `platforms` OMITTED warns: the default set
2777    /// (macOS + Linux) carries no Windows triple, so the MSI installs nothing.
2778    /// This is the common footgun — the author added msi but never listed a
2779    /// Windows target.
2780    #[test]
2781    fn msi_installer_with_defaulted_platforms_warns() {
2782        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2783                    distribution:\n  adapter: cargo-dist\n  installers: [msi]\n---\n";
2784        let n = norm(text);
2785        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2786        assert!(
2787            n.problems
2788                .warnings
2789                .iter()
2790                .any(|w| w.contains("includes 'msi'") && w.contains("no Windows")),
2791            "expected an msi/Windows warning against the defaulted platform set: {:?}",
2792            n.problems.warnings
2793        );
2794    }
2795
2796    /// `installers: [msi]` is satisfied by a `*-windows-gnu` triple just as by
2797    /// `*-windows-msvc` — both target the Windows OS. No warning.
2798    #[test]
2799    fn msi_installer_with_windows_gnu_no_warning() {
2800        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2801                    distribution:\n  adapter: cargo-dist\n  installers: [msi]\n  \
2802                    platforms: [x86_64-pc-windows-gnu]\n---\n";
2803        let n = norm(text);
2804        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2805        assert!(
2806            !n.problems.warnings.iter().any(|w| w.contains("'msi'")),
2807            "windows-gnu must satisfy msi: {:?}",
2808            n.problems.warnings
2809        );
2810    }
2811
2812    /// `installers: [homebrew]` with an ANDROID-only platform set warns: Android
2813    /// triples (`aarch64-linux-android`) carry `linux` in the *vendor* slot but an
2814    /// `android` OS component — Homebrew/Linuxbrew does not serve Android, so the
2815    /// formula has nothing to install. Regression guard for the positional
2816    /// `triple_os` OS-component match (vs a naive any-component `== "linux"`).
2817    #[test]
2818    fn homebrew_installer_with_android_only_warns() {
2819        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2820                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
2821                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
2822                    platforms: [aarch64-linux-android]\n---\n";
2823        let n = norm(text);
2824        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2825        assert!(
2826            n.problems
2827                .warnings
2828                .iter()
2829                .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
2830            "Android-only must strand a homebrew installer: {:?}",
2831            n.problems.warnings
2832        );
2833    }
2834
2835    /// `installers: [homebrew]` with an APPLE-iOS-only set warns: `*-apple-ios`
2836    /// carries an `ios` OS component, not `darwin`, so it is not a macOS target and
2837    /// Homebrew serves neither iOS nor (here) Linux.
2838    #[test]
2839    fn homebrew_installer_with_apple_ios_only_warns() {
2840        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2841                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
2842                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
2843                    platforms: [aarch64-apple-ios]\n---\n";
2844        let n = norm(text);
2845        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2846        assert!(
2847            n.problems
2848                .warnings
2849                .iter()
2850                .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
2851            "apple-ios must not satisfy homebrew's macOS need: {:?}",
2852            n.problems.warnings
2853        );
2854    }
2855
2856    /// `installers: [homebrew]` with a macOS-only set (no Linux) is coherent — the
2857    /// isolated darwin case, distinct from the Linux-only test above.
2858    #[test]
2859    fn homebrew_installer_with_macos_only_no_warning() {
2860        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2861                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
2862                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
2863                    platforms: [aarch64-apple-darwin]\n---\n";
2864        let n = norm(text);
2865        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2866        assert!(
2867            !n.problems.warnings.iter().any(|w| w.contains("'homebrew'")),
2868            "macOS-only must satisfy homebrew: {:?}",
2869            n.problems.warnings
2870        );
2871    }
2872
2873    /// Two stranded installers → two independent warnings. A wasm-only platform
2874    /// set has no OS component any installer supports, so both `msi` and `homebrew`
2875    /// warn (exactly once each — the installer list is de-duped and canonically
2876    /// ordered).
2877    #[test]
2878    fn both_installers_stranded_warn_once_each() {
2879        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2880                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew, msi]\n  \
2881                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
2882                    platforms: [wasm32-unknown-unknown]\n---\n";
2883        let n = norm(text);
2884        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2885        let msi = n
2886            .problems
2887            .warnings
2888            .iter()
2889            .filter(|w| w.contains("includes 'msi'"))
2890            .count();
2891        let brew = n
2892            .problems
2893            .warnings
2894            .iter()
2895            .filter(|w| w.contains("includes 'homebrew'"))
2896            .count();
2897        assert_eq!((msi, brew), (1, 1), "warnings: {:?}", n.problems.warnings);
2898    }
2899
2900    /// A malformed triple that happens to contain an OS keyword must NOT drive the
2901    /// cross-check: the block has a parse error (uppercase triple), so the advisory
2902    /// is gated off entirely. Otherwise the misspelled `x86_64-PC-WINDOWS-MSVC`
2903    /// would silently "satisfy" msi and the warning would flip once the author
2904    /// fixed the typo.
2905    #[test]
2906    fn malformed_platform_triple_gates_off_cross_check() {
2907        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2908                    distribution:\n  adapter: cargo-dist\n  installers: [msi]\n  \
2909                    platforms: [x86_64-PC-WINDOWS-MSVC]\n---\n";
2910        let n = norm(text);
2911        // The uppercase triple is a hard error → the document is invalid …
2912        assert!(!n.is_valid(), "expected a malformed-triple error");
2913        // … and the cross-check emitted no (misleading) installer/platform warning.
2914        assert!(
2915            !n.problems
2916                .warnings
2917                .iter()
2918                .any(|w| w.contains("nothing to install")),
2919            "cross-check must be gated off while platforms has errors: {:?}",
2920            n.problems.warnings
2921        );
2922    }
2923
2924    /// A distribution block setting EVERY known key carries an empty
2925    /// `extra_fields` map and emits no forward-compat warning — the additive field
2926    /// is shape-neutral for existing contracts. Exercising all of
2927    /// `KNOWN_DISTRIBUTION_KEYS` guards against the allowlist drifting out of sync
2928    /// with the struct (a new known key wrongly captured as "unknown").
2929    #[test]
2930    fn distribution_all_known_keys_has_empty_extra_fields() {
2931        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2932                    distribution:\n  adapter: cargo-dist\n  gh_releases: true\n  \
2933                    installers: [shell, homebrew]\n  homebrew_tap: owner/tap\n  \
2934                    platforms: [x86_64-unknown-linux-musl]\n---\n";
2935        let n = norm(text);
2936        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2937        let d = n
2938            .contract
2939            .distributions
2940            .into_iter()
2941            .next()
2942            .expect("distribution present");
2943        assert!(d.extra_fields.is_empty());
2944        assert!(
2945            !n.problems
2946                .warnings
2947                .iter()
2948                .any(|w| w.contains("unknown distribution field(s) preserved")),
2949            "no forward-compat warning for an all-known-keys block: {:?}",
2950            n.problems.warnings
2951        );
2952    }
2953
2954    /// Top-level and nested `extra_fields` capture are independent: a contract
2955    /// with BOTH an unknown top-level key AND an unknown distribution sub-key
2956    /// populates both maps and warns once for each, with the correct
2957    /// `schema_version` in each message.
2958    #[test]
2959    fn distribution_and_top_level_extra_fields_coexist() {
2960        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2961                    roadmap_url: https://example.com/x\n\
2962                    distribution:\n  adapter: cargo-dist\n  future_x: 1\n---\n";
2963        let n = norm(text);
2964        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2965        let c = n.contract.clone();
2966        assert!(c.extra_fields.contains_key("roadmap_url"));
2967        let d = c
2968            .distributions
2969            .into_iter()
2970            .next()
2971            .expect("distribution present");
2972        assert_eq!(d.extra_fields.get("future_x"), Some(&serde_json::json!(1)));
2973        // Two independent forward-compat warnings, each naming schema_version 2
2974        // (the contract omits schema_version → defaults to KNOWN_SCHEMA_VERSION).
2975        let fc: Vec<&String> = n
2976            .problems
2977            .warnings
2978            .iter()
2979            .filter(|w| w.contains("forward-compat") && w.contains("schema_version 2"))
2980            .collect();
2981        assert_eq!(fc.len(), 2, "expected two versioned warnings: {fc:?}");
2982    }
2983
2984    // ── extra_fields capture hardening ───────────────────────────────────────
2985
2986    /// A non-string top-level mapping key (`42:`, legal YAML) is a STRUCTURAL
2987    /// error, not silently coerced/dropped: distinct non-string keys collapse onto
2988    /// the same JSON key (`42` and `"42"`; every list key onto `<list>`), so
2989    /// preserving them losslessly is impossible — the normalizer rejects instead,
2990    /// keeping the never-drop invariant vacuously.
2991    #[test]
2992    fn non_string_top_level_key_rejected() {
2993        let n = norm("---\nstatus: approved\nmaturity: mvp\n42: answer\n---\n");
2994        assert_error_contains(&n, "must be a string");
2995        assert!(
2996            n.problems.errors.iter().any(|e| e.contains("42")),
2997            "error should name the offending key: {:?}",
2998            n.problems.errors
2999        );
3000    }
3001
3002    /// The nested `distribution` scan rejects the same way, with the block scope in
3003    /// the message.
3004    #[test]
3005    fn non_string_distribution_key_rejected() {
3006        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3007                    distribution:\n  adapter: cargo-dist\n  true: enabled\n---\n";
3008        let n = norm(text);
3009        assert_error_contains(&n, "must be a string");
3010        assert!(
3011            n.problems
3012                .errors
3013                .iter()
3014                .any(|e| e.contains("distribution field key")),
3015            "error should be scoped to the distribution block: {:?}",
3016            n.problems.errors
3017        );
3018    }
3019
3020    /// A known key placed normally is parsed as its field and NOT double-captured
3021    /// into `extra_fields` — the dedupe guarantee (a key is never both a known
3022    /// field and an extra field).
3023    #[test]
3024    fn known_key_not_double_captured() {
3025        let n = norm("---\nstatus: approved\nmaturity: production\necosystems: [rust]\n---\n");
3026        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3027        assert!(!n.contract.extra_fields.contains_key("ecosystems"));
3028        assert!(!n.contract.extra_fields.contains_key("status"));
3029        assert!(n.contract.extra_fields.is_empty());
3030    }
3031
3032    /// The reserved `extra_fields` metadata key is not re-captured into a nested
3033    /// `extra_fields.extra_fields`; its mapping contents are MERGED back, so a
3034    /// hand-authored (or defensively re-fed canonical) block round-trips losslessly
3035    /// rather than being silently dropped. The derived `warnings` key is ignored
3036    /// (regenerated), not preserved — it is not user contract data.
3037    #[test]
3038    fn reserved_extra_fields_block_merged_warnings_ignored() {
3039        let text = "---\nstatus: approved\nmaturity: mvp\n\
3040                    extra_fields:\n  foo: 1\nwarnings:\n  - a prior note\n---\n";
3041        let n = norm(text);
3042        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3043        // `foo` is preserved (merged), not nested and not dropped.
3044        assert_eq!(
3045            n.contract.extra_fields.get("foo"),
3046            Some(&serde_json::json!(1))
3047        );
3048        assert!(!n.contract.extra_fields.contains_key("extra_fields"));
3049        // The stale input `warnings` list is not resurrected into the output.
3050        assert!(
3051            !n.contract
3052                .warnings
3053                .iter()
3054                .any(|w| w.contains("a prior note")),
3055            "input warnings must be regenerated, not preserved: {:?}",
3056            n.contract.warnings
3057        );
3058    }
3059
3060    /// The nested analogue: `distribution.extra_fields` is merged back, not nested
3061    /// and not dropped.
3062    #[test]
3063    fn distribution_reserved_extra_fields_block_merged() {
3064        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3065                    distribution:\n  adapter: cargo-dist\n  extra_fields:\n    foo: 1\n---\n";
3066        let n = norm(text);
3067        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3068        let d = n
3069            .contract
3070            .distributions
3071            .into_iter()
3072            .next()
3073            .expect("distribution present");
3074        assert_eq!(d.extra_fields.get("foo"), Some(&serde_json::json!(1)));
3075        assert!(!d.extra_fields.contains_key("extra_fields"));
3076    }
3077
3078    /// Idempotence: normalizing, serializing the canonical `extra_fields` map, and
3079    /// re-feeding it as an `extra_fields` block yields the identical map — the
3080    /// round-trip the reserve+merge design guarantees (no nesting, no loss).
3081    #[test]
3082    fn extra_fields_round_trip_is_idempotent() {
3083        let first = norm("---\nstatus: approved\nmaturity: mvp\nroadmap_url: https://x/y\n---\n");
3084        assert!(first.is_valid(), "errors: {:?}", first.problems.errors);
3085        assert_eq!(first.contract.extra_fields.len(), 1);
3086        // Feed the captured extra_fields back under the reserved key.
3087        let inner = serde_yaml::to_string(&first.contract.extra_fields).unwrap();
3088        let indented = inner
3089            .lines()
3090            .map(|l| format!("  {l}"))
3091            .collect::<Vec<_>>()
3092            .join("\n");
3093        let text =
3094            format!("---\nstatus: approved\nmaturity: mvp\nextra_fields:\n{indented}\n---\n");
3095        let second = norm(&text);
3096        assert!(second.is_valid(), "errors: {:?}", second.problems.errors);
3097        assert_eq!(second.contract.extra_fields, first.contract.extra_fields);
3098    }
3099
3100    /// A key present BOTH inside the reserved `extra_fields` block AND as a sibling
3101    /// unknown top-level key is an ambiguity error — never a silent overwrite of
3102    /// either value (dedupe: a key resolves to exactly one source).
3103    #[test]
3104    fn extra_fields_block_sibling_collision_is_error() {
3105        let text = "---\nstatus: approved\nmaturity: mvp\n\
3106                    extra_fields:\n  dup: 1\ndup: 2\n---\n";
3107        let n = norm(text);
3108        assert_error_contains(&n, "appears both");
3109    }
3110
3111    /// A reserved `extra_fields` value that is not a mapping is a structural error
3112    /// (it can only carry preserved key/value pairs).
3113    #[test]
3114    fn reserved_extra_fields_non_mapping_is_error() {
3115        let n = norm("---\nstatus: approved\nmaturity: mvp\nextra_fields: nonsense\n---\n");
3116        assert_error_contains(&n, "must be a mapping");
3117    }
3118
3119    /// A contract setting EVERY parsed top-level known key carries an empty
3120    /// `extra_fields` and emits no forward-compat warning — the top-level analogue
3121    /// of `distribution_all_known_keys_has_empty_extra_fields`, guarding
3122    /// [`KNOWN_KEYS`] against drifting out of sync with the [`Contract`] struct (a
3123    /// new field whose key is missing here would be wrongly captured as unknown).
3124    #[test]
3125    fn top_level_all_known_keys_has_empty_extra_fields() {
3126        let text = "---\nschema_version: 1\nstatus: approved\nmaturity: production\n\
3127                    ecosystems: [rust]\n\
3128                    targets:\n  - {ecosystem: rust, package: x, registry: crates.io, adapter: cargo-publish}\n\
3129                    distribution:\n  adapter: cargo-dist\n\
3130                    versioning: semver\n\
3131                    changelog:\n  mode: curated\n  source: manual\n\
3132                    conventional_commits: false\n\
3133                    release:\n  model: gated\n  layout: single\n\
3134                    contribution_provenance: none\n\
3135                    provenance_level: none\n\
3136                    dependency_bot: dependabot\n\
3137                    health_badges: [ci, registry, license]\n\
3138                    license: MIT\n\
3139                    docs_site: none\n---\n";
3140        let n = norm(text);
3141        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3142        assert!(
3143            n.contract.extra_fields.is_empty(),
3144            "unexpected extra_fields (KNOWN_KEYS drift?): {:?}",
3145            n.contract.extra_fields
3146        );
3147        assert!(
3148            !n.problems
3149                .warnings
3150                .iter()
3151                .any(|w| w.contains("forward-compat")),
3152            "no forward-compat warning for an all-known-keys contract: {:?}",
3153            n.problems.warnings
3154        );
3155    }
3156
3157    /// Installers de-dup into canonical order regardless of source order.
3158    #[test]
3159    fn distribution_installers_dedup_canonical_order() {
3160        let text = "---\nstatus: approved\nmaturity: mvp\n\
3161                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew, shell, homebrew]\n  \
3162                    homebrew_tap: owner/tap\n---\n";
3163        let d = norm(text)
3164            .contract
3165            .distributions
3166            .into_iter()
3167            .next()
3168            .unwrap();
3169        assert_eq!(d.installers, vec![Installer::Shell, Installer::Homebrew]);
3170    }
3171
3172    /// A `homebrew` installer without a tap is a floor error.
3173    #[test]
3174    fn distribution_homebrew_installer_requires_tap() {
3175        let text = "---\nstatus: approved\nmaturity: mvp\n\
3176                    distribution:\n  adapter: cargo-dist\n  installers: [shell, homebrew]\n---\n";
3177        assert_error_contains(
3178            &norm(text),
3179            "includes 'homebrew' but no distribution.homebrew_tap",
3180        );
3181    }
3182
3183    /// A malformed tap slug (not `owner/repo`) is rejected AND, because the
3184    /// invalid value substitutes `None`, the homebrew-needs-tap floor still fires
3185    /// — a present-but-invalid tap must not slip a `homebrew` installer through.
3186    #[test]
3187    fn distribution_bad_tap_slug_rejected() {
3188        let text = "---\nstatus: approved\nmaturity: mvp\n\
3189                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
3190                    homebrew_tap: not-a-slug\n---\n";
3191        let n = norm(text);
3192        assert_error_contains(&n, "must be an 'owner/repo' slug");
3193        assert!(
3194            n.problems
3195                .errors
3196                .iter()
3197                .any(|e| e.contains("includes 'homebrew' but no distribution.homebrew_tap")),
3198            "the tap floor must still fire on an invalid (→None) tap: {:?}",
3199            n.problems.errors
3200        );
3201        // The malformed slug never leaks into the built block.
3202        assert_eq!(
3203            n.contract
3204                .distributions
3205                .into_iter()
3206                .next()
3207                .unwrap()
3208                .homebrew_tap,
3209            None
3210        );
3211    }
3212
3213    /// An unknown installer flavor surfaces an error listing the valid set.
3214    #[test]
3215    fn distribution_bad_installer_rejected() {
3216        let text = "---\nstatus: approved\nmaturity: mvp\n\
3217                    distribution:\n  adapter: cargo-dist\n  installers: [snap]\n---\n";
3218        assert_error_contains(&norm(text), "distribution.installers");
3219    }
3220
3221    /// `adapter` is required when a distribution block is present — a bare
3222    /// `distribution: {}` must not silently claim cargo-dist ownership.
3223    #[test]
3224    fn distribution_adapter_is_required() {
3225        assert_error_contains(
3226            &norm("---\nstatus: approved\nmaturity: mvp\ndistribution: {}\n---\n"),
3227            "distribution.adapter is required",
3228        );
3229    }
3230
3231    /// A distribution block ships public binaries — forbidden at maturity 'spike'
3232    /// (mirrors the `release.model: auto`-on-spike floor).
3233    #[test]
3234    fn distribution_forbidden_on_spike() {
3235        let text = "---\nstatus: approved\nmaturity: spike\n\
3236                    distribution:\n  adapter: cargo-dist\n---\n";
3237        assert_error_contains(&norm(text), "not allowed on maturity 'spike'");
3238    }
3239
3240    /// A `homebrew_tap` set with neither a `homebrew` installer nor a
3241    /// `homebrew`-registry target is dead config — a warning, not a floor (the
3242    /// contract is still valid). This is the genuinely-orphaned tap: no consumer
3243    /// exists, so the tap is truly never updated.
3244    #[test]
3245    fn distribution_tap_without_installer_warns() {
3246        let text = "---\nstatus: approved\nmaturity: mvp\n\
3247                    distribution:\n  adapter: cargo-dist\n  installers: [shell]\n  \
3248                    homebrew_tap: owner/tap\n---\n";
3249        let n = norm(text);
3250        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3251        assert!(
3252            n.problems
3253                .warnings
3254                .iter()
3255                .any(|w| w.contains("no formula is generated, so the tap will never be updated")),
3256            "expected dead-tap warning: {:?}",
3257            n.problems.warnings
3258        );
3259    }
3260
3261    /// A `homebrew_tap` set with NO `homebrew` installer but WITH a
3262    /// `homebrew`-registry target (the release engine's homebrew-tap adapter, which
3263    /// pushes the formula in its `dist` phase) is NOT dead config — the tap IS
3264    /// updated by the engine, so the dead-config warning must NOT fire. This is
3265    /// ossctl's own (correct) contract shape.
3266    #[test]
3267    fn distribution_tap_with_homebrew_target_no_warning() {
3268        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
3269                    targets:\n  \
3270                    - {ecosystem: rust, package: ossctl, registry: crates.io, adapter: cargo-publish}\n  \
3271                    - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: homebrew-tap}\n\
3272                    distribution:\n  adapter: cargo-dist\n  installers: [shell]\n  \
3273                    homebrew_tap: owner/tap\n---\n";
3274        let n = norm(text);
3275        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3276        // Stronger than a negative substring match: the whole contract is clean,
3277        // so it must produce NO warnings at all — this also catches any reworded
3278        // dead-tap advisory that a substring check would miss.
3279        assert!(
3280            n.problems.warnings.is_empty(),
3281            "homebrew-target contract must not warn: {:?}",
3282            n.problems.warnings
3283        );
3284    }
3285
3286    /// A goreleaser distribution with no installers and no tap is valid — the
3287    /// block is minimal and forward-compatible.
3288    #[test]
3289    fn distribution_goreleaser_minimal_is_valid() {
3290        let text = "---\nstatus: approved\nmaturity: production\necosystems: [go]\n\
3291                    distribution:\n  adapter: goreleaser\n  gh_releases: true\n---\n";
3292        let n = norm(text);
3293        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3294        let d = n.contract.distributions.into_iter().next().unwrap();
3295        assert_eq!(d.adapter, DistributionAdapter::Goreleaser);
3296        assert!(d.installers.is_empty());
3297        assert_eq!(d.homebrew_tap, None);
3298    }
3299
3300    /// A non-mapping `distribution` value is a structural error.
3301    #[test]
3302    fn distribution_non_mapping_rejected() {
3303        assert_error_contains(
3304            &norm("---\nstatus: approved\nmaturity: mvp\ndistribution: [nope]\n---\n"),
3305            "distribution must be a mapping",
3306        );
3307    }
3308
3309    // ── distribution.platforms (cross-platform target set) ───────────────────
3310
3311    /// Helper: does a platform list contain any Linux triple? The cross-platform
3312    /// install requirement is "at least one Linux triple", inspected via the OS
3313    /// component of the triple (exactly how `audit` will read this field).
3314    fn has_linux(platforms: &[String]) -> bool {
3315        platforms.iter().any(|t| t.contains("-linux"))
3316    }
3317
3318    /// Omitted `platforms` → the cross-platform default (macOS + Linux). The
3319    /// KEYSTONE assertion: the DEFAULT covers Linux, so every distribution that
3320    /// omits the field does (an explicit set is the author's own choice, which the
3321    /// cross-platform `audit` — not this normalizer — checks).
3322    #[test]
3323    fn distribution_platforms_default_is_cross_platform() {
3324        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3325                    distribution:\n  adapter: cargo-dist\n---\n";
3326        let d = norm(text)
3327            .contract
3328            .distributions
3329            .into_iter()
3330            .next()
3331            .expect("distribution present");
3332        assert_eq!(
3333            d.platforms,
3334            vec![
3335                "aarch64-apple-darwin",
3336                "x86_64-apple-darwin",
3337                "aarch64-unknown-linux-musl",
3338                "x86_64-unknown-linux-musl",
3339            ]
3340        );
3341        assert!(
3342            has_linux(&d.platforms),
3343            "the default set MUST contain a Linux triple: {:?}",
3344            d.platforms
3345        );
3346    }
3347
3348    /// An explicit `platforms` list round-trips through normalization and the
3349    /// serialized JSON downstream members read, order + values preserved.
3350    #[test]
3351    fn distribution_platforms_explicit_round_trips() {
3352        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3353                    distribution:\n  adapter: cargo-dist\n  \
3354                    platforms: [x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc]\n---\n";
3355        let n = norm(text);
3356        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3357        let d = n.contract.clone().distributions.into_iter().next().unwrap();
3358        assert_eq!(
3359            d.platforms,
3360            vec!["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
3361        );
3362        let json = serde_json::to_value(&n.contract).unwrap();
3363        assert_eq!(
3364            json["distributions"][0]["platforms"],
3365            serde_json::json!(["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"])
3366        );
3367    }
3368
3369    /// An explicit empty `platforms: []` is a hard error — NOT silently defaulted.
3370    /// Only an omitted/null field yields the cross-platform default; an empty list
3371    /// is a mistake (a distribution with no platforms builds nothing) and, if
3372    /// silently defaulted, would surprise the author and erase the intent the
3373    /// cross-platform audit needs to see.
3374    #[test]
3375    fn distribution_platforms_empty_is_rejected() {
3376        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3377                    distribution:\n  adapter: cargo-dist\n  platforms: []\n---\n";
3378        assert_error_contains(&norm(text), "empty list — omit the key");
3379    }
3380
3381    /// Duplicate triples de-duplicate, preserving first-seen order.
3382    #[test]
3383    fn distribution_platforms_dedup_preserves_order() {
3384        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3385                    distribution:\n  adapter: cargo-dist\n  \
3386                    platforms: [aarch64-apple-darwin, x86_64-apple-darwin, aarch64-apple-darwin]\n---\n";
3387        let d = norm(text)
3388            .contract
3389            .distributions
3390            .into_iter()
3391            .next()
3392            .unwrap();
3393        assert_eq!(
3394            d.platforms,
3395            vec!["aarch64-apple-darwin", "x86_64-apple-darwin"]
3396        );
3397    }
3398
3399    /// A malformed triple is rejected with a message naming the field.
3400    #[test]
3401    fn distribution_platforms_bad_triple_rejected() {
3402        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3403                    distribution:\n  adapter: cargo-dist\n  platforms: [not_a_triple]\n---\n";
3404        assert_error_contains(&norm(text), "is not a well-formed target-triple");
3405    }
3406
3407    /// A non-string entry (a nested list) is rejected structurally.
3408    #[test]
3409    fn distribution_platforms_non_string_entry_rejected() {
3410        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3411                    distribution:\n  adapter: cargo-dist\n  platforms: [[nope]]\n---\n";
3412        assert_error_contains(&norm(text), "each entry must be a target-triple string");
3413    }
3414
3415    /// A `platforms` value that is not a list is a structural error.
3416    #[test]
3417    fn distribution_platforms_non_list_rejected() {
3418        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3419                    distribution:\n  adapter: cargo-dist\n  platforms: x86_64-apple-darwin\n---\n";
3420        assert_error_contains(&norm(text), "must be a list of target-triple strings");
3421    }
3422
3423    /// Regression: a registry-only contract (no distribution block at all) is
3424    /// wholly unaffected by the additive `platforms` field — no distribution, so
3425    /// no `platforms` in the emitted shape.
3426    #[test]
3427    fn registry_only_contract_unaffected_by_platforms() {
3428        let json = serde_json::to_value(
3429            &norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract,
3430        )
3431        .unwrap();
3432        assert_eq!(json["distributions"], serde_json::json!([]));
3433    }
3434
3435    #[test]
3436    fn looks_like_target_triple_verdicts() {
3437        // Standard triples across arch/vendor/os/env shapes.
3438        assert!(looks_like_target_triple("aarch64-apple-darwin"));
3439        assert!(looks_like_target_triple("x86_64-apple-darwin"));
3440        assert!(looks_like_target_triple("x86_64-unknown-linux-musl"));
3441        assert!(looks_like_target_triple("x86_64-unknown-linux-gnu"));
3442        assert!(looks_like_target_triple("x86_64-pc-windows-msvc"));
3443        assert!(looks_like_target_triple("armv7-unknown-linux-gnueabihf"));
3444        assert!(looks_like_target_triple("wasm32-wasi"));
3445        // Real dotted arch names must pass (regression: the `.` was rejected).
3446        assert!(looks_like_target_triple("thumbv8m.main-none-eabi"));
3447        assert!(looks_like_target_triple("thumbv8m.base-none-eabi"));
3448        // Rejects: too few/many components, empty parts, case, punctuation.
3449        assert!(!looks_like_target_triple("linux"));
3450        assert!(!looks_like_target_triple("a-b-c-d-e"));
3451        assert!(!looks_like_target_triple("x86_64--linux"));
3452        assert!(!looks_like_target_triple("-apple-darwin"));
3453        assert!(!looks_like_target_triple("X86_64-apple-darwin"));
3454        assert!(!looks_like_target_triple("x86_64-apple-darwin;rm"));
3455        assert!(!looks_like_target_triple("x86_64 apple darwin"));
3456        assert!(!looks_like_target_triple(""));
3457        // Structural-only: nonsense that happens to be well-formed IS accepted —
3458        // the toolchain, not the contract, is the authority on buildability.
3459        assert!(looks_like_target_triple("aa-bb"));
3460    }
3461
3462    #[test]
3463    fn is_tap_slug_verdicts() {
3464        // Valid GitHub-style slugs.
3465        assert!(is_tap_slug("owner/repo"));
3466        assert!(is_tap_slug("jarimustonen/homebrew-issuectl"));
3467        assert!(is_tap_slug("Owner_1/repo.rb"));
3468        // Structural rejects.
3469        assert!(!is_tap_slug("no-slash"));
3470        assert!(!is_tap_slug("/repo"));
3471        assert!(!is_tap_slug("owner/"));
3472        assert!(!is_tap_slug("owner/repo/extra"));
3473        assert!(!is_tap_slug("owner / repo"));
3474        // Strict-charset rejects: path traversal, punctuation, injection chars.
3475        assert!(!is_tap_slug("owner/.."));
3476        assert!(!is_tap_slug("../repo"));
3477        assert!(!is_tap_slug("owner/repo;rm -rf"));
3478        assert!(!is_tap_slug("owner/@repo"));
3479        assert!(!is_tap_slug("ownér/repo"));
3480    }
3481
3482    /// `quote_for_diagnostic` JSON-encodes: quotes/backslashes/newlines/control
3483    /// chars are escaped, ordinary text stays readable.
3484    #[test]
3485    fn quote_for_diagnostic_escapes_hostile_input() {
3486        assert_eq!(quote_for_diagnostic("foo"), "\"foo\"");
3487        assert_eq!(quote_for_diagnostic("a\"b"), "\"a\\\"b\"");
3488        assert_eq!(quote_for_diagnostic("a\nb"), "\"a\\nb\"");
3489        assert_eq!(quote_for_diagnostic("a\tb"), "\"a\\tb\"");
3490        // A bare C0 control char (0x01) escapes to , never a raw byte.
3491        assert_eq!(quote_for_diagnostic("\u{1}"), "\"\\u0001\"");
3492    }
3493
3494    /// Log-injection hardening: a user-controlled unknown-field KEY carrying a
3495    /// quote, newline, and control char cannot forge a diagnostic line or emit a
3496    /// raw control char — it is JSON-encoded onto a single intact line.
3497    #[test]
3498    fn unknown_field_key_is_escaped_in_warning() {
3499        // The key is `evil"key` + newline + a forged-looking line + a control char.
3500        // Quoted in YAML so the literal quote/newline/control byte are the KEY text.
3501        let text =
3502            "---\nstatus: approved\nmaturity: mvp\n\"evil\\\"key\\nforged: line\\u0001\": 1\n---\n";
3503        let n = norm(text);
3504        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3505        let warning = n
3506            .problems
3507            .warnings
3508            .iter()
3509            .find(|w| w.contains("unknown field(s) preserved"))
3510            .expect("expected an unknown-field warning");
3511        // The raw quote/newline/control char never appear unescaped in the message:
3512        // no forged second line, no bare control byte.
3513        assert!(
3514            !warning.contains('\n'),
3515            "warning must stay on one line: {warning:?}"
3516        );
3517        assert!(
3518            !warning.contains('\u{1}'),
3519            "warning must not carry a raw control char: {warning:?}"
3520        );
3521        assert!(
3522            !warning.contains("evil\"key"),
3523            "the raw unescaped key must not appear: {warning:?}"
3524        );
3525        // The escaped JSON form is present (quote → \", newline → \n, ctrl → ).
3526        assert!(
3527            warning.contains("\\\"") && warning.contains("\\n") && warning.contains("\\u0001"),
3528            "the key must be JSON-escaped: {warning:?}"
3529        );
3530    }
3531
3532    /// The same hardening on a user-controlled VALUE routed through `yaml_display`
3533    /// (an invalid enum): a newline in the rejected value cannot forge an error
3534    /// line.
3535    #[test]
3536    fn invalid_enum_value_is_escaped_in_error() {
3537        let text = "---\nstatus: approved\nmaturity: \"mvp\\nforged: line\"\n---\n";
3538        let n = norm(text);
3539        assert_error_contains(&n, "maturity");
3540        let err = n
3541            .problems
3542            .errors
3543            .iter()
3544            .find(|e| e.contains("maturity") && e.contains("invalid"))
3545            .expect("expected a maturity-invalid error");
3546        assert!(!err.contains('\n'), "error must stay on one line: {err:?}");
3547        assert!(
3548            err.contains("\\n"),
3549            "the rejected value's newline must be escaped: {err:?}"
3550        );
3551    }
3552}