Skip to main content

claude_wrapper/
version.rs

1//! Claude CLI version parsing and tested-range checks.
2//!
3//! [`CliVersion`] parses the `claude --version` string; the helpers
4//! here compare it against the range this crate is tested against and
5//! surface drift (via [`CliVersionStatus`] and a `tracing::warn!`) so a
6//! host can react to an unexpectedly old or new CLI.
7
8use std::fmt;
9use std::str::FromStr;
10
11/// A parsed Claude CLI version (semver).
12///
13/// # Example
14///
15/// ```
16/// use claude_wrapper::CliVersion;
17///
18/// let v: CliVersion = "2.1.71".parse().unwrap();
19/// assert_eq!(v.major, 2);
20/// assert_eq!(v.minor, 1);
21/// assert_eq!(v.patch, 71);
22///
23/// let min: CliVersion = "2.1.0".parse().unwrap();
24/// assert!(v >= min);
25/// ```
26#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
27pub struct CliVersion {
28    /// Major version component.
29    pub major: u32,
30    /// Minor version component.
31    pub minor: u32,
32    /// Patch version component.
33    pub patch: u32,
34}
35
36impl CliVersion {
37    /// Create a new version.
38    #[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    /// Parse a version from the output of `claude --version`.
48    ///
49    /// Expects format like `"2.1.71 (Claude Code)"` or just `"2.1.71"`.
50    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    /// Check if this version satisfies a minimum version requirement.
56    #[must_use]
57    pub fn satisfies_minimum(&self, minimum: &CliVersion) -> bool {
58        self >= minimum
59    }
60
61    /// Classify this version against a tested-against `[min, max]`
62    /// range (both inclusive).
63    ///
64    /// Use to decide whether a host should warn about CLI drift.
65    /// The minimum is the floor we've verified the wrapper still
66    /// works against; the maximum is the upper end of the
67    /// tested-against window. A version below the minimum is a hard
68    /// "we know this is broken"; a version above the maximum is a
69    /// soft "we haven't verified this; semantics may have drifted."
70    #[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/// Classification of an installed CLI version against a tested
89/// range. Returned by [`CliVersion::status_within`] and
90/// [`crate::Claude::cli_version_status`].
91#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
92#[serde(tag = "status", rename_all = "snake_case")]
93pub enum CliVersionStatus {
94    /// CLI version is within the tested-against range.
95    Tested,
96    /// CLI is newer than the wrapper's tested-against maximum.
97    /// Semantics may have drifted; the wrapper should still
98    /// generally work but unexpected behavior is possible.
99    NewerUntested {
100        /// The installed CLI version.
101        found: CliVersion,
102        /// Highest CLI version the wrapper has been tested against.
103        tested_max: CliVersion,
104    },
105    /// CLI is older than the declared minimum. The wrapper is
106    /// known to behave incorrectly against this version (missing
107    /// flags, different argument shapes).
108    OlderThanMinimum {
109        /// The installed CLI version.
110        found: CliVersion,
111        /// Lowest CLI version the wrapper supports.
112        minimum: CliVersion,
113    },
114}
115
116impl CliVersionStatus {
117    /// True only for [`CliVersionStatus::Tested`]. Useful for
118    /// callers branching on "should I run?" without pattern
119    /// matching every variant.
120    #[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
141/// Lowest `claude` CLI version this crate is tested against.
142///
143/// Below this the wrapper may emit flags the CLI does not have. The name is
144/// the exact claim: lowest *tested*, not lowest working. 2.1.220 is verified,
145/// by running the contract suite against it with every flag the builders emit;
146/// the true minimum is somewhere below and is not worth the releases it would
147/// take to find, because nothing rests on knowing it.
148///
149/// This was 2.1.98, described here as bisected. That was true of the subset
150/// the contract suite exercised at the time, and not true of the builders:
151/// `--plugin-url`, `--safe-mode`, and `--prompt-suggestions` were emitted by
152/// builders no maximal command covered, and 2.1.98 has none of them. The
153/// coverage check added in #797 surfaced that, and #800 resolved it by moving
154/// the floor to a version the whole emitted set is checked against rather than
155/// by teaching the suite to describe a range spanning 500-odd releases.
156///
157/// Raising this is a support decision, and a cheap one: the gate is opt-in via
158/// [`Claude::ensure_tested_cli_version`](crate::Claude::ensure_tested_cli_version),
159/// and a host pinned to an older CLI can set its own bounds with
160/// [`ClaudeBuilder::tested_cli_version_range`](crate::ClaudeBuilder::tested_cli_version_range).
161/// Lowering it is a claim that must be measured, because the failure it
162/// prevents is silent: an invocation that looks right and is rejected by the
163/// binary.
164pub const TESTED_CLI_VERSION_MIN: CliVersion = CliVersion {
165    major: 2,
166    minor: 1,
167    patch: 220,
168};
169
170/// Highest `claude` CLI version this crate has been exercised against.
171///
172/// Above this the wrapper generally still works, but semantics may have
173/// drifted, so [`Claude::cli_version_status`](crate::Claude::cli_version_status)
174/// reports [`CliVersionStatus::NewerUntested`] rather than failing.
175///
176/// # Bumping this
177///
178/// Raising either bound is a claim about coverage, so it comes with work:
179/// add that version to the CI matrix and fix whatever drift the contract check
180/// reports. The `tested_range_matches_the_ci_contract_matrix` test below fails
181/// if a bound is declared here but never exercised in CI, which is what keeps
182/// the constants honest rather than aspirational.
183pub const TESTED_CLI_VERSION_MAX: CliVersion = CliVersion {
184    major: 2,
185    minor: 1,
186    patch: 999,
187};
188
189impl fmt::Display for CliVersion {
190    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
191        write!(f, "{}.{}.{}", self.major, self.minor, self.patch)
192    }
193}
194
195impl FromStr for CliVersion {
196    type Err = VersionParseError;
197
198    fn from_str(s: &str) -> Result<Self, Self::Err> {
199        let parts: Vec<&str> = s.split('.').collect();
200        if parts.len() != 3 {
201            return Err(VersionParseError(s.to_string()));
202        }
203
204        let major = parts[0]
205            .parse()
206            .map_err(|_| VersionParseError(s.to_string()))?;
207        let minor = parts[1]
208            .parse()
209            .map_err(|_| VersionParseError(s.to_string()))?;
210        let patch = parts[2]
211            .parse()
212            .map_err(|_| VersionParseError(s.to_string()))?;
213
214        Ok(Self {
215            major,
216            minor,
217            patch,
218        })
219    }
220}
221
222/// Error returned when a version string cannot be parsed.
223#[derive(Debug, Clone, thiserror::Error)]
224#[error("invalid version string: {0:?}")]
225pub struct VersionParseError(pub String);
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230
231    #[test]
232    fn test_parse_simple() {
233        let v: CliVersion = "2.1.71".parse().unwrap();
234        assert_eq!(v.major, 2);
235        assert_eq!(v.minor, 1);
236        assert_eq!(v.patch, 71);
237    }
238
239    #[test]
240    fn test_parse_version_output() {
241        let v = CliVersion::parse_version_output("2.1.71 (Claude Code)").unwrap();
242        assert_eq!(v, CliVersion::new(2, 1, 71));
243    }
244
245    #[test]
246    fn test_parse_version_output_trimmed() {
247        let v = CliVersion::parse_version_output("  2.1.71 (Claude Code)\n").unwrap();
248        assert_eq!(v, CliVersion::new(2, 1, 71));
249    }
250
251    #[test]
252    fn test_display() {
253        let v = CliVersion::new(2, 1, 71);
254        assert_eq!(v.to_string(), "2.1.71");
255    }
256
257    #[test]
258    fn test_ordering() {
259        let v1 = CliVersion::new(2, 0, 0);
260        let v2 = CliVersion::new(2, 1, 0);
261        let v3 = CliVersion::new(2, 1, 71);
262        let v4 = CliVersion::new(3, 0, 0);
263
264        assert!(v1 < v2);
265        assert!(v2 < v3);
266        assert!(v3 < v4);
267        assert!(v1 < v4);
268    }
269
270    #[test]
271    fn test_satisfies_minimum() {
272        let v = CliVersion::new(2, 1, 71);
273        assert!(v.satisfies_minimum(&CliVersion::new(2, 0, 0)));
274        assert!(v.satisfies_minimum(&CliVersion::new(2, 1, 71)));
275        assert!(!v.satisfies_minimum(&CliVersion::new(2, 2, 0)));
276        assert!(!v.satisfies_minimum(&CliVersion::new(3, 0, 0)));
277    }
278
279    #[test]
280    fn test_parse_invalid() {
281        assert!("not-a-version".parse::<CliVersion>().is_err());
282        assert!("2.1".parse::<CliVersion>().is_err());
283        assert!("2.1.x".parse::<CliVersion>().is_err());
284    }
285
286    // -- declared tested range --------------------------------------
287
288    #[test]
289    fn tested_range_is_ordered() {
290        assert!(
291            TESTED_CLI_VERSION_MIN <= TESTED_CLI_VERSION_MAX,
292            "declared tested range is inverted: {TESTED_CLI_VERSION_MIN} > {TESTED_CLI_VERSION_MAX}"
293        );
294    }
295
296    #[test]
297    fn tested_range_bounds_classify_as_tested() {
298        // The bounds are inclusive; a version at either end must not be
299        // reported as drift, or the range would be a lie at its own edges.
300        for v in [TESTED_CLI_VERSION_MIN, TESTED_CLI_VERSION_MAX] {
301            assert_eq!(
302                v.status_within(&TESTED_CLI_VERSION_MIN, &TESTED_CLI_VERSION_MAX),
303                CliVersionStatus::Tested,
304                "{v} should classify as Tested"
305            );
306        }
307    }
308
309    /// Extract the `claude_version` matrix axis from a workflow file, if the
310    /// contract job declares one. Split out from the test below so the
311    /// parsing is itself testable: the real check is vacuous until the
312    /// contract job lands (#753), and a vacuous check that would not work
313    /// when it stops being vacuous is worse than no check.
314    fn ci_claude_version_axis(yaml: &str) -> Option<String> {
315        let (_, rest) = yaml.split_once("claude_version:")?;
316        Some(rest.chars().take_while(|c| *c != ']').collect())
317    }
318
319    #[test]
320    fn ci_matrix_axis_parsing_works() {
321        assert_eq!(ci_claude_version_axis("no matrix here"), None);
322        let yaml = "    matrix:\n      claude_version: [\"2.1.0\", \"2.1.999\"]\n";
323        let axis = ci_claude_version_axis(yaml).expect("axis found");
324        assert!(axis.contains("2.1.0"));
325        assert!(axis.contains("2.1.999"));
326        assert!(!axis.contains("2.2.0"));
327    }
328
329    /// The constants claim CI coverage. This checks the claim.
330    ///
331    /// Returns early when the workflow file is absent (a vendored or packaged
332    /// build has no `.github/`), and when no CLI-version matrix exists yet, so
333    /// it starts enforcing as soon as the contract job lands (#753) rather
334    /// than blocking on it. `ci_matrix_axis_parsing_works` above covers the
335    /// parsing meanwhile.
336    #[test]
337    fn tested_range_matches_the_ci_contract_matrix() {
338        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/.github/workflows/ci.yml");
339        let Ok(yaml) = std::fs::read_to_string(path) else {
340            return;
341        };
342        let Some(axis) = ci_claude_version_axis(&yaml) else {
343            return;
344        };
345        for bound in [TESTED_CLI_VERSION_MIN, TESTED_CLI_VERSION_MAX] {
346            assert!(
347                axis.contains(&bound.to_string()),
348                "declared tested bound {bound} is not in ci.yml's claude_version matrix \
349                 ({axis:?}); either add it to CI or do not claim it here"
350            );
351        }
352    }
353
354    // -- status_within ---------------------------------------------
355
356    #[test]
357    fn status_tested_at_min() {
358        let v = CliVersion::new(2, 1, 0);
359        let s = v.status_within(&CliVersion::new(2, 1, 0), &CliVersion::new(2, 1, 999));
360        assert_eq!(s, CliVersionStatus::Tested);
361        assert!(s.is_tested());
362    }
363
364    #[test]
365    fn status_tested_at_max() {
366        let v = CliVersion::new(2, 1, 999);
367        let s = v.status_within(&CliVersion::new(2, 1, 0), &CliVersion::new(2, 1, 999));
368        assert_eq!(s, CliVersionStatus::Tested);
369    }
370
371    #[test]
372    fn status_tested_in_middle() {
373        let v = CliVersion::new(2, 1, 143);
374        let s = v.status_within(&CliVersion::new(2, 1, 0), &CliVersion::new(2, 1, 999));
375        assert_eq!(s, CliVersionStatus::Tested);
376    }
377
378    #[test]
379    fn status_newer_untested_above_max() {
380        let v = CliVersion::new(2, 2, 0);
381        let s = v.status_within(&CliVersion::new(2, 1, 0), &CliVersion::new(2, 1, 999));
382        assert_eq!(
383            s,
384            CliVersionStatus::NewerUntested {
385                found: v,
386                tested_max: CliVersion::new(2, 1, 999),
387            }
388        );
389        assert!(!s.is_tested());
390    }
391
392    #[test]
393    fn status_older_than_minimum() {
394        let v = CliVersion::new(2, 0, 99);
395        let s = v.status_within(&CliVersion::new(2, 1, 0), &CliVersion::new(2, 1, 999));
396        assert_eq!(
397            s,
398            CliVersionStatus::OlderThanMinimum {
399                found: v,
400                minimum: CliVersion::new(2, 1, 0),
401            }
402        );
403        assert!(!s.is_tested());
404    }
405
406    #[test]
407    fn status_serializes_to_tagged_json() {
408        let s = CliVersionStatus::Tested;
409        assert_eq!(serde_json::to_string(&s).unwrap(), r#"{"status":"tested"}"#);
410
411        let s = CliVersionStatus::NewerUntested {
412            found: CliVersion::new(2, 2, 0),
413            tested_max: CliVersion::new(2, 1, 999),
414        };
415        let json: serde_json::Value =
416            serde_json::from_str(&serde_json::to_string(&s).unwrap()).expect("re-parse json");
417        assert_eq!(json["status"], "newer_untested");
418        assert_eq!(json["found"]["major"], 2);
419        assert_eq!(json["tested_max"]["minor"], 1);
420    }
421}