1b4864215b
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.
169 lines
5.7 KiB
Rust
169 lines
5.7 KiB
Rust
//! 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<CachedSnapshot> {
|
|
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::<CachedSnapshot>(&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());
|
|
}
|
|
}
|