use super::Fixture;
use super::docs_only;
use anyhow::{Context, Result, bail};
use std::collections::HashMap;
use std::path::Path;
pub fn load_fixtures(dir: &Path) -> Result<Vec<Fixture>> {
let mut fixtures = Vec::new();
load_fixtures_recursive(dir, dir, &mut fixtures)?;
let mut seen: HashMap<String, String> = HashMap::new();
for f in &fixtures {
if let Some(prev_source) = seen.get(&f.id) {
bail!(
"duplicate fixture ID '{}': found in '{}' and '{}'",
f.id,
prev_source,
f.source
);
}
seen.insert(f.id.clone(), f.source.clone());
}
fixtures.sort_by(|a, b| {
let cat_cmp = a.resolved_category().cmp(&b.resolved_category());
cat_cmp.then_with(|| a.id.cmp(&b.id))
});
Ok(fixtures)
}
fn load_fixtures_recursive(base: &Path, dir: &Path, fixtures: &mut Vec<Fixture>) -> Result<()> {
let entries =
std::fs::read_dir(dir).with_context(|| format!("failed to read fixture directory: {}", dir.display()))?;
let mut paths: Vec<_> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
paths.sort();
for path in paths {
if path.is_dir() {
load_fixtures_recursive(base, &path, fixtures)?;
} else if path.extension().is_some_and(|ext| ext == "json") {
let filename = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if filename == "schema.json" || filename.starts_with('_') {
continue;
}
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read fixture: {}", path.display()))?;
let relative = path.strip_prefix(base).unwrap_or(&path).to_string_lossy().to_string();
if skip_docs_only_fixture(&content, &path)? {
continue;
}
let parsed: Vec<Fixture> = if content.trim_start().starts_with('[') {
let values: Vec<serde_json::Value> = serde_json::from_str(&content)
.with_context(|| format!("failed to parse fixture array: {}", path.display()))?;
values
.into_iter()
.map(normalize_fixture_value)
.map(serde_json::from_value)
.collect::<std::result::Result<Vec<_>, _>>()
.with_context(|| format!("failed to parse fixture array: {}", path.display()))?
} else {
let value: serde_json::Value = serde_json::from_str(&content)
.with_context(|| format!("failed to parse fixture: {}", path.display()))?;
let single: Fixture = serde_json::from_value(normalize_fixture_value(value))
.with_context(|| format!("failed to parse fixture: {}", path.display()))?;
vec![single]
};
for mut fixture in parsed {
fixture.source = relative.clone();
validate_docs_file_inputs(&fixture)
.with_context(|| format!("invalid docs file input in fixture: {}", path.display()))?;
validate_docs_metadata(&fixture)
.with_context(|| format!("invalid docs metadata in fixture: {}", path.display()))?;
expand_json_templates(&mut fixture.input);
if let Some(ref mut http) = fixture.http {
for v in http.request.headers.values_mut() {
*v = crate::e2e::escape::expand_fixture_templates(v);
}
if let Some(ref mut body) = http.request.body {
expand_json_templates(body);
}
}
fixtures.push(fixture);
}
}
}
Ok(())
}
fn skip_docs_only_fixture(content: &str, path: &Path) -> Result<bool> {
let Ok(peek) = serde_json::from_str::<serde_json::Value>(content) else {
return Ok(false);
};
if docs_only::is_docs_only_marker(&peek) {
return Ok(true);
}
if let Some(array) = peek.as_array()
&& array.iter().any(docs_only::is_docs_only_marker)
{
bail!(
"docs-only fixtures must be a single top-level JSON object, not inside a fixture array: {}",
path.display()
);
}
Ok(false)
}
fn validate_docs_metadata(fixture: &Fixture) -> Result<()> {
let Some(docs) = &fixture.docs else {
return Ok(());
};
let fixture_expects_error = fixture
.assertions
.iter()
.any(|assertion| assertion.assertion_type == "error");
if docs.error == Some(true) && !fixture_expects_error {
bail!("docs.error requires an error assertion");
}
if docs.error == Some(false) && fixture_expects_error {
bail!("docs.error cannot be false when the fixture expects an error");
}
if docs.shows.iter().any(|path| path.trim().is_empty()) {
bail!("docs.shows entries must be non-empty result paths");
}
Ok(())
}
fn validate_docs_file_inputs(fixture: &Fixture) -> Result<()> {
let Some(presentation) = fixture.docs.as_ref().and_then(|docs| docs.presentation.as_ref()) else {
return Ok(());
};
let input = presentation.input.as_ref().unwrap_or(&fixture.input);
for file in &presentation.files {
let path = Path::new(&file.path);
if path.is_absolute()
|| path
.components()
.any(|component| matches!(component, std::path::Component::ParentDir))
{
bail!("docs file path must be relative and traversal-free: {}", file.path);
}
if !file.field.starts_with('/') || input.pointer(&file.field).is_none() {
bail!("docs file field must be an existing JSON pointer: {}", file.field);
}
}
Ok(())
}
fn normalize_fixture_value(mut value: serde_json::Value) -> serde_json::Value {
let Some(object) = value.as_object_mut() else {
return value;
};
if let Some(config) = object.get("config").cloned() {
let input = object
.entry("input")
.or_insert_with(|| serde_json::Value::Object(serde_json::Map::new()));
if let Some(input_object) = input.as_object_mut() {
input_object.entry("config".to_string()).or_insert(config);
}
}
value
}
fn expand_json_templates(value: &mut serde_json::Value) {
match value {
serde_json::Value::String(s) => {
let expanded = crate::e2e::escape::expand_fixture_templates(s);
if expanded != *s {
*s = expanded;
}
}
serde_json::Value::Array(arr) => {
for item in arr {
expand_json_templates(item);
}
}
serde_json::Value::Object(map) => {
for (_, v) in map.iter_mut() {
expand_json_templates(v);
}
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_fixture_value_copies_top_level_config_into_input() {
let value = serde_json::json!({
"id": "configured_call",
"description": "Configured call",
"input": {"kind": "uri", "uri": "doc.txt"},
"config": {"output_format": "markdown"}
});
let normalized = normalize_fixture_value(value);
assert_eq!(
normalized.pointer("/input/config/output_format"),
Some(&serde_json::json!("markdown"))
);
}
#[test]
fn normalize_fixture_value_preserves_explicit_input_config() {
let value = serde_json::json!({
"id": "configured_call",
"description": "Configured call",
"input": {
"kind": "uri",
"uri": "doc.txt",
"config": {"output_format": "html"}
},
"config": {"output_format": "markdown"}
});
let normalized = normalize_fixture_value(value);
assert_eq!(
normalized.pointer("/input/config/output_format"),
Some(&serde_json::json!("html"))
);
}
#[test]
fn docs_file_inputs_require_safe_existing_pointers() {
let fixture: Fixture = serde_json::from_value(serde_json::json!({
"id": "file_input",
"description": "Reads a local document",
"input": {"content": [1, 2, 3]},
"assertions": [],
"docs": {
"topic": "guides",
"presentation": {"files": [{"field": "/content", "path": "../secret.pdf"}]}
}
}))
.expect("fixture");
let error = validate_docs_file_inputs(&fixture).expect_err("traversal must be rejected");
assert!(error.to_string().contains("traversal-free"), "{error}");
let mut missing = fixture;
let file = &mut missing
.docs
.as_mut()
.and_then(|docs| docs.presentation.as_mut())
.expect("presentation")
.files[0];
file.path = "document.pdf".into();
file.field = "/missing".into();
let error = validate_docs_file_inputs(&missing).expect_err("missing pointers must be rejected");
assert!(error.to_string().contains("existing JSON pointer"), "{error}");
}
#[test]
fn skip_docs_only_fixture_is_true_for_a_docs_only_marked_object() {
let content = serde_json::json!({
"kind": "docs_only",
"id": "config_discovery",
"topic": "guides",
"content": "text",
})
.to_string();
assert!(skip_docs_only_fixture(&content, Path::new("fixture.json")).unwrap());
}
#[test]
fn skip_docs_only_fixture_is_false_for_an_ordinary_fixture() {
let content = serde_json::json!({
"id": "ordinary",
"description": "not docs-only",
})
.to_string();
assert!(!skip_docs_only_fixture(&content, Path::new("fixture.json")).unwrap());
}
#[test]
fn skip_docs_only_fixture_rejects_a_docs_only_entry_inside_an_array() {
let content = serde_json::json!([
{"kind": "docs_only", "id": "a", "topic": "guides", "content": "text"},
])
.to_string();
let error =
skip_docs_only_fixture(&content, Path::new("fixture.json")).expect_err("array-wrapped must be rejected");
assert!(error.to_string().contains("single top-level JSON object"), "{error}");
}
}