aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--Cargo.lock35
-rw-r--r--crates/mozart/Cargo.toml1
-rw-r--r--crates/mozart/src/lib.rs1
-rw-r--r--crates/mozart/src/packagist.rs6
-rw-r--r--crates/mozart/src/resolver.rs1775
-rw-r--r--crates/mozart/src/version.rs77
6 files changed, 1839 insertions, 56 deletions
diff --git a/Cargo.lock b/Cargo.lock
index bce0330..f3889fe 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -963,6 +963,7 @@ dependencies = [
"dialoguer",
"flate2",
"md5",
+ "pubgrub",
"regex",
"reqwest",
"serde",
@@ -1052,6 +1053,17 @@ dependencies = [
]
[[package]]
+name = "priority-queue"
+version = "2.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96"
+dependencies = [
+ "equivalent",
+ "indexmap",
+ "serde",
+]
+
+[[package]]
name = "proc-macro2"
version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1061,6 +1073,20 @@ dependencies = [
]
[[package]]
+name = "pubgrub"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3f5df7e552bc7edd075f5783a87fbfc21d6a546e32c16985679c488c18192d83"
+dependencies = [
+ "indexmap",
+ "log",
+ "priority-queue",
+ "rustc-hash",
+ "thiserror 2.0.18",
+ "version-ranges",
+]
+
+[[package]]
name = "quinn"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1842,6 +1868,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
+name = "version-ranges"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3595ffe225639f1e0fd8d7269dcc05d2fbfea93cfac2fea367daf1adb60aae91"
+dependencies = [
+ "smallvec",
+]
+
+[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/crates/mozart/Cargo.toml b/crates/mozart/Cargo.toml
index 9432c4a..0b385fa 100644
--- a/crates/mozart/Cargo.toml
+++ b/crates/mozart/Cargo.toml
@@ -10,6 +10,7 @@ colored = "3.1.1"
dialoguer = "0.12.0"
flate2 = "1"
md5 = "0.7"
+pubgrub = "0.3.0"
regex = "1.12.3"
reqwest = { version = "0.13.2", features = ["blocking", "json"] }
serde = { version = "1.0.228", features = ["derive"] }
diff --git a/crates/mozart/src/lib.rs b/crates/mozart/src/lib.rs
index fb25e77..392e2a3 100644
--- a/crates/mozart/src/lib.rs
+++ b/crates/mozart/src/lib.rs
@@ -7,5 +7,6 @@ pub mod installed;
pub mod lockfile;
pub mod package;
pub mod packagist;
+pub mod resolver;
pub mod validation;
pub mod version;
diff --git a/crates/mozart/src/packagist.rs b/crates/mozart/src/packagist.rs
index 912ec60..7246ee6 100644
--- a/crates/mozart/src/packagist.rs
+++ b/crates/mozart/src/packagist.rs
@@ -24,6 +24,12 @@ pub struct PackagistVersion {
pub version_normalized: String,
#[serde(default)]
pub require: BTreeMap<String, String>,
+ #[serde(default)]
+ pub replace: BTreeMap<String, String>,
+ #[serde(default)]
+ pub provide: BTreeMap<String, String>,
+ #[serde(default)]
+ pub conflict: BTreeMap<String, String>,
pub dist: Option<PackagistDist>,
pub source: Option<PackagistSource>,
}
diff --git a/crates/mozart/src/resolver.rs b/crates/mozart/src/resolver.rs
new file mode 100644
index 0000000..645039b
--- /dev/null
+++ b/crates/mozart/src/resolver.rs
@@ -0,0 +1,1775 @@
+//! Dependency resolver using the pubgrub v0.3.0 algorithm.
+//!
+//! This module converts Composer-style dependency constraints into pubgrub's `Ranges<ComposerVersion>`
+//! and implements `DependencyProvider` for Mozart's package resolution.
+
+use std::cell::RefCell;
+use std::cmp::Reverse;
+use std::collections::{BTreeMap, HashMap};
+use std::fmt;
+
+use pubgrub::{
+ DefaultStringReporter, Dependencies, DependencyConstraints, DependencyProvider,
+ PackageResolutionStatistics, PubGrubError, Ranges, Reporter,
+};
+
+use crate::constraint::{Constraint, VersionConstraint};
+use crate::package::Stability;
+use crate::packagist;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Stability constants
+// ─────────────────────────────────────────────────────────────────────────────
+
+const STABILITY_DEV: u16 = 0;
+const STABILITY_ALPHA_BASE: u16 = 1000;
+const STABILITY_BETA_BASE: u16 = 2000;
+const STABILITY_RC_BASE: u16 = 3000;
+const STABILITY_STABLE: u16 = 4000;
+const STABILITY_PATCH_BASE: u16 = 5000;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// ComposerVersion
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// A Composer version suitable for use with pubgrub.
+///
+/// Encodes a 4-segment Composer version plus stability into an ordered struct.
+/// Stability is encoded numerically so that higher values are more stable:
+/// - dev=0, alpha(N)=1000+N, beta(N)=2000+N, RC(N)=3000+N, stable=4000, patch(N)=5000+N
+///
+/// This ensures natural `Ord` comparison matches Composer's version ordering.
+/// Dev branches (dev-master, dev-*) are NOT representable and return `None` from `from_normalized`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct ComposerVersion {
+ pub major: u16,
+ pub minor: u16,
+ pub patch: u16,
+ pub build: u16,
+ /// Stability encoded as a comparable integer. Higher = more stable.
+ pub stability: u16,
+}
+
+impl PartialOrd for ComposerVersion {
+ fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
+ Some(self.cmp(other))
+ }
+}
+
+impl Ord for ComposerVersion {
+ fn cmp(&self, other: &Self) -> std::cmp::Ordering {
+ (
+ self.major,
+ self.minor,
+ self.patch,
+ self.build,
+ self.stability,
+ )
+ .cmp(&(
+ other.major,
+ other.minor,
+ other.patch,
+ other.build,
+ other.stability,
+ ))
+ }
+}
+
+impl fmt::Display for ComposerVersion {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(
+ f,
+ "{}.{}.{}.{}",
+ self.major, self.minor, self.patch, self.build
+ )?;
+ let s = self.stability;
+ if s == STABILITY_STABLE {
+ // no suffix
+ } else if s >= STABILITY_PATCH_BASE {
+ write!(f, "-patch{}", s - STABILITY_PATCH_BASE)?;
+ } else if s >= STABILITY_RC_BASE {
+ write!(f, "-RC{}", s - STABILITY_RC_BASE)?;
+ } else if s >= STABILITY_BETA_BASE {
+ write!(f, "-beta{}", s - STABILITY_BETA_BASE)?;
+ } else if s >= STABILITY_ALPHA_BASE {
+ write!(f, "-alpha{}", s - STABILITY_ALPHA_BASE)?;
+ } else {
+ write!(f, "-dev")?;
+ }
+ Ok(())
+ }
+}
+
+impl ComposerVersion {
+ /// Parse from a Packagist normalized version string like "1.2.3.0", "1.0.0.0-beta1", "1.0.0.0-RC2".
+ /// Returns `None` for dev branches (dev-master, dev-*, *.x-dev).
+ pub fn from_normalized(normalized: &str) -> Option<ComposerVersion> {
+ let s = normalized.trim();
+
+ // Reject dev branches
+ if s.to_lowercase().starts_with("dev-") {
+ return None;
+ }
+ // Reject *.x-dev style (e.g. "9999999.9999999.9999999.9999999-dev" from packagist sometimes)
+ // Also reject anything like "2.1.x-dev"
+ if s.to_lowercase().ends_with("-dev") && s.contains(".x") {
+ return None;
+ }
+ // Packagist uses 9999999.9999999.9999999.9999999 for dev branches too
+ if s.starts_with("9999999") {
+ return None;
+ }
+
+ // Split on '-' for pre-release
+ let (version_part, pre_part) = if let Some(pos) = s.find('-') {
+ (&s[..pos], Some(&s[pos + 1..]))
+ } else {
+ (s, None)
+ };
+
+ let segments: Vec<&str> = version_part.split('.').collect();
+ if segments.is_empty() || segments[0].is_empty() {
+ return None;
+ }
+
+ let major: u16 = segments[0].parse().ok()?;
+ let minor: u16 = segments.get(1).and_then(|p| p.parse().ok()).unwrap_or(0);
+ let patch: u16 = segments.get(2).and_then(|p| p.parse().ok()).unwrap_or(0);
+ let build: u16 = segments.get(3).and_then(|p| p.parse().ok()).unwrap_or(0);
+
+ let stability = match pre_part {
+ None => STABILITY_STABLE,
+ Some(pre) => encode_pre_release_str(pre),
+ };
+
+ Some(ComposerVersion {
+ major,
+ minor,
+ patch,
+ build,
+ stability,
+ })
+ }
+
+ /// Construct a stable version from numeric segments.
+ pub fn stable(major: u16, minor: u16, patch: u16, build: u16) -> ComposerVersion {
+ ComposerVersion {
+ major,
+ minor,
+ patch,
+ build,
+ stability: STABILITY_STABLE,
+ }
+ }
+
+ /// Get the `Stability` enum value for this version.
+ pub fn stability_enum(&self) -> Stability {
+ if self.stability < STABILITY_ALPHA_BASE {
+ // Covers both STABILITY_DEV (0) and any value below ALPHA_BASE
+ Stability::Dev
+ } else if self.stability < STABILITY_BETA_BASE {
+ Stability::Alpha
+ } else if self.stability < STABILITY_RC_BASE {
+ Stability::Beta
+ } else if self.stability < STABILITY_STABLE {
+ Stability::RC
+ } else {
+ // >= STABILITY_STABLE (includes patch)
+ Stability::Stable
+ }
+ }
+}
+
+fn encode_pre_release_str(pre: &str) -> u16 {
+ let lower = pre.to_lowercase();
+ if lower == "dev" {
+ STABILITY_DEV
+ } else if lower.starts_with("alpha") || lower.starts_with('a') {
+ let n = extract_pre_release_number_from(
+ &lower,
+ if lower.starts_with("alpha") {
+ "alpha"
+ } else {
+ "a"
+ },
+ );
+ STABILITY_ALPHA_BASE + n
+ } else if lower.starts_with("beta") || lower.starts_with('b') {
+ let n = extract_pre_release_number_from(
+ &lower,
+ if lower.starts_with("beta") {
+ "beta"
+ } else {
+ "b"
+ },
+ );
+ STABILITY_BETA_BASE + n
+ } else if lower.starts_with("rc") {
+ let n = extract_pre_release_number_from(&lower, "rc");
+ STABILITY_RC_BASE + n
+ } else if lower.starts_with("patch") || lower.starts_with("pl") {
+ let n = extract_pre_release_number_from(
+ &lower,
+ if lower.starts_with("patch") {
+ "patch"
+ } else {
+ "pl"
+ },
+ );
+ STABILITY_PATCH_BASE + n
+ } else if lower == "p" {
+ STABILITY_PATCH_BASE
+ } else {
+ STABILITY_STABLE
+ }
+}
+
+fn extract_pre_release_number_from(s: &str, prefix: &str) -> u16 {
+ let after = &s[prefix.len()..];
+ let digits: String = after.chars().filter(|c| c.is_ascii_digit()).collect();
+ digits.parse().unwrap_or(0)
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// PackageName
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// A normalized package name (lowercase, e.g. "monolog/monolog").
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct PackageName(pub String);
+
+impl fmt::Display for PackageName {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ f.write_str(&self.0)
+ }
+}
+
+impl PackageName {
+ pub const ROOT: &'static str = "__root__";
+
+ pub fn root() -> Self {
+ PackageName(Self::ROOT.to_string())
+ }
+
+ /// Returns true if this is a platform package (php, ext-*, lib-*).
+ pub fn is_platform(&self) -> bool {
+ self.0 == "php"
+ || self.0.starts_with("ext-")
+ || self.0.starts_with("lib-")
+ || self.0 == "php-64bit"
+ || self.0 == "php-ipv6"
+ || self.0 == "php-zts"
+ || self.0 == "php-debug"
+ }
+
+ /// Returns true if this is the virtual root package.
+ pub fn is_root(&self) -> bool {
+ self.0 == Self::ROOT
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Type alias
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// The version set type used throughout the resolver.
+pub type ComposerVS = Ranges<ComposerVersion>;
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Constraint-to-Ranges conversion
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Convert a Composer version constraint string to a pubgrub `Ranges<ComposerVersion>`.
+///
+/// Supports: exact, >=, >, <=, <, !=, ^, ~, *, wildcards, hyphen ranges, AND, OR.
+pub fn constraint_to_ranges(constraint: &str) -> Result<ComposerVS, String> {
+ let vc = VersionConstraint::parse(constraint)
+ .map_err(|e| format!("Failed to parse constraint '{}': {}", constraint, e))?;
+ version_constraint_to_ranges(&vc)
+}
+
+fn version_constraint_to_ranges(vc: &VersionConstraint) -> Result<ComposerVS, String> {
+ match vc {
+ VersionConstraint::Single(c) => single_constraint_to_ranges(c),
+ VersionConstraint::And(cs) => {
+ let mut result = Ranges::full();
+ for c in cs {
+ result = result.intersection(&version_constraint_to_ranges(c)?);
+ }
+ Ok(result)
+ }
+ VersionConstraint::Or(cs) => {
+ let mut result = Ranges::empty();
+ for c in cs {
+ result = result.union(&version_constraint_to_ranges(c)?);
+ }
+ Ok(result)
+ }
+ }
+}
+
+fn single_constraint_to_ranges(c: &Constraint) -> Result<ComposerVS, String> {
+ match c {
+ Constraint::Any => Ok(Ranges::full()),
+ Constraint::Exact(v) => {
+ let cv = version_to_composer(v)?;
+ Ok(Ranges::singleton(cv))
+ }
+ Constraint::GreaterThan(v) => {
+ let cv = version_to_composer(v)?;
+ Ok(Ranges::strictly_higher_than(cv))
+ }
+ Constraint::GreaterThanOrEqual(v) => {
+ let cv = version_to_composer(v)?;
+ Ok(Ranges::higher_than(cv))
+ }
+ Constraint::LessThan(v) => {
+ let cv = version_to_composer(v)?;
+ Ok(Ranges::strictly_lower_than(cv))
+ }
+ Constraint::LessThanOrEqual(v) => {
+ let cv = version_to_composer(v)?;
+ // No Ranges::lower_than in version-ranges 0.1.x, so use complement of strictly_higher_than
+ Ok(Ranges::strictly_higher_than(cv).complement())
+ }
+ Constraint::NotEqual(v) => {
+ let cv = version_to_composer(v)?;
+ Ok(Ranges::singleton(cv).complement())
+ }
+ }
+}
+
+/// Convert a `constraint::Version` to a `ComposerVersion`.
+fn version_to_composer(v: &crate::constraint::Version) -> Result<ComposerVersion, String> {
+ // Dev branches cannot be represented as ComposerVersion
+ if v.is_dev_branch {
+ return Err(format!(
+ "Dev branch versions cannot be used in Ranges (branch: {:?})",
+ v.dev_branch_name
+ ));
+ }
+
+ let major: u16 = v
+ .major
+ .try_into()
+ .map_err(|_| format!("Major version {} too large for u16", v.major))?;
+ let minor: u16 = v
+ .minor
+ .try_into()
+ .map_err(|_| format!("Minor version {} too large for u16", v.minor))?;
+ let patch: u16 = v
+ .patch
+ .try_into()
+ .map_err(|_| format!("Patch version {} too large for u16", v.patch))?;
+ let build: u16 = v
+ .build
+ .try_into()
+ .map_err(|_| format!("Build version {} too large for u16", v.build))?;
+
+ let stability = encode_pre_release(&v.pre_release);
+
+ Ok(ComposerVersion {
+ major,
+ minor,
+ patch,
+ build,
+ stability,
+ })
+}
+
+fn encode_pre_release(pre: &Option<String>) -> u16 {
+ match pre {
+ None => STABILITY_STABLE,
+ Some(s) => encode_pre_release_str(s),
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Platform configuration
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Platform package configuration.
+/// Maps package names to version strings (normalized, e.g. "8.1.0.0").
+pub struct PlatformConfig {
+ pub packages: HashMap<String, String>,
+}
+
+impl Default for PlatformConfig {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+impl PlatformConfig {
+ /// Create a default platform config with PHP 8.1 and common extensions.
+ pub fn new() -> Self {
+ let mut packages = HashMap::new();
+ packages.insert("php".to_string(), "8.1.0.0".to_string());
+ packages.insert("php-64bit".to_string(), "8.1.0.0".to_string());
+ for ext in &[
+ "json",
+ "mbstring",
+ "openssl",
+ "pdo",
+ "tokenizer",
+ "xml",
+ "ctype",
+ "iconv",
+ "curl",
+ "dom",
+ "fileinfo",
+ "filter",
+ "hash",
+ "pcre",
+ "session",
+ "zlib",
+ "intl",
+ "gd",
+ "bcmath",
+ ] {
+ packages.insert(format!("ext-{ext}"), "8.1.0.0".to_string());
+ }
+ Self { packages }
+ }
+
+ /// Parse platform packages into `ComposerVersion` values.
+ pub fn to_versions(&self) -> HashMap<String, ComposerVersion> {
+ self.packages
+ .iter()
+ .filter_map(|(name, version_str)| {
+ ComposerVersion::from_normalized(version_str).map(|v| (name.clone(), v))
+ })
+ .collect()
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Error types
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Error returned by `DependencyProvider` methods (internal to the solver).
+#[derive(Debug)]
+pub enum ResolverError {
+ /// Network or API error fetching package metadata.
+ PackagistError(String),
+ /// Internal error.
+ Internal(String),
+}
+
+impl fmt::Display for ResolverError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::PackagistError(msg) => write!(f, "Packagist error: {}", msg),
+ Self::Internal(msg) => write!(f, "Internal resolver error: {}", msg),
+ }
+ }
+}
+
+impl std::error::Error for ResolverError {}
+
+/// Error returned by the public `resolve()` function.
+#[derive(Debug)]
+pub enum ResolveError {
+ /// No solution exists. Contains a human-readable explanation.
+ NoSolution(String),
+ /// Error parsing a version constraint.
+ ConstraintParseError(String, String, String), // (package, constraint, error)
+ /// Error fetching dependency metadata.
+ DependencyFetchError(String),
+ /// Internal error.
+ Internal(String),
+}
+
+impl fmt::Display for ResolveError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Self::NoSolution(report) => {
+ writeln!(
+ f,
+ "Your requirements could not be resolved to an installable set of packages."
+ )?;
+ writeln!(f)?;
+ write!(f, "{}", report)
+ }
+ Self::ConstraintParseError(pkg, constraint, err) => {
+ write!(
+ f,
+ "Could not parse version constraint '{}' for package {}: {}",
+ constraint, pkg, err
+ )
+ }
+ Self::DependencyFetchError(msg) => write!(f, "{}", msg),
+ Self::Internal(msg) => write!(f, "Internal resolver error: {}", msg),
+ }
+ }
+}
+
+impl std::error::Error for ResolveError {}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Priority type
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Priority for package resolution ordering.
+/// Higher priority = resolved first.
+#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
+pub struct ResolverPriority {
+ conflict_count: u32,
+ version_count_inverse: Reverse<usize>,
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Provider internals
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Cached version data for a single package.
+struct PackageVersions {
+ /// All versions that pass the stability filter, sorted by ComposerVersion.
+ versions: BTreeMap<ComposerVersion, VersionDependencies>,
+}
+
+/// Dependencies of a specific package version.
+struct VersionDependencies {
+ /// Required packages: (package_name, constraint_string)
+ require: Vec<(String, String)>,
+ /// Replace declarations: (package_name, constraint_string)
+ /// Stored for future replace/provide support (Phase 3.8+).
+ #[allow(dead_code)]
+ replace: Vec<(String, String)>,
+ /// Provide declarations: (package_name, constraint_string)
+ /// Stored for future replace/provide support (Phase 3.8+).
+ #[allow(dead_code)]
+ provide: Vec<(String, String)>,
+ /// Conflict declarations: (package_name, constraint_string)
+ conflict: Vec<(String, String)>,
+ /// Original version string (for output).
+ version_string: String,
+ /// Normalized version string.
+ version_normalized: String,
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// MozartProvider
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// pubgrub `DependencyProvider` that fetches package metadata from Packagist.
+pub struct MozartProvider {
+ /// Cache of fetched package metadata. Populated lazily from Packagist.
+ package_cache: RefCell<HashMap<String, PackageVersions>>,
+
+ /// Platform packages (php, ext-*, lib-*) with their fixed versions.
+ platform_packages: HashMap<String, ComposerVersion>,
+
+ /// Minimum stability threshold. Versions below this are excluded.
+ minimum_stability: Stability,
+
+ /// Per-package stability overrides from composer.json.
+ stability_flags: HashMap<String, Stability>,
+
+ /// Whether prefer-stable is enabled.
+ prefer_stable: bool,
+
+ /// Whether prefer-lowest is enabled (for testing).
+ prefer_lowest: bool,
+
+ /// Root package dependencies (require + optionally require-dev).
+ root_dependencies: Vec<(PackageName, ComposerVS)>,
+
+ /// Root package conflicts.
+ root_conflicts: Vec<(PackageName, ComposerVS)>,
+
+ /// Ignore all platform requirements.
+ ignore_platform_reqs: bool,
+
+ /// Specific platform requirements to ignore.
+ ignore_platform_req_list: Vec<String>,
+}
+
+impl MozartProvider {
+ /// Ensure package metadata is fetched from Packagist and stored in cache.
+ fn ensure_fetched(&self, package_name: &str) -> Result<(), ResolverError> {
+ // Check if already cached
+ {
+ let cache = self.package_cache.borrow();
+ if cache.contains_key(package_name) {
+ return Ok(());
+ }
+ }
+
+ // Fetch from Packagist
+ let packagist_versions = packagist::fetch_package_versions(package_name).map_err(|e| {
+ ResolverError::PackagistError(format!("Failed to fetch {}: {}", package_name, e))
+ })?;
+
+ // Convert and filter
+ let mut versions = BTreeMap::new();
+ for pv in &packagist_versions {
+ let Some(cv) = ComposerVersion::from_normalized(&pv.version_normalized) else {
+ continue; // Skip dev branches
+ };
+
+ // Apply minimum-stability filter
+ if !self.passes_stability_filter(package_name, &cv) {
+ continue;
+ }
+
+ let deps = VersionDependencies {
+ require: pv
+ .require
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect(),
+ replace: pv
+ .replace
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect(),
+ provide: pv
+ .provide
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect(),
+ conflict: pv
+ .conflict
+ .iter()
+ .map(|(k, v)| (k.clone(), v.clone()))
+ .collect(),
+ version_string: pv.version.clone(),
+ version_normalized: pv.version_normalized.clone(),
+ };
+
+ versions.insert(cv, deps);
+ }
+
+ let mut cache = self.package_cache.borrow_mut();
+ cache.insert(package_name.to_string(), PackageVersions { versions });
+
+ Ok(())
+ }
+
+ /// Check if a version passes the minimum-stability filter for the given package.
+ fn passes_stability_filter(&self, package_name: &str, version: &ComposerVersion) -> bool {
+ // Per-package stability override takes precedence
+ let min_stability = self
+ .stability_flags
+ .get(package_name)
+ .copied()
+ .unwrap_or(self.minimum_stability);
+
+ let version_stability = version.stability_enum();
+
+ // `Stability` enum: Stable=0, RC=5, Beta=10, Alpha=15, Dev=20
+ // Lower enum value = more stable.
+ // version_stability must be <= min_stability (i.e., at least as stable as minimum).
+ version_stability <= min_stability
+ }
+
+ /// Check whether a platform dependency should be skipped.
+ fn should_skip_platform_dep(&self, dep_name: &str) -> bool {
+ if !PackageName(dep_name.to_string()).is_platform() {
+ return false;
+ }
+ if self.ignore_platform_reqs {
+ return true;
+ }
+ self.ignore_platform_req_list.iter().any(|p| p == dep_name)
+ }
+}
+
+impl DependencyProvider for MozartProvider {
+ type P = PackageName;
+ type V = ComposerVersion;
+ type VS = ComposerVS;
+ type Priority = ResolverPriority;
+ type M = String;
+ type Err = ResolverError;
+
+ fn choose_version(
+ &self,
+ package: &PackageName,
+ range: &ComposerVS,
+ ) -> Result<Option<ComposerVersion>, ResolverError> {
+ // Root package: always version 0.0.0.0-stable
+ if package.is_root() {
+ let root_v = ComposerVersion::stable(0, 0, 0, 0);
+ if range.contains(&root_v) {
+ return Ok(Some(root_v));
+ }
+ return Ok(None);
+ }
+
+ // Platform packages: return the fixed version if it satisfies the range
+ if package.is_platform() {
+ if let Some(v) = self.platform_packages.get(&package.0)
+ && range.contains(v)
+ {
+ return Ok(Some(*v));
+ }
+ return Ok(None);
+ }
+
+ // Regular packages: ensure metadata is fetched
+ self.ensure_fetched(&package.0)?;
+
+ let cache = self.package_cache.borrow();
+ let Some(pkg_versions) = cache.get(&package.0) else {
+ return Ok(None);
+ };
+
+ if self.prefer_lowest {
+ // Pick the lowest matching version
+ return Ok(pkg_versions
+ .versions
+ .keys()
+ .find(|v| range.contains(*v))
+ .copied());
+ }
+
+ if self.prefer_stable {
+ // First try: highest stable version in range
+ if let Some(v) = pkg_versions
+ .versions
+ .keys()
+ .rev()
+ .find(|v| v.stability >= STABILITY_STABLE && range.contains(*v))
+ {
+ return Ok(Some(*v));
+ }
+ }
+
+ // Default: pick highest version in range
+ Ok(pkg_versions
+ .versions
+ .keys()
+ .rev()
+ .find(|v| range.contains(*v))
+ .copied())
+ }
+
+ fn prioritize(
+ &self,
+ package: &PackageName,
+ range: &ComposerVS,
+ package_conflicts_counts: &PackageResolutionStatistics,
+ ) -> Self::Priority {
+ // Root and platform packages: highest priority (resolved first)
+ if package.is_root() || package.is_platform() {
+ return ResolverPriority {
+ conflict_count: u32::MAX,
+ version_count_inverse: Reverse(0),
+ };
+ }
+
+ let cache = self.package_cache.borrow();
+ let count = cache
+ .get(&package.0)
+ .map(|pvs| pvs.versions.keys().filter(|v| range.contains(*v)).count())
+ .unwrap_or(0);
+
+ ResolverPriority {
+ conflict_count: package_conflicts_counts.conflict_count(),
+ version_count_inverse: Reverse(count),
+ }
+ }
+
+ fn get_dependencies(
+ &self,
+ package: &PackageName,
+ version: &ComposerVersion,
+ ) -> Result<Dependencies<PackageName, ComposerVS, String>, ResolverError> {
+ // Root package: return the configured root dependencies
+ if package.is_root() {
+ let mut deps = DependencyConstraints::default();
+ for (name, range) in &self.root_dependencies {
+ deps.insert(name.clone(), range.clone());
+ }
+ // Apply root conflicts as complement ranges
+ for (name, range) in &self.root_conflicts {
+ let anti_range = range.complement();
+ deps.entry(name.clone())
+ .and_modify(|existing| *existing = existing.intersection(&anti_range))
+ .or_insert(anti_range);
+ }
+ return Ok(Dependencies::Available(deps));
+ }
+
+ // Platform packages: no dependencies
+ if package.is_platform() {
+ return Ok(Dependencies::Available(DependencyConstraints::default()));
+ }
+
+ // Regular packages: fetch metadata and build dependency map
+ self.ensure_fetched(&package.0)?;
+
+ let cache = self.package_cache.borrow();
+ let Some(pkg_versions) = cache.get(&package.0) else {
+ return Ok(Dependencies::Unavailable(format!(
+ "package {} has no available versions",
+ package
+ )));
+ };
+
+ let Some(version_deps) = pkg_versions.versions.get(version) else {
+ return Ok(Dependencies::Unavailable(format!(
+ "{} {} is not available",
+ package, version
+ )));
+ };
+
+ let mut deps = DependencyConstraints::default();
+
+ // Process `require` constraints
+ for (dep_name, constraint_str) in &version_deps.require {
+ // Skip self-dependencies
+ if dep_name == &package.0 {
+ continue;
+ }
+
+ // Skip platform dependencies if configured
+ if self.should_skip_platform_dep(dep_name) {
+ continue;
+ }
+
+ let dep_pkg = PackageName(dep_name.clone());
+
+ match constraint_to_ranges(constraint_str) {
+ Ok(range) => {
+ deps.insert(dep_pkg, range);
+ }
+ Err(e) => {
+ // Unparseable constraint: mark this version as unavailable
+ return Ok(Dependencies::Unavailable(format!(
+ "cannot parse constraint '{}' for dependency {} of {} {}: {}",
+ constraint_str, dep_name, package, version, e
+ )));
+ }
+ }
+ }
+
+ // Process `conflict` declarations as complement ranges
+ for (conflict_name, constraint_str) in &version_deps.conflict {
+ if self.should_skip_platform_dep(conflict_name) {
+ continue;
+ }
+ let conflict_pkg = PackageName(conflict_name.clone());
+ if let Ok(range) = constraint_to_ranges(constraint_str) {
+ let anti_range = range.complement();
+ deps.entry(conflict_pkg)
+ .and_modify(|existing| *existing = existing.intersection(&anti_range))
+ .or_insert(anti_range);
+ }
+ }
+
+ Ok(Dependencies::Available(deps))
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Public API types
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Input to the resolver.
+pub struct ResolveRequest {
+ /// Dependencies from composer.json "require" section.
+ pub require: Vec<(String, String)>,
+ /// Dependencies from composer.json "require-dev" section.
+ pub require_dev: Vec<(String, String)>,
+ /// Whether to include require-dev in resolution.
+ pub include_dev: bool,
+ /// Minimum stability from composer.json.
+ pub minimum_stability: Stability,
+ /// Per-package stability overrides.
+ pub stability_flags: HashMap<String, Stability>,
+ /// Whether prefer-stable is enabled.
+ pub prefer_stable: bool,
+ /// Whether prefer-lowest is enabled.
+ pub prefer_lowest: bool,
+ /// Platform package configuration.
+ pub platform: PlatformConfig,
+ /// Ignore all platform requirements.
+ pub ignore_platform_reqs: bool,
+ /// Specific platform requirements to ignore.
+ pub ignore_platform_req_list: Vec<String>,
+}
+
+/// A single package in the resolution output.
+pub struct ResolvedPackage {
+ pub name: String,
+ /// Human-readable version string (e.g. "1.2.3").
+ pub version: String,
+ /// Normalized version string (e.g. "1.2.3.0").
+ pub version_normalized: String,
+ /// True if the resolved version is a dev/pre-release version.
+ pub is_dev: bool,
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Public resolve() function
+// ─────────────────────────────────────────────────────────────────────────────
+
+/// Run the dependency resolver.
+///
+/// Returns a list of resolved packages (excluding root and platform packages),
+/// or a human-readable error.
+pub fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, ResolveError> {
+ // 1. Build root dependencies
+ let mut root_deps: Vec<(PackageName, ComposerVS)> = Vec::new();
+ let root_conflicts: Vec<(PackageName, ComposerVS)> = Vec::new();
+
+ let parse_dep =
+ |name: &str, constraint: &str| -> Result<Option<(PackageName, ComposerVS)>, ResolveError> {
+ let pkg = PackageName(name.to_string());
+
+ // Skip platform deps if ignore_platform_reqs is set
+ if pkg.is_platform()
+ && (request.ignore_platform_reqs
+ || request.ignore_platform_req_list.contains(&name.to_string()))
+ {
+ return Ok(None);
+ }
+
+ let range = constraint_to_ranges(constraint).map_err(|e| {
+ ResolveError::ConstraintParseError(name.to_string(), constraint.to_string(), e)
+ })?;
+ Ok(Some((pkg, range)))
+ };
+
+ for (name, constraint) in &request.require {
+ if let Some(dep) = parse_dep(name, constraint)? {
+ root_deps.push(dep);
+ }
+ }
+
+ if request.include_dev {
+ for (name, constraint) in &request.require_dev {
+ if let Some(dep) = parse_dep(name, constraint)? {
+ root_deps.push(dep);
+ }
+ }
+ }
+
+ // 2. Build the provider
+ let provider = MozartProvider {
+ package_cache: RefCell::new(HashMap::new()),
+ platform_packages: request.platform.to_versions(),
+ minimum_stability: request.minimum_stability,
+ stability_flags: request.stability_flags.clone(),
+ prefer_stable: request.prefer_stable,
+ prefer_lowest: request.prefer_lowest,
+ root_dependencies: root_deps,
+ root_conflicts,
+ ignore_platform_reqs: request.ignore_platform_reqs,
+ ignore_platform_req_list: request.ignore_platform_req_list.clone(),
+ };
+
+ // 3. Run pubgrub
+ let root = PackageName::root();
+ let root_version = ComposerVersion::stable(0, 0, 0, 0);
+
+ match pubgrub::resolve(&provider, root, root_version) {
+ Ok(solution) => {
+ // 4. Convert solution to ResolvedPackage list
+ let mut result = Vec::new();
+ for (pkg, version) in solution {
+ // Skip root and platform packages
+ if pkg.is_root() || pkg.is_platform() {
+ continue;
+ }
+
+ // Look up the original version string from the cache
+ let cache = provider.package_cache.borrow();
+ let (version_str, version_normalized) = if let Some(pvs) = cache.get(&pkg.0) {
+ if let Some(vd) = pvs.versions.get(&version) {
+ (vd.version_string.clone(), vd.version_normalized.clone())
+ } else {
+ (version.to_string(), version.to_string())
+ }
+ } else {
+ (version.to_string(), version.to_string())
+ };
+
+ result.push(ResolvedPackage {
+ name: pkg.0.clone(),
+ version: version_str,
+ version_normalized,
+ is_dev: version.stability < STABILITY_ALPHA_BASE,
+ });
+ }
+ Ok(result)
+ }
+ Err(PubGrubError::NoSolution(mut derivation_tree)) => {
+ derivation_tree.collapse_no_versions();
+ let report = DefaultStringReporter::report(&derivation_tree);
+ Err(ResolveError::NoSolution(report))
+ }
+ Err(PubGrubError::ErrorRetrievingDependencies {
+ package,
+ version,
+ source,
+ }) => Err(ResolveError::DependencyFetchError(format!(
+ "Error retrieving dependencies for {} {}: {}",
+ package, version, source
+ ))),
+ Err(PubGrubError::ErrorChoosingVersion { package, source }) => {
+ Err(ResolveError::DependencyFetchError(format!(
+ "Error choosing version for {}: {}",
+ package, source
+ )))
+ }
+ Err(PubGrubError::ErrorInShouldCancel(e)) => {
+ Err(ResolveError::Internal(format!("Resolver cancelled: {}", e)))
+ }
+ }
+}
+
+// ─────────────────────────────────────────────────────────────────────────────
+// Tests
+// ─────────────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use pubgrub::{OfflineDependencyProvider, Ranges};
+
+ // ──────────── ComposerVersion parsing ────────────
+
+ #[test]
+ fn test_composer_version_parse_stable() {
+ let v = ComposerVersion::from_normalized("1.2.3.0").unwrap();
+ assert_eq!(v.major, 1);
+ assert_eq!(v.minor, 2);
+ assert_eq!(v.patch, 3);
+ assert_eq!(v.build, 0);
+ assert_eq!(v.stability, STABILITY_STABLE);
+ }
+
+ #[test]
+ fn test_composer_version_parse_beta() {
+ let v = ComposerVersion::from_normalized("1.0.0.0-beta1").unwrap();
+ assert_eq!(v.major, 1);
+ assert_eq!(v.minor, 0);
+ assert_eq!(v.patch, 0);
+ assert_eq!(v.build, 0);
+ assert_eq!(v.stability, STABILITY_BETA_BASE + 1);
+ }
+
+ #[test]
+ fn test_composer_version_parse_rc() {
+ let v = ComposerVersion::from_normalized("2.0.0.0-RC3").unwrap();
+ assert_eq!(v.major, 2);
+ assert_eq!(v.stability, STABILITY_RC_BASE + 3);
+ }
+
+ #[test]
+ fn test_composer_version_parse_alpha() {
+ let v = ComposerVersion::from_normalized("1.0.0.0-alpha2").unwrap();
+ assert_eq!(v.stability, STABILITY_ALPHA_BASE + 2);
+ }
+
+ #[test]
+ fn test_composer_version_parse_dev() {
+ let v = ComposerVersion::from_normalized("1.0.0.0-dev").unwrap();
+ assert_eq!(v.stability, STABILITY_DEV);
+ }
+
+ #[test]
+ fn test_composer_version_parse_dev_branch() {
+ let v = ComposerVersion::from_normalized("dev-master");
+ assert!(
+ v.is_none(),
+ "dev-master should not parse as ComposerVersion"
+ );
+ }
+
+ #[test]
+ fn test_composer_version_parse_x_dev() {
+ let v = ComposerVersion::from_normalized("dev-feature/foo");
+ assert!(v.is_none());
+ }
+
+ #[test]
+ fn test_composer_version_parse_9999999_dev() {
+ // Packagist sometimes uses 9999999.9999999.9999999.9999999 for dev
+ let v = ComposerVersion::from_normalized("9999999.9999999.9999999.9999999-dev");
+ assert!(v.is_none());
+ }
+
+ #[test]
+ fn test_composer_version_ordering_stable() {
+ let v1 = ComposerVersion::from_normalized("2.0.0.0").unwrap();
+ let v2 = ComposerVersion::from_normalized("1.0.0.0").unwrap();
+ assert!(v1 > v2);
+ }
+
+ #[test]
+ fn test_composer_version_ordering_stability() {
+ let stable = ComposerVersion::from_normalized("1.0.0.0").unwrap();
+ let rc = ComposerVersion::from_normalized("1.0.0.0-RC1").unwrap();
+ let beta = ComposerVersion::from_normalized("1.0.0.0-beta1").unwrap();
+ let alpha = ComposerVersion::from_normalized("1.0.0.0-alpha1").unwrap();
+ let dev = ComposerVersion::from_normalized("1.0.0.0-dev").unwrap();
+ assert!(stable > rc);
+ assert!(rc > beta);
+ assert!(beta > alpha);
+ assert!(alpha > dev);
+ }
+
+ #[test]
+ fn test_composer_version_ordering_pre_number() {
+ let beta2 = ComposerVersion::from_normalized("1.0.0.0-beta2").unwrap();
+ let beta1 = ComposerVersion::from_normalized("1.0.0.0-beta1").unwrap();
+ assert!(beta2 > beta1);
+ }
+
+ #[test]
+ fn test_composer_version_display() {
+ let stable = ComposerVersion::stable(1, 2, 3, 0);
+ assert_eq!(format!("{stable}"), "1.2.3.0");
+
+ let beta1 = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_BETA_BASE + 1,
+ };
+ assert_eq!(format!("{beta1}"), "1.0.0.0-beta1");
+
+ let rc2 = ComposerVersion {
+ major: 2,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_RC_BASE + 2,
+ };
+ assert_eq!(format!("{rc2}"), "2.0.0.0-RC2");
+
+ let dev = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_DEV,
+ };
+ assert_eq!(format!("{dev}"), "1.0.0.0-dev");
+ }
+
+ #[test]
+ fn test_composer_version_stability_enum() {
+ let stable = ComposerVersion::stable(1, 0, 0, 0);
+ assert_eq!(stable.stability_enum(), Stability::Stable);
+
+ let rc = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_RC_BASE,
+ };
+ assert_eq!(rc.stability_enum(), Stability::RC);
+
+ let beta = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_BETA_BASE,
+ };
+ assert_eq!(beta.stability_enum(), Stability::Beta);
+
+ let alpha = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_ALPHA_BASE,
+ };
+ assert_eq!(alpha.stability_enum(), Stability::Alpha);
+
+ let dev = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_DEV,
+ };
+ assert_eq!(dev.stability_enum(), Stability::Dev);
+ }
+
+ // ──────────── Constraint conversion ────────────
+
+ fn cv(major: u16, minor: u16, patch: u16, build: u16) -> ComposerVersion {
+ ComposerVersion::stable(major, minor, patch, build)
+ }
+
+ fn cv_dev(major: u16, minor: u16, patch: u16, build: u16) -> ComposerVersion {
+ ComposerVersion {
+ major,
+ minor,
+ patch,
+ build,
+ stability: STABILITY_DEV,
+ }
+ }
+
+ #[test]
+ fn test_constraint_any() {
+ let range = constraint_to_ranges("*").unwrap();
+ assert!(range.contains(&cv(1, 2, 3, 0)));
+ assert!(range.contains(&cv(0, 0, 0, 0)));
+ }
+
+ #[test]
+ fn test_constraint_exact() {
+ let range = constraint_to_ranges("1.2.3").unwrap();
+ // Exact "1.2.3" is parsed as Version { 1, 2, 3, 0, pre_release: None } → stable
+ assert!(range.contains(&cv(1, 2, 3, 0)));
+ assert!(!range.contains(&cv(1, 2, 4, 0)));
+ assert!(!range.contains(&cv(1, 2, 2, 0)));
+ }
+
+ #[test]
+ fn test_constraint_gte() {
+ let range = constraint_to_ranges(">=1.0").unwrap();
+ // >=1.0 parses "1.0" as a stable version (no dev_boundary), so >= 1.0.0.0 (stable)
+ assert!(range.contains(&cv(1, 0, 0, 0)));
+ assert!(range.contains(&cv(2, 0, 0, 0)));
+ // 0.9.0.0 should not be in range
+ assert!(!range.contains(&cv(0, 9, 0, 0)));
+ // 1.0.0.0-dev (stability=0) is LESS than 1.0.0.0 (stability=4000), so NOT in >=1.0
+ assert!(!range.contains(&cv_dev(1, 0, 0, 0)));
+ }
+
+ #[test]
+ fn test_constraint_lt() {
+ let range = constraint_to_ranges("<2.0").unwrap();
+ // <2.0 parses "2.0" as a stable version, so strictly < 2.0.0.0 (stable)
+ // 2.0.0.0-dev (stability=0) is LESS than 2.0.0.0 (stability=4000), so IS in <2.0
+ assert!(range.contains(&cv(1, 9, 9, 0)));
+ assert!(range.contains(&cv_dev(2, 0, 0, 0))); // 2.0.0.0-dev < 2.0.0.0 (stable)
+ // 2.0.0.0 (stable) and higher should not be in range
+ assert!(!range.contains(&cv(2, 0, 0, 0)));
+ }
+
+ #[test]
+ fn test_constraint_caret() {
+ // ^1.2 → >=1.2.0.0-dev <2.0.0.0-dev
+ let range = constraint_to_ranges("^1.2").unwrap();
+ assert!(range.contains(&cv_dev(1, 2, 0, 0)));
+ assert!(range.contains(&cv(1, 2, 0, 0)));
+ assert!(range.contains(&cv(1, 9, 9, 0)));
+ assert!(!range.contains(&cv_dev(2, 0, 0, 0)));
+ assert!(!range.contains(&cv(2, 0, 0, 0)));
+ // Below 1.2.0.0-dev should not match
+ assert!(!range.contains(&cv(1, 1, 9, 0)));
+ }
+
+ #[test]
+ fn test_constraint_caret_zero() {
+ // ^0.2.3 → >=0.2.3.0-dev <0.3.0.0-dev
+ let range = constraint_to_ranges("^0.2.3").unwrap();
+ assert!(range.contains(&cv(0, 2, 3, 0)));
+ assert!(range.contains(&cv(0, 2, 9, 0)));
+ assert!(!range.contains(&cv_dev(0, 3, 0, 0)));
+ assert!(!range.contains(&cv(1, 0, 0, 0)));
+ }
+
+ #[test]
+ fn test_constraint_tilde() {
+ // ~1.2.3 → >=1.2.3.0-dev <1.3.0.0-dev
+ let range = constraint_to_ranges("~1.2.3").unwrap();
+ assert!(range.contains(&cv(1, 2, 3, 0)));
+ assert!(range.contains(&cv(1, 2, 9, 0)));
+ assert!(!range.contains(&cv_dev(1, 3, 0, 0)));
+ }
+
+ #[test]
+ fn test_constraint_wildcard() {
+ // 1.2.* → >=1.2.0.0-dev <1.3.0.0-dev
+ let range = constraint_to_ranges("1.2.*").unwrap();
+ assert!(range.contains(&cv(1, 2, 0, 0)));
+ assert!(range.contains(&cv(1, 2, 9, 0)));
+ assert!(!range.contains(&cv_dev(1, 3, 0, 0)));
+ assert!(!range.contains(&cv(1, 3, 0, 0)));
+ }
+
+ #[test]
+ fn test_constraint_or() {
+ // ^1.0 || ^2.0
+ let range = constraint_to_ranges("^1.0 || ^2.0").unwrap();
+ assert!(range.contains(&cv(1, 5, 0, 0)));
+ assert!(range.contains(&cv(2, 3, 0, 0)));
+ assert!(!range.contains(&cv(3, 0, 0, 0)));
+ }
+
+ #[test]
+ fn test_constraint_and() {
+ // >=1.0 <2.0: >=1.0 means >= 1.0.0.0 (stable); <2.0 means < 2.0.0.0 (stable)
+ let range = constraint_to_ranges(">=1.0 <2.0").unwrap();
+ // 1.0.0.0-dev < 1.0.0.0 (stable), so NOT in >=1.0
+ assert!(!range.contains(&cv_dev(1, 0, 0, 0)));
+ assert!(range.contains(&cv(1, 0, 0, 0)));
+ assert!(range.contains(&cv(1, 9, 9, 0)));
+ // 2.0.0.0-dev < 2.0.0.0 (stable), so IS in <2.0 but overall intersection with >=1.0 is yes
+ assert!(range.contains(&cv_dev(2, 0, 0, 0)));
+ assert!(!range.contains(&cv(2, 0, 0, 0)));
+ }
+
+ #[test]
+ fn test_constraint_not_equal() {
+ let range = constraint_to_ranges("!=1.5.0").unwrap();
+ assert!(range.contains(&cv(1, 4, 0, 0)));
+ assert!(!range.contains(&cv(1, 5, 0, 0)));
+ assert!(range.contains(&cv(1, 6, 0, 0)));
+ }
+
+ #[test]
+ fn test_constraint_hyphen() {
+ // "1.0 - 2.0" → >=1.0.0.0 <=2.0.0.0
+ let range = constraint_to_ranges("1.0 - 2.0").unwrap();
+ assert!(range.contains(&cv(1, 0, 0, 0)));
+ assert!(range.contains(&cv(1, 5, 0, 0)));
+ assert!(range.contains(&cv(2, 0, 0, 0)));
+ assert!(!range.contains(&cv(2, 1, 0, 0)));
+ }
+
+ // ──────────── Provider tests (offline) ────────────
+
+ #[test]
+ fn test_package_name_is_platform() {
+ assert!(PackageName("php".to_string()).is_platform());
+ assert!(PackageName("ext-json".to_string()).is_platform());
+ assert!(PackageName("lib-curl".to_string()).is_platform());
+ assert!(!PackageName("monolog/monolog".to_string()).is_platform());
+ assert!(!PackageName("vendor/package".to_string()).is_platform());
+ }
+
+ #[test]
+ fn test_package_name_is_root() {
+ assert!(PackageName::root().is_root());
+ assert!(!PackageName("monolog/monolog".to_string()).is_root());
+ }
+
+ #[test]
+ fn test_platform_config_to_versions() {
+ let config = PlatformConfig::new();
+ let versions = config.to_versions();
+ assert!(versions.contains_key("php"));
+ assert!(versions.contains_key("ext-json"));
+ let php_v = versions["php"];
+ assert_eq!(php_v.major, 8);
+ assert_eq!(php_v.minor, 1);
+ }
+
+ // ──────────── Integration tests (offline, using OfflineDependencyProvider) ────────────
+
+ type TestVS = Ranges<ComposerVersion>;
+
+ fn cv_stable(major: u16, minor: u16, patch: u16) -> ComposerVersion {
+ ComposerVersion::stable(major, minor, patch, 0)
+ }
+
+ /// Test simple resolution: root → foo ^1.0, foo 1.0 → bar ^2.0, bar 2.0 → (nothing)
+ #[test]
+ fn test_resolve_simple_offline() {
+ let mut provider = OfflineDependencyProvider::<PackageName, TestVS>::new();
+
+ let root = PackageName::root();
+ let root_v = ComposerVersion::stable(0, 0, 0, 0);
+ let foo = PackageName("foo/foo".to_string());
+ let bar = PackageName("bar/bar".to_string());
+
+ let foo_1_0 = cv_stable(1, 0, 0);
+ let bar_2_0 = cv_stable(2, 0, 0);
+
+ // root depends on foo ^1.0
+ let foo_range = constraint_to_ranges("^1.0").unwrap();
+ provider.add_dependencies(root.clone(), root_v, [(foo.clone(), foo_range)]);
+
+ // foo 1.0 depends on bar ^2.0
+ let bar_range = constraint_to_ranges("^2.0").unwrap();
+ provider.add_dependencies(foo.clone(), foo_1_0, [(bar.clone(), bar_range)]);
+
+ // bar 2.0 has no dependencies
+ provider.add_dependencies(bar.clone(), bar_2_0, []);
+
+ let solution = pubgrub::resolve(&provider, root.clone(), root_v).unwrap();
+
+ assert_eq!(*solution.get(&foo).unwrap(), foo_1_0);
+ assert_eq!(*solution.get(&bar).unwrap(), bar_2_0);
+ }
+
+ /// Test conflict detection: two packages require incompatible versions of a third.
+ #[test]
+ fn test_resolve_no_solution_offline() {
+ let mut provider = OfflineDependencyProvider::<PackageName, TestVS>::new();
+
+ let root = PackageName::root();
+ let root_v = ComposerVersion::stable(0, 0, 0, 0);
+ let foo = PackageName("foo/foo".to_string());
+ let bar = PackageName("bar/bar".to_string());
+ let dep = PackageName("dep/dep".to_string());
+
+ let foo_1_0 = cv_stable(1, 0, 0);
+ let bar_1_0 = cv_stable(1, 0, 0);
+ let dep_1_0 = cv_stable(1, 0, 0);
+ let dep_2_0 = cv_stable(2, 0, 0);
+
+ // root depends on foo and bar
+ let foo_range = Ranges::singleton(foo_1_0);
+ let bar_range = Ranges::singleton(bar_1_0);
+ provider.add_dependencies(
+ root.clone(),
+ root_v,
+ [(foo.clone(), foo_range), (bar.clone(), bar_range)],
+ );
+
+ // foo 1.0 requires dep ^1.0 (excludes 2.x)
+ let dep_range_1 = constraint_to_ranges("^1.0").unwrap();
+ provider.add_dependencies(foo.clone(), foo_1_0, [(dep.clone(), dep_range_1)]);
+
+ // bar 1.0 requires dep ^2.0 (excludes 1.x)
+ let dep_range_2 = constraint_to_ranges("^2.0").unwrap();
+ provider.add_dependencies(bar.clone(), bar_1_0, [(dep.clone(), dep_range_2)]);
+
+ // dep has versions 1.0 and 2.0
+ provider.add_dependencies(dep.clone(), dep_1_0, []);
+ provider.add_dependencies(dep.clone(), dep_2_0, []);
+
+ let result = pubgrub::resolve(&provider, root.clone(), root_v);
+ assert!(result.is_err(), "Expected no solution for conflicting deps");
+ }
+
+ /// Test prefer-stable ordering: with prefer-stable, should pick stable over beta.
+ #[test]
+ fn test_prefer_stable() {
+ let stable = ComposerVersion::stable(1, 0, 0, 0);
+ let beta = ComposerVersion {
+ major: 1,
+ minor: 1,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_BETA_BASE + 1,
+ };
+
+ // stable should have higher stability numeric value than beta
+ assert!(
+ stable.stability > beta.stability,
+ "stable should be > beta numerically"
+ );
+ // But stable is 1.0.0.0 and beta is 1.1.0.0-beta1; when prefer-stable is on,
+ // we first look for stable version and pick the highest stable
+ assert!(stable.stability >= STABILITY_STABLE);
+ assert!(beta.stability < STABILITY_STABLE);
+ }
+
+ /// Test stability filter: alpha versions should be excluded when minimum_stability = stable.
+ #[test]
+ fn test_stability_filter() {
+ let provider = MozartProvider {
+ package_cache: RefCell::new(HashMap::new()),
+ platform_packages: HashMap::new(),
+ minimum_stability: Stability::Stable,
+ stability_flags: HashMap::new(),
+ prefer_stable: false,
+ prefer_lowest: false,
+ root_dependencies: vec![],
+ root_conflicts: vec![],
+ ignore_platform_reqs: false,
+ ignore_platform_req_list: vec![],
+ };
+
+ let stable_v = ComposerVersion::stable(1, 0, 0, 0);
+ let alpha_v = ComposerVersion {
+ major: 1,
+ minor: 1,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_ALPHA_BASE,
+ };
+ let beta_v = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_BETA_BASE,
+ };
+ let rc_v = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_RC_BASE,
+ };
+ let dev_v = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_DEV,
+ };
+
+ assert!(provider.passes_stability_filter("foo/foo", &stable_v));
+ assert!(!provider.passes_stability_filter("foo/foo", &alpha_v));
+ assert!(!provider.passes_stability_filter("foo/foo", &beta_v));
+ assert!(!provider.passes_stability_filter("foo/foo", &rc_v));
+ assert!(!provider.passes_stability_filter("foo/foo", &dev_v));
+ }
+
+ #[test]
+ fn test_stability_filter_beta() {
+ let provider = MozartProvider {
+ package_cache: RefCell::new(HashMap::new()),
+ platform_packages: HashMap::new(),
+ minimum_stability: Stability::Beta,
+ stability_flags: HashMap::new(),
+ prefer_stable: false,
+ prefer_lowest: false,
+ root_dependencies: vec![],
+ root_conflicts: vec![],
+ ignore_platform_reqs: false,
+ ignore_platform_req_list: vec![],
+ };
+
+ let stable_v = ComposerVersion::stable(1, 0, 0, 0);
+ let beta_v = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_BETA_BASE,
+ };
+ let alpha_v = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_ALPHA_BASE,
+ };
+ let dev_v = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_DEV,
+ };
+
+ assert!(provider.passes_stability_filter("foo/foo", &stable_v));
+ assert!(provider.passes_stability_filter("foo/foo", &beta_v));
+ assert!(!provider.passes_stability_filter("foo/foo", &alpha_v));
+ assert!(!provider.passes_stability_filter("foo/foo", &dev_v));
+ }
+
+ #[test]
+ fn test_stability_filter_dev() {
+ let provider = MozartProvider {
+ package_cache: RefCell::new(HashMap::new()),
+ platform_packages: HashMap::new(),
+ minimum_stability: Stability::Dev,
+ stability_flags: HashMap::new(),
+ prefer_stable: false,
+ prefer_lowest: false,
+ root_dependencies: vec![],
+ root_conflicts: vec![],
+ ignore_platform_reqs: false,
+ ignore_platform_req_list: vec![],
+ };
+
+ let dev_v = ComposerVersion {
+ major: 1,
+ minor: 0,
+ patch: 0,
+ build: 0,
+ stability: STABILITY_DEV,
+ };
+ assert!(provider.passes_stability_filter("foo/foo", &dev_v));
+ }
+
+ #[test]
+ fn test_skip_platform_dep() {
+ let provider = MozartProvider {
+ package_cache: RefCell::new(HashMap::new()),
+ platform_packages: HashMap::new(),
+ minimum_stability: Stability::Stable,
+ stability_flags: HashMap::new(),
+ prefer_stable: false,
+ prefer_lowest: false,
+ root_dependencies: vec![],
+ root_conflicts: vec![],
+ ignore_platform_reqs: true,
+ ignore_platform_req_list: vec![],
+ };
+
+ assert!(provider.should_skip_platform_dep("php"));
+ assert!(provider.should_skip_platform_dep("ext-json"));
+ assert!(!provider.should_skip_platform_dep("monolog/monolog"));
+ }
+
+ #[test]
+ fn test_skip_specific_platform_dep() {
+ let provider = MozartProvider {
+ package_cache: RefCell::new(HashMap::new()),
+ platform_packages: HashMap::new(),
+ minimum_stability: Stability::Stable,
+ stability_flags: HashMap::new(),
+ prefer_stable: false,
+ prefer_lowest: false,
+ root_dependencies: vec![],
+ root_conflicts: vec![],
+ ignore_platform_reqs: false,
+ ignore_platform_req_list: vec!["ext-intl".to_string()],
+ };
+
+ assert!(provider.should_skip_platform_dep("ext-intl"));
+ assert!(!provider.should_skip_platform_dep("ext-json"));
+ assert!(!provider.should_skip_platform_dep("php"));
+ assert!(!provider.should_skip_platform_dep("monolog/monolog"));
+ }
+
+ #[test]
+ fn test_root_package_choose_version() {
+ let provider = MozartProvider {
+ package_cache: RefCell::new(HashMap::new()),
+ platform_packages: HashMap::new(),
+ minimum_stability: Stability::Stable,
+ stability_flags: HashMap::new(),
+ prefer_stable: false,
+ prefer_lowest: false,
+ root_dependencies: vec![],
+ root_conflicts: vec![],
+ ignore_platform_reqs: false,
+ ignore_platform_req_list: vec![],
+ };
+
+ let root = PackageName::root();
+ let root_v = ComposerVersion::stable(0, 0, 0, 0);
+ let full_range: ComposerVS = Ranges::full();
+ let result = provider.choose_version(&root, &full_range).unwrap();
+ assert_eq!(result, Some(root_v));
+ }
+
+ #[test]
+ fn test_platform_choose_version() {
+ let mut platform = HashMap::new();
+ let php_v = ComposerVersion::from_normalized("8.1.0.0").unwrap();
+ platform.insert("php".to_string(), php_v);
+
+ let provider = MozartProvider {
+ package_cache: RefCell::new(HashMap::new()),
+ platform_packages: platform,
+ minimum_stability: Stability::Stable,
+ stability_flags: HashMap::new(),
+ prefer_stable: false,
+ prefer_lowest: false,
+ root_dependencies: vec![],
+ root_conflicts: vec![],
+ ignore_platform_reqs: false,
+ ignore_platform_req_list: vec![],
+ };
+
+ let php = PackageName("php".to_string());
+ let range = constraint_to_ranges(">=8.0").unwrap();
+ let result = provider.choose_version(&php, &range).unwrap();
+ assert_eq!(result, Some(php_v));
+
+ // Range that excludes 8.1
+ let too_new_range = constraint_to_ranges(">=9.0").unwrap();
+ let result2 = provider.choose_version(&php, &too_new_range).unwrap();
+ assert_eq!(result2, None);
+ }
+
+ /// Test constraint_to_ranges produces correct range with version containment checks.
+ #[test]
+ fn test_constraint_contains_version() {
+ // ^3.0 should contain 3.5.1.0 but not 4.0.0.0
+ let range = constraint_to_ranges("^3.0").unwrap();
+ assert!(range.contains(&cv_stable(3, 5, 1)));
+ assert!(!range.contains(&cv_stable(4, 0, 0)));
+ assert!(!range.contains(&cv_stable(2, 9, 9)));
+ }
+
+ // ──────────── Integration test with MozartProvider (no network) ────────────
+
+ /// Test resolve() with root dependencies using offline provider
+ #[test]
+ fn test_resolve_with_offline_provider_simple() {
+ let mut provider = OfflineDependencyProvider::<PackageName, TestVS>::new();
+
+ let root = PackageName::root();
+ let root_v = ComposerVersion::stable(0, 0, 0, 0);
+ let foo = PackageName("foo/foo".to_string());
+
+ let foo_1_0 = cv_stable(1, 0, 0);
+ let foo_1_1 = cv_stable(1, 1, 0);
+
+ let foo_range = constraint_to_ranges("^1.0").unwrap();
+ provider.add_dependencies(root.clone(), root_v, [(foo.clone(), foo_range)]);
+ provider.add_dependencies(foo.clone(), foo_1_0, []);
+ provider.add_dependencies(foo.clone(), foo_1_1, []);
+
+ let solution = pubgrub::resolve(&provider, root.clone(), root_v).unwrap();
+
+ // Should pick highest version: 1.1.0
+ assert_eq!(*solution.get(&foo).unwrap(), foo_1_1);
+ }
+
+ #[test]
+ fn test_resolve_or_constraint() {
+ let mut provider = OfflineDependencyProvider::<PackageName, TestVS>::new();
+
+ let root = PackageName::root();
+ let root_v = ComposerVersion::stable(0, 0, 0, 0);
+ let foo = PackageName("foo/foo".to_string());
+
+ // foo has versions 1.5.0 and 2.3.0
+ let foo_1_5 = cv_stable(1, 5, 0);
+ let foo_2_3 = cv_stable(2, 3, 0);
+
+ // root requires "^1.0 || ^2.0"
+ let foo_range = constraint_to_ranges("^1.0 || ^2.0").unwrap();
+ provider.add_dependencies(root.clone(), root_v, [(foo.clone(), foo_range)]);
+ provider.add_dependencies(foo.clone(), foo_1_5, []);
+ provider.add_dependencies(foo.clone(), foo_2_3, []);
+
+ let solution = pubgrub::resolve(&provider, root.clone(), root_v).unwrap();
+
+ // Should pick the highest matching version: 2.3.0
+ let picked = *solution.get(&foo).unwrap();
+ assert!(
+ picked == foo_1_5 || picked == foo_2_3,
+ "picked version should be one of the available versions"
+ );
+ }
+
+ // ──────────── End-to-end tests (require network, marked #[ignore]) ────────────
+
+ #[test]
+ #[ignore]
+ fn test_resolve_monolog_e2e() {
+ let request = ResolveRequest {
+ require: vec![("monolog/monolog".to_string(), "^3.0".to_string())],
+ require_dev: vec![],
+ include_dev: false,
+ minimum_stability: Stability::Stable,
+ stability_flags: HashMap::new(),
+ prefer_stable: true,
+ prefer_lowest: false,
+ platform: PlatformConfig::new(),
+ ignore_platform_reqs: false,
+ ignore_platform_req_list: vec![],
+ };
+
+ let result = resolve(&request);
+ match result {
+ Ok(packages) => {
+ println!("Resolved {} packages:", packages.len());
+ for pkg in &packages {
+ println!(" {} {}", pkg.name, pkg.version);
+ }
+ assert!(!packages.is_empty());
+ assert!(packages.iter().any(|p| p.name == "monolog/monolog"));
+ }
+ Err(e) => panic!("Resolution failed: {}", e),
+ }
+ }
+}
diff --git a/crates/mozart/src/version.rs b/crates/mozart/src/version.rs
index a5eca13..4e2bef7 100644
--- a/crates/mozart/src/version.rs
+++ b/crates/mozart/src/version.rs
@@ -173,37 +173,26 @@ mod tests {
);
}
+ fn make_pv(version: &str, version_normalized: &str) -> PackagistVersion {
+ PackagistVersion {
+ version: version.to_string(),
+ version_normalized: version_normalized.to_string(),
+ require: Default::default(),
+ replace: Default::default(),
+ provide: Default::default(),
+ conflict: Default::default(),
+ dist: None,
+ source: None,
+ }
+ }
+
#[test]
fn test_find_best_candidate_stable() {
let versions = vec![
- PackagistVersion {
- version: "dev-master".to_string(),
- version_normalized: "dev-master".to_string(),
- require: Default::default(),
- dist: None,
- source: None,
- },
- PackagistVersion {
- version: "2.0.0-beta.1".to_string(),
- version_normalized: "2.0.0.0-beta1".to_string(),
- require: Default::default(),
- dist: None,
- source: None,
- },
- PackagistVersion {
- version: "1.5.0".to_string(),
- version_normalized: "1.5.0.0".to_string(),
- require: Default::default(),
- dist: None,
- source: None,
- },
- PackagistVersion {
- version: "1.4.0".to_string(),
- version_normalized: "1.4.0.0".to_string(),
- require: Default::default(),
- dist: None,
- source: None,
- },
+ make_pv("dev-master", "dev-master"),
+ make_pv("2.0.0-beta.1", "2.0.0.0-beta1"),
+ make_pv("1.5.0", "1.5.0.0"),
+ make_pv("1.4.0", "1.4.0.0"),
];
let best = find_best_candidate(&versions, Stability::Stable).unwrap();
@@ -213,27 +202,9 @@ mod tests {
#[test]
fn test_find_best_candidate_beta() {
let versions = vec![
- PackagistVersion {
- version: "dev-master".to_string(),
- version_normalized: "dev-master".to_string(),
- require: Default::default(),
- dist: None,
- source: None,
- },
- PackagistVersion {
- version: "2.0.0-beta.1".to_string(),
- version_normalized: "2.0.0.0-beta1".to_string(),
- require: Default::default(),
- dist: None,
- source: None,
- },
- PackagistVersion {
- version: "1.5.0".to_string(),
- version_normalized: "1.5.0.0".to_string(),
- require: Default::default(),
- dist: None,
- source: None,
- },
+ make_pv("dev-master", "dev-master"),
+ make_pv("2.0.0-beta.1", "2.0.0.0-beta1"),
+ make_pv("1.5.0", "1.5.0.0"),
];
let best = find_best_candidate(&versions, Stability::Beta).unwrap();
@@ -242,13 +213,7 @@ mod tests {
#[test]
fn test_find_best_candidate_no_match() {
- let versions = vec![PackagistVersion {
- version: "dev-master".to_string(),
- version_normalized: "dev-master".to_string(),
- require: Default::default(),
- dist: None,
- source: None,
- }];
+ let versions = vec![make_pv("dev-master", "dev-master")];
let best = find_best_candidate(&versions, Stability::Stable);
assert!(best.is_none());