Add solver advice system for user-focused installation guidance

- Introduced a new `advice` module in the solver to provide user-oriented guidance for resolving dependency issues.
- Added structured failure details to `SolverError` for improved diagnosis and advice generation.
- Updated `pkg6` to display actionable advice when dependency resolution fails, including catalog refresh, publisher verification, and incorporation constraints.
- Enhanced CLI tools with a dangling dependency scan to identify and output unresolved stems.
- Improved modularity and caching mechanisms for catalog and manifest handling to support the advice system.
- Refined error messages and logging for better user feedback during installation errors.
This commit is contained in:
Till Wegmueller 2025-08-26 21:09:06 +02:00
parent 8bdc6d6641
commit d77e61f90f
No known key found for this signature in database
4 changed files with 538 additions and 10 deletions

277
libips/src/solver/advice.rs Normal file
View file

@ -0,0 +1,277 @@
use crate::actions::{Manifest, Property};
use crate::fmri::Fmri;
use crate::image::Image;
use crate::solver::{SolverError, SolverProblemKind};
use miette::Diagnostic;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use thiserror::Error;
#[derive(Debug, Error, Diagnostic)]
#[error("solver advice error: {message}")]
#[diagnostic(
code(ips::solver_advice_error::generic),
help("Ensure the image catalogs are built and accessible.")
)]
pub struct AdviceError {
pub message: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AdviceIssue {
pub path: Vec<String>,
pub stem: String,
pub constraint_release: Option<String>,
pub constraint_branch: Option<String>,
pub details: String,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub struct AdviceReport {
pub issues: Vec<AdviceIssue>,
}
#[derive(Debug, Default, Clone)]
pub struct AdviceOptions {
pub max_depth: usize, // 0 = unlimited
pub dependency_cap: usize, // 0 = unlimited per node
}
#[derive(Default)]
struct Ctx {
// caches
catalog_cache: HashMap<String, Vec<(String, Fmri)>>, // stem -> [(publisher, fmri)]
manifest_cache: HashMap<String, Manifest>, // fmri string -> manifest
lock_cache: HashMap<String, Option<String>>, // stem -> incorporated release
candidate_cache: HashMap<(String, Option<String>, Option<String>, Option<String>), Option<Fmri>>, // (stem, rel, branch, publisher)
publisher_filter: Option<String>,
cap: usize,
}
impl Ctx {
fn new(publisher_filter: Option<String>, cap: usize) -> Self {
Self { publisher_filter, cap, ..Default::default() }
}
}
pub fn advise_from_error(image: &Image, err: &SolverError, opts: AdviceOptions) -> Result<AdviceReport, AdviceError> {
let mut report = AdviceReport::default();
let Some(problem) = err.problem() else {
return Ok(report);
};
match &problem.kind {
SolverProblemKind::NoCandidates { stem, release, branch } => {
// Advise directly on the missing root
let mut ctx = Ctx::new(None, opts.dependency_cap);
let details = build_missing_detail(image, &mut ctx, stem, release.as_deref(), branch.as_deref());
report.issues.push(AdviceIssue {
path: vec![stem.clone()],
stem: stem.clone(),
constraint_release: release.clone(),
constraint_branch: branch.clone(),
details,
});
Ok(report)
}
SolverProblemKind::Unsolvable => {
// Fall back to analyzing roots and traversing dependencies to find a missing candidate leaf.
let mut ctx = Ctx::new(None, opts.dependency_cap);
for root in &problem.roots {
let root_fmri = match find_best_candidate(image, &mut ctx, &root.stem, root.version_req.as_deref(), root.branch.as_deref()) {
Ok(Some(f)) => f,
_ => {
// Missing root candidate
let details = build_missing_detail(image, &mut ctx, &root.stem, root.version_req.as_deref(), root.branch.as_deref());
report.issues.push(AdviceIssue {
path: vec![root.stem.clone()],
stem: root.stem.clone(),
constraint_release: root.version_req.clone(),
constraint_branch: root.branch.clone(),
details,
});
continue;
}
};
// Depth-first traversal looking for missing candidates
let mut path = vec![root.stem.clone()];
let mut seen = std::collections::HashSet::new();
advise_recursive(image, &mut ctx, &root_fmri, &mut path, 1, opts.max_depth, &mut seen, &mut report)?;
}
Ok(report)
}
}
}
fn advise_recursive(
image: &Image,
ctx: &mut Ctx,
fmri: &Fmri,
path: &mut Vec<String>,
depth: usize,
max_depth: usize,
seen: &mut std::collections::HashSet<String>,
report: &mut AdviceReport,
) -> Result<(), AdviceError> {
if max_depth != 0 && depth > max_depth { return Ok(()); }
let manifest = get_manifest_cached(image, ctx, fmri)?;
let mut processed = 0usize;
for dep in manifest.dependencies.iter().filter(|d| d.dependency_type == "require" || d.dependency_type == "incorporate") {
let Some(df) = &dep.fmri else { continue; };
let dep_stem = df.stem().to_string();
let (rel, br) = extract_constraint(&dep.optional);
if ctx.cap != 0 && processed >= ctx.cap { break; }
processed += 1;
match find_best_candidate(image, ctx, &dep_stem, rel.as_deref(), br.as_deref())? {
Some(next) => {
if !seen.contains(&dep_stem) {
seen.insert(dep_stem.clone());
path.push(dep_stem.clone());
advise_recursive(image, ctx, &next, path, depth + 1, max_depth, seen, report)?;
path.pop();
}
}
None => {
let details = build_missing_detail(image, ctx, &dep_stem, rel.as_deref(), br.as_deref());
report.issues.push(AdviceIssue {
path: path.clone(),
stem: dep_stem.clone(),
constraint_release: rel.clone(),
constraint_branch: br.clone(),
details,
});
}
}
}
Ok(())
}
fn extract_constraint(optional: &[Property]) -> (Option<String>, Option<String>) {
let mut release: Option<String> = None;
let mut branch: Option<String> = None;
for p in optional {
match p.key.as_str() {
"release" => release = Some(p.value.clone()),
"branch" => branch = Some(p.value.clone()),
_ => {}
}
}
(release, branch)
}
fn build_missing_detail(image: &Image, ctx: &mut Ctx, stem: &str, release: Option<&str>, branch: Option<&str>) -> String {
let mut available: Vec<String> = Vec::new();
if let Ok(list) = query_catalog_cached_mut(image, ctx, stem) {
for (pubname, fmri) in list {
if let Some(ref pfilter) = ctx.publisher_filter { if &pubname != pfilter { continue; } }
if fmri.stem() != stem { continue; }
let ver = fmri.version();
if ver.is_empty() { continue; }
available.push(ver);
}
}
available.sort();
available.dedup();
let available_str = if available.is_empty() { "<none>".to_string() } else { available.join(", ") };
let lock = get_incorporated_release_cached(image, ctx, stem).ok().flatten();
match (release, branch, lock.as_deref()) {
(Some(r), Some(b), Some(lr)) => format!("Required release={}, branch={} not found. Image incorporation lock release={} may constrain candidates. Available versions: {}", r, b, lr, available_str),
(Some(r), Some(b), None) => format!("Required release={}, branch={} not found. Available versions: {}", r, b, available_str),
(Some(r), None, Some(lr)) => format!("Required release={} not found. Image incorporation lock release={} present. Available versions: {}", r, lr, available_str),
(Some(r), None, None) => format!("Required release={} not found. Available versions: {}", r, available_str),
(None, Some(b), Some(lr)) => format!("Required branch={} not found. Image incorporation lock release={} present. Available versions: {}", b, lr, available_str),
(None, Some(b), None) => format!("Required branch={} not found. Available versions: {}", b, available_str),
(None, None, Some(lr)) => format!("No candidates matched. Image incorporation lock release={} present. Available versions: {}", lr, available_str),
(None, None, None) => format!("No candidates matched. Available versions: {}", available_str),
}
}
fn find_best_candidate(
image: &Image,
ctx: &mut Ctx,
stem: &str,
req_release: Option<&str>,
req_branch: Option<&str>,
) -> Result<Option<Fmri>, AdviceError> {
let key = (
stem.to_string(),
req_release.map(|s| s.to_string()),
req_branch.map(|s| s.to_string()),
ctx.publisher_filter.clone(),
);
if let Some(cached) = ctx.candidate_cache.get(&key) { return Ok(cached.clone()); }
let lock_release = if req_release.is_none() { get_incorporated_release_cached(image, ctx, stem).ok().flatten() } else { None };
let mut candidates: Vec<(String, Fmri)> = Vec::new();
for (pubf, pfmri) in query_catalog_cached(image, ctx, stem)? {
if let Some(ref pfilter) = ctx.publisher_filter { if &pubf != pfilter { continue; } }
if pfmri.stem() != stem { continue; }
let ver = pfmri.version();
if ver.is_empty() { continue; }
let rel = version_release(&ver);
let br = version_branch(&ver);
if let Some(req_r) = req_release { if Some(req_r) != rel.as_deref() { continue; } } else if let Some(lock_r) = lock_release.as_deref() { if Some(lock_r) != rel.as_deref() { continue; } }
if let Some(req_b) = req_branch { if Some(req_b) != br.as_deref() { continue; } }
candidates.push((ver.clone(), pfmri.clone()));
}
candidates.sort_by(|a, b| a.0.cmp(&b.0));
let res = candidates.pop().map(|x| x.1);
ctx.candidate_cache.insert(key, res.clone());
Ok(res)
}
fn version_release(version: &str) -> Option<String> {
version.split_once(',').map(|(rel, _)| rel.to_string())
}
fn version_branch(version: &str) -> Option<String> {
if let Some((_, rest)) = version.split_once(',') { return rest.split_once('-').map(|(b, _)| b.to_string()); }
None
}
fn query_catalog_cached(
image: &Image,
ctx: &Ctx,
stem: &str,
) -> Result<Vec<(String, Fmri)>, AdviceError> {
if let Some(v) = ctx.catalog_cache.get(stem) { return Ok(v.clone()); }
let mut tmp = Ctx { catalog_cache: ctx.catalog_cache.clone(), ..Default::default() };
query_catalog_cached_mut(image, &mut tmp, stem)
}
fn query_catalog_cached_mut(
image: &Image,
ctx: &mut Ctx,
stem: &str,
) -> Result<Vec<(String, Fmri)>, AdviceError> {
if let Some(v) = ctx.catalog_cache.get(stem) { return Ok(v.clone()); }
let mut out = Vec::new();
let res = image.query_catalog(Some(stem)).map_err(|e| AdviceError{ message: format!("Failed to query catalog for {}: {}", stem, e) })?;
for p in res { out.push((p.publisher, p.fmri)); }
ctx.catalog_cache.insert(stem.to_string(), out.clone());
Ok(out)
}
fn get_manifest_cached(image: &Image, ctx: &mut Ctx, fmri: &Fmri) -> Result<Manifest, AdviceError> {
let key = fmri.to_string();
if let Some(m) = ctx.manifest_cache.get(&key) { return Ok(m.clone()); }
let manifest_opt = image.get_manifest_from_catalog(fmri).map_err(|e| AdviceError { message: format!("Failed to load manifest for {}: {}", fmri.to_string(), e) })?;
let manifest = manifest_opt.unwrap_or_else(Manifest::new);
ctx.manifest_cache.insert(key, manifest.clone());
Ok(manifest)
}
fn get_incorporated_release_cached(image: &Image, ctx: &mut Ctx, stem: &str) -> Result<Option<String>, AdviceError> {
if let Some(v) = ctx.lock_cache.get(stem) { return Ok(v.clone()); }
let v = image.get_incorporated_release(stem).map_err(|e| AdviceError{ message: format!("Failed to read incorporation lock for {}: {}", stem, e) })?;
ctx.lock_cache.insert(stem.to_string(), v.clone());
Ok(v)
}

View file

@ -30,6 +30,9 @@ use std::io::{Cursor, Read};
use crate::actions::Manifest;
use crate::image::catalog::{CATALOG_TABLE, INCORPORATE_TABLE};
// Public advice API lives in a sibling module
pub mod advice;
// Local helpers to decode manifest bytes stored in catalog DB (JSON or LZ4-compressed JSON)
fn is_likely_json_local(bytes: &[u8]) -> bool {
let mut i = 0;
@ -539,6 +542,18 @@ impl<'a> DependencyProvider for IpsProvider<'a> {
}
}
#[derive(Debug, Clone)]
pub enum SolverProblemKind {
NoCandidates { stem: String, release: Option<String>, branch: Option<String> },
Unsolvable,
}
#[derive(Debug, Clone)]
pub struct SolverFailure {
pub kind: SolverProblemKind,
pub roots: Vec<Constraint>,
}
#[derive(Debug, Error, Diagnostic)]
#[error("Solver error: {message}")]
#[diagnostic(
@ -547,10 +562,13 @@ impl<'a> DependencyProvider for IpsProvider<'a> {
)]
pub struct SolverError {
pub message: String,
pub problem: Option<SolverFailure>,
}
impl SolverError {
fn new(msg: impl Into<String>) -> Self { Self { message: msg.into() } }
fn new(msg: impl Into<String>) -> Self { Self { message: msg.into(), problem: None } }
pub fn with_details(msg: impl Into<String>, problem: SolverFailure) -> Self { Self { message: msg.into(), problem: Some(problem) } }
pub fn problem(&self) -> Option<&SolverFailure> { self.problem.as_ref() }
}
#[derive(Debug, Clone)]
@ -693,11 +711,36 @@ pub fn resolve_install(image: &Image, constraints: &[Constraint]) -> Result<Inst
}
if !missing.is_empty() {
let pubs: Vec<String> = image.publishers().iter().map(|p| p.name.clone()).collect();
return Err(SolverError::new(format!(
"No candidates found for requested package(s): {}.\nChecked publishers: {}.\nRun 'pkg6 refresh' to update catalogs or verify the package names.",
missing.join(", "),
pubs.join(", ")
)));
// Pick the first missing root and its constraint for structured problem
let mut first_missing: Option<Constraint> = None;
for (name_id, c) in &root_names {
let has = provider
.cands_by_name
.get(name_id)
.map(|v| !v.is_empty())
.unwrap_or(false);
if !has {
first_missing = Some(c.clone());
break;
}
}
let roots: Vec<Constraint> = root_names.iter().map(|(_, c)| c.clone()).collect();
let problem = if let Some(c) = first_missing {
SolverFailure {
kind: SolverProblemKind::NoCandidates { stem: c.stem.clone(), release: c.version_req.clone(), branch: c.branch.clone() },
roots,
}
} else {
SolverFailure { kind: SolverProblemKind::Unsolvable, roots }
};
return Err(SolverError::with_details(
format!(
"No candidates found for requested package(s): {}.\nChecked publishers: {}.\nRun 'pkg6 refresh' to update catalogs or verify the package names.",
missing.join(", "),
pubs.join(", ")
),
problem,
));
}
// Before moving provider into the solver, capture useful snapshots for diagnostics
@ -743,14 +786,22 @@ pub fn resolve_install(image: &Image, constraints: &[Constraint]) -> Result<Inst
}
// Run the solver
let roots_for_err: Vec<Constraint> = root_names.iter().map(|(_, c)| c.clone()).collect();
let mut solver = RSolver::new(provider);
let solution_ids = solver.solve(problem).map_err(|conflict_or_cancelled| {
match conflict_or_cancelled {
UnsolvableOrCancelled::Unsolvable(u) => {
SolverError::new(u.display_user_friendly(&solver).to_string())
let msg = u.display_user_friendly(&solver).to_string();
SolverError::with_details(
msg,
SolverFailure { kind: SolverProblemKind::Unsolvable, roots: roots_for_err.clone() },
)
}
UnsolvableOrCancelled::Cancelled(_) => {
SolverError::new("dependency resolution cancelled".to_string())
SolverError::with_details(
"dependency resolution cancelled".to_string(),
SolverFailure { kind: SolverProblemKind::Unsolvable, roots: roots_for_err.clone() },
)
}
}
})?;

