1use std::fmt;
2use std::str::FromStr;
3
4#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
20pub struct CliVersion {
21 pub major: u32,
22 pub minor: u32,
23 pub patch: u32,
24}
25
26impl CliVersion {
27 #[must_use]
29 pub fn new(major: u32, minor: u32, patch: u32) -> Self {
30 Self {
31 major,
32 minor,
33 patch,
34 }
35 }
36
37 pub fn parse_version_output(output: &str) -> Result<Self, VersionParseError> {
41 let version_str = output.split_whitespace().next().unwrap_or("");
42 version_str.parse()
43 }
44
45 #[must_use]
47 pub fn satisfies_minimum(&self, minimum: &CliVersion) -> bool {
48 self >= minimum
49 }
50
51 #[must_use]
61 pub fn status_within(&self, min: &CliVersion, max: &CliVersion) -> CliVersionStatus {
62 if self < min {
63 CliVersionStatus::OlderThanMinimum {
64 found: *self,
65 minimum: *min,
66 }
67 } else if self > max {
68 CliVersionStatus::NewerUntested {
69 found: *self,
70 tested_max: *max,
71 }
72 } else {
73 CliVersionStatus::Tested
74 }
75 }
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
82#[serde(tag = "status", rename_all = "snake_case")]
83pub enum CliVersionStatus {
84 Tested,
86 NewerUntested {
90 found: CliVersion,
92 tested_max: CliVersion,
94 },
95 OlderThanMinimum {
99 found: CliVersion,
101 minimum: CliVersion,
103 },
104}
105
106impl CliVersionStatus {
107 #[must_use]
111 pub fn is_tested(self) -> bool {
112 matches!(self, CliVersionStatus::Tested)
113 }
114}
115
116impl PartialOrd for CliVersion {
117 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
118 Some(self.cmp(other))
119 }
120}
121
122impl Ord for CliVersion {
123 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
124 self.major
125 .cmp(&other.major)
126 .then(self.minor.cmp(&other.minor))
127 .then(self.patch.cmp(&other.patch))
128 }
129}
130
131impl fmt::Display for CliVersion {
132 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
134 }
135}
136
137impl FromStr for CliVersion {
138 type Err = VersionParseError;
139
140 fn from_str(s: &str) -> Result<Self, Self::Err> {
141 let parts: Vec<&str> = s.split('.').collect();
142 if parts.len() != 3 {
143 return Err(VersionParseError(s.to_string()));
144 }
145
146 let major = parts[0]
147 .parse()
148 .map_err(|_| VersionParseError(s.to_string()))?;
149 let minor = parts[1]
150 .parse()
151 .map_err(|_| VersionParseError(s.to_string()))?;
152 let patch = parts[2]
153 .parse()
154 .map_err(|_| VersionParseError(s.to_string()))?;
155
156 Ok(Self {
157 major,
158 minor,
159 patch,
160 })
161 }
162}
163
164#[derive(Debug, Clone, thiserror::Error)]
166#[error("invalid version string: {0:?}")]
167pub struct VersionParseError(pub String);
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172
173 #[test]
174 fn test_parse_simple() {
175 let v: CliVersion = "2.1.71".parse().unwrap();
176 assert_eq!(v.major, 2);
177 assert_eq!(v.minor, 1);
178 assert_eq!(v.patch, 71);
179 }
180
181 #[test]
182 fn test_parse_version_output() {
183 let v = CliVersion::parse_version_output("2.1.71 (Claude Code)").unwrap();
184 assert_eq!(v, CliVersion::new(2, 1, 71));
185 }
186
187 #[test]
188 fn test_parse_version_output_trimmed() {
189 let v = CliVersion::parse_version_output(" 2.1.71 (Claude Code)\n").unwrap();
190 assert_eq!(v, CliVersion::new(2, 1, 71));
191 }
192
193 #[test]
194 fn test_display() {
195 let v = CliVersion::new(2, 1, 71);
196 assert_eq!(v.to_string(), "2.1.71");
197 }
198
199 #[test]
200 fn test_ordering() {
201 let v1 = CliVersion::new(2, 0, 0);
202 let v2 = CliVersion::new(2, 1, 0);
203 let v3 = CliVersion::new(2, 1, 71);
204 let v4 = CliVersion::new(3, 0, 0);
205
206 assert!(v1 < v2);
207 assert!(v2 < v3);
208 assert!(v3 < v4);
209 assert!(v1 < v4);
210 }
211
212 #[test]
213 fn test_satisfies_minimum() {
214 let v = CliVersion::new(2, 1, 71);
215 assert!(v.satisfies_minimum(&CliVersion::new(2, 0, 0)));
216 assert!(v.satisfies_minimum(&CliVersion::new(2, 1, 71)));
217 assert!(!v.satisfies_minimum(&CliVersion::new(2, 2, 0)));
218 assert!(!v.satisfies_minimum(&CliVersion::new(3, 0, 0)));
219 }
220
221 #[test]
222 fn test_parse_invalid() {
223 assert!("not-a-version".parse::<CliVersion>().is_err());
224 assert!("2.1".parse::<CliVersion>().is_err());
225 assert!("2.1.x".parse::<CliVersion>().is_err());
226 }
227
228 #[test]
231 fn status_tested_at_min() {
232 let v = CliVersion::new(2, 1, 0);
233 let s = v.status_within(&CliVersion::new(2, 1, 0), &CliVersion::new(2, 1, 999));
234 assert_eq!(s, CliVersionStatus::Tested);
235 assert!(s.is_tested());
236 }
237
238 #[test]
239 fn status_tested_at_max() {
240 let v = CliVersion::new(2, 1, 999);
241 let s = v.status_within(&CliVersion::new(2, 1, 0), &CliVersion::new(2, 1, 999));
242 assert_eq!(s, CliVersionStatus::Tested);
243 }
244
245 #[test]
246 fn status_tested_in_middle() {
247 let v = CliVersion::new(2, 1, 143);
248 let s = v.status_within(&CliVersion::new(2, 1, 0), &CliVersion::new(2, 1, 999));
249 assert_eq!(s, CliVersionStatus::Tested);
250 }
251
252 #[test]
253 fn status_newer_untested_above_max() {
254 let v = CliVersion::new(2, 2, 0);
255 let s = v.status_within(&CliVersion::new(2, 1, 0), &CliVersion::new(2, 1, 999));
256 assert_eq!(
257 s,
258 CliVersionStatus::NewerUntested {
259 found: v,
260 tested_max: CliVersion::new(2, 1, 999),
261 }
262 );
263 assert!(!s.is_tested());
264 }
265
266 #[test]
267 fn status_older_than_minimum() {
268 let v = CliVersion::new(2, 0, 99);
269 let s = v.status_within(&CliVersion::new(2, 1, 0), &CliVersion::new(2, 1, 999));
270 assert_eq!(
271 s,
272 CliVersionStatus::OlderThanMinimum {
273 found: v,
274 minimum: CliVersion::new(2, 1, 0),
275 }
276 );
277 assert!(!s.is_tested());
278 }
279
280 #[test]
281 fn status_serializes_to_tagged_json() {
282 let s = CliVersionStatus::Tested;
283 assert_eq!(serde_json::to_string(&s).unwrap(), r#"{"status":"tested"}"#);
284
285 let s = CliVersionStatus::NewerUntested {
286 found: CliVersion::new(2, 2, 0),
287 tested_max: CliVersion::new(2, 1, 999),
288 };
289 let json: serde_json::Value =
290 serde_json::from_str(&serde_json::to_string(&s).unwrap()).expect("re-parse json");
291 assert_eq!(json["status"], "newer_untested");
292 assert_eq!(json["found"]["major"], 2);
293 assert_eq!(json["tested_max"]["minor"], 1);
294 }
295}