pacsea/sources/
search.rs

1//! AUR search query execution and result parsing.
2
3use crate::state::{PackageItem, Source};
4use crate::util::{percent_encode, s};
5
6/// What: Fetch search results from AUR and return items along with any error messages.
7///
8/// Input:
9/// - `query` raw query string to search
10///
11/// Output:
12/// - Tuple `(items, errors)` where `items` are `PackageItem`s found and `errors` are human-readable messages for partial failures
13///
14/// Details:
15/// - Percent-encodes the query and calls the AUR RPC v5 search endpoint in a blocking task, maps up to 200 results into `PackageItem`s, and collects any network/parse failures as error strings.
16pub async fn fetch_all_with_errors(query: String) -> (Vec<PackageItem>, Vec<String>) {
17    let q = percent_encode(query.trim());
18    let aur_url = format!("https://aur.archlinux.org/rpc/v5/search?by=name&arg={q}");
19
20    let mut items: Vec<PackageItem> = Vec::new();
21
22    let ret = tokio::task::spawn_blocking(move || crate::util::curl::curl_json(&aur_url)).await;
23    let mut errors = Vec::new();
24    match ret {
25        Ok(Ok(resp)) => {
26            if let Some(arr) = resp.get("results").and_then(|v| v.as_array()) {
27                for pkg in arr.iter().take(200) {
28                    let name = s(pkg, "Name");
29                    let version = s(pkg, "Version");
30                    let description = s(pkg, "Description");
31                    let popularity = pkg.get("Popularity").and_then(serde_json::Value::as_f64);
32                    if name.is_empty() {
33                        continue;
34                    }
35                    // Extract OutOfDate timestamp (i64 or null)
36                    let out_of_date = pkg
37                        .get("OutOfDate")
38                        .and_then(serde_json::Value::as_i64)
39                        .and_then(|ts| u64::try_from(ts).ok())
40                        .filter(|&ts| ts > 0);
41                    // Extract Maintainer and determine if orphaned (empty or null means orphaned)
42                    let maintainer = s(pkg, "Maintainer");
43                    let orphaned = maintainer.is_empty();
44                    items.push(PackageItem {
45                        name,
46                        version,
47                        description,
48                        source: Source::Aur,
49                        popularity,
50                        out_of_date,
51                        orphaned,
52                    });
53                }
54            }
55        }
56        Ok(Err(e)) => errors.push(format!("AUR search unavailable: {e}")),
57        Err(e) => errors.push(format!("AUR search failed: {e}")),
58    }
59
60    (items, errors)
61}
62
63#[cfg(not(target_os = "windows"))]
64#[cfg(test)]
65mod tests {
66    #[tokio::test]
67    #[allow(clippy::await_holding_lock, clippy::all)] // Shell variable syntax ${VAR:-default} in raw strings - false positive
68    async fn search_returns_items_on_success_and_error_on_failure() {
69        let _guard = crate::global_test_mutex_lock();
70        // Shim PATH curl to return a small JSON for success call, then fail on a second invocation
71        let old_path = std::env::var("PATH").unwrap_or_default();
72        let mut root = std::env::temp_dir();
73        root.push(format!(
74            "pacsea_fake_curl_search_{}_{}",
75            std::process::id(),
76            std::time::SystemTime::now()
77                .duration_since(std::time::UNIX_EPOCH)
78                .expect("System time is before UNIX epoch")
79                .as_nanos()
80        ));
81        std::fs::create_dir_all(&root).expect("failed to create test root directory");
82        let mut bin = root.clone();
83        bin.push("bin");
84        std::fs::create_dir_all(&bin).expect("failed to create test bin directory");
85        let mut curl = bin.clone();
86        curl.push("curl");
87        // Shell variable syntax ${VAR:-default} - not a Rust format string
88        #[allow(clippy::all, clippy::literal_string_with_formatting_args)]
89        let script = r#"#!/bin/sh
90set -e
91state_dir="${PACSEA_FAKE_STATE_DIR:-.}"
92if [ ! -f "$state_dir/pacsea_search_called" ]; then
93  : > "$state_dir/pacsea_search_called"
94  echo '{"results":[{"Name":"yay","Version":"12","Description":"AUR helper","Popularity":3.14,"OutOfDate":null,"Maintainer":"someuser"}]}'
95else
96  exit 22
97fi
98"#;
99        std::fs::write(&curl, script.as_bytes()).expect("failed to write test curl script");
100        #[cfg(unix)]
101        {
102            use std::os::unix::fs::PermissionsExt;
103            let mut perm = std::fs::metadata(&curl)
104                .expect("failed to read test curl script metadata")
105                .permissions();
106            perm.set_mode(0o755);
107            std::fs::set_permissions(&curl, perm)
108                .expect("failed to set test curl script permissions");
109        }
110        let new_path = format!("{}:{old_path}", bin.to_string_lossy());
111        unsafe {
112            std::env::set_var("PATH", &new_path);
113            std::env::set_var("PACSEA_FAKE_STATE_DIR", bin.to_string_lossy().to_string());
114            // Enable curl PATH lookup override so our fake curl is used instead of /usr/bin/curl
115            std::env::set_var("PACSEA_CURL_PATH", "1");
116        }
117        // Ensure PATH is set before executing commands
118        std::thread::sleep(std::time::Duration::from_millis(10));
119
120        let (items, errs) = super::fetch_all_with_errors("yay".into()).await;
121        assert_eq!(
122            items.len(),
123            1,
124            "Expected 1 item, got {} items. Errors: {:?}",
125            items.len(),
126            errs
127        );
128        assert!(errs.is_empty());
129        // Verify status fields are parsed correctly
130        assert_eq!(items[0].out_of_date, None);
131        assert!(!items[0].orphaned);
132
133        // Call again to exercise error path
134        let (_items2, errs2) = super::fetch_all_with_errors("yay".into()).await;
135        assert!(!errs2.is_empty());
136
137        unsafe {
138            std::env::set_var("PATH", &old_path);
139            std::env::remove_var("PACSEA_CURL_PATH");
140        }
141        let _ = std::fs::remove_dir_all(&root);
142    }
143}