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).
44const KNOWN_KEYS: &[&str] = &[
45    "schema_version",
46    "status",
47    "maturity",
48    "ecosystems",
49    "targets",
50    "distribution",
51    "versioning",
52    "changelog",
53    "conventional_commits",
54    "release",
55    "contribution_provenance",
56    "provenance_level",
57    "dependency_bot",
58    "health_badges",
59    "license",
60    "docs_site",
61];
62
63/// Collected fatal errors and non-fatal warnings from a normalization pass.
64#[derive(Debug, Default)]
65pub struct Problems {
66    /// Fatal validation errors; a non-empty list means the config would not
67    /// normalize (the CLI exits non-zero with the §10 error envelope).
68    pub errors: Vec<String>,
69    /// Non-fatal notes (aspirational draft producers, the unknown-field report).
70    pub warnings: Vec<String>,
71}
72
73impl Problems {
74    fn err(&mut self, msg: String) {
75        self.errors.push(msg);
76    }
77
78    fn warn(&mut self, msg: String) {
79        self.warnings.push(msg);
80    }
81}
82
83/// The result of a normalization pass: the canonical [`Contract`] plus the
84/// [`Problems`] gathered while building it.
85#[derive(Debug)]
86pub struct Normalized {
87    /// The canonical contract. Only meaningful when [`Self::is_valid`] holds.
88    pub contract: Contract,
89    /// Errors and warnings gathered during normalization.
90    pub problems: Problems,
91}
92
93impl Normalized {
94    /// Whether the config normalized cleanly (no fatal errors).
95    #[must_use]
96    pub fn is_valid(&self) -> bool {
97        self.problems.errors.is_empty()
98    }
99}
100
101/// Why the contract file could not be loaded (distinct from a *validation*
102/// failure, which is carried by [`Problems`]). Maps to a §2 exit-2 system error.
103#[derive(Debug)]
104pub enum LoadError {
105    /// No `OSS-RELEASE.md` at the expected path.
106    NotFound(PathBuf),
107    /// The file exists but could not be read.
108    Io(PathBuf, io::Error),
109    /// The file is not valid UTF-8.
110    Utf8(PathBuf),
111}
112
113impl std::fmt::Display for LoadError {
114    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115        match self {
116            Self::NotFound(p) => write!(
117                f,
118                "no {CONTRACT_FILENAME} at {} (run /oss-init to generate one)",
119                p.display()
120            ),
121            Self::Io(p, e) => write!(f, "cannot read {}: {e}", p.display()),
122            Self::Utf8(p) => write!(f, "{} is not valid UTF-8", p.display()),
123        }
124    }
125}
126
127/// Read `<repo_root>/OSS-RELEASE.md` through the [`Fs`] port and normalize it.
128///
129/// # Errors
130/// Returns [`LoadError`] when the file is missing, unreadable, or not UTF-8. A
131/// *validation* failure is not an error here — it is carried in the returned
132/// [`Normalized::problems`]; check [`Normalized::is_valid`].
133pub fn normalize(repo_root: &Path, fs: &dyn Fs) -> Result<Normalized, LoadError> {
134    let path = repo_root.join(CONTRACT_FILENAME);
135    let bytes = fs.read(&path).map_err(|e| match e.kind() {
136        io::ErrorKind::NotFound => LoadError::NotFound(path.clone()),
137        _ => LoadError::Io(path.clone(), e),
138    })?;
139    let text = String::from_utf8(bytes).map_err(|_| LoadError::Utf8(path.clone()))?;
140    Ok(normalize_str(&text, repo_root, fs))
141}
142
143/// Normalize the full text of an `OSS-RELEASE.md` (frontmatter + body).
144///
145/// Split from [`normalize`] so tests can exercise the pipeline on a string
146/// without a real file. `repo_root` and `fs` are still needed for the
147/// filesystem-dependent floors (the fragment-dir path floor and its advisory
148/// existence check).
149#[must_use]
150pub fn normalize_str(text: &str, repo_root: &Path, fs: &dyn Fs) -> Normalized {
151    let mut p = Problems::default();
152    let map = match split_frontmatter(text, &mut p) {
153        Some(fm) => parse_frontmatter(&fm, &mut p),
154        None => Mapping::new(),
155    };
156    let contract = build(&map, &mut p, repo_root, fs);
157    Normalized {
158        contract,
159        problems: p,
160    }
161}
162
163/// Read a required enum field, or record an error and fall back to `default`.
164/// Absent → `default` silently; present-but-invalid → error + `default`
165/// (matching the Python default-substitution behavior).
166macro_rules! enum_field {
167    ($map:expr, $key:expr, $ty:ty, $default:expr, $p:expr) => {{
168        match $map.get($key) {
169            None => $default,
170            Some(v) => match v.as_str().and_then(<$ty>::parse) {
171                Some(x) => x,
172                None => {
173                    $p.err(format!(
174                        "{} {} invalid — must be one of {:?}",
175                        $key,
176                        yaml_display(v),
177                        <$ty>::VALID
178                    ));
179                    $default
180                }
181            },
182        }
183    }};
184}
185
186#[allow(clippy::too_many_lines)]
187fn build(map: &Mapping, p: &mut Problems, repo_root: &Path, fs: &dyn Fs) -> Contract {
188    // schema_version — bound first; a too-new config is a hard stop.
189    let schema_version = match map.get("schema_version") {
190        None => KNOWN_SCHEMA_VERSION,
191        Some(v) => match v.as_i64() {
192            Some(n) if n > i64::from(KNOWN_SCHEMA_VERSION) => {
193                p.err(format!(
194                    "schema_version {n} exceeds what this tool knows ({KNOWN_SCHEMA_VERSION}); \
195                     upgrade the OSS-release skills before reading this config (refusing rather \
196                     than guessing)."
197                ));
198                u32::try_from(n).unwrap_or(KNOWN_SCHEMA_VERSION)
199            }
200            Some(n) if n < 1 => {
201                p.err(format!("schema_version {n} is invalid (must be >= 1)"));
202                KNOWN_SCHEMA_VERSION
203            }
204            Some(n) => u32::try_from(n).unwrap_or(KNOWN_SCHEMA_VERSION),
205            None => {
206                p.err(format!(
207                    "schema_version must be an integer, got {}",
208                    yaml_display(v)
209                ));
210                KNOWN_SCHEMA_VERSION
211            }
212        },
213    };
214
215    let status = enum_field!(map, "status", Status, Status::Draft, p);
216
217    // maturity — required (inference is /oss-init's job, not the normalizer's).
218    let maturity = match map.get("maturity") {
219        None => {
220            p.err("maturity is required (spike|mvp|production) — /oss-init infers it".to_string());
221            Maturity::Mvp
222        }
223        Some(v) => {
224            if let Some(m) = v.as_str().and_then(Maturity::parse) {
225                m
226            } else {
227                p.err(format!(
228                    "maturity {} invalid — must be one of {:?}",
229                    yaml_display(v),
230                    Maturity::VALID
231                ));
232                Maturity::Mvp
233            }
234        }
235    };
236
237    // ecosystems — validate, then de-dup into canonical order.
238    let mut parsed_ecos: Vec<Ecosystem> = Vec::new();
239    for item in as_list(map.get("ecosystems")) {
240        match item.as_str().and_then(Ecosystem::parse) {
241            Some(e) => parsed_ecos.push(e),
242            None => p.err(format!(
243                "ecosystems: {} invalid — must be one of {:?}",
244                yaml_display(&item),
245                Ecosystem::VALID
246            )),
247        }
248    }
249    let ecosystems: Vec<Ecosystem> = ECOSYSTEM_ORDER
250        .into_iter()
251        .filter(|e| parsed_ecos.contains(e))
252        .collect();
253
254    // versioning — split the base enum from the calver pattern.
255    let (versioning, versioning_pattern) = parse_versioning(map.get("versioning"), p);
256
257    // release (model + layout).
258    let (model, layout) = match map.get("release") {
259        None | Some(Value::Null) => (ReleaseModel::Gated, ReleaseLayout::Single),
260        Some(Value::Mapping(m)) => (
261            enum_field!(m, "model", ReleaseModel, ReleaseModel::Gated, p),
262            enum_field!(m, "layout", ReleaseLayout, ReleaseLayout::Single, p),
263        ),
264        Some(_) => {
265            p.err("release must be a mapping with model/layout".to_string());
266            (ReleaseModel::Gated, ReleaseLayout::Single)
267        }
268    };
269
270    // targets — expand from ecosystems when omitted; validate each entry.
271    let targets = match map.get("targets") {
272        None | Some(Value::Null) => expand_targets(&ecosystems, layout),
273        Some(Value::Sequence(seq)) if seq.is_empty() => expand_targets(&ecosystems, layout),
274        Some(Value::Sequence(seq)) => validate_targets(seq, &ecosystems, layout, p),
275        Some(_) => {
276            p.err(
277                "targets must be a list of {ecosystem, package?, registry, adapter?} maps"
278                    .to_string(),
279            );
280            Vec::new()
281        }
282    };
283
284    // distribution — the binary-distribution block (cargo-dist/goreleaser); a
285    // registry-only repo omits it (→ None), leaving its contract shape unchanged.
286    // A `homebrew`-registry target is the OTHER consumer of `homebrew_tap` (the
287    // release engine's homebrew-tap adapter pushes the formula in its `dist`
288    // phase), so it is passed in to suppress the dead-config warning.
289    let has_homebrew_target = targets.iter().any(|t| t.registry == Registry::Homebrew);
290    let distribution = parse_distribution(
291        map.get("distribution"),
292        has_homebrew_target,
293        schema_version,
294        p,
295    );
296
297    // changelog (mode + source + fragment_dir).
298    let changelog = match map.get("changelog") {
299        None | Some(Value::Null) => Changelog {
300            mode: ChangelogMode::Curated,
301            source: ChangelogSource::Manual,
302            fragment_dir: DEFAULT_FRAGMENT_DIR.to_string(),
303        },
304        Some(Value::Mapping(m)) => {
305            let mode = enum_field!(m, "mode", ChangelogMode, ChangelogMode::Curated, p);
306            let source = enum_field!(m, "source", ChangelogSource, ChangelogSource::Manual, p);
307            let fragment_dir = match m.get("fragment_dir") {
308                None => DEFAULT_FRAGMENT_DIR.to_string(),
309                Some(v) => {
310                    if let Some(s) = v.as_str() {
311                        s.to_string()
312                    } else {
313                        p.err("changelog.fragment_dir must be a string path".to_string());
314                        DEFAULT_FRAGMENT_DIR.to_string()
315                    }
316                }
317            };
318            Changelog {
319                mode,
320                source,
321                fragment_dir,
322            }
323        }
324        Some(_) => {
325            p.err("changelog must be a mapping with mode/source".to_string());
326            Changelog {
327                mode: ChangelogMode::Curated,
328                source: ChangelogSource::Manual,
329                fragment_dir: DEFAULT_FRAGMENT_DIR.to_string(),
330            }
331        }
332    };
333    // fragment_dir must be a relative path inside the repo (floor 6).
334    if !path_inside_repo(&changelog.fragment_dir) {
335        p.err(format!(
336            "floor: changelog.fragment_dir '{}' must be a relative path inside the repo (an \
337             absolute or '../'-escaping path is refused)",
338            changelog.fragment_dir
339        ));
340    }
341
342    // conventional_commits.
343    let conventional_commits = match map.get("conventional_commits") {
344        None => false,
345        Some(Value::Bool(b)) => *b,
346        Some(v) => {
347            p.err(format!(
348                "conventional_commits must be true|false, got {}",
349                yaml_display(v)
350            ));
351            false
352        }
353    };
354
355    let contribution_provenance = enum_field!(
356        map,
357        "contribution_provenance",
358        ContributionProvenance,
359        ContributionProvenance::None,
360        p
361    );
362    let provenance_level = enum_field!(
363        map,
364        "provenance_level",
365        ProvenanceLevel,
366        ProvenanceLevel::None,
367        p
368    );
369
370    let dep_default = if maturity == Maturity::Spike {
371        DependencyBot::None
372    } else {
373        DependencyBot::Dependabot
374    };
375    let dependency_bot = enum_field!(map, "dependency_bot", DependencyBot, dep_default, p);
376
377    // license — a valid SPDX expression when set (default MIT).
378    let license = match map.get("license") {
379        None => "MIT".to_string(),
380        Some(v) => match v.as_str() {
381            Some(s) if !s.trim().is_empty() => {
382                if !spdx_valid(s) {
383                    p.err(format!(
384                        "license '{s}' is not a valid SPDX expression (unknown id or malformed \
385                         AND/OR/WITH grammar)"
386                    ));
387                }
388                s.to_string()
389            }
390            _ => {
391                p.err("license must be a non-empty SPDX id/expression (default MIT)".to_string());
392                "MIT".to_string()
393            }
394        },
395    };
396
397    let docs_site = enum_field!(map, "docs_site", DocsSite, DocsSite::None, p);
398
399    // health_badges — validate when present (key-presence, per Python), else
400    // materialize a floor-clean default (maturity/target aware).
401    let health_badges = if map.contains_key("health_badges") {
402        let mut out = Vec::new();
403        for item in as_list(map.get("health_badges")) {
404            match item.as_str().and_then(HealthBadge::parse) {
405                Some(hb) => out.push(hb),
406                None => p.err(format!(
407                    "health_badges: {} invalid — must be one of {:?}",
408                    yaml_display(&item),
409                    HealthBadge::VALID
410                )),
411            }
412        }
413        out
414    } else {
415        default_health_badges(maturity, &targets)
416    };
417
418    // ── Cross-field floors (§2) — config-internal, ALWAYS hard errors ────────
419    if model == ReleaseModel::Auto && maturity == Maturity::Spike {
420        p.err(
421            "floor: release.model 'auto' is not allowed on maturity 'spike' — a spike is not \
422             being published; raise maturity or set release.model: gated"
423                .to_string(),
424        );
425    }
426    if provenance_level == ProvenanceLevel::SlsaL3 && maturity != Maturity::Production {
427        p.err(format!(
428            "floor: provenance_level 'slsa-l3' is production-only — current maturity is '{}'",
429            maturity.as_str()
430        ));
431    }
432    // A target with a registry requires a valid SPDX license. Every expanded
433    // target carries a registry, so "any registry" reduces to "any target".
434    if !targets.is_empty() && !spdx_valid(&license) {
435        p.err(format!(
436            "floor: a target has a registry (crates.io/npm/PyPI/… require a license) but license \
437             '{license}' is not a valid SPDX expression"
438        ));
439    }
440    check_badge_producers(&health_badges, maturity, &targets, p);
441    // A distribution block ships public binaries (GH-Release artifacts, a curl-pipe
442    // installer, a Homebrew tap PR) — that is publishing, and a spike is not being
443    // published. Mirrors the `release.model: auto` floor: raise maturity or drop the
444    // block. (Absent block → no constraint; registry-only spikes are unaffected.)
445    if distribution.is_some() && maturity == Maturity::Spike {
446        p.err(
447            "floor: a distribution block ships public binaries (installer + tap) — not allowed on \
448             maturity 'spike' (a spike is not being published); raise maturity or drop distribution"
449                .to_string(),
450        );
451    }
452
453    // ── Filesystem/producer-existence semantic check — ADVISORY, never fatal ─
454    if changelog.mode == ChangelogMode::Fragment
455        && path_inside_repo(&changelog.fragment_dir)
456        && !fs.is_dir(&repo_root.join(&changelog.fragment_dir))
457    {
458        p.warn(format!(
459            "changelog.mode 'fragment' but the fragment dir '{}' does not exist yet under {} — \
460             /oss-changelog creates it; /oss-readiness reports it as a gap until then",
461            changelog.fragment_dir,
462            repo_root.display()
463        ));
464    }
465
466    // ── Forward-compat: preserve unknown fields, report once ─────────────────
467    let mut extra_fields = serde_json::Map::new();
468    for (k, v) in map {
469        if let Value::String(key) = k {
470            if !KNOWN_KEYS.contains(&key.as_str()) {
471                extra_fields.insert(key.clone(), yaml_to_json(v));
472            }
473        }
474    }
475    if !extra_fields.is_empty() {
476        // serde_json::Map is ordered (BTreeMap) → keys already sorted. Rendered
477        // as a single-quoted list to match the Python normalizer's message.
478        let keys = extra_fields
479            .keys()
480            .map(|k| format!("'{k}'"))
481            .collect::<Vec<_>>()
482            .join(", ");
483        p.warn(format!(
484            "unknown field(s) preserved under schema_version {schema_version} (forward-compat): \
485             [{keys}]"
486        ));
487    }
488
489    let warnings = p.warnings.clone();
490    Contract {
491        schema_version,
492        status,
493        maturity,
494        ecosystems,
495        targets,
496        distribution,
497        versioning,
498        versioning_pattern,
499        changelog,
500        conventional_commits,
501        release: Release { model, layout },
502        contribution_provenance,
503        provenance_level,
504        dependency_bot,
505        health_badges,
506        license,
507        docs_site,
508        extra_fields,
509        warnings,
510    }
511}
512
513fn parse_versioning(value: Option<&Value>, p: &mut Problems) -> (VersioningBase, Option<String>) {
514    let Some(v) = value else {
515        return (VersioningBase::Semver, None);
516    };
517    let Some(s) = v.as_str() else {
518        p.err(format!(
519            "versioning {} invalid — must be semver | calver:<pattern> | zerover",
520            yaml_display(v)
521        ));
522        return (VersioningBase::Semver, None);
523    };
524    if let Some(rest) = s.strip_prefix("calver:") {
525        let pattern = rest.trim();
526        if pattern.is_empty() {
527            p.err(
528                "versioning 'calver:' carries no pattern — e.g. calver:YYYY.MM.MICRO".to_string(),
529            );
530        }
531        (VersioningBase::Calver, Some(pattern.to_string()))
532    } else if s == "calver" {
533        p.err(
534            "versioning 'calver' must carry its pattern (calver:YYYY.MM.MICRO), not a bare label"
535                .to_string(),
536        );
537        (VersioningBase::Calver, None)
538    } else if let Some(base) = VersioningBase::parse(s) {
539        (base, None)
540    } else {
541        p.err(format!(
542            "versioning '{s}' invalid — must be semver | calver:<pattern> | zerover"
543        ));
544        (VersioningBase::Semver, None)
545    }
546}
547
548/// Derive one target per ecosystem with default registry + adapter.
549fn expand_targets(ecosystems: &[Ecosystem], layout: ReleaseLayout) -> Vec<Target> {
550    ecosystems
551        .iter()
552        .map(|&e| Target {
553            ecosystem: e,
554            package: None,
555            registry: e.default_registry(),
556            adapter: e.default_adapter(layout),
557        })
558        .collect()
559}
560
561fn validate_targets(
562    seq: &[Value],
563    ecosystems: &[Ecosystem],
564    layout: ReleaseLayout,
565    p: &mut Problems,
566) -> Vec<Target> {
567    let mut out = Vec::new();
568    for (idx, item) in seq.iter().enumerate() {
569        let Value::Mapping(m) = item else {
570            p.err(format!(
571                "targets[{idx}] must be a map with at least {{ecosystem, registry}}"
572            ));
573            continue;
574        };
575
576        let ecosystem = if let Some(s) = m.get("ecosystem").and_then(Value::as_str) {
577            if let Some(e) = Ecosystem::parse(s) {
578                if !ecosystems.is_empty() && !ecosystems.contains(&e) {
579                    p.err(format!(
580                        "targets[{idx}].ecosystem '{s}' is not in ecosystems {:?}",
581                        ecosystems.iter().map(|e| e.as_str()).collect::<Vec<_>>()
582                    ));
583                }
584                Some(e)
585            } else {
586                p.err(format!(
587                    "targets[{idx}].ecosystem '{s}' invalid — one of {:?}",
588                    Ecosystem::VALID
589                ));
590                None
591            }
592        } else {
593            p.err(format!(
594                "targets[{idx}].ecosystem invalid — one of {:?}",
595                Ecosystem::VALID
596            ));
597            None
598        };
599
600        let registry = match m.get("registry").and_then(Value::as_str) {
601            None => {
602                p.err(format!(
603                    "targets[{idx}] has no registry (required — the publish destination)"
604                ));
605                None
606            }
607            Some(s) => {
608                if let Some(r) = Registry::parse(s) {
609                    Some(r)
610                } else {
611                    p.err(format!(
612                        "targets[{idx}].registry '{s}' invalid — one of {:?}",
613                        Registry::VALID
614                    ));
615                    None
616                }
617            }
618        };
619
620        let adapter = match m.get("adapter") {
621            None => ecosystem.map(|e| e.default_adapter(layout)),
622            Some(v) => {
623                if let Some(a) = v.as_str().and_then(Adapter::parse) {
624                    Some(a)
625                } else {
626                    p.err(format!(
627                        "targets[{idx}].adapter {} invalid — one of {:?}",
628                        yaml_display(v),
629                        Adapter::VALID
630                    ));
631                    None
632                }
633            }
634        };
635
636        // On the error path, placeholders keep the strong type; the document is
637        // never emitted when problems.errors is non-empty.
638        out.push(Target {
639            ecosystem: ecosystem.unwrap_or(Ecosystem::Binary),
640            package: m.get("package").and_then(Value::as_str).map(str::to_string),
641            registry: registry.unwrap_or(Registry::GhReleases),
642            adapter: adapter.unwrap_or(Adapter::Manual),
643        });
644    }
645    out
646}
647
648/// Known `distribution`-block keys; anything else is preserved under
649/// [`Distribution::extra_fields`] (forward-compat), the nested analogue of
650/// [`KNOWN_KEYS`].
651const KNOWN_DISTRIBUTION_KEYS: &[&str] = &[
652    "adapter",
653    "gh_releases",
654    "installers",
655    "homebrew_tap",
656    "platforms",
657];
658
659/// Canonical installer order — used to de-duplicate and stably order the
660/// `distribution.installers` list (mirrors [`ECOSYSTEM_ORDER`]'s role).
661const INSTALLER_ORDER: [Installer; 5] = [
662    Installer::Shell,
663    Installer::Powershell,
664    Installer::Homebrew,
665    Installer::Msi,
666    Installer::Npm,
667];
668
669/// Parse the optional `distribution` block (the cargo-dist/goreleaser binary
670/// layer). Absent/null → `None`, leaving a registry-only contract unchanged. A
671/// present-but-non-mapping value is an error (with `None` on the error path — the
672/// document is never emitted while `problems.errors` is non-empty).
673#[allow(clippy::too_many_lines)]
674fn parse_distribution(
675    value: Option<&Value>,
676    has_homebrew_target: bool,
677    schema_version: u32,
678    p: &mut Problems,
679) -> Option<Distribution> {
680    let m = match value {
681        None | Some(Value::Null) => return None,
682        Some(Value::Mapping(m)) => m,
683        Some(_) => {
684            p.err(
685                "distribution must be a mapping with {adapter?, gh_releases?, installers?, \
686                 homebrew_tap?, platforms?}"
687                    .to_string(),
688            );
689            return None;
690        }
691    };
692
693    // adapter — required when the block is present. Which tool OWNS the existing
694    // tag-triggered release workflow is not the normalizer's to guess (it renames
695    // release semantics and picks a Rust-specific default); inference is
696    // /oss-init's job, exactly as for `maturity`. A bare `distribution: {}` is
697    // therefore an error, not a silent "cargo-dist owns this repo".
698    let adapter = match m.get("adapter") {
699        None => {
700            p.err(
701                "distribution.adapter is required when a distribution block is present \
702                 (cargo-dist|goreleaser|manual) — /oss-init infers it"
703                    .to_string(),
704            );
705            DistributionAdapter::CargoDist
706        }
707        Some(v) => {
708            if let Some(a) = v.as_str().and_then(DistributionAdapter::parse) {
709                a
710            } else {
711                p.err(format!(
712                    "distribution.adapter {} invalid — must be one of {:?}",
713                    yaml_display(v),
714                    DistributionAdapter::VALID
715                ));
716                DistributionAdapter::CargoDist
717            }
718        }
719    };
720
721    let gh_releases = match m.get("gh_releases") {
722        // cargo-dist/goreleaser attach per-platform binaries by default.
723        None => true,
724        Some(Value::Bool(b)) => *b,
725        Some(v) => {
726            p.err(format!(
727                "distribution.gh_releases must be true|false, got {}",
728                yaml_display(v)
729            ));
730            true
731        }
732    };
733
734    // installers — validate each, then de-dup into canonical order.
735    let mut parsed_installers: Vec<Installer> = Vec::new();
736    for item in as_list(m.get("installers")) {
737        match item.as_str().and_then(Installer::parse) {
738            Some(i) => parsed_installers.push(i),
739            None => p.err(format!(
740                "distribution.installers: {} invalid — must be one of {:?}",
741                yaml_display(&item),
742                Installer::VALID
743            )),
744        }
745    }
746    let installers: Vec<Installer> = INSTALLER_ORDER
747        .into_iter()
748        .filter(|i| parsed_installers.contains(i))
749        .collect();
750
751    let homebrew_tap = match m.get("homebrew_tap") {
752        None | Some(Value::Null) => None,
753        Some(v) => match v.as_str() {
754            Some(s) if is_tap_slug(s) => Some(s.to_string()),
755            // An invalid slug substitutes `None` (not the bad value) so the
756            // built `Distribution` never carries a malformed tap — matching the
757            // "placeholders keep the strong type" error-path rule the rest of the
758            // normalizer follows, and letting the homebrew-needs-tap floor below
759            // still fire (a present-but-invalid tap is no tap).
760            Some(s) => {
761                p.err(format!(
762                    "distribution.homebrew_tap '{s}' invalid — must be an 'owner/repo' slug"
763                ));
764                None
765            }
766            None => {
767                p.err("distribution.homebrew_tap must be an 'owner/repo' string".to_string());
768                None
769            }
770        },
771    };
772
773    let wants_homebrew = installers.contains(&Installer::Homebrew);
774    // Floor: a `homebrew` installer needs a tap to push the generated formula to.
775    if wants_homebrew && homebrew_tap.is_none() {
776        p.err(
777            "floor: distribution.installers includes 'homebrew' but no distribution.homebrew_tap \
778             is set — the generated formula has nowhere to be pushed"
779                .to_string(),
780        );
781    }
782    // Advisory: a tap with NEITHER a `homebrew` installer NOR a `homebrew`-registry
783    // target is dead config — no formula is ever generated, so the tap is never
784    // pushed to. A warning, not a floor: the contract is internally consistent,
785    // just wasteful. The tap has TWO possible consumers: cargo-dist's `homebrew`
786    // installer, and the release engine's homebrew-tap adapter target (whose `dist`
787    // phase generates + pushes the formula). Either one means the tap IS updated —
788    // so the warning fires only when both are absent.
789    if homebrew_tap.is_some() && !wants_homebrew && !has_homebrew_target {
790        p.warn(
791            "distribution.homebrew_tap is set but there is neither a 'homebrew' installer in \
792             distribution.installers nor a 'homebrew'-registry target — no formula is generated, \
793             so the tap will never be updated"
794                .to_string(),
795        );
796    }
797
798    // platforms — the binary target-triple set. Omitted/null → the cross-platform
799    // default (macOS + Linux musl), so a distribution that doesn't specify
800    // platforms covers Linux by default (the cross-platform install requirement).
801    // An explicit list is validated per triple and de-duplicated, preserving the
802    // author's order (like the sibling `targets` list — there is no canonical
803    // triple ordering to impose). An explicit *empty* list is NOT the same as
804    // omitted: it is a mistake, and silently defaulting it would surprise the
805    // author with targets they never listed and erase the intent the downstream
806    // cross-platform audit needs — so it is a hard error.
807    let platforms = match m.get("platforms") {
808        None | Some(Value::Null) => default_cross_platform_targets(),
809        Some(Value::Sequence(seq)) if seq.is_empty() => {
810            // Default fallback keeps error-collection going; the contract is never
811            // emitted while `problems.errors` is non-empty.
812            p.err(
813                "distribution.platforms is an empty list — omit the key to accept the \
814                 cross-platform default (macOS + Linux) or list explicit target-triples; a \
815                 distribution with no platforms builds nothing"
816                    .to_string(),
817            );
818            default_cross_platform_targets()
819        }
820        Some(Value::Sequence(seq)) => {
821            let mut out: Vec<String> = Vec::new();
822            for item in seq {
823                match item.as_str() {
824                    Some(s) if looks_like_target_triple(s) => {
825                        let triple = s.to_string();
826                        if !out.contains(&triple) {
827                            out.push(triple);
828                        }
829                    }
830                    Some(s) => p.err(format!(
831                        "distribution.platforms: '{s}' is not a well-formed target-triple \
832                         (e.g. x86_64-unknown-linux-musl, aarch64-apple-darwin) — structural \
833                         check only; the toolchain is the final authority on what builds"
834                    )),
835                    None => p.err(format!(
836                        "distribution.platforms: {} invalid — each entry must be a \
837                         target-triple string",
838                        yaml_display(item)
839                    )),
840                }
841            }
842            out
843        }
844        Some(v) => {
845            p.err(format!(
846                "distribution.platforms must be a list of target-triple strings, got {}",
847                yaml_display(v)
848            ));
849            default_cross_platform_targets()
850        }
851    };
852
853    // Cross-check: an OS-specific installer whose target OS is absent from the
854    // resolved `platforms` set is dead config — the generated installer points at
855    // a binary the release never builds ("the installer has nothing to install").
856    // A warning, not a floor (mirrors the `homebrew_tap`-without-consumer advisory
857    // above): the contract is internally consistent, just wasteful. Only the
858    // OS-specific installers constrain the set — see [`installer_os_need`] for the
859    // full installer→OS table; npm/shell/powershell are not cross-checked.
860    //
861    // Gated on a clean parse: this is a cross-field semantic advisory, so it must
862    // read only well-formed triples. A malformed triple (rejected above) that
863    // happens to contain an OS keyword must neither satisfy nor spuriously fail
864    // the coverage check — otherwise the warning would flip as the author fixes an
865    // unrelated error. Errors already block emission, so gating here loses nothing.
866    if p.errors.is_empty() {
867        let has_windows = platforms.iter().any(|t| is_windows_triple(t));
868        let has_macos = platforms.iter().any(|t| is_macos_triple(t));
869        let has_linux = platforms.iter().any(|t| is_linux_triple(t));
870        for &installer in &installers {
871            let unmet = match installer_os_need(installer) {
872                OsNeed::Unchecked => None,
873                OsNeed::Windows => (!has_windows).then_some(
874                    "distribution.installers includes 'msi' but the resolved \
875                     distribution.platforms set has no Windows (*-windows-*) target — the MSI \
876                     installer has nothing to install",
877                ),
878                // Homebrew serves macOS natively AND Linux via Linuxbrew, so a
879                // single Linux triple satisfies it just as a darwin triple does;
880                // the warning fires only when NEITHER is present (the issue's stated
881                // intent when the darwin-vs-linux question is ambiguous).
882                OsNeed::MacosOrLinux => (!has_macos && !has_linux).then_some(
883                    "distribution.installers includes 'homebrew' but the resolved \
884                     distribution.platforms set has no macOS (*-apple-darwin) or Linux \
885                     (*-linux-*) target — the Homebrew formula has nothing to install",
886                ),
887            };
888            if let Some(msg) = unmet {
889                p.warn(msg.to_string());
890            }
891        }
892    }
893
894    // Forward-compat: preserve unknown distribution sub-keys (the nested analogue
895    // of the top-level `extra_fields` scan), so an older reader round-trips a
896    // newer contract's distribution keys rather than dropping them. Reported once,
897    // scoped to the block, mirroring the top-level unknown-field warning.
898    let mut extra_fields = serde_json::Map::new();
899    for (k, v) in m {
900        if let Value::String(key) = k {
901            if !KNOWN_DISTRIBUTION_KEYS.contains(&key.as_str()) {
902                extra_fields.insert(key.clone(), yaml_to_json(v));
903            }
904        }
905    }
906    if !extra_fields.is_empty() {
907        let keys = extra_fields
908            .keys()
909            .map(|k| format!("'{k}'"))
910            .collect::<Vec<_>>()
911            .join(", ");
912        p.warn(format!(
913            "unknown distribution field(s) preserved under schema_version {schema_version} \
914             (forward-compat): [{keys}]"
915        ));
916    }
917
918    Some(Distribution {
919        adapter,
920        gh_releases,
921        installers,
922        homebrew_tap,
923        platforms,
924        extra_fields,
925    })
926}
927
928/// The cross-platform default `distribution.platforms` set as owned strings —
929/// materialized when the block omits `platforms` (or gives an empty list). Always
930/// contains at least one Linux triple (the cross-platform install requirement).
931fn default_cross_platform_targets() -> Vec<String> {
932    DEFAULT_CROSS_PLATFORM_TARGETS
933        .iter()
934        .map(|&s| s.to_string())
935        .collect()
936}
937
938/// The OS coverage an installer needs from `distribution.platforms` to install
939/// anything — the small installer→OS spec behind the installer↔platform
940/// cross-check warning. Kept as one table (see [`installer_os_need`]) rather than
941/// scattered conditionals so the mapping stays inspectable in one place.
942enum OsNeed {
943    /// Not cross-checked — this installer never constrains `platforms`.
944    Unchecked,
945    /// Needs at least one Windows triple.
946    Windows,
947    /// Needs at least one macOS OR Linux triple.
948    MacosOrLinux,
949}
950
951/// The OS an installer's generated artifact can actually install onto — the spec
952/// that lets the normalizer flag an installer whose target OS is absent from
953/// `platforms`. Only `msi` and `homebrew` are OS-gated; the rest are deliberately
954/// left `Unchecked` (a scoping choice, not a claim that they run everywhere):
955///
956/// | installer    | need              | rationale                                          |
957/// |--------------|-------------------|----------------------------------------------------|
958/// | `msi`        | Windows           | an `.msi` installs only on Windows                 |
959/// | `homebrew`   | macOS **or** Linux| Homebrew serves macOS natively and Linux (Linuxbrew) |
960/// | `shell`      | — (not checked)   | a POSIX script; only msi/homebrew are gated for now |
961/// | `powershell` | — (not checked)   | Windows-oriented; only msi/homebrew are gated for now |
962/// | `npm`        | — (not checked)   | published to a registry, not tied to one OS's artifact |
963fn installer_os_need(i: Installer) -> OsNeed {
964    match i {
965        Installer::Msi => OsNeed::Windows,
966        Installer::Homebrew => OsNeed::MacosOrLinux,
967        Installer::Shell | Installer::Powershell | Installer::Npm => OsNeed::Unchecked,
968    }
969}
970
971/// The OS ("system") component of a target-triple — the 3rd `-`-separated field
972/// in the `<arch>-<vendor>-<os>[-<env>]` shape the shipped desktop triples use
973/// (`x86_64-pc-windows-msvc`, `aarch64-apple-darwin`, `x86_64-unknown-linux-musl`).
974/// `None` for a 2-component triple that names no vendor (`wasm32-wasip1`). Matching
975/// the OS *positionally* (rather than "any component equals …") is what keeps
976/// `aarch64-linux-android` out of the Linux bucket: its `linux` sits in the vendor
977/// slot and the real OS component is `android`.
978fn triple_os(s: &str) -> Option<&str> {
979    s.split('-').nth(2)
980}
981
982/// Whether a target-triple targets Windows — OS component `windows` (covering
983/// both `-windows-msvc` and `-windows-gnu`).
984fn is_windows_triple(s: &str) -> bool {
985    triple_os(s) == Some("windows")
986}
987
988/// Whether a target-triple targets macOS — OS component `darwin` (e.g.
989/// `aarch64-apple-darwin`). Apple's non-macOS triples (`*-apple-ios`, `-tvos`, …)
990/// carry a different OS component and are correctly excluded.
991fn is_macos_triple(s: &str) -> bool {
992    triple_os(s) == Some("darwin")
993}
994
995/// Whether a target-triple targets Linux — OS component `linux` (e.g.
996/// `x86_64-unknown-linux-musl`), covering the Linuxbrew case for `homebrew`.
997/// Android (`aarch64-linux-android`) has `android` as its OS component and does
998/// not count.
999fn is_linux_triple(s: &str) -> bool {
1000    triple_os(s) == Some("linux")
1001}
1002
1003/// Whether `s` is a *structurally* plausible target-triple — 2–4 `-`-separated
1004/// components, each a non-empty run of `[a-z0-9_.]`. Deliberately LEXICAL, not
1005/// semantic: the real triple set is open and rustc-defined, so this is a
1006/// well-formedness gate, not a whitelist. It rejects what could never be a triple
1007/// (empty parts, uppercase, whitespace, punctuation, injection chars, wrong shape)
1008/// and accepts real triples including dotted arch names like
1009/// `thumbv8m.main-none-eabi` — but it also accepts structurally-valid nonsense like
1010/// `aa-bb`, because the toolchain is the final authority on whether a triple
1011/// actually builds. The OS component stays intact and inspectable so the
1012/// cross-platform `audit` can classify a set downstream.
1013fn looks_like_target_triple(s: &str) -> bool {
1014    let parts: Vec<&str> = s.split('-').collect();
1015    (2..=4).contains(&parts.len())
1016        && parts.iter().all(|part| {
1017            !part.is_empty()
1018                && part.bytes().all(|b| {
1019                    b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.')
1020                })
1021        })
1022}
1023
1024/// Whether `s` is a plausible `owner/repo` tap slug — exactly one `/`, and each
1025/// part a non-empty run of the GitHub-name character set (ASCII alphanumeric plus
1026/// `-`, `_`, `.`), with `.`/`..` rejected. Lexical only — existence is not
1027/// checked. Deliberately strict: this value flows into `brew tap` and repo URLs
1028/// downstream, so arbitrary punctuation, whitespace, or path traversal
1029/// (`owner/..`) must not pass.
1030fn is_tap_slug(s: &str) -> bool {
1031    fn valid_part(part: &str) -> bool {
1032        !part.is_empty()
1033            && part != "."
1034            && part != ".."
1035            && part
1036                .bytes()
1037                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
1038    }
1039    match s.split_once('/') {
1040        Some((owner, repo)) => valid_part(owner) && valid_part(repo) && !repo.contains('/'),
1041        None => false,
1042    }
1043}
1044
1045/// A floor-clean default badge set: `ci` at mvp+, `registry` when a publishable
1046/// target exists, `license` always.
1047fn default_health_badges(maturity: Maturity, targets: &[Target]) -> Vec<HealthBadge> {
1048    let mut badges = Vec::new();
1049    if matches!(maturity, Maturity::Mvp | Maturity::Production) {
1050        badges.push(HealthBadge::Ci);
1051    }
1052    if !targets.is_empty() {
1053        badges.push(HealthBadge::Registry);
1054    }
1055    badges.push(HealthBadge::License);
1056    badges
1057}
1058
1059/// Every enabled badge must have its producer enabled (floor 4).
1060fn check_badge_producers(
1061    badges: &[HealthBadge],
1062    maturity: Maturity,
1063    targets: &[Target],
1064    p: &mut Problems,
1065) {
1066    let has_registry_target = !targets.is_empty();
1067    for b in badges {
1068        match b {
1069            HealthBadge::Ci if maturity == Maturity::Spike => p.err(
1070                "floor: health_badge 'ci' has no producer at maturity 'spike' (no CI until mvp) — \
1071                 drop it or raise maturity"
1072                    .to_string(),
1073            ),
1074            HealthBadge::Registry if !has_registry_target => p.err(
1075                "floor: health_badge 'registry' has no producer — no target has a registry to \
1076                 publish to"
1077                    .to_string(),
1078            ),
1079            HealthBadge::Coverage if maturity != Maturity::Production => p.err(format!(
1080                "floor: health_badge 'coverage' has no producer — the coverage gate is a \
1081                 production-tier /oss-ci output; current maturity is '{}'",
1082                maturity.as_str()
1083            )),
1084            HealthBadge::Scorecard if maturity != Maturity::Production => p.err(format!(
1085                "floor: health_badge 'scorecard' has no producer — the Scorecard action is a \
1086                 production-tier output; current maturity is '{}'",
1087                maturity.as_str()
1088            )),
1089            _ => {}
1090        }
1091    }
1092}
1093
1094// ── Frontmatter extraction + parse ───────────────────────────────────────────
1095
1096/// A `---` fence line (exactly three dashes plus optional trailing whitespace).
1097fn is_fence(line: &str) -> bool {
1098    let t = line.trim_end();
1099    t == "---" || (t.starts_with("---") && t[3..].chars().all(char::is_whitespace))
1100}
1101
1102/// Split the YAML frontmatter block out of the document. Returns the frontmatter
1103/// text (body discarded — the normalizer never reads it), or `None` on a
1104/// structural error (recorded on `p`).
1105fn split_frontmatter(text: &str, p: &mut Problems) -> Option<String> {
1106    let mut lines = text.lines();
1107    match lines.next() {
1108        Some(first) if is_fence(first) => {}
1109        _ => {
1110            p.err("frontmatter missing: file must begin with a '---' YAML block".to_string());
1111            return None;
1112        }
1113    }
1114    let mut fm = String::new();
1115    for line in lines {
1116        if is_fence(line) {
1117            return Some(fm);
1118        }
1119        fm.push_str(line);
1120        fm.push('\n');
1121    }
1122    p.err("frontmatter not closed: no terminating '---' line found".to_string());
1123    None
1124}
1125
1126/// Parse the frontmatter into a YAML mapping. `serde_yaml` rejects duplicate
1127/// keys natively; a non-mapping top level or any YAML error is recorded on `p`.
1128fn parse_frontmatter(fm: &str, p: &mut Problems) -> Mapping {
1129    if fm.trim().is_empty() {
1130        return Mapping::new();
1131    }
1132    match serde_yaml::from_str::<Value>(fm) {
1133        Ok(Value::Null) => Mapping::new(),
1134        Ok(Value::Mapping(m)) => m,
1135        Ok(_) => {
1136            p.err("frontmatter: top level must be a mapping".to_string());
1137            Mapping::new()
1138        }
1139        Err(e) => {
1140            p.err(format!("frontmatter: invalid YAML — {e}"));
1141            Mapping::new()
1142        }
1143    }
1144}
1145
1146// ── Helpers ──────────────────────────────────────────────────────────────────
1147
1148/// Coerce a value to a list: a sequence stays; absent/null → empty; a scalar
1149/// becomes a one-element list (mirrors the Python `_as_list`).
1150fn as_list(v: Option<&Value>) -> Vec<Value> {
1151    match v {
1152        None | Some(Value::Null) => Vec::new(),
1153        Some(Value::Sequence(seq)) => seq.clone(),
1154        Some(other) => vec![other.clone()],
1155    }
1156}
1157
1158/// A compact display of a YAML scalar for error messages (strings are quoted).
1159fn yaml_display(v: &Value) -> String {
1160    match v {
1161        Value::String(s) => format!("'{s}'"),
1162        Value::Bool(b) => b.to_string(),
1163        Value::Number(n) => n.to_string(),
1164        Value::Null => "null".to_string(),
1165        Value::Sequence(_) => "<list>".to_string(),
1166        Value::Mapping(_) => "<map>".to_string(),
1167        Value::Tagged(t) => yaml_display(&t.value),
1168    }
1169}
1170
1171/// Whether `rel` is a relative path that stays inside the repo — no absolute
1172/// path, no `../` escape — the fragment-dir floor. Lexical, so the path need not
1173/// exist. The check is purely on `rel`'s own component depth, so it holds
1174/// whether the repo root is absolute or relative (notably `--repo-root .`,
1175/// where `repo_root` normalizes to an empty path): a `..` is an escape the
1176/// moment it would pop above the repo root, exactly the Python
1177/// `_path_inside_repo` verdict (which rejects any `rel` that normalizes to an
1178/// escaping path). Joining `rel` onto a relative root and testing containment —
1179/// the previous approach — silently accepted `../etc` under a `.` root, because
1180/// an empty normalized root is a prefix of every path.
1181fn path_inside_repo(rel: &str) -> bool {
1182    let mut depth: usize = 0;
1183    for comp in Path::new(rel).components() {
1184        match comp {
1185            Component::CurDir => {}
1186            Component::Normal(_) => depth += 1,
1187            Component::ParentDir => {
1188                // An escape above the repo root the instant depth would go < 0.
1189                if depth == 0 {
1190                    return false;
1191                }
1192                depth -= 1;
1193            }
1194            // An absolute path (or a Windows drive prefix) never stays inside a
1195            // relative repo root.
1196            Component::RootDir | Component::Prefix(_) => return false,
1197        }
1198    }
1199    true
1200}
1201
1202/// Convert an arbitrary YAML value to JSON, for `extra_fields` preservation.
1203fn yaml_to_json(v: &Value) -> serde_json::Value {
1204    use serde_json::Value as J;
1205    match v {
1206        Value::Null => J::Null,
1207        Value::Bool(b) => J::Bool(*b),
1208        Value::Number(n) => {
1209            if let Some(i) = n.as_i64() {
1210                J::from(i)
1211            } else if let Some(u) = n.as_u64() {
1212                J::from(u)
1213            } else if let Some(f) = n.as_f64() {
1214                serde_json::Number::from_f64(f).map_or(J::Null, J::Number)
1215            } else {
1216                J::Null
1217            }
1218        }
1219        Value::String(s) => J::String(s.clone()),
1220        Value::Sequence(seq) => J::Array(seq.iter().map(yaml_to_json).collect()),
1221        Value::Mapping(m) => {
1222            let mut obj = serde_json::Map::new();
1223            for (k, val) in m {
1224                let key = match k {
1225                    Value::String(s) => s.clone(),
1226                    other => yaml_display(other),
1227                };
1228                obj.insert(key, yaml_to_json(val));
1229            }
1230            J::Object(obj)
1231        }
1232        Value::Tagged(t) => yaml_to_json(&t.value),
1233    }
1234}
1235
1236#[cfg(test)]
1237mod tests {
1238    use super::*;
1239    use std::collections::HashSet;
1240
1241    /// A fake `Fs`: `normalize_str` never `read`s, so only the directory set
1242    /// matters (for the fragment-dir advisory check).
1243    struct FakeFs {
1244        dirs: HashSet<PathBuf>,
1245    }
1246
1247    impl FakeFs {
1248        fn empty() -> Self {
1249            Self {
1250                dirs: HashSet::new(),
1251            }
1252        }
1253
1254        fn with_dirs<const N: usize>(dirs: [&str; N]) -> Self {
1255            Self {
1256                dirs: dirs.iter().map(PathBuf::from).collect(),
1257            }
1258        }
1259    }
1260
1261    impl Fs for FakeFs {
1262        fn read(&self, _path: &Path) -> io::Result<Vec<u8>> {
1263            Err(io::Error::from(io::ErrorKind::NotFound))
1264        }
1265        fn exists(&self, path: &Path) -> bool {
1266            self.dirs.contains(path)
1267        }
1268        fn is_dir(&self, path: &Path) -> bool {
1269            self.dirs.contains(path)
1270        }
1271        fn is_file(&self, _path: &Path) -> bool {
1272            // The contract normalizer models only directories (fragment-dir).
1273            false
1274        }
1275        fn read_dir(&self, _dir: &Path) -> io::Result<Vec<String>> {
1276            // The contract normalizer never lists directories.
1277            Ok(Vec::new())
1278        }
1279    }
1280
1281    fn repo() -> &'static Path {
1282        Path::new("/repo")
1283    }
1284
1285    fn norm(text: &str) -> Normalized {
1286        normalize_str(text, repo(), &FakeFs::empty())
1287    }
1288
1289    fn norm_with(text: &str, fs: &dyn Fs) -> Normalized {
1290        normalize_str(text, repo(), fs)
1291    }
1292
1293    fn assert_error_contains(n: &Normalized, needle: &str) {
1294        assert!(
1295            !n.is_valid(),
1296            "expected invalid, got clean normalize: {:?}",
1297            n.contract
1298        );
1299        assert!(
1300            n.problems.errors.iter().any(|e| e.contains(needle)),
1301            "no error contained {needle:?}; errors were {:?}",
1302            n.problems.errors
1303        );
1304    }
1305
1306    const MINIMAL: &str = "---\nstatus: approved\nmaturity: mvp\n---\n";
1307
1308    #[test]
1309    fn materializes_all_defaults() {
1310        let c = norm(MINIMAL).contract;
1311        assert_eq!(c.schema_version, 1);
1312        assert_eq!(c.status, Status::Approved);
1313        assert_eq!(c.maturity, Maturity::Mvp);
1314        assert!(c.ecosystems.is_empty());
1315        assert!(c.targets.is_empty());
1316        assert_eq!(c.versioning, VersioningBase::Semver);
1317        assert_eq!(c.versioning_pattern, None);
1318        assert_eq!(c.changelog.mode, ChangelogMode::Curated);
1319        assert_eq!(c.changelog.source, ChangelogSource::Manual);
1320        assert_eq!(c.changelog.fragment_dir, DEFAULT_FRAGMENT_DIR);
1321        assert!(!c.conventional_commits);
1322        assert_eq!(c.release.model, ReleaseModel::Gated);
1323        assert_eq!(c.release.layout, ReleaseLayout::Single);
1324        assert_eq!(c.contribution_provenance, ContributionProvenance::None);
1325        assert_eq!(c.provenance_level, ProvenanceLevel::None);
1326        assert_eq!(c.dependency_bot, DependencyBot::Dependabot); // mvp default
1327        assert_eq!(c.license, "MIT");
1328        assert_eq!(c.docs_site, DocsSite::None);
1329        // mvp, no publishable target → [ci, license].
1330        assert_eq!(c.health_badges, vec![HealthBadge::Ci, HealthBadge::License]);
1331        assert!(c.extra_fields.is_empty());
1332    }
1333
1334    #[test]
1335    fn spike_defaults_no_bot_no_ci_badge() {
1336        let c = norm("---\nstatus: approved\nmaturity: spike\n---\n").contract;
1337        assert_eq!(c.dependency_bot, DependencyBot::None);
1338        assert_eq!(c.health_badges, vec![HealthBadge::License]);
1339    }
1340
1341    #[test]
1342    fn maturity_is_required() {
1343        assert_error_contains(
1344            &norm("---\nstatus: approved\n---\n"),
1345            "maturity is required",
1346        );
1347    }
1348
1349    #[test]
1350    fn expands_targets_from_ecosystems() {
1351        let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [python]\n---\n").contract;
1352        assert_eq!(c.targets.len(), 1);
1353        assert_eq!(c.targets[0].ecosystem, Ecosystem::Python);
1354        assert_eq!(c.targets[0].package, None);
1355        assert_eq!(c.targets[0].registry, Registry::Pypi);
1356        assert_eq!(c.targets[0].adapter, Adapter::GhActionPypiPublish);
1357    }
1358
1359    #[test]
1360    fn node_monorepo_adapter_is_changesets() {
1361        let text = "---\nstatus: approved\nmaturity: production\necosystems: [node]\n\
1362                    release:\n  model: gated\n  layout: monorepo\n---\n";
1363        let c = norm(text).contract;
1364        assert_eq!(c.targets[0].adapter, Adapter::Changesets);
1365    }
1366
1367    #[test]
1368    fn ecosystems_dedup_to_canonical_order() {
1369        let c =
1370            norm("---\nstatus: approved\nmaturity: mvp\necosystems: [python, rust, python]\n---\n")
1371                .contract;
1372        assert_eq!(c.ecosystems, vec![Ecosystem::Rust, Ecosystem::Python]);
1373    }
1374
1375    #[test]
1376    fn calver_splits_base_and_pattern() {
1377        let c = norm(
1378            "---\nstatus: approved\nmaturity: mvp\nversioning: \"calver:YYYY.MM.MICRO\"\n---\n",
1379        )
1380        .contract;
1381        assert_eq!(c.versioning, VersioningBase::Calver);
1382        assert_eq!(c.versioning_pattern.as_deref(), Some("YYYY.MM.MICRO"));
1383    }
1384
1385    #[test]
1386    fn bare_calver_is_rejected() {
1387        assert_error_contains(
1388            &norm("---\nstatus: approved\nmaturity: mvp\nversioning: calver\n---\n"),
1389            "must carry its pattern",
1390        );
1391    }
1392
1393    #[test]
1394    fn floor_auto_on_spike() {
1395        let text = "---\nstatus: approved\nmaturity: spike\n\
1396                    release:\n  model: auto\n  layout: single\nhealth_badges: [license]\n---\n";
1397        assert_error_contains(&norm(text), "release.model 'auto' is not allowed");
1398    }
1399
1400    #[test]
1401    fn floor_slsa_l3_production_only() {
1402        assert_error_contains(
1403            &norm("---\nstatus: approved\nmaturity: mvp\nprovenance_level: slsa-l3\n---\n"),
1404            "slsa-l3' is production-only",
1405        );
1406    }
1407
1408    #[test]
1409    fn floor_registry_requires_valid_license() {
1410        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
1411                    license: Proprietary-Acme\nhealth_badges: [ci, registry, license]\n---\n";
1412        let n = norm(text);
1413        // Both the SPDX-validity error and the registry-needs-license floor fire.
1414        assert_error_contains(&n, "not a valid SPDX expression");
1415        assert!(n
1416            .problems
1417            .errors
1418            .iter()
1419            .any(|e| e.contains("floor: a target has a registry")));
1420    }
1421
1422    #[test]
1423    fn floor_badge_without_producer() {
1424        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [python]\n\
1425                    health_badges: [ci, coverage]\n---\n";
1426        assert_error_contains(&norm(text), "health_badge 'coverage' has no producer");
1427    }
1428
1429    #[test]
1430    fn floor_schema_version_too_new() {
1431        assert_error_contains(
1432            &norm("---\nschema_version: 99\nstatus: approved\nmaturity: mvp\n---\n"),
1433            "exceeds what this tool knows",
1434        );
1435    }
1436
1437    #[test]
1438    fn floor_fragment_dir_escape() {
1439        let text = "---\nstatus: approved\nmaturity: mvp\n\
1440                    changelog:\n  mode: fragment\n  source: manual\n  fragment_dir: /etc\n---\n";
1441        assert_error_contains(&norm(text), "must be a relative path inside the repo");
1442    }
1443
1444    #[test]
1445    fn floor_fragment_dir_escape_relative_root() {
1446        // Regression: with a *relative* repo root (the CLI's `--repo-root .`),
1447        // a `../`-escaping fragment_dir must still be rejected. The earlier
1448        // join-then-contain check accepted it because a `.` root normalizes to
1449        // an empty path that prefixes everything.
1450        let text = "---\nstatus: approved\nmaturity: mvp\n\
1451                    changelog:\n  mode: fragment\n  source: manual\n  fragment_dir: ../etc\n---\n";
1452        let n = normalize_str(text, Path::new("."), &FakeFs::empty());
1453        assert_error_contains(&n, "must be a relative path inside the repo");
1454    }
1455
1456    #[test]
1457    fn path_inside_repo_verdicts() {
1458        // Inside — plain and `.`/`..`-collapsing relative paths that stay in.
1459        assert!(path_inside_repo("changelog/fragments"));
1460        assert!(path_inside_repo("./changelog/fragments"));
1461        assert!(path_inside_repo("a/../fragments"));
1462        assert!(path_inside_repo("")); // the repo root itself
1463                                       // Escapes — absolute, leading `..`, and mid-path `..` that pops out.
1464        assert!(!path_inside_repo("/etc"));
1465        assert!(!path_inside_repo("../etc"));
1466        assert!(!path_inside_repo("a/../../etc"));
1467    }
1468
1469    #[test]
1470    fn unknown_fields_preserved_and_warned() {
1471        let text =
1472            "---\nstatus: approved\nmaturity: mvp\nroadmap_url: https://example.com/x\n---\n";
1473        let n = norm(text);
1474        assert!(n.is_valid());
1475        assert_eq!(
1476            n.contract
1477                .extra_fields
1478                .get("roadmap_url")
1479                .and_then(|v| v.as_str()),
1480            Some("https://example.com/x")
1481        );
1482        assert!(n
1483            .problems
1484            .warnings
1485            .iter()
1486            .any(|w| w.contains("roadmap_url") && w.contains("forward-compat")));
1487    }
1488
1489    #[test]
1490    fn duplicate_key_is_rejected() {
1491        assert_error_contains(
1492            &norm("---\nstatus: approved\nstatus: draft\nmaturity: mvp\n---\n"),
1493            "invalid YAML",
1494        );
1495    }
1496
1497    #[test]
1498    fn missing_frontmatter_is_rejected() {
1499        assert_error_contains(&norm("no frontmatter here\n"), "frontmatter missing");
1500    }
1501
1502    #[test]
1503    fn unclosed_frontmatter_is_rejected() {
1504        assert_error_contains(&norm("---\nstatus: approved\n"), "frontmatter not closed");
1505    }
1506
1507    #[test]
1508    fn invalid_enum_records_error_and_continues() {
1509        // A bad status AND a bad maturity: both surface (multi-error collection).
1510        let n = norm("---\nstatus: bogus\nmaturity: alsobogus\n---\n");
1511        assert!(n.problems.errors.iter().any(|e| e.contains("status")));
1512        assert!(n.problems.errors.iter().any(|e| e.contains("maturity")));
1513    }
1514
1515    #[test]
1516    fn fragment_dir_present_suppresses_advisory() {
1517        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
1518                    changelog:\n  mode: fragment\n  source: manual\n---\n";
1519        // The default fragment dir exists → no advisory warning.
1520        let fs = FakeFs::with_dirs(["/repo/changelog/fragments"]);
1521        let n = norm_with(text, &fs);
1522        assert!(n.is_valid());
1523        assert!(
1524            !n.problems
1525                .warnings
1526                .iter()
1527                .any(|w| w.contains("does not exist yet")),
1528            "advisory should be suppressed when the dir exists: {:?}",
1529            n.problems.warnings
1530        );
1531    }
1532
1533    #[test]
1534    fn serializes_to_schema_v4_shape() {
1535        let json =
1536            serde_json::to_value(&norm("---\nstatus: approved\nmaturity: mvp\n---\n").contract)
1537                .unwrap();
1538        // Spot-check the §4 top-level keys that consumers read.
1539        for key in [
1540            "schema_version",
1541            "status",
1542            "maturity",
1543            "ecosystems",
1544            "targets",
1545            "distribution",
1546            "versioning",
1547            "versioning_pattern",
1548            "changelog",
1549            "conventional_commits",
1550            "release",
1551            "contribution_provenance",
1552            "provenance_level",
1553            "dependency_bot",
1554            "health_badges",
1555            "license",
1556            "docs_site",
1557            "extra_fields",
1558            "warnings",
1559        ] {
1560            assert!(json.get(key).is_some(), "missing §4 key {key}");
1561        }
1562        assert!(json["versioning_pattern"].is_null());
1563        // A registry-only contract carries an explicit `distribution: null` — the
1564        // additive field is present but shape-neutral for existing configs.
1565        assert!(json["distribution"].is_null());
1566    }
1567
1568    // ── distribution (cargo-dist binary layer) ───────────────────────────────
1569
1570    /// A registry-only contract is unchanged by the additive `distribution`
1571    /// field: it normalizes clean and `distribution` is `None`.
1572    #[test]
1573    fn registry_only_contract_has_no_distribution() {
1574        let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract;
1575        assert_eq!(c.distribution, None);
1576        assert_eq!(c.targets.len(), 1);
1577        assert_eq!(c.targets[0].registry, Registry::CratesIo);
1578    }
1579
1580    /// A cargo-dist repo: a `distribution` block (binaries + shell/Homebrew
1581    /// installers + a tap) coexisting with a crates.io registry target.
1582    #[test]
1583    fn cargo_dist_distribution_coexists_with_registry() {
1584        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1585                    targets:\n  - {ecosystem: rust, package: issuectl, registry: crates.io, adapter: cargo-publish}\n\
1586                    distribution:\n  adapter: cargo-dist\n  installers: [shell, homebrew]\n  \
1587                    homebrew_tap: jarimustonen/homebrew-issuectl\n---\n";
1588        let n = norm(text);
1589        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1590        let c = n.contract;
1591        // The registry publish is still a Target.
1592        assert_eq!(c.targets.len(), 1);
1593        assert_eq!(c.targets[0].registry, Registry::CratesIo);
1594        assert_eq!(c.targets[0].adapter, Adapter::CargoPublish);
1595        // The binary layer is the Distribution block.
1596        let d = c.distribution.expect("distribution present");
1597        assert_eq!(d.adapter, DistributionAdapter::CargoDist);
1598        assert!(d.gh_releases); // default true
1599        assert_eq!(d.installers, vec![Installer::Shell, Installer::Homebrew]);
1600        assert_eq!(
1601            d.homebrew_tap.as_deref(),
1602            Some("jarimustonen/homebrew-issuectl")
1603        );
1604    }
1605
1606    /// Round-trip: the serialized JSON shape a downstream `/oss-*` member reads.
1607    #[test]
1608    fn distribution_json_round_trip_shape() {
1609        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1610                    distribution:\n  adapter: cargo-dist\n  gh_releases: true\n  \
1611                    installers: [shell, homebrew]\n  homebrew_tap: jarimustonen/homebrew-issuectl\n---\n";
1612        let json = serde_json::to_value(&norm(text).contract).unwrap();
1613        let d = &json["distribution"];
1614        assert_eq!(d["adapter"], "cargo-dist");
1615        assert_eq!(d["gh_releases"], true);
1616        assert_eq!(d["installers"], serde_json::json!(["shell", "homebrew"]));
1617        assert_eq!(d["homebrew_tap"], "jarimustonen/homebrew-issuectl");
1618    }
1619
1620    /// Forward-compat: an unknown key inside the `distribution` block is preserved
1621    /// under `distribution.extra_fields` (not dropped) and survives a
1622    /// parse→serialize round-trip, mirroring the top-level `extra_fields` capture.
1623    /// A warning reports it once; the known distribution keys are unaffected.
1624    #[test]
1625    fn distribution_unknown_subkey_preserved_and_warned() {
1626        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1627                    distribution:\n  adapter: cargo-dist\n  gh_releases: true\n  \
1628                    future_signing: {enabled: true, kms_key: alias/oss}\n---\n";
1629        let n = norm(text);
1630        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1631        let d = n
1632            .contract
1633            .clone()
1634            .distribution
1635            .expect("distribution present");
1636        // The unknown sub-key is captured, with its nested value intact.
1637        assert_eq!(
1638            d.extra_fields
1639                .get("future_signing")
1640                .and_then(|v| v.get("kms_key"))
1641                .and_then(|v| v.as_str()),
1642            Some("alias/oss")
1643        );
1644        // Known keys are untouched by the capture.
1645        assert_eq!(d.adapter, DistributionAdapter::CargoDist);
1646        assert!(d.gh_releases);
1647        // It round-trips through the serialized JSON downstream members read.
1648        let json = serde_json::to_value(&n.contract).unwrap();
1649        assert_eq!(
1650            json["distribution"]["extra_fields"]["future_signing"]["enabled"],
1651            serde_json::json!(true)
1652        );
1653        // Reported once, scoped to the block, naming the key.
1654        assert!(
1655            n.problems.warnings.iter().any(|w| {
1656                w.contains("unknown distribution field(s) preserved")
1657                    && w.contains("future_signing")
1658            }),
1659            "expected a scoped forward-compat warning: {:?}",
1660            n.problems.warnings
1661        );
1662    }
1663
1664    // ── installer ↔ platform cross-check (warning, not a floor) ──────────────
1665
1666    /// `installers: [msi]` with no Windows triple in `platforms` warns — the MSI
1667    /// installer points at a binary the release never builds. Still valid (warning,
1668    /// not error): the contract is internally consistent, just wasteful.
1669    #[test]
1670    fn msi_installer_without_windows_platform_warns() {
1671        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1672                    distribution:\n  adapter: cargo-dist\n  installers: [msi]\n  \
1673                    platforms: [x86_64-apple-darwin, x86_64-unknown-linux-musl]\n---\n";
1674        let n = norm(text);
1675        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1676        assert!(
1677            n.problems
1678                .warnings
1679                .iter()
1680                .any(|w| w.contains("includes 'msi'") && w.contains("no Windows")),
1681            "expected an msi/Windows cross-check warning: {:?}",
1682            n.problems.warnings
1683        );
1684    }
1685
1686    /// `installers: [msi]` WITH a Windows triple present → no cross-check warning.
1687    #[test]
1688    fn msi_installer_with_windows_platform_no_warning() {
1689        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1690                    distribution:\n  adapter: cargo-dist\n  installers: [msi]\n  \
1691                    platforms: [x86_64-pc-windows-msvc]\n---\n";
1692        let n = norm(text);
1693        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1694        assert!(
1695            !n.problems.warnings.iter().any(|w| w.contains("'msi'")),
1696            "unexpected msi cross-check warning: {:?}",
1697            n.problems.warnings
1698        );
1699    }
1700
1701    /// `installers: [homebrew]` with NEITHER a macOS nor a Linux triple warns —
1702    /// the generated formula has nothing to install. (A Windows-only platform set
1703    /// is the only way to strand a `homebrew` installer, since Homebrew serves
1704    /// both macOS and Linux.)
1705    #[test]
1706    fn homebrew_installer_without_darwin_or_linux_warns() {
1707        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1708                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
1709                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
1710                    platforms: [x86_64-pc-windows-msvc]\n---\n";
1711        let n = norm(text);
1712        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1713        assert!(
1714            n.problems
1715                .warnings
1716                .iter()
1717                .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
1718            "expected a homebrew/(macOS|Linux) cross-check warning: {:?}",
1719            n.problems.warnings
1720        );
1721    }
1722
1723    /// `installers: [homebrew]` is satisfied by a LINUX triple alone (Linuxbrew) —
1724    /// no darwin triple required. The chosen interpretation: homebrew needs macOS
1725    /// OR Linux, so a Linux-only platform set is coherent, not a warning.
1726    #[test]
1727    fn homebrew_installer_with_linux_only_no_warning() {
1728        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1729                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
1730                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
1731                    platforms: [x86_64-unknown-linux-musl]\n---\n";
1732        let n = norm(text);
1733        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1734        assert!(
1735            !n.problems.warnings.iter().any(|w| w.contains("'homebrew'")),
1736            "unexpected homebrew cross-check warning for a Linux-only set: {:?}",
1737            n.problems.warnings
1738        );
1739    }
1740
1741    /// npm and shell installers are OS-agnostic: even a platform set that would
1742    /// strand an msi (no Windows) never warns for them.
1743    #[test]
1744    fn npm_and_shell_installers_never_cross_check_warn() {
1745        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust, node]\n\
1746                    distribution:\n  adapter: cargo-dist\n  installers: [shell, npm]\n  \
1747                    platforms: [x86_64-apple-darwin]\n---\n";
1748        let n = norm(text);
1749        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1750        assert!(
1751            !n.problems
1752                .warnings
1753                .iter()
1754                .any(|w| w.contains("nothing to install")),
1755            "OS-agnostic installers must not cross-check warn: {:?}",
1756            n.problems.warnings
1757        );
1758    }
1759
1760    /// A coherent installer/platform set (msi + Windows, homebrew + darwin) emits
1761    /// no cross-check warning.
1762    #[test]
1763    fn coherent_installer_platform_set_no_warning() {
1764        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1765                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew, msi]\n  \
1766                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
1767                    platforms: [aarch64-apple-darwin, x86_64-pc-windows-msvc]\n---\n";
1768        let n = norm(text);
1769        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1770        assert!(
1771            !n.problems
1772                .warnings
1773                .iter()
1774                .any(|w| w.contains("nothing to install")),
1775            "coherent set must not warn: {:?}",
1776            n.problems.warnings
1777        );
1778    }
1779
1780    /// ossctl's own contract shape — installers `[shell, powershell]` with a
1781    /// platform set spanning Windows + macOS + Linux — produces no cross-check
1782    /// warning (both installers are agnostic here, and every OS is covered anyway).
1783    #[test]
1784    fn ossctl_own_contract_shape_no_cross_check_warning() {
1785        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1786                    distribution:\n  adapter: cargo-dist\n  installers: [shell, powershell]\n  \
1787                    platforms: [aarch64-apple-darwin, x86_64-apple-darwin, \
1788                    x86_64-unknown-linux-musl, aarch64-unknown-linux-musl, \
1789                    x86_64-pc-windows-msvc]\n---\n";
1790        let n = norm(text);
1791        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1792        assert!(
1793            !n.problems
1794                .warnings
1795                .iter()
1796                .any(|w| w.contains("nothing to install")),
1797            "ossctl's own shape must not cross-check warn: {:?}",
1798            n.problems.warnings
1799        );
1800    }
1801
1802    /// `installers: [msi]` with `platforms` OMITTED warns: the default set
1803    /// (macOS + Linux) carries no Windows triple, so the MSI installs nothing.
1804    /// This is the common footgun — the author added msi but never listed a
1805    /// Windows target.
1806    #[test]
1807    fn msi_installer_with_defaulted_platforms_warns() {
1808        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1809                    distribution:\n  adapter: cargo-dist\n  installers: [msi]\n---\n";
1810        let n = norm(text);
1811        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1812        assert!(
1813            n.problems
1814                .warnings
1815                .iter()
1816                .any(|w| w.contains("includes 'msi'") && w.contains("no Windows")),
1817            "expected an msi/Windows warning against the defaulted platform set: {:?}",
1818            n.problems.warnings
1819        );
1820    }
1821
1822    /// `installers: [msi]` is satisfied by a `*-windows-gnu` triple just as by
1823    /// `*-windows-msvc` — both target the Windows OS. No warning.
1824    #[test]
1825    fn msi_installer_with_windows_gnu_no_warning() {
1826        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1827                    distribution:\n  adapter: cargo-dist\n  installers: [msi]\n  \
1828                    platforms: [x86_64-pc-windows-gnu]\n---\n";
1829        let n = norm(text);
1830        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1831        assert!(
1832            !n.problems.warnings.iter().any(|w| w.contains("'msi'")),
1833            "windows-gnu must satisfy msi: {:?}",
1834            n.problems.warnings
1835        );
1836    }
1837
1838    /// `installers: [homebrew]` with an ANDROID-only platform set warns: Android
1839    /// triples (`aarch64-linux-android`) carry `linux` in the *vendor* slot but an
1840    /// `android` OS component — Homebrew/Linuxbrew does not serve Android, so the
1841    /// formula has nothing to install. Regression guard for the positional
1842    /// `triple_os` OS-component match (vs a naive any-component `== "linux"`).
1843    #[test]
1844    fn homebrew_installer_with_android_only_warns() {
1845        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1846                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
1847                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
1848                    platforms: [aarch64-linux-android]\n---\n";
1849        let n = norm(text);
1850        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1851        assert!(
1852            n.problems
1853                .warnings
1854                .iter()
1855                .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
1856            "Android-only must strand a homebrew installer: {:?}",
1857            n.problems.warnings
1858        );
1859    }
1860
1861    /// `installers: [homebrew]` with an APPLE-iOS-only set warns: `*-apple-ios`
1862    /// carries an `ios` OS component, not `darwin`, so it is not a macOS target and
1863    /// Homebrew serves neither iOS nor (here) Linux.
1864    #[test]
1865    fn homebrew_installer_with_apple_ios_only_warns() {
1866        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1867                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
1868                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
1869                    platforms: [aarch64-apple-ios]\n---\n";
1870        let n = norm(text);
1871        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1872        assert!(
1873            n.problems
1874                .warnings
1875                .iter()
1876                .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
1877            "apple-ios must not satisfy homebrew's macOS need: {:?}",
1878            n.problems.warnings
1879        );
1880    }
1881
1882    /// `installers: [homebrew]` with a macOS-only set (no Linux) is coherent — the
1883    /// isolated darwin case, distinct from the Linux-only test above.
1884    #[test]
1885    fn homebrew_installer_with_macos_only_no_warning() {
1886        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1887                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
1888                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
1889                    platforms: [aarch64-apple-darwin]\n---\n";
1890        let n = norm(text);
1891        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1892        assert!(
1893            !n.problems.warnings.iter().any(|w| w.contains("'homebrew'")),
1894            "macOS-only must satisfy homebrew: {:?}",
1895            n.problems.warnings
1896        );
1897    }
1898
1899    /// Two stranded installers → two independent warnings. A wasm-only platform
1900    /// set has no OS component any installer supports, so both `msi` and `homebrew`
1901    /// warn (exactly once each — the installer list is de-duped and canonically
1902    /// ordered).
1903    #[test]
1904    fn both_installers_stranded_warn_once_each() {
1905        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1906                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew, msi]\n  \
1907                    homebrew_tap: jarimustonen/homebrew-issuectl\n  \
1908                    platforms: [wasm32-unknown-unknown]\n---\n";
1909        let n = norm(text);
1910        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1911        let msi = n
1912            .problems
1913            .warnings
1914            .iter()
1915            .filter(|w| w.contains("includes 'msi'"))
1916            .count();
1917        let brew = n
1918            .problems
1919            .warnings
1920            .iter()
1921            .filter(|w| w.contains("includes 'homebrew'"))
1922            .count();
1923        assert_eq!((msi, brew), (1, 1), "warnings: {:?}", n.problems.warnings);
1924    }
1925
1926    /// A malformed triple that happens to contain an OS keyword must NOT drive the
1927    /// cross-check: the block has a parse error (uppercase triple), so the advisory
1928    /// is gated off entirely. Otherwise the misspelled `x86_64-PC-WINDOWS-MSVC`
1929    /// would silently "satisfy" msi and the warning would flip once the author
1930    /// fixed the typo.
1931    #[test]
1932    fn malformed_platform_triple_gates_off_cross_check() {
1933        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1934                    distribution:\n  adapter: cargo-dist\n  installers: [msi]\n  \
1935                    platforms: [x86_64-PC-WINDOWS-MSVC]\n---\n";
1936        let n = norm(text);
1937        // The uppercase triple is a hard error → the document is invalid …
1938        assert!(!n.is_valid(), "expected a malformed-triple error");
1939        // … and the cross-check emitted no (misleading) installer/platform warning.
1940        assert!(
1941            !n.problems
1942                .warnings
1943                .iter()
1944                .any(|w| w.contains("nothing to install")),
1945            "cross-check must be gated off while platforms has errors: {:?}",
1946            n.problems.warnings
1947        );
1948    }
1949
1950    /// A distribution block setting EVERY known key carries an empty
1951    /// `extra_fields` map and emits no forward-compat warning — the additive field
1952    /// is shape-neutral for existing contracts. Exercising all of
1953    /// `KNOWN_DISTRIBUTION_KEYS` guards against the allowlist drifting out of sync
1954    /// with the struct (a new known key wrongly captured as "unknown").
1955    #[test]
1956    fn distribution_all_known_keys_has_empty_extra_fields() {
1957        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1958                    distribution:\n  adapter: cargo-dist\n  gh_releases: true\n  \
1959                    installers: [shell, homebrew]\n  homebrew_tap: owner/tap\n  \
1960                    platforms: [x86_64-unknown-linux-musl]\n---\n";
1961        let n = norm(text);
1962        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1963        let d = n.contract.distribution.expect("distribution present");
1964        assert!(d.extra_fields.is_empty());
1965        assert!(
1966            !n.problems
1967                .warnings
1968                .iter()
1969                .any(|w| w.contains("unknown distribution field(s) preserved")),
1970            "no forward-compat warning for an all-known-keys block: {:?}",
1971            n.problems.warnings
1972        );
1973    }
1974
1975    /// Top-level and nested `extra_fields` capture are independent: a contract
1976    /// with BOTH an unknown top-level key AND an unknown distribution sub-key
1977    /// populates both maps and warns once for each, with the correct
1978    /// `schema_version` in each message.
1979    #[test]
1980    fn distribution_and_top_level_extra_fields_coexist() {
1981        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1982                    roadmap_url: https://example.com/x\n\
1983                    distribution:\n  adapter: cargo-dist\n  future_x: 1\n---\n";
1984        let n = norm(text);
1985        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1986        let c = n.contract.clone();
1987        assert!(c.extra_fields.contains_key("roadmap_url"));
1988        let d = c.distribution.expect("distribution present");
1989        assert_eq!(d.extra_fields.get("future_x"), Some(&serde_json::json!(1)));
1990        // Two independent forward-compat warnings, each naming schema_version 1.
1991        let fc: Vec<&String> = n
1992            .problems
1993            .warnings
1994            .iter()
1995            .filter(|w| w.contains("forward-compat") && w.contains("schema_version 1"))
1996            .collect();
1997        assert_eq!(fc.len(), 2, "expected two versioned warnings: {fc:?}");
1998    }
1999
2000    /// Installers de-dup into canonical order regardless of source order.
2001    #[test]
2002    fn distribution_installers_dedup_canonical_order() {
2003        let text = "---\nstatus: approved\nmaturity: mvp\n\
2004                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew, shell, homebrew]\n  \
2005                    homebrew_tap: owner/tap\n---\n";
2006        let d = norm(text).contract.distribution.unwrap();
2007        assert_eq!(d.installers, vec![Installer::Shell, Installer::Homebrew]);
2008    }
2009
2010    /// A `homebrew` installer without a tap is a floor error.
2011    #[test]
2012    fn distribution_homebrew_installer_requires_tap() {
2013        let text = "---\nstatus: approved\nmaturity: mvp\n\
2014                    distribution:\n  adapter: cargo-dist\n  installers: [shell, homebrew]\n---\n";
2015        assert_error_contains(
2016            &norm(text),
2017            "includes 'homebrew' but no distribution.homebrew_tap",
2018        );
2019    }
2020
2021    /// A malformed tap slug (not `owner/repo`) is rejected AND, because the
2022    /// invalid value substitutes `None`, the homebrew-needs-tap floor still fires
2023    /// — a present-but-invalid tap must not slip a `homebrew` installer through.
2024    #[test]
2025    fn distribution_bad_tap_slug_rejected() {
2026        let text = "---\nstatus: approved\nmaturity: mvp\n\
2027                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
2028                    homebrew_tap: not-a-slug\n---\n";
2029        let n = norm(text);
2030        assert_error_contains(&n, "must be an 'owner/repo' slug");
2031        assert!(
2032            n.problems
2033                .errors
2034                .iter()
2035                .any(|e| e.contains("includes 'homebrew' but no distribution.homebrew_tap")),
2036            "the tap floor must still fire on an invalid (→None) tap: {:?}",
2037            n.problems.errors
2038        );
2039        // The malformed slug never leaks into the built block.
2040        assert_eq!(n.contract.distribution.unwrap().homebrew_tap, None);
2041    }
2042
2043    /// An unknown installer flavor surfaces an error listing the valid set.
2044    #[test]
2045    fn distribution_bad_installer_rejected() {
2046        let text = "---\nstatus: approved\nmaturity: mvp\n\
2047                    distribution:\n  adapter: cargo-dist\n  installers: [snap]\n---\n";
2048        assert_error_contains(&norm(text), "distribution.installers");
2049    }
2050
2051    /// `adapter` is required when a distribution block is present — a bare
2052    /// `distribution: {}` must not silently claim cargo-dist ownership.
2053    #[test]
2054    fn distribution_adapter_is_required() {
2055        assert_error_contains(
2056            &norm("---\nstatus: approved\nmaturity: mvp\ndistribution: {}\n---\n"),
2057            "distribution.adapter is required",
2058        );
2059    }
2060
2061    /// A distribution block ships public binaries — forbidden at maturity 'spike'
2062    /// (mirrors the `release.model: auto`-on-spike floor).
2063    #[test]
2064    fn distribution_forbidden_on_spike() {
2065        let text = "---\nstatus: approved\nmaturity: spike\n\
2066                    distribution:\n  adapter: cargo-dist\n---\n";
2067        assert_error_contains(&norm(text), "not allowed on maturity 'spike'");
2068    }
2069
2070    /// A `homebrew_tap` set with neither a `homebrew` installer nor a
2071    /// `homebrew`-registry target is dead config — a warning, not a floor (the
2072    /// contract is still valid). This is the genuinely-orphaned tap: no consumer
2073    /// exists, so the tap is truly never updated.
2074    #[test]
2075    fn distribution_tap_without_installer_warns() {
2076        let text = "---\nstatus: approved\nmaturity: mvp\n\
2077                    distribution:\n  adapter: cargo-dist\n  installers: [shell]\n  \
2078                    homebrew_tap: owner/tap\n---\n";
2079        let n = norm(text);
2080        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2081        assert!(
2082            n.problems
2083                .warnings
2084                .iter()
2085                .any(|w| w.contains("no formula is generated, so the tap will never be updated")),
2086            "expected dead-tap warning: {:?}",
2087            n.problems.warnings
2088        );
2089    }
2090
2091    /// A `homebrew_tap` set with NO `homebrew` installer but WITH a
2092    /// `homebrew`-registry target (the release engine's homebrew-tap adapter, which
2093    /// pushes the formula in its `dist` phase) is NOT dead config — the tap IS
2094    /// updated by the engine, so the dead-config warning must NOT fire. This is
2095    /// ossctl's own (correct) contract shape.
2096    #[test]
2097    fn distribution_tap_with_homebrew_target_no_warning() {
2098        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
2099                    targets:\n  \
2100                    - {ecosystem: rust, package: ossctl, registry: crates.io, adapter: cargo-publish}\n  \
2101                    - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: homebrew-tap}\n\
2102                    distribution:\n  adapter: cargo-dist\n  installers: [shell]\n  \
2103                    homebrew_tap: owner/tap\n---\n";
2104        let n = norm(text);
2105        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2106        // Stronger than a negative substring match: the whole contract is clean,
2107        // so it must produce NO warnings at all — this also catches any reworded
2108        // dead-tap advisory that a substring check would miss.
2109        assert!(
2110            n.problems.warnings.is_empty(),
2111            "homebrew-target contract must not warn: {:?}",
2112            n.problems.warnings
2113        );
2114    }
2115
2116    /// A goreleaser distribution with no installers and no tap is valid — the
2117    /// block is minimal and forward-compatible.
2118    #[test]
2119    fn distribution_goreleaser_minimal_is_valid() {
2120        let text = "---\nstatus: approved\nmaturity: production\necosystems: [go]\n\
2121                    distribution:\n  adapter: goreleaser\n  gh_releases: true\n---\n";
2122        let n = norm(text);
2123        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2124        let d = n.contract.distribution.unwrap();
2125        assert_eq!(d.adapter, DistributionAdapter::Goreleaser);
2126        assert!(d.installers.is_empty());
2127        assert_eq!(d.homebrew_tap, None);
2128    }
2129
2130    /// A non-mapping `distribution` value is a structural error.
2131    #[test]
2132    fn distribution_non_mapping_rejected() {
2133        assert_error_contains(
2134            &norm("---\nstatus: approved\nmaturity: mvp\ndistribution: [nope]\n---\n"),
2135            "distribution must be a mapping",
2136        );
2137    }
2138
2139    // ── distribution.platforms (cross-platform target set) ───────────────────
2140
2141    /// Helper: does a platform list contain any Linux triple? The cross-platform
2142    /// install requirement is "at least one Linux triple", inspected via the OS
2143    /// component of the triple (exactly how `audit` will read this field).
2144    fn has_linux(platforms: &[String]) -> bool {
2145        platforms.iter().any(|t| t.contains("-linux"))
2146    }
2147
2148    /// Omitted `platforms` → the cross-platform default (macOS + Linux). The
2149    /// KEYSTONE assertion: the DEFAULT covers Linux, so every distribution that
2150    /// omits the field does (an explicit set is the author's own choice, which the
2151    /// cross-platform `audit` — not this normalizer — checks).
2152    #[test]
2153    fn distribution_platforms_default_is_cross_platform() {
2154        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2155                    distribution:\n  adapter: cargo-dist\n---\n";
2156        let d = norm(text)
2157            .contract
2158            .distribution
2159            .expect("distribution present");
2160        assert_eq!(
2161            d.platforms,
2162            vec![
2163                "aarch64-apple-darwin",
2164                "x86_64-apple-darwin",
2165                "aarch64-unknown-linux-musl",
2166                "x86_64-unknown-linux-musl",
2167            ]
2168        );
2169        assert!(
2170            has_linux(&d.platforms),
2171            "the default set MUST contain a Linux triple: {:?}",
2172            d.platforms
2173        );
2174    }
2175
2176    /// An explicit `platforms` list round-trips through normalization and the
2177    /// serialized JSON downstream members read, order + values preserved.
2178    #[test]
2179    fn distribution_platforms_explicit_round_trips() {
2180        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2181                    distribution:\n  adapter: cargo-dist\n  \
2182                    platforms: [x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc]\n---\n";
2183        let n = norm(text);
2184        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2185        let d = n.contract.clone().distribution.unwrap();
2186        assert_eq!(
2187            d.platforms,
2188            vec!["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
2189        );
2190        let json = serde_json::to_value(&n.contract).unwrap();
2191        assert_eq!(
2192            json["distribution"]["platforms"],
2193            serde_json::json!(["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"])
2194        );
2195    }
2196
2197    /// An explicit empty `platforms: []` is a hard error — NOT silently defaulted.
2198    /// Only an omitted/null field yields the cross-platform default; an empty list
2199    /// is a mistake (a distribution with no platforms builds nothing) and, if
2200    /// silently defaulted, would surprise the author and erase the intent the
2201    /// cross-platform audit needs to see.
2202    #[test]
2203    fn distribution_platforms_empty_is_rejected() {
2204        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2205                    distribution:\n  adapter: cargo-dist\n  platforms: []\n---\n";
2206        assert_error_contains(&norm(text), "empty list — omit the key");
2207    }
2208
2209    /// Duplicate triples de-duplicate, preserving first-seen order.
2210    #[test]
2211    fn distribution_platforms_dedup_preserves_order() {
2212        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2213                    distribution:\n  adapter: cargo-dist\n  \
2214                    platforms: [aarch64-apple-darwin, x86_64-apple-darwin, aarch64-apple-darwin]\n---\n";
2215        let d = norm(text).contract.distribution.unwrap();
2216        assert_eq!(
2217            d.platforms,
2218            vec!["aarch64-apple-darwin", "x86_64-apple-darwin"]
2219        );
2220    }
2221
2222    /// A malformed triple is rejected with a message naming the field.
2223    #[test]
2224    fn distribution_platforms_bad_triple_rejected() {
2225        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2226                    distribution:\n  adapter: cargo-dist\n  platforms: [not_a_triple]\n---\n";
2227        assert_error_contains(&norm(text), "is not a well-formed target-triple");
2228    }
2229
2230    /// A non-string entry (a nested list) is rejected structurally.
2231    #[test]
2232    fn distribution_platforms_non_string_entry_rejected() {
2233        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2234                    distribution:\n  adapter: cargo-dist\n  platforms: [[nope]]\n---\n";
2235        assert_error_contains(&norm(text), "each entry must be a target-triple string");
2236    }
2237
2238    /// A `platforms` value that is not a list is a structural error.
2239    #[test]
2240    fn distribution_platforms_non_list_rejected() {
2241        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2242                    distribution:\n  adapter: cargo-dist\n  platforms: x86_64-apple-darwin\n---\n";
2243        assert_error_contains(&norm(text), "must be a list of target-triple strings");
2244    }
2245
2246    /// Regression: a registry-only contract (no distribution block at all) is
2247    /// wholly unaffected by the additive `platforms` field — no distribution, so
2248    /// no `platforms` in the emitted shape.
2249    #[test]
2250    fn registry_only_contract_unaffected_by_platforms() {
2251        let json = serde_json::to_value(
2252            &norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract,
2253        )
2254        .unwrap();
2255        assert!(json["distribution"].is_null());
2256    }
2257
2258    #[test]
2259    fn looks_like_target_triple_verdicts() {
2260        // Standard triples across arch/vendor/os/env shapes.
2261        assert!(looks_like_target_triple("aarch64-apple-darwin"));
2262        assert!(looks_like_target_triple("x86_64-apple-darwin"));
2263        assert!(looks_like_target_triple("x86_64-unknown-linux-musl"));
2264        assert!(looks_like_target_triple("x86_64-unknown-linux-gnu"));
2265        assert!(looks_like_target_triple("x86_64-pc-windows-msvc"));
2266        assert!(looks_like_target_triple("armv7-unknown-linux-gnueabihf"));
2267        assert!(looks_like_target_triple("wasm32-wasi"));
2268        // Real dotted arch names must pass (regression: the `.` was rejected).
2269        assert!(looks_like_target_triple("thumbv8m.main-none-eabi"));
2270        assert!(looks_like_target_triple("thumbv8m.base-none-eabi"));
2271        // Rejects: too few/many components, empty parts, case, punctuation.
2272        assert!(!looks_like_target_triple("linux"));
2273        assert!(!looks_like_target_triple("a-b-c-d-e"));
2274        assert!(!looks_like_target_triple("x86_64--linux"));
2275        assert!(!looks_like_target_triple("-apple-darwin"));
2276        assert!(!looks_like_target_triple("X86_64-apple-darwin"));
2277        assert!(!looks_like_target_triple("x86_64-apple-darwin;rm"));
2278        assert!(!looks_like_target_triple("x86_64 apple darwin"));
2279        assert!(!looks_like_target_triple(""));
2280        // Structural-only: nonsense that happens to be well-formed IS accepted —
2281        // the toolchain, not the contract, is the authority on buildability.
2282        assert!(looks_like_target_triple("aa-bb"));
2283    }
2284
2285    #[test]
2286    fn is_tap_slug_verdicts() {
2287        // Valid GitHub-style slugs.
2288        assert!(is_tap_slug("owner/repo"));
2289        assert!(is_tap_slug("jarimustonen/homebrew-issuectl"));
2290        assert!(is_tap_slug("Owner_1/repo.rb"));
2291        // Structural rejects.
2292        assert!(!is_tap_slug("no-slash"));
2293        assert!(!is_tap_slug("/repo"));
2294        assert!(!is_tap_slug("owner/"));
2295        assert!(!is_tap_slug("owner/repo/extra"));
2296        assert!(!is_tap_slug("owner / repo"));
2297        // Strict-charset rejects: path traversal, punctuation, injection chars.
2298        assert!(!is_tap_slug("owner/.."));
2299        assert!(!is_tap_slug("../repo"));
2300        assert!(!is_tap_slug("owner/repo;rm -rf"));
2301        assert!(!is_tap_slug("owner/@repo"));
2302        assert!(!is_tap_slug("ownér/repo"));
2303    }
2304}