From c00e4cf8148e47a650fd537f0c201115a6998f5e Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Wed, 3 Jun 2026 18:42:39 +0200 Subject: [PATCH 1/3] feat(conflict): add structured hints for unsatisfiable conflicts Add Conflict::hints, which returns machine-actionable explanations of why a solve failed alongside the existing rendered error message. Each hint describes a single cause, such as an unavailable package, a version that does not match, candidates that were all excluded, incompatible requests, an unfulfillable constraint, or a lock. --- src/conflict.rs | 320 +++++++++++++++++++++++++++++++++++++++++- tests/solver/hints.rs | 198 ++++++++++++++++++++++++++ tests/solver/main.rs | 1 + 3 files changed, 518 insertions(+), 1 deletion(-) create mode 100644 tests/solver/hints.rs diff --git a/src/conflict.rs b/src/conflict.rs index 7b58afb7..f350367e 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,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`] @@ -480,6 +707,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 6d26355b..ddfc73f4 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}; From 7e0624570e4ade9689d3849cccc6d5a7acd21692 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:22:21 +0200 Subject: [PATCH 2/3] fix(conflict): clarify structured hint contract --- src/conflict.rs | 39 +++++++++++++++++++++++++++++---------- tests/solver/hints.rs | 23 ++++++++++++++++++----- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/src/conflict.rs b/src/conflict.rs index f350367e..75df08b7 100644 --- a/src/conflict.rs +++ b/src/conflict.rs @@ -396,14 +396,19 @@ impl Conflict { DisplayUnsat::new(graph, solver.provider()) } - /// Returns structured, machine-actionable hints describing the causes of - /// the conflict. + /// Returns high-level, machine-actionable summaries 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 are derived from the conflict graph and are intended for user + /// interfaces that need to suggest a correction for a misspelled package + /// name or explain a platform exclusion. They are not a complete or minimal + /// representation of the conflict graph, nor do they correspond one-to-one + /// with its nodes or clauses. Use [`Conflict::graph`] when those + /// relationships are required. + /// + /// Equivalent summaries may be coalesced. Hints for top-level requirements + /// are returned before transitive hints; the order of other hints is not a + /// compatibility guarantee. Constructing the graph may access metadata + /// through the solver's cached provider. /// /// Hints reference packages, versions and version sets by their interned /// ids; resolve them through the [`Interner`] of the provider. @@ -499,6 +504,8 @@ impl Conflict { &mut hints, ConflictHint::AllCandidatesExcluded { name, + requirement, + version_set, reasons, required_by, }, @@ -567,6 +574,8 @@ fn classify_missing( .collect(); return ConflictHint::AllCandidatesExcluded { name, + requirement, + version_set, reasons, required_by, }; @@ -708,6 +717,9 @@ pub enum ConflictCause { } /// Identifies what introduced a requirement reported in a [`ConflictHint`]. +/// +/// Additional sources may be added in future releases. +#[non_exhaustive] #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub enum RequiredBy { /// The requirement is a top-level requirement of the [`Problem`] itself. @@ -723,6 +735,9 @@ pub enum RequiredBy { /// /// All packages, versions and version sets are referenced by their interned /// ids. Resolve them through the [`Interner`] of the provider. +/// +/// Additional hint kinds may be added in future releases. +#[non_exhaustive] #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum ConflictHint { /// A required package has no candidates at all: the name is unknown to the @@ -746,12 +761,16 @@ pub enum ConflictHint { /// 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. + /// Every candidate matching a requested version set was excluded, for + /// example because it is not compatible with the current platform. AllCandidatesExcluded { /// The name of the excluded package. name: N, + /// The original requirement containing the failed version set. + requirement: Requirement, + /// The specific version-set alternative for which every candidate was + /// excluded. This disambiguates alternatives in union requirements. + version_set: VersionSetId, /// The reasons the matching candidates were excluded. reasons: Vec, /// What introduced the requirement. diff --git a/tests/solver/hints.rs b/tests/solver/hints.rs index 8f60a472..36738fec 100644 --- a/tests/solver/hints.rs +++ b/tests/solver/hints.rs @@ -17,6 +17,7 @@ fn format_required_by(provider: &BundleBoxProvider, required_by: &RequiredBy) -> RequiredBy::Solvable(solvable) => { format!("required by {}", provider.display_solvable(*solvable)) } + _ => "required by an unsupported source".to_string(), } } @@ -40,12 +41,13 @@ fn format_hint(provider: &BundleBoxProvider, hint: &ConflictHint) -> String { provider.display_merged_solvables(available), ), ConflictHint::AllCandidatesExcluded { - name, + requirement, reasons, required_by, + .. } => format!( - "Every candidate for '{}' is excluded, {}: {}.", - provider.display_name(*name), + "Every candidate matching '{}' is excluded, {}: {}.", + requirement.display(provider), format_required_by(provider, required_by), reasons .iter() @@ -70,6 +72,7 @@ fn format_hint(provider: &BundleBoxProvider, hint: &ConflictHint) -> String { "{} is locked, but another version is required.", provider.display_solvable(*locked) ), + _ => "An unsupported conflict hint was returned.".to_string(), } } @@ -133,7 +136,17 @@ fn test_no_matching_version_transitive() { 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."); + 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. @@ -141,7 +154,7 @@ fn test_all_candidates_excluded_top_level() { 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."); + 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. From cad713bb8f08b991c777205bd503b26b35c8cb33 Mon Sep 17 00:00:00 2001 From: Bas Zalmstra <4995967+baszalmstra@users.noreply.github.com> Date: Thu, 27 Aug 2026 10:53:40 +0200 Subject: [PATCH 3/3] docs(conflict): clarify structured hint API --- src/conflict.rs | 101 ++++++++++++++++++++++++------------------------ 1 file changed, 50 insertions(+), 51 deletions(-) diff --git a/src/conflict.rs b/src/conflict.rs index 75df08b7..feb5cac0 100644 --- a/src/conflict.rs +++ b/src/conflict.rs @@ -396,22 +396,30 @@ impl Conflict { DisplayUnsat::new(graph, solver.provider()) } - /// Returns high-level, machine-actionable summaries of the conflict. + /// Returns UI-oriented summaries of this conflict. /// - /// Hints are derived from the conflict graph and are intended for user - /// interfaces that need to suggest a correction for a misspelled package - /// name or explain a platform exclusion. They are not a complete or minimal - /// representation of the conflict graph, nor do they correspond one-to-one - /// with its nodes or clauses. Use [`Conflict::graph`] when those - /// relationships are required. + /// Use hints for concise explanations or recovery suggestions. Use + /// [`Conflict::graph`] when the caller needs the full causal graph. /// - /// Equivalent summaries may be coalesced. Hints for top-level requirements - /// are returned before transitive hints; the order of other hints is not a - /// compatibility guarantee. Constructing the graph may access metadata - /// through the solver's cached provider. + /// 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. /// - /// Hints reference packages, versions and version sets by their interned - /// ids; resolve them through the [`Interner`] of 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, @@ -716,84 +724,75 @@ pub enum ConflictCause { Excluded, } -/// Identifies what introduced a requirement reported in a [`ConflictHint`]. +/// Origin of a requirement reported by a [`ConflictHint`]. /// -/// Additional sources may be added in future releases. +/// Match this enum with a fallback arm: new origins may be added. #[non_exhaustive] #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] pub enum RequiredBy { - /// The requirement is a top-level requirement of the [`Problem`] itself. + /// A direct requirement of the [`Problem`]. /// /// [`Problem`]: crate::Problem Problem, - /// The requirement is a dependency of the given solvable. + /// A dependency declared by this 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. +/// A summary returned by [`Conflict::hints`]. /// -/// Additional hint kinds may be added in future releases. +/// 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 { - /// 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. + /// No candidate exists for the requested package name. PackageUnavailable { - /// The name of the package that could not be found. + /// Missing package name. name: N, - /// The requirement that asked for the package. + /// Requirement that named the package. requirement: Requirement, - /// What introduced the requirement. + /// Source of the requirement. required_by: RequiredBy, }, - /// The package exists but none of its versions match the requested version - /// range. + /// Candidates exist, but none satisfies the requirement. NoMatchingVersion { - /// The requirement that could not be satisfied. + /// Unsatisfied requirement. requirement: Requirement, - /// The candidates that exist for the package, none of which match. + /// All non-excluded candidates for its package. available: Vec, - /// What introduced the requirement. + /// Source of the requirement. required_by: RequiredBy, }, - /// Every candidate matching a requested version set was excluded, for - /// example because it is not compatible with the current platform. + /// Every candidate for one requested version set is excluded. AllCandidatesExcluded { - /// The name of the excluded package. + /// Excluded package name. name: N, - /// The original requirement containing the failed version set. + /// Requirement containing the failed version set. requirement: Requirement, - /// The specific version-set alternative for which every candidate was - /// excluded. This disambiguates alternatives in union requirements. + /// Failed alternative within `requirement`. version_set: VersionSetId, - /// The reasons the matching candidates were excluded. + /// Reasons attached to matching excluded candidates. reasons: Vec, - /// What introduced the requirement. + /// Source of the requirement. required_by: RequiredBy, }, - /// Multiple incompatible versions of the same package are required at the - /// same time and cannot be installed together. + /// Incompatible solvables of one package are required together. IncompatibleRequests { - /// The name of the package required in incompatible versions. + /// Shared package name. name: N, - /// The conflicting candidates involved. + /// Incompatible solvables. solvables: Vec, }, - /// A run constraint imposed by a package cannot be fulfilled. + /// A solvable's run constraint cannot be satisfied. Constrained { - /// The version set the dependency is constrained to. + /// Version set prohibited by the constraint. constraint: VersionSetId, - /// The solvable that imposes the constraint. + /// Solvable that imposed the constraint. constrained_by: S, }, - /// A locked package conflicts with the versions required by the request. + /// A locked solvable conflicts with another requirement. Locked { - /// The locked solvable. + /// Locked solvable. locked: S, }, }