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" 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..43ab5efb84 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,31 @@ 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..261e801bae --- /dev/null +++ b/crates/perry/src/update_policy.rs @@ -0,0 +1,497 @@ +//! 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" + )); + } +}