use crate::fixture::{
ExpectedEvent, Fixture, FixtureCategory, FixtureEvent, FixtureInput, FixtureOutput,
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use thiserror::Error;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DshFixture {
pub name: String,
#[serde(default)]
pub category: Option<String>,
#[serde(default)]
pub description: Option<String>,
#[serde(
default,
alias = "expectFail",
skip_serializing_if = "crate::fixture::is_false"
)]
pub expect_fail: bool,
pub input: DshInput,
#[serde(alias = "expected", alias = "expectedOutput")]
pub expected_output: DshExpectedOutput,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DshInput {
pub session_id: String,
#[serde(default, alias = "tools")]
pub plugins: Vec<String>,
#[serde(default)]
pub messages: Vec<DshMessage>,
#[serde(default)]
pub events: Vec<DshEvent>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DshMessage {
pub role: String,
pub content: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DshEvent {
#[serde(rename = "type")]
pub event_type: String,
#[serde(default, alias = "payload")]
pub data: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DshExpectedOutput {
#[serde(default)]
pub events: Vec<DshEvent>,
#[serde(default)]
pub messages: Vec<DshMessage>,
}
pub fn dsh_to_fixture(dsh: DshFixture) -> Fixture {
let category = parse_category(dsh.category.as_deref());
let input = convert_input(dsh.input);
let output = convert_expected(dsh.expected_output);
Fixture {
name: dsh.name,
category,
description: dsh.description,
expect_fail: dsh.expect_fail,
input,
output,
}
}
fn parse_category(s: Option<&str>) -> FixtureCategory {
match s {
Some("tool_call") | Some("tool") => FixtureCategory::ToolCall,
Some("agent_run") | Some("agent") => FixtureCategory::AgentRun,
Some("session_lifecycle") | Some("session") => FixtureCategory::SessionLifecycle,
Some("event_ordering") | Some("ordering") => FixtureCategory::EventOrdering,
Some("error_path") | Some("error") => FixtureCategory::ErrorPath,
_ => FixtureCategory::AgentRun, }
}
fn convert_input(input: DshInput) -> FixtureInput {
let mut events = input
.events
.into_iter()
.map(|e| FixtureEvent {
event_type: e.event_type,
payload: e.data,
timestamp_ms: None,
})
.collect::<Vec<_>>();
if events.is_empty() && !input.messages.is_empty() {
if input.messages.iter().any(|m| m.role == "user") {
events.push(FixtureEvent {
event_type: "RunStart".to_string(),
payload: serde_json::json!({"model": "stub"}),
timestamp_ms: None,
});
}
for msg in &input.messages {
let event_type = match msg.role.as_str() {
"user" => "UserInput",
"assistant" => "ModelResponse",
"system" => "SystemMessage",
"tool" => "ToolResult",
_ => continue,
};
events.push(FixtureEvent {
event_type: event_type.to_string(),
payload: serde_json::json!({
match event_type {
"UserInput" => "content",
"ModelResponse" => "content",
"SystemMessage" => "content",
"ToolResult" => "result",
_ => "content",
}: msg.content
}),
timestamp_ms: None,
});
}
}
FixtureInput {
session_id: input.session_id,
plugins: input.plugins,
events,
}
}
fn convert_expected(expected: DshExpectedOutput) -> FixtureOutput {
let events: Vec<ExpectedEvent> = expected
.events
.into_iter()
.map(|e| {
let payload_match: BTreeMap<String, serde_json::Value> = match e.data {
serde_json::Value::Object(m) => m.into_iter().collect(),
other => {
let mut map = BTreeMap::new();
let key = match e.event_type.as_str() {
"UserInput" | "ModelResponse" | "SystemMessage" | "ToolError" => "content",
"ToolResult" => "result",
_ => "data",
};
map.insert(key.to_string(), other);
map
}
};
ExpectedEvent {
event_type: e.event_type,
payload_match,
timestamp_ms: None,
}
})
.collect();
let mut all_events = events;
for msg in expected.messages.iter().filter(|m| m.role == "assistant") {
let mut payload_match = BTreeMap::new();
payload_match.insert(
"content".to_string(),
serde_json::Value::String(msg.content.clone()),
);
all_events.push(ExpectedEvent {
event_type: "ModelResponse".to_string(),
payload_match,
timestamp_ms: None,
});
}
FixtureOutput {
events: all_events,
final_state: BTreeMap::new(),
}
}
#[derive(Debug, Error)]
pub enum DshError {
#[error("dsh parse error: {0}")]
Parse(#[from] serde_json::Error),
#[error("dsh io error: {0}")]
Io(#[from] std::io::Error),
}
pub fn parse_dsh_jsonl(content: &str) -> Result<Vec<Fixture>, DshError> {
let mut out = Vec::new();
for line in content.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let dsh: DshFixture = serde_json::from_str(trimmed)?;
out.push(dsh_to_fixture(dsh));
}
Ok(out)
}
#[derive(Debug, Default)]
pub struct DshFixtureCache {
cache: std::sync::Mutex<BTreeMap<PathBuf, CacheEntry>>,
}
#[derive(Debug, Clone)]
struct CacheEntry {
mtime: SystemTime,
fixtures: Vec<Fixture>,
}
impl DshFixtureCache {
pub fn new() -> Self {
Self::default()
}
pub fn from_jsonl_cached(&self, path: impl AsRef<Path>) -> Result<Vec<Fixture>, DshError> {
let path = path.as_ref().to_path_buf();
let mtime = std::fs::metadata(&path)
.and_then(|m| m.modified())
.map_err(DshError::Io)?;
let mut cache = self.cache.lock().expect("cache lock poisoned");
if let Some(entry) = cache.get(&path) {
if entry.mtime == mtime {
return Ok(entry.fixtures.clone());
}
}
let content = std::fs::read_to_string(&path).map_err(DshError::Io)?;
let fixtures = parse_dsh_jsonl(&content)?;
cache.insert(
path,
CacheEntry {
mtime,
fixtures: fixtures.clone(),
},
);
Ok(fixtures)
}
pub fn clear(&self) {
self.cache.lock().expect("cache lock poisoned").clear();
}
pub fn len(&self) -> usize {
self.cache.lock().expect("cache lock poisoned").len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
#[cfg(test)]
mod cache_tests {
use super::*;
use std::io::Write;
#[test]
fn dsh_fixture_cache_basic() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.jsonl");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, r#"{{"name":"a","input":{{"session_id":"s","events":[]}},"expected_output":{{"events":[]}}}}"#).unwrap();
drop(f);
let cache = DshFixtureCache::new();
assert!(cache.is_empty());
let f1 = cache.from_jsonl_cached(&path).unwrap();
assert_eq!(f1.len(), 1);
assert_eq!(cache.len(), 1);
let f2 = cache.from_jsonl_cached(&path).unwrap();
assert_eq!(f2.len(), 1);
assert_eq!(cache.len(), 1); }
#[test]
fn dsh_fixture_cache_mtime_change() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.jsonl");
std::fs::write(
&path,
r#"{"name":"a","input":{"session_id":"s","events":[]},"expected_output":{"events":[]}}"#,
)
.unwrap();
let cache = DshFixtureCache::new();
let _f1 = cache.from_jsonl_cached(&path).unwrap();
std::thread::sleep(std::time::Duration::from_millis(50));
std::fs::write(
&path,
r#"{"name":"a","input":{"session_id":"s","events":[]},"expected_output":{"events":[]}}
{"name":"b","input":{"session_id":"s","events":[]},"expected_output":{"events":[]}}"#,
)
.unwrap();
let f2 = cache.from_jsonl_cached(&path).unwrap();
assert_eq!(f2.len(), 2, "mtime change should invalidate cache");
}
#[test]
fn dsh_fixture_cache_clear() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("test.jsonl");
std::fs::write(
&path,
r#"{"name":"a","input":{"session_id":"s","events":[]},"expected_output":{"events":[]}}"#,
)
.unwrap();
let cache = DshFixtureCache::new();
cache.from_jsonl_cached(&path).unwrap();
assert_eq!(cache.len(), 1);
cache.clear();
assert!(cache.is_empty());
}
#[test]
fn dsh_fixture_cache_nonexistent() {
let cache = DshFixtureCache::new();
let result = cache.from_jsonl_cached("/nonexistent/path.jsonl");
assert!(result.is_err());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_dsh_with_events() {
let json = r#"{
"name": "dsh_test_one",
"category": "tool_call",
"description": "dsh simple",
"input": {
"session_id": "s1",
"plugins": ["bash"],
"events": [
{"type": "ToolCall", "data": {"tool": "bash"}}
]
},
"expected_output": {
"events": [
{"type": "ToolResult", "data": {"result": "hi\n"}}
]
}
}"#;
let f: DshFixture = serde_json::from_str(json).unwrap();
let ma = dsh_to_fixture(f);
assert_eq!(ma.name, "dsh_test_one");
assert_eq!(ma.category, FixtureCategory::ToolCall);
assert_eq!(ma.input.events.len(), 1);
assert_eq!(ma.input.plugins, vec!["bash"]);
assert_eq!(ma.output.events.len(), 1);
assert_eq!(ma.output.events[0].event_type, "ToolResult");
assert_eq!(
ma.output.events[0].payload_match.get("result").unwrap(),
"hi\n"
);
}
#[test]
fn parse_dsh_alias_expected_output() {
let json = r#"{
"name": "alias_test",
"input": {"session_id": "s", "events": []},
"expected": {"events": []}
}"#;
let f: DshFixture = serde_json::from_str(json).unwrap();
let ma = dsh_to_fixture(f);
assert_eq!(ma.name, "alias_test");
}
#[test]
fn parse_dsh_alias_tools() {
let json = r#"{
"name": "tools_alias",
"input": {
"session_id": "s",
"tools": ["bash", "fs"],
"events": []
},
"expected_output": {"events": []}
}"#;
let f: DshFixture = serde_json::from_str(json).unwrap();
let ma = dsh_to_fixture(f);
assert_eq!(ma.input.plugins, vec!["bash", "fs"]);
}
#[test]
fn parse_dsh_data_alias_payload() {
let json = r#"{
"name": "payload_alias",
"input": {"session_id": "s", "events": []},
"expected_output": {
"events": [
{"type": "ModelResponse", "payload": {"text": "hi"}}
]
}
}"#;
let f: DshFixture = serde_json::from_str(json).unwrap();
let ma = dsh_to_fixture(f);
assert_eq!(ma.output.events[0].payload_match.get("text").unwrap(), "hi");
}
#[test]
fn parse_dsh_derives_user_input_from_messages() {
let json = r#"{
"name": "from_messages",
"input": {
"session_id": "s",
"messages": [
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hello"}
]
},
"expected_output": {"events": []}
}"#;
let f: DshFixture = serde_json::from_str(json).unwrap();
let ma = dsh_to_fixture(f);
assert_eq!(ma.input.events.len(), 3);
assert_eq!(ma.input.events[0].event_type, "RunStart");
assert_eq!(ma.input.events[1].event_type, "UserInput");
assert_eq!(ma.input.events[1].payload["content"], "hi");
assert_eq!(ma.input.events[2].event_type, "ModelResponse");
assert_eq!(ma.input.events[2].payload["content"], "hello");
}
#[test]
fn parse_dsh_derives_model_response_from_assistant_messages() {
let json = r#"{
"name": "assistant_msg",
"input": {"session_id": "s", "events": []},
"expected_output": {
"events": [],
"messages": [
{"role": "assistant", "content": "I can help"}
]
}
}"#;
let f: DshFixture = serde_json::from_str(json).unwrap();
let ma = dsh_to_fixture(f);
assert_eq!(ma.output.events.len(), 1);
assert_eq!(ma.output.events[0].event_type, "ModelResponse");
assert_eq!(
ma.output.events[0].payload_match.get("content").unwrap(),
"I can help"
);
}
#[test]
fn parse_dsh_category_alias() {
let cases = [
("tool", FixtureCategory::ToolCall),
("agent", FixtureCategory::AgentRun),
("session", FixtureCategory::SessionLifecycle),
("ordering", FixtureCategory::EventOrdering),
("error", FixtureCategory::ErrorPath),
("unknown", FixtureCategory::AgentRun),
];
for (s, expected) in cases {
assert_eq!(parse_category(Some(s)), expected, "s={s}");
}
assert_eq!(parse_category(None), FixtureCategory::AgentRun);
}
#[test]
fn parse_dsh_jsonl_skips_blank_and_comment() {
let content = r#"
# 注释
{"name":"a","input":{"session_id":"s","events":[]},"expected_output":{"events":[]}}
{"name":"b","input":{"session_id":"s","events":[]},"expected_output":{"events":[]}}
"#;
let fs = parse_dsh_jsonl(content).unwrap();
assert_eq!(fs.len(), 2);
assert_eq!(fs[0].name, "a");
assert_eq!(fs[1].name, "b");
}
#[test]
fn parse_dsh_non_object_data() {
let json = r#"{
"name": "string_data",
"input": {"session_id": "s", "events": []},
"expected_output": {
"events": [
{"type": "Log", "data": "raw_string_response"}
]
}
}"#;
let f: DshFixture = serde_json::from_str(json).unwrap();
let ma = dsh_to_fixture(f);
assert_eq!(ma.output.events[0].event_type, "Log");
assert_eq!(
ma.output.events[0].payload_match.get("data").unwrap(),
"raw_string_response"
);
}
#[test]
fn parse_dsh_non_object_data_for_model_response_uses_content_key() {
let json = r#"{
"name": "string_data_mr",
"input": {"session_id": "s", "events": []},
"expected_output": {
"events": [
{"type": "ModelResponse", "data": "raw_string_response"}
]
}
}"#;
let f: DshFixture = serde_json::from_str(json).unwrap();
let ma = dsh_to_fixture(f);
assert_eq!(ma.output.events[0].event_type, "ModelResponse");
assert_eq!(
ma.output.events[0].payload_match.get("content").unwrap(),
"raw_string_response"
);
}
#[test]
fn dsh_to_fixture_propagates_expect_fail() {
let json = r#"{
"name": "by_design_fail_snake",
"expect_fail": true,
"input": {"session_id": "s", "events": []},
"expected_output": {"events": []}
}"#;
let f: DshFixture = serde_json::from_str(json).unwrap();
assert!(f.expect_fail);
let ma = dsh_to_fixture(f);
assert!(ma.expect_fail);
let json = r#"{
"name": "by_design_fail_camel",
"expectFail": true,
"input": {"session_id": "s", "events": []},
"expected_output": {"events": []}
}"#;
let f: DshFixture = serde_json::from_str(json).unwrap();
assert!(f.expect_fail);
let ma = dsh_to_fixture(f);
assert!(ma.expect_fail);
}
}