pacsea/index/
update.rs

1#[cfg(not(target_os = "windows"))]
2use super::fetch::fetch_official_pkg_names;
3#[cfg(not(target_os = "windows"))]
4use super::{OfficialPkg, idx, save_to_disk};
5
6/// What: Spawn a background task to refresh the official index and notify on changes.
7///
8/// Inputs:
9/// - `persist_path`: File path to persist the updated index JSON
10/// - `net_err_tx`: Channel to send human-readable errors on failure
11/// - `notify_tx`: Channel to notify the UI when the set of names changes
12///
13/// Output:
14/// - Launches a task that updates the in-memory index and persists to disk when the set of names
15///   changes; sends notifications/errors via the provided channels.
16///
17/// Details:
18/// - Merges new names while preserving previously enriched fields (repo, arch, version, description)
19///   for still-existing packages.
20#[cfg(not(target_os = "windows"))]
21pub async fn update_in_background(
22    persist_path: std::path::PathBuf,
23    net_err_tx: tokio::sync::mpsc::UnboundedSender<String>,
24    notify_tx: tokio::sync::mpsc::UnboundedSender<()>,
25) {
26    tokio::spawn(async move {
27        tracing::info!("refreshing official index in background");
28        match fetch_official_pkg_names().await {
29            Ok(new_pkgs) => {
30                let new_count = new_pkgs.len();
31                let (different, merged): (bool, Vec<OfficialPkg>) = {
32                    let guard = idx().read().ok();
33                    if let Some(g) = guard {
34                        use std::collections::{HashMap, HashSet};
35                        let old_names: HashSet<String> =
36                            g.pkgs.iter().map(|p| p.name.clone()).collect();
37                        let new_names: HashSet<String> =
38                            new_pkgs.iter().map(|p| p.name.clone()).collect();
39                        let different = old_names != new_names;
40                        // Merge: prefer old/enriched fields when same name exists
41                        let mut old_map: HashMap<String, &OfficialPkg> = HashMap::new();
42                        for p in &g.pkgs {
43                            old_map.insert(p.name.clone(), p);
44                        }
45                        let mut merged = Vec::with_capacity(new_pkgs.len());
46                        for mut p in new_pkgs {
47                            if let Some(old) = old_map.get(&p.name) {
48                                // keep enriched data
49                                p.repo.clone_from(&old.repo);
50                                p.arch.clone_from(&old.arch);
51                                p.version.clone_from(&old.version);
52                                p.description.clone_from(&old.description);
53                            }
54                            merged.push(p);
55                        }
56                        (different, merged)
57                    } else {
58                        (true, new_pkgs)
59                    }
60                };
61                if different {
62                    if let Ok(mut g) = idx().write() {
63                        g.pkgs = merged;
64                        g.rebuild_name_index();
65                    }
66                    save_to_disk(&persist_path);
67                    let _ = notify_tx.send(());
68                    tracing::info!(count = new_count, "official index updated (names changed)");
69                } else {
70                    tracing::info!(
71                        count = new_count,
72                        "official index up-to-date (no name changes)"
73                    );
74                }
75            }
76            Err(e) => {
77                let _ = net_err_tx.send(format!("Failed to refresh official index: {e}"));
78                tracing::warn!(error = %e, "failed to refresh official index");
79            }
80        }
81    });
82}
83
84#[cfg(not(target_os = "windows"))]
85#[cfg(test)]
86mod tests {
87    #[tokio::test]
88    #[allow(clippy::await_holding_lock)]
89    /// What: Merge fetched names while preserving enriched fields and notify on change.
90    ///
91    /// Inputs:
92    /// - Seed index with enriched entry and stub `pacman -Sl` to add new packages.
93    ///
94    /// Output:
95    /// - Notification sent, no error emitted, and enriched data retained.
96    ///
97    /// Details:
98    /// - Simulates pacman output via PATH override to exercise merge path.
99    async fn update_merges_preserving_enriched_fields_and_notifies_on_name_changes() {
100        let _guard = crate::global_test_mutex_lock();
101
102        // Seed current index with enriched fields
103        seed_enriched_index();
104
105        // Create a fake pacman on PATH that returns -Sl results for fetch
106        let (old_path, root, tmp) = setup_fake_pacman_for_update();
107
108        // Setup channels and run update
109        let (err_tx, mut err_rx) = tokio::sync::mpsc::unbounded_channel::<String>();
110        let (notify_tx, mut notify_rx) = tokio::sync::mpsc::unbounded_channel::<()>();
111        super::update_in_background(tmp.clone(), err_tx, notify_tx).await;
112
113        // Verify notification and no error
114        verify_update_notification(&mut notify_rx, &mut err_rx).await;
115
116        // Check merge kept enriched fields for existing name "foo"
117        verify_enriched_fields_preserved();
118
119        // Teardown
120        teardown_test_env(&old_path, &tmp, &root);
121    }
122
123    /// What: Seed the index with enriched test data.
124    ///
125    /// Inputs: None.
126    ///
127    /// Output: None (modifies global index state).
128    ///
129    /// Details:
130    /// - Creates a test package "foo" with enriched fields.
131    fn seed_enriched_index() {
132        if let Ok(mut g) = super::idx().write() {
133            g.pkgs = vec![super::OfficialPkg {
134                name: "foo".to_string(),
135                repo: "core".to_string(),
136                arch: "x86_64".to_string(),
137                version: "0.9".to_string(),
138                description: "old".to_string(),
139            }];
140        }
141    }
142
143    /// What: Setup fake pacman script for update test.
144    ///
145    /// Inputs: None.
146    ///
147    /// Output:
148    /// - Returns (`old_path`, `root_dir`, `tmp_file`) for teardown.
149    ///
150    /// Details:
151    /// - Creates a temporary pacman script that returns test data.
152    fn setup_fake_pacman_for_update() -> (String, std::path::PathBuf, std::path::PathBuf) {
153        let old_path = std::env::var("PATH").unwrap_or_default();
154        let mut root = std::env::temp_dir();
155        root.push(format!(
156            "pacsea_fake_pacman_update_{}_{}",
157            std::process::id(),
158            std::time::SystemTime::now()
159                .duration_since(std::time::UNIX_EPOCH)
160                .expect("System time is before UNIX epoch")
161                .as_nanos()
162        ));
163        std::fs::create_dir_all(&root).expect("failed to create test root directory");
164        let mut bin = root.clone();
165        bin.push("bin");
166        std::fs::create_dir_all(&bin).expect("failed to create test bin directory");
167        let mut script = bin.clone();
168        script.push("pacman");
169        let body = r#"#!/usr/bin/env bash
170set -e
171if [[ "$1" == "-Sl" ]]; then
172  repo="$2"
173  case "$repo" in
174    core)
175      echo "core foo 1.0"
176      ;;
177    extra)
178      echo "extra bar 2.0"
179      ;;
180  esac
181  exit 0
182fi
183exit 0
184"#;
185        std::fs::write(&script, body).expect("failed to write test pacman script");
186        #[cfg(unix)]
187        {
188            use std::os::unix::fs::PermissionsExt;
189            let mut perm = std::fs::metadata(&script)
190                .expect("failed to read test pacman script metadata")
191                .permissions();
192            perm.set_mode(0o755);
193            std::fs::set_permissions(&script, perm)
194                .expect("failed to set test pacman script permissions");
195        }
196        let new_path = format!("{}:{old_path}", bin.to_string_lossy());
197        unsafe { std::env::set_var("PATH", &new_path) };
198        let mut tmp = std::env::temp_dir();
199        tmp.push("pacsea_update_merge.json");
200        (old_path, root, tmp)
201    }
202
203    /// What: Verify update notification and no error.
204    ///
205    /// Inputs:
206    /// - `notify_rx`: Receiver for notification channel
207    /// - `err_rx`: Receiver for error channel
208    ///
209    /// Output: None (panics on assertion failure).
210    ///
211    /// Details:
212    /// - Asserts notification received and no error sent.
213    async fn verify_update_notification(
214        notify_rx: &mut tokio::sync::mpsc::UnboundedReceiver<()>,
215        err_rx: &mut tokio::sync::mpsc::UnboundedReceiver<String>,
216    ) {
217        let notified =
218            tokio::time::timeout(std::time::Duration::from_millis(500), notify_rx.recv())
219                .await
220                .ok()
221                .flatten()
222                .is_some();
223        assert!(notified);
224        let none = tokio::time::timeout(std::time::Duration::from_millis(200), err_rx.recv())
225            .await
226            .ok()
227            .flatten();
228        assert!(none.is_none());
229    }
230
231    /// What: Verify enriched fields were preserved during merge.
232    ///
233    /// Inputs: None.
234    ///
235    /// Output: None (panics on assertion failure).
236    ///
237    /// Details:
238    /// - Checks that "foo" package retained its enriched fields.
239    fn verify_enriched_fields_preserved() {
240        let items = crate::index::all_official();
241        let foo = items
242            .iter()
243            .find(|p| p.name == "foo")
244            .expect("package 'foo' should exist in test data");
245        match &foo.source {
246            crate::state::Source::Official { repo, arch } => {
247                assert_eq!(repo, "core");
248                assert_eq!(arch, "x86_64");
249            }
250            crate::state::Source::Aur => panic!("expected official"),
251        }
252        assert_eq!(foo.version, "0.9"); // preserved from enriched
253    }
254
255    /// What: Cleanup test environment.
256    ///
257    /// Inputs:
258    /// - `old_path`: Original PATH value to restore
259    /// - `tmp`: Temporary file path to remove
260    /// - `root`: Root directory to remove
261    ///
262    /// Output: None.
263    ///
264    /// Details:
265    /// - Restores PATH and removes temporary files.
266    fn teardown_test_env(old_path: &str, tmp: &std::path::PathBuf, root: &std::path::PathBuf) {
267        unsafe { std::env::set_var("PATH", old_path) };
268        let _ = std::fs::remove_file(tmp);
269        let _ = std::fs::remove_dir_all(root);
270    }
271}