diff --git a/src/conflict.rs b/src/conflict.rs index 7b58afb..feb5cac 100644 --- a/src/conflict.rs +++ b/src/conflict.rs @@ -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, @@ -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(provider: &I, hint: &ConflictHint) -> 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( + &self, + solver: &Solver, + ) -> Vec> { + let provider = solver.provider(); + let conflict_graph = self.graph(solver); + let graph = &conflict_graph.graph; + + let mut hints: Vec> = Vec::new(); + let push = |hints: &mut Vec>, 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)> = 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( + solver: &Solver, + requirement: Requirement, + version_set: VersionSetId, + required_by: RequiredBy, +) -> ConflictHint { + 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( + solver: &Solver, + version_set: VersionSetId, +) -> Option> { + 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`] @@ -480,6 +724,98 @@ pub enum ConflictCause { 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 { + /// 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 { + /// 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, + }, + /// Candidates exist, but none satisfies the requirement. + NoMatchingVersion { + /// Unsatisfied requirement. + requirement: Requirement, + /// All non-excluded candidates for its package. + available: Vec, + /// Source of the requirement. + required_by: RequiredBy, + }, + /// 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, + /// Source of the requirement. + required_by: RequiredBy, + }, + /// Incompatible solvables of one package are required together. + IncompatibleRequests { + /// Shared package name. + name: N, + /// Incompatible solvables. + solvables: Vec, + }, + /// 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 ConflictHint { + /// 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 diff --git a/tests/solver/hints.rs b/tests/solver/hints.rs new file mode 100644 index 0000000..36738fe --- /dev/null +++ b/tests/solver/hints.rs @@ -0,0 +1,211 @@ +//! Tests for the structured [`ConflictHint`]s returned by `Conflict::hints`. +//! +//! Each hint is rendered to a readable line so the snapshot stays meaningful and +//! doubles as an example of resolving the interned ids through the `Interner`. + +use itertools::Itertools; +use resolvo::{ + Interner, Problem, Solver, UnsolvableOrCancelled, + conflict::{ConflictHint, RequiredBy}, +}; + +use crate::bundle_box::BundleBoxProvider; + +fn format_required_by(provider: &BundleBoxProvider, required_by: &RequiredBy) -> String { + match required_by { + RequiredBy::Problem => "requested by the user".to_string(), + RequiredBy::Solvable(solvable) => { + format!("required by {}", provider.display_solvable(*solvable)) + } + _ => "required by an unsupported source".to_string(), + } +} + +fn format_hint(provider: &BundleBoxProvider, hint: &ConflictHint) -> String { + match hint { + ConflictHint::PackageUnavailable { + name, required_by, .. + } => format!( + "Package '{}' is not available, {}.", + provider.display_name(*name), + format_required_by(provider, required_by), + ), + ConflictHint::NoMatchingVersion { + requirement, + available, + required_by, + } => format!( + "No version matches '{}', {}. Available: {}.", + requirement.display(provider), + format_required_by(provider, required_by), + provider.display_merged_solvables(available), + ), + ConflictHint::AllCandidatesExcluded { + requirement, + reasons, + required_by, + .. + } => format!( + "Every candidate matching '{}' is excluded, {}: {}.", + requirement.display(provider), + format_required_by(provider, required_by), + reasons + .iter() + .map(|&reason| provider.display_string(reason).to_string()) + .format(", "), + ), + ConflictHint::IncompatibleRequests { name, solvables } => format!( + "Package '{}' is required in incompatible versions: {}.", + provider.display_name(*name), + provider.display_merged_solvables(solvables), + ), + ConflictHint::Constrained { + constraint, + constrained_by, + } => format!( + "{} constrains '{} {}', which cannot be satisfied.", + provider.display_solvable(*constrained_by), + provider.display_name(provider.version_set_name(*constraint)), + provider.display_version_set(*constraint), + ), + ConflictHint::Locked { locked } => format!( + "{} is locked, but another version is required.", + provider.display_solvable(*locked) + ), + _ => "An unsupported conflict hint was returned.".to_string(), + } +} + +/// Solve the problem (expecting it to be unsat) and return the resulting hints +/// rendered to a readable, one-per-line string. +fn solve_unsat_hints(mut provider: BundleBoxProvider, specs: &[&str]) -> String { + let requirements = provider.requirements(specs); + let mut solver = Solver::new(provider); + let problem = Problem::new().requirements(requirements); + match solver.solve(problem) { + Ok(_) => panic!("expected unsat, but a solution was found"), + Err(UnsolvableOrCancelled::Unsolvable(conflict)) => { + let hints = conflict.hints(&solver); + let provider = solver.provider(); + hints + .iter() + .map(|hint| format_hint(provider, hint)) + .format("\n") + .to_string() + } + Err(UnsolvableOrCancelled::Cancelled(reason)) => *reason.downcast().unwrap(), + } +} + +/// A top-level requested package that does not exist at all. +#[test] +fn test_package_unavailable_top_level() { + let provider = BundleBoxProvider::from_packages(&[("asdf", 1, vec![])]); + insta::assert_snapshot!(solve_unsat_hints(provider, &["does-not-exist"]), @"Package 'does-not-exist' is not available, requested by the user."); +} + +/// A dependency of a requested package that does not exist at all. +#[test] +fn test_package_unavailable_transitive() { + let provider = BundleBoxProvider::from_packages(&[("a", 1, vec!["b"])]); + insta::assert_snapshot!(solve_unsat_hints(provider, &["a"]), @"Package 'b' is not available, required by a=1."); +} + +/// A top-level requested version range that matches none of the existing +/// versions. +#[test] +fn test_no_matching_version_top_level() { + let provider = BundleBoxProvider::from_packages(&[("a", 1, vec![]), ("a", 2, vec![])]); + insta::assert_snapshot!(solve_unsat_hints(provider, &["a 5..6"]), @"No version matches 'a >=5, <6', requested by the user. Available: a 1 | 2."); +} + +/// A dependency version range that matches none of the existing versions. +#[test] +fn test_no_matching_version_transitive() { + let provider = BundleBoxProvider::from_packages(&[ + ("a", 1, vec!["b 5..6"]), + ("b", 1, vec![]), + ("b", 2, vec![]), + ("b", 3, vec![]), + ]); + insta::assert_snapshot!(solve_unsat_hints(provider, &["a"]), @"No version matches 'b >=5, <6', required by a=1. Available: b 1 | 2 | 3."); +} + +/// A top-level requested package whose only candidate is excluded. +#[test] +fn test_all_candidates_excluded_top_level() { + let mut provider = BundleBoxProvider::from_packages(&[("a", 1, vec![])]); + provider.exclude("a", 1, "not available on this platform"); + insta::assert_snapshot!(solve_unsat_hints(provider, &["a"]), @"Every candidate matching 'a *' is excluded, requested by the user: not available on this platform."); +} + +/// A matching range can be unavailable even when other versions of the package +/// remain available. The hint must identify the failed range rather than imply +/// that every version of the package is excluded. +#[test] +fn test_all_candidates_excluded_for_matching_range() { + let mut provider = BundleBoxProvider::from_packages(&[("a", 1, vec![]), ("a", 2, vec![])]); + provider.exclude("a", 1, "not available on this platform"); + insta::assert_snapshot!(solve_unsat_hints(provider, &["a 1..2"]), @"Every candidate matching 'a >=1, <2' is excluded, requested by the user: not available on this platform."); +} + +/// A dependency whose only candidate is excluded. +#[test] +fn test_all_candidates_excluded_transitive() { + let mut provider = BundleBoxProvider::from_packages(&[("a", 1, vec!["b"]), ("b", 1, vec![])]); + provider.exclude("b", 1, "not available on this platform"); + insta::assert_snapshot!(solve_unsat_hints(provider, &["a"]), @"Every candidate matching 'b *' is excluded, required by a=1: not available on this platform."); +} + +/// Two packages each requiring an incompatible version of a shared dependency. +#[test] +fn test_incompatible_requests() { + let provider = BundleBoxProvider::from_packages(&[ + ("c", 1, vec!["b 1..2"]), + ("d", 1, vec!["b 2..3"]), + ("b", 1, vec![]), + ("b", 2, vec![]), + ]); + insta::assert_snapshot!(solve_unsat_hints(provider, &["c", "d"]), @"Package 'b' is required in incompatible versions: b 1 | 2."); +} + +/// A run constraint that cannot be fulfilled together with a dependency. +#[test] +fn test_constrained() { + let mut provider = BundleBoxProvider::from_packages(&[ + ("a", 10, vec!["b 50..100"]), + ("b", 50, vec![]), + ("b", 42, vec![]), + ]); + provider.add_package("c", 10.into(), &[], &["b 0..50"]); + insta::assert_snapshot!(solve_unsat_hints(provider, &["a", "c"]), @"c=10 constrains 'b >=0, <50', which cannot be satisfied."); +} + +/// A locked package that conflicts with a required version. +#[test] +fn test_locked() { + let mut provider = BundleBoxProvider::from_packages(&[ + ("a", 1, vec!["b 2..3"]), + ("b", 1, vec![]), + ("b", 2, vec![]), + ]); + provider.set_locked("b", 1); + insta::assert_snapshot!(solve_unsat_hints(provider, &["a"]), @"b=1 is locked, but another version is required."); +} + +/// The conda/rattler#2476 shape: several versions of a package each depend on a +/// different missing version of the same dependency, producing one hint per +/// distinct requirement. +#[test] +fn test_distinct_requirements() { + let provider = BundleBoxProvider::from_packages(&[ + ("a", 1, vec!["b 41..42"]), + ("a", 2, vec!["b 42..43"]), + ("a", 3, vec!["b 43..44"]), + ]); + insta::assert_snapshot!(solve_unsat_hints(provider, &["a"]), @r" + Package 'b' is not available, required by a=1. + Package 'b' is not available, required by a=2. + Package 'b' is not available, required by a=3. + "); +} diff --git a/tests/solver/main.rs b/tests/solver/main.rs index 6d26355..ddfc73f 100644 --- a/tests/solver/main.rs +++ b/tests/solver/main.rs @@ -1,4 +1,5 @@ mod bundle_box; +mod hints; use std::io::{Write, stderr};