Skip to main content

envvault/version_check/
mod.rs

1//! Version check — query crates.io for the latest published version.
2//!
3//! Behind the `version-check` feature flag. Caches results for 24 hours
4//! in `~/.config/envvault/version-check.json`. Never fails — returns `None`
5//! on any error.
6
7use std::fs;
8use std::path::PathBuf;
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13/// How long to cache the version check result.
14const CACHE_TTL_HOURS: i64 = 24;
15
16/// Cached version check result.
17#[derive(Serialize, Deserialize)]
18struct CachedVersion {
19    latest: String,
20    checked_at: DateTime<Utc>,
21}
22
23/// Check for the latest version of envvault on crates.io.
24///
25/// Returns `Some(version_string)` if a newer version is available,
26/// or `None` if already up-to-date or on any error.
27pub fn check_latest_version(current: &str) -> Option<String> {
28    // Try cache first.
29    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    // Fetch from crates.io.
41    let latest = fetch_latest_version()?;
42
43    // Cache the result (fire-and-forget).
44    let _ = write_cache(&latest);
45
46    if latest == current {
47        None
48    } else {
49        Some(latest)
50    }
51}
52
53/// Fetch the latest version from crates.io API.
54#[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
75/// Path to the cache file.
76fn cache_path() -> Option<PathBuf> {
77    let config_dir = dirs_cache_path()?;
78    Some(config_dir.join("version-check.json"))
79}
80
81/// Get the envvault config directory.
82fn dirs_cache_path() -> Option<PathBuf> {
83    // Use $HOME/.config/envvault on all platforms.
84    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
91/// Read the cached version check.
92fn 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
98/// Write a version check result to cache.
99fn write_cache(version: &str) -> Option<()> {
100    let path = cache_path()?;
101
102    // Create the directory if needed.
103    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        // Test serialization/deserialization directly without env var manipulation.
125        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        // Without the version-check feature, fetch always returns None.
144        #[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        // cache_path depends on HOME being set, which it normally is.
153        // Test the path construction logic directly.
154        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}