Skip to main content

arch_toolkit/index/
explicit.rs

1//! Explicit package query functions for the index module.
2
3use std::collections::HashSet;
4use std::process::{Command, Stdio};
5
6use crate::error::{ArchToolkitError, Result};
7use crate::types::index::InstalledPackagesMode;
8
9/// What: Query pacman for explicitly installed packages and optionally update a cache.
10///
11/// Inputs:
12/// - `mode`: Filter mode determining which packages to query (`LeafOnly` or `AllExplicit`).
13/// - `cache`: Optional mutable reference to a `HashSet<String>` to update with results.
14///
15/// Output:
16/// - Returns `Ok(HashSet<String>)` containing explicitly installed package names.
17/// - Returns `Ok(HashSet::new())` on failure (graceful degradation).
18///
19/// Details:
20/// - Uses `pacman -Qetq` for `LeafOnly` mode (explicitly installed AND not required).
21/// - Uses `pacman -Qeq` for `AllExplicit` mode (all explicitly installed).
22/// - If `cache` is provided, updates it with the results.
23/// - Sets `LC_ALL=C` and `LANG=C` for consistent locale-independent output.
24/// - Logs errors for diagnostics but returns empty set to avoid blocking operations.
25///
26/// # Errors
27///
28/// This function does not return errors - it gracefully degrades by returning an empty set.
29/// Errors are logged using `tracing::error` for diagnostics.
30///
31/// # Example
32///
33/// ```no_run
34/// use arch_toolkit::index::{refresh_explicit_cache, InstalledPackagesMode};
35/// use std::collections::HashSet;
36///
37/// let mut cache = HashSet::new();
38/// let packages = refresh_explicit_cache(InstalledPackagesMode::AllExplicit, Some(&mut cache)).unwrap();
39/// println!("Found {} explicitly installed packages", packages.len());
40/// ```
41#[allow(clippy::implicit_hasher)]
42pub fn refresh_explicit_cache(
43    mode: InstalledPackagesMode,
44    cache: Option<&mut HashSet<String>>,
45) -> Result<HashSet<String>> {
46    let args: &[&str] = match mode {
47        InstalledPackagesMode::LeafOnly => &["-Qetq"], // explicitly installed AND not required
48        InstalledPackagesMode::AllExplicit => &["-Qeq"], // all explicitly installed
49    };
50
51    tracing::debug!("Running: pacman {:?}", args);
52    let output = Command::new("pacman")
53        .args(args)
54        .env("LC_ALL", "C")
55        .env("LANG", "C")
56        .stdin(Stdio::null())
57        .stdout(Stdio::piped())
58        .stderr(Stdio::piped())
59        .output();
60
61    let packages = match output {
62        Ok(output) => {
63            if output.status.success() {
64                let text = String::from_utf8_lossy(&output.stdout);
65                let packages: HashSet<String> = text
66                    .lines()
67                    .map(|s| s.trim().to_string())
68                    .filter(|s| !s.is_empty())
69                    .collect();
70                tracing::debug!(
71                    "Successfully retrieved {} explicit packages (mode: {:?})",
72                    packages.len(),
73                    mode
74                );
75                packages
76            } else {
77                let stderr = String::from_utf8_lossy(&output.stderr);
78                tracing::error!(
79                    "pacman {:?} failed with status {:?}: {}",
80                    args,
81                    output.status.code(),
82                    stderr
83                );
84                HashSet::new()
85            }
86        }
87        Err(e) => {
88            tracing::error!("Failed to execute pacman {:?}: {}", args, e);
89            HashSet::new()
90        }
91    };
92
93    // Update cache if provided
94    if let Some(cache_ref) = cache {
95        cache_ref.clone_from(&packages);
96    }
97
98    Ok(packages)
99}
100
101/// What: Query pacman for explicitly installed packages asynchronously and optionally update a cache.
102///
103/// Inputs:
104/// - `mode`: Filter mode determining which packages to query (`LeafOnly` or `AllExplicit`).
105/// - `cache`: Optional mutable reference to a `HashSet<String>` to update with results.
106///
107/// Output:
108/// - Returns a future that resolves to `Result<HashSet<String>>` containing explicitly installed package names.
109///
110/// Details:
111/// - Uses `tokio::task::spawn_blocking` to run the sync version in a blocking task.
112/// - If `cache` is provided, updates it with the results after the task completes.
113/// - The cache parameter must be `Send` and `Sync` to be used across async boundaries.
114///
115/// # Errors
116///
117/// Returns `Err` if the blocking task fails, otherwise returns the same result as the sync version.
118///
119/// # Example
120///
121/// ```no_run
122/// use arch_toolkit::index::{refresh_explicit_cache_async, InstalledPackagesMode};
123/// use std::collections::HashSet;
124///
125/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
126/// let mut cache = HashSet::new();
127/// let packages = refresh_explicit_cache_async(InstalledPackagesMode::LeafOnly, Some(&mut cache)).await?;
128/// println!("Found {} leaf packages", packages.len());
129/// # Ok(())
130/// # }
131/// ```
132#[cfg(feature = "index")]
133#[allow(clippy::implicit_hasher)]
134pub async fn refresh_explicit_cache_async(
135    mode: InstalledPackagesMode,
136    cache: Option<&mut HashSet<String>>,
137) -> Result<HashSet<String>> {
138    // Run the blocking operation without the cache parameter
139    let result = tokio::task::spawn_blocking(move || refresh_explicit_cache(mode, None))
140        .await
141        .map_err(|e| ArchToolkitError::Parse(format!("Blocking task failed: {e}")))?;
142
143    // Update cache if provided and result is successful
144    if let (Ok(packages), Some(cache_ref)) = (result.as_ref(), cache) {
145        cache_ref.clone_from(packages);
146    }
147
148    result
149}
150
151/// What: Check if a package is explicitly installed, using cache if provided or querying pacman directly.
152///
153/// Inputs:
154/// - `name`: Package name to check.
155/// - `mode`: Filter mode for query type (`LeafOnly` or `AllExplicit`).
156/// - `cache`: Optional reference to a `HashSet<String>` containing explicit package names.
157///
158/// Output:
159/// - Returns `true` if the package is explicitly installed, `false` otherwise.
160///
161/// Details:
162/// - If `cache` is provided, checks membership in the cache (O(1) lookup).
163/// - If `cache` is `None`, queries pacman directly using the appropriate command for the mode.
164/// - Gracefully degrades: returns `false` on error.
165///
166/// # Example
167///
168/// ```no_run
169/// use arch_toolkit::index::{is_explicit, InstalledPackagesMode};
170/// use std::collections::HashSet;
171///
172/// let cache = HashSet::from(["vim".to_string(), "git".to_string()]);
173/// assert!(is_explicit("vim", InstalledPackagesMode::AllExplicit, Some(&cache)));
174/// assert!(!is_explicit("nonexistent", InstalledPackagesMode::AllExplicit, Some(&cache)));
175/// ```
176#[must_use]
177#[allow(clippy::implicit_hasher)]
178pub fn is_explicit(
179    name: &str,
180    mode: InstalledPackagesMode,
181    cache: Option<&HashSet<String>>,
182) -> bool {
183    if let Some(cache_ref) = cache {
184        return cache_ref.contains(name);
185    }
186
187    // Query pacman directly if no cache
188    let args: &[&str] = match mode {
189        InstalledPackagesMode::LeafOnly => &["-Qet", name],
190        InstalledPackagesMode::AllExplicit => &["-Qe", name],
191    };
192
193    tracing::debug!("Running: pacman {:?}", args);
194    let output = Command::new("pacman")
195        .args(args)
196        .env("LC_ALL", "C")
197        .env("LANG", "C")
198        .stdin(Stdio::null())
199        .stdout(Stdio::piped())
200        .stderr(Stdio::piped())
201        .output();
202
203    match output {
204        Ok(output) => output.status.success(),
205        Err(e) => {
206            tracing::error!("Failed to execute pacman {:?}: {}", args, e);
207            false
208        }
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    /// What: Verify `refresh_explicit_cache` updates cache when provided.
218    ///
219    /// Inputs:
220    /// - Empty cache and function call with cache parameter.
221    ///
222    /// Output:
223    /// - Cache is populated with results (if pacman is available).
224    ///
225    /// Details:
226    /// - Tests that cache parameter is updated correctly for both modes.
227    fn refresh_explicit_cache_updates_cache() {
228        let mut cache = HashSet::new();
229        let _result = refresh_explicit_cache(InstalledPackagesMode::AllExplicit, Some(&mut cache));
230        // Cache should be updated (may be empty if pacman unavailable, which is OK)
231    }
232
233    #[test]
234    /// What: Verify `refresh_explicit_cache` works without cache parameter.
235    ///
236    /// Inputs:
237    /// - Function call without cache parameter for both modes.
238    ///
239    /// Output:
240    /// - Returns `HashSet` (may be empty if pacman unavailable).
241    ///
242    /// Details:
243    /// - Tests that function works correctly when no cache is provided.
244    fn refresh_explicit_cache_without_cache() {
245        let result_leaf = refresh_explicit_cache(InstalledPackagesMode::LeafOnly, None);
246        assert!(result_leaf.is_ok());
247
248        let result_all = refresh_explicit_cache(InstalledPackagesMode::AllExplicit, None);
249        assert!(result_all.is_ok());
250    }
251
252    #[test]
253    /// What: Verify `is_explicit` uses cache when provided.
254    ///
255    /// Inputs:
256    /// - Package name, mode, and cache containing the package.
257    ///
258    /// Output:
259    /// - Returns `true` for cached package, `false` for non-cached package.
260    ///
261    /// Details:
262    /// - Tests that cache lookup works correctly for both modes.
263    fn is_explicit_uses_cache() {
264        let cache = HashSet::from(["vim".to_string(), "git".to_string()]);
265        assert!(is_explicit(
266            "vim",
267            InstalledPackagesMode::AllExplicit,
268            Some(&cache)
269        ));
270        assert!(is_explicit(
271            "git",
272            InstalledPackagesMode::LeafOnly,
273            Some(&cache)
274        ));
275        assert!(!is_explicit(
276            "nonexistent",
277            InstalledPackagesMode::AllExplicit,
278            Some(&cache)
279        ));
280    }
281
282    #[test]
283    /// What: Verify `is_explicit` queries pacman when cache is not provided.
284    ///
285    /// Inputs:
286    /// - Package name and mode without cache parameter.
287    ///
288    /// Output:
289    /// - Returns result from pacman query (may be false if pacman unavailable).
290    ///
291    /// Details:
292    /// - Tests that function falls back to direct pacman query for both modes.
293    fn is_explicit_without_cache() {
294        // This will query pacman directly
295        // Result depends on system state, but should not panic
296        let _result_leaf = is_explicit("vim", InstalledPackagesMode::LeafOnly, None);
297        let _result_all = is_explicit("vim", InstalledPackagesMode::AllExplicit, None);
298    }
299
300    #[cfg(feature = "index")]
301    #[tokio::test]
302    /// What: Verify `refresh_explicit_cache_async` works asynchronously.
303    ///
304    /// Inputs:
305    /// - Async function call with optional cache for both modes.
306    ///
307    /// Output:
308    /// - Returns future that resolves to `HashSet`.
309    ///
310    /// Details:
311    /// - Tests that async version works correctly for both modes.
312    async fn refresh_explicit_cache_async_works() {
313        let mut cache = HashSet::new();
314        let result =
315            refresh_explicit_cache_async(InstalledPackagesMode::AllExplicit, Some(&mut cache))
316                .await;
317        assert!(result.is_ok());
318        // Result may be empty if pacman unavailable, which is graceful degradation
319    }
320}