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 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 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 #[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#[derive(Debug)]
124pub enum UpdateOutcome {
125 Updated {
127 previous: Option<Version>,
129 installed: Version,
131 },
132 UpToDate {
134 current: Version,
136 },
137 ExternallyManaged {
140 command: &'static str,
142 },
143}
144
145#[derive(Debug)]
147pub enum CheckOutcome {
148 UpToDate {
150 current: Version,
152 },
153 Available {
155 current: Version,
157 latest: Version,
159 command: &'static str,
161 },
162}
163
164pub 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
184pub 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#[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#[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
245async 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
276fn 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
285async 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
305async 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
327fn 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
337fn 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
348async 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
370fn current_version() -> Version {
372 env!("CARGO_PKG_VERSION")
373 .parse()
374 .expect("package version is semver")
375}
376
377fn 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#[derive(Deserialize)]
399struct ReceiptPrefix {
400 install_prefix: String,
401}
402
403fn 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
433fn 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
454fn 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
463fn 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
480fn 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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}