1use super::{DoctorFinding, DoctorSeverity};
4use crate::render::manifest::ProductRoot;
5use std::cmp::Ordering;
6use std::process::{Command, Output, Stdio};
7use std::time::{Duration, SystemTime, UNIX_EPOCH};
8
9const PROBE_TIMEOUT_POLLS: usize = 50;
10const PROBE_TIMEOUT_SLEEP: Duration = Duration::from_millis(100);
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum VersionStatus {
14 Ok,
15 RecommendedOnly,
16 Warn,
17 Outdated,
18 Unparseable,
19}
20
21impl VersionStatus {
22 pub fn as_str(self) -> &'static str {
23 match self {
24 VersionStatus::Ok => "ok",
25 VersionStatus::RecommendedOnly => "recommended-only",
26 VersionStatus::Warn => "warn",
27 VersionStatus::Outdated => "outdated",
28 VersionStatus::Unparseable => "unparseable",
29 }
30 }
31}
32
33#[derive(Debug, Clone)]
34pub struct VersionProbeInput {
35 pub product: String,
36 pub command: String,
37 pub min_version: String,
38 pub recommended_version: String,
39 pub min_version_effective_from: String,
40 pub raw_output: String,
41 pub today: String,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct VersionProbeFinding {
46 pub product: String,
47 pub command: String,
48 pub status: VersionStatus,
49 pub severity: DoctorSeverity,
50 pub parsed_version: Option<String>,
51 pub raw_output: String,
52 pub message: String,
53}
54
55impl VersionProbeFinding {
56 pub fn to_doctor_finding(&self) -> DoctorFinding {
57 let message = if self.status == VersionStatus::Unparseable {
58 format!(
59 "status={} command=`{}` raw_output={:?}",
60 self.status.as_str(),
61 self.command,
62 self.raw_output
63 )
64 } else {
65 format!(
66 "status={} parsed={} command=`{}`: {}",
67 self.status.as_str(),
68 self.parsed_version.as_deref().unwrap_or("unknown"),
69 self.command,
70 self.message
71 )
72 };
73
74 match self.severity {
75 DoctorSeverity::Ok => DoctorFinding {
76 product: self.product.clone(),
77 check: "version-probe",
78 severity: DoctorSeverity::Ok,
79 entry_id: None,
80 path: None,
81 message,
82 },
83 DoctorSeverity::Warn => {
84 DoctorFinding::warn(&self.product, "version-probe", None, None, message)
85 }
86 DoctorSeverity::Block => {
87 DoctorFinding::block(&self.product, "version-probe", None, None, message)
88 }
89 }
90 }
91}
92
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub(crate) struct Version {
95 major: u64,
96 minor: u64,
97 patch: u64,
98}
99
100impl Version {
101 pub(crate) fn parse(raw: &str) -> Option<Self> {
102 let bytes = raw.as_bytes();
103 for i in 0..bytes.len() {
104 if !(bytes[i].is_ascii_digit()
105 || (bytes[i] == b'v' && bytes.get(i + 1).is_some_and(u8::is_ascii_digit)))
106 {
107 continue;
108 }
109 let start = if bytes[i] == b'v' { i + 1 } else { i };
110 if start > 0 {
111 let prev = bytes[start - 1];
112 if prev.is_ascii_alphanumeric() && prev != b'v' {
113 continue;
114 }
115 }
116 if let Some((version, _end)) = parse_at(bytes, start) {
117 return Some(version);
118 }
119 }
120 None
121 }
122}
123
124impl Ord for Version {
125 fn cmp(&self, other: &Self) -> Ordering {
126 (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch))
127 }
128}
129
130impl PartialOrd for Version {
131 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
132 Some(self.cmp(other))
133 }
134}
135
136impl std::fmt::Display for Version {
137 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
138 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
139 }
140}
141
142pub fn probe_product(product: &str, root: &ProductRoot) -> VersionProbeFinding {
143 let raw_output = run_probe_command(&root.version_probe);
144 classify(VersionProbeInput {
145 product: product.to_string(),
146 command: root.version_probe.clone(),
147 min_version: root.min_version.clone(),
148 recommended_version: root.recommended_version.clone(),
149 min_version_effective_from: root.min_version_effective_from.clone(),
150 raw_output,
151 today: today_utc(),
152 })
153}
154
155pub fn classify(input: VersionProbeInput) -> VersionProbeFinding {
156 let Some(parsed) = Version::parse(&input.raw_output) else {
157 return finding(
158 input,
159 VersionStatus::Unparseable,
160 DoctorSeverity::Warn,
161 None,
162 "version output could not be parsed",
163 );
164 };
165
166 let Some(minimum) = Version::parse(&input.min_version) else {
167 return finding(
168 input,
169 VersionStatus::Unparseable,
170 DoctorSeverity::Warn,
171 Some(parsed),
172 "min_version could not be parsed",
173 );
174 };
175 let Some(recommended) = Version::parse(&input.recommended_version) else {
176 return finding(
177 input,
178 VersionStatus::Unparseable,
179 DoctorSeverity::Warn,
180 Some(parsed),
181 "recommended_version could not be parsed",
182 );
183 };
184
185 if parsed >= recommended {
186 return finding(
187 input,
188 VersionStatus::Ok,
189 DoctorSeverity::Ok,
190 Some(parsed),
191 "version meets the recommended floor",
192 );
193 }
194
195 if parsed >= minimum {
196 return finding(
197 input,
198 VersionStatus::RecommendedOnly,
199 DoctorSeverity::Warn,
200 Some(parsed),
201 "version meets the minimum floor but is below the recommended floor",
202 );
203 }
204
205 if effective_date_has_passed(&input.today, &input.min_version_effective_from) {
206 finding(
207 input,
208 VersionStatus::Outdated,
209 DoctorSeverity::Block,
210 Some(parsed),
211 "version is below the minimum floor after the effective date",
212 )
213 } else {
214 finding(
215 input,
216 VersionStatus::Warn,
217 DoctorSeverity::Warn,
218 Some(parsed),
219 "version is below the minimum floor before the effective date",
220 )
221 }
222}
223
224fn finding(
225 input: VersionProbeInput,
226 status: VersionStatus,
227 severity: DoctorSeverity,
228 parsed_version: Option<Version>,
229 message: &str,
230) -> VersionProbeFinding {
231 VersionProbeFinding {
232 product: input.product,
233 command: input.command,
234 status,
235 severity,
236 parsed_version: parsed_version.map(|v| v.to_string()),
237 raw_output: input.raw_output,
238 message: message.to_string(),
239 }
240}
241
242pub(crate) fn run_probe_command(command: &str) -> String {
243 run_probe_command_with_timeout(command, PROBE_TIMEOUT_POLLS, PROBE_TIMEOUT_SLEEP)
244}
245
246fn run_probe_command_with_timeout(command: &str, polls: usize, sleep: Duration) -> String {
247 let mut parts = command.split_whitespace();
248 let Some(program) = parts.next() else {
249 return String::new();
250 };
251 let mut child = match Command::new(program)
252 .args(parts)
253 .stdout(Stdio::piped())
254 .stderr(Stdio::piped())
255 .spawn()
256 {
257 Ok(child) => child,
258 Err(source) => return format!("failed to run `{command}`: {source}"),
259 };
260
261 for _ in 0..polls {
262 match child.try_wait() {
263 Ok(Some(_)) => {
264 return child
265 .wait_with_output()
266 .map(output_to_raw)
267 .unwrap_or_else(|source| {
268 format!("failed to read `{command}` output: {source}")
269 });
270 }
271 Ok(None) => std::thread::sleep(sleep),
272 Err(source) => {
273 let _ = child.kill();
274 return format!("failed to wait for `{command}`: {source}");
275 }
276 }
277 }
278
279 let timeout_ms = polls as u128 * sleep.as_millis();
280 let _ = child.kill();
281 child
282 .wait_with_output()
283 .map(|output| {
284 let mut raw = output_to_raw(output);
285 if !raw.is_empty() {
286 raw.push('\n');
287 }
288 raw.push_str(&format!("timed_out_after_ms={timeout_ms}"));
289 raw
290 })
291 .unwrap_or_else(|source| format!("timed out running `{command}`; kill failed: {source}"))
292}
293
294fn output_to_raw(output: Output) -> String {
295 let mut raw = String::new();
296 raw.push_str(&String::from_utf8_lossy(&output.stdout));
297 raw.push_str(&String::from_utf8_lossy(&output.stderr));
298 if !output.status.success() {
299 raw.push_str(&format!("\nexit_status={}", output.status));
300 }
301 raw.trim().to_string()
302}
303
304fn parse_at(bytes: &[u8], mut i: usize) -> Option<(Version, usize)> {
305 let (major, next) = parse_number(bytes, i)?;
306 i = next;
307 if bytes.get(i) != Some(&b'.') {
308 return None;
309 }
310 let (minor, next) = parse_number(bytes, i + 1)?;
311 i = next;
312 if bytes.get(i) != Some(&b'.') {
313 return None;
314 }
315 let (patch, next) = parse_number(bytes, i + 1)?;
316 if bytes
317 .get(next)
318 .is_some_and(|b| b.is_ascii_digit() || b.is_ascii_alphabetic() || *b == b'_')
319 {
320 return None;
321 }
322 Some((
323 Version {
324 major,
325 minor,
326 patch,
327 },
328 next,
329 ))
330}
331
332fn parse_number(bytes: &[u8], mut i: usize) -> Option<(u64, usize)> {
333 let start = i;
334 let mut value = 0u64;
335 while let Some(byte) = bytes.get(i) {
336 if !byte.is_ascii_digit() {
337 break;
338 }
339 value = value
340 .saturating_mul(10)
341 .saturating_add(u64::from(byte - b'0'));
342 i += 1;
343 }
344 (i > start).then_some((value, i))
345}
346
347fn effective_date_has_passed(today: &str, effective_from: &str) -> bool {
348 valid_date(today) && valid_date(effective_from) && today >= effective_from
349}
350
351fn valid_date(value: &str) -> bool {
352 let bytes = value.as_bytes();
353 bytes.len() == 10
354 && bytes[0..4].iter().all(u8::is_ascii_digit)
355 && bytes[4] == b'-'
356 && bytes[5..7].iter().all(u8::is_ascii_digit)
357 && bytes[7] == b'-'
358 && bytes[8..10].iter().all(u8::is_ascii_digit)
359}
360
361fn today_utc() -> String {
362 if let Ok(today) = std::env::var("AGENT_RUNTIME_DOCTOR_TODAY")
363 && valid_date(&today)
364 {
365 return today;
366 }
367
368 #[allow(clippy::disallowed_methods)]
371 let now = SystemTime::now();
372 let days = now.duration_since(UNIX_EPOCH).unwrap_or_default().as_secs() / 86_400;
373 let (year, month, day) = civil_from_days(days as i64);
374 format!("{year:04}-{month:02}-{day:02}")
375}
376
377fn civil_from_days(days_since_unix_epoch: i64) -> (i64, i64, i64) {
378 let z = days_since_unix_epoch + 719_468;
379 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
380 let doe = z - era * 146_097;
381 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
382 let y = yoe + era * 400;
383 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
384 let mp = (5 * doy + 2) / 153;
385 let day = doy - (153 * mp + 2) / 5 + 1;
386 let month = mp + if mp < 10 { 3 } else { -9 };
387 let year = y + if month <= 2 { 1 } else { 0 };
388 (year, month, day)
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use std::fs;
395 use std::os::unix::fs::PermissionsExt;
396 use tempfile::TempDir;
397
398 #[test]
399 fn semver_matcher_tolerates_common_prefixes() {
400 assert_eq!(
401 Version::parse("codex 0.18.2 (build abc1234)").map(|v| v.to_string()),
402 Some("0.18.2".to_string())
403 );
404 assert_eq!(
405 Version::parse("claude-code v2.1.145").map(|v| v.to_string()),
406 Some("2.1.145".to_string())
407 );
408 }
409
410 #[test]
411 fn unix_epoch_date_conversion_is_stable() {
412 assert_eq!(civil_from_days(0), (1970, 1, 1));
413 assert_eq!(civil_from_days(20_229), (2025, 5, 21));
414 }
415
416 #[test]
417 fn version_probe_timeout_is_loud_and_bounded() {
418 let tmp = TempDir::new().unwrap();
419 let script = tmp.path().join("slow-version");
420 fs::write(&script, "#!/usr/bin/env sh\nsleep 10\n").unwrap();
421 let mut perms = fs::metadata(&script).unwrap().permissions();
422 perms.set_mode(0o755);
423 fs::set_permissions(&script, perms).unwrap();
424
425 let raw =
426 run_probe_command_with_timeout(&script.to_string_lossy(), 0, Duration::from_millis(0));
427
428 assert!(
429 raw.contains("timed_out_after_ms=0"),
430 "timeout should be explicit: {raw}"
431 );
432 }
433}