1use 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
29pub const CONTRACT_FILENAME: &str = "OSS-RELEASE.md";
31
32const ECOSYSTEM_ORDER: [Ecosystem; 5] = [
35 Ecosystem::Rust,
36 Ecosystem::Node,
37 Ecosystem::Python,
38 Ecosystem::Go,
39 Ecosystem::Binary,
40];
41
42const 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#[derive(Debug, Default)]
64pub struct Problems {
65 pub errors: Vec<String>,
68 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#[derive(Debug)]
85pub struct Normalized {
86 pub contract: Contract,
88 pub problems: Problems,
90}
91
92impl Normalized {
93 #[must_use]
95 pub fn is_valid(&self) -> bool {
96 self.problems.errors.is_empty()
97 }
98}
99
100#[derive(Debug)]
103pub enum LoadError {
104 NotFound(PathBuf),
106 Io(PathBuf, io::Error),
108 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
126pub 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#[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
162macro_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 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 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 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 let (versioning, versioning_pattern) = parse_versioning(map.get("versioning"), p);
255
256 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 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 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 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 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 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 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 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 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 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 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 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
522fn 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 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
622fn 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
636fn 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
671fn 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
679fn 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
703fn 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
723fn 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
735fn 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
748fn 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 if depth == 0 {
767 return false;
768 }
769 depth -= 1;
770 }
771 Component::RootDir | Component::Prefix(_) => return false,
774 }
775 }
776 true
777}
778
779fn 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 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 false
851 }
852 fn read_dir(&self, _dir: &Path) -> io::Result<Vec<String>> {
853 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); assert_eq!(c.license, "MIT");
905 assert_eq!(c.docs_site, DocsSite::None);
906 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 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 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 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("")); 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 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 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 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}