From f7b2a0ee988501fc660bf232576781df9823fad1 Mon Sep 17 00:00:00 2001 From: Swastik Patel Date: Tue, 22 Jul 2025 15:16:17 +0530 Subject: [PATCH 1/5] feat: package suggestions using shard index --- crates/pixi_cli/src/add.rs | 45 ++++++- crates/pixi_cli/src/lib.rs | 1 + crates/pixi_cli/src/package_suggestions.rs | 149 +++++++++++++++++++++ 3 files changed, 189 insertions(+), 6 deletions(-) create mode 100644 crates/pixi_cli/src/package_suggestions.rs diff --git a/crates/pixi_cli/src/add.rs b/crates/pixi_cli/src/add.rs index 60de855f7c..b7daabc861 100644 --- a/crates/pixi_cli/src/add.rs +++ b/crates/pixi_cli/src/add.rs @@ -2,13 +2,14 @@ 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_manifest::{FeatureName, KnownPreviewFeature, SpecType}; -use pixi_spec::{GitSpec, SourceLocationSpec, SourceSpec}; -use rattler_conda_types::{MatchSpec, PackageName}; +use pixi_core::{WorkspaceLocator, environment::sanity_check_workspace, DependencyType, repodata::Repodata}; +use pixi_manifest::{FeatureName, SpecType, KnownPreviewFeature}; +use pixi_spec::{GitSpec, SourceSpec, SourceLocationSpec}; +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 +213,40 @@ 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) + { + // Use CEP-0016 shard index with strict timeout for sub-second response + let result = async { + let default_channels = ChannelsConfig::default(); + let channels = + default_channels.resolve_from_project(Some(workspace.workspace())).ok()?; + let platform = dependency_config + .platforms + .first() + .copied() + .unwrap_or(Platform::current()); + + let gateway = workspace.workspace().repodata_gateway().ok()?.clone(); + + let suggester = + package_suggestions::PackageSuggester::new(channels, platform, gateway); + + Some(package_suggestions::get_fast_suggestions(&failed_package, &suggester).await) + }.await; + + result + } 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/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..81a5ccf4cd --- /dev/null +++ b/crates/pixi_cli/src/package_suggestions.rs @@ -0,0 +1,149 @@ +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_-]+)").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) + } +} + +/// Get suggestions using CEP-0016 shard index with strict timeout for sub-second response +pub async fn get_fast_suggestions( + failed_package: &str, + suggester: &PackageSuggester, +) -> miette::Report { + use tokio::time::{timeout, Duration}; + + // Very strict timeout - mentor wants sub-second response + let suggestion_timeout = Duration::from_millis(500); // 0.5 second max + + let suggestions = match timeout(suggestion_timeout, suggester.suggest_similar(failed_package)).await { + Ok(Ok(suggestions)) if !suggestions.is_empty() => suggestions, + Ok(Ok(_)) => vec![], // No suggestions found + Ok(Err(_)) => { + tracing::debug!("Network error getting suggestions, showing tip only"); + vec![] + } + Err(_) => { + tracing::debug!("Suggestion lookup timed out after 500ms, showing tip only"); + vec![] + } + }; + + create_enhanced_package_error(failed_package, &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 From 9fb4912d0e916853ec69d2651e21c1c9bdc73da9 Mon Sep 17 00:00:00 2001 From: Swastik Patel Date: Wed, 23 Jul 2025 15:38:31 +0530 Subject: [PATCH 2/5] chore: fix CI pytests --- crates/pixi_cli/src/package_suggestions.rs | 3 ++- tests/integration_python/pixi_global/test_global.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/pixi_cli/src/package_suggestions.rs b/crates/pixi_cli/src/package_suggestions.rs index 81a5ccf4cd..0fb4bce930 100644 --- a/crates/pixi_cli/src/package_suggestions.rs +++ b/crates/pixi_cli/src/package_suggestions.rs @@ -9,7 +9,8 @@ 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_-]+)").expect("Invalid regex") + 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| { diff --git a/tests/integration_python/pixi_global/test_global.py b/tests/integration_python/pixi_global/test_global.py index b16535a85a..981eb3783c 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 @@ -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'", ) From 6f44dc447c261d8557f76e2fc4ea26b7dad0ee92 Mon Sep 17 00:00:00 2001 From: Swastik Patel Date: Thu, 21 Aug 2025 17:28:37 +0530 Subject: [PATCH 3/5] fix: rebase on top of new pixi structure --- crates/pixi_cli/src/add.rs | 51 ++++++++++++---------- crates/pixi_cli/src/package_suggestions.rs | 45 +++++-------------- 2 files changed, 39 insertions(+), 57 deletions(-) diff --git a/crates/pixi_cli/src/add.rs b/crates/pixi_cli/src/add.rs index b7daabc861..d08b17c2e4 100644 --- a/crates/pixi_cli/src/add.rs +++ b/crates/pixi_cli/src/add.rs @@ -2,14 +2,18 @@ use clap::Parser; use indexmap::IndexMap; use miette::IntoDiagnostic; use pixi_config::ConfigCli; -use pixi_core::{WorkspaceLocator, environment::sanity_check_workspace, DependencyType, repodata::Repodata}; -use pixi_manifest::{FeatureName, SpecType, KnownPreviewFeature}; -use pixi_spec::{GitSpec, SourceSpec, SourceLocationSpec}; +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, Platform}; use super::package_suggestions; use crate::{ - cli_config::{ChannelsConfig, DependencyConfig, LockFileUpdateConfig, NoInstallConfig, WorkspaceConfig}, + cli_config::{ + ChannelsConfig, DependencyConfig, LockFileUpdateConfig, NoInstallConfig, WorkspaceConfig, + }, has_specs::HasSpecs, }; @@ -216,26 +220,29 @@ pub async fn execute(args: Args) -> miette::Result<()> { let enhanced_error = if let Some(failed_package) = package_suggestions::extract_failed_package_name(&e) { - // Use CEP-0016 shard index with strict timeout for sub-second response - let result = async { - let default_channels = ChannelsConfig::default(); - let channels = - default_channels.resolve_from_project(Some(workspace.workspace())).ok()?; - let platform = dependency_config - .platforms - .first() - .copied() - .unwrap_or(Platform::current()); - - let gateway = workspace.workspace().repodata_gateway().ok()?.clone(); + 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); + let suggester = + package_suggestions::PackageSuggester::new(channels, platform, gateway); - Some(package_suggestions::get_fast_suggestions(&failed_package, &suggester).await) - }.await; - - result + // 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 }; diff --git a/crates/pixi_cli/src/package_suggestions.rs b/crates/pixi_cli/src/package_suggestions.rs index 0fb4bce930..5c6e0ba6c4 100644 --- a/crates/pixi_cli/src/package_suggestions.rs +++ b/crates/pixi_cli/src/package_suggestions.rs @@ -67,15 +67,16 @@ impl PackageSuggester { } // 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 } - }; + 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())); @@ -95,32 +96,6 @@ impl PackageSuggester { } } -/// Get suggestions using CEP-0016 shard index with strict timeout for sub-second response -pub async fn get_fast_suggestions( - failed_package: &str, - suggester: &PackageSuggester, -) -> miette::Report { - use tokio::time::{timeout, Duration}; - - // Very strict timeout - mentor wants sub-second response - let suggestion_timeout = Duration::from_millis(500); // 0.5 second max - - let suggestions = match timeout(suggestion_timeout, suggester.suggest_similar(failed_package)).await { - Ok(Ok(suggestions)) if !suggestions.is_empty() => suggestions, - Ok(Ok(_)) => vec![], // No suggestions found - Ok(Err(_)) => { - tracing::debug!("Network error getting suggestions, showing tip only"); - vec![] - } - Err(_) => { - tracing::debug!("Suggestion lookup timed out after 500ms, showing tip only"); - vec![] - } - }; - - create_enhanced_package_error(failed_package, &suggestions) -} - pub fn create_enhanced_package_error( failed_package: &str, suggestions: &[String], From 4b1ee6ab7aa39b89725f3e16f0aa9f0c268cc16b Mon Sep 17 00:00:00 2001 From: Swastik Patel Date: Thu, 18 Sep 2025 20:07:53 +0530 Subject: [PATCH 4/5] pkg suggestion for 'global install' too --- crates/pixi_cli/src/global/install.rs | 55 ++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 2 deletions(-) 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)), + }; } } } From 9eeb223b3c80442eaa8981e955c01b13c5c67b7d Mon Sep 17 00:00:00 2001 From: Swastik Patel Date: Mon, 22 Sep 2025 20:15:29 +0530 Subject: [PATCH 5/5] misc: try to fix test --- tests/integration_python/pixi_global/test_global.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration_python/pixi_global/test_global.py b/tests/integration_python/pixi_global/test_global.py index 981eb3783c..bc0cd61435 100644 --- a/tests/integration_python/pixi_global/test_global.py +++ b/tests/integration_python/pixi_global/test_global.py @@ -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