use std::path::PathBuf;
use std::time::Duration;
use anyhow::Result;
use kaishin::{Checker, KaishinOptions, LatestRelease, UpdateOptions};
use crate::config::{self, AutoUpdateMode};
const OWNER: &str = "yukimemi";
const INSTALL_FINALIZE_TIMEOUT: Duration = Duration::from_secs(5);
const NOTIFY_FINALIZE_TIMEOUT: Duration = Duration::from_secs(1);
fn kaishin_opts() -> KaishinOptions {
KaishinOptions::new(
OWNER,
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_NAME"),
env!("CARGO_PKG_VERSION"),
)
}
fn state_path() -> Option<PathBuf> {
directories::BaseDirs::new()
.map(|d| d.cache_dir().join("todoke").join("last_update_check.json"))
}
fn env_value_disables(value: Option<&str>) -> bool {
match value {
Some(v) => {
let v = v.trim();
!v.is_empty() && !v.eq_ignore_ascii_case("0") && !v.eq_ignore_ascii_case("false")
}
None => false,
}
}
fn auto_update_disabled_by_env() -> bool {
env_value_disables(std::env::var("TODOKE_NO_AUTOUPDATE").ok().as_deref())
}
pub async fn run_self_update(yes: bool, check: bool) -> Result<()> {
let opts = kaishin_opts();
let upd_opts = UpdateOptions::new().yes(yes).check_only(check);
kaishin::run_self_update(&opts, upd_opts).await
}
pub enum AutoUpdateHandle {
CachedAvailable {
checker: Checker,
latest: LatestRelease,
},
Pending {
checker: Checker,
handle: tokio::task::JoinHandle<Result<Option<LatestRelease>>>,
cached_latest: Option<LatestRelease>,
},
Installing {
handle: tokio::task::JoinHandle<Result<Option<LatestRelease>>>,
},
}
fn build_checker(interval: Duration) -> Option<Checker> {
let path = state_path()?;
Some(
Checker::new(env!("CARGO_PKG_NAME"), kaishin_opts())
.interval(interval)
.state_path(path),
)
}
pub async fn maybe_spawn_auto_update_check(
explicit_config: Option<&std::path::Path>,
) -> Option<AutoUpdateHandle> {
if auto_update_disabled_by_env() {
return None;
}
let cfg = config::load(explicit_config).ok()?;
let mode = cfg.raw.options.auto_update;
if mode == AutoUpdateMode::Off {
return None;
}
let interval = cfg
.raw
.options
.update_interval
.as_deref()
.and_then(|s| kaishin::parse_interval(s).ok())
.unwrap_or_else(kaishin::default_interval);
let checker = build_checker(interval)?;
match mode {
AutoUpdateMode::Off => None, AutoUpdateMode::Notify => {
if !checker.should_check() {
return checker
.cached_update()
.map(|latest| AutoUpdateHandle::CachedAvailable { checker, latest });
}
let cached_latest = checker.cached_update();
let checker_clone = checker.clone();
let handle = tokio::spawn(async move { checker_clone.check_and_save().await });
Some(AutoUpdateHandle::Pending {
checker,
handle,
cached_latest,
})
}
AutoUpdateMode::Install => {
let handle = tokio::spawn(async move { checker.auto_update().await });
Some(AutoUpdateHandle::Installing { handle })
}
}
}
pub async fn finalize_auto_update_check(handle: AutoUpdateHandle) {
match handle {
AutoUpdateHandle::CachedAvailable { checker, latest } => {
eprintln!("\n{}", checker.format_banner(&latest));
}
AutoUpdateHandle::Pending {
checker,
handle,
cached_latest,
} => {
let res = tokio::time::timeout(NOTIFY_FINALIZE_TIMEOUT, handle).await;
match res {
Ok(Ok(Ok(Some(latest)))) => {
eprintln!("\n{}", checker.format_banner(&latest));
}
Ok(Ok(Ok(None))) => {
}
_ => {
if let Some(latest) = cached_latest {
eprintln!("\n{}", checker.format_banner(&latest));
}
}
}
}
AutoUpdateHandle::Installing { handle } => {
if let Ok(Ok(Ok(Some(latest)))) =
tokio::time::timeout(INSTALL_FINALIZE_TIMEOUT, handle).await
{
let version = latest.tag_name.trim_start_matches('v');
eprintln!(
"\u{2713} {bin} {version} installed in the background \u{2014} restart to apply.",
bin = env!("CARGO_PKG_NAME"),
);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_kill_switch_unset_is_enabled() {
assert!(!env_value_disables(None));
}
#[test]
fn env_kill_switch_truthy_disables() {
for v in ["1", "true", "TRUE", "yes", "on", " 1 "] {
assert!(env_value_disables(Some(v)), "{v:?} should disable");
}
}
#[test]
fn env_kill_switch_falsey_stays_enabled() {
for v in ["", "0", "false", "FALSE", " ", " 0 ", " false "] {
assert!(!env_value_disables(Some(v)), "{v:?} should NOT disable");
}
}
#[test]
fn build_checker_returns_some_with_resolved_state_path() {
let checker = build_checker(Duration::from_secs(3600))
.expect("checker should build when the cache dir resolves");
let _ = checker.cached_update();
}
#[test]
fn state_path_is_under_cache_dir() {
if let (Some(path), Some(dirs)) = (state_path(), directories::BaseDirs::new()) {
assert!(
path.starts_with(dirs.cache_dir()),
"state file should be under the cache dir, got {path:?}"
);
assert!(path.ends_with("last_update_check.json"));
}
}
static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_env_locked<R>(f: impl FnOnce() -> R) -> R {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
f()
}
#[test]
fn maybe_spawn_skips_when_disabled_by_env() {
let handle = with_env_locked(|| {
let prev = std::env::var("TODOKE_NO_AUTOUPDATE").ok();
unsafe {
std::env::set_var("TODOKE_NO_AUTOUPDATE", "1");
}
let handle = tokio::runtime::Runtime::new()
.unwrap()
.block_on(maybe_spawn_auto_update_check(None));
unsafe {
match prev {
Some(v) => std::env::set_var("TODOKE_NO_AUTOUPDATE", v),
None => std::env::remove_var("TODOKE_NO_AUTOUPDATE"),
}
}
handle
});
assert!(
handle.is_none(),
"env kill-switch must short-circuit before any spawn"
);
}
#[test]
fn maybe_spawn_skips_when_mode_off() {
let dir = std::env::temp_dir().join(format!("todoke-test-off-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let cfg_path = dir.join("todoke.toml");
std::fs::write(
&cfg_path,
"[options]\nauto_update = \"off\"\n\n[todoke.a]\ncommand = \"echo\"\n\n[[rules]]\nmatch = \".*\"\nto = \"a\"\n",
)
.unwrap();
let handle = with_env_locked(|| {
let prev = std::env::var("TODOKE_NO_AUTOUPDATE").ok();
unsafe {
std::env::remove_var("TODOKE_NO_AUTOUPDATE");
}
let handle = tokio::runtime::Runtime::new()
.unwrap()
.block_on(maybe_spawn_auto_update_check(Some(&cfg_path)));
unsafe {
match prev {
Some(v) => std::env::set_var("TODOKE_NO_AUTOUPDATE", v),
None => std::env::remove_var("TODOKE_NO_AUTOUPDATE"),
}
}
handle
});
let _ = std::fs::remove_dir_all(&dir);
assert!(
handle.is_none(),
"auto_update = \"off\" must short-circuit before any spawn"
);
}
}