Skip to main content

rustinel_core/
diff.rs

1use crate::errors::RustinelError;
2use crate::lockfile::{parse_lockfile, LockfileModel};
3use serde::{Deserialize, Serialize};
4use std::collections::{BTreeMap, BTreeSet};
5use std::path::Path;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct LockfileDiff {
9    /// Packages present in head but not base (`name@version`).
10    pub added: Vec<String>,
11    /// Packages present in base but not head.
12    pub removed: Vec<String>,
13    /// Crates present in both but with a different set of versions
14    /// (`name: v1 -> v2`).
15    pub changed: Vec<String>,
16}
17
18pub fn diff_lockfiles(base: &Path, head: &Path) -> Result<LockfileDiff, RustinelError> {
19    let base = parse_lockfile(base)?;
20    let head = parse_lockfile(head)?;
21    Ok(diff_models(&base, &head))
22}
23
24pub fn diff_models(base: &LockfileModel, head: &LockfileModel) -> LockfileDiff {
25    let base_ids: BTreeMap<String, ()> = base
26        .packages
27        .iter()
28        .map(|p| (p.id.to_string(), ()))
29        .collect();
30    let head_ids: BTreeMap<String, ()> = head
31        .packages
32        .iter()
33        .map(|p| (p.id.to_string(), ()))
34        .collect();
35
36    let mut added: Vec<String> = head_ids
37        .keys()
38        .filter(|k| !base_ids.contains_key(*k))
39        .cloned()
40        .collect();
41    let mut removed: Vec<String> = base_ids
42        .keys()
43        .filter(|k| !head_ids.contains_key(*k))
44        .cloned()
45        .collect();
46
47    // Version changes: same crate name, but the version multiset differs.
48    let base_versions = versions_by_name(base);
49    let head_versions = versions_by_name(head);
50    let mut changed = Vec::new();
51    // Crate names whose version multiset changed (present on both sides). A
52    // version bump is a *change*, not an add plus a remove, so these names are
53    // filtered out of `added`/`removed` below — otherwise a single upgrade would
54    // be triple-listed (added new version, removed old version, changed).
55    let mut changed_names: BTreeSet<&str> = BTreeSet::new();
56    for (name, head_vers) in &head_versions {
57        if let Some(base_vers) = base_versions.get(name) {
58            if base_vers != head_vers {
59                changed_names.insert(name.as_str());
60                changed.push(format!(
61                    "{name}: {} -> {}",
62                    base_vers.join("/"),
63                    head_vers.join("/")
64                ));
65            }
66        }
67    }
68    let name_of = |k: &str| k.split('@').next().unwrap_or(k).to_string();
69    added.retain(|k| !changed_names.contains(name_of(k).as_str()));
70    removed.retain(|k| !changed_names.contains(name_of(k).as_str()));
71
72    added.sort();
73    removed.sort();
74    changed.sort();
75    LockfileDiff {
76        added,
77        removed,
78        changed,
79    }
80}
81
82fn versions_by_name(lock: &LockfileModel) -> BTreeMap<String, Vec<String>> {
83    let mut map: BTreeMap<String, Vec<String>> = BTreeMap::new();
84    for p in &lock.packages {
85        map.entry(p.id.name.clone())
86            .or_default()
87            .push(p.id.version.clone());
88    }
89    for v in map.values_mut() {
90        // Semver-aware order for display (so 1.0.2 sorts before 1.0.10), with a
91        // lexical fallback for any non-semver token. The multiset equality check
92        // is order-independent across both sides, so this affects display only.
93        v.sort_by(
94            |a, b| match (semver::Version::parse(a), semver::Version::parse(b)) {
95                (Ok(va), Ok(vb)) => va.cmp(&vb),
96                _ => a.cmp(b),
97            },
98        );
99    }
100    map
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use crate::lockfile::parse_lockfile_str;
107    use std::path::PathBuf;
108
109    #[test]
110    fn detects_added_and_removed() {
111        let base = parse_lockfile_str(
112            PathBuf::from("base"),
113            "[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n",
114        )
115        .unwrap();
116        let head = parse_lockfile_str(
117            PathBuf::from("head"),
118            "[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n\n[[package]]\nname = \"openssl-sys\"\nversion = \"0.9.99\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n",
119        )
120        .unwrap();
121        let d = diff_models(&base, &head);
122        assert!(d.added.iter().any(|p| p.starts_with("openssl-sys@")));
123        assert!(d.removed.is_empty());
124    }
125
126    #[test]
127    fn detects_version_change() {
128        let base = parse_lockfile_str(
129            PathBuf::from("base"),
130            "[[package]]\nname = \"serde\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n",
131        )
132        .unwrap();
133        let head = parse_lockfile_str(
134            PathBuf::from("head"),
135            "[[package]]\nname = \"serde\"\nversion = \"1.0.1\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n",
136        )
137        .unwrap();
138        let d = diff_models(&base, &head);
139        assert_eq!(d.changed.len(), 1);
140        assert!(d.changed[0].contains("1.0.0 -> 1.0.1"));
141    }
142
143    fn pkg(name: &str, ver: &str) -> String {
144        format!(
145            "[[package]]\nname = \"{name}\"\nversion = \"{ver}\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\nchecksum = \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"\n\n"
146        )
147    }
148
149    #[test]
150    fn version_bump_is_only_changed_not_added_or_removed() {
151        // The canonical PR event: a single upgrade must land in `changed` only,
152        // never simultaneously in added (new version) and removed (old version).
153        let base = parse_lockfile_str(PathBuf::from("base"), &pkg("serde", "1.0.0")).unwrap();
154        let head = parse_lockfile_str(PathBuf::from("head"), &pkg("serde", "1.0.1")).unwrap();
155        let d = diff_models(&base, &head);
156        assert_eq!(d.changed.len(), 1);
157        assert!(
158            d.added.is_empty(),
159            "upgrade leaked into added: {:?}",
160            d.added
161        );
162        assert!(
163            d.removed.is_empty(),
164            "upgrade leaked into removed: {:?}",
165            d.removed
166        );
167    }
168
169    #[test]
170    fn changed_versions_display_in_semver_order() {
171        // 1.0.2 must sort before 1.0.10 in the display string (semver, not lexical).
172        let base = parse_lockfile_str(
173            PathBuf::from("base"),
174            &format!("{}{}", pkg("foo", "1.0.2"), pkg("foo", "1.0.10")),
175        )
176        .unwrap();
177        let head = parse_lockfile_str(
178            PathBuf::from("head"),
179            &format!("{}{}", pkg("foo", "1.0.2"), pkg("foo", "1.0.11")),
180        )
181        .unwrap();
182        let d = diff_models(&base, &head);
183        assert_eq!(d.changed.len(), 1);
184        assert!(
185            d.changed[0].contains("1.0.2/1.0.10"),
186            "versions not in semver order: {}",
187            d.changed[0]
188        );
189    }
190}