Skip to main content

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::{HashMap, 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`: Caller-supplied set of package names provided by installed packages.
235///
236/// Output:
237/// - Returns `true` if the package is directly installed or provided by an installed package.
238///
239/// Details:
240/// - First checks the caller-supplied installed and provided sets.
241/// - Then lazily checks unresolved names using `pacman -Qqo`.
242/// - This preserves deterministic injected-set behavior while retaining efficient host fallback.
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    if installed.contains(name) || provided.contains(name) {
261        return true;
262    }
263
264    // Lazy host fallback avoids building the full provides set for normal callers.
265    check_if_provided(name, installed).is_some()
266}
267
268/// What: Retrieve the locally installed version of a package.
269///
270/// Inputs:
271/// - `name`: Package to query via `pacman -Q`.
272///
273/// Output:
274/// - Returns `Ok(String)` with the installed version string on success.
275/// - Returns `Err(ArchToolkitError::PackageNotFound)` if the package is not installed.
276/// - Returns `Err(ArchToolkitError::Parse)` if the version string cannot be parsed.
277///
278/// Details:
279/// - Normalizes versions by removing revision suffixes to facilitate requirement comparisons.
280/// - Parses format: "name version" or "name version-revision".
281/// - Strips revision suffix (e.g., "1.2.3-1" -> "1.2.3").
282/// - Sets `LC_ALL=C` and `LANG=C` for consistent locale-independent output.
283///
284/// # Errors
285///
286/// - Returns `PackageNotFound` when the package is not installed.
287/// - Returns `Parse` when the version string cannot be parsed from command output.
288///
289/// # Example
290///
291/// ```no_run
292/// use arch_toolkit::deps::get_installed_version;
293///
294/// let version = get_installed_version("pacman")?;
295/// println!("Installed version: {}", version);
296/// # Ok::<(), arch_toolkit::error::ArchToolkitError>(())
297/// ```
298pub fn get_installed_version(name: &str) -> Result<String> {
299    let output = Command::new("pacman")
300        .args(["-Q", name])
301        .env("LC_ALL", "C")
302        .env("LANG", "C")
303        .stdin(Stdio::null())
304        .stdout(Stdio::piped())
305        .stderr(Stdio::piped())
306        .output()
307        .map_err(|e| ArchToolkitError::Parse(format!("pacman -Q failed: {e}")))?;
308
309    if !output.status.success() {
310        return Err(ArchToolkitError::PackageNotFound {
311            package: name.to_string(),
312        });
313    }
314
315    let text = String::from_utf8_lossy(&output.stdout);
316    if let Some(line) = text.lines().next() {
317        // Format: "name version" or "name version-revision"
318        if let Some(space_pos) = line.find(' ') {
319            let version = line[space_pos + 1..].trim();
320            // Remove revision suffix if present (e.g., "1.2.3-1" -> "1.2.3")
321            let version = version.split('-').next().unwrap_or(version);
322            return Ok(version.to_string());
323        }
324    }
325
326    Err(ArchToolkitError::Parse(format!(
327        "Could not parse version from pacman -Q output for package '{name}'"
328    )))
329}
330
331/// What: Retrieve the versions of all installed packages in one pacman invocation.
332///
333/// Inputs:
334/// - (none): Invokes `pacman -Q` to list every installed package with its version.
335///
336/// Output:
337/// - Map from package name to installed version (revision suffix stripped,
338///   e.g. `1.2.3-1` -> `1.2.3`, matching [`get_installed_version`]).
339/// - Empty map on failure (graceful degradation).
340///
341/// Details:
342/// - One subprocess for the whole system — use this instead of calling
343///   [`get_installed_version`] in a loop (e.g. build-preflight analysis of a
344///   long dependency list).
345/// - Sets `LC_ALL=C` and `LANG=C` for consistent locale-independent output.
346///
347/// # Example
348///
349/// ```no_run
350/// use arch_toolkit::deps::get_installed_versions;
351///
352/// let versions = get_installed_versions();
353/// if let Some(version) = versions.get("pacman") {
354///     println!("pacman {version}");
355/// }
356/// ```
357#[must_use]
358pub fn get_installed_versions() -> HashMap<String, String> {
359    tracing::debug!("Running: pacman -Q");
360    let output = Command::new("pacman")
361        .args(["-Q"])
362        .env("LC_ALL", "C")
363        .env("LANG", "C")
364        .stdin(Stdio::null())
365        .stdout(Stdio::piped())
366        .stderr(Stdio::piped())
367        .output();
368
369    match output {
370        Ok(output) if output.status.success() => {
371            let text = String::from_utf8_lossy(&output.stdout);
372            text.lines()
373                .filter_map(|line| {
374                    let (name, version) = line.trim().split_once(' ')?;
375                    let version = version.trim();
376                    let version = version.split('-').next().unwrap_or(version);
377                    Some((name.to_string(), version.to_string()))
378                })
379                .collect()
380        }
381        Ok(output) => {
382            let stderr = String::from_utf8_lossy(&output.stderr);
383            tracing::error!("pacman -Q failed: {}", stderr);
384            HashMap::new()
385        }
386        Err(e) => {
387            tracing::error!("Failed to execute pacman -Q: {}", e);
388            HashMap::new()
389        }
390    }
391}
392
393/// What: Enumerate foreign (locally installed, not in any sync repo) packages.
394///
395/// Inputs:
396/// - (none): Invokes `pacman -Qqm` to list foreign package names.
397///
398/// Output:
399/// - Set of package names whose repository is `local` (AUR builds, manual
400///   installs); empty set on failure (graceful degradation).
401///
402/// Details:
403/// - Equivalent to checking `pacman -Qi` `Repository: local` per package, but
404///   in a single subprocess — use for batch local-package filtering.
405/// - Sets `LC_ALL=C` and `LANG=C` for consistent locale-independent output.
406///
407/// # Example
408///
409/// ```no_run
410/// use arch_toolkit::deps::get_foreign_packages;
411///
412/// let foreign = get_foreign_packages();
413/// println!("{} foreign packages installed", foreign.len());
414/// ```
415#[must_use]
416pub fn get_foreign_packages() -> HashSet<String> {
417    tracing::debug!("Running: pacman -Qqm");
418    let output = Command::new("pacman")
419        .args(["-Qqm"])
420        .env("LC_ALL", "C")
421        .env("LANG", "C")
422        .stdin(Stdio::null())
423        .stdout(Stdio::piped())
424        .stderr(Stdio::piped())
425        .output();
426
427    match output {
428        Ok(output) if output.status.success() => {
429            let text = String::from_utf8_lossy(&output.stdout);
430            text.lines()
431                .map(|s| s.trim().to_string())
432                .filter(|s| !s.is_empty())
433                .collect()
434        }
435        // Non-zero exit also occurs when no foreign packages exist.
436        Ok(_) => HashSet::new(),
437        Err(e) => {
438            tracing::error!("Failed to execute pacman -Qqm: {}", e);
439            HashSet::new()
440        }
441    }
442}
443
444/// What: Query the repositories for the latest available version of a package.
445///
446/// Inputs:
447/// - `name`: Package name looked up via `pacman -Si`.
448///
449/// Output:
450/// - Returns `Some(String)` with the version string advertised in the repositories.
451/// - Returns `None` on failure (package not found in repos or command error).
452///
453/// Details:
454/// - Strips revision suffixes (e.g., `-1`) so comparisons focus on the base semantic version.
455/// - Parses "Version: x.y.z" line from pacman -Si output.
456/// - Sets `LC_ALL=C` and `LANG=C` for consistent locale-independent output.
457/// - Gracefully degrades by returning `None` if pacman is unavailable or package not found.
458///
459/// # Example
460///
461/// ```no_run
462/// use arch_toolkit::deps::get_available_version;
463///
464/// if let Some(version) = get_available_version("pacman") {
465///     println!("Available version: {}", version);
466/// }
467/// ```
468#[must_use]
469pub fn get_available_version(name: &str) -> Option<String> {
470    let output = Command::new("pacman")
471        .args(["-Si", name])
472        .env("LC_ALL", "C")
473        .env("LANG", "C")
474        .stdin(Stdio::null())
475        .stdout(Stdio::piped())
476        .stderr(Stdio::piped())
477        .output()
478        .ok()?;
479
480    if !output.status.success() {
481        return None;
482    }
483
484    let text = String::from_utf8_lossy(&output.stdout);
485    for line in text.lines() {
486        if line.starts_with("Version")
487            && let Some(colon_pos) = line.find(':')
488        {
489            let version = line[colon_pos + 1..].trim();
490            // Remove revision suffix if present
491            let version = version.split('-').next().unwrap_or(version);
492            return Some(version.to_string());
493        }
494    }
495    None
496}
497
498#[cfg(test)]
499mod tests {
500    use super::*;
501
502    #[test]
503    fn test_parse_installed_packages_output() {
504        // Test parsing logic with sample output
505        let sample_output = "pacman\nfirefox\nvim\n";
506        let packages: HashSet<String> = sample_output
507            .lines()
508            .map(|s| s.trim().to_string())
509            .filter(|s| !s.is_empty())
510            .collect();
511        assert_eq!(packages.len(), 3);
512        assert!(packages.contains("pacman"));
513        assert!(packages.contains("firefox"));
514        assert!(packages.contains("vim"));
515    }
516
517    #[test]
518    fn test_parse_upgradable_packages_output() {
519        // Test parsing logic with sample output
520        let sample_output =
521            "firefox 121.0-1 -> 122.0-1\nvim 9.0.0000-1 -> 9.0.1000-1\npackage-name\n";
522        let packages: HashSet<String> = sample_output
523            .lines()
524            .filter_map(|line| {
525                let line = line.trim();
526                if line.is_empty() {
527                    return None;
528                }
529                Some(line.find(' ').map_or_else(
530                    || line.to_string(),
531                    |space_pos| line[..space_pos].trim().to_string(),
532                ))
533            })
534            .collect();
535        assert_eq!(packages.len(), 3);
536        assert!(packages.contains("firefox"));
537        assert!(packages.contains("vim"));
538        assert!(packages.contains("package-name"));
539    }
540
541    #[test]
542    fn test_parse_installed_version_output() {
543        // Test parsing logic with sample output
544        let sample_output = "pacman 6.1.0-1\n";
545        if let Some(line) = sample_output.lines().next()
546            && let Some(space_pos) = line.find(' ')
547        {
548            let version = line[space_pos + 1..].trim();
549            let version = version.split('-').next().unwrap_or(version);
550            assert_eq!(version, "6.1.0");
551        }
552    }
553
554    #[test]
555    fn test_parse_available_version_output() {
556        // Test parsing logic with sample output
557        let sample_output =
558            "Repository      : extra\nName            : pacman\nVersion         : 6.1.0-1\n";
559        for line in sample_output.lines() {
560            if line.starts_with("Version")
561                && let Some(colon_pos) = line.find(':')
562            {
563                let version = line[colon_pos + 1..].trim();
564                let version = version.split('-').next().unwrap_or(version);
565                assert_eq!(version, "6.1.0");
566                return;
567            }
568        }
569        panic!("Version line not found");
570    }
571
572    #[test]
573    fn test_get_provided_packages_returns_empty() {
574        let installed = HashSet::from(["pacman".to_string()]);
575        let provided = get_provided_packages(&installed);
576        assert!(provided.is_empty());
577    }
578
579    #[test]
580    fn test_is_package_installed_or_provided_direct_install() {
581        let installed = HashSet::from(["pacman".to_string(), "vim".to_string()]);
582        let provided = HashSet::from(["arch-toolkit-virtual-fixture".to_string()]);
583        assert!(is_package_installed_or_provided(
584            "pacman", &installed, &provided
585        ));
586        assert!(is_package_installed_or_provided(
587            "vim", &installed, &provided
588        ));
589        assert!(is_package_installed_or_provided(
590            "arch-toolkit-virtual-fixture",
591            &installed,
592            &provided
593        ));
594        assert!(!is_package_installed_or_provided(
595            "nonexistent",
596            &installed,
597            &provided
598        ));
599    }
600
601    // Integration tests that require pacman - these are ignored by default
602    #[test]
603    #[ignore = "Requires pacman to be available"]
604    fn test_get_installed_packages_integration() {
605        if let Ok(packages) = get_installed_packages() {
606            // Should have at least some packages on a real system
607            // But we can't assert exact count since it varies
608            println!("Found {} installed packages", packages.len());
609        }
610    }
611
612    #[test]
613    #[ignore = "Requires pacman to be available"]
614    fn test_get_upgradable_packages_integration() {
615        if let Ok(packages) = get_upgradable_packages() {
616            // May be empty if system is up to date
617            println!("Found {} upgradable packages", packages.len());
618        }
619    }
620
621    #[test]
622    #[ignore = "Requires pacman to be available and package to be installed"]
623    fn test_get_installed_version_integration() {
624        // Test with a package that should be installed (pacman itself)
625        if let Ok(version) = get_installed_version("pacman") {
626            assert!(!version.is_empty());
627            println!("Installed pacman version: {version}");
628        }
629    }
630
631    #[test]
632    #[ignore = "Requires pacman to be available and package in repos"]
633    fn test_get_available_version_integration() {
634        // Test with a package that should be in repos
635        if let Some(version) = get_available_version("pacman") {
636            assert!(!version.is_empty());
637            println!("Available pacman version: {version}");
638        }
639    }
640}