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)]
390enum VersionCheckError {
391 Http(String),
392 Parse(String),
393}
394
395impl std::fmt::Display for VersionCheckError {
396 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397 match self {
398 Self::Http(e) => write!(f, "http: {e}"),
399 Self::Parse(e) => write!(f, "parse: {e}"),
400 }
401 }
402}
403
404#[cfg(test)]
409mod tests {
410 use super::*;
411
412 #[test]
413 fn test_default_config() {
414 let config = VersionCheckConfig::default();
415 assert_eq!(config.api_url, DEFAULT_API_URL);
416 assert_eq!(config.timeout, Duration::from_secs(5));
417 assert!(!config.disabled);
418 assert!(config.product.is_empty());
419 }
420
421 #[test]
422 fn telemetry_opt_out_recognises_common_values() {
423 for v in ["off", "Off", "OFF", "0", "false", "False", "no", "disabled"] {
428 temp_env::with_var("HYPERI_TELEMETRY", Some(v), || {
429 assert!(telemetry_opted_out(), "value `{v}` should opt out");
430 });
431 }
432 for v in ["on", "1", "true", ""] {
433 temp_env::with_var("HYPERI_TELEMETRY", Some(v), || {
434 assert!(!telemetry_opted_out(), "value `{v}` should NOT opt out");
435 });
436 }
437 temp_env::with_var_unset("HYPERI_TELEMETRY", || {
438 assert!(!telemetry_opted_out(), "absent var should NOT opt out");
439 });
440 }
441
442 #[test]
443 fn check_payload_omits_dropped_fields() {
444 let payload = CheckPayload {
448 product: "dfe-loader".into(),
449 current_version: "1.0.0".into(),
450 os: Some("linux".into()),
451 arch: Some("x86_64".into()),
452 };
453 let json = serde_json::to_string(&payload).unwrap();
454 assert!(!json.contains("instance_id"));
455 assert!(!json.contains("deployment"));
456 assert!(json.contains("product"));
457 assert!(json.contains("current_version"));
458 assert!(json.contains("\"os\":\"linux\""));
459 assert!(json.contains("\"arch\":\"x86_64\""));
460 }
461
462 #[test]
463 fn test_check_payload_serialization() {
464 let payload = CheckPayload {
465 product: "dfe-loader".into(),
466 current_version: "1.8.0".into(),
467 os: Some("linux".into()),
468 arch: Some("x86_64".into()),
469 };
470
471 let json = serde_json::to_value(&payload).unwrap();
472 assert_eq!(json["product"], "dfe-loader");
473 assert_eq!(json["current_version"], "1.8.0");
474 assert_eq!(json["os"], "linux");
475 assert_eq!(json["arch"], "x86_64");
476 assert!(json.get("instance_id").is_none());
478 assert!(json.get("deployment").is_none());
479 }
480
481 #[test]
482 fn test_response_deserialization() {
483 let json = r#"{
484 "latest_version": "1.9.0",
485 "update_available": true,
486 "release_url": "https://github.com/hyperi-io/dfe-loader/releases/tag/v1.9.0",
487 "published_at": "2026-02-15T10:00:00Z",
488 "message": null
489 }"#;
490
491 let resp: VersionCheckResponse = serde_json::from_str(json).unwrap();
492 assert!(resp.update_available);
493 assert_eq!(resp.latest_version.as_deref(), Some("1.9.0"));
494 assert_eq!(resp.published_at.as_deref(), Some("2026-02-15T10:00:00Z"));
495 assert!(resp.message.is_none());
496 }
497
498 #[test]
499 fn test_response_no_update() {
500 let json = r#"{
501 "latest_version": "1.8.0",
502 "update_available": false,
503 "release_url": null,
504 "published_at": null,
505 "message": null
506 }"#;
507
508 let resp: VersionCheckResponse = serde_json::from_str(json).unwrap();
509 assert!(!resp.update_available);
510 }
511
512 #[test]
513 fn test_format_age_today() {
514 let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string();
515 let age = format_age(&now).unwrap();
516 assert_eq!(age, "released today");
517 }
518
519 #[test]
520 fn test_format_age_days() {
521 let ten_days_ago = (chrono::Utc::now() - chrono::Duration::days(10))
522 .format("%Y-%m-%dT%H:%M:%SZ")
523 .to_string();
524 let age = format_age(&ten_days_ago).unwrap();
525 assert_eq!(age, "released 10 days ago");
526 }
527
528 #[test]
529 fn test_format_age_months() {
530 let three_months_ago = (chrono::Utc::now() - chrono::Duration::days(90))
531 .format("%Y-%m-%dT%H:%M:%SZ")
532 .to_string();
533 let age = format_age(&three_months_ago).unwrap();
534 assert_eq!(age, "released 3 months ago");
535 }
536
537 #[test]
538 fn test_format_age_invalid() {
539 assert!(format_age("not-a-date").is_none());
540 }
541
542 #[test]
543 fn test_disabled_does_not_spawn() {
544 let checker = VersionCheck::new(VersionCheckConfig {
545 disabled: true,
546 ..Default::default()
547 });
548 checker.check_on_startup();
550 }
551
552 #[test]
553 fn test_empty_product_does_not_spawn() {
554 let checker = VersionCheck::new(VersionCheckConfig::default());
555 checker.check_on_startup();
557 }
558}