diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-10 00:32:08 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-10 00:32:08 +0900 |
| commit | 8cc1ba8a02c0318b65658f1634de378c780392b9 (patch) | |
| tree | fdd5cb61e488018891a486b25991b87c84220bb8 /crates/mozart-registry/src/resolver.rs | |
| parent | 72b2e877c01e67ba7edd37e34ac2eadb7a1c62c4 (diff) | |
| download | php-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.tar.gz php-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.tar.zst php-mozart-8cc1ba8a02c0318b65658f1634de378c780392b9.zip | |
refactor(workspace): consolidate crates into mozart-core
Merged mozart-archiver, mozart-autoload, mozart-registry,
mozart-sat-resolver, and mozart-vcs into mozart-core to align
the source layout with Composer's structure.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-registry/src/resolver.rs')
| -rw-r--r-- | crates/mozart-registry/src/resolver.rs | 1999 |
1 files changed, 0 insertions, 1999 deletions
diff --git a/crates/mozart-registry/src/resolver.rs b/crates/mozart-registry/src/resolver.rs deleted file mode 100644 index dc9c6dd..0000000 --- a/crates/mozart-registry/src/resolver.rs +++ /dev/null @@ -1,1999 +0,0 @@ -//! Dependency resolver using the SAT solver. -//! -//! This module fetches package metadata from Packagist, builds a Pool of all -//! candidate packages, generates SAT rules, and runs the CDCL solver to find -//! a compatible set of packages to install. - -use indexmap::{IndexMap, IndexSet}; -use regex::{Captures, Regex}; -use std::fmt; -use std::sync::Arc; -use std::sync::LazyLock; - -use crate::packagist; -use crate::repository::{PackageQuery, RepositorySet}; -use crate::vcs_bridge; -use mozart_core::package::{RawRepository, Stability}; -use mozart_sat_resolver::{ - DefaultPolicy, PoolBuilder, PoolLink, PoolPackageInput, RuleSetGenerator, Solver, - make_pool_links, -}; -use mozart_semver::{Version, VersionConstraint}; - -/// Strip a `@stability` suffix from a constraint string and return the -/// cleaned constraint plus the parsed stability. Mirrors Composer's -/// `RootPackageLoader::extractStabilityFlags` (single-constraint case): -/// `"3.2.*@dev"` → (`"3.2.*"`, `Some(Stability::Dev)`). -pub(crate) fn extract_stability_suffix(constraint: &str) -> (String, Option<Stability>) { - let trimmed = constraint.trim(); - if let Some(at_pos) = trimmed.rfind('@') { - let suffix = &trimmed[at_pos + 1..]; - let stability = match suffix.to_lowercase().as_str() { - "dev" => Some(Stability::Dev), - "alpha" => Some(Stability::Alpha), - "beta" => Some(Stability::Beta), - "rc" => Some(Stability::RC), - "stable" => Some(Stability::Stable), - _ => None, - }; - if let Some(s) = stability { - let cleaned = trimmed[..at_pos].trim().to_string(); - // An empty constraint left after the strip means "any version" — - // mirrors Composer's `@dev` shorthand (no version constraint). - let cleaned = if cleaned.is_empty() { - "*".to_string() - } else { - cleaned - }; - return (cleaned, Some(s)); - } - } - (trimmed.to_string(), None) -} - -/// Mirror Composer's `VersionParser::parseStability` for a single-atom -/// constraint string (no `@flag` suffix). Returns `Some(stability)` for -/// recognised non-stable constraints (`dev-foo`, `1.0.x-dev`, `1.0.0-beta1`, -/// …), `None` for stable or unrecognised forms (in which case -/// `minimum_stability` already applies). -/// -/// Composer first strips a trailing `#hash` (handled here), then checks -/// `dev-` prefix / `-dev` suffix / a `(stab)?\d*` modifier. We follow the -/// same shape — the regex variant is overkill for inferring a flag. -pub(crate) fn infer_constraint_stability(constraint: &str) -> Option<Stability> { - let s = constraint.trim(); - // Strip `#ref` (matches Composer's `parseStability` line 54). - let s = match s.find('#') { - Some(p) => &s[..p], - None => s, - }; - // Reject multi-atom constraints — extractStabilityFlags inspects each - // sub-constraint individually but the most common single-atom case is - // all we need for `dev-foo` / `1.0.x-dev` style root requires. - if s.contains([' ', ',']) || s.contains("||") { - return None; - } - // Strip a leading comparison operator (`>=1.0-beta` → `1.0-beta`). - let s = s - .strip_prefix(">=") - .or_else(|| s.strip_prefix("<=")) - .or_else(|| s.strip_prefix("!=")) - .or_else(|| s.strip_prefix("==")) - .or_else(|| s.strip_prefix('>')) - .or_else(|| s.strip_prefix('<')) - .or_else(|| s.strip_prefix('=')) - .or_else(|| s.strip_prefix('^')) - .or_else(|| s.strip_prefix('~')) - .unwrap_or(s); - let lower = s.to_lowercase(); - if lower.starts_with("dev-") || lower.ends_with("-dev") { - return Some(Stability::Dev); - } - // Match `<modifier><digits?>` at the end after the last `-`/`@`. - // Composer uses `{(stable|RC|beta|alpha|dev)([.-]?\d+)?(?:\+.*)?$}`. - let tail = lower - .rsplit_once('-') - .or_else(|| lower.rsplit_once('@')) - .map(|(_, t)| t) - .unwrap_or(&lower); - let tail_word: String = tail.chars().take_while(|c| c.is_alphabetic()).collect(); - match tail_word.as_str() { - "alpha" | "a" => Some(Stability::Alpha), - "beta" | "b" => Some(Stability::Beta), - "rc" => Some(Stability::RC), - "patch" | "pl" | "p" | "stable" => Some(Stability::Stable), - _ => None, - } -} - -/// Determine the `Stability` of a `Version` from its pre_release string. -pub(crate) fn version_stability(v: &Version) -> Stability { - match &v.pre_release { - None => Stability::Stable, - Some(pre) => { - let lower = pre.to_lowercase(); - if lower.starts_with("dev") { - Stability::Dev - } else if lower.starts_with("alpha") || lower.starts_with('a') { - Stability::Alpha - } else if lower.starts_with("beta") || lower.starts_with('b') { - Stability::Beta - } else if lower.starts_with("rc") { - Stability::RC - } else { - // patch/pl/p and unknown → stable - Stability::Stable - } - } - } -} - -/// Parse a Packagist normalized version string like "1.2.3.0", "1.0.0.0-beta1". -/// Returns `None` for dev branches (dev-master, dev-*, *.x-dev). -pub(crate) fn parse_normalized(normalized: &str) -> Option<Version> { - let s = normalized.trim(); - - // Reject dev branches - if s.to_lowercase().starts_with("dev-") { - return None; - } - // Reject *.x-dev style - if s.to_lowercase().ends_with("-dev") && s.contains(".x") { - return None; - } - // Packagist uses 9999999.9999999.9999999.9999999 for dev branches - if s.starts_with("9999999") { - return None; - } - - Version::parse(s).ok() -} - -/// Parse a branch alias target like "2.x-dev" or "1.0.x-dev" into a `Version` with dev pre-release. -fn parse_branch_alias_target(alias_target: &str) -> Option<Version> { - let s = alias_target.trim().to_lowercase(); - if !s.ends_with("-dev") { - return None; - } - let base = &s[..s.len() - 4]; - let base = base.trim_end_matches(".x"); - let parts: Vec<&str> = base.split('.').collect(); - let major: u64 = parts.first().and_then(|p| p.parse().ok())?; - let minor: u64 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0); - let patch: u64 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0); - let build: u64 = parts.get(3).and_then(|p| p.parse().ok()).unwrap_or(0); - Some(Version { - major, - minor, - patch, - build, - pre_release: Some("dev".to_string()), - is_dev_branch: false, - dev_branch_name: None, - }) -} - -/// Mirror Composer's `VersionParser::parseNumericAliasPrefix`: returns true -/// when the input is a numeric branch like `1.2-dev` / `1.2.3-dev` / -/// `1.2.x-dev` (i.e. the prefix is suitable for version comparison). -/// Non-numeric branches like `dev-main` / `dev-feature/x` return false. -fn has_numeric_alias_prefix(branch: &str) -> bool { - let lower = branch.trim().to_lowercase(); - let lower = lower.strip_prefix('v').unwrap_or(&lower); - let Some(base) = lower.strip_suffix("-dev") else { - return false; - }; - let base = base.strip_suffix(".x").unwrap_or(base); - if base.is_empty() { - return false; - } - // Allow only digit segments separated by `.`. - base.split('.') - .all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_ascii_digit())) -} - -/// Mirror Composer's `VersionParser::normalizeBranch` for branch-alias -/// targets: turn a string like `"3.2.x-dev"` into the canonical numeric form -/// `"3.2.9999999.9999999-dev"`. Returns `None` if the input is not a numeric -/// branch (i.e. cannot be expanded to a four-segment numeric version). -/// -/// Composer's flow for an `extra.branch-alias` value: -/// 1. Strip the trailing `-dev`. -/// 2. Pad missing segments with `.x`. -/// 3. Replace each `x` with `9999999`. -/// 4. Re-append `-dev`. -/// -/// This is the form Composer's `Locker::lockPackages` writes into the -/// `aliases` block of `composer.lock` and the form `Pool` indexes for -/// constraint matching, so Mozart needs to use it too. -pub fn normalize_branch_alias_target(alias_target: &str) -> Option<String> { - let trimmed = alias_target.trim(); - let lower = trimmed.to_lowercase(); - let base = lower.strip_suffix("-dev")?; - // Strip leading v/V before normalizing, mirroring Composer's regex - let base = base.strip_prefix('v').unwrap_or(base); - let mut segments: Vec<String> = Vec::with_capacity(4); - for seg in base.split('.') { - if seg == "x" || seg == "X" || seg == "*" { - segments.push("x".to_string()); - } else if seg.chars().all(|c| c.is_ascii_digit()) && !seg.is_empty() { - segments.push(seg.to_string()); - } else { - return None; - } - } - if segments.is_empty() { - return None; - } - while segments.len() < 4 { - segments.push("x".to_string()); - } - let expanded: Vec<String> = segments - .into_iter() - .map(|s| if s == "x" { "9999999".to_string() } else { s }) - .collect(); - Some(format!("{}-dev", expanded.join("."))) -} - -/// Mirror Composer's `VersionParser::normalize` for the values that appear on -/// either side of an `as` clause (`require: "1.0.x-dev as dev-master"`). -/// -/// Composer sends both sides through `normalize`, which: -/// - Maps bare `master` / `trunk` / `default` to the `dev-` prefixed form -/// (`master` → `dev-master`) for BC with Composer 1, then returns -/// `dev-NAME` unchanged. Inline `type: package` entries for these branches -/// land in the pool under the same literal `dev-NAME` form, so root aliases -/// declared with the matching atom must point at that same string. -/// - Strips a leading `v` and treats numeric `*.x-dev` branches via -/// `normalizeBranch` (= `normalize_branch_alias_target`). -/// - Leaves other `dev-NAME` strings as `dev-NAME`. -fn normalize_root_alias_atom(atom: &str) -> Option<String> { - let trimmed = atom.trim(); - if trimmed.is_empty() { - return None; - } - let lower = trimmed.to_lowercase(); - // Composer's normalize: bare `master` / `trunk` / `default` get the - // `dev-` prefix prepended for BC, then fall through to the `dev-` - // branch below. - let with_prefix = if matches!(lower.as_str(), "master" | "trunk" | "default") { - format!("dev-{lower}") - } else { - trimmed.to_string() - }; - let lower_pref = with_prefix.to_lowercase(); - if let Some(rest) = lower_pref.strip_prefix("dev-") { - return Some(format!("dev-{rest}")); - } - if let Some(numeric) = normalize_branch_alias_target(&with_prefix) { - return Some(numeric); - } - // Stable numeric atoms (e.g. `1.1.1`) need to come back in the - // four-segment form `Version::Display` produces, so the alias - // matcher's `input.version != alias.version_normalized` check lines - // up with pool inputs (which carry the 4-segment normalized form). - // Returning the raw input here would silently never match. - parse_normalized(&with_prefix).map(|v| v.to_string()) -} - -/// A root-level alias declared via the `require: "X as Y"` shorthand on the -/// root composer.json. Mirrors Composer's -/// `RootPackageLoader::extractAliases` entries: when the resolver loads a -/// package matching `(package, version_normalized)`, it materializes an extra -/// alias entry exposing the same install under `alias_normalized`/`alias`. -#[derive(Debug, Clone)] -struct RootAlias { - package: String, - /// Normalized form of the LEFT-hand side (the actual constraint). - version_normalized: String, - /// Pretty form of the RIGHT-hand side (the alias to expose). - alias: String, - /// Normalized form of the RIGHT-hand side. - alias_normalized: String, -} - -/// Composer's `RootPackageLoader::extractAliases` regex. Finds every -/// `<left> as <right>` clause inside a constraint string, including those -/// nested in OR / AND expressions (e.g. `1.*||dev-feature-foo as 1.0.2||^2` -/// or `dev-feature-foo, dev-feature-foo as 1.0.2`). The optional `#hex` -/// suffix on the LEFT atom is captured but excluded from the alias target, -/// matching `RootPackageLoader::extractReferences` which records refs out -/// of band. -static ALIAS_CLAUSE_RE: LazyLock<Regex> = LazyLock::new(|| { - Regex::new( - r"(?P<sep>^|\| *|, *)(?P<left>[^,\s#|]+)(?:#[^ ]+)? +as +(?P<right>[^,\s|]+)(?P<after>$| *\|| *,)", - ) - .expect("alias clause regex compiles") -}); - -/// Strip every `<X> as <Y>` clause from a constraint string. Returns the -/// cleaned constraint plus an entry per alias. Mirrors Composer's -/// `VersionParser::parseConstraint` `as`-strip combined with -/// `RootPackageLoader::extractAliases`: the constraint passed to the -/// resolver is the LEFT side of each atom, and a separate alias entry is -/// recorded for each RIGHT side so `RootAliasPackage`-style virtual -/// packages can be materialized later. A trailing `#hex` reference -/// (`dev-main#abcd`) on the LEFT atom is also stripped from the cleaned -/// constraint — `RootPackageLoader::extractReferences` records the hash -/// out of band for the post-resolve `setSourceDistReferences` pass. -fn strip_root_alias_clause(constraint: &str) -> (String, Vec<(String, String)>) { - let trimmed = constraint.trim(); - let mut aliases: Vec<(String, String)> = Vec::new(); - let cleaned = ALIAS_CLAUSE_RE.replace_all(trimmed, |caps: &Captures<'_>| { - let sep = caps.name("sep").map_or("", |m| m.as_str()); - let left = caps.name("left").map_or("", |m| m.as_str()); - let right = caps.name("right").map_or("", |m| m.as_str()); - let after = caps.name("after").map_or("", |m| m.as_str()); - let cleaned_left = strip_inline_reference(left); - aliases.push((cleaned_left.clone(), right.to_string())); - format!("{sep}{cleaned_left}{after}") - }); - if aliases.is_empty() { - return (strip_inline_reference(trimmed), aliases); - } - (cleaned.into_owned(), aliases) -} - -/// Drop a trailing `#hex` reference from a single-atom `dev-*` / `*-dev` -/// constraint, matching Composer's `'{^[^,\s@]+?#([a-f0-9]+)$}'` guard. -/// Lockfile generation records the reference separately via -/// `extract_root_references` and applies it after resolution, so the SAT -/// constraint itself only needs the bare branch name. -fn strip_inline_reference(s: &str) -> String { - if let Some((head, hash)) = s.rsplit_once('#') - && !hash.is_empty() - && hash.chars().all(|c| c.is_ascii_hexdigit()) - && !head.contains([' ', '\t', ',', '@']) - && (head.to_lowercase().starts_with("dev-") || head.to_lowercase().ends_with("-dev")) - { - return head.to_string(); - } - s.to_string() -} - -/// 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-*, composer pseudo packages). - pub fn is_platform(&self) -> bool { - mozart_core::platform::is_platform_package(&self.0) - } - - /// Returns true if this is the virtual root package. - pub fn is_root(&self) -> bool { - self.0 == Self::ROOT - } -} - -/// Platform package configuration. -/// Maps package names to version strings (normalized, e.g. "8.1.0.0"). -pub struct PlatformConfig { - pub packages: IndexMap<String, String>, -} - -impl Default for PlatformConfig { - fn default() -> Self { - Self::new() - } -} - -impl PlatformConfig { - /// Detect platform packages from the local PHP installation. - pub fn new() -> Self { - let detected = mozart_core::platform::detect_platform(); - let mut packages = IndexMap::new(); - for pkg in detected { - packages.insert(pkg.name, pkg.version); - } - Self { packages } - } - - /// Apply `config.platform` overrides on top of the detected packages. - /// - /// Mirrors `Composer\Repository\PlatformRepository::__construct`'s - /// `$overrides` handling: each override either replaces a detected - /// package version or adds a virtual one (e.g. `ext-dummy`). A `false` - /// value disables the package, removing it from the platform. - pub fn apply_overrides(&mut self, overrides: &serde_json::Value) { - let Some(obj) = overrides.as_object() else { - return; - }; - for (name, value) in obj { - let key = name.to_lowercase(); - if value.as_bool() == Some(false) { - self.packages.shift_remove(&key); - continue; - } - if let Some(s) = value.as_str() { - self.packages.insert(key, s.to_string()); - } - } - } - - /// Parse platform packages into `Version` values. - pub fn to_versions(&self) -> IndexMap<String, Version> { - self.packages - .iter() - .filter_map(|(name, version_str)| { - Version::parse(version_str).ok().map(|v| (name.clone(), v)) - }) - .collect() - } -} - -/// 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 {} - -/// Check if a version passes the minimum-stability filter for the given package. -fn passes_stability_filter( - package_name: &str, - version: &Version, - minimum_stability: Stability, - stability_flags: &IndexMap<String, Stability>, -) -> bool { - let min_stability = stability_flags - .get(package_name) - .copied() - .unwrap_or(minimum_stability); - let vs = version_stability(version); - vs <= min_stability -} - -/// Check whether a platform dependency should be skipped. -fn should_skip_platform_dep( - dep_name: &str, - ignore_platform_reqs: bool, - ignore_platform_req_list: &[String], -) -> bool { - if !PackageName(dep_name.to_string()).is_platform() { - return false; - } - if ignore_platform_reqs { - return true; - } - ignore_platform_req_list - .iter() - .any(|p| mozart_core::matches_wildcard(dep_name, p)) -} - -/// Mirrors `Composer\Package\CompletePackage::isAbandoned`: any -/// `abandoned: true` or `abandoned: "<replacement>"` value is truthy. -/// `abandoned: false` and an empty string both register as not-abandoned. -fn is_abandoned(pv: &packagist::PackagistVersion) -> bool { - match &pv.abandoned { - None => false, - Some(serde_json::Value::Null) => false, - Some(serde_json::Value::Bool(b)) => *b, - Some(serde_json::Value::String(s)) => !s.is_empty(), - Some(_) => true, - } -} - -/// Convert a Packagist version entry to PoolPackageInput(s). -/// May return multiple entries if branch aliases are present. -fn packagist_to_pool_inputs( - package_name: &str, - pv: &packagist::PackagistVersion, - minimum_stability: Stability, - stability_flags: &IndexMap<String, Stability>, -) -> Vec<PoolPackageInput> { - let mut results = Vec::new(); - - let make_input = |version_str: &str, - version_normalized: &str, - is_alias_of: Option<String>| - -> PoolPackageInput { - PoolPackageInput { - name: package_name.to_string(), - version: version_normalized.to_string(), - pretty_version: version_str.to_string(), - requires: make_pool_links( - package_name, - version_normalized, - &pv.require - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::<Vec<_>>(), - ), - replaces: make_pool_links( - package_name, - version_normalized, - &pv.replace - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::<Vec<_>>(), - ), - provides: make_pool_links( - package_name, - version_normalized, - &pv.provide - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::<Vec<_>>(), - ), - conflicts: make_pool_links( - package_name, - version_normalized, - &pv.conflict - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::<Vec<_>>(), - ), - is_fixed: false, - is_alias_of, - } - }; - - match parse_normalized(&pv.version_normalized) { - Some(v) => { - if passes_stability_filter(package_name, &v, minimum_stability, stability_flags) { - results.push(make_input(&pv.version, &pv.version_normalized, None)); - } - } - None => { - // Dev branch — emit the original entry (so the alias has a target - // to point at) and one alias entry per matching `extra.branch-alias`. - // Mirrors Composer's `ArrayRepository::addPackage` which adds the - // base package and then calls `createAliasPackage` for each - // branch-alias declaration on it. - let original_passes = passes_stability_filter( - package_name, - &Version { - major: 0, - minor: 0, - patch: 0, - build: 0, - pre_release: Some("dev".to_string()), - is_dev_branch: true, - dev_branch_name: None, - }, - minimum_stability, - stability_flags, - ); - if !original_passes { - return results; - } - results.push(make_input(&pv.version, &pv.version_normalized, None)); - - let aliases = pv.branch_aliases(); - let mut emitted_explicit_alias = false; - for (branch, alias_target) in &aliases { - if branch.to_lowercase() != pv.version.to_lowercase() { - continue; - } - if parse_branch_alias_target(alias_target).is_none() { - continue; - } - let Some(alias_normalized) = normalize_branch_alias_target(alias_target) else { - continue; - }; - results.push(make_input( - alias_target, - &alias_normalized, - Some(pv.version_normalized.clone()), - )); - emitted_explicit_alias = true; - } - - // Mirror Composer's `ArrayLoader::getBranchAlias`: when a - // `dev-` package carries `default-branch: true` and the version - // has no numeric prefix (i.e. it isn't already a `1.0.x-dev` form - // that would be its own alias), synthesize the `9999999-dev` - // alias so root constraints like `dev-main` pick up a default - // branch surfaced as `9999999-dev` in the lock + trace output. - // - // `getBranchAlias` returns the *first* matching branch-alias when - // one exists — i.e. an explicit `branch-alias` entry takes - // precedence over the `default-branch` synthetic one. Skip the - // synthetic alias when an explicit one has already been emitted - // for this version. - if pv.default_branch - && !emitted_explicit_alias - && !has_numeric_alias_prefix(&pv.version) - { - let default_alias = "9999999-dev"; - let default_normalized = "9999999.9999999.9999999.9999999-dev"; - let already_present = results - .iter() - .any(|r| r.version == default_normalized && r.name == package_name); - if !already_present { - results.push(make_input( - default_alias, - default_normalized, - Some(pv.version_normalized.clone()), - )); - } - } - } - } - - results -} - -/// Input to the resolver. -pub struct ResolveRequest { - /// Root package name from composer.json "name" field (e.g. "laravel/laravel"). - /// Used in error messages. Falls back to `__root__` if empty. - pub root_name: String, - /// Root package version from composer.json "version" field. `None` falls - /// back to Composer's `RootPackage::DEFAULT_PRETTY_VERSION` (1.0.0+no-version-set). - /// Used to seed a fixed pool entry for the root so transitive requires - /// pointing at the root (legal circular dependencies via an intermediate - /// package) can be satisfied. - pub root_version: Option<String>, - /// 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: IndexMap<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>, - /// Repository set used to fetch package metadata. Mirrors Composer's - /// `RepositoryManager`. Production builders construct this with a single - /// `PackagistRepository`; in-process test harnesses can construct one - /// without any HTTP-backed repos to mimic Composer's - /// `'packagist' => false` test config. - pub repositories: Arc<RepositorySet>, - /// Temporary version constraint overrides (from --with flag). - /// Maps package name (lowercase) to constraint string. - pub temporary_constraints: IndexMap<String, String>, - /// VCS / inline-package repository entries from composer.json's - /// `repositories` section, used by the eager VCS scan and inline-package - /// preload that still live in `resolve()` (Step B follow-up will move - /// these through `RepositorySet` too). - pub raw_repositories: Vec<RawRepository>, - /// Root composer.json's `provide` map (target → constraint string). Drives - /// the self-fulfilling-rule check in the SAT generator: when a root - /// `require` names something the root itself `provide`s with a matching - /// constraint, no install-one-of rule is emitted, mirroring Composer's - /// `RuleSetGenerator::createRequireRule` self-fulfillment branch. - pub root_provide: IndexMap<String, String>, - /// Root composer.json's `replace` map. Same role as `root_provide` for the - /// `replace` link: a replaced target counts as fulfilled by the root. - pub root_replace: IndexMap<String, String>, - /// Root composer.json's `conflict` map (target → constraint). Composer's - /// `RootPackageRepository` carries these onto the in-pool root package - /// entry; the SAT generator then forbids any candidate matching the - /// constraint, so a root `conflict` blocks both direct selection of the - /// targeted version and any alias / replace / provide that would resolve - /// to it. - pub root_conflict: IndexMap<String, String>, - /// Lowercase names of packages that are pinned to their lock-file version - /// for this resolve (a partial update where the package is not in the - /// update list). Mirrors the `propagateUpdate=false` branch of Composer's - /// `PoolBuilder::loadPackage`: locked-only packages do not pick up - /// `require: "X as Y"` root aliases. Empty for installs and full updates, - /// where every package can take aliases as usual. - pub locked_package_names: IndexSet<String>, - /// Full data of packages pinned to their lock-file version (a partial - /// update). Each entry is added to the pool as a fixed entry, mirroring - /// Composer's `Request::lockPackage` + `PoolBuilder::buildPool`'s - /// `getFixedOrLockedPackages` loop: a locked-only package's pretty/normalized - /// version, requires, replaces, provides and conflicts all enter the pool - /// at exactly one version, so the SAT solver cannot pick a different - /// version (whether directly or via another package's `replace`). Empty - /// for installs and full updates. - pub locked_packages: Vec<LockedPackageInfo>, - /// When true, drop abandoned packages (`abandoned: true|<replacement>`) - /// from the pool before solving. Mirrors Composer's - /// `audit.block-abandoned` config feeding into - /// `SecurityAdvisoryPoolFilter`: the resolver simply never sees these - /// versions, so a root requirement that only matches abandoned candidates - /// fails with the standard "could not be resolved" error. - pub block_abandoned: bool, - /// Pretty form of the root's `extra.branch-alias` target when the root's - /// version matches a key in that map (e.g. `dev-master` → `2.0-dev`). - /// Mirrors Composer's `RootAliasPackage`: an extra alias entry is added - /// to the pool exposing the root under the numeric branch-alias version, - /// with `replace`/`provide`/`conflict` links extended to advertise the - /// alias's version for any link originally written as `self.version`. - /// `None` when the root carries no matching `branch-alias` entry. - pub root_branch_alias: Option<String>, - /// `name → normalized version` map fed to the policy's preferred-version - /// override. Used by `update --minimal-changes` so the solver only moves - /// a package when a constraint actually forces a different version. - /// Empty for a normal full update. - pub preferred_versions: IndexMap<String, String>, - /// When true, drop versions the repositories advertise as covered by an - /// active security advisory before solving. Mirrors Composer's - /// `SecurityAdvisoryPoolFilter` under `config.audit.block-insecure: true`. - pub block_insecure: bool, -} - -/// Full data for a lock-pinned package, used in partial updates. Carried on -/// `ResolveRequest::locked_packages` and turned into a fixed pool entry -/// inside `resolve()`. Mirrors what Composer's `PoolBuilder` reads off a -/// `BasePackage` retrieved from the locked repository. -pub struct LockedPackageInfo { - pub name: String, - /// Pretty (display) version, e.g. "1.2.3". - pub pretty_version: String, - /// Normalized version, e.g. "1.2.3.0". - pub version_normalized: String, - pub requires: Vec<(String, String)>, - pub replaces: Vec<(String, String)>, - pub provides: Vec<(String, String)>, - pub conflicts: Vec<(String, String)>, - /// Branch-alias entries to surface alongside the base locked package, as - /// `(pretty, normalized)` pairs. Mirrors what - /// `Composer\Package\Locker::getLockedRepository` constructs from - /// `extra.branch-alias`: a `dev-master` locked package with branch alias - /// `2.1.x-dev` needs to expose itself under both versions so root - /// constraints like `~2.1` still resolve on a partial update. - pub branch_aliases: Vec<(String, 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, - /// When `Some`, this entry is an `AliasPackage` rather than a real - /// install target. The value is the target's normalized version, used - /// by lock-file generation to populate the `aliases[]` block (and by - /// the installer to emit `Marking ... as installed, alias of ...` - /// trace lines). Real packages have `alias_of: None`. - pub alias_of_normalized: Option<String>, -} - -/// Run the dependency resolver. -/// -/// Returns a list of resolved packages (excluding root and platform packages), -/// or a human-readable error. -pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, ResolveError> { - // 1. Build root requirements - let mut root_requires: IndexMap<String, Option<String>> = IndexMap::new(); - // Per-package stability overrides extracted from `@dev`/`@beta`/etc. - // suffixes on root constraints. Mirrors Composer's - // `RootPackageLoader::extractStabilityFlags`. Merged on top of the - // request's caller-supplied flags (which today are usually empty). - let mut stability_flags: IndexMap<String, Stability> = request.stability_flags.clone(); - // Root-level aliases extracted from `require: "X as Y"`. Mirrors - // Composer's `RootPackageLoader::extractAliases`: each entry adds a new - // alias package to the pool exposing the matched real package under the - // RIGHT-hand version label. - let mut root_aliases: Vec<RootAlias> = Vec::new(); - - let minimum_stability = request.minimum_stability; - let mut insert_root_require = |name: &str, constraint: &str| { - // Strip every `<X> as <Y>` clause first (mirrors Composer's - // `parseConstraint` strip + `extractAliases` capture). The cleaned - // constraint feeds the resolver; each alias is recorded for a second - // pool-population pass once real packages are in. Complex constraints - // (`1.*||dev-feature-foo as 1.0.2||^2`) yield one alias entry plus a - // constraint with the ` as <Y>` segment removed in place. - let (constraint_no_as, alias_pieces) = strip_root_alias_clause(constraint); - for (target_atom, alias_atom) in alias_pieces { - let (Some(target_normalized), Some(alias_normalized)) = ( - normalize_root_alias_atom(&target_atom), - normalize_root_alias_atom(&alias_atom), - ) else { - continue; - }; - root_aliases.push(RootAlias { - package: name.to_lowercase(), - version_normalized: target_normalized, - alias: alias_atom, - alias_normalized, - }); - } - let (clean, stability) = extract_stability_suffix(&constraint_no_as); - let lower = name.to_lowercase(); - if let Some(s) = stability { - let entry = stability_flags.entry(lower.clone()).or_insert(s); - if (*entry as u8) > (s as u8) { - *entry = s; - } - } else if let Some(inferred) = infer_constraint_stability(&clean) { - // Mirrors `RootPackageLoader::extractStabilityFlags` second loop: - // when a single-atom constraint like `dev-main` or `1.0.x-dev` - // implies a non-stable stability and no explicit `@flag` was - // given, raise that package's stability ceiling so the pool - // accepts it. Only applied when the inferred level is *more* - // permissive than `minimum_stability` and any existing flag. - if (inferred as u8) > (minimum_stability as u8) { - let entry = stability_flags.entry(lower.clone()).or_insert(inferred); - if (*entry as u8) < (inferred as u8) { - *entry = inferred; - } - } - } - root_requires.insert(lower, Some(clean)); - }; - - for (name, constraint) in &request.require { - if should_skip_platform_dep( - name, - request.ignore_platform_reqs, - &request.ignore_platform_req_list, - ) { - continue; - } - insert_root_require(name, constraint); - } - - if request.include_dev { - for (name, constraint) in &request.require_dev { - if should_skip_platform_dep( - name, - request.ignore_platform_reqs, - &request.ignore_platform_req_list, - ) { - continue; - } - insert_root_require(name, constraint); - } - } - - // Apply temporary constraints (from --with flag or inline shorthand). - // These override existing root constraints or add new ones for transitive deps. - for (name, constraint) in &request.temporary_constraints { - insert_root_require(name, constraint); - } - - // 2. Build pool, generate rules, and solve - let mut builder = PoolBuilder::new(); - - // Set up ignore list for platform requirements - let mut ignore_set: IndexSet<String> = IndexSet::new(); - for name in &request.ignore_platform_req_list { - ignore_set.insert(name.clone()); - } - builder.set_ignore_platform_reqs(ignore_set.clone()); - builder.set_ignore_all_platform_reqs(request.ignore_platform_reqs); - - // Add platform packages as fixed entries - let platform_config = request.platform.to_versions(); - let mut fixed_packages_by_name: IndexMap<String, u32> = IndexMap::new(); - for (name, version) in &platform_config { - if should_skip_platform_dep( - name, - request.ignore_platform_reqs, - &request.ignore_platform_req_list, - ) { - continue; - } - let input = PoolPackageInput { - name: name.clone(), - version: version.to_string(), - pretty_version: version.to_string(), - requires: vec![], - replaces: vec![], - provides: vec![], - conflicts: vec![], - is_fixed: true, - is_alias_of: None, - }; - builder.add_package(input); - } - - // Mirror Composer's `RootPackageRepository`: put the root package itself - // in the pool as a fixed entry so transitive requires pointing at the - // root (legal circular dependencies via an intermediate package) can - // resolve. Composer clears the root's `require` / `require-dev` on this - // copy because the root requires are already plumbed through the - // rule generator's root-require path; carrying them here too would - // emit duplicate rules. Provide / replace links survive, so virtual - // packages declared on the root keep working for transitive consumers. - let root_name_lower = request.root_name.to_lowercase(); - if !root_name_lower.is_empty() { - let (root_pretty, root_normalized) = match request.root_version.as_deref() { - Some(v) if !v.is_empty() => (v.to_string(), v.to_string()), - _ => ("1.0.0+no-version-set".to_string(), "1.0.0.0".to_string()), - }; - // Resolve `self.version` against the root's normalized version when - // building base links. Mirrors Composer's `ArrayLoader::createLink`: - // a `self.version` constraint is parsed against the declaring package's - // pretty version (here, the root's). The base entry only carries this - // resolved form; any branch-alias entry below extends each base link - // with an extra link tagged at the alias's version, matching - // `AliasPackage::replaceSelfVersionDependencies`. - let make_base_links = |raw: &IndexMap<String, String>| -> Vec<PoolLink> { - raw.iter() - .map(|(target, constraint)| PoolLink { - target: target.to_lowercase(), - constraint: if constraint.trim() == "self.version" { - root_normalized.clone() - } else { - constraint.clone() - }, - source: root_name_lower.clone(), - }) - .collect() - }; - let base_replaces = make_base_links(&request.root_replace); - let base_provides = make_base_links(&request.root_provide); - let base_conflicts = make_base_links(&request.root_conflict); - let root_input = PoolPackageInput { - name: root_name_lower.clone(), - version: root_normalized.clone(), - pretty_version: root_pretty.clone(), - requires: vec![], - replaces: base_replaces.clone(), - provides: base_provides.clone(), - conflicts: base_conflicts.clone(), - is_fixed: true, - is_alias_of: None, - }; - builder.add_package(root_input); - - // Materialize a branch-alias entry for the root when `extra.branch-alias` - // mapped this version to a numeric alias (e.g. dev-master → 2.0-dev). - // Mirrors Composer's `RootAliasPackage`: the alias copies the base's - // resolved replace/provide/conflict links and then ADDS one more link - // per `self.version` original, this time pinned at the alias's own - // version. So a transitive `provided/dependency 2.*` lookup can be - // satisfied through the alias even though the base resolved - // `self.version` to a non-matching dev version. - if let Some(alias_pretty) = &request.root_branch_alias - && let Some(alias_normalized) = normalize_branch_alias_target(alias_pretty) - { - let extra_self_version_links = |raw: &IndexMap<String, String>| -> Vec<PoolLink> { - raw.iter() - .filter(|(_, constraint)| constraint.trim() == "self.version") - .map(|(target, _)| PoolLink { - target: target.to_lowercase(), - constraint: alias_normalized.clone(), - source: root_name_lower.clone(), - }) - .collect() - }; - let mut alias_replaces = base_replaces.clone(); - alias_replaces.extend(extra_self_version_links(&request.root_replace)); - let mut alias_provides = base_provides.clone(); - alias_provides.extend(extra_self_version_links(&request.root_provide)); - let mut alias_conflicts = base_conflicts.clone(); - alias_conflicts.extend(extra_self_version_links(&request.root_conflict)); - builder.add_package(PoolPackageInput { - name: root_name_lower.clone(), - version: alias_normalized, - pretty_version: alias_pretty.clone(), - requires: vec![], - replaces: alias_replaces, - provides: alias_provides, - conflicts: alias_conflicts, - is_fixed: false, - is_alias_of: Some(root_normalized), - }); - } - } - - // Add lock-pinned packages as pool entries (partial-update case). - // - // Mirrors Composer's `PoolBuilder::buildPool` flow: every locked package - // not in the `updateAllowList` is added through `Request::lockPackage`, - // then re-entered into the pool via the `getFixedOrLockedPackages` - // loop. Crucially, a *locked* package is NOT a *fixed* package - // (Request.php:89-98): the SAT solver does not force its installation, - // so a locked package whose root require has been removed will simply - // drop out of the result. The locked entry's purpose is to constrain - // the pool to *only* the locked version for that name — every other - // version is filtered out below — so other packages cannot pick a - // different version (whether directly, or via `replace`, which would - // otherwise let an upgraded replacer silently drop the dependency). - // - // Pre-check: a locked package whose version is rejected by the - // current minimum-stability (composer.json may have tightened - // stability or dropped a `stability-flags` entry the lock relied on) - // cannot be reused as a fixed pool entry. Mirrors what Composer - // surfaces via `Pool::isUnacceptableFixedOrLockedPackage` + - // `Problem::getPrettyString`: bail with the "fixed to <v> (lock file - // version) but that version is rejected by your minimum-stability" - // pointer so the user knows to add the package to the update - // arguments (or use `--with-all-dependencies`). - { - let mut rejected: Vec<String> = Vec::new(); - for locked in &request.locked_packages { - let Ok(v) = Version::parse(&locked.version_normalized) else { - continue; - }; - if !passes_stability_filter( - &locked.name, - &v, - request.minimum_stability, - &stability_flags, - ) { - rejected.push(format!( - " - {} is fixed to {} (lock file version) by a partial update but that version is rejected by your minimum-stability. Make sure you list it as an argument for the update command.", - locked.name, locked.pretty_version - )); - } - } - if !rejected.is_empty() { - let report = rejected - .into_iter() - .enumerate() - .map(|(i, msg)| format!(" Problem {}\n{}", i + 1, msg)) - .collect::<Vec<_>>() - .join("\n"); - return Err(ResolveError::NoSolution(report)); - } - } - - // Build a map first so the filter below knows which (name, version) - // pairs are the only allowed entries for locked names. Each entry holds - // the locked normalized version plus any branch-alias normalized - // versions Composer's `Locker::getLockedRepository` would expose - // alongside the base. Without the alias entries, an inline-package or - // VCS source providing the same `dev-master` + alias as the lock would - // have its alias filtered out, leaving root constraints like `~2.1` — - // which can only match the alias version, not the raw `dev-master` — - // unsatisfiable on a partial update. - let locked_name_to_versions: IndexMap<String, Vec<String>> = request - .locked_packages - .iter() - .map(|p| { - let mut versions = vec![p.version_normalized.clone()]; - for (_, alias_normalized) in &p.branch_aliases { - versions.push(alias_normalized.clone()); - } - (p.name.to_lowercase(), versions) - }) - .collect(); - let lock_filter_allows = |name: &str, version: &str| -> bool { - match locked_name_to_versions.get(&name.to_lowercase()) { - Some(locked_versions) => locked_versions.iter().any(|v| v == version), - None => true, - } - }; - for locked in &request.locked_packages { - let locked_name_lower = locked.name.to_lowercase(); - let input = PoolPackageInput { - name: locked_name_lower.clone(), - version: locked.version_normalized.clone(), - pretty_version: locked.pretty_version.clone(), - requires: make_pool_links( - &locked_name_lower, - &locked.version_normalized, - &locked.requires, - ), - replaces: make_pool_links( - &locked_name_lower, - &locked.version_normalized, - &locked.replaces, - ), - provides: make_pool_links( - &locked_name_lower, - &locked.version_normalized, - &locked.provides, - ), - conflicts: make_pool_links( - &locked_name_lower, - &locked.version_normalized, - &locked.conflicts, - ), - is_fixed: false, - is_alias_of: None, - }; - builder.add_package(input); - // Also expose each `extra.branch-alias` entry as a separate pool - // package, mirroring `Composer\Package\Locker::getLockedRepository` - // (which calls `ArrayLoader::load`, which materializes the - // branch-alias via `getBranchAlias`). Without this, a `dev-master` - // locked package with branch alias `2.2.x-dev` is only visible - // under `dev-master` in the pool, so root requires like `~2.1` - // see no candidate and the resolver fails on a partial update. - for (alias_pretty, alias_normalized) in &locked.branch_aliases { - builder.add_package(PoolPackageInput { - name: locked_name_lower.clone(), - version: alias_normalized.clone(), - pretty_version: alias_pretty.clone(), - requires: make_pool_links(&locked_name_lower, alias_normalized, &locked.requires), - replaces: make_pool_links(&locked_name_lower, alias_normalized, &locked.replaces), - provides: make_pool_links(&locked_name_lower, alias_normalized, &locked.provides), - conflicts: make_pool_links(&locked_name_lower, alias_normalized, &locked.conflicts), - is_fixed: false, - is_alias_of: Some(locked.version_normalized.clone()), - }); - } - } - - // Scan VCS repositories and collect packages from them - let vcs_packages = vcs_bridge::scan_vcs_repositories(&request.raw_repositories).await; - let mut vcs_package_names: IndexSet<String> = IndexSet::new(); - for vpkg in &vcs_packages { - vcs_package_names.insert(vpkg.name.clone()); - } - - // Add VCS packages to the pool - for vpkg in &vcs_packages { - let inputs = - vcs_bridge::vcs_to_pool_inputs(vpkg, request.minimum_stability, &stability_flags); - for input in inputs { - if !lock_filter_allows(&input.name, &input.version) { - continue; - } - builder.add_package(input); - } - } - - // Collect inline `type: package` repositories. These don't require any - // network fetch, but we mirror Composer's `PackageRepository` (which - // extends `ArrayRepository`) and only emit packages whose own `name` - // matches a queried name — `replace`/`provide` targets do NOT pull in - // their replacers eagerly. So we build a name-indexed lookup and add - // entries to the builder on demand from the seed/transitive loops. - // Loading every inline package up front would let the SAT resolver - // pick a replacer that nothing required by name (e.g. - // `broken-deps-do-not-replace.test`), where Composer would correctly - // surface the broken dependency instead. - let inline_packages = crate::inline_package::collect_inline_packages(&request.raw_repositories); - let mut inline_packages_by_name: IndexMap<String, Vec<&crate::inline_package::InlinePackage>> = - IndexMap::new(); - for ipkg in &inline_packages { - inline_packages_by_name - .entry(ipkg.name.clone()) - .or_default() - .push(ipkg); - } - // Build the security-advisory filter once. Mirrors Composer's - // `SecurityAdvisoryPoolFilter`: when `block-insecure` is on, every - // version listed by a repository's `security-advisories` is removed - // from the pool before solving. - let security_advisories = - crate::inline_package::collect_security_advisories(&request.raw_repositories); - let security_blocks_version = |name: &str, version_normalized: &str| -> bool { - if !request.block_insecure { - return false; - } - let Some(advisories) = security_advisories.get(&name.to_lowercase()) else { - return false; - }; - let Ok(parsed) = Version::parse(version_normalized) else { - return false; - }; - advisories.iter().any(|adv| { - VersionConstraint::parse(&adv.affected_versions) - .map(|c| c.matches(&parsed)) - .unwrap_or(false) - }) - }; - // Mirrors Composer's `PoolBuilder::markPackageNameForLoading`: a root - // require's constraint caps every load of that name. Transitive deps that - // would otherwise pull in an out-of-range version (e.g. `foo/requirer` - // requires `foo/original 1.0.0` while the root pinned it at `3.0.0`) are - // silently filtered down to the root-required range, so the pool never - // sees a candidate the root forbids. Without this, providers that satisfy - // the root require can coexist with the actual package at the wrong - // version, masking what should be a conflict. - // - // The match check considers both the base version and any branch-alias - // entries it expands to — mirrors `ArrayRepository::loadPackages`, which - // pulls in the base whenever any of its aliases satisfies the constraint - // (and vice-versa). Skipping the base when only an alias matches would - // leave the alias dangling. - let add_inline_for = |name: &str, - load_constraint: Option<&VersionConstraint>, - builder: &mut PoolBuilder| - -> bool { - let Some(packages) = inline_packages_by_name.get(name) else { - return false; - }; - for ipkg in packages { - if request.block_abandoned && is_abandoned(&ipkg.version) { - continue; - } - if security_blocks_version(&ipkg.name, &ipkg.version.version_normalized) { - continue; - } - let inputs = packagist_to_pool_inputs( - &ipkg.name, - &ipkg.version, - request.minimum_stability, - &stability_flags, - ); - if let Some(c) = load_constraint { - let any_matches = inputs.iter().any(|input| { - Version::parse(&input.version) - .map(|v| c.matches(&v)) - .unwrap_or(false) - }); - if !any_matches { - continue; - } - } - for input in inputs { - if !lock_filter_allows(&input.name, &input.version) { - continue; - } - builder.add_package(input); - } - } - true - }; - - // Pre-parse root-require constraints once. Reused for every name lookup - // in the seed + transitive loops below. - let root_require_constraints: IndexMap<String, VersionConstraint> = root_requires - .iter() - .filter_map(|(name, c)| { - c.as_deref() - .and_then(|s| VersionConstraint::parse(s).ok()) - .map(|vc| (name.clone(), vc)) - }) - .collect(); - - // Collect packages from `type: composer` repositories with file:// URLs. - // The harness rewrites `file://foobar` to `file:///abs/path` before this - // call so the read can be a plain `std::fs::read_to_string`. Same idea - // as inline packages — they bypass the RepositorySet and go straight - // into the pool, with names recorded so Packagist loops skip them. - let composer_repo_packages = - crate::composer_repo::collect_composer_packages(&request.raw_repositories); - let mut composer_repo_names: IndexSet<String> = IndexSet::new(); - for cpkg in &composer_repo_packages { - composer_repo_names.insert(cpkg.name.clone()); - if request.block_abandoned && is_abandoned(&cpkg.version) { - continue; - } - let inputs = packagist_to_pool_inputs( - &cpkg.name, - &cpkg.version, - request.minimum_stability, - &stability_flags, - ); - for input in inputs { - if !lock_filter_allows(&input.name, &input.version) { - continue; - } - builder.add_package(input); - } - } - - // The repository set is supplied by the caller. Today production - // builders pass a single-Packagist set; in-process tests can pass a - // set with no HTTP-backed repos. VCS and inline packages above are - // still preloaded directly, and their names go into the skip lists so - // we don't double-load them through this set. - let repo_set: &RepositorySet = &request.repositories; - - // Seed the builder with packages for root requirements. Inline - // `type: package` matches are added directly via the name-indexed - // lookup; everything else falls through to the network-backed - // repository set. - let seed_names: Vec<String> = root_requires - .keys() - .filter(|name| !PackageName((*name).clone()).is_platform()) - .filter(|name| !vcs_package_names.contains(*name) && !composer_repo_names.contains(*name)) - .cloned() - .collect(); - let mut seed_queries: Vec<PackageQuery<'_>> = Vec::new(); - for name in &seed_names { - let load_constraint = root_require_constraints.get(name); - if add_inline_for(name.as_str(), load_constraint, &mut builder) { - continue; - } - seed_queries.push(PackageQuery { - name: name.as_str(), - constraint: root_requires.get(name).and_then(|c| c.as_deref()), - }); - } - let seed_results = repo_set - .load_packages(&seed_queries) - .await - .map_err(|e| ResolveError::DependencyFetchError(e.to_string()))?; - for r in &seed_results { - if request.block_abandoned && is_abandoned(&r.version) { - continue; - } - let inputs = packagist_to_pool_inputs( - &r.name, - &r.version, - request.minimum_stability, - &stability_flags, - ); - for input in inputs { - if !lock_filter_allows(&input.name, &input.version) { - continue; - } - builder.add_package(input); - } - } - - // Explore transitive dependencies. - while let Some(name) = builder.next_pending() { - if PackageName(name.clone()).is_platform() { - continue; - } - - // Skip packages already provided by VCS or `type: composer` repos - // (those still get eager-loaded above). Inline `type: package` - // matches are loaded on demand by name, mirroring Composer's - // ArrayRepository semantics. - if vcs_package_names.contains(&name) || composer_repo_names.contains(&name) { - continue; - } - let load_constraint = root_require_constraints.get(&name); - if add_inline_for(name.as_str(), load_constraint, &mut builder) { - continue; - } - - let queries = [PackageQuery { - name: name.as_str(), - constraint: root_requires.get(&name).and_then(|c| c.as_deref()), - }]; - let results = match repo_set.load_packages(&queries).await { - Ok(v) => v, - Err(_) => { - // Virtual/meta packages (e.g. "psr/http-client-implementation") - // don't exist on Packagist. They are resolved via provides/replaces - // from other packages already in the pool. - continue; - } - }; - for r in &results { - if request.block_abandoned && is_abandoned(&r.version) { - continue; - } - let inputs = packagist_to_pool_inputs( - &r.name, - &r.version, - request.minimum_stability, - &request.stability_flags, - ); - for input in inputs { - if !lock_filter_allows(&input.name, &input.version) { - continue; - } - builder.add_package(input); - } - } - } - - // Second pass: materialize root aliases (`require: "X as Y"`). - // - // Mirrors Composer's `PoolBuilder::loadPackage` post-load step: when a - // package whose `(name, version)` matches a `rootAliases` entry is added, - // an extra `AliasPackage` exposing that install under - // `(alias_normalized, alias)` is appended to the pool. When the matched - // input is already an alias (e.g. an `extra.branch-alias` entry from - // `packagist_to_pool_inputs`), Composer follows `getAliasOf()` to the - // base package — we replicate by carrying the input's `is_alias_of` - // value forward, so the new alias points straight at the real package - // rather than chaining through the intermediate alias. - if !root_aliases.is_empty() { - let mut new_aliases: Vec<PoolPackageInput> = Vec::new(); - for input in builder.inputs() { - // Skip alias creation for packages locked to their lock-file - // version (partial update where this package wasn't requested). - // Mirrors Composer's `propagateUpdate=false` skip in - // `PoolBuilder::loadPackage`. - if request - .locked_package_names - .contains(&input.name.to_lowercase()) - { - continue; - } - for alias in &root_aliases { - if input.name.to_lowercase() != alias.package { - continue; - } - if input.version != alias.version_normalized { - continue; - } - let target_normalized = input - .is_alias_of - .clone() - .unwrap_or_else(|| input.version.clone()); - // Extend `self.version`-derived `replace` / `provide` / - // `conflict` links with an extra entry pinned at the - // alias's own version. Mirrors Composer's - // `AliasPackage::replaceSelfVersionDependencies`: a base - // link whose constraint matches the base's own version - // (the resolved form of `self.version`) is duplicated - // under the alias at the alias's version, so a transitive - // require like `a/aliased-replaced ^4.0` can match the - // alias even when the base is at a non-matching dev - // version. Without this, the alias's replace map keeps - // the base's `dev-next` constraint and the requirement - // never sees a numeric provider. - let alias_extra_self_links = |links: &[PoolLink]| -> Vec<PoolLink> { - links - .iter() - .filter(|l| l.constraint == input.version) - .map(|l| PoolLink { - target: l.target.clone(), - constraint: alias.alias_normalized.clone(), - source: l.source.clone(), - }) - .collect() - }; - let mut alias_replaces = input.replaces.clone(); - alias_replaces.extend(alias_extra_self_links(&input.replaces)); - let mut alias_provides = input.provides.clone(); - alias_provides.extend(alias_extra_self_links(&input.provides)); - let mut alias_conflicts = input.conflicts.clone(); - alias_conflicts.extend(alias_extra_self_links(&input.conflicts)); - new_aliases.push(PoolPackageInput { - name: input.name.clone(), - version: alias.alias_normalized.clone(), - pretty_version: alias.alias.clone(), - requires: input.requires.clone(), - replaces: alias_replaces, - provides: alias_provides, - conflicts: alias_conflicts, - is_fixed: false, - is_alias_of: Some(target_normalized), - }); - } - } - for alias_input in new_aliases { - builder.add_package(alias_input); - } - } - - // Build the pool - let mut pool = builder.build(); - // Collect fixed package IDs - let mut fixed_ids: Vec<u32> = Vec::new(); - for pkg in pool.packages() { - if pkg.is_fixed { - fixed_ids.push(pkg.id); - fixed_packages_by_name.insert(pkg.name.clone(), pkg.id); - } - } - - // Generate rules - let mut generator = RuleSetGenerator::new(&mut pool); - generator.set_ignore_platform_reqs(ignore_set); - generator.set_ignore_all_platform_reqs(request.ignore_platform_reqs); - let (rules, missing_root_requires) = generator.generate( - &root_requires, - &fixed_ids, - &request.root_provide, - &request.root_replace, - ); - - // Mirror Composer's `Solver::checkForRootRequireProblems`: a root require - // with no providers in the pool yields no SAT rule, so the solver would - // succeed with an empty plan. Surface it as an unresolvable problem - // instead, matching Composer's exit code 2 behaviour. - if !missing_root_requires.is_empty() { - let problems: Vec<String> = missing_root_requires - .iter() - .map(|(name, constraint)| match constraint.as_deref() { - Some(c) if !c.is_empty() => format!( - " - Root composer.json requires {name} {c}, no matching package found." - ), - _ => { - format!(" - Root composer.json requires {name}, no matching package found.") - } - }) - .collect(); - let report = problems - .into_iter() - .enumerate() - .map(|(i, msg)| format!(" Problem {}\n{}", i + 1, msg)) - .collect::<Vec<_>>() - .join("\n"); - return Err(ResolveError::NoSolution(report)); - } - - // Create policy and solve. When `preferred_versions` is non-empty (the - // `--minimal-changes` flow) feed it through the policy so the locked - // version wins over the regular highest/lowest pick whenever a candidate - // matches it. Mirrors Composer's - // `Installer::createPolicy` minimal-update branch. - let policy = if request.preferred_versions.is_empty() { - DefaultPolicy::new(request.prefer_stable, request.prefer_lowest) - } else { - DefaultPolicy::with_preferred( - request.prefer_stable, - request.prefer_lowest, - request.preferred_versions.clone(), - ) - }; - let fixed_set: IndexSet<u32> = fixed_ids.into_iter().collect(); - let solver = Solver::new(rules, &pool, policy, fixed_set); - - match solver.solve() { - Ok(result) => { - let mut resolved = Vec::new(); - for pkg_id in result.installed { - let pkg = pool.package_by_id(pkg_id); - - // Skip platform packages from output - if PackageName(pkg.name.clone()).is_platform() { - continue; - } - - // Skip the root package itself. It's in the pool as a fixed - // entry only so transitive requires pointing back at it - // can resolve; it must not appear in the lock file or - // operations list. Mirrors Composer's `LockTransaction` - // which discards fixed packages from the result. - if !root_name_lower.is_empty() && pkg.name == root_name_lower { - continue; - } - - let is_dev = if let Ok(v) = Version::parse(&pkg.version) { - version_stability(&v) == Stability::Dev - } else { - false - }; - - let alias_of_normalized = pkg - .is_alias_of - .map(|tid| pool.package_by_id(tid).version.clone()); - - resolved.push(ResolvedPackage { - name: pkg.name.clone(), - version: pkg.pretty_version.clone(), - version_normalized: pkg.version.clone(), - is_dev, - alias_of_normalized, - }); - } - Ok(resolved) - } - Err(e) => Err(ResolveError::NoSolution(e.to_string())), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn v(major: u64, minor: u64, patch: u64, build: u64) -> Version { - Version { - major, - minor, - patch, - build, - pre_release: None, - is_dev_branch: false, - dev_branch_name: None, - } - } - - fn v_pre(major: u64, minor: u64, patch: u64, build: u64, pre: &str) -> Version { - Version { - major, - minor, - patch, - build, - pre_release: Some(pre.to_string()), - is_dev_branch: false, - dev_branch_name: None, - } - } - - #[test] - fn test_parse_normalized_stable() { - let ver = parse_normalized("1.2.3.0").unwrap(); - assert_eq!((ver.major, ver.minor, ver.patch, ver.build), (1, 2, 3, 0)); - assert_eq!(ver.pre_release, None); - } - - #[test] - fn test_parse_normalized_beta() { - let ver = parse_normalized("1.0.0.0-beta1").unwrap(); - assert_eq!(ver.major, 1); - assert_eq!(ver.pre_release, Some("beta1".to_string())); - } - - #[test] - fn test_parse_normalized_rc() { - let ver = parse_normalized("2.0.0.0-RC3").unwrap(); - assert_eq!(ver.major, 2); - assert_eq!(ver.pre_release, Some("RC3".to_string())); - } - - #[test] - fn test_parse_normalized_alpha() { - let ver = parse_normalized("1.0.0.0-alpha2").unwrap(); - assert_eq!(ver.pre_release, Some("alpha2".to_string())); - } - - #[test] - fn test_parse_normalized_dev() { - let ver = parse_normalized("1.0.0.0-dev").unwrap(); - assert_eq!(ver.pre_release, Some("dev".to_string())); - } - - #[test] - fn test_parse_normalized_dev_branch() { - let ver = parse_normalized("dev-master"); - assert!( - ver.is_none(), - "dev-master should not parse as normalized version" - ); - } - - #[test] - fn test_parse_normalized_x_dev() { - let ver = parse_normalized("dev-feature/foo"); - assert!(ver.is_none()); - } - - #[test] - fn test_parse_normalized_9999999_dev() { - let ver = parse_normalized("9999999.9999999.9999999.9999999-dev"); - assert!(ver.is_none()); - } - - #[test] - fn test_parse_normalized_large_version() { - let ver = parse_normalized("20031129").unwrap(); - assert_eq!(ver.major, 20031129); - assert_eq!(ver.pre_release, None); - } - - #[test] - fn test_version_ordering_stable() { - let v1 = parse_normalized("2.0.0.0").unwrap(); - let v2 = parse_normalized("1.0.0.0").unwrap(); - assert!(v1 > v2); - } - - #[test] - fn test_version_ordering_stability() { - let stable = parse_normalized("1.0.0.0").unwrap(); - let rc = parse_normalized("1.0.0.0-RC1").unwrap(); - let beta = parse_normalized("1.0.0.0-beta1").unwrap(); - let alpha = parse_normalized("1.0.0.0-alpha1").unwrap(); - let dev = parse_normalized("1.0.0.0-dev").unwrap(); - assert!(stable > rc); - assert!(rc > beta); - assert!(beta > alpha); - assert!(alpha > dev); - } - - #[test] - fn test_version_ordering_pre_number() { - let beta2 = parse_normalized("1.0.0.0-beta2").unwrap(); - let beta1 = parse_normalized("1.0.0.0-beta1").unwrap(); - assert!(beta2 > beta1); - } - - #[test] - fn test_version_display() { - let stable = v(1, 2, 3, 0); - assert_eq!(format!("{stable}"), "1.2.3.0"); - - let beta1 = v_pre(1, 0, 0, 0, "beta1"); - assert_eq!(format!("{beta1}"), "1.0.0.0-beta1"); - - let rc2 = v_pre(2, 0, 0, 0, "RC2"); - assert_eq!(format!("{rc2}"), "2.0.0.0-RC2"); - - let dev = v_pre(1, 0, 0, 0, "dev"); - assert_eq!(format!("{dev}"), "1.0.0.0-dev"); - } - - #[test] - fn test_version_stability_fn() { - assert_eq!(version_stability(&v(1, 0, 0, 0)), Stability::Stable); - assert_eq!(version_stability(&v_pre(1, 0, 0, 0, "RC1")), Stability::RC); - assert_eq!( - version_stability(&v_pre(1, 0, 0, 0, "beta1")), - Stability::Beta - ); - assert_eq!( - version_stability(&v_pre(1, 0, 0, 0, "alpha1")), - Stability::Alpha - ); - assert_eq!(version_stability(&v_pre(1, 0, 0, 0, "dev")), Stability::Dev); - assert_eq!( - version_stability(&v_pre(1, 0, 0, 0, "patch1")), - Stability::Stable - ); - } - - #[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("composer".to_string()).is_platform()); - assert!(PackageName("composer-plugin-api".to_string()).is_platform()); - assert!(PackageName("composer-runtime-api".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_stability_filter() { - let stable_v = v(1, 0, 0, 0); - let alpha_v = v_pre(1, 1, 0, 0, "alpha1"); - let beta_v = v_pre(1, 0, 0, 0, "beta1"); - let rc_v = v_pre(1, 0, 0, 0, "RC1"); - let dev_v = v_pre(1, 0, 0, 0, "dev"); - - let flags = IndexMap::new(); - - assert!(passes_stability_filter( - "foo/foo", - &stable_v, - Stability::Stable, - &flags - )); - assert!(!passes_stability_filter( - "foo/foo", - &alpha_v, - Stability::Stable, - &flags - )); - assert!(!passes_stability_filter( - "foo/foo", - &beta_v, - Stability::Stable, - &flags - )); - assert!(!passes_stability_filter( - "foo/foo", - &rc_v, - Stability::Stable, - &flags - )); - assert!(!passes_stability_filter( - "foo/foo", - &dev_v, - Stability::Stable, - &flags - )); - } - - #[test] - fn test_stability_filter_beta() { - let stable_v = v(1, 0, 0, 0); - let beta_v = v_pre(1, 0, 0, 0, "beta1"); - let alpha_v = v_pre(1, 0, 0, 0, "alpha1"); - let dev_v = v_pre(1, 0, 0, 0, "dev"); - - let flags = IndexMap::new(); - - assert!(passes_stability_filter( - "foo/foo", - &stable_v, - Stability::Beta, - &flags - )); - assert!(passes_stability_filter( - "foo/foo", - &beta_v, - Stability::Beta, - &flags - )); - assert!(!passes_stability_filter( - "foo/foo", - &alpha_v, - Stability::Beta, - &flags - )); - assert!(!passes_stability_filter( - "foo/foo", - &dev_v, - Stability::Beta, - &flags - )); - } - - #[test] - fn test_stability_filter_dev() { - let dev_v = v_pre(1, 0, 0, 0, "dev"); - let flags = IndexMap::new(); - assert!(passes_stability_filter( - "foo/foo", - &dev_v, - Stability::Dev, - &flags - )); - } - - #[test] - fn test_skip_platform_dep() { - assert!(should_skip_platform_dep("php", true, &[])); - assert!(should_skip_platform_dep("ext-json", true, &[])); - assert!(!should_skip_platform_dep("monolog/monolog", true, &[])); - } - - #[test] - fn test_skip_specific_platform_dep() { - let list = vec!["ext-intl".to_string()]; - assert!(should_skip_platform_dep("ext-intl", false, &list)); - assert!(!should_skip_platform_dep("ext-json", false, &list)); - assert!(!should_skip_platform_dep("php", false, &list)); - assert!(!should_skip_platform_dep("monolog/monolog", false, &list)); - } - - #[test] - fn test_parse_branch_alias_target_x_dev() { - let ver = parse_branch_alias_target("2.x-dev").unwrap(); - assert_eq!((ver.major, ver.minor, ver.patch, ver.build), (2, 0, 0, 0)); - assert_eq!(ver.pre_release, Some("dev".to_string())); - } - - #[test] - fn test_parse_branch_alias_target_minor_x_dev() { - let ver = parse_branch_alias_target("1.5.x-dev").unwrap(); - assert_eq!((ver.major, ver.minor, ver.patch), (1, 5, 0)); - assert_eq!(ver.pre_release, Some("dev".to_string())); - } - - #[test] - fn test_parse_branch_alias_target_patch_x_dev() { - let ver = parse_branch_alias_target("1.0.2.x-dev").unwrap(); - assert_eq!((ver.major, ver.minor, ver.patch), (1, 0, 2)); - assert_eq!(ver.pre_release, Some("dev".to_string())); - } - - #[test] - fn test_parse_branch_alias_target_invalid() { - assert!(parse_branch_alias_target("dev-master").is_none()); - assert!(parse_branch_alias_target("2.0.0").is_none()); - assert!(parse_branch_alias_target("").is_none()); - } - - #[test] - fn test_sat_resolve_simple_offline() { - use mozart_sat_resolver::*; - - let mut pool = Pool::new( - vec![ - PoolPackageInput { - name: "foo/foo".to_string(), - version: "1.0.0.0".to_string(), - pretty_version: "1.0.0".to_string(), - requires: vec![PoolLink { - target: "bar/bar".to_string(), - constraint: "^2.0".to_string(), - source: "foo/foo".to_string(), - }], - replaces: vec![], - provides: vec![], - conflicts: vec![], - is_fixed: false, - is_alias_of: None, - }, - PoolPackageInput { - name: "bar/bar".to_string(), - version: "2.0.0.0".to_string(), - pretty_version: "2.0.0".to_string(), - requires: vec![], - replaces: vec![], - provides: vec![], - conflicts: vec![], - is_fixed: false, - is_alias_of: None, - }, - ], - vec![], - ); - - let mut requires = IndexMap::new(); - requires.insert("foo/foo".to_string(), Some("^1.0".to_string())); - - let generator = RuleSetGenerator::new(&mut pool); - let (rules, _) = generator.generate(&requires, &[], &IndexMap::new(), &IndexMap::new()); - - let policy = DefaultPolicy::default(); - let solver = Solver::new(rules, &pool, policy, IndexSet::new()); - let result = solver.solve().unwrap(); - - // Should install foo/foo (id=1) and bar/bar (id=2) - assert!(result.installed.contains(&1)); - assert!(result.installed.contains(&2)); - } - - #[tokio::test] - #[ignore] - async fn test_resolve_monolog_e2e() { - use crate::cache::Cache; - let request = ResolveRequest { - root_name: String::new(), - root_version: None, - require: vec![("monolog/monolog".to_string(), "^3.0".to_string())], - require_dev: vec![], - include_dev: false, - minimum_stability: Stability::Stable, - stability_flags: IndexMap::new(), - prefer_stable: true, - prefer_lowest: false, - platform: PlatformConfig::new(), - ignore_platform_reqs: false, - ignore_platform_req_list: vec![], - repositories: Arc::new(RepositorySet::with_packagist(Cache::new( - std::env::temp_dir().join("mozart-test-cache"), - false, - ))), - temporary_constraints: IndexMap::new(), - raw_repositories: vec![], - root_provide: IndexMap::new(), - root_replace: IndexMap::new(), - root_conflict: IndexMap::new(), - locked_package_names: IndexSet::new(), - locked_packages: Vec::new(), - block_abandoned: false, - root_branch_alias: None, - preferred_versions: IndexMap::new(), - block_insecure: false, - }; - - let result = resolve(&request).await; - 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), - } - } -} |
