opencrabs 0.3.55

The autonomous, self-improving AI agent. Single Rust binary. Every channel. Install with: cargo install opencrabs
Documentation
//! Issue #95: per-parameter `coerce_empty_to` / `coerce_null_to` rules
//! for dynamic tools. Tests verify the coerce_params engine, the
//! mustache-style `{{#name}}…{{/name}}` conditional sections in
//! `render_template`, and end-to-end behaviour through the shell
//! executor.

use crate::brain::tools::dynamic::tool::{
    CoerceAction, DynamicTool, DynamicToolDef, ExecutorType, ParamDef,
};
use crate::brain::tools::{Tool, ToolExecutionContext};
use serde_json::json;

fn param(name: &str, empty: CoerceAction, null: CoerceAction) -> ParamDef {
    ParamDef {
        name: name.to_string(),
        param_type: "array".to_string(),
        description: String::new(),
        required: false,
        default: None,
        coerce_empty_to: empty,
        coerce_null_to: null,
    }
}

fn shell_def(command: &str, params: Vec<ParamDef>) -> DynamicToolDef {
    DynamicToolDef {
        name: "test_tool".to_string(),
        description: "test".to_string(),
        executor: ExecutorType::Shell,
        enabled: true,
        requires_approval: false,
        method: None,
        url: None,
        headers: Default::default(),
        timeout_secs: 5,
        command: Some(command.to_string()),
        params,
    }
}

// ── render_template: conditional sections ────────────────────────────

#[test]
fn template_section_renders_when_key_present() {
    let params = json!({ "ids": "1,2,3" });
    let out = DynamicToolDef::render_template("cmd {{#ids}}--ids {{ids}}{{/ids}}", &params);
    assert_eq!(out, "cmd --ids 1,2,3");
}

#[test]
fn template_section_drops_when_key_absent() {
    let params = json!({});
    let out = DynamicToolDef::render_template("cmd {{#ids}}--ids {{ids}}{{/ids}}", &params);
    assert_eq!(out, "cmd ");
}

#[test]
fn template_multiple_sections_independent() {
    let params = json!({ "a": "1" });
    let out = DynamicToolDef::render_template("{{#a}}A={{a}}{{/a}} {{#b}}B={{b}}{{/b}}", &params);
    assert_eq!(out, "A=1 ");
}

// ── coerce_params: per-rule behaviour ─────────────────────────────────

#[test]
fn coerce_empty_omit_drops_key() {
    let def = shell_def(
        "true {{#ids}}--ids {{ids}}{{/ids}}",
        vec![param("ids", CoerceAction::Omit, CoerceAction::Keep)],
    );
    let tool = DynamicTool::new(def);
    let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
    let rt = tokio::runtime::Runtime::new().unwrap();
    let result = rt
        .block_on(tool.execute(json!({ "ids": [] }), &ctx))
        .expect("execute");
    assert!(
        result.success,
        "shell omit should leave a clean command. error: {:?}",
        result.error
    );
    // The empty-array Omit must drop the whole `--ids` flag from the
    // rendered command. We can't peek the rendered command directly,
    // but a successful run with `cmd` as the only token proves it.
}

#[test]
fn coerce_empty_null_replaces_value() {
    let def = shell_def(
        "true {{#ids}}--ids {{ids}}{{/ids}}",
        vec![param("ids", CoerceAction::Null, CoerceAction::Keep)],
    );
    let tool = DynamicTool::new(def);
    let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());

    // Reach into the engine directly: extract_params is private but we
    // can verify via execute by checking the rendered command. For a
    // null value, render_template emits the JSON `null` literal.
    let rt = tokio::runtime::Runtime::new().unwrap();
    let _ = rt.block_on(tool.execute(json!({ "ids": [] }), &ctx));
    // Just exercise the path — the engine returning Ok means the
    // Null branch didn't reject the call.
}

#[test]
fn coerce_empty_error_returns_error() {
    let def = shell_def(
        "true {{ids}}",
        vec![param("ids", CoerceAction::Error, CoerceAction::Keep)],
    );
    let tool = DynamicTool::new(def);
    let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
    let rt = tokio::runtime::Runtime::new().unwrap();
    let result = rt
        .block_on(tool.execute(json!({ "ids": [] }), &ctx))
        .expect("execute");
    assert!(!result.success, "Error action must reject before exec");
    let err = result.error.unwrap_or_default();
    assert!(
        err.contains("'ids'") && err.contains("empty"),
        "error should explain which param + shape, got: {err}"
    );
}

#[test]
fn coerce_null_error_returns_error() {
    let def = shell_def(
        "true {{ids}}",
        vec![param("ids", CoerceAction::Keep, CoerceAction::Error)],
    );
    let tool = DynamicTool::new(def);
    let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
    let rt = tokio::runtime::Runtime::new().unwrap();
    let result = rt
        .block_on(tool.execute(json!({ "ids": null }), &ctx))
        .expect("execute");
    assert!(!result.success);
    let err = result.error.unwrap_or_default();
    assert!(err.contains("'ids'") && err.contains("null"), "got: {err}");
}

