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