use std::fmt;
use crate::agent::Agent;
use crate::error::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Version {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl Version {
#[must_use]
pub fn find(text: &str) -> Option<Version> {
let bytes = text.as_bytes();
let mut start = 0;
while start < bytes.len() {
if bytes[start].is_ascii_digit()
&& (start == 0 || !bytes[start - 1].is_ascii_digit() && bytes[start - 1] != b'.')
&& let Some(version) = Version::parse_at(&text[start..])
{
return Some(version);
}
start += 1;
}
None
}
fn parse_at(text: &str) -> Option<Version> {
let mut parts = [0u32; 3];
let mut rest = text;
for (index, part) in parts.iter_mut().enumerate() {
if index > 0 {
rest = rest.strip_prefix('.')?;
}
let digits: String = rest.chars().take_while(char::is_ascii_digit).collect();
if digits.is_empty() {
return None;
}
*part = digits.parse().ok()?;
rest = &rest[digits.len()..];
}
Some(Version {
major: parts[0],
minor: parts[1],
patch: parts[2],
})
}
}
impl fmt::Display for Version {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VersionStatus {
Verified,
Newer,
Older,
Unrecognized,
}
impl VersionStatus {
#[must_use]
pub fn is_verified(self) -> bool {
self == VersionStatus::Verified
}
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct Probe {
pub agent: Agent,
pub bin: String,
pub reported: String,
pub version: Option<Version>,
pub verified: Version,
pub status: VersionStatus,
}
impl Probe {
pub async fn run(agent: Agent) -> Result<Probe> {
Probe::run_bin(agent, agent.bin()).await
}
pub async fn run_bin(agent: Agent, bin: &str) -> Result<Probe> {
let output = tokio::process::Command::new(bin)
.arg("--version")
.output()
.await
.map_err(|source| {
if source.kind() == std::io::ErrorKind::NotFound {
Error::NotInstalled {
agent,
bin: bin.to_string(),
hint: agent.install_hint(),
}
} else {
Error::Spawn {
bin: bin.to_string(),
source,
}
}
})?;
let mut reported = String::from_utf8_lossy(&output.stdout).trim().to_string();
if reported.is_empty() {
reported = String::from_utf8_lossy(&output.stderr).trim().to_string();
}
let verified = agent.verified_version();
let version = Version::find(&reported);
let status = match version {
None => VersionStatus::Unrecognized,
Some(found) if found == verified => VersionStatus::Verified,
Some(found) if found > verified => VersionStatus::Newer,
Some(_) => VersionStatus::Older,
};
Ok(Probe {
agent,
bin: bin.to_string(),
reported,
version,
verified,
status,
})
}
#[must_use]
pub fn advisory(&self) -> Option<String> {
let agent = self.agent;
let verified = self.verified;
match self.status {
VersionStatus::Verified => None,
VersionStatus::Newer => Some(format!(
"{agent} is newer than the {verified} this crate's flags were verified \
against ({}). Flags may have been renamed or moved between subcommands; \
a run failing with an unexpected-argument error is the likely symptom.",
self.version
.map_or_else(|| "unknown".into(), |v| v.to_string()),
)),
VersionStatus::Older => Some(format!(
"{agent} is older than the {verified} this crate's flags were verified \
against ({}). Flags this crate relies on may not exist in it yet.",
self.version
.map_or_else(|| "unknown".into(), |v| v.to_string()),
)),
VersionStatus::Unrecognized => Some(format!(
"could not read a version out of what {agent} reported ({:?}), so its flags \
cannot be checked against the verified {verified}.",
self.reported,
)),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn versions_are_found_inside_each_cli_s_own_prose() {
assert_eq!(
Version::find("2.1.205 (Claude Code)"),
Some(Version {
major: 2,
minor: 1,
patch: 205
})
);
assert_eq!(
Version::find("codex-cli 0.145.0"),
Some(Version {
major: 0,
minor: 145,
patch: 0
})
);
assert_eq!(
Version::find("GitHub Copilot CLI 1.0.75."),
Some(Version {
major: 1,
minor: 0,
patch: 75
})
);
}
#[test]
fn text_without_a_triple_yields_nothing() {
for text in ["", "no version here", "1.2", "v1", "beta"] {
assert_eq!(Version::find(text), None, "{text:?}");
}
}
#[test]
fn a_triple_is_only_read_from_a_token_boundary() {
assert_eq!(
Version::find("build20251.0.75"),
Some(Version {
major: 20251,
minor: 0,
patch: 75
}),
"the whole leading number belongs to the version"
);
}
#[test]
fn versions_order_numerically_not_lexically() {
let v = |major, minor, patch| Version {
major,
minor,
patch,
};
assert!(v(2, 1, 205) > v(2, 1, 99));
assert!(v(0, 145, 0) > v(0, 99, 9));
assert!(v(1, 0, 0) > v(0, 999, 999));
}
#[test]
fn every_agent_declares_a_parseable_verified_version() {
for agent in Agent::ALL {
let verified = agent.verified_version();
assert!(verified.major > 0 || verified.minor > 0, "{agent}");
}
}
#[tokio::test]
async fn probing_a_missing_binary_says_how_to_install_it() {
let err = Probe::run_bin(Agent::Claude, "agent-abstraction-no-such-binary")
.await
.unwrap_err();
assert!(matches!(err, Error::NotInstalled { .. }), "{err:?}");
}
}