Skip to main content

hs_intake/
compare.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2
3use std::cmp::Ordering;
4use std::collections::{BTreeMap, BTreeSet};
5
6use serde::Serialize;
7
8use crate::rpmvercmp::compare_evr;
9
10/// An entry whose version changed between branches.
11#[derive(Debug, PartialEq, Eq, Serialize)]
12pub struct VersionChange {
13    pub name: String,
14    pub source_version: String,
15    pub target_version: String,
16}
17
18/// Result of comparing entries between two branches.
19#[derive(Debug, Serialize)]
20pub struct CompareResult {
21    pub added: Vec<String>,
22    pub removed: Vec<String>,
23    pub upgraded: Vec<VersionChange>,
24    pub downgraded: Vec<VersionChange>,
25    pub unchanged: Vec<String>,
26}
27
28/// Split an RPM dependency/provide string into (name, operator, version).
29/// Recognizes ` = ` and ` >= ` as separators.
30/// Returns `None` for operator and version if neither separator is found.
31pub fn split_entry(entry: &str) -> (&str, Option<&str>, Option<&str>) {
32    // Try ` >= ` first since ` = ` would also match the `= ` portion of ` >= `.
33    if let Some((name, version)) = entry.split_once(" >= ") {
34        return (name, Some(">="), Some(version));
35    }
36    if let Some((name, version)) = entry.split_once(" = ") {
37        return (name, Some("="), Some(version));
38    }
39    (entry, None, None)
40}
41
42/// Split a solib-style entry like `libc.so.6(GLIBC_2.28)(64bit)` into
43/// (name_pattern, version_symbol), e.g. `("libc.so.6()(64bit)", "GLIBC_2.28")`.
44/// Returns `None` if the entry has no non-empty first parenthesized group.
45fn split_solib(entry: &str) -> Option<(String, &str)> {
46    let open = entry.find('(')?;
47    // Only treat as solib if the prefix contains ".so." (e.g. libc.so.6).
48    // This avoids matching non-solib entries like pkgconfig(dracut).
49    if !entry[..open].contains(".so.") {
50        return None;
51    }
52    let close = entry[open..].find(')')? + open;
53    let content = &entry[open + 1..close];
54    if content.is_empty() {
55        return None;
56    }
57    let name = format!("{}(){}", &entry[..open], &entry[close + 1..]);
58    Some((name, content))
59}
60
61/// Filter out entries whose name (the part before any operator) matches
62/// one of the given subpackage names.  This removes self-dependencies
63/// when comparing Requires or BuildRequires.
64pub fn filter_self_deps(entries: Vec<String>, subpkg_names: &BTreeSet<String>) -> Vec<String> {
65    entries
66        .into_iter()
67        .filter(|entry| {
68            let (name, _, _) = split_entry(entry);
69            !subpkg_names.contains(name)
70        })
71        .collect()
72}
73
74/// Diff two sets of RPM dependency/provide strings, detecting upgrades
75/// where the name matches but the version differs.
76pub fn diff(source: Vec<String>, target: Vec<String>) -> CompareResult {
77    let source_set: BTreeSet<String> = source.into_iter().collect();
78    let target_set: BTreeSet<String> = target.into_iter().collect();
79
80    let raw_added: BTreeSet<&String> = target_set.difference(&source_set).collect();
81    let raw_removed: BTreeSet<&String> = source_set.difference(&target_set).collect();
82    let unchanged: Vec<String> = source_set.intersection(&target_set).cloned().collect();
83
84    // Index removed entries by their name (LHS of operator) to find upgrades.
85    let mut removed_by_name: BTreeMap<&str, (&str, &str)> = BTreeMap::new();
86    for r in &raw_removed {
87        let (name, op, version) = split_entry(r);
88        if let (Some(op), Some(v)) = (op, version) {
89            removed_by_name.insert(name, (op, v));
90        }
91    }
92
93    let mut added = Vec::new();
94    let mut changed: Vec<VersionChange> = Vec::new();
95    let mut changed_names: BTreeSet<&str> = BTreeSet::new();
96
97    for a in &raw_added {
98        let (name, op, version) = split_entry(a);
99        match (op, version, removed_by_name.get(name)) {
100            (Some(target_op), Some(target_v), Some(&(source_op, source_v)))
101                if target_op == source_op =>
102            {
103                let prefix = if target_op == "=" { "" } else { ">= " };
104                changed.push(VersionChange {
105                    name: name.to_string(),
106                    source_version: format!("{prefix}{source_v}"),
107                    target_version: format!("{prefix}{target_v}"),
108                });
109                changed_names.insert(name);
110            }
111            _ => added.push(a.to_string()),
112        }
113    }
114
115    let remaining_removed: Vec<String> = raw_removed
116        .iter()
117        .filter(|r| {
118            let (name, _, _) = split_entry(r);
119            !changed_names.contains(name)
120        })
121        .map(|r| r.to_string())
122        .collect();
123
124    // Second pass: match solib-style entries like libc.so.6(GLIBC_2.28)(64bit).
125    let mut solib_removed_by_pattern: BTreeMap<String, (String, &str)> = BTreeMap::new();
126    for r in &remaining_removed {
127        if let Some((pattern, version)) = split_solib(r) {
128            solib_removed_by_pattern.insert(pattern, (r.clone(), version));
129        }
130    }
131
132    let mut solib_changed_originals: BTreeSet<String> = BTreeSet::new();
133    let mut final_added = Vec::new();
134    for a in &added {
135        if let Some((pattern, target_v)) = split_solib(a)
136            && let Some((original, source_v)) = solib_removed_by_pattern.get(&pattern)
137        {
138            changed.push(VersionChange {
139                name: pattern,
140                source_version: source_v.to_string(),
141                target_version: target_v.to_string(),
142            });
143            solib_changed_originals.insert(original.clone());
144            continue;
145        }
146        final_added.push(a.clone());
147    }
148
149    let removed: Vec<String> = remaining_removed
150        .into_iter()
151        .filter(|r| !solib_changed_originals.contains(r))
152        .collect();
153
154    changed.sort_by(|a, b| a.name.cmp(&b.name));
155
156    // Split changed into upgraded vs downgraded using RPM version comparison.
157    // Strip the ">= " prefix before comparing, as it's not part of the EVR.
158    let mut upgraded = Vec::new();
159    let mut downgraded = Vec::new();
160    for c in changed {
161        let src = c
162            .source_version
163            .strip_prefix(">= ")
164            .unwrap_or(&c.source_version);
165        let tgt = c
166            .target_version
167            .strip_prefix(">= ")
168            .unwrap_or(&c.target_version);
169        match compare_evr(src, tgt) {
170            Ordering::Less => upgraded.push(c),
171            Ordering::Greater => downgraded.push(c),
172            Ordering::Equal => upgraded.push(c),
173        }
174    }
175
176    CompareResult {
177        added: final_added,
178        removed,
179        upgraded,
180        downgraded,
181        unchanged,
182    }
183}
184
185/// Format a `CompareResult` as a human-readable string.
186///
187/// When `show_unchanged` is true, entries that are identical in both
188/// branches are listed at the end.
189pub fn format_result(
190    result: &CompareResult,
191    label: &str,
192    source_branch: &str,
193    target_branch: &str,
194    show_unchanged: bool,
195) -> String {
196    use std::fmt::Write;
197    let mut out = String::new();
198
199    if result.added.is_empty()
200        && result.removed.is_empty()
201        && result.upgraded.is_empty()
202        && result.downgraded.is_empty()
203    {
204        writeln!(out, "No differences in {label}.").unwrap();
205        if show_unchanged && !result.unchanged.is_empty() {
206            writeln!(out).unwrap();
207            writeln!(out, "Unchanged:").unwrap();
208            for p in &result.unchanged {
209                writeln!(out, "  {p}").unwrap();
210            }
211        }
212        return out;
213    }
214    let mut need_blank = false;
215    for (section_label, entries) in [
216        ("Upgraded", &result.upgraded),
217        ("Downgraded", &result.downgraded),
218    ] {
219        if entries.is_empty() {
220            continue;
221        }
222        if need_blank {
223            writeln!(out).unwrap();
224        }
225        let name_w = entries.iter().map(|u| u.name.len()).max().unwrap();
226        let src_w = entries
227            .iter()
228            .map(|u| u.source_version.len())
229            .max()
230            .unwrap()
231            .max(source_branch.len());
232        let tgt_w = entries
233            .iter()
234            .map(|u| u.target_version.len())
235            .max()
236            .unwrap()
237            .max(target_branch.len());
238
239        let sep = format!(
240            "+-{}-+-{}-+-{}-+",
241            "-".repeat(name_w),
242            "-".repeat(src_w),
243            "-".repeat(tgt_w)
244        );
245        writeln!(out, "{section_label} ({source_branch} -> {target_branch}):").unwrap();
246        writeln!(out, "{sep}").unwrap();
247        writeln!(
248            out,
249            "| {:<name_w$} | {:<src_w$} | {:<tgt_w$} |",
250            label, source_branch, target_branch
251        )
252        .unwrap();
253        writeln!(out, "{sep}").unwrap();
254        for u in entries {
255            writeln!(
256                out,
257                "| {:<name_w$} | {:<src_w$} | {:<tgt_w$} |",
258                u.name, u.source_version, u.target_version
259            )
260            .unwrap();
261        }
262        writeln!(out, "{sep}").unwrap();
263        need_blank = true;
264    }
265    if !result.removed.is_empty() {
266        if need_blank {
267            writeln!(out).unwrap();
268        }
269        writeln!(out, "Removed (in {source_branch} but not {target_branch}):").unwrap();
270        for p in &result.removed {
271            writeln!(out, "  - {p}").unwrap();
272        }
273        need_blank = true;
274    }
275    if !result.added.is_empty() {
276        if need_blank {
277            writeln!(out).unwrap();
278        }
279        writeln!(out, "Added (in {target_branch} but not {source_branch}):").unwrap();
280        for p in &result.added {
281            writeln!(out, "  + {p}").unwrap();
282        }
283        need_blank = true;
284    }
285    if show_unchanged && !result.unchanged.is_empty() {
286        if need_blank {
287            writeln!(out).unwrap();
288        }
289        writeln!(out, "Unchanged:").unwrap();
290        for p in &result.unchanged {
291            writeln!(out, "  {p}").unwrap();
292        }
293    }
294    out
295}
296
297/// Print a `CompareResult` in human-readable format.
298///
299/// When `show_unchanged` is true, entries that are identical in both
300/// branches are listed at the end.
301pub fn print_result(
302    result: &CompareResult,
303    label: &str,
304    source_branch: &str,
305    target_branch: &str,
306    show_unchanged: bool,
307) {
308    print!(
309        "{}",
310        format_result(result, label, source_branch, target_branch, show_unchanged)
311    );
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn filter_self_deps_removes_matching_names() {
320        let entries = vec![
321            "libbpf = 2:1.5.0-3.el9".to_string(),
322            "libbpf-devel = 2:1.5.0-3.el9".to_string(),
323            "glibc >= 2.28".to_string(),
324            "kernel-headers".to_string(),
325            "libbpf.so.1()(64bit)".to_string(),
326        ];
327        let self_names: BTreeSet<String> = ["libbpf", "libbpf-devel"]
328            .iter()
329            .map(|s| s.to_string())
330            .collect();
331        let filtered = filter_self_deps(entries, &self_names);
332        assert_eq!(
333            filtered,
334            vec!["glibc >= 2.28", "kernel-headers", "libbpf.so.1()(64bit)",]
335        );
336    }
337
338    #[test]
339    fn filter_self_deps_empty_names() {
340        let entries = vec!["foo".to_string(), "bar = 1.0".to_string()];
341        let self_names: BTreeSet<String> = BTreeSet::new();
342        let filtered = filter_self_deps(entries.clone(), &self_names);
343        assert_eq!(filtered, entries);
344    }
345
346    #[test]
347    fn split_entry_with_equals() {
348        let (name, op, version) = split_entry("libbpf(aarch-64) = 2:1.5.0-3.el9");
349        assert_eq!(name, "libbpf(aarch-64)");
350        assert_eq!(op, Some("="));
351        assert_eq!(version, Some("2:1.5.0-3.el9"));
352    }
353
354    #[test]
355    fn split_entry_with_gte() {
356        let (name, op, version) = split_entry("kernel-headers >= 5.14.0-647");
357        assert_eq!(name, "kernel-headers");
358        assert_eq!(op, Some(">="));
359        assert_eq!(version, Some("5.14.0-647"));
360    }
361
362    #[test]
363    fn split_entry_without_version() {
364        let (name, op, version) = split_entry("/usr/sbin/halt");
365        assert_eq!(name, "/usr/sbin/halt");
366        assert_eq!(op, None);
367        assert_eq!(version, None);
368    }
369
370    #[test]
371    fn diff_empty() {
372        let result = diff(vec![], vec![]);
373        assert!(result.added.is_empty());
374        assert!(result.removed.is_empty());
375        assert!(result.upgraded.is_empty());
376        assert!(result.downgraded.is_empty());
377    }
378
379    #[test]
380    fn diff_added_only() {
381        let result = diff(vec![], vec!["foo".to_string()]);
382        assert_eq!(result.added, vec!["foo"]);
383        assert!(result.removed.is_empty());
384        assert!(result.upgraded.is_empty());
385        assert!(result.downgraded.is_empty());
386    }
387
388    #[test]
389    fn diff_removed_only() {
390        let result = diff(vec!["foo".to_string()], vec![]);
391        assert!(result.added.is_empty());
392        assert_eq!(result.removed, vec!["foo"]);
393        assert!(result.upgraded.is_empty());
394        assert!(result.downgraded.is_empty());
395    }
396
397    #[test]
398    fn diff_detects_upgrade() {
399        let source = vec!["libbpf = 1.0".to_string()];
400        let target = vec!["libbpf = 2.0".to_string()];
401        let result = diff(source, target);
402        assert!(result.added.is_empty());
403        assert!(result.removed.is_empty());
404        assert!(result.downgraded.is_empty());
405        assert_eq!(result.upgraded.len(), 1);
406        assert_eq!(result.upgraded[0].name, "libbpf");
407        assert_eq!(result.upgraded[0].source_version, "1.0");
408        assert_eq!(result.upgraded[0].target_version, "2.0");
409    }
410
411    #[test]
412    fn diff_detects_downgrade() {
413        let source = vec!["libbpf = 2.0".to_string()];
414        let target = vec!["libbpf = 1.0".to_string()];
415        let result = diff(source, target);
416        assert!(result.added.is_empty());
417        assert!(result.removed.is_empty());
418        assert!(result.upgraded.is_empty());
419        assert_eq!(result.downgraded.len(), 1);
420        assert_eq!(result.downgraded[0].name, "libbpf");
421        assert_eq!(result.downgraded[0].source_version, "2.0");
422        assert_eq!(result.downgraded[0].target_version, "1.0");
423    }
424
425    #[test]
426    fn diff_mixed_changes() {
427        let source = vec!["libfoo = 1.0".to_string(), "libold".to_string()];
428        let target = vec!["libfoo = 2.0".to_string(), "libnew".to_string()];
429        let result = diff(source, target);
430        assert_eq!(result.added, vec!["libnew"]);
431        assert_eq!(result.removed, vec!["libold"]);
432        assert_eq!(result.upgraded.len(), 1);
433        assert_eq!(result.upgraded[0].name, "libfoo");
434        assert!(result.downgraded.is_empty());
435    }
436
437    #[test]
438    fn diff_detects_gte_upgrade() {
439        let source = vec!["kernel-headers >= 5.14.0-647".to_string()];
440        let target = vec!["kernel-headers >= 5.16.0".to_string()];
441        let result = diff(source, target);
442        assert!(result.added.is_empty());
443        assert!(result.removed.is_empty());
444        assert!(result.downgraded.is_empty());
445        assert_eq!(result.upgraded.len(), 1);
446        assert_eq!(result.upgraded[0].name, "kernel-headers");
447        assert_eq!(result.upgraded[0].source_version, ">= 5.14.0-647");
448        assert_eq!(result.upgraded[0].target_version, ">= 5.16.0");
449    }
450
451    #[test]
452    fn diff_detects_gte_downgrade() {
453        let source = vec!["kernel-headers >= 5.16.0".to_string()];
454        let target = vec!["kernel-headers >= 5.14.0-647".to_string()];
455        let result = diff(source, target);
456        assert!(result.added.is_empty());
457        assert!(result.removed.is_empty());
458        assert!(result.upgraded.is_empty());
459        assert_eq!(result.downgraded.len(), 1);
460        assert_eq!(result.downgraded[0].name, "kernel-headers");
461    }
462
463    #[test]
464    fn split_solib_with_version() {
465        let (name, version) = split_solib("libc.so.6(GLIBC_2.28)(64bit)").unwrap();
466        assert_eq!(name, "libc.so.6()(64bit)");
467        assert_eq!(version, "GLIBC_2.28");
468    }
469
470    #[test]
471    fn split_solib_empty_parens() {
472        assert!(split_solib("libfoo.so()(64bit)").is_none());
473    }
474
475    #[test]
476    fn split_solib_no_parens() {
477        assert!(split_solib("/usr/sbin/halt").is_none());
478    }
479
480    #[test]
481    fn split_solib_non_solib_parens() {
482        assert!(split_solib("pkgconfig(dracut)").is_none());
483    }
484
485    #[test]
486    fn diff_detects_solib_upgrade() {
487        let source = vec!["libc.so.6(GLIBC_2.28)(64bit)".to_string()];
488        let target = vec!["libc.so.6(GLIBC_2.38)(64bit)".to_string()];
489        let result = diff(source, target);
490        assert!(result.added.is_empty());
491        assert!(result.removed.is_empty());
492        assert!(result.downgraded.is_empty());
493        assert_eq!(result.upgraded.len(), 1);
494        assert_eq!(result.upgraded[0].name, "libc.so.6()(64bit)");
495        assert_eq!(result.upgraded[0].source_version, "GLIBC_2.28");
496        assert_eq!(result.upgraded[0].target_version, "GLIBC_2.38");
497    }
498
499    #[test]
500    fn diff_detects_solib_downgrade() {
501        let source = vec!["libc.so.6(GLIBC_2.38)(64bit)".to_string()];
502        let target = vec!["libc.so.6(GLIBC_2.28)(64bit)".to_string()];
503        let result = diff(source, target);
504        assert!(result.added.is_empty());
505        assert!(result.removed.is_empty());
506        assert!(result.upgraded.is_empty());
507        assert_eq!(result.downgraded.len(), 1);
508        assert_eq!(result.downgraded[0].name, "libc.so.6()(64bit)");
509    }
510
511    #[test]
512    fn diff_non_solib_parens_not_matched() {
513        let source = vec!["pkgconfig(dracut)".to_string()];
514        let target = vec!["pkgconfig(systemd)".to_string()];
515        let result = diff(source, target);
516        assert_eq!(result.added, vec!["pkgconfig(systemd)"]);
517        assert_eq!(result.removed, vec!["pkgconfig(dracut)"]);
518        assert!(result.upgraded.is_empty());
519        assert!(result.downgraded.is_empty());
520    }
521
522    #[test]
523    fn diff_no_changes() {
524        let entries = vec!["foo = 1.0".to_string(), "bar".to_string()];
525        let result = diff(entries.clone(), entries);
526        assert!(result.added.is_empty());
527        assert!(result.removed.is_empty());
528        assert!(result.upgraded.is_empty());
529        assert!(result.downgraded.is_empty());
530        assert_eq!(result.unchanged, vec!["bar", "foo = 1.0"]);
531    }
532
533    #[test]
534    fn diff_tracks_unchanged() {
535        let source = vec![
536            "libfoo = 1.0".to_string(),
537            "libbar".to_string(),
538            "libold".to_string(),
539        ];
540        let target = vec![
541            "libfoo = 2.0".to_string(),
542            "libbar".to_string(),
543            "libnew".to_string(),
544        ];
545        let result = diff(source, target);
546        assert_eq!(result.unchanged, vec!["libbar"]);
547    }
548
549    #[test]
550    fn print_result_no_differences() {
551        let result = CompareResult {
552            added: vec![],
553            removed: vec![],
554            upgraded: vec![],
555            downgraded: vec![],
556            unchanged: vec![],
557        };
558        // Exercises the early-return "No differences" path.
559        print_result(&result, "Provide", "f41", "rawhide", false);
560    }
561
562    #[test]
563    fn print_result_upgraded_only() {
564        let result = CompareResult {
565            added: vec![],
566            removed: vec![],
567            upgraded: vec![VersionChange {
568                name: "libbpf".to_string(),
569                source_version: "1.0".to_string(),
570                target_version: "2.0".to_string(),
571            }],
572            downgraded: vec![],
573            unchanged: vec![],
574        };
575        print_result(&result, "Require", "c9s", "f44", false);
576    }
577
578    #[test]
579    fn print_result_downgraded_only() {
580        let result = CompareResult {
581            added: vec![],
582            removed: vec![],
583            upgraded: vec![],
584            downgraded: vec![VersionChange {
585                name: "libbpf".to_string(),
586                source_version: "2.0".to_string(),
587                target_version: "1.0".to_string(),
588            }],
589            unchanged: vec![],
590        };
591        print_result(&result, "Require", "f44", "c9s", false);
592    }
593
594    #[test]
595    fn print_result_added_and_removed() {
596        let result = CompareResult {
597            added: vec!["libnew".to_string()],
598            removed: vec!["libold".to_string()],
599            upgraded: vec![],
600            downgraded: vec![],
601            unchanged: vec![],
602        };
603        print_result(&result, "Provide", "f41", "rawhide", false);
604    }
605
606    #[test]
607    fn print_result_all_sections() {
608        let result = CompareResult {
609            added: vec!["libnew".to_string()],
610            removed: vec!["libold".to_string()],
611            upgraded: vec![VersionChange {
612                name: "libfoo".to_string(),
613                source_version: "1.0".to_string(),
614                target_version: "2.0".to_string(),
615            }],
616            downgraded: vec![VersionChange {
617                name: "libbar".to_string(),
618                source_version: "3.0".to_string(),
619                target_version: "1.0".to_string(),
620            }],
621            unchanged: vec![],
622        };
623        // Exercises all four sections including need_blank separators.
624        print_result(&result, "Require", "f41", "c9s", false);
625    }
626
627    #[test]
628    fn print_result_show_unchanged_with_differences() {
629        let result = CompareResult {
630            added: vec!["libnew".to_string()],
631            removed: vec![],
632            upgraded: vec![],
633            downgraded: vec![],
634            unchanged: vec!["libcommon".to_string()],
635        };
636        print_result(&result, "Provide", "f41", "rawhide", true);
637    }
638
639    #[test]
640    fn print_result_show_unchanged_no_differences() {
641        let result = CompareResult {
642            added: vec![],
643            removed: vec![],
644            upgraded: vec![],
645            downgraded: vec![],
646            unchanged: vec!["libcommon".to_string()],
647        };
648        // Exercises the "No differences" + unchanged path.
649        print_result(&result, "Provide", "f41", "rawhide", true);
650    }
651
652    #[test]
653    fn print_result_all_sections_with_unchanged() {
654        let result = CompareResult {
655            added: vec!["libnew".to_string()],
656            removed: vec!["libold".to_string()],
657            upgraded: vec![VersionChange {
658                name: "libfoo".to_string(),
659                source_version: "1.0".to_string(),
660                target_version: "2.0".to_string(),
661            }],
662            downgraded: vec![VersionChange {
663                name: "libbar".to_string(),
664                source_version: "3.0".to_string(),
665                target_version: "1.0".to_string(),
666            }],
667            unchanged: vec!["libcommon".to_string()],
668        };
669        // Exercises all five sections including unchanged.
670        print_result(&result, "Require", "f41", "c9s", true);
671    }
672
673    #[test]
674    fn print_result_show_unchanged_false_hides_unchanged() {
675        let result = CompareResult {
676            added: vec![],
677            removed: vec![],
678            upgraded: vec![],
679            downgraded: vec![],
680            unchanged: vec!["libcommon".to_string()],
681        };
682        // With show_unchanged=false, unchanged entries are not shown.
683        print_result(&result, "Provide", "f41", "rawhide", false);
684    }
685}