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
//! Self-update **policy** — synchronous, throttled pre-run update checks.
//!
//! User-initiated `update` (the subcommand) is always available; this
//! module governs the *automatic* check a tool may run at the start of an
//! invocation, per its [`UpdatePolicy`] baseline. The check is throttled by
//! a persisted last-check timestamp so it runs at most once per
//! `update_check_interval`. This is **not** a background daemon — it is a
//! single synchronous check before command dispatch.
//!
//! The engine here is deliberately decoupled from the [`crate::Updater`]:
//! it queries a [`ReleaseProvider`] and reports a [`PolicyDecision`]; the
//! caller (the application pre-run hook) acts on it per policy — `Enabled`
//! performs the update before running, `Prompt` notifies the user.
//!
//! See `docs/development/specs/2026-06-23-rtb-update-policy-v0.1.md`.

use std::path::Path;
use std::time::Duration;

use rtb_forge::{Release, ReleaseProvider};
use semver::Version;
use serde::{Deserialize, Serialize};
use tracing::debug;

pub use rtb_app::metadata::UpdatePolicy;

use crate::error::Result;

/// Persisted throttle state — when the last automatic check ran and the
/// newest version it observed. Stored as TOML beside the tool's
/// `consent.toml` (the caller supplies the path).
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdateState {
    /// Unix timestamp (seconds) of the last automatic check; `0` = never.
    #[serde(default)]
    pub last_check_unix: i64,
    /// The newest version string seen at the last check, if any.
    #[serde(default)]
    pub last_seen: Option<String>,
}

impl UpdateState {
    /// Load state from `path`. **Fail-open**: a missing, unreadable, or
    /// malformed file yields the default (never-checked) state, so a
    /// corrupt state file degrades to "check now", never a hard error.
    #[must_use]
    pub fn load(path: &Path) -> Self {
        std::fs::read_to_string(path)
            .ok()
            .and_then(|text| toml::from_str(&text).ok())
            .unwrap_or_default()
    }

    /// Whether a check is due: `true` if at least `interval` has elapsed
    /// since the last check (or it never ran). A negative clock delta
    /// (clock moved backwards) fails open — due.
    #[must_use]
    pub const fn is_due(&self, interval: Duration, now_unix: i64) -> bool {
        let elapsed = now_unix - self.last_check_unix;
        elapsed < 0 || elapsed.unsigned_abs() >= interval.as_secs()
    }

    /// Persist `self` to `path`, best-effort. Creates parent dirs; a write
    /// or serialise failure is logged at debug and swallowed — throttling
    /// is an optimisation, never a correctness gate.
    pub fn save(&self, path: &Path) {
        if let Some(parent) = path.parent() {
            let _ = std::fs::create_dir_all(parent);
        }
        match toml::to_string(self) {
            Ok(text) => {
                if let Err(e) = std::fs::write(path, text) {
                    debug!(error = %e, "failed to persist update-check state");
                }
            }
            Err(e) => debug!(error = %e, "failed to serialise update-check state"),
        }
    }
}

/// What the pre-run policy check determined.
#[derive(Debug)]
pub enum PolicyDecision {
    /// Nothing to do — policy `Disabled`, throttled within the interval,
    /// already up to date, or the running version is newer than the latest
    /// release.
    NoAction,
    /// A newer release is available. The caller acts per policy
    /// (`Enabled` → update before running; `Prompt` → notify the user).
    UpdateAvailable {
        /// The running version.
        current: Version,
        /// The newer released version.
        latest: Version,
        /// Release metadata (assets, tag) for a subsequent update.
        release: Box<Release>,
    },
}

/// Run the synchronous, throttled pre-run update check.
///
/// Returns [`PolicyDecision::NoAction`] immediately when `policy` is
/// [`UpdatePolicy::Disabled`] (zero overhead) or when the last check was
/// within `interval` of `now_unix`. Otherwise queries `provider`, records
/// the check in the state file regardless of outcome (so the throttle
/// advances), and reports whether a newer release is available.
///
/// `now_unix` is the current Unix timestamp in seconds, injected so the
/// throttle is deterministic in tests.
///
/// # Errors
///
/// Propagates a provider error from [`ReleaseProvider::latest_release`].
pub async fn evaluate(
    provider: &dyn ReleaseProvider,
    current: &Version,
    policy: UpdatePolicy,
    interval: Duration,
    state_path: &Path,
    now_unix: i64,
) -> Result<PolicyDecision> {
    if policy == UpdatePolicy::Disabled {
        return Ok(PolicyDecision::NoAction);
    }

    let state = UpdateState::load(state_path);
    if !state.is_due(interval, now_unix) {
        debug!("automatic update check throttled");
        return Ok(PolicyDecision::NoAction);
    }

    let release = provider.latest_release().await?;
    let latest = parse_tag(&release.tag);

    // Record the check regardless of the result so the throttle advances.
    UpdateState { last_check_unix: now_unix, last_seen: latest.as_ref().map(ToString::to_string) }
        .save(state_path);

    match latest {
        Some(latest) if latest > *current => Ok(PolicyDecision::UpdateAvailable {
            current: current.clone(),
            latest,
            release: Box::new(release),
        }),
        _ => Ok(PolicyDecision::NoAction),
    }
}

/// Parse a release tag (`v1.2.3` or `1.2.3`) into a [`Version`].
fn parse_tag(tag: &str) -> Option<Version> {
    Version::parse(tag.trim_start_matches(['v', 'V'])).ok()
}

