1use std::path::PathBuf;
8use std::time::Duration;
9
10use anyhow::Result;
11
12use crate::config::{Update, UpdateMode};
13
14pub const NO_AUTOUPDATE_ENV: &str = "MAGI_NO_AUTOUPDATE";
18
19pub fn default_interval() -> Duration {
21 kaishin::default_interval()
22}
23
24pub fn disabled_by_env() -> bool {
26 match std::env::var(NO_AUTOUPDATE_ENV) {
27 Ok(v) => {
28 let v = v.trim();
29 !(v.is_empty() || v == "0" || v.eq_ignore_ascii_case("false"))
30 }
31 Err(_) => false,
32 }
33}
34
35const OWNER: &str = "yukimemi";
37const REPO: &str = "magi";
39const BIN: &str = "magi";
41const CRATE: &str = "magi-cli";
43
44fn options() -> kaishin::KaishinOptions {
52 kaishin::KaishinOptions::new(OWNER, REPO, BIN, env!("CARGO_PKG_VERSION")).crate_name(CRATE)
53}
54
55fn state_path() -> Option<PathBuf> {
58 dirs::cache_dir().map(|d| d.join("magi").join("last_update_check.json"))
59}
60
61pub async fn run_self_update(yes: bool, check_only: bool, non_interactive: bool) -> Result<()> {
63 let opts = kaishin::UpdateOptions::new()
64 .yes(yes)
65 .check_only(check_only)
66 .non_interactive(non_interactive);
67 kaishin::run_self_update(&options(), opts).await
68}
69
70pub enum Pending {
72 Cached {
74 checker: Checker,
76 latest: kaishin::LatestRelease,
78 },
79 Notify {
81 checker: Checker,
83 handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
85 },
86 Install {
88 handle: tokio::task::JoinHandle<Result<Option<kaishin::LatestRelease>>>,
90 },
91}
92
93#[derive(Clone)]
95pub struct Checker {
96 inner: kaishin::Checker,
97}
98
99impl Checker {
100 pub fn new(cfg: &Update) -> Option<Self> {
102 let mut inner = kaishin::Checker::new(BIN, options());
103 if let Some(path) = state_path() {
104 inner = inner.state_path(path);
105 }
106 let interval = cfg
107 .interval
108 .as_deref()
109 .and_then(|s| kaishin::parse_interval(s).ok())
110 .unwrap_or_else(default_interval);
111 Some(Self {
112 inner: inner.interval(interval),
113 })
114 }
115
116 pub fn should_check(&self) -> bool {
118 self.inner.should_check()
119 }
120
121 pub async fn newer_release(&self) -> Result<Option<kaishin::LatestRelease>> {
128 self.inner.check_and_save().await
129 }
130
131 pub fn cached_update(&self) -> Option<kaishin::LatestRelease> {
133 self.inner.cached_update()
134 }
135
136 pub fn format_banner(&self, latest: &kaishin::LatestRelease) -> String {
138 self.inner.format_banner(latest)
139 }
140}
141
142pub fn spawn(cfg: &Update, rt: &tokio::runtime::Handle) -> Option<Pending> {
144 if disabled_by_env() || cfg.mode == UpdateMode::Off {
145 return None;
146 }
147 let checker = Checker::new(cfg)?;
148 match cfg.mode {
149 UpdateMode::Off => None,
150 UpdateMode::Notify => {
151 if !checker.should_check() {
152 let latest = checker.cached_update()?;
153 return Some(Pending::Cached { checker, latest });
154 }
155 let inner = checker.inner.clone();
156 let handle = rt.spawn(async move { inner.check_and_save().await });
157 Some(Pending::Notify { checker, handle })
158 }
159 UpdateMode::Install => {
160 let inner = checker.inner.clone();
161 let handle = rt.spawn(async move { inner.auto_update().await });
162 Some(Pending::Install { handle })
163 }
164 }
165}
166
167pub async fn finalize(pending: Option<Pending>, budget: Duration) {
172 let Some(pending) = pending else {
173 return;
174 };
175 match pending {
176 Pending::Cached { checker, latest } => {
177 eprintln!("{}", checker.format_banner(&latest));
178 }
179 Pending::Notify { checker, handle } => {
180 if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
181 eprintln!("{}", checker.format_banner(&latest));
182 }
183 }
184 Pending::Install { handle } => {
185 if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
186 eprintln!("magi updated itself to {}", latest.tag_name);
187 }
188 }
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn env_kill_switch_semantics() {
198 unsafe {
200 std::env::remove_var(NO_AUTOUPDATE_ENV);
201 }
202 assert!(!disabled_by_env());
203 for (value, disabled) in [
204 ("1", true),
205 ("true", true),
206 ("yes", true),
207 ("0", false),
208 ("false", false),
209 ("FALSE", false),
210 ("", false),
211 (" ", false),
212 ] {
213 unsafe {
214 std::env::set_var(NO_AUTOUPDATE_ENV, value);
215 }
216 assert_eq!(
217 disabled_by_env(),
218 disabled,
219 "MAGI_NO_AUTOUPDATE={value:?} should {} disable",
220 if disabled { "" } else { "not" }
221 );
222 }
223 unsafe {
224 std::env::remove_var(NO_AUTOUPDATE_ENV);
225 }
226 }
227
228 #[test]
229 fn off_mode_never_spawns() {
230 let rt = tokio::runtime::Builder::new_current_thread()
231 .enable_all()
232 .build()
233 .unwrap();
234 let cfg = Update {
235 mode: UpdateMode::Off,
236 interval: None,
237 };
238 assert!(spawn(&cfg, rt.handle()).is_none());
239 }
240
241 #[test]
242 fn state_path_lives_under_the_cache_dir() {
243 let path = state_path().expect("a cache dir on every supported platform");
244 assert!(path.ends_with("magi/last_update_check.json"));
245 let data = dirs::data_local_dir().unwrap_or_default();
246 assert!(
247 !path.starts_with(&data) || dirs::cache_dir() == dirs::data_local_dir(),
248 "throttle state must not sit in the run history directory"
249 );
250 }
251
252 #[tokio::test]
253 async fn finalize_of_nothing_is_a_no_op() {
254 finalize(None, Duration::from_millis(1)).await;
255 }
256}