Skip to main content

pacsea/index/
enrich.rs

1use super::{OfficialPkg, idx, save_to_disk};
2
3/// What: Decide whether an official index row may still gain metadata from `pacman -Si`.
4///
5/// Inputs:
6/// - `p`: Official package row (typically seeded from `pacman -Sl`, which omits description and
7///   architecture).
8///
9/// Output:
10/// - `true` when a `-Si` round-trip could still change persisted fields.
11///
12/// Details:
13/// - Used to skip redundant enrichment work and to avoid the index-notify → search → enrich loop
14///   that was re-saving the full index every second when visible rows were already filled.
15const fn official_entry_needs_si_fill(p: &OfficialPkg) -> bool {
16    p.description.is_empty() || p.arch.is_empty()
17}
18
19/// What: Request enrichment (`pacman -Si`) for a set of package `names` in the background,
20/// merge fields into the index, persist, and notify.
21///
22/// Inputs:
23/// - `persist_path`: Path to write the updated index JSON
24/// - `notify_tx`: Channel to notify the UI after enrichment/persist
25/// - `names`: Package names to enrich
26///
27/// Output:
28/// - Spawns a task that enriches and persists the index; sends a unit notification on completion.
29///
30/// Details:
31/// - Only non-empty results are applied; fields prefer non-empty values from `-Si` output and leave
32///   existing values untouched when omitted.
33/// - Skips `-Si` entirely when every requested package already has description and architecture, and
34///   skips disk writes / notifications when no row actually changes (prevents feedback loops with
35///   `handle_index_notification`).
36pub fn request_enrich_for(
37    persist_path: std::path::PathBuf,
38    notify_tx: tokio::sync::mpsc::UnboundedSender<()>,
39    names: Vec<String>,
40) {
41    tokio::spawn(async move {
42        // Deduplicate names
43        use std::collections::HashSet;
44        const BATCH: usize = 100;
45        let set: HashSet<String> = names.into_iter().collect();
46        if set.is_empty() {
47            return;
48        }
49        let names_to_fetch: Vec<String> = {
50            let Ok(guard) = idx().read() else {
51                return;
52            };
53            set.into_iter()
54                .filter(|n| {
55                    guard
56                        .name_to_idx
57                        .get(&n.to_lowercase())
58                        .is_some_and(|i| official_entry_needs_si_fill(&guard.pkgs[*i]))
59                })
60                .collect()
61        };
62        if names_to_fetch.is_empty() {
63            return;
64        }
65        // Batch -Si queries
66        let mut desc_map: std::collections::HashMap<String, (String, String, String, String)> =
67            std::collections::HashMap::new(); // name -> (desc, arch, repo, version)
68        for chunk in names_to_fetch.chunks(BATCH) {
69            let args_owned: Vec<String> = std::iter::once("-Si".to_string())
70                .chain(chunk.iter().cloned())
71                .collect();
72            let block = tokio::task::spawn_blocking(move || {
73                let args_ref: Vec<&str> = args_owned.iter().map(String::as_str).collect();
74                crate::util::pacman::run_pacman(&args_ref)
75            })
76            .await;
77            let Ok(Ok(out)) = block else { continue };
78            // Parse blocks
79            let mut cur_name: Option<String> = None;
80            let mut cur_desc: Option<String> = None;
81            let mut cur_arch: Option<String> = None;
82            let mut cur_repo: Option<String> = None;
83            let mut cur_ver: Option<String> = None;
84            #[allow(clippy::collection_is_never_read)]
85            let mut _cur_packager: Option<String> = None;
86            for line in out.lines().chain(std::iter::once("")) {
87                let line = line.trim_end();
88                if line.is_empty() {
89                    if let Some(n) = cur_name.take() {
90                        let d = cur_desc.take().unwrap_or_default();
91                        let a = cur_arch.take().unwrap_or_default();
92                        let r = cur_repo.take().unwrap_or_default();
93                        let v = cur_ver.take().unwrap_or_default();
94
95                        desc_map.insert(n, (d, a, r, v));
96                    }
97                    continue;
98                }
99                if let Some((k, v)) = line.split_once(':') {
100                    let key = k.trim();
101                    let val = v.trim();
102                    match key {
103                        "Name" => cur_name = Some(val.to_string()),
104                        "Description" => cur_desc = Some(val.to_string()),
105                        "Architecture" => cur_arch = Some(val.to_string()),
106                        "Repository" => cur_repo = Some(val.to_string()),
107                        "Packager" => _cur_packager = Some(val.to_string()),
108                        "Version" => cur_ver = Some(val.to_string()),
109                        _ => {}
110                    }
111                }
112            }
113        }
114        if desc_map.is_empty() {
115            return;
116        }
117        // Update index entries
118        let mut index_dirty = false;
119        if let Ok(mut g) = idx().write() {
120            for p in &mut g.pkgs {
121                if let Some((d, a, r, v)) = desc_map.get(&p.name) {
122                    if p.description.is_empty() && !d.is_empty() {
123                        p.description.clone_from(d);
124                        index_dirty = true;
125                    }
126                    if !a.is_empty() && p.arch != *a {
127                        p.arch.clone_from(a);
128                        index_dirty = true;
129                    }
130                    if !r.is_empty() && p.repo != *r {
131                        p.repo.clone_from(r);
132                        index_dirty = true;
133                    }
134                    if !v.is_empty() && p.version != *v {
135                        p.version.clone_from(v);
136                        index_dirty = true;
137                    }
138                }
139            }
140        }
141        if index_dirty {
142            save_to_disk(&persist_path);
143            let _ = notify_tx.send(());
144        }
145    });
146}
147
148#[cfg(test)]
149mod tests {
150    #[tokio::test]
151    /// What: Skip enrichment when no package names are provided.
152    ///
153    /// Inputs:
154    /// - Invoke `request_enrich_for` with an empty names vector.
155    ///
156    /// Output:
157    /// - No notification received on the channel within the timeout.
158    ///
159    /// Details:
160    /// - Guards against spawning unnecessary work for empty requests.
161    async fn index_enrich_noop_on_empty_names() {
162        use std::path::PathBuf;
163        let mut path: PathBuf = std::env::temp_dir();
164        path.push(format!(
165            "pacsea_idx_empty_enrich_{}_{}.json",
166            std::process::id(),
167            std::time::SystemTime::now()
168                .duration_since(std::time::UNIX_EPOCH)
169                .expect("System time is before UNIX epoch")
170                .as_nanos()
171        ));
172        let idx_json = serde_json::json!({ "pkgs": [] });
173        std::fs::write(
174            &path,
175            serde_json::to_string(&idx_json).expect("Failed to serialize test index JSON"),
176        )
177        .expect("Failed to write test index file");
178        crate::index::load_from_disk(&path);
179
180        let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
181        super::request_enrich_for(path.clone(), notify_tx, Vec::new());
182        let none = tokio::time::timeout(std::time::Duration::from_millis(200), notify_rx.recv())
183            .await
184            .ok()
185            .flatten();
186        assert!(none.is_none());
187        let _ = std::fs::remove_file(&path);
188    }
189
190    #[tokio::test]
191    #[allow(clippy::await_holding_lock)]
192    /// What: Skip enrichment work when requested rows already carry `-Si`-backed fields.
193    ///
194    /// Inputs:
195    /// - Seed the global index with a package that already has non-empty description and arch.
196    ///
197    /// Output:
198    /// - No notification is sent within the wait window (no `pacman -Si`, no persist loop).
199    ///
200    /// Details:
201    /// - Regression guard for the index-notify → search → enrich feedback loop.
202    async fn enrich_skips_when_rows_already_filled() {
203        let _guard = crate::global_test_mutex_lock();
204        if let Ok(mut g) = crate::index::idx().write() {
205            g.pkgs = vec![crate::index::OfficialPkg {
206                name: "foo".to_string(),
207                repo: "core".to_string(),
208                arch: "x86_64".to_string(),
209                version: "1.0.0".to_string(),
210                description: "already filled".to_string(),
211            }];
212            g.rebuild_name_index();
213        }
214        let mut path = std::env::temp_dir();
215        path.push(format!(
216            "pacsea_enrich_skip_{}_{}.json",
217            std::process::id(),
218            std::time::SystemTime::now()
219                .duration_since(std::time::UNIX_EPOCH)
220                .expect("System time is before UNIX epoch")
221                .as_nanos()
222        ));
223        let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
224        super::request_enrich_for(path.clone(), notify_tx, vec!["foo".into()]);
225        let none = tokio::time::timeout(std::time::Duration::from_millis(400), notify_rx.recv())
226            .await
227            .ok()
228            .flatten();
229        assert!(none.is_none());
230        let _ = std::fs::remove_file(&path);
231    }
232
233    #[cfg(not(target_os = "windows"))]
234    #[tokio::test]
235    #[allow(clippy::await_holding_lock)]
236    /// What: Update fields from `pacman -Si` output and notify observers.
237    ///
238    /// Inputs:
239    /// - Seed the index with minimal entries and script a fake `pacman -Si` response.
240    ///
241    /// Output:
242    /// - Index entries updated with description, repo, arch, version, and a notification emitted.
243    ///
244    /// Details:
245    /// - Demonstrates deduplication of requested names and background task execution.
246    async fn enrich_updates_fields_and_notifies() {
247        let _guard = crate::global_test_mutex_lock();
248        // Seed index with minimal entries
249        if let Ok(mut g) = crate::index::idx().write() {
250            g.pkgs = vec![crate::index::OfficialPkg {
251                name: "foo".to_string(),
252                repo: String::new(),
253                arch: String::new(),
254                version: String::new(),
255                description: String::new(),
256            }];
257            g.rebuild_name_index();
258        }
259        // Fake pacman -Si output via PATH shim
260        let old_path = std::env::var("PATH").unwrap_or_default();
261        let mut root = std::env::temp_dir();
262        root.push(format!(
263            "pacsea_fake_pacman_si_{}_{}",
264            std::process::id(),
265            std::time::SystemTime::now()
266                .duration_since(std::time::UNIX_EPOCH)
267                .expect("System time is before UNIX epoch")
268                .as_nanos()
269        ));
270        std::fs::create_dir_all(&root).expect("Failed to create test root directory");
271        let mut bin = root.clone();
272        bin.push("bin");
273        std::fs::create_dir_all(&bin).expect("Failed to create test bin directory");
274        let mut script = bin.clone();
275        script.push("pacman");
276        let body = r#"#!/usr/bin/env bash
277set -e
278if [[ "$1" == "-Si" ]]; then
279  # Print two blocks, one for foo, one unrelated
280  cat <<EOF
281Name            : foo
282Version         : 1.2.3
283Architecture    : x86_64
284Repository      : core
285Description     : hello
286
287Name            : other
288Version         : 9.9.9
289Architecture    : any
290Repository      : extra
291Description     : nope
292EOF
293  exit 0
294fi
295exit 0
296"#;
297        std::fs::write(&script, body).expect("Failed to write test pacman script");
298        #[cfg(unix)]
299        {
300            use std::os::unix::fs::PermissionsExt;
301            let mut perm = std::fs::metadata(&script)
302                .expect("Failed to read test pacman script metadata")
303                .permissions();
304            perm.set_mode(0o755);
305            std::fs::set_permissions(&script, perm)
306                .expect("Failed to set test pacman script permissions");
307        }
308        let new_path = format!("{}:{old_path}", bin.to_string_lossy());
309        unsafe { std::env::set_var("PATH", &new_path) };
310
311        // Temp file for persistence
312        let mut path: std::path::PathBuf = std::env::temp_dir();
313        path.push("pacsea_enrich_test.json");
314        crate::index::save_to_disk(&path);
315
316        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<()>();
317        super::request_enrich_for(path.clone(), tx, vec!["foo".into(), "foo".into()]);
318        // Wait for notify
319        let notified = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv())
320            .await
321            .ok()
322            .flatten()
323            .is_some();
324        assert!(notified);
325
326        // Check that fields got updated for foo
327        let all = crate::index::all_official();
328        let pkg = all
329            .iter()
330            .find(|p| p.name == "foo")
331            .expect("package 'foo' should exist in test data");
332        assert_eq!(pkg.version, "1.2.3");
333        assert_eq!(pkg.description, "hello");
334        match &pkg.source {
335            crate::state::Source::Official { repo, arch } => {
336                assert_eq!(repo, "core");
337                assert_eq!(arch, "x86_64");
338            }
339            crate::state::Source::Aur => panic!("expected official"),
340        }
341
342        // Cleanup
343        unsafe { std::env::set_var("PATH", &old_path) };
344        let _ = std::fs::remove_file(&path);
345        let _ = std::fs::remove_dir_all(&root);
346    }
347}