Skip to main content

rtb_update/
prerun.rs

1//! Pre-run self-update hook.
2//!
3//! Registers a [`rtb_app::command::BUILTIN_PRERUN_HOOKS`] entry that runs
4//! the throttled [`crate::policy`] check before every command dispatch (the
5//! application run loop awaits it). This keeps `rtb-cli` decoupled from
6//! `rtb-update`: the binary links this crate, the hook self-registers.
7//!
8//! Behaviour by policy (after the throttle + version check in
9//! [`crate::policy::evaluate`]):
10//! - **`Disabled`** (default) — the hook returns immediately; zero overhead.
11//! - **`Prompt`** — on a newer release, print a notice; check failures are
12//!   non-fatal (advisory — they must not block the user's command).
13//! - **`Enabled`** — on a newer release, update before running; a check or
14//!   update failure aborts the run with a diagnostic.
15//!
16//! Gating: the hook is inert unless `release_source` is set and the policy
17//! is not `Disabled`. (The runtime `Feature::Update` flag is not reachable
18//! here — `App` does not carry the active `Features` — so feature-level
19//! gating is a documented follow-up; in practice the `Disabled` default
20//! makes the hook a no-op for tools that have not opted in.)
21//!
22//! See `docs/development/specs/2026-06-23-rtb-update-policy-v0.1.md`.
23
24use std::path::PathBuf;
25use std::time::{SystemTime, UNIX_EPOCH};
26
27use linkme::distributed_slice;
28use miette::{miette, IntoDiagnostic};
29use rtb_app::app::App;
30use rtb_app::command::{PreRunFuture, BUILTIN_PRERUN_HOOKS};
31
32use crate::options::RunOptions;
33use crate::policy::{evaluate, PolicyDecision, UpdatePolicy};
34use crate::updater::Updater;
35
36/// Registration of the self-update policy check into the framework's
37/// pre-run hook slice.
38#[distributed_slice(BUILTIN_PRERUN_HOOKS)]
39static UPDATE_POLICY_HOOK: fn(App) -> PreRunFuture = |app| Box::pin(run(app));
40
41/// Current Unix timestamp in seconds (saturates to `0` if the clock is
42/// before the epoch — fail-open; the throttle treats `0` as "never").
43fn current_unix() -> i64 {
44    SystemTime::now()
45        .duration_since(UNIX_EPOCH)
46        .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
47}
48
49/// The throttle-state path: `<config_dir>/<tool>/update.toml`, beside the
50/// telemetry `consent.toml` (same `ProjectDirs` qualifier as `rtb-cli`).
51fn state_path(app: &App) -> miette::Result<PathBuf> {
52    let dirs = directories::ProjectDirs::from("dev", "", &app.metadata.name)
53        .ok_or_else(|| miette!("update: could not resolve a config directory for state"))?;
54    Ok(dirs.config_dir().join("update.toml"))
55}
56
57/// The hook body. Gated, throttled, and policy-aware (see module docs).
58async fn run(app: App) -> miette::Result<()> {
59    let policy = app.metadata.update_policy;
60    if policy == UpdatePolicy::Disabled || app.metadata.release_source.is_none() {
61        return Ok(());
62    }
63
64    let interval = app.metadata.update_check_interval;
65    let path = state_path(&app)?;
66    let now = current_unix();
67    let current = app.version.version.clone();
68
69    let provider = match crate::command::build_provider(&app).await {
70        Ok(p) => p,
71        // Provider construction failing is advisory under Prompt (don't
72        // block the command); fatal under Enabled (the policy promised an
73        // up-to-date binary).
74        Err(e) if policy == UpdatePolicy::Prompt => {
75            tracing::debug!(error = %e, "update policy: provider unavailable; skipping check");
76            return Ok(());
77        }
78        Err(e) => return Err(e),
79    };
80
81    let decision = match evaluate(&*provider, &current, policy, interval, &path, now).await {
82        Ok(d) => d,
83        Err(e) if policy == UpdatePolicy::Prompt => {
84            tracing::debug!(error = %e, "update policy: check failed; skipping");
85            return Ok(());
86        }
87        Err(e) => return Err(e).into_diagnostic(),
88    };
89
90    let PolicyDecision::UpdateAvailable { current, latest, .. } = decision else {
91        return Ok(());
92    };
93
94    match policy {
95        UpdatePolicy::Prompt => {
96            eprintln!("{}: a newer version is available: {current} -> {latest}", app.metadata.name);
97            eprintln!("  run `{} update run` to install", app.metadata.name);
98            Ok(())
99        }
100        UpdatePolicy::Enabled => {
101            eprintln!("{}: updating {current} -> {latest} before running…", app.metadata.name);
102            let updater = Updater::builder().app(&app).provider(provider).build();
103            updater.run(RunOptions::default()).await.into_diagnostic()?;
104            Ok(())
105        }
106        // Disabled was short-circuited at the top.
107        UpdatePolicy::Disabled => Ok(()),
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::{current_unix, run, state_path};
114    use rtb_app::app::App;
115    use rtb_app::metadata::{ToolMetadata, UpdatePolicy};
116    use rtb_app::version::VersionInfo;
117
118    fn app_with(policy: UpdatePolicy, with_source: bool) -> App {
119        let source = with_source.then(|| rtb_app::metadata::ReleaseSource::Gitlab {
120            project: "owner/repo".into(),
121            host: "gitlab.com".into(),
122        });
123        let metadata = ToolMetadata::builder()
124            .name("hooktool")
125            .summary("s")
126            .update_policy(policy)
127            .maybe_release_source(source)
128            .build();
129        let version = VersionInfo {
130            version: semver::Version::parse("1.0.0").unwrap(),
131            commit: None,
132            date: None,
133        };
134        App::for_testing(metadata, version)
135    }
136
137    #[tokio::test]
138    async fn disabled_is_a_noop() {
139        // Disabled + a release source → returns immediately, no network.
140        run(app_with(UpdatePolicy::Disabled, true)).await.expect("disabled is inert");
141    }
142
143    #[tokio::test]
144    async fn no_release_source_is_a_noop() {
145        // Prompt but no release source → nothing to check; returns Ok.
146        run(app_with(UpdatePolicy::Prompt, false)).await.expect("no source is inert");
147    }
148
149    #[test]
150    fn state_path_is_beside_consent() {
151        let path = state_path(&app_with(UpdatePolicy::Prompt, true)).expect("state path");
152        assert!(path.ends_with("update.toml"));
153        assert!(path.to_string_lossy().contains("hooktool"));
154    }
155
156    #[test]
157    fn current_unix_is_positive() {
158        assert!(current_unix() > 0);
159    }
160}