1use std::io;
16use std::path::{Component, Path, PathBuf};
17
18use serde_yaml::{Mapping, Value};
19
20use crate::contract::schema::{
21 Adapter, Changelog, ChangelogMode, ChangelogSource, Contract, ContributionProvenance,
22 DependencyBot, Distribution, DistributionAdapter, DocsSite, Ecosystem, HealthBadge, Installer,
23 Maturity, ProvenanceLevel, Registry, Release, ReleaseLayout, ReleaseModel, Status, Target,
24 VersioningBase, DEFAULT_CROSS_PLATFORM_TARGETS, DEFAULT_FRAGMENT_DIR, KNOWN_SCHEMA_VERSION,
25};
26use crate::contract::spdx::spdx_valid;
27use crate::ports::Fs;
28
29pub const CONTRACT_FILENAME: &str = "OSS-RELEASE.md";
31
32const ECOSYSTEM_ORDER: [Ecosystem; 5] = [
35 Ecosystem::Rust,
36 Ecosystem::Node,
37 Ecosystem::Python,
38 Ecosystem::Go,
39 Ecosystem::Binary,
40];
41
42const KNOWN_KEYS: &[&str] = &[
45 "schema_version",
46 "status",
47 "maturity",
48 "ecosystems",
49 "targets",
50 "distribution",
51 "versioning",
52 "changelog",
53 "conventional_commits",
54 "release",
55 "contribution_provenance",
56 "provenance_level",
57 "dependency_bot",
58 "health_badges",
59 "license",
60 "docs_site",
61];
62
63#[derive(Debug, Default)]
65pub struct Problems {
66 pub errors: Vec<String>,
69 pub warnings: Vec<String>,
71}
72
73impl Problems {
74 fn err(&mut self, msg: String) {
75 self.errors.push(msg);
76 }
77
78 fn warn(&mut self, msg: String) {
79 self.warnings.push(msg);
80 }
81}
82
83#[derive(Debug)]
86pub struct Normalized {
87 pub contract: Contract,
89 pub problems: Problems,
91}
92
93impl Normalized {
94 #[must_use]
96 pub fn is_valid(&self) -> bool {
97 self.problems.errors.is_empty()
98 }
99}
100
101#[derive(Debug)]
104pub enum LoadError {
105 NotFound(PathBuf),
107 Io(PathBuf, io::Error),
109 Utf8(PathBuf),
111}
112
113impl std::fmt::Display for LoadError {
114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 match self {
116 Self::NotFound(p) => write!(
117 f,
118 "no {CONTRACT_FILENAME} at {} (run /oss-init to generate one)",
119 p.display()
120 ),
121 Self::Io(p, e) => write!(f, "cannot read {}: {e}", p.display()),
122 Self::Utf8(p) => write!(f, "{} is not valid UTF-8", p.display()),
123 }
124 }
125}
126
127pub fn normalize(repo_root: &Path, fs: &dyn Fs) -> Result<Normalized, LoadError> {
134 let path = repo_root.join(CONTRACT_FILENAME);
135 let bytes = fs.read(&path).map_err(|e| match e.kind() {
136 io::ErrorKind::NotFound => LoadError::NotFound(path.clone()),
137 _ => LoadError::Io(path.clone(), e),
138 })?;
139 let text = String::from_utf8(bytes).map_err(|_| LoadError::Utf8(path.clone()))?;
140 Ok(normalize_str(&text, repo_root, fs))
141}
142
143#[must_use]
150pub fn normalize_str(text: &str, repo_root: &Path, fs: &dyn Fs) -> Normalized {
151 let mut p = Problems::default();
152 let map = match split_frontmatter(text, &mut p) {
153 Some(fm) => parse_frontmatter(&fm, &mut p),
154 None => Mapping::new(),
155 };
156 let contract = build(&map, &mut p, repo_root, fs);
157 Normalized {
158 contract,
159 problems: p,
160 }
161}
162
163macro_rules! enum_field {
167 ($map:expr, $key:expr, $ty:ty, $default:expr, $p:expr) => {{
168 match $map.get($key) {
169 None => $default,
170 Some(v) => match v.as_str().and_then(<$ty>::parse) {
171 Some(x) => x,
172 None => {
173 $p.err(format!(
174 "{} {} invalid — must be one of {:?}",
175 $key,
176 yaml_display(v),
177 <$ty>::VALID
178 ));
179 $default
180 }
181 },
182 }
183 }};
184}
185
186#[allow(clippy::too_many_lines)]
187fn build(map: &Mapping, p: &mut Problems, repo_root: &Path, fs: &dyn Fs) -> Contract {
188 let schema_version = match map.get("schema_version") {
190 None => KNOWN_SCHEMA_VERSION,
191 Some(v) => match v.as_i64() {
192 Some(n) if n > i64::from(KNOWN_SCHEMA_VERSION) => {
193 p.err(format!(
194 "schema_version {n} exceeds what this tool knows ({KNOWN_SCHEMA_VERSION}); \
195 upgrade the OSS-release skills before reading this config (refusing rather \
196 than guessing)."
197 ));
198 u32::try_from(n).unwrap_or(KNOWN_SCHEMA_VERSION)
199 }
200 Some(n) if n < 1 => {
201 p.err(format!("schema_version {n} is invalid (must be >= 1)"));
202 KNOWN_SCHEMA_VERSION
203 }
204 Some(n) => u32::try_from(n).unwrap_or(KNOWN_SCHEMA_VERSION),
205 None => {
206 p.err(format!(
207 "schema_version must be an integer, got {}",
208 yaml_display(v)
209 ));
210 KNOWN_SCHEMA_VERSION
211 }
212 },
213 };
214
215 let status = enum_field!(map, "status", Status, Status::Draft, p);
216
217 let maturity = match map.get("maturity") {
219 None => {
220 p.err("maturity is required (spike|mvp|production) — /oss-init infers it".to_string());
221 Maturity::Mvp
222 }
223 Some(v) => {
224 if let Some(m) = v.as_str().and_then(Maturity::parse) {
225 m
226 } else {
227 p.err(format!(
228 "maturity {} invalid — must be one of {:?}",
229 yaml_display(v),
230 Maturity::VALID
231 ));
232 Maturity::Mvp
233 }
234 }
235 };
236
237 let mut parsed_ecos: Vec<Ecosystem> = Vec::new();
239 for item in as_list(map.get("ecosystems")) {
240 match item.as_str().and_then(Ecosystem::parse) {
241 Some(e) => parsed_ecos.push(e),
242 None => p.err(format!(
243 "ecosystems: {} invalid — must be one of {:?}",
244 yaml_display(&item),
245 Ecosystem::VALID
246 )),
247 }
248 }
249 let ecosystems: Vec<Ecosystem> = ECOSYSTEM_ORDER
250 .into_iter()
251 .filter(|e| parsed_ecos.contains(e))
252 .collect();
253
254 let (versioning, versioning_pattern) = parse_versioning(map.get("versioning"), p);
256
257 let (model, layout) = match map.get("release") {
259 None | Some(Value::Null) => (ReleaseModel::Gated, ReleaseLayout::Single),
260 Some(Value::Mapping(m)) => (
261 enum_field!(m, "model", ReleaseModel, ReleaseModel::Gated, p),
262 enum_field!(m, "layout", ReleaseLayout, ReleaseLayout::Single, p),
263 ),
264 Some(_) => {
265 p.err("release must be a mapping with model/layout".to_string());
266 (ReleaseModel::Gated, ReleaseLayout::Single)
267 }
268 };
269
270 let targets = match map.get("targets") {
272 None | Some(Value::Null) => expand_targets(&ecosystems, layout),
273 Some(Value::Sequence(seq)) if seq.is_empty() => expand_targets(&ecosystems, layout),
274 Some(Value::Sequence(seq)) => validate_targets(seq, &ecosystems, layout, p),
275 Some(_) => {
276 p.err(
277 "targets must be a list of {ecosystem, package?, registry, adapter?} maps"
278 .to_string(),
279 );
280 Vec::new()
281 }
282 };
283
284 let has_homebrew_target = targets.iter().any(|t| t.registry == Registry::Homebrew);
290 let distribution = parse_distribution(
291 map.get("distribution"),
292 has_homebrew_target,
293 schema_version,
294 p,
295 );
296
297 let changelog = match map.get("changelog") {
299 None | Some(Value::Null) => Changelog {
300 mode: ChangelogMode::Curated,
301 source: ChangelogSource::Manual,
302 fragment_dir: DEFAULT_FRAGMENT_DIR.to_string(),
303 },
304 Some(Value::Mapping(m)) => {
305 let mode = enum_field!(m, "mode", ChangelogMode, ChangelogMode::Curated, p);
306 let source = enum_field!(m, "source", ChangelogSource, ChangelogSource::Manual, p);
307 let fragment_dir = match m.get("fragment_dir") {
308 None => DEFAULT_FRAGMENT_DIR.to_string(),
309 Some(v) => {
310 if let Some(s) = v.as_str() {
311 s.to_string()
312 } else {
313 p.err("changelog.fragment_dir must be a string path".to_string());
314 DEFAULT_FRAGMENT_DIR.to_string()
315 }
316 }
317 };
318 Changelog {
319 mode,
320 source,
321 fragment_dir,
322 }
323 }
324 Some(_) => {
325 p.err("changelog must be a mapping with mode/source".to_string());
326 Changelog {
327 mode: ChangelogMode::Curated,
328 source: ChangelogSource::Manual,
329 fragment_dir: DEFAULT_FRAGMENT_DIR.to_string(),
330 }
331 }
332 };
333 if !path_inside_repo(&changelog.fragment_dir) {
335 p.err(format!(
336 "floor: changelog.fragment_dir '{}' must be a relative path inside the repo (an \
337 absolute or '../'-escaping path is refused)",
338 changelog.fragment_dir
339 ));
340 }
341
342 let conventional_commits = match map.get("conventional_commits") {
344 None => false,
345 Some(Value::Bool(b)) => *b,
346 Some(v) => {
347 p.err(format!(
348 "conventional_commits must be true|false, got {}",
349 yaml_display(v)
350 ));
351 false
352 }
353 };
354
355 let contribution_provenance = enum_field!(
356 map,
357 "contribution_provenance",
358 ContributionProvenance,
359 ContributionProvenance::None,
360 p
361 );
362 let provenance_level = enum_field!(
363 map,
364 "provenance_level",
365 ProvenanceLevel,
366 ProvenanceLevel::None,
367 p
368 );
369
370 let dep_default = if maturity == Maturity::Spike {
371 DependencyBot::None
372 } else {
373 DependencyBot::Dependabot
374 };
375 let dependency_bot = enum_field!(map, "dependency_bot", DependencyBot, dep_default, p);
376
377 let license = match map.get("license") {
379 None => "MIT".to_string(),
380 Some(v) => match v.as_str() {
381 Some(s) if !s.trim().is_empty() => {
382 if !spdx_valid(s) {
383 p.err(format!(
384 "license '{s}' is not a valid SPDX expression (unknown id or malformed \
385 AND/OR/WITH grammar)"
386 ));
387 }
388 s.to_string()
389 }
390 _ => {
391 p.err("license must be a non-empty SPDX id/expression (default MIT)".to_string());
392 "MIT".to_string()
393 }
394 },
395 };
396
397 let docs_site = enum_field!(map, "docs_site", DocsSite, DocsSite::None, p);
398
399 let health_badges = if map.contains_key("health_badges") {
402 let mut out = Vec::new();
403 for item in as_list(map.get("health_badges")) {
404 match item.as_str().and_then(HealthBadge::parse) {
405 Some(hb) => out.push(hb),
406 None => p.err(format!(
407 "health_badges: {} invalid — must be one of {:?}",
408 yaml_display(&item),
409 HealthBadge::VALID
410 )),
411 }
412 }
413 out
414 } else {
415 default_health_badges(maturity, &targets)
416 };
417
418 if model == ReleaseModel::Auto && maturity == Maturity::Spike {
420 p.err(
421 "floor: release.model 'auto' is not allowed on maturity 'spike' — a spike is not \
422 being published; raise maturity or set release.model: gated"
423 .to_string(),
424 );
425 }
426 if provenance_level == ProvenanceLevel::SlsaL3 && maturity != Maturity::Production {
427 p.err(format!(
428 "floor: provenance_level 'slsa-l3' is production-only — current maturity is '{}'",
429 maturity.as_str()
430 ));
431 }
432 if !targets.is_empty() && !spdx_valid(&license) {
435 p.err(format!(
436 "floor: a target has a registry (crates.io/npm/PyPI/… require a license) but license \
437 '{license}' is not a valid SPDX expression"
438 ));
439 }
440 check_badge_producers(&health_badges, maturity, &targets, p);
441 if distribution.is_some() && maturity == Maturity::Spike {
446 p.err(
447 "floor: a distribution block ships public binaries (installer + tap) — not allowed on \
448 maturity 'spike' (a spike is not being published); raise maturity or drop distribution"
449 .to_string(),
450 );
451 }
452
453 if changelog.mode == ChangelogMode::Fragment
455 && path_inside_repo(&changelog.fragment_dir)
456 && !fs.is_dir(&repo_root.join(&changelog.fragment_dir))
457 {
458 p.warn(format!(
459 "changelog.mode 'fragment' but the fragment dir '{}' does not exist yet under {} — \
460 /oss-changelog creates it; /oss-readiness reports it as a gap until then",
461 changelog.fragment_dir,
462 repo_root.display()
463 ));
464 }
465
466 let mut extra_fields = serde_json::Map::new();
468 for (k, v) in map {
469 if let Value::String(key) = k {
470 if !KNOWN_KEYS.contains(&key.as_str()) {
471 extra_fields.insert(key.clone(), yaml_to_json(v));
472 }
473 }
474 }
475 if !extra_fields.is_empty() {
476 let keys = extra_fields
479 .keys()
480 .map(|k| format!("'{k}'"))
481 .collect::<Vec<_>>()
482 .join(", ");
483 p.warn(format!(
484 "unknown field(s) preserved under schema_version {schema_version} (forward-compat): \
485 [{keys}]"
486 ));
487 }
488
489 let warnings = p.warnings.clone();
490 Contract {
491 schema_version,
492 status,
493 maturity,
494 ecosystems,
495 targets,
496 distribution,
497 versioning,
498 versioning_pattern,
499 changelog,
500 conventional_commits,
501 release: Release { model, layout },
502 contribution_provenance,
503 provenance_level,
504 dependency_bot,
505 health_badges,
506 license,
507 docs_site,
508 extra_fields,
509 warnings,
510 }
511}
512
513fn parse_versioning(value: Option<&Value>, p: &mut Problems) -> (VersioningBase, Option<String>) {
514 let Some(v) = value else {
515 return (VersioningBase::Semver, None);
516 };
517 let Some(s) = v.as_str() else {
518 p.err(format!(
519 "versioning {} invalid — must be semver | calver:<pattern> | zerover",
520 yaml_display(v)
521 ));
522 return (VersioningBase::Semver, None);
523 };
524 if let Some(rest) = s.strip_prefix("calver:") {
525 let pattern = rest.trim();
526 if pattern.is_empty() {
527 p.err(
528 "versioning 'calver:' carries no pattern — e.g. calver:YYYY.MM.MICRO".to_string(),
529 );
530 }
531 (VersioningBase::Calver, Some(pattern.to_string()))
532 } else if s == "calver" {
533 p.err(
534 "versioning 'calver' must carry its pattern (calver:YYYY.MM.MICRO), not a bare label"
535 .to_string(),
536 );
537 (VersioningBase::Calver, None)
538 } else if let Some(base) = VersioningBase::parse(s) {
539 (base, None)
540 } else {
541 p.err(format!(
542 "versioning '{s}' invalid — must be semver | calver:<pattern> | zerover"
543 ));
544 (VersioningBase::Semver, None)
545 }
546}
547
548fn expand_targets(ecosystems: &[Ecosystem], layout: ReleaseLayout) -> Vec<Target> {
550 ecosystems
551 .iter()
552 .map(|&e| Target {
553 ecosystem: e,
554 package: None,
555 registry: e.default_registry(),
556 adapter: e.default_adapter(layout),
557 })
558 .collect()
559}
560
561fn validate_targets(
562 seq: &[Value],
563 ecosystems: &[Ecosystem],
564 layout: ReleaseLayout,
565 p: &mut Problems,
566) -> Vec<Target> {
567 let mut out = Vec::new();
568 for (idx, item) in seq.iter().enumerate() {
569 let Value::Mapping(m) = item else {
570 p.err(format!(
571 "targets[{idx}] must be a map with at least {{ecosystem, registry}}"
572 ));
573 continue;
574 };
575
576 let ecosystem = if let Some(s) = m.get("ecosystem").and_then(Value::as_str) {
577 if let Some(e) = Ecosystem::parse(s) {
578 if !ecosystems.is_empty() && !ecosystems.contains(&e) {
579 p.err(format!(
580 "targets[{idx}].ecosystem '{s}' is not in ecosystems {:?}",
581 ecosystems.iter().map(|e| e.as_str()).collect::<Vec<_>>()
582 ));
583 }
584 Some(e)
585 } else {
586 p.err(format!(
587 "targets[{idx}].ecosystem '{s}' invalid — one of {:?}",
588 Ecosystem::VALID
589 ));
590 None
591 }
592 } else {
593 p.err(format!(
594 "targets[{idx}].ecosystem invalid — one of {:?}",
595 Ecosystem::VALID
596 ));
597 None
598 };
599
600 let registry = match m.get("registry").and_then(Value::as_str) {
601 None => {
602 p.err(format!(
603 "targets[{idx}] has no registry (required — the publish destination)"
604 ));
605 None
606 }
607 Some(s) => {
608 if let Some(r) = Registry::parse(s) {
609 Some(r)
610 } else {
611 p.err(format!(
612 "targets[{idx}].registry '{s}' invalid — one of {:?}",
613 Registry::VALID
614 ));
615 None
616 }
617 }
618 };
619
620 let adapter = match m.get("adapter") {
621 None => ecosystem.map(|e| e.default_adapter(layout)),
622 Some(v) => {
623 if let Some(a) = v.as_str().and_then(Adapter::parse) {
624 Some(a)
625 } else {
626 p.err(format!(
627 "targets[{idx}].adapter {} invalid — one of {:?}",
628 yaml_display(v),
629 Adapter::VALID
630 ));
631 None
632 }
633 }
634 };
635
636 out.push(Target {
639 ecosystem: ecosystem.unwrap_or(Ecosystem::Binary),
640 package: m.get("package").and_then(Value::as_str).map(str::to_string),
641 registry: registry.unwrap_or(Registry::GhReleases),
642 adapter: adapter.unwrap_or(Adapter::Manual),
643 });
644 }
645 out
646}
647
648const KNOWN_DISTRIBUTION_KEYS: &[&str] = &[
652 "adapter",
653 "gh_releases",
654 "installers",
655 "homebrew_tap",
656 "platforms",
657];
658
659const INSTALLER_ORDER: [Installer; 5] = [
662 Installer::Shell,
663 Installer::Powershell,
664 Installer::Homebrew,
665 Installer::Msi,
666 Installer::Npm,
667];
668
669#[allow(clippy::too_many_lines)]
674fn parse_distribution(
675 value: Option<&Value>,
676 has_homebrew_target: bool,
677 schema_version: u32,
678 p: &mut Problems,
679) -> Option<Distribution> {
680 let m = match value {
681 None | Some(Value::Null) => return None,
682 Some(Value::Mapping(m)) => m,
683 Some(_) => {
684 p.err(
685 "distribution must be a mapping with {adapter?, gh_releases?, installers?, \
686 homebrew_tap?, platforms?}"
687 .to_string(),
688 );
689 return None;
690 }
691 };
692
693 let adapter = match m.get("adapter") {
699 None => {
700 p.err(
701 "distribution.adapter is required when a distribution block is present \
702 (cargo-dist|goreleaser|manual) — /oss-init infers it"
703 .to_string(),
704 );
705 DistributionAdapter::CargoDist
706 }
707 Some(v) => {
708 if let Some(a) = v.as_str().and_then(DistributionAdapter::parse) {
709 a
710 } else {
711 p.err(format!(
712 "distribution.adapter {} invalid — must be one of {:?}",
713 yaml_display(v),
714 DistributionAdapter::VALID
715 ));
716 DistributionAdapter::CargoDist
717 }
718 }
719 };
720
721 let gh_releases = match m.get("gh_releases") {
722 None => true,
724 Some(Value::Bool(b)) => *b,
725 Some(v) => {
726 p.err(format!(
727 "distribution.gh_releases must be true|false, got {}",
728 yaml_display(v)
729 ));
730 true
731 }
732 };
733
734 let mut parsed_installers: Vec<Installer> = Vec::new();
736 for item in as_list(m.get("installers")) {
737 match item.as_str().and_then(Installer::parse) {
738 Some(i) => parsed_installers.push(i),
739 None => p.err(format!(
740 "distribution.installers: {} invalid — must be one of {:?}",
741 yaml_display(&item),
742 Installer::VALID
743 )),
744 }
745 }
746 let installers: Vec<Installer> = INSTALLER_ORDER
747 .into_iter()
748 .filter(|i| parsed_installers.contains(i))
749 .collect();
750
751 let homebrew_tap = match m.get("homebrew_tap") {
752 None | Some(Value::Null) => None,
753 Some(v) => match v.as_str() {
754 Some(s) if is_tap_slug(s) => Some(s.to_string()),
755 Some(s) => {
761 p.err(format!(
762 "distribution.homebrew_tap '{s}' invalid — must be an 'owner/repo' slug"
763 ));
764 None
765 }
766 None => {
767 p.err("distribution.homebrew_tap must be an 'owner/repo' string".to_string());
768 None
769 }
770 },
771 };
772
773 let wants_homebrew = installers.contains(&Installer::Homebrew);
774 if wants_homebrew && homebrew_tap.is_none() {
776 p.err(
777 "floor: distribution.installers includes 'homebrew' but no distribution.homebrew_tap \
778 is set — the generated formula has nowhere to be pushed"
779 .to_string(),
780 );
781 }
782 if homebrew_tap.is_some() && !wants_homebrew && !has_homebrew_target {
790 p.warn(
791 "distribution.homebrew_tap is set but there is neither a 'homebrew' installer in \
792 distribution.installers nor a 'homebrew'-registry target — no formula is generated, \
793 so the tap will never be updated"
794 .to_string(),
795 );
796 }
797
798 let platforms = match m.get("platforms") {
808 None | Some(Value::Null) => default_cross_platform_targets(),
809 Some(Value::Sequence(seq)) if seq.is_empty() => {
810 p.err(
813 "distribution.platforms is an empty list — omit the key to accept the \
814 cross-platform default (macOS + Linux) or list explicit target-triples; a \
815 distribution with no platforms builds nothing"
816 .to_string(),
817 );
818 default_cross_platform_targets()
819 }
820 Some(Value::Sequence(seq)) => {
821 let mut out: Vec<String> = Vec::new();
822 for item in seq {
823 match item.as_str() {
824 Some(s) if looks_like_target_triple(s) => {
825 let triple = s.to_string();
826 if !out.contains(&triple) {
827 out.push(triple);
828 }
829 }
830 Some(s) => p.err(format!(
831 "distribution.platforms: '{s}' is not a well-formed target-triple \
832 (e.g. x86_64-unknown-linux-musl, aarch64-apple-darwin) — structural \
833 check only; the toolchain is the final authority on what builds"
834 )),
835 None => p.err(format!(
836 "distribution.platforms: {} invalid — each entry must be a \
837 target-triple string",
838 yaml_display(item)
839 )),
840 }
841 }
842 out
843 }
844 Some(v) => {
845 p.err(format!(
846 "distribution.platforms must be a list of target-triple strings, got {}",
847 yaml_display(v)
848 ));
849 default_cross_platform_targets()
850 }
851 };
852
853 if p.errors.is_empty() {
867 let has_windows = platforms.iter().any(|t| is_windows_triple(t));
868 let has_macos = platforms.iter().any(|t| is_macos_triple(t));
869 let has_linux = platforms.iter().any(|t| is_linux_triple(t));
870 for &installer in &installers {
871 let unmet = match installer_os_need(installer) {
872 OsNeed::Unchecked => None,
873 OsNeed::Windows => (!has_windows).then_some(
874 "distribution.installers includes 'msi' but the resolved \
875 distribution.platforms set has no Windows (*-windows-*) target — the MSI \
876 installer has nothing to install",
877 ),
878 OsNeed::MacosOrLinux => (!has_macos && !has_linux).then_some(
883 "distribution.installers includes 'homebrew' but the resolved \
884 distribution.platforms set has no macOS (*-apple-darwin) or Linux \
885 (*-linux-*) target — the Homebrew formula has nothing to install",
886 ),
887 };
888 if let Some(msg) = unmet {
889 p.warn(msg.to_string());
890 }
891 }
892 }
893
894 let mut extra_fields = serde_json::Map::new();
899 for (k, v) in m {
900 if let Value::String(key) = k {
901 if !KNOWN_DISTRIBUTION_KEYS.contains(&key.as_str()) {
902 extra_fields.insert(key.clone(), yaml_to_json(v));
903 }
904 }
905 }
906 if !extra_fields.is_empty() {
907 let keys = extra_fields
908 .keys()
909 .map(|k| format!("'{k}'"))
910 .collect::<Vec<_>>()
911 .join(", ");
912 p.warn(format!(
913 "unknown distribution field(s) preserved under schema_version {schema_version} \
914 (forward-compat): [{keys}]"
915 ));
916 }
917
918 Some(Distribution {
919 adapter,
920 gh_releases,
921 installers,
922 homebrew_tap,
923 platforms,
924 extra_fields,
925 })
926}
927
928fn default_cross_platform_targets() -> Vec<String> {
932 DEFAULT_CROSS_PLATFORM_TARGETS
933 .iter()
934 .map(|&s| s.to_string())
935 .collect()
936}
937
938enum OsNeed {
943 Unchecked,
945 Windows,
947 MacosOrLinux,
949}
950
951fn installer_os_need(i: Installer) -> OsNeed {
964 match i {
965 Installer::Msi => OsNeed::Windows,
966 Installer::Homebrew => OsNeed::MacosOrLinux,
967 Installer::Shell | Installer::Powershell | Installer::Npm => OsNeed::Unchecked,
968 }
969}
970
971fn triple_os(s: &str) -> Option<&str> {
979 s.split('-').nth(2)
980}
981
982fn is_windows_triple(s: &str) -> bool {
985 triple_os(s) == Some("windows")
986}
987
988fn is_macos_triple(s: &str) -> bool {
992 triple_os(s) == Some("darwin")
993}
994
995fn is_linux_triple(s: &str) -> bool {
1000 triple_os(s) == Some("linux")
1001}
1002
1003fn looks_like_target_triple(s: &str) -> bool {
1014 let parts: Vec<&str> = s.split('-').collect();
1015 (2..=4).contains(&parts.len())
1016 && parts.iter().all(|part| {
1017 !part.is_empty()
1018 && part.bytes().all(|b| {
1019 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.')
1020 })
1021 })
1022}
1023
1024fn is_tap_slug(s: &str) -> bool {
1031 fn valid_part(part: &str) -> bool {
1032 !part.is_empty()
1033 && part != "."
1034 && part != ".."
1035 && part
1036 .bytes()
1037 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
1038 }
1039 match s.split_once('/') {
1040 Some((owner, repo)) => valid_part(owner) && valid_part(repo) && !repo.contains('/'),
1041 None => false,
1042 }
1043}
1044
1045fn default_health_badges(maturity: Maturity, targets: &[Target]) -> Vec<HealthBadge> {
1048 let mut badges = Vec::new();
1049 if matches!(maturity, Maturity::Mvp | Maturity::Production) {
1050 badges.push(HealthBadge::Ci);
1051 }
1052 if !targets.is_empty() {
1053 badges.push(HealthBadge::Registry);
1054 }
1055 badges.push(HealthBadge::License);
1056 badges
1057}
1058
1059fn check_badge_producers(
1061 badges: &[HealthBadge],
1062 maturity: Maturity,
1063 targets: &[Target],
1064 p: &mut Problems,
1065) {
1066 let has_registry_target = !targets.is_empty();
1067 for b in badges {
1068 match b {
1069 HealthBadge::Ci if maturity == Maturity::Spike => p.err(
1070 "floor: health_badge 'ci' has no producer at maturity 'spike' (no CI until mvp) — \
1071 drop it or raise maturity"
1072 .to_string(),
1073 ),
1074 HealthBadge::Registry if !has_registry_target => p.err(
1075 "floor: health_badge 'registry' has no producer — no target has a registry to \
1076 publish to"
1077 .to_string(),
1078 ),
1079 HealthBadge::Coverage if maturity != Maturity::Production => p.err(format!(
1080 "floor: health_badge 'coverage' has no producer — the coverage gate is a \
1081 production-tier /oss-ci output; current maturity is '{}'",
1082 maturity.as_str()
1083 )),
1084 HealthBadge::Scorecard if maturity != Maturity::Production => p.err(format!(
1085 "floor: health_badge 'scorecard' has no producer — the Scorecard action is a \
1086 production-tier output; current maturity is '{}'",
1087 maturity.as_str()
1088 )),
1089 _ => {}
1090 }
1091 }
1092}
1093
1094fn is_fence(line: &str) -> bool {
1098 let t = line.trim_end();
1099 t == "---" || (t.starts_with("---") && t[3..].chars().all(char::is_whitespace))
1100}
1101
1102fn split_frontmatter(text: &str, p: &mut Problems) -> Option<String> {
1106 let mut lines = text.lines();
1107 match lines.next() {
1108 Some(first) if is_fence(first) => {}
1109 _ => {
1110 p.err("frontmatter missing: file must begin with a '---' YAML block".to_string());
1111 return None;
1112 }
1113 }
1114 let mut fm = String::new();
1115 for line in lines {
1116 if is_fence(line) {
1117 return Some(fm);
1118 }
1119 fm.push_str(line);
1120 fm.push('\n');
1121 }
1122 p.err("frontmatter not closed: no terminating '---' line found".to_string());
1123 None
1124}
1125
1126fn parse_frontmatter(fm: &str, p: &mut Problems) -> Mapping {
1129 if fm.trim().is_empty() {
1130 return Mapping::new();
1131 }
1132 match serde_yaml::from_str::<Value>(fm) {
1133 Ok(Value::Null) => Mapping::new(),
1134 Ok(Value::Mapping(m)) => m,
1135 Ok(_) => {
1136 p.err("frontmatter: top level must be a mapping".to_string());
1137 Mapping::new()
1138 }
1139 Err(e) => {
1140 p.err(format!("frontmatter: invalid YAML — {e}"));
1141 Mapping::new()
1142 }
1143 }
1144}
1145
1146fn as_list(v: Option<&Value>) -> Vec<Value> {
1151 match v {
1152 None | Some(Value::Null) => Vec::new(),
1153 Some(Value::Sequence(seq)) => seq.clone(),
1154 Some(other) => vec![other.clone()],
1155 }
1156}
1157
1158fn yaml_display(v: &Value) -> String {
1160 match v {
1161 Value::String(s) => format!("'{s}'"),
1162 Value::Bool(b) => b.to_string(),
1163 Value::Number(n) => n.to_string(),
1164 Value::Null => "null".to_string(),
1165 Value::Sequence(_) => "<list>".to_string(),
1166 Value::Mapping(_) => "<map>".to_string(),
1167 Value::Tagged(t) => yaml_display(&t.value),
1168 }
1169}
1170
1171fn path_inside_repo(rel: &str) -> bool {
1182 let mut depth: usize = 0;
1183 for comp in Path::new(rel).components() {
1184 match comp {
1185 Component::CurDir => {}
1186 Component::Normal(_) => depth += 1,
1187 Component::ParentDir => {
1188 if depth == 0 {
1190 return false;
1191 }
1192 depth -= 1;
1193 }
1194 Component::RootDir | Component::Prefix(_) => return false,
1197 }
1198 }
1199 true
1200}
1201
1202fn yaml_to_json(v: &Value) -> serde_json::Value {
1204 use serde_json::Value as J;
1205 match v {
1206 Value::Null => J::Null,
1207 Value::Bool(b) => J::Bool(*b),
1208 Value::Number(n) => {
1209 if let Some(i) = n.as_i64() {
1210 J::from(i)
1211 } else if let Some(u) = n.as_u64() {
1212 J::from(u)
1213 } else if let Some(f) = n.as_f64() {
1214 serde_json::Number::from_f64(f).map_or(J::Null, J::Number)
1215 } else {
1216 J::Null
1217 }
1218 }
1219 Value::String(s) => J::String(s.clone()),
1220 Value::Sequence(seq) => J::Array(seq.iter().map(yaml_to_json).collect()),
1221 Value::Mapping(m) => {
1222 let mut obj = serde_json::Map::new();
1223 for (k, val) in m {
1224 let key = match k {
1225 Value::String(s) => s.clone(),
1226 other => yaml_display(other),
1227 };
1228 obj.insert(key, yaml_to_json(val));
1229 }
1230 J::Object(obj)
1231 }
1232 Value::Tagged(t) => yaml_to_json(&t.value),
1233 }
1234}
1235
1236#[cfg(test)]
1237mod tests {
1238 use super::*;
1239 use std::collections::HashSet;
1240
1241 struct FakeFs {
1244 dirs: HashSet<PathBuf>,
1245 }
1246
1247 impl FakeFs {
1248 fn empty() -> Self {
1249 Self {
1250 dirs: HashSet::new(),
1251 }
1252 }
1253
1254 fn with_dirs<const N: usize>(dirs: [&str; N]) -> Self {
1255 Self {
1256 dirs: dirs.iter().map(PathBuf::from).collect(),
1257 }
1258 }
1259 }
1260
1261 impl Fs for FakeFs {
1262 fn read(&self, _path: &Path) -> io::Result<Vec<u8>> {
1263 Err(io::Error::from(io::ErrorKind::NotFound))
1264 }
1265 fn exists(&self, path: &Path) -> bool {
1266 self.dirs.contains(path)
1267 }
1268 fn is_dir(&self, path: &Path) -> bool {
1269 self.dirs.contains(path)
1270 }
1271 fn is_file(&self, _path: &Path) -> bool {
1272 false
1274 }
1275 fn read_dir(&self, _dir: &Path) -> io::Result<Vec<String>> {
1276 Ok(Vec::new())
1278 }
1279 }
1280
1281 fn repo() -> &'static Path {
1282 Path::new("/repo")
1283 }
1284
1285 fn norm(text: &str) -> Normalized {
1286 normalize_str(text, repo(), &FakeFs::empty())
1287 }
1288
1289 fn norm_with(text: &str, fs: &dyn Fs) -> Normalized {
1290 normalize_str(text, repo(), fs)
1291 }
1292
1293 fn assert_error_contains(n: &Normalized, needle: &str) {
1294 assert!(
1295 !n.is_valid(),
1296 "expected invalid, got clean normalize: {:?}",
1297 n.contract
1298 );
1299 assert!(
1300 n.problems.errors.iter().any(|e| e.contains(needle)),
1301 "no error contained {needle:?}; errors were {:?}",
1302 n.problems.errors
1303 );
1304 }
1305
1306 const MINIMAL: &str = "---\nstatus: approved\nmaturity: mvp\n---\n";
1307
1308 #[test]
1309 fn materializes_all_defaults() {
1310 let c = norm(MINIMAL).contract;
1311 assert_eq!(c.schema_version, 1);
1312 assert_eq!(c.status, Status::Approved);
1313 assert_eq!(c.maturity, Maturity::Mvp);
1314 assert!(c.ecosystems.is_empty());
1315 assert!(c.targets.is_empty());
1316 assert_eq!(c.versioning, VersioningBase::Semver);
1317 assert_eq!(c.versioning_pattern, None);
1318 assert_eq!(c.changelog.mode, ChangelogMode::Curated);
1319 assert_eq!(c.changelog.source, ChangelogSource::Manual);
1320 assert_eq!(c.changelog.fragment_dir, DEFAULT_FRAGMENT_DIR);
1321 assert!(!c.conventional_commits);
1322 assert_eq!(c.release.model, ReleaseModel::Gated);
1323 assert_eq!(c.release.layout, ReleaseLayout::Single);
1324 assert_eq!(c.contribution_provenance, ContributionProvenance::None);
1325 assert_eq!(c.provenance_level, ProvenanceLevel::None);
1326 assert_eq!(c.dependency_bot, DependencyBot::Dependabot); assert_eq!(c.license, "MIT");
1328 assert_eq!(c.docs_site, DocsSite::None);
1329 assert_eq!(c.health_badges, vec![HealthBadge::Ci, HealthBadge::License]);
1331 assert!(c.extra_fields.is_empty());
1332 }
1333
1334 #[test]
1335 fn spike_defaults_no_bot_no_ci_badge() {
1336 let c = norm("---\nstatus: approved\nmaturity: spike\n---\n").contract;
1337 assert_eq!(c.dependency_bot, DependencyBot::None);
1338 assert_eq!(c.health_badges, vec![HealthBadge::License]);
1339 }
1340
1341 #[test]
1342 fn maturity_is_required() {
1343 assert_error_contains(
1344 &norm("---\nstatus: approved\n---\n"),
1345 "maturity is required",
1346 );
1347 }
1348
1349 #[test]
1350 fn expands_targets_from_ecosystems() {
1351 let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [python]\n---\n").contract;
1352 assert_eq!(c.targets.len(), 1);
1353 assert_eq!(c.targets[0].ecosystem, Ecosystem::Python);
1354 assert_eq!(c.targets[0].package, None);
1355 assert_eq!(c.targets[0].registry, Registry::Pypi);
1356 assert_eq!(c.targets[0].adapter, Adapter::GhActionPypiPublish);
1357 }
1358
1359 #[test]
1360 fn node_monorepo_adapter_is_changesets() {
1361 let text = "---\nstatus: approved\nmaturity: production\necosystems: [node]\n\
1362 release:\n model: gated\n layout: monorepo\n---\n";
1363 let c = norm(text).contract;
1364 assert_eq!(c.targets[0].adapter, Adapter::Changesets);
1365 }
1366
1367 #[test]
1368 fn ecosystems_dedup_to_canonical_order() {
1369 let c =
1370 norm("---\nstatus: approved\nmaturity: mvp\necosystems: [python, rust, python]\n---\n")
1371 .contract;
1372 assert_eq!(c.ecosystems, vec![Ecosystem::Rust, Ecosystem::Python]);
1373 }
1374
1375 #[test]
1376 fn calver_splits_base_and_pattern() {
1377 let c = norm(
1378 "---\nstatus: approved\nmaturity: mvp\nversioning: \"calver:YYYY.MM.MICRO\"\n---\n",
1379 )
1380 .contract;
1381 assert_eq!(c.versioning, VersioningBase::Calver);
1382 assert_eq!(c.versioning_pattern.as_deref(), Some("YYYY.MM.MICRO"));
1383 }
1384
1385 #[test]
1386 fn bare_calver_is_rejected() {
1387 assert_error_contains(
1388 &norm("---\nstatus: approved\nmaturity: mvp\nversioning: calver\n---\n"),
1389 "must carry its pattern",
1390 );
1391 }
1392
1393 #[test]
1394 fn floor_auto_on_spike() {
1395 let text = "---\nstatus: approved\nmaturity: spike\n\
1396 release:\n model: auto\n layout: single\nhealth_badges: [license]\n---\n";
1397 assert_error_contains(&norm(text), "release.model 'auto' is not allowed");
1398 }
1399
1400 #[test]
1401 fn floor_slsa_l3_production_only() {
1402 assert_error_contains(
1403 &norm("---\nstatus: approved\nmaturity: mvp\nprovenance_level: slsa-l3\n---\n"),
1404 "slsa-l3' is production-only",
1405 );
1406 }
1407
1408 #[test]
1409 fn floor_registry_requires_valid_license() {
1410 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
1411 license: Proprietary-Acme\nhealth_badges: [ci, registry, license]\n---\n";
1412 let n = norm(text);
1413 assert_error_contains(&n, "not a valid SPDX expression");
1415 assert!(n
1416 .problems
1417 .errors
1418 .iter()
1419 .any(|e| e.contains("floor: a target has a registry")));
1420 }
1421
1422 #[test]
1423 fn floor_badge_without_producer() {
1424 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [python]\n\
1425 health_badges: [ci, coverage]\n---\n";
1426 assert_error_contains(&norm(text), "health_badge 'coverage' has no producer");
1427 }
1428
1429 #[test]
1430 fn floor_schema_version_too_new() {
1431 assert_error_contains(
1432 &norm("---\nschema_version: 99\nstatus: approved\nmaturity: mvp\n---\n"),
1433 "exceeds what this tool knows",
1434 );
1435 }
1436
1437 #[test]
1438 fn floor_fragment_dir_escape() {
1439 let text = "---\nstatus: approved\nmaturity: mvp\n\
1440 changelog:\n mode: fragment\n source: manual\n fragment_dir: /etc\n---\n";
1441 assert_error_contains(&norm(text), "must be a relative path inside the repo");
1442 }
1443
1444 #[test]
1445 fn floor_fragment_dir_escape_relative_root() {
1446 let text = "---\nstatus: approved\nmaturity: mvp\n\
1451 changelog:\n mode: fragment\n source: manual\n fragment_dir: ../etc\n---\n";
1452 let n = normalize_str(text, Path::new("."), &FakeFs::empty());
1453 assert_error_contains(&n, "must be a relative path inside the repo");
1454 }
1455
1456 #[test]
1457 fn path_inside_repo_verdicts() {
1458 assert!(path_inside_repo("changelog/fragments"));
1460 assert!(path_inside_repo("./changelog/fragments"));
1461 assert!(path_inside_repo("a/../fragments"));
1462 assert!(path_inside_repo("")); assert!(!path_inside_repo("/etc"));
1465 assert!(!path_inside_repo("../etc"));
1466 assert!(!path_inside_repo("a/../../etc"));
1467 }
1468
1469 #[test]
1470 fn unknown_fields_preserved_and_warned() {
1471 let text =
1472 "---\nstatus: approved\nmaturity: mvp\nroadmap_url: https://example.com/x\n---\n";
1473 let n = norm(text);
1474 assert!(n.is_valid());
1475 assert_eq!(
1476 n.contract
1477 .extra_fields
1478 .get("roadmap_url")
1479 .and_then(|v| v.as_str()),
1480 Some("https://example.com/x")
1481 );
1482 assert!(n
1483 .problems
1484 .warnings
1485 .iter()
1486 .any(|w| w.contains("roadmap_url") && w.contains("forward-compat")));
1487 }
1488
1489 #[test]
1490 fn duplicate_key_is_rejected() {
1491 assert_error_contains(
1492 &norm("---\nstatus: approved\nstatus: draft\nmaturity: mvp\n---\n"),
1493 "invalid YAML",
1494 );
1495 }
1496
1497 #[test]
1498 fn missing_frontmatter_is_rejected() {
1499 assert_error_contains(&norm("no frontmatter here\n"), "frontmatter missing");
1500 }
1501
1502 #[test]
1503 fn unclosed_frontmatter_is_rejected() {
1504 assert_error_contains(&norm("---\nstatus: approved\n"), "frontmatter not closed");
1505 }
1506
1507 #[test]
1508 fn invalid_enum_records_error_and_continues() {
1509 let n = norm("---\nstatus: bogus\nmaturity: alsobogus\n---\n");
1511 assert!(n.problems.errors.iter().any(|e| e.contains("status")));
1512 assert!(n.problems.errors.iter().any(|e| e.contains("maturity")));
1513 }
1514
1515 #[test]
1516 fn fragment_dir_present_suppresses_advisory() {
1517 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
1518 changelog:\n mode: fragment\n source: manual\n---\n";
1519 let fs = FakeFs::with_dirs(["/repo/changelog/fragments"]);
1521 let n = norm_with(text, &fs);
1522 assert!(n.is_valid());
1523 assert!(
1524 !n.problems
1525 .warnings
1526 .iter()
1527 .any(|w| w.contains("does not exist yet")),
1528 "advisory should be suppressed when the dir exists: {:?}",
1529 n.problems.warnings
1530 );
1531 }
1532
1533 #[test]
1534 fn serializes_to_schema_v4_shape() {
1535 let json =
1536 serde_json::to_value(&norm("---\nstatus: approved\nmaturity: mvp\n---\n").contract)
1537 .unwrap();
1538 for key in [
1540 "schema_version",
1541 "status",
1542 "maturity",
1543 "ecosystems",
1544 "targets",
1545 "distribution",
1546 "versioning",
1547 "versioning_pattern",
1548 "changelog",
1549 "conventional_commits",
1550 "release",
1551 "contribution_provenance",
1552 "provenance_level",
1553 "dependency_bot",
1554 "health_badges",
1555 "license",
1556 "docs_site",
1557 "extra_fields",
1558 "warnings",
1559 ] {
1560 assert!(json.get(key).is_some(), "missing §4 key {key}");
1561 }
1562 assert!(json["versioning_pattern"].is_null());
1563 assert!(json["distribution"].is_null());
1566 }
1567
1568 #[test]
1573 fn registry_only_contract_has_no_distribution() {
1574 let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract;
1575 assert_eq!(c.distribution, None);
1576 assert_eq!(c.targets.len(), 1);
1577 assert_eq!(c.targets[0].registry, Registry::CratesIo);
1578 }
1579
1580 #[test]
1583 fn cargo_dist_distribution_coexists_with_registry() {
1584 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1585 targets:\n - {ecosystem: rust, package: issuectl, registry: crates.io, adapter: cargo-publish}\n\
1586 distribution:\n adapter: cargo-dist\n installers: [shell, homebrew]\n \
1587 homebrew_tap: jarimustonen/homebrew-issuectl\n---\n";
1588 let n = norm(text);
1589 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1590 let c = n.contract;
1591 assert_eq!(c.targets.len(), 1);
1593 assert_eq!(c.targets[0].registry, Registry::CratesIo);
1594 assert_eq!(c.targets[0].adapter, Adapter::CargoPublish);
1595 let d = c.distribution.expect("distribution present");
1597 assert_eq!(d.adapter, DistributionAdapter::CargoDist);
1598 assert!(d.gh_releases); assert_eq!(d.installers, vec![Installer::Shell, Installer::Homebrew]);
1600 assert_eq!(
1601 d.homebrew_tap.as_deref(),
1602 Some("jarimustonen/homebrew-issuectl")
1603 );
1604 }
1605
1606 #[test]
1608 fn distribution_json_round_trip_shape() {
1609 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1610 distribution:\n adapter: cargo-dist\n gh_releases: true\n \
1611 installers: [shell, homebrew]\n homebrew_tap: jarimustonen/homebrew-issuectl\n---\n";
1612 let json = serde_json::to_value(&norm(text).contract).unwrap();
1613 let d = &json["distribution"];
1614 assert_eq!(d["adapter"], "cargo-dist");
1615 assert_eq!(d["gh_releases"], true);
1616 assert_eq!(d["installers"], serde_json::json!(["shell", "homebrew"]));
1617 assert_eq!(d["homebrew_tap"], "jarimustonen/homebrew-issuectl");
1618 }
1619
1620 #[test]
1625 fn distribution_unknown_subkey_preserved_and_warned() {
1626 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1627 distribution:\n adapter: cargo-dist\n gh_releases: true\n \
1628 future_signing: {enabled: true, kms_key: alias/oss}\n---\n";
1629 let n = norm(text);
1630 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1631 let d = n
1632 .contract
1633 .clone()
1634 .distribution
1635 .expect("distribution present");
1636 assert_eq!(
1638 d.extra_fields
1639 .get("future_signing")
1640 .and_then(|v| v.get("kms_key"))
1641 .and_then(|v| v.as_str()),
1642 Some("alias/oss")
1643 );
1644 assert_eq!(d.adapter, DistributionAdapter::CargoDist);
1646 assert!(d.gh_releases);
1647 let json = serde_json::to_value(&n.contract).unwrap();
1649 assert_eq!(
1650 json["distribution"]["extra_fields"]["future_signing"]["enabled"],
1651 serde_json::json!(true)
1652 );
1653 assert!(
1655 n.problems.warnings.iter().any(|w| {
1656 w.contains("unknown distribution field(s) preserved")
1657 && w.contains("future_signing")
1658 }),
1659 "expected a scoped forward-compat warning: {:?}",
1660 n.problems.warnings
1661 );
1662 }
1663
1664 #[test]
1670 fn msi_installer_without_windows_platform_warns() {
1671 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1672 distribution:\n adapter: cargo-dist\n installers: [msi]\n \
1673 platforms: [x86_64-apple-darwin, x86_64-unknown-linux-musl]\n---\n";
1674 let n = norm(text);
1675 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1676 assert!(
1677 n.problems
1678 .warnings
1679 .iter()
1680 .any(|w| w.contains("includes 'msi'") && w.contains("no Windows")),
1681 "expected an msi/Windows cross-check warning: {:?}",
1682 n.problems.warnings
1683 );
1684 }
1685
1686 #[test]
1688 fn msi_installer_with_windows_platform_no_warning() {
1689 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1690 distribution:\n adapter: cargo-dist\n installers: [msi]\n \
1691 platforms: [x86_64-pc-windows-msvc]\n---\n";
1692 let n = norm(text);
1693 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1694 assert!(
1695 !n.problems.warnings.iter().any(|w| w.contains("'msi'")),
1696 "unexpected msi cross-check warning: {:?}",
1697 n.problems.warnings
1698 );
1699 }
1700
1701 #[test]
1706 fn homebrew_installer_without_darwin_or_linux_warns() {
1707 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1708 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
1709 homebrew_tap: jarimustonen/homebrew-issuectl\n \
1710 platforms: [x86_64-pc-windows-msvc]\n---\n";
1711 let n = norm(text);
1712 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1713 assert!(
1714 n.problems
1715 .warnings
1716 .iter()
1717 .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
1718 "expected a homebrew/(macOS|Linux) cross-check warning: {:?}",
1719 n.problems.warnings
1720 );
1721 }
1722
1723 #[test]
1727 fn homebrew_installer_with_linux_only_no_warning() {
1728 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1729 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
1730 homebrew_tap: jarimustonen/homebrew-issuectl\n \
1731 platforms: [x86_64-unknown-linux-musl]\n---\n";
1732 let n = norm(text);
1733 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1734 assert!(
1735 !n.problems.warnings.iter().any(|w| w.contains("'homebrew'")),
1736 "unexpected homebrew cross-check warning for a Linux-only set: {:?}",
1737 n.problems.warnings
1738 );
1739 }
1740
1741 #[test]
1744 fn npm_and_shell_installers_never_cross_check_warn() {
1745 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust, node]\n\
1746 distribution:\n adapter: cargo-dist\n installers: [shell, npm]\n \
1747 platforms: [x86_64-apple-darwin]\n---\n";
1748 let n = norm(text);
1749 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1750 assert!(
1751 !n.problems
1752 .warnings
1753 .iter()
1754 .any(|w| w.contains("nothing to install")),
1755 "OS-agnostic installers must not cross-check warn: {:?}",
1756 n.problems.warnings
1757 );
1758 }
1759
1760 #[test]
1763 fn coherent_installer_platform_set_no_warning() {
1764 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1765 distribution:\n adapter: cargo-dist\n installers: [homebrew, msi]\n \
1766 homebrew_tap: jarimustonen/homebrew-issuectl\n \
1767 platforms: [aarch64-apple-darwin, x86_64-pc-windows-msvc]\n---\n";
1768 let n = norm(text);
1769 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1770 assert!(
1771 !n.problems
1772 .warnings
1773 .iter()
1774 .any(|w| w.contains("nothing to install")),
1775 "coherent set must not warn: {:?}",
1776 n.problems.warnings
1777 );
1778 }
1779
1780 #[test]
1784 fn ossctl_own_contract_shape_no_cross_check_warning() {
1785 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1786 distribution:\n adapter: cargo-dist\n installers: [shell, powershell]\n \
1787 platforms: [aarch64-apple-darwin, x86_64-apple-darwin, \
1788 x86_64-unknown-linux-musl, aarch64-unknown-linux-musl, \
1789 x86_64-pc-windows-msvc]\n---\n";
1790 let n = norm(text);
1791 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1792 assert!(
1793 !n.problems
1794 .warnings
1795 .iter()
1796 .any(|w| w.contains("nothing to install")),
1797 "ossctl's own shape must not cross-check warn: {:?}",
1798 n.problems.warnings
1799 );
1800 }
1801
1802 #[test]
1807 fn msi_installer_with_defaulted_platforms_warns() {
1808 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1809 distribution:\n adapter: cargo-dist\n installers: [msi]\n---\n";
1810 let n = norm(text);
1811 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1812 assert!(
1813 n.problems
1814 .warnings
1815 .iter()
1816 .any(|w| w.contains("includes 'msi'") && w.contains("no Windows")),
1817 "expected an msi/Windows warning against the defaulted platform set: {:?}",
1818 n.problems.warnings
1819 );
1820 }
1821
1822 #[test]
1825 fn msi_installer_with_windows_gnu_no_warning() {
1826 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1827 distribution:\n adapter: cargo-dist\n installers: [msi]\n \
1828 platforms: [x86_64-pc-windows-gnu]\n---\n";
1829 let n = norm(text);
1830 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1831 assert!(
1832 !n.problems.warnings.iter().any(|w| w.contains("'msi'")),
1833 "windows-gnu must satisfy msi: {:?}",
1834 n.problems.warnings
1835 );
1836 }
1837
1838 #[test]
1844 fn homebrew_installer_with_android_only_warns() {
1845 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1846 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
1847 homebrew_tap: jarimustonen/homebrew-issuectl\n \
1848 platforms: [aarch64-linux-android]\n---\n";
1849 let n = norm(text);
1850 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1851 assert!(
1852 n.problems
1853 .warnings
1854 .iter()
1855 .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
1856 "Android-only must strand a homebrew installer: {:?}",
1857 n.problems.warnings
1858 );
1859 }
1860
1861 #[test]
1865 fn homebrew_installer_with_apple_ios_only_warns() {
1866 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1867 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
1868 homebrew_tap: jarimustonen/homebrew-issuectl\n \
1869 platforms: [aarch64-apple-ios]\n---\n";
1870 let n = norm(text);
1871 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1872 assert!(
1873 n.problems
1874 .warnings
1875 .iter()
1876 .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
1877 "apple-ios must not satisfy homebrew's macOS need: {:?}",
1878 n.problems.warnings
1879 );
1880 }
1881
1882 #[test]
1885 fn homebrew_installer_with_macos_only_no_warning() {
1886 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1887 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
1888 homebrew_tap: jarimustonen/homebrew-issuectl\n \
1889 platforms: [aarch64-apple-darwin]\n---\n";
1890 let n = norm(text);
1891 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1892 assert!(
1893 !n.problems.warnings.iter().any(|w| w.contains("'homebrew'")),
1894 "macOS-only must satisfy homebrew: {:?}",
1895 n.problems.warnings
1896 );
1897 }
1898
1899 #[test]
1904 fn both_installers_stranded_warn_once_each() {
1905 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1906 distribution:\n adapter: cargo-dist\n installers: [homebrew, msi]\n \
1907 homebrew_tap: jarimustonen/homebrew-issuectl\n \
1908 platforms: [wasm32-unknown-unknown]\n---\n";
1909 let n = norm(text);
1910 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1911 let msi = n
1912 .problems
1913 .warnings
1914 .iter()
1915 .filter(|w| w.contains("includes 'msi'"))
1916 .count();
1917 let brew = n
1918 .problems
1919 .warnings
1920 .iter()
1921 .filter(|w| w.contains("includes 'homebrew'"))
1922 .count();
1923 assert_eq!((msi, brew), (1, 1), "warnings: {:?}", n.problems.warnings);
1924 }
1925
1926 #[test]
1932 fn malformed_platform_triple_gates_off_cross_check() {
1933 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1934 distribution:\n adapter: cargo-dist\n installers: [msi]\n \
1935 platforms: [x86_64-PC-WINDOWS-MSVC]\n---\n";
1936 let n = norm(text);
1937 assert!(!n.is_valid(), "expected a malformed-triple error");
1939 assert!(
1941 !n.problems
1942 .warnings
1943 .iter()
1944 .any(|w| w.contains("nothing to install")),
1945 "cross-check must be gated off while platforms has errors: {:?}",
1946 n.problems.warnings
1947 );
1948 }
1949
1950 #[test]
1956 fn distribution_all_known_keys_has_empty_extra_fields() {
1957 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1958 distribution:\n adapter: cargo-dist\n gh_releases: true\n \
1959 installers: [shell, homebrew]\n homebrew_tap: owner/tap\n \
1960 platforms: [x86_64-unknown-linux-musl]\n---\n";
1961 let n = norm(text);
1962 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1963 let d = n.contract.distribution.expect("distribution present");
1964 assert!(d.extra_fields.is_empty());
1965 assert!(
1966 !n.problems
1967 .warnings
1968 .iter()
1969 .any(|w| w.contains("unknown distribution field(s) preserved")),
1970 "no forward-compat warning for an all-known-keys block: {:?}",
1971 n.problems.warnings
1972 );
1973 }
1974
1975 #[test]
1980 fn distribution_and_top_level_extra_fields_coexist() {
1981 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
1982 roadmap_url: https://example.com/x\n\
1983 distribution:\n adapter: cargo-dist\n future_x: 1\n---\n";
1984 let n = norm(text);
1985 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
1986 let c = n.contract.clone();
1987 assert!(c.extra_fields.contains_key("roadmap_url"));
1988 let d = c.distribution.expect("distribution present");
1989 assert_eq!(d.extra_fields.get("future_x"), Some(&serde_json::json!(1)));
1990 let fc: Vec<&String> = n
1992 .problems
1993 .warnings
1994 .iter()
1995 .filter(|w| w.contains("forward-compat") && w.contains("schema_version 1"))
1996 .collect();
1997 assert_eq!(fc.len(), 2, "expected two versioned warnings: {fc:?}");
1998 }
1999
2000 #[test]
2002 fn distribution_installers_dedup_canonical_order() {
2003 let text = "---\nstatus: approved\nmaturity: mvp\n\
2004 distribution:\n adapter: cargo-dist\n installers: [homebrew, shell, homebrew]\n \
2005 homebrew_tap: owner/tap\n---\n";
2006 let d = norm(text).contract.distribution.unwrap();
2007 assert_eq!(d.installers, vec![Installer::Shell, Installer::Homebrew]);
2008 }
2009
2010 #[test]
2012 fn distribution_homebrew_installer_requires_tap() {
2013 let text = "---\nstatus: approved\nmaturity: mvp\n\
2014 distribution:\n adapter: cargo-dist\n installers: [shell, homebrew]\n---\n";
2015 assert_error_contains(
2016 &norm(text),
2017 "includes 'homebrew' but no distribution.homebrew_tap",
2018 );
2019 }
2020
2021 #[test]
2025 fn distribution_bad_tap_slug_rejected() {
2026 let text = "---\nstatus: approved\nmaturity: mvp\n\
2027 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
2028 homebrew_tap: not-a-slug\n---\n";
2029 let n = norm(text);
2030 assert_error_contains(&n, "must be an 'owner/repo' slug");
2031 assert!(
2032 n.problems
2033 .errors
2034 .iter()
2035 .any(|e| e.contains("includes 'homebrew' but no distribution.homebrew_tap")),
2036 "the tap floor must still fire on an invalid (→None) tap: {:?}",
2037 n.problems.errors
2038 );
2039 assert_eq!(n.contract.distribution.unwrap().homebrew_tap, None);
2041 }
2042
2043 #[test]
2045 fn distribution_bad_installer_rejected() {
2046 let text = "---\nstatus: approved\nmaturity: mvp\n\
2047 distribution:\n adapter: cargo-dist\n installers: [snap]\n---\n";
2048 assert_error_contains(&norm(text), "distribution.installers");
2049 }
2050
2051 #[test]
2054 fn distribution_adapter_is_required() {
2055 assert_error_contains(
2056 &norm("---\nstatus: approved\nmaturity: mvp\ndistribution: {}\n---\n"),
2057 "distribution.adapter is required",
2058 );
2059 }
2060
2061 #[test]
2064 fn distribution_forbidden_on_spike() {
2065 let text = "---\nstatus: approved\nmaturity: spike\n\
2066 distribution:\n adapter: cargo-dist\n---\n";
2067 assert_error_contains(&norm(text), "not allowed on maturity 'spike'");
2068 }
2069
2070 #[test]
2075 fn distribution_tap_without_installer_warns() {
2076 let text = "---\nstatus: approved\nmaturity: mvp\n\
2077 distribution:\n adapter: cargo-dist\n installers: [shell]\n \
2078 homebrew_tap: owner/tap\n---\n";
2079 let n = norm(text);
2080 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2081 assert!(
2082 n.problems
2083 .warnings
2084 .iter()
2085 .any(|w| w.contains("no formula is generated, so the tap will never be updated")),
2086 "expected dead-tap warning: {:?}",
2087 n.problems.warnings
2088 );
2089 }
2090
2091 #[test]
2097 fn distribution_tap_with_homebrew_target_no_warning() {
2098 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
2099 targets:\n \
2100 - {ecosystem: rust, package: ossctl, registry: crates.io, adapter: cargo-publish}\n \
2101 - {ecosystem: rust, package: ossctl, registry: homebrew, adapter: homebrew-tap}\n\
2102 distribution:\n adapter: cargo-dist\n installers: [shell]\n \
2103 homebrew_tap: owner/tap\n---\n";
2104 let n = norm(text);
2105 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2106 assert!(
2110 n.problems.warnings.is_empty(),
2111 "homebrew-target contract must not warn: {:?}",
2112 n.problems.warnings
2113 );
2114 }
2115
2116 #[test]
2119 fn distribution_goreleaser_minimal_is_valid() {
2120 let text = "---\nstatus: approved\nmaturity: production\necosystems: [go]\n\
2121 distribution:\n adapter: goreleaser\n gh_releases: true\n---\n";
2122 let n = norm(text);
2123 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2124 let d = n.contract.distribution.unwrap();
2125 assert_eq!(d.adapter, DistributionAdapter::Goreleaser);
2126 assert!(d.installers.is_empty());
2127 assert_eq!(d.homebrew_tap, None);
2128 }
2129
2130 #[test]
2132 fn distribution_non_mapping_rejected() {
2133 assert_error_contains(
2134 &norm("---\nstatus: approved\nmaturity: mvp\ndistribution: [nope]\n---\n"),
2135 "distribution must be a mapping",
2136 );
2137 }
2138
2139 fn has_linux(platforms: &[String]) -> bool {
2145 platforms.iter().any(|t| t.contains("-linux"))
2146 }
2147
2148 #[test]
2153 fn distribution_platforms_default_is_cross_platform() {
2154 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2155 distribution:\n adapter: cargo-dist\n---\n";
2156 let d = norm(text)
2157 .contract
2158 .distribution
2159 .expect("distribution present");
2160 assert_eq!(
2161 d.platforms,
2162 vec![
2163 "aarch64-apple-darwin",
2164 "x86_64-apple-darwin",
2165 "aarch64-unknown-linux-musl",
2166 "x86_64-unknown-linux-musl",
2167 ]
2168 );
2169 assert!(
2170 has_linux(&d.platforms),
2171 "the default set MUST contain a Linux triple: {:?}",
2172 d.platforms
2173 );
2174 }
2175
2176 #[test]
2179 fn distribution_platforms_explicit_round_trips() {
2180 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2181 distribution:\n adapter: cargo-dist\n \
2182 platforms: [x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc]\n---\n";
2183 let n = norm(text);
2184 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2185 let d = n.contract.clone().distribution.unwrap();
2186 assert_eq!(
2187 d.platforms,
2188 vec!["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
2189 );
2190 let json = serde_json::to_value(&n.contract).unwrap();
2191 assert_eq!(
2192 json["distribution"]["platforms"],
2193 serde_json::json!(["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"])
2194 );
2195 }
2196
2197 #[test]
2203 fn distribution_platforms_empty_is_rejected() {
2204 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2205 distribution:\n adapter: cargo-dist\n platforms: []\n---\n";
2206 assert_error_contains(&norm(text), "empty list — omit the key");
2207 }
2208
2209 #[test]
2211 fn distribution_platforms_dedup_preserves_order() {
2212 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2213 distribution:\n adapter: cargo-dist\n \
2214 platforms: [aarch64-apple-darwin, x86_64-apple-darwin, aarch64-apple-darwin]\n---\n";
2215 let d = norm(text).contract.distribution.unwrap();
2216 assert_eq!(
2217 d.platforms,
2218 vec!["aarch64-apple-darwin", "x86_64-apple-darwin"]
2219 );
2220 }
2221
2222 #[test]
2224 fn distribution_platforms_bad_triple_rejected() {
2225 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2226 distribution:\n adapter: cargo-dist\n platforms: [not_a_triple]\n---\n";
2227 assert_error_contains(&norm(text), "is not a well-formed target-triple");
2228 }
2229
2230 #[test]
2232 fn distribution_platforms_non_string_entry_rejected() {
2233 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2234 distribution:\n adapter: cargo-dist\n platforms: [[nope]]\n---\n";
2235 assert_error_contains(&norm(text), "each entry must be a target-triple string");
2236 }
2237
2238 #[test]
2240 fn distribution_platforms_non_list_rejected() {
2241 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2242 distribution:\n adapter: cargo-dist\n platforms: x86_64-apple-darwin\n---\n";
2243 assert_error_contains(&norm(text), "must be a list of target-triple strings");
2244 }
2245
2246 #[test]
2250 fn registry_only_contract_unaffected_by_platforms() {
2251 let json = serde_json::to_value(
2252 &norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract,
2253 )
2254 .unwrap();
2255 assert!(json["distribution"].is_null());
2256 }
2257
2258 #[test]
2259 fn looks_like_target_triple_verdicts() {
2260 assert!(looks_like_target_triple("aarch64-apple-darwin"));
2262 assert!(looks_like_target_triple("x86_64-apple-darwin"));
2263 assert!(looks_like_target_triple("x86_64-unknown-linux-musl"));
2264 assert!(looks_like_target_triple("x86_64-unknown-linux-gnu"));
2265 assert!(looks_like_target_triple("x86_64-pc-windows-msvc"));
2266 assert!(looks_like_target_triple("armv7-unknown-linux-gnueabihf"));
2267 assert!(looks_like_target_triple("wasm32-wasi"));
2268 assert!(looks_like_target_triple("thumbv8m.main-none-eabi"));
2270 assert!(looks_like_target_triple("thumbv8m.base-none-eabi"));
2271 assert!(!looks_like_target_triple("linux"));
2273 assert!(!looks_like_target_triple("a-b-c-d-e"));
2274 assert!(!looks_like_target_triple("x86_64--linux"));
2275 assert!(!looks_like_target_triple("-apple-darwin"));
2276 assert!(!looks_like_target_triple("X86_64-apple-darwin"));
2277 assert!(!looks_like_target_triple("x86_64-apple-darwin;rm"));
2278 assert!(!looks_like_target_triple("x86_64 apple darwin"));
2279 assert!(!looks_like_target_triple(""));
2280 assert!(looks_like_target_triple("aa-bb"));
2283 }
2284
2285 #[test]
2286 fn is_tap_slug_verdicts() {
2287 assert!(is_tap_slug("owner/repo"));
2289 assert!(is_tap_slug("jarimustonen/homebrew-issuectl"));
2290 assert!(is_tap_slug("Owner_1/repo.rb"));
2291 assert!(!is_tap_slug("no-slash"));
2293 assert!(!is_tap_slug("/repo"));
2294 assert!(!is_tap_slug("owner/"));
2295 assert!(!is_tap_slug("owner/repo/extra"));
2296 assert!(!is_tap_slug("owner / repo"));
2297 assert!(!is_tap_slug("owner/.."));
2299 assert!(!is_tap_slug("../repo"));
2300 assert!(!is_tap_slug("owner/repo;rm -rf"));
2301 assert!(!is_tap_slug("owner/@repo"));
2302 assert!(!is_tap_slug("ownér/repo"));
2303 }
2304}