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