From 02d72a7615815a195a1b07a04985cb239d055983 Mon Sep 17 00:00:00 2001 From: jdalton Date: Mon, 10 Aug 2026 00:14:12 -0700 Subject: [PATCH 1/3] fix(cli): stop deleting the [update] config section, and give it a mode update_checker read [update] server through its own private structs, but PerryConfig -- what save_config writes -- had no field for it, and serde rebuilds the file from the struct. Every save therefore deleted the section, and save_config is called from the telemetry prompt, the compat-report prompt, the beta notice and the setup wizards. PerryConfig now owns the section and the duplicate reader is gone, so there is one loader rather than two views of the same file. On top of that the section gains a mode (off/notify/prompt/auto, default notify -- what Perry did before), a check interval and a notify throttle, resolved once in update_policy and consulted at BOTH main.rs hook sites. They previously asked should_skip_check() separately and the notice site had its own cached-status fallback, so wiring a policy into one would have left mode=off still printing from the warm cache. prompt and auto parse and resolve but do not yet install; that is the next slice. Two data-loss paths are closed: an unrecognized mode no longer fails the whole-file parse (which would discard the license key on the next save), and unknown keys inside [update] survive a round trip. The cache write is now tmp+rename via replace_path rather than an in-place truncate, because two perry processes can race and because a plain rename onto an existing file fails on Windows. fetch_latest_version carries the last-notified timestamp across a refresh, without which the notify throttle would reset every check interval. --- changelog.d/7749-update-config-surface.md | 101 ++++ .../src/commands/publish/saved_config.rs | 62 +++ crates/perry/src/main.rs | 47 +- crates/perry/src/update_checker.rs | 115 ++++- crates/perry/src/update_policy.rs | 470 ++++++++++++++++++ 5 files changed, 765 insertions(+), 30 deletions(-) create mode 100644 changelog.d/7749-update-config-surface.md create mode 100644 crates/perry/src/update_policy.rs diff --git a/changelog.d/7749-update-config-surface.md b/changelog.d/7749-update-config-surface.md new file mode 100644 index 0000000000..661280bb14 --- /dev/null +++ b/changelog.d/7749-update-config-surface.md @@ -0,0 +1,101 @@ +### Fixed + +**The `[update]` section of `~/.perry/config.toml` was deleted every time +anything else saved that file.** `update_checker` read `[update] server` +through its own private structs, but `PerryConfig` — the struct `save_config` +writes — had no field for it, and serde rebuilds the file from the struct. So +answering the telemetry prompt, the compatibility-report prompt or the beta +notice, or running a setup wizard, silently discarded the user's update +settings. + +`PerryConfig` now owns the section, and `update_checker`'s private duplicate +reader is gone, so there is one loader rather than two views of the same file. + +### Added + +**An `[update]` section, so update behaviour is a setting rather than an +all-or-nothing environment variable.** Before this, the only control was +`PERRY_NO_UPDATE_CHECK`; there was no way to check less often, and no way to +say anything once instead of in every shell. + +```toml +[update] +mode = "notify" # off | notify | prompt | auto +check_interval_hours = 24 # how often to ask what the latest version is +notify_interval_hours = 0 # 0 = mention it every run, which is what Perry did +prompt_default = false # what Enter means in prompt mode +``` + +`off` and `notify` are the two behaviours that already existed, and `notify` is +the default, so a user who never opens the config sees no change. `prompt` and +`auto` are accepted and documented here but not yet wired to an install — that +is the next slice, deliberately separate, because replacing the binary a user +is running deserves its own review. + +`PERRY_UPDATE_MODE` sets the same thing for one run. + +
+Precedence, and the two rules that outrank everything + +Strongest first: + +1. `PERRY_NO_UPDATE_CHECK`, and now also `NO_UPDATE_NOTIFIER` — the de-facto + ecosystem spelling (npm's `update-notifier`, with `GH_NO_UPDATE_NOTIFIER` + and `DENO_NO_UPDATE_CHECK` by analogy). Someone who sets either has already + told every tool on their machine what they want, and no config file may + argue: these beat `PERRY_UPDATE_MODE=auto` and a configured `auto` alike. +2. `CI`, by presence rather than by an exact `"true"`/`"1"` match, since CI + systems are not consistent about the value. An exported-but-empty `CI=` is + still *not* CI, matching `is-ci`'s truthiness test. +3. A non-terminal stderr, or `--format` asking for machine-readable output. + Nobody is reading a notice in either case, and interleaving one into + parseable output is the classic update-notifier bug report. +4. `PERRY_UPDATE_MODE`, then the config file, then `notify`. + +An unparseable `PERRY_UPDATE_MODE` falls through to the config rather than +selecting something: `of` must not quietly mean `off`, and must certainly not +mean `auto`. +
+ +
+Two ways a config file could lose data, both closed + +An unrecognized `mode` deserializes to a known-unknown rather than failing. +That matters more than it looks: `load_config` parses the whole file as one +document and falls back to defaults on any error, so a rejected `mode` would +have discarded the user's license key and API token with it — and the next +save would have written that loss to disk. A typo now costs one warning line +and the default mode. + +Unrecognized *keys* inside `[update]` are preserved across a load/save round +trip, so a key written by a newer Perry — or by hand, ahead of a feature +landing — is not dropped by an older one. That is the same defect as the +erasure above, one level down. +
+ +
+Two correctness details in the checker itself + +The cache is now written to a temporary file and renamed over the target, +rather than truncated in place. Two `perry` processes can be running at once — +one finishing a background check while another records a notice — and a reader +arriving mid-write got a partial file, which `load_cache` discards entirely. +The rename goes through the existing `replace_path` helper because a plain +`fs::rename` onto an existing file fails on Windows, which would have made +every write after the first silently do nothing. + +`fetch_latest_version` rebuilds the cache struct from scratch, so it now +carries the last-notified timestamp across a refresh. Without that, +`notify_interval_hours` would reset every check interval and quietly stop +working. +
+ +**Tests.** 13 new, all in the required per-pull-request job: the precedence +table including that the kill switches beat everything and that an empty `CI` +is not CI; that an unknown mode leaves the rest of the file intact; that +unknown keys survive a round trip; the throttle arithmetic including that an +unreadable timestamp notifies rather than staying silent forever; and the +erasure regression itself. Verified by sabotage — marking the new field +`#[serde(skip)]` turns the erasure test red. + +`cargo test -p perry`: 902 passed, 0 failed. diff --git a/crates/perry/src/commands/publish/saved_config.rs b/crates/perry/src/commands/publish/saved_config.rs index 118dde5902..ea7d52bf54 100644 --- a/crates/perry/src/commands/publish/saved_config.rs +++ b/crates/perry/src/commands/publish/saved_config.rs @@ -35,6 +35,17 @@ pub(crate) struct PerryConfig { pub(crate) telemetry: Option, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) beta: Option, + /// The `[update]` section. + /// + /// ★ This field is why the section survives a save. `update_checker` read + /// `[update] server` through its own private structs, but `PerryConfig` — + /// which is what `save_config` writes — had no field for it. serde + /// reconstructs the file from this struct, so every save silently deleted + /// the user's `[update]` section, and `save_config` is called from the + /// telemetry prompt, the compatibility-report prompt, the beta notice and + /// the setup wizards. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) update: Option, } #[derive(Default, Debug, Clone, Serialize, Deserialize)] @@ -264,3 +275,54 @@ pub(crate) fn prompt_input(prompt: &str, default: Option<&str>) -> Option None, } } + +#[cfg(test)] +mod saved_config_tests { + use super::*; + + /// ★ The erasure regression. + /// + /// `update_checker` read `[update] server` through its own private structs, + /// but `PerryConfig` — which is what `save_config` writes — had no field + /// for it. serde rebuilds the file from this struct, so any save deleted + /// the section: the telemetry prompt, the compatibility-report prompt, the + /// beta notice and the setup wizards all call `save_config`, so a user + /// answering one prompt silently lost their update settings. + /// + /// String-level rather than filesystem-level on purpose: the bug is in the + /// serde round trip, and a test that wrote to `~/.perry` would depend on + /// the developer's home directory. + #[test] + fn the_update_section_survives_a_round_trip() { + let original = r#" +license_key = "keep-me" + +[telemetry] +enabled = true +client_id = "abc" + +[update] +mode = "prompt" +server = "https://updates.example.test/latest" +check_interval_hours = 6 +"#; + let config: PerryConfig = + toml::from_str(original).expect("the fixture must parse as a whole config"); + let written = toml::to_string_pretty(&config).expect("serialize"); + + assert!( + written.contains("[update]"), + "the [update] section was dropped on save:\n{written}" + ); + assert!( + written.contains("updates.example.test"), + "the update server was dropped on save:\n{written}" + ); + assert!( + written.contains("check_interval_hours"), + "an [update] key was dropped on save:\n{written}" + ); + // ...and nothing else was lost on the way past. + assert!(written.contains("keep-me") && written.contains("[telemetry]")); + } +} diff --git a/crates/perry/src/main.rs b/crates/perry/src/main.rs index 5d19940525..36105e894e 100644 --- a/crates/perry/src/main.rs +++ b/crates/perry/src/main.rs @@ -11,6 +11,7 @@ mod telemetry; #[cfg(test)] mod test_env_lock; mod update_checker; +mod update_policy; use anyhow::Result; use clap::{Parser, Subcommand, ValueEnum}; @@ -441,15 +442,21 @@ fn main_inner() -> Result<()> { // env overrides as the generic telemetry channel). compat_reports::install_sink(); - // Spawn background update check (non-blocking, cached for 24h) + // Resolve the update policy ONCE, here, and use it at both hook sites. + // + // These used to ask `should_skip_check()` separately, and the notice site + // had its own cached-status fallback. Wiring a policy into only one of them + // leaves the other honouring the old rules — so a user who set + // `mode = "off"` would still get notices from the warm-cache path. One + // value, read once, is what makes "off" mean off. let is_update_cmd = matches!(cli.command, Some(Commands::Update(_))); - let bg_check = if !cli.quiet && !is_update_cmd && !update_checker::should_skip_check() { - if update_checker::is_cache_stale() { - let (_handle, rx) = update_checker::spawn_background_check(); - Some(rx) - } else { - None // will check cache after command runs - } + let update_policy = update_policy::UpdatePolicy::resolve(); + let update_surface_active = !cli.quiet && !is_update_cmd && update_policy.is_active(); + let bg_check = if update_surface_active + && update_checker::is_cache_stale_with(update_policy.check_interval) + { + let (_handle, rx) = update_checker::spawn_background_check(); + Some(rx) } else { None }; @@ -534,14 +541,12 @@ fn main_inner() -> Result<()> { } // Print update notice if available (to stderr, non-blocking) - if !cli.quiet && !is_update_cmd { + if update_surface_active { let use_stderr_color = !cli.no_color && std::io::stderr().is_terminal(); let status = if let Some(rx) = bg_check { rx.recv_timeout(std::time::Duration::from_millis(100)).ok() - } else if !update_checker::should_skip_check() { - Some(update_checker::check_cached_status()) } else { - None + Some(update_checker::check_cached_status()) }; if let Some(update_checker::UpdateStatus::UpdateAvailable { @@ -550,7 +555,23 @@ fn main_inner() -> Result<()> { release_url, }) = status { - update_checker::print_update_notice(¤t, &latest, &release_url, use_stderr_color); + // `notify_interval_hours` throttles repeats of the SAME available + // update. It defaults to 0 — a notice every run, which is what + // Perry did before — so this is inert until someone asks for it. + let last = update_checker::load_cache().and_then(|c| c.last_notification); + if update_policy::should_notify( + update_policy.notify_interval, + last.as_deref(), + &update_checker::now_rfc3339_public(), + ) { + update_checker::print_update_notice( + ¤t, + &latest, + &release_url, + use_stderr_color, + ); + update_checker::record_notification(); + } } } diff --git a/crates/perry/src/update_checker.rs b/crates/perry/src/update_checker.rs index 6fa22712d3..25d806d9fe 100644 --- a/crates/perry/src/update_checker.rs +++ b/crates/perry/src/update_checker.rs @@ -26,6 +26,13 @@ pub struct UpdateCache { pub last_check: String, pub latest_version: String, pub release_url: String, + /// When the user was last told about this update, if ever. + /// + /// `default` + `skip_serializing_if` so a cache written by an older Perry + /// still loads, and a cache that has never notified stays the shape it + /// always was. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_notification: Option, } #[derive(Debug, Deserialize)] @@ -71,11 +78,52 @@ fn save_cache(cache: &UpdateCache) { if let Some(parent) = path.parent() { let _ = fs::create_dir_all(parent); } - if let Ok(content) = serde_json::to_string_pretty(cache) { - let _ = fs::write(&path, content); + let Ok(content) = serde_json::to_string_pretty(cache) else { + return; + }; + // Two `perry` invocations can be in here at once — a background check + // finishing in one while another records a notice. A plain `fs::write` + // truncates first, so a reader arriving mid-write gets a partial file and + // `load_cache` throws the whole thing away. Write beside the target and + // rename over it, which is atomic for readers on every platform we ship. + // + // `replace_path` rather than `fs::rename`: on Windows a rename onto an + // EXISTING file fails, so every write after the first would silently do + // nothing and the throttle would never advance. + let tmp = path.with_extension("json.tmp"); + if fs::write(&tmp, content).is_err() { + let _ = fs::remove_file(&tmp); + return; + } + if replace_path(&tmp, &path).is_err() { + let _ = fs::remove_file(&tmp); } } +/// Record that the user has just been told about an available update. +/// +/// A no-op when there is no cache: the notice can only have come from one, and +/// inventing a file here would fabricate a `last_check` that never happened. +pub fn record_notification() { + let Some(mut cache) = load_cache() else { + return; + }; + cache.last_notification = Some(now_rfc3339()); + save_cache(&cache); +} + +/// `now` as RFC3339, for callers outside this module that need to compare +/// against a cached timestamp. +pub fn now_rfc3339_public() -> String { + now_rfc3339() +} + +/// Seconds since the epoch for an RFC3339 timestamp, or `None` if it cannot be +/// read. Exposed for `update_policy`'s throttle arithmetic. +pub fn parse_rfc3339(s: &str) -> Option { + chrono_parse_rfc3339(s).map(|t| t as i64) +} + pub fn should_skip_check() -> bool { if std::env::var("PERRY_NO_UPDATE_CHECK").is_ok_and(|v| v == "1" || v == "true") { return true; @@ -90,6 +138,12 @@ pub fn should_skip_check() -> bool { } pub fn is_cache_stale() -> bool { + is_cache_stale_with(CACHE_MAX_AGE) +} + +/// Staleness against a caller-chosen interval, so `[update] check_interval_hours` +/// means something. `is_cache_stale` is this with the shipped default. +pub fn is_cache_stale_with(max_age: Duration) -> bool { let cache = match load_cache() { Some(c) => c, None => return true, @@ -112,7 +166,7 @@ pub fn is_cache_stale() -> bool { .unwrap_or_default() .as_secs(); - now.saturating_sub(last_check) > CACHE_MAX_AGE.as_secs() + now.saturating_sub(last_check) > max_age.as_secs() } /// Simple RFC3339 timestamp to unix seconds parser @@ -255,21 +309,14 @@ fn get_update_servers() -> Vec { servers } +/// The configured update server, read through the SHARED config loader. +/// +/// This used to parse `~/.perry/config.toml` again into a private pair of +/// structs. That is what let `[update]` be silently erased: the real +/// `PerryConfig` had no field for it, so every `save_config` reconstructed the +/// file without it and threw the section away. fn load_config_update_server() -> Option { - let path = dirs::home_dir()?.join(".perry").join("config.toml"); - let content = fs::read_to_string(&path).ok()?; - - #[derive(Deserialize)] - struct Config { - update: Option, - } - #[derive(Deserialize)] - struct UpdateConfig { - server: Option, - } - - let config: Config = toml::from_str(&content).ok()?; - config.update?.server + crate::commands::publish::load_config().update?.server } fn fetch_latest_version() -> Result { @@ -282,6 +329,7 @@ fn fetch_latest_version() -> Result { let servers = get_update_servers(); let mut last_err = None; + let prior_notification = load_cache().and_then(|c| c.last_notification); for url in &servers { match client.get(url).send() { @@ -303,6 +351,12 @@ fn fetch_latest_version() -> Result { last_check: now_rfc3339(), latest_version: version, release_url: info.html_url, + // Carry the notice timestamp across the refresh. This + // struct is rebuilt from scratch, so dropping the field + // here would reset the notify throttle on every check + // and `notify_interval_hours` would silently do nothing + // beyond one check interval. + last_notification: prior_notification.clone(), }; save_cache(&cache); return Ok(cache); @@ -1498,6 +1552,7 @@ mod tests { last_check: "2025-01-15T10:30:00Z".to_string(), latest_version: "0.2.171".to_string(), release_url: "https://github.com/PerryTS/perry/releases/tag/v0.2.171".to_string(), + last_notification: Some("2025-01-15T11:00:00Z".to_string()), }; let json = serde_json::to_string(&cache).unwrap(); @@ -1505,6 +1560,32 @@ mod tests { assert_eq!(cache, parsed); } + /// A cache file written by a Perry that predates the notify throttle must + /// still load. Without `serde(default)` it would fail to parse, `load_cache` + /// would return `None`, and every user's first run on the new build would + /// re-check the network for no reason. + #[test] + fn a_cache_without_the_notification_field_still_loads() { + let legacy = r#"{ + "last_check": "2025-01-15T10:30:00Z", + "latest_version": "0.2.171", + "release_url": "https://example.test/v0.2.171" + }"#; + let parsed: UpdateCache = + serde_json::from_str(legacy).expect("a pre-throttle cache must still parse"); + assert_eq!(parsed.last_notification, None); + assert_eq!(parsed.latest_version, "0.2.171"); + + // ...and a cache that has never notified round-trips to the same shape + // it always had, rather than growing a null field. + let written = serde_json::to_string(&parsed).unwrap(); + assert!( + !written.contains("last_notification"), + "an unset field must not be written: {written}" + ); + } + + #[test] fn test_is_cache_stale_no_cache() { // When there's no cache file, it should be stale diff --git a/crates/perry/src/update_policy.rs b/crates/perry/src/update_policy.rs new file mode 100644 index 0000000000..6085c699c0 --- /dev/null +++ b/crates/perry/src/update_policy.rs @@ -0,0 +1,470 @@ +//! What the update checker is allowed to do this run, and how often. +//! +//! Perry has checked for updates since long before this module existed, but the +//! only way to influence it was `PERRY_NO_UPDATE_CHECK`, which is all-or- +//! nothing. There was no way to say "check less often", "ask me before +//! installing", or "just install it" — and no way to say any of them once, in a +//! config file, instead of in every shell. +//! +//! This is that surface: an `[update]` section in `~/.perry/config.toml` with a +//! mode, plus two intervals. The default is exactly what Perry did before, so a +//! user who never opens the config sees no change. +//! +//! # Why the modes are shaped this way +//! +//! `off` and `notify` are the two behaviours that already existed. `prompt` and +//! `auto` are new, and both are deliberately harder to reach than notify: +//! replacing the binary a user is running is not something to do because a +//! default was convenient. +//! +//! # Precedence, and why the kill switch stays on top +//! +//! An environment variable always beats the config file, because the config +//! file is a preference and the environment is a decision about *this* run — +//! usually made by a script, a CI job, or someone debugging. The one rule that +//! outranks everything is `PERRY_NO_UPDATE_CHECK`: it is the documented way to +//! make Perry stop touching the network, and a config file must never be able +//! to re-enable that. `NO_UPDATE_NOTIFIER` is honoured for the same reason — +//! it is the de-facto ecosystem-wide spelling (npm's `update-notifier`, and +//! `GH_NO_UPDATE_NOTIFIER` / `DENO_NO_UPDATE_CHECK` by analogy), and someone +//! who sets it has already told every other tool what they want. + +use std::io::IsTerminal; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +/// How much of the update surface is switched on. +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum UpdateMode { + /// Never check, never say anything. + Off, + /// Check in the background, print one line at the end of the run when a + /// newer version exists. Perry's behaviour before this module, and the + /// default. + #[default] + Notify, + /// Notify, then ask whether to install. Only ever on an interactive + /// terminal, and only after a command that succeeded. + Prompt, + /// Install without asking, at the end of a successful run. Opt-in only. + Auto, + /// Anything this build does not recognise. + /// + /// A typo in a config file must not take the whole file down with it — + /// `load_config` parses the file as a unit and falls back to defaults on + /// error, so a rejected `mode` would silently discard the user's license + /// key along with it. Unknown spellings therefore parse, and + /// [`UpdatePolicy::resolve`] treats them as `notify` after warning once. + #[serde(other)] + Unknown, +} + +impl UpdateMode { + pub(crate) fn parse(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "off" => Some(Self::Off), + "notify" => Some(Self::Notify), + "prompt" => Some(Self::Prompt), + "auto" => Some(Self::Auto), + _ => None, + } + } +} + +/// The `[update]` section of `~/.perry/config.toml`. +/// +/// Every field is optional so a partially-written section round-trips without +/// inventing values the user did not write. +#[derive(Default, Debug, Clone, Serialize, Deserialize)] +pub(crate) struct UpdateConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) mode: Option, + /// Where to ask what the latest version is. Pre-dates this module. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) server: Option, + /// Hours between background checks. Default 24. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) check_interval_hours: Option, + /// Minimum hours between two notices about the same available update. + /// Default 0, which is "every run" — what Perry did before. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) notify_interval_hours: Option, + /// What Enter means at the `prompt` mode question. Default false. + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) prompt_default: Option, + /// Keys this build does not know about. + /// + /// Without this, a `[update]` key written by a NEWER Perry — or by hand, + /// ahead of a feature landing — is dropped on the next save, because + /// serde reconstructs the file from the struct. That is the same defect + /// this module was written to fix, one level down, so the escape hatch is + /// not optional. + #[serde(flatten, skip_serializing_if = "toml::Table::is_empty")] + pub(crate) extra: toml::Table, +} + +impl UpdateConfig { + fn check_interval(&self) -> Duration { + Duration::from_secs(self.check_interval_hours.unwrap_or(24).saturating_mul(3600)) + } + + fn notify_interval(&self) -> Duration { + Duration::from_secs(self.notify_interval_hours.unwrap_or(0).saturating_mul(3600)) + } +} + +/// Everything the update surface needs to know about this run, resolved once. +#[derive(Debug, Clone, Copy)] +pub(crate) struct UpdatePolicy { + /// Already accounts for the environment, CI, and whether stderr is a + /// terminal — so a caller never has to re-derive "should I be quiet". + pub(crate) mode: UpdateMode, + pub(crate) check_interval: Duration, + pub(crate) notify_interval: Duration, + pub(crate) prompt_default: bool, +} + +/// The environment inputs, gathered in one place so the decision itself is a +/// pure function that tests can drive without touching the process. +#[derive(Debug, Clone, Copy, Default)] +pub(crate) struct PolicyEnv<'a> { + pub(crate) no_update_check: Option<&'a str>, + pub(crate) no_update_notifier: Option<&'a str>, + pub(crate) mode: Option<&'a str>, + pub(crate) ci: Option<&'a str>, + pub(crate) stderr_is_terminal: bool, + /// True when the command's output is machine-readable, in which case even + /// a stderr notice is unwelcome: it lands in the middle of whatever the + /// caller is parsing, and the classic update-notifier bug report is + /// exactly that. + pub(crate) structured_output: bool, +} + +fn env_is_on(raw: Option<&str>) -> bool { + matches!( + raw.map(|s| s.trim().to_ascii_lowercase()).as_deref(), + Some("1") | Some("true") | Some("on") | Some("yes") + ) +} + +/// Present and not explicitly false. +/// +/// Broader than the `"true"`/`"1"` test Perry used before, because CI systems +/// are not consistent about the value — but an EMPTY value still counts as +/// absent, matching what the ecosystem does: npm's `is-ci`, which +/// `update-notifier` is built on, tests JS truthiness, and `CI=""` is falsy +/// there. An exported-but-empty variable is not somebody telling us they are +/// in CI. +fn env_is_present(raw: Option<&str>) -> bool { + !matches!( + raw.map(|s| s.trim().to_ascii_lowercase()).as_deref(), + None | Some("") | Some("0") | Some("false") | Some("off") | Some("no") + ) +} + +/// Resolve the mode for this run. Pure — every input is an argument. +pub(crate) fn resolve_mode(env: PolicyEnv<'_>, configured: Option) -> UpdateMode { + // The kill switches come first and cannot be overridden by anything, + // including `PERRY_UPDATE_MODE=auto`. Somebody who has said "do not check" + // must not be talked out of it by a config file or a second variable. + if env_is_on(env.no_update_check) || env_is_present(env.no_update_notifier) { + return UpdateMode::Off; + } + // CI never wants a notice, and REALLY never wants an unattended install + // partway through a pipeline. + if env_is_present(env.ci) { + return UpdateMode::Off; + } + // Nobody is reading stderr, or something is parsing stdout. Either way + // there is no audience for a notice and no consent available for a prompt. + if !env.stderr_is_terminal || env.structured_output { + return UpdateMode::Off; + } + if let Some(raw) = env.mode { + // An unparseable value falls through to the config rather than + // silently selecting something: `PERRY_UPDATE_MODE=of` should not mean + // `off`, and it should not mean `auto` either. + if let Some(mode) = UpdateMode::parse(raw) { + return mode; + } + } + match configured { + Some(UpdateMode::Unknown) | None => UpdateMode::Notify, + Some(mode) => mode, + } +} + +impl UpdatePolicy { + /// Read the environment and the config file once, and decide. + pub(crate) fn resolve() -> Self { + Self::resolve_with(structured_output_selected()) + } + + pub(crate) fn resolve_with(structured_output: bool) -> Self { + let no_update_check = std::env::var("PERRY_NO_UPDATE_CHECK").ok(); + let no_update_notifier = std::env::var("NO_UPDATE_NOTIFIER").ok(); + let mode_var = std::env::var("PERRY_UPDATE_MODE").ok(); + let ci = std::env::var("CI").ok(); + let env = PolicyEnv { + no_update_check: no_update_check.as_deref(), + no_update_notifier: no_update_notifier.as_deref(), + mode: mode_var.as_deref(), + ci: ci.as_deref(), + stderr_is_terminal: std::io::stderr().is_terminal(), + structured_output, + }; + + let config = crate::commands::publish::load_config().update.unwrap_or_default(); + if matches!(config.mode, Some(UpdateMode::Unknown)) { + // One line, once, on the way past. Loud enough to fix, quiet + // enough not to be the thing the user remembers about the run. + eprintln!( + "warning: unrecognized `[update] mode` in ~/.perry/config.toml; \ + using \"notify\". Valid values: off, notify, prompt, auto." + ); + } + + Self { + mode: resolve_mode(env, config.mode), + check_interval: config.check_interval(), + notify_interval: config.notify_interval(), + prompt_default: config.prompt_default.unwrap_or(false), + } + } + + /// Is the update surface switched on at all this run? + pub(crate) fn is_active(&self) -> bool { + self.mode != UpdateMode::Off + } +} + +/// Whether the CLI was asked for machine-readable output. +/// +/// Read straight from the raw arguments rather than from the parsed `Cli`, +/// because the policy is resolved before dispatch and this is the one input +/// that has to be right on the very first line of output. +fn structured_output_selected() -> bool { + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + if let Some(value) = arg.strip_prefix("--format=") { + return !value.eq_ignore_ascii_case("text"); + } + if arg == "--format" { + return !args + .next() + .is_some_and(|value| value.eq_ignore_ascii_case("text")); + } + } + false +} + +/// Has enough time passed since the last notice about this same update? +/// +/// Pure so the interval arithmetic is testable without a clock or a cache +/// file. `last_notification` is whatever the cache recorded, if anything. +pub(crate) fn should_notify( + notify_interval: Duration, + last_notification: Option<&str>, + now_rfc3339: &str, +) -> bool { + if notify_interval.is_zero() { + return true; + } + let (Some(last), Some(now)) = ( + crate::update_checker::parse_rfc3339(last_notification.unwrap_or("")), + crate::update_checker::parse_rfc3339(now_rfc3339), + ) else { + // Never notified, or a timestamp this build cannot read. Both mean the + // throttle has nothing to stand on, and staying silent on a damaged + // cache would hide updates indefinitely. + return true; + }; + now.saturating_sub(last) >= notify_interval.as_secs() as i64 +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A terminal with nothing suppressing it — the shape every precedence + /// case below varies one field of. + fn tty() -> PolicyEnv<'static> { + PolicyEnv { + stderr_is_terminal: true, + ..PolicyEnv::default() + } + } + + #[test] + fn the_default_is_what_perry_did_before() { + assert_eq!(resolve_mode(tty(), None), UpdateMode::Notify); + } + + /// The kill switch outranks everything, including someone else's attempt + /// to turn the surface up. A user who has said "do not check" is not + /// negotiating. + #[test] + fn the_kill_switch_beats_every_other_input() { + let env = PolicyEnv { + no_update_check: Some("1"), + mode: Some("auto"), + ..tty() + }; + assert_eq!(resolve_mode(env, Some(UpdateMode::Auto)), UpdateMode::Off); + + // The ecosystem-standard spelling gets the same authority. + let env = PolicyEnv { + no_update_notifier: Some("1"), + mode: Some("auto"), + ..tty() + }; + assert_eq!(resolve_mode(env, Some(UpdateMode::Auto)), UpdateMode::Off); + } + + /// CI systems commonly set `CI=` with no value and mean yes, so presence + /// is the test — but an explicit `CI=false` is a person saying no. + #[test] + fn ci_is_detected_by_presence_but_an_empty_value_is_not_ci() { + for raw in ["1", "true", "yes", "anything"] { + let env = PolicyEnv { ci: Some(raw), ..tty() }; + assert_eq!( + resolve_mode(env, Some(UpdateMode::Auto)), + UpdateMode::Off, + "CI={raw:?} must suppress the update surface" + ); + } + // Explicitly false, and exported-but-empty, are both "not CI" — the + // latter because `is-ci` (what npm's update-notifier uses) tests JS + // truthiness, where an empty string is falsy. + for raw in ["0", "false", "off", "no", ""] { + let env = PolicyEnv { ci: Some(raw), ..tty() }; + assert_eq!( + resolve_mode(env, None), + UpdateMode::Notify, + "CI={raw:?} is not somebody telling us they are in CI" + ); + } + } + + #[test] + fn a_non_terminal_or_structured_output_run_says_nothing() { + let piped = PolicyEnv { + stderr_is_terminal: false, + ..tty() + }; + assert_eq!(resolve_mode(piped, Some(UpdateMode::Auto)), UpdateMode::Off); + + let json = PolicyEnv { + structured_output: true, + ..tty() + }; + assert_eq!(resolve_mode(json, Some(UpdateMode::Auto)), UpdateMode::Off); + } + + #[test] + fn the_environment_beats_the_config_file() { + let env = PolicyEnv { mode: Some("off"), ..tty() }; + assert_eq!(resolve_mode(env, Some(UpdateMode::Auto)), UpdateMode::Off); + + let env = PolicyEnv { mode: Some("AUTO"), ..tty() }; + assert_eq!( + resolve_mode(env, Some(UpdateMode::Notify)), + UpdateMode::Auto, + "the spelling is case-insensitive" + ); + } + + /// A misspelled environment value must not select a mode by accident. It + /// falls through to the config, which is the next most specific thing the + /// user actually said. + #[test] + fn an_unparseable_environment_value_falls_through() { + let env = PolicyEnv { mode: Some("of"), ..tty() }; + assert_eq!(resolve_mode(env, Some(UpdateMode::Prompt)), UpdateMode::Prompt); + assert_eq!(resolve_mode(env, None), UpdateMode::Notify); + } + + /// ★ The whole-file hazard. `load_config` parses `~/.perry/config.toml` as + /// one document and falls back to defaults on ANY error, so a `mode` that + /// failed to deserialize would discard the user's license key and API + /// token along with it — and the next save would write that loss to disk. + #[test] + fn an_unknown_mode_does_not_take_the_rest_of_the_file_with_it() { + #[derive(Deserialize)] + struct Wrapper { + license_key: String, + update: UpdateConfig, + } + let parsed: Wrapper = toml::from_str( + "license_key = \"keep-me\"\n[update]\nmode = \"yolo\"\n", + ) + .expect("an unknown mode must not fail the parse"); + assert_eq!(parsed.license_key, "keep-me"); + assert_eq!(parsed.update.mode, Some(UpdateMode::Unknown)); + assert_eq!( + resolve_mode(tty(), parsed.update.mode), + UpdateMode::Notify, + "and it must resolve to the default rather than to anything surprising" + ); + } + + /// ★ The erasure bug, one level down. A key written by a newer Perry (or + /// by hand, ahead of the feature) must survive a load/save round trip. + #[test] + fn unknown_keys_inside_the_update_section_survive_a_round_trip() { + let config: UpdateConfig = + toml::from_str("mode = \"notify\"\nsource = \"npm\"\nfuture_key = 1\n") + .expect("unknown keys must parse"); + let written = toml::to_string_pretty(&config).expect("serialize"); + assert!( + written.contains("source") && written.contains("future_key"), + "a save dropped keys it did not recognize:\n{written}" + ); + } + + #[test] + fn a_partial_section_round_trips_without_inventing_values() { + let config: UpdateConfig = toml::from_str("server = \"https://example.test\"\n").unwrap(); + let written = toml::to_string_pretty(&config).unwrap(); + assert!(written.contains("server")); + assert!( + !written.contains("mode"), + "an unset field must stay unset rather than being written as a default:\n{written}" + ); + } + + #[test] + fn the_notify_throttle_defaults_to_every_run() { + assert!(should_notify(Duration::ZERO, None, "2026-08-10T00:00:00Z")); + assert!(should_notify( + Duration::ZERO, + Some("2026-08-10T00:00:00Z"), + "2026-08-10T00:00:01Z" + )); + } + + #[test] + fn the_notify_throttle_honours_its_interval() { + let day = Duration::from_secs(24 * 3600); + assert!( + !should_notify(day, Some("2026-08-10T00:00:00Z"), "2026-08-10T01:00:00Z"), + "an hour into a one-day throttle must stay quiet" + ); + assert!( + should_notify(day, Some("2026-08-09T00:00:00Z"), "2026-08-10T01:00:00Z"), + "past the interval it must speak up" + ); + } + + /// A cache this build cannot read must not silence the notice forever — + /// that would turn one bad write into a permanently muted checker. + #[test] + fn an_unreadable_timestamp_notifies_rather_than_staying_silent() { + let day = Duration::from_secs(24 * 3600); + assert!(should_notify(day, None, "2026-08-10T00:00:00Z")); + assert!(should_notify(day, Some("not-a-date"), "2026-08-10T00:00:00Z")); + assert!(should_notify(day, Some("2026-08-10T00:00:00Z"), "also-not-a-date")); + } +} From 8412c36ed7601b1b489b244407f05e001f9c324e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 10 Aug 2026 13:33:41 +0200 Subject: [PATCH 2/3] chore: bump version to 0.5.1447 Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- CLAUDE.md | 2 +- Cargo.lock | 152 ++++++++++++++++++++++++++--------------------------- Cargo.toml | 2 +- 3 files changed, 78 insertions(+), 78 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b325429869..536d68935e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,7 +8,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co Perry is a native TypeScript compiler written in Rust that compiles TypeScript source code directly to native executables. It uses SWC for TypeScript parsing and LLVM for code generation. -**Current Version:** 0.5.1446 +**Current Version:** 0.5.1447 ## TypeScript Parity Status diff --git a/Cargo.lock b/Cargo.lock index e1c8d93611..5aff068416 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5547,7 +5547,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perry" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "anyhow", "base64", @@ -5607,14 +5607,14 @@ dependencies = [ [[package]] name = "perry-api-manifest" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "serde", ] [[package]] name = "perry-audio-miniaudio" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "cc", "libc", @@ -5622,7 +5622,7 @@ dependencies = [ [[package]] name = "perry-codegen" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "anyhow", "inkwell", @@ -5639,7 +5639,7 @@ dependencies = [ [[package]] name = "perry-codegen-arkts" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "anyhow", "perry-hir", @@ -5647,7 +5647,7 @@ dependencies = [ [[package]] name = "perry-codegen-glance" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "anyhow", "perry-hir", @@ -5655,7 +5655,7 @@ dependencies = [ [[package]] name = "perry-codegen-js" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "anyhow", "perry-dispatch", @@ -5664,7 +5664,7 @@ dependencies = [ [[package]] name = "perry-codegen-swiftui" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "anyhow", "perry-hir", @@ -5672,7 +5672,7 @@ dependencies = [ [[package]] name = "perry-codegen-wasm" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "anyhow", "base64", @@ -5684,7 +5684,7 @@ dependencies = [ [[package]] name = "perry-codegen-wear-tiles" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "anyhow", "perry-hir", @@ -5692,7 +5692,7 @@ dependencies = [ [[package]] name = "perry-container-compose" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "anyhow", "async-trait", @@ -5721,14 +5721,14 @@ dependencies = [ [[package]] name = "perry-container-e2e" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "anyhow", ] [[package]] name = "perry-diagnostics" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "serde", "serde_json", @@ -5736,7 +5736,7 @@ dependencies = [ [[package]] name = "perry-dispatch" -version = "0.5.1446" +version = "0.5.1447" [[package]] name = "perry-doc-fixture-my-bindings" @@ -5747,7 +5747,7 @@ dependencies = [ [[package]] name = "perry-doc-tests" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "anyhow", "clap", @@ -5762,7 +5762,7 @@ dependencies = [ [[package]] name = "perry-ext-ads" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "block2", "objc2", @@ -5772,7 +5772,7 @@ dependencies = [ [[package]] name = "perry-ext-argon2" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "argon2", "perry-ffi", @@ -5780,7 +5780,7 @@ dependencies = [ [[package]] name = "perry-ext-axios" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "perry-ffi", "reqwest", @@ -5789,7 +5789,7 @@ dependencies = [ [[package]] name = "perry-ext-bcrypt" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "bcrypt", "perry-ffi", @@ -5797,7 +5797,7 @@ dependencies = [ [[package]] name = "perry-ext-better-sqlite3" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "perry-ffi", "rusqlite", @@ -5805,7 +5805,7 @@ dependencies = [ [[package]] name = "perry-ext-cheerio" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "perry-ffi", "scraper", @@ -5813,7 +5813,7 @@ dependencies = [ [[package]] name = "perry-ext-commander" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "perry-ffi", "perry-runtime", @@ -5821,7 +5821,7 @@ dependencies = [ [[package]] name = "perry-ext-cron" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "chrono", "cron", @@ -5831,7 +5831,7 @@ dependencies = [ [[package]] name = "perry-ext-dayjs" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "chrono", "perry-ffi", @@ -5839,7 +5839,7 @@ dependencies = [ [[package]] name = "perry-ext-decimal" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "perry-ffi", "rust_decimal", @@ -5847,7 +5847,7 @@ dependencies = [ [[package]] name = "perry-ext-dotenv" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "perry-ffi", "serde_json", @@ -5855,7 +5855,7 @@ dependencies = [ [[package]] name = "perry-ext-ethers" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "perry-ffi", "rand 0.10.1", @@ -5863,7 +5863,7 @@ dependencies = [ [[package]] name = "perry-ext-events" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "perry-ffi", "perry-runtime", @@ -5871,14 +5871,14 @@ dependencies = [ [[package]] name = "perry-ext-exponential-backoff" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-fastify" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "bytes", "http-body-util", @@ -5896,7 +5896,7 @@ dependencies = [ [[package]] name = "perry-ext-fetch" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "bytes", "lazy_static", @@ -5909,7 +5909,7 @@ dependencies = [ [[package]] name = "perry-ext-http" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "bytes", "h2", @@ -5933,7 +5933,7 @@ dependencies = [ [[package]] name = "perry-ext-ioredis" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "lazy_static", "perry-ffi", @@ -5943,7 +5943,7 @@ dependencies = [ [[package]] name = "perry-ext-jsonwebtoken" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "base64", "jsonwebtoken", @@ -5954,7 +5954,7 @@ dependencies = [ [[package]] name = "perry-ext-lru-cache" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "lru", "perry-ffi", @@ -5963,7 +5963,7 @@ dependencies = [ [[package]] name = "perry-ext-moment" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "chrono", "perry-ffi", @@ -5971,7 +5971,7 @@ dependencies = [ [[package]] name = "perry-ext-mongodb" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "bson", "futures-util", @@ -5983,7 +5983,7 @@ dependencies = [ [[package]] name = "perry-ext-mysql2" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "chrono", "perry-ffi", @@ -5993,7 +5993,7 @@ dependencies = [ [[package]] name = "perry-ext-nanoid" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "nanoid", "perry-ffi", @@ -6002,7 +6002,7 @@ dependencies = [ [[package]] name = "perry-ext-net" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "bytes", "perry-ffi", @@ -6015,7 +6015,7 @@ dependencies = [ [[package]] name = "perry-ext-node-forge" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "const-oid 0.9.6", "der 0.7.10", @@ -6034,7 +6034,7 @@ dependencies = [ [[package]] name = "perry-ext-nodemailer" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "lettre", "perry-ffi", @@ -6044,7 +6044,7 @@ dependencies = [ [[package]] name = "perry-ext-pdf" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "perry-ffi", "printpdf", @@ -6052,7 +6052,7 @@ dependencies = [ [[package]] name = "perry-ext-pg" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "perry-ffi", "sqlx", @@ -6061,7 +6061,7 @@ dependencies = [ [[package]] name = "perry-ext-ratelimit" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "governor", "perry-ffi", @@ -6069,7 +6069,7 @@ dependencies = [ [[package]] name = "perry-ext-sharp" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "fast_image_resize", "image", @@ -6079,14 +6079,14 @@ dependencies = [ [[package]] name = "perry-ext-slugify" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "perry-ffi", ] [[package]] name = "perry-ext-streams" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "lazy_static", "perry-ffi", @@ -6095,7 +6095,7 @@ dependencies = [ [[package]] name = "perry-ext-undici" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "perry-ffi", "perry-runtime", @@ -6104,7 +6104,7 @@ dependencies = [ [[package]] name = "perry-ext-uuid" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "perry-ffi", "uuid", @@ -6112,7 +6112,7 @@ dependencies = [ [[package]] name = "perry-ext-validator" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "perry-ffi", "regex", @@ -6122,7 +6122,7 @@ dependencies = [ [[package]] name = "perry-ext-ws" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "futures-util", "lazy_static", @@ -6135,7 +6135,7 @@ dependencies = [ [[package]] name = "perry-ext-zlib" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "brotli", "flate2", @@ -6145,7 +6145,7 @@ dependencies = [ [[package]] name = "perry-ffi" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "dashmap", "once_cell", @@ -6154,7 +6154,7 @@ dependencies = [ [[package]] name = "perry-hir" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "anyhow", "perry-api-manifest", @@ -6172,7 +6172,7 @@ dependencies = [ [[package]] name = "perry-parser" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "anyhow", "perry-diagnostics", @@ -6184,7 +6184,7 @@ dependencies = [ [[package]] name = "perry-runtime" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "anyhow", "base64", @@ -6226,14 +6226,14 @@ dependencies = [ [[package]] name = "perry-runtime-static" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "perry-runtime", ] [[package]] name = "perry-stdlib" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "aes 0.8.4", "aes 0.9.1", @@ -6328,14 +6328,14 @@ dependencies = [ [[package]] name = "perry-stdlib-static" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "perry-stdlib", ] [[package]] name = "perry-transform" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "anyhow", "perry-hir", @@ -6344,14 +6344,14 @@ dependencies = [ [[package]] name = "perry-ui" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "perry-ui-model", ] [[package]] name = "perry-ui-android" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "base64", "itoa", @@ -6368,7 +6368,7 @@ dependencies = [ [[package]] name = "perry-ui-geisterhand" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "rand 0.10.1", "serde", @@ -6378,7 +6378,7 @@ dependencies = [ [[package]] name = "perry-ui-gtk4" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "base64", "cairo-rs 0.22.0", @@ -6401,7 +6401,7 @@ dependencies = [ [[package]] name = "perry-ui-ios" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "base64", "block2", @@ -6417,7 +6417,7 @@ dependencies = [ [[package]] name = "perry-ui-macos" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "base64", "block2", @@ -6432,7 +6432,7 @@ dependencies = [ [[package]] name = "perry-ui-model" -version = "0.5.1446" +version = "0.5.1447" [[package]] name = "perry-ui-test" @@ -6443,11 +6443,11 @@ dependencies = [ [[package]] name = "perry-ui-testkit" -version = "0.5.1446" +version = "0.5.1447" [[package]] name = "perry-ui-tvos" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "base64", "block2", @@ -6463,7 +6463,7 @@ dependencies = [ [[package]] name = "perry-ui-visionos" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "base64", "block2", @@ -6479,7 +6479,7 @@ dependencies = [ [[package]] name = "perry-ui-watchos" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "block2", "libc", @@ -6492,7 +6492,7 @@ dependencies = [ [[package]] name = "perry-ui-windows" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "base64", "libc", @@ -6509,14 +6509,14 @@ dependencies = [ [[package]] name = "perry-ui-windows-winui" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "perry-ui-windows", ] [[package]] name = "perry-updater" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "anyhow", "base64", @@ -6532,7 +6532,7 @@ dependencies = [ [[package]] name = "perry-wasm-host" -version = "0.5.1446" +version = "0.5.1447" dependencies = [ "wasmi", ] diff --git a/Cargo.toml b/Cargo.toml index 03e6ea0abd..86ddddd869 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -315,7 +315,7 @@ codegen-units = 16 codegen-units = 16 [workspace.package] -version = "0.5.1446" +version = "0.5.1447" edition = "2021" license = "MIT" repository = "https://github.com/PerryTS/perry" From 690063a593031ed4affbec8925c0602c8a3731c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Mon, 10 Aug 2026 13:37:25 +0200 Subject: [PATCH 3/3] style: cargo fmt Claude-Session: https://claude.ai/code/session_01Y1QZ5wUP9gRSwpiweT4Wix --- crates/perry/src/update_checker.rs | 1 - crates/perry/src/update_policy.rs | 53 ++++++++++++++++++++++-------- 2 files changed, 40 insertions(+), 14 deletions(-) diff --git a/crates/perry/src/update_checker.rs b/crates/perry/src/update_checker.rs index 25d806d9fe..43ab5efb84 100644 --- a/crates/perry/src/update_checker.rs +++ b/crates/perry/src/update_checker.rs @@ -1585,7 +1585,6 @@ mod tests { ); } - #[test] fn test_is_cache_stale_no_cache() { // When there's no cache file, it should be stale diff --git a/crates/perry/src/update_policy.rs b/crates/perry/src/update_policy.rs index 6085c699c0..261e801bae 100644 --- a/crates/perry/src/update_policy.rs +++ b/crates/perry/src/update_policy.rs @@ -216,7 +216,9 @@ impl UpdatePolicy { structured_output, }; - let config = crate::commands::publish::load_config().update.unwrap_or_default(); + let config = crate::commands::publish::load_config() + .update + .unwrap_or_default(); if matches!(config.mode, Some(UpdateMode::Unknown)) { // One line, once, on the way past. Loud enough to fix, quiet // enough not to be the thing the user remembers about the run. @@ -328,7 +330,10 @@ mod tests { #[test] fn ci_is_detected_by_presence_but_an_empty_value_is_not_ci() { for raw in ["1", "true", "yes", "anything"] { - let env = PolicyEnv { ci: Some(raw), ..tty() }; + let env = PolicyEnv { + ci: Some(raw), + ..tty() + }; assert_eq!( resolve_mode(env, Some(UpdateMode::Auto)), UpdateMode::Off, @@ -339,7 +344,10 @@ mod tests { // latter because `is-ci` (what npm's update-notifier uses) tests JS // truthiness, where an empty string is falsy. for raw in ["0", "false", "off", "no", ""] { - let env = PolicyEnv { ci: Some(raw), ..tty() }; + let env = PolicyEnv { + ci: Some(raw), + ..tty() + }; assert_eq!( resolve_mode(env, None), UpdateMode::Notify, @@ -365,10 +373,16 @@ mod tests { #[test] fn the_environment_beats_the_config_file() { - let env = PolicyEnv { mode: Some("off"), ..tty() }; + let env = PolicyEnv { + mode: Some("off"), + ..tty() + }; assert_eq!(resolve_mode(env, Some(UpdateMode::Auto)), UpdateMode::Off); - let env = PolicyEnv { mode: Some("AUTO"), ..tty() }; + let env = PolicyEnv { + mode: Some("AUTO"), + ..tty() + }; assert_eq!( resolve_mode(env, Some(UpdateMode::Notify)), UpdateMode::Auto, @@ -381,8 +395,14 @@ mod tests { /// user actually said. #[test] fn an_unparseable_environment_value_falls_through() { - let env = PolicyEnv { mode: Some("of"), ..tty() }; - assert_eq!(resolve_mode(env, Some(UpdateMode::Prompt)), UpdateMode::Prompt); + let env = PolicyEnv { + mode: Some("of"), + ..tty() + }; + assert_eq!( + resolve_mode(env, Some(UpdateMode::Prompt)), + UpdateMode::Prompt + ); assert_eq!(resolve_mode(env, None), UpdateMode::Notify); } @@ -397,10 +417,9 @@ mod tests { license_key: String, update: UpdateConfig, } - let parsed: Wrapper = toml::from_str( - "license_key = \"keep-me\"\n[update]\nmode = \"yolo\"\n", - ) - .expect("an unknown mode must not fail the parse"); + let parsed: Wrapper = + toml::from_str("license_key = \"keep-me\"\n[update]\nmode = \"yolo\"\n") + .expect("an unknown mode must not fail the parse"); assert_eq!(parsed.license_key, "keep-me"); assert_eq!(parsed.update.mode, Some(UpdateMode::Unknown)); assert_eq!( @@ -464,7 +483,15 @@ mod tests { fn an_unreadable_timestamp_notifies_rather_than_staying_silent() { let day = Duration::from_secs(24 * 3600); assert!(should_notify(day, None, "2026-08-10T00:00:00Z")); - assert!(should_notify(day, Some("not-a-date"), "2026-08-10T00:00:00Z")); - assert!(should_notify(day, Some("2026-08-10T00:00:00Z"), "also-not-a-date")); + assert!(should_notify( + day, + Some("not-a-date"), + "2026-08-10T00:00:00Z" + )); + assert!(should_notify( + day, + Some("2026-08-10T00:00:00Z"), + "also-not-a-date" + )); } }