Skip to main content

hs_relmon/
manifest.rs

1// SPDX-License-Identifier: MPL-2.0
2
3use crate::check_latest::{Distros, TrackRef};
4use serde::{Deserialize, Serialize};
5use std::path::Path;
6
7/// A manifest listing packages to check.
8#[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/// Default settings applied to all packages unless overridden.
17#[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/// A single package entry in the manifest.
32#[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/// A package entry with all defaults resolved.
48#[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    /// Load a manifest from a TOML file.
60    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    /// Add packages by name (skipping duplicates) and sort.
68    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    /// Sort packages by name.
90    pub fn sort_packages(&mut self) {
91        self.packages.sort_by(|a, b| a.name.cmp(&b.name));
92    }
93
94    /// Resolve all packages by merging per-package overrides
95    /// with defaults.
96    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
157/// Add packages to a manifest file, keeping entries sorted.
158///
159/// Preserves comments and formatting via `toml_edit`.
160pub 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    // Collect existing tables, rebuilding each without span
171    // info but preserving decorations (comments).
172    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        // Comments preserved
560        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        // Packages sorted
569        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}