diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-02 16:53:41 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-02 16:53:41 +0900 |
| commit | c1733d88510b7afb88f7a17849de514365e42c84 (patch) | |
| tree | 57f9eb854cb226059df90d190626e090ae07d639 /crates/mozart-registry/src/repository | |
| parent | 82501a36a0fa6725d656742da42c860e75a89b89 (diff) | |
| download | php-mozart-c1733d88510b7afb88f7a17849de514365e42c84.tar.gz php-mozart-c1733d88510b7afb88f7a17849de514365e42c84.tar.zst php-mozart-c1733d88510b7afb88f7a17849de514365e42c84.zip | |
refactor(registry): introduce Repository and InstallerExecutor traits
Sets up DI scaffolding for in-process installer E2E tests, mirroring how
Composer's PHPUnit suite swaps Packagist (FactoryMock) and the install
manager (InstallationManagerMock) without touching the network or filesystem.
Additions:
- Repository trait + RepositorySet (Composer's RepositoryInterface analog),
with PackagistRepository, InlinePackageRepository, VcsRepository impls.
- InstallerExecutor trait (Composer's InstallationManager analog) with
FilesystemExecutor extracted from install_from_lock.
install_from_lock now delegates per-package install/uninstall verbs to
FilesystemExecutor; console output orchestration stays in the caller so
existing --EXPECT-OUTPUT-shape assertions remain comparable. No behavior
change - all 136 enabled installer fixtures still pass.
Also tightens the installer_fixture\! ignore form to a single token
(installer_fixture\!(name, ignore)) for readability.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart-registry/src/repository')
| -rw-r--r-- | crates/mozart-registry/src/repository/inline_package_repo.rs | 63 | ||||
| -rw-r--r-- | crates/mozart-registry/src/repository/mod.rs | 127 | ||||
| -rw-r--r-- | crates/mozart-registry/src/repository/packagist_repo.rs | 58 | ||||
| -rw-r--r-- | crates/mozart-registry/src/repository/vcs_repo.rs | 63 |
4 files changed, 311 insertions, 0 deletions
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..1ab8797 --- /dev/null +++ b/crates/mozart-registry/src/repository/mod.rs @@ -0,0 +1,127 @@ +//! 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 } + } + + 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..17208c1 --- /dev/null +++ b/crates/mozart-registry/src/repository/packagist_repo.rs @@ -0,0 +1,58 @@ +//! [`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 { + // Mirror the existing transitive-loop tolerance: a 404 / network + // failure for one name is not fatal — it just means this repo + // contributes nothing for that name. `RepositorySet` falls + // through, and the solver fails later if no repo knows it. + let versions = + match packagist::fetch_package_versions(query.name, &self.cache).await { + Ok(v) => v, + Err(_) => continue, + }; + // `fetch_package_versions` returning Ok counts as "this repo + // authoritatively knows the name", even if the version list is + // empty (matches Composer `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) + } +} |
