From 1b4864215b3361ddf593bda1f83b1a20f6639c24 Mon Sep 17 00:00:00 2001 From: Brummel Date: Sat, 18 Apr 2026 10:05:10 +0200 Subject: [PATCH] feat: Implement snapshot cache persistence Add `SnapshotCache` struct to handle on-disk caching of API responses. This includes `load`, `store`, and `clear` methods for managing the cache file. Atomic writes are implemented using a temporary file and rename operation. Error handling for file operations and JSON deserialization is included. Unit tests are provided to verify cache functionality. --- doctate-client-core/src/snapshot_cache.rs | 150 +++++++++++++++++++++- 1 file changed, 147 insertions(+), 3 deletions(-) diff --git a/doctate-client-core/src/snapshot_cache.rs b/doctate-client-core/src/snapshot_cache.rs index 289868d..508e8e1 100644 --- a/doctate-client-core/src/snapshot_cache.rs +++ b/doctate-client-core/src/snapshot_cache.rs @@ -1,19 +1,29 @@ -//! Placeholder — full implementation follows in next step. +//! 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::PathBuf; +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 { - #[allow(dead_code)] path: PathBuf, } @@ -21,4 +31,138 @@ 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; + 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("Kniegelenk".into()), + created_at: "2026-04-18T10:00: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); + assert_eq!(loaded.response.oneliners[0].oneliner.as_deref(), 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()); + } }