use std::env;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
use httpmock::MockServer;
use httpmock::Recording;
pub const ENV_LIVE: &str = "ANTHROPIC_LIVE";
pub const ENV_RECORD: &str = "ANTHROPIC_RECORD";
pub const ENV_API_KEY: &str = "ANTHROPIC_API_KEY";
pub const ENV_SNAPSHOT_DIR: &str = "ANTHROPIC_SNAPSHOT_DIR";
pub const DEFAULT_UPSTREAM_BASE: &str = "https://api.anthropic.com";
const HEADERS_TO_REDACT: &[&str] = &[
"anthropic-organization-id", "request-id", "cf-ray", ];
#[must_use]
pub fn is_live() -> bool {
env::var(ENV_LIVE).as_deref() == Ok("1")
}
#[must_use]
pub fn is_recording() -> bool {
env::var(ENV_RECORD).as_deref() == Ok("1")
}
#[must_use]
pub fn default_snapshot_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join("snapshots")
}
#[must_use]
pub fn snapshot_dir() -> PathBuf {
env::var(ENV_SNAPSHOT_DIR).map_or_else(|_| default_snapshot_dir(), PathBuf::from)
}
#[must_use]
pub fn recording_path(dir: &Path, name: &str) -> PathBuf {
dir.join(format!("{name}.yaml"))
}
pub struct SnapshotServer {
server: MockServer,
recording_id: Option<usize>,
snapshot_dir: PathBuf,
name: String,
redact_api_key: Option<String>,
}
impl SnapshotServer {
pub async fn start_playback(name: &str) -> Self {
let dir = snapshot_dir();
let path = recording_path(&dir, name);
assert!(
path.exists(),
"Missing snapshot recording: {}\n\
Record it with: ANTHROPIC_LIVE=1 ANTHROPIC_RECORD=1 {}=... cargo test -p anthropic-async {} -- --nocapture",
path.display(),
ENV_API_KEY,
name
);
let server = MockServer::start_async().await;
server.playback(&path);
Self {
server,
recording_id: None,
snapshot_dir: dir,
name: name.to_string(),
redact_api_key: None,
}
}
pub async fn start_live_proxy(
name: &str,
upstream_base: &str,
upstream_api_key: String,
record: bool,
) -> Self {
let dir = snapshot_dir();
let server = MockServer::start_async().await;
let key_clone = upstream_api_key.clone();
server.forward_to(upstream_base, |rule| {
rule.add_request_header("x-api-key", key_clone);
});
let recording_id = if record {
let recording = server.record(|rule| {
rule.record_request_headers(vec![
"content-type",
"anthropic-version",
"anthropic-beta",
]);
});
Some(recording.id)
} else {
None
};
Self {
server,
recording_id,
snapshot_dir: dir,
name: name.to_string(),
redact_api_key: Some(upstream_api_key),
}
}
#[must_use]
pub fn base_url(&self) -> String {
self.server.base_url()
}
fn save_recording(&self) -> Result<(), Box<dyn std::error::Error>> {
let Some(id) = self.recording_id else {
return Ok(());
};
fs::create_dir_all(&self.snapshot_dir)?;
let recording = Recording::new(id, &self.server);
recording.save_to(&self.snapshot_dir, &self.name)?;
let canonical_path = recording_path(&self.snapshot_dir, &self.name);
self.postprocess_recording(&canonical_path)?;
Ok(())
}
fn postprocess_recording(
&self,
canonical_path: &Path,
) -> Result<(), Box<dyn std::error::Error>> {
let Some(key) = self.redact_api_key.as_deref() else {
return Ok(());
};
let entries = fs::read_dir(&self.snapshot_dir)?;
let mut matching_files: Vec<_> = entries
.filter_map(Result::ok)
.filter(|e| {
let path = e.path();
let expected_prefix = format!("{}_", self.name);
let name_matches = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with(&expected_prefix));
let ext_matches = path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("yaml"));
name_matches && ext_matches
})
.collect();
matching_files.sort_by(|a, b| {
b.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH)
.cmp(
&a.metadata()
.and_then(|m| m.modified())
.unwrap_or(std::time::SystemTime::UNIX_EPOCH),
)
});
if let Some(newest) = matching_files.first() {
let newest_path = newest.path();
redact_string_in_file(&newest_path, key, "<redacted>")?;
for header in HEADERS_TO_REDACT {
redact_header_value(&newest_path, header, "<redacted>")?;
}
if newest_path != canonical_path {
fs::rename(&newest_path, canonical_path)?;
}
}
Ok(())
}
}
impl Drop for SnapshotServer {
fn drop(&mut self) {
if self.recording_id.is_none() {
return;
}
if std::thread::panicking() {
eprintln!("Test failed, skipping cassette save for '{}'", self.name);
return;
}
if let Err(e) = self.save_recording() {
eprintln!("Failed to save recording '{}': {e}", self.name);
}
}
}
fn redact_string_in_file(path: &Path, needle: &str, replacement: &str) -> std::io::Result<()> {
let before = fs::read_to_string(path)?;
let after = before.replace(needle, replacement);
if after != before {
fs::write(path, after)?;
}
Ok(())
}
fn redact_header_value(path: &Path, header_name: &str, replacement: &str) -> std::io::Result<()> {
let content = fs::read_to_string(path)?;
let mut lines: Vec<String> = content.lines().map(String::from).collect();
let mut modified = false;
let mut i = 0;
while i < lines.len() {
let is_target_header = {
let trimmed = lines[i].trim();
trimmed.starts_with("- name:")
&& trimmed.strip_prefix("- name:").is_some_and(|rest| {
rest.trim()
.trim_matches(|c| c == '"' || c == '\'')
.eq_ignore_ascii_case(header_name)
})
};
if is_target_header && i + 1 < lines.len() {
if let Some(colon_pos) = lines[i + 1].find("value:") {
let prefix_end = colon_pos + 6; let prefix = &lines[i + 1][..prefix_end];
lines[i + 1] = format!("{prefix} {replacement}");
modified = true;
}
}
i += 1;
}
if modified {
let mut new_content = lines.join("\n");
if content.ends_with('\n') {
new_content.push('\n');
}
fs::write(path, new_content)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_live_default() {
if env::var(ENV_LIVE).is_err() {
assert!(!is_live());
}
}
#[test]
fn test_is_recording_default() {
if env::var(ENV_RECORD).is_err() {
assert!(!is_recording());
}
}
#[test]
fn test_default_snapshot_dir_is_valid() {
let dir = default_snapshot_dir();
assert!(dir.ends_with("tests/snapshots"));
}
#[test]
fn test_recording_path_formats_correctly() {
let dir = PathBuf::from("/tmp/snapshots");
let path = recording_path(&dir, "multi_turn_tool_conversation");
assert_eq!(
path,
PathBuf::from("/tmp/snapshots/multi_turn_tool_conversation.yaml")
);
}
#[tokio::test]
async fn test_playback_server_panics_on_missing_recording() {
let result = std::panic::catch_unwind(|| {
tokio::runtime::Runtime::new()
.unwrap()
.block_on(SnapshotServer::start_playback("nonexistent_test_12345"));
});
assert!(result.is_err(), "Expected panic for missing recording");
}
#[test]
fn test_redact_header_value() {
use std::io::Write;
let mut temp = tempfile::NamedTempFile::new().unwrap();
let yaml_content = r"then:
status: 200
header:
- name: content-type
value: application/json
- name: anthropic-organization-id
value: 74e30b4a-5fab-438b-8fe7-5989330fe3b2
- name: request-id
value: req_011CYTicPYkV5sJsYj1oKGUr
- name: cf-ray
value: 9d328cbcce781376-DFW
";
temp.write_all(yaml_content.as_bytes()).unwrap();
temp.flush().unwrap();
redact_header_value(temp.path(), "anthropic-organization-id", "<redacted>").unwrap();
let result = fs::read_to_string(temp.path()).unwrap();
assert!(result.contains("- name: anthropic-organization-id"));
assert!(result.contains("value: <redacted>"));
assert!(!result.contains("74e30b4a-5fab-438b-8fe7-5989330fe3b2"));
assert!(result.contains("value: application/json"));
assert!(result.contains("value: req_011CYTicPYkV5sJsYj1oKGUr"));
assert!(result.contains("value: 9d328cbcce781376-DFW"));
}
#[test]
fn test_redact_header_value_multiple_occurrences() {
use std::io::Write;
let mut temp = tempfile::NamedTempFile::new().unwrap();
let yaml_content = r"---
then:
header:
- name: anthropic-organization-id
value: first-org-id
---
then:
header:
- name: anthropic-organization-id
value: second-org-id
";
temp.write_all(yaml_content.as_bytes()).unwrap();
temp.flush().unwrap();
redact_header_value(temp.path(), "anthropic-organization-id", "<redacted>").unwrap();
let result = fs::read_to_string(temp.path()).unwrap();
assert!(!result.contains("first-org-id"));
assert!(!result.contains("second-org-id"));
assert_eq!(result.matches("value: <redacted>").count(), 2);
}
#[test]
fn test_redact_header_value_case_insensitive() {
use std::io::Write;
let mut temp = tempfile::NamedTempFile::new().unwrap();
let yaml_content = r#"then:
status: 200
header:
- name: Request-Id
value: req_mixed_case_123
- name: CF-RAY
value: uppercase-cf-ray-456
- name: "Anthropic-Organization-Id"
value: quoted-org-id-789
"#;
temp.write_all(yaml_content.as_bytes()).unwrap();
temp.flush().unwrap();
redact_header_value(temp.path(), "request-id", "<redacted>").unwrap();
redact_header_value(temp.path(), "cf-ray", "<redacted>").unwrap();
redact_header_value(temp.path(), "anthropic-organization-id", "<redacted>").unwrap();
let result = fs::read_to_string(temp.path()).unwrap();
assert!(!result.contains("req_mixed_case_123"));
assert!(!result.contains("uppercase-cf-ray-456"));
assert!(!result.contains("quoted-org-id-789"));
assert_eq!(result.matches("value: <redacted>").count(), 3);
}
}