use super::cache::JustRegistry;
use super::parser::ParamKind;
use super::security::SecurityValidator;
use super::types::ExecuteOutput;
use crate::paths;
use agentic_tools_core::ToolContext;
use serde_json::Value;
use std::collections::HashMap;
use tokio::io::AsyncReadExt;
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,
ctx: &ToolContext,
) -> 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 mut child = Command::new("just")
.args(&argv)
.current_dir(&chosen_dir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| format!("Failed to execute just: {e}"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| "Failed to capture just stdout".to_string())?;
let stderr = child
.stderr
.take()
.ok_or_else(|| "Failed to capture just stderr".to_string())?;
let stdout_task = tokio::spawn(async move {
let mut stdout = stdout;
let mut bytes = Vec::new();
stdout.read_to_end(&mut bytes).await.map(|_| bytes)
});
let stderr_task = tokio::spawn(async move {
let mut stderr = stderr;
let mut bytes = Vec::new();
stderr.read_to_end(&mut bytes).await.map(|_| bytes)
});
let status = tokio::select! {
() = ctx.cancelled() => {
let _ = child.kill().await;
let _ = child.wait().await;
stdout_task.abort();
stderr_task.abort();
return Err("Just execution cancelled".to_string());
}
status = child.wait() => status.map_err(|e| format!("Failed to execute just: {e}"))?,
};
let stdout = stdout_task
.await
.map_err(|e| format!("Failed to join stdout reader: {e}"))?
.map_err(|e| format!("Failed to read just stdout: {e}"))?;
let stderr = stderr_task
.await
.map_err(|e| format!("Failed to join stderr reader: {e}"))?
.map_err(|e| format!("Failed to read just stderr: {e}"))?;
Ok(ExecuteOutput {
dir: chosen_dir,
recipe: recipe_name.to_string(),
success: status.success(),
exit_code: status.code(),
stdout: String::from_utf8_lossy(&stdout).to_string(),
stderr: String::from_utf8_lossy(&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 agentic_tools_core::ToolContext;
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(),
&ToolContext::default(),
)
.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(),
&ToolContext::default(),
)
.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(),
&ToolContext::default(),
)
.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(),
&ToolContext::default(),
)
.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(),
&ToolContext::default(),
)
.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(),
&ToolContext::default(),
)
.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(),
&ToolContext::default(),
)
.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(),
&ToolContext::default(),
)
.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"));
}
#[tokio::test]
async fn cancellation_stops_recipe_execution() {
skip_if_just_unavailable!();
let tmp = TempDir::new().unwrap();
let root = tmp.path();
fs::write(root.join("justfile"), "hang:\n sleep 30").unwrap();
let registry = JustRegistry::new();
let ctx = ToolContext::default();
let cancel = ctx.cancellation_token();
let handle = tokio::spawn({
let repo_root = root.to_str().unwrap().to_string();
async move { execute_recipe(®istry, "hang", None, None, &repo_root, &ctx).await }
});
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
cancel.cancel();
let result = handle.await.unwrap();
assert_eq!(result.unwrap_err(), "Just execution cancelled");
}
}