aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-21 17:53:43 +0900
committernsfisis <nsfisis@gmail.com>2026-02-21 17:53:43 +0900
commit2db52ebd5cd4a6b7511ce71f2a3f03abed971f10 (patch)
treec8f786a3ff062e39ba59f3dad09689bfdab0d62c /crates
parent597a0711ae09fb47ee1889ccaaa6a38055494478 (diff)
downloadphp-mozart-2db52ebd5cd4a6b7511ce71f2a3f03abed971f10.tar.gz
php-mozart-2db52ebd5cd4a6b7511ce71f2a3f03abed971f10.tar.zst
php-mozart-2db52ebd5cd4a6b7511ce71f2a3f03abed971f10.zip
feat(exec): implement exec command to run vendor binaries
Add binary execution from vendor/bin/ with --list enumeration, root package bin entries marked as (local), configurable bin-dir resolution with {$vendor-dir} placeholder support, .bat filtering, and PATH prepending. Add bin field to RawPackageData. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates')
-rw-r--r--crates/mozart/src/commands/exec.rs352
-rw-r--r--crates/mozart/src/package.rs4
2 files changed, 353 insertions, 3 deletions
diff --git a/crates/mozart/src/commands/exec.rs b/crates/mozart/src/commands/exec.rs
index c95c1eb..9ef1624 100644
--- a/crates/mozart/src/commands/exec.rs
+++ b/crates/mozart/src/commands/exec.rs
@@ -1,4 +1,5 @@
use clap::Args;
+use std::path::{Path, PathBuf};
#[derive(Args)]
pub struct ExecArgs {
@@ -6,7 +7,7 @@ pub struct ExecArgs {
pub binary: Option<String>,
/// Arguments to pass to the binary
- #[arg(trailing_var_arg = true)]
+ #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
pub args: Vec<String>,
/// List the available binaries
@@ -14,6 +15,351 @@ pub struct ExecArgs {
pub list: bool,
}
-pub fn execute(_args: &ExecArgs, _cli: &super::Cli) -> anyhow::Result<()> {
- todo!()
+// ─── Main entry point ────────────────────────────────────────────────────────
+
+pub fn execute(args: &ExecArgs, cli: &super::Cli) -> anyhow::Result<()> {
+ let working_dir = match &cli.working_dir {
+ Some(dir) => PathBuf::from(dir),
+ None => std::env::current_dir()?,
+ };
+
+ let bin_dir = resolve_bin_dir(&working_dir);
+
+ if args.list || args.binary.is_none() {
+ let binaries = get_binaries(&working_dir, &bin_dir);
+ if binaries.is_empty() {
+ anyhow::bail!(
+ "No binaries found in composer.json or in bin-dir ({})",
+ bin_dir.display()
+ );
+ }
+ println!("Available binaries:");
+ for (name, is_local) in &binaries {
+ if *is_local {
+ println!("- {} (local)", name);
+ } else {
+ println!("- {}", name);
+ }
+ }
+ return Ok(());
+ }
+
+ let binary_name = args.binary.as_deref().unwrap();
+
+ // Resolve binary path: check bin_dir first, then root package bin entries
+ let bin_path = {
+ let candidate = bin_dir.join(binary_name);
+ if candidate.exists() {
+ Some(candidate)
+ } else {
+ // Check root composer.json bin entries
+ let composer_json_path = working_dir.join("composer.json");
+ if let Ok(root) = crate::package::read_from_file(&composer_json_path) {
+ root.bin.into_iter().find_map(|entry| {
+ let p = working_dir.join(&entry);
+ let stem = Path::new(&entry)
+ .file_name()
+ .and_then(|n| n.to_str())
+ .unwrap_or(&entry);
+ if stem == binary_name && p.exists() {
+ Some(p)
+ } else {
+ None
+ }
+ })
+ } else {
+ None
+ }
+ }
+ };
+
+ let bin_path = bin_path.ok_or_else(|| {
+ anyhow::anyhow!(
+ "Binary \"{}\" not found. Use --list to see available binaries.",
+ binary_name
+ )
+ })?;
+
+ // Build PATH with bin_dir prepended
+ let path_env = {
+ let current_path = std::env::var_os("PATH").unwrap_or_default();
+ let mut parts: Vec<PathBuf> = vec![bin_dir.clone()];
+ parts.extend(std::env::split_paths(&current_path));
+ std::env::join_paths(parts)?
+ };
+
+ let status = std::process::Command::new(&bin_path)
+ .args(&args.args)
+ .env("PATH", path_env)
+ .current_dir(&working_dir)
+ .status()?;
+
+ let code = status.code().unwrap_or(1);
+ if code != 0 {
+ std::process::exit(code);
+ }
+
+ Ok(())
+}
+
+// ─── 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")
+}
+
+/// Returns a vec of (name, is_local) tuples for all available binaries.
+/// Vendor binaries come first (is_local=false), then root package binaries
+/// not already present (is_local=true). Result is sorted alphabetically.
+fn get_binaries(working_dir: &Path, bin_dir: &Path) -> Vec<(String, bool)> {
+ let mut binaries: Vec<(String, bool)> = Vec::new();
+
+ // Collect from bin_dir (vendor binaries)
+ if let Ok(entries) = std::fs::read_dir(bin_dir) {
+ let mut vendor_names: Vec<String> = entries
+ .filter_map(|e| e.ok())
+ .filter_map(|e| {
+ let path = e.path();
+ if path.is_file() {
+ let name = path.file_name()?.to_str()?.to_string();
+ // Skip .bat files if a same-stem non-.bat file exists
+ if name.ends_with(".bat") {
+ let stem = &name[..name.len() - 4];
+ if bin_dir.join(stem).exists() {
+ return None;
+ }
+ }
+ Some(name)
+ } else {
+ None
+ }
+ })
+ .collect();
+ vendor_names.sort();
+ for name in vendor_names {
+ binaries.push((name, false));
+ }
+ }
+
+ // Collect from root composer.json bin entries
+ let composer_json_path = working_dir.join("composer.json");
+ if let Ok(root) = crate::package::read_from_file(&composer_json_path) {
+ let existing: std::collections::HashSet<&str> =
+ binaries.iter().map(|(n, _)| n.as_str()).collect();
+ let mut local: Vec<String> = root
+ .bin
+ .iter()
+ .filter_map(|entry| {
+ Path::new(entry)
+ .file_name()
+ .and_then(|n| n.to_str())
+ .map(|s| s.to_string())
+ })
+ .filter(|name| !existing.contains(name.as_str()))
+ .collect();
+ local.sort();
+ for name in local {
+ binaries.push((name, true));
+ }
+ }
+
+ binaries.sort_by(|a, b| a.0.cmp(&b.0));
+ binaries
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs;
+
+ // ── resolve_bin_dir ───────────────────────────────────────────────────────
+
+ #[test]
+ fn test_resolve_bin_dir_default() {
+ let dir = tempfile::tempdir().unwrap();
+ 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());
+ assert_eq!(result, dir.path().join("vendor/bin"));
+ }
+
+ #[test]
+ fn test_resolve_bin_dir_custom_vendor_dir() {
+ let dir = tempfile::tempdir().unwrap();
+ let composer_json = dir.path().join("composer.json");
+ fs::write(
+ &composer_json,
+ r#"{"name": "test/pkg", "require": {}, "config": {"vendor-dir": "libs"}}"#,
+ )
+ .unwrap();
+
+ let result = resolve_bin_dir(dir.path());
+ assert_eq!(result, dir.path().join("libs/bin"));
+ }
+
+ #[test]
+ fn test_resolve_bin_dir_custom_bin_dir() {
+ let dir = tempfile::tempdir().unwrap();
+ let composer_json = dir.path().join("composer.json");
+ fs::write(
+ &composer_json,
+ r#"{"name": "test/pkg", "require": {}, "config": {"bin-dir": "scripts"}}"#,
+ )
+ .unwrap();
+
+ let result = resolve_bin_dir(dir.path());
+ assert_eq!(result, dir.path().join("scripts"));
+ }
+
+ #[test]
+ fn test_resolve_bin_dir_with_placeholder() {
+ let dir = tempfile::tempdir().unwrap();
+ let composer_json = dir.path().join("composer.json");
+ fs::write(
+ &composer_json,
+ r#"{"name": "test/pkg", "require": {}, "config": {"vendor-dir": "packages", "bin-dir": "{$vendor-dir}/commands"}}"#,
+ )
+ .unwrap();
+
+ let result = resolve_bin_dir(dir.path());
+ assert_eq!(result, dir.path().join("packages/commands"));
+ }
+
+ // ── get_binaries ──────────────────────────────────────────────────────────
+
+ #[test]
+ fn test_get_binaries_from_bin_dir() {
+ let dir = tempfile::tempdir().unwrap();
+ let bin_dir = dir.path().join("vendor/bin");
+ fs::create_dir_all(&bin_dir).unwrap();
+
+ fs::write(bin_dir.join("phpunit"), "#!/bin/sh").unwrap();
+ fs::write(bin_dir.join("phpstan"), "#!/bin/sh").unwrap();
+
+ fs::write(
+ dir.path().join("composer.json"),
+ r#"{"name": "test/pkg", "require": {}}"#,
+ )
+ .unwrap();
+
+ let binaries = get_binaries(dir.path(), &bin_dir);
+ let names: Vec<&str> = binaries.iter().map(|(n, _)| n.as_str()).collect();
+ assert!(names.contains(&"phpunit"));
+ assert!(names.contains(&"phpstan"));
+ // All should be non-local
+ for (_, is_local) in &binaries {
+ assert!(!is_local);
+ }
+ }
+
+ #[test]
+ fn test_get_binaries_skips_bat_files() {
+ let dir = tempfile::tempdir().unwrap();
+ let bin_dir = dir.path().join("vendor/bin");
+ fs::create_dir_all(&bin_dir).unwrap();
+
+ fs::write(bin_dir.join("phpunit"), "#!/bin/sh").unwrap();
+ fs::write(bin_dir.join("phpunit.bat"), "@echo off").unwrap();
+
+ fs::write(
+ dir.path().join("composer.json"),
+ r#"{"name": "test/pkg", "require": {}}"#,
+ )
+ .unwrap();
+
+ let binaries = get_binaries(dir.path(), &bin_dir);
+ let names: Vec<&str> = binaries.iter().map(|(n, _)| n.as_str()).collect();
+ assert!(names.contains(&"phpunit"));
+ assert!(!names.contains(&"phpunit.bat"));
+ }
+
+ #[test]
+ fn test_get_binaries_from_root_composer_json() {
+ let dir = tempfile::tempdir().unwrap();
+ let bin_dir = dir.path().join("vendor/bin");
+ // Don't create bin_dir — no vendor binaries
+
+ fs::write(
+ dir.path().join("composer.json"),
+ r#"{"name": "test/pkg", "require": {}, "bin": ["bin/my-tool", "bin/helper"]}"#,
+ )
+ .unwrap();
+
+ let binaries = get_binaries(dir.path(), &bin_dir);
+ let names: Vec<&str> = binaries.iter().map(|(n, _)| n.as_str()).collect();
+ assert!(names.contains(&"my-tool"));
+ assert!(names.contains(&"helper"));
+ // All should be local
+ for (_, is_local) in &binaries {
+ assert!(is_local);
+ }
+ }
+
+ #[test]
+ fn test_get_binaries_empty_bin_dir() {
+ let dir = tempfile::tempdir().unwrap();
+ let bin_dir = dir.path().join("vendor/bin");
+ // bin_dir doesn't exist
+
+ fs::write(
+ dir.path().join("composer.json"),
+ r#"{"name": "test/pkg", "require": {}}"#,
+ )
+ .unwrap();
+
+ let binaries = get_binaries(dir.path(), &bin_dir);
+ assert!(binaries.is_empty());
+ }
+
+ #[test]
+ fn test_list_mode_no_binaries_errors() {
+ let dir = tempfile::tempdir().unwrap();
+ fs::write(
+ dir.path().join("composer.json"),
+ r#"{"name": "test/pkg", "require": {}}"#,
+ )
+ .unwrap();
+
+ let bin_dir = dir.path().join("vendor/bin");
+ let binaries = get_binaries(dir.path(), &bin_dir);
+ assert!(
+ binaries.is_empty(),
+ "Expected no binaries to trigger error path"
+ );
+ }
+
+ #[test]
+ fn test_execute_binary_not_found() {
+ let dir = tempfile::tempdir().unwrap();
+ fs::write(
+ dir.path().join("composer.json"),
+ r#"{"name": "test/pkg", "require": {}}"#,
+ )
+ .unwrap();
+
+ let bin_dir = resolve_bin_dir(dir.path());
+
+ // No binaries exist — looking up a name should find nothing
+ let candidate = bin_dir.join("nonexistent-binary");
+ assert!(!candidate.exists());
+
+ // Confirm root bin entries are also empty
+ let root = crate::package::read_from_file(&dir.path().join("composer.json")).unwrap();
+ assert!(root.bin.is_empty());
+ }
}
diff --git a/crates/mozart/src/package.rs b/crates/mozart/src/package.rs
index e439ac5..9904dc4 100644
--- a/crates/mozart/src/package.rs
+++ b/crates/mozart/src/package.rs
@@ -494,6 +494,9 @@ pub struct RawPackageData {
#[serde(skip_serializing_if = "Option::is_none")]
pub autoload: Option<RawAutoload>,
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ pub bin: Vec<String>,
+
#[serde(flatten)]
pub extra_fields: BTreeMap<String, serde_json::Value>,
}
@@ -533,6 +536,7 @@ impl RawPackageData {
require_dev: BTreeMap::new(),
repositories: Vec::new(),
autoload: None,
+ bin: Vec::new(),
extra_fields: BTreeMap::new(),
}
}