Skip to main content

ai_usagebar/
update.rs

1//! Self-update support for the Windows and macOS trays: release discovery,
2//! asset selection, checksum verification, and the in-place binary swap.
3//!
4//! Linux installs get new versions from the AUR, Nix, or cargo-binstall; a
5//! Windows install is a zip the user unpacked by hand, and a macOS one is a
6//! binary copied somewhere on `PATH`, so nothing would ever tell either that a
7//! newer release exists. The tray host polls GitHub's *latest* release once
8//! per [`CHECK_INTERVAL`], downloads the per-binary assets the release
9//! workflow publishes for its OS and architecture, checks each against its
10//! `.sha256` sidecar, and swaps the verified files into the install directory.
11//! No compiler is involved on the user's machine.
12//!
13//! Everything in this module is pure or takes an explicit path, so the whole
14//! flow is unit-tested against a temp directory and never touches the network
15//! or a real install. The tray host owns the HTTP calls and the UI; this
16//! module owns every decision the host must not get wrong twice:
17//!
18//! - [`parse_release`] / [`is_newer`]: a draft or prerelease is never an
19//!   update, and only a strict `X.Y.Z` compares — "newer" must never be a
20//!   string comparison, or `1.9.10` loses to `1.10.0`.
21//! - [`select_downloads`]: the tray binary is the one doing the updating, so
22//!   it is mandatory; the CLI and TUI are nice-to-have and a release that
23//!   ships without one of them still updates the tray.
24//! - [`verify_sha256`]: nothing lands in the install directory unverified.
25//! - [`stage_swap`] / [`sweep_old`]: Windows refuses to overwrite a running
26//!   executable but happily lets it be *renamed*, so the live exe becomes
27//!   `<name>.old`, the staged file takes its place, and the next start
28//!   sweeps the `.old` files once no process holds them any more. macOS
29//!   takes the same path: a rename gives the new binary its own inode, where
30//!   overwriting the running one in place would get it killed by the kernel's
31//!   code-signing check.
32
33use std::fs;
34use std::io::Read;
35use std::path::{Path, PathBuf};
36use std::time::Duration;
37
38use serde::{Deserialize, Serialize};
39use sha2::{Digest, Sha256};
40
41use crate::error::Result;
42
43/// The repository the running binary was built from, as `Cargo.toml`'s
44/// `repository` field says. A fork that builds its own tray therefore
45/// updates from its own releases without touching code.
46pub const SOURCE_REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");
47
48/// Build-time override for simulating an update on one machine:
49/// `AIUB_UPDATE_FEED=http://127.0.0.1:8765/latest.json cargo build ...`
50/// makes that binary ask the given URL instead of GitHub and accept plain-HTTP
51/// loopback downloads. The release workflow never sets it, so a shipped tray
52/// has no runtime switch that could point it elsewhere.
53pub const LOCAL_FEED: Option<&str> = option_env!("AIUB_UPDATE_FEED");
54
55/// GitHub's "latest" is the newest non-draft, non-prerelease release, which
56/// is exactly the set the tray may install. Polled once per interval.
57pub fn latest_release_url() -> Option<String> {
58    if let Some(feed) = LOCAL_FEED {
59        return Some(feed.to_string());
60    }
61    latest_release_url_for(SOURCE_REPOSITORY)
62}
63
64/// Where a download may come from: HTTPS, or loopback HTTP in a build made
65/// with [`LOCAL_FEED`].
66pub fn download_url_allowed(url: &str) -> bool {
67    download_url_allowed_with(url, LOCAL_FEED.is_some())
68}
69
70fn download_url_allowed_with(url: &str, local_feed: bool) -> bool {
71    if url.starts_with("https://") {
72        return true;
73    }
74    // Parsed, not prefix-matched: `http://127.0.0.1:1@evil.com/` starts with the
75    // loopback prefix but its host is evil.com.
76    local_feed
77        && reqwest::Url::parse(url).is_ok_and(|parsed| {
78            parsed.scheme() == "http"
79                && parsed.username().is_empty()
80                && parsed.password().is_none()
81                && matches!(parsed.host_str(), Some("127.0.0.1" | "localhost"))
82        })
83}
84
85/// `https://github.com/<owner>/<name>[.git][/]` → the releases/latest API URL.
86/// Anything that is not a GitHub repository yields `None`, and the tray
87/// reports that it cannot check rather than asking a random host.
88pub fn latest_release_url_for(repository: &str) -> Option<String> {
89    let path = repository
90        .trim()
91        .strip_prefix("https://github.com/")?
92        .trim_end_matches('/')
93        .trim_end_matches(".git");
94    let (owner, name) = path.split_once('/')?;
95    let valid = |s: &str| {
96        !s.is_empty()
97            && s.len() <= 100
98            && s.chars()
99                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
100    };
101    if !valid(owner) || !valid(name) || name.contains('/') {
102        return None;
103    }
104    Some(format!(
105        "https://api.github.com/repos/{owner}/{name}/releases/latest"
106    ))
107}
108
109/// Once an hour: releases are rare, and the unauthenticated GitHub API
110/// allows sixty requests an hour per IP — one of them is ours.
111pub const CHECK_INTERVAL: Duration = Duration::from_secs(60 * 60);
112
113/// A release exe is a few MiB. Anything reporting more than this is not one
114/// of ours and is refused before a single byte is downloaded.
115pub const MAX_ASSET_BYTES: u64 = 50 * 1024 * 1024;
116
117/// The three binaries, tray first: it is the one that must update (it runs
118/// the updater), the other two are optional extras.
119pub const BINARIES: [&str; 3] = ["ai-usagebar-tray", "ai-usagebar", "ai-usagebar-tui"];
120
121/// Longest `html_url` or asset name accepted from the release JSON. Real
122/// values are well under a hundred characters; anything longer is not
123/// something we want to log, display, or join into a path.
124const MAX_FIELD_LEN: usize = 512;
125
126/// One downloadable file of a release. `url` is the `browser_download_url`.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct Asset {
129    pub name: String,
130    pub size: u64,
131    pub url: String,
132}
133
134/// The parts of a GitHub release the updater acts on. `version` is bare
135/// (`1.11.0`, never `v1.11.0`) so it compares with `CARGO_PKG_VERSION`.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct Release {
138    pub assets: Vec<Asset>,
139    pub html_url: String,
140    pub version: String,
141}
142
143/// The exe/sidecar pair for one binary, as found in a release.
144#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct Download {
146    pub binary: &'static str,
147    pub exe: Asset,
148    pub sha256: Asset,
149}
150
151/// Wire shape of `GET /releases/latest` — only the fields we read. Every
152/// field but `tag_name` defaults so a schema drift in something we ignore
153/// cannot break the update check.
154#[derive(Deserialize)]
155struct WireRelease {
156    #[serde(default)]
157    assets: Vec<WireAsset>,
158    #[serde(default)]
159    draft: bool,
160    #[serde(default)]
161    html_url: String,
162    #[serde(default)]
163    prerelease: bool,
164    tag_name: Option<String>,
165}
166
167#[derive(Deserialize)]
168struct WireAsset {
169    #[serde(default)]
170    browser_download_url: String,
171    #[serde(default)]
172    name: String,
173    #[serde(default)]
174    size: u64,
175}
176
177/// Parse a release response into what the updater needs.
178///
179/// A draft or prerelease is an error rather than "not newer": the tray shows
180/// the reason, and a silent skip would hide a mis-tagged release forever.
181/// Assets are filtered, not rejected wholesale — one odd file in a release
182/// must not block the update — but an asset whose name could escape the
183/// staging directory (a separator, `..`) is dropped, as is one served from
184/// anywhere but HTTPS. `html_url` is the "open release notes" link, which
185/// only ever points at GitHub; anything else becomes an empty string so the
186/// tray simply shows no link.
187pub fn parse_release(json: &str) -> std::result::Result<Release, String> {
188    let wire: WireRelease =
189        serde_json::from_str(json).map_err(|error| format!("release JSON: {error}"))?;
190    if wire.draft || wire.prerelease {
191        return Err("prerelease/draft release".to_string());
192    }
193    let tag = wire
194        .tag_name
195        .ok_or_else(|| "release JSON has no tag_name".to_string())?;
196    let version = tag.strip_prefix('v').unwrap_or(&tag);
197    if parse_version(version).is_none() {
198        return Err(format!("release tag {tag:?} is not vX.Y.Z"));
199    }
200    let html_url = if wire.html_url.starts_with("https://github.com/")
201        && wire.html_url.len() <= MAX_FIELD_LEN
202    {
203        wire.html_url
204    } else {
205        String::new()
206    };
207    let assets = wire
208        .assets
209        .into_iter()
210        .filter(|asset| asset_name_is_safe(&asset.name))
211        .filter(|asset| {
212            download_url_allowed(&asset.browser_download_url)
213                && asset.browser_download_url.len() <= MAX_FIELD_LEN
214        })
215        .map(|asset| Asset {
216            name: asset.name,
217            size: asset.size,
218            url: asset.browser_download_url,
219        })
220        .collect();
221    Ok(Release {
222        assets,
223        html_url,
224        version: version.to_string(),
225    })
226}
227
228/// A name is joined onto the staging and install directories verbatim, so it
229/// must be a single plain component.
230fn asset_name_is_safe(name: &str) -> bool {
231    !name.is_empty()
232        && name.len() <= MAX_FIELD_LEN
233        && !name.contains(['/', '\\'])
234        && !name.contains("..")
235        && name != "."
236}
237
238/// Strict `X.Y.Z` with numeric components. Pre-release suffixes, build
239/// metadata, and two-part versions are all "not a version" here: the release
240/// workflow only ever tags `vX.Y.Z`, so anything else is not one of ours.
241fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
242    let mut parts = text.split('.');
243    let major = parts.next()?.parse().ok()?;
244    let minor = parts.next()?.parse().ok()?;
245    let patch = parts.next()?.parse().ok()?;
246    if parts.next().is_some() {
247        return None;
248    }
249    Some((major, minor, patch))
250}
251
252/// Numeric semver comparison. A malformed side is `false`: an update the
253/// tray cannot reason about is not an update it should offer.
254pub fn is_newer(current: &str, candidate: &str) -> bool {
255    match (parse_version(current), parse_version(candidate)) {
256        (Some(current), Some(candidate)) => candidate > current,
257        _ => false,
258    }
259}
260
261/// The architecture suffix the release workflow uses in asset names.
262/// `"unknown"` selects nothing and the tray reports that instead of
263/// installing the wrong binary.
264pub fn current_arch() -> &'static str {
265    if cfg!(target_arch = "x86_64") {
266        "x86_64"
267    } else if cfg!(target_arch = "aarch64") {
268        "aarch64"
269    } else {
270        "unknown"
271    }
272}
273
274/// Why the running tray must not replace itself, or `None` when it may.
275///
276/// `exe` is the path it was started from and `exe_is_link` whether that path is
277/// a symbolic link. A package manager links its binaries into place (Homebrew's
278/// `bin`), keeps them in its own tree (`Cellar`, `/nix/store`, Scoop's `apps`),
279/// and a source build lives in cargo's `target` directory: replacing any of
280/// those would fight the tool that owns the file, so the popover offers the
281/// release page. `exists` answers whether a file is there; only the Scoop check
282/// needs it (see [`scoop_managed`]).
283pub fn self_update_blocker(
284    exe: &Path,
285    exe_is_link: bool,
286    exists: impl Fn(&Path) -> bool,
287) -> Option<&'static str> {
288    if exe_is_link {
289        return Some("installed through a link; update it with the tool that installed it");
290    }
291    if scoop_managed(exe, exists) {
292        return Some("managed by Scoop; update it with scoop update");
293    }
294    let names: Vec<String> = exe
295        .components()
296        .filter_map(|part| match part {
297            std::path::Component::Normal(name) => Some(name.to_string_lossy().into_owned()),
298            _ => None,
299        })
300        .collect();
301    if names.iter().any(|name| name == "Cellar") {
302        return Some("managed by Homebrew; update it with brew");
303    }
304    if exe.starts_with("/nix/store") {
305        return Some("managed by Nix; update it through your flake or channel");
306    }
307    // `target/{debug,release}/exe` or `target/<triple>/{debug,release}/exe`.
308    let dirs = &names[..names.len().saturating_sub(1)];
309    if let Some(profile_at) = dirs.iter().rposition(|d| d == "debug" || d == "release")
310        && profile_at + 1 == dirs.len()
311        && dirs[..profile_at]
312            .iter()
313            .rev()
314            .take(2)
315            .any(|d| d == "target")
316    {
317        return Some("a cargo build; rebuild it from source");
318    }
319    None
320}
321
322/// Whether `exe` is a Scoop install: `<scoop root>\apps\<app>\<version or current>\<exe>`,
323/// with the `install.json` Scoop writes beside every version it installs. Replacing the exe
324/// there leaves Scoop's records on the old version (`scoop list`, `scoop status`, the next
325/// `scoop update` and `scoop reset` all go wrong), so Scoop has to own the update. The app
326/// name is not checked: a fork's bucket can install the tray as `ai-usagebar-dev`.
327pub fn scoop_managed(exe: &Path, exists: impl Fn(&Path) -> bool) -> bool {
328    let Some(version_dir) = exe.parent() else {
329        return false;
330    };
331    let under_apps = version_dir
332        .parent()
333        .and_then(Path::parent)
334        .and_then(Path::file_name)
335        .is_some_and(|name| name.eq_ignore_ascii_case("apps"));
336    under_apps && exists(&version_dir.join("install.json"))
337}
338
339/// The OS segment of an asset name. `"unknown"` selects nothing, like an
340/// unknown architecture.
341pub fn current_os() -> &'static str {
342    if cfg!(windows) {
343        "windows"
344    } else if cfg!(target_os = "macos") {
345        "macos"
346    } else {
347        "unknown"
348    }
349}
350
351/// `{binary}-windows-{arch}.exe` / `{binary}-macos-{arch}` — the bare-binary
352/// asset naming in `.github/workflows/release.yml`. Its sidecar is this plus
353/// `.sha256`.
354pub fn asset_name(binary: &str, os: &str, arch: &str) -> String {
355    format!("{binary}-{os}-{arch}{}", exe_suffix(os))
356}
357
358/// The file name a binary has once installed: `ai-usagebar-tray.exe` on
359/// Windows, `ai-usagebar-tray` elsewhere.
360pub fn installed_name(binary: &str, os: &str) -> String {
361    format!("{binary}{}", exe_suffix(os))
362}
363
364fn exe_suffix(os: &str) -> &'static str {
365    if os == "windows" { ".exe" } else { "" }
366}
367
368/// Pair each binary with its exe and sidecar, tray first.
369///
370/// The tray pair is required: a release without it cannot update the thing
371/// that is updating, so the whole check fails loudly. The CLI and TUI pairs
372/// are skipped when either half is missing — a partial release still updates
373/// the tray. A zero-byte or oversized exe is an error for every binary: that
374/// is a broken release, not an optional one, and installing a subset would
375/// leave a version mix on disk.
376pub fn select_downloads(
377    release: &Release,
378    os: &str,
379    arch: &str,
380) -> std::result::Result<Vec<Download>, String> {
381    let find = |name: &str| release.assets.iter().find(|asset| asset.name == name);
382    let mut downloads = Vec::with_capacity(BINARIES.len());
383    for binary in BINARIES {
384        let required = binary == BINARIES[0];
385        let exe_name = asset_name(binary, os, arch);
386        let sidecar_name = format!("{exe_name}.sha256");
387        let (exe, sha256) = match (find(&exe_name), find(&sidecar_name)) {
388            (Some(exe), Some(sha256)) => (exe, sha256),
389            _ if required => {
390                return Err(format!(
391                    "release {} has no {exe_name} + {sidecar_name} pair",
392                    release.version
393                ));
394            }
395            _ => continue,
396        };
397        if exe.size == 0 {
398            return Err(format!("{exe_name} is empty"));
399        }
400        if exe.size > MAX_ASSET_BYTES {
401            return Err(format!(
402                "{exe_name} is {} bytes, over the {MAX_ASSET_BYTES}-byte limit",
403                exe.size
404            ));
405        }
406        downloads.push(Download {
407            binary,
408            exe: exe.clone(),
409            sha256: sha256.clone(),
410        });
411    }
412    Ok(downloads)
413}
414
415/// The sidecar the release workflow writes is `"<hex>  <name>"` (the
416/// `sha256sum` format, so `sha256sum -c` works on Linux too); accept a bare
417/// digest as well. Folded to lowercase so callers compare bytes.
418pub fn parse_sha256_sidecar(text: &str) -> std::result::Result<String, String> {
419    let digest = text
420        .split_whitespace()
421        .next()
422        .ok_or_else(|| "empty sha256 sidecar".to_string())?;
423    if digest.len() != 64 || !digest.bytes().all(|byte| byte.is_ascii_hexdigit()) {
424        return Err(format!("sha256 sidecar is not a 64-hex digest: {digest:?}"));
425    }
426    Ok(digest.to_ascii_lowercase())
427}
428
429/// Stream the file through SHA-256 and compare with `expected_hex`
430/// (case-insensitive). Streamed rather than read whole: the buffer is a
431/// fixed 64 KiB whatever the download turned out to be.
432pub fn verify_sha256(path: &Path, expected_hex: &str) -> std::result::Result<(), String> {
433    let mut file =
434        fs::File::open(path).map_err(|error| format!("open {}: {error}", path.display()))?;
435    let mut hasher = Sha256::new();
436    let mut buffer = [0u8; 64 * 1024];
437    loop {
438        let read = file
439            .read(&mut buffer)
440            .map_err(|error| format!("read {}: {error}", path.display()))?;
441        if read == 0 {
442            break;
443        }
444        hasher.update(&buffer[..read]);
445    }
446    let actual = hex_lower(&hasher.finalize());
447    if actual == expected_hex.to_ascii_lowercase() {
448        Ok(())
449    } else {
450        Err(format!(
451            "sha256 mismatch for {}: expected {expected_hex}, got {actual}",
452            path.display()
453        ))
454    }
455}
456
457fn hex_lower(bytes: &[u8]) -> String {
458    use std::fmt::Write;
459    let mut text = String::with_capacity(bytes.len() * 2);
460    for byte in bytes {
461        let _ = write!(text, "{byte:02x}");
462    }
463    text
464}
465
466/// One entry [`stage_swap`] has completed, kept so a later failure can undo it.
467struct Swapped {
468    dest: PathBuf,
469    old: Option<PathBuf>,
470    source: PathBuf,
471}
472
473/// Move verified staged files into `install_dir`, one `(file name, staged
474/// path)` per binary, returning the paths written.
475///
476/// Every staged path is checked up front so a missing download fails before
477/// anything on disk moves. Per entry: an existing `install_dir/<name>` is
478/// renamed to `<name>.old` (a stale `.old` from an earlier update is removed
479/// first) and the staged file is renamed into place — copy + remove when the
480/// staging directory sits on another volume and `rename` refuses. On any
481/// failure the entries already swapped are rolled back best-effort: the new
482/// file goes back to its staged path and the `.old` returns to its name, so
483/// the install never ends up half of one version and half of another.
484pub fn stage_swap(
485    install_dir: &Path,
486    staged: &[(String, PathBuf)],
487) -> std::result::Result<Vec<PathBuf>, String> {
488    for (name, source) in staged {
489        if !asset_name_is_safe(name) {
490            return Err(format!("refusing to install a file named {name:?}"));
491        }
492        if !source.is_file() {
493            return Err(format!(
494                "staged file {} for {name} does not exist",
495                source.display()
496            ));
497        }
498    }
499    let mut done: Vec<Swapped> = Vec::with_capacity(staged.len());
500    for (name, source) in staged {
501        match swap_one(install_dir, name, source) {
502            Ok(swapped) => done.push(swapped),
503            Err(error) => {
504                rollback(&done);
505                return Err(error);
506            }
507        }
508    }
509    Ok(done.into_iter().map(|swapped| swapped.dest).collect())
510}
511
512fn swap_one(install_dir: &Path, name: &str, source: &Path) -> std::result::Result<Swapped, String> {
513    let dest = install_dir.join(name);
514    let old_path = install_dir.join(format!("{name}.old"));
515    let mut old = None;
516    if dest.exists() {
517        if old_path.exists() {
518            fs::remove_file(&old_path)
519                .map_err(|error| format!("remove stale {}: {error}", old_path.display()))?;
520        }
521        fs::rename(&dest, &old_path).map_err(|error| {
522            format!(
523                "rename {} to {}: {error}",
524                dest.display(),
525                old_path.display()
526            )
527        })?;
528        old = Some(old_path);
529    }
530    if let Err(error) = move_file(source, &dest) {
531        let _ = fs::remove_file(&dest);
532        if let Some(old) = &old {
533            let _ = fs::rename(old, &dest);
534        }
535        return Err(error);
536    }
537    Ok(Swapped {
538        dest,
539        old,
540        source: source.to_path_buf(),
541    })
542}
543
544/// `rename`, falling back to copy + remove for a cross-volume move. A
545/// half-written copy is removed so the destination is never a truncated exe.
546fn move_file(from: &Path, to: &Path) -> std::result::Result<(), String> {
547    let Err(rename_error) = fs::rename(from, to) else {
548        return Ok(());
549    };
550    if let Err(copy_error) = fs::copy(from, to) {
551        let _ = fs::remove_file(to);
552        return Err(format!(
553            "move {} to {}: rename failed ({rename_error}), copy failed ({copy_error})",
554            from.display(),
555            to.display()
556        ));
557    }
558    let _ = fs::remove_file(from);
559    Ok(())
560}
561
562fn rollback(done: &[Swapped]) {
563    for swapped in done.iter().rev() {
564        if move_file(&swapped.dest, &swapped.source).is_err() {
565            let _ = fs::remove_file(&swapped.dest);
566        }
567        if let Some(old) = &swapped.old {
568            let _ = fs::rename(old, &swapped.dest);
569        }
570    }
571}
572
573/// Delete the `<binary>.old` files a previous [`stage_swap`] left behind for
574/// `os` and return how many went. A file still held by a process that has not
575/// exited yet stays for the next sweep; nothing else in the directory is
576/// touched.
577pub fn sweep_old(install_dir: &Path, os: &str) -> usize {
578    BINARIES
579        .iter()
580        .map(|binary| install_dir.join(format!("{}.old", installed_name(binary, os))))
581        .filter(|path| path.is_file())
582        .filter(|path| fs::remove_file(path).is_ok())
583        .count()
584}
585
586/// What the tray remembers between checks: when it last asked GitHub, so a
587/// restart does not re-poll, and which version the user dismissed, so the
588/// same release is not offered again until a newer one appears.
589#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
590pub struct UpdateState {
591    /// The version the tray handed to Scoop and has not seen running yet. The next start clears it
592    /// when that version arrived, and reports it when Scoop did not deliver it; background checks
593    /// in Automatic mode never hand it off again.
594    #[serde(default)]
595    pub handed_off: Option<String>,
596    #[serde(default)]
597    pub last_check_ms: i64,
598    #[serde(default)]
599    pub snoozed_version: Option<String>,
600}
601
602impl UpdateState {
603    /// Missing or unreadable state is the default: the worst case is one
604    /// extra poll and one re-shown prompt, which is the safe direction for a
605    /// corrupt sidecar.
606    pub fn load_at(path: &Path) -> UpdateState {
607        fs::read(path)
608            .ok()
609            .and_then(|bytes| serde_json::from_slice(&bytes).ok())
610            .unwrap_or_default()
611    }
612
613    /// Atomic write (tempfile + rename), creating the parent directory.
614    pub fn save_at(&self, path: &Path) -> Result<()> {
615        let bytes = serde_json::to_vec_pretty(self)?;
616        crate::cache::atomic_write(path, &bytes)
617    }
618}
619
620/// `<cache dir>/ai-usagebar/update.json` — beside the vendor caches and
621/// `detect.json`, because it is derived state that is safe to delete.
622pub fn default_state_path() -> Result<PathBuf> {
623    Ok(crate::cache::xdg_cache_dir()?
624        .join("ai-usagebar")
625        .join("update.json"))
626}
627
628/// Where downloads for one version are staged: `<cache_root>/updates/<version>`.
629/// Per version, so an interrupted download of one release never mixes with
630/// the next, and the whole directory can be removed after a swap.
631pub fn staging_dir(cache_root: &Path, version: &str) -> PathBuf {
632    cache_root.join("updates").join(version)
633}
634
635#[cfg(test)]
636mod tests {
637    #[test]
638    fn latest_release_url_follows_the_cargo_repository_field() {
639        assert_eq!(
640            super::latest_release_url_for("https://github.com/akitaonrails/ai-usagebar"),
641            Some("https://api.github.com/repos/akitaonrails/ai-usagebar/releases/latest".into())
642        );
643        assert_eq!(
644            super::latest_release_url_for("https://github.com/djalmajr/ai-usagebar.git/"),
645            Some("https://api.github.com/repos/djalmajr/ai-usagebar/releases/latest".into())
646        );
647        assert_eq!(
648            super::latest_release_url_for("https://gitlab.com/x/y"),
649            None
650        );
651        assert_eq!(
652            super::latest_release_url_for("https://github.com/only-owner"),
653            None
654        );
655        assert_eq!(
656            super::latest_release_url_for("https://github.com/o/n/extra"),
657            None
658        );
659        assert_eq!(
660            super::latest_release_url_for("https://github.com/o/n%20e"),
661            None
662        );
663        // The build we are in points somewhere valid.
664        assert!(
665            super::latest_release_url().is_some(),
666            "{}",
667            super::SOURCE_REPOSITORY
668        );
669    }
670
671    use super::*;
672    use tempfile::TempDir;
673
674    const RELEASE_FIXTURE: &str = r#"{
675  "url": "https://api.github.com/repos/akitaonrails/ai-usagebar/releases/300000",
676  "html_url": "https://github.com/akitaonrails/ai-usagebar/releases/tag/v1.11.0",
677  "id": 300000,
678  "tag_name": "v1.11.0",
679  "target_commitish": "main",
680  "name": "v1.11.0",
681  "draft": false,
682  "prerelease": false,
683  "created_at": "2026-09-01T12:00:00Z",
684  "published_at": "2026-09-01T12:05:00Z",
685  "assets": [
686    {
687      "name": "ai-usagebar-linux-x86_64.tar.gz",
688      "size": 9000000,
689      "content_type": "application/gzip",
690      "browser_download_url": "https://github.com/akitaonrails/ai-usagebar/releases/download/v1.11.0/ai-usagebar-linux-x86_64.tar.gz"
691    },
692    {
693      "name": "ai-usagebar-tray-windows-x86_64.exe",
694      "size": 6100000,
695      "content_type": "application/octet-stream",
696      "browser_download_url": "https://github.com/akitaonrails/ai-usagebar/releases/download/v1.11.0/ai-usagebar-tray-windows-x86_64.exe"
697    },
698    {
699      "name": "ai-usagebar-tray-windows-x86_64.exe.sha256",
700      "size": 102,
701      "content_type": "application/octet-stream",
702      "browser_download_url": "https://github.com/akitaonrails/ai-usagebar/releases/download/v1.11.0/ai-usagebar-tray-windows-x86_64.exe.sha256"
703    },
704    {
705      "name": "../evil.exe",
706      "size": 10,
707      "browser_download_url": "https://github.com/akitaonrails/ai-usagebar/releases/download/v1.11.0/evil.exe"
708    },
709    {
710      "name": "plain-http.exe",
711      "size": 10,
712      "browser_download_url": "http://example.com/plain-http.exe"
713    }
714  ]
715}"#;
716
717    fn asset(name: &str, size: u64) -> Asset {
718        Asset {
719            name: name.to_string(),
720            size,
721            url: format!(
722                "https://github.com/akitaonrails/ai-usagebar/releases/download/v1.11.0/{name}"
723            ),
724        }
725    }
726
727    fn pair(binary: &str, size: u64) -> [Asset; 2] {
728        let exe = asset_name(binary, "windows", "x86_64");
729        [asset(&exe, size), asset(&format!("{exe}.sha256"), 100)]
730    }
731
732    fn release_with(assets: Vec<Asset>) -> Release {
733        Release {
734            assets,
735            html_url: String::new(),
736            version: "1.11.0".to_string(),
737        }
738    }
739
740    fn full_release() -> Release {
741        release_with(
742            BINARIES
743                .iter()
744                .flat_map(|binary| pair(binary, 5_000_000))
745                .collect(),
746        )
747    }
748
749    #[test]
750    fn parse_release_reads_version_url_and_safe_assets() {
751        let release = parse_release(RELEASE_FIXTURE).unwrap();
752
753        assert_eq!(release.version, "1.11.0");
754        assert_eq!(
755            release.html_url,
756            "https://github.com/akitaonrails/ai-usagebar/releases/tag/v1.11.0"
757        );
758        let names: Vec<&str> = release
759            .assets
760            .iter()
761            .map(|asset| asset.name.as_str())
762            .collect();
763        assert_eq!(
764            names,
765            vec![
766                "ai-usagebar-linux-x86_64.tar.gz",
767                "ai-usagebar-tray-windows-x86_64.exe",
768                "ai-usagebar-tray-windows-x86_64.exe.sha256",
769            ],
770            "the traversal name and the plain-http asset are dropped"
771        );
772        let tray = &release.assets[1];
773        assert_eq!(tray.size, 6_100_000);
774        assert_eq!(
775            tray.url,
776            "https://github.com/akitaonrails/ai-usagebar/releases/download/v1.11.0/ai-usagebar-tray-windows-x86_64.exe"
777        );
778    }
779
780    #[test]
781    fn parse_release_accepts_a_bare_tag_and_tolerates_missing_assets() {
782        let release =
783            parse_release(r#"{"tag_name": "1.11.0", "html_url": "https://evil.example/x"}"#)
784                .unwrap();
785
786        assert_eq!(release.version, "1.11.0");
787        assert!(release.assets.is_empty());
788        assert_eq!(release.html_url, "", "a non-GitHub link is blanked");
789    }
790
791    #[test]
792    fn parse_release_rejects_prerelease_draft_and_malformed_tags() {
793        let prerelease = r#"{"tag_name": "v1.11.0", "prerelease": true}"#;
794        assert_eq!(
795            parse_release(prerelease).unwrap_err(),
796            "prerelease/draft release"
797        );
798
799        let draft = r#"{"tag_name": "v1.11.0", "draft": true}"#;
800        assert_eq!(
801            parse_release(draft).unwrap_err(),
802            "prerelease/draft release"
803        );
804
805        for tag in ["v1.11", "v1.11.0-rc1", "nightly", "v1.11.0.1", ""] {
806            let json = format!(r#"{{"tag_name": "{tag}"}}"#);
807            assert!(parse_release(&json).is_err(), "{tag:?} parsed");
808        }
809        assert!(parse_release(r#"{"draft": false}"#).is_err(), "no tag_name");
810        assert!(parse_release("not json").is_err());
811    }
812
813    #[test]
814    fn is_newer_compares_numerically() {
815        assert!(is_newer("1.10.0", "1.11.0"));
816        assert!(!is_newer("1.11.0", "1.11.0"), "equal is not newer");
817        assert!(!is_newer("1.11.0", "1.10.0"), "older is not newer");
818        assert!(is_newer("1.9.10", "1.10.0"), "not a string compare");
819        assert!(is_newer("1.10.0", "2.0.0"));
820        assert!(is_newer("1.10.0", "1.10.1"));
821    }
822
823    #[test]
824    fn is_newer_is_false_for_anything_malformed() {
825        assert!(!is_newer("1.10.0", "v1.11.0"));
826        assert!(!is_newer("1.10.0", "1.11"));
827        assert!(!is_newer("1.10.0", "1.11.0-rc1"));
828        assert!(!is_newer("garbage", "1.11.0"));
829        assert!(!is_newer("", ""));
830    }
831
832    #[test]
833    fn asset_name_follows_the_release_workflow() {
834        assert_eq!(
835            asset_name("ai-usagebar-tray", "windows", "x86_64"),
836            "ai-usagebar-tray-windows-x86_64.exe"
837        );
838        assert_eq!(
839            asset_name("ai-usagebar-tray", "macos", "aarch64"),
840            "ai-usagebar-tray-macos-aarch64"
841        );
842        assert_eq!(
843            installed_name("ai-usagebar-tui", "windows"),
844            "ai-usagebar-tui.exe"
845        );
846        assert_eq!(
847            installed_name("ai-usagebar-tui", "macos"),
848            "ai-usagebar-tui"
849        );
850        assert!(["x86_64", "aarch64", "unknown"].contains(&current_arch()));
851        assert!(["windows", "macos", "unknown"].contains(&current_os()));
852    }
853
854    #[test]
855    fn select_downloads_picks_the_assets_for_the_requested_os() {
856        let mut assets = full_release().assets;
857        for binary in BINARIES {
858            let name = asset_name(binary, "macos", "aarch64");
859            assets.push(asset(&name, 4_000_000));
860            assets.push(asset(&format!("{name}.sha256"), 100));
861        }
862        let release = release_with(assets);
863        let mac = select_downloads(&release, "macos", "aarch64").unwrap();
864        assert_eq!(mac[0].exe.name, "ai-usagebar-tray-macos-aarch64");
865        let windows = select_downloads(&release, "windows", "x86_64").unwrap();
866        assert_eq!(windows[0].exe.name, "ai-usagebar-tray-windows-x86_64.exe");
867        // A Windows-only release offers nothing to a Mac.
868        assert!(select_downloads(&full_release(), "macos", "aarch64").is_err());
869    }
870
871    #[test]
872    fn self_update_stays_out_of_package_managers_and_source_trees() {
873        use std::path::Path;
874        let may = |p: &str| self_update_blocker(Path::new(p), false, |_| false);
875        assert_eq!(may("/Users/a/.local/bin/ai-usagebar-tray"), None);
876        assert_eq!(may("/Applications/AI Usage/ai-usagebar-tray"), None);
877        assert!(
878            self_update_blocker(
879                Path::new("/opt/homebrew/bin/ai-usagebar-tray"),
880                true,
881                |_| false
882            )
883            .is_some()
884        );
885        assert!(may("/opt/homebrew/Cellar/ai-usagebar/1.21.1/bin/ai-usagebar-tray").is_some());
886        assert!(may("/nix/store/0abc-ai-usagebar-1.21.1/bin/ai-usagebar-tray").is_some());
887        assert!(may("/Users/a/src/ai-usagebar/target/release/ai-usagebar-tray").is_some());
888        assert!(may("/Users/a/src/ai-usagebar/target/debug/ai-usagebar-tray").is_some());
889        assert!(
890            may("/Users/a/src/ai-usagebar/target/aarch64-apple-darwin/release/ai-usagebar-tray")
891                .is_some()
892        );
893        // A folder merely named "release" is not a cargo profile directory.
894        assert_eq!(may("/Users/a/release/ai-usagebar-tray"), None);
895        assert_eq!(
896            may("/Users/a/target-practice/release/ai-usagebar-tray"),
897            None
898        );
899    }
900
901    #[test]
902    fn a_scoop_install_is_left_to_scoop() {
903        use std::path::Path;
904        // Scoop writes install.json beside each version it installs; the probe stands in for it.
905        let scoop = |p: &Path| p.ends_with("install.json");
906        let user = "C:/Users/a/scoop/apps/ai-usagebar/current/ai-usagebar-tray.exe";
907        let version = "C:/Users/a/scoop/apps/ai-usagebar/1.24.0/ai-usagebar-tray.exe";
908        let global = "C:/ProgramData/scoop/apps/ai-usagebar/current/ai-usagebar-tray.exe";
909        let fork = "D:/tools/scoop/apps/ai-usagebar-dev/current/ai-usagebar-tray.exe";
910        for exe in [user, version, global, fork] {
911            assert!(scoop_managed(Path::new(exe), scoop), "{exe}");
912            assert_eq!(
913                self_update_blocker(Path::new(exe), false, scoop),
914                Some("managed by Scoop; update it with scoop update"),
915                "{exe}"
916            );
917        }
918        // The folder name is compared the way Windows compares it.
919        assert!(scoop_managed(
920            Path::new("C:/Users/a/scoop/Apps/ai-usagebar/current/ai-usagebar-tray.exe"),
921            scoop
922        ));
923    }
924
925    #[test]
926    fn a_folder_that_only_looks_like_scoop_still_updates_itself() {
927        use std::path::Path;
928        let exe = Path::new("D:/apps/ai-usagebar/1.24.0/ai-usagebar-tray.exe");
929        // No install.json beside the exe: an unzipped release under a folder named "apps".
930        assert!(!scoop_managed(exe, |_| false));
931        assert_eq!(self_update_blocker(exe, false, |_| false), None);
932        // install.json alone is not enough without Scoop's apps\<app>\<version> layout.
933        let has_manifest = |p: &Path| p.ends_with("install.json");
934        for exe in [
935            "C:/Users/a/AI Usage/ai-usagebar-tray.exe",
936            "C:/Users/a/scoop/ai-usagebar/current/ai-usagebar-tray.exe",
937            "ai-usagebar-tray.exe",
938        ] {
939            assert!(!scoop_managed(Path::new(exe), has_manifest), "{exe}");
940        }
941    }
942
943    #[test]
944    fn scoop_is_detected_from_the_install_json_on_disk() {
945        let tmp = tempfile::tempdir().unwrap();
946        let version_dir = tmp
947            .path()
948            .join("scoop")
949            .join("apps")
950            .join("ai-usagebar")
951            .join("1.24.0");
952        std::fs::create_dir_all(&version_dir).unwrap();
953        let exe = version_dir.join("ai-usagebar-tray.exe");
954        let on_disk = |p: &std::path::Path| p.is_file();
955        assert!(!scoop_managed(&exe, on_disk));
956        std::fs::write(version_dir.join("install.json"), b"{}").unwrap();
957        assert!(scoop_managed(&exe, on_disk));
958    }
959
960    #[test]
961    fn loopback_http_is_a_download_source_only_for_a_local_feed_build() {
962        assert!(download_url_allowed_with("https://github.com/x", false));
963        assert!(!download_url_allowed_with("http://127.0.0.1:8765/a", false));
964        assert!(download_url_allowed_with("http://127.0.0.1:8765/a", true));
965        assert!(download_url_allowed_with("http://localhost:8765/a", true));
966        assert!(!download_url_allowed_with("http://example.com/a", true));
967        assert!(!download_url_allowed_with(
968            "http://127.0.0.1.evil.com/a",
969            true
970        ));
971        assert!(!download_url_allowed_with(
972            "http://127.0.0.1:1@evil.com/a",
973            true
974        ));
975        assert!(!download_url_allowed_with(
976            "http://user:pw@127.0.0.1:8765/a",
977            true
978        ));
979    }
980
981    #[test]
982    fn select_downloads_pairs_all_three_binaries_tray_first() {
983        let downloads = select_downloads(&full_release(), "windows", "x86_64").unwrap();
984
985        let binaries: Vec<&str> = downloads.iter().map(|d| d.binary).collect();
986        assert_eq!(binaries, BINARIES.to_vec());
987        for download in &downloads {
988            assert_eq!(
989                download.exe.name,
990                asset_name(download.binary, "windows", "x86_64")
991            );
992            assert_eq!(
993                download.sha256.name,
994                format!("{}.sha256", download.exe.name)
995            );
996        }
997    }
998
999    #[test]
1000    fn select_downloads_requires_the_tray_pair() {
1001        let mut assets: Vec<Asset> = pair("ai-usagebar", 5_000_000).to_vec();
1002        assets.extend(pair("ai-usagebar-tui", 5_000_000));
1003        let error = select_downloads(&release_with(assets), "windows", "x86_64").unwrap_err();
1004        assert!(
1005            error.contains("ai-usagebar-tray-windows-x86_64.exe"),
1006            "{error}"
1007        );
1008
1009        // Exe without its sidecar is just as missing.
1010        let [tray_exe, _] = pair("ai-usagebar-tray", 5_000_000);
1011        assert!(select_downloads(&release_with(vec![tray_exe]), "windows", "x86_64").is_err());
1012
1013        // Wrong arch: nothing matches.
1014        assert!(select_downloads(&full_release(), "windows", "aarch64").is_err());
1015    }
1016
1017    #[test]
1018    fn select_downloads_skips_an_incomplete_optional_pair() {
1019        let mut assets: Vec<Asset> = pair("ai-usagebar-tray", 5_000_000).to_vec();
1020        assets.extend(pair("ai-usagebar-tui", 5_000_000));
1021        let [cli_exe, _] = pair("ai-usagebar", 5_000_000);
1022        assets.push(cli_exe); // sidecar missing → skipped
1023
1024        let downloads = select_downloads(&release_with(assets), "windows", "x86_64").unwrap();
1025
1026        let binaries: Vec<&str> = downloads.iter().map(|d| d.binary).collect();
1027        assert_eq!(binaries, vec!["ai-usagebar-tray", "ai-usagebar-tui"]);
1028    }
1029
1030    #[test]
1031    fn select_downloads_rejects_empty_and_oversized_exes() {
1032        let mut assets: Vec<Asset> = pair("ai-usagebar-tray", MAX_ASSET_BYTES + 1).to_vec();
1033        let error =
1034            select_downloads(&release_with(assets.clone()), "windows", "x86_64").unwrap_err();
1035        assert!(error.contains("over the"), "{error}");
1036
1037        assets = pair("ai-usagebar-tray", 0).to_vec();
1038        let error = select_downloads(&release_with(assets), "windows", "x86_64").unwrap_err();
1039        assert!(error.contains("empty"), "{error}");
1040
1041        // An optional exe with a bad size is a broken release, not a skip.
1042        let mut assets: Vec<Asset> = pair("ai-usagebar-tray", 5_000_000).to_vec();
1043        assets.extend(pair("ai-usagebar-tui", MAX_ASSET_BYTES + 1));
1044        assert!(select_downloads(&release_with(assets), "windows", "x86_64").is_err());
1045
1046        // Exactly the limit is still allowed.
1047        let assets: Vec<Asset> = pair("ai-usagebar-tray", MAX_ASSET_BYTES).to_vec();
1048        assert!(select_downloads(&release_with(assets), "windows", "x86_64").is_ok());
1049    }
1050
1051    #[test]
1052    fn parse_sha256_sidecar_accepts_both_formats_and_folds_case() {
1053        let hex = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1054
1055        assert_eq!(
1056            parse_sha256_sidecar(&format!("{hex}  ai-usagebar-tray-windows-x86_64.exe\n")).unwrap(),
1057            hex
1058        );
1059        assert_eq!(parse_sha256_sidecar(hex).unwrap(), hex);
1060        assert_eq!(parse_sha256_sidecar(&format!("  {hex}\r\n")).unwrap(), hex);
1061        assert_eq!(
1062            parse_sha256_sidecar(&hex.to_ascii_uppercase()).unwrap(),
1063            hex,
1064            "uppercase is folded"
1065        );
1066    }
1067
1068    #[test]
1069    fn parse_sha256_sidecar_rejects_garbage() {
1070        assert!(parse_sha256_sidecar("").is_err());
1071        assert!(parse_sha256_sidecar("   \n").is_err());
1072        assert!(
1073            parse_sha256_sidecar("deadbeef  name.exe").is_err(),
1074            "too short"
1075        );
1076        assert!(
1077            parse_sha256_sidecar(&"zz".repeat(32)).is_err(),
1078            "right length, not hex"
1079        );
1080        assert!(
1081            parse_sha256_sidecar(
1082                "name.exe  e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
1083            )
1084            .is_err(),
1085            "name first is not the format"
1086        );
1087    }
1088
1089    #[test]
1090    fn verify_sha256_streams_the_file_and_detects_mismatch() {
1091        let dir = TempDir::new().unwrap();
1092        let path = dir.path().join("blob.bin");
1093        let contents: Vec<u8> = (0..200_000u32).map(|i| (i % 251) as u8).collect();
1094        std::fs::write(&path, &contents).unwrap();
1095        let expected = hex_lower(&Sha256::digest(&contents));
1096
1097        assert_eq!(verify_sha256(&path, &expected), Ok(()));
1098        assert_eq!(
1099            verify_sha256(&path, &expected.to_ascii_uppercase()),
1100            Ok(()),
1101            "case-insensitive"
1102        );
1103
1104        let wrong = format!("{}{}", &expected[1..], "0");
1105        let error = verify_sha256(&path, &wrong).unwrap_err();
1106        assert!(error.contains("mismatch"), "{error}");
1107
1108        assert!(verify_sha256(&dir.path().join("absent"), &expected).is_err());
1109    }
1110
1111    #[test]
1112    fn stage_swap_replaces_the_exe_and_keeps_the_old_one() {
1113        let dir = TempDir::new().unwrap();
1114        let install = dir.path().join("install");
1115        let staging = dir.path().join("staging");
1116        std::fs::create_dir_all(&install).unwrap();
1117        std::fs::create_dir_all(&staging).unwrap();
1118        let name = "ai-usagebar-tray.exe";
1119        std::fs::write(install.join(name), b"v1").unwrap();
1120        let staged = staging.join(name);
1121        std::fs::write(&staged, b"v2").unwrap();
1122
1123        let written = stage_swap(&install, &[(name.to_string(), staged.clone())]).unwrap();
1124
1125        assert_eq!(written, vec![install.join(name)]);
1126        assert_eq!(std::fs::read(install.join(name)).unwrap(), b"v2");
1127        assert_eq!(
1128            std::fs::read(install.join("ai-usagebar-tray.exe.old")).unwrap(),
1129            b"v1"
1130        );
1131        assert!(!staged.exists(), "the staged file was moved, not copied");
1132
1133        // A second update replaces the stale `.old` rather than failing on it.
1134        std::fs::write(&staged, b"v3").unwrap();
1135        stage_swap(&install, &[(name.to_string(), staged.clone())]).unwrap();
1136        assert_eq!(std::fs::read(install.join(name)).unwrap(), b"v3");
1137        assert_eq!(
1138            std::fs::read(install.join("ai-usagebar-tray.exe.old")).unwrap(),
1139            b"v2"
1140        );
1141    }
1142
1143    #[test]
1144    fn stage_swap_installs_a_binary_that_was_not_there_before() {
1145        let dir = TempDir::new().unwrap();
1146        let install = dir.path().join("install");
1147        std::fs::create_dir_all(&install).unwrap();
1148        let staged = dir.path().join("ai-usagebar-tui.exe");
1149        std::fs::write(&staged, b"new").unwrap();
1150
1151        stage_swap(&install, &[("ai-usagebar-tui.exe".to_string(), staged)]).unwrap();
1152
1153        assert_eq!(
1154            std::fs::read(install.join("ai-usagebar-tui.exe")).unwrap(),
1155            b"new"
1156        );
1157        assert!(!install.join("ai-usagebar-tui.exe.old").exists());
1158    }
1159
1160    #[test]
1161    fn stage_swap_with_a_missing_staged_file_touches_nothing() {
1162        let dir = TempDir::new().unwrap();
1163        let install = dir.path().join("install");
1164        std::fs::create_dir_all(&install).unwrap();
1165        std::fs::write(install.join("ai-usagebar-tray.exe"), b"v1").unwrap();
1166        std::fs::write(install.join("ai-usagebar.exe"), b"v1").unwrap();
1167        let tray_staged = dir.path().join("ai-usagebar-tray.exe");
1168        std::fs::write(&tray_staged, b"v2").unwrap();
1169
1170        let error = stage_swap(
1171            &install,
1172            &[
1173                ("ai-usagebar-tray.exe".to_string(), tray_staged.clone()),
1174                (
1175                    "ai-usagebar.exe".to_string(),
1176                    dir.path().join("never-downloaded.exe"),
1177                ),
1178            ],
1179        )
1180        .unwrap_err();
1181
1182        assert!(error.contains("does not exist"), "{error}");
1183        assert_eq!(
1184            std::fs::read(install.join("ai-usagebar-tray.exe")).unwrap(),
1185            b"v1"
1186        );
1187        assert_eq!(
1188            std::fs::read(install.join("ai-usagebar.exe")).unwrap(),
1189            b"v1"
1190        );
1191        assert!(!install.join("ai-usagebar-tray.exe.old").exists());
1192        assert!(
1193            tray_staged.exists(),
1194            "the good download is kept for a retry"
1195        );
1196    }
1197
1198    #[test]
1199    fn stage_swap_rolls_back_entries_already_swapped_when_a_later_one_fails() {
1200        let dir = TempDir::new().unwrap();
1201        let install = dir.path().join("install");
1202        std::fs::create_dir_all(&install).unwrap();
1203        std::fs::write(install.join("ai-usagebar-tray.exe"), b"v1").unwrap();
1204        std::fs::write(install.join("ai-usagebar.exe"), b"v1").unwrap();
1205        // A stale `.old` that is a non-empty directory cannot be removed, so
1206        // the second entry fails after the first has already been swapped.
1207        let blocker = install.join("ai-usagebar.exe.old");
1208        std::fs::create_dir_all(&blocker).unwrap();
1209        std::fs::write(blocker.join("keep"), b"x").unwrap();
1210        let tray_staged = dir.path().join("ai-usagebar-tray.exe");
1211        let cli_staged = dir.path().join("ai-usagebar.exe");
1212        std::fs::write(&tray_staged, b"v2").unwrap();
1213        std::fs::write(&cli_staged, b"v2").unwrap();
1214
1215        let result = stage_swap(
1216            &install,
1217            &[
1218                ("ai-usagebar-tray.exe".to_string(), tray_staged.clone()),
1219                ("ai-usagebar.exe".to_string(), cli_staged.clone()),
1220            ],
1221        );
1222
1223        assert!(result.is_err());
1224        assert_eq!(
1225            std::fs::read(install.join("ai-usagebar-tray.exe")).unwrap(),
1226            b"v1",
1227            "the tray swap was undone"
1228        );
1229        assert!(!install.join("ai-usagebar-tray.exe.old").exists());
1230        assert_eq!(
1231            std::fs::read(&tray_staged).unwrap(),
1232            b"v2",
1233            "staged file restored"
1234        );
1235        assert_eq!(
1236            std::fs::read(install.join("ai-usagebar.exe")).unwrap(),
1237            b"v1"
1238        );
1239        assert_eq!(std::fs::read(&cli_staged).unwrap(), b"v2");
1240    }
1241
1242    #[test]
1243    fn stage_swap_refuses_names_that_leave_the_install_dir() {
1244        let dir = TempDir::new().unwrap();
1245        let staged = dir.path().join("x.exe");
1246        std::fs::write(&staged, b"x").unwrap();
1247
1248        for name in ["../x.exe", "sub/x.exe", "sub\\x.exe", "", ".."] {
1249            let error = stage_swap(dir.path(), &[(name.to_string(), staged.clone())]).unwrap_err();
1250            assert!(error.contains("refusing"), "{name:?}: {error}");
1251        }
1252    }
1253
1254    #[test]
1255    fn sweep_old_removes_only_the_binaries_old_files() {
1256        let dir = TempDir::new().unwrap();
1257        for name in [
1258            "ai-usagebar-tray.exe.old",
1259            "ai-usagebar.exe.old",
1260            "ai-usagebar-tray.exe",
1261            "notes.old",
1262            "config.toml",
1263        ] {
1264            std::fs::write(dir.path().join(name), b"x").unwrap();
1265        }
1266        std::fs::create_dir(dir.path().join("dir.exe.old")).unwrap();
1267
1268        assert_eq!(sweep_old(dir.path(), "windows"), 2);
1269
1270        let mut remaining: Vec<String> = std::fs::read_dir(dir.path())
1271            .unwrap()
1272            .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned())
1273            .collect();
1274        remaining.sort();
1275        assert_eq!(
1276            remaining,
1277            vec![
1278                "ai-usagebar-tray.exe",
1279                "config.toml",
1280                "dir.exe.old",
1281                "notes.old"
1282            ]
1283        );
1284
1285        assert_eq!(sweep_old(dir.path(), "windows"), 0, "nothing left to sweep");
1286        assert_eq!(sweep_old(&dir.path().join("absent"), "windows"), 0);
1287    }
1288
1289    #[test]
1290    fn sweep_old_on_macos_removes_the_suffixless_leftovers() {
1291        let dir = TempDir::new().unwrap();
1292        for name in ["ai-usagebar-tray.old", "ai-usagebar-tray", "notes.old"] {
1293            std::fs::write(dir.path().join(name), b"x").unwrap();
1294        }
1295        assert_eq!(sweep_old(dir.path(), "macos"), 1);
1296        assert!(dir.path().join("ai-usagebar-tray").is_file());
1297        assert!(dir.path().join("notes.old").is_file());
1298    }
1299
1300    #[test]
1301    fn update_state_round_trips_and_a_corrupt_file_is_the_default() {
1302        let dir = TempDir::new().unwrap();
1303        let path = dir.path().join("nested").join("update.json");
1304        let state = UpdateState {
1305            handed_off: None,
1306            last_check_ms: 1_757_246_400_000,
1307            snoozed_version: Some("1.11.0".to_string()),
1308        };
1309
1310        state.save_at(&path).unwrap();
1311
1312        assert_eq!(UpdateState::load_at(&path), state);
1313        let text = std::fs::read_to_string(&path).unwrap();
1314        assert!(text.contains("\"snoozed_version\": \"1.11.0\""), "{text}");
1315
1316        assert_eq!(
1317            UpdateState::load_at(&dir.path().join("absent.json")),
1318            UpdateState::default()
1319        );
1320        let corrupt = dir.path().join("corrupt.json");
1321        std::fs::write(&corrupt, "{\"last_check_ms\": ").unwrap();
1322        assert_eq!(UpdateState::load_at(&corrupt), UpdateState::default());
1323
1324        // Missing fields default rather than failing the whole load.
1325        let partial = dir.path().join("partial.json");
1326        std::fs::write(&partial, "{\"last_check_ms\": 5}").unwrap();
1327        assert_eq!(
1328            UpdateState::load_at(&partial),
1329            UpdateState {
1330                handed_off: None,
1331                last_check_ms: 5,
1332                snoozed_version: None,
1333            }
1334        );
1335    }
1336
1337    #[test]
1338    fn staging_dir_is_per_version_under_the_cache_root() {
1339        let root = Path::new("cache");
1340        assert_eq!(
1341            staging_dir(root, "1.11.0"),
1342            Path::new("cache").join("updates").join("1.11.0")
1343        );
1344    }
1345}