aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-09 18:44:31 +0900
committernsfisis <nsfisis@gmail.com>2026-05-09 18:44:31 +0900
commitf9671f2dcde92d5c037595d0d3f01396a8190970 (patch)
tree56e1bfcb8f9940a49bd9e658c982514cdde0c367
parent0802fd44ed11283f15900d2993fc495acf1bed01 (diff)
downloadphp-mozart-f9671f2dcde92d5c037595d0d3f01396a8190970.tar.gz
php-mozart-f9671f2dcde92d5c037595d0d3f01396a8190970.tar.zst
php-mozart-f9671f2dcde92d5c037595d0d3f01396a8190970.zip
refactor(composer): move Composer and Factory from mozart-core to mozart
Composer needs DownloadManager (from mozart-registry), but mozart-core sits below mozart-registry in the dependency graph — adding the field would create a dependency cycle. Moving Composer and create_composer to the mozart CLI crate breaks the cycle and lets the root state container hold a DownloadManager. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--crates/mozart-core/src/composer.rs578
-rw-r--r--crates/mozart-core/src/config.rs12
-rw-r--r--crates/mozart-core/src/factory.rs346
-rw-r--r--crates/mozart-registry/src/download_manager.rs15
-rw-r--r--crates/mozart/src/commands/archive.rs2
-rw-r--r--crates/mozart/src/commands/audit.rs2
-rw-r--r--crates/mozart/src/commands/base_config.rs3
-rw-r--r--crates/mozart/src/commands/browse.rs2
-rw-r--r--crates/mozart/src/commands/bump.rs3
-rw-r--r--crates/mozart/src/commands/clear_cache.rs2
-rw-r--r--crates/mozart/src/commands/config.rs8
-rw-r--r--crates/mozart/src/commands/config_helpers.rs3
-rw-r--r--crates/mozart/src/commands/diagnose.rs2
-rw-r--r--crates/mozart/src/commands/dump_autoload.rs3
-rw-r--r--crates/mozart/src/commands/exec.rs2
-rw-r--r--crates/mozart/src/commands/fund.rs2
-rw-r--r--crates/mozart/src/commands/global.rs3
-rw-r--r--crates/mozart/src/commands/licenses.rs2
-rw-r--r--crates/mozart/src/commands/reinstall.rs3
-rw-r--r--crates/mozart/src/commands/run_script.rs2
-rw-r--r--crates/mozart/src/commands/status.rs29
-rw-r--r--crates/mozart/src/commands/update.rs2
-rw-r--r--crates/mozart/src/commands/validate.rs2
-rw-r--r--crates/mozart/src/composer.rs152
-rw-r--r--crates/mozart/src/factory.rs362
-rw-r--r--crates/mozart/src/lib.rs3
26 files changed, 800 insertions, 745 deletions
diff --git a/crates/mozart-core/src/composer.rs b/crates/mozart-core/src/composer.rs
index 64c3c97..287652a 100644
--- a/crates/mozart-core/src/composer.rs
+++ b/crates/mozart-core/src/composer.rs
@@ -11,116 +11,6 @@
//! `Composer\Command\BaseCommand::tryComposer()` for the upstream contract
//! that [`Composer::require`] and [`Composer::try_load`] are modelled on.
-use std::path::{Path, PathBuf};
-
-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.
-///
-/// On Unix:
-/// - If XDG is in use (any `XDG_*` env var exists, or `/etc/xdg` exists),
-/// prefer `$XDG_CONFIG_HOME/composer` (or `$HOME/.config/composer`).
-/// - Always include `$HOME/.composer` as a fallback candidate.
-/// - Return the first candidate directory that exists on disk;
-/// if none exist, return the first candidate.
-pub fn composer_home() -> PathBuf {
- if let Ok(val) = std::env::var("COMPOSER_HOME")
- && !val.is_empty()
- {
- return PathBuf::from(val);
- }
-
- #[cfg(target_os = "windows")]
- {
- if let Ok(appdata) = std::env::var("APPDATA")
- && !appdata.is_empty()
- {
- return PathBuf::from(appdata).join("Composer");
- }
- return PathBuf::from("C:/ProgramData/ComposerSetup/bin");
- }
-
- #[cfg(not(target_os = "windows"))]
- {
- let home_dir = std::env::var("HOME")
- .map(PathBuf::from)
- .unwrap_or_else(|_| PathBuf::from("/tmp"));
-
- let mut candidates: Vec<PathBuf> = Vec::new();
-
- if use_xdg() {
- let xdg_config = std::env::var("XDG_CONFIG_HOME")
- .map(PathBuf::from)
- .unwrap_or_else(|_| home_dir.join(".config"));
- candidates.push(xdg_config.join("composer"));
- }
-
- candidates.push(home_dir.join(".composer"));
-
- // Return first candidate that exists; otherwise return the first
- candidates
- .iter()
- .find(|p| p.is_dir())
- .cloned()
- .unwrap_or_else(|| candidates.into_iter().next().unwrap())
- }
-}
-
-#[cfg(not(target_os = "windows"))]
-fn use_xdg() -> bool {
- std::env::vars().any(|(k, _)| k.starts_with("XDG_"))
- || std::path::Path::new("/etc/xdg").is_dir()
-}
-
-/// 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,
-}
-
-/// Which source the package was installed from. Mirrors
-/// `PackageInterface::getInstallationSource` ("source" | "dist").
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub enum InstallationSource {
- Source,
- Dist,
-}
-
-impl InstallationSource {
- /// Parse the `installation-source` field from `installed.json`.
- pub fn parse(s: &str) -> Option<Self> {
- match s {
- "source" => Some(InstallationSource::Source),
- "dist" => Some(InstallationSource::Dist),
- _ => None,
- }
- }
-}
-
-/// Source/dist descriptor — mirrors the nested `source`/`dist` objects in
-/// `installed.json`.
-#[derive(Debug, Clone)]
-pub struct PackageReference {
- pub kind: String,
- pub url: String,
- pub reference: Option<String>,
- pub shasum: Option<String>,
-}
-
/// Subset of `Composer\Package\PackageInterface` carried through Mozart's
/// `LocalRepository`. Holds the fields needed by both the installation
/// manager (`prettyName`, `targetDir`) and the status command
@@ -214,6 +104,177 @@ impl LocalPackage {
}
}
+/// Which source the package was installed from. Mirrors
+/// `PackageInterface::getInstallationSource` ("source" | "dist").
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum InstallationSource {
+ Source,
+ Dist,
+}
+
+impl InstallationSource {
+ /// Parse the `installation-source` field from `installed.json`.
+ pub fn parse(s: &str) -> Option<Self> {
+ match s {
+ "source" => Some(InstallationSource::Source),
+ "dist" => Some(InstallationSource::Dist),
+ _ => None,
+ }
+ }
+}
+
+/// Source/dist descriptor — mirrors the nested `source`/`dist` objects in
+/// `installed.json`.
+#[derive(Debug, Clone)]
+pub struct PackageReference {
+ pub kind: String,
+ pub url: String,
+ pub reference: Option<String>,
+ pub shasum: Option<String>,
+}
+
+/// 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::Regex>,
+ ignore_upper_bound_regex: Option<regex::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::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::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::Regex> = OnceLock::new();
+ let re = RE.get_or_init(|| {
+ regex::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)
+}
+
/// In-memory mirror of `Composer\Repository\InstalledFilesystemRepository`
/// (`vendor/composer/installed.json`). Carries enough information for
/// commands that walk the local install (currently: `dump-autoload`).
@@ -281,18 +342,18 @@ impl RepositoryManager {
/// installer plugin chain Mozart only supports the `LibraryInstaller`
/// behaviour (`vendor-dir/<pretty-name>(/<target-dir>)`).
pub struct InstallationManager {
- vendor_dir: PathBuf,
+ vendor_dir: std::path::PathBuf,
}
impl InstallationManager {
- pub fn new(vendor_dir: PathBuf) -> Self {
+ pub fn new(vendor_dir: std::path::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 {
+ pub fn vendor_dir(&self) -> &std::path::Path {
&self.vendor_dir
}
@@ -300,7 +361,7 @@ impl InstallationManager {
/// 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> {
+ pub fn get_install_path(&self, package: &LocalPackage) -> Option<std::path::PathBuf> {
let mut path = self.vendor_dir.join(package.pretty_name());
if let Some(td) = package.target_dir() {
path = path.join(td);
@@ -391,148 +452,6 @@ impl Default for AutoloadDumpOptions {
}
}
-/// 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
@@ -540,17 +459,17 @@ fn is_platform_package(name: &str) -> bool {
/// generator needs (`isLocked()` / `getLockData()['content-hash']`).
/// The richer accessors will land as more commands are ported.
pub struct Locker {
- lock_file_path: PathBuf,
+ lock_file_path: std::path::PathBuf,
}
impl Locker {
- pub fn new(lock_file_path: PathBuf) -> Self {
+ pub fn new(lock_file_path: std::path::PathBuf) -> Self {
Self { lock_file_path }
}
/// Path to the underlying `composer.lock`. Mirrors
/// `Locker::getJsonFile()->getPath()`.
- pub fn lock_file_path(&self) -> &Path {
+ pub fn lock_file_path(&self) -> &std::path::Path {
&self.lock_file_path
}
@@ -591,111 +510,60 @@ 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()`, 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");
- if !composer_json.exists() {
- anyhow::bail!(
- "Composer could not find a composer.json file in {}",
- project_dir.display()
- );
- }
- create_composer(project_dir, &composer_json)
+/// Return the Composer home directory, respecting `COMPOSER_HOME` and falling
+/// back to the platform default using Composer-compatible logic.
+///
+/// On Unix:
+/// - If XDG is in use (any `XDG_*` env var exists, or `/etc/xdg` exists),
+/// prefer `$XDG_CONFIG_HOME/composer` (or `$HOME/.config/composer`).
+/// - Always include `$HOME/.composer` as a fallback candidate.
+/// - Return the first candidate directory that exists on disk;
+/// if none exist, return the first candidate.
+pub fn composer_home() -> std::path::PathBuf {
+ if let Ok(val) = std::env::var("COMPOSER_HOME")
+ && !val.is_empty()
+ {
+ return std::path::PathBuf::from(val);
}
- /// Load Composer state for `project_dir`, returning `None` if no
- /// composer.json exists. Other I/O or parse errors still propagate.
- /// Mirrors `BaseCommand::tryComposer()`.
- pub fn try_load(project_dir: impl Into<PathBuf>) -> anyhow::Result<Option<Self>> {
- let project_dir = project_dir.into();
- let composer_json = project_dir.join("composer.json");
- if !composer_json.exists() {
- return Ok(None);
+ #[cfg(target_os = "windows")]
+ {
+ if let Ok(appdata) = std::env::var("APPDATA")
+ && !appdata.is_empty()
+ {
+ return std::path::PathBuf::from(appdata).join("Composer");
}
- create_composer(project_dir, &composer_json).map(Some)
- }
-
- /// Load Composer state keyed on a specific `composer.json` file, deriving
- /// the project directory from `file.parent()`. Mirrors
- /// `ValidateCommand::createComposerInstance($file)` — Composer keys
- /// instances on a file rather than a directory for non-default paths.
- pub fn try_load_from_file(file: &Path) -> anyhow::Result<Option<Self>> {
- let project_dir = file
- .parent()
- .map(Path::to_path_buf)
- .unwrap_or_else(|| PathBuf::from("."));
- Self::try_load(project_dir)
+ return std::path::PathBuf::from("C:/ProgramData/ComposerSetup/bin");
}
- pub fn project_dir(&self) -> &Path {
- &self.project_dir
- }
-
- pub fn config(&self) -> &Config {
- &self.config
- }
+ #[cfg(not(target_os = "windows"))]
+ {
+ let home_dir = std::env::var("HOME")
+ .map(std::path::PathBuf::from)
+ .unwrap_or_else(|_| std::path::PathBuf::from("/tmp"));
- /// 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
- }
+ let mut candidates = Vec::new();
- /// Mirror of `Composer::getRepositoryManager()`.
- pub fn repository_manager(&self) -> &RepositoryManager {
- &self.repository_manager
- }
+ if use_xdg() {
+ let xdg_config = std::env::var("XDG_CONFIG_HOME")
+ .map(std::path::PathBuf::from)
+ .unwrap_or_else(|_| home_dir.join(".config"));
+ candidates.push(xdg_config.join("composer"));
+ }
- /// Mirror of `Composer::getInstallationManager()`.
- pub fn installation_manager(&self) -> &InstallationManager {
- &self.installation_manager
- }
+ candidates.push(home_dir.join(".composer"));
- /// 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
+ // Return first candidate that exists; otherwise return the first
+ candidates
+ .iter()
+ .find(|p| p.is_dir())
+ .cloned()
+ .unwrap_or_else(|| candidates.into_iter().next().unwrap())
}
+}
- /// Mirror of `Composer::getLocker()`.
- pub fn locker(&self) -> &Locker {
- &self.locker
- }
+#[cfg(not(target_os = "windows"))]
+fn use_xdg() -> bool {
+ std::env::vars().any(|(k, _)| k.starts_with("XDG_"))
+ || std::path::Path::new("/etc/xdg").is_dir()
}
diff --git a/crates/mozart-core/src/config.rs b/crates/mozart-core/src/config.rs
index cbb3ba6..1220dee 100644
--- a/crates/mozart-core/src/config.rs
+++ b/crates/mozart-core/src/config.rs
@@ -328,12 +328,6 @@ impl Config {
}
}
-fn substitute(s: &str, vendor_dir: &str, home: &str, cache_dir: &str) -> String {
- s.replace("{$vendor-dir}", vendor_dir)
- .replace("{$home}", home)
- .replace("{$cache-dir}", cache_dir)
-}
-
/// Resolve `{$vendor-dir}`, `{$home}`, and `{$cache-dir}` placeholders in
/// string-valued fields. Only one pass is performed (no recursive expansion).
pub fn resolve_references(config: &mut Config) {
@@ -368,3 +362,9 @@ pub fn resolve_references(config: &mut Config) {
}
}
}
+
+fn substitute(s: &str, vendor_dir: &str, home: &str, cache_dir: &str) -> String {
+ s.replace("{$vendor-dir}", vendor_dir)
+ .replace("{$home}", home)
+ .replace("{$cache-dir}", cache_dir)
+}
diff --git a/crates/mozart-core/src/factory.rs b/crates/mozart-core/src/factory.rs
index a7a690c..39024c8 100644
--- a/crates/mozart-core/src/factory.rs
+++ b/crates/mozart-core/src/factory.rs
@@ -9,15 +9,10 @@
//! wiring are intentionally omitted as they are out of scope for the
//! current port.
+use crate::composer::composer_home;
+use crate::config::Config;
use std::collections::BTreeMap;
-use std::path::{Path, PathBuf};
-
-use crate::composer::{
- AutoloadGenerator, Composer, InstallationManager, LocalPackage, LocalRepository, Locker,
- RepositoryManager, composer_home,
-};
-use crate::config::{Config, resolve_references};
-use crate::package::read_from_file;
+use std::path::PathBuf;
/// Rust port of `Factory::getCacheDir()`.
///
@@ -156,194 +151,6 @@ 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, dev_mode) = read_local_packages(&vendor_dir)?;
- let repository_manager =
- RepositoryManager::new(LocalRepository::with_dev_mode(local_packages, dev_mode));
- 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>, Option<bool>)> {
- let path = vendor_dir.join("composer/installed.json");
- if !path.exists() {
- return Ok((Vec::new(), None));
- }
- let content = std::fs::read_to_string(&path)?;
- let value: serde_json::Value = serde_json::from_str(&content)?;
-
- let (entries, dev_mode): (&[serde_json::Value], Option<bool>) = match &value {
- serde_json::Value::Object(obj) => {
- let entries = match obj.get("packages") {
- Some(serde_json::Value::Array(arr)) => arr.as_slice(),
- _ => return Ok((Vec::new(), obj.get("dev").and_then(|v| v.as_bool()))),
- };
- (entries, obj.get("dev").and_then(|v| v.as_bool()))
- }
- serde_json::Value::Array(arr) => (arr.as_slice(), None),
- _ => return Ok((Vec::new(), None)),
- };
-
- 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 pretty_version = entry
- .get("version")
- .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());
- let package_type = entry
- .get("type")
- .and_then(|v| v.as_str())
- .map(|s| s.to_string());
- let installation_source = entry
- .get("installation-source")
- .and_then(|v| v.as_str())
- .and_then(crate::composer::InstallationSource::parse);
- let source = read_package_reference(entry.get("source"));
- let dist = read_package_reference(entry.get("dist"));
- let extra = entry
- .get("extra")
- .cloned()
- .unwrap_or(serde_json::Value::Null);
- out.push(LocalPackage::new(
- pretty_name,
- pretty_version,
- target_dir,
- package_type,
- installation_source,
- source,
- dist,
- extra,
- ));
- }
- Ok((out, dev_mode))
-}
-
-fn read_package_reference(
- value: Option<&serde_json::Value>,
-) -> Option<crate::composer::PackageReference> {
- let v = value?;
- let kind = v.get("type").and_then(|x| x.as_str())?.to_string();
- let url = v
- .get("url")
- .and_then(|x| x.as_str())
- .unwrap_or("")
- .to_string();
- let reference = v
- .get("reference")
- .and_then(|x| x.as_str())
- .map(|s| s.to_string());
- let shasum = v
- .get("shasum")
- .and_then(|x| x.as_str())
- .filter(|s| !s.is_empty())
- .map(|s| s.to_string());
- Some(crate::composer::PackageReference {
- kind,
- url,
- reference,
- shasum,
- })
-}
-
#[cfg(test)]
mod tests {
use super::*;
@@ -405,151 +212,4 @@ 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()
- .get_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()
- .get_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()
- .get_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()
- .get_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()
- .get_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"));
- }
- }
}
diff --git a/crates/mozart-registry/src/download_manager.rs b/crates/mozart-registry/src/download_manager.rs
index 3e05517..7c6ff73 100644
--- a/crates/mozart-registry/src/download_manager.rs
+++ b/crates/mozart-registry/src/download_manager.rs
@@ -35,7 +35,10 @@ impl DownloadManager {
Self { git_cache_dir }
}
- pub fn for_package(&self, package: &LocalPackage) -> Option<Box<dyn VcsDownloader>> {
+ pub fn get_downloader_for_package(
+ &self,
+ package: &LocalPackage,
+ ) -> Option<Box<dyn VcsDownloader>> {
if package.package_type() == Some("metapackage") {
return None;
}
@@ -107,34 +110,34 @@ mod tests {
None,
Value::Null,
);
- assert!(dm.for_package(&p).is_none());
+ assert!(dm.get_downloader_for_package(&p).is_none());
}
#[test]
fn dist_install_returns_none() {
let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache"));
let p = pkg(Some(InstallationSource::Dist), Some("git"));
- assert!(dm.for_package(&p).is_none());
+ assert!(dm.get_downloader_for_package(&p).is_none());
}
#[test]
fn source_install_with_git_returns_some() {
let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache"));
let p = pkg(Some(InstallationSource::Source), Some("git"));
- assert!(dm.for_package(&p).is_some());
+ assert!(dm.get_downloader_for_package(&p).is_some());
}
#[test]
fn unknown_source_kind_returns_none() {
let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache"));
let p = pkg(Some(InstallationSource::Source), Some("perforce"));
- assert!(dm.for_package(&p).is_none());
+ assert!(dm.get_downloader_for_package(&p).is_none());
}
#[test]
fn missing_installation_source_returns_none() {
let dm = DownloadManager::new(PathBuf::from("/tmp/mz-test-cache"));
let p = pkg(None, Some("git"));
- assert!(dm.for_package(&p).is_none());
+ assert!(dm.get_downloader_for_package(&p).is_none());
}
}
diff --git a/crates/mozart/src/commands/archive.rs b/crates/mozart/src/commands/archive.rs
index a3311df..7e1697f 100644
--- a/crates/mozart/src/commands/archive.rs
+++ b/crates/mozart/src/commands/archive.rs
@@ -1,6 +1,6 @@
+use crate::composer::Composer;
use clap::Args;
use mozart_archiver::{ArchiveManager, ArchivePackage};
-use mozart_core::composer::Composer;
use mozart_core::console_writeln;
use mozart_core::factory::create_config;
use std::borrow::Cow;
diff --git a/crates/mozart/src/commands/audit.rs b/crates/mozart/src/commands/audit.rs
index df0e5a9..30f0716 100644
--- a/crates/mozart/src/commands/audit.rs
+++ b/crates/mozart/src/commands/audit.rs
@@ -1,9 +1,9 @@
use std::path::Path;
+use crate::composer::Composer;
use clap::Args;
use indexmap::IndexMap;
use mozart_core::advisory::{AbandonedHandling, AuditConfig, AuditFormat};
-use mozart_core::composer::Composer;
use mozart_registry::advisory::{AuditOptions, Auditor, PackageInfo};
use mozart_registry::cache::{Cache, build_cache_config};
use mozart_registry::repository::RepositorySet;
diff --git a/crates/mozart/src/commands/base_config.rs b/crates/mozart/src/commands/base_config.rs
index bfed161..c10e7e7 100644
--- a/crates/mozart/src/commands/base_config.rs
+++ b/crates/mozart/src/commands/base_config.rs
@@ -1,9 +1,8 @@
use std::path::PathBuf;
+use mozart_core::composer::composer_home;
use mozart_core::config_source::JsonConfigSource;
-use super::config_helpers::composer_home;
-
/// Mirrors Composer's `BaseConfigCommand`: resolves the target config file path
/// and enforces the `--file` ↔ `--global` mutual exclusivity.
pub(crate) struct BaseConfigContext {
diff --git a/crates/mozart/src/commands/browse.rs b/crates/mozart/src/commands/browse.rs
index c4c957b..a8ccab3 100644
--- a/crates/mozart/src/commands/browse.rs
+++ b/crates/mozart/src/commands/browse.rs
@@ -1,5 +1,5 @@
+use crate::composer::Composer;
use clap::Args;
-use mozart_core::composer::Composer;
use mozart_core::console::Console;
use mozart_core::console_writeln;
use mozart_core::console_writeln_error;
diff --git a/crates/mozart/src/commands/bump.rs b/crates/mozart/src/commands/bump.rs
index 1ed4c54..4722fe2 100644
--- a/crates/mozart/src/commands/bump.rs
+++ b/crates/mozart/src/commands/bump.rs
@@ -1,6 +1,7 @@
+use crate::composer::Composer;
use clap::Args;
use indexmap::IndexMap;
-use mozart_core::composer::{Composer, LocalRepository};
+use mozart_core::composer::LocalRepository;
use mozart_core::console::Console;
use mozart_core::{console_writeln, console_writeln_error};
use std::collections::BTreeMap;
diff --git a/crates/mozart/src/commands/clear_cache.rs b/crates/mozart/src/commands/clear_cache.rs
index 8fdf665..9ee27ed 100644
--- a/crates/mozart/src/commands/clear_cache.rs
+++ b/crates/mozart/src/commands/clear_cache.rs
@@ -1,7 +1,7 @@
use std::{borrow::Cow, path::Path};
+use crate::composer::Composer;
use clap::Args;
-use mozart_core::composer::Composer;
use mozart_core::console_writeln_error;
use mozart_core::factory::create_config;
use mozart_registry::cache::Cache;
diff --git a/crates/mozart/src/commands/config.rs b/crates/mozart/src/commands/config.rs
index 7227556..5012163 100644
--- a/crates/mozart/src/commands/config.rs
+++ b/crates/mozart/src/commands/config.rs
@@ -1,15 +1,15 @@
+use super::config_helpers::{
+ add_repository, read_json_file, remove_repository, render_value, write_json_file,
+};
use anyhow::anyhow;
use clap::Args;
+use mozart_core::composer::composer_home;
use mozart_core::config::resolve_references;
use mozart_core::console_writeln;
use mozart_core::factory::create_config;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
-use super::config_helpers::{
- add_repository, composer_home, read_json_file, remove_repository, render_value, write_json_file,
-};
-
#[derive(Args)]
pub struct ConfigArgs {
/// Setting key
diff --git a/crates/mozart/src/commands/config_helpers.rs b/crates/mozart/src/commands/config_helpers.rs
index fb626f6..32aacdb 100644
--- a/crates/mozart/src/commands/config_helpers.rs
+++ b/crates/mozart/src/commands/config_helpers.rs
@@ -1,8 +1,7 @@
use anyhow::anyhow;
+use mozart_core::composer::composer_home;
use std::path::{Path, PathBuf};
-pub(crate) use mozart_core::composer::composer_home;
-
/// Read TLS-related options (`config.cafile`, `config.capath`) from the merged
/// global + local config. Local values override global. Relative paths are
/// resolved against the directory of the config file that defined them.
diff --git a/crates/mozart/src/commands/diagnose.rs b/crates/mozart/src/commands/diagnose.rs
index af18fdc..17a8b78 100644
--- a/crates/mozart/src/commands/diagnose.rs
+++ b/crates/mozart/src/commands/diagnose.rs
@@ -1,7 +1,7 @@
+use crate::composer::Composer;
use clap::Args;
use colored::Colorize;
use mozart_core::MOZART_VERSION;
-use mozart_core::composer::Composer;
use mozart_core::config::Config;
use mozart_core::config_validator::{ValidatorOptions, validate_manifest};
use mozart_core::console::Console;
diff --git a/crates/mozart/src/commands/dump_autoload.rs b/crates/mozart/src/commands/dump_autoload.rs
index 0f3366c..7557d37 100644
--- a/crates/mozart/src/commands/dump_autoload.rs
+++ b/crates/mozart/src/commands/dump_autoload.rs
@@ -1,6 +1,7 @@
+use crate::composer::Composer;
use clap::Args;
use mozart_autoload::AutoloadGeneratorExt;
-use mozart_core::composer::{AutoloadDumpOptions, Composer};
+use mozart_core::composer::AutoloadDumpOptions;
use mozart_core::console_writeln;
#[derive(Args, Default)]
diff --git a/crates/mozart/src/commands/exec.rs b/crates/mozart/src/commands/exec.rs
index 27d1a8a..2b3c836 100644
--- a/crates/mozart/src/commands/exec.rs
+++ b/crates/mozart/src/commands/exec.rs
@@ -1,5 +1,5 @@
+use crate::composer::Composer;
use clap::Args;
-use mozart_core::composer::Composer;
use mozart_core::console_writeln;
use std::path::{Path, PathBuf};
diff --git a/crates/mozart/src/commands/fund.rs b/crates/mozart/src/commands/fund.rs
index 1f334b3..90a8418 100644
--- a/crates/mozart/src/commands/fund.rs
+++ b/crates/mozart/src/commands/fund.rs
@@ -1,5 +1,5 @@
+use crate::composer::Composer;
use clap::Args;
-use mozart_core::composer::Composer;
use mozart_core::console::{Console, hyperlink};
use mozart_core::console_format;
use mozart_core::console_writeln;
diff --git a/crates/mozart/src/commands/global.rs b/crates/mozart/src/commands/global.rs
index 1a8a5f8..05e8aae 100644
--- a/crates/mozart/src/commands/global.rs
+++ b/crates/mozart/src/commands/global.rs
@@ -1,4 +1,5 @@
use clap::Args;
+use mozart_core::composer::composer_home;
#[derive(Args)]
pub struct GlobalArgs {
@@ -27,7 +28,7 @@ pub async fn execute(
}
};
- let home = super::config_helpers::composer_home();
+ let home = composer_home();
fs::create_dir_all(&home)?;
diff --git a/crates/mozart/src/commands/licenses.rs b/crates/mozart/src/commands/licenses.rs
index 394cc39..671ce2a 100644
--- a/crates/mozart/src/commands/licenses.rs
+++ b/crates/mozart/src/commands/licenses.rs
@@ -1,6 +1,6 @@
+use crate::composer::Composer;
use clap::Args;
use indexmap::IndexMap;
-use mozart_core::composer::Composer;
use mozart_core::console::Console;
use mozart_core::console::hyperlink;
use mozart_core::console_writeln;
diff --git a/crates/mozart/src/commands/reinstall.rs b/crates/mozart/src/commands/reinstall.rs
index ececa96..dc99a91 100644
--- a/crates/mozart/src/commands/reinstall.rs
+++ b/crates/mozart/src/commands/reinstall.rs
@@ -1,6 +1,7 @@
+use crate::composer::Composer;
use clap::Args;
use mozart_autoload::AutoloadGeneratorExt;
-use mozart_core::composer::{AutoloadDumpOptions, Composer, LocalPackage};
+use mozart_core::composer::{AutoloadDumpOptions, LocalPackage};
use mozart_core::console_format;
use mozart_core::validation::package_name_to_regexp;
diff --git a/crates/mozart/src/commands/run_script.rs b/crates/mozart/src/commands/run_script.rs
index ade389e..e4b701a 100644
--- a/crates/mozart/src/commands/run_script.rs
+++ b/crates/mozart/src/commands/run_script.rs
@@ -1,5 +1,5 @@
+use crate::composer::Composer;
use clap::Args;
-use mozart_core::composer::Composer;
use mozart_core::script_events;
use mozart_core::{console_writeln, console_writeln_error};
use std::collections::BTreeMap;
diff --git a/crates/mozart/src/commands/status.rs b/crates/mozart/src/commands/status.rs
index b6802a3..60185cb 100644
--- a/crates/mozart/src/commands/status.rs
+++ b/crates/mozart/src/commands/status.rs
@@ -1,11 +1,11 @@
+use crate::composer::Composer;
use clap::Args;
use indexmap::IndexMap;
-use mozart_core::composer::{Composer, InstallationSource, LocalPackage};
+use mozart_core::composer::{InstallationSource, LocalPackage};
use mozart_core::console::Console;
use mozart_core::console_writeln;
use mozart_core::console_writeln_error;
use mozart_core::exit_code;
-use mozart_registry::download_manager::DownloadManager;
use mozart_vcs::version_guesser::VersionGuesser;
#[derive(Args)]
@@ -26,19 +26,21 @@ pub async fn execute(
cli: &super::Cli,
console: &Console,
) -> anyhow::Result<()> {
- let working_dir = cli.working_dir()?;
- let composer = Composer::require(&working_dir)?;
+ let composer = Composer::require(cli.working_dir()?)?;
+
let installed_repo = composer.repository_manager().local_repository();
+
+ let dm = composer.download_manager();
let im = composer.installation_manager();
- let dm = DownloadManager::new(im.vendor_dir().join(".cache").join("git"));
- let guesser = VersionGuesser::new();
- let mut errors: IndexMap<String, String> = IndexMap::new();
- let mut unpushed_changes: IndexMap<String, String> = IndexMap::new();
- let mut vcs_version_changes: IndexMap<String, VcsVerChange> = IndexMap::new();
+ let mut errors = IndexMap::new();
+ let mut unpushed_changes = IndexMap::new();
+ let mut vcs_version_changes = IndexMap::new();
+
+ let guesser = VersionGuesser::new();
for package in installed_repo.get_canonical_packages() {
- let Some(downloader) = dm.for_package(package) else {
+ let Some(downloader) = dm.get_downloader_for_package(package) else {
continue;
};
let Some(target_dir) = im.get_install_path(package) else {
@@ -111,6 +113,7 @@ pub async fn execute(
console,
"<error>You have changes in the following dependencies:</error>"
);
+
for (path, changes) in &errors {
if verbose {
console_writeln!(console, "<info>{path}</info>:");
@@ -126,6 +129,7 @@ pub async fn execute(
console,
"<warning>You have unpushed changes on the current branch in the following dependencies:</warning>"
);
+
for (path, changes) in &unpushed_changes {
if verbose {
console_writeln!(console, "<info>{path}</info>:");
@@ -141,6 +145,7 @@ pub async fn execute(
console,
"<warning>You have version variations in the following dependencies:</warning>"
);
+
for (path, change) in &vcs_version_changes {
if verbose {
let mut prev = if change.previous.version.is_empty() {
@@ -173,8 +178,8 @@ pub async fn execute(
}
let code = (if !errors.is_empty() { 1 } else { 0 })
- | (if !unpushed_changes.is_empty() { 2 } else { 0 })
- | (if !vcs_version_changes.is_empty() {
+ + (if !unpushed_changes.is_empty() { 2 } else { 0 })
+ + (if !vcs_version_changes.is_empty() {
4
} else {
0
diff --git a/crates/mozart/src/commands/update.rs b/crates/mozart/src/commands/update.rs
index a7c1412..11672fd 100644
--- a/crates/mozart/src/commands/update.rs
+++ b/crates/mozart/src/commands/update.rs
@@ -1,6 +1,6 @@
+use crate::composer::Composer;
use clap::Args;
use indexmap::{IndexMap, IndexSet};
-use mozart_core::composer::Composer;
use mozart_core::console_format;
use mozart_core::package;
use mozart_core::platform::is_platform_package;
diff --git a/crates/mozart/src/commands/validate.rs b/crates/mozart/src/commands/validate.rs
index 3fd1f56..873b371 100644
--- a/crates/mozart/src/commands/validate.rs
+++ b/crates/mozart/src/commands/validate.rs
@@ -1,5 +1,5 @@
+use crate::composer::Composer;
use clap::Args;
-use mozart_core::composer::Composer;
use mozart_core::config_validator::{ValidationResult, ValidatorOptions, validate_manifest};
use mozart_core::console_format;
use mozart_core::console_writeln;
diff --git a/crates/mozart/src/composer.rs b/crates/mozart/src/composer.rs
new file mode 100644
index 0000000..108a5d3
--- /dev/null
+++ b/crates/mozart/src/composer.rs
@@ -0,0 +1,152 @@
+//! Composer-equivalent root state: composer.json + effective config +
+//! the manager objects commands look up off the root [`Composer`].
+//!
+//! 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::path::{Path, PathBuf};
+
+use crate::factory::create_composer;
+use mozart_core::composer::{AutoloadGenerator, InstallationManager, Locker, RepositoryManager};
+use mozart_core::config::Config;
+use mozart_core::package::RawPackageData;
+use mozart_registry::download_manager::DownloadManager;
+
+/// 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,
+ download_manager: DownloadManager,
+ autoload_generator: AutoloadGenerator,
+ locker: Locker,
+}
+
+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.
+ #[allow(clippy::too_many_arguments)]
+ pub fn new(
+ project_dir: PathBuf,
+ config: Config,
+ package: RawPackageData,
+ repository_manager: RepositoryManager,
+ installation_manager: InstallationManager,
+ download_manager: DownloadManager,
+ autoload_generator: AutoloadGenerator,
+ locker: Locker,
+ ) -> Self {
+ Self {
+ project_dir,
+ config,
+ package,
+ repository_manager,
+ installation_manager,
+ download_manager,
+ autoload_generator,
+ locker,
+ }
+ }
+
+ /// Load Composer state for `project_dir`, requiring a composer.json.
+ /// 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");
+ if !composer_json.exists() {
+ anyhow::bail!(
+ "Composer could not find a composer.json file in {}",
+ project_dir.display()
+ );
+ }
+ create_composer(project_dir, &composer_json)
+ }
+
+ /// Load Composer state for `project_dir`, returning `None` if no
+ /// composer.json exists. Other I/O or parse errors still propagate.
+ /// Mirrors `BaseCommand::tryComposer()`.
+ pub fn try_load(project_dir: impl Into<PathBuf>) -> anyhow::Result<Option<Self>> {
+ let project_dir = project_dir.into();
+ let composer_json = project_dir.join("composer.json");
+ if !composer_json.exists() {
+ return Ok(None);
+ }
+ create_composer(project_dir, &composer_json).map(Some)
+ }
+
+ /// Load Composer state keyed on a specific `composer.json` file, deriving
+ /// the project directory from `file.parent()`. Mirrors
+ /// `ValidateCommand::createComposerInstance($file)` — Composer keys
+ /// instances on a file rather than a directory for non-default paths.
+ pub fn try_load_from_file(file: &Path) -> anyhow::Result<Option<Self>> {
+ let project_dir = file
+ .parent()
+ .map(Path::to_path_buf)
+ .unwrap_or_else(|| PathBuf::from("."));
+ Self::try_load(project_dir)
+ }
+
+ pub fn project_dir(&self) -> &Path {
+ &self.project_dir
+ }
+
+ 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
+ }
+
+ pub fn download_manager(&self) -> &DownloadManager {
+ &self.download_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/src/factory.rs b/crates/mozart/src/factory.rs
new file mode 100644
index 0000000..67fc9a3
--- /dev/null
+++ b/crates/mozart/src/factory.rs
@@ -0,0 +1,362 @@
+//! Factory helpers for constructing Composer state.
+//!
+//! 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 crate::composer::Composer;
+use mozart_core::composer::{
+ AutoloadGenerator, InstallationManager, InstallationSource, LocalPackage, LocalRepository,
+ Locker, PackageReference, RepositoryManager,
+};
+use mozart_core::config::resolve_references;
+use mozart_core::factory::create_config;
+use mozart_core::package::read_from_file;
+use mozart_registry::download_manager::DownloadManager;
+
+/// 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: std::path::PathBuf,
+ composer_json: &std::path::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: std::collections::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 std::path::Path::new(&config.vendor_dir).is_absolute() {
+ std::path::PathBuf::from(&config.vendor_dir)
+ } else {
+ project_dir.join(&config.vendor_dir)
+ };
+
+ let (local_packages, dev_mode) = read_local_packages(&vendor_dir)?;
+ let repository_manager =
+ RepositoryManager::new(LocalRepository::with_dev_mode(local_packages, dev_mode));
+ let installation_manager = InstallationManager::new(vendor_dir.clone());
+ let download_manager = DownloadManager::new(vendor_dir.join(".cache").join("git"));
+ 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,
+ download_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: &std::path::Path,
+) -> anyhow::Result<(Vec<LocalPackage>, Option<bool>)> {
+ let path = vendor_dir.join("composer/installed.json");
+ if !path.exists() {
+ return Ok((Vec::new(), None));
+ }
+ let content = std::fs::read_to_string(&path)?;
+ let value: serde_json::Value = serde_json::from_str(&content)?;
+
+ let (entries, dev_mode): (&[serde_json::Value], Option<bool>) = match &value {
+ serde_json::Value::Object(obj) => {
+ let entries = match obj.get("packages") {
+ Some(serde_json::Value::Array(arr)) => arr.as_slice(),
+ _ => return Ok((Vec::new(), obj.get("dev").and_then(|v| v.as_bool()))),
+ };
+ (entries, obj.get("dev").and_then(|v| v.as_bool()))
+ }
+ serde_json::Value::Array(arr) => (arr.as_slice(), None),
+ _ => return Ok((Vec::new(), None)),
+ };
+
+ 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 pretty_version = entry
+ .get("version")
+ .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());
+ let package_type = entry
+ .get("type")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+ let installation_source = entry
+ .get("installation-source")
+ .and_then(|v| v.as_str())
+ .and_then(InstallationSource::parse);
+ let source = read_package_reference(entry.get("source"));
+ let dist = read_package_reference(entry.get("dist"));
+ let extra = entry
+ .get("extra")
+ .cloned()
+ .unwrap_or(serde_json::Value::Null);
+ out.push(LocalPackage::new(
+ pretty_name,
+ pretty_version,
+ target_dir,
+ package_type,
+ installation_source,
+ source,
+ dist,
+ extra,
+ ));
+ }
+ Ok((out, dev_mode))
+}
+
+fn read_package_reference(value: Option<&serde_json::Value>) -> Option<PackageReference> {
+ let v = value?;
+ let kind = v.get("type").and_then(|x| x.as_str())?.to_string();
+ let url = v
+ .get("url")
+ .and_then(|x| x.as_str())
+ .unwrap_or("")
+ .to_string();
+ let reference = v
+ .get("reference")
+ .and_then(|x| x.as_str())
+ .map(|s| s.to_string());
+ let shasum = v
+ .get("shasum")
+ .and_then(|x| x.as_str())
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string());
+ Some(PackageReference {
+ kind,
+ url,
+ reference,
+ shasum,
+ })
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs;
+ use std::path::Path;
+ 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()
+ .get_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()
+ .get_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()
+ .get_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()
+ .get_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()
+ .get_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"));
+ }
+}
diff --git a/crates/mozart/src/lib.rs b/crates/mozart/src/lib.rs
index 82b6da3..d28081a 100644
--- a/crates/mozart/src/lib.rs
+++ b/crates/mozart/src/lib.rs
@@ -1 +1,4 @@
pub mod commands;
+
+mod composer;
+mod factory;