#[cfg(test)]
mod tests {
    use super::{evaluate, parse_tag, PolicyDecision, UpdatePolicy, UpdateState};
    use async_trait::async_trait;
    use rtb_forge::release::ProviderError;
    use rtb_forge::{Release, ReleaseAsset, ReleaseProvider};
    use semver::Version;
    use std::path::Path;
    use std::time::Duration;
    use tokio::io::AsyncRead;

    const DAY: Duration = Duration::from_secs(86_400);

    struct StubProvider {
        tag: String,
        fail: bool,
    }

    impl StubProvider {
        fn new(tag: &str) -> Self {
            Self { tag: tag.into(), fail: false }
        }
    }

    #[async_trait]
    impl ReleaseProvider for StubProvider {
        async fn latest_release(&self) -> Result<Release, ProviderError> {
            if self.fail {
                return Err(ProviderError::NotFound { what: "latest".into() });
            }
            Ok(Release::new(&self.tag, &self.tag, time::OffsetDateTime::UNIX_EPOCH))
        }
        async fn release_by_tag(&self, _tag: &str) -> Result<Release, ProviderError> {
            unimplemented!("not used by the policy engine")
        }
        async fn list_releases(&self, _limit: usize) -> Result<Vec<Release>, ProviderError> {
            unimplemented!("not used by the policy engine")
        }
        async fn download_asset(
            &self,
            _asset: &ReleaseAsset,
        ) -> Result<(Box<dyn AsyncRead + Send + Unpin>, u64), ProviderError> {
            unimplemented!("not used by the policy engine")
        }
    }

    fn v(s: &str) -> Version {
        Version::parse(s).unwrap()
    }

    #[test]
    fn parse_tag_strips_v_prefix() {
        assert_eq!(parse_tag("v1.2.3"), Some(v("1.2.3")));
        assert_eq!(parse_tag("1.2.3"), Some(v("1.2.3")));
        assert_eq!(parse_tag("nightly"), None);
    }

    #[test]
    fn is_due_respects_interval() {
        let s = UpdateState { last_check_unix: 1_000_000, last_seen: None };
        // 1 hour later, 24h interval → not due.
        assert!(!s.is_due(DAY, 1_000_000 + 3_600));
        // 25 hours later → due.
        assert!(s.is_due(DAY, 1_000_000 + 25 * 3_600));
        // clock moved backwards → due (fail-open).
        assert!(s.is_due(DAY, 999_000));
        // never checked → due.
        assert!(UpdateState::default().is_due(DAY, 1_000_000));
    }

    #[test]
    fn load_is_fail_open() {
        // Missing file → default.
        assert_eq!(UpdateState::load(Path::new("/nonexistent/x.toml")).last_check_unix, 0);
    }

    #[tokio::test]
    async fn disabled_short_circuits() {
        let tmp = tempfile::tempdir().unwrap();
        let state = tmp.path().join("update.toml");
        let provider = StubProvider::new("v2.0.0");
        let decision =
            evaluate(&provider, &v("1.0.0"), UpdatePolicy::Disabled, DAY, &state, 1_000_000)
                .await
                .unwrap();
        assert!(matches!(decision, PolicyDecision::NoAction));
        // Disabled never touches the state file.
        assert!(!state.exists());
    }

    #[tokio::test]
    async fn throttled_within_interval() {
        let tmp = tempfile::tempdir().unwrap();
        let state_path = tmp.path().join("update.toml");
        UpdateState { last_check_unix: 1_000_000, last_seen: None }.save(&state_path);
        let provider = StubProvider::new("v2.0.0"); // newer, but throttled
        let decision = evaluate(
            &provider,
            &v("1.0.0"),
            UpdatePolicy::Enabled,
            DAY,
            &state_path,
            1_000_000 + 3_600,
        )
        .await
        .unwrap();
        assert!(matches!(decision, PolicyDecision::NoAction));
    }

    #[tokio::test]
    async fn newer_available_is_reported_and_recorded() {
        let tmp = tempfile::tempdir().unwrap();
        let state_path = tmp.path().join("update.toml");
        let provider = StubProvider::new("v1.5.0");
        let now = 2_000_000;
        let decision =
            evaluate(&provider, &v("1.0.0"), UpdatePolicy::Prompt, DAY, &state_path, now)
                .await
                .unwrap();
        match decision {
            PolicyDecision::UpdateAvailable { current, latest, .. } => {
                assert_eq!(current, v("1.0.0"));
                assert_eq!(latest, v("1.5.0"));
            }
            PolicyDecision::NoAction => panic!("expected UpdateAvailable"),
        }
        // The check is recorded so the next call throttles.
        let recorded = UpdateState::load(&state_path);
        assert_eq!(recorded.last_check_unix, now);
        assert_eq!(recorded.last_seen.as_deref(), Some("1.5.0"));
    }

    #[tokio::test]
    async fn up_to_date_is_no_action_but_records() {
        let tmp = tempfile::tempdir().unwrap();
        let state_path = tmp.path().join("update.toml");
        let provider = StubProvider::new("v1.0.0");
        let now = 3_000_000;
        let decision =
            evaluate(&provider, &v("1.0.0"), UpdatePolicy::Enabled, DAY, &state_path, now)
                .await
                .unwrap();
        assert!(matches!(decision, PolicyDecision::NoAction));
        assert_eq!(UpdateState::load(&state_path).last_check_unix, now);
    }

    #[tokio::test]
    async fn older_release_is_no_action() {
        let tmp = tempfile::tempdir().unwrap();
        let state_path = tmp.path().join("update.toml");
        let provider = StubProvider::new("v0.9.0");
        let decision =
            evaluate(&provider, &v("1.0.0"), UpdatePolicy::Enabled, DAY, &state_path, 4_000_000)
                .await
                .unwrap();
        assert!(matches!(decision, PolicyDecision::NoAction));
    }
}