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
141pub const TESTED_CLI_VERSION_MIN: CliVersion = CliVersion {
154 major: 2,
155 minor: 1,
156 patch: 98,
157};
158
159pub const TESTED_CLI_VERSION_MAX: CliVersion = CliVersion {
173 major: 2,
174 minor: 1,
175 patch: 999,
176};
177
178impl fmt::Display for CliVersion {
179 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180 write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
181 }
182}
183
184impl FromStr for CliVersion {
185 type Err = VersionParseError;
186
187 fn from_str(s: &str) -> Result<Self, Self::Err> {
188 let parts: Vec<&str> = s.split('.').collect();
189 if parts.len() != 3 {
190 return Err(VersionParseError(s.to_string()));
191 }
192
193 let major = parts[0]
194 .parse()
195 .map_err(|_| VersionParseError(s.to_string()))?;
196 let minor = parts[1]
197 .parse()
198 .map_err(|_| VersionParseError(s.to_string()))?;
199 let patch = parts[2]
200 .parse()
201 .map_err(|_| VersionParseError(s.to_string()))?;
202
203 Ok(Self {
204 major,
205 minor,
206 patch,
207 })
208 }
209}
210
211#[derive(Debug, Clone, thiserror::Error)]
213#[error("invalid version string: {0:?}")]
214pub struct VersionParseError(pub String);
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn test_parse_simple() {
222 let v: CliVersion = "2.1.71".parse().unwrap();
223 assert_eq!(v.major, 2);
224 assert_eq!(v.minor, 1);
225 assert_eq!(v.patch, 71);
226 }
227
228 #[test]
229 fn test_parse_version_output() {
230 let v = CliVersion::parse_version_output("2.1.71 (Claude Code)").unwrap();
231 assert_eq!(v, CliVersion::new(2, 1, 71));
232 }
233
234 #[test]
235 fn test_parse_version_output_trimmed() {
236 let v = CliVersion::parse_version_output(" 2.1.71 (Claude Code)\n").unwrap();
237 assert_eq!(v, CliVersion::new(2, 1, 71));
238 }
239
240 #[test]
241 fn test_display() {
242 let v = CliVersion::new(2, 1, 71);
243 assert_eq!(v.to_string(), "2.1.71");
244 }
245
246 #[test]
247 fn test_ordering() {
248 let v1 = CliVersion::new(2, 0, 0);
249 let v2 = CliVersion::new(2, 1, 0);
250 let v3 = CliVersion::new(2, 1, 71);
251 let v4 = CliVersion::new(3, 0, 0);
252
253 assert!(v1 < v2);
254 assert!(v2 < v3);
255 assert!(v3 < v4);
256 assert!(v1 < v4);
257 }
258
259 #[test]
260 fn test_satisfies_minimum() {
261 let v = CliVersion::new(2, 1, 71);
262 assert!(v.satisfies_minimum(&CliVersion::new(2, 0, 0)));
263 assert!(v.satisfies_minimum(&CliVersion::new(2, 1, 71)));
264 assert!(!v.satisfies_minimum(&CliVersion::new(2, 2, 0)));
265 assert!(!v.satisfies_minimum(&CliVersion::new(3, 0, 0)));
266 }
267
268 #[test]
269 fn test_parse_invalid() {
270 assert!("not-a-version".parse::<CliVersion>().is_err());
271 assert!("2.1".parse::<CliVersion>().is_err());
272 assert!("2.1.x".parse::<CliVersion>().is_err());
273 }
274
275 #[test]
278 fn tested_range_is_ordered() {
279 assert!(
280 TESTED_CLI_VERSION_MIN <= TESTED_CLI_VERSION_MAX,
281 "declared tested range is inverted: {TESTED_CLI_VERSION_MIN} > {TESTED_CLI_VERSION_MAX}"
282 );
283 }
284
285 #[test]
286 fn tested_range_bounds_classify_as_tested() {
287 for v in [TESTED_CLI_VERSION_MIN, TESTED_CLI_VERSION_MAX] {
290 assert_eq!(
291 v.status_within(&TESTED_CLI_VERSION_MIN, &TESTED_CLI_VERSION_MAX),
292 CliVersionStatus::Tested,
293 "{v} should classify as Tested"
294 );
295 }
296 }
297
298 fn ci_claude_version_axis(yaml: &str) -> Option<String> {
304 let (_, rest) = yaml.split_once("claude_version:")?;
305 Some(rest.chars().take_while(|c| *c != ']').collect())
306 }
307
308 #[test]
309 fn ci_matrix_axis_parsing_works() {
310 assert_eq!(ci_claude_version_axis("no matrix here"), None);
311 let yaml = " matrix:\n claude_version: [\"2.1.0\", \"2.1.999\"]\n";
312 let axis = ci_claude_version_axis(yaml).expect("axis found");
313 assert!(axis.contains("2.1.0"));
314 assert!(axis.contains("2.1.999"));
315 assert!(!axis.contains("2.2.0"));
316 }
317
318 #[test]
326 fn tested_range_matches_the_ci_contract_matrix() {
327 let path = concat!(env!("CARGO_MANIFEST_DIR"), "/.github/workflows/ci.yml");
328 let Ok(yaml) = std::fs::read_to_string(path) else {
329 return;
330 };
331 let Some(axis) = ci_claude_version_axis(&yaml) else {
332 return;
333 };
334 for bound in [TESTED_CLI_VERSION_MIN, TESTED_CLI_VERSION_MAX] {
335 assert!(
336 axis.contains(&bound.to_string()),
337 "declared tested bound {bound} is not in ci.yml's claude_version matrix \
338 ({axis:?}); either add it to CI or do not claim it here"
339 );
340 }
341 }
342
343 #[test]
346 fn status_tested_at_min() {
347 let v = CliVersion::new(2, 1, 0);
348 let s = v.status_within(&CliVersion::new(2, 1, 0), &CliVersion::new(2, 1, 999));
349 assert_eq!(s, CliVersionStatus::Tested);
350 assert!(s.is_tested());
351 }
352
353 #[test]
354 fn status_tested_at_max() {
355 let v = CliVersion::new(2, 1, 999);
356 let s = v.status_within(&CliVersion::new(2, 1, 0), &CliVersion::new(2, 1, 999));
357 assert_eq!(s, CliVersionStatus::Tested);
358 }
359
360 #[test]
361 fn status_tested_in_middle() {
362 let v = CliVersion::new(2, 1, 143);
363 let s = v.status_within(&CliVersion::new(2, 1, 0), &CliVersion::new(2, 1, 999));
364 assert_eq!(s, CliVersionStatus::Tested);
365 }
366
367 #[test]
368 fn status_newer_untested_above_max() {
369 let v = CliVersion::new(2, 2, 0);
370 let s = v.status_within(&CliVersion::new(2, 1, 0), &CliVersion::new(2, 1, 999));
371 assert_eq!(
372 s,
373 CliVersionStatus::NewerUntested {
374 found: v,
375 tested_max: CliVersion::new(2, 1, 999),
376 }
377 );
378 assert!(!s.is_tested());
379 }
380
381 #[test]
382 fn status_older_than_minimum() {
383 let v = CliVersion::new(2, 0, 99);
384 let s = v.status_within(&CliVersion::new(2, 1, 0), &CliVersion::new(2, 1, 999));
385 assert_eq!(
386 s,
387 CliVersionStatus::OlderThanMinimum {
388 found: v,
389 minimum: CliVersion::new(2, 1, 0),
390 }
391 );
392 assert!(!s.is_tested());
393 }
394
395 #[test]
396 fn status_serializes_to_tagged_json() {
397 let s = CliVersionStatus::Tested;
398 assert_eq!(serde_json::to_string(&s).unwrap(), r#"{"status":"tested"}"#);
399
400 let s = CliVersionStatus::NewerUntested {
401 found: CliVersion::new(2, 2, 0),
402 tested_max: CliVersion::new(2, 1, 999),
403 };
404 let json: serde_json::Value =
405 serde_json::from_str(&serde_json::to_string(&s).unwrap()).expect("re-parse json");
406 assert_eq!(json["status"], "newer_untested");
407 assert_eq!(json["found"]["major"], 2);
408 assert_eq!(json["tested_max"]["minor"], 1);
409 }
410}