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}