use anyhow::{anyhow, Result};
use serde_json::Value;
use std::collections::BTreeMap;
use std::env;
use std::path::{Path, PathBuf};
use std::process::{Command as ProcessCommand, ExitStatus};
pub fn find_package_json(start_dir: &Path) -> Option<PathBuf> {
let mut current = if start_dir.is_file() {
start_dir.parent()?.to_path_buf()
} else {
start_dir.to_path_buf()
};
loop {
let candidate = current.join("package.json");
if candidate.is_file() {
return Some(candidate);
}
if !current.pop() {
break;
}
}
None
}
pub fn load_scripts(package_json_path: &Path) -> Result<BTreeMap<String, String>> {
let content = std::fs::read_to_string(package_json_path)
.map_err(|e| anyhow!("Failed to read {}: {}", package_json_path.display(), e))?;
let json: Value = serde_json::from_str(&content)
.map_err(|e| anyhow!("Failed to parse {}: {}", package_json_path.display(), e))?;
let mut scripts = BTreeMap::new();
if let Some(scripts_obj) = json.get("scripts").and_then(|s| s.as_object()) {
for (name, cmd_val) in scripts_obj {
if let Some(cmd_str) = cmd_val.as_str() {
scripts.insert(name.clone(), cmd_str.to_string());
}
}
}
Ok(scripts)
}
pub fn build_enriched_path(project_dir: &Path) -> String {
let current_path = env::var("PATH").unwrap_or_default();
let local_bin = project_dir.join("node_modules").join(".bin");
let mut paths = Vec::new();
if local_bin.exists() {
paths.push(local_bin.to_string_lossy().to_string());
}
if let Ok(current_exe) = env::current_exe() {
if let Some(exe_dir) = current_exe.parent() {
paths.push(exe_dir.to_string_lossy().to_string());
}
}
if !current_path.is_empty() {
paths.push(current_path);
}
#[cfg(windows)]
let separator = ";";
#[cfg(not(windows))]
let separator = ":";
paths.join(separator)
}
pub fn run_script(
start_dir: &Path,
script_name: &str,
extra_args: &[String],
) -> Result<ExitStatus> {
let package_json = find_package_json(start_dir).ok_or_else(|| {
anyhow!(
"No package.json found in {} or any parent directory",
start_dir.display()
)
})?;
let project_dir = package_json.parent().unwrap_or_else(|| Path::new("."));
let scripts = load_scripts(&package_json)?;
let raw_cmd = scripts.get(script_name).ok_or_else(|| {
let available = if scripts.is_empty() {
"No scripts defined in package.json".to_string()
} else {
let names: Vec<&String> = scripts.keys().collect();
format!(
"Available scripts: {}",
names
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
)
};
anyhow!(
"Script '{}' not found in {}.\n{}",
script_name,
package_json.display(),
available
)
})?;
let mut full_cmd = raw_cmd.clone();
if !extra_args.is_empty() {
full_cmd.push(' ');
full_cmd.push_str(&extra_args.join(" "));
}
let enriched_path = build_enriched_path(project_dir);
#[cfg(windows)]
let mut command = {
let mut cmd = ProcessCommand::new("cmd");
cmd.args(["/C", &full_cmd]);
cmd
};
#[cfg(not(windows))]
let mut command = {
let mut cmd = ProcessCommand::new("sh");
cmd.args(["-c", &full_cmd]);
cmd
};
command.current_dir(project_dir);
command.env("PATH", enriched_path);
command.stdin(std::process::Stdio::inherit());
command.stdout(std::process::Stdio::inherit());
command.stderr(std::process::Stdio::inherit());
let status = command
.status()
.map_err(|e| anyhow!("Failed to execute script '{}': {}", script_name, e))?;
Ok(status)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn test_find_package_json_and_load_scripts() {
let dir = tempdir().expect("tempdir");
let pkg = dir.path().join("package.json");
std::fs::write(
&pkg,
r#"{"name": "test-pkg", "scripts": {"hello": "echo hello", "build": "echo build"}}"#,
)
.expect("write");
let found = find_package_json(dir.path()).expect("should find package.json");
assert_eq!(found, pkg);
let sub_dir = dir.path().join("sub").join("deep");
std::fs::create_dir_all(&sub_dir).expect("create sub dirs");
let found_nested = find_package_json(&sub_dir).expect("should find in parent");
assert_eq!(found_nested, pkg);
let scripts = load_scripts(&pkg).expect("load scripts");
assert_eq!(scripts.get("hello"), Some(&"echo hello".to_string()));
assert_eq!(scripts.get("build"), Some(&"echo build".to_string()));
}
}