use std::fs;
use std::path::Path;
use std::sync::OnceLock;
use regex::Regex;
fn uuid_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}").unwrap()
})
}
fn timestamp_regex() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| {
Regex::new(r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:?\d{2})?").unwrap()
})
}
pub fn is_bless_mode() -> bool {
std::env::var("RAPINA_BLESS").is_ok_and(|v| v == "1")
}
pub fn redact(value: &mut serde_json::Value) {
redact_inner(value, None);
}
fn redact_inner(value: &mut serde_json::Value, key: Option<&str>) {
match value {
serde_json::Value::String(s) => {
if key == Some("trace_id") {
*s = "[UUID]".to_string();
return;
}
if uuid_regex().is_match(s) {
*s = uuid_regex().replace_all(s, "[UUID]").to_string();
return;
}
if timestamp_regex().is_match(s) {
*s = timestamp_regex().replace_all(s, "[TIMESTAMP]").to_string();
}
}
serde_json::Value::Object(map) => {
for (k, v) in map.iter_mut() {
redact_inner(v, Some(k));
}
}
serde_json::Value::Array(arr) => {
for v in arr.iter_mut() {
redact_inner(v, None);
}
}
_ => {}
}
}
pub fn format_snapshot(status: u16, content_type: &str, body: &str) -> String {
let reason = reason_phrase(status);
format!(
"HTTP {} {}\nContent-Type: {}\n\n{}\n",
status, reason, content_type, body
)
}
pub fn assert_snapshot(name: &str, status: u16, content_type: &str, body: &[u8]) {
assert_snapshot_impl(
name,
status,
content_type,
body,
Path::new("snapshots"),
is_bless_mode(),
);
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn assert_snapshot_in(
name: &str,
status: u16,
content_type: &str,
body: &[u8],
base_dir: &Path,
bless: bool,
) {
assert_snapshot_impl(name, status, content_type, body, base_dir, bless);
}
fn assert_snapshot_impl(
name: &str,
status: u16,
content_type: &str,
body: &[u8],
base_dir: &Path,
bless: bool,
) {
let display_body = match serde_json::from_slice::<serde_json::Value>(body) {
Ok(mut val) => {
redact(&mut val);
serde_json::to_string_pretty(&val).unwrap()
}
Err(_) => String::from_utf8_lossy(body).to_string(),
};
let snapshot = format_snapshot(status, content_type, &display_body);
let snap_path = base_dir.join(format!("{}.snap", name));
if bless {
fs::create_dir_all(snap_path.parent().unwrap()).unwrap();
fs::write(&snap_path, &snapshot).unwrap();
return;
}
let expected = fs::read_to_string(&snap_path).unwrap_or_else(|_| {
panic!(
"Snapshot '{}' not found at {}. Run with --bless to create it.",
name,
snap_path.display()
)
});
if snapshot != expected {
panic!(
"Snapshot '{}' mismatch!\n\n--- expected ({})\n+++ actual\n\n{}",
name,
snap_path.display(),
line_diff(&expected, &snapshot)
);
}
}
fn reason_phrase(status: u16) -> &'static str {
http::StatusCode::from_u16(status)
.ok()
.and_then(|s| s.canonical_reason())
.unwrap_or("Unknown")
}
fn line_diff(expected: &str, actual: &str) -> String {
let expected_lines: Vec<&str> = expected.lines().collect();
let actual_lines: Vec<&str> = actual.lines().collect();
let mut output = String::new();
let max = expected_lines.len().max(actual_lines.len());
for i in 0..max {
match (expected_lines.get(i), actual_lines.get(i)) {
(Some(e), Some(a)) if e == a => {
output.push_str(&format!(" {}\n", e));
}
(Some(e), Some(a)) => {
output.push_str(&format!("- {}\n", e));
output.push_str(&format!("+ {}\n", a));
}
(Some(e), None) => {
output.push_str(&format!("- {}\n", e));
}
(None, Some(a)) => {
output.push_str(&format!("+ {}\n", a));
}
(None, None) => {}
}
}
output
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_redact_uuid() {
let mut val = json!({"id": "550e8400-e29b-41d4-a716-446655440000"});
redact(&mut val);
assert_eq!(val["id"], "[UUID]");
}
#[test]
fn test_redact_timestamp() {
let mut val = json!({"created_at": "2026-03-14T10:30:00Z"});
redact(&mut val);
assert_eq!(val["created_at"], "[TIMESTAMP]");
}
#[test]
fn test_redact_timestamp_with_fractional_seconds() {
let mut val = json!({"updated_at": "2026-03-14T10:30:00.123456+00:00"});
redact(&mut val);
assert_eq!(val["updated_at"], "[TIMESTAMP]");
}
#[test]
fn test_redact_trace_id_key() {
let mut val = json!({"trace_id": "not-a-uuid-but-still-redacted"});
redact(&mut val);
assert_eq!(val["trace_id"], "[UUID]");
}
#[test]
fn test_redact_nested() {
let mut val = json!({
"user": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "Alice"
},
"items": [
{"created_at": "2026-01-01T00:00:00Z"}
]
});
redact(&mut val);
assert_eq!(val["user"]["id"], "[UUID]");
assert_eq!(val["user"]["name"], "Alice");
assert_eq!(val["items"][0]["created_at"], "[TIMESTAMP]");
}
#[test]
fn test_redact_non_string_untouched() {
let mut val = json!({"count": 42, "active": true, "data": null});
let original = val.clone();
redact(&mut val);
assert_eq!(val, original);
}
#[test]
fn test_redact_no_false_positives() {
let mut val = json!({"name": "hello", "code": "NOT_FOUND", "short": "abc-123"});
let original = val.clone();
redact(&mut val);
assert_eq!(val, original);
}
#[test]
fn test_format_snapshot_json() {
let snap = format_snapshot(200, "application/json", "{\n \"id\": 1\n}");
assert!(snap.starts_with("HTTP 200 OK\n"));
assert!(snap.contains("Content-Type: application/json"));
assert!(snap.contains("\"id\": 1"));
}
#[test]
fn test_format_snapshot_404() {
let snap = format_snapshot(404, "text/plain", "not found");
assert!(snap.starts_with("HTTP 404 Not Found\n"));
assert!(snap.contains("Content-Type: text/plain"));
}
#[test]
fn test_format_snapshot_plain_text() {
let snap = format_snapshot(200, "text/plain", "Hello, world!");
assert!(snap.contains("Content-Type: text/plain"));
}
#[test]
fn test_diff_identical() {
let diff = line_diff("a\nb\nc\n", "a\nb\nc\n");
assert!(!diff.contains("- "));
assert!(!diff.contains("+ "));
}
#[test]
fn test_diff_changed_line() {
let diff = line_diff("a\nold\nc\n", "a\nnew\nc\n");
assert!(diff.contains("- old\n"));
assert!(diff.contains("+ new\n"));
assert!(diff.contains(" a\n"));
assert!(diff.contains(" c\n"));
}
#[test]
fn test_diff_added_line() {
let diff = line_diff("a\n", "a\nb\n");
assert!(diff.contains("+ b\n"));
}
#[test]
fn test_diff_removed_line() {
let diff = line_diff("a\nb\n", "a\n");
assert!(diff.contains("- b\n"));
}
#[test]
fn test_bless_creates_file() {
let dir = tempfile::tempdir().unwrap();
let snap_dir = dir.path().join("snaps");
assert_snapshot_in(
"test_endpoint",
200,
"application/json",
br#"{"name":"Alice","trace_id":"550e8400-e29b-41d4-a716-446655440000"}"#,
&snap_dir,
true,
);
let content = fs::read_to_string(snap_dir.join("test_endpoint.snap")).unwrap();
assert!(content.starts_with("HTTP 200 OK\n"));
assert!(content.contains("[UUID]"));
assert!(!content.contains("550e8400"));
}
#[test]
fn test_compare_passes_on_match() {
let dir = tempfile::tempdir().unwrap();
let snap_dir = dir.path().join("snaps");
assert_snapshot_in(
"match_test",
200,
"application/json",
br#"{"ok":true}"#,
&snap_dir,
true,
);
assert_snapshot_in(
"match_test",
200,
"application/json",
br#"{"ok":true}"#,
&snap_dir,
false,
);
}
#[test]
#[should_panic(expected = "mismatch")]
fn test_compare_panics_on_mismatch() {
let dir = tempfile::tempdir().unwrap();
let snap_dir = dir.path().join("snaps");
assert_snapshot_in(
"mismatch_test",
200,
"application/json",
br#"{"ok":true}"#,
&snap_dir,
true,
);
assert_snapshot_in(
"mismatch_test",
200,
"application/json",
br#"{"ok":false}"#,
&snap_dir,
false,
);
}
#[test]
#[should_panic(expected = "not found")]
fn test_compare_panics_when_missing() {
let dir = tempfile::tempdir().unwrap();
let snap_dir = dir.path().join("snaps");
assert_snapshot_in(
"nonexistent",
200,
"application/json",
br#"{"ok":true}"#,
&snap_dir,
false,
);
}
}