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::facts::{detect_distribution_surface, CargoPublishPolicy};
28use crate::ports::Fs;
29use crate::release::distribution::{
30 find_undeclared_distribution, undeclared_distribution_warnings,
31};
32
33pub const CONTRACT_FILENAME: &str = "OSS-RELEASE.md";
35
36const DIST_WORKSPACE_FILENAME: &str = "dist-workspace.toml";
38
39const ECOSYSTEM_ORDER: [Ecosystem; 5] = [
42 Ecosystem::Rust,
43 Ecosystem::Node,
44 Ecosystem::Python,
45 Ecosystem::Go,
46 Ecosystem::Binary,
47];
48
49const KNOWN_KEYS: &[&str] = &[
66 "schema_version",
67 "status",
68 "maturity",
69 "ecosystems",
70 "targets",
71 "distribution",
75 "distributions",
76 "versioning",
77 "changelog",
78 "conventional_commits",
79 "release",
80 "contribution_provenance",
81 "provenance_level",
82 "dependency_bot",
83 "health_badges",
84 "license",
85 "docs_site",
86 "extra_fields",
88 "warnings",
89];
90
91#[derive(Debug, Default)]
93pub struct Problems {
94 pub errors: Vec<String>,
97 pub warnings: Vec<String>,
99}
100
101impl Problems {
102 fn err(&mut self, msg: String) {
103 self.errors.push(msg);
104 }
105
106 fn warn(&mut self, msg: String) {
107 self.warnings.push(msg);
108 }
109}
110
111#[derive(Debug)]
114pub struct Normalized {
115 pub contract: Contract,
117 pub problems: Problems,
119}
120
121impl Normalized {
122 #[must_use]
124 pub fn is_valid(&self) -> bool {
125 self.problems.errors.is_empty()
126 }
127}
128
129#[derive(Debug)]
132pub enum LoadError {
133 NotFound(PathBuf),
135 Io(PathBuf, io::Error),
137 Utf8(PathBuf),
139}
140
141impl std::fmt::Display for LoadError {
142 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143 match self {
144 Self::NotFound(p) => write!(
145 f,
146 "no {CONTRACT_FILENAME} at {} (run /shipshape-init to generate one)",
147 p.display()
148 ),
149 Self::Io(p, e) => write!(f, "cannot read {}: {e}", p.display()),
150 Self::Utf8(p) => write!(f, "{} is not valid UTF-8", p.display()),
151 }
152 }
153}
154
155pub fn normalize(repo_root: &Path, fs: &dyn Fs) -> Result<Normalized, LoadError> {
162 let path = repo_root.join(CONTRACT_FILENAME);
163 let bytes = fs.read(&path).map_err(|e| match e.kind() {
164 io::ErrorKind::NotFound => LoadError::NotFound(path.clone()),
165 _ => LoadError::Io(path.clone(), e),
166 })?;
167 let text = String::from_utf8(bytes).map_err(|_| LoadError::Utf8(path.clone()))?;
168 Ok(normalize_str(&text, repo_root, fs))
169}
170
171#[must_use]
178pub fn normalize_str(text: &str, repo_root: &Path, fs: &dyn Fs) -> Normalized {
179 let mut p = Problems::default();
180 let map = match split_frontmatter(text, &mut p) {
181 Some(fm) => parse_frontmatter(&fm, &mut p),
182 None => Mapping::new(),
183 };
184 let contract = build(&map, &mut p, repo_root, fs);
185 Normalized {
186 contract,
187 problems: p,
188 }
189}
190
191macro_rules! enum_field {
195 ($map:expr, $key:expr, $ty:ty, $default:expr, $p:expr) => {{
196 match $map.get($key) {
197 None => $default,
198 Some(v) => match v.as_str().and_then(<$ty>::parse) {
199 Some(x) => x,
200 None => {
201 $p.err(format!(
202 "{} {} invalid — must be one of {:?}",
203 $key,
204 yaml_display(v),
205 <$ty>::VALID
206 ));
207 $default
208 }
209 },
210 }
211 }};
212}
213
214#[allow(clippy::too_many_lines)]
215fn build(map: &Mapping, p: &mut Problems, repo_root: &Path, fs: &dyn Fs) -> Contract {
216 match map.get("schema_version") {
225 None => {}
226 Some(v) => match v.as_i64() {
227 Some(n) if n > i64::from(KNOWN_SCHEMA_VERSION) => p.err(format!(
228 "schema_version {n} exceeds what this tool knows ({KNOWN_SCHEMA_VERSION}); \
229 upgrade the OSS-release skills before reading this config (refusing rather \
230 than guessing)."
231 )),
232 Some(n) if n < 1 => p.err(format!("schema_version {n} is invalid (must be >= 1)")),
233 Some(_) => {}
234 None => p.err(format!(
235 "schema_version must be an integer, got {}",
236 yaml_display(v)
237 )),
238 },
239 }
240 let schema_version = KNOWN_SCHEMA_VERSION;
241
242 let status = enum_field!(map, "status", Status, Status::Draft, p);
243
244 let maturity = match map.get("maturity") {
246 None => {
247 p.err(
248 "maturity is required (spike|mvp|production) — /shipshape-init infers it"
249 .to_string(),
250 );
251 Maturity::Mvp
252 }
253 Some(v) => {
254 if let Some(m) = v.as_str().and_then(Maturity::parse) {
255 m
256 } else {
257 p.err(format!(
258 "maturity {} invalid — must be one of {:?}",
259 yaml_display(v),
260 Maturity::VALID
261 ));
262 Maturity::Mvp
263 }
264 }
265 };
266
267 let mut parsed_ecos: Vec<Ecosystem> = Vec::new();
269 for item in as_list(map.get("ecosystems")) {
270 match item.as_str().and_then(Ecosystem::parse) {
271 Some(e) => parsed_ecos.push(e),
272 None => p.err(format!(
273 "ecosystems: {} invalid — must be one of {:?}",
274 yaml_display(&item),
275 Ecosystem::VALID
276 )),
277 }
278 }
279 let ecosystems: Vec<Ecosystem> = ECOSYSTEM_ORDER
280 .into_iter()
281 .filter(|e| parsed_ecos.contains(e))
282 .collect();
283
284 let (versioning, versioning_pattern) = parse_versioning(map.get("versioning"), p);
286
287 let (model, layout, bump_hook) = match map.get("release") {
289 None | Some(Value::Null) => (ReleaseModel::Gated, ReleaseLayout::Single, None),
290 Some(Value::Mapping(m)) => (
291 enum_field!(m, "model", ReleaseModel, ReleaseModel::Gated, p),
292 enum_field!(m, "layout", ReleaseLayout, ReleaseLayout::Single, p),
293 parse_bump_hook(m, p),
294 ),
295 Some(_) => {
296 p.err("release must be a mapping (model / layout / bump_hook)".to_string());
297 (ReleaseModel::Gated, ReleaseLayout::Single, None)
298 }
299 };
300
301 let targets = match map.get("targets") {
311 None | Some(Value::Null) => expand_targets(&ecosystems, layout),
312 Some(Value::Sequence(seq)) if seq.is_empty() => Vec::new(),
313 Some(Value::Sequence(seq)) => validate_targets(seq, &ecosystems, layout, p),
314 Some(_) => {
315 p.err(
316 "targets must be a list of {ecosystem, package?, registry, adapter?} maps"
317 .to_string(),
318 );
319 Vec::new()
320 }
321 };
322
323 let distributions = parse_distributions(map, &targets, schema_version, p);
329
330 let changelog = match map.get("changelog") {
332 None | Some(Value::Null) => Changelog {
333 mode: ChangelogMode::Curated,
334 source: ChangelogSource::Manual,
335 fragment_dir: DEFAULT_FRAGMENT_DIR.to_string(),
336 },
337 Some(Value::Mapping(m)) => {
338 let mode = enum_field!(m, "mode", ChangelogMode, ChangelogMode::Curated, p);
339 let source = enum_field!(m, "source", ChangelogSource, ChangelogSource::Manual, p);
340 let fragment_dir = match m.get("fragment_dir") {
341 None => DEFAULT_FRAGMENT_DIR.to_string(),
342 Some(v) => {
343 if let Some(s) = v.as_str() {
344 s.to_string()
345 } else {
346 p.err("changelog.fragment_dir must be a string path".to_string());
347 DEFAULT_FRAGMENT_DIR.to_string()
348 }
349 }
350 };
351 Changelog {
352 mode,
353 source,
354 fragment_dir,
355 }
356 }
357 Some(_) => {
358 p.err("changelog must be a mapping with mode/source".to_string());
359 Changelog {
360 mode: ChangelogMode::Curated,
361 source: ChangelogSource::Manual,
362 fragment_dir: DEFAULT_FRAGMENT_DIR.to_string(),
363 }
364 }
365 };
366 if !path_inside_repo(&changelog.fragment_dir) {
368 p.err(format!(
369 "floor: changelog.fragment_dir {} must be a relative path inside the repo (an \
370 absolute or '../'-escaping path is refused)",
371 quote_for_diagnostic(&changelog.fragment_dir)
372 ));
373 }
374
375 let conventional_commits = match map.get("conventional_commits") {
377 None => false,
378 Some(Value::Bool(b)) => *b,
379 Some(v) => {
380 p.err(format!(
381 "conventional_commits must be true|false, got {}",
382 yaml_display(v)
383 ));
384 false
385 }
386 };
387
388 let contribution_provenance = enum_field!(
389 map,
390 "contribution_provenance",
391 ContributionProvenance,
392 ContributionProvenance::None,
393 p
394 );
395 let provenance_level = enum_field!(
396 map,
397 "provenance_level",
398 ProvenanceLevel,
399 ProvenanceLevel::None,
400 p
401 );
402
403 let dep_default = if maturity == Maturity::Spike {
404 DependencyBot::None
405 } else {
406 DependencyBot::Dependabot
407 };
408 let dependency_bot = enum_field!(map, "dependency_bot", DependencyBot, dep_default, p);
409
410 let license = match map.get("license") {
412 None => "MIT".to_string(),
413 Some(v) => match v.as_str() {
414 Some(s) if !s.trim().is_empty() => {
415 if !spdx_valid(s) {
416 p.err(format!(
417 "license {} is not a valid SPDX expression (unknown id or malformed \
418 AND/OR/WITH grammar)",
419 quote_for_diagnostic(s)
420 ));
421 }
422 s.to_string()
423 }
424 _ => {
425 p.err("license must be a non-empty SPDX id/expression (default MIT)".to_string());
426 "MIT".to_string()
427 }
428 },
429 };
430
431 let docs_site = enum_field!(map, "docs_site", DocsSite, DocsSite::None, p);
432
433 let health_badges = if map.contains_key("health_badges") {
436 let mut out = Vec::new();
437 for item in as_list(map.get("health_badges")) {
438 match item.as_str().and_then(HealthBadge::parse) {
439 Some(hb) => out.push(hb),
440 None => p.err(format!(
441 "health_badges: {} invalid — must be one of {:?}",
442 yaml_display(&item),
443 HealthBadge::VALID
444 )),
445 }
446 }
447 out
448 } else {
449 default_health_badges(maturity, &targets)
450 };
451
452 if model == ReleaseModel::Auto && maturity == Maturity::Spike {
454 p.err(
455 "floor: release.model 'auto' is not allowed on maturity 'spike' — a spike is not \
456 being published; raise maturity or set release.model: gated"
457 .to_string(),
458 );
459 }
460 if provenance_level == ProvenanceLevel::SlsaL3 && maturity != Maturity::Production {
461 p.err(format!(
462 "floor: provenance_level 'slsa-l3' is production-only — current maturity is '{}'",
463 maturity.as_str()
464 ));
465 }
466 if !targets.is_empty() && !spdx_valid(&license) {
469 p.err(format!(
470 "floor: a target has a registry (crates.io/npm/PyPI/… require a license) but license \
471 {} is not a valid SPDX expression",
472 quote_for_diagnostic(&license)
473 ));
474 }
475 check_badge_producers(&health_badges, maturity, &targets, p);
476 check_homebrew_configuration(&targets, &distributions, p);
479 check_publisher_conflicts(&targets, p);
480 check_cargo_publish_evidence(&ecosystems, &targets, repo_root, fs, p);
481 check_dist_workspace_homebrew(&targets, &distributions, repo_root, fs, p);
482 let distribution_surface = detect_distribution_surface(repo_root, fs);
483 for warning in undeclared_distribution_warnings(&find_undeclared_distribution(
484 &targets,
485 &distribution_surface,
486 distributions
487 .iter()
488 .any(|d| d.homebrew_tap.is_some() && !d.installers.contains(&Installer::Homebrew)),
489 )) {
490 p.warn(warning);
491 }
492 if targets.is_empty() && !distributions.is_empty() {
501 p.err(
502 "floor: a distribution block declares a binary publish surface (GitHub Release \
503 artifacts / installers / a tap formula) but targets is empty — the two contradict \
504 each other: the release engine would treat the empty target set as publish-none and \
505 cut a TAG-ONLY release, while the pushed tag triggers the distribution's workflow to \
506 publish anyway, unplanned and unverified. Declare the distribution's target (e.g. \
507 {ecosystem, package, registry: gh-releases, adapter: cargo-dist}) or drop the \
508 distribution block for a genuine publish-none contract"
509 .to_string(),
510 );
511 }
512 if !distributions.is_empty() && maturity == Maturity::Spike {
517 p.err(
518 "floor: a distribution block ships public binaries (installer + tap) — not allowed on \
519 maturity 'spike' (a spike is not being published); raise maturity or drop distribution"
520 .to_string(),
521 );
522 }
523
524 if changelog.mode == ChangelogMode::Fragment
526 && path_inside_repo(&changelog.fragment_dir)
527 && !fs.is_dir(&repo_root.join(&changelog.fragment_dir))
528 {
529 p.warn(format!(
530 "changelog.mode 'fragment' but the fragment dir {} does not exist yet under {} — \
531 /shipshape-changelog creates it; /shipshape-readiness reports it as a gap until then",
532 quote_for_diagnostic(&changelog.fragment_dir),
533 repo_root.display()
534 ));
535 }
536
537 let extra_fields =
539 capture_unknown_fields(map, KNOWN_KEYS, CaptureScope::TopLevel, schema_version, p);
540
541 let warnings = p.warnings.clone();
542 Contract {
543 schema_version,
544 status,
545 maturity,
546 ecosystems,
547 targets,
548 distributions,
549 versioning,
550 versioning_pattern,
551 changelog,
552 conventional_commits,
553 release: Release {
554 model,
555 layout,
556 bump_hook,
557 },
558 contribution_provenance,
559 provenance_level,
560 dependency_bot,
561 health_badges,
562 license,
563 docs_site,
564 extra_fields,
565 warnings,
566 }
567}
568
569fn check_dist_workspace_homebrew(
575 targets: &[Target],
576 distributions: &[Distribution],
577 repo_root: &Path,
578 fs: &dyn Fs,
579 p: &mut Problems,
580) {
581 let path = repo_root.join(DIST_WORKSPACE_FILENAME);
582 let Ok(bytes) = fs.read(&path) else {
583 return;
584 };
585 let Ok(text) = String::from_utf8(bytes) else {
586 return;
587 };
588 let Ok(config) = text.parse::<toml::Value>() else {
589 return;
590 };
591 let Some(dist) = config.get("dist").and_then(toml::Value::as_table) else {
592 return;
593 };
594
595 let configured_tap = dist
596 .get("tap")
597 .and_then(toml::Value::as_str)
598 .filter(|tap| !tap.trim().is_empty());
599 let has_tap = configured_tap.is_some();
600 let has_homebrew_publish_job = dist
601 .get("publish-jobs")
602 .and_then(toml::Value::as_array)
603 .is_some_and(|jobs| {
604 jobs.iter()
605 .filter_map(toml::Value::as_str)
606 .any(|job| job == "homebrew")
607 });
608
609 let contract_tap = distributions.iter().find_map(|d| d.homebrew_tap.as_deref());
610 let has_delegated_homebrew_target = targets.iter().any(|target| {
611 target.registry == Registry::Homebrew && target.adapter == Adapter::CargoDist
612 });
613 if has_homebrew_publish_job && !has_delegated_homebrew_target {
614 p.err(
615 "floor: dist-workspace.toml publish-jobs includes 'homebrew', but the contract has \
616 no delegated Homebrew target — cargo-dist would write a formula that the verify \
617 barrier never observes. Add a target with registry 'homebrew' and adapter \
618 'cargo-dist', or remove cargo-dist's Homebrew publish job"
619 .to_string(),
620 );
621 }
622 if has_homebrew_publish_job
623 && targets.iter().any(|target| {
624 target.registry == Registry::Homebrew && target.adapter == Adapter::HomebrewTap
625 })
626 {
627 p.err(
628 "floor: dist-workspace.toml publish-jobs includes 'homebrew', but the contract's \
629 Homebrew target uses adapter 'homebrew-tap' — cargo-dist CI and shipshape would both \
630 write the same tap. Change that target to adapter 'cargo-dist' so the engine \
631 delegates the write and verifies the formula, or remove cargo-dist's Homebrew \
632 publish job"
633 .to_string(),
634 );
635 }
636 if (has_tap || has_homebrew_publish_job) && contract_tap.is_none() {
637 p.warn(
638 "dist-workspace.toml configures Homebrew, but the contract omits \
639 distribution.homebrew_tap; the Homebrew leg will not be planned. Add \
640 distribution.homebrew_tap: owner/repo to OSS-RELEASE.md"
641 .to_string(),
642 );
643 }
644
645 let has_ci_delegated_homebrew = targets.iter().any(|target| {
650 target.registry == Registry::Homebrew && target.adapter == Adapter::CargoDist
651 });
652 if let (true, Some(configured), Some(declared)) =
653 (has_ci_delegated_homebrew, configured_tap, contract_tap)
654 {
655 if configured != declared {
656 p.err(format!(
657 "floor: dist-workspace.toml configures Homebrew tap {}, but the CI-delegated \
658 target's distribution.homebrew_tap is {} — cargo-dist would write one tap while \
659 shipshape verifies another",
660 quote_for_diagnostic(configured),
661 quote_for_diagnostic(declared)
662 ));
663 }
664 }
665}
666
667fn parse_bump_hook(m: &Mapping, p: &mut Problems) -> Option<String> {
675 match m.get("bump_hook") {
676 None | Some(Value::Null) => None,
677 Some(v) => match v.as_str() {
678 Some(s) if !s.trim().is_empty() => Some(s.to_string()),
679 Some(_) => {
680 p.err(
681 "release.bump_hook must be a non-empty command string (or omit it for no hook)"
682 .to_string(),
683 );
684 None
685 }
686 None => {
687 p.err("release.bump_hook must be a command string".to_string());
688 None
689 }
690 },
691 }
692}
693
694fn parse_versioning(value: Option<&Value>, p: &mut Problems) -> (VersioningBase, Option<String>) {
695 let Some(v) = value else {
696 return (VersioningBase::Semver, None);
697 };
698 let Some(s) = v.as_str() else {
699 p.err(format!(
700 "versioning {} invalid — must be semver | calver:<pattern> | zerover",
701 yaml_display(v)
702 ));
703 return (VersioningBase::Semver, None);
704 };
705 if let Some(rest) = s.strip_prefix("calver:") {
706 let pattern = rest.trim();
707 if pattern.is_empty() {
708 p.err(
709 "versioning 'calver:' carries no pattern — e.g. calver:YYYY.MM.MICRO".to_string(),
710 );
711 }
712 (VersioningBase::Calver, Some(pattern.to_string()))
713 } else if s == "calver" {
714 p.err(
715 "versioning 'calver' must carry its pattern (calver:YYYY.MM.MICRO), not a bare label"
716 .to_string(),
717 );
718 (VersioningBase::Calver, None)
719 } else if let Some(base) = VersioningBase::parse(s) {
720 (base, None)
721 } else {
722 p.err(format!(
723 "versioning {} invalid — must be semver | calver:<pattern> | zerover",
724 quote_for_diagnostic(s)
725 ));
726 (VersioningBase::Semver, None)
727 }
728}
729
730fn expand_targets(ecosystems: &[Ecosystem], layout: ReleaseLayout) -> Vec<Target> {
732 ecosystems
733 .iter()
734 .map(|&e| Target {
735 ecosystem: e,
736 package: None,
737 registry: e.default_registry(),
738 adapter: e.default_adapter(layout),
739 })
740 .collect()
741}
742
743fn check_target_adapter_compat(
751 idx: usize,
752 ecosystem: Option<Ecosystem>,
753 registry: Option<Registry>,
754 adapter: Option<Adapter>,
755 p: &mut Problems,
756) {
757 if let (Some(Registry::Homebrew), Some(a)) = (registry, adapter) {
765 if !matches!(
766 a,
767 Adapter::HomebrewTap | Adapter::HomebrewCore | Adapter::CargoDist
768 ) {
769 p.err(format!(
770 "floor: targets[{idx}] has registry 'homebrew' but adapter {} — a \
771 homebrew-registry target requires adapter 'homebrew-tap' (personal tap), \
772 'homebrew-core' (central formula), or 'cargo-dist' (CI-delegated tap)",
773 quote_for_diagnostic(a.as_str())
774 ));
775 }
776 }
777
778 if let (Some(r), Some(Adapter::CargoPublishCi)) = (registry, adapter) {
787 if r != Registry::CratesIo {
788 p.err(format!(
789 "floor: targets[{idx}] has adapter 'cargo-publish-ci' but registry {} — the \
790 CI-delegated cargo publish targets crates.io only (use 'cargo-publish' for \
791 an engine-run publish, or the registry's own adapter)",
792 quote_for_diagnostic(r.as_str())
793 ));
794 }
795 if let Some(e) = ecosystem {
796 if e != Ecosystem::Rust {
797 p.err(format!(
798 "floor: targets[{idx}] has adapter 'cargo-publish-ci' but ecosystem {} — \
799 `cargo publish` releases a rust crate",
800 quote_for_diagnostic(e.as_str())
801 ));
802 }
803 }
804 }
805}
806
807fn validate_targets(
808 seq: &[Value],
809 ecosystems: &[Ecosystem],
810 layout: ReleaseLayout,
811 p: &mut Problems,
812) -> Vec<Target> {
813 let mut out = Vec::new();
814 for (idx, item) in seq.iter().enumerate() {
815 let Value::Mapping(m) = item else {
816 p.err(format!(
817 "targets[{idx}] must be a map with at least {{ecosystem, registry}}"
818 ));
819 continue;
820 };
821
822 let ecosystem = if let Some(s) = m.get("ecosystem").and_then(Value::as_str) {
823 if let Some(e) = Ecosystem::parse(s) {
824 if !ecosystems.is_empty() && !ecosystems.contains(&e) {
825 p.err(format!(
826 "targets[{idx}].ecosystem {} is not in ecosystems {:?}",
827 quote_for_diagnostic(s),
828 ecosystems.iter().map(|e| e.as_str()).collect::<Vec<_>>()
829 ));
830 }
831 Some(e)
832 } else {
833 p.err(format!(
834 "targets[{idx}].ecosystem {} invalid — one of {:?}",
835 quote_for_diagnostic(s),
836 Ecosystem::VALID
837 ));
838 None
839 }
840 } else {
841 p.err(format!(
842 "targets[{idx}].ecosystem invalid — one of {:?}",
843 Ecosystem::VALID
844 ));
845 None
846 };
847
848 let registry = match m.get("registry").and_then(Value::as_str) {
849 None => {
850 p.err(format!(
851 "targets[{idx}] has no registry (required — the publish destination)"
852 ));
853 None
854 }
855 Some(s) => {
856 if let Some(r) = Registry::parse(s) {
857 Some(r)
858 } else {
859 p.err(format!(
860 "targets[{idx}].registry {} invalid — one of {:?}",
861 quote_for_diagnostic(s),
862 Registry::VALID
863 ));
864 None
865 }
866 }
867 };
868
869 let adapter = match m.get("adapter") {
870 None => ecosystem.map(|e| e.default_adapter(layout)),
871 Some(v) => {
872 if let Some(a) = v.as_str().and_then(Adapter::parse) {
873 Some(a)
874 } else {
875 p.err(format!(
876 "targets[{idx}].adapter {} invalid — one of {:?}",
877 yaml_display(v),
878 Adapter::VALID
879 ));
880 None
881 }
882 }
883 };
884
885 check_target_adapter_compat(idx, ecosystem, registry, adapter, p);
886
887 out.push(Target {
890 ecosystem: ecosystem.unwrap_or(Ecosystem::Binary),
891 package: m.get("package").and_then(Value::as_str).map(str::to_string),
892 registry: registry.unwrap_or(Registry::GhReleases),
893 adapter: adapter.unwrap_or(Adapter::Manual),
894 });
895 }
896 out
897}
898
899const KNOWN_DISTRIBUTION_KEYS: &[&str] = &[
910 "package",
911 "adapter",
912 "gh_releases",
913 "installers",
914 "homebrew_tap",
915 "platforms",
916 "extra_fields",
918];
919
920const INSTALLER_ORDER: [Installer; 5] = [
923 Installer::Shell,
924 Installer::Powershell,
925 Installer::Homebrew,
926 Installer::Msi,
927 Installer::Npm,
928];
929
930fn parse_distributions(
940 map: &Mapping,
941 targets: &[Target],
942 schema_version: u32,
943 p: &mut Problems,
944) -> Vec<Distribution> {
945 let single = map.get("distribution");
946 let many = map.get("distributions");
947 let single_present = matches!(single, Some(v) if !v.is_null());
950 let many_present = matches!(many, Some(v) if !v.is_null());
951 if single_present && many_present {
952 p.err(
953 "declare either `distribution` (one block) or `distributions` (a list), not both — \
954 they are the singular and plural spellings of the same field"
955 .to_string(),
956 );
957 }
960
961 let distributions = match (single, many) {
962 (_, Some(Value::Sequence(seq))) => {
965 let mut out = Vec::with_capacity(seq.len());
966 for (idx, item) in seq.iter().enumerate() {
967 match item {
968 Value::Mapping(m) => {
969 out.push(parse_one_distribution(m, schema_version, p));
970 }
971 _ => p.err(format!(
972 "distributions[{idx}] must be a mapping with {{package, adapter, \
973 gh_releases?, installers?, homebrew_tap?, platforms?}}"
974 )),
975 }
976 }
977 out
978 }
979 (_, Some(v)) if !v.is_null() => {
980 p.err(format!(
981 "distributions must be a list of distribution mappings, got {}",
982 yaml_display(v)
983 ));
984 Vec::new()
985 }
986 (Some(Value::Mapping(m)), _) => {
988 vec![parse_one_distribution(m, schema_version, p)]
989 }
990 (Some(v), _) if !v.is_null() => {
991 p.err(
992 "distribution must be a mapping with {adapter?, gh_releases?, installers?, \
993 homebrew_tap?, platforms?} (or use `distributions:` for a list)"
994 .to_string(),
995 );
996 Vec::new()
997 }
998 _ => Vec::new(),
1000 };
1001
1002 if distributions.len() >= 2 {
1007 let mut seen: std::collections::BTreeSet<&str> = std::collections::BTreeSet::new();
1008 for (idx, d) in distributions.iter().enumerate() {
1009 match d.package.as_deref() {
1010 None => p.err(format!(
1011 "floor: distributions[{idx}] has no `package` — with two or more \
1012 distributions each must name the package it builds (the monorepo \
1013 association key), so they can be told apart"
1014 )),
1015 Some(pkg) if !seen.insert(pkg) => p.err(format!(
1016 "floor: distributions[{idx}].package {} is used by more than one \
1017 distribution — each distribution must name a distinct package",
1018 quote_for_diagnostic(pkg)
1019 )),
1020 Some(_) => {}
1021 }
1022 }
1023
1024 let target_pkgs: std::collections::BTreeSet<&str> = targets
1032 .iter()
1033 .filter_map(|t| t.package.as_deref())
1034 .collect();
1035 if !target_pkgs.is_empty() {
1036 for (idx, d) in distributions.iter().enumerate() {
1037 if let Some(pkg) = d.package.as_deref() {
1038 if !target_pkgs.contains(pkg) {
1039 p.warn(format!(
1040 "distributions[{idx}].package {} matches no targets[].package \
1041 ({target_pkgs:?}) — likely a typo; a distribution should build a \
1042 package the contract also lists as a target",
1043 quote_for_diagnostic(pkg)
1044 ));
1045 }
1046 }
1047 }
1048 }
1049 }
1050
1051 distributions
1052}
1053
1054#[allow(clippy::too_many_lines)]
1059fn parse_one_distribution(m: &Mapping, schema_version: u32, p: &mut Problems) -> Distribution {
1060 let package = match m.get("package") {
1064 None | Some(Value::Null) => None,
1065 Some(v) => match v.as_str() {
1066 Some(s) if !s.trim().is_empty() => Some(s.trim().to_string()),
1070 _ => {
1071 p.err(
1072 "distribution.package must be a non-empty string (the package this \
1073 distribution builds)"
1074 .to_string(),
1075 );
1076 None
1077 }
1078 },
1079 };
1080
1081 let adapter = match m.get("adapter") {
1087 None => {
1088 p.err(
1089 "distribution.adapter is required when a distribution block is present \
1090 (cargo-dist|goreleaser|manual) — /shipshape-init infers it"
1091 .to_string(),
1092 );
1093 DistributionAdapter::CargoDist
1094 }
1095 Some(v) => {
1096 if let Some(a) = v.as_str().and_then(DistributionAdapter::parse) {
1097 a
1098 } else {
1099 p.err(format!(
1100 "distribution.adapter {} invalid — must be one of {:?}",
1101 yaml_display(v),
1102 DistributionAdapter::VALID
1103 ));
1104 DistributionAdapter::CargoDist
1105 }
1106 }
1107 };
1108
1109 let gh_releases = match m.get("gh_releases") {
1110 None => true,
1112 Some(Value::Bool(b)) => *b,
1113 Some(v) => {
1114 p.err(format!(
1115 "distribution.gh_releases must be true|false, got {}",
1116 yaml_display(v)
1117 ));
1118 true
1119 }
1120 };
1121
1122 let mut parsed_installers: Vec<Installer> = Vec::new();
1124 for item in as_list(m.get("installers")) {
1125 match item.as_str().and_then(Installer::parse) {
1126 Some(i) => parsed_installers.push(i),
1127 None => p.err(format!(
1128 "distribution.installers: {} invalid — must be one of {:?}",
1129 yaml_display(&item),
1130 Installer::VALID
1131 )),
1132 }
1133 }
1134 let installers: Vec<Installer> = INSTALLER_ORDER
1135 .into_iter()
1136 .filter(|i| parsed_installers.contains(i))
1137 .collect();
1138
1139 let homebrew_tap = match m.get("homebrew_tap") {
1140 None | Some(Value::Null) => None,
1141 Some(v) => match v.as_str() {
1142 Some(s) if is_tap_slug(s) => Some(s.to_string()),
1143 Some(s) => {
1149 p.err(format!(
1150 "distribution.homebrew_tap {} invalid — must be an 'owner/repo' slug",
1151 quote_for_diagnostic(s)
1152 ));
1153 None
1154 }
1155 None => {
1156 p.err("distribution.homebrew_tap must be an 'owner/repo' string".to_string());
1157 None
1158 }
1159 },
1160 };
1161
1162 let wants_homebrew = installers.contains(&Installer::Homebrew);
1163 if wants_homebrew && homebrew_tap.is_none() {
1170 p.err(
1171 "floor: distribution.installers includes 'homebrew' but no distribution.homebrew_tap \
1172 is set — the generated formula has nowhere to be pushed"
1173 .to_string(),
1174 );
1175 }
1176
1177 let platforms = match m.get("platforms") {
1187 None | Some(Value::Null) => default_cross_platform_targets(),
1188 Some(Value::Sequence(seq)) if seq.is_empty() => {
1189 p.err(
1192 "distribution.platforms is an empty list — omit the key to accept the \
1193 cross-platform default (macOS + Linux) or list explicit target-triples; a \
1194 distribution with no platforms builds nothing"
1195 .to_string(),
1196 );
1197 default_cross_platform_targets()
1198 }
1199 Some(Value::Sequence(seq)) => {
1200 let mut out: Vec<String> = Vec::new();
1201 for item in seq {
1202 match item.as_str() {
1203 Some(s) if looks_like_target_triple(s) => {
1204 let triple = s.to_string();
1205 if !out.contains(&triple) {
1206 out.push(triple);
1207 }
1208 }
1209 Some(s) => p.err(format!(
1210 "distribution.platforms: {} is not a well-formed target-triple \
1211 (e.g. x86_64-unknown-linux-musl, aarch64-apple-darwin) — structural \
1212 check only; the toolchain is the final authority on what builds",
1213 quote_for_diagnostic(s)
1214 )),
1215 None => p.err(format!(
1216 "distribution.platforms: {} invalid — each entry must be a \
1217 target-triple string",
1218 yaml_display(item)
1219 )),
1220 }
1221 }
1222 out
1223 }
1224 Some(v) => {
1225 p.err(format!(
1226 "distribution.platforms must be a list of target-triple strings, got {}",
1227 yaml_display(v)
1228 ));
1229 default_cross_platform_targets()
1230 }
1231 };
1232
1233 if p.errors.is_empty() {
1247 let has_windows = platforms.iter().any(|t| is_windows_triple(t));
1248 let has_macos = platforms.iter().any(|t| is_macos_triple(t));
1249 let has_linux = platforms.iter().any(|t| is_linux_triple(t));
1250 for &installer in &installers {
1251 let unmet = match installer_os_need(installer) {
1252 OsNeed::Unchecked => None,
1253 OsNeed::Windows => (!has_windows).then_some(
1254 "distribution.installers includes 'msi' but the resolved \
1255 distribution.platforms set has no Windows (*-windows-*) target — the MSI \
1256 installer has nothing to install",
1257 ),
1258 OsNeed::MacosOrLinux => (!has_macos && !has_linux).then_some(
1263 "distribution.installers includes 'homebrew' but the resolved \
1264 distribution.platforms set has no macOS (*-apple-darwin) or Linux \
1265 (*-linux-*) target — the Homebrew formula has nothing to install",
1266 ),
1267 };
1268 if let Some(msg) = unmet {
1269 p.warn(msg.to_string());
1270 }
1271 }
1272 }
1273
1274 let extra_fields = capture_unknown_fields(
1280 m,
1281 KNOWN_DISTRIBUTION_KEYS,
1282 CaptureScope::Distribution,
1283 schema_version,
1284 p,
1285 );
1286
1287 Distribution {
1288 package,
1289 adapter,
1290 gh_releases,
1291 installers,
1292 homebrew_tap,
1293 platforms,
1294 extra_fields,
1295 }
1296}
1297
1298fn default_cross_platform_targets() -> Vec<String> {
1302 DEFAULT_CROSS_PLATFORM_TARGETS
1303 .iter()
1304 .map(|&s| s.to_string())
1305 .collect()
1306}
1307
1308enum OsNeed {
1313 Unchecked,
1315 Windows,
1317 MacosOrLinux,
1319}
1320
1321fn installer_os_need(i: Installer) -> OsNeed {
1334 match i {
1335 Installer::Msi => OsNeed::Windows,
1336 Installer::Homebrew => OsNeed::MacosOrLinux,
1337 Installer::Shell | Installer::Powershell | Installer::Npm => OsNeed::Unchecked,
1338 }
1339}
1340
1341fn triple_os(s: &str) -> Option<&str> {
1349 s.split('-').nth(2)
1350}
1351
1352fn is_windows_triple(s: &str) -> bool {
1355 triple_os(s) == Some("windows")
1356}
1357
1358fn is_macos_triple(s: &str) -> bool {
1362 triple_os(s) == Some("darwin")
1363}
1364
1365fn is_linux_triple(s: &str) -> bool {
1370 triple_os(s) == Some("linux")
1371}
1372
1373fn looks_like_target_triple(s: &str) -> bool {
1384 let parts: Vec<&str> = s.split('-').collect();
1385 (2..=4).contains(&parts.len())
1386 && parts.iter().all(|part| {
1387 !part.is_empty()
1388 && part.bytes().all(|b| {
1389 b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'.')
1390 })
1391 })
1392}
1393
1394fn is_tap_slug(s: &str) -> bool {
1401 fn valid_part(part: &str) -> bool {
1402 !part.is_empty()
1403 && part != "."
1404 && part != ".."
1405 && part
1406 .bytes()
1407 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
1408 }
1409 match s.split_once('/') {
1410 Some((owner, repo)) => valid_part(owner) && valid_part(repo) && !repo.contains('/'),
1411 None => false,
1412 }
1413}
1414
1415fn default_health_badges(maturity: Maturity, targets: &[Target]) -> Vec<HealthBadge> {
1418 let mut badges = Vec::new();
1419 if matches!(maturity, Maturity::Mvp | Maturity::Production) {
1420 badges.push(HealthBadge::Ci);
1421 }
1422 if !targets.is_empty() {
1423 badges.push(HealthBadge::Registry);
1424 }
1425 badges.push(HealthBadge::License);
1426 badges
1427}
1428
1429fn check_badge_producers(
1431 badges: &[HealthBadge],
1432 maturity: Maturity,
1433 targets: &[Target],
1434 p: &mut Problems,
1435) {
1436 let has_registry_target = !targets.is_empty();
1437 for b in badges {
1438 match b {
1439 HealthBadge::Ci if maturity == Maturity::Spike => p.err(
1440 "floor: health_badge 'ci' has no producer at maturity 'spike' (no CI until mvp) — \
1441 drop it or raise maturity"
1442 .to_string(),
1443 ),
1444 HealthBadge::Registry if !has_registry_target => p.err(
1445 "floor: health_badge 'registry' has no producer — no target has a registry to \
1446 publish to"
1447 .to_string(),
1448 ),
1449 HealthBadge::Coverage if maturity != Maturity::Production => p.err(format!(
1450 "floor: health_badge 'coverage' has no producer — the coverage gate is a \
1451 production-tier /shipshape-ci output; current maturity is '{}'",
1452 maturity.as_str()
1453 )),
1454 HealthBadge::Scorecard if maturity != Maturity::Production => p.err(format!(
1455 "floor: health_badge 'scorecard' has no producer — the Scorecard action is a \
1456 production-tier output; current maturity is '{}'",
1457 maturity.as_str()
1458 )),
1459 _ => {}
1460 }
1461 }
1462}
1463
1464fn check_publisher_conflicts(targets: &[Target], p: &mut Problems) {
1475 for target in targets
1476 .iter()
1477 .filter(|t| t.adapter == Adapter::CargoPublishCi)
1478 {
1479 let Some(package) = target.package.as_deref() else {
1480 continue;
1481 };
1482 if targets.iter().any(|other| {
1483 other.adapter == Adapter::CargoPublish
1484 && other.registry == target.registry
1485 && other.package.as_deref() == Some(package)
1486 }) {
1487 p.err(format!(
1488 "floor: package {} is declared for {} twice, once with adapter 'cargo-publish' \
1489 (the engine publishes it) and once with 'cargo-publish-ci' (CI publishes it) — \
1490 the engine would publish it AND the tag would trigger CI to publish it again. \
1491 Keep exactly one publisher for a package",
1492 quote_for_diagnostic(package),
1493 quote_for_diagnostic(target.registry.as_str())
1494 ));
1495 }
1496 }
1497}
1498
1499fn check_cargo_publish_evidence(
1540 ecosystems: &[Ecosystem],
1541 targets: &[Target],
1542 repo_root: &Path,
1543 fs: &dyn Fs,
1544 p: &mut Problems,
1545) {
1546 if !ecosystems.contains(&Ecosystem::Rust) {
1547 return;
1548 }
1549 let evidence = crate::facts::cargo_publish_evidence(repo_root, fs);
1550 if evidence.is_empty() {
1551 return;
1552 }
1553
1554 if targets.is_empty() {
1559 let unguarded: Vec<String> = evidence
1560 .iter()
1561 .filter(|m| m.policy == CargoPublishPolicy::Allowed)
1562 .map(|m| quote_for_diagnostic(&m.manifest))
1563 .collect();
1564 if !unguarded.is_empty() {
1565 p.warn(format!(
1566 "the contract declares no publish targets (publish-none), but {} {} not forbid \
1567 publishing — the intent holds in the contract, yet nothing in the tree stops an \
1568 accidental 'cargo publish'. Set publish = false to make it enforceable",
1569 unguarded.join(", "),
1570 if unguarded.len() == 1 { "does" } else { "do" }
1571 ));
1572 }
1573 return;
1574 }
1575
1576 for target in targets.iter().filter(|t| {
1577 t.ecosystem == Ecosystem::Rust
1578 && t.registry == Registry::CratesIo
1579 && matches!(t.adapter, Adapter::CargoPublish | Adapter::CargoPublishCi)
1580 }) {
1581 let forbidden =
1582 |m: &&crate::facts::CargoPublishFlag| m.policy == CargoPublishPolicy::Forbidden;
1583 let blocking: Vec<&crate::facts::CargoPublishFlag> = match target.package.as_deref() {
1589 Some(package) => evidence
1590 .iter()
1591 .filter(|m| m.package.as_deref() == Some(package))
1592 .filter(forbidden)
1593 .collect(),
1594 None if evidence.iter().all(|m| forbidden(&m)) => evidence.iter().collect(),
1595 None => Vec::new(),
1596 };
1597 if blocking.is_empty() {
1598 continue;
1599 }
1600 p.err(format!(
1601 "floor: targets declares a crates.io publish for {} with adapter {}, but {} \
1602 forbids publishing ('publish = false', 'publish = []', or an allow-list without \
1603 'crates-io') — the publish can never succeed. Drop the target (an explicit \
1604 'targets: []' is the publish-none contract) or allow the publish in the manifest. \
1605 Run 'shipshape facts --json' from the repository root and inspect \
1606 data.cargo_publish to see the manifest evidence shipshape read.",
1607 quote_for_diagnostic(target.package.as_deref().unwrap_or("this repo's crate")),
1608 target.adapter.as_str(),
1609 blocking
1610 .iter()
1611 .map(|m| quote_for_diagnostic(&m.manifest))
1612 .collect::<Vec<_>>()
1613 .join(", ")
1614 ));
1615 }
1616}
1617
1618fn check_homebrew_configuration(
1657 targets: &[Target],
1658 distributions: &[Distribution],
1659 p: &mut Problems,
1660) {
1661 let has_tap = distributions.iter().any(|d| d.homebrew_tap.is_some());
1662 let installer_producer = distributions
1663 .iter()
1664 .any(|d| d.installers.contains(&Installer::Homebrew));
1665 let tap_target_producer = targets
1670 .iter()
1671 .any(|t| t.registry == Registry::Homebrew && t.adapter == Adapter::HomebrewTap);
1672 let tap_target_needing_tap = targets.iter().any(|t| {
1676 t.registry == Registry::Homebrew
1677 && matches!(t.adapter, Adapter::HomebrewTap | Adapter::CargoDist)
1678 });
1679
1680 if tap_target_needing_tap && !has_tap {
1683 p.err(
1684 "floor: a 'homebrew'-registry target with adapter 'homebrew-tap' or 'cargo-dist' \
1685 needs distribution.homebrew_tap — the formula has nowhere to be published or \
1686 observed (set distribution.homebrew_tap to the 'owner/repo' tap)"
1687 .to_string(),
1688 );
1689 }
1690
1691 let ci_delegated_tap_target = targets
1692 .iter()
1693 .any(|t| t.registry == Registry::Homebrew && t.adapter == Adapter::CargoDist);
1694 if ci_delegated_tap_target && !installer_producer {
1698 p.err(
1699 "floor: a 'homebrew'-registry target with adapter 'cargo-dist' requires \
1700 distribution.installers to include 'homebrew' — cargo-dist only writes the formula \
1701 through its Homebrew installer job"
1702 .to_string(),
1703 );
1704 }
1705
1706 if installer_producer && tap_target_producer {
1710 p.err(
1711 "floor: both a 'homebrew' installer (distribution.installers) and a 'homebrew'-registry \
1712 target with adapter 'homebrew-tap' generate + push a formula to the tap — they would \
1713 collide; keep exactly one homebrew formula producer, not both"
1714 .to_string(),
1715 );
1716 }
1717
1718 }
1722
1723fn is_fence(line: &str) -> bool {
1727 let t = line.trim_end();
1728 t == "---" || (t.starts_with("---") && t[3..].chars().all(char::is_whitespace))
1729}
1730
1731fn split_frontmatter(text: &str, p: &mut Problems) -> Option<String> {
1735 let mut lines = text.lines();
1736 match lines.next() {
1737 Some(first) if is_fence(first) => {}
1738 _ => {
1739 p.err("frontmatter missing: file must begin with a '---' YAML block".to_string());
1740 return None;
1741 }
1742 }
1743 let mut fm = String::new();
1744 for line in lines {
1745 if is_fence(line) {
1746 return Some(fm);
1747 }
1748 fm.push_str(line);
1749 fm.push('\n');
1750 }
1751 p.err("frontmatter not closed: no terminating '---' line found".to_string());
1752 None
1753}
1754
1755fn parse_frontmatter(fm: &str, p: &mut Problems) -> Mapping {
1758 if fm.trim().is_empty() {
1759 return Mapping::new();
1760 }
1761 match serde_yaml::from_str::<Value>(fm) {
1762 Ok(Value::Null) => Mapping::new(),
1763 Ok(Value::Mapping(m)) => m,
1764 Ok(_) => {
1765 p.err("frontmatter: top level must be a mapping".to_string());
1766 Mapping::new()
1767 }
1768 Err(e) => {
1769 p.err(format!("frontmatter: invalid YAML — {e}"));
1770 Mapping::new()
1771 }
1772 }
1773}
1774
1775fn as_list(v: Option<&Value>) -> Vec<Value> {
1780 match v {
1781 None | Some(Value::Null) => Vec::new(),
1782 Some(Value::Sequence(seq)) => seq.clone(),
1783 Some(other) => vec![other.clone()],
1784 }
1785}
1786
1787fn yaml_display(v: &Value) -> String {
1789 match v {
1790 Value::String(s) => quote_for_diagnostic(s),
1791 Value::Bool(b) => b.to_string(),
1792 Value::Number(n) => n.to_string(),
1793 Value::Null => "null".to_string(),
1794 Value::Sequence(_) => "<list>".to_string(),
1795 Value::Mapping(_) => "<map>".to_string(),
1796 Value::Tagged(t) => yaml_display(&t.value),
1797 }
1798}
1799
1800fn quote_for_diagnostic(s: &str) -> String {
1812 serde_json::Value::String(s.to_owned()).to_string()
1813}
1814
1815fn path_inside_repo(rel: &str) -> bool {
1826 let mut depth: usize = 0;
1827 for comp in Path::new(rel).components() {
1828 match comp {
1829 Component::CurDir => {}
1830 Component::Normal(_) => depth += 1,
1831 Component::ParentDir => {
1832 if depth == 0 {
1834 return false;
1835 }
1836 depth -= 1;
1837 }
1838 Component::RootDir | Component::Prefix(_) => return false,
1841 }
1842 }
1843 true
1844}
1845
1846#[derive(Clone, Copy)]
1851enum CaptureScope {
1852 TopLevel,
1854 Distribution,
1856}
1857
1858impl CaptureScope {
1859 fn label(self) -> &'static str {
1863 match self {
1864 Self::TopLevel => "",
1865 Self::Distribution => "distribution ",
1866 }
1867 }
1868
1869 fn reserved_extra_fields_path(self, key: &str) -> String {
1873 let path = format!(
1874 "reserved 'extra_fields' field {}",
1875 quote_for_diagnostic(key)
1876 );
1877 match self {
1878 Self::TopLevel => path,
1879 Self::Distribution => format!("{path} in distribution"),
1880 }
1881 }
1882}
1883
1884fn capture_unknown_fields(
1911 m: &Mapping,
1912 known: &[&str],
1913 scope: CaptureScope,
1914 schema_version: u32,
1915 p: &mut Problems,
1916) -> serde_json::Map<String, serde_json::Value> {
1917 let label = scope.label();
1918 let mut extra_fields = serde_json::Map::new();
1919 if let Some(v) = m.get("extra_fields") {
1923 merge_reserved_extra_fields(v, scope, &mut extra_fields, p);
1924 }
1925 for (k, v) in m {
1926 match k {
1927 Value::String(key) => {
1928 if known.contains(&key.as_str()) {
1929 continue;
1930 }
1931 if extra_fields.contains_key(key) {
1932 p.err(format!(
1933 "{label}field '{key}' appears both as an unknown top-level key and inside \
1934 the reserved '{label}extra_fields' block — refusing to drop either value; \
1935 remove one"
1936 ));
1937 } else {
1938 let path = format!("{label}extra field {}", quote_for_diagnostic(key));
1939 match yaml_to_json(v, &path) {
1940 Ok(value) => {
1941 extra_fields.insert(key.clone(), value);
1942 }
1943 Err(error) => p.err(error),
1944 }
1945 }
1946 }
1947 other => p.err(format!(
1948 "{label}field key {} must be a string — a non-string key is not a \
1949 forward-compatible schema shape and cannot be preserved losslessly (distinct \
1950 non-string keys collapse onto the same JSON key)",
1951 yaml_display(other)
1952 )),
1953 }
1954 }
1955 if !extra_fields.is_empty() {
1956 let keys = extra_fields
1961 .keys()
1962 .map(|k| quote_for_diagnostic(k))
1963 .collect::<Vec<_>>()
1964 .join(", ");
1965 p.warn(format!(
1966 "unknown {label}field(s) preserved under schema_version {schema_version} \
1967 (forward-compat): [{keys}]"
1968 ));
1969 }
1970 extra_fields
1971}
1972
1973fn merge_reserved_extra_fields(
1981 v: &Value,
1982 scope: CaptureScope,
1983 out: &mut serde_json::Map<String, serde_json::Value>,
1984 p: &mut Problems,
1985) {
1986 let label = scope.label();
1987 match v {
1988 Value::Null => {}
1989 Value::Mapping(inner) => {
1990 for (k, val) in inner {
1991 match k {
1992 Value::String(key) => {
1993 let path = scope.reserved_extra_fields_path(key);
1994 match yaml_to_json(val, &path) {
1995 Ok(value) => {
1996 out.insert(key.clone(), value);
1997 }
1998 Err(error) => p.err(error),
1999 }
2000 }
2001 other => p.err(format!(
2002 "reserved '{label}extra_fields' block has a non-string key {} — its keys \
2003 must be strings",
2004 yaml_display(other)
2005 )),
2006 }
2007 }
2008 }
2009 other => p.err(format!(
2010 "reserved '{label}extra_fields' must be a mapping when present, got {}",
2011 yaml_display(other)
2012 )),
2013 }
2014}
2015
2016fn yaml_to_json(v: &Value, path: &str) -> Result<serde_json::Value, String> {
2024 use serde_json::Value as J;
2025 match v {
2026 Value::Null => Ok(J::Null),
2027 Value::Bool(b) => Ok(J::Bool(*b)),
2028 Value::Number(n) => {
2029 if let Some(i) = n.as_i64() {
2030 Ok(J::from(i))
2031 } else if let Some(u) = n.as_u64() {
2032 Ok(J::from(u))
2033 } else if let Some(f) = n.as_f64() {
2034 Ok(serde_json::Number::from_f64(f).map_or(J::Null, J::Number))
2035 } else {
2036 Ok(J::Null)
2037 }
2038 }
2039 Value::String(s) => Ok(J::String(s.clone())),
2040 Value::Sequence(seq) => seq
2041 .iter()
2042 .enumerate()
2043 .map(|(index, value)| yaml_to_json(value, &format!("{path}[{index}]")))
2044 .collect::<Result<Vec<_>, _>>()
2045 .map(J::Array),
2046 Value::Mapping(m) => {
2047 let mut obj = serde_json::Map::new();
2048 for (k, val) in m {
2049 let Value::String(key) = k else {
2050 return Err(format!(
2051 "preserved content at {path} has non-string mapping key {}; \
2052 canonical JSON object keys must be strings to preserve every value. \
2053 Quote the key in YAML or replace it with a string key",
2054 yaml_display(k)
2055 ));
2056 };
2057 let child_path = format!("{path}[{}]", quote_for_diagnostic(key));
2058 obj.insert(key.clone(), yaml_to_json(val, &child_path)?);
2059 }
2060 Ok(J::Object(obj))
2061 }
2062 Value::Tagged(t) => yaml_to_json(&t.value, path),
2063 }
2064}
2065
2066#[cfg(test)]
2067mod tests {
2068 use super::*;
2069 use std::collections::{HashMap, HashSet};
2070
2071 struct FakeFs {
2073 dirs: HashSet<PathBuf>,
2074 files: HashMap<PathBuf, Vec<u8>>,
2075 }
2076
2077 impl FakeFs {
2078 fn empty() -> Self {
2079 Self {
2080 dirs: HashSet::new(),
2081 files: HashMap::new(),
2082 }
2083 }
2084
2085 fn with_dirs<const N: usize>(dirs: [&str; N]) -> Self {
2086 Self {
2087 dirs: dirs.iter().map(PathBuf::from).collect(),
2088 files: HashMap::new(),
2089 }
2090 }
2091
2092 fn with_file(path: &str, content: &str) -> Self {
2093 Self {
2094 dirs: HashSet::new(),
2095 files: HashMap::from([(PathBuf::from(path), content.as_bytes().to_vec())]),
2096 }
2097 }
2098
2099 fn with_files<const N: usize>(files: [(&str, &str); N]) -> Self {
2100 Self {
2101 dirs: HashSet::new(),
2102 files: files
2103 .iter()
2104 .map(|(p, c)| (PathBuf::from(p), c.as_bytes().to_vec()))
2105 .collect(),
2106 }
2107 }
2108 }
2109
2110 impl Fs for FakeFs {
2111 fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
2112 self.files
2113 .get(path)
2114 .cloned()
2115 .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))
2116 }
2117 fn exists(&self, path: &Path) -> bool {
2118 self.dirs.contains(path)
2119 }
2120 fn is_dir(&self, path: &Path) -> bool {
2121 self.dirs.contains(path)
2122 }
2123 fn is_file(&self, path: &Path) -> bool {
2124 self.files.contains_key(path)
2125 }
2126 fn read_dir(&self, _dir: &Path) -> io::Result<Vec<String>> {
2127 Ok(Vec::new())
2129 }
2130 }
2131
2132 fn repo() -> &'static Path {
2133 Path::new("/repo")
2134 }
2135
2136 fn norm(text: &str) -> Normalized {
2137 normalize_str(text, repo(), &FakeFs::empty())
2138 }
2139
2140 fn norm_with(text: &str, fs: &dyn Fs) -> Normalized {
2141 normalize_str(text, repo(), fs)
2142 }
2143
2144 fn assert_error_contains(n: &Normalized, needle: &str) {
2145 assert!(
2146 !n.is_valid(),
2147 "expected invalid, got clean normalize: {:?}",
2148 n.contract
2149 );
2150 assert!(
2151 n.problems.errors.iter().any(|e| e.contains(needle)),
2152 "no error contained {needle:?}; errors were {:?}",
2153 n.problems.errors
2154 );
2155 }
2156
2157 const MINIMAL: &str = "---\nstatus: approved\nmaturity: mvp\n---\n";
2158
2159 #[test]
2160 fn materializes_all_defaults() {
2161 let c = norm(MINIMAL).contract;
2162 assert_eq!(c.schema_version, 2);
2165 assert_eq!(c.status, Status::Approved);
2166 assert_eq!(c.maturity, Maturity::Mvp);
2167 assert!(c.ecosystems.is_empty());
2168 assert!(c.targets.is_empty());
2169 assert_eq!(c.versioning, VersioningBase::Semver);
2170 assert_eq!(c.versioning_pattern, None);
2171 assert_eq!(c.changelog.mode, ChangelogMode::Curated);
2172 assert_eq!(c.changelog.source, ChangelogSource::Manual);
2173 assert_eq!(c.changelog.fragment_dir, DEFAULT_FRAGMENT_DIR);
2174 assert!(!c.conventional_commits);
2175 assert_eq!(c.release.model, ReleaseModel::Gated);
2176 assert_eq!(c.release.layout, ReleaseLayout::Single);
2177 assert_eq!(c.release.bump_hook, None); assert_eq!(c.contribution_provenance, ContributionProvenance::None);
2179 assert_eq!(c.provenance_level, ProvenanceLevel::None);
2180 assert_eq!(c.dependency_bot, DependencyBot::Dependabot); assert_eq!(c.license, "MIT");
2182 assert_eq!(c.docs_site, DocsSite::None);
2183 assert_eq!(c.health_badges, vec![HealthBadge::Ci, HealthBadge::License]);
2185 assert!(c.extra_fields.is_empty());
2186 }
2187
2188 #[test]
2189 fn parses_a_declared_bump_hook() {
2190 let c = norm(
2193 "---\nstatus: approved\nmaturity: mvp\n\
2194 release:\n model: gated\n bump_hook: \"cargo insta test --accept\"\n---\n",
2195 )
2196 .contract;
2197 assert_eq!(
2198 c.release.bump_hook.as_deref(),
2199 Some("cargo insta test --accept")
2200 );
2201 let json = serde_json::to_value(&c).unwrap();
2203 assert_eq!(
2204 json["release"]["bump_hook"],
2205 serde_json::json!("cargo insta test --accept")
2206 );
2207 }
2208
2209 #[test]
2210 fn an_absent_bump_hook_is_omitted_from_canonical_json() {
2211 let c = norm(MINIMAL).contract;
2214 let json = serde_json::to_value(&c).unwrap();
2215 assert!(
2216 json["release"].get("bump_hook").is_none(),
2217 "an absent hook must not appear in canonical JSON, got {:?}",
2218 json["release"]
2219 );
2220 }
2221
2222 #[test]
2223 fn an_empty_bump_hook_is_rejected() {
2224 assert_error_contains(
2227 &norm(
2228 "---\nstatus: approved\nmaturity: mvp\n\
2229 release:\n model: gated\n bump_hook: \" \"\n---\n",
2230 ),
2231 "release.bump_hook must be a non-empty",
2232 );
2233 }
2234
2235 #[test]
2236 fn a_non_string_bump_hook_is_rejected() {
2237 assert_error_contains(
2238 &norm(
2239 "---\nstatus: approved\nmaturity: mvp\n\
2240 release:\n model: gated\n bump_hook: [not, a, string]\n---\n",
2241 ),
2242 "release.bump_hook must be a command string",
2243 );
2244 }
2245
2246 #[test]
2247 fn spike_defaults_no_bot_no_ci_badge() {
2248 let c = norm("---\nstatus: approved\nmaturity: spike\n---\n").contract;
2249 assert_eq!(c.dependency_bot, DependencyBot::None);
2250 assert_eq!(c.health_badges, vec![HealthBadge::License]);
2251 }
2252
2253 #[test]
2254 fn maturity_is_required() {
2255 assert_error_contains(
2256 &norm("---\nstatus: approved\n---\n"),
2257 "maturity is required",
2258 );
2259 }
2260
2261 #[test]
2262 fn expands_targets_from_ecosystems() {
2263 let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [python]\n---\n").contract;
2264 assert_eq!(c.targets.len(), 1);
2265 assert_eq!(c.targets[0].ecosystem, Ecosystem::Python);
2266 assert_eq!(c.targets[0].package, None);
2267 assert_eq!(c.targets[0].registry, Registry::Pypi);
2268 assert_eq!(c.targets[0].adapter, Adapter::GhActionPypiPublish);
2269 }
2270
2271 #[test]
2277 fn explicit_empty_targets_is_honored_not_expanded() {
2278 let n =
2279 norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n---\n");
2280 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2281 let c = n.contract;
2282 assert_eq!(c.ecosystems, vec![Ecosystem::Rust]);
2284 assert!(
2286 c.targets.is_empty(),
2287 "explicit targets:[] must stay empty, got {:?}",
2288 c.targets
2289 );
2290 }
2291
2292 #[test]
2295 fn omitted_targets_still_expands_to_ecosystem_default() {
2296 let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract;
2297 assert_eq!(c.targets.len(), 1);
2298 assert_eq!(c.targets[0].ecosystem, Ecosystem::Rust);
2299 assert_eq!(c.targets[0].registry, Registry::CratesIo);
2300 assert_eq!(c.targets[0].adapter, Adapter::CargoPublish);
2301 }
2302
2303 #[test]
2307 fn null_targets_expands_like_omitted() {
2308 let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n---\n")
2309 .contract;
2310 assert_eq!(c.targets.len(), 1);
2311 assert_eq!(c.targets[0].registry, Registry::CratesIo);
2312 }
2313
2314 #[test]
2320 fn empty_targets_round_trips_through_canonical_json() {
2321 let n =
2322 norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n---\n");
2323 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2324 let json = serde_json::to_value(&n.contract).unwrap();
2325 assert_eq!(json["targets"], serde_json::json!([]));
2327
2328 let targets_yaml = serde_yaml::to_string(&json["targets"]).unwrap();
2331 let refed = format!(
2332 "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: {}\n---\n",
2333 targets_yaml.trim()
2334 );
2335 let n2 = norm(&refed);
2336 assert!(n2.is_valid(), "errors: {:?}", n2.problems.errors);
2337 assert_eq!(n2.contract.targets, n.contract.targets);
2338 assert!(n2.contract.targets.is_empty());
2339 }
2340
2341 #[test]
2346 fn explicit_empty_targets_skips_registry_license_floor() {
2347 let n = norm(
2348 "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n\
2349 license: not-a-real-spdx-id\n---\n",
2350 );
2351 assert!(!n.is_valid());
2353 assert!(
2356 !n.problems
2357 .errors
2358 .iter()
2359 .any(|e| e.contains("floor: a target has a registry")),
2360 "registry-license floor fired despite empty targets: {:?}",
2361 n.problems.errors
2362 );
2363 }
2364
2365 #[test]
2370 fn registry_badge_with_explicit_empty_targets_fails() {
2371 let n = norm(
2372 "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n\
2373 health_badges: [registry, license]\n---\n",
2374 );
2375 assert_error_contains(&n, "health_badge 'registry' has no producer");
2376 }
2377
2378 #[test]
2383 fn explicit_empty_targets_with_no_ecosystems() {
2384 let n = norm("---\nstatus: approved\nmaturity: mvp\ntargets: []\n---\n");
2385 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2386 assert!(n.contract.targets.is_empty());
2387 assert_eq!(
2388 n.contract.health_badges,
2389 vec![HealthBadge::Ci, HealthBadge::License]
2390 );
2391 }
2392
2393 const PUBLISH_NONE: &str =
2396 "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n---\n";
2397 const RUST_DEFAULT: &str = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n";
2398
2399 #[test]
2404 fn publish_none_confirmed_by_publish_false_normalizes_silently() {
2405 let fs = FakeFs::with_file(
2406 "/repo/Cargo.toml",
2407 "[package]\nname = \"intakectl\"\nversion = \"0.1.0\"\npublish = false\n",
2408 );
2409 let n = norm_with(PUBLISH_NONE, &fs);
2410 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2411 assert!(n.contract.targets.is_empty());
2412 assert!(
2413 !n.problems
2414 .warnings
2415 .iter()
2416 .any(|w| w.contains("publish-none")),
2417 "publish = false is the supporting evidence — nothing to warn about: {:?}",
2418 n.problems.warnings
2419 );
2420 }
2421
2422 #[test]
2426 fn publish_none_without_publish_false_warns_with_the_manifest_path() {
2427 let fs = FakeFs::with_file(
2428 "/repo/Cargo.toml",
2429 "[package]\nname = \"intakectl\"\nversion = \"0.1.0\"\n",
2430 );
2431 let n = norm_with(PUBLISH_NONE, &fs);
2432 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2433 assert!(n.contract.targets.is_empty());
2434 let warning = n
2435 .problems
2436 .warnings
2437 .iter()
2438 .find(|w| w.contains("publish-none"))
2439 .unwrap_or_else(|| panic!("no publish-none warning in {:?}", n.problems.warnings));
2440 assert!(warning.contains("Cargo.toml"), "warning was {warning:?}");
2441 }
2442
2443 #[test]
2448 fn expanded_crates_io_target_contradicted_by_publish_false_is_an_error() {
2449 let fs = FakeFs::with_file(
2450 "/repo/Cargo.toml",
2451 "[package]\nname = \"intakectl\"\nversion = \"0.1.0\"\npublish = false\n",
2452 );
2453 let n = norm_with(RUST_DEFAULT, &fs);
2454 assert_error_contains(&n, "forbids publishing");
2455 assert!(
2456 n.problems.errors.iter().any(|e| e.contains("targets: []")),
2457 "the error must name the publish-none escape: {:?}",
2458 n.problems.errors
2459 );
2460 assert!(
2461 n.problems
2462 .errors
2463 .iter()
2464 .any(|e| e.contains("shipshape facts --json") && e.contains("data.cargo_publish")),
2465 "the error must point to the inspectable evidence: {:?}",
2466 n.problems.errors
2467 );
2468 }
2469
2470 #[test]
2473 fn named_crates_io_target_contradicted_by_its_member_manifest_is_an_error() {
2474 let fs = FakeFs::with_files([
2475 (
2476 "/repo/Cargo.toml",
2477 "[workspace]\nmembers = [\"a\", \"b\"]\n",
2478 ),
2479 (
2480 "/repo/a/Cargo.toml",
2481 "[package]\nname = \"a\"\nversion = \"1.0.0\"\n",
2482 ),
2483 (
2484 "/repo/b/Cargo.toml",
2485 "[package]\nname = \"b\"\nversion = \"1.0.0\"\npublish = false\n",
2486 ),
2487 ]);
2488 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
2489 targets:\n - ecosystem: rust\n package: b\n registry: crates.io\n---\n";
2490 let n = norm_with(text, &fs);
2491 assert_error_contains(&n, "b/Cargo.toml");
2492
2493 let text_a = text.replace("package: b", "package: a");
2495 let n_a = norm_with(&text_a, &fs);
2496 assert!(n_a.is_valid(), "errors: {:?}", n_a.problems.errors);
2497 }
2498
2499 #[test]
2504 fn ci_delegated_crates_io_target_is_contradicted_by_publish_false_too() {
2505 let fs = FakeFs::with_file(
2506 "/repo/Cargo.toml",
2507 "[package]\nname = \"tool\"\nversion = \"1.0.0\"\npublish = false\n",
2508 );
2509 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n \
2510 - ecosystem: rust\n package: tool\n registry: crates.io\n \
2511 adapter: cargo-publish-ci\n---\n";
2512 let n = norm_with(text, &fs);
2513 assert_error_contains(&n, "forbids publishing");
2514 assert!(
2515 !n.problems
2516 .warnings
2517 .iter()
2518 .any(|w| w.contains("publish-none")),
2519 "a delegated publish is not publish-none: {:?}",
2520 n.problems.warnings
2521 );
2522 }
2523
2524 #[test]
2530 fn publish_none_with_a_distribution_block_is_a_floor_error() {
2531 let n = norm(
2532 "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets: []\n\
2533 distribution:\n adapter: cargo-dist\n gh_releases: true\n---\n",
2534 );
2535 assert_error_contains(&n, "but targets is empty");
2536 }
2537
2538 #[test]
2541 fn a_distribution_block_without_any_target_is_a_floor_error() {
2542 let n = norm(
2543 "---\nstatus: approved\nmaturity: mvp\n\
2544 distribution:\n adapter: cargo-dist\n gh_releases: true\n---\n",
2545 );
2546 assert_error_contains(&n, "but targets is empty");
2547 }
2548
2549 #[test]
2553 fn a_binary_only_rust_repo_gets_no_publish_none_warning() {
2554 let fs = FakeFs::with_file(
2555 "/repo/Cargo.toml",
2556 "[package]\nname = \"tool\"\nversion = \"1.0.0\"\n",
2557 );
2558 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n \
2559 - ecosystem: rust\n package: tool\n registry: gh-releases\n \
2560 adapter: cargo-dist\n---\ndistribution:\n";
2561 let n = norm_with(text, &fs);
2562 assert!(
2563 !n.problems
2564 .warnings
2565 .iter()
2566 .any(|w| w.contains("publish-none")),
2567 "a binary-publishing repo was called publish-none: {:?}",
2568 n.problems.warnings
2569 );
2570 }
2571
2572 #[test]
2576 fn an_unresolvable_publish_key_produces_no_diagnostic_in_either_direction() {
2577 let fs = FakeFs::with_files([
2578 ("/repo/Cargo.toml", "[workspace]\nmembers = [\"a\"]\n"),
2579 (
2580 "/repo/a/Cargo.toml",
2581 "[package]\nname = \"a\"\nversion = \"1.0.0\"\npublish.workspace = true\n",
2582 ),
2583 ]);
2584 let declared = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n \
2586 - ecosystem: rust\n package: a\n registry: crates.io\n---\n";
2587 let n = norm_with(declared, &fs);
2588 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2589 let n_none = norm_with(PUBLISH_NONE, &fs);
2591 assert!(
2592 !n_none
2593 .problems
2594 .warnings
2595 .iter()
2596 .any(|w| w.contains("publish-none")),
2597 "an unresolved publish key was reported as unguarded: {:?}",
2598 n_none.problems.warnings
2599 );
2600 }
2601
2602 #[test]
2605 fn inherited_publish_false_is_supporting_evidence_for_publish_none() {
2606 let fs = FakeFs::with_files([
2607 (
2608 "/repo/Cargo.toml",
2609 "[workspace]\nmembers = [\"a\"]\n\n[workspace.package]\npublish = false\n",
2610 ),
2611 (
2612 "/repo/a/Cargo.toml",
2613 "[package]\nname = \"a\"\nversion = \"1.0.0\"\npublish.workspace = true\n",
2614 ),
2615 ]);
2616 let n = norm_with(PUBLISH_NONE, &fs);
2617 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2618 assert!(
2619 !n.problems
2620 .warnings
2621 .iter()
2622 .any(|w| w.contains("publish-none")),
2623 "inherited publish = false was not read as evidence: {:?}",
2624 n.problems.warnings
2625 );
2626 }
2627
2628 #[test]
2631 fn a_blocked_member_under_a_hybrid_root_still_contradicts_its_target() {
2632 let fs = FakeFs::with_files([
2633 (
2634 "/repo/Cargo.toml",
2635 "[package]\nname = \"root\"\nversion = \"1.0.0\"\n\n\
2636 [workspace]\nmembers = [\"cli\"]\n",
2637 ),
2638 (
2639 "/repo/cli/Cargo.toml",
2640 "[package]\nname = \"cli\"\nversion = \"1.0.0\"\npublish = false\n",
2641 ),
2642 ]);
2643 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n \
2644 - ecosystem: rust\n package: cli\n registry: crates.io\n---\n";
2645 assert_error_contains(&norm_with(text, &fs), "cli/Cargo.toml");
2646 }
2647
2648 #[test]
2653 fn cargo_publish_cross_read_is_silent_without_a_manifest() {
2654 let n = norm(PUBLISH_NONE);
2655 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2656 assert!(
2657 n.problems.warnings.is_empty(),
2658 "warnings without evidence: {:?}",
2659 n.problems.warnings
2660 );
2661 assert!(norm(RUST_DEFAULT).is_valid());
2662 }
2663
2664 #[test]
2667 fn cargo_publish_cross_read_ignores_an_unmatched_package() {
2668 let fs = FakeFs::with_file(
2669 "/repo/Cargo.toml",
2670 "[package]\nname = \"other\"\nversion = \"1.0.0\"\npublish = false\n",
2671 );
2672 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n \
2673 - ecosystem: rust\n package: unrelated\n registry: crates.io\n---\n";
2674 let n = norm_with(text, &fs);
2675 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2676 }
2677
2678 #[test]
2681 fn cargo_publish_cross_read_does_not_touch_a_non_rust_contract() {
2682 let fs = FakeFs::with_file(
2683 "/repo/Cargo.toml",
2684 "[package]\nname = \"tool\"\nversion = \"1.0.0\"\npublish = false\n",
2685 );
2686 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [node]\n---\n";
2687 let n = norm_with(text, &fs);
2688 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2689 assert!(
2690 !n.problems
2691 .warnings
2692 .iter()
2693 .any(|w| w.contains("publish-none")),
2694 "warnings: {:?}",
2695 n.problems.warnings
2696 );
2697 }
2698
2699 #[test]
2700 fn node_monorepo_adapter_is_changesets() {
2701 let text = "---\nstatus: approved\nmaturity: production\necosystems: [node]\n\
2702 release:\n model: gated\n layout: monorepo\n---\n";
2703 let c = norm(text).contract;
2704 assert_eq!(c.targets[0].adapter, Adapter::Changesets);
2705 }
2706
2707 #[test]
2708 fn ecosystems_dedup_to_canonical_order() {
2709 let c =
2710 norm("---\nstatus: approved\nmaturity: mvp\necosystems: [python, rust, python]\n---\n")
2711 .contract;
2712 assert_eq!(c.ecosystems, vec![Ecosystem::Rust, Ecosystem::Python]);
2713 }
2714
2715 #[test]
2716 fn calver_splits_base_and_pattern() {
2717 let c = norm(
2718 "---\nstatus: approved\nmaturity: mvp\nversioning: \"calver:YYYY.MM.MICRO\"\n---\n",
2719 )
2720 .contract;
2721 assert_eq!(c.versioning, VersioningBase::Calver);
2722 assert_eq!(c.versioning_pattern.as_deref(), Some("YYYY.MM.MICRO"));
2723 }
2724
2725 #[test]
2726 fn bare_calver_is_rejected() {
2727 assert_error_contains(
2728 &norm("---\nstatus: approved\nmaturity: mvp\nversioning: calver\n---\n"),
2729 "must carry its pattern",
2730 );
2731 }
2732
2733 #[test]
2734 fn floor_auto_on_spike() {
2735 let text = "---\nstatus: approved\nmaturity: spike\n\
2736 release:\n model: auto\n layout: single\nhealth_badges: [license]\n---\n";
2737 assert_error_contains(&norm(text), "release.model 'auto' is not allowed");
2738 }
2739
2740 #[test]
2741 fn floor_slsa_l3_production_only() {
2742 assert_error_contains(
2743 &norm("---\nstatus: approved\nmaturity: mvp\nprovenance_level: slsa-l3\n---\n"),
2744 "slsa-l3' is production-only",
2745 );
2746 }
2747
2748 #[test]
2749 fn floor_registry_requires_valid_license() {
2750 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
2751 license: Proprietary-Acme\nhealth_badges: [ci, registry, license]\n---\n";
2752 let n = norm(text);
2753 assert_error_contains(&n, "not a valid SPDX expression");
2755 assert!(n
2756 .problems
2757 .errors
2758 .iter()
2759 .any(|e| e.contains("floor: a target has a registry")));
2760 }
2761
2762 #[test]
2763 fn floor_badge_without_producer() {
2764 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [python]\n\
2765 health_badges: [ci, coverage]\n---\n";
2766 assert_error_contains(&norm(text), "health_badge 'coverage' has no producer");
2767 }
2768
2769 #[test]
2770 fn floor_schema_version_too_new() {
2771 assert_error_contains(
2772 &norm("---\nschema_version: 99\nstatus: approved\nmaturity: mvp\n---\n"),
2773 "exceeds what this tool knows",
2774 );
2775 }
2776
2777 #[test]
2778 fn floor_fragment_dir_escape() {
2779 let text = "---\nstatus: approved\nmaturity: mvp\n\
2780 changelog:\n mode: fragment\n source: manual\n fragment_dir: /etc\n---\n";
2781 assert_error_contains(&norm(text), "must be a relative path inside the repo");
2782 }
2783
2784 #[test]
2785 fn floor_fragment_dir_escape_relative_root() {
2786 let text = "---\nstatus: approved\nmaturity: mvp\n\
2791 changelog:\n mode: fragment\n source: manual\n fragment_dir: ../etc\n---\n";
2792 let n = normalize_str(text, Path::new("."), &FakeFs::empty());
2793 assert_error_contains(&n, "must be a relative path inside the repo");
2794 }
2795
2796 #[test]
2797 fn path_inside_repo_verdicts() {
2798 assert!(path_inside_repo("changelog/fragments"));
2800 assert!(path_inside_repo("./changelog/fragments"));
2801 assert!(path_inside_repo("a/../fragments"));
2802 assert!(path_inside_repo("")); assert!(!path_inside_repo("/etc"));
2805 assert!(!path_inside_repo("../etc"));
2806 assert!(!path_inside_repo("a/../../etc"));
2807 }
2808
2809 #[test]
2810 fn unknown_fields_preserved_and_warned() {
2811 let text =
2812 "---\nstatus: approved\nmaturity: mvp\nroadmap_url: https://example.com/x\n---\n";
2813 let n = norm(text);
2814 assert!(n.is_valid());
2815 assert_eq!(
2816 n.contract
2817 .extra_fields
2818 .get("roadmap_url")
2819 .and_then(|v| v.as_str()),
2820 Some("https://example.com/x")
2821 );
2822 assert!(n
2823 .problems
2824 .warnings
2825 .iter()
2826 .any(|w| w.contains("roadmap_url") && w.contains("forward-compat")));
2827 }
2828
2829 #[test]
2830 fn duplicate_key_is_rejected() {
2831 assert_error_contains(
2832 &norm("---\nstatus: approved\nstatus: draft\nmaturity: mvp\n---\n"),
2833 "invalid YAML",
2834 );
2835 }
2836
2837 #[test]
2838 fn missing_frontmatter_is_rejected() {
2839 assert_error_contains(&norm("no frontmatter here\n"), "frontmatter missing");
2840 }
2841
2842 #[test]
2843 fn unclosed_frontmatter_is_rejected() {
2844 assert_error_contains(&norm("---\nstatus: approved\n"), "frontmatter not closed");
2845 }
2846
2847 #[test]
2848 fn invalid_enum_records_error_and_continues() {
2849 let n = norm("---\nstatus: bogus\nmaturity: alsobogus\n---\n");
2851 assert!(n.problems.errors.iter().any(|e| e.contains("status")));
2852 assert!(n.problems.errors.iter().any(|e| e.contains("maturity")));
2853 }
2854
2855 #[test]
2856 fn fragment_dir_present_suppresses_advisory() {
2857 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
2858 changelog:\n mode: fragment\n source: manual\n---\n";
2859 let fs = FakeFs::with_dirs(["/repo/changelog/fragments"]);
2861 let n = norm_with(text, &fs);
2862 assert!(n.is_valid());
2863 assert!(
2864 !n.problems
2865 .warnings
2866 .iter()
2867 .any(|w| w.contains("does not exist yet")),
2868 "advisory should be suppressed when the dir exists: {:?}",
2869 n.problems.warnings
2870 );
2871 }
2872
2873 #[test]
2874 fn serializes_to_schema_v4_shape() {
2875 let json =
2876 serde_json::to_value(&norm("---\nstatus: approved\nmaturity: mvp\n---\n").contract)
2877 .unwrap();
2878 for key in [
2880 "schema_version",
2881 "status",
2882 "maturity",
2883 "ecosystems",
2884 "targets",
2885 "distributions",
2886 "versioning",
2887 "versioning_pattern",
2888 "changelog",
2889 "conventional_commits",
2890 "release",
2891 "contribution_provenance",
2892 "provenance_level",
2893 "dependency_bot",
2894 "health_badges",
2895 "license",
2896 "docs_site",
2897 "warnings",
2898 ] {
2899 assert!(json.get(key).is_some(), "missing §4 key {key}");
2900 }
2901 assert!(json["versioning_pattern"].is_null());
2902 assert_eq!(json["distributions"], serde_json::json!([]));
2905 assert!(
2910 json.get("extra_fields").is_none(),
2911 "empty extra_fields must be absent, got {:?}",
2912 json.get("extra_fields")
2913 );
2914 }
2915
2916 #[test]
2921 fn empty_extra_fields_absent_populated_present() {
2922 let empty = serde_json::to_value(
2924 norm(
2925 "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2926 distribution:\n adapter: cargo-dist\n---\n",
2927 )
2928 .contract,
2929 )
2930 .unwrap();
2931 assert!(
2932 empty.get("extra_fields").is_none(),
2933 "empty top-level extra_fields must be absent"
2934 );
2935 assert!(
2936 empty["distributions"][0].get("extra_fields").is_none(),
2937 "empty nested extra_fields must be absent"
2938 );
2939
2940 let populated = serde_json::to_value(
2943 norm(
2944 "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2945 roadmap_url: https://example.com/roadmap\n\
2946 distribution:\n adapter: cargo-dist\n future_x: 1\n---\n",
2947 )
2948 .contract,
2949 )
2950 .unwrap();
2951 assert_eq!(
2952 populated["extra_fields"]["roadmap_url"],
2953 "https://example.com/roadmap"
2954 );
2955 assert_eq!(populated["distributions"][0]["extra_fields"]["future_x"], 1);
2956 }
2957
2958 #[test]
2963 fn registry_only_contract_has_no_distribution() {
2964 let c = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract;
2965 assert!(c.distributions.is_empty());
2966 assert_eq!(c.targets.len(), 1);
2967 assert_eq!(c.targets[0].registry, Registry::CratesIo);
2968 }
2969
2970 #[test]
2973 fn cargo_dist_distribution_coexists_with_registry() {
2974 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
2975 targets:\n - {ecosystem: rust, package: issuectl, registry: crates.io, adapter: cargo-publish}\n\
2976 distribution:\n adapter: cargo-dist\n installers: [shell, homebrew]\n \
2977 homebrew_tap: jarimustonen/homebrew-issuectl\n---\n";
2978 let n = norm(text);
2979 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
2980 let c = n.contract;
2981 assert_eq!(c.targets.len(), 1);
2983 assert_eq!(c.targets[0].registry, Registry::CratesIo);
2984 assert_eq!(c.targets[0].adapter, Adapter::CargoPublish);
2985 let d = c
2987 .distributions
2988 .into_iter()
2989 .next()
2990 .expect("distribution present");
2991 assert_eq!(d.adapter, DistributionAdapter::CargoDist);
2992 assert!(d.gh_releases); assert_eq!(d.installers, vec![Installer::Shell, Installer::Homebrew]);
2994 assert_eq!(
2995 d.homebrew_tap.as_deref(),
2996 Some("jarimustonen/homebrew-issuectl")
2997 );
2998 }
2999
3000 #[test]
3002 fn distribution_json_round_trip_shape() {
3003 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3004 distribution:\n adapter: cargo-dist\n gh_releases: true\n \
3005 installers: [shell, homebrew]\n homebrew_tap: jarimustonen/homebrew-issuectl\n---\n";
3006 let json = serde_json::to_value(&norm(text).contract).unwrap();
3007 let d = &json["distributions"][0];
3008 assert_eq!(d["adapter"], "cargo-dist");
3009 assert_eq!(d["gh_releases"], true);
3010 assert_eq!(d["installers"], serde_json::json!(["shell", "homebrew"]));
3011 assert_eq!(d["homebrew_tap"], "jarimustonen/homebrew-issuectl");
3012 assert!(d["package"].is_null());
3014 }
3015
3016 #[test]
3021 fn dist_workspace_tap_without_contract_tap_warns() {
3022 let fs = FakeFs::with_file(
3023 "/repo/dist-workspace.toml",
3024 "[dist]\ntap = \"owner/homebrew-tool\"\n",
3025 );
3026 let n = norm_with(
3027 "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n",
3028 &fs,
3029 );
3030 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3031 assert!(
3032 n.problems
3033 .warnings
3034 .iter()
3035 .any(|w| w.contains("distribution.homebrew_tap")
3036 && w.contains("will not be planned")),
3037 "expected cargo-dist drift warning: {:?}",
3038 n.problems.warnings
3039 );
3040 }
3041
3042 #[test]
3045 fn dist_workspace_homebrew_publish_job_without_target_is_a_floor() {
3046 let fs = FakeFs::with_file(
3047 "/repo/dist-workspace.toml",
3048 "[dist]\npublish-jobs = [\"homebrew\"]\n",
3049 );
3050 let n = norm_with(
3051 "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n",
3052 &fs,
3053 );
3054 assert_error_contains(&n, "no delegated Homebrew target");
3055 }
3056
3057 #[test]
3059 fn dist_workspace_tap_with_distribution_lacking_tap_warns() {
3060 let fs = FakeFs::with_file(
3061 "/repo/dist-workspace.toml",
3062 "[dist]\ntap = \"owner/homebrew-tool\"\n",
3063 );
3064 let n = norm_with(
3065 "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ndistribution:\n \
3066 adapter: cargo-dist\n installers: [shell]\n---\n",
3067 &fs,
3068 );
3069 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3070 assert!(
3071 n.problems
3072 .warnings
3073 .iter()
3074 .any(|w| w.contains("distribution.homebrew_tap")),
3075 "expected cargo-dist drift warning: {:?}",
3076 n.problems.warnings
3077 );
3078 }
3079
3080 #[test]
3081 fn dist_workspace_homebrew_publish_job_refuses_an_engine_owned_tap_target() {
3082 let fs = FakeFs::with_file(
3083 "/repo/dist-workspace.toml",
3084 "[dist]\ntap = \"owner/homebrew-tool\"\npublish-jobs = [\"homebrew\"]\n",
3085 );
3086 let n = norm_with(
3087 "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n \
3088 - {ecosystem: rust, package: tool, registry: gh-releases, adapter: cargo-dist}\n \
3089 - {ecosystem: rust, package: tool, registry: homebrew, adapter: homebrew-tap}\n\
3090 distribution:\n adapter: cargo-dist\n installers: [shell]\n \
3091 homebrew_tap: owner/homebrew-tool\n---\n",
3092 &fs,
3093 );
3094 assert_error_contains(
3095 &n,
3096 "cargo-dist CI and shipshape would both write the same tap",
3097 );
3098 }
3099
3100 #[test]
3101 fn dist_workspace_homebrew_publish_job_accepts_a_delegated_tap_target() {
3102 let fs = FakeFs::with_file(
3103 "/repo/dist-workspace.toml",
3104 "[dist]\ntap = \"owner/homebrew-tool\"\npublish-jobs = [\"homebrew\"]\n",
3105 );
3106 let n = norm_with(
3107 "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n \
3108 - {ecosystem: rust, package: tool, registry: gh-releases, adapter: cargo-dist}\n \
3109 - {ecosystem: rust, package: tool, registry: homebrew, adapter: cargo-dist}\n\
3110 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
3111 homebrew_tap: owner/homebrew-tool\n---\n",
3112 &fs,
3113 );
3114 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3115 }
3116
3117 #[test]
3120 fn dist_workspace_tap_matching_contract_is_silent() {
3121 let fs = FakeFs::with_file(
3122 "/repo/dist-workspace.toml",
3123 "[dist]\ntap = \"owner/homebrew-tool\"\n",
3124 );
3125 let n = norm_with(
3126 "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\ntargets:\n \
3127 - {ecosystem: rust, package: tool, registry: crates.io, adapter: cargo-publish}\n \
3128 - {ecosystem: rust, package: tool, registry: gh-releases, adapter: cargo-dist}\n \
3129 - {ecosystem: rust, package: tool, registry: homebrew, adapter: homebrew-tap}\n\
3130 distribution:\n adapter: cargo-dist\n installers: [shell]\n homebrew_tap: owner/homebrew-tool\n---\n",
3131 &fs,
3132 );
3133 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3134 assert!(
3135 n.problems.warnings.is_empty(),
3136 "warnings: {:?}",
3137 n.problems.warnings
3138 );
3139 }
3140
3141 #[test]
3142 fn ci_delegated_homebrew_rejects_a_dist_workspace_tap_mismatch() {
3143 let fs = FakeFs::with_file(
3144 "/repo/dist-workspace.toml",
3145 "[dist]\ntap = \"owner/actual-tap\"\npublish-jobs = [\"homebrew\"]\n",
3146 );
3147 let n = norm_with(
3148 "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
3149 - {ecosystem: rust, package: tool, registry: homebrew, adapter: cargo-dist}\n\
3150 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
3151 homebrew_tap: owner/declared-tap\n---\n",
3152 &fs,
3153 );
3154 assert_error_contains(
3155 &n,
3156 "cargo-dist would write one tap while shipshape verifies another",
3157 );
3158 }
3159
3160 #[test]
3162 fn absent_dist_workspace_is_silent() {
3163 let n = norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n");
3164 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3165 assert!(
3166 n.problems.warnings.is_empty(),
3167 "warnings: {:?}",
3168 n.problems.warnings
3169 );
3170 }
3171
3172 #[test]
3176 fn unparseable_dist_workspace_warns_about_missing_gh_releases() {
3177 let fs = FakeFs::with_file("/repo/dist-workspace.toml", "[dist\ntap = \"owner/tap\"");
3178 let n = norm_with(
3179 "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n",
3180 &fs,
3181 );
3182 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3183 assert!(
3184 n.problems
3185 .warnings
3186 .iter()
3187 .any(|warning| warning.contains("no 'gh-releases' target")),
3188 "warnings: {:?}",
3189 n.problems.warnings
3190 );
3191 }
3192
3193 fn hb_case(tap: bool, installer: bool, tap_target: bool) -> String {
3201 let mut fm = String::from(
3202 "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
3203 - {ecosystem: rust, package: shipshape, registry: crates.io, adapter: cargo-publish}\n",
3204 );
3205 if tap_target {
3206 fm.push_str(
3207 " - {ecosystem: rust, package: shipshape, registry: homebrew, adapter: homebrew-tap}\n",
3208 );
3209 }
3210 if installer || tap {
3213 fm.push_str("distribution:\n adapter: cargo-dist\n");
3214 if installer {
3215 fm.push_str(" installers: [homebrew]\n");
3216 } else {
3217 fm.push_str(" installers: [shell]\n");
3218 }
3219 if tap {
3220 fm.push_str(" homebrew_tap: owner/tap\n");
3221 }
3222 }
3223 fm.push_str("---\n");
3224 fm
3225 }
3226
3227 #[test]
3231 fn homebrew_truth_table_all_eight_rows() {
3232 let rows = [
3234 (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), ];
3243 for (tap, installer, tap_target, expect_valid) in rows {
3244 let n = norm(&hb_case(tap, installer, tap_target));
3245 assert_eq!(
3246 n.is_valid(),
3247 expect_valid,
3248 "row (tap={tap}, installer={installer}, tap_target={tap_target}) expected \
3249 valid={expect_valid}; errors were {:?}",
3250 n.problems.errors
3251 );
3252 }
3253 }
3254
3255 #[test]
3258 fn homebrew_tap_target_without_tap_is_a_floor() {
3259 assert_error_contains(
3260 &norm(&hb_case(false, false, true)),
3261 "needs distribution.homebrew_tap",
3262 );
3263 }
3264
3265 #[test]
3266 fn ci_delegated_homebrew_target_requires_tap_for_ci_and_verify() {
3267 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
3268 - {ecosystem: rust, package: shipshape, registry: homebrew, adapter: cargo-dist}\n---\n";
3269 assert_error_contains(&norm(text), "needs distribution.homebrew_tap");
3270 }
3271
3272 #[test]
3273 fn ci_delegated_homebrew_target_requires_cargo_dist_homebrew_installer() {
3274 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
3275 - {ecosystem: rust, package: shipshape, registry: homebrew, adapter: cargo-dist}\n\
3276 distribution:\n adapter: cargo-dist\n installers: [shell]\n \
3277 homebrew_tap: owner/tap\n---\n";
3278 assert_error_contains(
3279 &norm(text),
3280 "requires distribution.installers to include 'homebrew'",
3281 );
3282 }
3283
3284 #[test]
3287 fn homebrew_double_publish_is_a_floor() {
3288 assert_error_contains(
3289 &norm(&hb_case(true, true, true)),
3290 "they would collide; keep exactly one homebrew formula producer",
3291 );
3292 }
3293
3294 #[test]
3299 fn distribution_tap_without_formula_producer_warns() {
3300 let text = concat!(
3301 "---\n",
3302 "status: approved\n",
3303 "maturity: production\n",
3304 "ecosystems: [rust]\n",
3305 "targets:\n",
3306 " - {ecosystem: rust, package: project-canon-core, registry: crates.io, adapter: cargo-publish}\n",
3307 " - {ecosystem: rust, package: project-canon-cli, registry: crates.io, adapter: cargo-publish}\n",
3308 "distribution:\n",
3309 " adapter: cargo-dist\n",
3310 " installers: [shell, powershell]\n",
3311 " homebrew_tap: owner/homebrew-project-canon\n",
3312 "---\n",
3313 );
3314 let n = norm(text);
3315 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3316 assert!(n
3317 .problems
3318 .warnings
3319 .iter()
3320 .any(|warning| warning.contains("tap leg would be silently skipped")));
3321 }
3322
3323 #[test]
3326 fn homebrew_tap_target_with_tap_is_clean() {
3327 let n = norm(&hb_case(true, false, true));
3328 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3329 assert!(
3330 !n.problems
3331 .warnings
3332 .iter()
3333 .any(|w| w.contains("the tap will never be updated")),
3334 "unexpected dead-tap advisory: {:?}",
3335 n.problems.warnings
3336 );
3337 }
3338
3339 #[test]
3342 fn shipshape_contract_keeps_its_four_explicit_targets() {
3343 let n = norm(include_str!("../../../../OSS-RELEASE.md"));
3344 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3345 assert_eq!(n.contract.targets.len(), 4);
3346 assert!(n.contract.targets.iter().any(|target| {
3347 target.registry == Registry::Homebrew && target.adapter == Adapter::HomebrewTap
3348 }));
3349 }
3350
3351 #[test]
3355 fn homebrew_registry_requires_homebrew_adapter() {
3356 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
3357 - {ecosystem: rust, package: shipshape, registry: homebrew, adapter: manual}\n\
3358 distribution:\n adapter: cargo-dist\n homebrew_tap: owner/tap\n---\n";
3359 assert_error_contains(
3360 &norm(text),
3361 "requires adapter 'homebrew-tap' (personal tap), 'homebrew-core'",
3362 );
3363 }
3364
3365 #[test]
3368 fn ci_delegated_homebrew_target_normalizes_and_round_trips() {
3369 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
3370 - {ecosystem: rust, package: shipshape, registry: crates.io, adapter: cargo-publish}\n \
3371 - {ecosystem: rust, package: shipshape, registry: homebrew, adapter: cargo-dist}\n\
3372 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
3373 homebrew_tap: owner/tap\n---\n";
3374 let n = norm(text);
3375 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3376 assert!(
3377 n.problems.warnings.is_empty(),
3378 "warnings: {:?}",
3379 n.problems.warnings
3380 );
3381 let target = n
3382 .contract
3383 .targets
3384 .iter()
3385 .find(|target| target.registry == Registry::Homebrew)
3386 .expect("normalized Homebrew target");
3387 assert_eq!(target.adapter, Adapter::CargoDist);
3388 let canonical = serde_json::to_value(&n.contract).unwrap();
3389 assert_eq!(canonical["targets"][1]["adapter"], "cargo-dist");
3390 assert_eq!(canonical["targets"][1]["registry"], "homebrew");
3391 }
3392
3393 #[test]
3398 fn ci_delegated_crates_io_target_normalizes_and_round_trips() {
3399 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
3400 - {ecosystem: rust, package: glasspad, registry: crates.io, adapter: cargo-publish-ci}\n---\n";
3401 let n = norm(text);
3402 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3403 assert!(
3404 n.problems.warnings.is_empty(),
3405 "warnings: {:?}",
3406 n.problems.warnings
3407 );
3408 assert_eq!(n.contract.targets[0].adapter, Adapter::CargoPublishCi);
3409 let canonical = serde_json::to_value(&n.contract).unwrap();
3410 assert_eq!(canonical["targets"][0]["adapter"], "cargo-publish-ci");
3411 assert_eq!(canonical["targets"][0]["registry"], "crates.io");
3412 }
3413
3414 #[test]
3417 fn a_mixed_local_and_ci_publish_contract_is_valid() {
3418 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
3419 - {ecosystem: rust, package: lib, registry: crates.io, adapter: cargo-publish}\n \
3420 - {ecosystem: rust, package: cli, registry: crates.io, adapter: cargo-publish-ci}\n---\n";
3421 let n = norm(text);
3422 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3423 assert_eq!(n.contract.targets[0].adapter, Adapter::CargoPublish);
3424 assert_eq!(n.contract.targets[1].adapter, Adapter::CargoPublishCi);
3425 }
3426
3427 #[test]
3430 fn a_package_cannot_be_declared_with_both_publishers() {
3431 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
3432 - {ecosystem: rust, package: tool, registry: crates.io, adapter: cargo-publish}\n \
3433 - {ecosystem: rust, package: tool, registry: crates.io, adapter: cargo-publish-ci}\n---\n";
3434 assert_error_contains(&norm(text), "Keep exactly one publisher for a package");
3435 }
3436
3437 #[test]
3441 fn ci_delegated_cargo_publish_requires_crates_io() {
3442 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
3443 - {ecosystem: rust, package: tool, registry: gh-releases, adapter: cargo-publish-ci}\n---\n";
3444 assert_error_contains(
3445 &norm(text),
3446 "the CI-delegated cargo publish targets crates.io only",
3447 );
3448 }
3449
3450 #[test]
3452 fn ci_delegated_cargo_publish_requires_the_rust_ecosystem() {
3453 let text = "---\nstatus: approved\nmaturity: production\necosystems: [node]\ntargets:\n \
3454 - {ecosystem: node, package: tool, registry: crates.io, adapter: cargo-publish-ci}\n---\n";
3455 assert_error_contains(&norm(text), "`cargo publish` releases a rust crate");
3456 }
3457
3458 #[test]
3462 fn homebrew_core_target_needs_no_tap() {
3463 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
3464 - {ecosystem: rust, package: shipshape, registry: crates.io, adapter: cargo-publish}\n \
3465 - {ecosystem: rust, package: shipshape, registry: homebrew, adapter: homebrew-core}\n---\n";
3466 let n = norm(text);
3467 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3468 }
3469
3470 #[test]
3478 fn homebrew_registry_omitted_adapter_is_a_floor() {
3479 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
3480 - {ecosystem: rust, package: shipshape, registry: homebrew}\n---\n";
3481 assert_error_contains(
3482 &norm(text),
3483 "requires adapter 'homebrew-tap' (personal tap), 'homebrew-core'",
3484 );
3485 }
3486
3487 #[test]
3491 fn homebrew_tap_target_satisfied_via_plural_distributions() {
3492 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ntargets:\n \
3493 - {ecosystem: rust, package: shipshape, registry: crates.io, adapter: cargo-publish}\n \
3494 - {ecosystem: rust, package: shipshape, registry: homebrew, adapter: homebrew-tap}\n\
3495 distributions:\n \
3496 - {package: shipshape, adapter: cargo-dist, installers: [shell], homebrew_tap: owner/tap}\n---\n";
3497 let n = norm(text);
3498 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3499 assert!(
3500 !n.problems
3501 .warnings
3502 .iter()
3503 .any(|w| w.contains("the tap will never be updated")),
3504 "unexpected dead-tap advisory: {:?}",
3505 n.problems.warnings
3506 );
3507 }
3508
3509 #[test]
3515 fn singular_distribution_parses_as_one_element_list() {
3516 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3517 distribution:\n adapter: cargo-dist\n---\n";
3518 let c = norm(text).contract;
3519 assert_eq!(c.distributions.len(), 1);
3520 assert_eq!(c.distributions[0].package, None);
3521 assert_eq!(c.distributions[0].adapter, DistributionAdapter::CargoDist);
3522 }
3523
3524 #[test]
3528 fn plural_distributions_parse_with_per_package_association() {
3529 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3530 targets:\n - {ecosystem: rust, package: alpha, registry: crates.io}\n \
3531 - {ecosystem: rust, package: beta, registry: crates.io}\n\
3532 distributions:\n - {package: alpha, adapter: cargo-dist, installers: [shell]}\n \
3533 - {package: beta, adapter: cargo-dist, installers: [homebrew], homebrew_tap: owner/tap}\n---\n";
3534 let n = norm(text);
3535 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3536 let d = n.contract.distributions;
3537 assert_eq!(d.len(), 2);
3538 assert_eq!(d[0].package.as_deref(), Some("alpha"));
3539 assert_eq!(d[0].installers, vec![Installer::Shell]);
3540 assert_eq!(d[1].package.as_deref(), Some("beta"));
3541 assert_eq!(d[1].homebrew_tap.as_deref(), Some("owner/tap"));
3542 }
3543
3544 #[test]
3548 fn distributions_canonical_json_round_trip() {
3549 for text in [
3550 "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3551 distribution:\n adapter: cargo-dist\n installers: [shell]\n---\n",
3552 "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3553 targets:\n - {ecosystem: rust, package: a, registry: crates.io}\n \
3554 - {ecosystem: rust, package: b, registry: crates.io}\n\
3555 distributions:\n - {package: a, adapter: cargo-dist}\n \
3556 - {package: b, adapter: goreleaser}\n---\n",
3557 ] {
3558 let first = norm(text).contract;
3559 assert!(!first.distributions.is_empty());
3560 let json = serde_json::to_value(&first).unwrap();
3562 let refed = format!("---\n{}---\n", serde_yaml::to_string(&json).unwrap());
3563 let second = norm(&refed).contract;
3564 assert_eq!(
3565 first.distributions, second.distributions,
3566 "round-trip drift for: {text}"
3567 );
3568 }
3569 }
3570
3571 #[test]
3573 fn both_distribution_keys_is_an_error() {
3574 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3575 distribution:\n adapter: cargo-dist\n\
3576 distributions:\n - {package: a, adapter: cargo-dist}\n---\n";
3577 assert_error_contains(&norm(text), "not both");
3578 }
3579
3580 #[test]
3583 fn multi_distribution_missing_package_is_a_floor_error() {
3584 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3585 targets:\n - {ecosystem: rust, package: a, registry: crates.io}\n\
3586 distributions:\n - {package: a, adapter: cargo-dist}\n \
3587 - {adapter: cargo-dist}\n---\n";
3588 assert_error_contains(&norm(text), "must name the package it builds");
3589 }
3590
3591 #[test]
3593 fn multi_distribution_duplicate_package_is_a_floor_error() {
3594 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3595 distributions:\n - {package: dup, adapter: cargo-dist}\n \
3596 - {package: dup, adapter: goreleaser}\n---\n";
3597 assert_error_contains(&norm(text), "distinct package");
3598 }
3599
3600 #[test]
3604 fn v1_document_is_relabeled_to_current_schema_version_on_emit() {
3605 let text = "---\nschema_version: 1\nstatus: approved\nmaturity: production\n\
3606 ecosystems: [rust]\n\
3607 distribution:\n adapter: cargo-dist\n---\n";
3608 let n = norm(text);
3609 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3610 assert_eq!(n.contract.schema_version, 2);
3612 let json = serde_json::to_value(&n.contract).unwrap();
3613 assert_eq!(json["schema_version"], 2);
3614 assert_eq!(json["distributions"].as_array().map(Vec::len), Some(1));
3616 }
3617
3618 #[test]
3622 fn distribution_package_is_trimmed() {
3623 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3624 distributions:\n - {package: ' alpha ', adapter: cargo-dist}\n \
3625 - {package: alpha, adapter: goreleaser}\n---\n";
3626 assert_error_contains(&norm(text), "distinct package");
3628 }
3629
3630 #[test]
3633 fn single_distribution_may_carry_a_package() {
3634 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3635 targets:\n - {ecosystem: rust, package: solo, registry: crates.io}\n\
3636 distributions:\n - {package: solo, adapter: cargo-dist}\n---\n";
3637 let n = norm(text);
3638 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3639 assert_eq!(n.contract.distributions[0].package.as_deref(), Some("solo"));
3640 }
3641
3642 #[test]
3647 fn distribution_unknown_subkey_preserved_and_warned() {
3648 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3649 distribution:\n adapter: cargo-dist\n gh_releases: true\n \
3650 future_signing: {enabled: true, kms_key: alias/oss}\n---\n";
3651 let n = norm(text);
3652 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3653 let d = n
3654 .contract
3655 .clone()
3656 .distributions
3657 .into_iter()
3658 .next()
3659 .expect("distribution present");
3660 assert_eq!(
3662 d.extra_fields
3663 .get("future_signing")
3664 .and_then(|v| v.get("kms_key"))
3665 .and_then(|v| v.as_str()),
3666 Some("alias/oss")
3667 );
3668 assert_eq!(d.adapter, DistributionAdapter::CargoDist);
3670 assert!(d.gh_releases);
3671 let json = serde_json::to_value(&n.contract).unwrap();
3673 assert_eq!(
3674 json["distributions"][0]["extra_fields"]["future_signing"]["enabled"],
3675 serde_json::json!(true)
3676 );
3677 assert!(
3679 n.problems.warnings.iter().any(|w| {
3680 w.contains("unknown distribution field(s) preserved")
3681 && w.contains("future_signing")
3682 }),
3683 "expected a scoped forward-compat warning: {:?}",
3684 n.problems.warnings
3685 );
3686 }
3687
3688 #[test]
3694 fn msi_installer_without_windows_platform_warns() {
3695 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3696 distribution:\n adapter: cargo-dist\n installers: [msi]\n \
3697 platforms: [x86_64-apple-darwin, x86_64-unknown-linux-musl]\n---\n";
3698 let n = norm(text);
3699 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3700 assert!(
3701 n.problems
3702 .warnings
3703 .iter()
3704 .any(|w| w.contains("includes 'msi'") && w.contains("no Windows")),
3705 "expected an msi/Windows cross-check warning: {:?}",
3706 n.problems.warnings
3707 );
3708 }
3709
3710 #[test]
3712 fn msi_installer_with_windows_platform_no_warning() {
3713 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3714 distribution:\n adapter: cargo-dist\n installers: [msi]\n \
3715 platforms: [x86_64-pc-windows-msvc]\n---\n";
3716 let n = norm(text);
3717 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3718 assert!(
3719 !n.problems.warnings.iter().any(|w| w.contains("'msi'")),
3720 "unexpected msi cross-check warning: {:?}",
3721 n.problems.warnings
3722 );
3723 }
3724
3725 #[test]
3730 fn homebrew_installer_without_darwin_or_linux_warns() {
3731 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3732 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
3733 homebrew_tap: jarimustonen/homebrew-issuectl\n \
3734 platforms: [x86_64-pc-windows-msvc]\n---\n";
3735 let n = norm(text);
3736 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3737 assert!(
3738 n.problems
3739 .warnings
3740 .iter()
3741 .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
3742 "expected a homebrew/(macOS|Linux) cross-check warning: {:?}",
3743 n.problems.warnings
3744 );
3745 }
3746
3747 #[test]
3751 fn homebrew_installer_with_linux_only_no_warning() {
3752 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3753 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
3754 homebrew_tap: jarimustonen/homebrew-issuectl\n \
3755 platforms: [x86_64-unknown-linux-musl]\n---\n";
3756 let n = norm(text);
3757 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3758 assert!(
3759 !n.problems.warnings.iter().any(|w| w.contains("'homebrew'")),
3760 "unexpected homebrew cross-check warning for a Linux-only set: {:?}",
3761 n.problems.warnings
3762 );
3763 }
3764
3765 #[test]
3768 fn npm_and_shell_installers_never_cross_check_warn() {
3769 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust, node]\n\
3770 distribution:\n adapter: cargo-dist\n installers: [shell, npm]\n \
3771 platforms: [x86_64-apple-darwin]\n---\n";
3772 let n = norm(text);
3773 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3774 assert!(
3775 !n.problems
3776 .warnings
3777 .iter()
3778 .any(|w| w.contains("nothing to install")),
3779 "OS-agnostic installers must not cross-check warn: {:?}",
3780 n.problems.warnings
3781 );
3782 }
3783
3784 #[test]
3787 fn coherent_installer_platform_set_no_warning() {
3788 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3789 distribution:\n adapter: cargo-dist\n installers: [homebrew, msi]\n \
3790 homebrew_tap: jarimustonen/homebrew-issuectl\n \
3791 platforms: [aarch64-apple-darwin, x86_64-pc-windows-msvc]\n---\n";
3792 let n = norm(text);
3793 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3794 assert!(
3795 !n.problems
3796 .warnings
3797 .iter()
3798 .any(|w| w.contains("nothing to install")),
3799 "coherent set must not warn: {:?}",
3800 n.problems.warnings
3801 );
3802 }
3803
3804 #[test]
3808 fn shipshape_own_contract_shape_no_cross_check_warning() {
3809 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3810 distribution:\n adapter: cargo-dist\n installers: [shell, powershell]\n \
3811 platforms: [aarch64-apple-darwin, x86_64-apple-darwin, \
3812 x86_64-unknown-linux-musl, aarch64-unknown-linux-musl, \
3813 x86_64-pc-windows-msvc]\n---\n";
3814 let n = norm(text);
3815 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3816 assert!(
3817 !n.problems
3818 .warnings
3819 .iter()
3820 .any(|w| w.contains("nothing to install")),
3821 "shipshape's own shape must not cross-check warn: {:?}",
3822 n.problems.warnings
3823 );
3824 }
3825
3826 #[test]
3831 fn msi_installer_with_defaulted_platforms_warns() {
3832 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3833 distribution:\n adapter: cargo-dist\n installers: [msi]\n---\n";
3834 let n = norm(text);
3835 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3836 assert!(
3837 n.problems
3838 .warnings
3839 .iter()
3840 .any(|w| w.contains("includes 'msi'") && w.contains("no Windows")),
3841 "expected an msi/Windows warning against the defaulted platform set: {:?}",
3842 n.problems.warnings
3843 );
3844 }
3845
3846 #[test]
3849 fn msi_installer_with_windows_gnu_no_warning() {
3850 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3851 distribution:\n adapter: cargo-dist\n installers: [msi]\n \
3852 platforms: [x86_64-pc-windows-gnu]\n---\n";
3853 let n = norm(text);
3854 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3855 assert!(
3856 !n.problems.warnings.iter().any(|w| w.contains("'msi'")),
3857 "windows-gnu must satisfy msi: {:?}",
3858 n.problems.warnings
3859 );
3860 }
3861
3862 #[test]
3868 fn homebrew_installer_with_android_only_warns() {
3869 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3870 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
3871 homebrew_tap: jarimustonen/homebrew-issuectl\n \
3872 platforms: [aarch64-linux-android]\n---\n";
3873 let n = norm(text);
3874 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3875 assert!(
3876 n.problems
3877 .warnings
3878 .iter()
3879 .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
3880 "Android-only must strand a homebrew installer: {:?}",
3881 n.problems.warnings
3882 );
3883 }
3884
3885 #[test]
3889 fn homebrew_installer_with_apple_ios_only_warns() {
3890 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3891 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
3892 homebrew_tap: jarimustonen/homebrew-issuectl\n \
3893 platforms: [aarch64-apple-ios]\n---\n";
3894 let n = norm(text);
3895 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3896 assert!(
3897 n.problems
3898 .warnings
3899 .iter()
3900 .any(|w| w.contains("includes 'homebrew'") && w.contains("nothing to install")),
3901 "apple-ios must not satisfy homebrew's macOS need: {:?}",
3902 n.problems.warnings
3903 );
3904 }
3905
3906 #[test]
3909 fn homebrew_installer_with_macos_only_no_warning() {
3910 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3911 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
3912 homebrew_tap: jarimustonen/homebrew-issuectl\n \
3913 platforms: [aarch64-apple-darwin]\n---\n";
3914 let n = norm(text);
3915 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3916 assert!(
3917 !n.problems.warnings.iter().any(|w| w.contains("'homebrew'")),
3918 "macOS-only must satisfy homebrew: {:?}",
3919 n.problems.warnings
3920 );
3921 }
3922
3923 #[test]
3928 fn both_installers_stranded_warn_once_each() {
3929 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3930 distribution:\n adapter: cargo-dist\n installers: [homebrew, msi]\n \
3931 homebrew_tap: jarimustonen/homebrew-issuectl\n \
3932 platforms: [wasm32-unknown-unknown]\n---\n";
3933 let n = norm(text);
3934 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3935 let msi = n
3936 .problems
3937 .warnings
3938 .iter()
3939 .filter(|w| w.contains("includes 'msi'"))
3940 .count();
3941 let brew = n
3942 .problems
3943 .warnings
3944 .iter()
3945 .filter(|w| w.contains("includes 'homebrew'"))
3946 .count();
3947 assert_eq!((msi, brew), (1, 1), "warnings: {:?}", n.problems.warnings);
3948 }
3949
3950 #[test]
3956 fn malformed_platform_triple_gates_off_cross_check() {
3957 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3958 distribution:\n adapter: cargo-dist\n installers: [msi]\n \
3959 platforms: [x86_64-PC-WINDOWS-MSVC]\n---\n";
3960 let n = norm(text);
3961 assert!(!n.is_valid(), "expected a malformed-triple error");
3963 assert!(
3965 !n.problems
3966 .warnings
3967 .iter()
3968 .any(|w| w.contains("nothing to install")),
3969 "cross-check must be gated off while platforms has errors: {:?}",
3970 n.problems.warnings
3971 );
3972 }
3973
3974 #[test]
3980 fn distribution_all_known_keys_has_empty_extra_fields() {
3981 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
3982 distribution:\n adapter: cargo-dist\n gh_releases: true\n \
3983 installers: [shell, homebrew]\n homebrew_tap: owner/tap\n \
3984 platforms: [x86_64-unknown-linux-musl]\n---\n";
3985 let n = norm(text);
3986 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
3987 let d = n
3988 .contract
3989 .distributions
3990 .into_iter()
3991 .next()
3992 .expect("distribution present");
3993 assert!(d.extra_fields.is_empty());
3994 assert!(
3995 !n.problems
3996 .warnings
3997 .iter()
3998 .any(|w| w.contains("unknown distribution field(s) preserved")),
3999 "no forward-compat warning for an all-known-keys block: {:?}",
4000 n.problems.warnings
4001 );
4002 }
4003
4004 #[test]
4009 fn distribution_and_top_level_extra_fields_coexist() {
4010 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
4011 roadmap_url: https://example.com/x\n\
4012 distribution:\n adapter: cargo-dist\n future_x: 1\n---\n";
4013 let n = norm(text);
4014 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4015 let c = n.contract.clone();
4016 assert!(c.extra_fields.contains_key("roadmap_url"));
4017 let d = c
4018 .distributions
4019 .into_iter()
4020 .next()
4021 .expect("distribution present");
4022 assert_eq!(d.extra_fields.get("future_x"), Some(&serde_json::json!(1)));
4023 let fc: Vec<&String> = n
4026 .problems
4027 .warnings
4028 .iter()
4029 .filter(|w| w.contains("forward-compat") && w.contains("schema_version 2"))
4030 .collect();
4031 assert_eq!(fc.len(), 2, "expected two versioned warnings: {fc:?}");
4032 }
4033
4034 #[test]
4042 fn non_string_top_level_key_rejected() {
4043 let n = norm("---\nstatus: approved\nmaturity: mvp\n42: answer\n---\n");
4044 assert_error_contains(&n, "must be a string");
4045 assert!(
4046 n.problems.errors.iter().any(|e| e.contains("42")),
4047 "error should name the offending key: {:?}",
4048 n.problems.errors
4049 );
4050 }
4051
4052 #[test]
4055 fn non_string_distribution_key_rejected() {
4056 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
4057 distribution:\n adapter: cargo-dist\n true: enabled\n---\n";
4058 let n = norm(text);
4059 assert_error_contains(&n, "must be a string");
4060 assert!(
4061 n.problems
4062 .errors
4063 .iter()
4064 .any(|e| e.contains("distribution field key")),
4065 "error should be scoped to the distribution block: {:?}",
4066 n.problems.errors
4067 );
4068 }
4069
4070 #[test]
4074 fn extra_fields_nested_numeric_string_key_collision_is_rejected() {
4075 let n =
4076 norm("---\nstatus: approved\nmaturity: mvp\nfuture_x:\n 42: a\n \"42\": b\n---\n");
4077 assert_error_contains(&n, "non-string mapping key 42");
4078 assert!(
4079 n.problems
4080 .errors
4081 .iter()
4082 .any(|error| error.contains("extra field \"future_x\"")),
4083 "error should name the preserved field path: {:?}",
4084 n.problems.errors
4085 );
4086 assert!(
4087 !n.contract.extra_fields.contains_key("future_x"),
4088 "invalid preserved content must not be partially captured"
4089 );
4090 }
4091
4092 #[test]
4096 fn extra_fields_nested_list_key_is_rejected() {
4097 let n = norm(
4098 "---\nstatus: approved\nmaturity: mvp\nfuture_x:\n ? [one, two]\n : list-key\n---\n",
4099 );
4100 assert_error_contains(&n, "non-string mapping key <list>");
4101 assert!(
4102 n.problems
4103 .errors
4104 .iter()
4105 .any(|error| error.contains("extra field \"future_x\"")),
4106 "error should name the preserved field path: {:?}",
4107 n.problems.errors
4108 );
4109 }
4110
4111 #[test]
4115 fn extra_fields_deeply_nested_non_string_key_reports_path() {
4116 let n = norm(
4117 "---\nstatus: approved\nmaturity: mvp\nfuture_x:\n outer:\n inner:\n 42: answer\n---\n",
4118 );
4119 assert!(
4120 n.problems.errors.iter().any(|error| {
4121 error.contains("extra field \"future_x\"[\"outer\"][\"inner\"]")
4122 && error.contains("non-string mapping key 42")
4123 }),
4124 "error should name the complete nested path: {:?}",
4125 n.problems.errors
4126 );
4127 }
4128
4129 #[test]
4133 fn extra_fields_nested_string_keys_round_trip_unchanged() {
4134 let n = norm(
4135 "---\nstatus: approved\nmaturity: mvp\nfuture_x:\n outer:\n answer: 42\n items:\n - name: first\n enabled: true\n---\n",
4136 );
4137 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4138 assert_eq!(
4139 n.contract.extra_fields.get("future_x"),
4140 Some(&serde_json::json!({
4141 "outer": {
4142 "answer": 42,
4143 "items": [{"name": "first", "enabled": true}],
4144 }
4145 }))
4146 );
4147 }
4148
4149 #[test]
4152 fn nested_non_string_key_paths_cover_all_capture_contexts() {
4153 let reserved = norm(
4154 "---\nstatus: approved\nmaturity: mvp\nextra_fields:\n future_x:\n 42: answer\n---\n",
4155 );
4156 assert!(
4157 reserved.problems.errors.iter().any(|error| {
4158 error.contains("reserved 'extra_fields' field \"future_x\"")
4159 && error.contains("non-string mapping key 42")
4160 }),
4161 "reserved-block path missing: {:?}",
4162 reserved.problems.errors
4163 );
4164
4165 let distribution = norm(
4166 "---\nstatus: approved\nmaturity: production\necosystems: [rust]\ndistribution:\n adapter: cargo-dist\n future_x:\n 42: answer\n---\n",
4167 );
4168 assert!(
4169 distribution.problems.errors.iter().any(|error| {
4170 error.contains("distribution extra field \"future_x\"")
4171 && error.contains("non-string mapping key 42")
4172 }),
4173 "distribution path missing: {:?}",
4174 distribution.problems.errors
4175 );
4176
4177 let sequence = norm(
4178 "---\nstatus: approved\nmaturity: mvp\nfuture_x:\n - ok: first\n - 42: answer\n---\n",
4179 );
4180 assert!(
4181 sequence.problems.errors.iter().any(|error| {
4182 error.contains("extra field \"future_x\"[1]")
4183 && error.contains("non-string mapping key 42")
4184 }),
4185 "sequence index path missing: {:?}",
4186 sequence.problems.errors
4187 );
4188 }
4189
4190 #[test]
4194 fn known_key_not_double_captured() {
4195 let n = norm("---\nstatus: approved\nmaturity: production\necosystems: [rust]\n---\n");
4196 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4197 assert!(!n.contract.extra_fields.contains_key("ecosystems"));
4198 assert!(!n.contract.extra_fields.contains_key("status"));
4199 assert!(n.contract.extra_fields.is_empty());
4200 }
4201
4202 #[test]
4208 fn reserved_extra_fields_block_merged_warnings_ignored() {
4209 let text = "---\nstatus: approved\nmaturity: mvp\n\
4210 extra_fields:\n foo: 1\nwarnings:\n - a prior note\n---\n";
4211 let n = norm(text);
4212 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4213 assert_eq!(
4215 n.contract.extra_fields.get("foo"),
4216 Some(&serde_json::json!(1))
4217 );
4218 assert!(!n.contract.extra_fields.contains_key("extra_fields"));
4219 assert!(
4221 !n.contract
4222 .warnings
4223 .iter()
4224 .any(|w| w.contains("a prior note")),
4225 "input warnings must be regenerated, not preserved: {:?}",
4226 n.contract.warnings
4227 );
4228 }
4229
4230 #[test]
4233 fn distribution_reserved_extra_fields_block_merged() {
4234 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
4235 distribution:\n adapter: cargo-dist\n extra_fields:\n foo: 1\n---\n";
4236 let n = norm(text);
4237 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4238 let d = n
4239 .contract
4240 .distributions
4241 .into_iter()
4242 .next()
4243 .expect("distribution present");
4244 assert_eq!(d.extra_fields.get("foo"), Some(&serde_json::json!(1)));
4245 assert!(!d.extra_fields.contains_key("extra_fields"));
4246 }
4247
4248 #[test]
4252 fn extra_fields_round_trip_is_idempotent() {
4253 let first = norm("---\nstatus: approved\nmaturity: mvp\nroadmap_url: https://x/y\n---\n");
4254 assert!(first.is_valid(), "errors: {:?}", first.problems.errors);
4255 assert_eq!(first.contract.extra_fields.len(), 1);
4256 let inner = serde_yaml::to_string(&first.contract.extra_fields).unwrap();
4258 let indented = inner
4259 .lines()
4260 .map(|l| format!(" {l}"))
4261 .collect::<Vec<_>>()
4262 .join("\n");
4263 let text =
4264 format!("---\nstatus: approved\nmaturity: mvp\nextra_fields:\n{indented}\n---\n");
4265 let second = norm(&text);
4266 assert!(second.is_valid(), "errors: {:?}", second.problems.errors);
4267 assert_eq!(second.contract.extra_fields, first.contract.extra_fields);
4268 }
4269
4270 #[test]
4274 fn extra_fields_block_sibling_collision_is_error() {
4275 let text = "---\nstatus: approved\nmaturity: mvp\n\
4276 extra_fields:\n dup: 1\ndup: 2\n---\n";
4277 let n = norm(text);
4278 assert_error_contains(&n, "appears both");
4279 }
4280
4281 #[test]
4284 fn reserved_extra_fields_non_mapping_is_error() {
4285 let n = norm("---\nstatus: approved\nmaturity: mvp\nextra_fields: nonsense\n---\n");
4286 assert_error_contains(&n, "must be a mapping");
4287 }
4288
4289 #[test]
4295 fn top_level_all_known_keys_has_empty_extra_fields() {
4296 let text = "---\nschema_version: 1\nstatus: approved\nmaturity: production\n\
4297 ecosystems: [rust]\n\
4298 targets:\n - {ecosystem: rust, package: x, registry: crates.io, adapter: cargo-publish}\n\
4299 distribution:\n adapter: cargo-dist\n\
4300 versioning: semver\n\
4301 changelog:\n mode: curated\n source: manual\n\
4302 conventional_commits: false\n\
4303 release:\n model: gated\n layout: single\n\
4304 contribution_provenance: none\n\
4305 provenance_level: none\n\
4306 dependency_bot: dependabot\n\
4307 health_badges: [ci, registry, license]\n\
4308 license: MIT\n\
4309 docs_site: none\n---\n";
4310 let n = norm(text);
4311 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4312 assert!(
4313 n.contract.extra_fields.is_empty(),
4314 "unexpected extra_fields (KNOWN_KEYS drift?): {:?}",
4315 n.contract.extra_fields
4316 );
4317 assert!(
4318 !n.problems
4319 .warnings
4320 .iter()
4321 .any(|w| w.contains("forward-compat")),
4322 "no forward-compat warning for an all-known-keys contract: {:?}",
4323 n.problems.warnings
4324 );
4325 }
4326
4327 #[test]
4329 fn distribution_installers_dedup_canonical_order() {
4330 let text = "---\nstatus: approved\nmaturity: mvp\n\
4331 distribution:\n adapter: cargo-dist\n installers: [homebrew, shell, homebrew]\n \
4332 homebrew_tap: owner/tap\n---\n";
4333 let d = norm(text)
4334 .contract
4335 .distributions
4336 .into_iter()
4337 .next()
4338 .unwrap();
4339 assert_eq!(d.installers, vec![Installer::Shell, Installer::Homebrew]);
4340 }
4341
4342 #[test]
4344 fn distribution_homebrew_installer_requires_tap() {
4345 let text = "---\nstatus: approved\nmaturity: mvp\n\
4346 distribution:\n adapter: cargo-dist\n installers: [shell, homebrew]\n---\n";
4347 assert_error_contains(
4348 &norm(text),
4349 "includes 'homebrew' but no distribution.homebrew_tap",
4350 );
4351 }
4352
4353 #[test]
4357 fn distribution_bad_tap_slug_rejected() {
4358 let text = "---\nstatus: approved\nmaturity: mvp\n\
4359 distribution:\n adapter: cargo-dist\n installers: [homebrew]\n \
4360 homebrew_tap: not-a-slug\n---\n";
4361 let n = norm(text);
4362 assert_error_contains(&n, "must be an 'owner/repo' slug");
4363 assert!(
4364 n.problems
4365 .errors
4366 .iter()
4367 .any(|e| e.contains("includes 'homebrew' but no distribution.homebrew_tap")),
4368 "the tap floor must still fire on an invalid (→None) tap: {:?}",
4369 n.problems.errors
4370 );
4371 assert_eq!(
4373 n.contract
4374 .distributions
4375 .into_iter()
4376 .next()
4377 .unwrap()
4378 .homebrew_tap,
4379 None
4380 );
4381 }
4382
4383 #[test]
4385 fn distribution_bad_installer_rejected() {
4386 let text = "---\nstatus: approved\nmaturity: mvp\n\
4387 distribution:\n adapter: cargo-dist\n installers: [snap]\n---\n";
4388 assert_error_contains(&norm(text), "distribution.installers");
4389 }
4390
4391 #[test]
4394 fn distribution_adapter_is_required() {
4395 assert_error_contains(
4396 &norm("---\nstatus: approved\nmaturity: mvp\ndistribution: {}\n---\n"),
4397 "distribution.adapter is required",
4398 );
4399 }
4400
4401 #[test]
4404 fn distribution_forbidden_on_spike() {
4405 let text = "---\nstatus: approved\nmaturity: spike\n\
4406 distribution:\n adapter: cargo-dist\n---\n";
4407 assert_error_contains(&norm(text), "not allowed on maturity 'spike'");
4408 }
4409
4410 #[test]
4413 fn distribution_tap_without_target_warns() {
4414 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
4417 distribution:\n adapter: cargo-dist\n installers: [shell]\n \
4418 homebrew_tap: owner/tap\n---\n";
4419 let n = norm(text);
4420 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4421 assert!(n
4422 .problems
4423 .warnings
4424 .iter()
4425 .any(|warning| warning.contains("no 'homebrew' target")));
4426 }
4427
4428 #[test]
4434 fn distribution_tap_with_homebrew_target_no_warning() {
4435 let text = "---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n\
4436 targets:\n \
4437 - {ecosystem: rust, package: shipshape, registry: crates.io, adapter: cargo-publish}\n \
4438 - {ecosystem: rust, package: shipshape, registry: homebrew, adapter: homebrew-tap}\n\
4439 distribution:\n adapter: cargo-dist\n installers: [shell]\n \
4440 homebrew_tap: owner/tap\n---\n";
4441 let n = norm(text);
4442 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4443 assert!(
4447 n.problems.warnings.is_empty(),
4448 "homebrew-target contract must not warn: {:?}",
4449 n.problems.warnings
4450 );
4451 }
4452
4453 #[test]
4456 fn distribution_goreleaser_minimal_is_valid() {
4457 let text = "---\nstatus: approved\nmaturity: production\necosystems: [go]\n\
4458 distribution:\n adapter: goreleaser\n gh_releases: true\n---\n";
4459 let n = norm(text);
4460 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4461 let d = n.contract.distributions.into_iter().next().unwrap();
4462 assert_eq!(d.adapter, DistributionAdapter::Goreleaser);
4463 assert!(d.installers.is_empty());
4464 assert_eq!(d.homebrew_tap, None);
4465 }
4466
4467 #[test]
4469 fn distribution_non_mapping_rejected() {
4470 assert_error_contains(
4471 &norm("---\nstatus: approved\nmaturity: mvp\ndistribution: [nope]\n---\n"),
4472 "distribution must be a mapping",
4473 );
4474 }
4475
4476 fn has_linux(platforms: &[String]) -> bool {
4482 platforms.iter().any(|t| t.contains("-linux"))
4483 }
4484
4485 #[test]
4490 fn distribution_platforms_default_is_cross_platform() {
4491 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
4492 distribution:\n adapter: cargo-dist\n---\n";
4493 let d = norm(text)
4494 .contract
4495 .distributions
4496 .into_iter()
4497 .next()
4498 .expect("distribution present");
4499 assert_eq!(
4500 d.platforms,
4501 vec![
4502 "aarch64-apple-darwin",
4503 "x86_64-apple-darwin",
4504 "aarch64-unknown-linux-musl",
4505 "x86_64-unknown-linux-musl",
4506 ]
4507 );
4508 assert!(
4509 has_linux(&d.platforms),
4510 "the default set MUST contain a Linux triple: {:?}",
4511 d.platforms
4512 );
4513 }
4514
4515 #[test]
4518 fn distribution_platforms_explicit_round_trips() {
4519 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
4520 distribution:\n adapter: cargo-dist\n \
4521 platforms: [x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc]\n---\n";
4522 let n = norm(text);
4523 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4524 let d = n.contract.clone().distributions.into_iter().next().unwrap();
4525 assert_eq!(
4526 d.platforms,
4527 vec!["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
4528 );
4529 let json = serde_json::to_value(&n.contract).unwrap();
4530 assert_eq!(
4531 json["distributions"][0]["platforms"],
4532 serde_json::json!(["x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"])
4533 );
4534 }
4535
4536 #[test]
4542 fn distribution_platforms_empty_is_rejected() {
4543 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
4544 distribution:\n adapter: cargo-dist\n platforms: []\n---\n";
4545 assert_error_contains(&norm(text), "empty list — omit the key");
4546 }
4547
4548 #[test]
4550 fn distribution_platforms_dedup_preserves_order() {
4551 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
4552 distribution:\n adapter: cargo-dist\n \
4553 platforms: [aarch64-apple-darwin, x86_64-apple-darwin, aarch64-apple-darwin]\n---\n";
4554 let d = norm(text)
4555 .contract
4556 .distributions
4557 .into_iter()
4558 .next()
4559 .unwrap();
4560 assert_eq!(
4561 d.platforms,
4562 vec!["aarch64-apple-darwin", "x86_64-apple-darwin"]
4563 );
4564 }
4565
4566 #[test]
4568 fn distribution_platforms_bad_triple_rejected() {
4569 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
4570 distribution:\n adapter: cargo-dist\n platforms: [not_a_triple]\n---\n";
4571 assert_error_contains(&norm(text), "is not a well-formed target-triple");
4572 }
4573
4574 #[test]
4576 fn distribution_platforms_non_string_entry_rejected() {
4577 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
4578 distribution:\n adapter: cargo-dist\n platforms: [[nope]]\n---\n";
4579 assert_error_contains(&norm(text), "each entry must be a target-triple string");
4580 }
4581
4582 #[test]
4584 fn distribution_platforms_non_list_rejected() {
4585 let text = "---\nstatus: approved\nmaturity: production\necosystems: [rust]\n\
4586 distribution:\n adapter: cargo-dist\n platforms: x86_64-apple-darwin\n---\n";
4587 assert_error_contains(&norm(text), "must be a list of target-triple strings");
4588 }
4589
4590 #[test]
4594 fn registry_only_contract_unaffected_by_platforms() {
4595 let json = serde_json::to_value(
4596 &norm("---\nstatus: approved\nmaturity: mvp\necosystems: [rust]\n---\n").contract,
4597 )
4598 .unwrap();
4599 assert_eq!(json["distributions"], serde_json::json!([]));
4600 }
4601
4602 #[test]
4603 fn looks_like_target_triple_verdicts() {
4604 assert!(looks_like_target_triple("aarch64-apple-darwin"));
4606 assert!(looks_like_target_triple("x86_64-apple-darwin"));
4607 assert!(looks_like_target_triple("x86_64-unknown-linux-musl"));
4608 assert!(looks_like_target_triple("x86_64-unknown-linux-gnu"));
4609 assert!(looks_like_target_triple("x86_64-pc-windows-msvc"));
4610 assert!(looks_like_target_triple("armv7-unknown-linux-gnueabihf"));
4611 assert!(looks_like_target_triple("wasm32-wasi"));
4612 assert!(looks_like_target_triple("thumbv8m.main-none-eabi"));
4614 assert!(looks_like_target_triple("thumbv8m.base-none-eabi"));
4615 assert!(!looks_like_target_triple("linux"));
4617 assert!(!looks_like_target_triple("a-b-c-d-e"));
4618 assert!(!looks_like_target_triple("x86_64--linux"));
4619 assert!(!looks_like_target_triple("-apple-darwin"));
4620 assert!(!looks_like_target_triple("X86_64-apple-darwin"));
4621 assert!(!looks_like_target_triple("x86_64-apple-darwin;rm"));
4622 assert!(!looks_like_target_triple("x86_64 apple darwin"));
4623 assert!(!looks_like_target_triple(""));
4624 assert!(looks_like_target_triple("aa-bb"));
4627 }
4628
4629 #[test]
4630 fn is_tap_slug_verdicts() {
4631 assert!(is_tap_slug("owner/repo"));
4633 assert!(is_tap_slug("jarimustonen/homebrew-issuectl"));
4634 assert!(is_tap_slug("Owner_1/repo.rb"));
4635 assert!(!is_tap_slug("no-slash"));
4637 assert!(!is_tap_slug("/repo"));
4638 assert!(!is_tap_slug("owner/"));
4639 assert!(!is_tap_slug("owner/repo/extra"));
4640 assert!(!is_tap_slug("owner / repo"));
4641 assert!(!is_tap_slug("owner/.."));
4643 assert!(!is_tap_slug("../repo"));
4644 assert!(!is_tap_slug("owner/repo;rm -rf"));
4645 assert!(!is_tap_slug("owner/@repo"));
4646 assert!(!is_tap_slug("ownér/repo"));
4647 }
4648
4649 #[test]
4652 fn quote_for_diagnostic_escapes_hostile_input() {
4653 assert_eq!(quote_for_diagnostic("foo"), "\"foo\"");
4654 assert_eq!(quote_for_diagnostic("a\"b"), "\"a\\\"b\"");
4655 assert_eq!(quote_for_diagnostic("a\nb"), "\"a\\nb\"");
4656 assert_eq!(quote_for_diagnostic("a\tb"), "\"a\\tb\"");
4657 assert_eq!(quote_for_diagnostic("\u{1}"), "\"\\u0001\"");
4659 }
4660
4661 #[test]
4665 fn unknown_field_key_is_escaped_in_warning() {
4666 let text =
4669 "---\nstatus: approved\nmaturity: mvp\n\"evil\\\"key\\nforged: line\\u0001\": 1\n---\n";
4670 let n = norm(text);
4671 assert!(n.is_valid(), "errors: {:?}", n.problems.errors);
4672 let warning = n
4673 .problems
4674 .warnings
4675 .iter()
4676 .find(|w| w.contains("unknown field(s) preserved"))
4677 .expect("expected an unknown-field warning");
4678 assert!(
4681 !warning.contains('\n'),
4682 "warning must stay on one line: {warning:?}"
4683 );
4684 assert!(
4685 !warning.contains('\u{1}'),
4686 "warning must not carry a raw control char: {warning:?}"
4687 );
4688 assert!(
4689 !warning.contains("evil\"key"),
4690 "the raw unescaped key must not appear: {warning:?}"
4691 );
4692 assert!(
4694 warning.contains("\\\"") && warning.contains("\\n") && warning.contains("\\u0001"),
4695 "the key must be JSON-escaped: {warning:?}"
4696 );
4697 }
4698
4699 #[test]
4703 fn invalid_enum_value_is_escaped_in_error() {
4704 let text = "---\nstatus: approved\nmaturity: \"mvp\\nforged: line\"\n---\n";
4705 let n = norm(text);
4706 assert_error_contains(&n, "maturity");
4707 let err = n
4708 .problems
4709 .errors
4710 .iter()
4711 .find(|e| e.contains("maturity") && e.contains("invalid"))
4712 .expect("expected a maturity-invalid error");
4713 assert!(!err.contains('\n'), "error must stay on one line: {err:?}");
4714 assert!(
4715 err.contains("\\n"),
4716 "the rejected value's newline must be escaped: {err:?}"
4717 );
4718 }
4719}