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> {
114 if cfg.mode == UpdateMode::Off {
115 return None;
116 }
117 let mut inner = kaishin::Checker::new(BIN, options());
118 if let Some(path) = state_path() {
119 inner = inner.state_path(path);
120 }
121 let interval = cfg
122 .interval
123 .as_deref()
124 .and_then(|s| kaishin::parse_interval(s).ok())
125 .unwrap_or_else(default_interval);
126 Some(Self {
127 inner: inner.interval(interval),
128 })
129 }
130
131 pub fn should_check(&self) -> bool {
133 self.inner.should_check()
134 }
135
136 pub async fn newer_release(&self) -> Result<Option<kaishin::LatestRelease>> {
143 self.inner.check_and_save().await
144 }
145
146 pub fn cached_update(&self) -> Option<kaishin::LatestRelease> {
148 self.inner.cached_update()
149 }
150
151 pub fn format_banner(&self, latest: &kaishin::LatestRelease) -> String {
153 self.inner.format_banner(latest)
154 }
155}
156
157pub fn spawn(cfg: &Update, rt: &tokio::runtime::Handle) -> Option<Pending> {
159 if disabled_by_env() || cfg.mode == UpdateMode::Off {
160 return None;
161 }
162 let checker = Checker::new(cfg)?;
163 match cfg.mode {
164 UpdateMode::Off => None,
165 UpdateMode::Notify => {
166 if !checker.should_check() {
167 let latest = checker.cached_update()?;
168 return Some(Pending::Cached { checker, latest });
169 }
170 let inner = checker.inner.clone();
171 let handle = rt.spawn(async move { inner.check_and_save().await });
172 Some(Pending::Notify { checker, handle })
173 }
174 UpdateMode::Install => {
175 let inner = checker.inner.clone();
176 let handle = rt.spawn(async move { inner.auto_update().await });
177 Some(Pending::Install { handle })
178 }
179 }
180}
181
182pub async fn finalize(pending: Option<Pending>, budget: Duration) {
187 let Some(pending) = pending else {
188 return;
189 };
190 match pending {
191 Pending::Cached { checker, latest } => {
192 eprintln!("{}", checker.format_banner(&latest));
193 }
194 Pending::Notify { checker, handle } => {
195 if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
196 eprintln!("{}", checker.format_banner(&latest));
197 }
198 }
199 Pending::Install { handle } => {
200 if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
201 eprintln!("magi updated itself to {}", latest.tag_name);
202 }
203 }
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210
211 #[test]
212 fn env_kill_switch_semantics() {
213 unsafe {
215 std::env::remove_var(NO_AUTOUPDATE_ENV);
216 }
217 assert!(!disabled_by_env());
218 for (value, disabled) in [
219 ("1", true),
220 ("true", true),
221 ("yes", true),
222 ("0", false),
223 ("false", false),
224 ("FALSE", false),
225 ("", false),
226 (" ", false),
227 ] {
228 unsafe {
229 std::env::set_var(NO_AUTOUPDATE_ENV, value);
230 }
231 assert_eq!(
232 disabled_by_env(),
233 disabled,
234 "MAGI_NO_AUTOUPDATE={value:?} should {} disable",
235 if disabled { "" } else { "not" }
236 );
237 }
238 unsafe {
239 std::env::remove_var(NO_AUTOUPDATE_ENV);
240 }
241 }
242
243 #[test]
244 fn off_mode_never_spawns() {
245 let rt = tokio::runtime::Builder::new_current_thread()
246 .enable_all()
247 .build()
248 .unwrap();
249 let cfg = Update {
250 mode: UpdateMode::Off,
251 interval: None,
252 };
253 assert!(spawn(&cfg, rt.handle()).is_none());
254 }
255
256 #[test]
257 fn state_path_lives_under_the_cache_dir() {
258 let path = state_path().expect("a cache dir on every supported platform");
259 assert!(path.ends_with("magi/last_update_check.json"));
260 let data = dirs::data_local_dir().unwrap_or_default();
261 assert!(
262 !path.starts_with(&data) || dirs::cache_dir() == dirs::data_local_dir(),
263 "throttle state must not sit in the run history directory"
264 );
265 }
266
267 #[tokio::test]
268 async fn finalize_of_nothing_is_a_no_op() {
269 finalize(None, Duration::from_millis(1)).await;
270 }
271
272 #[test]
282 fn checking_is_off_for_every_caller_when_the_config_says_off() {
283 assert!(
284 Checker::new(&Update {
285 mode: UpdateMode::Off,
286 interval: None,
287 })
288 .is_none(),
289 "an operator who writes mode = \"off\" means it"
290 );
291 for mode in [UpdateMode::Notify, UpdateMode::Install] {
292 assert!(
293 Checker::new(&Update {
294 mode,
295 interval: None,
296 })
297 .is_some(),
298 "{mode:?} still asks the forge"
299 );
300 }
301 }
302}