1use crate::error::{Error, Result};
9use crate::model::{
10 Adapter, Bump, Pre1BumpMapping, ProjectionMode, ReleaseUnitDisposition, TagPhase, TagRole,
11};
12use crate::ownership::check_ownership;
13use crate::projection_file::validate_relative_path;
14pub use crate::projection_file::ProjectionFile;
15use serde::{Deserialize, Serialize};
16use std::collections::{BTreeMap, BTreeSet};
17use std::path::{Component, Path, PathBuf};
18
19pub const CONFIG_PATH: &str = ".intentional/config.yml";
21
22pub const CONFIG_SCHEMA: &str = "https://intentional.foo/schemas/config.yml";
24
25pub const CURRENT_CONTRACT: &str = "contract-1";
27
28#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
30#[serde(rename_all = "kebab-case", deny_unknown_fields)]
31pub struct Config {
32 #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
34 pub schema: Option<String>,
35 pub contract: String,
37 #[serde(default)]
39 pub settings: Settings,
40 #[serde(default, skip_serializing_if = "Vec::is_empty")]
42 pub fixed: Vec<Vec<String>>,
43 #[serde(default, skip_serializing_if = "Vec::is_empty")]
45 pub linked: Vec<Vec<String>>,
46 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
48 pub workspace_tags: BTreeMap<String, WorkspaceTagConfig>,
49 #[serde(default, skip_serializing_if = "DiscoveryConfig::is_empty")]
51 pub discovery: DiscoveryConfig,
52 pub release_units: BTreeMap<String, ReleaseUnitConfig>,
54}
55
56impl Default for Config {
57 fn default() -> Self {
58 Self {
59 schema: Some(CONFIG_SCHEMA.to_owned()),
60 contract: CURRENT_CONTRACT.to_owned(),
61 settings: Settings::default(),
62 fixed: Vec::new(),
63 linked: Vec::new(),
64 workspace_tags: BTreeMap::new(),
65 discovery: DiscoveryConfig::default(),
66 release_units: BTreeMap::new(),
67 }
68 }
69}
70
71#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
73#[serde(rename_all = "kebab-case", deny_unknown_fields)]
74pub struct DiscoveryConfig {
75 #[serde(default, skip_serializing_if = "Vec::is_empty")]
77 pub managed_paths: Vec<ManagedPathReceipt>,
78 #[serde(default, skip_serializing_if = "Vec::is_empty")]
80 pub excluded_paths: Vec<ExcludedPathReceipt>,
81}
82
83impl DiscoveryConfig {
84 fn is_empty(&self) -> bool {
85 self.managed_paths.is_empty() && self.excluded_paths.is_empty()
86 }
87}
88
89#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
91#[serde(rename_all = "kebab-case", deny_unknown_fields)]
92pub struct ManagedPathReceipt {
93 pub detector: String,
95 pub path: PathBuf,
97 pub release_unit: String,
99}
100
101#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
103#[serde(rename_all = "kebab-case", deny_unknown_fields)]
104pub struct ExcludedPathReceipt {
105 pub detector: String,
107 pub path: PathBuf,
109 pub evidence_digest: String,
111}
112
113#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
115#[serde(rename_all = "kebab-case", deny_unknown_fields)]
116pub struct Settings {
117 #[serde(default = "default_dependency_bump")]
119 pub internal_dependency_bump: Bump,
120 #[serde(default)]
122 pub pre_1_0_bump_mapping: Pre1BumpMapping,
123}
124
125impl Default for Settings {
126 fn default() -> Self {
127 Self {
128 internal_dependency_bump: default_dependency_bump(),
129 pre_1_0_bump_mapping: Pre1BumpMapping::default(),
130 }
131 }
132}
133
134const fn default_dependency_bump() -> Bump {
135 Bump::Patch
136}
137
138#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
140#[serde(rename_all = "kebab-case", deny_unknown_fields)]
141pub struct ReleaseUnitConfig {
142 pub path: PathBuf,
144 #[serde(default, skip_serializing_if = "is_managed")]
146 pub disposition: ReleaseUnitDisposition,
147 #[serde(default, skip_serializing_if = "Vec::is_empty")]
149 pub projections: Vec<Projection>,
150 pub tags: BTreeMap<String, TagConfig>,
152 #[serde(default, skip_serializing_if = "Vec::is_empty")]
154 pub depends_on: Vec<String>,
155}
156
157fn is_managed(disposition: &ReleaseUnitDisposition) -> bool {
158 *disposition == ReleaseUnitDisposition::Managed
159}
160
161#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
163#[serde(rename_all = "kebab-case", deny_unknown_fields)]
164pub struct TagConfig {
165 pub role: TagRole,
167 pub template: String,
169 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub require_phase: Option<TagPhase>,
172 #[serde(default, skip_serializing_if = "Vec::is_empty")]
174 pub tag_after: Vec<String>,
175}
176
177#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
179#[serde(rename_all = "kebab-case", deny_unknown_fields)]
180pub struct WorkspaceTagConfig {
181 pub template: String,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub require_phase: Option<TagPhase>,
186 #[serde(default, skip_serializing_if = "Vec::is_empty")]
188 pub tag_after: Vec<String>,
189}
190
191#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
193#[serde(rename_all = "kebab-case", deny_unknown_fields)]
194pub struct Projection {
195 pub adapter: Adapter,
197 pub file: ProjectionFile,
200 pub mode: ProjectionMode,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
204 pub pointer: Option<String>,
205}
206
207impl Config {
208 pub fn load(root: &Path) -> Result<Self> {
210 let path = root.join(CONFIG_PATH);
211 let text = std::fs::read_to_string(&path).map_err(|error| Error::io(&path, error))?;
212 Self::from_yaml(&text)
213 }
214
215 pub fn from_yaml(text: &str) -> Result<Self> {
217 let config: Self = serde_yaml::from_str(text)?;
218 config.validate()?;
219 Ok(config)
220 }
221
222 pub fn to_yaml(&self) -> Result<String> {
224 self.validate()?;
225 Ok(serde_yaml::to_string(self)?)
226 }
227
228 pub fn primary_tag<'a>(&'a self, release_unit_id: &str) -> Result<(&'a str, &'a TagConfig)> {
230 let release_unit = self
231 .release_units
232 .get(release_unit_id)
233 .ok_or_else(|| Error::Validation(format!("unknown release unit {release_unit_id}")))?;
234 release_unit
235 .tags
236 .iter()
237 .find(|(_, tag)| tag.role == TagRole::Primary)
238 .map(|(id, tag)| (id.as_str(), tag))
239 .ok_or_else(|| {
240 Error::Validation(format!("release unit {release_unit_id} has no primary tag"))
241 })
242 }
243
244 pub fn release_unit_tag_id(release_unit_id: &str, tag_id: &str) -> String {
246 format!("release-unit/{release_unit_id}/{tag_id}")
247 }
248
249 pub fn workspace_tag_id(tag_id: &str) -> String {
251 format!("workspace/{tag_id}")
252 }
253
254 pub fn validate(&self) -> Result<()> {
256 if self.contract != CURRENT_CONTRACT {
257 return Err(Error::Validation(format!(
258 "unsupported interpretation contract {:?}; expected {CURRENT_CONTRACT}",
259 self.contract
260 )));
261 }
262 if self.release_units.is_empty() {
263 return Err(Error::Validation(
264 "config must declare at least one release unit".to_owned(),
265 ));
266 }
267 if self.settings.internal_dependency_bump == Bump::None {
268 return Err(Error::Validation(
269 "internal-dependency-bump must be major, minor, or patch".to_owned(),
270 ));
271 }
272
273 let mut canonical_tags = BTreeSet::new();
274 let mut resolved_templates = BTreeMap::new();
275 for (id, release_unit) in &self.release_units {
276 validate_id(id, "release unit")?;
277 validate_relative_path(&release_unit.path, &format!("release unit {id} path"))?;
278 let primary_count = release_unit
279 .tags
280 .values()
281 .filter(|tag| tag.role == TagRole::Primary)
282 .count();
283 if primary_count != 1 {
284 return Err(Error::Validation(format!(
285 "release unit {id} must declare exactly one primary tag"
286 )));
287 }
288
289 for projection in &release_unit.projections {
290 projection.file.validate(id, projection.adapter)?;
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 }
300
301 validate_dependencies(id, release_unit, &self.release_units)?;
302 for (tag_id, tag) in &release_unit.tags {
303 validate_id(tag_id, "tag")?;
304 validate_tag_template(
305 &format!("release unit {id} tag {tag_id}"),
306 &tag.template,
307 true,
308 )?;
309 let canonical = Self::release_unit_tag_id(id, tag_id);
310 canonical_tags.insert(canonical);
311 let rendered = tag.template.replace("{id}", id);
312 if let Some(other) =
313 resolved_templates.insert(rendered, format!("release unit {id} tag {tag_id}"))
314 {
315 return Err(Error::Validation(format!(
316 "release unit {id} tag {tag_id} template collides with {other}"
317 )));
318 }
319 }
320 }
321
322 for (id, tag) in &self.workspace_tags {
323 validate_id(id, "workspace tag")?;
324 validate_tag_template(&format!("workspace tag {id}"), &tag.template, false)?;
325 canonical_tags.insert(Self::workspace_tag_id(id));
326 if let Some(other) =
327 resolved_templates.insert(tag.template.clone(), format!("workspace tag {id}"))
328 {
329 return Err(Error::Validation(format!(
330 "workspace tag {id} template collides with {other}"
331 )));
332 }
333 }
334
335 check_ownership(&self.release_units)?;
336 self.validate_discovery()?;
337 self.validate_release_groups()?;
338 self.validate_dependency_acyclic()?;
339 self.validate_tag_graph(&canonical_tags)
340 }
341
342 fn validate_discovery(&self) -> Result<()> {
343 let mut identities = BTreeSet::new();
344 for receipt in &self.discovery.managed_paths {
345 validate_detector_id(&receipt.detector)?;
346 validate_exact_discovery_path(&receipt.path, "managed discovery path")?;
347 validate_id(&receipt.release_unit, "managed discovery release unit")?;
348 if !self.release_units.contains_key(&receipt.release_unit) {
349 return Err(Error::Validation(format!(
350 "managed discovery path {} references unknown release unit {}",
351 receipt.path.display(),
352 receipt.release_unit
353 )));
354 }
355 if !identities.insert((&receipt.detector, &receipt.path)) {
356 return Err(Error::Validation(format!(
357 "duplicate discovery receipt for detector {} at {}",
358 receipt.detector,
359 receipt.path.display()
360 )));
361 }
362 }
363 for receipt in &self.discovery.excluded_paths {
364 validate_detector_id(&receipt.detector)?;
365 validate_exact_discovery_path(&receipt.path, "excluded discovery path")?;
366 validate_sha256(
367 &receipt.evidence_digest,
368 "excluded discovery evidence digest",
369 )?;
370 if !identities.insert((&receipt.detector, &receipt.path)) {
371 return Err(Error::Validation(format!(
372 "duplicate discovery receipt for detector {} at {}",
373 receipt.detector,
374 receipt.path.display()
375 )));
376 }
377 }
378 Ok(())
379 }
380
381 fn validate_release_groups(&self) -> Result<()> {
382 let mut assigned = BTreeMap::new();
383 for (kind, groups) in [("fixed", &self.fixed), ("linked", &self.linked)] {
384 for (index, group) in groups.iter().enumerate() {
385 if group.len() < 2 {
386 return Err(Error::Validation(format!(
387 "{kind} group {index} must contain at least two release units"
388 )));
389 }
390 let mut members = BTreeSet::new();
391 for member in group {
392 if !self.release_units.contains_key(member) {
393 return Err(Error::Validation(format!(
394 "{kind} group {index} references unknown release unit {member}"
395 )));
396 }
397 if !members.insert(member) {
398 return Err(Error::Validation(format!(
399 "{kind} group {index} repeats release unit {member}"
400 )));
401 }
402 if let Some(previous) = assigned.insert(member, format!("{kind} group {index}"))
403 {
404 return Err(Error::Validation(format!(
405 "release unit {member} belongs to both {previous} and {kind} group {index}"
406 )));
407 }
408 }
409 }
410 }
411 Ok(())
412 }
413
414 fn validate_dependency_acyclic(&self) -> Result<()> {
415 fn visit<'a>(
416 id: &'a str,
417 release_units: &'a BTreeMap<String, ReleaseUnitConfig>,
418 visiting: &mut BTreeSet<&'a str>,
419 visited: &mut BTreeSet<&'a str>,
420 ) -> Result<()> {
421 if visited.contains(id) {
422 return Ok(());
423 }
424 if !visiting.insert(id) {
425 return Err(Error::Validation(format!(
426 "dependency cycle includes release unit {id}"
427 )));
428 }
429 for dependency in &release_units[id].depends_on {
430 visit(dependency, release_units, visiting, visited)?;
431 }
432 visiting.remove(id);
433 visited.insert(id);
434 Ok(())
435 }
436
437 let mut visiting = BTreeSet::new();
438 let mut visited = BTreeSet::new();
439 for id in self.release_units.keys() {
440 visit(id, &self.release_units, &mut visiting, &mut visited)?;
441 }
442 Ok(())
443 }
444
445 fn validate_tag_graph(&self, known: &BTreeSet<String>) -> Result<()> {
446 let mut edges = BTreeMap::<String, Vec<String>>::new();
447 for (release_unit_id, release_unit) in &self.release_units {
448 for (tag_id, tag) in &release_unit.tags {
449 edges.insert(
450 Self::release_unit_tag_id(release_unit_id, tag_id),
451 tag.tag_after.clone(),
452 );
453 }
454 }
455 for (tag_id, tag) in &self.workspace_tags {
456 edges.insert(Self::workspace_tag_id(tag_id), tag.tag_after.clone());
457 }
458 for (id, prerequisites) in &edges {
459 let mut unique = BTreeSet::new();
460 for prerequisite in prerequisites {
461 if prerequisite == id {
462 return Err(Error::Validation(format!(
463 "tag {id} cannot depend on itself"
464 )));
465 }
466 if !known.contains(prerequisite) {
467 return Err(Error::Validation(format!(
468 "tag {id} depends on unknown tag {prerequisite}"
469 )));
470 }
471 if !unique.insert(prerequisite) {
472 return Err(Error::Validation(format!(
473 "tag {id} repeats prerequisite {prerequisite}"
474 )));
475 }
476 }
477 }
478
479 fn visit<'a>(
480 id: &'a str,
481 edges: &'a BTreeMap<String, Vec<String>>,
482 visiting: &mut BTreeSet<&'a str>,
483 visited: &mut BTreeSet<&'a str>,
484 ) -> Result<()> {
485 if visited.contains(id) {
486 return Ok(());
487 }
488 if !visiting.insert(id) {
489 return Err(Error::Validation(format!("tag-order cycle includes {id}")));
490 }
491 for dependency in &edges[id] {
492 visit(dependency, edges, visiting, visited)?;
493 }
494 visiting.remove(id);
495 visited.insert(id);
496 Ok(())
497 }
498 let mut visiting = BTreeSet::new();
499 let mut visited = BTreeSet::new();
500 for id in edges.keys() {
501 visit(id, &edges, &mut visiting, &mut visited)?;
502 }
503 Ok(())
504 }
505}
506
507fn validate_dependencies(
508 id: &str,
509 release_unit: &ReleaseUnitConfig,
510 release_units: &BTreeMap<String, ReleaseUnitConfig>,
511) -> Result<()> {
512 let mut dependencies = BTreeSet::new();
513 for dependency in &release_unit.depends_on {
514 if dependency == id {
515 return Err(Error::Validation(format!(
516 "release unit {id} cannot depend on itself"
517 )));
518 }
519 if !release_units.contains_key(dependency) {
520 return Err(Error::Validation(format!(
521 "release unit {id} depends on unknown release unit {dependency}"
522 )));
523 }
524 if !dependencies.insert(dependency) {
525 return Err(Error::Validation(format!(
526 "release unit {id} repeats dependency {dependency}"
527 )));
528 }
529 }
530 Ok(())
531}
532
533fn validate_id(id: &str, kind: &str) -> Result<()> {
534 if id.is_empty()
535 || !id
536 .chars()
537 .all(|character| character.is_ascii_alphanumeric() || "-_.@/".contains(character))
538 {
539 return Err(Error::Validation(format!("invalid {kind} id {id:?}")));
540 }
541 Ok(())
542}
543
544pub(crate) fn validate_detector_id(id: &str) -> Result<()> {
545 if id.is_empty()
546 || !id
547 .chars()
548 .next()
549 .is_some_and(|character| character.is_ascii_lowercase() || character.is_ascii_digit())
550 || !id.chars().all(|character| {
551 character.is_ascii_lowercase()
552 || character.is_ascii_digit()
553 || "-_.".contains(character)
554 })
555 {
556 return Err(Error::Validation(format!("invalid detector id {id:?}")));
557 }
558 Ok(())
559}
560
561pub(crate) fn validate_sha256(value: &str, description: &str) -> Result<()> {
562 let Some(digest) = value.strip_prefix("sha256:") else {
563 return Err(Error::Validation(format!(
564 "{description} must be sha256 followed by 64 lowercase hexadecimal digits"
565 )));
566 };
567 if digest.len() != 64
568 || !digest
569 .chars()
570 .all(|character| character.is_ascii_hexdigit() && !character.is_ascii_uppercase())
571 {
572 return Err(Error::Validation(format!(
573 "{description} must be sha256 followed by 64 lowercase hexadecimal digits"
574 )));
575 }
576 Ok(())
577}
578
579pub(crate) fn validate_exact_discovery_path(path: &Path, description: &str) -> Result<()> {
580 validate_relative_path(path, description)?;
581 let rendered = path.to_string_lossy();
582 let normalized = path
583 .components()
584 .filter_map(|component| match component {
585 Component::Normal(value) => Some(value.to_string_lossy()),
586 _ => None,
587 })
588 .collect::<Vec<_>>()
589 .join("/");
590 if rendered.replace('\\', "/") != normalized
591 || rendered
592 .chars()
593 .any(|character| matches!(character, '*' | '?' | '[' | ']' | '{' | '}'))
594 {
595 return Err(Error::Validation(format!(
596 "{description} must identify one exact workspace-relative path"
597 )));
598 }
599 Ok(())
600}
601
602pub(crate) fn validate_tag_template(
603 description: &str,
604 template: &str,
605 allow_id: bool,
606) -> Result<()> {
607 if template.matches("{version}").count() != 1
608 || template.matches("{id}").count() > usize::from(allow_id)
609 {
610 return Err(Error::Validation(format!(
611 "{description} template must contain exactly one {{version}}{}",
612 if allow_id {
613 " and at most one {id}"
614 } else {
615 " and no {id}"
616 }
617 )));
618 }
619 if template.contains("v{version}") {
620 return Err(Error::Validation(format!(
621 "{description} template must not prefix versions with v"
622 )));
623 }
624 Ok(())
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630
631 const VALID: &str = r#"
632$schema: https://intentional.foo/schemas/config.yml
633contract: contract-1
634settings:
635 internal-dependency-bump: patch
636 pre-1-0-bump-mapping: component
637workspace-tags:
638 release:
639 template: '{version}'
640release-units:
641 library:
642 path: packages/library
643 projections:
644 - { adapter: npm, file: package.json, mode: committed }
645 tags:
646 primary: { role: primary, template: '{id}@{version}' }
647 application:
648 path: packages/application
649 depends-on: [library]
650 projections:
651 - adapter: json
652 file: metadata.json
653 pointer: /version
654 mode: injected
655 tags:
656 primary: { role: primary, template: 'application@{version}' }
657"#;
658
659 #[test]
660 fn parses_complete_contract() {
661 let config = Config::from_yaml(VALID).expect("valid config");
662 assert_eq!(config.contract, CURRENT_CONTRACT);
663 assert_eq!(
664 config.settings.pre_1_0_bump_mapping,
665 Pre1BumpMapping::Component
666 );
667 assert_eq!(config.primary_tag("library").expect("primary").0, "primary");
668 }
669
670 #[test]
671 fn validates_managed_and_exact_excluded_discovery_receipts() {
672 let receipts = VALID.replace(
673 "release-units:\n",
674 "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",
675 );
676 let config = Config::from_yaml(&receipts).expect("discovery receipts accepted");
677 assert_eq!(config.discovery.managed_paths.len(), 1);
678 assert_eq!(config.discovery.excluded_paths.len(), 1);
679
680 let unknown = receipts.replace("release-unit: library", "release-unit: missing");
681 assert!(Config::from_yaml(&unknown)
682 .expect_err("unknown managed target rejected")
683 .to_string()
684 .contains("unknown release unit missing"));
685
686 let glob = receipts.replace("examples/package.json", "examples/*.json");
687 assert!(Config::from_yaml(&glob)
688 .expect_err("glob exclusion rejected")
689 .to_string()
690 .contains("one exact workspace-relative path"));
691
692 let non_canonical = receipts.replace("examples/package.json", "./examples/package.json");
693 assert!(Config::from_yaml(&non_canonical)
694 .expect_err("non-canonical exact path rejected")
695 .to_string()
696 .contains("one exact workspace-relative path"));
697
698 let stale_shape = receipts.replace(
699 "sha256:0000000000000000000000000000000000000000000000000000000000000000",
700 "sha256:abcd",
701 );
702 assert!(Config::from_yaml(&stale_shape)
703 .expect_err("invalid exclusion digest rejected")
704 .to_string()
705 .contains("64 lowercase hexadecimal digits"));
706 }
707
708 #[test]
709 fn published_config_schema_carries_discovery_receipt_contracts() {
710 let schema: serde_yaml::Value =
711 serde_yaml::from_str(include_str!("../../../schemas/config.yml"))
712 .expect("config schema parses");
713 let discovery = &schema["$defs"]["discovery"];
714 assert_eq!(discovery["additionalProperties"].as_bool(), Some(false));
715 assert_eq!(
716 discovery["properties"]["managed-paths"]["items"]["required"]
717 .as_sequence()
718 .expect("managed receipt required fields")
719 .len(),
720 3
721 );
722 assert_eq!(
723 discovery["properties"]["excluded-paths"]["items"]["properties"]["evidence-digest"]
724 ["pattern"]
725 .as_str(),
726 Some("^sha256:[0-9a-f]{64}$")
727 );
728 }
729
730 fn with_projection(adapter: &str, file: &str) -> String {
733 VALID.replace(
734 "release-units:\n",
735 &format!(
736 "release-units:\n beta:\n path: site\n projections:\n - {{ adapter: {adapter}, file: '{file}', pointer: /beta, mode: committed }}\n tags:\n primary: {{ role: primary, template: 'beta@{{version}}' }}\n"
737 ),
738 )
739 }
740
741 const NON_CANONICAL_ROOT_FILES: &[&str] = &[
742 "/a/../b.yml",
743 "/./b.yml",
744 "/a/./b.yml",
745 "//b.yml",
746 "/a//b.yml",
747 "/b/",
748 "/",
749 "/..",
750 "/a/b\\c.yml",
751 "\\b.yml",
752 "\\\\server\\b.yml",
753 "C:/b.yml",
754 "c:b.yml",
755 "/c:/b.yml",
756 "/a/C:/b.yml",
757 "/a/z:b.yml",
758 ];
759
760 #[test]
761 fn accepts_root_projection_for_generic_adapters() {
762 for adapter in ["json", "toml", "yaml"] {
763 let config = Config::from_yaml(&with_projection(adapter, "/docs/meta.yml"))
764 .expect("root projection accepted");
765 let file = &config.release_units["beta"].projections[0].file;
766 assert!(file.is_root());
767 for unit_path in ["site", ".", "a/b"] {
768 let resolved = file.workspace_path(Path::new(unit_path));
769 assert_eq!(resolved, Path::new("docs").join("meta.yml"));
770 assert!(resolved
771 .components()
772 .all(|component| matches!(component, Component::Normal(_))));
773 }
774 }
775 }
776
777 #[test]
778 fn rejects_non_canonical_root_projection_paths() {
779 for file in NON_CANONICAL_ROOT_FILES {
780 let error = Config::from_yaml(&with_projection("yaml", file))
781 .expect_err("non-canonical root projection rejected")
782 .to_string();
783 assert!(
784 error.contains(&format!(
785 "release unit beta projection file {file} must be a root path of the form /dir/file"
786 )),
787 "{file}: {error}"
788 );
789 }
790 }
791
792 #[test]
793 fn rejects_drive_prefixed_path_segments() {
794 for file in ["/docs/C:/meta.yml", "/c:/meta.yml", "/docs/z:meta.yml"] {
795 let error = Config::from_yaml(&with_projection("yaml", file))
796 .expect_err("drive-prefixed root segment rejected")
797 .to_string();
798 assert!(error.contains("must be a root path"), "{file}: {error}");
799 }
800 for file in [
801 "docs/C:/meta.yml",
802 "docs\\\\C:\\\\meta.yml",
803 "docs/c:meta.yml",
804 ] {
805 let error = Config::from_yaml(&with_projection("yaml", file))
806 .expect_err("drive-prefixed unit-relative segment rejected")
807 .to_string();
808 let segment = file
809 .split(['/', '\\'])
810 .find(|segment| segment.contains(':'))
811 .expect("drive segment");
812 assert!(
813 error.contains(&format!(
814 "release unit beta projection file {file} has a segment {segment} that starts with a drive prefix; on Windows the path would leave the repository"
815 )),
816 "{file}: {error}"
817 );
818 }
819 let unit_path = VALID.replace("path: packages/library", "path: packages/C:/library");
820 assert!(Config::from_yaml(&unit_path)
821 .expect_err("drive-prefixed unit path rejected")
822 .to_string()
823 .contains("release unit library path packages/C:/library has a segment C: that starts with a drive prefix"));
824 Config::from_yaml(&with_projection("yaml", "/docs/ab:c/CC:z.yml"))
825 .expect("a colon after the first character is no drive prefix");
826 }
827
828 #[test]
829 fn rejects_root_projection_for_ecosystem_adapters() {
830 for adapter in ["npm", "cargo", "pub", "python", "msbuild", "go"] {
831 let error = Config::from_yaml(&with_projection(adapter, "/site/manifest"))
832 .expect_err("ecosystem root projection rejected")
833 .to_string();
834 assert!(
835 error.contains(&format!(
836 "release unit beta {adapter} projection file must be relative to the release-unit path; only json, toml, and yaml projections may use a root path"
837 )),
838 "{adapter}: {error}"
839 );
840 }
841 }
842
843 #[test]
844 fn keeps_unit_relative_projection_rules() {
845 Config::from_yaml(&with_projection("yaml", "./meta.yml")).expect("dot prefix accepted");
846 Config::from_yaml(&with_projection("yaml", "sub/meta.yml")).expect("nested accepted");
847 for file in ["../meta.yml", "sub/../../meta.yml"] {
848 assert!(Config::from_yaml(&with_projection("yaml", file))
849 .expect_err("parent segment rejected")
850 .to_string()
851 .contains("must be a non-empty relative path without .."));
852 }
853 }
854
855 #[test]
856 fn root_projection_round_trips_through_to_yaml() {
857 let config = Config::from_yaml(&with_projection("yaml", "/docs/meta.yml"))
858 .expect("root projection accepted");
859 let yaml = config.to_yaml().expect("serialized");
860 assert!(yaml.contains("file: /docs/meta.yml"), "{yaml}");
861 assert_eq!(Config::from_yaml(&yaml).expect("reparsed"), config);
862 }
863
864 #[test]
865 fn published_projection_file_pattern_agrees_with_config_validation() {
866 let schema: serde_yaml::Value =
867 serde_yaml::from_str(include_str!("../../../schemas/config.yml"))
868 .expect("config schema parses");
869 let pattern = schema["$defs"]["projection"]["properties"]["file"]["pattern"]
870 .as_str()
871 .expect("projection file pattern");
872 let pattern = regex::Regex::new(pattern).expect("pattern compiles");
873 let accepted = [
874 "/b.yml",
875 "/docs/meta.yml",
876 "/.config/x.yml",
877 "/a/..b/c.yml",
878 "/a/b..",
879 "/a/ab:c/b.yml",
880 "/a/C/b.yml",
881 "meta.yml",
882 "./meta.yml",
883 "sub/meta.yml",
884 "sub//meta.yml",
885 "sub/./meta.yml",
886 "..b/meta.yml",
887 "ab:c/meta.yml",
888 "sub/ab:c.yml",
889 ];
890 for file in accepted {
891 assert!(pattern.is_match(file), "schema refuses {file}");
892 Config::from_yaml(&with_projection("yaml", file))
893 .unwrap_or_else(|error| panic!("config refuses {file}: {error}"));
894 }
895 let unit_relative_refused = [
896 "../meta.yml",
897 "sub/../meta.yml",
898 "sub/..",
899 "docs/C:/meta.yml",
900 "docs/c:meta.yml",
901 "C:/b.yml",
902 "c:b.yml",
903 ];
904 for file in NON_CANONICAL_ROOT_FILES
905 .iter()
906 .chain(&unit_relative_refused)
907 {
908 assert!(!pattern.is_match(file), "schema accepts {file}");
909 Config::from_yaml(&with_projection("yaml", file))
910 .expect_err(&format!("config accepts {file}"));
911 }
912 }
913
914 fn units(units: &[(&str, &str, &[&str])]) -> String {
917 let mut text = "contract: contract-1\nrelease-units:\n".to_owned();
918 for (id, path, projections) in units {
919 text.push_str(&format!(" {id}:\n path: {path}\n projections:\n"));
920 for projection in *projections {
921 text.push_str(&format!(" - {projection}\n"));
922 }
923 text.push_str(&format!(
924 " tags:\n primary: {{ role: primary, template: '{id}@{{version}}' }}\n"
925 ));
926 }
927 text
928 }
929
930 fn native(path: &str) -> String {
932 path.replace('/', std::path::MAIN_SEPARATOR_STR)
933 }
934
935 fn ownership_error(config: &str) -> String {
936 Config::from_yaml(config)
937 .expect_err("shared field rejected")
938 .to_string()
939 }
940
941 #[test]
942 fn rejects_two_release_units_projecting_one_pointer() {
943 let config = units(&[
944 (
945 "alpha",
946 "a",
947 &["{ adapter: yaml, file: /site/meta.yml, pointer: /meta/version, mode: committed }"],
948 ),
949 (
950 "beta",
951 "site",
952 &["{ adapter: yaml, file: meta.yml, pointer: /meta/version, mode: injected }"],
953 ),
954 ]);
955 assert!(ownership_error(&config).contains(&format!(
956 "release units alpha and beta both project /meta/version in {}",
957 native("site/meta.yml")
958 )));
959 }
960
961 #[test]
962 fn rejects_pointer_spelling_aliases() {
963 let dotted = units(&[
964 (
965 "alpha",
966 ".",
967 &["{ adapter: yaml, file: site/meta.yml, pointer: meta.version, mode: committed }"],
968 ),
969 (
970 "beta",
971 "b",
972 &["{ adapter: yaml, file: /site/meta.yml, pointer: /meta/version, mode: committed }"],
973 ),
974 ]);
975 assert!(ownership_error(&dotted).contains(&format!(
976 "release units alpha and beta both project /meta/version in {}",
977 native("site/meta.yml")
978 )));
979
980 let escaped = units(&[
981 (
982 "alpha",
983 "a",
984 &["{ adapter: json, file: /site/meta.json, pointer: /a~1b, mode: committed }"],
985 ),
986 (
987 "beta",
988 "b",
989 &["{ adapter: json, file: /./site/../site/meta.json, pointer: /a~1b, mode: committed }"],
990 ),
991 ]);
992 assert!(ownership_error(&escaped).contains("must be a root path"));
993 let escaped = escaped.replace("/./site/../site/meta.json", "/site/meta.json");
994 assert!(ownership_error(&escaped).contains(&format!(
995 "release units alpha and beta both project /a~1b in {}",
996 native("site/meta.json")
997 )));
998
999 let ecosystem = units(&[
1000 (
1001 "alpha",
1002 "pkg",
1003 &["{ adapter: npm, file: package.json, mode: committed }"],
1004 ),
1005 (
1006 "beta",
1007 "b",
1008 &["{ adapter: json, file: /pkg/package.json, pointer: /version, mode: committed }"],
1009 ),
1010 ]);
1011 assert!(ownership_error(&ecosystem).contains(&format!(
1012 "release units alpha and beta both project /version in {}",
1013 native("pkg/package.json")
1014 )));
1015
1016 let same_unit = units(&[(
1017 "alpha",
1018 "a",
1019 &[
1020 "{ adapter: yaml, file: meta.yml, pointer: meta.version, mode: committed }",
1021 "{ adapter: yaml, file: ./meta.yml, pointer: /meta/version, mode: committed }",
1022 ],
1023 )]);
1024 assert!(ownership_error(&same_unit)
1025 .contains("release unit alpha repeats projection ./meta.yml"));
1026 }
1027
1028 #[test]
1029 fn ecosystem_adapters_own_their_fixed_version_field() {
1030 let cases = [
1031 ("npm", "package.json", "json", "/version"),
1032 ("cargo", "Cargo.toml", "toml", "package.version"),
1033 ("pub", "pubspec.yaml", "yaml", "/version"),
1034 ("python", "pyproject.toml", "toml", "/project/version"),
1035 ];
1036 for (adapter, file, generic, pointer) in cases {
1037 let config = units(&[
1038 (
1039 "alpha",
1040 "pkg",
1041 &[&format!("{{ adapter: {adapter}, file: {file}, mode: committed }}")],
1042 ),
1043 (
1044 "beta",
1045 "b",
1046 &[&format!(
1047 "{{ adapter: {generic}, file: /pkg/{file}, pointer: {pointer}, mode: committed }}"
1048 )],
1049 ),
1050 ]);
1051 let error = ownership_error(&config);
1052 assert!(
1053 error.contains("release units alpha and beta both project"),
1054 "{adapter}: {error}"
1055 );
1056 }
1057 for (adapter, file, field) in [
1058 ("msbuild", "Sample.csproj", "<Version>"),
1059 ("go", "go.mod", "the module path"),
1060 ("npm", "package.json", "/version"),
1061 ] {
1062 let config = units(&[
1063 (
1064 "alpha",
1065 "pkg",
1066 &[&format!(
1067 "{{ adapter: {adapter}, file: {file}, mode: committed }}"
1068 )],
1069 ),
1070 (
1071 "beta",
1072 ".",
1073 &[&format!(
1074 "{{ adapter: {adapter}, file: pkg/{file}, mode: injected }}"
1075 )],
1076 ),
1077 ]);
1078 let error = ownership_error(&config);
1079 assert!(
1080 error.contains(&format!(
1081 "release units alpha and beta both project {field} in {}",
1082 native(&format!("pkg/{file}"))
1083 )),
1084 "{adapter}: {error}"
1085 );
1086 }
1087 let distinct = units(&[
1088 (
1089 "alpha",
1090 "pkg",
1091 &["{ adapter: cargo, file: Cargo.toml, mode: committed }"],
1092 ),
1093 (
1094 "beta",
1095 "b",
1096 &["{ adapter: toml, file: /pkg/Cargo.toml, pointer: /dependencies/beta/version, mode: committed }"],
1097 ),
1098 ]);
1099 Config::from_yaml(&distinct).expect("distinct field in one manifest accepted");
1100 }
1101
1102 #[test]
1103 fn go_projections_own_the_module_path_in_every_mode() {
1104 let config = units(&[
1105 (
1106 "alpha",
1107 "pkg",
1108 &["{ adapter: go, file: go.mod, mode: none }"],
1109 ),
1110 (
1111 "beta",
1112 ".",
1113 &["{ adapter: go, file: pkg/go.mod, mode: none }"],
1114 ),
1115 ]);
1116 assert!(ownership_error(&config).contains(&format!(
1117 "release units alpha and beta both project the module path in {}",
1118 native("pkg/go.mod")
1119 )));
1120 }
1121
1122 #[test]
1123 fn allows_distinct_pointers_in_one_shared_file() {
1124 let config = units(&[
1125 (
1126 "alpha",
1127 "a",
1128 &["{ adapter: yaml, file: /site/meta.yml, pointer: /alpha/version, mode: committed }"],
1129 ),
1130 (
1131 "beta",
1132 "site",
1133 &["{ adapter: yaml, file: meta.yml, pointer: /alpha/docs-version, mode: committed }"],
1134 ),
1135 ]);
1136 Config::from_yaml(&config).expect("distinct pointers accepted");
1137 }
1138
1139 #[test]
1140 fn ignores_none_mode_in_ownership() {
1141 let config = units(&[
1142 (
1143 "alpha",
1144 "a",
1145 &["{ adapter: yaml, file: /site/meta.yml, pointer: /meta/version, mode: committed }"],
1146 ),
1147 (
1148 "beta",
1149 "site",
1150 &["{ adapter: yaml, file: meta.yml, pointer: /meta/version, mode: none }"],
1151 ),
1152 ]);
1153 Config::from_yaml(&config).expect("mode none does not own a field");
1154 }
1155
1156 #[test]
1157 fn validates_fixed_and_linked_membership() {
1158 let invalid = VALID.replace(
1159 "release-units:\n",
1160 "fixed: [[library, application]]\nlinked: [[library, application]]\nrelease-units:\n",
1161 );
1162 let error = Config::from_yaml(&invalid).expect_err("overlap rejected");
1163 assert!(error.to_string().contains("belongs to both"));
1164 }
1165
1166 #[test]
1167 fn permits_tag_only_release_units() {
1168 let valid = VALID.replace(
1169 " projections:\n - { adapter: npm, file: package.json, mode: committed }\n tags:",
1170 " tags:",
1171 );
1172 Config::from_yaml(&valid).expect("tag-only release unit accepted");
1173 }
1174
1175 #[test]
1176 fn rejects_obsolete_packages_key() {
1177 let obsolete = VALID.replace("release-units:", "packages:");
1178 let error = Config::from_yaml(&obsolete).expect_err("obsolete public key rejected");
1179 assert!(error.to_string().contains("unknown field `packages`"));
1180 }
1181
1182 #[test]
1183 fn rejects_missing_or_duplicate_primary_tags() {
1184 let missing = VALID.replace(
1185 "role: primary, template: '{id}@{version}'",
1186 "role: projection, template: '{id}@{version}'",
1187 );
1188 assert!(Config::from_yaml(&missing)
1189 .expect_err("missing primary rejected")
1190 .to_string()
1191 .contains("exactly one primary"));
1192 let duplicate = VALID.replace(
1193 "primary: { role: primary, template: '{id}@{version}' }",
1194 "primary: { role: primary, template: '{id}@{version}' }\n second: { role: primary, template: 'second@{version}' }",
1195 );
1196 assert!(Config::from_yaml(&duplicate)
1197 .expect_err("duplicate primary rejected")
1198 .to_string()
1199 .contains("exactly one primary"));
1200 }
1201
1202 #[test]
1203 fn rejects_unknown_and_cyclic_tag_prerequisites() {
1204 let unknown = VALID.replace(
1205 "template: '{version}'",
1206 "template: '{version}'\n tag-after: [workspace/missing]",
1207 );
1208 assert!(Config::from_yaml(&unknown)
1209 .expect_err("unknown tag rejected")
1210 .to_string()
1211 .contains("unknown tag"));
1212 let cyclic = VALID
1213 .replace("template: '{version}'", "template: '{version}'\n tag-after: [release-unit/library/primary]")
1214 .replace(
1215 "primary: { role: primary, template: '{id}@{version}' }",
1216 "primary: { role: primary, template: '{id}@{version}', tag-after: [workspace/release] }",
1217 );
1218 assert!(Config::from_yaml(&cyclic)
1219 .expect_err("tag cycle rejected")
1220 .to_string()
1221 .contains("tag-order cycle"));
1222 }
1223
1224 #[test]
1225 fn rejects_versions_and_legacy_global_tag() {
1226 let versioned = VALID.replace(
1227 "path: packages/library",
1228 "path: packages/library\n version: 1.2.3",
1229 );
1230 assert!(Config::from_yaml(&versioned).is_err());
1231 let legacy = VALID.replace(
1232 "internal-dependency-bump: patch",
1233 "internal-dependency-bump: patch\n global-tag: true",
1234 );
1235 assert!(Config::from_yaml(&legacy).is_err());
1236 }
1237}