From bebc2bf77df25b6a36992fc87a501919c0506d2a Mon Sep 17 00:00:00 2001 From: Francisco Gouveia Date: Sun, 24 May 2026 15:25:04 +0100 Subject: [PATCH] feat(cli): notify when a new stable Rust release is available --- doc/user-guide/src/environment-variables.md | 3 + src/cli/common.rs | 67 ++++++++++++++++++++- src/cli/rustup_mode.rs | 10 +++ src/settings.rs | 2 + tests/suite/cli_rustup.rs | 5 +- tests/suite/cli_self_upd.rs | 4 ++ 6 files changed, 88 insertions(+), 3 deletions(-) diff --git a/doc/user-guide/src/environment-variables.md b/doc/user-guide/src/environment-variables.md index e45fff7a05..9da2253c2b 100644 --- a/doc/user-guide/src/environment-variables.md +++ b/doc/user-guide/src/environment-variables.md @@ -47,6 +47,9 @@ - `RUSTUP_NO_BACKTRACE`. Disables backtraces on non-panic errors even when `RUST_BACKTRACE` is set. +- `RUSTUP_RELEASE_HINT` (default: `1`). When set to `0`, suppresses the hint + that is shown when a new stable Rust release is (likely) available. + - `RUSTUP_PERMIT_COPY_RENAME` *unstable*. When set, allows rustup to fall-back to copying files if attempts to `rename` result in cross-device link errors. These errors occur on OverlayFS, which is used by [Docker][dc]. This diff --git a/src/cli/common.rs b/src/cli/common.rs index cf0257c958..0924914558 100644 --- a/src/cli/common.rs +++ b/src/cli/common.rs @@ -4,7 +4,9 @@ use std::fmt::Display; use std::fs; use std::io::{BufRead, Write}; use std::path::Path; +use std::str::FromStr; use std::sync::LazyLock; +use std::time::{SystemTime, UNIX_EPOCH}; use std::{cmp, env}; use anstyle::Style; @@ -17,15 +19,18 @@ use tracing_subscriber::{EnvFilter, Registry, reload::Handle}; use crate::{ config::Cfg, - dist::{DistOptions, TargetTuple, ToolchainDesc}, + dist::{DistOptions, PartialToolchainDesc, TargetTuple, ToolchainDesc}, errors::RustupError, install::{InstallMethod, UpdateStatus}, process::Process, - toolchain::{LocalToolchainName, Toolchain, ToolchainName}, + toolchain::{DistributableToolchain, LocalToolchainName, Toolchain, ToolchainName}, utils::{self, ExitCode}, }; pub(crate) const WARN_COMPLETE_PROFILE: &str = "downloading with complete profile isn't recommended unless you are a developer of the rust language"; +const RELEASE_CYCLE_DAYS: i64 = 42; +const NOTIFY_INTERVAL_SECS: u64 = 24 * 60 * 60; +const FALLBACK_RELEASE_DATE: &str = "2026-04-17"; pub(crate) fn confirm(question: &str, default: bool, process: &Process) -> Result { write!(process.stdout().lock(), "{question} ")?; @@ -569,3 +574,61 @@ pub(super) fn update_console_filter( .expect("error reloading `EnvFilter` for console_logger"); } } + +/// Notifies a user with a hint whenever a new Rust release is available. +/// This is only shown at max once per day and only if not in proxy mode. +pub(crate) fn notify_release(cfg: &Cfg<'_>) -> Result<()> { + if cfg.process.var("RUSTUP_RELEASE_HINT").ok().as_deref() == Some("0") { + return Ok(()); + } + + let time_now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + // Limit notifications to at most once per day. + // This is checked before loading the manifest to avoid unnecessary disk I/O. + let last_notified = cfg + .settings_file + .with(|s| Ok(s.last_release_notified_secs.unwrap_or(0)))?; + if time_now.saturating_sub(last_notified) < NOTIFY_INTERVAL_SECS { + return Ok(()); + } + + let default_host = cfg.default_host_tuple()?; + let stable_desc = PartialToolchainDesc::from_str("stable")?.resolve(&default_host)?; + let distributable = DistributableToolchain::new(cfg, stable_desc) + .map_err(|e| anyhow!("stable toolchain unavailable: {e}"))?; + let release_date_str = distributable + .get_manifestation() + .and_then(|m| m.load_manifest()) + .ok() + .flatten() + .map(|m| m.date.clone()) + .unwrap_or_else(|| FALLBACK_RELEASE_DATE.to_owned()); + + let today = chrono::DateTime::from_timestamp(time_now as i64, 0) + .unwrap_or_default() + .date_naive(); + + let release_date = chrono::NaiveDate::parse_from_str(&release_date_str, "%Y-%m-%d") + .map_err(|e| anyhow!("could not parse release date '{}': {e}", release_date_str))?; + + // Skip the hint if fewer than 6 weeks have passed since the last known release. + if (today - release_date).num_days() < RELEASE_CYCLE_DAYS { + return Ok(()); + } + + cfg.settings_file.with_mut(|s| { + s.last_release_notified_secs = Some(time_now); + Ok(()) + })?; + + writeln!( + cfg.process.stderr().lock(), + "hint: a new stable Rust release is available. Run `rustup update stable` to install it." + )?; + + Ok(()) +} diff --git a/src/cli/rustup_mode.rs b/src/cli/rustup_mode.rs index e0f08c8c3f..c93a018e12 100644 --- a/src/cli/rustup_mode.rs +++ b/src/cli/rustup_mode.rs @@ -707,6 +707,12 @@ pub async fn main( let should_warn = subcmd.should_warn_empty_setup(); + let should_notify = !matches.quiet + && !matches!( + subcmd, + RustupSubcmd::Update { .. } | RustupSubcmd::Install { .. } + ); + let exit_code = match subcmd { RustupSubcmd::DumpTestament => common::dump_testament(process), RustupSubcmd::Install { opts } => update(cfg, opts, true).await, @@ -840,6 +846,10 @@ pub async fn main( warn!("no toolchain installed and no default toolchain set\n{DEFAULT_STABLE_HINT}"); } + if should_notify { + let _ = common::notify_release(cfg); + } + Ok(exit_code) } diff --git a/src/settings.rs b/src/settings.rs index 42dc7ceebf..4b2969a485 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -96,6 +96,8 @@ pub struct Settings { pub auto_self_update: Option, #[serde(skip_serializing_if = "Option::is_none")] pub auto_install: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_release_notified_secs: Option, } impl Settings { diff --git a/tests/suite/cli_rustup.rs b/tests/suite/cli_rustup.rs index 7d49246411..8f0458c64a 100644 --- a/tests/suite/cli_rustup.rs +++ b/tests/suite/cli_rustup.rs @@ -880,7 +880,10 @@ installed targets: [HOST_TUPLE] "#]]) - .with_stderr(snapbox::str![[""]]) + .with_stderr(snapbox::str![[r#" +hint: a new stable Rust release is available. Run `rustup update stable` to install it. + +"#]]) .is_ok(); } diff --git a/tests/suite/cli_self_upd.rs b/tests/suite/cli_self_upd.rs index 4f95a9466b..3c77672abd 100644 --- a/tests/suite/cli_self_upd.rs +++ b/tests/suite/cli_self_upd.rs @@ -434,6 +434,7 @@ async fn update_exact() { .with_stderr(snapbox::str![[r#" info: checking for self-update (current version: [CURRENT_VERSION]) info: downloading self-update (new version: [TEST_VERSION]) +hint: a new stable Rust release is available. Run `rustup update stable` to install it. "#]]) .is_ok(); @@ -462,6 +463,7 @@ async fn update_precise() { info: checking for self-update (current version: [CURRENT_VERSION]) info: `RUSTUP_VERSION` has been set to `[TEST_VERSION]` info: downloading self-update (new version: [TEST_VERSION]) +hint: a new stable Rust release is available. Run `rustup update stable` to install it. "#]]); } @@ -650,6 +652,7 @@ async fn update_no_change() { "#]]) .with_stderr(snapbox::str![[r#" info: checking for self-update (current version: [CURRENT_VERSION]) +hint: a new stable Rust release is available. Run `rustup update stable` to install it. "#]]) .is_ok(); @@ -1112,6 +1115,7 @@ async fn update_does_not_overwrite_rustfmt() { .with_stderr(snapbox::str![[r#" info: checking for self-update (current version: [CURRENT_VERSION]) warn: tool `rustfmt` is already installed, remove it from `[..]`, then run `rustup update` to have rustup manage this tool. +hint: a new stable Rust release is available. Run `rustup update stable` to install it. "#]]) .is_ok();