diff options
Diffstat (limited to 'crates/mozart-core/src')
| -rw-r--r-- | crates/mozart-core/src/composer.rs | 578 | ||||
| -rw-r--r-- | crates/mozart-core/src/config.rs | 12 | ||||
| -rw-r--r-- | crates/mozart-core/src/factory.rs | 346 |
3 files changed, 232 insertions, 704 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")); - } - } } |
