use crate::config::Provider;
use crate::error::TalkError;
use chrono::{DateTime, Duration, Utc};
use fs2::FileExt;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs::OpenOptions;
use std::path::PathBuf;
use std::sync::{Mutex, OnceLock};
const TTL: Duration = Duration::hours(24);
const CACHE_FILE_NAME: &str = "validate-cache.yaml";
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
struct Key {
provider: String,
model: String,
api_base: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Entry {
provider: String,
model: String,
api_base: String,
validated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
struct CacheFile {
entries: Vec<Entry>,
}
static IN_PROCESS: OnceLock<Mutex<HashMap<Key, DateTime<Utc>>>> = OnceLock::new();
static LAST_DISK_MTIME: OnceLock<Mutex<Option<std::time::SystemTime>>> = OnceLock::new();
fn in_process() -> &'static Mutex<HashMap<Key, DateTime<Utc>>> {
IN_PROCESS.get_or_init(|| Mutex::new(HashMap::new()))
}
fn last_disk_mtime() -> &'static Mutex<Option<std::time::SystemTime>> {
LAST_DISK_MTIME.get_or_init(|| Mutex::new(None))
}
const CACHE_PATH_ENV: &str = "TALK_RS_VALIDATE_CACHE_PATH";
fn cache_path() -> Result<PathBuf, TalkError> {
if let Some(p) = std::env::var_os(CACHE_PATH_ENV) {
return Ok(PathBuf::from(p));
}
Ok(crate::daemon::cache_dir()?.join(CACHE_FILE_NAME))
}
fn lock_path(cache: &std::path::Path) -> PathBuf {
let mut p = cache.as_os_str().to_owned();
p.push(".lock");
PathBuf::from(p)
}
fn read_disk_unconditional() -> Option<Vec<Entry>> {
let path = cache_path().ok()?;
let bytes = std::fs::read(&path).ok()?;
let file: CacheFile = serde_yaml::from_slice(&bytes).ok()?;
Some(file.entries)
}
fn read_disk_if_changed() -> Option<Vec<Entry>> {
let path = cache_path().ok()?;
let metadata = std::fs::metadata(&path).ok()?;
let current_mtime = metadata.modified().ok()?;
let mtime_changed = {
let last = last_disk_mtime().lock().ok()?;
last.map(|m| m != current_mtime).unwrap_or(true)
};
if !mtime_changed {
return None; }
let bytes = std::fs::read(&path).ok()?;
let file: CacheFile = serde_yaml::from_slice(&bytes).ok()?;
if let Ok(mut last) = last_disk_mtime().lock() {
*last = Some(current_mtime);
}
Some(file.entries)
}
fn refresh_in_process_from_disk() {
let Some(entries) = read_disk_if_changed() else {
return;
};
let Ok(mut map) = in_process().lock() else {
return;
};
for e in entries {
let key = Key {
provider: e.provider,
model: e.model,
api_base: e.api_base,
};
map.insert(key, e.validated_at);
}
}
pub(crate) fn is_fresh(provider: Provider, model: &str, api_base: &str) -> bool {
refresh_in_process_from_disk();
let key = Key {
provider: provider.to_string(),
model: model.to_string(),
api_base: api_base.to_string(),
};
let Ok(map) = in_process().lock() else {
return false;
};
let Some(&validated_at) = map.get(&key) else {
return false;
};
Utc::now().signed_duration_since(validated_at) < TTL
}
pub(crate) fn record(provider: Provider, model: &str, api_base: &str) {
let key = Key {
provider: provider.to_string(),
model: model.to_string(),
api_base: api_base.to_string(),
};
let now = Utc::now();
if let Ok(mut map) = in_process().lock() {
map.insert(key, now);
}
if let Err(e) = persist_to_disk() {
log::warn!("validate-cache: failed to persist to disk: {}", e);
}
}
fn persist_to_disk() -> Result<(), TalkError> {
let path = cache_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
TalkError::Config(format!(
"validate-cache: failed to create cache dir {}: {}",
parent.display(),
e
))
})?;
}
let lock_file_path = lock_path(&path);
let lock_file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&lock_file_path)
.map_err(|e| {
TalkError::Config(format!(
"validate-cache: failed to open lock file {}: {}",
lock_file_path.display(),
e
))
})?;
lock_file.lock_exclusive().map_err(|e| {
TalkError::Config(format!(
"validate-cache: failed to acquire exclusive flock on {}: {}",
lock_file_path.display(),
e
))
})?;
let _lock_guard = LockGuard(&lock_file);
if let Some(disk_entries) = read_disk_unconditional() {
if let Ok(mut map) = in_process().lock() {
for e in disk_entries {
let key = Key {
provider: e.provider,
model: e.model,
api_base: e.api_base,
};
match map.get(&key).copied() {
None => {
map.insert(key, e.validated_at);
}
Some(existing) if e.validated_at > existing => {
map.insert(key, e.validated_at);
}
_ => {} }
}
}
}
let entries: Vec<Entry> = match in_process().lock() {
Ok(map) => map
.iter()
.map(|(k, &t)| Entry {
provider: k.provider.clone(),
model: k.model.clone(),
api_base: k.api_base.clone(),
validated_at: t,
})
.collect(),
Err(_) => {
return Err(TalkError::Config(
"validate-cache: in-process map poisoned".to_string(),
));
}
};
let file = CacheFile { entries };
let yaml = serde_yaml::to_string(&file).map_err(|e| {
TalkError::Config(format!("validate-cache: failed to serialise YAML: {}", e))
})?;
let tmp_path = path.with_extension("yaml.tmp");
std::fs::write(&tmp_path, yaml.as_bytes()).map_err(|e| {
TalkError::Config(format!(
"validate-cache: failed to write tempfile {}: {}",
tmp_path.display(),
e
))
})?;
std::fs::rename(&tmp_path, &path).map_err(|e| {
TalkError::Config(format!(
"validate-cache: failed to rename {} -> {}: {}",
tmp_path.display(),
path.display(),
e
))
})?;
if let Ok(metadata) = std::fs::metadata(&path) {
if let Ok(mtime) = metadata.modified() {
if let Ok(mut last) = last_disk_mtime().lock() {
*last = Some(mtime);
}
}
}
Ok(())
}
struct LockGuard<'a>(&'a std::fs::File);
impl Drop for LockGuard<'_> {
fn drop(&mut self) {
let _ = fs2::FileExt::unlock(self.0);
}
}
#[cfg(test)]
pub(crate) static __TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
pub(crate) fn __test_reset() {
if let Some(map) = IN_PROCESS.get() {
if let Ok(mut m) = map.lock() {
m.clear();
}
}
if let Some(mtime) = LAST_DISK_MTIME.get() {
if let Ok(mut t) = mtime.lock() {
*t = None;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn with_temp_home<F: FnOnce()>(f: F) {
let _guard = __TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
__test_reset();
let tmp = tempfile::TempDir::new().expect("test: tempdir");
let cache_file = tmp.path().join("validate-cache.yaml");
let prev = std::env::var_os(CACHE_PATH_ENV);
unsafe {
std::env::set_var(CACHE_PATH_ENV, &cache_file);
}
f();
unsafe {
match prev {
Some(v) => std::env::set_var(CACHE_PATH_ENV, v),
None => std::env::remove_var(CACHE_PATH_ENV),
}
}
__test_reset();
}
#[test]
fn record_then_is_fresh_returns_true() {
with_temp_home(|| {
assert!(
!is_fresh(Provider::Mistral, "voxtral", "https://x"),
"missing entry must not be fresh"
);
record(Provider::Mistral, "voxtral", "https://x");
assert!(
is_fresh(Provider::Mistral, "voxtral", "https://x"),
"just-recorded entry must be fresh"
);
});
}
#[test]
fn cache_distinguishes_api_base() {
with_temp_home(|| {
record(Provider::Mistral, "voxtral", "https://api.mistral.ai");
assert!(is_fresh(
Provider::Mistral,
"voxtral",
"https://api.mistral.ai"
));
assert!(
!is_fresh(Provider::Mistral, "voxtral", "https://staging.mistral.ai"),
"different api_base must be a separate entry"
);
});
}
#[test]
fn cache_distinguishes_model() {
with_temp_home(|| {
record(Provider::Mistral, "voxtral-mini-2602", "https://x");
assert!(is_fresh(
Provider::Mistral,
"voxtral-mini-2602",
"https://x"
));
assert!(!is_fresh(
Provider::Mistral,
"voxtral-mini-2507",
"https://x"
));
});
}
#[test]
fn stale_entry_reports_not_fresh() {
with_temp_home(|| {
let stale_time = Utc::now() - Duration::hours(25);
let key = Key {
provider: Provider::Mistral.to_string(),
model: "voxtral".into(),
api_base: "https://x".into(),
};
in_process()
.lock()
.expect("test: lock")
.insert(key, stale_time);
assert!(
!is_fresh(Provider::Mistral, "voxtral", "https://x"),
"entry older than 24h must not be fresh"
);
});
}
#[test]
fn disk_persistence_survives_in_process_clear() {
with_temp_home(|| {
record(Provider::Mistral, "voxtral", "https://x");
if let Ok(mut map) = in_process().lock() {
map.clear();
}
if let Ok(mut t) = last_disk_mtime().lock() {
*t = None; }
assert!(
is_fresh(Provider::Mistral, "voxtral", "https://x"),
"entry must be readable from disk after in-process clear"
);
});
}
#[test]
fn disk_format_is_valid_yaml() {
with_temp_home(|| {
record(Provider::Mistral, "voxtral", "https://x");
record(Provider::OpenAI, "whisper-1", "https://api.openai.com");
let path = cache_path().expect("test: path");
let bytes = std::fs::read(&path).expect("test: read cache");
let file: CacheFile = serde_yaml::from_slice(&bytes).expect("test: parse cache");
assert_eq!(file.entries.len(), 2);
});
}
#[test]
fn corrupt_disk_file_is_not_fatal() {
with_temp_home(|| {
let path = cache_path().expect("test: path");
std::fs::create_dir_all(path.parent().expect("test: parent"))
.expect("test: create dir");
std::fs::write(&path, b"this is not valid yaml: {[}}").expect("test: write garbage");
if let Ok(mut t) = last_disk_mtime().lock() {
*t = None;
}
if let Ok(mut map) = in_process().lock() {
map.clear();
}
assert!(!is_fresh(Provider::Mistral, "voxtral", "https://x"));
});
}
#[test]
fn record_merges_with_sibling_disk_entries() {
with_temp_home(|| {
let path = cache_path().expect("test: path");
std::fs::create_dir_all(path.parent().expect("test: parent"))
.expect("test: create dir");
let sibling_yaml = "\
entries:
- provider: openai
model: whisper-1
api_base: https://api.openai.com
validated_at: 2026-04-29T10:00:00Z
";
std::fs::write(&path, sibling_yaml).expect("test: write sibling cache");
if let Ok(mut map) = in_process().lock() {
map.clear();
}
if let Ok(mut t) = last_disk_mtime().lock() {
*t = None;
}
record(
Provider::Mistral,
"voxtral-mini-2602",
"https://api.mistral.ai",
);
let bytes = std::fs::read(&path).expect("test: read disk after record");
let parsed: CacheFile = serde_yaml::from_slice(&bytes).expect("test: parse YAML");
let keys: Vec<(String, String, String)> = parsed
.entries
.iter()
.map(|e| (e.provider.clone(), e.model.clone(), e.api_base.clone()))
.collect();
assert!(
keys.iter()
.any(|(p, m, _)| p == "openai" && m == "whisper-1"),
"sibling openai entry must be preserved, got: {:?}",
keys
);
assert!(
keys.iter()
.any(|(p, m, _)| p == "mistral" && m == "voxtral-mini-2602"),
"newly-recorded mistral entry must be present, got: {:?}",
keys
);
assert_eq!(keys.len(), 2, "expected exactly 2 entries, got: {:?}", keys);
});
}
#[test]
fn re_recording_refreshes_timestamp() {
with_temp_home(|| {
let stale = Utc::now() - Duration::hours(23) - Duration::minutes(59);
let key = Key {
provider: Provider::Mistral.to_string(),
model: "voxtral".into(),
api_base: "https://x".into(),
};
in_process()
.lock()
.expect("test: lock")
.insert(key.clone(), stale);
record(Provider::Mistral, "voxtral", "https://x");
let new_ts = *in_process()
.lock()
.expect("test: lock")
.get(&key)
.expect("test: entry present");
assert!(
new_ts > stale,
"re-record must advance the timestamp; got {} <= {}",
new_ts,
stale
);
});
}
}