1use std::time::Duration;
17
18const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
24
25const NPM_LATEST_URL: &str = "https://registry.npmjs.org/@anthropic-ai/claude-code/latest";
30
31#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
35pub struct CliVersionInfo {
36 pub installed: Option<String>,
40 pub latest: Option<String>,
44}
45
46impl CliVersionInfo {
47 pub fn has_update(&self) -> bool {
53 match (self.installed.as_deref(), self.latest.as_deref()) {
54 (Some(installed), Some(latest)) => is_strictly_newer(latest, installed),
55 _ => false,
56 }
57 }
58}
59
60pub async fn fetch_info() -> CliVersionInfo {
65 let (installed, latest) = tokio::join!(probe_installed(), probe_latest());
66 CliVersionInfo { installed, latest }
67}
68
69async fn probe_installed() -> Option<String> {
74 probe_installed_within(PROBE_TIMEOUT).await
75}
76
77async fn probe_installed_within(budget: Duration) -> Option<String> {
80 let result = tokio::time::timeout(
81 budget,
82 tokio::task::spawn_blocking(|| forge_sdk::transport::process::query_cli_version("claude")),
83 )
84 .await;
85 match result {
86 Ok(Ok(Ok(reported))) => reported_version_or_warn(&reported),
87 Ok(Ok(Err(err))) => {
88 tracing::warn!(
89 target: crate::logging::targets::ENV_GIT,
90 event_name = "cli_version_probe_failed",
91 message = "claude --version probe failed",
92 outcome = "failure",
93 error = %err,
94 );
95 None
96 }
97 Ok(Err(join_err)) => {
98 tracing::warn!(
99 target: crate::logging::targets::ENV_GIT,
100 event_name = "cli_version_probe_join_failed",
101 message = "claude --version probe task panicked",
102 outcome = "failure",
103 error = %join_err,
104 );
105 None
106 }
107 Err(_) => {
108 tracing::warn!(
109 target: crate::logging::targets::ENV_GIT,
110 event_name = "cli_version_probe_timeout",
111 message = "claude --version probe timed out",
112 outcome = "timeout",
113 );
114 None
115 }
116 }
117}
118
119fn reported_version_or_warn(reported: &str) -> Option<String> {
123 let Some(token) = extract_semver_token(reported) else {
124 tracing::warn!(
125 target: crate::logging::targets::ENV_GIT,
126 event_name = "cli_version_probe_unparseable",
127 message = "claude --version output carried no version token",
128 outcome = "failure",
129 output = %reported,
130 );
131 return None;
132 };
133 Some(token.to_owned())
134}
135
136enum ProbeOutcome {
141 Version(String),
143 Unreachable,
145 Unusable,
148}
149
150async fn probe_latest() -> Option<String> {
159 let client = build_probe_client(false)?;
160 match attempt_latest(&client, false).await {
161 ProbeOutcome::Version(v) => return Some(v),
162 ProbeOutcome::Unusable => return None,
163 ProbeOutcome::Unreachable => {}
164 }
165 let direct = build_probe_client(true)?;
166 match attempt_latest(&direct, true).await {
167 ProbeOutcome::Version(v) => Some(v),
168 _ => None,
169 }
170}
171
172fn build_probe_client(direct: bool) -> Option<reqwest::Client> {
177 let mut builder =
178 crate::http_trust::with_extra_roots(reqwest::Client::builder().timeout(PROBE_TIMEOUT));
179 if direct {
180 builder = builder.no_proxy();
181 }
182 match builder.build() {
183 Ok(client) => Some(client),
184 Err(err) => {
185 tracing::warn!(
186 target: crate::logging::targets::ENV_GIT,
187 event_name = "cli_release_client_build_failed",
188 message = "reqwest client build failed",
189 outcome = "failure",
190 direct,
191 error = %err,
192 );
193 None
194 }
195 }
196}
197
198async fn attempt_latest(client: &reqwest::Client, direct: bool) -> ProbeOutcome {
202 let resp = match client.get(NPM_LATEST_URL).send().await {
203 Ok(resp) => resp,
204 Err(err) => {
205 tracing::warn!(
206 target: crate::logging::targets::ENV_GIT,
207 event_name = "cli_release_fetch_failed",
208 message = "npm registry GET failed",
209 outcome = "failure",
210 direct,
211 error = %err,
212 );
213 return ProbeOutcome::Unreachable;
214 }
215 };
216 if !resp.status().is_success() {
217 tracing::warn!(
218 target: crate::logging::targets::ENV_GIT,
219 event_name = "cli_release_non_success",
220 message = "npm registry returned non-success",
221 outcome = "failure",
222 direct,
223 status = %resp.status(),
224 );
225 return ProbeOutcome::Unusable;
226 }
227 let body: serde_json::Value = match resp.json().await {
228 Ok(v) => v,
229 Err(err) => {
230 tracing::warn!(
231 target: crate::logging::targets::ENV_GIT,
232 event_name = "cli_release_parse_failed",
233 message = "npm registry JSON parse failed",
234 outcome = "failure",
235 direct,
236 error = %err,
237 );
238 return ProbeOutcome::Unusable;
239 }
240 };
241 match latest_version_or_warn(&body, direct) {
242 Some(version) => ProbeOutcome::Version(version),
243 None => ProbeOutcome::Unusable,
244 }
245}
246
247fn latest_version_or_warn(body: &serde_json::Value, direct: bool) -> Option<String> {
251 let Some(version) = parse_latest_version(body) else {
252 tracing::warn!(
253 target: crate::logging::targets::ENV_GIT,
254 event_name = "cli_release_version_unparseable",
255 message = "npm registry response carried no version token",
256 outcome = "failure",
257 direct,
258 version = ?body.get("version"),
259 );
260 return None;
261 };
262 Some(version)
263}
264
265fn parse_latest_version(body: &serde_json::Value) -> Option<String> {
268 let version = body.get("version").and_then(|v| v.as_str())?;
269 extract_semver_token(version).map(str::to_owned)
270}
271
272fn extract_semver_token(reported: &str) -> Option<&str> {
276 reported.split_whitespace().find(|t| t.chars().next().is_some_and(|c| c.is_ascii_digit()))
277}
278
279fn is_strictly_newer(lhs: &str, rhs: &str) -> bool {
283 let Some(lhs_tuple) = parse_semver_triple(lhs) else { return false };
284 let Some(rhs_tuple) = parse_semver_triple(rhs) else { return false };
285 lhs_tuple > rhs_tuple
286}
287
288fn parse_semver_triple(s: &str) -> Option<(u32, u32, u32)> {
292 let mut parts = s.split('.');
293 let major: u32 = parts.next()?.parse().ok()?;
294 let minor: u32 = parts.next()?.parse().ok()?;
295 let patch_token = parts.next()?;
298 let digits: String = patch_token.chars().take_while(char::is_ascii_digit).collect();
299 let patch: u32 = digits.parse().ok()?;
300 Some((major, minor, patch))
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn extract_token_handles_anthropic_suffix() {
309 assert_eq!(extract_semver_token("2.1.116 (anthropic)"), Some("2.1.116"));
310 }
311
312 #[test]
313 fn extract_token_handles_claude_prefix() {
314 assert_eq!(extract_semver_token("claude 2.1.116"), Some("2.1.116"));
315 }
316
317 #[test]
318 fn extract_token_handles_bare_version() {
319 assert_eq!(extract_semver_token("2.1.116"), Some("2.1.116"));
320 }
321
322 #[test]
323 fn extract_token_returns_none_on_no_digit_token() {
324 assert!(extract_semver_token("not a version").is_none());
325 }
326
327 #[test]
328 fn parse_semver_triple_basic() {
329 assert_eq!(parse_semver_triple("2.1.116"), Some((2, 1, 116)));
330 }
331
332 #[test]
333 fn parse_semver_triple_strips_pre_suffix() {
334 assert_eq!(parse_semver_triple("2.1.116-pre.1"), Some((2, 1, 116)));
335 }
336
337 #[test]
338 fn parse_semver_triple_strips_build_suffix() {
339 assert_eq!(parse_semver_triple("2.1.116+abc"), Some((2, 1, 116)));
340 }
341
342 #[test]
343 fn parse_semver_triple_rejects_too_short() {
344 assert!(parse_semver_triple("2.1").is_none());
345 }
346
347 #[test]
348 fn parse_semver_triple_rejects_non_numeric() {
349 assert!(parse_semver_triple("v2.1.116").is_none());
350 }
351
352 #[test]
353 fn has_update_when_latest_is_strictly_newer() {
354 let info =
355 CliVersionInfo { installed: Some("2.0.45".into()), latest: Some("2.0.50".into()) };
356 assert!(info.has_update());
357 }
358
359 #[test]
360 fn has_update_when_minor_bumps() {
361 let info =
362 CliVersionInfo { installed: Some("2.0.99".into()), latest: Some("2.1.0".into()) };
363 assert!(info.has_update());
364 }
365
366 #[test]
367 fn no_update_when_versions_match() {
368 let info =
369 CliVersionInfo { installed: Some("2.0.50".into()), latest: Some("2.0.50".into()) };
370 assert!(!info.has_update());
371 }
372
373 #[test]
374 fn no_update_when_installed_is_newer_dev_build() {
375 let info =
376 CliVersionInfo { installed: Some("2.1.0".into()), latest: Some("2.0.50".into()) };
377 assert!(!info.has_update());
378 }
379
380 #[test]
381 fn no_update_when_either_side_missing() {
382 let info = CliVersionInfo { installed: None, latest: Some("2.0.50".into()) };
383 assert!(!info.has_update());
384 let info = CliVersionInfo { installed: Some("2.0.45".into()), latest: None };
385 assert!(!info.has_update());
386 }
387
388 #[test]
389 fn parse_latest_version_reads_npm_version_field() {
390 let body = serde_json::json!({
391 "name": "@anthropic-ai/claude-code",
392 "version": "2.1.201",
393 });
394 assert_eq!(parse_latest_version(&body), Some("2.1.201".to_owned()));
395 }
396
397 #[test]
398 fn parse_latest_version_none_when_field_missing() {
399 let body = serde_json::json!({ "name": "@anthropic-ai/claude-code" });
400 assert!(parse_latest_version(&body).is_none());
401 }
402
403 #[test]
408 fn an_unusable_npm_latest_body_warns_and_yields_none() {
409 let body = serde_json::json!({
410 "name": "@anthropic-ai/claude-code",
411 "version": "not-a-version",
412 });
413 let (result, captured) = capture_logs(|| latest_version_or_warn(&body, false));
414 assert_eq!(result, None, "an unusable body yields None: {captured:?}");
415 assert!(
416 captured.contains("cli_release_version_unparseable"),
417 "the miss fired no warn: {captured:?}",
418 );
419 assert!(
420 captured.contains("not-a-version"),
421 "the warn must carry the field's value - the diagnostic: {captured:?}",
422 );
423 }
424
425 #[test]
428 fn readable_outputs_are_not_flagged() {
429 let (installed, captured) =
430 capture_logs(|| reported_version_or_warn("2.1.116 (anthropic)"));
431 assert_eq!(installed.as_deref(), Some("2.1.116"), "no token from a readable output");
432 assert!(
433 !captured.contains("cli_version_probe_unparseable"),
434 "warned on a readable version: {captured:?}",
435 );
436
437 let body = serde_json::json!({ "version": "2.1.201" });
438 let (latest, captured) = capture_logs(|| latest_version_or_warn(&body, false));
439 assert_eq!(latest.as_deref(), Some("2.1.201"), "no token from a readable body");
440 assert!(
441 !captured.contains("cli_release_version_unparseable"),
442 "warned on a readable body: {captured:?}",
443 );
444 }
445
446 #[derive(Clone, Default)]
448 struct LogCapture(std::sync::Arc<parking_lot::Mutex<Vec<u8>>>);
449
450 impl std::io::Write for LogCapture {
451 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
452 self.0.lock().extend_from_slice(buf);
453 Ok(buf.len())
454 }
455 fn flush(&mut self) -> std::io::Result<()> {
456 Ok(())
457 }
458 }
459
460 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for LogCapture {
461 type Writer = Self;
462 fn make_writer(&'a self) -> Self::Writer {
463 self.clone()
464 }
465 }
466
467 fn capture_logs<R>(f: impl FnOnce() -> R) -> (R, String) {
468 let capture = LogCapture::default();
469 let subscriber = tracing_subscriber::fmt().with_writer(capture.clone()).finish();
470 let result = tracing::subscriber::with_default(subscriber, f);
471 let text = String::from_utf8_lossy(&capture.0.lock().clone()).into_owned();
472 (result, text)
473 }
474
475 #[test]
484 fn a_zero_exit_unreadable_version_warns() {
485 const PROBE: &str = "FORGE_CLI_VERSION_PROBE";
486 const PROBE_OK: &str = "FORGE_CLI_VERSION_PROBE_OK";
487 const PROBE_TEST: &str = "env::cli_version::tests::a_zero_exit_unreadable_version_warns";
488
489 let stub_dir = tempfile::tempdir().expect("tempdir");
490 let stub = stub_dir.path().join("claude");
491 std::fs::write(&stub, "#!/bin/sh\necho 'unknown build'\n").expect("write stub");
492 {
493 use std::os::unix::fs::PermissionsExt;
494 std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o755))
495 .expect("chmod stub");
496 }
497
498 if std::env::var(PROBE).is_err() {
499 let out = std::process::Command::new(std::env::current_exe().expect("current_exe"))
502 .args(["--exact", PROBE_TEST, "--test-threads=1", "--nocapture"])
503 .env(PROBE, "1")
504 .env("PATH", stub_dir.path())
505 .output()
506 .expect("spawn probe");
507 let stdout = String::from_utf8_lossy(&out.stdout);
508 let stderr = String::from_utf8_lossy(&out.stderr);
509 assert!(
510 out.status.success(),
511 "the child probe failed: stdout={stdout}\nstderr={stderr}",
512 );
513 assert!(
514 stdout.contains(PROBE_OK),
515 "the probe never ran (filter matched nothing?): stdout={stdout}\nstderr={stderr}",
516 );
517 return;
518 }
519
520 let (installed, captured) = capture_logs(|| {
525 tokio::runtime::Builder::new_current_thread()
526 .enable_all()
527 .build()
528 .expect("runtime")
529 .block_on(probe_installed_within(std::time::Duration::from_secs(60)))
530 });
531 assert_eq!(
532 installed, None,
533 "an unreadable `--version` output still yields None: {captured:?}",
534 );
535 assert!(
536 captured.contains("cli_version_probe_unparseable"),
537 "the miss fired no warn, which is the defect: {captured:?}",
538 );
539 assert!(
540 captured.contains("unknown build"),
541 "the warn must carry the raw output - the only diagnostic: {captured:?}",
542 );
543 assert!(
544 !captured.contains("cli_version_probe_failed"),
545 "the probe must have read the stub's output, not failed to spawn it: {captured:?}",
546 );
547 println!("{PROBE_OK}");
548 }
549}