hyperi_rustlib/version_check/
mod.rs1use std::time::Duration;
34
35use serde::{Deserialize, Serialize};
36
37const DEFAULT_API_URL: &str = "https://releases.hyperi.io/api/v1/check";
39
40const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
42
43#[derive(Debug, Clone, Deserialize, Serialize)]
58pub struct VersionCheckConfig {
59 #[serde(default)]
61 pub product: String,
62 #[serde(default)]
64 pub current_version: String,
65 #[serde(default)]
67 pub deployment: Option<String>,
68 #[serde(default = "default_api_url")]
70 pub api_url: String,
71 #[serde(default = "default_timeout", with = "duration_secs")]
73 pub timeout: Duration,
74 #[serde(default)]
76 pub disabled: bool,
77}
78
79fn default_api_url() -> String {
80 DEFAULT_API_URL.into()
81}
82
83fn default_timeout() -> Duration {
84 DEFAULT_TIMEOUT
85}
86
87mod duration_secs {
89 use std::time::Duration;
90
91 use serde::{Deserialize, Deserializer, Serializer};
92
93 pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
94 s.serialize_u64(d.as_secs())
95 }
96
97 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
98 let secs = u64::deserialize(d)?;
99 Ok(Duration::from_secs(secs))
100 }
101}
102
103impl Default for VersionCheckConfig {
104 fn default() -> Self {
105 Self {
106 product: String::new(),
107 current_version: String::new(),
108 deployment: None,
109 api_url: default_api_url(),
110 timeout: DEFAULT_TIMEOUT,
111 disabled: false,
112 }
113 }
114}
115
116impl VersionCheckConfig {
117 #[must_use]
124 pub fn from_cascade(product: &str, current_version: &str) -> Self {
125 let mut config = Self::cascade_base();
126 config.product = product.into();
127 config.current_version = current_version.into();
128 config
129 }
130
131 fn cascade_base() -> Self {
133 #[cfg(feature = "config")]
134 {
135 if let Some(cfg) = crate::config::try_get()
136 && let Ok(vc) = cfg.unmarshal_key_registered::<Self>("version_check")
137 {
138 return vc;
139 }
140 }
141 Self::default()
142 }
143}
144
145#[derive(Debug, Clone)]
151pub struct VersionCheck {
152 config: VersionCheckConfig,
153}
154
155impl VersionCheck {
156 #[must_use]
158 pub fn new(config: VersionCheckConfig) -> Self {
159 Self { config }
160 }
161
162 pub fn check_on_startup(&self) {
167 if self.config.disabled {
168 tracing::debug!("version check disabled");
169 return;
170 }
171
172 if self.config.product.is_empty() || self.config.current_version.is_empty() {
173 tracing::debug!("version check skipped: product or version not set");
174 return;
175 }
176
177 let config = self.config.clone();
178 tokio::spawn(async move {
179 match do_version_check(&config).await {
180 Ok(resp) => {
181 #[cfg(any(feature = "metrics", feature = "otel-metrics"))]
187 {
188 let result = if resp.update_available { "stale" } else { "ok" };
189 metrics::counter!("version_check_total", "result" => result).increment(1);
190 }
191 log_version_response(&config, &resp);
192 }
193 Err(e) => {
194 #[cfg(any(feature = "metrics", feature = "otel-metrics"))]
195 metrics::counter!("version_check_total", "result" => "error").increment(1);
196 tracing::warn!(error = %e, "version check failed (non-fatal)");
197 }
198 }
199 });
200 }
201}
202
203#[derive(Debug, Serialize)]
225struct CheckPayload {
226 product: String,
227 current_version: String,
228 #[serde(skip_serializing_if = "Option::is_none")]
229 os: Option<String>,
230 #[serde(skip_serializing_if = "Option::is_none")]
231 arch: Option<String>,
232}
233
234#[derive(Debug, Deserialize)]
236pub struct VersionCheckResponse {
237 pub latest_version: Option<String>,
239 pub update_available: bool,
241 pub release_url: Option<String>,
243 pub published_at: Option<String>,
245 pub message: Option<String>,
247}
248
249fn telemetry_opted_out() -> bool {
256 std::env::var("HYPERI_TELEMETRY").is_ok_and(|v| {
257 let l = v.to_ascii_lowercase();
258 matches!(l.as_str(), "off" | "0" | "false" | "no" | "disabled")
259 })
260}
261
262fn announce_once(config: &VersionCheckConfig) {
267 static ANNOUNCED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
268 ANNOUNCED.get_or_init(|| {
269 tracing::info!(
270 endpoint = %config.api_url,
271 "version check telemetry: sending anonymous {{product, current_version, os, arch}} to endpoint; \
272 set HYPERI_TELEMETRY=off to disable"
273 );
274 });
275}
276
277async fn do_version_check(
279 config: &VersionCheckConfig,
280) -> Result<VersionCheckResponse, VersionCheckError> {
281 if telemetry_opted_out() {
282 return Err(VersionCheckError::Http(
283 "telemetry opted out via HYPERI_TELEMETRY env var".into(),
284 ));
285 }
286
287 announce_once(config);
288
289 let payload = CheckPayload {
290 product: config.product.clone(),
291 current_version: config.current_version.clone(),
292 os: Some(std::env::consts::OS.into()),
293 arch: Some(std::env::consts::ARCH.into()),
294 };
295
296 let client = reqwest::Client::builder()
297 .timeout(config.timeout)
298 .build()
299 .map_err(|e| VersionCheckError::Http(e.to_string()))?;
300
301 let resp = client
302 .post(&config.api_url)
303 .json(&payload)
304 .send()
305 .await
306 .map_err(|e| VersionCheckError::Http(e.to_string()))?;
307
308 if !resp.status().is_success() {
309 return Err(VersionCheckError::Http(format!("HTTP {}", resp.status())));
310 }
311
312 resp.json::<VersionCheckResponse>()
313 .await
314 .map_err(|e| VersionCheckError::Parse(e.to_string()))
315}
316
317fn log_version_response(config: &VersionCheckConfig, resp: &VersionCheckResponse) {
319 if resp.update_available {
320 if let Some(ref latest) = resp.latest_version {
321 let age = resp
322 .published_at
323 .as_deref()
324 .and_then(format_age)
325 .unwrap_or_default();
326
327 tracing::info!(
328 product = %config.product,
329 current = %config.current_version,
330 latest = %latest,
331 age = %age,
332 url = resp.release_url.as_deref().unwrap_or(""),
333 "new version available"
334 );
335 }
336 } else {
337 tracing::debug!(
338 product = %config.product,
339 version = %config.current_version,
340 "running latest version"
341 );
342 }
343
344 if let Some(ref msg) = resp.message
345 && !msg.is_empty()
346 {
347 tracing::info!(product = %config.product, "{msg}");
348 }
349}
350
351fn format_age(published_at: &str) -> Option<String> {
355 let published = published_at
358 .parse::<chrono::DateTime<chrono::Utc>>()
359 .or_else(|_| {
360 chrono::NaiveDateTime::parse_from_str(published_at, "%Y-%m-%dT%H:%M:%S")
361 .map(|dt| dt.and_utc())
362 })
363 .ok()?;
364
365 let now = chrono::Utc::now();
366 let duration = now.signed_duration_since(published);
367
368 let days = duration.num_days();
369 if days < 0 {
370 return Some("just released".into());
371 }
372 if days == 0 {
373 return Some("released today".into());
374 }
375 if days == 1 {
376 return Some("released 1 day ago".into());
377 }
378 if days < 30 {
379 return Some(format!("released {days} days ago"));
380 }
381 let months = days / 30;
382 if months == 1 {
383 return Some("released 1 month ago".into());
384 }
385 if months < 12 {
386 return Some(format!("released {months} months ago"));
387 }
388 let years = months / 12;
389 let remaining_months = months % 12;
390 if remaining_months == 0 {
391 Some(format!("released {years}y ago"))
392 } else {
393 Some(format!("released {years}y {remaining_months}m ago"))
394 }
395}
396
397#[derive(Debug)]
404enum VersionCheckError {
405 Http(String),
406 Parse(String),
407}
408
409impl std::fmt::Display for VersionCheckError {
410 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
411 match self {
412 Self::Http(e) => write!(f, "http: {e}"),
413 Self::Parse(e) => write!(f, "parse: {e}"),
414 }
415 }
416}
417
418#[cfg(test)]
423mod tests {
424 use super::*;
425
426 #[test]
427 fn test_default_config() {
428 let config = VersionCheckConfig::default();
429 assert_eq!(config.api_url, DEFAULT_API_URL);
430 assert_eq!(config.timeout, Duration::from_secs(5));
431 assert!(!config.disabled);
432 assert!(config.product.is_empty());
433 }
434
435 #[test]
436 fn telemetry_opt_out_recognises_common_values() {
437 for v in ["off", "Off", "OFF", "0", "false", "False", "no", "disabled"] {
442 temp_env::with_var("HYPERI_TELEMETRY", Some(v), || {
443 assert!(telemetry_opted_out(), "value `{v}` should opt out");
444 });
445 }
446 for v in ["on", "1", "true", ""] {
447 temp_env::with_var("HYPERI_TELEMETRY", Some(v), || {
448 assert!(!telemetry_opted_out(), "value `{v}` should NOT opt out");
449 });
450 }
451 temp_env::with_var_unset("HYPERI_TELEMETRY", || {
452 assert!(!telemetry_opted_out(), "absent var should NOT opt out");
453 });
454 }
455
456 #[test]
457 fn check_payload_omits_dropped_fields() {
458 let payload = CheckPayload {
462 product: "dfe-loader".into(),
463 current_version: "1.0.0".into(),
464 os: Some("linux".into()),
465 arch: Some("x86_64".into()),
466 };
467 let json = serde_json::to_string(&payload).unwrap();
468 assert!(!json.contains("instance_id"));
469 assert!(!json.contains("deployment"));
470 assert!(json.contains("product"));
471 assert!(json.contains("current_version"));
472 assert!(json.contains("\"os\":\"linux\""));
473 assert!(json.contains("\"arch\":\"x86_64\""));
474 }
475
476 #[test]
477 fn test_check_payload_serialization() {
478 let payload = CheckPayload {
479 product: "dfe-loader".into(),
480 current_version: "1.8.0".into(),
481 os: Some("linux".into()),
482 arch: Some("x86_64".into()),
483 };
484
485 let json = serde_json::to_value(&payload).unwrap();
486 assert_eq!(json["product"], "dfe-loader");
487 assert_eq!(json["current_version"], "1.8.0");
488 assert_eq!(json["os"], "linux");
489 assert_eq!(json["arch"], "x86_64");
490 assert!(json.get("instance_id").is_none());
492 assert!(json.get("deployment").is_none());
493 }
494
495 #[test]
496 fn test_response_deserialization() {
497 let json = r#"{
498 "latest_version": "1.9.0",
499 "update_available": true,
500 "release_url": "https://github.com/hyperi-io/dfe-loader/releases/tag/v1.9.0",
501 "published_at": "2026-02-15T10:00:00Z",
502 "message": null
503 }"#;
504
505 let resp: VersionCheckResponse = serde_json::from_str(json).unwrap();
506 assert!(resp.update_available);
507 assert_eq!(resp.latest_version.as_deref(), Some("1.9.0"));
508 assert_eq!(resp.published_at.as_deref(), Some("2026-02-15T10:00:00Z"));
509 assert!(resp.message.is_none());
510 }
511
512 #[test]
513 fn test_response_no_update() {
514 let json = r#"{
515 "latest_version": "1.8.0",
516 "update_available": false,
517 "release_url": null,
518 "published_at": null,
519 "message": null
520 }"#;
521
522 let resp: VersionCheckResponse = serde_json::from_str(json).unwrap();
523 assert!(!resp.update_available);
524 }
525
526 #[test]
527 fn test_format_age_today() {
528 let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
529 let age = format_age(&now).unwrap();
530 assert_eq!(age, "released today");
531 }
532
533 #[test]
534 fn test_format_age_days() {
535 let ten_days_ago = (chrono::Utc::now() - chrono::Duration::days(10))
536 .format("%Y-%m-%dT%H:%M:%SZ")
537 .to_string();
538 let age = format_age(&ten_days_ago).unwrap();
539 assert_eq!(age, "released 10 days ago");
540 }
541
542 #[test]
543 fn test_format_age_months() {
544 let three_months_ago = (chrono::Utc::now() - chrono::Duration::days(90))
545 .format("%Y-%m-%dT%H:%M:%SZ")
546 .to_string();
547 let age = format_age(&three_months_ago).unwrap();
548 assert_eq!(age, "released 3 months ago");
549 }
550
551 #[test]
552 fn test_format_age_invalid() {
553 assert!(format_age("not-a-date").is_none());
554 }
555
556 #[test]
557 fn test_disabled_does_not_spawn() {
558 let checker = VersionCheck::new(VersionCheckConfig {
559 disabled: true,
560 ..Default::default()
561 });
562 checker.check_on_startup();
564 }
565
566 #[test]
567 fn test_empty_product_does_not_spawn() {
568 let checker = VersionCheck::new(VersionCheckConfig::default());
569 checker.check_on_startup();
571 }
572}