1use serde::Deserialize;
4
5#[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#[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
52pub struct Client {
54 http: reqwest::blocking::Client,
55 base_url: String,
56}
57
58impl Client {
59 pub fn new() -> Self {
61 Self::with_base_url("https://repology.org/api/v1")
62 }
63
64 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 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
84pub fn filter_by_repo<'a>(packages: &'a [Package], repo: &str) -> Vec<&'a Package> {
86 packages.iter().filter(|p| p.repo == repo).collect()
87}
88
89pub fn find_newest(packages: &[Package]) -> Option<&Package> {
91 packages
92 .iter()
93 .find(|p| p.status.as_ref() == Some(&Status::Newest))
94}
95
96pub 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
113fn 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
126pub 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
140pub 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
162fn 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
170fn 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 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}