Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 44 additions & 4 deletions crates/pixi_cli/src/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};

Expand Down Expand Up @@ -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),
};
}
};

Expand Down
55 changes: 53 additions & 2 deletions crates/pixi_cli/src/global/install.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::{ops::Not, str::FromStr};

use indexmap::IndexMap;
use indexmap::{IndexMap, IndexSet};

use clap::Parser;
use fancy_display::FancyDisplay;
Expand All @@ -9,7 +9,9 @@ use miette::{Context, IntoDiagnostic};
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},
Expand Down Expand Up @@ -140,13 +142,62 @@ pub async fn execute(args: Args) -> miette::Result<()> {
};
}
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<IndexSet<_>, _> = 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);
}
return Err(err);

return match enhanced_error {
Some(enhanced) => Err(enhanced),
None => Err(err),
};
}
}
last_updated_project = project;
Expand Down
1 change: 1 addition & 0 deletions crates/pixi_cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,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;
Expand Down
125 changes: 125 additions & 0 deletions crates/pixi_cli/src/package_suggestions.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
static PACKAGE_REGEX: LazyLock<Regex> = 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<Channel>,
platform: Platform,
gateway: Gateway,
}

impl PackageSuggester {
pub fn new(channels: IndexSet<Channel>, 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<Vec<PackageName>> {
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<Vec<String>> {
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<String> = 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
4 changes: 2 additions & 2 deletions tests/integration_python/pixi_global/test_global.py
Original file line number Diff line number Diff line change
Expand Up @@ -1113,7 +1113,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
Expand Down Expand Up @@ -1154,7 +1154,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'",
)


Expand Down
Loading