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}