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    if !version.pre.is_empty() {
86        let dominated = req.comparators.iter().all(|c| {
87            let c_minor = c.minor.unwrap_or(0);
88            let c_patch = c.patch.unwrap_or(0);
89            (version.major, version.minor, version.patch) > (c.major, c_minor, c_patch)
90        });
91        if dominated {
92            let base = Version::new(version.major, version.minor, version.patch);
93            if req.matches(&base) {
94                return Ok(true);
95            }
96        }
97    }
98
99    Ok(false)
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105
106    #[test]
107    fn test_no_requirement() {
108        assert!(matches_requirement("0.88.0", None).unwrap());
109        assert!(matches_requirement("1.0.0", None).unwrap());
110        assert!(matches_requirement("0.1.0", None).unwrap());
111    }
112
113    #[test]
114    fn test_empty_requirement() {
115        assert!(matches_requirement("0.88.0", Some("")).unwrap());
116        assert!(matches_requirement("1.0.0", Some("   ")).unwrap());
117    }
118
119    #[test]
120    fn test_simple_requirement() {
121        // Exact version
122        assert!(matches_requirement("0.88.0", Some("=0.88.0")).unwrap());
123        assert!(!matches_requirement("0.88.1", Some("=0.88.0")).unwrap());
124
125        // Greater than or equal
126        assert!(matches_requirement("0.88.0", Some(">=0.88.0")).unwrap());
127        assert!(matches_requirement("0.89.0", Some(">=0.88.0")).unwrap());
128        assert!(!matches_requirement("0.87.0", Some(">=0.88.0")).unwrap());
129
130        // Less than
131        assert!(matches_requirement("0.87.0", Some("<0.88.0")).unwrap());
132        assert!(!matches_requirement("0.88.0", Some("<0.88.0")).unwrap());
133    }
134
135    #[test]
136    fn test_compound_requirement() {
137        let req = ">=0.88.0, <0.90.0";
138
139        assert!(matches_requirement("0.88.0", Some(req)).unwrap());
140        assert!(matches_requirement("0.89.0", Some(req)).unwrap());
141        assert!(matches_requirement("0.89.5", Some(req)).unwrap());
142        assert!(!matches_requirement("0.87.0", Some(req)).unwrap());
143        assert!(!matches_requirement("0.90.0", Some(req)).unwrap());
144        assert!(!matches_requirement("1.0.0", Some(req)).unwrap());
145    }
146
147    #[test]
148    fn test_caret_requirement() {
149        // ^0.88 means >=0.88.0, <0.89.0
150        let req = "^0.88";
151        assert!(matches_requirement("0.88.0", Some(req)).unwrap());
152        assert!(matches_requirement("0.88.5", Some(req)).unwrap());
153        assert!(!matches_requirement("0.89.0", Some(req)).unwrap());
154        assert!(!matches_requirement("0.87.0", Some(req)).unwrap());
155    }
156
157    #[test]
158    fn test_tilde_requirement() {
159        // ~0.88.0 means >=0.88.0, <0.89.0
160        let req = "~0.88.0";
161        assert!(matches_requirement("0.88.0", Some(req)).unwrap());
162        assert!(matches_requirement("0.88.9", Some(req)).unwrap());
163        assert!(!matches_requirement("0.89.0", Some(req)).unwrap());
164    }
165
166    #[test]
167    fn test_invalid_version() {
168        let result = matches_requirement("not-a-version", Some(">=0.88.0"));
169        assert!(result.is_err());
170        assert!(matches!(
171            result.unwrap_err(),
172            VersionError::InvalidVersion { .. }
173        ));
174    }
175
176    #[test]
177    fn test_invalid_requirement() {
178        let result = matches_requirement("0.88.0", Some(">=bad-version"));
179        assert!(result.is_err());
180        assert!(matches!(
181            result.unwrap_err(),
182            VersionError::InvalidRequirement { .. }
183        ));
184    }
185
186    #[test]
187    fn test_prerelease_versions() {
188        let req = ">=0.88.0-alpha.4";
189        assert!(matches_requirement("0.88.0-alpha.4", Some(req)).unwrap());
190        assert!(matches_requirement("0.88.0-alpha.5", Some(req)).unwrap());
191        assert!(matches_requirement("0.88.0", Some(req)).unwrap());
192        assert!(!matches_requirement("0.88.0-alpha.3", Some(req)).unwrap());
193    }
194
195    #[test]
196    fn test_prerelease_cross_minor_fallback() {
197        // Core fix: pre-release of a newer minor version should match
198        // open-ended ranges from an older minor version.
199
200        // Open-ended lower bound — 0.100.0-alpha.2 is clearly "after" 0.99
201        assert!(matches_requirement("0.100.0-alpha.2", Some(">=0.99.0-alpha.21")).unwrap());
202        assert!(matches_requirement("0.100.0-alpha.2", Some(">=0.92.0")).unwrap());
203        assert!(matches_requirement("0.100.0-alpha.2", Some(">=0.88.0")).unwrap());
204        assert!(matches_requirement("0.100.0-alpha.2", Some(">=0.99.0-alpha.0")).unwrap());
205
206        // Bounded ranges for an older minor — upper bound blocks it
207        assert!(!matches_requirement(
208            "0.100.0-alpha.2",
209            Some(">=0.99.0-alpha.2, <0.99.0-alpha.10")
210        )
211        .unwrap());
212        assert!(
213            !matches_requirement("0.100.0-alpha.2", Some(">=0.88.0, <0.99.0-alpha.7")).unwrap()
214        );
215
216        // Same minor.patch alpha ordering still works (no fallback triggered)
217        assert!(!matches_requirement("0.99.0-alpha.12", Some(">=0.99.0-alpha.21")).unwrap());
218        assert!(matches_requirement("0.99.0-alpha.22", Some(">=0.99.0-alpha.21")).unwrap());
219    }
220}