- 1
//! Opt-in update awareness (docs/design/29-personal-os.md P3): when - 2
//! `[update] url` is configured, poll it at most once per - 3
//! `interval_hours`, notice a newer `X.Y.Z` in the response body, print - 4
//! one line, and never install anything. Network trouble is silent — the - 5
//! check must never delay or fail startup. - 6
- 7
use std::path::PathBuf; - 8
- 9
use vak_config::Config; - 10
- 11
const CHECK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); - 12
- 13
pub fn maybe_check_update(config: &Config) { - 14
let Some(url) = config.update.url.clone() else { - 15
return; - 16
}; - 17
let Some(home) = vak_home() else { - 18
return; - 19
}; - 20
let cache_path = home.join("update-check.json"); - 21
if let Ok(raw) = std::fs::read_to_string(&cache_path) - 22
&& let Ok(value) = serde_json::from_str::<serde_json::Value>(&raw) - 23
&& let Some(last) = value.get("last_check").and_then(serde_json::Value::as_u64) - 24
&& !due(last, config.update.interval_hours) - 25
{ - 26
return; - 27
} - 28
- 29
// The blocking client is confined to its own thread so the fetch can - 30
// never interact with the async runtime this CLI boots. - 31
let fetched = std::thread::spawn(move || fetch_latest_version(&url)) - 32
.join() - 33
.ok() - 34
.flatten(); - 35
write_cache_timestamp(&cache_path); - 36
if let Some(latest) = fetched - 37
&& version_newer(latest, env!("CARGO_PKG_VERSION")) - 38
{ - 39
eprintln!( - 40
"note: Vakyartha {} is available (installed {}) — install manually; nothing is auto-updated", - 41
format_version(latest), - 42
env!("CARGO_PKG_VERSION") - 43
); - 44
} - 45
} - 46
- 47
fn due(last_check_secs: u64, interval_hours: u64) -> bool { - 48
let now = epoch_secs(); - 49
match now.checked_sub(last_check_secs) { - 50
Some(elapsed) => elapsed >= interval_hours.saturating_mul(3600), - 51
None => true, - 52
} - 53
} - 54
- 55
fn epoch_secs() -> u64 { - 56
std::time::SystemTime::now() - 57
.duration_since(std::time::UNIX_EPOCH) - 58
.unwrap_or_default() - 59
.as_secs() - 60
} - 61
- 62
fn write_cache_timestamp(cache_path: &std::path::Path) { - 63
if let Some(parent) = cache_path.parent() - 64
&& std::fs::create_dir_all(parent).is_ok() - 65
{ - 66
let body = serde_json::json!({ "last_check": epoch_secs() }); - 67
let _ = std::fs::write(cache_path, body.to_string()); - 68
} - 69
} - 70
- 71
fn vak_home() -> Option<PathBuf> { - 72
Some(vak_config::paths::data_home()) - 73
} - 74
- 75
fn fetch_latest_version(url: &str) -> Option<(u64, u64, u64)> { - 76
let client = reqwest::blocking::Client::builder() - 77
.timeout(CHECK_TIMEOUT) - 78
.build() - 79
.ok()?; - 80
let body = client.get(url).send().ok()?.text().ok()?; - 81
parse_version_token(&body) - 82
} - 83
- 84
/// First dotted-numeric token (`X.Y.Z`, optional v/V prefix) in the body. - 85
pub fn parse_version_token(body: &str) -> Option<(u64, u64, u64)> { - 86
for token in body.split(|c: char| !(c.is_ascii_alphanumeric() || c == '.')) { - 87
let t = token.trim_start_matches(['v', 'V']); - 88
if t.is_empty() || !t.starts_with(|c: char| c.is_ascii_digit()) { - 89
continue; - 90
} - 91
let parts: Vec<&str> = t.split('.').collect(); - 92
if parts.len() != 3 { - 93
continue; - 94
} - 95
if let (Ok(major), Ok(minor), Ok(patch)) = ( - 96
parts[0].parse::<u64>(), - 97
parts[1].parse::<u64>(), - 98
parts[2].parse::<u64>(), - 99
) { - 100
return Some((major, minor, patch)); - 101
} - 102
} - 103
None - 104
} - 105
- 106
fn parse_current(version: &str) -> Option<(u64, u64, u64)> { - 107
parse_version_token(version) - 108
} - 109
- 110
/// True when `latest` is strictly greater than the installed version. - 111
pub fn version_newer(latest: (u64, u64, u64), installed: &str) -> bool { - 112
parse_current(installed).is_some_and(|current| latest > current) - 113
} - 114
- 115
fn format_version(v: (u64, u64, u64)) -> String { - 116
format!("{}.{}.{}", v.0, v.1, v.2) - 117
} - 118
- 119
#[cfg(test)] - 120
mod tests { - 121
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 122
use super::*; - 123
- 124
#[test] - 125
fn parses_first_semver_token_from_prose_json_or_tags() { - 126
assert_eq!(parse_version_token("release 1.2.3 is out"), Some((1, 2, 3))); - 127
assert_eq!( - 128
parse_version_token(r#"{"latest":"0.4.1","notes":"see docs"}"#), - 129
Some((0, 4, 1)) - 130
); - 131
assert_eq!(parse_version_token("v2.10.0\nsha256…"), Some((2, 10, 0))); - 132
assert_eq!(parse_version_token("V3.0.0"), Some((3, 0, 0))); - 133
assert_eq!( - 134
parse_version_token("older 9.9.9 newer 10.0.0"), - 135
Some((9, 9, 9)), - 136
"first token wins" - 137
); - 138
assert_eq!(parse_version_token("no versions here"), None); - 139
assert_eq!(parse_version_token("1.2"), None); - 140
assert_eq!(parse_version_token("1.2.3.4"), None); - 141
assert_eq!(parse_version_token("x.2.3"), None); - 142
assert_eq!(parse_version_token(""), None); - 143
// A bare number inside a longer dotted run is not semver-ish. - 144
assert_eq!(parse_version_token("2026.08.24"), Some((2026, 8, 24))); - 145
} - 146
- 147
#[test] - 148
fn newer_only_when_strictly_greater_per_component() { - 149
assert!(version_newer((1, 0, 1), "1.0.0")); - 150
assert!(version_newer((0, 5, 0), "0.4.99")); - 151
assert!(!version_newer((1, 0, 0), "1.0.0")); - 152
assert!(!version_newer((0, 9, 0), "1.0.0")); - 153
assert!( - 154
!version_newer((1, 0, 0), "unparsable"), - 155
"an unparsable installed version never reads as older" - 156
); - 157
} - 158
- 159
#[test] - 160
fn due_honors_interval_and_clock_skew() { - 161
assert!(due(0, 24)); - 162
assert!(!due(epoch_secs(), 24)); - 163
assert!(due(epoch_secs() - 86_400, 24)); - 164
assert!(!due(epoch_secs() - 86_399, 24)); - 165
assert!(due(u64::MAX, 1), "future timestamp counts as due"); - 166
assert!(due(epoch_secs(), 0), "zero interval always due"); - 167
} - 168
- 169
#[test] - 170
fn cache_write_is_tolerant_of_missing_dirs() { - 171
let dir = tempfile::tempdir().unwrap(); - 172
let path = dir.path().join("nested/update-check.json"); - 173
write_cache_timestamp(&path); - 174
let raw = std::fs::read_to_string(path).unwrap(); - 175
let parsed: serde_json::Value = serde_json::from_str(&raw).unwrap(); - 176
assert!(parsed["last_check"].as_u64().is_some()); - 177
} - 178
} - 179
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.