agentcarousel 0.2.3

Evaluate agents and skills with YAML fixtures, run cases (mock or live), and keep run rows in SQLite for reports and evidence export.
Documentation
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MockStub {
    pub tool: String,
    #[serde(default)]
    pub args_match: Option<Value>,
    pub response: Value,
}

#[derive(Debug, Error)]
pub enum MockError {
    #[error("failed to read mock file: {0}")]
    ReadError(PathBuf),
    #[error("failed to parse mock file: {0}")]
    ParseError(String),
}

#[derive(Debug, Default, Clone)]
pub struct MockEngine {
    stubs: Vec<MockStub>,
}

impl MockEngine {
    pub fn load_dir(path: &Path) -> Result<Self, MockError> {
        let mut stubs = Vec::new();
        if !path.exists() {
            return Ok(Self { stubs });
        }

        for entry in fs::read_dir(path).map_err(|_| MockError::ReadError(path.to_path_buf()))? {
            let entry = entry.map_err(|_| MockError::ReadError(path.to_path_buf()))?;
            let path = entry.path();
            if path.extension().and_then(|ext| ext.to_str()) != Some("json") {
                continue;
            }
            let contents =
                fs::read_to_string(&path).map_err(|_| MockError::ReadError(path.clone()))?;
            let mut file_stubs: Vec<MockStub> = serde_json::from_str(&contents)
                .map_err(|err| MockError::ParseError(err.to_string()))?;
            stubs.append(&mut file_stubs);
        }

        Ok(Self { stubs })
    }

    pub fn match_response(&self, tool: &str, args: &Value) -> Option<Value> {
        self.stubs
            .iter()
            .find(|stub| stub.tool == tool && matches_args(stub.args_match.as_ref(), args))
            .map(|stub| stub.response.clone())
    }

    pub fn describe_miss(&self, tool: &str, args: &Value) -> String {
        let candidates: Vec<&MockStub> =
            self.stubs.iter().filter(|stub| stub.tool == tool).collect();
        if candidates.is_empty() {
            return format!("no mocks registered for tool '{}'", tool);
        }

        let mut details = Vec::new();
        for stub in candidates {
            let args_match = stub
                .args_match
                .as_ref()
                .map(|value| value.to_string())
                .unwrap_or_else(|| "<any>".to_string());
            details.push(args_match);
        }

        format!(
            "no mock matched for tool '{}' with args {}. candidates: {}",
            tool,
            args,
            details.join(", ")
        )
    }
}

fn matches_args(expected: Option<&Value>, actual: &Value) -> bool {
    match expected {
        None => true,
        Some(expected) => is_subset(expected, actual),
    }
}

fn is_subset(expected: &Value, actual: &Value) -> bool {
    match (expected, actual) {
        (Value::Object(expected_map), Value::Object(actual_map)) => {
            expected_map.iter().all(|(key, value)| {
                actual_map
                    .get(key)
                    .map(|actual_value| is_subset(value, actual_value))
                    .unwrap_or(false)
            })
        }
        (Value::Array(expected_arr), Value::Array(actual_arr)) => expected_arr == actual_arr,
        _ => expected == actual,
    }
}