aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-registry/src/repository
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-02 16:53:41 +0900
committernsfisis <nsfisis@gmail.com>2026-05-02 16:53:41 +0900
commitc1733d88510b7afb88f7a17849de514365e42c84 (patch)
tree57f9eb854cb226059df90d190626e090ae07d639 /crates/mozart-registry/src/repository
parent82501a36a0fa6725d656742da42c860e75a89b89 (diff)
downloadphp-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.rs63
-rw-r--r--crates/mozart-registry/src/repository/mod.rs127
-rw-r--r--crates/mozart-registry/src/repository/packagist_repo.rs58
-rw-r--r--crates/mozart-registry/src/repository/vcs_repo.rs63
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)
+ }
+}