yui/updater.rs
1//! Self-update support for yui, using the shared `kaishin` library.
2//!
3//! Thin sync facade around kaishin's async API so the rest of yui
4//! can stay synchronous. Same shape as renri's `src/updater.rs`;
5//! the only yui-specific bit is the hardcoded `(owner, repo, bin)`
6//! triple — yui's crate name is `yui-cli` (because crates.io's
7//! `yui` is taken by an unrelated abandoned crate), but the repo
8//! and binary are both `yui`, so going through `env!("CARGO_PKG_NAME")`
9//! the way renri does would produce the wrong GitHub Release URL.
10//!
11//! The module exposes two layers:
12//!
13//! - [`run_self_update`] — drives the `yui self-update` subcommand
14//! (interactive / `--yes` / `--check`).
15//! - [`Checker`] + [`maybe_spawn_auto_update_check`] /
16//! [`finalize_auto_update_check`] — the daily background banner
17//! shown after every other subcommand, the way `rvpm` / `renri`
18//! do it. `[ui] auto_update_check = false` in `config.toml` opts
19//! out, and `[ui] update_check_interval = "..."` overrides the
20//! default 24h cadence.
21
22use std::time::Duration;
23
24use anyhow::Result;
25use camino::{Utf8Path, Utf8PathBuf};
26
27use crate::config;
28use crate::paths;
29use crate::vars::YuiVars;
30
31const OWNER: &str = "yukimemi";
32const REPO: &str = "yui";
33const BIN: &str = "yui";
34
35fn kaishin_opts() -> kaishin::KaishinOptions {
36 kaishin::KaishinOptions::new(OWNER, REPO, BIN, env!("CARGO_PKG_VERSION"))
37}
38
39fn make_runtime() -> Result<tokio::runtime::Runtime> {
40 Ok(tokio::runtime::Builder::new_current_thread()
41 .enable_all()
42 .build()?)
43}
44
45/// Run `yui self-update`. Flags map directly onto kaishin's
46/// `UpdateOptions`:
47///
48/// - `yes` skips the confirmation prompt.
49/// - `check_only` reports availability and exits without installing.
50/// - `non_interactive` makes kaishin bail out (rather than prompt)
51/// when stdin isn't a tty; only meaningful together with `yes`.
52pub fn run_self_update(yes: bool, check_only: bool, non_interactive: bool) -> Result<()> {
53 let opts = kaishin_opts();
54 let upd_opts = kaishin::UpdateOptions::new()
55 .yes(yes)
56 .check_only(check_only)
57 .non_interactive(non_interactive);
58
59 let rt = make_runtime()?;
60 rt.block_on(async { kaishin::run_self_update(&opts, upd_opts).await })
61}
62
63/// Default interval between background update checks (24 hours).
64pub fn default_interval() -> Duration {
65 kaishin::default_interval()
66}
67
68/// Blocking facade over `kaishin::Checker` — yui's main loop is
69/// synchronous, so the async `check_and_save` call goes through a
70/// fresh `current_thread` runtime each time. Same shape as renri.
71pub struct Checker {
72 inner: kaishin::Checker,
73}
74
75impl Checker {
76 /// Build a checker pinned to yui's (owner, repo, bin) triple
77 /// and the running binary's version.
78 pub fn new() -> Result<Self> {
79 let inner = kaishin::Checker::new(BIN, kaishin_opts());
80 Ok(Self { inner })
81 }
82
83 /// Override the cadence between background checks. Pair with
84 /// `kaishin::parse_interval` when the value comes from the
85 /// `[ui] update_check_interval` config string.
86 pub fn interval(mut self, interval: Duration) -> Self {
87 self.inner = self.inner.interval(interval);
88 self
89 }
90
91 /// True if the on-disk cache is older than the configured
92 /// interval and we should fetch from GitHub again.
93 pub fn should_check(&self) -> bool {
94 self.inner.should_check()
95 }
96
97 /// Hit GitHub, write the result to the cache, and return the
98 /// release *only when it actually outranks the running binary*.
99 /// `Ok(None)` is "fetched fine, no update"; `Err` is "fetch
100 /// failed". Block on an ad-hoc tokio runtime — the caller is on
101 /// a regular OS thread spawned by `std::thread::spawn`.
102 pub fn check_and_save(&self) -> Result<Option<kaishin::LatestRelease>> {
103 let rt = make_runtime()?;
104 rt.block_on(async { self.inner.check_and_save().await })
105 }
106
107 /// Latest release known from the last successful check; `None`
108 /// if no check has ever completed.
109 pub fn cached_update(&self) -> Option<kaishin::LatestRelease> {
110 self.inner.cached_update()
111 }
112
113 /// Render the `A new version is available!` banner kaishin
114 /// ships out of the box.
115 pub fn format_banner(&self, latest: &kaishin::LatestRelease) -> String {
116 self.inner.format_banner(latest)
117 }
118}
119
120/// Handle for an ongoing or cached background update check.
121/// Mirrors renri's `AutoUpdateHandle`.
122pub enum AutoUpdateHandle {
123 /// A newer version was found in the local cache from a previous
124 /// run, and we don't need to hit GitHub again on this invocation.
125 CachedAvailable {
126 checker: Checker,
127 latest: kaishin::LatestRelease,
128 },
129 /// A background check is running on a worker thread; the receiver
130 /// hands the result back to the main thread at shutdown.
131 /// `Ok(Ok(None))` means "fetch succeeded, no update" — distinct
132 /// from a timeout/error case, where we may still want to fall back
133 /// to the cached release.
134 Pending {
135 checker: Checker,
136 rx: std::sync::mpsc::Receiver<Result<Option<kaishin::LatestRelease>>>,
137 cached_latest: Option<kaishin::LatestRelease>,
138 },
139}
140
141/// Spawn a background check on `std::thread::spawn` if the user
142/// hasn't opted out and the cache is older than the configured
143/// interval. The returned handle is consumed by
144/// [`finalize_auto_update_check`] at shutdown.
145///
146/// Source-repo discovery is best-effort: we read `[ui]` config to
147/// see whether the banner is disabled, but if the repo can't be
148/// located we just skip the banner rather than fail loudly. The
149/// banner is convenience; nothing else hangs off of it.
150pub fn maybe_spawn_auto_update_check(cli_source: Option<&Utf8Path>) -> Option<AutoUpdateHandle> {
151 let source = detect_source(cli_source)?;
152 let yui = YuiVars::detect(&source);
153 let loaded = config::load(&source, &yui).ok()?;
154 if !loaded.ui.auto_update_check {
155 return None;
156 }
157
158 // Surface a malformed `update_check_interval` rather than
159 // silently rolling it into the default. A typo here is exactly
160 // the sort of thing the user would want to know about; logging
161 // through `tracing::warn!` lets `-v` reveal it without crashing
162 // the rest of the command. (PR #76 review by coderabbitai.)
163 let interval = match loaded.ui.update_check_interval.as_deref() {
164 None => default_interval(),
165 Some(s) => match kaishin::parse_interval(s) {
166 Ok(d) => d,
167 Err(e) => {
168 tracing::warn!(
169 "invalid [ui] update_check_interval = {s:?} ({e}); \
170 falling back to default {:?}",
171 default_interval()
172 );
173 default_interval()
174 }
175 },
176 };
177
178 let checker = Checker::new().ok()?.interval(interval);
179
180 if !checker.should_check() {
181 if let Some(latest) = checker.cached_update() {
182 return Some(AutoUpdateHandle::CachedAvailable { checker, latest });
183 }
184 return None;
185 }
186
187 let cached_latest = checker.cached_update();
188 let (tx, rx) = std::sync::mpsc::channel();
189 let checker_clone = Checker::new().ok()?.interval(interval);
190 std::thread::spawn(move || {
191 let _ = tx.send(checker_clone.check_and_save());
192 });
193
194 Some(AutoUpdateHandle::Pending {
195 checker,
196 rx,
197 cached_latest,
198 })
199}
200
201/// Print the update banner (if any) before the binary exits. Waits
202/// up to one second for an in-flight background check to finish; on
203/// timeout, falls back to the previously-cached release so the user
204/// still gets the nudge.
205///
206/// kaishin 0.4 made `check_and_save` return `Result<Option<_>>`, so
207/// the "fetched, no update" case (`Ok(Ok(None))`) is now distinct
208/// from a timeout/error: in that case we skip the banner entirely
209/// instead of falling back to cache, since the cache can't be newer
210/// than the fresh fetch we just completed. Timeout/error still falls
211/// back to cache so a slow GitHub doesn't suppress the nudge.
212pub fn finalize_auto_update_check(handle: AutoUpdateHandle) {
213 let (checker, latest) = match handle {
214 AutoUpdateHandle::CachedAvailable { checker, latest } => (checker, Some(latest)),
215 AutoUpdateHandle::Pending {
216 checker,
217 rx,
218 cached_latest,
219 } => {
220 let latest = match rx.recv_timeout(Duration::from_secs(1)) {
221 Ok(Ok(Some(latest))) => Some(latest),
222 Ok(Ok(None)) => None,
223 _ => cached_latest,
224 };
225 (checker, latest)
226 }
227 };
228 if let Some(latest) = latest {
229 eprintln!("\n{}", checker.format_banner(&latest));
230 }
231}
232
233/// Best-effort source-repo resolution for the banner path. Honors
234/// `--source` / `$YUI_SOURCE` first, then walks cwd ancestors
235/// looking for a `config.toml`. Skips the `~/dotfiles` fallback
236/// that `cmd::resolve_source` does — the banner shouldn't surprise
237/// users running `yui` from outside their dotfiles repo.
238fn detect_source(cli_source: Option<&Utf8Path>) -> Option<Utf8PathBuf> {
239 if let Some(s) = cli_source {
240 return Some(absolutize_best_effort(s));
241 }
242 if let Ok(s) = std::env::var("YUI_SOURCE") {
243 return Some(absolutize_best_effort(Utf8Path::new(&s)));
244 }
245 let cwd = current_dir()?;
246 for ancestor in cwd.ancestors() {
247 if ancestor.join("config.toml").is_file() {
248 return Some(ancestor.to_path_buf());
249 }
250 }
251 None
252}
253
254fn absolutize_best_effort(p: &Utf8Path) -> Utf8PathBuf {
255 let expanded = paths::expand_tilde(p.as_str());
256 if expanded.is_absolute() {
257 return expanded;
258 }
259 current_dir()
260 .map(|cwd| cwd.join(&expanded))
261 .unwrap_or(expanded)
262}
263
264fn current_dir() -> Option<Utf8PathBuf> {
265 let cwd = std::env::current_dir().ok()?;
266 Utf8PathBuf::from_path_buf(cwd).ok()
267}