1use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use anyhow::{Context, Result};
11use jiff::Timestamp;
12use serde::{Deserialize, Serialize};
13
14use crate::config::{Update, UpdateMode};
15
16pub const NO_AUTOUPDATE_ENV: &str = "MAGI_NO_AUTOUPDATE";
20
21pub fn default_interval() -> Duration {
23 kaishin::default_interval()
24}
25
26pub fn disabled_by_env() -> bool {
28 match std::env::var(NO_AUTOUPDATE_ENV) {
29 Ok(v) => {
30 let v = v.trim();
31 !(v.is_empty() || v == "0" || v.eq_ignore_ascii_case("false"))
32 }
33 Err(_) => false,
34 }
35}
36
37const OWNER: &str = "yukimemi";
39const REPO: &str = "magi";
41const BIN: &str = "magi";
43const CRATE: &str = "magi-cli";
45
46fn options() -> kaishin::KaishinOptions {
54 kaishin::KaishinOptions::new(OWNER, REPO, BIN, env!("CARGO_PKG_VERSION")).crate_name(CRATE)
55}
56
57fn state_path() -> Option<PathBuf> {
60 dirs::cache_dir().map(|d| d.join("magi").join("last_update_check.json"))
61}
62
63pub async fn run_self_update(yes: bool, check_only: bool, non_interactive: bool) -> Result<()> {
65 let opts = kaishin::UpdateOptions::new()
66 .yes(yes)
67 .check_only(check_only)
68 .non_interactive(non_interactive);
69 kaishin::run_self_update(&options(), opts).await
70}
71
72pub enum Pending {
74 Cached {
76 checker: Checker,
78 latest: kaishin::LatestRelease,
80 },
81 Notify {
83 checker: Checker,
85 handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
87 },
88 Install {
90 handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
92 },
93}
94
95#[derive(Clone)]
97pub struct Checker {
98 inner: kaishin::Checker,
99}
100
101impl Checker {
102 pub fn new(cfg: &Update) -> Option<Self> {
116 if cfg.mode == UpdateMode::Off {
117 return None;
118 }
119 let mut inner = kaishin::Checker::new(BIN, options());
120 if let Some(path) = state_path() {
121 inner = inner.state_path(path);
122 }
123 let interval = cfg
124 .interval
125 .as_deref()
126 .and_then(|s| kaishin::parse_interval(s).ok())
127 .unwrap_or_else(default_interval);
128 Some(Self {
129 inner: inner.interval(interval),
130 })
131 }
132
133 pub fn should_check(&self) -> bool {
135 self.inner.should_check()
136 }
137
138 pub async fn newer_release(&self) -> Result<Option<kaishin::LatestRelease>> {
145 self.inner.check_and_save().await
146 }
147
148 pub fn cached_update(&self) -> Option<kaishin::LatestRelease> {
150 self.inner.cached_update()
151 }
152
153 pub fn format_banner(&self, latest: &kaishin::LatestRelease) -> String {
155 self.inner.format_banner(latest)
156 }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
165#[serde(rename_all = "snake_case")]
166pub enum Stage {
167 Downloading,
175 Replaced,
178 Parking,
182 Restarting,
186 Done,
189 Failed,
192}
193
194impl Stage {
195 #[must_use]
197 pub fn terminal(self) -> bool {
198 matches!(self, Self::Done | Self::Failed)
199 }
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct Progress {
213 pub stage: Stage,
215 pub from: String,
217 pub to: Option<String>,
219 #[serde(default)]
221 pub parked_run: Option<String>,
222 pub started_at: Timestamp,
224 pub updated_at: Timestamp,
226 #[serde(default)]
228 pub detail: Option<String>,
229}
230
231impl Progress {
232 #[must_use]
234 pub fn new(from: String, to: String) -> Self {
235 let now = Timestamp::now();
236 Self {
237 stage: Stage::Downloading,
238 from,
239 to: Some(to),
240 parked_run: None,
241 started_at: now,
242 updated_at: now,
243 detail: None,
244 }
245 }
246
247 pub fn advance(&mut self, stage: Stage) {
249 self.stage = stage;
250 self.updated_at = Timestamp::now();
251 }
252
253 pub fn fail(&mut self, detail: impl Into<String>) {
255 self.stage = Stage::Failed;
256 self.updated_at = Timestamp::now();
257 self.detail = Some(detail.into());
258 }
259}
260
261#[must_use]
264pub fn progress_path(home: &Path) -> PathBuf {
265 home.join("upgrade.json")
266}
267
268pub fn write_progress(home: &Path, progress: &Progress) -> Result<()> {
274 let path = progress_path(home);
275 if let Some(parent) = path.parent() {
276 std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
277 }
278 let body = serde_json::to_string_pretty(progress).context("serialize upgrade progress")?;
279 let tmp = path.with_extension("json.tmp");
280 std::fs::write(&tmp, &body).with_context(|| format!("write {}", tmp.display()))?;
281 std::fs::rename(&tmp, &path).with_context(|| format!("replace {}", path.display()))?;
282 Ok(())
283}
284
285#[must_use]
287pub fn read_progress(home: &Path) -> Option<Progress> {
288 let body = std::fs::read_to_string(progress_path(home)).ok()?;
289 serde_json::from_str(&body).ok()
290}
291
292pub fn reconcile_after_restart(home: &Path) {
304 let Some(mut progress) = read_progress(home) else {
305 return;
306 };
307 if progress.stage.terminal() {
308 return;
309 }
310 let running = env!("CARGO_PKG_VERSION");
311 if progress
317 .to
318 .as_deref()
319 .is_some_and(|to| to.trim_start_matches('v') == running)
320 {
321 progress.advance(Stage::Done);
322 } else {
323 let to = progress
324 .to
325 .clone()
326 .unwrap_or_else(|| "the expected release".to_owned());
327 progress.fail(format!(
328 "this process came up on {running}, not {to} - the upgrade may \
329 not have replaced the binary"
330 ));
331 }
332 let _ = write_progress(home, &progress);
333}
334
335pub fn spawn(cfg: &Update, rt: &tokio::runtime::Handle) -> Option<Pending> {
337 if disabled_by_env() || cfg.mode == UpdateMode::Off {
338 return None;
339 }
340 let checker = Checker::new(cfg)?;
341 match cfg.mode {
342 UpdateMode::Off => None,
343 UpdateMode::Notify => {
344 if !checker.should_check() {
345 let latest = checker.cached_update()?;
346 return Some(Pending::Cached { checker, latest });
347 }
348 let inner = checker.inner.clone();
349 let handle = rt.spawn(async move { inner.check_and_save().await });
350 Some(Pending::Notify { checker, handle })
351 }
352 UpdateMode::Install => {
353 let inner = checker.inner.clone();
354 let handle = rt.spawn(async move { inner.auto_update().await });
355 Some(Pending::Install { handle })
356 }
357 }
358}
359
360pub async fn finalize(pending: Option<Pending>, budget: Duration) {
365 let Some(pending) = pending else {
366 return;
367 };
368 match pending {
369 Pending::Cached { checker, latest } => {
370 eprintln!("{}", checker.format_banner(&latest));
371 }
372 Pending::Notify { checker, handle } => {
373 if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
374 eprintln!("{}", checker.format_banner(&latest));
375 }
376 }
377 Pending::Install { handle } => {
378 if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
379 eprintln!("magi updated itself to {}", latest.tag_name);
380 }
381 }
382 }
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 #[test]
390 fn env_kill_switch_semantics() {
391 unsafe {
393 std::env::remove_var(NO_AUTOUPDATE_ENV);
394 }
395 assert!(!disabled_by_env());
396 for (value, disabled) in [
397 ("1", true),
398 ("true", true),
399 ("yes", true),
400 ("0", false),
401 ("false", false),
402 ("FALSE", false),
403 ("", false),
404 (" ", false),
405 ] {
406 unsafe {
407 std::env::set_var(NO_AUTOUPDATE_ENV, value);
408 }
409 assert_eq!(
410 disabled_by_env(),
411 disabled,
412 "MAGI_NO_AUTOUPDATE={value:?} should {} disable",
413 if disabled { "" } else { "not" }
414 );
415 }
416 unsafe {
417 std::env::remove_var(NO_AUTOUPDATE_ENV);
418 }
419 }
420
421 #[test]
422 fn off_mode_never_spawns() {
423 let rt = tokio::runtime::Builder::new_current_thread()
424 .enable_all()
425 .build()
426 .unwrap();
427 let cfg = Update {
428 mode: UpdateMode::Off,
429 interval: None,
430 };
431 assert!(spawn(&cfg, rt.handle()).is_none());
432 }
433
434 #[test]
435 fn state_path_lives_under_the_cache_dir() {
436 let path = state_path().expect("a cache dir on every supported platform");
437 assert!(path.ends_with("magi/last_update_check.json"));
438 let data = dirs::data_local_dir().unwrap_or_default();
439 assert!(
440 !path.starts_with(&data) || dirs::cache_dir() == dirs::data_local_dir(),
441 "throttle state must not sit in the run history directory"
442 );
443 }
444
445 #[tokio::test]
446 async fn finalize_of_nothing_is_a_no_op() {
447 finalize(None, Duration::from_millis(1)).await;
448 }
449
450 #[test]
460 fn checking_is_off_for_every_caller_when_the_config_says_off() {
461 assert!(
462 Checker::new(&Update {
463 mode: UpdateMode::Off,
464 interval: None,
465 })
466 .is_none(),
467 "an operator who writes mode = \"off\" means it"
468 );
469 for mode in [UpdateMode::Notify, UpdateMode::Install] {
470 assert!(
471 Checker::new(&Update {
472 mode,
473 interval: None,
474 })
475 .is_some(),
476 "{mode:?} still asks the forge"
477 );
478 }
479 }
480
481 #[test]
492 fn cached_update_answers_from_disk_with_no_network_call() {
493 let dir = tempfile::tempdir().expect("temp dir");
494 let path = dir.path().join("state.json");
495 let opts = kaishin::KaishinOptions::new("yukimemi", "magi", "magi", "0.1.0");
496 let checker = Checker {
497 inner: kaishin::Checker::new("magi", opts).state_path(path.clone()),
498 };
499
500 assert!(
501 checker.cached_update().is_none(),
502 "no state file yet must read as \"unknown\", not an error"
503 );
504
505 let state = kaishin::UpdateCheckState {
506 last_checked_unix: 0,
507 last_known_latest: Some("v9.9.9".to_owned()),
508 last_known_url: Some("https://example.invalid/9.9.9".to_owned()),
509 };
510 kaishin::save_check_state(&path, &state).expect("seed the state file");
511
512 let latest = checker.cached_update().expect("a newer release was cached");
513 assert_eq!(latest.tag_name, "v9.9.9");
514 }
515
516 #[test]
517 fn reconcile_after_restart_confirms_a_matching_version() {
518 let home = tempfile::tempdir().expect("temp home");
523 let mut progress = Progress::new(
524 "0.1.0".to_owned(),
525 format!("v{}", env!("CARGO_PKG_VERSION")),
526 );
527 progress.advance(Stage::Restarting);
528 write_progress(home.path(), &progress).expect("seed progress");
529
530 reconcile_after_restart(home.path());
531
532 let after = read_progress(home.path()).expect("progress on disk");
533 assert_eq!(
534 after.stage,
535 Stage::Done,
536 "the successor is running exactly the release that was asked for, \
537 `v` prefix and all"
538 );
539 }
540
541 #[test]
542 fn reconcile_after_restart_flags_a_mismatched_version() {
543 let home = tempfile::tempdir().expect("temp home");
544 let mut progress = Progress::new("0.1.0".to_owned(), "v9.9.9".to_owned());
545 progress.advance(Stage::Restarting);
546 write_progress(home.path(), &progress).expect("seed progress");
547
548 reconcile_after_restart(home.path());
549
550 let after = read_progress(home.path()).expect("progress on disk");
551 assert_eq!(after.stage, Stage::Failed);
552 assert!(
553 after.detail.is_some_and(|d| d.contains("9.9.9")),
554 "the operator needs to know which release it did not come back on"
555 );
556 }
557
558 #[test]
559 fn reconcile_after_restart_leaves_a_settled_record_alone() {
560 let home = tempfile::tempdir().expect("temp home");
561 let mut progress = Progress::new("0.1.0".to_owned(), "9.9.9".to_owned());
562 progress.advance(Stage::Done);
563 write_progress(home.path(), &progress).expect("seed progress");
564
565 reconcile_after_restart(home.path());
566
567 let after = read_progress(home.path()).expect("progress on disk");
568 assert_eq!(
569 after.stage,
570 Stage::Done,
571 "an already-settled record must not be rewritten by a later, unrelated start"
572 );
573 }
574
575 #[test]
576 fn reconcile_after_restart_with_nothing_on_disk_is_a_quiet_no_op() {
577 let home = tempfile::tempdir().expect("temp home");
578 reconcile_after_restart(home.path());
579 assert!(read_progress(home.path()).is_none());
580 }
581}