robin/utils/
update_check.rs1use 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");
11const CHECK_INTERVAL_SECS: u64 = 60 * 60 * 24;
13const 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
34pub async fn check_for_update() {
41 if std::env::var_os("ROBIN_NO_UPDATE_CHECK").is_some() {
42 return;
43 }
44 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 if now.saturating_sub(cache.last_check) >= CHECK_INTERVAL_SECS {
55 let latest = fetch_latest().await;
56 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
130fn 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 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 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}