1use std::fmt;
18
19use crate::agent::Agent;
20use crate::error::{Error, Result};
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
29pub struct Version {
30 pub major: u32,
32 pub minor: u32,
34 pub patch: u32,
36}
37
38impl Version {
39 #[must_use]
45 pub fn find(text: &str) -> Option<Version> {
46 let bytes = text.as_bytes();
47 let mut start = 0;
48 while start < bytes.len() {
49 if bytes[start].is_ascii_digit()
50 && (start == 0 || !bytes[start - 1].is_ascii_digit() && bytes[start - 1] != b'.')
53 && let Some(version) = Version::parse_at(&text[start..])
54 {
55 return Some(version);
56 }
57 start += 1;
58 }
59 None
60 }
61
62 fn parse_at(text: &str) -> Option<Version> {
65 let mut parts = [0u32; 3];
66 let mut rest = text;
67 for (index, part) in parts.iter_mut().enumerate() {
68 if index > 0 {
69 rest = rest.strip_prefix('.')?;
70 }
71 let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
72 if digits.is_empty() {
73 return None;
74 }
75 *part = digits.parse().ok()?;
76 rest = &rest[digits.len()..];
77 }
78 Some(Version {
79 major: parts[0],
80 minor: parts[1],
81 patch: parts[2],
82 })
83 }
84}
85
86impl fmt::Display for Version {
87 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
89 }
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum VersionStatus {
95 Verified,
97 Newer,
100 Older,
102 Unrecognized,
104}
105
106impl VersionStatus {
107 #[must_use]
109 pub fn is_verified(self) -> bool {
110 self == VersionStatus::Verified
111 }
112}
113
114#[derive(Debug, Clone)]
116#[non_exhaustive]
117pub struct Probe {
118 pub agent: Agent,
120 pub bin: String,
122 pub reported: String,
124 pub version: Option<Version>,
126 pub verified: Version,
128 pub status: VersionStatus,
130}
131
132impl Probe {
133 pub async fn run(agent: Agent) -> Result<Probe> {
139 Probe::run_bin(agent, agent.bin()).await
140 }
141
142 pub async fn run_bin(agent: Agent, bin: &str) -> Result<Probe> {
149 let output = tokio::process::Command::new(bin)
150 .arg("--version")
151 .output()
152 .await
153 .map_err(|source| {
154 if source.kind() == std::io::ErrorKind::NotFound {
155 Error::NotInstalled {
156 agent,
157 bin: bin.to_string(),
158 hint: agent.install_hint(),
159 }
160 } else {
161 Error::Spawn {
162 bin: bin.to_string(),
163 source,
164 }
165 }
166 })?;
167
168 let mut reported = String::from_utf8_lossy(&output.stdout).trim().to_string();
170 if reported.is_empty() {
171 reported = String::from_utf8_lossy(&output.stderr).trim().to_string();
172 }
173
174 let verified = agent.verified_version();
175 let version = Version::find(&reported);
176 let status = match version {
177 None => VersionStatus::Unrecognized,
178 Some(found) if found == verified => VersionStatus::Verified,
179 Some(found) if found > verified => VersionStatus::Newer,
180 Some(_) => VersionStatus::Older,
181 };
182
183 Ok(Probe {
184 agent,
185 bin: bin.to_string(),
186 reported,
187 version,
188 verified,
189 status,
190 })
191 }
192
193 #[must_use]
198 pub fn advisory(&self) -> Option<String> {
199 let agent = self.agent;
200 let verified = self.verified;
201 match self.status {
202 VersionStatus::Verified => None,
203 VersionStatus::Newer => Some(format!(
204 "{agent} is newer than the {verified} this crate's flags were verified \
205 against ({}). Flags may have been renamed or moved between subcommands; \
206 a run failing with an unexpected-argument error is the likely symptom.",
207 self.version
208 .map_or_else(|| "unknown".into(), |v| v.to_string()),
209 )),
210 VersionStatus::Older => Some(format!(
211 "{agent} is older than the {verified} this crate's flags were verified \
212 against ({}). Flags this crate relies on may not exist in it yet.",
213 self.version
214 .map_or_else(|| "unknown".into(), |v| v.to_string()),
215 )),
216 VersionStatus::Unrecognized => Some(format!(
217 "could not read a version out of what {agent} reported ({:?}), so its flags \
218 cannot be checked against the verified {verified}.",
219 self.reported,
220 )),
221 }
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
231 fn versions_are_found_inside_each_cli_s_own_prose() {
232 assert_eq!(
233 Version::find("2.1.205 (Claude Code)"),
234 Some(Version {
235 major: 2,
236 minor: 1,
237 patch: 205
238 })
239 );
240 assert_eq!(
241 Version::find("codex-cli 0.145.0"),
242 Some(Version {
243 major: 0,
244 minor: 145,
245 patch: 0
246 })
247 );
248 assert_eq!(
250 Version::find("GitHub Copilot CLI 1.0.75."),
251 Some(Version {
252 major: 1,
253 minor: 0,
254 patch: 75
255 })
256 );
257 }
258
259 #[test]
260 fn text_without_a_triple_yields_nothing() {
261 for text in ["", "no version here", "1.2", "v1", "beta"] {
262 assert_eq!(Version::find(text), None, "{text:?}");
263 }
264 }
265
266 #[test]
268 fn a_triple_is_only_read_from_a_token_boundary() {
269 assert_eq!(
270 Version::find("build20251.0.75"),
271 Some(Version {
272 major: 20251,
273 minor: 0,
274 patch: 75
275 }),
276 "the whole leading number belongs to the version"
277 );
278 }
279
280 #[test]
281 fn versions_order_numerically_not_lexically() {
282 let v = |major, minor, patch| Version {
283 major,
284 minor,
285 patch,
286 };
287 assert!(v(2, 1, 205) > v(2, 1, 99));
289 assert!(v(0, 145, 0) > v(0, 99, 9));
290 assert!(v(1, 0, 0) > v(0, 999, 999));
291 }
292
293 #[test]
294 fn every_agent_declares_a_parseable_verified_version() {
295 for agent in Agent::ALL {
296 let verified = agent.verified_version();
297 assert!(verified.major > 0 || verified.minor > 0, "{agent}");
298 }
299 }
300
301 #[tokio::test]
302 async fn probing_a_missing_binary_says_how_to_install_it() {
303 let err = Probe::run_bin(Agent::Claude, "agent-abstraction-no-such-binary")
304 .await
305 .unwrap_err();
306 assert!(matches!(err, Error::NotInstalled { .. }), "{err:?}");
307 }
308}