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 | |
| 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')
| -rw-r--r-- | crates/mozart-core/src/composer.rs | 500 | ||||
| -rw-r--r-- | crates/mozart-core/src/factory.rs | 299 |
2 files changed, 761 insertions, 38 deletions
diff --git a/crates/mozart-core/src/composer.rs b/crates/mozart-core/src/composer.rs index 2e252c6..6fe022a 100644 --- a/crates/mozart-core/src/composer.rs +++ b/crates/mozart-core/src/composer.rs @@ -1,18 +1,23 @@ -//! Composer-equivalent root state: composer.json + effective config. +//! Composer-equivalent root state: composer.json + effective config + +//! the manager objects commands look up off the root [`Composer`]. //! -//! Mirrors the role of `Composer\Composer` (PHP) to the extent that command -//! handlers need today: a single struct loaded from the project directory, -//! exposing a `config()` accessor over the merged Composer config. +//! Mirrors the role of `Composer\Composer` / `Composer\PartialComposer` +//! (PHP) — a state container with getters for the merged [`Config`], the +//! root [`RawPackageData`], the [`RepositoryManager`], and the +//! [`InstallationManager`]. Wiring lives in [`crate::factory`], the same +//! split as upstream's `Composer\Factory::createComposer`. //! //! See `Composer\Command\BaseCommand::requireComposer()` / //! `Composer\Command\BaseCommand::tryComposer()` for the upstream contract //! that [`Composer::require`] and [`Composer::try_load`] are modelled on. -use std::collections::BTreeMap; use std::path::{Path, PathBuf}; -use crate::config::{Config, resolve_references}; -use crate::factory::create_config; +use regex::Regex; + +use crate::config::Config; +use crate::factory::create_composer; +use crate::package::RawPackageData; /// Return the Composer home directory, respecting `COMPOSER_HOME` and falling /// back to the platform default using Composer-compatible logic. @@ -72,17 +77,433 @@ fn use_xdg() -> bool { || std::path::Path::new("/etc/xdg").is_dir() } -/// Project-level Composer state. Currently only carries the merged -/// [`Config`]; additional accessors (root package, locker, …) can be -/// layered on as commands need them. +/// Project-level Composer state. Mirrors `Composer\PartialComposer` / +/// `Composer\Composer` in PHP, exposing the subset of getters command +/// handlers need today: config, root package, repository manager, +/// installation manager, autoload generator, and locker. More +/// managers (download, …) can be layered on as commands need them. pub struct Composer { project_dir: PathBuf, config: Config, + package: RawPackageData, + repository_manager: RepositoryManager, + installation_manager: InstallationManager, + autoload_generator: AutoloadGenerator, + locker: Locker, +} + +/// Subset of `Composer\Package\PackageInterface` needed by the +/// installation manager. Today only the fields referenced by +/// `LibraryInstaller::getInstallPath` (`prettyName`, `targetDir`). +#[derive(Debug, Clone)] +pub struct LocalPackage { + pretty_name: String, + target_dir: Option<String>, +} + +impl LocalPackage { + pub fn new(pretty_name: String, target_dir: Option<String>) -> Self { + Self { + pretty_name, + target_dir, + } + } + + /// Original case-preserving package name (`vendor/Name`). + /// Mirrors `PackageInterface::getPrettyName`. + pub fn pretty_name(&self) -> &str { + &self.pretty_name + } + + /// Optional sub-directory inside the install path that holds the + /// package code. Mirrors `PackageInterface::getTargetDir`. + pub fn target_dir(&self) -> Option<&str> { + self.target_dir.as_deref() + } +} + +/// In-memory mirror of `Composer\Repository\InstalledFilesystemRepository` +/// (`vendor/composer/installed.json`). Carries enough information for +/// commands that walk the local install (currently: `dump-autoload`). +pub struct LocalRepository { + packages: Vec<LocalPackage>, +} + +impl LocalRepository { + pub fn new(packages: Vec<LocalPackage>) -> Self { + Self { packages } + } + + /// Mirror of `WritableRepositoryInterface::getCanonicalPackages` — + /// "at most one package of each name, with aliases unfolded". Mozart + /// does not yet model alias packages, so this is currently a straight + /// pass-through over the loaded packages. + pub fn canonical_packages(&self) -> impl Iterator<Item = &LocalPackage> { + self.packages.iter() + } +} + +/// Mirror of `Composer\Repository\RepositoryManager`. Today only the +/// local repository is wired up; remote repositories are loaded ad hoc by +/// commands and will move here as the registry layer is ported. +pub struct RepositoryManager { + local_repository: LocalRepository, +} + +impl RepositoryManager { + pub fn new(local_repository: LocalRepository) -> Self { + Self { local_repository } + } + + /// Mirror of `RepositoryManager::getLocalRepository`. + pub fn local_repository(&self) -> &LocalRepository { + &self.local_repository + } +} + +/// Mirror of `Composer\Installer\InstallationManager`. Without an +/// installer plugin chain Mozart only supports the `LibraryInstaller` +/// behaviour (`vendor-dir/<pretty-name>(/<target-dir>)`). +pub struct InstallationManager { + vendor_dir: PathBuf, +} + +impl InstallationManager { + pub fn new(vendor_dir: PathBuf) -> Self { + Self { vendor_dir } + } + + /// Resolved absolute path of the vendor directory. Not on PHP's + /// `InstallationManager`, but the autoload generator needs it + /// without the round-trip through `Config::get('vendor-dir')`. + pub fn vendor_dir(&self) -> &Path { + &self.vendor_dir + } + + /// Mirror of `InstallationManager::getInstallPath` — the absolute + /// path on disk where a package's code is expected to live. Returns + /// `None` when the package has nothing on disk (metapackages); for + /// regular library packages this matches `LibraryInstaller::getInstallPath`. + pub fn get_install_path(&self, package: &LocalPackage) -> Option<PathBuf> { + let mut path = self.vendor_dir.join(package.pretty_name()); + if let Some(td) = package.target_dir() { + path = path.join(td); + } + Some(path) + } +} + +/// Mirror of `Composer\Autoload\AutoloadGenerator`. +/// +/// PHP's class is stateful: `setDryRun`, `setDevMode`, … flip private +/// flags that `dump()` later reads. Mozart deliberately diverges here — +/// the per-call toggles live in [`AutoloadDumpOptions`] which is +/// passed into `dump()` as a parameter, and [`AutoloadGenerator`] is a +/// once-constructed handle that only holds dependencies that are +/// genuinely lifetime-shared (PHP's `EventDispatcher` / `IO` will land +/// here once they're ported). Today there are none, so the struct is +/// empty — but keeping it as a real type preserves the +/// `composer.autoload_generator().dump(...)` calling shape and gives a +/// home for those dependencies later. +pub struct AutoloadGenerator { + // Intentionally empty. EventDispatcher / IO will move here once + // ported; for now `dump()` (in `mozart-autoload`) reads everything + // it needs from its arguments. + _private: (), +} + +impl AutoloadGenerator { + pub fn new() -> Self { + Self { _private: () } + } +} + +impl Default for AutoloadGenerator { + fn default() -> Self { + Self::new() + } +} + +/// Per-invocation toggles passed to +/// `mozart_autoload::AutoloadGeneratorExt::dump`. +/// +/// Diverges from PHP, where these live on `AutoloadGenerator` itself +/// and are flipped by `setDryRun` / `setDevMode` / … . In Mozart the +/// generator carries no transient state, so commands assemble an +/// [`AutoloadDumpOptions`] and hand it to `dump()` directly. +pub struct AutoloadDumpOptions { + /// `None` mirrors PHP's `private ?bool $devMode = null` — meaning + /// "auto-detect from `installed.json`'s `dev` flag at dump time". + /// `Some(_)` corresponds to an explicit `setDevMode` call. + pub dev_mode: Option<bool>, + /// `setClassMapAuthoritative`. + pub class_map_authoritative: bool, + /// `setApcu` first arg. + pub apcu: bool, + /// `setApcu` second arg. The prefix is recorded even when `apcu` + /// is false, matching the PHP signature. + pub apcu_prefix: Option<String>, + /// `setRunScripts`. + pub run_scripts: bool, + /// `setDryRun`. + pub dry_run: bool, + /// `setPlatformRequirementFilter`. Defaults to + /// `PlatformRequirementFilterFactory::ignoreNothing()`. + pub platform_requirement_filter: PlatformRequirementFilter, +} + +impl AutoloadDumpOptions { + /// Same defaults as PHP's `AutoloadGenerator::__construct` — every + /// toggle off, dev-mode unset (auto-detect), filter set to + /// `IgnoreNothing`. + pub fn new() -> Self { + Self { + dev_mode: None, + class_map_authoritative: false, + apcu: false, + apcu_prefix: None, + run_scripts: false, + dry_run: false, + platform_requirement_filter: PlatformRequirementFilter::ignore_nothing(), + } + } +} + +impl Default for AutoloadDumpOptions { + fn default() -> Self { + Self::new() + } +} + +/// Mirror of `Composer\Filter\PlatformRequirementFilter\PlatformRequirementFilterInterface` +/// and its three concrete implementations. +/// +/// The autoload generator and resolver consult this when deciding +/// whether to emit / enforce a `php`, `ext-*`, `lib-*`, or +/// `composer-*` requirement. For non-platform packages every variant +/// returns `false` — matching PHP's `IgnoreListPlatformRequirementFilter` +/// short-circuiting via `PlatformRepository::isPlatformPackage`. +pub enum PlatformRequirementFilter { + /// `IgnoreNothingPlatformRequirementFilter`. Default. + IgnoreNothing, + /// `IgnoreAllPlatformRequirementFilter` — every platform package is + /// ignored. + IgnoreAll, + /// `IgnoreListPlatformRequirementFilter` — match against an explicit + /// list of names (with `*` glob support). Names suffixed with `+` + /// only suppress the upper bound, mirroring the PHP constructor. + /// `None` for either regex means "no entries" (the corresponding + /// list was empty), short-circuiting to no match. + IgnoreList { + ignore_regex: Option<Regex>, + ignore_upper_bound_regex: Option<Regex>, + }, +} + +impl PlatformRequirementFilter { + /// Mirror of `PlatformRequirementFilterFactory::ignoreNothing`. + pub fn ignore_nothing() -> Self { + PlatformRequirementFilter::IgnoreNothing + } + + /// Mirror of `PlatformRequirementFilterFactory::ignoreAll`. + pub fn ignore_all() -> Self { + PlatformRequirementFilter::IgnoreAll + } + + /// Mirror of `PlatformRequirementFilterFactory::fromBoolOrList` for + /// the list branch. `reqs` accepts entries suffixed with `+` to + /// only ignore the upper bound (`IgnoreListPlatformRequirementFilter`'s + /// constructor splits on the same suffix). + pub fn from_list(reqs: &[String]) -> anyhow::Result<Self> { + let mut ignore_all: Vec<String> = Vec::new(); + let mut ignore_upper_bound: Vec<String> = Vec::new(); + for req in reqs { + if let Some(stripped) = req.strip_suffix('+') { + ignore_upper_bound.push(stripped.to_string()); + } else { + ignore_all.push(req.clone()); + } + } + Ok(PlatformRequirementFilter::IgnoreList { + ignore_regex: package_names_to_regexp(&ignore_all)?, + ignore_upper_bound_regex: package_names_to_regexp(&ignore_upper_bound)?, + }) + } + + /// Mirror of `PlatformRequirementFilterFactory::fromBoolOrList`. + pub fn from_bool_or_list(value: BoolOrList) -> anyhow::Result<Self> { + match value { + BoolOrList::Bool(true) => Ok(Self::ignore_all()), + BoolOrList::Bool(false) => Ok(Self::ignore_nothing()), + BoolOrList::List(list) => Self::from_list(&list), + } + } + + /// Mirror of `PlatformRequirementFilterInterface::isIgnored`. + pub fn is_ignored(&self, req: &str) -> bool { + match self { + PlatformRequirementFilter::IgnoreNothing => false, + PlatformRequirementFilter::IgnoreAll => is_platform_package(req), + PlatformRequirementFilter::IgnoreList { ignore_regex, .. } => { + is_platform_package(req) && ignore_regex.as_ref().is_some_and(|re| re.is_match(req)) + } + } + } + + /// Mirror of `PlatformRequirementFilterInterface::isUpperBoundIgnored`. + pub fn is_upper_bound_ignored(&self, req: &str) -> bool { + match self { + PlatformRequirementFilter::IgnoreNothing => false, + PlatformRequirementFilter::IgnoreAll => is_platform_package(req), + PlatformRequirementFilter::IgnoreList { + ignore_regex, + ignore_upper_bound_regex, + } => { + if !is_platform_package(req) { + return false; + } + ignore_regex.as_ref().is_some_and(|re| re.is_match(req)) + || ignore_upper_bound_regex + .as_ref() + .is_some_and(|re| re.is_match(req)) + } + } + } +} + +/// Helper accepted by [`PlatformRequirementFilter::from_bool_or_list`] +/// — mirrors PHP's `bool|string[]` union by replacing it with a tagged +/// enum at the boundary. Commands typically have an +/// `--ignore-platform-reqs` flag (the `Bool` arm) plus an optional +/// `--ignore-platform-req <name>` list (the `List` arm), and convert at +/// the call site. +pub enum BoolOrList { + Bool(bool), + List(Vec<String>), +} + +/// Compile a list of package names (with `*` glob support) into a +/// case-insensitive regex matching any of them. Mirrors +/// `BasePackage::packageNamesToRegexp` and its `packageNameToRegexp` +/// helper: each name is `preg_quote`'d, then `\*` becomes `.*`. +/// +/// Returns `None` when `names` is empty — Rust's `regex` crate refuses +/// regexes that never match, so we model "match nothing" as the +/// absence of a compiled regex and short-circuit at the call site. +fn package_names_to_regexp(names: &[String]) -> anyhow::Result<Option<Regex>> { + if names.is_empty() { + return Ok(None); + } + let parts: Vec<String> = names + .iter() + .map(|n| regex::escape(n).replace("\\*", ".*")) + .collect(); + let pattern = format!("(?i)^(?:{})$", parts.join("|")); + Ok(Some(Regex::new(&pattern)?)) +} + +/// Mirror of `Composer\Repository\PlatformRepository::isPlatformPackage` +/// using the same canonical regex (`PLATFORM_PACKAGE_REGEX`). +fn is_platform_package(name: &str) -> bool { + use std::sync::OnceLock; + static RE: OnceLock<Regex> = OnceLock::new(); + let re = RE.get_or_init(|| { + Regex::new( + r"(?i)^(?:php(?:-64bit|-ipv6|-zts|-debug)?|hhvm|(?:ext|lib)-[a-z0-9](?:[_.-]?[a-z0-9]+)*|composer(?:-(?:plugin|runtime)-api)?)$", + ) + .expect("PLATFORM_PACKAGE_REGEX compiles") + }); + re.is_match(name) +} + +/// Mirror of `Composer\Package\Locker`. The full PHP class is a thick +/// wrapper around `composer.lock` (lock-data dump/load, freshness +/// check, dev-package tracking, …) — Mozart's port currently just +/// holds the lockfile path and exposes the slice the autoload +/// generator needs (`isLocked()` / `getLockData()['content-hash']`). +/// The richer accessors will land as more commands are ported. +pub struct Locker { + lock_file_path: PathBuf, +} + +impl Locker { + pub fn new(lock_file_path: PathBuf) -> Self { + Self { lock_file_path } + } + + /// Path to the underlying `composer.lock`. Mirrors + /// `Locker::getJsonFile()->getPath()`. + pub fn lock_file_path(&self) -> &Path { + &self.lock_file_path + } + + /// Mirror of `Locker::isLocked`. PHP additionally checks for the + /// presence of the `packages` array in a parsed lock; for now the + /// file-existence check is enough — every command that calls + /// `lock_data()` afterwards will surface a parse error if the + /// lockfile is corrupt. + pub fn is_locked(&self) -> bool { + self.lock_file_path.exists() + } + + /// Mirror of `Locker::getLockData`. Returns `Ok(None)` when the + /// lockfile is absent (PHP would throw `LogicException`; Mozart + /// commands currently treat "no lock" as "no data" so the autoload + /// suffix path stays simple). + pub fn lock_data(&self) -> anyhow::Result<Option<LockData>> { + if !self.lock_file_path.exists() { + return Ok(None); + } + let content = std::fs::read_to_string(&self.lock_file_path)?; + let value: serde_json::Value = serde_json::from_str(&content)?; + let content_hash = value + .get("content-hash") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + Ok(Some(LockData { content_hash })) + } +} + +/// Subset of `composer.lock` fields the autoload generator currently +/// reads. Mirrors `Locker::getLockData()` return shape, narrowed to +/// what's load-bearing today (the `content-hash` used as the autoloader +/// suffix). More fields can be added when other ports start needing +/// them. +pub struct LockData { + pub content_hash: String, } impl Composer { + /// All-args constructor used by [`crate::factory::create_composer`]. + /// Mirrors the PHP pattern of `new Composer()` followed by + /// `setConfig` / `setPackage` / `setRepositoryManager` / + /// `setInstallationManager` / `setAutoloadGenerator` / `setLocker`, + /// collapsed into a single immutable build. + pub fn new( + project_dir: PathBuf, + config: Config, + package: RawPackageData, + repository_manager: RepositoryManager, + installation_manager: InstallationManager, + autoload_generator: AutoloadGenerator, + locker: Locker, + ) -> Self { + Self { + project_dir, + config, + package, + repository_manager, + installation_manager, + autoload_generator, + locker, + } + } + /// Load Composer state for `project_dir`, requiring a composer.json. - /// Mirrors `BaseCommand::requireComposer()`. + /// Mirrors `BaseCommand::requireComposer()`, which delegates to + /// `Factory::createComposer` after asserting the file exists. pub fn require(project_dir: impl Into<PathBuf>) -> anyhow::Result<Self> { let project_dir = project_dir.into(); let composer_json = project_dir.join("composer.json"); @@ -92,7 +513,7 @@ impl Composer { project_dir.display() ); } - Self::load(project_dir, &composer_json) + create_composer(project_dir, &composer_json) } /// Load Composer state for `project_dir`, returning `None` if no @@ -104,25 +525,7 @@ impl Composer { if !composer_json.exists() { return Ok(None); } - Self::load(project_dir, &composer_json).map(Some) - } - - fn load(project_dir: PathBuf, composer_json: &Path) -> anyhow::Result<Self> { - 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); - Ok(Self { - project_dir, - config, - }) + create_composer(project_dir, &composer_json).map(Some) } pub fn project_dir(&self) -> &Path { @@ -132,4 +535,39 @@ impl Composer { pub fn config(&self) -> &Config { &self.config } + + /// Root package loaded from the project's `composer.json`. Mirrors + /// `Composer::getPackage()`; ideally this would return a fully + /// resolved `RootPackageInterface` equivalent, but Mozart does not + /// yet have a `RootPackageLoader` port — for now callers see the + /// raw, pre-normalised JSON shape. + pub fn package(&self) -> &RawPackageData { + &self.package + } + + /// Mirror of `Composer::getRepositoryManager()`. + pub fn repository_manager(&self) -> &RepositoryManager { + &self.repository_manager + } + + /// Mirror of `Composer::getInstallationManager()`. + pub fn installation_manager(&self) -> &InstallationManager { + &self.installation_manager + } + + /// Mirror of `Composer::getAutoloadGenerator()`. + /// + /// Returned by shared reference because Mozart's + /// [`AutoloadGenerator`] is stateless — per-call toggles live on + /// [`AutoloadDumpOptions`] passed into `dump()`, not on the + /// generator itself. Diverges from PHP's + /// `$composer->getAutoloadGenerator()->setDryRun(...)` chain. + pub fn autoload_generator(&self) -> &AutoloadGenerator { + &self.autoload_generator + } + + /// Mirror of `Composer::getLocker()`. + pub fn locker(&self) -> &Locker { + &self.locker + } } 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")); + } + } } |
