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, 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
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 "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#[derive(Debug, Default)]
65pub struct Problems {
66 pub errors: Vec<String>,
69 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#[derive(Debug)]
86pub struct Normalized {
87 pub contract: Contract,
89 pub problems: Problems,
91}
92
93impl Normalized {
94 #[must_use]
96 pub fn is_valid(&self) -> bool {
97 self.problems.errors.is_empty()
98 }
99}
100
101#[derive(Debug)]
104pub enum LoadError {
105 NotFound(PathBuf),
107 Io(PathBuf, io::Error),
109 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
127pub 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#[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
163macro_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 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 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 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 let (versioning, versioning_pattern) = parse_versioning(map.get("versioning"), p);
256
257 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 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 let distribution = parse_distribution(map.get("distribution"), p);
287
288 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 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 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 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 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 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 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 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 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 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 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
539fn 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 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
639const INSTALLER_ORDER: [Installer; 5] = [
642 Installer::Shell,
643 Installer::Powershell,
644 Installer::Homebrew,
645 Installer::Msi,
646 Installer::Npm,
647];
648
649#[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 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 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 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 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 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 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 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 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
832fn default_cross_platform_targets() -> Vec<String> {
836 DEFAULT_CROSS_PLATFORM_TARGETS
837 .iter()
838 .map(|&s| s.to_string())
839 .collect()
840}
841
842fn 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
863fn 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
884fn 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
898fn 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
933fn 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
941fn 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
965fn 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
985fn 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
997fn 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
1010fn 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 if depth == 0 {
1029 return false;
1030 }
1031 depth -= 1;
1032 }
1033 Component::RootDir | Component::Prefix(_) => return false,
1036 }
1037 }
1038 true
1039}
1040
1041fn 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 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 false
1113 }
1114 fn read_dir(&self, _dir: &Path) -> io::Result<Vec<String>> {
1115 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); assert_eq!(c.license, "MIT");
1167 assert_eq!(c.docs_site, DocsSite::None);
1168 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 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 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 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("")); 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 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 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 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 assert!(json["distribution"].is_null());
1405 }
1406
1407 #[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 #[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 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 let d = c.distribution.expect("distribution present");
1436 assert_eq!(d.adapter, DistributionAdapter::CargoDist);
1437 assert!(d.gh_releases); 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 #[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 #[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 #[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 #[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 assert_eq!(n.contract.distribution.unwrap().homebrew_tap, None);
1500 }
1501
1502 #[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 #[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 #[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 #[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 #[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 #[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 fn has_linux(platforms: &[String]) -> bool {
1577 platforms.iter().any(|t| t.contains("-linux"))
1578 }
1579
1580 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 assert!(looks_like_target_triple("thumbv8m.main-none-eabi"));
1702 assert!(looks_like_target_triple("thumbv8m.base-none-eabi"));
1703 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 assert!(looks_like_target_triple("aa-bb"));
1715 }
1716
1717 #[test]
1718 fn is_tap_slug_verdicts() {
1719 assert!(is_tap_slug("owner/repo"));
1721 assert!(is_tap_slug("jarimustonen/homebrew-issuectl"));
1722 assert!(is_tap_slug("Owner_1/repo.rb"));
1723 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 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}