use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::time::Duration;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestScenario {
pub name: String,
pub description: Option<String>,
#[serde(default = "default_timeout")]
pub timeout: u64,
#[serde(default = "default_stop_on_failure")]
pub stop_on_failure: bool,
#[serde(default)]
pub variables: HashMap<String, Value>,
#[serde(default)]
pub setup: Vec<TestStep>,
pub steps: Vec<TestStep>,
#[serde(default)]
pub cleanup: Vec<TestStep>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestStep {
pub name: String,
pub operation: Operation,
pub timeout: Option<u64>,
#[serde(default)]
pub continue_on_failure: bool,
pub store_result: Option<String>,
#[serde(default)]
pub assertions: Vec<Assertion>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Operation {
#[serde(rename = "tool_call")]
ToolCall {
tool: String,
#[serde(default)]
arguments: Value,
},
#[serde(rename = "list_tools")]
ListTools,
#[serde(rename = "list_resources")]
ListResources,
#[serde(rename = "read_resource")]
ReadResource { uri: String },
#[serde(rename = "list_prompts")]
ListPrompts,
#[serde(rename = "get_prompt")]
GetPrompt {
name: String,
#[serde(default)]
arguments: Value,
},
#[serde(rename = "custom")]
Custom {
method: String,
#[serde(default)]
params: Value,
},
#[serde(rename = "wait")]
Wait { seconds: f64 },
#[serde(rename = "set_variable")]
SetVariable { name: String, value: Value },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Assertion {
#[serde(rename = "equals")]
Equals {
path: String,
value: Value,
#[serde(default)]
ignore_case: bool,
},
#[serde(rename = "contains")]
Contains {
path: String,
value: String,
#[serde(default)]
ignore_case: bool,
},
#[serde(rename = "matches")]
Matches { path: String, pattern: String },
#[serde(rename = "exists")]
Exists { path: String },
#[serde(rename = "not_exists")]
NotExists { path: String },
#[serde(rename = "success")]
Success,
#[serde(rename = "failure")]
Failure,
#[serde(rename = "array_length")]
ArrayLength {
path: String,
#[serde(flatten)]
comparison: Comparison,
},
#[serde(rename = "numeric")]
Numeric {
path: String,
#[serde(flatten)]
comparison: Comparison,
},
#[serde(rename = "jsonpath")]
JsonPath {
expression: String,
expected: Option<Value>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Comparison {
Equals(f64),
NotEquals(f64),
GreaterThan(f64),
GreaterThanOrEqual(f64),
LessThan(f64),
LessThanOrEqual(f64),
Between { min: f64, max: f64 },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScenarioResult {
pub scenario_name: String,
pub success: bool,
pub duration: Duration,
pub steps_completed: usize,
pub steps_total: usize,
pub step_results: Vec<StepResult>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepResult {
pub step_name: String,
pub success: bool,
pub duration: Duration,
pub response: Option<Value>,
pub assertion_results: Vec<AssertionResult>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AssertionResult {
pub assertion: String,
pub passed: bool,
pub actual_value: Option<Value>,
pub expected_value: Option<Value>,
pub message: Option<String>,
}
impl TestScenario {
pub fn from_yaml_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = fs::read_to_string(path.as_ref())
.with_context(|| format!("Failed to read scenario file: {:?}", path.as_ref()))?;
serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse YAML scenario: {:?}", path.as_ref()))
}
pub fn from_json_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let content = fs::read_to_string(path.as_ref())
.with_context(|| format!("Failed to read scenario file: {:?}", path.as_ref()))?;
serde_json::from_str(&content)
.with_context(|| format!("Failed to parse JSON scenario: {:?}", path.as_ref()))
}
pub fn from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let path_ref = path.as_ref();
match path_ref.extension().and_then(|s| s.to_str()) {
Some("yaml") | Some("yml") => Self::from_yaml_file(path),
Some("json") => Self::from_json_file(path),
_ => {
Self::from_yaml_file(path_ref)
.or_else(|_| Self::from_json_file(path_ref))
.context("Failed to parse scenario file as YAML or JSON")
},
}
}
pub fn validate(&self) -> Result<()> {
if self.name.is_empty() {
anyhow::bail!("Scenario name cannot be empty");
}
if self.steps.is_empty() {
anyhow::bail!("Scenario must have at least one step");
}
for step in &self.steps {
if let Some(var_name) = &step.store_result {
if var_name.is_empty() {
anyhow::bail!("Variable name for storing result cannot be empty");
}
}
}
Ok(())
}
}
fn default_timeout() -> u64 {
60
}
fn default_stop_on_failure() -> bool {
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_scenario() {
let yaml = r#"
name: Simple Tool Test
description: Test basic tool functionality
steps:
- name: List available tools
operation:
type: list_tools
assertions:
- type: success
- type: exists
path: tools
- name: Call echo tool
operation:
type: tool_call
tool: echo
arguments:
message: "Hello, World!"
assertions:
- type: success
- type: contains
path: result
value: "Hello, World!"
"#;
let scenario: TestScenario = serde_yaml::from_str(yaml).unwrap();
assert_eq!(scenario.name, "Simple Tool Test");
assert_eq!(scenario.steps.len(), 2);
scenario.validate().unwrap();
}
#[test]
fn test_parse_complex_scenario() {
let yaml = r#"
name: Complex Scenario
timeout: 120
variables:
test_message: "Test message"
expected_count: 5
setup:
- name: Initialize test data
operation:
type: set_variable
name: test_id
value: "test_123"
steps:
- name: Test with variable
operation:
type: tool_call
tool: process
arguments:
id: "${test_id}"
message: "${test_message}"
store_result: process_result
assertions:
- type: success
- type: numeric
path: count
greater_than_or_equal: 5
cleanup:
- name: Clean up test data
operation:
type: tool_call
tool: cleanup
arguments:
id: "${test_id}"
"#;
let scenario: TestScenario = serde_yaml::from_str(yaml).unwrap();
assert_eq!(scenario.name, "Complex Scenario");
assert_eq!(scenario.timeout, 120);
assert_eq!(scenario.setup.len(), 1);
assert_eq!(scenario.steps.len(), 1);
assert_eq!(scenario.cleanup.len(), 1);
scenario.validate().unwrap();
}
}