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