1use 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
32const APP_NAME: &str = env!("CARGO_PKG_NAME");
36
37const RELEASE_OWNER: &str = "water-rs";
40const RELEASE_REPO: &str = "cli";
41
42const 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
49const PASSIVE_CHECK_INTERVAL: Duration = Duration::from_hours(24);
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum InstallSource {
56 Dist,
59 Homebrew,
61 Cargo,
64 Unknown,
66}
67
68impl InstallSource {
69 #[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 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 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 #[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#[derive(Debug)]
135pub enum UpdateOutcome {
136 Updated {
138 previous: Option<Version>,
140 installed: Version,
142 },
143 UpToDate {
145 current: Version,
147 },
148 ExternallyManaged {
151 command: &'static str,
153 },
154}
155
156#[derive(Debug)]
158pub struct UpdateReport {
159 pub source: InstallSource,
161 pub install_dir: PathBuf,
163 pub outcome: UpdateOutcome,
165}
166
167#[derive(Debug)]
169pub enum CheckOutcome {
170 UpToDate {
172 current: Version,
174 },
175 Available {
177 current: Version,
179 latest: Version,
181 command: &'static str,
183 },
184}
185
186pub 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
219pub 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#[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#[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
280async 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
311fn 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
320async 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
340async 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
362fn 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
372fn 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
383async 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
405fn current_version() -> Version {
407 env!("CARGO_PKG_VERSION")
408 .parse()
409 .expect("package version is semver")
410}
411
412fn 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#[derive(Deserialize)]
434struct ReceiptPrefix {
435 install_prefix: String,
436}
437
438fn 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
468fn 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
489fn 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
498fn 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
515fn 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}