arch_toolkit/deps/
query.rs

1//! Package querying functions for dependency resolution.
2//!
3//! This module provides functions to query the pacman database for installed packages,
4//! upgradable packages, provided packages, and package versions. All functions gracefully
5//! degrade when pacman is unavailable, returning empty sets or None as appropriate.
6
7use crate::error::{ArchToolkitError, Result};
8use std::collections::HashSet;
9use std::hash::BuildHasher;
10use std::process::{Command, Stdio};
11
12/// What: Enumerate all currently installed packages on the system.
13///
14/// Inputs:
15/// - (none): Invokes `pacman -Qq` to query the local database.
16///
17/// Output:
18/// - Returns `Ok(HashSet<String>)` containing package names installed on the machine.
19/// - Returns `Ok(HashSet::new())` on failure (graceful degradation).
20///
21/// Details:
22/// - Uses pacman's quiet format to obtain trimmed names.
23/// - Logs errors for diagnostics but returns empty set to avoid blocking dependency checks.
24/// - Sets `LC_ALL=C` and `LANG=C` for consistent locale-independent output.
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::deps::get_installed_packages;
35///
36/// let installed = get_installed_packages().unwrap();
37/// println!("Found {} installed packages", installed.len());
38/// ```
39pub fn get_installed_packages() -> Result<HashSet<String>> {
40    tracing::debug!("Running: pacman -Qq");
41    let output = Command::new("pacman")
42        .args(["-Qq"])
43        .env("LC_ALL", "C")
44        .env("LANG", "C")
45        .stdin(Stdio::null())
46        .stdout(Stdio::piped())
47        .stderr(Stdio::piped())
48        .output();
49
50    match output {
51        Ok(output) => {
52            if output.status.success() {
53                let text = String::from_utf8_lossy(&output.stdout);
54                let packages: HashSet<String> = text
55                    .lines()
56                    .map(|s| s.trim().to_string())
57                    .filter(|s| !s.is_empty())
58                    .collect();
59                tracing::debug!(
60                    "Successfully retrieved {} installed packages",
61                    packages.len()
62                );
63                Ok(packages)
64            } else {
65                let stderr = String::from_utf8_lossy(&output.stderr);
66                tracing::error!(
67                    "pacman -Qq failed with status {:?}: {}",
68                    output.status.code(),
69                    stderr
70                );
71                Ok(HashSet::new())
72            }
73        }
74        Err(e) => {
75            tracing::error!("Failed to execute pacman -Qq: {}", e);
76            Ok(HashSet::new())
77        }
78    }
79}
80
81/// What: Collect names of packages that have upgrades available via pacman.
82///
83/// Inputs:
84/// - (none): Reads upgrade information by invoking `pacman -Qu`.
85///
86/// Output:
87/// - Returns `Ok(HashSet<String>)` containing package names that pacman reports as upgradable.
88/// - Returns `Ok(HashSet::new())` on failure (graceful degradation).
89///
90/// Details:
91/// - Parses output format: "name old-version -> new-version" or just "name" for AUR packages.
92/// - Extracts package name (everything before first space or "->").
93/// - Gracefully handles command failures by returning an empty set to avoid blocking dependency checks.
94/// - Sets `LC_ALL=C` and `LANG=C` for consistent locale-independent output.
95///
96/// # Errors
97///
98/// This function does not return errors - it gracefully degrades by returning an empty set.
99/// Errors are logged using `tracing::debug` for diagnostics.
100///
101/// # Example
102///
103/// ```no_run
104/// use arch_toolkit::deps::get_upgradable_packages;
105///
106/// let upgradable = get_upgradable_packages().unwrap();
107/// println!("Found {} upgradable packages", upgradable.len());
108/// ```
109pub fn get_upgradable_packages() -> Result<HashSet<String>> {
110    tracing::debug!("Running: pacman -Qu");
111    let output = Command::new("pacman")
112        .args(["-Qu"])
113        .env("LC_ALL", "C")
114        .env("LANG", "C")
115        .stdin(Stdio::null())
116        .stdout(Stdio::piped())
117        .stderr(Stdio::piped())
118        .output();
119
120    match output {
121        Ok(output) => {
122            if output.status.success() {
123                let text = String::from_utf8_lossy(&output.stdout);
124                // pacman -Qu outputs "name old-version -> new-version" or just "name" for AUR packages
125                let packages: HashSet<String> = text
126                    .lines()
127                    .filter_map(|line| {
128                        let line = line.trim();
129                        if line.is_empty() {
130                            return None;
131                        }
132                        // Extract package name (everything before space or "->")
133                        Some(line.find(' ').map_or_else(
134                            || line.to_string(),
135                            |space_pos| line[..space_pos].trim().to_string(),
136                        ))
137                    })
138                    .collect();
139                tracing::debug!(
140                    "Successfully retrieved {} upgradable packages",
141                    packages.len()
142                );
143                Ok(packages)
144            } else {
145                // No upgradable packages or error - return empty set
146                tracing::debug!("pacman -Qu returned non-zero status (no upgrades or error)");
147                Ok(HashSet::new())
148            }
149        }
150        Err(e) => {
151            tracing::debug!("Failed to execute pacman -Qu: {} (assuming no upgrades)", e);
152            Ok(HashSet::new())
153        }
154    }
155}
156
157/// What: Build an empty provides set (for API compatibility).
158///
159/// Inputs:
160/// - `installed`: Set of installed package names (unused, kept for API compatibility).
161///
162/// Output:
163/// - Returns an empty set (provides are now checked lazily).
164///
165/// Details:
166/// - This function is kept for API compatibility but no longer builds the full provides set.
167/// - Provides are now checked on-demand using `is_package_installed_or_provided()` for better performance.
168/// - This avoids querying all installed packages upfront, which was very slow.
169///
170/// # Example
171///
172/// ```
173/// use arch_toolkit::deps::{get_installed_packages, get_provided_packages};
174///
175/// let installed = get_installed_packages().unwrap();
176/// let provided = get_provided_packages(&installed);
177/// assert!(provided.is_empty()); // Always returns empty set
178/// ```
179#[must_use]
180pub fn get_provided_packages<S: BuildHasher + Default>(
181    _installed: &HashSet<String, S>,
182) -> HashSet<String> {
183    // Return empty set - provides are now checked lazily on-demand
184    // This avoids querying all installed packages upfront, which was very slow
185    HashSet::default()
186}
187
188/// What: Check if a specific package name is provided by any installed package (lazy check).
189///
190/// Inputs:
191/// - `name`: Package name to check.
192/// - `installed`: Set of installed package names (unused, kept for API compatibility).
193///
194/// Output:
195/// - Returns `Some(package_name)` if the name is provided by an installed package, `None` otherwise.
196///
197/// Details:
198/// - Uses `pacman -Qqo` to efficiently check if any installed package provides the name.
199/// - This is much faster than querying all packages upfront.
200/// - Returns the name of the providing package for debugging purposes.
201fn check_if_provided<S: BuildHasher>(
202    name: &str,
203    _installed: &HashSet<String, S>,
204) -> Option<String> {
205    // Use pacman -Qqo to check which package provides this name
206    // This is efficient - pacman does the lookup internally
207    let output = Command::new("pacman")
208        .args(["-Qqo", name])
209        .env("LC_ALL", "C")
210        .env("LANG", "C")
211        .stdin(Stdio::null())
212        .stdout(Stdio::piped())
213        .stderr(Stdio::piped())
214        .output();
215
216    match output {
217        Ok(output) if output.status.success() => {
218            let text = String::from_utf8_lossy(&output.stdout);
219            let providing_pkg = text.lines().next().map(|s| s.trim().to_string());
220            if let Some(providing_pkg) = &providing_pkg {
221                tracing::debug!("{} is provided by {}", name, providing_pkg);
222            }
223            providing_pkg
224        }
225        _ => None,
226    }
227}
228
229/// What: Check if a package is installed or provided by an installed package.
230///
231/// Inputs:
232/// - `name`: Package name to check.
233/// - `installed`: Set of directly installed package names.
234/// - `provided`: Set of package names provided by installed packages (unused, kept for API compatibility).
235///
236/// Output:
237/// - Returns `true` if the package is directly installed or provided by an installed package.
238///
239/// Details:
240/// - First checks if the package is directly installed.
241/// - Then lazily checks if it's provided by any installed package using `pacman -Qqo`.
242/// - This handles cases like `rustup` providing `rust` efficiently without querying all packages upfront.
243///
244/// # Example
245///
246/// ```no_run
247/// use arch_toolkit::deps::{get_installed_packages, get_provided_packages, is_package_installed_or_provided};
248///
249/// let installed = get_installed_packages().unwrap();
250/// let provided = get_provided_packages(&installed);
251///
252/// assert!(is_package_installed_or_provided("pacman", &installed, &provided));
253/// ```
254#[must_use]
255pub fn is_package_installed_or_provided<S: BuildHasher>(
256    name: &str,
257    installed: &HashSet<String, S>,
258    _provided: &HashSet<String, S>,
259) -> bool {
260    // First check if directly installed
261    if installed.contains(name) {
262        return true;
263    }
264
265    // Lazy check if provided by any installed package (much faster than building full set upfront)
266    check_if_provided(name, installed).is_some()
267}
268
269/// What: Retrieve the locally installed version of a package.
270///
271/// Inputs:
272/// - `name`: Package to query via `pacman -Q`.
273///
274/// Output:
275/// - Returns `Ok(String)` with the installed version string on success.
276/// - Returns `Err(ArchToolkitError::PackageNotFound)` if the package is not installed.
277/// - Returns `Err(ArchToolkitError::Parse)` if the version string cannot be parsed.
278///
279/// Details:
280/// - Normalizes versions by removing revision suffixes to facilitate requirement comparisons.
281/// - Parses format: "name version" or "name version-revision".
282/// - Strips revision suffix (e.g., "1.2.3-1" -> "1.2.3").
283/// - Sets `LC_ALL=C` and `LANG=C` for consistent locale-independent output.
284///
285/// # Errors
286///
287/// - Returns `PackageNotFound` when the package is not installed.
288/// - Returns `Parse` when the version string cannot be parsed from command output.
289///
290/// # Example
291///
292/// ```no_run
293/// use arch_toolkit::deps::get_installed_version;
294///
295/// let version = get_installed_version("pacman")?;
296/// println!("Installed version: {}", version);
297/// # Ok::<(), arch_toolkit::error::ArchToolkitError>(())
298/// ```
299pub fn get_installed_version(name: &str) -> Result<String> {
300    let output = Command::new("pacman")
301        .args(["-Q", name])
302        .env("LC_ALL", "C")
303        .env("LANG", "C")
304        .stdin(Stdio::null())
305        .stdout(Stdio::piped())
306        .stderr(Stdio::piped())
307        .output()
308        .map_err(|e| ArchToolkitError::Parse(format!("pacman -Q failed: {e}")))?;
309
310    if !output.status.success() {
311        return Err(ArchToolkitError::PackageNotFound {
312            package: name.to_string(),
313        });
314    }
315
316    let text = String::from_utf8_lossy(&output.stdout);
317    if let Some(line) = text.lines().next() {
318        // Format: "name version" or "name version-revision"
319        if let Some(space_pos) = line.find(' ') {
320            let version = line[space_pos + 1..].trim();
321            // Remove revision suffix if present (e.g., "1.2.3-1" -> "1.2.3")
322            let version = version.split('-').next().unwrap_or(version);
323            return Ok(version.to_string());
324        }
325    }
326
327    Err(ArchToolkitError::Parse(format!(
328        "Could not parse version from pacman -Q output for package '{name}'"
329    )))
330}
331
332/// What: Query the repositories for the latest available version of a package.
333///
334/// Inputs:
335/// - `name`: Package name looked up via `pacman -Si`.
336///
337/// Output:
338/// - Returns `Some(String)` with the version string advertised in the repositories.
339/// - Returns `None` on failure (package not found in repos or command error).
340///
341/// Details:
342/// - Strips revision suffixes (e.g., `-1`) so comparisons focus on the base semantic version.
343/// - Parses "Version: x.y.z" line from pacman -Si output.
344/// - Sets `LC_ALL=C` and `LANG=C` for consistent locale-independent output.
345/// - Gracefully degrades by returning `None` if pacman is unavailable or package not found.
346///
347/// # Example
348///
349/// ```no_run
350/// use arch_toolkit::deps::get_available_version;
351///
352/// if let Some(version) = get_available_version("pacman") {
353///     println!("Available version: {}", version);
354/// }
355/// ```
356#[must_use]
357pub fn get_available_version(name: &str) -> Option<String> {
358    let output = Command::new("pacman")
359        .args(["-Si", name])
360        .env("LC_ALL", "C")
361        .env("LANG", "C")
362        .stdin(Stdio::null())
363        .stdout(Stdio::piped())
364        .stderr(Stdio::piped())
365        .output()
366        .ok()?;
367
368    if !output.status.success() {
369        return None;
370    }
371
372    let text = String::from_utf8_lossy(&output.stdout);
373    for line in text.lines() {
374        if line.starts_with("Version")
375            && let Some(colon_pos) = line.find(':')
376        {
377            let version = line[colon_pos + 1..].trim();
378            // Remove revision suffix if present
379            let version = version.split('-').next().unwrap_or(version);
380            return Some(version.to_string());
381        }
382    }
383    None
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389
390    #[test]
391    fn test_parse_installed_packages_output() {
392        // Test parsing logic with sample output
393        let sample_output = "pacman\nfirefox\nvim\n";
394        let packages: HashSet<String> = sample_output
395            .lines()
396            .map(|s| s.trim().to_string())
397            .filter(|s| !s.is_empty())
398            .collect();
399        assert_eq!(packages.len(), 3);
400        assert!(packages.contains("pacman"));
401        assert!(packages.contains("firefox"));
402        assert!(packages.contains("vim"));
403    }
404
405    #[test]
406    fn test_parse_upgradable_packages_output() {
407        // Test parsing logic with sample output
408        let sample_output =
409            "firefox 121.0-1 -> 122.0-1\nvim 9.0.0000-1 -> 9.0.1000-1\npackage-name\n";
410        let packages: HashSet<String> = sample_output
411            .lines()
412            .filter_map(|line| {
413                let line = line.trim();
414                if line.is_empty() {
415                    return None;
416                }
417                Some(line.find(' ').map_or_else(
418                    || line.to_string(),
419                    |space_pos| line[..space_pos].trim().to_string(),
420                ))
421            })
422            .collect();
423        assert_eq!(packages.len(), 3);
424        assert!(packages.contains("firefox"));
425        assert!(packages.contains("vim"));
426        assert!(packages.contains("package-name"));
427    }
428
429    #[test]
430    fn test_parse_installed_version_output() {
431        // Test parsing logic with sample output
432        let sample_output = "pacman 6.1.0-1\n";
433        if let Some(line) = sample_output.lines().next()
434            && let Some(space_pos) = line.find(' ')
435        {
436            let version = line[space_pos + 1..].trim();
437            let version = version.split('-').next().unwrap_or(version);
438            assert_eq!(version, "6.1.0");
439        }
440    }
441
442    #[test]
443    fn test_parse_available_version_output() {
444        // Test parsing logic with sample output
445        let sample_output =
446            "Repository      : extra\nName            : pacman\nVersion         : 6.1.0-1\n";
447        for line in sample_output.lines() {
448            if line.starts_with("Version")
449                && let Some(colon_pos) = line.find(':')
450            {
451                let version = line[colon_pos + 1..].trim();
452                let version = version.split('-').next().unwrap_or(version);
453                assert_eq!(version, "6.1.0");
454                return;
455            }
456        }
457        panic!("Version line not found");
458    }
459
460    #[test]
461    fn test_get_provided_packages_returns_empty() {
462        let installed = HashSet::from(["pacman".to_string()]);
463        let provided = get_provided_packages(&installed);
464        assert!(provided.is_empty());
465    }
466
467    #[test]
468    fn test_is_package_installed_or_provided_direct_install() {
469        let installed = HashSet::from(["pacman".to_string(), "vim".to_string()]);
470        let provided = HashSet::new();
471        assert!(is_package_installed_or_provided(
472            "pacman", &installed, &provided
473        ));
474        assert!(is_package_installed_or_provided(
475            "vim", &installed, &provided
476        ));
477        assert!(!is_package_installed_or_provided(
478            "nonexistent",
479            &installed,
480            &provided
481        ));
482    }
483
484    // Integration tests that require pacman - these are ignored by default
485    #[test]
486    #[ignore = "Requires pacman to be available"]
487    fn test_get_installed_packages_integration() {
488        if let Ok(packages) = get_installed_packages() {
489            // Should have at least some packages on a real system
490            // But we can't assert exact count since it varies
491            println!("Found {} installed packages", packages.len());
492        }
493    }
494
495    #[test]
496    #[ignore = "Requires pacman to be available"]
497    fn test_get_upgradable_packages_integration() {
498        if let Ok(packages) = get_upgradable_packages() {
499            // May be empty if system is up to date
500            println!("Found {} upgradable packages", packages.len());
501        }
502    }
503
504    #[test]
505    #[ignore = "Requires pacman to be available and package to be installed"]
506    fn test_get_installed_version_integration() {
507        // Test with a package that should be installed (pacman itself)
508        if let Ok(version) = get_installed_version("pacman") {
509            assert!(!version.is_empty());
510            println!("Installed pacman version: {version}");
511        }
512    }
513
514    #[test]
515    #[ignore = "Requires pacman to be available and package in repos"]
516    fn test_get_available_version_integration() {
517        // Test with a package that should be in repos
518        if let Some(version) = get_available_version("pacman") {
519            assert!(!version.is_empty());
520            println!("Available pacman version: {version}");
521        }
522    }
523}