diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-05-06 18:05:27 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-05-06 18:05:27 +0900 |
| commit | 3d128352f93c4416d087069947920e9fa864df7d (patch) | |
| tree | 52026a6ae07ad0dbc2a62e487dd4d9550992e3b8 /crates/mozart-core/src/factory.rs | |
| parent | 4a9aff1af9fc74d2928fe54210d6aad5f0afd0b7 (diff) | |
| download | php-mozart-3d128352f93c4416d087069947920e9fa864df7d.tar.gz php-mozart-3d128352f93c4416d087069947920e9fa864df7d.tar.zst php-mozart-3d128352f93c4416d087069947920e9fa864df7d.zip | |
feat(core): port Factory::createComposer and AutoloadGenerator::dump
Add the Composer state-container types (LocalRepository,
RepositoryManager, InstallationManager, AutoloadGenerator,
AutoloadDumpOptions, PlatformRequirementFilter, Locker) plus the
factory wiring that builds them from composer.json and
vendor/composer/installed.json.
AutoloadGenerator::dump lives in mozart-autoload as an extension
trait so the orchestrating algorithm sits next to the classmap
scanner while the state container stays in mozart-core. Rework
dump-autoload to drive both, mirroring
$composer->getAutoloadGenerator()->dump(...).
Diffstat (limited to 'crates/mozart-core/src/factory.rs')
| -rw-r--r-- | crates/mozart-core/src/factory.rs | 299 |
1 files changed, 292 insertions, 7 deletions
diff --git a/crates/mozart-core/src/factory.rs b/crates/mozart-core/src/factory.rs index faa98d8..92aa70e 100644 --- a/crates/mozart-core/src/factory.rs +++ b/crates/mozart-core/src/factory.rs @@ -1,14 +1,23 @@ -//! Factory helpers for constructing Composer configuration. +//! Factory helpers for constructing Composer state. //! -//! Ports the static factory methods from `Composer\Factory` that deal with -//! default and global configuration. Auth loading and htaccess creation are -//! intentionally omitted as they are out of scope for the current port. +//! Ports the static factory methods from `Composer\Factory`. Today we +//! cover [`create_config`] (effective global [`Config`]) and +//! [`create_composer`] (the project-level [`Composer`] root, built from +//! `composer.json` plus the on-disk `vendor/composer/installed.json`). +//! +//! Auth loading, htaccess creation, and the plugin/event-dispatcher +//! wiring are intentionally omitted as they are out of scope for the +//! current port. use std::collections::BTreeMap; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; -use crate::composer::composer_home; -use crate::config::Config; +use crate::composer::{ + AutoloadGenerator, Composer, InstallationManager, LocalPackage, LocalRepository, Locker, + RepositoryManager, composer_home, +}; +use crate::config::{Config, resolve_references}; +use crate::package::read_from_file; /// Rust port of `Factory::getCacheDir()`. /// @@ -147,6 +156,135 @@ pub fn create_config() -> anyhow::Result<Config> { Ok(config) } +/// Rust port of `Factory::createComposer()`. +/// +/// Builds the project-level [`Composer`]: +/// 1. Read `composer.json` from `composer_json` and load it into both +/// the merged [`Config`] (overlaying [`create_config`]) and the +/// untyped [`crate::package::RawPackageData`]. +/// 2. Resolve all `{$home}` / `{$vendor-dir}` placeholders via +/// [`resolve_references`]. +/// 3. Resolve `vendor-dir` against `project_dir` if it is relative, so +/// the installation manager hands back absolute paths +/// (`Factory::createComposer` does the same via +/// `Filesystem::isAbsolutePath`). +/// 4. Wire up the [`InstallationManager`] and a [`RepositoryManager`] +/// whose local repository is populated from +/// `vendor/composer/installed.json` — the same role +/// `Factory::addLocalRepository` plays in PHP. +/// 5. Construct a fresh [`AutoloadGenerator`] with PHP defaults +/// (`new AutoloadGenerator($eventDispatcher, $io)` in PHP, minus the +/// not-yet-ported event dispatcher and IO dependencies). +/// 6. Construct a [`Locker`] pointed at `composer.lock` next to the +/// composer.json — same as `Factory::createComposer`'s +/// `new Locker($io, new JsonFile($lockFile, …), $im, $contents)`, +/// minus the IO/installation-manager/contents dependencies that +/// only matter once we port `setLockData`. +/// +/// The plugin manager, download manager, and event dispatcher that +/// `Factory::createComposer` also wires up are not yet ported. +pub fn create_composer(project_dir: PathBuf, composer_json: &Path) -> anyhow::Result<Composer> { + let content = std::fs::read_to_string(composer_json)?; + let value: serde_json::Value = serde_json::from_str(&content)?; + let mut config = create_config()?; + if let Some(cfg_obj) = value.get("config").and_then(|v| v.as_object()) { + let overrides: BTreeMap<String, serde_json::Value> = cfg_obj + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + config.merge(&overrides)?; + } + resolve_references(&mut config); + + let package = read_from_file(composer_json)?; + + // Mirrors `Factory::createComposer`'s `vendorDir` handling. The + // value out of `Config::get('vendor-dir')` already had `{$...}` + // placeholders substituted, but it may still be relative — resolve + // it against the project root so install paths are absolute. + let vendor_dir = if Path::new(&config.vendor_dir).is_absolute() { + PathBuf::from(&config.vendor_dir) + } else { + project_dir.join(&config.vendor_dir) + }; + + let local_packages = read_local_packages(&vendor_dir)?; + let repository_manager = RepositoryManager::new(LocalRepository::new(local_packages)); + let installation_manager = InstallationManager::new(vendor_dir); + let autoload_generator = AutoloadGenerator::new(); + + // Mirrors `Factory::createComposer`'s lock-file path: the lockfile + // sits next to composer.json, with `.json` swapped for `.lock`. + let lock_file_path = composer_json + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| project_dir.clone()) + .join( + composer_json + .file_name() + .and_then(|n| n.to_str()) + .map(|n| n.strip_suffix(".json").unwrap_or(n)) + .map(|stem| format!("{stem}.lock")) + .unwrap_or_else(|| "composer.lock".to_string()), + ); + let locker = Locker::new(lock_file_path); + + Ok(Composer::new( + project_dir, + config, + package, + repository_manager, + installation_manager, + autoload_generator, + locker, + )) +} + +/// Read `vendor/composer/installed.json` into the minimal shape the +/// installation manager needs. Mirrors the relevant slice of +/// `Composer\Repository\FilesystemRepository::initialize`: accept both +/// the v2 object form (`{packages: [...]}`) and the legacy v1 array +/// form. Returns an empty list when the file is missing — the same +/// semantics as `FilesystemRepository::isFresh`. +/// +/// We deliberately avoid pulling the full `InstalledPackages` reader from +/// `mozart-registry` here to keep `mozart-core` at the bottom of the +/// dependency graph; the parsing that's actually load-bearing for the +/// install-path computation is just the package name + optional +/// `target-dir`. +fn read_local_packages(vendor_dir: &Path) -> anyhow::Result<Vec<LocalPackage>> { + let path = vendor_dir.join("composer/installed.json"); + if !path.exists() { + return Ok(Vec::new()); + } + let content = std::fs::read_to_string(&path)?; + let value: serde_json::Value = serde_json::from_str(&content)?; + + let entries: &[serde_json::Value] = match &value { + serde_json::Value::Object(obj) => match obj.get("packages") { + Some(serde_json::Value::Array(arr)) => arr.as_slice(), + _ => return Ok(Vec::new()), + }, + serde_json::Value::Array(arr) => arr.as_slice(), + _ => return Ok(Vec::new()), + }; + + let mut out = Vec::with_capacity(entries.len()); + for entry in entries { + let pretty_name = entry + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let target_dir = entry + .get("target-dir") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + out.push(LocalPackage::new(pretty_name, target_dir)); + } + Ok(out) +} + #[cfg(test)] mod tests { use super::*; @@ -208,4 +346,151 @@ mod tests { result.display() ); } + + mod create_composer { + use super::*; + use std::fs; + use tempfile::tempdir; + + fn write(path: &Path, content: &str) { + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, content).unwrap(); + } + + #[test] + fn install_path_is_vendor_dir_plus_pretty_name() { + let dir = tempdir().unwrap(); + write(&dir.path().join("composer.json"), r#"{"name": "acme/app"}"#); + write( + &dir.path().join("vendor/composer/installed.json"), + r#"{"packages": [{"name": "Vendor/Pkg", "version": "1.0.0"}]}"#, + ); + + let composer = Composer::require(dir.path()).unwrap(); + let pkg = composer + .repository_manager() + .local_repository() + .canonical_packages() + .next() + .unwrap(); + + let install_path = composer + .installation_manager() + .get_install_path(pkg) + .unwrap(); + + // Mirrors `LibraryInstaller::getInstallPath`: + // `vendorDir + '/' + prettyName`. `pretty-name` is preserved + // case (Composer/Repository/FilesystemRepository keeps the original). + assert_eq!(install_path, dir.path().join("vendor").join("Vendor/Pkg")); + } + + #[test] + fn install_path_appends_target_dir() { + let dir = tempdir().unwrap(); + write(&dir.path().join("composer.json"), r#"{"name": "acme/app"}"#); + write( + &dir.path().join("vendor/composer/installed.json"), + r#"{"packages": [{"name": "vendor/pkg", "target-dir": "src/lib"}]}"#, + ); + + let composer = Composer::require(dir.path()).unwrap(); + let pkg = composer + .repository_manager() + .local_repository() + .canonical_packages() + .next() + .unwrap(); + + let install_path = composer + .installation_manager() + .get_install_path(pkg) + .unwrap(); + + assert_eq!(install_path, dir.path().join("vendor/vendor/pkg/src/lib")); + } + + #[test] + fn local_repository_is_empty_when_installed_json_missing() { + let dir = tempdir().unwrap(); + write(&dir.path().join("composer.json"), r#"{"name": "acme/app"}"#); + + let composer = Composer::require(dir.path()).unwrap(); + let count = composer + .repository_manager() + .local_repository() + .canonical_packages() + .count(); + assert_eq!(count, 0); + } + + #[test] + fn local_repository_accepts_v1_array_form() { + // Older Composer 1.x / fixture format: bare array of packages. + // FilesystemRepository::initialize accepts this; our minimal + // reader must too. + let dir = tempdir().unwrap(); + write(&dir.path().join("composer.json"), r#"{"name": "acme/app"}"#); + write( + &dir.path().join("vendor/composer/installed.json"), + r#"[{"name": "a/a"}, {"name": "b/b"}]"#, + ); + + let composer = Composer::require(dir.path()).unwrap(); + let names: Vec<&str> = composer + .repository_manager() + .local_repository() + .canonical_packages() + .map(|p| p.pretty_name()) + .collect(); + assert_eq!(names, vec!["a/a", "b/b"]); + } + + #[test] + fn package_returns_root_composer_json() { + let dir = tempdir().unwrap(); + write( + &dir.path().join("composer.json"), + r#"{"name": "acme/app", "require": {"vendor/pkg": "^1.0"}}"#, + ); + + let composer = Composer::require(dir.path()).unwrap(); + assert_eq!(composer.package().name, "acme/app"); + assert_eq!( + composer + .package() + .require + .get("vendor/pkg") + .map(String::as_str), + Some("^1.0"), + ); + } + + #[test] + fn install_path_uses_configured_vendor_dir() { + let dir = tempdir().unwrap(); + write( + &dir.path().join("composer.json"), + r#"{"name": "acme/app", "config": {"vendor-dir": "deps"}}"#, + ); + write( + &dir.path().join("deps/composer/installed.json"), + r#"{"packages": [{"name": "vendor/pkg"}]}"#, + ); + + let composer = Composer::require(dir.path()).unwrap(); + let pkg = composer + .repository_manager() + .local_repository() + .canonical_packages() + .next() + .unwrap(); + + let install_path = composer + .installation_manager() + .get_install_path(pkg) + .unwrap(); + assert_eq!(install_path, dir.path().join("deps/vendor/pkg")); + } + } } |
