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