diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-02 18:04:29 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-02 18:04:29 +0900 |
| commit | 33fe16285acbed1f5146c2d746eba2295bd57688 (patch) | |
| tree | 0df8f83fd9e95e87406e350ce48816451b6d07af /crates/mozart-registry | |
| parent | 82501a36a0fa6725d656742da42c860e75a89b89 (diff) | |
| parent | c446337e75ba9fd674dd63d56ec25d7bd5b5fa31 (diff) | |
| download | php-mozart-33fe16285acbed1f5146c2d746eba2295bd57688.tar.gz php-mozart-33fe16285acbed1f5146c2d746eba2295bd57688.tar.zst php-mozart-33fe16285acbed1f5146c2d746eba2295bd57688.zip | |
Merge branch 'test/di'
Diffstat (limited to 'crates/mozart-registry')
| -rw-r--r-- | crates/mozart-registry/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/mozart-registry/src/installer_executor/filesystem.rs | 225 | ||||
| -rw-r--r-- | crates/mozart-registry/src/installer_executor/mod.rs | 97 | ||||
| -rw-r--r-- | crates/mozart-registry/src/installer_executor/trace_recorder.rs | 102 | ||||
| -rw-r--r-- | crates/mozart-registry/src/lib.rs | 2 | ||||
| -rw-r--r-- | crates/mozart-registry/src/lockfile.rs | 50 | ||||
| -rw-r--r-- | crates/mozart-registry/src/repository/inline_package_repo.rs | 63 | ||||
| -rw-r--r-- | crates/mozart-registry/src/repository/mod.rs | 143 | ||||
| -rw-r--r-- | crates/mozart-registry/src/repository/packagist_repo.rs | 57 | ||||
| -rw-r--r-- | crates/mozart-registry/src/repository/vcs_repo.rs | 63 | ||||
| -rw-r--r-- | crates/mozart-registry/src/resolver.rs | 106 |
11 files changed, 850 insertions, 59 deletions
diff --git a/crates/mozart-registry/Cargo.toml b/crates/mozart-registry/Cargo.toml index af65026..abde30d 100644 --- a/crates/mozart-registry/Cargo.toml +++ b/crates/mozart-registry/Cargo.toml @@ -10,6 +10,7 @@ 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 md5.workspace = true diff --git a/crates/mozart-registry/src/installer_executor/filesystem.rs b/crates/mozart-registry/src/installer_executor/filesystem.rs new file mode 100644 index 0000000..185e5b9 --- /dev/null +++ b/crates/mozart-registry/src/installer_executor/filesystem.rs @@ -0,0 +1,225 @@ +//! 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<()> { + let pkg = op.package(); + + // 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 new file mode 100644 index 0000000..c70fe12 --- /dev/null +++ b/crates/mozart-registry/src/installer_executor/mod.rs @@ -0,0 +1,97 @@ +//! 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::lockfile::LockedPackage; + +pub mod filesystem; +pub mod trace_recorder; + +pub use filesystem::FilesystemExecutor; +pub use trace_recorder::TraceRecorderExecutor; + +/// 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. + Update { + from_version: &'a str, + package: &'a LockedPackage, + }, +} + +impl<'a> PackageOperation<'a> { + pub fn package(&self) -> &'a LockedPackage { + match self { + PackageOperation::Install { package } | PackageOperation::Update { package, .. } => { + package + } + } + } +} + +/// 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 new file mode 100644 index 0000000..9fdc91b --- /dev/null +++ b/crates/mozart-registry/src/installer_executor/trace_recorder.rs @@ -0,0 +1,102 @@ +//! 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: `Uninstalling <name> (<version>)` + +use mozart_semver::Version; + +use super::{ExecuteContext, InstallerExecutor, PackageOperation}; + +/// 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, package.version)); + } + PackageOperation::Update { + from_version, + package, + } => { + let action = if is_upgrade(from_version, &package.version) { + "Upgrading" + } else { + "Downgrading" + }; + self.trace.push(format!( + "{} {} ({} => {})", + action, package.name, from_version, package.version + )); + } + } + Ok(()) + } + + fn uninstall_package( + &mut self, + name: &str, + version: &str, + _ctx: &ExecuteContext, + ) -> anyhow::Result<()> { + self.trace + .push(format!("Uninstalling {} ({})", name, version)); + Ok(()) + } +} + +/// Mirrors `Composer\Package\Version\VersionParser::isUpgrade` — returns +/// true when `to` is a strictly higher version than `from`. Both unparseable +/// or both equal means treat as upgrade (Composer's behavior on edge cases). +fn is_upgrade(from: &str, to: &str) -> bool { + match (Version::parse(from), Version::parse(to)) { + (Ok(a), Ok(b)) => b >= a, + _ => true, + } +} diff --git a/crates/mozart-registry/src/lib.rs b/crates/mozart-registry/src/lib.rs index a4afacd..e60d7b0 100644 --- a/crates/mozart-registry/src/lib.rs +++ b/crates/mozart-registry/src/lib.rs @@ -2,8 +2,10 @@ pub mod cache; pub mod downloader; pub mod inline_package; pub mod installed; +pub mod installer_executor; pub mod lockfile; pub mod packagist; +pub mod repository; pub mod resolver; pub mod vcs_bridge; pub mod version; diff --git a/crates/mozart-registry/src/lockfile.rs b/crates/mozart-registry/src/lockfile.rs index a99c921..8022f8b 100644 --- a/crates/mozart-registry/src/lockfile.rs +++ b/crates/mozart-registry/src/lockfile.rs @@ -1,5 +1,5 @@ -use crate::cache::Cache; -use crate::packagist::{self, PackagistDist, PackagistSource, PackagistVersion}; +use crate::packagist::{PackagistDist, PackagistSource, PackagistVersion}; +use crate::repository::RepositorySet; use crate::resolver::ResolvedPackage; use mozart_core::package::{RawPackageData, to_json_pretty}; use serde::{Deserialize, Serialize}; @@ -350,8 +350,9 @@ pub struct LockFileGenerationRequest { pub composer_json: RawPackageData, /// Whether require-dev was included in resolution. pub include_dev: bool, - /// Repo cache for Packagist API calls made during generation. - pub repo_cache: Cache, + /// 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>, } impl LockFileGenerationRequest { @@ -514,21 +515,28 @@ fn extract_platform_requirements(requirements: &BTreeMap<String, String>) -> ser /// 3. Computes the content-hash /// 4. Assembles the complete `LockFile` struct pub async fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow::Result<LockFile> { - // 1. Fetch full metadata for all resolved packages + // 1. Fetch full metadata for all resolved 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. let mut package_metadata: HashMap<String, PackagistVersion> = HashMap::new(); + let repo_set = &request.repositories; for pkg in &request.resolved_packages { - // Inline `type: package` repositories carry full metadata in - // composer.json — use it directly instead of hitting Packagist. if let Some(inline) = request.inline_lookup(&pkg.name, &pkg.version_normalized) { package_metadata.insert(pkg.name.clone(), inline); continue; } - let versions = packagist::fetch_package_versions(&pkg.name, &request.repo_cache).await?; - // Find the exact version matching pkg.version_normalized - let matching = versions + 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(|v| v.version_normalized == pkg.version_normalized) + .find(|r| r.version.version_normalized == pkg.version_normalized) .ok_or_else(|| { anyhow::anyhow!( "Could not find version {} for package {} in Packagist response", @@ -536,7 +544,7 @@ pub async fn generate_lock_file(request: &LockFileGenerationRequest) -> anyhow:: pkg.name ) })?; - package_metadata.insert(pkg.name.clone(), matching); + package_metadata.insert(pkg.name.clone(), matching.version); } // 2. Classify dev vs non-dev packages @@ -1071,7 +1079,9 @@ mod tests { composer_json_content: composer_json_content.clone(), composer_json, include_dev: true, - repo_cache: Cache::new(std::env::temp_dir().join("mozart-test-cache"), false), + repositories: std::sync::Arc::new(RepositorySet::with_packagist( + crate::cache::Cache::new(std::env::temp_dir().join("mozart-test-cache"), false), + )), }; let lock = generate_lock_file(&request).await.unwrap(); @@ -1159,9 +1169,11 @@ mod tests { #[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 { @@ -1176,9 +1188,12 @@ mod tests { platform: PlatformConfig::new(), ignore_platform_reqs: false, ignore_platform_req_list: vec![], - repo_cache: Cache::new(std::env::temp_dir().join("mozart-test-cache"), false), + repositories: Arc::new(RepositorySet::with_packagist(Cache::new( + std::env::temp_dir().join("mozart-test-cache"), + false, + ))), temporary_constraints: HashMap::new(), - repositories: vec![], + raw_repositories: vec![], }; let resolved = resolve(&resolve_request) @@ -1195,7 +1210,10 @@ mod tests { composer_json_content: composer_json_content.clone(), composer_json, include_dev: false, - repo_cache: Cache::new(std::env::temp_dir().join("mozart-test-cache"), false), + repositories: Arc::new(RepositorySet::with_packagist(Cache::new( + std::env::temp_dir().join("mozart-test-cache"), + false, + ))), }; let lock = generate_lock_file(&gen_request) diff --git a/crates/mozart-registry/src/repository/inline_package_repo.rs b/crates/mozart-registry/src/repository/inline_package_repo.rs new file mode 100644 index 0000000..1043559 --- /dev/null +++ b/crates/mozart-registry/src/repository/inline_package_repo.rs @@ -0,0 +1,63 @@ +//! [`Repository`] for inline `type: package` repositories. +//! +//! Wraps [`crate::inline_package::collect_inline_packages`]. The data is +//! embedded in `composer.json` so there's no I/O — the repo just filters +//! its in-memory list by queried name. +//! +//! Mirrors `Composer\Repository\PackageRepository` (which extends +//! `ArrayRepository`). Only the package's own `name` is matched against +//! queries — `replace`/`provide` targets are NOT advertised here, exactly +//! like Composer's `ArrayRepository::loadPackages` checks `getName()` only. +//! Replacement satisfaction happens later in the solver once the replacing +//! package is loaded transitively. + +use super::{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 new file mode 100644 index 0000000..0f742a3 --- /dev/null +++ b/crates/mozart-registry/src/repository/mod.rs @@ -0,0 +1,143 @@ +//! 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 crate::packagist::PackagistVersion; + +pub mod inline_package_repo; +pub mod packagist_repo; +pub mod vcs_repo; + +/// 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>; +} + +/// 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 std::collections::HashSet; + + let mut packages: Vec<NamedPackagistVersion> = Vec::new(); + let mut answered: HashSet<String> = HashSet::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) + } +} diff --git a/crates/mozart-registry/src/repository/packagist_repo.rs b/crates/mozart-registry/src/repository/packagist_repo.rs new file mode 100644 index 0000000..6f9b687 --- /dev/null +++ b/crates/mozart-registry/src/repository/packagist_repo.rs @@ -0,0 +1,57 @@ +//! [`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}; +use crate::cache::Cache; +use crate::packagist; + +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) + } +} diff --git a/crates/mozart-registry/src/repository/vcs_repo.rs b/crates/mozart-registry/src/repository/vcs_repo.rs new file mode 100644 index 0000000..fff5f6f --- /dev/null +++ b/crates/mozart-registry/src/repository/vcs_repo.rs @@ -0,0 +1,63 @@ +//! [`Repository`] for VCS-type repositories. +//! +//! Wraps [`crate::vcs_bridge::scan_vcs_repositories`] + [`crate::vcs_bridge::vcs_to_packagist_version`]. +//! Scanning is expensive (clones / fetches), so we do it once at construction +//! and serve subsequent queries from the in-memory cache. Mirrors +//! `Composer\Repository\Vcs\VcsRepository`'s lazy-then-memoized behavior. + +use super::{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/resolver.rs b/crates/mozart-registry/src/resolver.rs index 710a9c4..336d6d7 100644 --- a/crates/mozart-registry/src/resolver.rs +++ b/crates/mozart-registry/src/resolver.rs @@ -6,9 +6,10 @@ use std::collections::{HashMap, HashSet}; use std::fmt; +use std::sync::Arc; -use crate::cache::Cache; use crate::packagist; +use crate::repository::{PackageQuery, RepositorySet}; use crate::vcs_bridge; use mozart_core::package::{RawRepository, Stability}; use mozart_sat_resolver::{ @@ -346,14 +347,20 @@ pub struct ResolveRequest { pub ignore_platform_reqs: bool, /// Specific platform requirements to ignore. pub ignore_platform_req_list: Vec<String>, - /// On-disk repo cache for Packagist API responses. - pub repo_cache: Cache, + /// 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: HashMap<String, String>, - /// VCS repositories from composer.json "repositories" section. - /// Used to fetch packages from VCS before falling back to Packagist. - pub repositories: Vec<RawRepository>, + /// 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>, } /// A single package in the resolution output. @@ -445,7 +452,7 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R } // Scan VCS repositories and collect packages from them - let vcs_packages = vcs_bridge::scan_vcs_repositories(&request.repositories).await; + let vcs_packages = vcs_bridge::scan_vcs_repositories(&request.raw_repositories).await; let mut vcs_package_names: HashSet<String> = HashSet::new(); for vpkg in &vcs_packages { vcs_package_names.insert(vpkg.name.clone()); @@ -466,7 +473,7 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R // Collect inline `type: package` repositories. These don't require any // network fetch; they go straight into the pool and are also tracked by // name so the Packagist seed/transitive loops below skip them. - let inline_packages = crate::inline_package::collect_inline_packages(&request.repositories); + let inline_packages = crate::inline_package::collect_inline_packages(&request.raw_repositories); let mut inline_package_names: HashSet<String> = HashSet::new(); for ipkg in &inline_packages { inline_package_names.insert(ipkg.name.clone()); @@ -481,38 +488,44 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R } } - // Seed the builder with packages for root requirements - for name in root_requires.keys() { - if PackageName(name.clone()).is_platform() { - continue; // platform packages already added - } - - // Skip packages already provided by VCS or inline-package repositories - if vcs_package_names.contains(name) || inline_package_names.contains(name) { - continue; - } + // 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; - // Fetch available versions from Packagist - let versions = packagist::fetch_package_versions(name, &request.repo_cache) - .await - .map_err(|e| { - ResolveError::DependencyFetchError(format!("Failed to fetch {}: {}", name, e)) - })?; - - for pv in &versions { - let inputs = packagist_to_pool_inputs( - name, - pv, - request.minimum_stability, - &request.stability_flags, - ); - for input in inputs { - builder.add_package(input); - } + // Seed the builder with packages for root requirements. + let seed_names: Vec<String> = root_requires + .keys() + .filter(|name| !PackageName((*name).clone()).is_platform()) + .filter(|name| !vcs_package_names.contains(*name) && !inline_package_names.contains(*name)) + .cloned() + .collect(); + let seed_queries: Vec<PackageQuery<'_>> = seed_names + .iter() + .map(|n| PackageQuery { + name: n.as_str(), + constraint: root_requires.get(n).and_then(|c| c.as_deref()), + }) + .collect(); + let seed_results = repo_set + .load_packages(&seed_queries) + .await + .map_err(|e| ResolveError::DependencyFetchError(e.to_string()))?; + for r in &seed_results { + let inputs = packagist_to_pool_inputs( + &r.name, + &r.version, + request.minimum_stability, + &request.stability_flags, + ); + for input in inputs { + builder.add_package(input); } } - // Explore transitive dependencies + // Explore transitive dependencies. while let Some(name) = builder.next_pending() { if PackageName(name.clone()).is_platform() { continue; @@ -523,7 +536,11 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R continue; } - let versions = match packagist::fetch_package_versions(&name, &request.repo_cache).await { + let queries = [PackageQuery { + name: name.as_str(), + constraint: None, + }]; + let results = match repo_set.load_packages(&queries).await { Ok(v) => v, Err(_) => { // Virtual/meta packages (e.g. "psr/http-client-implementation") @@ -532,11 +549,10 @@ pub async fn resolve(request: &ResolveRequest) -> Result<Vec<ResolvedPackage>, R continue; } }; - - for pv in &versions { + for r in &results { let inputs = packagist_to_pool_inputs( - &name, - pv, + &r.name, + &r.version, request.minimum_stability, &request.stability_flags, ); @@ -969,6 +985,7 @@ mod tests { #[tokio::test] #[ignore] async fn test_resolve_monolog_e2e() { + use crate::cache::Cache; let request = ResolveRequest { root_name: String::new(), require: vec![("monolog/monolog".to_string(), "^3.0".to_string())], @@ -981,9 +998,12 @@ mod tests { platform: PlatformConfig::new(), ignore_platform_reqs: false, ignore_platform_req_list: vec![], - repo_cache: Cache::new(std::env::temp_dir().join("mozart-test-cache"), false), + repositories: Arc::new(RepositorySet::with_packagist(Cache::new( + std::env::temp_dir().join("mozart-test-cache"), + false, + ))), temporary_constraints: HashMap::new(), - repositories: vec![], + raw_repositories: vec![], }; let result = resolve(&request).await; |
