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