use crate::assert::types::{RequestInfo, ResponseInfo, StepResult};
use crate::fixtures::{latest_passed_path, step_fixture_dir, INDEX_FILENAME};
use crate::model::RedactionConfig;
use crate::report::redaction::{redact_headers, sanitize_json, sanitize_string};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct FixtureWriteConfig {
pub enabled: bool,
pub workspace_root: PathBuf,
pub retention: usize,
}
impl Default for FixtureWriteConfig {
fn default() -> Self {
Self {
enabled: false,
workspace_root: PathBuf::from("."),
retention: crate::fixtures::DEFAULT_RETENTION,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Fixture {
pub recorded_at: String,
pub request: FixtureRequest,
#[serde(skip_serializing_if = "Option::is_none")]
pub response: Option<FixtureResponse>,
#[serde(default)]
pub captures: serde_json::Map<String, Value>,
pub passed: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failure_message: Option<String>,
#[serde(default)]
pub duration_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixtureRequest {
pub method: String,
pub url: String,
pub headers: std::collections::BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FixtureResponse {
pub status: u16,
pub headers: std::collections::BTreeMap<String, String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<Value>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct FixtureIndex {
pub history: Vec<String>,
}
pub fn build_fixture(
result: &StepResult,
redaction: &RedactionConfig,
secret_values: &[String],
) -> Fixture {
let (request, response) = if let Some(req) = result.request_info.as_ref() {
(
redact_request(req, redaction, secret_values),
result
.response_info
.as_ref()
.map(|r| redact_response(r, redaction, secret_values)),
)
} else {
(empty_request(), None)
};
let captures = serde_json::Map::new();
let failure_message = result
.assertion_results
.iter()
.find(|a| !a.passed)
.map(|a| sanitize_string(&a.message, &redaction.replacement, secret_values));
Fixture {
recorded_at: Utc::now().to_rfc3339(),
request,
response,
captures,
passed: result.passed,
failure_message,
duration_ms: result.duration_ms,
}
}
pub fn attach_captures(
fixture: &mut Fixture,
captures: &HashMap<String, Value>,
captures_set: &[String],
redaction: &RedactionConfig,
secret_values: &[String],
) {
for name in captures_set {
if let Some(value) = captures.get(name) {
let sanitised = sanitize_json(value, &redaction.replacement, secret_values);
fixture.captures.insert(name.clone(), sanitised);
}
}
}
fn empty_request() -> FixtureRequest {
FixtureRequest {
method: String::new(),
url: String::new(),
headers: std::collections::BTreeMap::new(),
body: None,
}
}
fn redact_request(
request: &RequestInfo,
redaction: &RedactionConfig,
secret_values: &[String],
) -> FixtureRequest {
FixtureRequest {
method: request.method.clone(),
url: sanitize_string(&request.url, &redaction.replacement, secret_values),
headers: redact_headers(&request.headers, redaction, secret_values),
body: request
.body
.as_ref()
.map(|b| sanitize_json(b, &redaction.replacement, secret_values)),
}
}
fn redact_response(
response: &ResponseInfo,
redaction: &RedactionConfig,
secret_values: &[String],
) -> FixtureResponse {
FixtureResponse {
status: response.status,
headers: redact_headers(&response.headers, redaction, secret_values),
body: response
.body
.as_ref()
.map(|b| sanitize_json(b, &redaction.replacement, secret_values)),
}
}
fn atomic_write(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let tmp = path.with_extension(format!(
"{}.tmp",
path.extension().and_then(|e| e.to_str()).unwrap_or("")
));
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, path)?;
Ok(())
}
pub fn write_step_fixture(
config: &FixtureWriteConfig,
file_path: &Path,
test: &str,
step_index: usize,
fixture: &Fixture,
) -> std::io::Result<()> {
let dir = step_fixture_dir(&config.workspace_root, file_path, test, step_index);
std::fs::create_dir_all(&dir)?;
let filename = next_history_filename(&dir);
let target = dir.join(&filename);
let encoded = serde_json::to_vec_pretty(fixture)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
atomic_write(&target, &encoded)?;
let mut index = read_index(&dir);
index.history.push(filename);
let retention = config.retention.max(1);
while index.history.len() > retention {
let oldest = index.history.remove(0);
let p = dir.join(&oldest);
let _ = std::fs::remove_file(&p);
}
write_index(&dir, &index)?;
if fixture.passed {
let latest = latest_passed_path(&config.workspace_root, file_path, test, step_index);
atomic_write(&latest, &encoded)?;
}
Ok(())
}
fn next_history_filename(_dir: &Path) -> String {
let millis = Utc::now().timestamp_millis();
let suffix: u32 = rand::random();
format!("{:013}-{:08x}.json", millis, suffix)
}
fn read_index(dir: &Path) -> FixtureIndex {
let path = dir.join(INDEX_FILENAME);
let Ok(bytes) = std::fs::read(&path) else {
return FixtureIndex::default();
};
serde_json::from_slice(&bytes).unwrap_or_default()
}
fn write_index(dir: &Path, index: &FixtureIndex) -> std::io::Result<()> {
let path = dir.join(INDEX_FILENAME);
let encoded = serde_json::to_vec_pretty(index)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
atomic_write(&path, &encoded)
}
pub fn clear_fixtures(root: &Path, file_path: Option<&Path>) -> std::io::Result<()> {
let target = match file_path {
Some(file) => {
use crate::fixtures::file_path_hash;
root.join(".tarn")
.join("fixtures")
.join(file_path_hash(root, file))
}
None => root.join(".tarn").join("fixtures"),
};
if target.exists() {
std::fs::remove_dir_all(&target)?;
}
Ok(())
}
pub fn read_latest_fixture(
root: &Path,
file_path: &Path,
test: &str,
step_index: usize,
) -> Option<Fixture> {
if let Some(passed) = read_fixture_file(&latest_passed_path(root, file_path, test, step_index))
{
return Some(passed);
}
let dir = step_fixture_dir(root, file_path, test, step_index);
let index = read_index(&dir);
let newest = index.history.last()?;
read_fixture_file(&dir.join(newest))
}
pub fn read_latest_response_value(
root: &Path,
file_path: &Path,
test: &str,
step_index: usize,
) -> Option<Value> {
let fx = read_latest_fixture(root, file_path, test, step_index)?;
fx.response.and_then(|r| r.body)
}
fn read_fixture_file(path: &Path) -> Option<Fixture> {
let bytes = std::fs::read(path).ok()?;
serde_json::from_slice::<Fixture>(&bytes).ok()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assert::types::{AssertionResult, RequestInfo, ResponseInfo};
use crate::fixtures::{file_path_hash, slugify_name, LATEST_PASSED_FILENAME};
use std::collections::HashMap;
use tempfile::TempDir;
fn step_result_ok() -> StepResult {
StepResult {
name: "create user".into(),
description: None,
debug: false,
passed: true,
duration_ms: 42,
assertion_results: vec![AssertionResult::pass("status", "200", "200")],
request_info: Some(RequestInfo {
method: "POST".into(),
url: "http://x.test/users".into(),
headers: HashMap::from([("X-Trace".into(), "leaky-secret".into())]),
body: Some(serde_json::json!({"name": "leaky-secret"})),
multipart: None,
}),
response_info: Some(ResponseInfo {
status: 200,
headers: HashMap::from([("content-type".into(), "application/json".into())]),
body: Some(serde_json::json!({"id": 7, "token": "leaky-secret"})),
}),
error_category: None,
response_status: Some(200),
response_summary: Some("200 OK".into()),
captures_set: vec!["user_id".into()],
location: None,
response_shape_mismatch: None,
}
}
#[test]
fn build_fixture_redacts_strings_in_request_and_response() {
let sr = step_result_ok();
let redaction = RedactionConfig::default();
let fx = build_fixture(&sr, &redaction, &["leaky-secret".into()]);
assert!(fx.passed);
let rep = &redaction.replacement;
assert_eq!(fx.request.headers.get("X-Trace"), Some(rep));
let body = fx.request.body.as_ref().unwrap();
assert_eq!(body["name"], serde_json::Value::String(rep.clone()));
let resp = fx.response.as_ref().unwrap();
let resp_body = resp.body.as_ref().unwrap();
assert_eq!(resp_body["token"], serde_json::Value::String(rep.clone()));
}
#[test]
fn attach_captures_drops_unknown_names_and_redacts_values() {
let sr = step_result_ok();
let redaction = RedactionConfig::default();
let mut fx = build_fixture(&sr, &redaction, &["leaky-secret".into()]);
let mut all_captures = HashMap::new();
all_captures.insert("user_id".to_string(), serde_json::json!(7));
all_captures.insert("other".to_string(), serde_json::json!("should-not-appear"));
attach_captures(&mut fx, &all_captures, &["user_id".into()], &redaction, &[]);
assert_eq!(fx.captures.get("user_id"), Some(&serde_json::json!(7)));
assert!(!fx.captures.contains_key("other"));
}
#[test]
fn write_step_fixture_creates_directory_tree_and_latest_passed() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let file = root.join("tests/users.tarn.yaml");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, "name: fixture\n").unwrap();
let sr = step_result_ok();
let fx = build_fixture(&sr, &RedactionConfig::default(), &[]);
let config = FixtureWriteConfig {
enabled: true,
workspace_root: root.clone(),
retention: 5,
};
write_step_fixture(&config, &file, "create user", 0, &fx).unwrap();
let expected_dir = root
.join(".tarn/fixtures")
.join(file_path_hash(&root, &file))
.join(slugify_name("create user"))
.join("0");
assert!(expected_dir.is_dir());
let latest = expected_dir.join("latest-passed.json");
assert!(latest.is_file(), "latest-passed missing");
let manifest: FixtureIndex =
serde_json::from_slice(&std::fs::read(expected_dir.join("_index.json")).unwrap())
.unwrap();
assert_eq!(manifest.history.len(), 1);
}
#[test]
fn write_step_fixture_prunes_history_to_retention_cap() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let file = root.join("tests/users.tarn.yaml");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, "name: x\n").unwrap();
let sr = step_result_ok();
let fx = build_fixture(&sr, &RedactionConfig::default(), &[]);
let config = FixtureWriteConfig {
enabled: true,
workspace_root: root.clone(),
retention: 3,
};
for _ in 0..7 {
std::thread::sleep(std::time::Duration::from_millis(2));
write_step_fixture(&config, &file, "t", 0, &fx).unwrap();
}
let dir = step_fixture_dir(&root, &file, "t", 0);
let entries: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.filter(|n| n != LATEST_PASSED_FILENAME && n != INDEX_FILENAME && !n.ends_with(".tmp"))
.collect();
assert_eq!(
entries.len(),
3,
"retention cap of 3 should keep exactly 3 rolling files, got {:?}",
entries
);
}
#[test]
fn read_latest_fixture_prefers_latest_passed() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let file = root.join("tests/users.tarn.yaml");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, "name: x\n").unwrap();
let mut fx_pass = build_fixture(&step_result_ok(), &RedactionConfig::default(), &[]);
fx_pass.passed = true;
let config = FixtureWriteConfig {
enabled: true,
workspace_root: root.clone(),
retention: 3,
};
write_step_fixture(&config, &file, "t", 0, &fx_pass).unwrap();
let mut failing = fx_pass.clone();
failing.passed = false;
failing.failure_message = Some("boom".into());
failing.recorded_at = "2026-04-17T00:00:00Z".into();
write_step_fixture(&config, &file, "t", 0, &failing).unwrap();
let read = read_latest_fixture(&root, &file, "t", 0).expect("fixture");
assert!(read.passed, "should prefer latest-passed.json");
}
#[test]
fn clear_fixtures_removes_entire_subtree() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let file = root.join("tests/users.tarn.yaml");
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(&file, "name: x\n").unwrap();
let fx = build_fixture(&step_result_ok(), &RedactionConfig::default(), &[]);
let config = FixtureWriteConfig {
enabled: true,
workspace_root: root.clone(),
retention: 3,
};
write_step_fixture(&config, &file, "t", 0, &fx).unwrap();
let base = root.join(".tarn/fixtures");
assert!(base.exists());
clear_fixtures(&root, None).unwrap();
assert!(!base.exists());
}
#[test]
fn clear_fixtures_can_scope_to_single_file() {
let tmp = TempDir::new().unwrap();
let root = tmp.path().to_path_buf();
let file_a = root.join("tests/a.tarn.yaml");
let file_b = root.join("tests/b.tarn.yaml");
std::fs::create_dir_all(file_a.parent().unwrap()).unwrap();
std::fs::write(&file_a, "name: a\n").unwrap();
std::fs::write(&file_b, "name: b\n").unwrap();
let fx = build_fixture(&step_result_ok(), &RedactionConfig::default(), &[]);
let config = FixtureWriteConfig {
enabled: true,
workspace_root: root.clone(),
retention: 3,
};
write_step_fixture(&config, &file_a, "t", 0, &fx).unwrap();
write_step_fixture(&config, &file_b, "t", 0, &fx).unwrap();
clear_fixtures(&root, Some(&file_a)).unwrap();
let dir_a = root
.join(".tarn/fixtures")
.join(file_path_hash(&root, &file_a));
let dir_b = root
.join(".tarn/fixtures")
.join(file_path_hash(&root, &file_b));
assert!(!dir_a.exists(), "file_a fixtures should be removed");
assert!(dir_b.exists(), "file_b fixtures should remain");
}
}