From 539470ba9ebf2833f8aff73877491f4c337afe2c Mon Sep 17 00:00:00 2001 From: 14elias Date: Sun, 11 Oct 2026 11:22:35 +0300 Subject: [PATCH 1/3] refactor(error): make component suggestions support add/remove actions --- src/errors.rs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/errors.rs b/src/errors.rs index 0e9cb8d32c..e05b3ce784 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -84,7 +84,11 @@ impl fmt::Display for TargetSuggestion { #[derive(Debug, Clone)] pub enum ComponentSuggestion { - Toolchain { name: String, component: String }, + Toolchain { + name: String, + component: String, + is_add: bool, + }, Component(String), } @@ -123,6 +127,7 @@ impl ComponentSuggestion { return Some(Self::Toolchain { name: toolchain_name.to_string(), component: manifest.short_name(component).to_string(), + is_add: false, }); } } @@ -134,8 +139,13 @@ impl ComponentSuggestion { impl fmt::Display for ComponentSuggestion { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - Self::Toolchain { name, component } => { - write!(f, "try `rustup +{name} component remove {component}`") + Self::Toolchain { + name, + component, + is_add, + } => { + let action = if *is_add { "add" } else { "remove" }; + write!(f, "try `rustup +{name} component {action} {component}`") } Self::Component(name) => write!(f, "did you mean '{name}'?"), } From e7001d68e8d13ed7b83136bbcde44a3e63b74e83 Mon Sep 17 00:00:00 2001 From: 14elias Date: Mon, 12 Oct 2026 00:18:09 +0300 Subject: [PATCH 2/3] feat(error): suggest another toolchain on component addition --- src/errors.rs | 95 +++++++++++++++++++++++++++------------ src/toolchain/channels.rs | 11 ++++- tests/suite/cli_v2.rs | 39 ++++++++++++++++ 3 files changed, 115 insertions(+), 30 deletions(-) diff --git a/src/errors.rs b/src/errors.rs index e05b3ce784..b97607629a 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -20,7 +20,7 @@ use crate::{ config::Config as DistConfig, manifest::{Component, Manifest}, }, - toolchain::{Toolchain, ToolchainName, ToolchainPath}, + toolchain::{ChannelToolchain, Toolchain, ToolchainName, ToolchainPath}, }; pub(crate) const DEFAULT_STABLE_HINT: &str = "help: run 'rustup default stable' to download the latest stable release of Rust and set it as your default toolchain."; @@ -87,7 +87,7 @@ pub enum ComponentSuggestion { Toolchain { name: String, component: String, - is_add: bool, + action: &'static str, }, Component(String), } @@ -100,51 +100,90 @@ impl ComponentSuggestion { manifest: &Manifest, cfg: &Cfg<'_>, ) -> Option { - let Ok(toolchains) = cfg.list_toolchains(true) else { - return component_suggestion(desc, component, config, manifest, true) - .map(Self::Component); - }; - - let names = [component.name(), component.wildcard().name()]; - for toolchain_name in toolchains { - if let ToolchainName::Channel(toolchain_desc) = &toolchain_name - && toolchain_desc == desc - { - continue; + for (name, channel_toolchain) in other_channel_toolchains(cfg, desc) { + if toolchain_has_installed_component(&channel_toolchain.toolchain, component) { + return Some(Self::Toolchain { + name: name.to_string(), + component: manifest.short_name(component).to_string(), + action: "remove", + }); } + } - let Ok(toolchain) = Toolchain::new(cfg, toolchain_name.clone().into()) else { - continue; - }; - let Ok(installed_components) = toolchain.installed_components() else { - continue; - }; + component_suggestion(desc, component, config, manifest, true).map(Self::Component) + } - if installed_components - .iter() - .any(|c| names.iter().any(|n| n == c.name())) - { + pub(crate) fn for_addition( + desc: &ChannelToolchainName, + component: &Component, + config: &DistConfig, + manifest: &Manifest, + cfg: &Cfg<'_>, + ) -> Option { + for (name, channel_toolchain) in other_channel_toolchains(cfg, desc) { + if toolchain_has_available_component(&channel_toolchain, component) { return Some(Self::Toolchain { - name: toolchain_name.to_string(), + name: name.to_string(), component: manifest.short_name(component).to_string(), - is_add: false, + action: "add", }); } } - component_suggestion(desc, component, config, manifest, true).map(Self::Component) + component_suggestion(desc, component, config, manifest, false).map(Self::Component) } } +fn other_channel_toolchains<'a>( + cfg: &'a Cfg<'a>, + desc: &ChannelToolchainName, +) -> impl Iterator)> { + cfg.list_toolchains(true) + .ok() + .into_iter() + .flatten() + .filter_map(move |toolchain_name| match toolchain_name { + ToolchainName::Channel(toolchain_desc) if &toolchain_desc != desc => { + let channel_toolchain = ChannelToolchain::new(cfg, toolchain_desc.clone()).ok()?; + Some((toolchain_desc, channel_toolchain)) + } + _ => None, + }) +} + +fn component_matches(component: &Component, candidate_name: &str) -> bool { + candidate_name == component.name() || candidate_name == component.wildcard().name() +} + +fn toolchain_has_installed_component(toolchain: &Toolchain<'_>, component: &Component) -> bool { + let Ok(installed_components) = toolchain.installed_components() else { + return false; + }; + installed_components + .iter() + .any(|installed| component_matches(component, installed.name())) +} + +fn toolchain_has_available_component( + toolchain: &ChannelToolchain<'_>, + component: &Component, +) -> bool { + let Ok(components) = toolchain.components() else { + return false; + }; + components + .iter() + .any(|status| status.available && component_matches(component, &status.component.name())) +} + impl fmt::Display for ComponentSuggestion { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Toolchain { name, component, - is_add, + action, } => { - let action = if *is_add { "add" } else { "remove" }; write!(f, "try `rustup +{name} component {action} {component}`") } Self::Component(name) => write!(f, "did you mean '{name}'?"), diff --git a/src/toolchain/channels.rs b/src/toolchain/channels.rs index 3429f7da71..eb41859b45 100644 --- a/src/toolchain/channels.rs +++ b/src/toolchain/channels.rs @@ -96,24 +96,31 @@ impl<'a> ChannelToolchain<'a> { } let config = manifestation.read_config()?.unwrap_or_default(); - let suggestion = component_suggestion(desc, &component, &config, &manifest, false); if targ_pkg .components .iter() .any(|c| c.target() == component.target()) { + let suggestion = ComponentSuggestion::for_addition( + desc, + &component, + &config, + &manifest, + self.toolchain.cfg, + ); return Err(RustupError::UnknownComponents { desc: desc.clone(), components: vec![UnknownComponentInfo { name: manifest.short_name(&component).to_string(), description: manifest.description(&component), - suggestion: suggestion.map(ComponentSuggestion::Component), + suggestion, }], } .into()); } + let suggestion = component_suggestion(desc, &component, &config, &manifest, false); let target = component.target.expect("component target should be known"); if let Some(platform) = Platform::find(&target) { return Err(RustupError::UnavailableTarget { diff --git a/tests/suite/cli_v2.rs b/tests/suite/cli_v2.rs index 97813807f5..25f29d5a0f 100644 --- a/tests/suite/cli_v2.rs +++ b/tests/suite/cli_v2.rs @@ -1923,6 +1923,45 @@ error: toolchain 'stable-[HOST_TUPLE]' does not contain component 'rust-analysis .is_err(); } +#[tokio::test] +async fn add_component_not_available_with_suggestion() { + let cx = CliTestContext::new(Scenario::SimpleV2).await; + cx.config + .expect(["rustup", "toolchain", "install", "nightly"]) + .await + .is_ok(); + cx.config + .expect(["rustup", "default", "stable"]) + .await + .is_ok(); + cx.config + .expect(["rustup", "component", "add", "rls-preview"]) + .await + .with_stderr(snapbox::str![[r#" +error: toolchain 'stable-[HOST_TUPLE]' does not contain component 'rls-preview' for target '[HOST_TUPLE]' +help: try `rustup +nightly-[HOST_TUPLE] component add rls-preview` + +"#]]) + .is_err(); +} + +#[tokio::test] +async fn add_component_not_available_no_alternative() { + let cx = CliTestContext::new(Scenario::SimpleV2).await; + cx.config + .expect(["rustup", "default", "stable"]) + .await + .is_ok(); + cx.config + .expect(["rustup", "component", "add", "rls-preview"]) + .await + .with_stderr(snapbox::str![[r#" +error: toolchain 'stable-[HOST_TUPLE]' does not contain component 'rls-preview' for target '[HOST_TUPLE]' + +"#]]) + .is_err(); +} + #[tokio::test] async fn remove_target_no_toolchain() { let cx = CliTestContext::new(Scenario::SimpleV2).await; From 951efbb5f3a85d68b1bacac3e8489b20372f22c3 Mon Sep 17 00:00:00 2001 From: 14elias Date: Tue, 13 Oct 2026 15:19:34 +0300 Subject: [PATCH 3/3] refactor(error): reuse toolchain lookup for target suggestions --- src/errors.rs | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/src/errors.rs b/src/errors.rs index b97607629a..befcdc97c6 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -40,28 +40,14 @@ impl TargetSuggestion { manifest: &Manifest, cfg: &Cfg<'_>, ) -> Option { - let Ok(toolchains) = cfg.list_toolchains(true) else { - return component_suggestion(desc, component, config, manifest, true) - .map(Self::Component); - }; - - for toolchain_name in toolchains { - if let ToolchainName::Channel(toolchain_desc) = &toolchain_name - && toolchain_desc == desc - { - continue; - } - - let Ok(toolchain) = Toolchain::new(cfg, toolchain_name.clone().into()) else { - continue; - }; - let Ok(installed_targets) = toolchain.installed_targets() else { + for (name, channel_toolchain) in other_channel_toolchains(cfg, desc) { + let Ok(installed_targets) = channel_toolchain.toolchain.installed_targets() else { continue; }; if installed_targets.contains(target) { return Some(Self::Toolchain { - name: toolchain_name.to_string(), + name: name.to_string(), target: target.clone(), }); }