From f025b2837252d9cab94783f25336496de44d3561 Mon Sep 17 00:00:00 2001 From: Francisco Gouveia Date: Tue, 30 Jun 2026 16:48:23 +0100 Subject: [PATCH 1/2] feat(config): add a new `state.toml` file to keep an internal state of rustup --- src/config.rs | 64 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/src/config.rs b/src/config.rs index 7797e7ab59..c07424d4e5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use std::str::FromStr; use anyhow::{Context, Result, anyhow, bail}; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; use thiserror::Error as ThisError; use tracing::{debug, info, trace, warn}; @@ -278,6 +278,7 @@ pub(crate) struct Cfg<'a> { pub profile_override: Option, pub rustup_dir: PathBuf, pub settings_file: SettingsFile, + state_file: StateFile, fallback_settings: Option, pub toolchains_dir: PathBuf, update_hash_dir: PathBuf, @@ -323,6 +324,8 @@ impl<'a> Cfg<'a> { } })?; + let state_file = StateFile::new(rustup_dir.join("state.toml")); + // Centralised file for multi-user systems to provide admin/distributor set initial values. #[cfg(unix)] let fallback_settings = FallbackSettings::new( @@ -355,6 +358,7 @@ impl<'a> Cfg<'a> { profile_override: None, rustup_dir, settings_file, + state_file, fallback_settings, toolchains_dir, update_hash_dir, @@ -1012,6 +1016,7 @@ impl Debug for Cfg<'_> { profile_override, rustup_dir, settings_file, + state_file, fallback_settings, toolchains_dir, update_hash_dir, @@ -1030,6 +1035,7 @@ impl Debug for Cfg<'_> { .field("profile_override", profile_override) .field("rustup_dir", rustup_dir) .field("settings_file", settings_file) + .field("state_file", state_file) .field("fallback_settings", fallback_settings) .field("toolchains_dir", toolchains_dir) .field("update_hash_dir", update_hash_dir) @@ -1045,6 +1051,62 @@ impl Debug for Cfg<'_> { } } +/// Contrary to `settings.toml`, this file is not intended to be user-facing +/// and is intended to merely persistently story rustup's internal state. +#[derive(Clone, Debug, Eq, PartialEq)] +struct StateFile { + path: PathBuf, +} + +impl StateFile { + fn new(path: PathBuf) -> Self { + Self { path } + } + + fn load(&self) -> Result { + if !utils::is_file(&self.path) { + return Ok(State::default()); + } + let content = utils::read_file("state", &self.path)?; + State::parse(&content).with_context(|| RustupError::ParsingFile { + name: "state", + path: self.path.clone(), + }) + } + + fn store(&self, state: &State) -> Result<()> { + utils::write_file("state", &self.path, &state.stringify()?)?; + Ok(()) + } + + fn with Result>(&self, f: F) -> Result { + f(&self.load()?) + } + + fn with_mut Result>(&self, f: F) -> Result { + let mut state = self.load()?; + let result = f(&mut state)?; + self.store(&state)?; + Ok(result) + } +} + +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] +struct State { + #[serde(skip_serializing_if = "Option::is_none")] + last_release_notified_secs: Option, +} + +impl State { + fn parse(data: &str) -> Result { + toml::from_str(data).context("error parsing state") + } + + fn stringify(&self) -> Result { + Ok(toml::to_string(self)?) + } +} + fn default_host_tuple(s: &Settings, process: &Process) -> TargetTuple { s.default_host_tuple .as_ref() From 882b94accff319a6edd5c2397f6a249b1dd7139d Mon Sep 17 00:00:00 2001 From: Francisco Gouveia Date: Sun, 24 May 2026 15:25:04 +0100 Subject: [PATCH 2/2] feat(cli): notify when a new stable Rust release is available --- src/cli/rustup_mode.rs | 21 ++++- src/config.rs | 78 ++++++++++++++++++- src/dist/mod.rs | 53 +++++++++++++ src/settings.rs | 4 +- src/test/clitools.rs | 26 ++++++- tests/suite/cli_rustup.rs | 43 ++++++++++ .../rustup_set_cmd_help_flag.stdout.term.svg | 14 ++-- 7 files changed, 227 insertions(+), 12 deletions(-) diff --git a/src/cli/rustup_mode.rs b/src/cli/rustup_mode.rs index 091e41734e..e5d6edf26e 100644 --- a/src/cli/rustup_mode.rs +++ b/src/cli/rustup_mode.rs @@ -44,7 +44,7 @@ use crate::{ command, component_for_bin, config::{ActiveSource, Cfg}, dist::{ - AutoInstallMode, DistOptions, PartialToolchainDesc, Profile, TargetTuple, + AutoInstallMode, DistOptions, PartialToolchainDesc, Profile, ReleaseHintMode, TargetTuple, download::DownloadCfg, manifest::{Component, ComponentStatus, ManifestWithHash}, }, @@ -649,6 +649,12 @@ enum SetSubcmd { #[arg(value_enum, default_value_t)] auto_install_mode: AutoInstallMode, }, + + /// The new stable release hint mode + ReleaseHint { + #[arg(value_enum, default_value_t)] + release_hint_mode: ReleaseHintMode, + }, } #[tracing::instrument(level = "trace", fields(args = format!("{:?}", process.args_os().collect::>())), skip(process, console_filter))] @@ -705,6 +711,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, @@ -828,6 +840,9 @@ pub async fn main( SetSubcmd::AutoInstall { auto_install_mode } => cfg .set_auto_install(auto_install_mode) .map(|_| ExitCode::SUCCESS), + SetSubcmd::ReleaseHint { release_hint_mode } => cfg + .set_release_hint(release_hint_mode) + .map(|_| ExitCode::SUCCESS), }, RustupSubcmd::Completions { shell, command } => { output_completion_script(shell, command, process) @@ -838,6 +853,10 @@ pub async fn main( warn!("no toolchain installed and no default toolchain set\n{DEFAULT_STABLE_HINT}"); } + if should_notify { + let _ = cfg.notify_release(); + } + Ok(exit_code) } diff --git a/src/config.rs b/src/config.rs index c07424d4e5..d9e8a419d5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,10 +1,13 @@ use std::fmt::{self, Debug, Display}; use std::io; +use std::io::Write; use std::ops::Deref; use std::path::{Path, PathBuf}; use std::str::FromStr; +use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result, anyhow, bail}; +use chrono::{DateTime, NaiveDate}; use serde::{Deserialize, Serialize}; use thiserror::Error as ThisError; use tracing::{debug, info, trace, warn}; @@ -12,8 +15,8 @@ use tracing::{debug, info, trace, warn}; use crate::{ cli::{common, self_update::SelfUpdateMode}, dist::{ - self, AutoInstallMode, DistOptions, PartialToolchainDesc, Profile, TargetTuple, - ToolchainDesc, + self, AutoInstallMode, DistOptions, PartialToolchainDesc, Profile, ReleaseHintMode, + TargetTuple, ToolchainDesc, }, errors::RustupError, fallback_settings::FallbackSettings, @@ -426,6 +429,15 @@ impl<'a> Cfg<'a> { Ok(()) } + pub(crate) fn set_release_hint(&self, mode: ReleaseHintMode) -> Result<()> { + self.settings_file.with_mut(|s| { + s.release_hint = Some(mode); + Ok(()) + })?; + info!("setting release hint mode to {}", mode.as_str()); + Ok(()) + } + pub(crate) fn should_auto_install(&self) -> Result { if !self.allow_auto_install { return Ok(false); @@ -988,6 +1000,64 @@ impl<'a> Cfg<'a> { LocalToolchainName::Path(p) => p.to_path_buf(), } } + + /// 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(&self) -> Result<()> { + if self.settings_file.with(|s| Ok(s.release_hint))? == Some(ReleaseHintMode::Disable) { + 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 = self + .state_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 = self.default_host_tuple()?; + let stable_desc = PartialToolchainDesc::from_str("stable")?.resolve(&default_host)?; + let distributable = DistributableToolchain::new(self, stable_desc) + .map_err(|e| anyhow!("stable toolchain unavailable: {e}"))?; + let release_date_str = match distributable.get_manifestation() { + Ok(manifestation) => match manifestation.load_manifest() { + Ok(Some(manifest)) => manifest.date, + Ok(None) | Err(_) => FALLBACK_RELEASE_DATE.to_owned(), + }, + Err(_) => FALLBACK_RELEASE_DATE.to_owned(), + }; + + let today = DateTime::from_timestamp(time_now as i64, 0) + .unwrap_or_default() + .date_naive(); + + let release_date = 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(()); + } + + self.state_file.with_mut(|s| { + s.last_release_notified_secs = Some(time_now); + Ok(()) + })?; + + writeln!( + self.process.stderr().lock(), + "hint: a new stable Rust release is available, run `rustup update stable` to install it" + )?; + + Ok(()) + } } /// The root path of the release server, without the `/dist` suffix. @@ -1138,6 +1208,10 @@ pub(crate) enum InstalledPath<'a> { Dir { path: &'a Path }, } +const RELEASE_CYCLE_DAYS: i64 = 42; +const NOTIFY_INTERVAL_SECS: u64 = 24 * 60 * 60; +const FALLBACK_RELEASE_DATE: &str = "2026-04-17"; + #[cfg(test)] mod tests { use super::*; diff --git a/src/dist/mod.rs b/src/dist/mod.rs index 7ae05ffd3f..3d00d818e9 100644 --- a/src/dist/mod.rs +++ b/src/dist/mod.rs @@ -866,6 +866,59 @@ impl fmt::Display for AutoInstallMode { } } +#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum ReleaseHintMode { + #[default] + Enable, + Disable, +} + +impl ReleaseHintMode { + pub(crate) fn as_str(&self) -> &'static str { + match self { + Self::Enable => "enable", + Self::Disable => "disable", + } + } +} + +impl ValueEnum for ReleaseHintMode { + fn value_variants<'a>() -> &'a [Self] { + &[Self::Enable, Self::Disable] + } + + fn to_possible_value(&self) -> Option { + Some(PossibleValue::new(self.as_str())) + } + + fn from_str(input: &str, _: bool) -> Result { + ::from_str(input).map_err(|e| e.to_string()) + } +} + +impl FromStr for ReleaseHintMode { + type Err = anyhow::Error; + + fn from_str(mode: &str) -> Result { + match mode { + "enable" => Ok(Self::Enable), + "disable" => Ok(Self::Disable), + _ => Err(anyhow!(format!( + "unknown release hint mode: '{}'; valid modes are {}", + mode, + Self::value_variants().iter().join(", ") + ))), + } + } +} + +impl fmt::Display for ReleaseHintMode { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + pub(crate) struct DistOptions<'cfg, 'a> { pub(super) cfg: &'cfg Cfg<'cfg>, pub(super) toolchain: &'a ToolchainDesc, diff --git a/src/settings.rs b/src/settings.rs index 42dc7ceebf..d01b40e98b 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -9,7 +9,7 @@ use serde::{Deserialize, Serialize}; use tracing::info; use crate::cli::self_update::SelfUpdateMode; -use crate::dist::{AutoInstallMode, Profile}; +use crate::dist::{AutoInstallMode, Profile, ReleaseHintMode}; use crate::errors::RustupError; use crate::utils; @@ -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 release_hint: Option, } impl Settings { diff --git a/src/test/clitools.rs b/src/test/clitools.rs index 2eb666ee92..b69c2c6344 100644 --- a/src/test/clitools.rs +++ b/src/test/clitools.rs @@ -15,9 +15,10 @@ use std::{ process::Command, string::FromUtf8Error, sync::{Arc, LazyLock, RwLock, RwLockWriteGuard}, - time::Instant, + time::{Instant, SystemTime, UNIX_EPOCH}, }; +use chrono::{DateTime, Duration}; use enum_map::{Enum, EnumMap, enum_map}; use snapbox::{IntoData, RedactedValue, Redactions, assert_data_eq}; use tempfile::TempDir; @@ -553,6 +554,8 @@ pub enum Scenario { /// Three dates, v2 manifests, host and MULTI_ARCH1 in first, host only in second, /// host and MULTI_ARCH1 but no RLS in last MissingComponentMulti, + /// One recent date (within the last 6 weeks), v2 manifests + RecentStable, } impl Scenario { @@ -641,6 +644,16 @@ impl Scenario { .multi_arch() .with_rls(RlsStatus::Unavailable), ], + Self::RecentStable => { + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let recent = (DateTime::from_timestamp(now as i64, 0).unwrap() - Duration::days(7)) + .format("%Y-%m-%d") + .to_string(); + vec![Release::stable("1.1.0", &recent)] + } }; let vs = match self { @@ -661,7 +674,8 @@ impl Scenario { | Self::HostGoesMissingBefore | Self::HostGoesMissingAfter | Self::MissingComponent - | Self::MissingComponentMulti => vec![MockManifestVersion::V2], + | Self::MissingComponentMulti + | Self::RecentStable => vec![MockManifestVersion::V2], }; MockDistServer { @@ -718,6 +732,7 @@ impl ConstState { Scenario::SimpleV2 => RwLock::new(None), Scenario::Unavailable => RwLock::new(None), Scenario::UnavailableRls => RwLock::new(None), + Scenario::RecentStable => RwLock::new(None), }, } } @@ -947,6 +962,13 @@ impl CliTestContext { config.distdir = Some(config.test_dist_dir.path().to_path_buf()); } + // Disable the new release hint by default to avoid extra + // output to be matched during the tests. + config + .expect(["rustup", "set", "release-hint", "disable"]) + .await + .is_ok(); + Self { config, _test_dir } } diff --git a/tests/suite/cli_rustup.rs b/tests/suite/cli_rustup.rs index 7d49246411..af2851caf2 100644 --- a/tests/suite/cli_rustup.rs +++ b/tests/suite/cli_rustup.rs @@ -880,6 +880,49 @@ installed targets: [HOST_TUPLE] "#]]) + .is_ok(); +} + +#[tokio::test] +async fn notify_release_hint_at_most_once_per_day() { + let cx = CliTestContext::new(Scenario::SimpleV2).await; + cx.config + .expect(["rustup", "set", "release-hint", "enable"]) + .await + .is_ok(); + cx.config + .expect(["rustup", "update", "stable"]) + .await + .is_ok(); + cx.config + .expect(["rustup", "show"]) + .await + .with_stderr(snapbox::str![[r#" +hint: a new stable Rust release is available, run `rustup update stable` to install it + +"#]]) + .is_ok(); + cx.config + .expect(["rustup", "show"]) + .await + .with_stderr(snapbox::str![[""]]) + .is_ok(); +} + +#[tokio::test] +async fn notify_release_hint_skipped_for_recent_release() { + let cx = CliTestContext::new(Scenario::RecentStable).await; + cx.config + .expect(["rustup", "set", "release-hint", "enable"]) + .await + .is_ok(); + cx.config + .expect(["rustup", "update", "stable"]) + .await + .is_ok(); + cx.config + .expect(["rustup", "show"]) + .await .with_stderr(snapbox::str![[""]]) .is_ok(); } diff --git a/tests/suite/cli_rustup_ui/rustup_set_cmd_help_flag.stdout.term.svg b/tests/suite/cli_rustup_ui/rustup_set_cmd_help_flag.stdout.term.svg index dbd73fe467..aec5f1489a 100644 --- a/tests/suite/cli_rustup_ui/rustup_set_cmd_help_flag.stdout.term.svg +++ b/tests/suite/cli_rustup_ui/rustup_set_cmd_help_flag.stdout.term.svg @@ -1,4 +1,4 @@ - +