1use anyhow::{Context, Result};
8use std::path::Path;
9use std::sync::atomic::{AtomicBool, Ordering};
10
11static CLI_VERSION_DETECTION: AtomicBool = AtomicBool::new(false);
14
15pub fn enable_cli_version_detection() {
23 CLI_VERSION_DETECTION.store(true, Ordering::Relaxed);
24}
25
26fn cli_version_detection_enabled() -> bool {
28 CLI_VERSION_DETECTION.load(Ordering::Relaxed)
29}
30
31pub fn detect_cli_version(bin: &str, cache_file: &str, fallback: &str) -> String {
37 if !cli_version_detection_enabled() {
38 return fallback.to_string();
39 }
40 if let Some(v) = read_cached_version(cache_file) {
41 return v;
42 }
43 if let Some(v) = run_cli_version(bin) {
44 let _ = write_cached_version(cache_file, &v);
45 return v;
46 }
47 fallback.to_string()
48}
49
50pub fn parse_version(raw: &str) -> Option<String> {
54 raw.split_whitespace()
55 .find(|t| t.starts_with(|c: char| c.is_ascii_digit()) && t.contains('.'))
56 .map(str::to_string)
57}
58
59fn read_cached_version(cache_file: &str) -> Option<String> {
62 read_cached_version_in(&crate::utils::get_cache_dir().ok()?, cache_file)
63}
64
65fn read_cached_version_in(dir: &Path, cache_file: &str) -> Option<String> {
68 let text = std::fs::read_to_string(dir.join(cache_file)).ok()?;
69 let v: serde_json::Value = serde_json::from_str(&text).ok()?;
70 if !is_today_utc(v.get("last_checked_at")?.as_str()?) {
71 return None;
72 }
73 v.get("version")?.as_str().map(str::to_string)
74}
75
76fn write_cached_version(cache_file: &str, version: &str) -> Result<()> {
78 write_cached_version_in(&crate::utils::get_cache_dir()?, cache_file, version)
79}
80
81fn write_cached_version_in(dir: &Path, cache_file: &str, version: &str) -> Result<()> {
84 crate::utils::write_json_atomic(
85 dir.join(cache_file),
86 &serde_json::json!({
87 "version": version,
88 "last_checked_at": crate::utils::now_rfc3339_utc_nanos(),
89 }),
90 )
91}
92
93fn run_cli_version(bin: &str) -> Option<String> {
95 let output = std::process::Command::new(bin)
96 .arg("--version")
97 .output()
98 .ok()?;
99 output.status.success().then_some(())?;
100 parse_version(&String::from_utf8_lossy(&output.stdout))
101}
102
103fn is_today_utc(ts: &str) -> bool {
109 chrono::DateTime::parse_from_rfc3339(ts)
110 .map(|dt| dt.with_timezone(&chrono::Utc).date_naive() == chrono::Utc::now().date_naive())
111 .unwrap_or(false)
112}
113
114pub fn build_client() -> Result<reqwest::blocking::Client> {
124 reqwest::blocking::Client::builder()
125 .timeout(std::time::Duration::from_secs(8))
126 .build()
127 .context("Failed to build HTTP client")
128}
129
130pub fn iso_to_unix_secs(s: &str) -> Option<i64> {
136 let ms = crate::utils::parse_iso_timestamp(s);
137 (ms > 0).then_some(ms / 1000)
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn iso_to_unix_secs_handles_bad_input() {
146 assert_eq!(iso_to_unix_secs("not-a-date"), None);
147 assert!(iso_to_unix_secs("2026-07-03T17:09:59.651608+00:00").unwrap() > 0);
148 }
149
150 #[test]
151 fn is_today_utc_matches_now_but_not_a_past_day() {
152 let now = crate::utils::now_rfc3339_utc_nanos();
153 assert!(is_today_utc(&now));
154 assert!(!is_today_utc("2000-01-01T00:00:00Z"));
155 assert!(!is_today_utc("not-a-timestamp"));
156 }
157
158 #[test]
161 fn cli_version_detection_stays_off_outside_the_binary() {
162 assert!(!cli_version_detection_enabled());
163 assert_eq!(
164 detect_cli_version("cargo", "cargo_version.json", "1.2.3"),
165 "1.2.3"
166 );
167 }
168
169 #[test]
170 fn cached_version_round_trips_and_goes_stale_the_next_utc_day() {
171 let dir = tempfile::tempdir().unwrap();
172 assert_eq!(
173 read_cached_version_in(dir.path(), "grok_version.json"),
174 None
175 );
176
177 write_cached_version_in(dir.path(), "grok_version.json", "0.2.112").unwrap();
178 assert_eq!(
179 read_cached_version_in(dir.path(), "grok_version.json").as_deref(),
180 Some("0.2.112")
181 );
182
183 std::fs::write(
184 dir.path().join("grok_version.json"),
185 serde_json::json!({ "version": "0.1.0", "last_checked_at": "2000-01-01T00:00:00Z" })
186 .to_string(),
187 )
188 .unwrap();
189 assert_eq!(
190 read_cached_version_in(dir.path(), "grok_version.json"),
191 None
192 );
193 }
194
195 #[test]
196 fn parse_version_handles_leading_or_trailing_labels() {
197 assert_eq!(
199 parse_version("2.1.201 (Claude Code)").as_deref(),
200 Some("2.1.201")
201 );
202 assert_eq!(
204 parse_version("codex-cli 0.142.5").as_deref(),
205 Some("0.142.5")
206 );
207 assert_eq!(parse_version(" 2.0.14\n").as_deref(), Some("2.0.14"));
208 assert_eq!(parse_version(""), None);
209 assert_eq!(parse_version("Claude Code"), None);
210 }
211}