use std::collections::BTreeMap;
use super::types::{PackageChanged, PackageDiff, PackageEntry};
pub(super) fn canonical_name(raw: &str) -> String {
let lower = raw.to_ascii_lowercase();
let trimmed = lower.split('[').next().unwrap_or(&lower);
let mut out = String::with_capacity(trimmed.len());
let mut prev_dash = false;
for c in trimmed.chars() {
if c == '-' || c == '_' || c == '.' {
if !prev_dash {
out.push('-');
prev_dash = true;
}
} else {
out.push(c);
prev_dash = false;
}
}
out
}
pub fn compute_package_diff(a: &[PackageEntry], b: &[PackageEntry]) -> PackageDiff {
fn index(entries: &[PackageEntry]) -> BTreeMap<&str, &PackageEntry> {
let mut map = BTreeMap::new();
for entry in entries {
map.entry(entry.name.as_str()).or_insert(entry);
}
map
}
let map_a = index(a);
let map_b = index(b);
let mut added = Vec::new();
let mut removed = Vec::new();
let mut changed = Vec::new();
for (name, pkg_a) in &map_a {
match map_b.get(name) {
None => removed.push((*pkg_a).clone()),
Some(pkg_b) if pkg_a.version != pkg_b.version => {
changed.push(PackageChanged {
name: (*name).to_string(),
version_a: pkg_a.version.clone(),
version_b: pkg_b.version.clone(),
});
}
Some(_) => {}
}
}
for (name, pkg_b) in &map_b {
if !map_a.contains_key(name) {
added.push((*pkg_b).clone());
}
}
PackageDiff {
added,
removed,
changed,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn pkg(name: &str, version: &str) -> PackageEntry {
PackageEntry {
name: canonical_name(name),
version: version.to_string(),
display_name: name.to_string(),
}
}
#[test]
fn canonical_name_lowercases() {
assert_eq!(canonical_name("Requests"), "requests");
}
#[test]
fn canonical_name_collapses_separators() {
assert_eq!(canonical_name("zope.interface"), "zope-interface");
assert_eq!(canonical_name("flask_sqlalchemy"), "flask-sqlalchemy");
assert_eq!(canonical_name("foo..bar"), "foo-bar");
assert_eq!(canonical_name("foo._-bar"), "foo-bar");
}
#[test]
fn canonical_name_strips_extras() {
assert_eq!(canonical_name("requests[socks]"), "requests");
assert_eq!(canonical_name("Django[bcrypt,argon2]"), "django");
}
#[test]
fn canonical_name_passes_through_already_canonical() {
assert_eq!(canonical_name("numpy"), "numpy");
assert_eq!(canonical_name("pytest-cov"), "pytest-cov");
}
#[test]
fn diff_empty_inputs_produce_empty_diff() {
let d = compute_package_diff(&[], &[]);
assert!(d.added.is_empty() && d.removed.is_empty() && d.changed.is_empty());
}
#[test]
fn diff_identical_envs_produce_empty_diff() {
let a = vec![pkg("requests", "2.31.0"), pkg("numpy", "1.26.0")];
let b = a.clone();
let d = compute_package_diff(&a, &b);
assert!(d.added.is_empty() && d.removed.is_empty() && d.changed.is_empty());
}
#[test]
fn diff_detects_added() {
let a = vec![pkg("requests", "2.31.0")];
let b = vec![pkg("requests", "2.31.0"), pkg("pandas", "2.2.0")];
let d = compute_package_diff(&a, &b);
assert_eq!(d.added.len(), 1);
assert_eq!(d.added[0].name, "pandas");
assert!(d.removed.is_empty());
}
#[test]
fn diff_detects_removed() {
let a = vec![pkg("requests", "2.31.0"), pkg("numpy", "1.26.0")];
let b = vec![pkg("requests", "2.31.0")];
let d = compute_package_diff(&a, &b);
assert_eq!(d.removed.len(), 1);
assert_eq!(d.removed[0].name, "numpy");
assert!(d.added.is_empty());
}
#[test]
fn diff_detects_version_change() {
let a = vec![pkg("requests", "2.31.0")];
let b = vec![pkg("requests", "2.32.0")];
let d = compute_package_diff(&a, &b);
assert_eq!(d.changed.len(), 1);
assert_eq!(d.changed[0].version_a, "2.31.0");
assert_eq!(d.changed[0].version_b, "2.32.0");
}
#[test]
fn diff_normalises_name_match() {
let a = vec![pkg("Requests", "2.31.0")];
let b = vec![pkg("requests", "2.31.0")];
let d = compute_package_diff(&a, &b);
assert!(d.added.is_empty() && d.removed.is_empty() && d.changed.is_empty());
}
#[test]
fn diff_treats_extras_as_same_package() {
let a = vec![pkg("requests[socks]", "2.31.0")];
let b = vec![pkg("requests", "2.31.0")];
let d = compute_package_diff(&a, &b);
assert!(d.added.is_empty() && d.removed.is_empty() && d.changed.is_empty());
}
#[test]
fn diff_duplicate_canonical_names_first_wins() {
let a = vec![pkg("Foo", "1.0"), pkg("foo", "2.0")]; let b = vec![pkg("foo", "1.0")];
let d = compute_package_diff(&a, &b);
assert!(d.added.is_empty(), "no additions expected");
assert!(d.removed.is_empty(), "no removals expected");
assert!(d.changed.is_empty(), "first-wins kept 1.0, identical to B");
}
#[test]
fn diff_mixed_add_remove_change_alphabetic() {
let a = vec![pkg("alpha", "1.0"), pkg("beta", "2.0"), pkg("gamma", "3.0")];
let b = vec![
pkg("alpha", "1.0"), pkg("beta", "2.1"), pkg("delta", "4.0"), ]; let d = compute_package_diff(&a, &b);
assert_eq!(d.added.len(), 1);
assert_eq!(d.added[0].name, "delta");
assert_eq!(d.removed.len(), 1);
assert_eq!(d.removed[0].name, "gamma");
assert_eq!(d.changed.len(), 1);
assert_eq!(d.changed[0].name, "beta");
}
}