aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-core/src/repository
diff options
context:
space:
mode:
Diffstat (limited to 'crates/mozart-core/src/repository')
-rw-r--r--crates/mozart-core/src/repository/advisory.rs731
-rw-r--r--crates/mozart-core/src/repository/browse_repos.rs293
-rw-r--r--crates/mozart-core/src/repository/cache.rs575
-rw-r--r--crates/mozart-core/src/repository/composer_repo.rs173
-rw-r--r--crates/mozart-core/src/repository/download_manager.rs143
-rw-r--r--crates/mozart-core/src/repository/downloader.rs500
-rw-r--r--crates/mozart-core/src/repository/inline_package.rs277
-rw-r--r--crates/mozart-core/src/repository/installed.rs383
-rw-r--r--crates/mozart-core/src/repository/installer_executor/filesystem.rs230
-rw-r--r--crates/mozart-core/src/repository/installer_executor/mod.rs348
-rw-r--r--crates/mozart-core/src/repository/installer_executor/trace_recorder.rs160
-rw-r--r--crates/mozart-core/src/repository/installer_executor/transaction.rs412
-rw-r--r--crates/mozart-core/src/repository/lockfile.rs2040
-rw-r--r--crates/mozart-core/src/repository/packagist.rs1011
-rw-r--r--crates/mozart-core/src/repository/path_repository.rs243
-rw-r--r--crates/mozart-core/src/repository/repository/inline_package_repo.rs63
-rw-r--r--crates/mozart-core/src/repository/repository/mod.rs319
-rw-r--r--crates/mozart-core/src/repository/repository/packagist_repo.rs121
-rw-r--r--crates/mozart-core/src/repository/repository/vcs_repo.rs63
-rw-r--r--crates/mozart-core/src/repository/repository_filter.rs136
-rw-r--r--crates/mozart-core/src/repository/resolver.rs1998
-rw-r--r--crates/mozart-core/src/repository/vcs_bridge.rs216
-rw-r--r--crates/mozart-core/src/repository/version.rs269
-rw-r--r--crates/mozart-core/src/repository/version_selector.rs48
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,
+ )
+ }
+ }
+}