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 pub added: Vec<String>,
11 pub removed: Vec<String>,
13 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 let base_versions = versions_by_name(base);
49 let head_versions = versions_by_name(head);
50 let mut changed = Vec::new();
51 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 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 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 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}