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