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
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/// Error returned when a version string cannot be parsed.
175#[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    // -- status_within ---------------------------------------------
239
240    #[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}