use std::fs;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::{from_str, to_string_pretty};
use tracing::{info, warn};
use crate::provider::{AgentConfig, AgentOutput, AgentProvider, InvokeFuture};
#[derive(Serialize, Deserialize)]
struct Fixture {
config: AgentConfig,
output: AgentOutput,
}
pub fn hash_config(config: &AgentConfig) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(config.prompt.as_bytes());
if let Some(ref sp) = config.system_prompt {
hasher.update(b"|sp:");
hasher.update(sp.as_bytes());
}
hasher.update(b"|m:");
hasher.update(config.model.to_string().as_bytes());
if !config.allowed_tools.is_empty() {
hasher.update(b"|at:");
hasher.update(config.allowed_tools.join(",").as_bytes());
}
if let Some(ref schema) = config.json_schema {
hasher.update(b"|js:");
hasher.update(schema.as_bytes());
}
if let Some(ref session_id) = config.resume_session_id {
hasher.update(b"|rs:");
hasher.update(session_id.as_bytes());
}
let result = hasher.finalize();
format!(
"{:016x}",
u64::from_be_bytes(result[..8].try_into().unwrap())
)
}
pub struct RecordReplayProvider<P: AgentProvider> {
inner: P,
fixtures_dir: PathBuf,
recording: bool,
}
impl<P: AgentProvider> RecordReplayProvider<P> {
pub fn new(inner: P, fixtures_dir: &str) -> Self {
let recording = std::env::var("IRONFLOW_RECORD").is_ok();
if recording && let Err(e) = fs::create_dir_all(fixtures_dir) {
warn!(path = %fixtures_dir, error = %e, "failed to create fixtures directory - recordings will fail");
}
Self {
inner,
fixtures_dir: PathBuf::from(fixtures_dir),
recording,
}
}
pub fn replay(inner: P, fixtures_dir: &str) -> Self {
Self {
inner,
fixtures_dir: PathBuf::from(fixtures_dir),
recording: false,
}
}
pub fn record(inner: P, fixtures_dir: &str) -> Self {
fs::create_dir_all(fixtures_dir).ok();
Self {
inner,
fixtures_dir: PathBuf::from(fixtures_dir),
recording: true,
}
}
fn fixture_path(&self, config: &AgentConfig) -> PathBuf {
let hash = hash_config(config);
self.fixtures_dir.join(format!("{hash}.json"))
}
fn load_fixture(&self, path: &Path) -> Option<AgentOutput> {
let content = fs::read_to_string(path).ok()?;
let fixture: Fixture = from_str(&content).ok()?;
Some(fixture.output)
}
fn save_fixture(&self, path: &Path, config: &AgentConfig, output: &AgentOutput) {
let fixture = Fixture {
config: config.clone(),
output: output.clone(),
};
if let Ok(json) = to_string_pretty(&fixture)
&& let Err(e) = fs::write(path, json)
{
warn!(path = %path.display(), error = %e, "failed to save fixture");
}
}
}
impl<P: AgentProvider> AgentProvider for RecordReplayProvider<P> {
fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a> {
Box::pin(async move {
let path = self.fixture_path(config);
if !self.recording {
if let Some(output) = self.load_fixture(&path) {
info!(fixture = %path.display(), "replaying from fixture");
return Ok(output);
}
warn!(fixture = %path.display(), "fixture not found, calling real provider");
}
let output = self.inner.invoke(config).await?;
if self.recording {
self.save_fixture(&path, config, &output);
info!(fixture = %path.display(), "recorded fixture");
}
Ok(output)
})
}
}
#[cfg(test)]
mod tests {
use std::process;
use std::sync::atomic::{AtomicU64, Ordering};
use serde_json::json;
use super::*;
use crate::operations::agent::Model;
use crate::providers::claude::ClaudeCodeProvider;
struct TempDirGuard(String);
impl Drop for TempDirGuard {
fn drop(&mut self) {
fs::remove_dir_all(&self.0).ok();
}
}
fn temp_fixtures_dir() -> (String, TempDirGuard) {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let dir = format!(
"/tmp/ironflow-test-rr-{}-{}",
process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed),
);
let guard = TempDirGuard(dir.clone());
(dir, guard)
}
fn replay_provider(fixtures_dir: &str) -> RecordReplayProvider<ClaudeCodeProvider> {
RecordReplayProvider::replay(ClaudeCodeProvider::new(), fixtures_dir)
}
fn record_provider(fixtures_dir: &str) -> RecordReplayProvider<ClaudeCodeProvider> {
RecordReplayProvider::record(ClaudeCodeProvider::new(), fixtures_dir)
}
fn write_fixture(fixtures_dir: &str, config: &AgentConfig, output: &AgentOutput) {
fs::create_dir_all(fixtures_dir).unwrap();
let hash = hash_config(config);
let fixture = Fixture {
config: config.clone(),
output: output.clone(),
};
let json = serde_json::to_string_pretty(&fixture).unwrap();
fs::write(format!("{}/{}.json", fixtures_dir, hash), json).unwrap();
}
fn sample_output() -> AgentOutput {
AgentOutput {
value: json!("Rust is a systems programming language"),
session_id: None,
cost_usd: Some(0.01),
input_tokens: Some(10),
output_tokens: Some(50),
model: Some("claude-sonnet".to_string()),
duration_ms: 100,
debug_messages: None,
}
}
#[test]
fn test_hash_config_deterministic() {
let config = AgentConfig::new("What is Rust?");
let hash1 = hash_config(&config);
let hash2 = hash_config(&config);
assert_eq!(hash1, hash2, "same config should produce same hash");
}
#[test]
fn test_hash_config_different_prompts() {
let config1 = AgentConfig::new("Prompt A");
let config2 = AgentConfig::new("Prompt B");
assert_ne!(hash_config(&config1), hash_config(&config2));
}
#[test]
fn test_hash_config_system_prompt_affects_hash() {
let mut config1 = AgentConfig::new("Analyze this code");
let mut config2 = AgentConfig::new("Analyze this code");
config1.system_prompt = Some("You are a Rust expert".to_string());
config2.system_prompt = Some("You are a Python expert".to_string());
assert_ne!(hash_config(&config1), hash_config(&config2));
}
#[test]
fn test_hash_config_no_system_prompt_vs_with_system_prompt() {
let config1 = AgentConfig::new("Analyze this code");
let mut config2 = AgentConfig::new("Analyze this code");
config2.system_prompt = Some("You are a Rust expert".to_string());
assert_ne!(hash_config(&config1), hash_config(&config2));
}
#[test]
fn test_hash_config_model_affects_hash() {
let mut config1 = AgentConfig::new("What is Rust?");
let mut config2 = AgentConfig::new("What is Rust?");
config1.model = Model::SONNET.to_string();
config2.model = Model::OPUS.to_string();
assert_ne!(hash_config(&config1), hash_config(&config2));
}
#[test]
fn test_hash_config_allowed_tools_affects_hash() {
let mut config1 = AgentConfig::new("Write code");
let mut config2 = AgentConfig::new("Write code");
config1.allowed_tools = vec!["Read".to_string(), "Write".to_string()];
config2.allowed_tools = vec!["Read".to_string()];
assert_ne!(hash_config(&config1), hash_config(&config2));
}
#[test]
fn test_hash_config_json_schema_affects_hash() {
let mut config1 = AgentConfig::new("Extract data");
let mut config2 = AgentConfig::new("Extract data");
config1.json_schema = Some(r#"{"type": "object"}"#.to_string());
config2.json_schema = Some(r#"{"type": "array"}"#.to_string());
assert_ne!(hash_config(&config1), hash_config(&config2));
}
#[test]
fn test_hash_config_resume_session_id_affects_hash() {
let mut config1 = AgentConfig::new("Continue conversation");
let mut config2 = AgentConfig::new("Continue conversation");
config1.resume_session_id = Some("session-123".to_string());
config2.resume_session_id = Some("session-456".to_string());
assert_ne!(hash_config(&config1), hash_config(&config2));
}
#[test]
fn test_hash_config_format() {
let config = AgentConfig::new("Test");
let hash = hash_config(&config);
assert_eq!(hash.len(), 16, "hash should be 16 hex characters");
assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_multiple_configs_produce_unique_hashes() {
let configs = [
AgentConfig::new("Prompt 1"),
AgentConfig::new("Prompt 2"),
AgentConfig::new("Prompt 3"),
];
let hashes: Vec<String> = configs.iter().map(hash_config).collect();
for i in 0..hashes.len() {
for j in (i + 1)..hashes.len() {
assert_ne!(hashes[i], hashes[j], "hashes should be unique");
}
}
}
#[tokio::test]
async fn test_replay_returns_fixture_when_present() {
let (dir, _guard) = temp_fixtures_dir();
let config = AgentConfig::new("What is Rust?");
let expected = sample_output();
write_fixture(&dir, &config, &expected);
let provider = replay_provider(&dir);
let result = provider.invoke(&config).await.unwrap();
assert_eq!(result.value, expected.value);
assert_eq!(result.cost_usd, expected.cost_usd);
assert_eq!(result.input_tokens, expected.input_tokens);
}
#[test]
fn test_fixture_path_uses_hash_config() {
let (dir, _guard) = temp_fixtures_dir();
let config = AgentConfig::new("Test prompt");
let provider = replay_provider(&dir);
let path = provider.fixture_path(&config);
let expected = format!("{}/{}.json", dir, hash_config(&config));
assert_eq!(path.to_string_lossy().to_string(), expected);
}
#[test]
fn test_fixture_path_different_configs_different_files() {
let (dir, _guard) = temp_fixtures_dir();
let provider = replay_provider(&dir);
let path1 = provider.fixture_path(&AgentConfig::new("Prompt A"));
let path2 = provider.fixture_path(&AgentConfig::new("Prompt B"));
assert_ne!(path1, path2);
}
#[test]
fn test_load_fixture_returns_none_when_file_missing() {
let (dir, _guard) = temp_fixtures_dir();
let provider = replay_provider(&dir);
let result = provider.load_fixture(Path::new("/tmp/nonexistent-xyz.json"));
assert!(result.is_none());
}
#[test]
fn test_load_fixture_returns_none_when_file_malformed() {
let (dir, _guard) = temp_fixtures_dir();
fs::create_dir_all(&dir).unwrap();
let path = format!("{}/malformed.json", dir);
fs::write(&path, "{ invalid json }").unwrap();
let provider = replay_provider(&dir);
assert!(provider.load_fixture(Path::new(&path)).is_none());
}
#[test]
fn test_load_fixture_extracts_output_from_valid_fixture() {
let (dir, _guard) = temp_fixtures_dir();
let config = AgentConfig::new("Test");
let expected = sample_output();
write_fixture(&dir, &config, &expected);
let provider = replay_provider(&dir);
let hash = hash_config(&config);
let path = format!("{}/{}.json", dir, hash);
let loaded = provider.load_fixture(Path::new(&path)).unwrap();
assert_eq!(loaded.value, expected.value);
assert_eq!(loaded.cost_usd, expected.cost_usd);
}
#[test]
fn test_save_fixture_creates_valid_json_file() {
let (dir, _guard) = temp_fixtures_dir();
fs::create_dir_all(&dir).unwrap();
let config = AgentConfig::new("Save test");
let output = AgentOutput::new(json!({"key": "value", "nested": {"count": 42}}));
let provider = replay_provider(&dir);
let path = provider.fixture_path(&config);
provider.save_fixture(&path, &config, &output);
assert!(path.exists());
let saved_json = fs::read_to_string(&path).unwrap();
let fixture: Fixture = serde_json::from_str(&saved_json).unwrap();
assert_eq!(fixture.config.prompt, config.prompt);
assert_eq!(fixture.output.value, output.value);
}
#[test]
fn test_save_then_load_roundtrip() {
let (dir, _guard) = temp_fixtures_dir();
fs::create_dir_all(&dir).unwrap();
let config = AgentConfig::new("Roundtrip");
let output = AgentOutput {
value: json!("ownership is key"),
session_id: Some("sess-42".to_string()),
cost_usd: Some(0.05),
input_tokens: Some(20),
output_tokens: Some(100),
model: Some("claude-sonnet".to_string()),
duration_ms: 500,
debug_messages: None,
};
let provider = replay_provider(&dir);
let path = provider.fixture_path(&config);
provider.save_fixture(&path, &config, &output);
let loaded = provider.load_fixture(&path).unwrap();
assert_eq!(loaded.value, output.value);
assert_eq!(loaded.session_id, output.session_id);
assert_eq!(loaded.cost_usd, output.cost_usd);
assert_eq!(loaded.input_tokens, output.input_tokens);
assert_eq!(loaded.output_tokens, output.output_tokens);
}
#[test]
fn test_empty_prompt_hash() {
let config = AgentConfig::new("");
let hash = hash_config(&config);
assert_eq!(hash.len(), 16);
}
#[test]
fn test_unicode_prompt_roundtrip() {
let (dir, _guard) = temp_fixtures_dir();
fs::create_dir_all(&dir).unwrap();
let config = AgentConfig::new("Explain: 你好世界 🌍 Здравствуй мир");
let output = AgentOutput::new(json!("Unicode response"));
let provider = replay_provider(&dir);
let path = provider.fixture_path(&config);
provider.save_fixture(&path, &config, &output);
let loaded = provider.load_fixture(&path).unwrap();
assert_eq!(loaded.value, output.value);
}
#[test]
fn test_new_defaults_to_replay_mode() {
let provider = RecordReplayProvider::new(ClaudeCodeProvider::new(), "/tmp/doesnt-matter");
assert!(!provider.recording);
}
#[test]
fn test_record_mode_flag() {
let (dir, _guard) = temp_fixtures_dir();
let provider = record_provider(&dir);
assert!(provider.recording);
}
#[test]
fn test_replay_mode_flag() {
let (dir, _guard) = temp_fixtures_dir();
let provider = replay_provider(&dir);
assert!(!provider.recording);
}
}