diff options
Diffstat (limited to 'crates/mozart-registry')
27 files changed, 0 insertions, 10884 deletions
diff --git a/crates/mozart-registry/Cargo.toml b/crates/mozart-registry/Cargo.toml deleted file mode 100644 index 6239973..0000000 --- a/crates/mozart-registry/Cargo.toml +++ /dev/null @@ -1,31 +0,0 @@ -[package] -name = "mozart-registry" -version.workspace = true -edition.workspace = true - -[dependencies] -mozart-console-macros.workspace = true -mozart-core.workspace = true -mozart-metadata-minifier.workspace = true -mozart-php-serialize.workspace = true -mozart-sat-resolver.workspace = true -mozart-semver.workspace = true -mozart-vcs.workspace = true -anyhow.workspace = true -async-trait.workspace = true -filetime.workspace = true -flate2.workspace = true -indexmap.workspace = true -md5.workspace = true -regex.workspace = true -serde.workspace = true -serde_json.workspace = true -sha1.workspace = true -tar.workspace = true -tempfile.workspace = true -tokio.workspace = true -tracing.workspace = true -zip.workspace = true - -[dev-dependencies] -mozart-test-harness.workspace = true diff --git a/crates/mozart-registry/src/advisory.rs b/crates/mozart-registry/src/advisory.rs deleted file mode 100644 index 86d37af..0000000 --- a/crates/mozart-registry/src/advisory.rs +++ /dev/null @@ -1,733 +0,0 @@ -use std::collections::BTreeMap; - -use indexmap::IndexMap; -use mozart_core::advisory::{AbandonedHandling, AuditFormat}; -use mozart_core::console::Console; -use mozart_core::{console_writeln, console_writeln_error}; - -use crate::packagist::SecurityAdvisory; -use crate::repository::RepositorySet; - -/// A package being audited, with version and abandonment information. -#[derive(Debug, Clone)] -pub struct PackageInfo { - pub name: String, - pub version: String, - pub version_normalized: Option<String>, - /// Raw abandoned field from JSON: `true` = abandoned no replacement, `String` = replacement name. - pub abandoned_raw: Option<serde_json::Value>, -} - -impl PackageInfo { - /// Mirrors `CompletePackage::isAbandoned()`. - pub fn is_abandoned(&self) -> bool { - matches!( - &self.abandoned_raw, - Some(serde_json::Value::Bool(true)) | Some(serde_json::Value::String(_)) - ) - } - - /// Mirrors `CompletePackage::getReplacementPackage()`. - pub fn replacement_package(&self) -> Option<&str> { - match &self.abandoned_raw { - Some(serde_json::Value::String(s)) => Some(s.as_str()), - _ => None, - } - } -} - -/// An advisory paired with the installed version of the package it affects. -#[derive(Debug, Clone)] -pub struct MatchedAdvisory { - pub advisory: SecurityAdvisory, - pub installed_version: String, -} - -/// A matched advisory that was filtered out by the ignore list. -#[derive(Debug, Clone)] -pub struct IgnoredAdvisory { - pub advisory: SecurityAdvisory, - pub installed_version: String, - pub ignore_reason: Option<String>, -} - -/// Result of `Auditor::process_advisories`. -#[derive(Debug, Default)] -pub struct ProcessedAdvisories { - pub advisories: BTreeMap<String, Vec<MatchedAdvisory>>, - pub ignored_advisories: BTreeMap<String, Vec<IgnoredAdvisory>>, -} - -/// An abandoned package found during audit. -#[derive(Debug, Clone)] -pub struct AbandonedPackage { - pub name: String, - pub version: String, - pub replacement: Option<String>, -} - -/// Options passed to `Auditor::audit()`. -pub struct AuditOptions<'a> { - pub format: AuditFormat, - pub warning_only: bool, - pub ignore_list: &'a IndexMap<String, Option<String>>, - pub abandoned: AbandonedHandling, - pub ignored_severities: &'a IndexMap<String, Option<String>>, - pub ignore_unreachable: bool, - pub ignore_abandoned: &'a IndexMap<String, Option<String>>, -} - -/// Mirrors `Composer\Advisory\Auditor`. -pub struct Auditor; - -impl Auditor { - pub fn new() -> Self { - Self - } - - /// Main audit entry point. Mirrors `Composer\Advisory\Auditor::audit()`. - /// - /// Returns a bitmask: 0=ok, 1=vulnerable, 2=abandoned, 3=both. - pub async fn audit( - &self, - console: &Console, - repo_set: &RepositorySet, - packages: &[PackageInfo], - options: &AuditOptions<'_>, - ) -> anyhow::Result<u8> { - let format = options.format; - let (all_advisories, unreachable_repos) = repo_set - .get_matching_security_advisories( - packages, - format == AuditFormat::Summary, - options.ignore_unreachable, - ) - .await?; - - let ProcessedAdvisories { - advisories, - ignored_advisories, - } = self.process_advisories( - all_advisories, - options.ignore_list, - options.ignored_severities, - ); - - let abandoned_packages = if options.abandoned == AbandonedHandling::Ignore { - vec![] - } else { - self.filter_abandoned_packages(packages, options.ignore_abandoned) - }; - - let abandoned_count = if options.abandoned == AbandonedHandling::Fail { - abandoned_packages.len() - } else { - 0 - }; - - let affected_packages_count = advisories.len(); - let bitmask = self.calculate_bitmask(affected_packages_count > 0, abandoned_count > 0); - - if format == AuditFormat::Json { - self.render_json( - &advisories, - &ignored_advisories, - &unreachable_repos, - &abandoned_packages, - console, - ); - return Ok(bitmask); - } - - let (ignored_pkg_count, ignored_total) = self.count_ignored(&ignored_advisories); - let (active_pkg_count, active_total) = self.count_matched(&advisories); - - if active_pkg_count > 0 || ignored_pkg_count > 0 { - if ignored_pkg_count > 0 { - let plurality = if ignored_total == 1 { "y" } else { "ies" }; - let pkg_plurality = if ignored_pkg_count == 1 { "" } else { "s" }; - let punctuation = if format == AuditFormat::Summary { - "." - } else { - ":" - }; - let msg = format!( - "Found {ignored_total} ignored security vulnerability advisor{plurality} affecting {ignored_pkg_count} package{pkg_plurality}{punctuation}" - ); - console_writeln_error!(console, "<info>{msg}</info>"); - self.output_advisories_ignored(console, &ignored_advisories, format); - } - - if active_pkg_count > 0 { - let plurality = if active_total == 1 { "y" } else { "ies" }; - let pkg_plurality = if active_pkg_count == 1 { "" } else { "s" }; - let punctuation = if format == AuditFormat::Summary { - "." - } else { - ":" - }; - let msg = format!( - "Found {active_total} security vulnerability advisor{plurality} affecting {active_pkg_count} package{pkg_plurality}{punctuation}" - ); - if options.warning_only { - console_writeln_error!(console, "<warning>{msg}</warning>"); - } else { - console_writeln_error!(console, "<error>{msg}</error>"); - } - self.output_advisories(console, &advisories, format); - } - - if format == AuditFormat::Summary { - console_writeln_error!( - console, - "Run \"mozart audit\" for a full list of advisories." - ); - } - } else { - console_writeln_error!( - console, - "<info>No security vulnerability advisories found.</info>", - ); - } - - if !unreachable_repos.is_empty() { - console_writeln_error!( - console, - "<warning>The following repositories were unreachable:</warning>", - ); - for repo in &unreachable_repos { - console_writeln_error!(console, " - {repo}"); - } - } - - if !abandoned_packages.is_empty() && format != AuditFormat::Summary { - self.output_abandoned_packages(console, &abandoned_packages, format); - } - - Ok(bitmask) - } - - /// Mirrors `Composer\Advisory\Auditor::processAdvisories()`. - /// - /// Splits advisories into active and ignored based on the ignore list and ignored severities. - /// Checks by: package name, advisory ID, severity, CVE, and source remote IDs. - pub fn process_advisories( - &self, - all_advisories: BTreeMap<String, Vec<MatchedAdvisory>>, - ignore_list: &IndexMap<String, Option<String>>, - ignored_severities: &IndexMap<String, Option<String>>, - ) -> ProcessedAdvisories { - if ignore_list.is_empty() && ignored_severities.is_empty() { - return ProcessedAdvisories { - advisories: all_advisories, - ignored_advisories: BTreeMap::new(), - }; - } - - let mut advisories: BTreeMap<String, Vec<MatchedAdvisory>> = BTreeMap::new(); - let mut ignored: BTreeMap<String, Vec<IgnoredAdvisory>> = BTreeMap::new(); - - for (package, pkg_advisories) in all_advisories { - for matched in pkg_advisories { - let adv = &matched.advisory; - let mut is_active = true; - let mut ignore_reason: Option<String> = None; - - // Check by package name - if let Some(reason) = ignore_list.get(&package) { - is_active = false; - ignore_reason = reason.clone(); - } - - // Check by advisory ID - if is_active && let Some(reason) = ignore_list.get(&adv.advisory_id) { - is_active = false; - ignore_reason = reason.clone(); - } - - // Check by severity - if is_active - && let Some(ref sev) = adv.severity - && let Some(reason) = ignored_severities.get(sev.as_str()) - { - is_active = false; - ignore_reason = reason - .clone() - .or_else(|| Some(format!("{sev} severity is ignored"))); - } - - // Check by CVE - if is_active - && let Some(ref cve) = adv.cve - && let Some(reason) = ignore_list.get(cve.as_str()) - { - is_active = false; - ignore_reason = reason.clone(); - } - - // Check by source remote IDs - if is_active { - for source in &adv.sources { - if let Some(reason) = ignore_list.get(&source.remote_id) { - is_active = false; - ignore_reason = reason.clone(); - break; - } - } - } - - if is_active { - advisories.entry(package.clone()).or_default().push(matched); - } else { - ignored - .entry(package.clone()) - .or_default() - .push(IgnoredAdvisory { - advisory: matched.advisory, - installed_version: matched.installed_version, - ignore_reason, - }); - } - } - } - - ProcessedAdvisories { - advisories, - ignored_advisories: ignored, - } - } - - /// Mirrors `Composer\Advisory\Auditor::filterAbandonedPackages()`. - pub fn filter_abandoned_packages( - &self, - packages: &[PackageInfo], - ignore_abandoned: &IndexMap<String, Option<String>>, - ) -> Vec<AbandonedPackage> { - packages - .iter() - .filter(|pkg| { - if !pkg.is_abandoned() { - return false; - } - if !ignore_abandoned.is_empty() { - let name_lower = pkg.name.to_lowercase(); - // Case-insensitive exact name match (wildcard support deferred) - if ignore_abandoned - .keys() - .any(|k| k.to_lowercase() == name_lower) - { - return false; - } - } - true - }) - .map(|pkg| AbandonedPackage { - name: pkg.name.clone(), - version: pkg.version.clone(), - replacement: pkg.replacement_package().map(|s| s.to_string()), - }) - .collect() - } - - /// Mirrors `Composer\Advisory\Auditor::needsCompleteAdvisoryLoad()`. - /// - /// Mozart always fetches full advisories (no partial optimization), so this is always false. - pub fn needs_complete_advisory_load( - &self, - advisories: &BTreeMap<String, Vec<MatchedAdvisory>>, - _ignore_list: &IndexMap<String, Option<String>>, - ) -> bool { - let _ = advisories; - false - } - - fn calculate_bitmask(&self, has_vulnerable: bool, has_abandoned: bool) -> u8 { - let mut bitmask = 0u8; - if has_vulnerable { - bitmask |= 1; - } - if has_abandoned { - bitmask |= 2; - } - bitmask - } - - fn count_ignored(&self, advisories: &BTreeMap<String, Vec<IgnoredAdvisory>>) -> (usize, usize) { - let pkg_count = advisories.len(); - let total = advisories.values().map(|v| v.len()).sum(); - (pkg_count, total) - } - - fn count_matched(&self, advisories: &BTreeMap<String, Vec<MatchedAdvisory>>) -> (usize, usize) { - let pkg_count = advisories.len(); - let total = advisories.values().map(|v| v.len()).sum(); - (pkg_count, total) - } - - fn output_advisories( - &self, - console: &Console, - advisories: &BTreeMap<String, Vec<MatchedAdvisory>>, - format: AuditFormat, - ) { - match format { - AuditFormat::Table => self.output_advisories_table(console, advisories), - AuditFormat::Plain => self.output_advisories_plain(console, advisories), - AuditFormat::Summary => {} - AuditFormat::Json => unreachable!(), - } - } - - fn output_advisories_ignored( - &self, - console: &Console, - advisories: &BTreeMap<String, Vec<IgnoredAdvisory>>, - format: AuditFormat, - ) { - match format { - AuditFormat::Table => self.output_ignored_advisories_table(console, advisories), - AuditFormat::Plain => self.output_ignored_advisories_plain(console, advisories), - AuditFormat::Summary => {} - AuditFormat::Json => unreachable!(), - } - } - - fn output_advisories_table( - &self, - console: &Console, - advisories: &BTreeMap<String, Vec<MatchedAdvisory>>, - ) { - for pkg_advisories in advisories.values() { - for matched in pkg_advisories { - self.render_advisory_table( - console, - &matched.advisory, - &matched.installed_version, - None, - ); - } - } - } - - fn output_ignored_advisories_table( - &self, - console: &Console, - advisories: &BTreeMap<String, Vec<IgnoredAdvisory>>, - ) { - for pkg_advisories in advisories.values() { - for ignored in pkg_advisories { - self.render_advisory_table( - console, - &ignored.advisory, - &ignored.installed_version, - ignored.ignore_reason.as_deref(), - ); - } - } - } - - fn render_advisory_table( - &self, - console: &Console, - adv: &SecurityAdvisory, - installed_version: &str, - ignore_reason: Option<&str>, - ) { - let label_width = 17usize; - let mut rows: Vec<(&str, String)> = vec![ - ("Package", adv.package_name.clone()), - ("Version", installed_version.to_string()), - ("Severity", adv.severity.clone().unwrap_or_default()), - ("Advisory ID", adv.advisory_id.clone()), - ( - "CVE", - adv.cve.clone().unwrap_or_else(|| "NO CVE".to_string()), - ), - ("Title", adv.title.clone()), - ("URL", adv.link.clone().unwrap_or_default()), - ("Affected versions", adv.affected_versions.clone()), - ("Reported at", adv.reported_at.clone()), - ]; - if let Some(reason) = ignore_reason { - rows.push(("Ignore reason", reason.to_string())); - } - - let value_width = rows.iter().map(|(_, v)| v.len()).max().unwrap_or(0).max(20); - let separator = format!( - "+-{:-<lw$}-+-{:-<vw$}-+", - "", - "", - lw = label_width, - vw = value_width - ); - - console_writeln_error!(console, "{}", separator); - for (label, value) in &rows { - console_writeln_error!( - console, - "| {:<lw$} | {:<vw$} |", - label, - value, - lw = label_width, - vw = value_width, - ); - } - console_writeln_error!(console, "{}", &separator); - console_writeln_error!(console, ""); - } - - fn output_advisories_plain( - &self, - console: &Console, - advisories: &BTreeMap<String, Vec<MatchedAdvisory>>, - ) { - let mut first = true; - for pkg_advisories in advisories.values() { - for matched in pkg_advisories { - if !first { - console_writeln_error!(console, "--------"); - } - self.render_advisory_plain( - console, - &matched.advisory, - &matched.installed_version, - None, - ); - first = false; - } - } - } - - fn output_ignored_advisories_plain( - &self, - console: &Console, - advisories: &BTreeMap<String, Vec<IgnoredAdvisory>>, - ) { - let mut first = true; - for pkg_advisories in advisories.values() { - for ignored in pkg_advisories { - if !first { - console_writeln_error!(console, "--------"); - } - self.render_advisory_plain( - console, - &ignored.advisory, - &ignored.installed_version, - ignored.ignore_reason.as_deref(), - ); - first = false; - } - } - } - - fn render_advisory_plain( - &self, - console: &Console, - adv: &SecurityAdvisory, - installed_version: &str, - ignore_reason: Option<&str>, - ) { - console_writeln_error!(console, "Package: {}", adv.package_name); - console_writeln_error!(console, "Version: {installed_version}"); - console_writeln_error!( - console, - "Severity: {}", - adv.severity.as_deref().unwrap_or(""), - ); - console_writeln_error!(console, "Advisory ID: {}", adv.advisory_id); - console_writeln_error!(console, "CVE: {}", adv.cve.as_deref().unwrap_or("NO CVE")); - console_writeln_error!(console, "Title: {}", adv.title); - console_writeln_error!(console, "URL: {}", adv.link.as_deref().unwrap_or("")); - console_writeln_error!(console, "Affected versions: {}", adv.affected_versions); - console_writeln_error!(console, "Reported at: {}", adv.reported_at); - if let Some(reason) = ignore_reason { - console_writeln_error!(console, "Ignore reason: {reason}"); - } - } - - fn output_abandoned_packages( - &self, - console: &Console, - packages: &[AbandonedPackage], - format: AuditFormat, - ) { - let count = packages.len(); - let plurality = if count == 1 { "" } else { "s" }; - console_writeln_error!( - console, - "<error>Found {count} abandoned package{plurality}:</error>", - ); - - if format == AuditFormat::Plain { - for pkg in packages { - match &pkg.replacement { - Some(repl) => console_writeln_error!( - console, - "{} ({}) is abandoned. Use {} instead.", - pkg.name, - pkg.version, - repl, - ), - None => console_writeln_error!( - console, - "{} ({}) is abandoned. No replacement was suggested.", - pkg.name, - pkg.version, - ), - } - } - return; - } - - // Table format - let name_width = 20usize; - let ver_width = packages - .iter() - .map(|a| a.version.len()) - .max() - .unwrap_or(0) - .max("Version".len()); - let repl_width = packages - .iter() - .map(|a| { - a.replacement - .as_deref() - .unwrap_or("No replacement suggested") - .len() - }) - .max() - .unwrap_or(0) - .max("Suggested Replacement".len()); - - console_writeln_error!( - console, - "| {:<nw$} | {:<vw$} | {:<rw$} |", - "Abandoned Package", - "Version", - "Suggested Replacement", - nw = name_width, - vw = ver_width, - rw = repl_width, - ); - console_writeln_error!( - console, - "+-{:-<nw$}-+-{:-<vw$}-+-{:-<rw$}-+", - "", - "", - "", - nw = name_width, - vw = ver_width, - rw = repl_width, - ); - for pkg in packages { - let replacement = pkg - .replacement - .as_deref() - .unwrap_or("No replacement suggested"); - console_writeln_error!( - console, - "| {:<nw$} | {:<vw$} | {:<rw$} |", - pkg.name, - pkg.version, - replacement, - nw = name_width, - vw = ver_width, - rw = repl_width, - ); - } - console_writeln_error!(console, ""); - } - - fn render_json( - &self, - advisories: &BTreeMap<String, Vec<MatchedAdvisory>>, - ignored_advisories: &BTreeMap<String, Vec<IgnoredAdvisory>>, - unreachable_repos: &[String], - abandoned_packages: &[AbandonedPackage], - console: &Console, - ) { - let mut advisories_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new(); - for (pkg_name, matched_list) in advisories { - let arr: Vec<serde_json::Value> = matched_list - .iter() - .map(|m| serde_json::to_value(&m.advisory).unwrap_or(serde_json::Value::Null)) - .collect(); - advisories_map.insert(pkg_name.clone(), serde_json::Value::Array(arr)); - } - - let mut output = serde_json::json!({ "advisories": advisories_map }); - - // ignored-advisories (only if non-empty) - if !ignored_advisories.is_empty() { - let mut ignored_map: serde_json::Map<String, serde_json::Value> = - serde_json::Map::new(); - for (pkg_name, ignored_list) in ignored_advisories { - let arr: Vec<serde_json::Value> = ignored_list - .iter() - .map(|i| { - let mut val = - serde_json::to_value(&i.advisory).unwrap_or(serde_json::Value::Null); - if let serde_json::Value::Object(ref mut obj) = val { - obj.insert( - "ignoreReason".to_string(), - i.ignore_reason - .as_ref() - .map(|r| serde_json::Value::String(r.clone())) - .unwrap_or(serde_json::Value::Null), - ); - } - val - }) - .collect(); - ignored_map.insert(pkg_name.clone(), serde_json::Value::Array(arr)); - } - if let serde_json::Value::Object(ref mut obj) = output { - obj.insert( - "ignored-advisories".to_string(), - serde_json::Value::Object(ignored_map), - ); - } - } - - // unreachable-repositories (only if non-empty) - if !unreachable_repos.is_empty() { - let repos_arr: Vec<serde_json::Value> = unreachable_repos - .iter() - .map(|r| serde_json::Value::String(r.clone())) - .collect(); - if let serde_json::Value::Object(ref mut obj) = output { - obj.insert( - "unreachable-repositories".to_string(), - serde_json::Value::Array(repos_arr), - ); - } - } - - // abandoned map: package_name => replacement (null if none) - let mut abandoned_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new(); - for pkg in abandoned_packages { - abandoned_map.insert( - pkg.name.clone(), - pkg.replacement - .as_ref() - .map(|r| serde_json::Value::String(r.clone())) - .unwrap_or(serde_json::Value::Null), - ); - } - if let serde_json::Value::Object(ref mut obj) = output { - obj.insert( - "abandoned".to_string(), - serde_json::Value::Object(abandoned_map), - ); - } - - let json_str = serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string()); - console_writeln!(console, "{}", &json_str); - } -} - -impl Default for Auditor { - fn default() -> Self { - Self::new() - } -} diff --git a/crates/mozart-registry/src/browse_repos.rs b/crates/mozart-registry/src/browse_repos.rs deleted file mode 100644 index 0f9b169..0000000 --- a/crates/mozart-registry/src/browse_repos.rs +++ /dev/null @@ -1,293 +0,0 @@ -//! Composite of repositories consulted by the `browse` command. -//! -//! Mirrors `Composer\Command\HomeCommand::initializeRepos()`: -//! root package + local installed repository + remote(s). Each repo -//! exposes a uniform [`BrowseRepo::find_packages`] that yields -//! [`CompletePackageView`]s — the trio of fields -//! `Composer\Command\HomeCommand::handlePackage` reads off -//! `CompletePackageInterface` (`getSupport()['source']`, -//! `getSourceUrl()`, `getHomepage()`). - -use crate::cache::Cache; -use crate::installed::{InstalledPackageEntry, InstalledPackages}; -use crate::lockfile::LockedPackage; -use crate::packagist::{self, PackagistVersion}; -use mozart_core::package::RawPackageData; - -/// Subset of `Composer\Package\CompletePackageInterface` consumed by -/// `HomeCommand::handlePackage`. Every backing repo flattens its -/// package shape into this so URL selection lives in one place. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct CompletePackageView { - /// `$package->getSupport()['source']`. - pub support_source: Option<String>, - /// `$package->getSourceUrl()`. - pub source_url: Option<String>, - /// `$package->getHomepage()`. - pub homepage: Option<String>, -} - -impl From<&LockedPackage> for CompletePackageView { - fn from(pkg: &LockedPackage) -> Self { - Self { - support_source: pkg - .support - .as_ref() - .and_then(|s| s.get("source")) - .and_then(|s| s.as_str()) - .map(str::to_string), - source_url: pkg.source.as_ref().map(|s| s.url.clone()), - homepage: pkg.homepage.clone(), - } - } -} - -impl From<&InstalledPackageEntry> for CompletePackageView { - fn from(pkg: &InstalledPackageEntry) -> Self { - Self { - support_source: pkg - .support - .as_ref() - .and_then(|s| s.get("source")) - .and_then(|s| s.as_str()) - .map(str::to_string), - source_url: pkg - .source - .as_ref() - .and_then(|s| s.get("url")) - .and_then(|s| s.as_str()) - .map(str::to_string), - homepage: pkg.homepage.clone(), - } - } -} - -impl From<&PackagistVersion> for CompletePackageView { - fn from(pkg: &PackagistVersion) -> Self { - Self { - support_source: pkg - .support - .as_ref() - .and_then(|s| s.get("source")) - .and_then(|s| s.as_str()) - .map(str::to_string), - source_url: pkg.source.as_ref().map(|s| s.url.clone()), - homepage: pkg.homepage.clone(), - } - } -} - -/// `RawPackageData` lacks a typed `support` field — the root package's -/// `support` block lives inside `extra_fields` because the schema is not -/// yet ported. Read it manually here. -pub fn view_from_raw(pkg: &RawPackageData) -> CompletePackageView { - CompletePackageView { - support_source: pkg - .extra_fields - .get("support") - .and_then(|s| s.get("source")) - .and_then(|s| s.as_str()) - .map(str::to_string), - source_url: None, - homepage: pkg.homepage.clone(), - } -} - -/// One repository in the composite. Mirrors the three repo kinds -/// `HomeCommand::initializeRepos()` returns: -/// `RootPackageRepository` + local installed + remotes. -pub enum BrowseRepo { - /// Stand-in for `Composer\Repository\RootPackageRepository` — - /// a one-package array containing the root composer.json. - /// Boxed because `RawPackageData` is much larger than the other - /// variants (clippy::large_enum_variant). - Root(Box<RawPackageData>), - /// Stand-in for `RepositoryManager::getLocalRepository()` — - /// the installed.json view of `vendor/`. - Installed(InstalledPackages), - /// Stand-in for the configured remote. For now Mozart only knows - /// the default Packagist remote (`RepositoryFactory::defaultRepos`). - Packagist { cache: Cache }, -} - -impl BrowseRepo { - /// Mirrors `RepositoryInterface::findPackages($name)` — case-insensitive - /// match by package name, returning every match the repo holds. - pub async fn find_packages(&self, name: &str) -> anyhow::Result<Vec<CompletePackageView>> { - match self { - BrowseRepo::Root(pkg) => { - if pkg.name.eq_ignore_ascii_case(name) { - Ok(vec![view_from_raw(pkg)]) - } else { - Ok(Vec::new()) - } - } - BrowseRepo::Installed(installed) => Ok(installed - .packages - .iter() - .filter(|p| p.name.eq_ignore_ascii_case(name)) - .map(CompletePackageView::from) - .collect()), - BrowseRepo::Packagist { cache } => { - let versions = packagist::fetch_package_versions(name, cache).await?; - Ok(versions.iter().map(CompletePackageView::from).collect()) - } - } - } -} - -/// Ordered composite consulted by `HomeCommand::execute()`'s outer -/// `foreach ($repos as $repo)` loop. -pub struct BrowseRepos { - repos: Vec<BrowseRepo>, -} - -impl BrowseRepos { - /// Build the composite. `root` and `installed` are passed in - /// rather than read here so callers can decide whether to load - /// them from `Composer` (when composer.json is present) or skip - /// them entirely (the `defaultReposWithDefaultManager` fallback). - pub fn new( - root: Option<RawPackageData>, - installed: Option<InstalledPackages>, - packagist_cache: Cache, - ) -> Self { - let mut repos: Vec<BrowseRepo> = Vec::with_capacity(3); - if let Some(root) = root { - repos.push(BrowseRepo::Root(Box::new(root))); - } - if let Some(installed) = installed { - repos.push(BrowseRepo::Installed(installed)); - } - repos.push(BrowseRepo::Packagist { - cache: packagist_cache, - }); - Self { repos } - } - - pub fn iter(&self) -> std::slice::Iter<'_, BrowseRepo> { - self.repos.iter() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::BTreeMap; - - fn locked( - name: &str, - source_url: Option<&str>, - homepage: Option<&str>, - support_source: Option<&str>, - ) -> LockedPackage { - LockedPackage { - name: name.to_string(), - version: "1.0.0".to_string(), - version_normalized: None, - source: source_url.map(|url| crate::lockfile::LockedSource { - source_type: "git".to_string(), - url: url.to_string(), - reference: None, - }), - dist: None, - require: BTreeMap::new(), - require_dev: BTreeMap::new(), - conflict: BTreeMap::new(), - provide: BTreeMap::new(), - replace: BTreeMap::new(), - suggest: None, - package_type: None, - autoload: None, - autoload_dev: None, - license: None, - description: None, - homepage: homepage.map(str::to_string), - keywords: None, - authors: None, - support: support_source.map(|s| serde_json::json!({"source": s})), - funding: None, - time: None, - extra_fields: BTreeMap::new(), - } - } - - #[test] - fn view_from_locked_package_carries_three_urls() { - let pkg = locked( - "vendor/pkg", - Some("https://github.com/vendor/pkg.git"), - Some("https://vendor.example.com"), - Some("https://github.com/vendor/pkg"), - ); - let view = CompletePackageView::from(&pkg); - assert_eq!( - view.support_source.as_deref(), - Some("https://github.com/vendor/pkg") - ); - assert_eq!( - view.source_url.as_deref(), - Some("https://github.com/vendor/pkg.git") - ); - assert_eq!(view.homepage.as_deref(), Some("https://vendor.example.com")); - } - - #[test] - fn view_from_installed_entry_extracts_source_url() { - let mut entry = InstalledPackageEntry { - name: "vendor/pkg".to_string(), - version: "1.0.0".to_string(), - version_normalized: None, - source: Some(serde_json::json!({"url": "https://github.com/vendor/pkg.git"})), - dist: None, - package_type: None, - install_path: None, - autoload: None, - aliases: vec![], - homepage: Some("https://vendor.example.com".to_string()), - support: Some(serde_json::json!({"source": "https://github.com/vendor/pkg"})), - extra_fields: BTreeMap::new(), - }; - let view = CompletePackageView::from(&entry); - assert_eq!( - view.source_url.as_deref(), - Some("https://github.com/vendor/pkg.git") - ); - assert_eq!( - view.support_source.as_deref(), - Some("https://github.com/vendor/pkg") - ); - assert_eq!(view.homepage.as_deref(), Some("https://vendor.example.com")); - - entry.support = None; - entry.source = None; - entry.homepage = None; - let empty = CompletePackageView::from(&entry); - assert_eq!(empty, CompletePackageView::default()); - } - - #[test] - fn view_from_raw_reads_support_via_extra_fields() { - let mut raw = RawPackageData::new("vendor/root".to_string()); - raw.homepage = Some("https://vendor.example.com".to_string()); - raw.extra_fields.insert( - "support".to_string(), - serde_json::json!({"source": "https://github.com/vendor/root"}), - ); - let view = view_from_raw(&raw); - assert_eq!( - view.support_source.as_deref(), - Some("https://github.com/vendor/root") - ); - assert!(view.source_url.is_none()); - assert_eq!(view.homepage.as_deref(), Some("https://vendor.example.com")); - } - - #[tokio::test] - async fn root_repo_matches_case_insensitively() { - let raw = RawPackageData::new("Vendor/Root".to_string()); - let repo = BrowseRepo::Root(Box::new(raw)); - assert_eq!(repo.find_packages("vendor/root").await.unwrap().len(), 1); - assert_eq!(repo.find_packages("other/pkg").await.unwrap().len(), 0); - } -} diff --git a/crates/mozart-registry/src/cache.rs b/crates/mozart-registry/src/cache.rs deleted file mode 100644 index 39e3e8d..0000000 --- a/crates/mozart-registry/src/cache.rs +++ /dev/null @@ -1,575 +0,0 @@ -//! Filesystem-backed cache system with TTL expiration and size-limited GC. -//! -//! Cache directory structure: -//! ```text -//! ~/.cache/mozart/ (or $COMPOSER_CACHE_DIR) -//! files/ dist archives (key: vendor~package~reference.ext) -//! repo/ API responses (key: provider-vendor~package.json) -//! vcs/ VCS mirrors (one subdir per sanitized URL) -//! ``` - -use std::fs; -use std::path::{Path, PathBuf}; -use std::time::{SystemTime, UNIX_EPOCH}; - -/// Configuration for the Mozart cache system. -pub struct CacheConfig { - /// Root cache directory (e.g. `~/.cache/mozart`). - pub cache_dir: PathBuf, - /// Directory for dist archives. - pub cache_files_dir: PathBuf, - /// Directory for API responses. - pub cache_repo_dir: PathBuf, - /// Directory for VCS mirrors (one subdirectory per sanitized URL). - pub cache_vcs_dir: PathBuf, - /// TTL in seconds for repo entries (default: 15,552,000 = 6 months). - pub cache_ttl: u64, - /// TTL in seconds for files entries (falls back to `cache_ttl`). - pub cache_files_ttl: u64, - /// Maximum size of the files cache in bytes (default: 300 MiB). - pub cache_files_maxsize: u64, - /// Whether the cache is read-only (no writes). - pub read_only: bool, -} - -impl CacheConfig { - /// Default TTL: 6 months in seconds. - pub const DEFAULT_TTL: u64 = 15_552_000; - /// Default max files cache size: 300 MiB. - pub const DEFAULT_FILES_MAXSIZE: u64 = 300 * 1024 * 1024; -} - -/// Build a `CacheConfig` from CLI flags and environment variables. -/// -/// Respects `$COMPOSER_CACHE_DIR` for the base directory, and -/// `$COMPOSER_NO_CACHE` / `COMPOSER_CACHE_READ_ONLY` env vars. -/// -/// When no-cache mode is active (via `cli_no_cache` or `$COMPOSER_NO_CACHE`), -/// all cache directories are set to a null device, mirroring Composer's -/// `Application::doRun()` which calls `putenv('COMPOSER_CACHE_DIR', '/dev/null')`. -pub fn build_cache_config(cli_no_cache: bool) -> CacheConfig { - let no_cache = std::env::var("COMPOSER_NO_CACHE").is_ok() || cli_no_cache; - - let read_only = std::env::var("COMPOSER_CACHE_READ_ONLY") - .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) - .unwrap_or(false); - - let cache_dir = if no_cache { - // Mirrors Composer: --no-cache redirects all cache paths to a null device so - // that Cache::is_usable() returns false and caching is transparently disabled. - #[cfg(windows)] - { - PathBuf::from("nul") - } - #[cfg(not(windows))] - { - PathBuf::from("/dev/null") - } - } else if let Ok(dir) = std::env::var("COMPOSER_CACHE_DIR") { - PathBuf::from(dir) - } else { - dirs_cache_dir().join("mozart") - }; - - let cache_files_dir = cache_dir.join("files"); - let cache_repo_dir = cache_dir.join("repo"); - let cache_vcs_dir = std::env::var("COMPOSER_CACHE_VCS_DIR") - .map(PathBuf::from) - .unwrap_or_else(|_| cache_dir.join("vcs")); - - CacheConfig { - cache_files_dir, - cache_repo_dir, - cache_vcs_dir, - cache_ttl: CacheConfig::DEFAULT_TTL, - cache_files_ttl: CacheConfig::DEFAULT_TTL, - cache_files_maxsize: CacheConfig::DEFAULT_FILES_MAXSIZE, - cache_dir, - read_only, - } -} - -/// Return the platform cache directory (XDG_CACHE_HOME or ~/.cache). -fn dirs_cache_dir() -> PathBuf { - if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") { - return PathBuf::from(xdg); - } - if let Ok(home) = std::env::var("HOME") { - return PathBuf::from(home).join(".cache"); - } - PathBuf::from("/tmp") -} - -/// A single cache bucket (a directory on disk). -#[derive(Clone)] -pub struct Cache { - root: PathBuf, - enabled: bool, - readonly: bool, -} - -impl Cache { - /// Create a new cache rooted at `root`. - /// - /// Mirrors Composer's `Cache::__construct` + `Cache::isEnabled()`: - /// - If the path is a null device (`/dev/null`, `nul`, etc.), the cache is disabled. - /// - If `readonly` is true, the cache is always enabled (no writability check). - /// - Otherwise, tries to create the directory and checks that it is writable; - /// disables the cache with a warning if not. - pub fn new(root: PathBuf, readonly: bool) -> Self { - let enabled = if !Self::is_usable(&root) { - false - } else if readonly { - true - } else { - if fs::create_dir_all(&root).is_err() { - false - } else { - fs::metadata(&root) - .map(|m| !m.permissions().readonly()) - .unwrap_or(false) - } - }; - Self { - root, - enabled, - readonly, - } - } - - /// Returns `false` for null-device paths that should never be used as a real cache. - /// - /// Mirrors Composer's `Cache::isUsable()`. - fn is_usable(path: &Path) -> bool { - let s = path.to_string_lossy(); - if cfg!(windows) { - // On Windows, "nul" and "$null" (any case) are null devices. - !s.split(['/', '\\']) - .any(|c| c.eq_ignore_ascii_case("nul") || c == "$null") - } else { - // On Unix, /dev/null and any path under it are unusable. - s != "/dev/null" && !s.starts_with("/dev/null/") - } - } - - /// Shorthand: create the repo cache from a `CacheConfig`. - pub fn repo(config: &CacheConfig) -> Self { - Self::new(config.cache_repo_dir.clone(), config.read_only) - } - - /// Shorthand: create the files cache from a `CacheConfig`. - pub fn files(config: &CacheConfig) -> Self { - Self::new(config.cache_files_dir.clone(), config.read_only) - } - - /// Whether caching is enabled for this bucket. - pub fn is_enabled(&self) -> bool { - self.enabled - } - - /// Sanitize a cache key for use as a filename. - /// - /// Replaces `/` with `~` and strips characters that are unsafe in - /// filenames (anything except alphanumerics, `-`, `_`, `.`, `~`). - pub fn sanitize_key(key: &str) -> String { - key.replace('/', "~") - .chars() - .filter(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '~')) - .collect() - } - - /// Return the full path for a cache entry. - fn path_for(&self, key: &str) -> PathBuf { - self.root.join(Self::sanitize_key(key)) - } - - /// Read a cached string entry, or `None` if absent or cache disabled. - pub fn read(&self, key: &str) -> Option<String> { - if !self.enabled { - return None; - } - fs::read_to_string(self.path_for(key)).ok() - } - - /// Write a string entry atomically (write to temp file, then rename). - pub fn write(&self, key: &str, contents: &str) -> anyhow::Result<()> { - if !self.enabled || self.readonly { - return Ok(()); - } - self.write_bytes(key, contents.as_bytes()) - } - - /// Read a cached binary entry, or `None` if absent or cache disabled. - pub fn read_bytes(&self, key: &str) -> Option<Vec<u8>> { - if !self.enabled { - return None; - } - fs::read(self.path_for(key)).ok() - } - - /// Write a binary entry atomically (write to temp file, then rename). - pub fn write_bytes(&self, key: &str, data: &[u8]) -> anyhow::Result<()> { - if !self.enabled || self.readonly { - return Ok(()); - } - let dest = self.path_for(key); - // Ensure parent directory exists - if let Some(parent) = dest.parent() { - fs::create_dir_all(parent)?; - } - // Write to a temp file next to the destination - let tmp = dest.with_extension("tmp"); - fs::write(&tmp, data)?; - fs::rename(&tmp, &dest)?; - Ok(()) - } - - /// Delete all cached entries in this bucket. - pub fn clear(&self) -> anyhow::Result<()> { - if !self.enabled || self.readonly { - return Ok(()); - } - if !self.root.exists() { - return Ok(()); - } - for entry in fs::read_dir(&self.root)? { - let entry = entry?; - let path = entry.path(); - if path.is_file() { - fs::remove_file(&path)?; - } else if path.is_dir() { - fs::remove_dir_all(&path)?; - } - } - Ok(()) - } - - /// Run garbage collection on this cache bucket. - /// - /// 1. Deletes files with mtime older than `ttl_seconds`. - /// 2. If total remaining size > `max_size_bytes`, deletes the oldest files - /// (by mtime) until the total is under the limit. - pub fn gc(&self, ttl_seconds: u64, max_size_bytes: u64) -> anyhow::Result<()> { - if !self.enabled || self.readonly || !self.root.exists() { - return Ok(()); - } - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - // Collect (path, mtime, size) for all files - let mut files: Vec<(PathBuf, u64, u64)> = Vec::new(); - collect_files(&self.root, &mut files)?; - - // Phase 1: delete TTL-expired files - let mut remaining: Vec<(PathBuf, u64, u64)> = Vec::new(); - for (path, mtime, size) in files { - let age = now.saturating_sub(mtime); - if age > ttl_seconds { - let _ = fs::remove_file(&path); - } else { - remaining.push((path, mtime, size)); - } - } - - // Phase 2: enforce size limit by deleting oldest first - let total_size: u64 = remaining.iter().map(|(_, _, sz)| sz).sum(); - if total_size > max_size_bytes { - // Sort by mtime ascending (oldest first) - remaining.sort_by_key(|(_, mtime, _)| *mtime); - let mut current_size = total_size; - for (path, _, size) in &remaining { - if current_size <= max_size_bytes { - break; - } - if fs::remove_file(path).is_ok() { - current_size = current_size.saturating_sub(*size); - } - } - } - - Ok(()) - } - - /// Run garbage collection on a VCS cache bucket. - /// - /// Each top-level subdirectory is one bare mirror keyed by sanitized URL. - /// Deletes entire subdirectories whose mtime is older than `ttl_seconds`. - /// Mirrors Composer's `Cache::gcVcsCache`. - pub fn gc_vcs_cache(&self, ttl_seconds: u64) -> anyhow::Result<()> { - if !self.enabled || !self.root.exists() { - return Ok(()); - } - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); - - for entry in fs::read_dir(&self.root)? { - let entry = entry?; - let path = entry.path(); - let metadata = entry.metadata()?; - if !metadata.is_dir() { - continue; - } - let mtime = metadata - .modified() - .ok() - .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) - .map(|d| d.as_secs()) - .unwrap_or(0); - if now.saturating_sub(mtime) > ttl_seconds { - let _ = fs::remove_dir_all(&path); - } - } - - Ok(()) - } - - /// Return the age in seconds of a cached entry based on its mtime, - /// or `None` if the entry doesn't exist or mtime can't be read. - pub fn age(&self, key: &str) -> Option<u64> { - if !self.enabled { - return None; - } - let path = self.path_for(key); - let metadata = fs::metadata(&path).ok()?; - let mtime = metadata.modified().ok()?; - let now = SystemTime::now(); - now.duration_since(mtime).ok().map(|d| d.as_secs()) - } -} - -/// Recursively collect all files under `dir` as `(path, mtime_secs, size_bytes)`. -fn collect_files(dir: &Path, out: &mut Vec<(PathBuf, u64, u64)>) -> anyhow::Result<()> { - if !dir.exists() { - return Ok(()); - } - for entry in fs::read_dir(dir)? { - let entry = entry?; - let path = entry.path(); - let metadata = entry.metadata()?; - if metadata.is_dir() { - collect_files(&path, out)?; - } else if metadata.is_file() { - let mtime = metadata - .modified() - .ok() - .and_then(|t| t.duration_since(UNIX_EPOCH).ok()) - .map(|d| d.as_secs()) - .unwrap_or(0); - let size = metadata.len(); - out.push((path, mtime, size)); - } - } - Ok(()) -} - -/// Return `true` with a probability of 1 in 50 (based on system time nanos). -/// -/// Used to decide whether to run GC after an install/update operation. -pub fn gc_is_necessary() -> bool { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .subsec_nanos(); - nanos.is_multiple_of(50) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::time::Duration; - use tempfile::tempdir; - - #[test] - fn test_sanitize_key_replaces_slash() { - assert_eq!(Cache::sanitize_key("vendor/package"), "vendor~package"); - } - - #[test] - fn test_sanitize_key_strips_unsafe_chars() { - // Colons and spaces should be stripped - assert_eq!(Cache::sanitize_key("foo:bar baz"), "foobarbaz"); - } - - #[test] - fn test_sanitize_key_preserves_safe_chars() { - let key = "provider-vendor~package.json"; - assert_eq!(Cache::sanitize_key(key), key); - } - - #[test] - fn test_sanitize_key_full_example() { - assert_eq!( - Cache::sanitize_key("provider-monolog/monolog.json"), - "provider-monolog~monolog.json" - ); - } - - #[test] - fn test_write_read_roundtrip_string() { - let dir = tempdir().unwrap(); - let cache = Cache::new(dir.path().to_path_buf(), false); - - cache.write("test-key", "hello world").unwrap(); - let result = cache.read("test-key"); - assert_eq!(result.as_deref(), Some("hello world")); - } - - #[test] - fn test_write_read_roundtrip_bytes() { - let dir = tempdir().unwrap(); - let cache = Cache::new(dir.path().to_path_buf(), false); - - let data = vec![0u8, 1, 2, 3, 255]; - cache.write_bytes("bin-key", &data).unwrap(); - let result = cache.read_bytes("bin-key"); - assert_eq!(result, Some(data)); - } - - #[test] - fn test_clear_removes_all_entries() { - let dir = tempdir().unwrap(); - let cache = Cache::new(dir.path().to_path_buf(), false); - - cache.write("key1", "value1").unwrap(); - cache.write("key2", "value2").unwrap(); - assert!(cache.read("key1").is_some()); - assert!(cache.read("key2").is_some()); - - cache.clear().unwrap(); - - assert!(cache.read("key1").is_none()); - assert!(cache.read("key2").is_none()); - } - - #[test] - fn test_disabled_cache_returns_none() { - // Point cache at /dev/null — is_usable() returns false → cache disabled. - let cache = Cache::new(PathBuf::from("/dev/null/files"), false); - - // Write should silently succeed (no-op) - cache.write("key", "value").unwrap(); - - // Read should return None even if we wrote - assert!(cache.read("key").is_none()); - assert!(cache.read_bytes("key").is_none()); - } - - #[test] - fn test_gc_ttl_expiration() { - let dir = tempdir().unwrap(); - let cache = Cache::new(dir.path().to_path_buf(), false); - - // Write a file, then manually set its mtime to the past - cache.write("old-key", "old content").unwrap(); - let old_path = dir.path().join(Cache::sanitize_key("old-key")); - - // Write a fresh file - cache.write("new-key", "new content").unwrap(); - - // Set the old file's mtime to 2 hours ago - let two_hours_ago = SystemTime::now() - Duration::from_secs(7200); - filetime::set_file_mtime( - &old_path, - filetime::FileTime::from_system_time(two_hours_ago), - ) - .unwrap(); - - // GC with TTL of 1 hour (3600 seconds) - cache.gc(3600, u64::MAX).unwrap(); - - // Old file should be deleted, new file should remain - assert!( - cache.read("old-key").is_none(), - "expired file should be deleted" - ); - assert!(cache.read("new-key").is_some(), "fresh file should remain"); - } - - #[test] - fn test_gc_size_limit() { - let dir = tempdir().unwrap(); - let cache = Cache::new(dir.path().to_path_buf(), false); - - // Write two files; the first one should be older - cache.write("old-file", "aaaaaaaaaa").unwrap(); // 10 bytes - let old_path = dir.path().join(Cache::sanitize_key("old-file")); - - // Add a small delay before writing second file via mtime manipulation - cache.write("new-file", "bbbbbbbbbb").unwrap(); // 10 bytes - - // Set old-file's mtime to 1 second ago so it's older - let one_second_ago = SystemTime::now() - Duration::from_secs(1); - filetime::set_file_mtime( - &old_path, - filetime::FileTime::from_system_time(one_second_ago), - ) - .unwrap(); - - // GC with a max size of 12 bytes (can only fit one 10-byte file) - // TTL is very long so no TTL expiration - cache.gc(u64::MAX / 2, 12).unwrap(); - - // The older file should be removed to get under the size limit - assert!( - cache.read("old-file").is_none() || cache.read("new-file").is_none(), - "at least one file should be removed to enforce size limit" - ); - } - - #[test] - fn test_gc_vcs_removes_old_subdirs() { - let dir = tempdir().unwrap(); - let cache = Cache::new(dir.path().to_path_buf(), false); - - let old_mirror = dir.path().join("old-mirror"); - let new_mirror = dir.path().join("new-mirror"); - fs::create_dir_all(&old_mirror).unwrap(); - fs::write(old_mirror.join("HEAD"), "ref: refs/heads/main\n").unwrap(); - fs::create_dir_all(&new_mirror).unwrap(); - fs::write(new_mirror.join("HEAD"), "ref: refs/heads/main\n").unwrap(); - - let two_hours_ago = SystemTime::now() - Duration::from_secs(7200); - filetime::set_file_mtime( - &old_mirror, - filetime::FileTime::from_system_time(two_hours_ago), - ) - .unwrap(); - - cache.gc_vcs_cache(3600).unwrap(); - - assert!(!old_mirror.exists(), "expired mirror should be removed"); - assert!(new_mirror.exists(), "fresh mirror should remain"); - } - - #[test] - fn test_age_existing_entry() { - let dir = tempdir().unwrap(); - let cache = Cache::new(dir.path().to_path_buf(), false); - - cache.write("fresh-key", "content").unwrap(); - let age = cache.age("fresh-key"); - - // Should be very recent (< 5 seconds) - assert!(age.is_some()); - assert!(age.unwrap() < 5); - } - - #[test] - fn test_age_missing_entry() { - let dir = tempdir().unwrap(); - let cache = Cache::new(dir.path().to_path_buf(), false); - assert!(cache.age("nonexistent-key").is_none()); - } - - #[test] - fn test_age_disabled_cache() { - let cache = Cache::new(PathBuf::from("/dev/null/files"), false); - assert!(cache.age("any-key").is_none()); - } -} diff --git a/crates/mozart-registry/src/composer_repo.rs b/crates/mozart-registry/src/composer_repo.rs deleted file mode 100644 index ef091ef..0000000 --- a/crates/mozart-registry/src/composer_repo.rs +++ /dev/null @@ -1,173 +0,0 @@ -//! Support for `type: composer` repositories. -//! -//! A Composer repository is a directory (or HTTP endpoint) hosting a -//! `packages.json` file. The legacy format embeds full package metadata -//! directly: -//! -//! ```json -//! { -//! "packages": { -//! "a/a": { -//! "dev-foobar": { "name": "a/a", "version": "dev-foobar", ... } -//! } -//! } -//! } -//! ``` -//! -//! Mirrors `Composer\Repository\ComposerRepository` for the file:// case -//! used by the test fixtures. Lazy / v2 / provider-includes / metadata-url -//! variants are out of scope here — the in-process installer fixtures only -//! exercise the legacy embedded-packages form. - -use crate::packagist::PackagistVersion; -use crate::repository_filter::RepositoryFilter; -use indexmap::IndexSet; -use mozart_core::package::RawRepository; -use std::path::PathBuf; - -/// One package version drawn from a `type: composer` repository. -pub struct ComposerRepoPackage { - pub name: String, - pub version: PackagistVersion, -} - -/// Read every package version from `type: composer` repositories declared in -/// `composer.json`. Only `file://` URLs are supported here — they're what -/// the installer fixtures use after the harness rewrites -/// `file://foobar` → `file:///abs/path/to/fixtures/foobar`. -pub fn collect_composer_packages(repositories: &[RawRepository]) -> Vec<ComposerRepoPackage> { - let mut out = Vec::new(); - let mut claimed: IndexSet<String> = IndexSet::new(); - for repo in repositories { - if repo.repo_type != "composer" { - continue; - } - let Some(url) = repo.url.as_deref() else { - continue; - }; - let Some(dir) = file_url_to_path(url) else { - continue; - }; - let packages_json = dir.join("packages.json"); - let Ok(content) = std::fs::read_to_string(&packages_json) else { - continue; - }; - let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&content) else { - continue; - }; - let Some(packages) = parsed.get("packages").and_then(|v| v.as_object()) else { - continue; - }; - let filter = RepositoryFilter::from_repo(repo); - let mut names_this_repo: IndexSet<String> = IndexSet::new(); - for (name, versions) in packages { - if !filter.is_allowed(name) { - continue; - } - if claimed.contains(name) { - continue; - } - let Some(versions_obj) = versions.as_object() else { - continue; - }; - let mut emitted = false; - for (_, version_value) in versions_obj { - if let Ok(pv) = serde_json::from_value::<PackagistVersion>(version_value.clone()) { - out.push(ComposerRepoPackage { - name: name.clone(), - version: pv, - }); - emitted = true; - } - } - if emitted { - names_this_repo.insert(name.clone()); - } - } - if filter.canonical { - claimed.extend(names_this_repo); - } - } - out -} - -/// Turn a `file://` URL into a filesystem path. Accepts both -/// `file:///abs/path` (RFC 8089 form) and `file://abs/path` (Composer's -/// loose form). Returns `None` for non-`file://` URLs. -fn file_url_to_path(url: &str) -> Option<PathBuf> { - let rest = url.strip_prefix("file://")?; - // RFC 8089: file:///abs/path → empty authority, rest starts with `/`. - // Composer's harness writes `file:///abs/...` after rewriting, so the - // typical input here is one leading `/`. - Some(PathBuf::from(rest)) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::fs; - use tempfile::TempDir; - - fn write_packages_json(dir: &std::path::Path, body: &str) { - fs::write(dir.join("packages.json"), body).unwrap(); - } - - fn composer_repo(url: String) -> RawRepository { - RawRepository { - repo_type: "composer".to_string(), - url: Some(url), - package: None, - only: None, - exclude: None, - canonical: None, - security_advisories: None, - } - } - - #[test] - fn reads_legacy_packages_json() { - let tmp = TempDir::new().unwrap(); - write_packages_json( - tmp.path(), - r#"{ - "packages": { - "a/a": { - "dev-foobar": { - "name": "a/a", - "version": "dev-foobar", - "version_normalized": "dev-foobar" - } - } - } - }"#, - ); - let url = format!("file://{}", tmp.path().display()); - let repos = vec![composer_repo(url)]; - let pkgs = collect_composer_packages(&repos); - assert_eq!(pkgs.len(), 1); - assert_eq!(pkgs[0].name, "a/a"); - assert_eq!(pkgs[0].version.version, "dev-foobar"); - } - - #[test] - fn ignores_non_composer_types() { - let repos = vec![RawRepository { - repo_type: "vcs".to_string(), - url: Some("https://example.com/foo.git".to_string()), - package: None, - only: None, - exclude: None, - canonical: None, - security_advisories: None, - }]; - assert!(collect_composer_packages(&repos).is_empty()); - } - - #[test] - fn skips_missing_packages_json() { - let tmp = TempDir::new().unwrap(); - let url = format!("file://{}", tmp.path().display()); - let repos = vec![composer_repo(url)]; - assert!(collect_composer_packages(&repos).is_empty()); - } -} diff --git a/crates/mozart-registry/src/download_manager.rs b/crates/mozart-registry/src/download_manager.rs deleted file mode 100644 index 7c6ff73..0000000 --- a/crates/mozart-registry/src/download_manager.rs +++ /dev/null @@ -1,143 +0,0 @@ -//! `DownloadManager` — pick the right [`VcsDownloader`] for a given -//! [`LocalPackage`]. Mirrors `Composer\Downloader\DownloadManager`. - -use std::path::PathBuf; - -use mozart_core::composer::{InstallationSource, LocalPackage}; -use mozart_vcs::downloader::VcsDownloader; -use mozart_vcs::downloader::git::GitDownloader; -use mozart_vcs::downloader::hg::HgDownloader; -use mozart_vcs::downloader::svn::SvnDownloader; -use mozart_vcs::process::ProcessExecutor; -use mozart_vcs::util::git::GitUtil; -use mozart_vcs::util::hg::HgUtil; -use mozart_vcs::util::svn::SvnUtil; - -/// Selects a `VcsDownloader` for a package based on its installation source -/// and source type. Mirrors `DownloadManager::getDownloaderForPackage`: -/// -/// - `metapackage` → `None`. -/// - `installation-source: dist` → `None` (Composer would return a -/// `FileDownloader`-family object that does not implement -/// `ChangeReportInterface` / `DvcsDownloaderInterface`, so the status -/// command's `instanceof` checks all become no-ops; returning `None` -/// directly is the equivalent in our trait-object world). -/// - `installation-source: source` → the matching VCS downloader by -/// `source.type` (`git` / `hg` / `svn`). -pub struct DownloadManager { - git_cache_dir: PathBuf, -} - -impl DownloadManager { - /// `git_cache_dir`: where `GitUtil` should keep mirror clones (e.g. - /// `<vendor>/.cache/git`). - pub fn new(git_cache_dir: PathBuf) -> Self { - Self { git_cache_dir } - } - - pub fn get_downloader_for_package( - &self, - package: &LocalPackage, - ) -> Option<Box<dyn VcsDownloader>> { - if package.package_type() == Some("metapackage") { - return None; - } - match package.installation_source()? { - InstallationSource::Dist => None, - InstallationSource::Source => { - let kind = package.source()?.kind.as_str(); - match kind { - "git" => { - let git_util = - GitUtil::new(ProcessExecutor::new(), self.git_cache_dir.clone()); - Some(Box::new(GitDownloader::new(git_util))) - } - "hg" => { - let hg_util = HgUtil::new(ProcessExecutor::new()); - Some(Box::new(HgDownloader::new(hg_util))) - } - "svn" => { - let svn_util = SvnUtil::new(ProcessExecutor::new()); - Some(Box::new(SvnDownloader::new(svn_util))) - } - _ => None, - } - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use mozart_core::composer::PackageReference; - use serde_json::Value; - - fn pkg( - installation_source: Option<InstallationSource>, - source_kind: Option<&str>, - ) -> LocalPackage { - let source = source_kind.map(|kind| PackageReference { - kind: kind.to_string(), - url: "https://example/repo".into(), - reference: Some("abc123".into()), - shasum: None, - }); - LocalPackage::new( - "vendor/pkg".into(), - "1.0.0".into(), - None, - Some("library".into()), - installation_source, - source, - None, - Value::Null, - ) - } - - #[test] - fn metapackage_returns_none() { - let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache")); - let mut p = pkg(Some(InstallationSource::Source), Some("git")); - // override type - p = LocalPackage::new( - "vendor/pkg".into(), - "1.0.0".into(), - None, - Some("metapackage".into()), - p.installation_source(), - p.source().cloned(), - None, - Value::Null, - ); - assert!(dm.get_downloader_for_package(&p).is_none()); - } - - #[test] - fn dist_install_returns_none() { - let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache")); - let p = pkg(Some(InstallationSource::Dist), Some("git")); - assert!(dm.get_downloader_for_package(&p).is_none()); - } - - #[test] - fn source_install_with_git_returns_some() { - let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache")); - let p = pkg(Some(InstallationSource::Source), Some("git")); - assert!(dm.get_downloader_for_package(&p).is_some()); - } - - #[test] - fn unknown_source_kind_returns_none() { - let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache")); - let p = pkg(Some(InstallationSource::Source), Some("perforce")); - assert!(dm.get_downloader_for_package(&p).is_none()); - } - - #[test] - fn missing_installation_source_returns_none() { - let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache")); - let p = pkg(None, Some("git")); - assert!(dm.get_downloader_for_package(&p).is_none()); - } -} diff --git a/crates/mozart-registry/src/downloader.rs b/crates/mozart-registry/src/downloader.rs deleted file mode 100644 index 3cb991b..0000000 --- a/crates/mozart-registry/src/downloader.rs +++ /dev/null @@ -1,500 +0,0 @@ -use crate::cache::Cache; -use indexmap::IndexSet; -use sha1::{Digest, Sha1}; -use std::fs; -use std::io::{Cursor, Read, Write}; -use std::path::Path; - -/// A simple download progress tracker that writes to stderr. -/// -/// When `show` is false, all methods are no-ops. This lets callers toggle -/// progress display without branching on every call. -pub struct DownloadProgress { - show: bool, - total: u64, - downloaded: u64, - label: String, -} - -impl DownloadProgress { - /// Create a new progress tracker. - /// - /// - `show`: whether to actually display anything. - /// - `label`: a human-readable label (e.g. "psr/log (3.0.2)"). - pub fn new(show: bool, label: impl Into<String>) -> Self { - Self { - show, - total: 0, - downloaded: 0, - label: label.into(), - } - } - - /// Set the total expected bytes from a `Content-Length` header. - pub fn set_total(&mut self, total: u64) { - self.total = total; - } - - /// Advance the downloaded byte count and redraw the line. - pub fn inc(&mut self, n: u64) { - if !self.show { - return; - } - self.downloaded += n; - let stderr = std::io::stderr(); - let mut out = stderr.lock(); - if let Some(pct) = (self.downloaded * 100).checked_div(self.total) { - let _ = write!( - out, - "\r Downloading {} ({}/{} bytes, {}%)", - self.label, self.downloaded, self.total, pct - ); - } else { - let _ = write!( - out, - "\r Downloading {} ({} bytes)", - self.label, self.downloaded - ); - } - let _ = out.flush(); - } - - /// Clear the progress line from the terminal. - pub fn finish(&self) { - if !self.show { - return; - } - let stderr = std::io::stderr(); - let mut out = stderr.lock(); - // Clear the line with spaces then return to start - let _ = write!(out, "\r{}\r", " ".repeat(80)); - let _ = out.flush(); - } -} - -/// Download a dist archive from a URL. -/// Returns the raw bytes of the downloaded archive. -/// If `expected_shasum` is provided and non-empty, verifies SHA-1 of the downloaded bytes. -/// If `progress` is provided, increments it as bytes are received and sets the total from -/// the `Content-Length` response header. -/// Downloaded bytes are cached by URL in `files_cache`; cache hits skip the network request -/// entirely. -#[tracing::instrument(skip(expected_shasum, progress, files_cache))] -pub async fn download_dist( - url: &str, - expected_shasum: Option<&str>, - progress: Option<&mut DownloadProgress>, - files_cache: &Cache, -) -> anyhow::Result<Vec<u8>> { - // Build a cache key from the URL - let cache_key = Cache::sanitize_key(url); - - // Check cache first - if let Some(cached_bytes) = files_cache.read_bytes(&cache_key) { - // Verify checksum against cache hit if provided - if let Some(shasum) = expected_shasum - && !shasum.is_empty() - { - let mut hasher = Sha1::new(); - hasher.update(&cached_bytes); - let computed = format!("{:x}", hasher.finalize()); - if computed == shasum { - tracing::debug!("cache hit"); - return Ok(cached_bytes); - } - // Checksum mismatch — discard cache, re-download - } else { - tracing::debug!("cache hit"); - return Ok(cached_bytes); - } - } - - let client = mozart_core::http::client_builder().build()?; - let response = client.get(url).send().await?; - tracing::debug!(status = %response.status(), "received response"); - - if !response.status().is_success() { - anyhow::bail!( - "Failed to download dist archive from {} (HTTP {})", - url, - response.status() - ); - } - - // Stream the response body, updating progress as bytes arrive - let bytes = if let Some(pb) = progress { - if let Some(content_length) = response.content_length() { - pb.set_total(content_length); - } - let mut buf = Vec::new(); - let mut stream = response; - while let Some(chunk) = stream.chunk().await? { - buf.extend_from_slice(&chunk); - pb.inc(chunk.len() as u64); - } - buf - } else { - response.bytes().await?.to_vec() - }; - - tracing::debug!(size = bytes.len(), "download complete"); - - // Verify SHA-1 checksum if provided - if let Some(shasum) = expected_shasum - && !shasum.is_empty() - { - let mut hasher = Sha1::new(); - hasher.update(&bytes); - let result = hasher.finalize(); - let computed = format!("{result:x}"); - - if computed != shasum { - anyhow::bail!("SHA-1 checksum mismatch for {url}: expected {shasum}, got {computed}"); - } - } - - // Write to cache - let _ = files_cache.write_bytes(&cache_key, &bytes); - - Ok(bytes) -} - -/// Find the common top-level directory prefix shared by all entries. -/// Returns `Some(prefix)` if all entries share a single top-level directory. -fn find_top_level_dir(entries: &[String]) -> Option<String> { - if entries.is_empty() { - return None; - } - - let mut prefixes: IndexSet<String> = IndexSet::new(); - for entry in entries { - let slash_pos = entry.find('/')?; - prefixes.insert(entry[..slash_pos + 1].to_string()); - } - - if prefixes.len() == 1 { - prefixes.into_iter().next() - } else { - None - } -} - -/// Extract a zip archive to the target directory. -/// Strips a common top-level directory if all entries share one (Packagist pattern). -pub fn extract_zip(data: &[u8], target_dir: &Path) -> anyhow::Result<()> { - let cursor = Cursor::new(data); - let mut archive = zip::ZipArchive::new(cursor)?; - - // Collect all entry names to detect common prefix - let entry_names: Vec<String> = (0..archive.len()) - .map(|i| archive.by_index(i).map(|e| e.name().to_string())) - .collect::<Result<_, _>>()?; - - let prefix = find_top_level_dir(&entry_names); - - for i in 0..archive.len() { - let mut entry = archive.by_index(i)?; - let raw_name = entry.name().to_string(); - - // Strip common prefix - let relative = if let Some(ref pfx) = prefix { - if raw_name.starts_with(pfx.as_str()) { - &raw_name[pfx.len()..] - } else { - &raw_name - } - } else { - &raw_name - }; - - // Skip the directory entry itself (empty name after stripping) - if relative.is_empty() { - continue; - } - - let target_path = target_dir.join(relative); - - if raw_name.ends_with('/') { - // Directory entry - fs::create_dir_all(&target_path)?; - } else { - // File entry - if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent)?; - } - - let mut buf = Vec::new(); - entry.read_to_end(&mut buf)?; - fs::write(&target_path, &buf)?; - - // Set permissions on Unix - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Some(mode) = entry.unix_mode() { - fs::set_permissions(&target_path, fs::Permissions::from_mode(mode))?; - } - } - } - } - - Ok(()) -} - -/// Extract a tar.gz archive to the target directory. -/// Strips a common top-level directory if all entries share one (Packagist pattern). -pub fn extract_tar_gz(data: &[u8], target_dir: &Path) -> anyhow::Result<()> { - let cursor = Cursor::new(data); - let decoder = flate2::read::GzDecoder::new(cursor); - let mut archive = tar::Archive::new(decoder); - - // We need to process in two passes: first collect names, then extract. - // Use a buffered approach: collect entries into memory. - let cursor2 = Cursor::new(data); - let decoder2 = flate2::read::GzDecoder::new(cursor2); - let mut archive2 = tar::Archive::new(decoder2); - - let entry_names: Vec<String> = archive2 - .entries()? - .filter_map(|e| e.ok()) - .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string())) - .collect(); - - let prefix = find_top_level_dir(&entry_names); - - for entry in archive.entries()? { - let mut entry = entry?; - let raw_path = entry.path()?.to_string_lossy().to_string(); - - // Strip common prefix - let relative = if let Some(ref pfx) = prefix { - if raw_path.starts_with(pfx.as_str()) { - raw_path[pfx.len()..].to_string() - } else { - raw_path.clone() - } - } else { - raw_path.clone() - }; - - // Skip empty (top-level dir itself) - if relative.is_empty() { - continue; - } - - let target_path = target_dir.join(&relative); - - let entry_type = entry.header().entry_type(); - if entry_type.is_dir() { - fs::create_dir_all(&target_path)?; - } else if entry_type.is_file() { - if let Some(parent) = target_path.parent() { - fs::create_dir_all(parent)?; - } - let mut buf = Vec::new(); - entry.read_to_end(&mut buf)?; - fs::write(&target_path, &buf)?; - - // Set permissions on Unix - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - if let Ok(mode) = entry.header().mode() { - fs::set_permissions(&target_path, fs::Permissions::from_mode(mode))?; - } - } - } - // Symlinks and other types are skipped for now - } - - Ok(()) -} - -/// Download and install a package to the vendor directory. -/// -/// - `dist_url`: the download URL (from `LockedPackage.dist.url`) -/// - `dist_type`: `"zip"` or `"tar"` (from `LockedPackage.dist.dist_type`) -/// - `dist_shasum`: optional SHA-1 checksum -/// - `vendor_dir`: path to `vendor/` directory -/// - `package_name`: e.g. `"monolog/monolog"` -/// - `progress`: optional mutable progress tracker to update during download -/// - `files_cache`: files cache; archive bytes are cached by URL -pub async fn install_package( - dist_url: &str, - dist_type: &str, - dist_shasum: Option<&str>, - vendor_dir: &Path, - package_name: &str, - progress: Option<&mut DownloadProgress>, - files_cache: &Cache, -) -> anyhow::Result<()> { - let target = vendor_dir.join(package_name); - - // Remove existing installation for a clean reinstall - if target.exists() { - fs::remove_dir_all(&target)?; - } - fs::create_dir_all(&target)?; - - let bytes = download_dist(dist_url, dist_shasum, progress, files_cache).await?; - - match dist_type { - "zip" => extract_zip(&bytes, &target)?, - "tar" | "tar.gz" | "tgz" => extract_tar_gz(&bytes, &target)?, - other => anyhow::bail!("Unsupported dist type: {other}"), - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use std::io::Write as IoWrite; - use tempfile::tempdir; - - /// Build a minimal zip archive in memory. - fn make_zip(files: &[(&str, &[u8])]) -> Vec<u8> { - let buf = Vec::new(); - let cursor = Cursor::new(buf); - let mut writer = zip::ZipWriter::new(cursor); - let options = zip::write::FileOptions::<()>::default() - .compression_method(zip::CompressionMethod::Stored); - - for (name, content) in files { - writer.start_file(*name, options).unwrap(); - writer.write_all(content).unwrap(); - } - - writer.finish().unwrap().into_inner() - } - - /// Build a minimal tar.gz archive in memory. - fn make_tar_gz(files: &[(&str, &[u8])]) -> Vec<u8> { - let buf = Vec::new(); - let enc = flate2::write::GzEncoder::new(buf, flate2::Compression::default()); - let mut builder = tar::Builder::new(enc); - - for (name, content) in files { - let mut header = tar::Header::new_gnu(); - header.set_size(content.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - builder - .append_data(&mut header, name, Cursor::new(content)) - .unwrap(); - } - - builder.into_inner().unwrap().finish().unwrap() - } - - #[test] - fn test_extract_zip_flat() { - let zip_data = make_zip(&[("file1.txt", b"hello"), ("subdir/file2.txt", b"world")]); - - let dir = tempdir().unwrap(); - extract_zip(&zip_data, dir.path()).unwrap(); - - assert_eq!( - fs::read_to_string(dir.path().join("file1.txt")).unwrap(), - "hello" - ); - assert_eq!( - fs::read_to_string(dir.path().join("subdir/file2.txt")).unwrap(), - "world" - ); - } - - #[test] - fn test_extract_zip_with_top_level_dir() { - // Packagist pattern: all files under vendor-package-abc123/ - let zip_data = make_zip(&[ - ("vendor-pkg-abc/", &[]), - ("vendor-pkg-abc/file1.txt", b"hello"), - ("vendor-pkg-abc/src/Foo.php", b"<?php"), - ]); - - let dir = tempdir().unwrap(); - extract_zip(&zip_data, dir.path()).unwrap(); - - // Top-level dir should be stripped - assert!(dir.path().join("file1.txt").exists()); - assert!(dir.path().join("src/Foo.php").exists()); - assert_eq!( - fs::read_to_string(dir.path().join("file1.txt")).unwrap(), - "hello" - ); - } - - #[test] - fn test_extract_tar_gz_flat() { - let tar_data = make_tar_gz(&[("file1.txt", b"hello"), ("subdir/file2.txt", b"world")]); - - let dir = tempdir().unwrap(); - extract_tar_gz(&tar_data, dir.path()).unwrap(); - - assert_eq!( - fs::read_to_string(dir.path().join("file1.txt")).unwrap(), - "hello" - ); - assert_eq!( - fs::read_to_string(dir.path().join("subdir/file2.txt")).unwrap(), - "world" - ); - } - - #[test] - fn test_extract_tar_gz_with_top_level_dir() { - let tar_data = make_tar_gz(&[ - ("vendor-pkg-abc/file1.txt", b"hello"), - ("vendor-pkg-abc/src/Foo.php", b"<?php"), - ]); - - let dir = tempdir().unwrap(); - extract_tar_gz(&tar_data, dir.path()).unwrap(); - - assert!(dir.path().join("file1.txt").exists()); - assert!(dir.path().join("src/Foo.php").exists()); - } - - #[test] - fn test_sha1_verification() { - use sha1::{Digest, Sha1}; - - let data = b"test content"; - let mut hasher = Sha1::new(); - hasher.update(data); - let expected = format!("{:x}", hasher.finalize()); - - // We can't test download_dist without a server, but we can verify the - // SHA-1 logic: same data should produce same hash - let mut hasher2 = Sha1::new(); - hasher2.update(data); - let computed = format!("{:x}", hasher2.finalize()); - - assert_eq!(expected, computed); - assert!(!expected.is_empty()); - } - - #[test] - fn test_find_top_level_dir_common() { - let entries = vec![ - "pkg-1.0/".to_string(), - "pkg-1.0/README.md".to_string(), - "pkg-1.0/src/Foo.php".to_string(), - ]; - assert_eq!(find_top_level_dir(&entries), Some("pkg-1.0/".to_string())); - } - - #[test] - fn test_find_top_level_dir_none_when_mixed() { - let entries = vec!["pkg-1.0/file.txt".to_string(), "other/file.txt".to_string()]; - assert_eq!(find_top_level_dir(&entries), None); - } - - #[test] - fn test_find_top_level_dir_none_when_root_file() { - let entries = vec!["file.txt".to_string(), "pkg/other.txt".to_string()]; - assert_eq!(find_top_level_dir(&entries), None); - } -} diff --git a/crates/mozart-registry/src/inline_package.rs b/crates/mozart-registry/src/inline_package.rs deleted file mode 100644 index 95f842f..0000000 --- a/crates/mozart-registry/src/inline_package.rs +++ /dev/null @@ -1,277 +0,0 @@ -//! Support for inline `type: package` repositories. -//! -//! `composer.json` may embed full package metadata under -//! `repositories[].package`, mirroring `Composer\Repository\PackageRepository`. -//! These packages need no network fetch — they go straight into the resolver -//! pool and into the generated lockfile entry verbatim. - -use crate::packagist::PackagistVersion; -use crate::repository_filter::RepositoryFilter; -use indexmap::IndexSet; -use mozart_core::package::RawRepository; - -/// One package extracted from a `type: package` repository. -pub struct InlinePackage { - pub name: String, - pub version: PackagistVersion, -} - -/// Collect every package definition from `type: package` repositories. -/// -/// Each repository's `package` field may be a single object or an array of -/// objects. Entries that fail to parse (missing `name`/`version`, etc.) are -/// silently skipped so the rest of the repositories list still applies — -/// matching Composer's lenient PackageRepository constructor. -/// -/// Repositories are processed in declaration order. Once any repository -/// authoritatively answers for a package name, lower-priority `type: package` -/// repositories that list the same name are skipped — mirroring Composer's -/// first-repo-wins priority via `RepositorySet::findPackages`. -pub fn collect_inline_packages(repositories: &[RawRepository]) -> Vec<InlinePackage> { - let mut packages = Vec::new(); - let mut claimed: IndexSet<String> = IndexSet::new(); - for repo in repositories { - if repo.repo_type != "package" { - continue; - } - let Some(value) = &repo.package else { - continue; - }; - let filter = RepositoryFilter::from_repo(repo); - - let mut from_this_repo: Vec<InlinePackage> = Vec::new(); - match value { - serde_json::Value::Array(arr) => { - for entry in arr { - if let Some(pkg) = parse_inline_package(entry) { - from_this_repo.push(pkg); - } - } - } - serde_json::Value::Object(_) => { - if let Some(pkg) = parse_inline_package(value) { - from_this_repo.push(pkg); - } - } - _ => {} - } - - let mut names_this_repo: IndexSet<String> = IndexSet::new(); - for pkg in from_this_repo { - if !filter.is_allowed(&pkg.name) { - continue; - } - if claimed.contains(&pkg.name) { - continue; - } - names_this_repo.insert(pkg.name.clone()); - packages.push(pkg); - } - // canonical: false → packages enter the pool but the name is not - // claimed, so lower-priority repositories may still answer for it. - // Mirrors `FilterRepository::loadPackages`'s `namesFound = []` reset. - if filter.canonical { - claimed.extend(names_this_repo); - } - } - packages -} - -/// One advisory extracted from a repository's `security-advisories` block. -/// Carries enough to filter affected versions out of the pool when -/// `config.audit.block-insecure` is set, matching the slice of Composer's -/// `SecurityAdvisoryPoolFilter` Mozart needs for resolution-time blocking. -#[derive(Debug, Clone)] -pub struct SecurityAdvisory { - pub advisory_id: String, - pub affected_versions: String, -} - -/// Collect every `security-advisories` entry across all repositories. -/// Returned map is keyed by lowercase package name so the resolver can -/// look up affected versions in lockstep with the rest of its -/// case-insensitive name handling. Repository order is preserved within -/// each list. -pub fn collect_security_advisories( - repositories: &[RawRepository], -) -> indexmap::IndexMap<String, Vec<SecurityAdvisory>> { - let mut out: indexmap::IndexMap<String, Vec<SecurityAdvisory>> = indexmap::IndexMap::new(); - for repo in repositories { - let Some(advisories) = &repo.security_advisories else { - continue; - }; - let Some(map) = advisories.as_object() else { - continue; - }; - for (pkg_name, list) in map { - let Some(arr) = list.as_array() else { - continue; - }; - for entry in arr { - let Some(obj) = entry.as_object() else { - continue; - }; - let Some(affected) = obj - .get("affectedVersions") - .and_then(|v| v.as_str()) - .map(String::from) - else { - continue; - }; - let advisory_id = obj - .get("advisoryId") - .and_then(|v| v.as_str()) - .map(String::from) - .unwrap_or_default(); - out.entry(pkg_name.to_lowercase()) - .or_default() - .push(SecurityAdvisory { - advisory_id, - affected_versions: affected, - }); - } - } - } - out -} - -fn parse_inline_package(value: &serde_json::Value) -> Option<InlinePackage> { - let obj = value.as_object()?; - let name = obj.get("name")?.as_str()?.to_string(); - let version_str = obj.get("version")?.as_str()?.to_string(); - - // PackagistVersion requires `version_normalized`. If the inline definition - // omits it (the common case), compute it the same way Packagist does: - // run the version through Mozart's normalizer. - // - // Mirrors Composer's `ArrayLoader::parsePackage` Composer v1 compat path: - // when `version_normalized` is exactly `9999999-dev` (the legacy default - // branch sentinel), re-normalize from the human-readable `version` field - // instead. Without this, the package's version stays as `9999999-dev` - // even though its pretty form is e.g. `dev-master`, and a root require - // for `dev-master` then can't match the loaded package. - let mut value_for_parse = value.clone(); - if let serde_json::Value::Object(ref mut map) = value_for_parse { - let needs_normalize = match map.get("version_normalized") { - None => true, - Some(serde_json::Value::String(s)) => s == "9999999-dev", - _ => false, - }; - if needs_normalize { - let normalized = mozart_semver::Version::parse(&version_str) - .map(|v| v.to_string()) - .unwrap_or_else(|_| version_str.clone()); - map.insert( - "version_normalized".to_string(), - serde_json::Value::String(normalized), - ); - } - } - - let version: PackagistVersion = serde_json::from_value(value_for_parse).ok()?; - Some(InlinePackage { name, version }) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn pkg_repo(value: serde_json::Value) -> RawRepository { - RawRepository { - repo_type: "package".to_string(), - url: None, - package: Some(value), - only: None, - exclude: None, - canonical: None, - security_advisories: None, - } - } - - #[test] - fn collects_single_inline_package_object() { - let repos = vec![pkg_repo(serde_json::json!({ - "name": "a/a", - "version": "1.0.0" - }))]; - let pkgs = collect_inline_packages(&repos); - assert_eq!(pkgs.len(), 1); - assert_eq!(pkgs[0].name, "a/a"); - assert_eq!(pkgs[0].version.version, "1.0.0"); - assert_eq!(pkgs[0].version.version_normalized, "1.0.0.0"); - } - - #[test] - fn collects_inline_package_array() { - let repos = vec![pkg_repo(serde_json::json!([ - {"name": "a/a", "version": "1.0.0"}, - {"name": "b/b", "version": "2.0.0"} - ]))]; - let pkgs = collect_inline_packages(&repos); - assert_eq!(pkgs.len(), 2); - assert_eq!(pkgs[0].name, "a/a"); - assert_eq!(pkgs[1].name, "b/b"); - } - - #[test] - fn ignores_non_package_repos() { - let repos = vec![RawRepository { - repo_type: "vcs".to_string(), - url: Some("https://example.com/foo.git".to_string()), - package: None, - only: None, - exclude: None, - canonical: None, - security_advisories: None, - }]; - assert!(collect_inline_packages(&repos).is_empty()); - } - - #[test] - fn skips_entries_missing_name_or_version() { - let repos = vec![pkg_repo(serde_json::json!([ - {"name": "a/a", "version": "1.0.0"}, - {"name": "missing/version"}, - {"version": "2.0.0"}, - {"name": "b/b", "version": "2.0.0"} - ]))]; - let pkgs = collect_inline_packages(&repos); - assert_eq!(pkgs.len(), 2); - assert_eq!(pkgs[0].name, "a/a"); - assert_eq!(pkgs[1].name, "b/b"); - } - - #[test] - fn preserves_explicit_version_normalized() { - let repos = vec![pkg_repo(serde_json::json!({ - "name": "a/a", - "version": "1.0", - "version_normalized": "1.0.0.0-explicit" - }))]; - let pkgs = collect_inline_packages(&repos); - assert_eq!(pkgs[0].version.version_normalized, "1.0.0.0-explicit"); - } - - #[test] - fn parses_full_metadata_fields() { - let repos = vec![pkg_repo(serde_json::json!({ - "name": "a/a", - "version": "1.0.0", - "type": "library", - "require": {"b/b": "^2.0"}, - "replace": {"old/x": "1.0"}, - "provide": {"some/iface": "1.0"}, - "conflict": {"bad/pkg": "*"}, - "dist": {"type": "zip", "url": "https://e.com/a.zip"} - }))]; - let pkgs = collect_inline_packages(&repos); - assert_eq!(pkgs.len(), 1); - let v = &pkgs[0].version; - assert_eq!(v.package_type.as_deref(), Some("library")); - assert_eq!(v.require.get("b/b").map(String::as_str), Some("^2.0")); - assert_eq!(v.replace.get("old/x").map(String::as_str), Some("1.0")); - assert_eq!(v.provide.get("some/iface").map(String::as_str), Some("1.0")); - assert_eq!(v.conflict.get("bad/pkg").map(String::as_str), Some("*")); - assert!(v.dist.is_some()); - } -} diff --git a/crates/mozart-registry/src/installed.rs b/crates/mozart-registry/src/installed.rs deleted file mode 100644 index 108b844..0000000 --- a/crates/mozart-registry/src/installed.rs +++ /dev/null @@ -1,383 +0,0 @@ -use mozart_core::installer::HasSuggests; -use mozart_core::package::to_json_pretty; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; -use std::fs; -use std::path::Path; - -fn default_true() -> bool { - true -} - -/// Represents `vendor/composer/installed.json`. -/// This is the Composer 2.x format. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InstalledPackages { - pub packages: Vec<InstalledPackageEntry>, - - #[serde(rename = "dev-package-names", default)] - pub dev_package_names: Vec<String>, - - #[serde(default = "default_true")] - pub dev: bool, -} - -/// An entry in installed.json's packages array. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InstalledPackageEntry { - pub name: String, - pub version: String, - - #[serde(rename = "version_normalized", skip_serializing_if = "Option::is_none")] - pub version_normalized: Option<String>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub source: Option<serde_json::Value>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub dist: Option<serde_json::Value>, - - #[serde(rename = "type", skip_serializing_if = "Option::is_none")] - pub package_type: Option<String>, - - #[serde(rename = "install-path", skip_serializing_if = "Option::is_none")] - pub install_path: Option<String>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub autoload: Option<serde_json::Value>, - - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub aliases: Vec<String>, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub homepage: Option<String>, - - #[serde(default, skip_serializing_if = "Option::is_none")] - pub support: Option<serde_json::Value>, - - #[serde(flatten)] - pub extra_fields: BTreeMap<String, serde_json::Value>, -} - -impl HasSuggests for InstalledPackageEntry { - fn pretty_name(&self) -> &str { - &self.name - } - - fn suggests(&self) -> Vec<(String, String)> { - let Some(val) = self.extra_fields.get("suggest") else { - return Vec::new(); - }; - let Some(obj) = val.as_object() else { - return Vec::new(); - }; - obj.iter() - .filter_map(|(target, reason)| reason.as_str().map(|r| (target.clone(), r.to_string()))) - .collect() - } -} - -impl Default for InstalledPackages { - fn default() -> Self { - Self::new() - } -} - -impl InstalledPackages { - /// Create an empty registry. - pub fn new() -> InstalledPackages { - InstalledPackages { - packages: Vec::new(), - dev_package_names: Vec::new(), - dev: true, - } - } - - /// Read installed.json from `vendor/composer/installed.json`. - /// If the file does not exist, returns an empty registry. - /// - /// Accepts both Composer formats, mirroring `FilesystemRepository::initialize`: - /// - **v2** — object with a `packages` array, plus optional `dev-package-names`/`dev` - /// (the shape Composer 2.x writes). - /// - **v1** — bare array of package entries (older shape; still legal input). - pub fn read(vendor_dir: &Path) -> anyhow::Result<InstalledPackages> { - let path = vendor_dir.join("composer/installed.json"); - if !path.exists() { - return Ok(InstalledPackages::new()); - } - let content = fs::read_to_string(&path)?; - Self::from_json_str(&content) - } - - /// Parse an installed.json document. See [`Self::read`] for the accepted shapes. - pub fn from_json_str(content: &str) -> anyhow::Result<InstalledPackages> { - use anyhow::{Context, anyhow}; - - let value: serde_json::Value = - serde_json::from_str(content).context("invalid installed.json")?; - - match value { - serde_json::Value::Object(mut obj) => { - let packages_value = obj.remove("packages").ok_or_else(|| { - anyhow!("Could not parse package list from installed.json (missing `packages`)") - })?; - let packages: Vec<InstalledPackageEntry> = - serde_json::from_value(packages_value) - .context("invalid `packages` array in installed.json")?; - - let dev_package_names: Vec<String> = match obj.remove("dev-package-names") { - Some(v) => serde_json::from_value(v) - .context("invalid `dev-package-names` in installed.json")?, - None => Vec::new(), - }; - let dev: bool = match obj.remove("dev") { - Some(v) => { - serde_json::from_value(v).context("invalid `dev` flag in installed.json")? - } - None => true, - }; - - Ok(InstalledPackages { - packages, - dev_package_names, - dev, - }) - } - serde_json::Value::Array(_) => { - let packages: Vec<InstalledPackageEntry> = serde_json::from_value(value) - .context("invalid v1 installed.json package array")?; - Ok(InstalledPackages { - packages, - dev_package_names: Vec::new(), - dev: true, - }) - } - _ => Err(anyhow!( - "Could not parse package list from installed.json (expected object or array)" - )), - } - } - - /// Write installed.json to `vendor/composer/installed.json`. - /// Creates the `vendor/composer/` directory if it doesn't exist. - pub fn write(&self, vendor_dir: &Path) -> anyhow::Result<()> { - let composer_dir = vendor_dir.join("composer"); - fs::create_dir_all(&composer_dir)?; - let path = composer_dir.join("installed.json"); - let json = to_json_pretty(self)?; - fs::write(path, json)?; - Ok(()) - } - - /// Check if a package at a specific version is installed. - pub fn is_installed(&self, name: &str, version: &str) -> bool { - self.packages - .iter() - .any(|p| p.name.eq_ignore_ascii_case(name) && p.version == version) - } - - /// Add or update a package entry (replace if same name exists). - pub fn upsert(&mut self, entry: InstalledPackageEntry) { - if let Some(pos) = self - .packages - .iter() - .position(|p| p.name.eq_ignore_ascii_case(&entry.name)) - { - self.packages[pos] = entry; - } else { - self.packages.push(entry); - } - } - - /// Remove a package by name. - pub fn remove(&mut self, name: &str) { - self.packages.retain(|p| !p.name.eq_ignore_ascii_case(name)); - self.dev_package_names - .retain(|n| !n.eq_ignore_ascii_case(name)); - } -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - fn make_entry(name: &str, version: &str) -> InstalledPackageEntry { - InstalledPackageEntry { - name: name.to_string(), - version: version.to_string(), - version_normalized: None, - source: None, - dist: None, - package_type: None, - install_path: None, - autoload: None, - aliases: vec![], - homepage: None, - support: None, - extra_fields: BTreeMap::new(), - } - } - - #[test] - fn test_new_is_empty() { - let installed = InstalledPackages::new(); - assert!(installed.packages.is_empty()); - assert!(installed.dev_package_names.is_empty()); - assert!(installed.dev); - } - - #[test] - fn test_write_read_empty() { - let dir = tempdir().unwrap(); - let vendor = dir.path().join("vendor"); - - let installed = InstalledPackages::new(); - installed.write(&vendor).unwrap(); - - let loaded = InstalledPackages::read(&vendor).unwrap(); - assert!(loaded.packages.is_empty()); - assert!(loaded.dev); - } - - #[test] - fn test_read_nonexistent_returns_empty() { - let dir = tempdir().unwrap(); - let vendor = dir.path().join("vendor"); - // Don't create the directory - let installed = InstalledPackages::read(&vendor).unwrap(); - assert!(installed.packages.is_empty()); - } - - #[test] - fn test_upsert_and_is_installed() { - let mut installed = InstalledPackages::new(); - installed.upsert(make_entry("monolog/monolog", "3.8.0")); - - assert!(installed.is_installed("monolog/monolog", "3.8.0")); - assert!(!installed.is_installed("monolog/monolog", "3.7.0")); - assert!(!installed.is_installed("other/pkg", "1.0.0")); - } - - #[test] - fn test_upsert_replaces_existing() { - let mut installed = InstalledPackages::new(); - installed.upsert(make_entry("monolog/monolog", "3.7.0")); - installed.upsert(make_entry("monolog/monolog", "3.8.0")); - - assert_eq!(installed.packages.len(), 1); - assert_eq!(installed.packages[0].version, "3.8.0"); - } - - #[test] - fn test_remove() { - let mut installed = InstalledPackages::new(); - installed.upsert(make_entry("monolog/monolog", "3.8.0")); - installed.upsert(make_entry("psr/log", "3.0.0")); - installed - .dev_package_names - .push("monolog/monolog".to_string()); - - installed.remove("monolog/monolog"); - - assert_eq!(installed.packages.len(), 1); - assert_eq!(installed.packages[0].name, "psr/log"); - assert!(installed.dev_package_names.is_empty()); - } - - #[test] - fn test_reads_v2_object_form() { - let json = r#"{ - "packages": [ - {"name": "a/a", "version": "1.0.0"} - ], - "dev-package-names": ["a/a"], - "dev": false - }"#; - let installed = InstalledPackages::from_json_str(json).unwrap(); - assert_eq!(installed.packages.len(), 1); - assert_eq!(installed.packages[0].name, "a/a"); - assert_eq!(installed.dev_package_names, vec!["a/a".to_string()]); - assert!(!installed.dev); - } - - #[test] - fn test_reads_v1_array_form() { - // Composer 1.x / fixture-style: bare array of packages. - // FilesystemRepository::initialize accepts this; so must Mozart. - let json = r#"[ - {"name": "a/a", "version": "1.0.0"}, - {"name": "b/b", "version": "2.0.0"} - ]"#; - let installed = InstalledPackages::from_json_str(json).unwrap(); - assert_eq!(installed.packages.len(), 2); - assert_eq!(installed.packages[0].name, "a/a"); - assert_eq!(installed.packages[1].name, "b/b"); - assert!(installed.dev_package_names.is_empty()); - assert!(installed.dev); - } - - #[test] - fn test_v2_defaults_when_optional_fields_missing() { - let json = r#"{"packages": []}"#; - let installed = InstalledPackages::from_json_str(json).unwrap(); - assert!(installed.packages.is_empty()); - assert!(installed.dev_package_names.is_empty()); - assert!(installed.dev); - } - - #[test] - fn test_rejects_non_object_non_array() { - let err = InstalledPackages::from_json_str("\"oops\"").unwrap_err(); - assert!( - err.to_string().contains("expected object or array"), - "{err}" - ); - } - - #[test] - fn test_is_installed_case_insensitive() { - let mut installed = InstalledPackages::new(); - installed.upsert(make_entry("Monolog/Monolog", "3.8.0")); - assert!(installed.is_installed("monolog/monolog", "3.8.0")); - } - - #[test] - fn test_roundtrip_with_package() { - let dir = tempdir().unwrap(); - let vendor = dir.path().join("vendor"); - - let mut installed = InstalledPackages::new(); - installed.upsert(make_entry("monolog/monolog", "3.8.0")); - installed.write(&vendor).unwrap(); - - let loaded = InstalledPackages::read(&vendor).unwrap(); - assert_eq!(loaded.packages.len(), 1); - assert_eq!(loaded.packages[0].name, "monolog/monolog"); - assert_eq!(loaded.packages[0].version, "3.8.0"); - } - - #[test] - fn test_homepage_and_support_roundtrip() { - let json = r#"{ - "packages": [ - { - "name": "vendor/pkg", - "version": "1.0.0", - "homepage": "https://vendor.example.com", - "support": {"source": "https://github.com/vendor/pkg"} - } - ] - }"#; - let installed = InstalledPackages::from_json_str(json).unwrap(); - let pkg = &installed.packages[0]; - assert_eq!(pkg.homepage.as_deref(), Some("https://vendor.example.com")); - assert_eq!( - pkg.support - .as_ref() - .and_then(|s| s.get("source")) - .and_then(|s| s.as_str()), - Some("https://github.com/vendor/pkg") - ); - } -} diff --git a/crates/mozart-registry/src/installer_executor/filesystem.rs b/crates/mozart-registry/src/installer_executor/filesystem.rs deleted file mode 100644 index cb1a2cc..0000000 --- a/crates/mozart-registry/src/installer_executor/filesystem.rs +++ /dev/null @@ -1,232 +0,0 @@ -//! Production [`InstallerExecutor`] that touches the real filesystem. -//! -//! This is the verb behind `mozart install` / `mozart update` — it pulls -//! dist archives via [`crate::downloader`], clones VCS sources via -//! [`mozart_vcs`], and removes vendor directories. Test code substitutes a -//! recording-only executor instead (added in a later step). - -use std::path::Path; - -use crate::cache::Cache; -use crate::downloader; - -use super::{ExecuteContext, InstallerExecutor, PackageOperation}; - -pub struct FilesystemExecutor { - files_cache: Cache, -} - -impl FilesystemExecutor { - pub fn new(files_cache: Cache) -> Self { - Self { files_cache } - } -} - -#[async_trait::async_trait] -impl InstallerExecutor for FilesystemExecutor { - async fn install_package( - &mut self, - op: PackageOperation<'_>, - ctx: &ExecuteContext, - ) -> anyhow::Result<()> { - // Marking an alias as installed/uninstalled has no filesystem side - // effects — the target package's files are already in vendor/. - // Mirrors Composer's `MarkAlias{,Un}installedOperation` which the - // installation manager only uses to update the in-memory installed - // repository. - let Some(pkg) = op.package() else { - return Ok(()); - }; - - // Try source install if --prefer-source and source info is available. - if ctx.prefer_source - && let Some(source) = &pkg.source - { - return install_from_source( - &source.source_type, - &source.url, - source.reference.as_deref().unwrap_or("HEAD"), - &ctx.vendor_dir, - &pkg.name, - ); - } - - // A package with neither dist nor source has no install action. - // This covers Composer's `type: metapackage` (modeled explicitly as - // "no installer") and inline `type: package` definitions used in - // test fixtures that intentionally omit download metadata. Mozart - // records the operation and the installed.json entry but performs - // no filesystem work, mirroring Composer's MetapackageInstaller. - if pkg.dist.is_none() && pkg.source.is_none() { - return Ok(()); - } - - let dist = pkg.dist.as_ref().ok_or_else(|| { - anyhow::anyhow!( - "Package {} has no dist information. Use --prefer-source to install from VCS.", - pkg.name, - ) - })?; - - let mut progress = downloader::DownloadProgress::new( - !ctx.no_progress, - format!("{} ({})", pkg.name, pkg.version), - ); - - downloader::install_package( - &dist.url, - &dist.dist_type, - dist.shasum.as_deref(), - &ctx.vendor_dir, - &pkg.name, - Some(&mut progress), - &self.files_cache, - ) - .await?; - - progress.finish(); - Ok(()) - } - - fn uninstall_package( - &mut self, - name: &str, - _version: &str, - ctx: &ExecuteContext, - ) -> anyhow::Result<()> { - let pkg_dir = ctx.vendor_dir.join(name); - if pkg_dir.exists() { - std::fs::remove_dir_all(&pkg_dir)?; - } - Ok(()) - } - - fn cleanup_after_uninstalls(&mut self, ctx: &ExecuteContext) -> anyhow::Result<()> { - cleanup_empty_vendor_dirs(&ctx.vendor_dir) - } -} - -/// Remove empty vendor namespace directories left behind after package -/// removals. Skips the `composer/` and `bin/` directories. Mirrors the -/// post-uninstall cleanup Composer does in `LibraryInstaller::removeCode`. -fn cleanup_empty_vendor_dirs(vendor_dir: &Path) -> anyhow::Result<()> { - if let Ok(entries) = std::fs::read_dir(vendor_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path.is_dir() { - let name = entry.file_name().to_string_lossy().to_string(); - if name == "composer" || name == "bin" { - continue; - } - if std::fs::read_dir(&path)?.next().is_none() { - std::fs::remove_dir(&path)?; - } - } - } - } - Ok(()) -} - -/// Install a package from VCS source (git/svn/hg). Lifted from the previous -/// `commands/install.rs::install_from_source`. Mirrors the per-driver -/// dispatch in `Composer\Downloader\VcsDownloader::install`. -fn install_from_source( - source_type: &str, - url: &str, - reference: &str, - vendor_dir: &Path, - package_name: &str, -) -> anyhow::Result<()> { - let target = vendor_dir.join(package_name); - if target.exists() { - std::fs::remove_dir_all(&target)?; - } - - match source_type { - "git" => { - let process = mozart_vcs::process::ProcessExecutor::new(); - let git_util = - mozart_vcs::util::git::GitUtil::new(process, vendor_dir.join(".cache").join("git")); - let downloader = mozart_vcs::downloader::git::GitDownloader::new(git_util); - use mozart_vcs::downloader::VcsDownloader; - downloader.download(url, reference, &target)?; - downloader.install(url, reference, &target)?; - } - "svn" => { - let process = mozart_vcs::process::ProcessExecutor::new(); - let svn_util = mozart_vcs::util::svn::SvnUtil::new(process); - let downloader = mozart_vcs::downloader::svn::SvnDownloader::new(svn_util); - use mozart_vcs::downloader::VcsDownloader; - downloader.install(url, reference, &target)?; - } - "hg" => { - let process = mozart_vcs::process::ProcessExecutor::new(); - let hg_util = mozart_vcs::util::hg::HgUtil::new(process); - let downloader = mozart_vcs::downloader::hg::HgDownloader::new(hg_util); - use mozart_vcs::downloader::VcsDownloader; - downloader.install(url, reference, &target)?; - } - _ => { - anyhow::bail!("Unsupported source type for VCS install: {}", source_type); - } - } - - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - fn make_executor() -> FilesystemExecutor { - FilesystemExecutor::new(Cache::new(std::env::temp_dir().join("__no_cache"), false)) - } - - #[test] - fn cleanup_after_uninstalls_removes_empty_namespace_dirs() { - let dir = tempdir().unwrap(); - let vendor_dir = dir.path().join("vendor"); - std::fs::create_dir_all(&vendor_dir).unwrap(); - - let empty_ns = vendor_dir.join("old-vendor"); - std::fs::create_dir_all(&empty_ns).unwrap(); - - let nonempty_ns = vendor_dir.join("psr"); - std::fs::create_dir_all(nonempty_ns.join("log")).unwrap(); - - std::fs::create_dir_all(vendor_dir.join("composer")).unwrap(); - - let mut exec = make_executor(); - exec.cleanup_after_uninstalls(&ExecuteContext { - vendor_dir: vendor_dir.clone(), - no_progress: true, - prefer_source: false, - }) - .unwrap(); - - assert!(!empty_ns.exists()); - assert!(vendor_dir.join("psr").exists()); - assert!(vendor_dir.join("composer").exists()); - } - - #[test] - fn cleanup_after_uninstalls_preserves_bin_dir() { - let dir = tempdir().unwrap(); - let vendor_dir = dir.path().join("vendor"); - std::fs::create_dir_all(&vendor_dir).unwrap(); - - let bin_dir = vendor_dir.join("bin"); - std::fs::create_dir_all(&bin_dir).unwrap(); - - let mut exec = make_executor(); - exec.cleanup_after_uninstalls(&ExecuteContext { - vendor_dir: vendor_dir.clone(), - no_progress: true, - prefer_source: false, - }) - .unwrap(); - - assert!(bin_dir.exists()); - } -} diff --git a/crates/mozart-registry/src/installer_executor/mod.rs b/crates/mozart-registry/src/installer_executor/mod.rs deleted file mode 100644 index 4ddad66..0000000 --- a/crates/mozart-registry/src/installer_executor/mod.rs +++ /dev/null @@ -1,348 +0,0 @@ -//! Installation execution abstraction. -//! -//! Mirrors `Composer\Installer\InstallationManager`: the per-operation -//! side-effect surface (download, extract, remove from vendor/) lives behind -//! a trait so test code can substitute a recording-only implementation -//! (Composer's `InstallationManagerMock`) without going anywhere near the -//! filesystem or the network. -//! -//! The orchestration loop (computing operations from lock vs installed, -//! emitting console messages, writing `installed.json`, generating the -//! autoloader) stays in the caller. The executor is purely the verb — -//! "install this package" / "uninstall this package" — so test traces match -//! Composer's `(string) $operation` byte-for-byte without the executor -//! having to also reproduce console formatting. - -use std::path::PathBuf; - -use crate::installed::InstalledPackageEntry; -use crate::lockfile::{LockAlias, LockedPackage}; - -pub mod filesystem; -pub mod trace_recorder; -pub mod transaction; - -pub use filesystem::FilesystemExecutor; -pub use trace_recorder::TraceRecorderExecutor; -pub use transaction::{ - Action, StaleInstalledAlias, compute_operations, compute_stale_installed_aliases, - locked_to_installed_entry, previously_installed_alias_versions, -}; - -/// One install or update operation handed to [`InstallerExecutor::install_package`]. -#[derive(Debug, Clone, Copy)] -pub enum PackageOperation<'a> { - /// First-time install. The whole package directory is created from - /// `package.dist`/`package.source`. - Install { package: &'a LockedPackage }, - /// Replace an existing install with a new version. `from_version` is the - /// pretty version that was installed before (no reference suffix — - /// drives the upgrade-vs-downgrade direction). `from_full_pretty` / - /// `to_full_pretty` are the formatted display strings used verbatim in - /// the trace output; the caller renders them via - /// [`format_update_pretty_versions`] so the SOURCE_REF / DIST_REF mode - /// switch from Composer's `UpdateOperation::format` lands on both sides. - Update { - from_version: &'a str, - from_full_pretty: &'a str, - to_full_pretty: &'a str, - package: &'a LockedPackage, - }, - /// Mark an alias of a real package as installed. No filesystem effects — - /// only the trace recorder needs this. Mirrors Composer's - /// `MarkAliasInstalledOperation`. - MarkAliasInstalled { - /// The alias entry from `composer.lock`'s `aliases[]` block. Carries - /// pretty + normalized alias version and the target's pretty version. - alias: &'a LockAlias, - /// The target package the alias points at — used to source the - /// reference suffix for the trace line. - target: &'a LockedPackage, - }, - /// Mark a previously-installed alias as uninstalled. No filesystem - /// effects — only the trace recorder cares. Mirrors Composer's - /// `MarkAliasUninstalledOperation`. Composer derives the AliasPackage - /// from the previous installed.json entries (via `extra.branch-alias`), - /// then emits this when the alias is no longer in the result. Caller - /// pre-renders the display strings so this variant doesn't need to know - /// how to spelunk the entry. - MarkAliasUninstalled { - /// Package name (e.g. `a/a`) used as both the alias's name and the - /// target's name on the trace line. - name: &'a str, - /// Alias's full-pretty form (alias pretty version plus reference - /// suffix), e.g. `1.0.x-dev master`. - alias_full: &'a str, - /// Target's full-pretty form, e.g. `dev-master master`. - target_full: &'a str, - }, -} - -impl<'a> PackageOperation<'a> { - pub fn package(&self) -> Option<&'a LockedPackage> { - match self { - PackageOperation::Install { package } | PackageOperation::Update { package, .. } => { - Some(package) - } - PackageOperation::MarkAliasInstalled { .. } - | PackageOperation::MarkAliasUninstalled { .. } => None, - } - } -} - -/// Mirror Composer's `BasePackage::getFullPrettyVersion()` for a `LockedPackage`. -/// -/// For dev-stability versions backed by a git/hg source, append the reference -/// (truncated to 7 chars when it looks like a 40-char sha1). Otherwise return -/// the pretty version unchanged. -pub fn format_full_pretty_version(pkg: &LockedPackage) -> String { - format_full_pretty_with_pretty(&pkg.version, pkg) -} - -/// Same as [`format_full_pretty_version`] but lets the caller supply an -/// alternate pretty version (used by `MarkAliasInstalled` so the alias's -/// `3.2.x-dev` text is rendered with the *target's* reference). -pub fn format_full_pretty_with_pretty(pretty_version: &str, pkg: &LockedPackage) -> String { - let source_ref = pkg.source.as_ref().and_then(|s| s.reference.as_deref()); - let dist_ref = pkg.dist.as_ref().and_then(|d| d.reference.as_deref()); - let source_type = pkg.source.as_ref().map(|s| s.source_type.as_str()); - format_full_pretty_with_refs( - pretty_version, - &pkg.version, - source_ref, - dist_ref, - source_type, - ) -} - -/// Render an alias's full pretty version: the alias's own pretty form for -/// the visible text, the alias's *normalized* version for the dev-stability -/// gate, and the target package's source/dist references for the suffix. -/// Mirrors `AliasPackage::getFullPrettyVersion`, where the alias decides on -/// its own whether to append a reference based on its own stability — so a -/// stable alias like `1.0.0` skips the suffix even when the target is a dev -/// branch. -pub fn format_full_pretty_alias( - alias_pretty: &str, - alias_version: &str, - target: &LockedPackage, -) -> String { - let source_ref = target.source.as_ref().and_then(|s| s.reference.as_deref()); - let dist_ref = target.dist.as_ref().and_then(|d| d.reference.as_deref()); - let source_type = target.source.as_ref().map(|s| s.source_type.as_str()); - format_full_pretty_with_refs( - alias_pretty, - alias_version, - source_ref, - dist_ref, - source_type, - ) -} - -/// Same as [`format_full_pretty_version_for_installed`] but lets the caller -/// supply an alternate pretty version. Used when emitting -/// `MarkAliasUninstalled`: the alias's `1.0.x-dev` text needs to be rendered -/// with the *target installed entry's* reference suffix. -pub fn format_full_pretty_with_pretty_for_installed( - pretty_version: &str, - entry: &InstalledPackageEntry, -) -> String { - let source_ref = entry - .source - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let dist_ref = entry - .dist - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let source_type = entry - .source - .as_ref() - .and_then(|v| v.get("type")) - .and_then(|v| v.as_str()); - format_full_pretty_with_refs( - pretty_version, - &entry.version, - source_ref, - dist_ref, - source_type, - ) -} - -/// Mirror Composer's `BasePackage::getFullPrettyVersion()` for an -/// `InstalledPackageEntry`. Same display rules as -/// [`format_full_pretty_version`] but pulls source/dist info out of the -/// installed.json `source`/`dist` JSON values. -pub fn format_full_pretty_version_for_installed(entry: &InstalledPackageEntry) -> String { - format_full_pretty_with_pretty_for_installed(&entry.version, entry) -} - -/// Render the from/to display strings for an update trace line, mirroring -/// Composer's `UpdateOperation::format`. Defaults to `DISPLAY_SOURCE_REF_IF_DEV`, -/// then if both sides render identically: -/// -/// - source references differ → re-render in `DISPLAY_SOURCE_REF` mode, -/// - else dist references differ → re-render in `DISPLAY_DIST_REF` mode. -/// -/// Without the switch, two same-version-different-reference packages would -/// produce a useless `pkg (X => X)` trace line. -pub fn format_update_pretty_versions( - from_entry: &InstalledPackageEntry, - to_pkg: &LockedPackage, -) -> (String, String) { - let from_default = format_full_pretty_version_for_installed(from_entry); - let to_default = format_full_pretty_version(to_pkg); - if from_default != to_default { - return (from_default, to_default); - } - - let from_source_ref = from_entry - .source - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let from_source_type = from_entry - .source - .as_ref() - .and_then(|v| v.get("type")) - .and_then(|v| v.as_str()); - let to_source_ref = to_pkg.source.as_ref().and_then(|s| s.reference.as_deref()); - let to_source_type = to_pkg.source.as_ref().map(|s| s.source_type.as_str()); - - if from_source_ref != to_source_ref { - return ( - format_with_explicit_reference(&from_entry.version, from_source_ref, from_source_type), - format_with_explicit_reference(&to_pkg.version, to_source_ref, to_source_type), - ); - } - - let from_dist_ref = from_entry - .dist - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let to_dist_ref = to_pkg.dist.as_ref().and_then(|d| d.reference.as_deref()); - - if from_dist_ref != to_dist_ref { - return ( - format_with_explicit_reference(&from_entry.version, from_dist_ref, from_source_type), - format_with_explicit_reference(&to_pkg.version, to_dist_ref, to_source_type), - ); - } - - (from_default, to_default) -} - -/// Render `pretty_version` with an explicitly chosen reference, mirroring -/// Composer's `BasePackage::getFullPrettyVersion` with `DISPLAY_SOURCE_REF` -/// or `DISPLAY_DIST_REF`: skip the dev-stability gate, just truncate sha1 -/// references and concatenate. A `None` reference falls back to the bare -/// pretty version. -fn format_with_explicit_reference( - pretty_version: &str, - reference: Option<&str>, - source_type: Option<&str>, -) -> String { - let Some(reference) = reference else { - return pretty_version.to_string(); - }; - if matches!(source_type, Some("svn")) { - return format!("{} {}", pretty_version, reference); - } - if reference.len() == 40 { - return format!("{} {}", pretty_version, &reference[..7]); - } - format!("{} {}", pretty_version, reference) -} - -/// Core of `BasePackage::getFullPrettyVersion()` factored over raw -/// fields so both [`LockedPackage`] and [`InstalledPackageEntry`] can share -/// the rendering logic. `version` drives the dev-stability check; the result -/// is `pretty_version` plus a reference suffix when the package is a dev -/// branch backed by git/hg (with sha1 references truncated to 7 chars). -fn format_full_pretty_with_refs( - pretty_version: &str, - version: &str, - source_ref: Option<&str>, - dist_ref: Option<&str>, - source_type: Option<&str>, -) -> String { - let is_dev = mozart_semver::Version::parse(version) - .map(|v| matches!(v.pre_release.as_deref(), Some("dev")) || v.is_dev_branch) - .unwrap_or(false); - if !is_dev { - return pretty_version.to_string(); - } - // Composer falls back to dist reference only when no source type is set - // (or the package isn't git/hg — in which case the dev display is skipped - // entirely above). - let reference = source_ref.or(match source_type { - Some("git") | Some("hg") => None, - _ => dist_ref, - }); - let Some(reference) = reference else { - return pretty_version.to_string(); - }; - if matches!(source_type, Some("git") | Some("hg")) && reference.len() == 40 { - format!("{} {}", pretty_version, &reference[..7]) - } else if matches!(source_type, Some("svn")) { - // svn references are revision numbers, never truncated - format!("{} {}", pretty_version, reference) - } else if reference.len() == 40 { - // dist-ref fallback (no git/hg source) — Composer truncates here too - format!("{} {}", pretty_version, &reference[..7]) - } else { - format!("{} {}", pretty_version, reference) - } -} - -/// Per-call configuration shared across executor methods. Owned by the -/// caller (typically `install_from_lock`) so the executor sees a consistent -/// view across an entire install/update run. -#[derive(Debug, Clone)] -pub struct ExecuteContext { - pub vendor_dir: PathBuf, - /// Suppress download progress bars. - pub no_progress: bool, - /// Prefer cloning from VCS source over downloading dist archives. - pub prefer_source: bool, -} - -/// Side-effect surface for install/update/uninstall operations. -/// -/// Implementations are stateful — `&mut self` lets a recorder accumulate -/// trace lines and lets the filesystem implementation hold long-lived -/// handles (caches, progress bars). All methods return `anyhow::Result` so -/// callers can short-circuit on the first failure, mirroring Composer's -/// fail-fast `InstallationManager::execute`. -#[async_trait::async_trait] -pub trait InstallerExecutor: Send + Sync { - /// Perform side effects for one install or update operation. - async fn install_package( - &mut self, - op: PackageOperation<'_>, - ctx: &ExecuteContext, - ) -> anyhow::Result<()>; - - /// Perform side effects for one uninstall. - /// - /// `version` is the previously-installed version (from installed.json), - /// passed so the trace recorder can format Composer's - /// `Uninstalling pkg/name (version)` line. The filesystem implementation - /// ignores it — `name` alone is enough to locate the vendor directory. - fn uninstall_package( - &mut self, - name: &str, - version: &str, - ctx: &ExecuteContext, - ) -> anyhow::Result<()>; - - /// Hook called once after every uninstall has run. Default no-op. - /// Composer cleans up empty namespace directories here; the recorder - /// has no work to do. - fn cleanup_after_uninstalls(&mut self, _ctx: &ExecuteContext) -> anyhow::Result<()> { - Ok(()) - } -} diff --git a/crates/mozart-registry/src/installer_executor/trace_recorder.rs b/crates/mozart-registry/src/installer_executor/trace_recorder.rs deleted file mode 100644 index b60a869..0000000 --- a/crates/mozart-registry/src/installer_executor/trace_recorder.rs +++ /dev/null @@ -1,160 +0,0 @@ -//! Recording-only [`InstallerExecutor`] for in-process tests. -//! -//! Mirrors `Composer\Test\Mock\InstallationManagerMock` — every call appends -//! a string to a `Vec<String>` matching Composer's -//! `(string) $operation` output (after `strip_tags`). No filesystem or -//! network I/O happens. The recorded trace is what tests assert against -//! `--EXPECT--` in Composer's `.test` fixture format. -//! -//! Trace line shapes (byte-equivalent to Composer's `*Operation::__toString` -//! after `strip_tags`): -//! -//! - Install: `Installing <name> (<version>)` -//! - Update (upgrade direction): `Upgrading <name> (<oldVersion> => <newVersion>)` -//! - Update (downgrade direction): `Downgrading <name> (<oldVersion> => <newVersion>)` -//! - Uninstall: `Removing <name> (<version>)` - -use mozart_semver::Version; - -use super::{ - ExecuteContext, InstallerExecutor, PackageOperation, format_full_pretty_alias, - format_full_pretty_version, -}; - -/// Recording-only executor. Construct with [`TraceRecorderExecutor::new`], -/// then read [`TraceRecorderExecutor::trace`] after the run completes. -pub struct TraceRecorderExecutor { - trace: Vec<String>, -} - -impl TraceRecorderExecutor { - pub fn new() -> Self { - Self { trace: Vec::new() } - } - - /// Recorded operation strings, in the order [`InstallerExecutor`] was - /// invoked. Pass this to `assert_eq!` against the fixture's `--EXPECT--` - /// section after splitting on newlines. - pub fn trace(&self) -> &[String] { - &self.trace - } - - /// Take ownership of the recorded trace. Use after the run if the - /// executor is going out of scope. - pub fn into_trace(self) -> Vec<String> { - self.trace - } -} - -impl Default for TraceRecorderExecutor { - fn default() -> Self { - Self::new() - } -} - -#[async_trait::async_trait] -impl InstallerExecutor for TraceRecorderExecutor { - async fn install_package( - &mut self, - op: PackageOperation<'_>, - _ctx: &ExecuteContext, - ) -> anyhow::Result<()> { - match op { - PackageOperation::Install { package } => { - self.trace.push(format!( - "Installing {} ({})", - package.name, - format_full_pretty_version(package) - )); - } - PackageOperation::Update { - from_version, - from_full_pretty, - to_full_pretty, - package, - } => { - let action = if is_upgrade(from_version, &package.version) { - "Upgrading" - } else { - "Downgrading" - }; - self.trace.push(format!( - "{} {} ({} => {})", - action, package.name, from_full_pretty, to_full_pretty - )); - } - PackageOperation::MarkAliasInstalled { alias, target } => { - let alias_full = - format_full_pretty_alias(&alias.alias, &alias.alias_normalized, target); - let target_full = format_full_pretty_version(target); - self.trace.push(format!( - "Marking {} ({}) as installed, alias of {} ({})", - alias.package, alias_full, alias.package, target_full - )); - } - PackageOperation::MarkAliasUninstalled { - name, - alias_full, - target_full, - } => { - self.trace.push(format!( - "Marking {} ({}) as uninstalled, alias of {} ({})", - name, alias_full, name, target_full - )); - } - } - Ok(()) - } - - fn uninstall_package( - &mut self, - name: &str, - version: &str, - _ctx: &ExecuteContext, - ) -> anyhow::Result<()> { - self.trace.push(format!("Removing {} ({})", name, version)); - Ok(()) - } -} - -/// Mirrors `Composer\Package\Version\VersionParser::isUpgrade`. Returns true -/// when `to` should be treated as an upgrade from `from` for the purpose of -/// the trace verb (`Upgrading` vs `Downgrading`). -/// -/// The rules: -/// 1. Same string → upgrade. -/// 2. `dev-master` / `dev-trunk` / `dev-default` substitute to the -/// `9999999-dev` default-branch alias before further checks (they are -/// not literal dev-* names; they are the conventional "latest" branch). -/// 3. After that substitution, if either side starts with `dev-` (i.e. is -/// a dev branch other than the defaults) → upgrade. Composer treats -/// hopping between dev branches as a forward move regardless of order. -/// 4. Otherwise sort numerically and check the original `from` ended up -/// first (= the smaller value). -fn is_upgrade(from: &str, to: &str) -> bool { - if from == to { - return true; - } - let original_from = from; - let normalize_default = |s: &str| -> String { - if matches!(s, "dev-master" | "dev-trunk" | "dev-default") { - "9999999-dev".to_string() - } else { - s.to_string() - } - }; - let from_norm = normalize_default(from); - let to_norm = normalize_default(to); - if from_norm.starts_with("dev-") || to_norm.starts_with("dev-") { - return true; - } - match (Version::parse(&from_norm), Version::parse(&to_norm)) { - (Ok(a), Ok(b)) => b >= a, - _ => { - // Mirror Composer's fall-through: with two unparseable strings - // there is nothing to compare, treat the move as an upgrade. - let _ = original_from; - true - } - } -} diff --git a/crates/mozart-registry/src/installer_executor/transaction.rs b/crates/mozart-registry/src/installer_executor/transaction.rs deleted file mode 100644 index 95f9718..0000000 --- a/crates/mozart-registry/src/installer_executor/transaction.rs +++ /dev/null @@ -1,411 +0,0 @@ -//! Transaction computation — lock-vs-installed diff and alias reconciliation. -//! -//! Mirrors `Composer\DependencyResolver\Transaction::calculateOperations` and -//! `Composer\Installer\InstalledFilesystemRepository` (the `ArrayDumper` -//! path). Kept separate so both `install` and `update` commands can share the -//! same operation-computation machinery without going through the `install` -//! command module. - -use crate::installed::{InstalledPackageEntry, InstalledPackages}; -use crate::lockfile::{LockFile, LockedPackage}; -use indexmap::IndexSet; -use std::path::Path; - -/// The action to take for a package during install. -#[derive(Debug, PartialEq, Eq)] -pub enum Action { - Install, - Update, - Skip, -} - -/// Compute install operations by comparing locked packages against installed packages. -/// -/// Returns `(ops, removals)` where: -/// - `ops`: list of `(package, action)` ordered topologically — every package's -/// lock-internal `require` deps appear before it, matching Composer's -/// `Transaction::calculateOperations`. -/// - `removals`: list of package names that are installed but not locked. -pub fn compute_operations<'a>( - locked: &[&'a LockedPackage], - installed: &InstalledPackages, -) -> (Vec<(&'a LockedPackage, Action)>, Vec<String>) { - let ordered = topological_sort(locked); - - let mut ops: Vec<(&'a LockedPackage, Action)> = Vec::new(); - for pkg in ordered { - let installed_entry = installed - .packages - .iter() - .find(|p| p.name.eq_ignore_ascii_case(&pkg.name)); - let action = match installed_entry { - None => Action::Install, - Some(entry) if entry.version != pkg.version => Action::Update, - Some(entry) if !installed_refs_match_locked(entry, pkg) => Action::Update, - Some(entry) if !installed_abandoned_matches_locked(entry, pkg) => Action::Update, - Some(_) => Action::Skip, - }; - ops.push((pkg, action)); - } - - // Compute removals: packages in installed but not in locked. Iterate - // installed.json in reverse, mirroring Composer's - // `Transaction::calculateOperations`, which seeds `removeMap` from - // `presentPackages` in order and then `array_unshift`s each entry onto - // `operations` — flipping the iteration order. - let locked_names: IndexSet<String> = locked.iter().map(|p| p.name.to_lowercase()).collect(); - let removals: Vec<String> = installed - .packages - .iter() - .rev() - .filter(|p| !locked_names.contains(&p.name.to_lowercase())) - .map(|p| p.name.clone()) - .collect(); - - (ops, removals) -} - -/// Order a slice of locked packages so every package's `require` deps that -/// are present in the same slice come before it. Mirrors -/// `Composer\DependencyResolver\Transaction::calculateOperations` — the -/// stack-based DFS over the result map. -fn topological_sort<'a>(packages: &[&'a LockedPackage]) -> Vec<&'a LockedPackage> { - use std::collections::BTreeMap; - - // Reverse-alphabetical sort, mirroring `setResultPackageMaps`. - let mut sorted: Vec<&'a LockedPackage> = packages.to_vec(); - sorted.sort_by_key(|p| std::cmp::Reverse(p.name.to_lowercase())); - - // Multimap: name → [packages]. A package contributes itself under its - // own name *and* under every `provide`/`replace` entry. - let mut resolves: BTreeMap<String, Vec<&'a LockedPackage>> = BTreeMap::new(); - for pkg in &sorted { - let names = std::iter::once(pkg.name.to_lowercase()) - .chain(pkg.provide.keys().map(|s| s.to_lowercase())) - .chain(pkg.replace.keys().map(|s| s.to_lowercase())); - for n in names { - resolves.entry(n).or_default().push(*pkg); - } - } - - // Mirror Composer's `getRootPackages`: walk in sorted order, removing - // each package's required providers from the candidate-roots set. - let mut roots_set: IndexSet<String> = sorted.iter().map(|p| p.name.to_lowercase()).collect(); - for pkg in &sorted { - let pkg_lower = pkg.name.to_lowercase(); - if !roots_set.contains(&pkg_lower) { - continue; - } - for dep in pkg.require.keys() { - let dep_lower = dep.to_lowercase(); - if let Some(matches) = resolves.get(&dep_lower) { - for &m in matches { - let m_lower = m.name.to_lowercase(); - if m_lower != pkg_lower { - roots_set.shift_remove(&m_lower); - } - } - } - } - } - - let mut stack: Vec<&'a LockedPackage> = sorted - .iter() - .filter(|p| roots_set.contains(&p.name.to_lowercase())) - .copied() - .collect(); - - let mut visited: IndexSet<String> = IndexSet::new(); - let mut processed: IndexSet<String> = IndexSet::new(); - let mut ordered: Vec<&'a LockedPackage> = Vec::with_capacity(packages.len()); - - while let Some(pkg) = stack.pop() { - let lower = pkg.name.to_lowercase(); - if processed.contains(&lower) { - continue; - } - if !visited.contains(&lower) { - visited.insert(lower); - stack.push(pkg); - for dep in pkg.require.keys() { - let dep_lower = dep.to_lowercase(); - if let Some(matches) = resolves.get(&dep_lower) { - for &m in matches { - stack.push(m); - } - } - } - } else { - processed.insert(lower); - ordered.push(pkg); - } - } - - // Cycle / disconnected fallback: append any leftover packages. - for pkg in packages { - let lower = pkg.name.to_lowercase(); - if !processed.contains(&lower) { - processed.insert(lower); - ordered.push(*pkg); - } - } - - ordered -} - -/// Pre-rendered MarkAliasUninstalled operation. Caller pre-computes the -/// display strings so the executor call site stays simple. -pub struct StaleInstalledAlias { - pub name: String, - pub alias_full: String, - pub target_full: String, -} - -/// `(package_name_lowercase, alias_pretty)` pairs the *new* lock's packages -/// will surface — used by `compute_stale_installed_aliases` to determine which -/// currently-installed alias packages no longer have a counterpart in the new -/// lock. Mirrors `Locker::getLockedRepository` running every locked package -/// through `ArrayLoader`. -fn lock_alias_pretty_pairs(lock: &LockFile) -> std::collections::HashSet<(String, String)> { - use std::collections::HashSet; - let mut set: HashSet<(String, String)> = HashSet::new(); - for a in &lock.aliases { - set.insert((a.package.to_lowercase(), a.alias.clone())); - } - for pkg in lock - .packages - .iter() - .chain(lock.packages_dev.iter().flatten()) - { - let mut emitted_explicit = false; - if let Some(map) = pkg - .extra_fields - .get("extra") - .and_then(|e| e.get("branch-alias")) - .and_then(|b| b.as_object()) - { - for (source, target) in map { - if !source.eq_ignore_ascii_case(&pkg.version) { - continue; - } - let Some(target_str) = target.as_str() else { - continue; - }; - if !target_str.to_lowercase().ends_with("-dev") { - continue; - } - set.insert((pkg.name.to_lowercase(), target_str.to_string())); - emitted_explicit = true; - } - } - if emitted_explicit { - continue; - } - let is_default_branch = pkg - .extra_fields - .get("default-branch") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if !is_default_branch { - continue; - } - let version_lower = pkg.version.to_lowercase(); - let is_dev_branch = version_lower.starts_with("dev-") || version_lower.ends_with("-dev"); - if !is_dev_branch { - continue; - } - set.insert((pkg.name.to_lowercase(), "9999999-dev".to_string())); - } - set -} - -/// Walk every `installed.json` entry, expand its `extra.branch-alias` map, and -/// emit a [`StaleInstalledAlias`] for each whose alias version doesn't appear -/// in the new lock. Mirrors `Transaction::calculateOperations` -/// `MarkAliasUninstalledOperation` logic. -pub fn compute_stale_installed_aliases( - installed: &InstalledPackages, - lock: &LockFile, -) -> Vec<StaleInstalledAlias> { - use super::{ - format_full_pretty_version_for_installed, format_full_pretty_with_pretty_for_installed, - }; - - let preserved = lock_alias_pretty_pairs(lock); - let still_present = |name: &str, alias_pretty: &str| -> bool { - preserved.contains(&(name.to_lowercase(), alias_pretty.to_string())) - }; - let mut stale = Vec::new(); - for entry in &installed.packages { - let mut emitted_explicit = false; - if let Some(branch_alias) = entry - .extra_fields - .get("extra") - .and_then(|e| e.get("branch-alias")) - .and_then(|b| b.as_object()) - { - for (target_branch, alias_value) in branch_alias { - if entry.version != *target_branch { - continue; - } - let Some(alias_pretty) = alias_value.as_str() else { - continue; - }; - emitted_explicit = true; - if still_present(&entry.name, alias_pretty) { - continue; - } - stale.push(StaleInstalledAlias { - name: entry.name.clone(), - alias_full: format_full_pretty_with_pretty_for_installed(alias_pretty, entry), - target_full: format_full_pretty_version_for_installed(entry), - }); - } - } - - // Synthetic `9999999-dev` default-branch alias. - if emitted_explicit { - continue; - } - let is_default_branch = entry - .extra_fields - .get("default-branch") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - if !is_default_branch { - continue; - } - let version_lower = entry.version.to_lowercase(); - let is_dev_branch = version_lower.starts_with("dev-") || version_lower.ends_with("-dev"); - if !is_dev_branch { - continue; - } - const DEFAULT_BRANCH_ALIAS: &str = "9999999-dev"; - if still_present(&entry.name, DEFAULT_BRANCH_ALIAS) { - continue; - } - stale.push(StaleInstalledAlias { - name: entry.name.clone(), - alias_full: format_full_pretty_with_pretty_for_installed(DEFAULT_BRANCH_ALIAS, entry), - target_full: format_full_pretty_version_for_installed(entry), - }); - } - stale -} - -/// Collect the alias normalized-versions a previous install recorded for -/// `pkg_name`. Mirrors Composer's `presentAliasMap` seeding. -pub fn previously_installed_alias_versions( - installed: &InstalledPackages, - pkg_name: &str, -) -> Vec<String> { - let mut out = Vec::new(); - for entry in &installed.packages { - if !entry.name.eq_ignore_ascii_case(pkg_name) { - continue; - } - let version_lower = entry.version.to_lowercase(); - let is_dev_branch = version_lower.starts_with("dev-") || version_lower.ends_with("-dev"); - if !is_dev_branch { - continue; - } - - let mut emitted_explicit_alias = false; - if let Some(branch_alias_map) = entry - .extra_fields - .get("extra") - .and_then(|e| e.get("branch-alias")) - .and_then(|b| b.as_object()) - { - for (source, target) in branch_alias_map { - if !source.eq_ignore_ascii_case(&entry.version) { - continue; - } - let Some(target_str) = target.as_str() else { - continue; - }; - if !target_str.to_lowercase().ends_with("-dev") { - continue; - } - if let Some(normalized) = crate::resolver::normalize_branch_alias_target(target_str) - { - out.push(normalized); - emitted_explicit_alias = true; - } - } - } - - if !emitted_explicit_alias - && entry - .extra_fields - .get("default-branch") - .and_then(|v| v.as_bool()) - .unwrap_or(false) - { - out.push("9999999.9999999.9999999.9999999-dev".to_string()); - } - } - out -} - -/// Convert a `LockedPackage` to an `InstalledPackageEntry`. -/// -/// Mirrors Composer's `InstalledFilesystemRepository::write()` via -/// `ArrayDumper` — `extra_fields` is forwarded verbatim so flags like -/// `abandoned` and `default-branch` survive the lock → installed.json round -/// trip. -pub fn locked_to_installed_entry(pkg: &LockedPackage, _vendor_dir: &Path) -> InstalledPackageEntry { - let install_path = format!("../{}", pkg.name); - InstalledPackageEntry { - name: pkg.name.clone(), - version: pkg.version.clone(), - version_normalized: pkg.version_normalized.clone(), - source: pkg - .source - .as_ref() - .map(|s| serde_json::to_value(s).unwrap_or_default()), - dist: pkg - .dist - .as_ref() - .map(|d| serde_json::to_value(d).unwrap_or_default()), - package_type: pkg.package_type.clone(), - install_path: Some(install_path), - autoload: pkg.autoload.clone(), - aliases: vec![], - homepage: pkg.homepage.clone(), - support: pkg.support.clone(), - extra_fields: pkg.extra_fields.clone(), - } -} - -fn installed_refs_match_locked(entry: &InstalledPackageEntry, locked: &LockedPackage) -> bool { - let installed_source_ref = entry - .source - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let installed_dist_ref = entry - .dist - .as_ref() - .and_then(|v| v.get("reference")) - .and_then(|v| v.as_str()); - let locked_source_ref = locked.source.as_ref().and_then(|s| s.reference.as_deref()); - let locked_dist_ref = locked.dist.as_ref().and_then(|d| d.reference.as_deref()); - installed_source_ref == locked_source_ref && installed_dist_ref == locked_dist_ref -} - -fn abandoned_state(v: Option<&serde_json::Value>) -> (bool, Option<&str>) { - match v { - Some(serde_json::Value::Bool(b)) => (*b, None), - Some(serde_json::Value::String(s)) => (true, Some(s.as_str())), - _ => (false, None), - } -} - -fn installed_abandoned_matches_locked( - entry: &InstalledPackageEntry, - locked: &LockedPackage, -) -> bool { - abandoned_state(entry.extra_fields.get("abandoned")) - == abandoned_state(locked.extra_fields.get("abandoned")) -} diff --git a/crates/mozart-registry/src/lib.rs b/crates/mozart-registry/src/lib.rs deleted file mode 100644 index e35056c..0000000 --- a/crates/mozart-registry/src/lib.rs +++ /dev/null @@ -1,18 +0,0 @@ -pub mod advisory; -pub mod browse_repos; -pub mod cache; -pub mod composer_repo; -pub mod download_manager; -pub mod downloader; -pub mod inline_package; -pub mod installed; -pub mod installer_executor; -pub mod lockfile; -pub mod packagist; -pub mod path_repository; -pub mod repository; -pub mod repository_filter; -pub mod resolver; -pub mod vcs_bridge; -pub mod version; -pub mod version_selector; diff --git a/crates/mozart-registry/src/lockfile.rs b/crates/mozart-registry/src/lockfile.rs deleted file mode 100644 index fd6b5e3..0000000 --- a/crates/mozart-registry/src/lockfile.rs +++ /dev/null @@ -1,2037 +0,0 @@ -use crate::packagist::{PackagistDist, PackagistSource, PackagistVersion}; -use crate::repository::RepositorySet; -use crate::resolver::ResolvedPackage; -use indexmap::IndexMap; -use indexmap::IndexSet; -use mozart_core::installer::HasSuggests; -use mozart_core::package::{RawPackageData, to_json_pretty}; -use serde::{Deserialize, Serialize}; -use std::collections::{BTreeMap, VecDeque}; -use std::fs; -use std::path::Path; - -fn default_stability() -> String { - "stable".to_string() -} - -fn default_empty_object() -> serde_json::Value { - serde_json::Value::Object(serde_json::Map::new()) -} - -/// Represents the content of a composer.lock file. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LockFile { - #[serde(rename = "_readme", default = "LockFile::default_readme")] - pub readme: Vec<String>, - - /// Composer lock files written before content-hash existed (or fixtures - /// covering BC behavior) may omit this field; mirror Composer's BC support - /// in `Locker::isLocked()` by defaulting to empty. - #[serde(rename = "content-hash", default)] - pub content_hash: String, - - pub packages: Vec<LockedPackage>, - - #[serde(rename = "packages-dev")] - pub packages_dev: Option<Vec<LockedPackage>>, - - #[serde(default)] - pub aliases: Vec<LockAlias>, - - #[serde(rename = "minimum-stability", default = "default_stability")] - pub minimum_stability: String, - - #[serde(rename = "stability-flags", default = "default_empty_object")] - pub stability_flags: serde_json::Value, - - #[serde(rename = "prefer-stable", default)] - pub prefer_stable: bool, - - #[serde(rename = "prefer-lowest", default)] - pub prefer_lowest: bool, - - #[serde(default = "default_empty_object")] - pub platform: serde_json::Value, - - #[serde(rename = "platform-dev", default = "default_empty_object")] - pub platform_dev: serde_json::Value, - - #[serde(rename = "plugin-api-version", skip_serializing_if = "Option::is_none")] - pub plugin_api_version: Option<String>, -} - -/// A locked package entry in composer.lock. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LockedPackage { - pub name: String, - pub version: String, - - #[serde(rename = "version_normalized", skip_serializing_if = "Option::is_none")] - pub version_normalized: Option<String>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub source: Option<LockedSource>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub dist: Option<LockedDist>, - - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub require: BTreeMap<String, String>, - - #[serde( - rename = "require-dev", - default, - skip_serializing_if = "BTreeMap::is_empty" - )] - pub require_dev: BTreeMap<String, String>, - - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub conflict: BTreeMap<String, String>, - - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub provide: BTreeMap<String, String>, - - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub replace: BTreeMap<String, String>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub suggest: Option<BTreeMap<String, String>>, - - #[serde(rename = "type", skip_serializing_if = "Option::is_none")] - pub package_type: Option<String>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub autoload: Option<serde_json::Value>, - - #[serde(rename = "autoload-dev", skip_serializing_if = "Option::is_none")] - pub autoload_dev: Option<serde_json::Value>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub license: Option<Vec<String>>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub description: Option<String>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub homepage: Option<String>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub keywords: Option<Vec<String>>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub authors: Option<Vec<serde_json::Value>>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub support: Option<serde_json::Value>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub funding: Option<Vec<serde_json::Value>>, - - #[serde(skip_serializing_if = "Option::is_none")] - pub time: Option<String>, - - /// Catch-all for extra fields we don't explicitly model - #[serde(flatten)] - pub extra_fields: BTreeMap<String, serde_json::Value>, -} - -impl HasSuggests for LockedPackage { - fn pretty_name(&self) -> &str { - &self.name - } - - fn suggests(&self) -> Vec<(String, String)> { - self.suggest - .as_ref() - .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect()) - .unwrap_or_default() - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LockedSource { - #[serde(rename = "type")] - pub source_type: String, - pub url: String, - pub reference: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LockedDist { - #[serde(rename = "type")] - pub dist_type: String, - pub url: String, - pub reference: Option<String>, - #[serde(skip_serializing_if = "Option::is_none")] - pub shasum: Option<String>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct LockAlias { - pub package: String, - pub version: String, - pub alias: String, - pub alias_normalized: String, -} - -impl LockFile { - /// Create default readme entries. - pub fn default_readme() -> Vec<String> { - vec![ - "This file locks the dependencies of your project to a known state".to_string(), - "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies".to_string(), - "This file is @generated automatically".to_string(), - ] - } - - /// Read a composer.lock file from disk. - pub fn read_from_file(path: &Path) -> anyhow::Result<LockFile> { - let content = fs::read_to_string(path)?; - let lock: LockFile = serde_json::from_str(&content)?; - Ok(lock) - } - - /// Write a composer.lock file to disk with deterministic formatting. - pub fn write_to_file(&self, path: &Path) -> anyhow::Result<()> { - let json = to_json_pretty(self)?; - fs::write(path, json)?; - Ok(()) - } - - /// Check if the lock file is fresh (content-hash matches composer.json). - pub fn is_fresh(&self, composer_json_content: &str) -> bool { - match Self::compute_content_hash(composer_json_content) { - Ok(hash) => hash == self.content_hash, - Err(_) => false, - } - } - - /// Compute the content hash from composer.json content. - /// Matches Composer's `Locker::getContentHash()`. - pub fn compute_content_hash(composer_json_content: &str) -> anyhow::Result<String> { - let value: serde_json::Value = serde_json::from_str(composer_json_content)?; - let obj = value - .as_object() - .ok_or_else(|| anyhow::anyhow!("composer.json must be a JSON object"))?; - - // Keys that affect the content hash (Composer's relevantKeys) - let relevant_keys = [ - "name", - "version", - "require", - "require-dev", - "conflict", - "replace", - "provide", - "minimum-stability", - "prefer-stable", - "repositories", - "extra", - ]; - - // Collect relevant keys into a BTreeMap (auto-sorted by key) - let mut filtered: BTreeMap<&str, &serde_json::Value> = BTreeMap::new(); - for key in &relevant_keys { - if let Some(v) = obj.get(*key) { - filtered.insert(key, v); - } - } - - // Also include config.platform if present - if let Some(config) = obj.get("config") - && let Some(platform) = config.get("platform") - { - filtered.insert("config.platform", platform); - } - - // Encode to compact JSON - let compact = serde_json::to_string(&filtered)?; - - // Compute MD5 - let digest = md5::compute(compact.as_bytes()); - Ok(format!("{:x}", digest)) - } - - /// Check that every root `require` (and `require-dev` when `include_dev`) - /// is satisfied by the locked packages. Returns the list of bullet-prefixed - /// error lines (plus the trailing merge-conflict hint) if anything is - /// missing or mismatched, otherwise an empty vec. - /// - /// Mirrors `Composer\Package\Locker::getMissingRequirementInfo()`. - pub fn get_missing_requirement_info( - &self, - root: &mozart_core::package::RawPackageData, - include_dev: bool, - ) -> Vec<String> { - let mut messages = Vec::new(); - let mut any_missing = false; - - let base_pool: Vec<LockedSearchEntry> = self - .packages - .iter() - .map(|p| LockedSearchEntry::build(p, &self.aliases)) - .collect(); - let mut dev_pool: Vec<LockedSearchEntry> = base_pool.clone(); - if let Some(dev) = &self.packages_dev { - dev_pool.extend( - dev.iter() - .map(|p| LockedSearchEntry::build(p, &self.aliases)), - ); - } - - check_requirement_set( - &root.require, - "Required", - &base_pool, - &mut messages, - &mut any_missing, - ); - if include_dev { - check_requirement_set( - &root.require_dev, - "Required (in require-dev)", - &dev_pool, - &mut messages, - &mut any_missing, - ); - } - - if any_missing { - messages.push( - "This usually happens when composer files are incorrectly merged or the composer.json file is manually edited.".to_string(), - ); - messages.push( - "Read more about correctly resolving merge conflicts https://getcomposer.org/doc/articles/resolving-merge-conflicts.md".to_string(), - ); - messages.push( - "and prefer using the \"require\" command over editing the composer.json file directly https://getcomposer.org/doc/03-cli.md#require-r".to_string(), - ); - } - - messages - } -} - -/// A locked package paired with the additional version strings the locked -/// repository would surface for it (branch-alias targets + matching root -/// aliases from `lock.aliases`). -/// -/// Mirrors the AliasPackage entries that `Composer\Package\Locker::getLockedRepository` -/// adds alongside each locked package, so requirement checks see the same -/// version surface Composer does. -#[derive(Clone)] -struct LockedSearchEntry<'a> { - package: &'a LockedPackage, - alias_versions: Vec<String>, -} - -impl<'a> LockedSearchEntry<'a> { - fn build(package: &'a LockedPackage, root_aliases: &[LockAlias]) -> Self { - let mut alias_versions: Vec<String> = locked_package_branch_aliases(package) - .into_iter() - .map(|a| a.alias_normalized) - .collect(); - for alias in root_aliases { - if alias.package.eq_ignore_ascii_case(&package.name) - && alias.version.eq_ignore_ascii_case(&package.version) - { - alias_versions.push(alias.alias_normalized.clone()); - } - } - Self { - package, - alias_versions, - } - } -} - -/// Build the synthetic `LockAlias` entries a `dev-*` locked package contributes -/// via `extra.branch-alias`. Mirrors `Composer\Package\Loader\ArrayLoader::getBranchAlias` -/// followed by `VersionParser::normalizeBranch` — the same expansion -/// `Locker::getLockedRepository` performs when constructing AliasPackages -/// alongside each locked package. -pub fn locked_package_branch_aliases(pkg: &LockedPackage) -> Vec<LockAlias> { - let pkg_version_lower = pkg.version.to_lowercase(); - let is_dev_branch = - pkg_version_lower.starts_with("dev-") || pkg_version_lower.ends_with("-dev"); - if !is_dev_branch { - return Vec::new(); - } - let Some(extra) = pkg.extra_fields.get("extra") else { - return Vec::new(); - }; - let Some(branch_alias) = extra.get("branch-alias") else { - return Vec::new(); - }; - let Some(map) = branch_alias.as_object() else { - return Vec::new(); - }; - let mut out = Vec::new(); - for (source, target) in map.iter() { - if !source.eq_ignore_ascii_case(&pkg.version) { - continue; - } - let Some(target_str) = target.as_str() else { - continue; - }; - if !target_str.to_lowercase().ends_with("-dev") { - continue; - } - let Some(normalized) = crate::resolver::normalize_branch_alias_target(target_str) else { - continue; - }; - // Pretty-form trim: Composer's `Preg::replace('{(\.9{7})+}', '.x', ...)` - // turns the normalized form back into the wildcard form (e.g. - // `2.1.9999999.9999999-dev` → `2.1.x-dev`). For trace output we want - // the raw alias target string the package author wrote. - out.push(LockAlias { - package: pkg.name.clone(), - version: pkg.version.clone(), - alias: target_str.to_string(), - alias_normalized: normalized, - }); - } - out -} - -fn check_requirement_set( - requires: &BTreeMap<String, String>, - description: &str, - pool: &[LockedSearchEntry], - messages: &mut Vec<String>, - any_missing: &mut bool, -) { - for (name, constraint_str) in requires { - if mozart_core::platform::is_platform_package(name) { - continue; - } - if constraint_str.trim() == "self.version" { - continue; - } - - let constraint = mozart_semver::VersionConstraint::parse(constraint_str).ok(); - - let mut name_only_match: Option<&LockedPackage> = None; - let mut satisfied = false; - for entry in pool { - let pkg = entry.package; - if pkg.name != *name { - continue; - } - if name_only_match.is_none() { - name_only_match = Some(pkg); - } - let Some(ref c) = constraint else { continue }; - if let Ok(version) = mozart_semver::Version::parse(&pkg.version) - && c.matches(&version) - { - satisfied = true; - break; - } - if entry.alias_versions.iter().any(|alias| { - mozart_semver::Version::parse(alias) - .ok() - .is_some_and(|v| c.matches(&v)) - }) { - satisfied = true; - break; - } - } - - if satisfied { - continue; - } - - *any_missing = true; - if let Some(pkg) = name_only_match { - messages.push(format!( - "- {description} package \"{name}\" is in the lock file as \"{}\" but that does not satisfy your constraint \"{constraint_str}\".", - pkg.version - )); - } else { - messages.push(format!( - "- {description} package \"{name}\" is not present in the lock file." - )); - } - } -} - -/// Input for lock file generation. -pub struct LockFileGenerationRequest { - /// Resolved packages from the dependency resolver. - pub resolved_packages: Vec<ResolvedPackage>, - /// Raw composer.json content string (for content-hash computation). - pub composer_json_content: String, - /// Parsed composer.json data (for platform, minimum-stability, etc.). - pub composer_json: RawPackageData, - /// Whether require-dev was included in resolution. - pub include_dev: bool, - /// Repository set used to fetch full metadata for resolved packages - /// that aren't already covered by inline `type: package` repositories. - pub repositories: std::sync::Arc<RepositorySet>, - /// Previous `composer.lock` (when running update / require / remove). - /// For each resolved package whose name+normalized-version matches an - /// entry in this lock, the entry is copied into the new lock verbatim - /// rather than being re-fetched from the inline / composer-repo / - /// Packagist sources. Mirrors Composer's `Locker::setLockData` behaviour - /// during partial updates: lock entries are stable across updates that - /// don't touch the package, even if the upstream metadata has drifted. - pub previous_lock: Option<LockFile>, - /// Lowercase package names that were held back to their locked version - /// on a partial update — i.e. they were NOT in the CLI's allow list and - /// were re-pinned by `apply_partial_update`. For these names the lock - /// entry's metadata (source/dist references in particular) is canonical: - /// inline / composer-repo metadata may have drifted to a newer commit - /// that the partial update is explicitly choosing not to take. Mirrors - /// Composer's `PoolBuilder`, which keeps non-allow-listed packages at - /// the locked-repo entry rather than re-loading them from the inline / - /// VCS sources. - pub lock_pinned_names: indexmap::IndexSet<String>, -} - -impl LockFileGenerationRequest { - /// Look up an inline `type: package` definition for `name` (if any). - /// Returns the matching `PackagistVersion` so callers can short-circuit - /// the Packagist fetch for resolved packages that came from a `type: - /// package` repository. - fn inline_lookup(&self, name: &str, version_normalized: &str) -> Option<PackagistVersion> { - crate::inline_package::collect_inline_packages(&self.composer_json.repositories) - .into_iter() - .find(|ipkg| ipkg.name == name && ipkg.version.version_normalized == version_normalized) - .map(|ipkg| ipkg.version) - } - - /// Look up a `type: composer` repository entry for `name@version_normalized`. - /// Used to short-circuit the Packagist fetch when the resolved package came - /// from a local Composer repo (the test fixtures' file:// case). - fn composer_repo_lookup( - &self, - name: &str, - version_normalized: &str, - ) -> Option<PackagistVersion> { - crate::composer_repo::collect_composer_packages(&self.composer_json.repositories) - .into_iter() - .find(|cpkg| cpkg.name == name && cpkg.version.version_normalized == version_normalized) - .map(|cpkg| cpkg.version) - } - - /// Reuse `previous_lock` as a metadata source when no repository can - /// answer for `(name, version_normalized)`. Mirrors the slice of - /// Composer's `PoolBuilder` flow that re-loads locked-only packages - /// straight off the lock: a partial update keeping a package at its - /// locked version doesn't need to re-fetch its metadata, and the - /// repositories may no longer carry that version (e.g. an inline - /// `type: package` repo only listing the new release). - fn previous_lock_lookup( - &self, - name: &str, - version_normalized: &str, - ) -> Option<PackagistVersion> { - let prev = self.previous_lock.as_ref()?; - prev.packages - .iter() - .chain(prev.packages_dev.iter().flatten()) - .find(|p| { - p.name.eq_ignore_ascii_case(name) - && p.version_normalized - .as_deref() - .map(|v| v == version_normalized) - .unwrap_or_else(|| { - mozart_semver::Version::parse(&p.version) - .map(|v| v.to_string() == version_normalized) - .unwrap_or(false) - }) - }) - .map(locked_package_to_packagist_version) - } -} - -/// Synthesize a `PackagistVersion` from a `LockedPackage`. Used by -/// `previous_lock_lookup` so the metadata loop has a complete view even -/// when the surrounding repositories have moved on from a locked version. -fn locked_package_to_packagist_version(pkg: &LockedPackage) -> PackagistVersion { - PackagistVersion { - version: pkg.version.clone(), - version_normalized: pkg - .version_normalized - .clone() - .unwrap_or_else(|| pkg.version.clone()), - require: pkg.require.clone(), - replace: pkg.replace.clone(), - provide: pkg.provide.clone(), - conflict: pkg.conflict.clone(), - dist: pkg.dist.as_ref().map(|d| PackagistDist { - dist_type: d.dist_type.clone(), - url: d.url.clone(), - reference: d.reference.clone(), - shasum: d.shasum.clone(), - }), - source: pkg.source.as_ref().map(|s| PackagistSource { - source_type: s.source_type.clone(), - url: s.url.clone(), - reference: s.reference.clone(), - }), - require_dev: pkg.require_dev.clone(), - suggest: pkg.suggest.clone(), - package_type: pkg.package_type.clone(), - autoload: pkg.autoload.clone(), - autoload_dev: pkg.autoload_dev.clone(), - license: pkg.license.clone(), - description: pkg.description.clone(), - homepage: pkg.homepage.clone(), - keywords: pkg.keywords.clone(), - authors: pkg.authors.clone(), - support: None, - funding: None, - time: pkg.time.clone(), - extra: pkg.extra_fields.get("extra").cloned(), - notification_url: pkg - .extra_fields - .get("notification-url") - .and_then(|v| v.as_str()) - .map(String::from), - default_branch: pkg - .extra_fields - .get("default-branch") - .and_then(|v| v.as_bool()) - .unwrap_or(false), - abandoned: pkg.extra_fields.get("abandoned").cloned(), - } -} - -/// Convert a `PackagistSource` to a `LockedSource`. -fn packagist_source_to_locked(ps: &PackagistSource) -> LockedSource { - LockedSource { - source_type: ps.source_type.clone(), - url: ps.url.clone(), - reference: ps.reference.clone(), - } -} - -/// Convert a `PackagistDist` to a `LockedDist`. -fn packagist_dist_to_locked(pd: &PackagistDist) -> LockedDist { - LockedDist { - dist_type: pd.dist_type.clone(), - url: pd.url.clone(), - reference: pd.reference.clone(), - shasum: pd.shasum.clone(), - } -} - -/// Mirror Composer's `RootPackageLoader::extractReferences`: scan -/// `require`/`require-dev` for `dev-foo#hex` style constraints, returning a -/// lowercase package name → reference map. Constraints whose stability isn't -/// `dev` after stripping the reference are left out (matching the -/// `'dev' === VersionParser::parseStability(...)` guard in PHP). -fn extract_root_references( - require: &BTreeMap<String, String>, - require_dev: &BTreeMap<String, String>, -) -> BTreeMap<String, String> { - let mut out = BTreeMap::new(); - for (name, raw_constraint) in require.iter().chain(require_dev.iter()) { - if let Some(reference) = parse_inline_reference(raw_constraint) { - out.insert(name.to_lowercase(), reference); - } - } - out -} - -/// Pull the `#hex` suffix out of a single-atom dev constraint. Returns -/// `None` for non-`dev-*` / non-`*-dev` constraints, matching Composer's -/// `'{^[^,\s@]+?#([a-f0-9]+)$}'` + `parseStability == 'dev'` guard. -fn parse_inline_reference(constraint: &str) -> Option<String> { - // Strip `... as alias` first, mirroring extractReferences's - // `'{^([^,\s@]+) as .+$}'` replacement. - let core = match constraint.split(" as ").next() { - Some(c) => c.trim(), - None => constraint.trim(), - }; - let (head, hash) = core.rsplit_once('#')?; - if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) { - return None; - } - if head.contains([' ', '\t', ',', '@']) { - return None; - } - let lower = head.to_lowercase(); - if !(lower.starts_with("dev-") || lower.ends_with("-dev")) { - return None; - } - Some(hash.to_string()) -} - -/// Mirror `Composer\Package\Package::setSourceDistReferences`: rewrite both -/// source and dist references to the supplied value, and rewrite the -/// reference inside any auto-generated GitHub/GitLab/Bitbucket dist URL when -/// present. The dist reference is only written if there was already one -/// (Composer leaves `dist.reference == null` packages alone). -fn apply_reference_override(pkg: &mut LockedPackage, reference: &str) { - if let Some(source) = pkg.source.as_mut() { - source.reference = Some(reference.to_string()); - } - if let Some(dist) = pkg.dist.as_mut() { - let url_carries_known_host = matches_dist_url_with_known_host(Some(&dist.url)); - if dist.reference.is_some() || url_carries_known_host { - dist.reference = Some(reference.to_string()); - } - if url_carries_known_host { - dist.url = rewrite_known_dist_url_reference(&dist.url, reference); - } - } -} - -/// Match the bitbucket / github / gitlab dist-URL prefixes Composer -/// rewrites. Mirrors the regex -/// `{^https?://(?:(?:www\.)?bitbucket\.org|(api\.)?github\.com|(?:www\.)?gitlab\.com)/}i`. -fn matches_dist_url_with_known_host(url: Option<&str>) -> bool { - let Some(url) = url else { return false }; - let lower = url.to_lowercase(); - let stripped = lower - .strip_prefix("http://") - .or_else(|| lower.strip_prefix("https://")) - .unwrap_or(&lower); - let stripped = stripped.strip_prefix("www.").unwrap_or(stripped); - let stripped = stripped.strip_prefix("api.").unwrap_or(stripped); - stripped.starts_with("bitbucket.org/") - || stripped.starts_with("github.com/") - || stripped.starts_with("gitlab.com/") -} - -/// Substitute any 40-char hex segment surrounded by `/` or `sha=` (the -/// archive shape produced by GitHub/GitLab/Bitbucket) with the new -/// reference. Matches Composer's -/// `'{(?<=/|sha=)[a-f0-9]{40}(?=/|$)}i'` rewrite. -fn rewrite_known_dist_url_reference(url: &str, reference: &str) -> String { - let bytes = url.as_bytes(); - let mut out = String::with_capacity(url.len()); - let mut i = 0; - while i < bytes.len() { - let start = i; - let preceded_by_slash = i > 0 && bytes[i - 1] == b'/'; - let preceded_by_sha = i >= 4 && &bytes[i - 4..i] == b"sha="; - if (preceded_by_slash || preceded_by_sha) && i + 40 <= bytes.len() { - let candidate = &url[i..i + 40]; - if candidate.chars().all(|c| c.is_ascii_hexdigit()) { - let after = bytes.get(i + 40).copied(); - if after == Some(b'/') || after.is_none() { - out.push_str(reference); - i += 40; - continue; - } - } - } - out.push(url[start..].chars().next().unwrap()); - i += url[start..].chars().next().unwrap().len_utf8(); - } - out -} - -/// Convert a `PackagistVersion` to a `LockedPackage`. -fn packagist_version_to_locked_package(name: &str, pv: &PackagistVersion) -> LockedPackage { - let mut extra_fields: BTreeMap<String, serde_json::Value> = BTreeMap::new(); - - if let Some(extra) = &pv.extra { - extra_fields.insert("extra".to_string(), extra.clone()); - } - if let Some(notification_url) = &pv.notification_url { - extra_fields.insert( - "notification-url".to_string(), - serde_json::Value::String(notification_url.clone()), - ); - } - // Propagate `abandoned` so the lock (and downstream installed.json - // round-trip) preserves the package's deprecation state. Mirrors - // Composer's `ArrayDumper::dump`, which emits the field when truthy - // (`true` for "abandoned, no replacement", a string for "abandoned, - // use this instead"). `false`/null collapse to "not abandoned" and - // are dropped. - if let Some(abandoned) = &pv.abandoned { - let keep = match abandoned { - serde_json::Value::Bool(b) => *b, - serde_json::Value::String(s) => !s.is_empty(), - serde_json::Value::Null => false, - _ => true, - }; - if keep { - extra_fields.insert("abandoned".to_string(), abandoned.clone()); - } - } - // Propagate `default-branch: true` so the lock surface — and the - // installed.json round-trip — keeps the marker that drives Composer's - // synthetic `9999999-dev` alias for default-branch dev packages. - // Without this, `Locker::getLockedRepository` (which Mozart mirrors via - // `collect_stale_installed_aliases` / `lock_alias_pretty_pairs`) can't - // tell that the package's default branch is still aliased and emits a - // spurious `MarkAliasUninstalled` for the missing `9999999-dev` alias. - if pv.default_branch { - extra_fields.insert("default-branch".to_string(), serde_json::Value::Bool(true)); - } - - LockedPackage { - name: name.to_string(), - version: pv.version.clone(), - version_normalized: Some(pv.version_normalized.clone()), - source: pv.source.as_ref().map(packagist_source_to_locked), - dist: pv.dist.as_ref().map(packagist_dist_to_locked), - require: pv.require.clone(), - require_dev: pv.require_dev.clone(), - conflict: pv.conflict.clone(), - provide: pv.provide.clone(), - replace: pv.replace.clone(), - suggest: pv.suggest.clone(), - package_type: pv.package_type.clone(), - autoload: pv.autoload.clone(), - autoload_dev: pv.autoload_dev.clone(), - license: pv.license.clone(), - description: pv.description.clone(), - homepage: pv.homepage.clone(), - keywords: pv.keywords.clone(), - authors: pv.authors.clone(), - support: pv.support.clone(), - funding: pv.funding.clone(), - time: pv.time.clone(), - extra_fields, - } -} - -/// Determine which resolved packages are dev-only. -/// -/// A package is dev-only if it is NOT reachable from the non-dev dependency tree -/// (i.e., only reachable through require-dev paths). -/// -/// `requires_by_name` and `providers_by_name` are keyed by lowercase package -/// names. `providers_by_name` maps a satisfied name (own name + each `provide` -/// or `replace` target) to the list of resolved package names that satisfy it, -/// so a non-dev `require` like `provided/pkg` reaches `b/b` when `b/b` -/// declares `provide: { provided/pkg: 1.0.0 }`. -fn classify_dev_packages( - resolved: &[ResolvedPackage], - require: &BTreeMap<String, String>, - _require_dev: &BTreeMap<String, String>, - requires_by_name: &IndexMap<String, Vec<String>>, - providers_by_name: &IndexMap<String, Vec<String>>, -) -> IndexSet<String> { - // BFS from non-dev root dependencies through each package's `require` map. - // All reachable packages are production packages. - let mut production: IndexSet<String> = IndexSet::new(); - let mut queue: VecDeque<String> = VecDeque::new(); - - let visit = |name: &str, production: &mut IndexSet<String>, queue: &mut VecDeque<String>| { - let name_lower = name.to_lowercase(); - if is_platform_name(&name_lower) { - return; - } - // A required name is satisfied either by a resolved package whose own - // name matches (the common case, captured here as `providers_by_name` - // also indexes own names) or by a resolved package that provides / - // replaces it. Mirrors Composer's `extractDevPackages` second-solve - // semantics, which walks the same provide/replace edges through a - // real Solver call. - if let Some(provs) = providers_by_name.get(&name_lower) { - for prov in provs { - let prov_lower = prov.to_lowercase(); - if production.insert(prov_lower.clone()) { - queue.push_back(prov_lower); - } - } - } - }; - - for name in require.keys() { - visit(name, &mut production, &mut queue); - } - - while let Some(pkg_name) = queue.pop_front() { - if let Some(deps) = requires_by_name.get(&pkg_name) { - for dep_name in deps.clone() { - visit(&dep_name, &mut production, &mut queue); - } - } - } - - // Any resolved package not in `production` is dev-only - resolved - .iter() - .filter(|p| !production.contains(&p.name.to_lowercase())) - .map(|p| p.name.clone()) - .collect() -} - -/// Returns true if the package name is a platform package (php, ext-*, lib-*, etc.). -fn is_platform_name(name: &str) -> bool { - name == "php" - || name.starts_with("ext-") - || name.starts_with("lib-") - || name == "php-64bit" - || name == "php-ipv6" - || name == "php-zts" - || name == "php-debug" -} - -/// Extract platform requirements from a requirements map. -/// -/// Filters the map to include only platform package keys (`php`, `ext-*`, `lib-*`, etc.) -/// and returns them as a JSON object. -fn extract_platform_requirements(requirements: &BTreeMap<String, String>) -> serde_json::Value { - let map: serde_json::Map<String, serde_json::Value> = requirements - .iter() - .filter(|(k, _)| is_platform_name(k)) - .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone()))) - .collect(); - serde_json::Value::Object(map) -} - -/// Generate a complete `LockFile` from resolution results. -/// -/// This function: -/// 1. Fetches full metadata from Packagist for each resolved package -/// 2. Separates packages into production vs dev-only -/// 3. Computes the content-hash -/// 4. Assembles the complete `LockFile` struct -pub async fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow::Result<LockFile> { - // Split the resolved set into real packages and alias entries up front. - // Aliases get emitted as a separate `aliases[]` block and never enter the - // metadata fetch loop — their target package carries the real metadata. - let (real_resolved, alias_resolved): (Vec<&ResolvedPackage>, Vec<&ResolvedPackage>) = request - .resolved_packages - .iter() - .partition(|p| p.alias_of_normalized.is_none()); - - // 1. Fetch full metadata for real (non-alias) packages. - // - // Inline `type: package` repositories carry full metadata in composer.json - // — short-circuit those before hitting the network. Everything else goes - // through `RepositorySet`, which today contains only Packagist; future - // steps will move VCS / inline through the same set. - // Previous-lock relationship pass-through: when a resolved package - // matches an entry in `previous_lock` at the same name + - // version_normalized, capture the entry's relationship-shaped fields - // (require / require-dev / conflict / replace / provide / suggest). - // Composer's transaction calculates operation order using these - // relationship fields off the locked repository, so a partial update - // shouldn't refresh them from upstream metadata for packages that - // didn't move — otherwise topological_sort sees a different graph - // than Composer would. - // - // Source/dist references and version-shaped fields still come from - // the freshly-fetched metadata, so dev packages whose ref bumped (the - // resolver picked a new commit at the same version label) still get - // their ref refreshed. - struct PreservedRelationships { - require: BTreeMap<String, String>, - require_dev: BTreeMap<String, String>, - conflict: BTreeMap<String, String>, - provide: BTreeMap<String, String>, - replace: BTreeMap<String, String>, - suggest: Option<BTreeMap<String, String>>, - } - let mut preserved_rel: IndexMap<String, PreservedRelationships> = IndexMap::new(); - if let Some(prev) = &request.previous_lock { - for prev_pkg in prev - .packages - .iter() - .chain(prev.packages_dev.iter().flatten()) - { - let prev_normalized = prev_pkg.version_normalized.clone().unwrap_or_else(|| { - mozart_semver::Version::parse(&prev_pkg.version) - .map(|v| v.to_string()) - .unwrap_or_else(|_| prev_pkg.version.clone()) - }); - for pkg in &real_resolved { - if pkg.name.eq_ignore_ascii_case(&prev_pkg.name) - && pkg.version_normalized == prev_normalized - { - preserved_rel.insert( - pkg.name.clone(), - PreservedRelationships { - require: prev_pkg.require.clone(), - require_dev: prev_pkg.require_dev.clone(), - conflict: prev_pkg.conflict.clone(), - provide: prev_pkg.provide.clone(), - replace: prev_pkg.replace.clone(), - suggest: prev_pkg.suggest.clone(), - }, - ); - } - } - } - } - - let mut package_metadata: IndexMap<String, PackagistVersion> = IndexMap::new(); - let repo_set = &request.repositories; - for pkg in &real_resolved { - // For packages held back to the locked version on a partial update, - // the lock entry is the canonical metadata source. Inline / composer- - // repo / VCS sources may have moved to a newer commit that this - // partial update is explicitly choosing NOT to take, so consulting - // them first would silently bump the source/dist reference. Mirrors - // Composer's `PoolBuilder` behaviour: non-allow-listed packages keep - // the locked-repo entry rather than re-loading from upstream. - let pinned = request.lock_pinned_names.contains(&pkg.name.to_lowercase()); - if pinned - && let Some(prev) = request.previous_lock_lookup(&pkg.name, &pkg.version_normalized) - { - package_metadata.insert(pkg.name.clone(), prev); - continue; - } - - if let Some(inline) = request.inline_lookup(&pkg.name, &pkg.version_normalized) { - package_metadata.insert(pkg.name.clone(), inline); - continue; - } - - if let Some(cv) = request.composer_repo_lookup(&pkg.name, &pkg.version_normalized) { - package_metadata.insert(pkg.name.clone(), cv); - continue; - } - - if let Some(prev) = request.previous_lock_lookup(&pkg.name, &pkg.version_normalized) { - package_metadata.insert(pkg.name.clone(), prev); - continue; - } - - let queries = [crate::repository::PackageQuery { - name: pkg.name.as_str(), - constraint: None, - }]; - let results = repo_set.load_packages(&queries).await?; - let matching = results - .into_iter() - .find(|r| r.version.version_normalized == pkg.version_normalized) - .ok_or_else(|| { - anyhow::anyhow!( - "Could not find version {} for package {} in Packagist response", - pkg.version_normalized, - pkg.name - ) - })?; - package_metadata.insert(pkg.name.clone(), matching.version); - } - - // 2. Classify dev vs non-dev packages (real packages only). - let real_owned: Vec<ResolvedPackage> = real_resolved - .iter() - .map(|p| ResolvedPackage { - name: p.name.clone(), - version: p.version.clone(), - version_normalized: p.version_normalized.clone(), - is_dev: p.is_dev, - alias_of_normalized: None, - }) - .collect(); - // Build the `name → require keys` view classify_dev_packages walks. Use - // preserved-from-old-lock requires when available so a partial update - // sees the same dev-classification graph the previous lock did. - let mut requires_by_name: IndexMap<String, Vec<String>> = IndexMap::new(); - // Inverse map: `satisfied name → list of resolved packages that satisfy it`. - // A resolved package satisfies its own name plus each `provide` / `replace` - // target (Composer's `extractDevPackages` reaches the same edges through - // its second Solver run; we walk them directly during the dev BFS). - let mut providers_by_name: IndexMap<String, Vec<String>> = IndexMap::new(); - for (name, pv) in &package_metadata { - let name_lower = name.to_lowercase(); - let (require_keys, provide_keys, replace_keys): (Vec<String>, Vec<String>, Vec<String>) = - if let Some(rel) = preserved_rel.get(name) { - ( - rel.require.keys().cloned().collect(), - rel.provide.keys().cloned().collect(), - rel.replace.keys().cloned().collect(), - ) - } else { - ( - pv.require.keys().cloned().collect(), - pv.provide.keys().cloned().collect(), - pv.replace.keys().cloned().collect(), - ) - }; - requires_by_name.insert(name_lower.clone(), require_keys); - providers_by_name - .entry(name_lower.clone()) - .or_default() - .push(name_lower.clone()); - for target in provide_keys.iter().chain(replace_keys.iter()) { - providers_by_name - .entry(target.to_lowercase()) - .or_default() - .push(name_lower.clone()); - } - } - let dev_only = classify_dev_packages( - &real_owned, - &request.composer_json.require, - &request.composer_json.require_dev, - &requires_by_name, - &providers_by_name, - ); - - // 3. Build LockedPackage lists. - // - // Apply root-level `#hex` reference overrides extracted from - // `require`/`require-dev`. Mirrors Composer's - // `RootPackageLoader::extractReferences` + `PoolBuilder::loadPackage`'s - // `setSourceDistReferences` call: when the user pinned a dev package via - // `dev-main#abcd123`, the resolved package's source/dist must show that - // reference in the lock + trace, not whatever the inline metadata said. - let root_references = extract_root_references( - &request.composer_json.require, - &request.composer_json.require_dev, - ); - let mut packages: Vec<LockedPackage> = Vec::new(); - let mut packages_dev: Vec<LockedPackage> = Vec::new(); - for pkg in &real_resolved { - let pv = &package_metadata[&pkg.name]; - let mut locked = packagist_version_to_locked_package(&pkg.name, pv); - // Overlay relationship fields from the previous lock when applicable - // — the resolver's transaction-time view came from the lock, so the - // new lock should mirror those relationships even if the upstream - // metadata has drifted. - if let Some(rel) = preserved_rel.get(&pkg.name) { - locked.require = rel.require.clone(); - locked.require_dev = rel.require_dev.clone(); - locked.conflict = rel.conflict.clone(); - locked.provide = rel.provide.clone(); - locked.replace = rel.replace.clone(); - locked.suggest = rel.suggest.clone(); - } - if let Some(reference) = root_references.get(&pkg.name.to_lowercase()) { - apply_reference_override(&mut locked, reference); - } - if dev_only.contains(&pkg.name) { - packages_dev.push(locked); - } else { - packages.push(locked); - } - } - - // 4. Sort each list alphabetically by name (Composer does this) - packages.sort_by(|a, b| a.name.cmp(&b.name)); - packages_dev.sort_by(|a, b| a.name.cmp(&b.name)); - - // 5. Build the aliases[] block. Each alias entry references the target - // package (`package` + `version`) and carries the alias's pretty/normalized - // form (`alias` + `alias_normalized`). Mirrors Composer's - // `Locker::lockPackages` alias dump. - let mut alias_blocks: Vec<LockAlias> = Vec::new(); - for alias in &alias_resolved { - let target_normalized = match &alias.alias_of_normalized { - Some(t) => t.clone(), - None => continue, - }; - let target_pretty = real_resolved - .iter() - .find(|p| p.name == alias.name && p.version_normalized == target_normalized) - .map(|p| p.version.clone()) - .unwrap_or_else(|| target_normalized.clone()); - alias_blocks.push(LockAlias { - package: alias.name.clone(), - version: target_pretty, - alias: alias.version.clone(), - alias_normalized: alias.version_normalized.clone(), - }); - } - alias_blocks.sort_by(|a, b| a.package.cmp(&b.package).then(a.alias.cmp(&b.alias))); - - // 6. Compute content-hash - let content_hash = LockFile::compute_content_hash(&request.composer_json_content)?; - - // 7. Extract platform requirements - let platform = extract_platform_requirements(&request.composer_json.require); - let platform_dev = extract_platform_requirements(&request.composer_json.require_dev); - - // 8. Determine minimum-stability and prefer-stable - let minimum_stability = request - .composer_json - .minimum_stability - .clone() - .unwrap_or_else(|| "stable".to_string()); - - let prefer_stable = request - .composer_json - .extra_fields - .get("prefer-stable") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - - // 9. Assemble LockFile - Ok(LockFile { - readme: LockFile::default_readme(), - content_hash, - packages, - packages_dev: if request.include_dev { - Some(packages_dev) - } else { - Some(vec![]) - }, - aliases: alias_blocks, - minimum_stability, - stability_flags: serde_json::json!({}), - prefer_stable, - prefer_lowest: false, - platform, - platform_dev, - plugin_api_version: Some("2.6.0".to_string()), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use tempfile::tempdir; - - fn minimal_lock() -> LockFile { - LockFile { - readme: LockFile::default_readme(), - content_hash: "abc123".to_string(), - packages: vec![], - packages_dev: Some(vec![]), - aliases: vec![], - minimum_stability: "stable".to_string(), - stability_flags: serde_json::json!({}), - prefer_stable: false, - prefer_lowest: false, - platform: serde_json::json!({}), - platform_dev: serde_json::json!({}), - plugin_api_version: Some("2.6.0".to_string()), - } - } - - #[test] - fn test_roundtrip_minimal() { - let dir = tempdir().unwrap(); - let path = dir.path().join("composer.lock"); - - let lock = minimal_lock(); - lock.write_to_file(&path).unwrap(); - - let loaded = LockFile::read_from_file(&path).unwrap(); - assert_eq!(loaded.content_hash, "abc123"); - assert_eq!(loaded.minimum_stability, "stable"); - assert!(!loaded.prefer_stable); - assert_eq!(loaded.packages.len(), 0); - } - - #[test] - fn test_roundtrip_with_package() { - let dir = tempdir().unwrap(); - let path = dir.path().join("composer.lock"); - - let mut lock = minimal_lock(); - lock.packages.push(LockedPackage { - name: "monolog/monolog".to_string(), - version: "3.8.0".to_string(), - version_normalized: None, - source: None, - dist: Some(LockedDist { - dist_type: "zip".to_string(), - url: "https://example.com/monolog.zip".to_string(), - reference: Some("abc123".to_string()), - shasum: Some("".to_string()), - }), - require: BTreeMap::new(), - require_dev: BTreeMap::new(), - conflict: BTreeMap::new(), - provide: BTreeMap::new(), - replace: BTreeMap::new(), - suggest: None, - package_type: Some("library".to_string()), - autoload: None, - autoload_dev: None, - license: Some(vec!["MIT".to_string()]), - description: Some("A logging library".to_string()), - homepage: None, - keywords: None, - authors: None, - support: None, - funding: None, - time: None, - extra_fields: BTreeMap::new(), - }); - - lock.write_to_file(&path).unwrap(); - let loaded = LockFile::read_from_file(&path).unwrap(); - - assert_eq!(loaded.packages.len(), 1); - assert_eq!(loaded.packages[0].name, "monolog/monolog"); - assert_eq!(loaded.packages[0].version, "3.8.0"); - assert_eq!( - loaded.packages[0].description.as_deref(), - Some("A logging library") - ); - } - - #[test] - fn test_content_hash_deterministic() { - let composer_json = r#"{"name": "test/project", "require": {"monolog/monolog": "^3.0"}}"#; - let h1 = LockFile::compute_content_hash(composer_json).unwrap(); - let h2 = LockFile::compute_content_hash(composer_json).unwrap(); - assert_eq!(h1, h2); - assert!(!h1.is_empty()); - } - - #[test] - fn test_content_hash_changes_on_require_change() { - let composer1 = r#"{"name": "test/project", "require": {"monolog/monolog": "^3.0"}}"#; - let composer2 = r#"{"name": "test/project", "require": {"monolog/monolog": "^2.0"}}"#; - let h1 = LockFile::compute_content_hash(composer1).unwrap(); - let h2 = LockFile::compute_content_hash(composer2).unwrap(); - assert_ne!(h1, h2); - } - - #[test] - fn test_is_fresh() { - let composer_json = r#"{"name": "test/project", "require": {"php": ">=8.1"}}"#; - let hash = LockFile::compute_content_hash(composer_json).unwrap(); - - let mut lock = minimal_lock(); - lock.content_hash = hash; - - assert!(lock.is_fresh(composer_json)); - assert!(!lock.is_fresh(r#"{"name": "test/project", "require": {"php": ">=8.0"}}"#)); - } - - #[test] - fn test_default_readme() { - let readme = LockFile::default_readme(); - assert_eq!(readme.len(), 3); - assert!(readme[0].contains("locks the dependencies")); - } - - #[test] - fn parses_lock_without_content_hash() { - // Composer fixtures (and historical lock files) may omit content-hash; - // mirror Composer's BC handling by accepting it and treating the lock - // as not-fresh against any composer.json. - let raw = r#"{ - "packages": [], - "packages-dev": [], - "aliases": [], - "minimum-stability": "dev", - "stability-flags": {}, - "prefer-stable": false, - "prefer-lowest": false - }"#; - let lock: LockFile = serde_json::from_str(raw).unwrap(); - assert_eq!(lock.content_hash, ""); - assert!(!lock.is_fresh(r#"{"require": {}}"#)); - } - - fn make_packagist_version( - version: &str, - version_normalized: &str, - require: BTreeMap<String, String>, - ) -> PackagistVersion { - PackagistVersion { - version: version.to_string(), - version_normalized: version_normalized.to_string(), - require, - replace: BTreeMap::new(), - provide: BTreeMap::new(), - conflict: BTreeMap::new(), - dist: Some(crate::packagist::PackagistDist { - dist_type: "zip".to_string(), - url: format!("https://example.com/{version}.zip"), - reference: Some("deadbeef".to_string()), - shasum: Some("abc123".to_string()), - }), - source: Some(crate::packagist::PackagistSource { - source_type: "git".to_string(), - url: "https://github.com/example/pkg.git".to_string(), - reference: Some("deadbeef".to_string()), - }), - require_dev: BTreeMap::new(), - suggest: None, - package_type: Some("library".to_string()), - autoload: Some(serde_json::json!({"psr-4": {"Example\\": "src/"}})), - autoload_dev: None, - license: Some(vec!["MIT".to_string()]), - description: Some("An example package".to_string()), - homepage: Some("https://example.com".to_string()), - keywords: Some(vec!["example".to_string(), "test".to_string()]), - authors: Some(vec![ - serde_json::json!({"name": "Alice", "email": "alice@example.com"}), - ]), - support: Some(serde_json::json!({"issues": "https://github.com/example/pkg/issues"})), - funding: Some(vec![ - serde_json::json!({"type": "github", "url": "https://github.com/sponsors/alice"}), - ]), - time: Some("2024-01-15T10:00:00+00:00".to_string()), - extra: Some(serde_json::json!({"branch-alias": {"dev-main": "1.0.x-dev"}})), - notification_url: Some("https://packagist.org/downloads/".to_string()), - default_branch: false, - abandoned: None, - } - } - - #[test] - fn test_packagist_version_to_locked_package() { - let pv = make_packagist_version("1.2.3", "1.2.3.0", BTreeMap::new()); - let locked = packagist_version_to_locked_package("example/pkg", &pv); - - assert_eq!(locked.name, "example/pkg"); - assert_eq!(locked.version, "1.2.3"); - assert_eq!(locked.version_normalized.as_deref(), Some("1.2.3.0")); - assert_eq!(locked.description.as_deref(), Some("An example package")); - assert_eq!(locked.homepage.as_deref(), Some("https://example.com")); - assert_eq!( - locked.license.as_deref(), - Some(vec!["MIT".to_string()].as_slice()) - ); - assert_eq!( - locked.keywords.as_deref(), - Some(["example".to_string(), "test".to_string()].as_slice()) - ); - assert_eq!(locked.package_type.as_deref(), Some("library")); - assert!(locked.autoload.is_some()); - assert!(locked.authors.is_some()); - assert!(locked.support.is_some()); - assert!(locked.funding.is_some()); - assert_eq!(locked.time.as_deref(), Some("2024-01-15T10:00:00+00:00")); - - // Check dist - let dist = locked.dist.as_ref().unwrap(); - assert_eq!(dist.dist_type, "zip"); - assert_eq!(dist.reference.as_deref(), Some("deadbeef")); - assert_eq!(dist.shasum.as_deref(), Some("abc123")); - - // Check source - let source = locked.source.as_ref().unwrap(); - assert_eq!(source.source_type, "git"); - assert_eq!(source.reference.as_deref(), Some("deadbeef")); - - // Check extra_fields (extra and notification-url) - assert!(locked.extra_fields.contains_key("extra")); - assert!(locked.extra_fields.contains_key("notification-url")); - assert_eq!( - locked.extra_fields["notification-url"], - serde_json::Value::String("https://packagist.org/downloads/".to_string()) - ); - } - - #[test] - fn test_packagist_version_to_locked_package_no_optional_fields() { - let pv = PackagistVersion { - version: "1.0.0".to_string(), - version_normalized: "1.0.0.0".to_string(), - require: BTreeMap::new(), - replace: BTreeMap::new(), - provide: BTreeMap::new(), - conflict: BTreeMap::new(), - dist: None, - source: None, - require_dev: BTreeMap::new(), - suggest: None, - package_type: None, - autoload: None, - autoload_dev: None, - license: None, - description: None, - homepage: None, - keywords: None, - authors: None, - support: None, - funding: None, - time: None, - extra: None, - notification_url: None, - default_branch: false, - abandoned: None, - }; - - let locked = packagist_version_to_locked_package("vendor/pkg", &pv); - assert_eq!(locked.name, "vendor/pkg"); - assert!(locked.dist.is_none()); - assert!(locked.source.is_none()); - assert!(locked.description.is_none()); - assert!(locked.license.is_none()); - assert!(locked.extra_fields.is_empty()); - } - - #[test] - fn test_classify_dev_packages_simple() { - // Root: require={A}, require-dev={B} - // A depends on C; B depends on D - // Expected dev-only: {B, D} - let resolved = vec![ - ResolvedPackage { - name: "vendor/a".to_string(), - version: "1.0.0".to_string(), - version_normalized: "1.0.0.0".to_string(), - is_dev: false, - alias_of_normalized: None, - }, - ResolvedPackage { - name: "vendor/b".to_string(), - version: "1.0.0".to_string(), - version_normalized: "1.0.0.0".to_string(), - is_dev: false, - alias_of_normalized: None, - }, - ResolvedPackage { - name: "vendor/c".to_string(), - version: "1.0.0".to_string(), - version_normalized: "1.0.0.0".to_string(), - is_dev: false, - alias_of_normalized: None, - }, - ResolvedPackage { - name: "vendor/d".to_string(), - version: "1.0.0".to_string(), - version_normalized: "1.0.0.0".to_string(), - is_dev: false, - alias_of_normalized: None, - }, - ]; - - let mut require = BTreeMap::new(); - require.insert("vendor/a".to_string(), "^1.0".to_string()); - - let mut require_dev = BTreeMap::new(); - require_dev.insert("vendor/b".to_string(), "^1.0".to_string()); - - let mut metadata: IndexMap<String, PackagistVersion> = IndexMap::new(); - - // A requires C - let mut a_require = BTreeMap::new(); - a_require.insert("vendor/c".to_string(), "^1.0".to_string()); - metadata.insert( - "vendor/a".to_string(), - make_packagist_version("1.0.0", "1.0.0.0", a_require), - ); - - // B requires D - let mut b_require = BTreeMap::new(); - b_require.insert("vendor/d".to_string(), "^1.0".to_string()); - metadata.insert( - "vendor/b".to_string(), - make_packagist_version("1.0.0", "1.0.0.0", b_require), - ); - - // C and D have no deps - metadata.insert( - "vendor/c".to_string(), - make_packagist_version("1.0.0", "1.0.0.0", BTreeMap::new()), - ); - metadata.insert( - "vendor/d".to_string(), - make_packagist_version("1.0.0", "1.0.0.0", BTreeMap::new()), - ); - - let requires_by_name: IndexMap<String, Vec<String>> = metadata - .iter() - .map(|(name, pv)| (name.to_lowercase(), pv.require.keys().cloned().collect())) - .collect(); - let providers_by_name: IndexMap<String, Vec<String>> = metadata - .keys() - .map(|name| { - let lower = name.to_lowercase(); - (lower.clone(), vec![lower]) - }) - .collect(); - let dev_only = classify_dev_packages( - &resolved, - &require, - &require_dev, - &requires_by_name, - &providers_by_name, - ); - - assert!(!dev_only.contains("vendor/a"), "A is a production package"); - assert!(dev_only.contains("vendor/b"), "B is dev-only"); - assert!( - !dev_only.contains("vendor/c"), - "C is reachable from A (production)" - ); - assert!( - dev_only.contains("vendor/d"), - "D is only reachable from B (dev)" - ); - } - - #[test] - fn test_classify_dev_packages_shared() { - // Root: require={A}, require-dev={B} - // Both A and B depend on C — C is NOT dev-only (reachable from production) - let resolved = vec![ - ResolvedPackage { - name: "vendor/a".to_string(), - version: "1.0.0".to_string(), - version_normalized: "1.0.0.0".to_string(), - is_dev: false, - alias_of_normalized: None, - }, - ResolvedPackage { - name: "vendor/b".to_string(), - version: "1.0.0".to_string(), - version_normalized: "1.0.0.0".to_string(), - is_dev: false, - alias_of_normalized: None, - }, - ResolvedPackage { - name: "vendor/c".to_string(), - version: "1.0.0".to_string(), - version_normalized: "1.0.0.0".to_string(), - is_dev: false, - alias_of_normalized: None, - }, - ]; - - let mut require = BTreeMap::new(); - require.insert("vendor/a".to_string(), "^1.0".to_string()); - - let mut require_dev = BTreeMap::new(); - require_dev.insert("vendor/b".to_string(), "^1.0".to_string()); - - let mut metadata: IndexMap<String, PackagistVersion> = IndexMap::new(); - - // A requires C - let mut a_require = BTreeMap::new(); - a_require.insert("vendor/c".to_string(), "^1.0".to_string()); - metadata.insert( - "vendor/a".to_string(), - make_packagist_version("1.0.0", "1.0.0.0", a_require), - ); - - // B also requires C - let mut b_require = BTreeMap::new(); - b_require.insert("vendor/c".to_string(), "^1.0".to_string()); - metadata.insert( - "vendor/b".to_string(), - make_packagist_version("1.0.0", "1.0.0.0", b_require), - ); - - // C has no deps - metadata.insert( - "vendor/c".to_string(), - make_packagist_version("1.0.0", "1.0.0.0", BTreeMap::new()), - ); - - let requires_by_name: IndexMap<String, Vec<String>> = metadata - .iter() - .map(|(name, pv)| (name.to_lowercase(), pv.require.keys().cloned().collect())) - .collect(); - let providers_by_name: IndexMap<String, Vec<String>> = metadata - .keys() - .map(|name| { - let lower = name.to_lowercase(); - (lower.clone(), vec![lower]) - }) - .collect(); - let dev_only = classify_dev_packages( - &resolved, - &require, - &require_dev, - &requires_by_name, - &providers_by_name, - ); - - assert!(!dev_only.contains("vendor/a"), "A is a production package"); - assert!(dev_only.contains("vendor/b"), "B is dev-only"); - assert!( - !dev_only.contains("vendor/c"), - "C is shared but reachable from production (A), so it's not dev-only" - ); - } - - #[test] - fn test_extract_platform_requirements() { - let mut requirements = BTreeMap::new(); - requirements.insert("php".to_string(), ">=8.1".to_string()); - requirements.insert("ext-json".to_string(), "*".to_string()); - requirements.insert("ext-mbstring".to_string(), "*".to_string()); - requirements.insert("monolog/monolog".to_string(), "^3.0".to_string()); - requirements.insert("lib-pcre".to_string(), "*".to_string()); - - let platform = extract_platform_requirements(&requirements); - let obj = platform.as_object().unwrap(); - - assert!(obj.contains_key("php"), "php should be in platform"); - assert!( - obj.contains_key("ext-json"), - "ext-json should be in platform" - ); - assert!( - obj.contains_key("ext-mbstring"), - "ext-mbstring should be in platform" - ); - assert!( - obj.contains_key("lib-pcre"), - "lib-pcre should be in platform" - ); - assert!( - !obj.contains_key("monolog/monolog"), - "monolog/monolog should NOT be in platform" - ); - assert_eq!(obj["php"], serde_json::Value::String(">=8.1".to_string())); - assert_eq!(obj["ext-json"], serde_json::Value::String("*".to_string())); - } - - #[test] - fn test_extract_platform_requirements_empty() { - let requirements = BTreeMap::new(); - let platform = extract_platform_requirements(&requirements); - assert_eq!(platform, serde_json::json!({})); - } - - #[tokio::test] - async fn test_generate_lock_file_minimal() { - let composer_json_content = - r#"{"name": "test/project", "require": {"php": ">=8.1"}}"#.to_string(); - let composer_json: RawPackageData = serde_json::from_str(&composer_json_content).unwrap(); - - let request = LockFileGenerationRequest { - resolved_packages: vec![], - composer_json_content: composer_json_content.clone(), - composer_json, - include_dev: true, - repositories: std::sync::Arc::new(RepositorySet::with_packagist( - crate::cache::Cache::new(std::env::temp_dir().join("mozart-test-cache"), false), - )), - previous_lock: None, - lock_pinned_names: IndexSet::new(), - }; - - let lock = generate_lock_file(&request).await.unwrap(); - - assert_eq!(lock.packages.len(), 0); - assert_eq!(lock.packages_dev.as_ref().unwrap().len(), 0); - assert_eq!(lock.minimum_stability, "stable"); - assert!(!lock.prefer_stable); - assert!(!lock.prefer_lowest); - assert_eq!(lock.plugin_api_version.as_deref(), Some("2.6.0")); - - // Verify content-hash matches - let expected_hash = LockFile::compute_content_hash(&composer_json_content).unwrap(); - assert_eq!(lock.content_hash, expected_hash); - - // Verify platform requirements extracted - let platform_obj = lock.platform.as_object().unwrap(); - assert!(platform_obj.contains_key("php")); - assert_eq!( - platform_obj["php"], - serde_json::Value::String(">=8.1".to_string()) - ); - } - - #[test] - fn test_lock_file_packages_sorted() { - // Verify that packages are sorted alphabetically when assembled in generate_lock_file - // We test this by constructing two LockedPackages and sorting them the same way - - let mut packages = [ - LockedPackage { - name: "vendor/zebra".to_string(), - version: "1.0.0".to_string(), - version_normalized: None, - source: None, - dist: None, - require: BTreeMap::new(), - require_dev: BTreeMap::new(), - conflict: BTreeMap::new(), - provide: BTreeMap::new(), - replace: BTreeMap::new(), - suggest: None, - package_type: None, - autoload: None, - autoload_dev: None, - license: None, - description: None, - homepage: None, - keywords: None, - authors: None, - support: None, - funding: None, - time: None, - extra_fields: BTreeMap::new(), - }, - LockedPackage { - name: "vendor/alpha".to_string(), - version: "1.0.0".to_string(), - version_normalized: None, - source: None, - dist: None, - require: BTreeMap::new(), - require_dev: BTreeMap::new(), - conflict: BTreeMap::new(), - provide: BTreeMap::new(), - replace: BTreeMap::new(), - suggest: None, - package_type: None, - autoload: None, - autoload_dev: None, - license: None, - description: None, - homepage: None, - keywords: None, - authors: None, - support: None, - funding: None, - time: None, - extra_fields: BTreeMap::new(), - }, - ]; - - packages.sort_by(|a, b| a.name.cmp(&b.name)); - - assert_eq!(packages[0].name, "vendor/alpha"); - assert_eq!(packages[1].name, "vendor/zebra"); - } - - #[tokio::test] - #[ignore] - async fn test_generate_lock_file_monolog() { - use crate::cache::Cache; - use crate::resolver::PlatformConfig; - use crate::resolver::{ResolveRequest, resolve}; - use mozart_core::package::Stability; - use std::sync::Arc; - - // Resolve monolog/monolog ^3.0 - let resolve_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 resolved = resolve(&resolve_request) - .await - .expect("Resolution should succeed"); - assert!(!resolved.is_empty()); - - let composer_json_content = - r#"{"name": "test/project", "require": {"monolog/monolog": "^3.0"}}"#.to_string(); - let composer_json: RawPackageData = serde_json::from_str(&composer_json_content).unwrap(); - - let gen_request = LockFileGenerationRequest { - resolved_packages: resolved, - composer_json_content: composer_json_content.clone(), - composer_json, - include_dev: false, - repositories: Arc::new(RepositorySet::with_packagist(Cache::new( - std::env::temp_dir().join("mozart-test-cache"), - false, - ))), - previous_lock: None, - lock_pinned_names: IndexSet::new(), - }; - - let lock = generate_lock_file(&gen_request) - .await - .expect("Lock file generation should succeed"); - - // Verify monolog is in packages - assert!( - lock.packages.iter().any(|p| p.name == "monolog/monolog"), - "monolog/monolog should be in packages" - ); - - // Verify packages are sorted alphabetically - let names: Vec<&str> = lock.packages.iter().map(|p| p.name.as_str()).collect(); - let mut sorted_names = names.clone(); - sorted_names.sort(); - assert_eq!( - names, sorted_names, - "Packages should be sorted alphabetically" - ); - - // Verify content-hash matches - let expected_hash = LockFile::compute_content_hash(&composer_json_content).unwrap(); - assert_eq!(lock.content_hash, expected_hash); - - // Verify monolog has full metadata - let monolog = lock - .packages - .iter() - .find(|p| p.name == "monolog/monolog") - .unwrap(); - assert!(monolog.dist.is_some(), "monolog should have dist info"); - assert!( - monolog.description.is_some(), - "monolog should have description" - ); - assert!(monolog.autoload.is_some(), "monolog should have autoload"); - - println!("Generated lock file with {} packages:", lock.packages.len()); - for pkg in &lock.packages { - println!(" {} {}", pkg.name, pkg.version); - } - } - - fn make_locked(name: &str, version: &str) -> LockedPackage { - LockedPackage { - name: name.to_string(), - version: version.to_string(), - version_normalized: None, - source: None, - dist: None, - require: BTreeMap::new(), - require_dev: BTreeMap::new(), - conflict: BTreeMap::new(), - provide: BTreeMap::new(), - replace: BTreeMap::new(), - suggest: None, - package_type: Some("library".to_string()), - autoload: None, - autoload_dev: None, - license: None, - description: None, - homepage: None, - keywords: None, - authors: None, - support: None, - funding: None, - time: None, - extra_fields: BTreeMap::new(), - } - } - - fn lock_with(packages: Vec<LockedPackage>, dev: Vec<LockedPackage>) -> LockFile { - LockFile { - readme: LockFile::default_readme(), - content_hash: "x".to_string(), - packages, - packages_dev: Some(dev), - aliases: vec![], - minimum_stability: "stable".to_string(), - stability_flags: serde_json::json!({}), - prefer_stable: false, - prefer_lowest: false, - platform: serde_json::json!({}), - platform_dev: serde_json::json!({}), - plugin_api_version: Some("2.6.0".to_string()), - } - } - - fn root_with_require( - require: &[(&str, &str)], - require_dev: &[(&str, &str)], - ) -> mozart_core::package::RawPackageData { - let mut root = mozart_core::package::RawPackageData::new("__root__".to_string()); - for (k, v) in require { - root.require.insert((*k).to_string(), (*v).to_string()); - } - for (k, v) in require_dev { - root.require_dev.insert((*k).to_string(), (*v).to_string()); - } - root - } - - #[test] - fn missing_requirement_info_empty_when_satisfied() { - let lock = lock_with(vec![make_locked("a/a", "1.0.0")], vec![]); - let root = root_with_require(&[("a/a", "^1.0")], &[]); - assert!(lock.get_missing_requirement_info(&root, true).is_empty()); - } - - #[test] - fn missing_requirement_info_reports_missing_package() { - let lock = lock_with(vec![], vec![]); - let root = root_with_require(&[("a/a", "^1.0")], &[]); - let info = lock.get_missing_requirement_info(&root, true); - assert_eq!( - info[0], - "- Required package \"a/a\" is not present in the lock file." - ); - assert!(info.iter().any(|m| m.contains("merge conflicts"))); - } - - #[test] - fn missing_requirement_info_reports_unsatisfied_constraint() { - let lock = lock_with(vec![make_locked("some/dep", "dev-foo")], vec![]); - let root = root_with_require(&[("some/dep", "dev-main")], &[]); - let info = lock.get_missing_requirement_info(&root, true); - assert_eq!( - info[0], - "- Required package \"some/dep\" is in the lock file as \"dev-foo\" but that does not satisfy your constraint \"dev-main\"." - ); - } - - #[test] - fn missing_requirement_info_skips_platform_packages() { - let lock = lock_with(vec![], vec![]); - let root = root_with_require(&[("php", "^8.0"), ("ext-json", "*")], &[]); - assert!(lock.get_missing_requirement_info(&root, true).is_empty()); - } - - #[test] - fn missing_requirement_info_skips_self_version() { - let lock = lock_with(vec![], vec![]); - let root = root_with_require(&[("a/a", "self.version")], &[]); - assert!(lock.get_missing_requirement_info(&root, true).is_empty()); - } - - #[test] - fn missing_requirement_info_dev_pool_includes_packages_dev() { - // require-dev "a/a" should be satisfied by an entry in packages-dev. - let lock = lock_with(vec![], vec![make_locked("a/a", "1.0.0")]); - let root = root_with_require(&[], &[("a/a", "^1.0")]); - assert!(lock.get_missing_requirement_info(&root, true).is_empty()); - } - - #[test] - fn missing_requirement_info_skips_dev_when_include_dev_false() { - // require-dev errors must NOT appear when include_dev is false (no_dev). - let lock = lock_with(vec![], vec![]); - let root = root_with_require(&[], &[("a/a", "^1.0")]); - assert!(lock.get_missing_requirement_info(&root, false).is_empty()); - } - - #[test] - fn missing_requirement_info_require_pool_excludes_packages_dev() { - // A regular require should NOT be satisfied by an entry that lives only - // in packages-dev. - let lock = lock_with(vec![], vec![make_locked("a/a", "1.0.0")]); - let root = root_with_require(&[("a/a", "^1.0")], &[]); - let info = lock.get_missing_requirement_info(&root, true); - assert_eq!( - info[0], - "- Required package \"a/a\" is not present in the lock file." - ); - } - - #[test] - fn missing_requirement_info_reports_multiple_problems() { - let lock = lock_with(vec![make_locked("some/dep", "dev-foo")], vec![]); - let root = root_with_require(&[("some/dep", "dev-main"), ("some/dep2", "dev-main")], &[]); - let info = lock.get_missing_requirement_info(&root, true); - assert!( - info.iter() - .any(|m| m.contains("some/dep") && m.contains("dev-foo") && m.contains("dev-main")) - ); - assert!( - info.iter() - .any(|m| m == "- Required package \"some/dep2\" is not present in the lock file.") - ); - } - - #[test] - fn missing_requirement_info_uses_dev_description_label() { - let lock = lock_with(vec![], vec![]); - let root = root_with_require(&[], &[("a/a", "^1.0")]); - let info = lock.get_missing_requirement_info(&root, true); - assert!(info[0].contains("Required (in require-dev) package \"a/a\"")); - } -} diff --git a/crates/mozart-registry/src/packagist.rs b/crates/mozart-registry/src/packagist.rs deleted file mode 100644 index 5c99b07..0000000 --- a/crates/mozart-registry/src/packagist.rs +++ /dev/null @@ -1,1011 +0,0 @@ -use crate::cache::Cache; -use serde::de::Deserializer; -use serde::{Deserialize, Serialize}; -use std::collections::BTreeMap; - -/// Deserialize a field that may contain the Packagist minifier sentinel `"__unset"`. -/// -/// Packagist's metadata minifier (see `composer/metadata-minifier`) encodes -/// deleted fields as the literal string `"__unset"` in version diffs. When we -/// encounter this sentinel we treat the field as absent (`None` / default). -fn deserialize_unset_as_none<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error> -where - D: Deserializer<'de>, - T: serde::de::DeserializeOwned, -{ - let value = serde_json::Value::deserialize(deserializer)?; - if value.as_str() == Some("__unset") { - return Ok(None); - } - serde_json::from_value(value).map_err(serde::de::Error::custom) -} - -/// Like [`deserialize_unset_as_none`] but returns a default `T` instead of `Option`. -fn deserialize_unset_as_default<'de, D, T>(deserializer: D) -> Result<T, D::Error> -where - D: Deserializer<'de>, - T: serde::de::DeserializeOwned + Default, -{ - let value = serde_json::Value::deserialize(deserializer)?; - if value.as_str() == Some("__unset") { - return Ok(T::default()); - } - serde_json::from_value(value).map_err(serde::de::Error::custom) -} - -#[derive(Debug, Clone, Deserialize)] -pub struct PackagistDist { - #[serde(rename = "type")] - pub dist_type: String, - pub url: String, - pub reference: Option<String>, - pub shasum: Option<String>, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct PackagistSource { - #[serde(rename = "type")] - pub source_type: String, - pub url: String, - pub reference: Option<String>, -} - -#[derive(Debug, Clone, Deserialize)] -pub struct PackagistVersion { - pub version: String, - pub version_normalized: String, - #[serde(default, deserialize_with = "deserialize_unset_as_default")] - pub require: BTreeMap<String, String>, - #[serde(default, deserialize_with = "deserialize_unset_as_default")] - pub replace: BTreeMap<String, String>, - #[serde(default, deserialize_with = "deserialize_unset_as_default")] - pub provide: BTreeMap<String, String>, - #[serde(default, deserialize_with = "deserialize_unset_as_default")] - pub conflict: BTreeMap<String, String>, - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub dist: Option<PackagistDist>, - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub source: Option<PackagistSource>, - - #[serde( - rename = "require-dev", - default, - deserialize_with = "deserialize_unset_as_default" - )] - pub require_dev: BTreeMap<String, String>, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub suggest: Option<BTreeMap<String, String>>, - - #[serde( - rename = "type", - default, - deserialize_with = "deserialize_unset_as_none" - )] - pub package_type: Option<String>, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub autoload: Option<serde_json::Value>, - - #[serde( - rename = "autoload-dev", - default, - deserialize_with = "deserialize_unset_as_none" - )] - pub autoload_dev: Option<serde_json::Value>, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub license: Option<Vec<String>>, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub description: Option<String>, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub homepage: Option<String>, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub keywords: Option<Vec<String>>, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub authors: Option<Vec<serde_json::Value>>, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub support: Option<serde_json::Value>, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub funding: Option<Vec<serde_json::Value>>, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub time: Option<String>, - - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub extra: Option<serde_json::Value>, - - #[serde( - rename = "notification-url", - default, - deserialize_with = "deserialize_unset_as_none" - )] - pub notification_url: Option<String>, - - /// `default-branch: true` marks the repository's default branch (e.g. the - /// branch returned by `git symbolic-ref HEAD`). For packages without a - /// numeric version prefix this triggers the synthetic `9999999-dev` alias - /// generation in `ArrayLoader::getBranchAlias` — see the alias loop in - /// `crate::resolver::packagist_to_pool_inputs`. - #[serde(rename = "default-branch", default)] - pub default_branch: bool, - - /// Abandonment marker. Composer accepts `abandoned: true` (no replacement - /// suggested) or `abandoned: "<replacement-package>"`. Anything else - /// (absent, `false`, empty string) means the package is active. Mirrors - /// `Composer\Package\CompletePackage::isAbandoned`. - #[serde(default, deserialize_with = "deserialize_unset_as_none")] - pub abandoned: Option<serde_json::Value>, -} - -impl PackagistVersion { - /// Extract the `extra.branch-alias` map from this version's metadata. - /// - /// Composer packages can declare branch aliases in `extra.branch-alias`: - /// ```json - /// { - /// "extra": { - /// "branch-alias": { - /// "dev-master": "2.x-dev" - /// } - /// } - /// } - /// ``` - /// - /// Returns a map from branch name (e.g. `"dev-master"`) to alias target - /// (e.g. `"2.x-dev"`). Returns an empty map when no aliases are declared. - pub fn branch_aliases(&self) -> BTreeMap<String, String> { - let Some(extra) = &self.extra else { - return BTreeMap::new(); - }; - - let Some(branch_alias) = extra.get("branch-alias") else { - return BTreeMap::new(); - }; - - let Some(map) = branch_alias.as_object() else { - return BTreeMap::new(); - }; - - map.iter() - .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) - .collect() - } -} - -/// Parse a Packagist p2 API JSON response. -/// -/// The response format is: -/// ```json -/// { -/// "packages": {"vendor/package": [...]}, -/// "minified": "composer/2.0" // optional -/// } -/// ``` -/// -/// When the `"minified"` key is present the version list is delta-encoded by -/// Composer's `MetadataMinifier`. This function transparently expands the -/// minified data before deserializing into [`PackagistVersion`] structs. -pub fn parse_p2_response(json: &str, package_name: &str) -> anyhow::Result<Vec<PackagistVersion>> { - let raw: serde_json::Value = serde_json::from_str(json)?; - - // Check whether the response is minified. - let is_minified = raw - .get("minified") - .and_then(|v| v.as_str()) - .is_some_and(|s| s == "composer/2.0"); - - // Extract the version array for the requested package. - let versions_value = raw - .get("packages") - .and_then(|p| p.get(package_name)) - .ok_or_else(|| anyhow::anyhow!("Package \"{package_name}\" not found in response"))?; - - let versions_array = versions_value - .as_array() - .ok_or_else(|| anyhow::anyhow!("Expected array for package \"{package_name}\""))?; - - // Expand minified diffs into full version objects if necessary. - let versions: Vec<serde_json::Value> = if is_minified { - mozart_metadata_minifier::expand(versions_array) - } else { - versions_array.clone() - }; - - // Deserialize the (possibly expanded) version objects. - versions - .into_iter() - .map(|v| serde_json::from_value(v).map_err(Into::into)) - .collect() -} - -/// Fetch package version metadata from the Packagist p2 API. -/// -/// The JSON response is cached on disk under the key -/// `"provider-{vendor}~{package}.json"`. Subsequent calls for the same -/// package are served from cache without a network request (unless the -/// cache is disabled). -#[tracing::instrument(skip(repo_cache))] -pub async fn fetch_package_versions( - package_name: &str, - repo_cache: &Cache, -) -> anyhow::Result<Vec<PackagistVersion>> { - // Build cache key: replace `/` with `~` per cache key convention - let cache_key = format!("provider-{}.json", package_name.replace('/', "~")); - - // Check cache first - if let Some(cached) = repo_cache.read(&cache_key) { - tracing::debug!("cache hit"); - return parse_p2_response(&cached, package_name); - } - - // Cache miss — fetch from Packagist - let url = format!("https://repo.packagist.org/p2/{package_name}.json"); - tracing::debug!(%url, "fetching package metadata"); - let client = mozart_core::http::client_builder().build()?; - let response = client.get(&url).send().await?; - tracing::debug!(status = %response.status(), "received response"); - - if !response.status().is_success() { - anyhow::bail!( - "Failed to fetch package \"{package_name}\" from Packagist (HTTP {})", - response.status() - ); - } - - let body = response.text().await?; - - // Write to cache - let _ = repo_cache.write(&cache_key, &body); - - parse_p2_response(&body, package_name) -} - -/// A single search result from the Packagist search API. -#[derive(Debug, Deserialize, Serialize, Clone)] -pub struct SearchResult { - pub name: String, - pub description: String, - pub url: String, - pub repository: Option<String>, - pub downloads: u64, - pub favers: u64, - /// Abandonment status: absent/false means active, a string indicates the - /// replacement package name, `true` means abandoned with no replacement. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub abandoned: Option<serde_json::Value>, -} - -#[derive(Debug, Deserialize)] -pub struct SearchResponse { - pub results: Vec<SearchResult>, - pub total: u64, - pub next: Option<String>, -} - -/// Maximum number of pages to fetch from the Packagist search API. -const SEARCH_MAX_PAGES: usize = 20; - -/// Percent-encode a string for use in a URL query parameter value. -fn url_encode(s: &str) -> String { - let mut encoded = String::with_capacity(s.len()); - for byte in s.bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - encoded.push(byte as char); - } - b' ' => encoded.push_str("%20"), - other => { - encoded.push_str(&format!("%{other:02X}")); - } - } - } - encoded -} - -/// Search Packagist for packages matching `query`. -/// -/// Fetches up to `SEARCH_MAX_PAGES` pages of results and returns the full list. -/// An optional `package_type` filter can narrow results (e.g. `"library"`). -#[tracing::instrument(fields(type_filter = package_type))] -pub async fn search_packages( - query: &str, - package_type: Option<&str>, -) -> anyhow::Result<(Vec<SearchResult>, u64)> { - let client = mozart_core::http::client_builder().build()?; - - let mut all_results: Vec<SearchResult> = Vec::new(); - let mut page = 1usize; - let mut next_url: Option<String> = None; - let mut total: u64 = 0; - - loop { - let response: SearchResponse = if let Some(ref url) = next_url { - tracing::debug!(%url, page, "fetching next page"); - let resp = client.get(url).send().await?; - tracing::debug!(status = %resp.status(), "received response"); - if !resp.status().is_success() { - anyhow::bail!("Packagist search request failed (HTTP {})", resp.status()); - } - resp.json().await? - } else { - let encoded_query = url_encode(query); - let mut url = format!("https://packagist.org/search.json?q={encoded_query}"); - if let Some(t) = package_type { - url.push_str("&type="); - url.push_str(&url_encode(t)); - } - - tracing::debug!(%url, "fetching search results"); - let resp = client.get(&url).send().await?; - tracing::debug!(status = %resp.status(), "received response"); - if !resp.status().is_success() { - anyhow::bail!("Packagist search request failed (HTTP {})", resp.status()); - } - resp.json().await? - }; - - if page == 1 { - total = response.total; - } - - all_results.extend(response.results); - next_url = response.next; - page += 1; - - if next_url.is_none() || page > SEARCH_MAX_PAGES { - break; - } - } - - Ok((all_results, total)) -} - -/// Response shape of `https://packagist.org/packages/list.json[?type=...]`. -#[derive(Debug, Deserialize)] -struct ListResponse { - #[serde(rename = "packageNames")] - package_names: Vec<String>, -} - -/// Fetch the full list of Packagist package names, optionally filtered by type. -/// -/// Backs Composer's `ComposerRepository::getPackageNames()` for the -/// `SEARCH_NAME` and `SEARCH_VENDOR` search modes. Cached on disk under -/// `list-packages~{type}.json` (or `list-packages~all.json` when no type -/// filter is given). -#[tracing::instrument(skip(repo_cache))] -pub async fn fetch_package_names( - package_type: Option<&str>, - repo_cache: &Cache, -) -> anyhow::Result<Vec<String>> { - let cache_key = match package_type { - Some(t) => format!("list-packages~{t}.json"), - None => "list-packages~all.json".to_string(), - }; - - if let Some(cached) = repo_cache.read(&cache_key) { - tracing::debug!("cache hit"); - let parsed: ListResponse = serde_json::from_str(&cached)?; - return Ok(parsed.package_names); - } - - let mut url = "https://packagist.org/packages/list.json".to_string(); - if let Some(t) = package_type { - url.push_str("?type="); - url.push_str(&url_encode(t)); - } - tracing::debug!(%url, "fetching package list"); - let client = mozart_core::http::client_builder().build()?; - let response = client.get(&url).send().await?; - tracing::debug!(status = %response.status(), "received response"); - - if !response.status().is_success() { - anyhow::bail!( - "Failed to fetch package list from Packagist (HTTP {})", - response.status() - ); - } - - let body = response.text().await?; - let _ = repo_cache.write(&cache_key, &body); - - let parsed: ListResponse = serde_json::from_str(&body)?; - Ok(parsed.package_names) -} - -/// Fetch the deduplicated list of Packagist vendor names. -/// -/// Mirrors Composer's `ComposerRepository::getVendorNames()` which derives -/// vendors from `getPackageNames()` (regardless of type) by stripping the -/// `/...` suffix and de-duplicating in insertion order. -#[tracing::instrument(skip(repo_cache))] -pub async fn fetch_vendor_names(repo_cache: &Cache) -> anyhow::Result<Vec<String>> { - let names = fetch_package_names(None, repo_cache).await?; - let mut seen: indexmap::IndexSet<String> = indexmap::IndexSet::new(); - for name in names { - let vendor = match name.split_once('/') { - Some((v, _)) => v.to_string(), - None => name, - }; - seen.insert(vendor); - } - Ok(seen.into_iter().collect()) -} - -/// A single security advisory from the Packagist API. -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct SecurityAdvisory { - #[serde(rename = "advisoryId")] - pub advisory_id: String, - - #[serde(rename = "packageName")] - pub package_name: String, - - #[serde(rename = "remoteId")] - pub remote_id: String, - - pub title: String, - - pub link: Option<String>, - - pub cve: Option<String>, - - /// Composer version constraint string, e.g. ">=1.0,<1.5.1|>=2.0,<2.3" - #[serde(rename = "affectedVersions")] - pub affected_versions: String, - - pub source: String, - - #[serde(rename = "reportedAt")] - pub reported_at: String, - - #[serde(rename = "composerRepository")] - pub composer_repository: Option<String>, - - pub severity: Option<String>, - - #[serde(default)] - pub sources: Vec<AdvisorySource>, -} - -/// A source entry within a security advisory. -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct AdvisorySource { - pub name: String, - #[serde(rename = "remoteId")] - pub remote_id: String, -} - -/// Response from POST `https://packagist.org/api/security-advisories/`. -#[derive(Debug, Deserialize)] -pub struct SecurityAdvisoriesResponse { - pub advisories: BTreeMap<String, Vec<SecurityAdvisory>>, -} - -/// Fetch security advisories for the given package names from the Packagist API. -/// -/// Sends a POST request to `https://packagist.org/api/security-advisories/` -/// with form-encoded package names. Returns advisories grouped by package name. -/// -/// If the package list is very large (500+), requests are batched in chunks of -/// 500 names per request and the results are merged. -#[tracing::instrument(skip(package_names), fields(package_count = package_names.len()))] -pub async fn fetch_security_advisories( - package_names: &[&str], -) -> anyhow::Result<BTreeMap<String, Vec<SecurityAdvisory>>> { - let client = mozart_core::http::client_builder().build()?; - - let mut all_advisories: BTreeMap<String, Vec<SecurityAdvisory>> = BTreeMap::new(); - - for chunk in package_names.chunks(500) { - // Build an application/x-www-form-urlencoded body manually. - // Each package is encoded as `packages[]=<name>` and joined with `&`. - let body: String = chunk - .iter() - .map(|name| format!("packages[]={}", url_encode(name))) - .collect::<Vec<_>>() - .join("&"); - - tracing::debug!(chunk_size = chunk.len(), "fetching security advisories"); - let response = client - .post("https://packagist.org/api/security-advisories/") - .header("Content-Type", "application/x-www-form-urlencoded") - .body(body) - .send() - .await?; - tracing::debug!(status = %response.status(), "received response"); - - if !response.status().is_success() { - anyhow::bail!( - "Packagist security advisories request failed (HTTP {})", - response.status() - ); - } - - let parsed: SecurityAdvisoriesResponse = response.json().await?; - - for (pkg_name, advisories) in parsed.advisories { - if !advisories.is_empty() { - all_advisories - .entry(pkg_name) - .or_default() - .extend(advisories); - } - } - } - - Ok(all_advisories) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn parse_p2_response_basic() { - let json = r#"{ - "packages": { - "monolog/monolog": [ - { - "version": "3.8.0", - "version_normalized": "3.8.0.0", - "require": {"php": ">=8.1"}, - "dist": { - "type": "zip", - "url": "https://example.com/monolog-3.8.0.zip", - "reference": "abc123", - "shasum": "" - }, - "source": { - "type": "git", - "url": "https://github.com/Seldaek/monolog.git", - "reference": "abc123" - } - }, - { - "version": "3.7.0", - "version_normalized": "3.7.0.0", - "require": {"php": ">=8.1"} - } - ] - } - }"#; - - let versions = parse_p2_response(json, "monolog/monolog").unwrap(); - assert_eq!(versions.len(), 2); - assert_eq!(versions[0].version, "3.8.0"); - assert_eq!(versions[0].version_normalized, "3.8.0.0"); - assert_eq!(versions[0].require.get("php").unwrap(), ">=8.1"); - assert!(versions[0].dist.is_some()); - assert!(versions[0].source.is_some()); - assert_eq!(versions[1].version, "3.7.0"); - assert!(versions[1].dist.is_none()); - } - - #[test] - fn parse_p2_response_not_found() { - let json = r#"{"packages": {"other/pkg": []}}"#; - let result = parse_p2_response(json, "monolog/monolog"); - assert!(result.is_err()); - } - - #[test] - fn parse_p2_response_with_dev_version() { - let json = r#"{ - "packages": { - "test/pkg": [ - { - "version": "dev-master", - "version_normalized": "dev-master", - "require": {} - }, - { - "version": "1.0.0", - "version_normalized": "1.0.0.0", - "require": {} - } - ] - } - }"#; - - let versions = parse_p2_response(json, "test/pkg").unwrap(); - assert_eq!(versions.len(), 2); - assert_eq!(versions[0].version, "dev-master"); - assert_eq!(versions[1].version, "1.0.0"); - } - - #[test] - fn test_branch_aliases_present() { - let json = r#"{ - "packages": { - "test/pkg": [ - { - "version": "dev-master", - "version_normalized": "dev-master", - "require": {}, - "extra": { - "branch-alias": { - "dev-master": "2.x-dev" - } - } - } - ] - } - }"#; - - let versions = parse_p2_response(json, "test/pkg").unwrap(); - let aliases = versions[0].branch_aliases(); - assert_eq!(aliases.len(), 1); - assert_eq!(aliases.get("dev-master").unwrap(), "2.x-dev"); - } - - #[test] - fn test_branch_aliases_multiple() { - let json = r#"{ - "packages": { - "test/pkg": [ - { - "version": "dev-master", - "version_normalized": "dev-master", - "require": {}, - "extra": { - "branch-alias": { - "dev-master": "2.x-dev", - "dev-1.x": "1.5.x-dev" - } - } - } - ] - } - }"#; - - let versions = parse_p2_response(json, "test/pkg").unwrap(); - let aliases = versions[0].branch_aliases(); - assert_eq!(aliases.len(), 2); - assert_eq!(aliases.get("dev-master").unwrap(), "2.x-dev"); - assert_eq!(aliases.get("dev-1.x").unwrap(), "1.5.x-dev"); - } - - #[test] - fn test_branch_aliases_no_extra() { - let json = r#"{ - "packages": { - "test/pkg": [ - { - "version": "dev-master", - "version_normalized": "dev-master", - "require": {} - } - ] - } - }"#; - - let versions = parse_p2_response(json, "test/pkg").unwrap(); - let aliases = versions[0].branch_aliases(); - assert!(aliases.is_empty()); - } - - #[test] - fn test_branch_aliases_extra_without_branch_alias_key() { - let json = r#"{ - "packages": { - "test/pkg": [ - { - "version": "dev-master", - "version_normalized": "dev-master", - "require": {}, - "extra": { - "installer-name": "my-plugin" - } - } - ] - } - }"#; - - let versions = parse_p2_response(json, "test/pkg").unwrap(); - let aliases = versions[0].branch_aliases(); - assert!(aliases.is_empty()); - } - - #[test] - fn parse_p2_response_unset_fields() { - // Packagist metadata minifier uses "__unset" to mark deleted fields. - let json = r#"{ - "packages": { - "test/pkg": [ - { - "version": "2.0.0", - "version_normalized": "2.0.0.0", - "require": {"php": ">=8.1"}, - "license": ["MIT"], - "keywords": ["framework"], - "authors": [{"name": "Alice"}], - "funding": [{"type": "github", "url": "https://github.com/sponsors/alice"}] - }, - { - "version": "1.0.0", - "version_normalized": "1.0.0.0", - "license": "__unset", - "keywords": "__unset", - "authors": "__unset", - "funding": "__unset", - "require": "__unset", - "homepage": "__unset", - "description": "__unset", - "extra": "__unset", - "suggest": "__unset" - } - ] - } - }"#; - - let versions = parse_p2_response(json, "test/pkg").unwrap(); - assert_eq!(versions.len(), 2); - - // First version has normal values - assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]); - assert_eq!(versions[0].keywords.as_ref().unwrap(), &["framework"]); - - // Second version has __unset → treated as absent - assert!(versions[1].license.is_none()); - assert!(versions[1].keywords.is_none()); - assert!(versions[1].authors.is_none()); - assert!(versions[1].funding.is_none()); - assert!(versions[1].require.is_empty()); - assert!(versions[1].homepage.is_none()); - assert!(versions[1].description.is_none()); - assert!(versions[1].extra.is_none()); - assert!(versions[1].suggest.is_none()); - } - - #[test] - fn parse_p2_response_minified_expand() { - // Mirrors the Composer MetadataMinifierTest: 3 versions where only - // the first carries all fields and subsequent entries are diffs. - let json = r#"{ - "packages": { - "foo/bar": [ - { - "name": "foo/bar", - "version": "2.0.0", - "version_normalized": "2.0.0.0", - "type": "library", - "license": ["MIT"], - "require": {"php": ">=8.1"}, - "description": "A great package" - }, - { - "version": "1.2.0", - "version_normalized": "1.2.0.0", - "license": ["GPL"], - "homepage": "https://example.org" - }, - { - "version": "1.0.0", - "version_normalized": "1.0.0.0", - "homepage": "__unset" - } - ] - }, - "minified": "composer/2.0" - }"#; - - let versions = parse_p2_response(json, "foo/bar").unwrap(); - assert_eq!(versions.len(), 3); - - // Version 2.0.0 — full data (first entry). - assert_eq!(versions[0].version, "2.0.0"); - assert_eq!(versions[0].package_type.as_deref(), Some("library")); - assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]); - assert_eq!(versions[0].require.get("php").unwrap(), ">=8.1"); - assert_eq!(versions[0].description.as_deref(), Some("A great package")); - assert!(versions[0].homepage.is_none()); - - // Version 1.2.0 — inherits name, type, require, description from 2.0.0; - // license changed to GPL; homepage added. - assert_eq!(versions[1].version, "1.2.0"); - assert_eq!(versions[1].package_type.as_deref(), Some("library")); - assert_eq!(versions[1].license.as_ref().unwrap(), &["GPL"]); - assert_eq!(versions[1].require.get("php").unwrap(), ">=8.1"); - assert_eq!(versions[1].description.as_deref(), Some("A great package")); - assert_eq!(versions[1].homepage.as_deref(), Some("https://example.org")); - - // Version 1.0.0 — inherits everything from 1.2.0 except homepage - // which is __unset (deleted). - assert_eq!(versions[2].version, "1.0.0"); - assert_eq!(versions[2].package_type.as_deref(), Some("library")); - assert_eq!(versions[2].license.as_ref().unwrap(), &["GPL"]); - assert_eq!(versions[2].require.get("php").unwrap(), ">=8.1"); - assert_eq!(versions[2].description.as_deref(), Some("A great package")); - assert!(versions[2].homepage.is_none()); - } - - #[test] - fn parse_p2_response_not_minified_no_inheritance() { - // Without "minified" key, each version stands alone — no inheritance. - let json = r#"{ - "packages": { - "foo/bar": [ - { - "version": "2.0.0", - "version_normalized": "2.0.0.0", - "license": ["MIT"], - "description": "A great package" - }, - { - "version": "1.0.0", - "version_normalized": "1.0.0.0" - } - ] - } - }"#; - - let versions = parse_p2_response(json, "foo/bar").unwrap(); - assert_eq!(versions.len(), 2); - - assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]); - assert_eq!(versions[0].description.as_deref(), Some("A great package")); - - // Without minified flag, version 1.0.0 does NOT inherit from 2.0.0. - assert!(versions[1].license.is_none()); - assert!(versions[1].description.is_none()); - } - - #[test] - fn parse_p2_response_minified_single_version() { - // Edge case: minified response with only one version. - let json = r#"{ - "packages": { - "foo/bar": [ - { - "version": "1.0.0", - "version_normalized": "1.0.0.0", - "license": ["MIT"] - } - ] - }, - "minified": "composer/2.0" - }"#; - - let versions = parse_p2_response(json, "foo/bar").unwrap(); - assert_eq!(versions.len(), 1); - assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]); - } - - #[test] - fn parse_p2_response_minified_empty_versions() { - let json = r#"{ - "packages": { - "foo/bar": [] - }, - "minified": "composer/2.0" - }"#; - - let versions = parse_p2_response(json, "foo/bar").unwrap(); - assert!(versions.is_empty()); - } - - #[test] - fn parse_p2_response_minified_map_fields_inherited() { - // Verify BTreeMap fields (require, replace, etc.) are inherited. - let json = r#"{ - "packages": { - "foo/bar": [ - { - "version": "2.0.0", - "version_normalized": "2.0.0.0", - "require": {"php": ">=8.1", "ext-json": "*"}, - "replace": {"foo/old": "self.version"} - }, - { - "version": "1.0.0", - "version_normalized": "1.0.0.0", - "replace": "__unset" - } - ] - }, - "minified": "composer/2.0" - }"#; - - let versions = parse_p2_response(json, "foo/bar").unwrap(); - assert_eq!(versions.len(), 2); - - // Version 1.0.0 inherits require from 2.0.0, replace is unset. - assert_eq!(versions[1].require.get("php").unwrap(), ">=8.1"); - assert_eq!(versions[1].require.get("ext-json").unwrap(), "*"); - assert!(versions[1].replace.is_empty()); - } - - #[test] - fn test_parse_security_advisories_response() { - let json = r#"{ - "advisories": { - "monolog/monolog": [ - { - "advisoryId": "PKSA-b2m0-qqf7-qck4", - "packageName": "monolog/monolog", - "remoteId": "monolog/monolog/2017-11-13-1.yaml", - "title": "Header injection in NativeMailerHandler", - "link": "https://github.com/Seldaek/monolog/pull/683", - "cve": null, - "affectedVersions": ">=1.8.0,<1.12.0", - "source": "FriendsOfPHP/security-advisories", - "reportedAt": "2017-11-13T00:00:00+00:00", - "composerRepository": "https://packagist.org", - "severity": "low", - "sources": [ - { - "name": "FriendsOfPHP/security-advisories", - "remoteId": "monolog/monolog/2017-11-13-1.yaml" - } - ] - } - ] - } - }"#; - - let response: SecurityAdvisoriesResponse = serde_json::from_str(json).unwrap(); - assert_eq!(response.advisories.len(), 1); - let advisories = response.advisories.get("monolog/monolog").unwrap(); - assert_eq!(advisories.len(), 1); - let adv = &advisories[0]; - assert_eq!(adv.advisory_id, "PKSA-b2m0-qqf7-qck4"); - assert_eq!(adv.package_name, "monolog/monolog"); - assert_eq!(adv.title, "Header injection in NativeMailerHandler"); - assert_eq!(adv.affected_versions, ">=1.8.0,<1.12.0"); - assert_eq!(adv.severity.as_deref(), Some("low")); - assert!(adv.cve.is_none()); - assert_eq!(adv.sources.len(), 1); - assert_eq!(adv.sources[0].name, "FriendsOfPHP/security-advisories"); - } - - #[test] - fn test_parse_security_advisories_empty() { - let json = r#"{"advisories": {"other/package": []}}"#; - let response: SecurityAdvisoriesResponse = serde_json::from_str(json).unwrap(); - assert_eq!(response.advisories.len(), 1); - let advisories = response.advisories.get("other/package").unwrap(); - assert!(advisories.is_empty()); - } - - #[test] - fn test_parse_security_advisories_null_fields() { - let json = r#"{ - "advisories": { - "vendor/pkg": [ - { - "advisoryId": "PKSA-0000-0000-0000", - "packageName": "vendor/pkg", - "remoteId": "vendor/pkg/2024-01-01.yaml", - "title": "Some vulnerability", - "link": null, - "cve": null, - "affectedVersions": ">=1.0,<2.0", - "source": "FriendsOfPHP/security-advisories", - "reportedAt": "2024-01-01T00:00:00+00:00", - "composerRepository": null, - "severity": null, - "sources": [] - } - ] - } - }"#; - - let response: SecurityAdvisoriesResponse = serde_json::from_str(json).unwrap(); - let advisories = response.advisories.get("vendor/pkg").unwrap(); - assert_eq!(advisories.len(), 1); - let adv = &advisories[0]; - assert!(adv.link.is_none()); - assert!(adv.cve.is_none()); - assert!(adv.severity.is_none()); - assert!(adv.composer_repository.is_none()); - assert!(adv.sources.is_empty()); - } -} diff --git a/crates/mozart-registry/src/path_repository.rs b/crates/mozart-registry/src/path_repository.rs deleted file mode 100644 index bf71315..0000000 --- a/crates/mozart-registry/src/path_repository.rs +++ /dev/null @@ -1,243 +0,0 @@ -//! Support for `type: path` repositories. -//! -//! Mirrors `Composer\Repository\PathRepository`: a path repo points at a -//! local directory containing a `composer.json`, and the resolver loads the -//! package from that file directly. Mozart does not yet support glob URLs or -//! the `versions` / `reference: none` options — only the bare -//! `{ type: path, url: ... }` form the installer fixtures exercise. -//! -//! Resolution model: a path repo is expanded into a synthetic -//! `type: package` [`RawRepository`] whose payload is the loaded composer.json -//! plus a `dist` block. After this expansion the rest of the registry treats -//! the package the same as any inline `type: package` entry — that is the -//! whole point of doing the work here rather than threading a new repo type -//! through the resolver / lockfile. -//! -//! `dist.reference` matches Composer's `hash('sha1', $json . serialize($options))` -//! where `$options` carries the auto-detected `relative` flag (true when the -//! original URL was not absolute). The same SHA-1 ends up in the lockfile, so -//! consumers comparing references against Composer-produced lockfiles see -//! byte-identical values. - -use std::path::{Path, PathBuf}; - -use mozart_core::package::RawRepository; -use mozart_php_serialize::{Value as PhpValue, serialize as php_serialize}; -use sha1::{Digest, Sha1}; - -/// Translate path repos in `repositories` into synthetic `type: package` -/// entries. Non-path entries are returned unchanged in original order. -/// -/// `base_dir` is the directory used to resolve relative `url` values -/// (Composer's PHP code resolves these against the process cwd; in production -/// that equals the project root, in tests it equals the fixtures anchor). -/// -/// Failures (missing directory, unreadable composer.json, missing -/// `name`/`version`) drop the offending entry silently — the rest of the -/// repository list still applies. This mirrors Composer's lenient -/// PathRepository, which logs a warning and moves on rather than aborting the -/// whole resolve. -pub fn expand_path_repositories( - repositories: &[RawRepository], - base_dir: &Path, -) -> Vec<RawRepository> { - let mut out = Vec::with_capacity(repositories.len()); - for repo in repositories { - if repo.repo_type != "path" { - out.push(repo.clone()); - continue; - } - let Some(url) = repo.url.as_deref() else { - continue; - }; - let Some(synthetic) = load_path_package(url, base_dir) else { - continue; - }; - out.push(synthetic); - } - out -} - -/// Read one path repo's `composer.json` and synthesize the inline-package -/// form. Returns `None` for any I/O or parse failure (Composer behaves the -/// same — `PathRepository::initialize` skips entries whose `composer.json` -/// is missing). -fn load_path_package(url: &str, base_dir: &Path) -> Option<RawRepository> { - let resolved = resolve_path(url, base_dir); - let composer_json_path = resolved.join("composer.json"); - let json = std::fs::read_to_string(&composer_json_path).ok()?; - let mut package: serde_json::Value = serde_json::from_str(&json).ok()?; - let obj = package.as_object_mut()?; - - // `version` is mandatory in the inline-package representation: without it - // the resolver would skip the package. Composer's PathRepository falls - // back to `dev-main` when no version is declared and no VCS is present; - // mirror that so a path repo whose composer.json omits `version` still - // produces a usable entry. - if !obj.contains_key("version") { - obj.insert( - "version".to_string(), - serde_json::Value::String("dev-main".to_string()), - ); - } - - let is_relative = !Path::new(url).is_absolute(); - let reference = compute_path_reference(json.as_bytes(), is_relative); - - obj.insert( - "dist".to_string(), - serde_json::json!({ - "type": "path", - "url": url, - "reference": reference, - }), - ); - // Composer copies `symlink`/`relative` from `options` into - // `transport-options`. We have no `options` to forward today but emit an - // empty object so consumers reading the package see the same shape. - obj.entry("transport-options") - .or_insert_with(|| serde_json::json!({})); - - Some(RawRepository { - repo_type: "package".to_string(), - url: None, - package: Some(serde_json::Value::Array(vec![package])), - only: None, - exclude: None, - canonical: None, - security_advisories: None, - }) -} - -fn resolve_path(url: &str, base_dir: &Path) -> PathBuf { - let p = Path::new(url); - if p.is_absolute() { - p.to_path_buf() - } else { - base_dir.join(p) - } -} - -/// Compose the SHA-1 reference Composer uses for path repos: -/// `sha1($json . serialize(['relative' => $isRelative]))`. The `relative` -/// flag is the only option Composer's auto-detection populates when the user -/// supplied no `options` block. -fn compute_path_reference(json_bytes: &[u8], is_relative: bool) -> String { - let options = PhpValue::Array(vec![( - PhpValue::String("relative".to_string()), - PhpValue::Bool(is_relative), - )]); - let serialized = php_serialize(&options); - let mut hasher = Sha1::new(); - hasher.update(json_bytes); - hasher.update(serialized.as_bytes()); - let bytes = hasher.finalize(); - let mut hex = String::with_capacity(bytes.len() * 2); - for b in bytes { - use std::fmt::Write; - let _ = write!(&mut hex, "{:02x}", b); - } - hex -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn computes_known_reference_for_plugin_a_fixture() { - // Fixture used by partial-update-loads-root-aliases-for-path-repos.test. - // Expected reference (`b133081...`) is what PHP's - // `hash('sha1', file_get_contents($composerJson) . serialize(['relative' => true]))` - // produces for this file — pin it here so reference computation - // changes can't drift silently from Composer. - let composer_json_path = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../composer/tests/Composer/Test/Fixtures/functional/installed-versions/plugin-a/composer.json"); - let bytes = std::fs::read(&composer_json_path).expect("fixture composer.json must exist"); - let reference = compute_path_reference(&bytes, true); - assert!( - reference.starts_with("b133081"), - "unexpected reference: {reference}" - ); - } - - #[test] - fn relative_url_resolves_against_base_dir_and_emits_synthetic_package_repo() { - let temp = tempfile::tempdir().unwrap(); - std::fs::create_dir_all(temp.path().join("pkg-dir")).unwrap(); - std::fs::write( - temp.path().join("pkg-dir").join("composer.json"), - r#"{"name": "vendor/pkg", "version": "1.2.3"}"#, - ) - .unwrap(); - - let input = vec![RawRepository { - repo_type: "path".to_string(), - url: Some("pkg-dir".to_string()), - package: None, - only: None, - exclude: None, - canonical: None, - security_advisories: None, - }]; - let expanded = expand_path_repositories(&input, temp.path()); - assert_eq!(expanded.len(), 1); - assert_eq!(expanded[0].repo_type, "package"); - - let pkgs = expanded[0] - .package - .as_ref() - .expect("expanded entry must carry a package payload") - .as_array() - .expect("payload should be an array"); - assert_eq!(pkgs.len(), 1); - let pkg = &pkgs[0]; - assert_eq!(pkg["name"], "vendor/pkg"); - assert_eq!(pkg["version"], "1.2.3"); - assert_eq!(pkg["dist"]["type"], "path"); - assert_eq!(pkg["dist"]["url"], "pkg-dir"); - assert!( - pkg["dist"]["reference"] - .as_str() - .map(|s| s.len() == 40) - .unwrap_or(false), - "reference should be a 40-char SHA-1" - ); - } - - #[test] - fn missing_composer_json_drops_the_entry() { - let temp = tempfile::tempdir().unwrap(); - let input = vec![RawRepository { - repo_type: "path".to_string(), - url: Some("does-not-exist".to_string()), - package: None, - only: None, - exclude: None, - canonical: None, - security_advisories: None, - }]; - let expanded = expand_path_repositories(&input, temp.path()); - assert!(expanded.is_empty()); - } - - #[test] - fn non_path_repos_pass_through_unchanged() { - let input = vec![RawRepository { - repo_type: "vcs".to_string(), - url: Some("https://example.com/repo.git".to_string()), - package: None, - only: None, - exclude: None, - canonical: None, - security_advisories: None, - }]; - let expanded = expand_path_repositories(&input, Path::new("/tmp")); - assert_eq!(expanded.len(), 1); - assert_eq!(expanded[0].repo_type, "vcs"); - assert_eq!( - expanded[0].url.as_deref(), - Some("https://example.com/repo.git") - ); - } -} diff --git a/crates/mozart-registry/src/repository/inline_package_repo.rs b/crates/mozart-registry/src/repository/inline_package_repo.rs deleted file mode 100644 index 1043559..0000000 --- a/crates/mozart-registry/src/repository/inline_package_repo.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! [`Repository`] for inline `type: package` repositories. -//! -//! Wraps [`crate::inline_package::collect_inline_packages`]. The data is -//! embedded in `composer.json` so there's no I/O — the repo just filters -//! its in-memory list by queried name. -//! -//! Mirrors `Composer\Repository\PackageRepository` (which extends -//! `ArrayRepository`). Only the package's own `name` is matched against -//! queries — `replace`/`provide` targets are NOT advertised here, exactly -//! like Composer's `ArrayRepository::loadPackages` checks `getName()` only. -//! Replacement satisfaction happens later in the solver once the replacing -//! package is loaded transitively. - -use super::{LoadResult, NamedPackagistVersion, PackageQuery, Repository}; -use crate::inline_package::{InlinePackage, collect_inline_packages}; -use mozart_core::package::RawRepository; - -pub struct InlinePackageRepository { - id: String, - packages: Vec<InlinePackage>, -} - -impl InlinePackageRepository { - /// Build from the raw `repositories` array of a `composer.json`. Non- - /// `package` entries are ignored. - pub fn from_repositories(repositories: &[RawRepository]) -> Self { - Self { - id: "package".to_string(), - packages: collect_inline_packages(repositories), - } - } - - pub fn package_count(&self) -> usize { - self.packages.len() - } -} - -#[async_trait::async_trait] -impl Repository for InlinePackageRepository { - fn id(&self) -> &str { - &self.id - } - - async fn load_packages(&self, queries: &[PackageQuery<'_>]) -> anyhow::Result<LoadResult> { - let mut result = LoadResult::default(); - for query in queries { - let mut found_any = false; - for ipkg in &self.packages { - if ipkg.name == query.name { - found_any = true; - result.packages.push(NamedPackagistVersion { - name: ipkg.name.clone(), - version: ipkg.version.clone(), - }); - } - } - if found_any { - result.names_found.push(query.name.to_string()); - } - } - Ok(result) - } -} diff --git a/crates/mozart-registry/src/repository/mod.rs b/crates/mozart-registry/src/repository/mod.rs deleted file mode 100644 index 46f62f0..0000000 --- a/crates/mozart-registry/src/repository/mod.rs +++ /dev/null @@ -1,319 +0,0 @@ -//! Repository abstraction over package metadata sources. -//! -//! Mirrors Composer's `Composer\Repository\RepositoryInterface::loadPackages` -//! and `Composer\Repository\RepositoryManager`. The resolver and lockfile -//! generator query a [`RepositorySet`] instead of calling Packagist directly, -//! so test code can substitute a set without `PackagistRepository` (mirroring -//! Composer's `FactoryMock` injecting `repositories: ['packagist' => false]`). -//! -//! Concrete implementations live in sibling modules: [`packagist_repo`] for -//! the live Packagist HTTP repo, [`inline_package_repo`] for `type: package` -//! entries embedded in `composer.json`, and [`vcs_repo`] for VCS repositories. - -use std::collections::BTreeMap; - -use crate::advisory::{MatchedAdvisory, PackageInfo}; -use crate::packagist::{PackagistVersion, SearchResult}; - -pub mod inline_package_repo; -pub mod packagist_repo; -pub mod vcs_repo; - -/// Search modes for [`Repository::search`]. -/// -/// Mirrors Composer's `RepositoryInterface::SEARCH_FULLTEXT|SEARCH_NAME|SEARCH_VENDOR` -/// constants (`composer/src/Composer/Repository/RepositoryInterface.php`). -#[derive(Copy, Clone, Eq, PartialEq, Debug)] -pub enum SearchMode { - /// Full-text search over name, description, and keywords (Packagist's - /// `search.json` API). - Fulltext, - /// Match the regex against package names. Tokens are split on whitespace - /// and joined as `(?:t1|t2|...)`; callers must pre-quote regex metachars. - Name, - /// Match the regex against vendor names. Result rows have only `name` - /// populated (the vendor part). - Vendor, -} - -/// One name-keyed lookup against a repository. -/// -/// Matches the `$packageNameMap` argument of Composer's `loadPackages`. The -/// constraint is informational — repositories may use it to skip versions -/// that obviously can't match (an optimization), but the resolver still -/// re-checks every returned version when generating rules. -#[derive(Debug, Clone)] -pub struct PackageQuery<'a> { - pub name: &'a str, - /// Raw constraint string from `composer.json`, e.g. `"^1.2"`. `None` - /// when the caller wants every version (transitive exploration). - pub constraint: Option<&'a str>, -} - -/// Result of a single [`Repository::load_packages`] call. -/// -/// Mirrors Composer's `['packages' => ..., 'namesFound' => ...]` tuple. -/// `names_found` lets [`RepositorySet`] short-circuit lower-priority repos -/// once an upstream repo has authoritatively answered for a name (Composer's -/// "first repo wins" semantics). -#[derive(Debug, Default)] -pub struct LoadResult { - pub packages: Vec<NamedPackagistVersion>, - pub names_found: Vec<String>, -} - -/// A `PackagistVersion` paired with the canonical package name it answers -/// for. Inline `type: package` repos can return packages whose own `name` -/// field differs from the queried name when they declare `replace`/`provide`, -/// so callers need both. -#[derive(Debug, Clone)] -pub struct NamedPackagistVersion { - pub name: String, - pub version: PackagistVersion, -} - -/// A source of package metadata. Mirrors Composer's `RepositoryInterface`. -/// -/// Implementations should return an empty [`LoadResult`] (not an error) when -/// they simply don't know a queried name — [`RepositorySet`] uses that to -/// fall through to the next repo. Reserve `Err` for genuine I/O failures -/// the caller cannot route around. -#[async_trait::async_trait] -pub trait Repository: Send + Sync { - /// Identifier for diagnostics (`"packagist.org"`, `"package"`, `"vcs:<url>"`). - fn id(&self) -> &str; - - /// Look up every version of every queried name this repo knows about. - async fn load_packages(&self, queries: &[PackageQuery<'_>]) -> anyhow::Result<LoadResult>; - - /// Search this repository. - /// - /// The default returns an empty result so repositories that don't - /// participate in search (e.g. inline / VCS repos that only resolve - /// known names) can opt out. Mirrors Composer's - /// `RepositoryInterface::search` whose default behavior on - /// `ArrayRepository` walks the in-memory list. - async fn search( - &self, - _query: &str, - _mode: SearchMode, - _package_type: Option<&str>, - ) -> anyhow::Result<Vec<SearchResult>> { - Ok(Vec::new()) - } -} - -/// Ordered list of repositories. Mirrors `Composer\Repository\RepositoryManager`. -/// -/// `load_packages` queries each repo in order. Once a repo authoritatively -/// answers for a name (i.e. lists it in `names_found`), later repos are not -/// asked about that name — matching Composer's first-repo-wins priority. -pub struct RepositorySet { - repos: Vec<Box<dyn Repository>>, -} - -impl RepositorySet { - pub fn new(repos: Vec<Box<dyn Repository>>) -> Self { - Self { repos } - } - - /// Production default: a single [`packagist_repo::PackagistRepository`] - /// backed by the given on-disk cache. Mirrors what Composer does when - /// no `'packagist' => false` entry appears in the merged config. - pub fn with_packagist(repo_cache: crate::cache::Cache) -> Self { - Self::new(vec![Box::new(packagist_repo::PackagistRepository::new( - repo_cache, - ))]) - } - - /// An empty set. Mirrors Composer's `'packagist' => false` test config: - /// resolution proceeds entirely from packages already in the pool - /// (eager VCS scan, inline `type: package` repos, the locked repository). - pub fn empty() -> Self { - Self::new(Vec::new()) - } - - pub fn is_empty(&self) -> bool { - self.repos.is_empty() - } - - pub fn len(&self) -> usize { - self.repos.len() - } - - /// Iterate over repositories in priority order. - pub fn repos(&self) -> impl Iterator<Item = &dyn Repository> { - self.repos.iter().map(|b| b.as_ref()) - } - - /// Query every repo, accumulating packages and tracking which names have - /// been authoritatively answered. Names already covered by an earlier - /// repo are dropped from the query passed to later repos. - pub async fn load_packages( - &self, - queries: &[PackageQuery<'_>], - ) -> anyhow::Result<Vec<NamedPackagistVersion>> { - use indexmap::IndexSet; - - let mut packages: Vec<NamedPackagistVersion> = Vec::new(); - let mut answered: IndexSet<String> = IndexSet::new(); - - for repo in &self.repos { - let pending: Vec<PackageQuery<'_>> = queries - .iter() - .filter(|q| !answered.contains(q.name)) - .cloned() - .collect(); - if pending.is_empty() { - break; - } - let result = repo.load_packages(&pending).await?; - for name in result.names_found { - answered.insert(name); - } - packages.extend(result.packages); - } - - Ok(packages) - } - - /// Fan-out search across every repository, concatenating results in - /// priority order. Mirrors Composer's - /// `CompositeRepository::search` which `array_merge`s per-repo results - /// without de-duplication. - pub async fn search( - &self, - query: &str, - mode: SearchMode, - package_type: Option<&str>, - ) -> anyhow::Result<Vec<SearchResult>> { - let mut all = Vec::new(); - for repo in &self.repos { - let mut hits = repo.search(query, mode, package_type).await?; - all.append(&mut hits); - } - Ok(all) - } - - /// Fetch security advisories matching the installed packages, with version filtering. - /// - /// Mirrors `Composer\Repository\RepositorySet::getMatchingSecurityAdvisories()`. - /// Returns the matched advisories (already filtered by installed version) and a list - /// of unreachable repository URLs. When `ignore_unreachable` is false and a repository - /// is unreachable, the error is propagated instead. - pub async fn get_matching_security_advisories( - &self, - packages: &[PackageInfo], - _allow_partial: bool, - ignore_unreachable: bool, - ) -> anyhow::Result<(BTreeMap<String, Vec<MatchedAdvisory>>, Vec<String>)> { - let names: Vec<&str> = packages.iter().map(|p| p.name.as_str()).collect(); - - let (raw_advisories, unreachable_repos) = - match crate::packagist::fetch_security_advisories(&names).await { - Ok(a) => (a, vec![]), - Err(e) if ignore_unreachable => { - tracing::warn!("Packagist advisory fetch failed (ignored): {e}"); - let unreachable = vec!["https://packagist.org".to_string()]; - (BTreeMap::new(), unreachable) - } - Err(e) => return Err(e), - }; - - let matched = version_filter_advisories(&raw_advisories, packages); - - Ok((matched, unreachable_repos)) - } -} - -/// Normalize single-pipe OR separators (`|`) in a version constraint string to -/// double-pipe (`||`) so the constraint parser can handle both forms. -/// -/// The Packagist security advisories API may return constraints with single `|` -/// as the OR separator (e.g. `>=1.0,<1.5|>=2.0,<2.3`), but Mozart's -/// `VersionConstraint::parse` expects `||`. -/// -/// TODO: fix `mozart_semver::VersionConstraint::parse` to accept single `|` and remove this. -fn normalize_or_separator(constraint: &str) -> String { - let bytes = constraint.as_bytes(); - let mut result = String::with_capacity(constraint.len() + 4); - let mut i = 0; - while i < bytes.len() { - if bytes[i] == b'|' { - if i + 1 < bytes.len() && bytes[i + 1] == b'|' { - result.push_str("||"); - i += 2; - } else { - result.push_str("||"); - i += 1; - } - } else { - result.push(bytes[i] as char); - i += 1; - } - } - result -} - -/// Filter raw advisories by installed package versions. -/// -/// Mirrors the version-matching step inside Composer's repository advisory fetch. -fn version_filter_advisories( - all_advisories: &BTreeMap<String, Vec<crate::packagist::SecurityAdvisory>>, - packages: &[PackageInfo], -) -> BTreeMap<String, Vec<MatchedAdvisory>> { - let mut result: BTreeMap<String, Vec<MatchedAdvisory>> = BTreeMap::new(); - - for pkg in packages { - let Some(advisories) = all_advisories.get(&pkg.name) else { - continue; - }; - - let version_str = pkg - .version_normalized - .as_deref() - .unwrap_or(pkg.version.as_str()); - - let installed_ver = match mozart_semver::Version::parse(version_str) { - Ok(v) => v, - Err(_) => { - tracing::warn!( - "Could not parse version {:?} for package {:?}, skipping advisory matching", - version_str, - pkg.name - ); - continue; - } - }; - - let mut matched: Vec<MatchedAdvisory> = Vec::new(); - - for advisory in advisories { - let normalized = normalize_or_separator(&advisory.affected_versions); - let constraint = match mozart_semver::VersionConstraint::parse(&normalized) { - Ok(c) => c, - Err(_) => { - tracing::warn!( - "Could not parse affected versions {:?} for advisory {:?}, skipping", - advisory.affected_versions, - advisory.advisory_id - ); - continue; - } - }; - - if constraint.matches(&installed_ver) { - matched.push(MatchedAdvisory { - advisory: advisory.clone(), - installed_version: pkg.version.clone(), - }); - } - } - - if !matched.is_empty() { - result.insert(pkg.name.clone(), matched); - } - } - - result -} diff --git a/crates/mozart-registry/src/repository/packagist_repo.rs b/crates/mozart-registry/src/repository/packagist_repo.rs deleted file mode 100644 index fa656b7..0000000 --- a/crates/mozart-registry/src/repository/packagist_repo.rs +++ /dev/null @@ -1,121 +0,0 @@ -//! [`Repository`] backed by the live Packagist HTTP API. -//! -//! Wraps the existing [`crate::packagist::fetch_package_versions`] so the -//! resolver sees the same data either through this trait or via the legacy -//! direct call. Construction takes ownership of the [`Cache`] handle so -//! callers no longer thread it through `ResolveRequest` / `LockFileGenerationRequest`. - -use super::{LoadResult, NamedPackagistVersion, PackageQuery, Repository, SearchMode}; -use crate::cache::Cache; -use crate::packagist; -use crate::packagist::SearchResult; - -pub struct PackagistRepository { - id: String, - cache: Cache, -} - -impl PackagistRepository { - pub fn new(cache: Cache) -> Self { - Self { - id: "packagist.org".to_string(), - cache, - } - } -} - -#[async_trait::async_trait] -impl Repository for PackagistRepository { - fn id(&self) -> &str { - &self.id - } - - async fn load_packages(&self, queries: &[PackageQuery<'_>]) -> anyhow::Result<LoadResult> { - let mut result = LoadResult::default(); - for query in queries { - // Errors propagate to the caller. Composer's - // `ComposerRepository::loadAsyncPackages` distinguishes 404 - // (empty result, no error) from transport failures (exception); - // Mozart's underlying `fetch_package_versions` doesn't yet make - // that distinction, so for now both surface as `Err` and the - // caller decides whether the loop wants to continue (transitive - // exploration) or abort (seed-time fetch failure). - let versions = packagist::fetch_package_versions(query.name, &self.cache).await?; - // A successful fetch counts as "this repo authoritatively knows - // the name", even if the version list is empty — mirrors - // Composer's `ArrayRepository::loadPackages` which adds the - // name to `namesFound` regardless of constraint match. - result.names_found.push(query.name.to_string()); - for version in versions { - result.packages.push(NamedPackagistVersion { - name: query.name.to_string(), - version, - }); - } - } - Ok(result) - } - - async fn search( - &self, - query: &str, - mode: SearchMode, - package_type: Option<&str>, - ) -> anyhow::Result<Vec<SearchResult>> { - match mode { - SearchMode::Fulltext => { - let (results, _total) = packagist::search_packages(query, package_type).await?; - Ok(results) - } - SearchMode::Name => { - let pattern = build_name_regex(query)?; - let names = packagist::fetch_package_names(package_type, &self.cache).await?; - Ok(names - .into_iter() - .filter(|name| pattern.is_match(name)) - .map(empty_search_result) - .collect()) - } - SearchMode::Vendor => { - let pattern = build_name_regex(query)?; - let vendors = packagist::fetch_vendor_names(&self.cache).await?; - Ok(vendors - .into_iter() - .filter(|name| pattern.is_match(name)) - .map(empty_search_result) - .collect()) - } - } - } -} - -/// Build the case-insensitive `(?:t1|t2|...)` regex from whitespace-split -/// tokens, mirroring Composer's `'{(?:'.implode('|', $matches).')}i'`. -/// -/// Tokens are joined as-is — callers are expected to have already escaped -/// regex metacharacters (`SearchCommand` calls `preg_quote`; Mozart calls -/// `regex::escape` before reaching this point). -fn build_name_regex(query: &str) -> anyhow::Result<regex::Regex> { - let tokens: Vec<&str> = query.split_whitespace().collect(); - let body = if tokens.is_empty() { - String::new() - } else { - tokens.join("|") - }; - Ok(regex::Regex::new(&format!("(?i)(?:{body})"))?) -} - -/// Build a [`SearchResult`] with only `name` populated, mirroring the shape -/// Composer returns for `SEARCH_NAME` / `SEARCH_VENDOR` modes -/// (`['name' => $name]`, all other fields `null`). -fn empty_search_result(name: String) -> SearchResult { - SearchResult { - name, - description: String::new(), - url: String::new(), - repository: None, - downloads: 0, - favers: 0, - abandoned: None, - } -} diff --git a/crates/mozart-registry/src/repository/vcs_repo.rs b/crates/mozart-registry/src/repository/vcs_repo.rs deleted file mode 100644 index fff5f6f..0000000 --- a/crates/mozart-registry/src/repository/vcs_repo.rs +++ /dev/null @@ -1,63 +0,0 @@ -//! [`Repository`] for VCS-type repositories. -//! -//! Wraps [`crate::vcs_bridge::scan_vcs_repositories`] + [`crate::vcs_bridge::vcs_to_packagist_version`]. -//! Scanning is expensive (clones / fetches), so we do it once at construction -//! and serve subsequent queries from the in-memory cache. Mirrors -//! `Composer\Repository\Vcs\VcsRepository`'s lazy-then-memoized behavior. - -use super::{LoadResult, NamedPackagistVersion, PackageQuery, Repository}; -use crate::packagist::PackagistVersion; -use crate::vcs_bridge::{scan_vcs_repositories, vcs_to_packagist_version}; -use mozart_core::package::RawRepository; - -pub struct VcsRepository { - id: String, - versions: Vec<(String, PackagistVersion)>, -} - -impl VcsRepository { - /// Scan every VCS-type entry in `repositories` and cache the resulting - /// versions. Non-VCS entries are ignored. This performs network I/O. - pub async fn from_repositories(repositories: &[RawRepository]) -> Self { - let scanned = scan_vcs_repositories(repositories).await; - let versions = scanned - .iter() - .map(|v| (v.name.clone(), vcs_to_packagist_version(v))) - .collect(); - Self { - id: "vcs".to_string(), - versions, - } - } - - pub fn version_count(&self) -> usize { - self.versions.len() - } -} - -#[async_trait::async_trait] -impl Repository for VcsRepository { - fn id(&self) -> &str { - &self.id - } - - async fn load_packages(&self, queries: &[PackageQuery<'_>]) -> anyhow::Result<LoadResult> { - let mut result = LoadResult::default(); - for query in queries { - let mut found_any = false; - for (name, version) in &self.versions { - if name == query.name { - found_any = true; - result.packages.push(NamedPackagistVersion { - name: name.clone(), - version: version.clone(), - }); - } - } - if found_any { - result.names_found.push(query.name.to_string()); - } - } - Ok(result) - } -} diff --git a/crates/mozart-registry/src/repository_filter.rs b/crates/mozart-registry/src/repository_filter.rs deleted file mode 100644 index facbb36..0000000 --- a/crates/mozart-registry/src/repository_filter.rs +++ /dev/null @@ -1,136 +0,0 @@ -//! Repository-level package filters (`only`, `exclude`, `canonical`). -//! -//! Mirrors `Composer\Repository\FilterRepository`: a wrapper around an -//! underlying repository that drops packages by name and/or removes the -//! repo's authoritative claim on the names it serves. We model the same -//! semantics for inline `type: package` and local `type: composer` -//! repositories, since the installer fixtures rely on them. - -use mozart_core::package::RawRepository; -use regex::Regex; - -/// Resolved filter for a single `repositories[]` entry. -pub struct RepositoryFilter { - only: Option<Regex>, - exclude: Option<Regex>, - /// `canonical: true` (default) — packages from this repo claim their - /// names, suppressing lower-priority repos for the same name. - /// `canonical: false` — packages enter the pool but lower-priority - /// repos may also answer. - pub canonical: bool, -} - -impl RepositoryFilter { - pub fn from_repo(repo: &RawRepository) -> Self { - Self { - only: repo.only.as_ref().and_then(|names| build_name_regex(names)), - exclude: repo - .exclude - .as_ref() - .and_then(|names| build_name_regex(names)), - canonical: repo.canonical.unwrap_or(true), - } - } - - /// `true` if `name` may pass through this filter. - /// Mirrors `FilterRepository::isAllowed`. - pub fn is_allowed(&self, name: &str) -> bool { - if let Some(only) = &self.only { - return only.is_match(name); - } - if let Some(exclude) = &self.exclude { - return !exclude.is_match(name); - } - true - } -} - -/// Build a case-insensitive `^(?:p1|p2|…)$` regex from Composer's pattern -/// list. Mirrors `BasePackage::packageNamesToRegexp` — `*` becomes `.*`, -/// every other regex metacharacter is escaped, and the alternation is -/// anchored to the full string. -fn build_name_regex(patterns: &[String]) -> Option<Regex> { - if patterns.is_empty() { - return None; - } - let parts: Vec<String> = patterns.iter().map(|p| pattern_to_regex(p)).collect(); - let joined = parts.join("|"); - Regex::new(&format!(r"(?i)^(?:{joined})$")).ok() -} - -fn pattern_to_regex(pattern: &str) -> String { - let escaped = regex::escape(pattern); - // `*` was escaped to `\*` — turn it into `.*` so glob semantics match - // Composer. - escaped.replace(r"\*", ".*") -} - -#[cfg(test)] -mod tests { - use super::*; - - fn repo( - only: Option<Vec<String>>, - exclude: Option<Vec<String>>, - canonical: Option<bool>, - ) -> RawRepository { - RawRepository { - repo_type: "package".to_string(), - url: None, - package: None, - only, - exclude, - canonical, - security_advisories: None, - } - } - - #[test] - fn no_filter_allows_all() { - let f = RepositoryFilter::from_repo(&repo(None, None, None)); - assert!(f.is_allowed("a/a")); - assert!(f.is_allowed("foo/bar")); - assert!(f.canonical); - } - - #[test] - fn only_restricts_to_listed_names() { - let f = RepositoryFilter::from_repo(&repo(Some(vec!["foo/b".to_string()]), None, None)); - assert!(f.is_allowed("foo/b")); - assert!(!f.is_allowed("foo/a")); - } - - #[test] - fn exclude_drops_listed_names() { - let f = RepositoryFilter::from_repo(&repo(None, Some(vec!["foo/c".to_string()]), None)); - assert!(f.is_allowed("foo/a")); - assert!(!f.is_allowed("foo/c")); - } - - #[test] - fn glob_star_expands() { - let f = RepositoryFilter::from_repo(&repo(Some(vec!["foo/*".to_string()]), None, None)); - assert!(f.is_allowed("foo/a")); - assert!(f.is_allowed("foo/anything")); - assert!(!f.is_allowed("bar/a")); - } - - #[test] - fn match_is_case_insensitive() { - let f = RepositoryFilter::from_repo(&repo(Some(vec!["Foo/Bar".to_string()]), None, None)); - assert!(f.is_allowed("foo/bar")); - assert!(f.is_allowed("FOO/BAR")); - } - - #[test] - fn canonical_default_is_true() { - let f = RepositoryFilter::from_repo(&repo(None, None, None)); - assert!(f.canonical); - } - - #[test] - fn canonical_false_honored() { - let f = RepositoryFilter::from_repo(&repo(None, None, Some(false))); - assert!(!f.canonical); - } -} 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), - } - } -} diff --git a/crates/mozart-registry/src/vcs_bridge.rs b/crates/mozart-registry/src/vcs_bridge.rs deleted file mode 100644 index aae3d87..0000000 --- a/crates/mozart-registry/src/vcs_bridge.rs +++ /dev/null @@ -1,218 +0,0 @@ -//! Bridge between `mozart-vcs` and `mozart-registry`. -//! -//! Scans VCS repositories defined in composer.json and converts -//! discovered package versions into pool inputs for the SAT resolver. - -use indexmap::IndexMap; -use std::collections::BTreeMap; - -use mozart_core::package::{RawRepository, Stability}; -use mozart_sat_resolver::{PoolPackageInput, make_pool_links}; -use mozart_vcs::driver::DriverConfig; -use mozart_vcs::repository::{VcsPackageVersion, VcsRepository}; - -use crate::packagist::PackagistVersion; -use crate::resolver::{parse_normalized, version_stability}; - -/// Scan all VCS-type repositories and collect package versions. -/// -/// Non-VCS repos (e.g. "composer", "package") are silently skipped. -pub async fn scan_vcs_repositories(repositories: &[RawRepository]) -> Vec<VcsPackageVersion> { - let config = DriverConfig::default(); - let mut all_versions = Vec::new(); - - for repo in repositories { - let repo_type = repo.repo_type.as_str(); - match repo_type { - "vcs" | "git" | "svn" | "hg" | "github" | "gitlab" | "bitbucket" | "forgejo" => {} - _ => continue, - } - - let forced_type = match repo_type { - "vcs" => None, - other => Some(other), - }; - - // VCS repositories require `url`; skip silently if missing (Composer - // would reject this earlier in RepositoryFactory). - let Some(url) = repo.url.clone() else { - continue; - }; - - let vcs_repo = VcsRepository::new(url.clone(), forced_type, config.clone()); - - match vcs_repo.scan().await { - Ok(versions) => { - all_versions.extend(versions); - } - Err(e) => { - eprintln!("Warning: Failed to scan VCS repository {url}: {e}"); - } - } - } - - all_versions -} - -/// Convert a VCS package version to SAT pool inputs. -pub fn vcs_to_pool_inputs( - vpkg: &VcsPackageVersion, - minimum_stability: Stability, - stability_flags: &IndexMap<String, Stability>, -) -> Vec<PoolPackageInput> { - let mut results = Vec::new(); - - // Extract dependency links from composer.json - let require = extract_dep_map(&vpkg.composer_json, "require"); - let replace = extract_dep_map(&vpkg.composer_json, "replace"); - let provide = extract_dep_map(&vpkg.composer_json, "provide"); - let conflict = extract_dep_map(&vpkg.composer_json, "conflict"); - - let input = PoolPackageInput { - name: vpkg.name.clone(), - version: vpkg.version_normalized.clone(), - pretty_version: vpkg.version.clone(), - requires: make_pool_links( - &vpkg.name, - &vpkg.version_normalized, - &require - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::<Vec<_>>(), - ), - replaces: make_pool_links( - &vpkg.name, - &vpkg.version_normalized, - &replace - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::<Vec<_>>(), - ), - provides: make_pool_links( - &vpkg.name, - &vpkg.version_normalized, - &provide - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::<Vec<_>>(), - ), - conflicts: make_pool_links( - &vpkg.name, - &vpkg.version_normalized, - &conflict - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect::<Vec<_>>(), - ), - is_fixed: false, - is_alias_of: None, - }; - - // Apply stability filtering - if let Some(v) = parse_normalized(&vpkg.version_normalized) { - if passes_vcs_stability_filter(&vpkg.name, &v, minimum_stability, stability_flags) { - results.push(input); - } - } else { - // Dev version: always include (dev stability) - let pkg_flag = stability_flags.get(&vpkg.name.to_lowercase()); - let allowed = pkg_flag.copied().unwrap_or(minimum_stability); - if allowed >= Stability::Dev { - results.push(input); - } - } - - results -} - -/// Convert a `VcsPackageVersion` into a `PackagistVersion` for lockfile generation. -pub fn vcs_to_packagist_version(vpkg: &VcsPackageVersion) -> PackagistVersion { - PackagistVersion { - version: vpkg.version.clone(), - version_normalized: vpkg.version_normalized.clone(), - require: extract_dep_map(&vpkg.composer_json, "require"), - replace: extract_dep_map(&vpkg.composer_json, "replace"), - provide: extract_dep_map(&vpkg.composer_json, "provide"), - conflict: extract_dep_map(&vpkg.composer_json, "conflict"), - dist: vpkg.dist.as_ref().map(|d| crate::packagist::PackagistDist { - dist_type: d.dist_type.clone(), - url: d.url.clone(), - reference: Some(d.reference.clone()), - shasum: d.shasum.clone(), - }), - source: Some(crate::packagist::PackagistSource { - source_type: vpkg.source.source_type.clone(), - url: vpkg.source.url.clone(), - reference: Some(vpkg.source.reference.clone()), - }), - require_dev: extract_dep_map(&vpkg.composer_json, "require-dev"), - suggest: vpkg - .composer_json - .get("suggest") - .and_then(|v| serde_json::from_value(v.clone()).ok()), - package_type: vpkg - .composer_json - .get("type") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - autoload: vpkg.composer_json.get("autoload").cloned(), - autoload_dev: vpkg.composer_json.get("autoload-dev").cloned(), - license: vpkg - .composer_json - .get("license") - .and_then(|v| serde_json::from_value(v.clone()).ok()), - description: vpkg - .composer_json - .get("description") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - homepage: vpkg - .composer_json - .get("homepage") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()), - keywords: vpkg - .composer_json - .get("keywords") - .and_then(|v| serde_json::from_value(v.clone()).ok()), - authors: vpkg - .composer_json - .get("authors") - .and_then(|v| serde_json::from_value(v.clone()).ok()), - support: vpkg.composer_json.get("support").cloned(), - funding: vpkg - .composer_json - .get("funding") - .and_then(|v| serde_json::from_value(v.clone()).ok()), - time: vpkg.time.clone(), - extra: vpkg.composer_json.get("extra").cloned(), - notification_url: None, - default_branch: vpkg.is_default_branch, - abandoned: vpkg.composer_json.get("abandoned").cloned(), - } -} - -/// Extract a dependency map from composer.json JSON. -fn extract_dep_map(json: &serde_json::Value, key: &str) -> BTreeMap<String, String> { - json.get(key) - .and_then(|v| v.as_object()) - .map(|obj| { - obj.iter() - .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string()))) - .collect() - }) - .unwrap_or_default() -} - -/// Stability filter for VCS packages (mirrors resolver logic). -fn passes_vcs_stability_filter( - package_name: &str, - version: &mozart_semver::Version, - minimum_stability: Stability, - stability_flags: &IndexMap<String, Stability>, -) -> bool { - let stability = version_stability(version); - let pkg_flag = stability_flags.get(&package_name.to_lowercase()); - let allowed = pkg_flag.copied().unwrap_or(minimum_stability); - stability <= allowed -} diff --git a/crates/mozart-registry/src/version.rs b/crates/mozart-registry/src/version.rs deleted file mode 100644 index 9a7c6e6..0000000 --- a/crates/mozart-registry/src/version.rs +++ /dev/null @@ -1,269 +0,0 @@ -use crate::packagist::PackagistVersion; -use mozart_core::package::Stability; -use std::cmp::Ordering; - -/// Determine the stability of a normalized version string. -pub fn stability_of(version_normalized: &str) -> Stability { - let v = version_normalized.to_lowercase(); - if v.starts_with("dev-") || v.ends_with("-dev") { - return Stability::Dev; - } - // Check for pre-release suffixes: alpha, beta, RC - // Normalized versions use formats like "1.0.0.0-alpha1", "1.0.0.0-beta2", "1.0.0.0-RC1" - if let Some(pos) = v.rfind('-') { - let suffix = &v[pos + 1..]; - if suffix.starts_with("alpha") { - return Stability::Alpha; - } - if suffix.starts_with("beta") { - return Stability::Beta; - } - if suffix.starts_with("rc") || suffix.starts_with("RC") { - return Stability::RC; - } - } - Stability::Stable -} - -/// Compare two normalized version strings (e.g. "1.2.3.0" vs "1.2.4.0"). -/// -/// Each version is split into numeric parts. Non-numeric suffixes (like "-beta1") -/// are handled by treating the base parts as numeric and the suffix separately. -pub fn compare_normalized_versions(a: &str, b: &str) -> Ordering { - let parse = |v: &str| -> (Vec<u64>, Option<String>) { - // Split off any pre-release suffix - let (base, suffix) = if let Some(pos) = v.find('-') { - (&v[..pos], Some(v[pos + 1..].to_string())) - } else { - (v, None) - }; - let parts: Vec<u64> = base.split('.').filter_map(|p| p.parse().ok()).collect(); - (parts, suffix) - }; - - let (a_parts, a_suffix) = parse(a); - let (b_parts, b_suffix) = parse(b); - - // Compare numeric parts - let max_len = a_parts.len().max(b_parts.len()); - for i in 0..max_len { - let a_val = a_parts.get(i).copied().unwrap_or(0); - let b_val = b_parts.get(i).copied().unwrap_or(0); - match a_val.cmp(&b_val) { - Ordering::Equal => continue, - other => return other, - } - } - - // If numeric parts are equal, compare stability - // A stable version (no suffix) is greater than a pre-release - match (&a_suffix, &b_suffix) { - (None, None) => Ordering::Equal, - (None, Some(_)) => Ordering::Greater, // stable > pre-release - (Some(_), None) => Ordering::Less, // pre-release < stable - (Some(a_s), Some(b_s)) => { - let stab_a = stability_of(&format!("0.0.0.0-{a_s}")); - let stab_b = stability_of(&format!("0.0.0.0-{b_s}")); - // Lower stability value = more stable = greater version - match stab_a.cmp(&stab_b) { - Ordering::Equal => a_s.cmp(b_s), - // Stability enum: Stable(0) < RC(5) < Beta(10) < Alpha(15) < Dev(20) - // But more stable = higher version, so we reverse - Ordering::Less => Ordering::Greater, - Ordering::Greater => Ordering::Less, - } - } - } -} - -/// Find the best version candidate given a preferred minimum stability. -/// -/// Returns the highest version whose stability is at least as stable as -/// the preferred stability (i.e., stability value <= preferred value). -pub fn find_best_candidate( - versions: &[PackagistVersion], - preferred_stability: Stability, -) -> Option<&PackagistVersion> { - versions - .iter() - .filter(|v| stability_of(&v.version_normalized) <= preferred_stability) - .max_by(|a, b| compare_normalized_versions(&a.version_normalized, &b.version_normalized)) -} - -/// Generate a recommended version constraint string from a concrete version. -/// -/// Examples: -/// - `"1.2.1"` (stable) → `"^1.2"` -/// - `"0.3.5"` (stable) → `"^0.3"` -/// - `"2.0.0-beta.1"` (beta) → `"^2.0@beta"` -/// - `"dev-master"` (dev) → `"dev-master"` -pub fn find_recommended_require_version( - version: &str, - version_normalized: &str, - stability: Stability, -) -> String { - // dev branches are returned as-is - if stability == Stability::Dev { - return version.to_string(); - } - - // Extract major.minor from the normalized version (e.g. "1.2.3.0" → "1.2") - let base = if let Some(pos) = version_normalized.find('-') { - &version_normalized[..pos] - } else { - version_normalized - }; - - let parts: Vec<&str> = base.split('.').collect(); - let major = parts.first().copied().unwrap_or("0"); - let minor = parts.get(1).copied().unwrap_or("0"); - - let constraint = format!("^{major}.{minor}"); - - match stability { - Stability::Stable => constraint, - Stability::RC => format!("{constraint}@RC"), - Stability::Beta => format!("{constraint}@beta"), - Stability::Alpha => format!("{constraint}@alpha"), - Stability::Dev => format!("{constraint}@dev"), - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_stability_of() { - assert_eq!(stability_of("1.0.0.0"), Stability::Stable); - assert_eq!(stability_of("2.3.1.0"), Stability::Stable); - assert_eq!(stability_of("1.0.0.0-alpha1"), Stability::Alpha); - assert_eq!(stability_of("1.0.0.0-beta2"), Stability::Beta); - assert_eq!(stability_of("1.0.0.0-RC1"), Stability::RC); - assert_eq!(stability_of("dev-master"), Stability::Dev); - assert_eq!(stability_of("dev-feature/foo"), Stability::Dev); - assert_eq!(stability_of("1.0.0.0-dev"), Stability::Dev); - } - - #[test] - fn test_compare_normalized_versions() { - assert_eq!( - compare_normalized_versions("1.0.0.0", "1.0.0.0"), - Ordering::Equal - ); - assert_eq!( - compare_normalized_versions("2.0.0.0", "1.0.0.0"), - Ordering::Greater - ); - assert_eq!( - compare_normalized_versions("1.0.0.0", "2.0.0.0"), - Ordering::Less - ); - assert_eq!( - compare_normalized_versions("1.2.0.0", "1.1.0.0"), - Ordering::Greater - ); - assert_eq!( - compare_normalized_versions("1.0.0.0", "1.0.0.0-beta1"), - Ordering::Greater - ); - assert_eq!( - compare_normalized_versions("1.0.0.0-RC1", "1.0.0.0-beta1"), - Ordering::Greater - ); - } - - fn make_pv(version: &str, version_normalized: &str) -> PackagistVersion { - PackagistVersion { - version: version.to_string(), - version_normalized: version_normalized.to_string(), - require: Default::default(), - replace: Default::default(), - provide: Default::default(), - conflict: Default::default(), - dist: None, - source: None, - require_dev: Default::default(), - suggest: None, - package_type: None, - autoload: None, - autoload_dev: None, - license: None, - description: None, - homepage: None, - keywords: None, - authors: None, - support: None, - funding: None, - time: None, - extra: None, - notification_url: None, - default_branch: false, - abandoned: None, - } - } - - #[test] - fn test_find_best_candidate_stable() { - let versions = vec![ - make_pv("dev-master", "dev-master"), - make_pv("2.0.0-beta.1", "2.0.0.0-beta1"), - make_pv("1.5.0", "1.5.0.0"), - make_pv("1.4.0", "1.4.0.0"), - ]; - - let best = find_best_candidate(&versions, Stability::Stable).unwrap(); - assert_eq!(best.version, "1.5.0"); - } - - #[test] - fn test_find_best_candidate_beta() { - let versions = vec![ - make_pv("dev-master", "dev-master"), - make_pv("2.0.0-beta.1", "2.0.0.0-beta1"), - make_pv("1.5.0", "1.5.0.0"), - ]; - - let best = find_best_candidate(&versions, Stability::Beta).unwrap(); - assert_eq!(best.version, "2.0.0-beta.1"); - } - - #[test] - fn test_find_best_candidate_no_match() { - let versions = vec![make_pv("dev-master", "dev-master")]; - - let best = find_best_candidate(&versions, Stability::Stable); - assert!(best.is_none()); - } - - #[test] - fn test_find_recommended_require_version() { - // Stable - assert_eq!( - find_recommended_require_version("1.2.1", "1.2.1.0", Stability::Stable), - "^1.2" - ); - assert_eq!( - find_recommended_require_version("0.3.5", "0.3.5.0", Stability::Stable), - "^0.3" - ); - - // Beta - assert_eq!( - find_recommended_require_version("2.0.0-beta.1", "2.0.0.0-beta1", Stability::Beta), - "^2.0@beta" - ); - - // RC - assert_eq!( - find_recommended_require_version("3.0.0-RC1", "3.0.0.0-RC1", Stability::RC), - "^3.0@RC" - ); - - // Dev - assert_eq!( - find_recommended_require_version("dev-master", "dev-master", Stability::Dev), - "dev-master" - ); - } -} diff --git a/crates/mozart-registry/src/version_selector.rs b/crates/mozart-registry/src/version_selector.rs deleted file mode 100644 index 7aa409e..0000000 --- a/crates/mozart-registry/src/version_selector.rs +++ /dev/null @@ -1,48 +0,0 @@ -use crate::cache::Cache; -use crate::packagist::{self, PackagistVersion}; -use crate::version; -use mozart_core::package::Stability; - -/// Mirrors `Composer\Package\Version\VersionSelector`. -pub struct VersionSelector { - preferred_stability: Stability, - repo_cache: Cache, -} - -impl VersionSelector { - pub fn new(preferred_stability: Stability, repo_cache: Cache) -> Self { - Self { - preferred_stability, - repo_cache, - } - } - - /// Fetch versions from Packagist and pick the best candidate. - /// Mirrors `VersionSelector::findBestCandidate()`. - pub async fn find_best_candidate( - &self, - package_name: &str, - ) -> anyhow::Result<Option<PackagistVersion>> { - let versions = packagist::fetch_package_versions(package_name, &self.repo_cache).await?; - Ok(version::find_best_candidate(&versions, self.preferred_stability).cloned()) - } - - /// Generate a recommended constraint string from a concrete version. - /// Mirrors `VersionSelector::findRecommendedRequireVersion()`. - pub fn find_recommended_require_version_string( - &self, - pkg: &PackagistVersion, - fixed: bool, - ) -> String { - if fixed { - pkg.version.clone() - } else { - let stability = version::stability_of(&pkg.version_normalized); - version::find_recommended_require_version( - &pkg.version, - &pkg.version_normalized, - stability, - ) - } - } -} diff --git a/crates/mozart-registry/tests/poolbuilder.rs b/crates/mozart-registry/tests/poolbuilder.rs deleted file mode 100644 index d8511e4..0000000 --- a/crates/mozart-registry/tests/poolbuilder.rs +++ /dev/null @@ -1,80 +0,0 @@ -//! Pool-builder fixture suite, ported from -//! `composer/tests/Composer/Test/DependencyResolver/PoolBuilderTest.php`. -//! -//! Composer drives this suite through a `@dataProvider`; each `.test` file -//! becomes one parameterized case. Mirrored here as one `#[test]` per -//! fixture so the count surfaces in `cargo test` output and individual -//! cases can be re-enabled as the runner is fleshed out. -//! -//! Every test is currently `#[ignore]` because the runner is a stub: the -//! orchestration that takes a `RepositorySet` + `Request` and produces a -//! populated `Pool` lives inline in `mozart_registry::resolver::resolve`, -//! not as an extracted entry point. Wiring those up — alias handling, -//! stability flags, fixed/locked packages, the optimizer pass — is the -//! follow-up work this scaffolding exists to track. - -use std::path::{Path, PathBuf}; - -use mozart_test_harness::{ParsedPoolBuilderTest, parse_pool_builder_test_file}; - -fn fixtures_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../composer/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder") -} - -fn run_poolbuilder_fixture(ident: &str) { - let filename = format!("{}.test", ident.replace('_', "-")); - let path = fixtures_dir().join(&filename); - let _parsed: ParsedPoolBuilderTest = parse_pool_builder_test_file(&path) - .unwrap_or_else(|e| panic!("failed to parse {}: {:#}", path.display(), e)); - - // Runner is intentionally not implemented yet — see module docs. - // Removing `#[ignore]` from a case will surface this `unimplemented!` - // and force the missing pool-builder entry point into existence. - unimplemented!( - "PoolBuilderTest runner not yet wired up; cannot execute {}", - path.display() - ); -} - -macro_rules! poolbuilder_fixture { - ($name:ident) => { - #[test] - #[ignore] - fn $name() { - run_poolbuilder_fixture(stringify!($name)); - } - }; -} - -poolbuilder_fixture!(alias_priority_conflicting); -poolbuilder_fixture!(alias_with_reference); -poolbuilder_fixture!(constraint_expansion_works_with_exact_versions); -poolbuilder_fixture!(filter_impossible_packages); -poolbuilder_fixture!(filter_impossible_packages_locked_replacer); -poolbuilder_fixture!(filter_impossible_packages_only_required); -poolbuilder_fixture!(filter_impossible_packages_only_required_provides); -poolbuilder_fixture!(filter_impossible_packages_only_required_replaces); -poolbuilder_fixture!(filter_impossible_packages_provides); -poolbuilder_fixture!(filter_impossible_packages_replaces); -poolbuilder_fixture!(fixed_packages_do_not_load_from_repos); -poolbuilder_fixture!(fixed_packages_replaced_do_not_load_from_repos); -poolbuilder_fixture!(load_replaced_package_if_replacer_dropped); -poolbuilder_fixture!(load_replaced_root_package_if_replacer_dropped); -poolbuilder_fixture!(multi_repo_replace); -poolbuilder_fixture!(multi_repo_replace_partial_update_all); -poolbuilder_fixture!(must_expand_root_reqs); -poolbuilder_fixture!(package_versions_are_not_loaded_if_not_required_expansion); -poolbuilder_fixture!(package_versions_are_not_loaded_if_not_required_recursive); -poolbuilder_fixture!(packages_that_do_not_exist); -poolbuilder_fixture!(partial_update); -poolbuilder_fixture!(partial_update_transitive_deps_no_root_unfix); -poolbuilder_fixture!(partial_update_transitive_deps_unfix); -poolbuilder_fixture!(partial_update_unfixes_path_repo_replacer_with_transitive_deps); -poolbuilder_fixture!(partial_update_unfixes_path_repos_always_but_not_their_transitive_deps); -poolbuilder_fixture!(partial_update_unfixing_locked_deps); -poolbuilder_fixture!(partial_update_unfixing_replacers); -poolbuilder_fixture!(partial_update_unfixing_with_replacers); -poolbuilder_fixture!(partial_update_unfixing_with_replacers_providers); -poolbuilder_fixture!(root_requirements_avoid_loading_further_versions); -poolbuilder_fixture!(stability_flags_take_over_minimum_stability_and_filter_packages); |
