aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/shirabe
diff options
context:
space:
mode:
Diffstat (limited to 'crates/shirabe')
-rw-r--r--crates/shirabe/src/command/archive_command.rs2
-rw-r--r--crates/shirabe/src/factory.rs124
-rw-r--r--crates/shirabe/src/installer/installation_manager.rs148
-rw-r--r--crates/shirabe/src/package/version/version_guesser.rs24
-rw-r--r--crates/shirabe/src/repository/installed_filesystem_repository.rs26
-rw-r--r--crates/shirabe/tests/installer_test.rs1207
6 files changed, 1509 insertions, 22 deletions
diff --git a/crates/shirabe/src/command/archive_command.rs b/crates/shirabe/src/command/archive_command.rs
index 91e8036..ce1d496 100644
--- a/crates/shirabe/src/command/archive_command.rs
+++ b/crates/shirabe/src/command/archive_command.rs
@@ -288,7 +288,7 @@ impl ArchiveCommand {
composer_archive_manager_ref = composer_archive_manager.borrow_mut();
&mut *composer_archive_manager_ref
} else {
- let factory = Factory;
+ let factory = Factory::default();
let process = std::rc::Rc::new(std::cell::RefCell::new(ProcessExecutor::new(None)));
let http_downloader = std::rc::Rc::new(std::cell::RefCell::new(
Factory::create_http_downloader(io.clone(), config, indexmap::IndexMap::new())?,
diff --git a/crates/shirabe/src/factory.rs b/crates/shirabe/src/factory.rs
index b3d478c..dcbdf5d 100644
--- a/crates/shirabe/src/factory.rs
+++ b/crates/shirabe/src/factory.rs
@@ -85,7 +85,12 @@ impl DisablePlugins {
}
/// Creates a configured instance of composer.
-pub struct Factory;
+#[derive(Default)]
+pub struct Factory {
+ /// For testing only: when true, the overridable `create_*`/`add_*` hooks behave like
+ /// `Composer\Test\Mock\FactoryMock`. `false` in production.
+ mock: bool,
+}
impl Factory {
fn get_home_dir() -> anyhow::Result<String> {
@@ -501,7 +506,11 @@ impl Factory {
}
// Load config and override with local config/auth config
- let mut config = Self::create_config(Some(io.clone()), Some(&cwd))?;
+ let mut config = if self.mock {
+ Self::__create_config_mock(Some(&cwd))?
+ } else {
+ Self::create_config(Some(io.clone()), Some(&cwd))?
+ };
let is_global = local_config_source != Config::SOURCE_UNKNOWN
&& realpath(&config.get_str("home")?) == realpath(&dirname(&local_config_source));
config.merge(&local_config_data, &local_config_source);
@@ -653,12 +662,22 @@ impl Factory {
// load package
let parser = VersionParser::new();
- let guesser = VersionGuesser::new(
- config.clone(),
- process.clone(),
- parser.clone(),
- Some(io.clone()),
- );
+ // FactoryMock::loadRootPackage swaps in a VersionGuesserMock (guessVersion returns null).
+ let guesser = if self.mock {
+ VersionGuesser::__new_mock(
+ config.clone(),
+ process.clone(),
+ parser.clone(),
+ Some(io.clone()),
+ )
+ } else {
+ VersionGuesser::new(
+ config.clone(),
+ process.clone(),
+ parser.clone(),
+ Some(io.clone()),
+ )
+ };
let mut loader = self.load_root_package(
rm.clone(),
config.clone(),
@@ -890,7 +909,7 @@ impl Factory {
disable_plugins: DisablePlugins,
disable_scripts: bool,
) -> Option<PartialComposerHandle> {
- let factory = Self;
+ let factory = Self::default();
let config = Self::create_config(Some(io.clone()), None).ok()?;
factory.create_global_composer(io, &config, disable_plugins, disable_scripts, true)
@@ -904,6 +923,15 @@ impl Factory {
root_package: RootPackageInterfaceHandle,
process: Option<&std::rc::Rc<std::cell::RefCell<ProcessExecutor>>>,
) {
+ // FactoryMock::addLocalRepository installs a bare InstalledArrayRepository instead.
+ if self.mock {
+ rm.set_local_repository(crate::repository::RepositoryInterfaceHandle::new(
+ crate::repository::InstalledArrayRepository::new()
+ .expect("InstalledArrayRepository::new should not fail"),
+ ));
+ return;
+ }
+
let fs = process
.map(|p| std::rc::Rc::new(std::cell::RefCell::new(Filesystem::new(Some(p.clone())))));
@@ -1210,6 +1238,10 @@ impl Factory {
io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>,
event_dispatcher: Option<std::rc::Rc<std::cell::RefCell<EventDispatcher>>>,
) -> InstallationManager {
+ // FactoryMock::createInstallationManager returns a recording InstallationManagerMock.
+ if self.mock {
+ return InstallationManager::__new_mock(r#loop, io, event_dispatcher);
+ }
InstallationManager::new(r#loop, io, event_dispatcher)
}
@@ -1220,6 +1252,11 @@ impl Factory {
io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>,
process: Option<&std::rc::Rc<std::cell::RefCell<ProcessExecutor>>>,
) {
+ // FactoryMock::createDefaultInstallers is a noop (no installers registered).
+ if self.mock {
+ return;
+ }
+
let fs = std::rc::Rc::new(std::cell::RefCell::new(Filesystem::new(
process.map(std::rc::Rc::clone),
)));
@@ -1307,7 +1344,7 @@ impl Factory {
disable_plugins: DisablePlugins,
disable_scripts: bool,
) -> anyhow::Result<ComposerHandle> {
- let factory = Self;
+ let factory = Self::default();
// for BC reasons, if a config is passed in either as array or a path that is not the default composer.json path
// we disable local plugins as they really should not be loaded from CWD
@@ -1338,6 +1375,73 @@ impl Factory {
})
}
+ /// For testing only: equivalent of `Composer\Test\Mock\FactoryMock::create`. Builds the Composer
+ /// the same way as `create`, but with the FactoryMock overrides enabled (see the `self.mock`
+ /// branches in `create_composer`/`add_local_repository`/`create_installation_manager`/
+ /// `create_default_installers`).
+ pub fn __create_mock(
+ io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>,
+ config: Option<LocalConfigInput>,
+ disable_plugins: DisablePlugins,
+ disable_scripts: bool,
+ ) -> anyhow::Result<ComposerHandle> {
+ let factory = Self { mock: true };
+
+ let default_composer_file = Self::get_composer_file()?;
+ let config_is_default = matches!(
+ config.as_ref(),
+ Some(LocalConfigInput::Path(p)) if *p == default_composer_file
+ );
+ let disable_plugins = if config.is_some()
+ && !config_is_default
+ && matches!(disable_plugins, DisablePlugins::None)
+ {
+ DisablePlugins::Local
+ } else {
+ disable_plugins
+ };
+
+ let composer =
+ factory.create_composer(io, config, disable_plugins, None, true, disable_scripts)?;
+ composer.as_full().ok_or_else(|| {
+ anyhow::anyhow!(RuntimeException {
+ message: "Composer expected with fullLoad=true".to_string(),
+ code: 0,
+ })
+ })
+ }
+
+ /// For testing only: equivalent of `FactoryMock::createConfig`. Builds a `Config` with a unique
+ /// temp home and packagist disabled, without loading the global config/auth files.
+ fn __create_config_mock(cwd: Option<&str>) -> anyhow::Result<Config> {
+ use std::sync::atomic::{AtomicU64, Ordering};
+ static NEXT: AtomicU64 = AtomicU64::new(0);
+
+ let mut config = Config::new(true, cwd.map(|s| s.to_string()));
+
+ let home = std::env::temp_dir().join(format!(
+ "shirabe-test-{}-{}",
+ std::process::id(),
+ NEXT.fetch_add(1, Ordering::Relaxed)
+ ));
+ let _ = std::fs::create_dir_all(&home);
+
+ let mut config_section: IndexMap<String, PhpMixed> = IndexMap::new();
+ config_section.insert(
+ "home".to_string(),
+ PhpMixed::String(home.to_string_lossy().into_owned()),
+ );
+ let mut repositories: IndexMap<String, PhpMixed> = IndexMap::new();
+ repositories.insert("packagist".to_string(), PhpMixed::Bool(false));
+
+ let mut merge: IndexMap<String, PhpMixed> = IndexMap::new();
+ merge.insert("config".to_string(), PhpMixed::Array(config_section));
+ merge.insert("repositories".to_string(), PhpMixed::Array(repositories));
+ config.merge(&merge, Config::SOURCE_UNKNOWN);
+
+ Ok(config)
+ }
+
/// If you are calling this in a plugin, you probably should instead use `$composer->getLoop()->getHttpDownloader()`
pub fn create_http_downloader(
io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>,
diff --git a/crates/shirabe/src/installer/installation_manager.rs b/crates/shirabe/src/installer/installation_manager.rs
index a2cc228..ea6f668 100644
--- a/crates/shirabe/src/installer/installation_manager.rs
+++ b/crates/shirabe/src/installer/installation_manager.rs
@@ -40,6 +40,19 @@ pub struct InstallationManager {
io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>,
event_dispatcher: Option<std::rc::Rc<std::cell::RefCell<EventDispatcher>>>,
output_progress: bool,
+ /// For testing only: present iff this manager behaves like
+ /// `Composer\Test\Mock\InstallationManagerMock`, recording operations instead of executing
+ /// them. `None` in production.
+ mock: Option<InstallationManagerMockState>,
+}
+
+/// For testing only: recorded operations for the `InstallationManagerMock` behavior.
+#[derive(Debug, Default)]
+struct InstallationManagerMockState {
+ installed: Vec<PackageInterfaceHandle>,
+ updated: Vec<(PackageInterfaceHandle, PackageInterfaceHandle)>,
+ uninstalled: Vec<PackageInterfaceHandle>,
+ trace: Vec<String>,
}
impl InstallationManager {
@@ -56,9 +69,55 @@ impl InstallationManager {
io,
event_dispatcher,
output_progress: false,
+ mock: None,
}
}
+ /// For testing only: builds a manager that records operations instead of executing them,
+ /// mirroring `Composer\Test\Mock\InstallationManagerMock`.
+ pub fn __new_mock(
+ loop_: std::rc::Rc<std::cell::RefCell<Loop>>,
+ io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>,
+ event_dispatcher: Option<std::rc::Rc<std::cell::RefCell<EventDispatcher>>>,
+ ) -> Self {
+ Self {
+ mock: Some(InstallationManagerMockState::default()),
+ ..Self::new(loop_, io, event_dispatcher)
+ }
+ }
+
+ /// For testing only: the trace of stringified operations recorded by the mock.
+ pub fn __get_trace(&self) -> Vec<String> {
+ self.mock
+ .as_ref()
+ .map(|m| m.trace.clone())
+ .unwrap_or_default()
+ }
+
+ /// For testing only: packages passed to install (and markAliasInstalled) operations.
+ pub fn __get_installed_packages(&self) -> Vec<PackageInterfaceHandle> {
+ self.mock
+ .as_ref()
+ .map(|m| m.installed.clone())
+ .unwrap_or_default()
+ }
+
+ /// For testing only: (initial, target) package pairs passed to update operations.
+ pub fn __get_updated_packages(&self) -> Vec<(PackageInterfaceHandle, PackageInterfaceHandle)> {
+ self.mock
+ .as_ref()
+ .map(|m| m.updated.clone())
+ .unwrap_or_default()
+ }
+
+ /// For testing only: packages passed to uninstall (and markAliasUninstalled) operations.
+ pub fn __get_uninstalled_packages(&self) -> Vec<PackageInterfaceHandle> {
+ self.mock
+ .as_ref()
+ .map(|m| m.uninstalled.clone())
+ .unwrap_or_default()
+ }
+
pub fn reset(&mut self) {
self.notifiable_packages = IndexMap::new();
FileDownloader::reset_download_metadata();
@@ -126,6 +185,11 @@ impl InstallationManager {
repo: &dyn InstalledRepositoryInterface,
package: PackageInterfaceHandle,
) -> Result<bool> {
+ // For testing only (ref InstallationManagerMock::isPackageInstalled).
+ if self.mock.is_some() {
+ return Ok(repo.has_package(package));
+ }
+
if let Some(alias) = package.as_alias() {
let alias_of: PackageInterfaceHandle = alias.get_alias_of().into();
return Ok(
@@ -165,6 +229,70 @@ impl InstallationManager {
run_scripts: bool,
download_only: bool,
) -> Result<()> {
+ // For testing only: the mock records each operation and mutates the repo directly,
+ // skipping the download step (ref InstallationManagerMock::execute). The alias operations'
+ // repo mutation is inlined (rather than calling mark_alias_*) so `self.mock` can stay
+ // borrowed across the loop without also borrowing `&self`.
+ if let Some(mock) = self.mock.as_mut() {
+ let _ = (dev_mode, run_scripts, download_only);
+ for operation in operations {
+ let trace = shirabe_php_shim::strip_tags(&operation.to_string());
+ match operation.get_operation_type().as_str() {
+ "install" => {
+ let op = operation.as_install_operation().expect("install operation");
+ let package = op.get_package();
+ mock.installed.push(package.clone());
+ mock.trace.push(trace);
+ repo.add_package(PackageInterfaceHandle::dup(&package));
+ }
+ "update" => {
+ let op = operation.as_update_operation().expect("update operation");
+ let initial = op.get_initial_package().clone();
+ let target = op.get_target_package().clone();
+ mock.updated.push((initial.clone(), target.clone()));
+ mock.trace.push(trace);
+ repo.remove_package(initial);
+ if !repo.has_package(target.clone()) {
+ repo.add_package(PackageInterfaceHandle::dup(&target));
+ }
+ }
+ "uninstall" => {
+ let op = operation
+ .as_uninstall_operation()
+ .expect("uninstall operation");
+ let package = op.get_package();
+ mock.uninstalled.push(package.clone());
+ mock.trace.push(trace);
+ repo.remove_package(package);
+ }
+ "markAliasInstalled" => {
+ let op = operation
+ .as_any()
+ .downcast_ref::<MarkAliasInstalledOperation>()
+ .expect("markAliasInstalled operation");
+ let package = op.get_package();
+ mock.installed.push(package.clone().into());
+ mock.trace.push(trace);
+ if !repo.has_package(package.clone().into()) {
+ repo.add_package(PackageInterfaceHandle::dup(&package.into()));
+ }
+ }
+ "markAliasUninstalled" => {
+ let op = operation
+ .as_any()
+ .downcast_ref::<MarkAliasUninstalledOperation>()
+ .expect("markAliasUninstalled operation");
+ let package = op.get_package();
+ mock.uninstalled.push(package.clone().into());
+ mock.trace.push(trace);
+ repo.remove_package(package.into());
+ }
+ other => panic!("unknown operation type: {}", other),
+ }
+ }
+ return Ok(());
+ }
+
// @var array<callable(): ?PromiseInterface<void|null>> $cleanupPromises
let mut cleanup_promises: IndexMap<
i64,
@@ -623,6 +751,11 @@ impl InstallationManager {
/// Returns the installation path of a package
pub fn get_install_path(&mut self, package: PackageInterfaceHandle) -> Option<String> {
+ // For testing only (ref InstallationManagerMock::getInstallPath).
+ if self.mock.is_some() {
+ return Some(format!("vendor/{}", package.get_name()));
+ }
+
let installer = self.get_installer(&package.get_type()).ok()?;
installer.get_install_path(package)
@@ -633,6 +766,11 @@ impl InstallationManager {
}
pub fn notify_installs(&mut self, _io: std::rc::Rc<std::cell::RefCell<dyn IOInterface>>) {
+ // For testing only (ref InstallationManagerMock::notifyInstalls is a noop).
+ if self.mock.is_some() {
+ return;
+ }
+
// TODO(phase-c-promise): PHP collects every http_downloader.add() promise and runs them via
// Loop::wait; the single-threaded sync bridge block_on's each notification serially instead.
let result: Result<()> = (|| -> Result<()> {
@@ -792,6 +930,12 @@ impl InstallationManager {
// plugins may swap in a replacement. The interface captures the methods reached through Composer's
// accessor and through the `&mut dyn InstallationManagerInterface` references fed from it.
pub trait InstallationManagerInterface: std::fmt::Debug {
+ /// For testing only: lets a test recover the concrete manager (e.g. the recording mock) from a
+ /// trait object returned by `Composer::get_installation_manager`.
+ fn as_any(&self) -> &dyn std::any::Any {
+ unimplemented!("as_any is only implemented for the concrete InstallationManager")
+ }
+
fn add_installer(&mut self, installer: Box<dyn InstallerInterface>);
fn remove_installer(&mut self, installer: &dyn InstallerInterface);
fn disable_plugins(&mut self);
@@ -815,6 +959,10 @@ pub trait InstallationManagerInterface: std::fmt::Debug {
}
impl InstallationManagerInterface for InstallationManager {
+ fn as_any(&self) -> &dyn std::any::Any {
+ self
+ }
+
fn add_installer(&mut self, installer: Box<dyn InstallerInterface>) {
self.add_installer(installer);
}
diff --git a/crates/shirabe/src/package/version/version_guesser.rs b/crates/shirabe/src/package/version/version_guesser.rs
index 8e8cc32..6fba7c4 100644
--- a/crates/shirabe/src/package/version/version_guesser.rs
+++ b/crates/shirabe/src/package/version/version_guesser.rs
@@ -49,6 +49,10 @@ pub struct VersionGuesser {
/// @var IOInterface|null
io: Option<std::rc::Rc<std::cell::RefCell<dyn IOInterface>>>,
+
+ /// For testing only: when true, `guess_version` always returns `None`, mirroring
+ /// `Composer\Test\Mock\VersionGuesserMock`. `false` in production.
+ mock: bool,
}
/// PHP: @phpstan-type Version array{version, commit, pretty_version, feature_version?, feature_pretty_version?}
@@ -73,6 +77,21 @@ impl VersionGuesser {
process,
version_parser,
io,
+ mock: false,
+ }
+ }
+
+ /// For testing only: builds a guesser whose `guess_version` always returns `None`, mirroring
+ /// `Composer\Test\Mock\VersionGuesserMock`.
+ pub fn __new_mock(
+ config: std::rc::Rc<std::cell::RefCell<Config>>,
+ process: std::rc::Rc<std::cell::RefCell<ProcessExecutor>>,
+ version_parser: VersionParser,
+ io: Option<std::rc::Rc<std::cell::RefCell<dyn IOInterface>>>,
+ ) -> Self {
+ Self {
+ mock: true,
+ ..Self::new(config, process, version_parser, io)
}
}
@@ -85,6 +104,11 @@ impl VersionGuesser {
package_config: &IndexMap<String, PhpMixed>,
path: &str,
) -> Result<Option<VersionData>> {
+ // For testing only (ref VersionGuesserMock::guessVersion returns null).
+ if self.mock {
+ return Ok(None);
+ }
+
if !function_exists("proc_open") {
return Ok(None);
}
diff --git a/crates/shirabe/src/repository/installed_filesystem_repository.rs b/crates/shirabe/src/repository/installed_filesystem_repository.rs
index 3cd21eb..8ce07e4 100644
--- a/crates/shirabe/src/repository/installed_filesystem_repository.rs
+++ b/crates/shirabe/src/repository/installed_filesystem_repository.rs
@@ -19,6 +19,9 @@ use shirabe_semver::constraint::AnyConstraint;
#[derive(Debug)]
pub struct InstalledFilesystemRepository {
inner: FilesystemRepository,
+ /// For testing only: when true, `reload` and `write` are no-ops, mirroring
+ /// `Composer\Test\Mock\InstalledFilesystemRepositoryMock`. `false` in production.
+ mock: bool,
}
impl InstalledFilesystemRepository {
@@ -35,6 +38,21 @@ impl InstalledFilesystemRepository {
root_package,
filesystem,
)?,
+ mock: false,
+ })
+ }
+
+ /// For testing only: builds a repository whose `reload`/`write` are no-ops, mirroring
+ /// `Composer\Test\Mock\InstalledFilesystemRepositoryMock`.
+ pub fn __new_mock(
+ repository_file: JsonFile,
+ dump_versions: bool,
+ root_package: Option<RootPackageInterfaceHandle>,
+ filesystem: Option<std::rc::Rc<std::cell::RefCell<Filesystem>>>,
+ ) -> Result<Self> {
+ Ok(Self {
+ mock: true,
+ ..Self::new(repository_file, dump_versions, root_package, filesystem)?
})
}
@@ -59,6 +77,10 @@ impl WritableRepositoryInterface for InstalledFilesystemRepository {
dev_mode: bool,
installation_manager: &mut crate::installer::InstallationManager,
) -> anyhow::Result<()> {
+ // For testing only (ref InstalledFilesystemRepositoryMock::write is a noop).
+ if self.mock {
+ return Ok(());
+ }
self.inner.write(dev_mode, installation_manager)
}
@@ -75,6 +97,10 @@ impl WritableRepositoryInterface for InstalledFilesystemRepository {
}
fn reload(&mut self) -> anyhow::Result<()> {
+ // For testing only (ref InstalledFilesystemRepositoryMock::reload is a noop).
+ if self.mock {
+ return Ok(());
+ }
self.inner.reload()
}
diff --git a/crates/shirabe/tests/installer_test.rs b/crates/shirabe/tests/installer_test.rs
index e2d4939..a89209b 100644
--- a/crates/shirabe/tests/installer_test.rs
+++ b/crates/shirabe/tests/installer_test.rs
@@ -1,10 +1,60 @@
//! ref: composer/tests/Composer/Test/InstallerTest.php
-// These are large end-to-end installer integration cases driven by fixtures and a mocked
-// Composer/IO/repositories; the full install pipeline (and constraint parsing through a
-// look-around regex) is not ported.
+#[path = "common/config_stub.rs"]
+mod config_stub;
+#[path = "common/test_case.rs"]
+mod test_case;
+use config_stub::ConfigStubBuilder;
+use test_case::{get_package, get_version_constraint};
+
+use indexmap::IndexMap;
+use std::cell::RefCell;
+use std::rc::Rc;
+
+use shirabe::advisory::{AuditConfig, Auditor};
+use shirabe::autoload::{AutoloadGeneratorInterface, ClassLoader};
+use shirabe::config::Config;
+use shirabe::console::application::ApplicationHandle;
+use shirabe::dependency_resolver::{Transaction, UpdateAllowTransitiveDeps};
+use shirabe::downloader::{DownloadManagerInterface, DownloaderInterface};
+use shirabe::event_dispatcher::{Callable, EventDispatcherInterface, EventInterface};
+use shirabe::factory::{DisablePlugins, Factory, LocalConfigInput};
+use shirabe::filter::platform_requirement_filter::{
+ PlatformRequirementFilterFactory, PlatformRequirementFilterInterface,
+};
+use shirabe::installer::{InstallationManager, Installer};
+use shirabe::io::IOInterface;
+use shirabe::io::buffer_io::BufferIO;
+use shirabe::json::JsonFile;
+use shirabe::package::dumper::ArrayDumper;
+use shirabe::package::{
+ Link, Locker, LockerInterface, PackageInterfaceHandle, RootPackageHandle,
+ RootPackageInterfaceHandle,
+};
+use shirabe::repository::{
+ ArrayRepository, InstalledArrayRepository, InstalledRepositoryInterface,
+ RepositoryInterfaceHandle, RepositoryManager, RepositoryManagerInterface,
+};
+use shirabe::util::http_downloader::HttpDownloader;
+use shirabe::util::r#loop::Loop;
use shirabe::util::platform::Platform;
+use shirabe::util::process_executor::ProcessExecutor;
+use shirabe_class_map_generator::class_map::ClassMap;
+use shirabe_external_packages::composer::pcre::preg::Preg;
+use shirabe_external_packages::symfony::console::command::command::Command as SymfonyCommand;
+use shirabe_external_packages::symfony::console::command::command::CommandData;
+use shirabe_external_packages::symfony::console::input::input_argument::InputArgument;
+use shirabe_external_packages::symfony::console::input::input_interface::InputInterface;
+use shirabe_external_packages::symfony::console::input::input_option::InputOption;
+use shirabe_external_packages::symfony::console::input::string_input::StringInput;
+use shirabe_external_packages::symfony::console::output::output_interface::{
+ OutputInterface, VERBOSITY_NORMAL,
+};
+use shirabe_external_packages::symfony::console::output::stream_output::StreamOutput;
+use shirabe_php_shim::{PREG_SPLIT_DELIM_CAPTURE, PhpMixed};
+use shirabe_semver::VersionParser;
+use shirabe_semver::constraint::AnyConstraint;
// The chdir back to prevCwd (cwd management) and removeDirectory of tempComposerHome (a
// path produced by the unported install pipeline) are not ported; only the env clears are.
@@ -21,30 +71,1165 @@ impl Drop for TearDown {
}
}
+// PHP mocks `Composer\Downloader\DownloadManager` with getMockBuilder; PHPUnit mocks are permissive
+// (every method returns null), so the Rust equivalent is a no-op stub over the trait seam.
+#[derive(Debug)]
+struct StubDownloadManager;
+
+#[async_trait::async_trait(?Send)]
+impl DownloadManagerInterface for StubDownloadManager {
+ fn set_prefer_source(&mut self, _prefer_source: bool) {}
+ fn set_prefer_dist(&mut self, _prefer_dist: bool) {}
+ fn get_downloader_for_package(
+ &self,
+ _package: PackageInterfaceHandle,
+ ) -> anyhow::Result<Option<Rc<RefCell<dyn DownloaderInterface>>>> {
+ Ok(None)
+ }
+ async fn download(
+ &self,
+ _package: PackageInterfaceHandle,
+ _target_dir: &str,
+ _prev_package: Option<PackageInterfaceHandle>,
+ ) -> anyhow::Result<Option<PhpMixed>> {
+ Ok(None)
+ }
+ async fn prepare(
+ &self,
+ _type: &str,
+ _package: PackageInterfaceHandle,
+ _target_dir: &str,
+ _prev_package: Option<PackageInterfaceHandle>,
+ ) -> anyhow::Result<Option<PhpMixed>> {
+ Ok(None)
+ }
+ async fn install(
+ &self,
+ _package: PackageInterfaceHandle,
+ _target_dir: &str,
+ ) -> anyhow::Result<Option<PhpMixed>> {
+ Ok(None)
+ }
+ async fn update(
+ &self,
+ _initial: PackageInterfaceHandle,
+ _target: PackageInterfaceHandle,
+ _target_dir: &str,
+ ) -> anyhow::Result<Option<PhpMixed>> {
+ Ok(None)
+ }
+ async fn remove(
+ &self,
+ _package: PackageInterfaceHandle,
+ _target_dir: &str,
+ ) -> anyhow::Result<Option<PhpMixed>> {
+ Ok(None)
+ }
+ async fn cleanup(
+ &self,
+ _type: &str,
+ _package: PackageInterfaceHandle,
+ _target_dir: &str,
+ _prev_package: Option<PackageInterfaceHandle>,
+ ) -> anyhow::Result<Option<PhpMixed>> {
+ Ok(None)
+ }
+}
+
+// PHP mocks `Composer\EventDispatcher\EventDispatcher` with disableOriginalConstructor()->getMock();
+// a permissive no-op stub mirrors the PHPUnit mock.
+#[derive(Debug)]
+struct StubEventDispatcher;
+
+impl EventDispatcherInterface for StubEventDispatcher {
+ fn dispatch(
+ &mut self,
+ _event_name: Option<&str>,
+ _event: Option<&mut dyn EventInterface>,
+ ) -> anyhow::Result<i64> {
+ Ok(0)
+ }
+ fn dispatch_script(
+ &mut self,
+ _event_name: &str,
+ _dev_mode: bool,
+ _additional_args: Vec<String>,
+ _flags: IndexMap<String, PhpMixed>,
+ ) -> anyhow::Result<i64> {
+ Ok(0)
+ }
+ fn dispatch_installer_event(
+ &mut self,
+ _event_name: &str,
+ _dev_mode: bool,
+ _execute_operations: bool,
+ _transaction: Transaction,
+ ) -> anyhow::Result<i64> {
+ Ok(0)
+ }
+ fn add_listener(&mut self, _event_name: &str, _listener: Callable, _priority: i64) {}
+ fn has_event_listeners(&mut self, _event: &dyn EventInterface) -> bool {
+ false
+ }
+}
+
+// PHP mocks `Composer\Autoload\AutoloadGenerator` with disableOriginalConstructor()->getMock();
+// a permissive no-op stub mirrors the PHPUnit mock.
+#[derive(Debug)]
+struct StubAutoloadGenerator;
+
+impl AutoloadGeneratorInterface for StubAutoloadGenerator {
+ fn set_dev_mode(&mut self, _dev_mode: bool) {}
+ fn set_class_map_authoritative(&mut self, _class_map_authoritative: bool) {}
+ fn set_apcu(&mut self, _apcu: bool, _apcu_prefix: Option<String>) {}
+ fn set_run_scripts(&mut self, _run_scripts: bool) {}
+ fn set_dry_run(&mut self, _dry_run: bool) {}
+ fn set_platform_requirement_filter(
+ &mut self,
+ _platform_requirement_filter: Rc<dyn PlatformRequirementFilterInterface>,
+ ) {
+ }
+ #[allow(clippy::too_many_arguments)]
+ fn dump(
+ &mut self,
+ _config: &Config,
+ _local_repo: &mut dyn InstalledRepositoryInterface,
+ _root_package: RootPackageInterfaceHandle,
+ _installation_manager: &mut dyn shirabe::installer::InstallationManagerInterface,
+ _target_dir: &str,
+ _scan_psr_packages: bool,
+ _suffix: Option<String>,
+ _locker: Option<&mut dyn LockerInterface>,
+ _strict_ambiguous: bool,
+ ) -> anyhow::Result<ClassMap> {
+ Ok(ClassMap::new())
+ }
+ fn build_package_map(
+ &self,
+ _installation_manager: &mut dyn shirabe::installer::InstallationManagerInterface,
+ _root_package: RootPackageInterfaceHandle,
+ _packages: Vec<PackageInterfaceHandle>,
+ ) -> anyhow::Result<Vec<(PackageInterfaceHandle, Option<String>)>> {
+ Ok(vec![])
+ }
+ fn parse_autoloads(
+ &self,
+ _package_map: Vec<(PackageInterfaceHandle, Option<String>)>,
+ _root_package: RootPackageInterfaceHandle,
+ _filtered_dev_packages: PhpMixed,
+ ) -> IndexMap<String, PhpMixed> {
+ IndexMap::new()
+ }
+ fn create_loader(
+ &self,
+ _autoloads: &IndexMap<String, PhpMixed>,
+ _vendor_dir: Option<String>,
+ ) -> ClassLoader {
+ unimplemented!("create_loader is not reached by the installer test path")
+ }
+}
+
+/// ref: TestCase::getPackage with class `Composer\Package\RootPackage`.
+fn root_package(name: &str, version: &str) -> RootPackageHandle {
+ let normalized = VersionParser.normalize(version, None).unwrap();
+ RootPackageHandle::new(name.to_string(), normalized, version.to_string())
+}
+
+/// ref: `new Link($source, $target, $constraint, $type, $constraint->getPrettyString())`.
+fn link(source: &str, target: &str, constraint: AnyConstraint, r#type: &str) -> Link {
+ let pretty = constraint.get_pretty_string();
+ Link::new(
+ source.to_string(),
+ target.to_string(),
+ constraint,
+ Some(r#type.to_string()),
+ pretty,
+ )
+}
+
+/// One row of `provideInstaller`.
+struct InstallerCase {
+ root_package: RootPackageHandle,
+ repositories: Vec<RepositoryInterfaceHandle>,
+ expected_install: Vec<PackageInterfaceHandle>,
+ expected_update: Vec<(PackageInterfaceHandle, PackageInterfaceHandle)>,
+ expected_uninstall: Vec<PackageInterfaceHandle>,
+}
+
+/// ref: InstallerTest::provideInstaller
+fn provide_installer() -> Vec<InstallerCase> {
+ let mut cases = vec![];
+
+ // when A requires B and B requires A, and A is a non-published root package
+ // the install of B should succeed
+ let a = root_package("A", "1.0.0");
+ a.set_requires(IndexMap::from([(
+ "b".to_string(),
+ link(
+ "A",
+ "B",
+ get_version_constraint("=", "1.0.0"),
+ Link::TYPE_REQUIRE,
+ ),
+ )]));
+ let b = get_package("B", "1.0.0");
+ b.as_complete_package()
+ .unwrap()
+ .__set_requires(IndexMap::from([(
+ "a".to_string(),
+ link(
+ "B",
+ "A",
+ get_version_constraint("=", "1.0.0"),
+ Link::TYPE_REQUIRE,
+ ),
+ )]));
+
+ cases.push(InstallerCase {
+ root_package: a,
+ repositories: vec![RepositoryInterfaceHandle::new(
+ ArrayRepository::new(vec![b.clone()]).unwrap(),
+ )],
+ expected_install: vec![b],
+ expected_update: vec![],
+ expected_uninstall: vec![],
+ });
+
+ // #480: when A requires B and B requires A, and A is a published root package
+ // only B should be installed, as A is the root
+ let a = root_package("A", "1.0.0");
+ a.set_requires(IndexMap::from([(
+ "b".to_string(),
+ link(
+ "A",
+ "B",
+ get_version_constraint("=", "1.0.0"),
+ Link::TYPE_REQUIRE,
+ ),
+ )]));
+ let b = get_package("B", "1.0.0");
+ b.as_complete_package()
+ .unwrap()
+ .__set_requires(IndexMap::from([(
+ "a".to_string(),
+ link(
+ "B",
+ "A",
+ get_version_constraint("=", "1.0.0"),
+ Link::TYPE_REQUIRE,
+ ),
+ )]));
+
+ cases.push(InstallerCase {
+ root_package: a.clone(),
+ repositories: vec![RepositoryInterfaceHandle::new(
+ ArrayRepository::new(vec![a.into(), b.clone()]).unwrap(),
+ )],
+ expected_install: vec![b],
+ expected_update: vec![],
+ expected_uninstall: vec![],
+ });
+
+ // TODO why are there not more cases with uninstall/update?
+ cases
+}
+
+/// ref: InstallerTest::makePackagesComparable
+fn make_packages_comparable(
+ packages: &[PackageInterfaceHandle],
+) -> Vec<IndexMap<String, PhpMixed>> {
+ let dumper = ArrayDumper::new();
+ packages.iter().map(|p| dumper.dump(p.clone())).collect()
+}
+
#[test]
-#[ignore = "requires PHPUnit getMockBuilder mocks of DownloadManager/Config/EventDispatcher/HttpDownloader/JsonFile/AutoloadGenerator plus an unported InstallationManagerMock and the provideInstaller data provider; no mocking infrastructure exists"]
fn test_installer() {
let _tear_down = TearDown;
- todo!()
+
+ for case in provide_installer() {
+ let io_buffer = Rc::new(RefCell::new(
+ BufferIO::new(String::new(), VERBOSITY_NORMAL, None).unwrap(),
+ ));
+ let io: Rc<RefCell<dyn IOInterface>> = io_buffer.clone();
+
+ let config = ConfigStubBuilder::new()
+ .with("vendor-dir", PhpMixed::String("foo".to_string()))
+ .with("lock", PhpMixed::Bool(true))
+ .with("notify-on-install", PhpMixed::Bool(true))
+ .build_shared();
+
+ let download_manager: Rc<RefCell<dyn DownloadManagerInterface>> =
+ Rc::new(RefCell::new(StubDownloadManager));
+
+ let http_downloader = Rc::new(RefCell::new(HttpDownloader::__new_mock(
+ io.clone(),
+ config.clone(),
+ )));
+
+ let mut repository_manager = RepositoryManager::new(
+ io.clone(),
+ config.clone(),
+ http_downloader.clone(),
+ None,
+ None,
+ );
+ repository_manager.set_local_repository(RepositoryInterfaceHandle::new(
+ InstalledArrayRepository::new().unwrap(),
+ ));
+ for repository in &case.repositories {
+ repository_manager.add_repository(repository.clone());
+ }
+ let repository_manager: Rc<RefCell<dyn RepositoryManagerInterface>> =
+ Rc::new(RefCell::new(repository_manager));
+
+ let r#loop = Rc::new(RefCell::new(Loop::new(http_downloader.clone(), None)));
+ let installation_manager: Rc<RefCell<InstallationManager>> = Rc::new(RefCell::new(
+ InstallationManager::__new_mock(r#loop, io.clone(), None),
+ ));
+
+ // emulate a writable lock file: a real JsonFile over a fresh temp path (initially absent, so
+ // the installer falls back to an update; PHP uses an in-memory JsonFile mock instead).
+ let lock_dir = tempfile::TempDir::new().unwrap();
+ let lock_path = lock_dir.path().join("composer.lock");
+ let lock_json =
+ JsonFile::new(lock_path.to_string_lossy().into_owned(), None, None).unwrap();
+ let process = Rc::new(RefCell::new(ProcessExecutor::new(Some(io.clone()))));
+ let locker: Rc<RefCell<dyn LockerInterface>> = Rc::new(RefCell::new(Locker::new(
+ io.clone(),
+ lock_json,
+ installation_manager.clone(),
+ "{}",
+ process,
+ )));
+
+ let autoload_generator: Rc<RefCell<dyn AutoloadGeneratorInterface>> =
+ Rc::new(RefCell::new(StubAutoloadGenerator));
+
+ let root_package: RootPackageInterfaceHandle =
+ RootPackageInterfaceHandle::dup(&case.root_package.clone().into());
+ let mut installer = Installer::new(
+ io.clone(),
+ config.clone(),
+ root_package,
+ download_manager,
+ repository_manager,
+ locker,
+ installation_manager.clone(),
+ Rc::new(RefCell::new(StubEventDispatcher)),
+ autoload_generator,
+ );
+ installer.set_audit_config(
+ AuditConfig::from_config(&mut config.borrow_mut(), false, Auditor::FORMAT_SUMMARY)
+ .unwrap(),
+ );
+ let result = installer.run().unwrap();
+
+ let output = io_buffer.borrow().get_output().replace('\r', "");
+ assert_eq!(0, result, "{}", output);
+
+ let installed = installation_manager.borrow().__get_installed_packages();
+ assert_eq!(
+ make_packages_comparable(&case.expected_install),
+ make_packages_comparable(&installed),
+ "{}",
+ output
+ );
+
+ let updated = installation_manager.borrow().__get_updated_packages();
+ assert_eq!(case.expected_update, updated);
+
+ let uninstalled = installation_manager.borrow().__get_uninstalled_packages();
+ assert_eq!(case.expected_uninstall, uninstalled);
+ }
+}
+
+/// ref: PHPUnit assertStringMatchesFormat's StringMatchesFormatDescription::createPatternFromFormat.
+fn create_pattern_from_format(format: &str) -> String {
+ let escaped = regex::escape(format);
+ let bytes = escaped.as_bytes();
+ let mut out = String::from("(?s)^");
+ let mut i = 0;
+ while i < bytes.len() {
+ // regex::escape turns "%" into "%" (it is not special) so the format codes survive intact.
+ if bytes[i] == b'%' && i + 1 < bytes.len() {
+ let replacement: Option<&str> = match bytes[i + 1] {
+ b'%' => Some("%"),
+ b'e' => Some("\\/"),
+ b's' => Some("[^\\r\\n]+"),
+ b'S' => Some("[^\\r\\n]*"),
+ b'a' => Some(".+"),
+ b'A' => Some(".*"),
+ b'w' => Some("\\s*"),
+ b'i' => Some("[+-]?\\d+"),
+ b'd' => Some("\\d+"),
+ b'x' => Some("[0-9a-fA-F]+"),
+ b'f' => Some("[+-]?\\.?\\d+\\.?\\d*(?:[Ee][+-]?\\d+)?"),
+ b'c' => Some("."),
+ _ => None,
+ };
+ if let Some(replacement) = replacement {
+ out.push_str(replacement);
+ i += 2;
+ continue;
+ }
+ }
+ out.push(escaped[i..].chars().next().unwrap());
+ i += escaped[i..].chars().next().unwrap().len_utf8();
+ }
+ out.push('$');
+ out
+}
+
+/// ref: PHPUnit self::assertStringMatchesFormat.
+fn assert_string_matches_format(format: &str, subject: &str, context: &str) {
+ let pattern = create_pattern_from_format(format);
+ let re = regex::Regex::new(&pattern)
+ .unwrap_or_else(|e| panic!("invalid format pattern {}: {}", pattern, e));
+ assert!(
+ re.is_match(subject),
+ "output does not match format.\n--- format ---\n{}\n--- output ---\n{}\n--- context ---\n{}",
+ format,
+ subject,
+ context
+ );
+}
+
+#[derive(Debug, Clone)]
+enum ExpectLock {
+ /// No EXPECT-LOCK section (`[]` in PHP); the lock is not asserted.
+ Unset,
+ /// EXPECT-LOCK is the literal string "false"; the lock must never be written.
+ Never,
+ /// EXPECT-LOCK holds an expected lock JSON.
+ Json(serde_json::Value),
+}
+
+#[derive(Debug, Clone)]
+enum ExpectResult {
+ ExitCode(i64),
+ /// EXPECT-EXCEPTION: the class-string of an expected exception.
+ Exception(String),
+}
+
+#[derive(Debug, Clone)]
+struct IntegrationCase {
+ file: String,
+ message: String,
+ condition: Option<String>,
+ composer: serde_json::Value,
+ lock: Option<serde_json::Value>,
+ installed: Option<serde_json::Value>,
+ run: String,
+ expect_lock: ExpectLock,
+ expect_installed: Option<serde_json::Value>,
+ expect_output: Option<String>,
+ expect_output_optimized: Option<String>,
+ expect: String,
+ expect_result: ExpectResult,
+}
+
+fn fixtures_dir(path: &str) -> std::path::PathBuf {
+ std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+ .join("../../composer/tests/Composer/Test/Fixtures")
+ .join(path)
+ .canonicalize()
+ .unwrap()
+}
+
+/// ref: InstallerTest::readTestFile
+fn read_test_file(
+ file: &std::path::Path,
+ fixtures_dir: &std::path::Path,
+) -> IndexMap<String, String> {
+ let contents = std::fs::read_to_string(file).unwrap();
+ let tokens = Preg::split4(
+ r"#(?:^|\n*)--([A-Z-]+)--\n#",
+ &contents,
+ -1,
+ PREG_SPLIT_DELIM_CAPTURE,
+ );
+
+ let section_info: [(&str, bool); 13] = [
+ ("TEST", true),
+ ("CONDITION", false),
+ ("COMPOSER", true),
+ ("LOCK", false),
+ ("INSTALLED", false),
+ ("RUN", true),
+ ("EXPECT-LOCK", false),
+ ("EXPECT-INSTALLED", false),
+ ("EXPECT-OUTPUT", false),
+ ("EXPECT-OUTPUT-OPTIMIZED", false),
+ ("EXPECT-EXIT-CODE", false),
+ ("EXPECT-EXCEPTION", false),
+ ("EXPECT", true),
+ ];
+ let known: indexmap::IndexSet<&str> = section_info.iter().map(|(k, _)| *k).collect();
+
+ let mut section: Option<String> = None;
+ let mut data: IndexMap<String, String> = IndexMap::new();
+ for token in tokens {
+ if section.is_none() && token.is_empty() {
+ continue; // skip leading blank
+ }
+ if section.is_none() {
+ assert!(
+ known.contains(token.as_str()),
+ "The test file \"{}\" must not contain a section named \"{}\".",
+ file.display(),
+ token
+ );
+ section = Some(token);
+ continue;
+ }
+ let sec = section.take().unwrap();
+ data.insert(sec, token);
+ }
+
+ for (sec, required) in section_info {
+ if required {
+ assert!(
+ data.contains_key(sec),
+ "The test file \"{}\" must have a section named \"{}\".",
+ file.display(),
+ sec
+ );
+ }
+ }
+ let _ = fixtures_dir;
+ data
+}
+
+fn collect_test_files(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
+ for entry in std::fs::read_dir(dir).unwrap() {
+ let entry = entry.unwrap();
+ let path = entry.path();
+ if path.is_dir() {
+ collect_test_files(&path, out);
+ } else if path.extension().and_then(|e| e.to_str()) == Some("test") {
+ out.push(path);
+ }
+ }
+}
+
+/// ref: InstallerTest::loadIntegrationTests
+fn load_integration_tests(path: &str) -> Vec<IntegrationCase> {
+ let dir = fixtures_dir(path);
+ let mut files = Vec::new();
+ collect_test_files(&dir, &mut files);
+ files.sort();
+
+ let mut tests = Vec::new();
+ for file in files {
+ let test_data = read_test_file(&file, &dir);
+
+ // skip 64bit related tests on 32bit (usize is 64-bit here, so this never triggers).
+ if test_data
+ .get("EXPECT-OUTPUT")
+ .map(|s| s.contains("php-64bit"))
+ .unwrap_or(false)
+ && (usize::BITS == 32)
+ {
+ continue;
+ }
+
+ let message = test_data["TEST"].clone();
+ let condition = test_data
+ .get("CONDITION")
+ .filter(|s| !s.is_empty())
+ .cloned();
+ let mut composer: serde_json::Value = serde_json::from_str(&test_data["COMPOSER"]).unwrap();
+
+ if let Some(repositories) = composer.get_mut("repositories") {
+ let fixtures_str = dir.to_string_lossy().replace('\\', "/");
+ let rewrite = |repo: &mut serde_json::Value| {
+ if repo.get("type").and_then(|t| t.as_str()) != Some("composer") {
+ return;
+ }
+ if let Some(url) = repo.get("url").and_then(|u| u.as_str())
+ && Preg::is_match(r"{^file://[^/]}", url)
+ {
+ let new_url = format!("file://{}/{}", fixtures_str, &url[7..]);
+ repo["url"] = serde_json::Value::String(new_url);
+ }
+ };
+ match repositories {
+ serde_json::Value::Array(list) => list.iter_mut().for_each(rewrite),
+ serde_json::Value::Object(map) => map.values_mut().for_each(rewrite),
+ _ => {}
+ }
+ }
+
+ let lock = test_data.get("LOCK").filter(|s| !s.is_empty()).map(|s| {
+ let mut lock: serde_json::Value = serde_json::from_str(s).unwrap();
+ if lock.get("hash").is_none() {
+ let encoded = JsonFile::encode_with_options(
+ &composer,
+ shirabe::json::JsonEncodeOptions::none(),
+ );
+ let hash = format!("{:x}", md5::compute(encoded.as_bytes()));
+ lock["hash"] = serde_json::Value::String(hash);
+ }
+ lock
+ });
+
+ let installed = test_data
+ .get("INSTALLED")
+ .filter(|s| !s.is_empty())
+ .map(|s| serde_json::from_str(s).unwrap());
+
+ let run = test_data["RUN"].clone();
+
+ let expect_lock = match test_data.get("EXPECT-LOCK").filter(|s| !s.is_empty()) {
+ None => ExpectLock::Unset,
+ Some(s) if s == "false" => ExpectLock::Never,
+ Some(s) => ExpectLock::Json(serde_json::from_str(s).unwrap()),
+ };
+
+ let expect_installed = test_data
+ .get("EXPECT-INSTALLED")
+ .filter(|s| !s.is_empty())
+ .map(|s| serde_json::from_str(s).unwrap());
+
+ let expect_output = test_data.get("EXPECT-OUTPUT").cloned();
+ let expect_output_optimized = test_data.get("EXPECT-OUTPUT-OPTIMIZED").cloned();
+ let expect = test_data["EXPECT"].clone();
+
+ let expect_result =
+ if let Some(exc) = test_data.get("EXPECT-EXCEPTION").filter(|s| !s.is_empty()) {
+ assert!(
+ test_data
+ .get("EXPECT-EXIT-CODE")
+ .filter(|s| !s.is_empty())
+ .is_none(),
+ "EXPECT-EXCEPTION and EXPECT-EXIT-CODE are mutually exclusive"
+ );
+ ExpectResult::Exception(exc.clone())
+ } else if let Some(code) = test_data.get("EXPECT-EXIT-CODE").filter(|s| !s.is_empty()) {
+ ExpectResult::ExitCode(code.trim().parse().unwrap())
+ } else {
+ ExpectResult::ExitCode(0)
+ };
+
+ tests.push(IntegrationCase {
+ file: file
+ .strip_prefix(&dir)
+ .unwrap()
+ .to_string_lossy()
+ .into_owned(),
+ message,
+ condition,
+ composer,
+ lock,
+ installed,
+ run,
+ expect_lock,
+ expect_installed,
+ expect_output,
+ expect_output_optimized,
+ expect,
+ expect_result,
+ });
+ }
+
+ tests
+}
+
+/// ref: the inline `eval($condition)` in doTestIntegration, ported for the known fixture conditions.
+fn evaluate_condition(condition: &str) -> bool {
+ match condition.trim() {
+ // putenv() returns true on success, so these conditions always run the test (with the env set).
+ "putenv('COMPOSER_FUND=1')" => {
+ Platform::put_env("COMPOSER_FUND", "1");
+ true
+ }
+ "putenv('COMPOSER_FUND=0')" => {
+ Platform::put_env("COMPOSER_FUND", "0");
+ true
+ }
+ // HHVM is never defined under the Rust port.
+ "!defined('HHVM_VERSION')" => true,
+ // TODO(phase-d): unported CONDITION expression (PHP eval has no Rust equivalent).
+ other => panic!("// TODO(phase-d): unported CONDITION: {}", other),
+ }
+}
+
+fn opt_bool(input: &dyn InputInterface, name: &str) -> bool {
+ input
+ .get_option(name)
+ .ok()
+ .and_then(|m| m.as_bool())
+ .unwrap_or(false)
+}
+
+/// ref: `$ignorePlatformReqs = true === getOption('ignore-platform-reqs') ?: (getOption('ignore-platform-req') ?: false)`.
+fn ignore_platform_reqs_value(input: &dyn InputInterface) -> PhpMixed {
+ if opt_bool(input, "ignore-platform-reqs") {
+ return PhpMixed::Bool(true);
+ }
+ let list = input
+ .get_option("ignore-platform-req")
+ .unwrap_or(PhpMixed::Bool(false));
+ match &list {
+ PhpMixed::List(items) if !items.is_empty() => list,
+ PhpMixed::Array(map) if !map.is_empty() => list,
+ _ => PhpMixed::Bool(false),
+ }
+}
+
+fn write_json(path: &std::path::Path, value: &serde_json::Value) {
+ std::fs::write(path, serde_json::to_string_pretty(value).unwrap()).unwrap();
+}
+
+/// ref: InstallerTest::doTestIntegration
+fn do_test_integration(case: &IntegrationCase, expect_output: Option<&str>) {
+ if let Some(condition) = &case.condition
+ && !evaluate_condition(condition)
+ {
+ return; // markTestSkipped
+ }
+
+ let io_buffer = Rc::new(RefCell::new(
+ BufferIO::new(String::new(), VERBOSITY_NORMAL, None).unwrap(),
+ ));
+ let io: Rc<RefCell<dyn IOInterface>> = io_buffer.clone();
+
+ let is_exception = matches!(case.expect_result, ExpectResult::Exception(_));
+
+ // Create Composer mock object according to configuration (FactoryMock::create).
+ let composer_str = serde_json::to_string(&case.composer).unwrap();
+ let composer_data = JsonFile::parse_json(Some(&composer_str), None)
+ .unwrap()
+ .as_array()
+ .cloned()
+ .unwrap_or_default();
+ let composer = Factory::__create_mock(
+ io.clone(),
+ Some(LocalConfigInput::Data(composer_data)),
+ DisablePlugins::None,
+ false,
+ )
+ .unwrap();
+
+ // installed.json mock: a real JsonFile over a temp file holding $installed, wrapped in the
+ // no-op InstalledFilesystemRepositoryMock.
+ let installed_dir = tempfile::TempDir::new().unwrap();
+ let installed_path = installed_dir.path().join("installed.json");
+ write_json(
+ &installed_path,
+ case.installed.as_ref().unwrap_or(&serde_json::json!([])),
+ );
+ let installed_json =
+ JsonFile::new(installed_path.to_string_lossy().into_owned(), None, None).unwrap();
+ let local_repo = shirabe::repository::InstalledFilesystemRepository::__new_mock(
+ installed_json,
+ false,
+ None,
+ None,
+ )
+ .unwrap();
+ let repository_manager = composer.borrow().get_repository_manager();
+ repository_manager
+ .borrow_mut()
+ .set_local_repository(RepositoryInterfaceHandle::new(local_repo));
+
+ // emulate a writable lock file: a real composer.lock over a temp path.
+ let lock_dir = tempfile::TempDir::new().unwrap();
+ let lock_path = lock_dir.path().join("composer.lock");
+ if let Some(lock) = &case.lock {
+ write_json(&lock_path, lock);
+ }
+ let lock_before = std::fs::read_to_string(&lock_path).ok();
+ let lock_json = JsonFile::new(lock_path.to_string_lossy().into_owned(), None, None).unwrap();
+
+ // The Locker needs a concrete InstallationManager; build a fresh recording mock just for it. The
+ // asserted trace comes from the composer's own installation manager (read via as_any below).
+ let process = Rc::new(RefCell::new(ProcessExecutor::new(Some(io.clone()))));
+ let locker_loop = composer.borrow().get_loop();
+ let locker_im = Rc::new(RefCell::new(InstallationManager::__new_mock(
+ locker_loop,
+ io.clone(),
+ None,
+ )));
+ let contents = serde_json::to_string(&case.composer).unwrap();
+ let locker = Locker::new(io.clone(), lock_json, locker_im, &contents, process);
+ composer
+ .borrow_mut()
+ .set_locker(Rc::new(RefCell::new(locker)));
+
+ composer
+ .borrow_mut()
+ .set_autoload_generator(Rc::new(RefCell::new(StubAutoloadGenerator)));
+ composer
+ .borrow_mut()
+ .set_event_dispatcher(Rc::new(RefCell::new(StubEventDispatcher)));
+
+ let installer = Rc::new(RefCell::new(Installer::create(
+ io.clone(),
+ &composer.upcast(),
+ )));
+
+ // Application with inline install/update commands (setCode closures).
+ let application = ApplicationHandle::new("Composer".to_string(), "".to_string()).unwrap();
+ application.set_catch_exceptions(false);
+
+ let run_result: Rc<RefCell<Option<anyhow::Result<i64>>>> = Rc::new(RefCell::new(None));
+
+ let install = Rc::new(RefCell::new(CommandData::new(Some("install".to_string()))));
+ {
+ let install_ref = install.borrow();
+ install_ref
+ .add_option(
+ "ignore-platform-reqs",
+ PhpMixed::Null,
+ Some(InputOption::VALUE_NONE),
+ "",
+ PhpMixed::Null,
+ )
+ .unwrap();
+ install_ref
+ .add_option(
+ "ignore-platform-req",
+ PhpMixed::Null,
+ Some(InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY),
+ "",
+ PhpMixed::Null,
+ )
+ .unwrap();
+ install_ref
+ .add_option(
+ "no-dev",
+ PhpMixed::Null,
+ Some(InputOption::VALUE_NONE),
+ "",
+ PhpMixed::Null,
+ )
+ .unwrap();
+ install_ref
+ .add_option(
+ "dry-run",
+ PhpMixed::Null,
+ Some(InputOption::VALUE_NONE),
+ "",
+ PhpMixed::Null,
+ )
+ .unwrap();
+ let installer_cl = installer.clone();
+ let composer_cl = composer.clone();
+ let run_result_cl = run_result.clone();
+ install_ref.set_code(Box::new(move |input, _output| {
+ let ignore = ignore_platform_reqs_value(input);
+ let mut inst = installer_cl.borrow_mut();
+ inst.set_dev_mode(!opt_bool(input, "no-dev"))
+ .set_dry_run(opt_bool(input, "dry-run"))
+ .set_platform_requirement_filter(
+ PlatformRequirementFilterFactory::from_bool_or_list(ignore).unwrap(),
+ )
+ .set_audit_config(
+ AuditConfig::from_config(
+ &mut composer_cl.borrow().get_config().borrow_mut(),
+ false,
+ Auditor::FORMAT_SUMMARY,
+ )
+ .unwrap(),
+ );
+ let r = inst.run();
+ let code = match &r {
+ Ok(c) => *c,
+ Err(_) => 1,
+ };
+ *run_result_cl.borrow_mut() = Some(r);
+ PhpMixed::Int(code)
+ }));
+ }
+ application
+ .add(install.clone() as Rc<RefCell<dyn SymfonyCommand>>)
+ .unwrap();
+
+ let update = Rc::new(RefCell::new(CommandData::new(Some("update".to_string()))));
+ {
+ let update_ref = update.borrow();
+ for (name, mode) in [
+ ("ignore-platform-reqs", InputOption::VALUE_NONE),
+ ("no-dev", InputOption::VALUE_NONE),
+ ("no-install", InputOption::VALUE_NONE),
+ ("dry-run", InputOption::VALUE_NONE),
+ ("lock", InputOption::VALUE_NONE),
+ ("with-all-dependencies", InputOption::VALUE_NONE),
+ ("with-dependencies", InputOption::VALUE_NONE),
+ ("minimal-changes", InputOption::VALUE_NONE),
+ ("prefer-stable", InputOption::VALUE_NONE),
+ ("prefer-lowest", InputOption::VALUE_NONE),
+ ] {
+ update_ref
+ .add_option(name, PhpMixed::Null, Some(mode), "", PhpMixed::Null)
+ .unwrap();
+ }
+ update_ref
+ .add_option(
+ "ignore-platform-req",
+ PhpMixed::Null,
+ Some(InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY),
+ "",
+ PhpMixed::Null,
+ )
+ .unwrap();
+ update_ref
+ .add_argument(
+ "packages",
+ Some(InputArgument::IS_ARRAY | InputArgument::OPTIONAL),
+ "",
+ PhpMixed::Null,
+ )
+ .unwrap();
+ let installer_cl = installer.clone();
+ let composer_cl = composer.clone();
+ let run_result_cl = run_result.clone();
+ update_ref.set_code(Box::new(move |input, _output| {
+ let packages: Vec<String> =
+ match input.get_argument("packages").unwrap_or(PhpMixed::Null) {
+ PhpMixed::List(items) => items
+ .into_iter()
+ .filter_map(|v| v.as_string().map(|s| s.to_string()))
+ .collect(),
+ _ => vec![],
+ };
+ let filtered: Vec<String> = packages
+ .iter()
+ .filter(|p| !["lock", "nothing", "mirrors"].contains(&p.as_str()))
+ .cloned()
+ .collect();
+ let update_mirrors = opt_bool(input, "lock") || filtered.len() != packages.len();
+
+ let update_allow_transitive = if opt_bool(input, "with-all-dependencies") {
+ UpdateAllowTransitiveDeps::UpdateListedWithTransitiveDeps
+ } else if opt_bool(input, "with-dependencies") {
+ UpdateAllowTransitiveDeps::UpdateListedWithTransitiveDepsNoRootRequire
+ } else {
+ UpdateAllowTransitiveDeps::UpdateOnlyListed
+ };
+
+ let ignore = ignore_platform_reqs_value(input);
+
+ let mut inst = installer_cl.borrow_mut();
+ inst.set_dev_mode(!opt_bool(input, "no-dev"))
+ .set_update(true)
+ .set_install(!opt_bool(input, "no-install"))
+ .set_dry_run(opt_bool(input, "dry-run"))
+ .set_update_mirrors(update_mirrors)
+ .set_update_allow_list(filtered)
+ .set_update_allow_transitive_dependencies(update_allow_transitive)
+ .unwrap()
+ .set_prefer_stable(opt_bool(input, "prefer-stable"))
+ .set_prefer_lowest(opt_bool(input, "prefer-lowest"))
+ .set_platform_requirement_filter(
+ PlatformRequirementFilterFactory::from_bool_or_list(ignore).unwrap(),
+ )
+ .set_audit_config(
+ AuditConfig::from_config(
+ &mut composer_cl.borrow().get_config().borrow_mut(),
+ false,
+ Auditor::FORMAT_SUMMARY,
+ )
+ .unwrap(),
+ )
+ .set_minimal_update(opt_bool(input, "minimal-changes"));
+ let r = inst.run();
+ let code = match &r {
+ Ok(c) => *c,
+ Err(_) => 1,
+ };
+ *run_result_cl.borrow_mut() = Some(r);
+ PhpMixed::Int(code)
+ }));
+ }
+ application
+ .add(update.clone() as Rc<RefCell<dyn SymfonyCommand>>)
+ .unwrap();
+
+ assert!(
+ Preg::is_match(r"{^(install|update)\b}", &case.run),
+ "The run command only supports install and update"
+ );
+
+ let app_output_stream = shirabe_php_shim::php_fopen_resource("php://memory", "w+");
+ let app_output = StreamOutput::new(app_output_stream.clone(), None, None, None)
+ .unwrap()
+ .expect("php://memory is a valid stream");
+ let mut string_input = StringInput::new(&format!("{} -vvv", case.run)).unwrap();
+ string_input.set_interactive(false);
+ let input: Rc<RefCell<dyn InputInterface>> = Rc::new(RefCell::new(string_input));
+ let output: Rc<RefCell<dyn OutputInterface>> = Rc::new(RefCell::new(app_output));
+
+ let app_run = application.run(Some(input), Some(output));
+
+ let output_string = io_buffer.borrow().get_output().replace('\r', "");
+
+ // Shouldn't check output and results if an exception was expected by this point.
+ if is_exception {
+ let ExpectResult::Exception(_) = &case.expect_result else {
+ unreachable!()
+ };
+ let normalized = case.expect.replace('\n', shirabe_php_shim::PHP_EOL);
+ let normalized = normalized.trim_end();
+ let err = match run_result.borrow().as_ref() {
+ Some(Err(e)) => format!("{}", e),
+ _ => app_run
+ .as_ref()
+ .err()
+ .map(|e| format!("{}", e))
+ .unwrap_or_default(),
+ };
+ assert!(
+ err.contains(normalized),
+ "expected exception message containing:\n{}\n--- got ---\n{}",
+ normalized,
+ err
+ );
+ return;
+ }
+
+ let result = match run_result.borrow().as_ref() {
+ Some(Ok(c)) => *c,
+ Some(Err(e)) => panic!("installer run failed: {}\n{}", e, output_string),
+ None => app_run.unwrap_or(-1) as i64,
+ };
+
+ let ExpectResult::ExitCode(expect_result) = &case.expect_result else {
+ unreachable!()
+ };
+ shirabe_php_shim::rewind(&app_output_stream);
+ let app_output_contents =
+ shirabe_php_shim::stream_get_contents(&app_output_stream).unwrap_or_default();
+ assert_eq!(
+ *expect_result, result,
+ "{}{}",
+ output_string, app_output_contents
+ );
+
+ if let ExpectLock::Json(expect_lock) = &case.expect_lock {
+ let actual = std::fs::read_to_string(&lock_path).unwrap();
+ let mut actual_lock: serde_json::Value = serde_json::from_str(&actual).unwrap();
+ if let Some(obj) = actual_lock.as_object_mut() {
+ for k in ["hash", "content-hash", "_readme", "plugin-api-version"] {
+ obj.remove(k);
+ }
+ }
+ let mut expect_lock = expect_lock.clone();
+ // PHP turns the empty-array sentinel into stdClass; serde compares {} vs [] strictly, so
+ // normalize the known object-valued keys to {} when they are empty.
+ if let Some(obj) = expect_lock.as_object_mut() {
+ for k in ["stability-flags", "platform", "platform-dev"] {
+ if obj.get(k) == Some(&serde_json::json!([])) {
+ obj.insert(k.to_string(), serde_json::json!({}));
+ }
+ }
+ }
+ assert_eq!(expect_lock, actual_lock);
+ } else if let ExpectLock::Never = &case.expect_lock {
+ let lock_after = std::fs::read_to_string(&lock_path).ok();
+ assert_eq!(lock_before, lock_after, "lock file must not be written");
+ }
+
+ if let Some(expect_installed) = &case.expect_installed {
+ let dumper = ArrayDumper::new();
+ let local_repo = repository_manager.borrow().get_local_repository();
+ let mut actual_installed: Vec<IndexMap<String, PhpMixed>> = local_repo
+ .get_canonical_packages()
+ .unwrap()
+ .into_iter()
+ .map(|package| {
+ let mut dumped = dumper.dump(package);
+ dumped.shift_remove("version_normalized");
+ dumped
+ })
+ .collect();
+ actual_installed.sort_by(
+ |a: &IndexMap<String, PhpMixed>, b: &IndexMap<String, PhpMixed>| {
+ let an = a
+ .get("name")
+ .and_then(|m| m.as_string())
+ .map(|s| s.to_string())
+ .unwrap_or_default();
+ let bn = b
+ .get("name")
+ .and_then(|m| m.as_string())
+ .map(|s| s.to_string())
+ .unwrap_or_default();
+ an.cmp(&bn)
+ },
+ );
+ // Faithful comparison would dump expect_installed through the same shape; we compare the
+ // serialized forms so the assertion still fails loudly on divergence.
+ let actual_json = serde_json::to_value(
+ actual_installed
+ .iter()
+ .map(php_mixed_map_to_json)
+ .collect::<Vec<_>>(),
+ )
+ .unwrap();
+ assert_eq!(expect_installed, &actual_json);
+ }
+
+ // trace from the composer's recording InstallationManager.
+ let im_handle = composer.borrow().get_installation_manager();
+ let im_ref = im_handle.borrow();
+ let trace = im_ref
+ .as_any()
+ .downcast_ref::<InstallationManager>()
+ .expect("composer installation manager is the recording mock")
+ .__get_trace();
+ assert_eq!(case.expect.trim_end(), trace.join("\n"));
+
+ if let Some(expect_output) = expect_output
+ && !expect_output.is_empty()
+ {
+ let output = Preg::replace(r"{^ - .*?\.ini$}m", "__inilist__", &output_string);
+ let output = Preg::replace(r"{(__inilist__\r?\n)+}", "__inilist__\n", &output);
+ assert_string_matches_format(expect_output.trim_end(), output.trim_end(), &output_string);
+ }
+}
+
+fn php_mixed_map_to_json(map: &IndexMap<String, PhpMixed>) -> serde_json::Value {
+ serde_json::to_value(map).unwrap_or(serde_json::Value::Null)
}
#[test]
-#[ignore = "delegates to unported do_test_integration which needs FactoryMock, InstalledFilesystemRepositoryMock, the loadIntegrationTests .test-fixture loader and a symfony console Application; none exist in the Rust port"]
+#[ignore = "ported; exercises the full install pipeline which is not yet executable end-to-end (execute_batch / repository / autoload stubs), so cases are expected to fail at runtime"]
fn test_slow_integration() {
let _tear_down = TearDown;
- todo!()
+ for case in load_integration_tests("installer-slow/") {
+ Platform::clear_env("COMPOSER_FUND");
+ Platform::put_env("COMPOSER_POOL_OPTIMIZER", "0");
+ let expect_output = case.expect_output.clone();
+ do_test_integration(&case, expect_output.as_deref());
+ }
}
#[test]
-#[ignore = "delegates to unported do_test_integration which needs FactoryMock, InstalledFilesystemRepositoryMock, the loadIntegrationTests .test-fixture loader and a symfony console Application; none exist in the Rust port"]
+#[ignore = "ported; exercises the full install pipeline which is not yet executable end-to-end (execute_batch / repository / autoload stubs), so cases are expected to fail at runtime"]
fn test_integration_with_pool_optimizer() {
let _tear_down = TearDown;
- todo!()
+ for case in load_integration_tests("installer/") {
+ Platform::clear_env("COMPOSER_FUND");
+ Platform::put_env("COMPOSER_POOL_OPTIMIZER", "1");
+ let expect_output = case
+ .expect_output_optimized
+ .clone()
+ .filter(|s| !s.is_empty())
+ .or_else(|| case.expect_output.clone());
+ do_test_integration(&case, expect_output.as_deref());
+ }
}
#[test]
-#[ignore = "delegates to unported do_test_integration which needs FactoryMock, InstalledFilesystemRepositoryMock, the loadIntegrationTests .test-fixture loader and a symfony console Application; none exist in the Rust port"]
+#[ignore = "ported; exercises the full install pipeline which is not yet executable end-to-end (execute_batch / repository / autoload stubs), so cases are expected to fail at runtime"]
fn test_integration_with_raw_pool() {
let _tear_down = TearDown;
- todo!()
+ for case in load_integration_tests("installer/") {
+ Platform::clear_env("COMPOSER_FUND");
+ Platform::put_env("COMPOSER_POOL_OPTIMIZER", "0");
+ let expect_output = case.expect_output.clone();
+ do_test_integration(&case, expect_output.as_deref());
+ }
}