1use std::cmp::Ordering;
4use std::collections::{BTreeMap, BTreeSet};
5
6use serde::Serialize;
7
8use crate::fedrq::Fedrq;
9use crate::rpmvercmp::compare_evr;
10
11#[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#[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
29pub fn split_entry(entry: &str) -> (&str, Option<&str>, Option<&str>) {
33 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
43fn split_solib(entry: &str) -> Option<(String, &str)> {
47 let open = entry.find('(')?;
48 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
62pub 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
75pub 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
113pub 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 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 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 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
224pub 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
336pub 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 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 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 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 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 print_result(&result, "Provide", "f41", "rawhide", false);
723 }
724}