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;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct UpdateState {
#[serde(default)]
pub last_check_unix: i64,
#[serde(default)]
pub last_seen: Option<String>,
}
impl UpdateState {
#[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()
}
#[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()
}
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"),
}
}
}
#[derive(Debug)]
pub enum PolicyDecision {
NoAction,
UpdateAvailable {
current: Version,
latest: Version,
release: Box<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);
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),
}
}
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 };
assert!(!s.is_due(DAY, 1_000_000 + 3_600));
assert!(s.is_due(DAY, 1_000_000 + 25 * 3_600));
assert!(s.is_due(DAY, 999_000));
assert!(UpdateState::default().is_due(DAY, 1_000_000));
}
#[test]
fn load_is_fail_open() {
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));
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"); 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"),
}
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));
}
}