pacsea/sources/
details.rs

1//! Package details fetching from official repositories and AUR.
2
3use serde_json::Value;
4
5use crate::state::{PackageDetails, PackageItem, Source};
6use crate::util::{arrs, s, ss, u64_of};
7
8/// Result type alias for package details fetching operations.
9type Result<T> = super::Result<T>;
10
11/// Split a whitespace-separated field to Vec<String>, treating "None"/missing as empty.
12///
13/// Inputs:
14/// - `s`: Optional string field from pacman output
15///
16/// Output:
17/// - Vector of tokens, or empty when field is missing or "None".
18fn 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
25/// Process a continuation line (indented line) for a given key in the map.
26///
27/// Inputs:
28/// - `map`: Map to update
29/// - `key`: Current key being continued
30/// - `line`: Continuation line content
31///
32/// Output:
33/// - Updates the map entry for the key with the continuation content.
34///
35/// Details:
36/// - Handles special formatting for "Optional Deps" (newline-separated) vs other fields (space-separated).
37fn 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
51/// Run `pacman -Si` command and return the output text.
52///
53/// Inputs:
54/// - `repo`: Preferred repository prefix (may be empty to let pacman resolve)
55/// - `name`: Package name
56///
57/// Output:
58/// - `Ok(String)` with command output on success; `Err` if command fails.
59///
60/// Details:
61/// - Sets locale to C for consistent output parsing.
62fn 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
79/// Parse pacman output text into a key-value map.
80///
81/// Inputs:
82/// - `text`: Raw output from `pacman -Si`
83///
84/// Output:
85/// - `BTreeMap<String, String>` with parsed key-value pairs.
86///
87/// Details:
88/// - Handles continuation lines (indented lines) that extend previous keys.
89/// - Skips empty lines and processes key:value pairs.
90fn 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
111/// Extracted fields from pacman output parsing.
112///
113/// Groups related fields together to reduce data flow complexity.
114struct ParsedFields {
115    /// Package licenses.
116    licenses: Vec<String>,
117    /// Package groups.
118    groups: Vec<String>,
119    /// Packages provided by this package.
120    provides: Vec<String>,
121    /// Required dependencies.
122    depends: Vec<String>,
123    /// Optional dependencies.
124    opt_depends: Vec<String>,
125    /// Packages that require this package.
126    required_by: Vec<String>,
127    /// Packages that optionally depend on this package.
128    optional_for: Vec<String>,
129    /// Packages that conflict with this package.
130    conflicts: Vec<String>,
131    /// Packages that this package replaces.
132    replaces: Vec<String>,
133    /// Package description.
134    description: String,
135    /// Target architecture.
136    architecture: String,
137    /// Download size in bytes.
138    download_size: Option<u64>,
139    /// Installed size in bytes.
140    install_size: Option<u64>,
141}
142
143/// Extract all dependency and metadata fields from the parsed map.
144///
145/// Inputs:
146/// - `map`: Parsed key-value map from pacman output
147///
148/// Output:
149/// - `ParsedFields` struct containing all extracted fields.
150///
151/// Details:
152/// - Handles multiple field name variants (e.g., "Licenses" vs "License").
153/// - Parses optional dependencies with special formatting.
154fn 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
190/// Fill missing description and architecture from the official index if needed.
191///
192/// Inputs:
193/// - `name`: Package name to search for
194/// - `description`: Description string to fill if empty (mutable)
195/// - `architecture`: Architecture string to fill if empty (mutable)
196///
197/// Output:
198/// - Updates description and architecture in place if found in index.
199///
200/// Details:
201/// - Searches official repositories for matching package name.
202/// - Only updates fields that are currently empty.
203fn 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        // Use normal substring search for this helper (not fuzzy)
207        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
227/// Build `PackageDetails` from parsed map and extracted fields.
228///
229/// Inputs:
230/// - `repo`: Repository name (fallback if not in map)
231/// - `name`: Package name (fallback if not in map)
232/// - `map`: Parsed key-value map
233/// - `fields`: Extracted fields struct
234///
235/// Output:
236/// - `PackageDetails` struct with all fields populated.
237fn 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
272/// Run `pacman -Si` for a package, parsing its key-value output into `PackageDetails`.
273///
274/// Inputs:
275/// - `repo`: Preferred repository prefix (may be empty to let pacman resolve)
276/// - `name`: Package name
277///
278/// Output:
279/// - `Ok(PackageDetails)` on success; `Err` if command fails or parse errors occur.
280fn 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
288/// Parse a pacman human-readable size like "1.5 MiB" into bytes.
289///
290/// Inputs:
291/// - `s`: Size string containing a number and unit
292///
293/// Output:
294/// - `Some(bytes)` when parsed; `None` for invalid strings. Accepts B, KiB, MiB, GiB, TiB, PiB.
295fn parse_size_bytes(s: &str) -> Option<u64> {
296    // Maximum f64 value that fits in u64 (2^64 - 1, but f64 can represent up to 2^53 exactly)
297    // For values beyond 2^53, we check if they exceed u64::MAX by comparing with a threshold
298    const MAX_U64_AS_F64: f64 = 18_446_744_073_709_551_615.0; // u64::MAX as approximate f64
299    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    // Check bounds: negative values are invalid
311    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    /// What: Ensure `parse_size_bytes` converts human-readable sizes into raw bytes.
326    ///
327    /// Inputs:
328    /// - Representative strings covering `B`, `KiB`, `MiB`, `GiB`, `TiB`, and an invalid token.
329    ///
330    /// Output:
331    /// - Returns the correct byte counts for valid inputs and `None` for malformed strings.
332    ///
333    /// Details:
334    /// - Covers the unit matching branch and protects against accidental unit regression.
335    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
351/// What: Fetch package details for either official repositories or AUR, based on the item's source.
352///
353/// Inputs:
354/// - `item`: Package to fetch details for.
355///
356/// Output:
357/// - `Ok(PackageDetails)` on success; `Err` if retrieval or parsing fails.
358///
359/// # Errors
360/// - Returns `Err` when network request fails (curl execution error)
361/// - Returns `Err` when package details cannot be fetched from official repositories or AUR
362/// - Returns `Err` when response parsing fails (invalid JSON or missing fields)
363///
364pub 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
371/// Fetch AUR package details via the AUR RPC API.
372///
373/// Inputs: `item` with `Source::Aur`.
374///
375/// Output: Parsed `PackageDetails` populated with AUR fields or an error.
376pub 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    // Extract OutOfDate timestamp (i64 or null)
393    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    // Extract Maintainer and determine if orphaned (empty or null means orphaned)
399    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
439/// Fetch official repository package details via pacman JSON endpoints.
440///
441/// Inputs:
442/// - `repo`: Repository name to prefer when multiple are available.
443/// - `arch`: Architecture string to prefer.
444/// - `item`: Package to fetch.
445///
446/// Output: `Ok(PackageDetails)` with repository fields filled; `Err` on network/parse failure.
447pub 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    // use super::*;
538
539    #[test]
540    /// What: Parse official repository JSON into `PackageDetails`, ensuring defaults mirror the packages API.
541    ///
542    /// Inputs:
543    /// - Minimal JSON payload containing version metadata, sizes, and packager fields.
544    /// - Sample `PackageItem` representing the queried package.
545    ///
546    /// Output:
547    /// - Populated `PackageDetails` carries expected strings and parsed size values.
548    ///
549    /// Details:
550    /// - Exercises helper extraction functions (`ss`, `arrs`, `u64_of`) and fallback behaviour when fields are missing.
551    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    /// What: Parse AUR RPC JSON into `PackageDetails`, handling optional fields and popularity.
632    ///
633    /// Inputs:
634    /// - Minimal AUR JSON document providing version, description, popularity, and URL.
635    /// - Seed `PackageItem` used to supply fallback values.
636    ///
637    /// Output:
638    /// - Resulting `PackageDetails` retains `AUR` repository label, uses JSON data when present, and sets popularity.
639    ///
640    /// Details:
641    /// - Validates interplay between helper functions and fallback assignments for missing fields.
642    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    /// What: Parse AUR RPC JSON with `OutOfDate` and orphaned status fields.
713    ///
714    /// Inputs:
715    /// - AUR JSON document with `OutOfDate` timestamp and empty Maintainer (orphaned).
716    ///
717    /// Output:
718    /// - Resulting `PackageDetails` correctly sets `out_of_date` and orphaned flags.
719    ///
720    /// Details:
721    /// - Validates that `OutOfDate` timestamp is extracted and orphaned status is determined from empty Maintainer.
722    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, // 2024-01-01 timestamp
728            "Maintainer": "" // Empty means orphaned
729        });
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        // Extract OutOfDate timestamp (i64 or null)
740        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        // Extract Maintainer and determine if orphaned (empty or null means orphaned)
746        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    /// What: Parse AUR RPC JSON with non-orphaned package (has maintainer).
755    ///
756    /// Inputs:
757    /// - AUR JSON document with Maintainer field set to a username.
758    ///
759    /// Output:
760    /// - Resulting package is not marked as orphaned.
761    ///
762    /// Details:
763    /// - Validates that packages with a maintainer are not marked as orphaned.
764    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}