Skip to main content

claude_wrapper/
version.rs

1use std::fmt;
2use std::str::FromStr;
3
4/// A parsed Claude CLI version (semver).
5///
6/// # Example
7///
8/// ```
9/// use claude_wrapper::CliVersion;
10///
11/// let v: CliVersion = "2.1.71".parse().unwrap();
12/// assert_eq!(v.major, 2);
13/// assert_eq!(v.minor, 1);
14/// assert_eq!(v.patch, 71);
15///
16/// let min: CliVersion = "2.1.0".parse().unwrap();
17/// assert!(v >= min);
18/// ```
19#[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    /// Create a new version.
28    #[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    /// Parse a version from the output of `claude --version`.
38    ///
39    /// Expects format like `"2.1.71 (Claude Code)"` or just `"2.1.71"`.
40    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    /// Check if this version satisfies a minimum version requirement.
46    #[must_use]
47    pub fn satisfies_minimum(&self, minimum: &CliVersion) -> bool {
48        self >= minimum
49    }
50
51    /// Classify this version against a tested-against `[min, max]`
52    /// range (both inclusive).
53    ///
54    /// Use to decide whether a host should warn about CLI drift.
55    /// The minimum is the floor we've verified the wrapper still
56    /// works against; the maximum is the upper end of the
57    /// tested-against window. A version below the minimum is a hard
58    /// "we know this is broken"; a version above the maximum is a
59    /// soft "we haven't verified this; semantics may have drifted."
60    #[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/// Classification of an installed CLI version against a tested
79/// range. Returned by [`CliVersion::status_within`] and
80/// [`crate::Claude::cli_version_status`].
81#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
82#[serde(tag = "status", rename_all = "snake_case")]
83pub enum CliVersionStatus {
84    /// CLI version is within the tested-against range.
85    Tested,
86    /// CLI is newer than the wrapper's tested-against maximum.
87    /// Semantics may have drifted; the wrapper should still
88    /// generally work but unexpected behavior is possible.
89    NewerUntested {
90        /// The installed CLI version.
91        found: CliVersion,
92        /// Highest CLI version the wrapper has been tested against.
93        tested_max: CliVersion,
94    },
95    /// CLI is older than the declared minimum. The wrapper is
96    /// known to behave incorrectly against this version (missing
97    /// flags, different argument shapes).
98    OlderThanMinimum {
99        /// The installed CLI version.
100        found: CliVersion,
101        /// Lowest CLI version the wrapper supports.
102        minimum: CliVersion,
103    },
104}
105
106impl CliVersionStatus {
107    /// True only for [`CliVersionStatus::Tested`]. Useful for
108    /// callers branching on "should I run?" without pattern
109    /// matching every variant.
110    #[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/// Error returned when a version string cannot be parsed.
165#[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    // -- status_within ---------------------------------------------
229
230    #[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}