diff options
| author | nsfisis <nsfisis@gmail.com> | 2026-02-21 17:53:43 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2026-02-21 17:53:43 +0900 |
| commit | 2db52ebd5cd4a6b7511ce71f2a3f03abed971f10 (patch) | |
| tree | c8f786a3ff062e39ba59f3dad09689bfdab0d62c /crates | |
| parent | 597a0711ae09fb47ee1889ccaaa6a38055494478 (diff) | |
| download | php-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.rs | 352 | ||||
| -rw-r--r-- | crates/mozart/src/package.rs | 4 |
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(¤t_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(), } } |
