Skip to main content

intentional_core/
config.rs

1// ---
2// relationships:
3//   implements: intent-driven-polyglot-release
4// ---
5
6//! Workspace configuration and validation.
7
8use 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
19/// Location of the workspace configuration.
20pub const CONFIG_PATH: &str = ".intentional/config.yml";
21
22/// Published configuration schema identifier.
23pub const CONFIG_SCHEMA: &str = "https://intentional.foo/schemas/config.yml";
24
25/// Current interpretation contract written by initialization.
26pub const CURRENT_CONTRACT: &str = "contract-1";
27
28/// Complete workspace configuration.
29#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
30#[serde(rename_all = "kebab-case", deny_unknown_fields)]
31pub struct Config {
32    /// Optional schema URL for editor and validation tooling.
33    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
34    pub schema: Option<String>,
35    /// Versioned release-semantics contract.
36    pub contract: String,
37    /// Workspace-wide release settings.
38    #[serde(default)]
39    pub settings: Settings,
40    /// Fixed release groups using Changesets semantics.
41    #[serde(default, skip_serializing_if = "Vec::is_empty")]
42    pub fixed: Vec<Vec<String>>,
43    /// Linked release groups using Changesets semantics.
44    #[serde(default, skip_serializing_if = "Vec::is_empty")]
45    pub linked: Vec<Vec<String>>,
46    /// Repository-level release tag streams.
47    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
48    pub workspace_tags: BTreeMap<String, WorkspaceTagConfig>,
49    /// Receipts for discovery candidates already resolved by initialization.
50    #[serde(default, skip_serializing_if = "DiscoveryConfig::is_empty")]
51    pub discovery: DiscoveryConfig,
52    /// Release-unit inventory keyed by stable release-unit id.
53    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/// Durable receipts for resolved discovery candidates.
72#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
73#[serde(rename_all = "kebab-case", deny_unknown_fields)]
74pub struct DiscoveryConfig {
75    /// Candidate paths incorporated into configured release units.
76    #[serde(default, skip_serializing_if = "Vec::is_empty")]
77    pub managed_paths: Vec<ManagedPathReceipt>,
78    /// Candidate paths explicitly excluded at an exact evidence digest.
79    #[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/// Receipt connecting one detector/path identity to a configured release unit.
90#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
91#[serde(rename_all = "kebab-case", deny_unknown_fields)]
92pub struct ManagedPathReceipt {
93    /// Stable detector id.
94    pub detector: String,
95    /// Exact workspace-relative candidate path.
96    pub path: PathBuf,
97    /// Configured release unit that owns the candidate projection.
98    pub release_unit: String,
99}
100
101/// Receipt excluding one detector/path identity while its evidence is unchanged.
102#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
103#[serde(rename_all = "kebab-case", deny_unknown_fields)]
104pub struct ExcludedPathReceipt {
105    /// Stable detector id.
106    pub detector: String,
107    /// Exact workspace-relative candidate path.
108    pub path: PathBuf,
109    /// SHA-256 digest of the evidence at the time of exclusion.
110    pub evidence_digest: String,
111}
112
113/// Workspace-wide release behavior.
114#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
115#[serde(rename_all = "kebab-case", deny_unknown_fields)]
116pub struct Settings {
117    /// Minimum bump propagated to internal dependents.
118    #[serde(default = "default_dependency_bump")]
119    pub internal_dependency_bump: Bump,
120    /// Interpretation of bump names before semantic version 1.0.0.
121    #[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/// One independently versioned release unit.
139#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
140#[serde(rename_all = "kebab-case", deny_unknown_fields)]
141pub struct ReleaseUnitConfig {
142    /// Release-unit directory relative to the workspace root.
143    pub path: PathBuf,
144    /// Whether releases may include this release unit.
145    #[serde(default, skip_serializing_if = "is_managed")]
146    pub disposition: ReleaseUnitDisposition,
147    /// Version-bearing ecosystem and format projections. Empty means tag-only.
148    #[serde(default, skip_serializing_if = "Vec::is_empty")]
149    pub projections: Vec<Projection>,
150    /// Named release-unit tag streams. Exactly one is primary.
151    pub tags: BTreeMap<String, TagConfig>,
152    /// Authored internal dependency edges.
153    #[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/// One named release-unit tag.
162#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
163#[serde(rename_all = "kebab-case", deny_unknown_fields)]
164pub struct TagConfig {
165    /// Whether this tag supplies version authority or projects it.
166    pub role: TagRole,
167    /// Tag name template containing `{version}` and optionally `{id}`.
168    pub template: String,
169    /// Optional executor phase declaration required for creation.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub require_phase: Option<TagPhase>,
172    /// Observable tag prerequisites expressed as canonical tag ids.
173    #[serde(default, skip_serializing_if = "Vec::is_empty")]
174    pub tag_after: Vec<String>,
175}
176
177/// One named workspace-level tag.
178#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
179#[serde(rename_all = "kebab-case", deny_unknown_fields)]
180pub struct WorkspaceTagConfig {
181    /// Tag name template containing `{version}`.
182    pub template: String,
183    /// Optional executor phase declaration required for creation.
184    #[serde(default, skip_serializing_if = "Option::is_none")]
185    pub require_phase: Option<TagPhase>,
186    /// Observable tag prerequisites expressed as canonical tag ids.
187    #[serde(default, skip_serializing_if = "Vec::is_empty")]
188    pub tag_after: Vec<String>,
189}
190
191/// A version projection into a manifest or arbitrary file.
192#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
193#[serde(rename_all = "kebab-case", deny_unknown_fields)]
194pub struct Projection {
195    /// Adapter specialization.
196    pub adapter: Adapter,
197    /// File relative to the release-unit path, or to the repository root
198    /// when it starts with `/`.
199    pub file: ProjectionFile,
200    /// Projection materialization mode.
201    pub mode: ProjectionMode,
202    /// JSON Pointer or dotted TOML/YAML key path for generic formats.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub pointer: Option<String>,
205}
206
207impl Config {
208    /// Load and validate configuration rooted at `root`.
209    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    /// Parse and validate configuration YAML.
216    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    /// Serialize configuration deterministically.
223    pub fn to_yaml(&self) -> Result<String> {
224        self.validate()?;
225        Ok(serde_yaml::to_string(self)?)
226    }
227
228    /// Return the configured primary tag for a release unit.
229    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    /// Canonical id for a named release-unit tag.
245    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    /// Canonical id for a named workspace tag.
250    pub fn workspace_tag_id(tag_id: &str) -> String {
251        format!("workspace/{tag_id}")
252    }
253
254    /// Validate release-unit ids, projections, release groups, tags, and dependency graphs.
255    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    /// `VALID` with one more release unit `beta` at `site` whose single
731    /// projection uses `adapter` and `file`.
732    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    /// A config of release units built from `(id, path, projections)`,
915    /// where each projection is a flow mapping.
916    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    /// A `/`-separated workspace path as error messages print it.
931    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}