diff --git a/docs/content/docs/cua-driver/guide/getting-started/installation.mdx b/docs/content/docs/cua-driver/guide/getting-started/installation.mdx
index d97b62cc00..23075dab9a 100644
--- a/docs/content/docs/cua-driver/guide/getting-started/installation.mdx
+++ b/docs/content/docs/cua-driver/guide/getting-started/installation.mdx
@@ -98,6 +98,49 @@ If a grant still reads `NOT granted` after granting in the dialog, open **System
(including unset) leaves the gate active.
+
+ **Update-available banner (`cua-driver mcp` / `serve` / `doctor`).** The
+ long-running interactive entry points kick off a non-blocking background
+ check against the GitHub releases API at startup and print a small
+ two-line banner to stderr when a newer `cua-driver-rs-v*` release exists:
+
+ ```
+ ✨ cua-driver v0.1.4 is available (you have v0.1.3).
+ Update with: cua-driver update
+ Release notes: https://github.com/trycua/cua/releases/tag/cua-driver-rs-v0.1.4
+ ```
+
+ The answer is cached at `~/.cua-driver-rs/version_check.json` for ~20
+ hours, so subsequent launches reuse the cached result without a network
+ call. Network failures are silent — the next launch retries.
+
+ **Scripted / machine-readable entry points are NOT instrumented.**
+ The banner only fires from the long-running entry points (`mcp`,
+ `serve`, `doctor`). One-shot and machine-readable subcommands —
+ `--version`, `list-tools`, `describe`, `call`, `dump-docs`,
+ `mcp-config`, `update`, `stop`, `status`, `recording`, `config`,
+ `diagnose`, and `telemetry install-event` — all skip it so piped
+ output (`cua-driver list-tools | jq …`) stays clean.
+
+ **Disable per-invocation:**
+
+ ```bash
+ CUA_DRIVER_RS_UPDATE_CHECK=false cua-driver serve
+ ```
+
+ Accepted "off" values (case-insensitive): `0`, `false`, `no`, `off`.
+
+ **Disable permanently** via the persisted config:
+
+ ```bash
+ cua-driver config set update_check_enabled false
+ ```
+
+ Source / dev builds (any `CARGO_PKG_VERSION` with a pre-release suffix
+ like `-dev`) auto-skip the check — there is no matching published
+ release for them to recommend.
+
+
## Requirements
- macOS 14 (Sonoma) or later
diff --git a/docs/content/docs/cua-driver/reference/cli-reference.mdx b/docs/content/docs/cua-driver/reference/cli-reference.mdx
index 3fbb866803..a49ebb23f8 100644
--- a/docs/content/docs/cua-driver/reference/cli-reference.mdx
+++ b/docs/content/docs/cua-driver/reference/cli-reference.mdx
@@ -7,7 +7,7 @@ description: Command Line Interface reference for Cua Driver
AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY
Generated by: npx tsx scripts/docs-generators/cua-driver.ts
Source: recursive Swift sources under libs/cua-driver/Sources
- Version: 0.1.7
+ Version: 0.1.9
*/}
import { Callout } from 'fumadocs-ui/components/callout';
@@ -16,7 +16,7 @@ import { VersionHeader } from '@/components/version-selector';
@@ -142,13 +142,6 @@ Subsequent `cua-driver call/list-tools/describe` invocations auto-detect
the socket and forward their requests, so the AppStateEngine's per-pid
element_index cache survives across CLI calls.
-On macOS, `serve` runs a **first-launch permissions gate** before binding
-the socket. When TCC grants for Accessibility or Screen Recording are
-missing, it prints a banner listing exactly what is missing, auto-opens
-the matching `System Settings → Privacy & Security` pane(s), and polls
-every second until the user grants both. When grants are already active
-the gate is a transparent no-op.
-
**Options:**
| Name | Type | Default | Description |
@@ -160,7 +153,6 @@ the gate is a transparent no-op.
| Name | Description |
| ---- | ----------- |
| `--no-relaunch` | Stay in the current process instead of re-execing via `open -n -g -a CuaDriver`. |
-| `--no-permissions-gate` | Skip the macOS TCC permissions gate at startup. Use for CI / headless runners where blocking on user input would deadlock the process. Also toggleable by setting `CUA_DRIVER_RS_PERMISSIONS_GATE` to any of `0`, `false`, `no`, or `off` (case-insensitive — e.g. `CUA_DRIVER_RS_PERMISSIONS_GATE=FALSE` works too). |
### cua-driver stop
diff --git a/docs/content/docs/cua-driver/reference/mcp-tools.mdx b/docs/content/docs/cua-driver/reference/mcp-tools.mdx
index c0982f220b..9e35e14119 100644
--- a/docs/content/docs/cua-driver/reference/mcp-tools.mdx
+++ b/docs/content/docs/cua-driver/reference/mcp-tools.mdx
@@ -7,7 +7,7 @@ description: Reference for every MCP tool cua-driver exposes
AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY
Generated by: npx tsx scripts/docs-generators/cua-driver.ts
Source: recursive Swift sources under libs/cua-driver/Sources
- Version: 0.1.7
+ Version: 0.1.9
*/}
import { Callout } from 'fumadocs-ui/components/callout';
diff --git a/libs/cua-driver-rs/Cargo.lock b/libs/cua-driver-rs/Cargo.lock
index 39297031a4..886f8eb704 100644
--- a/libs/cua-driver-rs/Cargo.lock
+++ b/libs/cua-driver-rs/Cargo.lock
@@ -254,8 +254,10 @@ dependencies = [
"platform-linux",
"platform-macos",
"platform-windows",
+ "semver",
"serde",
"serde_json",
+ "tempfile",
"tokio",
"tracing",
"tracing-subscriber",
diff --git a/libs/cua-driver-rs/PARITY.md b/libs/cua-driver-rs/PARITY.md
index dfc5df0dc9..c975e4840a 100644
--- a/libs/cua-driver-rs/PARITY.md
+++ b/libs/cua-driver-rs/PARITY.md
@@ -1775,3 +1775,109 @@ surfaced to stdout/stderr unless `CUA_DRIVER_RS_TELEMETRY_DEBUG=true`.
GUI surface yet, so the constant is reserved but unused.
- **`is_ci` uses env-var probing only.** Same probe list as Swift; no
extra Rust-specific signals.
+
+## Startup flow: update-available banner (`mcp` / `serve` / `doctor`)
+
+- Swift: not present (the Swift port has no analogous banner today)
+- Rust: `crates/cua-driver/src/version_check.rs`
+- Status: INTENTIONAL_ADDITION (Rust-only)
+- Test: `crates/cua-driver/src/version_check.rs` `#[cfg(test)] mod tests`
+ (22 unit tests: semver edge cases, cache round-trip in a
+ tempdir, dismissal persistence, 20-hour refresh threshold,
+ env-var + config opt-out, JSON release-list filtering,
+ banner format, ISO-8601 timestamp)
+
+### Behavior
+
+On the long-running interactive entry points (`mcp`, `serve`,
+`doctor`) the binary kicks off a background HTTP check against
+`https://api.github.com/repos/trycua/cua/releases?per_page=40`,
+filters to the `cua-driver-rs-v*` tag prefix, and prints a two-line
+banner to **stderr** if the highest non-draft non-prerelease release
+is strictly newer than `CARGO_PKG_VERSION` and the user hasn't
+previously dismissed it:
+
+```text
+✨ cua-driver v0.1.4 is available (you have v0.1.3).
+ Update with: cua-driver update
+ Release notes: https://github.com/trycua/cua/releases/tag/cua-driver-rs-v0.1.4
+```
+
+The check runs on `tokio::task::spawn_blocking` when a runtime is
+live, else a short-lived OS thread, so the daemon's start-up path
+is never delayed by network latency.
+
+### Cache
+
+The latest-version answer is cached on disk at
+`~/.cua-driver-rs/version_check.json`:
+
+```json
+{
+ "last_checked_unix": 1700000000,
+ "last_checked_at": "2023-11-14T22:13:20Z",
+ "latest_version": "0.1.4",
+ "dismissed_versions": []
+}
+```
+
+Refreshed only when the cached `last_checked_unix` is more than 20
+hours old, bounding outbound requests to roughly one per machine per
+day even on a hot reload loop. Failed HTTP fetches fall back to the
+cached value (better an old banner than none on a brief network blip).
+
+### Skipped contexts
+
+Only `mcp`, `serve`, and `doctor` call `maybe_announce_update()`.
+The following entry points are **NOT** instrumented because they're
+routinely piped from scripts and a banner would corrupt their
+parseable output:
+
+- `--version` / `-V`
+- `list-tools`
+- `describe `
+- `call `
+- `dump-docs`
+- `mcp-config`
+- `update`, `stop`, `status`, `recording`, `config`, `diagnose`,
+ `telemetry install-event`
+
+### Opt-out (three layers, any one disables the check)
+
+1. **Env var** `CUA_DRIVER_RS_UPDATE_CHECK=false` (also `0`, `no`,
+ `off`; case-insensitive) — single-invocation off.
+2. **Config flag** `update_check_enabled = false` in
+ `~/.cua-driver/config.json` — persistent off. Set via
+ `cua-driver config set update_check_enabled false`.
+3. **Pre-release build auto-skip** — any `CARGO_PKG_VERSION` that
+ carries a semver pre-release suffix (`-dev`, `-rc.1`, `-beta`)
+ short-circuits the check entirely. There is no matching published
+ release for a source / development build to recommend.
+
+### HTTP client
+
+`ureq` v3 with `Accept: application/vnd.github+json` and a
+`cua-driver-rs/` user-agent. 4-second timeout. Single GET,
+fire-and-forget — the response body is read into `serde_json::Value`
+on the background task, never touching the foreground startup path.
+
+Network errors, timeouts, 4xx/5xx responses, and JSON-parse failures
+are logged via `tracing::debug!(target: "cua_driver::version_check",
+…)` only — never surfaced to stderr.
+
+### Shared with `cua-driver update`
+
+`run_update_cmd` calls into the same
+`version_check::fetch_latest_version()` and `version_check::is_newer()`
+helpers so the proactive banner and the manual subcommand agree on
+tag-filtering rules and semver compare semantics. This also removes
+the prior shell-out to `curl` from `cli::run_update_cmd`.
+
+### Dismissal API
+
+`version_check::dismiss_version(&str)` appends a version string to
+the `dismissed_versions` list on disk. No call site in the current
+binary (banner is informational only today); kept public so a future
+interactive prompt path (TUI helper, GUI extra) can persist the
+"skip until next version" choice without re-implementing the cache
+layer.
diff --git a/libs/cua-driver-rs/crates/cua-driver/Cargo.toml b/libs/cua-driver-rs/crates/cua-driver/Cargo.toml
index 23e51590c3..0f93d0b784 100644
--- a/libs/cua-driver-rs/crates/cua-driver/Cargo.toml
+++ b/libs/cua-driver-rs/crates/cua-driver/Cargo.toml
@@ -25,6 +25,10 @@ uuid = { workspace = true }
# PostHog ingest is a single fire-and-forget POST with a 3s timeout. Uses
# rustls (default) so Linux/Windows builds don't require system OpenSSL.
ureq = { version = "3", features = ["json"] }
+# Strict semver comparison for the startup update-available banner so that
+# pre-release tags (`-dev`, `-rc.1`) sort below their corresponding release
+# and we never recommend a pre-release as an "update".
+semver = "1"
# Used by crate::bundle::parent_is_not_launchd() for the TCC
# auto-relaunch detection path on Unix (only the macOS heuristic
@@ -45,6 +49,9 @@ platform-linux = { path = "../platform-linux" }
tokio = { workspace = true, features = ["full"] }
image = { workspace = true }
base64 = { workspace = true }
+# Isolated HOME / cache directories for `version_check` unit tests so they
+# never touch the developer's real `~/.cua-driver-rs/version_check.json`.
+tempfile = "3"
[target.'cfg(target_os = "windows")'.dev-dependencies]
platform-windows = { path = "../platform-windows" }
diff --git a/libs/cua-driver-rs/crates/cua-driver/src/cli.rs b/libs/cua-driver-rs/crates/cua-driver/src/cli.rs
index 13bba63c67..da1aa9441b 100644
--- a/libs/cua-driver-rs/crates/cua-driver/src/cli.rs
+++ b/libs/cua-driver-rs/crates/cua-driver/src/cli.rs
@@ -796,23 +796,29 @@ pub fn run_recording_cmd(subcommand: &str, args: &[String], socket: Option<&str>
/// `cua-driver update [--apply]` — check for a newer release and optionally apply it.
///
-/// Uses `curl` to query the GitHub releases API (same as Swift reference).
-/// Pass `--apply` to download and install via the canonical install.sh.
+/// Shares the GitHub releases fetch with the startup banner via
+/// [`crate::version_check::fetch_latest_version`] so both code paths
+/// agree on tag filtering and HTTP semantics. Pass `--apply` to download
+/// and install via the canonical install.sh.
pub fn run_update_cmd(apply: bool) {
let current = env!("CARGO_PKG_VERSION");
println!("Current version: {current}");
println!("Checking for updates…");
- let latest = fetch_latest_version();
+ let latest = crate::version_check::fetch_latest_version();
match latest {
- None => {
+ Err(e) => {
+ // The shared helper returns a human-readable error string for
+ // the CLI surface — pass it through so the user can see why
+ // (timeout, parse error, etc.) instead of just "unreachable".
+ tracing::debug!(target: "cua_driver::update", "fetch failed: {e}");
println!("Could not reach GitHub — check your connection and try again.");
process::exit(1);
}
- Some(v) if !is_version_newer(&v, current) => {
+ Ok(v) if !crate::version_check::is_newer(&v, current) => {
println!("Already up to date.");
}
- Some(v) => {
+ Ok(v) => {
println!("New version available: {v}");
if !apply {
@@ -845,56 +851,6 @@ pub fn run_update_cmd(apply: bool) {
}
}
-/// Fetch the latest `cua-driver-v*` release tag from GitHub using curl.
-/// Returns the version string (e.g. "0.1.2") or `None` on any error.
-fn fetch_latest_version() -> Option {
- let out = std::process::Command::new("curl")
- .args(["-s", "--max-time", "4",
- "-H", "Accept: application/vnd.github+json",
- "https://api.github.com/repos/trycua/cua/releases?per_page=40"])
- .output()
- .ok()?;
- if !out.status.success() { return None; }
- let text = String::from_utf8_lossy(&out.stdout);
- let releases: serde_json::Value = serde_json::from_str(&text).ok()?;
- // `cua-driver-rs-v*` releases are distinct from the Swift `cua-driver-v*`
- // releases on the same repo — must filter by the Rust-port prefix or
- // we'd recommend the Swift binary instead.
- let prefix = "cua-driver-rs-v";
- let mut versions: Vec = releases.as_array()?.iter()
- .filter_map(|r| {
- let tag = r["tag_name"].as_str()?;
- if !tag.starts_with(prefix) { return None; }
- if r["draft"].as_bool().unwrap_or(false) { return None; }
- if r["prerelease"].as_bool().unwrap_or(false) { return None; }
- Some(tag[prefix.len()..].to_owned())
- })
- .collect();
- // Sort descending (newest first).
- versions.sort_by(|a, b| compare_versions(b, a));
- versions.into_iter().next()
-}
-
-/// True when `candidate` is strictly newer than `current` (semver compare).
-fn is_version_newer(candidate: &str, current: &str) -> bool {
- compare_versions(candidate, current) == std::cmp::Ordering::Greater
-}
-
-fn compare_versions(a: &str, b: &str) -> std::cmp::Ordering {
- let parts = |s: &str| -> Vec {
- s.split('.').filter_map(|p| p.parse().ok()).collect()
- };
- let pa = parts(a);
- let pb = parts(b);
- for (x, y) in pa.iter().zip(pb.iter()) {
- match x.cmp(y) {
- std::cmp::Ordering::Equal => continue,
- other => return other,
- }
- }
- pa.len().cmp(&pb.len())
-}
-
/// `cua-driver dump-docs [--pretty]` — output all MCP tool schemas as JSON.
pub fn run_dump_docs(registry: &ToolRegistry, pretty: bool) {
run_dump_docs_with_type(registry, pretty, "all")
diff --git a/libs/cua-driver-rs/crates/cua-driver/src/main.rs b/libs/cua-driver-rs/crates/cua-driver/src/main.rs
index d7b752f5b4..51fc67d394 100644
--- a/libs/cua-driver-rs/crates/cua-driver/src/main.rs
+++ b/libs/cua-driver-rs/crates/cua-driver/src/main.rs
@@ -29,6 +29,7 @@ mod cli;
mod proxy;
mod serve;
mod telemetry;
+mod version_check;
use std::sync::Arc;
@@ -114,6 +115,10 @@ fn main() {
return;
}
cli::Command::Serve { socket, no_permissions_gate } => {
+ // Long-running daemon — kick off the background update check
+ // before any blocking work so the banner can land on stderr
+ // early in the serve lifecycle.
+ version_check::maybe_announce_update();
// First-launch permissions gate (Swift PermissionsGate parity).
// Runs on every `serve` start; no-op when both grants are
// already active. Honors --no-permissions-gate and
@@ -177,6 +182,10 @@ fn main() {
return;
}
cli::Command::Doctor => {
+ // Long-running interactive entry point — kick off the
+ // background "new version available?" check so the banner
+ // can land on stderr if the user is on an outdated install.
+ version_check::maybe_announce_update();
cli::run_doctor_cmd();
return;
}
@@ -192,6 +201,10 @@ fn main() {
return;
}
cli::Command::Mcp { no_daemon_relaunch, socket } => {
+ // Long-running MCP server — kick off the background update
+ // check before any TCC / daemon-proxy decisions so the
+ // banner can land on stderr in either dispatch path.
+ version_check::maybe_announce_update();
// TCC sidestep: if we're a shell-spawned bare binary that
// resolves into /Applications/CuaDriverRs.app, run the
// proxy path instead of the in-process MCP server. The
@@ -328,6 +341,9 @@ fn main() -> anyhow::Result<()> {
return Ok(());
}
cli::Command::Serve { socket, no_permissions_gate } => {
+ // Long-running daemon — kick off the background update check
+ // before any blocking work so the banner can land on stderr.
+ version_check::maybe_announce_update();
// The Rust permissions gate is macOS-only (TCC concept).
// On Windows / Linux the flag is silently accepted for
// CLI uniformity and ignored.
@@ -369,6 +385,9 @@ fn main() -> anyhow::Result<()> {
return Ok(());
}
cli::Command::Doctor => {
+ // Long-running interactive entry point — kick off the
+ // background update check so the banner can land on stderr.
+ version_check::maybe_announce_update();
cli::run_doctor_cmd();
return Ok(());
}
@@ -386,6 +405,9 @@ fn main() -> anyhow::Result<()> {
return Ok(());
}
cli::Command::Mcp { no_daemon_relaunch, socket } => {
+ // Long-running MCP server — kick off the background update
+ // check before falling through to the in-process server.
+ version_check::maybe_announce_update();
// Non-macOS: TCC doesn't exist, no daemon proxy path. The
// flags parse cleanly so cross-platform MCP config
// snippets work, but we ignore them and run in-process.
diff --git a/libs/cua-driver-rs/crates/cua-driver/src/version_check.rs b/libs/cua-driver-rs/crates/cua-driver/src/version_check.rs
new file mode 100644
index 0000000000..73db9e7280
--- /dev/null
+++ b/libs/cua-driver-rs/crates/cua-driver/src/version_check.rs
@@ -0,0 +1,818 @@
+//! Startup "new version available" banner.
+//!
+//! On the interactive entry points (`mcp`, `serve`, `doctor`) we check the
+//! GitHub releases API for a newer `cua-driver-rs-v*` tag, cache the answer
+//! on disk for ~20 hours, and print a small two-line banner to **stderr**
+//! if a strictly-newer release exists and the user hasn't dismissed it.
+//!
+//! Design constraints:
+//!
+//! - **Non-blocking.** [`maybe_announce_update`] returns instantly. The
+//! actual HTTP fetch runs on a background task (`tokio::task::spawn` when
+//! a runtime is already live, else a short-lived OS thread). If the
+//! network is slow, the daemon starts up regardless and the banner
+//! either lands on a later stderr line or appears next launch.
+//! - **Silent on failure.** HTTP timeouts, 4xx/5xx, JSON-parse errors,
+//! refusal-to-write-cache — everything is `tracing::debug!` only. Never
+//! pollute the user's stderr with "couldn't check for updates" noise;
+//! the next launch just retries.
+//! - **Cache-first.** A 20-hour-old cache short-circuits the network call
+//! entirely. This bounds outbound requests to roughly one per machine
+//! per day even on a hot reload loop.
+//! - **Opt-out at three layers.** Env var `CUA_DRIVER_RS_UPDATE_CHECK=false`
+//! (single invocation), persisted config `update_check_enabled = false`
+//! in `~/.cua-driver/config.json` (permanent), and an automatic skip when
+//! `CARGO_PKG_VERSION` carries any pre-release suffix (source / dev
+//! builds — there is no matching published release to recommend).
+//! - **Skip in machine-readable contexts.** Only the long-running
+//! entry points call [`maybe_announce_update`]. `--version`,
+//! `list-tools`, `describe`, `call`, `dump-docs`, etc. are routinely
+//! piped through `jq` from scripts; a banner would corrupt parseable
+//! output. The decision lives at the call sites in `main.rs`, not here.
+
+use std::path::PathBuf;
+use std::time::{SystemTime, UNIX_EPOCH};
+
+/// Disk-resident cache file (sibling of the telemetry artifacts under
+/// `~/.cua-driver-rs/`). Holds the last-seen latest version plus the list
+/// of versions the user has actively dismissed.
+const CACHE_FILE_NAME: &str = "version_check.json";
+
+/// `~/.cua-driver-rs/` — same subdirectory the telemetry client uses,
+/// kept separate from the `~/.cua-driver/` config tree that the Swift
+/// reference owns.
+const HOME_SUBDIRECTORY: &str = ".cua-driver-rs";
+
+/// Single-invocation opt-out env var. Recognised values mirror the
+/// telemetry opt-out: `0|false|no|off` disables the check, everything
+/// else (including unset) leaves it on.
+const ENV_UPDATE_CHECK: &str = "CUA_DRIVER_RS_UPDATE_CHECK";
+
+/// Refresh threshold for the on-disk cache. A fresh cache short-circuits
+/// the network call so we hit the GitHub API at most ~1× per machine per
+/// day even when the daemon restarts repeatedly.
+const CACHE_REFRESH_SECONDS: u64 = 20 * 60 * 60; // 20 hours
+
+/// HTTP timeout for the releases API fetch. Kept tight so the background
+/// task can't linger past the daemon's normal startup window.
+const HTTP_TIMEOUT_SECONDS: u64 = 4;
+
+/// Tag-name prefix used by the Rust-port releases on the trycua/cua repo.
+/// Releases tagged with anything else (e.g. the Swift port's
+/// `cua-driver-v*`) are filtered out so we never recommend the wrong binary.
+pub(crate) const RELEASE_TAG_PREFIX: &str = "cua-driver-rs-v";
+
+/// GitHub releases API endpoint. Paginates newest-first; 40 entries is
+/// plenty of headroom past the most recent stable release even when
+/// pre-releases are sprinkled in between.
+const RELEASES_URL: &str =
+ "https://api.github.com/repos/trycua/cua/releases?per_page=40";
+
+// ── Public API ───────────────────────────────────────────────────────────
+
+/// Kick off a background "is there a newer release?" check.
+///
+/// **Returns immediately.** The actual network round-trip happens on a
+/// `tokio::task::spawn` task (when a runtime is live) or a short-lived
+/// OS thread (sync entry points). The two-line banner is printed to
+/// **stderr** if/when the check completes and finds a strictly-newer
+/// non-dismissed version.
+///
+/// No-op when:
+/// - `CUA_DRIVER_RS_UPDATE_CHECK` env var is set to a falsy value
+/// - The persisted config flag `update_check_enabled` is `false`
+/// - `CARGO_PKG_VERSION` has a pre-release suffix (source / dev build —
+/// nothing on GitHub will be "newer" in a meaningful way)
+pub fn maybe_announce_update() {
+ if !is_enabled() {
+ tracing::debug!(target: "cua_driver::version_check",
+ "update check skipped (opt-out / pre-release build)");
+ return;
+ }
+
+ let current = env!("CARGO_PKG_VERSION").to_owned();
+
+ let task = move || {
+ run_check_and_announce(¤t, fetch_latest_version, std::io::stderr());
+ };
+
+ if tokio::runtime::Handle::try_current().is_ok() {
+ tokio::task::spawn_blocking(task);
+ } else {
+ std::thread::Builder::new()
+ .name("cua-version-check".into())
+ .spawn(task)
+ .ok();
+ }
+}
+
+/// Mark `version` as dismissed so the banner stops nagging the user about
+/// this specific release. They will see the next banner the moment a
+/// strictly-newer tag ships.
+///
+/// Idempotent. Failures (no HOME, IO error) are logged via
+/// `tracing::debug!` and silently dropped — dismissal is a UX nicety, not
+/// a correctness boundary.
+///
+/// Exposed publicly so a future interactive prompt (TUI, GUI helper) can
+/// wire it in without re-implementing the persistence layer. No call site
+/// in the current binary — the banner today is informational only.
+#[allow(dead_code)]
+pub fn dismiss_version(version: &str) {
+ let mut cache = read_cache().unwrap_or_default();
+ if !cache.dismissed_versions.iter().any(|v| v == version) {
+ cache.dismissed_versions.push(version.to_owned());
+ }
+ if let Err(e) = write_cache(&cache) {
+ tracing::debug!(target: "cua_driver::version_check",
+ "failed to persist dismissal: {e}");
+ }
+}
+
+// ── Core logic (testable seam) ───────────────────────────────────────────
+
+/// Inner routine wired up by [`maybe_announce_update`].
+///
+/// Split out from the public entry point so tests can drive the check
+/// against a stubbed fetcher and capture the banner into an in-memory
+/// buffer. Errors here never propagate — the function consumes them
+/// and converts to `tracing::debug!` lines.
+fn run_check_and_announce(current: &str, fetch: F, mut writer: W)
+where
+ F: FnOnce() -> Result,
+ W: std::io::Write,
+{
+ let now = unix_now();
+
+ // Decide whether the cache is still fresh enough to skip the network.
+ let cached = read_cache().unwrap_or_default();
+ let needs_refresh = cached
+ .last_checked_unix
+ .map(|t| now.saturating_sub(t) >= CACHE_REFRESH_SECONDS)
+ .unwrap_or(true);
+
+ let latest = if needs_refresh {
+ match fetch() {
+ Ok(v) => {
+ // Persist on success so the next launch re-uses the answer.
+ let new_cache = VersionCache {
+ last_checked_unix: Some(now),
+ last_checked_at: Some(iso8601(now)),
+ latest_version: Some(v.clone()),
+ dismissed_versions: cached.dismissed_versions.clone(),
+ };
+ if let Err(e) = write_cache(&new_cache) {
+ tracing::debug!(target: "cua_driver::version_check",
+ "failed to write cache: {e}");
+ }
+ v
+ }
+ Err(e) => {
+ tracing::debug!(target: "cua_driver::version_check",
+ "fetch failed: {e}");
+ // Fall back to the cached value if any — better an old
+ // banner than none on a brief network blip.
+ match cached.latest_version.clone() {
+ Some(v) => v,
+ None => return,
+ }
+ }
+ }
+ } else {
+ match cached.latest_version.clone() {
+ Some(v) => v,
+ None => return,
+ }
+ };
+
+ // Re-read dismissals: dismiss_version may have run between our cache
+ // load and now (e.g. on a separately-spawned task in the same process).
+ let dismissed = read_cache()
+ .map(|c| c.dismissed_versions)
+ .unwrap_or(cached.dismissed_versions);
+
+ if !is_newer(&latest, current) {
+ return;
+ }
+ if dismissed.iter().any(|v| v == &latest) {
+ tracing::debug!(target: "cua_driver::version_check",
+ "newer version {latest} dismissed; skipping banner");
+ return;
+ }
+
+ let banner = format_banner(&latest, current);
+ if let Err(e) = writer.write_all(banner.as_bytes()) {
+ tracing::debug!(target: "cua_driver::version_check",
+ "failed to print banner: {e}");
+ }
+}
+
+/// Format the two-line banner. Plain text, no ANSI colours — terminals
+/// without UTF-8 still see the `✨` byte sequence but the text is
+/// readable either way.
+fn format_banner(latest: &str, current: &str) -> String {
+ format!(
+ "\n\u{2728} cua-driver v{latest} is available (you have v{current}).\n \
+ Update with: cua-driver update\n \
+ Release notes: https://github.com/trycua/cua/releases/tag/{prefix}{latest}\n\n",
+ prefix = RELEASE_TAG_PREFIX,
+ )
+}
+
+// ── Enable / disable logic ───────────────────────────────────────────────
+
+/// True when the update check should run on this invocation.
+///
+/// Order of precedence (any one being "off" wins):
+/// 1. Env var `CUA_DRIVER_RS_UPDATE_CHECK` (falsy → off)
+/// 2. Persisted config `update_check_enabled` (false → off)
+/// 3. Built `CARGO_PKG_VERSION` is a pre-release (`-dev`, `-rc.1`, …) → off
+fn is_enabled() -> bool {
+ if let Some(false) = parse_env_bool(ENV_UPDATE_CHECK) {
+ return false;
+ }
+ if let Some(false) = read_config_flag() {
+ return false;
+ }
+ if is_prerelease(env!("CARGO_PKG_VERSION")) {
+ return false;
+ }
+ true
+}
+
+/// Read the `update_check_enabled` flag out of the same JSON config file
+/// the `cua-driver config set` subcommand writes to. Returns `None` when
+/// the file is missing, unreadable, or doesn't have the key.
+fn read_config_flag() -> Option {
+ let home = std::env::var_os("HOME")
+ .or_else(|| std::env::var_os("USERPROFILE"))?;
+ let path = PathBuf::from(home).join(".cua-driver").join("config.json");
+ let raw = std::fs::read_to_string(&path).ok()?;
+ let json: serde_json::Value = serde_json::from_str(&raw).ok()?;
+ json.get("update_check_enabled").and_then(|v| v.as_bool())
+}
+
+fn parse_env_bool(var: &str) -> Option {
+ let raw = std::env::var(var).ok()?;
+ match raw.trim().to_ascii_lowercase().as_str() {
+ "0" | "false" | "no" | "off" => Some(false),
+ "1" | "true" | "yes" | "on" => Some(true),
+ _ => None,
+ }
+}
+
+/// True when `version` carries any semver pre-release suffix.
+///
+/// Returns `true` (skip the check) for unparseable strings too — better
+/// to silently skip than to print a banner against a malformed version.
+pub(crate) fn is_prerelease(version: &str) -> bool {
+ match semver::Version::parse(version) {
+ Ok(v) => !v.pre.is_empty(),
+ Err(_) => true,
+ }
+}
+
+// ── Semver comparison ────────────────────────────────────────────────────
+
+/// True when `latest` is strictly newer than `current` under semver.
+///
+/// Unparseable inputs return `false` so a broken release tag or
+/// `CARGO_PKG_VERSION` quirk can never produce a spurious "update
+/// available" banner. We'd rather miss a real update than nag the user
+/// about a phantom one.
+pub fn is_newer(latest: &str, current: &str) -> bool {
+ let Ok(l) = semver::Version::parse(latest) else { return false; };
+ let Ok(c) = semver::Version::parse(current) else { return false; };
+ l > c
+}
+
+// ── On-disk cache ────────────────────────────────────────────────────────
+
+/// Serialised shape of `~/.cua-driver-rs/version_check.json`.
+///
+/// `last_checked_unix` is the source of truth for cache-age decisions;
+/// `last_checked_at` is the same instant rendered as ISO-8601 for human
+/// inspection of the file. Both fields are optional so we can write
+/// partial state (e.g. only a dismissed list) without forcing a sentinel.
+#[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize)]
+pub(crate) struct VersionCache {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub last_checked_unix: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub last_checked_at: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub latest_version: Option,
+ #[serde(default)]
+ pub dismissed_versions: Vec,
+}
+
+/// Read the cache file. Returns `None` when missing / unreadable / not
+/// valid JSON — callers fall back to the default (empty) shape.
+fn read_cache() -> Option {
+ let path = cache_path()?;
+ let raw = std::fs::read_to_string(&path).ok()?;
+ serde_json::from_str(&raw).ok()
+}
+
+/// Write the cache file. Creates the parent directory if missing.
+/// IO errors propagate so the caller can decide whether to log; the
+/// fire-and-forget callers in this module always swallow them.
+fn write_cache(cache: &VersionCache) -> std::io::Result<()> {
+ let path = cache_path().ok_or_else(|| {
+ std::io::Error::new(
+ std::io::ErrorKind::NotFound,
+ "no HOME / USERPROFILE — cannot resolve version_check cache path",
+ )
+ })?;
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent)?;
+ }
+ let json = serde_json::to_string_pretty(cache)
+ .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
+ std::fs::write(&path, json)
+}
+
+fn cache_path() -> Option {
+ let home = std::env::var_os("HOME")
+ .or_else(|| std::env::var_os("USERPROFILE"))?;
+ Some(PathBuf::from(home).join(HOME_SUBDIRECTORY).join(CACHE_FILE_NAME))
+}
+
+// ── HTTP fetch (shared with the `update` subcommand) ─────────────────────
+
+/// Fetch the highest `cua-driver-rs-v*` release tag from GitHub.
+///
+/// Uses `ureq` (already a dep via telemetry) so we don't shell out to
+/// `curl` from a background task and stay cross-platform. Filters out:
+///
+/// - tags that don't start with `cua-driver-rs-v` (Swift port releases)
+/// - draft releases (`"draft": true`)
+/// - pre-releases (`"prerelease": true`)
+///
+/// Returns the bare version string (e.g. `"0.1.4"`) on success, or a
+/// human-readable error string on failure. The caller is expected to
+/// downgrade errors to `tracing::debug!`.
+pub(crate) fn fetch_latest_version() -> Result {
+ let agent = ureq::Agent::config_builder()
+ .timeout_global(Some(std::time::Duration::from_secs(HTTP_TIMEOUT_SECONDS)))
+ .build()
+ .new_agent();
+
+ let response = agent
+ .get(RELEASES_URL)
+ .header("Accept", "application/vnd.github+json")
+ .header("User-Agent", concat!("cua-driver-rs/", env!("CARGO_PKG_VERSION")))
+ .call()
+ .map_err(|e| format!("HTTP error: {e}"))?;
+
+ let body: serde_json::Value = response
+ .into_body()
+ .read_json()
+ .map_err(|e| format!("JSON parse error: {e}"))?;
+
+ pick_latest_release(&body)
+ .ok_or_else(|| "no matching cua-driver-rs-v* release in response".to_owned())
+}
+
+/// Pull the highest non-draft non-prerelease `cua-driver-rs-v*` tag out
+/// of the parsed releases response. Split out so unit tests can feed in
+/// canned JSON without hitting the network.
+pub(crate) fn pick_latest_release(body: &serde_json::Value) -> Option {
+ let releases = body.as_array()?;
+ let mut versions: Vec = releases
+ .iter()
+ .filter_map(|r| {
+ let tag = r.get("tag_name")?.as_str()?;
+ let bare = tag.strip_prefix(RELEASE_TAG_PREFIX)?;
+ if r.get("draft").and_then(|d| d.as_bool()).unwrap_or(false) {
+ return None;
+ }
+ if r.get("prerelease").and_then(|p| p.as_bool()).unwrap_or(false) {
+ return None;
+ }
+ semver::Version::parse(bare).ok()
+ })
+ .collect();
+ versions.sort();
+ versions.last().map(|v| v.to_string())
+}
+
+// ── Time helpers ─────────────────────────────────────────────────────────
+
+fn unix_now() -> u64 {
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
+}
+
+/// Render a unix timestamp as `YYYY-MM-DDTHH:MM:SSZ`. Mirrors the
+/// telemetry module's formatter so the on-disk cache file stays
+/// human-readable without dragging in `chrono`.
+fn iso8601(unix_secs: u64) -> String {
+ let (year, month, day, hour, minute, second) = civil_from_unix(unix_secs);
+ format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
+}
+
+fn civil_from_unix(unix_secs: u64) -> (i32, u32, u32, u32, u32, u32) {
+ let days = (unix_secs / 86_400) as i64;
+ let secs_of_day = (unix_secs % 86_400) as u32;
+ let hour = secs_of_day / 3600;
+ let minute = (secs_of_day % 3600) / 60;
+ let second = secs_of_day % 60;
+
+ // Howard Hinnant's civil_from_days, shifted to 0000-03-01 era epoch.
+ let z = days + 719_468;
+ let era = z.div_euclid(146_097);
+ let doe = (z - era * 146_097) as u32;
+ let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
+ let y = yoe as i64 + era * 400;
+ let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
+ let mp = (5 * doy + 2) / 153;
+ let d = doy - (153 * mp + 2) / 5 + 1;
+ let m = if mp < 10 { mp + 3 } else { mp - 9 };
+ let year = (y + if m <= 2 { 1 } else { 0 }) as i32;
+ (year, m, d, hour, minute, second)
+}
+
+// ── Tests ────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::sync::Mutex;
+
+ /// All env-mutating tests serialise on this lock — `std::env::set_var`
+ /// is process-global, parallel tests would race.
+ static ENV_LOCK: Mutex<()> = Mutex::new(());
+
+ /// Redirect `HOME` / `USERPROFILE` to a fresh temp dir for the body
+ /// of `f`, then restore. Ensures the cache file lives in an isolated
+ /// directory and never touches the developer's real
+ /// `~/.cua-driver-rs/version_check.json`.
+ fn with_isolated_home(f: impl FnOnce(&std::path::Path) -> R) -> R {
+ let tmp = tempfile::tempdir().expect("tempdir");
+ let saved_home = std::env::var_os("HOME");
+ let saved_userprofile = std::env::var_os("USERPROFILE");
+ unsafe { std::env::set_var("HOME", tmp.path()); }
+ unsafe { std::env::set_var("USERPROFILE", tmp.path()); }
+
+ let result = f(tmp.path());
+
+ match saved_home {
+ Some(s) => unsafe { std::env::set_var("HOME", s); },
+ None => unsafe { std::env::remove_var("HOME"); },
+ }
+ match saved_userprofile {
+ Some(s) => unsafe { std::env::set_var("USERPROFILE", s); },
+ None => unsafe { std::env::remove_var("USERPROFILE"); },
+ }
+ result
+ }
+
+ // ── is_newer ────────────────────────────────────────────────────────
+
+ #[test]
+ fn is_newer_recognises_strict_patch_bump() {
+ assert!(is_newer("0.1.4", "0.1.3"));
+ }
+
+ #[test]
+ fn is_newer_recognises_minor_bump_past_double_digit() {
+ // 0.2.0 > 0.1.99 — naive lexicographic compare would fail here.
+ assert!(is_newer("0.2.0", "0.1.99"));
+ }
+
+ #[test]
+ fn is_newer_treats_pre_release_as_lower_than_release() {
+ // 0.1.3 > 0.1.3-dev — semver rule: release > pre-release of same triple.
+ assert!(is_newer("0.1.3", "0.1.3-dev"));
+ }
+
+ #[test]
+ fn is_newer_returns_false_for_equal_versions() {
+ assert!(!is_newer("0.1.3", "0.1.3"));
+ }
+
+ #[test]
+ fn is_newer_returns_false_for_older_candidate() {
+ assert!(!is_newer("0.1.2", "0.1.3"));
+ assert!(!is_newer("0.0.99", "0.1.0"));
+ }
+
+ #[test]
+ fn is_newer_returns_false_for_unparseable_input() {
+ // Garbage in → false out. Better to miss a real update than nag
+ // about a phantom one.
+ assert!(!is_newer("not-a-version", "0.1.3"));
+ assert!(!is_newer("0.1.4", "broken"));
+ }
+
+ // ── Pre-release detection ───────────────────────────────────────────
+
+ #[test]
+ fn is_prerelease_flags_dev_and_rc_suffixes() {
+ assert!(is_prerelease("0.1.4-dev"));
+ assert!(is_prerelease("0.1.4-rc.1"));
+ assert!(is_prerelease("0.1.4-beta"));
+ }
+
+ #[test]
+ fn is_prerelease_passes_plain_release() {
+ assert!(!is_prerelease("0.1.4"));
+ assert!(!is_prerelease("1.0.0"));
+ }
+
+ #[test]
+ fn is_prerelease_returns_true_for_garbage() {
+ // Unparseable strings count as "pre-release" so the update check
+ // skips them rather than blowing up.
+ assert!(is_prerelease("not-a-version"));
+ assert!(is_prerelease(""));
+ }
+
+ // ── Cache round-trip ────────────────────────────────────────────────
+
+ #[test]
+ fn cache_round_trips_through_tempdir() {
+ let _g = ENV_LOCK.lock().unwrap();
+ with_isolated_home(|_| {
+ let original = VersionCache {
+ last_checked_unix: Some(1_700_000_000),
+ last_checked_at: Some("2023-11-14T22:13:20Z".into()),
+ latest_version: Some("0.1.4".into()),
+ dismissed_versions: vec!["0.1.3".into()],
+ };
+ write_cache(&original).expect("write_cache");
+ let read_back = read_cache().expect("read_cache");
+ assert_eq!(read_back.last_checked_unix, Some(1_700_000_000));
+ assert_eq!(read_back.latest_version.as_deref(), Some("0.1.4"));
+ assert_eq!(read_back.dismissed_versions, vec!["0.1.3".to_owned()]);
+ });
+ }
+
+ #[test]
+ fn dismissed_versions_persist_across_writes() {
+ let _g = ENV_LOCK.lock().unwrap();
+ with_isolated_home(|_| {
+ // First dismissal.
+ dismiss_version("0.1.4");
+ let after_first = read_cache().expect("cache after first");
+ assert_eq!(after_first.dismissed_versions, vec!["0.1.4".to_owned()]);
+
+ // Second dismissal of a different version appends, doesn't replace.
+ dismiss_version("0.1.5");
+ let after_second = read_cache().expect("cache after second");
+ assert_eq!(
+ after_second.dismissed_versions,
+ vec!["0.1.4".to_owned(), "0.1.5".to_owned()],
+ );
+
+ // Re-dismissing an already-dismissed version is idempotent.
+ dismiss_version("0.1.4");
+ let after_dup = read_cache().expect("cache after dup");
+ assert_eq!(
+ after_dup.dismissed_versions,
+ vec!["0.1.4".to_owned(), "0.1.5".to_owned()],
+ );
+ });
+ }
+
+ // ── 20h refresh threshold ───────────────────────────────────────────
+
+ #[test]
+ fn cache_older_than_threshold_triggers_refresh() {
+ let _g = ENV_LOCK.lock().unwrap();
+ with_isolated_home(|_| {
+ // Seed the cache 21 hours in the past.
+ let now = unix_now();
+ let stale = VersionCache {
+ last_checked_unix: Some(now.saturating_sub(21 * 60 * 60)),
+ last_checked_at: Some(iso8601(now.saturating_sub(21 * 60 * 60))),
+ latest_version: Some("0.1.3".into()), // stale data
+ dismissed_versions: vec![],
+ };
+ write_cache(&stale).unwrap();
+
+ let mut buf: Vec = Vec::new();
+ let fetch_calls = std::sync::atomic::AtomicUsize::new(0);
+ run_check_and_announce(
+ "0.1.3",
+ || {
+ fetch_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+ Ok("0.1.4".to_owned()) // newer version returned by network
+ },
+ &mut buf,
+ );
+
+ assert_eq!(
+ fetch_calls.load(std::sync::atomic::Ordering::SeqCst),
+ 1,
+ "stale cache must trigger a fetch",
+ );
+ let banner = String::from_utf8(buf).unwrap();
+ assert!(banner.contains("v0.1.4 is available"), "got: {banner:?}");
+ assert!(banner.contains("you have v0.1.3"), "got: {banner:?}");
+ });
+ }
+
+ #[test]
+ fn cache_younger_than_threshold_skips_network() {
+ let _g = ENV_LOCK.lock().unwrap();
+ with_isolated_home(|_| {
+ // Seed the cache 1 hour in the past with a known-newer version.
+ let now = unix_now();
+ let fresh = VersionCache {
+ last_checked_unix: Some(now.saturating_sub(60 * 60)),
+ last_checked_at: Some(iso8601(now.saturating_sub(60 * 60))),
+ latest_version: Some("0.1.4".into()),
+ dismissed_versions: vec![],
+ };
+ write_cache(&fresh).unwrap();
+
+ let mut buf: Vec = Vec::new();
+ let fetch_calls = std::sync::atomic::AtomicUsize::new(0);
+ run_check_and_announce(
+ "0.1.3",
+ || {
+ fetch_calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
+ Ok("0.1.5".to_owned())
+ },
+ &mut buf,
+ );
+
+ assert_eq!(
+ fetch_calls.load(std::sync::atomic::Ordering::SeqCst),
+ 0,
+ "fresh cache must NOT trigger a fetch",
+ );
+ let banner = String::from_utf8(buf).unwrap();
+ // Banner uses the cached value (0.1.4), not the would-be network value (0.1.5).
+ assert!(banner.contains("v0.1.4 is available"), "got: {banner:?}");
+ });
+ }
+
+ #[test]
+ fn dismissed_latest_suppresses_banner() {
+ let _g = ENV_LOCK.lock().unwrap();
+ with_isolated_home(|_| {
+ // Cache is fresh and reports a newer version, but the user
+ // dismissed exactly that version — banner must stay silent.
+ let now = unix_now();
+ let cache = VersionCache {
+ last_checked_unix: Some(now),
+ last_checked_at: Some(iso8601(now)),
+ latest_version: Some("0.1.4".into()),
+ dismissed_versions: vec!["0.1.4".into()],
+ };
+ write_cache(&cache).unwrap();
+
+ let mut buf: Vec = Vec::new();
+ run_check_and_announce("0.1.3", || Ok("0.1.4".to_owned()), &mut buf);
+ assert!(buf.is_empty(), "dismissed version must suppress banner");
+ });
+ }
+
+ // ── Opt-out paths ───────────────────────────────────────────────────
+
+ #[test]
+ fn env_opt_out_short_circuits_enabled_check() {
+ let _g = ENV_LOCK.lock().unwrap();
+ let saved = std::env::var_os(ENV_UPDATE_CHECK);
+
+ unsafe { std::env::set_var(ENV_UPDATE_CHECK, "false"); }
+ // Use a separately-redirected HOME so any config file present on
+ // the developer's machine can't influence the result.
+ with_isolated_home(|_| {
+ assert!(!is_enabled(), "explicit false env var must disable");
+ });
+
+ unsafe { std::env::set_var(ENV_UPDATE_CHECK, "0"); }
+ with_isolated_home(|_| {
+ assert!(!is_enabled(), "0 must disable");
+ });
+
+ unsafe { std::env::set_var(ENV_UPDATE_CHECK, "off"); }
+ with_isolated_home(|_| {
+ assert!(!is_enabled(), "off must disable");
+ });
+
+ // Restore.
+ match saved {
+ Some(s) => unsafe { std::env::set_var(ENV_UPDATE_CHECK, s); },
+ None => unsafe { std::env::remove_var(ENV_UPDATE_CHECK); },
+ }
+ }
+
+ #[test]
+ fn config_flag_disables_check() {
+ let _g = ENV_LOCK.lock().unwrap();
+ let saved = std::env::var_os(ENV_UPDATE_CHECK);
+ unsafe { std::env::remove_var(ENV_UPDATE_CHECK); }
+
+ with_isolated_home(|home| {
+ // Write a config that disables the check.
+ let cfg_dir = home.join(".cua-driver");
+ std::fs::create_dir_all(&cfg_dir).unwrap();
+ std::fs::write(
+ cfg_dir.join("config.json"),
+ r#"{"update_check_enabled": false}"#,
+ ).unwrap();
+
+ // Build version is a normal release (this crate's pkg version
+ // ships as "0.1.3", a stable release — see workspace Cargo.toml).
+ // The env var is unset, so the config flag is the only signal.
+ assert!(!is_enabled(),
+ "persisted update_check_enabled=false must disable the check");
+ });
+
+ match saved {
+ Some(s) => unsafe { std::env::set_var(ENV_UPDATE_CHECK, s); },
+ None => unsafe { std::env::remove_var(ENV_UPDATE_CHECK); },
+ }
+ }
+
+ #[test]
+ fn config_flag_true_or_missing_leaves_check_on() {
+ let _g = ENV_LOCK.lock().unwrap();
+ let saved = std::env::var_os(ENV_UPDATE_CHECK);
+ unsafe { std::env::remove_var(ENV_UPDATE_CHECK); }
+
+ with_isolated_home(|home| {
+ // Case A: no config file at all → enabled.
+ // CARGO_PKG_VERSION is "0.1.3" (stable), env unset, no config file.
+ assert!(is_enabled(),
+ "no config file + stable version + no env opt-out → enabled");
+
+ // Case B: config file present but flag = true → still enabled.
+ let cfg_dir = home.join(".cua-driver");
+ std::fs::create_dir_all(&cfg_dir).unwrap();
+ std::fs::write(
+ cfg_dir.join("config.json"),
+ r#"{"update_check_enabled": true, "other_key": 42}"#,
+ ).unwrap();
+ assert!(is_enabled(),
+ "explicit update_check_enabled=true must leave check on");
+ });
+
+ match saved {
+ Some(s) => unsafe { std::env::set_var(ENV_UPDATE_CHECK, s); },
+ None => unsafe { std::env::remove_var(ENV_UPDATE_CHECK); },
+ }
+ }
+
+ // ── Banner formatting ───────────────────────────────────────────────
+
+ #[test]
+ fn banner_contains_required_lines() {
+ let banner = format_banner("0.1.4", "0.1.3");
+ // Headline.
+ assert!(banner.contains("cua-driver v0.1.4 is available"), "got: {banner:?}");
+ assert!(banner.contains("you have v0.1.3"), "got: {banner:?}");
+ // Update instruction.
+ assert!(banner.contains("Update with: cua-driver update"), "got: {banner:?}");
+ // Release notes URL uses the correct tag prefix.
+ assert!(
+ banner.contains(
+ "https://github.com/trycua/cua/releases/tag/cua-driver-rs-v0.1.4"
+ ),
+ "got: {banner:?}",
+ );
+ }
+
+ // ── pick_latest_release ─────────────────────────────────────────────
+
+ #[test]
+ fn pick_latest_release_filters_swift_port_tags() {
+ // Releases JSON contains both Swift-port tags and Rust-port tags;
+ // only the Rust-port (`cua-driver-rs-v*`) tags must be considered.
+ let body = serde_json::json!([
+ {"tag_name": "cua-driver-v0.9.0", "draft": false, "prerelease": false},
+ {"tag_name": "cua-driver-rs-v0.1.4", "draft": false, "prerelease": false},
+ {"tag_name": "cua-driver-rs-v0.1.3", "draft": false, "prerelease": false},
+ {"tag_name": "cua-driver-v9.9.9", "draft": false, "prerelease": false},
+ ]);
+ assert_eq!(pick_latest_release(&body).as_deref(), Some("0.1.4"));
+ }
+
+ #[test]
+ fn pick_latest_release_skips_drafts_and_prereleases() {
+ let body = serde_json::json!([
+ {"tag_name": "cua-driver-rs-v0.2.0", "draft": true, "prerelease": false},
+ {"tag_name": "cua-driver-rs-v0.1.5", "draft": false, "prerelease": true},
+ {"tag_name": "cua-driver-rs-v0.1.4", "draft": false, "prerelease": false},
+ ]);
+ assert_eq!(pick_latest_release(&body).as_deref(), Some("0.1.4"));
+ }
+
+ #[test]
+ fn pick_latest_release_returns_none_for_empty_array() {
+ assert_eq!(pick_latest_release(&serde_json::json!([])), None);
+ }
+
+ #[test]
+ fn iso8601_round_trips_against_known_unix_timestamp() {
+ // 2023-11-14T22:13:20Z = 1_700_000_000 unix seconds.
+ assert_eq!(iso8601(1_700_000_000), "2023-11-14T22:13:20Z");
+ }
+}