pacsea/index/
enrich.rs

1use super::{idx, save_to_disk};
2
3/// What: Request enrichment (`pacman -Si`) for a set of package `names` in the background,
4/// merge fields into the index, persist, and notify.
5///
6/// Inputs:
7/// - `persist_path`: Path to write the updated index JSON
8/// - `notify_tx`: Channel to notify the UI after enrichment/persist
9/// - `names`: Package names to enrich
10///
11/// Output:
12/// - Spawns a task that enriches and persists the index; sends a unit notification on completion.
13///
14/// Details:
15/// - Only non-empty results are applied; fields prefer non-empty values from `-Si` output and leave
16///   existing values untouched when omitted.
17pub fn request_enrich_for(
18    persist_path: std::path::PathBuf,
19    notify_tx: tokio::sync::mpsc::UnboundedSender<()>,
20    names: Vec<String>,
21) {
22    tokio::spawn(async move {
23        // Deduplicate names
24        use std::collections::HashSet;
25        const BATCH: usize = 100;
26        let set: HashSet<String> = names.into_iter().collect();
27        if set.is_empty() {
28            return;
29        }
30        // Batch -Si queries
31        let mut desc_map: std::collections::HashMap<String, (String, String, String, String)> =
32            std::collections::HashMap::new(); // name -> (desc, arch, repo, version)
33        let all: Vec<String> = set.into_iter().collect();
34        for chunk in all.chunks(BATCH) {
35            let args_owned: Vec<String> = std::iter::once("-Si".to_string())
36                .chain(chunk.iter().cloned())
37                .collect();
38            let block = tokio::task::spawn_blocking(move || {
39                let args_ref: Vec<&str> = args_owned.iter().map(String::as_str).collect();
40                crate::util::pacman::run_pacman(&args_ref)
41            })
42            .await;
43            let Ok(Ok(out)) = block else { continue };
44            // Parse blocks
45            let mut cur_name: Option<String> = None;
46            let mut cur_desc: Option<String> = None;
47            let mut cur_arch: Option<String> = None;
48            let mut cur_repo: Option<String> = None;
49            let mut cur_ver: Option<String> = None;
50            #[allow(clippy::collection_is_never_read)]
51            let mut _cur_packager: Option<String> = None;
52            for line in out.lines().chain(std::iter::once("")) {
53                let line = line.trim_end();
54                if line.is_empty() {
55                    if let Some(n) = cur_name.take() {
56                        let d = cur_desc.take().unwrap_or_default();
57                        let a = cur_arch.take().unwrap_or_default();
58                        let r = cur_repo.take().unwrap_or_default();
59                        let v = cur_ver.take().unwrap_or_default();
60
61                        desc_map.insert(n, (d, a, r, v));
62                    }
63                    continue;
64                }
65                if let Some((k, v)) = line.split_once(':') {
66                    let key = k.trim();
67                    let val = v.trim();
68                    match key {
69                        "Name" => cur_name = Some(val.to_string()),
70                        "Description" => cur_desc = Some(val.to_string()),
71                        "Architecture" => cur_arch = Some(val.to_string()),
72                        "Repository" => cur_repo = Some(val.to_string()),
73                        "Packager" => _cur_packager = Some(val.to_string()),
74                        "Version" => cur_ver = Some(val.to_string()),
75                        _ => {}
76                    }
77                }
78            }
79        }
80        if desc_map.is_empty() {
81            return;
82        }
83        // Update index entries
84        if let Ok(mut g) = idx().write() {
85            for p in &mut g.pkgs {
86                if let Some((d, a, r, v)) = desc_map.get(&p.name) {
87                    if p.description.is_empty() {
88                        p.description = d.clone();
89                    }
90                    if !a.is_empty() {
91                        p.arch = a.clone();
92                    }
93                    if !r.is_empty() {
94                        p.repo = r.clone();
95                    }
96                    if !v.is_empty() {
97                        p.version = v.clone();
98                    }
99                }
100            }
101        }
102        save_to_disk(&persist_path);
103        let _ = notify_tx.send(());
104    });
105}
106
107#[cfg(test)]
108mod tests {
109    #[tokio::test]
110    /// What: Skip enrichment when no package names are provided.
111    ///
112    /// Inputs:
113    /// - Invoke `request_enrich_for` with an empty names vector.
114    ///
115    /// Output:
116    /// - No notification received on the channel within the timeout.
117    ///
118    /// Details:
119    /// - Guards against spawning unnecessary work for empty requests.
120    async fn index_enrich_noop_on_empty_names() {
121        use std::path::PathBuf;
122        let mut path: PathBuf = std::env::temp_dir();
123        path.push(format!(
124            "pacsea_idx_empty_enrich_{}_{}.json",
125            std::process::id(),
126            std::time::SystemTime::now()
127                .duration_since(std::time::UNIX_EPOCH)
128                .expect("System time is before UNIX epoch")
129                .as_nanos()
130        ));
131        let idx_json = serde_json::json!({ "pkgs": [] });
132        std::fs::write(
133            &path,
134            serde_json::to_string(&idx_json).expect("Failed to serialize test index JSON"),
135        )
136        .expect("Failed to write test index file");
137        crate::index::load_from_disk(&path);
138
139        let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
140        super::request_enrich_for(path.clone(), notify_tx, Vec::new());
141        let none = tokio::time::timeout(std::time::Duration::from_millis(200), notify_rx.recv())
142            .await
143            .ok()
144            .flatten();
145        assert!(none.is_none());
146        let _ = std::fs::remove_file(&path);
147    }
148
149    #[cfg(not(target_os = "windows"))]
150    #[tokio::test]
151    #[allow(clippy::await_holding_lock)]
152    /// What: Update fields from `pacman -Si` output and notify observers.
153    ///
154    /// Inputs:
155    /// - Seed the index with minimal entries and script a fake `pacman -Si` response.
156    ///
157    /// Output:
158    /// - Index entries updated with description, repo, arch, version, and a notification emitted.
159    ///
160    /// Details:
161    /// - Demonstrates deduplication of requested names and background task execution.
162    async fn enrich_updates_fields_and_notifies() {
163        let _guard = crate::global_test_mutex_lock();
164        // Seed index with minimal entries
165        if let Ok(mut g) = crate::index::idx().write() {
166            g.pkgs = vec![crate::index::OfficialPkg {
167                name: "foo".to_string(),
168                repo: String::new(),
169                arch: String::new(),
170                version: String::new(),
171                description: String::new(),
172            }];
173        }
174        // Fake pacman -Si output via PATH shim
175        let old_path = std::env::var("PATH").unwrap_or_default();
176        let mut root = std::env::temp_dir();
177        root.push(format!(
178            "pacsea_fake_pacman_si_{}_{}",
179            std::process::id(),
180            std::time::SystemTime::now()
181                .duration_since(std::time::UNIX_EPOCH)
182                .expect("System time is before UNIX epoch")
183                .as_nanos()
184        ));
185        std::fs::create_dir_all(&root).expect("Failed to create test root directory");
186        let mut bin = root.clone();
187        bin.push("bin");
188        std::fs::create_dir_all(&bin).expect("Failed to create test bin directory");
189        let mut script = bin.clone();
190        script.push("pacman");
191        let body = r#"#!/usr/bin/env bash
192set -e
193if [[ "$1" == "-Si" ]]; then
194  # Print two blocks, one for foo, one unrelated
195  cat <<EOF
196Name            : foo
197Version         : 1.2.3
198Architecture    : x86_64
199Repository      : core
200Description     : hello
201
202Name            : other
203Version         : 9.9.9
204Architecture    : any
205Repository      : extra
206Description     : nope
207EOF
208  exit 0
209fi
210exit 0
211"#;
212        std::fs::write(&script, body).expect("Failed to write test pacman script");
213        #[cfg(unix)]
214        {
215            use std::os::unix::fs::PermissionsExt;
216            let mut perm = std::fs::metadata(&script)
217                .expect("Failed to read test pacman script metadata")
218                .permissions();
219            perm.set_mode(0o755);
220            std::fs::set_permissions(&script, perm)
221                .expect("Failed to set test pacman script permissions");
222        }
223        let new_path = format!("{}:{old_path}", bin.to_string_lossy());
224        unsafe { std::env::set_var("PATH", &new_path) };
225
226        // Temp file for persistence
227        let mut path: std::path::PathBuf = std::env::temp_dir();
228        path.push("pacsea_enrich_test.json");
229        crate::index::save_to_disk(&path);
230
231        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<()>();
232        super::request_enrich_for(path.clone(), tx, vec!["foo".into(), "foo".into()]);
233        // Wait for notify
234        let notified = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
235            .await
236            .ok()
237            .flatten()
238            .is_some();
239        assert!(notified);
240
241        // Check that fields got updated for foo
242        let all = crate::index::all_official();
243        let pkg = all
244            .iter()
245            .find(|p| p.name == "foo")
246            .expect("package 'foo' should exist in test data");
247        assert_eq!(pkg.version, "1.2.3");
248        assert_eq!(pkg.description, "hello");
249        match &pkg.source {
250            crate::state::Source::Official { repo, arch } => {
251                assert_eq!(repo, "core");
252                assert_eq!(arch, "x86_64");
253            }
254            crate::state::Source::Aur => panic!("expected official"),
255        }
256
257        // Cleanup
258        unsafe { std::env::set_var("PATH", &old_path) };
259        let _ = std::fs::remove_file(&path);
260        let _ = std::fs::remove_dir_all(&root);
261    }
262}