View file

@ -609,8 +609,51 @@ fn main() -> Result<()> {
let plan = match libips::solver::resolve_install(&image, &constraints) {
Ok(p) => p,
Err(e) => {
error!("Failed to resolve install plan: {}", e);
return Err(e.into());
let mut printed_advice = false;
if !*quiet {
// Attempt to provide user-focused advice on how to resolve dependency issues
let opts = libips::solver::advice::AdviceOptions { max_depth: 3, dependency_cap: 400 };
match libips::solver::advice::advise_from_error(&image, &e, opts) {
Ok(report) => {
if !report.issues.is_empty() {
printed_advice = true;
eprintln!("\nAdvice: detected {} issue(s) preventing installation:", report.issues.len());
for (i, iss) in report.issues.iter().enumerate() {
let constraint_str = {
let mut s = String::new();
if let Some(r) = &iss.constraint_release { s.push_str(&format!("release={} ", r)); }
if let Some(b) = &iss.constraint_branch { s.push_str(&format!("branch={}", b)); }
s.trim().to_string()
};
eprintln!(
" {}. Missing viable candidates for '{}'\n - Path: {}\n - Constraint: {}\n - Details: {}",
i + 1,
iss.stem,
if iss.path.is_empty() { iss.stem.clone() } else { iss.path.join(" -> ") },
if constraint_str.is_empty() { "<none>".to_string() } else { constraint_str },
iss.details
);
}
eprintln!("\nWhat you can try as a user:");
eprintln!(" • Ensure your catalogs are up to date: 'pkg6 refresh'.");
eprintln!(" • Verify that the required publishers are configured: 'pkg6 publisher'.");
eprintln!(" • Some versions may be constrained by image incorporations; updating the image or selecting a compatible package set may help.");
eprintln!(" • If the problem persists, report this to the repository maintainers with the above details.");
}
}
Err(advice_err) => {
eprintln!("(Note) Unable to compute advice: {}", advice_err);
}
}
}
if printed_advice {
// We've printed actionable advice; exit with a non-zero code without printing further errors.
std::process::exit(1);
} else {
// No advice printed; fall back to standard error reporting
error!("Failed to resolve install plan: {}", e);
return Err(e.into());
}
}
};

View file

@ -40,6 +40,10 @@ struct Cli {
#[arg(short = 's', long = "suggest", action = ArgAction::SetTrue)]
suggest: bool,
/// Find packages whose dependencies reference missing stems (dangling)
#[arg(long = "find-dangling", action = ArgAction::SetTrue)]
find_dangling: bool,
/// Advise on an install for the given package stem (advisor mode)
#[arg(long = "advise-install")]
advise_install: Option<String>,
@ -139,6 +143,12 @@ fn main() -> Result<()> {
return Ok(());
}
// Dangling dependency scan has priority over graph mode
if cli.find_dangling {
run_dangling_scan(&image, cli.publisher.as_deref(), cli.package.as_deref(), cli.format)?;
return Ok(());
}
// Graph mode
// Query catalog (filtered if --package provided)
let mut pkgs = if let Some(ref needle) = cli.package {
@ -768,6 +778,153 @@ mod tests {
}
// ---------- Dangling dependency scan ----------
fn run_dangling_scan(
image: &Image,
publisher: Option<&str>,
package_filter: Option<&str>,
format: OutputFormat,
) -> Result<()> {
// Query full catalog once
let mut pkgs = image
.query_catalog(None)
.map_err(|e| PkgTreeError { message: format!("Failed to query catalog: {}", e) })?;
// Build set of available non-obsolete stems AND an index of available (release, branch) pairs per stem,
// honoring publisher filter
let mut available_stems: HashSet<String> = HashSet::new();
let mut available_index: HashMap<String, Vec<(String, Option<String>)>> = HashMap::new();
for p in &pkgs {
if let Some(pubf) = publisher {
if p.publisher != pubf { continue; }
}
if p.obsolete { continue; }
let stem = p.fmri.stem().to_string();
available_stems.insert(stem.clone());
let ver = p.fmri.version();
if !ver.is_empty() {
if let Some(rel) = version_release(&ver) {
let br = version_branch(&ver);
available_index.entry(stem).or_default().push((rel, br));
}
}
}
// Filter the list of requiring packages we'll scan
if let Some(pubf) = publisher {
pkgs.retain(|p| p.publisher == pubf);
}
pkgs.retain(|p| !p.obsolete);
if let Some(needle) = package_filter {
pkgs.retain(|p| p.fmri.stem().contains(needle) || p.fmri.to_string().contains(needle));
}
// Map of requiring package fmri string -> Vec<missing_stems>
let mut dangling: HashMap<String, Vec<String>> = HashMap::new();
for p in &pkgs {
let fmri = &p.fmri;
match image.get_manifest_from_catalog(fmri) {
Ok(Some(man)) => {
let mut missing_for_pkg: Vec<String> = Vec::new();
for dep in man.dependencies {
if dep.dependency_type != "require" && dep.dependency_type != "incorporate" { continue; }
let Some(df) = dep.fmri else { continue; };
let stem = df.stem().to_string();
// Extract version/branch constraints if any (from optional properties)
let mut c = extract_constraint(&dep.optional);
// Also merge constraints from the dependency FMRI's version string if not provided in optional
let df_ver_str = df.version();
if !df_ver_str.is_empty() {
if c.release.is_none() {
c.release = version_release(&df_ver_str);
}
if c.branch.is_none() {
c.branch = version_branch(&df_ver_str);
}
}
// Helper to check availability against constraints
let satisfies = |stem: &str, rel: Option<&str>, br: Option<&str>| -> bool {
if let Some(list) = available_index.get(stem) {
if let (Some(rreq), Some(breq)) = (rel, br) {
return list.iter().any(|(r, b)| r == rreq && b.as_deref() == Some(breq));
} else if let Some(rreq) = rel {
return list.iter().any(|(r, _)| r == rreq);
} else if let Some(breq) = br {
return list.iter().any(|(_, b)| b.as_deref() == Some(breq));
} else {
return true; // no constraint: stem existing already confirmed elsewhere
}
}
false
};
let mut mark_missing: Option<String> = None;
if !available_stems.contains(&stem) {
mark_missing = Some(stem.clone());
} else if c.release.is_some() || c.branch.is_some() {
if !satisfies(&stem, c.release.as_deref(), c.branch.as_deref()) {
// Include constraint context in output for maintainers
let mut ctx = String::new();
if let Some(r) = &c.release { ctx.push_str(&format!("release={} ", r)); }
if let Some(b) = &c.branch { ctx.push_str(&format!("branch={}", b)); }
let ctx = ctx.trim().to_string();
if ctx.is_empty() { mark_missing = Some(stem.clone()); } else { mark_missing = Some(format!("{} [required {}]", stem, ctx)); }
}
}
if let Some(m) = mark_missing { missing_for_pkg.push(m); }
}
if !missing_for_pkg.is_empty() {
missing_for_pkg.sort();
missing_for_pkg.dedup();
dangling.insert(fmri.to_string(), missing_for_pkg);
}
}
Ok(None) => {
warn!(pkg=%fmri.to_string(), "Manifest not found in catalog while scanning dangling deps");
}
Err(e) => {
warn!(pkg=%fmri.to_string(), error=%format!("{}", e), "Failed to read manifest while scanning dangling deps");
}
}
}
// Output
match format {
OutputFormat::Tree => {
if dangling.is_empty() {
println!("No dangling dependencies detected.");
} else {
println!("Found {} package(s) with dangling dependencies:", dangling.len());
let mut keys: Vec<String> = dangling.keys().cloned().collect();
keys.sort();
for k in keys {
println!("- {}:", k);
if let Some(list) = dangling.get(&k) {
for m in list { println!("{}", m); }
}
}
}
}
OutputFormat::Json => {
use serde::Serialize;
#[derive(Serialize)]
struct DanglingJson { package_fmri: String, missing_stems: Vec<String> }
let mut out: Vec<DanglingJson> = Vec::new();
for (pkg, miss) in dangling.into_iter() {
out.push(DanglingJson { package_fmri: pkg, missing_stems: miss });
}
out.sort_by(|a, b| a.package_fmri.cmp(&b.package_fmri));
println!("{}", serde_json::to_string_pretty(&out).into_diagnostic()?);
}
}
Ok(())
}
// ---------- Targeted analysis: parse pkg6 solver error text ----------
fn analyze_solver_error(image: &Image, publisher: Option<&str>, err_path: &PathBuf) -> Result<()> {
let text = std::fs::read_to_string(err_path)