1use serde_json::Value;
4
5use crate::state::{PackageDetails, PackageItem, Source};
6use crate::util::{arrs, s, ss, u64_of};
7
8type Result<T> = super::Result<T>;
10
11fn split_ws_or_none(s: Option<&String>) -> Vec<String> {
19 match s {
20 Some(v) if v != "None" => v.split_whitespace().map(ToString::to_string).collect(),
21 _ => Vec::new(),
22 }
23}
24
25fn process_continuation_line(
38 map: &mut std::collections::BTreeMap<String, String>,
39 key: &str,
40 line: &str,
41) {
42 let entry = map.entry(key.to_string()).or_default();
43 if key == "Optional Deps" {
44 entry.push('\n');
45 } else if !entry.ends_with(' ') {
46 entry.push(' ');
47 }
48 entry.push_str(line.trim());
49}
50
51fn run_pacman_si(repo: &str, name: &str) -> Result<String> {
63 let spec = if repo.is_empty() {
64 name.to_string()
65 } else {
66 format!("{repo}/{name}")
67 };
68 let out = std::process::Command::new("pacman")
69 .env("LC_ALL", "C")
70 .env("LANG", "C")
71 .args(["-Si", &spec])
72 .output()?;
73 if !out.status.success() {
74 return Err(format!("pacman -Si failed: {:?}", out.status).into());
75 }
76 String::from_utf8(out.stdout).map_err(std::convert::Into::into)
77}
78
79fn parse_pacman_output(text: &str) -> std::collections::BTreeMap<String, String> {
91 let mut map: std::collections::BTreeMap<String, String> = std::collections::BTreeMap::new();
92 let mut last_key: Option<String> = None;
93 for line in text.lines() {
94 if line.trim().is_empty() {
95 continue;
96 }
97 if let Some((k, v)) = line.split_once(':') {
98 let key = k.trim().to_string();
99 let val = v.trim().to_string();
100 map.insert(key.clone(), val);
101 last_key = Some(key);
102 } else if line.starts_with(' ')
103 && let Some(k) = &last_key
104 {
105 process_continuation_line(&mut map, k, line);
106 }
107 }
108 map
109}
110
111struct ParsedFields {
115 licenses: Vec<String>,
117 groups: Vec<String>,
119 provides: Vec<String>,
121 depends: Vec<String>,
123 opt_depends: Vec<String>,
125 required_by: Vec<String>,
127 optional_for: Vec<String>,
129 conflicts: Vec<String>,
131 replaces: Vec<String>,
133 description: String,
135 architecture: String,
137 download_size: Option<u64>,
139 install_size: Option<u64>,
141}
142
143fn extract_fields(map: &std::collections::BTreeMap<String, String>) -> ParsedFields {
155 let licenses = split_ws_or_none(map.get("Licenses").or_else(|| map.get("License")));
156 let groups = split_ws_or_none(map.get("Groups"));
157 let provides = split_ws_or_none(map.get("Provides"));
158 let depends = split_ws_or_none(map.get("Depends On"));
159 let opt_depends = map
160 .get("Optional Deps")
161 .map(|s| {
162 s.lines()
163 .filter_map(|l| l.split_once(':').map(|(pkg, _)| pkg.trim().to_string()))
164 .filter(|x| !x.is_empty() && x != "None")
165 .collect()
166 })
167 .unwrap_or_default();
168 let required_by = split_ws_or_none(map.get("Required By"));
169 let optional_for = split_ws_or_none(map.get("Optional For"));
170 let conflicts = split_ws_or_none(map.get("Conflicts With"));
171 let replaces = split_ws_or_none(map.get("Replaces"));
172
173 ParsedFields {
174 licenses,
175 groups,
176 provides,
177 depends,
178 opt_depends,
179 required_by,
180 optional_for,
181 conflicts,
182 replaces,
183 description: map.get("Description").cloned().unwrap_or_default(),
184 architecture: map.get("Architecture").cloned().unwrap_or_default(),
185 download_size: map.get("Download Size").and_then(|s| parse_size_bytes(s)),
186 install_size: map.get("Installed Size").and_then(|s| parse_size_bytes(s)),
187 }
188}
189
190fn fill_missing_fields(name: &str, description: &mut String, architecture: &mut String) {
204 if description.is_empty() || architecture.is_empty() {
205 let mut from_idx = None;
206 let official_results = crate::index::search_official(name, false);
208 for (it, _) in official_results {
209 if it.name.eq_ignore_ascii_case(name) {
210 from_idx = Some(it);
211 break;
212 }
213 }
214 if let Some(it) = from_idx {
215 if description.is_empty() {
216 *description = it.description;
217 }
218 if architecture.is_empty()
219 && let Source::Official { arch, .. } = it.source
220 {
221 *architecture = arch;
222 }
223 }
224 }
225}
226
227fn build_package_details(
238 repo: &str,
239 name: &str,
240 map: &std::collections::BTreeMap<String, String>,
241 fields: ParsedFields,
242) -> PackageDetails {
243 PackageDetails {
244 repository: map
245 .get("Repository")
246 .cloned()
247 .unwrap_or_else(|| repo.to_string()),
248 name: map.get("Name").cloned().unwrap_or_else(|| name.to_string()),
249 version: map.get("Version").cloned().unwrap_or_default(),
250 description: fields.description,
251 architecture: fields.architecture,
252 url: map.get("URL").cloned().unwrap_or_default(),
253 licenses: fields.licenses,
254 groups: fields.groups,
255 provides: fields.provides,
256 depends: fields.depends,
257 opt_depends: fields.opt_depends,
258 required_by: fields.required_by,
259 optional_for: fields.optional_for,
260 conflicts: fields.conflicts,
261 replaces: fields.replaces,
262 download_size: fields.download_size,
263 install_size: fields.install_size,
264 owner: map.get("Packager").cloned().unwrap_or_default(),
265 build_date: map.get("Build Date").cloned().unwrap_or_default(),
266 popularity: None,
267 out_of_date: None,
268 orphaned: false,
269 }
270}
271
272fn pacman_si(repo: &str, name: &str) -> Result<PackageDetails> {
281 let text = run_pacman_si(repo, name)?;
282 let map = parse_pacman_output(&text);
283 let mut fields = extract_fields(&map);
284 fill_missing_fields(name, &mut fields.description, &mut fields.architecture);
285 Ok(build_package_details(repo, name, &map, fields))
286}
287
288fn parse_size_bytes(s: &str) -> Option<u64> {
296 const MAX_U64_AS_F64: f64 = 18_446_744_073_709_551_615.0; let mut it = s.split_whitespace();
300 let num = it.next()?.parse::<f64>().ok()?;
301 let unit = it.next().unwrap_or("");
302 let mult = match unit {
303 "KiB" => 1024.0,
304 "MiB" => 1024.0 * 1024.0,
305 "GiB" => 1024.0 * 1024.0 * 1024.0,
306 "TiB" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
307 _ => 1.0,
308 };
309 let result = num * mult;
310 if result < 0.0 {
312 return None;
313 }
314 if result > MAX_U64_AS_F64 {
315 return None;
316 }
317 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
318 let bytes = result as u64;
319 Some(bytes)
320}
321
322#[cfg(test)]
323mod size_tests {
324 #[test]
325 fn details_parse_size_bytes_units() {
336 assert_eq!(super::parse_size_bytes("10 B"), Some(10));
337 assert_eq!(super::parse_size_bytes("1 KiB"), Some(1024));
338 assert_eq!(super::parse_size_bytes("2 MiB"), Some(2 * 1024 * 1024));
339 assert_eq!(
340 super::parse_size_bytes("3 GiB"),
341 Some(3 * 1024 * 1024 * 1024)
342 );
343 assert_eq!(
344 super::parse_size_bytes("4 TiB"),
345 Some(4 * 1024 * 1024 * 1024 * 1024)
346 );
347 assert!(super::parse_size_bytes("bad").is_none());
348 }
349}
350
351pub async fn fetch_details(item: PackageItem) -> Result<PackageDetails> {
365 match item.source.clone() {
366 Source::Official { repo, arch } => fetch_official_details(repo, arch, item).await,
367 Source::Aur => fetch_aur_details(item).await,
368 }
369}
370
371pub async fn fetch_aur_details(item: PackageItem) -> Result<PackageDetails> {
377 let url = format!(
378 "https://aur.archlinux.org/rpc/v5/info?arg={}",
379 crate::util::percent_encode(&item.name)
380 );
381 let v = tokio::task::spawn_blocking(move || crate::util::curl::curl_json(&url)).await??;
382 let arr = v
383 .get("results")
384 .and_then(|x| x.as_array())
385 .cloned()
386 .unwrap_or_default();
387 let obj = arr.first().cloned().unwrap_or(Value::Null);
388
389 let version0 = s(&obj, "Version");
390 let description0 = s(&obj, "Description");
391 let popularity0 = obj.get("Popularity").and_then(serde_json::Value::as_f64);
392 let out_of_date = obj
394 .get("OutOfDate")
395 .and_then(serde_json::Value::as_i64)
396 .and_then(|ts| u64::try_from(ts).ok())
397 .filter(|&ts| ts > 0);
398 let maintainer = s(&obj, "Maintainer");
400 let orphaned = maintainer.is_empty();
401
402 let d = PackageDetails {
403 repository: "AUR".into(),
404 name: item.name.clone(),
405 version: if version0.is_empty() {
406 item.version.clone()
407 } else {
408 version0
409 },
410 description: if description0.is_empty() {
411 item.description.clone()
412 } else {
413 description0
414 },
415 architecture: "any".into(),
416 url: s(&obj, "URL"),
417 licenses: arrs(&obj, &["License", "Licenses"]),
418 groups: arrs(&obj, &["Groups"]),
419 provides: arrs(&obj, &["Provides"]),
420 depends: arrs(&obj, &["Depends"]),
421 opt_depends: arrs(&obj, &["OptDepends"]),
422 required_by: vec![],
423 optional_for: vec![],
424 conflicts: arrs(&obj, &["Conflicts"]),
425 replaces: arrs(&obj, &["Replaces"]),
426 download_size: None,
427 install_size: None,
428 owner: maintainer,
429 build_date: crate::util::ts_to_date(
430 obj.get("LastModified").and_then(serde_json::Value::as_i64),
431 ),
432 popularity: popularity0,
433 out_of_date,
434 orphaned,
435 };
436 Ok(d)
437}
438
439pub async fn fetch_official_details(
448 repo: String,
449 arch: String,
450 item: PackageItem,
451) -> Result<PackageDetails> {
452 if let Ok(Ok(pd)) = tokio::task::spawn_blocking({
453 let repo = repo.clone();
454 let name = item.name.clone();
455 move || pacman_si(&repo, &name)
456 })
457 .await
458 {
459 let has_core =
460 !(pd.description.is_empty() && pd.architecture.is_empty() && pd.licenses.is_empty());
461 if has_core {
462 return Ok(pd);
463 }
464 }
465
466 let arch_candidates: Vec<String> = if arch.trim().is_empty() {
467 vec!["x86_64".to_string(), "any".to_string()]
468 } else if arch.to_lowercase() == "any" {
469 vec!["any".to_string()]
470 } else {
471 vec![arch.clone(), "any".to_string()]
472 };
473 let repo_candidates: Vec<String> = if repo.trim().is_empty() {
474 vec!["core".to_string(), "extra".to_string()]
475 } else {
476 vec![repo.clone()]
477 };
478 let mut v: Option<Value> = None;
479 let mut repo_selected = repo.clone();
480 let mut arch_selected = arch.clone();
481 'outer: for r in &repo_candidates {
482 for a in &arch_candidates {
483 let url = format!(
484 "https://archlinux.org/packages/{}/{}/{}/json/",
485 r.to_lowercase(),
486 a,
487 item.name
488 );
489 if let Ok(Ok(val)) = tokio::task::spawn_blocking({
490 let url = url.clone();
491 move || crate::util::curl::curl_json(&url)
492 })
493 .await
494 {
495 v = Some(val);
496 repo_selected.clone_from(r);
497 arch_selected.clone_from(a);
498 break 'outer;
499 }
500 }
501 }
502
503 if let Some(v) = v {
504 let obj = v.get("pkg").unwrap_or(&v);
505 let d = PackageDetails {
506 repository: repo_selected,
507 name: item.name.clone(),
508 version: ss(obj, &["pkgver", "Version"]).unwrap_or(item.version),
509 description: ss(obj, &["pkgdesc", "Description"]).unwrap_or(item.description),
510 architecture: ss(obj, &["arch", "Architecture"]).unwrap_or(arch_selected),
511 url: ss(obj, &["url", "URL"]).unwrap_or_default(),
512 licenses: arrs(obj, &["licenses", "Licenses"]),
513 groups: arrs(obj, &["groups", "Groups"]),
514 provides: arrs(obj, &["provides", "Provides"]),
515 depends: arrs(obj, &["depends", "Depends"]),
516 opt_depends: arrs(obj, &["optdepends", "OptDepends"]),
517 required_by: arrs(obj, &["requiredby", "RequiredBy"]),
518 optional_for: vec![],
519 conflicts: arrs(obj, &["conflicts", "Conflicts"]),
520 replaces: arrs(obj, &["replaces", "Replaces"]),
521 download_size: u64_of(obj, &["compressed_size", "CompressedSize"]),
522 install_size: u64_of(obj, &["installed_size", "InstalledSize"]),
523 owner: ss(obj, &["packager", "Packager"]).unwrap_or_default(),
524 build_date: ss(obj, &["build_date", "BuildDate"]).unwrap_or_default(),
525 popularity: None,
526 out_of_date: None,
527 orphaned: false,
528 };
529 return Ok(d);
530 }
531
532 Err("official details unavailable".into())
533}
534
535#[cfg(test)]
536mod tests {
537 #[test]
540 fn sources_details_parse_official_json_defaults_and_fields() {
552 fn parse_official_from_json(
553 obj: &serde_json::Value,
554 repo_selected: String,
555 arch_selected: String,
556 item: &crate::state::PackageItem,
557 ) -> crate::state::PackageDetails {
558 use crate::util::{arrs, ss, u64_of};
559 crate::state::PackageDetails {
560 repository: repo_selected,
561 name: item.name.clone(),
562 version: ss(obj, &["pkgver", "Version"]).unwrap_or_else(|| item.version.clone()),
563 description: ss(obj, &["pkgdesc", "Description"])
564 .unwrap_or_else(|| item.description.clone()),
565 architecture: ss(obj, &["arch", "Architecture"]).unwrap_or(arch_selected),
566 url: ss(obj, &["url", "URL"]).unwrap_or_default(),
567 licenses: arrs(obj, &["licenses", "Licenses"]),
568 groups: arrs(obj, &["groups", "Groups"]),
569 provides: arrs(obj, &["provides", "Provides"]),
570 depends: arrs(obj, &["depends", "Depends"]),
571 opt_depends: arrs(obj, &["optdepends", "OptDepends"]),
572 required_by: arrs(obj, &["requiredby", "RequiredBy"]),
573 optional_for: vec![],
574 conflicts: arrs(obj, &["conflicts", "Conflicts"]),
575 replaces: arrs(obj, &["replaces", "Replaces"]),
576 download_size: u64_of(obj, &["compressed_size", "CompressedSize"]),
577 install_size: u64_of(obj, &["installed_size", "InstalledSize"]),
578 owner: ss(obj, &["packager", "Packager"]).unwrap_or_default(),
579 build_date: ss(obj, &["build_date", "BuildDate"]).unwrap_or_default(),
580 popularity: None,
581 out_of_date: None,
582 orphaned: false,
583 }
584 }
585 let v: serde_json::Value = serde_json::json!({
586 "pkg": {
587 "pkgver": "14",
588 "pkgdesc": "ripgrep fast search",
589 "arch": "x86_64",
590 "url": "https://example.com",
591 "licenses": ["MIT"],
592 "groups": [],
593 "provides": ["rg"],
594 "depends": ["pcre2"],
595 "optdepends": ["bash: completions"],
596 "requiredby": [],
597 "conflicts": [],
598 "replaces": [],
599 "compressed_size": 1024u64,
600 "installed_size": 2048u64,
601 "packager": "Arch Dev",
602 "build_date": "2024-01-01"
603 }
604 });
605 let item = crate::state::PackageItem {
606 name: "ripgrep".into(),
607 version: String::new(),
608 description: String::new(),
609 source: crate::state::Source::Official {
610 repo: "extra".into(),
611 arch: "x86_64".into(),
612 },
613 popularity: None,
614 out_of_date: None,
615 orphaned: false,
616 };
617 let d = parse_official_from_json(&v["pkg"], "extra".into(), "x86_64".into(), &item);
618 assert_eq!(d.repository, "extra");
619 assert_eq!(d.name, "ripgrep");
620 assert_eq!(d.version, "14");
621 assert_eq!(d.description, "ripgrep fast search");
622 assert_eq!(d.architecture, "x86_64");
623 assert_eq!(d.url, "https://example.com");
624 assert_eq!(d.download_size, Some(1024));
625 assert_eq!(d.install_size, Some(2048));
626 assert_eq!(d.owner, "Arch Dev");
627 assert_eq!(d.build_date, "2024-01-01");
628 }
629
630 #[test]
631 fn sources_details_parse_aur_json_defaults_and_popularity() {
643 fn parse_aur_from_json(
644 obj: &serde_json::Value,
645 item: &crate::state::PackageItem,
646 ) -> crate::state::PackageDetails {
647 use crate::util::{arrs, s};
648 let version0 = s(obj, "Version");
649 let description0 = s(obj, "Description");
650 let popularity0 = obj.get("Popularity").and_then(serde_json::Value::as_f64);
651 crate::state::PackageDetails {
652 repository: "AUR".into(),
653 name: item.name.clone(),
654 version: if version0.is_empty() {
655 item.version.clone()
656 } else {
657 version0
658 },
659 description: if description0.is_empty() {
660 item.description.clone()
661 } else {
662 description0
663 },
664 architecture: "any".into(),
665 url: s(obj, "URL"),
666 licenses: arrs(obj, &["License", "Licenses"]),
667 groups: arrs(obj, &["Groups"]),
668 provides: arrs(obj, &["Provides"]),
669 depends: arrs(obj, &["Depends"]),
670 opt_depends: arrs(obj, &["OptDepends"]),
671 required_by: vec![],
672 optional_for: vec![],
673 conflicts: arrs(obj, &["Conflicts"]),
674 replaces: arrs(obj, &["Replaces"]),
675 download_size: None,
676 install_size: None,
677 owner: s(obj, "Maintainer"),
678 build_date: crate::util::ts_to_date(
679 obj.get("LastModified").and_then(serde_json::Value::as_i64),
680 ),
681 popularity: popularity0,
682 out_of_date: None,
683 orphaned: false,
684 }
685 }
686 let obj: serde_json::Value = serde_json::json!({
687 "Version": "1.2.3",
688 "Description": "cool",
689 "Popularity": std::f64::consts::PI,
690 "URL": "https://aur.example/ripgrep"
691 });
692 let item = crate::state::PackageItem {
693 name: "ripgrep-git".into(),
694 version: String::new(),
695 description: String::new(),
696 source: crate::state::Source::Aur,
697 popularity: None,
698 out_of_date: None,
699 orphaned: false,
700 };
701 let d = parse_aur_from_json(&obj, &item);
702 assert_eq!(d.repository, "AUR");
703 assert_eq!(d.name, "ripgrep-git");
704 assert_eq!(d.version, "1.2.3");
705 assert_eq!(d.description, "cool");
706 assert_eq!(d.architecture, "any");
707 assert_eq!(d.url, "https://aur.example/ripgrep");
708 assert_eq!(d.popularity, Some(std::f64::consts::PI));
709 }
710
711 #[test]
712 fn sources_details_parse_aur_status_fields() {
723 use crate::util::s;
724 let obj: serde_json::Value = serde_json::json!({
725 "Version": "1.0.0",
726 "Description": "test package",
727 "OutOfDate": 1_704_067_200_i64, "Maintainer": "" });
730 let _item = crate::state::PackageItem {
731 name: "test-pkg".into(),
732 version: String::new(),
733 description: String::new(),
734 source: crate::state::Source::Aur,
735 popularity: None,
736 out_of_date: None,
737 orphaned: false,
738 };
739 let out_of_date = obj
741 .get("OutOfDate")
742 .and_then(serde_json::Value::as_i64)
743 .and_then(|ts| u64::try_from(ts).ok())
744 .filter(|&ts| ts > 0);
745 let maintainer = s(&obj, "Maintainer");
747 let orphaned = maintainer.is_empty();
748
749 assert_eq!(out_of_date, Some(1_704_067_200));
750 assert!(orphaned);
751 }
752
753 #[test]
754 fn sources_details_parse_aur_with_maintainer() {
765 use crate::util::s;
766 let obj: serde_json::Value = serde_json::json!({
767 "Version": "1.0.0",
768 "Description": "test package",
769 "Maintainer": "someuser"
770 });
771 let maintainer = s(&obj, "Maintainer");
772 let orphaned = maintainer.is_empty();
773
774 assert!(!orphaned);
775 assert_eq!(maintainer, "someuser");
776 }
777}