diff --git a/libips/src/solver/advice.rs b/libips/src/solver/advice.rs new file mode 100644 index 0000000..72d8246 --- /dev/null +++ b/libips/src/solver/advice.rs @@ -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, + pub stem: String, + pub constraint_release: Option, + pub constraint_branch: Option, + pub details: String, +} + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct AdviceReport { + pub issues: Vec, +} + +#[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>, // stem -> [(publisher, fmri)] + manifest_cache: HashMap, // fmri string -> manifest + lock_cache: HashMap>, // stem -> incorporated release + candidate_cache: HashMap<(String, Option, Option, Option), Option>, // (stem, rel, branch, publisher) + publisher_filter: Option, + cap: usize, +} + +impl Ctx { + fn new(publisher_filter: Option, cap: usize) -> Self { + Self { publisher_filter, cap, ..Default::default() } + } +} + +pub fn advise_from_error(image: &Image, err: &SolverError, opts: AdviceOptions) -> Result { + 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, + depth: usize, + max_depth: usize, + seen: &mut std::collections::HashSet, + 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, Option) { + let mut release: Option = None; + let mut branch: Option = 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 = 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() { "".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, 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 { + version.split_once(',').map(|(rel, _)| rel.to_string()) +} + +fn version_branch(version: &str) -> Option { + 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, 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, 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 { + 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, 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) +} diff --git a/libips/src/solver/mod.rs b/libips/src/solver/mod.rs index ea7ac9d..121bbf5 100644 --- a/libips/src/solver/mod.rs +++ b/libips/src/solver/mod.rs @@ -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, branch: Option }, + Unsolvable, +} + +#[derive(Debug, Clone)] +pub struct SolverFailure { + pub kind: SolverProblemKind, + pub roots: Vec, +} + #[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, } impl SolverError { - fn new(msg: impl Into) -> Self { Self { message: msg.into() } } + fn new(msg: impl Into) -> Self { Self { message: msg.into(), problem: None } } + pub fn with_details(msg: impl Into, 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 = 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 = 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 = 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 = 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() }, + ) } } })?; diff --git a/pkg6/src/main.rs b/pkg6/src/main.rs index 5c680d6..7581eb2 100644 --- a/pkg6/src/main.rs +++ b/pkg6/src/main.rs @@ -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() { "".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()); + } } }; diff --git a/pkgtree/src/main.rs b/pkgtree/src/main.rs index be793ac..0db126b 100644 --- a/pkgtree/src/main.rs +++ b/pkgtree/src/main.rs @@ -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, @@ -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 = HashSet::new(); + let mut available_index: HashMap)>> = 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 + let mut dangling: HashMap> = 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 = 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 = 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 = 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 } + let mut out: Vec = 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)