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.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 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(|| version_cmp(&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 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
149pub 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
163pub 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
185fn 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
193fn 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 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}