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        for package in packages {
149            if let Some(dependencies) = package
150                .get_mut("dependencies")
151                .and_then(toml::Value::as_array_mut)
152            {
153                for dependency in dependencies {
154                    for projection in projections {
155                        if dependency.as_str()
156                            == Some(
157                                format!("{} {}", projection.package, projection.previous).as_str(),
158                            )
159                        {
160                            *dependency = toml::Value::String(format!(
161                                "{} {}",
162                                projection.package, projection.next
163                            ));
164                            break;
165                        }
166                    }
167                }
168            }
169        }
170    }
171    let actual: toml::Value =
172        toml::from_str(after).map_err(|source| LockProjectionError::Parse {
173            input: "after",
174            source,
175        })?;
176    if expected != actual {
177        return Err(LockProjectionError::ChangedInputs);
178    }
179    Ok(())
180}
181
182#[cfg(test)]
183mod tests;