Skip to main content

ic_build/cargo/release_metadata/
mod.rs

1//! Pure, format-preserving Cargo release candidates.
2//!
3//! Responsibility: edit explicitly selected version literals and verify lock projections.
4//! Does not own: release policy, source admission, filesystem custody or publication.
5//! Boundary: candidates are returned only after complete parsing and validation.
6
7use super::lock_projection::{
8    LockProjectionError, WorkspaceVersionProjection, verify_workspace_release_projection,
9};
10use std::{collections::HashSet, fmt, ops::Range};
11use toml_edit::{Document, Item, Value};
12
13/// One explicitly admitted workspace dependency alias and its exact requirements.
14#[derive(Clone, Copy, Debug)]
15pub struct DependencyVersionProjection<'a> {
16    /// Key beneath `workspace.dependencies`, including renamed dependencies.
17    pub alias: &'a str,
18    /// Exact requirement in the admitted input.
19    pub previous: &'a str,
20    /// Caller-selected replacement requirement.
21    pub next: &'a str,
22}
23
24/// A candidate could not be rendered; no caller input is changed.
25#[derive(Debug)]
26pub enum ReleaseMetadataError {
27    /// Missing, ambiguous or conflicting selected literals.
28    InvalidSelection(&'static str),
29    /// Complete TOML parsing failed.
30    Parse(toml_edit::TomlError),
31    /// The independent semantic lock projection refused the candidate.
32    Projection(LockProjectionError),
33}
34impl fmt::Display for ReleaseMetadataError {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        match self {
37            Self::InvalidSelection(reason) => f.write_str(reason),
38            Self::Parse(error) => error.fmt(f),
39            Self::Projection(error) => error.fmt(f),
40        }
41    }
42}
43impl std::error::Error for ReleaseMetadataError {
44    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
45        match self {
46            Self::InvalidSelection(_) => None,
47            Self::Parse(error) => Some(error),
48            Self::Projection(error) => Some(error),
49        }
50    }
51}
52
53const fn refuse(reason: &'static str) -> ReleaseMetadataError {
54    ReleaseMetadataError::InvalidSelection(reason)
55}
56fn stable(version: &str) -> bool {
57    let mut count = 0;
58    version.split('.').all(|part| {
59        count += 1;
60        !part.is_empty()
61            && part.bytes().all(|byte| byte.is_ascii_digit())
62            && (part == "0" || !part.starts_with('0'))
63    }) && count == 3
64}
65fn edit(
66    value: &Value,
67    previous: &str,
68    next: &str,
69) -> Result<(Range<usize>, String), ReleaseMetadataError> {
70    if value.as_str() != Some(previous) {
71        return Err(refuse("selected version differs from the admitted input"));
72    }
73    let span = value
74        .span()
75        .ok_or_else(|| refuse("selected literal has no source span"))?;
76    Ok((span, Value::from(next).to_string()))
77}
78fn apply(
79    input: &str,
80    mut edits: Vec<(Range<usize>, String)>,
81) -> Result<String, ReleaseMetadataError> {
82    edits.sort_by_key(|edit| std::cmp::Reverse(edit.0.start));
83    let mut output = input.to_owned();
84    let mut boundary = input.len();
85    for (span, replacement) in edits {
86        if span.end > boundary || input.get(span.clone()).is_none() {
87            return Err(refuse("overlapping or invalid selected literal spans"));
88        }
89        boundary = span.start;
90        output.replace_range(span, &replacement);
91    }
92    Document::parse(output.as_str()).map_err(ReleaseMetadataError::Parse)?;
93    Ok(output)
94}
95
96/// Render a workspace version and explicitly admitted local dependency requirements.
97///
98/// TOML owns syntax and spans. Every other byte (including comments, line endings,
99/// Unicode and external requirements) is retained. The caller establishes dependency
100/// ownership and selects replacement requirements; no filesystem or process runs.
101///
102/// # Errors
103/// Refuses invalid stable versions, malformed TOML, missing/duplicate aliases,
104/// conflicting prior literals or unrepresentable edits. No partial candidate escapes.
105pub fn render_workspace_manifest(
106    input: &str,
107    previous: &str,
108    next: &str,
109    dependencies: &[DependencyVersionProjection<'_>],
110) -> Result<String, ReleaseMetadataError> {
111    if !stable(previous) || !stable(next) {
112        return Err(refuse("expected canonical stable workspace versions"));
113    }
114    let document = Document::parse(input).map_err(ReleaseMetadataError::Parse)?;
115    let workspace = document
116        .as_item()
117        .get("workspace")
118        .ok_or_else(|| refuse("missing workspace"))?;
119    let version = workspace
120        .get("package")
121        .and_then(|item| item.get("version"))
122        .and_then(Item::as_value)
123        .ok_or_else(|| refuse("missing workspace.package.version"))?;
124    let mut edits = vec![edit(version, previous, next)?];
125    let mut seen = HashSet::new();
126    for dependency in dependencies {
127        if dependency.alias.is_empty()
128            || !seen.insert(dependency.alias)
129            || dependency.previous.is_empty()
130            || dependency.next.is_empty()
131        {
132            return Err(refuse("invalid or duplicate dependency selection"));
133        }
134        let version = workspace
135            .get("dependencies")
136            .and_then(|item| item.get(dependency.alias))
137            .and_then(|item| item.get("version"))
138            .and_then(Item::as_value)
139            .ok_or_else(|| refuse("missing selected dependency version"))?;
140        edits.push(edit(version, dependency.previous, dependency.next)?);
141    }
142    apply(input, edits)
143}
144
145/// Render exact unsourced package versions and unqualified local dependency references.
146///
147/// Source-qualified registry/Git references, checksums and all other bytes are
148/// preserved. The separate semantic projection checker admits the complete result.
149/// Callers retain input byte limits, saved originals, Cargo graph checks and rollback.
150///
151/// # Errors
152/// Refuses invalid stable versions, empty/duplicate selections, unsupported lock
153/// formats, malformed TOML, ambiguous/missing packages or conflicting prior values.
154pub fn render_workspace_lock(
155    input: &str,
156    projections: &[WorkspaceVersionProjection<'_>],
157) -> Result<String, ReleaseMetadataError> {
158    if projections.is_empty()
159        || projections
160            .iter()
161            .any(|item| !stable(item.previous) || !stable(item.next) || item.previous == item.next)
162    {
163        return Err(refuse("expected distinct canonical stable lock versions"));
164    }
165    // Establish package identities and prior values independently before rendering.
166    // The verifier will report the expected ChangedInputs for a nontrivial valid selection.
167    match verify_workspace_release_projection(input, input, projections) {
168        Err(LockProjectionError::ChangedInputs) => {}
169        Err(error) => return Err(ReleaseMetadataError::Projection(error)),
170        Ok(()) => return Err(refuse("lock selection changes no versions")),
171    }
172    let document = Document::parse(input).map_err(ReleaseMetadataError::Parse)?;
173    if !matches!(
174        document.as_item().get("version").and_then(Item::as_integer),
175        Some(3 | 4)
176    ) {
177        return Err(refuse("expected Cargo lock format 3 or 4"));
178    }
179    let packages = document
180        .as_item()
181        .get("package")
182        .and_then(Item::as_array_of_tables)
183        .ok_or_else(|| refuse("missing lock packages"))?;
184    let mut edits = Vec::new();
185    for package in packages {
186        if package.get("source").is_none()
187            && let Some(projection) = projections
188                .iter()
189                .find(|item| package.get("name").and_then(Item::as_str) == Some(item.package))
190        {
191            let value = package
192                .get("version")
193                .and_then(Item::as_value)
194                .ok_or_else(|| refuse("missing local package version"))?;
195            edits.push(edit(value, projection.previous, projection.next)?);
196        }
197        if let Some(dependencies) = package.get("dependencies").and_then(Item::as_array) {
198            for dependency in dependencies {
199                for projection in projections {
200                    let previous = format!("{} {}", projection.package, projection.previous);
201                    if dependency.as_str() == Some(previous.as_str()) {
202                        edits.push(edit(
203                            dependency,
204                            &previous,
205                            &format!("{} {}", projection.package, projection.next),
206                        )?);
207                    }
208                }
209            }
210        }
211    }
212    let output = apply(input, edits)?;
213    verify_workspace_release_projection(input, &output, projections)
214        .map_err(ReleaseMetadataError::Projection)?;
215    Ok(output)
216}
217
218#[cfg(test)]
219mod tests;