diff --git a/crates/pixi_cli/src/add.rs b/crates/pixi_cli/src/add.rs index 60de855f7c..d08b17c2e4 100644 --- a/crates/pixi_cli/src/add.rs +++ b/crates/pixi_cli/src/add.rs @@ -2,13 +2,18 @@ use clap::Parser; use indexmap::IndexMap; use miette::IntoDiagnostic; use pixi_config::ConfigCli; -use pixi_core::{WorkspaceLocator, environment::sanity_check_workspace, workspace::DependencyType}; +use pixi_core::{ + DependencyType, WorkspaceLocator, environment::sanity_check_workspace, repodata::Repodata, +}; use pixi_manifest::{FeatureName, KnownPreviewFeature, SpecType}; use pixi_spec::{GitSpec, SourceLocationSpec, SourceSpec}; -use rattler_conda_types::{MatchSpec, PackageName}; +use rattler_conda_types::{MatchSpec, PackageName, Platform}; +use super::package_suggestions; use crate::{ - cli_config::{DependencyConfig, LockFileUpdateConfig, NoInstallConfig, WorkspaceConfig}, + cli_config::{ + ChannelsConfig, DependencyConfig, LockFileUpdateConfig, NoInstallConfig, WorkspaceConfig, + }, has_specs::HasSpecs, }; @@ -212,8 +217,43 @@ pub async fn execute(args: Args) -> miette::Result<()> { update_deps } Err(e) => { + let enhanced_error = if let Some(failed_package) = + package_suggestions::extract_failed_package_name(&e) + { + let default_channels = ChannelsConfig::default(); + let channels = + default_channels.resolve_from_project(Some(workspace.workspace()))?; + let platform = dependency_config + .platforms + .first() + .copied() + .unwrap_or(Platform::current()); + let gateway = workspace.workspace().repodata_gateway()?.clone(); + + let suggester = + package_suggestions::PackageSuggester::new(channels, platform, gateway); + + // Use CEP-0016 fast gateway.names() approach + match suggester.suggest_similar(&failed_package).await { + Ok(suggestions) if !suggestions.is_empty() => { + Some(package_suggestions::create_enhanced_package_error( + &failed_package, + &suggestions, + )) + } + _ => None, // Fall back to original error if suggestions fail + } + } else { + None + }; + workspace.revert().await.into_diagnostic()?; - return Err(e); + + // Return enhanced error with suggestions or original error + return match enhanced_error { + Some(enhanced) => Err(enhanced), + None => Err(e), + }; } }; diff --git a/crates/pixi_cli/src/global/install.rs b/crates/pixi_cli/src/global/install.rs index eadb591b5a..a026c364b0 100644 --- a/crates/pixi_cli/src/global/install.rs +++ b/crates/pixi_cli/src/global/install.rs @@ -1,6 +1,6 @@ use std::{ops::Not, str::FromStr}; -use indexmap::IndexMap; +use indexmap::{IndexMap, IndexSet}; use clap::Parser; use fancy_display::FancyDisplay; @@ -9,7 +9,9 @@ use miette::Report; use rattler_conda_types::{MatchSpec, NamedChannelOrUrl, Platform}; use crate::global::{global_specs::GlobalSpecs, revert_environment_after_error}; +use crate::package_suggestions; use pixi_config::{self, Config, ConfigCli}; +use pixi_core::repodata::Repodata; use pixi_global::{ self, EnvChanges, EnvState, EnvironmentName, Mapping, Project, StateChange, StateChanges, common::{NotChangedReason, contains_menuinst_document}, @@ -135,13 +137,62 @@ pub async fn execute(args: Args) -> miette::Result<()> { last_updated_project = project; } Err(err) => { + let enhanced_error = if let Some(failed_package) = + package_suggestions::extract_failed_package_name(&err) + { + let channel_urls = if args.channels.is_empty() { + project.config().default_channels() + } else { + args.channels.clone() + }; + + let channels_result: Result, _> = channel_urls + .into_iter() + .map(|channel_url| { + channel_url.into_channel(project.global_channel_config()) + }) + .collect(); + + if let Ok(channels) = channels_result { + let platform = args.platform.unwrap_or_else(Platform::current); + + if let Ok(gateway) = project.repodata_gateway() { + let suggester = package_suggestions::PackageSuggester::new( + channels, + platform, + gateway.clone(), + ); + + match suggester.suggest_similar(&failed_package).await { + Ok(suggestions) if !suggestions.is_empty() => { + Some(package_suggestions::create_enhanced_package_error( + &failed_package, + &suggestions, + )) + } + _ => None, + } + } else { + None + } + } else { + None + } + } else { + None + }; + if let Err(revert_err) = revert_environment_after_error(env_name, &last_updated_project).await { tracing::warn!("Reverting of the operation failed"); tracing::info!("Reversion error: {:?}", revert_err); } - errors.push((env_name.clone(), err)); + + match enhanced_error { + Some(enhanced) => errors.push((env_name.clone(), enhanced)), + None => errors.push((env_name.clone(), err)), + }; } } } diff --git a/crates/pixi_cli/src/lib.rs b/crates/pixi_cli/src/lib.rs index 18ea0deaaf..9e3d2e3633 100644 --- a/crates/pixi_cli/src/lib.rs +++ b/crates/pixi_cli/src/lib.rs @@ -36,6 +36,7 @@ pub mod init; pub mod install; pub mod list; pub mod lock; +pub mod package_suggestions; pub mod reinstall; pub mod remove; pub mod run; diff --git a/crates/pixi_cli/src/package_suggestions.rs b/crates/pixi_cli/src/package_suggestions.rs new file mode 100644 index 0000000000..5c6e0ba6c4 --- /dev/null +++ b/crates/pixi_cli/src/package_suggestions.rs @@ -0,0 +1,125 @@ +use indexmap::IndexSet; +use miette::{IntoDiagnostic, Report}; +use rattler_conda_types::{Channel, PackageName, Platform}; +use rattler_repodata_gateway::Gateway; +use regex::Regex; +use std::sync::LazyLock; +use strsim::jaro; + +/// Extracts package name from "No candidates were found" error messages +pub fn extract_failed_package_name(error: &Report) -> Option { + static PACKAGE_REGEX: LazyLock = LazyLock::new(|| { + Regex::new(r"No candidates were found for ([a-zA-Z0-9_-]+)(?:\s+\*)?") + .expect("Invalid regex") + }); + + let error_chain = std::iter::successors(Some(error.as_ref() as &dyn std::error::Error), |e| { + e.source() + }); + + for error in error_chain { + if let Some(captures) = PACKAGE_REGEX.captures(&error.to_string()) { + return captures.get(1).map(|m| m.as_str().to_string()); + } + } + None +} + +pub struct PackageSuggester { + channels: IndexSet, + platform: Platform, + gateway: Gateway, +} + +impl PackageSuggester { + pub fn new(channels: IndexSet, platform: Platform, gateway: Gateway) -> Self { + Self { + channels, + platform, + gateway, + } + } + + /// Get all package names using CEP-0016 shard index + async fn get_all_package_names(&self) -> miette::Result> { + self.gateway + .names(self.channels.clone(), [self.platform, Platform::NoArch]) + .await + .into_diagnostic() + } + + /// Get suggestions using fast shard index lookup + pub async fn suggest_similar(&self, failed_package: &str) -> miette::Result> { + let all_names = self.get_all_package_names().await?; + + // Simple but fast approach: collect matches and similarities in one pass + let failed_lower = failed_package.to_lowercase(); + let mut matches: Vec<(f64, String)> = Vec::new(); + + // Single pass through packages with early termination for good matches + for pkg in &all_names { + let name = pkg.as_normalized(); + let name_lower = name.to_lowercase(); + + // Skip exact matches to avoid suggesting the same package + if name_lower == failed_lower { + continue; + } + + // Quick wins first (fast string operations) + let score = + if name_lower.starts_with(&failed_lower) || failed_lower.starts_with(&name_lower) { + 0.9 // Prefix match (high priority) + } else if name_lower.contains(&failed_lower) { + 0.8 // Substring match (medium priority) + } else { + // Only compute expensive Jaro for potential matches + let jaro_score = jaro(&name_lower, &failed_lower); + if jaro_score > 0.6 { jaro_score } else { 0.0 } + }; + + if score > 0.0 { + matches.push((score, name.to_string())); + + // Early termination if we have enough good matches + if matches.len() >= 10 && score > 0.8 { + break; + } + } + } + + // Sort by score (highest first) and take top 3 + matches.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); + let suggestions: Vec = matches.into_iter().take(3).map(|(_, name)| name).collect(); + + Ok(suggestions) + } +} + +pub fn create_enhanced_package_error( + failed_package: &str, + suggestions: &[String], +) -> miette::Report { + let mut help_text = String::new(); + + if !suggestions.is_empty() { + help_text.push_str("Did you mean one of these?\n"); + for suggestion in suggestions { + help_text.push_str(&format!(" - {}\n", suggestion)); + } + help_text.push('\n'); + } + + help_text.push_str(&format!( + "tip: a similar subcommand exists: 'search {}'", + failed_package + )); + + miette::miette!( + help = help_text, + "No candidates were found for '{}'", + failed_package + ) +} + +//Todo: Add tests maybe diff --git a/tests/integration_python/pixi_global/test_global.py b/tests/integration_python/pixi_global/test_global.py index b16535a85a..bc0cd61435 100644 --- a/tests/integration_python/pixi_global/test_global.py +++ b/tests/integration_python/pixi_global/test_global.py @@ -1114,7 +1114,7 @@ def test_install_only_reverts_failing(pixi: Path, tmp_path: Path, dummy_channel_ [pixi, "global", "install", "--channel", dummy_channel_1, "dummy-a", "dummy-b", "dummy-x"], ExitCode.FAILURE, env=env, - stderr_contains="No candidates were found for dummy-x", + stderr_contains="No candidates were found for 'dummy-x'", ) # dummy-a, dummy-b should be installed, but not dummy-x @@ -1145,7 +1145,7 @@ def test_install_continues_past_failing_env( ], ExitCode.FAILURE, env=env, - stderr_contains="No candidates were found for dummy-x", + stderr_contains="No candidates were found for 'dummy-x'", ) # Both valid packages are installed despite the error in between @@ -1185,7 +1185,7 @@ def test_install_platform(pixi: Path, tmp_path: Path) -> None: ], ExitCode.FAILURE, env=env, - stderr_contains="No candidates were found", + stderr_contains="No candidates were found for 'binutils'", )