Skip to main content

ic_build/cargo/lock_projection/
mod.rs

1//! Strict semantic lockfile comparison after caller-selected workspace version changes.
2
3use std::collections::HashMap;
4
5/// One unsourced workspace package whose version may change; all other parsed
6/// lockfile content must remain identical. Release version policy is caller-owned.
7#[derive(Clone, Copy, Debug)]
8pub struct WorkspaceVersionProjection<'a> {
9    /// Exact package name, selecting only records with no source field.
10    pub package: &'a str,
11    /// Expected version in the saved input.
12    pub previous: &'a str,
13    /// Only permitted replacement version.
14    pub next: &'a str,
15}
16
17/// Strict version projection failed without changing either input.
18#[derive(Debug, thiserror::Error)]
19#[non_exhaustive]
20pub enum LockProjectionError {
21    /// No changes selected, or a name/version is empty or a name repeated.
22    #[error("invalid workspace version selection")]
23    InvalidSelection,
24    /// One selected input is not valid TOML.
25    #[error("invalid {input} lockfile: {source}")]
26    Parse {
27        /// `before` or `after` input role.
28        input: &'static str,
29        /// Original parser error.
30        source: toml::de::Error,
31    },
32    /// The saved lockfile has no package array.
33    #[error("lockfile has no package array")]
34    MissingPackages,
35    /// A name must identify exactly one unsourced package.
36    #[error("workspace package {package} has {matches} unsourced records; expected one")]
37    PackageSelection {
38        /// Requested workspace package.
39        package: String,
40        /// Number of matching unsourced records.
41        matches: usize,
42    },
43    /// The saved selected package does not have its expected prior version.
44    #[error("unexpected previous workspace version for {package}")]
45    PreviousVersion {
46        /// Requested workspace package.
47        package: String,
48    },
49    /// The after input differs from the exact permitted projection.
50    #[error("lockfile differs from the selected workspace version projection")]
51    ChangedInputs,
52}
53
54/// Verify that only explicitly selected unsourced package versions changed.
55///
56/// This is independent of any renderer. Parsed equality permits TOML comments
57/// and table-field ordering differences, but preserves array order, dependencies,
58/// source/checksum records and every other value. Registry records with the same
59/// name are never projected. No file IO, dependency resolution, version selection
60/// or release effect occurs. Callers own byte limits and saved-input custody.
61///
62/// # Errors
63/// Refuses invalid/duplicate selections, parse failures, missing/ambiguous workspace
64/// packages, unexpected prior versions and any other parsed difference.
65pub fn verify_workspace_version_projection(
66    before: &str,
67    after: &str,
68    projections: &[WorkspaceVersionProjection<'_>],
69) -> Result<(), LockProjectionError> {
70    verify_projection(before, after, projections, false)
71}
72
73/// Verify workspace package versions plus exact unqualified local dependency references.
74///
75/// This release projection additionally replaces `name previous` with `name next`
76/// in package dependency arrays. Source-qualified references and every other parsed
77/// value remain immutable. The stricter version-only verifier is unchanged.
78/// No filesystem, process, registry or release effects occur.
79///
80/// # Errors
81/// Returns the same typed selection, parsing and changed-input errors as
82/// [`verify_workspace_version_projection`].
83pub fn verify_workspace_release_projection(
84    before: &str,
85    after: &str,
86    projections: &[WorkspaceVersionProjection<'_>],
87) -> Result<(), LockProjectionError> {
88    verify_projection(before, after, projections, true)
89}
90
91fn verify_projection(
92    before: &str,
93    after: &str,
94    projections: &[WorkspaceVersionProjection<'_>],
95    references: bool,
96) -> Result<(), LockProjectionError> {
97    if projections.is_empty() {
98        return Err(LockProjectionError::InvalidSelection);
99    }
100    let mut selected = HashMap::with_capacity(projections.len());
101    for item in projections {
102        if item.package.is_empty()
103            || item.previous.is_empty()
104            || item.next.is_empty()
105            || selected.insert(item.package, Vec::new()).is_some()
106        {
107            return Err(LockProjectionError::InvalidSelection);
108        }
109    }
110    let mut expected: toml::Value =
111        toml::from_str(before).map_err(|source| LockProjectionError::Parse {
112            input: "before",
113            source,
114        })?;
115    let packages = expected
116        .get_mut("package")
117        .and_then(toml::Value::as_array_mut)
118        .ok_or(LockProjectionError::MissingPackages)?;
119    for (index, package) in packages.iter().enumerate() {
120        if package.get("source").is_none()
121            && let Some(matches) = package
122                .get("name")
123                .and_then(toml::Value::as_str)
124                .and_then(|name| selected.get_mut(name))
125        {
126            matches.push(index);
127        }
128    }
129    for projection in projections {
130        let matches = &selected[projection.package];
131        for &index in matches {
132            let package = &mut packages[index];
133            if package.get("version").and_then(toml::Value::as_str) != Some(projection.previous) {
134                return Err(LockProjectionError::PreviousVersion {
135                    package: projection.package.to_owned(),
136                });
137            }
138            package["version"] = toml::Value::String(projection.next.to_owned());
139        }
140        if matches.len() != 1 {
141            return Err(LockProjectionError::PackageSelection {
142                package: projection.package.to_owned(),
143                matches: matches.len(),
144            });
145        }
146    }
147    if references {
148        let mut replacements = HashMap::with_capacity(projections.len());
149        for item in projections {
150            // Version syntax is caller-owned here; retain the first exact match
151            // even when distinct opaque selections form the same reference.
152            replacements
153                .entry(format!("{} {}", item.package, item.previous))
154                .or_insert_with(|| format!("{} {}", item.package, item.next));
155        }
156        for package in packages {
157            if let Some(dependencies) = package
158                .get_mut("dependencies")
159                .and_then(toml::Value::as_array_mut)
160            {
161                for dependency in dependencies {
162                    if let Some(next) = dependency.as_str().and_then(|name| replacements.get(name))
163                    {
164                        *dependency = toml::Value::String(next.clone());
165                    }
166                }
167            }
168        }
169    }
170    let actual: toml::Value =
171        toml::from_str(after).map_err(|source| LockProjectionError::Parse {
172            input: "after",
173            source,
174        })?;
175    if expected != actual {
176        return Err(LockProjectionError::ChangedInputs);
177    }
178    Ok(())
179}
180
181#[cfg(test)]
182mod tests;