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, DocsSite, Ecosystem, HealthBadge, Maturity, ProvenanceLevel, Registry, Release,
23    ReleaseLayout, ReleaseModel, Status, Target, VersioningBase, DEFAULT_FRAGMENT_DIR,
24    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    "versioning",
51    "changelog",
52    "conventional_commits",
53    "release",
54    "contribution_provenance",
55    "provenance_level",
56    "dependency_bot",
57    "health_badges",
58    "license",
59    "docs_site",
60];
61
62/// Collected fatal errors and non-fatal warnings from a normalization pass.
63#[derive(Debug, Default)]
64pub struct Problems {
65    /// Fatal validation errors; a non-empty list means the config would not
66    /// normalize (the CLI exits non-zero with the §10 error envelope).
67    pub errors: Vec<String>,
68    /// Non-fatal notes (aspirational draft producers, the unknown-field report).
69    pub warnings: Vec<String>,
70}
71
72impl Problems {
73    fn err(&mut self, msg: String) {
74        self.errors.push(msg);
75    }
76
77    fn warn(&mut self, msg: String) {
78        self.warnings.push(msg);
79    }
80}
81
82/// The result of a normalization pass: the canonical [`Contract`] plus the
83/// [`Problems`] gathered while building it.
84#[derive(Debug)]
85pub struct Normalized {
86    /// The canonical contract. Only meaningful when [`Self::is_valid`] holds.
87    pub contract: Contract,
88    /// Errors and warnings gathered during normalization.
89    pub problems: Problems,
90}
91
92impl Normalized {
93    /// Whether the config normalized cleanly (no fatal errors).
94    #[must_use]
95    pub fn is_valid(&self) -> bool {
96        self.problems.errors.is_empty()
97    }
98}
99
100/// Why the contract file could not be loaded (distinct from a *validation*
101/// failure, which is carried by [`Problems`]). Maps to a §2 exit-2 system error.
102#[derive(Debug)]
103pub enum LoadError {
104    /// No `OSS-RELEASE.md` at the expected path.
105    NotFound(PathBuf),
106    /// The file exists but could not be read.
107    Io(PathBuf, io::Error),
108    /// The file is not valid UTF-8.
109    Utf8(PathBuf),
110}
111
112impl std::fmt::Display for LoadError {
113    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        match self {
115            Self::NotFound(p) => write!(
116                f,
117                "no {CONTRACT_FILENAME} at {} (run /oss-init to generate one)",
118                p.display()
119            ),
120            Self::Io(p, e) => write!(f, "cannot read {}: {e}", p.display()),
121            Self::Utf8(p) => write!(f, "{} is not valid UTF-8", p.display()),
122        }
123    }
124}
125
126/// Read `<repo_root>/OSS-RELEASE.md` through the [`Fs`] port and normalize it.
127///
128/// # Errors
129/// Returns [`LoadError`] when the file is missing, unreadable, or not UTF-8. A
130/// *validation* failure is not an error here — it is carried in the returned
131/// [`Normalized::problems`]; check [`Normalized::is_valid`].
132pub fn normalize(repo_root: &Path, fs: &dyn Fs) -> Result<Normalized, LoadError> {
133    let path = repo_root.join(CONTRACT_FILENAME);
134    let bytes = fs.read(&path).map_err(|e| match e.kind() {
135        io::ErrorKind::NotFound => LoadError::NotFound(path.clone()),
136        _ => LoadError::Io(path.clone(), e),
137    })?;
138    let text = String::from_utf8(bytes).map_err(|_| LoadError::Utf8(path.clone()))?;
139    Ok(normalize_str(&text, repo_root, fs))
140}
141
142/// Normalize the full text of an `OSS-RELEASE.md` (frontmatter + body).
143///
144/// Split from [`normalize`] so tests can exercise the pipeline on a string
145/// without a real file. `repo_root` and `fs` are still needed for the
146/// filesystem-dependent floors (the fragment-dir path floor and its advisory
147/// existence check).
148#[must_use]
149pub fn normalize_str(text: &str, repo_root: &Path, fs: &dyn Fs) -> Normalized {
150    let mut p = Problems::default();
151    let map = match split_frontmatter(text, &mut p) {
152        Some(fm) => parse_frontmatter(&fm, &mut p),
153        None => Mapping::new(),
154    };
155    let contract = build(&map, &mut p, repo_root, fs);
156    Normalized {
157        contract,
158        problems: p,
159    }
160}
161
162/// Read a required enum field, or record an error and fall back to `default`.
163/// Absent → `default` silently; present-but-invalid → error + `default`
164/// (matching the Python default-substitution behavior).
165macro_rules! enum_field {
166    ($map:expr, $key:expr, $ty:ty, $default:expr, $p:expr) => {{
167        match $map.get($key) {
168            None => $default,
169            Some(v) => match v.as_str().and_then(<$ty>::parse) {
170                Some(x) => x,
171                None => {
172                    $p.err(format!(
173                        "{} {} invalid — must be one of {:?}",
174                        $key,
175                        yaml_display(v),
176                        <$ty>::VALID
177                    ));
178                    $default
179                }
180            },
181        }
182    }};
183}
184
185#[allow(clippy::too_many_lines)]
186fn build(map: &Mapping, p: &mut Problems, repo_root: &Path, fs: &dyn Fs) -> Contract {
187    // schema_version — bound first; a too-new config is a hard stop.
188    let schema_version = match map.get("schema_version") {
189        None => KNOWN_SCHEMA_VERSION,
190        Some(v) => match v.as_i64() {
191            Some(n) if n > i64::from(KNOWN_SCHEMA_VERSION) => {
192                p.err(format!(
193                    "schema_version {n} exceeds what this tool knows ({KNOWN_SCHEMA_VERSION}); \
194                     upgrade the OSS-release skills before reading this config (refusing rather \
195                     than guessing)."
196                ));
197                u32::try_from(n).unwrap_or(KNOWN_SCHEMA_VERSION)
198            }
199            Some(n) if n < 1 => {
200                p.err(format!("schema_version {n} is invalid (must be >= 1)"));
201                KNOWN_SCHEMA_VERSION
202            }
203            Some(n) => u32::try_from(n).unwrap_or(KNOWN_SCHEMA_VERSION),
204            None => {
205                p.err(format!(
206                    "schema_version must be an integer, got {}",
207                    yaml_display(v)
208                ));
209                KNOWN_SCHEMA_VERSION
210            }
211        },
212    };
213
214    let status = enum_field!(map, "status", Status, Status::Draft, p);
215
216    // maturity — required (inference is /oss-init's job, not the normalizer's).
217    let maturity = match map.get("maturity") {
218        None => {
219            p.err("maturity is required (spike|mvp|production) — /oss-init infers it".to_string());
220            Maturity::Mvp
221        }
222        Some(v) => {
223            if let Some(m) = v.as_str().and_then(Maturity::parse) {
224                m
225            } else {
226                p.err(format!(
227                    "maturity {} invalid — must be one of {:?}",
228                    yaml_display(v),
229                    Maturity::VALID
230                ));
231                Maturity::Mvp
232            }
233        }
234    };
235
236    // ecosystems — validate, then de-dup into canonical order.
237    let mut parsed_ecos: Vec<Ecosystem> = Vec::new();
238    for item in as_list(map.get("ecosystems")) {
239        match item.as_str().and_then(Ecosystem::parse) {
240            Some(e) => parsed_ecos.push(e),
241            None => p.err(format!(
242                "ecosystems: {} invalid — must be one of {:?}",
243                yaml_display(&item),
244                Ecosystem::VALID
245            )),
246        }
247    }
248    let ecosystems: Vec<Ecosystem> = ECOSYSTEM_ORDER
249        .into_iter()
250        .filter(|e| parsed_ecos.contains(e))
251        .collect();
252
253    // versioning — split the base enum from the calver pattern.
254    let (versioning, versioning_pattern) = parse_versioning(map.get("versioning"), p);
255
256    // release (model + layout).
257    let (model, layout) = match map.get("release") {
258        None | Some(Value::Null) => (ReleaseModel::Gated, ReleaseLayout::Single),
259        Some(Value::Mapping(m)) => (
260            enum_field!(m, "model", ReleaseModel, ReleaseModel::Gated, p),
261            enum_field!(m, "layout", ReleaseLayout, ReleaseLayout::Single, p),
262        ),
263        Some(_) => {
264            p.err("release must be a mapping with model/layout".to_string());
265            (ReleaseModel::Gated, ReleaseLayout::Single)
266        }
267    };
268
269    // targets — expand from ecosystems when omitted; validate each entry.
270    let targets = match map.get("targets") {
271        None | Some(Value::Null) => expand_targets(&ecosystems, layout),
272        Some(Value::Sequence(seq)) if seq.is_empty() => expand_targets(&ecosystems, layout),
273        Some(Value::Sequence(seq)) => validate_targets(seq, &ecosystems, layout, p),
274        Some(_) => {
275            p.err(
276                "targets must be a list of {ecosystem, package?, registry, adapter?} maps"
277                    .to_string(),
278            );
279            Vec::new()
280        }
281    };
282
283    // changelog (mode + source + fragment_dir).
284    let changelog = match map.get("changelog") {
285        None | Some(Value::Null) => Changelog {
286            mode: ChangelogMode::Curated,
287            source: ChangelogSource::Manual,
288            fragment_dir: DEFAULT_FRAGMENT_DIR.to_string(),
289        },
290        Some(Value::Mapping(m)) => {
291            let mode = enum_field!(m, "mode", ChangelogMode, ChangelogMode::Curated, p);
292            let source = enum_field!(m, "source", ChangelogSource, ChangelogSource::Manual, p);
293            let fragment_dir = match m.get("fragment_dir") {
294                None => DEFAULT_FRAGMENT_DIR.to_string(),
295                Some(v) => {
296                    if let Some(s) = v.as_str() {
297                        s.to_string()
298                    } else {
299                        p.err("changelog.fragment_dir must be a string path".to_string());
300                        DEFAULT_FRAGMENT_DIR.to_string()
301                    }
302                }
303            };
304            Changelog {
305                mode,
306                source,
307                fragment_dir,
308            }
309        }
310        Some(_) => {
311            p.err("changelog must be a mapping with mode/source".to_string());
312            Changelog {
313                mode: ChangelogMode::Curated,
314                source: ChangelogSource::Manual,
315                fragment_dir: DEFAULT_FRAGMENT_DIR.to_string(),
316            }
317        }
318    };
319    // fragment_dir must be a relative path inside the repo (floor 6).
320    if !path_inside_repo(&changelog.fragment_dir) {
321        p.err(format!(
322            "floor: changelog.fragment_dir '{}' must be a relative path inside the repo (an \
323             absolute or '../'-escaping path is refused)",
324            changelog.fragment_dir
325        ));
326    }
327
328    // conventional_commits.
329    let conventional_commits = match map.get("conventional_commits") {
330        None => false,
331        Some(Value::Bool(b)) => *b,
332        Some(v) => {
333            p.err(format!(
334                "conventional_commits must be true|false, got {}",
335                yaml_display(v)
336            ));
337            false
338        }
339    };
340
341    let contribution_provenance = enum_field!(
342        map,
343        "contribution_provenance",
344        ContributionProvenance,
345        ContributionProvenance::None,
346        p
347    );
348    let provenance_level = enum_field!(
349        map,
350        "provenance_level",
351        ProvenanceLevel,
352        ProvenanceLevel::None,
353        p
354    );
355
356    let dep_default = if maturity == Maturity::Spike {
357        DependencyBot::None
358    } else {
359        DependencyBot::Dependabot
360    };
361    let dependency_bot = enum_field!(map, "dependency_bot", DependencyBot, dep_default, p);
362
363    // license — a valid SPDX expression when set (default MIT).
364    let license = match map.get("license") {
365        None => "MIT".to_string(),
366        Some(v) => match v.as_str() {
367            Some(s) if !s.trim().is_empty() => {
368                if !spdx_valid(s) {
369                    p.err(format!(
370                        "license '{s}' is not a valid SPDX expression (unknown id or malformed \
371                         AND/OR/WITH grammar)"
372                    ));
373                }
374                s.to_string()
375            }
376            _ => {
377                p.err("license must be a non-empty SPDX id/expression (default MIT)".to_string());
378                "MIT".to_string()
379            }
380        },
381    };
382
383    let docs_site = enum_field!(map, "docs_site", DocsSite, DocsSite::None, p);
384
385    // health_badges — validate when present (key-presence, per Python), else
386    // materialize a floor-clean default (maturity/target aware).
387    let health_badges = if map.contains_key("health_badges") {
388        let mut out = Vec::new();
389        for item in as_list(map.get("health_badges")) {
390            match item.as_str().and_then(HealthBadge::parse) {
391                Some(hb) => out.push(hb),
392                None => p.err(format!(
393                    "health_badges: {} invalid — must be one of {:?}",
394                    yaml_display(&item),
395                    HealthBadge::VALID
396                )),
397            }
398        }
399        out
400    } else {
401        default_health_badges(maturity, &targets)
402    };
403
404    // ── Cross-field floors (§2) — config-internal, ALWAYS hard errors ────────
405    if model == ReleaseModel::Auto && maturity == Maturity::Spike {
406        p.err(
407            "floor: release.model 'auto' is not allowed on maturity 'spike' — a spike is not \
408             being published; raise maturity or set release.model: gated"
409                .to_string(),
410        );
411    }
412    if provenance_level == ProvenanceLevel::SlsaL3 && maturity != Maturity::Production {
413        p.err(format!(
414            "floor: provenance_level 'slsa-l3' is production-only — current maturity is '{}'",
415            maturity.as_str()
416        ));
417    }
418    // A target with a registry requires a valid SPDX license. Every expanded
419    // target carries a registry, so "any registry" reduces to "any target".
420    if !targets.is_empty() && !spdx_valid(&license) {
421        p.err(format!(
422            "floor: a target has a registry (crates.io/npm/PyPI/… require a license) but license \
423             '{license}' is not a valid SPDX expression"
424        ));
425    }
426    check_badge_producers(&health_badges, maturity, &targets, p);
427
428    // ── Filesystem/producer-existence semantic check — ADVISORY, never fatal ─
429    if changelog.mode == ChangelogMode::Fragment
430        && path_inside_repo(&changelog.fragment_dir)
431        && !fs.is_dir(&repo_root.join(&changelog.fragment_dir))
432    {
433        p.warn(format!(
434            "changelog.mode 'fragment' but the fragment dir '{}' does not exist yet under {} — \
435             /oss-changelog creates it; /oss-readiness reports it as a gap until then",
436            changelog.fragment_dir,
437            repo_root.display()
438        ));
439    }
440
441    // ── Forward-compat: preserve unknown fields, report once ─────────────────
442    let mut extra_fields = serde_json::Map::new();
443    for (k, v) in map {
444        if let Value::String(key) = k {
445            if !KNOWN_KEYS.contains(&key.as_str()) {
446                extra_fields.insert(key.clone(), yaml_to_json(v));
447            }
448        }
449    }
450    if !extra_fields.is_empty() {
451        // serde_json::Map is ordered (BTreeMap) → keys already sorted. Rendered
452        // as a single-quoted list to match the Python normalizer's message.
453        let keys = extra_fields
454            .keys()
455            .map(|k| format!("'{k}'"))
456            .collect::<Vec<_>>()
457            .join(", ");
458        p.warn(format!(
459            "unknown field(s) preserved under schema_version {schema_version} (forward-compat): \
460             [{keys}]"
461        ));
462    }
463
464    let warnings = p.warnings.clone();
465    Contract {
466        schema_version,
467        status,
468        maturity,
469        ecosystems,
470        targets,
471        versioning,
472        versioning_pattern,
473        changelog,
474        conventional_commits,
475        release: Release { model, layout },
476        contribution_provenance,
477        provenance_level,
478        dependency_bot,
479        health_badges,
480        license,
481        docs_site,
482        extra_fields,
483        warnings,
484    }
485}
486
487fn parse_versioning(value: Option<&Value>, p: &mut Problems) -> (VersioningBase, Option<String>) {
488    let Some(v) = value else {
489        return (VersioningBase::Semver, None);
490    };
491    let Some(s) = v.as_str() else {
492        p.err(format!(
493            "versioning {} invalid — must be semver | calver:<pattern> | zerover",
494            yaml_display(v)
495        ));
496        return (VersioningBase::Semver, None);
497    };
498    if let Some(rest) = s.strip_prefix("calver:") {
499        let pattern = rest.trim();
500        if pattern.is_empty() {
501            p.err(
502                "versioning 'calver:' carries no pattern — e.g. calver:YYYY.MM.MICRO".to_string(),
503            );
504        }
505        (VersioningBase::Calver, Some(pattern.to_string()))
506    } else if s == "calver" {
507        p.err(
508            "versioning 'calver' must carry its pattern (calver:YYYY.MM.MICRO), not a bare label"
509                .to_string(),
510        );
511        (VersioningBase::Calver, None)
512    } else if let Some(base) = VersioningBase::parse(s) {
513        (base, None)
514    } else {
515        p.err(format!(
516            "versioning '{s}' invalid — must be semver | calver:<pattern> | zerover"
517        ));
518        (VersioningBase::Semver, None)
519    }
520}
521
522/// Derive one target per ecosystem with default registry + adapter.
523fn expand_targets(ecosystems: &[Ecosystem], layout: ReleaseLayout) -> Vec<Target> {
524    ecosystems
525        .iter()
526        .map(|&e| Target {
527            ecosystem: e,
528            package: None,
529            registry: e.default_registry(),
530            adapter: e.default_adapter(layout),
531        })
532        .collect()
533}
534
535fn validate_targets(
536    seq: &[Value],
537    ecosystems: &[Ecosystem],
538    layout: ReleaseLayout,
539    p: &mut Problems,
540) -> Vec<Target> {
541    let mut out = Vec::new();
542    for (idx, item) in seq.iter().enumerate() {
543        let Value::Mapping(m) = item else {
544            p.err(format!(
545                "targets[{idx}] must be a map with at least {{ecosystem, registry}}"
546            ));
547            continue;
548        };
549
550        let ecosystem = if let Some(s) = m.get("ecosystem").and_then(Value::as_str) {
551            if let Some(e) = Ecosystem::parse(s) {
552                if !ecosystems.is_empty() && !ecosystems.contains(&e) {
553                    p.err(format!(
554                        "targets[{idx}].ecosystem '{s}' is not in ecosystems {:?}",
555                        ecosystems.iter().map(|e| e.as_str()).collect::<Vec<_>>()
556                    ));
557                }
558                Some(e)
559            } else {
560                p.err(format!(
561                    "targets[{idx}].ecosystem '{s}' invalid — one of {:?}",
562                    Ecosystem::VALID
563                ));
564                None
565            }
566        } else {
567            p.err(format!(
568                "targets[{idx}].ecosystem invalid — one of {:?}",
569                Ecosystem::VALID
570            ));
571            None
572        };
573
574        let registry = match m.get("registry").and_then(Value::as_str) {
575            None => {
576                p.err(format!(
577                    "targets[{idx}] has no registry (required — the publish destination)"
578                ));
579                None
580            }
581            Some(s) => {
582                if let Some(r) = Registry::parse(s) {
583                    Some(r)
584                } else {
585                    p.err(format!(
586                        "targets[{idx}].registry '{s}' invalid — one of {:?}",
587                        Registry::VALID
588                    ));
589                    None
590                }
591            }
592        };
593
594        let adapter = match m.get("adapter") {
595            None => ecosystem.map(|e| e.default_adapter(layout)),
596            Some(v) => {
597                if let Some(a) = v.as_str().and_then(Adapter::parse) {
598                    Some(a)
599                } else {
600                    p.err(format!(
601                        "targets[{idx}].adapter {} invalid — one of {:?}",
602                        yaml_display(v),
603                        Adapter::VALID
604                    ));
605                    None
606                }
607            }
608        };
609
610        // On the error path, placeholders keep the strong type; the document is
611        // never emitted when problems.errors is non-empty.
612        out.push(Target {
613            ecosystem: ecosystem.unwrap_or(Ecosystem::Binary),
614            package: m.get("package").and_then(Value::as_str).map(str::to_string),
615            registry: registry.unwrap_or(Registry::GhReleases),
616            adapter: adapter.unwrap_or(Adapter::Manual),
617        });
618    }
619    out
620}
621
622/// A floor-clean default badge set: `ci` at mvp+, `registry` when a publishable
623/// target exists, `license` always.
624fn default_health_badges(maturity: Maturity, targets: &[Target]) -> Vec<HealthBadge> {
625    let mut badges = Vec::new();
626    if matches!(maturity, Maturity::Mvp | Maturity::Production) {
627        badges.push(HealthBadge::Ci);
628    }
629    if !targets.is_empty() {
630        badges.push(HealthBadge::Registry);
631    }
632    badges.push(HealthBadge::License);
633    badges
634}
635
636/// Every enabled badge must have its producer enabled (floor 4).
637fn check_badge_producers(
638    badges: &[HealthBadge],
639    maturity: Maturity,
640    targets: &[Target],
641    p: &mut Problems,
642) {
643    let has_registry_target = !targets.is_empty();
644    for b in badges {
645        match b {
646            HealthBadge::Ci if maturity == Maturity::Spike => p.err(
647                "floor: health_badge 'ci' has no producer at maturity 'spike' (no CI until mvp) — \
648                 drop it or raise maturity"
649                    .to_string(),
650            ),
651            HealthBadge::Registry if !has_registry_target => p.err(
652                "floor: health_badge 'registry' has no producer — no target has a registry to \
653                 publish to"
654                    .to_string(),
655            ),
656            HealthBadge::Coverage if maturity != Maturity::Production => p.err(format!(
657                "floor: health_badge 'coverage' has no producer — the coverage gate is a \
658                 production-tier /oss-ci output; current maturity is '{}'",
659                maturity.as_str()
660            )),
661            HealthBadge::Scorecard if maturity != Maturity::Production => p.err(format!(
662                "floor: health_badge 'scorecard' has no producer — the Scorecard action is a \
663                 production-tier output; current maturity is '{}'",
664                maturity.as_str()
665            )),
666            _ => {}
667        }
668    }
669}
670
671// ── Frontmatter extraction + parse ───────────────────────────────────────────
672
673/// A `---` fence line (exactly three dashes plus optional trailing whitespace).
674fn is_fence(line: &str) -> bool {
675    let t = line.trim_end();
676    t == "---" || (t.starts_with("---") && t[3..].chars().all(char::is_whitespace))
677}
678
679/// Split the YAML frontmatter block out of the document. Returns the frontmatter
680/// text (body discarded — the normalizer never reads it), or `None` on a
681/// structural error (recorded on `p`).
682fn split_frontmatter(text: &str, p: &mut Problems) -> Option<String> {
683    let mut lines = text.lines();
684    match lines.next() {
685        Some(first) if is_fence(first) => {}
686        _ => {
687            p.err("frontmatter missing: file must begin with a '---' YAML block".to_string());
688            return None;
689        }
690    }
691    let mut fm = String::new();
692    for line in lines {
693        if is_fence(line) {
694            return Some(fm);
695        }
696        fm.push_str(line);
697        fm.push('\n');
698    }
699    p.err("frontmatter not closed: no terminating '---' line found".to_string());
700    None
701}
702
703/// Parse the frontmatter into a YAML mapping. `serde_yaml` rejects duplicate
704/// keys natively; a non-mapping top level or any YAML error is recorded on `p`.
705fn parse_frontmatter(fm: &str, p: &mut Problems) -> Mapping {
706    if fm.trim().is_empty() {
707        return Mapping::new();
708    }
709    match serde_yaml::from_str::<Value>(fm) {
710        Ok(Value::Null) => Mapping::new(),
711        Ok(Value::Mapping(m)) => m,
712        Ok(_) => {
713            p.err("frontmatter: top level must be a mapping".to_string());
714            Mapping::new()
715        }
716        Err(e) => {
717            p.err(format!("frontmatter: invalid YAML — {e}"));
718            Mapping::new()
719        }
720    }
721}
722
723// ── Helpers ──────────────────────────────────────────────────────────────────
724
725/// Coerce a value to a list: a sequence stays; absent/null → empty; a scalar
726/// becomes a one-element list (mirrors the Python `_as_list`).
727fn as_list(v: Option<&Value>) -> Vec<Value> {
728    match v {
729        None | Some(Value::Null) => Vec::new(),
730        Some(Value::Sequence(seq)) => seq.clone(),
731        Some(other) => vec![other.clone()],
732    }
733}
734
735/// A compact display of a YAML scalar for error messages (strings are quoted).
736fn yaml_display(v: &Value) -> String {
737    match v {
738        Value::String(s) => format!("'{s}'"),
739        Value::Bool(b) => b.to_string(),
740        Value::Number(n) => n.to_string(),
741        Value::Null => "null".to_string(),
742        Value::Sequence(_) => "<list>".to_string(),
743        Value::Mapping(_) => "<map>".to_string(),
744        Value::Tagged(t) => yaml_display(&t.value),
745    }
746}
747
748/// Whether `rel` is a relative path that stays inside the repo — no absolute
749/// path, no `../` escape — the fragment-dir floor. Lexical, so the path need not
750/// exist. The check is purely on `rel`'s own component depth, so it holds
751/// whether the repo root is absolute or relative (notably `--repo-root .`,
752/// where `repo_root` normalizes to an empty path): a `..` is an escape the
753/// moment it would pop above the repo root, exactly the Python
754/// `_path_inside_repo` verdict (which rejects any `rel` that normalizes to an
755/// escaping path). Joining `rel` onto a relative root and testing containment —
756/// the previous approach — silently accepted `../etc` under a `.` root, because
757/// an empty normalized root is a prefix of every path.
758fn path_inside_repo(rel: &str) -> bool {
759    let mut depth: usize = 0;
760    for comp in Path::new(rel).components() {
761        match comp {
762            Component::CurDir => {}
763            Component::Normal(_) => depth += 1,
764            Component::ParentDir => {
765                // An escape above the repo root the instant depth would go < 0.
766                if depth == 0 {
767                    return false;
768                }
769                depth -= 1;
770            }
771            // An absolute path (or a Windows drive prefix) never stays inside a
772            // relative repo root.
773            Component::RootDir | Component::Prefix(_) => return false,
774        }
775    }
776    true
777}
778
779/// Convert an arbitrary YAML value to JSON, for `extra_fields` preservation.
780fn yaml_to_json(v: &Value) -> serde_json::Value {
781    use serde_json::Value as J;
782    match v {
783        Value::Null => J::Null,
784        Value::Bool(b) => J::Bool(*b),
785        Value::Number(n) => {
786            if let Some(i) = n.as_i64() {
787                J::from(i)
788            } else if let Some(u) = n.as_u64() {
789                J::from(u)
790            } else if let Some(f) = n.as_f64() {
791                serde_json::Number::from_f64(f).map_or(J::Null, J::Number)
792            } else {
793                J::Null
794            }
795        }
796        Value::String(s) => J::String(s.clone()),
797        Value::Sequence(seq) => J::Array(seq.iter().map(yaml_to_json).collect()),
798        Value::Mapping(m) => {
799            let mut obj = serde_json::Map::new();
800            for (k, val) in m {
801                let key = match k {
802                    Value::String(s) => s.clone(),
803                    other => yaml_display(other),
804                };
805                obj.insert(key, yaml_to_json(val));
806            }
807            J::Object(obj)
808        }
809        Value::Tagged(t) => yaml_to_json(&t.value),
810    }
811}
812
813#[cfg(test)]
814mod tests {
815    use super::*;
816    use std::collections::HashSet;
817
818    /// A fake `Fs`: `normalize_str` never `read`s, so only the directory set
819    /// matters (for the fragment-dir advisory check).
820    struct FakeFs {
821        dirs: HashSet<PathBuf>,
822    }
823
824    impl FakeFs {
825        fn empty() -> Self {
826            Self {
827                dirs: HashSet::new(),
828            }
829        }
830
831        fn with_dirs<const N: usize>(dirs: [&str; N]) -> Self {
832            Self {
833                dirs: dirs.iter().map(PathBuf::from).collect(),
834            }
835        }
836    }
837
838    impl Fs for FakeFs {
839        fn read(&self, _path: &Path) -> io::Result<Vec<u8>> {
840            Err(io::Error::from(io::ErrorKind::NotFound))
841        }
842        fn exists(&self, path: &Path) -> bool {
843            self.dirs.contains(path)
844        }
845        fn is_dir(&self, path: &Path) -> bool {
846            self.dirs.contains(path)
847        }
848        fn is_file(&self, _path: &Path) -> bool {
849            // The contract normalizer models only directories (fragment-dir).
850            false
851        }
852        fn read_dir(&self, _dir: &Path) -> io::Result<Vec<String>> {
853            // The contract normalizer never lists directories.
854            Ok(Vec::new())
855        }
856    }
857
858    fn repo() -> &'static Path {
859        Path::new("/repo")
860    }
861
862    fn norm(text: &str) -> Normalized {
863        normalize_str(text, repo(), &FakeFs::empty())
864    }
865
866    fn norm_with(text: &str, fs: &dyn Fs) -> Normalized {
867        normalize_str(text, repo(), fs)
868    }
869
870    fn assert_error_contains(n: &Normalized, needle: &str) {
871        assert!(
872            !n.is_valid(),
873            "expected invalid, got clean normalize: {:?}",
874            n.contract
875        );
876        assert!(
877            n.problems.errors.iter().any(|e| e.contains(needle)),
878            "no error contained {needle:?}; errors were {:?}",
879            n.problems.errors
880        );
881    }
882
883    const MINIMAL: &str = "---\nstatus: approved\nmaturity: mvp\n---\n";
884
885    #[test]
886    fn materializes_all_defaults() {
887        let c = norm(MINIMAL).contract;
888        assert_eq!(c.schema_version, 1);
889        assert_eq!(c.status, Status::Approved);
890        assert_eq!(c.maturity, Maturity::Mvp);
891        assert!(c.ecosystems.is_empty());
892        assert!(c.targets.is_empty());
893        assert_eq!(c.versioning, VersioningBase::Semver);
894        assert_eq!(c.versioning_pattern, None);
895        assert_eq!(c.changelog.mode, ChangelogMode::Curated);
896        assert_eq!(c.changelog.source, ChangelogSource::Manual);
897        assert_eq!(c.changelog.fragment_dir, DEFAULT_FRAGMENT_DIR);
898        assert!(!c.conventional_commits);
899        assert_eq!(c.release.model, ReleaseModel::Gated);
900        assert_eq!(c.release.layout, ReleaseLayout::Single);
901        assert_eq!(c.contribution_provenance, ContributionProvenance::None);
902        assert_eq!(c.provenance_level, ProvenanceLevel::None);
903        assert_eq!(c.dependency_bot, DependencyBot::Dependabot); // mvp default
904        assert_eq!(c.license, "MIT");
905        assert_eq!(c.docs_site, DocsSite::None);
906        // mvp, no publishable target → [ci, license].
907        assert_eq!(c.health_badges, vec![HealthBadge::Ci, HealthBadge::License]);
908        assert!(c.extra_fields.is_empty());
909    }
910
911    #[test]
912    fn spike_defaults_no_bot_no_ci_badge() {
913        let c = norm("---\nstatus: approved\nmaturity: spike\n---\n").contract;
914        assert_eq!(c.dependency_bot, DependencyBot::None);
915        assert_eq!(c.health_badges, vec![HealthBadge::License]);
916    }
917
918    #[test]
919    fn maturity_is_required() {
920        assert_error_contains(
921            &norm("---\nstatus: approved\n---\n"),
922            "maturity is required",
923        );
924    }
925
926    #[test]
927    fn expands_targets_from_ecosystems() {
928        let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [python]\n---\n").contract;
929        assert_eq!(c.targets.len(), 1);
930        assert_eq!(c.targets[0].ecosystem, Ecosystem::Python);
931        assert_eq!(c.targets[0].package, None);
932        assert_eq!(c.targets[0].registry, Registry::Pypi);
933        assert_eq!(c.targets[0].adapter, Adapter::GhActionPypiPublish);
934    }
935
936    #[test]
937    fn node_monorepo_adapter_is_changesets() {
938        let text = "---\nstatus: approved\nmaturity: production\necosystems: [node]\n\
939                    release:\n  model: gated\n  layout: monorepo\n---\n";
940        let c = norm(text).contract;
941        assert_eq!(c.targets[0].adapter, Adapter::Changesets);
942    }
943
944    #[test]
945    fn ecosystems_dedup_to_canonical_order() {
946        let c =
947            norm("---\nstatus: approved\nmaturity: mvp\necosystems: [python, rust, python]\n---\n")
948                .contract;
949        assert_eq!(c.ecosystems, vec![Ecosystem::Rust, Ecosystem::Python]);
950    }
951
952    #[test]
953    fn calver_splits_base_and_pattern() {
954        let c = norm(
955            "---\nstatus: approved\nmaturity: mvp\nversioning: \"calver:YYYY.MM.MICRO\"\n---\n",
956        )
957        .contract;
958        assert_eq!(c.versioning, VersioningBase::Calver);
959        assert_eq!(c.versioning_pattern.as_deref(), Some("YYYY.MM.MICRO"));
960    }
961
962    #[test]
963    fn bare_calver_is_rejected() {
964        assert_error_contains(
965            &norm("---\nstatus: approved\nmaturity: mvp\nversioning: calver\n---\n"),
966            "must carry its pattern",
967        );
968    }
969
970    #[test]
971    fn floor_auto_on_spike() {
972        let text = "---\nstatus: approved\nmaturity: spike\n\
973                    release:\n  model: auto\n  layout: single\nhealth_badges: [license]\n---\n";
974        assert_error_contains(&norm(text), "release.model 'auto' is not allowed");
975    }
976
977    #[test]
978    fn floor_slsa_l3_production_only() {
979        assert_error_contains(
980            &norm("---\nstatus: approved\nmaturity: mvp\nprovenance_level: slsa-l3\n---\n"),
981            "slsa-l3' is production-only",
982        );
983    }
984
985    #[test]
986    fn floor_registry_requires_valid_license() {
987        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
988                    license: Proprietary-Acme\nhealth_badges: [ci, registry, license]\n---\n";
989        let n = norm(text);
990        // Both the SPDX-validity error and the registry-needs-license floor fire.
991        assert_error_contains(&n, "not a valid SPDX expression");
992        assert!(n
993            .problems
994            .errors
995            .iter()
996            .any(|e| e.contains("floor: a target has a registry")));
997    }
998
999    #[test]
1000    fn floor_badge_without_producer() {
1001        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [python]\n\
1002                    health_badges: [ci, coverage]\n---\n";
1003        assert_error_contains(&norm(text), "health_badge 'coverage' has no producer");
1004    }
1005
1006    #[test]
1007    fn floor_schema_version_too_new() {
1008        assert_error_contains(
1009            &norm("---\nschema_version: 99\nstatus: approved\nmaturity: mvp\n---\n"),
1010            "exceeds what this tool knows",
1011        );
1012    }
1013
1014    #[test]
1015    fn floor_fragment_dir_escape() {
1016        let text = "---\nstatus: approved\nmaturity: mvp\n\
1017                    changelog:\n  mode: fragment\n  source: manual\n  fragment_dir: /etc\n---\n";
1018        assert_error_contains(&norm(text), "must be a relative path inside the repo");
1019    }
1020
1021    #[test]
1022    fn floor_fragment_dir_escape_relative_root() {
1023        // Regression: with a *relative* repo root (the CLI's `--repo-root .`),
1024        // a `../`-escaping fragment_dir must still be rejected. The earlier
1025        // join-then-contain check accepted it because a `.` root normalizes to
1026        // an empty path that prefixes everything.
1027        let text = "---\nstatus: approved\nmaturity: mvp\n\
1028                    changelog:\n  mode: fragment\n  source: manual\n  fragment_dir: ../etc\n---\n";
1029        let n = normalize_str(text, Path::new("."), &FakeFs::empty());
1030        assert_error_contains(&n, "must be a relative path inside the repo");
1031    }
1032
1033    #[test]
1034    fn path_inside_repo_verdicts() {
1035        // Inside — plain and `.`/`..`-collapsing relative paths that stay in.
1036        assert!(path_inside_repo("changelog/fragments"));
1037        assert!(path_inside_repo("./changelog/fragments"));
1038        assert!(path_inside_repo("a/../fragments"));
1039        assert!(path_inside_repo("")); // the repo root itself
1040                                       // Escapes — absolute, leading `..`, and mid-path `..` that pops out.
1041        assert!(!path_inside_repo("/etc"));
1042        assert!(!path_inside_repo("../etc"));
1043        assert!(!path_inside_repo("a/../../etc"));
1044    }
1045
1046    #[test]
1047    fn unknown_fields_preserved_and_warned() {
1048        let text =
1049            "---\nstatus: approved\nmaturity: mvp\nroadmap_url: https://example.com/x\n---\n";
1050        let n = norm(text);
1051        assert!(n.is_valid());
1052        assert_eq!(
1053            n.contract
1054                .extra_fields
1055                .get("roadmap_url")
1056                .and_then(|v| v.as_str()),
1057            Some("https://example.com/x")
1058        );
1059        assert!(n
1060            .problems
1061            .warnings
1062            .iter()
1063            .any(|w| w.contains("roadmap_url") && w.contains("forward-compat")));
1064    }
1065
1066    #[test]
1067    fn duplicate_key_is_rejected() {
1068        assert_error_contains(
1069            &norm("---\nstatus: approved\nstatus: draft\nmaturity: mvp\n---\n"),
1070            "invalid YAML",
1071        );
1072    }
1073
1074    #[test]
1075    fn missing_frontmatter_is_rejected() {
1076        assert_error_contains(&norm("no frontmatter here\n"), "frontmatter missing");
1077    }
1078
1079    #[test]
1080    fn unclosed_frontmatter_is_rejected() {
1081        assert_error_contains(&norm("---\nstatus: approved\n"), "frontmatter not closed");
1082    }
1083
1084    #[test]
1085    fn invalid_enum_records_error_and_continues() {
1086        // A bad status AND a bad maturity: both surface (multi-error collection).
1087        let n = norm("---\nstatus: bogus\nmaturity: alsobogus\n---\n");
1088        assert!(n.problems.errors.iter().any(|e| e.contains("status")));
1089        assert!(n.problems.errors.iter().any(|e| e.contains("maturity")));
1090    }
1091
1092    #[test]
1093    fn fragment_dir_present_suppresses_advisory() {
1094        let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
1095                    changelog:\n  mode: fragment\n  source: manual\n---\n";
1096        // The default fragment dir exists → no advisory warning.
1097        let fs = FakeFs::with_dirs(["/repo/changelog/fragments"]);
1098        let n = norm_with(text, &fs);
1099        assert!(n.is_valid());
1100        assert!(
1101            !n.problems
1102                .warnings
1103                .iter()
1104                .any(|w| w.contains("does not exist yet")),
1105            "advisory should be suppressed when the dir exists: {:?}",
1106            n.problems.warnings
1107        );
1108    }
1109
1110    #[test]
1111    fn serializes_to_schema_v4_shape() {
1112        let json =
1113            serde_json::to_value(&norm("---\nstatus: approved\nmaturity: mvp\n---\n").contract)
1114                .unwrap();
1115        // Spot-check the §4 top-level keys that consumers read.
1116        for key in [
1117            "schema_version",
1118            "status",
1119            "maturity",
1120            "ecosystems",
1121            "targets",
1122            "versioning",
1123            "versioning_pattern",
1124            "changelog",
1125            "conventional_commits",
1126            "release",
1127            "contribution_provenance",
1128            "provenance_level",
1129            "dependency_bot",
1130            "health_badges",
1131            "license",
1132            "docs_site",
1133            "extra_fields",
1134            "warnings",
1135        ] {
1136            assert!(json.get(key).is_some(), "missing §4 key {key}");
1137        }
1138        assert!(json["versioning_pattern"].is_null());
1139    }
1140}