Skip to main content

waterui_cli/
self_update.rs

1//! Self-update for the `water` binary itself.
2//!
3//! `water` reaches machines through four channels — the dist-generated shell
4//! and PowerShell installers, the Homebrew tap, `cargo install` /
5//! `cargo binstall`, and everything else — and only the first owns an update
6//! path this binary may take itself: a dist install receipt says the
7//! release's own installer put the binary where it is, so re-running the
8//! newest release's installer (which `axoupdater` drives) is safe. Every
9//! other channel belongs to a package manager whose files must not be
10//! rewritten underneath it, so [`InstallSource`] resolves which channel owns
11//! the running executable before anything is downloaded, and [`update`] /
12//! [`check`] act on the answer.
13//!
14//! The passive check is a separate surface: [`passive_update_notice`] runs at
15//! most once per [`PASSIVE_CHECK_INTERVAL`], records the attempt in the CLI's
16//! own state directory (`~/.water/config.toml`), and is silent on failure.
17
18use std::{
19    ffi::OsStr,
20    path::{Path, PathBuf},
21    time::{Duration, SystemTime, UNIX_EPOCH},
22};
23
24use axoupdater::{AxoUpdater, ReleaseSource, ReleaseSourceType};
25use eyre::{Result, WrapErr, bail};
26use semver::Version;
27use serde::Deserialize;
28
29use crate::toolchain::Host;
30use crate::water_dir;
31
32/// The name install receipts and release installers carry: the cargo-dist
33/// "app" is the package, so receipts live under `waterui-cli` and the
34/// installer assets are `waterui-cli-installer.{sh,ps1}`, not `water`.
35const APP_NAME: &str = env!("CARGO_PKG_NAME");
36
37/// The repository the GitHub release source queries when no install receipt
38/// supplies one (`--check` and the passive check on non-receipt installs).
39const RELEASE_OWNER: &str = "water-rs";
40const RELEASE_REPO: &str = "cli";
41
42/// The refusal `water update` gives for a layout no channel claims — an
43/// unrecognized layout is an error with a clear message, not a guess.
44const UNKNOWN_INSTALL_MESSAGE: &str = "cannot determine how this `water` \
45     binary was installed: no dist install receipt matches it, it resolves \
46     under no Homebrew prefix, and it sits outside CARGO_HOME/bin; refusing \
47     to update it";
48
49/// The smallest gap between passive version checks.
50const PASSIVE_CHECK_INTERVAL: Duration = Duration::from_hours(24);
51
52/// The install channel the running `water` binary came through — the four
53/// rows the update path distinguishes before touching anything.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum InstallSource {
56    /// A cargo-dist install receipt covers this executable; the release's own
57    /// installer may rewrite it in place (`water update`).
58    Dist,
59    /// The executable resolves under the Homebrew prefix; `brew` owns it.
60    Homebrew,
61    /// The executable sits in `CARGO_HOME/bin` with no receipt; `cargo`
62    /// (`install` / `binstall`) owns it.
63    Cargo,
64    /// No channel's evidence matched; nothing may touch the binary.
65    Unknown,
66}
67
68impl InstallSource {
69    /// Classify the running executable on `host` — the real-machine entry
70    /// point.
71    ///
72    /// # Errors
73    /// Returns an error when the executable's path cannot be determined or a
74    /// receipt exists but cannot be read — a corrupt receipt is not evidence
75    /// of a different channel.
76    pub fn detect(host: &Host) -> Result<Self> {
77        let executable =
78            Host::current_exe().wrap_err("the running executable's path cannot be determined")?;
79        Self::detect_exe(host, &executable)
80    }
81
82    /// Classify `executable` against the evidence `host` declares: a matching
83    /// install receipt first, then the Homebrew prefix, then
84    /// `CARGO_HOME/bin`. Anything left over is [`InstallSource::Unknown`].
85    ///
86    /// # Errors
87    /// Returns an error when a receipt exists but cannot be read.
88    fn detect_exe(host: &Host, executable: &Path) -> Result<Self> {
89        let executable = canonicalize_or_self(executable);
90        if let Some(prefix) = receipt_install_prefix(host)?
91            && same_install_root(&executable, &canonicalize_or_self(&prefix))
92        {
93            return Ok(Self::Dist);
94        }
95        for prefix in homebrew_prefixes(host) {
96            if executable.starts_with(canonicalize_or_self(&prefix)) {
97                return Ok(Self::Homebrew);
98            }
99        }
100        if let Some(cargo_bin) = cargo_bin_dir(host)
101            && executable.parent() == Some(canonicalize_or_self(&cargo_bin).as_path())
102        {
103            return Ok(Self::Cargo);
104        }
105        Ok(Self::Unknown)
106    }
107
108    /// The command that updates an install from this channel — `water
109    /// update` for receipt installs, the owning package manager's command
110    /// otherwise. [`InstallSource::Unknown`] has no channel to name.
111    #[must_use]
112    pub const fn update_command(self) -> Option<&'static str> {
113        match self {
114            Self::Dist => Some("water update"),
115            Self::Homebrew => Some("brew upgrade water"),
116            Self::Cargo => Some("cargo binstall waterui-cli"),
117            Self::Unknown => None,
118        }
119    }
120}
121
122/// What a completed `water update` leaves behind.
123#[derive(Debug)]
124pub enum UpdateOutcome {
125    /// The release installer moved the binary between versions.
126    Updated {
127        /// The version the install receipt recorded before the update.
128        previous: Option<Version>,
129        /// The version now installed.
130        installed: Version,
131    },
132    /// The release source lists nothing newer.
133    UpToDate {
134        /// The running version.
135        current: Version,
136    },
137    /// A package manager owns the binary; `command` is its update path and
138    /// nothing was changed.
139    ExternallyManaged {
140        /// The owning package manager's update command.
141        command: &'static str,
142    },
143}
144
145/// What `water update --check` reports.
146#[derive(Debug)]
147pub enum CheckOutcome {
148    /// The running version is the newest the release source lists.
149    UpToDate {
150        /// The running version.
151        current: Version,
152    },
153    /// The release source lists a newer version.
154    Available {
155        /// The running version.
156        current: Version,
157        /// The newest version the release source lists.
158        latest: Version,
159        /// The command that installs it for this install channel.
160        command: &'static str,
161    },
162}
163
164/// `water update`: self-update in place when a dist receipt owns the binary;
165/// name the owning package manager's command for every other channel and
166/// change nothing.
167///
168/// # Errors
169/// Returns an error when the install source cannot be determined (no receipt,
170/// no Homebrew prefix, no `CARGO_HOME/bin`) or when the updater fails — a
171/// missing receipt, a failed release query, or an installer that exits badly.
172pub async fn update(host: &Host) -> Result<UpdateOutcome> {
173    match InstallSource::detect(host)? {
174        InstallSource::Dist => run_dist_update(host).await,
175        source => {
176            let Some(command) = source.update_command() else {
177                bail!("{UNKNOWN_INSTALL_MESSAGE}");
178            };
179            Ok(UpdateOutcome::ExternallyManaged { command })
180        }
181    }
182}
183
184/// `water update --check`: report the newest release without installing it.
185///
186/// The release source comes from the install receipt when one covers this
187/// binary and from this repository's GitHub releases otherwise.
188///
189/// # Errors
190/// Returns an error when the install source cannot be determined or the
191/// release query fails.
192pub async fn check(host: &Host) -> Result<CheckOutcome> {
193    let source = InstallSource::detect(host)?;
194    let Some(command) = source.update_command() else {
195        bail!("{UNKNOWN_INSTALL_MESSAGE}");
196    };
197    let current = current_version();
198    let latest = query_latest(host, source).await?;
199    if current < latest {
200        Ok(CheckOutcome::Available {
201            current,
202            latest,
203            command,
204        })
205    } else {
206        Ok(CheckOutcome::UpToDate { current })
207    }
208}
209
210/// The update command the `minimum-cli-version` rejection names.
211///
212/// `water update` when a dist receipt owns this binary, `brew upgrade water`
213/// under the Homebrew prefix, and `fallback` — the channel-appropriate cargo
214/// invocation the caller already selected — everywhere else.
215#[must_use]
216pub fn cli_update_command(fallback: &str) -> String {
217    match InstallSource::detect(&Host::current()) {
218        Ok(InstallSource::Dist) => "water update".to_owned(),
219        Ok(InstallSource::Homebrew) => "brew upgrade water".to_owned(),
220        Ok(InstallSource::Cargo | InstallSource::Unknown) | Err(_) => fallback.to_owned(),
221    }
222}
223
224/// The passive version check behind every non-hot-path command.
225///
226/// At most one release query per [`PASSIVE_CHECK_INTERVAL`], recorded in the
227/// CLI's state directory, silent on any failure. Returns the notice to print
228/// when a newer release exists, `None` otherwise.
229#[must_use]
230pub async fn passive_update_notice() -> Option<String> {
231    let host = Host::current();
232    let water_home = water_dir::water_home_dir_in(&host).ok()?;
233    let mut config = water_dir::ensure_global_config_in(&water_home).await.ok()?;
234    if !passive_check_due(config.last_update_check_unix_seconds, unix_now()) {
235        return None;
236    }
237    let notice = passive_notice_inner(&host).await;
238    config.last_update_check_unix_seconds = Some(unix_now());
239    if let Err(error) = water_dir::write_global_config_in(&water_home, &config).await {
240        tracing::debug!("update check: failed to record the check timestamp: {error}");
241    }
242    notice
243}
244
245/// The query half of the passive check; failures degrade to `None` because
246/// the notice must stay silent when the network does.
247async fn passive_notice_inner(host: &Host) -> Option<String> {
248    let source = match InstallSource::detect(host) {
249        Ok(source) => source,
250        Err(error) => {
251            tracing::debug!("update check: install source detection failed: {error}");
252            return None;
253        }
254    };
255    if source == InstallSource::Unknown {
256        return None;
257    }
258    let latest = match query_latest(host, source).await {
259        Ok(latest) => latest,
260        Err(error) => {
261            tracing::debug!("update check: release query failed: {error}");
262            return None;
263        }
264    };
265    let current = current_version();
266    if latest > current {
267        Some(format!(
268            "water {latest} is available (installed: {current}); update with `{}`",
269            source.update_command()?,
270        ))
271    } else {
272        None
273    }
274}
275
276/// Whether the passive check may query again — at most once per interval,
277/// and always when no check has been recorded or the recorded timestamp is
278/// in the future (a clock that moved backward makes it untrustworthy).
279fn passive_check_due(last_unix_seconds: Option<u64>, now_unix_seconds: u64) -> bool {
280    last_unix_seconds.is_none_or(|last| {
281        last > now_unix_seconds || now_unix_seconds - last >= PASSIVE_CHECK_INTERVAL.as_secs()
282    })
283}
284
285/// Re-run the newest release's installer over the receipt-installed binary.
286async fn run_dist_update(host: &Host) -> Result<UpdateOutcome> {
287    let mut updater = configured_updater(host);
288    let result = unblock_axoupdater(move || async move {
289        updater.load_receipt()?;
290        updater.run().await
291    })
292    .await
293    .map_err(eyre::Report::new)?;
294    match result {
295        Some(result) => Ok(UpdateOutcome::Updated {
296            previous: result.old_version,
297            installed: result.new_version,
298        }),
299        None => Ok(UpdateOutcome::UpToDate {
300            current: current_version(),
301        }),
302    }
303}
304
305/// The newest version the release source for `source` lists.
306async fn query_latest(host: &Host, source: InstallSource) -> Result<Version> {
307    let mut updater = configured_updater(host);
308    let latest = unblock_axoupdater(move || async move {
309        match source {
310            InstallSource::Dist => {
311                updater.load_receipt()?;
312            }
313            _ => {
314                updater.set_release_source(github_release_source());
315            }
316        }
317        updater
318            .query_new_version()
319            .await
320            .map(Option::<&Version>::cloned)
321    })
322    .await
323    .map_err(eyre::Report::new)?;
324    latest.ok_or_else(|| eyre::eyre!("the release source lists no releases"))
325}
326
327/// An [`AxoUpdater`] for this app, carrying a GitHub token when the host
328/// declares one — axoupdater's own recommendation for CI rate limits.
329fn configured_updater(host: &Host) -> AxoUpdater {
330    let mut updater = AxoUpdater::new_for(APP_NAME);
331    if let Some(token) = host.env_string("WATERUI_GITHUB_TOKEN") {
332        updater.set_github_token(&token);
333    }
334    updater
335}
336
337/// The release source a receipt would name, constructed explicitly for
338/// installs no receipt covers.
339fn github_release_source() -> ReleaseSource {
340    ReleaseSource {
341        release_type: ReleaseSourceType::GitHub,
342        owner: RELEASE_OWNER.to_owned(),
343        name: RELEASE_REPO.to_owned(),
344        app_name: APP_NAME.to_owned(),
345    }
346}
347
348/// Run an axoupdater call to completion on the blocking thread
349/// [`smol::unblock`] provides.
350///
351/// axoupdater's futures are reqwest-based and need a tokio reactor the
352/// smol-based CLI does not run, so each call builds a scratch current-thread
353/// runtime here; the `unblock` hop keeps the calling executor free to observe
354/// cancellation while the updater works.
355async fn unblock_axoupdater<Fut, T>(f: impl FnOnce() -> Fut + Send + 'static) -> T
356where
357    Fut: std::future::Future<Output = T>,
358    T: Send + 'static,
359{
360    smol::unblock(move || {
361        tokio::runtime::Builder::new_current_thread()
362            .enable_all()
363            .build()
364            .expect("tokio current-thread runtime for axoupdater")
365            .block_on(f())
366    })
367    .await
368}
369
370/// The version this binary was built as.
371fn current_version() -> Version {
372    env!("CARGO_PKG_VERSION")
373        .parse()
374        .expect("package version is semver")
375}
376
377/// The `install_prefix` the first found install receipt records, or `None`
378/// when no receipt exists. A receipt that exists but cannot be read is an
379/// error — a corrupt receipt is not evidence of a different channel.
380fn receipt_install_prefix(host: &Host) -> Result<Option<PathBuf>> {
381    for dir in receipt_dirs(host) {
382        let path = dir.join(format!("{APP_NAME}-receipt.json"));
383        if !path.is_file() {
384            continue;
385        }
386        let contents = std::fs::read_to_string(&path).wrap_err_with(|| {
387            format!("the install receipt at {} cannot be read", path.display())
388        })?;
389        let receipt: ReceiptPrefix = serde_json::from_str(&contents)
390            .wrap_err_with(|| format!("the install receipt at {} is invalid", path.display()))?;
391        return Ok(Some(PathBuf::from(receipt.install_prefix)));
392    }
393    Ok(None)
394}
395
396/// The `install_prefix` a dist receipt records — the only field detection
397/// needs; axoupdater re-parses the whole receipt when it runs the update.
398#[derive(Deserialize)]
399struct ReceiptPrefix {
400    install_prefix: String,
401}
402
403/// The directories that may hold `<app>-receipt.json`, in axoupdater's own
404/// search order: the `AXOUPDATER_*` overrides first, then
405/// `$XDG_CONFIG_HOME` (existing dirs only) ahead of the platform default —
406/// `~/.config` on Unix, `%LOCALAPPDATA%` on Windows.
407fn receipt_dirs(host: &Host) -> Vec<PathBuf> {
408    if host.env("AXOUPDATER_CONFIG_WORKING_DIR").is_some() {
409        return vec![host.cwd().to_owned()];
410    }
411    if let Some(path) = host.env_string("AXOUPDATER_CONFIG_PATH") {
412        return vec![PathBuf::from(path)];
413    }
414    let mut dirs = Vec::new();
415    if cfg!(windows) {
416        if let Some(local) = host.env_string("LOCALAPPDATA") {
417            dirs.push(Path::new(&local).join(APP_NAME));
418        }
419    } else {
420        if let Some(xdg) = host.env_string("XDG_CONFIG_HOME") {
421            let dir = Path::new(&xdg).join(APP_NAME);
422            if dir.is_dir() {
423                dirs.push(dir);
424            }
425        }
426        if let Some(home) = host.home_dir() {
427            dirs.push(home.join(".config").join(APP_NAME));
428        }
429    }
430    dirs
431}
432
433/// The Homebrew prefixes that could own a binary: `$HOMEBREW_PREFIX` (set by
434/// `brew shellenv`, so custom-prefix installs are covered) plus the prefix a
435/// `brew` on this host's `PATH` resolves to — `brew` always lives at
436/// `<prefix>/bin/brew`, so its parent's parent is the prefix and no
437/// well-known locations need guessing. Windows has no Homebrew.
438fn homebrew_prefixes(host: &Host) -> Vec<PathBuf> {
439    let mut prefixes = Vec::new();
440    if let Some(prefix) = host.env_string("HOMEBREW_PREFIX") {
441        prefixes.push(PathBuf::from(prefix));
442    }
443    let paths = host.path_entries();
444    if !paths.is_empty()
445        && let Ok(path) = std::env::join_paths(&paths)
446        && let Ok(brew) = which::which_in("brew", Some(path), host.cwd())
447        && let Some(prefix) = canonicalize_or_self(&brew).parent().and_then(Path::parent)
448    {
449        prefixes.push(prefix.to_path_buf());
450    }
451    prefixes
452}
453
454/// The directory `cargo install` and `cargo binstall` write binaries to:
455/// `$CARGO_HOME/bin`, or `~/.cargo/bin` when `CARGO_HOME` is unset.
456fn cargo_bin_dir(host: &Host) -> Option<PathBuf> {
457    if let Some(cargo_home) = host.env_string("CARGO_HOME") {
458        return Some(PathBuf::from(cargo_home).join("bin"));
459    }
460    host.home_dir().map(|home| home.join(".cargo").join("bin"))
461}
462
463/// Whether `executable` lives under the receipt's `install_prefix`, matching
464/// axoupdater's own normalization: strip the executable's `bin` parent only
465/// when the prefix is not itself a `bin` directory, so both the `cargo-home`
466/// layout (prefix `~/.cargo`, binary in `bin/`) and the `flat` layout
467/// (prefix is the `bin` dir itself) match.
468fn same_install_root(executable: &Path, install_prefix: &Path) -> bool {
469    let exe_dir = executable.parent().unwrap_or(executable);
470    let exe_root = if exe_dir.file_name() == Some(OsStr::new("bin"))
471        && install_prefix.file_name() != Some(OsStr::new("bin"))
472    {
473        exe_dir.parent().unwrap_or(exe_dir)
474    } else {
475        exe_dir
476    };
477    exe_root == install_prefix
478}
479
480/// Canonicalize when the path exists, keep it verbatim otherwise — the
481/// normalization axoupdater applies to receipt paths.
482fn canonicalize_or_self(path: &Path) -> PathBuf {
483    dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
484}
485
486fn unix_now() -> u64 {
487    SystemTime::now()
488        .duration_since(UNIX_EPOCH)
489        .unwrap_or_default()
490        .as_secs()
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496    use crate::toolchain::testing::TestMachine;
497
498    /// A realistic cargo-dist receipt body; detection reads only
499    /// `install_prefix` out of it.
500    fn receipt_json(install_prefix: &Path) -> String {
501        serde_json::json!({
502            "binaries": ["water"],
503            "install_layout": "cargo-home",
504            "install_prefix": install_prefix,
505            "modify_path": true,
506            "provider": { "source": "cargo-dist", "version": "0.30.2" },
507            "source": {
508                "app_name": "waterui-cli",
509                "name": "cli",
510                "owner": "water-rs",
511                "release_type": "github",
512            },
513            "version": "0.3.2",
514        })
515        .to_string()
516    }
517
518    /// Write a receipt for `install_prefix` where the platform's receipt
519    /// search finds it, and return the host vars that make it visible.
520    fn stage_receipt(machine: &TestMachine, install_prefix: &Path) -> Vec<(String, String)> {
521        let contents = receipt_json(install_prefix);
522        if cfg!(windows) {
523            let local = machine.dir("localappdata");
524            machine.file(
525                Path::new("localappdata")
526                    .join(APP_NAME)
527                    .join(format!("{APP_NAME}-receipt.json")),
528                &contents,
529            );
530            vec![("LOCALAPPDATA".to_owned(), local.display().to_string())]
531        } else {
532            machine.file(
533                Path::new("home/.config")
534                    .join(APP_NAME)
535                    .join(format!("{APP_NAME}-receipt.json")),
536                &contents,
537            );
538            Vec::new()
539        }
540    }
541
542    /// A dist install receipt covering the executable — the self-update row.
543    #[test]
544    fn receipt_covering_the_executable_is_a_dist_install() {
545        let machine = TestMachine::new();
546        let install = machine.dir("install");
547        let exe = machine.file("install/bin/water", "");
548        let vars = stage_receipt(&machine, &install);
549        let host = machine.host(vars);
550        assert_eq!(
551            InstallSource::detect_exe(&host, &exe).unwrap(),
552            InstallSource::Dist
553        );
554    }
555
556    /// dist's `cargo-home` layout puts the binary in `CARGO_HOME/bin` too —
557    /// the receipt, not the location, distinguishes it from `cargo install`.
558    #[test]
559    fn a_receipt_wins_over_the_cargo_bin_location() {
560        let machine = TestMachine::new();
561        let cargo_home = machine.dir("cargo");
562        let exe = machine.file("cargo/bin/water", "");
563        let mut vars = stage_receipt(&machine, &cargo_home);
564        vars.push(("CARGO_HOME".to_owned(), cargo_home.display().to_string()));
565        let host = machine.host(vars);
566        assert_eq!(
567            InstallSource::detect_exe(&host, &exe).unwrap(),
568            InstallSource::Dist
569        );
570    }
571
572    /// An executable resolving under the Homebrew prefix is `brew`-owned —
573    /// `water update` must print `brew upgrade water` and change nothing.
574    #[test]
575    fn executable_under_the_homebrew_prefix_is_homebrew_owned() {
576        let machine = TestMachine::new();
577        let prefix = machine.dir("homebrew");
578        let exe = machine.file("homebrew/bin/water", "");
579        let host = machine.host([("HOMEBREW_PREFIX", prefix.display().to_string())]);
580        assert_eq!(
581            InstallSource::detect_exe(&host, &exe).unwrap(),
582            InstallSource::Homebrew
583        );
584    }
585
586    /// `brew` found on the host's `PATH` names its own prefix — the binary
587    /// it lives beside is brew-owned even when `HOMEBREW_PREFIX` is unset.
588    #[test]
589    fn executable_beside_brew_on_the_path_is_homebrew_owned() {
590        let machine = TestMachine::new();
591        machine.install("brew");
592        let exe = machine.file("bin/water", "");
593        let host = machine.host(Vec::<(String, String)>::new());
594        assert_eq!(
595            InstallSource::detect_exe(&host, &exe).unwrap(),
596            InstallSource::Homebrew
597        );
598    }
599
600    /// A receipt for some *other* install must not shadow the package
601    /// manager that owns this binary — the guard that keeps a stale receipt
602    /// from authorizing a rewrite of a brew-owned file.
603    #[test]
604    fn a_receipt_for_another_install_does_not_shadow_the_package_manager() {
605        let machine = TestMachine::new();
606        let other_install = machine.dir("other-install");
607        let prefix = machine.dir("homebrew");
608        let exe = machine.file("homebrew/bin/water", "");
609        let mut vars = stage_receipt(&machine, &other_install);
610        vars.push(("HOMEBREW_PREFIX".to_owned(), prefix.display().to_string()));
611        let host = machine.host(vars);
612        assert_eq!(
613            InstallSource::detect_exe(&host, &exe).unwrap(),
614            InstallSource::Homebrew
615        );
616    }
617
618    /// `CARGO_HOME/bin` without a receipt — the `cargo install` /
619    /// `cargo binstall` row, updated with `cargo binstall waterui-cli`.
620    #[test]
621    fn executable_in_cargo_home_bin_without_a_receipt_is_cargo_owned() {
622        let machine = TestMachine::new();
623        let cargo_home = machine.dir("cargo");
624        let exe = machine.file("cargo/bin/water", "");
625        let host = machine.host([("CARGO_HOME", cargo_home.display().to_string())]);
626        assert_eq!(
627            InstallSource::detect_exe(&host, &exe).unwrap(),
628            InstallSource::Cargo
629        );
630    }
631
632    /// `~/.cargo/bin` without `CARGO_HOME` or a receipt is the same row.
633    #[test]
634    fn executable_in_default_cargo_bin_is_cargo_owned() {
635        let machine = TestMachine::new();
636        let exe = machine.file("home/.cargo/bin/water", "");
637        let host = machine.host(Vec::<(String, String)>::new());
638        assert_eq!(
639            InstallSource::detect_exe(&host, &exe).unwrap(),
640            InstallSource::Cargo
641        );
642    }
643
644    /// No receipt, no Homebrew prefix, outside `CARGO_HOME/bin` — the
645    /// "say so and stop" row.
646    #[test]
647    fn no_evidence_is_unknown() {
648        let machine = TestMachine::new();
649        let exe = machine.file("somewhere/water", "");
650        let host = machine.host(Vec::<(String, String)>::new());
651        assert_eq!(
652            InstallSource::detect_exe(&host, &exe).unwrap(),
653            InstallSource::Unknown
654        );
655    }
656
657    /// A receipt that exists but is not JSON is an error, not evidence for
658    /// another channel — the corrupt-receipt case must not silently classify.
659    #[test]
660    fn a_corrupt_receipt_is_an_error_not_a_guess() {
661        let machine = TestMachine::new();
662        if cfg!(windows) {
663            machine.file(
664                Path::new("localappdata")
665                    .join(APP_NAME)
666                    .join(format!("{APP_NAME}-receipt.json")),
667                "not a receipt",
668            );
669        } else {
670            machine.file(
671                Path::new("home/.config")
672                    .join(APP_NAME)
673                    .join(format!("{APP_NAME}-receipt.json")),
674                "not a receipt",
675            );
676        }
677        let vars: Vec<(String, String)> = if cfg!(windows) {
678            vec![(
679                "LOCALAPPDATA".to_owned(),
680                machine.root().join("localappdata").display().to_string(),
681            )]
682        } else {
683            Vec::new()
684        };
685        let host = machine.host(vars);
686        let exe = machine.file("home/.cargo/bin/water", "");
687        assert!(InstallSource::detect_exe(&host, &exe).is_err());
688    }
689
690    /// The 24-hour gate: first check always runs, then once per interval.
691    #[test]
692    fn passive_check_is_due_at_most_once_per_interval() {
693        let interval = PASSIVE_CHECK_INTERVAL.as_secs();
694        assert!(passive_check_due(None, 1_000));
695        assert!(!passive_check_due(Some(1_000), 1_000 + interval - 1));
696        assert!(passive_check_due(Some(1_000), 1_000 + interval));
697        assert!(passive_check_due(Some(1_000 + interval), 1_000));
698    }
699}