aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-05-05 15:12:57 +0900
committernsfisis <nsfisis@gmail.com>2026-05-05 15:12:57 +0900
commit2ad57b7efb685040b24d93aab5b81ddfbd0ebefb (patch)
tree9ee877ed2abc055cc91f2b7120a6122917fa6998 /crates/mozart
parentd4df60e70a4581aba6308f803ec7f9473d2671d8 (diff)
downloadphp-mozart-2ad57b7efb685040b24d93aab5b81ddfbd0ebefb.tar.gz
php-mozart-2ad57b7efb685040b24d93aab5b81ddfbd0ebefb.tar.zst
php-mozart-2ad57b7efb685040b24d93aab5b81ddfbd0ebefb.zip
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) <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart')
-rw-r--r--crates/mozart/src/commands/archive.rs36
-rw-r--r--crates/mozart/src/commands/config.rs131
-rw-r--r--crates/mozart/src/commands/config_helpers.rs59
-rw-r--r--crates/mozart/src/commands/dump_autoload.rs17
-rw-r--r--crates/mozart/src/commands/exec.rs42
-rw-r--r--crates/mozart/src/commands/run_script.rs48
6 files changed, 70 insertions, 263 deletions
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<String, serde_json::Value>,
-}
-
-impl ComposerConfig {
- /// Build a `ComposerConfig` starting from the built-in defaults.
- pub fn defaults() -> Self {
- let mut m: BTreeMap<String, serde_json::Value> = 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<String, serde_json::Value>) {
- 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<String> = 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<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())
- }
-}
-
-/// 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<PathBuf> {
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::<serde_json::Value>(&content)
- && 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();
- 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::<serde_json::Value>(&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::<serde_json::Value>(&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<String> = 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::<serde_json::Value>(&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 ─────────────────────────────────────────────────────