1use std::collections::HashMap;
19use std::fs;
20use std::path::{Path, PathBuf};
21
22use async_trait::async_trait;
23use serde::{Deserialize, Serialize};
24
25use crate::adapter::{blake3_hex, LlmAdapter, LlmError, LlmRequest, LlmResponse, TokenUsage};
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct IndexEntry {
30 pub path: String,
34 pub blake3: String,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize, Default)]
40pub struct FixtureIndex {
41 #[serde(default, rename = "fixture")]
43 pub fixtures: Vec<IndexEntry>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct FixtureMatch {
49 pub model: String,
51 pub prompt_hash: String,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct FixtureResponse {
58 pub text: String,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
62 pub parsed_json: Option<serde_json::Value>,
63 #[serde(default, skip_serializing_if = "Option::is_none")]
66 pub model: Option<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub usage: Option<TokenUsage>,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct FixtureFile {
75 pub request_match: FixtureMatch,
77 pub response: FixtureResponse,
79}
80
81#[derive(Debug)]
85pub struct ReplayAdapter {
86 fixtures_dir: PathBuf,
87 by_key: HashMap<(String, String), FixtureFile>,
88}
89
90impl ReplayAdapter {
91 pub fn new<P: Into<PathBuf>>(fixtures_dir: P) -> Result<Self, LlmError> {
102 let fixtures_dir = fixtures_dir.into();
103 let canonical_root = fs::canonicalize(&fixtures_dir)
104 .map_err(|e| LlmError::Io(format!("fixtures dir {}: {e}", fixtures_dir.display())))?;
105
106 let index_path = canonical_root.join("INDEX.toml");
107 let index_text = fs::read_to_string(&index_path).map_err(|e| {
108 LlmError::FixtureIntegrityFailed(format!(
109 "INDEX.toml not readable at {}: {e}",
110 index_path.display()
111 ))
112 })?;
113 let index: FixtureIndex = toml::from_str(&index_text).map_err(|e| {
114 LlmError::FixtureIntegrityFailed(format!("INDEX.toml parse error: {e}"))
115 })?;
116
117 let mut trusted: HashMap<PathBuf, String> = HashMap::new();
120 for entry in &index.fixtures {
121 let resolved = resolve_under(&canonical_root, &entry.path)?;
122 if trusted
123 .insert(resolved.clone(), entry.blake3.clone())
124 .is_some()
125 {
126 return Err(LlmError::FixtureIntegrityFailed(format!(
127 "duplicate INDEX entry: {}",
128 entry.path
129 )));
130 }
131 }
132
133 for dirent in fs::read_dir(&canonical_root)
138 .map_err(|e| LlmError::Io(format!("scanning {}: {e}", canonical_root.display())))?
139 {
140 let dirent = dirent.map_err(|e| LlmError::Io(format!("dirent: {e}")))?;
141 let path = dirent.path();
142 if !path.is_file() {
143 continue;
144 }
145 let name = path
146 .file_name()
147 .and_then(|s| s.to_str())
148 .unwrap_or_default();
149 if name == "INDEX.toml" || name == "schema.json" {
150 continue;
151 }
152 if !name.ends_with(".json") {
153 continue;
154 }
155 if !trusted.contains_key(&path) {
156 return Err(LlmError::FixtureIntegrityFailed(format!(
157 "unsigned fixture present (not in INDEX.toml): {}",
158 path.display()
159 )));
160 }
161 }
162
163 let mut by_key: HashMap<(String, String), FixtureFile> = HashMap::new();
165 for (path, expected_hash) in trusted {
166 let bytes = fs::read(&path).map_err(|e| {
167 LlmError::FixtureIntegrityFailed(format!("read {}: {e}", path.display()))
168 })?;
169 let actual = blake3_hex(&bytes);
170 if !constant_time_eq(actual.as_bytes(), expected_hash.as_bytes()) {
171 return Err(LlmError::FixtureIntegrityFailed(format!(
172 "hash mismatch for {} (expected {expected_hash}, got {actual})",
173 path.display()
174 )));
175 }
176 let fixture: FixtureFile = serde_json::from_slice(&bytes).map_err(|e| {
177 LlmError::FixtureIntegrityFailed(format!("fixture {} parse: {e}", path.display()))
178 })?;
179 let key = (
180 fixture.request_match.model.clone(),
181 fixture.request_match.prompt_hash.clone(),
182 );
183 if by_key.insert(key, fixture).is_some() {
184 return Err(LlmError::FixtureIntegrityFailed(format!(
185 "duplicate (model, prompt_hash) match in fixtures dir {}",
186 canonical_root.display()
187 )));
188 }
189 }
190
191 Ok(Self {
192 fixtures_dir: canonical_root,
193 by_key,
194 })
195 }
196
197 #[must_use]
199 pub fn fixtures_dir(&self) -> &Path {
200 &self.fixtures_dir
201 }
202
203 #[must_use]
205 pub fn fixture_count(&self) -> usize {
206 self.by_key.len()
207 }
208}
209
210#[async_trait]
211impl LlmAdapter for ReplayAdapter {
212 fn adapter_id(&self) -> &'static str {
213 "replay"
214 }
215
216 async fn complete(&self, req: LlmRequest) -> Result<LlmResponse, LlmError> {
217 let prompt_hash = req.prompt_hash();
218 let key = (req.model.clone(), prompt_hash.clone());
219 let Some(fixture) = self.by_key.get(&key) else {
220 return Err(LlmError::NoFixture {
221 model: req.model,
222 prompt_hash,
223 });
224 };
225
226 let text = fixture.response.text.clone();
227 Ok(LlmResponse {
228 text: text.clone(),
229 parsed_json: fixture.response.parsed_json.clone(),
230 model: fixture
231 .response
232 .model
233 .clone()
234 .unwrap_or_else(|| req.model.clone()),
235 usage: fixture.response.usage.clone(),
236 raw_hash: blake3_hex(text.as_bytes()),
237 })
238 }
239}
240
241fn resolve_under(root: &Path, relative: &str) -> Result<PathBuf, LlmError> {
244 let candidate = root.join(relative);
245 if candidate
246 .components()
247 .any(|c| matches!(c, std::path::Component::ParentDir))
248 {
249 return Err(LlmError::FixtureIntegrityFailed(format!(
250 "fixture path escapes fixtures dir: {relative}"
251 )));
252 }
253 let canonical = fs::canonicalize(&candidate).map_err(|e| {
254 LlmError::FixtureIntegrityFailed(format!(
255 "fixture path {} not resolvable: {e}",
256 candidate.display()
257 ))
258 })?;
259 if !canonical.starts_with(root) {
260 return Err(LlmError::FixtureIntegrityFailed(format!(
261 "fixture path {} escapes fixtures dir {}",
262 canonical.display(),
263 root.display()
264 )));
265 }
266 Ok(canonical)
267}
268
269fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
272 if a.len() != b.len() {
273 return false;
274 }
275 let mut diff: u8 = 0;
276 for (x, y) in a.iter().zip(b.iter()) {
277 diff |= x ^ y;
278 }
279 diff == 0
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285 use crate::adapter::{LlmMessage, LlmRole};
286 use std::fs::File;
287 use std::io::Write;
288 use tempfile::TempDir;
289
290 fn sample_request(content: &str) -> LlmRequest {
291 LlmRequest {
292 model: "claude-3-5-sonnet-20240620".into(),
293 system: "you are a test".into(),
294 messages: vec![LlmMessage {
295 role: LlmRole::User,
296 content: content.to_string(),
297 }],
298 temperature: 0.0,
299 max_tokens: 256,
300 json_schema: None,
301 timeout_ms: 30_000,
302 }
303 }
304
305 fn write_fixture(
309 dir: &Path,
310 name: &str,
311 req: &LlmRequest,
312 reply: &str,
313 ) -> (PathBuf, FixtureFile) {
314 let fixture = FixtureFile {
315 request_match: FixtureMatch {
316 model: req.model.clone(),
317 prompt_hash: req.prompt_hash(),
318 },
319 response: FixtureResponse {
320 text: reply.into(),
321 parsed_json: None,
322 model: None,
323 usage: None,
324 },
325 };
326 let path = dir.join(name);
327 let bytes = serde_json::to_vec_pretty(&fixture).unwrap();
328 let mut f = File::create(&path).unwrap();
329 f.write_all(&bytes).unwrap();
330 (path, fixture)
331 }
332
333 fn write_index(dir: &Path, entries: &[(&str, &str)]) {
334 let mut s = String::new();
335 for (path, hash) in entries {
336 s.push_str(&format!(
337 "[[fixture]]\npath = \"{path}\"\nblake3 = \"{hash}\"\n\n"
338 ));
339 }
340 fs::write(dir.join("INDEX.toml"), s).unwrap();
341 }
342
343 fn hash_file(p: &Path) -> String {
344 blake3_hex(&fs::read(p).unwrap())
345 }
346
347 #[tokio::test]
348 async fn replay_returns_matching_fixture() {
349 let tmp = TempDir::new().unwrap();
350 let dir = tmp.path();
351
352 let req_a = sample_request("hello");
353 let req_b = sample_request("world");
354 let (path_a, _) = write_fixture(dir, "a.json", &req_a, "hi from A");
355 let (path_b, _) = write_fixture(dir, "b.json", &req_b, "hi from B");
356 write_index(
357 dir,
358 &[
359 ("a.json", &hash_file(&path_a)),
360 ("b.json", &hash_file(&path_b)),
361 ],
362 );
363
364 let adapter = ReplayAdapter::new(dir).unwrap();
365 assert_eq!(adapter.fixture_count(), 2);
366 let resp = adapter.complete(req_b).await.unwrap();
367 assert_eq!(resp.text, "hi from B");
368 assert_eq!(resp.model, "claude-3-5-sonnet-20240620");
369 }
370
371 #[tokio::test]
372 async fn replay_rejects_unsigned_fixture() {
373 let tmp = TempDir::new().unwrap();
374 let dir = tmp.path();
375
376 let req = sample_request("hello");
377 let (path, _) = write_fixture(dir, "a.json", &req, "ok");
378 let (_path_b, _) = write_fixture(dir, "b-unsigned.json", &sample_request("world"), "boom");
380 write_index(dir, &[("a.json", &hash_file(&path))]);
382
383 let err = ReplayAdapter::new(dir).unwrap_err();
384 match err {
385 LlmError::FixtureIntegrityFailed(msg) => {
386 assert!(
387 msg.contains("unsigned fixture present"),
388 "unexpected message: {msg}"
389 );
390 }
391 other => panic!("expected FixtureIntegrityFailed, got {other:?}"),
392 }
393 }
394
395 #[tokio::test]
396 async fn replay_rejects_hash_mismatch() {
397 let tmp = TempDir::new().unwrap();
398 let dir = tmp.path();
399
400 let req = sample_request("hello");
401 let (path, _) = write_fixture(dir, "a.json", &req, "ok");
402 let original_hash = hash_file(&path);
403 write_index(dir, &[("a.json", &original_hash)]);
404
405 let mut bytes = fs::read(&path).unwrap();
407 let last = bytes.len() - 1;
409 bytes[last] = bytes[last].wrapping_add(1);
410 fs::write(&path, bytes).unwrap();
411
412 let err = ReplayAdapter::new(dir).unwrap_err();
413 match err {
414 LlmError::FixtureIntegrityFailed(msg) => {
415 assert!(msg.contains("hash mismatch"), "unexpected message: {msg}");
416 }
417 other => panic!("expected FixtureIntegrityFailed, got {other:?}"),
418 }
419 }
420
421 #[tokio::test]
422 async fn replay_returns_no_fixture_when_unmatched() {
423 let tmp = TempDir::new().unwrap();
424 let dir = tmp.path();
425
426 let req = sample_request("hello");
427 let (path, _) = write_fixture(dir, "a.json", &req, "ok");
428 write_index(dir, &[("a.json", &hash_file(&path))]);
429
430 let adapter = ReplayAdapter::new(dir).unwrap();
431 let other = sample_request("not in any fixture");
432 let err = adapter.complete(other).await.unwrap_err();
433 assert!(matches!(err, LlmError::NoFixture { .. }));
434 }
435
436 #[test]
437 fn missing_index_is_integrity_failure() {
438 let tmp = TempDir::new().unwrap();
439 let err = ReplayAdapter::new(tmp.path()).unwrap_err();
440 assert!(matches!(err, LlmError::FixtureIntegrityFailed(_)));
441 }
442}