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