claude_code_rust/app/
update_check.rs1use super::App;
18use crate::Cli;
19use crate::acp::client::ClientEvent;
20use reqwest::header::{ACCEPT, HeaderMap, HeaderValue, USER_AGENT};
21use serde::{Deserialize, Serialize};
22use std::path::{Path, PathBuf};
23use std::time::{Duration, SystemTime, UNIX_EPOCH};
24
25const UPDATE_CHECK_DISABLE_ENV: &str = "CLAUDE_RUST_NO_UPDATE_CHECK";
26const UPDATE_CHECK_TTL_SECS: u64 = 24 * 60 * 60;
27const UPDATE_CHECK_TIMEOUT: Duration = Duration::from_secs(4);
28const GITHUB_LATEST_RELEASE_API_URL: &str =
29 "https://api.github.com/repos/srothgan/claude-code-rust/releases/latest";
30const GITHUB_API_ACCEPT_VALUE: &str = "application/vnd.github+json";
31const GITHUB_API_VERSION_VALUE: &str = "2022-11-28";
32const GITHUB_USER_AGENT_VALUE: &str = "claude-code-rust-update-check";
33const CACHE_FILE: &str = "update-check.json";
34const CACHE_DIR_NAME: &str = "claude-code-rust";
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
37struct SimpleVersion {
38 major: u64,
39 minor: u64,
40 patch: u64,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
44struct UpdateCheckCache {
45 checked_at_unix_secs: u64,
46 latest_version: String,
47}
48
49#[derive(Debug, Clone, Deserialize)]
50struct GithubLatestRelease {
51 tag_name: String,
52}
53
54pub fn start_update_check(app: &App, cli: &Cli) {
55 if update_check_disabled(cli.no_update_check) {
56 tracing::debug!("Skipping update check (disabled by flag/env)");
57 return;
58 }
59
60 let event_tx = app.event_tx.clone();
61 let current_version = env!("CARGO_PKG_VERSION").to_owned();
62
63 tokio::task::spawn_local(async move {
64 let latest_version = resolve_latest_version().await;
65 let Some(latest_version) = latest_version else {
66 return;
67 };
68
69 if is_newer_version(&latest_version, ¤t_version) {
70 let _ = event_tx.send(ClientEvent::UpdateAvailable { latest_version, current_version });
71 }
72 });
73}
74
75fn update_check_disabled(no_update_check_flag: bool) -> bool {
76 if no_update_check_flag {
77 return true;
78 }
79 std::env::var(UPDATE_CHECK_DISABLE_ENV)
80 .ok()
81 .is_some_and(|v| matches!(v.trim().to_ascii_lowercase().as_str(), "1" | "true" | "yes"))
82}
83
84async fn resolve_latest_version() -> Option<String> {
85 let cache_path = update_cache_path()?;
86 let now = unix_now_secs()?;
87 let cached = read_cache(&cache_path).await;
88
89 if let Some(cache) = cached.as_ref()
90 && now.saturating_sub(cache.checked_at_unix_secs) <= UPDATE_CHECK_TTL_SECS
91 && is_valid_version(&cache.latest_version)
92 {
93 return Some(cache.latest_version.clone());
94 }
95
96 match fetch_latest_release_tag().await {
97 Some(latest_version) => {
98 let cache = UpdateCheckCache { checked_at_unix_secs: now, latest_version };
99 if let Err(err) = write_cache(&cache_path, &cache).await {
100 tracing::debug!("update-check cache write failed: {err}");
101 }
102 Some(cache.latest_version)
103 }
104 None => cached.and_then(|cache| {
105 is_valid_version(&cache.latest_version).then_some(cache.latest_version)
106 }),
107 }
108}
109
110fn update_cache_path() -> Option<PathBuf> {
111 dirs::cache_dir().map(|dir| dir.join(CACHE_DIR_NAME).join(CACHE_FILE))
112}
113
114fn unix_now_secs() -> Option<u64> {
115 SystemTime::now().duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs())
116}
117
118async fn read_cache(path: &Path) -> Option<UpdateCheckCache> {
119 let content = tokio::fs::read_to_string(path).await.ok()?;
120 serde_json::from_str::<UpdateCheckCache>(&content).ok()
121}
122
123async fn write_cache(path: &Path, cache: &UpdateCheckCache) -> anyhow::Result<()> {
124 if let Some(parent) = path.parent() {
125 tokio::fs::create_dir_all(parent).await?;
126 }
127 let content = serde_json::to_vec(cache)?;
128 tokio::fs::write(path, content).await?;
129 Ok(())
130}
131
132async fn fetch_latest_release_tag() -> Option<String> {
133 let client = reqwest::Client::builder().timeout(UPDATE_CHECK_TIMEOUT).build().ok()?;
134
135 let response = client
136 .get(GITHUB_LATEST_RELEASE_API_URL)
137 .headers(github_api_headers())
138 .send()
139 .await
140 .ok()?;
141
142 if !response.status().is_success() {
143 tracing::debug!("update-check request failed with status {}", response.status());
144 return None;
145 }
146
147 let release = response.json::<GithubLatestRelease>().await.ok()?;
148 normalize_version_string(&release.tag_name)
149}
150
151fn github_api_headers() -> HeaderMap {
152 let mut headers = HeaderMap::new();
153 headers.insert(ACCEPT, HeaderValue::from_static(GITHUB_API_ACCEPT_VALUE));
154 headers.insert("X-GitHub-Api-Version", HeaderValue::from_static(GITHUB_API_VERSION_VALUE));
155 headers.insert(USER_AGENT, HeaderValue::from_static(GITHUB_USER_AGENT_VALUE));
156 headers
157}
158
159fn normalize_version_string(raw: &str) -> Option<String> {
160 parse_simple_version(raw).map(|v| format!("{}.{}.{}", v.major, v.minor, v.patch))
161}
162
163fn parse_simple_version(raw: &str) -> Option<SimpleVersion> {
164 let trimmed = raw.trim();
165 let without_prefix = trimmed.strip_prefix('v').unwrap_or(trimmed);
166 let core = without_prefix.split_once('-').map_or(without_prefix, |(c, _)| c);
167
168 let mut parts = core.split('.');
169 let major = parts.next()?.parse().ok()?;
170 let minor = parts.next()?.parse().ok()?;
171 let patch = parts.next()?.parse().ok()?;
172 if parts.next().is_some() {
173 return None;
174 }
175 Some(SimpleVersion { major, minor, patch })
176}
177
178fn is_valid_version(version: &str) -> bool {
179 parse_simple_version(version).is_some()
180}
181
182fn is_newer_version(candidate: &str, current: &str) -> bool {
183 let Some(candidate) = parse_simple_version(candidate) else {
184 return false;
185 };
186 let Some(current) = parse_simple_version(current) else {
187 return false;
188 };
189 candidate > current
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn parse_simple_version_accepts_v_prefix() {
198 assert_eq!(
199 parse_simple_version("v1.2.3"),
200 Some(SimpleVersion { major: 1, minor: 2, patch: 3 })
201 );
202 }
203
204 #[test]
205 fn parse_simple_version_rejects_invalid_shapes() {
206 assert_eq!(parse_simple_version("1.2"), None);
207 assert_eq!(parse_simple_version("1.2.3.4"), None);
208 assert_eq!(parse_simple_version("v1.two.3"), None);
209 }
210
211 #[test]
212 fn parse_simple_version_ignores_prerelease_suffix() {
213 assert_eq!(
214 parse_simple_version("v2.4.6-rc1"),
215 Some(SimpleVersion { major: 2, minor: 4, patch: 6 })
216 );
217 }
218
219 #[test]
220 fn normalize_version_string_accepts_release_tag() {
221 assert_eq!(normalize_version_string("v0.10.0").as_deref(), Some("0.10.0"));
222 }
223
224 #[test]
225 fn github_release_payload_parses_tag_name() {
226 let payload = r#"{"tag_name":"v0.11.0"}"#;
227 let parsed = serde_json::from_str::<GithubLatestRelease>(payload).ok();
228 assert_eq!(parsed.map(|r| r.tag_name), Some("v0.11.0".to_owned()));
229 }
230
231 #[test]
232 fn update_check_disabled_prefers_flag() {
233 assert!(update_check_disabled(true));
234 }
235
236 #[test]
237 fn is_newer_version_compares_semver_triplets() {
238 assert!(is_newer_version("0.3.0", "0.2.9"));
239 assert!(!is_newer_version("0.2.9", "0.3.0"));
240 assert!(!is_newer_version("bad", "0.3.0"));
241 }
242}