Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
338 changes: 337 additions & 1 deletion src/conflict.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ use petgraph::{
};

use crate::{
DenseIndex, DependencyProvider, Interner, Requirement, SolvableId, SolverId, StringId,
DenseIndex, DependencyProvider, Interner, NameId, Requirement, SolvableId, SolverId, StringId,
VariableId, VersionSetId,
internal::{id::ClauseId, solver_id::SolvableIdOrRoot},
runtime::AsyncRuntime,
Expand Down Expand Up @@ -395,6 +395,250 @@ impl Conflict {
let graph = self.graph(solver);
DisplayUnsat::new(graph, solver.provider())
}

/// Returns UI-oriented summaries of this conflict.
///
/// Use hints for concise explanations or recovery suggestions. Use
/// [`Conflict::graph`] when the caller needs the full causal graph.
///
/// Equal hints are coalesced. Hints for direct problem requirements come
/// first; the remaining order is unspecified. This reconstructs the
/// conflict graph and may query candidate metadata through the provider.
///
/// IDs are owned by the provider. Resolve them with [`Interner`]:
///
/// ```
/// # use resolvo::{Interner, conflict::ConflictHint};
/// # fn describe<I: Interner>(provider: &I, hint: &ConflictHint<I::NameId, I::SolvableId>) -> String {
/// match hint {
/// ConflictHint::PackageUnavailable { name, .. } => {
/// format!("unknown package: {}", provider.display_name(*name))
/// }
/// ConflictHint::AllCandidatesExcluded { requirement, .. } => {
/// format!("no usable candidate for {}", requirement.display(provider))
/// }
/// _ => "resolution failed".to_string(),
/// }
/// # }
pub fn hints<D: DependencyProvider, RT: AsyncRuntime>(
&self,
solver: &Solver<D, RT>,
) -> Vec<ConflictHint<D::NameId, D::SolvableId>> {
let provider = solver.provider();
let conflict_graph = self.graph(solver);
let graph = &conflict_graph.graph;

let mut hints: Vec<ConflictHint<D::NameId, D::SolvableId>> = Vec::new();
let push = |hints: &mut Vec<ConflictHint<D::NameId, D::SolvableId>>, hint| {
if !hints.contains(&hint) {
hints.push(hint);
}
};

// Incompatible solvables grouped by name. Insertion order keeps the
// output deterministic.
let mut forbidden: Vec<(D::NameId, Vec<D::SolvableId>)> = Vec::new();

for edge in graph.edge_indices() {
let (source, target) = graph.edge_endpoints(edge).unwrap();
match graph[edge] {
ConflictEdge::Requires(requirement) => {
// Requirements with no candidates reach the unresolved sink.
if Some(target) != conflict_graph.unresolved_node {
continue;
}
let required_by = match graph[source] {
ConflictNode::Root => RequiredBy::Problem,
ConflictNode::Solvable(solvable) => RequiredBy::Solvable(solvable),
_ => continue,
};
for version_set in requirement.version_sets(provider) {
let hint = classify_missing(solver, requirement, version_set, required_by);
push(&mut hints, hint);
}
}
ConflictEdge::Conflict(ConflictCause::Constrains(constraint)) => {
if let ConflictNode::Solvable(constrained_by) = graph[source] {
push(
&mut hints,
ConflictHint::Constrained {
constraint,
constrained_by,
},
);
}
}
ConflictEdge::Conflict(ConflictCause::Locked(locked)) => {
push(&mut hints, ConflictHint::Locked { locked });
}
ConflictEdge::Conflict(ConflictCause::ForbidMultipleInstances) => {
for node in [source, target] {
if let ConflictNode::Solvable(solvable) = graph[node] {
let name = provider.solvable_name(solvable);
match forbidden.iter_mut().find(|(n, _)| *n == name) {
Some((_, solvables)) if !solvables.contains(&solvable) => {
solvables.push(solvable)
}
Some(_) => {}
None => forbidden.push((name, vec![solvable])),
}
}
}
}
ConflictEdge::Conflict(ConflictCause::Excluded) => {
// Excluded candidate. Providers keep excluded candidates in
// the candidate list, so the requirement edge points here
// instead of at the unresolved sink. Report it only when
// every matching candidate was excluded.
let ConflictNode::Solvable(candidate) = graph[source] else {
continue;
};
let name = provider.solvable_name(candidate);
for incoming in graph.edges_directed(source, Direction::Incoming) {
let ConflictEdge::Requires(requirement) = *incoming.weight() else {
continue;
};
let required_by = match graph[incoming.source()] {
ConflictNode::Root => RequiredBy::Problem,
ConflictNode::Solvable(solvable) => RequiredBy::Solvable(solvable),
_ => continue,
};
for version_set in requirement.version_sets(provider) {
if provider.version_set_name(version_set) != name {
continue;
}
if let Some(reasons) = all_candidates_excluded(solver, version_set) {
push(
&mut hints,
ConflictHint::AllCandidatesExcluded {
name,
requirement,
version_set,
reasons,
required_by,
},
);
}
}
}
}
}
}

for (name, solvables) in forbidden {
if solvables.len() > 1 {
hints.push(ConflictHint::IncompatibleRequests { name, solvables });
}
}

// Top-level request hints first, otherwise stable.
hints.sort_by_key(|hint| !hint.is_top_level());
hints
}
}

