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) => log_version_response(&config, &resp),
181 Err(e) => {
182 tracing::warn!(error = %e, "version check failed (non-fatal)");
183 }
184 }
185 });
186 }
187}
188
189#[derive(Debug, Serialize)]
211struct CheckPayload {
212 product: String,
213 current_version: String,
214 #[serde(skip_serializing_if = "Option::is_none")]
215 os: Option<String>,
216 #[serde(skip_serializing_if = "Option::is_none")]
217 arch: Option<String>,
218}
219
220#[derive(Debug, Deserialize)]
222pub struct VersionCheckResponse {
223 pub latest_version: Option<String>,
225 pub update_available: bool,
227 pub release_url: Option<String>,
229 pub published_at: Option<String>,
231 pub message: Option<String>,
233}
234
235fn telemetry_opted_out() -> bool {
242 std::env::var("HYPERI_TELEMETRY").is_ok_and(|v| {
243 let l = v.to_ascii_lowercase();
244 matches!(l.as_str(), "off" | "0" | "false" | "no" | "disabled")
245 })
246}
247
248fn announce_once(config: &VersionCheckConfig) {
253 static ANNOUNCED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
254 ANNOUNCED.get_or_init(|| {
255 tracing::info!(
256 endpoint = %config.api_url,
257 "version check telemetry: sending anonymous {{product, current_version, os, arch}} to endpoint; \
258 set HYPERI_TELEMETRY=off to disable"
259 );
260 });
261}
262
263async fn do_version_check(
265 config: &VersionCheckConfig,
266) -> Result<VersionCheckResponse, VersionCheckError> {
267 if telemetry_opted_out() {
268 return Err(VersionCheckError::Http(
269 "telemetry opted out via HYPERI_TELEMETRY env var".into(),
270 ));
271 }
272
273 announce_once(config);
274
275 let payload = CheckPayload {
276 product: config.product.clone(),
277 current_version: config.current_version.clone(),
278 os: Some(std::env::consts::OS.into()),
279 arch: Some(std::env::consts::ARCH.into()),
280 };
281
282 let client = reqwest::Client::builder()
283 .timeout(config.timeout)
284 .build()
285 .map_err(|e| VersionCheckError::Http(e.to_string()))?;
286
287 let resp = client
288 .post(&config.api_url)
289 .json(&payload)
290 .send()
291 .await
292 .map_err(|e| VersionCheckError::Http(e.to_string()))?;
293
294 if !resp.status().is_success() {
295 return Err(VersionCheckError::Http(format!("HTTP {}", resp.status())));
296 }
297
298 resp.json::<VersionCheckResponse>()
299 .await
300 .map_err(|e| VersionCheckError::Parse(e.to_string()))
301}
302
303fn log_version_response(config: &VersionCheckConfig, resp: &VersionCheckResponse) {
305 if resp.update_available {
306 if let Some(ref latest) = resp.latest_version {
307 let age = resp
308 .published_at
309 .as_deref()
310 .and_then(format_age)
311 .unwrap_or_default();
312
313 tracing::info!(
314 product = %config.product,
315 current = %config.current_version,
316 latest = %latest,
317 age = %age,
318 url = resp.release_url.as_deref().unwrap_or(""),
319 "new version available"
320 );
321 }
322 } else {
323 tracing::debug!(
324 product = %config.product,
325 version = %config.current_version,
326 "running latest version"
327 );
328 }
329
330 if let Some(ref msg) = resp.message
331 && !msg.is_empty()
332 {
333 tracing::info!(product = %config.product, "{msg}");
334 }
335}
336
337fn format_age(published_at: &str) -> Option<String> {
341 let published = published_at
344 .parse::<chrono::DateTime<chrono::Utc>>()
345 .or_else(|_| {
346 chrono::NaiveDateTime::parse_from_str(published_at, "%Y-%m-%dT%H:%M:%S")
347 .map(|dt| dt.and_utc())
348 })
349 .ok()?;
350
351 let now = chrono::Utc::now();
352 let duration = now.signed_duration_since(published);
353
354 let days = duration.num_days();
355 if days < 0 {
356 return Some("just released".into());
357 }
358 if days == 0 {
359 return Some("released today".into());
360 }
361 if days == 1 {
362 return Some("released 1 day ago".into());
363 }
364 if days < 30 {
365 return Some(format!("released {days} days ago"));
366 }
367 let months = days / 30;
368 if months == 1 {
369 return Some("released 1 month ago".into());
370 }
371 if months < 12 {
372 return Some(format!("released {months} months ago"));
373 }
374 let years = months / 12;
375 let remaining_months = months % 12;
376 if remaining_months == 0 {
377 Some(format!("released {years}y ago"))
378 } else {
379 Some(format!("released {years}y {remaining_months}m ago"))
380 }
381}
382
383#[derive(Debug)]
395enum VersionCheckError {
396 Http(String),
397 Parse(String),
398}
399
400impl std::fmt::Display for VersionCheckError {
401 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
402 match self {
403 Self::Http(e) => write!(f, "http: {e}"),
404 Self::Parse(e) => write!(f, "parse: {e}"),
405 }
406 }
407}
408
409#[cfg(test)]
414mod tests {
415 use super::*;
416
417 #[test]
418 fn test_default_config() {
419 let config = VersionCheckConfig::default();
420 assert_eq!(config.api_url, DEFAULT_API_URL);
421 assert_eq!(config.timeout, Duration::from_secs(5));
422 assert!(!config.disabled);
423 assert!(config.product.is_empty());
424 }
425
426 #[test]
427 fn telemetry_opt_out_recognises_common_values() {
428 for v in ["off", "Off", "OFF", "0", "false", "False", "no", "disabled"] {
433 temp_env::with_var("HYPERI_TELEMETRY", Some(v), || {
434 assert!(telemetry_opted_out(), "value `{v}` should opt out");
435 });
436 }
437 for v in ["on", "1", "true", ""] {
438 temp_env::with_var("HYPERI_TELEMETRY", Some(v), || {
439 assert!(!telemetry_opted_out(), "value `{v}` should NOT opt out");
440 });
441 }
442 temp_env::with_var_unset("HYPERI_TELEMETRY", || {
443 assert!(!telemetry_opted_out(), "absent var should NOT opt out");
444 });
445 }
446
447 #[test]
448 fn check_payload_omits_dropped_fields() {
449 let payload = CheckPayload {
453 product: "dfe-loader".into(),
454 current_version: "1.0.0".into(),
455 os: Some("linux".into()),
456 arch: Some("x86_64".into()),
457 };
458 let json = serde_json::to_string(&payload).unwrap();
459 assert!(!json.contains("instance_id"));
460 assert!(!json.contains("deployment"));
461 assert!(json.contains("product"));
462 assert!(json.contains("current_version"));
463 assert!(json.contains("\"os\":\"linux\""));
464 assert!(json.contains("\"arch\":\"x86_64\""));
465 }
466
467 #[test]
468 fn test_check_payload_serialization() {
469 let payload = CheckPayload {
470 product: "dfe-loader".into(),
471 current_version: "1.8.0".into(),
472 os: Some("linux".into()),
473 arch: Some("x86_64".into()),
474 };
475
476 let json = serde_json::to_value(&payload).unwrap();
477 assert_eq!(json["product"], "dfe-loader");
478 assert_eq!(json["current_version"], "1.8.0");
479 assert_eq!(json["os"], "linux");
480 assert_eq!(json["arch"], "x86_64");
481 assert!(json.get("instance_id").is_none());
483 assert!(json.get("deployment").is_none());
484 }
485
486 #[test]
487 fn test_response_deserialization() {
488 let json = r#"{
489 "latest_version": "1.9.0",
490 "update_available": true,
491 "release_url": "https://github.com/hyperi-io/dfe-loader/releases/tag/v1.9.0",
492 "published_at": "2026-02-15T10:00:00Z",
493 "message": null
494 }"#;
495
496 let resp: VersionCheckResponse = serde_json::from_str(json).unwrap();
497 assert!(resp.update_available);
498 assert_eq!(resp.latest_version.as_deref(), Some("1.9.0"));
499 assert_eq!(resp.published_at.as_deref(), Some("2026-02-15T10:00:00Z"));
500 assert!(resp.message.is_none());
501 }
502
503 #[test]
504 fn test_response_no_update() {
505 let json = r#"{
506 "latest_version": "1.8.0",
507 "update_available": false,
508 "release_url": null,
509 "published_at": null,
510 "message": null
511 }"#;
512
513 let resp: VersionCheckResponse = serde_json::from_str(json).unwrap();
514 assert!(!resp.update_available);
515 }
516
517 #[test]
518 fn test_format_age_today() {
519 let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
520 let age = format_age(&now).unwrap();
521 assert_eq!(age, "released today");
522 }
523
524 #[test]
525 fn test_format_age_days() {
526 let ten_days_ago = (chrono::Utc::now() - chrono::Duration::days(10))
527 .format("%Y-%m-%dT%H:%M:%SZ")
528 .to_string();
529 let age = format_age(&ten_days_ago).unwrap();
530 assert_eq!(age, "released 10 days ago");
531 }
532
533 #[test]
534 fn test_format_age_months() {
535 let three_months_ago = (chrono::Utc::now() - chrono::Duration::days(90))
536 .format("%Y-%m-%dT%H:%M:%SZ")
537 .to_string();
538 let age = format_age(&three_months_ago).unwrap();
539 assert_eq!(age, "released 3 months ago");
540 }
541
542 #[test]
543 fn test_format_age_invalid() {
544 assert!(format_age("not-a-date").is_none());
545 }
546
547 #[test]
548 fn test_disabled_does_not_spawn() {
549 let checker = VersionCheck::new(VersionCheckConfig {
550 disabled: true,
551 ..Default::default()
552 });
553 checker.check_on_startup();
555 }
556
557 #[test]
558 fn test_empty_product_does_not_spawn() {
559 let checker = VersionCheck::new(VersionCheckConfig::default());
560 checker.check_on_startup();
562 }
563}