//! Dependency resolver and execution planner. //! //! Builds a recursive dependency closure for install/build operations, resolves //! dependencies by exact package name or provided feature, and returns a //! topologically sorted execution plan. use crate::config::Config; use crate::db; use crate::deps; use crate::index::PackageIndex; use crate::package::PackageSpec; use crate::ui; use anyhow::{Context, Result}; use petgraph::algo::toposort; use petgraph::graph::{DiGraph, NodeIndex}; use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::{Path, PathBuf}; #[derive(Debug, Clone)] pub(crate) enum PlanOrigin { Installed, Source { path: PathBuf, local_sibling: bool, }, Binary { repo_name: String, record: Box, }, } #[derive(Debug, Clone, Copy, Eq, PartialEq)] pub(crate) enum PlanAction { SkipInstalled, BuildAndInstall, InstallBinary, } #[derive(Debug, Clone)] pub(crate) struct PlannedStep { pub package: String, pub action: PlanAction, pub origin: PlanOrigin, pub requested_by: Vec, } #[derive(Debug, Clone)] pub(crate) struct ExecutionPlan { pub steps: Vec, } impl ExecutionPlan { pub(crate) fn actionable_steps(&self) -> impl Iterator { self.steps .iter() .filter(|s| !matches!(s.action, PlanAction::SkipInstalled)) } } #[derive(Debug, Clone)] pub(crate) enum InstallTarget { PackageName(String), SpecPath(PathBuf), } #[derive(Debug, Clone)] pub(crate) struct PlannerOptions { pub assume_yes: bool, pub prefer_binary: bool, pub local_sibling_root: Option, pub include_test_deps: bool, pub lib32_only_requested_specs: bool, } #[derive(Debug, Clone)] enum CandidateKind { Source { path: PathBuf, local_sibling: bool, }, Binary { repo_name: String, record: Box, }, } #[derive(Debug, Clone, Copy, Eq, PartialEq)] enum MatchKind { Replaces, Exact, Provides, } #[derive(Debug, Clone)] struct Candidate { package: String, kind: CandidateKind, match_kind: MatchKind, sort_repo_priority: i32, sort_label: String, } #[derive(Debug, Clone)] struct NodeData { step: PlannedStep, } #[derive(Debug, Clone)] struct LocalSpecHit { spec_name: String, real_name: Option, path: PathBuf, provides: Vec, replaces: Vec, } struct Resolver<'a> { config: &'a Config, rootfs: &'a Path, db_path: PathBuf, opts: PlannerOptions, pkg_index: PackageIndex, graph: DiGraph, by_package: HashMap, spec_cache: HashMap, local_sibling_cache: BTreeMap>, stack: Vec, emitted_installed_roots: HashSet, } impl<'a> Resolver<'a> { fn new(config: &'a Config, rootfs: &'a Path, opts: PlannerOptions) -> Self { let db_path = config.installed_db_path(rootfs); let pkg_index = PackageIndex::build_with_repo_dir(Some(config.repo_clone_dir.clone())); Self { config, rootfs, db_path, opts, pkg_index, graph: DiGraph::new(), by_package: HashMap::new(), spec_cache: HashMap::new(), local_sibling_cache: BTreeMap::new(), stack: Vec::new(), emitted_installed_roots: HashSet::new(), } } fn plan_for_install_target(mut self, target: InstallTarget) -> Result { self.resolve_install_target(&target)?; self.finish_plan() } fn plan_for_install_targets(mut self, targets: &[InstallTarget]) -> Result { for target in targets { self.resolve_install_target(target)?; } self.finish_plan() } fn resolve_install_target(&mut self, target: &InstallTarget) -> Result<()> { match target { InstallTarget::PackageName(name) => { if deps::is_dep_satisfied_in_db(name, &self.db_path)? { if self.emitted_installed_roots.insert(name.clone()) { self.add_installed_root_step(name.clone(), "requested package".to_string()); } } else { let root = self.resolve_dep_node(name, None, "requested package".to_string())?; if let Some(root_idx) = root { self.mark_requested_by(root_idx, "requested package".to_string()); } } } InstallTarget::SpecPath(path) => { let root_idx = self.ensure_source_spec_node(path, true, true, "requested spec".to_string())?; self.mark_requested_by(root_idx, "requested spec".to_string()); } } Ok(()) } fn plan_for_deps(mut self, deps_to_install: &[String]) -> Result { for dep in deps_to_install { if deps::is_dep_satisfied_in_db(dep, &self.db_path)? { continue; } if let Some(idx) = self.resolve_dep_node(dep, None, format!("dependency {}", dep))? { self.mark_requested_by(idx, format!("dependency {}", dep)); } } self.finish_plan() } fn finish_plan(self) -> Result { let order = toposort(&self.graph, None) .map_err(|_| anyhow::anyhow!("Dependency cycle detected in plan graph"))?; let mut steps = Vec::with_capacity(order.len()); for idx in order { let node = self .graph .node_weight(idx) .with_context(|| format!("Missing plan node {idx:?}"))?; steps.push(node.step.clone()); } Ok(ExecutionPlan { steps }) } fn add_installed_root_step(&mut self, package: String, requested_by: String) { let step = PlannedStep { package, action: PlanAction::SkipInstalled, origin: PlanOrigin::Installed, requested_by: vec![requested_by], }; self.graph.add_node(NodeData { step }); } fn mark_requested_by(&mut self, idx: NodeIndex, reason: String) { if let Some(node) = self.graph.node_weight_mut(idx) && !node.step.requested_by.contains(&reason) { node.step.requested_by.push(reason); } } fn ensure_source_spec_node( &mut self, path: &Path, allow_local_sibling_fallback: bool, local_sibling: bool, requested_by: String, ) -> Result { let include_test_deps = self.opts.include_test_deps; let lib32_only = self.opts.lib32_only_requested_specs && requested_by.starts_with("requested "); let (package_name, deps_needed) = { let spec = self.load_spec(path)?; ( spec.package.name.clone(), source_deps_for_install(spec, include_test_deps, lib32_only), ) }; if let Some(&idx) = self.by_package.get(&package_name) { self.bail_if_active_cycle(&package_name)?; self.mark_requested_by(idx, requested_by); return Ok(idx); } self.push_stack(&package_name)?; let idx = self.graph.add_node(NodeData { step: PlannedStep { package: package_name.clone(), action: PlanAction::BuildAndInstall, origin: PlanOrigin::Source { path: path.to_path_buf(), local_sibling, }, requested_by: vec![requested_by], }, }); self.by_package.insert(package_name.clone(), idx); for dep in deps_needed { if let Some(dep_idx) = self.resolve_dep_node( &dep, allow_local_sibling_fallback.then_some(path), format!("{} needs {}", package_name, dep), )? { self.graph.add_edge(dep_idx, idx, ()); } } self.pop_stack(&package_name); Ok(idx) } fn resolve_dep_node( &mut self, dep: &str, requester_spec_path: Option<&Path>, requested_by: String, ) -> Result> { if deps::is_dep_satisfied_in_db(dep, &self.db_path)? { return Ok(None); } let dep_name = deps::dep_name(dep); let candidates = self.collect_candidates(dep_name, requester_spec_path)?; if candidates.is_empty() { if let Some(spec_path) = requester_spec_path { anyhow::bail!( "Could not resolve dependency '{}' (from {}). Checked binary repos, source repos, and local sibling specs", dep, spec_path.display() ); } anyhow::bail!( "Could not resolve dependency '{}'. Checked binary repos and source repos", dep ); } let chosen = self.choose_candidate(dep, &candidates)?; let idx = match chosen.kind { CandidateKind::Source { ref path, local_sibling, } => self.ensure_source_spec_node(path, true, local_sibling, requested_by)?, CandidateKind::Binary { ref repo_name, ref record, } => self.ensure_binary_node(repo_name, (**record).clone(), requested_by)?, }; Ok(Some(idx)) } fn ensure_binary_node( &mut self, repo_name: &str, record: db::repo::BinaryRepoPackageRecord, requested_by: String, ) -> Result { if let Some(&idx) = self.by_package.get(&record.name) { self.mark_requested_by(idx, requested_by); return Ok(idx); } self.push_stack(&record.name)?; let idx = self.graph.add_node(NodeData { step: PlannedStep { package: record.name.clone(), action: PlanAction::InstallBinary, origin: PlanOrigin::Binary { repo_name: repo_name.to_string(), record: Box::new(record.clone()), }, requested_by: vec![requested_by], }, }); self.by_package.insert(record.name.clone(), idx); for dep in &record.runtime_dependencies { if let Some(dep_idx) = self.resolve_dep_node(dep, None, format!("{} needs {}", record.name, dep))? { self.add_dependency_edge(dep_idx, idx, &record.name)?; } } self.pop_stack(&record.name); Ok(idx) } fn choose_candidate(&self, dep: &str, candidates: &[Candidate]) -> Result { let mut sorted = prune_replacement_fallback_candidates(dedupe_candidate_packages( sort_candidates(candidates, self.opts.prefer_binary), )); if sorted.len() == 1 { return Ok(sorted.remove(0)); } if self.opts.assume_yes { let chosen = sorted.remove(0); ui::info(format!( "Multiple providers matched '{}' - using {} ({}) due to --yes", dep, chosen.package, chosen.sort_label )); return Ok(chosen); } let options: Vec = sorted.iter().map(format_candidate_label).collect(); let prompt = format!("Multiple packages satisfy '{}'. Choose one", dep); let choice = ui::prompt_select_index(&prompt, &options, 0)?; Ok(sorted.remove(choice)) } fn collect_candidates( &mut self, dep_name: &str, requester_spec_path: Option<&Path>, ) -> Result> { let mut out = Vec::new(); let mut seen = HashSet::::new(); // Local sibling fallback (e.g. ../foo/*.toml when building from a local tree). // Prefer these candidates before probing configured repos so local development // remains deterministic and does not block on external repository I/O. let local_sibling_root = requester_spec_path .and_then(|p| p.parent()) .and_then(|p| p.parent()) .map(Path::to_path_buf) .or_else(|| self.opts.local_sibling_root.clone()); if let Some(root) = local_sibling_root { for hit in self.local_sibling_hits(&root)? { let exact = hit.spec_name.eq_ignore_ascii_case(dep_name) || hit .real_name .as_deref() .is_some_and(|name| name.eq_ignore_ascii_case(dep_name)); let replaces = hit .replaces .iter() .any(|p| p.eq_ignore_ascii_case(dep_name)); let provides = hit .provides .iter() .any(|p| p.eq_ignore_ascii_case(dep_name)); if !(exact || provides || replaces) { continue; } let key = format!("src:{}", hit.path.display()); if !seen.insert(key) { continue; } out.push(Candidate { package: hit.spec_name.clone(), kind: CandidateKind::Source { path: hit.path.clone(), local_sibling: true, }, match_kind: if replaces { MatchKind::Replaces } else if exact { MatchKind::Exact } else { MatchKind::Provides }, sort_repo_priority: -10, sort_label: "source:local-sibling".to_string(), }); } } // Binary repos let host_arch = std::env::consts::ARCH; let mut binary_repos: Vec<_> = self .config .binary_repos .iter() .filter(|(_, repo)| repo.enabled && repo.supports_arch(host_arch)) .collect(); binary_repos.sort_by(|a, b| a.1.priority.cmp(&b.1.priority).then_with(|| a.0.cmp(b.0))); for (repo_name, repo_cfg) in binary_repos { match db::repo::find_binary_repo_packages( repo_name, repo_cfg, self.rootfs, &self.config.package_cache_dir, dep_name, ) { Ok(records) => { for rec in records { let match_kind = if rec.name.eq_ignore_ascii_case(dep_name) { MatchKind::Exact } else if rec .replaces .iter() .any(|replacement| replacement.eq_ignore_ascii_case(dep_name)) { MatchKind::Replaces } else { MatchKind::Provides }; let key = format!("bin:{}:{}", repo_name, rec.name); if !seen.insert(key) { continue; } out.push(Candidate { package: rec.name.clone(), kind: CandidateKind::Binary { repo_name: repo_name.clone(), record: Box::new(rec.clone()), }, match_kind, sort_repo_priority: repo_cfg.priority, sort_label: format!("binary:{}", repo_name), }); } } Err(e) => crate::log_warn!("Binary repo '{}': {}", repo_name, e), } } // Global source index if let Some(path) = self.pkg_index.find(dep_name) { let spec = self.load_spec(&path)?; let match_kind = if spec .alternatives .replaces .iter() .any(|replacement| replacement.eq_ignore_ascii_case(dep_name)) { MatchKind::Replaces } else if spec.package.name.eq_ignore_ascii_case(dep_name) { MatchKind::Exact } else { MatchKind::Provides }; let local_sibling = false; let key = format!("src:{}", path.display()); if seen.insert(key) { out.push(Candidate { package: spec.package.name.clone(), kind: CandidateKind::Source { path: path.clone(), local_sibling, }, match_kind, sort_repo_priority: 0, sort_label: format!("source:{}", source_label_for_path(self.config, &path)), }); } } // Additional source providers from index (for provider prompt) for path in self.pkg_index.find_replacements(dep_name) { let spec = self.load_spec(&path)?; let key = format!("src:{}", path.display()); if !seen.insert(key) { continue; } out.push(Candidate { package: spec.package.name.clone(), kind: CandidateKind::Source { path: path.clone(), local_sibling: false, }, match_kind: MatchKind::Replaces, sort_repo_priority: 0, sort_label: format!("source:{}", source_label_for_path(self.config, &path)), }); } // Additional source providers from index (for provider prompt) for path in self.pkg_index.find_providers(dep_name) { let spec = self.load_spec(&path)?; let key = format!("src:{}", path.display()); if !seen.insert(key) { continue; } out.push(Candidate { package: spec.package.name.clone(), kind: CandidateKind::Source { path: path.clone(), local_sibling: false, }, match_kind: if spec.package.name.eq_ignore_ascii_case(dep_name) { MatchKind::Exact } else { MatchKind::Provides }, sort_repo_priority: 0, sort_label: format!("source:{}", source_label_for_path(self.config, &path)), }); } Ok(out) } fn load_spec(&mut self, path: &Path) -> Result<&PackageSpec> { if !self.spec_cache.contains_key(path) { let mut spec = PackageSpec::from_file(path) .with_context(|| format!("Failed to parse spec {}", path.display()))?; spec.apply_config(self.config); self.spec_cache.insert(path.to_path_buf(), spec); } self.spec_cache .get(path) .with_context(|| format!("Spec cache entry missing for {}", path.display())) } fn local_sibling_hits(&mut self, root: &Path) -> Result<&[LocalSpecHit]> { if !self.local_sibling_cache.contains_key(root) { let mut hits = Vec::new(); for entry in walkdir::WalkDir::new(root).min_depth(1).max_depth(4) { let entry = match entry { Ok(e) => e, Err(_) => continue, }; let path = entry.path(); if !entry.file_type().is_file() { continue; } if path.extension().is_none_or(|ext| ext != "toml") { continue; } if let Ok(mut spec) = PackageSpec::from_file(path) { spec.apply_config(self.config); hits.push(LocalSpecHit { spec_name: spec.package.name.clone(), real_name: spec.package.real_name.clone(), path: path.to_path_buf(), provides: spec.alternatives.provides.clone(), replaces: spec.alternatives.replaces.clone(), }); } } self.local_sibling_cache.insert(root.to_path_buf(), hits); } Ok(self .local_sibling_cache .get(root) .map(Vec::as_slice) .unwrap_or(&[])) } fn push_stack(&mut self, pkg: &str) -> Result<()> { self.bail_if_active_cycle(pkg)?; self.stack.push(pkg.to_string()); Ok(()) } fn pop_stack(&mut self, pkg: &str) { if matches!(self.stack.last(), Some(last) if last == pkg) { self.stack.pop(); } } fn add_dependency_edge( &mut self, dep_idx: NodeIndex, dependent_idx: NodeIndex, dependent_package: &str, ) -> Result<()> { let dep_package = self .graph .node_weight(dep_idx) .with_context(|| format!("Missing dependency plan node {dep_idx:?}"))? .step .package .clone(); if self.is_active_stack_member(&dep_package) { crate::log_warn!( "dependency cycle detected: {} will be installed before its {} dependency", dependent_package, dep_package ); return Ok(()); } self.graph.add_edge(dep_idx, dependent_idx, ()); Ok(()) } fn bail_if_active_cycle(&self, pkg: &str) -> Result<()> { if let Some(position) = self.stack.iter().position(|entry| entry == pkg) { let mut chain = self.stack[position..].join(" -> "); if !chain.is_empty() { chain.push_str(" -> "); } chain.push_str(pkg); anyhow::bail!("Dependency cycle detected: {}", chain); } Ok(()) } fn is_active_stack_member(&self, pkg: &str) -> bool { self.stack.iter().any(|entry| entry == pkg) } } fn source_deps_for_install( spec: &PackageSpec, include_test_deps: bool, lib32_only: bool, ) -> Vec { let mut deps_all = Vec::new(); let lib32_only = lib32_only || spec.builds_only_lib32_output(); let include_lib32 = lib32_only || spec.builds_lib32_output(); let skip_automatic_tests = spec.should_skip_automatic_tests() || lib32_only; let local_provides = spec.local_dependency_provides_for_selection(!lib32_only, include_lib32); if !lib32_only && !spec.is_metapackage() { for dep in &spec.dependencies.build { push_unique(&mut deps_all, dep.clone()); } } if include_lib32 && !spec.is_metapackage() { for dep in &spec.lib32_dependencies().build { push_unique(&mut deps_all, dep.clone()); } } if !lib32_only { for dep in &spec.dependencies.runtime { if !local_provides.contains(deps::dep_name(dep)) { push_unique(&mut deps_all, dep.clone()); } } for out in spec.outputs() { let deps = spec.dependencies_for_output(&out.name); for dep in deps.runtime { if !local_provides.contains(deps::dep_name(&dep)) { push_unique(&mut deps_all, dep); } } } } if include_lib32 { for dep in &spec.lib32_dependencies().runtime { if !local_provides.contains(deps::dep_name(dep)) { push_unique(&mut deps_all, dep.clone()); } } } if include_test_deps && !skip_automatic_tests { if !lib32_only { for dep in &spec.dependencies.test { push_unique(&mut deps_all, dep.clone()); } } if include_lib32 { for dep in &spec.lib32_dependencies().test { push_unique(&mut deps_all, dep.clone()); } } } deps_all } fn push_unique(v: &mut Vec, item: String) { if !v.contains(&item) { v.push(item); } } fn source_label_for_path(config: &Config, path: &Path) -> String { if let Ok(rel) = path.strip_prefix(&config.repo_clone_dir) && let Some(repo) = rel.components().next() { return repo.as_os_str().to_string_lossy().into_owned(); } "local".to_string() } fn format_candidate_label(c: &Candidate) -> String { let match_label = match c.match_kind { MatchKind::Replaces => "replaces", MatchKind::Exact => "exact", MatchKind::Provides => "provides", }; match &c.kind { CandidateKind::Source { path, local_sibling, } => format!( "{} [{}] {} {}", c.package, if *local_sibling { "source:local-sibling" } else { &c.sort_label }, match_label, path.display() ), CandidateKind::Binary { repo_name, record } => format!( "{} [binary:{}] {} {}-{} size={} file={}", c.package, repo_name, match_label, record.version, record.revision, record.size, record.filename ), } } fn sort_candidates(candidates: &[Candidate], prefer_binary: bool) -> Vec { let mut sorted = candidates.to_vec(); sorted.sort_by(|a, b| { candidate_sort_key(a, prefer_binary).cmp(&candidate_sort_key(b, prefer_binary)) }); sorted } fn dedupe_candidate_packages(candidates: Vec) -> Vec { let mut out = Vec::with_capacity(candidates.len()); let mut seen = HashSet::new(); for candidate in candidates { if seen.insert(candidate.package.to_ascii_lowercase()) { out.push(candidate); } } out } fn prune_replacement_fallback_candidates(candidates: Vec) -> Vec { if candidates .iter() .any(|candidate| candidate.match_kind != MatchKind::Replaces) { candidates .into_iter() .filter(|candidate| candidate.match_kind != MatchKind::Replaces) .collect() } else { candidates } } fn candidate_sort_key(c: &Candidate, prefer_binary: bool) -> (i32, i32, i32, String, String) { let is_binary = matches!(c.kind, CandidateKind::Binary { .. }); let kind_rank = match (prefer_binary, is_binary) { (true, true) => 0, (true, false) => 1, (false, false) => 0, (false, true) => 1, }; let match_rank = match c.match_kind { MatchKind::Exact => 0, MatchKind::Provides => 1, MatchKind::Replaces => 2, }; ( kind_rank, match_rank, c.sort_repo_priority, c.package.clone(), c.sort_label.clone(), ) } pub(crate) fn build_install_plan( config: &Config, rootfs: &Path, target: InstallTarget, opts: PlannerOptions, ) -> Result { Resolver::new(config, rootfs, opts).plan_for_install_target(target) } pub(crate) fn build_install_plan_for_targets( config: &Config, rootfs: &Path, targets: &[InstallTarget], opts: PlannerOptions, ) -> Result { Resolver::new(config, rootfs, opts).plan_for_install_targets(targets) } pub(crate) fn build_dependency_install_plan( config: &Config, rootfs: &Path, deps_to_install: &[String], opts: PlannerOptions, ) -> Result { Resolver::new(config, rootfs, opts).plan_for_deps(deps_to_install) } #[cfg(test)] mod tests { use super::{ Candidate, CandidateKind, MatchKind, PlannerOptions, build_dependency_install_plan, dedupe_candidate_packages, prune_replacement_fallback_candidates, sort_candidates, source_deps_for_install, }; use crate::config::Config; use crate::db; use crate::package::{ Alternatives, Build, BuildFlags, BuildType, Dependencies, PackageInfo, PackageSpec, Source, }; use std::collections::BTreeMap; use std::fs; use std::path::{Path, PathBuf}; fn mk_spec() -> PackageSpec { PackageSpec { package: PackageInfo { name: "foo".into(), real_name: None, version: "1.0".into(), revision: 1, description: "d".into(), homepage: "h".into(), abi_breaking: false, license: vec!["MIT".into()], }, packages: vec![PackageInfo { name: "foo-libs".into(), real_name: None, version: "1.0".into(), revision: 1, description: "d".into(), homepage: "h".into(), abi_breaking: false, license: vec!["MIT".into()], }], alternatives: Alternatives::default(), manual_sources: Vec::new(), source: vec![Source { url: "https://example.test/foo.tar.gz".into(), sha256: "skip".into(), extract_dir: "foo".into(), patches: Vec::new(), post_extract: Vec::new(), cherry_pick: Vec::new(), }], build: Build { build_type: BuildType::Autotools, flags: BuildFlags::default(), }, dependencies: Dependencies { build: vec!["make".into()], runtime: vec!["foo-libs".into(), "zlib".into()], test: vec!["bats".into()], optional: vec!["docs-viewer".into()], groups: Vec::new(), lib32: None, }, package_alternatives: BTreeMap::from([( "foo-libs".into(), Alternatives { provides: vec!["libfoo".into()], conflicts: Vec::new(), replaces: Vec::new(), lib32: None, }, )]), package_dependencies: BTreeMap::from([( "foo".into(), Dependencies { build: Vec::new(), runtime: vec!["foo-libs".into(), "libfoo".into(), "openssl".into()], test: Vec::new(), optional: Vec::new(), groups: Vec::new(), lib32: None, }, )]), spec_dir: PathBuf::from("."), } } fn mk_installed_spec(name: &str, version: &str) -> PackageSpec { let mut spec = mk_spec(); spec.package.name = name.to_string(); spec.package.version = version.to_string(); spec } fn mk_binary_candidate(name: &str, repo_name: &str, priority: i32) -> Candidate { Candidate { package: name.to_string(), kind: CandidateKind::Binary { repo_name: repo_name.to_string(), record: Box::new(db::repo::BinaryRepoPackageRecord { repo_name: repo_name.to_string(), name: name.to_string(), real_name: None, version: "1.0.0".to_string(), revision: 1, abi_breaking: false, completed_at: None, filename: format!("{name}-1.0.0-1-x86_64.depot.pkg.tar.zst"), size: 1024, sha256: "sha256".to_string(), sha512: "sha512".to_string(), description: None, homepage: None, license: None, provides: Vec::new(), conflicts: Vec::new(), replaces: Vec::new(), runtime_dependencies: Vec::new(), optional_dependencies: Vec::new(), groups: Vec::new(), }), }, match_kind: MatchKind::Exact, sort_repo_priority: priority, sort_label: format!("binary:{repo_name}"), } } fn mk_source_candidate(name: &str, path: &Path, local_sibling: bool) -> Candidate { Candidate { package: name.to_string(), kind: CandidateKind::Source { path: path.to_path_buf(), local_sibling, }, match_kind: MatchKind::Exact, sort_repo_priority: if local_sibling { -10 } else { 0 }, sort_label: if local_sibling { "source:local-sibling".to_string() } else { "source:local".to_string() }, } } #[test] fn source_deps_for_install_excludes_local_runtime_outputs_and_provides() { let spec = mk_spec(); let deps = source_deps_for_install(&spec, false, false); assert!(deps.contains(&"make".to_string())); assert!(deps.contains(&"zlib".to_string())); assert!(deps.contains(&"openssl".to_string())); assert!(!deps.contains(&"foo-libs".to_string())); assert!(!deps.contains(&"libfoo".to_string())); } #[test] fn source_deps_for_install_does_not_include_test_deps() { let spec = mk_spec(); let deps = source_deps_for_install(&spec, false, false); assert!(!deps.contains(&"bats".to_string())); } #[test] fn source_deps_for_install_includes_test_deps_when_enabled() { let spec = mk_spec(); let deps = source_deps_for_install(&spec, true, false); assert!(deps.contains(&"bats".to_string())); } #[test] fn source_deps_for_install_uses_lib32_only_dependencies_when_requested() { let mut spec = mk_spec(); spec.dependencies.lib32 = Some(crate::package::DependencyGroup { build: vec!["gcc-multilib".into()], runtime: vec!["lib32-zlib".into()], test: vec!["lib32-bats".into()], optional: Vec::new(), groups: Vec::new(), }); let deps = source_deps_for_install(&spec, true, true); assert!(deps.contains(&"gcc-multilib".to_string())); assert!(deps.contains(&"lib32-zlib".to_string())); assert!(!deps.contains(&"lib32-bats".to_string())); assert!(!deps.contains(&"make".to_string())); assert!(!deps.contains(&"zlib".to_string())); assert!(!deps.contains(&"bats".to_string())); } #[test] fn source_deps_for_install_uses_lib32_only_dependencies_from_spec_flag() { let mut spec = mk_spec(); spec.build.flags.lib32_only = true; spec.dependencies.lib32 = Some(crate::package::DependencyGroup { build: vec!["gcc-multilib".into()], runtime: vec!["lib32-zlib".into()], test: vec!["lib32-bats".into()], optional: Vec::new(), groups: Vec::new(), }); let deps = source_deps_for_install(&spec, true, false); assert!(deps.contains(&"gcc-multilib".to_string())); assert!(deps.contains(&"lib32-zlib".to_string())); assert!(!deps.contains(&"lib32-bats".to_string())); assert!(!deps.contains(&"make".to_string())); assert!(!deps.contains(&"zlib".to_string())); assert!(!deps.contains(&"bats".to_string())); } #[test] fn candidate_dedup_keeps_highest_priority_origin_for_same_package() { let candidates = vec![ mk_source_candidate("meson", Path::new("packages/core/meson/meson.toml"), false), mk_binary_candidate("meson", "core", 0), mk_source_candidate( "meson", Path::new("../packages/core/meson/meson.toml"), true, ), ]; let deduped = dedupe_candidate_packages(sort_candidates(&candidates, true)); assert_eq!(deduped.len(), 1); assert!(matches!(deduped[0].kind, CandidateKind::Binary { .. })); } #[test] fn candidate_dedup_uses_local_origin_when_binaries_are_not_preferred() { let candidates = vec![ mk_binary_candidate("ninja", "core", 0), mk_source_candidate("ninja", Path::new("packages/core/ninja/ninja.toml"), false), ]; let deduped = dedupe_candidate_packages(sort_candidates(&candidates, false)); assert_eq!(deduped.len(), 1); assert!(matches!(deduped[0].kind, CandidateKind::Source { .. })); } #[test] fn replacement_candidates_are_pruned_when_direct_matches_exist() { let mut replacement = mk_binary_candidate("vx", "core", 0); replacement.match_kind = MatchKind::Replaces; let mut exact = mk_binary_candidate("patch", "core", 0); exact.match_kind = MatchKind::Exact; let mut provides = mk_binary_candidate("busybox", "core", 0); provides.match_kind = MatchKind::Provides; let pruned = prune_replacement_fallback_candidates(vec![replacement, exact, provides]); assert_eq!(pruned.len(), 2); assert!( pruned .iter() .all(|candidate| candidate.match_kind != MatchKind::Replaces) ); } #[test] fn replacement_candidates_remain_when_they_are_the_only_matches() { let mut replacement = mk_binary_candidate("vx", "core", 0); replacement.match_kind = MatchKind::Replaces; let pruned = prune_replacement_fallback_candidates(vec![replacement]); assert_eq!(pruned.len(), 1); assert!(matches!(pruned[0].match_kind, MatchKind::Replaces)); assert_eq!(pruned[0].package, "vx"); } #[test] fn build_dependency_install_plan_skips_installed_dependency() { let rootfs = tempfile::tempdir().unwrap(); let config = Config::for_rootfs(rootfs.path()); let db_path = config.db_dir.join("packages.db"); let destdir = rootfs.path().join("dest"); std::fs::create_dir_all(&destdir).unwrap(); db::register_package(&db_path, &mk_installed_spec("meson", "1.0.0"), &destdir).unwrap(); let plan = build_dependency_install_plan( &config, rootfs.path(), &["meson".to_string()], PlannerOptions { assume_yes: false, prefer_binary: true, local_sibling_root: None, include_test_deps: false, lib32_only_requested_specs: false, }, ) .unwrap(); assert!(plan.steps.is_empty()); assert!(plan.actionable_steps().next().is_none()); } #[test] fn build_dependency_install_plan_reports_source_cycle_chain() { let rootfs = tempfile::tempdir().unwrap(); let repo_root = tempfile::tempdir().unwrap(); let config = Config::for_rootfs(rootfs.path()); let alpha_dir = repo_root.path().join("alpha"); let beta_dir = repo_root.path().join("beta"); fs::create_dir_all(&alpha_dir).unwrap(); fs::create_dir_all(&beta_dir).unwrap(); fs::write( alpha_dir.join("alpha.toml"), r#" [build] type = "meta" [dependencies] runtime = ["beta"] [package] description = "alpha" homepage = "https://example.test/alpha" license = "MIT" name = "alpha" version = "1.0.0" "#, ) .unwrap(); fs::write( beta_dir.join("beta.toml"), r#" [build] type = "meta" [dependencies] runtime = ["alpha"] [package] description = "beta" homepage = "https://example.test/beta" license = "MIT" name = "beta" version = "1.0.0" "#, ) .unwrap(); let err = build_dependency_install_plan( &config, rootfs.path(), &["alpha".to_string()], PlannerOptions { assume_yes: false, prefer_binary: false, local_sibling_root: Some(repo_root.path().to_path_buf()), include_test_deps: false, lib32_only_requested_specs: false, }, ) .unwrap_err(); assert_eq!( err.to_string(), "Dependency cycle detected: alpha -> beta -> alpha" ); } #[test] fn build_dependency_install_plan_matches_local_sibling_real_name() { let rootfs = tempfile::tempdir().unwrap(); let repo_root = tempfile::tempdir().unwrap(); let config = Config::for_rootfs(rootfs.path()); let libressl_dir = repo_root.path().join("libressl43"); fs::create_dir_all(&libressl_dir).unwrap(); fs::write( libressl_dir.join("libressl43.toml"), r#" [build] type = "meta" [package] description = "LibreSSL" homepage = "https://www.libressl.org/" license = "ISC" name = "libressl43" real_name = "libressl" version = "4.3.2" "#, ) .unwrap(); let plan = build_dependency_install_plan( &config, rootfs.path(), &["libressl".to_string()], PlannerOptions { assume_yes: false, prefer_binary: false, local_sibling_root: Some(repo_root.path().to_path_buf()), include_test_deps: false, lib32_only_requested_specs: false, }, ) .unwrap(); assert_eq!(plan.steps.len(), 1); assert_eq!(plan.steps[0].package, "libressl43"); assert!(matches!( plan.steps[0].origin, super::PlanOrigin::Source { local_sibling: true, .. } )); } #[test] fn add_dependency_edge_skips_active_binary_cycle_back_edge() { let rootfs = tempfile::tempdir().unwrap(); let config = Config::for_rootfs(rootfs.path()); let mut resolver = super::Resolver::new( &config, rootfs.path(), PlannerOptions { assume_yes: false, prefer_binary: true, local_sibling_root: None, include_test_deps: false, lib32_only_requested_specs: false, }, ); let freetype2 = resolver.graph.add_node(super::NodeData { step: super::PlannedStep { package: "freetype2".into(), action: super::PlanAction::InstallBinary, origin: super::PlanOrigin::Installed, requested_by: vec!["requested".into()], }, }); let harfbuzz = resolver.graph.add_node(super::NodeData { step: super::PlannedStep { package: "harfbuzz".into(), action: super::PlanAction::InstallBinary, origin: super::PlanOrigin::Installed, requested_by: vec!["requested".into()], }, }); resolver.stack = vec!["freetype2".into(), "harfbuzz".into()]; resolver .add_dependency_edge(freetype2, harfbuzz, "harfbuzz") .unwrap(); assert_eq!(resolver.graph.edge_count(), 0); resolver.stack.clear(); resolver .add_dependency_edge(freetype2, harfbuzz, "harfbuzz") .unwrap(); assert_eq!(resolver.graph.edge_count(), 1); } }