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) = match map.get("release") {
279 None | Some(Value::Null) => (ReleaseModel::Gated, ReleaseLayout::Single),
280 Some(Value::Mapping(m)) => (
281 enum_field!(m, "model", ReleaseModel, ReleaseModel::Gated, p),
282 enum_field!(m, "layout", ReleaseLayout, ReleaseLayout::Single, p),
283 ),
284 Some(_) => {
285 p.err("release must be a mapping with model/layout".to_string());
286 (ReleaseModel::Gated, ReleaseLayout::Single)
287 }
288 };
289
290 let targets = match map.get("targets") {
300 None | Some(Value::Null) => expand_targets(&ecosystems, layout),
301 Some(Value::Sequence(seq)) if seq.is_empty() => Vec::new(),
302 Some(Value::Sequence(seq)) => validate_targets(seq, &ecosystems, layout, p),
303 Some(_) => {
304 p.err(
305 "targets must be a list of {ecosystem, package?, registry, adapter?} maps"
306 .to_string(),
307 );
308 Vec::new()
309 }
310 };
311
312 let distributions = parse_distributions(map, &targets, schema_version, p);
318
319 let changelog = match map.get("changelog") {
321 None | Some(Value::Null) => Changelog {
322 mode: ChangelogMode::Curated,
323 source: ChangelogSource::Manual,
324 fragment_dir: DEFAULT_FRAGMENT_DIR.to_string(),
325 },
326 Some(Value::Mapping(m)) => {
327 let mode = enum_field!(m, "mode", ChangelogMode, ChangelogMode::Curated, p);
328 let source = enum_field!(m, "source", ChangelogSource, ChangelogSource::Manual, p);
329 let fragment_dir = match m.get("fragment_dir") {
330 None => DEFAULT_FRAGMENT_DIR.to_string(),
331 Some(v) => {
332 if let Some(s) = v.as_str() {
333 s.to_string()
334 } else {
335 p.err("changelog.fragment_dir must be a string path".to_string());
336 DEFAULT_FRAGMENT_DIR.to_string()
337 }
338 }
339 };
340 Changelog {
341 mode,
342 source,
343 fragment_dir,
344 }
345 }
346 Some(_) => {
347 p.err("changelog must be a mapping with mode/source".to_string());
348 Changelog {
349 mode: ChangelogMode::Curated,
350 source: ChangelogSource::Manual,
351 fragment_dir: DEFAULT_FRAGMENT_DIR.to_string(),
352 }
353 }
354 };
355 if !path_inside_repo(&changelog.fragment_dir) {
357 p.err(format!(
358 "floor: changelog.fragment_dir {} must be a relative path inside the repo (an \
359 absolute or '../'-escaping path is refused)",
360 quote_for_diagnostic(&changelog.fragment_dir)
361 ));
362 }
363
364 let conventional_commits = match map.get("conventional_commits") {
366 None => false,
367 Some(Value::Bool(b)) => *b,
368 Some(v) => {
369 p.err(format!(
370 "conventional_commits must be true|false, got {}",
371 yaml_display(v)
372 ));
373 false
374 }
375 };
376
377 let contribution_provenance = enum_field!(
378 map,
379 "contribution_provenance",
380 ContributionProvenance,
381 ContributionProvenance::None,
382 p
383 );
384 let provenance_level = enum_field!(
385 map,
386 "provenance_level",
387 ProvenanceLevel,
388 ProvenanceLevel::None,
389 p
390 );
391
392 let dep_default = if maturity == Maturity::Spike {
393 DependencyBot::None
394 } else {
395 DependencyBot::Dependabot
396 };
397 let dependency_bot = enum_field!(map, "dependency_bot", DependencyBot, dep_default, p);
398
399 let license = match map.get("license") {
401 None => "MIT".to_string(),
402 Some(v) => match v.as_str() {
403 Some(s) if !s.trim().is_empty() => {
404 if !spdx_valid(s) {
405 p.err(format!(
406 "license {} is not a valid SPDX expression (unknown id or malformed \
407 AND/OR/WITH grammar)",
408 quote_for_diagnostic(s)
409 ));
410 }
411 s.to_string()
412 }
413 _ => {
414 p.err("license must be a non-empty SPDX id/expression (default MIT)".to_string());
415 "MIT".to_string()
416 }
417 },
418 };
419
420 let docs_site = enum_field!(map, "docs_site", DocsSite, DocsSite::None, p);
421
422 let health_badges = if map.contains_key("health_badges") {
425 let mut out = Vec::new();
426 for item in as_list(map.get("health_badges")) {
427 match item.as_str().and_then(HealthBadge::parse) {
428 Some(hb) => out.push(hb),
429 None => p.err(format!(
430 "health_badges: {} invalid — must be one of {:?}",
431 yaml_display(&item),
432 HealthBadge::VALID
433 )),
434 }
435 }
436 out
437 } else {
438 default_health_badges(maturity, &targets)
439 };
440
441 if model == ReleaseModel::Auto && maturity == Maturity::Spike {
443 p.err(
444 "floor: release.model 'auto' is not allowed on maturity 'spike' — a spike is not \
445 being published; raise maturity or set release.model: gated"
446 .to_string(),
447 );
448 }
449 if provenance_level == ProvenanceLevel::SlsaL3 && maturity != Maturity::Production {
450 p.err(format!(
451 "floor: provenance_level 'slsa-l3' is production-only — current maturity is '{}'",
452 maturity.as_str()
453 ));
454 }
455 if !targets.is_empty() && !spdx_valid(&license) {
458 p.err(format!(
459 "floor: a target has a registry (crates.io/npm/PyPI/… require a license) but license \
460 {} is not a valid SPDX expression",
461 quote_for_diagnostic(&license)
462 ));
463 }
464 check_badge_producers(&health_badges, maturity, &targets, p);
465 check_homebrew_configuration(&targets, &distributions, p);
468 if !distributions.is_empty() && maturity == Maturity::Spike {
473 p.err(
474 "floor: a distribution block ships public binaries (installer + tap) — not allowed on \
475 maturity 'spike' (a spike is not being published); raise maturity or drop distribution"
476 .to_string(),
477 );
478 }
479
480 if changelog.mode == ChangelogMode::Fragment
482 && path_inside_repo(&changelog.fragment_dir)
483 && !fs.is_dir(&repo_root.join(&changelog.fragment_dir))
484 {
485 p.warn(format!(
486 "changelog.mode 'fragment' but the fragment dir {} does not exist yet under {} — \
487 /oss-changelog creates it; /oss-readiness reports it as a gap until then",
488 quote_for_diagnostic(&changelog.fragment_dir),
489 repo_root.display()
490 ));
491 }
492
493 let extra_fields =
495 capture_unknown_fields(map, KNOWN_KEYS, CaptureScope::TopLevel, schema_version, p);
496
497 let warnings = p.warnings.clone();
498 Contract {
499 schema_version,
500 status,
501 maturity,
502 ecosystems,
503 targets,
504 distributions,
505 versioning,
506 versioning_pattern,
507 changelog,
508 conventional_commits,
509 release: Release { model, layout },
510 contribution_provenance,
511 provenance_level,
512 dependency_bot,
513 health_badges,
514 license,
515 docs_site,
516 extra_fields,
517 warnings,
518 }
519}
520
521fn parse_versioning(value: Option<&Value>, p: &mut Problems) -> (VersioningBase, Option<String>) {
522 let Some(v) = value else {
523 return (VersioningBase::Semver, None);
524 };
525 let Some(s) = v.as_str() else {
526 p.err(format!(
527 "versioning {} invalid — must be semver | calver:<pattern> | zerover",
528 yaml_display(v)
529 ));
530 return (VersioningBase::Semver, None);
531 };
532 if let Some(rest) = s.strip_prefix("calver:") {
533 let pattern = rest.trim();
534 if pattern.is_empty() {
535 p.err(
536 "versioning 'calver:' carries no pattern — e.g. calver:YYYY.MM.MICRO".to_string(),
537 );
538 }
539 (VersioningBase::Calver, Some(pattern.to_string()))
540 } else if s == "calver" {
541 p.err(
542 "versioning 'calver' must carry its pattern (calver:YYYY.MM.MICRO), not a bare label"
543 .to_string(),
544 );
545 (VersioningBase::Calver, None)
546 } else if let Some(base) = VersioningBase::parse(s) {
547 (base, None)
548 } else {
549 p.err(format!(
550 "versioning {} invalid — must be semver | calver:<pattern> | zerover",
551 quote_for_diagnostic(s)
552 ));
553 (VersioningBase::Semver, None)
554 }
555}
556
557fn expand_targets(ecosystems: &[Ecosystem], layout: ReleaseLayout) -> Vec<Target> {
559 ecosystems
560 .iter()
561 .map(|&e| Target {
562 ecosystem: e,
563 package: None,
564 registry: e.default_registry(),
565 adapter: e.default_adapter(layout),
566 })
567 .collect()
568}
569
570fn validate_targets(
571 seq: &[Value],
572 ecosystems: &[Ecosystem],
573 layout: ReleaseLayout,
574 p: &mut Problems,
575) -> Vec<Target> {
576 let mut out = Vec::new();
577 for (idx, item) in seq.iter().enumerate() {
578 let Value::Mapping(m) = item else {
579 p.err(format!(
580 "targets[{idx}] must be a map with at least {{ecosystem, registry}}"
581 ));
582 continue;
583 };
584
585 let ecosystem = if let Some(s) = m.get("ecosystem").and_then(Value::as_str) {
586 if let Some(e) = Ecosystem::parse(s) {
587 if !ecosystems.is_empty() && !ecosystems.contains(&e) {
588 p.err(format!(
589 "targets[{idx}].ecosystem {} is not in ecosystems {:?}",
590 quote_for_diagnostic(s),
591 ecosystems.iter().map(|e| e.as_str()).collect::<Vec<_>>()
592 ));
593 }
594 Some(e)
595 } else {
596 p.err(format!(
597 "targets[{idx}].ecosystem {} invalid — one of {:?}",
598 quote_for_diagnostic(s),
599 Ecosystem::VALID
600 ));
601 None
602 }
603 } else {
604 p.err(format!(
605 "targets[{idx}].ecosystem invalid — one of {:?}",
606 Ecosystem::VALID
607 ));
608 None
609 };
610
611 let registry = match m.get("registry").and_then(Value::as_str) {
612 None => {
613 p.err(format!(
614 "targets[{idx}] has no registry (required — the publish destination)"
615 ));
616 None
617 }
618 Some(s) => {
619 if let Some(r) = Registry::parse(s) {
620 Some(r)
621 } else {
622 p.err(format!(
623 "targets[{idx}].registry {} invalid — one of {:?}",
624 quote_for_diagnostic(s),
625 Registry::VALID
626 ));
627 None
628 }
629 }
630 };
631
632 let adapter = match m.get("adapter") {
633 None => ecosystem.map(|e| e.default_adapter(layout)),
634 Some(v) => {
635 if let Some(a) = v.as_str().and_then(Adapter::parse) {
636 Some(a)
637 } else {
638 p.err(format!(
639 "targets[{idx}].adapter {} invalid — one of {:?}",
640 yaml_display(v),
641 Adapter::VALID
642 ));
643 None
644 }
645 }
646 };
647
648 if let (Some(Registry::Homebrew), Some(a)) = (registry, adapter) {
656 if !matches!(a, Adapter::HomebrewTap | Adapter::HomebrewCore) {
657 p.err(format!(
658 "floor: targets[{idx}] has registry 'homebrew' but adapter {} — a \
659 homebrew-registry target requires adapter 'homebrew-tap' (personal tap) \
660 or 'homebrew-core' (central formula)",
661 quote_for_diagnostic(a.as_str())
662 ));
663 }
664 }
665
666 out.push(Target {
669 ecosystem: ecosystem.unwrap_or(Ecosystem::Binary),
670 package: m.get("package").and_then(Value::as_str).map(str::to_string),
671 registry: registry.unwrap_or(Registry::GhReleases),
672 adapter: adapter.unwrap_or(Adapter::Manual),
673 });
674 }
675 out
676}
677
678const KNOWN_DISTRIBUTION_KEYS: &[&str] = &[
689 "package",
690 "adapter",
691 "gh_releases",
692 "installers",
693 "homebrew_tap",
694 "platforms",
695 "extra_fields",
697];
698
699const INSTALLER_ORDER: [Installer; 5] = [
702 Installer::Shell,
703 Installer::Powershell,
704 Installer::Homebrew,
705 Installer::Msi,
706 Installer::Npm,
707];
708
709fn parse_distributions(
719 map: &Mapping,
720 targets: &[Target],
721 schema_version: u32,
722 p: &mut Problems,
723) -> Vec<Distribution> {
724 let single = map.get("distribution");
725 let many = map.get("distributions");
726 let single_present = matches!(single, Some(v) if !v.is_null());
729 let many_present = matches!(many, Some(v) if !v.is_null());
730 if single_present && many_present {
731 p.err(
732 "declare either `distribution` (one block) or `distributions` (a list), not both — \
733 they are the singular and plural spellings of the same field"
734 .to_string(),
735 );
736 }
739
740 let distributions = match (single, many) {
741 (_, Some(Value::Sequence(seq))) => {
744 let mut out = Vec::with_capacity(seq.len());
745 for (idx, item) in seq.iter().enumerate() {
746 match item {
747 Value::Mapping(m) => {
748 out.push(parse_one_distribution(m, schema_version, p));
749 }
750 _ => p.err(format!(
751 "distributions[{idx}] must be a mapping with {{package, adapter, \
752 gh_releases?, installers?, homebrew_tap?, platforms?}}"
753 )),
754 }
755 }
756 out
757 }
758 (_, Some(v)) if !v.is_null() => {
759 p.err(format!(
760 "distributions must be a list of distribution mappings, got {}",
761 yaml_display(v)
762 ));
763 Vec::new()
764 }
765 (Some(Value::Mapping(m)), _) => {
767 vec![parse_one_distribution(m, schema_version, p)]
768 }
769 (Some(v), _) if !v.is_null() => {
770 p.err(
771 "distribution must be a mapping with {adapter?, gh_releases?, installers?, \
772 homebrew_tap?, platforms?} (or use `distributions:` for a list)"
773 .to_string(),
774 );
775 Vec::new()
776 }
777 _ => Vec::new(),
779 };
780
781 if distributions.len() >= 2 {
786 let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
787 for (idx, d) in distributions.iter().enumerate() {
788 match d.package.as_deref() {
789 None => p.err(format!(
790 "floor: distributions[{idx}] has no `package` — with two or more \
791 distributions each must name the package it builds (the monorepo \
792 association key), so they can be told apart"
793 )),
794 Some(pkg) if !seen.insert(pkg) => p.err(format!(
795 "floor: distributions[{idx}].package {} is used by more than one \
796 distribution — each distribution must name a distinct package",
797 quote_for_diagnostic(pkg)
798 )),
799 Some(_) => {}
800 }
801 }
802
803 let target_pkgs: std::collections::BTreeSet<&str> = targets
811 .iter()
812 .filter_map(|t| t.package.as_deref())
813 .collect();
814 if !target_pkgs.is_empty() {
815 for (idx, d) in distributions.iter().enumerate() {
816 if let Some(pkg) = d.package.as_deref() {
817 if !target_pkgs.contains(pkg) {
818 p.warn(format!(
819 "distributions[{idx}].package {} matches no targets[].package \
820 ({target_pkgs:?}) — likely a typo; a distribution should build a \
821 package the contract also lists as a target",
822 quote_for_diagnostic(pkg)
823 ));
824 }
825 }
826 }
827 }
828 }
829
830 distributions
831}
832
833#[allow(clippy::too_many_lines)]
838fn parse_one_distribution(m: &Mapping, schema_version: u32, p: &mut Problems) -> Distribution {
839 let package = match m.get("package") {
843 None | Some(Value::Null) => None,
844 Some(v) => match v.as_str() {
845 Some(s) if !s.trim().is_empty() => Some(s.trim().to_string()),
849 _ => {
850 p.err(
851 "distribution.package must be a non-empty string (the package this \
852 distribution builds)"
853 .to_string(),
854 );
855 None
856 }
857 },
858 };
859
860 let adapter = match m.get("adapter") {
866 None => {
867 p.err(
868 "distribution.adapter is required when a distribution block is present \
869 (cargo-dist|goreleaser|manual) — /oss-init infers it"
870 .to_string(),
871 );
872 DistributionAdapter::CargoDist
873 }
874 Some(v) => {
875 if let Some(a) = v.as_str().and_then(DistributionAdapter::parse) {
876 a
877 } else {
878 p.err(format!(
879 "distribution.adapter {} invalid — must be one of {:?}",
880 yaml_display(v),
881 DistributionAdapter::VALID
882 ));
883 DistributionAdapter::CargoDist
884 }
885 }
886 };
887
888 let gh_releases = match m.get("gh_releases") {
889 None => true,
891 Some(Value::Bool(b)) => *b,
892 Some(v) => {
893 p.err(format!(
894 "distribution.gh_releases must be true|false, got {}",
895 yaml_display(v)
896 ));
897 true
898 }
899 };
900
901 let mut parsed_installers: Vec<Installer> = Vec::new();
903 for item in as_list(m.get("installers")) {
904 match item.as_str().and_then(Installer::parse) {
905 Some(i) => parsed_installers.push(i),
906 None => p.err(format!(
907 "distribution.installers: {} invalid — must be one of {:?}",
908 yaml_display(&item),
909 Installer::VALID
910 )),
911 }
912 }
913 let installers: Vec<Installer> = INSTALLER_ORDER
914 .into_iter()
915 .filter(|i| parsed_installers.contains(i))
916 .collect();
917
918 let homebrew_tap = match m.get("homebrew_tap") {
919 None | Some(Value::Null) => None,
920 Some(v) => match v.as_str() {
921 Some(s) if is_tap_slug(s) => Some(s.to_string()),
922 Some(s) => {
928 p.err(format!(
929 "distribution.homebrew_tap {} invalid — must be an 'owner/repo' slug",
930 quote_for_diagnostic(s)
931 ));
932 None
933 }
934 None => {
935 p.err("distribution.homebrew_tap must be an 'owner/repo' string".to_string());
936 None
937 }
938 },
939 };
940
941 let wants_homebrew = installers.contains(&Installer::Homebrew);
942 if wants_homebrew && homebrew_tap.is_none() {
949 p.err(
950 "floor: distribution.installers includes 'homebrew' but no distribution.homebrew_tap \
951 is set — the generated formula has nowhere to be pushed"
952 .to_string(),
953 );
954 }
955
956 let platforms = match m.get("platforms") {
966 None | Some(Value::Null) => default_cross_platform_targets(),
967 Some(Value::Sequence(seq)) if seq.is_empty() => {
968 p.err(
971 "distribution.platforms is an empty list — omit the key to accept the \
972 cross-platform default (macOS + Linux) or list explicit target-triples; a \
973 distribution with no platforms builds nothing"
974 .to_string(),
975 );
976 default_cross_platform_targets()
977 }
978 Some(Value::Sequence(seq)) => {
979 let mut out: Vec<String> = Vec::new();
980 for item in seq {
981 match item.as_str() {
982 Some(s) if looks_like_target_triple(s) => {
983 let triple = s.to_string();
984 if !out.contains(&triple) {
985 out.push(triple);
986 }
987 }
988 Some(s) => p.err(format!(
989 "distribution.platforms: {} is not a well-formed target-triple \
990 (e.g. x86_64-unknown-linux-musl, aarch64-apple-darwin) — structural \
991 check only; the toolchain is the final authority on what builds",
992 quote_for_diagnostic(s)
993 )),
994 None => p.err(format!(
995 "distribution.platforms: {} invalid — each entry must be a \
996 target-triple string",
997 yaml_display(item)
998 )),
999 }
1000 }
1001 out
1002 }
1003 Some(v) => {
1004 p.err(format!(
1005 "distribution.platforms must be a list of target-triple strings, got {}",
1006 yaml_display(v)
1007 ));
1008 default_cross_platform_targets()
1009 }
1010 };
1011
1012 if p.errors.is_empty() {
1026 let has_windows = platforms.iter().any(|t| is_windows_triple(t));
1027 let has_macos = platforms.iter().any(|t| is_macos_triple(t));
1028 let has_linux = platforms.iter().any(|t| is_linux_triple(t));
1029 for &installer in &installers {
1030 let unmet = match installer_os_need(installer) {
1031 OsNeed::Unchecked => None,
1032 OsNeed::Windows => (!has_windows).then_some(
1033 "distribution.installers includes 'msi' but the resolved \
1034 distribution.platforms set has no Windows (*-windows-*) target — the MSI \
1035 installer has nothing to install",
1036 ),
1037 OsNeed::MacosOrLinux => (!has_macos && !has_linux).then_some(
1042 "distribution.installers includes 'homebrew' but the resolved \
1043 distribution.platforms set has no macOS (*-apple-darwin) or Linux \
1044 (*-linux-*) target — the Homebrew formula has nothing to install",
1045 ),
1046 };
1047 if let Some(msg) = unmet {
1048 p.warn(msg.to_string());
1049 }
1050 }
1051 }
1052
1053 let extra_fields = capture_unknown_fields(
1059 m,
1060 KNOWN_DISTRIBUTION_KEYS,
1061 CaptureScope::Distribution,
1062 schema_version,
1063 p,
1064 );
1065
1066 Distribution {
1067 package,
1068 adapter,
1069 gh_releases,
1070 installers,
1071 homebrew_tap,
1072 platforms,
1073 extra_fields,
1074 }
1075}
1076
1077fn default_cross_platform_targets() -> Vec<String> {
1081 DEFAULT_CROSS_PLATFORM_TARGETS
1082 .iter()
1083 .map(|&s| s.to_string())
1084 .collect()
1085}
1086
1087enum OsNeed {
1092 Unchecked,
1094 Windows,
1096 MacosOrLinux,
1098}
1099
1100fn installer_os_need(i: Installer) -> OsNeed {
1113 match i {
1114 Installer::Msi => OsNeed::Windows,
1115 Installer::Homebrew => OsNeed::MacosOrLinux,
1116 Installer::Shell | Installer::Powershell | Installer::Npm => OsNeed::Unchecked,
1117 }
1118}
1119
1120fn triple_os(s: &str) -> Option<&str> {
1128 s.split('-').nth(2)
1129}
1130
1131fn is_windows_triple(s: &str) -> bool {
1134 triple_os(s) == Some("windows")
1135}
1136
1137fn is_macos_triple(s: &str) -> bool {
1141 triple_os(s) == Some("darwin")
1142}
1143
1144fn is_linux_triple(s: &str) -> bool {
1149 triple_os(s) == Some("linux")
1150}
1151
1152fn looks_like_target_triple(s: &str) -> bool {
1163 let parts: Vec<&str> = s.split('-').collect();
1164 (2..=4).contains(&parts.len())
1165 && parts.iter().all(|part| {
1166 !part.is_empty()
1167 && part.bytes().all(|b| {
1168 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.')
1169 })
1170 })
1171}
1172
1173fn is_tap_slug(s: &str) -> bool {
1180 fn valid_part(part: &str) -> bool {
1181 !part.is_empty()
1182 && part != "."
1183 && part != ".."
1184 && part
1185 .bytes()
1186 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
1187 }
1188 match s.split_once('/') {
1189 Some((owner, repo)) => valid_part(owner) && valid_part(repo) && !repo.contains('/'),
1190 None => false,
1191 }
1192}
1193
1194fn default_health_badges(maturity: Maturity, targets: &[Target]) -> Vec<HealthBadge> {
1197 let mut badges = Vec::new();
1198 if matches!(maturity, Maturity::Mvp | Maturity::Production) {
1199 badges.push(HealthBadge::Ci);
1200 }
1201 if !targets.is_empty() {
1202 badges.push(HealthBadge::Registry);
1203 }
1204 badges.push(HealthBadge::License);
1205 badges
1206}
1207
1208fn check_badge_producers(
1210 badges: &[HealthBadge],
1211 maturity: Maturity,
1212 targets: &[Target],
1213 p: &mut Problems,
1214) {
1215 let has_registry_target = !targets.is_empty();
1216 for b in badges {
1217 match b {
1218 HealthBadge::Ci if maturity == Maturity::Spike => p.err(
1219 "floor: health_badge 'ci' has no producer at maturity 'spike' (no CI until mvp) — \
1220 drop it or raise maturity"
1221 .to_string(),
1222 ),
1223 HealthBadge::Registry if !has_registry_target => p.err(
1224 "floor: health_badge 'registry' has no producer — no target has a registry to \
1225 publish to"
1226 .to_string(),
1227 ),
1228 HealthBadge::Coverage if maturity != Maturity::Production => p.err(format!(
1229 "floor: health_badge 'coverage' has no producer — the coverage gate is a \
1230 production-tier /oss-ci output; current maturity is '{}'",
1231 maturity.as_str()
1232 )),
1233 HealthBadge::Scorecard if maturity != Maturity::Production => p.err(format!(
1234 "floor: health_badge 'scorecard' has no producer — the Scorecard action is a \
1235 production-tier output; current maturity is '{}'",
1236 maturity.as_str()
1237 )),
1238 _ => {}
1239 }
1240 }
1241}
1242
1243fn check_homebrew_configuration(
1276 targets: &[Target],
1277 distributions: &[Distribution],
1278 p: &mut Problems,
1279) {
1280 let has_tap = distributions.iter().any(|d| d.homebrew_tap.is_some());
1281 let installer_producer = distributions
1282 .iter()
1283 .any(|d| d.installers.contains(&Installer::Homebrew));
1284 let tap_target_producer = targets
1289 .iter()
1290 .any(|t| t.registry == Registry::Homebrew && t.adapter == Adapter::HomebrewTap);
1291
1292 if tap_target_producer && !has_tap {
1294 p.err(
1295 "floor: a 'homebrew'-registry target with adapter 'homebrew-tap' generates a formula \
1296 but no distribution sets homebrew_tap — the formula has nowhere to be pushed (set \
1297 distribution.homebrew_tap to the 'owner/repo' tap)"
1298 .to_string(),
1299 );
1300 }
1301
1302 if installer_producer && tap_target_producer {
1305 p.err(
1306 "floor: both a 'homebrew' installer (distribution.installers) and a 'homebrew'-registry \
1307 target with adapter 'homebrew-tap' generate + push a formula to the tap — they would \
1308 collide; keep exactly one homebrew formula producer, not both"
1309 .to_string(),
1310 );
1311 }
1312
1313 if has_tap && !installer_producer && !tap_target_producer {
1315 p.warn(
1316 "distribution.homebrew_tap is set but there is neither a 'homebrew' installer in \
1317 distribution.installers nor a 'homebrew'-registry target with adapter 'homebrew-tap' \
1318 — no formula is generated, so the tap will never be updated"
1319 .to_string(),
1320 );
1321 }
1322}
1323
1324fn is_fence(line: &str) -> bool {
1328 let t = line.trim_end();
1329 t == "---" || (t.starts_with("---") && t[3..].chars().all(char::is_whitespace))
1330}
1331
1332fn split_frontmatter(text: &str, p: &mut Problems) -> Option<String> {
1336 let mut lines = text.lines();
1337 match lines.next() {
1338 Some(first) if is_fence(first) => {}
1339 _ => {
1340 p.err("frontmatter missing: file must begin with a '---' YAML block".to_string());
1341 return None;
1342 }
1343 }
1344 let mut fm = String::new();
1345 for line in lines {
1346 if is_fence(line) {
1347 return Some(fm);
1348 }
1349 fm.push_str(line);
1350 fm.push('\n');
1351 }
1352 p.err("frontmatter not closed: no terminating '---' line found".to_string());
1353 None
1354}
1355
1356fn parse_frontmatter(fm: &str, p: &mut Problems) -> Mapping {
1359 if fm.trim().is_empty() {
1360 return Mapping::new();
1361 }
1362 match serde_yaml::from_str::<Value>(fm) {
1363 Ok(Value::Null) => Mapping::new(),
1364 Ok(Value::Mapping(m)) => m,
1365 Ok(_) => {
1366 p.err("frontmatter: top level must be a mapping".to_string());
1367 Mapping::new()
1368 }
1369 Err(e) => {
1370 p.err(format!("frontmatter: invalid YAML — {e}"));
1371 Mapping::new()
1372 }
1373 }
1374}
1375
1376fn as_list(v: Option<&Value>) -> Vec<Value> {
1381 match v {
1382 None | Some(Value::Null) => Vec::new(),
1383 Some(Value::Sequence(seq)) => seq.clone(),
1384 Some(other) => vec![other.clone()],
1385 }
1386}
1387
1388fn yaml_display(v: &Value) -> String {
1390 match v {
1391 Value::String(s) => quote_for_diagnostic(s),
1392 Value::Bool(b) => b.to_string(),
1393 Value::Number(n) => n.to_string(),
1394 Value::Null => "null".to_string(),
1395 Value::Sequence(_) => "<list>".to_string(),
1396 Value::Mapping(_) => "<map>".to_string(),
1397 Value::Tagged(t) => yaml_display(&t.value),
1398 }
1399}
1400
1401fn quote_for_diagnostic(s: &str) -> String {
1413 serde_json::Value::String(s.to_owned()).to_string()
1414}
1415
1416fn path_inside_repo(rel: &str) -> bool {
1427 let mut depth: usize = 0;
1428 for comp in Path::new(rel).components() {
1429 match comp {
1430 Component::CurDir => {}
1431 Component::Normal(_) => depth += 1,
1432 Component::ParentDir => {
1433 if depth == 0 {
1435 return false;
1436 }
1437 depth -= 1;
1438 }
1439 Component::RootDir | Component::Prefix(_) => return false,
1442 }
1443 }
1444 true
1445}
1446
1447#[derive(Clone, Copy)]
1452enum CaptureScope {
1453 TopLevel,
1455 Distribution,
1457}
1458
1459impl CaptureScope {
1460 fn label(self) -> &'static str {
1464 match self {
1465 Self::TopLevel => "",
1466 Self::Distribution => "distribution ",
1467 }
1468 }
1469}
1470
1471fn capture_unknown_fields(
1498 m: &Mapping,
1499 known: &[&str],
1500 scope: CaptureScope,
1501 schema_version: u32,
1502 p: &mut Problems,
1503) -> serde_json::Map<String, serde_json::Value> {
1504 let label = scope.label();
1505 let mut extra_fields = serde_json::Map::new();
1506 if let Some(v) = m.get("extra_fields") {
1510 merge_reserved_extra_fields(v, scope, &mut extra_fields, p);
1511 }
1512 for (k, v) in m {
1513 match k {
1514 Value::String(key) => {
1515 if known.contains(&key.as_str()) {
1516 continue;
1517 }
1518 if extra_fields.contains_key(key) {
1519 p.err(format!(
1520 "{label}field '{key}' appears both as an unknown top-level key and inside \
1521 the reserved '{label}extra_fields' block — refusing to drop either value; \
1522 remove one"
1523 ));
1524 } else {
1525 extra_fields.insert(key.clone(), yaml_to_json(v));
1526 }
1527 }
1528 other => p.err(format!(
1529 "{label}field key {} must be a string — a non-string key is not a \
1530 forward-compatible schema shape and cannot be preserved losslessly (distinct \
1531 non-string keys collapse onto the same JSON key)",
1532 yaml_display(other)
1533 )),
1534 }
1535 }
1536 if !extra_fields.is_empty() {
1537 let keys = extra_fields
1542 .keys()
1543 .map(|k| quote_for_diagnostic(k))
1544 .collect::<Vec<_>>()
1545 .join(", ");
1546 p.warn(format!(
1547 "unknown {label}field(s) preserved under schema_version {schema_version} \
1548 (forward-compat): [{keys}]"
1549 ));
1550 }
1551 extra_fields
1552}
1553
1554fn merge_reserved_extra_fields(
1562 v: &Value,
1563 scope: CaptureScope,
1564 out: &mut serde_json::Map<String, serde_json::Value>,
1565 p: &mut Problems,
1566) {
1567 let label = scope.label();
1568 match v {
1569 Value::Null => {}
1570 Value::Mapping(inner) => {
1571 for (k, val) in inner {
1572 match k {
1573 Value::String(key) => {
1574 out.insert(key.clone(), yaml_to_json(val));
1575 }
1576 other => p.err(format!(
1577 "reserved '{label}extra_fields' block has a non-string key {} — its keys \
1578 must be strings",
1579 yaml_display(other)
1580 )),
1581 }
1582 }
1583 }
1584 other => p.err(format!(
1585 "reserved '{label}extra_fields' must be a mapping when present, got {}",
1586 yaml_display(other)
1587 )),
1588 }
1589}
1590
1591fn yaml_to_json(v: &Value) -> serde_json::Value {
1593 use serde_json::Value as J;
1594 match v {
1595 Value::Null => J::Null,
1596 Value::Bool(b) => J::Bool(*b),
1597 Value::Number(n) => {
1598 if let Some(i) = n.as_i64() {
1599 J::from(i)
1600 } else if let Some(u) = n.as_u64() {
1601 J::from(u)
1602 } else if let Some(f) = n.as_f64() {
1603 serde_json::Number::from_f64(f).map_or(J::Null, J::Number)
1604 } else {
1605 J::Null
1606 }
1607 }
1608 Value::String(s) => J::String(s.clone()),
1609 Value::Sequence(seq) => J::Array(seq.iter().map(yaml_to_json).collect()),
1610 Value::Mapping(m) => {
1611 let mut obj = serde_json::Map::new();
1612 for (k, val) in m {
1613 let key = match k {
1614 Value::String(s) => s.clone(),
1615 other => yaml_display(other),
1616 };
1617 obj.insert(key, yaml_to_json(val));
1618 }
1619 J::Object(obj)
1620 }
1621 Value::Tagged(t) => yaml_to_json(&t.value),
1622 }
1623}
1624
1625#[cfg(test)]
1626mod tests {
1627 use super::*;
1628 use std::collections::HashSet;
1629
1630 struct FakeFs {
1633 dirs: HashSet<PathBuf>,
1634 }
1635
1636 impl FakeFs {
1637 fn empty() -> Self {
1638 Self {
1639 dirs: HashSet::new(),
1640 }
1641 }
1642
1643 fn with_dirs<const N: usize>(dirs: [&str; N]) -> Self {
1644 Self {
1645 dirs: dirs.iter().map(PathBuf::from).collect(),
1646 }
1647 }
1648 }
1649
1650 impl Fs for FakeFs {
1651 fn read(&self, _path: &Path) -> io::Result<Vec<u8>> {
1652 Err(io::Error::from(io::ErrorKind::NotFound))
1653 }
1654 fn exists(&self, path: &Path) -> bool {
1655 self.dirs.contains(path)
1656 }
1657 fn is_dir(&self, path: &Path) -> bool {
1658 self.dirs.contains(path)
1659 }
1660 fn is_file(&self, _path: &Path) -> bool {
1661 false
1663 }
1664 fn read_dir(&self, _dir: &Path) -> io::Result<Vec<String>> {
1665 Ok(Vec::new())
1667 }
1668 }
1669
1670 fn repo() -> &'static Path {
1671 Path::new("/repo")
1672 }
1673
1674 fn norm(text: &str) -> Normalized {
1675 normalize_str(text, repo(), &FakeFs::empty())
1676 }
1677
1678 fn norm_with(text: &str, fs: &dyn Fs) -> Normalized {
1679 normalize_str(text, repo(), fs)
1680 }
1681
1682 fn assert_error_contains(n: &Normalized, needle: &str) {
1683 assert!(
1684 !n.is_valid(),
1685 "expected invalid, got clean normalize: {:?}",
1686 n.contract
1687 );
1688 assert!(
1689 n.problems.errors.iter().any(|e| e.contains(needle)),
1690 "no error contained {needle:?}; errors were {:?}",
1691 n.problems.errors
1692 );
1693 }
1694
1695 const MINIMAL: &str = "---\nstatus: approved\nmaturity: mvp\n---\n";
1696
1697 #[test]
1698 fn materializes_all_defaults() {
1699 let c = norm(MINIMAL).contract;
1700 assert_eq!(c.schema_version, 2);
1703 assert_eq!(c.status, Status::Approved);
1704 assert_eq!(c.maturity, Maturity::Mvp);
1705 assert!(c.ecosystems.is_empty());
1706 assert!(c.targets.is_empty());
1707 assert_eq!(c.versioning, VersioningBase::Semver);
1708 assert_eq!(c.versioning_pattern, None);
1709 assert_eq!(c.changelog.mode, ChangelogMode::Curated);
1710 assert_eq!(c.changelog.source, ChangelogSource::Manual);
1711 assert_eq!(c.changelog.fragment_dir, DEFAULT_FRAGMENT_DIR);
1712 assert!(!c.conventional_commits);
1713 assert_eq!(c.release.model, ReleaseModel::Gated);
1714 assert_eq!(c.release.layout, ReleaseLayout::Single);
1715 assert_eq!(c.contribution_provenance, ContributionProvenance::None);
1716 assert_eq!(c.provenance_level, ProvenanceLevel::None);
1717 assert_eq!(c.dependency_bot, DependencyBot::Dependabot); assert_eq!(c.license, "MIT");
1719 assert_eq!(c.docs_site, DocsSite::None);
1720 assert_eq!(c.health_badges, vec![HealthBadge::Ci, HealthBadge::License]);
1722 assert!(c.extra_fields.is_empty());
1723 }
1724
1725 #[test]
1726 fn spike_defaults_no_bot_no_ci_badge() {
1727 let c = norm("---\nstatus: approved\nmaturity: spike\n---\n").contract;
1728 assert_eq!(c.dependency_bot, DependencyBot::None);
1729 assert_eq!(c.health_badges, vec![HealthBadge::License]);
1730 }
1731
1732 #[test]
1733 fn maturity_is_required() {
1734 assert_error_contains(
1735 &norm("---\nstatus: approved\n---\n"),
1736 "maturity is required",
1737 );
1738 }
1739
1740 #[test]
1741 fn expands_targets_from_ecosystems() {
1742 let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [python]\n---\n").contract;
1743 assert_eq!(c.targets.len(), 1);
1744 assert_eq!(c.targets[0].ecosystem, Ecosystem::Python);
1745 assert_eq!(c.targets[0].package, None);
1746 assert_eq!(c.targets[0].registry, Registry::Pypi);
1747 assert_eq!(c.targets[0].adapter, Adapter::GhActionPypiPublish);
1748 }
1749
1750 #[test]
1756 fn explicit_empty_targets_is_honored_not_expanded() {
1757 let n =
1758 norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n---\n");
1759 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1760 let c = n.contract;
1761 assert_eq!(c.ecosystems, vec![Ecosystem::Rust]);
1763 assert!(
1765 c.targets.is_empty(),
1766 "explicit targets:[] must stay empty, got {:?}",
1767 c.targets
1768 );
1769 }
1770
1771 #[test]
1774 fn omitted_targets_still_expands_to_ecosystem_default() {
1775 let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract;
1776 assert_eq!(c.targets.len(), 1);
1777 assert_eq!(c.targets[0].ecosystem, Ecosystem::Rust);
1778 assert_eq!(c.targets[0].registry, Registry::CratesIo);
1779 assert_eq!(c.targets[0].adapter, Adapter::CargoPublish);
1780 }
1781
1782 #[test]
1786 fn null_targets_expands_like_omitted() {
1787 let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n---\n")
1788 .contract;
1789 assert_eq!(c.targets.len(), 1);
1790 assert_eq!(c.targets[0].registry, Registry::CratesIo);
1791 }
1792
1793 #[test]
1799 fn empty_targets_round_trips_through_canonical_json() {
1800 let n =
1801 norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n---\n");
1802 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1803 let json = serde_json::to_value(&n.contract).unwrap();
1804 assert_eq!(json["targets"], serde_json::json!([]));
1806
1807 let targets_yaml = serde_yaml::to_string(&json["targets"]).unwrap();
1810 let refed = format!(
1811 "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: {}\n---\n",
1812 targets_yaml.trim()
1813 );
1814 let n2 = norm(&refed);
1815 assert!(n2.is_valid(), "errors: {:?}", n2.problems.errors);
1816 assert_eq!(n2.contract.targets, n.contract.targets);
1817 assert!(n2.contract.targets.is_empty());
1818 }
1819
1820 #[test]
1825 fn explicit_empty_targets_skips_registry_license_floor() {
1826 let n = norm(
1827 "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n\
1828 license: not-a-real-spdx-id\n---\n",
1829 );
1830 assert!(!n.is_valid());
1832 assert!(
1835 !n.problems
1836 .errors
1837 .iter()
1838 .any(|e| e.contains("floor: a target has a registry")),
1839 "registry-license floor fired despite empty targets: {:?}",
1840 n.problems.errors
1841 );
1842 }
1843
1844 #[test]
1849 fn registry_badge_with_explicit_empty_targets_fails() {
1850 let n = norm(
1851 "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n\
1852 health_badges: [registry, license]\n---\n",
1853 );
1854 assert_error_contains(&n, "health_badge 'registry' has no producer");
1855 }
1856
1857 #[test]
1862 fn explicit_empty_targets_with_no_ecosystems() {
1863 let n = norm("---\nstatus: approved\nmaturity: mvp\ntargets: []\n---\n");
1864 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1865 assert!(n.contract.targets.is_empty());
1866 assert_eq!(
1867 n.contract.health_badges,
1868 vec![HealthBadge::Ci, HealthBadge::License]
1869 );
1870 }
1871
1872 #[test]
1873 fn node_monorepo_adapter_is_changesets() {
1874 let text = "---\nstatus: approved\nmaturity: production\necosystems: [node]\n\
1875 release:\n model: gated\n layout: monorepo\n---\n";
1876 let c = norm(text).contract;
1877 assert_eq!(c.targets[0].adapter, Adapter::Changesets);
1878 }
1879
1880 #[test]
1881 fn ecosystems_dedup_to_canonical_order() {
1882 let c =
1883 norm("---\nstatus: approved\nmaturity: mvp\necosystems: [python, rust, python]\n---\n")
1884 .contract;
1885 assert_eq!(c.ecosystems, vec![Ecosystem::Rust, Ecosystem::Python]);
1886 }
1887
1888 #[test]
1889 fn calver_splits_base_and_pattern() {
1890 let c = norm(
1891 "---\nstatus: approved\nmaturity: mvp\nversioning: \"calver:YYYY.MM.MICRO\"\n---\n",
1892 )
1893 .contract;
1894 assert_eq!(c.versioning, VersioningBase::Calver);
1895 assert_eq!(c.versioning_pattern.as_deref(), Some("YYYY.MM.MICRO"));
1896 }
1897
1898 #[test]
1899 fn bare_calver_is_rejected() {
1900 assert_error_contains(
1901 &norm("---\nstatus: approved\nmaturity: mvp\nversioning: calver\n---\n"),
1902 "must carry its pattern",
1903 );
1904 }
1905
1906 #[test]
1907 fn floor_auto_on_spike() {
1908 let text = "---\nstatus: approved\nmaturity: spike\n\
1909 release:\n model: auto\n layout: single\nhealth_badges: [license]\n---\n";
1910 assert_error_contains(&norm(text), "release.model 'auto' is not allowed");
1911 }
1912
1913 #[test]
1914 fn floor_slsa_l3_production_only() {
1915 assert_error_contains(
1916 &norm("---\nstatus: approved\nmaturity: mvp\nprovenance_level: slsa-l3\n---\n"),
1917 "slsa-l3' is production-only",
1918 );
1919 }
1920
1921 #[test]
1922 fn floor_registry_requires_valid_license() {
1923 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
1924 license: Proprietary-Acme\nhealth_badges: [ci, registry, license]\n---\n";
1925 let n = norm(text);
1926 assert_error_contains(&n, "not a valid SPDX expression");
1928 assert!(n
1929 .problems
1930 .errors
1931 .iter()
1932 .any(|e| e.contains("floor: a target has a registry")));
1933 }
1934
1935 #[test]
1936 fn floor_badge_without_producer() {
1937 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [python]\n\
1938 health_badges: [ci, coverage]\n---\n";
1939 assert_error_contains(&norm(text), "health_badge 'coverage' has no producer");
1940 }
1941
1942 #[test]
1943 fn floor_schema_version_too_new() {
1944 assert_error_contains(
1945 &norm("---\nschema_version: 99\nstatus: approved\nmaturity: mvp\n---\n"),
1946 "exceeds what this tool knows",
1947 );
1948 }
1949
1950 #[test]
1951 fn floor_fragment_dir_escape() {
1952 let text = "---\nstatus: approved\nmaturity: mvp\n\
1953 changelog:\n mode: fragment\n source: manual\n fragment_dir: /etc\n---\n";
1954 assert_error_contains(&norm(text), "must be a relative path inside the repo");
1955 }
1956
1957 #[test]
1958 fn floor_fragment_dir_escape_relative_root() {
1959 let text = "---\nstatus: approved\nmaturity: mvp\n\
1964 changelog:\n mode: fragment\n source: manual\n fragment_dir: ../etc\n---\n";
1965 let n = normalize_str(text, Path::new("."), &FakeFs::empty());
1966 assert_error_contains(&n, "must be a relative path inside the repo");
1967 }
1968
1969 #[test]
1970 fn path_inside_repo_verdicts() {
1971 assert!(path_inside_repo("changelog/fragments"));
1973 assert!(path_inside_repo("./changelog/fragments"));
1974 assert!(path_inside_repo("a/../fragments"));
1975 assert!(path_inside_repo("")); assert!(!path_inside_repo("/etc"));
1978 assert!(!path_inside_repo("../etc"));
1979 assert!(!path_inside_repo("a/../../etc"));
1980 }
1981
1982 #[test]
1983 fn unknown_fields_preserved_and_warned() {
1984 let text =
1985 "---\nstatus: approved\nmaturity: mvp\nroadmap_url: https://example.com/x\n---\n";
1986 let n = norm(text);
1987 assert!(n.is_valid());
1988 assert_eq!(
1989 n.contract
1990 .extra_fields
1991 .get("roadmap_url")
1992 .and_then(|v| v.as_str()),
1993 Some("https://example.com/x")
1994 );
1995 assert!(n
1996 .problems
1997 .warnings
1998 .iter()
1999 .any(|w| w.contains("roadmap_url") && w.contains("forward-compat")));
2000 }
2001
2002 #[test]
2003 fn duplicate_key_is_rejected() {
2004 assert_error_contains(
2005 &norm("---\nstatus: approved\nstatus: draft\nmaturity: mvp\n---\n"),
2006 "invalid YAML",
2007 );
2008 }
2009
2010 #[test]
2011 fn missing_frontmatter_is_rejected() {
2012 assert_error_contains(&norm("no frontmatter here\n"), "frontmatter missing");
2013 }
2014
2015 #[test]
2016 fn unclosed_frontmatter_is_rejected() {
2017 assert_error_contains(&norm("---\nstatus: approved\n"), "frontmatter not closed");
2018 }
2019
2020 #[test]
2021 fn invalid_enum_records_error_and_continues() {
2022 let n = norm("---\nstatus: bogus\nmaturity: alsobogus\n---\n");
2024 assert!(n.problems.errors.iter().any(|e| e.contains("status")));
2025 assert!(n.problems.errors.iter().any(|e| e.contains("maturity")));
2026 }
2027
2028 #[test]
2029 fn fragment_dir_present_suppresses_advisory() {
2030 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
2031 changelog:\n mode: fragment\n source: manual\n---\n";
2032 let fs = FakeFs::with_dirs(["/repo/changelog/fragments"]);
2034 let n = norm_with(text, &fs);
2035 assert!(n.is_valid());
2036 assert!(
2037 !n.problems
2038 .warnings
2039 .iter()
2040 .any(|w| w.contains("does not exist yet")),
2041 "advisory should be suppressed when the dir exists: {:?}",
2042 n.problems.warnings
2043 );
2044 }
2045
2046 #[test]
2047 fn serializes_to_schema_v4_shape() {
2048 let json =
2049 serde_json::to_value(&norm("---\nstatus: approved\nmaturity: mvp\n---\n").contract)
2050 .unwrap();
2051 for key in [
2053 "schema_version",
2054 "status",
2055 "maturity",
2056 "ecosystems",
2057 "targets",
2058 "distributions",
2059 "versioning",
2060 "versioning_pattern",
2061 "changelog",
2062 "conventional_commits",
2063 "release",
2064 "contribution_provenance",
2065 "provenance_level",
2066 "dependency_bot",
2067 "health_badges",
2068 "license",
2069 "docs_site",
2070 "warnings",
2071 ] {
2072 assert!(json.get(key).is_some(), "missing §4 key {key}");
2073 }
2074 assert!(json["versioning_pattern"].is_null());
2075 assert_eq!(json["distributions"], serde_json::json!([]));
2078 assert!(
2083 json.get("extra_fields").is_none(),
2084 "empty extra_fields must be absent, got {:?}",
2085 json.get("extra_fields")
2086 );
2087 }
2088
2089 #[test]
2094 fn empty_extra_fields_absent_populated_present() {
2095 let empty = serde_json::to_value(
2097 norm(
2098 "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2099 distribution:\n adapter: cargo-dist\n---\n",
2100 )
2101 .contract,
2102 )
2103 .unwrap();
2104 assert!(
2105 empty.get("extra_fields").is_none(),
2106 "empty top-level extra_fields must be absent"
2107 );
2108 assert!(
2109 empty["distributions"][0].get("extra_fields").is_none(),
2110 "empty nested extra_fields must be absent"
2111 );
2112
2113 let populated = serde_json::to_value(
2116 norm(
2117 "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2118 roadmap_url: https://example.com/roadmap\n\
2119 distribution:\n adapter: cargo-dist\n future_x: 1\n---\n",
2120 )
2121 .contract,
2122 )
2123 .unwrap();
2124 assert_eq!(
2125 populated["extra_fields"]["roadmap_url"],
2126 "https://example.com/roadmap"
2127 );
2128 assert_eq!(populated["distributions"][0]["extra_fields"]["future_x"], 1);
2129 }
2130
2131 #[test]
2136 fn registry_only_contract_has_no_distribution() {
2137 let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract;
2138 assert!(c.distributions.is_empty());
2139 assert_eq!(c.targets.len(), 1);
2140 assert_eq!(c.targets[0].registry, Registry::CratesIo);
2141 }
2142
2143 #[test]
2146 fn cargo_dist_distribution_coexists_with_registry() {
2147 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2148 targets:\n - {ecosystem: rust, package: issuectl, registry: crates.io, adapter: cargo-publish}\n\
2149 distribution:\n adapter: cargo-dist\n installers: [shell, homebrew]\n \
2150 homebrew_tap: jarimustonen/homebrew-issuectl\n---\n";
2151 let n = norm(text);
2152 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2153 let c = n.contract;
2154 assert_eq!(c.targets.len(), 1);
2156 assert_eq!(c.targets[0].registry, Registry::CratesIo);
2157 assert_eq!(c.targets[0].adapter, Adapter::CargoPublish);
2158 let d = c
2160 .distributions
2161 .into_iter()
2162 .next()
2163 .expect("distribution present");
2164 assert_eq!(d.adapter, DistributionAdapter::CargoDist);
2165 assert!(d.gh_releases); assert_eq!(d.installers, vec![Installer::Shell, Installer::Homebrew]);
2167 assert_eq!(
2168 d.homebrew_tap.as_deref(),
2169 Some("jarimustonen/homebrew-issuectl")
2170 );
2171 }
2172
2173 #[test]
2175 fn distribution_json_round_trip_shape() {
2176 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2177 distribution:\n adapter: cargo-dist\n gh_releases: true\n \
2178 installers: [shell, homebrew]\n homebrew_tap: jarimustonen/homebrew-issuectl\n---\n";
2179 let json = serde_json::to_value(&norm(text).contract).unwrap();
2180 let d = &json["distributions"][0];
2181 assert_eq!(d["adapter"], "cargo-dist");
2182 assert_eq!(d["gh_releases"], true);
2183 assert_eq!(d["installers"], serde_json::json!(["shell", "homebrew"]));
2184 assert_eq!(d["homebrew_tap"], "jarimustonen/homebrew-issuectl");
2185 assert!(d["package"].is_null());
2187 }
2188
2189 fn hb_case(tap: bool, installer: bool, tap_target: bool) -> String {
2197 let mut fm = String::from(
2198 "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
2199 - {ecosystem: rust, package: ossctl, registry: crates.io, adapter: cargo-publish}\n",
2200 );
2201 if tap_target {
2202 fm.push_str(
2203 " - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: homebrew-tap}\n",
2204 );
2205 }
2206 if installer || tap {
2209 fm.push_str("distribution:\n adapter: cargo-dist\n");
2210 if installer {
2211 fm.push_str(" installers: [homebrew]\n");
2212 } else {
2213 fm.push_str(" installers: [shell]\n");
2214 }
2215 if tap {
2216 fm.push_str(" homebrew_tap: owner/tap\n");
2217 }
2218 }
2219 fm.push_str("---\n");
2220 fm
2221 }
2222
2223 #[test]
2227 fn homebrew_truth_table_all_eight_rows() {
2228 let rows = [
2230 (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), ];
2239 for (tap, installer, tap_target, expect_valid) in rows {
2240 let n = norm(&hb_case(tap, installer, tap_target));
2241 assert_eq!(
2242 n.is_valid(),
2243 expect_valid,
2244 "row (tap={tap}, installer={installer}, tap_target={tap_target}) expected \
2245 valid={expect_valid}; errors were {:?}",
2246 n.problems.errors
2247 );
2248 }
2249 }
2250
2251 #[test]
2254 fn homebrew_tap_target_without_tap_is_a_floor() {
2255 assert_error_contains(
2256 &norm(&hb_case(false, false, true)),
2257 "generates a formula but no distribution sets homebrew_tap",
2258 );
2259 }
2260
2261 #[test]
2264 fn homebrew_double_publish_is_a_floor() {
2265 assert_error_contains(
2266 &norm(&hb_case(true, true, true)),
2267 "they would collide; keep exactly one homebrew formula producer",
2268 );
2269 }
2270
2271 #[test]
2274 fn homebrew_dead_tap_is_an_advisory_not_a_floor() {
2275 let n = norm(&hb_case(true, false, false));
2276 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2277 assert!(
2278 n.problems
2279 .warnings
2280 .iter()
2281 .any(|w| w.contains("the tap will never be updated")),
2282 "expected dead-tap advisory, warnings were {:?}",
2283 n.problems.warnings
2284 );
2285 }
2286
2287 #[test]
2290 fn homebrew_tap_target_with_tap_is_clean() {
2291 let n = norm(&hb_case(true, false, true));
2292 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2293 assert!(
2294 !n.problems
2295 .warnings
2296 .iter()
2297 .any(|w| w.contains("the tap will never be updated")),
2298 "unexpected dead-tap advisory: {:?}",
2299 n.problems.warnings
2300 );
2301 }
2302
2303 #[test]
2307 fn homebrew_registry_requires_homebrew_adapter() {
2308 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
2309 - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: manual}\n\
2310 distribution:\n adapter: cargo-dist\n homebrew_tap: owner/tap\n---\n";
2311 assert_error_contains(
2312 &norm(text),
2313 "requires adapter 'homebrew-tap' (personal tap) or 'homebrew-core'",
2314 );
2315 }
2316
2317 #[test]
2321 fn homebrew_core_target_needs_no_tap() {
2322 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
2323 - {ecosystem: rust, package: ossctl, registry: crates.io, adapter: cargo-publish}\n \
2324 - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: homebrew-core}\n---\n";
2325 let n = norm(text);
2326 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2327 }
2328
2329 #[test]
2337 fn homebrew_registry_omitted_adapter_is_a_floor() {
2338 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
2339 - {ecosystem: rust, package: ossctl, registry: homebrew}\n---\n";
2340 assert_error_contains(
2341 &norm(text),
2342 "requires adapter 'homebrew-tap' (personal tap) or 'homebrew-core'",
2343 );
2344 }
2345
2346 #[test]
2350 fn homebrew_tap_target_satisfied_via_plural_distributions() {
2351 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
2352 - {ecosystem: rust, package: ossctl, registry: crates.io, adapter: cargo-publish}\n \
2353 - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: homebrew-tap}\n\
2354 distributions:\n \
2355 - {package: ossctl, adapter: cargo-dist, installers: [shell], homebrew_tap: owner/tap}\n---\n";
2356 let n = norm(text);
2357 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2358 assert!(
2359 !n.problems
2360 .warnings
2361 .iter()
2362 .any(|w| w.contains("the tap will never be updated")),
2363 "unexpected dead-tap advisory: {:?}",
2364 n.problems.warnings
2365 );
2366 }
2367
2368 #[test]
2374 fn singular_distribution_parses_as_one_element_list() {
2375 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2376 distribution:\n adapter: cargo-dist\n---\n";
2377 let c = norm(text).contract;
2378 assert_eq!(c.distributions.len(), 1);
2379 assert_eq!(c.distributions[0].package, None);
2380 assert_eq!(c.distributions[0].adapter, DistributionAdapter::CargoDist);
2381 }
2382
2383 #[test]
2387 fn plural_distributions_parse_with_per_package_association() {
2388 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2389 targets:\n - {ecosystem: rust, package: alpha, registry: crates.io}\n \
2390 - {ecosystem: rust, package: beta, registry: crates.io}\n\
2391 distributions:\n - {package: alpha, adapter: cargo-dist, installers: [shell]}\n \
2392 - {package: beta, adapter: cargo-dist, installers: [homebrew], homebrew_tap: owner/tap}\n---\n";
2393 let n = norm(text);
2394 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2395 let d = n.contract.distributions;
2396 assert_eq!(d.len(), 2);
2397 assert_eq!(d[0].package.as_deref(), Some("alpha"));
2398 assert_eq!(d[0].installers, vec![Installer::Shell]);
2399 assert_eq!(d[1].package.as_deref(), Some("beta"));
2400 assert_eq!(d[1].homebrew_tap.as_deref(), Some("owner/tap"));
2401 }
2402
2403 #[test]
2407 fn distributions_canonical_json_round_trip() {
2408 for text in [
2409 "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2410 distribution:\n adapter: cargo-dist\n installers: [shell]\n---\n",
2411 "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2412 targets:\n - {ecosystem: rust, package: a, registry: crates.io}\n \
2413 - {ecosystem: rust, package: b, registry: crates.io}\n\
2414 distributions:\n - {package: a, adapter: cargo-dist}\n \
2415 - {package: b, adapter: goreleaser}\n---\n",
2416 ] {
2417 let first = norm(text).contract;
2418 assert!(!first.distributions.is_empty());
2419 let json = serde_json::to_value(&first).unwrap();
2421 let refed = format!("---\n{}---\n", serde_yaml::to_string(&json).unwrap());
2422 let second = norm(&refed).contract;
2423 assert_eq!(
2424 first.distributions, second.distributions,
2425 "round-trip drift for: {text}"
2426 );
2427 }
2428 }
2429
2430 #[test]
2432 fn both_distribution_keys_is_an_error() {
2433 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2434 distribution:\n adapter: cargo-dist\n\
2435 distributions:\n - {package: a, adapter: cargo-dist}\n---\n";
2436 assert_error_contains(&norm(text), "not both");
2437 }
2438
2439 #[test]
2442 fn multi_distribution_missing_package_is_a_floor_error() {
2443 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2444 targets:\n - {ecosystem: rust, package: a, registry: crates.io}\n\
2445 distributions:\n - {package: a, adapter: cargo-dist}\n \
2446 - {adapter: cargo-dist}\n---\n";
2447 assert_error_contains(&norm(text), "must name the package it builds");
2448 }
2449
2450 #[test]
2452 fn multi_distribution_duplicate_package_is_a_floor_error() {
2453 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2454 distributions:\n - {package: dup, adapter: cargo-dist}\n \
2455 - {package: dup, adapter: goreleaser}\n---\n";
2456 assert_error_contains(&norm(text), "distinct package");
2457 }
2458
2459 #[test]
2463 fn v1_document_is_relabeled_to_current_schema_version_on_emit() {
2464 let text = "---\nschema_version: 1\nstatus: approved\nmaturity: production\n\
2465 ecosystems: [rust]\n\
2466 distribution:\n adapter: cargo-dist\n---\n";
2467 let n = norm(text);
2468 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2469 assert_eq!(n.contract.schema_version, 2);
2471 let json = serde_json::to_value(&n.contract).unwrap();
2472 assert_eq!(json["schema_version"], 2);
2473 assert_eq!(json["distributions"].as_array().map(Vec::len), Some(1));
2475 }
2476
2477 #[test]
2481 fn distribution_package_is_trimmed() {
2482 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2483 distributions:\n - {package: ' alpha ', adapter: cargo-dist}\n \
2484 - {package: alpha, adapter: goreleaser}\n---\n";
2485 assert_error_contains(&norm(text), "distinct package");
2487 }
2488
2489 #[test]
2492 fn single_distribution_may_carry_a_package() {
2493 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2494 targets:\n - {ecosystem: rust, package: solo, registry: crates.io}\n\
2495 distributions:\n - {package: solo, adapter: cargo-dist}\n---\n";
2496 let n = norm(text);
2497 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2498 assert_eq!(n.contract.distributions[0].package.as_deref(), Some("solo"));
2499 }
2500
2501 #[test]
2506 fn distribution_unknown_subkey_preserved_and_warned() {
2507 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2508 distribution:\n adapter: cargo-dist\n gh_releases: true\n \
2509 future_signing: {enabled: true, kms_key: alias/oss}\n---\n";
2510 let n = norm(text);
2511 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2512 let d = n
2513 .contract
2514 .clone()
2515 .distributions
2516 .into_iter()
2517 .next()
2518 .expect("distribution present");
2519 assert_eq!(
2521 d.extra_fields
2522 .get("future_signing")
2523 .and_then(|v| v.get("kms_key"))
2524 .and_then(|v| v.as_str()),
2525 Some("alias/oss")
2526 );
2527 assert_eq!(d.adapter, DistributionAdapter::CargoDist);
2529 assert!(d.gh_releases);
2530 let json = serde_json::to_value(&n.contract).unwrap();
2532 assert_eq!(
2533 json["distributions"][0]["extra_fields"]["future_signing"]["enabled"],
2534 serde_json::json!(true)
2535 );
2536 assert!(
2538 n.problems.warnings.iter().any(|w| {
2539 w.contains("unknown distribution field(s) preserved")
2540 && w.contains("future_signing")
2541 }),
2542 "expected a scoped forward-compat warning: {:?}",
2543 n.problems.warnings
2544 );
2545 }
2546
2547 #[test]
2553 fn msi_installer_without_windows_platform_warns() {
2554 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2555 distribution:\n adapter: cargo-dist\n installers: [msi]\n \
2556 platforms: [x86_64-apple-darwin, x86_64-unknown-linux-musl]\n---\n";
2557 let n = norm(text);
2558 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2559 assert!(
2560 n.problems
2561 .warnings
2562 .iter()
2563 .any(|w| w.contains("includes 'msi'") && w.contains("no Windows")),
2564 "expected an msi/Windows cross-check warning: {:?}",
2565 n.problems.warnings
2566 );
2567 }
2568
2569 #[test]
2571 fn msi_installer_with_windows_platform_no_warning() {
2572 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2573 distribution:\n adapter: cargo-dist\n installers: [msi]\n \
2574 platforms: [x86_64-pc-windows-msvc]\n---\n";
2575 let n = norm(text);
2576 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2577 assert!(
2578 !n.problems.warnings.iter().any(|w| w.contains("'msi'")),
2579 "unexpected msi cross-check warning: {:?}",
2580 n.problems.warnings
2581 );
2582 }
2583
2584 #[test]
2589 fn homebrew_installer_without_darwin_or_linux_warns() {
2590 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2591 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
2592 homebrew_tap: jarimustonen/homebrew-issuectl\n \
2593 platforms: [x86_64-pc-windows-msvc]\n---\n";
2594 let n = norm(text);
2595 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2596 assert!(
2597 n.problems
2598 .warnings
2599 .iter()
2600 .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
2601 "expected a homebrew/(macOS|Linux) cross-check warning: {:?}",
2602 n.problems.warnings
2603 );
2604 }
2605
2606 #[test]
2610 fn homebrew_installer_with_linux_only_no_warning() {
2611 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2612 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
2613 homebrew_tap: jarimustonen/homebrew-issuectl\n \
2614 platforms: [x86_64-unknown-linux-musl]\n---\n";
2615 let n = norm(text);
2616 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2617 assert!(
2618 !n.problems.warnings.iter().any(|w| w.contains("'homebrew'")),
2619 "unexpected homebrew cross-check warning for a Linux-only set: {:?}",
2620 n.problems.warnings
2621 );
2622 }
2623
2624 #[test]
2627 fn npm_and_shell_installers_never_cross_check_warn() {
2628 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust, node]\n\
2629 distribution:\n adapter: cargo-dist\n installers: [shell, npm]\n \
2630 platforms: [x86_64-apple-darwin]\n---\n";
2631 let n = norm(text);
2632 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2633 assert!(
2634 !n.problems
2635 .warnings
2636 .iter()
2637 .any(|w| w.contains("nothing to install")),
2638 "OS-agnostic installers must not cross-check warn: {:?}",
2639 n.problems.warnings
2640 );
2641 }
2642
2643 #[test]
2646 fn coherent_installer_platform_set_no_warning() {
2647 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2648 distribution:\n adapter: cargo-dist\n installers: [homebrew, msi]\n \
2649 homebrew_tap: jarimustonen/homebrew-issuectl\n \
2650 platforms: [aarch64-apple-darwin, x86_64-pc-windows-msvc]\n---\n";
2651 let n = norm(text);
2652 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2653 assert!(
2654 !n.problems
2655 .warnings
2656 .iter()
2657 .any(|w| w.contains("nothing to install")),
2658 "coherent set must not warn: {:?}",
2659 n.problems.warnings
2660 );
2661 }
2662
2663 #[test]
2667 fn ossctl_own_contract_shape_no_cross_check_warning() {
2668 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2669 distribution:\n adapter: cargo-dist\n installers: [shell, powershell]\n \
2670 platforms: [aarch64-apple-darwin, x86_64-apple-darwin, \
2671 x86_64-unknown-linux-musl, aarch64-unknown-linux-musl, \
2672 x86_64-pc-windows-msvc]\n---\n";
2673 let n = norm(text);
2674 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2675 assert!(
2676 !n.problems
2677 .warnings
2678 .iter()
2679 .any(|w| w.contains("nothing to install")),
2680 "ossctl's own shape must not cross-check warn: {:?}",
2681 n.problems.warnings
2682 );
2683 }
2684
2685 #[test]
2690 fn msi_installer_with_defaulted_platforms_warns() {
2691 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2692 distribution:\n adapter: cargo-dist\n installers: [msi]\n---\n";
2693 let n = norm(text);
2694 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2695 assert!(
2696 n.problems
2697 .warnings
2698 .iter()
2699 .any(|w| w.contains("includes 'msi'") && w.contains("no Windows")),
2700 "expected an msi/Windows warning against the defaulted platform set: {:?}",
2701 n.problems.warnings
2702 );
2703 }
2704
2705 #[test]
2708 fn msi_installer_with_windows_gnu_no_warning() {
2709 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2710 distribution:\n adapter: cargo-dist\n installers: [msi]\n \
2711 platforms: [x86_64-pc-windows-gnu]\n---\n";
2712 let n = norm(text);
2713 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2714 assert!(
2715 !n.problems.warnings.iter().any(|w| w.contains("'msi'")),
2716 "windows-gnu must satisfy msi: {:?}",
2717 n.problems.warnings
2718 );
2719 }
2720
2721 #[test]
2727 fn homebrew_installer_with_android_only_warns() {
2728 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2729 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
2730 homebrew_tap: jarimustonen/homebrew-issuectl\n \
2731 platforms: [aarch64-linux-android]\n---\n";
2732 let n = norm(text);
2733 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2734 assert!(
2735 n.problems
2736 .warnings
2737 .iter()
2738 .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
2739 "Android-only must strand a homebrew installer: {:?}",
2740 n.problems.warnings
2741 );
2742 }
2743
2744 #[test]
2748 fn homebrew_installer_with_apple_ios_only_warns() {
2749 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2750 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
2751 homebrew_tap: jarimustonen/homebrew-issuectl\n \
2752 platforms: [aarch64-apple-ios]\n---\n";
2753 let n = norm(text);
2754 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2755 assert!(
2756 n.problems
2757 .warnings
2758 .iter()
2759 .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
2760 "apple-ios must not satisfy homebrew's macOS need: {:?}",
2761 n.problems.warnings
2762 );
2763 }
2764
2765 #[test]
2768 fn homebrew_installer_with_macos_only_no_warning() {
2769 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2770 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
2771 homebrew_tap: jarimustonen/homebrew-issuectl\n \
2772 platforms: [aarch64-apple-darwin]\n---\n";
2773 let n = norm(text);
2774 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2775 assert!(
2776 !n.problems.warnings.iter().any(|w| w.contains("'homebrew'")),
2777 "macOS-only must satisfy homebrew: {:?}",
2778 n.problems.warnings
2779 );
2780 }
2781
2782 #[test]
2787 fn both_installers_stranded_warn_once_each() {
2788 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2789 distribution:\n adapter: cargo-dist\n installers: [homebrew, msi]\n \
2790 homebrew_tap: jarimustonen/homebrew-issuectl\n \
2791 platforms: [wasm32-unknown-unknown]\n---\n";
2792 let n = norm(text);
2793 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2794 let msi = n
2795 .problems
2796 .warnings
2797 .iter()
2798 .filter(|w| w.contains("includes 'msi'"))
2799 .count();
2800 let brew = n
2801 .problems
2802 .warnings
2803 .iter()
2804 .filter(|w| w.contains("includes 'homebrew'"))
2805 .count();
2806 assert_eq!((msi, brew), (1, 1), "warnings: {:?}", n.problems.warnings);
2807 }
2808
2809 #[test]
2815 fn malformed_platform_triple_gates_off_cross_check() {
2816 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2817 distribution:\n adapter: cargo-dist\n installers: [msi]\n \
2818 platforms: [x86_64-PC-WINDOWS-MSVC]\n---\n";
2819 let n = norm(text);
2820 assert!(!n.is_valid(), "expected a malformed-triple error");
2822 assert!(
2824 !n.problems
2825 .warnings
2826 .iter()
2827 .any(|w| w.contains("nothing to install")),
2828 "cross-check must be gated off while platforms has errors: {:?}",
2829 n.problems.warnings
2830 );
2831 }
2832
2833 #[test]
2839 fn distribution_all_known_keys_has_empty_extra_fields() {
2840 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2841 distribution:\n adapter: cargo-dist\n gh_releases: true\n \
2842 installers: [shell, homebrew]\n homebrew_tap: owner/tap\n \
2843 platforms: [x86_64-unknown-linux-musl]\n---\n";
2844 let n = norm(text);
2845 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2846 let d = n
2847 .contract
2848 .distributions
2849 .into_iter()
2850 .next()
2851 .expect("distribution present");
2852 assert!(d.extra_fields.is_empty());
2853 assert!(
2854 !n.problems
2855 .warnings
2856 .iter()
2857 .any(|w| w.contains("unknown distribution field(s) preserved")),
2858 "no forward-compat warning for an all-known-keys block: {:?}",
2859 n.problems.warnings
2860 );
2861 }
2862
2863 #[test]
2868 fn distribution_and_top_level_extra_fields_coexist() {
2869 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2870 roadmap_url: https://example.com/x\n\
2871 distribution:\n adapter: cargo-dist\n future_x: 1\n---\n";
2872 let n = norm(text);
2873 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2874 let c = n.contract.clone();
2875 assert!(c.extra_fields.contains_key("roadmap_url"));
2876 let d = c
2877 .distributions
2878 .into_iter()
2879 .next()
2880 .expect("distribution present");
2881 assert_eq!(d.extra_fields.get("future_x"), Some(&serde_json::json!(1)));
2882 let fc: Vec<&String> = n
2885 .problems
2886 .warnings
2887 .iter()
2888 .filter(|w| w.contains("forward-compat") && w.contains("schema_version 2"))
2889 .collect();
2890 assert_eq!(fc.len(), 2, "expected two versioned warnings: {fc:?}");
2891 }
2892
2893 #[test]
2901 fn non_string_top_level_key_rejected() {
2902 let n = norm("---\nstatus: approved\nmaturity: mvp\n42: answer\n---\n");
2903 assert_error_contains(&n, "must be a string");
2904 assert!(
2905 n.problems.errors.iter().any(|e| e.contains("42")),
2906 "error should name the offending key: {:?}",
2907 n.problems.errors
2908 );
2909 }
2910
2911 #[test]
2914 fn non_string_distribution_key_rejected() {
2915 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2916 distribution:\n adapter: cargo-dist\n true: enabled\n---\n";
2917 let n = norm(text);
2918 assert_error_contains(&n, "must be a string");
2919 assert!(
2920 n.problems
2921 .errors
2922 .iter()
2923 .any(|e| e.contains("distribution field key")),
2924 "error should be scoped to the distribution block: {:?}",
2925 n.problems.errors
2926 );
2927 }
2928
2929 #[test]
2933 fn known_key_not_double_captured() {
2934 let n = norm("---\nstatus: approved\nmaturity: production\necosystems: [rust]\n---\n");
2935 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2936 assert!(!n.contract.extra_fields.contains_key("ecosystems"));
2937 assert!(!n.contract.extra_fields.contains_key("status"));
2938 assert!(n.contract.extra_fields.is_empty());
2939 }
2940
2941 #[test]
2947 fn reserved_extra_fields_block_merged_warnings_ignored() {
2948 let text = "---\nstatus: approved\nmaturity: mvp\n\
2949 extra_fields:\n foo: 1\nwarnings:\n - a prior note\n---\n";
2950 let n = norm(text);
2951 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2952 assert_eq!(
2954 n.contract.extra_fields.get("foo"),
2955 Some(&serde_json::json!(1))
2956 );
2957 assert!(!n.contract.extra_fields.contains_key("extra_fields"));
2958 assert!(
2960 !n.contract
2961 .warnings
2962 .iter()
2963 .any(|w| w.contains("a prior note")),
2964 "input warnings must be regenerated, not preserved: {:?}",
2965 n.contract.warnings
2966 );
2967 }
2968
2969 #[test]
2972 fn distribution_reserved_extra_fields_block_merged() {
2973 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2974 distribution:\n adapter: cargo-dist\n extra_fields:\n foo: 1\n---\n";
2975 let n = norm(text);
2976 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2977 let d = n
2978 .contract
2979 .distributions
2980 .into_iter()
2981 .next()
2982 .expect("distribution present");
2983 assert_eq!(d.extra_fields.get("foo"), Some(&serde_json::json!(1)));
2984 assert!(!d.extra_fields.contains_key("extra_fields"));
2985 }
2986
2987 #[test]
2991 fn extra_fields_round_trip_is_idempotent() {
2992 let first = norm("---\nstatus: approved\nmaturity: mvp\nroadmap_url: https://x/y\n---\n");
2993 assert!(first.is_valid(), "errors: {:?}", first.problems.errors);
2994 assert_eq!(first.contract.extra_fields.len(), 1);
2995 let inner = serde_yaml::to_string(&first.contract.extra_fields).unwrap();
2997 let indented = inner
2998 .lines()
2999 .map(|l| format!(" {l}"))
3000 .collect::<Vec<_>>()
3001 .join("\n");
3002 let text =
3003 format!("---\nstatus: approved\nmaturity: mvp\nextra_fields:\n{indented}\n---\n");
3004 let second = norm(&text);
3005 assert!(second.is_valid(), "errors: {:?}", second.problems.errors);
3006 assert_eq!(second.contract.extra_fields, first.contract.extra_fields);
3007 }
3008
3009 #[test]
3013 fn extra_fields_block_sibling_collision_is_error() {
3014 let text = "---\nstatus: approved\nmaturity: mvp\n\
3015 extra_fields:\n dup: 1\ndup: 2\n---\n";
3016 let n = norm(text);
3017 assert_error_contains(&n, "appears both");
3018 }
3019
3020 #[test]
3023 fn reserved_extra_fields_non_mapping_is_error() {
3024 let n = norm("---\nstatus: approved\nmaturity: mvp\nextra_fields: nonsense\n---\n");
3025 assert_error_contains(&n, "must be a mapping");
3026 }
3027
3028 #[test]
3034 fn top_level_all_known_keys_has_empty_extra_fields() {
3035 let text = "---\nschema_version: 1\nstatus: approved\nmaturity: production\n\
3036 ecosystems: [rust]\n\
3037 targets:\n - {ecosystem: rust, package: x, registry: crates.io, adapter: cargo-publish}\n\
3038 distribution:\n adapter: cargo-dist\n\
3039 versioning: semver\n\
3040 changelog:\n mode: curated\n source: manual\n\
3041 conventional_commits: false\n\
3042 release:\n model: gated\n layout: single\n\
3043 contribution_provenance: none\n\
3044 provenance_level: none\n\
3045 dependency_bot: dependabot\n\
3046 health_badges: [ci, registry, license]\n\
3047 license: MIT\n\
3048 docs_site: none\n---\n";
3049 let n = norm(text);
3050 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3051 assert!(
3052 n.contract.extra_fields.is_empty(),
3053 "unexpected extra_fields (KNOWN_KEYS drift?): {:?}",
3054 n.contract.extra_fields
3055 );
3056 assert!(
3057 !n.problems
3058 .warnings
3059 .iter()
3060 .any(|w| w.contains("forward-compat")),
3061 "no forward-compat warning for an all-known-keys contract: {:?}",
3062 n.problems.warnings
3063 );
3064 }
3065
3066 #[test]
3068 fn distribution_installers_dedup_canonical_order() {
3069 let text = "---\nstatus: approved\nmaturity: mvp\n\
3070 distribution:\n adapter: cargo-dist\n installers: [homebrew, shell, homebrew]\n \
3071 homebrew_tap: owner/tap\n---\n";
3072 let d = norm(text)
3073 .contract
3074 .distributions
3075 .into_iter()
3076 .next()
3077 .unwrap();
3078 assert_eq!(d.installers, vec![Installer::Shell, Installer::Homebrew]);
3079 }
3080
3081 #[test]
3083 fn distribution_homebrew_installer_requires_tap() {
3084 let text = "---\nstatus: approved\nmaturity: mvp\n\
3085 distribution:\n adapter: cargo-dist\n installers: [shell, homebrew]\n---\n";
3086 assert_error_contains(
3087 &norm(text),
3088 "includes 'homebrew' but no distribution.homebrew_tap",
3089 );
3090 }
3091
3092 #[test]
3096 fn distribution_bad_tap_slug_rejected() {
3097 let text = "---\nstatus: approved\nmaturity: mvp\n\
3098 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
3099 homebrew_tap: not-a-slug\n---\n";
3100 let n = norm(text);
3101 assert_error_contains(&n, "must be an 'owner/repo' slug");
3102 assert!(
3103 n.problems
3104 .errors
3105 .iter()
3106 .any(|e| e.contains("includes 'homebrew' but no distribution.homebrew_tap")),
3107 "the tap floor must still fire on an invalid (→None) tap: {:?}",
3108 n.problems.errors
3109 );
3110 assert_eq!(
3112 n.contract
3113 .distributions
3114 .into_iter()
3115 .next()
3116 .unwrap()
3117 .homebrew_tap,
3118 None
3119 );
3120 }
3121
3122 #[test]
3124 fn distribution_bad_installer_rejected() {
3125 let text = "---\nstatus: approved\nmaturity: mvp\n\
3126 distribution:\n adapter: cargo-dist\n installers: [snap]\n---\n";
3127 assert_error_contains(&norm(text), "distribution.installers");
3128 }
3129
3130 #[test]
3133 fn distribution_adapter_is_required() {
3134 assert_error_contains(
3135 &norm("---\nstatus: approved\nmaturity: mvp\ndistribution: {}\n---\n"),
3136 "distribution.adapter is required",
3137 );
3138 }
3139
3140 #[test]
3143 fn distribution_forbidden_on_spike() {
3144 let text = "---\nstatus: approved\nmaturity: spike\n\
3145 distribution:\n adapter: cargo-dist\n---\n";
3146 assert_error_contains(&norm(text), "not allowed on maturity 'spike'");
3147 }
3148
3149 #[test]
3154 fn distribution_tap_without_installer_warns() {
3155 let text = "---\nstatus: approved\nmaturity: mvp\n\
3156 distribution:\n adapter: cargo-dist\n installers: [shell]\n \
3157 homebrew_tap: owner/tap\n---\n";
3158 let n = norm(text);
3159 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3160 assert!(
3161 n.problems
3162 .warnings
3163 .iter()
3164 .any(|w| w.contains("no formula is generated, so the tap will never be updated")),
3165 "expected dead-tap warning: {:?}",
3166 n.problems.warnings
3167 );
3168 }
3169
3170 #[test]
3176 fn distribution_tap_with_homebrew_target_no_warning() {
3177 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
3178 targets:\n \
3179 - {ecosystem: rust, package: ossctl, registry: crates.io, adapter: cargo-publish}\n \
3180 - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: homebrew-tap}\n\
3181 distribution:\n adapter: cargo-dist\n installers: [shell]\n \
3182 homebrew_tap: owner/tap\n---\n";
3183 let n = norm(text);
3184 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3185 assert!(
3189 n.problems.warnings.is_empty(),
3190 "homebrew-target contract must not warn: {:?}",
3191 n.problems.warnings
3192 );
3193 }
3194
3195 #[test]
3198 fn distribution_goreleaser_minimal_is_valid() {
3199 let text = "---\nstatus: approved\nmaturity: production\necosystems: [go]\n\
3200 distribution:\n adapter: goreleaser\n gh_releases: true\n---\n";
3201 let n = norm(text);
3202 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3203 let d = n.contract.distributions.into_iter().next().unwrap();
3204 assert_eq!(d.adapter, DistributionAdapter::Goreleaser);
3205 assert!(d.installers.is_empty());
3206 assert_eq!(d.homebrew_tap, None);
3207 }
3208
3209 #[test]
3211 fn distribution_non_mapping_rejected() {
3212 assert_error_contains(
3213 &norm("---\nstatus: approved\nmaturity: mvp\ndistribution: [nope]\n---\n"),
3214 "distribution must be a mapping",
3215 );
3216 }
3217
3218 fn has_linux(platforms: &[String]) -> bool {
3224 platforms.iter().any(|t| t.contains("-linux"))
3225 }
3226
3227 #[test]
3232 fn distribution_platforms_default_is_cross_platform() {
3233 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3234 distribution:\n adapter: cargo-dist\n---\n";
3235 let d = norm(text)
3236 .contract
3237 .distributions
3238 .into_iter()
3239 .next()
3240 .expect("distribution present");
3241 assert_eq!(
3242 d.platforms,
3243 vec![
3244 "aarch64-apple-darwin",
3245 "x86_64-apple-darwin",
3246 "aarch64-unknown-linux-musl",
3247 "x86_64-unknown-linux-musl",
3248 ]
3249 );
3250 assert!(
3251 has_linux(&d.platforms),
3252 "the default set MUST contain a Linux triple: {:?}",
3253 d.platforms
3254 );
3255 }
3256
3257 #[test]
3260 fn distribution_platforms_explicit_round_trips() {
3261 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3262 distribution:\n adapter: cargo-dist\n \
3263 platforms: [x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc]\n---\n";
3264 let n = norm(text);
3265 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3266 let d = n.contract.clone().distributions.into_iter().next().unwrap();
3267 assert_eq!(
3268 d.platforms,
3269 vec!["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
3270 );
3271 let json = serde_json::to_value(&n.contract).unwrap();
3272 assert_eq!(
3273 json["distributions"][0]["platforms"],
3274 serde_json::json!(["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"])
3275 );
3276 }
3277
3278 #[test]
3284 fn distribution_platforms_empty_is_rejected() {
3285 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3286 distribution:\n adapter: cargo-dist\n platforms: []\n---\n";
3287 assert_error_contains(&norm(text), "empty list — omit the key");
3288 }
3289
3290 #[test]
3292 fn distribution_platforms_dedup_preserves_order() {
3293 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3294 distribution:\n adapter: cargo-dist\n \
3295 platforms: [aarch64-apple-darwin, x86_64-apple-darwin, aarch64-apple-darwin]\n---\n";
3296 let d = norm(text)
3297 .contract
3298 .distributions
3299 .into_iter()
3300 .next()
3301 .unwrap();
3302 assert_eq!(
3303 d.platforms,
3304 vec!["aarch64-apple-darwin", "x86_64-apple-darwin"]
3305 );
3306 }
3307
3308 #[test]
3310 fn distribution_platforms_bad_triple_rejected() {
3311 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3312 distribution:\n adapter: cargo-dist\n platforms: [not_a_triple]\n---\n";
3313 assert_error_contains(&norm(text), "is not a well-formed target-triple");
3314 }
3315
3316 #[test]
3318 fn distribution_platforms_non_string_entry_rejected() {
3319 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3320 distribution:\n adapter: cargo-dist\n platforms: [[nope]]\n---\n";
3321 assert_error_contains(&norm(text), "each entry must be a target-triple string");
3322 }
3323
3324 #[test]
3326 fn distribution_platforms_non_list_rejected() {
3327 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3328 distribution:\n adapter: cargo-dist\n platforms: x86_64-apple-darwin\n---\n";
3329 assert_error_contains(&norm(text), "must be a list of target-triple strings");
3330 }
3331
3332 #[test]
3336 fn registry_only_contract_unaffected_by_platforms() {
3337 let json = serde_json::to_value(
3338 &norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract,
3339 )
3340 .unwrap();
3341 assert_eq!(json["distributions"], serde_json::json!([]));
3342 }
3343
3344 #[test]
3345 fn looks_like_target_triple_verdicts() {
3346 assert!(looks_like_target_triple("aarch64-apple-darwin"));
3348 assert!(looks_like_target_triple("x86_64-apple-darwin"));
3349 assert!(looks_like_target_triple("x86_64-unknown-linux-musl"));
3350 assert!(looks_like_target_triple("x86_64-unknown-linux-gnu"));
3351 assert!(looks_like_target_triple("x86_64-pc-windows-msvc"));
3352 assert!(looks_like_target_triple("armv7-unknown-linux-gnueabihf"));
3353 assert!(looks_like_target_triple("wasm32-wasi"));
3354 assert!(looks_like_target_triple("thumbv8m.main-none-eabi"));
3356 assert!(looks_like_target_triple("thumbv8m.base-none-eabi"));
3357 assert!(!looks_like_target_triple("linux"));
3359 assert!(!looks_like_target_triple("a-b-c-d-e"));
3360 assert!(!looks_like_target_triple("x86_64--linux"));
3361 assert!(!looks_like_target_triple("-apple-darwin"));
3362 assert!(!looks_like_target_triple("X86_64-apple-darwin"));
3363 assert!(!looks_like_target_triple("x86_64-apple-darwin;rm"));
3364 assert!(!looks_like_target_triple("x86_64 apple darwin"));
3365 assert!(!looks_like_target_triple(""));
3366 assert!(looks_like_target_triple("aa-bb"));
3369 }
3370
3371 #[test]
3372 fn is_tap_slug_verdicts() {
3373 assert!(is_tap_slug("owner/repo"));
3375 assert!(is_tap_slug("jarimustonen/homebrew-issuectl"));
3376 assert!(is_tap_slug("Owner_1/repo.rb"));
3377 assert!(!is_tap_slug("no-slash"));
3379 assert!(!is_tap_slug("/repo"));
3380 assert!(!is_tap_slug("owner/"));
3381 assert!(!is_tap_slug("owner/repo/extra"));
3382 assert!(!is_tap_slug("owner / repo"));
3383 assert!(!is_tap_slug("owner/.."));
3385 assert!(!is_tap_slug("../repo"));
3386 assert!(!is_tap_slug("owner/repo;rm -rf"));
3387 assert!(!is_tap_slug("owner/@repo"));
3388 assert!(!is_tap_slug("ownér/repo"));
3389 }
3390
3391 #[test]
3394 fn quote_for_diagnostic_escapes_hostile_input() {
3395 assert_eq!(quote_for_diagnostic("foo"), "\"foo\"");
3396 assert_eq!(quote_for_diagnostic("a\"b"), "\"a\\\"b\"");
3397 assert_eq!(quote_for_diagnostic("a\nb"), "\"a\\nb\"");
3398 assert_eq!(quote_for_diagnostic("a\tb"), "\"a\\tb\"");
3399 assert_eq!(quote_for_diagnostic("\u{1}"), "\"\\u0001\"");
3401 }
3402
3403 #[test]
3407 fn unknown_field_key_is_escaped_in_warning() {
3408 let text =
3411 "---\nstatus: approved\nmaturity: mvp\n\"evil\\\"key\\nforged: line\\u0001\": 1\n---\n";
3412 let n = norm(text);
3413 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3414 let warning = n
3415 .problems
3416 .warnings
3417 .iter()
3418 .find(|w| w.contains("unknown field(s) preserved"))
3419 .expect("expected an unknown-field warning");
3420 assert!(
3423 !warning.contains('\n'),
3424 "warning must stay on one line: {warning:?}"
3425 );
3426 assert!(
3427 !warning.contains('\u{1}'),
3428 "warning must not carry a raw control char: {warning:?}"
3429 );
3430 assert!(
3431 !warning.contains("evil\"key"),
3432 "the raw unescaped key must not appear: {warning:?}"
3433 );
3434 assert!(
3436 warning.contains("\\\"") && warning.contains("\\n") && warning.contains("\\u0001"),
3437 "the key must be JSON-escaped: {warning:?}"
3438 );
3439 }
3440
3441 #[test]
3445 fn invalid_enum_value_is_escaped_in_error() {
3446 let text = "---\nstatus: approved\nmaturity: \"mvp\\nforged: line\"\n---\n";
3447 let n = norm(text);
3448 assert_error_contains(&n, "maturity");
3449 let err = n
3450 .problems
3451 .errors
3452 .iter()
3453 .find(|e| e.contains("maturity") && e.contains("invalid"))
3454 .expect("expected a maturity-invalid error");
3455 assert!(!err.contains('\n'), "error must stay on one line: {err:?}");
3456 assert!(
3457 err.contains("\\n"),
3458 "the rejected value's newline must be escaped: {err:?}"
3459 );
3460 }
3461}