1use crate::error::{Error, Result};
9use crate::model::{
10 Adapter, Bump, Pre1BumpMapping, ProjectionMode, ReleaseUnitDisposition, TagPhase, TagRole,
11};
12use serde::{Deserialize, Serialize};
13use std::collections::{BTreeMap, BTreeSet};
14use std::path::{Component, Path, PathBuf};
15
16pub const CONFIG_PATH: &str = ".intentional/config.yml";
18
19pub const CONFIG_SCHEMA: &str = "https://intentional.foo/schemas/config.yml";
21
22pub const CURRENT_CONTRACT: &str = "contract-1";
24
25#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
27#[serde(rename_all = "kebab-case", deny_unknown_fields)]
28pub struct Config {
29 #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
31 pub schema: Option<String>,
32 pub contract: String,
34 #[serde(default)]
36 pub settings: Settings,
37 #[serde(default, skip_serializing_if = "Vec::is_empty")]
39 pub fixed: Vec<Vec<String>>,
40 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub linked: Vec<Vec<String>>,
43 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
45 pub workspace_tags: BTreeMap<String, WorkspaceTagConfig>,
46 #[serde(default, skip_serializing_if = "DiscoveryConfig::is_empty")]
48 pub discovery: DiscoveryConfig,
49 pub release_units: BTreeMap<String, ReleaseUnitConfig>,
51}
52
53impl Default for Config {
54 fn default() -> Self {
55 Self {
56 schema: Some(CONFIG_SCHEMA.to_owned()),
57 contract: CURRENT_CONTRACT.to_owned(),
58 settings: Settings::default(),
59 fixed: Vec::new(),
60 linked: Vec::new(),
61 workspace_tags: BTreeMap::new(),
62 discovery: DiscoveryConfig::default(),
63 release_units: BTreeMap::new(),
64 }
65 }
66}
67
68#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
70#[serde(rename_all = "kebab-case", deny_unknown_fields)]
71pub struct DiscoveryConfig {
72 #[serde(default, skip_serializing_if = "Vec::is_empty")]
74 pub managed_paths: Vec<ManagedPathReceipt>,
75 #[serde(default, skip_serializing_if = "Vec::is_empty")]
77 pub excluded_paths: Vec<ExcludedPathReceipt>,
78}
79
80impl DiscoveryConfig {
81 fn is_empty(&self) -> bool {
82 self.managed_paths.is_empty() && self.excluded_paths.is_empty()
83 }
84}
85
86#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
88#[serde(rename_all = "kebab-case", deny_unknown_fields)]
89pub struct ManagedPathReceipt {
90 pub detector: String,
92 pub path: PathBuf,
94 pub release_unit: String,
96}
97
98#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
100#[serde(rename_all = "kebab-case", deny_unknown_fields)]
101pub struct ExcludedPathReceipt {
102 pub detector: String,
104 pub path: PathBuf,
106 pub evidence_digest: String,
108}
109
110#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
112#[serde(rename_all = "kebab-case", deny_unknown_fields)]
113pub struct Settings {
114 #[serde(default = "default_dependency_bump")]
116 pub internal_dependency_bump: Bump,
117 #[serde(default)]
119 pub pre_1_0_bump_mapping: Pre1BumpMapping,
120}
121
122impl Default for Settings {
123 fn default() -> Self {
124 Self {
125 internal_dependency_bump: default_dependency_bump(),
126 pre_1_0_bump_mapping: Pre1BumpMapping::default(),
127 }
128 }
129}
130
131const fn default_dependency_bump() -> Bump {
132 Bump::Patch
133}
134
135#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
137#[serde(rename_all = "kebab-case", deny_unknown_fields)]
138pub struct ReleaseUnitConfig {
139 pub path: PathBuf,
141 #[serde(default, skip_serializing_if = "is_managed")]
143 pub disposition: ReleaseUnitDisposition,
144 #[serde(default, skip_serializing_if = "Vec::is_empty")]
146 pub projections: Vec<Projection>,
147 pub tags: BTreeMap<String, TagConfig>,
149 #[serde(default, skip_serializing_if = "Vec::is_empty")]
151 pub depends_on: Vec<String>,
152}
153
154fn is_managed(disposition: &ReleaseUnitDisposition) -> bool {
155 *disposition == ReleaseUnitDisposition::Managed
156}
157
158#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
160#[serde(rename_all = "kebab-case", deny_unknown_fields)]
161pub struct TagConfig {
162 pub role: TagRole,
164 pub template: String,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub require_phase: Option<TagPhase>,
169 #[serde(default, skip_serializing_if = "Vec::is_empty")]
171 pub tag_after: Vec<String>,
172}
173
174#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
176#[serde(rename_all = "kebab-case", deny_unknown_fields)]
177pub struct WorkspaceTagConfig {
178 pub template: String,
180 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub require_phase: Option<TagPhase>,
183 #[serde(default, skip_serializing_if = "Vec::is_empty")]
185 pub tag_after: Vec<String>,
186}
187
188#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
190#[serde(rename_all = "kebab-case", deny_unknown_fields)]
191pub struct Projection {
192 pub adapter: Adapter,
194 pub file: PathBuf,
196 pub mode: ProjectionMode,
198 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub pointer: Option<String>,
201}
202
203impl Config {
204 pub fn load(root: &Path) -> Result<Self> {
206 let path = root.join(CONFIG_PATH);
207 let text = std::fs::read_to_string(&path).map_err(|error| Error::io(&path, error))?;
208 Self::from_yaml(&text)
209 }
210
211 pub fn from_yaml(text: &str) -> Result<Self> {
213 let config: Self = serde_yaml::from_str(text)?;
214 config.validate()?;
215 Ok(config)
216 }
217
218 pub fn to_yaml(&self) -> Result<String> {
220 self.validate()?;
221 Ok(serde_yaml::to_string(self)?)
222 }
223
224 pub fn primary_tag<'a>(&'a self, release_unit_id: &str) -> Result<(&'a str, &'a TagConfig)> {
226 let release_unit = self
227 .release_units
228 .get(release_unit_id)
229 .ok_or_else(|| Error::Validation(format!("unknown release unit {release_unit_id}")))?;
230 release_unit
231 .tags
232 .iter()
233 .find(|(_, tag)| tag.role == TagRole::Primary)
234 .map(|(id, tag)| (id.as_str(), tag))
235 .ok_or_else(|| {
236 Error::Validation(format!("release unit {release_unit_id} has no primary tag"))
237 })
238 }
239
240 pub fn release_unit_tag_id(release_unit_id: &str, tag_id: &str) -> String {
242 format!("release-unit/{release_unit_id}/{tag_id}")
243 }
244
245 pub fn workspace_tag_id(tag_id: &str) -> String {
247 format!("workspace/{tag_id}")
248 }
249
250 pub fn validate(&self) -> Result<()> {
252 if self.contract != CURRENT_CONTRACT {
253 return Err(Error::Validation(format!(
254 "unsupported interpretation contract {:?}; expected {CURRENT_CONTRACT}",
255 self.contract
256 )));
257 }
258 if self.release_units.is_empty() {
259 return Err(Error::Validation(
260 "config must declare at least one release unit".to_owned(),
261 ));
262 }
263 if self.settings.internal_dependency_bump == Bump::None {
264 return Err(Error::Validation(
265 "internal-dependency-bump must be major, minor, or patch".to_owned(),
266 ));
267 }
268
269 let mut canonical_tags = BTreeSet::new();
270 let mut resolved_templates = BTreeMap::new();
271 for (id, release_unit) in &self.release_units {
272 validate_id(id, "release unit")?;
273 validate_relative_path(&release_unit.path, &format!("release unit {id} path"))?;
274 let primary_count = release_unit
275 .tags
276 .values()
277 .filter(|tag| tag.role == TagRole::Primary)
278 .count();
279 if primary_count != 1 {
280 return Err(Error::Validation(format!(
281 "release unit {id} must declare exactly one primary tag"
282 )));
283 }
284
285 let mut projection_keys = BTreeSet::new();
286 for projection in &release_unit.projections {
287 validate_relative_path(
288 &projection.file,
289 &format!("release unit {id} projection file"),
290 )?;
291 if projection.adapter.requires_pointer()
292 && projection.pointer.as_deref().is_none_or(str::is_empty)
293 {
294 return Err(Error::Validation(format!(
295 "release unit {id} generic {:?} projection requires a pointer",
296 projection.adapter
297 )));
298 }
299 let key = (&projection.file, projection.pointer.as_deref());
300 if !projection_keys.insert(key) {
301 return Err(Error::Validation(format!(
302 "release unit {id} repeats projection {}",
303 projection.file.display()
304 )));
305 }
306 }
307
308 validate_dependencies(id, release_unit, &self.release_units)?;
309 for (tag_id, tag) in &release_unit.tags {
310 validate_id(tag_id, "tag")?;
311 validate_tag_template(
312 &format!("release unit {id} tag {tag_id}"),
313 &tag.template,
314 true,
315 )?;
316 let canonical = Self::release_unit_tag_id(id, tag_id);
317 canonical_tags.insert(canonical);
318 let rendered = tag.template.replace("{id}", id);
319 if let Some(other) =
320 resolved_templates.insert(rendered, format!("release unit {id} tag {tag_id}"))
321 {
322 return Err(Error::Validation(format!(
323 "release unit {id} tag {tag_id} template collides with {other}"
324 )));
325 }
326 }
327 }
328
329 for (id, tag) in &self.workspace_tags {
330 validate_id(id, "workspace tag")?;
331 validate_tag_template(&format!("workspace tag {id}"), &tag.template, false)?;
332 canonical_tags.insert(Self::workspace_tag_id(id));
333 if let Some(other) =
334 resolved_templates.insert(tag.template.clone(), format!("workspace tag {id}"))
335 {
336 return Err(Error::Validation(format!(
337 "workspace tag {id} template collides with {other}"
338 )));
339 }
340 }
341
342 self.validate_discovery()?;
343 self.validate_release_groups()?;
344 self.validate_dependency_acyclic()?;
345 self.validate_tag_graph(&canonical_tags)
346 }
347
348 fn validate_discovery(&self) -> Result<()> {
349 let mut identities = BTreeSet::new();
350 for receipt in &self.discovery.managed_paths {
351 validate_detector_id(&receipt.detector)?;
352 validate_exact_discovery_path(&receipt.path, "managed discovery path")?;
353 validate_id(&receipt.release_unit, "managed discovery release unit")?;
354 if !self.release_units.contains_key(&receipt.release_unit) {
355 return Err(Error::Validation(format!(
356 "managed discovery path {} references unknown release unit {}",
357 receipt.path.display(),
358 receipt.release_unit
359 )));
360 }
361 if !identities.insert((&receipt.detector, &receipt.path)) {
362 return Err(Error::Validation(format!(
363 "duplicate discovery receipt for detector {} at {}",
364 receipt.detector,
365 receipt.path.display()
366 )));
367 }
368 }
369 for receipt in &self.discovery.excluded_paths {
370 validate_detector_id(&receipt.detector)?;
371 validate_exact_discovery_path(&receipt.path, "excluded discovery path")?;
372 validate_sha256(
373 &receipt.evidence_digest,
374 "excluded discovery evidence digest",
375 )?;
376 if !identities.insert((&receipt.detector, &receipt.path)) {
377 return Err(Error::Validation(format!(
378 "duplicate discovery receipt for detector {} at {}",
379 receipt.detector,
380 receipt.path.display()
381 )));
382 }
383 }
384 Ok(())
385 }
386
387 fn validate_release_groups(&self) -> Result<()> {
388 let mut assigned = BTreeMap::new();
389 for (kind, groups) in [("fixed", &self.fixed), ("linked", &self.linked)] {
390 for (index, group) in groups.iter().enumerate() {
391 if group.len() < 2 {
392 return Err(Error::Validation(format!(
393 "{kind} group {index} must contain at least two release units"
394 )));
395 }
396 let mut members = BTreeSet::new();
397 for member in group {
398 if !self.release_units.contains_key(member) {
399 return Err(Error::Validation(format!(
400 "{kind} group {index} references unknown release unit {member}"
401 )));
402 }
403 if !members.insert(member) {
404 return Err(Error::Validation(format!(
405 "{kind} group {index} repeats release unit {member}"
406 )));
407 }
408 if let Some(previous) = assigned.insert(member, format!("{kind} group {index}"))
409 {
410 return Err(Error::Validation(format!(
411 "release unit {member} belongs to both {previous} and {kind} group {index}"
412 )));
413 }
414 }
415 }
416 }
417 Ok(())
418 }
419
420 fn validate_dependency_acyclic(&self) -> Result<()> {
421 fn visit<'a>(
422 id: &'a str,
423 release_units: &'a BTreeMap<String, ReleaseUnitConfig>,
424 visiting: &mut BTreeSet<&'a str>,
425 visited: &mut BTreeSet<&'a str>,
426 ) -> Result<()> {
427 if visited.contains(id) {
428 return Ok(());
429 }
430 if !visiting.insert(id) {
431 return Err(Error::Validation(format!(
432 "dependency cycle includes release unit {id}"
433 )));
434 }
435 for dependency in &release_units[id].depends_on {
436 visit(dependency, release_units, visiting, visited)?;
437 }
438 visiting.remove(id);
439 visited.insert(id);
440 Ok(())
441 }
442
443 let mut visiting = BTreeSet::new();
444 let mut visited = BTreeSet::new();
445 for id in self.release_units.keys() {
446 visit(id, &self.release_units, &mut visiting, &mut visited)?;
447 }
448 Ok(())
449 }
450
451 fn validate_tag_graph(&self, known: &BTreeSet<String>) -> Result<()> {
452 let mut edges = BTreeMap::<String, Vec<String>>::new();
453 for (release_unit_id, release_unit) in &self.release_units {
454 for (tag_id, tag) in &release_unit.tags {
455 edges.insert(
456 Self::release_unit_tag_id(release_unit_id, tag_id),
457 tag.tag_after.clone(),
458 );
459 }
460 }
461 for (tag_id, tag) in &self.workspace_tags {
462 edges.insert(Self::workspace_tag_id(tag_id), tag.tag_after.clone());
463 }
464 for (id, prerequisites) in &edges {
465 let mut unique = BTreeSet::new();
466 for prerequisite in prerequisites {
467 if prerequisite == id {
468 return Err(Error::Validation(format!(
469 "tag {id} cannot depend on itself"
470 )));
471 }
472 if !known.contains(prerequisite) {
473 return Err(Error::Validation(format!(
474 "tag {id} depends on unknown tag {prerequisite}"
475 )));
476 }
477 if !unique.insert(prerequisite) {
478 return Err(Error::Validation(format!(
479 "tag {id} repeats prerequisite {prerequisite}"
480 )));
481 }
482 }
483 }
484
485 fn visit<'a>(
486 id: &'a str,
487 edges: &'a BTreeMap<String, Vec<String>>,
488 visiting: &mut BTreeSet<&'a str>,
489 visited: &mut BTreeSet<&'a str>,
490 ) -> Result<()> {
491 if visited.contains(id) {
492 return Ok(());
493 }
494 if !visiting.insert(id) {
495 return Err(Error::Validation(format!("tag-order cycle includes {id}")));
496 }
497 for dependency in &edges[id] {
498 visit(dependency, edges, visiting, visited)?;
499 }
500 visiting.remove(id);
501 visited.insert(id);
502 Ok(())
503 }
504 let mut visiting = BTreeSet::new();
505 let mut visited = BTreeSet::new();
506 for id in edges.keys() {
507 visit(id, &edges, &mut visiting, &mut visited)?;
508 }
509 Ok(())
510 }
511}
512
513fn validate_dependencies(
514 id: &str,
515 release_unit: &ReleaseUnitConfig,
516 release_units: &BTreeMap<String, ReleaseUnitConfig>,
517) -> Result<()> {
518 let mut dependencies = BTreeSet::new();
519 for dependency in &release_unit.depends_on {
520 if dependency == id {
521 return Err(Error::Validation(format!(
522 "release unit {id} cannot depend on itself"
523 )));
524 }
525 if !release_units.contains_key(dependency) {
526 return Err(Error::Validation(format!(
527 "release unit {id} depends on unknown release unit {dependency}"
528 )));
529 }
530 if !dependencies.insert(dependency) {
531 return Err(Error::Validation(format!(
532 "release unit {id} repeats dependency {dependency}"
533 )));
534 }
535 }
536 Ok(())
537}
538
539fn validate_id(id: &str, kind: &str) -> Result<()> {
540 if id.is_empty()
541 || !id
542 .chars()
543 .all(|character| character.is_ascii_alphanumeric() || "-_.@/".contains(character))
544 {
545 return Err(Error::Validation(format!("invalid {kind} id {id:?}")));
546 }
547 Ok(())
548}
549
550pub(crate) fn validate_detector_id(id: &str) -> Result<()> {
551 if id.is_empty()
552 || !id
553 .chars()
554 .next()
555 .is_some_and(|character| character.is_ascii_lowercase() || character.is_ascii_digit())
556 || !id.chars().all(|character| {
557 character.is_ascii_lowercase()
558 || character.is_ascii_digit()
559 || "-_.".contains(character)
560 })
561 {
562 return Err(Error::Validation(format!("invalid detector id {id:?}")));
563 }
564 Ok(())
565}
566
567pub(crate) fn validate_sha256(value: &str, description: &str) -> Result<()> {
568 let Some(digest) = value.strip_prefix("sha256:") else {
569 return Err(Error::Validation(format!(
570 "{description} must be sha256 followed by 64 lowercase hexadecimal digits"
571 )));
572 };
573 if digest.len() != 64
574 || !digest
575 .chars()
576 .all(|character| character.is_ascii_hexdigit() && !character.is_ascii_uppercase())
577 {
578 return Err(Error::Validation(format!(
579 "{description} must be sha256 followed by 64 lowercase hexadecimal digits"
580 )));
581 }
582 Ok(())
583}
584
585fn validate_relative_path(path: &Path, description: &str) -> Result<()> {
586 if path.as_os_str().is_empty()
587 || path.is_absolute()
588 || path
589 .components()
590 .any(|component| matches!(component, Component::ParentDir))
591 {
592 return Err(Error::Validation(format!(
593 "{description} must be a non-empty relative path without .."
594 )));
595 }
596 Ok(())
597}
598
599pub(crate) fn validate_exact_discovery_path(path: &Path, description: &str) -> Result<()> {
600 validate_relative_path(path, description)?;
601 let rendered = path.to_string_lossy();
602 let normalized = path
603 .components()
604 .filter_map(|component| match component {
605 Component::Normal(value) => Some(value.to_string_lossy()),
606 _ => None,
607 })
608 .collect::<Vec<_>>()
609 .join("/");
610 if rendered.replace('\\', "/") != normalized
611 || rendered
612 .chars()
613 .any(|character| matches!(character, '*' | '?' | '[' | ']' | '{' | '}'))
614 {
615 return Err(Error::Validation(format!(
616 "{description} must identify one exact workspace-relative path"
617 )));
618 }
619 Ok(())
620}
621
622pub(crate) fn validate_tag_template(
623 description: &str,
624 template: &str,
625 allow_id: bool,
626) -> Result<()> {
627 if template.matches("{version}").count() != 1
628 || template.matches("{id}").count() > usize::from(allow_id)
629 {
630 return Err(Error::Validation(format!(
631 "{description} template must contain exactly one {{version}}{}",
632 if allow_id {
633 " and at most one {id}"
634 } else {
635 " and no {id}"
636 }
637 )));
638 }
639 if template.contains("v{version}") {
640 return Err(Error::Validation(format!(
641 "{description} template must not prefix versions with v"
642 )));
643 }
644 Ok(())
645}
646
647#[cfg(test)]
648mod tests {
649 use super::*;
650
651 const VALID: &str = r#"
652$schema: https://intentional.foo/schemas/config.yml
653contract: contract-1
654settings:
655 internal-dependency-bump: patch
656 pre-1-0-bump-mapping: component
657workspace-tags:
658 release:
659 template: '{version}'
660release-units:
661 library:
662 path: packages/library
663 projections:
664 - { adapter: npm, file: package.json, mode: committed }
665 tags:
666 primary: { role: primary, template: '{id}@{version}' }
667 application:
668 path: packages/application
669 depends-on: [library]
670 projections:
671 - adapter: json
672 file: metadata.json
673 pointer: /version
674 mode: injected
675 tags:
676 primary: { role: primary, template: 'application@{version}' }
677"#;
678
679 #[test]
680 fn parses_complete_contract() {
681 let config = Config::from_yaml(VALID).expect("valid config");
682 assert_eq!(config.contract, CURRENT_CONTRACT);
683 assert_eq!(
684 config.settings.pre_1_0_bump_mapping,
685 Pre1BumpMapping::Component
686 );
687 assert_eq!(config.primary_tag("library").expect("primary").0, "primary");
688 }
689
690 #[test]
691 fn validates_managed_and_exact_excluded_discovery_receipts() {
692 let receipts = VALID.replace(
693 "release-units:\n",
694 "discovery:\n managed-paths:\n - detector: npm-package\n path: packages/library/package.json\n release-unit: library\n excluded-paths:\n - detector: npm-package\n path: examples/package.json\n evidence-digest: sha256:0000000000000000000000000000000000000000000000000000000000000000\nrelease-units:\n",
695 );
696 let config = Config::from_yaml(&receipts).expect("discovery receipts accepted");
697 assert_eq!(config.discovery.managed_paths.len(), 1);
698 assert_eq!(config.discovery.excluded_paths.len(), 1);
699
700 let unknown = receipts.replace("release-unit: library", "release-unit: missing");
701 assert!(Config::from_yaml(&unknown)
702 .expect_err("unknown managed target rejected")
703 .to_string()
704 .contains("unknown release unit missing"));
705
706 let glob = receipts.replace("examples/package.json", "examples/*.json");
707 assert!(Config::from_yaml(&glob)
708 .expect_err("glob exclusion rejected")
709 .to_string()
710 .contains("one exact workspace-relative path"));
711
712 let non_canonical = receipts.replace("examples/package.json", "./examples/package.json");
713 assert!(Config::from_yaml(&non_canonical)
714 .expect_err("non-canonical exact path rejected")
715 .to_string()
716 .contains("one exact workspace-relative path"));
717
718 let stale_shape = receipts.replace(
719 "sha256:0000000000000000000000000000000000000000000000000000000000000000",
720 "sha256:abcd",
721 );
722 assert!(Config::from_yaml(&stale_shape)
723 .expect_err("invalid exclusion digest rejected")
724 .to_string()
725 .contains("64 lowercase hexadecimal digits"));
726 }
727
728 #[test]
729 fn published_config_schema_carries_discovery_receipt_contracts() {
730 let schema: serde_yaml::Value =
731 serde_yaml::from_str(include_str!("../../../schemas/config.yml"))
732 .expect("config schema parses");
733 let discovery = &schema["$defs"]["discovery"];
734 assert_eq!(discovery["additionalProperties"].as_bool(), Some(false));
735 assert_eq!(
736 discovery["properties"]["managed-paths"]["items"]["required"]
737 .as_sequence()
738 .expect("managed receipt required fields")
739 .len(),
740 3
741 );
742 assert_eq!(
743 discovery["properties"]["excluded-paths"]["items"]["properties"]["evidence-digest"]
744 ["pattern"]
745 .as_str(),
746 Some("^sha256:[0-9a-f]{64}$")
747 );
748 }
749
750 #[test]
751 fn validates_fixed_and_linked_membership() {
752 let invalid = VALID.replace(
753 "release-units:\n",
754 "fixed: [[library, application]]\nlinked: [[library, application]]\nrelease-units:\n",
755 );
756 let error = Config::from_yaml(&invalid).expect_err("overlap rejected");
757 assert!(error.to_string().contains("belongs to both"));
758 }
759
760 #[test]
761 fn permits_tag_only_release_units() {
762 let valid = VALID.replace(
763 " projections:\n - { adapter: npm, file: package.json, mode: committed }\n tags:",
764 " tags:",
765 );
766 Config::from_yaml(&valid).expect("tag-only release unit accepted");
767 }
768
769 #[test]
770 fn rejects_obsolete_packages_key() {
771 let obsolete = VALID.replace("release-units:", "packages:");
772 let error = Config::from_yaml(&obsolete).expect_err("obsolete public key rejected");
773 assert!(error.to_string().contains("unknown field `packages`"));
774 }
775
776 #[test]
777 fn rejects_missing_or_duplicate_primary_tags() {
778 let missing = VALID.replace(
779 "role: primary, template: '{id}@{version}'",
780 "role: projection, template: '{id}@{version}'",
781 );
782 assert!(Config::from_yaml(&missing)
783 .expect_err("missing primary rejected")
784 .to_string()
785 .contains("exactly one primary"));
786 let duplicate = VALID.replace(
787 "primary: { role: primary, template: '{id}@{version}' }",
788 "primary: { role: primary, template: '{id}@{version}' }\n second: { role: primary, template: 'second@{version}' }",
789 );
790 assert!(Config::from_yaml(&duplicate)
791 .expect_err("duplicate primary rejected")
792 .to_string()
793 .contains("exactly one primary"));
794 }
795
796 #[test]
797 fn rejects_unknown_and_cyclic_tag_prerequisites() {
798 let unknown = VALID.replace(
799 "template: '{version}'",
800 "template: '{version}'\n tag-after: [workspace/missing]",
801 );
802 assert!(Config::from_yaml(&unknown)
803 .expect_err("unknown tag rejected")
804 .to_string()
805 .contains("unknown tag"));
806 let cyclic = VALID
807 .replace("template: '{version}'", "template: '{version}'\n tag-after: [release-unit/library/primary]")
808 .replace(
809 "primary: { role: primary, template: '{id}@{version}' }",
810 "primary: { role: primary, template: '{id}@{version}', tag-after: [workspace/release] }",
811 );
812 assert!(Config::from_yaml(&cyclic)
813 .expect_err("tag cycle rejected")
814 .to_string()
815 .contains("tag-order cycle"));
816 }
817
818 #[test]
819 fn rejects_versions_and_legacy_global_tag() {
820 let versioned = VALID.replace(
821 "path: packages/library",
822 "path: packages/library\n version: 1.2.3",
823 );
824 assert!(Config::from_yaml(&versioned).is_err());
825 let legacy = VALID.replace(
826 "internal-dependency-bump: patch",
827 "internal-dependency-bump: patch\n global-tag: true",
828 );
829 assert!(Config::from_yaml(&legacy).is_err());
830 }
831}