mirror of
https://codeberg.org/Toasterson/ips.git
synced 2026-04-10 21:30:41 +00:00
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:
parent
8bdc6d6641
commit
d77e61f90f
4 changed files with 538 additions and 10 deletions
277
libips/src/solver/advice.rs
Normal file
277
libips/src/solver/advice.rs
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -30,6 +30,9 @@ use std::io::{Cursor, Read};
|
||||||
use crate::actions::Manifest;
|
use crate::actions::Manifest;
|
||||||
use crate::image::catalog::{CATALOG_TABLE, INCORPORATE_TABLE};
|
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)
|
// Local helpers to decode manifest bytes stored in catalog DB (JSON or LZ4-compressed JSON)
|
||||||
fn is_likely_json_local(bytes: &[u8]) -> bool {
|
fn is_likely_json_local(bytes: &[u8]) -> bool {
|
||||||
let mut i = 0;
|
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)]
|
#[derive(Debug, Error, Diagnostic)]
|
||||||
#[error("Solver error: {message}")]
|
#[error("Solver error: {message}")]
|
||||||
#[diagnostic(
|
#[diagnostic(
|
||||||
|
|
@ -547,10 +562,13 @@ impl<'a> DependencyProvider for IpsProvider<'a> {
|
||||||
)]
|
)]
|
||||||
pub struct SolverError {
|
pub struct SolverError {
|
||||||
pub message: String,
|
pub message: String,
|
||||||
|
pub problem: Option<SolverFailure>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SolverError {
|
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)]
|
#[derive(Debug, Clone)]
|
||||||
|
|
@ -693,11 +711,36 @@ pub fn resolve_install(image: &Image, constraints: &[Constraint]) -> Result<Inst
|
||||||
}
|
}
|
||||||
if !missing.is_empty() {
|
if !missing.is_empty() {
|
||||||
let pubs: Vec<String> = image.publishers().iter().map(|p| p.name.clone()).collect();
|
let pubs: Vec<String> = image.publishers().iter().map(|p| p.name.clone()).collect();
|
||||||
return Err(SolverError::new(format!(
|
// 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.",
|
"No candidates found for requested package(s): {}.\nChecked publishers: {}.\nRun 'pkg6 refresh' to update catalogs or verify the package names.",
|
||||||
missing.join(", "),
|
missing.join(", "),
|
||||||
pubs.join(", ")
|
pubs.join(", ")
|
||||||
)));
|
),
|
||||||
|
problem,
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Before moving provider into the solver, capture useful snapshots for diagnostics
|
// 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
|
// Run the solver
|
||||||
|
let roots_for_err: Vec<Constraint> = root_names.iter().map(|(_, c)| c.clone()).collect();
|
||||||
let mut solver = RSolver::new(provider);
|
let mut solver = RSolver::new(provider);
|
||||||
let solution_ids = solver.solve(problem).map_err(|conflict_or_cancelled| {
|
let solution_ids = solver.solve(problem).map_err(|conflict_or_cancelled| {
|
||||||
match conflict_or_cancelled {
|
match conflict_or_cancelled {
|
||||||
UnsolvableOrCancelled::Unsolvable(u) => {
|
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(_) => {
|
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() },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
|
||||||
|
|
@ -609,9 +609,52 @@ fn main() -> Result<()> {
|
||||||
let plan = match libips::solver::resolve_install(&image, &constraints) {
|
let plan = match libips::solver::resolve_install(&image, &constraints) {
|
||||||
Ok(p) => p,
|
Ok(p) => p,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
|
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);
|
error!("Failed to resolve install plan: {}", e);
|
||||||
return Err(e.into());
|
return Err(e.into());
|
||||||
}
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if !quiet { println!("Resolved {} package(s) to install", plan.add.len()); }
|
if !quiet { println!("Resolved {} package(s) to install", plan.add.len()); }
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,10 @@ struct Cli {
|
||||||
#[arg(short = 's', long = "suggest", action = ArgAction::SetTrue)]
|
#[arg(short = 's', long = "suggest", action = ArgAction::SetTrue)]
|
||||||
suggest: bool,
|
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)
|
/// Advise on an install for the given package stem (advisor mode)
|
||||||
#[arg(long = "advise-install")]
|
#[arg(long = "advise-install")]
|
||||||
advise_install: Option<String>,
|
advise_install: Option<String>,
|
||||||
|
|
@ -139,6 +143,12 @@ fn main() -> Result<()> {
|
||||||
return Ok(());
|
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
|
// Graph mode
|
||||||
// Query catalog (filtered if --package provided)
|
// Query catalog (filtered if --package provided)
|
||||||
let mut pkgs = if let Some(ref needle) = cli.package {
|
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 ----------
|
// ---------- Targeted analysis: parse pkg6 solver error text ----------
|
||||||
fn analyze_solver_error(image: &Image, publisher: Option<&str>, err_path: &PathBuf) -> Result<()> {
|
fn analyze_solver_error(image: &Image, publisher: Option<&str>, err_path: &PathBuf) -> Result<()> {
|
||||||
let text = std::fs::read_to_string(err_path)
|
let text = std::fs::read_to_string(err_path)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue