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::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        || projections.iter().enumerate().any(|(index, item)| {
94            item.package.is_empty()
95                || item.previous.is_empty()
96                || item.next.is_empty()
97                || projections[..index]
98                    .iter()
99                    .any(|other| other.package == item.package)
100        })
101    {
102        return Err(LockProjectionError::InvalidSelection);
103    }
104    let mut expected: toml::Value =
105        toml::from_str(before).map_err(|source| LockProjectionError::Parse {
106            input: "before",
107            source,
108        })?;
109    let packages = expected
110        .get_mut("package")
111        .and_then(toml::Value::as_array_mut)
112        .ok_or(LockProjectionError::MissingPackages)?;
113    for projection in projections {
114        let mut matches = 0;
115        for package in packages.iter_mut().filter(|package| {
116            package.get("name").and_then(toml::Value::as_str) == Some(projection.package)
117                && package.get("source").is_none()
118        }) {
119            matches += 1;
120            if package.get("version").and_then(toml::Value::as_str) != Some(projection.previous) {
121                return Err(LockProjectionError::PreviousVersion {
122                    package: projection.package.to_owned(),
123                });
124            }
125            package["version"] = toml::Value::String(projection.next.to_owned());
126        }
127        if matches != 1 {
128            return Err(LockProjectionError::PackageSelection {
129                package: projection.package.to_owned(),
130                matches,
131            });
132        }
133    }
134    let actual: toml::Value =
135        toml::from_str(after).map_err(|source| LockProjectionError::Parse {
136            input: "after",
137            source,
138        })?;
139    if expected != actual {
140        return Err(LockProjectionError::ChangedInputs);
141    }
142    Ok(())
143}
144
145#[cfg(test)]
146mod tests;