Skip to main content

hs_relmon/
repology.rs

1// SPDX-License-Identifier: MPL-2.0
2
3use serde::Deserialize;
4
5/// Package version status as reported by Repology.
6#[derive(Debug, Clone, PartialEq, Deserialize)]
7#[serde(rename_all = "lowercase")]
8pub enum Status {
9    Newest,
10    Devel,
11    Unique,
12    Outdated,
13    Legacy,
14    Rolling,
15    Noscheme,
16    Incorrect,
17    Untrusted,
18    Ignored,
19}
20
21/// A single package entry from the Repology API.
22///
23/// Only `repo` and `version` are guaranteed to be present.
24#[derive(Debug, Clone, Deserialize)]
25pub struct Package {
26    pub repo: String,
27    pub version: String,
28    #[serde(default)]
29    pub subrepo: Option<String>,
30    #[serde(default)]
31    pub srcname: Option<String>,
32    #[serde(default)]
33    pub binname: Option<String>,
34    #[serde(default)]
35    pub binnames: Option<Vec<String>>,
36    #[serde(default)]
37    pub visiblename: Option<String>,
38    #[serde(default)]
39    pub origversion: Option<String>,
40    #[serde(default)]
41    pub status: Option<Status>,
42    #[serde(default)]
43    pub summary: Option<String>,
44    #[serde(default)]
45    pub categories: Option<Vec<String>>,
46    #[serde(default)]
47    pub licenses: Option<Vec<String>>,
48    #[serde(default)]
49    pub maintainers: Option<Vec<String>>,
50}
51
52/// Client for the Repology API.
53pub struct Client {
54    http: reqwest::blocking::Client,
55    base_url: String,
56}
57
58impl Client {
59    /// Create a new client using the default Repology API URL.
60    pub fn new() -> Self {
61        Self::with_base_url("https://repology.org/api/v1")
62    }
63
64    /// Create a client with a custom base URL (useful for testing).
65    pub fn with_base_url(base_url: &str) -> Self {
66        let http = reqwest::blocking::Client::builder()
67            .user_agent("hs-relmon/0.1.0")
68            .build()
69            .expect("failed to build HTTP client");
70        Self {
71            http,
72            base_url: base_url.trim_end_matches('/').to_string(),
73        }
74    }
75
76    /// Fetch all package entries for a given project name.
77    pub fn get_project(&self, name: &str) -> Result<Vec<Package>, Box<dyn std::error::Error>> {
78        let url = format!("{}/project/{}", self.base_url, name);
79        let packages = self.http.get(&url).send()?.json::<Vec<Package>>()?;
80        Ok(packages)
81    }
82}
83
84/// Return packages whose `repo` field matches the given name exactly.
85pub fn filter_by_repo<'a>(packages: &'a [Package], repo: &str) -> Vec<&'a Package> {
86    packages.iter().filter(|p| p.repo == repo).collect()
87}
88
89/// Find the first package with `status == Newest`.
90pub fn find_newest(packages: &[Package]) -> Option<&Package> {
91    packages
92        .iter()
93        .find(|p| p.status.as_ref() == Some(&Status::Newest))
94}
95
96/// Find the latest entry for a specific repo.
97///
98/// When a Repology project contains multiple source packages, picks the
99/// best entry by status priority (newest > outdated > legacy), breaking
100/// ties with version comparison.
101pub fn latest_for_repo<'a>(packages: &'a [Package], repo: &str) -> Option<&'a Package> {
102    let matches = filter_by_repo(packages, repo);
103    matches
104        .iter()
105        .max_by(|a, b| {
106            status_priority(&a.status)
107                .cmp(&status_priority(&b.status))
108                .then_with(|| version_cmp(&a.version, &b.version))
109        })
110        .copied()
111}
112
113/// Ranking for Repology status values (higher = more preferred).
114fn status_priority(status: &Option<Status>) -> u8 {
115    match status.as_ref() {
116        Some(Status::Newest) => 6,
117        Some(Status::Devel) => 5,
118        Some(Status::Unique) => 4,
119        Some(Status::Rolling) => 3,
120        Some(Status::Outdated) | Some(Status::Incorrect) => 2,
121        Some(Status::Legacy) => 0,
122        _ => 1,
123    }
124}
125
126/// Compare version strings by splitting on separators and comparing
127/// each component numerically when possible.
128pub fn version_cmp(a: &str, b: &str) -> std::cmp::Ordering {
129    let mut a_parts = a.split(|c: char| !c.is_alphanumeric());
130    let mut b_parts = b.split(|c: char| !c.is_alphanumeric());
131    loop {
132        match (a_parts.next(), b_parts.next()) {
133            (None, None) => return std::cmp::Ordering::Equal,
134            (None, Some(_)) => return std::cmp::Ordering::Less,
135            (Some(_), None) => return std::cmp::Ordering::Greater,
136            (Some(ap), Some(bp)) => {
137                let ord = match (ap.parse::<u64>(), bp.parse::<u64>()) {
138                    (Ok(an), Ok(bn)) => an.cmp(&bn),
139                    _ => ap.cmp(bp),
140                };
141                if ord != std::cmp::Ordering::Equal {
142                    return ord;
143                }
144            }
145        }
146    }
147}
148
149/// Find the package from the latest stable Fedora release.
150///
151/// Looks for `fedora_NN` repos (excluding `fedora_rawhide`), picks the
152/// highest release number, and prefers the "updates" subrepo.
153pub fn latest_fedora_stable(packages: &[Package]) -> Option<&Package> {
154    let max_release = packages
155        .iter()
156        .filter_map(|p| fedora_release_number(p))
157        .max()?;
158
159    let repo = format!("fedora_{}", max_release);
160    latest_for_repo(packages, &repo)
161}
162
163/// Find the package from the latest CentOS Stream release.
164///
165/// Looks for `centos_stream_NN` repos, picks the highest release number,
166/// then returns the entry with the highest version (non-legacy).
167pub fn latest_centos_stream(packages: &[Package]) -> Option<&Package> {
168    let max_release = packages
169        .iter()
170        .filter_map(|p| centos_stream_release_number(p))
171        .max()?;
172
173    let repo = format!("centos_stream_{}", max_release);
174    let matches = filter_by_repo(packages, &repo);
175    matches
176        .iter()
177        .max_by(|a, b| {
178            status_priority(&a.status)
179                .cmp(&status_priority(&b.status))
180                .then_with(|| version_cmp(&a.version, &b.version))
181        })
182        .copied()
183}
184
185/// Extract the numeric release from a `centos_stream_NN` repo name.
186fn centos_stream_release_number(package: &Package) -> Option<u32> {
187    package
188        .repo
189        .strip_prefix("centos_stream_")
190        .and_then(|s| s.parse::<u32>().ok())
191}
192
193/// Extract the numeric release from a `fedora_NN` repo name.
194fn fedora_release_number(package: &Package) -> Option<u32> {
195    package
196        .repo
197        .strip_prefix("fedora_")
198        .and_then(|s| s.parse::<u32>().ok())
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    fn fixture_packages() -> Vec<Package> {
206        let json = include_str!("../tests/fixtures/ethtool.json");
207        serde_json::from_str(json).expect("failed to parse fixture")
208    }
209
210    #[test]
211    fn deserialize_fixture() {
212        let packages = fixture_packages();
213        assert_eq!(packages.len(), 14);
214
215        let arch = &packages[0];
216        assert_eq!(arch.repo, "arch");
217        assert_eq!(arch.version, "6.19");
218        assert_eq!(arch.status, Some(Status::Newest));
219        assert_eq!(arch.origversion.as_deref(), Some("2:6.19-1"));
220    }
221
222    #[test]
223    fn deserialize_all_status_values() {
224        let cases = [
225            ("newest", Status::Newest),
226            ("devel", Status::Devel),
227            ("unique", Status::Unique),
228            ("outdated", Status::Outdated),
229            ("legacy", Status::Legacy),
230            ("rolling", Status::Rolling),
231            ("noscheme", Status::Noscheme),
232            ("incorrect", Status::Incorrect),
233            ("untrusted", Status::Untrusted),
234            ("ignored", Status::Ignored),
235        ];
236        for (input, expected) in cases {
237            let json = format!(r#"{{"repo":"test","version":"1","status":"{}"}}"#, input);
238            let pkg: Package = serde_json::from_str(&json).unwrap();
239            assert_eq!(pkg.status, Some(expected));
240        }
241    }
242
243    #[test]
244    fn deserialize_minimal_package() {
245        let json = r#"{"repo":"test","version":"1.0"}"#;
246        let pkg: Package = serde_json::from_str(json).unwrap();
247        assert_eq!(pkg.repo, "test");
248        assert_eq!(pkg.version, "1.0");
249        assert!(pkg.status.is_none());
250        assert!(pkg.subrepo.is_none());
251        assert!(pkg.srcname.is_none());
252    }
253
254    #[test]
255    fn test_filter_by_repo() {
256        let packages = fixture_packages();
257        let fedora_43 = filter_by_repo(&packages, "fedora_43");
258        assert_eq!(fedora_43.len(), 2);
259        assert!(fedora_43.iter().all(|p| p.repo == "fedora_43"));
260    }
261
262    #[test]
263    fn test_filter_by_repo_no_match() {
264        let packages = fixture_packages();
265        let result = filter_by_repo(&packages, "nonexistent");
266        assert!(result.is_empty());
267    }
268
269    #[test]
270    fn test_find_newest() {
271        let packages = fixture_packages();
272        let newest = find_newest(&packages).unwrap();
273        assert_eq!(newest.status, Some(Status::Newest));
274        assert_eq!(newest.version, "6.19");
275    }
276
277    #[test]
278    fn test_find_newest_none() {
279        let packages: Vec<Package> = vec![
280            serde_json::from_str(r#"{"repo":"a","version":"1","status":"outdated"}"#).unwrap(),
281            serde_json::from_str(r#"{"repo":"b","version":"2","status":"legacy"}"#).unwrap(),
282        ];
283        assert!(find_newest(&packages).is_none());
284    }
285
286    #[test]
287    fn test_latest_for_repo_prefers_updates() {
288        let packages = fixture_packages();
289        let pkg = latest_for_repo(&packages, "fedora_43").unwrap();
290        assert_eq!(pkg.subrepo.as_deref(), Some("updates"));
291        assert_eq!(pkg.version, "6.19");
292    }
293
294    #[test]
295    fn test_latest_for_repo_single_entry() {
296        let packages = fixture_packages();
297        let pkg = latest_for_repo(&packages, "fedora_rawhide").unwrap();
298        assert_eq!(pkg.repo, "fedora_rawhide");
299        assert_eq!(pkg.version, "6.19");
300    }
301
302    #[test]
303    fn test_latest_for_repo_no_match() {
304        let packages = fixture_packages();
305        assert!(latest_for_repo(&packages, "nonexistent").is_none());
306    }
307
308    #[test]
309    fn test_latest_for_repo_prefers_newest_status() {
310        let packages: Vec<Package> = vec![
311            serde_json::from_str(
312                r#"{"repo":"fedora_rawhide","version":"5.7.9","status":"outdated","srcname":"usbip"}"#,
313            ).unwrap(),
314            serde_json::from_str(
315                r#"{"repo":"fedora_rawhide","version":"7.0.0","status":"incorrect","srcname":"kernel"}"#,
316            ).unwrap(),
317            serde_json::from_str(
318                r#"{"repo":"fedora_rawhide","version":"6.19","status":"newest","srcname":"kernel"}"#,
319            ).unwrap(),
320        ];
321        let pkg = latest_for_repo(&packages, "fedora_rawhide").unwrap();
322        assert_eq!(pkg.version, "6.19");
323    }
324
325    #[test]
326    fn test_latest_for_repo_picks_highest_version_on_same_status() {
327        // Simulates linux project in fedora_rawhide: no newest entries,
328        // outdated usbip and incorrect kernel/kernel-headers.
329        let packages: Vec<Package> = vec![
330            serde_json::from_str(
331                r#"{"repo":"fedora_rawhide","version":"5.7.9","status":"outdated","srcname":"usbip"}"#,
332            ).unwrap(),
333            serde_json::from_str(
334                r#"{"repo":"fedora_rawhide","version":"7.0.0","status":"outdated","srcname":"kernel"}"#,
335            ).unwrap(),
336        ];
337        let pkg = latest_for_repo(&packages, "fedora_rawhide").unwrap();
338        assert_eq!(pkg.version, "7.0.0");
339    }
340
341    #[test]
342    fn test_latest_fedora_stable() {
343        let packages = fixture_packages();
344        let pkg = latest_fedora_stable(&packages).unwrap();
345        assert_eq!(pkg.repo, "fedora_43");
346        assert_eq!(pkg.subrepo.as_deref(), Some("updates"));
347        assert_eq!(pkg.version, "6.19");
348    }
349
350    #[test]
351    fn test_latest_fedora_stable_no_fedora() {
352        let packages: Vec<Package> = vec![
353            serde_json::from_str(r#"{"repo":"arch","version":"1","status":"newest"}"#).unwrap(),
354            serde_json::from_str(r#"{"repo":"debian_13","version":"2","status":"outdated"}"#)
355                .unwrap(),
356        ];
357        assert!(latest_fedora_stable(&packages).is_none());
358    }
359
360    #[test]
361    fn test_fedora_release_number() {
362        let pkg: Package =
363            serde_json::from_str(r#"{"repo":"fedora_43","version":"1"}"#).unwrap();
364        assert_eq!(fedora_release_number(&pkg), Some(43));
365
366        let rawhide: Package =
367            serde_json::from_str(r#"{"repo":"fedora_rawhide","version":"1"}"#).unwrap();
368        assert_eq!(fedora_release_number(&rawhide), None);
369
370        let other: Package =
371            serde_json::from_str(r#"{"repo":"arch","version":"1"}"#).unwrap();
372        assert_eq!(fedora_release_number(&other), None);
373    }
374
375    #[test]
376    fn test_latest_centos_stream() {
377        let packages = fixture_packages();
378        let pkg = latest_centos_stream(&packages).unwrap();
379        assert_eq!(pkg.repo, "centos_stream_10");
380        assert_eq!(pkg.version, "6.15");
381        assert_eq!(pkg.status, Some(Status::Outdated));
382    }
383
384    #[test]
385    fn test_latest_centos_stream_no_centos() {
386        let packages: Vec<Package> = vec![
387            serde_json::from_str(r#"{"repo":"arch","version":"1","status":"newest"}"#).unwrap(),
388            serde_json::from_str(r#"{"repo":"fedora_43","version":"2","status":"outdated"}"#)
389                .unwrap(),
390        ];
391        assert!(latest_centos_stream(&packages).is_none());
392    }
393
394    #[test]
395    fn test_centos_stream_release_number() {
396        let pkg: Package =
397            serde_json::from_str(r#"{"repo":"centos_stream_10","version":"1"}"#).unwrap();
398        assert_eq!(centos_stream_release_number(&pkg), Some(10));
399
400        let old: Package =
401            serde_json::from_str(r#"{"repo":"centos_8","version":"1"}"#).unwrap();
402        assert_eq!(centos_stream_release_number(&old), None);
403
404        let other: Package =
405            serde_json::from_str(r#"{"repo":"fedora_43","version":"1"}"#).unwrap();
406        assert_eq!(centos_stream_release_number(&other), None);
407    }
408
409    #[test]
410    fn test_version_cmp() {
411        use std::cmp::Ordering;
412        assert_eq!(version_cmp("6.18.16", "6.18.3"), Ordering::Greater);
413        assert_eq!(version_cmp("6.18.3", "6.18.16"), Ordering::Less);
414        assert_eq!(version_cmp("6.19", "6.19"), Ordering::Equal);
415        assert_eq!(version_cmp("7.0.0", "5.7.9"), Ordering::Greater);
416        assert_eq!(version_cmp("10.0", "9.0"), Ordering::Greater);
417        assert_eq!(version_cmp("1.0", "1.0.1"), Ordering::Less);
418        assert_eq!(version_cmp("1.0.1", "1.0"), Ordering::Greater);
419    }
420
421    #[test]
422    fn test_status_priority_ordering() {
423        assert!(status_priority(&Some(Status::Newest)) > status_priority(&Some(Status::Outdated)));
424        assert!(status_priority(&Some(Status::Outdated)) > status_priority(&Some(Status::Legacy)));
425        assert!(status_priority(&Some(Status::Outdated)) == status_priority(&Some(Status::Incorrect)));
426        assert!(status_priority(&Some(Status::Devel)) > status_priority(&Some(Status::Outdated)));
427    }
428
429    #[test]
430    fn test_client_new() {
431        let client = Client::new();
432        assert_eq!(client.base_url, "https://repology.org/api/v1");
433    }
434
435    #[test]
436    fn test_client_with_base_url_trims_slash() {
437        let client = Client::with_base_url("https://example.com/api/");
438        assert_eq!(client.base_url, "https://example.com/api");
439    }
440}