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}