use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
pub struct CliVersion {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl CliVersion {
#[must_use]
pub fn new(major: u32, minor: u32, patch: u32) -> Self {
Self {
major,
minor,
patch,
}
}
pub fn parse_version_output(output: &str) -> Result<Self, VersionParseError> {
let version_str = output.split_whitespace().next().unwrap_or("");
version_str.parse()
}
#[must_use]
pub fn satisfies_minimum(&self, minimum: &CliVersion) -> bool {
self >= minimum
}
#[must_use]
pub fn status_within(&self, min: &CliVersion, max: &CliVersion) -> CliVersionStatus {
if self < min {
CliVersionStatus::OlderThanMinimum {
found: *self,
minimum: *min,
}
} else if self > max {
CliVersionStatus::NewerUntested {
found: *self,
tested_max: *max,
}
} else {
CliVersionStatus::Tested
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum CliVersionStatus {
Tested,
NewerUntested {
found: CliVersion,
tested_max: CliVersion,
},
OlderThanMinimum {
found: CliVersion,
minimum: CliVersion,
},
}
impl CliVersionStatus {
#[must_use]
pub fn is_tested(self) -> bool {
matches!(self, CliVersionStatus::Tested)
}
}
impl PartialOrd for CliVersion {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for CliVersion {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.major
.cmp(&other.major)
.then(self.minor.cmp(&other.minor))
.then(self.patch.cmp(&other.patch))
}
}
impl fmt::Display for CliVersion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
}
}
impl FromStr for CliVersion {
type Err = VersionParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let parts: Vec<&str> = s.split('.').collect();
if parts.len() != 3 {
return Err(VersionParseError(s.to_string()));
}
let major = parts[0]
.parse()
.map_err(|_| VersionParseError(s.to_string()))?;
let minor = parts[1]
.parse()
.map_err(|_| VersionParseError(s.to_string()))?;
let patch = parts[2]
.parse()
.map_err(|_| VersionParseError(s.to_string()))?;
Ok(Self {
major,
minor,
patch,
})
}
}
#[derive(Debug, Clone, thiserror::Error)]
#[error("invalid version string: {0:?}")]
pub struct VersionParseError(pub String);
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple() {
let v: CliVersion = "2.1.71".parse().unwrap();
assert_eq!(v.major, 2);
assert_eq!(v.minor, 1);
assert_eq!(v.patch, 71);
}
#[test]
fn test_parse_version_output() {
let v = CliVersion::parse_version_output("2.1.71 (Claude Code)").unwrap();
assert_eq!(v, CliVersion::new(2, 1, 71));
}
#[test]
fn test_parse_version_output_trimmed() {
let v = CliVersion::parse_version_output(" 2.1.71 (Claude Code)\n").unwrap();
assert_eq!(v, CliVersion::new(2, 1, 71));
}
#[test]
fn test_display() {
let v = CliVersion::new(2, 1, 71);
assert_eq!(v.to_string(), "2.1.71");
}
#[test]
fn test_ordering() {
let v1 = CliVersion::new(2, 0, 0);
let v2 = CliVersion::new(2, 1, 0);
let v3 = CliVersion::new(2, 1, 71);
let v4 = CliVersion::new(3, 0, 0);
assert!(v1 < v2);
assert!(v2 < v3);
assert!(v3 < v4);
assert!(v1 < v4);
}
#[test]
fn test_satisfies_minimum() {
let v = CliVersion::new(2, 1, 71);
assert!(v.satisfies_minimum(&CliVersion::new(2, 0, 0)));
assert!(v.satisfies_minimum(&CliVersion::new(2, 1, 71)));
assert!(!v.satisfies_minimum(&CliVersion::new(2, 2, 0)));
assert!(!v.satisfies_minimum(&CliVersion::new(3, 0, 0)));
}
#[test]
fn test_parse_invalid() {
assert!("not-a-version".parse::<CliVersion>().is_err());
assert!("2.1".parse::<CliVersion>().is_err());
assert!("2.1.x".parse::<CliVersion>().is_err());
}
#[test]
fn status_tested_at_min() {
let v = CliVersion::new(2, 1, 0);
let s = v.status_within(&CliVersion::new(2, 1, 0), &CliVersion::new(2, 1, 999));
assert_eq!(s, CliVersionStatus::Tested);
assert!(s.is_tested());
}
#[test]
fn status_tested_at_max() {
let v = CliVersion::new(2, 1, 999);
let s = v.status_within(&CliVersion::new(2, 1, 0), &CliVersion::new(2, 1, 999));
assert_eq!(s, CliVersionStatus::Tested);
}
#[test]
fn status_tested_in_middle() {
let v = CliVersion::new(2, 1, 143);
let s = v.status_within(&CliVersion::new(2, 1, 0), &CliVersion::new(2, 1, 999));
assert_eq!(s, CliVersionStatus::Tested);
}
#[test]
fn status_newer_untested_above_max() {
let v = CliVersion::new(2, 2, 0);
let s = v.status_within(&CliVersion::new(2, 1, 0), &CliVersion::new(2, 1, 999));
assert_eq!(
s,
CliVersionStatus::NewerUntested {
found: v,
tested_max: CliVersion::new(2, 1, 999),
}
);
assert!(!s.is_tested());
}
#[test]
fn status_older_than_minimum() {
let v = CliVersion::new(2, 0, 99);
let s = v.status_within(&CliVersion::new(2, 1, 0), &CliVersion::new(2, 1, 999));
assert_eq!(
s,
CliVersionStatus::OlderThanMinimum {
found: v,
minimum: CliVersion::new(2, 1, 0),
}
);
assert!(!s.is_tested());
}
#[test]
fn status_serializes_to_tagged_json() {
let s = CliVersionStatus::Tested;
assert_eq!(serde_json::to_string(&s).unwrap(), r#"{"status":"tested"}"#);
let s = CliVersionStatus::NewerUntested {
found: CliVersion::new(2, 2, 0),
tested_max: CliVersion::new(2, 1, 999),
};
let json: serde_json::Value =
serde_json::from_str(&serde_json::to_string(&s).unwrap()).expect("re-parse json");
assert_eq!(json["status"], "newer_untested");
assert_eq!(json["found"]["major"], 2);
assert_eq!(json["tested_max"]["minor"], 1);
}
}