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::{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
17/// Location of the workspace configuration.
18pub const CONFIG_PATH: &str = ".intentional/config.yml";
19
20/// Published configuration schema identifier.
21pub const CONFIG_SCHEMA: &str = "https://intentional.foo/schemas/config.yml";
22
23/// Current interpretation contract written by initialization.
24pub const CURRENT_CONTRACT: &str = "contract-2";
25
26/// Previous interpretation contract. `intentional init` upgrades it; every
27/// other command refuses to load it.
28pub const CONTRACT_1: &str = "contract-1";
29
30/// Complete workspace configuration.
31#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
32#[serde(rename_all = "kebab-case", deny_unknown_fields)]
33pub struct Config {
34    /// Optional schema URL for editor and validation tooling.
35    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
36    pub schema: Option<String>,
37    /// Versioned release-semantics contract.
38    pub contract: String,
39    /// Workspace-wide release settings.
40    #[serde(default)]
41    pub settings: Settings,
42    /// Fixed release groups using Changesets semantics.
43    #[serde(default, skip_serializing_if = "Vec::is_empty")]
44    pub fixed: Vec<Vec<String>>,
45    /// Linked release groups using Changesets semantics.
46    #[serde(default, skip_serializing_if = "Vec::is_empty")]
47    pub linked: Vec<Vec<String>>,
48    /// Repository-level release tag streams.
49    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
50    pub workspace_tags: BTreeMap<String, WorkspaceTagConfig>,
51    /// Receipts for discovery candidates already resolved by initialization.
52    #[serde(default, skip_serializing_if = "DiscoveryConfig::is_empty")]
53    pub discovery: DiscoveryConfig,
54    /// Release-unit inventory keyed by stable release-unit id.
55    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/// How a `contract-1` configuration read bump names before `1.0.0`.
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub(crate) enum Contract1Mapping {
76    /// Bump names stated change significance, as `contract-2` does.
77    Compatibility,
78    /// Bump names named the version component to increment.
79    Component,
80}
81
82/// A `contract-1` configuration upgraded to the current contract.
83#[derive(Debug, Clone)]
84pub(crate) struct Contract1Upgrade {
85    /// Upgraded configuration.
86    pub config: Config,
87    /// Upgraded file text. Comments and layout are kept where possible.
88    pub text: String,
89    /// The removed `settings.pre-1-0-bump-mapping`.
90    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
97/// Upgrade `contract-1` configuration YAML. Returns `None` when the text
98/// declares another contract.
99pub(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
118/// Upgrade a parsed `contract-1` configuration value in place.
119pub(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
153/// Line edit that keeps comments: set the contract and remove the mapping
154/// line, and an empty `settings:` block. The caller verifies the result.
155fn 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    // Remove a `settings:` key that no longer has an indented child.
179    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/// Durable receipts for resolved discovery candidates.
201#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)]
202#[serde(rename_all = "kebab-case", deny_unknown_fields)]
203pub struct DiscoveryConfig {
204    /// Candidate paths incorporated into configured release units.
205    #[serde(default, skip_serializing_if = "Vec::is_empty")]
206    pub managed_paths: Vec<ManagedPathReceipt>,
207    /// Candidate paths explicitly excluded at an exact evidence digest.
208    #[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/// Receipt connecting one detector/path identity to a configured release unit.
219#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
220#[serde(rename_all = "kebab-case", deny_unknown_fields)]
221pub struct ManagedPathReceipt {
222    /// Stable detector id.
223    pub detector: String,
224    /// Exact workspace-relative candidate path.
225    pub path: PathBuf,
226    /// Configured release unit that owns the candidate projection.
227    pub release_unit: String,
228}
229
230/// Receipt excluding one detector/path identity while its evidence is unchanged.
231#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
232#[serde(rename_all = "kebab-case", deny_unknown_fields)]
233pub struct ExcludedPathReceipt {
234    /// Stable detector id.
235    pub detector: String,
236    /// Exact workspace-relative candidate path.
237    pub path: PathBuf,
238    /// SHA-256 digest of the evidence at the time of exclusion.
239    pub evidence_digest: String,
240}
241
242/// Workspace-wide release behavior.
243#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
244#[serde(rename_all = "kebab-case", deny_unknown_fields)]
245pub struct Settings {
246    /// Minimum bump propagated to internal dependents.
247    #[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/// One independently versioned release unit.
264#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
265#[serde(rename_all = "kebab-case", deny_unknown_fields)]
266pub struct ReleaseUnitConfig {
267    /// Release-unit directory relative to the workspace root.
268    pub path: PathBuf,
269    /// Whether releases may include this release unit.
270    #[serde(default, skip_serializing_if = "is_managed")]
271    pub disposition: ReleaseUnitDisposition,
272    /// Version-bearing ecosystem and format projections. Empty means tag-only.
273    #[serde(default, skip_serializing_if = "Vec::is_empty")]
274    pub projections: Vec<Projection>,
275    /// Named release-unit tag streams. Exactly one is primary.
276    pub tags: BTreeMap<String, TagConfig>,
277    /// Authored internal dependency edges.
278    #[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/// One named release-unit tag.
287#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
288#[serde(rename_all = "kebab-case", deny_unknown_fields)]
289pub struct TagConfig {
290    /// Whether this tag supplies version authority or projects it.
291    pub role: TagRole,
292    /// Tag name template containing `{version}` and optionally `{id}`.
293    pub template: String,
294    /// Optional executor phase declaration required for creation.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub require_phase: Option<TagPhase>,
297    /// Observable tag prerequisites expressed as canonical tag ids.
298    #[serde(default, skip_serializing_if = "Vec::is_empty")]
299    pub tag_after: Vec<String>,
300}
301
302/// One named workspace-level tag.
303#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
304#[serde(rename_all = "kebab-case", deny_unknown_fields)]
305pub struct WorkspaceTagConfig {
306    /// Tag name template containing `{version}`.
307    pub template: String,
308    /// Optional executor phase declaration required for creation.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub require_phase: Option<TagPhase>,
311    /// Observable tag prerequisites expressed as canonical tag ids.
312    #[serde(default, skip_serializing_if = "Vec::is_empty")]
313    pub tag_after: Vec<String>,
314}
315
316/// A version projection into a manifest or arbitrary file.
317#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
318#[serde(rename_all = "kebab-case", deny_unknown_fields)]
319pub struct Projection {
320    /// Adapter specialization.
321    pub adapter: Adapter,
322    /// File relative to the release-unit path, or to the repository root
323    /// when it starts with `/`.
324    pub file: ProjectionFile,
325    /// Projection materialization mode.
326    pub mode: ProjectionMode,
327    /// JSON Pointer or dotted TOML/YAML key path for generic formats.
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub pointer: Option<String>,
330}
331
332impl Config {
333    /// Load and validate configuration rooted at `root`.
334    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    /// Parse and validate configuration YAML.
341    ///
342    /// A `contract-1` configuration fails with
343    /// [`Error::ContractUpgradeRequired`]. Only `intentional init` upgrades it.
344    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    /// Serialize configuration deterministically.
355    pub fn to_yaml(&self) -> Result<String> {
356        self.validate()?;
357        Ok(serde_yaml::to_string(self)?)
358    }
359
360    /// Return the configured primary tag for a release unit.
361    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    /// Canonical id for a named release-unit tag.
377    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    /// Canonical id for a named workspace tag.
382    pub fn workspace_tag_id(tag_id: &str) -> String {
383        format!("workspace/{tag_id}")
384    }
385
386    /// Validate release-unit ids, projections, release groups, tags, and dependency graphs.
387    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    /// `VALID` with one more release unit `beta` at `site` whose single
858    /// projection uses `adapter` and `file`.
859    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    /// A config of release units built from `(id, path, projections)`,
1042    /// where each projection is a flow mapping.
1043    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    /// A `/`-separated workspace path as error messages print it.
1058    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}