1use crate::cbs::{self, HyperscaleSummary};
4use crate::repology;
5use serde::Serialize;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct Distros {
10 pub upstream: bool,
11 pub fedora_rawhide: bool,
12 pub fedora_stable: bool,
13 pub centos_stream: bool,
14 pub hyperscale_9: bool,
15 pub hyperscale_10: bool,
16}
17
18impl Distros {
19 pub fn all() -> Self {
21 Self {
22 upstream: true,
23 fedora_rawhide: true,
24 fedora_stable: true,
25 centos_stream: true,
26 hyperscale_9: true,
27 hyperscale_10: true,
28 }
29 }
30
31 pub fn parse(input: &str) -> Result<Self, String> {
36 let mut d = Self {
37 upstream: false,
38 fedora_rawhide: false,
39 fedora_stable: false,
40 centos_stream: false,
41 hyperscale_9: false,
42 hyperscale_10: false,
43 };
44 for token in input.split(',') {
45 match token.trim() {
46 "upstream" => d.upstream = true,
47 "fedora" => {
48 d.fedora_rawhide = true;
49 d.fedora_stable = true;
50 }
51 "fedora-rawhide" => d.fedora_rawhide = true,
52 "fedora-stable" => d.fedora_stable = true,
53 "centos" | "centos-stream" => d.centos_stream = true,
54 "hyperscale" | "hs" => {
55 d.hyperscale_9 = true;
56 d.hyperscale_10 = true;
57 }
58 "hs9" => d.hyperscale_9 = true,
59 "hs10" => d.hyperscale_10 = true,
60 other => return Err(format!("unknown distro: {other:?}")),
61 }
62 }
63 Ok(d)
64 }
65
66 fn needs_repology(&self) -> bool {
67 self.upstream || self.fedora_rawhide || self.fedora_stable || self.centos_stream
68 }
69
70 fn needs_cbs(&self) -> bool {
71 self.hyperscale_9 || self.hyperscale_10
72 }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum TrackRef {
78 Upstream,
79 FedoraRawhide,
80 FedoraStable,
81 CentosStream,
82}
83
84impl TrackRef {
85 pub fn parse(input: &str) -> Result<Self, String> {
90 match input.trim() {
91 "upstream" => Ok(Self::Upstream),
92 "fedora-rawhide" => Ok(Self::FedoraRawhide),
93 "fedora-stable" => Ok(Self::FedoraStable),
94 "centos" | "centos-stream" => Ok(Self::CentosStream),
95 other => Err(format!("unknown track reference: {other:?}")),
96 }
97 }
98
99 fn resolve(&self, packages: &[repology::Package]) -> Option<String> {
101 match self {
102 Self::Upstream => repology::find_newest(packages).map(|p| p.version.clone()),
103 Self::FedoraRawhide => {
104 repology::latest_for_repo(packages, "fedora_rawhide").map(|p| p.version.clone())
105 }
106 Self::FedoraStable => {
107 repology::latest_fedora_stable(packages).map(|p| p.version.clone())
108 }
109 Self::CentosStream => {
110 repology::latest_centos_stream(packages).map(|p| p.version.clone())
111 }
112 }
113 }
114}
115
116#[derive(Debug, Serialize)]
118pub struct HyperscaleResult {
119 #[serde(flatten)]
120 pub summary: HyperscaleSummary,
121 #[serde(skip_serializing_if = "Option::is_none")]
122 pub newest_version: Option<bool>,
123}
124
125#[derive(Debug, Serialize)]
127pub struct CheckResult {
128 pub package: String,
129 #[serde(skip_serializing_if = "Option::is_none")]
130 pub upstream: Option<String>,
131 #[serde(skip_serializing_if = "Option::is_none")]
132 pub fedora_rawhide: Option<String>,
133 #[serde(skip_serializing_if = "Option::is_none")]
134 pub fedora_stable: Option<VersionWithDetail>,
135 #[serde(skip_serializing_if = "Option::is_none")]
136 pub centos_stream: Option<VersionWithDetail>,
137 #[serde(skip_serializing_if = "Option::is_none")]
138 pub hs9: Option<HyperscaleResult>,
139 #[serde(skip_serializing_if = "Option::is_none")]
140 pub hs10: Option<HyperscaleResult>,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub issue: Option<IssueRef>,
143 #[serde(skip)]
145 ref_version: Option<String>,
146}
147
148#[derive(Debug, Clone, Serialize)]
150pub struct IssueRef {
151 pub iid: u64,
152 pub url: String,
153 pub status: String,
154 #[serde(skip_serializing_if = "Vec::is_empty")]
155 pub assignees: Vec<String>,
156}
157
158impl IssueRef {
159 pub fn from_gitlab_issue(
165 issue: &crate::gitlab::Issue,
166 status: Option<String>,
167 ) -> Self {
168 Self {
169 iid: issue.iid,
170 url: issue.web_url.clone(),
171 status: status
172 .unwrap_or_else(|| issue.state.clone()),
173 assignees: issue
174 .assignees
175 .iter()
176 .map(|a| a.username.clone())
177 .collect(),
178 }
179 }
180}
181
182#[derive(Debug, Serialize)]
183pub struct VersionWithDetail {
184 pub version: String,
185 pub detail: String,
186}
187
188impl CheckResult {
189 pub fn is_outdated(&self) -> bool {
191 [&self.hs9, &self.hs10]
192 .iter()
193 .filter_map(|r| r.as_ref())
194 .any(|r| r.newest_version == Some(false))
195 }
196
197 pub fn ref_version(&self) -> Option<&str> {
199 self.ref_version.as_deref()
200 }
201
202 pub fn matches_issue_filter(
207 &self,
208 status: Option<&str>,
209 assignee: Option<&str>,
210 ) -> bool {
211 let issue = match &self.issue {
212 Some(i) => i,
213 None => return false,
214 };
215 matches_filter(
216 &issue.status,
217 &issue.assignees,
218 status,
219 assignee,
220 )
221 }
222}
223
224pub fn matches_filter(
228 status: &str,
229 assignees: &[String],
230 filter_status: Option<&str>,
231 filter_assignee: Option<&str>,
232) -> bool {
233 if let Some(s) = filter_status {
234 if status != s {
235 return false;
236 }
237 }
238 if let Some(a) = filter_assignee {
239 if a == "none" {
240 if !assignees.is_empty() {
241 return false;
242 }
243 } else if !assignees.iter().any(|u| u == a) {
244 return false;
245 }
246 }
247 true
248}
249
250pub fn check(
255 repology_client: &repology::Client,
256 cbs_client: &cbs::Client,
257 package: &str,
258 repology_name: &str,
259 distros: &Distros,
260 track: &TrackRef,
261) -> Result<CheckResult, Box<dyn std::error::Error>> {
262 let mut result = CheckResult {
263 package: package.to_string(),
264 upstream: None,
265 fedora_rawhide: None,
266 fedora_stable: None,
267 centos_stream: None,
268 hs9: None,
269 hs10: None,
270 issue: None,
271 ref_version: None,
272 };
273
274 let fetch_repology = distros.needs_repology() || distros.needs_cbs();
276 let packages = if fetch_repology {
277 repology_client.get_project(repology_name)?
278 } else {
279 Vec::new()
280 };
281
282 if distros.upstream {
283 result.upstream = repology::find_newest(&packages).map(|p| p.version.clone());
284 }
285 if distros.fedora_rawhide {
286 result.fedora_rawhide = repology::latest_for_repo(&packages, "fedora_rawhide")
287 .map(|p| p.version.clone());
288 }
289 if distros.fedora_stable {
290 result.fedora_stable = repology::latest_fedora_stable(&packages).map(|p| {
291 VersionWithDetail {
292 version: p.version.clone(),
293 detail: p.repo.clone(),
294 }
295 });
296 }
297 if distros.centos_stream {
298 result.centos_stream =
299 repology::latest_centos_stream(&packages).map(|p| VersionWithDetail {
300 version: p.version.clone(),
301 detail: p.repo.clone(),
302 });
303 }
304
305 let ref_version = track.resolve(&packages);
306 result.ref_version = ref_version.clone();
307
308 if distros.needs_cbs() {
309 let builds = cbs_client
310 .get_package_id(package)?
311 .map(|id| cbs_client.list_builds(id))
312 .transpose()?;
313 let empty = Vec::new();
314 let builds = builds.as_deref().unwrap_or(&empty);
315
316 if distros.hyperscale_9 {
317 let summary = cbs_client.hyperscale_summary(builds, 9)?;
318 let newest_version = compute_newest_version(&summary, &ref_version);
319 result.hs9 = Some(HyperscaleResult {
320 summary,
321 newest_version,
322 });
323 }
324 if distros.hyperscale_10 {
325 let summary = cbs_client.hyperscale_summary(builds, 10)?;
326 let newest_version = compute_newest_version(&summary, &ref_version);
327 result.hs10 = Some(HyperscaleResult {
328 summary,
329 newest_version,
330 });
331 }
332 }
333
334 Ok(result)
335}
336
337fn compute_newest_version(summary: &HyperscaleSummary, ref_version: &Option<String>) -> Option<bool> {
343 let ref_ver = ref_version.as_ref()?;
344 let effective = summary.release.as_ref().or(summary.testing.as_ref())?;
345 Some(crate::rpmvercmp::rpmvercmp(&effective.version, ref_ver) != std::cmp::Ordering::Less)
346}
347
348struct Row {
350 distro: String,
351 version: String,
352 detail: String,
353 status: String,
354}
355
356fn result_to_rows(result: &CheckResult) -> Vec<Row> {
358 let mut rows = Vec::new();
359
360 if let Some(v) = &result.upstream {
361 rows.push(Row {
362 distro: "Upstream".into(),
363 version: v.clone(),
364 detail: String::new(),
365 status: String::new(),
366 });
367 }
368 if let Some(v) = &result.fedora_rawhide {
369 rows.push(Row {
370 distro: "Fedora Rawhide".into(),
371 version: v.clone(),
372 detail: String::new(),
373 status: String::new(),
374 });
375 }
376 if let Some(vd) = &result.fedora_stable {
377 rows.push(Row {
378 distro: "Fedora Stable".into(),
379 version: vd.version.clone(),
380 detail: vd.detail.clone(),
381 status: String::new(),
382 });
383 }
384 if let Some(vd) = &result.centos_stream {
385 rows.push(Row {
386 distro: "CentOS Stream".into(),
387 version: vd.version.clone(),
388 detail: vd.detail.clone(),
389 status: String::new(),
390 });
391 }
392 if let Some(hs_result) = &result.hs9 {
393 hs_rows(&mut rows, "Hyperscale 9", &hs_result.summary, result.ref_version.as_deref());
394 }
395 if let Some(hs_result) = &result.hs10 {
396 hs_rows(&mut rows, "Hyperscale 10", &hs_result.summary, result.ref_version.as_deref());
397 }
398
399 rows
400}
401
402fn version_status(version: &str, ref_version: Option<&str>) -> String {
403 match ref_version {
404 Some(ref_ver) => {
405 if crate::rpmvercmp::rpmvercmp(version, ref_ver) != std::cmp::Ordering::Less {
406 "newest".into()
407 } else {
408 "outdated".into()
409 }
410 }
411 None => String::new(),
412 }
413}
414
415fn hs_rows(rows: &mut Vec<Row>, label: &str, summary: &HyperscaleSummary, ref_version: Option<&str>) {
416 match (&summary.release, &summary.testing) {
417 (Some(rel), Some(test)) => {
418 rows.push(Row {
419 distro: format!("{label} (release)"),
420 version: rel.version.clone(),
421 detail: rel.nvr.clone(),
422 status: version_status(&rel.version, ref_version),
423 });
424 rows.push(Row {
425 distro: format!("{label} (testing)"),
426 version: test.version.clone(),
427 detail: test.nvr.clone(),
428 status: version_status(&test.version, ref_version),
429 });
430 }
431 (Some(rel), None) => {
432 rows.push(Row {
433 distro: label.into(),
434 version: rel.version.clone(),
435 detail: rel.nvr.clone(),
436 status: version_status(&rel.version, ref_version),
437 });
438 }
439 (None, Some(test)) => {
440 rows.push(Row {
441 distro: format!("{label} (testing)"),
442 version: test.version.clone(),
443 detail: test.nvr.clone(),
444 status: version_status(&test.version, ref_version),
445 });
446 }
447 (None, None) => {
448 rows.push(Row {
449 distro: label.into(),
450 version: "not found".into(),
451 detail: String::new(),
452 status: String::new(),
453 });
454 }
455 }
456}
457
458pub fn format_table(result: &CheckResult) -> String {
460 let mut buf = Vec::new();
461 let _ = write_table(result, &mut buf);
462 String::from_utf8(buf).unwrap_or_default()
463}
464
465pub fn print_table(result: &CheckResult) {
467 let _ = write_table(result, &mut std::io::stdout().lock());
468}
469
470pub fn print_json(result: &CheckResult) -> Result<(), Box<dyn std::error::Error>> {
472 write_json(result, &mut std::io::stdout().lock())?;
473 Ok(())
474}
475
476pub fn print_json_array(
478 results: &[CheckResult],
479) -> Result<(), Box<dyn std::error::Error>> {
480 write_json_array(results, &mut std::io::stdout().lock())
481}
482
483fn write_json_array(
484 results: &[CheckResult],
485 w: &mut dyn std::io::Write,
486) -> Result<(), Box<dyn std::error::Error>> {
487 writeln!(w, "{}", serde_json::to_string_pretty(results)?)?;
488 Ok(())
489}
490
491fn write_table(
492 result: &CheckResult,
493 w: &mut dyn std::io::Write,
494) -> std::io::Result<()> {
495 let rows = result_to_rows(result);
496 if rows.is_empty() {
497 return Ok(());
498 }
499
500 let distro_w = rows.iter().map(|r| r.distro.len()).max().unwrap_or(0).max("Distribution".len());
501 let version_w = rows.iter().map(|r| r.version.len()).max().unwrap_or(0).max("Version".len());
502 let has_status = rows.iter().any(|r| !r.status.is_empty());
503 let detail_w = rows
504 .iter()
505 .map(|r| r.detail.len())
506 .max()
507 .unwrap_or(0)
508 .max("Detail".len());
509
510 writeln!(w, "{}", result.package)?;
511 if has_status {
512 writeln!(
513 w,
514 " {:<distro_w$} {:<version_w$} {:<detail_w$} {}",
515 "Distribution", "Version", "Detail", "Status"
516 )?;
517 writeln!(
518 w,
519 " {:<distro_w$} {:<version_w$} {:<detail_w$} {}",
520 "─".repeat(distro_w),
521 "─".repeat(version_w),
522 "─".repeat(detail_w),
523 "──────"
524 )?;
525 } else {
526 writeln!(
527 w,
528 " {:<distro_w$} {:<version_w$} {}",
529 "Distribution", "Version", "Detail"
530 )?;
531 writeln!(
532 w,
533 " {:<distro_w$} {:<version_w$} {}",
534 "─".repeat(distro_w),
535 "─".repeat(version_w),
536 "──────"
537 )?;
538 }
539 for row in &rows {
540 if !row.status.is_empty() {
541 writeln!(
542 w,
543 " {:<distro_w$} {:<version_w$} {:<detail_w$} {}",
544 row.distro, row.version, row.detail, row.status
545 )?;
546 } else if row.detail.is_empty() {
547 writeln!(w, " {:<distro_w$} {}", row.distro, row.version)?;
548 } else {
549 writeln!(
550 w,
551 " {:<distro_w$} {:<version_w$} {}",
552 row.distro, row.version, row.detail
553 )?;
554 }
555 }
556 Ok(())
557}
558
559fn write_json(
560 result: &CheckResult,
561 w: &mut dyn std::io::Write,
562) -> Result<(), Box<dyn std::error::Error>> {
563 writeln!(w, "{}", serde_json::to_string_pretty(result)?)?;
564 Ok(())
565}
566
567#[cfg(test)]
568mod tests {
569 use super::*;
570 use crate::cbs::Build;
571
572 #[test]
573 fn test_distros_all() {
574 let d = Distros::all();
575 assert!(d.upstream);
576 assert!(d.fedora_rawhide);
577 assert!(d.fedora_stable);
578 assert!(d.centos_stream);
579 assert!(d.hyperscale_9);
580 assert!(d.hyperscale_10);
581 }
582
583 #[test]
584 fn test_distros_parse_single() {
585 let d = Distros::parse("upstream").unwrap();
586 assert!(d.upstream);
587 assert!(!d.fedora_rawhide);
588 assert!(!d.hyperscale_9);
589 }
590
591 #[test]
592 fn test_distros_parse_fedora_expands() {
593 let d = Distros::parse("fedora").unwrap();
594 assert!(d.fedora_rawhide);
595 assert!(d.fedora_stable);
596 assert!(!d.upstream);
597 }
598
599 #[test]
600 fn test_distros_parse_hyperscale_expands() {
601 let d = Distros::parse("hyperscale").unwrap();
602 assert!(d.hyperscale_9);
603 assert!(d.hyperscale_10);
604 assert!(!d.upstream);
605 }
606
607 #[test]
608 fn test_distros_parse_hs_alias() {
609 let d = Distros::parse("hs").unwrap();
610 assert!(d.hyperscale_9);
611 assert!(d.hyperscale_10);
612 }
613
614 #[test]
615 fn test_distros_parse_comma_separated() {
616 let d = Distros::parse("upstream,fedora-rawhide,hs10").unwrap();
617 assert!(d.upstream);
618 assert!(d.fedora_rawhide);
619 assert!(!d.fedora_stable);
620 assert!(d.hyperscale_10);
621 assert!(!d.hyperscale_9);
622 }
623
624 #[test]
625 fn test_distros_parse_centos_aliases() {
626 let d1 = Distros::parse("centos").unwrap();
627 assert!(d1.centos_stream);
628 let d2 = Distros::parse("centos-stream").unwrap();
629 assert!(d2.centos_stream);
630 }
631
632 #[test]
633 fn test_distros_parse_with_spaces() {
634 let d = Distros::parse("upstream , hs9").unwrap();
635 assert!(d.upstream);
636 assert!(d.hyperscale_9);
637 }
638
639 #[test]
640 fn test_distros_parse_unknown() {
641 let err = Distros::parse("upstream,bogus").unwrap_err();
642 assert!(err.contains("bogus"));
643 }
644
645 #[test]
646 fn test_needs_repology() {
647 let d = Distros::parse("hs9").unwrap();
648 assert!(!d.needs_repology());
649 assert!(d.needs_cbs());
650
651 let d = Distros::parse("upstream").unwrap();
652 assert!(d.needs_repology());
653 assert!(!d.needs_cbs());
654 }
655
656 fn make_build(version: &str, nvr: &str) -> Build {
657 Build {
658 build_id: 1,
659 name: "pkg".into(),
660 version: version.into(),
661 release: String::new(),
662 nvr: nvr.into(),
663 }
664 }
665
666 #[test]
667 fn test_track_ref_parse() {
668 assert_eq!(TrackRef::parse("upstream").unwrap(), TrackRef::Upstream);
669 assert_eq!(
670 TrackRef::parse("fedora-rawhide").unwrap(),
671 TrackRef::FedoraRawhide
672 );
673 assert_eq!(
674 TrackRef::parse("fedora-stable").unwrap(),
675 TrackRef::FedoraStable
676 );
677 assert_eq!(
678 TrackRef::parse("centos").unwrap(),
679 TrackRef::CentosStream
680 );
681 assert_eq!(
682 TrackRef::parse("centos-stream").unwrap(),
683 TrackRef::CentosStream
684 );
685 assert!(TrackRef::parse("bogus").is_err());
686 }
687
688 #[test]
689 fn test_track_ref_parse_trims_spaces() {
690 assert_eq!(
691 TrackRef::parse(" upstream ").unwrap(),
692 TrackRef::Upstream
693 );
694 }
695
696 fn make_hs_result(summary: HyperscaleSummary) -> HyperscaleResult {
697 HyperscaleResult {
698 summary,
699 newest_version: None,
700 }
701 }
702
703 #[test]
704 fn test_result_to_rows_all_fields() {
705 let result = CheckResult {
706 package: "ethtool".into(),
707 upstream: Some("6.19".into()),
708 fedora_rawhide: Some("6.19".into()),
709 fedora_stable: Some(VersionWithDetail {
710 version: "6.19".into(),
711 detail: "fedora_43".into(),
712 }),
713 centos_stream: Some(VersionWithDetail {
714 version: "6.15".into(),
715 detail: "centos_stream_10".into(),
716 }),
717 hs9: Some(make_hs_result(HyperscaleSummary {
718 release: Some(make_build("6.15", "ethtool-6.15-3.hs.el9")),
719 testing: None,
720 })),
721 hs10: None,
722 issue: None,
723 ref_version: None,
724 };
725 let rows = result_to_rows(&result);
726 assert_eq!(rows.len(), 5);
727 assert_eq!(rows[0].distro, "Upstream");
728 assert_eq!(rows[0].version, "6.19");
729 assert_eq!(rows[3].distro, "CentOS Stream");
730 assert_eq!(rows[4].distro, "Hyperscale 9");
731 }
732
733 #[test]
734 fn test_result_to_rows_hs_testing_and_release() {
735 let result = CheckResult {
736 package: "systemd".into(),
737 upstream: None,
738 fedora_rawhide: None,
739 fedora_stable: None,
740 centos_stream: None,
741 hs9: Some(make_hs_result(HyperscaleSummary {
742 release: Some(make_build("258.5", "systemd-258.5-1.1.hs.el9")),
743 testing: Some(make_build("260~rc2", "systemd-260~rc2-20260309.hs.el9")),
744 })),
745 hs10: None,
746 issue: None,
747 ref_version: None,
748 };
749 let rows = result_to_rows(&result);
750 assert_eq!(rows.len(), 2);
751 assert_eq!(rows[0].distro, "Hyperscale 9 (release)");
752 assert_eq!(rows[0].version, "258.5");
753 assert_eq!(rows[1].distro, "Hyperscale 9 (testing)");
754 assert_eq!(rows[1].version, "260~rc2");
755 }
756
757 #[test]
758 fn test_result_to_rows_hs_testing_only() {
759 let result = CheckResult {
760 package: "pkg".into(),
761 upstream: None,
762 fedora_rawhide: None,
763 fedora_stable: None,
764 centos_stream: None,
765 hs9: Some(make_hs_result(HyperscaleSummary {
766 release: None,
767 testing: Some(make_build("1.0", "pkg-1.0-1.hs.el9")),
768 })),
769 hs10: None,
770 issue: None,
771 ref_version: None,
772 };
773 let rows = result_to_rows(&result);
774 assert_eq!(rows.len(), 1);
775 assert_eq!(rows[0].distro, "Hyperscale 9 (testing)");
776 }
777
778 #[test]
779 fn test_result_to_rows_hs_not_found() {
780 let result = CheckResult {
781 package: "pkg".into(),
782 upstream: None,
783 fedora_rawhide: None,
784 fedora_stable: None,
785 centos_stream: None,
786 hs9: Some(make_hs_result(HyperscaleSummary {
787 release: None,
788 testing: None,
789 })),
790 hs10: None,
791 issue: None,
792 ref_version: None,
793 };
794 let rows = result_to_rows(&result);
795 assert_eq!(rows.len(), 1);
796 assert_eq!(rows[0].distro, "Hyperscale 9");
797 assert_eq!(rows[0].version, "not found");
798 }
799
800 #[test]
801 fn test_result_to_rows_with_tracking_outdated() {
802 let result = CheckResult {
803 package: "ethtool".into(),
804 upstream: Some("6.19".into()),
805 fedora_rawhide: None,
806 fedora_stable: None,
807 centos_stream: None,
808 hs9: Some(make_hs_result(HyperscaleSummary {
809 release: Some(make_build("6.15", "ethtool-6.15-3.hs.el9")),
810 testing: None,
811 })),
812 hs10: None,
813 issue: None,
814 ref_version: Some("6.19".into()),
815 };
816 let rows = result_to_rows(&result);
817 assert_eq!(rows[1].status, "outdated");
818 assert_eq!(rows[0].status, ""); }
820
821 #[test]
822 fn test_result_to_rows_with_tracking_newest() {
823 let result = CheckResult {
824 package: "ethtool".into(),
825 upstream: None,
826 fedora_rawhide: None,
827 fedora_stable: None,
828 centos_stream: None,
829 hs9: Some(make_hs_result(HyperscaleSummary {
830 release: Some(make_build("6.15", "ethtool-6.15-3.hs.el9")),
831 testing: None,
832 })),
833 hs10: None,
834 issue: None,
835 ref_version: Some("6.15".into()),
836 };
837 let rows = result_to_rows(&result);
838 assert_eq!(rows[0].status, "newest");
839 }
840
841 #[test]
842 fn test_result_to_rows_tracking_per_build() {
843 let result = CheckResult {
845 package: "systemd".into(),
846 upstream: None,
847 fedora_rawhide: None,
848 fedora_stable: None,
849 centos_stream: None,
850 hs9: Some(make_hs_result(HyperscaleSummary {
851 release: Some(make_build("258.5", "systemd-258.5-1.1.hs.el9")),
852 testing: Some(make_build("260", "systemd-260-1.hs.el9")),
853 })),
854 hs10: None,
855 issue: None,
856 ref_version: Some("260".into()),
857 };
858 let rows = result_to_rows(&result);
859 assert_eq!(rows[0].distro, "Hyperscale 9 (release)");
860 assert_eq!(rows[0].status, "outdated");
861 assert_eq!(rows[1].distro, "Hyperscale 9 (testing)");
862 assert_eq!(rows[1].status, "newest");
863 }
864
865 #[test]
866 fn test_compute_newest_version_matches() {
867 let summary = HyperscaleSummary {
868 release: Some(make_build("6.15", "ethtool-6.15-3.hs.el9")),
869 testing: None,
870 };
871 assert_eq!(
872 compute_newest_version(&summary, &Some("6.15".into())),
873 Some(true)
874 );
875 }
876
877 #[test]
878 fn test_compute_newest_version_outdated() {
879 let summary = HyperscaleSummary {
880 release: Some(make_build("6.15", "ethtool-6.15-3.hs.el9")),
881 testing: None,
882 };
883 assert_eq!(
884 compute_newest_version(&summary, &Some("6.19".into())),
885 Some(false)
886 );
887 }
888
889 #[test]
890 fn test_compute_newest_version_ahead() {
891 let summary = HyperscaleSummary {
892 release: Some(make_build("6.19", "pkg-6.19-1.hs.el9")),
893 testing: None,
894 };
895 assert_eq!(
896 compute_newest_version(&summary, &Some("6.18.16".into())),
897 Some(true)
898 );
899 }
900
901 #[test]
902 fn test_compute_newest_version_no_ref() {
903 let summary = HyperscaleSummary {
904 release: Some(make_build("6.15", "ethtool-6.15-3.hs.el9")),
905 testing: None,
906 };
907 assert_eq!(compute_newest_version(&summary, &None), None);
908 }
909
910 #[test]
911 fn test_compute_newest_version_uses_testing_fallback() {
912 let summary = HyperscaleSummary {
913 release: None,
914 testing: Some(make_build("6.19", "ethtool-6.19-1.hs.el9")),
915 };
916 assert_eq!(
917 compute_newest_version(&summary, &Some("6.19".into())),
918 Some(true)
919 );
920 }
921
922 #[test]
923 fn test_compute_newest_version_no_builds() {
924 let summary = HyperscaleSummary {
925 release: None,
926 testing: None,
927 };
928 assert_eq!(
929 compute_newest_version(&summary, &Some("6.19".into())),
930 None
931 );
932 }
933
934 #[test]
935 fn test_json_serialization() {
936 let result = CheckResult {
937 package: "ethtool".into(),
938 upstream: Some("6.19".into()),
939 fedora_rawhide: None,
940 fedora_stable: None,
941 centos_stream: None,
942 hs9: None,
943 hs10: None,
944 issue: None,
945 ref_version: None,
946 };
947 let json = serde_json::to_value(&result).unwrap();
948 assert_eq!(json["package"], "ethtool");
949 assert_eq!(json["upstream"], "6.19");
950 assert!(json.get("fedora_rawhide").is_none());
952 assert!(json.get("hs9").is_none());
953 }
954
955 #[test]
956 fn test_json_serialization_with_newest_version() {
957 let result = CheckResult {
958 package: "ethtool".into(),
959 upstream: Some("6.19".into()),
960 fedora_rawhide: None,
961 fedora_stable: None,
962 centos_stream: None,
963 hs9: Some(HyperscaleResult {
964 summary: HyperscaleSummary {
965 release: Some(make_build("6.15", "ethtool-6.15-3.hs.el9")),
966 testing: None,
967 },
968 newest_version: Some(false),
969 }),
970 hs10: None,
971 issue: None,
972 ref_version: Some("6.19".into()),
973 };
974 let json = serde_json::to_value(&result).unwrap();
975 assert_eq!(json["hs9"]["newest_version"], false);
976 assert_eq!(json["hs9"]["release"]["version"], "6.15");
977 assert!(json.get("ref_version").is_none());
979 }
980
981 #[test]
982 fn test_is_outdated_true() {
983 let result = CheckResult {
984 package: "pkg".into(),
985 upstream: None,
986 fedora_rawhide: None,
987 fedora_stable: None,
988 centos_stream: None,
989 hs9: Some(HyperscaleResult {
990 summary: HyperscaleSummary {
991 release: Some(make_build("1.0", "pkg-1.0-1.hs.el9")),
992 testing: None,
993 },
994 newest_version: Some(false),
995 }),
996 hs10: None,
997 issue: None,
998 ref_version: Some("2.0".into()),
999 };
1000 assert!(result.is_outdated());
1001 assert_eq!(result.ref_version(), Some("2.0"));
1002 }
1003
1004 #[test]
1005 fn test_is_outdated_false_when_newest() {
1006 let result = CheckResult {
1007 package: "pkg".into(),
1008 upstream: None,
1009 fedora_rawhide: None,
1010 fedora_stable: None,
1011 centos_stream: None,
1012 hs9: Some(HyperscaleResult {
1013 summary: HyperscaleSummary {
1014 release: Some(make_build("2.0", "pkg-2.0-1.hs.el9")),
1015 testing: None,
1016 },
1017 newest_version: Some(true),
1018 }),
1019 hs10: None,
1020 issue: None,
1021 ref_version: Some("2.0".into()),
1022 };
1023 assert!(!result.is_outdated());
1024 }
1025
1026 #[test]
1027 fn test_is_outdated_false_when_no_hs() {
1028 let result = CheckResult {
1029 package: "pkg".into(),
1030 upstream: Some("2.0".into()),
1031 fedora_rawhide: None,
1032 fedora_stable: None,
1033 centos_stream: None,
1034 hs9: None,
1035 hs10: None,
1036 issue: None,
1037 ref_version: None,
1038 };
1039 assert!(!result.is_outdated());
1040 assert_eq!(result.ref_version(), None);
1041 }
1042
1043 #[test]
1044 fn test_is_outdated_mixed_hs9_hs10() {
1045 let result = CheckResult {
1046 package: "pkg".into(),
1047 upstream: None,
1048 fedora_rawhide: None,
1049 fedora_stable: None,
1050 centos_stream: None,
1051 hs9: Some(HyperscaleResult {
1052 summary: HyperscaleSummary {
1053 release: Some(make_build("2.0", "pkg-2.0-1.hs.el9")),
1054 testing: None,
1055 },
1056 newest_version: Some(true),
1057 }),
1058 hs10: Some(HyperscaleResult {
1059 summary: HyperscaleSummary {
1060 release: Some(make_build("1.0", "pkg-1.0-1.hs.el10")),
1061 testing: None,
1062 },
1063 newest_version: Some(false),
1064 }),
1065 issue: None,
1066 ref_version: Some("2.0".into()),
1067 };
1068 assert!(result.is_outdated());
1069 }
1070
1071 #[test]
1072 fn test_format_table() {
1073 let result = CheckResult {
1074 package: "ethtool".into(),
1075 upstream: Some("6.19".into()),
1076 fedora_rawhide: None,
1077 fedora_stable: None,
1078 centos_stream: None,
1079 hs9: Some(make_hs_result(HyperscaleSummary {
1080 release: Some(make_build("6.15", "ethtool-6.15-3.hs.el9")),
1081 testing: None,
1082 })),
1083 hs10: None,
1084 issue: None,
1085 ref_version: Some("6.19".into()),
1086 };
1087 let table = format_table(&result);
1088 assert!(table.contains("ethtool"));
1089 assert!(table.contains("Upstream"));
1090 assert!(table.contains("outdated"));
1091 }
1092
1093 #[test]
1094 fn test_write_table_with_status() {
1095 let result = CheckResult {
1096 package: "ethtool".into(),
1097 upstream: Some("6.19".into()),
1098 fedora_rawhide: None,
1099 fedora_stable: None,
1100 centos_stream: None,
1101 hs9: Some(make_hs_result(HyperscaleSummary {
1102 release: Some(make_build("6.15", "ethtool-6.15-3.hs.el9")),
1103 testing: None,
1104 })),
1105 hs10: None,
1106 issue: None,
1107 ref_version: Some("6.19".into()),
1108 };
1109 let mut buf = Vec::new();
1110 write_table(&result, &mut buf).unwrap();
1111 let output = String::from_utf8(buf).unwrap();
1112 assert!(output.contains("ethtool"));
1113 assert!(output.contains("Upstream"));
1114 assert!(output.contains("6.19"));
1115 assert!(output.contains("outdated"));
1116 assert!(output.contains("Status"));
1117 }
1118
1119 #[test]
1120 fn test_write_table_without_status() {
1121 let result = CheckResult {
1122 package: "pkg".into(),
1123 upstream: Some("1.0".into()),
1124 fedora_rawhide: None,
1125 fedora_stable: Some(VersionWithDetail {
1126 version: "1.0".into(),
1127 detail: "fedora_43".into(),
1128 }),
1129 centos_stream: None,
1130 hs9: None,
1131 hs10: None,
1132 issue: None,
1133 ref_version: None,
1134 };
1135 let mut buf = Vec::new();
1136 write_table(&result, &mut buf).unwrap();
1137 let output = String::from_utf8(buf).unwrap();
1138 assert!(output.contains("pkg"));
1139 assert!(output.contains("Upstream"));
1140 assert!(output.contains("fedora_43"));
1141 assert!(!output.contains("Status"));
1142 }
1143
1144 #[test]
1145 fn test_write_table_empty() {
1146 let result = CheckResult {
1147 package: "pkg".into(),
1148 upstream: None,
1149 fedora_rawhide: None,
1150 fedora_stable: None,
1151 centos_stream: None,
1152 hs9: None,
1153 hs10: None,
1154 issue: None,
1155 ref_version: None,
1156 };
1157 let mut buf = Vec::new();
1158 write_table(&result, &mut buf).unwrap();
1159 assert!(buf.is_empty());
1160 }
1161
1162 #[test]
1163 fn test_write_json() {
1164 let result = CheckResult {
1165 package: "ethtool".into(),
1166 upstream: Some("6.19".into()),
1167 fedora_rawhide: None,
1168 fedora_stable: None,
1169 centos_stream: None,
1170 hs9: None,
1171 hs10: None,
1172 issue: None,
1173 ref_version: None,
1174 };
1175 let mut buf = Vec::new();
1176 write_json(&result, &mut buf).unwrap();
1177 let output = String::from_utf8(buf).unwrap();
1178 let json: serde_json::Value = serde_json::from_str(&output).unwrap();
1179 assert_eq!(json["package"], "ethtool");
1180 assert_eq!(json["upstream"], "6.19");
1181 }
1182
1183 #[test]
1184 fn test_write_json_with_issue() {
1185 let result = CheckResult {
1186 package: "pkg".into(),
1187 upstream: Some("2.0".into()),
1188 fedora_rawhide: None,
1189 fedora_stable: None,
1190 centos_stream: None,
1191 hs9: None,
1192 hs10: None,
1193 issue: Some(IssueRef {
1194 iid: 5,
1195 url: "https://example.com/-/issues/5".into(),
1196 status: "opened".into(),
1197 assignees: vec!["alice".into()],
1198 }),
1199 ref_version: None,
1200 };
1201 let mut buf = Vec::new();
1202 write_json(&result, &mut buf).unwrap();
1203 let output = String::from_utf8(buf).unwrap();
1204 let json: serde_json::Value =
1205 serde_json::from_str(&output).unwrap();
1206 assert_eq!(json["issue"]["iid"], 5);
1207 assert_eq!(json["issue"]["status"], "opened");
1208 assert_eq!(json["issue"]["assignees"][0], "alice");
1209 }
1210
1211 #[test]
1212 fn test_write_json_array() {
1213 let results = vec![
1214 CheckResult {
1215 package: "a".into(),
1216 upstream: Some("1.0".into()),
1217 fedora_rawhide: None,
1218 fedora_stable: None,
1219 centos_stream: None,
1220 hs9: None,
1221 hs10: None,
1222 issue: None,
1223 ref_version: None,
1224 },
1225 CheckResult {
1226 package: "b".into(),
1227 upstream: None,
1228 fedora_rawhide: None,
1229 fedora_stable: None,
1230 centos_stream: None,
1231 hs9: None,
1232 hs10: None,
1233 issue: Some(IssueRef {
1234 iid: 3,
1235 url: "u".into(),
1236 status: "closed".into(),
1237 assignees: vec![],
1238 }),
1239 ref_version: None,
1240 },
1241 ];
1242 let mut buf = Vec::new();
1243 write_json_array(&results, &mut buf).unwrap();
1244 let output = String::from_utf8(buf).unwrap();
1245 let json: serde_json::Value =
1246 serde_json::from_str(&output).unwrap();
1247 let arr = json.as_array().unwrap();
1248 assert_eq!(arr.len(), 2);
1249 assert_eq!(arr[0]["package"], "a");
1250 assert!(arr[0].get("issue").is_none());
1251 assert_eq!(arr[1]["issue"]["iid"], 3);
1252 assert_eq!(arr[1]["issue"]["status"], "closed");
1253 }
1254
1255 #[test]
1256 fn test_json_serialization_without_tracking() {
1257 let result = CheckResult {
1258 package: "pkg".into(),
1259 upstream: None,
1260 fedora_rawhide: None,
1261 fedora_stable: None,
1262 centos_stream: None,
1263 hs9: Some(HyperscaleResult {
1264 summary: HyperscaleSummary {
1265 release: Some(make_build("1.0", "pkg-1.0-1.hs.el9")),
1266 testing: None,
1267 },
1268 newest_version: None,
1269 }),
1270 hs10: None,
1271 issue: None,
1272 ref_version: None,
1273 };
1274 let json = serde_json::to_value(&result).unwrap();
1275 assert!(json["hs9"].get("newest_version").is_none());
1277 }
1278
1279 #[test]
1280 fn test_json_serialization_with_issue() {
1281 let result = CheckResult {
1282 package: "pkg".into(),
1283 upstream: Some("2.0".into()),
1284 fedora_rawhide: None,
1285 fedora_stable: None,
1286 centos_stream: None,
1287 hs9: None,
1288 hs10: None,
1289 issue: Some(IssueRef {
1290 iid: 42,
1291 url: "https://gitlab.com/test/pkg/-/issues/42"
1292 .into(),
1293 status: "opened".into(),
1294 assignees: vec!["alice".into()],
1295 }),
1296 ref_version: None,
1297 };
1298 let json = serde_json::to_value(&result).unwrap();
1299 assert_eq!(json["issue"]["iid"], 42);
1300 assert_eq!(
1301 json["issue"]["url"],
1302 "https://gitlab.com/test/pkg/-/issues/42"
1303 );
1304 assert_eq!(json["issue"]["status"], "opened");
1305 assert_eq!(json["issue"]["assignees"][0], "alice");
1306 }
1307
1308 #[test]
1309 fn test_json_serialization_issue_no_assignees() {
1310 let issue_ref = IssueRef {
1311 iid: 1,
1312 url: "u".into(),
1313 status: "closed".into(),
1314 assignees: vec![],
1315 };
1316 let json = serde_json::to_value(&issue_ref).unwrap();
1317 assert_eq!(json["status"], "closed");
1318 assert!(json.get("assignees").is_none());
1320 }
1321
1322 #[test]
1323 fn test_json_array_serialization() {
1324 let results = vec![
1325 CheckResult {
1326 package: "a".into(),
1327 upstream: Some("1.0".into()),
1328 fedora_rawhide: None,
1329 fedora_stable: None,
1330 centos_stream: None,
1331 hs9: None,
1332 hs10: None,
1333 issue: None,
1334 ref_version: None,
1335 },
1336 CheckResult {
1337 package: "b".into(),
1338 upstream: Some("2.0".into()),
1339 fedora_rawhide: None,
1340 fedora_stable: None,
1341 centos_stream: None,
1342 hs9: None,
1343 hs10: None,
1344 issue: None,
1345 ref_version: None,
1346 },
1347 ];
1348 let json = serde_json::to_value(&results).unwrap();
1349 let arr = json.as_array().unwrap();
1350 assert_eq!(arr.len(), 2);
1351 assert_eq!(arr[0]["package"], "a");
1352 assert_eq!(arr[1]["package"], "b");
1353 }
1354
1355 #[test]
1356 fn test_issue_ref_from_gitlab_issue_with_status() {
1357 use crate::gitlab;
1358 let issue = gitlab::Issue {
1359 iid: 7,
1360 title: "t".into(),
1361 description: None,
1362 state: "opened".into(),
1363 web_url: "https://example.com/issues/7".into(),
1364 assignees: vec![
1365 gitlab::Assignee {
1366 username: "alice".into(),
1367 },
1368 gitlab::Assignee {
1369 username: "bob".into(),
1370 },
1371 ],
1372 };
1373 let r = IssueRef::from_gitlab_issue(
1374 &issue,
1375 Some("To do".into()),
1376 );
1377 assert_eq!(r.iid, 7);
1378 assert_eq!(r.url, "https://example.com/issues/7");
1379 assert_eq!(r.status, "To do");
1380 assert_eq!(r.assignees, vec!["alice", "bob"]);
1381 }
1382
1383 #[test]
1384 fn test_issue_ref_from_gitlab_issue_no_status() {
1385 use crate::gitlab;
1386 let issue = gitlab::Issue {
1387 iid: 1,
1388 title: "t".into(),
1389 description: None,
1390 state: "closed".into(),
1391 web_url: "u".into(),
1392 assignees: vec![],
1393 };
1394 let r = IssueRef::from_gitlab_issue(&issue, None);
1395 assert_eq!(r.status, "closed");
1396 assert!(r.assignees.is_empty());
1397 }
1398
1399 fn make_result_with_issue(
1400 issue: Option<IssueRef>,
1401 ) -> CheckResult {
1402 CheckResult {
1403 package: "pkg".into(),
1404 upstream: None,
1405 fedora_rawhide: None,
1406 fedora_stable: None,
1407 centos_stream: None,
1408 hs9: None,
1409 hs10: None,
1410 issue,
1411 ref_version: None,
1412 }
1413 }
1414
1415 #[test]
1416 fn test_matches_issue_filter_no_issue() {
1417 let r = make_result_with_issue(None);
1418 assert!(!r.matches_issue_filter(None, None));
1419 assert!(!r.matches_issue_filter(
1420 Some("opened"),
1421 None,
1422 ));
1423 }
1424
1425 #[test]
1426 fn test_matches_issue_filter_status() {
1427 let r = make_result_with_issue(Some(IssueRef {
1428 iid: 1,
1429 url: "u".into(),
1430 status: "opened".into(),
1431 assignees: vec![],
1432 }));
1433 assert!(r.matches_issue_filter(
1434 Some("opened"),
1435 None,
1436 ));
1437 assert!(!r.matches_issue_filter(
1438 Some("closed"),
1439 None,
1440 ));
1441 }
1442
1443 #[test]
1444 fn test_matches_issue_filter_assignee() {
1445 let r = make_result_with_issue(Some(IssueRef {
1446 iid: 1,
1447 url: "u".into(),
1448 status: "opened".into(),
1449 assignees: vec![
1450 "alice".into(),
1451 "bob".into(),
1452 ],
1453 }));
1454 assert!(r.matches_issue_filter(
1455 None,
1456 Some("alice"),
1457 ));
1458 assert!(r.matches_issue_filter(
1459 None,
1460 Some("bob"),
1461 ));
1462 assert!(!r.matches_issue_filter(
1463 None,
1464 Some("eve"),
1465 ));
1466 }
1467
1468 #[test]
1469 fn test_matches_issue_filter_both() {
1470 let r = make_result_with_issue(Some(IssueRef {
1471 iid: 1,
1472 url: "u".into(),
1473 status: "opened".into(),
1474 assignees: vec!["alice".into()],
1475 }));
1476 assert!(r.matches_issue_filter(
1477 Some("opened"),
1478 Some("alice"),
1479 ));
1480 assert!(!r.matches_issue_filter(
1481 Some("closed"),
1482 Some("alice"),
1483 ));
1484 assert!(!r.matches_issue_filter(
1485 Some("opened"),
1486 Some("bob"),
1487 ));
1488 }
1489
1490 #[test]
1491 fn test_matches_issue_filter_no_filters() {
1492 let r = make_result_with_issue(Some(IssueRef {
1493 iid: 1,
1494 url: "u".into(),
1495 status: "opened".into(),
1496 assignees: vec![],
1497 }));
1498 assert!(r.matches_issue_filter(None, None));
1499 }
1500
1501 #[test]
1502 fn test_matches_issue_filter_unassigned() {
1503 let unassigned = make_result_with_issue(Some(IssueRef {
1504 iid: 1,
1505 url: "u".into(),
1506 status: "opened".into(),
1507 assignees: vec![],
1508 }));
1509 assert!(unassigned.matches_issue_filter(
1510 None,
1511 Some("none"),
1512 ));
1513
1514 let assigned = make_result_with_issue(Some(IssueRef {
1515 iid: 2,
1516 url: "u".into(),
1517 status: "opened".into(),
1518 assignees: vec!["alice".into()],
1519 }));
1520 assert!(!assigned.matches_issue_filter(
1521 None,
1522 Some("none"),
1523 ));
1524 }
1525
1526 #[test]
1527 fn test_matches_filter_no_filters() {
1528 assert!(matches_filter(
1529 "To do",
1530 &["alice".into()],
1531 None,
1532 None,
1533 ));
1534 }
1535
1536 #[test]
1537 fn test_matches_filter_status() {
1538 assert!(matches_filter(
1539 "To do",
1540 &[],
1541 Some("To do"),
1542 None,
1543 ));
1544 assert!(!matches_filter(
1545 "Done",
1546 &[],
1547 Some("To do"),
1548 None,
1549 ));
1550 }
1551
1552 #[test]
1553 fn test_matches_filter_assignee() {
1554 let a: Vec<String> = vec!["alice".into()];
1555 assert!(matches_filter(
1556 "To do",
1557 &a,
1558 None,
1559 Some("alice"),
1560 ));
1561 assert!(!matches_filter(
1562 "To do",
1563 &a,
1564 None,
1565 Some("bob"),
1566 ));
1567 }
1568
1569 #[test]
1570 fn test_matches_filter_none_assignee() {
1571 assert!(matches_filter(
1572 "To do",
1573 &[],
1574 None,
1575 Some("none"),
1576 ));
1577 assert!(!matches_filter(
1578 "To do",
1579 &["alice".into()],
1580 None,
1581 Some("none"),
1582 ));
1583 }
1584
1585 #[test]
1586 fn test_matches_filter_both() {
1587 let a: Vec<String> = vec!["alice".into()];
1588 assert!(matches_filter(
1589 "To do",
1590 &a,
1591 Some("To do"),
1592 Some("alice"),
1593 ));
1594 assert!(!matches_filter(
1595 "Done",
1596 &a,
1597 Some("To do"),
1598 Some("alice"),
1599 ));
1600 assert!(!matches_filter(
1601 "To do",
1602 &a,
1603 Some("To do"),
1604 Some("bob"),
1605 ));
1606 }
1607}