use std::path::PathBuf;
use std::time::Duration;
use anyhow::Result;
use crate::config::{Update, UpdateMode};
pub const NO_AUTOUPDATE_ENV: &str = "MAGI_NO_AUTOUPDATE";
pub fn default_interval() -> Duration {
kaishin::default_interval()
}
pub fn disabled_by_env() -> bool {
match std::env::var(NO_AUTOUPDATE_ENV) {
Ok(v) => {
let v = v.trim();
!(v.is_empty() || v == "0" || v.eq_ignore_ascii_case("false"))
}
Err(_) => false,
}
}
const OWNER: &str = "yukimemi";
const REPO: &str = "magi";
const BIN: &str = "magi";
const CRATE: &str = "magi-cli";
fn options() -> kaishin::KaishinOptions {
kaishin::KaishinOptions::new(OWNER, REPO, BIN, env!("CARGO_PKG_VERSION")).crate_name(CRATE)
}
fn state_path() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("magi").join("last_update_check.json"))
}
pub async fn run_self_update(yes: bool, check_only: bool, non_interactive: bool) -> Result<()> {
let opts = kaishin::UpdateOptions::new()
.yes(yes)
.check_only(check_only)
.non_interactive(non_interactive);
kaishin::run_self_update(&options(), opts).await
}
pub enum Pending {
Cached {
checker: Checker,
latest: kaishin::LatestRelease,
},
Notify {
checker: Checker,
handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
},
Install {
handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
},
}
#[derive(Clone)]
pub struct Checker {
inner: kaishin::Checker,
}
impl Checker {
pub fn new(cfg: &Update) -> Option<Self> {
if cfg.mode == UpdateMode::Off {
return None;
}
let mut inner = kaishin::Checker::new(BIN, options());
if let Some(path) = state_path() {
inner = inner.state_path(path);
}
let interval = cfg
.interval
.as_deref()
.and_then(|s| kaishin::parse_interval(s).ok())
.unwrap_or_else(default_interval);
Some(Self {
inner: inner.interval(interval),
})
}
pub fn should_check(&self) -> bool {
self.inner.should_check()
}
pub async fn newer_release(&self) -> Result<Option<kaishin::LatestRelease>> {
self.inner.check_and_save().await
}
pub fn cached_update(&self) -> Option<kaishin::LatestRelease> {
self.inner.cached_update()
}
pub fn format_banner(&self, latest: &kaishin::LatestRelease) -> String {
self.inner.format_banner(latest)
}
}
pub fn spawn(cfg: &Update, rt: &tokio::runtime::Handle) -> Option<Pending> {
if disabled_by_env() || cfg.mode == UpdateMode::Off {
return None;
}
let checker = Checker::new(cfg)?;
match cfg.mode {
UpdateMode::Off => None,
UpdateMode::Notify => {
if !checker.should_check() {
let latest = checker.cached_update()?;
return Some(Pending::Cached { checker, latest });
}
let inner = checker.inner.clone();
let handle = rt.spawn(async move { inner.check_and_save().await });
Some(Pending::Notify { checker, handle })
}
UpdateMode::Install => {
let inner = checker.inner.clone();
let handle = rt.spawn(async move { inner.auto_update().await });
Some(Pending::Install { handle })
}
}
}
pub async fn finalize(pending: Option<Pending>, budget: Duration) {
let Some(pending) = pending else {
return;
};
match pending {
Pending::Cached { checker, latest } => {
eprintln!("{}", checker.format_banner(&latest));
}
Pending::Notify { checker, handle } => {
if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
eprintln!("{}", checker.format_banner(&latest));
}
}
Pending::Install { handle } => {
if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
eprintln!("magi updated itself to {}", latest.tag_name);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn env_kill_switch_semantics() {
unsafe {
std::env::remove_var(NO_AUTOUPDATE_ENV);
}
assert!(!disabled_by_env());
for (value, disabled) in [
("1", true),
("true", true),
("yes", true),
("0", false),
("false", false),
("FALSE", false),
("", false),
(" ", false),
] {
unsafe {
std::env::set_var(NO_AUTOUPDATE_ENV, value);
}
assert_eq!(
disabled_by_env(),
disabled,
"MAGI_NO_AUTOUPDATE={value:?} should {} disable",
if disabled { "" } else { "not" }
);
}
unsafe {
std::env::remove_var(NO_AUTOUPDATE_ENV);
}
}
#[test]
fn off_mode_never_spawns() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.unwrap();
let cfg = Update {
mode: UpdateMode::Off,
interval: None,
};
assert!(spawn(&cfg, rt.handle()).is_none());
}
#[test]
fn state_path_lives_under_the_cache_dir() {
let path = state_path().expect("a cache dir on every supported platform");
assert!(path.ends_with("magi/last_update_check.json"));
let data = dirs::data_local_dir().unwrap_or_default();
assert!(
!path.starts_with(&data) || dirs::cache_dir() == dirs::data_local_dir(),
"throttle state must not sit in the run history directory"
);
}
#[tokio::test]
async fn finalize_of_nothing_is_a_no_op() {
finalize(None, Duration::from_millis(1)).await;
}
#[test]
fn checking_is_off_for_every_caller_when_the_config_says_off() {
assert!(
Checker::new(&Update {
mode: UpdateMode::Off,
interval: None,
})
.is_none(),
"an operator who writes mode = \"off\" means it"
);
for mode in [UpdateMode::Notify, UpdateMode::Install] {
assert!(
Checker::new(&Update {
mode,
interval: None,
})
.is_some(),
"{mode:?} still asks the forge"
);
}
}
}