aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-semver/src/lib.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/mozart-semver/src/lib.rs')
-rw-r--r--crates/mozart-semver/src/lib.rs211
1 files changed, 211 insertions, 0 deletions
diff --git a/crates/mozart-semver/src/lib.rs b/crates/mozart-semver/src/lib.rs
index 29df538..8b77300 100644
--- a/crates/mozart-semver/src/lib.rs
+++ b/crates/mozart-semver/src/lib.rs
@@ -384,6 +384,20 @@ impl VersionConstraint {
}
}
+ /// Whether the two constraints share at least one satisfying version.
+ ///
+ /// Mirrors Composer's `MultiConstraint::matches` which is used to decide
+ /// whether a `provide` / `replace` link can satisfy a `require`. OR
+ /// constraints are flattened, then each AND/Single branch is reduced to a
+ /// single (low, high, excluded) interval; the two intervals are intersected
+ /// arithmetically.
+ pub fn intersects(&self, other: &VersionConstraint) -> bool {
+ let lhs = flatten_or(self);
+ let rhs = flatten_or(other);
+ lhs.iter()
+ .any(|a| rhs.iter().any(|b| ranges_intersect(a, b)))
+ }
+
/// Parse a constraint string like `^1.2`, `>=1.0 <2.0`, `^1.0 || ^2.0`.
pub fn parse(input: &str) -> Result<VersionConstraint, String> {
let input = input.trim();
@@ -406,6 +420,147 @@ impl VersionConstraint {
}
}
+// ─────────────────────────────────────────────────────────────────────────────
+// Constraint intersection helpers
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// A reduced range form of a constraint branch: a half-open interval with an
+/// optional excluded version (from `!=`). `lower`/`upper` are `(version,
+/// inclusive)`; `None` means unbounded on that side.
+#[derive(Debug, Clone)]
+struct Range {
+ lower: Option<(Version, bool)>,
+ upper: Option<(Version, bool)>,
+ excluded: Vec<Version>,
+}
+
+impl Range {
+ fn unbounded() -> Self {
+ Range {
+ lower: None,
+ upper: None,
+ excluded: Vec::new(),
+ }
+ }
+}
+
+/// Flatten an OR tree into a vector of single-branch ranges. Returns one entry
+/// per disjunct; AND trees are merged into a single Range.
+fn flatten_or(c: &VersionConstraint) -> Vec<Range> {
+ match c {
+ VersionConstraint::Or(cs) => cs.iter().flat_map(flatten_or).collect(),
+ _ => match constraint_to_range(c) {
+ Some(r) => vec![r],
+ None => vec![Range::unbounded()],
+ },
+ }
+}
+
+/// Reduce a non-OR constraint to a single Range. Returns `None` if the
+/// constraint has nested OR (caller should `flatten_or` first).
+fn constraint_to_range(c: &VersionConstraint) -> Option<Range> {
+ match c {
+ VersionConstraint::Single(atom) => Some(atom_to_range(atom)),
+ VersionConstraint::And(cs) => {
+ let mut acc = Range::unbounded();
+ for sub in cs {
+ let r = constraint_to_range(sub)?;
+ merge_into(&mut acc, &r);
+ }
+ Some(acc)
+ }
+ VersionConstraint::Or(_) => None,
+ }
+}
+
+fn atom_to_range(c: &Constraint) -> Range {
+ match c {
+ Constraint::Any => Range::unbounded(),
+ Constraint::Exact(v) => Range {
+ lower: Some((v.clone(), true)),
+ upper: Some((v.clone(), true)),
+ excluded: Vec::new(),
+ },
+ Constraint::GreaterThan(v) => Range {
+ lower: Some((v.clone(), false)),
+ upper: None,
+ excluded: Vec::new(),
+ },
+ Constraint::GreaterThanOrEqual(v) => Range {
+ lower: Some((v.clone(), true)),
+ upper: None,
+ excluded: Vec::new(),
+ },
+ Constraint::LessThan(v) => Range {
+ lower: None,
+ upper: Some((v.clone(), false)),
+ excluded: Vec::new(),
+ },
+ Constraint::LessThanOrEqual(v) => Range {
+ lower: None,
+ upper: Some((v.clone(), true)),
+ excluded: Vec::new(),
+ },
+ Constraint::NotEqual(v) => Range {
+ lower: None,
+ upper: None,
+ excluded: vec![v.clone()],
+ },
+ }
+}
+
+/// Tighten `acc` by intersecting with `other`. Lower bound becomes the higher
+/// of the two; upper bound becomes the lower; excluded versions accumulate.
+fn merge_into(acc: &mut Range, other: &Range) {
+ if let Some((ov, oi)) = &other.lower {
+ match &acc.lower {
+ None => acc.lower = Some((ov.clone(), *oi)),
+ Some((av, ai)) => {
+ if ov > av || (ov == av && !*oi && *ai) {
+ acc.lower = Some((ov.clone(), *oi));
+ }
+ }
+ }
+ }
+ if let Some((ov, oi)) = &other.upper {
+ match &acc.upper {
+ None => acc.upper = Some((ov.clone(), *oi)),
+ Some((av, ai)) => {
+ if ov < av || (ov == av && !*oi && *ai) {
+ acc.upper = Some((ov.clone(), *oi));
+ }
+ }
+ }
+ }
+ acc.excluded.extend(other.excluded.iter().cloned());
+}
+
+/// Whether two reduced ranges share at least one version.
+fn ranges_intersect(a: &Range, b: &Range) -> bool {
+ let mut combined = a.clone();
+ merge_into(&mut combined, b);
+
+ // Empty interval (lower > upper)
+ if let (Some((lo, li)), Some((hi, hi_inc))) = (&combined.lower, &combined.upper) {
+ if lo > hi {
+ return false;
+ }
+ if lo == hi && !(*li && *hi_inc) {
+ return false;
+ }
+ // Pinned to a single version that is excluded
+ if lo == hi && *li && *hi_inc && combined.excluded.iter().any(|e| e == lo) {
+ return false;
+ }
+ }
+
+ // Witness extraction is approximate: if no bounds, any version works.
+ // Excluded versions only invalidate when the interval has shrunk to a
+ // single excluded point (handled above) — for ranges with width, a
+ // satisfying neighbour always exists.
+ true
+}
+
/// Split on `|` or `||` (pipe-OR). Composer accepts both forms.
fn split_or(s: &str) -> Vec<&str> {
let mut parts = Vec::new();
@@ -2101,4 +2256,60 @@ mod tests {
let b = Version::parse("20220101.0.0").unwrap();
assert!(a > b);
}
+
+ #[test]
+ fn test_intersects_disjoint_exacts() {
+ // The replaced-packages cluster: a `replace: foo "1.0.0"` link is
+ // not a valid provider for a `require: foo "2.0.0"` request.
+ let a = VersionConstraint::parse("1.0.0").unwrap();
+ let b = VersionConstraint::parse("2.0.0").unwrap();
+ assert!(!a.intersects(&b));
+ assert!(!b.intersects(&a));
+ }
+
+ #[test]
+ fn test_intersects_overlapping_ranges() {
+ let a = VersionConstraint::parse("^1.0").unwrap();
+ let b = VersionConstraint::parse(">=1.5 <3.0").unwrap();
+ assert!(a.intersects(&b));
+ }
+
+ #[test]
+ fn test_intersects_disjoint_ranges() {
+ let a = VersionConstraint::parse("^1.0").unwrap();
+ let b = VersionConstraint::parse("^2.0").unwrap();
+ assert!(!a.intersects(&b));
+ }
+
+ #[test]
+ fn test_intersects_or_branch() {
+ // Either branch in an OR is enough to intersect.
+ let a = VersionConstraint::parse("1.0.0 || 2.0.0").unwrap();
+ let b = VersionConstraint::parse("2.0.0").unwrap();
+ assert!(a.intersects(&b));
+ }
+
+ #[test]
+ fn test_intersects_any() {
+ let a = VersionConstraint::parse("*").unwrap();
+ let b = VersionConstraint::parse("1.0.0").unwrap();
+ assert!(a.intersects(&b));
+ assert!(b.intersects(&a));
+ }
+
+ #[test]
+ fn test_intersects_touching_open_boundaries() {
+ // [1, 2) and (2, 3] do not share any version.
+ let a = VersionConstraint::parse(">=1.0 <2.0").unwrap();
+ let b = VersionConstraint::parse(">2.0 <=3.0").unwrap();
+ assert!(!a.intersects(&b));
+ }
+
+ #[test]
+ fn test_intersects_touching_closed_boundaries() {
+ // [1, 2] and [2, 3] share version 2.
+ let a = VersionConstraint::parse(">=1.0 <=2.0").unwrap();
+ let b = VersionConstraint::parse(">=2.0 <=3.0").unwrap();
+ assert!(a.intersects(&b));
+ }
}