1320 lines
43 KiB
Rust
1320 lines
43 KiB
Rust
//! 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<db::repo::BinaryRepoPackageRecord>,
|
|
},
|
|
}
|
|
|
|
#[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<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub(crate) struct ExecutionPlan {
|
|
pub steps: Vec<PlannedStep>,
|
|
}
|
|
|
|
impl ExecutionPlan {
|
|
pub(crate) fn actionable_steps(&self) -> impl Iterator<Item = &PlannedStep> {
|
|
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<PathBuf>,
|
|
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<db::repo::BinaryRepoPackageRecord>,
|
|
},
|
|
}
|
|
|
|
#[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<String>,
|
|
path: PathBuf,
|
|
provides: Vec<String>,
|
|
replaces: Vec<String>,
|
|
}
|
|
|
|
struct Resolver<'a> {
|
|
config: &'a Config,
|
|
rootfs: &'a Path,
|
|
db_path: PathBuf,
|
|
opts: PlannerOptions,
|
|
pkg_index: PackageIndex,
|
|
graph: DiGraph<NodeData, ()>,
|
|
by_package: HashMap<String, NodeIndex>,
|
|
spec_cache: HashMap<PathBuf, PackageSpec>,
|
|
local_sibling_cache: BTreeMap<PathBuf, Vec<LocalSpecHit>>,
|
|
stack: Vec<String>,
|
|
emitted_installed_roots: HashSet<String>,
|
|
}
|
|
|
|
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<ExecutionPlan> {
|
|
self.resolve_install_target(&target)?;
|
|
self.finish_plan()
|
|
}
|
|
|
|
fn plan_for_install_targets(mut self, targets: &[InstallTarget]) -> Result<ExecutionPlan> {
|
|
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<ExecutionPlan> {
|
|
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<ExecutionPlan> {
|
|
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<NodeIndex> {
|
|
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<Option<NodeIndex>> {
|
|
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<NodeIndex> {
|
|
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<Candidate> {
|
|
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<String> = 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<Vec<Candidate>> {
|
|
let mut out = Vec::new();
|
|
let mut seen = HashSet::<String>::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<String> {
|
|
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<String>, 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<Candidate> {
|
|
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<Candidate>) -> Vec<Candidate> {
|
|
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<Candidate>) -> Vec<Candidate> {
|
|
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<ExecutionPlan> {
|
|
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<ExecutionPlan> {
|
|
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<ExecutionPlan> {
|
|
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);
|
|
}
|
|
}
|