//! On-disk cache of the last successful `/api/oneliners` response. //! //! Survives app restarts so the UI has data before the first poll //! completes. A single JSON file under a caller-chosen path. Atomic //! writes via temp-file + rename. Read errors on malformed content are //! treated the same as "no cache" — the next poll will rebuild it. use std::path::{Path, PathBuf}; use doctate_common::oneliners::OnelinersResponse; use serde::{Deserialize, Serialize}; use tracing::{debug, warn}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CachedSnapshot { /// Last ETag we handed the server in `If-None-Match` — raw header /// value, including the `W/"..."` wrapper. pub etag: String, /// RFC3339 UTC; when the client received the response. Feeds the /// staleness indicator on cold start. pub fetched_at: String, pub response: OnelinersResponse, } #[derive(Clone)] pub struct SnapshotCache { path: PathBuf, } impl SnapshotCache { pub fn new(path: PathBuf) -> Self { Self { path } } pub fn path(&self) -> &Path { &self.path } /// Read and deserialize. `None` on missing file, unreadable file, or /// parse error — the caller treats all three as "cold start". pub async fn load(&self) -> Option { let bytes = match tokio::fs::read(&self.path).await { Ok(b) => b, Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None, Err(e) => { warn!(path = %self.path.display(), error = %e, "snapshot cache read failed"); return None; } }; match serde_json::from_slice::(&bytes) { Ok(c) => Some(c), Err(e) => { warn!(path = %self.path.display(), error = %e, "snapshot cache malformed — ignoring"); None } } } /// Persist atomically: write to `{path}.tmp`, then rename. pub async fn store(&self, cached: &CachedSnapshot) -> std::io::Result<()> { if let Some(parent) = self.path.parent() { tokio::fs::create_dir_all(parent).await?; } let tmp = self.path.with_extension(tmp_extension(&self.path)); let bytes = serde_json::to_vec(cached) .map_err(|e| std::io::Error::other(format!("serialize cached snapshot: {e}")))?; tokio::fs::write(&tmp, bytes).await?; tokio::fs::rename(&tmp, &self.path).await?; debug!(path = %self.path.display(), "snapshot cache written"); Ok(()) } /// Delete the cache file. Idempotent: missing file is success. pub async fn clear(&self) -> std::io::Result<()> { match tokio::fs::remove_file(&self.path).await { Ok(_) => Ok(()), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), Err(e) => Err(e), } } } /// Build a `{original_extension}.tmp`-style extension that the rename /// target never accidentally matches. For `foo.json` → `json.tmp`. fn tmp_extension(path: &Path) -> String { match path.extension().and_then(|s| s.to_str()) { Some(ext) => format!("{ext}.tmp"), None => "tmp".to_owned(), } } #[cfg(test)] mod tests { use super::*; use doctate_common::oneliners::{OnelinerEntry, OnelinerState}; use tempfile::TempDir; fn sample() -> CachedSnapshot { CachedSnapshot { etag: "W/\"1729512345-72\"".into(), fetched_at: "2026-04-18T10:45:00Z".into(), response: OnelinersResponse { as_of: "2026-04-18T10:45:00Z".into(), window_hours: 72, oneliners: vec![OnelinerEntry { case_id: "550e8400-e29b-41d4-a716-446655440000".into(), oneliner: Some(OnelinerState::Ready { text: "Kniegelenk".into(), generated_at: "2026-04-18T10:30:00Z".into(), }), created_at: "2026-04-18T10:00:00Z".into(), last_recording_at: Some("2026-04-18T10:30:00Z".into()), updated_at: Some("2026-04-18T10:30:00Z".into()), }], }, } } #[tokio::test] async fn store_and_load_roundtrip() { let tmp = TempDir::new().unwrap(); let cache = SnapshotCache::new(tmp.path().join("cache.json")); let s = sample(); cache.store(&s).await.unwrap(); let loaded = cache.load().await.expect("cache present"); assert_eq!(loaded.etag, s.etag); assert_eq!(loaded.response.oneliners.len(), 1); let text = match &loaded.response.oneliners[0].oneliner { Some(OnelinerState::Ready { text, .. }) => Some(text.as_str()), _ => None, }; assert_eq!(text, Some("Kniegelenk")); } #[tokio::test] async fn load_returns_none_when_missing() { let tmp = TempDir::new().unwrap(); let cache = SnapshotCache::new(tmp.path().join("missing.json")); assert!(cache.load().await.is_none()); } #[tokio::test] async fn load_returns_none_on_garbage() { let tmp = TempDir::new().unwrap(); let path = tmp.path().join("garbage.json"); tokio::fs::write(&path, b"this is not json").await.unwrap(); let cache = SnapshotCache::new(path); assert!(cache.load().await.is_none()); } #[tokio::test] async fn clear_is_idempotent() { let tmp = TempDir::new().unwrap(); let cache = SnapshotCache::new(tmp.path().join("cache.json")); cache.clear().await.unwrap(); cache.store(&sample()).await.unwrap(); assert!(cache.load().await.is_some()); cache.clear().await.unwrap(); assert!(cache.load().await.is_none()); // Second clear on already-empty is still Ok. cache.clear().await.unwrap(); } #[tokio::test] async fn store_creates_parent_dirs() { let tmp = TempDir::new().unwrap(); let deep = tmp.path().join("a/b/c/cache.json"); let cache = SnapshotCache::new(deep.clone()); cache.store(&sample()).await.unwrap(); assert!(deep.exists()); } }