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 fn cached_update(&self) -> Option<kaishin::LatestRelease> {
123 self.inner.cached_update()
124 }
125
126 pub fn format_banner(&self, latest: &kaishin::LatestRelease) -> String {
128 self.inner.format_banner(latest)
129 }
130}
131
132pub fn spawn(cfg: &Update, rt: &tokio::runtime::Handle) -> Option<Pending> {
134 if disabled_by_env() || cfg.mode == UpdateMode::Off {
135 return None;
136 }
137 let checker = Checker::new(cfg)?;
138 match cfg.mode {
139 UpdateMode::Off => None,
140 UpdateMode::Notify => {
141 if !checker.should_check() {
142 let latest = checker.cached_update()?;
143 return Some(Pending::Cached { checker, latest });
144 }
145 let inner = checker.inner.clone();
146 let handle = rt.spawn(async move { inner.check_and_save().await });
147 Some(Pending::Notify { checker, handle })
148 }
149 UpdateMode::Install => {
150 let inner = checker.inner.clone();
151 let handle = rt.spawn(async move { inner.auto_update().await });
152 Some(Pending::Install { handle })
153 }
154 }
155}
156
157pub async fn finalize(pending: Option<Pending>, budget: Duration) {
162 let Some(pending) = pending else {
163 return;
164 };
165 match pending {
166 Pending::Cached { checker, latest } => {
167 eprintln!("{}", checker.format_banner(&latest));
168 }
169 Pending::Notify { checker, handle } => {
170 if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
171 eprintln!("{}", checker.format_banner(&latest));
172 }
173 }
174 Pending::Install { handle } => {
175 if let Ok(Ok(Ok(Some(latest)))) = tokio::time::timeout(budget, handle).await {
176 eprintln!("magi updated itself to {}", latest.tag_name);
177 }
178 }
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185
186 #[test]
187 fn env_kill_switch_semantics() {
188 unsafe {
190 std::env::remove_var(NO_AUTOUPDATE_ENV);
191 }
192 assert!(!disabled_by_env());
193 for (value, disabled) in [
194 ("1", true),
195 ("true", true),
196 ("yes", true),
197 ("0", false),
198 ("false", false),
199 ("FALSE", false),
200 ("", false),
201 (" ", false),
202 ] {
203 unsafe {
204 std::env::set_var(NO_AUTOUPDATE_ENV, value);
205 }
206 assert_eq!(
207 disabled_by_env(),
208 disabled,
209 "MAGI_NO_AUTOUPDATE={value:?} should {} disable",
210 if disabled { "" } else { "not" }
211 );
212 }
213 unsafe {
214 std::env::remove_var(NO_AUTOUPDATE_ENV);
215 }
216 }
217
218 #[test]
219 fn off_mode_never_spawns() {
220 let rt = tokio::runtime::Builder::new_current_thread()
221 .enable_all()
222 .build()
223 .unwrap();
224 let cfg = Update {
225 mode: UpdateMode::Off,
226 interval: None,
227 };
228 assert!(spawn(&cfg, rt.handle()).is_none());
229 }
230
231 #[test]
232 fn state_path_lives_under_the_cache_dir() {
233 let path = state_path().expect("a cache dir on every supported platform");
234 assert!(path.ends_with("magi/last_update_check.json"));
235 let data = dirs::data_local_dir().unwrap_or_default();
236 assert!(
237 !path.starts_with(&data) || dirs::cache_dir() == dirs::data_local_dir(),
238 "throttle state must not sit in the run history directory"
239 );
240 }
241
242 #[tokio::test]
243 async fn finalize_of_nothing_is_a_no_op() {
244 finalize(None, Duration::from_millis(1)).await;
245 }
246}