1use crate::check_latest::{Distros, TrackRef};
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7#[derive(Debug, Deserialize, Serialize)]
9pub struct Manifest {
10 #[serde(default)]
11 pub defaults: Defaults,
12 #[serde(rename = "package")]
13 pub packages: Vec<PackageEntry>,
14}
15
16#[derive(Debug, Default, Deserialize, Serialize)]
18pub struct Defaults {
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub distros: Option<String>,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub track: Option<String>,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub repology_name: Option<String>,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub file_issue: Option<bool>,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub issue_url: Option<String>,
29}
30
31#[derive(Debug, Deserialize, Serialize)]
33pub struct PackageEntry {
34 pub name: String,
35 #[serde(skip_serializing_if = "Option::is_none")]
36 pub distros: Option<String>,
37 #[serde(skip_serializing_if = "Option::is_none")]
38 pub track: Option<String>,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub repology_name: Option<String>,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub file_issue: Option<bool>,
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub issue_url: Option<String>,
45}
46
47#[derive(Debug)]
49pub struct ResolvedPackage {
50 pub name: String,
51 pub distros: Distros,
52 pub track: TrackRef,
53 pub repology_name: Option<String>,
54 pub file_issue: bool,
55 pub issue_url: Option<String>,
56}
57
58impl Manifest {
59 pub fn load(
61 path: &Path,
62 ) -> Result<Self, Box<dyn std::error::Error>> {
63 let contents = std::fs::read_to_string(path)?;
64 Ok(toml::from_str(&contents)?)
65 }
66
67 pub fn add_packages(&mut self, names: &[String]) {
69 let existing: std::collections::HashSet<String> = self
70 .packages
71 .iter()
72 .map(|p| p.name.clone())
73 .collect();
74 for name in names {
75 if !existing.contains(name) {
76 self.packages.push(PackageEntry {
77 name: name.clone(),
78 distros: None,
79 track: None,
80 repology_name: None,
81 file_issue: None,
82 issue_url: None,
83 });
84 }
85 }
86 self.sort_packages();
87 }
88
89 pub fn sort_packages(&mut self) {
91 self.packages.sort_by(|a, b| a.name.cmp(&b.name));
92 }
93
94 pub fn resolve(
97 &self,
98 ) -> Result<Vec<ResolvedPackage>, Box<dyn std::error::Error>> {
99 self.packages
100 .iter()
101 .map(|pkg| self.resolve_one(pkg))
102 .collect()
103 }
104
105 fn resolve_one(
106 &self,
107 pkg: &PackageEntry,
108 ) -> Result<ResolvedPackage, Box<dyn std::error::Error>> {
109 let distros_str = pkg
110 .distros
111 .as_ref()
112 .or(self.defaults.distros.as_ref());
113 let distros = match distros_str {
114 Some(s) => Distros::parse(s).map_err(|e| {
115 format!("{}: {e}", pkg.name)
116 })?,
117 None => Distros::all(),
118 };
119
120 let track_str = pkg
121 .track
122 .as_ref()
123 .or(self.defaults.track.as_ref());
124 let track = match track_str {
125 Some(s) => TrackRef::parse(s).map_err(|e| {
126 format!("{}: {e}", pkg.name)
127 })?,
128 None => TrackRef::Upstream,
129 };
130
131 let repology_name = pkg
132 .repology_name
133 .clone()
134 .or_else(|| self.defaults.repology_name.clone());
135
136 let file_issue = pkg
137 .file_issue
138 .or(self.defaults.file_issue)
139 .unwrap_or(false);
140
141 let issue_url = pkg
142 .issue_url
143 .clone()
144 .or_else(|| self.defaults.issue_url.clone());
145
146 Ok(ResolvedPackage {
147 name: pkg.name.clone(),
148 distros,
149 track,
150 repology_name,
151 file_issue,
152 issue_url,
153 })
154 }
155}
156
157pub fn add_packages_to_file(
161 path: &Path,
162 names: &[String],
163) -> Result<(), Box<dyn std::error::Error>> {
164 use std::collections::HashSet;
165 use toml_edit::DocumentMut;
166
167 let contents = std::fs::read_to_string(path)?;
168 let mut doc: DocumentMut = contents.parse()?;
169
170 let mut pkg_tables: Vec<(String, toml_edit::Table)> =
173 Vec::new();
174 let mut first_prefix: Option<toml_edit::RawString> = None;
175 if let Some(arr) =
176 doc.get("package").and_then(|i| i.as_array_of_tables())
177 {
178 for (i, table) in arr.iter().enumerate() {
179 if i == 0 {
180 first_prefix = table
181 .decor()
182 .prefix()
183 .cloned();
184 }
185 let name = table
186 .get("name")
187 .and_then(|v| v.as_str())
188 .unwrap_or("")
189 .to_string();
190 let mut new_table = toml_edit::Table::new();
191 for (key, item) in table.iter() {
192 new_table.insert(key, item.clone());
193 }
194 pkg_tables.push((name, new_table));
195 }
196 }
197
198 let existing: HashSet<String> = pkg_tables
199 .iter()
200 .map(|(name, _)| name.clone())
201 .collect();
202
203 for name in names {
204 if !existing.contains(name) {
205 let mut table = toml_edit::Table::new();
206 table.insert(
207 "name",
208 toml_edit::value(name.as_str()),
209 );
210 pkg_tables.push((name.clone(), table));
211 }
212 }
213
214 pkg_tables.sort_by(|a, b| a.0.cmp(&b.0));
215
216 let mut new_arr = toml_edit::ArrayOfTables::new();
217 for (i, (_, mut table)) in
218 pkg_tables.into_iter().enumerate()
219 {
220 if i == 0 {
221 if let Some(prefix) = &first_prefix {
222 table.decor_mut().set_prefix(prefix.clone());
223 }
224 }
225 new_arr.push(table);
226 }
227 doc.remove("package");
228 doc.insert(
229 "package",
230 toml_edit::Item::ArrayOfTables(new_arr),
231 );
232
233 std::fs::write(path, doc.to_string())?;
234 Ok(())
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240
241 #[test]
242 fn test_deserialize_minimal() {
243 let toml_str = r#"
244[[package]]
245name = "ethtool"
246"#;
247 let m: Manifest = toml::from_str(toml_str).unwrap();
248 assert_eq!(m.packages.len(), 1);
249 assert_eq!(m.packages[0].name, "ethtool");
250 assert!(m.defaults.distros.is_none());
251 }
252
253 #[test]
254 fn test_deserialize_with_defaults() {
255 let toml_str = r#"
256[defaults]
257distros = "upstream,hyperscale"
258track = "centos-stream"
259file_issue = true
260
261[[package]]
262name = "ethtool"
263
264[[package]]
265name = "perf"
266repology_name = "linux"
267"#;
268 let m: Manifest = toml::from_str(toml_str).unwrap();
269 assert_eq!(
270 m.defaults.distros.as_deref(),
271 Some("upstream,hyperscale")
272 );
273 assert_eq!(m.defaults.file_issue, Some(true));
274 assert_eq!(m.packages.len(), 2);
275 assert_eq!(
276 m.packages[1].repology_name.as_deref(),
277 Some("linux")
278 );
279 }
280
281 #[test]
282 fn test_resolve_inherits_defaults() {
283 let toml_str = r#"
284[defaults]
285distros = "upstream,hs9"
286track = "centos-stream"
287file_issue = true
288
289[[package]]
290name = "ethtool"
291"#;
292 let m: Manifest = toml::from_str(toml_str).unwrap();
293 let resolved = m.resolve().unwrap();
294 assert_eq!(resolved.len(), 1);
295 let pkg = &resolved[0];
296 assert!(pkg.distros.upstream);
297 assert!(pkg.distros.hyperscale_9);
298 assert!(!pkg.distros.hyperscale_10);
299 assert_eq!(pkg.track, TrackRef::CentosStream);
300 assert!(pkg.file_issue);
301 }
302
303 #[test]
304 fn test_resolve_per_package_overrides() {
305 let toml_str = r#"
306[defaults]
307distros = "upstream"
308track = "upstream"
309file_issue = false
310
311[[package]]
312name = "systemd"
313distros = "upstream,fedora,hyperscale"
314track = "fedora-rawhide"
315file_issue = true
316issue_url = "https://gitlab.com/custom/systemd"
317"#;
318 let m: Manifest = toml::from_str(toml_str).unwrap();
319 let resolved = m.resolve().unwrap();
320 let pkg = &resolved[0];
321 assert!(pkg.distros.fedora_rawhide);
322 assert!(pkg.distros.fedora_stable);
323 assert_eq!(pkg.track, TrackRef::FedoraRawhide);
324 assert!(pkg.file_issue);
325 assert_eq!(
326 pkg.issue_url.as_deref(),
327 Some("https://gitlab.com/custom/systemd")
328 );
329 }
330
331 #[test]
332 fn test_resolve_hardcoded_fallbacks() {
333 let toml_str = r#"
334[[package]]
335name = "pkg"
336"#;
337 let m: Manifest = toml::from_str(toml_str).unwrap();
338 let resolved = m.resolve().unwrap();
339 let pkg = &resolved[0];
340 assert_eq!(pkg.distros, Distros::all());
341 assert_eq!(pkg.track, TrackRef::Upstream);
342 assert!(!pkg.file_issue);
343 assert!(pkg.repology_name.is_none());
344 assert!(pkg.issue_url.is_none());
345 }
346
347 #[test]
348 fn test_resolve_bad_distro() {
349 let toml_str = r#"
350[[package]]
351name = "bad"
352distros = "bogus"
353"#;
354 let m: Manifest = toml::from_str(toml_str).unwrap();
355 let err = m.resolve().unwrap_err();
356 let msg = err.to_string();
357 assert!(msg.contains("bad"));
358 assert!(msg.contains("bogus"));
359 }
360
361 #[test]
362 fn test_resolve_bad_track() {
363 let toml_str = r#"
364[[package]]
365name = "bad"
366track = "nope"
367"#;
368 let m: Manifest = toml::from_str(toml_str).unwrap();
369 let err = m.resolve().unwrap_err();
370 let msg = err.to_string();
371 assert!(msg.contains("bad"));
372 assert!(msg.contains("nope"));
373 }
374
375 #[test]
376 fn test_resolve_repology_name_from_defaults() {
377 let toml_str = r#"
378[defaults]
379repology_name = "linux"
380
381[[package]]
382name = "perf"
383"#;
384 let m: Manifest = toml::from_str(toml_str).unwrap();
385 let resolved = m.resolve().unwrap();
386 assert_eq!(
387 resolved[0].repology_name.as_deref(),
388 Some("linux")
389 );
390 }
391
392 #[test]
393 fn test_resolve_repology_name_override() {
394 let toml_str = r#"
395[defaults]
396repology_name = "default-name"
397
398[[package]]
399name = "perf"
400repology_name = "linux"
401"#;
402 let m: Manifest = toml::from_str(toml_str).unwrap();
403 let resolved = m.resolve().unwrap();
404 assert_eq!(
405 resolved[0].repology_name.as_deref(),
406 Some("linux")
407 );
408 }
409
410 #[test]
411 fn test_resolve_issue_url_from_defaults() {
412 let toml_str = r#"
413[defaults]
414file_issue = true
415issue_url = "https://gitlab.com/default/project"
416
417[[package]]
418name = "pkg"
419"#;
420 let m: Manifest = toml::from_str(toml_str).unwrap();
421 let resolved = m.resolve().unwrap();
422 assert_eq!(
423 resolved[0].issue_url.as_deref(),
424 Some("https://gitlab.com/default/project")
425 );
426 }
427
428 #[test]
429 fn test_multiple_packages() {
430 let toml_str = r#"
431[defaults]
432file_issue = true
433
434[[package]]
435name = "ethtool"
436
437[[package]]
438name = "perf"
439repology_name = "linux"
440
441[[package]]
442name = "systemd"
443file_issue = false
444"#;
445 let m: Manifest = toml::from_str(toml_str).unwrap();
446 let resolved = m.resolve().unwrap();
447 assert_eq!(resolved.len(), 3);
448 assert!(resolved[0].file_issue);
449 assert!(resolved[1].file_issue);
450 assert!(!resolved[2].file_issue);
451 assert_eq!(
452 resolved[1].repology_name.as_deref(),
453 Some("linux")
454 );
455 }
456
457 #[test]
458 fn test_sort_packages() {
459 let toml_str = r#"
460[[package]]
461name = "systemd"
462
463[[package]]
464name = "ethtool"
465
466[[package]]
467name = "perf"
468"#;
469 let mut m: Manifest = toml::from_str(toml_str).unwrap();
470 m.sort_packages();
471 assert_eq!(m.packages[0].name, "ethtool");
472 assert_eq!(m.packages[1].name, "perf");
473 assert_eq!(m.packages[2].name, "systemd");
474 }
475
476 #[test]
477 fn test_add_packages() {
478 let toml_str = r#"
479[[package]]
480name = "ethtool"
481
482[[package]]
483name = "systemd"
484"#;
485 let mut m: Manifest = toml::from_str(toml_str).unwrap();
486 m.add_packages(&[
487 "perf".into(),
488 "bpftrace".into(),
489 ]);
490 assert_eq!(m.packages.len(), 4);
491 assert_eq!(m.packages[0].name, "bpftrace");
492 assert_eq!(m.packages[1].name, "ethtool");
493 assert_eq!(m.packages[2].name, "perf");
494 assert_eq!(m.packages[3].name, "systemd");
495 }
496
497 #[test]
498 fn test_add_packages_skips_duplicates() {
499 let toml_str = r#"
500[[package]]
501name = "ethtool"
502"#;
503 let mut m: Manifest = toml::from_str(toml_str).unwrap();
504 m.add_packages(&["ethtool".into(), "perf".into()]);
505 assert_eq!(m.packages.len(), 2);
506 assert_eq!(m.packages[0].name, "ethtool");
507 assert_eq!(m.packages[1].name, "perf");
508 }
509
510 #[test]
511 fn test_add_packages_preserves_existing_fields() {
512 let toml_str = r#"
513[[package]]
514name = "perf"
515repology_name = "linux"
516"#;
517 let mut m: Manifest = toml::from_str(toml_str).unwrap();
518 m.add_packages(&["ethtool".into()]);
519 assert_eq!(m.packages.len(), 2);
520 assert_eq!(m.packages[0].name, "ethtool");
521 assert_eq!(m.packages[1].name, "perf");
522 assert_eq!(
523 m.packages[1].repology_name.as_deref(),
524 Some("linux")
525 );
526 }
527
528 #[test]
529 fn test_add_packages_to_file() {
530 let original = "\
531# SPDX-License-Identifier: MPL-2.0
532
533# Default settings.
534[defaults]
535file_issue = true
536
537# Packages to monitor.
538
539[[package]]
540name = \"systemd\"
541
542[[package]]
543name = \"ethtool\"
544";
545 let dir = std::env::temp_dir().join("hs-relmon-test");
546 std::fs::create_dir_all(&dir).unwrap();
547 let path = dir.join("test-add-packages.toml");
548 std::fs::write(&path, original).unwrap();
549
550 add_packages_to_file(
551 &path,
552 &["perf".into()],
553 )
554 .unwrap();
555
556 let contents =
557 std::fs::read_to_string(&path).unwrap();
558
559 assert!(
561 contents.contains("# SPDX-License-Identifier")
562 );
563 assert!(contents.contains("# Default settings."));
564 assert!(
565 contents.contains("# Packages to monitor.")
566 );
567
568 let reloaded = Manifest::load(&path).unwrap();
570 assert_eq!(reloaded.packages.len(), 3);
571 assert_eq!(reloaded.packages[0].name, "ethtool");
572 assert_eq!(reloaded.packages[1].name, "perf");
573 assert_eq!(reloaded.packages[2].name, "systemd");
574 assert_eq!(
575 reloaded.defaults.file_issue,
576 Some(true)
577 );
578
579 std::fs::remove_file(&path).ok();
580 }
581
582 #[test]
583 fn test_add_packages_to_file_skips_duplicates() {
584 let original = "\
585[[package]]
586name = \"ethtool\"
587";
588 let dir = std::env::temp_dir().join("hs-relmon-test");
589 std::fs::create_dir_all(&dir).unwrap();
590 let path =
591 dir.join("test-add-packages-dup.toml");
592 std::fs::write(&path, original).unwrap();
593
594 add_packages_to_file(
595 &path,
596 &["ethtool".into(), "perf".into()],
597 )
598 .unwrap();
599
600 let reloaded = Manifest::load(&path).unwrap();
601 assert_eq!(reloaded.packages.len(), 2);
602 assert_eq!(reloaded.packages[0].name, "ethtool");
603 assert_eq!(reloaded.packages[1].name, "perf");
604
605 std::fs::remove_file(&path).ok();
606 }
607
608 #[test]
609 fn test_add_packages_to_file_preserves_fields() {
610 let original = "\
611[[package]]
612name = \"perf\"
613repology_name = \"linux\"
614";
615 let dir = std::env::temp_dir().join("hs-relmon-test");
616 std::fs::create_dir_all(&dir).unwrap();
617 let path =
618 dir.join("test-add-packages-fields.toml");
619 std::fs::write(&path, original).unwrap();
620
621 add_packages_to_file(
622 &path,
623 &["ethtool".into()],
624 )
625 .unwrap();
626
627 let contents =
628 std::fs::read_to_string(&path).unwrap();
629 assert!(contents.contains("repology_name = \"linux\""));
630
631 let reloaded = Manifest::load(&path).unwrap();
632 assert_eq!(reloaded.packages.len(), 2);
633 assert_eq!(reloaded.packages[0].name, "ethtool");
634 assert_eq!(reloaded.packages[1].name, "perf");
635
636 std::fs::remove_file(&path).ok();
637 }
638}