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    let distribution = parse_distribution(map.get("distribution"), p);
287
288    // changelog (mode + source + fragment_dir).
289    let changelog = match map.get("changelog") {
290        None | Some(Value::Null) => Changelog {
291            mode: ChangelogMode::Curated,
292            source: ChangelogSource::Manual,
293            fragment_dir: DEFAULT_FRAGMENT_DIR.to_string(),
294        },
295        Some(Value::Mapping(m)) => {
296            let mode = enum_field!(m, "mode", ChangelogMode, ChangelogMode::Curated, p);
297            let source = enum_field!(m, "source", ChangelogSource, ChangelogSource::Manual, p);
298            let fragment_dir = match m.get("fragment_dir") {
299                None => DEFAULT_FRAGMENT_DIR.to_string(),
300                Some(v) => {
301                    if let Some(s) = v.as_str() {
302                        s.to_string()
303                    } else {
304                        p.err("changelog.fragment_dir must be a string path".to_string());
305                        DEFAULT_FRAGMENT_DIR.to_string()
306                    }
307                }
308            };
309            Changelog {
310                mode,
311                source,
312                fragment_dir,
313            }
314        }
315        Some(_) => {
316            p.err("changelog must be a mapping with mode/source".to_string());
317            Changelog {
318                mode: ChangelogMode::Curated,
319                source: ChangelogSource::Manual,
320                fragment_dir: DEFAULT_FRAGMENT_DIR.to_string(),
321            }
322        }
323    };
324    // fragment_dir must be a relative path inside the repo (floor 6).
325    if !path_inside_repo(&changelog.fragment_dir) {
326        p.err(format!(
327            "floor: changelog.fragment_dir '{}' must be a relative path inside the repo (an \
328             absolute or '../'-escaping path is refused)",
329            changelog.fragment_dir
330        ));
331    }
332
333    // conventional_commits.
334    let conventional_commits = match map.get("conventional_commits") {
335        None => false,
336        Some(Value::Bool(b)) => *b,
337        Some(v) => {
338            p.err(format!(
339                "conventional_commits must be true|false, got {}",
340                yaml_display(v)
341            ));
342            false
343        }
344    };
345
346    let contribution_provenance = enum_field!(
347        map,
348        "contribution_provenance",
349        ContributionProvenance,
350        ContributionProvenance::None,
351        p
352    );
353    let provenance_level = enum_field!(
354        map,
355        "provenance_level",
356        ProvenanceLevel,
357        ProvenanceLevel::None,
358        p
359    );
360
361    let dep_default = if maturity == Maturity::Spike {
362        DependencyBot::None
363    } else {
364        DependencyBot::Dependabot
365    };
366    let dependency_bot = enum_field!(map, "dependency_bot", DependencyBot, dep_default, p);
367
368    // license — a valid SPDX expression when set (default MIT).
369    let license = match map.get("license") {
370        None => "MIT".to_string(),
371        Some(v) => match v.as_str() {
372            Some(s) if !s.trim().is_empty() => {
373                if !spdx_valid(s) {
374                    p.err(format!(
375                        "license '{s}' is not a valid SPDX expression (unknown id or malformed \
376                         AND/OR/WITH grammar)"
377                    ));
378                }
379                s.to_string()
380            }
381            _ => {
382                p.err("license must be a non-empty SPDX id/expression (default MIT)".to_string());
383                "MIT".to_string()
384            }
385        },
386    };
387
388    let docs_site = enum_field!(map, "docs_site", DocsSite, DocsSite::None, p);
389
390    // health_badges — validate when present (key-presence, per Python), else
391    // materialize a floor-clean default (maturity/target aware).
392    let health_badges = if map.contains_key("health_badges") {
393        let mut out = Vec::new();
394        for item in as_list(map.get("health_badges")) {
395            match item.as_str().and_then(HealthBadge::parse) {
396                Some(hb) => out.push(hb),
397                None => p.err(format!(
398                    "health_badges: {} invalid — must be one of {:?}",
399                    yaml_display(&item),
400                    HealthBadge::VALID
401                )),
402            }
403        }
404        out
405    } else {
406        default_health_badges(maturity, &targets)
407    };
408
409    // ── Cross-field floors (§2) — config-internal, ALWAYS hard errors ────────
410    if model == ReleaseModel::Auto && maturity == Maturity::Spike {
411        p.err(
412            "floor: release.model 'auto' is not allowed on maturity 'spike' — a spike is not \
413             being published; raise maturity or set release.model: gated"
414                .to_string(),
415        );
416    }
417    if provenance_level == ProvenanceLevel::SlsaL3 && maturity != Maturity::Production {
418        p.err(format!(
419            "floor: provenance_level 'slsa-l3' is production-only — current maturity is '{}'",
420            maturity.as_str()
421        ));
422    }
423    // A target with a registry requires a valid SPDX license. Every expanded
424    // target carries a registry, so "any registry" reduces to "any target".
425    if !targets.is_empty() && !spdx_valid(&license) {
426        p.err(format!(
427            "floor: a target has a registry (crates.io/npm/PyPI/… require a license) but license \
428             '{license}' is not a valid SPDX expression"
429        ));
430    }
431    check_badge_producers(&health_badges, maturity, &targets, p);
432    // A distribution block ships public binaries (GH-Release artifacts, a curl-pipe
433    // installer, a Homebrew tap PR) — that is publishing, and a spike is not being
434    // published. Mirrors the `release.model: auto` floor: raise maturity or drop the
435    // block. (Absent block → no constraint; registry-only spikes are unaffected.)
436    if distribution.is_some() && maturity == Maturity::Spike {
437        p.err(
438            "floor: a distribution block ships public binaries (installer + tap) — not allowed on \
439             maturity 'spike' (a spike is not being published); raise maturity or drop distribution"
440                .to_string(),
441        );
442    }
443
444    // ── Filesystem/producer-existence semantic check — ADVISORY, never fatal ─
445    if changelog.mode == ChangelogMode::Fragment
446        && path_inside_repo(&changelog.fragment_dir)
447        && !fs.is_dir(&repo_root.join(&changelog.fragment_dir))
448    {
449        p.warn(format!(
450            "changelog.mode 'fragment' but the fragment dir '{}' does not exist yet under {} — \
451             /oss-changelog creates it; /oss-readiness reports it as a gap until then",
452            changelog.fragment_dir,
453            repo_root.display()
454        ));
455    }
456
457    // ── Forward-compat: preserve unknown fields, report once ─────────────────
458    let mut extra_fields = serde_json::Map::new();
459    for (k, v) in map {
460        if let Value::String(key) = k {
461            if !KNOWN_KEYS.contains(&key.as_str()) {
462                extra_fields.insert(key.clone(), yaml_to_json(v));
463            }
464        }
465    }
466    if !extra_fields.is_empty() {
467        // serde_json::Map is ordered (BTreeMap) → keys already sorted. Rendered
468        // as a single-quoted list to match the Python normalizer's message.
469        let keys = extra_fields
470            .keys()
471            .map(|k| format!("'{k}'"))
472            .collect::<Vec<_>>()
473            .join(", ");
474        p.warn(format!(
475            "unknown field(s) preserved under schema_version {schema_version} (forward-compat): \
476             [{keys}]"
477        ));
478    }
479
480    let warnings = p.warnings.clone();
481    Contract {
482        schema_version,
483        status,
484        maturity,
485        ecosystems,
486        targets,
487        distribution,
488        versioning,
489        versioning_pattern,
490        changelog,
491        conventional_commits,
492        release: Release { model, layout },
493        contribution_provenance,
494        provenance_level,
495        dependency_bot,
496        health_badges,
497        license,
498        docs_site,
499        extra_fields,
500        warnings,
501    }
502}
503
504fn parse_versioning(value: Option<&Value>, p: &mut Problems) -> (VersioningBase, Option<String>) {
505    let Some(v) = value else {
506        return (VersioningBase::Semver, None);
507    };
508    let Some(s) = v.as_str() else {
509        p.err(format!(
510            "versioning {} invalid — must be semver | calver:<pattern> | zerover",
511            yaml_display(v)
512        ));
513        return (VersioningBase::Semver, None);
514    };
515    if let Some(rest) = s.strip_prefix("calver:") {
516        let pattern = rest.trim();
517        if pattern.is_empty() {
518            p.err(
519                "versioning 'calver:' carries no pattern — e.g. calver:YYYY.MM.MICRO".to_string(),
520            );
521        }
522        (VersioningBase::Calver, Some(pattern.to_string()))
523    } else if s == "calver" {
524        p.err(
525            "versioning 'calver' must carry its pattern (calver:YYYY.MM.MICRO), not a bare label"
526                .to_string(),
527        );
528        (VersioningBase::Calver, None)
529    } else if let Some(base) = VersioningBase::parse(s) {
530        (base, None)
531    } else {
532        p.err(format!(
533            "versioning '{s}' invalid — must be semver | calver:<pattern> | zerover"
534        ));
535        (VersioningBase::Semver, None)
536    }
537}
538
539/// Derive one target per ecosystem with default registry + adapter.
540fn expand_targets(ecosystems: &[Ecosystem], layout: ReleaseLayout) -> Vec<Target> {
541    ecosystems
542        .iter()
543        .map(|&e| Target {
544            ecosystem: e,
545            package: None,
546            registry: e.default_registry(),
547            adapter: e.default_adapter(layout),
548        })
549        .collect()
550}
551
552fn validate_targets(
553    seq: &[Value],
554    ecosystems: &[Ecosystem],
555    layout: ReleaseLayout,
556    p: &mut Problems,
557) -> Vec<Target> {
558    let mut out = Vec::new();
559    for (idx, item) in seq.iter().enumerate() {
560        let Value::Mapping(m) = item else {
561            p.err(format!(
562                "targets[{idx}] must be a map with at least {{ecosystem, registry}}"
563            ));
564            continue;
565        };
566
567        let ecosystem = if let Some(s) = m.get("ecosystem").and_then(Value::as_str) {
568            if let Some(e) = Ecosystem::parse(s) {
569                if !ecosystems.is_empty() && !ecosystems.contains(&e) {
570                    p.err(format!(
571                        "targets[{idx}].ecosystem '{s}' is not in ecosystems {:?}",
572                        ecosystems.iter().map(|e| e.as_str()).collect::<Vec<_>>()
573                    ));
574                }
575                Some(e)
576            } else {
577                p.err(format!(
578                    "targets[{idx}].ecosystem '{s}' invalid — one of {:?}",
579                    Ecosystem::VALID
580                ));
581                None
582            }
583        } else {
584            p.err(format!(
585                "targets[{idx}].ecosystem invalid — one of {:?}",
586                Ecosystem::VALID
587            ));
588            None
589        };
590
591        let registry = match m.get("registry").and_then(Value::as_str) {
592            None => {
593                p.err(format!(
594                    "targets[{idx}] has no registry (required — the publish destination)"
595                ));
596                None
597            }
598            Some(s) => {
599                if let Some(r) = Registry::parse(s) {
600                    Some(r)
601                } else {
602                    p.err(format!(
603                        "targets[{idx}].registry '{s}' invalid — one of {:?}",
604                        Registry::VALID
605                    ));
606                    None
607                }
608            }
609        };
610
611        let adapter = match m.get("adapter") {
612            None => ecosystem.map(|e| e.default_adapter(layout)),
613            Some(v) => {
614                if let Some(a) = v.as_str().and_then(Adapter::parse) {
615                    Some(a)
616                } else {
617                    p.err(format!(
618                        "targets[{idx}].adapter {} invalid — one of {:?}",
619                        yaml_display(v),
620                        Adapter::VALID
621                    ));
622                    None
623                }
624            }
625        };
626
627        // On the error path, placeholders keep the strong type; the document is
628        // never emitted when problems.errors is non-empty.
629        out.push(Target {
630            ecosystem: ecosystem.unwrap_or(Ecosystem::Binary),
631            package: m.get("package").and_then(Value::as_str).map(str::to_string),
632            registry: registry.unwrap_or(Registry::GhReleases),
633            adapter: adapter.unwrap_or(Adapter::Manual),
634        });
635    }
636    out
637}
638
639/// Canonical installer order — used to de-duplicate and stably order the
640/// `distribution.installers` list (mirrors [`ECOSYSTEM_ORDER`]'s role).
641const INSTALLER_ORDER: [Installer; 5] = [
642    Installer::Shell,
643    Installer::Powershell,
644    Installer::Homebrew,
645    Installer::Msi,
646    Installer::Npm,
647];
648
649/// Parse the optional `distribution` block (the cargo-dist/goreleaser binary
650/// layer). Absent/null → `None`, leaving a registry-only contract unchanged. A
651/// present-but-non-mapping value is an error (with `None` on the error path — the
652/// document is never emitted while `problems.errors` is non-empty).
653#[allow(clippy::too_many_lines)]
654fn parse_distribution(value: Option<&Value>, p: &mut Problems) -> Option<Distribution> {
655    let m = match value {
656        None | Some(Value::Null) => return None,
657        Some(Value::Mapping(m)) => m,
658        Some(_) => {
659            p.err(
660                "distribution must be a mapping with {adapter?, gh_releases?, installers?, \
661                 homebrew_tap?, platforms?}"
662                    .to_string(),
663            );
664            return None;
665        }
666    };
667
668    // adapter — required when the block is present. Which tool OWNS the existing
669    // tag-triggered release workflow is not the normalizer's to guess (it renames
670    // release semantics and picks a Rust-specific default); inference is
671    // /oss-init's job, exactly as for `maturity`. A bare `distribution: {}` is
672    // therefore an error, not a silent "cargo-dist owns this repo".
673    let adapter = match m.get("adapter") {
674        None => {
675            p.err(
676                "distribution.adapter is required when a distribution block is present \
677                 (cargo-dist|goreleaser|manual) — /oss-init infers it"
678                    .to_string(),
679            );
680            DistributionAdapter::CargoDist
681        }
682        Some(v) => {
683            if let Some(a) = v.as_str().and_then(DistributionAdapter::parse) {
684                a
685            } else {
686                p.err(format!(
687                    "distribution.adapter {} invalid — must be one of {:?}",
688                    yaml_display(v),
689                    DistributionAdapter::VALID
690                ));
691                DistributionAdapter::CargoDist
692            }
693        }
694    };
695
696    let gh_releases = match m.get("gh_releases") {
697        // cargo-dist/goreleaser attach per-platform binaries by default.
698        None => true,
699        Some(Value::Bool(b)) => *b,
700        Some(v) => {
701            p.err(format!(
702                "distribution.gh_releases must be true|false, got {}",
703                yaml_display(v)
704            ));
705            true
706        }
707    };
708
709    // installers — validate each, then de-dup into canonical order.
710    let mut parsed_installers: Vec<Installer> = Vec::new();
711    for item in as_list(m.get("installers")) {
712        match item.as_str().and_then(Installer::parse) {
713            Some(i) => parsed_installers.push(i),
714            None => p.err(format!(
715                "distribution.installers: {} invalid — must be one of {:?}",
716                yaml_display(&item),
717                Installer::VALID
718            )),
719        }
720    }
721    let installers: Vec<Installer> = INSTALLER_ORDER
722        .into_iter()
723        .filter(|i| parsed_installers.contains(i))
724        .collect();
725
726    let homebrew_tap = match m.get("homebrew_tap") {
727        None | Some(Value::Null) => None,
728        Some(v) => match v.as_str() {
729            Some(s) if is_tap_slug(s) => Some(s.to_string()),
730            // An invalid slug substitutes `None` (not the bad value) so the
731            // built `Distribution` never carries a malformed tap — matching the
732            // "placeholders keep the strong type" error-path rule the rest of the
733            // normalizer follows, and letting the homebrew-needs-tap floor below
734            // still fire (a present-but-invalid tap is no tap).
735            Some(s) => {
736                p.err(format!(
737                    "distribution.homebrew_tap '{s}' invalid — must be an 'owner/repo' slug"
738                ));
739                None
740            }
741            None => {
742                p.err("distribution.homebrew_tap must be an 'owner/repo' string".to_string());
743                None
744            }
745        },
746    };
747
748    let wants_homebrew = installers.contains(&Installer::Homebrew);
749    // Floor: a `homebrew` installer needs a tap to push the generated formula to.
750    if wants_homebrew && homebrew_tap.is_none() {
751        p.err(
752            "floor: distribution.installers includes 'homebrew' but no distribution.homebrew_tap \
753             is set — the generated formula has nowhere to be pushed"
754                .to_string(),
755        );
756    }
757    // Advisory: a tap with no `homebrew` installer is dead config — the formula
758    // is never generated, so the tap is never pushed to. A warning, not a floor:
759    // the contract is internally consistent, just wasteful.
760    if homebrew_tap.is_some() && !wants_homebrew {
761        p.warn(
762            "distribution.homebrew_tap is set but 'homebrew' is not in distribution.installers — \
763             no formula is generated, so the tap will never be updated"
764                .to_string(),
765        );
766    }
767
768    // platforms — the binary target-triple set. Omitted/null → the cross-platform
769    // default (macOS + Linux musl), so a distribution that doesn't specify
770    // platforms covers Linux by default (the cross-platform install requirement).
771    // An explicit list is validated per triple and de-duplicated, preserving the
772    // author's order (like the sibling `targets` list — there is no canonical
773    // triple ordering to impose). An explicit *empty* list is NOT the same as
774    // omitted: it is a mistake, and silently defaulting it would surprise the
775    // author with targets they never listed and erase the intent the downstream
776    // cross-platform audit needs — so it is a hard error.
777    let platforms = match m.get("platforms") {
778        None | Some(Value::Null) => default_cross_platform_targets(),
779        Some(Value::Sequence(seq)) if seq.is_empty() => {
780            // Default fallback keeps error-collection going; the contract is never
781            // emitted while `problems.errors` is non-empty.
782            p.err(
783                "distribution.platforms is an empty list — omit the key to accept the \
784                 cross-platform default (macOS + Linux) or list explicit target-triples; a \
785                 distribution with no platforms builds nothing"
786                    .to_string(),
787            );
788            default_cross_platform_targets()
789        }
790        Some(Value::Sequence(seq)) => {
791            let mut out: Vec<String> = Vec::new();
792            for item in seq {
793                match item.as_str() {
794                    Some(s) if looks_like_target_triple(s) => {
795                        let triple = s.to_string();
796                        if !out.contains(&triple) {
797                            out.push(triple);
798                        }
799                    }
800                    Some(s) => p.err(format!(
801                        "distribution.platforms: '{s}' is not a well-formed target-triple \
802                         (e.g. x86_64-unknown-linux-musl, aarch64-apple-darwin) — structural \
803                         check only; the toolchain is the final authority on what builds"
804                    )),
805                    None => p.err(format!(
806                        "distribution.platforms: {} invalid — each entry must be a \
807                         target-triple string",
808                        yaml_display(item)
809                    )),
810                }
811            }
812            out
813        }
814        Some(v) => {
815            p.err(format!(
816                "distribution.platforms must be a list of target-triple strings, got {}",
817                yaml_display(v)
818            ));
819            default_cross_platform_targets()
820        }
821    };
822
823    Some(Distribution {
824        adapter,
825        gh_releases,
826        installers,
827        homebrew_tap,
828        platforms,
829    })
830}
831
832/// The cross-platform default `distribution.platforms` set as owned strings —
833/// materialized when the block omits `platforms` (or gives an empty list). Always
834/// contains at least one Linux triple (the cross-platform install requirement).
835fn default_cross_platform_targets() -> Vec<String> {
836    DEFAULT_CROSS_PLATFORM_TARGETS
837        .iter()
838        .map(|&s| s.to_string())
839        .collect()
840}
841
842/// Whether `s` is a *structurally* plausible target-triple — 2–4 `-`-separated
843/// components, each a non-empty run of `[a-z0-9_.]`. Deliberately LEXICAL, not
844/// semantic: the real triple set is open and rustc-defined, so this is a
845/// well-formedness gate, not a whitelist. It rejects what could never be a triple
846/// (empty parts, uppercase, whitespace, punctuation, injection chars, wrong shape)
847/// and accepts real triples including dotted arch names like
848/// `thumbv8m.main-none-eabi` — but it also accepts structurally-valid nonsense like
849/// `aa-bb`, because the toolchain is the final authority on whether a triple
850/// actually builds. The OS component stays intact and inspectable so the
851/// cross-platform `audit` can classify a set downstream.
852fn looks_like_target_triple(s: &str) -> bool {
853    let parts: Vec<&str> = s.split('-').collect();
854    (2..=4).contains(&parts.len())
855        && parts.iter().all(|part| {
856            !part.is_empty()
857                && part.bytes().all(|b| {
858                    b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.')
859                })
860        })
861}
862
863/// Whether `s` is a plausible `owner/repo` tap slug — exactly one `/`, and each
864/// part a non-empty run of the GitHub-name character set (ASCII alphanumeric plus
865/// `-`, `_`, `.`), with `.`/`..` rejected. Lexical only — existence is not
866/// checked. Deliberately strict: this value flows into `brew tap` and repo URLs
867/// downstream, so arbitrary punctuation, whitespace, or path traversal
868/// (`owner/..`) must not pass.
869fn is_tap_slug(s: &str) -> bool {
870    fn valid_part(part: &str) -> bool {
871        !part.is_empty()
872            && part != "."
873            && part != ".."
874            && part
875                .bytes()
876                .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
877    }
878    match s.split_once('/') {
879        Some((owner, repo)) => valid_part(owner) && valid_part(repo) && !repo.contains('/'),
880        None => false,
881    }
882}
883
884/// A floor-clean default badge set: `ci` at mvp+, `registry` when a publishable
885/// target exists, `license` always.
886fn default_health_badges(maturity: Maturity, targets: &[Target]) -> Vec<HealthBadge> {
887    let mut badges = Vec::new();
888    if matches!(maturity, Maturity::Mvp | Maturity::Production) {
889        badges.push(HealthBadge::Ci);
890    }
891    if !targets.is_empty() {
892        badges.push(HealthBadge::Registry);
893    }
894    badges.push(HealthBadge::License);
895    badges
896}
897
898/// Every enabled badge must have its producer enabled (floor 4).
899fn check_badge_producers(
900    badges: &[HealthBadge],
901    maturity: Maturity,
902    targets: &[Target],
903    p: &mut Problems,
904) {
905    let has_registry_target = !targets.is_empty();
906    for b in badges {
907        match b {
908            HealthBadge::Ci if maturity == Maturity::Spike => p.err(
909                "floor: health_badge 'ci' has no producer at maturity 'spike' (no CI until mvp) — \
910                 drop it or raise maturity"
911                    .to_string(),
912            ),
913            HealthBadge::Registry if !has_registry_target => p.err(
914                "floor: health_badge 'registry' has no producer — no target has a registry to \
915                 publish to"
916                    .to_string(),
917            ),
918            HealthBadge::Coverage if maturity != Maturity::Production => p.err(format!(
919                "floor: health_badge 'coverage' has no producer — the coverage gate is a \
920                 production-tier /oss-ci output; current maturity is '{}'",
921                maturity.as_str()
922            )),
923            HealthBadge::Scorecard if maturity != Maturity::Production => p.err(format!(
924                "floor: health_badge 'scorecard' has no producer — the Scorecard action is a \
925                 production-tier output; current maturity is '{}'",
926                maturity.as_str()
927            )),
928            _ => {}
929        }
930    }
931}
932
933// ── Frontmatter extraction + parse ───────────────────────────────────────────
934
935/// A `---` fence line (exactly three dashes plus optional trailing whitespace).
936fn is_fence(line: &str) -> bool {
937    let t = line.trim_end();
938    t == "---" || (t.starts_with("---") && t[3..].chars().all(char::is_whitespace))
939}
940
941/// Split the YAML frontmatter block out of the document. Returns the frontmatter
942/// text (body discarded — the normalizer never reads it), or `None` on a
943/// structural error (recorded on `p`).
944fn split_frontmatter(text: &str, p: &mut Problems) -> Option<String> {
945    let mut lines = text.lines();
946    match lines.next() {
947        Some(first) if is_fence(first) => {}
948        _ => {
949            p.err("frontmatter missing: file must begin with a '---' YAML block".to_string());
950            return None;
951        }
952    }
953    let mut fm = String::new();
954    for line in lines {
955        if is_fence(line) {
956            return Some(fm);
957        }
958        fm.push_str(line);
959        fm.push('\n');
960    }
961    p.err("frontmatter not closed: no terminating '---' line found".to_string());
962    None
963}
964
965/// Parse the frontmatter into a YAML mapping. `serde_yaml` rejects duplicate
966/// keys natively; a non-mapping top level or any YAML error is recorded on `p`.
967fn parse_frontmatter(fm: &str, p: &mut Problems) -> Mapping {
968    if fm.trim().is_empty() {
969        return Mapping::new();
970    }
971    match serde_yaml::from_str::<Value>(fm) {
972        Ok(Value::Null) => Mapping::new(),
973        Ok(Value::Mapping(m)) => m,
974        Ok(_) => {
975            p.err("frontmatter: top level must be a mapping".to_string());
976            Mapping::new()
977        }
978        Err(e) => {
979            p.err(format!("frontmatter: invalid YAML — {e}"));
980            Mapping::new()
981        }
982    }
983}
984
985// ── Helpers ──────────────────────────────────────────────────────────────────
986
987/// Coerce a value to a list: a sequence stays; absent/null → empty; a scalar
988/// becomes a one-element list (mirrors the Python `_as_list`).
989fn as_list(v: Option<&Value>) -> Vec<Value> {
990    match v {
991        None | Some(Value::Null) => Vec::new(),
992        Some(Value::Sequence(seq)) => seq.clone(),
993        Some(other) => vec![other.clone()],
994    }
995}
996
997/// A compact display of a YAML scalar for error messages (strings are quoted).
998fn yaml_display(v: &Value) -> String {
999    match v {
1000        Value::String(s) => format!("'{s}'"),
1001        Value::Bool(b) => b.to_string(),
1002        Value::Number(n) => n.to_string(),
1003        Value::Null => "null".to_string(),
1004        Value::Sequence(_) => "<list>".to_string(),
1005        Value::Mapping(_) => "<map>".to_string(),
1006        Value::Tagged(t) => yaml_display(&t.value),
1007    }
1008}
1009
1010/// Whether `rel` is a relative path that stays inside the repo — no absolute
1011/// path, no `../` escape — the fragment-dir floor. Lexical, so the path need not
1012/// exist. The check is purely on `rel`'s own component depth, so it holds
1013/// whether the repo root is absolute or relative (notably `--repo-root .`,
1014/// where `repo_root` normalizes to an empty path): a `..` is an escape the
1015/// moment it would pop above the repo root, exactly the Python
1016/// `_path_inside_repo` verdict (which rejects any `rel` that normalizes to an
1017/// escaping path). Joining `rel` onto a relative root and testing containment —
1018/// the previous approach — silently accepted `../etc` under a `.` root, because
1019/// an empty normalized root is a prefix of every path.
1020fn path_inside_repo(rel: &str) -> bool {
1021    let mut depth: usize = 0;
1022    for comp in Path::new(rel).components() {
1023        match comp {
1024            Component::CurDir => {}
1025            Component::Normal(_) => depth += 1,
1026            Component::ParentDir => {
1027                // An escape above the repo root the instant depth would go < 0.
1028                if depth == 0 {
1029                    return false;
1030                }
1031                depth -= 1;
1032            }
1033            // An absolute path (or a Windows drive prefix) never stays inside a
1034            // relative repo root.
1035            Component::RootDir | Component::Prefix(_) => return false,
1036        }
1037    }
1038    true
1039}
1040
1041/// Convert an arbitrary YAML value to JSON, for `extra_fields` preservation.
1042fn yaml_to_json(v: &Value) -> serde_json::Value {
1043    use serde_json::Value as J;
1044    match v {
1045        Value::Null => J::Null,
1046        Value::Bool(b) => J::Bool(*b),
1047        Value::Number(n) => {
1048            if let Some(i) = n.as_i64() {
1049                J::from(i)
1050            } else if let Some(u) = n.as_u64() {
1051                J::from(u)
1052            } else if let Some(f) = n.as_f64() {
1053                serde_json::Number::from_f64(f).map_or(J::Null, J::Number)
1054            } else {
1055                J::Null
1056            }
1057        }
1058        Value::String(s) => J::String(s.clone()),
1059        Value::Sequence(seq) => J::Array(seq.iter().map(yaml_to_json).collect()),
1060        Value::Mapping(m) => {
1061            let mut obj = serde_json::Map::new();
1062            for (k, val) in m {
1063                let key = match k {
1064                    Value::String(s) => s.clone(),
1065                    other => yaml_display(other),
1066                };
1067                obj.insert(key, yaml_to_json(val));
1068            }
1069            J::Object(obj)
1070        }
1071        Value::Tagged(t) => yaml_to_json(&t.value),
1072    }
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077    use super::*;
1078    use std::collections::HashSet;
1079
1080    /// A fake `Fs`: `normalize_str` never `read`s, so only the directory set
1081    /// matters (for the fragment-dir advisory check).
1082    struct FakeFs {
1083        dirs: HashSet<PathBuf>,
1084    }
1085
1086    impl FakeFs {
1087        fn empty() -> Self {
1088            Self {
1089                dirs: HashSet::new(),
1090            }
1091        }
1092
1093        fn with_dirs<const N: usize>(dirs: [&str; N]) -> Self {
1094            Self {
1095                dirs: dirs.iter().map(PathBuf::from).collect(),
1096            }
1097        }
1098    }
1099
1100    impl Fs for FakeFs {
1101        fn read(&self, _path: &Path) -> io::Result<Vec<u8>> {
1102            Err(io::Error::from(io::ErrorKind::NotFound))
1103        }
1104        fn exists(&self, path: &Path) -> bool {
1105            self.dirs.contains(path)
1106        }
1107        fn is_dir(&self, path: &Path) -> bool {
1108            self.dirs.contains(path)
1109        }
1110        fn is_file(&self, _path: &Path) -> bool {
1111            // The contract normalizer models only directories (fragment-dir).
1112            false
1113        }
1114        fn read_dir(&self, _dir: &Path) -> io::Result<Vec<String>> {
1115            // The contract normalizer never lists directories.
1116            Ok(Vec::new())
1117        }
1118    }
1119
1120    fn repo() -> &'static Path {
1121        Path::new("/repo")
1122    }
1123
1124    fn norm(text: &str) -> Normalized {
1125        normalize_str(text, repo(), &FakeFs::empty())
1126    }
1127
1128    fn norm_with(text: &str, fs: &dyn Fs) -> Normalized {
1129        normalize_str(text, repo(), fs)
1130    }
1131
1132    fn assert_error_contains(n: &Normalized, needle: &str) {
1133        assert!(
1134            !n.is_valid(),
1135            "expected invalid, got clean normalize: {:?}",
1136            n.contract
1137        );
1138        assert!(
1139            n.problems.errors.iter().any(|e| e.contains(needle)),
1140            "no error contained {needle:?}; errors were {:?}",
1141            n.problems.errors
1142        );
1143    }
1144
1145    const MINIMAL: &str = "---\nstatus: approved\nmaturity: mvp\n---\n";
1146
1147    #[test]
1148    fn materializes_all_defaults() {
1149        let c = norm(MINIMAL).contract;
1150        assert_eq!(c.schema_version, 1);
1151        assert_eq!(c.status, Status::Approved);
1152        assert_eq!(c.maturity, Maturity::Mvp);
1153        assert!(c.ecosystems.is_empty());
1154        assert!(c.targets.is_empty());
1155        assert_eq!(c.versioning, VersioningBase::Semver);
1156        assert_eq!(c.versioning_pattern, None);
1157        assert_eq!(c.changelog.mode, ChangelogMode::Curated);
1158        assert_eq!(c.changelog.source, ChangelogSource::Manual);
1159        assert_eq!(c.changelog.fragment_dir, DEFAULT_FRAGMENT_DIR);
1160        assert!(!c.conventional_commits);
1161        assert_eq!(c.release.model, ReleaseModel::Gated);
1162        assert_eq!(c.release.layout, ReleaseLayout::Single);
1163        assert_eq!(c.contribution_provenance, ContributionProvenance::None);
1164        assert_eq!(c.provenance_level, ProvenanceLevel::None);
1165        assert_eq!(c.dependency_bot, DependencyBot::Dependabot); // mvp default
1166        assert_eq!(c.license, "MIT");
1167        assert_eq!(c.docs_site, DocsSite::None);
1168        // mvp, no publishable target → [ci, license].
1169        assert_eq!(c.health_badges, vec![HealthBadge::Ci, HealthBadge::License]);
1170        assert!(c.extra_fields.is_empty());
1171    }
1172
1173    #[test]
1174    fn spike_defaults_no_bot_no_ci_badge() {
1175        let c = norm("---\nstatus: approved\nmaturity: spike\n---\n").contract;
1176        assert_eq!(c.dependency_bot, DependencyBot::None);
1177        assert_eq!(c.health_badges, vec![HealthBadge::License]);
1178    }
1179
1180    #[test]
1181    fn maturity_is_required() {
1182        assert_error_contains(
1183            &norm("---\nstatus: approved\n---\n"),
1184            "maturity is required",
1185        );
1186    }
1187
1188    #[test]
1189    fn expands_targets_from_ecosystems() {
1190        let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [python]\n---\n").contract;
1191        assert_eq!(c.targets.len(), 1);
1192        assert_eq!(c.targets[0].ecosystem, Ecosystem::Python);
1193        assert_eq!(c.targets[0].package, None);
1194        assert_eq!(c.targets[0].registry, Registry::Pypi);
1195        assert_eq!(c.targets[0].adapter, Adapter::GhActionPypiPublish);
1196    }
1197
1198    #[test]
1199    fn node_monorepo_adapter_is_changesets() {
1200        let text = "---\nstatus: approved\nmaturity: production\necosystems: [node]\n\
1201                    release:\n  model: gated\n  layout: monorepo\n---\n";
1202        let c = norm(text).contract;
1203        assert_eq!(c.targets[0].adapter, Adapter::Changesets);
1204    }
1205
1206    #[test]
1207    fn ecosystems_dedup_to_canonical_order() {
1208        let c =
1209            norm("---\nstatus: approved\nmaturity: mvp\necosystems: [python, rust, python]\n---\n")
1210                .contract;
1211        assert_eq!(c.ecosystems, vec![Ecosystem::Rust, Ecosystem::Python]);
1212    }
1213
1214    #[test]
1215    fn calver_splits_base_and_pattern() {
1216        let c = norm(
1217            "---\nstatus: approved\nmaturity: mvp\nversioning: \"calver:YYYY.MM.MICRO\"\n---\n",
1218        )
1219        .contract;
1220        assert_eq!(c.versioning, VersioningBase::Calver);
1221        assert_eq!(c.versioning_pattern.as_deref(), Some("YYYY.MM.MICRO"));
1222    }
1223
1224    #[test]
1225    fn bare_calver_is_rejected() {
1226        assert_error_contains(
1227            &norm("---\nstatus: approved\nmaturity: mvp\nversioning: calver\n---\n"),
1228            "must carry its pattern",
1229        );
1230    }
1231
1232    #[test]
1233    fn floor_auto_on_spike() {
1234        let text = "---\nstatus: approved\nmaturity: spike\n\
1235                    release:\n  model: auto\n  layout: single\nhealth_badges: [license]\n---\n";
1236        assert_error_contains(&norm(text), "release.model 'auto' is not allowed");
1237    }
1238
1239    #[test]
1240    fn floor_slsa_l3_production_only() {
1241        assert_error_contains(
1242            &norm("---\nstatus: approved\nmaturity: mvp\nprovenance_level: slsa-l3\n---\n"),
1243            "slsa-l3' is production-only",
1244        );
1245    }
1246
1247    #[test]
1248    fn floor_registry_requires_valid_license() {
1249        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
1250                    license: Proprietary-Acme\nhealth_badges: [ci, registry, license]\n---\n";
1251        let n = norm(text);
1252        // Both the SPDX-validity error and the registry-needs-license floor fire.
1253        assert_error_contains(&n, "not a valid SPDX expression");
1254        assert!(n
1255            .problems
1256            .errors
1257            .iter()
1258            .any(|e| e.contains("floor: a target has a registry")));
1259    }
1260
1261    #[test]
1262    fn floor_badge_without_producer() {
1263        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [python]\n\
1264                    health_badges: [ci, coverage]\n---\n";
1265        assert_error_contains(&norm(text), "health_badge 'coverage' has no producer");
1266    }
1267
1268    #[test]
1269    fn floor_schema_version_too_new() {
1270        assert_error_contains(
1271            &norm("---\nschema_version: 99\nstatus: approved\nmaturity: mvp\n---\n"),
1272            "exceeds what this tool knows",
1273        );
1274    }
1275
1276    #[test]
1277    fn floor_fragment_dir_escape() {
1278        let text = "---\nstatus: approved\nmaturity: mvp\n\
1279                    changelog:\n  mode: fragment\n  source: manual\n  fragment_dir: /etc\n---\n";
1280        assert_error_contains(&norm(text), "must be a relative path inside the repo");
1281    }
1282
1283    #[test]
1284    fn floor_fragment_dir_escape_relative_root() {
1285        // Regression: with a *relative* repo root (the CLI's `--repo-root .`),
1286        // a `../`-escaping fragment_dir must still be rejected. The earlier
1287        // join-then-contain check accepted it because a `.` root normalizes to
1288        // an empty path that prefixes everything.
1289        let text = "---\nstatus: approved\nmaturity: mvp\n\
1290                    changelog:\n  mode: fragment\n  source: manual\n  fragment_dir: ../etc\n---\n";
1291        let n = normalize_str(text, Path::new("."), &FakeFs::empty());
1292        assert_error_contains(&n, "must be a relative path inside the repo");
1293    }
1294
1295    #[test]
1296    fn path_inside_repo_verdicts() {
1297        // Inside — plain and `.`/`..`-collapsing relative paths that stay in.
1298        assert!(path_inside_repo("changelog/fragments"));
1299        assert!(path_inside_repo("./changelog/fragments"));
1300        assert!(path_inside_repo("a/../fragments"));
1301        assert!(path_inside_repo("")); // the repo root itself
1302                                       // Escapes — absolute, leading `..`, and mid-path `..` that pops out.
1303        assert!(!path_inside_repo("/etc"));
1304        assert!(!path_inside_repo("../etc"));
1305        assert!(!path_inside_repo("a/../../etc"));
1306    }
1307
1308    #[test]
1309    fn unknown_fields_preserved_and_warned() {
1310        let text =
1311            "---\nstatus: approved\nmaturity: mvp\nroadmap_url: https://example.com/x\n---\n";
1312        let n = norm(text);
1313        assert!(n.is_valid());
1314        assert_eq!(
1315            n.contract
1316                .extra_fields
1317                .get("roadmap_url")
1318                .and_then(|v| v.as_str()),
1319            Some("https://example.com/x")
1320        );
1321        assert!(n
1322            .problems
1323            .warnings
1324            .iter()
1325            .any(|w| w.contains("roadmap_url") && w.contains("forward-compat")));
1326    }
1327
1328    #[test]
1329    fn duplicate_key_is_rejected() {
1330        assert_error_contains(
1331            &norm("---\nstatus: approved\nstatus: draft\nmaturity: mvp\n---\n"),
1332            "invalid YAML",
1333        );
1334    }
1335
1336    #[test]
1337    fn missing_frontmatter_is_rejected() {
1338        assert_error_contains(&norm("no frontmatter here\n"), "frontmatter missing");
1339    }
1340
1341    #[test]
1342    fn unclosed_frontmatter_is_rejected() {
1343        assert_error_contains(&norm("---\nstatus: approved\n"), "frontmatter not closed");
1344    }
1345
1346    #[test]
1347    fn invalid_enum_records_error_and_continues() {
1348        // A bad status AND a bad maturity: both surface (multi-error collection).
1349        let n = norm("---\nstatus: bogus\nmaturity: alsobogus\n---\n");
1350        assert!(n.problems.errors.iter().any(|e| e.contains("status")));
1351        assert!(n.problems.errors.iter().any(|e| e.contains("maturity")));
1352    }
1353
1354    #[test]
1355    fn fragment_dir_present_suppresses_advisory() {
1356        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
1357                    changelog:\n  mode: fragment\n  source: manual\n---\n";
1358        // The default fragment dir exists → no advisory warning.
1359        let fs = FakeFs::with_dirs(["/repo/changelog/fragments"]);
1360        let n = norm_with(text, &fs);
1361        assert!(n.is_valid());
1362        assert!(
1363            !n.problems
1364                .warnings
1365                .iter()
1366                .any(|w| w.contains("does not exist yet")),
1367            "advisory should be suppressed when the dir exists: {:?}",
1368            n.problems.warnings
1369        );
1370    }
1371
1372    #[test]
1373    fn serializes_to_schema_v4_shape() {
1374        let json =
1375            serde_json::to_value(&norm("---\nstatus: approved\nmaturity: mvp\n---\n").contract)
1376                .unwrap();
1377        // Spot-check the §4 top-level keys that consumers read.
1378        for key in [
1379            "schema_version",
1380            "status",
1381            "maturity",
1382            "ecosystems",
1383            "targets",
1384            "distribution",
1385            "versioning",
1386            "versioning_pattern",
1387            "changelog",
1388            "conventional_commits",
1389            "release",
1390            "contribution_provenance",
1391            "provenance_level",
1392            "dependency_bot",
1393            "health_badges",
1394            "license",
1395            "docs_site",
1396            "extra_fields",
1397            "warnings",
1398        ] {
1399            assert!(json.get(key).is_some(), "missing §4 key {key}");
1400        }
1401        assert!(json["versioning_pattern"].is_null());
1402        // A registry-only contract carries an explicit `distribution: null` — the
1403        // additive field is present but shape-neutral for existing configs.
1404        assert!(json["distribution"].is_null());
1405    }
1406
1407    // ── distribution (cargo-dist binary layer) ───────────────────────────────
1408
1409    /// A registry-only contract is unchanged by the additive `distribution`
1410    /// field: it normalizes clean and `distribution` is `None`.
1411    #[test]
1412    fn registry_only_contract_has_no_distribution() {
1413        let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract;
1414        assert_eq!(c.distribution, None);
1415        assert_eq!(c.targets.len(), 1);
1416        assert_eq!(c.targets[0].registry, Registry::CratesIo);
1417    }
1418
1419    /// A cargo-dist repo: a `distribution` block (binaries + shell/Homebrew
1420    /// installers + a tap) coexisting with a crates.io registry target.
1421    #[test]
1422    fn cargo_dist_distribution_coexists_with_registry() {
1423        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1424                    targets:\n  - {ecosystem: rust, package: issuectl, registry: crates.io, adapter: cargo-publish}\n\
1425                    distribution:\n  adapter: cargo-dist\n  installers: [shell, homebrew]\n  \
1426                    homebrew_tap: jarimustonen/homebrew-issuectl\n---\n";
1427        let n = norm(text);
1428        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1429        let c = n.contract;
1430        // The registry publish is still a Target.
1431        assert_eq!(c.targets.len(), 1);
1432        assert_eq!(c.targets[0].registry, Registry::CratesIo);
1433        assert_eq!(c.targets[0].adapter, Adapter::CargoPublish);
1434        // The binary layer is the Distribution block.
1435        let d = c.distribution.expect("distribution present");
1436        assert_eq!(d.adapter, DistributionAdapter::CargoDist);
1437        assert!(d.gh_releases); // default true
1438        assert_eq!(d.installers, vec![Installer::Shell, Installer::Homebrew]);
1439        assert_eq!(
1440            d.homebrew_tap.as_deref(),
1441            Some("jarimustonen/homebrew-issuectl")
1442        );
1443    }
1444
1445    /// Round-trip: the serialized JSON shape a downstream `/oss-*` member reads.
1446    #[test]
1447    fn distribution_json_round_trip_shape() {
1448        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1449                    distribution:\n  adapter: cargo-dist\n  gh_releases: true\n  \
1450                    installers: [shell, homebrew]\n  homebrew_tap: jarimustonen/homebrew-issuectl\n---\n";
1451        let json = serde_json::to_value(&norm(text).contract).unwrap();
1452        let d = &json["distribution"];
1453        assert_eq!(d["adapter"], "cargo-dist");
1454        assert_eq!(d["gh_releases"], true);
1455        assert_eq!(d["installers"], serde_json::json!(["shell", "homebrew"]));
1456        assert_eq!(d["homebrew_tap"], "jarimustonen/homebrew-issuectl");
1457    }
1458
1459    /// Installers de-dup into canonical order regardless of source order.
1460    #[test]
1461    fn distribution_installers_dedup_canonical_order() {
1462        let text = "---\nstatus: approved\nmaturity: mvp\n\
1463                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew, shell, homebrew]\n  \
1464                    homebrew_tap: owner/tap\n---\n";
1465        let d = norm(text).contract.distribution.unwrap();
1466        assert_eq!(d.installers, vec![Installer::Shell, Installer::Homebrew]);
1467    }
1468
1469    /// A `homebrew` installer without a tap is a floor error.
1470    #[test]
1471    fn distribution_homebrew_installer_requires_tap() {
1472        let text = "---\nstatus: approved\nmaturity: mvp\n\
1473                    distribution:\n  adapter: cargo-dist\n  installers: [shell, homebrew]\n---\n";
1474        assert_error_contains(
1475            &norm(text),
1476            "includes 'homebrew' but no distribution.homebrew_tap",
1477        );
1478    }
1479
1480    /// A malformed tap slug (not `owner/repo`) is rejected AND, because the
1481    /// invalid value substitutes `None`, the homebrew-needs-tap floor still fires
1482    /// — a present-but-invalid tap must not slip a `homebrew` installer through.
1483    #[test]
1484    fn distribution_bad_tap_slug_rejected() {
1485        let text = "---\nstatus: approved\nmaturity: mvp\n\
1486                    distribution:\n  adapter: cargo-dist\n  installers: [homebrew]\n  \
1487                    homebrew_tap: not-a-slug\n---\n";
1488        let n = norm(text);
1489        assert_error_contains(&n, "must be an 'owner/repo' slug");
1490        assert!(
1491            n.problems
1492                .errors
1493                .iter()
1494                .any(|e| e.contains("includes 'homebrew' but no distribution.homebrew_tap")),
1495            "the tap floor must still fire on an invalid (→None) tap: {:?}",
1496            n.problems.errors
1497        );
1498        // The malformed slug never leaks into the built block.
1499        assert_eq!(n.contract.distribution.unwrap().homebrew_tap, None);
1500    }
1501
1502    /// An unknown installer flavor surfaces an error listing the valid set.
1503    #[test]
1504    fn distribution_bad_installer_rejected() {
1505        let text = "---\nstatus: approved\nmaturity: mvp\n\
1506                    distribution:\n  adapter: cargo-dist\n  installers: [snap]\n---\n";
1507        assert_error_contains(&norm(text), "distribution.installers");
1508    }
1509
1510    /// `adapter` is required when a distribution block is present — a bare
1511    /// `distribution: {}` must not silently claim cargo-dist ownership.
1512    #[test]
1513    fn distribution_adapter_is_required() {
1514        assert_error_contains(
1515            &norm("---\nstatus: approved\nmaturity: mvp\ndistribution: {}\n---\n"),
1516            "distribution.adapter is required",
1517        );
1518    }
1519
1520    /// A distribution block ships public binaries — forbidden at maturity 'spike'
1521    /// (mirrors the `release.model: auto`-on-spike floor).
1522    #[test]
1523    fn distribution_forbidden_on_spike() {
1524        let text = "---\nstatus: approved\nmaturity: spike\n\
1525                    distribution:\n  adapter: cargo-dist\n---\n";
1526        assert_error_contains(&norm(text), "not allowed on maturity 'spike'");
1527    }
1528
1529    /// A `homebrew_tap` set without a `homebrew` installer is dead config — a
1530    /// warning, not a floor (the contract is still valid).
1531    #[test]
1532    fn distribution_tap_without_installer_warns() {
1533        let text = "---\nstatus: approved\nmaturity: mvp\n\
1534                    distribution:\n  adapter: cargo-dist\n  installers: [shell]\n  \
1535                    homebrew_tap: owner/tap\n---\n";
1536        let n = norm(text);
1537        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1538        assert!(
1539            n.problems
1540                .warnings
1541                .iter()
1542                .any(|w| w.contains("homebrew_tap is set but 'homebrew' is not")),
1543            "expected dead-tap warning: {:?}",
1544            n.problems.warnings
1545        );
1546    }
1547
1548    /// A goreleaser distribution with no installers and no tap is valid — the
1549    /// block is minimal and forward-compatible.
1550    #[test]
1551    fn distribution_goreleaser_minimal_is_valid() {
1552        let text = "---\nstatus: approved\nmaturity: production\necosystems: [go]\n\
1553                    distribution:\n  adapter: goreleaser\n  gh_releases: true\n---\n";
1554        let n = norm(text);
1555        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1556        let d = n.contract.distribution.unwrap();
1557        assert_eq!(d.adapter, DistributionAdapter::Goreleaser);
1558        assert!(d.installers.is_empty());
1559        assert_eq!(d.homebrew_tap, None);
1560    }
1561
1562    /// A non-mapping `distribution` value is a structural error.
1563    #[test]
1564    fn distribution_non_mapping_rejected() {
1565        assert_error_contains(
1566            &norm("---\nstatus: approved\nmaturity: mvp\ndistribution: [nope]\n---\n"),
1567            "distribution must be a mapping",
1568        );
1569    }
1570
1571    // ── distribution.platforms (cross-platform target set) ───────────────────
1572
1573    /// Helper: does a platform list contain any Linux triple? The cross-platform
1574    /// install requirement is "at least one Linux triple", inspected via the OS
1575    /// component of the triple (exactly how `audit` will read this field).
1576    fn has_linux(platforms: &[String]) -> bool {
1577        platforms.iter().any(|t| t.contains("-linux"))
1578    }
1579
1580    /// Omitted `platforms` → the cross-platform default (macOS + Linux). The
1581    /// KEYSTONE assertion: the DEFAULT covers Linux, so every distribution that
1582    /// omits the field does (an explicit set is the author's own choice, which the
1583    /// cross-platform `audit` — not this normalizer — checks).
1584    #[test]
1585    fn distribution_platforms_default_is_cross_platform() {
1586        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1587                    distribution:\n  adapter: cargo-dist\n---\n";
1588        let d = norm(text)
1589            .contract
1590            .distribution
1591            .expect("distribution present");
1592        assert_eq!(
1593            d.platforms,
1594            vec![
1595                "aarch64-apple-darwin",
1596                "x86_64-apple-darwin",
1597                "aarch64-unknown-linux-musl",
1598                "x86_64-unknown-linux-musl",
1599            ]
1600        );
1601        assert!(
1602            has_linux(&d.platforms),
1603            "the default set MUST contain a Linux triple: {:?}",
1604            d.platforms
1605        );
1606    }
1607
1608    /// An explicit `platforms` list round-trips through normalization and the
1609    /// serialized JSON downstream members read, order + values preserved.
1610    #[test]
1611    fn distribution_platforms_explicit_round_trips() {
1612        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1613                    distribution:\n  adapter: cargo-dist\n  \
1614                    platforms: [x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc]\n---\n";
1615        let n = norm(text);
1616        assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1617        let d = n.contract.clone().distribution.unwrap();
1618        assert_eq!(
1619            d.platforms,
1620            vec!["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
1621        );
1622        let json = serde_json::to_value(&n.contract).unwrap();
1623        assert_eq!(
1624            json["distribution"]["platforms"],
1625            serde_json::json!(["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"])
1626        );
1627    }
1628
1629    /// An explicit empty `platforms: []` is a hard error — NOT silently defaulted.
1630    /// Only an omitted/null field yields the cross-platform default; an empty list
1631    /// is a mistake (a distribution with no platforms builds nothing) and, if
1632    /// silently defaulted, would surprise the author and erase the intent the
1633    /// cross-platform audit needs to see.
1634    #[test]
1635    fn distribution_platforms_empty_is_rejected() {
1636        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1637                    distribution:\n  adapter: cargo-dist\n  platforms: []\n---\n";
1638        assert_error_contains(&norm(text), "empty list — omit the key");
1639    }
1640
1641    /// Duplicate triples de-duplicate, preserving first-seen order.
1642    #[test]
1643    fn distribution_platforms_dedup_preserves_order() {
1644        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1645                    distribution:\n  adapter: cargo-dist\n  \
1646                    platforms: [aarch64-apple-darwin, x86_64-apple-darwin, aarch64-apple-darwin]\n---\n";
1647        let d = norm(text).contract.distribution.unwrap();
1648        assert_eq!(
1649            d.platforms,
1650            vec!["aarch64-apple-darwin", "x86_64-apple-darwin"]
1651        );
1652    }
1653
1654    /// A malformed triple is rejected with a message naming the field.
1655    #[test]
1656    fn distribution_platforms_bad_triple_rejected() {
1657        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1658                    distribution:\n  adapter: cargo-dist\n  platforms: [not_a_triple]\n---\n";
1659        assert_error_contains(&norm(text), "is not a well-formed target-triple");
1660    }
1661
1662    /// A non-string entry (a nested list) is rejected structurally.
1663    #[test]
1664    fn distribution_platforms_non_string_entry_rejected() {
1665        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1666                    distribution:\n  adapter: cargo-dist\n  platforms: [[nope]]\n---\n";
1667        assert_error_contains(&norm(text), "each entry must be a target-triple string");
1668    }
1669
1670    /// A `platforms` value that is not a list is a structural error.
1671    #[test]
1672    fn distribution_platforms_non_list_rejected() {
1673        let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1674                    distribution:\n  adapter: cargo-dist\n  platforms: x86_64-apple-darwin\n---\n";
1675        assert_error_contains(&norm(text), "must be a list of target-triple strings");
1676    }
1677
1678    /// Regression: a registry-only contract (no distribution block at all) is
1679    /// wholly unaffected by the additive `platforms` field — no distribution, so
1680    /// no `platforms` in the emitted shape.
1681    #[test]
1682    fn registry_only_contract_unaffected_by_platforms() {
1683        let json = serde_json::to_value(
1684            &norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract,
1685        )
1686        .unwrap();
1687        assert!(json["distribution"].is_null());
1688    }
1689
1690    #[test]
1691    fn looks_like_target_triple_verdicts() {
1692        // Standard triples across arch/vendor/os/env shapes.
1693        assert!(looks_like_target_triple("aarch64-apple-darwin"));
1694        assert!(looks_like_target_triple("x86_64-apple-darwin"));
1695        assert!(looks_like_target_triple("x86_64-unknown-linux-musl"));
1696        assert!(looks_like_target_triple("x86_64-unknown-linux-gnu"));
1697        assert!(looks_like_target_triple("x86_64-pc-windows-msvc"));
1698        assert!(looks_like_target_triple("armv7-unknown-linux-gnueabihf"));
1699        assert!(looks_like_target_triple("wasm32-wasi"));
1700        // Real dotted arch names must pass (regression: the `.` was rejected).
1701        assert!(looks_like_target_triple("thumbv8m.main-none-eabi"));
1702        assert!(looks_like_target_triple("thumbv8m.base-none-eabi"));
1703        // Rejects: too few/many components, empty parts, case, punctuation.
1704        assert!(!looks_like_target_triple("linux"));
1705        assert!(!looks_like_target_triple("a-b-c-d-e"));
1706        assert!(!looks_like_target_triple("x86_64--linux"));
1707        assert!(!looks_like_target_triple("-apple-darwin"));
1708        assert!(!looks_like_target_triple("X86_64-apple-darwin"));
1709        assert!(!looks_like_target_triple("x86_64-apple-darwin;rm"));
1710        assert!(!looks_like_target_triple("x86_64 apple darwin"));
1711        assert!(!looks_like_target_triple(""));
1712        // Structural-only: nonsense that happens to be well-formed IS accepted —
1713        // the toolchain, not the contract, is the authority on buildability.
1714        assert!(looks_like_target_triple("aa-bb"));
1715    }
1716
1717    #[test]
1718    fn is_tap_slug_verdicts() {
1719        // Valid GitHub-style slugs.
1720        assert!(is_tap_slug("owner/repo"));
1721        assert!(is_tap_slug("jarimustonen/homebrew-issuectl"));
1722        assert!(is_tap_slug("Owner_1/repo.rb"));
1723        // Structural rejects.
1724        assert!(!is_tap_slug("no-slash"));
1725        assert!(!is_tap_slug("/repo"));
1726        assert!(!is_tap_slug("owner/"));
1727        assert!(!is_tap_slug("owner/repo/extra"));
1728        assert!(!is_tap_slug("owner / repo"));
1729        // Strict-charset rejects: path traversal, punctuation, injection chars.
1730        assert!(!is_tap_slug("owner/.."));
1731        assert!(!is_tap_slug("../repo"));
1732        assert!(!is_tap_slug("owner/repo;rm -rf"));
1733        assert!(!is_tap_slug("owner/@repo"));
1734        assert!(!is_tap_slug("ownér/repo"));
1735    }
1736}