use std::cmp::Ordering;
use std::collections::{BTreeMap, BTreeSet};
use serde::Serialize;
use crate::rpmvercmp::compare_evr;
#[derive(Debug, PartialEq, Eq, Serialize)]
pub struct VersionChange {
pub name: String,
pub source_version: String,
pub target_version: String,
}
#[derive(Debug, Serialize)]
pub struct CompareResult {
pub added: Vec<String>,
pub removed: Vec<String>,
pub upgraded: Vec<VersionChange>,
pub downgraded: Vec<VersionChange>,
pub unchanged: Vec<String>,
}
pub fn split_entry(entry: &str) -> (&str, Option<&str>, Option<&str>) {
if let Some((name, version)) = entry.split_once(" >= ") {
return (name, Some(">="), Some(version));
}
if let Some((name, version)) = entry.split_once(" = ") {
return (name, Some("="), Some(version));
}
(entry, None, None)
}
fn split_solib(entry: &str) -> Option<(String, &str)> {
let open = entry.find('(')?;
if !entry[..open].contains(".so.") {
return None;
}
let close = entry[open..].find(')')? + open;
let content = &entry[open + 1..close];
if content.is_empty() {
return None;
}
let name = format!("{}(){}", &entry[..open], &entry[close + 1..]);
Some((name, content))
}
pub fn filter_self_deps(entries: Vec<String>, subpkg_names: &BTreeSet<String>) -> Vec<String> {
entries
.into_iter()
.filter(|entry| {
let (name, _, _) = split_entry(entry);
!subpkg_names.contains(name)
})
.collect()
}
pub fn diff(source: Vec<String>, target: Vec<String>) -> CompareResult {
let source_set: BTreeSet<String> = source.into_iter().collect();
let target_set: BTreeSet<String> = target.into_iter().collect();
let raw_added: BTreeSet<&String> = target_set.difference(&source_set).collect();
let raw_removed: BTreeSet<&String> = source_set.difference(&target_set).collect();
let unchanged: Vec<String> = source_set.intersection(&target_set).cloned().collect();
let mut removed_by_name: BTreeMap<&str, (&str, &str)> = BTreeMap::new();
for r in &raw_removed {
let (name, op, version) = split_entry(r);
if let (Some(op), Some(v)) = (op, version) {
removed_by_name.insert(name, (op, v));
}
}
let mut added = Vec::new();
let mut changed: Vec<VersionChange> = Vec::new();
let mut changed_names: BTreeSet<&str> = BTreeSet::new();
for a in &raw_added {
let (name, op, version) = split_entry(a);
match (op, version, removed_by_name.get(name)) {
(Some(target_op), Some(target_v), Some(&(source_op, source_v)))
if target_op == source_op =>
{
let prefix = if target_op == "=" { "" } else { ">= " };
changed.push(VersionChange {
name: name.to_string(),
source_version: format!("{prefix}{source_v}"),
target_version: format!("{prefix}{target_v}"),
});
changed_names.insert(name);
}
_ => added.push(a.to_string()),
}
}
let remaining_removed: Vec<String> = raw_removed
.iter()
.filter(|r| {
let (name, _, _) = split_entry(r);
!changed_names.contains(name)
})
.map(|r| r.to_string())
.collect();
let mut solib_removed_by_pattern: BTreeMap<String, (String, &str)> = BTreeMap::new();
for r in &remaining_removed {
if let Some((pattern, version)) = split_solib(r) {
solib_removed_by_pattern.insert(pattern, (r.clone(), version));
}
}
let mut solib_changed_originals: BTreeSet<String> = BTreeSet::new();
let mut final_added = Vec::new();
for a in &added {
if let Some((pattern, target_v)) = split_solib(a) {
if let Some((original, source_v)) = solib_removed_by_pattern.get(&pattern) {
changed.push(VersionChange {
name: pattern,
source_version: source_v.to_string(),
target_version: target_v.to_string(),
});
solib_changed_originals.insert(original.clone());
continue;
}
}
final_added.push(a.clone());
}
let removed: Vec<String> = remaining_removed
.into_iter()
.filter(|r| !solib_changed_originals.contains(r))
.collect();
changed.sort_by(|a, b| a.name.cmp(&b.name));
let mut upgraded = Vec::new();
let mut downgraded = Vec::new();
for c in changed {
let src = c
.source_version
.strip_prefix(">= ")
.unwrap_or(&c.source_version);
let tgt = c
.target_version
.strip_prefix(">= ")
.unwrap_or(&c.target_version);
match compare_evr(src, tgt) {
Ordering::Less => upgraded.push(c),
Ordering::Greater => downgraded.push(c),
Ordering::Equal => upgraded.push(c),
}
}
CompareResult {
added: final_added,
removed,
upgraded,
downgraded,
unchanged,
}
}
pub fn format_result(
result: &CompareResult,
label: &str,
source_branch: &str,
target_branch: &str,
show_unchanged: bool,
) -> String {
use std::fmt::Write;
let mut out = String::new();
if result.added.is_empty()
&& result.removed.is_empty()
&& result.upgraded.is_empty()
&& result.downgraded.is_empty()
{
writeln!(out, "No differences in {label}.").unwrap();
if show_unchanged && !result.unchanged.is_empty() {
writeln!(out).unwrap();
writeln!(out, "Unchanged:").unwrap();
for p in &result.unchanged {
writeln!(out, " {p}").unwrap();
}
}
return out;
}
let mut need_blank = false;
for (section_label, entries) in [
("Upgraded", &result.upgraded),
("Downgraded", &result.downgraded),
] {
if entries.is_empty() {
continue;
}
if need_blank {
writeln!(out).unwrap();
}
let name_w = entries.iter().map(|u| u.name.len()).max().unwrap();
let src_w = entries
.iter()
.map(|u| u.source_version.len())
.max()
.unwrap()
.max(source_branch.len());
let tgt_w = entries
.iter()
.map(|u| u.target_version.len())
.max()
.unwrap()
.max(target_branch.len());
let sep = format!(
"+-{}-+-{}-+-{}-+",
"-".repeat(name_w),
"-".repeat(src_w),
"-".repeat(tgt_w)
);
writeln!(out, "{section_label} ({source_branch} -> {target_branch}):").unwrap();
writeln!(out, "{sep}").unwrap();
writeln!(
out,
"| {:<name_w$} | {:<src_w$} | {:<tgt_w$} |",
label, source_branch, target_branch
)
.unwrap();
writeln!(out, "{sep}").unwrap();
for u in entries {
writeln!(
out,
"| {:<name_w$} | {:<src_w$} | {:<tgt_w$} |",
u.name, u.source_version, u.target_version
)
.unwrap();
}
writeln!(out, "{sep}").unwrap();
need_blank = true;
}
if !result.removed.is_empty() {
if need_blank {
writeln!(out).unwrap();
}
writeln!(out, "Removed (in {source_branch} but not {target_branch}):").unwrap();
for p in &result.removed {
writeln!(out, " - {p}").unwrap();
}
need_blank = true;
}
if !result.added.is_empty() {
if need_blank {
writeln!(out).unwrap();
}
writeln!(out, "Added (in {target_branch} but not {source_branch}):").unwrap();
for p in &result.added {
writeln!(out, " + {p}").unwrap();
}
need_blank = true;
}
if show_unchanged && !result.unchanged.is_empty() {
if need_blank {
writeln!(out).unwrap();
}
writeln!(out, "Unchanged:").unwrap();
for p in &result.unchanged {
writeln!(out, " {p}").unwrap();
}
}
out
}
pub fn print_result(
result: &CompareResult,
label: &str,
source_branch: &str,
target_branch: &str,
show_unchanged: bool,
) {
print!(
"{}",
format_result(result, label, source_branch, target_branch, show_unchanged)
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filter_self_deps_removes_matching_names() {
let entries = vec![
"libbpf = 2:1.5.0-3.el9".to_string(),
"libbpf-devel = 2:1.5.0-3.el9".to_string(),
"glibc >= 2.28".to_string(),
"kernel-headers".to_string(),
"libbpf.so.1()(64bit)".to_string(),
];
let self_names: BTreeSet<String> = ["libbpf", "libbpf-devel"]
.iter()
.map(|s| s.to_string())
.collect();
let filtered = filter_self_deps(entries, &self_names);
assert_eq!(
filtered,
vec!["glibc >= 2.28", "kernel-headers", "libbpf.so.1()(64bit)",]
);
}
#[test]
fn filter_self_deps_empty_names() {
let entries = vec!["foo".to_string(), "bar = 1.0".to_string()];
let self_names: BTreeSet<String> = BTreeSet::new();
let filtered = filter_self_deps(entries.clone(), &self_names);
assert_eq!(filtered, entries);
}
#[test]
fn split_entry_with_equals() {
let (name, op, version) = split_entry("libbpf(aarch-64) = 2:1.5.0-3.el9");
assert_eq!(name, "libbpf(aarch-64)");
assert_eq!(op, Some("="));
assert_eq!(version, Some("2:1.5.0-3.el9"));
}
#[test]
fn split_entry_with_gte() {
let (name, op, version) = split_entry("kernel-headers >= 5.14.0-647");
assert_eq!(name, "kernel-headers");
assert_eq!(op, Some(">="));
assert_eq!(version, Some("5.14.0-647"));
}
#[test]
fn split_entry_without_version() {
let (name, op, version) = split_entry("/usr/sbin/halt");
assert_eq!(name, "/usr/sbin/halt");
assert_eq!(op, None);
assert_eq!(version, None);
}
#[test]
fn diff_empty() {
let result = diff(vec![], vec![]);
assert!(result.added.is_empty());
assert!(result.removed.is_empty());
assert!(result.upgraded.is_empty());
assert!(result.downgraded.is_empty());
}
#[test]
fn diff_added_only() {
let result = diff(vec![], vec!["foo".to_string()]);
assert_eq!(result.added, vec!["foo"]);
assert!(result.removed.is_empty());
assert!(result.upgraded.is_empty());
assert!(result.downgraded.is_empty());
}
#[test]
fn diff_removed_only() {
let result = diff(vec!["foo".to_string()], vec![]);
assert!(result.added.is_empty());
assert_eq!(result.removed, vec!["foo"]);
assert!(result.upgraded.is_empty());
assert!(result.downgraded.is_empty());
}
#[test]
fn diff_detects_upgrade() {
let source = vec!["libbpf = 1.0".to_string()];
let target = vec!["libbpf = 2.0".to_string()];
let result = diff(source, target);
assert!(result.added.is_empty());
assert!(result.removed.is_empty());
assert!(result.downgraded.is_empty());
assert_eq!(result.upgraded.len(), 1);
assert_eq!(result.upgraded[0].name, "libbpf");
assert_eq!(result.upgraded[0].source_version, "1.0");
assert_eq!(result.upgraded[0].target_version, "2.0");
}
#[test]
fn diff_detects_downgrade() {
let source = vec!["libbpf = 2.0".to_string()];
let target = vec!["libbpf = 1.0".to_string()];
let result = diff(source, target);
assert!(result.added.is_empty());
assert!(result.removed.is_empty());
assert!(result.upgraded.is_empty());
assert_eq!(result.downgraded.len(), 1);
assert_eq!(result.downgraded[0].name, "libbpf");
assert_eq!(result.downgraded[0].source_version, "2.0");
assert_eq!(result.downgraded[0].target_version, "1.0");
}
#[test]
fn diff_mixed_changes() {
let source = vec!["libfoo = 1.0".to_string(), "libold".to_string()];
let target = vec!["libfoo = 2.0".to_string(), "libnew".to_string()];
let result = diff(source, target);
assert_eq!(result.added, vec!["libnew"]);
assert_eq!(result.removed, vec!["libold"]);
assert_eq!(result.upgraded.len(), 1);
assert_eq!(result.upgraded[0].name, "libfoo");
assert!(result.downgraded.is_empty());
}
#[test]
fn diff_detects_gte_upgrade() {
let source = vec!["kernel-headers >= 5.14.0-647".to_string()];
let target = vec!["kernel-headers >= 5.16.0".to_string()];
let result = diff(source, target);
assert!(result.added.is_empty());
assert!(result.removed.is_empty());
assert!(result.downgraded.is_empty());
assert_eq!(result.upgraded.len(), 1);
assert_eq!(result.upgraded[0].name, "kernel-headers");
assert_eq!(result.upgraded[0].source_version, ">= 5.14.0-647");
assert_eq!(result.upgraded[0].target_version, ">= 5.16.0");
}
#[test]
fn diff_detects_gte_downgrade() {
let source = vec!["kernel-headers >= 5.16.0".to_string()];
let target = vec!["kernel-headers >= 5.14.0-647".to_string()];
let result = diff(source, target);
assert!(result.added.is_empty());
assert!(result.removed.is_empty());
assert!(result.upgraded.is_empty());
assert_eq!(result.downgraded.len(), 1);
assert_eq!(result.downgraded[0].name, "kernel-headers");
}
#[test]
fn split_solib_with_version() {
let (name, version) = split_solib("libc.so.6(GLIBC_2.28)(64bit)").unwrap();
assert_eq!(name, "libc.so.6()(64bit)");
assert_eq!(version, "GLIBC_2.28");
}
#[test]
fn split_solib_empty_parens() {
assert!(split_solib("libfoo.so()(64bit)").is_none());
}
#[test]
fn split_solib_no_parens() {
assert!(split_solib("/usr/sbin/halt").is_none());
}
#[test]
fn split_solib_non_solib_parens() {
assert!(split_solib("pkgconfig(dracut)").is_none());
}
#[test]
fn diff_detects_solib_upgrade() {
let source = vec!["libc.so.6(GLIBC_2.28)(64bit)".to_string()];
let target = vec!["libc.so.6(GLIBC_2.38)(64bit)".to_string()];
let result = diff(source, target);
assert!(result.added.is_empty());
assert!(result.removed.is_empty());
assert!(result.downgraded.is_empty());
assert_eq!(result.upgraded.len(), 1);
assert_eq!(result.upgraded[0].name, "libc.so.6()(64bit)");
assert_eq!(result.upgraded[0].source_version, "GLIBC_2.28");
assert_eq!(result.upgraded[0].target_version, "GLIBC_2.38");
}
#[test]
fn diff_detects_solib_downgrade() {
let source = vec!["libc.so.6(GLIBC_2.38)(64bit)".to_string()];
let target = vec!["libc.so.6(GLIBC_2.28)(64bit)".to_string()];
let result = diff(source, target);
assert!(result.added.is_empty());
assert!(result.removed.is_empty());
assert!(result.upgraded.is_empty());
assert_eq!(result.downgraded.len(), 1);
assert_eq!(result.downgraded[0].name, "libc.so.6()(64bit)");
}
#[test]
fn diff_non_solib_parens_not_matched() {
let source = vec!["pkgconfig(dracut)".to_string()];
let target = vec!["pkgconfig(systemd)".to_string()];
let result = diff(source, target);
assert_eq!(result.added, vec!["pkgconfig(systemd)"]);
assert_eq!(result.removed, vec!["pkgconfig(dracut)"]);
assert!(result.upgraded.is_empty());
assert!(result.downgraded.is_empty());
}
#[test]
fn diff_no_changes() {
let entries = vec!["foo = 1.0".to_string(), "bar".to_string()];
let result = diff(entries.clone(), entries);
assert!(result.added.is_empty());
assert!(result.removed.is_empty());
assert!(result.upgraded.is_empty());
assert!(result.downgraded.is_empty());
assert_eq!(result.unchanged, vec!["bar", "foo = 1.0"]);
}
#[test]
fn diff_tracks_unchanged() {
let source = vec![
"libfoo = 1.0".to_string(),
"libbar".to_string(),
"libold".to_string(),
];
let target = vec![
"libfoo = 2.0".to_string(),
"libbar".to_string(),
"libnew".to_string(),
];
let result = diff(source, target);
assert_eq!(result.unchanged, vec!["libbar"]);
}
#[test]
fn print_result_no_differences() {
let result = CompareResult {
added: vec![],
removed: vec![],
upgraded: vec![],
downgraded: vec![],
unchanged: vec![],
};
print_result(&result, "Provide", "f41", "rawhide", false);
}
#[test]
fn print_result_upgraded_only() {
let result = CompareResult {
added: vec![],
removed: vec![],
upgraded: vec![VersionChange {
name: "libbpf".to_string(),
source_version: "1.0".to_string(),
target_version: "2.0".to_string(),
}],
downgraded: vec![],
unchanged: vec![],
};
print_result(&result, "Require", "c9s", "f44", false);
}
#[test]
fn print_result_downgraded_only() {
let result = CompareResult {
added: vec![],
removed: vec![],
upgraded: vec![],
downgraded: vec![VersionChange {
name: "libbpf".to_string(),
source_version: "2.0".to_string(),
target_version: "1.0".to_string(),
}],
unchanged: vec![],
};
print_result(&result, "Require", "f44", "c9s", false);
}
#[test]
fn print_result_added_and_removed() {
let result = CompareResult {
added: vec!["libnew".to_string()],
removed: vec!["libold".to_string()],
upgraded: vec![],
downgraded: vec![],
unchanged: vec![],
};
print_result(&result, "Provide", "f41", "rawhide", false);
}
#[test]
fn print_result_all_sections() {
let result = CompareResult {
added: vec!["libnew".to_string()],
removed: vec!["libold".to_string()],
upgraded: vec![VersionChange {
name: "libfoo".to_string(),
source_version: "1.0".to_string(),
target_version: "2.0".to_string(),
}],
downgraded: vec![VersionChange {
name: "libbar".to_string(),
source_version: "3.0".to_string(),
target_version: "1.0".to_string(),
}],
unchanged: vec![],
};
print_result(&result, "Require", "f41", "c9s", false);
}
#[test]
fn print_result_show_unchanged_with_differences() {
let result = CompareResult {
added: vec!["libnew".to_string()],
removed: vec![],
upgraded: vec![],
downgraded: vec![],
unchanged: vec!["libcommon".to_string()],
};
print_result(&result, "Provide", "f41", "rawhide", true);
}
#[test]
fn print_result_show_unchanged_no_differences() {
let result = CompareResult {
added: vec![],
removed: vec![],
upgraded: vec![],
downgraded: vec![],
unchanged: vec!["libcommon".to_string()],
};
print_result(&result, "Provide", "f41", "rawhide", true);
}
#[test]
fn print_result_all_sections_with_unchanged() {
let result = CompareResult {
added: vec!["libnew".to_string()],
removed: vec!["libold".to_string()],
upgraded: vec![VersionChange {
name: "libfoo".to_string(),
source_version: "1.0".to_string(),
target_version: "2.0".to_string(),
}],
downgraded: vec![VersionChange {
name: "libbar".to_string(),
source_version: "3.0".to_string(),
target_version: "1.0".to_string(),
}],
unchanged: vec!["libcommon".to_string()],
};
print_result(&result, "Require", "f41", "c9s", true);
}
#[test]
fn print_result_show_unchanged_false_hides_unchanged() {
let result = CompareResult {
added: vec![],
removed: vec![],
upgraded: vec![],
downgraded: vec![],
unchanged: vec!["libcommon".to_string()],
};
print_result(&result, "Provide", "f41", "rawhide", false);
}
}