diff options
Diffstat (limited to 'crates')
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; |
