use std::{
ffi::OsStr,
path::{Path, PathBuf},
time::{Duration, SystemTime, UNIX_EPOCH},
};
use axoupdater::{AxoUpdater, ReleaseSource, ReleaseSourceType};
use eyre::{Result, WrapErr, bail};
use semver::Version;
use serde::Deserialize;
use crate::toolchain::Host;
use crate::water_dir;
const APP_NAME: &str = env!("CARGO_PKG_NAME");
const RELEASE_OWNER: &str = "water-rs";
const RELEASE_REPO: &str = "cli";
const UNKNOWN_INSTALL_MESSAGE: &str = "cannot determine how this `water` \
binary was installed: no dist install receipt matches it, it resolves \
under no Homebrew prefix, and it sits outside CARGO_HOME/bin; refusing \
to update it";
const PASSIVE_CHECK_INTERVAL: Duration = Duration::from_hours(24);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstallSource {
Dist,
Homebrew,
Cargo,
Unknown,
}
impl InstallSource {
pub fn detect(host: &Host) -> Result<Self> {
let executable =
Host::current_exe().wrap_err("the running executable's path cannot be determined")?;
Self::detect_exe(host, &executable)
}
fn detect_exe(host: &Host, executable: &Path) -> Result<Self> {
let executable = canonicalize_or_self(executable);
if let Some(prefix) = receipt_install_prefix(host)?
&& same_install_root(&executable, &canonicalize_or_self(&prefix))
{
return Ok(Self::Dist);
}
for prefix in homebrew_prefixes(host) {
if executable.starts_with(canonicalize_or_self(&prefix)) {
return Ok(Self::Homebrew);
}
}
if let Some(cargo_bin) = cargo_bin_dir(host)
&& executable.parent() == Some(canonicalize_or_self(&cargo_bin).as_path())
{
return Ok(Self::Cargo);
}
Ok(Self::Unknown)
}
#[must_use]
pub const fn update_command(self) -> Option<&'static str> {
match self {
Self::Dist => Some("water update"),
Self::Homebrew => Some("brew upgrade water"),
Self::Cargo => Some("cargo binstall waterui-cli"),
Self::Unknown => None,
}
}
}
#[derive(Debug)]
pub enum UpdateOutcome {
Updated {
previous: Option<Version>,
installed: Version,
},
UpToDate {
current: Version,
},
ExternallyManaged {
command: &'static str,
},
}
#[derive(Debug)]
pub enum CheckOutcome {
UpToDate {
current: Version,
},
Available {
current: Version,
latest: Version,
command: &'static str,
},
}
pub async fn update(host: &Host) -> Result<UpdateOutcome> {
match InstallSource::detect(host)? {
InstallSource::Dist => run_dist_update(host).await,
source => {
let Some(command) = source.update_command() else {
bail!("{UNKNOWN_INSTALL_MESSAGE}");
};
Ok(UpdateOutcome::ExternallyManaged { command })
}
}
}
pub async fn check(host: &Host) -> Result<CheckOutcome> {
let source = InstallSource::detect(host)?;
let Some(command) = source.update_command() else {
bail!("{UNKNOWN_INSTALL_MESSAGE}");
};
let current = current_version();
let latest = query_latest(host, source).await?;
if current < latest {
Ok(CheckOutcome::Available {
current,
latest,
command,
})
} else {
Ok(CheckOutcome::UpToDate { current })
}
}
#[must_use]
pub fn cli_update_command(fallback: &str) -> String {
match InstallSource::detect(&Host::current()) {
Ok(InstallSource::Dist) => "water update".to_owned(),
Ok(InstallSource::Homebrew) => "brew upgrade water".to_owned(),
Ok(InstallSource::Cargo | InstallSource::Unknown) | Err(_) => fallback.to_owned(),
}
}
#[must_use]
pub async fn passive_update_notice() -> Option<String> {
let host = Host::current();
let water_home = water_dir::water_home_dir_in(&host).ok()?;
let mut config = water_dir::ensure_global_config_in(&water_home).await.ok()?;
if !passive_check_due(config.last_update_check_unix_seconds, unix_now()) {
return None;
}
let notice = passive_notice_inner(&host).await;
config.last_update_check_unix_seconds = Some(unix_now());
if let Err(error) = water_dir::write_global_config_in(&water_home, &config).await {
tracing::debug!("update check: failed to record the check timestamp: {error}");
}
notice
}
async fn passive_notice_inner(host: &Host) -> Option<String> {
let source = match InstallSource::detect(host) {
Ok(source) => source,
Err(error) => {
tracing::debug!("update check: install source detection failed: {error}");
return None;
}
};
if source == InstallSource::Unknown {
return None;
}
let latest = match query_latest(host, source).await {
Ok(latest) => latest,
Err(error) => {
tracing::debug!("update check: release query failed: {error}");
return None;
}
};
let current = current_version();
if latest > current {
Some(format!(
"water {latest} is available (installed: {current}); update with `{}`",
source.update_command()?,
))
} else {
None
}
}
fn passive_check_due(last_unix_seconds: Option<u64>, now_unix_seconds: u64) -> bool {
last_unix_seconds.is_none_or(|last| {
last > now_unix_seconds || now_unix_seconds - last >= PASSIVE_CHECK_INTERVAL.as_secs()
})
}
async fn run_dist_update(host: &Host) -> Result<UpdateOutcome> {
let mut updater = configured_updater(host);
let result = unblock_axoupdater(move || async move {
updater.load_receipt()?;
updater.run().await
})
.await
.map_err(eyre::Report::new)?;
match result {
Some(result) => Ok(UpdateOutcome::Updated {
previous: result.old_version,
installed: result.new_version,
}),
None => Ok(UpdateOutcome::UpToDate {
current: current_version(),
}),
}
}
async fn query_latest(host: &Host, source: InstallSource) -> Result<Version> {
let mut updater = configured_updater(host);
let latest = unblock_axoupdater(move || async move {
match source {
InstallSource::Dist => {
updater.load_receipt()?;
}
_ => {
updater.set_release_source(github_release_source());
}
}
updater
.query_new_version()
.await
.map(Option::<&Version>::cloned)
})
.await
.map_err(eyre::Report::new)?;
latest.ok_or_else(|| eyre::eyre!("the release source lists no releases"))
}
fn configured_updater(host: &Host) -> AxoUpdater {
let mut updater = AxoUpdater::new_for(APP_NAME);
if let Some(token) = host.env_string("WATERUI_GITHUB_TOKEN") {
updater.set_github_token(&token);
}
updater
}
fn github_release_source() -> ReleaseSource {
ReleaseSource {
release_type: ReleaseSourceType::GitHub,
owner: RELEASE_OWNER.to_owned(),
name: RELEASE_REPO.to_owned(),
app_name: APP_NAME.to_owned(),
}
}
async fn unblock_axoupdater<Fut, T>(f: impl FnOnce() -> Fut + Send + 'static) -> T
where
Fut: std::future::Future<Output = T>,
T: Send + 'static,
{
smol::unblock(move || {
tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("tokio current-thread runtime for axoupdater")
.block_on(f())
})
.await
}
fn current_version() -> Version {
env!("CARGO_PKG_VERSION")
.parse()
.expect("package version is semver")
}
fn receipt_install_prefix(host: &Host) -> Result<Option<PathBuf>> {
for dir in receipt_dirs(host) {
let path = dir.join(format!("{APP_NAME}-receipt.json"));
if !path.is_file() {
continue;
}
let contents = std::fs::read_to_string(&path).wrap_err_with(|| {
format!("the install receipt at {} cannot be read", path.display())
})?;
let receipt: ReceiptPrefix = serde_json::from_str(&contents)
.wrap_err_with(|| format!("the install receipt at {} is invalid", path.display()))?;
return Ok(Some(PathBuf::from(receipt.install_prefix)));
}
Ok(None)
}
#[derive(Deserialize)]
struct ReceiptPrefix {
install_prefix: String,
}
fn receipt_dirs(host: &Host) -> Vec<PathBuf> {
if host.env("AXOUPDATER_CONFIG_WORKING_DIR").is_some() {
return vec![host.cwd().to_owned()];
}
if let Some(path) = host.env_string("AXOUPDATER_CONFIG_PATH") {
return vec![PathBuf::from(path)];
}
let mut dirs = Vec::new();
if cfg!(windows) {
if let Some(local) = host.env_string("LOCALAPPDATA") {
dirs.push(Path::new(&local).join(APP_NAME));
}
} else {
if let Some(xdg) = host.env_string("XDG_CONFIG_HOME") {
let dir = Path::new(&xdg).join(APP_NAME);
if dir.is_dir() {
dirs.push(dir);
}
}
if let Some(home) = host.home_dir() {
dirs.push(home.join(".config").join(APP_NAME));
}
}
dirs
}
fn homebrew_prefixes(host: &Host) -> Vec<PathBuf> {
let mut prefixes = Vec::new();
if let Some(prefix) = host.env_string("HOMEBREW_PREFIX") {
prefixes.push(PathBuf::from(prefix));
}
let paths = host.path_entries();
if !paths.is_empty()
&& let Ok(path) = std::env::join_paths(&paths)
&& let Ok(brew) = which::which_in("brew", Some(path), host.cwd())
&& let Some(prefix) = canonicalize_or_self(&brew).parent().and_then(Path::parent)
{
prefixes.push(prefix.to_path_buf());
}
prefixes
}
fn cargo_bin_dir(host: &Host) -> Option<PathBuf> {
if let Some(cargo_home) = host.env_string("CARGO_HOME") {
return Some(PathBuf::from(cargo_home).join("bin"));
}
host.home_dir().map(|home| home.join(".cargo").join("bin"))
}
fn same_install_root(executable: &Path, install_prefix: &Path) -> bool {
let exe_dir = executable.parent().unwrap_or(executable);
let exe_root = if exe_dir.file_name() == Some(OsStr::new("bin"))
&& install_prefix.file_name() != Some(OsStr::new("bin"))
{
exe_dir.parent().unwrap_or(exe_dir)
} else {
exe_dir
};
exe_root == install_prefix
}
fn canonicalize_or_self(path: &Path) -> PathBuf {
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::toolchain::testing::TestMachine;
fn receipt_json(install_prefix: &Path) -> String {
serde_json::json!({
"binaries": ["water"],
"install_layout": "cargo-home",
"install_prefix": install_prefix,
"modify_path": true,
"provider": { "source": "cargo-dist", "version": "0.30.2" },
"source": {
"app_name": "waterui-cli",
"name": "cli",
"owner": "water-rs",
"release_type": "github",
},
"version": "0.3.2",
})
.to_string()
}
fn stage_receipt(machine: &TestMachine, install_prefix: &Path) -> Vec<(String, String)> {
let contents = receipt_json(install_prefix);
if cfg!(windows) {
let local = machine.dir("localappdata");
machine.file(
Path::new("localappdata")
.join(APP_NAME)
.join(format!("{APP_NAME}-receipt.json")),
&contents,
);
vec![("LOCALAPPDATA".to_owned(), local.display().to_string())]
} else {
machine.file(
Path::new("home/.config")
.join(APP_NAME)
.join(format!("{APP_NAME}-receipt.json")),
&contents,
);
Vec::new()
}
}
#[test]
fn receipt_covering_the_executable_is_a_dist_install() {
let machine = TestMachine::new();
let install = machine.dir("install");
let exe = machine.file("install/bin/water", "");
let vars = stage_receipt(&machine, &install);
let host = machine.host(vars);
assert_eq!(
InstallSource::detect_exe(&host, &exe).unwrap(),
InstallSource::Dist
);
}
#[test]
fn a_receipt_wins_over_the_cargo_bin_location() {
let machine = TestMachine::new();
let cargo_home = machine.dir("cargo");
let exe = machine.file("cargo/bin/water", "");
let mut vars = stage_receipt(&machine, &cargo_home);
vars.push(("CARGO_HOME".to_owned(), cargo_home.display().to_string()));
let host = machine.host(vars);
assert_eq!(
InstallSource::detect_exe(&host, &exe).unwrap(),
InstallSource::Dist
);
}
#[test]
fn executable_under_the_homebrew_prefix_is_homebrew_owned() {
let machine = TestMachine::new();
let prefix = machine.dir("homebrew");
let exe = machine.file("homebrew/bin/water", "");
let host = machine.host([("HOMEBREW_PREFIX", prefix.display().to_string())]);
assert_eq!(
InstallSource::detect_exe(&host, &exe).unwrap(),
InstallSource::Homebrew
);
}
#[test]
fn executable_beside_brew_on_the_path_is_homebrew_owned() {
let machine = TestMachine::new();
machine.install("brew");
let exe = machine.file("bin/water", "");
let host = machine.host(Vec::<(String, String)>::new());
assert_eq!(
InstallSource::detect_exe(&host, &exe).unwrap(),
InstallSource::Homebrew
);
}
#[test]
fn a_receipt_for_another_install_does_not_shadow_the_package_manager() {
let machine = TestMachine::new();
let other_install = machine.dir("other-install");
let prefix = machine.dir("homebrew");
let exe = machine.file("homebrew/bin/water", "");
let mut vars = stage_receipt(&machine, &other_install);
vars.push(("HOMEBREW_PREFIX".to_owned(), prefix.display().to_string()));
let host = machine.host(vars);
assert_eq!(
InstallSource::detect_exe(&host, &exe).unwrap(),
InstallSource::Homebrew
);
}
#[test]
fn executable_in_cargo_home_bin_without_a_receipt_is_cargo_owned() {
let machine = TestMachine::new();
let cargo_home = machine.dir("cargo");
let exe = machine.file("cargo/bin/water", "");
let host = machine.host([("CARGO_HOME", cargo_home.display().to_string())]);
assert_eq!(
InstallSource::detect_exe(&host, &exe).unwrap(),
InstallSource::Cargo
);
}
#[test]
fn executable_in_default_cargo_bin_is_cargo_owned() {
let machine = TestMachine::new();
let exe = machine.file("home/.cargo/bin/water", "");
let host = machine.host(Vec::<(String, String)>::new());
assert_eq!(
InstallSource::detect_exe(&host, &exe).unwrap(),
InstallSource::Cargo
);
}
#[test]
fn no_evidence_is_unknown() {
let machine = TestMachine::new();
let exe = machine.file("somewhere/water", "");
let host = machine.host(Vec::<(String, String)>::new());
assert_eq!(
InstallSource::detect_exe(&host, &exe).unwrap(),
InstallSource::Unknown
);
}
#[test]
fn a_corrupt_receipt_is_an_error_not_a_guess() {
let machine = TestMachine::new();
if cfg!(windows) {
machine.file(
Path::new("localappdata")
.join(APP_NAME)
.join(format!("{APP_NAME}-receipt.json")),
"not a receipt",
);
} else {
machine.file(
Path::new("home/.config")
.join(APP_NAME)
.join(format!("{APP_NAME}-receipt.json")),
"not a receipt",
);
}
let vars: Vec<(String, String)> = if cfg!(windows) {
vec![(
"LOCALAPPDATA".to_owned(),
machine.root().join("localappdata").display().to_string(),
)]
} else {
Vec::new()
};
let host = machine.host(vars);
let exe = machine.file("home/.cargo/bin/water", "");
assert!(InstallSource::detect_exe(&host, &exe).is_err());
}
#[test]
fn passive_check_is_due_at_most_once_per_interval() {
let interval = PASSIVE_CHECK_INTERVAL.as_secs();
assert!(passive_check_due(None, 1_000));
assert!(!passive_check_due(Some(1_000), 1_000 + interval - 1));
assert!(passive_check_due(Some(1_000), 1_000 + interval));
assert!(passive_check_due(Some(1_000 + interval), 1_000));
}
}