exeora-cli 0.8.0

Native Exeora CLI and local tool executor
Documentation
use exeora_cli::{error::ErrorCode, protocol::ToolName, tools::ToolEngine};
use serde_json::json;
use tempfile::TempDir;
use tokio_util::sync::CancellationToken;

async fn call(
    engine: &ToolEngine,
    root: &TempDir,
    tool: ToolName,
    args: serde_json::Value,
) -> serde_json::Value {
    engine
        .execute(root.path(), tool, args, CancellationToken::new())
        .await
        .unwrap()
}

#[tokio::test]
async fn file_tools_share_the_typescript_contract() {
    let root = TempDir::new().unwrap();
    let engine = ToolEngine::new().unwrap();

    let written = call(
        &engine,
        &root,
        ToolName::WriteFile,
        json!({"path": "src/../src/main.txt", "content": "alpha\nbeta\nalpha\n"}),
    )
    .await;
    assert_eq!(
        written,
        json!({"path":"src/main.txt","bytesWritten":17,"created":true})
    );

    let read = call(
        &engine,
        &root,
        ToolName::ReadFile,
        json!({"path": "src/main.txt", "offset": 2, "limit": 1}),
    )
    .await;
    assert_eq!(
        read,
        json!({"path":"src/main.txt","content":"beta","truncated":true,"totalLines":3})
    );

    let grep = call(
        &engine,
        &root,
        ToolName::Grep,
        json!({"pattern": "^alpha$", "glob": "**/*.txt"}),
    )
    .await;
    assert_eq!(grep["matches"].as_array().unwrap().len(), 2);
    assert_eq!(grep["matches"][0]["line"], 1);

    let edited = call(
        &engine,
        &root,
        ToolName::EditFile,
        json!({"path": "src/main.txt", "oldString": "beta", "newString": "gamma"}),
    )
    .await;
    assert_eq!(edited["replacements"], 1);
    assert!(edited["diff"].as_str().unwrap().contains("+gamma"));

    let listed = call(
        &engine,
        &root,
        ToolName::ListFiles,
        json!({"recursive": true}),
    )
    .await;
    assert!(
        listed["entries"]
            .as_array()
            .unwrap()
            .iter()
            .any(|entry| entry["path"] == "src/main.txt")
    );
}

#[tokio::test]
async fn rejects_escape_invalid_arguments_and_observes_cancellation() {
    let root = TempDir::new().unwrap();
    let engine = ToolEngine::new().unwrap();
    let escape = engine
        .execute(
            root.path(),
            ToolName::ReadFile,
            json!({"path":"../secret"}),
            CancellationToken::new(),
        )
        .await
        .unwrap_err();
    assert_eq!(escape.code, ErrorCode::PathEscape);

    let invalid = engine
        .execute(
            root.path(),
            ToolName::ReadFile,
            json!({}),
            CancellationToken::new(),
        )
        .await
        .unwrap_err();
    assert_eq!(invalid.code, ErrorCode::InvalidArguments);

    let cancelled = CancellationToken::new();
    cancelled.cancel();
    let result = engine
        .execute(root.path(), ToolName::ListFiles, json!({}), cancelled)
        .await
        .unwrap_err();
    assert_eq!(result.code, ErrorCode::Cancelled);
}

#[tokio::test]
async fn command_tools_capture_output_and_time_out() {
    let root = TempDir::new().unwrap();
    let engine = ToolEngine::new().unwrap();
    let output = call(
        &engine,
        &root,
        ToolName::RunCommand,
        json!({"command":"printf exeora"}),
    )
    .await;
    assert_eq!(output["stdout"], "exeora");
    assert_eq!(output["exitCode"], 0);
    assert_eq!(output["timedOut"], false);

    let timeout = call(
        &engine,
        &root,
        ToolName::RunCommand,
        json!({"command":"sleep 2", "timeoutMs": 1000}),
    )
    .await;
    assert_eq!(timeout["timedOut"], true);
    assert_eq!(timeout["exitCode"], serde_json::Value::Null);
}