1use std::collections::BTreeMap;
6
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9
10#[derive(
14 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema,
15)]
16#[serde(rename_all = "lowercase")]
17pub enum Priority {
18 Unspecified,
23 Low,
24 Medium,
25 High,
26 Urgent,
27}
28
29impl Priority {
30 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#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
46pub struct Inventory {
47 pub inventory: InventoryMeta,
49 #[serde(default)]
51 pub package: Vec<Package>,
52}
53
54#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
56pub struct InventoryMeta {
57 pub name: String,
59 pub description: String,
61 pub maintainer: String,
63 #[serde(default)]
65 pub labels: Vec<String>,
66 #[serde(default)]
70 pub workloads: BTreeMap<String, WorkloadMeta>,
71 #[serde(default)]
73 pub private_fields: Vec<String>,
74}
75
76#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
81pub struct WorkloadMeta {
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub name: Option<String>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub description: Option<String>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub maintainer: Option<String>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub labels: Option<Vec<String>>,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub default_priority: Option<Priority>,
101 #[serde(default, skip_serializing_if = "Vec::is_empty")]
103 pub packages: Vec<String>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
108pub struct Package {
109 pub name: String,
111
112 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub poc: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub reason: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub team: Option<String>,
122 #[serde(default, skip_serializing_if = "Option::is_none")]
124 pub task: Option<String>,
125
126 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub rpms: Option<Vec<String>>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub arch_rpms: Option<BTreeMap<String, Vec<String>>>,
133
134 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub track: Option<String>,
138 #[serde(default, skip_serializing_if = "Option::is_none")]
140 pub repology_name: Option<String>,
141 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub distros: Option<String>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub file_issue: Option<bool>,
147
148 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub priority: Option<Priority>,
155
156 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub retired_on: Option<Vec<String>>,
165
166 #[serde(default, skip_serializing_if = "Option::is_none")]
177 pub unshipped: Option<String>,
178
179 #[serde(default, skip_serializing_if = "Option::is_none")]
187 pub archived_builds: Option<String>,
188}
189
190impl Package {
191 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 pub fn is_unshipped(&self) -> bool {
201 self.unshipped.is_some()
202 }
203
204 pub fn has_archived_builds(&self) -> bool {
207 self.archived_builds.is_some()
208 }
209
210 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 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 pub fn is_private(&self, field: &str) -> bool {
264 self.inventory.private_fields.iter().any(|f| f == field)
265 }
266
267 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 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 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 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 pub fn find_package(&self, name: &str) -> Option<&Package> {
323 self.package.iter().find(|p| p.name == name)
324 }
325
326 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 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 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 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 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 let hs = inv.packages_for_workload(Some("hyperscale"));
487 assert!(!hs.iter().any(|p| p.name == "unlisted"));
488 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 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 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 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 assert_eq!(earlier.reason.as_deref(), Some("rust SIG"));
606 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 assert_eq!(
636 earlier.retired_on,
637 Some(vec!["epel9".to_string(), "rawhide".to_string()])
638 );
639 assert!(earlier.is_unshipped());
641 }
642
643 #[test]
644 fn merge_preserves_markers_from_earlier_files() {
645 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 assert_eq!(inv1.package.len(), 2);
665 }
666
667 #[test]
668 fn priority_ordering() {
669 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 inv.inventory
702 .workloads
703 .get_mut("hyperscale")
704 .unwrap()
705 .default_priority = Some(Priority::Urgent);
706 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 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}