Skip to main content

claude_code_rust/app/
update_check.rs

1// Claude Code Rust - A native Rust terminal interface for Claude Code
2// Copyright (C) 2025  Simon Peter Rothgang
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU Affero General Public License as
6// published by the Free Software Foundation, either version 3 of the
7// License, or (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU Affero General Public License for more details.
13//
14// You should have received a copy of the GNU Affero General Public License
15// along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17use 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, &current_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}