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 it.
98 /// Block on an ad-hoc tokio runtime — the caller is on a
99 /// regular OS thread spawned by `std::thread::spawn`.
100 pub fn check_and_save(&self) -> Result<kaishin::LatestRelease> {
101 let rt = make_runtime()?;
102 rt.block_on(async { self.inner.check_and_save().await })
103 }
104
105 /// Latest release known from the last successful check; `None`
106 /// if no check has ever completed.
107 pub fn cached_update(&self) -> Option<kaishin::LatestRelease> {
108 self.inner.cached_update()
109 }
110
111 /// Render the `A new version is available!` banner kaishin
112 /// ships out of the box.
113 pub fn format_banner(&self, latest: &kaishin::LatestRelease) -> String {
114 self.inner.format_banner(latest)
115 }
116}
117
118/// Handle for an ongoing or cached background update check.
119/// Mirrors renri's `AutoUpdateHandle`.
120pub enum AutoUpdateHandle {
121 /// A newer version was found in the local cache from a previous
122 /// run, and we don't need to hit GitHub again on this invocation.
123 CachedAvailable {
124 checker: Checker,
125 latest: kaishin::LatestRelease,
126 },
127 /// A background check is running on a worker thread; the receiver
128 /// hands the result back to the main thread at shutdown.
129 Pending {
130 checker: Checker,
131 rx: std::sync::mpsc::Receiver<Result<kaishin::LatestRelease>>,
132 cached_latest: Option<kaishin::LatestRelease>,
133 },
134}
135
136/// Spawn a background check on `std::thread::spawn` if the user
137/// hasn't opted out and the cache is older than the configured
138/// interval. The returned handle is consumed by
139/// [`finalize_auto_update_check`] at shutdown.
140///
141/// Source-repo discovery is best-effort: we read `[ui]` config to
142/// see whether the banner is disabled, but if the repo can't be
143/// located we just skip the banner rather than fail loudly. The
144/// banner is convenience; nothing else hangs off of it.
145pub fn maybe_spawn_auto_update_check(cli_source: Option<&Utf8Path>) -> Option<AutoUpdateHandle> {
146 let source = detect_source(cli_source)?;
147 let yui = YuiVars::detect(&source);
148 let loaded = config::load(&source, &yui).ok()?;
149 if !loaded.ui.auto_update_check {
150 return None;
151 }
152
153 // Surface a malformed `update_check_interval` rather than
154 // silently rolling it into the default. A typo here is exactly
155 // the sort of thing the user would want to know about; logging
156 // through `tracing::warn!` lets `-v` reveal it without crashing
157 // the rest of the command. (PR #76 review by coderabbitai.)
158 let interval = match loaded.ui.update_check_interval.as_deref() {
159 None => default_interval(),
160 Some(s) => match kaishin::parse_interval(s) {
161 Ok(d) => d,
162 Err(e) => {
163 tracing::warn!(
164 "invalid [ui] update_check_interval = {s:?} ({e}); \
165 falling back to default {:?}",
166 default_interval()
167 );
168 default_interval()
169 }
170 },
171 };
172
173 let checker = Checker::new().ok()?.interval(interval);
174
175 if !checker.should_check() {
176 if let Some(latest) = checker.cached_update() {
177 return Some(AutoUpdateHandle::CachedAvailable { checker, latest });
178 }
179 return None;
180 }
181
182 let cached_latest = checker.cached_update();
183 let (tx, rx) = std::sync::mpsc::channel();
184 let checker_clone = Checker::new().ok()?.interval(interval);
185 std::thread::spawn(move || {
186 let _ = tx.send(checker_clone.check_and_save());
187 });
188
189 Some(AutoUpdateHandle::Pending {
190 checker,
191 rx,
192 cached_latest,
193 })
194}
195
196/// Print the update banner (if any) before the binary exits. Waits
197/// up to one second for an in-flight background check to finish; on
198/// timeout, falls back to the previously-cached release so the user
199/// still gets the nudge. Skips the leading newline when the banner
200/// would be empty (e.g. kaishin returning a release that doesn't
201/// actually outrank the running version). (PR #76 review by
202/// gemini-code-assist.)
203pub fn finalize_auto_update_check(handle: AutoUpdateHandle) {
204 let (checker, latest) = match handle {
205 AutoUpdateHandle::CachedAvailable { checker, latest } => (checker, Some(latest)),
206 AutoUpdateHandle::Pending {
207 checker,
208 rx,
209 cached_latest,
210 } => {
211 let latest = rx
212 .recv_timeout(Duration::from_secs(1))
213 .ok()
214 .and_then(|r| r.ok())
215 .or(cached_latest);
216 (checker, latest)
217 }
218 };
219 if let Some(latest) = latest {
220 let banner = checker.format_banner(&latest);
221 if !banner.is_empty() {
222 eprintln!("\n{banner}");
223 }
224 }
225}
226
227/// Best-effort source-repo resolution for the banner path. Honors
228/// `--source` / `$YUI_SOURCE` first, then walks cwd ancestors
229/// looking for a `config.toml`. Skips the `~/dotfiles` fallback
230/// that `cmd::resolve_source` does — the banner shouldn't surprise
231/// users running `yui` from outside their dotfiles repo.
232fn detect_source(cli_source: Option<&Utf8Path>) -> Option<Utf8PathBuf> {
233 if let Some(s) = cli_source {
234 return Some(absolutize_best_effort(s));
235 }
236 if let Ok(s) = std::env::var("YUI_SOURCE") {
237 return Some(absolutize_best_effort(Utf8Path::new(&s)));
238 }
239 let cwd = current_dir()?;
240 for ancestor in cwd.ancestors() {
241 if ancestor.join("config.toml").is_file() {
242 return Some(ancestor.to_path_buf());
243 }
244 }
245 None
246}
247
248fn absolutize_best_effort(p: &Utf8Path) -> Utf8PathBuf {
249 let expanded = paths::expand_tilde(p.as_str());
250 if expanded.is_absolute() {
251 return expanded;
252 }
253 current_dir()
254 .map(|cwd| cwd.join(&expanded))
255 .unwrap_or(expanded)
256}
257
258fn current_dir() -> Option<Utf8PathBuf> {
259 let cwd = std::env::current_dir().ok()?;
260 Utf8PathBuf::from_path_buf(cwd).ok()
261}