use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub mod assertion;
pub use assertion::*;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestPlan {
pub name: String,
#[serde(default)]
pub base_url: String,
#[serde(default)]
pub default_headers: HashMap<String, String>,
#[serde(default)]
pub steps: Vec<Step>,
#[serde(default)]
pub setup: Vec<Step>,
#[serde(default)]
pub teardown: Vec<Step>,
}
impl TestPlan {
pub fn total_tests(&self) -> usize {
self.steps.iter().map(|s| s.count_tests()).sum()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Step {
Request(RequestStep),
Sequence(SequenceStep),
Parallel(Vec<Step>),
Script(ScriptStep),
Noop {
#[serde(default)]
description: String,
},
}
impl Step {
pub fn count_tests(&self) -> usize {
match self {
Step::Request(_) => 1,
Step::Sequence(s) => s.steps.iter().map(|s| s.count_tests()).sum(),
Step::Parallel(steps) => steps.iter().map(|s| s.count_tests()).sum(),
Step::Script(_) => 1,
Step::Noop { .. } => 0,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RequestStep {
pub name: String,
pub method: Method,
pub url: String,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub body: Option<serde_json::Value>,
#[serde(default)]
pub assert: Vec<Assertion>,
#[serde(default)]
pub save_as: String,
#[serde(default)]
pub soft_fail: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum Method {
Get,
Post,
Put,
Delete,
Patch,
Head,
Options,
}
impl std::fmt::Display for Method {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Method::Get => write!(f, "GET"),
Method::Post => write!(f, "POST"),
Method::Put => write!(f, "PUT"),
Method::Delete => write!(f, "DELETE"),
Method::Patch => write!(f, "PATCH"),
Method::Head => write!(f, "HEAD"),
Method::Options => write!(f, "OPTIONS"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SequenceStep {
pub name: String,
pub steps: Vec<Step>,
#[serde(default)]
pub continue_on_failure: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScriptStep {
pub name: String,
pub language: String,
pub source: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestResult {
pub name: String,
pub passed: bool,
pub status_code: u16,
pub request_method: String,
pub request_url: String,
pub request_headers: HashMap<String, String>,
pub request_body: Option<serde_json::Value>,
pub response_headers: HashMap<String, String>,
pub response_body: Option<serde_json::Value>,
#[serde(default)]
pub assertion_results: Vec<AssertionResult>,
#[serde(default)]
pub errors: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestGroupResult {
pub name: String,
pub passed: usize,
pub failed: usize,
pub total: usize,
pub results: Vec<TestResult>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RunReport {
pub plan_name: String,
pub total: usize,
pub passed: usize,
pub failed: usize,
pub groups: Vec<TestGroupResult>,
pub duration_ms: u64,
}
impl RunReport {
pub fn write_results(&self, output_dir: &std::path::Path) -> anyhow::Result<()> {
let results_dir = output_dir.join("results");
std::fs::create_dir_all(&results_dir)?;
for group in &self.groups {
let path = results_dir.join(format!("{}.json", group.name));
let json = serde_json::to_string_pretty(group)?;
std::fs::write(path, json)?;
}
let summary = serde_json::json!({
"plan": self.plan_name,
"total": self.total,
"passed": self.passed,
"failed": self.failed,
"duration_ms": self.duration_ms,
"groups": self.groups.iter().map(|g| serde_json::json!({
"name": g.name,
"total": g.total,
"passed": g.passed,
"failed": g.failed,
})).collect::<Vec<_>>(),
});
std::fs::write(
results_dir.join("summary.json"),
serde_json::to_string_pretty(&summary)?,
)?;
let failed: Vec<&TestResult> = self
.groups
.iter()
.flat_map(|g| g.results.iter())
.filter(|r| !r.passed)
.collect();
if !failed.is_empty() {
std::fs::write(
results_dir.join("failed.json"),
serde_json::to_string_pretty(&failed)?,
)?;
}
Ok(())
}
}
impl std::fmt::Display for RunReport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for group in &self.groups {
writeln!(f, "\n── {} ──", group.name)?;
for result in &group.results {
let status = if result.passed { "✓" } else { "✗" };
writeln!(
f,
" {} {} {} [{}]",
status, result.request_method, result.request_url, result.status_code
)?;
for err in &result.errors {
writeln!(f, " └─ {}", err)?;
}
}
}
writeln!(f)?;
writeln!(f, "=== Results ===")?;
writeln!(
f,
"Total: {} | Passed: {} | Failed: {} | Duration: {}ms",
self.total, self.passed, self.failed, self.duration_ms
)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_plan_total_tests() {
let plan = TestPlan {
name: "test".into(),
base_url: "http://localhost".into(),
default_headers: HashMap::new(),
steps: vec![
Step::Request(RequestStep {
name: "r1".into(),
method: Method::Get,
url: "/".into(),
headers: HashMap::new(),
body: None,
assert: vec![],
save_as: String::new(),
soft_fail: false,
}),
Step::Sequence(SequenceStep {
name: "seq".into(),
steps: vec![
Step::Request(RequestStep {
name: "r2".into(),
method: Method::Post,
url: "/".into(),
headers: HashMap::new(),
body: None,
assert: vec![],
save_as: String::new(),
soft_fail: false,
}),
Step::Request(RequestStep {
name: "r3".into(),
method: Method::Get,
url: "/".into(),
headers: HashMap::new(),
body: None,
assert: vec![],
save_as: String::new(),
soft_fail: false,
}),
],
continue_on_failure: false,
}),
Step::Noop {
description: "skip".into(),
},
],
setup: vec![],
teardown: vec![],
};
assert_eq!(plan.total_tests(), 3);
}
#[test]
fn method_display() {
assert_eq!(Method::Get.to_string(), "GET");
assert_eq!(Method::Post.to_string(), "POST");
assert_eq!(Method::Delete.to_string(), "DELETE");
}
}