/// Classifies a requirement that has no installable candidates into a
/// [`ConflictHint`], distinguishing an unknown package name from a version that
/// does not match and from candidates that were all excluded.
fn classify_missing<D: DependencyProvider, RT: AsyncRuntime>(
solver: &Solver<D, RT>,
requirement: Requirement,
version_set: VersionSetId,
required_by: RequiredBy<D::SolvableId>,
) -> ConflictHint<D::NameId, D::SolvableId> {
let provider = solver.provider();
let name = provider.version_set_name(version_set);
let candidates = solver
.async_runtime
.block_on(solver.cache.get_or_cache_candidates(name))
.unwrap_or_else(|_| {
unreachable!("the candidates were used during solving, so they are cached")
});

// No solvables for this name at all: the package is unknown.
if candidates.candidates.is_empty() && candidates.excluded.is_empty() {
return ConflictHint::PackageUnavailable {
name,
requirement,
required_by,
};
}

// Matching candidates that were excluded outrank a plain version mismatch.
if !candidates.excluded.is_empty() {
let excluded_ids: Vec<_> = candidates.excluded.iter().map(|&(id, _)| id).collect();
let matching_excluded = solver.async_runtime.block_on(provider.filter_candidates(
&excluded_ids,
version_set,
false,
));
if !matching_excluded.is_empty() {
let reasons = candidates
.excluded
.iter()
.filter(|(id, _)| matching_excluded.contains(id))
.map(|&(_, reason)| reason)
.unique()
.collect();
return ConflictHint::AllCandidatesExcluded {
name,
requirement,
version_set,
reasons,
required_by,
};
}
}

// The package exists but no version matches the requested range.
ConflictHint::NoMatchingVersion {
requirement,
available: candidates.candidates.clone(),
required_by,
}
}

/// Returns the exclusion reasons when every candidate matching the version set
/// was excluded, or `None` when at least one matching candidate was not.
fn all_candidates_excluded<D: DependencyProvider, RT: AsyncRuntime>(
solver: &Solver<D, RT>,
version_set: VersionSetId,
) -> Option<Vec<StringId>> {
let provider = solver.provider();
let name = provider.version_set_name(version_set);
let candidates = solver
.async_runtime
.block_on(solver.cache.get_or_cache_candidates(name))
.unwrap_or_else(|_| {
unreachable!("the candidates were used during solving, so they are cached")
});
if candidates.excluded.is_empty() {
return None;
}
let matching = solver
.async_runtime
.block_on(solver.cache.get_or_cache_matching_candidates(version_set))
.unwrap_or_else(|_| {
unreachable!("the candidates were used during solving, so they are cached")
});
if matching.is_empty() {
return None;
}

let excluded: HashSet<_> = candidates.excluded.iter().map(|&(id, _)| id).collect();
if !matching.iter().all(|id| excluded.contains(id)) {
return None;
}

Some(
candidates
.excluded
.iter()
.filter(|(id, _)| matching.contains(id))
.map(|&(_, reason)| reason)
.unique()
.collect(),
)
}

/// A node in the graph representation of a [`Conflict`]
Expand Down Expand Up @@ -480,6 +724,98 @@ pub enum ConflictCause<S = SolvableId> {
Excluded,
}

/// Origin of a requirement reported by a [`ConflictHint`].
///
/// Match this enum with a fallback arm: new origins may be added.
#[non_exhaustive]
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub enum RequiredBy<S = SolvableId> {
/// A direct requirement of the [`Problem`].
///
/// [`Problem`]: crate::Problem
Problem,
/// A dependency declared by this solvable.
Solvable(S),
}

/// A summary returned by [`Conflict::hints`].
///
/// Each variant describes one user-facing cause. The IDs use the provider's
/// types. Match with a fallback arm: new hint kinds may be added.
#[non_exhaustive]
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub enum ConflictHint<N = NameId, S = SolvableId> {
/// No candidate exists for the requested package name.
PackageUnavailable {
/// Missing package name.
name: N,
/// Requirement that named the package.
requirement: Requirement,
/// Source of the requirement.
required_by: RequiredBy<S>,
},
/// Candidates exist, but none satisfies the requirement.
NoMatchingVersion {
/// Unsatisfied requirement.
requirement: Requirement,
/// All non-excluded candidates for its package.
available: Vec<S>,
/// Source of the requirement.
required_by: RequiredBy<S>,
},
/// Every candidate for one requested version set is excluded.
AllCandidatesExcluded {
/// Excluded package name.
name: N,
/// Requirement containing the failed version set.
requirement: Requirement,
/// Failed alternative within `requirement`.
version_set: VersionSetId,
/// Reasons attached to matching excluded candidates.
reasons: Vec<StringId>,
/// Source of the requirement.
required_by: RequiredBy<S>,
},
/// Incompatible solvables of one package are required together.
IncompatibleRequests {
/// Shared package name.
name: N,
/// Incompatible solvables.
solvables: Vec<S>,
},
/// A solvable's run constraint cannot be satisfied.
Constrained {
/// Version set prohibited by the constraint.
constraint: VersionSetId,
/// Solvable that imposed the constraint.
constrained_by: S,
},
/// A locked solvable conflicts with another requirement.
Locked {
/// Locked solvable.
locked: S,
},
}

impl<N, S> ConflictHint<N, S> {
/// Whether the hint maps directly to a top-level requirement of the request.
fn is_top_level(&self) -> bool {
matches!(
self,
ConflictHint::PackageUnavailable {
required_by: RequiredBy::Problem,
..
} | ConflictHint::NoMatchingVersion {
required_by: RequiredBy::Problem,
..
} | ConflictHint::AllCandidatesExcluded {
required_by: RequiredBy::Problem,
..
}
)
}
}

/// Represents a node that has been merged with others
///
/// Merging is done to simplify error messages, and happens when a group of
Expand Down
Loading