use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::{BufRead, BufReader};
use std::path::Path;
use thiserror::Error;
#[inline]
pub fn is_false(b: &bool) -> bool {
!*b
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Fixture {
pub name: String,
pub category: FixtureCategory,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "is_false")]
pub expect_fail: bool,
pub input: FixtureInput,
pub output: FixtureOutput,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FixtureCategory {
ToolCall,
AgentRun,
SessionLifecycle,
EventOrdering,
ErrorPath,
}
impl FixtureCategory {
pub fn as_str(&self) -> &'static str {
match self {
Self::ToolCall => "tool_call",
Self::AgentRun => "agent_run",
Self::SessionLifecycle => "session_lifecycle",
Self::EventOrdering => "event_ordering",
Self::ErrorPath => "error_path",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixtureInput {
pub session_id: String,
#[serde(default)]
pub plugins: Vec<String>,
pub events: Vec<FixtureEvent>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixtureOutput {
pub events: Vec<ExpectedEvent>,
#[serde(default)]
pub final_state: std::collections::BTreeMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixtureEvent {
#[serde(rename = "type")]
pub event_type: String,
pub payload: serde_json::Value,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp_ms: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExpectedEvent {
#[serde(rename = "type")]
pub event_type: String,
#[serde(default)]
pub payload_match: std::collections::BTreeMap<String, serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp_ms: Option<u64>,
}
pub struct FixtureLoader;
impl FixtureLoader {
pub fn from_jsonl(path: impl AsRef<Path>) -> Result<Vec<Fixture>, FixtureError> {
let file = File::open(path.as_ref())?;
let reader = BufReader::new(file);
let mut fixtures = Vec::new();
for (line_idx, line) in reader.lines().enumerate() {
let line_no = line_idx + 1;
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let fixture: Fixture =
serde_json::from_str(trimmed).map_err(|e| FixtureError::Parse {
line: line_no,
source: e,
raw: trimmed.to_string(),
})?;
fixtures.push(fixture);
}
Ok(fixtures)
}
pub fn from_dir(dir: impl AsRef<Path>) -> Result<Vec<Fixture>, FixtureError> {
let mut all = Vec::new();
for entry in std::fs::read_dir(dir.as_ref())? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("jsonl") {
let mut fixtures = Self::from_jsonl(&path)?;
all.append(&mut fixtures);
}
}
all.sort_by(|a, b| a.name.cmp(&b.name));
Ok(all)
}
}
#[derive(Debug, Error)]
pub enum FixtureError {
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("parse error on line {line}: {source}\n raw: {raw:?}")]
Parse {
line: usize,
#[source]
source: serde_json::Error,
raw: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fixture_category_as_str() {
assert_eq!(FixtureCategory::ToolCall.as_str(), "tool_call");
assert_eq!(FixtureCategory::AgentRun.as_str(), "agent_run");
assert_eq!(
FixtureCategory::SessionLifecycle.as_str(),
"session_lifecycle"
);
assert_eq!(FixtureCategory::EventOrdering.as_str(), "event_ordering");
assert_eq!(FixtureCategory::ErrorPath.as_str(), "error_path");
}
#[test]
fn fixture_roundtrip_json() {
let json = r#"{
"name": "test_one",
"category": "tool_call",
"input": {
"session_id": "s1",
"plugins": ["bash"],
"events": [
{"type": "ToolCall", "payload": {"tool": "bash"}}
]
},
"output": {
"events": [
{"type": "ToolCall", "payload_match": {"tool": "bash"}}
]
}
}"#;
let f: Fixture = serde_json::from_str(json).unwrap();
assert_eq!(f.name, "test_one");
assert_eq!(f.category, FixtureCategory::ToolCall);
assert_eq!(f.input.events.len(), 1);
assert_eq!(f.output.events.len(), 1);
let s = serde_json::to_string(&f).unwrap();
let f2: Fixture = serde_json::from_str(&s).unwrap();
assert_eq!(f.name, f2.name);
}
#[test]
fn fixture_skip_optional_fields() {
let json = r#"{
"name": "minimal",
"category": "agent_run",
"input": {
"session_id": "s2",
"events": []
},
"output": {
"events": []
}
}"#;
let f: Fixture = serde_json::from_str(json).unwrap();
assert!(f.description.is_none());
assert!(f.input.plugins.is_empty());
assert!(f.output.final_state.is_empty());
assert!(!f.expect_fail);
}
#[test]
fn fixture_expect_fail_parses() {
let json = r#"{
"name": "by_design_fail",
"category": "event_ordering",
"expect_fail": true,
"input": {"session_id": "s", "events": []},
"output": {"events": []}
}"#;
let f: Fixture = serde_json::from_str(json).unwrap();
assert!(f.expect_fail);
}
#[test]
fn fixture_expect_fail_false_skipped_in_serialize() {
let json = r#"{
"name": "normal",
"category": "agent_run",
"input": {"session_id": "s", "events": []},
"output": {"events": []}
}"#;
let f: Fixture = serde_json::from_str(json).unwrap();
let s = serde_json::to_string(&f).unwrap();
assert!(
!s.contains("expect_fail"),
"expect_fail=false should be skipped: {s}"
);
}
}