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, fmt};
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)]
19#[non_exhaustive]
20pub enum LockProjectionError {
21    /// No changes selected, or a name/version is empty or a name repeated.
22    InvalidSelection,
23    /// One selected input is not valid TOML.
24    Parse {
25        /// `before` or `after` input role.
26        input: &'static str,
27        /// Original parser error.
28        source: toml::de::Error,
29    },
30    /// The saved lockfile has no package array.
31    MissingPackages,
32    /// A name must identify exactly one unsourced package.
33    PackageSelection {
34        /// Requested workspace package.
35        package: String,
36        /// Number of matching unsourced records.
37        matches: usize,
38    },
39    /// The saved selected package does not have its expected prior version.
40    PreviousVersion {
41        /// Requested workspace package.
42        package: String,
43    },
44    /// The after input differs from the exact permitted projection.
45    ChangedInputs,
46}
47
48impl fmt::Display for LockProjectionError {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        match self {
51            Self::InvalidSelection => f.write_str("invalid workspace version selection"),
52            Self::Parse { input, source } => write!(f, "invalid {input} lockfile: {source}"),
53            Self::MissingPackages => f.write_str("lockfile has no package array"),
54            Self::PackageSelection { package, matches } => write!(
55                f,
56                "workspace package {package} has {matches} unsourced records; expected one"
57            ),
58            Self::PreviousVersion { package } => {
59                write!(f, "unexpected previous workspace version for {package}")
60            }
61            Self::ChangedInputs => {
62                f.write_str("lockfile differs from the selected workspace version projection")
63            }
64        }
65    }
66}
67impl std::error::Error for LockProjectionError {
68    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
69        match self {
70            Self::Parse { source, .. } => Some(source),
71            _ => None,
72        }
73    }
74}
75
76/// Verify that only explicitly selected unsourced package versions changed.
77///
78/// This is independent of any renderer. Parsed equality permits TOML comments
79/// and table-field ordering differences, but preserves array order, dependencies,
80/// source/checksum records and every other value. Registry records with the same
81/// name are never projected. No file IO, dependency resolution, version selection
82/// or release effect occurs. Callers own byte limits and saved-input custody.
83///
84/// # Errors
85/// Refuses invalid/duplicate selections, parse failures, missing/ambiguous workspace
86/// packages, unexpected prior versions and any other parsed difference.
87pub fn verify_workspace_version_projection(
88    before: &str,
89    after: &str,
90    projections: &[WorkspaceVersionProjection<'_>],
91) -> Result<(), LockProjectionError> {
92    if projections.is_empty() {
93        return Err(LockProjectionError::InvalidSelection);
94    }
95    let mut selected = HashMap::with_capacity(projections.len());
96    for item in projections {
97        if item.package.is_empty()
98            || item.previous.is_empty()
99            || item.next.is_empty()
100            || selected.insert(item.package, Vec::new()).is_some()
101        {
102            return Err(LockProjectionError::InvalidSelection);
103        }
104    }
105    let mut expected: toml::Value =
106        toml::from_str(before).map_err(|source| LockProjectionError::Parse {
107            input: "before",
108            source,
109        })?;
110    let packages = expected
111        .get_mut("package")
112        .and_then(toml::Value::as_array_mut)
113        .ok_or(LockProjectionError::MissingPackages)?;
114    for (index, package) in packages.iter().enumerate() {
115        if package.get("source").is_none()
116            && let Some(matches) = package
117                .get("name")
118                .and_then(toml::Value::as_str)
119                .and_then(|name| selected.get_mut(name))
120        {
121            matches.push(index);
122        }
123    }
124    for projection in projections {
125        let matches = &selected[projection.package];
126        for &index in matches {
127            let package = &mut packages[index];
128            if package.get("version").and_then(toml::Value::as_str) != Some(projection.previous) {
129                return Err(LockProjectionError::PreviousVersion {
130                    package: projection.package.to_owned(),
131                });
132            }
133            package["version"] = toml::Value::String(projection.next.to_owned());
134        }
135        if matches.len() != 1 {
136            return Err(LockProjectionError::PackageSelection {
137                package: projection.package.to_owned(),
138                matches: matches.len(),
139            });
140        }
141    }
142    let actual: toml::Value =
143        toml::from_str(after).map_err(|source| LockProjectionError::Parse {
144            input: "after",
145            source,
146        })?;
147    if expected != actual {
148        return Err(LockProjectionError::ChangedInputs);
149    }
150    Ok(())
151}
152
153#[cfg(test)]
154mod tests;