Skip to main content

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