Skip to main content

dix_diff/
engine.rs

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}