aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-registry
diff options
context:
space:
mode:
Diffstat (limited to 'crates/mozart-registry')
-rw-r--r--crates/mozart-registry/Cargo.toml31
-rw-r--r--crates/mozart-registry/src/advisory.rs733
-rw-r--r--crates/mozart-registry/src/browse_repos.rs293
-rw-r--r--crates/mozart-registry/src/cache.rs575
-rw-r--r--crates/mozart-registry/src/composer_repo.rs173
-rw-r--r--crates/mozart-registry/src/download_manager.rs143
-rw-r--r--crates/mozart-registry/src/downloader.rs500
-rw-r--r--crates/mozart-registry/src/inline_package.rs277
-rw-r--r--crates/mozart-registry/src/installed.rs383
-rw-r--r--crates/mozart-registry/src/installer_executor/filesystem.rs232
-rw-r--r--crates/mozart-registry/src/installer_executor/mod.rs348
-rw-r--r--crates/mozart-registry/src/installer_executor/trace_recorder.rs160
-rw-r--r--crates/mozart-registry/src/installer_executor/transaction.rs411
-rw-r--r--crates/mozart-registry/src/lib.rs18
-rw-r--r--crates/mozart-registry/src/lockfile.rs2037
-rw-r--r--crates/mozart-registry/src/packagist.rs1011
-rw-r--r--crates/mozart-registry/src/path_repository.rs243
-rw-r--r--crates/mozart-registry/src/repository/inline_package_repo.rs63
-rw-r--r--crates/mozart-registry/src/repository/mod.rs319
-rw-r--r--crates/mozart-registry/src/repository/packagist_repo.rs121
-rw-r--r--crates/mozart-registry/src/repository/vcs_repo.rs63
-rw-r--r--crates/mozart-registry/src/repository_filter.rs136
-rw-r--r--crates/mozart-registry/src/resolver.rs1999
-rw-r--r--crates/mozart-registry/src/vcs_bridge.rs218
-rw-r--r--crates/mozart-registry/src/version.rs269
-rw-r--r--crates/mozart-registry/src/version_selector.rs48
-rw-r--r--crates/mozart-registry/tests/poolbuilder.rs80
27 files changed, 0 insertions, 10884 deletions
diff --git a/crates/mozart-registry/Cargo.toml b/crates/mozart-registry/Cargo.toml
deleted file mode 100644
index 6239973..0000000
--- a/crates/mozart-registry/Cargo.toml
+++ /dev/null
@@ -1,31 +0,0 @@
-[package]
-name = "mozart-registry"
-version.workspace = true
-edition.workspace = true
-
-[dependencies]
-mozart-console-macros.workspace = true
-mozart-core.workspace = true
-mozart-metadata-minifier.workspace = true
-mozart-php-serialize.workspace = true
-mozart-sat-resolver.workspace = true
-mozart-semver.workspace = true
-mozart-vcs.workspace = true
-anyhow.workspace = true
-async-trait.workspace = true
-filetime.workspace = true
-flate2.workspace = true
-indexmap.workspace = true
-md5.workspace = true
-regex.workspace = true
-serde.workspace = true
-serde_json.workspace = true
-sha1.workspace = true
-tar.workspace = true
-tempfile.workspace = true
-tokio.workspace = true
-tracing.workspace = true
-zip.workspace = true
-
-[dev-dependencies]
-mozart-test-harness.workspace = true
diff --git a/crates/mozart-registry/src/advisory.rs b/crates/mozart-registry/src/advisory.rs
deleted file mode 100644
index 86d37af..0000000
--- a/crates/mozart-registry/src/advisory.rs
+++ /dev/null
@@ -1,733 +0,0 @@
-use std::collections::BTreeMap;
-
-use indexmap::IndexMap;
-use mozart_core::advisory::{AbandonedHandling, AuditFormat};
-use mozart_core::console::Console;
-use mozart_core::{console_writeln, console_writeln_error};
-
-use crate::packagist::SecurityAdvisory;
-use crate::repository::RepositorySet;
-
-/// A package being audited, with version and abandonment information.
-#[derive(Debug, Clone)]
-pub struct PackageInfo {
- pub name: String,
- pub version: String,
- pub version_normalized: Option<String>,
- /// Raw abandoned field from JSON: `true` = abandoned no replacement, `String` = replacement name.
- pub abandoned_raw: Option<serde_json::Value>,
-}
-
-impl PackageInfo {
- /// Mirrors `CompletePackage::isAbandoned()`.
- pub fn is_abandoned(&self) -> bool {
- matches!(
- &self.abandoned_raw,
- Some(serde_json::Value::Bool(true)) | Some(serde_json::Value::String(_))
- )
- }
-
- /// Mirrors `CompletePackage::getReplacementPackage()`.
- pub fn replacement_package(&self) -> Option<&str> {
- match &self.abandoned_raw {
- Some(serde_json::Value::String(s)) => Some(s.as_str()),
- _ => None,
- }
- }
-}
-
-/// An advisory paired with the installed version of the package it affects.
-#[derive(Debug, Clone)]
-pub struct MatchedAdvisory {
- pub advisory: SecurityAdvisory,
- pub installed_version: String,
-}
-
-/// A matched advisory that was filtered out by the ignore list.
-#[derive(Debug, Clone)]
-pub struct IgnoredAdvisory {
- pub advisory: SecurityAdvisory,
- pub installed_version: String,
- pub ignore_reason: Option<String>,
-}
-
-/// Result of `Auditor::process_advisories`.
-#[derive(Debug, Default)]
-pub struct ProcessedAdvisories {
- pub advisories: BTreeMap<String, Vec<MatchedAdvisory>>,
- pub ignored_advisories: BTreeMap<String, Vec<IgnoredAdvisory>>,
-}
-
-/// An abandoned package found during audit.
-#[derive(Debug, Clone)]
-pub struct AbandonedPackage {
- pub name: String,
- pub version: String,
- pub replacement: Option<String>,
-}
-
-/// Options passed to `Auditor::audit()`.
-pub struct AuditOptions<'a> {
- pub format: AuditFormat,
- pub warning_only: bool,
- pub ignore_list: &'a IndexMap<String, Option<String>>,
- pub abandoned: AbandonedHandling,
- pub ignored_severities: &'a IndexMap<String, Option<String>>,
- pub ignore_unreachable: bool,
- pub ignore_abandoned: &'a IndexMap<String, Option<String>>,
-}
-
-/// Mirrors `Composer\Advisory\Auditor`.
-pub struct Auditor;
-
-impl Auditor {
- pub fn new() -> Self {
- Self
- }
-
- /// Main audit entry point. Mirrors `Composer\Advisory\Auditor::audit()`.
- ///
- /// Returns a bitmask: 0=ok, 1=vulnerable, 2=abandoned, 3=both.
- pub async fn audit(
- &self,
- console: &Console,
- repo_set: &RepositorySet,
- packages: &[PackageInfo],
- options: &AuditOptions<'_>,
- ) -> anyhow::Result<u8> {
- let format = options.format;
- let (all_advisories, unreachable_repos) = repo_set
- .get_matching_security_advisories(
- packages,
- format == AuditFormat::Summary,
- options.ignore_unreachable,
- )
- .await?;
-
- let ProcessedAdvisories {
- advisories,
- ignored_advisories,
- } = self.process_advisories(
- all_advisories,
- options.ignore_list,
- options.ignored_severities,
- );
-
- let abandoned_packages = if options.abandoned == AbandonedHandling::Ignore {
- vec![]
- } else {
- self.filter_abandoned_packages(packages, options.ignore_abandoned)
- };
-
- let abandoned_count = if options.abandoned == AbandonedHandling::Fail {
- abandoned_packages.len()
- } else {
- 0
- };
-
- let affected_packages_count = advisories.len();
- let bitmask = self.calculate_bitmask(affected_packages_count > 0, abandoned_count > 0);
-
- if format == AuditFormat::Json {
- self.render_json(
- &advisories,
- &ignored_advisories,
- &unreachable_repos,
- &abandoned_packages,
- console,
- );
- return Ok(bitmask);
- }
-
- let (ignored_pkg_count, ignored_total) = self.count_ignored(&ignored_advisories);
- let (active_pkg_count, active_total) = self.count_matched(&advisories);
-
- if active_pkg_count > 0 || ignored_pkg_count > 0 {
- if ignored_pkg_count > 0 {
- let plurality = if ignored_total == 1 { "y" } else { "ies" };
- let pkg_plurality = if ignored_pkg_count == 1 { "" } else { "s" };
- let punctuation = if format == AuditFormat::Summary {
- "."
- } else {
- ":"
- };
- let msg = format!(
- "Found {ignored_total} ignored security vulnerability advisor{plurality} affecting {ignored_pkg_count} package{pkg_plurality}{punctuation}"
- );
- console_writeln_error!(console, "<info>{msg}</info>");
- self.output_advisories_ignored(console, &ignored_advisories, format);
- }
-
- if active_pkg_count > 0 {
- let plurality = if active_total == 1 { "y" } else { "ies" };
- let pkg_plurality = if active_pkg_count == 1 { "" } else { "s" };
- let punctuation = if format == AuditFormat::Summary {
- "."
- } else {
- ":"
- };
- let msg = format!(
- "Found {active_total} security vulnerability advisor{plurality} affecting {active_pkg_count} package{pkg_plurality}{punctuation}"
- );
- if options.warning_only {
- console_writeln_error!(console, "<warning>{msg}</warning>");
- } else {
- console_writeln_error!(console, "<error>{msg}</error>");
- }
- self.output_advisories(console, &advisories, format);
- }
-
- if format == AuditFormat::Summary {
- console_writeln_error!(
- console,
- "Run \"mozart audit\" for a full list of advisories."
- );
- }
- } else {
- console_writeln_error!(
- console,
- "<info>No security vulnerability advisories found.</info>",
- );
- }
-
- if !unreachable_repos.is_empty() {
- console_writeln_error!(
- console,
- "<warning>The following repositories were unreachable:</warning>",
- );
- for repo in &unreachable_repos {
- console_writeln_error!(console, " - {repo}");
- }
- }
-
- if !abandoned_packages.is_empty() && format != AuditFormat::Summary {
- self.output_abandoned_packages(console, &abandoned_packages, format);
- }
-
- Ok(bitmask)
- }
-
- /// Mirrors `Composer\Advisory\Auditor::processAdvisories()`.
- ///
- /// Splits advisories into active and ignored based on the ignore list and ignored severities.
- /// Checks by: package name, advisory ID, severity, CVE, and source remote IDs.
- pub fn process_advisories(
- &self,
- all_advisories: BTreeMap<String, Vec<MatchedAdvisory>>,
- ignore_list: &IndexMap<String, Option<String>>,
- ignored_severities: &IndexMap<String, Option<String>>,
- ) -> ProcessedAdvisories {
- if ignore_list.is_empty() && ignored_severities.is_empty() {
- return ProcessedAdvisories {
- advisories: all_advisories,
- ignored_advisories: BTreeMap::new(),
- };
- }
-
- let mut advisories: BTreeMap<String, Vec<MatchedAdvisory>> = BTreeMap::new();
- let mut ignored: BTreeMap<String, Vec<IgnoredAdvisory>> = BTreeMap::new();
-
- for (package, pkg_advisories) in all_advisories {
- for matched in pkg_advisories {
- let adv = &matched.advisory;
- let mut is_active = true;
- let mut ignore_reason: Option<String> = None;
-
- // Check by package name
- if let Some(reason) = ignore_list.get(&package) {
- is_active = false;
- ignore_reason = reason.clone();
- }
-
- // Check by advisory ID
- if is_active && let Some(reason) = ignore_list.get(&adv.advisory_id) {
- is_active = false;
- ignore_reason = reason.clone();
- }
-
- // Check by severity
- if is_active
- && let Some(ref sev) = adv.severity
- && let Some(reason) = ignored_severities.get(sev.as_str())
- {
- is_active = false;
- ignore_reason = reason
- .clone()
- .or_else(|| Some(format!("{sev} severity is ignored")));
- }
-
- // Check by CVE
- if is_active
- && let Some(ref cve) = adv.cve
- && let Some(reason) = ignore_list.get(cve.as_str())
- {
- is_active = false;
- ignore_reason = reason.clone();
- }
-
- // Check by source remote IDs
- if is_active {
- for source in &adv.sources {
- if let Some(reason) = ignore_list.get(&source.remote_id) {
- is_active = false;
- ignore_reason = reason.clone();
- break;
- }
- }
- }
-
- if is_active {
- advisories.entry(package.clone()).or_default().push(matched);
- } else {
- ignored
- .entry(package.clone())
- .or_default()
- .push(IgnoredAdvisory {
- advisory: matched.advisory,
- installed_version: matched.installed_version,
- ignore_reason,
- });
- }
- }
- }
-
- ProcessedAdvisories {
- advisories,
- ignored_advisories: ignored,
- }
- }
-
- /// Mirrors `Composer\Advisory\Auditor::filterAbandonedPackages()`.
- pub fn filter_abandoned_packages(
- &self,
- packages: &[PackageInfo],
- ignore_abandoned: &IndexMap<String, Option<String>>,
- ) -> Vec<AbandonedPackage> {
- packages
- .iter()
- .filter(|pkg| {
- if !pkg.is_abandoned() {
- return false;
- }
- if !ignore_abandoned.is_empty() {
- let name_lower = pkg.name.to_lowercase();
- // Case-insensitive exact name match (wildcard support deferred)
- if ignore_abandoned
- .keys()
- .any(|k| k.to_lowercase() == name_lower)
- {
- return false;
- }
- }
- true
- })
- .map(|pkg| AbandonedPackage {
- name: pkg.name.clone(),
- version: pkg.version.clone(),
- replacement: pkg.replacement_package().map(|s| s.to_string()),
- })
- .collect()
- }
-
- /// Mirrors `Composer\Advisory\Auditor::needsCompleteAdvisoryLoad()`.
- ///
- /// Mozart always fetches full advisories (no partial optimization), so this is always false.
- pub fn needs_complete_advisory_load(
- &self,
- advisories: &BTreeMap<String, Vec<MatchedAdvisory>>,
- _ignore_list: &IndexMap<String, Option<String>>,
- ) -> bool {
- let _ = advisories;
- false
- }
-
- fn calculate_bitmask(&self, has_vulnerable: bool, has_abandoned: bool) -> u8 {
- let mut bitmask = 0u8;
- if has_vulnerable {
- bitmask |= 1;
- }
- if has_abandoned {
- bitmask |= 2;
- }
- bitmask
- }
-
- fn count_ignored(&self, advisories: &BTreeMap<String, Vec<IgnoredAdvisory>>) -> (usize, usize) {
- let pkg_count = advisories.len();
- let total = advisories.values().map(|v| v.len()).sum();
- (pkg_count, total)
- }
-
- fn count_matched(&self, advisories: &BTreeMap<String, Vec<MatchedAdvisory>>) -> (usize, usize) {
- let pkg_count = advisories.len();
- let total = advisories.values().map(|v| v.len()).sum();
- (pkg_count, total)
- }
-
- fn output_advisories(
- &self,
- console: &Console,
- advisories: &BTreeMap<String, Vec<MatchedAdvisory>>,
- format: AuditFormat,
- ) {
- match format {
- AuditFormat::Table => self.output_advisories_table(console, advisories),
- AuditFormat::Plain => self.output_advisories_plain(console, advisories),
- AuditFormat::Summary => {}
- AuditFormat::Json => unreachable!(),
- }
- }
-
- fn output_advisories_ignored(
- &self,
- console: &Console,
- advisories: &BTreeMap<String, Vec<IgnoredAdvisory>>,
- format: AuditFormat,
- ) {
- match format {
- AuditFormat::Table => self.output_ignored_advisories_table(console, advisories),
- AuditFormat::Plain => self.output_ignored_advisories_plain(console, advisories),
- AuditFormat::Summary => {}
- AuditFormat::Json => unreachable!(),
- }
- }
-
- fn output_advisories_table(
- &self,
- console: &Console,
- advisories: &BTreeMap<String, Vec<MatchedAdvisory>>,
- ) {
- for pkg_advisories in advisories.values() {
- for matched in pkg_advisories {
- self.render_advisory_table(
- console,
- &matched.advisory,
- &matched.installed_version,
- None,
- );
- }
- }
- }
-
- fn output_ignored_advisories_table(
- &self,
- console: &Console,
- advisories: &BTreeMap<String, Vec<IgnoredAdvisory>>,
- ) {
- for pkg_advisories in advisories.values() {
- for ignored in pkg_advisories {
- self.render_advisory_table(
- console,
- &ignored.advisory,
- &ignored.installed_version,
- ignored.ignore_reason.as_deref(),
- );
- }
- }
- }
-
- fn render_advisory_table(
- &self,
- console: &Console,
- adv: &SecurityAdvisory,
- installed_version: &str,
- ignore_reason: Option<&str>,
- ) {
- let label_width = 17usize;
- let mut rows: Vec<(&str, String)> = vec![
- ("Package", adv.package_name.clone()),
- ("Version", installed_version.to_string()),
- ("Severity", adv.severity.clone().unwrap_or_default()),
- ("Advisory ID", adv.advisory_id.clone()),
- (
- "CVE",
- adv.cve.clone().unwrap_or_else(|| "NO CVE".to_string()),
- ),
- ("Title", adv.title.clone()),
- ("URL", adv.link.clone().unwrap_or_default()),
- ("Affected versions", adv.affected_versions.clone()),
- ("Reported at", adv.reported_at.clone()),
- ];
- if let Some(reason) = ignore_reason {
- rows.push(("Ignore reason", reason.to_string()));
- }
-
- let value_width = rows.iter().map(|(_, v)| v.len()).max().unwrap_or(0).max(20);
- let separator = format!(
- "+-{:-<lw$}-+-{:-<vw$}-+",
- "",
- "",
- lw = label_width,
- vw = value_width
- );
-
- console_writeln_error!(console, "{}", separator);
- for (label, value) in &rows {
- console_writeln_error!(
- console,
- "| {:<lw$} | {:<vw$} |",
- label,
- value,
- lw = label_width,
- vw = value_width,
- );
- }
- console_writeln_error!(console, "{}", &separator);
- console_writeln_error!(console, "");
- }
-
- fn output_advisories_plain(
- &self,
- console: &Console,
- advisories: &BTreeMap<String, Vec<MatchedAdvisory>>,
- ) {
- let mut first = true;
- for pkg_advisories in advisories.values() {
- for matched in pkg_advisories {
- if !first {
- console_writeln_error!(console, "--------");
- }
- self.render_advisory_plain(
- console,
- &matched.advisory,
- &matched.installed_version,
- None,
- );
- first = false;
- }
- }
- }
-
- fn output_ignored_advisories_plain(
- &self,
- console: &Console,
- advisories: &BTreeMap<String, Vec<IgnoredAdvisory>>,
- ) {
- let mut first = true;
- for pkg_advisories in advisories.values() {
- for ignored in pkg_advisories {
- if !first {
- console_writeln_error!(console, "--------");
- }
- self.render_advisory_plain(
- console,
- &ignored.advisory,
- &ignored.installed_version,
- ignored.ignore_reason.as_deref(),
- );
- first = false;
- }
- }
- }
-
- fn render_advisory_plain(
- &self,
- console: &Console,
- adv: &SecurityAdvisory,
- installed_version: &str,
- ignore_reason: Option<&str>,
- ) {
- console_writeln_error!(console, "Package: {}", adv.package_name);
- console_writeln_error!(console, "Version: {installed_version}");
- console_writeln_error!(
- console,
- "Severity: {}",
- adv.severity.as_deref().unwrap_or(""),
- );
- console_writeln_error!(console, "Advisory ID: {}", adv.advisory_id);
- console_writeln_error!(console, "CVE: {}", adv.cve.as_deref().unwrap_or("NO CVE"));
- console_writeln_error!(console, "Title: {}", adv.title);
- console_writeln_error!(console, "URL: {}", adv.link.as_deref().unwrap_or(""));
- console_writeln_error!(console, "Affected versions: {}", adv.affected_versions);
- console_writeln_error!(console, "Reported at: {}", adv.reported_at);
- if let Some(reason) = ignore_reason {
- console_writeln_error!(console, "Ignore reason: {reason}");
- }
- }
-
- fn output_abandoned_packages(
- &self,
- console: &Console,
- packages: &[AbandonedPackage],
- format: AuditFormat,
- ) {
- let count = packages.len();
- let plurality = if count == 1 { "" } else { "s" };
- console_writeln_error!(
- console,
- "<error>Found {count} abandoned package{plurality}:</error>",
- );
-
- if format == AuditFormat::Plain {
- for pkg in packages {
- match &pkg.replacement {
- Some(repl) => console_writeln_error!(
- console,
- "{} ({}) is abandoned. Use {} instead.",
- pkg.name,
- pkg.version,
- repl,
- ),
- None => console_writeln_error!(
- console,
- "{} ({}) is abandoned. No replacement was suggested.",
- pkg.name,
- pkg.version,
- ),
- }
- }
- return;
- }
-
- // Table format
- let name_width = 20usize;
- let ver_width = packages
- .iter()
- .map(|a| a.version.len())
- .max()
- .unwrap_or(0)
- .max("Version".len());
- let repl_width = packages
- .iter()
- .map(|a| {
- a.replacement
- .as_deref()
- .unwrap_or("No replacement suggested")
- .len()
- })
- .max()
- .unwrap_or(0)
- .max("Suggested Replacement".len());
-
- console_writeln_error!(
- console,
- "| {:<nw$} | {:<vw$} | {:<rw$} |",
- "Abandoned Package",
- "Version",
- "Suggested Replacement",
- nw = name_width,
- vw = ver_width,
- rw = repl_width,
- );
- console_writeln_error!(
- console,
- "+-{:-<nw$}-+-{:-<vw$}-+-{:-<rw$}-+",
- "",
- "",
- "",
- nw = name_width,
- vw = ver_width,
- rw = repl_width,
- );
- for pkg in packages {
- let replacement = pkg
- .replacement
- .as_deref()
- .unwrap_or("No replacement suggested");
- console_writeln_error!(
- console,
- "| {:<nw$} | {:<vw$} | {:<rw$} |",
- pkg.name,
- pkg.version,
- replacement,
- nw = name_width,
- vw = ver_width,
- rw = repl_width,
- );
- }
- console_writeln_error!(console, "");
- }
-
- fn render_json(
- &self,
- advisories: &BTreeMap<String, Vec<MatchedAdvisory>>,
- ignored_advisories: &BTreeMap<String, Vec<IgnoredAdvisory>>,
- unreachable_repos: &[String],
- abandoned_packages: &[AbandonedPackage],
- console: &Console,
- ) {
- let mut advisories_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
- for (pkg_name, matched_list) in advisories {
- let arr: Vec<serde_json::Value> = matched_list
- .iter()
- .map(|m| serde_json::to_value(&m.advisory).unwrap_or(serde_json::Value::Null))
- .collect();
- advisories_map.insert(pkg_name.clone(), serde_json::Value::Array(arr));
- }
-
- let mut output = serde_json::json!({ "advisories": advisories_map });
-
- // ignored-advisories (only if non-empty)
- if !ignored_advisories.is_empty() {
- let mut ignored_map: serde_json::Map<String, serde_json::Value> =
- serde_json::Map::new();
- for (pkg_name, ignored_list) in ignored_advisories {
- let arr: Vec<serde_json::Value> = ignored_list
- .iter()
- .map(|i| {
- let mut val =
- serde_json::to_value(&i.advisory).unwrap_or(serde_json::Value::Null);
- if let serde_json::Value::Object(ref mut obj) = val {
- obj.insert(
- "ignoreReason".to_string(),
- i.ignore_reason
- .as_ref()
- .map(|r| serde_json::Value::String(r.clone()))
- .unwrap_or(serde_json::Value::Null),
- );
- }
- val
- })
- .collect();
- ignored_map.insert(pkg_name.clone(), serde_json::Value::Array(arr));
- }
- if let serde_json::Value::Object(ref mut obj) = output {
- obj.insert(
- "ignored-advisories".to_string(),
- serde_json::Value::Object(ignored_map),
- );
- }
- }
-
- // unreachable-repositories (only if non-empty)
- if !unreachable_repos.is_empty() {
- let repos_arr: Vec<serde_json::Value> = unreachable_repos
- .iter()
- .map(|r| serde_json::Value::String(r.clone()))
- .collect();
- if let serde_json::Value::Object(ref mut obj) = output {
- obj.insert(
- "unreachable-repositories".to_string(),
- serde_json::Value::Array(repos_arr),
- );
- }
- }
-
- // abandoned map: package_name => replacement (null if none)
- let mut abandoned_map: serde_json::Map<String, serde_json::Value> = serde_json::Map::new();
- for pkg in abandoned_packages {
- abandoned_map.insert(
- pkg.name.clone(),
- pkg.replacement
- .as_ref()
- .map(|r| serde_json::Value::String(r.clone()))
- .unwrap_or(serde_json::Value::Null),
- );
- }
- if let serde_json::Value::Object(ref mut obj) = output {
- obj.insert(
- "abandoned".to_string(),
- serde_json::Value::Object(abandoned_map),
- );
- }
-
- let json_str = serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string());
- console_writeln!(console, "{}", &json_str);
- }
-}
-
-impl Default for Auditor {
- fn default() -> Self {
- Self::new()
- }
-}
diff --git a/crates/mozart-registry/src/browse_repos.rs b/crates/mozart-registry/src/browse_repos.rs
deleted file mode 100644
index 0f9b169..0000000
--- a/crates/mozart-registry/src/browse_repos.rs
+++ /dev/null
@@ -1,293 +0,0 @@
-//! Composite of repositories consulted by the `browse` command.
-//!
-//! Mirrors `Composer\Command\HomeCommand::initializeRepos()`:
-//! root package + local installed repository + remote(s). Each repo
-//! exposes a uniform [`BrowseRepo::find_packages`] that yields
-//! [`CompletePackageView`]s — the trio of fields
-//! `Composer\Command\HomeCommand::handlePackage` reads off
-//! `CompletePackageInterface` (`getSupport()['source']`,
-//! `getSourceUrl()`, `getHomepage()`).
-
-use crate::cache::Cache;
-use crate::installed::{InstalledPackageEntry, InstalledPackages};
-use crate::lockfile::LockedPackage;
-use crate::packagist::{self, PackagistVersion};
-use mozart_core::package::RawPackageData;
-
-/// Subset of `Composer\Package\CompletePackageInterface` consumed by
-/// `HomeCommand::handlePackage`. Every backing repo flattens its
-/// package shape into this so URL selection lives in one place.
-#[derive(Debug, Clone, Default, PartialEq, Eq)]
-pub struct CompletePackageView {
- /// `$package->getSupport()['source']`.
- pub support_source: Option<String>,
- /// `$package->getSourceUrl()`.
- pub source_url: Option<String>,
- /// `$package->getHomepage()`.
- pub homepage: Option<String>,
-}
-
-impl From<&LockedPackage> for CompletePackageView {
- fn from(pkg: &LockedPackage) -> Self {
- Self {
- support_source: pkg
- .support
- .as_ref()
- .and_then(|s| s.get("source"))
- .and_then(|s| s.as_str())
- .map(str::to_string),
- source_url: pkg.source.as_ref().map(|s| s.url.clone()),
- homepage: pkg.homepage.clone(),
- }
- }
-}
-
-impl From<&InstalledPackageEntry> for CompletePackageView {
- fn from(pkg: &InstalledPackageEntry) -> Self {
- Self {
- support_source: pkg
- .support
- .as_ref()
- .and_then(|s| s.get("source"))
- .and_then(|s| s.as_str())
- .map(str::to_string),
- source_url: pkg
- .source
- .as_ref()
- .and_then(|s| s.get("url"))
- .and_then(|s| s.as_str())
- .map(str::to_string),
- homepage: pkg.homepage.clone(),
- }
- }
-}
-
-impl From<&PackagistVersion> for CompletePackageView {
- fn from(pkg: &PackagistVersion) -> Self {
- Self {
- support_source: pkg
- .support
- .as_ref()
- .and_then(|s| s.get("source"))
- .and_then(|s| s.as_str())
- .map(str::to_string),
- source_url: pkg.source.as_ref().map(|s| s.url.clone()),
- homepage: pkg.homepage.clone(),
- }
- }
-}
-
-/// `RawPackageData` lacks a typed `support` field — the root package's
-/// `support` block lives inside `extra_fields` because the schema is not
-/// yet ported. Read it manually here.
-pub fn view_from_raw(pkg: &RawPackageData) -> CompletePackageView {
- CompletePackageView {
- support_source: pkg
- .extra_fields
- .get("support")
- .and_then(|s| s.get("source"))
- .and_then(|s| s.as_str())
- .map(str::to_string),
- source_url: None,
- homepage: pkg.homepage.clone(),
- }
-}
-
-/// One repository in the composite. Mirrors the three repo kinds
-/// `HomeCommand::initializeRepos()` returns:
-/// `RootPackageRepository` + local installed + remotes.
-pub enum BrowseRepo {
- /// Stand-in for `Composer\Repository\RootPackageRepository` —
- /// a one-package array containing the root composer.json.
- /// Boxed because `RawPackageData` is much larger than the other
- /// variants (clippy::large_enum_variant).
- Root(Box<RawPackageData>),
- /// Stand-in for `RepositoryManager::getLocalRepository()` —
- /// the installed.json view of `vendor/`.
- Installed(InstalledPackages),
- /// Stand-in for the configured remote. For now Mozart only knows
- /// the default Packagist remote (`RepositoryFactory::defaultRepos`).
- Packagist { cache: Cache },
-}
-
-impl BrowseRepo {
- /// Mirrors `RepositoryInterface::findPackages($name)` — case-insensitive
- /// match by package name, returning every match the repo holds.
- pub async fn find_packages(&self, name: &str) -> anyhow::Result<Vec<CompletePackageView>> {
- match self {
- BrowseRepo::Root(pkg) => {
- if pkg.name.eq_ignore_ascii_case(name) {
- Ok(vec![view_from_raw(pkg)])
- } else {
- Ok(Vec::new())
- }
- }
- BrowseRepo::Installed(installed) => Ok(installed
- .packages
- .iter()
- .filter(|p| p.name.eq_ignore_ascii_case(name))
- .map(CompletePackageView::from)
- .collect()),
- BrowseRepo::Packagist { cache } => {
- let versions = packagist::fetch_package_versions(name, cache).await?;
- Ok(versions.iter().map(CompletePackageView::from).collect())
- }
- }
- }
-}
-
-/// Ordered composite consulted by `HomeCommand::execute()`'s outer
-/// `foreach ($repos as $repo)` loop.
-pub struct BrowseRepos {
- repos: Vec<BrowseRepo>,
-}
-
-impl BrowseRepos {
- /// Build the composite. `root` and `installed` are passed in
- /// rather than read here so callers can decide whether to load
- /// them from `Composer` (when composer.json is present) or skip
- /// them entirely (the `defaultReposWithDefaultManager` fallback).
- pub fn new(
- root: Option<RawPackageData>,
- installed: Option<InstalledPackages>,
- packagist_cache: Cache,
- ) -> Self {
- let mut repos: Vec<BrowseRepo> = Vec::with_capacity(3);
- if let Some(root) = root {
- repos.push(BrowseRepo::Root(Box::new(root)));
- }
- if let Some(installed) = installed {
- repos.push(BrowseRepo::Installed(installed));
- }
- repos.push(BrowseRepo::Packagist {
- cache: packagist_cache,
- });
- Self { repos }
- }
-
- pub fn iter(&self) -> std::slice::Iter<'_, BrowseRepo> {
- self.repos.iter()
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use std::collections::BTreeMap;
-
- fn locked(
- name: &str,
- source_url: Option<&str>,
- homepage: Option<&str>,
- support_source: Option<&str>,
- ) -> LockedPackage {
- LockedPackage {
- name: name.to_string(),
- version: "1.0.0".to_string(),
- version_normalized: None,
- source: source_url.map(|url| crate::lockfile::LockedSource {
- source_type: "git".to_string(),
- url: url.to_string(),
- reference: None,
- }),
- dist: None,
- require: BTreeMap::new(),
- require_dev: BTreeMap::new(),
- conflict: BTreeMap::new(),
- provide: BTreeMap::new(),
- replace: BTreeMap::new(),
- suggest: None,
- package_type: None,
- autoload: None,
- autoload_dev: None,
- license: None,
- description: None,
- homepage: homepage.map(str::to_string),
- keywords: None,
- authors: None,
- support: support_source.map(|s| serde_json::json!({"source": s})),
- funding: None,
- time: None,
- extra_fields: BTreeMap::new(),
- }
- }
-
- #[test]
- fn view_from_locked_package_carries_three_urls() {
- let pkg = locked(
- "vendor/pkg",
- Some("https://github.com/vendor/pkg.git"),
- Some("https://vendor.example.com"),
- Some("https://github.com/vendor/pkg"),
- );
- let view = CompletePackageView::from(&pkg);
- assert_eq!(
- view.support_source.as_deref(),
- Some("https://github.com/vendor/pkg")
- );
- assert_eq!(
- view.source_url.as_deref(),
- Some("https://github.com/vendor/pkg.git")
- );
- assert_eq!(view.homepage.as_deref(), Some("https://vendor.example.com"));
- }
-
- #[test]
- fn view_from_installed_entry_extracts_source_url() {
- let mut entry = InstalledPackageEntry {
- name: "vendor/pkg".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: None,
- source: Some(serde_json::json!({"url": "https://github.com/vendor/pkg.git"})),
- dist: None,
- package_type: None,
- install_path: None,
- autoload: None,
- aliases: vec![],
- homepage: Some("https://vendor.example.com".to_string()),
- support: Some(serde_json::json!({"source": "https://github.com/vendor/pkg"})),
- extra_fields: BTreeMap::new(),
- };
- let view = CompletePackageView::from(&entry);
- assert_eq!(
- view.source_url.as_deref(),
- Some("https://github.com/vendor/pkg.git")
- );
- assert_eq!(
- view.support_source.as_deref(),
- Some("https://github.com/vendor/pkg")
- );
- assert_eq!(view.homepage.as_deref(), Some("https://vendor.example.com"));
-
- entry.support = None;
- entry.source = None;
- entry.homepage = None;
- let empty = CompletePackageView::from(&entry);
- assert_eq!(empty, CompletePackageView::default());
- }
-
- #[test]
- fn view_from_raw_reads_support_via_extra_fields() {
- let mut raw = RawPackageData::new("vendor/root".to_string());
- raw.homepage = Some("https://vendor.example.com".to_string());
- raw.extra_fields.insert(
- "support".to_string(),
- serde_json::json!({"source": "https://github.com/vendor/root"}),
- );
- let view = view_from_raw(&raw);
- assert_eq!(
- view.support_source.as_deref(),
- Some("https://github.com/vendor/root")
- );
- assert!(view.source_url.is_none());
- assert_eq!(view.homepage.as_deref(), Some("https://vendor.example.com"));
- }
-
- #[tokio::test]
- async fn root_repo_matches_case_insensitively() {
- let raw = RawPackageData::new("Vendor/Root".to_string());
- let repo = BrowseRepo::Root(Box::new(raw));
- assert_eq!(repo.find_packages("vendor/root").await.unwrap().len(), 1);
- assert_eq!(repo.find_packages("other/pkg").await.unwrap().len(), 0);
- }
-}
diff --git a/crates/mozart-registry/src/cache.rs b/crates/mozart-registry/src/cache.rs
deleted file mode 100644
index 39e3e8d..0000000
--- a/crates/mozart-registry/src/cache.rs
+++ /dev/null
@@ -1,575 +0,0 @@
-//! Filesystem-backed cache system with TTL expiration and size-limited GC.
-//!
-//! Cache directory structure:
-//! ```text
-//! ~/.cache/mozart/ (or $COMPOSER_CACHE_DIR)
-//! files/ dist archives (key: vendor~package~reference.ext)
-//! repo/ API responses (key: provider-vendor~package.json)
-//! vcs/ VCS mirrors (one subdir per sanitized URL)
-//! ```
-
-use std::fs;
-use std::path::{Path, PathBuf};
-use std::time::{SystemTime, UNIX_EPOCH};
-
-/// Configuration for the Mozart cache system.
-pub struct CacheConfig {
- /// Root cache directory (e.g. `~/.cache/mozart`).
- pub cache_dir: PathBuf,
- /// Directory for dist archives.
- pub cache_files_dir: PathBuf,
- /// Directory for API responses.
- pub cache_repo_dir: PathBuf,
- /// Directory for VCS mirrors (one subdirectory per sanitized URL).
- pub cache_vcs_dir: PathBuf,
- /// TTL in seconds for repo entries (default: 15,552,000 = 6 months).
- pub cache_ttl: u64,
- /// TTL in seconds for files entries (falls back to `cache_ttl`).
- pub cache_files_ttl: u64,
- /// Maximum size of the files cache in bytes (default: 300 MiB).
- pub cache_files_maxsize: u64,
- /// Whether the cache is read-only (no writes).
- pub read_only: bool,
-}
-
-impl CacheConfig {
- /// Default TTL: 6 months in seconds.
- pub const DEFAULT_TTL: u64 = 15_552_000;
- /// Default max files cache size: 300 MiB.
- pub const DEFAULT_FILES_MAXSIZE: u64 = 300 * 1024 * 1024;
-}
-
-/// Build a `CacheConfig` from CLI flags and environment variables.
-///
-/// Respects `$COMPOSER_CACHE_DIR` for the base directory, and
-/// `$COMPOSER_NO_CACHE` / `COMPOSER_CACHE_READ_ONLY` env vars.
-///
-/// When no-cache mode is active (via `cli_no_cache` or `$COMPOSER_NO_CACHE`),
-/// all cache directories are set to a null device, mirroring Composer's
-/// `Application::doRun()` which calls `putenv('COMPOSER_CACHE_DIR', '/dev/null')`.
-pub fn build_cache_config(cli_no_cache: bool) -> CacheConfig {
- let no_cache = std::env::var("COMPOSER_NO_CACHE").is_ok() || cli_no_cache;
-
- let read_only = std::env::var("COMPOSER_CACHE_READ_ONLY")
- .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
- .unwrap_or(false);
-
- let cache_dir = if no_cache {
- // Mirrors Composer: --no-cache redirects all cache paths to a null device so
- // that Cache::is_usable() returns false and caching is transparently disabled.
- #[cfg(windows)]
- {
- PathBuf::from("nul")
- }
- #[cfg(not(windows))]
- {
- PathBuf::from("/dev/null")
- }
- } else if let Ok(dir) = std::env::var("COMPOSER_CACHE_DIR") {
- PathBuf::from(dir)
- } else {
- dirs_cache_dir().join("mozart")
- };
-
- let cache_files_dir = cache_dir.join("files");
- let cache_repo_dir = cache_dir.join("repo");
- let cache_vcs_dir = std::env::var("COMPOSER_CACHE_VCS_DIR")
- .map(PathBuf::from)
- .unwrap_or_else(|_| cache_dir.join("vcs"));
-
- CacheConfig {
- cache_files_dir,
- cache_repo_dir,
- cache_vcs_dir,
- cache_ttl: CacheConfig::DEFAULT_TTL,
- cache_files_ttl: CacheConfig::DEFAULT_TTL,
- cache_files_maxsize: CacheConfig::DEFAULT_FILES_MAXSIZE,
- cache_dir,
- read_only,
- }
-}
-
-/// Return the platform cache directory (XDG_CACHE_HOME or ~/.cache).
-fn dirs_cache_dir() -> PathBuf {
- if let Ok(xdg) = std::env::var("XDG_CACHE_HOME") {
- return PathBuf::from(xdg);
- }
- if let Ok(home) = std::env::var("HOME") {
- return PathBuf::from(home).join(".cache");
- }
- PathBuf::from("/tmp")
-}
-
-/// A single cache bucket (a directory on disk).
-#[derive(Clone)]
-pub struct Cache {
- root: PathBuf,
- enabled: bool,
- readonly: bool,
-}
-
-impl Cache {
- /// Create a new cache rooted at `root`.
- ///
- /// Mirrors Composer's `Cache::__construct` + `Cache::isEnabled()`:
- /// - If the path is a null device (`/dev/null`, `nul`, etc.), the cache is disabled.
- /// - If `readonly` is true, the cache is always enabled (no writability check).
- /// - Otherwise, tries to create the directory and checks that it is writable;
- /// disables the cache with a warning if not.
- pub fn new(root: PathBuf, readonly: bool) -> Self {
- let enabled = if !Self::is_usable(&root) {
- false
- } else if readonly {
- true
- } else {
- if fs::create_dir_all(&root).is_err() {
- false
- } else {
- fs::metadata(&root)
- .map(|m| !m.permissions().readonly())
- .unwrap_or(false)
- }
- };
- Self {
- root,
- enabled,
- readonly,
- }
- }
-
- /// Returns `false` for null-device paths that should never be used as a real cache.
- ///
- /// Mirrors Composer's `Cache::isUsable()`.
- fn is_usable(path: &Path) -> bool {
- let s = path.to_string_lossy();
- if cfg!(windows) {
- // On Windows, "nul" and "$null" (any case) are null devices.
- !s.split(['/', '\\'])
- .any(|c| c.eq_ignore_ascii_case("nul") || c == "$null")
- } else {
- // On Unix, /dev/null and any path under it are unusable.
- s != "/dev/null" && !s.starts_with("/dev/null/")
- }
- }
-
- /// Shorthand: create the repo cache from a `CacheConfig`.
- pub fn repo(config: &CacheConfig) -> Self {
- Self::new(config.cache_repo_dir.clone(), config.read_only)
- }
-
- /// Shorthand: create the files cache from a `CacheConfig`.
- pub fn files(config: &CacheConfig) -> Self {
- Self::new(config.cache_files_dir.clone(), config.read_only)
- }
-
- /// Whether caching is enabled for this bucket.
- pub fn is_enabled(&self) -> bool {
- self.enabled
- }
-
- /// Sanitize a cache key for use as a filename.
- ///
- /// Replaces `/` with `~` and strips characters that are unsafe in
- /// filenames (anything except alphanumerics, `-`, `_`, `.`, `~`).
- pub fn sanitize_key(key: &str) -> String {
- key.replace('/', "~")
- .chars()
- .filter(|c| c.is_alphanumeric() || matches!(c, '-' | '_' | '.' | '~'))
- .collect()
- }
-
- /// Return the full path for a cache entry.
- fn path_for(&self, key: &str) -> PathBuf {
- self.root.join(Self::sanitize_key(key))
- }
-
- /// Read a cached string entry, or `None` if absent or cache disabled.
- pub fn read(&self, key: &str) -> Option<String> {
- if !self.enabled {
- return None;
- }
- fs::read_to_string(self.path_for(key)).ok()
- }
-
- /// Write a string entry atomically (write to temp file, then rename).
- pub fn write(&self, key: &str, contents: &str) -> anyhow::Result<()> {
- if !self.enabled || self.readonly {
- return Ok(());
- }
- self.write_bytes(key, contents.as_bytes())
- }
-
- /// Read a cached binary entry, or `None` if absent or cache disabled.
- pub fn read_bytes(&self, key: &str) -> Option<Vec<u8>> {
- if !self.enabled {
- return None;
- }
- fs::read(self.path_for(key)).ok()
- }
-
- /// Write a binary entry atomically (write to temp file, then rename).
- pub fn write_bytes(&self, key: &str, data: &[u8]) -> anyhow::Result<()> {
- if !self.enabled || self.readonly {
- return Ok(());
- }
- let dest = self.path_for(key);
- // Ensure parent directory exists
- if let Some(parent) = dest.parent() {
- fs::create_dir_all(parent)?;
- }
- // Write to a temp file next to the destination
- let tmp = dest.with_extension("tmp");
- fs::write(&tmp, data)?;
- fs::rename(&tmp, &dest)?;
- Ok(())
- }
-
- /// Delete all cached entries in this bucket.
- pub fn clear(&self) -> anyhow::Result<()> {
- if !self.enabled || self.readonly {
- return Ok(());
- }
- if !self.root.exists() {
- return Ok(());
- }
- for entry in fs::read_dir(&self.root)? {
- let entry = entry?;
- let path = entry.path();
- if path.is_file() {
- fs::remove_file(&path)?;
- } else if path.is_dir() {
- fs::remove_dir_all(&path)?;
- }
- }
- Ok(())
- }
-
- /// Run garbage collection on this cache bucket.
- ///
- /// 1. Deletes files with mtime older than `ttl_seconds`.
- /// 2. If total remaining size > `max_size_bytes`, deletes the oldest files
- /// (by mtime) until the total is under the limit.
- pub fn gc(&self, ttl_seconds: u64, max_size_bytes: u64) -> anyhow::Result<()> {
- if !self.enabled || self.readonly || !self.root.exists() {
- return Ok(());
- }
-
- let now = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .unwrap_or_default()
- .as_secs();
-
- // Collect (path, mtime, size) for all files
- let mut files: Vec<(PathBuf, u64, u64)> = Vec::new();
- collect_files(&self.root, &mut files)?;
-
- // Phase 1: delete TTL-expired files
- let mut remaining: Vec<(PathBuf, u64, u64)> = Vec::new();
- for (path, mtime, size) in files {
- let age = now.saturating_sub(mtime);
- if age > ttl_seconds {
- let _ = fs::remove_file(&path);
- } else {
- remaining.push((path, mtime, size));
- }
- }
-
- // Phase 2: enforce size limit by deleting oldest first
- let total_size: u64 = remaining.iter().map(|(_, _, sz)| sz).sum();
- if total_size > max_size_bytes {
- // Sort by mtime ascending (oldest first)
- remaining.sort_by_key(|(_, mtime, _)| *mtime);
- let mut current_size = total_size;
- for (path, _, size) in &remaining {
- if current_size <= max_size_bytes {
- break;
- }
- if fs::remove_file(path).is_ok() {
- current_size = current_size.saturating_sub(*size);
- }
- }
- }
-
- Ok(())
- }
-
- /// Run garbage collection on a VCS cache bucket.
- ///
- /// Each top-level subdirectory is one bare mirror keyed by sanitized URL.
- /// Deletes entire subdirectories whose mtime is older than `ttl_seconds`.
- /// Mirrors Composer's `Cache::gcVcsCache`.
- pub fn gc_vcs_cache(&self, ttl_seconds: u64) -> anyhow::Result<()> {
- if !self.enabled || !self.root.exists() {
- return Ok(());
- }
-
- let now = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .unwrap_or_default()
- .as_secs();
-
- for entry in fs::read_dir(&self.root)? {
- let entry = entry?;
- let path = entry.path();
- let metadata = entry.metadata()?;
- if !metadata.is_dir() {
- continue;
- }
- let mtime = metadata
- .modified()
- .ok()
- .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
- .map(|d| d.as_secs())
- .unwrap_or(0);
- if now.saturating_sub(mtime) > ttl_seconds {
- let _ = fs::remove_dir_all(&path);
- }
- }
-
- Ok(())
- }
-
- /// Return the age in seconds of a cached entry based on its mtime,
- /// or `None` if the entry doesn't exist or mtime can't be read.
- pub fn age(&self, key: &str) -> Option<u64> {
- if !self.enabled {
- return None;
- }
- let path = self.path_for(key);
- let metadata = fs::metadata(&path).ok()?;
- let mtime = metadata.modified().ok()?;
- let now = SystemTime::now();
- now.duration_since(mtime).ok().map(|d| d.as_secs())
- }
-}
-
-/// Recursively collect all files under `dir` as `(path, mtime_secs, size_bytes)`.
-fn collect_files(dir: &Path, out: &mut Vec<(PathBuf, u64, u64)>) -> anyhow::Result<()> {
- if !dir.exists() {
- return Ok(());
- }
- for entry in fs::read_dir(dir)? {
- let entry = entry?;
- let path = entry.path();
- let metadata = entry.metadata()?;
- if metadata.is_dir() {
- collect_files(&path, out)?;
- } else if metadata.is_file() {
- let mtime = metadata
- .modified()
- .ok()
- .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
- .map(|d| d.as_secs())
- .unwrap_or(0);
- let size = metadata.len();
- out.push((path, mtime, size));
- }
- }
- Ok(())
-}
-
-/// Return `true` with a probability of 1 in 50 (based on system time nanos).
-///
-/// Used to decide whether to run GC after an install/update operation.
-pub fn gc_is_necessary() -> bool {
- let nanos = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .unwrap_or_default()
- .subsec_nanos();
- nanos.is_multiple_of(50)
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use std::time::Duration;
- use tempfile::tempdir;
-
- #[test]
- fn test_sanitize_key_replaces_slash() {
- assert_eq!(Cache::sanitize_key("vendor/package"), "vendor~package");
- }
-
- #[test]
- fn test_sanitize_key_strips_unsafe_chars() {
- // Colons and spaces should be stripped
- assert_eq!(Cache::sanitize_key("foo:bar baz"), "foobarbaz");
- }
-
- #[test]
- fn test_sanitize_key_preserves_safe_chars() {
- let key = "provider-vendor~package.json";
- assert_eq!(Cache::sanitize_key(key), key);
- }
-
- #[test]
- fn test_sanitize_key_full_example() {
- assert_eq!(
- Cache::sanitize_key("provider-monolog/monolog.json"),
- "provider-monolog~monolog.json"
- );
- }
-
- #[test]
- fn test_write_read_roundtrip_string() {
- let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), false);
-
- cache.write("test-key", "hello world").unwrap();
- let result = cache.read("test-key");
- assert_eq!(result.as_deref(), Some("hello world"));
- }
-
- #[test]
- fn test_write_read_roundtrip_bytes() {
- let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), false);
-
- let data = vec![0u8, 1, 2, 3, 255];
- cache.write_bytes("bin-key", &data).unwrap();
- let result = cache.read_bytes("bin-key");
- assert_eq!(result, Some(data));
- }
-
- #[test]
- fn test_clear_removes_all_entries() {
- let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), false);
-
- cache.write("key1", "value1").unwrap();
- cache.write("key2", "value2").unwrap();
- assert!(cache.read("key1").is_some());
- assert!(cache.read("key2").is_some());
-
- cache.clear().unwrap();
-
- assert!(cache.read("key1").is_none());
- assert!(cache.read("key2").is_none());
- }
-
- #[test]
- fn test_disabled_cache_returns_none() {
- // Point cache at /dev/null — is_usable() returns false → cache disabled.
- let cache = Cache::new(PathBuf::from("/dev/null/files"), false);
-
- // Write should silently succeed (no-op)
- cache.write("key", "value").unwrap();
-
- // Read should return None even if we wrote
- assert!(cache.read("key").is_none());
- assert!(cache.read_bytes("key").is_none());
- }
-
- #[test]
- fn test_gc_ttl_expiration() {
- let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), false);
-
- // Write a file, then manually set its mtime to the past
- cache.write("old-key", "old content").unwrap();
- let old_path = dir.path().join(Cache::sanitize_key("old-key"));
-
- // Write a fresh file
- cache.write("new-key", "new content").unwrap();
-
- // Set the old file's mtime to 2 hours ago
- let two_hours_ago = SystemTime::now() - Duration::from_secs(7200);
- filetime::set_file_mtime(
- &old_path,
- filetime::FileTime::from_system_time(two_hours_ago),
- )
- .unwrap();
-
- // GC with TTL of 1 hour (3600 seconds)
- cache.gc(3600, u64::MAX).unwrap();
-
- // Old file should be deleted, new file should remain
- assert!(
- cache.read("old-key").is_none(),
- "expired file should be deleted"
- );
- assert!(cache.read("new-key").is_some(), "fresh file should remain");
- }
-
- #[test]
- fn test_gc_size_limit() {
- let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), false);
-
- // Write two files; the first one should be older
- cache.write("old-file", "aaaaaaaaaa").unwrap(); // 10 bytes
- let old_path = dir.path().join(Cache::sanitize_key("old-file"));
-
- // Add a small delay before writing second file via mtime manipulation
- cache.write("new-file", "bbbbbbbbbb").unwrap(); // 10 bytes
-
- // Set old-file's mtime to 1 second ago so it's older
- let one_second_ago = SystemTime::now() - Duration::from_secs(1);
- filetime::set_file_mtime(
- &old_path,
- filetime::FileTime::from_system_time(one_second_ago),
- )
- .unwrap();
-
- // GC with a max size of 12 bytes (can only fit one 10-byte file)
- // TTL is very long so no TTL expiration
- cache.gc(u64::MAX / 2, 12).unwrap();
-
- // The older file should be removed to get under the size limit
- assert!(
- cache.read("old-file").is_none() || cache.read("new-file").is_none(),
- "at least one file should be removed to enforce size limit"
- );
- }
-
- #[test]
- fn test_gc_vcs_removes_old_subdirs() {
- let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), false);
-
- let old_mirror = dir.path().join("old-mirror");
- let new_mirror = dir.path().join("new-mirror");
- fs::create_dir_all(&old_mirror).unwrap();
- fs::write(old_mirror.join("HEAD"), "ref: refs/heads/main\n").unwrap();
- fs::create_dir_all(&new_mirror).unwrap();
- fs::write(new_mirror.join("HEAD"), "ref: refs/heads/main\n").unwrap();
-
- let two_hours_ago = SystemTime::now() - Duration::from_secs(7200);
- filetime::set_file_mtime(
- &old_mirror,
- filetime::FileTime::from_system_time(two_hours_ago),
- )
- .unwrap();
-
- cache.gc_vcs_cache(3600).unwrap();
-
- assert!(!old_mirror.exists(), "expired mirror should be removed");
- assert!(new_mirror.exists(), "fresh mirror should remain");
- }
-
- #[test]
- fn test_age_existing_entry() {
- let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), false);
-
- cache.write("fresh-key", "content").unwrap();
- let age = cache.age("fresh-key");
-
- // Should be very recent (< 5 seconds)
- assert!(age.is_some());
- assert!(age.unwrap() < 5);
- }
-
- #[test]
- fn test_age_missing_entry() {
- let dir = tempdir().unwrap();
- let cache = Cache::new(dir.path().to_path_buf(), false);
- assert!(cache.age("nonexistent-key").is_none());
- }
-
- #[test]
- fn test_age_disabled_cache() {
- let cache = Cache::new(PathBuf::from("/dev/null/files"), false);
- assert!(cache.age("any-key").is_none());
- }
-}
diff --git a/crates/mozart-registry/src/composer_repo.rs b/crates/mozart-registry/src/composer_repo.rs
deleted file mode 100644
index ef091ef..0000000
--- a/crates/mozart-registry/src/composer_repo.rs
+++ /dev/null
@@ -1,173 +0,0 @@
-//! Support for `type: composer` repositories.
-//!
-//! A Composer repository is a directory (or HTTP endpoint) hosting a
-//! `packages.json` file. The legacy format embeds full package metadata
-//! directly:
-//!
-//! ```json
-//! {
-//! "packages": {
-//! "a/a": {
-//! "dev-foobar": { "name": "a/a", "version": "dev-foobar", ... }
-//! }
-//! }
-//! }
-//! ```
-//!
-//! Mirrors `Composer\Repository\ComposerRepository` for the file:// case
-//! used by the test fixtures. Lazy / v2 / provider-includes / metadata-url
-//! variants are out of scope here — the in-process installer fixtures only
-//! exercise the legacy embedded-packages form.
-
-use crate::packagist::PackagistVersion;
-use crate::repository_filter::RepositoryFilter;
-use indexmap::IndexSet;
-use mozart_core::package::RawRepository;
-use std::path::PathBuf;
-
-/// One package version drawn from a `type: composer` repository.
-pub struct ComposerRepoPackage {
- pub name: String,
- pub version: PackagistVersion,
-}
-
-/// Read every package version from `type: composer` repositories declared in
-/// `composer.json`. Only `file://` URLs are supported here — they're what
-/// the installer fixtures use after the harness rewrites
-/// `file://foobar` → `file:///abs/path/to/fixtures/foobar`.
-pub fn collect_composer_packages(repositories: &[RawRepository]) -> Vec<ComposerRepoPackage> {
- let mut out = Vec::new();
- let mut claimed: IndexSet<String> = IndexSet::new();
- for repo in repositories {
- if repo.repo_type != "composer" {
- continue;
- }
- let Some(url) = repo.url.as_deref() else {
- continue;
- };
- let Some(dir) = file_url_to_path(url) else {
- continue;
- };
- let packages_json = dir.join("packages.json");
- let Ok(content) = std::fs::read_to_string(&packages_json) else {
- continue;
- };
- let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&content) else {
- continue;
- };
- let Some(packages) = parsed.get("packages").and_then(|v| v.as_object()) else {
- continue;
- };
- let filter = RepositoryFilter::from_repo(repo);
- let mut names_this_repo: IndexSet<String> = IndexSet::new();
- for (name, versions) in packages {
- if !filter.is_allowed(name) {
- continue;
- }
- if claimed.contains(name) {
- continue;
- }
- let Some(versions_obj) = versions.as_object() else {
- continue;
- };
- let mut emitted = false;
- for (_, version_value) in versions_obj {
- if let Ok(pv) = serde_json::from_value::<PackagistVersion>(version_value.clone()) {
- out.push(ComposerRepoPackage {
- name: name.clone(),
- version: pv,
- });
- emitted = true;
- }
- }
- if emitted {
- names_this_repo.insert(name.clone());
- }
- }
- if filter.canonical {
- claimed.extend(names_this_repo);
- }
- }
- out
-}
-
-/// Turn a `file://` URL into a filesystem path. Accepts both
-/// `file:///abs/path` (RFC 8089 form) and `file://abs/path` (Composer's
-/// loose form). Returns `None` for non-`file://` URLs.
-fn file_url_to_path(url: &str) -> Option<PathBuf> {
- let rest = url.strip_prefix("file://")?;
- // RFC 8089: file:///abs/path → empty authority, rest starts with `/`.
- // Composer's harness writes `file:///abs/...` after rewriting, so the
- // typical input here is one leading `/`.
- Some(PathBuf::from(rest))
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use std::fs;
- use tempfile::TempDir;
-
- fn write_packages_json(dir: &std::path::Path, body: &str) {
- fs::write(dir.join("packages.json"), body).unwrap();
- }
-
- fn composer_repo(url: String) -> RawRepository {
- RawRepository {
- repo_type: "composer".to_string(),
- url: Some(url),
- package: None,
- only: None,
- exclude: None,
- canonical: None,
- security_advisories: None,
- }
- }
-
- #[test]
- fn reads_legacy_packages_json() {
- let tmp = TempDir::new().unwrap();
- write_packages_json(
- tmp.path(),
- r#"{
- "packages": {
- "a/a": {
- "dev-foobar": {
- "name": "a/a",
- "version": "dev-foobar",
- "version_normalized": "dev-foobar"
- }
- }
- }
- }"#,
- );
- let url = format!("file://{}", tmp.path().display());
- let repos = vec![composer_repo(url)];
- let pkgs = collect_composer_packages(&repos);
- assert_eq!(pkgs.len(), 1);
- assert_eq!(pkgs[0].name, "a/a");
- assert_eq!(pkgs[0].version.version, "dev-foobar");
- }
-
- #[test]
- fn ignores_non_composer_types() {
- let repos = vec![RawRepository {
- repo_type: "vcs".to_string(),
- url: Some("https://example.com/foo.git".to_string()),
- package: None,
- only: None,
- exclude: None,
- canonical: None,
- security_advisories: None,
- }];
- assert!(collect_composer_packages(&repos).is_empty());
- }
-
- #[test]
- fn skips_missing_packages_json() {
- let tmp = TempDir::new().unwrap();
- let url = format!("file://{}", tmp.path().display());
- let repos = vec![composer_repo(url)];
- assert!(collect_composer_packages(&repos).is_empty());
- }
-}
diff --git a/crates/mozart-registry/src/download_manager.rs b/crates/mozart-registry/src/download_manager.rs
deleted file mode 100644
index 7c6ff73..0000000
--- a/crates/mozart-registry/src/download_manager.rs
+++ /dev/null
@@ -1,143 +0,0 @@
-//! `DownloadManager` — pick the right [`VcsDownloader`] for a given
-//! [`LocalPackage`]. Mirrors `Composer\Downloader\DownloadManager`.
-
-use std::path::PathBuf;
-
-use mozart_core::composer::{InstallationSource, LocalPackage};
-use mozart_vcs::downloader::VcsDownloader;
-use mozart_vcs::downloader::git::GitDownloader;
-use mozart_vcs::downloader::hg::HgDownloader;
-use mozart_vcs::downloader::svn::SvnDownloader;
-use mozart_vcs::process::ProcessExecutor;
-use mozart_vcs::util::git::GitUtil;
-use mozart_vcs::util::hg::HgUtil;
-use mozart_vcs::util::svn::SvnUtil;
-
-/// Selects a `VcsDownloader` for a package based on its installation source
-/// and source type. Mirrors `DownloadManager::getDownloaderForPackage`:
-///
-/// - `metapackage` → `None`.
-/// - `installation-source: dist` → `None` (Composer would return a
-/// `FileDownloader`-family object that does not implement
-/// `ChangeReportInterface` / `DvcsDownloaderInterface`, so the status
-/// command's `instanceof` checks all become no-ops; returning `None`
-/// directly is the equivalent in our trait-object world).
-/// - `installation-source: source` → the matching VCS downloader by
-/// `source.type` (`git` / `hg` / `svn`).
-pub struct DownloadManager {
- git_cache_dir: PathBuf,
-}
-
-impl DownloadManager {
- /// `git_cache_dir`: where `GitUtil` should keep mirror clones (e.g.
- /// `<vendor>/.cache/git`).
- pub fn new(git_cache_dir: PathBuf) -> Self {
- Self { git_cache_dir }
- }
-
- pub fn get_downloader_for_package(
- &self,
- package: &LocalPackage,
- ) -> Option<Box<dyn VcsDownloader>> {
- if package.package_type() == Some("metapackage") {
- return None;
- }
- match package.installation_source()? {
- InstallationSource::Dist => None,
- InstallationSource::Source => {
- let kind = package.source()?.kind.as_str();
- match kind {
- "git" => {
- let git_util =
- GitUtil::new(ProcessExecutor::new(), self.git_cache_dir.clone());
- Some(Box::new(GitDownloader::new(git_util)))
- }
- "hg" => {
- let hg_util = HgUtil::new(ProcessExecutor::new());
- Some(Box::new(HgDownloader::new(hg_util)))
- }
- "svn" => {
- let svn_util = SvnUtil::new(ProcessExecutor::new());
- Some(Box::new(SvnDownloader::new(svn_util)))
- }
- _ => None,
- }
- }
- }
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use mozart_core::composer::PackageReference;
- use serde_json::Value;
-
- fn pkg(
- installation_source: Option<InstallationSource>,
- source_kind: Option<&str>,
- ) -> LocalPackage {
- let source = source_kind.map(|kind| PackageReference {
- kind: kind.to_string(),
- url: "https://example/repo".into(),
- reference: Some("abc123".into()),
- shasum: None,
- });
- LocalPackage::new(
- "vendor/pkg".into(),
- "1.0.0".into(),
- None,
- Some("library".into()),
- installation_source,
- source,
- None,
- Value::Null,
- )
- }
-
- #[test]
- fn metapackage_returns_none() {
- let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache"));
- let mut p = pkg(Some(InstallationSource::Source), Some("git"));
- // override type
- p = LocalPackage::new(
- "vendor/pkg".into(),
- "1.0.0".into(),
- None,
- Some("metapackage".into()),
- p.installation_source(),
- p.source().cloned(),
- None,
- Value::Null,
- );
- assert!(dm.get_downloader_for_package(&p).is_none());
- }
-
- #[test]
- fn dist_install_returns_none() {
- let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache"));
- let p = pkg(Some(InstallationSource::Dist), Some("git"));
- assert!(dm.get_downloader_for_package(&p).is_none());
- }
-
- #[test]
- fn source_install_with_git_returns_some() {
- let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache"));
- let p = pkg(Some(InstallationSource::Source), Some("git"));
- assert!(dm.get_downloader_for_package(&p).is_some());
- }
-
- #[test]
- fn unknown_source_kind_returns_none() {
- let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache"));
- let p = pkg(Some(InstallationSource::Source), Some("perforce"));
- assert!(dm.get_downloader_for_package(&p).is_none());
- }
-
- #[test]
- fn missing_installation_source_returns_none() {
- let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache"));
- let p = pkg(None, Some("git"));
- assert!(dm.get_downloader_for_package(&p).is_none());
- }
-}
diff --git a/crates/mozart-registry/src/downloader.rs b/crates/mozart-registry/src/downloader.rs
deleted file mode 100644
index 3cb991b..0000000
--- a/crates/mozart-registry/src/downloader.rs
+++ /dev/null
@@ -1,500 +0,0 @@
-use crate::cache::Cache;
-use indexmap::IndexSet;
-use sha1::{Digest, Sha1};
-use std::fs;
-use std::io::{Cursor, Read, Write};
-use std::path::Path;
-
-/// A simple download progress tracker that writes to stderr.
-///
-/// When `show` is false, all methods are no-ops. This lets callers toggle
-/// progress display without branching on every call.
-pub struct DownloadProgress {
- show: bool,
- total: u64,
- downloaded: u64,
- label: String,
-}
-
-impl DownloadProgress {
- /// Create a new progress tracker.
- ///
- /// - `show`: whether to actually display anything.
- /// - `label`: a human-readable label (e.g. "psr/log (3.0.2)").
- pub fn new(show: bool, label: impl Into<String>) -> Self {
- Self {
- show,
- total: 0,
- downloaded: 0,
- label: label.into(),
- }
- }
-
- /// Set the total expected bytes from a `Content-Length` header.
- pub fn set_total(&mut self, total: u64) {
- self.total = total;
- }
-
- /// Advance the downloaded byte count and redraw the line.
- pub fn inc(&mut self, n: u64) {
- if !self.show {
- return;
- }
- self.downloaded += n;
- let stderr = std::io::stderr();
- let mut out = stderr.lock();
- if let Some(pct) = (self.downloaded * 100).checked_div(self.total) {
- let _ = write!(
- out,
- "\r Downloading {} ({}/{} bytes, {}%)",
- self.label, self.downloaded, self.total, pct
- );
- } else {
- let _ = write!(
- out,
- "\r Downloading {} ({} bytes)",
- self.label, self.downloaded
- );
- }
- let _ = out.flush();
- }
-
- /// Clear the progress line from the terminal.
- pub fn finish(&self) {
- if !self.show {
- return;
- }
- let stderr = std::io::stderr();
- let mut out = stderr.lock();
- // Clear the line with spaces then return to start
- let _ = write!(out, "\r{}\r", " ".repeat(80));
- let _ = out.flush();
- }
-}
-
-/// Download a dist archive from a URL.
-/// Returns the raw bytes of the downloaded archive.
-/// If `expected_shasum` is provided and non-empty, verifies SHA-1 of the downloaded bytes.
-/// If `progress` is provided, increments it as bytes are received and sets the total from
-/// the `Content-Length` response header.
-/// Downloaded bytes are cached by URL in `files_cache`; cache hits skip the network request
-/// entirely.
-#[tracing::instrument(skip(expected_shasum, progress, files_cache))]
-pub async fn download_dist(
- url: &str,
- expected_shasum: Option<&str>,
- progress: Option<&mut DownloadProgress>,
- files_cache: &Cache,
-) -> anyhow::Result<Vec<u8>> {
- // Build a cache key from the URL
- let cache_key = Cache::sanitize_key(url);
-
- // Check cache first
- if let Some(cached_bytes) = files_cache.read_bytes(&cache_key) {
- // Verify checksum against cache hit if provided
- if let Some(shasum) = expected_shasum
- && !shasum.is_empty()
- {
- let mut hasher = Sha1::new();
- hasher.update(&cached_bytes);
- let computed = format!("{:x}", hasher.finalize());
- if computed == shasum {
- tracing::debug!("cache hit");
- return Ok(cached_bytes);
- }
- // Checksum mismatch — discard cache, re-download
- } else {
- tracing::debug!("cache hit");
- return Ok(cached_bytes);
- }
- }
-
- let client = mozart_core::http::client_builder().build()?;
- let response = client.get(url).send().await?;
- tracing::debug!(status = %response.status(), "received response");
-
- if !response.status().is_success() {
- anyhow::bail!(
- "Failed to download dist archive from {} (HTTP {})",
- url,
- response.status()
- );
- }
-
- // Stream the response body, updating progress as bytes arrive
- let bytes = if let Some(pb) = progress {
- if let Some(content_length) = response.content_length() {
- pb.set_total(content_length);
- }
- let mut buf = Vec::new();
- let mut stream = response;
- while let Some(chunk) = stream.chunk().await? {
- buf.extend_from_slice(&chunk);
- pb.inc(chunk.len() as u64);
- }
- buf
- } else {
- response.bytes().await?.to_vec()
- };
-
- tracing::debug!(size = bytes.len(), "download complete");
-
- // Verify SHA-1 checksum if provided
- if let Some(shasum) = expected_shasum
- && !shasum.is_empty()
- {
- let mut hasher = Sha1::new();
- hasher.update(&bytes);
- let result = hasher.finalize();
- let computed = format!("{result:x}");
-
- if computed != shasum {
- anyhow::bail!("SHA-1 checksum mismatch for {url}: expected {shasum}, got {computed}");
- }
- }
-
- // Write to cache
- let _ = files_cache.write_bytes(&cache_key, &bytes);
-
- Ok(bytes)
-}
-
-/// Find the common top-level directory prefix shared by all entries.
-/// Returns `Some(prefix)` if all entries share a single top-level directory.
-fn find_top_level_dir(entries: &[String]) -> Option<String> {
- if entries.is_empty() {
- return None;
- }
-
- let mut prefixes: IndexSet<String> = IndexSet::new();
- for entry in entries {
- let slash_pos = entry.find('/')?;
- prefixes.insert(entry[..slash_pos + 1].to_string());
- }
-
- if prefixes.len() == 1 {
- prefixes.into_iter().next()
- } else {
- None
- }
-}
-
-/// Extract a zip archive to the target directory.
-/// Strips a common top-level directory if all entries share one (Packagist pattern).
-pub fn extract_zip(data: &[u8], target_dir: &Path) -> anyhow::Result<()> {
- let cursor = Cursor::new(data);
- let mut archive = zip::ZipArchive::new(cursor)?;
-
- // Collect all entry names to detect common prefix
- let entry_names: Vec<String> = (0..archive.len())
- .map(|i| archive.by_index(i).map(|e| e.name().to_string()))
- .collect::<Result<_, _>>()?;
-
- let prefix = find_top_level_dir(&entry_names);
-
- for i in 0..archive.len() {
- let mut entry = archive.by_index(i)?;
- let raw_name = entry.name().to_string();
-
- // Strip common prefix
- let relative = if let Some(ref pfx) = prefix {
- if raw_name.starts_with(pfx.as_str()) {
- &raw_name[pfx.len()..]
- } else {
- &raw_name
- }
- } else {
- &raw_name
- };
-
- // Skip the directory entry itself (empty name after stripping)
- if relative.is_empty() {
- continue;
- }
-
- let target_path = target_dir.join(relative);
-
- if raw_name.ends_with('/') {
- // Directory entry
- fs::create_dir_all(&target_path)?;
- } else {
- // File entry
- if let Some(parent) = target_path.parent() {
- fs::create_dir_all(parent)?;
- }
-
- let mut buf = Vec::new();
- entry.read_to_end(&mut buf)?;
- fs::write(&target_path, &buf)?;
-
- // Set permissions on Unix
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- if let Some(mode) = entry.unix_mode() {
- fs::set_permissions(&target_path, fs::Permissions::from_mode(mode))?;
- }
- }
- }
- }
-
- Ok(())
-}
-
-/// Extract a tar.gz archive to the target directory.
-/// Strips a common top-level directory if all entries share one (Packagist pattern).
-pub fn extract_tar_gz(data: &[u8], target_dir: &Path) -> anyhow::Result<()> {
- let cursor = Cursor::new(data);
- let decoder = flate2::read::GzDecoder::new(cursor);
- let mut archive = tar::Archive::new(decoder);
-
- // We need to process in two passes: first collect names, then extract.
- // Use a buffered approach: collect entries into memory.
- let cursor2 = Cursor::new(data);
- let decoder2 = flate2::read::GzDecoder::new(cursor2);
- let mut archive2 = tar::Archive::new(decoder2);
-
- let entry_names: Vec<String> = archive2
- .entries()?
- .filter_map(|e| e.ok())
- .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string()))
- .collect();
-
- let prefix = find_top_level_dir(&entry_names);
-
- for entry in archive.entries()? {
- let mut entry = entry?;
- let raw_path = entry.path()?.to_string_lossy().to_string();
-
- // Strip common prefix
- let relative = if let Some(ref pfx) = prefix {
- if raw_path.starts_with(pfx.as_str()) {
- raw_path[pfx.len()..].to_string()
- } else {
- raw_path.clone()
- }
- } else {
- raw_path.clone()
- };
-
- // Skip empty (top-level dir itself)
- if relative.is_empty() {
- continue;
- }
-
- let target_path = target_dir.join(&relative);
-
- let entry_type = entry.header().entry_type();
- if entry_type.is_dir() {
- fs::create_dir_all(&target_path)?;
- } else if entry_type.is_file() {
- if let Some(parent) = target_path.parent() {
- fs::create_dir_all(parent)?;
- }
- let mut buf = Vec::new();
- entry.read_to_end(&mut buf)?;
- fs::write(&target_path, &buf)?;
-
- // Set permissions on Unix
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- if let Ok(mode) = entry.header().mode() {
- fs::set_permissions(&target_path, fs::Permissions::from_mode(mode))?;
- }
- }
- }
- // Symlinks and other types are skipped for now
- }
-
- Ok(())
-}
-
-/// Download and install a package to the vendor directory.
-///
-/// - `dist_url`: the download URL (from `LockedPackage.dist.url`)
-/// - `dist_type`: `"zip"` or `"tar"` (from `LockedPackage.dist.dist_type`)
-/// - `dist_shasum`: optional SHA-1 checksum
-/// - `vendor_dir`: path to `vendor/` directory
-/// - `package_name`: e.g. `"monolog/monolog"`
-/// - `progress`: optional mutable progress tracker to update during download
-/// - `files_cache`: files cache; archive bytes are cached by URL
-pub async fn install_package(
- dist_url: &str,
- dist_type: &str,
- dist_shasum: Option<&str>,
- vendor_dir: &Path,
- package_name: &str,
- progress: Option<&mut DownloadProgress>,
- files_cache: &Cache,
-) -> anyhow::Result<()> {
- let target = vendor_dir.join(package_name);
-
- // Remove existing installation for a clean reinstall
- if target.exists() {
- fs::remove_dir_all(&target)?;
- }
- fs::create_dir_all(&target)?;
-
- let bytes = download_dist(dist_url, dist_shasum, progress, files_cache).await?;
-
- match dist_type {
- "zip" => extract_zip(&bytes, &target)?,
- "tar" | "tar.gz" | "tgz" => extract_tar_gz(&bytes, &target)?,
- other => anyhow::bail!("Unsupported dist type: {other}"),
- }
-
- Ok(())
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use std::io::Write as IoWrite;
- use tempfile::tempdir;
-
- /// Build a minimal zip archive in memory.
- fn make_zip(files: &[(&str, &[u8])]) -> Vec<u8> {
- let buf = Vec::new();
- let cursor = Cursor::new(buf);
- let mut writer = zip::ZipWriter::new(cursor);
- let options = zip::write::FileOptions::<()>::default()
- .compression_method(zip::CompressionMethod::Stored);
-
- for (name, content) in files {
- writer.start_file(*name, options).unwrap();
- writer.write_all(content).unwrap();
- }
-
- writer.finish().unwrap().into_inner()
- }
-
- /// Build a minimal tar.gz archive in memory.
- fn make_tar_gz(files: &[(&str, &[u8])]) -> Vec<u8> {
- let buf = Vec::new();
- let enc = flate2::write::GzEncoder::new(buf, flate2::Compression::default());
- let mut builder = tar::Builder::new(enc);
-
- for (name, content) in files {
- let mut header = tar::Header::new_gnu();
- header.set_size(content.len() as u64);
- header.set_mode(0o644);
- header.set_cksum();
- builder
- .append_data(&mut header, name, Cursor::new(content))
- .unwrap();
- }
-
- builder.into_inner().unwrap().finish().unwrap()
- }
-
- #[test]
- fn test_extract_zip_flat() {
- let zip_data = make_zip(&[("file1.txt", b"hello"), ("subdir/file2.txt", b"world")]);
-
- let dir = tempdir().unwrap();
- extract_zip(&zip_data, dir.path()).unwrap();
-
- assert_eq!(
- fs::read_to_string(dir.path().join("file1.txt")).unwrap(),
- "hello"
- );
- assert_eq!(
- fs::read_to_string(dir.path().join("subdir/file2.txt")).unwrap(),
- "world"
- );
- }
-
- #[test]
- fn test_extract_zip_with_top_level_dir() {
- // Packagist pattern: all files under vendor-package-abc123/
- let zip_data = make_zip(&[
- ("vendor-pkg-abc/", &[]),
- ("vendor-pkg-abc/file1.txt", b"hello"),
- ("vendor-pkg-abc/src/Foo.php", b"<?php"),
- ]);
-
- let dir = tempdir().unwrap();
- extract_zip(&zip_data, dir.path()).unwrap();
-
- // Top-level dir should be stripped
- assert!(dir.path().join("file1.txt").exists());
- assert!(dir.path().join("src/Foo.php").exists());
- assert_eq!(
- fs::read_to_string(dir.path().join("file1.txt")).unwrap(),
- "hello"
- );
- }
-
- #[test]
- fn test_extract_tar_gz_flat() {
- let tar_data = make_tar_gz(&[("file1.txt", b"hello"), ("subdir/file2.txt", b"world")]);
-
- let dir = tempdir().unwrap();
- extract_tar_gz(&tar_data, dir.path()).unwrap();
-
- assert_eq!(
- fs::read_to_string(dir.path().join("file1.txt")).unwrap(),
- "hello"
- );
- assert_eq!(
- fs::read_to_string(dir.path().join("subdir/file2.txt")).unwrap(),
- "world"
- );
- }
-
- #[test]
- fn test_extract_tar_gz_with_top_level_dir() {
- let tar_data = make_tar_gz(&[
- ("vendor-pkg-abc/file1.txt", b"hello"),
- ("vendor-pkg-abc/src/Foo.php", b"<?php"),
- ]);
-
- let dir = tempdir().unwrap();
- extract_tar_gz(&tar_data, dir.path()).unwrap();
-
- assert!(dir.path().join("file1.txt").exists());
- assert!(dir.path().join("src/Foo.php").exists());
- }
-
- #[test]
- fn test_sha1_verification() {
- use sha1::{Digest, Sha1};
-
- let data = b"test content";
- let mut hasher = Sha1::new();
- hasher.update(data);
- let expected = format!("{:x}", hasher.finalize());
-
- // We can't test download_dist without a server, but we can verify the
- // SHA-1 logic: same data should produce same hash
- let mut hasher2 = Sha1::new();
- hasher2.update(data);
- let computed = format!("{:x}", hasher2.finalize());
-
- assert_eq!(expected, computed);
- assert!(!expected.is_empty());
- }
-
- #[test]
- fn test_find_top_level_dir_common() {
- let entries = vec![
- "pkg-1.0/".to_string(),
- "pkg-1.0/README.md".to_string(),
- "pkg-1.0/src/Foo.php".to_string(),
- ];
- assert_eq!(find_top_level_dir(&entries), Some("pkg-1.0/".to_string()));
- }
-
- #[test]
- fn test_find_top_level_dir_none_when_mixed() {
- let entries = vec!["pkg-1.0/file.txt".to_string(), "other/file.txt".to_string()];
- assert_eq!(find_top_level_dir(&entries), None);
- }
-
- #[test]
- fn test_find_top_level_dir_none_when_root_file() {
- let entries = vec!["file.txt".to_string(), "pkg/other.txt".to_string()];
- assert_eq!(find_top_level_dir(&entries), None);
- }
-}
diff --git a/crates/mozart-registry/src/inline_package.rs b/crates/mozart-registry/src/inline_package.rs
deleted file mode 100644
index 95f842f..0000000
--- a/crates/mozart-registry/src/inline_package.rs
+++ /dev/null
@@ -1,277 +0,0 @@
-//! Support for inline `type: package` repositories.
-//!
-//! `composer.json` may embed full package metadata under
-//! `repositories[].package`, mirroring `Composer\Repository\PackageRepository`.
-//! These packages need no network fetch — they go straight into the resolver
-//! pool and into the generated lockfile entry verbatim.
-
-use crate::packagist::PackagistVersion;
-use crate::repository_filter::RepositoryFilter;
-use indexmap::IndexSet;
-use mozart_core::package::RawRepository;
-
-/// One package extracted from a `type: package` repository.
-pub struct InlinePackage {
- pub name: String,
- pub version: PackagistVersion,
-}
-
-/// Collect every package definition from `type: package` repositories.
-///
-/// Each repository's `package` field may be a single object or an array of
-/// objects. Entries that fail to parse (missing `name`/`version`, etc.) are
-/// silently skipped so the rest of the repositories list still applies —
-/// matching Composer's lenient PackageRepository constructor.
-///
-/// Repositories are processed in declaration order. Once any repository
-/// authoritatively answers for a package name, lower-priority `type: package`
-/// repositories that list the same name are skipped — mirroring Composer's
-/// first-repo-wins priority via `RepositorySet::findPackages`.
-pub fn collect_inline_packages(repositories: &[RawRepository]) -> Vec<InlinePackage> {
- let mut packages = Vec::new();
- let mut claimed: IndexSet<String> = IndexSet::new();
- for repo in repositories {
- if repo.repo_type != "package" {
- continue;
- }
- let Some(value) = &repo.package else {
- continue;
- };
- let filter = RepositoryFilter::from_repo(repo);
-
- let mut from_this_repo: Vec<InlinePackage> = Vec::new();
- match value {
- serde_json::Value::Array(arr) => {
- for entry in arr {
- if let Some(pkg) = parse_inline_package(entry) {
- from_this_repo.push(pkg);
- }
- }
- }
- serde_json::Value::Object(_) => {
- if let Some(pkg) = parse_inline_package(value) {
- from_this_repo.push(pkg);
- }
- }
- _ => {}
- }
-
- let mut names_this_repo: IndexSet<String> = IndexSet::new();
- for pkg in from_this_repo {
- if !filter.is_allowed(&pkg.name) {
- continue;
- }
- if claimed.contains(&pkg.name) {
- continue;
- }
- names_this_repo.insert(pkg.name.clone());
- packages.push(pkg);
- }
- // canonical: false → packages enter the pool but the name is not
- // claimed, so lower-priority repositories may still answer for it.
- // Mirrors `FilterRepository::loadPackages`'s `namesFound = []` reset.
- if filter.canonical {
- claimed.extend(names_this_repo);
- }
- }
- packages
-}
-
-/// One advisory extracted from a repository's `security-advisories` block.
-/// Carries enough to filter affected versions out of the pool when
-/// `config.audit.block-insecure` is set, matching the slice of Composer's
-/// `SecurityAdvisoryPoolFilter` Mozart needs for resolution-time blocking.
-#[derive(Debug, Clone)]
-pub struct SecurityAdvisory {
- pub advisory_id: String,
- pub affected_versions: String,
-}
-
-/// Collect every `security-advisories` entry across all repositories.
-/// Returned map is keyed by lowercase package name so the resolver can
-/// look up affected versions in lockstep with the rest of its
-/// case-insensitive name handling. Repository order is preserved within
-/// each list.
-pub fn collect_security_advisories(
- repositories: &[RawRepository],
-) -> indexmap::IndexMap<String, Vec<SecurityAdvisory>> {
- let mut out: indexmap::IndexMap<String, Vec<SecurityAdvisory>> = indexmap::IndexMap::new();
- for repo in repositories {
- let Some(advisories) = &repo.security_advisories else {
- continue;
- };
- let Some(map) = advisories.as_object() else {
- continue;
- };
- for (pkg_name, list) in map {
- let Some(arr) = list.as_array() else {
- continue;
- };
- for entry in arr {
- let Some(obj) = entry.as_object() else {
- continue;
- };
- let Some(affected) = obj
- .get("affectedVersions")
- .and_then(|v| v.as_str())
- .map(String::from)
- else {
- continue;
- };
- let advisory_id = obj
- .get("advisoryId")
- .and_then(|v| v.as_str())
- .map(String::from)
- .unwrap_or_default();
- out.entry(pkg_name.to_lowercase())
- .or_default()
- .push(SecurityAdvisory {
- advisory_id,
- affected_versions: affected,
- });
- }
- }
- }
- out
-}
-
-fn parse_inline_package(value: &serde_json::Value) -> Option<InlinePackage> {
- let obj = value.as_object()?;
- let name = obj.get("name")?.as_str()?.to_string();
- let version_str = obj.get("version")?.as_str()?.to_string();
-
- // PackagistVersion requires `version_normalized`. If the inline definition
- // omits it (the common case), compute it the same way Packagist does:
- // run the version through Mozart's normalizer.
- //
- // Mirrors Composer's `ArrayLoader::parsePackage` Composer v1 compat path:
- // when `version_normalized` is exactly `9999999-dev` (the legacy default
- // branch sentinel), re-normalize from the human-readable `version` field
- // instead. Without this, the package's version stays as `9999999-dev`
- // even though its pretty form is e.g. `dev-master`, and a root require
- // for `dev-master` then can't match the loaded package.
- let mut value_for_parse = value.clone();
- if let serde_json::Value::Object(ref mut map) = value_for_parse {
- let needs_normalize = match map.get("version_normalized") {
- None => true,
- Some(serde_json::Value::String(s)) => s == "9999999-dev",
- _ => false,
- };
- if needs_normalize {
- let normalized = mozart_semver::Version::parse(&version_str)
- .map(|v| v.to_string())
- .unwrap_or_else(|_| version_str.clone());
- map.insert(
- "version_normalized".to_string(),
- serde_json::Value::String(normalized),
- );
- }
- }
-
- let version: PackagistVersion = serde_json::from_value(value_for_parse).ok()?;
- Some(InlinePackage { name, version })
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn pkg_repo(value: serde_json::Value) -> RawRepository {
- RawRepository {
- repo_type: "package".to_string(),
- url: None,
- package: Some(value),
- only: None,
- exclude: None,
- canonical: None,
- security_advisories: None,
- }
- }
-
- #[test]
- fn collects_single_inline_package_object() {
- let repos = vec![pkg_repo(serde_json::json!({
- "name": "a/a",
- "version": "1.0.0"
- }))];
- let pkgs = collect_inline_packages(&repos);
- assert_eq!(pkgs.len(), 1);
- assert_eq!(pkgs[0].name, "a/a");
- assert_eq!(pkgs[0].version.version, "1.0.0");
- assert_eq!(pkgs[0].version.version_normalized, "1.0.0.0");
- }
-
- #[test]
- fn collects_inline_package_array() {
- let repos = vec![pkg_repo(serde_json::json!([
- {"name": "a/a", "version": "1.0.0"},
- {"name": "b/b", "version": "2.0.0"}
- ]))];
- let pkgs = collect_inline_packages(&repos);
- assert_eq!(pkgs.len(), 2);
- assert_eq!(pkgs[0].name, "a/a");
- assert_eq!(pkgs[1].name, "b/b");
- }
-
- #[test]
- fn ignores_non_package_repos() {
- let repos = vec![RawRepository {
- repo_type: "vcs".to_string(),
- url: Some("https://example.com/foo.git".to_string()),
- package: None,
- only: None,
- exclude: None,
- canonical: None,
- security_advisories: None,
- }];
- assert!(collect_inline_packages(&repos).is_empty());
- }
-
- #[test]
- fn skips_entries_missing_name_or_version() {
- let repos = vec![pkg_repo(serde_json::json!([
- {"name": "a/a", "version": "1.0.0"},
- {"name": "missing/version"},
- {"version": "2.0.0"},
- {"name": "b/b", "version": "2.0.0"}
- ]))];
- let pkgs = collect_inline_packages(&repos);
- assert_eq!(pkgs.len(), 2);
- assert_eq!(pkgs[0].name, "a/a");
- assert_eq!(pkgs[1].name, "b/b");
- }
-
- #[test]
- fn preserves_explicit_version_normalized() {
- let repos = vec![pkg_repo(serde_json::json!({
- "name": "a/a",
- "version": "1.0",
- "version_normalized": "1.0.0.0-explicit"
- }))];
- let pkgs = collect_inline_packages(&repos);
- assert_eq!(pkgs[0].version.version_normalized, "1.0.0.0-explicit");
- }
-
- #[test]
- fn parses_full_metadata_fields() {
- let repos = vec![pkg_repo(serde_json::json!({
- "name": "a/a",
- "version": "1.0.0",
- "type": "library",
- "require": {"b/b": "^2.0"},
- "replace": {"old/x": "1.0"},
- "provide": {"some/iface": "1.0"},
- "conflict": {"bad/pkg": "*"},
- "dist": {"type": "zip", "url": "https://e.com/a.zip"}
- }))];
- let pkgs = collect_inline_packages(&repos);
- assert_eq!(pkgs.len(), 1);
- let v = &pkgs[0].version;
- assert_eq!(v.package_type.as_deref(), Some("library"));
- assert_eq!(v.require.get("b/b").map(String::as_str), Some("^2.0"));
- assert_eq!(v.replace.get("old/x").map(String::as_str), Some("1.0"));
- assert_eq!(v.provide.get("some/iface").map(String::as_str), Some("1.0"));
- assert_eq!(v.conflict.get("bad/pkg").map(String::as_str), Some("*"));
- assert!(v.dist.is_some());
- }
-}
diff --git a/crates/mozart-registry/src/installed.rs b/crates/mozart-registry/src/installed.rs
deleted file mode 100644
index 108b844..0000000
--- a/crates/mozart-registry/src/installed.rs
+++ /dev/null
@@ -1,383 +0,0 @@
-use mozart_core::installer::HasSuggests;
-use mozart_core::package::to_json_pretty;
-use serde::{Deserialize, Serialize};
-use std::collections::BTreeMap;
-use std::fs;
-use std::path::Path;
-
-fn default_true() -> bool {
- true
-}
-
-/// Represents `vendor/composer/installed.json`.
-/// This is the Composer 2.x format.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct InstalledPackages {
- pub packages: Vec<InstalledPackageEntry>,
-
- #[serde(rename = "dev-package-names", default)]
- pub dev_package_names: Vec<String>,
-
- #[serde(default = "default_true")]
- pub dev: bool,
-}
-
-/// An entry in installed.json's packages array.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct InstalledPackageEntry {
- pub name: String,
- pub version: String,
-
- #[serde(rename = "version_normalized", skip_serializing_if = "Option::is_none")]
- pub version_normalized: Option<String>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub source: Option<serde_json::Value>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub dist: Option<serde_json::Value>,
-
- #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
- pub package_type: Option<String>,
-
- #[serde(rename = "install-path", skip_serializing_if = "Option::is_none")]
- pub install_path: Option<String>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub autoload: Option<serde_json::Value>,
-
- #[serde(default, skip_serializing_if = "Vec::is_empty")]
- pub aliases: Vec<String>,
-
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub homepage: Option<String>,
-
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub support: Option<serde_json::Value>,
-
- #[serde(flatten)]
- pub extra_fields: BTreeMap<String, serde_json::Value>,
-}
-
-impl HasSuggests for InstalledPackageEntry {
- fn pretty_name(&self) -> &str {
- &self.name
- }
-
- fn suggests(&self) -> Vec<(String, String)> {
- let Some(val) = self.extra_fields.get("suggest") else {
- return Vec::new();
- };
- let Some(obj) = val.as_object() else {
- return Vec::new();
- };
- obj.iter()
- .filter_map(|(target, reason)| reason.as_str().map(|r| (target.clone(), r.to_string())))
- .collect()
- }
-}
-
-impl Default for InstalledPackages {
- fn default() -> Self {
- Self::new()
- }
-}
-
-impl InstalledPackages {
- /// Create an empty registry.
- pub fn new() -> InstalledPackages {
- InstalledPackages {
- packages: Vec::new(),
- dev_package_names: Vec::new(),
- dev: true,
- }
- }
-
- /// Read installed.json from `vendor/composer/installed.json`.
- /// If the file does not exist, returns an empty registry.
- ///
- /// Accepts both Composer formats, mirroring `FilesystemRepository::initialize`:
- /// - **v2** — object with a `packages` array, plus optional `dev-package-names`/`dev`
- /// (the shape Composer 2.x writes).
- /// - **v1** — bare array of package entries (older shape; still legal input).
- pub fn read(vendor_dir: &Path) -> anyhow::Result<InstalledPackages> {
- let path = vendor_dir.join("composer/installed.json");
- if !path.exists() {
- return Ok(InstalledPackages::new());
- }
- let content = fs::read_to_string(&path)?;
- Self::from_json_str(&content)
- }
-
- /// Parse an installed.json document. See [`Self::read`] for the accepted shapes.
- pub fn from_json_str(content: &str) -> anyhow::Result<InstalledPackages> {
- use anyhow::{Context, anyhow};
-
- let value: serde_json::Value =
- serde_json::from_str(content).context("invalid installed.json")?;
-
- match value {
- serde_json::Value::Object(mut obj) => {
- let packages_value = obj.remove("packages").ok_or_else(|| {
- anyhow!("Could not parse package list from installed.json (missing `packages`)")
- })?;
- let packages: Vec<InstalledPackageEntry> =
- serde_json::from_value(packages_value)
- .context("invalid `packages` array in installed.json")?;
-
- let dev_package_names: Vec<String> = match obj.remove("dev-package-names") {
- Some(v) => serde_json::from_value(v)
- .context("invalid `dev-package-names` in installed.json")?,
- None => Vec::new(),
- };
- let dev: bool = match obj.remove("dev") {
- Some(v) => {
- serde_json::from_value(v).context("invalid `dev` flag in installed.json")?
- }
- None => true,
- };
-
- Ok(InstalledPackages {
- packages,
- dev_package_names,
- dev,
- })
- }
- serde_json::Value::Array(_) => {
- let packages: Vec<InstalledPackageEntry> = serde_json::from_value(value)
- .context("invalid v1 installed.json package array")?;
- Ok(InstalledPackages {
- packages,
- dev_package_names: Vec::new(),
- dev: true,
- })
- }
- _ => Err(anyhow!(
- "Could not parse package list from installed.json (expected object or array)"
- )),
- }
- }
-
- /// Write installed.json to `vendor/composer/installed.json`.
- /// Creates the `vendor/composer/` directory if it doesn't exist.
- pub fn write(&self, vendor_dir: &Path) -> anyhow::Result<()> {
- let composer_dir = vendor_dir.join("composer");
- fs::create_dir_all(&composer_dir)?;
- let path = composer_dir.join("installed.json");
- let json = to_json_pretty(self)?;
- fs::write(path, json)?;
- Ok(())
- }
-
- /// Check if a package at a specific version is installed.
- pub fn is_installed(&self, name: &str, version: &str) -> bool {
- self.packages
- .iter()
- .any(|p| p.name.eq_ignore_ascii_case(name) && p.version == version)
- }
-
- /// Add or update a package entry (replace if same name exists).
- pub fn upsert(&mut self, entry: InstalledPackageEntry) {
- if let Some(pos) = self
- .packages
- .iter()
- .position(|p| p.name.eq_ignore_ascii_case(&entry.name))
- {
- self.packages[pos] = entry;
- } else {
- self.packages.push(entry);
- }
- }
-
- /// Remove a package by name.
- pub fn remove(&mut self, name: &str) {
- self.packages.retain(|p| !p.name.eq_ignore_ascii_case(name));
- self.dev_package_names
- .retain(|n| !n.eq_ignore_ascii_case(name));
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use tempfile::tempdir;
-
- fn make_entry(name: &str, version: &str) -> InstalledPackageEntry {
- InstalledPackageEntry {
- name: name.to_string(),
- version: version.to_string(),
- version_normalized: None,
- source: None,
- dist: None,
- package_type: None,
- install_path: None,
- autoload: None,
- aliases: vec![],
- homepage: None,
- support: None,
- extra_fields: BTreeMap::new(),
- }
- }
-
- #[test]
- fn test_new_is_empty() {
- let installed = InstalledPackages::new();
- assert!(installed.packages.is_empty());
- assert!(installed.dev_package_names.is_empty());
- assert!(installed.dev);
- }
-
- #[test]
- fn test_write_read_empty() {
- let dir = tempdir().unwrap();
- let vendor = dir.path().join("vendor");
-
- let installed = InstalledPackages::new();
- installed.write(&vendor).unwrap();
-
- let loaded = InstalledPackages::read(&vendor).unwrap();
- assert!(loaded.packages.is_empty());
- assert!(loaded.dev);
- }
-
- #[test]
- fn test_read_nonexistent_returns_empty() {
- let dir = tempdir().unwrap();
- let vendor = dir.path().join("vendor");
- // Don't create the directory
- let installed = InstalledPackages::read(&vendor).unwrap();
- assert!(installed.packages.is_empty());
- }
-
- #[test]
- fn test_upsert_and_is_installed() {
- let mut installed = InstalledPackages::new();
- installed.upsert(make_entry("monolog/monolog", "3.8.0"));
-
- assert!(installed.is_installed("monolog/monolog", "3.8.0"));
- assert!(!installed.is_installed("monolog/monolog", "3.7.0"));
- assert!(!installed.is_installed("other/pkg", "1.0.0"));
- }
-
- #[test]
- fn test_upsert_replaces_existing() {
- let mut installed = InstalledPackages::new();
- installed.upsert(make_entry("monolog/monolog", "3.7.0"));
- installed.upsert(make_entry("monolog/monolog", "3.8.0"));
-
- assert_eq!(installed.packages.len(), 1);
- assert_eq!(installed.packages[0].version, "3.8.0");
- }
-
- #[test]
- fn test_remove() {
- let mut installed = InstalledPackages::new();
- installed.upsert(make_entry("monolog/monolog", "3.8.0"));
- installed.upsert(make_entry("psr/log", "3.0.0"));
- installed
- .dev_package_names
- .push("monolog/monolog".to_string());
-
- installed.remove("monolog/monolog");
-
- assert_eq!(installed.packages.len(), 1);
- assert_eq!(installed.packages[0].name, "psr/log");
- assert!(installed.dev_package_names.is_empty());
- }
-
- #[test]
- fn test_reads_v2_object_form() {
- let json = r#"{
- "packages": [
- {"name": "a/a", "version": "1.0.0"}
- ],
- "dev-package-names": ["a/a"],
- "dev": false
- }"#;
- let installed = InstalledPackages::from_json_str(json).unwrap();
- assert_eq!(installed.packages.len(), 1);
- assert_eq!(installed.packages[0].name, "a/a");
- assert_eq!(installed.dev_package_names, vec!["a/a".to_string()]);
- assert!(!installed.dev);
- }
-
- #[test]
- fn test_reads_v1_array_form() {
- // Composer 1.x / fixture-style: bare array of packages.
- // FilesystemRepository::initialize accepts this; so must Mozart.
- let json = r#"[
- {"name": "a/a", "version": "1.0.0"},
- {"name": "b/b", "version": "2.0.0"}
- ]"#;
- let installed = InstalledPackages::from_json_str(json).unwrap();
- assert_eq!(installed.packages.len(), 2);
- assert_eq!(installed.packages[0].name, "a/a");
- assert_eq!(installed.packages[1].name, "b/b");
- assert!(installed.dev_package_names.is_empty());
- assert!(installed.dev);
- }
-
- #[test]
- fn test_v2_defaults_when_optional_fields_missing() {
- let json = r#"{"packages": []}"#;
- let installed = InstalledPackages::from_json_str(json).unwrap();
- assert!(installed.packages.is_empty());
- assert!(installed.dev_package_names.is_empty());
- assert!(installed.dev);
- }
-
- #[test]
- fn test_rejects_non_object_non_array() {
- let err = InstalledPackages::from_json_str("\"oops\"").unwrap_err();
- assert!(
- err.to_string().contains("expected object or array"),
- "{err}"
- );
- }
-
- #[test]
- fn test_is_installed_case_insensitive() {
- let mut installed = InstalledPackages::new();
- installed.upsert(make_entry("Monolog/Monolog", "3.8.0"));
- assert!(installed.is_installed("monolog/monolog", "3.8.0"));
- }
-
- #[test]
- fn test_roundtrip_with_package() {
- let dir = tempdir().unwrap();
- let vendor = dir.path().join("vendor");
-
- let mut installed = InstalledPackages::new();
- installed.upsert(make_entry("monolog/monolog", "3.8.0"));
- installed.write(&vendor).unwrap();
-
- let loaded = InstalledPackages::read(&vendor).unwrap();
- assert_eq!(loaded.packages.len(), 1);
- assert_eq!(loaded.packages[0].name, "monolog/monolog");
- assert_eq!(loaded.packages[0].version, "3.8.0");
- }
-
- #[test]
- fn test_homepage_and_support_roundtrip() {
- let json = r#"{
- "packages": [
- {
- "name": "vendor/pkg",
- "version": "1.0.0",
- "homepage": "https://vendor.example.com",
- "support": {"source": "https://github.com/vendor/pkg"}
- }
- ]
- }"#;
- let installed = InstalledPackages::from_json_str(json).unwrap();
- let pkg = &installed.packages[0];
- assert_eq!(pkg.homepage.as_deref(), Some("https://vendor.example.com"));
- assert_eq!(
- pkg.support
- .as_ref()
- .and_then(|s| s.get("source"))
- .and_then(|s| s.as_str()),
- Some("https://github.com/vendor/pkg")
- );
- }
-}
diff --git a/crates/mozart-registry/src/installer_executor/filesystem.rs b/crates/mozart-registry/src/installer_executor/filesystem.rs
deleted file mode 100644
index cb1a2cc..0000000
--- a/crates/mozart-registry/src/installer_executor/filesystem.rs
+++ /dev/null
@@ -1,232 +0,0 @@
-//! Production [`InstallerExecutor`] that touches the real filesystem.
-//!
-//! This is the verb behind `mozart install` / `mozart update` — it pulls
-//! dist archives via [`crate::downloader`], clones VCS sources via
-//! [`mozart_vcs`], and removes vendor directories. Test code substitutes a
-//! recording-only executor instead (added in a later step).
-
-use std::path::Path;
-
-use crate::cache::Cache;
-use crate::downloader;
-
-use super::{ExecuteContext, InstallerExecutor, PackageOperation};
-
-pub struct FilesystemExecutor {
- files_cache: Cache,
-}
-
-impl FilesystemExecutor {
- pub fn new(files_cache: Cache) -> Self {
- Self { files_cache }
- }
-}
-
-#[async_trait::async_trait]
-impl InstallerExecutor for FilesystemExecutor {
- async fn install_package(
- &mut self,
- op: PackageOperation<'_>,
- ctx: &ExecuteContext,
- ) -> anyhow::Result<()> {
- // Marking an alias as installed/uninstalled has no filesystem side
- // effects — the target package's files are already in vendor/.
- // Mirrors Composer's `MarkAlias{,Un}installedOperation` which the
- // installation manager only uses to update the in-memory installed
- // repository.
- let Some(pkg) = op.package() else {
- return Ok(());
- };
-
- // Try source install if --prefer-source and source info is available.
- if ctx.prefer_source
- && let Some(source) = &pkg.source
- {
- return install_from_source(
- &source.source_type,
- &source.url,
- source.reference.as_deref().unwrap_or("HEAD"),
- &ctx.vendor_dir,
- &pkg.name,
- );
- }
-
- // A package with neither dist nor source has no install action.
- // This covers Composer's `type: metapackage` (modeled explicitly as
- // "no installer") and inline `type: package` definitions used in
- // test fixtures that intentionally omit download metadata. Mozart
- // records the operation and the installed.json entry but performs
- // no filesystem work, mirroring Composer's MetapackageInstaller.
- if pkg.dist.is_none() && pkg.source.is_none() {
- return Ok(());
- }
-
- let dist = pkg.dist.as_ref().ok_or_else(|| {
- anyhow::anyhow!(
- "Package {} has no dist information. Use --prefer-source to install from VCS.",
- pkg.name,
- )
- })?;
-
- let mut progress = downloader::DownloadProgress::new(
- !ctx.no_progress,
- format!("{} ({})", pkg.name, pkg.version),
- );
-
- downloader::install_package(
- &dist.url,
- &dist.dist_type,
- dist.shasum.as_deref(),
- &ctx.vendor_dir,
- &pkg.name,
- Some(&mut progress),
- &self.files_cache,
- )
- .await?;
-
- progress.finish();
- Ok(())
- }
-
- fn uninstall_package(
- &mut self,
- name: &str,
- _version: &str,
- ctx: &ExecuteContext,
- ) -> anyhow::Result<()> {
- let pkg_dir = ctx.vendor_dir.join(name);
- if pkg_dir.exists() {
- std::fs::remove_dir_all(&pkg_dir)?;
- }
- Ok(())
- }
-
- fn cleanup_after_uninstalls(&mut self, ctx: &ExecuteContext) -> anyhow::Result<()> {
- cleanup_empty_vendor_dirs(&ctx.vendor_dir)
- }
-}
-
-/// Remove empty vendor namespace directories left behind after package
-/// removals. Skips the `composer/` and `bin/` directories. Mirrors the
-/// post-uninstall cleanup Composer does in `LibraryInstaller::removeCode`.
-fn cleanup_empty_vendor_dirs(vendor_dir: &Path) -> anyhow::Result<()> {
- if let Ok(entries) = std::fs::read_dir(vendor_dir) {
- for entry in entries.flatten() {
- let path = entry.path();
- if path.is_dir() {
- let name = entry.file_name().to_string_lossy().to_string();
- if name == "composer" || name == "bin" {
- continue;
- }
- if std::fs::read_dir(&path)?.next().is_none() {
- std::fs::remove_dir(&path)?;
- }
- }
- }
- }
- Ok(())
-}
-
-/// Install a package from VCS source (git/svn/hg). Lifted from the previous
-/// `commands/install.rs::install_from_source`. Mirrors the per-driver
-/// dispatch in `Composer\Downloader\VcsDownloader::install`.
-fn install_from_source(
- source_type: &str,
- url: &str,
- reference: &str,
- vendor_dir: &Path,
- package_name: &str,
-) -> anyhow::Result<()> {
- let target = vendor_dir.join(package_name);
- if target.exists() {
- std::fs::remove_dir_all(&target)?;
- }
-
- match source_type {
- "git" => {
- let process = mozart_vcs::process::ProcessExecutor::new();
- let git_util =
- mozart_vcs::util::git::GitUtil::new(process, vendor_dir.join(".cache").join("git"));
- let downloader = mozart_vcs::downloader::git::GitDownloader::new(git_util);
- use mozart_vcs::downloader::VcsDownloader;
- downloader.download(url, reference, &target)?;
- downloader.install(url, reference, &target)?;
- }
- "svn" => {
- let process = mozart_vcs::process::ProcessExecutor::new();
- let svn_util = mozart_vcs::util::svn::SvnUtil::new(process);
- let downloader = mozart_vcs::downloader::svn::SvnDownloader::new(svn_util);
- use mozart_vcs::downloader::VcsDownloader;
- downloader.install(url, reference, &target)?;
- }
- "hg" => {
- let process = mozart_vcs::process::ProcessExecutor::new();
- let hg_util = mozart_vcs::util::hg::HgUtil::new(process);
- let downloader = mozart_vcs::downloader::hg::HgDownloader::new(hg_util);
- use mozart_vcs::downloader::VcsDownloader;
- downloader.install(url, reference, &target)?;
- }
- _ => {
- anyhow::bail!("Unsupported source type for VCS install: {}", source_type);
- }
- }
-
- Ok(())
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use tempfile::tempdir;
-
- fn make_executor() -> FilesystemExecutor {
- FilesystemExecutor::new(Cache::new(std::env::temp_dir().join("__no_cache"), false))
- }
-
- #[test]
- fn cleanup_after_uninstalls_removes_empty_namespace_dirs() {
- let dir = tempdir().unwrap();
- let vendor_dir = dir.path().join("vendor");
- std::fs::create_dir_all(&vendor_dir).unwrap();
-
- let empty_ns = vendor_dir.join("old-vendor");
- std::fs::create_dir_all(&empty_ns).unwrap();
-
- let nonempty_ns = vendor_dir.join("psr");
- std::fs::create_dir_all(nonempty_ns.join("log")).unwrap();
-
- std::fs::create_dir_all(vendor_dir.join("composer")).unwrap();
-
- let mut exec = make_executor();
- exec.cleanup_after_uninstalls(&ExecuteContext {
- vendor_dir: vendor_dir.clone(),
- no_progress: true,
- prefer_source: false,
- })
- .unwrap();
-
- assert!(!empty_ns.exists());
- assert!(vendor_dir.join("psr").exists());
- assert!(vendor_dir.join("composer").exists());
- }
-
- #[test]
- fn cleanup_after_uninstalls_preserves_bin_dir() {
- let dir = tempdir().unwrap();
- let vendor_dir = dir.path().join("vendor");
- std::fs::create_dir_all(&vendor_dir).unwrap();
-
- let bin_dir = vendor_dir.join("bin");
- std::fs::create_dir_all(&bin_dir).unwrap();
-
- let mut exec = make_executor();
- exec.cleanup_after_uninstalls(&ExecuteContext {
- vendor_dir: vendor_dir.clone(),
- no_progress: true,
- prefer_source: false,
- })
- .unwrap();
-
- assert!(bin_dir.exists());
- }
-}
diff --git a/crates/mozart-registry/src/installer_executor/mod.rs b/crates/mozart-registry/src/installer_executor/mod.rs
deleted file mode 100644
index 4ddad66..0000000
--- a/crates/mozart-registry/src/installer_executor/mod.rs
+++ /dev/null
@@ -1,348 +0,0 @@
-//! Installation execution abstraction.
-//!
-//! Mirrors `Composer\Installer\InstallationManager`: the per-operation
-//! side-effect surface (download, extract, remove from vendor/) lives behind
-//! a trait so test code can substitute a recording-only implementation
-//! (Composer's `InstallationManagerMock`) without going anywhere near the
-//! filesystem or the network.
-//!
-//! The orchestration loop (computing operations from lock vs installed,
-//! emitting console messages, writing `installed.json`, generating the
-//! autoloader) stays in the caller. The executor is purely the verb —
-//! "install this package" / "uninstall this package" — so test traces match
-//! Composer's `(string) $operation` byte-for-byte without the executor
-//! having to also reproduce console formatting.
-
-use std::path::PathBuf;
-
-use crate::installed::InstalledPackageEntry;
-use crate::lockfile::{LockAlias, LockedPackage};
-
-pub mod filesystem;
-pub mod trace_recorder;
-pub mod transaction;
-
-pub use filesystem::FilesystemExecutor;
-pub use trace_recorder::TraceRecorderExecutor;
-pub use transaction::{
- Action, StaleInstalledAlias, compute_operations, compute_stale_installed_aliases,
- locked_to_installed_entry, previously_installed_alias_versions,
-};
-
-/// One install or update operation handed to [`InstallerExecutor::install_package`].
-#[derive(Debug, Clone, Copy)]
-pub enum PackageOperation<'a> {
- /// First-time install. The whole package directory is created from
- /// `package.dist`/`package.source`.
- Install { package: &'a LockedPackage },
- /// Replace an existing install with a new version. `from_version` is the
- /// pretty version that was installed before (no reference suffix —
- /// drives the upgrade-vs-downgrade direction). `from_full_pretty` /
- /// `to_full_pretty` are the formatted display strings used verbatim in
- /// the trace output; the caller renders them via
- /// [`format_update_pretty_versions`] so the SOURCE_REF / DIST_REF mode
- /// switch from Composer's `UpdateOperation::format` lands on both sides.
- Update {
- from_version: &'a str,
- from_full_pretty: &'a str,
- to_full_pretty: &'a str,
- package: &'a LockedPackage,
- },
- /// Mark an alias of a real package as installed. No filesystem effects —
- /// only the trace recorder needs this. Mirrors Composer's
- /// `MarkAliasInstalledOperation`.
- MarkAliasInstalled {
- /// The alias entry from `composer.lock`'s `aliases[]` block. Carries
- /// pretty + normalized alias version and the target's pretty version.
- alias: &'a LockAlias,
- /// The target package the alias points at — used to source the
- /// reference suffix for the trace line.
- target: &'a LockedPackage,
- },
- /// Mark a previously-installed alias as uninstalled. No filesystem
- /// effects — only the trace recorder cares. Mirrors Composer's
- /// `MarkAliasUninstalledOperation`. Composer derives the AliasPackage
- /// from the previous installed.json entries (via `extra.branch-alias`),
- /// then emits this when the alias is no longer in the result. Caller
- /// pre-renders the display strings so this variant doesn't need to know
- /// how to spelunk the entry.
- MarkAliasUninstalled {
- /// Package name (e.g. `a/a`) used as both the alias's name and the
- /// target's name on the trace line.
- name: &'a str,
- /// Alias's full-pretty form (alias pretty version plus reference
- /// suffix), e.g. `1.0.x-dev master`.
- alias_full: &'a str,
- /// Target's full-pretty form, e.g. `dev-master master`.
- target_full: &'a str,
- },
-}
-
-impl<'a> PackageOperation<'a> {
- pub fn package(&self) -> Option<&'a LockedPackage> {
- match self {
- PackageOperation::Install { package } | PackageOperation::Update { package, .. } => {
- Some(package)
- }
- PackageOperation::MarkAliasInstalled { .. }
- | PackageOperation::MarkAliasUninstalled { .. } => None,
- }
- }
-}
-
-/// Mirror Composer's `BasePackage::getFullPrettyVersion()` for a `LockedPackage`.
-///
-/// For dev-stability versions backed by a git/hg source, append the reference
-/// (truncated to 7 chars when it looks like a 40-char sha1). Otherwise return
-/// the pretty version unchanged.
-pub fn format_full_pretty_version(pkg: &LockedPackage) -> String {
- format_full_pretty_with_pretty(&pkg.version, pkg)
-}
-
-/// Same as [`format_full_pretty_version`] but lets the caller supply an
-/// alternate pretty version (used by `MarkAliasInstalled` so the alias's
-/// `3.2.x-dev` text is rendered with the *target's* reference).
-pub fn format_full_pretty_with_pretty(pretty_version: &str, pkg: &LockedPackage) -> String {
- let source_ref = pkg.source.as_ref().and_then(|s| s.reference.as_deref());
- let dist_ref = pkg.dist.as_ref().and_then(|d| d.reference.as_deref());
- let source_type = pkg.source.as_ref().map(|s| s.source_type.as_str());
- format_full_pretty_with_refs(
- pretty_version,
- &pkg.version,
- source_ref,
- dist_ref,
- source_type,
- )
-}
-
-/// Render an alias's full pretty version: the alias's own pretty form for
-/// the visible text, the alias's *normalized* version for the dev-stability
-/// gate, and the target package's source/dist references for the suffix.
-/// Mirrors `AliasPackage::getFullPrettyVersion`, where the alias decides on
-/// its own whether to append a reference based on its own stability — so a
-/// stable alias like `1.0.0` skips the suffix even when the target is a dev
-/// branch.
-pub fn format_full_pretty_alias(
- alias_pretty: &str,
- alias_version: &str,
- target: &LockedPackage,
-) -> String {
- let source_ref = target.source.as_ref().and_then(|s| s.reference.as_deref());
- let dist_ref = target.dist.as_ref().and_then(|d| d.reference.as_deref());
- let source_type = target.source.as_ref().map(|s| s.source_type.as_str());
- format_full_pretty_with_refs(
- alias_pretty,
- alias_version,
- source_ref,
- dist_ref,
- source_type,
- )
-}
-
-/// Same as [`format_full_pretty_version_for_installed`] but lets the caller
-/// supply an alternate pretty version. Used when emitting
-/// `MarkAliasUninstalled`: the alias's `1.0.x-dev` text needs to be rendered
-/// with the *target installed entry's* reference suffix.
-pub fn format_full_pretty_with_pretty_for_installed(
- pretty_version: &str,
- entry: &InstalledPackageEntry,
-) -> String {
- let source_ref = entry
- .source
- .as_ref()
- .and_then(|v| v.get("reference"))
- .and_then(|v| v.as_str());
- let dist_ref = entry
- .dist
- .as_ref()
- .and_then(|v| v.get("reference"))
- .and_then(|v| v.as_str());
- let source_type = entry
- .source
- .as_ref()
- .and_then(|v| v.get("type"))
- .and_then(|v| v.as_str());
- format_full_pretty_with_refs(
- pretty_version,
- &entry.version,
- source_ref,
- dist_ref,
- source_type,
- )
-}
-
-/// Mirror Composer's `BasePackage::getFullPrettyVersion()` for an
-/// `InstalledPackageEntry`. Same display rules as
-/// [`format_full_pretty_version`] but pulls source/dist info out of the
-/// installed.json `source`/`dist` JSON values.
-pub fn format_full_pretty_version_for_installed(entry: &InstalledPackageEntry) -> String {
- format_full_pretty_with_pretty_for_installed(&entry.version, entry)
-}
-
-/// Render the from/to display strings for an update trace line, mirroring
-/// Composer's `UpdateOperation::format`. Defaults to `DISPLAY_SOURCE_REF_IF_DEV`,
-/// then if both sides render identically:
-///
-/// - source references differ → re-render in `DISPLAY_SOURCE_REF` mode,
-/// - else dist references differ → re-render in `DISPLAY_DIST_REF` mode.
-///
-/// Without the switch, two same-version-different-reference packages would
-/// produce a useless `pkg (X => X)` trace line.
-pub fn format_update_pretty_versions(
- from_entry: &InstalledPackageEntry,
- to_pkg: &LockedPackage,
-) -> (String, String) {
- let from_default = format_full_pretty_version_for_installed(from_entry);
- let to_default = format_full_pretty_version(to_pkg);
- if from_default != to_default {
- return (from_default, to_default);
- }
-
- let from_source_ref = from_entry
- .source
- .as_ref()
- .and_then(|v| v.get("reference"))
- .and_then(|v| v.as_str());
- let from_source_type = from_entry
- .source
- .as_ref()
- .and_then(|v| v.get("type"))
- .and_then(|v| v.as_str());
- let to_source_ref = to_pkg.source.as_ref().and_then(|s| s.reference.as_deref());
- let to_source_type = to_pkg.source.as_ref().map(|s| s.source_type.as_str());
-
- if from_source_ref != to_source_ref {
- return (
- format_with_explicit_reference(&from_entry.version, from_source_ref, from_source_type),
- format_with_explicit_reference(&to_pkg.version, to_source_ref, to_source_type),
- );
- }
-
- let from_dist_ref = from_entry
- .dist
- .as_ref()
- .and_then(|v| v.get("reference"))
- .and_then(|v| v.as_str());
- let to_dist_ref = to_pkg.dist.as_ref().and_then(|d| d.reference.as_deref());
-
- if from_dist_ref != to_dist_ref {
- return (
- format_with_explicit_reference(&from_entry.version, from_dist_ref, from_source_type),
- format_with_explicit_reference(&to_pkg.version, to_dist_ref, to_source_type),
- );
- }
-
- (from_default, to_default)
-}
-
-/// Render `pretty_version` with an explicitly chosen reference, mirroring
-/// Composer's `BasePackage::getFullPrettyVersion` with `DISPLAY_SOURCE_REF`
-/// or `DISPLAY_DIST_REF`: skip the dev-stability gate, just truncate sha1
-/// references and concatenate. A `None` reference falls back to the bare
-/// pretty version.
-fn format_with_explicit_reference(
- pretty_version: &str,
- reference: Option<&str>,
- source_type: Option<&str>,
-) -> String {
- let Some(reference) = reference else {
- return pretty_version.to_string();
- };
- if matches!(source_type, Some("svn")) {
- return format!("{} {}", pretty_version, reference);
- }
- if reference.len() == 40 {
- return format!("{} {}", pretty_version, &reference[..7]);
- }
- format!("{} {}", pretty_version, reference)
-}
-
-/// Core of `BasePackage::getFullPrettyVersion()` factored over raw
-/// fields so both [`LockedPackage`] and [`InstalledPackageEntry`] can share
-/// the rendering logic. `version` drives the dev-stability check; the result
-/// is `pretty_version` plus a reference suffix when the package is a dev
-/// branch backed by git/hg (with sha1 references truncated to 7 chars).
-fn format_full_pretty_with_refs(
- pretty_version: &str,
- version: &str,
- source_ref: Option<&str>,
- dist_ref: Option<&str>,
- source_type: Option<&str>,
-) -> String {
- let is_dev = mozart_semver::Version::parse(version)
- .map(|v| matches!(v.pre_release.as_deref(), Some("dev")) || v.is_dev_branch)
- .unwrap_or(false);
- if !is_dev {
- return pretty_version.to_string();
- }
- // Composer falls back to dist reference only when no source type is set
- // (or the package isn't git/hg — in which case the dev display is skipped
- // entirely above).
- let reference = source_ref.or(match source_type {
- Some("git") | Some("hg") => None,
- _ => dist_ref,
- });
- let Some(reference) = reference else {
- return pretty_version.to_string();
- };
- if matches!(source_type, Some("git") | Some("hg")) && reference.len() == 40 {
- format!("{} {}", pretty_version, &reference[..7])
- } else if matches!(source_type, Some("svn")) {
- // svn references are revision numbers, never truncated
- format!("{} {}", pretty_version, reference)
- } else if reference.len() == 40 {
- // dist-ref fallback (no git/hg source) — Composer truncates here too
- format!("{} {}", pretty_version, &reference[..7])
- } else {
- format!("{} {}", pretty_version, reference)
- }
-}
-
-/// Per-call configuration shared across executor methods. Owned by the
-/// caller (typically `install_from_lock`) so the executor sees a consistent
-/// view across an entire install/update run.
-#[derive(Debug, Clone)]
-pub struct ExecuteContext {
- pub vendor_dir: PathBuf,
- /// Suppress download progress bars.
- pub no_progress: bool,
- /// Prefer cloning from VCS source over downloading dist archives.
- pub prefer_source: bool,
-}
-
-/// Side-effect surface for install/update/uninstall operations.
-///
-/// Implementations are stateful — `&mut self` lets a recorder accumulate
-/// trace lines and lets the filesystem implementation hold long-lived
-/// handles (caches, progress bars). All methods return `anyhow::Result` so
-/// callers can short-circuit on the first failure, mirroring Composer's
-/// fail-fast `InstallationManager::execute`.
-#[async_trait::async_trait]
-pub trait InstallerExecutor: Send + Sync {
- /// Perform side effects for one install or update operation.
- async fn install_package(
- &mut self,
- op: PackageOperation<'_>,
- ctx: &ExecuteContext,
- ) -> anyhow::Result<()>;
-
- /// Perform side effects for one uninstall.
- ///
- /// `version` is the previously-installed version (from installed.json),
- /// passed so the trace recorder can format Composer's
- /// `Uninstalling pkg/name (version)` line. The filesystem implementation
- /// ignores it — `name` alone is enough to locate the vendor directory.
- fn uninstall_package(
- &mut self,
- name: &str,
- version: &str,
- ctx: &ExecuteContext,
- ) -> anyhow::Result<()>;
-
- /// Hook called once after every uninstall has run. Default no-op.
- /// Composer cleans up empty namespace directories here; the recorder
- /// has no work to do.
- fn cleanup_after_uninstalls(&mut self, _ctx: &ExecuteContext) -> anyhow::Result<()> {
- Ok(())
- }
-}
diff --git a/crates/mozart-registry/src/installer_executor/trace_recorder.rs b/crates/mozart-registry/src/installer_executor/trace_recorder.rs
deleted file mode 100644
index b60a869..0000000
--- a/crates/mozart-registry/src/installer_executor/trace_recorder.rs
+++ /dev/null
@@ -1,160 +0,0 @@
-//! Recording-only [`InstallerExecutor`] for in-process tests.
-//!
-//! Mirrors `Composer\Test\Mock\InstallationManagerMock` — every call appends
-//! a string to a `Vec<String>` matching Composer's
-//! `(string) $operation` output (after `strip_tags`). No filesystem or
-//! network I/O happens. The recorded trace is what tests assert against
-//! `--EXPECT--` in Composer's `.test` fixture format.
-//!
-//! Trace line shapes (byte-equivalent to Composer's `*Operation::__toString`
-//! after `strip_tags`):
-//!
-//! - Install: `Installing <name> (<version>)`
-//! - Update (upgrade direction): `Upgrading <name> (<oldVersion> => <newVersion>)`
-//! - Update (downgrade direction): `Downgrading <name> (<oldVersion> => <newVersion>)`
-//! - Uninstall: `Removing <name> (<version>)`
-
-use mozart_semver::Version;
-
-use super::{
- ExecuteContext, InstallerExecutor, PackageOperation, format_full_pretty_alias,
- format_full_pretty_version,
-};
-
-/// Recording-only executor. Construct with [`TraceRecorderExecutor::new`],
-/// then read [`TraceRecorderExecutor::trace`] after the run completes.
-pub struct TraceRecorderExecutor {
- trace: Vec<String>,
-}
-
-impl TraceRecorderExecutor {
- pub fn new() -> Self {
- Self { trace: Vec::new() }
- }
-
- /// Recorded operation strings, in the order [`InstallerExecutor`] was
- /// invoked. Pass this to `assert_eq!` against the fixture's `--EXPECT--`
- /// section after splitting on newlines.
- pub fn trace(&self) -> &[String] {
- &self.trace
- }
-
- /// Take ownership of the recorded trace. Use after the run if the
- /// executor is going out of scope.
- pub fn into_trace(self) -> Vec<String> {
- self.trace
- }
-}
-
-impl Default for TraceRecorderExecutor {
- fn default() -> Self {
- Self::new()
- }
-}
-
-#[async_trait::async_trait]
-impl InstallerExecutor for TraceRecorderExecutor {
- async fn install_package(
- &mut self,
- op: PackageOperation<'_>,
- _ctx: &ExecuteContext,
- ) -> anyhow::Result<()> {
- match op {
- PackageOperation::Install { package } => {
- self.trace.push(format!(
- "Installing {} ({})",
- package.name,
- format_full_pretty_version(package)
- ));
- }
- PackageOperation::Update {
- from_version,
- from_full_pretty,
- to_full_pretty,
- package,
- } => {
- let action = if is_upgrade(from_version, &package.version) {
- "Upgrading"
- } else {
- "Downgrading"
- };
- self.trace.push(format!(
- "{} {} ({} => {})",
- action, package.name, from_full_pretty, to_full_pretty
- ));
- }
- PackageOperation::MarkAliasInstalled { alias, target } => {
- let alias_full =
- format_full_pretty_alias(&alias.alias, &alias.alias_normalized, target);
- let target_full = format_full_pretty_version(target);
- self.trace.push(format!(
- "Marking {} ({}) as installed, alias of {} ({})",
- alias.package, alias_full, alias.package, target_full
- ));
- }
- PackageOperation::MarkAliasUninstalled {
- name,
- alias_full,
- target_full,
- } => {
- self.trace.push(format!(
- "Marking {} ({}) as uninstalled, alias of {} ({})",
- name, alias_full, name, target_full
- ));
- }
- }
- Ok(())
- }
-
- fn uninstall_package(
- &mut self,
- name: &str,
- version: &str,
- _ctx: &ExecuteContext,
- ) -> anyhow::Result<()> {
- self.trace.push(format!("Removing {} ({})", name, version));
- Ok(())
- }
-}
-
-/// Mirrors `Composer\Package\Version\VersionParser::isUpgrade`. Returns true
-/// when `to` should be treated as an upgrade from `from` for the purpose of
-/// the trace verb (`Upgrading` vs `Downgrading`).
-///
-/// The rules:
-/// 1. Same string → upgrade.
-/// 2. `dev-master` / `dev-trunk` / `dev-default` substitute to the
-/// `9999999-dev` default-branch alias before further checks (they are
-/// not literal dev-* names; they are the conventional "latest" branch).
-/// 3. After that substitution, if either side starts with `dev-` (i.e. is
-/// a dev branch other than the defaults) → upgrade. Composer treats
-/// hopping between dev branches as a forward move regardless of order.
-/// 4. Otherwise sort numerically and check the original `from` ended up
-/// first (= the smaller value).
-fn is_upgrade(from: &str, to: &str) -> bool {
- if from == to {
- return true;
- }
- let original_from = from;
- let normalize_default = |s: &str| -> String {
- if matches!(s, "dev-master" | "dev-trunk" | "dev-default") {
- "9999999-dev".to_string()
- } else {
- s.to_string()
- }
- };
- let from_norm = normalize_default(from);
- let to_norm = normalize_default(to);
- if from_norm.starts_with("dev-") || to_norm.starts_with("dev-") {
- return true;
- }
- match (Version::parse(&from_norm), Version::parse(&to_norm)) {
- (Ok(a), Ok(b)) => b >= a,
- _ => {
- // Mirror Composer's fall-through: with two unparseable strings
- // there is nothing to compare, treat the move as an upgrade.
- let _ = original_from;
- true
- }
- }
-}
diff --git a/crates/mozart-registry/src/installer_executor/transaction.rs b/crates/mozart-registry/src/installer_executor/transaction.rs
deleted file mode 100644
index 95f9718..0000000
--- a/crates/mozart-registry/src/installer_executor/transaction.rs
+++ /dev/null
@@ -1,411 +0,0 @@
-//! Transaction computation — lock-vs-installed diff and alias reconciliation.
-//!
-//! Mirrors `Composer\DependencyResolver\Transaction::calculateOperations` and
-//! `Composer\Installer\InstalledFilesystemRepository` (the `ArrayDumper`
-//! path). Kept separate so both `install` and `update` commands can share the
-//! same operation-computation machinery without going through the `install`
-//! command module.
-
-use crate::installed::{InstalledPackageEntry, InstalledPackages};
-use crate::lockfile::{LockFile, LockedPackage};
-use indexmap::IndexSet;
-use std::path::Path;
-
-/// The action to take for a package during install.
-#[derive(Debug, PartialEq, Eq)]
-pub enum Action {
- Install,
- Update,
- Skip,
-}
-
-/// Compute install operations by comparing locked packages against installed packages.
-///
-/// Returns `(ops, removals)` where:
-/// - `ops`: list of `(package, action)` ordered topologically — every package's
-/// lock-internal `require` deps appear before it, matching Composer's
-/// `Transaction::calculateOperations`.
-/// - `removals`: list of package names that are installed but not locked.
-pub fn compute_operations<'a>(
- locked: &[&'a LockedPackage],
- installed: &InstalledPackages,
-) -> (Vec<(&'a LockedPackage, Action)>, Vec<String>) {
- let ordered = topological_sort(locked);
-
- let mut ops: Vec<(&'a LockedPackage, Action)> = Vec::new();
- for pkg in ordered {
- let installed_entry = installed
- .packages
- .iter()
- .find(|p| p.name.eq_ignore_ascii_case(&pkg.name));
- let action = match installed_entry {
- None => Action::Install,
- Some(entry) if entry.version != pkg.version => Action::Update,
- Some(entry) if !installed_refs_match_locked(entry, pkg) => Action::Update,
- Some(entry) if !installed_abandoned_matches_locked(entry, pkg) => Action::Update,
- Some(_) => Action::Skip,
- };
- ops.push((pkg, action));
- }
-
- // Compute removals: packages in installed but not in locked. Iterate
- // installed.json in reverse, mirroring Composer's
- // `Transaction::calculateOperations`, which seeds `removeMap` from
- // `presentPackages` in order and then `array_unshift`s each entry onto
- // `operations` — flipping the iteration order.
- let locked_names: IndexSet<String> = locked.iter().map(|p| p.name.to_lowercase()).collect();
- let removals: Vec<String> = installed
- .packages
- .iter()
- .rev()
- .filter(|p| !locked_names.contains(&p.name.to_lowercase()))
- .map(|p| p.name.clone())
- .collect();
-
- (ops, removals)
-}
-
-/// Order a slice of locked packages so every package's `require` deps that
-/// are present in the same slice come before it. Mirrors
-/// `Composer\DependencyResolver\Transaction::calculateOperations` — the
-/// stack-based DFS over the result map.
-fn topological_sort<'a>(packages: &[&'a LockedPackage]) -> Vec<&'a LockedPackage> {
- use std::collections::BTreeMap;
-
- // Reverse-alphabetical sort, mirroring `setResultPackageMaps`.
- let mut sorted: Vec<&'a LockedPackage> = packages.to_vec();
- sorted.sort_by_key(|p| std::cmp::Reverse(p.name.to_lowercase()));
-
- // Multimap: name → [packages]. A package contributes itself under its
- // own name *and* under every `provide`/`replace` entry.
- let mut resolves: BTreeMap<String, Vec<&'a LockedPackage>> = BTreeMap::new();
- for pkg in &sorted {
- let names = std::iter::once(pkg.name.to_lowercase())
- .chain(pkg.provide.keys().map(|s| s.to_lowercase()))
- .chain(pkg.replace.keys().map(|s| s.to_lowercase()));
- for n in names {
- resolves.entry(n).or_default().push(*pkg);
- }
- }
-
- // Mirror Composer's `getRootPackages`: walk in sorted order, removing
- // each package's required providers from the candidate-roots set.
- let mut roots_set: IndexSet<String> = sorted.iter().map(|p| p.name.to_lowercase()).collect();
- for pkg in &sorted {
- let pkg_lower = pkg.name.to_lowercase();
- if !roots_set.contains(&pkg_lower) {
- continue;
- }
- for dep in pkg.require.keys() {
- let dep_lower = dep.to_lowercase();
- if let Some(matches) = resolves.get(&dep_lower) {
- for &m in matches {
- let m_lower = m.name.to_lowercase();
- if m_lower != pkg_lower {
- roots_set.shift_remove(&m_lower);
- }
- }
- }
- }
- }
-
- let mut stack: Vec<&'a LockedPackage> = sorted
- .iter()
- .filter(|p| roots_set.contains(&p.name.to_lowercase()))
- .copied()
- .collect();
-
- let mut visited: IndexSet<String> = IndexSet::new();
- let mut processed: IndexSet<String> = IndexSet::new();
- let mut ordered: Vec<&'a LockedPackage> = Vec::with_capacity(packages.len());
-
- while let Some(pkg) = stack.pop() {
- let lower = pkg.name.to_lowercase();
- if processed.contains(&lower) {
- continue;
- }
- if !visited.contains(&lower) {
- visited.insert(lower);
- stack.push(pkg);
- for dep in pkg.require.keys() {
- let dep_lower = dep.to_lowercase();
- if let Some(matches) = resolves.get(&dep_lower) {
- for &m in matches {
- stack.push(m);
- }
- }
- }
- } else {
- processed.insert(lower);
- ordered.push(pkg);
- }
- }
-
- // Cycle / disconnected fallback: append any leftover packages.
- for pkg in packages {
- let lower = pkg.name.to_lowercase();
- if !processed.contains(&lower) {
- processed.insert(lower);
- ordered.push(*pkg);
- }
- }
-
- ordered
-}
-
-/// Pre-rendered MarkAliasUninstalled operation. Caller pre-computes the
-/// display strings so the executor call site stays simple.
-pub struct StaleInstalledAlias {
- pub name: String,
- pub alias_full: String,
- pub target_full: String,
-}
-
-/// `(package_name_lowercase, alias_pretty)` pairs the *new* lock's packages
-/// will surface — used by `compute_stale_installed_aliases` to determine which
-/// currently-installed alias packages no longer have a counterpart in the new
-/// lock. Mirrors `Locker::getLockedRepository` running every locked package
-/// through `ArrayLoader`.
-fn lock_alias_pretty_pairs(lock: &LockFile) -> std::collections::HashSet<(String, String)> {
- use std::collections::HashSet;
- let mut set: HashSet<(String, String)> = HashSet::new();
- for a in &lock.aliases {
- set.insert((a.package.to_lowercase(), a.alias.clone()));
- }
- for pkg in lock
- .packages
- .iter()
- .chain(lock.packages_dev.iter().flatten())
- {
- let mut emitted_explicit = false;
- if let Some(map) = pkg
- .extra_fields
- .get("extra")
- .and_then(|e| e.get("branch-alias"))
- .and_then(|b| b.as_object())
- {
- for (source, target) in map {
- if !source.eq_ignore_ascii_case(&pkg.version) {
- continue;
- }
- let Some(target_str) = target.as_str() else {
- continue;
- };
- if !target_str.to_lowercase().ends_with("-dev") {
- continue;
- }
- set.insert((pkg.name.to_lowercase(), target_str.to_string()));
- emitted_explicit = true;
- }
- }
- if emitted_explicit {
- continue;
- }
- let is_default_branch = pkg
- .extra_fields
- .get("default-branch")
- .and_then(|v| v.as_bool())
- .unwrap_or(false);
- if !is_default_branch {
- continue;
- }
- let version_lower = pkg.version.to_lowercase();
- let is_dev_branch = version_lower.starts_with("dev-") || version_lower.ends_with("-dev");
- if !is_dev_branch {
- continue;
- }
- set.insert((pkg.name.to_lowercase(), "9999999-dev".to_string()));
- }
- set
-}
-
-/// Walk every `installed.json` entry, expand its `extra.branch-alias` map, and
-/// emit a [`StaleInstalledAlias`] for each whose alias version doesn't appear
-/// in the new lock. Mirrors `Transaction::calculateOperations`
-/// `MarkAliasUninstalledOperation` logic.
-pub fn compute_stale_installed_aliases(
- installed: &InstalledPackages,
- lock: &LockFile,
-) -> Vec<StaleInstalledAlias> {
- use super::{
- format_full_pretty_version_for_installed, format_full_pretty_with_pretty_for_installed,
- };
-
- let preserved = lock_alias_pretty_pairs(lock);
- let still_present = |name: &str, alias_pretty: &str| -> bool {
- preserved.contains(&(name.to_lowercase(), alias_pretty.to_string()))
- };
- let mut stale = Vec::new();
- for entry in &installed.packages {
- let mut emitted_explicit = false;
- if let Some(branch_alias) = entry
- .extra_fields
- .get("extra")
- .and_then(|e| e.get("branch-alias"))
- .and_then(|b| b.as_object())
- {
- for (target_branch, alias_value) in branch_alias {
- if entry.version != *target_branch {
- continue;
- }
- let Some(alias_pretty) = alias_value.as_str() else {
- continue;
- };
- emitted_explicit = true;
- if still_present(&entry.name, alias_pretty) {
- continue;
- }
- stale.push(StaleInstalledAlias {
- name: entry.name.clone(),
- alias_full: format_full_pretty_with_pretty_for_installed(alias_pretty, entry),
- target_full: format_full_pretty_version_for_installed(entry),
- });
- }
- }
-
- // Synthetic `9999999-dev` default-branch alias.
- if emitted_explicit {
- continue;
- }
- let is_default_branch = entry
- .extra_fields
- .get("default-branch")
- .and_then(|v| v.as_bool())
- .unwrap_or(false);
- if !is_default_branch {
- continue;
- }
- let version_lower = entry.version.to_lowercase();
- let is_dev_branch = version_lower.starts_with("dev-") || version_lower.ends_with("-dev");
- if !is_dev_branch {
- continue;
- }
- const DEFAULT_BRANCH_ALIAS: &str = "9999999-dev";
- if still_present(&entry.name, DEFAULT_BRANCH_ALIAS) {
- continue;
- }
- stale.push(StaleInstalledAlias {
- name: entry.name.clone(),
- alias_full: format_full_pretty_with_pretty_for_installed(DEFAULT_BRANCH_ALIAS, entry),
- target_full: format_full_pretty_version_for_installed(entry),
- });
- }
- stale
-}
-
-/// Collect the alias normalized-versions a previous install recorded for
-/// `pkg_name`. Mirrors Composer's `presentAliasMap` seeding.
-pub fn previously_installed_alias_versions(
- installed: &InstalledPackages,
- pkg_name: &str,
-) -> Vec<String> {
- let mut out = Vec::new();
- for entry in &installed.packages {
- if !entry.name.eq_ignore_ascii_case(pkg_name) {
- continue;
- }
- let version_lower = entry.version.to_lowercase();
- let is_dev_branch = version_lower.starts_with("dev-") || version_lower.ends_with("-dev");
- if !is_dev_branch {
- continue;
- }
-
- let mut emitted_explicit_alias = false;
- if let Some(branch_alias_map) = entry
- .extra_fields
- .get("extra")
- .and_then(|e| e.get("branch-alias"))
- .and_then(|b| b.as_object())
- {
- for (source, target) in branch_alias_map {
- if !source.eq_ignore_ascii_case(&entry.version) {
- continue;
- }
- let Some(target_str) = target.as_str() else {
- continue;
- };
- if !target_str.to_lowercase().ends_with("-dev") {
- continue;
- }
- if let Some(normalized) = crate::resolver::normalize_branch_alias_target(target_str)
- {
- out.push(normalized);
- emitted_explicit_alias = true;
- }
- }
- }
-
- if !emitted_explicit_alias
- && entry
- .extra_fields
- .get("default-branch")
- .and_then(|v| v.as_bool())
- .unwrap_or(false)
- {
- out.push("9999999.9999999.9999999.9999999-dev".to_string());
- }
- }
- out
-}
-
-/// Convert a `LockedPackage` to an `InstalledPackageEntry`.
-///
-/// Mirrors Composer's `InstalledFilesystemRepository::write()` via
-/// `ArrayDumper` — `extra_fields` is forwarded verbatim so flags like
-/// `abandoned` and `default-branch` survive the lock → installed.json round
-/// trip.
-pub fn locked_to_installed_entry(pkg: &LockedPackage, _vendor_dir: &Path) -> InstalledPackageEntry {
- let install_path = format!("../{}", pkg.name);
- InstalledPackageEntry {
- name: pkg.name.clone(),
- version: pkg.version.clone(),
- version_normalized: pkg.version_normalized.clone(),
- source: pkg
- .source
- .as_ref()
- .map(|s| serde_json::to_value(s).unwrap_or_default()),
- dist: pkg
- .dist
- .as_ref()
- .map(|d| serde_json::to_value(d).unwrap_or_default()),
- package_type: pkg.package_type.clone(),
- install_path: Some(install_path),
- autoload: pkg.autoload.clone(),
- aliases: vec![],
- homepage: pkg.homepage.clone(),
- support: pkg.support.clone(),
- extra_fields: pkg.extra_fields.clone(),
- }
-}
-
-fn installed_refs_match_locked(entry: &InstalledPackageEntry, locked: &LockedPackage) -> bool {
- let installed_source_ref = entry
- .source
- .as_ref()
- .and_then(|v| v.get("reference"))
- .and_then(|v| v.as_str());
- let installed_dist_ref = entry
- .dist
- .as_ref()
- .and_then(|v| v.get("reference"))
- .and_then(|v| v.as_str());
- let locked_source_ref = locked.source.as_ref().and_then(|s| s.reference.as_deref());
- let locked_dist_ref = locked.dist.as_ref().and_then(|d| d.reference.as_deref());
- installed_source_ref == locked_source_ref && installed_dist_ref == locked_dist_ref
-}
-
-fn abandoned_state(v: Option<&serde_json::Value>) -> (bool, Option<&str>) {
- match v {
- Some(serde_json::Value::Bool(b)) => (*b, None),
- Some(serde_json::Value::String(s)) => (true, Some(s.as_str())),
- _ => (false, None),
- }
-}
-
-fn installed_abandoned_matches_locked(
- entry: &InstalledPackageEntry,
- locked: &LockedPackage,
-) -> bool {
- abandoned_state(entry.extra_fields.get("abandoned"))
- == abandoned_state(locked.extra_fields.get("abandoned"))
-}
diff --git a/crates/mozart-registry/src/lib.rs b/crates/mozart-registry/src/lib.rs
deleted file mode 100644
index e35056c..0000000
--- a/crates/mozart-registry/src/lib.rs
+++ /dev/null
@@ -1,18 +0,0 @@
-pub mod advisory;
-pub mod browse_repos;
-pub mod cache;
-pub mod composer_repo;
-pub mod download_manager;
-pub mod downloader;
-pub mod inline_package;
-pub mod installed;
-pub mod installer_executor;
-pub mod lockfile;
-pub mod packagist;
-pub mod path_repository;
-pub mod repository;
-pub mod repository_filter;
-pub mod resolver;
-pub mod vcs_bridge;
-pub mod version;
-pub mod version_selector;
diff --git a/crates/mozart-registry/src/lockfile.rs b/crates/mozart-registry/src/lockfile.rs
deleted file mode 100644
index fd6b5e3..0000000
--- a/crates/mozart-registry/src/lockfile.rs
+++ /dev/null
@@ -1,2037 +0,0 @@
-use crate::packagist::{PackagistDist, PackagistSource, PackagistVersion};
-use crate::repository::RepositorySet;
-use crate::resolver::ResolvedPackage;
-use indexmap::IndexMap;
-use indexmap::IndexSet;
-use mozart_core::installer::HasSuggests;
-use mozart_core::package::{RawPackageData, to_json_pretty};
-use serde::{Deserialize, Serialize};
-use std::collections::{BTreeMap, VecDeque};
-use std::fs;
-use std::path::Path;
-
-fn default_stability() -> String {
- "stable".to_string()
-}
-
-fn default_empty_object() -> serde_json::Value {
- serde_json::Value::Object(serde_json::Map::new())
-}
-
-/// Represents the content of a composer.lock file.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct LockFile {
- #[serde(rename = "_readme", default = "LockFile::default_readme")]
- pub readme: Vec<String>,
-
- /// Composer lock files written before content-hash existed (or fixtures
- /// covering BC behavior) may omit this field; mirror Composer's BC support
- /// in `Locker::isLocked()` by defaulting to empty.
- #[serde(rename = "content-hash", default)]
- pub content_hash: String,
-
- pub packages: Vec<LockedPackage>,
-
- #[serde(rename = "packages-dev")]
- pub packages_dev: Option<Vec<LockedPackage>>,
-
- #[serde(default)]
- pub aliases: Vec<LockAlias>,
-
- #[serde(rename = "minimum-stability", default = "default_stability")]
- pub minimum_stability: String,
-
- #[serde(rename = "stability-flags", default = "default_empty_object")]
- pub stability_flags: serde_json::Value,
-
- #[serde(rename = "prefer-stable", default)]
- pub prefer_stable: bool,
-
- #[serde(rename = "prefer-lowest", default)]
- pub prefer_lowest: bool,
-
- #[serde(default = "default_empty_object")]
- pub platform: serde_json::Value,
-
- #[serde(rename = "platform-dev", default = "default_empty_object")]
- pub platform_dev: serde_json::Value,
-
- #[serde(rename = "plugin-api-version", skip_serializing_if = "Option::is_none")]
- pub plugin_api_version: Option<String>,
-}
-
-/// A locked package entry in composer.lock.
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct LockedPackage {
- pub name: String,
- pub version: String,
-
- #[serde(rename = "version_normalized", skip_serializing_if = "Option::is_none")]
- pub version_normalized: Option<String>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub source: Option<LockedSource>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub dist: Option<LockedDist>,
-
- #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
- pub require: BTreeMap<String, String>,
-
- #[serde(
- rename = "require-dev",
- default,
- skip_serializing_if = "BTreeMap::is_empty"
- )]
- pub require_dev: BTreeMap<String, String>,
-
- #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
- pub conflict: BTreeMap<String, String>,
-
- #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
- pub provide: BTreeMap<String, String>,
-
- #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
- pub replace: BTreeMap<String, String>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub suggest: Option<BTreeMap<String, String>>,
-
- #[serde(rename = "type", skip_serializing_if = "Option::is_none")]
- pub package_type: Option<String>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub autoload: Option<serde_json::Value>,
-
- #[serde(rename = "autoload-dev", skip_serializing_if = "Option::is_none")]
- pub autoload_dev: Option<serde_json::Value>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub license: Option<Vec<String>>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub description: Option<String>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub homepage: Option<String>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub keywords: Option<Vec<String>>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub authors: Option<Vec<serde_json::Value>>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub support: Option<serde_json::Value>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub funding: Option<Vec<serde_json::Value>>,
-
- #[serde(skip_serializing_if = "Option::is_none")]
- pub time: Option<String>,
-
- /// Catch-all for extra fields we don't explicitly model
- #[serde(flatten)]
- pub extra_fields: BTreeMap<String, serde_json::Value>,
-}
-
-impl HasSuggests for LockedPackage {
- fn pretty_name(&self) -> &str {
- &self.name
- }
-
- fn suggests(&self) -> Vec<(String, String)> {
- self.suggest
- .as_ref()
- .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
- .unwrap_or_default()
- }
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct LockedSource {
- #[serde(rename = "type")]
- pub source_type: String,
- pub url: String,
- pub reference: Option<String>,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct LockedDist {
- #[serde(rename = "type")]
- pub dist_type: String,
- pub url: String,
- pub reference: Option<String>,
- #[serde(skip_serializing_if = "Option::is_none")]
- pub shasum: Option<String>,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize)]
-pub struct LockAlias {
- pub package: String,
- pub version: String,
- pub alias: String,
- pub alias_normalized: String,
-}
-
-impl LockFile {
- /// Create default readme entries.
- pub fn default_readme() -> Vec<String> {
- vec![
- "This file locks the dependencies of your project to a known state".to_string(),
- "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies".to_string(),
- "This file is @generated automatically".to_string(),
- ]
- }
-
- /// Read a composer.lock file from disk.
- pub fn read_from_file(path: &Path) -> anyhow::Result<LockFile> {
- let content = fs::read_to_string(path)?;
- let lock: LockFile = serde_json::from_str(&content)?;
- Ok(lock)
- }
-
- /// Write a composer.lock file to disk with deterministic formatting.
- pub fn write_to_file(&self, path: &Path) -> anyhow::Result<()> {
- let json = to_json_pretty(self)?;
- fs::write(path, json)?;
- Ok(())
- }
-
- /// Check if the lock file is fresh (content-hash matches composer.json).
- pub fn is_fresh(&self, composer_json_content: &str) -> bool {
- match Self::compute_content_hash(composer_json_content) {
- Ok(hash) => hash == self.content_hash,
- Err(_) => false,
- }
- }
-
- /// Compute the content hash from composer.json content.
- /// Matches Composer's `Locker::getContentHash()`.
- pub fn compute_content_hash(composer_json_content: &str) -> anyhow::Result<String> {
- let value: serde_json::Value = serde_json::from_str(composer_json_content)?;
- let obj = value
- .as_object()
- .ok_or_else(|| anyhow::anyhow!("composer.json must be a JSON object"))?;
-
- // Keys that affect the content hash (Composer's relevantKeys)
- let relevant_keys = [
- "name",
- "version",
- "require",
- "require-dev",
- "conflict",
- "replace",
- "provide",
- "minimum-stability",
- "prefer-stable",
- "repositories",
- "extra",
- ];
-
- // Collect relevant keys into a BTreeMap (auto-sorted by key)
- let mut filtered: BTreeMap<&str, &serde_json::Value> = BTreeMap::new();
- for key in &relevant_keys {
- if let Some(v) = obj.get(*key) {
- filtered.insert(key, v);
- }
- }
-
- // Also include config.platform if present
- if let Some(config) = obj.get("config")
- && let Some(platform) = config.get("platform")
- {
- filtered.insert("config.platform", platform);
- }
-
- // Encode to compact JSON
- let compact = serde_json::to_string(&filtered)?;
-
- // Compute MD5
- let digest = md5::compute(compact.as_bytes());
- Ok(format!("{:x}", digest))
- }
-
- /// Check that every root `require` (and `require-dev` when `include_dev`)
- /// is satisfied by the locked packages. Returns the list of bullet-prefixed
- /// error lines (plus the trailing merge-conflict hint) if anything is
- /// missing or mismatched, otherwise an empty vec.
- ///
- /// Mirrors `Composer\Package\Locker::getMissingRequirementInfo()`.
- pub fn get_missing_requirement_info(
- &self,
- root: &mozart_core::package::RawPackageData,
- include_dev: bool,
- ) -> Vec<String> {
- let mut messages = Vec::new();
- let mut any_missing = false;
-
- let base_pool: Vec<LockedSearchEntry> = self
- .packages
- .iter()
- .map(|p| LockedSearchEntry::build(p, &self.aliases))
- .collect();
- let mut dev_pool: Vec<LockedSearchEntry> = base_pool.clone();
- if let Some(dev) = &self.packages_dev {
- dev_pool.extend(
- dev.iter()
- .map(|p| LockedSearchEntry::build(p, &self.aliases)),
- );
- }
-
- check_requirement_set(
- &root.require,
- "Required",
- &base_pool,
- &mut messages,
- &mut any_missing,
- );
- if include_dev {
- check_requirement_set(
- &root.require_dev,
- "Required (in require-dev)",
- &dev_pool,
- &mut messages,
- &mut any_missing,
- );
- }
-
- if any_missing {
- messages.push(
- "This usually happens when composer files are incorrectly merged or the composer.json file is manually edited.".to_string(),
- );
- messages.push(
- "Read more about correctly resolving merge conflicts https://getcomposer.org/doc/articles/resolving-merge-conflicts.md".to_string(),
- );
- messages.push(
- "and prefer using the \"require\" command over editing the composer.json file directly https://getcomposer.org/doc/03-cli.md#require-r".to_string(),
- );
- }
-
- messages
- }
-}
-
-/// A locked package paired with the additional version strings the locked
-/// repository would surface for it (branch-alias targets + matching root
-/// aliases from `lock.aliases`).
-///
-/// Mirrors the AliasPackage entries that `Composer\Package\Locker::getLockedRepository`
-/// adds alongside each locked package, so requirement checks see the same
-/// version surface Composer does.
-#[derive(Clone)]
-struct LockedSearchEntry<'a> {
- package: &'a LockedPackage,
- alias_versions: Vec<String>,
-}
-
-impl<'a> LockedSearchEntry<'a> {
- fn build(package: &'a LockedPackage, root_aliases: &[LockAlias]) -> Self {
- let mut alias_versions: Vec<String> = locked_package_branch_aliases(package)
- .into_iter()
- .map(|a| a.alias_normalized)
- .collect();
- for alias in root_aliases {
- if alias.package.eq_ignore_ascii_case(&package.name)
- && alias.version.eq_ignore_ascii_case(&package.version)
- {
- alias_versions.push(alias.alias_normalized.clone());
- }
- }
- Self {
- package,
- alias_versions,
- }
- }
-}
-
-/// Build the synthetic `LockAlias` entries a `dev-*` locked package contributes
-/// via `extra.branch-alias`. Mirrors `Composer\Package\Loader\ArrayLoader::getBranchAlias`
-/// followed by `VersionParser::normalizeBranch` — the same expansion
-/// `Locker::getLockedRepository` performs when constructing AliasPackages
-/// alongside each locked package.
-pub fn locked_package_branch_aliases(pkg: &LockedPackage) -> Vec<LockAlias> {
- let pkg_version_lower = pkg.version.to_lowercase();
- let is_dev_branch =
- pkg_version_lower.starts_with("dev-") || pkg_version_lower.ends_with("-dev");
- if !is_dev_branch {
- return Vec::new();
- }
- let Some(extra) = pkg.extra_fields.get("extra") else {
- return Vec::new();
- };
- let Some(branch_alias) = extra.get("branch-alias") else {
- return Vec::new();
- };
- let Some(map) = branch_alias.as_object() else {
- return Vec::new();
- };
- let mut out = Vec::new();
- for (source, target) in map.iter() {
- if !source.eq_ignore_ascii_case(&pkg.version) {
- continue;
- }
- let Some(target_str) = target.as_str() else {
- continue;
- };
- if !target_str.to_lowercase().ends_with("-dev") {
- continue;
- }
- let Some(normalized) = crate::resolver::normalize_branch_alias_target(target_str) else {
- continue;
- };
- // Pretty-form trim: Composer's `Preg::replace('{(\.9{7})+}', '.x', ...)`
- // turns the normalized form back into the wildcard form (e.g.
- // `2.1.9999999.9999999-dev` → `2.1.x-dev`). For trace output we want
- // the raw alias target string the package author wrote.
- out.push(LockAlias {
- package: pkg.name.clone(),
- version: pkg.version.clone(),
- alias: target_str.to_string(),
- alias_normalized: normalized,
- });
- }
- out
-}
-
-fn check_requirement_set(
- requires: &BTreeMap<String, String>,
- description: &str,
- pool: &[LockedSearchEntry],
- messages: &mut Vec<String>,
- any_missing: &mut bool,
-) {
- for (name, constraint_str) in requires {
- if mozart_core::platform::is_platform_package(name) {
- continue;
- }
- if constraint_str.trim() == "self.version" {
- continue;
- }
-
- let constraint = mozart_semver::VersionConstraint::parse(constraint_str).ok();
-
- let mut name_only_match: Option<&LockedPackage> = None;
- let mut satisfied = false;
- for entry in pool {
- let pkg = entry.package;
- if pkg.name != *name {
- continue;
- }
- if name_only_match.is_none() {
- name_only_match = Some(pkg);
- }
- let Some(ref c) = constraint else { continue };
- if let Ok(version) = mozart_semver::Version::parse(&pkg.version)
- && c.matches(&version)
- {
- satisfied = true;
- break;
- }
- if entry.alias_versions.iter().any(|alias| {
- mozart_semver::Version::parse(alias)
- .ok()
- .is_some_and(|v| c.matches(&v))
- }) {
- satisfied = true;
- break;
- }
- }
-
- if satisfied {
- continue;
- }
-
- *any_missing = true;
- if let Some(pkg) = name_only_match {
- messages.push(format!(
- "- {description} package \"{name}\" is in the lock file as \"{}\" but that does not satisfy your constraint \"{constraint_str}\".",
- pkg.version
- ));
- } else {
- messages.push(format!(
- "- {description} package \"{name}\" is not present in the lock file."
- ));
- }
- }
-}
-
-/// Input for lock file generation.
-pub struct LockFileGenerationRequest {
- /// Resolved packages from the dependency resolver.
- pub resolved_packages: Vec<ResolvedPackage>,
- /// Raw composer.json content string (for content-hash computation).
- pub composer_json_content: String,
- /// Parsed composer.json data (for platform, minimum-stability, etc.).
- pub composer_json: RawPackageData,
- /// Whether require-dev was included in resolution.
- pub include_dev: bool,
- /// Repository set used to fetch full metadata for resolved packages
- /// that aren't already covered by inline `type: package` repositories.
- pub repositories: std::sync::Arc<RepositorySet>,
- /// Previous `composer.lock` (when running update / require / remove).
- /// For each resolved package whose name+normalized-version matches an
- /// entry in this lock, the entry is copied into the new lock verbatim
- /// rather than being re-fetched from the inline / composer-repo /
- /// Packagist sources. Mirrors Composer's `Locker::setLockData` behaviour
- /// during partial updates: lock entries are stable across updates that
- /// don't touch the package, even if the upstream metadata has drifted.
- pub previous_lock: Option<LockFile>,
- /// Lowercase package names that were held back to their locked version
- /// on a partial update — i.e. they were NOT in the CLI's allow list and
- /// were re-pinned by `apply_partial_update`. For these names the lock
- /// entry's metadata (source/dist references in particular) is canonical:
- /// inline / composer-repo metadata may have drifted to a newer commit
- /// that the partial update is explicitly choosing not to take. Mirrors
- /// Composer's `PoolBuilder`, which keeps non-allow-listed packages at
- /// the locked-repo entry rather than re-loading them from the inline /
- /// VCS sources.
- pub lock_pinned_names: indexmap::IndexSet<String>,
-}
-
-impl LockFileGenerationRequest {
- /// Look up an inline `type: package` definition for `name` (if any).
- /// Returns the matching `PackagistVersion` so callers can short-circuit
- /// the Packagist fetch for resolved packages that came from a `type:
- /// package` repository.
- fn inline_lookup(&self, name: &str, version_normalized: &str) -> Option<PackagistVersion> {
- crate::inline_package::collect_inline_packages(&self.composer_json.repositories)
- .into_iter()
- .find(|ipkg| ipkg.name == name && ipkg.version.version_normalized == version_normalized)
- .map(|ipkg| ipkg.version)
- }
-
- /// Look up a `type: composer` repository entry for `name@version_normalized`.
- /// Used to short-circuit the Packagist fetch when the resolved package came
- /// from a local Composer repo (the test fixtures' file:// case).
- fn composer_repo_lookup(
- &self,
- name: &str,
- version_normalized: &str,
- ) -> Option<PackagistVersion> {
- crate::composer_repo::collect_composer_packages(&self.composer_json.repositories)
- .into_iter()
- .find(|cpkg| cpkg.name == name && cpkg.version.version_normalized == version_normalized)
- .map(|cpkg| cpkg.version)
- }
-
- /// Reuse `previous_lock` as a metadata source when no repository can
- /// answer for `(name, version_normalized)`. Mirrors the slice of
- /// Composer's `PoolBuilder` flow that re-loads locked-only packages
- /// straight off the lock: a partial update keeping a package at its
- /// locked version doesn't need to re-fetch its metadata, and the
- /// repositories may no longer carry that version (e.g. an inline
- /// `type: package` repo only listing the new release).
- fn previous_lock_lookup(
- &self,
- name: &str,
- version_normalized: &str,
- ) -> Option<PackagistVersion> {
- let prev = self.previous_lock.as_ref()?;
- prev.packages
- .iter()
- .chain(prev.packages_dev.iter().flatten())
- .find(|p| {
- p.name.eq_ignore_ascii_case(name)
- && p.version_normalized
- .as_deref()
- .map(|v| v == version_normalized)
- .unwrap_or_else(|| {
- mozart_semver::Version::parse(&p.version)
- .map(|v| v.to_string() == version_normalized)
- .unwrap_or(false)
- })
- })
- .map(locked_package_to_packagist_version)
- }
-}
-
-/// Synthesize a `PackagistVersion` from a `LockedPackage`. Used by
-/// `previous_lock_lookup` so the metadata loop has a complete view even
-/// when the surrounding repositories have moved on from a locked version.
-fn locked_package_to_packagist_version(pkg: &LockedPackage) -> PackagistVersion {
- PackagistVersion {
- version: pkg.version.clone(),
- version_normalized: pkg
- .version_normalized
- .clone()
- .unwrap_or_else(|| pkg.version.clone()),
- require: pkg.require.clone(),
- replace: pkg.replace.clone(),
- provide: pkg.provide.clone(),
- conflict: pkg.conflict.clone(),
- dist: pkg.dist.as_ref().map(|d| PackagistDist {
- dist_type: d.dist_type.clone(),
- url: d.url.clone(),
- reference: d.reference.clone(),
- shasum: d.shasum.clone(),
- }),
- source: pkg.source.as_ref().map(|s| PackagistSource {
- source_type: s.source_type.clone(),
- url: s.url.clone(),
- reference: s.reference.clone(),
- }),
- require_dev: pkg.require_dev.clone(),
- suggest: pkg.suggest.clone(),
- package_type: pkg.package_type.clone(),
- autoload: pkg.autoload.clone(),
- autoload_dev: pkg.autoload_dev.clone(),
- license: pkg.license.clone(),
- description: pkg.description.clone(),
- homepage: pkg.homepage.clone(),
- keywords: pkg.keywords.clone(),
- authors: pkg.authors.clone(),
- support: None,
- funding: None,
- time: pkg.time.clone(),
- extra: pkg.extra_fields.get("extra").cloned(),
- notification_url: pkg
- .extra_fields
- .get("notification-url")
- .and_then(|v| v.as_str())
- .map(String::from),
- default_branch: pkg
- .extra_fields
- .get("default-branch")
- .and_then(|v| v.as_bool())
- .unwrap_or(false),
- abandoned: pkg.extra_fields.get("abandoned").cloned(),
- }
-}
-
-/// Convert a `PackagistSource` to a `LockedSource`.
-fn packagist_source_to_locked(ps: &PackagistSource) -> LockedSource {
- LockedSource {
- source_type: ps.source_type.clone(),
- url: ps.url.clone(),
- reference: ps.reference.clone(),
- }
-}
-
-/// Convert a `PackagistDist` to a `LockedDist`.
-fn packagist_dist_to_locked(pd: &PackagistDist) -> LockedDist {
- LockedDist {
- dist_type: pd.dist_type.clone(),
- url: pd.url.clone(),
- reference: pd.reference.clone(),
- shasum: pd.shasum.clone(),
- }
-}
-
-/// Mirror Composer's `RootPackageLoader::extractReferences`: scan
-/// `require`/`require-dev` for `dev-foo#hex` style constraints, returning a
-/// lowercase package name → reference map. Constraints whose stability isn't
-/// `dev` after stripping the reference are left out (matching the
-/// `'dev' === VersionParser::parseStability(...)` guard in PHP).
-fn extract_root_references(
- require: &BTreeMap<String, String>,
- require_dev: &BTreeMap<String, String>,
-) -> BTreeMap<String, String> {
- let mut out = BTreeMap::new();
- for (name, raw_constraint) in require.iter().chain(require_dev.iter()) {
- if let Some(reference) = parse_inline_reference(raw_constraint) {
- out.insert(name.to_lowercase(), reference);
- }
- }
- out
-}
-
-/// Pull the `#hex` suffix out of a single-atom dev constraint. Returns
-/// `None` for non-`dev-*` / non-`*-dev` constraints, matching Composer's
-/// `'{^[^,\s@]+?#([a-f0-9]+)$}'` + `parseStability == 'dev'` guard.
-fn parse_inline_reference(constraint: &str) -> Option<String> {
- // Strip `... as alias` first, mirroring extractReferences's
- // `'{^([^,\s@]+) as .+$}'` replacement.
- let core = match constraint.split(" as ").next() {
- Some(c) => c.trim(),
- None => constraint.trim(),
- };
- let (head, hash) = core.rsplit_once('#')?;
- if hash.is_empty() || !hash.chars().all(|c| c.is_ascii_hexdigit()) {
- return None;
- }
- if head.contains([' ', '\t', ',', '@']) {
- return None;
- }
- let lower = head.to_lowercase();
- if !(lower.starts_with("dev-") || lower.ends_with("-dev")) {
- return None;
- }
- Some(hash.to_string())
-}
-
-/// Mirror `Composer\Package\Package::setSourceDistReferences`: rewrite both
-/// source and dist references to the supplied value, and rewrite the
-/// reference inside any auto-generated GitHub/GitLab/Bitbucket dist URL when
-/// present. The dist reference is only written if there was already one
-/// (Composer leaves `dist.reference == null` packages alone).
-fn apply_reference_override(pkg: &mut LockedPackage, reference: &str) {
- if let Some(source) = pkg.source.as_mut() {
- source.reference = Some(reference.to_string());
- }
- if let Some(dist) = pkg.dist.as_mut() {
- let url_carries_known_host = matches_dist_url_with_known_host(Some(&dist.url));
- if dist.reference.is_some() || url_carries_known_host {
- dist.reference = Some(reference.to_string());
- }
- if url_carries_known_host {
- dist.url = rewrite_known_dist_url_reference(&dist.url, reference);
- }
- }
-}
-
-/// Match the bitbucket / github / gitlab dist-URL prefixes Composer
-/// rewrites. Mirrors the regex
-/// `{^https?://(?:(?:www\.)?bitbucket\.org|(api\.)?github\.com|(?:www\.)?gitlab\.com)/}i`.
-fn matches_dist_url_with_known_host(url: Option<&str>) -> bool {
- let Some(url) = url else { return false };
- let lower = url.to_lowercase();
- let stripped = lower
- .strip_prefix("http://")
- .or_else(|| lower.strip_prefix("https://"))
- .unwrap_or(&lower);
- let stripped = stripped.strip_prefix("www.").unwrap_or(stripped);
- let stripped = stripped.strip_prefix("api.").unwrap_or(stripped);
- stripped.starts_with("bitbucket.org/")
- || stripped.starts_with("github.com/")
- || stripped.starts_with("gitlab.com/")
-}
-
-/// Substitute any 40-char hex segment surrounded by `/` or `sha=` (the
-/// archive shape produced by GitHub/GitLab/Bitbucket) with the new
-/// reference. Matches Composer's
-/// `'{(?<=/|sha=)[a-f0-9]{40}(?=/|$)}i'` rewrite.
-fn rewrite_known_dist_url_reference(url: &str, reference: &str) -> String {
- let bytes = url.as_bytes();
- let mut out = String::with_capacity(url.len());
- let mut i = 0;
- while i < bytes.len() {
- let start = i;
- let preceded_by_slash = i > 0 && bytes[i - 1] == b'/';
- let preceded_by_sha = i >= 4 && &bytes[i - 4..i] == b"sha=";
- if (preceded_by_slash || preceded_by_sha) && i + 40 <= bytes.len() {
- let candidate = &url[i..i + 40];
- if candidate.chars().all(|c| c.is_ascii_hexdigit()) {
- let after = bytes.get(i + 40).copied();
- if after == Some(b'/') || after.is_none() {
- out.push_str(reference);
- i += 40;
- continue;
- }
- }
- }
- out.push(url[start..].chars().next().unwrap());
- i += url[start..].chars().next().unwrap().len_utf8();
- }
- out
-}
-
-/// Convert a `PackagistVersion` to a `LockedPackage`.
-fn packagist_version_to_locked_package(name: &str, pv: &PackagistVersion) -> LockedPackage {
- let mut extra_fields: BTreeMap<String, serde_json::Value> = BTreeMap::new();
-
- if let Some(extra) = &pv.extra {
- extra_fields.insert("extra".to_string(), extra.clone());
- }
- if let Some(notification_url) = &pv.notification_url {
- extra_fields.insert(
- "notification-url".to_string(),
- serde_json::Value::String(notification_url.clone()),
- );
- }
- // Propagate `abandoned` so the lock (and downstream installed.json
- // round-trip) preserves the package's deprecation state. Mirrors
- // Composer's `ArrayDumper::dump`, which emits the field when truthy
- // (`true` for "abandoned, no replacement", a string for "abandoned,
- // use this instead"). `false`/null collapse to "not abandoned" and
- // are dropped.
- if let Some(abandoned) = &pv.abandoned {
- let keep = match abandoned {
- serde_json::Value::Bool(b) => *b,
- serde_json::Value::String(s) => !s.is_empty(),
- serde_json::Value::Null => false,
- _ => true,
- };
- if keep {
- extra_fields.insert("abandoned".to_string(), abandoned.clone());
- }
- }
- // Propagate `default-branch: true` so the lock surface — and the
- // installed.json round-trip — keeps the marker that drives Composer's
- // synthetic `9999999-dev` alias for default-branch dev packages.
- // Without this, `Locker::getLockedRepository` (which Mozart mirrors via
- // `collect_stale_installed_aliases` / `lock_alias_pretty_pairs`) can't
- // tell that the package's default branch is still aliased and emits a
- // spurious `MarkAliasUninstalled` for the missing `9999999-dev` alias.
- if pv.default_branch {
- extra_fields.insert("default-branch".to_string(), serde_json::Value::Bool(true));
- }
-
- LockedPackage {
- name: name.to_string(),
- version: pv.version.clone(),
- version_normalized: Some(pv.version_normalized.clone()),
- source: pv.source.as_ref().map(packagist_source_to_locked),
- dist: pv.dist.as_ref().map(packagist_dist_to_locked),
- require: pv.require.clone(),
- require_dev: pv.require_dev.clone(),
- conflict: pv.conflict.clone(),
- provide: pv.provide.clone(),
- replace: pv.replace.clone(),
- suggest: pv.suggest.clone(),
- package_type: pv.package_type.clone(),
- autoload: pv.autoload.clone(),
- autoload_dev: pv.autoload_dev.clone(),
- license: pv.license.clone(),
- description: pv.description.clone(),
- homepage: pv.homepage.clone(),
- keywords: pv.keywords.clone(),
- authors: pv.authors.clone(),
- support: pv.support.clone(),
- funding: pv.funding.clone(),
- time: pv.time.clone(),
- extra_fields,
- }
-}
-
-/// Determine which resolved packages are dev-only.
-///
-/// A package is dev-only if it is NOT reachable from the non-dev dependency tree
-/// (i.e., only reachable through require-dev paths).
-///
-/// `requires_by_name` and `providers_by_name` are keyed by lowercase package
-/// names. `providers_by_name` maps a satisfied name (own name + each `provide`
-/// or `replace` target) to the list of resolved package names that satisfy it,
-/// so a non-dev `require` like `provided/pkg` reaches `b/b` when `b/b`
-/// declares `provide: { provided/pkg: 1.0.0 }`.
-fn classify_dev_packages(
- resolved: &[ResolvedPackage],
- require: &BTreeMap<String, String>,
- _require_dev: &BTreeMap<String, String>,
- requires_by_name: &IndexMap<String, Vec<String>>,
- providers_by_name: &IndexMap<String, Vec<String>>,
-) -> IndexSet<String> {
- // BFS from non-dev root dependencies through each package's `require` map.
- // All reachable packages are production packages.
- let mut production: IndexSet<String> = IndexSet::new();
- let mut queue: VecDeque<String> = VecDeque::new();
-
- let visit = |name: &str, production: &mut IndexSet<String>, queue: &mut VecDeque<String>| {
- let name_lower = name.to_lowercase();
- if is_platform_name(&name_lower) {
- return;
- }
- // A required name is satisfied either by a resolved package whose own
- // name matches (the common case, captured here as `providers_by_name`
- // also indexes own names) or by a resolved package that provides /
- // replaces it. Mirrors Composer's `extractDevPackages` second-solve
- // semantics, which walks the same provide/replace edges through a
- // real Solver call.
- if let Some(provs) = providers_by_name.get(&name_lower) {
- for prov in provs {
- let prov_lower = prov.to_lowercase();
- if production.insert(prov_lower.clone()) {
- queue.push_back(prov_lower);
- }
- }
- }
- };
-
- for name in require.keys() {
- visit(name, &mut production, &mut queue);
- }
-
- while let Some(pkg_name) = queue.pop_front() {
- if let Some(deps) = requires_by_name.get(&pkg_name) {
- for dep_name in deps.clone() {
- visit(&dep_name, &mut production, &mut queue);
- }
- }
- }
-
- // Any resolved package not in `production` is dev-only
- resolved
- .iter()
- .filter(|p| !production.contains(&p.name.to_lowercase()))
- .map(|p| p.name.clone())
- .collect()
-}
-
-/// Returns true if the package name is a platform package (php, ext-*, lib-*, etc.).
-fn is_platform_name(name: &str) -> bool {
- name == "php"
- || name.starts_with("ext-")
- || name.starts_with("lib-")
- || name == "php-64bit"
- || name == "php-ipv6"
- || name == "php-zts"
- || name == "php-debug"
-}
-
-/// Extract platform requirements from a requirements map.
-///
-/// Filters the map to include only platform package keys (`php`, `ext-*`, `lib-*`, etc.)
-/// and returns them as a JSON object.
-fn extract_platform_requirements(requirements: &BTreeMap<String, String>) -> serde_json::Value {
- let map: serde_json::Map<String, serde_json::Value> = requirements
- .iter()
- .filter(|(k, _)| is_platform_name(k))
- .map(|(k, v)| (k.clone(), serde_json::Value::String(v.clone())))
- .collect();
- serde_json::Value::Object(map)
-}
-
-/// Generate a complete `LockFile` from resolution results.
-///
-/// This function:
-/// 1. Fetches full metadata from Packagist for each resolved package
-/// 2. Separates packages into production vs dev-only
-/// 3. Computes the content-hash
-/// 4. Assembles the complete `LockFile` struct
-pub async fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow::Result<LockFile> {
- // Split the resolved set into real packages and alias entries up front.
- // Aliases get emitted as a separate `aliases[]` block and never enter the
- // metadata fetch loop — their target package carries the real metadata.
- let (real_resolved, alias_resolved): (Vec<&ResolvedPackage>, Vec<&ResolvedPackage>) = request
- .resolved_packages
- .iter()
- .partition(|p| p.alias_of_normalized.is_none());
-
- // 1. Fetch full metadata for real (non-alias) packages.
- //
- // Inline `type: package` repositories carry full metadata in composer.json
- // — short-circuit those before hitting the network. Everything else goes
- // through `RepositorySet`, which today contains only Packagist; future
- // steps will move VCS / inline through the same set.
- // Previous-lock relationship pass-through: when a resolved package
- // matches an entry in `previous_lock` at the same name +
- // version_normalized, capture the entry's relationship-shaped fields
- // (require / require-dev / conflict / replace / provide / suggest).
- // Composer's transaction calculates operation order using these
- // relationship fields off the locked repository, so a partial update
- // shouldn't refresh them from upstream metadata for packages that
- // didn't move — otherwise topological_sort sees a different graph
- // than Composer would.
- //
- // Source/dist references and version-shaped fields still come from
- // the freshly-fetched metadata, so dev packages whose ref bumped (the
- // resolver picked a new commit at the same version label) still get
- // their ref refreshed.
- struct PreservedRelationships {
- require: BTreeMap<String, String>,
- require_dev: BTreeMap<String, String>,
- conflict: BTreeMap<String, String>,
- provide: BTreeMap<String, String>,
- replace: BTreeMap<String, String>,
- suggest: Option<BTreeMap<String, String>>,
- }
- let mut preserved_rel: IndexMap<String, PreservedRelationships> = IndexMap::new();
- if let Some(prev) = &request.previous_lock {
- for prev_pkg in prev
- .packages
- .iter()
- .chain(prev.packages_dev.iter().flatten())
- {
- let prev_normalized = prev_pkg.version_normalized.clone().unwrap_or_else(|| {
- mozart_semver::Version::parse(&prev_pkg.version)
- .map(|v| v.to_string())
- .unwrap_or_else(|_| prev_pkg.version.clone())
- });
- for pkg in &real_resolved {
- if pkg.name.eq_ignore_ascii_case(&prev_pkg.name)
- && pkg.version_normalized == prev_normalized
- {
- preserved_rel.insert(
- pkg.name.clone(),
- PreservedRelationships {
- require: prev_pkg.require.clone(),
- require_dev: prev_pkg.require_dev.clone(),
- conflict: prev_pkg.conflict.clone(),
- provide: prev_pkg.provide.clone(),
- replace: prev_pkg.replace.clone(),
- suggest: prev_pkg.suggest.clone(),
- },
- );
- }
- }
- }
- }
-
- let mut package_metadata: IndexMap<String, PackagistVersion> = IndexMap::new();
- let repo_set = &request.repositories;
- for pkg in &real_resolved {
- // For packages held back to the locked version on a partial update,
- // the lock entry is the canonical metadata source. Inline / composer-
- // repo / VCS sources may have moved to a newer commit that this
- // partial update is explicitly choosing NOT to take, so consulting
- // them first would silently bump the source/dist reference. Mirrors
- // Composer's `PoolBuilder` behaviour: non-allow-listed packages keep
- // the locked-repo entry rather than re-loading from upstream.
- let pinned = request.lock_pinned_names.contains(&pkg.name.to_lowercase());
- if pinned
- && let Some(prev) = request.previous_lock_lookup(&pkg.name, &pkg.version_normalized)
- {
- package_metadata.insert(pkg.name.clone(), prev);
- continue;
- }
-
- if let Some(inline) = request.inline_lookup(&pkg.name, &pkg.version_normalized) {
- package_metadata.insert(pkg.name.clone(), inline);
- continue;
- }
-
- if let Some(cv) = request.composer_repo_lookup(&pkg.name, &pkg.version_normalized) {
- package_metadata.insert(pkg.name.clone(), cv);
- continue;
- }
-
- if let Some(prev) = request.previous_lock_lookup(&pkg.name, &pkg.version_normalized) {
- package_metadata.insert(pkg.name.clone(), prev);
- continue;
- }
-
- let queries = [crate::repository::PackageQuery {
- name: pkg.name.as_str(),
- constraint: None,
- }];
- let results = repo_set.load_packages(&queries).await?;
- let matching = results
- .into_iter()
- .find(|r| r.version.version_normalized == pkg.version_normalized)
- .ok_or_else(|| {
- anyhow::anyhow!(
- "Could not find version {} for package {} in Packagist response",
- pkg.version_normalized,
- pkg.name
- )
- })?;
- package_metadata.insert(pkg.name.clone(), matching.version);
- }
-
- // 2. Classify dev vs non-dev packages (real packages only).
- let real_owned: Vec<ResolvedPackage> = real_resolved
- .iter()
- .map(|p| ResolvedPackage {
- name: p.name.clone(),
- version: p.version.clone(),
- version_normalized: p.version_normalized.clone(),
- is_dev: p.is_dev,
- alias_of_normalized: None,
- })
- .collect();
- // Build the `name → require keys` view classify_dev_packages walks. Use
- // preserved-from-old-lock requires when available so a partial update
- // sees the same dev-classification graph the previous lock did.
- let mut requires_by_name: IndexMap<String, Vec<String>> = IndexMap::new();
- // Inverse map: `satisfied name → list of resolved packages that satisfy it`.
- // A resolved package satisfies its own name plus each `provide` / `replace`
- // target (Composer's `extractDevPackages` reaches the same edges through
- // its second Solver run; we walk them directly during the dev BFS).
- let mut providers_by_name: IndexMap<String, Vec<String>> = IndexMap::new();
- for (name, pv) in &package_metadata {
- let name_lower = name.to_lowercase();
- let (require_keys, provide_keys, replace_keys): (Vec<String>, Vec<String>, Vec<String>) =
- if let Some(rel) = preserved_rel.get(name) {
- (
- rel.require.keys().cloned().collect(),
- rel.provide.keys().cloned().collect(),
- rel.replace.keys().cloned().collect(),
- )
- } else {
- (
- pv.require.keys().cloned().collect(),
- pv.provide.keys().cloned().collect(),
- pv.replace.keys().cloned().collect(),
- )
- };
- requires_by_name.insert(name_lower.clone(), require_keys);
- providers_by_name
- .entry(name_lower.clone())
- .or_default()
- .push(name_lower.clone());
- for target in provide_keys.iter().chain(replace_keys.iter()) {
- providers_by_name
- .entry(target.to_lowercase())
- .or_default()
- .push(name_lower.clone());
- }
- }
- let dev_only = classify_dev_packages(
- &real_owned,
- &request.composer_json.require,
- &request.composer_json.require_dev,
- &requires_by_name,
- &providers_by_name,
- );
-
- // 3. Build LockedPackage lists.
- //
- // Apply root-level `#hex` reference overrides extracted from
- // `require`/`require-dev`. Mirrors Composer's
- // `RootPackageLoader::extractReferences` + `PoolBuilder::loadPackage`'s
- // `setSourceDistReferences` call: when the user pinned a dev package via
- // `dev-main#abcd123`, the resolved package's source/dist must show that
- // reference in the lock + trace, not whatever the inline metadata said.
- let root_references = extract_root_references(
- &request.composer_json.require,
- &request.composer_json.require_dev,
- );
- let mut packages: Vec<LockedPackage> = Vec::new();
- let mut packages_dev: Vec<LockedPackage> = Vec::new();
- for pkg in &real_resolved {
- let pv = &package_metadata[&pkg.name];
- let mut locked = packagist_version_to_locked_package(&pkg.name, pv);
- // Overlay relationship fields from the previous lock when applicable
- // — the resolver's transaction-time view came from the lock, so the
- // new lock should mirror those relationships even if the upstream
- // metadata has drifted.
- if let Some(rel) = preserved_rel.get(&pkg.name) {
- locked.require = rel.require.clone();
- locked.require_dev = rel.require_dev.clone();
- locked.conflict = rel.conflict.clone();
- locked.provide = rel.provide.clone();
- locked.replace = rel.replace.clone();
- locked.suggest = rel.suggest.clone();
- }
- if let Some(reference) = root_references.get(&pkg.name.to_lowercase()) {
- apply_reference_override(&mut locked, reference);
- }
- if dev_only.contains(&pkg.name) {
- packages_dev.push(locked);
- } else {
- packages.push(locked);
- }
- }
-
- // 4. Sort each list alphabetically by name (Composer does this)
- packages.sort_by(|a, b| a.name.cmp(&b.name));
- packages_dev.sort_by(|a, b| a.name.cmp(&b.name));
-
- // 5. Build the aliases[] block. Each alias entry references the target
- // package (`package` + `version`) and carries the alias's pretty/normalized
- // form (`alias` + `alias_normalized`). Mirrors Composer's
- // `Locker::lockPackages` alias dump.
- let mut alias_blocks: Vec<LockAlias> = Vec::new();
- for alias in &alias_resolved {
- let target_normalized = match &alias.alias_of_normalized {
- Some(t) => t.clone(),
- None => continue,
- };
- let target_pretty = real_resolved
- .iter()
- .find(|p| p.name == alias.name && p.version_normalized == target_normalized)
- .map(|p| p.version.clone())
- .unwrap_or_else(|| target_normalized.clone());
- alias_blocks.push(LockAlias {
- package: alias.name.clone(),
- version: target_pretty,
- alias: alias.version.clone(),
- alias_normalized: alias.version_normalized.clone(),
- });
- }
- alias_blocks.sort_by(|a, b| a.package.cmp(&b.package).then(a.alias.cmp(&b.alias)));
-
- // 6. Compute content-hash
- let content_hash = LockFile::compute_content_hash(&request.composer_json_content)?;
-
- // 7. Extract platform requirements
- let platform = extract_platform_requirements(&request.composer_json.require);
- let platform_dev = extract_platform_requirements(&request.composer_json.require_dev);
-
- // 8. Determine minimum-stability and prefer-stable
- let minimum_stability = request
- .composer_json
- .minimum_stability
- .clone()
- .unwrap_or_else(|| "stable".to_string());
-
- let prefer_stable = request
- .composer_json
- .extra_fields
- .get("prefer-stable")
- .and_then(|v| v.as_bool())
- .unwrap_or(false);
-
- // 9. Assemble LockFile
- Ok(LockFile {
- readme: LockFile::default_readme(),
- content_hash,
- packages,
- packages_dev: if request.include_dev {
- Some(packages_dev)
- } else {
- Some(vec![])
- },
- aliases: alias_blocks,
- minimum_stability,
- stability_flags: serde_json::json!({}),
- prefer_stable,
- prefer_lowest: false,
- platform,
- platform_dev,
- plugin_api_version: Some("2.6.0".to_string()),
- })
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use tempfile::tempdir;
-
- fn minimal_lock() -> LockFile {
- LockFile {
- readme: LockFile::default_readme(),
- content_hash: "abc123".to_string(),
- packages: vec![],
- packages_dev: Some(vec![]),
- aliases: vec![],
- minimum_stability: "stable".to_string(),
- stability_flags: serde_json::json!({}),
- prefer_stable: false,
- prefer_lowest: false,
- platform: serde_json::json!({}),
- platform_dev: serde_json::json!({}),
- plugin_api_version: Some("2.6.0".to_string()),
- }
- }
-
- #[test]
- fn test_roundtrip_minimal() {
- let dir = tempdir().unwrap();
- let path = dir.path().join("composer.lock");
-
- let lock = minimal_lock();
- lock.write_to_file(&path).unwrap();
-
- let loaded = LockFile::read_from_file(&path).unwrap();
- assert_eq!(loaded.content_hash, "abc123");
- assert_eq!(loaded.minimum_stability, "stable");
- assert!(!loaded.prefer_stable);
- assert_eq!(loaded.packages.len(), 0);
- }
-
- #[test]
- fn test_roundtrip_with_package() {
- let dir = tempdir().unwrap();
- let path = dir.path().join("composer.lock");
-
- let mut lock = minimal_lock();
- lock.packages.push(LockedPackage {
- name: "monolog/monolog".to_string(),
- version: "3.8.0".to_string(),
- version_normalized: None,
- source: None,
- dist: Some(LockedDist {
- dist_type: "zip".to_string(),
- url: "https://example.com/monolog.zip".to_string(),
- reference: Some("abc123".to_string()),
- shasum: Some("".to_string()),
- }),
- require: BTreeMap::new(),
- require_dev: BTreeMap::new(),
- conflict: BTreeMap::new(),
- provide: BTreeMap::new(),
- replace: BTreeMap::new(),
- suggest: None,
- package_type: Some("library".to_string()),
- autoload: None,
- autoload_dev: None,
- license: Some(vec!["MIT".to_string()]),
- description: Some("A logging library".to_string()),
- homepage: None,
- keywords: None,
- authors: None,
- support: None,
- funding: None,
- time: None,
- extra_fields: BTreeMap::new(),
- });
-
- lock.write_to_file(&path).unwrap();
- let loaded = LockFile::read_from_file(&path).unwrap();
-
- assert_eq!(loaded.packages.len(), 1);
- assert_eq!(loaded.packages[0].name, "monolog/monolog");
- assert_eq!(loaded.packages[0].version, "3.8.0");
- assert_eq!(
- loaded.packages[0].description.as_deref(),
- Some("A logging library")
- );
- }
-
- #[test]
- fn test_content_hash_deterministic() {
- let composer_json = r#"{"name": "test/project", "require": {"monolog/monolog": "^3.0"}}"#;
- let h1 = LockFile::compute_content_hash(composer_json).unwrap();
- let h2 = LockFile::compute_content_hash(composer_json).unwrap();
- assert_eq!(h1, h2);
- assert!(!h1.is_empty());
- }
-
- #[test]
- fn test_content_hash_changes_on_require_change() {
- let composer1 = r#"{"name": "test/project", "require": {"monolog/monolog": "^3.0"}}"#;
- let composer2 = r#"{"name": "test/project", "require": {"monolog/monolog": "^2.0"}}"#;
- let h1 = LockFile::compute_content_hash(composer1).unwrap();
- let h2 = LockFile::compute_content_hash(composer2).unwrap();
- assert_ne!(h1, h2);
- }
-
- #[test]
- fn test_is_fresh() {
- let composer_json = r#"{"name": "test/project", "require": {"php": ">=8.1"}}"#;
- let hash = LockFile::compute_content_hash(composer_json).unwrap();
-
- let mut lock = minimal_lock();
- lock.content_hash = hash;
-
- assert!(lock.is_fresh(composer_json));
- assert!(!lock.is_fresh(r#"{"name": "test/project", "require": {"php": ">=8.0"}}"#));
- }
-
- #[test]
- fn test_default_readme() {
- let readme = LockFile::default_readme();
- assert_eq!(readme.len(), 3);
- assert!(readme[0].contains("locks the dependencies"));
- }
-
- #[test]
- fn parses_lock_without_content_hash() {
- // Composer fixtures (and historical lock files) may omit content-hash;
- // mirror Composer's BC handling by accepting it and treating the lock
- // as not-fresh against any composer.json.
- let raw = r#"{
- "packages": [],
- "packages-dev": [],
- "aliases": [],
- "minimum-stability": "dev",
- "stability-flags": {},
- "prefer-stable": false,
- "prefer-lowest": false
- }"#;
- let lock: LockFile = serde_json::from_str(raw).unwrap();
- assert_eq!(lock.content_hash, "");
- assert!(!lock.is_fresh(r#"{"require": {}}"#));
- }
-
- fn make_packagist_version(
- version: &str,
- version_normalized: &str,
- require: BTreeMap<String, String>,
- ) -> PackagistVersion {
- PackagistVersion {
- version: version.to_string(),
- version_normalized: version_normalized.to_string(),
- require,
- replace: BTreeMap::new(),
- provide: BTreeMap::new(),
- conflict: BTreeMap::new(),
- dist: Some(crate::packagist::PackagistDist {
- dist_type: "zip".to_string(),
- url: format!("https://example.com/{version}.zip"),
- reference: Some("deadbeef".to_string()),
- shasum: Some("abc123".to_string()),
- }),
- source: Some(crate::packagist::PackagistSource {
- source_type: "git".to_string(),
- url: "https://github.com/example/pkg.git".to_string(),
- reference: Some("deadbeef".to_string()),
- }),
- require_dev: BTreeMap::new(),
- suggest: None,
- package_type: Some("library".to_string()),
- autoload: Some(serde_json::json!({"psr-4": {"Example\\": "src/"}})),
- autoload_dev: None,
- license: Some(vec!["MIT".to_string()]),
- description: Some("An example package".to_string()),
- homepage: Some("https://example.com".to_string()),
- keywords: Some(vec!["example".to_string(), "test".to_string()]),
- authors: Some(vec![
- serde_json::json!({"name": "Alice", "email": "alice@example.com"}),
- ]),
- support: Some(serde_json::json!({"issues": "https://github.com/example/pkg/issues"})),
- funding: Some(vec![
- serde_json::json!({"type": "github", "url": "https://github.com/sponsors/alice"}),
- ]),
- time: Some("2024-01-15T10:00:00+00:00".to_string()),
- extra: Some(serde_json::json!({"branch-alias": {"dev-main": "1.0.x-dev"}})),
- notification_url: Some("https://packagist.org/downloads/".to_string()),
- default_branch: false,
- abandoned: None,
- }
- }
-
- #[test]
- fn test_packagist_version_to_locked_package() {
- let pv = make_packagist_version("1.2.3", "1.2.3.0", BTreeMap::new());
- let locked = packagist_version_to_locked_package("example/pkg", &pv);
-
- assert_eq!(locked.name, "example/pkg");
- assert_eq!(locked.version, "1.2.3");
- assert_eq!(locked.version_normalized.as_deref(), Some("1.2.3.0"));
- assert_eq!(locked.description.as_deref(), Some("An example package"));
- assert_eq!(locked.homepage.as_deref(), Some("https://example.com"));
- assert_eq!(
- locked.license.as_deref(),
- Some(vec!["MIT".to_string()].as_slice())
- );
- assert_eq!(
- locked.keywords.as_deref(),
- Some(["example".to_string(), "test".to_string()].as_slice())
- );
- assert_eq!(locked.package_type.as_deref(), Some("library"));
- assert!(locked.autoload.is_some());
- assert!(locked.authors.is_some());
- assert!(locked.support.is_some());
- assert!(locked.funding.is_some());
- assert_eq!(locked.time.as_deref(), Some("2024-01-15T10:00:00+00:00"));
-
- // Check dist
- let dist = locked.dist.as_ref().unwrap();
- assert_eq!(dist.dist_type, "zip");
- assert_eq!(dist.reference.as_deref(), Some("deadbeef"));
- assert_eq!(dist.shasum.as_deref(), Some("abc123"));
-
- // Check source
- let source = locked.source.as_ref().unwrap();
- assert_eq!(source.source_type, "git");
- assert_eq!(source.reference.as_deref(), Some("deadbeef"));
-
- // Check extra_fields (extra and notification-url)
- assert!(locked.extra_fields.contains_key("extra"));
- assert!(locked.extra_fields.contains_key("notification-url"));
- assert_eq!(
- locked.extra_fields["notification-url"],
- serde_json::Value::String("https://packagist.org/downloads/".to_string())
- );
- }
-
- #[test]
- fn test_packagist_version_to_locked_package_no_optional_fields() {
- let pv = PackagistVersion {
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- require: BTreeMap::new(),
- replace: BTreeMap::new(),
- provide: BTreeMap::new(),
- conflict: BTreeMap::new(),
- dist: None,
- source: None,
- require_dev: BTreeMap::new(),
- suggest: None,
- package_type: None,
- autoload: None,
- autoload_dev: None,
- license: None,
- description: None,
- homepage: None,
- keywords: None,
- authors: None,
- support: None,
- funding: None,
- time: None,
- extra: None,
- notification_url: None,
- default_branch: false,
- abandoned: None,
- };
-
- let locked = packagist_version_to_locked_package("vendor/pkg", &pv);
- assert_eq!(locked.name, "vendor/pkg");
- assert!(locked.dist.is_none());
- assert!(locked.source.is_none());
- assert!(locked.description.is_none());
- assert!(locked.license.is_none());
- assert!(locked.extra_fields.is_empty());
- }
-
- #[test]
- fn test_classify_dev_packages_simple() {
- // Root: require={A}, require-dev={B}
- // A depends on C; B depends on D
- // Expected dev-only: {B, D}
- let resolved = vec![
- ResolvedPackage {
- name: "vendor/a".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- alias_of_normalized: None,
- },
- ResolvedPackage {
- name: "vendor/b".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- alias_of_normalized: None,
- },
- ResolvedPackage {
- name: "vendor/c".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- alias_of_normalized: None,
- },
- ResolvedPackage {
- name: "vendor/d".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- alias_of_normalized: None,
- },
- ];
-
- let mut require = BTreeMap::new();
- require.insert("vendor/a".to_string(), "^1.0".to_string());
-
- let mut require_dev = BTreeMap::new();
- require_dev.insert("vendor/b".to_string(), "^1.0".to_string());
-
- let mut metadata: IndexMap<String, PackagistVersion> = IndexMap::new();
-
- // A requires C
- let mut a_require = BTreeMap::new();
- a_require.insert("vendor/c".to_string(), "^1.0".to_string());
- metadata.insert(
- "vendor/a".to_string(),
- make_packagist_version("1.0.0", "1.0.0.0", a_require),
- );
-
- // B requires D
- let mut b_require = BTreeMap::new();
- b_require.insert("vendor/d".to_string(), "^1.0".to_string());
- metadata.insert(
- "vendor/b".to_string(),
- make_packagist_version("1.0.0", "1.0.0.0", b_require),
- );
-
- // C and D have no deps
- metadata.insert(
- "vendor/c".to_string(),
- make_packagist_version("1.0.0", "1.0.0.0", BTreeMap::new()),
- );
- metadata.insert(
- "vendor/d".to_string(),
- make_packagist_version("1.0.0", "1.0.0.0", BTreeMap::new()),
- );
-
- let requires_by_name: IndexMap<String, Vec<String>> = metadata
- .iter()
- .map(|(name, pv)| (name.to_lowercase(), pv.require.keys().cloned().collect()))
- .collect();
- let providers_by_name: IndexMap<String, Vec<String>> = metadata
- .keys()
- .map(|name| {
- let lower = name.to_lowercase();
- (lower.clone(), vec![lower])
- })
- .collect();
- let dev_only = classify_dev_packages(
- &resolved,
- &require,
- &require_dev,
- &requires_by_name,
- &providers_by_name,
- );
-
- assert!(!dev_only.contains("vendor/a"), "A is a production package");
- assert!(dev_only.contains("vendor/b"), "B is dev-only");
- assert!(
- !dev_only.contains("vendor/c"),
- "C is reachable from A (production)"
- );
- assert!(
- dev_only.contains("vendor/d"),
- "D is only reachable from B (dev)"
- );
- }
-
- #[test]
- fn test_classify_dev_packages_shared() {
- // Root: require={A}, require-dev={B}
- // Both A and B depend on C — C is NOT dev-only (reachable from production)
- let resolved = vec![
- ResolvedPackage {
- name: "vendor/a".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- alias_of_normalized: None,
- },
- ResolvedPackage {
- name: "vendor/b".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- alias_of_normalized: None,
- },
- ResolvedPackage {
- name: "vendor/c".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: "1.0.0.0".to_string(),
- is_dev: false,
- alias_of_normalized: None,
- },
- ];
-
- let mut require = BTreeMap::new();
- require.insert("vendor/a".to_string(), "^1.0".to_string());
-
- let mut require_dev = BTreeMap::new();
- require_dev.insert("vendor/b".to_string(), "^1.0".to_string());
-
- let mut metadata: IndexMap<String, PackagistVersion> = IndexMap::new();
-
- // A requires C
- let mut a_require = BTreeMap::new();
- a_require.insert("vendor/c".to_string(), "^1.0".to_string());
- metadata.insert(
- "vendor/a".to_string(),
- make_packagist_version("1.0.0", "1.0.0.0", a_require),
- );
-
- // B also requires C
- let mut b_require = BTreeMap::new();
- b_require.insert("vendor/c".to_string(), "^1.0".to_string());
- metadata.insert(
- "vendor/b".to_string(),
- make_packagist_version("1.0.0", "1.0.0.0", b_require),
- );
-
- // C has no deps
- metadata.insert(
- "vendor/c".to_string(),
- make_packagist_version("1.0.0", "1.0.0.0", BTreeMap::new()),
- );
-
- let requires_by_name: IndexMap<String, Vec<String>> = metadata
- .iter()
- .map(|(name, pv)| (name.to_lowercase(), pv.require.keys().cloned().collect()))
- .collect();
- let providers_by_name: IndexMap<String, Vec<String>> = metadata
- .keys()
- .map(|name| {
- let lower = name.to_lowercase();
- (lower.clone(), vec![lower])
- })
- .collect();
- let dev_only = classify_dev_packages(
- &resolved,
- &require,
- &require_dev,
- &requires_by_name,
- &providers_by_name,
- );
-
- assert!(!dev_only.contains("vendor/a"), "A is a production package");
- assert!(dev_only.contains("vendor/b"), "B is dev-only");
- assert!(
- !dev_only.contains("vendor/c"),
- "C is shared but reachable from production (A), so it's not dev-only"
- );
- }
-
- #[test]
- fn test_extract_platform_requirements() {
- let mut requirements = BTreeMap::new();
- requirements.insert("php".to_string(), ">=8.1".to_string());
- requirements.insert("ext-json".to_string(), "*".to_string());
- requirements.insert("ext-mbstring".to_string(), "*".to_string());
- requirements.insert("monolog/monolog".to_string(), "^3.0".to_string());
- requirements.insert("lib-pcre".to_string(), "*".to_string());
-
- let platform = extract_platform_requirements(&requirements);
- let obj = platform.as_object().unwrap();
-
- assert!(obj.contains_key("php"), "php should be in platform");
- assert!(
- obj.contains_key("ext-json"),
- "ext-json should be in platform"
- );
- assert!(
- obj.contains_key("ext-mbstring"),
- "ext-mbstring should be in platform"
- );
- assert!(
- obj.contains_key("lib-pcre"),
- "lib-pcre should be in platform"
- );
- assert!(
- !obj.contains_key("monolog/monolog"),
- "monolog/monolog should NOT be in platform"
- );
- assert_eq!(obj["php"], serde_json::Value::String(">=8.1".to_string()));
- assert_eq!(obj["ext-json"], serde_json::Value::String("*".to_string()));
- }
-
- #[test]
- fn test_extract_platform_requirements_empty() {
- let requirements = BTreeMap::new();
- let platform = extract_platform_requirements(&requirements);
- assert_eq!(platform, serde_json::json!({}));
- }
-
- #[tokio::test]
- async fn test_generate_lock_file_minimal() {
- let composer_json_content =
- r#"{"name": "test/project", "require": {"php": ">=8.1"}}"#.to_string();
- let composer_json: RawPackageData = serde_json::from_str(&composer_json_content).unwrap();
-
- let request = LockFileGenerationRequest {
- resolved_packages: vec![],
- composer_json_content: composer_json_content.clone(),
- composer_json,
- include_dev: true,
- repositories: std::sync::Arc::new(RepositorySet::with_packagist(
- crate::cache::Cache::new(std::env::temp_dir().join("mozart-test-cache"), false),
- )),
- previous_lock: None,
- lock_pinned_names: IndexSet::new(),
- };
-
- let lock = generate_lock_file(&request).await.unwrap();
-
- assert_eq!(lock.packages.len(), 0);
- assert_eq!(lock.packages_dev.as_ref().unwrap().len(), 0);
- assert_eq!(lock.minimum_stability, "stable");
- assert!(!lock.prefer_stable);
- assert!(!lock.prefer_lowest);
- assert_eq!(lock.plugin_api_version.as_deref(), Some("2.6.0"));
-
- // Verify content-hash matches
- let expected_hash = LockFile::compute_content_hash(&composer_json_content).unwrap();
- assert_eq!(lock.content_hash, expected_hash);
-
- // Verify platform requirements extracted
- let platform_obj = lock.platform.as_object().unwrap();
- assert!(platform_obj.contains_key("php"));
- assert_eq!(
- platform_obj["php"],
- serde_json::Value::String(">=8.1".to_string())
- );
- }
-
- #[test]
- fn test_lock_file_packages_sorted() {
- // Verify that packages are sorted alphabetically when assembled in generate_lock_file
- // We test this by constructing two LockedPackages and sorting them the same way
-
- let mut packages = [
- LockedPackage {
- name: "vendor/zebra".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: None,
- source: None,
- dist: None,
- require: BTreeMap::new(),
- require_dev: BTreeMap::new(),
- conflict: BTreeMap::new(),
- provide: BTreeMap::new(),
- replace: BTreeMap::new(),
- suggest: None,
- package_type: None,
- autoload: None,
- autoload_dev: None,
- license: None,
- description: None,
- homepage: None,
- keywords: None,
- authors: None,
- support: None,
- funding: None,
- time: None,
- extra_fields: BTreeMap::new(),
- },
- LockedPackage {
- name: "vendor/alpha".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: None,
- source: None,
- dist: None,
- require: BTreeMap::new(),
- require_dev: BTreeMap::new(),
- conflict: BTreeMap::new(),
- provide: BTreeMap::new(),
- replace: BTreeMap::new(),
- suggest: None,
- package_type: None,
- autoload: None,
- autoload_dev: None,
- license: None,
- description: None,
- homepage: None,
- keywords: None,
- authors: None,
- support: None,
- funding: None,
- time: None,
- extra_fields: BTreeMap::new(),
- },
- ];
-
- packages.sort_by(|a, b| a.name.cmp(&b.name));
-
- assert_eq!(packages[0].name, "vendor/alpha");
- assert_eq!(packages[1].name, "vendor/zebra");
- }
-
- #[tokio::test]
- #[ignore]
- async fn test_generate_lock_file_monolog() {
- use crate::cache::Cache;
- use crate::resolver::PlatformConfig;
- use crate::resolver::{ResolveRequest, resolve};
- use mozart_core::package::Stability;
- use std::sync::Arc;
-
- // Resolve monolog/monolog ^3.0
- let resolve_request = ResolveRequest {
- root_name: String::new(),
- root_version: None,
- require: vec![("monolog/monolog".to_string(), "^3.0".to_string())],
- require_dev: vec![],
- include_dev: false,
- minimum_stability: Stability::Stable,
- stability_flags: IndexMap::new(),
- prefer_stable: true,
- prefer_lowest: false,
- platform: PlatformConfig::new(),
- ignore_platform_reqs: false,
- ignore_platform_req_list: vec![],
- repositories: Arc::new(RepositorySet::with_packagist(Cache::new(
- std::env::temp_dir().join("mozart-test-cache"),
- false,
- ))),
- temporary_constraints: IndexMap::new(),
- raw_repositories: vec![],
- root_provide: IndexMap::new(),
- root_replace: IndexMap::new(),
- root_conflict: IndexMap::new(),
- locked_package_names: IndexSet::new(),
- locked_packages: Vec::new(),
- block_abandoned: false,
- root_branch_alias: None,
- preferred_versions: IndexMap::new(),
- block_insecure: false,
- };
-
- let resolved = resolve(&resolve_request)
- .await
- .expect("Resolution should succeed");
- assert!(!resolved.is_empty());
-
- let composer_json_content =
- r#"{"name": "test/project", "require": {"monolog/monolog": "^3.0"}}"#.to_string();
- let composer_json: RawPackageData = serde_json::from_str(&composer_json_content).unwrap();
-
- let gen_request = LockFileGenerationRequest {
- resolved_packages: resolved,
- composer_json_content: composer_json_content.clone(),
- composer_json,
- include_dev: false,
- repositories: Arc::new(RepositorySet::with_packagist(Cache::new(
- std::env::temp_dir().join("mozart-test-cache"),
- false,
- ))),
- previous_lock: None,
- lock_pinned_names: IndexSet::new(),
- };
-
- let lock = generate_lock_file(&gen_request)
- .await
- .expect("Lock file generation should succeed");
-
- // Verify monolog is in packages
- assert!(
- lock.packages.iter().any(|p| p.name == "monolog/monolog"),
- "monolog/monolog should be in packages"
- );
-
- // Verify packages are sorted alphabetically
- let names: Vec<&str> = lock.packages.iter().map(|p| p.name.as_str()).collect();
- let mut sorted_names = names.clone();
- sorted_names.sort();
- assert_eq!(
- names, sorted_names,
- "Packages should be sorted alphabetically"
- );
-
- // Verify content-hash matches
- let expected_hash = LockFile::compute_content_hash(&composer_json_content).unwrap();
- assert_eq!(lock.content_hash, expected_hash);
-
- // Verify monolog has full metadata
- let monolog = lock
- .packages
- .iter()
- .find(|p| p.name == "monolog/monolog")
- .unwrap();
- assert!(monolog.dist.is_some(), "monolog should have dist info");
- assert!(
- monolog.description.is_some(),
- "monolog should have description"
- );
- assert!(monolog.autoload.is_some(), "monolog should have autoload");
-
- println!("Generated lock file with {} packages:", lock.packages.len());
- for pkg in &lock.packages {
- println!(" {} {}", pkg.name, pkg.version);
- }
- }
-
- fn make_locked(name: &str, version: &str) -> LockedPackage {
- LockedPackage {
- name: name.to_string(),
- version: version.to_string(),
- version_normalized: None,
- source: None,
- dist: None,
- require: BTreeMap::new(),
- require_dev: BTreeMap::new(),
- conflict: BTreeMap::new(),
- provide: BTreeMap::new(),
- replace: BTreeMap::new(),
- suggest: None,
- package_type: Some("library".to_string()),
- autoload: None,
- autoload_dev: None,
- license: None,
- description: None,
- homepage: None,
- keywords: None,
- authors: None,
- support: None,
- funding: None,
- time: None,
- extra_fields: BTreeMap::new(),
- }
- }
-
- fn lock_with(packages: Vec<LockedPackage>, dev: Vec<LockedPackage>) -> LockFile {
- LockFile {
- readme: LockFile::default_readme(),
- content_hash: "x".to_string(),
- packages,
- packages_dev: Some(dev),
- aliases: vec![],
- minimum_stability: "stable".to_string(),
- stability_flags: serde_json::json!({}),
- prefer_stable: false,
- prefer_lowest: false,
- platform: serde_json::json!({}),
- platform_dev: serde_json::json!({}),
- plugin_api_version: Some("2.6.0".to_string()),
- }
- }
-
- fn root_with_require(
- require: &[(&str, &str)],
- require_dev: &[(&str, &str)],
- ) -> mozart_core::package::RawPackageData {
- let mut root = mozart_core::package::RawPackageData::new("__root__".to_string());
- for (k, v) in require {
- root.require.insert((*k).to_string(), (*v).to_string());
- }
- for (k, v) in require_dev {
- root.require_dev.insert((*k).to_string(), (*v).to_string());
- }
- root
- }
-
- #[test]
- fn missing_requirement_info_empty_when_satisfied() {
- let lock = lock_with(vec![make_locked("a/a", "1.0.0")], vec![]);
- let root = root_with_require(&[("a/a", "^1.0")], &[]);
- assert!(lock.get_missing_requirement_info(&root, true).is_empty());
- }
-
- #[test]
- fn missing_requirement_info_reports_missing_package() {
- let lock = lock_with(vec![], vec![]);
- let root = root_with_require(&[("a/a", "^1.0")], &[]);
- let info = lock.get_missing_requirement_info(&root, true);
- assert_eq!(
- info[0],
- "- Required package \"a/a\" is not present in the lock file."
- );
- assert!(info.iter().any(|m| m.contains("merge conflicts")));
- }
-
- #[test]
- fn missing_requirement_info_reports_unsatisfied_constraint() {
- let lock = lock_with(vec![make_locked("some/dep", "dev-foo")], vec![]);
- let root = root_with_require(&[("some/dep", "dev-main")], &[]);
- let info = lock.get_missing_requirement_info(&root, true);
- assert_eq!(
- info[0],
- "- Required package \"some/dep\" is in the lock file as \"dev-foo\" but that does not satisfy your constraint \"dev-main\"."
- );
- }
-
- #[test]
- fn missing_requirement_info_skips_platform_packages() {
- let lock = lock_with(vec![], vec![]);
- let root = root_with_require(&[("php", "^8.0"), ("ext-json", "*")], &[]);
- assert!(lock.get_missing_requirement_info(&root, true).is_empty());
- }
-
- #[test]
- fn missing_requirement_info_skips_self_version() {
- let lock = lock_with(vec![], vec![]);
- let root = root_with_require(&[("a/a", "self.version")], &[]);
- assert!(lock.get_missing_requirement_info(&root, true).is_empty());
- }
-
- #[test]
- fn missing_requirement_info_dev_pool_includes_packages_dev() {
- // require-dev "a/a" should be satisfied by an entry in packages-dev.
- let lock = lock_with(vec![], vec![make_locked("a/a", "1.0.0")]);
- let root = root_with_require(&[], &[("a/a", "^1.0")]);
- assert!(lock.get_missing_requirement_info(&root, true).is_empty());
- }
-
- #[test]
- fn missing_requirement_info_skips_dev_when_include_dev_false() {
- // require-dev errors must NOT appear when include_dev is false (no_dev).
- let lock = lock_with(vec![], vec![]);
- let root = root_with_require(&[], &[("a/a", "^1.0")]);
- assert!(lock.get_missing_requirement_info(&root, false).is_empty());
- }
-
- #[test]
- fn missing_requirement_info_require_pool_excludes_packages_dev() {
- // A regular require should NOT be satisfied by an entry that lives only
- // in packages-dev.
- let lock = lock_with(vec![], vec![make_locked("a/a", "1.0.0")]);
- let root = root_with_require(&[("a/a", "^1.0")], &[]);
- let info = lock.get_missing_requirement_info(&root, true);
- assert_eq!(
- info[0],
- "- Required package \"a/a\" is not present in the lock file."
- );
- }
-
- #[test]
- fn missing_requirement_info_reports_multiple_problems() {
- let lock = lock_with(vec![make_locked("some/dep", "dev-foo")], vec![]);
- let root = root_with_require(&[("some/dep", "dev-main"), ("some/dep2", "dev-main")], &[]);
- let info = lock.get_missing_requirement_info(&root, true);
- assert!(
- info.iter()
- .any(|m| m.contains("some/dep") && m.contains("dev-foo") && m.contains("dev-main"))
- );
- assert!(
- info.iter()
- .any(|m| m == "- Required package \"some/dep2\" is not present in the lock file.")
- );
- }
-
- #[test]
- fn missing_requirement_info_uses_dev_description_label() {
- let lock = lock_with(vec![], vec![]);
- let root = root_with_require(&[], &[("a/a", "^1.0")]);
- let info = lock.get_missing_requirement_info(&root, true);
- assert!(info[0].contains("Required (in require-dev) package \"a/a\""));
- }
-}
diff --git a/crates/mozart-registry/src/packagist.rs b/crates/mozart-registry/src/packagist.rs
deleted file mode 100644
index 5c99b07..0000000
--- a/crates/mozart-registry/src/packagist.rs
+++ /dev/null
@@ -1,1011 +0,0 @@
-use crate::cache::Cache;
-use serde::de::Deserializer;
-use serde::{Deserialize, Serialize};
-use std::collections::BTreeMap;
-
-/// Deserialize a field that may contain the Packagist minifier sentinel `"__unset"`.
-///
-/// Packagist's metadata minifier (see `composer/metadata-minifier`) encodes
-/// deleted fields as the literal string `"__unset"` in version diffs. When we
-/// encounter this sentinel we treat the field as absent (`None` / default).
-fn deserialize_unset_as_none<'de, D, T>(deserializer: D) -> Result<Option<T>, D::Error>
-where
- D: Deserializer<'de>,
- T: serde::de::DeserializeOwned,
-{
- let value = serde_json::Value::deserialize(deserializer)?;
- if value.as_str() == Some("__unset") {
- return Ok(None);
- }
- serde_json::from_value(value).map_err(serde::de::Error::custom)
-}
-
-/// Like [`deserialize_unset_as_none`] but returns a default `T` instead of `Option`.
-fn deserialize_unset_as_default<'de, D, T>(deserializer: D) -> Result<T, D::Error>
-where
- D: Deserializer<'de>,
- T: serde::de::DeserializeOwned + Default,
-{
- let value = serde_json::Value::deserialize(deserializer)?;
- if value.as_str() == Some("__unset") {
- return Ok(T::default());
- }
- serde_json::from_value(value).map_err(serde::de::Error::custom)
-}
-
-#[derive(Debug, Clone, Deserialize)]
-pub struct PackagistDist {
- #[serde(rename = "type")]
- pub dist_type: String,
- pub url: String,
- pub reference: Option<String>,
- pub shasum: Option<String>,
-}
-
-#[derive(Debug, Clone, Deserialize)]
-pub struct PackagistSource {
- #[serde(rename = "type")]
- pub source_type: String,
- pub url: String,
- pub reference: Option<String>,
-}
-
-#[derive(Debug, Clone, Deserialize)]
-pub struct PackagistVersion {
- pub version: String,
- pub version_normalized: String,
- #[serde(default, deserialize_with = "deserialize_unset_as_default")]
- pub require: BTreeMap<String, String>,
- #[serde(default, deserialize_with = "deserialize_unset_as_default")]
- pub replace: BTreeMap<String, String>,
- #[serde(default, deserialize_with = "deserialize_unset_as_default")]
- pub provide: BTreeMap<String, String>,
- #[serde(default, deserialize_with = "deserialize_unset_as_default")]
- pub conflict: BTreeMap<String, String>,
- #[serde(default, deserialize_with = "deserialize_unset_as_none")]
- pub dist: Option<PackagistDist>,
- #[serde(default, deserialize_with = "deserialize_unset_as_none")]
- pub source: Option<PackagistSource>,
-
- #[serde(
- rename = "require-dev",
- default,
- deserialize_with = "deserialize_unset_as_default"
- )]
- pub require_dev: BTreeMap<String, String>,
-
- #[serde(default, deserialize_with = "deserialize_unset_as_none")]
- pub suggest: Option<BTreeMap<String, String>>,
-
- #[serde(
- rename = "type",
- default,
- deserialize_with = "deserialize_unset_as_none"
- )]
- pub package_type: Option<String>,
-
- #[serde(default, deserialize_with = "deserialize_unset_as_none")]
- pub autoload: Option<serde_json::Value>,
-
- #[serde(
- rename = "autoload-dev",
- default,
- deserialize_with = "deserialize_unset_as_none"
- )]
- pub autoload_dev: Option<serde_json::Value>,
-
- #[serde(default, deserialize_with = "deserialize_unset_as_none")]
- pub license: Option<Vec<String>>,
-
- #[serde(default, deserialize_with = "deserialize_unset_as_none")]
- pub description: Option<String>,
-
- #[serde(default, deserialize_with = "deserialize_unset_as_none")]
- pub homepage: Option<String>,
-
- #[serde(default, deserialize_with = "deserialize_unset_as_none")]
- pub keywords: Option<Vec<String>>,
-
- #[serde(default, deserialize_with = "deserialize_unset_as_none")]
- pub authors: Option<Vec<serde_json::Value>>,
-
- #[serde(default, deserialize_with = "deserialize_unset_as_none")]
- pub support: Option<serde_json::Value>,
-
- #[serde(default, deserialize_with = "deserialize_unset_as_none")]
- pub funding: Option<Vec<serde_json::Value>>,
-
- #[serde(default, deserialize_with = "deserialize_unset_as_none")]
- pub time: Option<String>,
-
- #[serde(default, deserialize_with = "deserialize_unset_as_none")]
- pub extra: Option<serde_json::Value>,
-
- #[serde(
- rename = "notification-url",
- default,
- deserialize_with = "deserialize_unset_as_none"
- )]
- pub notification_url: Option<String>,
-
- /// `default-branch: true` marks the repository's default branch (e.g. the
- /// branch returned by `git symbolic-ref HEAD`). For packages without a
- /// numeric version prefix this triggers the synthetic `9999999-dev` alias
- /// generation in `ArrayLoader::getBranchAlias` — see the alias loop in
- /// `crate::resolver::packagist_to_pool_inputs`.
- #[serde(rename = "default-branch", default)]
- pub default_branch: bool,
-
- /// Abandonment marker. Composer accepts `abandoned: true` (no replacement
- /// suggested) or `abandoned: "<replacement-package>"`. Anything else
- /// (absent, `false`, empty string) means the package is active. Mirrors
- /// `Composer\Package\CompletePackage::isAbandoned`.
- #[serde(default, deserialize_with = "deserialize_unset_as_none")]
- pub abandoned: Option<serde_json::Value>,
-}
-
-impl PackagistVersion {
- /// Extract the `extra.branch-alias` map from this version's metadata.
- ///
- /// Composer packages can declare branch aliases in `extra.branch-alias`:
- /// ```json
- /// {
- /// "extra": {
- /// "branch-alias": {
- /// "dev-master": "2.x-dev"
- /// }
- /// }
- /// }
- /// ```
- ///
- /// Returns a map from branch name (e.g. `"dev-master"`) to alias target
- /// (e.g. `"2.x-dev"`). Returns an empty map when no aliases are declared.
- pub fn branch_aliases(&self) -> BTreeMap<String, String> {
- let Some(extra) = &self.extra else {
- return BTreeMap::new();
- };
-
- let Some(branch_alias) = extra.get("branch-alias") else {
- return BTreeMap::new();
- };
-
- let Some(map) = branch_alias.as_object() else {
- return BTreeMap::new();
- };
-
- map.iter()
- .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
- .collect()
- }
-}
-
-/// Parse a Packagist p2 API JSON response.
-///
-/// The response format is:
-/// ```json
-/// {
-/// "packages": {"vendor/package": [...]},
-/// "minified": "composer/2.0" // optional
-/// }
-/// ```
-///
-/// When the `"minified"` key is present the version list is delta-encoded by
-/// Composer's `MetadataMinifier`. This function transparently expands the
-/// minified data before deserializing into [`PackagistVersion`] structs.
-pub fn parse_p2_response(json: &str, package_name: &str) -> anyhow::Result<Vec<PackagistVersion>> {
- let raw: serde_json::Value = serde_json::from_str(json)?;
-
- // Check whether the response is minified.
- let is_minified = raw
- .get("minified")
- .and_then(|v| v.as_str())
- .is_some_and(|s| s == "composer/2.0");
-
- // Extract the version array for the requested package.
- let versions_value = raw
- .get("packages")
- .and_then(|p| p.get(package_name))
- .ok_or_else(|| anyhow::anyhow!("Package \"{package_name}\" not found in response"))?;
-
- let versions_array = versions_value
- .as_array()
- .ok_or_else(|| anyhow::anyhow!("Expected array for package \"{package_name}\""))?;
-
- // Expand minified diffs into full version objects if necessary.
- let versions: Vec<serde_json::Value> = if is_minified {
- mozart_metadata_minifier::expand(versions_array)
- } else {
- versions_array.clone()
- };
-
- // Deserialize the (possibly expanded) version objects.
- versions
- .into_iter()
- .map(|v| serde_json::from_value(v).map_err(Into::into))
- .collect()
-}
-
-/// Fetch package version metadata from the Packagist p2 API.
-///
-/// The JSON response is cached on disk under the key
-/// `"provider-{vendor}~{package}.json"`. Subsequent calls for the same
-/// package are served from cache without a network request (unless the
-/// cache is disabled).
-#[tracing::instrument(skip(repo_cache))]
-pub async fn fetch_package_versions(
- package_name: &str,
- repo_cache: &Cache,
-) -> anyhow::Result<Vec<PackagistVersion>> {
- // Build cache key: replace `/` with `~` per cache key convention
- let cache_key = format!("provider-{}.json", package_name.replace('/', "~"));
-
- // Check cache first
- if let Some(cached) = repo_cache.read(&cache_key) {
- tracing::debug!("cache hit");
- return parse_p2_response(&cached, package_name);
- }
-
- // Cache miss — fetch from Packagist
- let url = format!("https://repo.packagist.org/p2/{package_name}.json");
- tracing::debug!(%url, "fetching package metadata");
- let client = mozart_core::http::client_builder().build()?;
- let response = client.get(&url).send().await?;
- tracing::debug!(status = %response.status(), "received response");
-
- if !response.status().is_success() {
- anyhow::bail!(
- "Failed to fetch package \"{package_name}\" from Packagist (HTTP {})",
- response.status()
- );
- }
-
- let body = response.text().await?;
-
- // Write to cache
- let _ = repo_cache.write(&cache_key, &body);
-
- parse_p2_response(&body, package_name)
-}
-
-/// A single search result from the Packagist search API.
-#[derive(Debug, Deserialize, Serialize, Clone)]
-pub struct SearchResult {
- pub name: String,
- pub description: String,
- pub url: String,
- pub repository: Option<String>,
- pub downloads: u64,
- pub favers: u64,
- /// Abandonment status: absent/false means active, a string indicates the
- /// replacement package name, `true` means abandoned with no replacement.
- #[serde(default, skip_serializing_if = "Option::is_none")]
- pub abandoned: Option<serde_json::Value>,
-}
-
-#[derive(Debug, Deserialize)]
-pub struct SearchResponse {
- pub results: Vec<SearchResult>,
- pub total: u64,
- pub next: Option<String>,
-}
-
-/// Maximum number of pages to fetch from the Packagist search API.
-const SEARCH_MAX_PAGES: usize = 20;
-
-/// Percent-encode a string for use in a URL query parameter value.
-fn url_encode(s: &str) -> String {
- let mut encoded = String::with_capacity(s.len());
- for byte in s.bytes() {
- match byte {
- b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
- encoded.push(byte as char);
- }
- b' ' => encoded.push_str("%20"),
- other => {
- encoded.push_str(&format!("%{other:02X}"));
- }
- }
- }
- encoded
-}
-
-/// Search Packagist for packages matching `query`.
-///
-/// Fetches up to `SEARCH_MAX_PAGES` pages of results and returns the full list.
-/// An optional `package_type` filter can narrow results (e.g. `"library"`).
-#[tracing::instrument(fields(type_filter = package_type))]
-pub async fn search_packages(
- query: &str,
- package_type: Option<&str>,
-) -> anyhow::Result<(Vec<SearchResult>, u64)> {
- let client = mozart_core::http::client_builder().build()?;
-
- let mut all_results: Vec<SearchResult> = Vec::new();
- let mut page = 1usize;
- let mut next_url: Option<String> = None;
- let mut total: u64 = 0;
-
- loop {
- let response: SearchResponse = if let Some(ref url) = next_url {
- tracing::debug!(%url, page, "fetching next page");
- let resp = client.get(url).send().await?;
- tracing::debug!(status = %resp.status(), "received response");
- if !resp.status().is_success() {
- anyhow::bail!("Packagist search request failed (HTTP {})", resp.status());
- }
- resp.json().await?
- } else {
- let encoded_query = url_encode(query);
- let mut url = format!("https://packagist.org/search.json?q={encoded_query}");
- if let Some(t) = package_type {
- url.push_str("&type=");
- url.push_str(&url_encode(t));
- }
-
- tracing::debug!(%url, "fetching search results");
- let resp = client.get(&url).send().await?;
- tracing::debug!(status = %resp.status(), "received response");
- if !resp.status().is_success() {
- anyhow::bail!("Packagist search request failed (HTTP {})", resp.status());
- }
- resp.json().await?
- };
-
- if page == 1 {
- total = response.total;
- }
-
- all_results.extend(response.results);
- next_url = response.next;
- page += 1;
-
- if next_url.is_none() || page > SEARCH_MAX_PAGES {
- break;
- }
- }
-
- Ok((all_results, total))
-}
-
-/// Response shape of `https://packagist.org/packages/list.json[?type=...]`.
-#[derive(Debug, Deserialize)]
-struct ListResponse {
- #[serde(rename = "packageNames")]
- package_names: Vec<String>,
-}
-
-/// Fetch the full list of Packagist package names, optionally filtered by type.
-///
-/// Backs Composer's `ComposerRepository::getPackageNames()` for the
-/// `SEARCH_NAME` and `SEARCH_VENDOR` search modes. Cached on disk under
-/// `list-packages~{type}.json` (or `list-packages~all.json` when no type
-/// filter is given).
-#[tracing::instrument(skip(repo_cache))]
-pub async fn fetch_package_names(
- package_type: Option<&str>,
- repo_cache: &Cache,
-) -> anyhow::Result<Vec<String>> {
- let cache_key = match package_type {
- Some(t) => format!("list-packages~{t}.json"),
- None => "list-packages~all.json".to_string(),
- };
-
- if let Some(cached) = repo_cache.read(&cache_key) {
- tracing::debug!("cache hit");
- let parsed: ListResponse = serde_json::from_str(&cached)?;
- return Ok(parsed.package_names);
- }
-
- let mut url = "https://packagist.org/packages/list.json".to_string();
- if let Some(t) = package_type {
- url.push_str("?type=");
- url.push_str(&url_encode(t));
- }
- tracing::debug!(%url, "fetching package list");
- let client = mozart_core::http::client_builder().build()?;
- let response = client.get(&url).send().await?;
- tracing::debug!(status = %response.status(), "received response");
-
- if !response.status().is_success() {
- anyhow::bail!(
- "Failed to fetch package list from Packagist (HTTP {})",
- response.status()
- );
- }
-
- let body = response.text().await?;
- let _ = repo_cache.write(&cache_key, &body);
-
- let parsed: ListResponse = serde_json::from_str(&body)?;
- Ok(parsed.package_names)
-}
-
-/// Fetch the deduplicated list of Packagist vendor names.
-///
-/// Mirrors Composer's `ComposerRepository::getVendorNames()` which derives
-/// vendors from `getPackageNames()` (regardless of type) by stripping the
-/// `/...` suffix and de-duplicating in insertion order.
-#[tracing::instrument(skip(repo_cache))]
-pub async fn fetch_vendor_names(repo_cache: &Cache) -> anyhow::Result<Vec<String>> {
- let names = fetch_package_names(None, repo_cache).await?;
- let mut seen: indexmap::IndexSet<String> = indexmap::IndexSet::new();
- for name in names {
- let vendor = match name.split_once('/') {
- Some((v, _)) => v.to_string(),
- None => name,
- };
- seen.insert(vendor);
- }
- Ok(seen.into_iter().collect())
-}
-
-/// A single security advisory from the Packagist API.
-#[derive(Debug, Clone, Deserialize, Serialize)]
-pub struct SecurityAdvisory {
- #[serde(rename = "advisoryId")]
- pub advisory_id: String,
-
- #[serde(rename = "packageName")]
- pub package_name: String,
-
- #[serde(rename = "remoteId")]
- pub remote_id: String,
-
- pub title: String,
-
- pub link: Option<String>,
-
- pub cve: Option<String>,
-
- /// Composer version constraint string, e.g. ">=1.0,<1.5.1|>=2.0,<2.3"
- #[serde(rename = "affectedVersions")]
- pub affected_versions: String,
-
- pub source: String,
-
- #[serde(rename = "reportedAt")]
- pub reported_at: String,
-
- #[serde(rename = "composerRepository")]
- pub composer_repository: Option<String>,
-
- pub severity: Option<String>,
-
- #[serde(default)]
- pub sources: Vec<AdvisorySource>,
-}
-
-/// A source entry within a security advisory.
-#[derive(Debug, Clone, Deserialize, Serialize)]
-pub struct AdvisorySource {
- pub name: String,
- #[serde(rename = "remoteId")]
- pub remote_id: String,
-}
-
-/// Response from POST `https://packagist.org/api/security-advisories/`.
-#[derive(Debug, Deserialize)]
-pub struct SecurityAdvisoriesResponse {
- pub advisories: BTreeMap<String, Vec<SecurityAdvisory>>,
-}
-
-/// Fetch security advisories for the given package names from the Packagist API.
-///
-/// Sends a POST request to `https://packagist.org/api/security-advisories/`
-/// with form-encoded package names. Returns advisories grouped by package name.
-///
-/// If the package list is very large (500+), requests are batched in chunks of
-/// 500 names per request and the results are merged.
-#[tracing::instrument(skip(package_names), fields(package_count = package_names.len()))]
-pub async fn fetch_security_advisories(
- package_names: &[&str],
-) -> anyhow::Result<BTreeMap<String, Vec<SecurityAdvisory>>> {
- let client = mozart_core::http::client_builder().build()?;
-
- let mut all_advisories: BTreeMap<String, Vec<SecurityAdvisory>> = BTreeMap::new();
-
- for chunk in package_names.chunks(500) {
- // Build an application/x-www-form-urlencoded body manually.
- // Each package is encoded as `packages[]=<name>` and joined with `&`.
- let body: String = chunk
- .iter()
- .map(|name| format!("packages[]={}", url_encode(name)))
- .collect::<Vec<_>>()
- .join("&");
-
- tracing::debug!(chunk_size = chunk.len(), "fetching security advisories");
- let response = client
- .post("https://packagist.org/api/security-advisories/")
- .header("Content-Type", "application/x-www-form-urlencoded")
- .body(body)
- .send()
- .await?;
- tracing::debug!(status = %response.status(), "received response");
-
- if !response.status().is_success() {
- anyhow::bail!(
- "Packagist security advisories request failed (HTTP {})",
- response.status()
- );
- }
-
- let parsed: SecurityAdvisoriesResponse = response.json().await?;
-
- for (pkg_name, advisories) in parsed.advisories {
- if !advisories.is_empty() {
- all_advisories
- .entry(pkg_name)
- .or_default()
- .extend(advisories);
- }
- }
- }
-
- Ok(all_advisories)
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn parse_p2_response_basic() {
- let json = r#"{
- "packages": {
- "monolog/monolog": [
- {
- "version": "3.8.0",
- "version_normalized": "3.8.0.0",
- "require": {"php": ">=8.1"},
- "dist": {
- "type": "zip",
- "url": "https://example.com/monolog-3.8.0.zip",
- "reference": "abc123",
- "shasum": ""
- },
- "source": {
- "type": "git",
- "url": "https://github.com/Seldaek/monolog.git",
- "reference": "abc123"
- }
- },
- {
- "version": "3.7.0",
- "version_normalized": "3.7.0.0",
- "require": {"php": ">=8.1"}
- }
- ]
- }
- }"#;
-
- let versions = parse_p2_response(json, "monolog/monolog").unwrap();
- assert_eq!(versions.len(), 2);
- assert_eq!(versions[0].version, "3.8.0");
- assert_eq!(versions[0].version_normalized, "3.8.0.0");
- assert_eq!(versions[0].require.get("php").unwrap(), ">=8.1");
- assert!(versions[0].dist.is_some());
- assert!(versions[0].source.is_some());
- assert_eq!(versions[1].version, "3.7.0");
- assert!(versions[1].dist.is_none());
- }
-
- #[test]
- fn parse_p2_response_not_found() {
- let json = r#"{"packages": {"other/pkg": []}}"#;
- let result = parse_p2_response(json, "monolog/monolog");
- assert!(result.is_err());
- }
-
- #[test]
- fn parse_p2_response_with_dev_version() {
- let json = r#"{
- "packages": {
- "test/pkg": [
- {
- "version": "dev-master",
- "version_normalized": "dev-master",
- "require": {}
- },
- {
- "version": "1.0.0",
- "version_normalized": "1.0.0.0",
- "require": {}
- }
- ]
- }
- }"#;
-
- let versions = parse_p2_response(json, "test/pkg").unwrap();
- assert_eq!(versions.len(), 2);
- assert_eq!(versions[0].version, "dev-master");
- assert_eq!(versions[1].version, "1.0.0");
- }
-
- #[test]
- fn test_branch_aliases_present() {
- let json = r#"{
- "packages": {
- "test/pkg": [
- {
- "version": "dev-master",
- "version_normalized": "dev-master",
- "require": {},
- "extra": {
- "branch-alias": {
- "dev-master": "2.x-dev"
- }
- }
- }
- ]
- }
- }"#;
-
- let versions = parse_p2_response(json, "test/pkg").unwrap();
- let aliases = versions[0].branch_aliases();
- assert_eq!(aliases.len(), 1);
- assert_eq!(aliases.get("dev-master").unwrap(), "2.x-dev");
- }
-
- #[test]
- fn test_branch_aliases_multiple() {
- let json = r#"{
- "packages": {
- "test/pkg": [
- {
- "version": "dev-master",
- "version_normalized": "dev-master",
- "require": {},
- "extra": {
- "branch-alias": {
- "dev-master": "2.x-dev",
- "dev-1.x": "1.5.x-dev"
- }
- }
- }
- ]
- }
- }"#;
-
- let versions = parse_p2_response(json, "test/pkg").unwrap();
- let aliases = versions[0].branch_aliases();
- assert_eq!(aliases.len(), 2);
- assert_eq!(aliases.get("dev-master").unwrap(), "2.x-dev");
- assert_eq!(aliases.get("dev-1.x").unwrap(), "1.5.x-dev");
- }
-
- #[test]
- fn test_branch_aliases_no_extra() {
- let json = r#"{
- "packages": {
- "test/pkg": [
- {
- "version": "dev-master",
- "version_normalized": "dev-master",
- "require": {}
- }
- ]
- }
- }"#;
-
- let versions = parse_p2_response(json, "test/pkg").unwrap();
- let aliases = versions[0].branch_aliases();
- assert!(aliases.is_empty());
- }
-
- #[test]
- fn test_branch_aliases_extra_without_branch_alias_key() {
- let json = r#"{
- "packages": {
- "test/pkg": [
- {
- "version": "dev-master",
- "version_normalized": "dev-master",
- "require": {},
- "extra": {
- "installer-name": "my-plugin"
- }
- }
- ]
- }
- }"#;
-
- let versions = parse_p2_response(json, "test/pkg").unwrap();
- let aliases = versions[0].branch_aliases();
- assert!(aliases.is_empty());
- }
-
- #[test]
- fn parse_p2_response_unset_fields() {
- // Packagist metadata minifier uses "__unset" to mark deleted fields.
- let json = r#"{
- "packages": {
- "test/pkg": [
- {
- "version": "2.0.0",
- "version_normalized": "2.0.0.0",
- "require": {"php": ">=8.1"},
- "license": ["MIT"],
- "keywords": ["framework"],
- "authors": [{"name": "Alice"}],
- "funding": [{"type": "github", "url": "https://github.com/sponsors/alice"}]
- },
- {
- "version": "1.0.0",
- "version_normalized": "1.0.0.0",
- "license": "__unset",
- "keywords": "__unset",
- "authors": "__unset",
- "funding": "__unset",
- "require": "__unset",
- "homepage": "__unset",
- "description": "__unset",
- "extra": "__unset",
- "suggest": "__unset"
- }
- ]
- }
- }"#;
-
- let versions = parse_p2_response(json, "test/pkg").unwrap();
- assert_eq!(versions.len(), 2);
-
- // First version has normal values
- assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]);
- assert_eq!(versions[0].keywords.as_ref().unwrap(), &["framework"]);
-
- // Second version has __unset → treated as absent
- assert!(versions[1].license.is_none());
- assert!(versions[1].keywords.is_none());
- assert!(versions[1].authors.is_none());
- assert!(versions[1].funding.is_none());
- assert!(versions[1].require.is_empty());
- assert!(versions[1].homepage.is_none());
- assert!(versions[1].description.is_none());
- assert!(versions[1].extra.is_none());
- assert!(versions[1].suggest.is_none());
- }
-
- #[test]
- fn parse_p2_response_minified_expand() {
- // Mirrors the Composer MetadataMinifierTest: 3 versions where only
- // the first carries all fields and subsequent entries are diffs.
- let json = r#"{
- "packages": {
- "foo/bar": [
- {
- "name": "foo/bar",
- "version": "2.0.0",
- "version_normalized": "2.0.0.0",
- "type": "library",
- "license": ["MIT"],
- "require": {"php": ">=8.1"},
- "description": "A great package"
- },
- {
- "version": "1.2.0",
- "version_normalized": "1.2.0.0",
- "license": ["GPL"],
- "homepage": "https://example.org"
- },
- {
- "version": "1.0.0",
- "version_normalized": "1.0.0.0",
- "homepage": "__unset"
- }
- ]
- },
- "minified": "composer/2.0"
- }"#;
-
- let versions = parse_p2_response(json, "foo/bar").unwrap();
- assert_eq!(versions.len(), 3);
-
- // Version 2.0.0 — full data (first entry).
- assert_eq!(versions[0].version, "2.0.0");
- assert_eq!(versions[0].package_type.as_deref(), Some("library"));
- assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]);
- assert_eq!(versions[0].require.get("php").unwrap(), ">=8.1");
- assert_eq!(versions[0].description.as_deref(), Some("A great package"));
- assert!(versions[0].homepage.is_none());
-
- // Version 1.2.0 — inherits name, type, require, description from 2.0.0;
- // license changed to GPL; homepage added.
- assert_eq!(versions[1].version, "1.2.0");
- assert_eq!(versions[1].package_type.as_deref(), Some("library"));
- assert_eq!(versions[1].license.as_ref().unwrap(), &["GPL"]);
- assert_eq!(versions[1].require.get("php").unwrap(), ">=8.1");
- assert_eq!(versions[1].description.as_deref(), Some("A great package"));
- assert_eq!(versions[1].homepage.as_deref(), Some("https://example.org"));
-
- // Version 1.0.0 — inherits everything from 1.2.0 except homepage
- // which is __unset (deleted).
- assert_eq!(versions[2].version, "1.0.0");
- assert_eq!(versions[2].package_type.as_deref(), Some("library"));
- assert_eq!(versions[2].license.as_ref().unwrap(), &["GPL"]);
- assert_eq!(versions[2].require.get("php").unwrap(), ">=8.1");
- assert_eq!(versions[2].description.as_deref(), Some("A great package"));
- assert!(versions[2].homepage.is_none());
- }
-
- #[test]
- fn parse_p2_response_not_minified_no_inheritance() {
- // Without "minified" key, each version stands alone — no inheritance.
- let json = r#"{
- "packages": {
- "foo/bar": [
- {
- "version": "2.0.0",
- "version_normalized": "2.0.0.0",
- "license": ["MIT"],
- "description": "A great package"
- },
- {
- "version": "1.0.0",
- "version_normalized": "1.0.0.0"
- }
- ]
- }
- }"#;
-
- let versions = parse_p2_response(json, "foo/bar").unwrap();
- assert_eq!(versions.len(), 2);
-
- assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]);
- assert_eq!(versions[0].description.as_deref(), Some("A great package"));
-
- // Without minified flag, version 1.0.0 does NOT inherit from 2.0.0.
- assert!(versions[1].license.is_none());
- assert!(versions[1].description.is_none());
- }
-
- #[test]
- fn parse_p2_response_minified_single_version() {
- // Edge case: minified response with only one version.
- let json = r#"{
- "packages": {
- "foo/bar": [
- {
- "version": "1.0.0",
- "version_normalized": "1.0.0.0",
- "license": ["MIT"]
- }
- ]
- },
- "minified": "composer/2.0"
- }"#;
-
- let versions = parse_p2_response(json, "foo/bar").unwrap();
- assert_eq!(versions.len(), 1);
- assert_eq!(versions[0].license.as_ref().unwrap(), &["MIT"]);
- }
-
- #[test]
- fn parse_p2_response_minified_empty_versions() {
- let json = r#"{
- "packages": {
- "foo/bar": []
- },
- "minified": "composer/2.0"
- }"#;
-
- let versions = parse_p2_response(json, "foo/bar").unwrap();
- assert!(versions.is_empty());
- }
-
- #[test]
- fn parse_p2_response_minified_map_fields_inherited() {
- // Verify BTreeMap fields (require, replace, etc.) are inherited.
- let json = r#"{
- "packages": {
- "foo/bar": [
- {
- "version": "2.0.0",
- "version_normalized": "2.0.0.0",
- "require": {"php": ">=8.1", "ext-json": "*"},
- "replace": {"foo/old": "self.version"}
- },
- {
- "version": "1.0.0",
- "version_normalized": "1.0.0.0",
- "replace": "__unset"
- }
- ]
- },
- "minified": "composer/2.0"
- }"#;
-
- let versions = parse_p2_response(json, "foo/bar").unwrap();
- assert_eq!(versions.len(), 2);
-
- // Version 1.0.0 inherits require from 2.0.0, replace is unset.
- assert_eq!(versions[1].require.get("php").unwrap(), ">=8.1");
- assert_eq!(versions[1].require.get("ext-json").unwrap(), "*");
- assert!(versions[1].replace.is_empty());
- }
-
- #[test]
- fn test_parse_security_advisories_response() {
- let json = r#"{
- "advisories": {
- "monolog/monolog": [
- {
- "advisoryId": "PKSA-b2m0-qqf7-qck4",
- "packageName": "monolog/monolog",
- "remoteId": "monolog/monolog/2017-11-13-1.yaml",
- "title": "Header injection in NativeMailerHandler",
- "link": "https://github.com/Seldaek/monolog/pull/683",
- "cve": null,
- "affectedVersions": ">=1.8.0,<1.12.0",
- "source": "FriendsOfPHP/security-advisories",
- "reportedAt": "2017-11-13T00:00:00+00:00",
- "composerRepository": "https://packagist.org",
- "severity": "low",
- "sources": [
- {
- "name": "FriendsOfPHP/security-advisories",
- "remoteId": "monolog/monolog/2017-11-13-1.yaml"
- }
- ]
- }
- ]
- }
- }"#;
-
- let response: SecurityAdvisoriesResponse = serde_json::from_str(json).unwrap();
- assert_eq!(response.advisories.len(), 1);
- let advisories = response.advisories.get("monolog/monolog").unwrap();
- assert_eq!(advisories.len(), 1);
- let adv = &advisories[0];
- assert_eq!(adv.advisory_id, "PKSA-b2m0-qqf7-qck4");
- assert_eq!(adv.package_name, "monolog/monolog");
- assert_eq!(adv.title, "Header injection in NativeMailerHandler");
- assert_eq!(adv.affected_versions, ">=1.8.0,<1.12.0");
- assert_eq!(adv.severity.as_deref(), Some("low"));
- assert!(adv.cve.is_none());
- assert_eq!(adv.sources.len(), 1);
- assert_eq!(adv.sources[0].name, "FriendsOfPHP/security-advisories");
- }
-
- #[test]
- fn test_parse_security_advisories_empty() {
- let json = r#"{"advisories": {"other/package": []}}"#;
- let response: SecurityAdvisoriesResponse = serde_json::from_str(json).unwrap();
- assert_eq!(response.advisories.len(), 1);
- let advisories = response.advisories.get("other/package").unwrap();
- assert!(advisories.is_empty());
- }
-
- #[test]
- fn test_parse_security_advisories_null_fields() {
- let json = r#"{
- "advisories": {
- "vendor/pkg": [
- {
- "advisoryId": "PKSA-0000-0000-0000",
- "packageName": "vendor/pkg",
- "remoteId": "vendor/pkg/2024-01-01.yaml",
- "title": "Some vulnerability",
- "link": null,
- "cve": null,
- "affectedVersions": ">=1.0,<2.0",
- "source": "FriendsOfPHP/security-advisories",
- "reportedAt": "2024-01-01T00:00:00+00:00",
- "composerRepository": null,
- "severity": null,
- "sources": []
- }
- ]
- }
- }"#;
-
- let response: SecurityAdvisoriesResponse = serde_json::from_str(json).unwrap();
- let advisories = response.advisories.get("vendor/pkg").unwrap();
- assert_eq!(advisories.len(), 1);
- let adv = &advisories[0];
- assert!(adv.link.is_none());
- assert!(adv.cve.is_none());
- assert!(adv.severity.is_none());
- assert!(adv.composer_repository.is_none());
- assert!(adv.sources.is_empty());
- }
-}
diff --git a/crates/mozart-registry/src/path_repository.rs b/crates/mozart-registry/src/path_repository.rs
deleted file mode 100644
index bf71315..0000000
--- a/crates/mozart-registry/src/path_repository.rs
+++ /dev/null
@@ -1,243 +0,0 @@
-//! Support for `type: path` repositories.
-//!
-//! Mirrors `Composer\Repository\PathRepository`: a path repo points at a
-//! local directory containing a `composer.json`, and the resolver loads the
-//! package from that file directly. Mozart does not yet support glob URLs or
-//! the `versions` / `reference: none` options — only the bare
-//! `{ type: path, url: ... }` form the installer fixtures exercise.
-//!
-//! Resolution model: a path repo is expanded into a synthetic
-//! `type: package` [`RawRepository`] whose payload is the loaded composer.json
-//! plus a `dist` block. After this expansion the rest of the registry treats
-//! the package the same as any inline `type: package` entry — that is the
-//! whole point of doing the work here rather than threading a new repo type
-//! through the resolver / lockfile.
-//!
-//! `dist.reference` matches Composer's `hash('sha1', $json . serialize($options))`
-//! where `$options` carries the auto-detected `relative` flag (true when the
-//! original URL was not absolute). The same SHA-1 ends up in the lockfile, so
-//! consumers comparing references against Composer-produced lockfiles see
-//! byte-identical values.
-
-use std::path::{Path, PathBuf};
-
-use mozart_core::package::RawRepository;
-use mozart_php_serialize::{Value as PhpValue, serialize as php_serialize};
-use sha1::{Digest, Sha1};
-
-/// Translate path repos in `repositories` into synthetic `type: package`
-/// entries. Non-path entries are returned unchanged in original order.
-///
-/// `base_dir` is the directory used to resolve relative `url` values
-/// (Composer's PHP code resolves these against the process cwd; in production
-/// that equals the project root, in tests it equals the fixtures anchor).
-///
-/// Failures (missing directory, unreadable composer.json, missing
-/// `name`/`version`) drop the offending entry silently — the rest of the
-/// repository list still applies. This mirrors Composer's lenient
-/// PathRepository, which logs a warning and moves on rather than aborting the
-/// whole resolve.
-pub fn expand_path_repositories(
- repositories: &[RawRepository],
- base_dir: &Path,
-) -> Vec<RawRepository> {
- let mut out = Vec::with_capacity(repositories.len());
- for repo in repositories {
- if repo.repo_type != "path" {
- out.push(repo.clone());
- continue;
- }
- let Some(url) = repo.url.as_deref() else {
- continue;
- };
- let Some(synthetic) = load_path_package(url, base_dir) else {
- continue;
- };
- out.push(synthetic);
- }
- out
-}
-
-/// Read one path repo's `composer.json` and synthesize the inline-package
-/// form. Returns `None` for any I/O or parse failure (Composer behaves the
-/// same — `PathRepository::initialize` skips entries whose `composer.json`
-/// is missing).
-fn load_path_package(url: &str, base_dir: &Path) -> Option<RawRepository> {
- let resolved = resolve_path(url, base_dir);
- let composer_json_path = resolved.join("composer.json");
- let json = std::fs::read_to_string(&composer_json_path).ok()?;
- let mut package: serde_json::Value = serde_json::from_str(&json).ok()?;
- let obj = package.as_object_mut()?;
-
- // `version` is mandatory in the inline-package representation: without it
- // the resolver would skip the package. Composer's PathRepository falls
- // back to `dev-main` when no version is declared and no VCS is present;
- // mirror that so a path repo whose composer.json omits `version` still
- // produces a usable entry.
- if !obj.contains_key("version") {
- obj.insert(
- "version".to_string(),
- serde_json::Value::String("dev-main".to_string()),
- );
- }
-
- let is_relative = !Path::new(url).is_absolute();
- let reference = compute_path_reference(json.as_bytes(), is_relative);
-
- obj.insert(
- "dist".to_string(),
- serde_json::json!({
- "type": "path",
- "url": url,
- "reference": reference,
- }),
- );
- // Composer copies `symlink`/`relative` from `options` into
- // `transport-options`. We have no `options` to forward today but emit an
- // empty object so consumers reading the package see the same shape.
- obj.entry("transport-options")
- .or_insert_with(|| serde_json::json!({}));
-
- Some(RawRepository {
- repo_type: "package".to_string(),
- url: None,
- package: Some(serde_json::Value::Array(vec![package])),
- only: None,
- exclude: None,
- canonical: None,
- security_advisories: None,
- })
-}
-
-fn resolve_path(url: &str, base_dir: &Path) -> PathBuf {
- let p = Path::new(url);
- if p.is_absolute() {
- p.to_path_buf()
- } else {
- base_dir.join(p)
- }
-}
-
-/// Compose the SHA-1 reference Composer uses for path repos:
-/// `sha1($json . serialize(['relative' => $isRelative]))`. The `relative`
-/// flag is the only option Composer's auto-detection populates when the user
-/// supplied no `options` block.
-fn compute_path_reference(json_bytes: &[u8], is_relative: bool) -> String {
- let options = PhpValue::Array(vec![(
- PhpValue::String("relative".to_string()),
- PhpValue::Bool(is_relative),
- )]);
- let serialized = php_serialize(&options);
- let mut hasher = Sha1::new();
- hasher.update(json_bytes);
- hasher.update(serialized.as_bytes());
- let bytes = hasher.finalize();
- let mut hex = String::with_capacity(bytes.len() * 2);
- for b in bytes {
- use std::fmt::Write;
- let _ = write!(&mut hex, "{:02x}", b);
- }
- hex
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn computes_known_reference_for_plugin_a_fixture() {
- // Fixture used by partial-update-loads-root-aliases-for-path-repos.test.
- // Expected reference (`b133081...`) is what PHP's
- // `hash('sha1', file_get_contents($composerJson) . serialize(['relative' => true]))`
- // produces for this file — pin it here so reference computation
- // changes can't drift silently from Composer.
- let composer_json_path = Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("../../composer/tests/Composer/Test/Fixtures/functional/installed-versions/plugin-a/composer.json");
- let bytes = std::fs::read(&composer_json_path).expect("fixture composer.json must exist");
- let reference = compute_path_reference(&bytes, true);
- assert!(
- reference.starts_with("b133081"),
- "unexpected reference: {reference}"
- );
- }
-
- #[test]
- fn relative_url_resolves_against_base_dir_and_emits_synthetic_package_repo() {
- let temp = tempfile::tempdir().unwrap();
- std::fs::create_dir_all(temp.path().join("pkg-dir")).unwrap();
- std::fs::write(
- temp.path().join("pkg-dir").join("composer.json"),
- r#"{"name": "vendor/pkg", "version": "1.2.3"}"#,
- )
- .unwrap();
-
- let input = vec![RawRepository {
- repo_type: "path".to_string(),
- url: Some("pkg-dir".to_string()),
- package: None,
- only: None,
- exclude: None,
- canonical: None,
- security_advisories: None,
- }];
- let expanded = expand_path_repositories(&input, temp.path());
- assert_eq!(expanded.len(), 1);
- assert_eq!(expanded[0].repo_type, "package");
-
- let pkgs = expanded[0]
- .package
- .as_ref()
- .expect("expanded entry must carry a package payload")
- .as_array()
- .expect("payload should be an array");
- assert_eq!(pkgs.len(), 1);
- let pkg = &pkgs[0];
- assert_eq!(pkg["name"], "vendor/pkg");
- assert_eq!(pkg["version"], "1.2.3");
- assert_eq!(pkg["dist"]["type"], "path");
- assert_eq!(pkg["dist"]["url"], "pkg-dir");
- assert!(
- pkg["dist"]["reference"]
- .as_str()
- .map(|s| s.len() == 40)
- .unwrap_or(false),
- "reference should be a 40-char SHA-1"
- );
- }
-
- #[test]
- fn missing_composer_json_drops_the_entry() {
- let temp = tempfile::tempdir().unwrap();
- let input = vec![RawRepository {
- repo_type: "path".to_string(),
- url: Some("does-not-exist".to_string()),
- package: None,
- only: None,
- exclude: None,
- canonical: None,
- security_advisories: None,
- }];
- let expanded = expand_path_repositories(&input, temp.path());
- assert!(expanded.is_empty());
- }
-
- #[test]
- fn non_path_repos_pass_through_unchanged() {
- let input = vec![RawRepository {
- repo_type: "vcs".to_string(),
- url: Some("https://example.com/repo.git".to_string()),
- package: None,
- only: None,
- exclude: None,
- canonical: None,
- security_advisories: None,
- }];
- let expanded = expand_path_repositories(&input, Path::new("/tmp"));
- assert_eq!(expanded.len(), 1);
- assert_eq!(expanded[0].repo_type, "vcs");
- assert_eq!(
- expanded[0].url.as_deref(),
- Some("https://example.com/repo.git")
- );
- }
-}
diff --git a/crates/mozart-registry/src/repository/inline_package_repo.rs b/crates/mozart-registry/src/repository/inline_package_repo.rs
deleted file mode 100644
index 1043559..0000000
--- a/crates/mozart-registry/src/repository/inline_package_repo.rs
+++ /dev/null
@@ -1,63 +0,0 @@
-//! [`Repository`] for inline `type: package` repositories.
-//!
-//! Wraps [`crate::inline_package::collect_inline_packages`]. The data is
-//! embedded in `composer.json` so there's no I/O — the repo just filters
-//! its in-memory list by queried name.
-//!
-//! Mirrors `Composer\Repository\PackageRepository` (which extends
-//! `ArrayRepository`). Only the package's own `name` is matched against
-//! queries — `replace`/`provide` targets are NOT advertised here, exactly
-//! like Composer's `ArrayRepository::loadPackages` checks `getName()` only.
-//! Replacement satisfaction happens later in the solver once the replacing
-//! package is loaded transitively.
-
-use super::{LoadResult, NamedPackagistVersion, PackageQuery, Repository};
-use crate::inline_package::{InlinePackage, collect_inline_packages};
-use mozart_core::package::RawRepository;
-
-pub struct InlinePackageRepository {
- id: String,
- packages: Vec<InlinePackage>,
-}
-
-impl InlinePackageRepository {
- /// Build from the raw `repositories` array of a `composer.json`. Non-
- /// `package` entries are ignored.
- pub fn from_repositories(repositories: &[RawRepository]) -> Self {
- Self {
- id: "package".to_string(),
- packages: collect_inline_packages(repositories),
- }
- }
-
- pub fn package_count(&self) -> usize {
- self.packages.len()
- }
-}
-
-#[async_trait::async_trait]
-impl Repository for InlinePackageRepository {
- fn id(&self) -> &str {
- &self.id
- }
-
- async fn load_packages(&self, queries: &[PackageQuery<'_>]) -> anyhow::Result<LoadResult> {
- let mut result = LoadResult::default();
- for query in queries {
- let mut found_any = false;
- for ipkg in &self.packages {
- if ipkg.name == query.name {
- found_any = true;
- result.packages.push(NamedPackagistVersion {
- name: ipkg.name.clone(),
- version: ipkg.version.clone(),
- });
- }
- }
- if found_any {
- result.names_found.push(query.name.to_string());
- }
- }
- Ok(result)
- }
-}
diff --git a/crates/mozart-registry/src/repository/mod.rs b/crates/mozart-registry/src/repository/mod.rs
deleted file mode 100644
index 46f62f0..0000000
--- a/crates/mozart-registry/src/repository/mod.rs
+++ /dev/null
@@ -1,319 +0,0 @@
-//! Repository abstraction over package metadata sources.
-//!
-//! Mirrors Composer's `Composer\Repository\RepositoryInterface::loadPackages`
-//! and `Composer\Repository\RepositoryManager`. The resolver and lockfile
-//! generator query a [`RepositorySet`] instead of calling Packagist directly,
-//! so test code can substitute a set without `PackagistRepository` (mirroring
-//! Composer's `FactoryMock` injecting `repositories: ['packagist' => false]`).
-//!
-//! Concrete implementations live in sibling modules: [`packagist_repo`] for
-//! the live Packagist HTTP repo, [`inline_package_repo`] for `type: package`
-//! entries embedded in `composer.json`, and [`vcs_repo`] for VCS repositories.
-
-use std::collections::BTreeMap;
-
-use crate::advisory::{MatchedAdvisory, PackageInfo};
-use crate::packagist::{PackagistVersion, SearchResult};
-
-pub mod inline_package_repo;
-pub mod packagist_repo;
-pub mod vcs_repo;
-
-/// Search modes for [`Repository::search`].
-///
-/// Mirrors Composer's `RepositoryInterface::SEARCH_FULLTEXT|SEARCH_NAME|SEARCH_VENDOR`
-/// constants (`composer/src/Composer/Repository/RepositoryInterface.php`).
-#[derive(Copy, Clone, Eq, PartialEq, Debug)]
-pub enum SearchMode {
- /// Full-text search over name, description, and keywords (Packagist's
- /// `search.json` API).
- Fulltext,
- /// Match the regex against package names. Tokens are split on whitespace
- /// and joined as `(?:t1|t2|...)`; callers must pre-quote regex metachars.
- Name,
- /// Match the regex against vendor names. Result rows have only `name`
- /// populated (the vendor part).
- Vendor,
-}
-
-/// One name-keyed lookup against a repository.
-///
-/// Matches the `$packageNameMap` argument of Composer's `loadPackages`. The
-/// constraint is informational — repositories may use it to skip versions
-/// that obviously can't match (an optimization), but the resolver still
-/// re-checks every returned version when generating rules.
-#[derive(Debug, Clone)]
-pub struct PackageQuery<'a> {
- pub name: &'a str,
- /// Raw constraint string from `composer.json`, e.g. `"^1.2"`. `None`
- /// when the caller wants every version (transitive exploration).
- pub constraint: Option<&'a str>,
-}
-
-/// Result of a single [`Repository::load_packages`] call.
-///
-/// Mirrors Composer's `['packages' => ..., 'namesFound' => ...]` tuple.
-/// `names_found` lets [`RepositorySet`] short-circuit lower-priority repos
-/// once an upstream repo has authoritatively answered for a name (Composer's
-/// "first repo wins" semantics).
-#[derive(Debug, Default)]
-pub struct LoadResult {
- pub packages: Vec<NamedPackagistVersion>,
- pub names_found: Vec<String>,
-}
-
-/// A `PackagistVersion` paired with the canonical package name it answers
-/// for. Inline `type: package` repos can return packages whose own `name`
-/// field differs from the queried name when they declare `replace`/`provide`,
-/// so callers need both.
-#[derive(Debug, Clone)]
-pub struct NamedPackagistVersion {
- pub name: String,
- pub version: PackagistVersion,
-}
-
-/// A source of package metadata. Mirrors Composer's `RepositoryInterface`.
-///
-/// Implementations should return an empty [`LoadResult`] (not an error) when
-/// they simply don't know a queried name — [`RepositorySet`] uses that to
-/// fall through to the next repo. Reserve `Err` for genuine I/O failures
-/// the caller cannot route around.
-#[async_trait::async_trait]
-pub trait Repository: Send + Sync {
- /// Identifier for diagnostics (`"packagist.org"`, `"package"`, `"vcs:<url>"`).
- fn id(&self) -> &str;
-
- /// Look up every version of every queried name this repo knows about.
- async fn load_packages(&self, queries: &[PackageQuery<'_>]) -> anyhow::Result<LoadResult>;
-
- /// Search this repository.
- ///
- /// The default returns an empty result so repositories that don't
- /// participate in search (e.g. inline / VCS repos that only resolve
- /// known names) can opt out. Mirrors Composer's
- /// `RepositoryInterface::search` whose default behavior on
- /// `ArrayRepository` walks the in-memory list.
- async fn search(
- &self,
- _query: &str,
- _mode: SearchMode,
- _package_type: Option<&str>,
- ) -> anyhow::Result<Vec<SearchResult>> {
- Ok(Vec::new())
- }
-}
-
-/// Ordered list of repositories. Mirrors `Composer\Repository\RepositoryManager`.
-///
-/// `load_packages` queries each repo in order. Once a repo authoritatively
-/// answers for a name (i.e. lists it in `names_found`), later repos are not
-/// asked about that name — matching Composer's first-repo-wins priority.
-pub struct RepositorySet {
- repos: Vec<Box<dyn Repository>>,
-}
-
-impl RepositorySet {
- pub fn new(repos: Vec<Box<dyn Repository>>) -> Self {
- Self { repos }
- }
-
- /// Production default: a single [`packagist_repo::PackagistRepository`]
- /// backed by the given on-disk cache. Mirrors what Composer does when
- /// no `'packagist' => false` entry appears in the merged config.
- pub fn with_packagist(repo_cache: crate::cache::Cache) -> Self {
- Self::new(vec![Box::new(packagist_repo::PackagistRepository::new(
- repo_cache,
- ))])
- }
-
- /// An empty set. Mirrors Composer's `'packagist' => false` test config:
- /// resolution proceeds entirely from packages already in the pool
- /// (eager VCS scan, inline `type: package` repos, the locked repository).
- pub fn empty() -> Self {
- Self::new(Vec::new())
- }
-
- pub fn is_empty(&self) -> bool {
- self.repos.is_empty()
- }
-
- pub fn len(&self) -> usize {
- self.repos.len()
- }
-
- /// Iterate over repositories in priority order.
- pub fn repos(&self) -> impl Iterator<Item = &dyn Repository> {
- self.repos.iter().map(|b| b.as_ref())
- }
-
- /// Query every repo, accumulating packages and tracking which names have
- /// been authoritatively answered. Names already covered by an earlier
- /// repo are dropped from the query passed to later repos.
- pub async fn load_packages(
- &self,
- queries: &[PackageQuery<'_>],
- ) -> anyhow::Result<Vec<NamedPackagistVersion>> {
- use indexmap::IndexSet;
-
- let mut packages: Vec<NamedPackagistVersion> = Vec::new();
- let mut answered: IndexSet<String> = IndexSet::new();
-
- for repo in &self.repos {
- let pending: Vec<PackageQuery<'_>> = queries
- .iter()
- .filter(|q| !answered.contains(q.name))
- .cloned()
- .collect();
- if pending.is_empty() {
- break;
- }
- let result = repo.load_packages(&pending).await?;
- for name in result.names_found {
- answered.insert(name);
- }
- packages.extend(result.packages);
- }
-
- Ok(packages)
- }
-
- /// Fan-out search across every repository, concatenating results in
- /// priority order. Mirrors Composer's
- /// `CompositeRepository::search` which `array_merge`s per-repo results
- /// without de-duplication.
- pub async fn search(
- &self,
- query: &str,
- mode: SearchMode,
- package_type: Option<&str>,
- ) -> anyhow::Result<Vec<SearchResult>> {
- let mut all = Vec::new();
- for repo in &self.repos {
- let mut hits = repo.search(query, mode, package_type).await?;
- all.append(&mut hits);
- }
- Ok(all)
- }
-
- /// Fetch security advisories matching the installed packages, with version filtering.
- ///
- /// Mirrors `Composer\Repository\RepositorySet::getMatchingSecurityAdvisories()`.
- /// Returns the matched advisories (already filtered by installed version) and a list
- /// of unreachable repository URLs. When `ignore_unreachable` is false and a repository
- /// is unreachable, the error is propagated instead.
- pub async fn get_matching_security_advisories(
- &self,
- packages: &[PackageInfo],
- _allow_partial: bool,
- ignore_unreachable: bool,
- ) -> anyhow::Result<(BTreeMap<String, Vec<MatchedAdvisory>>, Vec<String>)> {
- let names: Vec<&str> = packages.iter().map(|p| p.name.as_str()).collect();
-
- let (raw_advisories, unreachable_repos) =
- match crate::packagist::fetch_security_advisories(&names).await {
- Ok(a) => (a, vec![]),
- Err(e) if ignore_unreachable => {
- tracing::warn!("Packagist advisory fetch failed (ignored): {e}");
- let unreachable = vec!["https://packagist.org".to_string()];
- (BTreeMap::new(), unreachable)
- }
- Err(e) => return Err(e),
- };
-
- let matched = version_filter_advisories(&raw_advisories, packages);
-
- Ok((matched, unreachable_repos))
- }
-}
-
-/// Normalize single-pipe OR separators (`|`) in a version constraint string to
-/// double-pipe (`||`) so the constraint parser can handle both forms.
-///
-/// The Packagist security advisories API may return constraints with single `|`
-/// as the OR separator (e.g. `>=1.0,<1.5|>=2.0,<2.3`), but Mozart's
-/// `VersionConstraint::parse` expects `||`.
-///
-/// TODO: fix `mozart_semver::VersionConstraint::parse` to accept single `|` and remove this.
-fn normalize_or_separator(constraint: &str) -> String {
- let bytes = constraint.as_bytes();
- let mut result = String::with_capacity(constraint.len() + 4);
- let mut i = 0;
- while i < bytes.len() {
- if bytes[i] == b'|' {
- if i + 1 < bytes.len() && bytes[i + 1] == b'|' {
- result.push_str("||");
- i += 2;
- } else {
- result.push_str("||");
- i += 1;
- }
- } else {
- result.push(bytes[i] as char);
- i += 1;
- }
- }
- result
-}
-
-/// Filter raw advisories by installed package versions.
-///
-/// Mirrors the version-matching step inside Composer's repository advisory fetch.
-fn version_filter_advisories(
- all_advisories: &BTreeMap<String, Vec<crate::packagist::SecurityAdvisory>>,
- packages: &[PackageInfo],
-) -> BTreeMap<String, Vec<MatchedAdvisory>> {
- let mut result: BTreeMap<String, Vec<MatchedAdvisory>> = BTreeMap::new();
-
- for pkg in packages {
- let Some(advisories) = all_advisories.get(&pkg.name) else {
- continue;
- };
-
- let version_str = pkg
- .version_normalized
- .as_deref()
- .unwrap_or(pkg.version.as_str());
-
- let installed_ver = match mozart_semver::Version::parse(version_str) {
- Ok(v) => v,
- Err(_) => {
- tracing::warn!(
- "Could not parse version {:?} for package {:?}, skipping advisory matching",
- version_str,
- pkg.name
- );
- continue;
- }
- };
-
- let mut matched: Vec<MatchedAdvisory> = Vec::new();
-
- for advisory in advisories {
- let normalized = normalize_or_separator(&advisory.affected_versions);
- let constraint = match mozart_semver::VersionConstraint::parse(&normalized) {
- Ok(c) => c,
- Err(_) => {
- tracing::warn!(
- "Could not parse affected versions {:?} for advisory {:?}, skipping",
- advisory.affected_versions,
- advisory.advisory_id
- );
- continue;
- }
- };
-
- if constraint.matches(&installed_ver) {
- matched.push(MatchedAdvisory {
- advisory: advisory.clone(),
- installed_version: pkg.version.clone(),
- });
- }
- }
-
- if !matched.is_empty() {
- result.insert(pkg.name.clone(), matched);
- }
- }
-
- result
-}
diff --git a/crates/mozart-registry/src/repository/packagist_repo.rs b/crates/mozart-registry/src/repository/packagist_repo.rs
deleted file mode 100644
index fa656b7..0000000
--- a/crates/mozart-registry/src/repository/packagist_repo.rs
+++ /dev/null
@@ -1,121 +0,0 @@
-//! [`Repository`] backed by the live Packagist HTTP API.
-//!
-//! Wraps the existing [`crate::packagist::fetch_package_versions`] so the
-//! resolver sees the same data either through this trait or via the legacy
-//! direct call. Construction takes ownership of the [`Cache`] handle so
-//! callers no longer thread it through `ResolveRequest` / `LockFileGenerationRequest`.
-
-use super::{LoadResult, NamedPackagistVersion, PackageQuery, Repository, SearchMode};
-use crate::cache::Cache;
-use crate::packagist;
-use crate::packagist::SearchResult;
-
-pub struct PackagistRepository {
- id: String,
- cache: Cache,
-}
-
-impl PackagistRepository {
- pub fn new(cache: Cache) -> Self {
- Self {
- id: "packagist.org".to_string(),
- cache,
- }
- }
-}
-
-#[async_trait::async_trait]
-impl Repository for PackagistRepository {
- fn id(&self) -> &str {
- &self.id
- }
-
- async fn load_packages(&self, queries: &[PackageQuery<'_>]) -> anyhow::Result<LoadResult> {
- let mut result = LoadResult::default();
- for query in queries {
- // Errors propagate to the caller. Composer's
- // `ComposerRepository::loadAsyncPackages` distinguishes 404
- // (empty result, no error) from transport failures (exception);
- // Mozart's underlying `fetch_package_versions` doesn't yet make
- // that distinction, so for now both surface as `Err` and the
- // caller decides whether the loop wants to continue (transitive
- // exploration) or abort (seed-time fetch failure).
- let versions = packagist::fetch_package_versions(query.name, &self.cache).await?;
- // A successful fetch counts as "this repo authoritatively knows
- // the name", even if the version list is empty — mirrors
- // Composer's `ArrayRepository::loadPackages` which adds the
- // name to `namesFound` regardless of constraint match.
- result.names_found.push(query.name.to_string());
- for version in versions {
- result.packages.push(NamedPackagistVersion {
- name: query.name.to_string(),
- version,
- });
- }
- }
- Ok(result)
- }
-
- async fn search(
- &self,
- query: &str,
- mode: SearchMode,
- package_type: Option<&str>,
- ) -> anyhow::Result<Vec<SearchResult>> {
- match mode {
- SearchMode::Fulltext => {
- let (results, _total) = packagist::search_packages(query, package_type).await?;
- Ok(results)
- }
- SearchMode::Name => {
- let pattern = build_name_regex(query)?;
- let names = packagist::fetch_package_names(package_type, &self.cache).await?;
- Ok(names
- .into_iter()
- .filter(|name| pattern.is_match(name))
- .map(empty_search_result)
- .collect())
- }
- SearchMode::Vendor => {
- let pattern = build_name_regex(query)?;
- let vendors = packagist::fetch_vendor_names(&self.cache).await?;
- Ok(vendors
- .into_iter()
- .filter(|name| pattern.is_match(name))
- .map(empty_search_result)
- .collect())
- }
- }
- }
-}
-
-/// Build the case-insensitive `(?:t1|t2|...)` regex from whitespace-split
-/// tokens, mirroring Composer's `'{(?:'.implode('|', $matches).')}i'`.
-///
-/// Tokens are joined as-is — callers are expected to have already escaped
-/// regex metacharacters (`SearchCommand` calls `preg_quote`; Mozart calls
-/// `regex::escape` before reaching this point).
-fn build_name_regex(query: &str) -> anyhow::Result<regex::Regex> {
- let tokens: Vec<&str> = query.split_whitespace().collect();
- let body = if tokens.is_empty() {
- String::new()
- } else {
- tokens.join("|")
- };
- Ok(regex::Regex::new(&format!("(?i)(?:{body})"))?)
-}
-
-/// Build a [`SearchResult`] with only `name` populated, mirroring the shape
-/// Composer returns for `SEARCH_NAME` / `SEARCH_VENDOR` modes
-/// (`['name' => $name]`, all other fields `null`).
-fn empty_search_result(name: String) -> SearchResult {
- SearchResult {
- name,
- description: String::new(),
- url: String::new(),
- repository: None,
- downloads: 0,
- favers: 0,
- abandoned: None,
- }
-}
diff --git a/crates/mozart-registry/src/repository/vcs_repo.rs b/crates/mozart-registry/src/repository/vcs_repo.rs
deleted file mode 100644
index fff5f6f..0000000
--- a/crates/mozart-registry/src/repository/vcs_repo.rs
+++ /dev/null
@@ -1,63 +0,0 @@
-//! [`Repository`] for VCS-type repositories.
-//!
-//! Wraps [`crate::vcs_bridge::scan_vcs_repositories`] + [`crate::vcs_bridge::vcs_to_packagist_version`].
-//! Scanning is expensive (clones / fetches), so we do it once at construction
-//! and serve subsequent queries from the in-memory cache. Mirrors
-//! `Composer\Repository\Vcs\VcsRepository`'s lazy-then-memoized behavior.
-
-use super::{LoadResult, NamedPackagistVersion, PackageQuery, Repository};
-use crate::packagist::PackagistVersion;
-use crate::vcs_bridge::{scan_vcs_repositories, vcs_to_packagist_version};
-use mozart_core::package::RawRepository;
-
-pub struct VcsRepository {
- id: String,
- versions: Vec<(String, PackagistVersion)>,
-}
-
-impl VcsRepository {
- /// Scan every VCS-type entry in `repositories` and cache the resulting
- /// versions. Non-VCS entries are ignored. This performs network I/O.
- pub async fn from_repositories(repositories: &[RawRepository]) -> Self {
- let scanned = scan_vcs_repositories(repositories).await;
- let versions = scanned
- .iter()
- .map(|v| (v.name.clone(), vcs_to_packagist_version(v)))
- .collect();
- Self {
- id: "vcs".to_string(),
- versions,
- }
- }
-
- pub fn version_count(&self) -> usize {
- self.versions.len()
- }
-}
-
-#[async_trait::async_trait]
-impl Repository for VcsRepository {
- fn id(&self) -> &str {
- &self.id
- }
-
- async fn load_packages(&self, queries: &[PackageQuery<'_>]) -> anyhow::Result<LoadResult> {
- let mut result = LoadResult::default();
- for query in queries {
- let mut found_any = false;
- for (name, version) in &self.versions {
- if name == query.name {
- found_any = true;
- result.packages.push(NamedPackagistVersion {
- name: name.clone(),
- version: version.clone(),
- });
- }
- }
- if found_any {
- result.names_found.push(query.name.to_string());
- }
- }
- Ok(result)
- }
-}
diff --git a/crates/mozart-registry/src/repository_filter.rs b/crates/mozart-registry/src/repository_filter.rs
deleted file mode 100644
index facbb36..0000000
--- a/crates/mozart-registry/src/repository_filter.rs
+++ /dev/null
@@ -1,136 +0,0 @@
-//! Repository-level package filters (`only`, `exclude`, `canonical`).
-//!
-//! Mirrors `Composer\Repository\FilterRepository`: a wrapper around an
-//! underlying repository that drops packages by name and/or removes the
-//! repo's authoritative claim on the names it serves. We model the same
-//! semantics for inline `type: package` and local `type: composer`
-//! repositories, since the installer fixtures rely on them.
-
-use mozart_core::package::RawRepository;
-use regex::Regex;
-
-/// Resolved filter for a single `repositories[]` entry.
-pub struct RepositoryFilter {
- only: Option<Regex>,
- exclude: Option<Regex>,
- /// `canonical: true` (default) — packages from this repo claim their
- /// names, suppressing lower-priority repos for the same name.
- /// `canonical: false` — packages enter the pool but lower-priority
- /// repos may also answer.
- pub canonical: bool,
-}
-
-impl RepositoryFilter {
- pub fn from_repo(repo: &RawRepository) -> Self {
- Self {
- only: repo.only.as_ref().and_then(|names| build_name_regex(names)),
- exclude: repo
- .exclude
- .as_ref()
- .and_then(|names| build_name_regex(names)),
- canonical: repo.canonical.unwrap_or(true),
- }
- }
-
- /// `true` if `name` may pass through this filter.
- /// Mirrors `FilterRepository::isAllowed`.
- pub fn is_allowed(&self, name: &str) -> bool {
- if let Some(only) = &self.only {
- return only.is_match(name);
- }
- if let Some(exclude) = &self.exclude {
- return !exclude.is_match(name);
- }
- true
- }
-}
-
-/// Build a case-insensitive `^(?:p1|p2|…)$` regex from Composer's pattern
-/// list. Mirrors `BasePackage::packageNamesToRegexp` — `*` becomes `.*`,
-/// every other regex metacharacter is escaped, and the alternation is
-/// anchored to the full string.
-fn build_name_regex(patterns: &[String]) -> Option<Regex> {
- if patterns.is_empty() {
- return None;
- }
- let parts: Vec<String> = patterns.iter().map(|p| pattern_to_regex(p)).collect();
- let joined = parts.join("|");
- Regex::new(&format!(r"(?i)^(?:{joined})$")).ok()
-}
-
-fn pattern_to_regex(pattern: &str) -> String {
- let escaped = regex::escape(pattern);
- // `*` was escaped to `\*` — turn it into `.*` so glob semantics match
- // Composer.
- escaped.replace(r"\*", ".*")
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn repo(
- only: Option<Vec<String>>,
- exclude: Option<Vec<String>>,
- canonical: Option<bool>,
- ) -> RawRepository {
- RawRepository {
- repo_type: "package".to_string(),
- url: None,
- package: None,
- only,
- exclude,
- canonical,
- security_advisories: None,
- }
- }
-
- #[test]
- fn no_filter_allows_all() {
- let f = RepositoryFilter::from_repo(&repo(None, None, None));
- assert!(f.is_allowed("a/a"));
- assert!(f.is_allowed("foo/bar"));
- assert!(f.canonical);
- }
-
- #[test]
- fn only_restricts_to_listed_names() {
- let f = RepositoryFilter::from_repo(&repo(Some(vec!["foo/b".to_string()]), None, None));
- assert!(f.is_allowed("foo/b"));
- assert!(!f.is_allowed("foo/a"));
- }
-
- #[test]
- fn exclude_drops_listed_names() {
- let f = RepositoryFilter::from_repo(&repo(None, Some(vec!["foo/c".to_string()]), None));
- assert!(f.is_allowed("foo/a"));
- assert!(!f.is_allowed("foo/c"));
- }
-
- #[test]
- fn glob_star_expands() {
- let f = RepositoryFilter::from_repo(&repo(Some(vec!["foo/*".to_string()]), None, None));
- assert!(f.is_allowed("foo/a"));
- assert!(f.is_allowed("foo/anything"));
- assert!(!f.is_allowed("bar/a"));
- }
-
- #[test]
- fn match_is_case_insensitive() {
- let f = RepositoryFilter::from_repo(&repo(Some(vec!["Foo/Bar".to_string()]), None, None));
- assert!(f.is_allowed("foo/bar"));
- assert!(f.is_allowed("FOO/BAR"));
- }
-
- #[test]
- fn canonical_default_is_true() {
- let f = RepositoryFilter::from_repo(&repo(None, None, None));
- assert!(f.canonical);
- }
-
- #[test]
- fn canonical_false_honored() {
- let f = RepositoryFilter::from_repo(&repo(None, None, Some(false)));
- assert!(!f.canonical);
- }
-}
diff --git a/crates/mozart-registry/src/resolver.rs b/crates/mozart-registry/src/resolver.rs
deleted file mode 100644
index dc9c6dd..0000000
--- a/crates/mozart-registry/src/resolver.rs
+++ /dev/null
@@ -1,1999 +0,0 @@
-//! Dependency resolver using the SAT solver.
-//!
-//! This module fetches package metadata from Packagist, builds a Pool of all
-//! candidate packages, generates SAT rules, and runs the CDCL solver to find
-//! a compatible set of packages to install.
-
-use indexmap::{IndexMap, IndexSet};
-use regex::{Captures, Regex};
-use std::fmt;
-use std::sync::Arc;
-use std::sync::LazyLock;
-
-use crate::packagist;
-use crate::repository::{PackageQuery, RepositorySet};
-use crate::vcs_bridge;
-use mozart_core::package::{RawRepository, Stability};
-use mozart_sat_resolver::{
- DefaultPolicy, PoolBuilder, PoolLink, PoolPackageInput, RuleSetGenerator, Solver,
- make_pool_links,
-};
-use mozart_semver::{Version, VersionConstraint};
-
-/// Strip a `@stability` suffix from a constraint string and return the
-/// cleaned constraint plus the parsed stability. Mirrors Composer's
-/// `RootPackageLoader::extractStabilityFlags` (single-constraint case):
-/// `"3.2.*@dev"` → (`"3.2.*"`, `Some(Stability::Dev)`).
-pub(crate) fn extract_stability_suffix(constraint: &str) -> (String, Option<Stability>) {
- let trimmed = constraint.trim();
- if let Some(at_pos) = trimmed.rfind('@') {
- let suffix = &trimmed[at_pos + 1..];
- let stability = match suffix.to_lowercase().as_str() {
- "dev" => Some(Stability::Dev),
- "alpha" => Some(Stability::Alpha),
- "beta" => Some(Stability::Beta),
- "rc" => Some(Stability::RC),
- "stable" => Some(Stability::Stable),
- _ => None,
- };
- if let Some(s) = stability {
- let cleaned = trimmed[..at_pos].trim().to_string();
- // An empty constraint left after the strip means "any version" —
- // mirrors Composer's `@dev` shorthand (no version constraint).
- let cleaned = if cleaned.is_empty() {
- "*".to_string()
- } else {
- cleaned
- };
- return (cleaned, Some(s));
- }
- }
- (trimmed.to_string(), None)
-}
-
-/// Mirror Composer's `VersionParser::parseStability` for a single-atom
-/// constraint string (no `@flag` suffix). Returns `Some(stability)` for
-/// recognised non-stable constraints (`dev-foo`, `1.0.x-dev`, `1.0.0-beta1`,
-/// …), `None` for stable or unrecognised forms (in which case
-/// `minimum_stability` already applies).
-///
-/// Composer first strips a trailing `#hash` (handled here), then checks
-/// `dev-` prefix / `-dev` suffix / a `(stab)?\d*` modifier. We follow the
-/// same shape — the regex variant is overkill for inferring a flag.
-pub(crate) fn infer_constraint_stability(constraint: &str) -> Option<Stability> {
- let s = constraint.trim();
- // Strip `#ref` (matches Composer's `parseStability` line 54).
- let s = match s.find('#') {
- Some(p) => &s[..p],
- None => s,
- };
- // Reject multi-atom constraints — extractStabilityFlags inspects each
- // sub-constraint individually but the most common single-atom case is
- // all we need for `dev-foo` / `1.0.x-dev` style root requires.
- if s.contains([' ', ',']) || s.contains("||") {
- return None;
- }
- // Strip a leading comparison operator (`>=1.0-beta` → `1.0-beta`).
- let s = s
- .strip_prefix(">=")
- .or_else(|| s.strip_prefix("<="))
- .or_else(|| s.strip_prefix("!="))
- .or_else(|| s.strip_prefix("=="))
- .or_else(|| s.strip_prefix('>'))
- .or_else(|| s.strip_prefix('<'))
- .or_else(|| s.strip_prefix('='))
- .or_else(|| s.strip_prefix('^'))
- .or_else(|| s.strip_prefix('~'))
- .unwrap_or(s);
- let lower = s.to_lowercase();
- if lower.starts_with("dev-") || lower.ends_with("-dev") {
- return Some(Stability::Dev);
- }
- // Match `<modifier><digits?>` at the end after the last `-`/`@`.
- // Composer uses `{(stable|RC|beta|alpha|dev)([.-]?\d+)?(?:\+.*)?$}`.
- let tail = lower
- .rsplit_once('-')
- .or_else(|| lower.rsplit_once('@'))
- .map(|(_, t)| t)
- .unwrap_or(&lower);
- let tail_word: String = tail.chars().take_while(|c| c.is_alphabetic()).collect();
- match tail_word.as_str() {
- "alpha" | "a" => Some(Stability::Alpha),
- "beta" | "b" => Some(Stability::Beta),
- "rc" => Some(Stability::RC),
- "patch" | "pl" | "p" | "stable" => Some(Stability::Stable),
- _ => None,
- }
-}
-
-/// Determine the `Stability` of a `Version` from its pre_release string.
-pub(crate) fn version_stability(v: &Version) -> Stability {
- match &v.pre_release {
- None => Stability::Stable,
- Some(pre) => {
- let lower = pre.to_lowercase();
- if lower.starts_with("dev") {
- Stability::Dev
- } else if lower.starts_with("alpha") || lower.starts_with('a') {
- Stability::Alpha
- } else if lower.starts_with("beta") || lower.starts_with('b') {
- Stability::Beta
- } else if lower.starts_with("rc") {
- Stability::RC
- } else {
- // patch/pl/p and unknown → stable
- Stability::Stable
- }
- }
- }
-}
-
-/// Parse a Packagist normalized version string like "1.2.3.0", "1.0.0.0-beta1".
-/// Returns `None` for dev branches (dev-master, dev-*, *.x-dev).
-pub(crate) fn parse_normalized(normalized: &str) -> Option<Version> {
- let s = normalized.trim();
-
- // Reject dev branches
- if s.to_lowercase().starts_with("dev-") {
- return None;
- }
- // Reject *.x-dev style
- if s.to_lowercase().ends_with("-dev") && s.contains(".x") {
- return None;
- }
- // Packagist uses 9999999.9999999.9999999.9999999 for dev branches
- if s.starts_with("9999999") {
- return None;
- }
-
- Version::parse(s).ok()
-}
-
-/// Parse a branch alias target like "2.x-dev" or "1.0.x-dev" into a `Version` with dev pre-release.
-fn parse_branch_alias_target(alias_target: &str) -> Option<Version> {
- let s = alias_target.trim().to_lowercase();
- if !s.ends_with("-dev") {
- return None;
- }
- let base = &s[..s.len() - 4];
- let base = base.trim_end_matches(".x");
- let parts: Vec<&str> = base.split('.').collect();
- let major: u64 = parts.first().and_then(|p| p.parse().ok())?;
- let minor: u64 = parts.get(1).and_then(|p| p.parse().ok()).unwrap_or(0);
- let patch: u64 = parts.get(2).and_then(|p| p.parse().ok()).unwrap_or(0);
- let build: u64 = parts.get(3).and_then(|p| p.parse().ok()).unwrap_or(0);
- Some(Version {
- major,
- minor,
- patch,
- build,
- pre_release: Some("dev".to_string()),
- is_dev_branch: false,
- dev_branch_name: None,
- })
-}
-
-/// Mirror Composer's `VersionParser::parseNumericAliasPrefix`: returns true
-/// when the input is a numeric branch like `1.2-dev` / `1.2.3-dev` /
-/// `1.2.x-dev` (i.e. the prefix is suitable for version comparison).
-/// Non-numeric branches like `dev-main` / `dev-feature/x` return false.
-fn has_numeric_alias_prefix(branch: &str) -> bool {
- let lower = branch.trim().to_lowercase();
- let lower = lower.strip_prefix('v').unwrap_or(&lower);
- let Some(base) = lower.strip_suffix("-dev") else {
- return false;
- };
- let base = base.strip_suffix(".x").unwrap_or(base);
- if base.is_empty() {
- return false;
- }
- // Allow only digit segments separated by `.`.
- base.split('.')
- .all(|seg| !seg.is_empty() && seg.chars().all(|c| c.is_ascii_digit()))
-}
-
-/// Mirror Composer's `VersionParser::normalizeBranch` for branch-alias
-/// targets: turn a string like `"3.2.x-dev"` into the canonical numeric form
-/// `"3.2.9999999.9999999-dev"`. Returns `None` if the input is not a numeric
-/// branch (i.e. cannot be expanded to a four-segment numeric version).
-///
-/// Composer's flow for an `extra.branch-alias` value:
-/// 1. Strip the trailing `-dev`.
-/// 2. Pad missing segments with `.x`.
-/// 3. Replace each `x` with `9999999`.
-/// 4. Re-append `-dev`.
-///
-/// This is the form Composer's `Locker::lockPackages` writes into the
-/// `aliases` block of `composer.lock` and the form `Pool` indexes for
-/// constraint matching, so Mozart needs to use it too.
-pub fn normalize_branch_alias_target(alias_target: &str) -> Option<String> {
- let trimmed = alias_target.trim();
- let lower = trimmed.to_lowercase();
- let base = lower.strip_suffix("-dev")?;
- // Strip leading v/V before normalizing, mirroring Composer's regex
- let base = base.strip_prefix('v').unwrap_or(base);
- let mut segments: Vec<String> = Vec::with_capacity(4);
- for seg in base.split('.') {
- if seg == "x" || seg == "X" || seg == "*" {
- segments.push("x".to_string());
- } else if seg.chars().all(|c| c.is_ascii_digit()) && !seg.is_empty() {
- segments.push(seg.to_string());
- } else {
- return None;
- }
- }
- if segments.is_empty() {
- return None;
- }
- while segments.len() < 4 {
- segments.push("x".to_string());
- }
- let expanded: Vec<String> = segments
- .into_iter()
- .map(|s| if s == "x" { "9999999".to_string() } else { s })
- .collect();
- Some(format!("{}-dev", expanded.join(".")))
-}
-
-/// Mirror Composer's `VersionParser::normalize` for the values that appear on
-/// either side of an `as` clause (`require: "1.0.x-dev as dev-master"`).
-///
-/// Composer sends both sides through `normalize`, which:
-/// - Maps bare `master` / `trunk` / `default` to the `dev-` prefixed form
-/// (`master` → `dev-master`) for BC with Composer 1, then returns
-/// `dev-NAME` unchanged. Inline `type: package` entries for these branches
-/// land in the pool under the same literal `dev-NAME` form, so root aliases
-/// declared with the matching atom must point at that same string.
-/// - Strips a leading `v` and treats numeric `*.x-dev` branches via
-/// `normalizeBranch` (= `normalize_branch_alias_target`).
-/// - Leaves other `dev-NAME` strings as `dev-NAME`.
-fn normalize_root_alias_atom(atom: &str) -> Option<String> {
- let trimmed = atom.trim();
- if trimmed.is_empty() {
- return None;
- }
- let lower = trimmed.to_lowercase();
- // Composer's normalize: bare `master` / `trunk` / `default` get the
- // `dev-` prefix prepended for BC, then fall through to the `dev-`
- // branch below.
- let with_prefix = if matches!(lower.as_str(), "master" | "trunk" | "default") {
- format!("dev-{lower}")
- } else {
- trimmed.to_string()
- };
- let lower_pref = with_prefix.to_lowercase();
- if let Some(rest) = lower_pref.strip_prefix("dev-") {
- return Some(format!("dev-{rest}"));
- }
- if let Some(numeric) = normalize_branch_alias_target(&with_prefix) {
- return Some(numeric);
- }
- // Stable numeric atoms (e.g. `1.1.1`) need to come back in the
- // four-segment form `Version::Display` produces, so the alias
- // matcher's `input.version != alias.version_normalized` check lines
- // up with pool inputs (which carry the 4-segment normalized form).
- // Returning the raw input here would silently never match.
- parse_normalized(&with_prefix).map(|v| v.to_string())
-}
-
-/// A root-level alias declared via the `require: "X as Y"` shorthand on the
-/// root composer.json. Mirrors Composer's
-/// `RootPackageLoader::extractAliases` entries: when the resolver loads a
-/// package matching `(package, version_normalized)`, it materializes an extra
-/// alias entry exposing the same install under `alias_normalized`/`alias`.
-#[derive(Debug, Clone)]
-struct RootAlias {
- package: String,
- /// Normalized form of the LEFT-hand side (the actual constraint).
- version_normalized: String,
- /// Pretty form of the RIGHT-hand side (the alias to expose).
- alias: String,
- /// Normalized form of the RIGHT-hand side.
- alias_normalized: String,
-}
-
-/// Composer's `RootPackageLoader::extractAliases` regex. Finds every
-/// `<left> as <right>` clause inside a constraint string, including those
-/// nested in OR / AND expressions (e.g. `1.*||dev-feature-foo as 1.0.2||^2`
-/// or `dev-feature-foo, dev-feature-foo as 1.0.2`). The optional `#hex`
-/// suffix on the LEFT atom is captured but excluded from the alias target,
-/// matching `RootPackageLoader::extractReferences` which records refs out
-/// of band.
-static ALIAS_CLAUSE_RE: LazyLock<Regex> = LazyLock::new(|| {
- Regex::new(
- r"(?P<sep>^|\| *|, *)(?P<left>[^,\s#|]+)(?:#[^ ]+)? +as +(?P<right>[^,\s|]+)(?P<after>$| *\|| *,)",
- )
- .expect("alias clause regex compiles")
-});
-
-/// Strip every `<X> as <Y>` clause from a constraint string. Returns the
-/// cleaned constraint plus an entry per alias. Mirrors Composer's
-/// `VersionParser::parseConstraint` `as`-strip combined with
-/// `RootPackageLoader::extractAliases`: the constraint passed to the
-/// resolver is the LEFT side of each atom, and a separate alias entry is
-/// recorded for each RIGHT side so `RootAliasPackage`-style virtual
-/// packages can be materialized later. A trailing `#hex` reference
-/// (`dev-main#abcd`) on the LEFT atom is also stripped from the cleaned
-/// constraint — `RootPackageLoader::extractReferences` records the hash
-/// out of band for the post-resolve `setSourceDistReferences` pass.
-fn strip_root_alias_clause(constraint: &str) -> (String, Vec<(String, String)>) {
- let trimmed = constraint.trim();
- let mut aliases: Vec<(String, String)> = Vec::new();
- let cleaned = ALIAS_CLAUSE_RE.replace_all(trimmed, |caps: &Captures<'_>| {
- let sep = caps.name("sep").map_or("", |m| m.as_str());
- let left = caps.name("left").map_or("", |m| m.as_str());
- let right = caps.name("right").map_or("", |m| m.as_str());
- let after = caps.name("after").map_or("", |m| m.as_str());
- let cleaned_left = strip_inline_reference(left);
- aliases.push((cleaned_left.clone(), right.to_string()));
- format!("{sep}{cleaned_left}{after}")
- });
- if aliases.is_empty() {
- return (strip_inline_reference(trimmed), aliases);
- }
- (cleaned.into_owned(), aliases)
-}
-
-/// Drop a trailing `#hex` reference from a single-atom `dev-*` / `*-dev`
-/// constraint, matching Composer's `'{^[^,\s@]+?#([a-f0-9]+)$}'` guard.
-/// Lockfile generation records the reference separately via
-/// `extract_root_references` and applies it after resolution, so the SAT
-/// constraint itself only needs the bare branch name.
-fn strip_inline_reference(s: &str) -> String {
- if let Some((head, hash)) = s.rsplit_once('#')
- && !hash.is_empty()
- && hash.chars().all(|c| c.is_ascii_hexdigit())
- && !head.contains([' ', '\t', ',', '@'])
- && (head.to_lowercase().starts_with("dev-") || head.to_lowercase().ends_with("-dev"))
- {
- return head.to_string();
- }
- s.to_string()
-}
-
-/// A normalized package name (lowercase, e.g. "monolog/monolog").
-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
-pub struct PackageName(pub String);
-
-impl fmt::Display for PackageName {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- f.write_str(&self.0)
- }
-}
-
-impl PackageName {
- pub const ROOT: &'static str = "__root__";
-
- pub fn root() -> Self {
- PackageName(Self::ROOT.to_string())
- }
-
- /// Returns true if this is a platform package (php, ext-*, lib-*, composer pseudo packages).
- pub fn is_platform(&self) -> bool {
- mozart_core::platform::is_platform_package(&self.0)
- }
-
- /// Returns true if this is the virtual root package.
- pub fn is_root(&self) -> bool {
- self.0 == Self::ROOT
- }
-}
-
-/// Platform package configuration.
-/// Maps package names to version strings (normalized, e.g. "8.1.0.0").
-pub struct PlatformConfig {
- pub packages: IndexMap<String, String>,
-}
-
-impl Default for PlatformConfig {
- fn default() -> Self {
- Self::new()
- }
-}
-
-impl PlatformConfig {
- /// Detect platform packages from the local PHP installation.
- pub fn new() -> Self {
- let detected = mozart_core::platform::detect_platform();
- let mut packages = IndexMap::new();
- for pkg in detected {
- packages.insert(pkg.name, pkg.version);
- }
- Self { packages }
- }
-
- /// Apply `config.platform` overrides on top of the detected packages.
- ///
- /// Mirrors `Composer\Repository\PlatformRepository::__construct`'s
- /// `$overrides` handling: each override either replaces a detected
- /// package version or adds a virtual one (e.g. `ext-dummy`). A `false`
- /// value disables the package, removing it from the platform.
- pub fn apply_overrides(&mut self, overrides: &serde_json::Value) {
- let Some(obj) = overrides.as_object() else {
- return;
- };
- for (name, value) in obj {
- let key = name.to_lowercase();
- if value.as_bool() == Some(false) {
- self.packages.shift_remove(&key);
- continue;
- }
- if let Some(s) = value.as_str() {
- self.packages.insert(key, s.to_string());
- }
- }
- }
-
- /// Parse platform packages into `Version` values.
- pub fn to_versions(&self) -> IndexMap<String, Version> {
- self.packages
- .iter()
- .filter_map(|(name, version_str)| {
- Version::parse(version_str).ok().map(|v| (name.clone(), v))
- })
- .collect()
- }
-}
-
-/// Error returned by the public `resolve()` function.
-#[derive(Debug)]
-pub enum ResolveError {
- /// No solution exists. Contains a human-readable explanation.
- NoSolution(String),
- /// Error parsing a version constraint.
- ConstraintParseError(String, String, String), // (package, constraint, error)
- /// Error fetching dependency metadata.
- DependencyFetchError(String),
- /// Internal error.
- Internal(String),
-}
-
-impl fmt::Display for ResolveError {
- fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
- match self {
- Self::NoSolution(report) => {
- writeln!(
- f,
- "Your requirements could not be resolved to an installable set of packages."
- )?;
- writeln!(f)?;
- write!(f, "{}", report)
- }
- Self::ConstraintParseError(pkg, constraint, err) => {
- write!(
- f,
- "Could not parse version constraint '{}' for package {}: {}",
- constraint, pkg, err
- )
- }
- Self::DependencyFetchError(msg) => write!(f, "{}", msg),
- Self::Internal(msg) => write!(f, "Internal resolver error: {}", msg),
- }
- }
-}
-
-impl std::error::Error for ResolveError {}
-
-/// Check if a version passes the minimum-stability filter for the given package.
-fn passes_stability_filter(
- package_name: &str,
- version: &Version,
- minimum_stability: Stability,
- stability_flags: &IndexMap<String, Stability>,
-) -> bool {
- let min_stability = stability_flags
- .get(package_name)
- .copied()
- .unwrap_or(minimum_stability);
- let vs = version_stability(version);
- vs <= min_stability
-}
-
-/// Check whether a platform dependency should be skipped.
-fn should_skip_platform_dep(
- dep_name: &str,
- ignore_platform_reqs: bool,
- ignore_platform_req_list: &[String],
-) -> bool {
- if !PackageName(dep_name.to_string()).is_platform() {
- return false;
- }
- if ignore_platform_reqs {
- return true;
- }
- ignore_platform_req_list
- .iter()
- .any(|p| mozart_core::matches_wildcard(dep_name, p))
-}
-
-/// Mirrors `Composer\Package\CompletePackage::isAbandoned`: any
-/// `abandoned: true` or `abandoned: "<replacement>"` value is truthy.
-/// `abandoned: false` and an empty string both register as not-abandoned.
-fn is_abandoned(pv: &packagist::PackagistVersion) -> bool {
- match &pv.abandoned {
- None => false,
- Some(serde_json::Value::Null) => false,
- Some(serde_json::Value::Bool(b)) => *b,
- Some(serde_json::Value::String(s)) => !s.is_empty(),
- Some(_) => true,
- }
-}
-
-/// Convert a Packagist version entry to PoolPackageInput(s).
-/// May return multiple entries if branch aliases are present.
-fn packagist_to_pool_inputs(
- package_name: &str,
- pv: &packagist::PackagistVersion,
- minimum_stability: Stability,
- stability_flags: &IndexMap<String, Stability>,
-) -> Vec<PoolPackageInput> {
- let mut results = Vec::new();
-
- let make_input = |version_str: &str,
- version_normalized: &str,
- is_alias_of: Option<String>|
- -> PoolPackageInput {
- PoolPackageInput {
- name: package_name.to_string(),
- version: version_normalized.to_string(),
- pretty_version: version_str.to_string(),
- requires: make_pool_links(
- package_name,
- version_normalized,
- &pv.require
- .iter()
- .map(|(k, v)| (k.clone(), v.clone()))
- .collect::<Vec<_>>(),
- ),
- replaces: make_pool_links(
- package_name,
- version_normalized,
- &pv.replace
- .iter()
- .map(|(k, v)| (k.clone(), v.clone()))
- .collect::<Vec<_>>(),
- ),
- provides: make_pool_links(
- package_name,
- version_normalized,
- &pv.provide
- .iter()
- .map(|(k, v)| (k.clone(), v.clone()))
- .collect::<Vec<_>>(),
- ),
- conflicts: make_pool_links(
- package_name,
- version_normalized,
- &pv.conflict
- .iter()
- .map(|(k, v)| (k.clone(), v.clone()))
- .collect::<Vec<_>>(),
- ),
- is_fixed: false,
- is_alias_of,
- }
- };
-
- match parse_normalized(&pv.version_normalized) {
- Some(v) => {
- if passes_stability_filter(package_name, &v, minimum_stability, stability_flags) {
- results.push(make_input(&pv.version, &pv.version_normalized, None));
- }
- }
- None => {
- // Dev branch — emit the original entry (so the alias has a target
- // to point at) and one alias entry per matching `extra.branch-alias`.
- // Mirrors Composer's `ArrayRepository::addPackage` which adds the
- // base package and then calls `createAliasPackage` for each
- // branch-alias declaration on it.
- let original_passes = passes_stability_filter(
- package_name,
- &Version {
- major: 0,
- minor: 0,
- patch: 0,
- build: 0,
- pre_release: Some("dev".to_string()),
- is_dev_branch: true,
- dev_branch_name: None,
- },
- minimum_stability,
- stability_flags,
- );
- if !original_passes {
- return results;
- }
- results.push(make_input(&pv.version, &pv.version_normalized, None));
-
- let aliases = pv.branch_aliases();
- let mut emitted_explicit_alias = false;
- for (branch, alias_target) in &aliases {
- if branch.to_lowercase() != pv.version.to_lowercase() {
- continue;
- }
- if parse_branch_alias_target(alias_target).is_none() {
- continue;
- }
- let Some(alias_normalized) = normalize_branch_alias_target(alias_target) else {
- continue;
- };
- results.push(make_input(
- alias_target,
- &alias_normalized,
- Some(pv.version_normalized.clone()),
- ));
- emitted_explicit_alias = true;
- }
-
- // Mirror Composer's `ArrayLoader::getBranchAlias`: when a
- // `dev-` package carries `default-branch: true` and the version
- // has no numeric prefix (i.e. it isn't already a `1.0.x-dev` form
- // that would be its own alias), synthesize the `9999999-dev`
- // alias so root constraints like `dev-main` pick up a default
- // branch surfaced as `9999999-dev` in the lock + trace output.
- //
- // `getBranchAlias` returns the *first* matching branch-alias when
- // one exists — i.e. an explicit `branch-alias` entry takes
- // precedence over the `default-branch` synthetic one. Skip the
- // synthetic alias when an explicit one has already been emitted
- // for this version.
- if pv.default_branch
- && !emitted_explicit_alias
- && !has_numeric_alias_prefix(&pv.version)
- {
- let default_alias = "9999999-dev";
- let default_normalized = "9999999.9999999.9999999.9999999-dev";
- let already_present = results
- .iter()
- .any(|r| r.version == default_normalized && r.name == package_name);
- if !already_present {
- results.push(make_input(
- default_alias,
- default_normalized,
- Some(pv.version_normalized.clone()),
- ));
- }
- }
- }
- }
-
- results
-}
-
-/// Input to the resolver.
-pub struct ResolveRequest {
- /// Root package name from composer.json "name" field (e.g. "laravel/laravel").
- /// Used in error messages. Falls back to `__root__` if empty.
- pub root_name: String,
- /// Root package version from composer.json "version" field. `None` falls
- /// back to Composer's `RootPackage::DEFAULT_PRETTY_VERSION` (1.0.0+no-version-set).
- /// Used to seed a fixed pool entry for the root so transitive requires
- /// pointing at the root (legal circular dependencies via an intermediate
- /// package) can be satisfied.
- pub root_version: Option<String>,
- /// Dependencies from composer.json "require" section.
- pub require: Vec<(String, String)>,
- /// Dependencies from composer.json "require-dev" section.
- pub require_dev: Vec<(String, String)>,
- /// Whether to include require-dev in resolution.
- pub include_dev: bool,
- /// Minimum stability from composer.json.
- pub minimum_stability: Stability,
- /// Per-package stability overrides.
- pub stability_flags: IndexMap<String, Stability>,
- /// Whether prefer-stable is enabled.
- pub prefer_stable: bool,
- /// Whether prefer-lowest is enabled.
- pub prefer_lowest: bool,
- /// Platform package configuration.
- pub platform: PlatformConfig,
- /// Ignore all platform requirements.
- pub ignore_platform_reqs: bool,
- /// Specific platform requirements to ignore.
- pub ignore_platform_req_list: Vec<String>,
- /// Repository set used to fetch package metadata. Mirrors Composer's
- /// `RepositoryManager`. Production builders construct this with a single
- /// `PackagistRepository`; in-process test harnesses can construct one
- /// without any HTTP-backed repos to mimic Composer's
- /// `'packagist' => false` test config.
- pub repositories: Arc<RepositorySet>,
- /// Temporary version constraint overrides (from --with flag).
- /// Maps package name (lowercase) to constraint string.
- pub temporary_constraints: IndexMap<String, String>,
- /// VCS / inline-package repository entries from composer.json's
- /// `repositories` section, used by the eager VCS scan and inline-package
- /// preload that still live in `resolve()` (Step B follow-up will move
- /// these through `RepositorySet` too).
- pub raw_repositories: Vec<RawRepository>,
- /// Root composer.json's `provide` map (target → constraint string). Drives
- /// the self-fulfilling-rule check in the SAT generator: when a root
- /// `require` names something the root itself `provide`s with a matching
- /// constraint, no install-one-of rule is emitted, mirroring Composer's
- /// `RuleSetGenerator::createRequireRule` self-fulfillment branch.
- pub root_provide: IndexMap<String, String>,
- /// Root composer.json's `replace` map. Same role as `root_provide` for the
- /// `replace` link: a replaced target counts as fulfilled by the root.
- pub root_replace: IndexMap<String, String>,
- /// Root composer.json's `conflict` map (target → constraint). Composer's
- /// `RootPackageRepository` carries these onto the in-pool root package
- /// entry; the SAT generator then forbids any candidate matching the
- /// constraint, so a root `conflict` blocks both direct selection of the
- /// targeted version and any alias / replace / provide that would resolve
- /// to it.
- pub root_conflict: IndexMap<String, String>,
- /// Lowercase names of packages that are pinned to their lock-file version
- /// for this resolve (a partial update where the package is not in the
- /// update list). Mirrors the `propagateUpdate=false` branch of Composer's
- /// `PoolBuilder::loadPackage`: locked-only packages do not pick up
- /// `require: "X as Y"` root aliases. Empty for installs and full updates,
- /// where every package can take aliases as usual.
- pub locked_package_names: IndexSet<String>,
- /// Full data of packages pinned to their lock-file version (a partial
- /// update). Each entry is added to the pool as a fixed entry, mirroring
- /// Composer's `Request::lockPackage` + `PoolBuilder::buildPool`'s
- /// `getFixedOrLockedPackages` loop: a locked-only package's pretty/normalized
- /// version, requires, replaces, provides and conflicts all enter the pool
- /// at exactly one version, so the SAT solver cannot pick a different
- /// version (whether directly or via another package's `replace`). Empty
- /// for installs and full updates.
- pub locked_packages: Vec<LockedPackageInfo>,
- /// When true, drop abandoned packages (`abandoned: true|<replacement>`)
- /// from the pool before solving. Mirrors Composer's
- /// `audit.block-abandoned` config feeding into
- /// `SecurityAdvisoryPoolFilter`: the resolver simply never sees these
- /// versions, so a root requirement that only matches abandoned candidates
- /// fails with the standard "could not be resolved" error.
- pub block_abandoned: bool,
- /// Pretty form of the root's `extra.branch-alias` target when the root's
- /// version matches a key in that map (e.g. `dev-master` → `2.0-dev`).
- /// Mirrors Composer's `RootAliasPackage`: an extra alias entry is added
- /// to the pool exposing the root under the numeric branch-alias version,
- /// with `replace`/`provide`/`conflict` links extended to advertise the
- /// alias's version for any link originally written as `self.version`.
- /// `None` when the root carries no matching `branch-alias` entry.
- pub root_branch_alias: Option<String>,
- /// `name → normalized version` map fed to the policy's preferred-version
- /// override. Used by `update --minimal-changes` so the solver only moves
- /// a package when a constraint actually forces a different version.
- /// Empty for a normal full update.
- pub preferred_versions: IndexMap<String, String>,
- /// When true, drop versions the repositories advertise as covered by an
- /// active security advisory before solving. Mirrors Composer's
- /// `SecurityAdvisoryPoolFilter` under `config.audit.block-insecure: true`.
- pub block_insecure: bool,
-}
-
-/// Full data for a lock-pinned package, used in partial updates. Carried on
-/// `ResolveRequest::locked_packages` and turned into a fixed pool entry
-/// inside `resolve()`. Mirrors what Composer's `PoolBuilder` reads off a
-/// `BasePackage` retrieved from the locked repository.
-pub struct LockedPackageInfo {
- pub name: String,
- /// Pretty (display) version, e.g. "1.2.3".
- pub pretty_version: String,
- /// Normalized version, e.g. "1.2.3.0".
- pub version_normalized: String,
- pub requires: Vec<(String, String)>,
- pub replaces: Vec<(String, String)>,
- pub provides: Vec<(String, String)>,
- pub conflicts: Vec<(String, String)>,
- /// Branch-alias entries to surface alongside the base locked package, as
- /// `(pretty, normalized)` pairs. Mirrors what
- /// `Composer\Package\Locker::getLockedRepository` constructs from
- /// `extra.branch-alias`: a `dev-master` locked package with branch alias
- /// `2.1.x-dev` needs to expose itself under both versions so root
- /// constraints like `~2.1` still resolve on a partial update.
- pub branch_aliases: Vec<(String, String)>,
-}
-
-/// A single package in the resolution output.
-pub struct ResolvedPackage {
- pub name: String,
- /// Human-readable version string (e.g. "1.2.3").
- pub version: String,
- /// Normalized version string (e.g. "1.2.3.0").
- pub version_normalized: String,
- /// True if the resolved version is a dev/pre-release version.
- pub is_dev: bool,
- /// When `Some`, this entry is an `AliasPackage` rather than a real
- /// install target. The value is the target's normalized version, used
- /// by lock-file generation to populate the `aliases[]` block (and by
- /// the installer to emit `Marking ... as installed, alias of ...`
- /// trace lines). Real packages have `alias_of: None`.
- pub alias_of_normalized: Option<String>,
-}
-
-/// Run the dependency resolver.
-///
-/// Returns a list of resolved packages (excluding root and platform packages),
-/// or a human-readable error.
-pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, ResolveError> {
- // 1. Build root requirements
- let mut root_requires: IndexMap<String, Option<String>> = IndexMap::new();
- // Per-package stability overrides extracted from `@dev`/`@beta`/etc.
- // suffixes on root constraints. Mirrors Composer's
- // `RootPackageLoader::extractStabilityFlags`. Merged on top of the
- // request's caller-supplied flags (which today are usually empty).
- let mut stability_flags: IndexMap<String, Stability> = request.stability_flags.clone();
- // Root-level aliases extracted from `require: "X as Y"`. Mirrors
- // Composer's `RootPackageLoader::extractAliases`: each entry adds a new
- // alias package to the pool exposing the matched real package under the
- // RIGHT-hand version label.
- let mut root_aliases: Vec<RootAlias> = Vec::new();
-
- let minimum_stability = request.minimum_stability;
- let mut insert_root_require = |name: &str, constraint: &str| {
- // Strip every `<X> as <Y>` clause first (mirrors Composer's
- // `parseConstraint` strip + `extractAliases` capture). The cleaned
- // constraint feeds the resolver; each alias is recorded for a second
- // pool-population pass once real packages are in. Complex constraints
- // (`1.*||dev-feature-foo as 1.0.2||^2`) yield one alias entry plus a
- // constraint with the ` as <Y>` segment removed in place.
- let (constraint_no_as, alias_pieces) = strip_root_alias_clause(constraint);
- for (target_atom, alias_atom) in alias_pieces {
- let (Some(target_normalized), Some(alias_normalized)) = (
- normalize_root_alias_atom(&target_atom),
- normalize_root_alias_atom(&alias_atom),
- ) else {
- continue;
- };
- root_aliases.push(RootAlias {
- package: name.to_lowercase(),
- version_normalized: target_normalized,
- alias: alias_atom,
- alias_normalized,
- });
- }
- let (clean, stability) = extract_stability_suffix(&constraint_no_as);
- let lower = name.to_lowercase();
- if let Some(s) = stability {
- let entry = stability_flags.entry(lower.clone()).or_insert(s);
- if (*entry as u8) > (s as u8) {
- *entry = s;
- }
- } else if let Some(inferred) = infer_constraint_stability(&clean) {
- // Mirrors `RootPackageLoader::extractStabilityFlags` second loop:
- // when a single-atom constraint like `dev-main` or `1.0.x-dev`
- // implies a non-stable stability and no explicit `@flag` was
- // given, raise that package's stability ceiling so the pool
- // accepts it. Only applied when the inferred level is *more*
- // permissive than `minimum_stability` and any existing flag.
- if (inferred as u8) > (minimum_stability as u8) {
- let entry = stability_flags.entry(lower.clone()).or_insert(inferred);
- if (*entry as u8) < (inferred as u8) {
- *entry = inferred;
- }
- }
- }
- root_requires.insert(lower, Some(clean));
- };
-
- for (name, constraint) in &request.require {
- if should_skip_platform_dep(
- name,
- request.ignore_platform_reqs,
- &request.ignore_platform_req_list,
- ) {
- continue;
- }
- insert_root_require(name, constraint);
- }
-
- if request.include_dev {
- for (name, constraint) in &request.require_dev {
- if should_skip_platform_dep(
- name,
- request.ignore_platform_reqs,
- &request.ignore_platform_req_list,
- ) {
- continue;
- }
- insert_root_require(name, constraint);
- }
- }
-
- // Apply temporary constraints (from --with flag or inline shorthand).
- // These override existing root constraints or add new ones for transitive deps.
- for (name, constraint) in &request.temporary_constraints {
- insert_root_require(name, constraint);
- }
-
- // 2. Build pool, generate rules, and solve
- let mut builder = PoolBuilder::new();
-
- // Set up ignore list for platform requirements
- let mut ignore_set: IndexSet<String> = IndexSet::new();
- for name in &request.ignore_platform_req_list {
- ignore_set.insert(name.clone());
- }
- builder.set_ignore_platform_reqs(ignore_set.clone());
- builder.set_ignore_all_platform_reqs(request.ignore_platform_reqs);
-
- // Add platform packages as fixed entries
- let platform_config = request.platform.to_versions();
- let mut fixed_packages_by_name: IndexMap<String, u32> = IndexMap::new();
- for (name, version) in &platform_config {
- if should_skip_platform_dep(
- name,
- request.ignore_platform_reqs,
- &request.ignore_platform_req_list,
- ) {
- continue;
- }
- let input = PoolPackageInput {
- name: name.clone(),
- version: version.to_string(),
- pretty_version: version.to_string(),
- requires: vec![],
- replaces: vec![],
- provides: vec![],
- conflicts: vec![],
- is_fixed: true,
- is_alias_of: None,
- };
- builder.add_package(input);
- }
-
- // Mirror Composer's `RootPackageRepository`: put the root package itself
- // in the pool as a fixed entry so transitive requires pointing at the
- // root (legal circular dependencies via an intermediate package) can
- // resolve. Composer clears the root's `require` / `require-dev` on this
- // copy because the root requires are already plumbed through the
- // rule generator's root-require path; carrying them here too would
- // emit duplicate rules. Provide / replace links survive, so virtual
- // packages declared on the root keep working for transitive consumers.
- let root_name_lower = request.root_name.to_lowercase();
- if !root_name_lower.is_empty() {
- let (root_pretty, root_normalized) = match request.root_version.as_deref() {
- Some(v) if !v.is_empty() => (v.to_string(), v.to_string()),
- _ => ("1.0.0+no-version-set".to_string(), "1.0.0.0".to_string()),
- };
- // Resolve `self.version` against the root's normalized version when
- // building base links. Mirrors Composer's `ArrayLoader::createLink`:
- // a `self.version` constraint is parsed against the declaring package's
- // pretty version (here, the root's). The base entry only carries this
- // resolved form; any branch-alias entry below extends each base link
- // with an extra link tagged at the alias's version, matching
- // `AliasPackage::replaceSelfVersionDependencies`.
- let make_base_links = |raw: &IndexMap<String, String>| -> Vec<PoolLink> {
- raw.iter()
- .map(|(target, constraint)| PoolLink {
- target: target.to_lowercase(),
- constraint: if constraint.trim() == "self.version" {
- root_normalized.clone()
- } else {
- constraint.clone()
- },
- source: root_name_lower.clone(),
- })
- .collect()
- };
- let base_replaces = make_base_links(&request.root_replace);
- let base_provides = make_base_links(&request.root_provide);
- let base_conflicts = make_base_links(&request.root_conflict);
- let root_input = PoolPackageInput {
- name: root_name_lower.clone(),
- version: root_normalized.clone(),
- pretty_version: root_pretty.clone(),
- requires: vec![],
- replaces: base_replaces.clone(),
- provides: base_provides.clone(),
- conflicts: base_conflicts.clone(),
- is_fixed: true,
- is_alias_of: None,
- };
- builder.add_package(root_input);
-
- // Materialize a branch-alias entry for the root when `extra.branch-alias`
- // mapped this version to a numeric alias (e.g. dev-master → 2.0-dev).
- // Mirrors Composer's `RootAliasPackage`: the alias copies the base's
- // resolved replace/provide/conflict links and then ADDS one more link
- // per `self.version` original, this time pinned at the alias's own
- // version. So a transitive `provided/dependency 2.*` lookup can be
- // satisfied through the alias even though the base resolved
- // `self.version` to a non-matching dev version.
- if let Some(alias_pretty) = &request.root_branch_alias
- && let Some(alias_normalized) = normalize_branch_alias_target(alias_pretty)
- {
- let extra_self_version_links = |raw: &IndexMap<String, String>| -> Vec<PoolLink> {
- raw.iter()
- .filter(|(_, constraint)| constraint.trim() == "self.version")
- .map(|(target, _)| PoolLink {
- target: target.to_lowercase(),
- constraint: alias_normalized.clone(),
- source: root_name_lower.clone(),
- })
- .collect()
- };
- let mut alias_replaces = base_replaces.clone();
- alias_replaces.extend(extra_self_version_links(&request.root_replace));
- let mut alias_provides = base_provides.clone();
- alias_provides.extend(extra_self_version_links(&request.root_provide));
- let mut alias_conflicts = base_conflicts.clone();
- alias_conflicts.extend(extra_self_version_links(&request.root_conflict));
- builder.add_package(PoolPackageInput {
- name: root_name_lower.clone(),
- version: alias_normalized,
- pretty_version: alias_pretty.clone(),
- requires: vec![],
- replaces: alias_replaces,
- provides: alias_provides,
- conflicts: alias_conflicts,
- is_fixed: false,
- is_alias_of: Some(root_normalized),
- });
- }
- }
-
- // Add lock-pinned packages as pool entries (partial-update case).
- //
- // Mirrors Composer's `PoolBuilder::buildPool` flow: every locked package
- // not in the `updateAllowList` is added through `Request::lockPackage`,
- // then re-entered into the pool via the `getFixedOrLockedPackages`
- // loop. Crucially, a *locked* package is NOT a *fixed* package
- // (Request.php:89-98): the SAT solver does not force its installation,
- // so a locked package whose root require has been removed will simply
- // drop out of the result. The locked entry's purpose is to constrain
- // the pool to *only* the locked version for that name — every other
- // version is filtered out below — so other packages cannot pick a
- // different version (whether directly, or via `replace`, which would
- // otherwise let an upgraded replacer silently drop the dependency).
- //
- // Pre-check: a locked package whose version is rejected by the
- // current minimum-stability (composer.json may have tightened
- // stability or dropped a `stability-flags` entry the lock relied on)
- // cannot be reused as a fixed pool entry. Mirrors what Composer
- // surfaces via `Pool::isUnacceptableFixedOrLockedPackage` +
- // `Problem::getPrettyString`: bail with the "fixed to <v> (lock file
- // version) but that version is rejected by your minimum-stability"
- // pointer so the user knows to add the package to the update
- // arguments (or use `--with-all-dependencies`).
- {
- let mut rejected: Vec<String> = Vec::new();
- for locked in &request.locked_packages {
- let Ok(v) = Version::parse(&locked.version_normalized) else {
- continue;
- };
- if !passes_stability_filter(
- &locked.name,
- &v,
- request.minimum_stability,
- &stability_flags,
- ) {
- rejected.push(format!(
- " - {} is fixed to {} (lock file version) by a partial update but that version is rejected by your minimum-stability. Make sure you list it as an argument for the update command.",
- locked.name, locked.pretty_version
- ));
- }
- }
- if !rejected.is_empty() {
- let report = rejected
- .into_iter()
- .enumerate()
- .map(|(i, msg)| format!(" Problem {}\n{}", i + 1, msg))
- .collect::<Vec<_>>()
- .join("\n");
- return Err(ResolveError::NoSolution(report));
- }
- }
-
- // Build a map first so the filter below knows which (name, version)
- // pairs are the only allowed entries for locked names. Each entry holds
- // the locked normalized version plus any branch-alias normalized
- // versions Composer's `Locker::getLockedRepository` would expose
- // alongside the base. Without the alias entries, an inline-package or
- // VCS source providing the same `dev-master` + alias as the lock would
- // have its alias filtered out, leaving root constraints like `~2.1` —
- // which can only match the alias version, not the raw `dev-master` —
- // unsatisfiable on a partial update.
- let locked_name_to_versions: IndexMap<String, Vec<String>> = request
- .locked_packages
- .iter()
- .map(|p| {
- let mut versions = vec![p.version_normalized.clone()];
- for (_, alias_normalized) in &p.branch_aliases {
- versions.push(alias_normalized.clone());
- }
- (p.name.to_lowercase(), versions)
- })
- .collect();
- let lock_filter_allows = |name: &str, version: &str| -> bool {
- match locked_name_to_versions.get(&name.to_lowercase()) {
- Some(locked_versions) => locked_versions.iter().any(|v| v == version),
- None => true,
- }
- };
- for locked in &request.locked_packages {
- let locked_name_lower = locked.name.to_lowercase();
- let input = PoolPackageInput {
- name: locked_name_lower.clone(),
- version: locked.version_normalized.clone(),
- pretty_version: locked.pretty_version.clone(),
- requires: make_pool_links(
- &locked_name_lower,
- &locked.version_normalized,
- &locked.requires,
- ),
- replaces: make_pool_links(
- &locked_name_lower,
- &locked.version_normalized,
- &locked.replaces,
- ),
- provides: make_pool_links(
- &locked_name_lower,
- &locked.version_normalized,
- &locked.provides,
- ),
- conflicts: make_pool_links(
- &locked_name_lower,
- &locked.version_normalized,
- &locked.conflicts,
- ),
- is_fixed: false,
- is_alias_of: None,
- };
- builder.add_package(input);
- // Also expose each `extra.branch-alias` entry as a separate pool
- // package, mirroring `Composer\Package\Locker::getLockedRepository`
- // (which calls `ArrayLoader::load`, which materializes the
- // branch-alias via `getBranchAlias`). Without this, a `dev-master`
- // locked package with branch alias `2.2.x-dev` is only visible
- // under `dev-master` in the pool, so root requires like `~2.1`
- // see no candidate and the resolver fails on a partial update.
- for (alias_pretty, alias_normalized) in &locked.branch_aliases {
- builder.add_package(PoolPackageInput {
- name: locked_name_lower.clone(),
- version: alias_normalized.clone(),
- pretty_version: alias_pretty.clone(),
- requires: make_pool_links(&locked_name_lower, alias_normalized, &locked.requires),
- replaces: make_pool_links(&locked_name_lower, alias_normalized, &locked.replaces),
- provides: make_pool_links(&locked_name_lower, alias_normalized, &locked.provides),
- conflicts: make_pool_links(&locked_name_lower, alias_normalized, &locked.conflicts),
- is_fixed: false,
- is_alias_of: Some(locked.version_normalized.clone()),
- });
- }
- }
-
- // Scan VCS repositories and collect packages from them
- let vcs_packages = vcs_bridge::scan_vcs_repositories(&request.raw_repositories).await;
- let mut vcs_package_names: IndexSet<String> = IndexSet::new();
- for vpkg in &vcs_packages {
- vcs_package_names.insert(vpkg.name.clone());
- }
-
- // Add VCS packages to the pool
- for vpkg in &vcs_packages {
- let inputs =
- vcs_bridge::vcs_to_pool_inputs(vpkg, request.minimum_stability, &stability_flags);
- for input in inputs {
- if !lock_filter_allows(&input.name, &input.version) {
- continue;
- }
- builder.add_package(input);
- }
- }
-
- // Collect inline `type: package` repositories. These don't require any
- // network fetch, but we mirror Composer's `PackageRepository` (which
- // extends `ArrayRepository`) and only emit packages whose own `name`
- // matches a queried name — `replace`/`provide` targets do NOT pull in
- // their replacers eagerly. So we build a name-indexed lookup and add
- // entries to the builder on demand from the seed/transitive loops.
- // Loading every inline package up front would let the SAT resolver
- // pick a replacer that nothing required by name (e.g.
- // `broken-deps-do-not-replace.test`), where Composer would correctly
- // surface the broken dependency instead.
- let inline_packages = crate::inline_package::collect_inline_packages(&request.raw_repositories);
- let mut inline_packages_by_name: IndexMap<String, Vec<&crate::inline_package::InlinePackage>> =
- IndexMap::new();
- for ipkg in &inline_packages {
- inline_packages_by_name
- .entry(ipkg.name.clone())
- .or_default()
- .push(ipkg);
- }
- // Build the security-advisory filter once. Mirrors Composer's
- // `SecurityAdvisoryPoolFilter`: when `block-insecure` is on, every
- // version listed by a repository's `security-advisories` is removed
- // from the pool before solving.
- let security_advisories =
- crate::inline_package::collect_security_advisories(&request.raw_repositories);
- let security_blocks_version = |name: &str, version_normalized: &str| -> bool {
- if !request.block_insecure {
- return false;
- }
- let Some(advisories) = security_advisories.get(&name.to_lowercase()) else {
- return false;
- };
- let Ok(parsed) = Version::parse(version_normalized) else {
- return false;
- };
- advisories.iter().any(|adv| {
- VersionConstraint::parse(&adv.affected_versions)
- .map(|c| c.matches(&parsed))
- .unwrap_or(false)
- })
- };
- // Mirrors Composer's `PoolBuilder::markPackageNameForLoading`: a root
- // require's constraint caps every load of that name. Transitive deps that
- // would otherwise pull in an out-of-range version (e.g. `foo/requirer`
- // requires `foo/original 1.0.0` while the root pinned it at `3.0.0`) are
- // silently filtered down to the root-required range, so the pool never
- // sees a candidate the root forbids. Without this, providers that satisfy
- // the root require can coexist with the actual package at the wrong
- // version, masking what should be a conflict.
- //
- // The match check considers both the base version and any branch-alias
- // entries it expands to — mirrors `ArrayRepository::loadPackages`, which
- // pulls in the base whenever any of its aliases satisfies the constraint
- // (and vice-versa). Skipping the base when only an alias matches would
- // leave the alias dangling.
- let add_inline_for = |name: &str,
- load_constraint: Option<&VersionConstraint>,
- builder: &mut PoolBuilder|
- -> bool {
- let Some(packages) = inline_packages_by_name.get(name) else {
- return false;
- };
- for ipkg in packages {
- if request.block_abandoned && is_abandoned(&ipkg.version) {
- continue;
- }
- if security_blocks_version(&ipkg.name, &ipkg.version.version_normalized) {
- continue;
- }
- let inputs = packagist_to_pool_inputs(
- &ipkg.name,
- &ipkg.version,
- request.minimum_stability,
- &stability_flags,
- );
- if let Some(c) = load_constraint {
- let any_matches = inputs.iter().any(|input| {
- Version::parse(&input.version)
- .map(|v| c.matches(&v))
- .unwrap_or(false)
- });
- if !any_matches {
- continue;
- }
- }
- for input in inputs {
- if !lock_filter_allows(&input.name, &input.version) {
- continue;
- }
- builder.add_package(input);
- }
- }
- true
- };
-
- // Pre-parse root-require constraints once. Reused for every name lookup
- // in the seed + transitive loops below.
- let root_require_constraints: IndexMap<String, VersionConstraint> = root_requires
- .iter()
- .filter_map(|(name, c)| {
- c.as_deref()
- .and_then(|s| VersionConstraint::parse(s).ok())
- .map(|vc| (name.clone(), vc))
- })
- .collect();
-
- // Collect packages from `type: composer` repositories with file:// URLs.
- // The harness rewrites `file://foobar` to `file:///abs/path` before this
- // call so the read can be a plain `std::fs::read_to_string`. Same idea
- // as inline packages — they bypass the RepositorySet and go straight
- // into the pool, with names recorded so Packagist loops skip them.
- let composer_repo_packages =
- crate::composer_repo::collect_composer_packages(&request.raw_repositories);
- let mut composer_repo_names: IndexSet<String> = IndexSet::new();
- for cpkg in &composer_repo_packages {
- composer_repo_names.insert(cpkg.name.clone());
- if request.block_abandoned && is_abandoned(&cpkg.version) {
- continue;
- }
- let inputs = packagist_to_pool_inputs(
- &cpkg.name,
- &cpkg.version,
- request.minimum_stability,
- &stability_flags,
- );
- for input in inputs {
- if !lock_filter_allows(&input.name, &input.version) {
- continue;
- }
- builder.add_package(input);
- }
- }
-
- // The repository set is supplied by the caller. Today production
- // builders pass a single-Packagist set; in-process tests can pass a
- // set with no HTTP-backed repos. VCS and inline packages above are
- // still preloaded directly, and their names go into the skip lists so
- // we don't double-load them through this set.
- let repo_set: &RepositorySet = &request.repositories;
-
- // Seed the builder with packages for root requirements. Inline
- // `type: package` matches are added directly via the name-indexed
- // lookup; everything else falls through to the network-backed
- // repository set.
- let seed_names: Vec<String> = root_requires
- .keys()
- .filter(|name| !PackageName((*name).clone()).is_platform())
- .filter(|name| !vcs_package_names.contains(*name) && !composer_repo_names.contains(*name))
- .cloned()
- .collect();
- let mut seed_queries: Vec<PackageQuery<'_>> = Vec::new();
- for name in &seed_names {
- let load_constraint = root_require_constraints.get(name);
- if add_inline_for(name.as_str(), load_constraint, &mut builder) {
- continue;
- }
- seed_queries.push(PackageQuery {
- name: name.as_str(),
- constraint: root_requires.get(name).and_then(|c| c.as_deref()),
- });
- }
- let seed_results = repo_set
- .load_packages(&seed_queries)
- .await
- .map_err(|e| ResolveError::DependencyFetchError(e.to_string()))?;
- for r in &seed_results {
- if request.block_abandoned && is_abandoned(&r.version) {
- continue;
- }
- let inputs = packagist_to_pool_inputs(
- &r.name,
- &r.version,
- request.minimum_stability,
- &stability_flags,
- );
- for input in inputs {
- if !lock_filter_allows(&input.name, &input.version) {
- continue;
- }
- builder.add_package(input);
- }
- }
-
- // Explore transitive dependencies.
- while let Some(name) = builder.next_pending() {
- if PackageName(name.clone()).is_platform() {
- continue;
- }
-
- // Skip packages already provided by VCS or `type: composer` repos
- // (those still get eager-loaded above). Inline `type: package`
- // matches are loaded on demand by name, mirroring Composer's
- // ArrayRepository semantics.
- if vcs_package_names.contains(&name) || composer_repo_names.contains(&name) {
- continue;
- }
- let load_constraint = root_require_constraints.get(&name);
- if add_inline_for(name.as_str(), load_constraint, &mut builder) {
- continue;
- }
-
- let queries = [PackageQuery {
- name: name.as_str(),
- constraint: root_requires.get(&name).and_then(|c| c.as_deref()),
- }];
- let results = match repo_set.load_packages(&queries).await {
- Ok(v) => v,
- Err(_) => {
- // Virtual/meta packages (e.g. "psr/http-client-implementation")
- // don't exist on Packagist. They are resolved via provides/replaces
- // from other packages already in the pool.
- continue;
- }
- };
- for r in &results {
- if request.block_abandoned && is_abandoned(&r.version) {
- continue;
- }
- let inputs = packagist_to_pool_inputs(
- &r.name,
- &r.version,
- request.minimum_stability,
- &request.stability_flags,
- );
- for input in inputs {
- if !lock_filter_allows(&input.name, &input.version) {
- continue;
- }
- builder.add_package(input);
- }
- }
- }
-
- // Second pass: materialize root aliases (`require: "X as Y"`).
- //
- // Mirrors Composer's `PoolBuilder::loadPackage` post-load step: when a
- // package whose `(name, version)` matches a `rootAliases` entry is added,
- // an extra `AliasPackage` exposing that install under
- // `(alias_normalized, alias)` is appended to the pool. When the matched
- // input is already an alias (e.g. an `extra.branch-alias` entry from
- // `packagist_to_pool_inputs`), Composer follows `getAliasOf()` to the
- // base package — we replicate by carrying the input's `is_alias_of`
- // value forward, so the new alias points straight at the real package
- // rather than chaining through the intermediate alias.
- if !root_aliases.is_empty() {
- let mut new_aliases: Vec<PoolPackageInput> = Vec::new();
- for input in builder.inputs() {
- // Skip alias creation for packages locked to their lock-file
- // version (partial update where this package wasn't requested).
- // Mirrors Composer's `propagateUpdate=false` skip in
- // `PoolBuilder::loadPackage`.
- if request
- .locked_package_names
- .contains(&input.name.to_lowercase())
- {
- continue;
- }
- for alias in &root_aliases {
- if input.name.to_lowercase() != alias.package {
- continue;
- }
- if input.version != alias.version_normalized {
- continue;
- }
- let target_normalized = input
- .is_alias_of
- .clone()
- .unwrap_or_else(|| input.version.clone());
- // Extend `self.version`-derived `replace` / `provide` /
- // `conflict` links with an extra entry pinned at the
- // alias's own version. Mirrors Composer's
- // `AliasPackage::replaceSelfVersionDependencies`: a base
- // link whose constraint matches the base's own version
- // (the resolved form of `self.version`) is duplicated
- // under the alias at the alias's version, so a transitive
- // require like `a/aliased-replaced ^4.0` can match the
- // alias even when the base is at a non-matching dev
- // version. Without this, the alias's replace map keeps
- // the base's `dev-next` constraint and the requirement
- // never sees a numeric provider.
- let alias_extra_self_links = |links: &[PoolLink]| -> Vec<PoolLink> {
- links
- .iter()
- .filter(|l| l.constraint == input.version)
- .map(|l| PoolLink {
- target: l.target.clone(),
- constraint: alias.alias_normalized.clone(),
- source: l.source.clone(),
- })
- .collect()
- };
- let mut alias_replaces = input.replaces.clone();
- alias_replaces.extend(alias_extra_self_links(&input.replaces));
- let mut alias_provides = input.provides.clone();
- alias_provides.extend(alias_extra_self_links(&input.provides));
- let mut alias_conflicts = input.conflicts.clone();
- alias_conflicts.extend(alias_extra_self_links(&input.conflicts));
- new_aliases.push(PoolPackageInput {
- name: input.name.clone(),
- version: alias.alias_normalized.clone(),
- pretty_version: alias.alias.clone(),
- requires: input.requires.clone(),
- replaces: alias_replaces,
- provides: alias_provides,
- conflicts: alias_conflicts,
- is_fixed: false,
- is_alias_of: Some(target_normalized),
- });
- }
- }
- for alias_input in new_aliases {
- builder.add_package(alias_input);
- }
- }
-
- // Build the pool
- let mut pool = builder.build();
- // Collect fixed package IDs
- let mut fixed_ids: Vec<u32> = Vec::new();
- for pkg in pool.packages() {
- if pkg.is_fixed {
- fixed_ids.push(pkg.id);
- fixed_packages_by_name.insert(pkg.name.clone(), pkg.id);
- }
- }
-
- // Generate rules
- let mut generator = RuleSetGenerator::new(&mut pool);
- generator.set_ignore_platform_reqs(ignore_set);
- generator.set_ignore_all_platform_reqs(request.ignore_platform_reqs);
- let (rules, missing_root_requires) = generator.generate(
- &root_requires,
- &fixed_ids,
- &request.root_provide,
- &request.root_replace,
- );
-
- // Mirror Composer's `Solver::checkForRootRequireProblems`: a root require
- // with no providers in the pool yields no SAT rule, so the solver would
- // succeed with an empty plan. Surface it as an unresolvable problem
- // instead, matching Composer's exit code 2 behaviour.
- if !missing_root_requires.is_empty() {
- let problems: Vec<String> = missing_root_requires
- .iter()
- .map(|(name, constraint)| match constraint.as_deref() {
- Some(c) if !c.is_empty() => format!(
- " - Root composer.json requires {name} {c}, no matching package found."
- ),
- _ => {
- format!(" - Root composer.json requires {name}, no matching package found.")
- }
- })
- .collect();
- let report = problems
- .into_iter()
- .enumerate()
- .map(|(i, msg)| format!(" Problem {}\n{}", i + 1, msg))
- .collect::<Vec<_>>()
- .join("\n");
- return Err(ResolveError::NoSolution(report));
- }
-
- // Create policy and solve. When `preferred_versions` is non-empty (the
- // `--minimal-changes` flow) feed it through the policy so the locked
- // version wins over the regular highest/lowest pick whenever a candidate
- // matches it. Mirrors Composer's
- // `Installer::createPolicy` minimal-update branch.
- let policy = if request.preferred_versions.is_empty() {
- DefaultPolicy::new(request.prefer_stable, request.prefer_lowest)
- } else {
- DefaultPolicy::with_preferred(
- request.prefer_stable,
- request.prefer_lowest,
- request.preferred_versions.clone(),
- )
- };
- let fixed_set: IndexSet<u32> = fixed_ids.into_iter().collect();
- let solver = Solver::new(rules, &pool, policy, fixed_set);
-
- match solver.solve() {
- Ok(result) => {
- let mut resolved = Vec::new();
- for pkg_id in result.installed {
- let pkg = pool.package_by_id(pkg_id);
-
- // Skip platform packages from output
- if PackageName(pkg.name.clone()).is_platform() {
- continue;
- }
-
- // Skip the root package itself. It's in the pool as a fixed
- // entry only so transitive requires pointing back at it
- // can resolve; it must not appear in the lock file or
- // operations list. Mirrors Composer's `LockTransaction`
- // which discards fixed packages from the result.
- if !root_name_lower.is_empty() && pkg.name == root_name_lower {
- continue;
- }
-
- let is_dev = if let Ok(v) = Version::parse(&pkg.version) {
- version_stability(&v) == Stability::Dev
- } else {
- false
- };
-
- let alias_of_normalized = pkg
- .is_alias_of
- .map(|tid| pool.package_by_id(tid).version.clone());
-
- resolved.push(ResolvedPackage {
- name: pkg.name.clone(),
- version: pkg.pretty_version.clone(),
- version_normalized: pkg.version.clone(),
- is_dev,
- alias_of_normalized,
- });
- }
- Ok(resolved)
- }
- Err(e) => Err(ResolveError::NoSolution(e.to_string())),
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- fn v(major: u64, minor: u64, patch: u64, build: u64) -> Version {
- Version {
- major,
- minor,
- patch,
- build,
- pre_release: None,
- is_dev_branch: false,
- dev_branch_name: None,
- }
- }
-
- fn v_pre(major: u64, minor: u64, patch: u64, build: u64, pre: &str) -> Version {
- Version {
- major,
- minor,
- patch,
- build,
- pre_release: Some(pre.to_string()),
- is_dev_branch: false,
- dev_branch_name: None,
- }
- }
-
- #[test]
- fn test_parse_normalized_stable() {
- let ver = parse_normalized("1.2.3.0").unwrap();
- assert_eq!((ver.major, ver.minor, ver.patch, ver.build), (1, 2, 3, 0));
- assert_eq!(ver.pre_release, None);
- }
-
- #[test]
- fn test_parse_normalized_beta() {
- let ver = parse_normalized("1.0.0.0-beta1").unwrap();
- assert_eq!(ver.major, 1);
- assert_eq!(ver.pre_release, Some("beta1".to_string()));
- }
-
- #[test]
- fn test_parse_normalized_rc() {
- let ver = parse_normalized("2.0.0.0-RC3").unwrap();
- assert_eq!(ver.major, 2);
- assert_eq!(ver.pre_release, Some("RC3".to_string()));
- }
-
- #[test]
- fn test_parse_normalized_alpha() {
- let ver = parse_normalized("1.0.0.0-alpha2").unwrap();
- assert_eq!(ver.pre_release, Some("alpha2".to_string()));
- }
-
- #[test]
- fn test_parse_normalized_dev() {
- let ver = parse_normalized("1.0.0.0-dev").unwrap();
- assert_eq!(ver.pre_release, Some("dev".to_string()));
- }
-
- #[test]
- fn test_parse_normalized_dev_branch() {
- let ver = parse_normalized("dev-master");
- assert!(
- ver.is_none(),
- "dev-master should not parse as normalized version"
- );
- }
-
- #[test]
- fn test_parse_normalized_x_dev() {
- let ver = parse_normalized("dev-feature/foo");
- assert!(ver.is_none());
- }
-
- #[test]
- fn test_parse_normalized_9999999_dev() {
- let ver = parse_normalized("9999999.9999999.9999999.9999999-dev");
- assert!(ver.is_none());
- }
-
- #[test]
- fn test_parse_normalized_large_version() {
- let ver = parse_normalized("20031129").unwrap();
- assert_eq!(ver.major, 20031129);
- assert_eq!(ver.pre_release, None);
- }
-
- #[test]
- fn test_version_ordering_stable() {
- let v1 = parse_normalized("2.0.0.0").unwrap();
- let v2 = parse_normalized("1.0.0.0").unwrap();
- assert!(v1 > v2);
- }
-
- #[test]
- fn test_version_ordering_stability() {
- let stable = parse_normalized("1.0.0.0").unwrap();
- let rc = parse_normalized("1.0.0.0-RC1").unwrap();
- let beta = parse_normalized("1.0.0.0-beta1").unwrap();
- let alpha = parse_normalized("1.0.0.0-alpha1").unwrap();
- let dev = parse_normalized("1.0.0.0-dev").unwrap();
- assert!(stable > rc);
- assert!(rc > beta);
- assert!(beta > alpha);
- assert!(alpha > dev);
- }
-
- #[test]
- fn test_version_ordering_pre_number() {
- let beta2 = parse_normalized("1.0.0.0-beta2").unwrap();
- let beta1 = parse_normalized("1.0.0.0-beta1").unwrap();
- assert!(beta2 > beta1);
- }
-
- #[test]
- fn test_version_display() {
- let stable = v(1, 2, 3, 0);
- assert_eq!(format!("{stable}"), "1.2.3.0");
-
- let beta1 = v_pre(1, 0, 0, 0, "beta1");
- assert_eq!(format!("{beta1}"), "1.0.0.0-beta1");
-
- let rc2 = v_pre(2, 0, 0, 0, "RC2");
- assert_eq!(format!("{rc2}"), "2.0.0.0-RC2");
-
- let dev = v_pre(1, 0, 0, 0, "dev");
- assert_eq!(format!("{dev}"), "1.0.0.0-dev");
- }
-
- #[test]
- fn test_version_stability_fn() {
- assert_eq!(version_stability(&v(1, 0, 0, 0)), Stability::Stable);
- assert_eq!(version_stability(&v_pre(1, 0, 0, 0, "RC1")), Stability::RC);
- assert_eq!(
- version_stability(&v_pre(1, 0, 0, 0, "beta1")),
- Stability::Beta
- );
- assert_eq!(
- version_stability(&v_pre(1, 0, 0, 0, "alpha1")),
- Stability::Alpha
- );
- assert_eq!(version_stability(&v_pre(1, 0, 0, 0, "dev")), Stability::Dev);
- assert_eq!(
- version_stability(&v_pre(1, 0, 0, 0, "patch1")),
- Stability::Stable
- );
- }
-
- #[test]
- fn test_package_name_is_platform() {
- assert!(PackageName("php".to_string()).is_platform());
- assert!(PackageName("ext-json".to_string()).is_platform());
- assert!(PackageName("lib-curl".to_string()).is_platform());
- assert!(PackageName("composer".to_string()).is_platform());
- assert!(PackageName("composer-plugin-api".to_string()).is_platform());
- assert!(PackageName("composer-runtime-api".to_string()).is_platform());
- assert!(!PackageName("monolog/monolog".to_string()).is_platform());
- assert!(!PackageName("vendor/package".to_string()).is_platform());
- }
-
- #[test]
- fn test_package_name_is_root() {
- assert!(PackageName::root().is_root());
- assert!(!PackageName("monolog/monolog".to_string()).is_root());
- }
-
- #[test]
- fn test_stability_filter() {
- let stable_v = v(1, 0, 0, 0);
- let alpha_v = v_pre(1, 1, 0, 0, "alpha1");
- let beta_v = v_pre(1, 0, 0, 0, "beta1");
- let rc_v = v_pre(1, 0, 0, 0, "RC1");
- let dev_v = v_pre(1, 0, 0, 0, "dev");
-
- let flags = IndexMap::new();
-
- assert!(passes_stability_filter(
- "foo/foo",
- &stable_v,
- Stability::Stable,
- &flags
- ));
- assert!(!passes_stability_filter(
- "foo/foo",
- &alpha_v,
- Stability::Stable,
- &flags
- ));
- assert!(!passes_stability_filter(
- "foo/foo",
- &beta_v,
- Stability::Stable,
- &flags
- ));
- assert!(!passes_stability_filter(
- "foo/foo",
- &rc_v,
- Stability::Stable,
- &flags
- ));
- assert!(!passes_stability_filter(
- "foo/foo",
- &dev_v,
- Stability::Stable,
- &flags
- ));
- }
-
- #[test]
- fn test_stability_filter_beta() {
- let stable_v = v(1, 0, 0, 0);
- let beta_v = v_pre(1, 0, 0, 0, "beta1");
- let alpha_v = v_pre(1, 0, 0, 0, "alpha1");
- let dev_v = v_pre(1, 0, 0, 0, "dev");
-
- let flags = IndexMap::new();
-
- assert!(passes_stability_filter(
- "foo/foo",
- &stable_v,
- Stability::Beta,
- &flags
- ));
- assert!(passes_stability_filter(
- "foo/foo",
- &beta_v,
- Stability::Beta,
- &flags
- ));
- assert!(!passes_stability_filter(
- "foo/foo",
- &alpha_v,
- Stability::Beta,
- &flags
- ));
- assert!(!passes_stability_filter(
- "foo/foo",
- &dev_v,
- Stability::Beta,
- &flags
- ));
- }
-
- #[test]
- fn test_stability_filter_dev() {
- let dev_v = v_pre(1, 0, 0, 0, "dev");
- let flags = IndexMap::new();
- assert!(passes_stability_filter(
- "foo/foo",
- &dev_v,
- Stability::Dev,
- &flags
- ));
- }
-
- #[test]
- fn test_skip_platform_dep() {
- assert!(should_skip_platform_dep("php", true, &[]));
- assert!(should_skip_platform_dep("ext-json", true, &[]));
- assert!(!should_skip_platform_dep("monolog/monolog", true, &[]));
- }
-
- #[test]
- fn test_skip_specific_platform_dep() {
- let list = vec!["ext-intl".to_string()];
- assert!(should_skip_platform_dep("ext-intl", false, &list));
- assert!(!should_skip_platform_dep("ext-json", false, &list));
- assert!(!should_skip_platform_dep("php", false, &list));
- assert!(!should_skip_platform_dep("monolog/monolog", false, &list));
- }
-
- #[test]
- fn test_parse_branch_alias_target_x_dev() {
- let ver = parse_branch_alias_target("2.x-dev").unwrap();
- assert_eq!((ver.major, ver.minor, ver.patch, ver.build), (2, 0, 0, 0));
- assert_eq!(ver.pre_release, Some("dev".to_string()));
- }
-
- #[test]
- fn test_parse_branch_alias_target_minor_x_dev() {
- let ver = parse_branch_alias_target("1.5.x-dev").unwrap();
- assert_eq!((ver.major, ver.minor, ver.patch), (1, 5, 0));
- assert_eq!(ver.pre_release, Some("dev".to_string()));
- }
-
- #[test]
- fn test_parse_branch_alias_target_patch_x_dev() {
- let ver = parse_branch_alias_target("1.0.2.x-dev").unwrap();
- assert_eq!((ver.major, ver.minor, ver.patch), (1, 0, 2));
- assert_eq!(ver.pre_release, Some("dev".to_string()));
- }
-
- #[test]
- fn test_parse_branch_alias_target_invalid() {
- assert!(parse_branch_alias_target("dev-master").is_none());
- assert!(parse_branch_alias_target("2.0.0").is_none());
- assert!(parse_branch_alias_target("").is_none());
- }
-
- #[test]
- fn test_sat_resolve_simple_offline() {
- use mozart_sat_resolver::*;
-
- let mut pool = Pool::new(
- vec![
- PoolPackageInput {
- name: "foo/foo".to_string(),
- version: "1.0.0.0".to_string(),
- pretty_version: "1.0.0".to_string(),
- requires: vec![PoolLink {
- target: "bar/bar".to_string(),
- constraint: "^2.0".to_string(),
- source: "foo/foo".to_string(),
- }],
- replaces: vec![],
- provides: vec![],
- conflicts: vec![],
- is_fixed: false,
- is_alias_of: None,
- },
- PoolPackageInput {
- name: "bar/bar".to_string(),
- version: "2.0.0.0".to_string(),
- pretty_version: "2.0.0".to_string(),
- requires: vec![],
- replaces: vec![],
- provides: vec![],
- conflicts: vec![],
- is_fixed: false,
- is_alias_of: None,
- },
- ],
- vec![],
- );
-
- let mut requires = IndexMap::new();
- requires.insert("foo/foo".to_string(), Some("^1.0".to_string()));
-
- let generator = RuleSetGenerator::new(&mut pool);
- let (rules, _) = generator.generate(&requires, &[], &IndexMap::new(), &IndexMap::new());
-
- let policy = DefaultPolicy::default();
- let solver = Solver::new(rules, &pool, policy, IndexSet::new());
- let result = solver.solve().unwrap();
-
- // Should install foo/foo (id=1) and bar/bar (id=2)
- assert!(result.installed.contains(&1));
- assert!(result.installed.contains(&2));
- }
-
- #[tokio::test]
- #[ignore]
- async fn test_resolve_monolog_e2e() {
- use crate::cache::Cache;
- let request = ResolveRequest {
- root_name: String::new(),
- root_version: None,
- require: vec![("monolog/monolog".to_string(), "^3.0".to_string())],
- require_dev: vec![],
- include_dev: false,
- minimum_stability: Stability::Stable,
- stability_flags: IndexMap::new(),
- prefer_stable: true,
- prefer_lowest: false,
- platform: PlatformConfig::new(),
- ignore_platform_reqs: false,
- ignore_platform_req_list: vec![],
- repositories: Arc::new(RepositorySet::with_packagist(Cache::new(
- std::env::temp_dir().join("mozart-test-cache"),
- false,
- ))),
- temporary_constraints: IndexMap::new(),
- raw_repositories: vec![],
- root_provide: IndexMap::new(),
- root_replace: IndexMap::new(),
- root_conflict: IndexMap::new(),
- locked_package_names: IndexSet::new(),
- locked_packages: Vec::new(),
- block_abandoned: false,
- root_branch_alias: None,
- preferred_versions: IndexMap::new(),
- block_insecure: false,
- };
-
- let result = resolve(&request).await;
- match result {
- Ok(packages) => {
- println!("Resolved {} packages:", packages.len());
- for pkg in &packages {
- println!(" {} {}", pkg.name, pkg.version);
- }
- assert!(!packages.is_empty());
- assert!(packages.iter().any(|p| p.name == "monolog/monolog"));
- }
- Err(e) => panic!("Resolution failed: {}", e),
- }
- }
-}
diff --git a/crates/mozart-registry/src/vcs_bridge.rs b/crates/mozart-registry/src/vcs_bridge.rs
deleted file mode 100644
index aae3d87..0000000
--- a/crates/mozart-registry/src/vcs_bridge.rs
+++ /dev/null
@@ -1,218 +0,0 @@
-//! Bridge between `mozart-vcs` and `mozart-registry`.
-//!
-//! Scans VCS repositories defined in composer.json and converts
-//! discovered package versions into pool inputs for the SAT resolver.
-
-use indexmap::IndexMap;
-use std::collections::BTreeMap;
-
-use mozart_core::package::{RawRepository, Stability};
-use mozart_sat_resolver::{PoolPackageInput, make_pool_links};
-use mozart_vcs::driver::DriverConfig;
-use mozart_vcs::repository::{VcsPackageVersion, VcsRepository};
-
-use crate::packagist::PackagistVersion;
-use crate::resolver::{parse_normalized, version_stability};
-
-/// Scan all VCS-type repositories and collect package versions.
-///
-/// Non-VCS repos (e.g. "composer", "package") are silently skipped.
-pub async fn scan_vcs_repositories(repositories: &[RawRepository]) -> Vec<VcsPackageVersion> {
- let config = DriverConfig::default();
- let mut all_versions = Vec::new();
-
- for repo in repositories {
- let repo_type = repo.repo_type.as_str();
- match repo_type {
- "vcs" | "git" | "svn" | "hg" | "github" | "gitlab" | "bitbucket" | "forgejo" => {}
- _ => continue,
- }
-
- let forced_type = match repo_type {
- "vcs" => None,
- other => Some(other),
- };
-
- // VCS repositories require `url`; skip silently if missing (Composer
- // would reject this earlier in RepositoryFactory).
- let Some(url) = repo.url.clone() else {
- continue;
- };
-
- let vcs_repo = VcsRepository::new(url.clone(), forced_type, config.clone());
-
- match vcs_repo.scan().await {
- Ok(versions) => {
- all_versions.extend(versions);
- }
- Err(e) => {
- eprintln!("Warning: Failed to scan VCS repository {url}: {e}");
- }
- }
- }
-
- all_versions
-}
-
-/// Convert a VCS package version to SAT pool inputs.
-pub fn vcs_to_pool_inputs(
- vpkg: &VcsPackageVersion,
- minimum_stability: Stability,
- stability_flags: &IndexMap<String, Stability>,
-) -> Vec<PoolPackageInput> {
- let mut results = Vec::new();
-
- // Extract dependency links from composer.json
- let require = extract_dep_map(&vpkg.composer_json, "require");
- let replace = extract_dep_map(&vpkg.composer_json, "replace");
- let provide = extract_dep_map(&vpkg.composer_json, "provide");
- let conflict = extract_dep_map(&vpkg.composer_json, "conflict");
-
- let input = PoolPackageInput {
- name: vpkg.name.clone(),
- version: vpkg.version_normalized.clone(),
- pretty_version: vpkg.version.clone(),
- requires: make_pool_links(
- &vpkg.name,
- &vpkg.version_normalized,
- &require
- .iter()
- .map(|(k, v)| (k.clone(), v.clone()))
- .collect::<Vec<_>>(),
- ),
- replaces: make_pool_links(
- &vpkg.name,
- &vpkg.version_normalized,
- &replace
- .iter()
- .map(|(k, v)| (k.clone(), v.clone()))
- .collect::<Vec<_>>(),
- ),
- provides: make_pool_links(
- &vpkg.name,
- &vpkg.version_normalized,
- &provide
- .iter()
- .map(|(k, v)| (k.clone(), v.clone()))
- .collect::<Vec<_>>(),
- ),
- conflicts: make_pool_links(
- &vpkg.name,
- &vpkg.version_normalized,
- &conflict
- .iter()
- .map(|(k, v)| (k.clone(), v.clone()))
- .collect::<Vec<_>>(),
- ),
- is_fixed: false,
- is_alias_of: None,
- };
-
- // Apply stability filtering
- if let Some(v) = parse_normalized(&vpkg.version_normalized) {
- if passes_vcs_stability_filter(&vpkg.name, &v, minimum_stability, stability_flags) {
- results.push(input);
- }
- } else {
- // Dev version: always include (dev stability)
- let pkg_flag = stability_flags.get(&vpkg.name.to_lowercase());
- let allowed = pkg_flag.copied().unwrap_or(minimum_stability);
- if allowed >= Stability::Dev {
- results.push(input);
- }
- }
-
- results
-}
-
-/// Convert a `VcsPackageVersion` into a `PackagistVersion` for lockfile generation.
-pub fn vcs_to_packagist_version(vpkg: &VcsPackageVersion) -> PackagistVersion {
- PackagistVersion {
- version: vpkg.version.clone(),
- version_normalized: vpkg.version_normalized.clone(),
- require: extract_dep_map(&vpkg.composer_json, "require"),
- replace: extract_dep_map(&vpkg.composer_json, "replace"),
- provide: extract_dep_map(&vpkg.composer_json, "provide"),
- conflict: extract_dep_map(&vpkg.composer_json, "conflict"),
- dist: vpkg.dist.as_ref().map(|d| crate::packagist::PackagistDist {
- dist_type: d.dist_type.clone(),
- url: d.url.clone(),
- reference: Some(d.reference.clone()),
- shasum: d.shasum.clone(),
- }),
- source: Some(crate::packagist::PackagistSource {
- source_type: vpkg.source.source_type.clone(),
- url: vpkg.source.url.clone(),
- reference: Some(vpkg.source.reference.clone()),
- }),
- require_dev: extract_dep_map(&vpkg.composer_json, "require-dev"),
- suggest: vpkg
- .composer_json
- .get("suggest")
- .and_then(|v| serde_json::from_value(v.clone()).ok()),
- package_type: vpkg
- .composer_json
- .get("type")
- .and_then(|v| v.as_str())
- .map(|s| s.to_string()),
- autoload: vpkg.composer_json.get("autoload").cloned(),
- autoload_dev: vpkg.composer_json.get("autoload-dev").cloned(),
- license: vpkg
- .composer_json
- .get("license")
- .and_then(|v| serde_json::from_value(v.clone()).ok()),
- description: vpkg
- .composer_json
- .get("description")
- .and_then(|v| v.as_str())
- .map(|s| s.to_string()),
- homepage: vpkg
- .composer_json
- .get("homepage")
- .and_then(|v| v.as_str())
- .map(|s| s.to_string()),
- keywords: vpkg
- .composer_json
- .get("keywords")
- .and_then(|v| serde_json::from_value(v.clone()).ok()),
- authors: vpkg
- .composer_json
- .get("authors")
- .and_then(|v| serde_json::from_value(v.clone()).ok()),
- support: vpkg.composer_json.get("support").cloned(),
- funding: vpkg
- .composer_json
- .get("funding")
- .and_then(|v| serde_json::from_value(v.clone()).ok()),
- time: vpkg.time.clone(),
- extra: vpkg.composer_json.get("extra").cloned(),
- notification_url: None,
- default_branch: vpkg.is_default_branch,
- abandoned: vpkg.composer_json.get("abandoned").cloned(),
- }
-}
-
-/// Extract a dependency map from composer.json JSON.
-fn extract_dep_map(json: &serde_json::Value, key: &str) -> BTreeMap<String, String> {
- json.get(key)
- .and_then(|v| v.as_object())
- .map(|obj| {
- obj.iter()
- .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
- .collect()
- })
- .unwrap_or_default()
-}
-
-/// Stability filter for VCS packages (mirrors resolver logic).
-fn passes_vcs_stability_filter(
- package_name: &str,
- version: &mozart_semver::Version,
- minimum_stability: Stability,
- stability_flags: &IndexMap<String, Stability>,
-) -> bool {
- let stability = version_stability(version);
- let pkg_flag = stability_flags.get(&package_name.to_lowercase());
- let allowed = pkg_flag.copied().unwrap_or(minimum_stability);
- stability <= allowed
-}
diff --git a/crates/mozart-registry/src/version.rs b/crates/mozart-registry/src/version.rs
deleted file mode 100644
index 9a7c6e6..0000000
--- a/crates/mozart-registry/src/version.rs
+++ /dev/null
@@ -1,269 +0,0 @@
-use crate::packagist::PackagistVersion;
-use mozart_core::package::Stability;
-use std::cmp::Ordering;
-
-/// Determine the stability of a normalized version string.
-pub fn stability_of(version_normalized: &str) -> Stability {
- let v = version_normalized.to_lowercase();
- if v.starts_with("dev-") || v.ends_with("-dev") {
- return Stability::Dev;
- }
- // Check for pre-release suffixes: alpha, beta, RC
- // Normalized versions use formats like "1.0.0.0-alpha1", "1.0.0.0-beta2", "1.0.0.0-RC1"
- if let Some(pos) = v.rfind('-') {
- let suffix = &v[pos + 1..];
- if suffix.starts_with("alpha") {
- return Stability::Alpha;
- }
- if suffix.starts_with("beta") {
- return Stability::Beta;
- }
- if suffix.starts_with("rc") || suffix.starts_with("RC") {
- return Stability::RC;
- }
- }
- Stability::Stable
-}
-
-/// Compare two normalized version strings (e.g. "1.2.3.0" vs "1.2.4.0").
-///
-/// Each version is split into numeric parts. Non-numeric suffixes (like "-beta1")
-/// are handled by treating the base parts as numeric and the suffix separately.
-pub fn compare_normalized_versions(a: &str, b: &str) -> Ordering {
- let parse = |v: &str| -> (Vec<u64>, Option<String>) {
- // Split off any pre-release suffix
- let (base, suffix) = if let Some(pos) = v.find('-') {
- (&v[..pos], Some(v[pos + 1..].to_string()))
- } else {
- (v, None)
- };
- let parts: Vec<u64> = base.split('.').filter_map(|p| p.parse().ok()).collect();
- (parts, suffix)
- };
-
- let (a_parts, a_suffix) = parse(a);
- let (b_parts, b_suffix) = parse(b);
-
- // Compare numeric parts
- let max_len = a_parts.len().max(b_parts.len());
- for i in 0..max_len {
- let a_val = a_parts.get(i).copied().unwrap_or(0);
- let b_val = b_parts.get(i).copied().unwrap_or(0);
- match a_val.cmp(&b_val) {
- Ordering::Equal => continue,
- other => return other,
- }
- }
-
- // If numeric parts are equal, compare stability
- // A stable version (no suffix) is greater than a pre-release
- match (&a_suffix, &b_suffix) {
- (None, None) => Ordering::Equal,
- (None, Some(_)) => Ordering::Greater, // stable > pre-release
- (Some(_), None) => Ordering::Less, // pre-release < stable
- (Some(a_s), Some(b_s)) => {
- let stab_a = stability_of(&format!("0.0.0.0-{a_s}"));
- let stab_b = stability_of(&format!("0.0.0.0-{b_s}"));
- // Lower stability value = more stable = greater version
- match stab_a.cmp(&stab_b) {
- Ordering::Equal => a_s.cmp(b_s),
- // Stability enum: Stable(0) < RC(5) < Beta(10) < Alpha(15) < Dev(20)
- // But more stable = higher version, so we reverse
- Ordering::Less => Ordering::Greater,
- Ordering::Greater => Ordering::Less,
- }
- }
- }
-}
-
-/// Find the best version candidate given a preferred minimum stability.
-///
-/// Returns the highest version whose stability is at least as stable as
-/// the preferred stability (i.e., stability value <= preferred value).
-pub fn find_best_candidate(
- versions: &[PackagistVersion],
- preferred_stability: Stability,
-) -> Option<&PackagistVersion> {
- versions
- .iter()
- .filter(|v| stability_of(&v.version_normalized) <= preferred_stability)
- .max_by(|a, b| compare_normalized_versions(&a.version_normalized, &b.version_normalized))
-}
-
-/// Generate a recommended version constraint string from a concrete version.
-///
-/// Examples:
-/// - `"1.2.1"` (stable) → `"^1.2"`
-/// - `"0.3.5"` (stable) → `"^0.3"`
-/// - `"2.0.0-beta.1"` (beta) → `"^2.0@beta"`
-/// - `"dev-master"` (dev) → `"dev-master"`
-pub fn find_recommended_require_version(
- version: &str,
- version_normalized: &str,
- stability: Stability,
-) -> String {
- // dev branches are returned as-is
- if stability == Stability::Dev {
- return version.to_string();
- }
-
- // Extract major.minor from the normalized version (e.g. "1.2.3.0" → "1.2")
- let base = if let Some(pos) = version_normalized.find('-') {
- &version_normalized[..pos]
- } else {
- version_normalized
- };
-
- let parts: Vec<&str> = base.split('.').collect();
- let major = parts.first().copied().unwrap_or("0");
- let minor = parts.get(1).copied().unwrap_or("0");
-
- let constraint = format!("^{major}.{minor}");
-
- match stability {
- Stability::Stable => constraint,
- Stability::RC => format!("{constraint}@RC"),
- Stability::Beta => format!("{constraint}@beta"),
- Stability::Alpha => format!("{constraint}@alpha"),
- Stability::Dev => format!("{constraint}@dev"),
- }
-}
-
-#[cfg(test)]
-mod tests {
- use super::*;
-
- #[test]
- fn test_stability_of() {
- assert_eq!(stability_of("1.0.0.0"), Stability::Stable);
- assert_eq!(stability_of("2.3.1.0"), Stability::Stable);
- assert_eq!(stability_of("1.0.0.0-alpha1"), Stability::Alpha);
- assert_eq!(stability_of("1.0.0.0-beta2"), Stability::Beta);
- assert_eq!(stability_of("1.0.0.0-RC1"), Stability::RC);
- assert_eq!(stability_of("dev-master"), Stability::Dev);
- assert_eq!(stability_of("dev-feature/foo"), Stability::Dev);
- assert_eq!(stability_of("1.0.0.0-dev"), Stability::Dev);
- }
-
- #[test]
- fn test_compare_normalized_versions() {
- assert_eq!(
- compare_normalized_versions("1.0.0.0", "1.0.0.0"),
- Ordering::Equal
- );
- assert_eq!(
- compare_normalized_versions("2.0.0.0", "1.0.0.0"),
- Ordering::Greater
- );
- assert_eq!(
- compare_normalized_versions("1.0.0.0", "2.0.0.0"),
- Ordering::Less
- );
- assert_eq!(
- compare_normalized_versions("1.2.0.0", "1.1.0.0"),
- Ordering::Greater
- );
- assert_eq!(
- compare_normalized_versions("1.0.0.0", "1.0.0.0-beta1"),
- Ordering::Greater
- );
- assert_eq!(
- compare_normalized_versions("1.0.0.0-RC1", "1.0.0.0-beta1"),
- Ordering::Greater
- );
- }
-
- fn make_pv(version: &str, version_normalized: &str) -> PackagistVersion {
- PackagistVersion {
- version: version.to_string(),
- version_normalized: version_normalized.to_string(),
- require: Default::default(),
- replace: Default::default(),
- provide: Default::default(),
- conflict: Default::default(),
- dist: None,
- source: None,
- require_dev: Default::default(),
- suggest: None,
- package_type: None,
- autoload: None,
- autoload_dev: None,
- license: None,
- description: None,
- homepage: None,
- keywords: None,
- authors: None,
- support: None,
- funding: None,
- time: None,
- extra: None,
- notification_url: None,
- default_branch: false,
- abandoned: None,
- }
- }
-
- #[test]
- fn test_find_best_candidate_stable() {
- let versions = vec![
- make_pv("dev-master", "dev-master"),
- make_pv("2.0.0-beta.1", "2.0.0.0-beta1"),
- make_pv("1.5.0", "1.5.0.0"),
- make_pv("1.4.0", "1.4.0.0"),
- ];
-
- let best = find_best_candidate(&versions, Stability::Stable).unwrap();
- assert_eq!(best.version, "1.5.0");
- }
-
- #[test]
- fn test_find_best_candidate_beta() {
- let versions = vec![
- make_pv("dev-master", "dev-master"),
- make_pv("2.0.0-beta.1", "2.0.0.0-beta1"),
- make_pv("1.5.0", "1.5.0.0"),
- ];
-
- let best = find_best_candidate(&versions, Stability::Beta).unwrap();
- assert_eq!(best.version, "2.0.0-beta.1");
- }
-
- #[test]
- fn test_find_best_candidate_no_match() {
- let versions = vec![make_pv("dev-master", "dev-master")];
-
- let best = find_best_candidate(&versions, Stability::Stable);
- assert!(best.is_none());
- }
-
- #[test]
- fn test_find_recommended_require_version() {
- // Stable
- assert_eq!(
- find_recommended_require_version("1.2.1", "1.2.1.0", Stability::Stable),
- "^1.2"
- );
- assert_eq!(
- find_recommended_require_version("0.3.5", "0.3.5.0", Stability::Stable),
- "^0.3"
- );
-
- // Beta
- assert_eq!(
- find_recommended_require_version("2.0.0-beta.1", "2.0.0.0-beta1", Stability::Beta),
- "^2.0@beta"
- );
-
- // RC
- assert_eq!(
- find_recommended_require_version("3.0.0-RC1", "3.0.0.0-RC1", Stability::RC),
- "^3.0@RC"
- );
-
- // Dev
- assert_eq!(
- find_recommended_require_version("dev-master", "dev-master", Stability::Dev),
- "dev-master"
- );
- }
-}
diff --git a/crates/mozart-registry/src/version_selector.rs b/crates/mozart-registry/src/version_selector.rs
deleted file mode 100644
index 7aa409e..0000000
--- a/crates/mozart-registry/src/version_selector.rs
+++ /dev/null
@@ -1,48 +0,0 @@
-use crate::cache::Cache;
-use crate::packagist::{self, PackagistVersion};
-use crate::version;
-use mozart_core::package::Stability;
-
-/// Mirrors `Composer\Package\Version\VersionSelector`.
-pub struct VersionSelector {
- preferred_stability: Stability,
- repo_cache: Cache,
-}
-
-impl VersionSelector {
- pub fn new(preferred_stability: Stability, repo_cache: Cache) -> Self {
- Self {
- preferred_stability,
- repo_cache,
- }
- }
-
- /// Fetch versions from Packagist and pick the best candidate.
- /// Mirrors `VersionSelector::findBestCandidate()`.
- pub async fn find_best_candidate(
- &self,
- package_name: &str,
- ) -> anyhow::Result<Option<PackagistVersion>> {
- let versions = packagist::fetch_package_versions(package_name, &self.repo_cache).await?;
- Ok(version::find_best_candidate(&versions, self.preferred_stability).cloned())
- }
-
- /// Generate a recommended constraint string from a concrete version.
- /// Mirrors `VersionSelector::findRecommendedRequireVersion()`.
- pub fn find_recommended_require_version_string(
- &self,
- pkg: &PackagistVersion,
- fixed: bool,
- ) -> String {
- if fixed {
- pkg.version.clone()
- } else {
- let stability = version::stability_of(&pkg.version_normalized);
- version::find_recommended_require_version(
- &pkg.version,
- &pkg.version_normalized,
- stability,
- )
- }
- }
-}
diff --git a/crates/mozart-registry/tests/poolbuilder.rs b/crates/mozart-registry/tests/poolbuilder.rs
deleted file mode 100644
index d8511e4..0000000
--- a/crates/mozart-registry/tests/poolbuilder.rs
+++ /dev/null
@@ -1,80 +0,0 @@
-//! Pool-builder fixture suite, ported from
-//! `composer/tests/Composer/Test/DependencyResolver/PoolBuilderTest.php`.
-//!
-//! Composer drives this suite through a `@dataProvider`; each `.test` file
-//! becomes one parameterized case. Mirrored here as one `#[test]` per
-//! fixture so the count surfaces in `cargo test` output and individual
-//! cases can be re-enabled as the runner is fleshed out.
-//!
-//! Every test is currently `#[ignore]` because the runner is a stub: the
-//! orchestration that takes a `RepositorySet` + `Request` and produces a
-//! populated `Pool` lives inline in `mozart_registry::resolver::resolve`,
-//! not as an extracted entry point. Wiring those up — alias handling,
-//! stability flags, fixed/locked packages, the optimizer pass — is the
-//! follow-up work this scaffolding exists to track.
-
-use std::path::{Path, PathBuf};
-
-use mozart_test_harness::{ParsedPoolBuilderTest, parse_pool_builder_test_file};
-
-fn fixtures_dir() -> PathBuf {
- Path::new(env!("CARGO_MANIFEST_DIR"))
- .join("../../composer/tests/Composer/Test/DependencyResolver/Fixtures/poolbuilder")
-}
-
-fn run_poolbuilder_fixture(ident: &str) {
- let filename = format!("{}.test", ident.replace('_', "-"));
- let path = fixtures_dir().join(&filename);
- let _parsed: ParsedPoolBuilderTest = parse_pool_builder_test_file(&path)
- .unwrap_or_else(|e| panic!("failed to parse {}: {:#}", path.display(), e));
-
- // Runner is intentionally not implemented yet — see module docs.
- // Removing `#[ignore]` from a case will surface this `unimplemented!`
- // and force the missing pool-builder entry point into existence.
- unimplemented!(
- "PoolBuilderTest runner not yet wired up; cannot execute {}",
- path.display()
- );
-}
-
-macro_rules! poolbuilder_fixture {
- ($name:ident) => {
- #[test]
- #[ignore]
- fn $name() {
- run_poolbuilder_fixture(stringify!($name));
- }
- };
-}
-
-poolbuilder_fixture!(alias_priority_conflicting);
-poolbuilder_fixture!(alias_with_reference);
-poolbuilder_fixture!(constraint_expansion_works_with_exact_versions);
-poolbuilder_fixture!(filter_impossible_packages);
-poolbuilder_fixture!(filter_impossible_packages_locked_replacer);
-poolbuilder_fixture!(filter_impossible_packages_only_required);
-poolbuilder_fixture!(filter_impossible_packages_only_required_provides);
-poolbuilder_fixture!(filter_impossible_packages_only_required_replaces);
-poolbuilder_fixture!(filter_impossible_packages_provides);
-poolbuilder_fixture!(filter_impossible_packages_replaces);
-poolbuilder_fixture!(fixed_packages_do_not_load_from_repos);
-poolbuilder_fixture!(fixed_packages_replaced_do_not_load_from_repos);
-poolbuilder_fixture!(load_replaced_package_if_replacer_dropped);
-poolbuilder_fixture!(load_replaced_root_package_if_replacer_dropped);
-poolbuilder_fixture!(multi_repo_replace);
-poolbuilder_fixture!(multi_repo_replace_partial_update_all);
-poolbuilder_fixture!(must_expand_root_reqs);
-poolbuilder_fixture!(package_versions_are_not_loaded_if_not_required_expansion);
-poolbuilder_fixture!(package_versions_are_not_loaded_if_not_required_recursive);
-poolbuilder_fixture!(packages_that_do_not_exist);
-poolbuilder_fixture!(partial_update);
-poolbuilder_fixture!(partial_update_transitive_deps_no_root_unfix);
-poolbuilder_fixture!(partial_update_transitive_deps_unfix);
-poolbuilder_fixture!(partial_update_unfixes_path_repo_replacer_with_transitive_deps);
-poolbuilder_fixture!(partial_update_unfixes_path_repos_always_but_not_their_transitive_deps);
-poolbuilder_fixture!(partial_update_unfixing_locked_deps);
-poolbuilder_fixture!(partial_update_unfixing_replacers);
-poolbuilder_fixture!(partial_update_unfixing_with_replacers);
-poolbuilder_fixture!(partial_update_unfixing_with_replacers_providers);
-poolbuilder_fixture!(root_requirements_avoid_loading_further_versions);
-poolbuilder_fixture!(stability_flags_take_over_minimum_stability_and_filter_packages);