1use std::{
2 cmp,
3 collections::BTreeMap,
4 num::NonZeroUsize,
5};
6
7use itertools::EitherOrBoth;
8
9use crate::{
10 Version,
11 matching::match_version_amounts,
12 model::{
13 Diff,
14 DiffStatus,
15 VersionAmount,
16 VersionDiff,
17 },
18 snapshot::{
19 Package,
20 PackageSnapshot,
21 },
22 version::VersionChangeOrdering,
23};
24
25#[must_use]
26pub fn diff_snapshots(
27 old: &PackageSnapshot,
28 new: &PackageSnapshot,
29) -> Vec<Diff> {
30 let mut diffs = build_package_diffs(&old.packages, &new.packages);
31 canonicalize_diffs(&mut diffs);
32 diffs
33}
34
35fn build_package_diffs<'a>(
36 packages_old: impl IntoIterator<Item = &'a Package>,
37 packages_new: impl IntoIterator<Item = &'a Package>,
38) -> Vec<Diff> {
39 let versions_by_name = collect_package_versions(packages_old, packages_new);
40 generate_diffs_from_version_map(versions_by_name)
41}
42
43fn canonicalize_diffs(diffs: &mut [Diff]) {
44 diffs.sort_by(|left, right| left.name.cmp(&right.name));
45}
46
47fn collect_package_versions<'a>(
48 old: impl IntoIterator<Item = &'a Package>,
49 new: impl IntoIterator<Item = &'a Package>,
50) -> BTreeMap<String, PackageVersions> {
51 let mut packages = BTreeMap::<String, PackageVersions>::new();
52 let mut old_count = 0usize;
53 let mut new_count = 0usize;
54
55 for package in old {
56 old_count += 1;
57 packages
58 .entry(package.name.clone())
59 .or_default()
60 .old
61 .add(package.version.clone());
62 }
63
64 for package in new {
65 new_count += 1;
66 packages
67 .entry(package.name.clone())
68 .or_default()
69 .new
70 .add(package.version.clone());
71 }
72
73 debug_assert_eq!(
74 old_count + new_count,
75 packages
76 .values()
77 .map(PackageVersions::total_amount)
78 .sum::<usize>(),
79 );
80
81 packages
82}
83
84#[derive(Default)]
85struct PackageVersions {
86 old: VersionAmounts,
87 new: VersionAmounts,
88}
89
90impl PackageVersions {
91 fn total_amount(&self) -> usize {
92 self.old.total_amount() + self.new.total_amount()
93 }
94}
95
96#[derive(Default)]
97struct VersionAmounts {
98 amounts: BTreeMap<Version, NonZeroUsize>,
99}
100
101impl VersionAmounts {
102 fn add(&mut self, version: Version) {
103 self
104 .amounts
105 .entry(version)
106 .and_modify(|amount| *amount = increment_amount(*amount))
107 .or_insert(NonZeroUsize::MIN);
108 }
109
110 fn is_empty(&self) -> bool {
111 self.amounts.is_empty()
112 }
113
114 fn get(&self, version: &Version) -> Option<NonZeroUsize> {
115 self.amounts.get(version).copied()
116 }
117
118 fn contains(&self, version: &Version) -> bool {
119 self.amounts.contains_key(version)
120 }
121
122 fn iter(&self) -> impl Iterator<Item = (&Version, NonZeroUsize)> {
123 self
124 .amounts
125 .iter()
126 .map(|(version, amount)| (version, *amount))
127 }
128
129 fn total_amount(&self) -> usize {
130 self.amounts.values().map(|amount| amount.get()).sum()
131 }
132}
133
134fn increment_amount(amount: NonZeroUsize) -> NonZeroUsize {
135 amount
136 .get()
137 .checked_add(1)
138 .and_then(NonZeroUsize::new)
139 .unwrap_or_else(|| panic!("version amount overflowed usize"))
140}
141
142struct VersionChanges {
143 diffs: Vec<VersionDiff>,
144 has_omitted_versions: bool,
145}
146
147fn collect_version_changes(
148 old_versions: &VersionAmounts,
149 new_versions: &VersionAmounts,
150) -> VersionChanges {
151 let mut old_only = Vec::new();
152 let mut new_only = Vec::new();
153 let mut amount_diffs = Vec::new();
154 let mut has_omitted_versions = false;
155
156 for (version, old_amount) in old_versions.iter() {
157 match new_versions.get(version) {
158 Some(new_amount) if old_amount == new_amount => {
159 has_omitted_versions = true;
160 },
161 Some(new_amount) => {
162 amount_diffs.push(VersionDiff::AmountChanged {
163 version: version.clone(),
164 old_amount,
165 new_amount,
166 });
167 },
168 None => old_only.push(VersionAmount::new(version.clone(), old_amount)),
169 }
170 }
171
172 for (version, new_amount) in new_versions.iter() {
173 if !old_versions.contains(version) {
174 new_only.push(VersionAmount::new(version.clone(), new_amount));
175 }
176 }
177
178 let mut diffs = version_diffs(&old_only, &new_only);
179 diffs.extend(amount_diffs);
180
181 VersionChanges {
182 diffs,
183 has_omitted_versions,
184 }
185}
186
187fn generate_diffs_from_version_map(
188 packages: BTreeMap<String, PackageVersions>,
189) -> Vec<Diff> {
190 let mut result = Vec::with_capacity(packages.len());
191
192 for (name, package_versions) in packages {
193 let version_changes =
194 collect_version_changes(&package_versions.old, &package_versions.new);
195
196 let status = if version_changes.diffs.is_empty() {
197 continue;
198 } else if package_versions.old.is_empty() {
199 DiffStatus::Added
200 } else if package_versions.new.is_empty() {
201 DiffStatus::Removed
202 } else {
203 determine_change_status(&version_changes.diffs)
204 .unwrap_or(DiffStatus::Changed)
205 };
206
207 result.push(Diff {
208 name,
209 versions: version_changes.diffs,
210 status,
211 has_omitted_versions: version_changes.has_omitted_versions,
212 });
213 }
214
215 result
216}
217
218fn version_diffs(
219 old_versions: &[VersionAmount],
220 new_versions: &[VersionAmount],
221) -> Vec<VersionDiff> {
222 match_version_amounts(old_versions, new_versions)
223 .into_iter()
224 .map(|ver_diff| {
225 match ver_diff {
226 EitherOrBoth::Left(old) => VersionDiff::Removed(old.clone()),
227 EitherOrBoth::Right(new) => VersionDiff::Added(new.clone()),
228 EitherOrBoth::Both(old, new) => {
229 VersionDiff::Changed {
230 old: old.clone(),
231 new: new.clone(),
232 }
233 },
234 }
235 })
236 .collect()
237}
238
239fn determine_change_status(
240 version_diffs: &[VersionDiff],
241) -> Option<DiffStatus> {
242 let mut saw_upgrade = false;
243 let mut saw_downgrade = false;
244 let mut saw_changed = false;
245
246 for ver_diff in version_diffs {
247 match ver_diff {
248 VersionDiff::Removed(_) => saw_downgrade = true,
249 VersionDiff::Added(_) => saw_upgrade = true,
250 VersionDiff::Changed { old, new } => {
251 match old.version.change_ordering(&new.version) {
252 VersionChangeOrdering::Ordered(cmp::Ordering::Less) => {
253 saw_upgrade = true;
254 },
255 VersionChangeOrdering::Ordered(cmp::Ordering::Greater) => {
256 saw_downgrade = true;
257 },
258 VersionChangeOrdering::Ordered(cmp::Ordering::Equal)
259 | VersionChangeOrdering::Unordered => {
260 saw_changed = true;
261 },
262 }
263 },
264 VersionDiff::AmountChanged { .. } => {
265 saw_changed = true;
266 },
267 }
268 if saw_upgrade && saw_downgrade {
269 break;
270 }
271 }
272
273 match (saw_upgrade, saw_downgrade, saw_changed) {
274 (true, true, _) => Some(DiffStatus::Mixed),
275 (true, false, _) => Some(DiffStatus::Upgraded),
276 (false, true, _) => Some(DiffStatus::Downgraded),
277 (false, false, true) => Some(DiffStatus::Changed),
278 (false, false, false) => None,
279 }
280}
281
282#[cfg(test)]
283mod tests {
284 use std::{
285 collections::{
286 BTreeMap,
287 HashSet,
288 },
289 num::NonZeroUsize,
290 };
291
292 use super::*;
293
294 fn nonzero(amount: usize) -> NonZeroUsize {
295 NonZeroUsize::new(amount)
296 .unwrap_or_else(|| panic!("test version amount must be nonzero"))
297 }
298
299 fn version_amount(version: &str, amount: usize) -> VersionAmount {
300 VersionAmount::new(version, nonzero(amount))
301 }
302
303 fn package_versions(old: &[&str], new: &[&str]) -> PackageVersions {
304 let mut versions = PackageVersions::default();
305 for version in old {
306 versions.old.add(Version::new(*version));
307 }
308 for version in new {
309 versions.new.add(Version::new(*version));
310 }
311 versions
312 }
313
314 fn version_map(
315 name: &str,
316 old: &[&str],
317 new: &[&str],
318 ) -> BTreeMap<String, PackageVersions> {
319 let mut packages = BTreeMap::new();
320 packages.insert(name.to_owned(), package_versions(old, new));
321 packages
322 }
323
324 fn diff_for(name: &str, old: &[&str], new: &[&str]) -> Diff {
325 let mut diffs =
326 generate_diffs_from_version_map(version_map(name, old, new));
327 canonicalize_diffs(&mut diffs);
328 assert_eq!(diffs.len(), 1);
329 diffs.remove(0)
330 }
331
332 fn old_side_count(diff: &Diff) -> usize {
333 diff
334 .versions
335 .iter()
336 .filter(|version_diff| {
337 matches!(
338 version_diff,
339 VersionDiff::Removed(_)
340 | VersionDiff::Changed { .. }
341 | VersionDiff::AmountChanged { .. }
342 )
343 })
344 .count()
345 }
346
347 fn new_side_count(diff: &Diff) -> usize {
348 diff
349 .versions
350 .iter()
351 .filter(|version_diff| {
352 matches!(
353 version_diff,
354 VersionDiff::Added(_)
355 | VersionDiff::Changed { .. }
356 | VersionDiff::AmountChanged { .. }
357 )
358 })
359 .count()
360 }
361
362 #[test]
363 fn generate_diffs_empty_paths() {
364 let packages = BTreeMap::new();
365 assert!(generate_diffs_from_version_map(packages).is_empty());
366 }
367
368 #[test]
369 fn generate_diffs_unchanged_package() {
370 assert!(
371 generate_diffs_from_version_map(version_map("package", &["1.0.0"], &[
372 "1.0.0"
373 ]))
374 .is_empty()
375 );
376 }
377
378 #[test]
379 fn generate_diffs_unchanged_duplicate_versions() {
380 assert!(
381 generate_diffs_from_version_map(version_map(
382 "package",
383 &["1.0.0", "1.0.0"],
384 &["1.0.0", "1.0.0"],
385 ))
386 .is_empty()
387 );
388 }
389
390 #[test]
391 fn generate_diffs_added_package() {
392 let diff = diff_for("new-pkg", &[], &["1.0.0"]);
393
394 assert_eq!(diff.name, "new-pkg");
395 assert_eq!(diff.status, DiffStatus::Added);
396 assert_eq!(diff.versions, vec![VersionDiff::Added(version_amount(
397 "1.0.0", 1
398 ))]);
399 }
400
401 #[test]
402 fn generate_diffs_removed_package() {
403 let diff = diff_for("old-pkg", &["1.0.0"], &[]);
404
405 assert_eq!(diff.name, "old-pkg");
406 assert_eq!(diff.status, DiffStatus::Removed);
407 assert_eq!(diff.versions, vec![VersionDiff::Removed(version_amount(
408 "1.0.0", 1
409 ))]);
410 }
411
412 #[test]
413 fn generate_diffs_reports_added_version_amounts() {
414 let diff = diff_for("new-pkg", &[], &["1.0.0", "1.0.0"]);
415
416 assert_eq!(diff.status, DiffStatus::Added);
417 assert_eq!(diff.versions, vec![VersionDiff::Added(version_amount(
418 "1.0.0", 2
419 ))]);
420 }
421
422 #[test]
423 fn generate_diffs_reports_removed_version_amounts() {
424 let diff = diff_for("old-pkg", &["1.0.0", "1.0.0"], &[]);
425
426 assert_eq!(diff.status, DiffStatus::Removed);
427 assert_eq!(diff.versions, vec![VersionDiff::Removed(version_amount(
428 "1.0.0", 2
429 ))]);
430 }
431
432 #[test]
433 fn generate_diffs_reports_changed_version_amounts() {
434 let diff = diff_for("pkg", &["1.0.0"], &["1.0.0", "1.0.0"]);
435
436 assert_eq!(diff.status, DiffStatus::Changed);
437 assert!(!diff.has_omitted_versions);
438 assert_eq!(diff.versions, vec![VersionDiff::AmountChanged {
439 version: Version::new("1.0.0"),
440 old_amount: nonzero(1),
441 new_amount: nonzero(2),
442 }]);
443 }
444
445 #[test]
446 fn generate_diffs_reports_version_change_amounts() {
447 let diff = diff_for("pkg", &["1.0.0"], &["2.0.0", "2.0.0"]);
448
449 assert_eq!(diff.status, DiffStatus::Upgraded);
450 assert_eq!(diff.versions, vec![VersionDiff::Changed {
451 old: version_amount("1.0.0", 1),
452 new: version_amount("2.0.0", 2),
453 }]);
454 }
455
456 #[test]
457 fn generate_diffs_upgraded() {
458 let diff = diff_for("pkg", &["1.0.0"], &["2.0.0"]);
459
460 assert_eq!(diff.status, DiffStatus::Upgraded);
461 assert_eq!(diff.versions, vec![VersionDiff::Changed {
462 old: version_amount("1.0.0", 1),
463 new: version_amount("2.0.0", 1),
464 }]);
465 }
466
467 #[test]
468 fn generate_diffs_reports_git_hash_only_change_as_changed() {
469 let diff = diff_for("sunsetr", &["0.11.1-946aa34"], &["0.11.1-3564204"]);
470
471 assert_eq!(diff.status, DiffStatus::Changed);
472 assert_eq!(diff.versions, vec![VersionDiff::Changed {
473 old: version_amount("0.11.1-946aa34", 1),
474 new: version_amount("0.11.1-3564204", 1),
475 }]);
476 }
477
478 #[test]
479 fn generate_diffs_uses_dated_component_before_git_hash_for_upgrade() {
480 let diff = diff_for("yazi", &["25.05.31pre20250531_946aa34"], &[
481 "25.05.31pre20250601_3564204",
482 ]);
483
484 assert_eq!(diff.status, DiffStatus::Upgraded);
485 }
486
487 #[test]
488 fn generate_diffs_downgraded() {
489 let diff = diff_for("pkg", &["2.0.0"], &["1.0.0"]);
490
491 assert_eq!(diff.status, DiffStatus::Downgraded);
492 }
493
494 #[test]
495 fn generate_diffs_upgrade_downgrade() {
496 let diff = diff_for("pkg", &["1.0", "5.0"], &["2.0", "4.0"]);
497
498 assert_eq!(diff.status, DiffStatus::Mixed);
499 }
500
501 #[test]
502 fn generate_diffs_multiple_packages() {
503 let mut packages = BTreeMap::new();
504 packages
505 .insert("pkg-a".to_owned(), package_versions(&["1.0.0"], &["2.0.0"]));
506 packages.insert("pkg-b".to_owned(), package_versions(&[], &["1.0.0"]));
507 packages
508 .insert("pkg-c".to_owned(), package_versions(&["1.0.0"], &["1.0.0"]));
509
510 let result = generate_diffs_from_version_map(packages);
511 let names: HashSet<_> =
512 result.iter().map(|diff| diff.name.as_str()).collect();
513
514 assert_eq!(result.len(), 2);
515 assert!(names.contains("pkg-a"));
516 assert!(names.contains("pkg-b"));
517 assert!(!names.contains("pkg-c"));
518 }
519
520 #[test]
521 fn generate_diffs_common_versions() {
522 let diff = diff_for("pkg", &["1.0.0", "2.0.0"], &["2.0.0", "3.0.0"]);
523
524 assert!(diff.has_omitted_versions);
525 assert_eq!(diff.status, DiffStatus::Upgraded);
526 }
527
528 #[test]
529 fn generate_diffs_many_versions() {
530 let old_versions: Vec<_> = (0..100)
531 .map(|index| Version::new(format!("1.{index}.{index}")))
532 .collect();
533 let new_versions: Vec<_> = (50..150)
534 .map(|index| Version::new(format!("1.{index}.{index}")))
535 .collect();
536 let mut versions = PackageVersions::default();
537 for version in old_versions {
538 versions.old.add(version);
539 }
540 for version in new_versions {
541 versions.new.add(version);
542 }
543 let mut packages = BTreeMap::new();
544 packages.insert("large-pkg".to_owned(), versions);
545
546 let result = generate_diffs_from_version_map(packages);
547
548 assert_eq!(result.len(), 1);
549 assert!(result[0].has_omitted_versions);
550 assert_eq!(old_side_count(&result[0]), 50);
551 assert_eq!(new_side_count(&result[0]), 50);
552 }
553}