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
21 changes: 20 additions & 1 deletion src/cli/rustup_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
},
Expand Down Expand Up @@ -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::<Vec<_>>())), skip(process, console_filter))]
Expand Down Expand Up @@ -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 { .. }
);
Comment thread
djc marked this conversation as resolved.

let exit_code = match subcmd {
RustupSubcmd::DumpTestament => common::dump_testament(process),
RustupSubcmd::Install { opts } => update(cfg, opts, true).await,
Expand Down Expand Up @@ -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)
Expand All @@ -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();

@rami3l rami3l Jul 3, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Sorry I have almost missed this, but can we at least have some error logging here? A simple if should_notify && let Err(e) = cfg.notify_release() { warn!(...) } would do 🙏

View changes since the review

}

Ok(exit_code)
}

Expand Down
142 changes: 139 additions & 3 deletions src/config.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
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 serde::Deserialize;
use chrono::{DateTime, NaiveDate};
use serde::{Deserialize, Serialize};
use thiserror::Error as ThisError;
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,
Expand Down Expand Up @@ -278,6 +281,7 @@ pub(crate) struct Cfg<'a> {
pub profile_override: Option<Profile>,
pub rustup_dir: PathBuf,
pub settings_file: SettingsFile,
state_file: StateFile,
fallback_settings: Option<FallbackSettings>,
pub toolchains_dir: PathBuf,
update_hash_dir: PathBuf,
Expand Down Expand Up @@ -323,6 +327,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(
Expand Down Expand Up @@ -355,6 +361,7 @@ impl<'a> Cfg<'a> {
profile_override: None,
rustup_dir,
settings_file,
state_file,
fallback_settings,
toolchains_dir,
update_hash_dir,
Expand Down Expand Up @@ -422,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<bool> {
if !self.allow_auto_install {
return Ok(false);
Expand Down Expand Up @@ -984,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.
Expand Down Expand Up @@ -1012,6 +1086,7 @@ impl Debug for Cfg<'_> {
profile_override,
rustup_dir,
settings_file,
state_file,
fallback_settings,
toolchains_dir,
update_hash_dir,
Expand All @@ -1030,6 +1105,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)
Expand All @@ -1045,6 +1121,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<State> {
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<T, F: FnOnce(&State) -> Result<T>>(&self, f: F) -> Result<T> {
f(&self.load()?)
}

fn with_mut<T, F: FnOnce(&mut State) -> Result<T>>(&self, f: F) -> Result<T> {
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<u64>,
}

impl State {
fn parse(data: &str) -> Result<Self> {
toml::from_str(data).context("error parsing state")
}

fn stringify(&self) -> Result<String> {
Ok(toml::to_string(self)?)
}
}

fn default_host_tuple(s: &Settings, process: &Process) -> TargetTuple {
s.default_host_tuple
.as_ref()
Expand Down Expand Up @@ -1076,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::*;
Expand Down
53 changes: 53 additions & 0 deletions src/dist/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PossibleValue> {
Some(PossibleValue::new(self.as_str()))
}

fn from_str(input: &str, _: bool) -> Result<Self, String> {
<Self as FromStr>::from_str(input).map_err(|e| e.to_string())
}
}

impl FromStr for ReleaseHintMode {
type Err = anyhow::Error;

fn from_str(mode: &str) -> Result<Self> {
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,
Expand Down
4 changes: 3 additions & 1 deletion src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -96,6 +96,8 @@ pub struct Settings {
pub auto_self_update: Option<SelfUpdateMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auto_install: Option<AutoInstallMode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub release_hint: Option<ReleaseHintMode>,
}

impl Settings {
Expand Down
Loading
Loading