Skip to main content

intentional_core/
init.rs

1// ---
2// relationships:
3//   implements: intent-driven-polyglot-release
4// ---
5
6//! Workspace-aware initialization and explicit Changesets takeover.
7
8use crate::config::{
9    validate_detector_id, validate_exact_discovery_path, validate_sha256, validate_tag_template,
10    Config, ExcludedPathReceipt, ManagedPathReceipt, Projection, ReleaseUnitConfig, TagConfig,
11    CONFIG_PATH, CONFIG_SCHEMA, CURRENT_CONTRACT,
12};
13use crate::error::{Error, Result};
14use crate::model::{
15    Adapter, Bump, Pre1BumpMapping, ProjectionMode, ReleaseUnitDisposition, TagRole,
16};
17use crate::plan::canonical_json;
18use crate::version::{
19    bump_version_with_mapping, effective_bumps, resolve_versions, ReleaseUnitVersion,
20};
21use glob::glob;
22use node_semver::{Range as NodeRange, Version as NodeVersion};
23use semver::Version;
24use serde::{Deserialize, Serialize};
25use serde_json::Value as JsonValue;
26use sha2::{Digest, Sha256};
27use std::collections::{BTreeMap, BTreeSet};
28use std::io::Write;
29use std::path::{Path, PathBuf};
30use std::process::{Command, Stdio};
31use walkdir::WalkDir;
32
33/// Transient initialization-plan location.
34pub const INIT_PLAN_PATH: &str = ".intentional/init-plan.yml";
35
36/// Published initialization-plan schema identifier.
37pub const INIT_PLAN_SCHEMA: &str = "https://intentional.foo/schemas/init-plan.yml";
38
39const CHANGESETS_CONFIG: &str = ".changeset/config.json";
40const TRANSACTION_PATH: &str = ".intentional/.takeover-transaction";
41const TRANSACTION_STATE_PATH: &str = ".intentional/.takeover-state";
42const DEVCONTAINER_FEATURE_MANIFEST: &str = "devcontainer-feature.json";
43const DEVCONTAINER_TEMPLATE_MANIFEST: &str = "devcontainer-template.json";
44
45/// Process outcome for initialization.
46#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq)]
47#[serde(rename_all = "kebab-case")]
48pub enum InitState {
49    /// Canonical configuration was produced, or takeover completed.
50    Success,
51    /// A deterministic plan exists but needs finite resolutions or repository edits.
52    NeedsInput,
53    /// A Changesets plan is complete and awaits explicit takeover.
54    Ready,
55}
56
57/// One source artifact used as initialization evidence.
58#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, PartialOrd, Ord)]
59#[serde(rename_all = "kebab-case", deny_unknown_fields)]
60pub struct SourceEvidence {
61    /// Workspace-relative source path.
62    pub path: PathBuf,
63    /// SHA-256 of the complete file contents.
64    pub digest: String,
65    /// Relevant one-based source lines.
66    #[serde(default, skip_serializing_if = "Vec::is_empty")]
67    pub lines: Vec<usize>,
68}
69
70/// Optional raw version text together with the evidence from which it was extracted.
71#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
72#[serde(rename_all = "kebab-case", deny_unknown_fields)]
73pub struct RawVersionEvidence {
74    /// Uninterpreted version text supplied by the artifact.
75    pub value: String,
76    /// Exact evidence supporting the extracted text.
77    pub evidence: Vec<SourceEvidence>,
78}
79
80/// A projection a detector can suggest when its required fields were extracted.
81#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
82#[serde(rename_all = "kebab-case", deny_unknown_fields)]
83pub struct CandidateProjectionSuggestion {
84    /// Adapter specialization.
85    pub adapter: Adapter,
86    /// Exact workspace-relative projection path.
87    pub path: PathBuf,
88    /// Projection materialization mode.
89    pub mode: ProjectionMode,
90    /// JSON Pointer or dotted TOML/YAML key path for generic formats.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub pointer: Option<String>,
93}
94
95/// A tag a detector can suggest without granting it version authority.
96#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
97#[serde(rename_all = "kebab-case", deny_unknown_fields)]
98pub struct CandidateTagSuggestion {
99    /// Suggested tag id within the resolved release unit.
100    pub id: String,
101    /// Suggested primary or projection role.
102    pub role: TagRole,
103    /// Suggested template containing exactly one `{version}` placeholder.
104    pub template: String,
105}
106
107/// Evidence-backed extraction problem that does not assert artifact invalidity.
108#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
109#[serde(rename_all = "kebab-case", deny_unknown_fields)]
110pub struct ExtractionDiagnostic {
111    /// Stable diagnostic identity within the candidate.
112    pub id: String,
113    /// Stable extraction category.
114    pub code: String,
115    /// Human-readable extraction problem.
116    pub message: String,
117    /// Exact evidence encountered by the detector.
118    pub evidence: Vec<SourceEvidence>,
119}
120
121/// Explicit user or agent resolution for one discovery candidate.
122#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
123#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
124pub enum CandidateResolution {
125    /// Create one independently versioned release unit from this candidate.
126    Independent {
127        /// Stable id for the new release unit.
128        release_unit: String,
129    },
130    /// Add this candidate as a projection of a release unit.
131    Projection {
132        /// Final configured or planned release-unit id.
133        release_unit: String,
134        /// Planned candidate to follow; omitted when the release unit is already configured.
135        #[serde(default, skip_serializing_if = "Option::is_none")]
136        target_candidate: Option<String>,
137    },
138    /// Keep this exact detector/path evidence outside the release-unit inventory.
139    Excluded,
140}
141
142/// Artifact-neutral evidence emitted by any release-unit detector.
143#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
144#[serde(rename_all = "kebab-case", deny_unknown_fields)]
145pub struct DiscoveryCandidate {
146    /// Stable hash of detector and exact path identity.
147    pub id: String,
148    /// Stable detector id, independent from adapter or ecosystem names.
149    pub detector: String,
150    /// Exact workspace-relative artifact path.
151    pub path: PathBuf,
152    /// All evidence used to create this candidate.
153    pub evidence: Vec<SourceEvidence>,
154    /// Manifest-native identity, when extraction succeeds.
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub native_identity: Option<String>,
157    /// Uninterpreted version evidence, when present and readable.
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub raw_version: Option<RawVersionEvidence>,
160    /// Projection suggestion, only when the detector has enough evidence.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub projection: Option<CandidateProjectionSuggestion>,
163    /// Tag suggestion, only when the detector has enough evidence.
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub tag: Option<CandidateTagSuggestion>,
166    /// Narrow extraction diagnostics; these do not validate publication or artifact completeness.
167    #[serde(default, skip_serializing_if = "Vec::is_empty")]
168    pub diagnostics: Vec<ExtractionDiagnostic>,
169    /// Editable independent, projection, or excluded choice.
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub resolution: Option<CandidateResolution>,
172}
173
174impl DiscoveryCandidate {
175    /// Derive the stable candidate id from detector and exact path identity.
176    pub fn stable_id(detector: &str, path: &Path) -> Result<String> {
177        validate_detector_id(detector)?;
178        validate_exact_discovery_path(path, "discovery candidate path")?;
179        let mut path_parts = Vec::new();
180        for component in path.components() {
181            if let std::path::Component::Normal(value) = component {
182                path_parts.push(value.to_str().ok_or_else(|| {
183                    Error::Validation("discovery candidate path must be valid UTF-8".to_owned())
184                })?);
185            }
186        }
187        let path = path_parts.join("/");
188        let mut identity = Sha256::new();
189        identity.update(detector.as_bytes());
190        identity.update([0]);
191        identity.update(path.as_bytes());
192        Ok(format!("candidate:{:x}", identity.finalize()))
193    }
194
195    fn validate(&self) -> Result<()> {
196        let expected = Self::stable_id(&self.detector, &self.path)?;
197        if self.id != expected {
198            return Err(Error::Validation(format!(
199                "discovery candidate {} does not match detector {} and path {}",
200                self.id,
201                self.detector,
202                self.path.display()
203            )));
204        }
205        if self.evidence.is_empty() {
206            return Err(Error::Validation(format!(
207                "discovery candidate {} must contain evidence",
208                self.id
209            )));
210        }
211        validate_source_evidence(&self.evidence, &format!("discovery candidate {}", self.id))?;
212        if !self.evidence.iter().any(|item| item.path == self.path) {
213            return Err(Error::Validation(format!(
214                "discovery candidate {} evidence must include its exact path {}",
215                self.id,
216                self.path.display()
217            )));
218        }
219        if self.native_identity.as_deref().is_some_and(str::is_empty) {
220            return Err(Error::Validation(format!(
221                "discovery candidate {} native identity must not be empty",
222                self.id
223            )));
224        }
225        if let Some(version) = &self.raw_version {
226            if version.value.is_empty() {
227                return Err(Error::Validation(format!(
228                    "discovery candidate {} raw version must not be empty",
229                    self.id
230                )));
231            }
232            validate_source_evidence(
233                &version.evidence,
234                &format!("discovery candidate {} raw version", self.id),
235            )?;
236            if !version
237                .evidence
238                .iter()
239                .all(|item| self.evidence.contains(item))
240            {
241                return Err(Error::Validation(format!(
242                    "discovery candidate {} raw version references evidence outside the candidate",
243                    self.id
244                )));
245            }
246        }
247        if let Some(projection) = &self.projection {
248            validate_exact_discovery_path(&projection.path, "candidate projection path")?;
249            if !self
250                .evidence
251                .iter()
252                .any(|item| item.path == projection.path)
253            {
254                return Err(Error::Validation(format!(
255                    "discovery candidate {} projection path lacks candidate evidence",
256                    self.id
257                )));
258            }
259            if projection.adapter.requires_pointer()
260                && projection.pointer.as_deref().is_none_or(str::is_empty)
261            {
262                return Err(Error::Validation(format!(
263                    "discovery candidate {} generic projection requires a pointer",
264                    self.id
265                )));
266            }
267        }
268        if let Some(tag) = &self.tag {
269            if tag.id.is_empty()
270                || !tag
271                    .id
272                    .chars()
273                    .next()
274                    .is_some_and(|character| character.is_ascii_alphanumeric())
275                || !tag
276                    .id
277                    .chars()
278                    .all(|character| character.is_ascii_alphanumeric() || "-_.".contains(character))
279            {
280                return Err(Error::Validation(format!(
281                    "discovery candidate {} has invalid tag id {:?}",
282                    self.id, tag.id
283                )));
284            }
285            validate_tag_template(
286                &format!("discovery candidate {} tag", self.id),
287                &tag.template,
288                true,
289            )?;
290        }
291        let mut diagnostic_ids = BTreeSet::new();
292        for diagnostic in &self.diagnostics {
293            if diagnostic.id.is_empty()
294                || diagnostic.code.is_empty()
295                || diagnostic.message.is_empty()
296            {
297                return Err(Error::Validation(format!(
298                    "discovery candidate {} has an incomplete extraction diagnostic",
299                    self.id
300                )));
301            }
302            if !diagnostic_ids.insert(&diagnostic.id) {
303                return Err(Error::Validation(format!(
304                    "discovery candidate {} repeats extraction diagnostic {}",
305                    self.id, diagnostic.id
306                )));
307            }
308            validate_source_evidence(
309                &diagnostic.evidence,
310                &format!(
311                    "discovery candidate {} diagnostic {}",
312                    self.id, diagnostic.id
313                ),
314            )?;
315            if !diagnostic
316                .evidence
317                .iter()
318                .all(|item| self.evidence.contains(item))
319            {
320                return Err(Error::Validation(format!(
321                    "discovery candidate {} diagnostic {} references evidence outside the candidate",
322                    self.id, diagnostic.id
323                )));
324            }
325        }
326        Ok(())
327    }
328}
329
330/// One detector invocation before its candidates are flattened into an initialization plan.
331#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
332#[serde(rename_all = "kebab-case", deny_unknown_fields)]
333pub struct DetectorResult {
334    /// Stable detector id.
335    pub detector: String,
336    /// Candidates emitted in stable path order.
337    pub candidates: Vec<DiscoveryCandidate>,
338}
339
340impl DetectorResult {
341    /// Validate detector identity and candidate ownership.
342    pub fn validate(&self) -> Result<()> {
343        validate_detector_id(&self.detector)?;
344        let mut paths = BTreeSet::new();
345        for candidate in &self.candidates {
346            if candidate.detector != self.detector {
347                return Err(Error::Validation(format!(
348                    "detector {} returned candidate owned by {}",
349                    self.detector, candidate.detector
350                )));
351            }
352            candidate.validate()?;
353            if !paths.insert(&candidate.path) {
354                return Err(Error::Validation(format!(
355                    "detector {} repeated candidate path {}",
356                    self.detector,
357                    candidate.path.display()
358                )));
359            }
360        }
361        Ok(())
362    }
363
364    /// Return candidates in deterministic path order for plan serialization.
365    pub fn into_candidates(mut self) -> Result<Vec<DiscoveryCandidate>> {
366        self.validate()?;
367        self.candidates
368            .sort_by(|left, right| left.path.cmp(&right.path));
369        Ok(self.candidates)
370    }
371}
372
373fn validate_source_evidence(evidence: &[SourceEvidence], description: &str) -> Result<()> {
374    if evidence.is_empty() {
375        return Err(Error::Validation(format!(
376            "{description} must contain evidence"
377        )));
378    }
379    let mut paths = BTreeSet::new();
380    if evidence.windows(2).any(|pair| pair[0] >= pair[1]) {
381        return Err(Error::Validation(format!(
382            "{description} evidence must be ordered by path"
383        )));
384    }
385    for item in evidence {
386        validate_exact_discovery_path(&item.path, &format!("{description} evidence path"))?;
387        validate_sha256(&item.digest, &format!("{description} evidence digest"))?;
388        if !paths.insert(&item.path) {
389            return Err(Error::Validation(format!(
390                "{description} repeats evidence path {}",
391                item.path.display()
392            )));
393        }
394        if item.lines.contains(&0) {
395            return Err(Error::Validation(format!(
396                "{description} evidence lines must be one-based"
397            )));
398        }
399        let unique_lines = item.lines.iter().collect::<BTreeSet<_>>();
400        if unique_lines.len() != item.lines.len() {
401            return Err(Error::Validation(format!(
402                "{description} repeats an evidence line for {}",
403                item.path.display()
404            )));
405        }
406        if item.lines.windows(2).any(|pair| pair[0] >= pair[1]) {
407            return Err(Error::Validation(format!(
408                "{description} evidence lines for {} must be ordered",
409                item.path.display()
410            )));
411        }
412    }
413    Ok(())
414}
415
416/// One finite initialization decision or warning.
417#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
418#[serde(rename_all = "kebab-case", deny_unknown_fields)]
419pub struct InitDiagnostic {
420    /// Stable diagnostic identity.
421    pub id: String,
422    /// Stable diagnostic category.
423    pub code: String,
424    /// Actionable human-readable explanation.
425    pub message: String,
426    /// Exact evidence supporting the diagnostic.
427    pub evidence: Vec<SourceEvidence>,
428    /// Finite resolution values. Empty means informational.
429    #[serde(default, skip_serializing_if = "Vec::is_empty")]
430    pub choices: Vec<String>,
431    /// Parity-preserving or boundary-preserving recommendation.
432    #[serde(default, skip_serializing_if = "Option::is_none")]
433    pub recommended: Option<String>,
434    /// Editable user/agent resolution.
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub resolution: Option<String>,
437    /// Whether repository state proves the selected resolution.
438    #[serde(default)]
439    pub verified: bool,
440    /// Whether a prior resolution was discarded because its evidence became stale.
441    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
442    pub invalidated_resolution: bool,
443}
444
445/// One side of a Changesets takeover parity comparison.
446#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
447#[serde(rename_all = "kebab-case", deny_unknown_fields)]
448pub struct ParityRelease {
449    /// Aggregate requested bump after dependency and group propagation.
450    pub requested_bump: Bump,
451    /// Resulting version under that authority's contract.
452    pub next_version: String,
453}
454
455/// Changesets and proposed release results used for takeover parity.
456#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
457#[serde(rename_all = "kebab-case", deny_unknown_fields)]
458pub struct ParityReleaseUnit {
459    /// Release-unit id.
460    pub release_unit: String,
461    /// Version in source projections.
462    pub current_version: String,
463    /// Independently computed Changesets result, when the source releases this release unit.
464    #[serde(default, skip_serializing_if = "Option::is_none")]
465    pub source: Option<ParityRelease>,
466    /// Proposed Intentional result, when the proposed contract releases this release unit.
467    #[serde(default, skip_serializing_if = "Option::is_none")]
468    pub proposed: Option<ParityRelease>,
469}
470
471/// Deterministic parity comparison.
472#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
473#[serde(rename_all = "kebab-case", deny_unknown_fields)]
474pub struct ParityResult {
475    /// `equivalent` when all logical releases agree; otherwise `blocked`.
476    pub status: String,
477    /// Per-release-unit results.
478    pub release_units: Vec<ParityReleaseUnit>,
479}
480
481/// One pending Changesets file converted losslessly at takeover.
482#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
483#[serde(rename_all = "kebab-case", deny_unknown_fields)]
484pub struct ConvertedIntent {
485    /// Changeset filename stem retained as the Intentional intent id.
486    pub id: String,
487    /// Release-unit bumps.
488    pub release_units: BTreeMap<String, Bump>,
489    /// Markdown body, excluding Changesets frontmatter delimiters.
490    pub message: String,
491    /// Original Changesets file.
492    pub source: PathBuf,
493    /// Intentional target file.
494    pub target: PathBuf,
495}
496
497impl ConvertedIntent {
498    fn contents(&self) -> Result<String> {
499        Ok(format!(
500            "---\n{}---\n\n{}\n",
501            serde_yaml::to_string(&self.release_units)?,
502            self.message.trim()
503        ))
504    }
505}
506
507/// Durable, editable handoff produced while Changesets still owns authority.
508#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
509#[serde(rename_all = "kebab-case", deny_unknown_fields)]
510pub struct InitPlan {
511    /// Published schema location.
512    #[serde(rename = "$schema")]
513    pub schema: String,
514    /// Computed state. Editing this field never bypasses validation.
515    pub state: InitState,
516    /// Existing authority kind.
517    pub source_kind: String,
518    /// Digest over all current source evidence.
519    pub source_fingerprint: String,
520    /// Complete confidently inferred canonical configuration.
521    pub inferred_config: Config,
522    /// Artifact-neutral candidates awaiting or carrying explicit resolutions.
523    pub discovery_candidates: Vec<DiscoveryCandidate>,
524    /// Finite choices, warnings, and verified repository integration outcomes.
525    pub diagnostics: Vec<InitDiagnostic>,
526    /// Pending source intents and their lossless targets.
527    pub converted_intents: Vec<ConvertedIntent>,
528    /// Independent source/proposed release comparison.
529    pub parity: ParityResult,
530    /// Exact operations an explicit takeover performs.
531    pub planned_operations: Vec<String>,
532    /// Required command after the takeover changes are committed.
533    pub post_commit_action: String,
534}
535
536impl InitPlan {
537    /// Validate candidate evidence and its projection resolution graph.
538    pub fn validate(&self) -> Result<()> {
539        if self.inferred_config.release_units.is_empty() {
540            if self.state != InitState::NeedsInput || self.discovery_candidates.is_empty() {
541                return Err(Error::Validation(
542                    "an empty inferred config requires unresolved discovery candidates".to_owned(),
543                ));
544            }
545            validate_empty_inferred_config(&self.inferred_config)?;
546        } else {
547            self.inferred_config.validate()?;
548        }
549        let mut candidates = BTreeMap::new();
550        for candidate in &self.discovery_candidates {
551            candidate.validate()?;
552            if candidates
553                .insert(candidate.id.as_str(), candidate)
554                .is_some()
555            {
556                return Err(Error::Validation(format!(
557                    "duplicate discovery candidate {}",
558                    candidate.id
559                )));
560            }
561        }
562
563        let mut creators = self
564            .inferred_config
565            .release_units
566            .keys()
567            .map(|id| (id.as_str(), "configured release unit"))
568            .collect::<BTreeMap<_, _>>();
569        let mut edges = BTreeMap::<&str, &str>::new();
570        for candidate in &self.discovery_candidates {
571            match &candidate.resolution {
572                Some(CandidateResolution::Independent { release_unit }) => {
573                    validate_resolution_release_unit(release_unit)?;
574                    if let Some(previous) = creators.insert(release_unit, candidate.id.as_str()) {
575                        return Err(Error::Validation(format!(
576                            "duplicate creator for release unit {release_unit}: {previous} and {}",
577                            candidate.id
578                        )));
579                    }
580                }
581                Some(CandidateResolution::Projection {
582                    release_unit,
583                    target_candidate,
584                }) => {
585                    validate_resolution_release_unit(release_unit)?;
586                    if let Some(target) = target_candidate {
587                        if !candidates.contains_key(target.as_str()) {
588                            return Err(Error::Validation(format!(
589                                "discovery candidate {} projects onto absent candidate {target}",
590                                candidate.id
591                            )));
592                        }
593                        edges.insert(candidate.id.as_str(), target.as_str());
594                    } else if !self
595                        .inferred_config
596                        .release_units
597                        .contains_key(release_unit)
598                    {
599                        return Err(Error::Validation(format!(
600                            "discovery candidate {} projects onto absent configured release unit {release_unit}",
601                            candidate.id
602                        )));
603                    }
604                }
605                Some(CandidateResolution::Excluded) | None => {}
606            }
607        }
608
609        validate_candidate_graph_acyclic(&edges)?;
610        for candidate in &self.discovery_candidates {
611            let Some(CandidateResolution::Projection {
612                release_unit,
613                target_candidate: Some(target),
614            }) = &candidate.resolution
615            else {
616                continue;
617            };
618            validate_projection_target(candidate, release_unit, target, &candidates)?;
619        }
620        Ok(())
621    }
622
623    /// Serialize deterministically with compact, explained YAML enum choices.
624    pub fn to_yaml(&self) -> Result<String> {
625        self.validate()?;
626        let yaml = serde_yaml::to_string(self)?;
627        Ok(annotate_choice_lines(&yaml))
628    }
629
630    /// Equivalent structured JSON for agent consumers.
631    pub fn to_json(&self) -> Result<String> {
632        self.validate()?;
633        canonical_json(self)
634    }
635}
636
637fn validate_empty_inferred_config(config: &Config) -> Result<()> {
638    if config
639        .schema
640        .as_deref()
641        .is_some_and(|schema| schema != CONFIG_SCHEMA)
642    {
643        return Err(Error::Validation(format!(
644            "empty inferred config schema must be {CONFIG_SCHEMA}"
645        )));
646    }
647    if config.contract != CURRENT_CONTRACT {
648        return Err(Error::Validation(format!(
649            "unsupported interpretation contract {:?}; expected {CURRENT_CONTRACT}",
650            config.contract
651        )));
652    }
653    if config.settings.internal_dependency_bump == Bump::None {
654        return Err(Error::Validation(
655            "internal-dependency-bump must be major, minor, or patch".to_owned(),
656        ));
657    }
658    if !config.fixed.is_empty()
659        || !config.linked.is_empty()
660        || !config.workspace_tags.is_empty()
661        || !config.discovery.managed_paths.is_empty()
662        || !config.discovery.excluded_paths.is_empty()
663    {
664        return Err(Error::Validation(
665            "empty inferred config may only contain schema, contract, settings, and release-units"
666                .to_owned(),
667        ));
668    }
669    Ok(())
670}
671
672fn validate_resolution_release_unit(id: &str) -> Result<()> {
673    if id.is_empty()
674        || !id
675            .chars()
676            .all(|character| character.is_ascii_alphanumeric() || "-_.@/".contains(character))
677    {
678        return Err(Error::Validation(format!(
679            "invalid candidate resolution release-unit id {id:?}"
680        )));
681    }
682    Ok(())
683}
684
685fn validate_candidate_graph_acyclic(edges: &BTreeMap<&str, &str>) -> Result<()> {
686    fn visit<'a>(
687        id: &'a str,
688        edges: &BTreeMap<&'a str, &'a str>,
689        visiting: &mut BTreeSet<&'a str>,
690        visited: &mut BTreeSet<&'a str>,
691    ) -> Result<()> {
692        if visited.contains(id) {
693            return Ok(());
694        }
695        if !visiting.insert(id) {
696            return Err(Error::Validation(format!(
697                "discovery candidate projection cycle includes {id}"
698            )));
699        }
700        if let Some(target) = edges.get(id) {
701            visit(target, edges, visiting, visited)?;
702        }
703        visiting.remove(id);
704        visited.insert(id);
705        Ok(())
706    }
707
708    let mut visiting = BTreeSet::new();
709    let mut visited = BTreeSet::new();
710    for id in edges.keys() {
711        visit(id, edges, &mut visiting, &mut visited)?;
712    }
713    Ok(())
714}
715
716fn validate_projection_target(
717    source: &DiscoveryCandidate,
718    release_unit: &str,
719    target: &str,
720    candidates: &BTreeMap<&str, &DiscoveryCandidate>,
721) -> Result<()> {
722    let target = candidates[target];
723    match &target.resolution {
724        Some(CandidateResolution::Independent {
725            release_unit: target_release_unit,
726        }) => {
727            if target_release_unit != release_unit {
728                return Err(Error::Validation(format!(
729                    "discovery candidate {} resolves to {release_unit} but target {} creates {target_release_unit}",
730                    source.id, target.id
731                )));
732            }
733            Ok(())
734        }
735        Some(CandidateResolution::Projection {
736            release_unit: _,
737            target_candidate: _,
738        }) => Err(Error::Validation(format!(
739            "discovery candidate {} projects onto target {} that is not an independent creator",
740            source.id, target.id
741        ))),
742        Some(CandidateResolution::Excluded) | None => Err(Error::Validation(format!(
743            "discovery candidate {} projects onto target {} without an independent or configured resolution",
744            source.id, target.id
745        ))),
746    }
747}
748
749/// Planned initialization or takeover output.
750#[derive(Debug, Clone)]
751pub struct InitResult {
752    /// Computed process outcome.
753    pub state: InitState,
754    /// Primary output path.
755    pub path: PathBuf,
756    /// Human-readable/schema-backed file contents.
757    pub contents: String,
758    /// Exact operations.
759    pub operations: Vec<String>,
760    /// Structured plan when adopting Changesets.
761    pub plan: Option<InitPlan>,
762    writes: Vec<(PathBuf, String)>,
763    deletes: Vec<PathBuf>,
764    takeover: bool,
765    takeover_evidence: Option<(String, BTreeSet<PathBuf>)>,
766}
767
768impl InitResult {
769    /// Materialize initialization unless this is a dry run.
770    pub fn apply(&self, root: &Path, dry_run: bool) -> Result<()> {
771        if dry_run {
772            return Ok(());
773        }
774        if self.takeover {
775            let (expected_fingerprint, evidence_paths) = self
776                .takeover_evidence
777                .as_ref()
778                .expect("takeover carries evidence");
779            verify_takeover_preconditions(
780                root,
781                &self.writes,
782                expected_fingerprint,
783                evidence_paths,
784            )?;
785            return apply_takeover_transaction(root, &self.writes, &self.deletes);
786        }
787        for (relative, contents) in &self.writes {
788            let path = root.join(relative);
789            if let Some(parent) = path.parent() {
790                std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
791            }
792            std::fs::write(&path, contents).map_err(|error| Error::io(&path, error))?;
793        }
794        for relative in &self.deletes {
795            let path = root.join(relative);
796            if path.is_file() {
797                std::fs::remove_file(&path).map_err(|error| Error::io(&path, error))?;
798            }
799        }
800        if self.state == InitState::Success {
801            std::fs::create_dir_all(root.join(crate::intent::INTENTS_PATH))
802                .map_err(|error| Error::io(root.join(crate::intent::INTENTS_PATH), error))?;
803        }
804        Ok(())
805    }
806
807    /// Structured output matching the written configuration or initialization plan.
808    pub fn to_json(&self) -> Result<String> {
809        match &self.plan {
810            Some(plan) => plan.to_json(),
811            None => {
812                let config = Config::from_yaml(&self.contents)?;
813                canonical_json(&config)
814            }
815        }
816    }
817}
818
819/// Discover or reconcile initialization, optionally executing explicit takeover.
820pub fn initialize(root: &Path, scan_all: bool, take_over: bool) -> Result<InitResult> {
821    let root = root
822        .canonicalize()
823        .map_err(|error| Error::io(root, error))?;
824    let root = root.as_path();
825    gix::discover(root)
826        .map_err(|_| Error::Validation("intentional init requires a Git repository".to_owned()))?;
827    recover_interrupted_takeover(root)?;
828    if root.join(CHANGESETS_CONFIG).exists() {
829        return changesets_plan(root, scan_all, take_over);
830    }
831    if take_over {
832        return Err(Error::Validation(
833            "--take-over requires an existing .changeset/config.json".to_owned(),
834        ));
835    }
836    ordinary_plan(root, scan_all)
837}
838
839/// Compatibility wrapper for callers performing ordinary initialization.
840pub fn discover_config(root: &Path) -> Result<InitResult> {
841    initialize(root, false, false)
842}
843
844#[derive(Default)]
845struct Discovery {
846    config: Config,
847    versions: BTreeMap<String, Version>,
848    evidence: BTreeSet<PathBuf>,
849    workspace_packages: BTreeSet<String>,
850    private_packages: BTreeSet<String>,
851    npm_dependencies: Vec<NpmDependencyEdge>,
852    candidates: Vec<DiscoveryCandidate>,
853}
854
855#[derive(Debug, Clone, Copy, PartialEq, Eq)]
856enum NpmDependencyKind {
857    Dependency,
858    Optional,
859    Peer,
860    Dev,
861}
862
863#[derive(Debug, Clone, PartialEq, Eq)]
864struct NpmDependencyEdge {
865    dependent: String,
866    dependency: String,
867    kind: NpmDependencyKind,
868    range: String,
869}
870
871struct ChangesetsSourceSemantics<'a> {
872    private_packages: &'a BTreeSet<String>,
873    suppress_private_versions: bool,
874    npm_dependencies: &'a [NpmDependencyEdge],
875    internal_dependency_bump: Bump,
876    update_internal_dependents_always: bool,
877    only_update_peer_dependents_when_out_of_range: bool,
878    preflight_error: Option<String>,
879}
880
881fn ordinary_plan(root: &Path, scan_all: bool) -> Result<InitResult> {
882    let previous = load_previous_plan(root)?;
883    let had_previous_plan = previous.is_some();
884    let configured = if root.join(CONFIG_PATH).is_file() {
885        Config::load(root)?
886    } else {
887        Config::default()
888    };
889    let mut discovery = discover(root, scan_all, &BTreeSet::new())?;
890    reconcile_candidate_resolutions(&mut discovery.candidates, previous.as_ref());
891    let mut candidates = unresolved_by_receipts(&configured, discovery.candidates);
892
893    if candidates.is_empty() {
894        if configured.release_units.is_empty() {
895            return Err(Error::Validation(
896                "no supported workspace manifests found".to_owned(),
897            ));
898        }
899        let contents = configured.to_yaml()?;
900        let (operations, deletes) = if had_previous_plan {
901            (
902                vec![format!("delete {INIT_PLAN_PATH}")],
903                vec![PathBuf::from(INIT_PLAN_PATH)],
904            )
905        } else {
906            (Vec::new(), Vec::new())
907        };
908        return Ok(InitResult {
909            state: InitState::Success,
910            path: PathBuf::from(CONFIG_PATH),
911            operations,
912            contents,
913            plan: None,
914            writes: Vec::new(),
915            deletes,
916            takeover: false,
917            takeover_evidence: None,
918        });
919    }
920
921    candidates.sort_by(|left, right| left.id.cmp(&right.id));
922    let all_resolved = candidates
923        .iter()
924        .all(|candidate| candidate.resolution.is_some());
925    if all_resolved {
926        let applied = apply_candidate_resolutions(root, configured, &candidates)?;
927        let contents = applied.to_yaml()?;
928        return Ok(InitResult {
929            state: InitState::Success,
930            path: PathBuf::from(CONFIG_PATH),
931            operations: vec![
932                format!("write {CONFIG_PATH}"),
933                format!("create {}", crate::intent::INTENTS_PATH),
934                format!("delete {INIT_PLAN_PATH}"),
935            ],
936            contents: contents.clone(),
937            plan: None,
938            writes: vec![(PathBuf::from(CONFIG_PATH), contents)],
939            deletes: vec![PathBuf::from(INIT_PLAN_PATH)],
940            takeover: false,
941            takeover_evidence: None,
942        });
943    }
944
945    let evidence_paths = candidates
946        .iter()
947        .flat_map(|candidate| candidate.evidence.iter().map(|item| item.path.clone()))
948        .collect::<BTreeSet<_>>();
949    let plan = InitPlan {
950        schema: INIT_PLAN_SCHEMA.to_owned(),
951        state: InitState::NeedsInput,
952        source_kind: if root.join(CONFIG_PATH).is_file() {
953            "intentional".to_owned()
954        } else {
955            "workspace".to_owned()
956        },
957        source_fingerprint: fingerprint(root, &evidence_paths)?,
958        inferred_config: configured,
959        discovery_candidates: candidates,
960        diagnostics: Vec::new(),
961        converted_intents: Vec::new(),
962        parity: ParityResult {
963            status: "equivalent".to_owned(),
964            release_units: Vec::new(),
965        },
966        planned_operations: vec![
967            format!("write {CONFIG_PATH}"),
968            format!("create {}", crate::intent::INTENTS_PATH),
969            format!("delete {INIT_PLAN_PATH}"),
970        ],
971        post_commit_action: String::new(),
972    };
973    let contents = plan.to_yaml()?;
974    Ok(InitResult {
975        state: InitState::NeedsInput,
976        path: PathBuf::from(INIT_PLAN_PATH),
977        operations: vec![format!("write {INIT_PLAN_PATH}")],
978        contents: contents.clone(),
979        plan: Some(plan),
980        writes: vec![(PathBuf::from(INIT_PLAN_PATH), contents)],
981        deletes: Vec::new(),
982        takeover: false,
983        takeover_evidence: None,
984    })
985}
986
987fn reconcile_candidate_resolutions(
988    candidates: &mut [DiscoveryCandidate],
989    previous: Option<&InitPlan>,
990) {
991    let Some(previous) = previous else {
992        return;
993    };
994    let old = previous
995        .discovery_candidates
996        .iter()
997        .map(|candidate| (candidate.id.as_str(), candidate))
998        .collect::<BTreeMap<_, _>>();
999    for candidate in candidates {
1000        if let Some(previous) = old.get(candidate.id.as_str()) {
1001            if previous.evidence == candidate.evidence
1002                && previous.native_identity == candidate.native_identity
1003                && previous.projection == candidate.projection
1004                && previous.tag == candidate.tag
1005            {
1006                candidate.resolution = previous.resolution.clone();
1007            }
1008        }
1009    }
1010}
1011
1012fn unresolved_by_receipts(
1013    config: &Config,
1014    candidates: Vec<DiscoveryCandidate>,
1015) -> Vec<DiscoveryCandidate> {
1016    let managed = config
1017        .discovery
1018        .managed_paths
1019        .iter()
1020        .map(|receipt| (&receipt.detector, &receipt.path))
1021        .collect::<BTreeSet<_>>();
1022    let excluded = config
1023        .discovery
1024        .excluded_paths
1025        .iter()
1026        .map(|receipt| ((&receipt.detector, &receipt.path), &receipt.evidence_digest))
1027        .collect::<BTreeMap<_, _>>();
1028    candidates
1029        .into_iter()
1030        .filter_map(|candidate| {
1031            if managed.contains(&(&candidate.detector, &candidate.path)) {
1032                return None;
1033            }
1034            if let Some(expected) = excluded.get(&(&candidate.detector, &candidate.path)) {
1035                let actual = candidate
1036                    .evidence
1037                    .iter()
1038                    .find(|item| item.path == candidate.path)
1039                    .map(|item| &item.digest);
1040                if actual == Some(*expected) {
1041                    return None;
1042                }
1043            }
1044            Some(candidate)
1045        })
1046        .collect()
1047}
1048
1049fn apply_candidate_resolutions(
1050    root: &Path,
1051    mut config: Config,
1052    candidates: &[DiscoveryCandidate],
1053) -> Result<Config> {
1054    let validation_plan = InitPlan {
1055        schema: INIT_PLAN_SCHEMA.to_owned(),
1056        state: InitState::NeedsInput,
1057        source_kind: "workspace".to_owned(),
1058        source_fingerprint: format!("sha256:{}", "0".repeat(64)),
1059        inferred_config: config.clone(),
1060        discovery_candidates: candidates.to_vec(),
1061        diagnostics: Vec::new(),
1062        converted_intents: Vec::new(),
1063        parity: ParityResult {
1064            status: "equivalent".to_owned(),
1065            release_units: Vec::new(),
1066        },
1067        planned_operations: Vec::new(),
1068        post_commit_action: String::new(),
1069    };
1070    validation_plan.validate()?;
1071
1072    let resolved_paths = candidates
1073        .iter()
1074        .map(|candidate| (candidate.detector.clone(), candidate.path.clone()))
1075        .collect::<BTreeSet<_>>();
1076    config.discovery.managed_paths.retain(|receipt| {
1077        !resolved_paths.contains(&(receipt.detector.clone(), receipt.path.clone()))
1078    });
1079    config.discovery.excluded_paths.retain(|receipt| {
1080        !resolved_paths.contains(&(receipt.detector.clone(), receipt.path.clone()))
1081    });
1082
1083    for candidate in candidates {
1084        let CandidateResolution::Independent { release_unit } = candidate
1085            .resolution
1086            .as_ref()
1087            .expect("all candidate resolutions checked")
1088        else {
1089            continue;
1090        };
1091        let projection = candidate
1092            .projection
1093            .as_ref()
1094            .map(|_| {
1095                candidate_projection(candidate, candidate.path.parent().unwrap_or(Path::new("")))
1096            })
1097            .transpose()?;
1098        let path = candidate
1099            .path
1100            .parent()
1101            .filter(|path| !path.as_os_str().is_empty())
1102            .map(Path::to_path_buf)
1103            .unwrap_or_else(|| PathBuf::from("."));
1104        let tag = candidate.tag.as_ref().ok_or_else(|| Error::Validation(format!(
1105            "discovery candidate {} cannot create an independent release unit without a tag suggestion",
1106            candidate.id
1107        )))?;
1108        config.release_units.insert(
1109            release_unit.clone(),
1110            ReleaseUnitConfig {
1111                path,
1112                disposition: ReleaseUnitDisposition::Managed,
1113                projections: projection.into_iter().collect(),
1114                tags: BTreeMap::from([(
1115                    tag.id.clone(),
1116                    TagConfig {
1117                        role: tag.role,
1118                        template: tag.template.clone(),
1119                        require_phase: None,
1120                        tag_after: Vec::new(),
1121                    },
1122                )]),
1123                depends_on: Vec::new(),
1124            },
1125        );
1126    }
1127    for candidate in candidates {
1128        match candidate
1129            .resolution
1130            .as_ref()
1131            .expect("all candidate resolutions checked")
1132        {
1133            CandidateResolution::Independent { release_unit } => {
1134                config.discovery.managed_paths.push(ManagedPathReceipt {
1135                    detector: candidate.detector.clone(),
1136                    path: candidate.path.clone(),
1137                    release_unit: release_unit.clone(),
1138                });
1139            }
1140            CandidateResolution::Projection { release_unit, .. } => {
1141                let unit = config.release_units.get_mut(release_unit).ok_or_else(|| {
1142                    Error::Validation(format!(
1143                        "discovery candidate {} projects onto absent release unit {release_unit}",
1144                        candidate.id
1145                    ))
1146                })?;
1147                if candidate.projection.is_some() {
1148                    let projection = candidate_projection(candidate, &unit.path)?;
1149                    if !unit.projections.iter().any(|existing| {
1150                        existing.file == projection.file && existing.pointer == projection.pointer
1151                    }) {
1152                        unit.projections.push(projection);
1153                    }
1154                }
1155                config.discovery.managed_paths.push(ManagedPathReceipt {
1156                    detector: candidate.detector.clone(),
1157                    path: candidate.path.clone(),
1158                    release_unit: release_unit.clone(),
1159                });
1160            }
1161            CandidateResolution::Excluded => {
1162                let digest = candidate
1163                    .evidence
1164                    .iter()
1165                    .find(|item| item.path == candidate.path)
1166                    .expect("candidate path evidence validated")
1167                    .digest
1168                    .clone();
1169                config.discovery.excluded_paths.push(ExcludedPathReceipt {
1170                    detector: candidate.detector.clone(),
1171                    path: candidate.path.clone(),
1172                    evidence_digest: digest,
1173                });
1174            }
1175        }
1176    }
1177    config
1178        .discovery
1179        .managed_paths
1180        .sort_by(|a, b| (&a.detector, &a.path).cmp(&(&b.detector, &b.path)));
1181    config
1182        .discovery
1183        .excluded_paths
1184        .sort_by(|a, b| (&a.detector, &a.path).cmp(&(&b.detector, &b.path)));
1185    let npm_release_units = config
1186        .release_units
1187        .iter()
1188        .filter(|(_, unit)| {
1189            unit.projections
1190                .iter()
1191                .any(|projection| projection.adapter == Adapter::Npm)
1192        })
1193        .map(|(id, _)| id.clone())
1194        .collect::<BTreeSet<_>>();
1195    let configured_non_npm_dependencies = config
1196        .release_units
1197        .iter()
1198        .filter(|(_, unit)| {
1199            unit.projections
1200                .iter()
1201                .any(|projection| projection.adapter == Adapter::Npm)
1202        })
1203        .map(|(id, unit)| {
1204            // npm-to-npm edges are manifest-owned. Only edges that an npm
1205            // manifest cannot natively describe remain authored config.
1206            let dependencies = unit
1207                .depends_on
1208                .iter()
1209                .filter(|dependency| !npm_release_units.contains(*dependency))
1210                .cloned()
1211                .collect::<Vec<_>>();
1212            (id.clone(), dependencies)
1213        })
1214        .collect::<BTreeMap<_, _>>();
1215    derive_npm_dependencies(root, &mut config)?;
1216    for (id, dependencies) in configured_non_npm_dependencies {
1217        if let Some(unit) = config.release_units.get_mut(&id) {
1218            unit.depends_on.extend(dependencies);
1219            unit.depends_on.sort();
1220            unit.depends_on.dedup();
1221        }
1222    }
1223    config.validate()?;
1224    Ok(config)
1225}
1226
1227fn candidate_projection(candidate: &DiscoveryCandidate, unit_path: &Path) -> Result<Projection> {
1228    let suggestion = candidate.projection.as_ref().ok_or_else(|| {
1229        Error::Validation(format!(
1230            "discovery candidate {} has no projection suggestion",
1231            candidate.id
1232        ))
1233    })?;
1234    let file = if unit_path == Path::new(".") {
1235        suggestion.path.clone()
1236    } else {
1237        suggestion
1238            .path
1239            .strip_prefix(unit_path)
1240            .map_err(|_| {
1241                Error::Validation(format!(
1242                    "candidate projection {} is outside release-unit path {}",
1243                    suggestion.path.display(),
1244                    unit_path.display()
1245                ))
1246            })?
1247            .to_owned()
1248    };
1249    Ok(Projection {
1250        adapter: suggestion.adapter,
1251        file,
1252        mode: suggestion.mode,
1253        pointer: suggestion.pointer.clone(),
1254    })
1255}
1256
1257fn apply_changesets_candidate_resolutions(
1258    root: &Path,
1259    discovery: &mut Discovery,
1260    candidates: &[DiscoveryCandidate],
1261) -> Result<BTreeMap<String, String>> {
1262    let mut removed_release_units = BTreeSet::new();
1263    let mut identity_map = BTreeMap::new();
1264    for candidate in candidates {
1265        match &candidate.resolution {
1266            None => {}
1267            Some(CandidateResolution::Projection { release_unit, .. }) => {
1268                let projection_owner =
1269                    candidate_projection_owner(&discovery.config, candidate).map(str::to_owned);
1270                let Some(target) = discovery.config.release_units.get(release_unit) else {
1271                    return Err(Error::Validation(format!(
1272                        "Changesets discovery candidate {} projects onto absent release unit {release_unit}",
1273                        candidate.id
1274                    )));
1275                };
1276                let target_path = target.path.clone();
1277                if candidate.projection.is_some() {
1278                    if let Some(owner) = projection_owner.as_deref() {
1279                        let native_owner = candidate.native_identity.as_deref() == Some(owner);
1280                        if owner != release_unit && !native_owner {
1281                            return Err(Error::Validation(format!(
1282                                "Changesets discovery candidate {} projection is already owned by release unit {owner}, not {release_unit}",
1283                                candidate.id
1284                            )));
1285                        }
1286                    }
1287                    let projection = candidate_projection(candidate, &target_path)?;
1288                    let target = discovery
1289                        .config
1290                        .release_units
1291                        .get_mut(release_unit)
1292                        .expect("validated target release unit");
1293                    if let Some(existing) = target.projections.iter().find(|existing| {
1294                        existing.file == projection.file && existing.pointer == projection.pointer
1295                    }) {
1296                        if existing.adapter != projection.adapter
1297                            || existing.mode != projection.mode
1298                        {
1299                            return Err(Error::Validation(format!(
1300                                "Changesets discovery candidate {} conflicts with existing projection {} on release unit {release_unit}: adapter or mode differs",
1301                                candidate.id,
1302                                projection.file.display()
1303                            )));
1304                        }
1305                    } else {
1306                        target.projections.push(projection);
1307                    }
1308                }
1309                if let Some(native_identity) = &candidate.native_identity {
1310                    if native_identity != release_unit {
1311                        if let Some(existing) =
1312                            identity_map.insert(native_identity.clone(), release_unit.clone())
1313                        {
1314                            if existing != *release_unit {
1315                                return Err(Error::Validation(format!(
1316                                    "Changesets discovery candidates map native identity {native_identity} to both {existing} and {release_unit}"
1317                                )));
1318                            }
1319                        }
1320                        if let Some(source) =
1321                            discovery.config.release_units.get_mut(native_identity)
1322                        {
1323                            source.projections.retain(|projection| {
1324                                root.join(&source.path).join(&projection.file)
1325                                    != root.join(&candidate.path)
1326                            });
1327                            if source.projections.is_empty() {
1328                                removed_release_units.insert(native_identity.clone());
1329                            }
1330                        }
1331                    }
1332                }
1333                discovery
1334                    .config
1335                    .discovery
1336                    .managed_paths
1337                    .push(ManagedPathReceipt {
1338                        detector: candidate.detector.clone(),
1339                        path: candidate.path.clone(),
1340                        release_unit: release_unit.clone(),
1341                    });
1342            }
1343            Some(CandidateResolution::Excluded) => {
1344                if let Some(native_identity) = &candidate.native_identity {
1345                    if let Some(unit) = discovery.config.release_units.get_mut(native_identity) {
1346                        unit.projections.retain(|projection| {
1347                            unit.path.join(&projection.file) != candidate.path
1348                        });
1349                        if unit.projections.is_empty() {
1350                            removed_release_units.insert(native_identity.clone());
1351                        }
1352                    }
1353                }
1354                let digest = candidate
1355                    .evidence
1356                    .iter()
1357                    .find(|item| item.path == candidate.path)
1358                    .expect("candidate path evidence validated")
1359                    .digest
1360                    .clone();
1361                discovery
1362                    .config
1363                    .discovery
1364                    .excluded_paths
1365                    .push(ExcludedPathReceipt {
1366                        detector: candidate.detector.clone(),
1367                        path: candidate.path.clone(),
1368                        evidence_digest: digest,
1369                    });
1370            }
1371            Some(CandidateResolution::Independent { release_unit }) => {
1372                return Err(Error::Validation(format!(
1373                    "Changesets already establishes release unit {release_unit}; candidate {} must project onto it or be excluded",
1374                    candidate.id
1375                )));
1376            }
1377        }
1378    }
1379    for release_unit in &removed_release_units {
1380        discovery.config.release_units.remove(release_unit);
1381    }
1382    for (source, target) in &identity_map {
1383        if discovery.config.release_units.contains_key(source) {
1384            return Err(Error::Validation(format!(
1385                "Changesets discovery candidates leave native identity {source} split between release units {source} and {target}; resolve every projection for that identity consistently"
1386            )));
1387        }
1388        if discovery.workspace_packages.remove(source) {
1389            discovery.workspace_packages.insert(target.clone());
1390        }
1391        if discovery.private_packages.remove(source) {
1392            discovery.private_packages.insert(target.clone());
1393        }
1394        discovery.versions.remove(source);
1395    }
1396    for unit in discovery.config.release_units.values_mut() {
1397        for dependency in &mut unit.depends_on {
1398            if let Some(target) = identity_map.get(dependency) {
1399                *dependency = target.clone();
1400            }
1401        }
1402        unit.depends_on
1403            .retain(|dependency| !removed_release_units.contains(dependency));
1404        unit.depends_on.sort();
1405        unit.depends_on.dedup();
1406    }
1407    for edge in &mut discovery.npm_dependencies {
1408        if let Some(target) = identity_map.get(&edge.dependent) {
1409            edge.dependent = target.clone();
1410        }
1411        if let Some(target) = identity_map.get(&edge.dependency) {
1412            edge.dependency = target.clone();
1413        }
1414    }
1415    discovery.npm_dependencies.retain(|edge| {
1416        edge.dependent != edge.dependency
1417            && discovery.config.release_units.contains_key(&edge.dependent)
1418            && discovery
1419                .config
1420                .release_units
1421                .contains_key(&edge.dependency)
1422    });
1423    discovery
1424        .config
1425        .discovery
1426        .managed_paths
1427        .sort_by(|left, right| (&left.detector, &left.path).cmp(&(&right.detector, &right.path)));
1428    discovery
1429        .config
1430        .discovery
1431        .excluded_paths
1432        .sort_by(|left, right| (&left.detector, &left.path).cmp(&(&right.detector, &right.path)));
1433    Ok(identity_map)
1434}
1435
1436fn candidate_projection_owner<'a>(
1437    config: &'a Config,
1438    candidate: &DiscoveryCandidate,
1439) -> Option<&'a str> {
1440    let Some(suggestion) = &candidate.projection else {
1441        return None;
1442    };
1443    config.release_units.iter().find_map(|(id, unit)| {
1444        unit.projections
1445            .iter()
1446            .any(|projection| {
1447                projection_workspace_path(unit, projection) == suggestion.path
1448                    && projection.pointer == suggestion.pointer
1449            })
1450            .then_some(id.as_str())
1451    })
1452}
1453
1454fn projection_workspace_path(unit: &ReleaseUnitConfig, projection: &Projection) -> PathBuf {
1455    if unit.path == Path::new(".") {
1456        projection.file.clone()
1457    } else {
1458        unit.path.join(&projection.file)
1459    }
1460}
1461
1462fn changesets_plan(root: &Path, scan_all: bool, take_over: bool) -> Result<InitResult> {
1463    let previous = load_previous_plan(root)?;
1464    let changesets_config_path = root.join(CHANGESETS_CONFIG);
1465    let changesets_text = std::fs::read_to_string(&changesets_config_path)
1466        .map_err(|error| Error::io(&changesets_config_path, error))?;
1467    let changesets: JsonValue = serde_json::from_str(&changesets_text)
1468        .map_err(|error| Error::Validation(format!("invalid Changesets config: {error}")))?;
1469    let mut converted_intents = load_changesets_intents(root)?;
1470    let mut referenced_names = converted_intents
1471        .iter()
1472        .flat_map(|intent| intent.release_units.keys().cloned())
1473        .collect::<BTreeSet<_>>();
1474    for key in ["ignore", "fixed", "linked"] {
1475        collect_json_strings(&changesets[key], &mut referenced_names);
1476    }
1477    let mut discovery = discover(root, scan_all, &referenced_names)?;
1478    reconcile_candidate_resolutions(&mut discovery.candidates, previous.as_ref());
1479    let candidates = discovery.candidates.clone();
1480    let identity_map = apply_changesets_candidate_resolutions(root, &mut discovery, &candidates)?;
1481    discovery.config.settings.pre_1_0_bump_mapping = Pre1BumpMapping::Component;
1482    discovery.config.settings.internal_dependency_bump = changesets["updateInternalDependencies"]
1483        .as_str()
1484        .and_then(|value| value.parse().ok())
1485        .unwrap_or(Bump::Patch);
1486    discovery.config.fixed = parse_groups(&changesets["fixed"])?;
1487    discovery.config.linked = parse_groups(&changesets["linked"])?;
1488    remap_groups(&mut discovery.config.fixed, &identity_map);
1489    remap_groups(&mut discovery.config.linked, &identity_map);
1490    remap_converted_intents(&mut converted_intents, &identity_map, &discovery.config)?;
1491
1492    let mut diagnostics = Vec::new();
1493    let config_evidence = evidence(root, Path::new(CHANGESETS_CONFIG), Vec::new())?;
1494    for ignored in changesets["ignore"].as_array().into_iter().flatten() {
1495        let Some(source_package) = ignored.as_str() else {
1496            continue;
1497        };
1498        diagnostics.push(InitDiagnostic {
1499            id: format!("ignored-release-unit-disposition:{source_package}"),
1500            code: "ignored-release-unit-disposition".to_owned(),
1501            message: format!(
1502                "Choose whether Changesets-ignored package {source_package} is suspended, excluded, or managed. Selecting managed requires removing it from Changesets ignore before takeover."
1503            ),
1504            evidence: vec![config_evidence.clone()],
1505            choices: vec!["suspended".to_owned(), "excluded".to_owned(), "managed".to_owned()],
1506            recommended: Some("suspended".to_owned()),
1507            resolution: None,
1508            verified: false,
1509            invalidated_resolution: false,
1510        });
1511    }
1512    if let Some((versions_private, tags_private)) =
1513        changesets_private_package_settings(&changesets["privatePackages"])
1514    {
1515        diagnostics.push(InitDiagnostic {
1516            id: "private-package-versioning".to_owned(),
1517            code: "private-package-versioning".to_owned(),
1518            message: if versions_private {
1519                "Changesets versions private packages; Intentional preserves that behavior because package privacy is independent from version management.".to_owned()
1520            } else {
1521                "Changesets suppresses private-package versions; Intentional manages versions independently from publication privacy, so accepting Intentional semantics is a deliberate contract change.".to_owned()
1522            },
1523            evidence: vec![config_evidence.clone()],
1524            choices: if versions_private {
1525                Vec::new()
1526            } else {
1527                vec!["intentional".to_owned()]
1528            },
1529            recommended: (!versions_private).then(|| "intentional".to_owned()),
1530            resolution: None,
1531            verified: versions_private,
1532            invalidated_resolution: false,
1533        });
1534        if !tags_private {
1535            diagnostics.push(InitDiagnostic {
1536                id: "private-package-tagging".to_owned(),
1537                code: "private-package-tagging".to_owned(),
1538                message: "Changesets suppresses private-package tags; Intentional creates annotated records for every managed release unit, independently from publication privacy.".to_owned(),
1539                evidence: vec![config_evidence.clone()],
1540                choices: vec!["intentional".to_owned()],
1541                recommended: Some("intentional".to_owned()),
1542                resolution: None,
1543                verified: false,
1544                invalidated_resolution: false,
1545            });
1546        }
1547    }
1548    if changesets["changelog"] != JsonValue::Bool(false) && !changesets["changelog"].is_null() {
1549        diagnostics.push(InitDiagnostic {
1550            id: "changesets-changelog".to_owned(),
1551            code: "changesets-changelog".to_owned(),
1552            message: "Intentional renders its contract-defined release-unit changelogs instead of invoking the configured Changesets changelog generator.".to_owned(),
1553            evidence: vec![config_evidence.clone()],
1554            choices: vec!["intentional".to_owned()],
1555            recommended: Some("intentional".to_owned()),
1556            resolution: None,
1557            verified: false,
1558            invalidated_resolution: false,
1559        });
1560    }
1561    if changesets["commit"] != JsonValue::Bool(false) && !changesets["commit"].is_null() {
1562        diagnostics.push(InitDiagnostic {
1563            id: "changesets-commit".to_owned(),
1564            code: "changesets-commit".to_owned(),
1565            message: "Intentional never creates commits; repository orchestration must retain the configured commit behavior externally.".to_owned(),
1566            evidence: vec![config_evidence.clone()],
1567            choices: vec!["external".to_owned()],
1568            recommended: Some("external".to_owned()),
1569            resolution: None,
1570            verified: false,
1571            invalidated_resolution: false,
1572        });
1573    }
1574    if changesets["___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH"]
1575        ["onlyUpdatePeerDependentsWhenOutOfRange"]
1576        .as_bool()
1577        == Some(true)
1578    {
1579        diagnostics.push(InitDiagnostic {
1580            id: "changesets-peer-dependent-policy".to_owned(),
1581            code: "changesets-peer-dependent-policy".to_owned(),
1582            message: "Changesets conditionally updates peer dependents from npm ranges; Intentional applies explicit depends-on edges uniformly. Accept Intentional's dependency contract, and resolve any current release divergence shown by parity before takeover.".to_owned(),
1583            evidence: vec![config_evidence.clone()],
1584            choices: vec!["intentional".to_owned()],
1585            recommended: Some("intentional".to_owned()),
1586            resolution: None,
1587            verified: false,
1588            invalidated_resolution: false,
1589        });
1590    }
1591    let dev_dependents = discovery
1592        .npm_dependencies
1593        .iter()
1594        .filter(|edge| edge.kind == NpmDependencyKind::Dev)
1595        .map(|edge| edge.dependent.clone())
1596        .collect::<BTreeSet<_>>();
1597    if !dev_dependents.is_empty() {
1598        let evidence = dev_dependents
1599            .iter()
1600            .filter_map(|id| {
1601                let release_unit = &discovery.config.release_units[id];
1602                release_unit
1603                    .projections
1604                    .iter()
1605                    .find(|projection| projection.adapter == Adapter::Npm)
1606                    .map(|projection| release_unit.path.join(&projection.file))
1607            })
1608            .map(|path| evidence(root, &path, Vec::new()))
1609            .collect::<Result<Vec<_>>>()?;
1610        diagnostics.push(InitDiagnostic {
1611            id: "changesets-dev-dependency-policy".to_owned(),
1612            code: "changesets-dev-dependency-policy".to_owned(),
1613            message: "Changesets propagates releases through internal devDependencies; Intentional reserves depends-on for durable release dependencies. Accept Intentional's dependency contract, and resolve any current release divergence shown by parity before takeover.".to_owned(),
1614            evidence,
1615            choices: vec!["intentional".to_owned()],
1616            recommended: Some("intentional".to_owned()),
1617            resolution: None,
1618            verified: false,
1619            invalidated_resolution: false,
1620        });
1621    }
1622    let mut unmapped_release_units = BTreeSet::new();
1623    for release_unit in discovery.config.release_units.keys() {
1624        if referenced_names.contains(release_unit)
1625            || discovery.workspace_packages.contains(release_unit)
1626        {
1627            continue;
1628        }
1629        unmapped_release_units.insert(release_unit.clone());
1630        let release_unit_config = &discovery.config.release_units[release_unit];
1631        let manifest = release_unit_config
1632            .projections
1633            .first()
1634            .map(|projection| release_unit_config.path.join(&projection.file))
1635            .ok_or_else(|| {
1636                Error::Validation(format!(
1637                    "discovered release unit {release_unit} has no manifest evidence"
1638                ))
1639            })?;
1640        diagnostics.push(InitDiagnostic {
1641            id: format!("unmapped-release-unit-disposition:{release_unit}"),
1642            code: "unmapped-release-unit-disposition".to_owned(),
1643            message: format!(
1644                "Choose whether workspace package {release_unit}, which is outside the Changesets release inventory, is excluded, suspended, or managed."
1645            ),
1646            evidence: vec![evidence(root, &manifest, Vec::new())?],
1647            choices: vec!["excluded".to_owned(), "suspended".to_owned(), "managed".to_owned()],
1648            recommended: Some("excluded".to_owned()),
1649            resolution: None,
1650            verified: false,
1651            invalidated_resolution: false,
1652        });
1653    }
1654    let current_integrations = scan_changesets_integrations(root)?;
1655    for item in &current_integrations {
1656        diagnostics.push(InitDiagnostic {
1657            id: format!("repository-integration:{}", item.path.display()),
1658            code: "repository-integration".to_owned(),
1659            message: format!(
1660                "Remove or replace executable Changesets references in {} and rerun init for verification.",
1661                item.path.display()
1662            ),
1663            evidence: vec![item.clone()],
1664            choices: vec!["removed".to_owned()],
1665            recommended: Some("removed".to_owned()),
1666            resolution: None,
1667            verified: false,
1668            invalidated_resolution: false,
1669        });
1670    }
1671    let integration_paths = current_integrations
1672        .iter()
1673        .map(|item| item.path.as_path())
1674        .collect::<BTreeSet<_>>();
1675    for item in scan_external_release_evidence(root)?
1676        .into_iter()
1677        .filter(|item| !integration_paths.contains(item.path.as_path()))
1678    {
1679        diagnostics.push(InitDiagnostic {
1680            id: format!("external-release-evidence:{}", item.path.display()),
1681            code: "external-release-evidence".to_owned(),
1682            message: format!(
1683                "Keep repository-specific release behavior from {} in the external release executor; Intentional records this file as evidence without interpreting its contents.",
1684                item.path.display()
1685            ),
1686            evidence: vec![item],
1687            choices: vec!["external".to_owned()],
1688            recommended: Some("external".to_owned()),
1689            resolution: None,
1690            verified: false,
1691            invalidated_resolution: false,
1692        });
1693    }
1694    reconcile_diagnostics(root, &mut diagnostics, previous.as_ref())?;
1695    let mut source_config = discovery.config.clone();
1696    let ignored = changesets["ignore"]
1697        .as_array()
1698        .into_iter()
1699        .flatten()
1700        .filter_map(JsonValue::as_str)
1701        .map(str::to_owned)
1702        .collect::<BTreeSet<_>>();
1703    let source_excluded = ignored
1704        .iter()
1705        .cloned()
1706        .chain(unmapped_release_units.iter().cloned())
1707        .collect();
1708    exclude_release_units(&mut source_config, &source_excluded);
1709    apply_disposition_resolutions(&mut discovery.config, &diagnostics)?;
1710    discovery.config.validate()?;
1711
1712    let declared: BTreeMap<String, Bump> =
1713        converted_intents
1714            .iter()
1715            .fold(BTreeMap::new(), |mut aggregate, intent| {
1716                for (id, bump) in &intent.release_units {
1717                    aggregate
1718                        .entry(id.clone())
1719                        .and_modify(|existing| *existing = (*existing).max(*bump))
1720                        .or_insert(*bump);
1721                }
1722                aggregate
1723            });
1724    let suppress_private_versions =
1725        changesets_private_package_settings(&changesets["privatePackages"])
1726            .is_some_and(|(versions_private, _)| !versions_private);
1727    let merged_ignored = ignored
1728        .iter()
1729        .filter_map(|source| {
1730            identity_map
1731                .get(source)
1732                .map(|target| (source.clone(), target.clone()))
1733        })
1734        .collect::<Vec<_>>();
1735    let merged_ignore_error = (!merged_ignored.is_empty()).then(|| {
1736        let conflicts = merged_ignored
1737            .iter()
1738            .map(|(source, target)| format!("{source} onto {target}"))
1739            .collect::<Vec<_>>()
1740            .join(", ");
1741        format!(
1742            "Changesets ignore names native package identities that candidate resolutions map onto managed release units ({conflicts}); source parity cannot apply a package-scoped ignore after identities are merged. Remove the ignore entries or revise the candidate resolutions before takeover"
1743        )
1744    });
1745    let mut skipped_release_units = ignored.clone();
1746    if suppress_private_versions {
1747        skipped_release_units.extend(discovery.private_packages.iter().cloned());
1748    }
1749    skipped_release_units.extend(
1750        discovery
1751            .config
1752            .release_units
1753            .keys()
1754            .filter(|id| !discovery.versions.contains_key(*id))
1755            .cloned(),
1756    );
1757    let source_semantics = ChangesetsSourceSemantics {
1758        private_packages: &discovery.private_packages,
1759        suppress_private_versions,
1760        npm_dependencies: &discovery.npm_dependencies,
1761        internal_dependency_bump: discovery.config.settings.internal_dependency_bump,
1762        update_internal_dependents_always: changesets
1763            ["___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH"]["updateInternalDependents"]
1764            .as_str()
1765            == Some("always"),
1766        only_update_peer_dependents_when_out_of_range: changesets
1767            ["___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH"]
1768            ["onlyUpdatePeerDependentsWhenOutOfRange"]
1769            .as_bool()
1770            .unwrap_or(false),
1771        preflight_error: merged_ignore_error
1772            .or_else(|| mixed_skipped_changeset(&converted_intents, &skipped_release_units)),
1773    };
1774    let parity = parity_result(
1775        &source_config,
1776        &discovery.config,
1777        &discovery.versions,
1778        &declared,
1779        &source_semantics,
1780    )?;
1781    if let Some(message) = &parity.source_error {
1782        diagnostics.push(InitDiagnostic {
1783            id: "changesets-release-invalid".to_owned(),
1784            code: "changesets-release-invalid".to_owned(),
1785            message: format!("The source Changesets release is invalid: {message}"),
1786            evidence: vec![config_evidence.clone()],
1787            choices: Vec::new(),
1788            recommended: None,
1789            resolution: None,
1790            verified: false,
1791            invalidated_resolution: false,
1792        });
1793    }
1794    if let Some(message) = &parity.proposed_error {
1795        diagnostics.push(InitDiagnostic {
1796            id: "proposed-release-invalid".to_owned(),
1797            code: "proposed-release-invalid".to_owned(),
1798            message: format!("The proposed Intentional release is invalid: {message}"),
1799            evidence: vec![config_evidence.clone()],
1800            choices: Vec::new(),
1801            recommended: None,
1802            resolution: None,
1803            verified: false,
1804            invalidated_resolution: false,
1805        });
1806        diagnostics.sort_by(|left, right| left.id.cmp(&right.id));
1807    }
1808    let unresolved = discovery
1809        .candidates
1810        .iter()
1811        .any(|candidate| candidate.resolution.is_none())
1812        || diagnostics.iter().any(|diagnostic| {
1813            !diagnostic.choices.is_empty()
1814                && (diagnostic.resolution.is_none() || !diagnostic.verified)
1815        });
1816    let state = if unresolved || parity.result.status != "equivalent" {
1817        InitState::NeedsInput
1818    } else {
1819        InitState::Ready
1820    };
1821
1822    let mut evidence_paths = discovery.evidence;
1823    evidence_paths.insert(PathBuf::from(CHANGESETS_CONFIG));
1824    for intent in &converted_intents {
1825        evidence_paths.insert(intent.source.clone());
1826    }
1827    for diagnostic in &diagnostics {
1828        for item in &diagnostic.evidence {
1829            evidence_paths.insert(item.path.clone());
1830        }
1831    }
1832    let source_fingerprint = fingerprint(root, &evidence_paths)?;
1833    let planned_operations = takeover_operations(root, &converted_intents);
1834    let mut plan = InitPlan {
1835        schema: INIT_PLAN_SCHEMA.to_owned(),
1836        state,
1837        source_kind: "changesets".to_owned(),
1838        source_fingerprint,
1839        inferred_config: discovery.config,
1840        discovery_candidates: discovery.candidates,
1841        diagnostics,
1842        converted_intents,
1843        parity: parity.result,
1844        planned_operations: planned_operations.clone(),
1845        post_commit_action: "intentional tag --baseline".to_owned(),
1846    };
1847    // The state is always recomputed after reading editable resolutions.
1848    plan.state = state;
1849    let contents = plan.to_yaml()?;
1850
1851    if take_over {
1852        if state != InitState::Ready {
1853            return Err(Error::Validation(
1854                "takeover requires a ready initialization plan with verified resolutions and parity"
1855                    .to_owned(),
1856            ));
1857        }
1858        let mut writes = vec![(PathBuf::from(CONFIG_PATH), plan.inferred_config.to_yaml()?)];
1859        for intent in &plan.converted_intents {
1860            writes.push((intent.target.clone(), intent.contents()?));
1861        }
1862        let deletes = takeover_deletes(root);
1863        verify_takeover_preconditions(root, &writes, &plan.source_fingerprint, &evidence_paths)?;
1864        let takeover_evidence = Some((plan.source_fingerprint.clone(), evidence_paths));
1865        return Ok(InitResult {
1866            state: InitState::Success,
1867            path: PathBuf::from(CONFIG_PATH),
1868            contents: plan.inferred_config.to_yaml()?,
1869            operations: planned_operations,
1870            plan: Some(plan),
1871            writes,
1872            deletes,
1873            takeover: true,
1874            takeover_evidence,
1875        });
1876    }
1877
1878    Ok(InitResult {
1879        state,
1880        path: PathBuf::from(INIT_PLAN_PATH),
1881        operations: vec![format!("write {INIT_PLAN_PATH}")],
1882        contents: contents.clone(),
1883        plan: Some(plan),
1884        writes: vec![(PathBuf::from(INIT_PLAN_PATH), contents)],
1885        deletes: Vec::new(),
1886        takeover: false,
1887        takeover_evidence: None,
1888    })
1889}
1890
1891fn discover(root: &Path, scan_all: bool, referenced_names: &BTreeSet<String>) -> Result<Discovery> {
1892    let workspace_paths = workspace_manifest_paths(root)?;
1893    let mut manifest_paths = if scan_all {
1894        all_manifest_paths(root)?
1895    } else {
1896        workspace_paths.clone()
1897    };
1898    if !referenced_names.is_empty() {
1899        for path in all_manifest_paths(root)? {
1900            let Some(adapter) = adapter_for(&path) else {
1901                continue;
1902            };
1903            if let Ok((name, _)) = manifest_identity(&path, adapter) {
1904                if referenced_names.contains(&name) {
1905                    manifest_paths.insert(path);
1906                }
1907            }
1908        }
1909    }
1910    let mut discovery = Discovery {
1911        config: Config::default(),
1912        ..Discovery::default()
1913    };
1914    for path in manifest_paths {
1915        if let Some(candidate) = devcontainer_candidate(root, &path)? {
1916            discovery.evidence.insert(candidate.path.clone());
1917            discovery.candidates.push(candidate);
1918            continue;
1919        }
1920        let Some(adapter) = adapter_for(&path) else {
1921            continue;
1922        };
1923        if !is_project_manifest(&path, adapter)? {
1924            continue;
1925        }
1926        let (id, version) = manifest_identity(&path, adapter)?;
1927        let relative_manifest = path
1928            .strip_prefix(root)
1929            .map_err(|error| Error::Validation(format!("manifest is outside workspace: {error}")))?
1930            .to_owned();
1931        let manifest_evidence = evidence(root, &relative_manifest, Vec::new())?;
1932        let detector = detector_for(adapter).to_owned();
1933        discovery.candidates.push(DiscoveryCandidate {
1934            id: DiscoveryCandidate::stable_id(&detector, &relative_manifest)?,
1935            detector,
1936            path: relative_manifest.clone(),
1937            evidence: vec![manifest_evidence.clone()],
1938            native_identity: Some(id.clone()),
1939            raw_version: version.as_ref().map(|value| RawVersionEvidence {
1940                value: value.clone(),
1941                evidence: vec![manifest_evidence],
1942            }),
1943            projection: Some(CandidateProjectionSuggestion {
1944                adapter,
1945                path: relative_manifest.clone(),
1946                mode: if adapter == Adapter::Go {
1947                    ProjectionMode::None
1948                } else {
1949                    ProjectionMode::Committed
1950                },
1951                pointer: None,
1952            }),
1953            tag: Some(CandidateTagSuggestion {
1954                id: "primary".to_owned(),
1955                role: TagRole::Primary,
1956                template: "{id}@{version}".to_owned(),
1957            }),
1958            diagnostics: Vec::new(),
1959            resolution: None,
1960        });
1961        let workspace_package = workspace_paths.contains(&path);
1962        let private_package = adapter == Adapter::Npm && npm_manifest_is_private(&path)?;
1963        let directory = path.parent().expect("manifest has parent");
1964        let relative_directory = directory.strip_prefix(root).map_err(|error| {
1965            Error::Validation(format!("manifest is outside workspace: {error}"))
1966        })?;
1967        let package_path = if relative_directory.as_os_str().is_empty() {
1968            PathBuf::from(".")
1969        } else {
1970            relative_directory.to_owned()
1971        };
1972        let projection = Projection {
1973            adapter,
1974            file: path
1975                .file_name()
1976                .map(PathBuf::from)
1977                .expect("manifest has filename"),
1978            mode: if adapter == Adapter::Go {
1979                ProjectionMode::None
1980            } else {
1981                ProjectionMode::Committed
1982            },
1983            pointer: None,
1984        };
1985        if let Some(existing) = discovery.config.release_units.get_mut(&id) {
1986            if existing.path != package_path {
1987                return Err(Error::Validation(format!(
1988                    "manifest-native package name {id} is declared in both {} and {}",
1989                    existing.path.display(),
1990                    package_path.display()
1991                )));
1992            }
1993            existing.projections.push(projection);
1994        } else {
1995            discovery.config.release_units.insert(
1996                id.clone(),
1997                ReleaseUnitConfig {
1998                    path: package_path,
1999                    disposition: ReleaseUnitDisposition::Managed,
2000                    projections: vec![projection],
2001                    tags: BTreeMap::from([(
2002                        "primary".to_owned(),
2003                        TagConfig {
2004                            role: TagRole::Primary,
2005                            template: "{id}@{version}".to_owned(),
2006                            require_phase: None,
2007                            tag_after: Vec::new(),
2008                        },
2009                    )]),
2010                    depends_on: Vec::new(),
2011                },
2012            );
2013        }
2014        if let Some(version) = version {
2015            if let Ok(version) = Version::parse(&version) {
2016                discovery.versions.insert(id.clone(), version);
2017            }
2018        }
2019        if workspace_package {
2020            discovery.workspace_packages.insert(id.clone());
2021        }
2022        if private_package {
2023            discovery.private_packages.insert(id);
2024        }
2025        discovery
2026            .evidence
2027            .insert(path.strip_prefix(root).unwrap_or(&path).to_owned());
2028    }
2029    discovery.npm_dependencies = derive_npm_dependencies(root, &mut discovery.config)?;
2030    discovery
2031        .candidates
2032        .sort_by(|left, right| left.path.cmp(&right.path));
2033    Ok(discovery)
2034}
2035
2036fn workspace_manifest_paths(root: &Path) -> Result<BTreeSet<PathBuf>> {
2037    let mut directories = BTreeSet::new();
2038    let mut found_workspace = false;
2039    let pnpm = root.join("pnpm-workspace.yaml");
2040    if pnpm.exists() {
2041        found_workspace = true;
2042        let text = std::fs::read_to_string(&pnpm).map_err(|error| Error::io(&pnpm, error))?;
2043        let value: serde_yaml::Value = serde_yaml::from_str(&text)?;
2044        expand_workspace_patterns(
2045            root,
2046            value["packages"]
2047                .as_sequence()
2048                .into_iter()
2049                .flatten()
2050                .filter_map(serde_yaml::Value::as_str),
2051            &mut directories,
2052        )?;
2053    }
2054    let package_json = root.join("package.json");
2055    if package_json.exists() {
2056        let text = std::fs::read_to_string(&package_json)
2057            .map_err(|error| Error::io(&package_json, error))?;
2058        let value: JsonValue = serde_json::from_str(&text)
2059            .map_err(|error| Error::Validation(format!("invalid package.json: {error}")))?;
2060        let workspaces = value["workspaces"]
2061            .as_array()
2062            .or_else(|| value["workspaces"]["packages"].as_array());
2063        if let Some(workspaces) = workspaces {
2064            found_workspace = true;
2065            expand_workspace_patterns(
2066                root,
2067                workspaces.iter().filter_map(JsonValue::as_str),
2068                &mut directories,
2069            )?;
2070        }
2071    }
2072    let cargo = root.join("Cargo.toml");
2073    if cargo.exists() {
2074        let text = std::fs::read_to_string(&cargo).map_err(|error| Error::io(&cargo, error))?;
2075        let document = text
2076            .parse::<toml_edit::DocumentMut>()
2077            .map_err(|error| Error::Validation(format!("invalid Cargo.toml: {error}")))?;
2078        if let Some(members) = document
2079            .get("workspace")
2080            .and_then(|workspace| workspace.get("members"))
2081            .and_then(toml_edit::Item::as_array)
2082        {
2083            found_workspace = true;
2084            expand_workspace_patterns(
2085                root,
2086                members.iter().filter_map(toml_edit::Value::as_str),
2087                &mut directories,
2088            )?;
2089        }
2090    }
2091    if !found_workspace {
2092        directories.insert(root.to_owned());
2093    }
2094    let mut paths = BTreeSet::new();
2095    for directory in directories {
2096        add_manifests_in_directory(&directory, &mut paths)?;
2097    }
2098    paths.retain(|path| !hard_excluded(root, path));
2099    remove_git_ignored(root, &mut paths)?;
2100    Ok(paths)
2101}
2102
2103fn expand_workspace_pattern(
2104    root: &Path,
2105    pattern: &str,
2106    directories: &mut BTreeSet<PathBuf>,
2107) -> Result<()> {
2108    let (excluded, pattern) = pattern
2109        .strip_prefix('!')
2110        .map_or((false, pattern), |pattern| (true, pattern));
2111    let absolute = root.join(pattern).to_string_lossy().to_string();
2112    for entry in glob(&absolute).map_err(|error| {
2113        Error::Validation(format!("invalid workspace pattern {pattern}: {error}"))
2114    })? {
2115        let path = entry
2116            .map_err(|error| Error::Validation(format!("workspace pattern failed: {error}")))?;
2117        if path.is_dir() {
2118            if excluded {
2119                directories.remove(&path);
2120            } else {
2121                directories.insert(path);
2122            }
2123        }
2124    }
2125    Ok(())
2126}
2127
2128fn expand_workspace_patterns<'a>(
2129    root: &Path,
2130    patterns: impl IntoIterator<Item = &'a str>,
2131    directories: &mut BTreeSet<PathBuf>,
2132) -> Result<()> {
2133    let patterns = patterns.into_iter().collect::<Vec<_>>();
2134    for excluded in [false, true] {
2135        for pattern in &patterns {
2136            if pattern.starts_with('!') == excluded {
2137                expand_workspace_pattern(root, pattern, directories)?;
2138            }
2139        }
2140    }
2141    Ok(())
2142}
2143
2144fn add_manifests_in_directory(directory: &Path, paths: &mut BTreeSet<PathBuf>) -> Result<()> {
2145    for name in [
2146        "package.json",
2147        "Cargo.toml",
2148        "pubspec.yaml",
2149        "pyproject.toml",
2150        "go.mod",
2151        DEVCONTAINER_FEATURE_MANIFEST,
2152        DEVCONTAINER_TEMPLATE_MANIFEST,
2153    ] {
2154        let path = directory.join(name);
2155        if path.is_file() {
2156            paths.insert(path);
2157        }
2158    }
2159    for entry in std::fs::read_dir(directory).map_err(|error| Error::io(directory, error))? {
2160        let path = entry.map_err(|error| Error::io(directory, error))?.path();
2161        if path
2162            .extension()
2163            .is_some_and(|extension| extension == "csproj")
2164        {
2165            paths.insert(path);
2166        }
2167    }
2168    Ok(())
2169}
2170
2171fn all_manifest_paths(root: &Path) -> Result<BTreeSet<PathBuf>> {
2172    let mut paths = WalkDir::new(root)
2173        .into_iter()
2174        .filter_entry(|entry| !hard_excluded(root, entry.path()))
2175        .filter_map(std::result::Result::ok)
2176        .filter(|entry| entry.file_type().is_file() && is_discoverable_manifest(entry.path()))
2177        .map(|entry| entry.into_path())
2178        .collect();
2179    remove_git_ignored(root, &mut paths)?;
2180    Ok(paths)
2181}
2182
2183fn hard_excluded(root: &Path, path: &Path) -> bool {
2184    path.strip_prefix(root)
2185        .unwrap_or(path)
2186        .components()
2187        .filter_map(|component| match component {
2188            std::path::Component::Normal(value) => value.to_str(),
2189            _ => None,
2190        })
2191        .any(|component| {
2192            matches!(
2193                component,
2194                ".git"
2195                    | ".intentional"
2196                    | "node_modules"
2197                    | "target"
2198                    | ".pnpm-store"
2199                    | ".yarn"
2200                    | ".npm"
2201                    | ".cache"
2202                    | ".venv"
2203                    | "venv"
2204                    | "__pycache__"
2205                    | ".tox"
2206                    | ".nox"
2207                    | ".pytest_cache"
2208                    | ".mypy_cache"
2209                    | ".ruff_cache"
2210                    | ".dart_tool"
2211                    | ".pub-cache"
2212                    | "obj"
2213            )
2214        })
2215}
2216
2217fn remove_git_ignored(root: &Path, paths: &mut BTreeSet<PathBuf>) -> Result<()> {
2218    if paths.is_empty() {
2219        return Ok(());
2220    }
2221    let relative_paths = paths
2222        .iter()
2223        .map(|path| path.strip_prefix(root).unwrap_or(path).to_owned())
2224        .collect::<Vec<_>>();
2225    let mut input = Vec::new();
2226    for path in &relative_paths {
2227        input.extend_from_slice(path.to_string_lossy().as_bytes());
2228        input.push(0);
2229    }
2230    let mut child = Command::new("git")
2231        .args(["check-ignore", "--stdin", "-z"])
2232        .current_dir(root)
2233        .stdin(Stdio::piped())
2234        .stdout(Stdio::piped())
2235        .stderr(Stdio::piped())
2236        .spawn()
2237        .map_err(|error| Error::io(root, error))?;
2238    let mut stdin = child.stdin.take().expect("piped git check-ignore stdin");
2239    let writer = std::thread::spawn(move || stdin.write_all(&input));
2240    let output = child
2241        .wait_with_output()
2242        .map_err(|error| Error::io(root, error))?;
2243    writer
2244        .join()
2245        .map_err(|_| Error::Validation("git check-ignore input writer panicked".to_owned()))?
2246        .map_err(|error| Error::io(root, error))?;
2247    if !matches!(output.status.code(), Some(0 | 1)) {
2248        return Err(Error::Validation(format!(
2249            "git check-ignore failed: {}",
2250            String::from_utf8_lossy(&output.stderr).trim()
2251        )));
2252    }
2253    let ignored = output
2254        .stdout
2255        .split(|byte| *byte == 0)
2256        .filter(|path| !path.is_empty())
2257        .map(|path| PathBuf::from(String::from_utf8_lossy(path).into_owned()))
2258        .collect::<BTreeSet<_>>();
2259    paths.retain(|path| {
2260        let relative = path.strip_prefix(root).unwrap_or(path);
2261        !ignored.contains(relative)
2262    });
2263    Ok(())
2264}
2265
2266fn detector_for(adapter: Adapter) -> &'static str {
2267    match adapter {
2268        Adapter::Npm => "npm-package",
2269        Adapter::Cargo => "cargo-package",
2270        Adapter::Go => "go-module",
2271        Adapter::Python => "python-project",
2272        Adapter::Msbuild => "msbuild-project",
2273        Adapter::Pub => "dart-package",
2274        Adapter::Json | Adapter::Toml | Adapter::Yaml => {
2275            unreachable!("generic adapters are not discovered")
2276        }
2277    }
2278}
2279
2280fn devcontainer_detector_for(path: &Path) -> Option<&'static str> {
2281    match path.file_name()?.to_str()? {
2282        DEVCONTAINER_FEATURE_MANIFEST => Some("devcontainer-feature"),
2283        DEVCONTAINER_TEMPLATE_MANIFEST => Some("devcontainer-template"),
2284        _ => None,
2285    }
2286}
2287
2288fn is_discoverable_manifest(path: &Path) -> bool {
2289    adapter_for(path).is_some() || devcontainer_detector_for(path).is_some()
2290}
2291
2292fn devcontainer_candidate(root: &Path, path: &Path) -> Result<Option<DiscoveryCandidate>> {
2293    let Some(detector) = devcontainer_detector_for(path) else {
2294        return Ok(None);
2295    };
2296    let relative = path
2297        .strip_prefix(root)
2298        .map_err(|error| Error::Validation(format!("manifest is outside workspace: {error}")))?
2299        .to_owned();
2300    let bytes = std::fs::read(path).map_err(|error| Error::io(path, error))?;
2301    let source = SourceEvidence {
2302        path: relative.clone(),
2303        digest: format!("sha256:{:x}", Sha256::digest(&bytes)),
2304        lines: Vec::new(),
2305    };
2306    let mut diagnostics = Vec::new();
2307    let value = match std::str::from_utf8(&bytes) {
2308        Ok(text) => serde_json::from_str::<JsonValue>(text).map_err(|error| error.to_string()),
2309        Err(error) => Err(error.to_string()),
2310    };
2311    let (native_identity, raw_version, projection, tag) = match value {
2312        Ok(value) => {
2313            let native_identity = non_empty_json_string(&value["id"]);
2314            if native_identity.is_none() {
2315                diagnostics.push(extraction_diagnostic(
2316                    "identity-extraction",
2317                    "devcontainer-id-unreadable",
2318                    format!(
2319                        "{} has no non-empty string id; the detector inspects only id for identity.",
2320                        relative.display()
2321                    ),
2322                    &source,
2323                ));
2324            }
2325
2326            let version = non_empty_json_string(&value["version"]);
2327            if version.is_none() {
2328                diagnostics.push(extraction_diagnostic(
2329                    "version-extraction",
2330                    "devcontainer-version-unreadable",
2331                    format!(
2332                        "{} has no non-empty string version; the detector inspects only version for version evidence.",
2333                        relative.display()
2334                    ),
2335                    &source,
2336                ));
2337            }
2338            let valid_version = version.as_deref().is_some_and(|version| {
2339                if let Err(error) = Version::parse(version) {
2340                    diagnostics.push(extraction_diagnostic(
2341                        "version-semver",
2342                        "devcontainer-version-not-semver",
2343                        format!(
2344                            "{} version {version:?} is not Semantic Versioning 2.0.0: {error}.",
2345                            relative.display()
2346                        ),
2347                        &source,
2348                    ));
2349                    false
2350                } else {
2351                    true
2352                }
2353            });
2354            let raw_version = version.map(|value| RawVersionEvidence {
2355                value,
2356                evidence: vec![source.clone()],
2357            });
2358            let projection = valid_version.then(|| CandidateProjectionSuggestion {
2359                adapter: Adapter::Json,
2360                path: relative.clone(),
2361                mode: ProjectionMode::Committed,
2362                pointer: Some("/version".to_owned()),
2363            });
2364            let tag = native_identity.as_ref().map(|_| CandidateTagSuggestion {
2365                id: "primary".to_owned(),
2366                role: TagRole::Primary,
2367                template: "{id}@{version}".to_owned(),
2368            });
2369            (native_identity, raw_version, projection, tag)
2370        }
2371        Err(error) => {
2372            diagnostics.push(extraction_diagnostic(
2373                "manifest-extraction",
2374                "devcontainer-json-unreadable",
2375                format!(
2376                    "{} could not be read as JSON for id and version extraction: {error}.",
2377                    relative.display()
2378                ),
2379                &source,
2380            ));
2381            (None, None, None, None)
2382        }
2383    };
2384
2385    Ok(Some(DiscoveryCandidate {
2386        id: DiscoveryCandidate::stable_id(detector, &relative)?,
2387        detector: detector.to_owned(),
2388        path: relative,
2389        evidence: vec![source],
2390        native_identity,
2391        raw_version,
2392        projection,
2393        tag,
2394        diagnostics,
2395        resolution: None,
2396    }))
2397}
2398
2399fn non_empty_json_string(value: &JsonValue) -> Option<String> {
2400    value
2401        .as_str()
2402        .filter(|value| !value.is_empty())
2403        .map(str::to_owned)
2404}
2405
2406fn extraction_diagnostic(
2407    id: &str,
2408    code: &str,
2409    message: String,
2410    evidence: &SourceEvidence,
2411) -> ExtractionDiagnostic {
2412    ExtractionDiagnostic {
2413        id: id.to_owned(),
2414        code: code.to_owned(),
2415        message,
2416        evidence: vec![evidence.clone()],
2417    }
2418}
2419
2420fn adapter_for(path: &Path) -> Option<Adapter> {
2421    let name = path.file_name()?.to_str()?;
2422    match name {
2423        "package.json" => Some(Adapter::Npm),
2424        "Cargo.toml" => Some(Adapter::Cargo),
2425        "pubspec.yaml" => Some(Adapter::Pub),
2426        "pyproject.toml" => Some(Adapter::Python),
2427        "go.mod" => Some(Adapter::Go),
2428        name if name.ends_with(".csproj") => Some(Adapter::Msbuild),
2429        _ => None,
2430    }
2431}
2432
2433fn is_project_manifest(path: &Path, adapter: Adapter) -> Result<bool> {
2434    if adapter != Adapter::Cargo {
2435        return Ok(true);
2436    }
2437    let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
2438    let document = text
2439        .parse::<toml_edit::DocumentMut>()
2440        .map_err(|error| Error::Validation(format!("invalid Cargo.toml: {error}")))?;
2441    Ok(document.get("package").is_some())
2442}
2443
2444fn manifest_identity(path: &Path, adapter: Adapter) -> Result<(String, Option<String>)> {
2445    let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
2446    match adapter {
2447        Adapter::Npm => {
2448            let value: JsonValue = serde_json::from_str(&text).map_err(|error| {
2449                Error::Validation(format!("invalid {}: {error}", path.display()))
2450            })?;
2451            Ok((
2452                required_string(&value["name"], path, "name")?,
2453                value["version"].as_str().map(str::to_owned),
2454            ))
2455        }
2456        Adapter::Cargo => {
2457            let document = text.parse::<toml_edit::DocumentMut>().map_err(|error| {
2458                Error::Validation(format!("invalid {}: {error}", path.display()))
2459            })?;
2460            let package = document.get("package").ok_or_else(|| {
2461                Error::Validation(format!("{} has no package table", path.display()))
2462            })?;
2463            let name = package
2464                .get("name")
2465                .and_then(toml_edit::Item::as_str)
2466                .ok_or_else(|| {
2467                    Error::Validation(format!("{} has no package.name", path.display()))
2468                })?;
2469            let version = package
2470                .get("version")
2471                .and_then(toml_edit::Item::as_str)
2472                .map(str::to_owned);
2473            Ok((name.to_owned(), version))
2474        }
2475        Adapter::Pub => {
2476            let value: serde_yaml::Value = serde_yaml::from_str(&text)?;
2477            let name = value["name"]
2478                .as_str()
2479                .ok_or_else(|| Error::Validation(format!("{} has no name", path.display())))?;
2480            Ok((
2481                name.to_owned(),
2482                value["version"].as_str().map(str::to_owned),
2483            ))
2484        }
2485        Adapter::Python => {
2486            let document = text.parse::<toml_edit::DocumentMut>().map_err(|error| {
2487                Error::Validation(format!("invalid {}: {error}", path.display()))
2488            })?;
2489            let project = document.get("project").ok_or_else(|| {
2490                Error::Validation(format!("{} has no project table", path.display()))
2491            })?;
2492            let name = project
2493                .get("name")
2494                .and_then(toml_edit::Item::as_str)
2495                .ok_or_else(|| {
2496                    Error::Validation(format!("{} has no project.name", path.display()))
2497                })?;
2498            let version = project
2499                .get("version")
2500                .and_then(toml_edit::Item::as_str)
2501                .map(str::to_owned);
2502            Ok((name.to_owned(), version))
2503        }
2504        Adapter::Go => {
2505            let name = text
2506                .lines()
2507                .find_map(|line| line.trim().strip_prefix("module "))
2508                .ok_or_else(|| {
2509                    Error::Validation(format!("{} has no module directive", path.display()))
2510                })?;
2511            Ok((name.to_owned(), None))
2512        }
2513        Adapter::Msbuild => {
2514            let name = xml_element(&text, "PackageId")
2515                .or_else(|| xml_element(&text, "AssemblyName"))
2516                .or_else(|| {
2517                    path.file_stem()
2518                        .and_then(|value| value.to_str())
2519                        .map(str::to_owned)
2520                })
2521                .ok_or_else(|| {
2522                    Error::Validation(format!("{} has no package identity", path.display()))
2523                })?;
2524            Ok((name, xml_element(&text, "Version")))
2525        }
2526        Adapter::Json | Adapter::Toml | Adapter::Yaml => {
2527            unreachable!("generic adapters are not discovered")
2528        }
2529    }
2530}
2531
2532fn npm_manifest_is_private(path: &Path) -> Result<bool> {
2533    let text = std::fs::read_to_string(path).map_err(|error| Error::io(path, error))?;
2534    let value: JsonValue = serde_json::from_str(&text)
2535        .map_err(|error| Error::Validation(format!("invalid {}: {error}", path.display())))?;
2536    Ok(value["private"].as_bool() == Some(true))
2537}
2538
2539fn required_string(value: &JsonValue, path: &Path, field: &str) -> Result<String> {
2540    value
2541        .as_str()
2542        .map(str::to_owned)
2543        .ok_or_else(|| Error::Validation(format!("{} has no string {field}", path.display())))
2544}
2545
2546fn xml_element(text: &str, name: &str) -> Option<String> {
2547    let start = format!("<{name}>");
2548    let end = format!("</{name}>");
2549    text.split_once(&start)?
2550        .1
2551        .split_once(&end)
2552        .map(|(value, _)| value.trim().to_owned())
2553}
2554
2555fn derive_npm_dependencies(root: &Path, config: &mut Config) -> Result<Vec<NpmDependencyEdge>> {
2556    let ids = config
2557        .release_units
2558        .keys()
2559        .cloned()
2560        .collect::<BTreeSet<_>>();
2561    let mut edges = Vec::new();
2562    for (id, release_unit) in &mut config.release_units {
2563        let npm = release_unit
2564            .projections
2565            .iter()
2566            .find(|projection| projection.adapter == Adapter::Npm);
2567        let Some(npm) = npm else { continue };
2568        let path = root.join(&release_unit.path).join(&npm.file);
2569        let text = std::fs::read_to_string(&path).map_err(|error| Error::io(&path, error))?;
2570        let value: JsonValue = serde_json::from_str(&text)
2571            .map_err(|error| Error::Validation(format!("invalid {}: {error}", path.display())))?;
2572        let mut dependencies = BTreeSet::new();
2573        for (group, kind) in [
2574            ("dependencies", NpmDependencyKind::Dependency),
2575            ("optionalDependencies", NpmDependencyKind::Optional),
2576            ("peerDependencies", NpmDependencyKind::Peer),
2577            ("devDependencies", NpmDependencyKind::Dev),
2578        ] {
2579            if let Some(entries) = value[group].as_object() {
2580                for (dependency, range) in entries {
2581                    if !ids.contains(dependency) {
2582                        continue;
2583                    }
2584                    if kind != NpmDependencyKind::Dev {
2585                        dependencies.insert(dependency.clone());
2586                    }
2587                    edges.push(NpmDependencyEdge {
2588                        dependent: id.clone(),
2589                        dependency: dependency.clone(),
2590                        kind,
2591                        range: range.as_str().unwrap_or_default().to_owned(),
2592                    });
2593                }
2594            }
2595        }
2596        release_unit.depends_on = dependencies.into_iter().collect();
2597    }
2598    edges.sort_by(|left, right| {
2599        (
2600            &left.dependent,
2601            &left.dependency,
2602            left.kind as u8,
2603            &left.range,
2604        )
2605            .cmp(&(
2606                &right.dependent,
2607                &right.dependency,
2608                right.kind as u8,
2609                &right.range,
2610            ))
2611    });
2612    Ok(edges)
2613}
2614
2615fn load_changesets_intents(root: &Path) -> Result<Vec<ConvertedIntent>> {
2616    let directory = root.join(".changeset");
2617    let mut paths = std::fs::read_dir(&directory)
2618        .map_err(|error| Error::io(&directory, error))?
2619        .filter_map(std::result::Result::ok)
2620        .map(|entry| entry.path())
2621        .filter(|path| path.extension().is_some_and(|extension| extension == "md"))
2622        .filter(|path| path.file_name().is_none_or(|name| name != "README.md"))
2623        .collect::<Vec<_>>();
2624    paths.sort();
2625    paths
2626        .into_iter()
2627        .map(|path| {
2628            let text = std::fs::read_to_string(&path).map_err(|error| Error::io(&path, error))?;
2629            let rest = text.strip_prefix("---\n").ok_or_else(|| {
2630                Error::Validation(format!("{} has no Changesets frontmatter", path.display()))
2631            })?;
2632            let (frontmatter, message) = rest.split_once("\n---\n").ok_or_else(|| {
2633                Error::Validation(format!(
2634                    "{} has unterminated Changesets frontmatter",
2635                    path.display()
2636                ))
2637            })?;
2638            let packages = serde_yaml::from_str(frontmatter)?;
2639            let id = path
2640                .file_stem()
2641                .and_then(|value| value.to_str())
2642                .ok_or_else(|| {
2643                    Error::Validation(format!("invalid Changesets filename {}", path.display()))
2644                })?
2645                .to_owned();
2646            Ok(ConvertedIntent {
2647                target: PathBuf::from(crate::intent::INTENTS_PATH).join(format!("{id}.md")),
2648                source: path.strip_prefix(root).unwrap_or(&path).to_owned(),
2649                id,
2650                release_units: packages,
2651                message: message.trim().to_owned(),
2652            })
2653        })
2654        .collect()
2655}
2656
2657fn parse_groups(value: &JsonValue) -> Result<Vec<Vec<String>>> {
2658    value
2659        .as_array()
2660        .into_iter()
2661        .flatten()
2662        .map(|group| {
2663            group
2664                .as_array()
2665                .ok_or_else(|| {
2666                    Error::Validation("Changesets release group must be an array".to_owned())
2667                })?
2668                .iter()
2669                .map(|member| {
2670                    member.as_str().map(str::to_owned).ok_or_else(|| {
2671                        Error::Validation("Changesets group member must be a string".to_owned())
2672                    })
2673                })
2674                .collect()
2675        })
2676        .collect()
2677}
2678
2679fn collect_json_strings(value: &JsonValue, output: &mut BTreeSet<String>) {
2680    match value {
2681        JsonValue::String(value) => {
2682            output.insert(value.clone());
2683        }
2684        JsonValue::Array(values) => {
2685            for value in values {
2686                collect_json_strings(value, output);
2687            }
2688        }
2689        _ => {}
2690    }
2691}
2692
2693fn changesets_private_package_settings(value: &JsonValue) -> Option<(bool, bool)> {
2694    match value {
2695        JsonValue::Bool(enabled) => Some((*enabled, *enabled)),
2696        JsonValue::Object(settings) => Some((
2697            settings
2698                .get("version")
2699                .and_then(JsonValue::as_bool)
2700                .unwrap_or(true),
2701            settings
2702                .get("tag")
2703                .and_then(JsonValue::as_bool)
2704                .unwrap_or(true),
2705        )),
2706        _ => None,
2707    }
2708}
2709
2710fn remap_groups(groups: &mut Vec<Vec<String>>, identity_map: &BTreeMap<String, String>) {
2711    for group in groups.iter_mut() {
2712        for release_unit in group.iter_mut() {
2713            if let Some(target) = identity_map.get(release_unit) {
2714                *release_unit = target.clone();
2715            }
2716        }
2717        group.sort();
2718        group.dedup();
2719    }
2720    groups.retain(|group| group.len() > 1);
2721}
2722
2723fn remap_converted_intents(
2724    intents: &mut [ConvertedIntent],
2725    identity_map: &BTreeMap<String, String>,
2726    config: &Config,
2727) -> Result<()> {
2728    for intent in intents {
2729        let mut release_units = BTreeMap::<String, Bump>::new();
2730        for (id, bump) in std::mem::take(&mut intent.release_units) {
2731            let logical_id = identity_map.get(&id).cloned().unwrap_or(id);
2732            if !config.release_units.contains_key(&logical_id) {
2733                return Err(Error::Validation(format!(
2734                    "Changesets intent {} references release unit {logical_id}, which has no Intentional release-unit identity",
2735                    intent.id
2736                )));
2737            }
2738            release_units
2739                .entry(logical_id)
2740                .and_modify(|existing| *existing = (*existing).max(bump))
2741                .or_insert(bump);
2742        }
2743        intent.release_units = release_units;
2744    }
2745    Ok(())
2746}
2747
2748fn scan_changesets_integrations(root: &Path) -> Result<Vec<SourceEvidence>> {
2749    let mut findings = Vec::new();
2750    for entry in WalkDir::new(root)
2751        .into_iter()
2752        .filter_entry(|entry| !hard_excluded(root, entry.path()))
2753        .filter_map(std::result::Result::ok)
2754        .filter(|entry| entry.file_type().is_file())
2755    {
2756        let path = entry.path();
2757        let relative = path.strip_prefix(root).unwrap_or(path);
2758        if relative.starts_with(".changeset") || relative.starts_with(".intentional") {
2759            continue;
2760        }
2761        let relevant = relative == Path::new("package.json")
2762            || relative == Path::new("pnpm-lock.yaml")
2763            || relative.starts_with(".github")
2764            || relative.starts_with("scripts")
2765            || relative
2766                .components()
2767                .any(|component| component.as_os_str().to_string_lossy().contains("test"));
2768        if !relevant {
2769            continue;
2770        }
2771        let Ok(text) = std::fs::read_to_string(path) else {
2772            continue;
2773        };
2774        let lines = text
2775            .lines()
2776            .enumerate()
2777            .filter(|(_, line)| line.to_ascii_lowercase().contains("changeset"))
2778            .map(|(index, _)| index + 1)
2779            .collect::<Vec<_>>();
2780        if !lines.is_empty() {
2781            findings.push(evidence(root, relative, lines)?);
2782        }
2783    }
2784    findings.sort();
2785    Ok(findings)
2786}
2787
2788fn scan_external_release_evidence(root: &Path) -> Result<Vec<SourceEvidence>> {
2789    let mut findings = Vec::new();
2790    for entry in WalkDir::new(root)
2791        .into_iter()
2792        .filter_entry(|entry| !hard_excluded(root, entry.path()))
2793        .filter_map(std::result::Result::ok)
2794        .filter(|entry| entry.file_type().is_file())
2795    {
2796        let path = entry.path();
2797        let relative = path.strip_prefix(root).unwrap_or(path);
2798        let is_release_file = relative.file_name().is_some_and(|name| {
2799            name.to_string_lossy()
2800                .to_ascii_lowercase()
2801                .contains("release")
2802        });
2803        if (relative.starts_with("scripts") || relative.starts_with(".github")) && is_release_file {
2804            findings.push(evidence(root, relative, Vec::new())?);
2805        }
2806    }
2807    findings.sort();
2808    Ok(findings)
2809}
2810
2811fn reconcile_diagnostics(
2812    root: &Path,
2813    current: &mut Vec<InitDiagnostic>,
2814    previous: Option<&InitPlan>,
2815) -> Result<()> {
2816    let Some(previous) = previous else {
2817        return Ok(());
2818    };
2819    let previous_by_id = previous
2820        .diagnostics
2821        .iter()
2822        .map(|diagnostic| (diagnostic.id.as_str(), diagnostic))
2823        .collect::<BTreeMap<_, _>>();
2824    for diagnostic in current.iter_mut() {
2825        let Some(old) = previous_by_id.get(diagnostic.id.as_str()) else {
2826            continue;
2827        };
2828        if old.evidence == diagnostic.evidence {
2829            diagnostic.resolution = old.resolution.clone();
2830            if !diagnostic.choices.is_empty() {
2831                diagnostic.verified =
2832                    diagnostic.code != "repository-integration" && diagnostic.resolution.is_some();
2833            }
2834        } else if old.resolution.is_some() {
2835            diagnostic.invalidated_resolution = true;
2836        }
2837    }
2838    let current_ids = current
2839        .iter()
2840        .map(|diagnostic| diagnostic.id.clone())
2841        .collect::<BTreeSet<_>>();
2842    for old in &previous.diagnostics {
2843        if old.code != "repository-integration"
2844            || current_ids.contains(&old.id)
2845            || old.resolution.as_deref() != Some("removed")
2846        {
2847            continue;
2848        }
2849        let Some(path) = old.evidence.first().map(|evidence| evidence.path.clone()) else {
2850            continue;
2851        };
2852        let full = root.join(&path);
2853        if !full.is_file() {
2854            continue;
2855        }
2856        let text = std::fs::read_to_string(&full).map_err(|error| Error::io(&full, error))?;
2857        if text.to_ascii_lowercase().contains("changeset") {
2858            continue;
2859        }
2860        current.push(InitDiagnostic {
2861            id: old.id.clone(),
2862            code: old.code.clone(),
2863            message: old.message.clone(),
2864            evidence: vec![evidence(root, &path, Vec::new())?],
2865            choices: old.choices.clone(),
2866            recommended: old.recommended.clone(),
2867            resolution: old.resolution.clone(),
2868            verified: true,
2869            invalidated_resolution: false,
2870        });
2871    }
2872    current.sort_by(|left, right| left.id.cmp(&right.id));
2873    Ok(())
2874}
2875
2876fn apply_disposition_resolutions(
2877    config: &mut Config,
2878    diagnostics: &[InitDiagnostic],
2879) -> Result<()> {
2880    let mut excluded = BTreeSet::new();
2881    for diagnostic in diagnostics.iter().filter(|diagnostic| {
2882        diagnostic.code == "ignored-release-unit-disposition"
2883            || diagnostic.code == "unmapped-release-unit-disposition"
2884    }) {
2885        let release_unit = diagnostic
2886            .id
2887            .split_once(':')
2888            .map(|(_, release_unit)| release_unit)
2889            .expect("diagnostic id prefix");
2890        match diagnostic.resolution.as_deref() {
2891            Some("suspended") => {
2892                if let Some(config) = config.release_units.get_mut(release_unit) {
2893                    config.disposition = ReleaseUnitDisposition::Suspended;
2894                }
2895            }
2896            Some("excluded") => {
2897                config.release_units.remove(release_unit);
2898                excluded.insert(release_unit.to_owned());
2899            }
2900            Some("managed") | None => {}
2901            Some(value) => {
2902                return Err(Error::Validation(format!(
2903                    "invalid resolution {value} for {}",
2904                    diagnostic.id
2905                )))
2906            }
2907        }
2908    }
2909    exclude_release_units(config, &excluded);
2910    Ok(())
2911}
2912
2913fn exclude_release_units(config: &mut Config, excluded: &BTreeSet<String>) {
2914    for id in excluded {
2915        config.release_units.remove(id);
2916    }
2917    if excluded.is_empty() {
2918        return;
2919    }
2920    for release_unit in config.release_units.values_mut() {
2921        release_unit.depends_on.retain(|id| !excluded.contains(id));
2922    }
2923    for groups in [&mut config.fixed, &mut config.linked] {
2924        for group in groups.iter_mut() {
2925            group.retain(|id| !excluded.contains(id));
2926        }
2927        groups.retain(|group| group.len() >= 2);
2928    }
2929}
2930
2931fn parity_result(
2932    source_config: &Config,
2933    proposed_config: &Config,
2934    current: &BTreeMap<String, Version>,
2935    declared: &BTreeMap<String, Bump>,
2936    source_semantics: &ChangesetsSourceSemantics<'_>,
2937) -> Result<ParityComputation> {
2938    let mut source_config = source_config.clone();
2939    let mut proposed_config = proposed_config.clone();
2940    let effective = effective_bumps(&proposed_config, declared);
2941    let non_releasing_without_versions = proposed_config
2942        .release_units
2943        .keys()
2944        .filter(|id| !current.contains_key(*id) && effective[*id] == Bump::None)
2945        .cloned()
2946        .collect::<BTreeSet<_>>();
2947    exclude_release_units(&mut source_config, &non_releasing_without_versions);
2948    exclude_release_units(&mut proposed_config, &non_releasing_without_versions);
2949    let excluded_pending = declared
2950        .keys()
2951        .filter(|id| !proposed_config.release_units.contains_key(*id))
2952        .cloned()
2953        .collect::<Vec<_>>();
2954    let proposed_preflight_error = (!excluded_pending.is_empty()).then(|| {
2955        format!(
2956            "converted intents reference release units excluded from the proposed inventory: {}",
2957            excluded_pending.join(", ")
2958        )
2959    });
2960    if source_semantics.preflight_error.is_some() || proposed_preflight_error.is_some() {
2961        return Ok(ParityComputation {
2962            result: ParityResult {
2963                status: "blocked".to_owned(),
2964                release_units: Vec::new(),
2965            },
2966            source_error: source_semantics.preflight_error.clone(),
2967            proposed_error: proposed_preflight_error,
2968        });
2969    }
2970    let missing = source_config
2971        .release_units
2972        .keys()
2973        .chain(proposed_config.release_units.keys())
2974        .filter(|id| !current.contains_key(*id))
2975        .cloned()
2976        .collect::<BTreeSet<_>>();
2977    if !missing.is_empty() {
2978        return Ok(ParityComputation {
2979            result: ParityResult {
2980                status: "blocked".to_owned(),
2981                release_units: Vec::new(),
2982            },
2983            source_error: None,
2984            proposed_error: Some(format!(
2985                "missing current versions for release units: {}",
2986                missing.into_iter().collect::<Vec<_>>().join(", ")
2987            )),
2988        });
2989    }
2990    let (source, source_error) =
2991        match resolve_changesets_source(&source_config, declared, current, source_semantics) {
2992            Ok(resolved) => (resolved, None),
2993            Err(Error::Validation(message)) => (BTreeMap::new(), Some(message)),
2994            Err(error) => return Err(error),
2995        };
2996    let (proposed, proposed_error) = match resolve_versions(&proposed_config, declared, current) {
2997        Ok(resolved) => (resolved, None),
2998        Err(Error::Validation(message)) => (BTreeMap::new(), Some(message)),
2999        Err(error) => return Err(error),
3000    };
3001    let release_ids = source
3002        .iter()
3003        .chain(&proposed)
3004        .filter(|(_, versions)| versions.bump != Bump::None)
3005        .map(|(id, _)| id.clone())
3006        .collect::<BTreeSet<_>>();
3007    let release_units = release_ids
3008        .into_iter()
3009        .map(|release_unit| ParityReleaseUnit {
3010            current_version: current[&release_unit].to_string(),
3011            source: parity_release(source.get(&release_unit)),
3012            proposed: parity_release(proposed.get(&release_unit)),
3013            release_unit,
3014        })
3015        .collect::<Vec<_>>();
3016    let equivalent = source_error.is_none()
3017        && proposed_error.is_none()
3018        && release_units
3019            .iter()
3020            .all(|release_unit| release_unit.source == release_unit.proposed);
3021    Ok(ParityComputation {
3022        result: ParityResult {
3023            status: if equivalent { "equivalent" } else { "blocked" }.to_owned(),
3024            release_units,
3025        },
3026        source_error,
3027        proposed_error,
3028    })
3029}
3030
3031fn mixed_skipped_changeset(
3032    intents: &[ConvertedIntent],
3033    skipped_release_units: &BTreeSet<String>,
3034) -> Option<String> {
3035    intents.iter().find_map(|intent| {
3036        let has_skipped = intent
3037            .release_units
3038            .keys()
3039            .any(|id| skipped_release_units.contains(id));
3040        let has_managed = intent
3041            .release_units
3042            .keys()
3043            .any(|id| !skipped_release_units.contains(id));
3044        (has_skipped && has_managed).then(|| {
3045            format!(
3046                "Changesets intent {} mixes skipped and managed release units; split or revise the source changeset before takeover",
3047                intent.id
3048            )
3049        })
3050    })
3051}
3052
3053struct ParityComputation {
3054    result: ParityResult,
3055    source_error: Option<String>,
3056    proposed_error: Option<String>,
3057}
3058
3059fn parity_release(version: Option<&ReleaseUnitVersion>) -> Option<ParityRelease> {
3060    version
3061        .filter(|version| version.bump != Bump::None)
3062        .map(|version| ParityRelease {
3063            requested_bump: version.bump,
3064            next_version: version.next.to_string(),
3065        })
3066}
3067
3068/// Compute the source Changesets result without invoking Intentional's resolver.
3069///
3070/// This deliberately duplicates the small release-propagation algorithm so the
3071/// takeover gate compares two computations rather than comparing a result with
3072/// itself.
3073fn resolve_changesets_source(
3074    config: &Config,
3075    declared: &BTreeMap<String, Bump>,
3076    current: &BTreeMap<String, Version>,
3077    semantics: &ChangesetsSourceSemantics<'_>,
3078) -> Result<BTreeMap<String, ReleaseUnitVersion>> {
3079    let mut effective = config
3080        .release_units
3081        .keys()
3082        .map(|id| {
3083            let suppressed =
3084                semantics.suppress_private_versions && semantics.private_packages.contains(id);
3085            (
3086                id.clone(),
3087                if suppressed {
3088                    Bump::None
3089                } else {
3090                    declared.get(id).copied().unwrap_or(Bump::None)
3091                },
3092            )
3093        })
3094        .collect::<BTreeMap<_, _>>();
3095    loop {
3096        let before = effective.clone();
3097        for edge in semantics.npm_dependencies {
3098            if !config.release_units.contains_key(&edge.dependent)
3099                || !config.release_units.contains_key(&edge.dependency)
3100                || (semantics.suppress_private_versions
3101                    && semantics.private_packages.contains(&edge.dependent))
3102            {
3103                continue;
3104            }
3105            let dependency_bump = effective[&edge.dependency];
3106            if dependency_bump == Bump::None {
3107                continue;
3108            }
3109            let next = changesets_dependency_next(config, &edge.dependency, &effective, current);
3110            let in_range = npm_range_satisfies(&edge.range, &current[&edge.dependency], &next)?;
3111            let required = if edge.kind == NpmDependencyKind::Peer
3112                && dependency_bump >= Bump::Minor
3113                && (!semantics.only_update_peer_dependents_when_out_of_range || !in_range)
3114            {
3115                Bump::Major
3116            } else if semantics.update_internal_dependents_always || !in_range {
3117                semantics.internal_dependency_bump
3118            } else {
3119                Bump::None
3120            };
3121            if required != Bump::None {
3122                effective
3123                    .entry(edge.dependent.clone())
3124                    .and_modify(|bump| *bump = (*bump).max(required));
3125            }
3126        }
3127        for group in &config.fixed {
3128            let bump = group
3129                .iter()
3130                .map(|id| effective[id])
3131                .max()
3132                .unwrap_or_default();
3133            if bump != Bump::None {
3134                for id in group {
3135                    if !semantics.suppress_private_versions
3136                        || !semantics.private_packages.contains(id)
3137                    {
3138                        effective.insert(id.clone(), bump);
3139                    }
3140                }
3141            }
3142        }
3143        for group in &config.linked {
3144            let bump = group
3145                .iter()
3146                .map(|id| effective[id])
3147                .max()
3148                .unwrap_or_default();
3149            if bump != Bump::None {
3150                for id in group {
3151                    if effective[id] != Bump::None {
3152                        effective.insert(id.clone(), bump);
3153                    }
3154                }
3155            }
3156        }
3157        if effective == before {
3158            break;
3159        }
3160    }
3161
3162    let mut resolved = config
3163        .release_units
3164        .keys()
3165        .map(|id| {
3166            let current_version = current.get(id).ok_or_else(|| {
3167                Error::Validation(format!("missing current version for release unit {id}"))
3168            })?;
3169            Ok((
3170                id.clone(),
3171                ReleaseUnitVersion::new_with_mapping(
3172                    current_version.clone(),
3173                    effective[id],
3174                    Pre1BumpMapping::Component,
3175                ),
3176            ))
3177        })
3178        .collect::<Result<BTreeMap<_, _>>>()?;
3179    for (fixed, groups) in [(true, &config.fixed), (false, &config.linked)] {
3180        for group in groups {
3181            let highest_current = group
3182                .iter()
3183                .map(|id| &current[id])
3184                .max()
3185                .expect("validated release group")
3186                .clone();
3187            let highest_bump = group
3188                .iter()
3189                .map(|id| effective[id])
3190                .max()
3191                .unwrap_or_default();
3192            let next = bump_version_with_mapping(
3193                &highest_current,
3194                highest_bump,
3195                Pre1BumpMapping::Component,
3196            );
3197            for id in group {
3198                let suppressed =
3199                    semantics.suppress_private_versions && semantics.private_packages.contains(id);
3200                let releases = !suppressed
3201                    && highest_bump != Bump::None
3202                    && (fixed || effective[id] != Bump::None);
3203                resolved.insert(
3204                    id.clone(),
3205                    ReleaseUnitVersion {
3206                        current: current[id].clone(),
3207                        next: if releases {
3208                            next.clone()
3209                        } else {
3210                            current[id].clone()
3211                        },
3212                        bump: if releases { highest_bump } else { Bump::None },
3213                    },
3214                );
3215            }
3216        }
3217    }
3218    Ok(resolved)
3219}
3220
3221fn changesets_dependency_next(
3222    config: &Config,
3223    id: &str,
3224    effective: &BTreeMap<String, Bump>,
3225    current: &BTreeMap<String, Version>,
3226) -> Version {
3227    for group in config.fixed.iter().chain(&config.linked) {
3228        if group.iter().any(|member| member == id) && effective[id] != Bump::None {
3229            let highest_current = group
3230                .iter()
3231                .map(|member| &current[member])
3232                .max()
3233                .expect("validated group");
3234            let highest_bump = group
3235                .iter()
3236                .map(|member| effective[member])
3237                .max()
3238                .unwrap_or_default();
3239            return bump_version_with_mapping(
3240                highest_current,
3241                highest_bump,
3242                Pre1BumpMapping::Component,
3243            );
3244        }
3245    }
3246    bump_version_with_mapping(&current[id], effective[id], Pre1BumpMapping::Component)
3247}
3248
3249fn npm_range_satisfies(range: &str, current: &Version, next: &Version) -> Result<bool> {
3250    let normalized = match range.strip_prefix("workspace:") {
3251        Some("*") => current.to_string(),
3252        Some("^") => format!("^{current}"),
3253        Some("~") => format!("~{current}"),
3254        Some(range) => range.to_owned(),
3255        None => range.to_owned(),
3256    };
3257    let Ok(range) = NodeRange::parse(&normalized) else {
3258        return Ok(false);
3259    };
3260    let next = NodeVersion::parse(next.to_string())
3261        .map_err(|error| Error::Validation(format!("invalid next npm version: {error}")))?;
3262    Ok(range.satisfies(&next))
3263}
3264
3265fn load_previous_plan(root: &Path) -> Result<Option<InitPlan>> {
3266    let path = root.join(INIT_PLAN_PATH);
3267    match std::fs::read_to_string(&path) {
3268        Ok(text) => {
3269            let plan: InitPlan = serde_yaml::from_str(&text)?;
3270            plan.validate()?;
3271            Ok(Some(plan))
3272        }
3273        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
3274        Err(error) => Err(Error::io(path, error)),
3275    }
3276}
3277
3278fn evidence(root: &Path, relative: &Path, lines: Vec<usize>) -> Result<SourceEvidence> {
3279    let path = root.join(relative);
3280    let bytes = std::fs::read(&path).map_err(|error| Error::io(&path, error))?;
3281    Ok(SourceEvidence {
3282        path: relative.to_owned(),
3283        digest: format!("sha256:{:x}", Sha256::digest(&bytes)),
3284        lines,
3285    })
3286}
3287
3288fn fingerprint(root: &Path, paths: &BTreeSet<PathBuf>) -> Result<String> {
3289    let mut source = BTreeMap::new();
3290    for path in paths {
3291        if root.join(path).is_file() {
3292            source.insert(
3293                path.to_string_lossy().to_string(),
3294                evidence(root, path, Vec::new())?.digest,
3295            );
3296        }
3297    }
3298    for path in ["package.json", "pnpm-workspace.yaml", "Cargo.toml"] {
3299        let path = PathBuf::from(path);
3300        if root.join(&path).is_file() {
3301            source.insert(
3302                path.to_string_lossy().to_string(),
3303                evidence(root, &path, Vec::new())?.digest,
3304            );
3305        }
3306    }
3307    for entry in WalkDir::new(root.join(".changeset"))
3308        .into_iter()
3309        .filter_map(std::result::Result::ok)
3310        .filter(|entry| entry.file_type().is_file())
3311    {
3312        let path = entry
3313            .path()
3314            .strip_prefix(root)
3315            .unwrap_or(entry.path())
3316            .to_owned();
3317        source.insert(
3318            path.to_string_lossy().to_string(),
3319            evidence(root, &path, Vec::new())?.digest,
3320        );
3321    }
3322    Ok(format!(
3323        "sha256:{:x}",
3324        Sha256::digest(canonical_json(&source)?.as_bytes())
3325    ))
3326}
3327
3328fn verify_takeover_preconditions(
3329    root: &Path,
3330    writes: &[(PathBuf, String)],
3331    expected_fingerprint: &str,
3332    evidence_paths: &BTreeSet<PathBuf>,
3333) -> Result<()> {
3334    let actual_fingerprint = fingerprint(root, evidence_paths)?;
3335    if actual_fingerprint != expected_fingerprint {
3336        return Err(Error::Validation(
3337            "initialization plan source evidence became stale; rerun intentional init".to_owned(),
3338        ));
3339    }
3340    for (relative, _) in writes {
3341        if root.join(relative).exists() {
3342            return Err(Error::Validation(format!(
3343                "takeover target {} already exists; resolve the competing Intentional state first",
3344                relative.display()
3345            )));
3346        }
3347    }
3348    Ok(())
3349}
3350
3351fn takeover_operations(root: &Path, intents: &[ConvertedIntent]) -> Vec<String> {
3352    let mut operations = vec![format!("write {CONFIG_PATH}")];
3353    operations.extend(intents.iter().map(|intent| {
3354        format!(
3355            "write {} from {}",
3356            intent.target.display(),
3357            intent.source.display()
3358        )
3359    }));
3360    operations.extend(
3361        takeover_deletes(root)
3362            .iter()
3363            .map(|path| format!("delete {}", path.display())),
3364    );
3365    operations.push("after commit: intentional tag --baseline".to_owned());
3366    operations
3367}
3368
3369fn takeover_deletes(root: &Path) -> Vec<PathBuf> {
3370    let mut paths = WalkDir::new(root.join(".changeset"))
3371        .into_iter()
3372        .filter_map(std::result::Result::ok)
3373        .filter(|entry| entry.file_type().is_file())
3374        .map(|entry| {
3375            entry
3376                .path()
3377                .strip_prefix(root)
3378                .unwrap_or(entry.path())
3379                .to_owned()
3380        })
3381        .collect::<Vec<_>>();
3382    paths.push(PathBuf::from(INIT_PLAN_PATH));
3383    paths.sort();
3384    paths
3385}
3386
3387fn apply_takeover_transaction(
3388    root: &Path,
3389    writes: &[(PathBuf, String)],
3390    deletes: &[PathBuf],
3391) -> Result<()> {
3392    let transaction = root.join(TRANSACTION_PATH);
3393    if transaction.exists() {
3394        recover_interrupted_takeover(root)?;
3395    }
3396    let setup = (|| {
3397        std::fs::create_dir_all(transaction.join("original"))
3398            .map_err(|error| Error::io(&transaction, error))?;
3399        let affected = writes
3400            .iter()
3401            .map(|(path, _)| path.clone())
3402            .chain(deletes.iter().cloned())
3403            .collect::<BTreeSet<_>>();
3404        for relative in &affected {
3405            let source = root.join(relative);
3406            if source.is_file() {
3407                let backup = transaction.join("original").join(relative);
3408                if let Some(parent) = backup.parent() {
3409                    std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
3410                }
3411                std::fs::copy(&source, &backup).map_err(|error| Error::io(&source, error))?;
3412            }
3413        }
3414        let manifest = serde_yaml::to_string(
3415            &affected
3416                .iter()
3417                .map(|path| path.to_string_lossy().to_string())
3418                .collect::<Vec<_>>(),
3419        )?;
3420        let manifest_path = transaction.join("manifest.yml");
3421        let manifest_temporary = transaction.join("manifest-tmp.yml");
3422        std::fs::write(&manifest_temporary, manifest)
3423            .map_err(|error| Error::io(&manifest_temporary, error))?;
3424        std::fs::rename(&manifest_temporary, &manifest_path)
3425            .map_err(|error| Error::io(&manifest_path, error))?;
3426        Ok::<(), Error>(())
3427    })();
3428    match setup {
3429        Ok(()) => {}
3430        Err(error) => {
3431            if transaction.exists() {
3432                std::fs::remove_dir_all(&transaction)
3433                    .map_err(|cleanup| Error::io(&transaction, cleanup))?;
3434            }
3435            return Err(error);
3436        }
3437    }
3438    let result = (|| {
3439        for (relative, contents) in writes {
3440            let path = root.join(relative);
3441            if let Some(parent) = path.parent() {
3442                std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
3443            }
3444            let temporary = transaction.join("staged").join(relative);
3445            if let Some(parent) = temporary.parent() {
3446                std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
3447            }
3448            std::fs::write(&temporary, contents).map_err(|error| Error::io(&temporary, error))?;
3449            std::fs::rename(&temporary, &path).map_err(|error| Error::io(&path, error))?;
3450        }
3451        for relative in deletes {
3452            let path = root.join(relative);
3453            if path.is_file() {
3454                std::fs::remove_file(&path).map_err(|error| Error::io(&path, error))?;
3455            }
3456        }
3457        let changeset_directory = root.join(".changeset");
3458        if changeset_directory.is_dir()
3459            && std::fs::read_dir(&changeset_directory)
3460                .map_err(|error| Error::io(&changeset_directory, error))?
3461                .next()
3462                .is_none()
3463        {
3464            std::fs::remove_dir(&changeset_directory)
3465                .map_err(|error| Error::io(&changeset_directory, error))?;
3466        }
3467        Ok(())
3468    })();
3469    if result.is_err() {
3470        recover_interrupted_takeover(root)?;
3471        return result;
3472    }
3473    if let Err(error) = write_transaction_state(root, "committed") {
3474        recover_interrupted_takeover(root)?;
3475        return Err(error);
3476    }
3477    finish_transaction_cleanup(root)
3478}
3479
3480fn recover_interrupted_takeover(root: &Path) -> Result<()> {
3481    let transaction = root.join(TRANSACTION_PATH);
3482    let state_path = root.join(TRANSACTION_STATE_PATH);
3483    if state_path.is_file() {
3484        return finish_transaction_cleanup(root);
3485    }
3486    if !transaction.exists() {
3487        return Ok(());
3488    }
3489    let manifest_path = transaction.join("manifest.yml");
3490    if !manifest_path.is_file() {
3491        return std::fs::remove_dir_all(&transaction)
3492            .map_err(|error| Error::io(&transaction, error));
3493    }
3494    let manifest = std::fs::read_to_string(&manifest_path)
3495        .map_err(|error| Error::io(&manifest_path, error))?;
3496    let affected: Vec<String> = serde_yaml::from_str(&manifest)?;
3497    for relative in affected {
3498        let relative = PathBuf::from(relative);
3499        let target = root.join(&relative);
3500        let backup = transaction.join("original").join(&relative);
3501        if backup.is_file() {
3502            if let Some(parent) = target.parent() {
3503                std::fs::create_dir_all(parent).map_err(|error| Error::io(parent, error))?;
3504            }
3505            std::fs::copy(&backup, &target).map_err(|error| Error::io(&target, error))?;
3506        } else if target.is_file() {
3507            std::fs::remove_file(&target).map_err(|error| Error::io(&target, error))?;
3508        }
3509    }
3510    write_transaction_state(root, "rolled-back")?;
3511    finish_transaction_cleanup(root)
3512}
3513
3514fn write_transaction_state(root: &Path, state: &str) -> Result<()> {
3515    let path = root.join(TRANSACTION_STATE_PATH);
3516    let temporary = root.join(".intentional/.takeover-state-tmp");
3517    std::fs::write(&temporary, state).map_err(|error| Error::io(&temporary, error))?;
3518    std::fs::rename(&temporary, &path).map_err(|error| Error::io(&path, error))
3519}
3520
3521fn finish_transaction_cleanup(root: &Path) -> Result<()> {
3522    let transaction = root.join(TRANSACTION_PATH);
3523    if transaction.exists() {
3524        std::fs::remove_dir_all(&transaction).map_err(|error| Error::io(&transaction, error))?;
3525    }
3526    for path in [
3527        root.join(TRANSACTION_STATE_PATH),
3528        root.join(".intentional/.takeover-state-tmp"),
3529    ] {
3530        if path.is_file() {
3531            std::fs::remove_file(&path).map_err(|error| Error::io(&path, error))?;
3532        }
3533    }
3534    Ok(())
3535}
3536
3537fn annotate_choice_lines(yaml: &str) -> String {
3538    let comments = BTreeMap::from([
3539        ("suspended", "configured, but releases are blocked"),
3540        ("excluded", "outside Intentional's release-unit inventory"),
3541        ("managed", "configured and eligible for release"),
3542        (
3543            "removed",
3544            "reference removed or replaced and verified from the file",
3545        ),
3546        (
3547            "external",
3548            "publication sequencing remains with the external executor",
3549        ),
3550        ("intentional", "Intentional's contract owns this behavior"),
3551    ]);
3552    let mut output = String::new();
3553    let mut choices_indent = None;
3554    for line in yaml.lines() {
3555        let indent = line.len() - line.trim_start().len();
3556        if line.trim() == "choices:" {
3557            choices_indent = Some(indent);
3558            output.push_str(line);
3559            output.push('\n');
3560            continue;
3561        }
3562        if let Some(base) = choices_indent {
3563            if let Some(value) = line.trim().strip_prefix("- ") {
3564                if let Some(comment) = comments.get(value) {
3565                    output.push_str(line);
3566                    output.push_str(" # ");
3567                    output.push_str(comment);
3568                    output.push('\n');
3569                    continue;
3570                }
3571            } else if indent <= base {
3572                choices_indent = None;
3573            }
3574        }
3575        output.push_str(line);
3576        output.push('\n');
3577    }
3578    output
3579}
3580
3581#[cfg(test)]
3582mod tests {
3583    use super::*;
3584
3585    const DIGEST: &str = "sha256:0000000000000000000000000000000000000000000000000000000000000000";
3586
3587    fn candidate(path: &str, resolution: Option<CandidateResolution>) -> DiscoveryCandidate {
3588        let path = PathBuf::from(path);
3589        let evidence = SourceEvidence {
3590            path: path.clone(),
3591            digest: DIGEST.to_owned(),
3592            lines: vec![1],
3593        };
3594        DiscoveryCandidate {
3595            id: DiscoveryCandidate::stable_id("sample-detector", &path).expect("stable id"),
3596            detector: "sample-detector".to_owned(),
3597            path: path.clone(),
3598            evidence: vec![evidence.clone()],
3599            native_identity: Some("sample-native-id".to_owned()),
3600            raw_version: Some(RawVersionEvidence {
3601                value: "raw-version-text".to_owned(),
3602                evidence: vec![evidence.clone()],
3603            }),
3604            projection: Some(CandidateProjectionSuggestion {
3605                adapter: Adapter::Npm,
3606                path,
3607                mode: ProjectionMode::Committed,
3608                pointer: None,
3609            }),
3610            tag: Some(CandidateTagSuggestion {
3611                id: "primary".to_owned(),
3612                role: TagRole::Primary,
3613                template: "{id}@{version}".to_owned(),
3614            }),
3615            diagnostics: vec![ExtractionDiagnostic {
3616                id: "sample-diagnostic".to_owned(),
3617                code: "sample-extraction".to_owned(),
3618                message: "Sample extraction evidence is incomplete.".to_owned(),
3619                evidence: vec![evidence],
3620            }],
3621            resolution,
3622        }
3623    }
3624
3625    fn candidate_plan(candidates: Vec<DiscoveryCandidate>) -> InitPlan {
3626        let config = Config::from_yaml(
3627            "contract: contract-1\nrelease-units:\n  configured:\n    path: configured\n    tags:\n      primary: { role: primary, template: '{id}@{version}' }\n",
3628        )
3629        .expect("candidate test config");
3630        InitPlan {
3631            schema: INIT_PLAN_SCHEMA.to_owned(),
3632            state: InitState::NeedsInput,
3633            source_kind: "changesets".to_owned(),
3634            source_fingerprint: DIGEST.to_owned(),
3635            inferred_config: config,
3636            discovery_candidates: candidates,
3637            diagnostics: Vec::new(),
3638            converted_intents: Vec::new(),
3639            parity: ParityResult {
3640                status: "equivalent".to_owned(),
3641                release_units: Vec::new(),
3642            },
3643            planned_operations: Vec::new(),
3644            post_commit_action: "intentional tag --baseline".to_owned(),
3645        }
3646    }
3647
3648    #[test]
3649    fn recognizes_supported_manifests() {
3650        assert_eq!(adapter_for(Path::new("package.json")), Some(Adapter::Npm));
3651        assert_eq!(
3652            adapter_for(Path::new("module.csproj")),
3653            Some(Adapter::Msbuild)
3654        );
3655        assert_eq!(adapter_for(Path::new("README.md")), None);
3656    }
3657
3658    #[test]
3659    fn choice_rendering_is_compact_and_explained() {
3660        let yaml = "choices:\n- suspended\n- excluded\nresolution: null\n";
3661        let rendered = annotate_choice_lines(yaml);
3662        assert!(rendered.contains("- suspended # configured, but releases are blocked"));
3663        assert!(rendered.contains("- excluded # outside Intentional's release-unit inventory"));
3664    }
3665
3666    #[test]
3667    fn candidate_identity_and_detector_results_are_stable() {
3668        let first = candidate(
3669            "examples/first.json",
3670            Some(CandidateResolution::Independent {
3671                release_unit: "sample-unit".to_owned(),
3672            }),
3673        );
3674        let second = candidate("examples/second.json", Some(CandidateResolution::Excluded));
3675        assert_eq!(
3676            first.id,
3677            DiscoveryCandidate::stable_id("sample-detector", Path::new("examples/first.json"))
3678                .expect("same identity")
3679        );
3680        assert_ne!(first.id, second.id);
3681        assert_ne!(
3682            first.id,
3683            DiscoveryCandidate::stable_id("other-detector", Path::new("examples/first.json"))
3684                .expect("different detector")
3685        );
3686        assert!(DiscoveryCandidate::stable_id(
3687            "-invalid-detector",
3688            Path::new("examples/first.json")
3689        )
3690        .is_err());
3691
3692        let result = DetectorResult {
3693            detector: "sample-detector".to_owned(),
3694            candidates: vec![second.clone(), first.clone()],
3695        };
3696        let ordered = result.into_candidates().expect("detector result valid");
3697        assert!(ordered[0].path < ordered[1].path);
3698
3699        let mut tampered = first.clone();
3700        tampered.id = format!("candidate:{}", "0".repeat(64));
3701        assert!(candidate_plan(vec![tampered])
3702            .validate()
3703            .expect_err("tampered candidate id rejected")
3704            .to_string()
3705            .contains("does not match detector"));
3706
3707        let foreign = DetectorResult {
3708            detector: "other-detector".to_owned(),
3709            candidates: vec![first.clone()],
3710        };
3711        assert!(foreign
3712            .validate()
3713            .expect_err("foreign detector candidate rejected")
3714            .to_string()
3715            .contains("returned candidate owned by"));
3716
3717        let yaml = candidate_plan(vec![first, second])
3718            .to_yaml()
3719            .expect("candidate plan serializes");
3720        assert!(yaml.contains("kind: independent"));
3721        assert!(yaml.contains("kind: excluded"));
3722        assert!(yaml.contains("raw-version:"));
3723        assert!(yaml.contains("diagnostics:"));
3724    }
3725
3726    #[test]
3727    fn changesets_projection_dedup_rejects_adapter_or_mode_conflicts() {
3728        for (adapter, mode) in [
3729            (Adapter::Cargo, ProjectionMode::Committed),
3730            (Adapter::Npm, ProjectionMode::Injected),
3731        ] {
3732            let mut config = candidate_plan(Vec::new()).inferred_config;
3733            let release_unit = config
3734                .release_units
3735                .get_mut("configured")
3736                .expect("configured release unit");
3737            release_unit.path = PathBuf::from("examples");
3738            release_unit.projections = vec![Projection {
3739                adapter: Adapter::Npm,
3740                file: PathBuf::from("sample.json"),
3741                mode: ProjectionMode::Committed,
3742                pointer: None,
3743            }];
3744            let mut candidate = candidate(
3745                "examples/sample.json",
3746                Some(CandidateResolution::Projection {
3747                    release_unit: "configured".to_owned(),
3748                    target_candidate: None,
3749                }),
3750            );
3751            let suggestion = candidate
3752                .projection
3753                .as_mut()
3754                .expect("projection suggestion");
3755            suggestion.adapter = adapter;
3756            suggestion.mode = mode;
3757            let mut discovery = Discovery {
3758                config,
3759                ..Discovery::default()
3760            };
3761
3762            let error = apply_changesets_candidate_resolutions(
3763                Path::new("."),
3764                &mut discovery,
3765                &[candidate],
3766            )
3767            .expect_err("same target with different semantics rejected");
3768            assert!(error
3769                .to_string()
3770                .contains("conflicts with existing projection"));
3771        }
3772    }
3773
3774    #[test]
3775    fn changesets_projection_rejects_an_unrelated_existing_owner() {
3776        let config = Config::from_yaml(
3777            r#"contract: contract-1
3778release-units:
3779  alpha:
3780    path: examples
3781    tags:
3782      primary: { role: primary, template: '{id}@{version}' }
3783  gamma:
3784    path: examples
3785    projections:
3786      - adapter: json
3787        file: shared.json
3788        mode: committed
3789        pointer: /version
3790    tags:
3791      primary: { role: primary, template: 'gamma@{version}' }
3792"#,
3793        )
3794        .expect("unrelated-owner config");
3795        let mut candidate = candidate(
3796            "examples/shared.json",
3797            Some(CandidateResolution::Projection {
3798                release_unit: "alpha".to_owned(),
3799                target_candidate: None,
3800            }),
3801        );
3802        candidate.projection = Some(CandidateProjectionSuggestion {
3803            adapter: Adapter::Json,
3804            path: candidate.path.clone(),
3805            mode: ProjectionMode::Committed,
3806            pointer: Some("/version".to_owned()),
3807        });
3808        let candidate_id = candidate.id.clone();
3809        let mut discovery = Discovery {
3810            config,
3811            ..Discovery::default()
3812        };
3813
3814        let error =
3815            apply_changesets_candidate_resolutions(Path::new("."), &mut discovery, &[candidate])
3816                .expect_err("unrelated projection ownership rejected");
3817        assert_eq!(
3818            error.to_string(),
3819            format!(
3820                "validation failed: Changesets discovery candidate {candidate_id} projection is already owned by release unit gamma, not alpha"
3821            )
3822        );
3823    }
3824
3825    #[test]
3826    fn rejects_plan_without_required_discovery_candidates() {
3827        let yaml = candidate_plan(Vec::new())
3828            .to_yaml()
3829            .expect("complete plan serializes");
3830        assert!(yaml.contains("discovery-candidates: []"));
3831        let incomplete = yaml.replace("discovery-candidates: []\n", "");
3832        let error = serde_yaml::from_str::<InitPlan>(&incomplete)
3833            .expect_err("missing discovery candidate collection rejected");
3834        assert!(error
3835            .to_string()
3836            .contains("missing field `discovery-candidates`"));
3837    }
3838
3839    #[test]
3840    fn empty_inferred_config_obeys_its_published_schema_shape() {
3841        let mut plan = candidate_plan(vec![candidate("examples/sample.json", None)]);
3842        plan.inferred_config = Config::default();
3843        plan.validate().expect("schema-compatible empty config");
3844
3845        plan.inferred_config
3846            .discovery
3847            .excluded_paths
3848            .push(ExcludedPathReceipt {
3849                detector: "sample-detector".to_owned(),
3850                path: PathBuf::from("examples/sample.json"),
3851                evidence_digest: DIGEST.to_owned(),
3852            });
3853        assert!(plan
3854            .validate()
3855            .expect_err("discovery is forbidden on an empty inferred config")
3856            .to_string()
3857            .contains("may only contain schema, contract, settings, and release-units"));
3858
3859        plan.inferred_config.discovery.excluded_paths.clear();
3860        plan.inferred_config.schema = Some("https://example.invalid/config.yml".to_owned());
3861        assert!(plan
3862            .validate()
3863            .expect_err("foreign schema is forbidden on an empty inferred config")
3864            .to_string()
3865            .contains("schema must be"));
3866    }
3867
3868    #[test]
3869    fn candidate_tag_templates_use_the_shared_contract() {
3870        for template in ["v{version}", "{version}-{version}", "{id}-{id}@{version}"] {
3871            let mut invalid = candidate("examples/template.json", None);
3872            invalid.tag.as_mut().expect("tag suggestion").template = template.to_owned();
3873            assert!(candidate_plan(vec![invalid])
3874                .validate()
3875                .expect_err("invalid candidate tag template rejected")
3876                .to_string()
3877                .contains("tag template"));
3878        }
3879    }
3880
3881    #[test]
3882    fn published_init_schema_carries_candidate_resolution_variants() {
3883        let schema: serde_yaml::Value =
3884            serde_yaml::from_str(include_str!("../../../schemas/init-plan.yml"))
3885                .expect("initialization-plan schema parses");
3886        assert!(schema["required"]
3887            .as_sequence()
3888            .expect("required plan fields")
3889            .iter()
3890            .any(|field| field.as_str() == Some("discovery-candidates")));
3891        let variants = schema["$defs"]["candidate-resolution"]["oneOf"]
3892            .as_sequence()
3893            .expect("candidate resolution variants");
3894        assert_eq!(variants.len(), 3);
3895        let kinds = variants
3896            .iter()
3897            .map(|variant| {
3898                variant["properties"]["kind"]["const"]
3899                    .as_str()
3900                    .expect("resolution kind")
3901            })
3902            .collect::<BTreeSet<_>>();
3903        assert_eq!(
3904            kinds,
3905            BTreeSet::from(["excluded", "independent", "projection"])
3906        );
3907        assert_eq!(
3908            schema["$defs"]["discovery-candidate"]["properties"]["tag"]["properties"]["template"]
3909                ["pattern"]
3910                .as_str(),
3911            Some(
3912                r"^(?![\s\S]*v\{version\})(?![\s\S]*\{version\}[\s\S]*\{version\})(?![\s\S]*\{id\}[\s\S]*\{id\})(?=[\s\S]*\{version\})[\s\S]*$"
3913            )
3914        );
3915    }
3916
3917    #[test]
3918    fn accepts_configured_and_same_plan_projection_targets() {
3919        let configured = candidate(
3920            "examples/configured.json",
3921            Some(CandidateResolution::Projection {
3922                release_unit: "configured".to_owned(),
3923                target_candidate: None,
3924            }),
3925        );
3926        let independent = candidate(
3927            "examples/independent.json",
3928            Some(CandidateResolution::Independent {
3929                release_unit: "planned".to_owned(),
3930            }),
3931        );
3932        let projection = candidate(
3933            "examples/projection.json",
3934            Some(CandidateResolution::Projection {
3935                release_unit: "planned".to_owned(),
3936                target_candidate: Some(independent.id.clone()),
3937            }),
3938        );
3939        candidate_plan(vec![configured, independent, projection])
3940            .validate()
3941            .expect("both projection target kinds accepted");
3942    }
3943
3944    #[test]
3945    fn changesets_materializes_any_unrepresented_candidate_projection_once() {
3946        let config = candidate_plan(Vec::new()).inferred_config;
3947        let mut generic = candidate(
3948            "configured/metadata.json",
3949            Some(CandidateResolution::Projection {
3950                release_unit: "configured".to_owned(),
3951                target_candidate: None,
3952            }),
3953        );
3954        generic.projection = Some(CandidateProjectionSuggestion {
3955            adapter: Adapter::Json,
3956            path: generic.path.clone(),
3957            mode: ProjectionMode::Committed,
3958            pointer: Some("/version".to_owned()),
3959        });
3960
3961        let mut discovery = Discovery {
3962            config,
3963            ..Discovery::default()
3964        };
3965        apply_changesets_candidate_resolutions(
3966            Path::new("."),
3967            &mut discovery,
3968            std::slice::from_ref(&generic),
3969        )
3970        .expect("generic projection materialized");
3971        assert_eq!(
3972            discovery.config.release_units["configured"]
3973                .projections
3974                .len(),
3975            1
3976        );
3977        assert_eq!(
3978            discovery.config.release_units["configured"].projections[0]
3979                .pointer
3980                .as_deref(),
3981            Some("/version")
3982        );
3983
3984        apply_changesets_candidate_resolutions(
3985            Path::new("."),
3986            &mut discovery,
3987            std::slice::from_ref(&generic),
3988        )
3989        .expect("represented projection not duplicated");
3990        assert_eq!(
3991            discovery.config.release_units["configured"]
3992                .projections
3993                .len(),
3994            1
3995        );
3996    }
3997
3998    #[test]
3999    fn rejects_duplicate_creators_and_absent_projection_targets() {
4000        let first = candidate(
4001            "examples/first.json",
4002            Some(CandidateResolution::Independent {
4003                release_unit: "duplicate".to_owned(),
4004            }),
4005        );
4006        let second = candidate(
4007            "examples/second.json",
4008            Some(CandidateResolution::Independent {
4009                release_unit: "duplicate".to_owned(),
4010            }),
4011        );
4012        assert!(candidate_plan(vec![first, second])
4013            .validate()
4014            .expect_err("duplicate creators rejected")
4015            .to_string()
4016            .contains("duplicate creator"));
4017
4018        let absent = candidate(
4019            "examples/absent.json",
4020            Some(CandidateResolution::Projection {
4021                release_unit: "missing".to_owned(),
4022                target_candidate: None,
4023            }),
4024        );
4025        assert!(candidate_plan(vec![absent])
4026            .validate()
4027            .expect_err("absent configured target rejected")
4028            .to_string()
4029            .contains("absent configured release unit"));
4030    }
4031
4032    #[test]
4033    fn rejects_projection_cycles_and_release_unit_mismatches() {
4034        let mut first = candidate("examples/first.json", None);
4035        let mut second = candidate("examples/second.json", None);
4036        first.resolution = Some(CandidateResolution::Projection {
4037            release_unit: "planned".to_owned(),
4038            target_candidate: Some(second.id.clone()),
4039        });
4040        second.resolution = Some(CandidateResolution::Projection {
4041            release_unit: "planned".to_owned(),
4042            target_candidate: Some(first.id.clone()),
4043        });
4044        assert!(candidate_plan(vec![first, second])
4045            .validate()
4046            .expect_err("candidate cycle rejected")
4047            .to_string()
4048            .contains("projection cycle"));
4049
4050        let independent = candidate(
4051            "examples/independent.json",
4052            Some(CandidateResolution::Independent {
4053                release_unit: "planned".to_owned(),
4054            }),
4055        );
4056        let mismatched = candidate(
4057            "examples/mismatched.json",
4058            Some(CandidateResolution::Projection {
4059                release_unit: "other".to_owned(),
4060                target_candidate: Some(independent.id.clone()),
4061            }),
4062        );
4063        assert!(candidate_plan(vec![independent, mismatched])
4064            .validate()
4065            .expect_err("mismatched target rejected")
4066            .to_string()
4067            .contains("target"));
4068
4069        let independent = candidate(
4070            "examples/independent.json",
4071            Some(CandidateResolution::Independent {
4072                release_unit: "planned".to_owned(),
4073            }),
4074        );
4075        let intermediate = candidate(
4076            "examples/intermediate.json",
4077            Some(CandidateResolution::Projection {
4078                release_unit: "planned".to_owned(),
4079                target_candidate: Some(independent.id.clone()),
4080            }),
4081        );
4082        let chained = candidate(
4083            "examples/chained.json",
4084            Some(CandidateResolution::Projection {
4085                release_unit: "planned".to_owned(),
4086                target_candidate: Some(intermediate.id.clone()),
4087            }),
4088        );
4089        assert!(candidate_plan(vec![independent, intermediate, chained])
4090            .validate()
4091            .expect_err("projection chain rejected")
4092            .to_string()
4093            .contains("not an independent creator"));
4094    }
4095}