rtb-update 0.7.1

Self-update subsystem composing on rtb-forge to fetch signed release assets and atomically swap the running binary. Part of the phpboyscout Rust toolkit.
Documentation
//! Pre-run self-update hook.
//!
//! Registers a [`rtb_app::command::BUILTIN_PRERUN_HOOKS`] entry that runs
//! the throttled [`crate::policy`] check before every command dispatch (the
//! application run loop awaits it). This keeps `rtb-cli` decoupled from
//! `rtb-update`: the binary links this crate, the hook self-registers.
//!
//! Behaviour by policy (after the throttle + version check in
//! [`crate::policy::evaluate`]):
//! - **`Disabled`** (default) — the hook returns immediately; zero overhead.
//! - **`Prompt`** — on a newer release, print a notice; check failures are
//!   non-fatal (advisory — they must not block the user's command).
//! - **`Enabled`** — on a newer release, update before running; a check or
//!   update failure aborts the run with a diagnostic.
//!
//! Gating: the hook is inert unless `release_source` is set and the policy
//! is not `Disabled`. (The runtime `Feature::Update` flag is not reachable
//! here — `App` does not carry the active `Features` — so feature-level
//! gating is a documented follow-up; in practice the `Disabled` default
//! makes the hook a no-op for tools that have not opted in.)
//!
//! See `docs/development/specs/2026-06-23-rtb-update-policy-v0.1.md`.

use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};

use linkme::distributed_slice;
use miette::{miette, IntoDiagnostic};
use rtb_app::app::App;
use rtb_app::command::{PreRunFuture, BUILTIN_PRERUN_HOOKS};

use crate::options::RunOptions;
use crate::policy::{evaluate, PolicyDecision, UpdatePolicy};
use crate::updater::Updater;

/// Registration of the self-update policy check into the framework's
/// pre-run hook slice.
#[distributed_slice(BUILTIN_PRERUN_HOOKS)]
static UPDATE_POLICY_HOOK: fn(App) -> PreRunFuture = |app| Box::pin(run(app));

/// Current Unix timestamp in seconds (saturates to `0` if the clock is
/// before the epoch — fail-open; the throttle treats `0` as "never").
fn current_unix() -> i64 {
    SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
}

/// The throttle-state path: `<config_dir>/<tool>/update.toml`, beside the
/// telemetry `consent.toml` (same `ProjectDirs` qualifier as `rtb-cli`).
fn state_path(app: &App) -> miette::Result<PathBuf> {
    let dirs = directories::ProjectDirs::from("dev", "", &app.metadata.name)
        .ok_or_else(|| miette!("update: could not resolve a config directory for state"))?;
    Ok(dirs.config_dir().join("update.toml"))
}

/// The hook body. Gated, throttled, and policy-aware (see module docs).
async fn run(app: App) -> miette::Result<()> {
    let policy = app.metadata.update_policy;
    if policy == UpdatePolicy::Disabled || app.metadata.release_source.is_none() {
        return Ok(());
    }

    let interval = app.metadata.update_check_interval;
    let path = state_path(&app)?;
    let now = current_unix();
    let current = app.version.version.clone();

    let provider = match crate::command::build_provider(&app).await {
        Ok(p) => p,
        // Provider construction failing is advisory under Prompt (don't
        // block the command); fatal under Enabled (the policy promised an
        // up-to-date binary).
        Err(e) if policy == UpdatePolicy::Prompt => {
            tracing::debug!(error = %e, "update policy: provider unavailable; skipping check");
            return Ok(());
        }
        Err(e) => return Err(e),
    };

    let decision = match evaluate(&*provider, &current, policy, interval, &path, now).await {
        Ok(d) => d,
        Err(e) if policy == UpdatePolicy::Prompt => {
            tracing::debug!(error = %e, "update policy: check failed; skipping");
            return Ok(());
        }
        Err(e) => return Err(e).into_diagnostic(),
    };

    let PolicyDecision::UpdateAvailable { current, latest, .. } = decision else {
        return Ok(());
    };

    match policy {
        UpdatePolicy::Prompt => {
            eprintln!("{}: a newer version is available: {current} -> {latest}", app.metadata.name);
            eprintln!("  run `{} update run` to install", app.metadata.name);
            Ok(())
        }
        UpdatePolicy::Enabled => {
            eprintln!("{}: updating {current} -> {latest} before running…", app.metadata.name);
            let updater = Updater::builder().app(&app).provider(provider).build();
            updater.run(RunOptions::default()).await.into_diagnostic()?;
            Ok(())
        }
        // Disabled was short-circuited at the top.
        UpdatePolicy::Disabled => Ok(()),
    }
}

#[cfg(test)]
mod tests {
    use super::{current_unix, run, state_path};
    use rtb_app::app::App;
    use rtb_app::metadata::{ToolMetadata, UpdatePolicy};
    use rtb_app::version::VersionInfo;

    fn app_with(policy: UpdatePolicy, with_source: bool) -> App {
        let source = with_source.then(|| rtb_app::metadata::ReleaseSource::Gitlab {
            project: "owner/repo".into(),
            host: "gitlab.com".into(),
        });
        let metadata = ToolMetadata::builder()
            .name("hooktool")
            .summary("s")
            .update_policy(policy)
            .maybe_release_source(source)
            .build();
        let version = VersionInfo {
            version: semver::Version::parse("1.0.0").unwrap(),
            commit: None,
            date: None,
        };
        App::for_testing(metadata, version)
    }

    #[tokio::test]
    async fn disabled_is_a_noop() {
        // Disabled + a release source → returns immediately, no network.
        run(app_with(UpdatePolicy::Disabled, true)).await.expect("disabled is inert");
    }

    #[tokio::test]
    async fn no_release_source_is_a_noop() {
        // Prompt but no release source → nothing to check; returns Ok.
        run(app_with(UpdatePolicy::Prompt, false)).await.expect("no source is inert");
    }

    #[test]
    fn state_path_is_beside_consent() {
        let path = state_path(&app_with(UpdatePolicy::Prompt, true)).expect("state path");
        assert!(path.ends_with("update.toml"));
        assert!(path.to_string_lossy().contains("hooktool"));
    }

    #[test]
    fn current_unix_is_positive() {
        assert!(current_unix() > 0);
    }
}