From 430d9bf3e94702d316aea97f1bde4485e54a660f Mon Sep 17 00:00:00 2001 From: Gaurav Kamathe Date: Mon, 3 Aug 2026 18:05:40 +0530 Subject: [PATCH] feat(cli): add --check flag to rustup update for semantic exit codes When --check is passed, rustup update returns exit code 100 if any of the requested toolchains were updated, and 0 if everything was already up to date. Without --check, behavior is unchanged. Closes #4987 --- src/cli/common.rs | 14 +++- src/cli/help.rs | 7 +- src/cli/rustup_mode.rs | 26 ++++++- tests/suite/cli_exact.rs | 74 +++++++++++++++++++ .../rustup_up_cmd_help_flag.stdout.term.svg | 52 ++++++++----- ...ustup_update_cmd_help_flag.stdout.term.svg | 52 ++++++++----- ...stup_upgrade_cmd_help_flag.stdout.term.svg | 52 ++++++++----- 7 files changed, 211 insertions(+), 66 deletions(-) diff --git a/src/cli/common.rs b/src/cli/common.rs index 09e9abe31e..7409686148 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -212,7 +212,10 @@ fn show_channel_updates( Ok(()) } -pub(crate) async fn update_all_channels(cfg: &Cfg<'_>, force_update: bool) -> Result { +pub(crate) async fn update_all_channels( + cfg: &Cfg<'_>, + force_update: bool, +) -> Result<(ExitCode, bool)> { let profile = cfg.get_profile()?; let channels = cfg.list_channels()?; @@ -249,6 +252,13 @@ pub(crate) async fn update_all_channels(cfg: &Cfg<'_>, force_update: bool) -> Re ExitCode::SUCCESS }; + let any_updates = toolchains.iter().any(|(_, r)| { + matches!( + r, + Ok(UpdateStatus::Installed) | Ok(UpdateStatus::Updated(_)) + ) + }); + if toolchains.is_empty() { info!("no updatable toolchains installed"); } @@ -263,7 +273,7 @@ pub(crate) async fn update_all_channels(cfg: &Cfg<'_>, force_update: bool) -> Re show_channel_updates(cfg, t)?; } - Ok(exit_code) + Ok((exit_code, any_updates)) } /// Print a list of items (targets or components) to stdout. diff --git a/src/cli/help.rs b/src/cli/help.rs index 52d1f0ca42..71db18bdc5 100644 --- a/src/cli/help.rs +++ b/src/cli/help.rs @@ -62,7 +62,12 @@ pub(crate) fn update_help() -> String { If given a toolchain argument then `update` updates that toolchain, the same as `rustup toolchain install`. -{TOOLCHAIN_INSTALL_HINT}" +{TOOLCHAIN_INSTALL_HINT} + +{HEADER}Exit status (with --check):{HEADER:#} + {LITERAL}0{LITERAL:#} No updates were applied; everything was already up to date. + {LITERAL}100{LITERAL:#} At least one toolchain was updated. + {LITERAL}1{LITERAL:#} An error occurred." ) } diff --git a/src/cli/rustup_mode.rs b/src/cli/rustup_mode.rs index 30911dd0a3..88a66eadc3 100644 --- a/src/cli/rustup_mode.rs +++ b/src/cli/rustup_mode.rs @@ -193,6 +193,10 @@ enum RustupSubcmd { /// Install toolchains that require an emulator. See https://github.com/rust-lang/rustup/wiki/Non-host-toolchains #[arg(long)] force_non_host: bool, + + /// Set exit code to indicate whether udpates were applied + #[arg(long)] + check: bool, }, /// Check for updates to Rust toolchains and rustup @@ -728,7 +732,7 @@ pub async fn main( let exit_code = match subcmd { RustupSubcmd::DumpTestament => common::dump_testament(process), - RustupSubcmd::Install { opts } => update(cfg, opts, true).await, + RustupSubcmd::Install { opts } => update(cfg, opts, true, false).await, RustupSubcmd::Uninstall { opts } => toolchain_remove(cfg, opts).await, RustupSubcmd::Show { verbose, subcmd } => handle_epipe(match subcmd { None => show(cfg, verbose).await, @@ -746,6 +750,7 @@ pub async fn main( no_self_update, force, force_non_host, + check, } => { update( cfg, @@ -757,11 +762,12 @@ pub async fn main( ..UpdateOpts::default() }, false, + check, ) .await } RustupSubcmd::Toolchain { subcmd } => match subcmd { - ToolchainSubcmd::Install { opts } => update(cfg, opts, true).await, + ToolchainSubcmd::Install { opts } => update(cfg, opts, true, false).await, ToolchainSubcmd::List { verbose, quiet } => { handle_epipe(common::list_toolchains(cfg, verbose, quiet).await) } @@ -1062,8 +1068,10 @@ async fn update( cfg: &mut Cfg<'_>, opts: UpdateOpts, ensure_active_toolchain: bool, + check: bool, ) -> Result { let mut exit_code = ExitCode::SUCCESS; + let mut any_updated = false; common::warn_if_host_is_emulated(cfg.process); let self_update_mode = SelfUpdateMode::from_cfg(cfg)?; @@ -1120,6 +1128,10 @@ async fn update( Err(e) => Err(e)?, }; + if matches!(status, UpdateStatus::Installed | UpdateStatus::Updated(_)) { + any_updated = true; + } + writeln!(cfg.process.stdout().lock())?; common::show_channel_update( cfg, @@ -1144,7 +1156,11 @@ async fn update( info!("it's active because: {}", source.to_reason()); exit_code &= self_update_mode.update(should_self_update, &dl_cfg).await?; } else { - exit_code &= common::update_all_channels(cfg, opts.force).await?; + let (channels_exit, channels_updated) = + common::update_all_channels(cfg, opts.force).await?; + exit_code &= channels_exit; + any_updated = channels_updated; + exit_code &= self_update_mode.update(should_self_update, &dl_cfg).await?; info!("cleaning up downloads & tmp directories"); @@ -1152,6 +1168,10 @@ async fn update( dl_cfg.tmp_cx.clean(); } + if check && exit_code == ExitCode::SUCCESS && any_updated { + return Ok(ExitCode::UPDATES_AVAILABLE); + } + Ok(exit_code) } diff --git a/tests/suite/cli_exact.rs b/tests/suite/cli_exact.rs index e161760b44..80f4c2340f 100644 --- a/tests/suite/cli_exact.rs +++ b/tests/suite/cli_exact.rs @@ -305,6 +305,80 @@ nightly-[HOST_TUPLE] - update available: 1.2.0 (hash-nightly-1) -> 1.3.0 (hash-n "#]]); } +#[tokio::test] +async fn update_check_no_updates() { + let cx = CliTestContext::new(Scenario::SimpleV2).await; + cx.config + .expect(["rustup", "toolchain", "add", "stable"]) + .await + .is_ok(); + // Updating an already-current toolchain with --check should return 0 + cx.config + .expect(["rustup", "update", "--check", "stable"]) + .await + .is_ok(); +} + +#[tokio::test] +async fn update_check_with_updates() { + let mut cx = CliTestContext::new(Scenario::None).await; + + { + let cx = cx.with_dist_dir(Scenario::ArchivesV2_2015_01_01); + cx.config + .expect(["rustup", "toolchain", "add", "stable"]) + .await + .is_ok(); + } + + let cx = cx.with_dist_dir(Scenario::SimpleV2); + // Updating an outdated toolchain with --check should return 100 + cx.config + .expect(["rustup", "update", "--check", "stable"]) + .await + .has_code(100); +} + +#[tokio::test] +async fn update_without_check_always_succeeds() { + let mut cx = CliTestContext::new(Scenario::None).await; + + { + let cx = cx.with_dist_dir(Scenario::ArchivesV2_2015_01_01); + cx.config + .expect(["rustup", "toolchain", "add", "stable"]) + .await + .is_ok(); + } + + let cx = cx.with_dist_dir(Scenario::SimpleV2); + // Without --check, update should return 0 even when updates occurred + cx.config + .expect(["rustup", "update", "stable"]) + .await + .is_ok(); +} + +#[tokio::test] +async fn update_check_all_channels() { + let mut cx = CliTestContext::new(Scenario::None).await; + + { + let cx = cx.with_dist_dir(Scenario::ArchivesV2_2015_01_01); + cx.config + .expect(["rustup", "toolchain", "add", "stable", "beta", "nightly"]) + .await + .is_ok(); + } + + let cx = cx.with_dist_dir(Scenario::SimpleV2); + // update --check with no specific toolchain updates all and returns 100 + cx.config + .expect(["rustup", "update", "--check"]) + .await + .has_code(100); +} + #[tokio::test] async fn default() { let cx = CliTestContext::new(Scenario::SimpleV2).await; diff --git a/tests/suite/cli_rustup_ui/rustup_up_cmd_help_flag.stdout.term.svg b/tests/suite/cli_rustup_ui/rustup_up_cmd_help_flag.stdout.term.svg index 3d78c43eaa..4e0f303db5 100644 --- a/tests/suite/cli_rustup_ui/rustup_up_cmd_help_flag.stdout.term.svg +++ b/tests/suite/cli_rustup_ui/rustup_up_cmd_help_flag.stdout.term.svg @@ -1,4 +1,4 @@ - +