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] = &[
59 "schema_version",
60 "status",
61 "maturity",
62 "ecosystems",
63 "targets",
64 "distribution",
68 "distributions",
69 "versioning",
70 "changelog",
71 "conventional_commits",
72 "release",
73 "contribution_provenance",
74 "provenance_level",
75 "dependency_bot",
76 "health_badges",
77 "license",
78 "docs_site",
79 "extra_fields",
81 "warnings",
82];
83
84#[derive(Debug, Default)]
86pub struct Problems {
87 pub errors: Vec<String>,
90 pub warnings: Vec<String>,
92}
93
94impl Problems {
95 fn err(&mut self, msg: String) {
96 self.errors.push(msg);
97 }
98
99 fn warn(&mut self, msg: String) {
100 self.warnings.push(msg);
101 }
102}
103
104#[derive(Debug)]
107pub struct Normalized {
108 pub contract: Contract,
110 pub problems: Problems,
112}
113
114impl Normalized {
115 #[must_use]
117 pub fn is_valid(&self) -> bool {
118 self.problems.errors.is_empty()
119 }
120}
121
122#[derive(Debug)]
125pub enum LoadError {
126 NotFound(PathBuf),
128 Io(PathBuf, io::Error),
130 Utf8(PathBuf),
132}
133
134impl std::fmt::Display for LoadError {
135 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136 match self {
137 Self::NotFound(p) => write!(
138 f,
139 "no {CONTRACT_FILENAME} at {} (run /oss-init to generate one)",
140 p.display()
141 ),
142 Self::Io(p, e) => write!(f, "cannot read {}: {e}", p.display()),
143 Self::Utf8(p) => write!(f, "{} is not valid UTF-8", p.display()),
144 }
145 }
146}
147
148pub fn normalize(repo_root: &Path, fs: &dyn Fs) -> Result<Normalized, LoadError> {
155 let path = repo_root.join(CONTRACT_FILENAME);
156 let bytes = fs.read(&path).map_err(|e| match e.kind() {
157 io::ErrorKind::NotFound => LoadError::NotFound(path.clone()),
158 _ => LoadError::Io(path.clone(), e),
159 })?;
160 let text = String::from_utf8(bytes).map_err(|_| LoadError::Utf8(path.clone()))?;
161 Ok(normalize_str(&text, repo_root, fs))
162}
163
164#[must_use]
171pub fn normalize_str(text: &str, repo_root: &Path, fs: &dyn Fs) -> Normalized {
172 let mut p = Problems::default();
173 let map = match split_frontmatter(text, &mut p) {
174 Some(fm) => parse_frontmatter(&fm, &mut p),
175 None => Mapping::new(),
176 };
177 let contract = build(&map, &mut p, repo_root, fs);
178 Normalized {
179 contract,
180 problems: p,
181 }
182}
183
184macro_rules! enum_field {
188 ($map:expr, $key:expr, $ty:ty, $default:expr, $p:expr) => {{
189 match $map.get($key) {
190 None => $default,
191 Some(v) => match v.as_str().and_then(<$ty>::parse) {
192 Some(x) => x,
193 None => {
194 $p.err(format!(
195 "{} {} invalid — must be one of {:?}",
196 $key,
197 yaml_display(v),
198 <$ty>::VALID
199 ));
200 $default
201 }
202 },
203 }
204 }};
205}
206
207#[allow(clippy::too_many_lines)]
208fn build(map: &Mapping, p: &mut Problems, repo_root: &Path, fs: &dyn Fs) -> Contract {
209 match map.get("schema_version") {
218 None => {}
219 Some(v) => match v.as_i64() {
220 Some(n) if n > i64::from(KNOWN_SCHEMA_VERSION) => p.err(format!(
221 "schema_version {n} exceeds what this tool knows ({KNOWN_SCHEMA_VERSION}); \
222 upgrade the OSS-release skills before reading this config (refusing rather \
223 than guessing)."
224 )),
225 Some(n) if n < 1 => p.err(format!("schema_version {n} is invalid (must be >= 1)")),
226 Some(_) => {}
227 None => p.err(format!(
228 "schema_version must be an integer, got {}",
229 yaml_display(v)
230 )),
231 },
232 }
233 let schema_version = KNOWN_SCHEMA_VERSION;
234
235 let status = enum_field!(map, "status", Status, Status::Draft, p);
236
237 let maturity = match map.get("maturity") {
239 None => {
240 p.err("maturity is required (spike|mvp|production) — /oss-init infers it".to_string());
241 Maturity::Mvp
242 }
243 Some(v) => {
244 if let Some(m) = v.as_str().and_then(Maturity::parse) {
245 m
246 } else {
247 p.err(format!(
248 "maturity {} invalid — must be one of {:?}",
249 yaml_display(v),
250 Maturity::VALID
251 ));
252 Maturity::Mvp
253 }
254 }
255 };
256
257 let mut parsed_ecos: Vec<Ecosystem> = Vec::new();
259 for item in as_list(map.get("ecosystems")) {
260 match item.as_str().and_then(Ecosystem::parse) {
261 Some(e) => parsed_ecos.push(e),
262 None => p.err(format!(
263 "ecosystems: {} invalid — must be one of {:?}",
264 yaml_display(&item),
265 Ecosystem::VALID
266 )),
267 }
268 }
269 let ecosystems: Vec<Ecosystem> = ECOSYSTEM_ORDER
270 .into_iter()
271 .filter(|e| parsed_ecos.contains(e))
272 .collect();
273
274 let (versioning, versioning_pattern) = parse_versioning(map.get("versioning"), p);
276
277 let (model, layout, bump_hook) = match map.get("release") {
279 None | Some(Value::Null) => (ReleaseModel::Gated, ReleaseLayout::Single, None),
280 Some(Value::Mapping(m)) => (
281 enum_field!(m, "model", ReleaseModel, ReleaseModel::Gated, p),
282 enum_field!(m, "layout", ReleaseLayout, ReleaseLayout::Single, p),
283 parse_bump_hook(m, p),
284 ),
285 Some(_) => {
286 p.err("release must be a mapping (model / layout / bump_hook)".to_string());
287 (ReleaseModel::Gated, ReleaseLayout::Single, None)
288 }
289 };
290
291 let targets = match map.get("targets") {
301 None | Some(Value::Null) => expand_targets(&ecosystems, layout),
302 Some(Value::Sequence(seq)) if seq.is_empty() => Vec::new(),
303 Some(Value::Sequence(seq)) => validate_targets(seq, &ecosystems, layout, p),
304 Some(_) => {
305 p.err(
306 "targets must be a list of {ecosystem, package?, registry, adapter?} maps"
307 .to_string(),
308 );
309 Vec::new()
310 }
311 };
312
313 let distributions = parse_distributions(map, &targets, schema_version, p);
319
320 let changelog = match map.get("changelog") {
322 None | Some(Value::Null) => Changelog {
323 mode: ChangelogMode::Curated,
324 source: ChangelogSource::Manual,
325 fragment_dir: DEFAULT_FRAGMENT_DIR.to_string(),
326 },
327 Some(Value::Mapping(m)) => {
328 let mode = enum_field!(m, "mode", ChangelogMode, ChangelogMode::Curated, p);
329 let source = enum_field!(m, "source", ChangelogSource, ChangelogSource::Manual, p);
330 let fragment_dir = match m.get("fragment_dir") {
331 None => DEFAULT_FRAGMENT_DIR.to_string(),
332 Some(v) => {
333 if let Some(s) = v.as_str() {
334 s.to_string()
335 } else {
336 p.err("changelog.fragment_dir must be a string path".to_string());
337 DEFAULT_FRAGMENT_DIR.to_string()
338 }
339 }
340 };
341 Changelog {
342 mode,
343 source,
344 fragment_dir,
345 }
346 }
347 Some(_) => {
348 p.err("changelog must be a mapping with mode/source".to_string());
349 Changelog {
350 mode: ChangelogMode::Curated,
351 source: ChangelogSource::Manual,
352 fragment_dir: DEFAULT_FRAGMENT_DIR.to_string(),
353 }
354 }
355 };
356 if !path_inside_repo(&changelog.fragment_dir) {
358 p.err(format!(
359 "floor: changelog.fragment_dir {} must be a relative path inside the repo (an \
360 absolute or '../'-escaping path is refused)",
361 quote_for_diagnostic(&changelog.fragment_dir)
362 ));
363 }
364
365 let conventional_commits = match map.get("conventional_commits") {
367 None => false,
368 Some(Value::Bool(b)) => *b,
369 Some(v) => {
370 p.err(format!(
371 "conventional_commits must be true|false, got {}",
372 yaml_display(v)
373 ));
374 false
375 }
376 };
377
378 let contribution_provenance = enum_field!(
379 map,
380 "contribution_provenance",
381 ContributionProvenance,
382 ContributionProvenance::None,
383 p
384 );
385 let provenance_level = enum_field!(
386 map,
387 "provenance_level",
388 ProvenanceLevel,
389 ProvenanceLevel::None,
390 p
391 );
392
393 let dep_default = if maturity == Maturity::Spike {
394 DependencyBot::None
395 } else {
396 DependencyBot::Dependabot
397 };
398 let dependency_bot = enum_field!(map, "dependency_bot", DependencyBot, dep_default, p);
399
400 let license = match map.get("license") {
402 None => "MIT".to_string(),
403 Some(v) => match v.as_str() {
404 Some(s) if !s.trim().is_empty() => {
405 if !spdx_valid(s) {
406 p.err(format!(
407 "license {} is not a valid SPDX expression (unknown id or malformed \
408 AND/OR/WITH grammar)",
409 quote_for_diagnostic(s)
410 ));
411 }
412 s.to_string()
413 }
414 _ => {
415 p.err("license must be a non-empty SPDX id/expression (default MIT)".to_string());
416 "MIT".to_string()
417 }
418 },
419 };
420
421 let docs_site = enum_field!(map, "docs_site", DocsSite, DocsSite::None, p);
422
423 let health_badges = if map.contains_key("health_badges") {
426 let mut out = Vec::new();
427 for item in as_list(map.get("health_badges")) {
428 match item.as_str().and_then(HealthBadge::parse) {
429 Some(hb) => out.push(hb),
430 None => p.err(format!(
431 "health_badges: {} invalid — must be one of {:?}",
432 yaml_display(&item),
433 HealthBadge::VALID
434 )),
435 }
436 }
437 out
438 } else {
439 default_health_badges(maturity, &targets)
440 };
441
442 if model == ReleaseModel::Auto && maturity == Maturity::Spike {
444 p.err(
445 "floor: release.model 'auto' is not allowed on maturity 'spike' — a spike is not \
446 being published; raise maturity or set release.model: gated"
447 .to_string(),
448 );
449 }
450 if provenance_level == ProvenanceLevel::SlsaL3 && maturity != Maturity::Production {
451 p.err(format!(
452 "floor: provenance_level 'slsa-l3' is production-only — current maturity is '{}'",
453 maturity.as_str()
454 ));
455 }
456 if !targets.is_empty() && !spdx_valid(&license) {
459 p.err(format!(
460 "floor: a target has a registry (crates.io/npm/PyPI/… require a license) but license \
461 {} is not a valid SPDX expression",
462 quote_for_diagnostic(&license)
463 ));
464 }
465 check_badge_producers(&health_badges, maturity, &targets, p);
466 check_homebrew_configuration(&targets, &distributions, p);
469 if !distributions.is_empty() && maturity == Maturity::Spike {
474 p.err(
475 "floor: a distribution block ships public binaries (installer + tap) — not allowed on \
476 maturity 'spike' (a spike is not being published); raise maturity or drop distribution"
477 .to_string(),
478 );
479 }
480
481 if changelog.mode == ChangelogMode::Fragment
483 && path_inside_repo(&changelog.fragment_dir)
484 && !fs.is_dir(&repo_root.join(&changelog.fragment_dir))
485 {
486 p.warn(format!(
487 "changelog.mode 'fragment' but the fragment dir {} does not exist yet under {} — \
488 /oss-changelog creates it; /oss-readiness reports it as a gap until then",
489 quote_for_diagnostic(&changelog.fragment_dir),
490 repo_root.display()
491 ));
492 }
493
494 let extra_fields =
496 capture_unknown_fields(map, KNOWN_KEYS, CaptureScope::TopLevel, schema_version, p);
497
498 let warnings = p.warnings.clone();
499 Contract {
500 schema_version,
501 status,
502 maturity,
503 ecosystems,
504 targets,
505 distributions,
506 versioning,
507 versioning_pattern,
508 changelog,
509 conventional_commits,
510 release: Release {
511 model,
512 layout,
513 bump_hook,
514 },
515 contribution_provenance,
516 provenance_level,
517 dependency_bot,
518 health_badges,
519 license,
520 docs_site,
521 extra_fields,
522 warnings,
523 }
524}
525
526fn parse_bump_hook(m: &Mapping, p: &mut Problems) -> Option<String> {
534 match m.get("bump_hook") {
535 None | Some(Value::Null) => None,
536 Some(v) => match v.as_str() {
537 Some(s) if !s.trim().is_empty() => Some(s.to_string()),
538 Some(_) => {
539 p.err(
540 "release.bump_hook must be a non-empty command string (or omit it for no hook)"
541 .to_string(),
542 );
543 None
544 }
545 None => {
546 p.err("release.bump_hook must be a command string".to_string());
547 None
548 }
549 },
550 }
551}
552
553fn parse_versioning(value: Option<&Value>, p: &mut Problems) -> (VersioningBase, Option<String>) {
554 let Some(v) = value else {
555 return (VersioningBase::Semver, None);
556 };
557 let Some(s) = v.as_str() else {
558 p.err(format!(
559 "versioning {} invalid — must be semver | calver:<pattern> | zerover",
560 yaml_display(v)
561 ));
562 return (VersioningBase::Semver, None);
563 };
564 if let Some(rest) = s.strip_prefix("calver:") {
565 let pattern = rest.trim();
566 if pattern.is_empty() {
567 p.err(
568 "versioning 'calver:' carries no pattern — e.g. calver:YYYY.MM.MICRO".to_string(),
569 );
570 }
571 (VersioningBase::Calver, Some(pattern.to_string()))
572 } else if s == "calver" {
573 p.err(
574 "versioning 'calver' must carry its pattern (calver:YYYY.MM.MICRO), not a bare label"
575 .to_string(),
576 );
577 (VersioningBase::Calver, None)
578 } else if let Some(base) = VersioningBase::parse(s) {
579 (base, None)
580 } else {
581 p.err(format!(
582 "versioning {} invalid — must be semver | calver:<pattern> | zerover",
583 quote_for_diagnostic(s)
584 ));
585 (VersioningBase::Semver, None)
586 }
587}
588
589fn expand_targets(ecosystems: &[Ecosystem], layout: ReleaseLayout) -> Vec<Target> {
591 ecosystems
592 .iter()
593 .map(|&e| Target {
594 ecosystem: e,
595 package: None,
596 registry: e.default_registry(),
597 adapter: e.default_adapter(layout),
598 })
599 .collect()
600}
601
602fn validate_targets(
603 seq: &[Value],
604 ecosystems: &[Ecosystem],
605 layout: ReleaseLayout,
606 p: &mut Problems,
607) -> Vec<Target> {
608 let mut out = Vec::new();
609 for (idx, item) in seq.iter().enumerate() {
610 let Value::Mapping(m) = item else {
611 p.err(format!(
612 "targets[{idx}] must be a map with at least {{ecosystem, registry}}"
613 ));
614 continue;
615 };
616
617 let ecosystem = if let Some(s) = m.get("ecosystem").and_then(Value::as_str) {
618 if let Some(e) = Ecosystem::parse(s) {
619 if !ecosystems.is_empty() && !ecosystems.contains(&e) {
620 p.err(format!(
621 "targets[{idx}].ecosystem {} is not in ecosystems {:?}",
622 quote_for_diagnostic(s),
623 ecosystems.iter().map(|e| e.as_str()).collect::<Vec<_>>()
624 ));
625 }
626 Some(e)
627 } else {
628 p.err(format!(
629 "targets[{idx}].ecosystem {} invalid — one of {:?}",
630 quote_for_diagnostic(s),
631 Ecosystem::VALID
632 ));
633 None
634 }
635 } else {
636 p.err(format!(
637 "targets[{idx}].ecosystem invalid — one of {:?}",
638 Ecosystem::VALID
639 ));
640 None
641 };
642
643 let registry = match m.get("registry").and_then(Value::as_str) {
644 None => {
645 p.err(format!(
646 "targets[{idx}] has no registry (required — the publish destination)"
647 ));
648 None
649 }
650 Some(s) => {
651 if let Some(r) = Registry::parse(s) {
652 Some(r)
653 } else {
654 p.err(format!(
655 "targets[{idx}].registry {} invalid — one of {:?}",
656 quote_for_diagnostic(s),
657 Registry::VALID
658 ));
659 None
660 }
661 }
662 };
663
664 let adapter = match m.get("adapter") {
665 None => ecosystem.map(|e| e.default_adapter(layout)),
666 Some(v) => {
667 if let Some(a) = v.as_str().and_then(Adapter::parse) {
668 Some(a)
669 } else {
670 p.err(format!(
671 "targets[{idx}].adapter {} invalid — one of {:?}",
672 yaml_display(v),
673 Adapter::VALID
674 ));
675 None
676 }
677 }
678 };
679
680 if let (Some(Registry::Homebrew), Some(a)) = (registry, adapter) {
688 if !matches!(a, Adapter::HomebrewTap | Adapter::HomebrewCore) {
689 p.err(format!(
690 "floor: targets[{idx}] has registry 'homebrew' but adapter {} — a \
691 homebrew-registry target requires adapter 'homebrew-tap' (personal tap) \
692 or 'homebrew-core' (central formula)",
693 quote_for_diagnostic(a.as_str())
694 ));
695 }
696 }
697
698 out.push(Target {
701 ecosystem: ecosystem.unwrap_or(Ecosystem::Binary),
702 package: m.get("package").and_then(Value::as_str).map(str::to_string),
703 registry: registry.unwrap_or(Registry::GhReleases),
704 adapter: adapter.unwrap_or(Adapter::Manual),
705 });
706 }
707 out
708}
709
710const KNOWN_DISTRIBUTION_KEYS: &[&str] = &[
721 "package",
722 "adapter",
723 "gh_releases",
724 "installers",
725 "homebrew_tap",
726 "platforms",
727 "extra_fields",
729];
730
731const INSTALLER_ORDER: [Installer; 5] = [
734 Installer::Shell,
735 Installer::Powershell,
736 Installer::Homebrew,
737 Installer::Msi,
738 Installer::Npm,
739];
740
741fn parse_distributions(
751 map: &Mapping,
752 targets: &[Target],
753 schema_version: u32,
754 p: &mut Problems,
755) -> Vec<Distribution> {
756 let single = map.get("distribution");
757 let many = map.get("distributions");
758 let single_present = matches!(single, Some(v) if !v.is_null());
761 let many_present = matches!(many, Some(v) if !v.is_null());
762 if single_present && many_present {
763 p.err(
764 "declare either `distribution` (one block) or `distributions` (a list), not both — \
765 they are the singular and plural spellings of the same field"
766 .to_string(),
767 );
768 }
771
772 let distributions = match (single, many) {
773 (_, Some(Value::Sequence(seq))) => {
776 let mut out = Vec::with_capacity(seq.len());
777 for (idx, item) in seq.iter().enumerate() {
778 match item {
779 Value::Mapping(m) => {
780 out.push(parse_one_distribution(m, schema_version, p));
781 }
782 _ => p.err(format!(
783 "distributions[{idx}] must be a mapping with {{package, adapter, \
784 gh_releases?, installers?, homebrew_tap?, platforms?}}"
785 )),
786 }
787 }
788 out
789 }
790 (_, Some(v)) if !v.is_null() => {
791 p.err(format!(
792 "distributions must be a list of distribution mappings, got {}",
793 yaml_display(v)
794 ));
795 Vec::new()
796 }
797 (Some(Value::Mapping(m)), _) => {
799 vec![parse_one_distribution(m, schema_version, p)]
800 }
801 (Some(v), _) if !v.is_null() => {
802 p.err(
803 "distribution must be a mapping with {adapter?, gh_releases?, installers?, \
804 homebrew_tap?, platforms?} (or use `distributions:` for a list)"
805 .to_string(),
806 );
807 Vec::new()
808 }
809 _ => Vec::new(),
811 };
812
813 if distributions.len() >= 2 {
818 let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
819 for (idx, d) in distributions.iter().enumerate() {
820 match d.package.as_deref() {
821 None => p.err(format!(
822 "floor: distributions[{idx}] has no `package` — with two or more \
823 distributions each must name the package it builds (the monorepo \
824 association key), so they can be told apart"
825 )),
826 Some(pkg) if !seen.insert(pkg) => p.err(format!(
827 "floor: distributions[{idx}].package {} is used by more than one \
828 distribution — each distribution must name a distinct package",
829 quote_for_diagnostic(pkg)
830 )),
831 Some(_) => {}
832 }
833 }
834
835 let target_pkgs: std::collections::BTreeSet<&str> = targets
843 .iter()
844 .filter_map(|t| t.package.as_deref())
845 .collect();
846 if !target_pkgs.is_empty() {
847 for (idx, d) in distributions.iter().enumerate() {
848 if let Some(pkg) = d.package.as_deref() {
849 if !target_pkgs.contains(pkg) {
850 p.warn(format!(
851 "distributions[{idx}].package {} matches no targets[].package \
852 ({target_pkgs:?}) — likely a typo; a distribution should build a \
853 package the contract also lists as a target",
854 quote_for_diagnostic(pkg)
855 ));
856 }
857 }
858 }
859 }
860 }
861
862 distributions
863}
864
865#[allow(clippy::too_many_lines)]
870fn parse_one_distribution(m: &Mapping, schema_version: u32, p: &mut Problems) -> Distribution {
871 let package = match m.get("package") {
875 None | Some(Value::Null) => None,
876 Some(v) => match v.as_str() {
877 Some(s) if !s.trim().is_empty() => Some(s.trim().to_string()),
881 _ => {
882 p.err(
883 "distribution.package must be a non-empty string (the package this \
884 distribution builds)"
885 .to_string(),
886 );
887 None
888 }
889 },
890 };
891
892 let adapter = match m.get("adapter") {
898 None => {
899 p.err(
900 "distribution.adapter is required when a distribution block is present \
901 (cargo-dist|goreleaser|manual) — /oss-init infers it"
902 .to_string(),
903 );
904 DistributionAdapter::CargoDist
905 }
906 Some(v) => {
907 if let Some(a) = v.as_str().and_then(DistributionAdapter::parse) {
908 a
909 } else {
910 p.err(format!(
911 "distribution.adapter {} invalid — must be one of {:?}",
912 yaml_display(v),
913 DistributionAdapter::VALID
914 ));
915 DistributionAdapter::CargoDist
916 }
917 }
918 };
919
920 let gh_releases = match m.get("gh_releases") {
921 None => true,
923 Some(Value::Bool(b)) => *b,
924 Some(v) => {
925 p.err(format!(
926 "distribution.gh_releases must be true|false, got {}",
927 yaml_display(v)
928 ));
929 true
930 }
931 };
932
933 let mut parsed_installers: Vec<Installer> = Vec::new();
935 for item in as_list(m.get("installers")) {
936 match item.as_str().and_then(Installer::parse) {
937 Some(i) => parsed_installers.push(i),
938 None => p.err(format!(
939 "distribution.installers: {} invalid — must be one of {:?}",
940 yaml_display(&item),
941 Installer::VALID
942 )),
943 }
944 }
945 let installers: Vec<Installer> = INSTALLER_ORDER
946 .into_iter()
947 .filter(|i| parsed_installers.contains(i))
948 .collect();
949
950 let homebrew_tap = match m.get("homebrew_tap") {
951 None | Some(Value::Null) => None,
952 Some(v) => match v.as_str() {
953 Some(s) if is_tap_slug(s) => Some(s.to_string()),
954 Some(s) => {
960 p.err(format!(
961 "distribution.homebrew_tap {} invalid — must be an 'owner/repo' slug",
962 quote_for_diagnostic(s)
963 ));
964 None
965 }
966 None => {
967 p.err("distribution.homebrew_tap must be an 'owner/repo' string".to_string());
968 None
969 }
970 },
971 };
972
973 let wants_homebrew = installers.contains(&Installer::Homebrew);
974 if wants_homebrew && homebrew_tap.is_none() {
981 p.err(
982 "floor: distribution.installers includes 'homebrew' but no distribution.homebrew_tap \
983 is set — the generated formula has nowhere to be pushed"
984 .to_string(),
985 );
986 }
987
988 let platforms = match m.get("platforms") {
998 None | Some(Value::Null) => default_cross_platform_targets(),
999 Some(Value::Sequence(seq)) if seq.is_empty() => {
1000 p.err(
1003 "distribution.platforms is an empty list — omit the key to accept the \
1004 cross-platform default (macOS + Linux) or list explicit target-triples; a \
1005 distribution with no platforms builds nothing"
1006 .to_string(),
1007 );
1008 default_cross_platform_targets()
1009 }
1010 Some(Value::Sequence(seq)) => {
1011 let mut out: Vec<String> = Vec::new();
1012 for item in seq {
1013 match item.as_str() {
1014 Some(s) if looks_like_target_triple(s) => {
1015 let triple = s.to_string();
1016 if !out.contains(&triple) {
1017 out.push(triple);
1018 }
1019 }
1020 Some(s) => p.err(format!(
1021 "distribution.platforms: {} is not a well-formed target-triple \
1022 (e.g. x86_64-unknown-linux-musl, aarch64-apple-darwin) — structural \
1023 check only; the toolchain is the final authority on what builds",
1024 quote_for_diagnostic(s)
1025 )),
1026 None => p.err(format!(
1027 "distribution.platforms: {} invalid — each entry must be a \
1028 target-triple string",
1029 yaml_display(item)
1030 )),
1031 }
1032 }
1033 out
1034 }
1035 Some(v) => {
1036 p.err(format!(
1037 "distribution.platforms must be a list of target-triple strings, got {}",
1038 yaml_display(v)
1039 ));
1040 default_cross_platform_targets()
1041 }
1042 };
1043
1044 if p.errors.is_empty() {
1058 let has_windows = platforms.iter().any(|t| is_windows_triple(t));
1059 let has_macos = platforms.iter().any(|t| is_macos_triple(t));
1060 let has_linux = platforms.iter().any(|t| is_linux_triple(t));
1061 for &installer in &installers {
1062 let unmet = match installer_os_need(installer) {
1063 OsNeed::Unchecked => None,
1064 OsNeed::Windows => (!has_windows).then_some(
1065 "distribution.installers includes 'msi' but the resolved \
1066 distribution.platforms set has no Windows (*-windows-*) target — the MSI \
1067 installer has nothing to install",
1068 ),
1069 OsNeed::MacosOrLinux => (!has_macos && !has_linux).then_some(
1074 "distribution.installers includes 'homebrew' but the resolved \
1075 distribution.platforms set has no macOS (*-apple-darwin) or Linux \
1076 (*-linux-*) target — the Homebrew formula has nothing to install",
1077 ),
1078 };
1079 if let Some(msg) = unmet {
1080 p.warn(msg.to_string());
1081 }
1082 }
1083 }
1084
1085 let extra_fields = capture_unknown_fields(
1091 m,
1092 KNOWN_DISTRIBUTION_KEYS,
1093 CaptureScope::Distribution,
1094 schema_version,
1095 p,
1096 );
1097
1098 Distribution {
1099 package,
1100 adapter,
1101 gh_releases,
1102 installers,
1103 homebrew_tap,
1104 platforms,
1105 extra_fields,
1106 }
1107}
1108
1109fn default_cross_platform_targets() -> Vec<String> {
1113 DEFAULT_CROSS_PLATFORM_TARGETS
1114 .iter()
1115 .map(|&s| s.to_string())
1116 .collect()
1117}
1118
1119enum OsNeed {
1124 Unchecked,
1126 Windows,
1128 MacosOrLinux,
1130}
1131
1132fn installer_os_need(i: Installer) -> OsNeed {
1145 match i {
1146 Installer::Msi => OsNeed::Windows,
1147 Installer::Homebrew => OsNeed::MacosOrLinux,
1148 Installer::Shell | Installer::Powershell | Installer::Npm => OsNeed::Unchecked,
1149 }
1150}
1151
1152fn triple_os(s: &str) -> Option<&str> {
1160 s.split('-').nth(2)
1161}
1162
1163fn is_windows_triple(s: &str) -> bool {
1166 triple_os(s) == Some("windows")
1167}
1168
1169fn is_macos_triple(s: &str) -> bool {
1173 triple_os(s) == Some("darwin")
1174}
1175
1176fn is_linux_triple(s: &str) -> bool {
1181 triple_os(s) == Some("linux")
1182}
1183
1184fn looks_like_target_triple(s: &str) -> bool {
1195 let parts: Vec<&str> = s.split('-').collect();
1196 (2..=4).contains(&parts.len())
1197 && parts.iter().all(|part| {
1198 !part.is_empty()
1199 && part.bytes().all(|b| {
1200 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.')
1201 })
1202 })
1203}
1204
1205fn is_tap_slug(s: &str) -> bool {
1212 fn valid_part(part: &str) -> bool {
1213 !part.is_empty()
1214 && part != "."
1215 && part != ".."
1216 && part
1217 .bytes()
1218 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
1219 }
1220 match s.split_once('/') {
1221 Some((owner, repo)) => valid_part(owner) && valid_part(repo) && !repo.contains('/'),
1222 None => false,
1223 }
1224}
1225
1226fn default_health_badges(maturity: Maturity, targets: &[Target]) -> Vec<HealthBadge> {
1229 let mut badges = Vec::new();
1230 if matches!(maturity, Maturity::Mvp | Maturity::Production) {
1231 badges.push(HealthBadge::Ci);
1232 }
1233 if !targets.is_empty() {
1234 badges.push(HealthBadge::Registry);
1235 }
1236 badges.push(HealthBadge::License);
1237 badges
1238}
1239
1240fn check_badge_producers(
1242 badges: &[HealthBadge],
1243 maturity: Maturity,
1244 targets: &[Target],
1245 p: &mut Problems,
1246) {
1247 let has_registry_target = !targets.is_empty();
1248 for b in badges {
1249 match b {
1250 HealthBadge::Ci if maturity == Maturity::Spike => p.err(
1251 "floor: health_badge 'ci' has no producer at maturity 'spike' (no CI until mvp) — \
1252 drop it or raise maturity"
1253 .to_string(),
1254 ),
1255 HealthBadge::Registry if !has_registry_target => p.err(
1256 "floor: health_badge 'registry' has no producer — no target has a registry to \
1257 publish to"
1258 .to_string(),
1259 ),
1260 HealthBadge::Coverage if maturity != Maturity::Production => p.err(format!(
1261 "floor: health_badge 'coverage' has no producer — the coverage gate is a \
1262 production-tier /oss-ci output; current maturity is '{}'",
1263 maturity.as_str()
1264 )),
1265 HealthBadge::Scorecard if maturity != Maturity::Production => p.err(format!(
1266 "floor: health_badge 'scorecard' has no producer — the Scorecard action is a \
1267 production-tier output; current maturity is '{}'",
1268 maturity.as_str()
1269 )),
1270 _ => {}
1271 }
1272 }
1273}
1274
1275fn check_homebrew_configuration(
1308 targets: &[Target],
1309 distributions: &[Distribution],
1310 p: &mut Problems,
1311) {
1312 let has_tap = distributions.iter().any(|d| d.homebrew_tap.is_some());
1313 let installer_producer = distributions
1314 .iter()
1315 .any(|d| d.installers.contains(&Installer::Homebrew));
1316 let tap_target_producer = targets
1321 .iter()
1322 .any(|t| t.registry == Registry::Homebrew && t.adapter == Adapter::HomebrewTap);
1323
1324 if tap_target_producer && !has_tap {
1326 p.err(
1327 "floor: a 'homebrew'-registry target with adapter 'homebrew-tap' generates a formula \
1328 but no distribution sets homebrew_tap — the formula has nowhere to be pushed (set \
1329 distribution.homebrew_tap to the 'owner/repo' tap)"
1330 .to_string(),
1331 );
1332 }
1333
1334 if installer_producer && tap_target_producer {
1337 p.err(
1338 "floor: both a 'homebrew' installer (distribution.installers) and a 'homebrew'-registry \
1339 target with adapter 'homebrew-tap' generate + push a formula to the tap — they would \
1340 collide; keep exactly one homebrew formula producer, not both"
1341 .to_string(),
1342 );
1343 }
1344
1345 if has_tap && !installer_producer && !tap_target_producer {
1347 p.warn(
1348 "distribution.homebrew_tap is set but there is neither a 'homebrew' installer in \
1349 distribution.installers nor a 'homebrew'-registry target with adapter 'homebrew-tap' \
1350 — no formula is generated, so the tap will never be updated"
1351 .to_string(),
1352 );
1353 }
1354}
1355
1356fn is_fence(line: &str) -> bool {
1360 let t = line.trim_end();
1361 t == "---" || (t.starts_with("---") && t[3..].chars().all(char::is_whitespace))
1362}
1363
1364fn split_frontmatter(text: &str, p: &mut Problems) -> Option<String> {
1368 let mut lines = text.lines();
1369 match lines.next() {
1370 Some(first) if is_fence(first) => {}
1371 _ => {
1372 p.err("frontmatter missing: file must begin with a '---' YAML block".to_string());
1373 return None;
1374 }
1375 }
1376 let mut fm = String::new();
1377 for line in lines {
1378 if is_fence(line) {
1379 return Some(fm);
1380 }
1381 fm.push_str(line);
1382 fm.push('\n');
1383 }
1384 p.err("frontmatter not closed: no terminating '---' line found".to_string());
1385 None
1386}
1387
1388fn parse_frontmatter(fm: &str, p: &mut Problems) -> Mapping {
1391 if fm.trim().is_empty() {
1392 return Mapping::new();
1393 }
1394 match serde_yaml::from_str::<Value>(fm) {
1395 Ok(Value::Null) => Mapping::new(),
1396 Ok(Value::Mapping(m)) => m,
1397 Ok(_) => {
1398 p.err("frontmatter: top level must be a mapping".to_string());
1399 Mapping::new()
1400 }
1401 Err(e) => {
1402 p.err(format!("frontmatter: invalid YAML — {e}"));
1403 Mapping::new()
1404 }
1405 }
1406}
1407
1408fn as_list(v: Option<&Value>) -> Vec<Value> {
1413 match v {
1414 None | Some(Value::Null) => Vec::new(),
1415 Some(Value::Sequence(seq)) => seq.clone(),
1416 Some(other) => vec![other.clone()],
1417 }
1418}
1419
1420fn yaml_display(v: &Value) -> String {
1422 match v {
1423 Value::String(s) => quote_for_diagnostic(s),
1424 Value::Bool(b) => b.to_string(),
1425 Value::Number(n) => n.to_string(),
1426 Value::Null => "null".to_string(),
1427 Value::Sequence(_) => "<list>".to_string(),
1428 Value::Mapping(_) => "<map>".to_string(),
1429 Value::Tagged(t) => yaml_display(&t.value),
1430 }
1431}
1432
1433fn quote_for_diagnostic(s: &str) -> String {
1445 serde_json::Value::String(s.to_owned()).to_string()
1446}
1447
1448fn path_inside_repo(rel: &str) -> bool {
1459 let mut depth: usize = 0;
1460 for comp in Path::new(rel).components() {
1461 match comp {
1462 Component::CurDir => {}
1463 Component::Normal(_) => depth += 1,
1464 Component::ParentDir => {
1465 if depth == 0 {
1467 return false;
1468 }
1469 depth -= 1;
1470 }
1471 Component::RootDir | Component::Prefix(_) => return false,
1474 }
1475 }
1476 true
1477}
1478
1479#[derive(Clone, Copy)]
1484enum CaptureScope {
1485 TopLevel,
1487 Distribution,
1489}
1490
1491impl CaptureScope {
1492 fn label(self) -> &'static str {
1496 match self {
1497 Self::TopLevel => "",
1498 Self::Distribution => "distribution ",
1499 }
1500 }
1501}
1502
1503fn capture_unknown_fields(
1530 m: &Mapping,
1531 known: &[&str],
1532 scope: CaptureScope,
1533 schema_version: u32,
1534 p: &mut Problems,
1535) -> serde_json::Map<String, serde_json::Value> {
1536 let label = scope.label();
1537 let mut extra_fields = serde_json::Map::new();
1538 if let Some(v) = m.get("extra_fields") {
1542 merge_reserved_extra_fields(v, scope, &mut extra_fields, p);
1543 }
1544 for (k, v) in m {
1545 match k {
1546 Value::String(key) => {
1547 if known.contains(&key.as_str()) {
1548 continue;
1549 }
1550 if extra_fields.contains_key(key) {
1551 p.err(format!(
1552 "{label}field '{key}' appears both as an unknown top-level key and inside \
1553 the reserved '{label}extra_fields' block — refusing to drop either value; \
1554 remove one"
1555 ));
1556 } else {
1557 extra_fields.insert(key.clone(), yaml_to_json(v));
1558 }
1559 }
1560 other => p.err(format!(
1561 "{label}field key {} must be a string — a non-string key is not a \
1562 forward-compatible schema shape and cannot be preserved losslessly (distinct \
1563 non-string keys collapse onto the same JSON key)",
1564 yaml_display(other)
1565 )),
1566 }
1567 }
1568 if !extra_fields.is_empty() {
1569 let keys = extra_fields
1574 .keys()
1575 .map(|k| quote_for_diagnostic(k))
1576 .collect::<Vec<_>>()
1577 .join(", ");
1578 p.warn(format!(
1579 "unknown {label}field(s) preserved under schema_version {schema_version} \
1580 (forward-compat): [{keys}]"
1581 ));
1582 }
1583 extra_fields
1584}
1585
1586fn merge_reserved_extra_fields(
1594 v: &Value,
1595 scope: CaptureScope,
1596 out: &mut serde_json::Map<String, serde_json::Value>,
1597 p: &mut Problems,
1598) {
1599 let label = scope.label();
1600 match v {
1601 Value::Null => {}
1602 Value::Mapping(inner) => {
1603 for (k, val) in inner {
1604 match k {
1605 Value::String(key) => {
1606 out.insert(key.clone(), yaml_to_json(val));
1607 }
1608 other => p.err(format!(
1609 "reserved '{label}extra_fields' block has a non-string key {} — its keys \
1610 must be strings",
1611 yaml_display(other)
1612 )),
1613 }
1614 }
1615 }
1616 other => p.err(format!(
1617 "reserved '{label}extra_fields' must be a mapping when present, got {}",
1618 yaml_display(other)
1619 )),
1620 }
1621}
1622
1623fn yaml_to_json(v: &Value) -> serde_json::Value {
1625 use serde_json::Value as J;
1626 match v {
1627 Value::Null => J::Null,
1628 Value::Bool(b) => J::Bool(*b),
1629 Value::Number(n) => {
1630 if let Some(i) = n.as_i64() {
1631 J::from(i)
1632 } else if let Some(u) = n.as_u64() {
1633 J::from(u)
1634 } else if let Some(f) = n.as_f64() {
1635 serde_json::Number::from_f64(f).map_or(J::Null, J::Number)
1636 } else {
1637 J::Null
1638 }
1639 }
1640 Value::String(s) => J::String(s.clone()),
1641 Value::Sequence(seq) => J::Array(seq.iter().map(yaml_to_json).collect()),
1642 Value::Mapping(m) => {
1643 let mut obj = serde_json::Map::new();
1644 for (k, val) in m {
1645 let key = match k {
1646 Value::String(s) => s.clone(),
1647 other => yaml_display(other),
1648 };
1649 obj.insert(key, yaml_to_json(val));
1650 }
1651 J::Object(obj)
1652 }
1653 Value::Tagged(t) => yaml_to_json(&t.value),
1654 }
1655}
1656
1657#[cfg(test)]
1658mod tests {
1659 use super::*;
1660 use std::collections::HashSet;
1661
1662 struct FakeFs {
1665 dirs: HashSet<PathBuf>,
1666 }
1667
1668 impl FakeFs {
1669 fn empty() -> Self {
1670 Self {
1671 dirs: HashSet::new(),
1672 }
1673 }
1674
1675 fn with_dirs<const N: usize>(dirs: [&str; N]) -> Self {
1676 Self {
1677 dirs: dirs.iter().map(PathBuf::from).collect(),
1678 }
1679 }
1680 }
1681
1682 impl Fs for FakeFs {
1683 fn read(&self, _path: &Path) -> io::Result<Vec<u8>> {
1684 Err(io::Error::from(io::ErrorKind::NotFound))
1685 }
1686 fn exists(&self, path: &Path) -> bool {
1687 self.dirs.contains(path)
1688 }
1689 fn is_dir(&self, path: &Path) -> bool {
1690 self.dirs.contains(path)
1691 }
1692 fn is_file(&self, _path: &Path) -> bool {
1693 false
1695 }
1696 fn read_dir(&self, _dir: &Path) -> io::Result<Vec<String>> {
1697 Ok(Vec::new())
1699 }
1700 }
1701
1702 fn repo() -> &'static Path {
1703 Path::new("/repo")
1704 }
1705
1706 fn norm(text: &str) -> Normalized {
1707 normalize_str(text, repo(), &FakeFs::empty())
1708 }
1709
1710 fn norm_with(text: &str, fs: &dyn Fs) -> Normalized {
1711 normalize_str(text, repo(), fs)
1712 }
1713
1714 fn assert_error_contains(n: &Normalized, needle: &str) {
1715 assert!(
1716 !n.is_valid(),
1717 "expected invalid, got clean normalize: {:?}",
1718 n.contract
1719 );
1720 assert!(
1721 n.problems.errors.iter().any(|e| e.contains(needle)),
1722 "no error contained {needle:?}; errors were {:?}",
1723 n.problems.errors
1724 );
1725 }
1726
1727 const MINIMAL: &str = "---\nstatus: approved\nmaturity: mvp\n---\n";
1728
1729 #[test]
1730 fn materializes_all_defaults() {
1731 let c = norm(MINIMAL).contract;
1732 assert_eq!(c.schema_version, 2);
1735 assert_eq!(c.status, Status::Approved);
1736 assert_eq!(c.maturity, Maturity::Mvp);
1737 assert!(c.ecosystems.is_empty());
1738 assert!(c.targets.is_empty());
1739 assert_eq!(c.versioning, VersioningBase::Semver);
1740 assert_eq!(c.versioning_pattern, None);
1741 assert_eq!(c.changelog.mode, ChangelogMode::Curated);
1742 assert_eq!(c.changelog.source, ChangelogSource::Manual);
1743 assert_eq!(c.changelog.fragment_dir, DEFAULT_FRAGMENT_DIR);
1744 assert!(!c.conventional_commits);
1745 assert_eq!(c.release.model, ReleaseModel::Gated);
1746 assert_eq!(c.release.layout, ReleaseLayout::Single);
1747 assert_eq!(c.release.bump_hook, None); assert_eq!(c.contribution_provenance, ContributionProvenance::None);
1749 assert_eq!(c.provenance_level, ProvenanceLevel::None);
1750 assert_eq!(c.dependency_bot, DependencyBot::Dependabot); assert_eq!(c.license, "MIT");
1752 assert_eq!(c.docs_site, DocsSite::None);
1753 assert_eq!(c.health_badges, vec![HealthBadge::Ci, HealthBadge::License]);
1755 assert!(c.extra_fields.is_empty());
1756 }
1757
1758 #[test]
1759 fn parses_a_declared_bump_hook() {
1760 let c = norm(
1763 "---\nstatus: approved\nmaturity: mvp\n\
1764 release:\n model: gated\n bump_hook: \"cargo insta test --accept\"\n---\n",
1765 )
1766 .contract;
1767 assert_eq!(
1768 c.release.bump_hook.as_deref(),
1769 Some("cargo insta test --accept")
1770 );
1771 let json = serde_json::to_value(&c).unwrap();
1773 assert_eq!(
1774 json["release"]["bump_hook"],
1775 serde_json::json!("cargo insta test --accept")
1776 );
1777 }
1778
1779 #[test]
1780 fn an_absent_bump_hook_is_omitted_from_canonical_json() {
1781 let c = norm(MINIMAL).contract;
1784 let json = serde_json::to_value(&c).unwrap();
1785 assert!(
1786 json["release"].get("bump_hook").is_none(),
1787 "an absent hook must not appear in canonical JSON, got {:?}",
1788 json["release"]
1789 );
1790 }
1791
1792 #[test]
1793 fn an_empty_bump_hook_is_rejected() {
1794 assert_error_contains(
1797 &norm(
1798 "---\nstatus: approved\nmaturity: mvp\n\
1799 release:\n model: gated\n bump_hook: \" \"\n---\n",
1800 ),
1801 "release.bump_hook must be a non-empty",
1802 );
1803 }
1804
1805 #[test]
1806 fn a_non_string_bump_hook_is_rejected() {
1807 assert_error_contains(
1808 &norm(
1809 "---\nstatus: approved\nmaturity: mvp\n\
1810 release:\n model: gated\n bump_hook: [not, a, string]\n---\n",
1811 ),
1812 "release.bump_hook must be a command string",
1813 );
1814 }
1815
1816 #[test]
1817 fn spike_defaults_no_bot_no_ci_badge() {
1818 let c = norm("---\nstatus: approved\nmaturity: spike\n---\n").contract;
1819 assert_eq!(c.dependency_bot, DependencyBot::None);
1820 assert_eq!(c.health_badges, vec![HealthBadge::License]);
1821 }
1822
1823 #[test]
1824 fn maturity_is_required() {
1825 assert_error_contains(
1826 &norm("---\nstatus: approved\n---\n"),
1827 "maturity is required",
1828 );
1829 }
1830
1831 #[test]
1832 fn expands_targets_from_ecosystems() {
1833 let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [python]\n---\n").contract;
1834 assert_eq!(c.targets.len(), 1);
1835 assert_eq!(c.targets[0].ecosystem, Ecosystem::Python);
1836 assert_eq!(c.targets[0].package, None);
1837 assert_eq!(c.targets[0].registry, Registry::Pypi);
1838 assert_eq!(c.targets[0].adapter, Adapter::GhActionPypiPublish);
1839 }
1840
1841 #[test]
1847 fn explicit_empty_targets_is_honored_not_expanded() {
1848 let n =
1849 norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n---\n");
1850 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1851 let c = n.contract;
1852 assert_eq!(c.ecosystems, vec![Ecosystem::Rust]);
1854 assert!(
1856 c.targets.is_empty(),
1857 "explicit targets:[] must stay empty, got {:?}",
1858 c.targets
1859 );
1860 }
1861
1862 #[test]
1865 fn omitted_targets_still_expands_to_ecosystem_default() {
1866 let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract;
1867 assert_eq!(c.targets.len(), 1);
1868 assert_eq!(c.targets[0].ecosystem, Ecosystem::Rust);
1869 assert_eq!(c.targets[0].registry, Registry::CratesIo);
1870 assert_eq!(c.targets[0].adapter, Adapter::CargoPublish);
1871 }
1872
1873 #[test]
1877 fn null_targets_expands_like_omitted() {
1878 let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n---\n")
1879 .contract;
1880 assert_eq!(c.targets.len(), 1);
1881 assert_eq!(c.targets[0].registry, Registry::CratesIo);
1882 }
1883
1884 #[test]
1890 fn empty_targets_round_trips_through_canonical_json() {
1891 let n =
1892 norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n---\n");
1893 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1894 let json = serde_json::to_value(&n.contract).unwrap();
1895 assert_eq!(json["targets"], serde_json::json!([]));
1897
1898 let targets_yaml = serde_yaml::to_string(&json["targets"]).unwrap();
1901 let refed = format!(
1902 "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: {}\n---\n",
1903 targets_yaml.trim()
1904 );
1905 let n2 = norm(&refed);
1906 assert!(n2.is_valid(), "errors: {:?}", n2.problems.errors);
1907 assert_eq!(n2.contract.targets, n.contract.targets);
1908 assert!(n2.contract.targets.is_empty());
1909 }
1910
1911 #[test]
1916 fn explicit_empty_targets_skips_registry_license_floor() {
1917 let n = norm(
1918 "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n\
1919 license: not-a-real-spdx-id\n---\n",
1920 );
1921 assert!(!n.is_valid());
1923 assert!(
1926 !n.problems
1927 .errors
1928 .iter()
1929 .any(|e| e.contains("floor: a target has a registry")),
1930 "registry-license floor fired despite empty targets: {:?}",
1931 n.problems.errors
1932 );
1933 }
1934
1935 #[test]
1940 fn registry_badge_with_explicit_empty_targets_fails() {
1941 let n = norm(
1942 "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n\
1943 health_badges: [registry, license]\n---\n",
1944 );
1945 assert_error_contains(&n, "health_badge 'registry' has no producer");
1946 }
1947
1948 #[test]
1953 fn explicit_empty_targets_with_no_ecosystems() {
1954 let n = norm("---\nstatus: approved\nmaturity: mvp\ntargets: []\n---\n");
1955 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1956 assert!(n.contract.targets.is_empty());
1957 assert_eq!(
1958 n.contract.health_badges,
1959 vec![HealthBadge::Ci, HealthBadge::License]
1960 );
1961 }
1962
1963 #[test]
1964 fn node_monorepo_adapter_is_changesets() {
1965 let text = "---\nstatus: approved\nmaturity: production\necosystems: [node]\n\
1966 release:\n model: gated\n layout: monorepo\n---\n";
1967 let c = norm(text).contract;
1968 assert_eq!(c.targets[0].adapter, Adapter::Changesets);
1969 }
1970
1971 #[test]
1972 fn ecosystems_dedup_to_canonical_order() {
1973 let c =
1974 norm("---\nstatus: approved\nmaturity: mvp\necosystems: [python, rust, python]\n---\n")
1975 .contract;
1976 assert_eq!(c.ecosystems, vec![Ecosystem::Rust, Ecosystem::Python]);
1977 }
1978
1979 #[test]
1980 fn calver_splits_base_and_pattern() {
1981 let c = norm(
1982 "---\nstatus: approved\nmaturity: mvp\nversioning: \"calver:YYYY.MM.MICRO\"\n---\n",
1983 )
1984 .contract;
1985 assert_eq!(c.versioning, VersioningBase::Calver);
1986 assert_eq!(c.versioning_pattern.as_deref(), Some("YYYY.MM.MICRO"));
1987 }
1988
1989 #[test]
1990 fn bare_calver_is_rejected() {
1991 assert_error_contains(
1992 &norm("---\nstatus: approved\nmaturity: mvp\nversioning: calver\n---\n"),
1993 "must carry its pattern",
1994 );
1995 }
1996
1997 #[test]
1998 fn floor_auto_on_spike() {
1999 let text = "---\nstatus: approved\nmaturity: spike\n\
2000 release:\n model: auto\n layout: single\nhealth_badges: [license]\n---\n";
2001 assert_error_contains(&norm(text), "release.model 'auto' is not allowed");
2002 }
2003
2004 #[test]
2005 fn floor_slsa_l3_production_only() {
2006 assert_error_contains(
2007 &norm("---\nstatus: approved\nmaturity: mvp\nprovenance_level: slsa-l3\n---\n"),
2008 "slsa-l3' is production-only",
2009 );
2010 }
2011
2012 #[test]
2013 fn floor_registry_requires_valid_license() {
2014 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
2015 license: Proprietary-Acme\nhealth_badges: [ci, registry, license]\n---\n";
2016 let n = norm(text);
2017 assert_error_contains(&n, "not a valid SPDX expression");
2019 assert!(n
2020 .problems
2021 .errors
2022 .iter()
2023 .any(|e| e.contains("floor: a target has a registry")));
2024 }
2025
2026 #[test]
2027 fn floor_badge_without_producer() {
2028 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [python]\n\
2029 health_badges: [ci, coverage]\n---\n";
2030 assert_error_contains(&norm(text), "health_badge 'coverage' has no producer");
2031 }
2032
2033 #[test]
2034 fn floor_schema_version_too_new() {
2035 assert_error_contains(
2036 &norm("---\nschema_version: 99\nstatus: approved\nmaturity: mvp\n---\n"),
2037 "exceeds what this tool knows",
2038 );
2039 }
2040
2041 #[test]
2042 fn floor_fragment_dir_escape() {
2043 let text = "---\nstatus: approved\nmaturity: mvp\n\
2044 changelog:\n mode: fragment\n source: manual\n fragment_dir: /etc\n---\n";
2045 assert_error_contains(&norm(text), "must be a relative path inside the repo");
2046 }
2047
2048 #[test]
2049 fn floor_fragment_dir_escape_relative_root() {
2050 let text = "---\nstatus: approved\nmaturity: mvp\n\
2055 changelog:\n mode: fragment\n source: manual\n fragment_dir: ../etc\n---\n";
2056 let n = normalize_str(text, Path::new("."), &FakeFs::empty());
2057 assert_error_contains(&n, "must be a relative path inside the repo");
2058 }
2059
2060 #[test]
2061 fn path_inside_repo_verdicts() {
2062 assert!(path_inside_repo("changelog/fragments"));
2064 assert!(path_inside_repo("./changelog/fragments"));
2065 assert!(path_inside_repo("a/../fragments"));
2066 assert!(path_inside_repo("")); assert!(!path_inside_repo("/etc"));
2069 assert!(!path_inside_repo("../etc"));
2070 assert!(!path_inside_repo("a/../../etc"));
2071 }
2072
2073 #[test]
2074 fn unknown_fields_preserved_and_warned() {
2075 let text =
2076 "---\nstatus: approved\nmaturity: mvp\nroadmap_url: https://example.com/x\n---\n";
2077 let n = norm(text);
2078 assert!(n.is_valid());
2079 assert_eq!(
2080 n.contract
2081 .extra_fields
2082 .get("roadmap_url")
2083 .and_then(|v| v.as_str()),
2084 Some("https://example.com/x")
2085 );
2086 assert!(n
2087 .problems
2088 .warnings
2089 .iter()
2090 .any(|w| w.contains("roadmap_url") && w.contains("forward-compat")));
2091 }
2092
2093 #[test]
2094 fn duplicate_key_is_rejected() {
2095 assert_error_contains(
2096 &norm("---\nstatus: approved\nstatus: draft\nmaturity: mvp\n---\n"),
2097 "invalid YAML",
2098 );
2099 }
2100
2101 #[test]
2102 fn missing_frontmatter_is_rejected() {
2103 assert_error_contains(&norm("no frontmatter here\n"), "frontmatter missing");
2104 }
2105
2106 #[test]
2107 fn unclosed_frontmatter_is_rejected() {
2108 assert_error_contains(&norm("---\nstatus: approved\n"), "frontmatter not closed");
2109 }
2110
2111 #[test]
2112 fn invalid_enum_records_error_and_continues() {
2113 let n = norm("---\nstatus: bogus\nmaturity: alsobogus\n---\n");
2115 assert!(n.problems.errors.iter().any(|e| e.contains("status")));
2116 assert!(n.problems.errors.iter().any(|e| e.contains("maturity")));
2117 }
2118
2119 #[test]
2120 fn fragment_dir_present_suppresses_advisory() {
2121 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
2122 changelog:\n mode: fragment\n source: manual\n---\n";
2123 let fs = FakeFs::with_dirs(["/repo/changelog/fragments"]);
2125 let n = norm_with(text, &fs);
2126 assert!(n.is_valid());
2127 assert!(
2128 !n.problems
2129 .warnings
2130 .iter()
2131 .any(|w| w.contains("does not exist yet")),
2132 "advisory should be suppressed when the dir exists: {:?}",
2133 n.problems.warnings
2134 );
2135 }
2136
2137 #[test]
2138 fn serializes_to_schema_v4_shape() {
2139 let json =
2140 serde_json::to_value(&norm("---\nstatus: approved\nmaturity: mvp\n---\n").contract)
2141 .unwrap();
2142 for key in [
2144 "schema_version",
2145 "status",
2146 "maturity",
2147 "ecosystems",
2148 "targets",
2149 "distributions",
2150 "versioning",
2151 "versioning_pattern",
2152 "changelog",
2153 "conventional_commits",
2154 "release",
2155 "contribution_provenance",
2156 "provenance_level",
2157 "dependency_bot",
2158 "health_badges",
2159 "license",
2160 "docs_site",
2161 "warnings",
2162 ] {
2163 assert!(json.get(key).is_some(), "missing §4 key {key}");
2164 }
2165 assert!(json["versioning_pattern"].is_null());
2166 assert_eq!(json["distributions"], serde_json::json!([]));
2169 assert!(
2174 json.get("extra_fields").is_none(),
2175 "empty extra_fields must be absent, got {:?}",
2176 json.get("extra_fields")
2177 );
2178 }
2179
2180 #[test]
2185 fn empty_extra_fields_absent_populated_present() {
2186 let empty = serde_json::to_value(
2188 norm(
2189 "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2190 distribution:\n adapter: cargo-dist\n---\n",
2191 )
2192 .contract,
2193 )
2194 .unwrap();
2195 assert!(
2196 empty.get("extra_fields").is_none(),
2197 "empty top-level extra_fields must be absent"
2198 );
2199 assert!(
2200 empty["distributions"][0].get("extra_fields").is_none(),
2201 "empty nested extra_fields must be absent"
2202 );
2203
2204 let populated = serde_json::to_value(
2207 norm(
2208 "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2209 roadmap_url: https://example.com/roadmap\n\
2210 distribution:\n adapter: cargo-dist\n future_x: 1\n---\n",
2211 )
2212 .contract,
2213 )
2214 .unwrap();
2215 assert_eq!(
2216 populated["extra_fields"]["roadmap_url"],
2217 "https://example.com/roadmap"
2218 );
2219 assert_eq!(populated["distributions"][0]["extra_fields"]["future_x"], 1);
2220 }
2221
2222 #[test]
2227 fn registry_only_contract_has_no_distribution() {
2228 let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract;
2229 assert!(c.distributions.is_empty());
2230 assert_eq!(c.targets.len(), 1);
2231 assert_eq!(c.targets[0].registry, Registry::CratesIo);
2232 }
2233
2234 #[test]
2237 fn cargo_dist_distribution_coexists_with_registry() {
2238 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2239 targets:\n - {ecosystem: rust, package: issuectl, registry: crates.io, adapter: cargo-publish}\n\
2240 distribution:\n adapter: cargo-dist\n installers: [shell, homebrew]\n \
2241 homebrew_tap: jarimustonen/homebrew-issuectl\n---\n";
2242 let n = norm(text);
2243 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2244 let c = n.contract;
2245 assert_eq!(c.targets.len(), 1);
2247 assert_eq!(c.targets[0].registry, Registry::CratesIo);
2248 assert_eq!(c.targets[0].adapter, Adapter::CargoPublish);
2249 let d = c
2251 .distributions
2252 .into_iter()
2253 .next()
2254 .expect("distribution present");
2255 assert_eq!(d.adapter, DistributionAdapter::CargoDist);
2256 assert!(d.gh_releases); assert_eq!(d.installers, vec![Installer::Shell, Installer::Homebrew]);
2258 assert_eq!(
2259 d.homebrew_tap.as_deref(),
2260 Some("jarimustonen/homebrew-issuectl")
2261 );
2262 }
2263
2264 #[test]
2266 fn distribution_json_round_trip_shape() {
2267 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2268 distribution:\n adapter: cargo-dist\n gh_releases: true\n \
2269 installers: [shell, homebrew]\n homebrew_tap: jarimustonen/homebrew-issuectl\n---\n";
2270 let json = serde_json::to_value(&norm(text).contract).unwrap();
2271 let d = &json["distributions"][0];
2272 assert_eq!(d["adapter"], "cargo-dist");
2273 assert_eq!(d["gh_releases"], true);
2274 assert_eq!(d["installers"], serde_json::json!(["shell", "homebrew"]));
2275 assert_eq!(d["homebrew_tap"], "jarimustonen/homebrew-issuectl");
2276 assert!(d["package"].is_null());
2278 }
2279
2280 fn hb_case(tap: bool, installer: bool, tap_target: bool) -> String {
2288 let mut fm = String::from(
2289 "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
2290 - {ecosystem: rust, package: ossctl, registry: crates.io, adapter: cargo-publish}\n",
2291 );
2292 if tap_target {
2293 fm.push_str(
2294 " - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: homebrew-tap}\n",
2295 );
2296 }
2297 if installer || tap {
2300 fm.push_str("distribution:\n adapter: cargo-dist\n");
2301 if installer {
2302 fm.push_str(" installers: [homebrew]\n");
2303 } else {
2304 fm.push_str(" installers: [shell]\n");
2305 }
2306 if tap {
2307 fm.push_str(" homebrew_tap: owner/tap\n");
2308 }
2309 }
2310 fm.push_str("---\n");
2311 fm
2312 }
2313
2314 #[test]
2318 fn homebrew_truth_table_all_eight_rows() {
2319 let rows = [
2321 (false, false, false, true), (false, false, true, false), (false, true, false, false), (false, true, true, false), (true, false, false, true), (true, false, true, true), (true, true, false, true), (true, true, true, false), ];
2330 for (tap, installer, tap_target, expect_valid) in rows {
2331 let n = norm(&hb_case(tap, installer, tap_target));
2332 assert_eq!(
2333 n.is_valid(),
2334 expect_valid,
2335 "row (tap={tap}, installer={installer}, tap_target={tap_target}) expected \
2336 valid={expect_valid}; errors were {:?}",
2337 n.problems.errors
2338 );
2339 }
2340 }
2341
2342 #[test]
2345 fn homebrew_tap_target_without_tap_is_a_floor() {
2346 assert_error_contains(
2347 &norm(&hb_case(false, false, true)),
2348 "generates a formula but no distribution sets homebrew_tap",
2349 );
2350 }
2351
2352 #[test]
2355 fn homebrew_double_publish_is_a_floor() {
2356 assert_error_contains(
2357 &norm(&hb_case(true, true, true)),
2358 "they would collide; keep exactly one homebrew formula producer",
2359 );
2360 }
2361
2362 #[test]
2365 fn homebrew_dead_tap_is_an_advisory_not_a_floor() {
2366 let n = norm(&hb_case(true, false, false));
2367 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2368 assert!(
2369 n.problems
2370 .warnings
2371 .iter()
2372 .any(|w| w.contains("the tap will never be updated")),
2373 "expected dead-tap advisory, warnings were {:?}",
2374 n.problems.warnings
2375 );
2376 }
2377
2378 #[test]
2381 fn homebrew_tap_target_with_tap_is_clean() {
2382 let n = norm(&hb_case(true, false, true));
2383 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2384 assert!(
2385 !n.problems
2386 .warnings
2387 .iter()
2388 .any(|w| w.contains("the tap will never be updated")),
2389 "unexpected dead-tap advisory: {:?}",
2390 n.problems.warnings
2391 );
2392 }
2393
2394 #[test]
2398 fn homebrew_registry_requires_homebrew_adapter() {
2399 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
2400 - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: manual}\n\
2401 distribution:\n adapter: cargo-dist\n homebrew_tap: owner/tap\n---\n";
2402 assert_error_contains(
2403 &norm(text),
2404 "requires adapter 'homebrew-tap' (personal tap) or 'homebrew-core'",
2405 );
2406 }
2407
2408 #[test]
2412 fn homebrew_core_target_needs_no_tap() {
2413 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
2414 - {ecosystem: rust, package: ossctl, registry: crates.io, adapter: cargo-publish}\n \
2415 - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: homebrew-core}\n---\n";
2416 let n = norm(text);
2417 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2418 }
2419
2420 #[test]
2428 fn homebrew_registry_omitted_adapter_is_a_floor() {
2429 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
2430 - {ecosystem: rust, package: ossctl, registry: homebrew}\n---\n";
2431 assert_error_contains(
2432 &norm(text),
2433 "requires adapter 'homebrew-tap' (personal tap) or 'homebrew-core'",
2434 );
2435 }
2436
2437 #[test]
2441 fn homebrew_tap_target_satisfied_via_plural_distributions() {
2442 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
2443 - {ecosystem: rust, package: ossctl, registry: crates.io, adapter: cargo-publish}\n \
2444 - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: homebrew-tap}\n\
2445 distributions:\n \
2446 - {package: ossctl, adapter: cargo-dist, installers: [shell], homebrew_tap: owner/tap}\n---\n";
2447 let n = norm(text);
2448 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2449 assert!(
2450 !n.problems
2451 .warnings
2452 .iter()
2453 .any(|w| w.contains("the tap will never be updated")),
2454 "unexpected dead-tap advisory: {:?}",
2455 n.problems.warnings
2456 );
2457 }
2458
2459 #[test]
2465 fn singular_distribution_parses_as_one_element_list() {
2466 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2467 distribution:\n adapter: cargo-dist\n---\n";
2468 let c = norm(text).contract;
2469 assert_eq!(c.distributions.len(), 1);
2470 assert_eq!(c.distributions[0].package, None);
2471 assert_eq!(c.distributions[0].adapter, DistributionAdapter::CargoDist);
2472 }
2473
2474 #[test]
2478 fn plural_distributions_parse_with_per_package_association() {
2479 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2480 targets:\n - {ecosystem: rust, package: alpha, registry: crates.io}\n \
2481 - {ecosystem: rust, package: beta, registry: crates.io}\n\
2482 distributions:\n - {package: alpha, adapter: cargo-dist, installers: [shell]}\n \
2483 - {package: beta, adapter: cargo-dist, installers: [homebrew], homebrew_tap: owner/tap}\n---\n";
2484 let n = norm(text);
2485 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2486 let d = n.contract.distributions;
2487 assert_eq!(d.len(), 2);
2488 assert_eq!(d[0].package.as_deref(), Some("alpha"));
2489 assert_eq!(d[0].installers, vec![Installer::Shell]);
2490 assert_eq!(d[1].package.as_deref(), Some("beta"));
2491 assert_eq!(d[1].homebrew_tap.as_deref(), Some("owner/tap"));
2492 }
2493
2494 #[test]
2498 fn distributions_canonical_json_round_trip() {
2499 for text in [
2500 "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2501 distribution:\n adapter: cargo-dist\n installers: [shell]\n---\n",
2502 "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2503 targets:\n - {ecosystem: rust, package: a, registry: crates.io}\n \
2504 - {ecosystem: rust, package: b, registry: crates.io}\n\
2505 distributions:\n - {package: a, adapter: cargo-dist}\n \
2506 - {package: b, adapter: goreleaser}\n---\n",
2507 ] {
2508 let first = norm(text).contract;
2509 assert!(!first.distributions.is_empty());
2510 let json = serde_json::to_value(&first).unwrap();
2512 let refed = format!("---\n{}---\n", serde_yaml::to_string(&json).unwrap());
2513 let second = norm(&refed).contract;
2514 assert_eq!(
2515 first.distributions, second.distributions,
2516 "round-trip drift for: {text}"
2517 );
2518 }
2519 }
2520
2521 #[test]
2523 fn both_distribution_keys_is_an_error() {
2524 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2525 distribution:\n adapter: cargo-dist\n\
2526 distributions:\n - {package: a, adapter: cargo-dist}\n---\n";
2527 assert_error_contains(&norm(text), "not both");
2528 }
2529
2530 #[test]
2533 fn multi_distribution_missing_package_is_a_floor_error() {
2534 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2535 targets:\n - {ecosystem: rust, package: a, registry: crates.io}\n\
2536 distributions:\n - {package: a, adapter: cargo-dist}\n \
2537 - {adapter: cargo-dist}\n---\n";
2538 assert_error_contains(&norm(text), "must name the package it builds");
2539 }
2540
2541 #[test]
2543 fn multi_distribution_duplicate_package_is_a_floor_error() {
2544 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2545 distributions:\n - {package: dup, adapter: cargo-dist}\n \
2546 - {package: dup, adapter: goreleaser}\n---\n";
2547 assert_error_contains(&norm(text), "distinct package");
2548 }
2549
2550 #[test]
2554 fn v1_document_is_relabeled_to_current_schema_version_on_emit() {
2555 let text = "---\nschema_version: 1\nstatus: approved\nmaturity: production\n\
2556 ecosystems: [rust]\n\
2557 distribution:\n adapter: cargo-dist\n---\n";
2558 let n = norm(text);
2559 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2560 assert_eq!(n.contract.schema_version, 2);
2562 let json = serde_json::to_value(&n.contract).unwrap();
2563 assert_eq!(json["schema_version"], 2);
2564 assert_eq!(json["distributions"].as_array().map(Vec::len), Some(1));
2566 }
2567
2568 #[test]
2572 fn distribution_package_is_trimmed() {
2573 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2574 distributions:\n - {package: ' alpha ', adapter: cargo-dist}\n \
2575 - {package: alpha, adapter: goreleaser}\n---\n";
2576 assert_error_contains(&norm(text), "distinct package");
2578 }
2579
2580 #[test]
2583 fn single_distribution_may_carry_a_package() {
2584 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2585 targets:\n - {ecosystem: rust, package: solo, registry: crates.io}\n\
2586 distributions:\n - {package: solo, adapter: cargo-dist}\n---\n";
2587 let n = norm(text);
2588 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2589 assert_eq!(n.contract.distributions[0].package.as_deref(), Some("solo"));
2590 }
2591
2592 #[test]
2597 fn distribution_unknown_subkey_preserved_and_warned() {
2598 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2599 distribution:\n adapter: cargo-dist\n gh_releases: true\n \
2600 future_signing: {enabled: true, kms_key: alias/oss}\n---\n";
2601 let n = norm(text);
2602 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2603 let d = n
2604 .contract
2605 .clone()
2606 .distributions
2607 .into_iter()
2608 .next()
2609 .expect("distribution present");
2610 assert_eq!(
2612 d.extra_fields
2613 .get("future_signing")
2614 .and_then(|v| v.get("kms_key"))
2615 .and_then(|v| v.as_str()),
2616 Some("alias/oss")
2617 );
2618 assert_eq!(d.adapter, DistributionAdapter::CargoDist);
2620 assert!(d.gh_releases);
2621 let json = serde_json::to_value(&n.contract).unwrap();
2623 assert_eq!(
2624 json["distributions"][0]["extra_fields"]["future_signing"]["enabled"],
2625 serde_json::json!(true)
2626 );
2627 assert!(
2629 n.problems.warnings.iter().any(|w| {
2630 w.contains("unknown distribution field(s) preserved")
2631 && w.contains("future_signing")
2632 }),
2633 "expected a scoped forward-compat warning: {:?}",
2634 n.problems.warnings
2635 );
2636 }
2637
2638 #[test]
2644 fn msi_installer_without_windows_platform_warns() {
2645 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2646 distribution:\n adapter: cargo-dist\n installers: [msi]\n \
2647 platforms: [x86_64-apple-darwin, x86_64-unknown-linux-musl]\n---\n";
2648 let n = norm(text);
2649 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2650 assert!(
2651 n.problems
2652 .warnings
2653 .iter()
2654 .any(|w| w.contains("includes 'msi'") && w.contains("no Windows")),
2655 "expected an msi/Windows cross-check warning: {:?}",
2656 n.problems.warnings
2657 );
2658 }
2659
2660 #[test]
2662 fn msi_installer_with_windows_platform_no_warning() {
2663 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2664 distribution:\n adapter: cargo-dist\n installers: [msi]\n \
2665 platforms: [x86_64-pc-windows-msvc]\n---\n";
2666 let n = norm(text);
2667 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2668 assert!(
2669 !n.problems.warnings.iter().any(|w| w.contains("'msi'")),
2670 "unexpected msi cross-check warning: {:?}",
2671 n.problems.warnings
2672 );
2673 }
2674
2675 #[test]
2680 fn homebrew_installer_without_darwin_or_linux_warns() {
2681 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2682 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
2683 homebrew_tap: jarimustonen/homebrew-issuectl\n \
2684 platforms: [x86_64-pc-windows-msvc]\n---\n";
2685 let n = norm(text);
2686 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2687 assert!(
2688 n.problems
2689 .warnings
2690 .iter()
2691 .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
2692 "expected a homebrew/(macOS|Linux) cross-check warning: {:?}",
2693 n.problems.warnings
2694 );
2695 }
2696
2697 #[test]
2701 fn homebrew_installer_with_linux_only_no_warning() {
2702 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2703 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
2704 homebrew_tap: jarimustonen/homebrew-issuectl\n \
2705 platforms: [x86_64-unknown-linux-musl]\n---\n";
2706 let n = norm(text);
2707 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2708 assert!(
2709 !n.problems.warnings.iter().any(|w| w.contains("'homebrew'")),
2710 "unexpected homebrew cross-check warning for a Linux-only set: {:?}",
2711 n.problems.warnings
2712 );
2713 }
2714
2715 #[test]
2718 fn npm_and_shell_installers_never_cross_check_warn() {
2719 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust, node]\n\
2720 distribution:\n adapter: cargo-dist\n installers: [shell, npm]\n \
2721 platforms: [x86_64-apple-darwin]\n---\n";
2722 let n = norm(text);
2723 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2724 assert!(
2725 !n.problems
2726 .warnings
2727 .iter()
2728 .any(|w| w.contains("nothing to install")),
2729 "OS-agnostic installers must not cross-check warn: {:?}",
2730 n.problems.warnings
2731 );
2732 }
2733
2734 #[test]
2737 fn coherent_installer_platform_set_no_warning() {
2738 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2739 distribution:\n adapter: cargo-dist\n installers: [homebrew, msi]\n \
2740 homebrew_tap: jarimustonen/homebrew-issuectl\n \
2741 platforms: [aarch64-apple-darwin, x86_64-pc-windows-msvc]\n---\n";
2742 let n = norm(text);
2743 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2744 assert!(
2745 !n.problems
2746 .warnings
2747 .iter()
2748 .any(|w| w.contains("nothing to install")),
2749 "coherent set must not warn: {:?}",
2750 n.problems.warnings
2751 );
2752 }
2753
2754 #[test]
2758 fn ossctl_own_contract_shape_no_cross_check_warning() {
2759 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2760 distribution:\n adapter: cargo-dist\n installers: [shell, powershell]\n \
2761 platforms: [aarch64-apple-darwin, x86_64-apple-darwin, \
2762 x86_64-unknown-linux-musl, aarch64-unknown-linux-musl, \
2763 x86_64-pc-windows-msvc]\n---\n";
2764 let n = norm(text);
2765 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2766 assert!(
2767 !n.problems
2768 .warnings
2769 .iter()
2770 .any(|w| w.contains("nothing to install")),
2771 "ossctl's own shape must not cross-check warn: {:?}",
2772 n.problems.warnings
2773 );
2774 }
2775
2776 #[test]
2781 fn msi_installer_with_defaulted_platforms_warns() {
2782 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2783 distribution:\n adapter: cargo-dist\n installers: [msi]\n---\n";
2784 let n = norm(text);
2785 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2786 assert!(
2787 n.problems
2788 .warnings
2789 .iter()
2790 .any(|w| w.contains("includes 'msi'") && w.contains("no Windows")),
2791 "expected an msi/Windows warning against the defaulted platform set: {:?}",
2792 n.problems.warnings
2793 );
2794 }
2795
2796 #[test]
2799 fn msi_installer_with_windows_gnu_no_warning() {
2800 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2801 distribution:\n adapter: cargo-dist\n installers: [msi]\n \
2802 platforms: [x86_64-pc-windows-gnu]\n---\n";
2803 let n = norm(text);
2804 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2805 assert!(
2806 !n.problems.warnings.iter().any(|w| w.contains("'msi'")),
2807 "windows-gnu must satisfy msi: {:?}",
2808 n.problems.warnings
2809 );
2810 }
2811
2812 #[test]
2818 fn homebrew_installer_with_android_only_warns() {
2819 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2820 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
2821 homebrew_tap: jarimustonen/homebrew-issuectl\n \
2822 platforms: [aarch64-linux-android]\n---\n";
2823 let n = norm(text);
2824 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2825 assert!(
2826 n.problems
2827 .warnings
2828 .iter()
2829 .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
2830 "Android-only must strand a homebrew installer: {:?}",
2831 n.problems.warnings
2832 );
2833 }
2834
2835 #[test]
2839 fn homebrew_installer_with_apple_ios_only_warns() {
2840 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2841 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
2842 homebrew_tap: jarimustonen/homebrew-issuectl\n \
2843 platforms: [aarch64-apple-ios]\n---\n";
2844 let n = norm(text);
2845 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2846 assert!(
2847 n.problems
2848 .warnings
2849 .iter()
2850 .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
2851 "apple-ios must not satisfy homebrew's macOS need: {:?}",
2852 n.problems.warnings
2853 );
2854 }
2855
2856 #[test]
2859 fn homebrew_installer_with_macos_only_no_warning() {
2860 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2861 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
2862 homebrew_tap: jarimustonen/homebrew-issuectl\n \
2863 platforms: [aarch64-apple-darwin]\n---\n";
2864 let n = norm(text);
2865 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2866 assert!(
2867 !n.problems.warnings.iter().any(|w| w.contains("'homebrew'")),
2868 "macOS-only must satisfy homebrew: {:?}",
2869 n.problems.warnings
2870 );
2871 }
2872
2873 #[test]
2878 fn both_installers_stranded_warn_once_each() {
2879 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2880 distribution:\n adapter: cargo-dist\n installers: [homebrew, msi]\n \
2881 homebrew_tap: jarimustonen/homebrew-issuectl\n \
2882 platforms: [wasm32-unknown-unknown]\n---\n";
2883 let n = norm(text);
2884 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2885 let msi = n
2886 .problems
2887 .warnings
2888 .iter()
2889 .filter(|w| w.contains("includes 'msi'"))
2890 .count();
2891 let brew = n
2892 .problems
2893 .warnings
2894 .iter()
2895 .filter(|w| w.contains("includes 'homebrew'"))
2896 .count();
2897 assert_eq!((msi, brew), (1, 1), "warnings: {:?}", n.problems.warnings);
2898 }
2899
2900 #[test]
2906 fn malformed_platform_triple_gates_off_cross_check() {
2907 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2908 distribution:\n adapter: cargo-dist\n installers: [msi]\n \
2909 platforms: [x86_64-PC-WINDOWS-MSVC]\n---\n";
2910 let n = norm(text);
2911 assert!(!n.is_valid(), "expected a malformed-triple error");
2913 assert!(
2915 !n.problems
2916 .warnings
2917 .iter()
2918 .any(|w| w.contains("nothing to install")),
2919 "cross-check must be gated off while platforms has errors: {:?}",
2920 n.problems.warnings
2921 );
2922 }
2923
2924 #[test]
2930 fn distribution_all_known_keys_has_empty_extra_fields() {
2931 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2932 distribution:\n adapter: cargo-dist\n gh_releases: true\n \
2933 installers: [shell, homebrew]\n homebrew_tap: owner/tap\n \
2934 platforms: [x86_64-unknown-linux-musl]\n---\n";
2935 let n = norm(text);
2936 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2937 let d = n
2938 .contract
2939 .distributions
2940 .into_iter()
2941 .next()
2942 .expect("distribution present");
2943 assert!(d.extra_fields.is_empty());
2944 assert!(
2945 !n.problems
2946 .warnings
2947 .iter()
2948 .any(|w| w.contains("unknown distribution field(s) preserved")),
2949 "no forward-compat warning for an all-known-keys block: {:?}",
2950 n.problems.warnings
2951 );
2952 }
2953
2954 #[test]
2959 fn distribution_and_top_level_extra_fields_coexist() {
2960 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2961 roadmap_url: https://example.com/x\n\
2962 distribution:\n adapter: cargo-dist\n future_x: 1\n---\n";
2963 let n = norm(text);
2964 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2965 let c = n.contract.clone();
2966 assert!(c.extra_fields.contains_key("roadmap_url"));
2967 let d = c
2968 .distributions
2969 .into_iter()
2970 .next()
2971 .expect("distribution present");
2972 assert_eq!(d.extra_fields.get("future_x"), Some(&serde_json::json!(1)));
2973 let fc: Vec<&String> = n
2976 .problems
2977 .warnings
2978 .iter()
2979 .filter(|w| w.contains("forward-compat") && w.contains("schema_version 2"))
2980 .collect();
2981 assert_eq!(fc.len(), 2, "expected two versioned warnings: {fc:?}");
2982 }
2983
2984 #[test]
2992 fn non_string_top_level_key_rejected() {
2993 let n = norm("---\nstatus: approved\nmaturity: mvp\n42: answer\n---\n");
2994 assert_error_contains(&n, "must be a string");
2995 assert!(
2996 n.problems.errors.iter().any(|e| e.contains("42")),
2997 "error should name the offending key: {:?}",
2998 n.problems.errors
2999 );
3000 }
3001
3002 #[test]
3005 fn non_string_distribution_key_rejected() {
3006 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3007 distribution:\n adapter: cargo-dist\n true: enabled\n---\n";
3008 let n = norm(text);
3009 assert_error_contains(&n, "must be a string");
3010 assert!(
3011 n.problems
3012 .errors
3013 .iter()
3014 .any(|e| e.contains("distribution field key")),
3015 "error should be scoped to the distribution block: {:?}",
3016 n.problems.errors
3017 );
3018 }
3019
3020 #[test]
3024 fn known_key_not_double_captured() {
3025 let n = norm("---\nstatus: approved\nmaturity: production\necosystems: [rust]\n---\n");
3026 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3027 assert!(!n.contract.extra_fields.contains_key("ecosystems"));
3028 assert!(!n.contract.extra_fields.contains_key("status"));
3029 assert!(n.contract.extra_fields.is_empty());
3030 }
3031
3032 #[test]
3038 fn reserved_extra_fields_block_merged_warnings_ignored() {
3039 let text = "---\nstatus: approved\nmaturity: mvp\n\
3040 extra_fields:\n foo: 1\nwarnings:\n - a prior note\n---\n";
3041 let n = norm(text);
3042 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3043 assert_eq!(
3045 n.contract.extra_fields.get("foo"),
3046 Some(&serde_json::json!(1))
3047 );
3048 assert!(!n.contract.extra_fields.contains_key("extra_fields"));
3049 assert!(
3051 !n.contract
3052 .warnings
3053 .iter()
3054 .any(|w| w.contains("a prior note")),
3055 "input warnings must be regenerated, not preserved: {:?}",
3056 n.contract.warnings
3057 );
3058 }
3059
3060 #[test]
3063 fn distribution_reserved_extra_fields_block_merged() {
3064 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3065 distribution:\n adapter: cargo-dist\n extra_fields:\n foo: 1\n---\n";
3066 let n = norm(text);
3067 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3068 let d = n
3069 .contract
3070 .distributions
3071 .into_iter()
3072 .next()
3073 .expect("distribution present");
3074 assert_eq!(d.extra_fields.get("foo"), Some(&serde_json::json!(1)));
3075 assert!(!d.extra_fields.contains_key("extra_fields"));
3076 }
3077
3078 #[test]
3082 fn extra_fields_round_trip_is_idempotent() {
3083 let first = norm("---\nstatus: approved\nmaturity: mvp\nroadmap_url: https://x/y\n---\n");
3084 assert!(first.is_valid(), "errors: {:?}", first.problems.errors);
3085 assert_eq!(first.contract.extra_fields.len(), 1);
3086 let inner = serde_yaml::to_string(&first.contract.extra_fields).unwrap();
3088 let indented = inner
3089 .lines()
3090 .map(|l| format!(" {l}"))
3091 .collect::<Vec<_>>()
3092 .join("\n");
3093 let text =
3094 format!("---\nstatus: approved\nmaturity: mvp\nextra_fields:\n{indented}\n---\n");
3095 let second = norm(&text);
3096 assert!(second.is_valid(), "errors: {:?}", second.problems.errors);
3097 assert_eq!(second.contract.extra_fields, first.contract.extra_fields);
3098 }
3099
3100 #[test]
3104 fn extra_fields_block_sibling_collision_is_error() {
3105 let text = "---\nstatus: approved\nmaturity: mvp\n\
3106 extra_fields:\n dup: 1\ndup: 2\n---\n";
3107 let n = norm(text);
3108 assert_error_contains(&n, "appears both");
3109 }
3110
3111 #[test]
3114 fn reserved_extra_fields_non_mapping_is_error() {
3115 let n = norm("---\nstatus: approved\nmaturity: mvp\nextra_fields: nonsense\n---\n");
3116 assert_error_contains(&n, "must be a mapping");
3117 }
3118
3119 #[test]
3125 fn top_level_all_known_keys_has_empty_extra_fields() {
3126 let text = "---\nschema_version: 1\nstatus: approved\nmaturity: production\n\
3127 ecosystems: [rust]\n\
3128 targets:\n - {ecosystem: rust, package: x, registry: crates.io, adapter: cargo-publish}\n\
3129 distribution:\n adapter: cargo-dist\n\
3130 versioning: semver\n\
3131 changelog:\n mode: curated\n source: manual\n\
3132 conventional_commits: false\n\
3133 release:\n model: gated\n layout: single\n\
3134 contribution_provenance: none\n\
3135 provenance_level: none\n\
3136 dependency_bot: dependabot\n\
3137 health_badges: [ci, registry, license]\n\
3138 license: MIT\n\
3139 docs_site: none\n---\n";
3140 let n = norm(text);
3141 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3142 assert!(
3143 n.contract.extra_fields.is_empty(),
3144 "unexpected extra_fields (KNOWN_KEYS drift?): {:?}",
3145 n.contract.extra_fields
3146 );
3147 assert!(
3148 !n.problems
3149 .warnings
3150 .iter()
3151 .any(|w| w.contains("forward-compat")),
3152 "no forward-compat warning for an all-known-keys contract: {:?}",
3153 n.problems.warnings
3154 );
3155 }
3156
3157 #[test]
3159 fn distribution_installers_dedup_canonical_order() {
3160 let text = "---\nstatus: approved\nmaturity: mvp\n\
3161 distribution:\n adapter: cargo-dist\n installers: [homebrew, shell, homebrew]\n \
3162 homebrew_tap: owner/tap\n---\n";
3163 let d = norm(text)
3164 .contract
3165 .distributions
3166 .into_iter()
3167 .next()
3168 .unwrap();
3169 assert_eq!(d.installers, vec![Installer::Shell, Installer::Homebrew]);
3170 }
3171
3172 #[test]
3174 fn distribution_homebrew_installer_requires_tap() {
3175 let text = "---\nstatus: approved\nmaturity: mvp\n\
3176 distribution:\n adapter: cargo-dist\n installers: [shell, homebrew]\n---\n";
3177 assert_error_contains(
3178 &norm(text),
3179 "includes 'homebrew' but no distribution.homebrew_tap",
3180 );
3181 }
3182
3183 #[test]
3187 fn distribution_bad_tap_slug_rejected() {
3188 let text = "---\nstatus: approved\nmaturity: mvp\n\
3189 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
3190 homebrew_tap: not-a-slug\n---\n";
3191 let n = norm(text);
3192 assert_error_contains(&n, "must be an 'owner/repo' slug");
3193 assert!(
3194 n.problems
3195 .errors
3196 .iter()
3197 .any(|e| e.contains("includes 'homebrew' but no distribution.homebrew_tap")),
3198 "the tap floor must still fire on an invalid (→None) tap: {:?}",
3199 n.problems.errors
3200 );
3201 assert_eq!(
3203 n.contract
3204 .distributions
3205 .into_iter()
3206 .next()
3207 .unwrap()
3208 .homebrew_tap,
3209 None
3210 );
3211 }
3212
3213 #[test]
3215 fn distribution_bad_installer_rejected() {
3216 let text = "---\nstatus: approved\nmaturity: mvp\n\
3217 distribution:\n adapter: cargo-dist\n installers: [snap]\n---\n";
3218 assert_error_contains(&norm(text), "distribution.installers");
3219 }
3220
3221 #[test]
3224 fn distribution_adapter_is_required() {
3225 assert_error_contains(
3226 &norm("---\nstatus: approved\nmaturity: mvp\ndistribution: {}\n---\n"),
3227 "distribution.adapter is required",
3228 );
3229 }
3230
3231 #[test]
3234 fn distribution_forbidden_on_spike() {
3235 let text = "---\nstatus: approved\nmaturity: spike\n\
3236 distribution:\n adapter: cargo-dist\n---\n";
3237 assert_error_contains(&norm(text), "not allowed on maturity 'spike'");
3238 }
3239
3240 #[test]
3245 fn distribution_tap_without_installer_warns() {
3246 let text = "---\nstatus: approved\nmaturity: mvp\n\
3247 distribution:\n adapter: cargo-dist\n installers: [shell]\n \
3248 homebrew_tap: owner/tap\n---\n";
3249 let n = norm(text);
3250 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3251 assert!(
3252 n.problems
3253 .warnings
3254 .iter()
3255 .any(|w| w.contains("no formula is generated, so the tap will never be updated")),
3256 "expected dead-tap warning: {:?}",
3257 n.problems.warnings
3258 );
3259 }
3260
3261 #[test]
3267 fn distribution_tap_with_homebrew_target_no_warning() {
3268 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
3269 targets:\n \
3270 - {ecosystem: rust, package: ossctl, registry: crates.io, adapter: cargo-publish}\n \
3271 - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: homebrew-tap}\n\
3272 distribution:\n adapter: cargo-dist\n installers: [shell]\n \
3273 homebrew_tap: owner/tap\n---\n";
3274 let n = norm(text);
3275 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3276 assert!(
3280 n.problems.warnings.is_empty(),
3281 "homebrew-target contract must not warn: {:?}",
3282 n.problems.warnings
3283 );
3284 }
3285
3286 #[test]
3289 fn distribution_goreleaser_minimal_is_valid() {
3290 let text = "---\nstatus: approved\nmaturity: production\necosystems: [go]\n\
3291 distribution:\n adapter: goreleaser\n gh_releases: true\n---\n";
3292 let n = norm(text);
3293 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3294 let d = n.contract.distributions.into_iter().next().unwrap();
3295 assert_eq!(d.adapter, DistributionAdapter::Goreleaser);
3296 assert!(d.installers.is_empty());
3297 assert_eq!(d.homebrew_tap, None);
3298 }
3299
3300 #[test]
3302 fn distribution_non_mapping_rejected() {
3303 assert_error_contains(
3304 &norm("---\nstatus: approved\nmaturity: mvp\ndistribution: [nope]\n---\n"),
3305 "distribution must be a mapping",
3306 );
3307 }
3308
3309 fn has_linux(platforms: &[String]) -> bool {
3315 platforms.iter().any(|t| t.contains("-linux"))
3316 }
3317
3318 #[test]
3323 fn distribution_platforms_default_is_cross_platform() {
3324 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3325 distribution:\n adapter: cargo-dist\n---\n";
3326 let d = norm(text)
3327 .contract
3328 .distributions
3329 .into_iter()
3330 .next()
3331 .expect("distribution present");
3332 assert_eq!(
3333 d.platforms,
3334 vec![
3335 "aarch64-apple-darwin",
3336 "x86_64-apple-darwin",
3337 "aarch64-unknown-linux-musl",
3338 "x86_64-unknown-linux-musl",
3339 ]
3340 );
3341 assert!(
3342 has_linux(&d.platforms),
3343 "the default set MUST contain a Linux triple: {:?}",
3344 d.platforms
3345 );
3346 }
3347
3348 #[test]
3351 fn distribution_platforms_explicit_round_trips() {
3352 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3353 distribution:\n adapter: cargo-dist\n \
3354 platforms: [x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc]\n---\n";
3355 let n = norm(text);
3356 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3357 let d = n.contract.clone().distributions.into_iter().next().unwrap();
3358 assert_eq!(
3359 d.platforms,
3360 vec!["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
3361 );
3362 let json = serde_json::to_value(&n.contract).unwrap();
3363 assert_eq!(
3364 json["distributions"][0]["platforms"],
3365 serde_json::json!(["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"])
3366 );
3367 }
3368
3369 #[test]
3375 fn distribution_platforms_empty_is_rejected() {
3376 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3377 distribution:\n adapter: cargo-dist\n platforms: []\n---\n";
3378 assert_error_contains(&norm(text), "empty list — omit the key");
3379 }
3380
3381 #[test]
3383 fn distribution_platforms_dedup_preserves_order() {
3384 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3385 distribution:\n adapter: cargo-dist\n \
3386 platforms: [aarch64-apple-darwin, x86_64-apple-darwin, aarch64-apple-darwin]\n---\n";
3387 let d = norm(text)
3388 .contract
3389 .distributions
3390 .into_iter()
3391 .next()
3392 .unwrap();
3393 assert_eq!(
3394 d.platforms,
3395 vec!["aarch64-apple-darwin", "x86_64-apple-darwin"]
3396 );
3397 }
3398
3399 #[test]
3401 fn distribution_platforms_bad_triple_rejected() {
3402 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3403 distribution:\n adapter: cargo-dist\n platforms: [not_a_triple]\n---\n";
3404 assert_error_contains(&norm(text), "is not a well-formed target-triple");
3405 }
3406
3407 #[test]
3409 fn distribution_platforms_non_string_entry_rejected() {
3410 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3411 distribution:\n adapter: cargo-dist\n platforms: [[nope]]\n---\n";
3412 assert_error_contains(&norm(text), "each entry must be a target-triple string");
3413 }
3414
3415 #[test]
3417 fn distribution_platforms_non_list_rejected() {
3418 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3419 distribution:\n adapter: cargo-dist\n platforms: x86_64-apple-darwin\n---\n";
3420 assert_error_contains(&norm(text), "must be a list of target-triple strings");
3421 }
3422
3423 #[test]
3427 fn registry_only_contract_unaffected_by_platforms() {
3428 let json = serde_json::to_value(
3429 &norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract,
3430 )
3431 .unwrap();
3432 assert_eq!(json["distributions"], serde_json::json!([]));
3433 }
3434
3435 #[test]
3436 fn looks_like_target_triple_verdicts() {
3437 assert!(looks_like_target_triple("aarch64-apple-darwin"));
3439 assert!(looks_like_target_triple("x86_64-apple-darwin"));
3440 assert!(looks_like_target_triple("x86_64-unknown-linux-musl"));
3441 assert!(looks_like_target_triple("x86_64-unknown-linux-gnu"));
3442 assert!(looks_like_target_triple("x86_64-pc-windows-msvc"));
3443 assert!(looks_like_target_triple("armv7-unknown-linux-gnueabihf"));
3444 assert!(looks_like_target_triple("wasm32-wasi"));
3445 assert!(looks_like_target_triple("thumbv8m.main-none-eabi"));
3447 assert!(looks_like_target_triple("thumbv8m.base-none-eabi"));
3448 assert!(!looks_like_target_triple("linux"));
3450 assert!(!looks_like_target_triple("a-b-c-d-e"));
3451 assert!(!looks_like_target_triple("x86_64--linux"));
3452 assert!(!looks_like_target_triple("-apple-darwin"));
3453 assert!(!looks_like_target_triple("X86_64-apple-darwin"));
3454 assert!(!looks_like_target_triple("x86_64-apple-darwin;rm"));
3455 assert!(!looks_like_target_triple("x86_64 apple darwin"));
3456 assert!(!looks_like_target_triple(""));
3457 assert!(looks_like_target_triple("aa-bb"));
3460 }
3461
3462 #[test]
3463 fn is_tap_slug_verdicts() {
3464 assert!(is_tap_slug("owner/repo"));
3466 assert!(is_tap_slug("jarimustonen/homebrew-issuectl"));
3467 assert!(is_tap_slug("Owner_1/repo.rb"));
3468 assert!(!is_tap_slug("no-slash"));
3470 assert!(!is_tap_slug("/repo"));
3471 assert!(!is_tap_slug("owner/"));
3472 assert!(!is_tap_slug("owner/repo/extra"));
3473 assert!(!is_tap_slug("owner / repo"));
3474 assert!(!is_tap_slug("owner/.."));
3476 assert!(!is_tap_slug("../repo"));
3477 assert!(!is_tap_slug("owner/repo;rm -rf"));
3478 assert!(!is_tap_slug("owner/@repo"));
3479 assert!(!is_tap_slug("ownér/repo"));
3480 }
3481
3482 #[test]
3485 fn quote_for_diagnostic_escapes_hostile_input() {
3486 assert_eq!(quote_for_diagnostic("foo"), "\"foo\"");
3487 assert_eq!(quote_for_diagnostic("a\"b"), "\"a\\\"b\"");
3488 assert_eq!(quote_for_diagnostic("a\nb"), "\"a\\nb\"");
3489 assert_eq!(quote_for_diagnostic("a\tb"), "\"a\\tb\"");
3490 assert_eq!(quote_for_diagnostic("\u{1}"), "\"\\u0001\"");
3492 }
3493
3494 #[test]
3498 fn unknown_field_key_is_escaped_in_warning() {
3499 let text =
3502 "---\nstatus: approved\nmaturity: mvp\n\"evil\\\"key\\nforged: line\\u0001\": 1\n---\n";
3503 let n = norm(text);
3504 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3505 let warning = n
3506 .problems
3507 .warnings
3508 .iter()
3509 .find(|w| w.contains("unknown field(s) preserved"))
3510 .expect("expected an unknown-field warning");
3511 assert!(
3514 !warning.contains('\n'),
3515 "warning must stay on one line: {warning:?}"
3516 );
3517 assert!(
3518 !warning.contains('\u{1}'),
3519 "warning must not carry a raw control char: {warning:?}"
3520 );
3521 assert!(
3522 !warning.contains("evil\"key"),
3523 "the raw unescaped key must not appear: {warning:?}"
3524 );
3525 assert!(
3527 warning.contains("\\\"") && warning.contains("\\n") && warning.contains("\\u0001"),
3528 "the key must be JSON-escaped: {warning:?}"
3529 );
3530 }
3531
3532 #[test]
3536 fn invalid_enum_value_is_escaped_in_error() {
3537 let text = "---\nstatus: approved\nmaturity: \"mvp\\nforged: line\"\n---\n";
3538 let n = norm(text);
3539 assert_error_contains(&n, "maturity");
3540 let err = n
3541 .problems
3542 .errors
3543 .iter()
3544 .find(|e| e.contains("maturity") && e.contains("invalid"))
3545 .expect("expected a maturity-invalid error");
3546 assert!(!err.contains('\n'), "error must stay on one line: {err:?}");
3547 assert!(
3548 err.contains("\\n"),
3549 "the rejected value's newline must be escaped: {err:?}"
3550 );
3551 }
3552}