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