use super::cache::JustRegistry;
use super::parser::ParamKind;
use super::security::SecurityValidator;
use super::types::ExecuteOutput;
use crate::paths;
use serde_json::Value;
use std::collections::HashMap;
use tokio::process::Command;
#[expect(
clippy::implicit_hasher,
reason = "HashMap with default hasher is simpler for MCP tool API"
)]
#[expect(
clippy::similar_names,
reason = "args and argv are distinct: args is input, argv is CLI"
)]
pub async fn execute_recipe(
registry: &JustRegistry,
recipe_name: &str,
dir_opt: Option<String>,
args_opt: Option<HashMap<String, Value>>,
repo_root: &str,
) -> Result<ExecuteOutput, String> {
let repo_root = paths::to_abs_string(repo_root)?;
let all = registry.get_all_recipes(&repo_root).await?;
let mut candidates: Vec<_> = all
.into_iter()
.filter(|(_, r)| r.name == recipe_name && !r.is_private && !r.is_mcp_hidden)
.collect();
if let Some(ref dir) = dir_opt {
let abs_dir = paths::to_abs_string(dir)?;
candidates.retain(|(d, _)| d == &abs_dir);
}
if candidates.is_empty() {
return Err(format!(
"Recipe '{recipe_name}' not found or not exposed. Use just_search(query='{recipe_name}') to discover available recipes."
));
}
let unique_dirs: std::collections::HashSet<_> =
candidates.iter().map(|(d, _)| d.as_str()).collect();
let chosen_idx = if unique_dirs.len() > 1 && dir_opt.is_none() {
if let Some(idx) = candidates.iter().position(|(d, _)| d == &repo_root) {
idx
} else {
let dirs_list = unique_dirs
.into_iter()
.map(|d| format!(" - {d}"))
.collect::<Vec<_>>()
.join("\n");
return Err(format!(
"Recipe '{recipe_name}' not in root justfile and exists in multiple directories:\n{dirs_list}\nSpecify dir parameter to disambiguate."
));
}
} else {
0
};
let (chosen_dir, recipe) = candidates.swap_remove(chosen_idx);
let args = args_opt.unwrap_or_default();
SecurityValidator::default().validate(&args)?;
let expected_params: std::collections::HashSet<&str> =
recipe.params.iter().map(|p| p.name.as_str()).collect();
let unused_keys: Vec<&str> = args
.keys()
.filter(|k| !expected_params.contains(k.as_str()))
.map(std::string::String::as_str)
.collect();
let mut argv = vec![recipe_name.to_string()];
for p in &recipe.params {
if let Some(val) = args.get(&p.name) {
match p.kind {
ParamKind::Star => {
if let Value::Array(items) = val {
for item in items {
argv.push(value_to_arg(item)?);
}
} else {
argv.push(value_to_arg(val)?);
}
}
ParamKind::Singular => {
argv.push(value_to_arg(val)?);
}
}
} else if !p.has_default {
use std::fmt::Write;
let mut err_msg = format!(
"Missing required argument '{}' for recipe '{}'.",
p.name, recipe_name
);
if !unused_keys.is_empty() {
let _ = write!(
err_msg,
" You provided key(s) {unused_keys:?} which didn't match any parameter."
);
}
let param_names: Vec<&str> = recipe.params.iter().map(|p| p.name.as_str()).collect();
let _ = write!(err_msg, " Expected parameter(s): {param_names:?}");
return Err(err_msg);
}
}
let output = Command::new("just")
.args(&argv)
.current_dir(&chosen_dir)
.output()
.await
.map_err(|e| format!("Failed to execute just: {e}"))?;
Ok(ExecuteOutput {
dir: chosen_dir,
recipe: recipe_name.to_string(),
success: output.status.success(),
exit_code: output.status.code(),
stdout: String::from_utf8_lossy(&output.stdout).to_string(),
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
})
}
fn value_to_arg(v: &Value) -> Result<String, String> {
match v {
Value::String(s) => Ok(s.clone()),
Value::Number(n) => Ok(n.to_string()),
Value::Bool(b) => Ok(b.to_string()),
Value::Array(_) => Err("Arrays are only supported for variadic (*) parameters".to_string()),
Value::Null => Err("Null values are not supported; use empty string instead".to_string()),
Value::Object(_) => Err("Object arguments are not supported".to_string()),
}
}
#[cfg(test)]
#[expect(clippy::unwrap_used)]
mod tests {
use super::*;
use serde_json::json;
use std::fs;
use tempfile::TempDir;
macro_rules! skip_if_just_unavailable {
() => {
if tokio::process::Command::new("just")
.arg("--version")
.output()
.await
.is_err()
{
eprintln!("Skipping test: just not installed");
return;
}
};
}
#[test]
fn value_to_arg_string() {
assert_eq!(value_to_arg(&json!("hello")).unwrap(), "hello");
}
#[test]
fn value_to_arg_number() {
assert_eq!(value_to_arg(&json!(42)).unwrap(), "42");
assert_eq!(value_to_arg(&json!(3.5)).unwrap(), "3.5");
}
#[test]
fn value_to_arg_bool() {
assert_eq!(value_to_arg(&json!(true)).unwrap(), "true");
assert_eq!(value_to_arg(&json!(false)).unwrap(), "false");
}
#[test]
fn value_to_arg_rejects_complex_with_clear_messages() {
let obj_err = value_to_arg(&json!({"key": "value"})).unwrap_err();
assert!(obj_err.contains("Object"));
let arr_err = value_to_arg(&json!(["a", "b"])).unwrap_err();
assert!(arr_err.contains("variadic"));
let null_err = value_to_arg(&json!(null)).unwrap_err();
assert!(null_err.contains("empty string"));
}
#[tokio::test]
async fn recipe_not_found_error() {
skip_if_just_unavailable!();
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(root.join("justfile"), "build:\n echo building").unwrap();
let registry = JustRegistry::new();
let result =
execute_recipe(®istry, "nonexistent", None, None, root.to_str().unwrap()).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("not found"));
assert!(err.contains("nonexistent"));
}
#[tokio::test]
async fn defaults_to_root_when_recipe_in_multiple_dirs() {
skip_if_just_unavailable!();
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(root.join("justfile"), "check:\n echo root").unwrap();
fs::create_dir_all(root.join("sub")).unwrap();
fs::write(root.join("sub/justfile"), "check:\n echo sub").unwrap();
let registry = JustRegistry::new();
let result = execute_recipe(®istry, "check", None, None, root.to_str().unwrap()).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.success);
assert!(output.stdout.contains("root"));
}
#[tokio::test]
async fn ambiguous_recipe_not_in_root_errors() {
skip_if_just_unavailable!();
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(root.join("justfile"), "other:\n echo other").unwrap();
fs::create_dir_all(root.join("sub1")).unwrap();
fs::write(root.join("sub1/justfile"), "check:\n echo sub1").unwrap();
fs::create_dir_all(root.join("sub2")).unwrap();
fs::write(root.join("sub2/justfile"), "check:\n echo sub2").unwrap();
let registry = JustRegistry::new();
let result = execute_recipe(®istry, "check", None, None, root.to_str().unwrap()).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("not in root"));
assert!(err.contains("multiple directories"));
}
#[tokio::test]
async fn missing_required_arg_error() {
skip_if_just_unavailable!();
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(
root.join("justfile"),
"greet name:\n echo hello {{name}}",
)
.unwrap();
let registry = JustRegistry::new();
let result = execute_recipe(®istry, "greet", None, None, root.to_str().unwrap()).await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("Missing required argument"));
assert!(err.contains("name"));
}
#[tokio::test]
async fn successful_execution() {
skip_if_just_unavailable!();
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(root.join("justfile"), "hello:\n echo hello world").unwrap();
let registry = JustRegistry::new();
let result = execute_recipe(®istry, "hello", None, None, root.to_str().unwrap()).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.success);
assert_eq!(output.exit_code, Some(0));
assert!(output.stdout.contains("hello world"));
}
#[tokio::test]
async fn non_zero_exit_code() {
skip_if_just_unavailable!();
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(root.join("justfile"), "fail:\n exit 42").unwrap();
let registry = JustRegistry::new();
let result = execute_recipe(®istry, "fail", None, None, root.to_str().unwrap()).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(!output.success);
assert!(output.exit_code != Some(0));
}
#[tokio::test]
async fn disambiguate_with_dir() {
skip_if_just_unavailable!();
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(root.join("justfile"), "check:\n echo root").unwrap();
fs::create_dir_all(root.join("sub")).unwrap();
fs::write(root.join("sub/justfile"), "check:\n echo sub").unwrap();
let registry = JustRegistry::new();
let sub_dir = root.join("sub").to_string_lossy().to_string();
let result = execute_recipe(
®istry,
"check",
Some(sub_dir),
None,
root.to_str().unwrap(),
)
.await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.success);
assert!(output.stdout.contains("sub"));
}
#[tokio::test]
async fn missing_arg_error_shows_unused_keys() {
skip_if_just_unavailable!();
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(
root.join("justfile"),
"build target:\n echo building {{target}}",
)
.unwrap();
let registry = JustRegistry::new();
let mut wrong_args = HashMap::new();
wrong_args.insert("tgt".to_string(), json!("x86_64"));
let result = execute_recipe(
®istry,
"build",
None,
Some(wrong_args),
root.to_str().unwrap(),
)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("Missing required argument"));
assert!(err.contains("target"));
assert!(err.contains("tgt"));
assert!(err.contains("Expected parameter"));
}
}