1use 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
33pub struct UpdateState {
34 #[serde(default)]
36 pub last_check_unix: i64,
37 #[serde(default)]
39 pub last_seen: Option<String>,
40}
41
42impl UpdateState {
43 #[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 #[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 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#[derive(Debug)]
83pub enum PolicyDecision {
84 NoAction,
88 UpdateAvailable {
91 current: Version,
93 latest: Version,
95 release: Box<Release>,
97 },
98}
99
100pub 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 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
149fn 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 assert!(!s.is_due(DAY, 1_000_000 + 3_600));
216 assert!(s.is_due(DAY, 1_000_000 + 25 * 3_600));
218 assert!(s.is_due(DAY, 999_000));
220 assert!(UpdateState::default().is_due(DAY, 1_000_000));
222 }
223
224 #[test]
225 fn load_is_fail_open() {
226 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 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"); 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 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}