From 2ad57b7efb685040b24d93aab5b81ddfbd0ebefb Mon Sep 17 00:00:00 2001 From: nsfisis Date: Tue, 5 May 2026 15:12:57 +0900 Subject: feat(core): add Composer struct mirroring requireComposer/tryComposer Introduce mozart_core::composer::Composer with require()/try_load() constructors and a config() accessor, modelled on PHP Composer's BaseCommand::requireComposer / tryComposer. ComposerConfig and composer_home move into mozart-core so Composer::load can resolve placeholders consistently. Migrate dump-autoload, archive, exec and run-script away from ad-hoc composer.json reads. exec and run-script now fail when composer.json is missing instead of silently falling back to "vendor/bin", matching the upstream requireComposer contract. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/mozart-core/src/composer.rs | 260 +++++++++++++++++++++++++++++++++++++ crates/mozart-core/src/lib.rs | 1 + 2 files changed, 261 insertions(+) create mode 100644 crates/mozart-core/src/composer.rs (limited to 'crates/mozart-core') diff --git a/crates/mozart-core/src/composer.rs b/crates/mozart-core/src/composer.rs new file mode 100644 index 0000000..dd7c9a6 --- /dev/null +++ b/crates/mozart-core/src/composer.rs @@ -0,0 +1,260 @@ +//! Composer-equivalent root state: composer.json + effective config. +//! +//! Mirrors the role of `Composer\Composer` (PHP) to the extent that command +//! handlers need today: a single struct loaded from the project directory, +//! exposing a `config()` accessor over the merged Composer config. +//! +//! See `Composer\Command\BaseCommand::requireComposer()` / +//! `Composer\Command\BaseCommand::tryComposer()` for the upstream contract +//! that [`Composer::require`] and [`Composer::try_load`] are modelled on. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +// ─── composer_home ──────────────────────────────────────────────────────────── + +/// 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 = 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() +} + +// ─── ComposerConfig ─────────────────────────────────────────────────────────── + +/// Effective Composer config key/value pairs for a project. +/// Keys mirror `Composer\Config`'s defaults; values are stored as raw +/// `serde_json::Value` so callers can re-interpret them per key. +pub struct ComposerConfig { + pub values: BTreeMap, +} + +impl ComposerConfig { + /// Build a `ComposerConfig` populated with Composer's built-in defaults. + pub fn defaults() -> Self { + let mut m: BTreeMap = BTreeMap::new(); + + m.insert("process-timeout".to_string(), serde_json::json!(300)); + m.insert("use-include-path".to_string(), serde_json::json!(false)); + m.insert("preferred-install".to_string(), serde_json::json!("dist")); + m.insert("notify-on-install".to_string(), serde_json::json!(true)); + m.insert( + "github-protocols".to_string(), + serde_json::json!(["https", "ssh", "git"]), + ); + m.insert("vendor-dir".to_string(), serde_json::json!("vendor")); + m.insert( + "bin-dir".to_string(), + serde_json::json!("{$vendor-dir}/bin"), + ); + m.insert("bin-compat".to_string(), serde_json::json!("auto")); + m.insert("cache-dir".to_string(), serde_json::json!("{$home}/cache")); + m.insert( + "cache-files-dir".to_string(), + serde_json::json!("{$cache-dir}/files"), + ); + m.insert( + "cache-repo-dir".to_string(), + serde_json::json!("{$cache-dir}/repo"), + ); + m.insert( + "cache-vcs-dir".to_string(), + serde_json::json!("{$cache-dir}/vcs"), + ); + m.insert("cache-files-ttl".to_string(), serde_json::json!(15_552_000)); + m.insert( + "cache-files-maxsize".to_string(), + serde_json::json!("300MiB"), + ); + m.insert("cache-read-only".to_string(), serde_json::json!(false)); + m.insert("prepend-autoloader".to_string(), serde_json::json!(true)); + m.insert("autoloader-suffix".to_string(), serde_json::Value::Null); + m.insert("optimize-autoloader".to_string(), serde_json::json!(false)); + m.insert("sort-packages".to_string(), serde_json::json!(false)); + m.insert( + "classmap-authoritative".to_string(), + serde_json::json!(false), + ); + m.insert("apcu-autoloader".to_string(), serde_json::json!(false)); + m.insert("platform".to_string(), serde_json::json!({})); + m.insert("platform-check".to_string(), serde_json::json!("php-only")); + m.insert("lock".to_string(), serde_json::json!(true)); + m.insert("discard-changes".to_string(), serde_json::json!(false)); + m.insert("archive-format".to_string(), serde_json::json!("tar")); + m.insert("archive-dir".to_string(), serde_json::json!(".")); + m.insert("htaccess-protect".to_string(), serde_json::json!(true)); + m.insert("secure-http".to_string(), serde_json::json!(true)); + m.insert("allow-plugins".to_string(), serde_json::json!({})); + + Self { values: m } + } + + /// Merge `overrides` on top of the current values. + pub fn merge(&mut self, overrides: &BTreeMap) { + for (k, v) in overrides { + self.values.insert(k.clone(), v.clone()); + } + } + + /// Return the effective value for a single key, or `None` if absent. + pub fn get(&self, key: &str) -> Option<&serde_json::Value> { + self.values.get(key) + } +} + +/// Resolve `{$vendor-dir}`, `{$home}`, `{$cache-dir}` placeholders inside +/// string values. Only one pass is performed (no recursive expansion). +pub fn resolve_references(config: &mut ComposerConfig) { + // Snapshot the values we need for substitution before mutating. + let vendor_dir = config + .values + .get("vendor-dir") + .and_then(|v| v.as_str()) + .unwrap_or("vendor") + .to_string(); + + let home = composer_home().to_string_lossy().into_owned(); + + let cache_dir = config + .values + .get("cache-dir") + .and_then(|v| v.as_str()) + .unwrap_or("{$home}/cache") + .replace("{$home}", &home); + + let replacements: &[(&str, &str)] = &[ + ("{$vendor-dir}", &vendor_dir), + ("{$home}", &home), + ("{$cache-dir}", &cache_dir), + ]; + + let keys: Vec = config.values.keys().cloned().collect(); + for key in keys { + if let Some(serde_json::Value::String(s)) = config.values.get(&key).cloned() { + let mut resolved = s.clone(); + for (placeholder, replacement) in replacements { + resolved = resolved.replace(placeholder, replacement); + } + if resolved != s { + config + .values + .insert(key, serde_json::Value::String(resolved)); + } + } + } +} + +// ─── Composer ──────────────────────────────────────────────────────────────── + +/// Project-level Composer state. Currently only carries the merged +/// `ComposerConfig`; additional accessors (root package, locker, …) can be +/// layered on as commands need them. +pub struct Composer { + project_dir: PathBuf, + config: ComposerConfig, +} + +impl Composer { + /// Load Composer state for `project_dir`, requiring a composer.json. + /// Mirrors `BaseCommand::requireComposer()`. + pub fn require(project_dir: impl Into) -> anyhow::Result { + 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() + ); + } + Self::load(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) -> anyhow::Result> { + let project_dir = project_dir.into(); + let composer_json = project_dir.join("composer.json"); + if !composer_json.exists() { + return Ok(None); + } + Self::load(project_dir, &composer_json).map(Some) + } + + fn load(project_dir: PathBuf, composer_json: &Path) -> anyhow::Result { + let content = std::fs::read_to_string(composer_json)?; + let value: serde_json::Value = serde_json::from_str(&content)?; + let mut config = ComposerConfig::defaults(); + if let Some(cfg_obj) = value.get("config").and_then(|v| v.as_object()) { + let overrides: BTreeMap = cfg_obj + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + config.merge(&overrides); + } + resolve_references(&mut config); + Ok(Self { + project_dir, + config, + }) + } + + pub fn project_dir(&self) -> &Path { + &self.project_dir + } + + pub fn config(&self) -> &ComposerConfig { + &self.config + } +} diff --git a/crates/mozart-core/src/lib.rs b/crates/mozart-core/src/lib.rs index bc0da53..5e51d63 100644 --- a/crates/mozart-core/src/lib.rs +++ b/crates/mozart-core/src/lib.rs @@ -1,3 +1,4 @@ +pub mod composer; pub mod console; pub mod exit_code; pub mod http; -- cgit v1.3.1