arch_toolkit/deps/resolve.rs
1//! Core dependency resolution logic for individual packages.
2//!
3//! This module provides functions to resolve dependencies for packages, determine
4//! dependency status, and handle batch operations for efficient dependency resolution.
5
6use crate::deps::parse::{parse_dep_spec, parse_pacman_si_conflicts, parse_pacman_si_deps};
7use crate::deps::pkgbuild::parse_pkgbuild_deps;
8use crate::deps::query::{
9 get_available_version, get_installed_packages, get_installed_version, get_provided_packages,
10 get_upgradable_packages, is_package_installed_or_provided,
11};
12use crate::deps::source::{determine_dependency_source, is_system_package};
13use crate::deps::version::version_satisfies;
14use crate::error::Result;
15use crate::types::dependency::{
16 Dependency, DependencySource, DependencyStatus, PackageRef, PackageSource, ResolverConfig,
17};
18use std::collections::{HashMap, HashSet};
19use std::hash::BuildHasher;
20use std::process::{Command, Stdio};
21
22/// Type alias for PKGBUILD cache callback function.
23type PkgbuildCacheFn = dyn Fn(&str) -> Option<String> + Send + Sync;
24
25/// What: Evaluate a dependency's installation status relative to required versions.
26///
27/// Inputs:
28/// - `name`: Dependency package identifier.
29/// - `version_req`: Optional version constraint string (e.g., `>=1.2`).
30/// - `installed`: Set of names currently installed on the system.
31/// - `provided`: Set of package names provided by installed packages.
32/// - `upgradable`: Set of names pacman reports as upgradable.
33///
34/// Output:
35/// - Returns a `DependencyStatus` describing whether installation, upgrade, or no action is needed.
36///
37/// Details:
38/// - Combines local database queries with helper functions to capture upgrade requirements.
39/// - Uses `is_package_installed_or_provided()` to check if package is available.
40/// - Uses `get_installed_version()` and `get_available_version()` for version checking.
41/// - Uses `version_satisfies()` for version requirement validation.
42///
43/// # Example
44///
45/// ```no_run
46/// use arch_toolkit::deps::determine_status;
47/// use std::collections::HashSet;
48///
49/// let installed = HashSet::from(["glibc".to_string()]);
50/// let provided = HashSet::new();
51/// let upgradable = HashSet::new();
52///
53/// let status = determine_status("glibc", "", &installed, &provided, &upgradable);
54/// println!("Status: {:?}", status);
55/// ```
56pub fn determine_status<S: BuildHasher>(
57 name: &str,
58 version_req: &str,
59 installed: &HashSet<String, S>,
60 provided: &HashSet<String, S>,
61 upgradable: &HashSet<String, S>,
62) -> DependencyStatus {
63 // Check if package is installed or provided by an installed package
64 if !is_package_installed_or_provided(name, installed, provided) {
65 return DependencyStatus::ToInstall;
66 }
67
68 // Check if package is upgradable (even without version requirement)
69 let is_upgradable = upgradable.contains(name);
70
71 // If version requirement is specified, check if it matches
72 if !version_req.is_empty() {
73 // Try to get installed version
74 if let Ok(installed_version) = get_installed_version(name) {
75 // Check if version requirement is satisfied
76 if !version_satisfies(&installed_version, version_req) {
77 return DependencyStatus::ToUpgrade {
78 current: installed_version,
79 required: version_req.to_string(),
80 };
81 }
82 // Version requirement satisfied, but check if package is upgradable anyway
83 if is_upgradable {
84 // Get available version from pacman -Si if possible
85 let available_version =
86 get_available_version(name).unwrap_or_else(|| "newer".to_string());
87 return DependencyStatus::ToUpgrade {
88 current: installed_version,
89 required: available_version,
90 };
91 }
92 return DependencyStatus::Installed {
93 version: installed_version,
94 };
95 }
96 }
97
98 // Installed but no version check needed - check if upgradable
99 if is_upgradable {
100 match get_installed_version(name) {
101 Ok(current_version) => {
102 let available_version =
103 get_available_version(name).unwrap_or_else(|| "newer".to_string());
104 return DependencyStatus::ToUpgrade {
105 current: current_version,
106 required: available_version,
107 };
108 }
109 Err(_) => {
110 return DependencyStatus::ToUpgrade {
111 current: "installed".to_string(),
112 required: "newer".to_string(),
113 };
114 }
115 }
116 }
117
118 // Installed and up-to-date - get actual version
119 get_installed_version(name).map_or_else(
120 |_| DependencyStatus::Installed {
121 version: "installed".to_string(),
122 },
123 |version| DependencyStatus::Installed { version },
124 )
125}
126
127/// What: Batch fetch dependency lists for multiple official packages using `pacman -Si`.
128///
129/// Inputs:
130/// - `names`: Package names to query (must be official packages, not local).
131///
132/// Output:
133/// - `HashMap` mapping package name to its dependency list (`Vec<String>`).
134///
135/// Details:
136/// - Batches queries into chunks of 50 to avoid command-line length limits.
137/// - Parses multi-package `pacman -Si` output (packages separated by blank lines).
138/// - Gracefully handles command failures by returning partial results.
139///
140/// # Example
141///
142/// ```no_run
143/// use arch_toolkit::deps::batch_fetch_official_deps;
144///
145/// let packages = vec!["firefox", "vim"];
146/// let deps = batch_fetch_official_deps(&packages);
147/// println!("Found dependencies for {} packages", deps.len());
148/// ```
149#[must_use]
150pub fn batch_fetch_official_deps(names: &[&str]) -> HashMap<String, Vec<String>> {
151 const BATCH_SIZE: usize = 50;
152 let mut result_map = HashMap::new();
153
154 for chunk in names.chunks(BATCH_SIZE) {
155 let mut args = vec!["-Si"];
156 args.extend(chunk.iter().copied());
157 match Command::new("pacman")
158 .args(&args)
159 .env("LC_ALL", "C")
160 .env("LANG", "C")
161 .stdin(Stdio::null())
162 .stdout(Stdio::piped())
163 .stderr(Stdio::piped())
164 .output()
165 {
166 Ok(output) if output.status.success() => {
167 let text = String::from_utf8_lossy(&output.stdout);
168 // Parse multi-package output: packages are separated by blank lines
169 let mut package_blocks = Vec::new();
170 let mut current_block = String::new();
171 for line in text.lines() {
172 if line.trim().is_empty() {
173 if !current_block.is_empty() {
174 package_blocks.push(current_block.clone());
175 current_block.clear();
176 }
177 } else {
178 current_block.push_str(line);
179 current_block.push('\n');
180 }
181 }
182 if !current_block.is_empty() {
183 package_blocks.push(current_block);
184 }
185
186 // Parse each block to extract package name and dependencies
187 for block in package_blocks {
188 let dep_names = parse_pacman_si_deps(&block);
189 // Extract package name from block
190 if let Some(name_line) =
191 block.lines().find(|l| l.trim_start().starts_with("Name"))
192 && let Some((_, name)) = name_line.split_once(':')
193 {
194 let pkg_name = name.trim().to_string();
195 result_map.insert(pkg_name, dep_names);
196 }
197 }
198 }
199 _ => {
200 // If batch fails, fall back to individual queries (but don't do it here to avoid recursion)
201 // The caller will handle individual queries
202 break;
203 }
204 }
205 }
206 result_map
207}
208
209/// What: Check if a command is available in PATH.
210///
211/// Inputs:
212/// - `cmd`: Command name to check.
213///
214/// Output:
215/// - Returns true if the command exists and can be executed.
216///
217/// Details:
218/// - Uses a simple version check to verify command availability.
219fn is_command_available(cmd: &str) -> bool {
220 Command::new(cmd)
221 .args(["--version"])
222 .stdin(Stdio::null())
223 .stdout(Stdio::null())
224 .stderr(Stdio::null())
225 .output()
226 .is_ok()
227}
228
229/// What: Check if a package name should be filtered out (virtual package or self-reference).
230///
231/// Inputs:
232/// - `pkg_name`: Package name to check.
233/// - `parent_name`: Name of the parent package (to detect self-references).
234///
235/// Output:
236/// - Returns true if the package should be filtered out.
237///
238/// Details:
239/// - Filters out .so files (virtual packages) and self-references.
240#[allow(clippy::case_sensitive_file_extension_comparisons)]
241fn should_filter_dependency(pkg_name: &str, parent_name: &str) -> bool {
242 let pkg_lower = pkg_name.to_lowercase();
243 pkg_name == parent_name
244 || pkg_lower.ends_with(".so")
245 || pkg_lower.contains(".so.")
246 || pkg_lower.contains(".so=")
247}
248
249/// What: Convert a dependency spec into a `Dependency` record.
250///
251/// Inputs:
252/// - `dep_spec`: Dependency specification string (may include version requirements).
253/// - `parent_name`: Name of the package that requires this dependency.
254/// - `installed`: Set of locally installed packages.
255/// - `provided`: Set of package names provided by installed packages.
256/// - `upgradable`: Set of packages flagged for upgrades.
257///
258/// Output:
259/// - Returns Some(Dependency) if the dependency should be included, None if filtered.
260///
261/// Details:
262/// - Parses the dependency spec, filters out virtual packages and self-references,
263/// and determines status, source, and system package flags.
264fn process_dependency_spec<S: BuildHasher>(
265 dep_spec: &str,
266 parent_name: &str,
267 installed: &HashSet<String, S>,
268 provided: &HashSet<String, S>,
269 upgradable: &HashSet<String, S>,
270) -> Option<Dependency> {
271 let spec = parse_dep_spec(dep_spec);
272 let pkg_name = spec.name;
273 let version_req = spec.version_req;
274
275 if should_filter_dependency(&pkg_name, parent_name) {
276 if pkg_name == parent_name {
277 tracing::debug!("Skipping self-reference: {} == {}", pkg_name, parent_name);
278 } else {
279 tracing::debug!("Filtering out virtual package: {}", pkg_name);
280 }
281 return None;
282 }
283
284 let status = determine_status(&pkg_name, &version_req, installed, provided, upgradable);
285 let (source, is_core) = determine_dependency_source(&pkg_name, installed);
286 let is_system = is_core || is_system_package(&pkg_name);
287
288 Some(Dependency {
289 name: pkg_name,
290 version_req,
291 status,
292 source,
293 required_by: vec![parent_name.to_string()],
294 depends_on: Vec::new(),
295 is_core,
296 is_system,
297 })
298}
299
300/// What: Process a list of dependency specs into `Dependency` records.
301///
302/// Inputs:
303/// - `dep_specs`: Vector of dependency specification strings.
304/// - `parent_name`: Name of the package that requires these dependencies.
305/// - `installed`: Set of locally installed packages.
306/// - `provided`: Set of package names provided by installed packages.
307/// - `upgradable`: Set of packages flagged for upgrades.
308///
309/// Output:
310/// - Returns a vector of `Dependency` records (filtered).
311///
312/// Details:
313/// - Processes each dependency spec and collects valid dependencies.
314fn process_dependency_specs<S: BuildHasher>(
315 dep_specs: Vec<String>,
316 parent_name: &str,
317 installed: &HashSet<String, S>,
318 provided: &HashSet<String, S>,
319 upgradable: &HashSet<String, S>,
320) -> Vec<Dependency> {
321 dep_specs
322 .into_iter()
323 .filter_map(|dep_spec| {
324 process_dependency_spec(&dep_spec, parent_name, installed, provided, upgradable)
325 })
326 .collect()
327}
328
329/// What: Resolve dependencies for a local package using pacman -Qi.
330///
331/// Inputs:
332/// - `name`: Package name.
333/// - `installed`: Set of locally installed packages.
334/// - `provided`: Set of package names provided by installed packages.
335/// - `upgradable`: Set of packages flagged for upgrades.
336///
337/// Output:
338/// - Returns a vector of `Dependency` records or an error string.
339///
340/// Details:
341/// - Uses pacman -Qi to get dependency information for locally installed packages.
342fn resolve_local_package_deps<S: BuildHasher>(
343 name: &str,
344 installed: &HashSet<String, S>,
345 provided: &HashSet<String, S>,
346 upgradable: &HashSet<String, S>,
347) -> Result<Vec<Dependency>> {
348 tracing::debug!("Running: pacman -Qi {} (local package)", name);
349 let output = Command::new("pacman")
350 .args(["-Qi", name])
351 .env("LC_ALL", "C")
352 .env("LANG", "C")
353 .stdin(Stdio::null())
354 .stdout(Stdio::piped())
355 .stderr(Stdio::piped())
356 .output()
357 .map_err(|e| {
358 tracing::error!("Failed to execute pacman -Qi {}: {}", name, e);
359 crate::error::ArchToolkitError::Parse(format!("pacman -Qi failed: {e}"))
360 })?;
361
362 if !output.status.success() {
363 let stderr = String::from_utf8_lossy(&output.stderr);
364 tracing::warn!(
365 "pacman -Qi {} failed with status {:?}: {}",
366 name,
367 output.status.code(),
368 stderr
369 );
370 return Ok(Vec::new());
371 }
372
373 let text = String::from_utf8_lossy(&output.stdout);
374 tracing::debug!("pacman -Qi {} output ({} bytes)", name, text.len());
375
376 let dep_names = parse_pacman_si_deps(&text);
377 tracing::debug!(
378 "Parsed {} dependency names from pacman -Qi output",
379 dep_names.len()
380 );
381
382 Ok(process_dependency_specs(
383 dep_names, name, installed, provided, upgradable,
384 ))
385}
386
387/// What: Resolve dependencies for an official package using pacman -Si.
388///
389/// Inputs:
390/// - `name`: Package name.
391/// - `repo`: Repository name (for logging).
392/// - `installed`: Set of locally installed packages.
393/// - `provided`: Set of package names provided by installed packages.
394/// - `upgradable`: Set of packages flagged for upgrades.
395///
396/// Output:
397/// - Returns a vector of `Dependency` records or an error string.
398///
399/// Details:
400/// - Uses pacman -Si to get dependency information for official packages.
401fn resolve_official_package_deps<S: BuildHasher>(
402 name: &str,
403 repo: &str,
404 installed: &HashSet<String, S>,
405 provided: &HashSet<String, S>,
406 upgradable: &HashSet<String, S>,
407) -> Result<Vec<Dependency>> {
408 tracing::debug!("Running: pacman -Si {} (repo: {})", name, repo);
409 let output = Command::new("pacman")
410 .args(["-Si", name])
411 .env("LC_ALL", "C")
412 .env("LANG", "C")
413 .stdin(Stdio::null())
414 .stdout(Stdio::piped())
415 .stderr(Stdio::piped())
416 .output()
417 .map_err(|e| {
418 tracing::error!("Failed to execute pacman -Si {}: {}", name, e);
419 crate::error::ArchToolkitError::Parse(format!("pacman -Si failed: {e}"))
420 })?;
421
422 if !output.status.success() {
423 let stderr = String::from_utf8_lossy(&output.stderr);
424 tracing::error!(
425 "pacman -Si {} failed with status {:?}: {}",
426 name,
427 output.status.code(),
428 stderr
429 );
430 return Err(crate::error::ArchToolkitError::Parse(format!(
431 "pacman -Si failed for {name}: {stderr}"
432 )));
433 }
434
435 let text = String::from_utf8_lossy(&output.stdout);
436 tracing::debug!("pacman -Si {} output ({} bytes)", name, text.len());
437
438 let dep_names = parse_pacman_si_deps(&text);
439 tracing::debug!(
440 "Parsed {} dependency names from pacman -Si output",
441 dep_names.len()
442 );
443
444 Ok(process_dependency_specs(
445 dep_names, name, installed, provided, upgradable,
446 ))
447}
448
449/// What: Try to resolve dependencies using an AUR helper (paru or yay).
450///
451/// Inputs:
452/// - `helper`: Helper command name ("paru" or "yay").
453/// - `name`: Package name.
454/// - `installed`: Set of locally installed packages.
455/// - `provided`: Set of package names provided by installed packages.
456/// - `upgradable`: Set of packages flagged for upgrades.
457///
458/// Output:
459/// - Returns Some(Vec<Dependency>) if successful, None otherwise.
460///
461/// Details:
462/// - Executes helper -Si command and parses the output for dependencies.
463fn try_helper_resolution<S: BuildHasher>(
464 helper: &str,
465 name: &str,
466 installed: &HashSet<String, S>,
467 provided: &HashSet<String, S>,
468 upgradable: &HashSet<String, S>,
469) -> Option<Vec<Dependency>> {
470 tracing::debug!("Trying {} -Si {} for dependency resolution", helper, name);
471 let output = Command::new(helper)
472 .args(["-Si", name])
473 .env("LC_ALL", "C")
474 .env("LANG", "C")
475 .stdin(Stdio::null())
476 .stdout(Stdio::piped())
477 .stderr(Stdio::piped())
478 .output()
479 .ok()?;
480
481 if !output.status.success() {
482 let stderr = String::from_utf8_lossy(&output.stderr);
483 tracing::debug!(
484 "{} -Si {} failed (will try other methods): {}",
485 helper,
486 name,
487 stderr.trim()
488 );
489 return None;
490 }
491
492 let text = String::from_utf8_lossy(&output.stdout);
493 tracing::debug!("{} -Si {} output ({} bytes)", helper, name, text.len());
494 let dep_names = parse_pacman_si_deps(&text);
495
496 if dep_names.is_empty() {
497 return None;
498 }
499
500 tracing::info!(
501 "Using {} to resolve runtime dependencies for {} (will fetch .SRCINFO for build-time deps)",
502 helper,
503 name
504 );
505
506 let deps = process_dependency_specs(dep_names, name, installed, provided, upgradable);
507 Some(deps)
508}
509
510/// What: Enhance dependency list with .SRCINFO data.
511///
512/// Inputs:
513/// - `name`: Package name.
514/// - `deps`: Existing dependency list to enhance.
515/// - `installed`: Set of locally installed packages.
516/// - `provided`: Set of package names provided by installed packages.
517/// - `upgradable`: Set of packages flagged for upgrades.
518///
519/// Output:
520/// - Returns the enhanced dependency list.
521///
522/// Details:
523/// - Fetches and parses .SRCINFO to add missing depends entries.
524/// - Requires `feature = "aur"` to be enabled.
525#[cfg(feature = "aur")]
526fn enhance_with_srcinfo<S: BuildHasher>(
527 name: &str,
528 deps: Vec<Dependency>,
529 _installed: &HashSet<String, S>,
530 _provided: &HashSet<String, S>,
531 _upgradable: &HashSet<String, S>,
532) -> Vec<Dependency> {
533 // Note: fetch_srcinfo is async and requires a reqwest client, so we can't use it here
534 // in the sync context. This is a limitation - callers should fetch .SRCINFO separately
535 // if needed, or use the async API when available.
536 tracing::debug!(
537 "Skipping .SRCINFO enhancement for {} (requires async context)",
538 name
539 );
540 deps
541}
542
543/// What: Enhance dependency list with .SRCINFO data (no-op when AUR feature is disabled).
544///
545/// Inputs:
546/// - `name`: Package name (unused).
547/// - `deps`: Existing dependency list to return as-is.
548/// - `installed`: Set of locally installed packages (unused).
549/// - `provided`: Set of package names provided by installed packages (unused).
550/// - `upgradable`: Set of packages flagged for upgrades (unused).
551///
552/// Output:
553/// - Returns the dependency list unchanged.
554///
555/// Details:
556/// - This is a no-op when `feature = "aur"` is not enabled.
557#[cfg(not(feature = "aur"))]
558#[allow(clippy::unused_parameters)]
559fn enhance_with_srcinfo<S: BuildHasher>(
560 _name: &str,
561 deps: Vec<Dependency>,
562 _installed: &HashSet<String, S>,
563 _provided: &HashSet<String, S>,
564 _upgradable: &HashSet<String, S>,
565) -> Vec<Dependency> {
566 deps
567}
568
569/// What: Fallback to cached PKGBUILD for dependency resolution.
570///
571/// Inputs:
572/// - `name`: Package name.
573/// - `pkgbuild_cache`: Optional callback to fetch PKGBUILD from cache.
574/// - `installed`: Set of locally installed packages.
575/// - `provided`: Set of package names provided by installed packages.
576/// - `upgradable`: Set of packages flagged for upgrades.
577///
578/// Output:
579/// - Returns a vector of `Dependency` records if `PKGBUILD` is found, empty vector otherwise.
580///
581/// Details:
582/// - Attempts to use cached PKGBUILD when .SRCINFO is unavailable (offline fallback).
583fn fallback_to_pkgbuild<S: BuildHasher>(
584 name: &str,
585 pkgbuild_cache: Option<&PkgbuildCacheFn>,
586 installed: &HashSet<String, S>,
587 provided: &HashSet<String, S>,
588 upgradable: &HashSet<String, S>,
589) -> Vec<Dependency> {
590 let Some(pkgbuild_text) = pkgbuild_cache.and_then(|f| f(name)) else {
591 tracing::debug!(
592 "No cached PKGBUILD available for {} (offline, no dependencies resolved)",
593 name
594 );
595 return Vec::new();
596 };
597
598 tracing::info!(
599 "Using cached PKGBUILD for {} to resolve dependencies (offline fallback)",
600 name
601 );
602 let (pkgbuild_depends, _, _, _) = parse_pkgbuild_deps(&pkgbuild_text);
603
604 let deps = process_dependency_specs(pkgbuild_depends, name, installed, provided, upgradable);
605 tracing::info!(
606 "Resolved {} dependencies from cached PKGBUILD for {}",
607 deps.len(),
608 name
609 );
610 deps
611}
612
613/// What: Resolve dependencies for an AUR package.
614///
615/// Inputs:
616/// - `name`: Package name.
617/// - `installed`: Set of locally installed packages.
618/// - `provided`: Set of package names provided by installed packages.
619/// - `upgradable`: Set of packages flagged for upgrades.
620/// - `pkgbuild_cache`: Optional callback to fetch PKGBUILD from cache.
621///
622/// Output:
623/// - Returns a vector of `Dependency` records.
624///
625/// Details:
626/// - Tries paru/yay first, then falls back to .SRCINFO and cached PKGBUILD.
627fn resolve_aur_package_deps<S: BuildHasher>(
628 name: &str,
629 installed: &HashSet<String, S>,
630 provided: &HashSet<String, S>,
631 upgradable: &HashSet<String, S>,
632 pkgbuild_cache: Option<&PkgbuildCacheFn>,
633) -> Vec<Dependency> {
634 tracing::debug!(
635 "Attempting to resolve AUR package: {} (will skip if not found)",
636 name
637 );
638
639 // Try paru first
640 let (mut deps, mut used_helper) = if is_command_available("paru")
641 && let Some(helper_deps) =
642 try_helper_resolution("paru", name, installed, provided, upgradable)
643 {
644 (helper_deps, true)
645 } else {
646 (Vec::new(), false)
647 };
648
649 // Try yay if paru didn't work
650 if !used_helper
651 && is_command_available("yay")
652 && let Some(helper_deps) =
653 try_helper_resolution("yay", name, installed, provided, upgradable)
654 {
655 deps = helper_deps;
656 used_helper = true;
657 }
658
659 if !used_helper {
660 tracing::debug!(
661 "Skipping AUR API for {} - paru/yay failed or not available (likely not a real package)",
662 name
663 );
664 }
665
666 // Always try to enhance with .SRCINFO
667 deps = enhance_with_srcinfo(name, deps, installed, provided, upgradable);
668
669 // Fallback to PKGBUILD if no dependencies were found
670 if !used_helper && deps.is_empty() {
671 deps = fallback_to_pkgbuild(name, pkgbuild_cache, installed, provided, upgradable);
672 }
673
674 deps
675}
676
677/// What: Resolve direct dependency metadata for a single package.
678///
679/// Inputs:
680/// - `name`: Package identifier whose dependencies should be enumerated.
681/// - `source`: Source enum describing whether the package is official or AUR.
682/// - `installed`: Set of locally installed packages for status determination.
683/// - `provided`: Set of package names provided by installed packages.
684/// - `upgradable`: Set of packages flagged for upgrades, used to detect stale dependencies.
685/// - `pkgbuild_cache`: Optional callback to fetch PKGBUILD from cache.
686///
687/// Output:
688/// - Returns a vector of `Dependency` records or an error string when resolution fails.
689///
690/// Details:
691/// - Invokes pacman or AUR helpers depending on source, filtering out virtual entries and self references.
692fn resolve_package_deps<S: BuildHasher>(
693 name: &str,
694 source: &PackageSource,
695 installed: &HashSet<String, S>,
696 provided: &HashSet<String, S>,
697 upgradable: &HashSet<String, S>,
698 pkgbuild_cache: Option<&PkgbuildCacheFn>,
699) -> Result<Vec<Dependency>> {
700 let deps = match source {
701 PackageSource::Official { repo, .. } => {
702 if repo == "local" {
703 resolve_local_package_deps(name, installed, provided, upgradable)?
704 } else {
705 resolve_official_package_deps(name, repo, installed, provided, upgradable)?
706 }
707 }
708 PackageSource::Aur => {
709 resolve_aur_package_deps(name, installed, provided, upgradable, pkgbuild_cache)
710 }
711 };
712
713 tracing::debug!("Resolved {} dependencies for package {}", deps.len(), name);
714 Ok(deps)
715}
716
717/// What: Fetch conflicts for a package from pacman or AUR sources.
718///
719/// Inputs:
720/// - `name`: Package identifier.
721/// - `source`: Source enum describing whether the package is official or AUR.
722///
723/// Output:
724/// - Returns a vector of conflicting package names, or empty vector on error.
725///
726/// Details:
727/// - For official packages, uses `pacman -Si` to get conflicts.
728/// - For AUR packages, tries paru/yay first, then falls back to .SRCINFO.
729///
730/// # Example
731///
732/// ```no_run
733/// use arch_toolkit::deps::fetch_package_conflicts;
734/// use arch_toolkit::PackageSource;
735///
736/// let conflicts = fetch_package_conflicts(
737/// "firefox",
738/// &PackageSource::Official {
739/// repo: "extra".into(),
740/// arch: "x86_64".into(),
741/// },
742/// );
743/// println!("Found {} conflicts", conflicts.len());
744/// ```
745pub fn fetch_package_conflicts(name: &str, source: &PackageSource) -> Vec<String> {
746 match source {
747 PackageSource::Official { repo, .. } => {
748 // Handle local packages specially - use pacman -Qi instead of -Si
749 if repo == "local" {
750 tracing::debug!("Running: pacman -Qi {} (local package, conflicts)", name);
751 if let Ok(output) = Command::new("pacman")
752 .args(["-Qi", name])
753 .env("LC_ALL", "C")
754 .env("LANG", "C")
755 .stdin(Stdio::null())
756 .stdout(Stdio::piped())
757 .stderr(Stdio::piped())
758 .output()
759 && output.status.success()
760 {
761 let text = String::from_utf8_lossy(&output.stdout);
762 return parse_pacman_si_conflicts(&text);
763 }
764 return Vec::new();
765 }
766
767 // Use pacman -Si to get conflicts
768 tracing::debug!("Running: pacman -Si {} (conflicts)", name);
769 if let Ok(output) = Command::new("pacman")
770 .args(["-Si", name])
771 .env("LC_ALL", "C")
772 .env("LANG", "C")
773 .stdin(Stdio::null())
774 .stdout(Stdio::piped())
775 .stderr(Stdio::piped())
776 .output()
777 && output.status.success()
778 {
779 let text = String::from_utf8_lossy(&output.stdout);
780 return parse_pacman_si_conflicts(&text);
781 }
782 Vec::new()
783 }
784 PackageSource::Aur => {
785 // Try paru/yay first
786 let has_paru = is_command_available("paru");
787 let has_yay = is_command_available("yay");
788
789 if has_paru {
790 tracing::debug!("Trying paru -Si {} for conflicts", name);
791 if let Ok(output) = Command::new("paru")
792 .args(["-Si", name])
793 .env("LC_ALL", "C")
794 .env("LANG", "C")
795 .stdin(Stdio::null())
796 .stdout(Stdio::piped())
797 .stderr(Stdio::piped())
798 .output()
799 && output.status.success()
800 {
801 let text = String::from_utf8_lossy(&output.stdout);
802 let conflicts = parse_pacman_si_conflicts(&text);
803 if !conflicts.is_empty() {
804 return conflicts;
805 }
806 }
807 }
808
809 if has_yay {
810 tracing::debug!("Trying yay -Si {} for conflicts", name);
811 if let Ok(output) = Command::new("yay")
812 .args(["-Si", name])
813 .env("LC_ALL", "C")
814 .env("LANG", "C")
815 .stdin(Stdio::null())
816 .stdout(Stdio::piped())
817 .stderr(Stdio::piped())
818 .output()
819 && output.status.success()
820 {
821 let text = String::from_utf8_lossy(&output.stdout);
822 let conflicts = parse_pacman_si_conflicts(&text);
823 if !conflicts.is_empty() {
824 return conflicts;
825 }
826 }
827 }
828
829 // Fall back to .SRCINFO
830 // Note: fetch_srcinfo is async, so we can't use it here in sync context
831 // This is a limitation - conflicts from .SRCINFO won't be detected in sync mode
832 #[cfg(feature = "aur")]
833 {
834 tracing::debug!(
835 "Skipping .SRCINFO conflict check for {} (requires async context)",
836 name
837 );
838 }
839
840 Vec::new()
841 }
842 }
843}
844
845/// What: Get priority value for dependency status (lower = more urgent).
846///
847/// Inputs:
848/// - `status`: Dependency status to get priority for.
849///
850/// Output:
851/// - Returns a numeric priority where lower numbers indicate higher urgency.
852///
853/// Details:
854/// - Priority order: Conflict (0) < Missing (1) < `ToInstall` (2) < `ToUpgrade` (3) < Installed (4).
855const fn dependency_priority(status: &DependencyStatus) -> u8 {
856 status.priority()
857}
858
859/// What: Merge a dependency into the dependency map.
860///
861/// Inputs:
862/// - `dep`: Dependency to merge.
863/// - `parent_name`: Name of the package that requires this dependency.
864/// - `installed`: Set of installed package names.
865/// - `provided`: Set of provided packages.
866/// - `upgradable`: Set of upgradable package names.
867/// - `deps`: Mutable reference to the dependency map to update.
868///
869/// Output:
870/// - Updates the `deps` map with the merged dependency.
871///
872/// Details:
873/// - Merges status (keeps worst), version requirements (keeps more restrictive), and `required_by` lists.
874fn merge_dependency<S: BuildHasher>(
875 dep: &Dependency,
876 parent_name: &str,
877 installed: &HashSet<String, S>,
878 provided: &HashSet<String, S>,
879 upgradable: &HashSet<String, S>,
880 deps: &mut HashMap<String, Dependency>,
881) {
882 let dep_name = dep.name.clone();
883
884 // Check if dependency already exists and get its current state
885 let needs_required_by_update = deps
886 .get(&dep_name)
887 .is_none_or(|e| !e.required_by.contains(&parent_name.to_string()));
888
889 // Update or create dependency entry
890 let entry = deps.entry(dep_name.clone()).or_insert_with(|| Dependency {
891 name: dep_name.clone(),
892 version_req: dep.version_req.clone(),
893 status: dep.status.clone(),
894 source: dep.source.clone(),
895 required_by: vec![parent_name.to_string()],
896 depends_on: Vec::new(),
897 is_core: dep.is_core,
898 is_system: dep.is_system,
899 });
900
901 // Update required_by (add the parent if not already present)
902 if needs_required_by_update {
903 entry.required_by.push(parent_name.to_string());
904 }
905
906 // Merge status (keep worst)
907 // But never overwrite a Conflict status - conflicts take precedence
908 if !matches!(entry.status, DependencyStatus::Conflict { .. }) {
909 let existing_priority = dependency_priority(&entry.status);
910 let new_priority = dependency_priority(&dep.status);
911 if new_priority < existing_priority {
912 entry.status = dep.status.clone();
913 }
914 }
915
916 // Merge version requirements (keep more restrictive)
917 // But never overwrite a Conflict status - conflicts take precedence
918 if !dep.version_req.is_empty() && dep.version_req != entry.version_req {
919 // If entry is already a conflict, don't overwrite it with dependency status
920 if matches!(entry.status, DependencyStatus::Conflict { .. }) {
921 // Still update version if needed, but keep conflict status
922 if entry.version_req.is_empty() {
923 entry.version_req.clone_from(&dep.version_req);
924 }
925 return;
926 }
927
928 if entry.version_req.is_empty() {
929 entry.version_req.clone_from(&dep.version_req);
930 } else {
931 // Check which version requirement is more restrictive
932 let existing_status = determine_status(
933 &entry.name,
934 &entry.version_req,
935 installed,
936 provided,
937 upgradable,
938 );
939 let new_status = determine_status(
940 &entry.name,
941 &dep.version_req,
942 installed,
943 provided,
944 upgradable,
945 );
946 let existing_req_priority = dependency_priority(&existing_status);
947 let new_req_priority = dependency_priority(&new_status);
948
949 if new_req_priority < existing_req_priority {
950 entry.version_req.clone_from(&dep.version_req);
951 entry.status = new_status;
952 }
953 }
954 }
955}
956
957/// Dependency resolver for batch package operations.
958///
959/// Provides a high-level API for resolving dependencies for multiple packages,
960/// handling batch operations, conflict detection, and dependency merging.
961pub struct DependencyResolver {
962 /// Resolver configuration.
963 config: ResolverConfig,
964}
965
966impl DependencyResolver {
967 /// What: Create a new dependency resolver with default configuration.
968 ///
969 /// Inputs:
970 /// - (none)
971 ///
972 /// Output:
973 /// - Returns a new `DependencyResolver` with default configuration.
974 ///
975 /// Details:
976 /// - Uses `ResolverConfig::default()` for configuration.
977 /// - Default config: direct dependencies only, no optional/make/check deps, no AUR checking.
978 ///
979 /// # Example
980 ///
981 /// ```no_run
982 /// use arch_toolkit::deps::DependencyResolver;
983 ///
984 /// let resolver = DependencyResolver::new();
985 /// ```
986 #[must_use]
987 pub fn new() -> Self {
988 Self {
989 config: ResolverConfig::default(),
990 }
991 }
992
993 /// What: Create a resolver with custom configuration.
994 ///
995 /// Inputs:
996 /// - `config`: Custom resolver configuration.
997 ///
998 /// Output:
999 /// - Returns a new `DependencyResolver` with the provided configuration.
1000 ///
1001 /// Details:
1002 /// - Allows customization of dependency resolution behavior.
1003 ///
1004 /// # Example
1005 ///
1006 /// ```no_run
1007 /// use arch_toolkit::deps::DependencyResolver;
1008 /// use arch_toolkit::types::dependency::ResolverConfig;
1009 ///
1010 /// let config = ResolverConfig {
1011 /// include_optdepends: true,
1012 /// include_makedepends: false,
1013 /// include_checkdepends: false,
1014 /// max_depth: 0,
1015 /// pkgbuild_cache: None,
1016 /// check_aur: false,
1017 /// };
1018 /// let resolver = DependencyResolver::with_config(config);
1019 /// ```
1020 #[must_use]
1021 #[allow(clippy::missing_const_for_fn)] // ResolverConfig contains function pointer, can't be const
1022 pub fn with_config(config: ResolverConfig) -> Self {
1023 Self { config }
1024 }
1025
1026 /// What: Resolve dependencies for a list of packages.
1027 ///
1028 /// Inputs:
1029 /// - `packages`: Slice of `PackageRef` records to resolve dependencies for.
1030 ///
1031 /// Output:
1032 /// - Returns `Ok(DependencyResolution)` with resolved dependencies, conflicts, and missing packages.
1033 /// - Returns `Err(ArchToolkitError)` if resolution fails.
1034 ///
1035 /// Details:
1036 /// - Resolves ONLY direct dependencies (non-recursive) for each package.
1037 /// - Merges duplicates by name, retaining the most severe status across all requesters.
1038 /// - Detects conflicts between packages being installed and already installed packages.
1039 /// - Sorts dependencies by priority (conflicts first, then missing, then to-install, then installed).
1040 /// - Uses batch fetching for official packages to reduce pacman command overhead.
1041 ///
1042 /// # Errors
1043 ///
1044 /// Returns `Err(ArchToolkitError::Parse)` if pacman commands fail or output cannot be parsed.
1045 /// Returns `Err(ArchToolkitError::PackageNotFound)` if required packages are not found.
1046 ///
1047 /// # Example
1048 ///
1049 /// ```no_run
1050 /// use arch_toolkit::deps::DependencyResolver;
1051 /// use arch_toolkit::{PackageRef, PackageSource};
1052 ///
1053 /// let resolver = DependencyResolver::new();
1054 /// let packages = vec![
1055 /// PackageRef {
1056 /// name: "firefox".into(),
1057 /// version: "121.0".into(),
1058 /// source: PackageSource::Official {
1059 /// repo: "extra".into(),
1060 /// arch: "x86_64".into(),
1061 /// },
1062 /// },
1063 /// ];
1064 ///
1065 /// let result = resolver.resolve(&packages)?;
1066 /// println!("Found {} dependencies", result.dependencies.len());
1067 /// # Ok::<(), arch_toolkit::error::ArchToolkitError>(())
1068 /// ```
1069 pub fn resolve(
1070 &self,
1071 packages: &[PackageRef],
1072 ) -> Result<crate::types::dependency::DependencyResolution> {
1073 use crate::types::dependency::DependencyResolution;
1074
1075 if packages.is_empty() {
1076 tracing::warn!("No packages provided for dependency resolution");
1077 return Ok(DependencyResolution::default());
1078 }
1079
1080 let mut deps: HashMap<String, Dependency> = HashMap::new();
1081 let mut conflicts: Vec<String> = Vec::new();
1082 let mut missing: Vec<String> = Vec::new();
1083
1084 // Get installed packages set
1085 tracing::info!("Fetching list of installed packages...");
1086 let installed = get_installed_packages()?;
1087 tracing::info!("Found {} installed packages", installed.len());
1088
1089 // Get all provided packages (e.g., rustup provides rust)
1090 // Note: Provides are checked lazily on-demand for performance, not built upfront
1091 tracing::debug!(
1092 "Provides will be checked lazily on-demand (not building full set for performance)"
1093 );
1094 let provided = get_provided_packages(&installed);
1095
1096 // Get list of upgradable packages to detect if dependencies need upgrades
1097 let upgradable = get_upgradable_packages()?;
1098 tracing::info!("Found {} upgradable packages", upgradable.len());
1099
1100 // Initialize set of root packages (for tracking)
1101 let root_names: HashSet<String> = packages.iter().map(|p| p.name.clone()).collect();
1102
1103 // Check conflicts for packages being installed
1104 tracing::info!("Checking conflicts for {} package(s)", packages.len());
1105 for package in packages {
1106 let package_conflicts = fetch_package_conflicts(&package.name, &package.source);
1107 for conflict_name in package_conflicts {
1108 if installed.contains(&conflict_name) || root_names.contains(&conflict_name) {
1109 if !conflicts.contains(&conflict_name) {
1110 conflicts.push(conflict_name.clone());
1111 }
1112 // Mark as conflict in dependency map
1113 let dep = Dependency {
1114 name: conflict_name,
1115 version_req: String::new(),
1116 status: DependencyStatus::Conflict {
1117 reason: format!("Conflicts with {}", package.name),
1118 },
1119 source: DependencySource::Local,
1120 required_by: vec![package.name.clone()],
1121 depends_on: Vec::new(),
1122 is_core: false,
1123 is_system: false,
1124 };
1125 merge_dependency(
1126 &dep,
1127 &package.name,
1128 &installed,
1129 &provided,
1130 &upgradable,
1131 &mut deps,
1132 );
1133 }
1134 }
1135 }
1136
1137 // Batch fetch official package dependencies to reduce pacman command overhead
1138 let official_packages: Vec<&str> = packages
1139 .iter()
1140 .filter_map(|pkg| {
1141 if let PackageSource::Official { repo, .. } = &pkg.source {
1142 if repo == "local" {
1143 None
1144 } else {
1145 Some(pkg.name.as_str())
1146 }
1147 } else {
1148 None
1149 }
1150 })
1151 .collect();
1152 let batched_deps_cache = if official_packages.is_empty() {
1153 HashMap::new()
1154 } else {
1155 batch_fetch_official_deps(&official_packages)
1156 };
1157
1158 // Resolve ONLY direct dependencies (non-recursive)
1159 // This is faster and avoids resolving transitive dependencies which can be slow and error-prone
1160 for package in packages {
1161 // Check if we have batched results for this official package
1162 let use_batched = matches!(package.source, PackageSource::Official { ref repo, .. } if repo != "local")
1163 && batched_deps_cache.contains_key(package.name.as_str());
1164
1165 let resolved_deps = if use_batched {
1166 // Use batched dependency list
1167 let dep_names = batched_deps_cache
1168 .get(package.name.as_str())
1169 .cloned()
1170 .unwrap_or_default();
1171 process_dependency_specs(
1172 dep_names,
1173 &package.name,
1174 &installed,
1175 &provided,
1176 &upgradable,
1177 )
1178 } else {
1179 // Resolve individually
1180 match resolve_package_deps(
1181 &package.name,
1182 &package.source,
1183 &installed,
1184 &provided,
1185 &upgradable,
1186 self.config
1187 .pkgbuild_cache
1188 .as_ref()
1189 .map(|f| f.as_ref() as &(dyn Fn(&str) -> Option<String> + Send + Sync)),
1190 ) {
1191 Ok(deps) => deps,
1192 Err(e) => {
1193 tracing::warn!(
1194 " Failed to resolve dependencies for {}: {}",
1195 package.name,
1196 e
1197 );
1198 // Mark as missing
1199 if !missing.contains(&package.name) {
1200 missing.push(package.name.clone());
1201 }
1202 continue;
1203 }
1204 }
1205 };
1206
1207 tracing::debug!(
1208 " Found {} dependencies for {}",
1209 resolved_deps.len(),
1210 package.name
1211 );
1212
1213 for dep in resolved_deps {
1214 // Check if dependency is missing
1215 if matches!(dep.status, DependencyStatus::Missing) && !missing.contains(&dep.name) {
1216 missing.push(dep.name.clone());
1217 }
1218
1219 merge_dependency(
1220 &dep,
1221 &package.name,
1222 &installed,
1223 &provided,
1224 &upgradable,
1225 &mut deps,
1226 );
1227
1228 // DON'T recursively resolve dependencies - only show direct dependencies
1229 // This prevents resolving transitive dependencies which can be slow and error-prone
1230 }
1231 }
1232
1233 let mut result: Vec<Dependency> = deps.into_values().collect();
1234 tracing::info!("Total unique dependencies found: {}", result.len());
1235
1236 // Sort dependencies: conflicts first, then missing, then to-install, then installed
1237 result.sort_by(|a, b| {
1238 let priority_a = dependency_priority(&a.status);
1239 let priority_b = dependency_priority(&b.status);
1240 priority_a
1241 .cmp(&priority_b)
1242 .then_with(|| a.name.cmp(&b.name))
1243 });
1244
1245 Ok(DependencyResolution {
1246 dependencies: result,
1247 conflicts,
1248 missing,
1249 })
1250 }
1251}
1252
1253impl Default for DependencyResolver {
1254 fn default() -> Self {
1255 Self::new()
1256 }
1257}
1258
1259#[cfg(test)]
1260mod tests {
1261 use super::*;
1262 use crate::types::dependency::DependencyStatus;
1263
1264 #[test]
1265 fn test_should_filter_dependency() {
1266 assert!(should_filter_dependency("libfoo.so", "package"));
1267 assert!(should_filter_dependency("libfoo.so.1", "package"));
1268 assert!(should_filter_dependency("libfoo.so=1", "package"));
1269 assert!(should_filter_dependency("package", "package")); // self-reference
1270 assert!(!should_filter_dependency("glibc", "package"));
1271 assert!(!should_filter_dependency("firefox", "package"));
1272 }
1273
1274 #[test]
1275 fn test_determine_status_not_installed() {
1276 let installed = HashSet::new();
1277 let provided = HashSet::new();
1278 let upgradable = HashSet::new();
1279
1280 let status = determine_status("nonexistent", "", &installed, &provided, &upgradable);
1281 assert!(matches!(status, DependencyStatus::ToInstall));
1282 }
1283
1284 #[test]
1285 fn test_batch_fetch_official_deps_parsing() {
1286 // Test parsing logic with sample output
1287 let sample_output = "Name : firefox\nDepends On : glibc\n\nName : vim\nDepends On : glibc\n";
1288 let mut package_blocks = Vec::new();
1289 let mut current_block = String::new();
1290 for line in sample_output.lines() {
1291 if line.trim().is_empty() {
1292 if !current_block.is_empty() {
1293 package_blocks.push(current_block.clone());
1294 current_block.clear();
1295 }
1296 } else {
1297 current_block.push_str(line);
1298 current_block.push('\n');
1299 }
1300 }
1301 if !current_block.is_empty() {
1302 package_blocks.push(current_block);
1303 }
1304
1305 assert_eq!(package_blocks.len(), 2);
1306 assert!(package_blocks[0].contains("firefox"));
1307 assert!(package_blocks[1].contains("vim"));
1308 }
1309
1310 #[test]
1311 fn test_dependency_priority() {
1312 assert_eq!(
1313 dependency_priority(&DependencyStatus::Conflict {
1314 reason: "test".to_string(),
1315 }),
1316 0
1317 );
1318 assert_eq!(dependency_priority(&DependencyStatus::Missing), 1);
1319 assert_eq!(dependency_priority(&DependencyStatus::ToInstall), 2);
1320 assert_eq!(
1321 dependency_priority(&DependencyStatus::ToUpgrade {
1322 current: "1.0".to_string(),
1323 required: "2.0".to_string(),
1324 }),
1325 3
1326 );
1327 assert_eq!(
1328 dependency_priority(&DependencyStatus::Installed {
1329 version: "1.0".to_string(),
1330 }),
1331 4
1332 );
1333 }
1334
1335 #[test]
1336 fn test_dependency_resolver_new() {
1337 let resolver = DependencyResolver::new();
1338 // Just verify it can be created
1339 assert!(matches!(resolver.config.max_depth, 0));
1340 }
1341
1342 #[test]
1343 fn test_dependency_resolver_with_config() {
1344 let config = ResolverConfig {
1345 include_optdepends: true,
1346 include_makedepends: true,
1347 include_checkdepends: true,
1348 max_depth: 2,
1349 pkgbuild_cache: None,
1350 check_aur: true,
1351 };
1352 let resolver = DependencyResolver::with_config(config);
1353 assert_eq!(resolver.config.max_depth, 2);
1354 assert!(resolver.config.include_optdepends);
1355 assert!(resolver.config.check_aur);
1356 }
1357
1358 #[test]
1359 fn test_dependency_resolver_resolve_empty() {
1360 let resolver = DependencyResolver::new();
1361 let result = resolver
1362 .resolve(&[])
1363 .expect("resolve should succeed for empty packages");
1364 assert_eq!(result.dependencies.len(), 0);
1365 assert_eq!(result.conflicts.len(), 0);
1366 assert_eq!(result.missing.len(), 0);
1367 }
1368
1369 // Integration tests that require pacman - these are ignored by default
1370 #[test]
1371 #[ignore = "Requires pacman to be available"]
1372 fn test_dependency_resolver_resolve_integration() {
1373 let resolver = DependencyResolver::new();
1374 let packages = vec![PackageRef {
1375 name: "pacman".to_string(),
1376 version: "6.1.0".to_string(),
1377 source: PackageSource::Official {
1378 repo: "core".to_string(),
1379 arch: "x86_64".to_string(),
1380 },
1381 }];
1382
1383 if let Ok(result) = resolver.resolve(&packages) {
1384 // Should find some dependencies for pacman
1385 println!("Found {} dependencies", result.dependencies.len());
1386 assert!(!result.dependencies.is_empty());
1387 }
1388 }
1389}