Skip to main content

sandogasa_inventory/
model.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2
3//! Data model for package-of-interest inventories.
4
5use std::collections::BTreeMap;
6
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10/// A Bugzilla priority level. Variants are ordered from
11/// least- to most-important so a `max(...)` across several
12/// candidates picks the highest priority.
13#[derive(
14    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
15)]
16#[serde(rename_all = "lowercase")]
17pub enum Priority {
18    /// Bugzilla's `unspecified` — the default a release-monitoring
19    /// bug arrives at. Treated as "don't manage" when resolving
20    /// from inventory; valid as an explicit override that opts
21    /// a package out of a workload-level default.
22    Unspecified,
23    Low,
24    Medium,
25    High,
26    Urgent,
27}
28
29impl Priority {
30    /// The matching Bugzilla API string (`"unspecified"`, `"low"`,
31    /// …). Used when constructing PUT bodies and when comparing
32    /// against the `priority` field of a fetched bug.
33    pub fn as_bugzilla_str(&self) -> &'static str {
34        match self {
35            Priority::Unspecified => "unspecified",
36            Priority::Low => "low",
37            Priority::Medium => "medium",
38            Priority::High => "high",
39            Priority::Urgent => "urgent",
40        }
41    }
42}
43
44/// Top-level inventory document.
45#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
46pub struct Inventory {
47    /// Inventory metadata.
48    pub inventory: InventoryMeta,
49    /// Packages in the inventory.
50    #[serde(default)]
51    pub package: Vec<Package>,
52}
53
54/// Inventory metadata.
55#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
56pub struct InventoryMeta {
57    /// Inventory name (e.g. "hyperscale-packages").
58    pub name: String,
59    /// Human-readable description.
60    pub description: String,
61    /// Maintainer (person or team).
62    pub maintainer: String,
63    /// Labels/tags for the inventory (e.g. "eln-extras").
64    #[serde(default)]
65    pub labels: Vec<String>,
66    /// Workload definitions. Keys are workload identifiers; values
67    /// carry per-workload metadata for content-resolver export.
68    /// Packages without explicit workloads inherit all keys.
69    #[serde(default)]
70    pub workloads: BTreeMap<String, WorkloadMeta>,
71    /// Field names to strip from all packages on export.
72    #[serde(default)]
73    pub private_fields: Vec<String>,
74}
75
76/// Per-workload metadata for content-resolver export.
77///
78/// All fields except `packages` are optional — omitted fields
79/// fall back to the inventory-level values.
80#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
81pub struct WorkloadMeta {
82    /// Workload name in content-resolver.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub name: Option<String>,
85    /// Human-readable description.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub description: Option<String>,
88    /// Maintainer override.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub maintainer: Option<String>,
91    /// Content-resolver labels.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub labels: Option<Vec<String>>,
94    /// Default Bugzilla priority for packages in this workload
95    /// when they don't carry an explicit `priority`. The
96    /// resolved priority is the max across all workloads listing
97    /// the package, so a package in both a "best-effort" and a
98    /// "security-sensitive" workload picks up the latter.
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub default_priority: Option<Priority>,
101    /// Source RPM names belonging to this workload.
102    #[serde(default, skip_serializing_if = "Vec::is_empty")]
103    pub packages: Vec<String>,
104}
105
106/// A package of interest (source RPM).
107#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
108pub struct Package {
109    /// Source RPM name (required).
110    pub name: String,
111
112    // --- Metadata fields (may be private) ---
113    /// Point of contact ("Name <email>").
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub poc: Option<String>,
116    /// Reason for tracking this package.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub reason: Option<String>,
119    /// Team responsible.
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub team: Option<String>,
122    /// Internal task/ticket reference.
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub task: Option<String>,
125
126    // --- Content-resolver fields ---
127    /// Binary RPM subpackages to track. If omitted, all are assumed.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub rpms: Option<Vec<String>>,
130    /// Architecture-specific RPMs.
131    #[serde(default, skip_serializing_if = "Option::is_none")]
132    pub arch_rpms: Option<BTreeMap<String, Vec<String>>>,
133
134    // --- hs-relmon fields ---
135    /// Which branch/repository to track (upstream, fedora-rawhide, etc.).
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub track: Option<String>,
138    /// Name in Repology if different from RPM name.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub repology_name: Option<String>,
141    /// Comma-separated distribution list for hs-relmon.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub distros: Option<String>,
144    /// Whether to file GitLab issues for version updates.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub file_issue: Option<bool>,
147
148    // --- Bugzilla bug-priority management ---
149    /// Bugzilla priority to apply to release-monitoring bugs for
150    /// this package. Overrides any workload-level
151    /// `default_priority`. Set to `unspecified` to explicitly
152    /// opt out of a workload default.
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub priority: Option<Priority>,
155
156    // --- dist-git state ---
157    /// Dist-git branches where this package is retired (a
158    /// `dead.package` marker is present), as recorded by
159    /// `poi-tracker triage-retired --mark`. Consumers skip checks
160    /// that can't succeed for a retired branch (e.g. auditing
161    /// rawhide update requests). Refreshed — in both directions —
162    /// each time `triage-retired --mark` checks the branch.
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub retired_on: Option<Vec<String>>,
165
166    /// Set when the package is no longer shipped on any active
167    /// branch — the dist-git project is gone, it has no branch on
168    /// an active release, or it is retired everywhere — as
169    /// recorded by `poi-tracker prune-retired`. The value records
170    /// why. Most operations skip such packages; `triage-retired`
171    /// still processes them so remaining bugs get closed, and the
172    /// sync commands' `--prune` preserves them (a fresh sync
173    /// would otherwise re-add retired packages, whose ACLs
174    /// remain). Refreshed — in both directions — each time
175    /// `prune-retired` checks the package.
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub unshipped: Option<String>,
178
179    /// Set when the package's upstream repo is archived but it
180    /// still has builds tagged into CBS release tags — recorded by
181    /// `poi-tracker sync-gitlab --mark-unshipped`. The value records
182    /// why. Unlike [`Self::unshipped`] the package still ships, so
183    /// it is NOT skipped by triage/audit; instead it is a build
184    /// cleanup candidate for `hs-relmon` to untag. Refreshed — in
185    /// both directions — each `--mark-unshipped` run.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub archived_builds: Option<String>,
188}
189
190impl Package {
191    /// Whether this package is recorded as retired on `branch`.
192    pub fn is_retired_on(&self, branch: &str) -> bool {
193        self.retired_on
194            .as_ref()
195            .is_some_and(|branches| branches.iter().any(|b| b == branch))
196    }
197
198    /// Whether this package is recorded as no longer shipped on
199    /// any active branch.
200    pub fn is_unshipped(&self) -> bool {
201        self.unshipped.is_some()
202    }
203
204    /// Whether this package's upstream repo is archived while it
205    /// still has CBS builds (a cleanup candidate for hs-relmon).
206    pub fn has_archived_builds(&self) -> bool {
207        self.archived_builds.is_some()
208    }
209
210    /// Merge another entry for the same package into this one,
211    /// field by field: fields set in `other` win, fields unset in
212    /// `other` keep this entry's values, and retirement knowledge
213    /// is combined (`retired_on` is unioned; `unshipped` keeps
214    /// whichever side knows). Returns a human-readable note for
215    /// each field where both sides were set to different values
216    /// (the `other` side won).
217    pub fn merge_from(&mut self, other: &Package) -> Vec<String> {
218        let mut conflicts = Vec::new();
219        macro_rules! take {
220            ($field:ident) => {
221                if let Some(theirs) = &other.$field {
222                    if let Some(ours) = &self.$field
223                        && ours != theirs
224                    {
225                        conflicts.push(format!(
226                            "{}: conflicting {} ({ours:?} vs {theirs:?}; later file wins)",
227                            self.name,
228                            stringify!($field),
229                        ));
230                    }
231                    self.$field = Some(theirs.clone());
232                }
233            };
234        }
235        take!(poc);
236        take!(reason);
237        take!(team);
238        take!(task);
239        take!(rpms);
240        take!(arch_rpms);
241        take!(track);
242        take!(repology_name);
243        take!(distros);
244        take!(file_issue);
245        take!(priority);
246        take!(unshipped);
247        take!(archived_builds);
248        // Retirement branches are facts about dist-git, not
249        // per-inventory preferences: union them.
250        if let Some(theirs) = &other.retired_on {
251            let mut branches = self.retired_on.take().unwrap_or_default();
252            branches.extend(theirs.iter().cloned());
253            branches.sort();
254            branches.dedup();
255            self.retired_on = Some(branches);
256        }
257        conflicts
258    }
259}
260
261impl Inventory {
262    /// Check if a field name is private (should be stripped on export).
263    pub fn is_private(&self, field: &str) -> bool {
264        self.inventory.private_fields.iter().any(|f| f == field)
265    }
266
267    /// Get packages filtered by workload. Returns all if workload is None.
268    ///
269    /// When a workload key is given, returns only packages listed in
270    /// that workload's `packages` field.
271    pub fn packages_for_workload(&self, workload: Option<&str>) -> Vec<&Package> {
272        match workload {
273            None => self.package.iter().collect(),
274            Some(w) => {
275                let pkg_names: std::collections::HashSet<&str> = self
276                    .inventory
277                    .workloads
278                    .get(w)
279                    .map(|wl| wl.packages.iter().map(|s| s.as_str()).collect())
280                    .unwrap_or_default();
281                self.package
282                    .iter()
283                    .filter(|p| pkg_names.contains(p.name.as_str()))
284                    .collect()
285            }
286        }
287    }
288
289    /// Return the sorted list of workload identifiers.
290    pub fn workload_names(&self) -> Vec<&str> {
291        self.inventory
292            .workloads
293            .keys()
294            .map(|k| k.as_str())
295            .collect()
296    }
297
298    /// Return the workload keys that contain a given package.
299    pub fn workloads_for_package(&self, name: &str) -> Vec<&str> {
300        self.inventory
301            .workloads
302            .iter()
303            .filter(|(_, meta)| meta.packages.iter().any(|p| p == name))
304            .map(|(k, _)| k.as_str())
305            .collect()
306    }
307
308    /// Add a package to a workload, creating the workload if needed.
309    pub fn add_to_workload(&mut self, workload: &str, package: &str) {
310        let meta = self
311            .inventory
312            .workloads
313            .entry(workload.to_string())
314            .or_default();
315        if !meta.packages.iter().any(|p| p == package) {
316            meta.packages.push(package.to_string());
317            meta.packages.sort();
318        }
319    }
320
321    /// Find a package by name.
322    pub fn find_package(&self, name: &str) -> Option<&Package> {
323        self.package.iter().find(|p| p.name == name)
324    }
325
326    /// Resolve the Bugzilla priority for a package. An explicit
327    /// `priority` on the package wins outright (including
328    /// `unspecified`, which acts as an opt-out from any
329    /// workload-level default). Otherwise, walk every workload
330    /// that lists this package and return the highest
331    /// `default_priority` among them.
332    pub fn priority_for(&self, name: &str) -> Option<Priority> {
333        let pkg = self.find_package(name)?;
334        if let Some(p) = pkg.priority {
335            return Some(p);
336        }
337        let mut best: Option<Priority> = None;
338        for meta in self.inventory.workloads.values() {
339            if !meta.packages.iter().any(|p| p == name) {
340                continue;
341            }
342            if let Some(p) = meta.default_priority {
343                best = match best {
344                    None => Some(p),
345                    Some(b) => Some(b.max(p)),
346                };
347            }
348        }
349        best
350    }
351
352    /// Find a package by name (mutable).
353    pub fn find_package_mut(&mut self, name: &str) -> Option<&mut Package> {
354        self.package.iter_mut().find(|p| p.name == name)
355    }
356
357    /// Add a package, replacing if one with the same name exists.
358    pub fn add_package(&mut self, pkg: Package) {
359        if let Some(existing) = self.find_package_mut(&pkg.name) {
360            *existing = pkg;
361        } else {
362            self.package.push(pkg);
363            self.package.sort_by(|a, b| a.name.cmp(&b.name));
364        }
365    }
366
367    /// Remove a package by name. Returns true if found and removed.
368    pub fn remove_package(&mut self, name: &str) -> bool {
369        let len = self.package.len();
370        self.package.retain(|p| p.name != name);
371        self.package.len() < len
372    }
373
374    /// Merge another inventory into this one.
375    ///
376    /// New packages from `other` are added. A package present in
377    /// both is merged field by field ([`Package::merge_from`]):
378    /// `other`'s set fields win, its unset fields keep this
379    /// inventory's values, and retirement knowledge is combined —
380    /// so a marker recorded in one file survives the package also
381    /// appearing bare in another. Metadata (name, description,
382    /// workloads, etc.) is kept from the original. Returns a
383    /// note per field where the two files genuinely disagreed.
384    pub fn merge(&mut self, other: &Inventory) -> Vec<String> {
385        let mut conflicts = Vec::new();
386        for pkg in &other.package {
387            match self.find_package_mut(&pkg.name) {
388                Some(existing) => conflicts.extend(existing.merge_from(pkg)),
389                None => self.add_package(pkg.clone()),
390            }
391        }
392        conflicts
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    fn make_pkg(name: &str) -> Package {
401        Package {
402            name: name.to_string(),
403            poc: None,
404            reason: None,
405            team: None,
406            task: None,
407            rpms: None,
408            arch_rpms: None,
409            track: None,
410            repology_name: None,
411            distros: None,
412            file_issue: None,
413            priority: None,
414            retired_on: None,
415            unshipped: None,
416            archived_builds: None,
417        }
418    }
419
420    fn make_inventory() -> Inventory {
421        let mut inv = Inventory {
422            inventory: InventoryMeta {
423                name: "test".to_string(),
424                description: "test".to_string(),
425                maintainer: "tester".to_string(),
426                labels: vec![],
427                workloads: BTreeMap::from([
428                    (
429                        "hyperscale".to_string(),
430                        WorkloadMeta {
431                            packages: vec!["bar".to_string(), "foo".to_string()],
432                            ..Default::default()
433                        },
434                    ),
435                    (
436                        "epel".to_string(),
437                        WorkloadMeta {
438                            packages: vec!["foo".to_string()],
439                            ..Default::default()
440                        },
441                    ),
442                ]),
443                private_fields: vec!["poc".to_string(), "team".to_string()],
444            },
445            package: vec![],
446        };
447        let mut bar = make_pkg("bar");
448        bar.poc = Some("Team <t@e.com>".to_string());
449        bar.team = Some("infra".to_string());
450        inv.package.push(bar);
451
452        let mut foo = make_pkg("foo");
453        foo.rpms = Some(vec!["foo".to_string(), "foo-libs".to_string()]);
454        foo.track = Some("upstream".to_string());
455        inv.package.push(foo);
456
457        inv
458    }
459
460    #[test]
461    fn is_private() {
462        let inv = make_inventory();
463        assert!(inv.is_private("poc"));
464        assert!(inv.is_private("team"));
465        assert!(!inv.is_private("reason"));
466        assert!(!inv.is_private("name"));
467    }
468
469    #[test]
470    fn packages_for_workload() {
471        let inv = make_inventory();
472        let hs = inv.packages_for_workload(Some("hyperscale"));
473        assert_eq!(hs.len(), 2);
474        let epel = inv.packages_for_workload(Some("epel"));
475        assert_eq!(epel.len(), 1);
476        assert_eq!(epel[0].name, "foo");
477        let all = inv.packages_for_workload(None);
478        assert_eq!(all.len(), 2);
479    }
480
481    #[test]
482    fn unlisted_package_not_in_workload() {
483        let mut inv = make_inventory();
484        inv.add_package(make_pkg("unlisted"));
485        // Not listed in any workload's packages.
486        let hs = inv.packages_for_workload(Some("hyperscale"));
487        assert!(!hs.iter().any(|p| p.name == "unlisted"));
488        // But shows up in unfiltered list.
489        let all = inv.packages_for_workload(None);
490        assert!(all.iter().any(|p| p.name == "unlisted"));
491    }
492
493    #[test]
494    fn workload_names() {
495        let inv = make_inventory();
496        assert_eq!(inv.workload_names(), vec!["epel", "hyperscale"]);
497    }
498
499    #[test]
500    fn workloads_for_package() {
501        let inv = make_inventory();
502        assert_eq!(inv.workloads_for_package("bar"), vec!["hyperscale"]);
503        assert_eq!(inv.workloads_for_package("foo"), vec!["epel", "hyperscale"]);
504        assert!(inv.workloads_for_package("nonexistent").is_empty());
505    }
506
507    #[test]
508    fn add_to_workload() {
509        let mut inv = make_inventory();
510        inv.add_to_workload("hyperscale", "newpkg");
511        assert!(
512            inv.inventory.workloads["hyperscale"]
513                .packages
514                .contains(&"newpkg".to_string())
515        );
516        // Duplicate add is a no-op.
517        inv.add_to_workload("hyperscale", "newpkg");
518        assert_eq!(
519            inv.inventory.workloads["hyperscale"]
520                .packages
521                .iter()
522                .filter(|p| *p == "newpkg")
523                .count(),
524            1
525        );
526    }
527
528    #[test]
529    fn add_to_workload_creates_workload() {
530        let mut inv = make_inventory();
531        inv.add_to_workload("newwl", "pkg");
532        assert!(inv.inventory.workloads.contains_key("newwl"));
533        assert_eq!(inv.inventory.workloads["newwl"].packages, vec!["pkg"]);
534    }
535
536    #[test]
537    fn find_package() {
538        let inv = make_inventory();
539        assert!(inv.find_package("foo").is_some());
540        assert!(inv.find_package("nonexistent").is_none());
541    }
542
543    #[test]
544    fn add_package_new() {
545        let mut inv = make_inventory();
546        inv.add_package(make_pkg("aaa"));
547        assert_eq!(inv.package.len(), 3);
548        // Should be sorted: aaa, bar, foo.
549        assert_eq!(inv.package[0].name, "aaa");
550    }
551
552    #[test]
553    fn add_package_replace() {
554        let mut inv = make_inventory();
555        let mut pkg = inv.find_package("foo").unwrap().clone();
556        pkg.reason = Some("updated reason".to_string());
557        inv.add_package(pkg);
558        assert_eq!(inv.package.len(), 2);
559        assert_eq!(
560            inv.find_package("foo").unwrap().reason.as_deref(),
561            Some("updated reason")
562        );
563    }
564
565    #[test]
566    fn remove_package() {
567        let mut inv = make_inventory();
568        assert!(inv.remove_package("foo"));
569        assert_eq!(inv.package.len(), 1);
570        assert!(!inv.remove_package("nonexistent"));
571    }
572
573    #[test]
574    fn merge_inventories() {
575        let mut inv1 = make_inventory();
576        let inv2 = Inventory {
577            inventory: InventoryMeta {
578                name: "other".to_string(),
579                description: "other".to_string(),
580                maintainer: "other".to_string(),
581                labels: vec![],
582                workloads: BTreeMap::new(),
583                private_fields: vec![],
584            },
585            package: vec![make_pkg("new-pkg")],
586        };
587        inv1.merge(&inv2);
588        assert_eq!(inv1.package.len(), 3);
589        assert!(inv1.find_package("new-pkg").is_some());
590        // Metadata stays from inv1.
591        assert_eq!(inv1.inventory.name, "test");
592    }
593
594    #[test]
595    fn merge_from_set_fields_win_unset_preserved() {
596        let mut earlier = make_pkg("foo");
597        earlier.poc = Some("alice".to_string());
598        earlier.priority = Some(Priority::High);
599        let mut later = make_pkg("foo");
600        later.reason = Some("rust SIG".to_string());
601
602        let conflicts = earlier.merge_from(&later);
603        assert!(conflicts.is_empty());
604        // Later file's set field landed...
605        assert_eq!(earlier.reason.as_deref(), Some("rust SIG"));
606        // ...and its unset fields kept the earlier values.
607        assert_eq!(earlier.poc.as_deref(), Some("alice"));
608        assert_eq!(earlier.priority, Some(Priority::High));
609    }
610
611    #[test]
612    fn merge_from_reports_conflicts_later_wins() {
613        let mut earlier = make_pkg("foo");
614        earlier.priority = Some(Priority::High);
615        let mut later = make_pkg("foo");
616        later.priority = Some(Priority::Low);
617
618        let conflicts = earlier.merge_from(&later);
619        assert_eq!(conflicts.len(), 1);
620        assert!(conflicts[0].contains("priority"), "{}", conflicts[0]);
621        assert_eq!(earlier.priority, Some(Priority::Low));
622    }
623
624    #[test]
625    fn merge_from_combines_retirement_knowledge() {
626        let mut earlier = make_pkg("foo");
627        earlier.retired_on = Some(vec!["rawhide".to_string()]);
628        earlier.unshipped = Some("dist-git project gone (404)".to_string());
629        let mut later = make_pkg("foo");
630        later.retired_on = Some(vec!["epel9".to_string(), "rawhide".to_string()]);
631
632        let conflicts = earlier.merge_from(&later);
633        assert!(conflicts.is_empty());
634        // retired_on unioned, not replaced.
635        assert_eq!(
636            earlier.retired_on,
637            Some(vec!["epel9".to_string(), "rawhide".to_string()])
638        );
639        // A bare later entry doesn't erase the unshipped marker.
640        assert!(earlier.is_unshipped());
641    }
642
643    #[test]
644    fn merge_preserves_markers_from_earlier_files() {
645        // The original motivation: a package marked unshipped in
646        // one inventory also appears bare in a later-merged one.
647        let mut inv1 = make_inventory();
648        inv1.find_package_mut("foo").unwrap().unshipped = Some("gone".to_string());
649        let inv2 = Inventory {
650            inventory: InventoryMeta {
651                name: "other".to_string(),
652                description: "other".to_string(),
653                maintainer: "other".to_string(),
654                labels: vec![],
655                workloads: BTreeMap::new(),
656                private_fields: vec![],
657            },
658            package: vec![make_pkg("foo")],
659        };
660        let conflicts = inv1.merge(&inv2);
661        assert!(conflicts.is_empty());
662        assert!(inv1.find_package("foo").unwrap().is_unshipped());
663        // Still two packages — merged, not duplicated.
664        assert_eq!(inv1.package.len(), 2);
665    }
666
667    #[test]
668    fn priority_ordering() {
669        // The ordering matters: max() over workload defaults
670        // must pick the most-important one.
671        assert!(Priority::Urgent > Priority::High);
672        assert!(Priority::High > Priority::Medium);
673        assert!(Priority::Medium > Priority::Low);
674        assert!(Priority::Low > Priority::Unspecified);
675    }
676
677    #[test]
678    fn priority_serializes_lowercase() {
679        let toml_str = toml::to_string(&PriorityWrapper { p: Priority::High }).unwrap();
680        assert!(toml_str.contains("p = \"high\""));
681        let back: PriorityWrapper = toml::from_str("p = \"urgent\"").unwrap();
682        assert_eq!(back.p, Priority::Urgent);
683    }
684
685    #[derive(Serialize, Deserialize)]
686    struct PriorityWrapper {
687        p: Priority,
688    }
689
690    #[test]
691    fn priority_for_returns_none_when_unset() {
692        let inv = make_inventory();
693        assert_eq!(inv.priority_for("foo"), None);
694    }
695
696    #[test]
697    fn priority_for_explicit_package_field_wins() {
698        let mut inv = make_inventory();
699        inv.find_package_mut("foo").unwrap().priority = Some(Priority::High);
700        // Add a workload default that would otherwise apply.
701        inv.inventory
702            .workloads
703            .get_mut("hyperscale")
704            .unwrap()
705            .default_priority = Some(Priority::Urgent);
706        // Package field wins even when the workload would be
707        // strictly higher.
708        assert_eq!(inv.priority_for("foo"), Some(Priority::High));
709    }
710
711    #[test]
712    fn priority_for_falls_back_to_workload_default() {
713        let mut inv = make_inventory();
714        inv.inventory
715            .workloads
716            .get_mut("hyperscale")
717            .unwrap()
718            .default_priority = Some(Priority::Medium);
719        assert_eq!(inv.priority_for("bar"), Some(Priority::Medium));
720    }
721
722    #[test]
723    fn priority_for_picks_max_across_workloads() {
724        let mut inv = make_inventory();
725        // `foo` is in both hyperscale and epel.
726        inv.inventory
727            .workloads
728            .get_mut("hyperscale")
729            .unwrap()
730            .default_priority = Some(Priority::Low);
731        inv.inventory
732            .workloads
733            .get_mut("epel")
734            .unwrap()
735            .default_priority = Some(Priority::High);
736        assert_eq!(inv.priority_for("foo"), Some(Priority::High));
737    }
738
739    #[test]
740    fn priority_for_explicit_unspecified_opts_out_of_workload_default() {
741        let mut inv = make_inventory();
742        inv.inventory
743            .workloads
744            .get_mut("hyperscale")
745            .unwrap()
746            .default_priority = Some(Priority::Urgent);
747        inv.find_package_mut("foo").unwrap().priority = Some(Priority::Unspecified);
748        assert_eq!(inv.priority_for("foo"), Some(Priority::Unspecified));
749    }
750
751    #[test]
752    fn priority_for_unknown_package_is_none() {
753        let inv = make_inventory();
754        assert_eq!(inv.priority_for("does-not-exist"), None);
755    }
756}