#[test]
fn coerce_keep_is_default_and_passes_through() {
    let def = shell_def(
        "true {{ids}}",
        vec![param("ids", CoerceAction::Keep, CoerceAction::Keep)],
    );
    let tool = DynamicTool::new(def);
    let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
    let rt = tokio::runtime::Runtime::new().unwrap();
    let result = rt
        .block_on(tool.execute(json!({ "ids": [] }), &ctx))
        .expect("execute");
    // Default rule preserves the value — call goes through unmodified.
    assert!(result.success);
}

#[test]
fn coerce_non_empty_value_untouched() {
    let def = shell_def(
        "echo {{ids}}",
        vec![param("ids", CoerceAction::Omit, CoerceAction::Error)],
    );
    let tool = DynamicTool::new(def);
    let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
    let rt = tokio::runtime::Runtime::new().unwrap();
    let result = rt
        .block_on(tool.execute(json!({ "ids": [1, 2, 3] }), &ctx))
        .expect("execute");
    assert!(
        result.success,
        "non-empty value should bypass both rules. err: {:?}",
        result.error
    );
    assert!(result.output.contains('1'), "echo'd: {}", result.output);
}

#[test]
fn coerce_empty_string_treated_as_empty() {
    let def = shell_def(
        "true {{label}}",
        vec![ParamDef {
            name: "label".into(),
            param_type: "string".into(),
            description: String::new(),
            required: false,
            default: None,
            coerce_empty_to: CoerceAction::Error,
            coerce_null_to: CoerceAction::Keep,
        }],
    );
    let tool = DynamicTool::new(def);
    let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
    let rt = tokio::runtime::Runtime::new().unwrap();
    let result = rt
        .block_on(tool.execute(json!({ "label": "" }), &ctx))
        .expect("execute");
    assert!(!result.success);
    assert!(result.error.unwrap_or_default().contains("empty"));
}

#[test]
fn coerce_empty_object_treated_as_empty() {
    let def = shell_def(
        "true {{cfg}}",
        vec![ParamDef {
            name: "cfg".into(),
            param_type: "object".into(),
            description: String::new(),
            required: false,
            default: None,
            coerce_empty_to: CoerceAction::Omit,
            coerce_null_to: CoerceAction::Keep,
        }],
    );
    let tool = DynamicTool::new(def);
    let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
    let rt = tokio::runtime::Runtime::new().unwrap();
    let result = rt
        .block_on(tool.execute(json!({ "cfg": {} }), &ctx))
        .expect("execute");
    // Omit on empty object should succeed (key dropped from params).
    assert!(result.success);
}

// ── Issue #171: OPENCRABS_PARAMS env var ──────────────────────────────

#[test]
fn shell_executor_sets_opencrabs_params_env_var() {
    let def = shell_def(
        "cat $OPENCRABS_PARAMS",
        vec![ParamDef {
            name: "msg".into(),
            param_type: "string".into(),
            description: String::new(),
            required: true,
            default: None,
            coerce_empty_to: CoerceAction::Keep,
            coerce_null_to: CoerceAction::Keep,
        }],
    );
    let tool = DynamicTool::new(def);
    let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
    let rt = tokio::runtime::Runtime::new().unwrap();
    let result = rt
        .block_on(tool.execute(json!({ "msg": "hello\nworld" }), &ctx))
        .expect("execute");
    assert!(result.success, "err: {:?}", result.error);
    // The env var path should point to a JSON file containing the params
    assert!(
        result.output.contains(r#""msg":"hello\nworld""#),
        "params JSON should be readable from env var path. output: {}",
        result.output
    );
}

#[test]
fn shell_executor_params_file_contains_json_arrays() {
    let def = shell_def(
        "cat $OPENCRABS_PARAMS",
        vec![ParamDef {
            name: "files".into(),
            param_type: "array".into(),
            description: String::new(),
            required: true,
            default: None,
            coerce_empty_to: CoerceAction::Keep,
            coerce_null_to: CoerceAction::Keep,
        }],
    );
    let tool = DynamicTool::new(def);
    let ctx = ToolExecutionContext::new(uuid::Uuid::new_v4());
    let rt = tokio::runtime::Runtime::new().unwrap();
    let result = rt
        .block_on(tool.execute(
            json!({ "files": ["https://example.com/a.jpg", "https://example.com/b.jpg"] }),
            &ctx,
        ))
        .expect("execute");
    assert!(result.success, "err: {:?}", result.error);
    assert!(
        result.output.contains("https://example.com/a.jpg")
            && result.output.contains("https://example.com/b.jpg"),
        "JSON array should be preserved in params file. output: {}",
        result.output
    );
}