From c1733d88510b7afb88f7a17849de514365e42c84 Mon Sep 17 00:00:00 2001 From: nsfisis Date: Sat, 2 May 2026 16:53:41 +0900 Subject: 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) --- Cargo.lock | 12 ++ Cargo.toml | 1 + crates/mozart-registry/Cargo.toml | 1 + .../src/installer_executor/filesystem.rs | 222 ++++++++++++++++++++ .../mozart-registry/src/installer_executor/mod.rs | 84 ++++++++ crates/mozart-registry/src/lib.rs | 2 + .../src/repository/inline_package_repo.rs | 63 ++++++ crates/mozart-registry/src/repository/mod.rs | 127 ++++++++++++ .../src/repository/packagist_repo.rs | 58 ++++++ crates/mozart-registry/src/repository/vcs_repo.rs | 63 ++++++ crates/mozart/src/commands/install.rs | 202 +++--------------- crates/mozart/tests/installer.rs | 226 +++++---------------- 12 files changed, 711 insertions(+), 350 deletions(-) create mode 100644 crates/mozart-registry/src/installer_executor/filesystem.rs create mode 100644 crates/mozart-registry/src/installer_executor/mod.rs create mode 100644 crates/mozart-registry/src/repository/inline_package_repo.rs create mode 100644 crates/mozart-registry/src/repository/mod.rs create mode 100644 crates/mozart-registry/src/repository/packagist_repo.rs create mode 100644 crates/mozart-registry/src/repository/vcs_repo.rs diff --git a/Cargo.lock b/Cargo.lock index d1ae1cb..890314b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -97,6 +97,17 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -1170,6 +1181,7 @@ name = "mozart-registry" version = "0.1.0" dependencies = [ "anyhow", + "async-trait", "filetime", "flate2", "md5", diff --git a/Cargo.toml b/Cargo.toml index 324c0a5..e358c5c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ mozart-test-harness = { path = "crates/mozart-test-harness" } mozart-vcs = { path = "crates/mozart-vcs" } anyhow = "1.0.102" assert_cmd = "2.1.2" +async-trait = "0.1.83" base64 = "0.22.1" bzip2 = "0.5.2" clap = { version = "4.5.60", features = ["derive"] } 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..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(()) + } +} 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/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, +} + +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 { + 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, + pub names_found: Vec, +} + +/// 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:"`). + 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; +} + +/// 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>, +} + +impl RepositorySet { + pub fn new(repos: Vec>) -> 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 { + 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> { + use std::collections::HashSet; + + let mut packages: Vec = Vec::new(); + let mut answered: HashSet = HashSet::new(); + + for repo in &self.repos { + let pending: Vec> = 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 { + 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 { + 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/src/commands/install.rs b/crates/mozart/src/commands/install.rs index 1cc4e6f..b303ade 100644 --- a/crates/mozart/src/commands/install.rs +++ b/crates/mozart/src/commands/install.rs @@ -1,8 +1,10 @@ use clap::Args; use mozart_core::console; use mozart_core::console_format; -use mozart_registry::downloader; use mozart_registry::installed; +use mozart_registry::installer_executor::{ + ExecuteContext, FilesystemExecutor, InstallerExecutor, PackageOperation, +}; use mozart_registry::lockfile; use std::collections::{BTreeMap, HashSet}; use std::path::{Path, PathBuf}; @@ -236,27 +238,6 @@ pub fn locked_to_installed_entry( } } -/// Clean up empty vendor namespace directories after removals. -pub 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(); - // Skip "composer" dir and "bin" dir - if name == "composer" || name == "bin" { - continue; - } - // If the namespace dir is empty, remove it - if std::fs::read_dir(&path)?.next().is_none() { - std::fs::remove_dir(&path)?; - } - } - } - } - Ok(()) -} - /// Check whether a package name refers to a platform package. /// /// Platform packages are: names starting with "php", "ext-", or "lib-". @@ -435,56 +416,6 @@ fn warn_platform_requirements( } } -/// Create a download progress tracker for a package. -fn make_progress(show: bool, pkg_name: &str, version: &str) -> downloader::DownloadProgress { - downloader::DownloadProgress::new(show, format!("{pkg_name} ({version})")) -} - -/// Install a package from VCS source (git/svn/hg). -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(()) -} - pub async fn install_from_lock( lock: &lockfile::LockFile, working_dir: &Path, @@ -575,8 +506,15 @@ pub async fn install_from_lock( console.info(&console_format!(" - Would remove {}", name)); } } else { + let mut executor = FilesystemExecutor::new(files_cache); + let exec_ctx = ExecuteContext { + vendor_dir: vendor_dir.to_path_buf(), + no_progress: config.no_progress, + prefer_source: config.prefer_source, + }; + for (pkg, action) in &ops { - match action { + let op = match action { Action::Skip => continue, Action::Install => { console.info(&console_format!( @@ -584,6 +522,7 @@ pub async fn install_from_lock( pkg.name, pkg.version )); + PackageOperation::Install { package: pkg } } Action::Update => { console.info(&console_format!( @@ -591,69 +530,29 @@ pub async fn install_from_lock( pkg.name, pkg.version )); + // The previous-version string is unknown to install_from_lock + // (it only sees the post-update lock). Pass the new version + // as a placeholder; this path is unused by the recorder, and + // Composer's `Upgrading` trace string is generated upstream + // by the resolver, not by InstallationManager itself. + PackageOperation::Update { + from_version: &pkg.version, + package: pkg, + } } - } - - // Try source install if --prefer-source and source info is available - if config.prefer_source - && let Some(source) = &pkg.source - { - install_from_source( - &source.source_type, - &source.url, - source.reference.as_deref().unwrap_or("HEAD"), - vendor_dir, - &pkg.name, - )?; - continue; - } - - // 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() { - continue; - } - - 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 = make_progress(!config.no_progress, &pkg.name, &pkg.version); - - downloader::install_package( - &dist.url, - &dist.dist_type, - dist.shasum.as_deref(), - vendor_dir, - &pkg.name, - Some(&mut progress), - &files_cache, - ) - .await?; - - progress.finish(); + }; + executor.install_package(op, &exec_ctx).await?; } // Handle removals for name in &removals { console.info(&console_format!(" - Removing {}", name)); - let pkg_dir = vendor_dir.join(name); - if pkg_dir.exists() { - std::fs::remove_dir_all(&pkg_dir)?; - } + executor.uninstall_package(name, &exec_ctx)?; } // Step 7: Clean up empty vendor namespace directories if !removals.is_empty() { - cleanup_empty_vendor_dirs(vendor_dir)?; + executor.cleanup_after_uninstalls(&exec_ctx)?; } // Step 8: Write updated vendor/composer/installed.json (unless download_only) @@ -1232,57 +1131,6 @@ mod tests { assert_eq!(loaded.dev_package_names, vec!["phpunit/phpunit"]); } - // ----------------------------------------------------------------------- - // cleanup_empty_vendor_dirs tests - // ----------------------------------------------------------------------- - - #[test] - fn test_cleanup_empty_vendor_dirs_removes_empty() { - let dir = tempdir().unwrap(); - let vendor_dir = dir.path().join("vendor"); - std::fs::create_dir_all(&vendor_dir).unwrap(); - - // Create an empty namespace dir - let empty_ns = vendor_dir.join("old-vendor"); - std::fs::create_dir_all(&empty_ns).unwrap(); - - // Create a non-empty namespace dir - let nonempty_ns = vendor_dir.join("psr"); - std::fs::create_dir_all(nonempty_ns.join("log")).unwrap(); - - // Create the composer dir (should be skipped) - std::fs::create_dir_all(vendor_dir.join("composer")).unwrap(); - - cleanup_empty_vendor_dirs(&vendor_dir).unwrap(); - - assert!(!empty_ns.exists(), "empty namespace dir should be removed"); - assert!( - vendor_dir.join("psr").exists(), - "non-empty namespace dir should remain" - ); - assert!( - vendor_dir.join("composer").exists(), - "composer dir should be preserved" - ); - } - - #[test] - fn test_cleanup_empty_vendor_dirs_skips_bin() { - 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(); - - cleanup_empty_vendor_dirs(&vendor_dir).unwrap(); - - assert!( - bin_dir.exists(), - "bin dir should be preserved even if empty" - ); - } - // ----------------------------------------------------------------------- // Platform requirement check tests // ----------------------------------------------------------------------- diff --git a/crates/mozart/tests/installer.rs b/crates/mozart/tests/installer.rs index e1bc7a5..719a721 100644 --- a/crates/mozart/tests/installer.rs +++ b/crates/mozart/tests/installer.rs @@ -57,9 +57,9 @@ macro_rules! installer_fixture { run_installer_fixture(stringify!($name)); } }; - ($name:ident, ignore = $reason:literal) => { + ($name:ident, ignore) => { #[test] - #[ignore = $reason] + #[ignore = "not implemented yet"] fn $name() { run_installer_fixture(stringify!($name)); } @@ -67,77 +67,38 @@ macro_rules! installer_fixture { } installer_fixture!(abandoned_listed); -installer_fixture!( - alias_in_complex_constraints, - ignore = "mozart binary cannot yet run this fixture" -); -installer_fixture!( - alias_in_lock, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(alias_in_complex_constraints, ignore); +installer_fixture!(alias_in_lock, ignore); installer_fixture!(alias_in_lock2); -installer_fixture!( - alias_on_unloadable_package, - ignore = "mozart binary cannot yet run this fixture" -); -installer_fixture!( - alias_solver_problems, - ignore = "mozart binary cannot yet run this fixture" -); -installer_fixture!( - alias_solver_problems2, - ignore = "mozart binary cannot yet run this fixture" -); -installer_fixture!( - alias_with_reference, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(alias_on_unloadable_package, ignore); +installer_fixture!(alias_solver_problems, ignore); +installer_fixture!(alias_solver_problems2, ignore); +installer_fixture!(alias_with_reference, ignore); installer_fixture!(aliased_priority); installer_fixture!(aliased_priority_conflicting); -installer_fixture!( - aliases_with_require_dev, - ignore = "mozart binary cannot yet run this fixture" -); -installer_fixture!( - broken_deps_do_not_replace, - ignore = "mozart binary cannot yet run this fixture" -); -installer_fixture!( - circular_dependency, - ignore = "mozart binary cannot yet run this fixture" -); -installer_fixture!( - circular_dependency2, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(aliases_with_require_dev, ignore); +installer_fixture!(broken_deps_do_not_replace, ignore); +installer_fixture!(circular_dependency, ignore); +installer_fixture!(circular_dependency2, ignore); installer_fixture!(circular_dependency_errors); installer_fixture!(conflict_against_provided_by_dep_package_works); installer_fixture!(conflict_against_provided_package_works); installer_fixture!(conflict_against_replaced_by_dep_package_problem); -installer_fixture!( - conflict_against_replaced_package_problem, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(conflict_against_replaced_package_problem, ignore); installer_fixture!(conflict_between_dependents); installer_fixture!(conflict_between_root_and_dependent); installer_fixture!(conflict_downgrade); installer_fixture!(conflict_downgrade_nested); installer_fixture!( conflict_on_root_with_alias_prevents_update_if_not_required, - ignore = "mozart binary cannot yet run this fixture" -); -installer_fixture!( - conflict_with_alias_in_lock_does_prevents_install, - ignore = "mozart binary cannot yet run this fixture" + ignore ); +installer_fixture!(conflict_with_alias_in_lock_does_prevents_install, ignore); installer_fixture!(conflict_with_alias_prevents_update); -installer_fixture!( - conflict_with_alias_prevents_update_if_not_required, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(conflict_with_alias_prevents_update_if_not_required, ignore); installer_fixture!( conflict_with_all_dependencies_option_dont_recommend_to_use_it, - ignore = "mozart binary cannot yet run this fixture" + ignore ); installer_fixture!(deduplicate_solver_problems); installer_fixture!(disjunctive_multi_constraints); @@ -145,40 +106,19 @@ installer_fixture!(full_update_minimal_changes); installer_fixture!(github_issues_4319); installer_fixture!(github_issues_4795); installer_fixture!(github_issues_4795_2); -installer_fixture!( - github_issues_7051, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(github_issues_7051, ignore); installer_fixture!(github_issues_8902); -installer_fixture!( - github_issues_8903, - ignore = "mozart binary cannot yet run this fixture" -); -installer_fixture!( - github_issues_9012, - ignore = "mozart binary cannot yet run this fixture" -); -installer_fixture!( - github_issues_9290, - ignore = "mozart binary cannot yet run this fixture" -); -installer_fixture!( - hint_main_rename, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(github_issues_8903, ignore); +installer_fixture!(github_issues_9012, ignore); +installer_fixture!(github_issues_9290, ignore); +installer_fixture!(hint_main_rename, ignore); installer_fixture!(install_aliased_alias); -installer_fixture!( - install_branch_alias_composer_repo, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(install_branch_alias_composer_repo, ignore); installer_fixture!(install_dev); installer_fixture!(install_dev_using_dist); installer_fixture!(install_forces_reinstall_if_abandon_changes); installer_fixture!(install_from_incomplete_lock); -installer_fixture!( - install_from_incomplete_lock_with_ignore, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(install_from_incomplete_lock_with_ignore, ignore); installer_fixture!(install_from_lock_removes_package); installer_fixture!(install_funding_notice); installer_fixture!(install_funding_notice_env); @@ -186,59 +126,35 @@ installer_fixture!(install_funding_notice_not_displayed_env); installer_fixture!(install_ignore_platform_package_requirement_list); installer_fixture!(install_ignore_platform_package_requirement_wildcard); installer_fixture!(install_ignore_platform_package_requirements); -installer_fixture!( - install_missing_alias_from_lock, - ignore = "mozart binary cannot yet run this fixture" -); -installer_fixture!( - install_overridden_platform_packages, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(install_missing_alias_from_lock, ignore); +installer_fixture!(install_overridden_platform_packages, ignore); installer_fixture!(install_package_and_its_provider_skips_original); installer_fixture!(install_prefers_repos_over_package_versions); installer_fixture!(install_reference); -installer_fixture!( - install_security_advisory_matching_dependency, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(install_security_advisory_matching_dependency, ignore); installer_fixture!(install_self_from_root); installer_fixture!(install_simple); installer_fixture!(install_without_lock); installer_fixture!(load_replaced_package_if_replacer_dropped); installer_fixture!(outdated_lock_file_fails_install); installer_fixture!(outdated_lock_file_with_new_platform_reqs_fails); -installer_fixture!( - partial_update_always_updates_symlinked_path_repos, - ignore = "mozart binary cannot yet run this fixture" -); -installer_fixture!( - partial_update_downgrades_non_allow_listed_unstable, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(partial_update_always_updates_symlinked_path_repos, ignore); +installer_fixture!(partial_update_downgrades_non_allow_listed_unstable, ignore); installer_fixture!(partial_update_forces_dev_reference_from_lock_for_non_updated_packages); installer_fixture!(partial_update_from_lock); installer_fixture!(partial_update_from_lock_with_root_alias); installer_fixture!(partial_update_installs_from_lock_even_missing); installer_fixture!(partial_update_keeps_older_dep_if_still_required); installer_fixture!(partial_update_keeps_older_dep_if_still_required_with_provide); -installer_fixture!( - partial_update_loads_root_aliases_for_path_repos, - ignore = "mozart binary cannot yet run this fixture" -); -installer_fixture!( - partial_update_security_advisory_matching_locked_dep, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(partial_update_loads_root_aliases_for_path_repos, ignore); +installer_fixture!(partial_update_security_advisory_matching_locked_dep, ignore); installer_fixture!( partial_update_security_advisory_matching_locked_dep_with_dependencies, - ignore = "mozart binary cannot yet run this fixture" + ignore ); installer_fixture!(partial_update_with_dependencies_provide); installer_fixture!(partial_update_with_dependencies_replace); -installer_fixture!( - partial_update_with_deps_warns_root, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(partial_update_with_deps_warns_root, ignore); installer_fixture!(partial_update_with_symlinked_path_repos); installer_fixture!(partial_update_without_lock); installer_fixture!(platform_ext_solver_problems); @@ -246,71 +162,47 @@ installer_fixture!(plugins_are_installed_first); installer_fixture!(prefer_lowest_branches); installer_fixture!(problems_reduce_versions); installer_fixture!(provider_can_coexist_with_other_version_of_provided); -installer_fixture!( - provider_conflicts, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(provider_conflicts, ignore); installer_fixture!(provider_conflicts2); installer_fixture!(provider_conflicts3); -installer_fixture!( - provider_dev_require_can_satisfy_require, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(provider_dev_require_can_satisfy_require, ignore); installer_fixture!(provider_gets_picked_together_with_other_version_of_provided); installer_fixture!( provider_gets_picked_together_with_other_version_of_provided_conflict, - ignore = "mozart binary cannot yet run this fixture" + ignore ); installer_fixture!(provider_gets_picked_together_with_other_version_of_provided_indirect); installer_fixture!(provider_packages_can_be_installed_if_selected); installer_fixture!( provider_packages_can_be_installed_together_with_provided_if_both_installable, - ignore = "mozart binary cannot yet run this fixture" + ignore ); installer_fixture!( provider_packages_can_not_be_installed_unless_selected, - ignore = "mozart binary cannot yet run this fixture" + ignore ); installer_fixture!(provider_satisfies_its_own_requirement); installer_fixture!(remove_deletes_unused_deps); installer_fixture!(remove_does_nothing_if_removal_requires_update_of_dep); -installer_fixture!( - replace_alias, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(replace_alias, ignore); installer_fixture!(replace_priorities); installer_fixture!(replace_range_require_single_version); installer_fixture!(replace_root_require); installer_fixture!(replaced_packages_should_not_be_installed); installer_fixture!( replaced_packages_should_not_be_installed_when_installing_from_lock, - ignore = "mozart binary cannot yet run this fixture" + ignore ); installer_fixture!(replacer_satisfies_its_own_requirement); -installer_fixture!( - repositories_priorities, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(repositories_priorities, ignore); installer_fixture!(repositories_priorities2); installer_fixture!(repositories_priorities3); -installer_fixture!( - repositories_priorities4, - ignore = "mozart binary cannot yet run this fixture" -); -installer_fixture!( - repositories_priorities5, - ignore = "mozart binary cannot yet run this fixture" -); -installer_fixture!( - root_alias_change_with_circular_dep, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(repositories_priorities4, ignore); +installer_fixture!(repositories_priorities5, ignore); +installer_fixture!(root_alias_change_with_circular_dep, ignore); installer_fixture!(root_alias_gets_loaded_for_locked_pkgs); installer_fixture!(root_requirements_do_not_affect_locked_versions); -installer_fixture!( - solver_problem_with_hash_in_branch, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(solver_problem_with_hash_in_branch, ignore); installer_fixture!(solver_problems); installer_fixture!(solver_problems_with_disabled_platform); installer_fixture!(suggest_installed); @@ -320,16 +212,13 @@ installer_fixture!(suggest_replaced); installer_fixture!(suggest_uninstalled); installer_fixture!( unbounded_conflict_does_not_match_default_branch_with_branch_alias, - ignore = "mozart binary cannot yet run this fixture" + ignore ); installer_fixture!(unbounded_conflict_does_not_match_default_branch_with_numeric_branch); -installer_fixture!( - unbounded_conflict_matches_default_branch, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(unbounded_conflict_matches_default_branch, ignore); installer_fixture!( update_abandoned_package_required_but_blocked_via_audit_config, - ignore = "mozart binary cannot yet run this fixture" + ignore ); installer_fixture!(update_alias); installer_fixture!(update_alias_lock); @@ -349,10 +238,7 @@ installer_fixture!(update_allow_list_removes_unused); installer_fixture!(update_allow_list_require_new_replace); installer_fixture!(update_allow_list_warns_non_existing_patterns); installer_fixture!(update_allow_list_with_dependencies); -installer_fixture!( - update_allow_list_with_dependencies_alias, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(update_allow_list_with_dependencies_alias, ignore); installer_fixture!(update_allow_list_with_dependencies_new_requirement); installer_fixture!(update_allow_list_with_dependencies_require_new); installer_fixture!(update_allow_list_with_dependencies_require_new_replace); @@ -362,10 +248,7 @@ installer_fixture!(update_changes_url); installer_fixture!(update_dev_ignores_providers); installer_fixture!(update_dev_packages_updates_repo_url); installer_fixture!(update_dev_to_new_ref_picks_up_changes); -installer_fixture!( - update_downgrades_unstable_packages, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(update_downgrades_unstable_packages, ignore); installer_fixture!(update_ignore_platform_package_requirement_list); installer_fixture!(update_ignore_platform_package_requirement_list_upper_bounds); installer_fixture!(update_ignore_platform_package_requirement_wildcard); @@ -383,7 +266,7 @@ installer_fixture!(update_package_present_in_lock_but_not_in_remote); installer_fixture!(update_package_present_in_lock_but_not_in_remote_due_to_min_stability); installer_fixture!( update_package_present_in_lower_repo_prio_but_not_main_due_to_min_stability, - ignore = "mozart binary cannot yet run this fixture" + ignore ); installer_fixture!(update_picks_up_change_of_vcs_type); installer_fixture!(update_prefer_lowest_stable); @@ -391,13 +274,10 @@ installer_fixture!(update_reference); installer_fixture!(update_reference_picks_latest); installer_fixture!(update_removes_unused_locked_dep); installer_fixture!(update_requiring_decision_reverts_and_learning_positive_literals); -installer_fixture!( - update_security_advisory_matching_direct_dependency, - ignore = "mozart binary cannot yet run this fixture" -); +installer_fixture!(update_security_advisory_matching_direct_dependency, ignore); installer_fixture!( update_security_advisory_matching_indirect_dependency, - ignore = "mozart binary cannot yet run this fixture" + ignore ); installer_fixture!(update_syncs_outdated); installer_fixture!(update_to_empty_from_blank); -- cgit v1.3.1