Skip to main content

arch_toolkit/types/
news.rs

1//! News-related data types for Arch Linux news and security advisories.
2
3use serde::{Deserialize, Serialize};
4
5/// What: A news item from the Arch Linux news RSS feed.
6///
7/// Inputs:
8/// - Produced by `news::parse_arch_news_rss()` / `news::fetch_arch_news()`.
9///
10/// Output:
11/// - Date, title, and URL of a news posting on archlinux.org.
12///
13/// Details:
14/// - `date` is normalized to `YYYY-MM-DD` so items sort lexicographically.
15/// - Serializable via Serde for caller-side caching.
16#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
17pub struct ArchNewsItem {
18    /// Publication date, normalized to `YYYY-MM-DD`.
19    pub date: String,
20    /// News headline.
21    pub title: String,
22    /// Link to the full article on archlinux.org.
23    pub url: String,
24}
25
26/// What: Severity level of a security advisory.
27///
28/// Inputs:
29/// - Parsed from advisory feed/title strings via `AdvisorySeverity::parse()`.
30///
31/// Output:
32/// - Ordered severity classification, sortable via `rank()`.
33///
34/// Details:
35/// - Ported from Pacsea's `AdvisorySeverity` with the same rank ordering
36///   (Critical=5 > High=4 > Medium=3 > Low=2 > Unknown=1).
37#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
38pub enum AdvisorySeverity {
39    /// Unknown or not provided.
40    #[default]
41    Unknown,
42    /// Low severity.
43    Low,
44    /// Medium severity.
45    Medium,
46    /// High severity.
47    High,
48    /// Critical severity.
49    Critical,
50}
51
52impl AdvisorySeverity {
53    /// What: Return a numeric rank for sorting advisories by severity.
54    ///
55    /// Inputs: None.
56    ///
57    /// Output:
58    /// - `5` for Critical down to `1` for Unknown.
59    ///
60    /// Details:
61    /// - Matches Pacsea's `severity_rank()` so sorting behavior is identical.
62    #[must_use]
63    pub const fn rank(self) -> u8 {
64        match self {
65            Self::Critical => 5,
66            Self::High => 4,
67            Self::Medium => 3,
68            Self::Low => 2,
69            Self::Unknown => 1,
70        }
71    }
72
73    /// What: Parse a severity string from a feed into a variant.
74    ///
75    /// Inputs:
76    /// - `s`: Severity text (e.g., "Critical", "high", "MEDIUM").
77    ///
78    /// Output:
79    /// - Matching variant; `Unknown` for unrecognized input.
80    ///
81    /// Details:
82    /// - Case-insensitive; tolerates surrounding whitespace.
83    #[must_use]
84    pub fn parse(s: &str) -> Self {
85        match s.trim().to_ascii_lowercase().as_str() {
86            "low" => Self::Low,
87            "medium" => Self::Medium,
88            "high" => Self::High,
89            "critical" => Self::Critical,
90            _ => Self::Unknown,
91        }
92    }
93}
94
95impl std::fmt::Display for AdvisorySeverity {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        let s = match self {
98            Self::Unknown => "unknown",
99            Self::Low => "low",
100            Self::Medium => "medium",
101            Self::High => "high",
102            Self::Critical => "critical",
103        };
104        f.write_str(s)
105    }
106}
107
108/// What: A security advisory from security.archlinux.org.
109///
110/// Inputs:
111/// - Produced by `news::parse_advisories_atom()` / `news::fetch_security_advisories()`.
112///
113/// Output:
114/// - Advisory metadata: id, date, title, optional summary/URL, severity, packages.
115///
116/// Details:
117/// - `id` falls back from URL → title → raw date, matching Pacsea's behavior.
118/// - `severity` and `packages` are best-effort extracted from the advisory title
119///   (format: `ASA-YYYYMM-N: package: issue type`); severity defaults to Unknown.
120/// - `date` is normalized to `YYYY-MM-DD`.
121#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
122pub struct SecurityAdvisory {
123    /// Stable identifier (advisory URL, or title/date fallback).
124    pub id: String,
125    /// Publication or update date, normalized to `YYYY-MM-DD`.
126    pub date: String,
127    /// Advisory headline.
128    pub title: String,
129    /// Optional summary text from the feed.
130    pub summary: Option<String>,
131    /// Optional link to the advisory page.
132    pub url: Option<String>,
133    /// Parsed severity (Unknown when the feed does not state one).
134    pub severity: AdvisorySeverity,
135    /// Affected package names extracted from the advisory title (best-effort).
136    pub packages: Vec<String>,
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    /// What: Verify severity ranks preserve Pacsea's sorting order.
145    ///
146    /// Inputs:
147    /// - All severity variants.
148    ///
149    /// Output:
150    /// - Critical > High > Medium > Low > Unknown.
151    ///
152    /// Details:
153    /// - Rank values must match Pacsea's `severity_rank()` exactly.
154    fn severity_ranks() {
155        assert_eq!(AdvisorySeverity::Critical.rank(), 5);
156        assert_eq!(AdvisorySeverity::High.rank(), 4);
157        assert_eq!(AdvisorySeverity::Medium.rank(), 3);
158        assert_eq!(AdvisorySeverity::Low.rank(), 2);
159        assert_eq!(AdvisorySeverity::Unknown.rank(), 1);
160    }
161
162    #[test]
163    /// What: Verify severity parsing is case-insensitive with Unknown fallback.
164    ///
165    /// Inputs:
166    /// - Mixed-case severity strings and garbage input.
167    ///
168    /// Output:
169    /// - Correct variants; Unknown for unrecognized text.
170    ///
171    /// Details:
172    /// - Feed severity capitalization varies, so parsing must normalize.
173    fn severity_parsing() {
174        assert_eq!(
175            AdvisorySeverity::parse("Critical"),
176            AdvisorySeverity::Critical
177        );
178        assert_eq!(AdvisorySeverity::parse("HIGH"), AdvisorySeverity::High);
179        assert_eq!(
180            AdvisorySeverity::parse(" medium "),
181            AdvisorySeverity::Medium
182        );
183        assert_eq!(AdvisorySeverity::parse("low"), AdvisorySeverity::Low);
184        assert_eq!(AdvisorySeverity::parse("weird"), AdvisorySeverity::Unknown);
185        assert_eq!(AdvisorySeverity::parse(""), AdvisorySeverity::Unknown);
186    }
187
188    #[test]
189    /// What: Verify serde roundtrips for news types.
190    ///
191    /// Inputs:
192    /// - Sample `ArchNewsItem` and `SecurityAdvisory` values.
193    ///
194    /// Output:
195    /// - Deserialized values equal the originals.
196    ///
197    /// Details:
198    /// - Ensures caller-side JSON caching works.
199    fn serde_roundtrips() {
200        let item = ArchNewsItem {
201            date: "2026-07-01".to_string(),
202            title: "Grub update".to_string(),
203            url: "https://archlinux.org/news/grub-update/".to_string(),
204        };
205        let back: ArchNewsItem =
206            serde_json::from_str(&serde_json::to_string(&item).expect("serialize item"))
207                .expect("deserialize item");
208        assert_eq!(back, item);
209
210        let advisory = SecurityAdvisory {
211            id: "https://security.archlinux.org/ASA-202607-1".to_string(),
212            date: "2026-07-01".to_string(),
213            title: "ASA-202607-1: openssl: multiple issues".to_string(),
214            summary: Some("Multiple issues".to_string()),
215            url: Some("https://security.archlinux.org/ASA-202607-1".to_string()),
216            severity: AdvisorySeverity::High,
217            packages: vec!["openssl".to_string()],
218        };
219        let back: SecurityAdvisory =
220            serde_json::from_str(&serde_json::to_string(&advisory).expect("serialize advisory"))
221                .expect("deserialize advisory");
222        assert_eq!(back, advisory);
223    }
224}