envvault/version_check/
mod.rs1use std::fs;
8use std::path::PathBuf;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13const CACHE_TTL_HOURS: i64 = 24;
15
16#[derive(Serialize, Deserialize)]
18struct CachedVersion {
19 latest: String,
20 checked_at: DateTime<Utc>,
21}
22
23pub fn check_latest_version(current: &str) -> Option<String> {
28 if let Some(cached) = read_cache() {
30 let age = Utc::now() - cached.checked_at;
31 if age.num_hours() < CACHE_TTL_HOURS {
32 return if cached.latest == current {
33 None
34 } else {
35 Some(cached.latest)
36 };
37 }
38 }
39
40 let latest = fetch_latest_version()?;
42
43 let _ = write_cache(&latest);
45
46 if latest == current {
47 None
48 } else {
49 Some(latest)
50 }
51}
52
53#[cfg(feature = "version-check")]
55fn fetch_latest_version() -> Option<String> {
56 let resp = ureq::get("https://crates.io/api/v1/crates/envvault")
57 .header(
58 "User-Agent",
59 &format!("envvault/{}", env!("CARGO_PKG_VERSION")),
60 )
61 .call()
62 .ok()?;
63
64 let body: serde_json::Value = resp.into_body().read_json().ok()?;
65 let version = body.get("crate")?.get("max_version")?.as_str()?.to_string();
66
67 Some(version)
68}
69
70#[cfg(not(feature = "version-check"))]
71fn fetch_latest_version() -> Option<String> {
72 None
73}
74
75fn cache_path() -> Option<PathBuf> {
77 let config_dir = dirs_cache_path()?;
78 Some(config_dir.join("version-check.json"))
79}
80
81fn dirs_cache_path() -> Option<PathBuf> {
83 let home = std::env::var("HOME")
85 .or_else(|_| std::env::var("USERPROFILE"))
86 .ok()?;
87 let path = PathBuf::from(home).join(".config").join("envvault");
88 Some(path)
89}
90
91fn read_cache() -> Option<CachedVersion> {
93 let path = cache_path()?;
94 let content = fs::read_to_string(path).ok()?;
95 serde_json::from_str(&content).ok()
96}
97
98fn write_cache(version: &str) -> Option<()> {
100 let path = cache_path()?;
101
102 if let Some(parent) = path.parent() {
104 fs::create_dir_all(parent).ok()?;
105 }
106
107 let cached = CachedVersion {
108 latest: version.to_string(),
109 checked_at: Utc::now(),
110 };
111
112 let content = serde_json::to_string_pretty(&cached).ok()?;
113 fs::write(path, content).ok()?;
114
115 Some(())
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 #[test]
123 fn cache_roundtrip() {
124 let dir = tempfile::TempDir::new().unwrap();
126 let cache_file = dir.path().join("version-check.json");
127
128 let cached = CachedVersion {
129 latest: "1.2.3".to_string(),
130 checked_at: Utc::now(),
131 };
132
133 let content = serde_json::to_string_pretty(&cached).unwrap();
134 fs::write(&cache_file, &content).unwrap();
135
136 let read_back: CachedVersion =
137 serde_json::from_str(&fs::read_to_string(&cache_file).unwrap()).unwrap();
138 assert_eq!(read_back.latest, "1.2.3");
139 }
140
141 #[test]
142 fn check_returns_none_without_feature() {
143 #[cfg(not(feature = "version-check"))]
145 {
146 assert!(fetch_latest_version().is_none());
147 }
148 }
149
150 #[test]
151 fn cache_path_returns_some() {
152 let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE"));
155 if let Ok(home) = home {
156 let expected = PathBuf::from(home)
157 .join(".config")
158 .join("envvault")
159 .join("version-check.json");
160 let actual = cache_path();
161 assert_eq!(actual, Some(expected));
162 }
163 }
164}