aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-registry/src/installer_executor
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/installer_executor
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/installer_executor')
-rw-r--r--crates/mozart-registry/src/installer_executor/filesystem.rs222
-rw-r--r--crates/mozart-registry/src/installer_executor/mod.rs84
2 files changed, 306 insertions, 0 deletions
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..82acc42
--- /dev/null
+++ b/crates/mozart-registry/src/installer_executor/filesystem.rs
@@ -0,0 +1,222 @@
+//! 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, 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..fde4c49
--- /dev/null
+++ b/crates/mozart-registry/src/installer_executor/mod.rs
@@ -0,0 +1,84 @@
+//! 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 use filesystem::FilesystemExecutor;
+
+/// 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.
+ fn uninstall_package(&mut self, name: &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(())
+ }
+}