Skip to main content

gen_types/
constraint.rs

1//! Typed [`VersionConstraint`] — what versions a dependency accepts.
2//!
3//! Adapters parse their native constraint syntax (cargo `^1.2.3`,
4//! npm `>=1.2.3 <2.0.0`, Bundler `~> 1.2.3`, pip `>=1.2,<2.0`,
5//! Composer `^1.2 || ^2.0`, …) into this canonical typed shape.
6//! The resolver in `gen-engine` reads this once + does its
7//! version-selection math against it; per-adapter constraint logic
8//! lives only at the parse boundary.
9
10use crate::Version;
11use serde::{Deserialize, Serialize};
12
13/// One typed variant per constraint kind every package manager
14/// surfaces. Adapter is responsible for converting `~> 1.2.3` etc.
15/// into the matching shape.
16#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
17#[serde(tag = "kind", rename_all = "kebab-case")]
18pub enum ConstraintSpec {
19    /// Match exactly one version: `=1.2.3`.
20    Exact(Version),
21    /// Inclusive lower bound, exclusive upper bound: `>=1.2.3,<2.0.0`.
22    Range {
23        lower_inclusive: Version,
24        upper_exclusive: Version,
25    },
26    /// `~1.2.3` — patch-level: `>=1.2.3,<1.3.0`. Caller-friendly
27    /// alias for the common "approximately equal to" shape.
28    Tilde(Version),
29    /// `^1.2.3` — minor-level (default Cargo behavior): `>=1.2.3,<2.0.0`.
30    /// Identical to `Range { 1.2.3, 2.0.0 }` — kept as a separate
31    /// variant for diagnostics + round-tripping.
32    Caret(Version),
33    /// `>=1.2.3` — open upper bound. Common in npm + pip.
34    GreaterEqual(Version),
35    /// `>1.2.3` — strict lower bound.
36    Greater(Version),
37    /// `<=1.2.3` — closed upper bound.
38    LessEqual(Version),
39    /// `<1.2.3` — strict upper bound.
40    Less(Version),
41    /// `*` / `any` — matches every version. Last-resort.
42    Any,
43}
44
45/// Compound constraint container — disjunction / conjunction of
46/// atomic `ConstraintSpec`s. Kept separate from `ConstraintSpec` to
47/// avoid serde recursion-overflow on the derive macros (Vec<Self>
48/// inside a derived Serialize/Deserialize enum triggers a
49/// monomorphization storm). Most adapters never need this — the
50/// `Range` + `Caret` + `Tilde` variants cover ≥95% of real-world
51/// constraints.
52#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
53pub struct CompoundConstraint {
54    pub combinator: Combinator,
55    pub atoms: Vec<ConstraintSpec>,
56}
57
58#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "kebab-case")]
60pub enum Combinator {
61    /// Disjunction: `^1.2.3 || ^2.0.0`. Composer + npm support this.
62    Or,
63    /// Conjunction: `>=1.2,<2.0` (the and-of-two-constraints shape
64    /// some adapters surface separately from `Range`).
65    And,
66}
67
68impl CompoundConstraint {
69    #[must_use]
70    pub fn matches(&self, v: &Version) -> bool {
71        match self.combinator {
72            Combinator::Or => self.atoms.iter().any(|a| match_constraint(a, v)),
73            Combinator::And => self.atoms.iter().all(|a| match_constraint(a, v)),
74        }
75    }
76}
77
78/// Wrapper carrying both the typed [`ConstraintSpec`] and the
79/// original native syntax — handy for diagnostics + round-tripping
80/// back to the source manifest.
81#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
82pub struct VersionConstraint {
83    pub spec: ConstraintSpec,
84    /// Original adapter-native syntax (e.g. `~> 1.2.3` for Bundler).
85    /// Optional — adapters that lose the original syntax during
86    /// parsing leave this `None`.
87    #[serde(default)]
88    pub native_syntax: Option<String>,
89}
90
91impl VersionConstraint {
92    /// Convenience: build from a typed spec without retaining the
93    /// native syntax. Useful in tests + adapter-independent code.
94    #[must_use]
95    pub const fn from_spec(spec: ConstraintSpec) -> Self {
96        Self {
97            spec,
98            native_syntax: None,
99        }
100    }
101
102    /// Does this constraint accept the supplied version? Pure
103    /// function over the typed spec — adapter is never consulted.
104    #[must_use]
105    pub fn matches(&self, v: &Version) -> bool {
106        match_constraint(&self.spec, v)
107    }
108}
109
110fn match_constraint(c: &ConstraintSpec, v: &Version) -> bool {
111    match c {
112        ConstraintSpec::Exact(target) => v == target,
113        ConstraintSpec::Range {
114            lower_inclusive,
115            upper_exclusive,
116        } => v >= lower_inclusive && v < upper_exclusive,
117        ConstraintSpec::Tilde(base) => {
118            // ~1.2.3 → >=1.2.3, <1.3.0
119            v >= base && v < &Version::new(base.major, base.minor + 1, 0)
120        }
121        ConstraintSpec::Caret(base) => {
122            // ^1.2.3 → >=1.2.3, <2.0.0 (when major > 0)
123            // ^0.2.3 → >=0.2.3, <0.3.0 (cargo edge case for 0.x)
124            // ^0.0.3 → >=0.0.3, <0.0.4 (cargo edge case for 0.0.x)
125            if base.major > 0 {
126                v >= base && v < &Version::new(base.major + 1, 0, 0)
127            } else if base.minor > 0 {
128                v >= base && v < &Version::new(0, base.minor + 1, 0)
129            } else {
130                v >= base && v < &Version::new(0, 0, base.patch + 1)
131            }
132        }
133        ConstraintSpec::GreaterEqual(target) => v >= target,
134        ConstraintSpec::Greater(target) => v > target,
135        ConstraintSpec::LessEqual(target) => v <= target,
136        ConstraintSpec::Less(target) => v < target,
137        ConstraintSpec::Any => true,
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn exact_matches_only_exact_version() {
147        let c = VersionConstraint::from_spec(ConstraintSpec::Exact(Version::new(1, 2, 3)));
148        assert!(c.matches(&Version::new(1, 2, 3)));
149        assert!(!c.matches(&Version::new(1, 2, 4)));
150    }
151
152    #[test]
153    fn caret_major_above_zero() {
154        let c = VersionConstraint::from_spec(ConstraintSpec::Caret(Version::new(1, 2, 3)));
155        assert!(c.matches(&Version::new(1, 2, 3)));
156        assert!(c.matches(&Version::new(1, 99, 0)));
157        assert!(!c.matches(&Version::new(2, 0, 0)));
158        assert!(!c.matches(&Version::new(1, 2, 2)));
159    }
160
161    #[test]
162    fn caret_major_zero_minor_above_zero() {
163        // ^0.2.3 → >=0.2.3, <0.3.0
164        let c = VersionConstraint::from_spec(ConstraintSpec::Caret(Version::new(0, 2, 3)));
165        assert!(c.matches(&Version::new(0, 2, 3)));
166        assert!(c.matches(&Version::new(0, 2, 99)));
167        assert!(!c.matches(&Version::new(0, 3, 0)));
168    }
169
170    #[test]
171    fn caret_major_and_minor_zero() {
172        // ^0.0.3 → >=0.0.3, <0.0.4
173        let c = VersionConstraint::from_spec(ConstraintSpec::Caret(Version::new(0, 0, 3)));
174        assert!(c.matches(&Version::new(0, 0, 3)));
175        assert!(!c.matches(&Version::new(0, 0, 4)));
176    }
177
178    #[test]
179    fn tilde_only_allows_patch_changes() {
180        // ~1.2.3 → >=1.2.3, <1.3.0
181        let c = VersionConstraint::from_spec(ConstraintSpec::Tilde(Version::new(1, 2, 3)));
182        assert!(c.matches(&Version::new(1, 2, 3)));
183        assert!(c.matches(&Version::new(1, 2, 99)));
184        assert!(!c.matches(&Version::new(1, 3, 0)));
185    }
186
187    #[test]
188    fn range_inclusive_lower_exclusive_upper() {
189        let c = VersionConstraint::from_spec(ConstraintSpec::Range {
190            lower_inclusive: Version::new(1, 2, 3),
191            upper_exclusive: Version::new(2, 0, 0),
192        });
193        assert!(c.matches(&Version::new(1, 2, 3)));
194        assert!(c.matches(&Version::new(1, 99, 99)));
195        assert!(!c.matches(&Version::new(2, 0, 0)));
196        assert!(!c.matches(&Version::new(1, 2, 2)));
197    }
198
199    #[test]
200    fn open_bounds() {
201        let ge = VersionConstraint::from_spec(ConstraintSpec::GreaterEqual(Version::new(1, 0, 0)));
202        let gt = VersionConstraint::from_spec(ConstraintSpec::Greater(Version::new(1, 0, 0)));
203        let le = VersionConstraint::from_spec(ConstraintSpec::LessEqual(Version::new(1, 0, 0)));
204        let lt = VersionConstraint::from_spec(ConstraintSpec::Less(Version::new(1, 0, 0)));
205        assert!(ge.matches(&Version::new(1, 0, 0)));
206        assert!(!gt.matches(&Version::new(1, 0, 0)));
207        assert!(le.matches(&Version::new(1, 0, 0)));
208        assert!(!lt.matches(&Version::new(1, 0, 0)));
209    }
210
211    #[test]
212    fn any_matches_everything() {
213        let c = VersionConstraint::from_spec(ConstraintSpec::Any);
214        assert!(c.matches(&Version::new(0, 0, 0)));
215        assert!(c.matches(&Version::new(999, 999, 999)));
216    }
217
218    #[test]
219    fn disjunction_via_compound() {
220        let c = CompoundConstraint {
221            combinator: Combinator::Or,
222            atoms: vec![
223                ConstraintSpec::Caret(Version::new(1, 0, 0)),
224                ConstraintSpec::Caret(Version::new(2, 0, 0)),
225            ],
226        };
227        assert!(c.matches(&Version::new(1, 5, 0)));
228        assert!(c.matches(&Version::new(2, 5, 0)));
229        assert!(!c.matches(&Version::new(3, 0, 0)));
230    }
231
232    #[test]
233    fn conjunction_via_compound() {
234        let c = CompoundConstraint {
235            combinator: Combinator::And,
236            atoms: vec![
237                ConstraintSpec::GreaterEqual(Version::new(1, 2, 0)),
238                ConstraintSpec::Less(Version::new(2, 0, 0)),
239            ],
240        };
241        assert!(c.matches(&Version::new(1, 5, 0)));
242        assert!(!c.matches(&Version::new(1, 1, 0)));
243        assert!(!c.matches(&Version::new(2, 0, 0)));
244    }
245
246    #[test]
247    fn round_trip_through_serde() {
248        let c = VersionConstraint {
249            spec: ConstraintSpec::Caret(Version::new(1, 2, 3)),
250            native_syntax: Some("^1.2.3".to_string()),
251        };
252        let j = serde_json::to_string(&c).unwrap();
253        let parsed: VersionConstraint = serde_json::from_str(&j).unwrap();
254        assert_eq!(c, parsed);
255    }
256}