use async_trait::async_trait;
use clap::{CommandFactory, Parser};
use kaish_types::Value;
use crate::interpreter::{
is_collection, numeric_compare, scalar_test_operand_error, value_to_string, values_equal,
ExecResult,
};
use crate::tools::{schema_from_clap, ExecContext, GlobalFlags, Tool, ToolArgs, ToolCtx, ToolSchema};
pub struct Test;
#[derive(Parser, Debug)]
#[command(name = "test", about = "Evaluate a conditional expression (exit 0 true / 1 false / 2 error)")]
struct TestArgs {
#[command(flatten)]
global: GlobalFlags,
#[arg(trailing_var_arg = true, allow_hyphen_values = true, hide = true)]
rest: Vec<String>,
}
#[async_trait]
impl Tool for Test {
fn name(&self) -> &str {
"test"
}
fn schema(&self) -> ToolSchema {
schema_from_clap(
&TestArgs::command(),
"test",
"Evaluate a conditional expression: exit 0 if true, 1 if false, 2 on error",
[
("File exists and is regular", "test -f config.toml"),
("String equality", r#"test "$mode" = release"#),
("Numeric comparison", "test $count -gt 0"),
("Negation", "test ! -d build"),
("Compound via shell", "test -f a && test -f b"),
],
)
.with_raw_argv()
}
async fn execute(&self, args: ToolArgs, ctx: &mut dyn ToolCtx) -> ExecResult {
let Some(ctx) = ctx.as_any_mut().downcast_mut::<ExecContext>() else {
return ExecResult::failure(1, "internal error: kernel builtin requires ExecContext");
};
let parsed = match TestArgs::try_parse_from(
std::iter::once("test".to_string()).chain(args.to_argv()),
) {
Ok(p) => p,
Err(e) => return ExecResult::failure(2, format!("test: {e}")),
};
parsed.global.apply(ctx);
match eval_test(ctx, &args.positional).await {
Ok(true) => ExecResult::success(""),
Ok(false) => ExecResult::failure(1, ""),
Err(msg) => ExecResult::failure(2, msg),
}
}
}
fn is_unary_op(s: &str) -> bool {
matches!(s, "-z" | "-n" | "-e" | "-f" | "-d" | "-r" | "-w" | "-x")
}
fn is_binary_op(s: &str) -> bool {
matches!(s, "=" | "==" | "!=" | "-eq" | "-ne" | "-gt" | "-lt" | "-ge" | "-le")
}
fn is_rejected_op(s: &str) -> bool {
matches!(s, "-a" | "-o" | "(" | ")")
}
fn is_any_op(s: &str) -> bool {
is_unary_op(s) || is_binary_op(s) || is_rejected_op(s) || s == "!"
}
const COMPOUND_HINT: &str =
"kaish `test` has no -a/-o/() compound — chain with shell `&&`/`||` or use `[[ ... ]]`";
async fn eval_test(ctx: &ExecContext, operands: &[Value]) -> Result<bool, String> {
let mut negate = false;
let mut ops = operands;
while ops.len() >= 2 && value_to_string(&ops[0]) == "!" {
negate = !negate;
ops = &ops[1..];
}
let result = eval_primary(ctx, ops).await?;
Ok(negate ^ result)
}
async fn eval_primary(ctx: &ExecContext, operands: &[Value]) -> Result<bool, String> {
match operands.len() {
0 => Err("test: missing expression".to_string()),
1 => {
let operand = &operands[0];
let s = value_to_string(operand);
if is_any_op(&s) {
return Err(format!("test: '{s}' needs an operand"));
}
if is_collection(operand) {
return Err(format!(
"test: operand is a {}, not a string; a collection has no truth value",
collection_kind(operand)
));
}
Ok(!s.is_empty())
}
2 => {
let op = value_to_string(&operands[0]);
if is_rejected_op(&op) {
return Err(format!("test: '{op}' is not supported — {COMPOUND_HINT}"));
}
if is_unary_op(&op) {
return apply_unary(ctx, &op, &operands[1]).await;
}
Err(format!(
"test: expected a unary operator (-f, -z, …) before the operand, found '{op}'"
))
}
3 => {
let op = value_to_string(&operands[1]);
if is_rejected_op(&op) {
return Err(format!("test: '{op}' is not supported — {COMPOUND_HINT}"));
}
if is_binary_op(&op) {
return apply_binary(&operands[0], &op, &operands[2]);
}
Err(format!(
"test: expected a binary operator (=, !=, -eq, …) between the operands, found '{op}'"
))
}
_ => Err(format!("test: too many arguments — {COMPOUND_HINT}")),
}
}
async fn apply_unary(ctx: &ExecContext, op: &str, operand: &Value) -> Result<bool, String> {
if let Some(msg) = scalar_test_operand_error(op, operand) {
return Err(msg);
}
match op {
"-z" => Ok(value_to_string(operand).is_empty()),
"-n" => Ok(!value_to_string(operand).is_empty()),
"-e" | "-f" | "-d" | "-r" | "-w" | "-x" => {
Ok(file_test(ctx, op, &value_to_string(operand)).await)
}
_ => unreachable!("apply_unary called with non-unary op {op:?}"),
}
}
fn apply_binary(left: &Value, op: &str, right: &Value) -> Result<bool, String> {
match op {
"=" | "==" => values_equal(left, right).map_err(|e| format!("test: {e}")),
"!=" => values_equal(left, right)
.map(|eq| !eq)
.map_err(|e| format!("test: {e}")),
"-eq" | "-ne" | "-gt" | "-lt" | "-ge" | "-le" => {
if let Some(msg) = scalar_test_operand_error(op, left) {
return Err(msg);
}
if let Some(msg) = scalar_test_operand_error(op, right) {
return Err(msg);
}
let ord = numeric_compare(left, right).map_err(|e| format!("test: {e}"))?;
Ok(match op {
"-eq" => ord.is_eq(),
"-ne" => !ord.is_eq(),
"-gt" => ord.is_gt(),
"-lt" => ord.is_lt(),
"-ge" => ord.is_ge(),
"-le" => ord.is_le(),
_ => unreachable!(),
})
}
_ => unreachable!("apply_binary called with non-binary op {op:?}"),
}
}
async fn file_test(ctx: &ExecContext, op: &str, path: &str) -> bool {
let resolved = ctx.resolve_path(path);
let entry = ctx.backend.stat(&resolved).await.ok();
match op {
"-e" | "-r" => entry.is_some(),
"-f" => entry.as_ref().is_some_and(|e| e.is_file()),
"-d" => entry.as_ref().is_some_and(|e| e.is_dir()),
"-w" => entry
.as_ref()
.is_some_and(|e| e.permissions.is_none_or(|p| p & 0o222 != 0)),
"-x" => entry
.as_ref()
.is_some_and(|e| e.permissions.is_some_and(|p| p & 0o111 != 0)),
_ => unreachable!("file_test called with non-file op {op:?}"),
}
}
fn collection_kind(value: &Value) -> &'static str {
match value {
Value::Json(serde_json::Value::Array(_)) => "list",
Value::Json(serde_json::Value::Object(_)) => "record",
_ => "collection",
}
}