Skip to main content

robin/utils/
update_check.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5use anyhow::{Context, Result};
6use colored::*;
7use serde::{Deserialize, Serialize};
8
9const PKG_NAME: &str = "robin_cli_tool";
10const CURRENT_VERSION: &str = env!("CARGO_PKG_VERSION");
11/// How long to wait between network checks. We hit crates.io at most once a day.
12const CHECK_INTERVAL_SECS: u64 = 60 * 60 * 24;
13/// Keep the network check snappy so it never noticeably delays the CLI.
14const REQUEST_TIMEOUT: Duration = Duration::from_secs(2);
15
16#[derive(Debug, Default, Serialize, Deserialize)]
17struct UpdateCache {
18    last_check: u64,
19    latest_version: Option<String>,
20}
21
22#[derive(Deserialize)]
23struct CratesResponse {
24    #[serde(rename = "crate")]
25    krate: CrateInfo,
26}
27
28#[derive(Deserialize)]
29struct CrateInfo {
30    max_stable_version: Option<String>,
31    newest_version: Option<String>,
32}
33
34/// Best-effort, throttled "new version available" check against crates.io.
35///
36/// It never fails the caller and never blocks for long: the network is queried
37/// at most once per [`CHECK_INTERVAL_SECS`] (the result is cached on disk), and
38/// a short notice is printed to stderr whenever the cached latest version is
39/// newer than the running one. Set `ROBIN_NO_UPDATE_CHECK` to disable it.
40pub async fn check_for_update() {
41    if std::env::var_os("ROBIN_NO_UPDATE_CHECK").is_some() {
42        return;
43    }
44    // Swallow every error: an update check must never break a real command.
45    let _ = run_check().await;
46}
47
48async fn run_check() -> Result<()> {
49    let now = unix_now();
50    let path = cache_path().context("no cache directory available")?;
51    let mut cache = read_cache(&path);
52
53    // Only touch the network when the cached result is stale.
54    if now.saturating_sub(cache.last_check) >= CHECK_INTERVAL_SECS {
55        let latest = fetch_latest().await;
56        // Record the attempt either way so we don't retry on every invocation;
57        // keep any previously known version if the request failed.
58        cache.last_check = now;
59        if let Some(latest) = latest {
60            cache.latest_version = Some(latest);
61        }
62        let _ = write_cache(&path, &cache);
63    }
64
65    if let Some(latest) = &cache.latest_version {
66        if is_newer(latest, CURRENT_VERSION) {
67            print_notice(latest);
68        }
69    }
70    Ok(())
71}
72
73fn print_notice(latest: &str) {
74    eprintln!(
75        "\n{} A new version of robin is available: {} (you have {}).\n  Update with: {}",
76        "➜".yellow().bold(),
77        latest.green().bold(),
78        CURRENT_VERSION,
79        format!("cargo install {PKG_NAME}").cyan(),
80    );
81}
82
83async fn fetch_latest() -> Option<String> {
84    let url = format!("https://crates.io/api/v1/crates/{PKG_NAME}");
85    let client = reqwest::Client::builder()
86        .user_agent(concat!(
87            "robin/",
88            env!("CARGO_PKG_VERSION"),
89            " (update-check; https://github.com/cesarferreira/robin)"
90        ))
91        .timeout(REQUEST_TIMEOUT)
92        .build()
93        .ok()?;
94
95    let resp = client.get(&url).send().await.ok()?;
96    if !resp.status().is_success() {
97        return None;
98    }
99
100    let body: CratesResponse = resp.json().await.ok()?;
101    body.krate.max_stable_version.or(body.krate.newest_version)
102}
103
104fn cache_path() -> Option<PathBuf> {
105    Some(dirs::cache_dir()?.join("robin").join("update_check.json"))
106}
107
108fn read_cache(path: &Path) -> UpdateCache {
109    fs::read_to_string(path)
110        .ok()
111        .and_then(|s| serde_json::from_str(&s).ok())
112        .unwrap_or_default()
113}
114
115fn write_cache(path: &Path, cache: &UpdateCache) -> Result<()> {
116    if let Some(parent) = path.parent() {
117        fs::create_dir_all(parent)?;
118    }
119    fs::write(path, serde_json::to_string(cache)?)?;
120    Ok(())
121}
122
123fn unix_now() -> u64 {
124    SystemTime::now()
125        .duration_since(UNIX_EPOCH)
126        .map(|d| d.as_secs())
127        .unwrap_or(0)
128}
129
130/// Compares dotted numeric versions numerically (so `1.0.10` > `1.0.9`). Any
131/// parse failure yields `false`, so an unexpected version string never nags.
132fn is_newer(candidate: &str, current: &str) -> bool {
133    match (parse_version(candidate), parse_version(current)) {
134        (Some(a), Some(b)) => a > b,
135        _ => false,
136    }
137}
138
139fn parse_version(v: &str) -> Option<(u64, u64, u64)> {
140    // Drop any pre-release/build suffix (e.g. "1.2.3-beta.1" -> "1.2.3").
141    let core = v.split(['-', '+']).next()?;
142    let mut parts = core.split('.');
143    let major = parts.next()?.parse().ok()?;
144    let minor = parts.next()?.parse().ok()?;
145    let patch = parts.next()?.parse().ok()?;
146    if parts.next().is_some() {
147        return None;
148    }
149    Some((major, minor, patch))
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn newer_patch_minor_major() {
158        assert!(is_newer("1.0.3", "1.0.2"));
159        assert!(is_newer("1.1.0", "1.0.9"));
160        assert!(is_newer("2.0.0", "1.9.9"));
161    }
162
163    #[test]
164    fn same_or_older_is_not_newer() {
165        assert!(!is_newer("1.0.2", "1.0.2"));
166        assert!(!is_newer("1.0.1", "1.0.2"));
167        assert!(!is_newer("0.9.9", "1.0.0"));
168    }
169
170    #[test]
171    fn comparison_is_numeric_not_lexical() {
172        // "1.0.10" must beat "1.0.9" — a string compare would get this wrong.
173        assert!(is_newer("1.0.10", "1.0.9"));
174        assert!(!is_newer("1.0.9", "1.0.10"));
175    }
176
177    #[test]
178    fn prerelease_suffix_is_ignored() {
179        assert!(is_newer("1.0.3-beta.1", "1.0.2"));
180        assert!(!is_newer("1.0.2-rc.1", "1.0.2"));
181    }
182
183    #[test]
184    fn unparseable_versions_never_nag() {
185        assert!(!is_newer("not-a-version", "1.0.2"));
186        assert!(!is_newer("1.0", "1.0.2"));
187        assert!(!is_newer("1.0.2.3", "1.0.2"));
188    }
189}