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 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}