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    if projections.is_empty() {
71        return Err(LockProjectionError::InvalidSelection);
72    }
73    let mut selected = HashMap::with_capacity(projections.len());
74    for item in projections {
75        if item.package.is_empty()
76            || item.previous.is_empty()
77            || item.next.is_empty()
78            || selected.insert(item.package, Vec::new()).is_some()
79        {
80            return Err(LockProjectionError::InvalidSelection);
81        }
82    }
83    let mut expected: toml::Value =
84        toml::from_str(before).map_err(|source| LockProjectionError::Parse {
85            input: "before",
86            source,
87        })?;
88    let packages = expected
89        .get_mut("package")
90        .and_then(toml::Value::as_array_mut)
91        .ok_or(LockProjectionError::MissingPackages)?;
92    for (index, package) in packages.iter().enumerate() {
93        if package.get("source").is_none()
94            && let Some(matches) = package
95                .get("name")
96                .and_then(toml::Value::as_str)
97                .and_then(|name| selected.get_mut(name))
98        {
99            matches.push(index);
100        }
101    }
102    for projection in projections {
103        let matches = &selected[projection.package];
104        for &index in matches {
105            let package = &mut packages[index];
106            if package.get("version").and_then(toml::Value::as_str) != Some(projection.previous) {
107                return Err(LockProjectionError::PreviousVersion {
108                    package: projection.package.to_owned(),
109                });
110            }
111            package["version"] = toml::Value::String(projection.next.to_owned());
112        }
113        if matches.len() != 1 {
114            return Err(LockProjectionError::PackageSelection {
115                package: projection.package.to_owned(),
116                matches: matches.len(),
117            });
118        }
119    }
120    let actual: toml::Value =
121        toml::from_str(after).map_err(|source| LockProjectionError::Parse {
122            input: "after",
123            source,
124        })?;
125    if expected != actual {
126        return Err(LockProjectionError::ChangedInputs);
127    }
128    Ok(())
129}
130
131#[cfg(test)]
132mod tests;