Skip to main content

hs_relmon/
check_latest.rs

1// SPDX-License-Identifier: MPL-2.0
2
3use crate::cbs::{self, HyperscaleSummary};
4use crate::repology;
5use serde::Serialize;
6
7/// Which distribution sources to check.
8#[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    /// All sources enabled (the default).
20    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    /// Parse a comma-separated list of distro names.
32    ///
33    /// Valid names: `upstream`, `fedora` (rawhide + stable), `fedora-rawhide`,
34    /// `fedora-stable`, `centos`, `hyperscale` (9 + 10), `hs9`, `hs10`.
35    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/// Which distribution to compare Hyperscale builds against.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum TrackRef {
78    Upstream,
79    FedoraRawhide,
80    FedoraStable,
81    CentosStream,
82}
83
84impl TrackRef {
85    /// Parse a track reference name.
86    ///
87    /// Valid names: `upstream`, `fedora-rawhide`, `fedora-stable`, `centos`,
88    /// `centos-stream`.
89    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    /// Resolve the reference version from Repology package data.
100    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/// A Hyperscale summary with optional freshness status.
117#[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/// Result of checking a single package across selected distros.
126#[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    /// Reference version for tracking (not included in JSON).
142    #[serde(skip)]
143    ref_version: Option<String>,
144}
145
146#[derive(Debug, Serialize)]
147pub struct VersionWithDetail {
148    pub version: String,
149    pub detail: String,
150}
151
152impl CheckResult {
153    /// Whether any Hyperscale build is outdated relative to the reference.
154    pub fn is_outdated(&self) -> bool {
155        [&self.hs9, &self.hs10]
156            .iter()
157            .filter_map(|r| r.as_ref())
158            .any(|r| r.newest_version == Some(false))
159    }
160
161    /// The reference version used for tracking.
162    pub fn ref_version(&self) -> Option<&str> {
163        self.ref_version.as_deref()
164    }
165}
166
167/// Run the check-latest query for a package with the given distro selection.
168///
169/// The `track` reference determines which distribution Hyperscale builds are
170/// compared against to determine freshness (newest / outdated).
171pub fn check(
172    repology_client: &repology::Client,
173    cbs_client: &cbs::Client,
174    package: &str,
175    repology_name: &str,
176    distros: &Distros,
177    track: &TrackRef,
178) -> Result<CheckResult, Box<dyn std::error::Error>> {
179    let mut result = CheckResult {
180        package: package.to_string(),
181        upstream: None,
182        fedora_rawhide: None,
183        fedora_stable: None,
184        centos_stream: None,
185        hs9: None,
186        hs10: None,
187        ref_version: None,
188    };
189
190    // Fetch Repology data if needed for display or for tracking reference.
191    let fetch_repology = distros.needs_repology() || distros.needs_cbs();
192    let packages = if fetch_repology {
193        repology_client.get_project(repology_name)?
194    } else {
195        Vec::new()
196    };
197
198    if distros.upstream {
199        result.upstream = repology::find_newest(&packages).map(|p| p.version.clone());
200    }
201    if distros.fedora_rawhide {
202        result.fedora_rawhide = repology::latest_for_repo(&packages, "fedora_rawhide")
203            .map(|p| p.version.clone());
204    }
205    if distros.fedora_stable {
206        result.fedora_stable = repology::latest_fedora_stable(&packages).map(|p| {
207            VersionWithDetail {
208                version: p.version.clone(),
209                detail: p.repo.clone(),
210            }
211        });
212    }
213    if distros.centos_stream {
214        result.centos_stream =
215            repology::latest_centos_stream(&packages).map(|p| VersionWithDetail {
216                version: p.version.clone(),
217                detail: p.repo.clone(),
218            });
219    }
220
221    let ref_version = track.resolve(&packages);
222    result.ref_version = ref_version.clone();
223
224    if distros.needs_cbs() {
225        let builds = cbs_client
226            .get_package_id(package)?
227            .map(|id| cbs_client.list_builds(id))
228            .transpose()?;
229        let empty = Vec::new();
230        let builds = builds.as_deref().unwrap_or(&empty);
231
232        if distros.hyperscale_9 {
233            let summary = cbs_client.hyperscale_summary(builds, 9)?;
234            let newest_version = compute_newest_version(&summary, &ref_version);
235            result.hs9 = Some(HyperscaleResult {
236                summary,
237                newest_version,
238            });
239        }
240        if distros.hyperscale_10 {
241            let summary = cbs_client.hyperscale_summary(builds, 10)?;
242            let newest_version = compute_newest_version(&summary, &ref_version);
243            result.hs10 = Some(HyperscaleResult {
244                summary,
245                newest_version,
246            });
247        }
248    }
249
250    Ok(result)
251}
252
253/// Determine whether the effective Hyperscale version is at least as
254/// new as the reference.
255///
256/// Uses the release build version, falling back to testing if no release exists.
257/// Returns `None` if the reference version is unknown.
258fn compute_newest_version(summary: &HyperscaleSummary, ref_version: &Option<String>) -> Option<bool> {
259    let ref_ver = ref_version.as_ref()?;
260    let effective = summary.release.as_ref().or(summary.testing.as_ref())?;
261    Some(repology::version_cmp(&effective.version, ref_ver) != std::cmp::Ordering::Less)
262}
263
264/// A single row in the output table.
265struct Row {
266    distro: String,
267    version: String,
268    detail: String,
269    status: String,
270}
271
272/// Collect the result into table rows.
273fn result_to_rows(result: &CheckResult) -> Vec<Row> {
274    let mut rows = Vec::new();
275
276    if let Some(v) = &result.upstream {
277        rows.push(Row {
278            distro: "Upstream".into(),
279            version: v.clone(),
280            detail: String::new(),
281            status: String::new(),
282        });
283    }
284    if let Some(v) = &result.fedora_rawhide {
285        rows.push(Row {
286            distro: "Fedora Rawhide".into(),
287            version: v.clone(),
288            detail: String::new(),
289            status: String::new(),
290        });
291    }
292    if let Some(vd) = &result.fedora_stable {
293        rows.push(Row {
294            distro: "Fedora Stable".into(),
295            version: vd.version.clone(),
296            detail: vd.detail.clone(),
297            status: String::new(),
298        });
299    }
300    if let Some(vd) = &result.centos_stream {
301        rows.push(Row {
302            distro: "CentOS Stream".into(),
303            version: vd.version.clone(),
304            detail: vd.detail.clone(),
305            status: String::new(),
306        });
307    }
308    if let Some(hs_result) = &result.hs9 {
309        hs_rows(&mut rows, "Hyperscale 9", &hs_result.summary, result.ref_version.as_deref());
310    }
311    if let Some(hs_result) = &result.hs10 {
312        hs_rows(&mut rows, "Hyperscale 10", &hs_result.summary, result.ref_version.as_deref());
313    }
314
315    rows
316}
317
318fn version_status(version: &str, ref_version: Option<&str>) -> String {
319    match ref_version {
320        Some(ref_ver) => {
321            if repology::version_cmp(version, ref_ver) != std::cmp::Ordering::Less {
322                "newest".into()
323            } else {
324                "outdated".into()
325            }
326        }
327        None => String::new(),
328    }
329}
330
331fn hs_rows(rows: &mut Vec<Row>, label: &str, summary: &HyperscaleSummary, ref_version: Option<&str>) {
332    match (&summary.release, &summary.testing) {
333        (Some(rel), Some(test)) => {
334            rows.push(Row {
335                distro: format!("{label} (release)"),
336                version: rel.version.clone(),
337                detail: rel.nvr.clone(),
338                status: version_status(&rel.version, ref_version),
339            });
340            rows.push(Row {
341                distro: format!("{label} (testing)"),
342                version: test.version.clone(),
343                detail: test.nvr.clone(),
344                status: version_status(&test.version, ref_version),
345            });
346        }
347        (Some(rel), None) => {
348            rows.push(Row {
349                distro: label.into(),
350                version: rel.version.clone(),
351                detail: rel.nvr.clone(),
352                status: version_status(&rel.version, ref_version),
353            });
354        }
355        (None, Some(test)) => {
356            rows.push(Row {
357                distro: format!("{label} (testing)"),
358                version: test.version.clone(),
359                detail: test.nvr.clone(),
360                status: version_status(&test.version, ref_version),
361            });
362        }
363        (None, None) => {
364            rows.push(Row {
365                distro: label.into(),
366                version: "not found".into(),
367                detail: String::new(),
368                status: String::new(),
369            });
370        }
371    }
372}
373
374/// Format the result as a table string.
375pub fn format_table(result: &CheckResult) -> String {
376    let mut buf = Vec::new();
377    let _ = write_table(result, &mut buf);
378    String::from_utf8(buf).unwrap_or_default()
379}
380
381/// Format the result as a table and print to stdout.
382pub fn print_table(result: &CheckResult) {
383    let _ = write_table(result, &mut std::io::stdout().lock());
384}
385
386/// Format the result as JSON and print to stdout.
387pub fn print_json(result: &CheckResult) -> Result<(), Box<dyn std::error::Error>> {
388    write_json(result, &mut std::io::stdout().lock())?;
389    Ok(())
390}
391
392fn write_table(
393    result: &CheckResult,
394    w: &mut dyn std::io::Write,
395) -> std::io::Result<()> {
396    let rows = result_to_rows(result);
397    if rows.is_empty() {
398        return Ok(());
399    }
400
401    let distro_w = rows.iter().map(|r| r.distro.len()).max().unwrap_or(0).max("Distribution".len());
402    let version_w = rows.iter().map(|r| r.version.len()).max().unwrap_or(0).max("Version".len());
403    let has_status = rows.iter().any(|r| !r.status.is_empty());
404    let detail_w = rows
405        .iter()
406        .map(|r| r.detail.len())
407        .max()
408        .unwrap_or(0)
409        .max("Detail".len());
410
411    writeln!(w, "{}", result.package)?;
412    if has_status {
413        writeln!(
414            w,
415            "  {:<distro_w$}  {:<version_w$}  {:<detail_w$}  {}",
416            "Distribution", "Version", "Detail", "Status"
417        )?;
418        writeln!(
419            w,
420            "  {:<distro_w$}  {:<version_w$}  {:<detail_w$}  {}",
421            "─".repeat(distro_w),
422            "─".repeat(version_w),
423            "─".repeat(detail_w),
424            "──────"
425        )?;
426    } else {
427        writeln!(
428            w,
429            "  {:<distro_w$}  {:<version_w$}  {}",
430            "Distribution", "Version", "Detail"
431        )?;
432        writeln!(
433            w,
434            "  {:<distro_w$}  {:<version_w$}  {}",
435            "─".repeat(distro_w),
436            "─".repeat(version_w),
437            "──────"
438        )?;
439    }
440    for row in &rows {
441        if !row.status.is_empty() {
442            writeln!(
443                w,
444                "  {:<distro_w$}  {:<version_w$}  {:<detail_w$}  {}",
445                row.distro, row.version, row.detail, row.status
446            )?;
447        } else if row.detail.is_empty() {
448            writeln!(w, "  {:<distro_w$}  {}", row.distro, row.version)?;
449        } else {
450            writeln!(
451                w,
452                "  {:<distro_w$}  {:<version_w$}  {}",
453                row.distro, row.version, row.detail
454            )?;
455        }
456    }
457    Ok(())
458}
459
460fn write_json(
461    result: &CheckResult,
462    w: &mut dyn std::io::Write,
463) -> Result<(), Box<dyn std::error::Error>> {
464    writeln!(w, "{}", serde_json::to_string_pretty(result)?)?;
465    Ok(())
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471    use crate::cbs::Build;
472
473    #[test]
474    fn test_distros_all() {
475        let d = Distros::all();
476        assert!(d.upstream);
477        assert!(d.fedora_rawhide);
478        assert!(d.fedora_stable);
479        assert!(d.centos_stream);
480        assert!(d.hyperscale_9);
481        assert!(d.hyperscale_10);
482    }
483
484    #[test]
485    fn test_distros_parse_single() {
486        let d = Distros::parse("upstream").unwrap();
487        assert!(d.upstream);
488        assert!(!d.fedora_rawhide);
489        assert!(!d.hyperscale_9);
490    }
491
492    #[test]
493    fn test_distros_parse_fedora_expands() {
494        let d = Distros::parse("fedora").unwrap();
495        assert!(d.fedora_rawhide);
496        assert!(d.fedora_stable);
497        assert!(!d.upstream);
498    }
499
500    #[test]
501    fn test_distros_parse_hyperscale_expands() {
502        let d = Distros::parse("hyperscale").unwrap();
503        assert!(d.hyperscale_9);
504        assert!(d.hyperscale_10);
505        assert!(!d.upstream);
506    }
507
508    #[test]
509    fn test_distros_parse_hs_alias() {
510        let d = Distros::parse("hs").unwrap();
511        assert!(d.hyperscale_9);
512        assert!(d.hyperscale_10);
513    }
514
515    #[test]
516    fn test_distros_parse_comma_separated() {
517        let d = Distros::parse("upstream,fedora-rawhide,hs10").unwrap();
518        assert!(d.upstream);
519        assert!(d.fedora_rawhide);
520        assert!(!d.fedora_stable);
521        assert!(d.hyperscale_10);
522        assert!(!d.hyperscale_9);
523    }
524
525    #[test]
526    fn test_distros_parse_centos_aliases() {
527        let d1 = Distros::parse("centos").unwrap();
528        assert!(d1.centos_stream);
529        let d2 = Distros::parse("centos-stream").unwrap();
530        assert!(d2.centos_stream);
531    }
532
533    #[test]
534    fn test_distros_parse_with_spaces() {
535        let d = Distros::parse("upstream , hs9").unwrap();
536        assert!(d.upstream);
537        assert!(d.hyperscale_9);
538    }
539
540    #[test]
541    fn test_distros_parse_unknown() {
542        let err = Distros::parse("upstream,bogus").unwrap_err();
543        assert!(err.contains("bogus"));
544    }
545
546    #[test]
547    fn test_needs_repology() {
548        let d = Distros::parse("hs9").unwrap();
549        assert!(!d.needs_repology());
550        assert!(d.needs_cbs());
551
552        let d = Distros::parse("upstream").unwrap();
553        assert!(d.needs_repology());
554        assert!(!d.needs_cbs());
555    }
556
557    fn make_build(version: &str, nvr: &str) -> Build {
558        Build {
559            build_id: 1,
560            name: "pkg".into(),
561            version: version.into(),
562            release: String::new(),
563            nvr: nvr.into(),
564        }
565    }
566
567    #[test]
568    fn test_track_ref_parse() {
569        assert_eq!(TrackRef::parse("upstream").unwrap(), TrackRef::Upstream);
570        assert_eq!(
571            TrackRef::parse("fedora-rawhide").unwrap(),
572            TrackRef::FedoraRawhide
573        );
574        assert_eq!(
575            TrackRef::parse("fedora-stable").unwrap(),
576            TrackRef::FedoraStable
577        );
578        assert_eq!(
579            TrackRef::parse("centos").unwrap(),
580            TrackRef::CentosStream
581        );
582        assert_eq!(
583            TrackRef::parse("centos-stream").unwrap(),
584            TrackRef::CentosStream
585        );
586        assert!(TrackRef::parse("bogus").is_err());
587    }
588
589    #[test]
590    fn test_track_ref_parse_trims_spaces() {
591        assert_eq!(
592            TrackRef::parse("  upstream  ").unwrap(),
593            TrackRef::Upstream
594        );
595    }
596
597    fn make_hs_result(summary: HyperscaleSummary) -> HyperscaleResult {
598        HyperscaleResult {
599            summary,
600            newest_version: None,
601        }
602    }
603
604    #[test]
605    fn test_result_to_rows_all_fields() {
606        let result = CheckResult {
607            package: "ethtool".into(),
608            upstream: Some("6.19".into()),
609            fedora_rawhide: Some("6.19".into()),
610            fedora_stable: Some(VersionWithDetail {
611                version: "6.19".into(),
612                detail: "fedora_43".into(),
613            }),
614            centos_stream: Some(VersionWithDetail {
615                version: "6.15".into(),
616                detail: "centos_stream_10".into(),
617            }),
618            hs9: Some(make_hs_result(HyperscaleSummary {
619                release: Some(make_build("6.15", "ethtool-6.15-3.hs.el9")),
620                testing: None,
621            })),
622            hs10: None,
623            ref_version: None,
624        };
625        let rows = result_to_rows(&result);
626        assert_eq!(rows.len(), 5);
627        assert_eq!(rows[0].distro, "Upstream");
628        assert_eq!(rows[0].version, "6.19");
629        assert_eq!(rows[3].distro, "CentOS Stream");
630        assert_eq!(rows[4].distro, "Hyperscale 9");
631    }
632
633    #[test]
634    fn test_result_to_rows_hs_testing_and_release() {
635        let result = CheckResult {
636            package: "systemd".into(),
637            upstream: None,
638            fedora_rawhide: None,
639            fedora_stable: None,
640            centos_stream: None,
641            hs9: Some(make_hs_result(HyperscaleSummary {
642                release: Some(make_build("258.5", "systemd-258.5-1.1.hs.el9")),
643                testing: Some(make_build("260~rc2", "systemd-260~rc2-20260309.hs.el9")),
644            })),
645            hs10: None,
646            ref_version: None,
647        };
648        let rows = result_to_rows(&result);
649        assert_eq!(rows.len(), 2);
650        assert_eq!(rows[0].distro, "Hyperscale 9 (release)");
651        assert_eq!(rows[0].version, "258.5");
652        assert_eq!(rows[1].distro, "Hyperscale 9 (testing)");
653        assert_eq!(rows[1].version, "260~rc2");
654    }
655
656    #[test]
657    fn test_result_to_rows_hs_testing_only() {
658        let result = CheckResult {
659            package: "pkg".into(),
660            upstream: None,
661            fedora_rawhide: None,
662            fedora_stable: None,
663            centos_stream: None,
664            hs9: Some(make_hs_result(HyperscaleSummary {
665                release: None,
666                testing: Some(make_build("1.0", "pkg-1.0-1.hs.el9")),
667            })),
668            hs10: None,
669            ref_version: None,
670        };
671        let rows = result_to_rows(&result);
672        assert_eq!(rows.len(), 1);
673        assert_eq!(rows[0].distro, "Hyperscale 9 (testing)");
674    }
675
676    #[test]
677    fn test_result_to_rows_hs_not_found() {
678        let result = CheckResult {
679            package: "pkg".into(),
680            upstream: None,
681            fedora_rawhide: None,
682            fedora_stable: None,
683            centos_stream: None,
684            hs9: Some(make_hs_result(HyperscaleSummary {
685                release: None,
686                testing: None,
687            })),
688            hs10: None,
689            ref_version: None,
690        };
691        let rows = result_to_rows(&result);
692        assert_eq!(rows.len(), 1);
693        assert_eq!(rows[0].distro, "Hyperscale 9");
694        assert_eq!(rows[0].version, "not found");
695    }
696
697    #[test]
698    fn test_result_to_rows_with_tracking_outdated() {
699        let result = CheckResult {
700            package: "ethtool".into(),
701            upstream: Some("6.19".into()),
702            fedora_rawhide: None,
703            fedora_stable: None,
704            centos_stream: None,
705            hs9: Some(make_hs_result(HyperscaleSummary {
706                release: Some(make_build("6.15", "ethtool-6.15-3.hs.el9")),
707                testing: None,
708            })),
709            hs10: None,
710            ref_version: Some("6.19".into()),
711        };
712        let rows = result_to_rows(&result);
713        assert_eq!(rows[1].status, "outdated");
714        assert_eq!(rows[0].status, ""); // non-HS rows have no status
715    }
716
717    #[test]
718    fn test_result_to_rows_with_tracking_newest() {
719        let result = CheckResult {
720            package: "ethtool".into(),
721            upstream: None,
722            fedora_rawhide: None,
723            fedora_stable: None,
724            centos_stream: None,
725            hs9: Some(make_hs_result(HyperscaleSummary {
726                release: Some(make_build("6.15", "ethtool-6.15-3.hs.el9")),
727                testing: None,
728            })),
729            hs10: None,
730            ref_version: Some("6.15".into()),
731        };
732        let rows = result_to_rows(&result);
733        assert_eq!(rows[0].status, "newest");
734    }
735
736    #[test]
737    fn test_result_to_rows_tracking_per_build() {
738        // release is outdated, testing is newest
739        let result = CheckResult {
740            package: "systemd".into(),
741            upstream: None,
742            fedora_rawhide: None,
743            fedora_stable: None,
744            centos_stream: None,
745            hs9: Some(make_hs_result(HyperscaleSummary {
746                release: Some(make_build("258.5", "systemd-258.5-1.1.hs.el9")),
747                testing: Some(make_build("260", "systemd-260-1.hs.el9")),
748            })),
749            hs10: None,
750            ref_version: Some("260".into()),
751        };
752        let rows = result_to_rows(&result);
753        assert_eq!(rows[0].distro, "Hyperscale 9 (release)");
754        assert_eq!(rows[0].status, "outdated");
755        assert_eq!(rows[1].distro, "Hyperscale 9 (testing)");
756        assert_eq!(rows[1].status, "newest");
757    }
758
759    #[test]
760    fn test_compute_newest_version_matches() {
761        let summary = HyperscaleSummary {
762            release: Some(make_build("6.15", "ethtool-6.15-3.hs.el9")),
763            testing: None,
764        };
765        assert_eq!(
766            compute_newest_version(&summary, &Some("6.15".into())),
767            Some(true)
768        );
769    }
770
771    #[test]
772    fn test_compute_newest_version_outdated() {
773        let summary = HyperscaleSummary {
774            release: Some(make_build("6.15", "ethtool-6.15-3.hs.el9")),
775            testing: None,
776        };
777        assert_eq!(
778            compute_newest_version(&summary, &Some("6.19".into())),
779            Some(false)
780        );
781    }
782
783    #[test]
784    fn test_compute_newest_version_ahead() {
785        let summary = HyperscaleSummary {
786            release: Some(make_build("6.19", "pkg-6.19-1.hs.el9")),
787            testing: None,
788        };
789        assert_eq!(
790            compute_newest_version(&summary, &Some("6.18.16".into())),
791            Some(true)
792        );
793    }
794
795    #[test]
796    fn test_compute_newest_version_no_ref() {
797        let summary = HyperscaleSummary {
798            release: Some(make_build("6.15", "ethtool-6.15-3.hs.el9")),
799            testing: None,
800        };
801        assert_eq!(compute_newest_version(&summary, &None), None);
802    }
803
804    #[test]
805    fn test_compute_newest_version_uses_testing_fallback() {
806        let summary = HyperscaleSummary {
807            release: None,
808            testing: Some(make_build("6.19", "ethtool-6.19-1.hs.el9")),
809        };
810        assert_eq!(
811            compute_newest_version(&summary, &Some("6.19".into())),
812            Some(true)
813        );
814    }
815
816    #[test]
817    fn test_compute_newest_version_no_builds() {
818        let summary = HyperscaleSummary {
819            release: None,
820            testing: None,
821        };
822        assert_eq!(
823            compute_newest_version(&summary, &Some("6.19".into())),
824            None
825        );
826    }
827
828    #[test]
829    fn test_json_serialization() {
830        let result = CheckResult {
831            package: "ethtool".into(),
832            upstream: Some("6.19".into()),
833            fedora_rawhide: None,
834            fedora_stable: None,
835            centos_stream: None,
836            hs9: None,
837            hs10: None,
838            ref_version: None,
839        };
840        let json = serde_json::to_value(&result).unwrap();
841        assert_eq!(json["package"], "ethtool");
842        assert_eq!(json["upstream"], "6.19");
843        // None fields should be absent
844        assert!(json.get("fedora_rawhide").is_none());
845        assert!(json.get("hs9").is_none());
846    }
847
848    #[test]
849    fn test_json_serialization_with_newest_version() {
850        let result = CheckResult {
851            package: "ethtool".into(),
852            upstream: Some("6.19".into()),
853            fedora_rawhide: None,
854            fedora_stable: None,
855            centos_stream: None,
856            hs9: Some(HyperscaleResult {
857                summary: HyperscaleSummary {
858                    release: Some(make_build("6.15", "ethtool-6.15-3.hs.el9")),
859                    testing: None,
860                },
861                newest_version: Some(false),
862            }),
863            hs10: None,
864            ref_version: Some("6.19".into()),
865        };
866        let json = serde_json::to_value(&result).unwrap();
867        assert_eq!(json["hs9"]["newest_version"], false);
868        assert_eq!(json["hs9"]["release"]["version"], "6.15");
869        // ref_version should not appear in JSON
870        assert!(json.get("ref_version").is_none());
871    }
872
873    #[test]
874    fn test_is_outdated_true() {
875        let result = CheckResult {
876            package: "pkg".into(),
877            upstream: None,
878            fedora_rawhide: None,
879            fedora_stable: None,
880            centos_stream: None,
881            hs9: Some(HyperscaleResult {
882                summary: HyperscaleSummary {
883                    release: Some(make_build("1.0", "pkg-1.0-1.hs.el9")),
884                    testing: None,
885                },
886                newest_version: Some(false),
887            }),
888            hs10: None,
889            ref_version: Some("2.0".into()),
890        };
891        assert!(result.is_outdated());
892        assert_eq!(result.ref_version(), Some("2.0"));
893    }
894
895    #[test]
896    fn test_is_outdated_false_when_newest() {
897        let result = CheckResult {
898            package: "pkg".into(),
899            upstream: None,
900            fedora_rawhide: None,
901            fedora_stable: None,
902            centos_stream: None,
903            hs9: Some(HyperscaleResult {
904                summary: HyperscaleSummary {
905                    release: Some(make_build("2.0", "pkg-2.0-1.hs.el9")),
906                    testing: None,
907                },
908                newest_version: Some(true),
909            }),
910            hs10: None,
911            ref_version: Some("2.0".into()),
912        };
913        assert!(!result.is_outdated());
914    }
915
916    #[test]
917    fn test_is_outdated_false_when_no_hs() {
918        let result = CheckResult {
919            package: "pkg".into(),
920            upstream: Some("2.0".into()),
921            fedora_rawhide: None,
922            fedora_stable: None,
923            centos_stream: None,
924            hs9: None,
925            hs10: None,
926            ref_version: None,
927        };
928        assert!(!result.is_outdated());
929        assert_eq!(result.ref_version(), None);
930    }
931
932    #[test]
933    fn test_is_outdated_mixed_hs9_hs10() {
934        let result = CheckResult {
935            package: "pkg".into(),
936            upstream: None,
937            fedora_rawhide: None,
938            fedora_stable: None,
939            centos_stream: None,
940            hs9: Some(HyperscaleResult {
941                summary: HyperscaleSummary {
942                    release: Some(make_build("2.0", "pkg-2.0-1.hs.el9")),
943                    testing: None,
944                },
945                newest_version: Some(true),
946            }),
947            hs10: Some(HyperscaleResult {
948                summary: HyperscaleSummary {
949                    release: Some(make_build("1.0", "pkg-1.0-1.hs.el10")),
950                    testing: None,
951                },
952                newest_version: Some(false),
953            }),
954            ref_version: Some("2.0".into()),
955        };
956        assert!(result.is_outdated());
957    }
958
959    #[test]
960    fn test_format_table() {
961        let result = CheckResult {
962            package: "ethtool".into(),
963            upstream: Some("6.19".into()),
964            fedora_rawhide: None,
965            fedora_stable: None,
966            centos_stream: None,
967            hs9: Some(make_hs_result(HyperscaleSummary {
968                release: Some(make_build("6.15", "ethtool-6.15-3.hs.el9")),
969                testing: None,
970            })),
971            hs10: None,
972            ref_version: Some("6.19".into()),
973        };
974        let table = format_table(&result);
975        assert!(table.contains("ethtool"));
976        assert!(table.contains("Upstream"));
977        assert!(table.contains("outdated"));
978    }
979
980    #[test]
981    fn test_write_table_with_status() {
982        let result = CheckResult {
983            package: "ethtool".into(),
984            upstream: Some("6.19".into()),
985            fedora_rawhide: None,
986            fedora_stable: None,
987            centos_stream: None,
988            hs9: Some(make_hs_result(HyperscaleSummary {
989                release: Some(make_build("6.15", "ethtool-6.15-3.hs.el9")),
990                testing: None,
991            })),
992            hs10: None,
993            ref_version: Some("6.19".into()),
994        };
995        let mut buf = Vec::new();
996        write_table(&result, &mut buf).unwrap();
997        let output = String::from_utf8(buf).unwrap();
998        assert!(output.contains("ethtool"));
999        assert!(output.contains("Upstream"));
1000        assert!(output.contains("6.19"));
1001        assert!(output.contains("outdated"));
1002        assert!(output.contains("Status"));
1003    }
1004
1005    #[test]
1006    fn test_write_table_without_status() {
1007        let result = CheckResult {
1008            package: "pkg".into(),
1009            upstream: Some("1.0".into()),
1010            fedora_rawhide: None,
1011            fedora_stable: Some(VersionWithDetail {
1012                version: "1.0".into(),
1013                detail: "fedora_43".into(),
1014            }),
1015            centos_stream: None,
1016            hs9: None,
1017            hs10: None,
1018            ref_version: None,
1019        };
1020        let mut buf = Vec::new();
1021        write_table(&result, &mut buf).unwrap();
1022        let output = String::from_utf8(buf).unwrap();
1023        assert!(output.contains("pkg"));
1024        assert!(output.contains("Upstream"));
1025        assert!(output.contains("fedora_43"));
1026        assert!(!output.contains("Status"));
1027    }
1028
1029    #[test]
1030    fn test_write_table_empty() {
1031        let result = CheckResult {
1032            package: "pkg".into(),
1033            upstream: None,
1034            fedora_rawhide: None,
1035            fedora_stable: None,
1036            centos_stream: None,
1037            hs9: None,
1038            hs10: None,
1039            ref_version: None,
1040        };
1041        let mut buf = Vec::new();
1042        write_table(&result, &mut buf).unwrap();
1043        assert!(buf.is_empty());
1044    }
1045
1046    #[test]
1047    fn test_write_json() {
1048        let result = CheckResult {
1049            package: "ethtool".into(),
1050            upstream: Some("6.19".into()),
1051            fedora_rawhide: None,
1052            fedora_stable: None,
1053            centos_stream: None,
1054            hs9: None,
1055            hs10: None,
1056            ref_version: None,
1057        };
1058        let mut buf = Vec::new();
1059        write_json(&result, &mut buf).unwrap();
1060        let output = String::from_utf8(buf).unwrap();
1061        let json: serde_json::Value = serde_json::from_str(&output).unwrap();
1062        assert_eq!(json["package"], "ethtool");
1063        assert_eq!(json["upstream"], "6.19");
1064    }
1065
1066    #[test]
1067    fn test_json_serialization_without_tracking() {
1068        let result = CheckResult {
1069            package: "pkg".into(),
1070            upstream: None,
1071            fedora_rawhide: None,
1072            fedora_stable: None,
1073            centos_stream: None,
1074            hs9: Some(HyperscaleResult {
1075                summary: HyperscaleSummary {
1076                    release: Some(make_build("1.0", "pkg-1.0-1.hs.el9")),
1077                    testing: None,
1078                },
1079                newest_version: None,
1080            }),
1081            hs10: None,
1082            ref_version: None,
1083        };
1084        let json = serde_json::to_value(&result).unwrap();
1085        // newest_version should be absent when None
1086        assert!(json["hs9"].get("newest_version").is_none());
1087    }
1088}