aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart-core/src/factory.rs
diff options
context:
space:
mode:
Diffstat (limited to 'crates/mozart-core/src/factory.rs')
-rw-r--r--crates/mozart-core/src/factory.rs299
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"));
+ }
+ }
}