Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions src/detectors/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ use std::sync::Arc;
pub enum CommandSupport {
/// The command is explicitly supported (e.g., found in package.json scripts)
Supported,
/// The command is a built-in command of the tool (e.g., "install", "test" when not in scripts)
BuiltIn,
/// The command is definitely not supported (e.g., not found in package.json scripts)
NotSupported,
/// It's unknown if the command is supported (e.g., no manifest parsing implemented)
Expand Down Expand Up @@ -166,7 +168,7 @@ impl DetectedRunner {
}

/// Build the command to execute
pub fn build_command(&self, task: &str, extra_args: &[String]) -> Vec<String> {
pub fn build_command(&self, task: &str, extra_args: &[String], working_dir: &Path) -> Vec<String> {
// First check if this is a custom command
if let Some(commands) = &self.custom_commands {
if let Some(cmd_str) = commands.get(task) {
Expand All @@ -185,10 +187,14 @@ impl DetectedRunner {

let mut cmd = match self.name.as_str() {
// Node.js ecosystem
"bun" => vec!["bun".to_string(), "run".to_string(), task.to_string()],
"pnpm" => vec!["pnpm".to_string(), "run".to_string(), task.to_string()],
"yarn" => vec!["yarn".to_string(), "run".to_string(), task.to_string()],
"npm" => vec!["npm".to_string(), "run".to_string(), task.to_string()],
"bun" | "pnpm" | "yarn" | "npm" => {
let support = self.validator.supports_command(working_dir, task);
if support == CommandSupport::BuiltIn {
vec![self.name.clone(), task.to_string()]
} else {
vec![self.name.clone(), "run".to_string(), task.to_string()]
}
}

// Python ecosystem
"uv" => vec!["uv".to_string(), "run".to_string(), task.to_string()],
Expand Down Expand Up @@ -358,35 +364,35 @@ mod tests {
#[test]
fn test_build_command_npm() {
let runner = DetectedRunner::new("npm", "package.json", Ecosystem::NodeJs, 4);
let cmd = runner.build_command("test", &[]);
let cmd = runner.build_command("test", &[], Path::new("."));
assert_eq!(cmd, vec!["npm", "run", "test"]);
}

#[test]
fn test_build_command_with_args() {
let runner = DetectedRunner::new("npm", "package.json", Ecosystem::NodeJs, 4);
let cmd = runner.build_command("test", &["--coverage".to_string()]);
let cmd = runner.build_command("test", &["--coverage".to_string()], Path::new("."));
assert_eq!(cmd, vec!["npm", "run", "test", "--coverage"]);
}

#[test]
fn test_build_command_cargo() {
let runner = DetectedRunner::new("cargo", "Cargo.toml", Ecosystem::Rust, 9);
let cmd = runner.build_command("build", &["--release".to_string()]);
let cmd = runner.build_command("build", &["--release".to_string()], Path::new("."));
assert_eq!(cmd, vec!["cargo", "build", "--release"]);
}

#[test]
fn test_build_command_go_path() {
let runner = DetectedRunner::new("go", "go.mod", Ecosystem::Go, 12);
let cmd = runner.build_command("./cmd/main.go", &[]);
let cmd = runner.build_command("./cmd/main.go", &[], Path::new("."));
assert_eq!(cmd, vec!["go", "run", "./cmd/main.go"]);
}

#[test]
fn test_build_command_go_task() {
let runner = DetectedRunner::new("go", "go.mod", Ecosystem::Go, 12);
let cmd = runner.build_command("build", &[]);
let cmd = runner.build_command("build", &[], Path::new("."));
assert_eq!(cmd, vec!["go", "build"]);
}

Expand Down Expand Up @@ -585,7 +591,7 @@ precommit: build test
commands,
);

let cmd = runner.build_command("hello", &[]);
let cmd = runner.build_command("hello", &[], Path::new("."));
assert_eq!(cmd, vec!["echo", "hello world"]);
}
}
16 changes: 16 additions & 0 deletions src/detectors/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ use std::sync::Arc;

pub struct NodeValidator;

// List of built-in commands common to npm, yarn, pnpm, bun
// These should be executed directly, not via "run", unless a script with the same name exists
const BUILTIN_COMMANDS: &[&str] = &[
"install", "test", "start", "restart", "stop", "publish", "version", "audit", "outdated",
"ci", "clean-install", "init", "link", "list", "login", "logout", "pack", "prune", "search",
"uninstall", "update", "view", "whoami", "add", "remove", "why", "dlx", "create", "exec",
];

impl CommandValidator for NodeValidator {
fn supports_command(&self, working_dir: &Path, command: &str) -> CommandSupport {
let package_json = working_dir.join("package.json");
Expand All @@ -37,6 +45,14 @@ impl CommandValidator for NodeValidator {
if scripts.contains_key(command) {
return CommandSupport::Supported;
}
}

if BUILTIN_COMMANDS.contains(&command) {
return CommandSupport::BuiltIn;
}

// If we have a scripts object but the command is not in it and not a builtin
if json.get("scripts").is_some() {
return CommandSupport::NotSupported;
}

Expand Down
11 changes: 10 additions & 1 deletion src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,15 @@ pub fn select_runner(
}
supported_runners.push(runner);
}
CommandSupport::BuiltIn => {
if verbose {
output::info(&format!(
"{} has built-in command '{}'",
runner.name, command
));
}
supported_runners.push(runner);
}
CommandSupport::NotSupported => {
if verbose {
output::info(&format!(
Expand Down Expand Up @@ -243,7 +252,7 @@ pub fn execute(
}

// Build the command
let cmd_parts = runner.build_command(task, extra_args);
let cmd_parts = runner.build_command(task, extra_args, working_dir);
let cmd_string = cmd_parts.join(" ");

if verbose {
Expand Down
77 changes: 77 additions & 0 deletions tests/node_builtin_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
use std::fs::File;
use std::io::Write;
use tempfile::tempdir;
use run_cli::detectors::{detect_all, CommandSupport};
use run_cli::runner::{select_runner};

#[test]
fn test_npm_install_builtin_works() {
let dir = tempdir().unwrap();
let mut file = File::create(dir.path().join("package.json")).unwrap();
// Add a script so supports_command would normally return NotSupported for "install"
writeln!(file, r#"{{"scripts": {{"test": "echo test"}}}}"#).unwrap();
File::create(dir.path().join("package-lock.json")).unwrap();

let runners = detect_all(dir.path(), &[]);
assert_eq!(runners.len(), 1);
let runner = &runners[0];
assert_eq!(runner.name, "npm");

// "install" is a built-in command, so supports_command should return BuiltIn
assert_eq!(runner.supports_command("install", dir.path()), CommandSupport::BuiltIn);

// select_runner should succeed
let result = select_runner(&runners, "install", dir.path(), false);
assert!(result.is_ok());
let selected = result.unwrap();

// Verify command construction
let cmd = selected.build_command("install", &[], dir.path());
assert_eq!(cmd, vec!["npm", "install"]);
}

#[test]
fn test_npm_test_script_priority() {
let dir = tempdir().unwrap();
let mut file = File::create(dir.path().join("package.json")).unwrap();
// Add a script named "test" - this should take priority over built-in "test"
writeln!(file, r#"{{"scripts": {{"test": "echo custom test"}}}}"#).unwrap();
File::create(dir.path().join("package-lock.json")).unwrap();

let runners = detect_all(dir.path(), &[]);
let runner = &runners[0];

// "test" is in scripts, so supports_command should return Supported
assert_eq!(runner.supports_command("test", dir.path()), CommandSupport::Supported);

let result = select_runner(&runners, "test", dir.path(), false);
assert!(result.is_ok());
let selected = result.unwrap();

// Verify command construction uses "run"
let cmd = selected.build_command("test", &[], dir.path());
assert_eq!(cmd, vec!["npm", "run", "test"]);
}

#[test]
fn test_npm_test_builtin_fallback() {
let dir = tempdir().unwrap();
let mut file = File::create(dir.path().join("package.json")).unwrap();
// No scripts
writeln!(file, r#"{{"scripts": {{}}}}"#).unwrap();
File::create(dir.path().join("package-lock.json")).unwrap();

let runners = detect_all(dir.path(), &[]);
let runner = &runners[0];

// "test" is not in scripts but is built-in -> BuiltIn
assert_eq!(runner.supports_command("test", dir.path()), CommandSupport::BuiltIn);

let result = select_runner(&runners, "test", dir.path(), false);
assert!(result.is_ok());
let selected = result.unwrap();

// Verify command construction uses direct execution (npm test)
let cmd = selected.build_command("test", &[], dir.path());
assert_eq!(cmd, vec!["npm", "test"]);
}
Loading