use std::fs;
use std::path::{Path, PathBuf};
use super::http::{HttpClient, HttpError};
pub const DEFAULT_CACHE_DIR: &str = "data/translation-cache";
pub struct CachedHttpClient<T: HttpClient> {
cache_dir: PathBuf,
transport: T,
online: bool,
}
impl<T: HttpClient> CachedHttpClient<T> {
pub fn new(cache_dir: impl Into<PathBuf>, transport: T) -> Self {
Self {
cache_dir: cache_dir.into(),
transport,
online: live_api_enabled(),
}
}
#[must_use]
#[allow(dead_code)]
pub const fn with_online(mut self, online: bool) -> Self {
self.online = online;
self
}
#[must_use]
#[allow(dead_code)]
pub fn cache_dir(&self) -> &Path {
&self.cache_dir
}
#[must_use]
#[allow(dead_code)]
pub const fn is_online(&self) -> bool {
self.online
}
fn cache_paths(&self, url: &str) -> (PathBuf, PathBuf) {
let key = cache_key(url);
let mut body = self.cache_dir.clone();
body.push(format!("{key}.body"));
let mut meta = self.cache_dir.clone();
meta.push(format!("{key}.url"));
(body, meta)
}
}
#[must_use]
pub fn cache_key(url: &str) -> String {
let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
for byte in url.bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01B3);
}
format!("{hash:016x}")
}
fn live_api_enabled() -> bool {
std::env::var("FORMAL_AI_LIVE_API").is_ok_and(|value| {
matches!(
value.trim().to_ascii_lowercase().as_str(),
"1" | "true" | "yes" | "on"
)
})
}
impl<T: HttpClient> HttpClient for CachedHttpClient<T> {
fn get(&self, url: &str) -> Result<String, HttpError> {
let (body_path, meta_path) = self.cache_paths(url);
if let Ok(body) = fs::read_to_string(&body_path) {
return Ok(body);
}
if !self.online {
return Err(HttpError::Transport(format!(
"translation cache miss for {url} and offline mode is active; \
set FORMAL_AI_LIVE_API=1 to fetch and populate the cache",
)));
}
let body = self.transport.get(url)?;
if let Err(error) = fs::create_dir_all(&self.cache_dir) {
return Err(HttpError::Transport(format!(
"failed to create cache directory {}: {error}",
self.cache_dir.display(),
)));
}
if let Err(error) = fs::write(&body_path, &body) {
return Err(HttpError::Transport(format!(
"failed to write cache body {}: {error}",
body_path.display(),
)));
}
if let Err(error) = fs::write(&meta_path, url) {
return Err(HttpError::Transport(format!(
"failed to write cache url marker {}: {error}",
meta_path.display(),
)));
}
Ok(body)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
struct StubHttp {
responses: Mutex<HashMap<String, String>>,
calls: Mutex<Vec<String>>,
}
impl StubHttp {
fn new(responses: &[(&str, &str)]) -> Self {
Self {
responses: Mutex::new(
responses
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect(),
),
calls: Mutex::new(Vec::new()),
}
}
}
impl HttpClient for StubHttp {
fn get(&self, url: &str) -> Result<String, HttpError> {
self.calls.lock().unwrap().push(url.to_owned());
self.responses
.lock()
.unwrap()
.get(url)
.cloned()
.ok_or_else(|| HttpError::Status {
status: 404,
body: format!("stub had no response for {url}"),
})
}
}
fn temp_dir(slug: &str) -> PathBuf {
let mut dir = std::env::temp_dir();
dir.push(format!(
"formal-ai-cache-{slug}-{}",
std::process::id() ^ rand_u32()
));
let _ = fs::create_dir_all(&dir);
dir
}
fn rand_u32() -> u32 {
use std::time::{SystemTime, UNIX_EPOCH};
u32::try_from(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| u128::from(d.subsec_nanos())),
)
.unwrap_or(0)
}
#[test]
fn cache_key_is_stable_across_runs() {
let one = cache_key("https://example.com/foo");
let two = cache_key("https://example.com/foo");
assert_eq!(one, two);
let other = cache_key("https://example.com/bar");
assert_ne!(one, other);
}
#[test]
fn cache_hit_short_circuits_transport() {
let dir = temp_dir("hit");
let cache = CachedHttpClient::new(&dir, StubHttp::new(&[])).with_online(false);
let url = "https://example.com/cached";
let (body_path, meta_path) = cache.cache_paths(url);
fs::create_dir_all(&dir).unwrap();
fs::write(&body_path, "cached body").unwrap();
fs::write(&meta_path, url).unwrap();
assert_eq!(cache.get(url).unwrap(), "cached body");
}
#[test]
fn cache_miss_offline_returns_transport_error() {
let dir = temp_dir("offline-miss");
let cache = CachedHttpClient::new(&dir, StubHttp::new(&[])).with_online(false);
let error = cache.get("https://example.com/missing").unwrap_err();
match error {
HttpError::Transport(message) => {
assert!(message.contains("cache miss"), "got: {message}");
assert!(message.contains("FORMAL_AI_LIVE_API"), "got: {message}");
}
other @ HttpError::Status { .. } => {
panic!("expected Transport error, got {other:?}")
}
}
}
#[test]
fn cache_miss_online_populates_and_returns_body() {
let dir = temp_dir("online-miss");
let url = "https://example.com/foo";
let stub = StubHttp::new(&[(url, "fetched body")]);
let cache = CachedHttpClient::new(&dir, stub).with_online(true);
assert_eq!(cache.get(url).unwrap(), "fetched body");
let again = CachedHttpClient::new(&dir, StubHttp::new(&[])).with_online(false);
assert_eq!(again.get(url).unwrap(), "fetched body");
}
#[test]
fn cache_paths_use_fnv_keyed_filenames() {
let dir = PathBuf::from("/tmp/whatever");
let cache = CachedHttpClient::new(&dir, StubHttp::new(&[])).with_online(false);
let (body, meta) = cache.cache_paths("https://example.com/x");
assert!(body.to_string_lossy().ends_with(".body"));
assert!(meta.to_string_lossy().ends_with(".url"));
}
}