use async_trait::async_trait;
use std::path::Path;
use crate::ast::Value;
use crate::interpreter::{ExecResult, OutputData};
use crate::tools::{ExecContext, ParamSchema, Tool, ToolArgs, ToolSchema};
pub struct Which;
#[async_trait]
impl Tool for Which {
fn name(&self) -> &str {
"which"
}
fn schema(&self) -> ToolSchema {
ToolSchema::new("which", "Locate a command in PATH")
.param(ParamSchema::required(
"command",
"string",
"Command name(s) to find",
))
.param(ParamSchema::optional(
"a",
"bool",
Value::Bool(false),
"Print all matches, not just the first (-a)",
))
.example("Find a command", "which cargo")
.example("Show all matches", "which -a python")
}
async fn execute(&self, args: ToolArgs, ctx: &mut ExecContext) -> ExecResult {
if args.positional.is_empty() {
return ExecResult::failure(1, "which: missing command name");
}
let all_matches = args.has_flag("a");
let path_var = ctx
.scope
.get("PATH")
.map(value_to_string)
.unwrap_or_else(|| std::env::var("PATH").unwrap_or_default());
let path_dirs: Vec<&str> = path_var.split(':').collect();
let mut output = String::new();
let mut found_any = false;
let mut not_found = Vec::new();
for arg in &args.positional {
let name = match arg {
Value::String(s) => s.as_str(),
_ => continue,
};
let matches = find_in_path(name, &path_dirs);
if matches.is_empty() {
not_found.push(name);
} else {
found_any = true;
if all_matches {
for path in matches {
output.push_str(&path);
output.push('\n');
}
} else {
output.push_str(&matches[0]);
output.push('\n');
}
}
}
if output.ends_with('\n') {
output.pop();
}
if found_any {
if not_found.is_empty() {
ExecResult::with_output(OutputData::text(output))
} else {
let mut result = ExecResult::with_output(OutputData::text(output));
result.err = not_found
.iter()
.map(|n| format!("which: no {} in ({})", n, path_var))
.collect::<Vec<_>>()
.join("\n");
result
}
} else {
ExecResult::failure(
1,
not_found
.iter()
.map(|n| format!("which: no {} in ({})", n, path_var))
.collect::<Vec<_>>()
.join("\n"),
)
}
}
}
fn find_in_path(name: &str, path_dirs: &[&str]) -> Vec<String> {
let mut results = Vec::new();
for dir in path_dirs {
if dir.is_empty() {
continue;
}
let full_path = format!("{}/{}", dir, name);
let path = Path::new(&full_path);
if path.is_file() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(metadata) = path.metadata() {
let mode = metadata.permissions().mode();
if mode & 0o111 != 0 {
results.push(full_path);
}
}
}
#[cfg(not(unix))]
{
results.push(full_path);
}
}
}
results
}
fn value_to_string(value: &Value) -> String {
match value {
Value::Null => String::new(),
Value::Bool(b) => b.to_string(),
Value::Int(i) => i.to_string(),
Value::Float(f) => f.to_string(),
Value::String(s) => s.clone(),
Value::Json(json) => json.to_string(),
Value::Blob(blob) => format!("[blob: {} {}]", blob.formatted_size(), blob.content_type),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vfs::{MemoryFs, VfsRouter};
use std::sync::Arc;
fn make_ctx() -> ExecContext {
let mut vfs = VfsRouter::new();
vfs.mount("/", MemoryFs::new());
ExecContext::new(Arc::new(vfs))
}
#[tokio::test]
async fn test_which_finds_common_commands() {
let mut ctx = make_ctx();
let mut args = ToolArgs::new();
args.positional.push(Value::String("ls".into()));
let result = Which.execute(args, &mut ctx).await;
if result.ok() {
assert!(result.text_out().contains("/ls"));
}
}
#[tokio::test]
async fn test_which_not_found() {
let mut ctx = make_ctx();
ctx.scope.set("PATH", Value::String("/nonexistent".into()));
let mut args = ToolArgs::new();
args.positional
.push(Value::String("definitely_not_a_command_xyz".into()));
let result = Which.execute(args, &mut ctx).await;
assert!(!result.ok());
assert!(result.err.contains("no definitely_not_a_command_xyz"));
}
#[tokio::test]
async fn test_which_no_args() {
let mut ctx = make_ctx();
let args = ToolArgs::new();
let result = Which.execute(args, &mut ctx).await;
assert!(!result.ok());
assert!(result.err.contains("missing command name"));
}
#[tokio::test]
async fn test_which_multiple_commands() {
let mut ctx = make_ctx();
let mut args = ToolArgs::new();
args.positional.push(Value::String("ls".into()));
args.positional.push(Value::String("cat".into()));
let result = Which.execute(args, &mut ctx).await;
if result.ok() {
let text = result.text_out();
let lines: Vec<&str> = text.lines().collect();
assert!(lines.len() >= 1);
}
}
#[tokio::test]
async fn test_which_uses_path_from_scope() {
let mut ctx = make_ctx();
ctx.scope.set("PATH", Value::String("/usr/bin:/bin".into()));
let mut args = ToolArgs::new();
args.positional.push(Value::String("ls".into()));
let result = Which.execute(args, &mut ctx).await;
if result.ok() {
assert!(
result.text_out().starts_with("/usr/bin/") || result.text_out().starts_with("/bin/")
);
}
}
}