pacsea/index/
explicit.rs

1use std::collections::HashSet;
2
3use super::explicit_lock;
4use crate::state::InstalledPackagesMode;
5
6/// What: Refresh the process-wide cache of explicitly installed package names.
7///
8/// Inputs:
9/// - `mode`: Filter mode for installed packages.
10///   - `LeafOnly`: Uses `pacman -Qetq` (explicitly installed AND not required)
11///   - `AllExplicit`: Uses `pacman -Qeq` (all explicitly installed)
12///
13/// Output:
14/// - Updates the global explicit-name set; ignores errors.
15///
16/// Details:
17/// - Converts command stdout into a `HashSet` and replaces the shared cache atomically.
18pub async fn refresh_explicit_cache(mode: InstalledPackagesMode) {
19    let args: &[&str] = match mode {
20        InstalledPackagesMode::LeafOnly => &["-Qetq"], // explicitly installed AND not required (leaf)
21        InstalledPackagesMode::AllExplicit => &["-Qeq"], // all explicitly installed
22    };
23    if let Ok(Ok(body)) =
24        tokio::task::spawn_blocking(move || crate::util::pacman::run_pacman(args)).await
25    {
26        let set: HashSet<String> = body.lines().map(|s| s.trim().to_string()).collect();
27        if let Ok(mut g) = explicit_lock().write() {
28            *g = set;
29        }
30    }
31}
32
33/// What: Return a cloned set of explicitly installed package names.
34///
35/// Inputs:
36/// - None
37///
38/// Output:
39/// - A cloned `HashSet<String>` of explicit names (empty on lock failure).
40///
41/// Details:
42/// - Returns an owned copy so callers can mutate the result without holding the lock.
43#[must_use]
44pub fn explicit_names() -> HashSet<String> {
45    explicit_lock()
46        .read()
47        .map(|s| s.clone())
48        .unwrap_or_default()
49}
50
51/// What: Query pacman directly for explicitly installed packages with the specified mode.
52///
53/// Inputs:
54/// - `mode`: Filter mode for installed packages.
55///   - `LeafOnly`: Uses `pacman -Qetq` (explicitly installed AND not required)
56///   - `AllExplicit`: Uses `pacman -Qeq` (all explicitly installed)
57///
58/// Output:
59/// - Returns a sorted vector of package names, or empty vector on error.
60///
61/// Details:
62/// - Queries pacman synchronously without using the cache.
63/// - Used when writing `installed_packages.txt` to ensure the file reflects the current mode setting.
64#[must_use]
65pub fn query_explicit_packages_sync(mode: InstalledPackagesMode) -> Vec<String> {
66    let args: &[&str] = match mode {
67        InstalledPackagesMode::LeafOnly => &["-Qetq"], // explicitly installed AND not required (leaf)
68        InstalledPackagesMode::AllExplicit => &["-Qeq"], // all explicitly installed
69    };
70    match crate::util::pacman::run_pacman(args) {
71        Ok(body) => {
72            let mut names: Vec<String> = body
73                .lines()
74                .map(|s| s.trim().to_string())
75                .filter(|s| !s.is_empty())
76                .collect();
77            names.sort();
78            names
79        }
80        Err(e) => {
81            tracing::warn!(
82                mode = ?mode,
83                error = %e,
84                "Failed to query explicit packages from pacman"
85            );
86            Vec::new()
87        }
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    /// What: Return an empty set when the explicit cache has not been populated.
94    ///
95    /// Inputs:
96    /// - Clear `EXPLICIT_SET` before calling `explicit_names`.
97    ///
98    /// Output:
99    /// - Empty `HashSet<String>`.
100    ///
101    /// Details:
102    /// - Confirms the helper gracefully handles uninitialized state.
103    #[test]
104    fn explicit_names_returns_empty_when_uninitialized() {
105        let _guard = crate::global_test_mutex_lock();
106        // Ensure empty state
107        if let Ok(mut g) = super::explicit_lock().write() {
108            g.clear();
109        }
110        let set = super::explicit_names();
111        assert!(set.is_empty());
112    }
113
114    /// What: Clone the cached explicit set for callers.
115    ///
116    /// Inputs:
117    /// - Populate `EXPLICIT_SET` with `a` and `b` prior to the call.
118    ///
119    /// Output:
120    /// - Returned set contains the inserted names.
121    ///
122    /// Details:
123    /// - Ensures cloning semantics (rather than references) are preserved.
124    #[test]
125    fn explicit_names_returns_cloned_set() {
126        let _guard = crate::global_test_mutex_lock();
127        if let Ok(mut g) = super::explicit_lock().write() {
128            g.clear();
129            g.insert("a".to_string());
130            g.insert("b".to_string());
131        }
132        let mut set = super::explicit_names();
133        assert_eq!(set.len(), 2);
134        let mut v: Vec<String> = set.drain().collect();
135        v.sort();
136        assert_eq!(v, vec!["a", "b"]);
137    }
138
139    #[cfg(not(target_os = "windows"))]
140    #[allow(clippy::await_holding_lock)]
141    #[tokio::test]
142    /// What: Populate the explicit cache from pacman output.
143    ///
144    /// Inputs:
145    /// - Override PATH with a fake pacman returning two explicit package names before invoking the refresh.
146    ///
147    /// Output:
148    /// - Cache contains both names after `refresh_explicit_cache` completes.
149    ///
150    /// Details:
151    /// - Verifies the async refresh reads command output, updates the cache, and the cache contents persist after restoring PATH.
152    async fn refresh_explicit_cache_populates_cache_from_pacman_output() {
153        struct PathGuard {
154            original: String,
155        }
156        impl Drop for PathGuard {
157            fn drop(&mut self) {
158                unsafe {
159                    std::env::set_var("PATH", &self.original);
160                }
161            }
162        }
163        let _guard = crate::global_test_mutex_lock();
164
165        if let Ok(mut g) = super::explicit_lock().write() {
166            g.clear();
167        }
168
169        let old_path = std::env::var("PATH").unwrap_or_default();
170        let _path_guard = PathGuard {
171            original: old_path.clone(),
172        };
173
174        let mut root = std::env::temp_dir();
175        root.push(format!(
176            "pacsea_fake_pacman_qetq_{}_{}",
177            std::process::id(),
178            std::time::SystemTime::now()
179                .duration_since(std::time::UNIX_EPOCH)
180                .expect("System time is before UNIX epoch")
181                .as_nanos()
182        ));
183        std::fs::create_dir_all(&root).expect("failed to create test root directory");
184        let mut bin = root.clone();
185        bin.push("bin");
186        std::fs::create_dir_all(&bin).expect("failed to create test bin directory");
187        let mut script = bin.clone();
188        script.push("pacman");
189        let body = r#"#!/usr/bin/env bash
190set -e
191if [[ "$1" == "-Qetq" ]]; then
192  echo "alpha"
193  echo "beta"
194  exit 0
195fi
196exit 1
197"#;
198        std::fs::write(&script, body).expect("failed to write test pacman script");
199        #[cfg(unix)]
200        {
201            use std::os::unix::fs::PermissionsExt;
202            let mut perm = std::fs::metadata(&script)
203                .expect("failed to read test pacman script metadata")
204                .permissions();
205            perm.set_mode(0o755);
206            std::fs::set_permissions(&script, perm)
207                .expect("failed to set test pacman script permissions");
208        }
209        let new_path = format!("{}:{old_path}", bin.to_string_lossy());
210        unsafe {
211            std::env::set_var("PATH", &new_path);
212        }
213
214        super::refresh_explicit_cache(crate::state::InstalledPackagesMode::LeafOnly).await;
215
216        let _ = std::fs::remove_dir_all(&root);
217
218        let set = super::explicit_names();
219        assert_eq!(set.len(), 2);
220        assert!(set.contains("alpha"));
221        assert!(set.contains("beta"));
222    }
223
224    #[cfg(not(target_os = "windows"))]
225    #[allow(clippy::await_holding_lock)]
226    #[tokio::test]
227    /// What: Populate the explicit cache from pacman output using `AllExplicit` mode.
228    ///
229    /// Inputs:
230    /// - Override PATH with a fake pacman returning explicit package names before invoking the refresh.
231    ///
232    /// Output:
233    /// - Cache contains all names after `refresh_explicit_cache` completes with `AllExplicit` mode.
234    ///
235    /// Details:
236    /// - Verifies the async refresh uses `-Qeq` argument (all explicitly installed packages)
237    ///   instead of `-Qetq` (leaf packages only), and updates the cache correctly.
238    async fn refresh_explicit_cache_populates_cache_with_all_explicit_mode() {
239        struct PathGuard {
240            original: String,
241        }
242        impl Drop for PathGuard {
243            fn drop(&mut self) {
244                unsafe {
245                    std::env::set_var("PATH", &self.original);
246                }
247            }
248        }
249        let _guard = crate::global_test_mutex_lock();
250
251        if let Ok(mut g) = super::explicit_lock().write() {
252            g.clear();
253        }
254
255        let old_path = std::env::var("PATH").unwrap_or_default();
256        let _path_guard = PathGuard {
257            original: old_path.clone(),
258        };
259
260        let mut root = std::env::temp_dir();
261        root.push(format!(
262            "pacsea_fake_pacman_qeq_{}_{}",
263            std::process::id(),
264            std::time::SystemTime::now()
265                .duration_since(std::time::UNIX_EPOCH)
266                .expect("System time is before UNIX epoch")
267                .as_nanos()
268        ));
269        std::fs::create_dir_all(&root).expect("failed to create test root directory");
270        let mut bin = root.clone();
271        bin.push("bin");
272        std::fs::create_dir_all(&bin).expect("failed to create test bin directory");
273        let mut script = bin.clone();
274        script.push("pacman");
275        let body = r#"#!/usr/bin/env bash
276set -e
277if [[ "$1" == "-Qeq" ]]; then
278  echo "git"
279  echo "python"
280  echo "wget"
281  exit 0
282fi
283exit 1
284"#;
285        std::fs::write(&script, body).expect("failed to write test pacman script");
286        #[cfg(unix)]
287        {
288            use std::os::unix::fs::PermissionsExt;
289            let mut perm = std::fs::metadata(&script)
290                .expect("failed to read test pacman script metadata")
291                .permissions();
292            perm.set_mode(0o755);
293            std::fs::set_permissions(&script, perm)
294                .expect("failed to set test pacman script permissions");
295        }
296        let new_path = format!("{}:{old_path}", bin.to_string_lossy());
297        unsafe {
298            std::env::set_var("PATH", &new_path);
299        }
300
301        super::refresh_explicit_cache(crate::state::InstalledPackagesMode::AllExplicit).await;
302
303        let _ = std::fs::remove_dir_all(&root);
304
305        let set = super::explicit_names();
306        assert_eq!(set.len(), 3);
307        assert!(set.contains("git"));
308        assert!(set.contains("python"));
309        assert!(set.contains("wget"));
310    }
311}