diff options
| -rw-r--r-- | Cargo.lock | 35 | ||||
| -rw-r--r-- | crates/mozart/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/mozart/src/lib.rs | 1 | ||||
| -rw-r--r-- | crates/mozart/src/packagist.rs | 6 | ||||
| -rw-r--r-- | crates/mozart/src/resolver.rs | 1775 | ||||
| -rw-r--r-- | crates/mozart/src/version.rs | 77 |
6 files changed, 1839 insertions, 56 deletions
@@ -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()); |
