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.2.1")
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(|| crate::rpmvercmp::rpmvercmp(&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/// Find the package from the latest stable Fedora release.
127///
128/// Looks for `fedora_NN` repos (excluding `fedora_rawhide`), picks the
129/// highest release number, and prefers the "updates" subrepo.
130pub fn latest_fedora_stable(packages: &[Package]) -> Option<&Package> {
131    let max_release = packages
132        .iter()
133        .filter_map(|p| fedora_release_number(p))
134        .max()?;
135
136    let repo = format!("fedora_{}", max_release);
137    latest_for_repo(packages, &repo)
138}
139
140/// Find the package from the latest CentOS Stream release.
141///
142/// Looks for `centos_stream_NN` repos, picks the highest release number,
143/// then returns the entry with the highest version (non-legacy).
144pub fn latest_centos_stream(packages: &[Package]) -> Option<&Package> {
145    let max_release = packages
146        .iter()
147        .filter_map(|p| centos_stream_release_number(p))
148        .max()?;
149
150    let repo = format!("centos_stream_{}", max_release);
151    let matches = filter_by_repo(packages, &repo);
152    matches
153        .iter()
154        .max_by(|a, b| {
155            status_priority(&a.status)
156                .cmp(&status_priority(&b.status))
157                .then_with(|| crate::rpmvercmp::rpmvercmp(&a.version, &b.version))
158        })
159        .copied()
160}
161
162/// Extract the numeric release from a `centos_stream_NN` repo name.
163fn centos_stream_release_number(package: &Package) -> Option<u32> {
164    package
165        .repo
166        .strip_prefix("centos_stream_")
167        .and_then(|s| s.parse::<u32>().ok())
168}
169
170/// Extract the numeric release from a `fedora_NN` repo name.
171fn fedora_release_number(package: &Package) -> Option<u32> {
172    package
173        .repo
174        .strip_prefix("fedora_")
175        .and_then(|s| s.parse::<u32>().ok())
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn fixture_packages() -> Vec<Package> {
183        let json = include_str!("../tests/fixtures/ethtool.json");
184        serde_json::from_str(json).expect("failed to parse fixture")
185    }
186
187    #[test]
188    fn deserialize_fixture() {
189        let packages = fixture_packages();
190        assert_eq!(packages.len(), 14);
191
192        let arch = &packages[0];
193        assert_eq!(arch.repo, "arch");
194        assert_eq!(arch.version, "6.19");
195        assert_eq!(arch.status, Some(Status::Newest));
196        assert_eq!(arch.origversion.as_deref(), Some("2:6.19-1"));
197    }
198
199    #[test]
200    fn deserialize_all_status_values() {
201        let cases = [
202            ("newest", Status::Newest),
203            ("devel", Status::Devel),
204            ("unique", Status::Unique),
205            ("outdated", Status::Outdated),
206            ("legacy", Status::Legacy),
207            ("rolling", Status::Rolling),
208            ("noscheme", Status::Noscheme),
209            ("incorrect", Status::Incorrect),
210            ("untrusted", Status::Untrusted),
211            ("ignored", Status::Ignored),
212        ];
213        for (input, expected) in cases {
214            let json = format!(r#"{{"repo":"test","version":"1","status":"{}"}}"#, input);
215            let pkg: Package = serde_json::from_str(&json).unwrap();
216            assert_eq!(pkg.status, Some(expected));
217        }
218    }
219
220    #[test]
221    fn deserialize_minimal_package() {
222        let json = r#"{"repo":"test","version":"1.0"}"#;
223        let pkg: Package = serde_json::from_str(json).unwrap();
224        assert_eq!(pkg.repo, "test");
225        assert_eq!(pkg.version, "1.0");
226        assert!(pkg.status.is_none());
227        assert!(pkg.subrepo.is_none());
228        assert!(pkg.srcname.is_none());
229    }
230
231    #[test]
232    fn test_filter_by_repo() {
233        let packages = fixture_packages();
234        let fedora_43 = filter_by_repo(&packages, "fedora_43");
235        assert_eq!(fedora_43.len(), 2);
236        assert!(fedora_43.iter().all(|p| p.repo == "fedora_43"));
237    }
238
239    #[test]
240    fn test_filter_by_repo_no_match() {
241        let packages = fixture_packages();
242        let result = filter_by_repo(&packages, "nonexistent");
243        assert!(result.is_empty());
244    }
245
246    #[test]
247    fn test_find_newest() {
248        let packages = fixture_packages();
249        let newest = find_newest(&packages).unwrap();
250        assert_eq!(newest.status, Some(Status::Newest));
251        assert_eq!(newest.version, "6.19");
252    }
253
254    #[test]
255    fn test_find_newest_none() {
256        let packages: Vec<Package> = vec![
257            serde_json::from_str(r#"{"repo":"a","version":"1","status":"outdated"}"#).unwrap(),
258            serde_json::from_str(r#"{"repo":"b","version":"2","status":"legacy"}"#).unwrap(),
259        ];
260        assert!(find_newest(&packages).is_none());
261    }
262
263    #[test]
264    fn test_latest_for_repo_prefers_updates() {
265        let packages = fixture_packages();
266        let pkg = latest_for_repo(&packages, "fedora_43").unwrap();
267        assert_eq!(pkg.subrepo.as_deref(), Some("updates"));
268        assert_eq!(pkg.version, "6.19");
269    }
270
271    #[test]
272    fn test_latest_for_repo_single_entry() {
273        let packages = fixture_packages();
274        let pkg = latest_for_repo(&packages, "fedora_rawhide").unwrap();
275        assert_eq!(pkg.repo, "fedora_rawhide");
276        assert_eq!(pkg.version, "6.19");
277    }
278
279    #[test]
280    fn test_latest_for_repo_no_match() {
281        let packages = fixture_packages();
282        assert!(latest_for_repo(&packages, "nonexistent").is_none());
283    }
284
285    #[test]
286    fn test_latest_for_repo_prefers_newest_status() {
287        let packages: Vec<Package> = vec![
288            serde_json::from_str(
289                r#"{"repo":"fedora_rawhide","version":"5.7.9","status":"outdated","srcname":"usbip"}"#,
290            ).unwrap(),
291            serde_json::from_str(
292                r#"{"repo":"fedora_rawhide","version":"7.0.0","status":"incorrect","srcname":"kernel"}"#,
293            ).unwrap(),
294            serde_json::from_str(
295                r#"{"repo":"fedora_rawhide","version":"6.19","status":"newest","srcname":"kernel"}"#,
296            ).unwrap(),
297        ];
298        let pkg = latest_for_repo(&packages, "fedora_rawhide").unwrap();
299        assert_eq!(pkg.version, "6.19");
300    }
301
302    #[test]
303    fn test_latest_for_repo_picks_highest_version_on_same_status() {
304        // Simulates linux project in fedora_rawhide: no newest entries,
305        // outdated usbip and incorrect kernel/kernel-headers.
306        let packages: Vec<Package> = vec![
307            serde_json::from_str(
308                r#"{"repo":"fedora_rawhide","version":"5.7.9","status":"outdated","srcname":"usbip"}"#,
309            ).unwrap(),
310            serde_json::from_str(
311                r#"{"repo":"fedora_rawhide","version":"7.0.0","status":"outdated","srcname":"kernel"}"#,
312            ).unwrap(),
313        ];
314        let pkg = latest_for_repo(&packages, "fedora_rawhide").unwrap();
315        assert_eq!(pkg.version, "7.0.0");
316    }
317
318    #[test]
319    fn test_latest_fedora_stable() {
320        let packages = fixture_packages();
321        let pkg = latest_fedora_stable(&packages).unwrap();
322        assert_eq!(pkg.repo, "fedora_43");
323        assert_eq!(pkg.subrepo.as_deref(), Some("updates"));
324        assert_eq!(pkg.version, "6.19");
325    }
326
327    #[test]
328    fn test_latest_fedora_stable_no_fedora() {
329        let packages: Vec<Package> = vec![
330            serde_json::from_str(r#"{"repo":"arch","version":"1","status":"newest"}"#).unwrap(),
331            serde_json::from_str(r#"{"repo":"debian_13","version":"2","status":"outdated"}"#)
332                .unwrap(),
333        ];
334        assert!(latest_fedora_stable(&packages).is_none());
335    }
336
337    #[test]
338    fn test_fedora_release_number() {
339        let pkg: Package =
340            serde_json::from_str(r#"{"repo":"fedora_43","version":"1"}"#).unwrap();
341        assert_eq!(fedora_release_number(&pkg), Some(43));
342
343        let rawhide: Package =
344            serde_json::from_str(r#"{"repo":"fedora_rawhide","version":"1"}"#).unwrap();
345        assert_eq!(fedora_release_number(&rawhide), None);
346
347        let other: Package =
348            serde_json::from_str(r#"{"repo":"arch","version":"1"}"#).unwrap();
349        assert_eq!(fedora_release_number(&other), None);
350    }
351
352    #[test]
353    fn test_latest_centos_stream() {
354        let packages = fixture_packages();
355        let pkg = latest_centos_stream(&packages).unwrap();
356        assert_eq!(pkg.repo, "centos_stream_10");
357        assert_eq!(pkg.version, "6.15");
358        assert_eq!(pkg.status, Some(Status::Outdated));
359    }
360
361    #[test]
362    fn test_latest_centos_stream_no_centos() {
363        let packages: Vec<Package> = vec![
364            serde_json::from_str(r#"{"repo":"arch","version":"1","status":"newest"}"#).unwrap(),
365            serde_json::from_str(r#"{"repo":"fedora_43","version":"2","status":"outdated"}"#)
366                .unwrap(),
367        ];
368        assert!(latest_centos_stream(&packages).is_none());
369    }
370
371    #[test]
372    fn test_centos_stream_release_number() {
373        let pkg: Package =
374            serde_json::from_str(r#"{"repo":"centos_stream_10","version":"1"}"#).unwrap();
375        assert_eq!(centos_stream_release_number(&pkg), Some(10));
376
377        let old: Package =
378            serde_json::from_str(r#"{"repo":"centos_8","version":"1"}"#).unwrap();
379        assert_eq!(centos_stream_release_number(&old), None);
380
381        let other: Package =
382            serde_json::from_str(r#"{"repo":"fedora_43","version":"1"}"#).unwrap();
383        assert_eq!(centos_stream_release_number(&other), None);
384    }
385
386
387    #[test]
388    fn test_status_priority_ordering() {
389        assert!(status_priority(&Some(Status::Newest)) > status_priority(&Some(Status::Outdated)));
390        assert!(status_priority(&Some(Status::Outdated)) > status_priority(&Some(Status::Legacy)));
391        assert!(status_priority(&Some(Status::Outdated)) == status_priority(&Some(Status::Incorrect)));
392        assert!(status_priority(&Some(Status::Devel)) > status_priority(&Some(Status::Outdated)));
393    }
394
395    #[test]
396    fn test_client_new() {
397        let client = Client::new();
398        assert_eq!(client.base_url, "https://repology.org/api/v1");
399    }
400
401    #[test]
402    fn test_client_with_base_url_trims_slash() {
403        let client = Client::with_base_url("https://example.com/api/");
404        assert_eq!(client.base_url, "https://example.com/api");
405    }
406}