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/src/commands/archive.rs | 36 ++++---- crates/mozart/src/commands/config.rs | 131 ++------------------------- crates/mozart/src/commands/config_helpers.rs | 59 +----------- crates/mozart/src/commands/dump_autoload.rs | 17 +--- crates/mozart/src/commands/exec.rs | 42 +++++---- crates/mozart/src/commands/run_script.rs | 48 ++++------ 6 files changed, 70 insertions(+), 263 deletions(-) (limited to 'crates/mozart') diff --git a/crates/mozart/src/commands/archive.rs b/crates/mozart/src/commands/archive.rs index faa0e94..ca57259 100644 --- a/crates/mozart/src/commands/archive.rs +++ b/crates/mozart/src/commands/archive.rs @@ -104,24 +104,26 @@ pub async fn execute( None => std::env::current_dir()?, }; - // 2. Load config for format/dir defaults from composer.json's "config" section + // 2. Load Composer state for format/dir defaults. Composer's + // `archive` command falls back to `Factory::createConfig()` when no + // composer.json is present, so we mirror that by treating a missing + // file as "use defaults" (Composer::try_load returns None). + let composer = mozart_core::composer::Composer::try_load(&working_dir)?; let composer_json_path = working_dir.join("composer.json"); - let (config_archive_format, config_archive_dir) = if composer_json_path.exists() { - let content = std::fs::read_to_string(&composer_json_path)?; - let value: serde_json::Value = serde_json::from_str(&content)?; - let fmt = value - .get("config") - .and_then(|c| c.get("archive-format")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - let dir = value - .get("config") - .and_then(|c| c.get("archive-dir")) - .and_then(|v| v.as_str()) - .map(|s| s.to_string()); - (fmt, dir) - } else { - (None, None) + let (config_archive_format, config_archive_dir) = match composer.as_ref() { + Some(c) => { + let cfg = c.config(); + let fmt = cfg + .get("archive-format") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let dir = cfg + .get("archive-dir") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + (fmt, dir) + } + None => (None, None), }; // 3. Determine format: args -> config -> default "tar" diff --git a/crates/mozart/src/commands/config.rs b/crates/mozart/src/commands/config.rs index 3947afd..11b1b7b 100644 --- a/crates/mozart/src/commands/config.rs +++ b/crates/mozart/src/commands/config.rs @@ -63,124 +63,7 @@ pub struct ConfigArgs { // ─── ComposerConfig ─────────────────────────────────────────────────────────── -/// Holds the effective configuration key-value pairs for a project. -/// Keys mirror Composer's `Config.php` defaults. -pub struct ComposerConfig { - pub values: BTreeMap, -} - -impl ComposerConfig { - /// Build a `ComposerConfig` starting from the 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()); - } - } - - /// Resolve `{$vendor-dir}`, `{$home}`, `{$cache-dir}` placeholders inside - /// string values. Only one pass is performed (no recursive expansion). - pub fn resolve_references(&mut self) { - // Snapshot the values we need for substitution before mutating. - let vendor_dir = self - .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 = self - .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 = self.values.keys().cloned().collect(); - for key in keys { - if let Some(serde_json::Value::String(s)) = self.values.get(&key).cloned() { - let mut resolved = s.clone(); - for (placeholder, replacement) in replacements { - resolved = resolved.replace(placeholder, replacement); - } - if resolved != s { - self.values.insert(key, serde_json::Value::String(resolved)); - } - } - } - } - - /// 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) - } -} +pub use mozart_core::composer::{ComposerConfig, resolve_references}; // ─── ConfigValueType ───────────────────────────────────────────────────────── @@ -911,7 +794,7 @@ fn execute_read( config.merge(&overrides); } - config.resolve_references(); + resolve_references(&mut config); // If --absolute is requested, resolve *-dir values to absolute paths. if args.absolute { @@ -1120,7 +1003,7 @@ mod tests { fn test_reference_resolution_bin_dir() { let mut cfg = ComposerConfig::defaults(); // bin-dir default is "{$vendor-dir}/bin"; vendor-dir default is "vendor" - cfg.resolve_references(); + resolve_references(&mut cfg); assert_eq!(cfg.values["bin-dir"], serde_json::json!("vendor/bin")); } @@ -1132,7 +1015,7 @@ mod tests { // Override vendor-dir before resolving cfg.values .insert("vendor-dir".to_string(), serde_json::json!("lib")); - cfg.resolve_references(); + resolve_references(&mut cfg); assert_eq!(cfg.values["bin-dir"], serde_json::json!("lib/bin")); } @@ -1145,7 +1028,7 @@ mod tests { "cache-dir".to_string(), serde_json::json!("/home/user/.cache/composer"), ); - cfg.resolve_references(); + resolve_references(&mut cfg); assert_eq!( cfg.values["cache-files-dir"], @@ -1165,7 +1048,7 @@ mod tests { fn test_reference_resolution_no_change_for_non_string() { let mut cfg = ComposerConfig::defaults(); let before = cfg.values["process-timeout"].clone(); - cfg.resolve_references(); + resolve_references(&mut cfg); // Numeric values should be untouched. assert_eq!(cfg.values["process-timeout"], before); } @@ -1280,7 +1163,7 @@ mod tests { let overrides = load_config_section(&composer_json).unwrap(); let mut cfg = ComposerConfig::defaults(); cfg.merge(&overrides); - cfg.resolve_references(); + resolve_references(&mut cfg); assert_eq!(cfg.values["vendor-dir"], serde_json::json!("custom_vendor")); assert_eq!(cfg.values["sort-packages"], serde_json::json!(true)); diff --git a/crates/mozart/src/commands/config_helpers.rs b/crates/mozart/src/commands/config_helpers.rs index 422db4d..2f856a7 100644 --- a/crates/mozart/src/commands/config_helpers.rs +++ b/crates/mozart/src/commands/config_helpers.rs @@ -1,64 +1,7 @@ use anyhow::anyhow; use std::path::{Path, PathBuf}; -/// 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(crate) 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()) - } -} - -/// Check whether XDG base directories are in use: -/// any env var starting with `XDG_` exists, OR `/etc/xdg` directory exists. -fn use_xdg() -> bool { - std::env::vars().any(|(k, _)| k.starts_with("XDG_")) - || std::path::Path::new("/etc/xdg").is_dir() -} +pub(crate) use mozart_core::composer::composer_home; /// Build the working directory path, preferring `--working-dir` over `cwd`. pub(crate) fn working_dir(cli: &super::Cli) -> anyhow::Result { diff --git a/crates/mozart/src/commands/dump_autoload.rs b/crates/mozart/src/commands/dump_autoload.rs index c35690b..ec25b54 100644 --- a/crates/mozart/src/commands/dump_autoload.rs +++ b/crates/mozart/src/commands/dump_autoload.rs @@ -66,20 +66,9 @@ pub async fn execute( let vendor_dir = working_dir.join("vendor"); let dev_mode = !args.no_dev; - // B: Read config-driven defaults from composer.json - let composer_json_path = working_dir.join("composer.json"); - let mut composer_config = super::config::ComposerConfig::defaults(); - if composer_json_path.exists() - && let Ok(content) = std::fs::read_to_string(&composer_json_path) - && let Ok(value) = serde_json::from_str::(&content) - && let Some(cfg_obj) = value.get("config").and_then(|v| v.as_object()) - { - let overrides: std::collections::BTreeMap = cfg_obj - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - composer_config.merge(&overrides); - } + // B: Load Composer state (composer.json is required for dump-autoload) + let composer = mozart_core::composer::Composer::require(&working_dir)?; + let composer_config = composer.config(); let optimize = args.optimize || composer_config diff --git a/crates/mozart/src/commands/exec.rs b/crates/mozart/src/commands/exec.rs index eaaf465..5f1f0bc 100644 --- a/crates/mozart/src/commands/exec.rs +++ b/crates/mozart/src/commands/exec.rs @@ -1,4 +1,5 @@ use clap::Args; +use mozart_core::composer::Composer; use mozart_core::console_format; use std::path::{Path, PathBuf}; @@ -29,7 +30,9 @@ pub async fn execute( None => std::env::current_dir()?, }; - let bin_dir = resolve_bin_dir(&working_dir); + // ExecCommand uses requireComposer in Composer; composer.json must exist. + let composer = Composer::require(&working_dir)?; + let bin_dir = resolve_bin_dir(&working_dir, &composer); if args.list || args.binary.is_none() { let binaries = get_binaries(&working_dir, &bin_dir); @@ -119,20 +122,14 @@ pub async fn execute( // ─── Helpers ────────────────────────────────────────────────────────────────── -fn resolve_bin_dir(working_dir: &Path) -> PathBuf { - let composer_json_path = working_dir.join("composer.json"); - if let Ok(content) = std::fs::read_to_string(&composer_json_path) - && let Ok(parsed) = serde_json::from_str::(&content) - { - let vendor_dir = parsed["config"]["vendor-dir"].as_str().unwrap_or("vendor"); - let bin_dir = parsed["config"]["bin-dir"].as_str().unwrap_or_default(); - if !bin_dir.is_empty() { - let resolved = bin_dir.replace("{$vendor-dir}", vendor_dir); - return working_dir.join(resolved); - } - return working_dir.join(vendor_dir).join("bin"); - } - working_dir.join("vendor/bin") +fn resolve_bin_dir(working_dir: &Path, composer: &Composer) -> PathBuf { + // bin-dir's `{$vendor-dir}` placeholder is already resolved by Composer::load. + let bin_dir = composer + .config() + .get("bin-dir") + .and_then(|v| v.as_str()) + .unwrap_or("vendor/bin"); + working_dir.join(bin_dir) } /// Returns a vec of (name, is_local) tuples for all available binaries. @@ -208,7 +205,8 @@ mod tests { let composer_json = dir.path().join("composer.json"); fs::write(&composer_json, r#"{"name": "test/pkg", "require": {}}"#).unwrap(); - let result = resolve_bin_dir(dir.path()); + let composer = Composer::require(dir.path()).unwrap(); + let result = resolve_bin_dir(dir.path(), &composer); assert_eq!(result, dir.path().join("vendor/bin")); } @@ -222,7 +220,8 @@ mod tests { ) .unwrap(); - let result = resolve_bin_dir(dir.path()); + let composer = Composer::require(dir.path()).unwrap(); + let result = resolve_bin_dir(dir.path(), &composer); assert_eq!(result, dir.path().join("libs/bin")); } @@ -236,7 +235,8 @@ mod tests { ) .unwrap(); - let result = resolve_bin_dir(dir.path()); + let composer = Composer::require(dir.path()).unwrap(); + let result = resolve_bin_dir(dir.path(), &composer); assert_eq!(result, dir.path().join("scripts")); } @@ -250,7 +250,8 @@ mod tests { ) .unwrap(); - let result = resolve_bin_dir(dir.path()); + let composer = Composer::require(dir.path()).unwrap(); + let result = resolve_bin_dir(dir.path(), &composer); assert_eq!(result, dir.path().join("packages/commands")); } @@ -366,7 +367,8 @@ mod tests { ) .unwrap(); - let bin_dir = resolve_bin_dir(dir.path()); + let composer = Composer::require(dir.path()).unwrap(); + let bin_dir = resolve_bin_dir(dir.path(), &composer); // No binaries exist — looking up a name should find nothing let candidate = bin_dir.join("nonexistent-binary"); diff --git a/crates/mozart/src/commands/run_script.rs b/crates/mozart/src/commands/run_script.rs index 2d88a81..e57d1c2 100644 --- a/crates/mozart/src/commands/run_script.rs +++ b/crates/mozart/src/commands/run_script.rs @@ -84,6 +84,9 @@ pub async fn execute( None => std::env::current_dir()?, }; + // RunScriptCommand uses requireComposer in Composer; composer.json must exist. + let composer = mozart_core::composer::Composer::require(&working_dir)?; + let (scripts, descriptions) = load_scripts(&working_dir)?; if args.list { @@ -114,21 +117,12 @@ pub async fn execute( let timeout = match args.timeout { Some(0) => None, Some(secs) => Some(Duration::from_secs(secs)), - None => { - let composer_json_path = working_dir.join("composer.json"); - if let Ok(content) = std::fs::read_to_string(&composer_json_path) - && let Ok(parsed) = serde_json::from_str::(&content) - && let Some(secs) = parsed["config"]["process-timeout"].as_u64() - { - if secs == 0 { - None - } else { - Some(Duration::from_secs(secs)) - } - } else { - Some(Duration::from_secs(300)) - } - } + None => composer + .config() + .get("process-timeout") + .and_then(|v| v.as_u64()) + .filter(|s| *s != 0) + .map(Duration::from_secs), }; let dev_mode = !args.no_dev; @@ -138,7 +132,7 @@ pub async fn execute( std::env::set_var("COMPOSER_DEV_MODE", if dev_mode { "1" } else { "0" }); } - let bin_dir = resolve_bin_dir(&working_dir); + let bin_dir = resolve_bin_dir(&working_dir, &composer); let mut event_stack: Vec = Vec::new(); let exit_code = run_script( @@ -464,20 +458,14 @@ fn wait_with_timeout( // ─── Bin dir resolution ─────────────────────────────────────────────────────── -fn resolve_bin_dir(working_dir: &Path) -> PathBuf { - let composer_json_path = working_dir.join("composer.json"); - if let Ok(content) = std::fs::read_to_string(&composer_json_path) - && let Ok(parsed) = serde_json::from_str::(&content) - { - let vendor_dir = parsed["config"]["vendor-dir"].as_str().unwrap_or("vendor"); - let bin_dir = parsed["config"]["bin-dir"].as_str().unwrap_or_default(); - if !bin_dir.is_empty() { - let resolved = bin_dir.replace("{$vendor-dir}", vendor_dir); - return working_dir.join(resolved); - } - return working_dir.join(vendor_dir).join("bin"); - } - working_dir.join("vendor/bin") +fn resolve_bin_dir(working_dir: &Path, composer: &mozart_core::composer::Composer) -> PathBuf { + // bin-dir's `{$vendor-dir}` placeholder is already resolved by Composer::load. + let bin_dir = composer + .config() + .get("bin-dir") + .and_then(|v| v.as_str()) + .unwrap_or("vendor/bin"); + working_dir.join(bin_dir) } // ─── Classifier functions ───────────────────────────────────────────────────── -- cgit v1.3.1