diff --git a/src/conflict.rs b/src/conflict.rs index 32d22c4b..6927ae60 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, VersionSetId, internal::{id::ClauseId, solver_id::SolvableIdOrRoot}, runtime::AsyncRuntime, @@ -213,6 +213,233 @@ impl Conflict { let graph = self.graph(solver); DisplayUnsat::new(graph, solver.provider()) } + + /// Returns structured, machine-actionable hints describing the causes of + /// the conflict. + /// + /// This is the data counterpart of [`Conflict::display_user_friendly`], + /// which renders the same causes as a human readable tree. Callers can act + /// on the hints programmatically, for example to suggest a correction for a + /// misspelled package name or to report that a package is not available for + /// the current platform. + /// + /// Hints reference packages, versions and version sets by their interned + /// ids; resolve them through the [`Interner`] of the provider. + 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, + 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, + 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`] @@ -298,6 +525,97 @@ pub enum ConflictCause { Excluded, } +/// Identifies what introduced a requirement reported in a [`ConflictHint`]. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +pub enum RequiredBy { + /// The requirement is a top-level requirement of the [`Problem`] itself. + /// + /// [`Problem`]: crate::Problem + Problem, + /// The requirement is a dependency of the given solvable. + Solvable(S), +} + +/// A structured, machine-actionable explanation of a single cause of an +/// unsatisfiable solve, returned by [`Conflict::hints`]. +/// +/// All packages, versions and version sets are referenced by their interned +/// ids. Resolve them through the [`Interner`] of the provider. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub enum ConflictHint { + /// A required package has no candidates at all: the name is unknown to the + /// provider. This commonly indicates a misspelled package name or a missing + /// channel. + PackageUnavailable { + /// The name of the package that could not be found. + name: N, + /// The requirement that asked for the package. + requirement: Requirement, + /// What introduced the requirement. + required_by: RequiredBy, + }, + /// The package exists but none of its versions match the requested version + /// range. + NoMatchingVersion { + /// The requirement that could not be satisfied. + requirement: Requirement, + /// The candidates that exist for the package, none of which match. + available: Vec, + /// What introduced the requirement. + required_by: RequiredBy, + }, + /// The package has candidates matching the requested range, but all of them + /// were excluded, for example because they are not compatible with the + /// current platform. + AllCandidatesExcluded { + /// The name of the excluded package. + name: N, + /// The reasons the matching candidates were excluded. + reasons: Vec, + /// What introduced the requirement. + required_by: RequiredBy, + }, + /// Multiple incompatible versions of the same package are required at the + /// same time and cannot be installed together. + IncompatibleRequests { + /// The name of the package required in incompatible versions. + name: N, + /// The conflicting candidates involved. + solvables: Vec, + }, + /// A run constraint imposed by a package cannot be fulfilled. + Constrained { + /// The version set the dependency is constrained to. + constraint: VersionSetId, + /// The solvable that imposes the constraint. + constrained_by: S, + }, + /// A locked package conflicts with the versions required by the request. + Locked { + /// The 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 00000000..8f60a472 --- /dev/null +++ b/tests/solver/hints.rs @@ -0,0 +1,198 @@ +//! 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)) + } + } +} + +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 { + name, + reasons, + required_by, + } => format!( + "Every candidate for '{}' is excluded, {}: {}.", + provider.display_name(*name), + 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) + ), + } +} + +/// 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 for 'a' 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 for '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 a1d16f8d..759434c3 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};