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