use std::collections::HashMap;
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Deserialize)]
pub struct Scenario {
#[serde(default)]
pub config: ScenarioConfig,
#[serde(default)]
pub definitions: Vec<AssertDefinition>,
#[serde(default)]
pub test: Vec<TestGroup>,
}
#[derive(Debug, Deserialize, Default, Clone)]
pub struct ScenarioConfig {
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub llm_url: Option<String>,
#[serde(default)]
pub llm_model: Option<String>,
#[serde(default)]
pub llm_api_key: Option<String>,
#[serde(default, deserialize_with = "deserialize_headers")]
pub llm_headers: HashMap<String, String>,
#[serde(default)]
pub browser_headless: Option<bool>,
#[serde(default)]
pub timeout_secs: Option<u64>,
#[serde(default)]
pub viewport_width: Option<u32>,
#[serde(default)]
pub viewport_height: Option<u32>,
#[serde(default)]
pub start_url: Option<String>,
#[serde(default = "default_auto_navigate")]
pub auto_navigate: bool,
#[serde(default = "default_temperature")]
pub temperature: f64,
#[serde(default)]
pub thinking: Option<bool>,
#[serde(default, deserialize_with = "deserialize_model_params")]
pub model_params: HashMap<String, Value>,
#[serde(default)]
pub endpoints: HashMap<String, EndpointConfig>,
#[serde(default)]
pub budgets: BudgetsConfig,
#[serde(default)]
pub mcp_server: Option<McpServerConfig>,
#[serde(default)]
pub a2a_server: Option<A2aServerConfig>,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct EndpointConfig {
#[serde(rename = "type")]
pub endpoint_type: EndpointType,
#[serde(default)]
pub url: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default, deserialize_with = "deserialize_headers")]
pub headers: HashMap<String, String>,
#[serde(default)]
pub pricing: Option<PricingConfig>,
#[serde(default)]
pub default_for: Vec<String>,
#[serde(default)]
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
}
#[derive(Debug, Deserialize, Clone, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum EndpointType {
#[default]
Llm,
Mcp,
A2a,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct PricingConfig {
#[serde(default)]
pub input_per_1m_tokens: f64,
#[serde(default)]
pub output_per_1m_tokens: f64,
#[serde(default)]
pub per_call: f64,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct BudgetsConfig {
#[serde(default)]
pub global: Option<BudgetDef>,
#[serde(default)]
pub per_test_default: Option<BudgetDef>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct BudgetDef {
#[serde(default)]
pub max_cost: Option<f64>,
#[serde(default)]
pub max_tokens: Option<u64>,
#[serde(default)]
pub max_calls: Option<u64>,
#[serde(default)]
pub enforcement: Option<BudgetEnforcement>,
}
#[derive(Debug, Deserialize, Clone, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum BudgetEnforcement {
Hard,
Soft,
}
#[derive(Debug, Deserialize, Clone)]
pub struct McpServerConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_mcp_port")]
pub port: u16,
}
const fn default_mcp_port() -> u16 {
3000
}
#[derive(Debug, Deserialize, Clone)]
pub struct A2aServerConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_a2a_port")]
pub port: u16,
}
const fn default_a2a_port() -> u16 {
3100
}
fn deserialize_headers<'de, D>(deserializer: D) -> Result<HashMap<String, String>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw: Option<serde_json::Value> = Option::deserialize(deserializer)?;
let Some(json) = raw else {
return Ok(HashMap::new());
};
let serde_json::Value::Object(obj) = json else {
return Ok(HashMap::new());
};
Ok(obj
.into_iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k, s.to_owned())))
.collect())
}
const fn default_auto_navigate() -> bool {
true
}
const fn default_temperature() -> f64 {
0.0
}
fn deserialize_model_params<'de, D>(deserializer: D) -> Result<HashMap<String, Value>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Map(HashMap<String, Value>),
Table(HashMap<String, Value>),
}
let raw: Option<Raw> = Option::deserialize(deserializer)?;
Ok(match raw {
Some(Raw::Map(m) | Raw::Table(m)) => m,
None => HashMap::new(),
})
}
#[derive(Debug, Deserialize, Clone)]
pub struct AssertDefinition {
pub name: String,
#[serde(default)]
pub preset: Option<String>,
#[serde(default)]
pub prompt: Option<String>,
#[serde(default)]
pub system: Option<String>,
#[serde(default)]
pub user_template: Option<String>,
#[serde(default)]
pub assert_text: Option<String>,
#[serde(default)]
pub agent: Option<String>,
#[serde(default)]
pub task_template: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct TestGroup {
pub name: String,
#[serde(default)]
pub start_url: Option<String>,
#[serde(default)]
pub auto_navigate: Option<bool>,
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub timeout_secs: Option<u64>,
#[serde(default)]
pub browser_headless: Option<bool>,
#[serde(default)]
pub budget: Option<BudgetDef>,
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default)]
pub steps: Vec<TestStep>,
}
#[derive(Debug, Deserialize)]
#[serde(tag = "kind")]
pub enum TestStep {
#[serde(rename = "navigate")]
Navigate {
url: String,
#[serde(default)]
wait_after_ms: Option<u64>,
},
#[serde(rename = "click")]
Click {
target: String,
#[serde(default)]
selector: Option<String>,
#[serde(default)]
wait_after_ms: Option<u64>,
#[serde(default)]
endpoint: Option<String>,
},
#[serde(rename = "type")]
Type {
target: String,
text: String,
#[serde(default)]
selector: Option<String>,
#[serde(default)]
wait_after_ms: Option<u64>,
#[serde(default)]
endpoint: Option<String>,
},
#[serde(rename = "wait")]
Wait {
target: String,
#[serde(default)]
selector: Option<String>,
#[serde(default)]
timeout_ms: Option<u64>,
#[serde(default)]
endpoint: Option<String>,
},
#[serde(rename = "assert")]
Assert {
#[serde(default)]
definition: Option<String>,
#[serde(default)]
preset: Option<String>,
#[serde(default)]
prompt: Option<String>,
#[serde(default)]
assert_text: Option<String>,
#[serde(default)]
endpoint: Option<String>,
},
#[serde(rename = "screenshot")]
Screenshot {
#[serde(default)]
path: Option<String>,
},
#[serde(rename = "agent")]
Agent {
agent: String,
task: String,
#[serde(default)]
definition: Option<String>,
},
#[serde(rename = "mcp")]
Mcp {
server: String,
tool: String,
#[serde(default)]
args: Option<serde_json::Value>,
},
}