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"
+ ));
+ }
+}