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    #[serde(skip_serializing_if = "Option::is_none")]
142    pub issue: Option<IssueRef>,
143    /// Reference version for tracking (not included in JSON).
144    #[serde(skip)]
145    ref_version: Option<String>,
146}
147
148/// Reference to a GitLab issue.
149#[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    /// Build an `IssueRef` from a GitLab API issue.
160    ///
161    /// `status` is the resolved work-item status
162    /// (e.g. "To do"), falling back to `issue.state` if
163    /// the GraphQL status is unavailable.
164    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    /// Whether any Hyperscale build is outdated relative to the reference.
190    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    /// The reference version used for tracking.
198    pub fn ref_version(&self) -> Option<&str> {
199        self.ref_version.as_deref()
200    }
201
202    /// Whether the issue (if any) matches the given filters.
203    ///
204    /// Returns `false` if there is no issue. Both filters
205    /// must match when provided.
206    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
224/// Whether an issue matches the given status/assignee filters.
225///
226/// `"none"` as assignee matches issues with no assignees.
227pub 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
250/// Run the check-latest query for a package with the given distro selection.
251///
252/// The `track` reference determines which distribution Hyperscale builds are
253/// compared against to determine freshness (newest / outdated).
254pub 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    // Fetch Repology data if needed for display or for tracking reference.
275    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
337/// Determine whether the effective Hyperscale version is at least as
338/// new as the reference.
339///
340/// Uses the release build version, falling back to testing if no release exists.
341/// Returns `None` if the reference version is unknown.
342fn 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
348/// A single row in the output table.
349struct Row {
350    distro: String,
351    version: String,
352    detail: String,
353    status: String,
354}
355
356/// Collect the result into table rows.
357fn 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
458/// Format the result as a table string.
459pub 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
465/// Format the result as a table and print to stdout.
466pub fn print_table(result: &CheckResult) {
467    let _ = write_table(result, &mut std::io::stdout().lock());
468}
469
470/// Format the result as JSON and print to stdout.
471pub fn print_json(result: &CheckResult) -> Result<(), Box<dyn std::error::Error>> {
472    write_json(result, &mut std::io::stdout().lock())?;
473    Ok(())
474}
475
476/// Format multiple results as a JSON array and print to stdout.
477pub 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, ""); // non-HS rows have no status
819    }
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        // release is outdated, testing is newest
844        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        // None fields should be absent
951        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        // ref_version should not appear in JSON
978        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        // newest_version should be absent when None
1276        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        // empty assignees should be absent
1319        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}