Skip to main content

codex_patcher/config/
version.rs

1//! Version filtering for patches using semver constraints
2//!
3//! Allows patches to specify version ranges like ">=0.88.0, <0.90.0"
4//! and filters them based on the workspace version.
5
6use semver::{Version, VersionReq};
7use std::fmt;
8
9/// Errors during version filtering
10#[derive(Debug, Clone)]
11pub enum VersionError {
12    /// Invalid version string (e.g., "not-a-version")
13    InvalidVersion { value: String, source: String },
14    /// Invalid version requirement (e.g., ">=bad")
15    InvalidRequirement { value: String, source: String },
16}
17
18impl fmt::Display for VersionError {
19    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
20        match self {
21            VersionError::InvalidVersion { value, source } => {
22                write!(f, "invalid version '{}': {}", value, source)
23            }
24            VersionError::InvalidRequirement { value, source } => {
25                write!(f, "invalid version requirement '{}': {}", value, source)
26            }
27        }
28    }
29}
30
31impl std::error::Error for VersionError {}
32
33/// Check if a version matches a requirement string
34///
35/// # Examples
36///
37/// ```
38/// use codex_patcher::config::version::matches_requirement;
39///
40/// assert!(matches_requirement("0.88.0", Some(">=0.88.0")).unwrap());
41/// assert!(matches_requirement("0.89.0", Some(">=0.88.0, <0.90.0")).unwrap());
42/// assert!(!matches_requirement("0.87.0", Some(">=0.88.0")).unwrap());
43///
44/// // None requirement means "apply to all versions"
45/// assert!(matches_requirement("1.0.0", None).unwrap());
46/// ```
47pub fn matches_requirement(version: &str, requirement: Option<&str>) -> Result<bool, VersionError> {
48    // No requirement means "apply to all versions"
49    let Some(req_str) = requirement else {
50        return Ok(true);
51    };
52
53    // Empty requirement string means "apply to all versions"
54    let req_str = req_str.trim();
55    if req_str.is_empty() {
56        return Ok(true);
57    }
58
59    // Parse version
60    let version = Version::parse(version).map_err(|e| VersionError::InvalidVersion {
61        value: version.to_string(),
62        source: e.to_string(),
63    })?;
64
65    // Parse requirement
66    let req = VersionReq::parse(req_str).map_err(|e| VersionError::InvalidRequirement {
67        value: req_str.to_string(),
68        source: e.to_string(),
69    })?;
70
71    if req.matches(&version) {
72        return Ok(true);
73    }
74
75    // Semver pre-release matching rule: a pre-release version (e.g.
76    // 0.100.0-alpha.2) only matches comparators that reference the *same*
77    // major.minor.patch with a pre-release tag.  This means `>=0.99.0-alpha.21`
78    // will NOT match `0.100.0-alpha.2`, and even `>=0.92.0` won't match it.
79    //
80    // For our use case this is too strict.  If the workspace is a pre-release of
81    // a version that is strictly *newer* than every comparator's base version,
82    // we retry the match with the pre-release stripped.  This preserves correct
83    // behavior for intra-minor alpha ranges (e.g. >=0.99.0-alpha.10,
84    // <0.99.0-alpha.14 must NOT match 0.99.0-alpha.20).
85    //
86    // Guard: if ANY upper-bound comparator has a pre-release tag and the version's
87    // base strictly exceeds that comparator's base, the version is clearly above
88    // the upper bound.  Stripping the pre-release and retrying would cause the
89    // semver crate to silently ignore that comparator (it only evaluates
90    // pre-release comparators against versions with the same major.minor.patch),
91    // producing a false positive match.  Skip the retry in that case.
92    if !version.pre.is_empty() {
93        // A pre-release version (e.g. 0.106.0-alpha.5) is "dominated" when the
94        // semver crate would evaluate it incorrectly: it refuses to apply any
95        // comparator whose pre-release tag references a *different* major.minor.patch,
96        // so both `>=0.105.0-alpha.13` and `<0.108.0-alpha.1` return false for
97        // 0.106.0-alpha.5, even though it is clearly in range.
98        //
99        // We retry with the pre-release stripped when:
100        //   (a) every LOWER-BOUND comparator's base is strictly below the version's
101        //       base (so the version is above all lower bounds), AND
102        //   (b) no UPPER-BOUND comparator has a pre-release tag for a base that the
103        //       version's base strictly EXCEEDS — that would mean the version is
104        //       above the upper bound, and the semver crate would silently ignore
105        //       that comparator after stripping, producing a false positive.
106        let dominated = req.comparators.iter().all(|c| {
107            let c_minor = c.minor.unwrap_or(0);
108            let c_patch = c.patch.unwrap_or(0);
109            // Upper-bound comparators do not need to be below the version's base.
110            matches!(c.op, semver::Op::Less | semver::Op::LessEq)
111                || (version.major, version.minor, version.patch) > (c.major, c_minor, c_patch)
112        });
113        if dominated {
114            let exceeds_prerelease_upper = req.comparators.iter().any(|c| {
115                matches!(c.op, semver::Op::Less | semver::Op::LessEq)
116                    && !c.pre.is_empty()
117                    && {
118                        let c_minor = c.minor.unwrap_or(0);
119                        let c_patch = c.patch.unwrap_or(0);
120                        (version.major, version.minor, version.patch) > (c.major, c_minor, c_patch)
121                    }
122            });
123            if !exceeds_prerelease_upper {
124                let base = Version::new(version.major, version.minor, version.patch);
125                if req.matches(&base) {
126                    return Ok(true);
127                }
128            }
129        }
130    }
131
132    Ok(false)
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn test_no_requirement() {
141        assert!(matches_requirement("0.88.0", None).unwrap());
142        assert!(matches_requirement("1.0.0", None).unwrap());
143        assert!(matches_requirement("0.1.0", None).unwrap());
144    }
145
146    #[test]
147    fn test_empty_requirement() {
148        assert!(matches_requirement("0.88.0", Some("")).unwrap());
149        assert!(matches_requirement("1.0.0", Some("   ")).unwrap());
150    }
151
152    #[test]
153    fn test_simple_requirement() {
154        // Exact version
155        assert!(matches_requirement("0.88.0", Some("=0.88.0")).unwrap());
156        assert!(!matches_requirement("0.88.1", Some("=0.88.0")).unwrap());
157
158        // Greater than or equal
159        assert!(matches_requirement("0.88.0", Some(">=0.88.0")).unwrap());
160        assert!(matches_requirement("0.89.0", Some(">=0.88.0")).unwrap());
161        assert!(!matches_requirement("0.87.0", Some(">=0.88.0")).unwrap());
162
163        // Less than
164        assert!(matches_requirement("0.87.0", Some("<0.88.0")).unwrap());
165        assert!(!matches_requirement("0.88.0", Some("<0.88.0")).unwrap());
166    }
167
168    #[test]
169    fn test_compound_requirement() {
170        let req = ">=0.88.0, <0.90.0";
171
172        assert!(matches_requirement("0.88.0", Some(req)).unwrap());
173        assert!(matches_requirement("0.89.0", Some(req)).unwrap());
174        assert!(matches_requirement("0.89.5", Some(req)).unwrap());
175        assert!(!matches_requirement("0.87.0", Some(req)).unwrap());
176        assert!(!matches_requirement("0.90.0", Some(req)).unwrap());
177        assert!(!matches_requirement("1.0.0", Some(req)).unwrap());
178    }
179
180    #[test]
181    fn test_caret_requirement() {
182        // ^0.88 means >=0.88.0, <0.89.0
183        let req = "^0.88";
184        assert!(matches_requirement("0.88.0", Some(req)).unwrap());
185        assert!(matches_requirement("0.88.5", Some(req)).unwrap());
186        assert!(!matches_requirement("0.89.0", Some(req)).unwrap());
187        assert!(!matches_requirement("0.87.0", Some(req)).unwrap());
188    }
189
190    #[test]
191    fn test_tilde_requirement() {
192        // ~0.88.0 means >=0.88.0, <0.89.0
193        let req = "~0.88.0";
194        assert!(matches_requirement("0.88.0", Some(req)).unwrap());
195        assert!(matches_requirement("0.88.9", Some(req)).unwrap());
196        assert!(!matches_requirement("0.89.0", Some(req)).unwrap());
197    }
198
199    #[test]
200    fn test_invalid_version() {
201        let result = matches_requirement("not-a-version", Some(">=0.88.0"));
202        assert!(result.is_err());
203        assert!(matches!(
204            result.unwrap_err(),
205            VersionError::InvalidVersion { .. }
206        ));
207    }
208
209    #[test]
210    fn test_invalid_requirement() {
211        let result = matches_requirement("0.88.0", Some(">=bad-version"));
212        assert!(result.is_err());
213        assert!(matches!(
214            result.unwrap_err(),
215            VersionError::InvalidRequirement { .. }
216        ));
217    }
218
219    #[test]
220    fn test_prerelease_versions() {
221        let req = ">=0.88.0-alpha.4";
222        assert!(matches_requirement("0.88.0-alpha.4", Some(req)).unwrap());
223        assert!(matches_requirement("0.88.0-alpha.5", Some(req)).unwrap());
224        assert!(matches_requirement("0.88.0", Some(req)).unwrap());
225        assert!(!matches_requirement("0.88.0-alpha.3", Some(req)).unwrap());
226    }
227
228    #[test]
229    fn test_prerelease_cross_minor_fallback() {
230        // Core fix: pre-release of a newer minor version should match
231        // open-ended ranges from an older minor version.
232
233        // Open-ended lower bound — 0.100.0-alpha.2 is clearly "after" 0.99
234        assert!(matches_requirement("0.100.0-alpha.2", Some(">=0.99.0-alpha.21")).unwrap());
235        assert!(matches_requirement("0.100.0-alpha.2", Some(">=0.92.0")).unwrap());
236        assert!(matches_requirement("0.100.0-alpha.2", Some(">=0.88.0")).unwrap());
237        assert!(matches_requirement("0.100.0-alpha.2", Some(">=0.99.0-alpha.0")).unwrap());
238
239        // Bounded ranges for an older minor — upper bound blocks it
240        assert!(!matches_requirement(
241            "0.100.0-alpha.2",
242            Some(">=0.99.0-alpha.2, <0.99.0-alpha.10")
243        )
244        .unwrap());
245        assert!(
246            !matches_requirement("0.100.0-alpha.2", Some(">=0.88.0, <0.99.0-alpha.7")).unwrap()
247        );
248
249        // Same minor.patch alpha ordering still works (no fallback triggered)
250        assert!(!matches_requirement("0.99.0-alpha.12", Some(">=0.99.0-alpha.21")).unwrap());
251        assert!(matches_requirement("0.99.0-alpha.22", Some(">=0.99.0-alpha.21")).unwrap());
252    }
253
254    #[test]
255    fn test_prerelease_upper_bound_not_exceeded() {
256        // Regression: v0.112.0-alpha.11 must NOT match a range capped at
257        // <0.108.0-alpha.1.  The old "dominated" fallback stripped the pre-release
258        // from 0.112.0-alpha.11 to get 0.112.0, then retried — but the semver
259        // crate ignores the <0.108.0-alpha.1 upper-bound comparator for 0.112.0
260        // (different major.minor.patch), leaving only the >= lower bound to
261        // match, producing a false positive.
262        let req = ">=0.105.0-alpha.13, <0.108.0-alpha.1";
263        assert!(!matches_requirement("0.112.0-alpha.11", Some(req)).unwrap());
264        assert!(!matches_requirement("0.108.0", Some(req)).unwrap());
265        assert!(!matches_requirement("0.109.0-alpha.1", Some(req)).unwrap());
266
267        // Versions within the range still match.
268        assert!(matches_requirement("0.105.0-alpha.13", Some(req)).unwrap());
269        assert!(matches_requirement("0.106.0-alpha.5", Some(req)).unwrap());
270        assert!(matches_requirement("0.107.0", Some(req)).unwrap());
271
272        // Open-ended lower-bound ranges (no pre-release upper bound) still use
273        // the fallback correctly.
274        assert!(matches_requirement("0.112.0-alpha.11", Some(">=0.105.0-alpha.13")).unwrap());
275    }
276}