Skip to main content

rtb_update/
policy.rs

1//! Self-update **policy** — synchronous, throttled pre-run update checks.
2//!
3//! User-initiated `update` (the subcommand) is always available; this
4//! module governs the *automatic* check a tool may run at the start of an
5//! invocation, per its [`UpdatePolicy`] baseline. The check is throttled by
6//! a persisted last-check timestamp so it runs at most once per
7//! `update_check_interval`. This is **not** a background daemon — it is a
8//! single synchronous check before command dispatch.
9//!
10//! The engine here is deliberately decoupled from the [`crate::Updater`]:
11//! it queries a [`ReleaseProvider`] and reports a [`PolicyDecision`]; the
12//! caller (the application pre-run hook) acts on it per policy — `Enabled`
13//! performs the update before running, `Prompt` notifies the user.
14//!
15//! See `docs/development/specs/2026-06-23-rtb-update-policy-v0.1.md`.
16
17use std::path::Path;
18use std::time::Duration;
19
20use rtb_forge::{Release, ReleaseProvider};
21use semver::Version;
22use serde::{Deserialize, Serialize};
23use tracing::debug;
24
25pub use rtb_app::metadata::UpdatePolicy;
26
27use crate::error::Result;
28
29/// Persisted throttle state — when the last automatic check ran and the
30/// newest version it observed. Stored as TOML beside the tool's
31/// `consent.toml` (the caller supplies the path).
32#[derive(Debug, Clone, Default, Serialize, Deserialize)]
33pub struct UpdateState {
34    /// Unix timestamp (seconds) of the last automatic check; `0` = never.
35    #[serde(default)]
36    pub last_check_unix: i64,
37    /// The newest version string seen at the last check, if any.
38    #[serde(default)]
39    pub last_seen: Option<String>,
40}
41
42impl UpdateState {
43    /// Load state from `path`. **Fail-open**: a missing, unreadable, or
44    /// malformed file yields the default (never-checked) state, so a
45    /// corrupt state file degrades to "check now", never a hard error.
46    #[must_use]
47    pub fn load(path: &Path) -> Self {
48        std::fs::read_to_string(path)
49            .ok()
50            .and_then(|text| toml::from_str(&text).ok())
51            .unwrap_or_default()
52    }
53
54    /// Whether a check is due: `true` if at least `interval` has elapsed
55    /// since the last check (or it never ran). A negative clock delta
56    /// (clock moved backwards) fails open — due.
57    #[must_use]
58    pub const fn is_due(&self, interval: Duration, now_unix: i64) -> bool {
59        let elapsed = now_unix - self.last_check_unix;
60        elapsed < 0 || elapsed.unsigned_abs() >= interval.as_secs()
61    }
62
63    /// Persist `self` to `path`, best-effort. Creates parent dirs; a write
64    /// or serialise failure is logged at debug and swallowed — throttling
65    /// is an optimisation, never a correctness gate.
66    pub fn save(&self, path: &Path) {
67        if let Some(parent) = path.parent() {
68            let _ = std::fs::create_dir_all(parent);
69        }
70        match toml::to_string(self) {
71            Ok(text) => {
72                if let Err(e) = std::fs::write(path, text) {
73                    debug!(error = %e, "failed to persist update-check state");
74                }
75            }
76            Err(e) => debug!(error = %e, "failed to serialise update-check state"),
77        }
78    }
79}
80
81/// What the pre-run policy check determined.
82#[derive(Debug)]
83pub enum PolicyDecision {
84    /// Nothing to do — policy `Disabled`, throttled within the interval,
85    /// already up to date, or the running version is newer than the latest
86    /// release.
87    NoAction,
88    /// A newer release is available. The caller acts per policy
89    /// (`Enabled` → update before running; `Prompt` → notify the user).
90    UpdateAvailable {
91        /// The running version.
92        current: Version,
93        /// The newer released version.
94        latest: Version,
95        /// Release metadata (assets, tag) for a subsequent update.
96        release: Box<Release>,
97    },
98}
99
100/// Run the synchronous, throttled pre-run update check.
101///
102/// Returns [`PolicyDecision::NoAction`] immediately when `policy` is
103/// [`UpdatePolicy::Disabled`] (zero overhead) or when the last check was
104/// within `interval` of `now_unix`. Otherwise queries `provider`, records
105/// the check in the state file regardless of outcome (so the throttle
106/// advances), and reports whether a newer release is available.
107///
108/// `now_unix` is the current Unix timestamp in seconds, injected so the
109/// throttle is deterministic in tests.
110///
111/// # Errors
112///
113/// Propagates a provider error from [`ReleaseProvider::latest_release`].
114pub async fn evaluate(
115    provider: &dyn ReleaseProvider,
116    current: &Version,
117    policy: UpdatePolicy,
118    interval: Duration,
119    state_path: &Path,
120    now_unix: i64,
121) -> Result<PolicyDecision> {
122    if policy == UpdatePolicy::Disabled {
123        return Ok(PolicyDecision::NoAction);
124    }
125
126    let state = UpdateState::load(state_path);
127    if !state.is_due(interval, now_unix) {
128        debug!("automatic update check throttled");
129        return Ok(PolicyDecision::NoAction);
130    }
131
132    let release = provider.latest_release().await?;
133    let latest = parse_tag(&release.tag);
134
135    // Record the check regardless of the result so the throttle advances.
136    UpdateState { last_check_unix: now_unix, last_seen: latest.as_ref().map(ToString::to_string) }
137        .save(state_path);
138
139    match latest {
140        Some(latest) if latest > *current => Ok(PolicyDecision::UpdateAvailable {
141            current: current.clone(),
142            latest,
143            release: Box::new(release),
144        }),
145        _ => Ok(PolicyDecision::NoAction),
146    }
147}
148
149/// Parse a release tag (`v1.2.3` or `1.2.3`) into a [`Version`].
150fn parse_tag(tag: &str) -> Option<Version> {
151    Version::parse(tag.trim_start_matches(['v', 'V'])).ok()
152}
153
154#[cfg(test)]
155mod tests {
156    use super::{evaluate, parse_tag, PolicyDecision, UpdatePolicy, UpdateState};
157    use async_trait::async_trait;
158    use rtb_forge::release::ProviderError;
159    use rtb_forge::{Release, ReleaseAsset, ReleaseProvider};
160    use semver::Version;
161    use std::path::Path;
162    use std::time::Duration;
163    use tokio::io::AsyncRead;
164
165    const DAY: Duration = Duration::from_secs(86_400);
166
167    struct StubProvider {
168        tag: String,
169        fail: bool,
170    }
171
172    impl StubProvider {
173        fn new(tag: &str) -> Self {
174            Self { tag: tag.into(), fail: false }
175        }
176    }
177
178    #[async_trait]
179    impl ReleaseProvider for StubProvider {
180        async fn latest_release(&self) -> Result<Release, ProviderError> {
181            if self.fail {
182                return Err(ProviderError::NotFound { what: "latest".into() });
183            }
184            Ok(Release::new(&self.tag, &self.tag, time::OffsetDateTime::UNIX_EPOCH))
185        }
186        async fn release_by_tag(&self, _tag: &str) -> Result<Release, ProviderError> {
187            unimplemented!("not used by the policy engine")
188        }
189        async fn list_releases(&self, _limit: usize) -> Result<Vec<Release>, ProviderError> {
190            unimplemented!("not used by the policy engine")
191        }
192        async fn download_asset(
193            &self,
194            _asset: &ReleaseAsset,
195        ) -> Result<(Box<dyn AsyncRead + Send + Unpin>, u64), ProviderError> {
196            unimplemented!("not used by the policy engine")
197        }
198    }
199
200    fn v(s: &str) -> Version {
201        Version::parse(s).unwrap()
202    }
203
204    #[test]
205    fn parse_tag_strips_v_prefix() {
206        assert_eq!(parse_tag("v1.2.3"), Some(v("1.2.3")));
207        assert_eq!(parse_tag("1.2.3"), Some(v("1.2.3")));
208        assert_eq!(parse_tag("nightly"), None);
209    }
210
211    #[test]
212    fn is_due_respects_interval() {
213        let s = UpdateState { last_check_unix: 1_000_000, last_seen: None };
214        // 1 hour later, 24h interval → not due.
215        assert!(!s.is_due(DAY, 1_000_000 + 3_600));
216        // 25 hours later → due.
217        assert!(s.is_due(DAY, 1_000_000 + 25 * 3_600));
218        // clock moved backwards → due (fail-open).
219        assert!(s.is_due(DAY, 999_000));
220        // never checked → due.
221        assert!(UpdateState::default().is_due(DAY, 1_000_000));
222    }
223
224    #[test]
225    fn load_is_fail_open() {
226        // Missing file → default.
227        assert_eq!(UpdateState::load(Path::new("/nonexistent/x.toml")).last_check_unix, 0);
228    }
229
230    #[tokio::test]
231    async fn disabled_short_circuits() {
232        let tmp = tempfile::tempdir().unwrap();
233        let state = tmp.path().join("update.toml");
234        let provider = StubProvider::new("v2.0.0");
235        let decision =
236            evaluate(&provider, &v("1.0.0"), UpdatePolicy::Disabled, DAY, &state, 1_000_000)
237                .await
238                .unwrap();
239        assert!(matches!(decision, PolicyDecision::NoAction));
240        // Disabled never touches the state file.
241        assert!(!state.exists());
242    }
243
244    #[tokio::test]
245    async fn throttled_within_interval() {
246        let tmp = tempfile::tempdir().unwrap();
247        let state_path = tmp.path().join("update.toml");
248        UpdateState { last_check_unix: 1_000_000, last_seen: None }.save(&state_path);
249        let provider = StubProvider::new("v2.0.0"); // newer, but throttled
250        let decision = evaluate(
251            &provider,
252            &v("1.0.0"),
253            UpdatePolicy::Enabled,
254            DAY,
255            &state_path,
256            1_000_000 + 3_600,
257        )
258        .await
259        .unwrap();
260        assert!(matches!(decision, PolicyDecision::NoAction));
261    }
262
263    #[tokio::test]
264    async fn newer_available_is_reported_and_recorded() {
265        let tmp = tempfile::tempdir().unwrap();
266        let state_path = tmp.path().join("update.toml");
267        let provider = StubProvider::new("v1.5.0");
268        let now = 2_000_000;
269        let decision =
270            evaluate(&provider, &v("1.0.0"), UpdatePolicy::Prompt, DAY, &state_path, now)
271                .await
272                .unwrap();
273        match decision {
274            PolicyDecision::UpdateAvailable { current, latest, .. } => {
275                assert_eq!(current, v("1.0.0"));
276                assert_eq!(latest, v("1.5.0"));
277            }
278            PolicyDecision::NoAction => panic!("expected UpdateAvailable"),
279        }
280        // The check is recorded so the next call throttles.
281        let recorded = UpdateState::load(&state_path);
282        assert_eq!(recorded.last_check_unix, now);
283        assert_eq!(recorded.last_seen.as_deref(), Some("1.5.0"));
284    }
285
286    #[tokio::test]
287    async fn up_to_date_is_no_action_but_records() {
288        let tmp = tempfile::tempdir().unwrap();
289        let state_path = tmp.path().join("update.toml");
290        let provider = StubProvider::new("v1.0.0");
291        let now = 3_000_000;
292        let decision =
293            evaluate(&provider, &v("1.0.0"), UpdatePolicy::Enabled, DAY, &state_path, now)
294                .await
295                .unwrap();
296        assert!(matches!(decision, PolicyDecision::NoAction));
297        assert_eq!(UpdateState::load(&state_path).last_check_unix, now);
298    }
299
300    #[tokio::test]
301    async fn older_release_is_no_action() {
302        let tmp = tempfile::tempdir().unwrap();
303        let state_path = tmp.path().join("update.toml");
304        let provider = StubProvider::new("v0.9.0");
305        let decision =
306            evaluate(&provider, &v("1.0.0"), UpdatePolicy::Enabled, DAY, &state_path, 4_000_000)
307                .await
308                .unwrap();
309        assert!(matches!(decision, PolicyDecision::NoAction));
310    }
311}