Skip to main content

agi4_adapters/
lib.rs

1//! Upstream source adapters for AGI/4 evidence ingestion.
2//!
3//! Each upstream benchmark source implements the Source trait for pure parsing
4//! and evidence conversion. The Fetcher trait handles I/O (HTTP, file, in-memory).
5//! Adapters are testable in isolation against frozen JSON fixtures.
6
7pub mod apex_agents;
8pub mod arc_prize;
9pub mod gdpval;
10pub mod gpqa_diamond;
11pub mod hle;
12pub mod metr;
13pub mod osworld;
14pub mod re_bench;
15pub mod rli;
16pub mod swe_bench;
17
18use agi4_core::evidence::{Evidence, SourceId};
19use parking_lot::Mutex;
20use serde::de::DeserializeOwned;
21use sha2::{Digest, Sha256};
22use std::collections::HashMap;
23use std::error::Error;
24use std::fmt;
25use std::fs;
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28use url::Url;
29
30/// Model identifier for evidence ingestion.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct ModelId(pub String);
33
34impl ModelId {
35    /// Create a new model identifier.
36    pub fn new(id: impl Into<String>) -> Self {
37        Self(id.into())
38    }
39
40    /// Get the model ID as a string.
41    pub fn as_str(&self) -> &str {
42        &self.0
43    }
44}
45
46/// Error kind for source adaptation operations.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum AdapterErrorKind {
49    /// Error occurred during JSON parsing or schema deserialization.
50    Parse,
51    /// Error occurred during validation of parsed data.
52    Validation,
53}
54
55/// Error type for source adaptation operations.
56#[derive(Debug, Clone)]
57pub struct AdapterError {
58    source_id: String,
59    kind: AdapterErrorKind,
60    message: String,
61}
62
63impl AdapterError {
64    /// Create a new adapter error.
65    pub fn new(source_id: impl Into<String>, message: impl Into<String>) -> Self {
66        Self::with_kind(source_id, AdapterErrorKind::Validation, message)
67    }
68
69    /// Create an adapter parse error.
70    pub fn parse(source_id: impl Into<String>, message: impl Into<String>) -> Self {
71        Self::with_kind(source_id, AdapterErrorKind::Parse, message)
72    }
73
74    /// Create an adapter validation error.
75    pub fn validation(source_id: impl Into<String>, message: impl Into<String>) -> Self {
76        Self::with_kind(source_id, AdapterErrorKind::Validation, message)
77    }
78
79    /// Create an adapter error with explicit kind.
80    pub fn with_kind(
81        source_id: impl Into<String>,
82        kind: AdapterErrorKind,
83        message: impl Into<String>,
84    ) -> Self {
85        Self {
86            source_id: source_id.into(),
87            kind,
88            message: message.into(),
89        }
90    }
91
92    /// Get the error message.
93    pub fn message(&self) -> &str {
94        &self.message
95    }
96
97    /// Get the error kind.
98    pub fn kind(&self) -> AdapterErrorKind {
99        self.kind
100    }
101}
102
103impl fmt::Display for AdapterError {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        write!(f, "adapter error [{}]: {}", self.source_id, self.message)
106    }
107}
108
109impl Error for AdapterError {}
110
111/// The Source trait: each upstream source implements this.
112///
113/// Sources are pure: they parse and convert without performing I/O.
114/// This allows adapters to be tested against frozen fixtures without network access.
115pub trait Source {
116    /// The native schema for this source's upstream data.
117    type Raw: DeserializeOwned;
118
119    /// Error type for parse/to_evidence failures.
120    type Error: Error + Send + Sync + 'static;
121
122    /// Stable identifier for this source (e.g., "arc-agi-3").
123    fn id(&self) -> SourceId;
124
125    /// The URL or endpoint this source ingests from.
126    fn endpoint(&self) -> &Url;
127
128    /// Parse raw upstream data (JSON string) into the typed schema.
129    /// Fails closed on any malformed input.
130    fn parse(&self, raw: &str) -> Result<Self::Raw, Self::Error>;
131
132    /// Convert validated raw data into agi4-core Evidence values.
133    /// One source may produce evidence for multiple conjuncts
134    /// (e.g., ARC-AGI-3 contributes to both Generality and EnvironmentalTransfer).
135    fn to_evidence(&self, raw: Self::Raw, model: &ModelId) -> Result<Vec<Evidence>, Self::Error>;
136}
137
138/// Fetcher abstraction for I/O.
139///
140/// Implementations handle HTTP, file, in-memory, or other fetch strategies.
141/// Injected at the CLI layer; adapters use pure Source trait, never Fetcher directly.
142pub trait Fetcher {
143    /// Error type for fetch failures.
144    type Error: Error + Send + Sync + 'static;
145
146    /// Fetch raw data from a URL. Returns the response body as a string.
147    fn fetch(&self, url: &Url) -> Result<String, Self::Error>;
148}
149
150/// In-memory test fetcher for fixture-based testing.
151///
152/// Stores frozen upstream data snapshots. Useful for unit testing adapters
153/// without network access. Does not perform any I/O; all data is pre-loaded.
154#[derive(Debug, Clone)]
155pub struct InMemoryFetcher {
156    data: HashMap<String, String>,
157}
158
159impl InMemoryFetcher {
160    /// Create a new empty in-memory fetcher.
161    pub fn new() -> Self {
162        Self {
163            data: HashMap::new(),
164        }
165    }
166
167    /// Insert fixture data for a URL.
168    pub fn insert(&mut self, url: impl Into<String>, data: impl Into<String>) {
169        self.data.insert(url.into(), data.into());
170    }
171
172    /// Insert multiple fixture entries at once.
173    pub fn with_data(mut self, entries: Vec<(String, String)>) -> Self {
174        for (url, data) in entries {
175            self.data.insert(url, data);
176        }
177        self
178    }
179}
180
181impl Default for InMemoryFetcher {
182    fn default() -> Self {
183        Self::new()
184    }
185}
186
187/// Error for in-memory fetcher (URL not found in fixtures).
188#[derive(Debug, Clone)]
189pub struct InMemoryFetcherError {
190    url: String,
191}
192
193impl fmt::Display for InMemoryFetcherError {
194    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
195        write!(f, "fixture not found for URL: {}", self.url)
196    }
197}
198
199impl Error for InMemoryFetcherError {}
200
201impl Fetcher for InMemoryFetcher {
202    type Error = InMemoryFetcherError;
203
204    fn fetch(&self, url: &Url) -> Result<String, Self::Error> {
205        self.data
206            .get(url.as_str())
207            .cloned()
208            .ok_or_else(|| InMemoryFetcherError {
209                url: url.to_string(),
210            })
211    }
212}
213
214/// HTTP fetcher with timeout and exponential backoff retry (blocking).
215#[derive(Clone)]
216pub struct HttpFetcher {
217    /// Request timeout in seconds.
218    timeout_secs: u64,
219    /// Maximum number of retry attempts.
220    max_retries: u32,
221}
222
223/// Error for HTTP fetcher operations.
224#[derive(Debug, Clone)]
225pub struct HttpFetcherError {
226    url: String,
227    message: String,
228}
229
230impl fmt::Display for HttpFetcherError {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        write!(f, "HTTP fetch failed for {}: {}", self.url, self.message)
233    }
234}
235
236impl Error for HttpFetcherError {}
237
238impl HttpFetcher {
239    /// Create a new HTTP fetcher with default timeout (30s) and retries (3).
240    pub fn new() -> Self {
241        Self {
242            timeout_secs: 30,
243            max_retries: 3,
244        }
245    }
246
247    /// Create an HTTP fetcher with custom timeout and retries.
248    pub fn with_config(timeout_secs: u64, max_retries: u32) -> Self {
249        Self {
250            timeout_secs,
251            max_retries,
252        }
253    }
254}
255
256impl Default for HttpFetcher {
257    fn default() -> Self {
258        Self::new()
259    }
260}
261
262impl Fetcher for HttpFetcher {
263    type Error = HttpFetcherError;
264
265    fn fetch(&self, url: &Url) -> Result<String, Self::Error> {
266        let client = reqwest::blocking::Client::builder()
267            .timeout(std::time::Duration::from_secs(self.timeout_secs))
268            .build()
269            .map_err(|e| HttpFetcherError {
270                url: url.to_string(),
271                message: format!("failed to create HTTP client: {}", e),
272            })?;
273
274        for attempt in 0..=self.max_retries {
275            match client.get(url.as_str()).send() {
276                Ok(response) => {
277                    return response.text().map_err(|e| HttpFetcherError {
278                        url: url.to_string(),
279                        message: format!("failed to read response: {}", e),
280                    });
281                }
282                Err(e) => {
283                    if attempt < self.max_retries {
284                        let backoff_ms = 100u64 * (2u64.pow(attempt));
285                        std::thread::sleep(std::time::Duration::from_millis(backoff_ms));
286                        continue;
287                    }
288                    return Err(HttpFetcherError {
289                        url: url.to_string(),
290                        message: format!(
291                            "request failed after {} retries: {}",
292                            self.max_retries, e
293                        ),
294                    });
295                }
296            }
297        }
298
299        Err(HttpFetcherError {
300            url: url.to_string(),
301            message: "all retries exhausted".to_string(),
302        })
303    }
304}
305
306/// Error for caching fetcher operations.
307#[derive(Debug, Clone)]
308pub struct CachingFetcherError {
309    url: String,
310    message: String,
311}
312
313impl fmt::Display for CachingFetcherError {
314    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
315        write!(f, "caching fetch failed for {}: {}", self.url, self.message)
316    }
317}
318
319impl Error for CachingFetcherError {}
320
321/// Caching fetcher with local filesystem storage and TTL.
322///
323/// Wraps HttpFetcher and caches responses on disk. Uses URL hash as cache key.
324/// Implements concurrent deduplication via per-URL locks: only one HTTP request
325/// per unique URL even under concurrent access. Writes are atomic (temp file + rename)
326/// to avoid torn cache entries. Safe for concurrent use across threads.
327pub struct CachingFetcher {
328    http_fetcher: HttpFetcher,
329    cache_dir: PathBuf,
330    cache_ttl_secs: u64,
331    // Per-URL locks to deduplicate concurrent fetches and ensure atomic writes.
332    // Maps URL hash to a mutex protecting that URL's cache entry.
333    url_locks: Mutex<HashMap<String, Arc<Mutex<()>>>>,
334}
335
336impl CachingFetcher {
337    /// Create a new caching fetcher with default HTTP config and cache settings.
338    /// Cache directory defaults to `~/.cache/agi4/` with 24-hour TTL.
339    pub fn new() -> Result<Self, CachingFetcherError> {
340        let cache_dir =
341            dirs::cache_dir()
342                .map(|d| d.join("agi4"))
343                .ok_or_else(|| CachingFetcherError {
344                    url: "cache_dir".to_string(),
345                    message: "could not determine cache directory".to_string(),
346                })?;
347
348        fs::create_dir_all(&cache_dir).map_err(|e| CachingFetcherError {
349            url: "cache_dir".to_string(),
350            message: format!("failed to create cache directory: {}", e),
351        })?;
352
353        Ok(Self {
354            http_fetcher: HttpFetcher::new(),
355            cache_dir,
356            cache_ttl_secs: 86400, // 24 hours
357            url_locks: Mutex::new(HashMap::new()),
358        })
359    }
360
361    /// Create a caching fetcher with custom HTTP config and cache directory.
362    pub fn with_config(
363        http_fetcher: HttpFetcher,
364        cache_dir: PathBuf,
365        cache_ttl_secs: u64,
366    ) -> Result<Self, CachingFetcherError> {
367        fs::create_dir_all(&cache_dir).map_err(|e| CachingFetcherError {
368            url: "cache_dir".to_string(),
369            message: format!("failed to create cache directory: {}", e),
370        })?;
371
372        Ok(Self {
373            http_fetcher,
374            cache_dir,
375            cache_ttl_secs,
376            url_locks: Mutex::new(HashMap::new()),
377        })
378    }
379
380    /// Get cache file path for a given URL.
381    fn cache_path(&self, url: &Url) -> PathBuf {
382        let mut hasher = Sha256::new();
383        hasher.update(url.as_str().as_bytes());
384        let hash = format!("{:x}", hasher.finalize());
385        self.cache_dir.join(hash)
386    }
387
388    /// Check if cache entry exists and is still valid (not expired).
389    fn is_cache_valid(&self, cache_path: &Path) -> bool {
390        if !cache_path.exists() {
391            return false;
392        }
393
394        if let Ok(metadata) = fs::metadata(cache_path)
395            && let Ok(modified) = metadata.modified()
396            && let Ok(elapsed) = modified.elapsed()
397        {
398            return elapsed.as_secs() < self.cache_ttl_secs;
399        }
400
401        false
402    }
403
404    /// Try to read from cache; return None if invalid or missing.
405    fn read_cache(&self, cache_path: &Path) -> Option<String> {
406        if self.is_cache_valid(cache_path) {
407            fs::read_to_string(cache_path).ok()
408        } else {
409            None
410        }
411    }
412
413    /// Write data to cache file atomically (temp file + rename).
414    /// Graceful fallback on error; cache is optional but should not corrupt on concurrent access.
415    fn write_cache(&self, cache_path: &Path, data: &str) {
416        // Write to a temporary file in the same directory
417        let temp_path = {
418            let mut temp = cache_path.to_path_buf();
419            let filename = match temp.file_name() {
420                Some(name) => name.to_string_lossy().to_string(),
421                None => {
422                    // If no filename component (e.g., path is ".."), skip cache write
423                    return;
424                }
425            };
426            temp.pop();
427            temp.push(format!(".{}.tmp", filename));
428            temp
429        };
430
431        // Write to temp file, then atomically rename
432        if fs::write(&temp_path, data).is_ok() {
433            let _ = fs::rename(&temp_path, cache_path);
434        }
435    }
436
437    /// Get or create a per-URL lock for coordinating concurrent access to the same URL.
438    fn get_url_lock(&self, url: &Url) -> Arc<Mutex<()>> {
439        let url_hash = {
440            let mut hasher = Sha256::new();
441            hasher.update(url.as_str().as_bytes());
442            format!("{:x}", hasher.finalize())
443        };
444
445        let mut locks = self.url_locks.lock();
446        locks
447            .entry(url_hash)
448            .or_insert_with(|| Arc::new(Mutex::new(())))
449            .clone()
450    }
451}
452
453impl Fetcher for CachingFetcher {
454    type Error = CachingFetcherError;
455
456    fn fetch(&self, url: &Url) -> Result<String, Self::Error> {
457        let cache_path = self.cache_path(url);
458
459        // Try cache first (quick path, no lock needed)
460        if let Some(data) = self.read_cache(&cache_path) {
461            return Ok(data);
462        }
463
464        // Cache miss or expired: acquire per-URL lock for deduplication.
465        // Only one HTTP request per unique URL even under concurrent access.
466        let url_lock = self.get_url_lock(url);
467        let _lock_guard = url_lock.lock();
468
469        // Double-check cache inside lock (another thread may have filled it while we waited)
470        if let Some(data) = self.read_cache(&cache_path) {
471            return Ok(data);
472        }
473
474        // Still a miss: fetch from upstream and update cache atomically
475        let data = self
476            .http_fetcher
477            .fetch(url)
478            .map_err(|e| CachingFetcherError {
479                url: url.to_string(),
480                message: format!("HTTP fetch failed: {}", e),
481            })?;
482
483        self.write_cache(&cache_path, &data);
484        Ok(data)
485    }
486}
487
488// Re-export all public adapter types for convenience
489pub use apex_agents::ApexAgentsAdapter;
490pub use arc_prize::ArcPrizeAdapter;
491pub use gdpval::GdpvalAdapter;
492pub use gpqa_diamond::GpqaDiamondAdapter;
493pub use hle::HleAdapter;
494pub use metr::MetrAdapter;
495pub use osworld::OsworldAdapter;
496pub use re_bench::ReBenchAdapter;
497pub use rli::RliAdapter;
498pub use swe_bench::SweBenchAdapter;
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503    use std::time::SystemTime;
504
505    #[test]
506    fn model_id_new_and_as_str() {
507        let model = ModelId::new("example-model-v1");
508        assert_eq!(model.as_str(), "example-model-v1");
509    }
510
511    #[test]
512    fn model_id_from_string() {
513        let s = "test-model".to_string();
514        let model = ModelId::new(s);
515        assert_eq!(model.as_str(), "test-model");
516    }
517
518    #[test]
519    fn model_id_equality() {
520        let m1 = ModelId::new("model-a");
521        let m2 = ModelId::new("model-a");
522        let m3 = ModelId::new("model-b");
523        assert_eq!(m1, m2);
524        assert_ne!(m1, m3);
525    }
526
527    #[test]
528    fn adapter_error_new_and_message() {
529        let err = AdapterError::new("arc-agi-3", "failed to parse JSON");
530        assert_eq!(err.message(), "failed to parse JSON");
531        assert!(err.to_string().contains("arc-agi-3"));
532    }
533
534    #[test]
535    fn adapter_error_display() {
536        let err = AdapterError::new("metr", "network timeout");
537        let display_str = err.to_string();
538        assert!(display_str.contains("metr"));
539        assert!(display_str.contains("network timeout"));
540    }
541
542    #[test]
543    fn in_memory_fetcher_new() {
544        let fetcher = InMemoryFetcher::new();
545        assert!(matches!(
546            fetcher.fetch(&Url::parse("http://example.com").unwrap()),
547            Err(InMemoryFetcherError { .. })
548        ));
549    }
550
551    #[test]
552    fn in_memory_fetcher_default() {
553        let fetcher = InMemoryFetcher::default();
554        assert!(matches!(
555            fetcher.fetch(&Url::parse("http://example.com").unwrap()),
556            Err(InMemoryFetcherError { .. })
557        ));
558    }
559
560    #[test]
561    fn in_memory_fetcher_insert_and_fetch() {
562        let mut fetcher = InMemoryFetcher::new();
563        let url = "http://example.com/data.json";
564        let data = r#"{"value": 42}"#;
565        fetcher.insert(url, data);
566
567        let result = fetcher
568            .fetch(&Url::parse(url).unwrap())
569            .expect("should fetch inserted data");
570        assert_eq!(result, data);
571    }
572
573    #[test]
574    fn in_memory_fetcher_with_data() {
575        let entries = vec![
576            ("http://arc.org/data".to_string(), "arc data".to_string()),
577            ("http://metr.org/data".to_string(), "metr data".to_string()),
578        ];
579        let fetcher = InMemoryFetcher::new().with_data(entries);
580
581        let url1 = Url::parse("http://arc.org/data").unwrap();
582        let result1 = fetcher.fetch(&url1).expect("should fetch arc data");
583        assert_eq!(result1, "arc data");
584
585        let url2 = Url::parse("http://metr.org/data").unwrap();
586        let result2 = fetcher.fetch(&url2).expect("should fetch metr data");
587        assert_eq!(result2, "metr data");
588    }
589
590    #[test]
591    fn in_memory_fetcher_missing_url_error() {
592        let fetcher = InMemoryFetcher::new();
593        let url = Url::parse("http://nonexistent.com/data").unwrap();
594        let err = fetcher
595            .fetch(&url)
596            .expect_err("should error for missing URL");
597        assert!(err.to_string().contains("nonexistent.com"));
598    }
599
600    #[test]
601    fn in_memory_fetcher_clone() {
602        let mut fetcher1 = InMemoryFetcher::new();
603        fetcher1.insert("http://test.com/data", "test data");
604
605        let fetcher2 = fetcher1.clone();
606        let url = Url::parse("http://test.com/data").unwrap();
607        let result = fetcher2.fetch(&url).expect("clone should have data");
608        assert_eq!(result, "test data");
609    }
610
611    #[test]
612    fn in_memory_fetcher_multiple_urls() {
613        let mut fetcher = InMemoryFetcher::new();
614        fetcher.insert("http://source-a.com/api", "data-a");
615        fetcher.insert("http://source-b.com/api", "data-b");
616        fetcher.insert("http://source-c.com/api", "data-c");
617
618        let url_a = Url::parse("http://source-a.com/api").unwrap();
619        assert_eq!(fetcher.fetch(&url_a).unwrap(), "data-a");
620
621        let url_b = Url::parse("http://source-b.com/api").unwrap();
622        assert_eq!(fetcher.fetch(&url_b).unwrap(), "data-b");
623
624        let url_c = Url::parse("http://source-c.com/api").unwrap();
625        assert_eq!(fetcher.fetch(&url_c).unwrap(), "data-c");
626    }
627
628    #[test]
629    fn in_memory_fetcher_error_is_send_sync() {
630        fn assert_send_sync<T: Send + Sync>() {}
631        assert_send_sync::<InMemoryFetcherError>();
632    }
633
634    #[test]
635    fn in_memory_fetcher_is_send_sync() {
636        fn assert_send_sync<T: Send + Sync>() {}
637        assert_send_sync::<InMemoryFetcher>();
638    }
639
640    #[test]
641    fn http_fetcher_new() {
642        let fetcher = HttpFetcher::new();
643        assert_eq!(fetcher.timeout_secs, 30);
644        assert_eq!(fetcher.max_retries, 3);
645    }
646
647    #[test]
648    fn http_fetcher_default() {
649        let fetcher = HttpFetcher::default();
650        assert_eq!(fetcher.timeout_secs, 30);
651        assert_eq!(fetcher.max_retries, 3);
652    }
653
654    #[test]
655    fn http_fetcher_with_config() {
656        let fetcher = HttpFetcher::with_config(60, 5);
657        assert_eq!(fetcher.timeout_secs, 60);
658        assert_eq!(fetcher.max_retries, 5);
659    }
660
661    #[test]
662    fn http_fetcher_error_display() {
663        let err = HttpFetcherError {
664            url: "https://example.com".to_string(),
665            message: "connection refused".to_string(),
666        };
667        assert!(err.to_string().contains("example.com"));
668        assert!(err.to_string().contains("connection refused"));
669    }
670
671    #[test]
672    fn http_fetcher_invalid_url() {
673        let fetcher = HttpFetcher::new();
674        let invalid_url =
675            Url::parse("https://invalid-nonexistent-domain-12345.local").expect("URL should parse");
676        let result = fetcher.fetch(&invalid_url);
677        assert!(result.is_err());
678    }
679
680    #[test]
681    fn http_fetcher_is_send_sync() {
682        fn assert_send_sync<T: Send + Sync>() {}
683        assert_send_sync::<HttpFetcher>();
684    }
685
686    #[test]
687    fn caching_fetcher_new() {
688        let fetcher = CachingFetcher::new();
689        assert!(fetcher.is_ok());
690        let cf = fetcher.unwrap();
691        assert!(cf.cache_dir.ends_with("agi4"));
692    }
693
694    #[test]
695    fn caching_fetcher_default() {
696        let fetcher = CachingFetcher::new().expect("create fetcher");
697        assert!(fetcher.cache_dir.ends_with("agi4"));
698        assert_eq!(fetcher.cache_ttl_secs, 86400);
699    }
700
701    #[test]
702    fn caching_fetcher_cache_path() {
703        let fetcher = CachingFetcher::new().expect("create fetcher");
704        let url1 = Url::parse("http://example.com/data").unwrap();
705        let url2 = Url::parse("http://example.com/data").unwrap();
706        let url3 = Url::parse("http://other.com/data").unwrap();
707
708        let path1 = fetcher.cache_path(&url1);
709        let path2 = fetcher.cache_path(&url2);
710        let path3 = fetcher.cache_path(&url3);
711
712        // Same URL should produce same cache path
713        assert_eq!(path1, path2);
714        // Different URL should produce different cache path
715        assert_ne!(path1, path3);
716    }
717
718    #[test]
719    fn caching_fetcher_is_cache_valid_missing() {
720        let fetcher = CachingFetcher::new().expect("create fetcher");
721        let nonexistent = fetcher.cache_dir.join("nonexistent-cache-entry");
722        assert!(!fetcher.is_cache_valid(&nonexistent));
723    }
724
725    #[test]
726    fn caching_fetcher_is_cache_valid_expired() {
727        let fetcher = CachingFetcher::new().expect("create fetcher");
728        let temp_cache = fetcher.cache_dir.join("temp-cache-entry");
729
730        // Create a cache file
731        fs::write(&temp_cache, "cached data").expect("write cache");
732
733        // Manually set file modification time to far in the past
734        let past = SystemTime::now() - std::time::Duration::from_secs(200000);
735        filetime::set_file_mtime(&temp_cache, past.into()).expect("set mtime");
736
737        // Cache should be considered invalid (expired)
738        assert!(!fetcher.is_cache_valid(&temp_cache));
739
740        // Clean up
741        let _ = fs::remove_file(temp_cache);
742    }
743
744    #[test]
745    fn caching_fetcher_read_write_cache() {
746        let fetcher = CachingFetcher::new().expect("create fetcher");
747        let test_cache = fetcher.cache_dir.join("test-read-write");
748
749        let test_data = "test cached content";
750        fetcher.write_cache(&test_cache, test_data);
751
752        let read_data = fetcher.read_cache(&test_cache);
753        assert_eq!(read_data, Some(test_data.to_string()));
754
755        // Clean up
756        let _ = fs::remove_file(test_cache);
757    }
758
759    #[test]
760    fn caching_fetcher_read_cache_invalid() {
761        let fetcher = CachingFetcher::new().expect("create fetcher");
762        let expired_cache = fetcher.cache_dir.join("expired-cache");
763
764        // Create and manually expire the cache file
765        fs::write(&expired_cache, "old data").expect("write cache");
766        let past = SystemTime::now() - std::time::Duration::from_secs(200000);
767        filetime::set_file_mtime(&expired_cache, past.into()).expect("set mtime");
768
769        // Should return None due to expiration
770        let data = fetcher.read_cache(&expired_cache);
771        assert_eq!(data, None);
772
773        // Clean up
774        let _ = fs::remove_file(expired_cache);
775    }
776
777    #[test]
778    fn caching_fetcher_with_config() {
779        let temp_dir = std::env::temp_dir().join("agi4-test-cache");
780        let http_fetcher = HttpFetcher::with_config(60, 5);
781
782        let result = CachingFetcher::with_config(http_fetcher, temp_dir.clone(), 3600);
783        assert!(result.is_ok());
784
785        let cf = result.unwrap();
786        assert_eq!(cf.cache_ttl_secs, 3600);
787
788        // Clean up
789        let _ = fs::remove_dir_all(&temp_dir);
790    }
791
792    #[test]
793    fn caching_fetcher_in_memory_fetch_with_mock() {
794        // Use in-memory fetcher instead of HTTP for testing
795        let mut in_memory = InMemoryFetcher::new();
796        in_memory.insert("http://test.local/api", r#"{"test": "data"}"#);
797
798        let url = Url::parse("http://test.local/api").unwrap();
799        let result = in_memory.fetch(&url);
800        assert_eq!(result.unwrap(), r#"{"test": "data"}"#);
801    }
802
803    #[test]
804    fn caching_fetcher_error_display() {
805        let err = CachingFetcherError {
806            url: "https://example.com".to_string(),
807            message: "cache write failed".to_string(),
808        };
809        let display = err.to_string();
810        assert!(display.contains("example.com"));
811        assert!(display.contains("cache write failed"));
812    }
813
814    #[test]
815    fn caching_fetcher_is_send_sync() {
816        fn assert_send_sync<T: Send + Sync>() {}
817        assert_send_sync::<CachingFetcher>();
818    }
819
820    #[test]
821    fn swe_bench_uses_canonical_source_id() {
822        // Verify SWE-Bench adapter uses canonical source ID constant, not hardcoded string.
823        // This prevents silent evidence routing failures due to ID mismatches (task 2.17).
824        let adapter =
825            swe_bench::SweBenchAdapter::new().expect("SWE-bench adapter should initialize");
826        assert_eq!(
827            adapter.id().as_str(),
828            "swe-bench-verified",
829            "SWE-bench adapter must return canonical 'swe-bench-verified' ID"
830        );
831    }
832
833    #[test]
834    fn caching_fetcher_concurrent_access_no_panic() {
835        use std::sync::Arc as StdArc;
836        use std::thread;
837
838        // Verify that concurrent access to CachingFetcher doesn't panic or corrupt data.
839        // This tests that the per-URL locking mechanism is thread-safe.
840        let temp_dir = std::env::temp_dir().join("agi4-test-concurrent");
841        let _ = fs::remove_dir_all(&temp_dir);
842        fs::create_dir_all(&temp_dir).unwrap();
843
844        let cache_fetcher = CachingFetcher::new().expect("should create cache fetcher");
845        let cache_fetcher_arc = StdArc::new(cache_fetcher);
846
847        // Simulate concurrent cache operations by spawning threads that access the locks
848        let mut handles = vec![];
849        for _ in 0..5 {
850            let cf = cache_fetcher_arc.clone();
851            let handle = thread::spawn(move || {
852                let url = Url::parse("http://test.example.com/data").unwrap();
853                // Don't actually fetch (would fail), just exercise the lock mechanism
854                let _lock = cf.get_url_lock(&url);
855                // Lock is held here, proving it's thread-safe and Mutex works
856                true
857            });
858            handles.push(handle);
859        }
860
861        // Wait for all threads
862        for handle in handles {
863            let result = handle.join().expect("thread should not panic");
864            assert!(result, "lock acquisition should succeed");
865        }
866
867        let _ = fs::remove_dir_all(&temp_dir);
868    }
869
870    #[test]
871    fn caching_fetcher_atomic_writes_no_tmp_files() {
872        // Verify that writes are atomic: temp file is renamed, not left as orphan.
873        let temp_dir = std::env::temp_dir().join("agi4-test-atomic-write");
874        let _ = fs::remove_dir_all(&temp_dir);
875        fs::create_dir_all(&temp_dir).unwrap();
876
877        let cache_fetcher = CachingFetcher::new().expect("should create cache fetcher");
878        let test_path = temp_dir.join("test-cache-file");
879
880        // Call write_cache directly to test atomic behavior
881        cache_fetcher.write_cache(&test_path, "test data");
882
883        // Verify:
884        // 1. Cache file exists
885        assert!(
886            test_path.exists(),
887            "cache file should exist after write_cache"
888        );
889
890        // 2. Cache content is correct
891        let content = fs::read_to_string(&test_path).expect("should read cache");
892        assert_eq!(content, "test data", "cache should contain correct data");
893
894        // 3. No temp file remains (atomic rename completed)
895        let tmp_path = temp_dir.join(".test-cache-file.tmp");
896        assert!(
897            !tmp_path.exists(),
898            "temp file should not remain after atomic write"
899        );
900
901        let _ = fs::remove_dir_all(&temp_dir);
902    }
903
904    #[test]
905    fn adapter_raw_structs_deny_unknown_fields() {
906        // Verify that all adapter raw structs have deny_unknown_fields enabled.
907        // This enforces Parse-Don't-Validate: unknown upstream schema changes fail
908        // immediately rather than being silently ignored.
909
910        // Test METR adapter rejects unknown fields
911        let valid_metr = r#"{"value": 168.0}"#;
912        assert!(
913            serde_json::from_str::<metr::MetrRaw>(valid_metr).is_ok(),
914            "valid METR data should parse"
915        );
916
917        let invalid_metr = r#"{"value": 168.0, "unknown_field": "should_fail"}"#;
918        assert!(
919            serde_json::from_str::<metr::MetrRaw>(invalid_metr).is_err(),
920            "METR data with unknown field should be rejected"
921        );
922
923        // Test ARC Prize adapter rejects unknown fields in nested structs
924        let valid_arc = r#"{"arc_agi_2": {"pass_rate": 0.85}, "arc_agi_3": {"pass_rate": 0.90}}"#;
925        assert!(
926            serde_json::from_str::<arc_prize::ArcPrizeRaw>(valid_arc).is_ok(),
927            "valid ARC Prize data should parse"
928        );
929
930        let invalid_arc_top = r#"{"arc_agi_2": {"pass_rate": 0.85}, "arc_agi_3": {"pass_rate": 0.90}, "extra_field": "fail"}"#;
931        assert!(
932            serde_json::from_str::<arc_prize::ArcPrizeRaw>(invalid_arc_top).is_err(),
933            "ARC Prize data with unknown top-level field should be rejected"
934        );
935
936        let invalid_arc_nested = r#"{"arc_agi_2": {"pass_rate": 0.85, "extra": "fail"}, "arc_agi_3": {"pass_rate": 0.90}}"#;
937        assert!(
938            serde_json::from_str::<arc_prize::ArcPrizeRaw>(invalid_arc_nested).is_err(),
939            "ARC Prize data with unknown nested field should be rejected"
940        );
941
942        // Test HLE adapter rejects unknown fields
943        let valid_hle = r#"{"overall_accuracy": 0.75}"#;
944        assert!(
945            serde_json::from_str::<hle::HleRaw>(valid_hle).is_ok(),
946            "valid HLE data should parse"
947        );
948
949        let invalid_hle = r#"{"overall_accuracy": 0.75, "noise": "fail"}"#;
950        assert!(
951            serde_json::from_str::<hle::HleRaw>(invalid_hle).is_err(),
952            "HLE data with unknown field should be rejected"
953        );
954    }
955}