Skip to main content

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}