diff options
Diffstat (limited to 'crates/mozart-core/src/repository/resolver.rs')
| -rw-r--r-- | crates/mozart-core/src/repository/resolver.rs | 1998 |
1 files changed, 1998 insertions, 0 deletions
diff --git a/crates/mozart-core/src/repository/resolver.rs b/crates/mozart-core/src/repository/resolver.rs new file mode 100644 index 0000000..1b06f9b --- /dev/null +++ b/crates/mozart-core/src/repository/resolver.rs @@ -0,0 +1,1998 @@ +//! 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 super::packagist; +use super::repository::{PackageQuery, RepositorySet}; +use super::vcs_bridge; +use crate::dependency_resolver::{ + DefaultPolicy, PoolBuilder, PoolLink, PoolPackageInput, RuleSetGenerator, Solver, + make_pool_links, +}; +use crate::package::{RawRepository, Stability}; +use indexmap::{IndexMap, IndexSet}; +use mozart_semver::{Version, VersionConstraint}; +use regex::{Captures, Regex}; +use std::fmt; +use std::sync::Arc; +use std::sync::LazyLock; + +/// 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 { + crate::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 = crate::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| crate::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 = super::inline_package::collect_inline_packages(&request.raw_repositories); + let mut inline_packages_by_name: IndexMap<String, Vec<&super::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 = + super::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 = + super::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 crate::dependency_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 super::super::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), + } + } +} |
