Skip to main content

rez_next_version/range/
mod.rs

1//! Version range implementation - full rez-compatible version range parsing
2
3mod parser;
4mod satisfiability;
5mod types;
6
7#[cfg(test)]
8#[path = "tests.rs"]
9mod tests;
10
11use super::Version;
12use rez_next_common::RezCoreError;
13use serde::{Deserialize, Serialize};
14use types::BoundSet;
15
16use parser::parse_range_str;
17use satisfiability::{bound_sets_intersect, is_bound_set_satisfiable};
18use types::Bound;
19
20/// `VersionRange` representation - a disjunction of `BoundSet`s (union of intersections)
21#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
22pub struct VersionRange {
23    /// Cached string representation
24    pub range_str: String,
25    /// Parsed bound sets (disjunction - any must match)
26    #[serde(skip)]
27    bound_sets: Vec<BoundSet>,
28    /// Whether the range was successfully parsed
29    #[serde(skip)]
30    is_parsed: bool,
31    /// Ranges to subtract (for set difference operations)
32    #[serde(skip)]
33    subtract_from: Vec<VersionRange>,
34}
35
36impl VersionRange {
37    /// Create a new version range from a string
38    #[allow(clippy::missing_errors_doc)]
39    pub fn new(range_str: &str) -> Result<Self, RezCoreError> {
40        Self::parse(range_str)
41    }
42
43    /// Create a version range that matches any version (equivalent to `""` or `"*"`)
44    pub fn any() -> Self {
45        VersionRange {
46            range_str: String::new(),
47            bound_sets: vec![BoundSet::any()],
48            is_parsed: true,
49            subtract_from: Vec::new(),
50        }
51    }
52
53    /// Create a version range that matches no version (empty set)
54    pub fn none() -> Self {
55        VersionRange {
56            range_str: "!*".to_string(),
57            bound_sets: vec![BoundSet::none()],
58            is_parsed: true,
59            subtract_from: Vec::new(),
60        }
61    }
62
63    /// Parse a version range string
64    ///
65    /// Supported formats:
66    /// - `""` or `"*"` - any version
67    /// - `">=1.0"` - single constraint
68    /// - `">=1.0,<2.0"` - comma-separated AND constraints (rez style)
69    /// - `">=1.0 <2.0"` - space-separated AND constraints
70    /// - `"1.0+"` - rez shorthand for `>=1.0`
71    /// - `"<1.0|>=2.0"` - pipe-separated OR constraints
72    /// - `"==1.0"` - exact version
73    /// - `"~=1.4"` - compatible release
74    pub fn parse(range_str: &str) -> Result<Self, RezCoreError> {
75        let trimmed = range_str.trim();
76        let bound_sets = parse_range_str(trimmed)?;
77        Ok(VersionRange {
78            range_str: range_str.to_string(),
79            bound_sets,
80            is_parsed: true,
81            subtract_from: Vec::new(),
82        })
83    }
84
85    /// Check if a version satisfies this range
86    pub fn contains(&self, version: &Version) -> bool {
87        if !self.is_parsed || self.bound_sets.is_empty() {
88            return true;
89        }
90        // Disjunction: any bound_set matching means the version is included
91        let in_self = self.bound_sets.iter().any(|bs| bs.contains(version));
92        if !in_self {
93            return false;
94        }
95        // Subtract: if version is in any subtract range, exclude it
96        for sub_range in &self.subtract_from {
97            if sub_range.contains(version) {
98                return false;
99            }
100        }
101        true
102    }
103
104    /// Get the string representation
105    pub fn as_str(&self) -> &str {
106        &self.range_str
107    }
108
109    /// Check if this range intersects with another range
110    pub fn intersects(&self, other: &VersionRange) -> bool {
111        // Conservative: if either is "any", they intersect
112        if self.is_any() || other.is_any() {
113            return true;
114        }
115        // Check if the ranges have any overlap by checking bounds interaction
116        // For exact versions, check containment
117        for bs_self in &self.bound_sets {
118            for bs_other in &other.bound_sets {
119                // Check if these two bound sets can co-exist
120                if bound_sets_intersect(bs_self, bs_other) {
121                    return true;
122                }
123            }
124        }
125        false
126    }
127
128    /// Compute the intersection of two ranges
129    pub fn intersect(&self, other: &VersionRange) -> Option<VersionRange> {
130        if self.is_any() {
131            return Some(other.clone());
132        }
133        if other.is_any() {
134            return Some(self.clone());
135        }
136        // Merge all bound sets with AND semantics
137        // Only include merged sets that are satisfiable (not trivially empty)
138        let mut result_sets = Vec::new();
139        for bs_self in &self.bound_sets {
140            for bs_other in &other.bound_sets {
141                let mut merged = bs_self.bounds.clone();
142                merged.extend(bs_other.bounds.clone());
143                let merged_set = BoundSet { bounds: merged };
144                // Only include if this merged set is satisfiable
145                if is_bound_set_satisfiable(&merged_set) {
146                    result_sets.push(merged_set);
147                }
148            }
149        }
150
151        if result_sets.is_empty() {
152            return None;
153        }
154        let new_str = format!("({})&({})", self.range_str, other.range_str);
155        let mut combined_subtracts = self.subtract_from.clone();
156        combined_subtracts.extend(other.subtract_from.clone());
157        Some(VersionRange {
158            range_str: new_str,
159            bound_sets: result_sets,
160            is_parsed: true,
161            subtract_from: combined_subtracts,
162        })
163    }
164
165    /// Compute the union of two ranges (pipe-separated)
166    pub fn union(&self, other: &VersionRange) -> VersionRange {
167        let new_str = format!("{}|{}", self.range_str, other.range_str);
168        let mut sets = self.bound_sets.clone();
169        sets.extend(other.bound_sets.clone());
170        VersionRange {
171            range_str: new_str,
172            bound_sets: sets,
173            is_parsed: true,
174            subtract_from: Vec::new(),
175        }
176    }
177
178    /// Compute the difference of two ranges: versions in self but not in other
179    /// Returns None if the result would be empty
180    pub fn subtract(&self, other: &VersionRange) -> Option<VersionRange> {
181        if other.is_any() {
182            return None; // self - any = empty
183        }
184        if other.is_empty() {
185            return Some(self.clone());
186        }
187        if self.is_empty() {
188            return None;
189        }
190        // Use subtract_from field: self with other excluded via contains() check
191        let new_str = format!("({})-({})", self.range_str, other.range_str);
192        let mut subtracts = self.subtract_from.clone();
193        subtracts.push(other.clone());
194        let range = VersionRange {
195            range_str: new_str,
196            bound_sets: self.bound_sets.clone(),
197            is_parsed: true,
198            subtract_from: subtracts,
199        };
200        // Quick sanity: at least one probe version must be in the result
201        let probes = self.collect_probe_versions_with_other(other);
202        let has_any = probes.iter().any(|v| range.contains(v));
203        if has_any { Some(range) } else { None }
204    }
205
206    /// Check if this range is the "any" range (matches all versions)
207    pub fn is_any(&self) -> bool {
208        let s = self.range_str.trim();
209        if s.is_empty() || s == "*" {
210            return true;
211        }
212        // Check if all bound sets are Any
213        self.bound_sets
214            .iter()
215            .all(|bs| bs.bounds.is_empty() || bs.bounds.iter().all(|b| matches!(b, Bound::Any)))
216    }
217
218    /// Check if this range is a subset of another range
219    /// (every version in self is also in other)
220    pub fn is_subset_of(&self, other: &VersionRange) -> bool {
221        if other.is_any() {
222            return true;
223        }
224        if self.is_any() {
225            return other.is_any();
226        }
227        if self.is_empty() {
228            return true;
229        }
230        let probe_versions = self.collect_probe_versions_with_other(other);
231        for v in &probe_versions {
232            if self.contains(v) && !other.contains(v) {
233                return false;
234            }
235        }
236        true
237    }
238
239    /// Check if this range is a superset of another range
240    pub fn is_superset_of(&self, other: &VersionRange) -> bool {
241        other.is_subset_of(self)
242    }
243
244    /// Collect probe versions from both self and other's bounds, plus "beyond" versions
245    fn collect_probe_versions_with_other(&self, other: &VersionRange) -> Vec<Version> {
246        let mut versions = Vec::new();
247        for range in [self as &VersionRange, other] {
248            for bs in &range.bound_sets {
249                for bound in &bs.bounds {
250                    match bound {
251                        Bound::Ge(v)
252                        | Bound::Gt(v)
253                        | Bound::Le(v)
254                        | Bound::Lt(v)
255                        | Bound::Eq(v)
256                        | Bound::Ne(v)
257                        | Bound::Compatible(v) => {
258                            versions.push(v.clone());
259                            if let Ok(bumped) = Version::parse(&format!("{}.999999", v.as_str())) {
260                                versions.push(bumped);
261                            }
262                        }
263                        _ => {}
264                    }
265                }
266            }
267        }
268        for s in &["0.0.1", "999.999.999"] {
269            if let Ok(v) = Version::parse(s) {
270                versions.push(v);
271            }
272        }
273        versions
274    }
275
276    /// Check if this range is empty (no versions match)
277    pub fn is_empty(&self) -> bool {
278        let s = self.range_str.trim();
279        if s == "empty" || s == "!*" {
280            return true;
281        }
282        if self.is_parsed && !self.bound_sets.is_empty() {
283            return self
284                .bound_sets
285                .iter()
286                .all(|bs| bs.bounds.iter().any(|b| matches!(b, Bound::None)));
287        }
288        false
289    }
290}