refactor: dissolve doctate-client-core into doctate-desktop
Move the 9 client-core modules (case_store, case_update, config, footer_status, pending_cleanup, server_sync, snapshot_cache, startup, upload) into client-desktop/src/. Resolve the config.rs name collision by keeping the TOML schema in config.rs (was core) and renaming the platform-path wrapper to config_path.rs. Introduce client-desktop/src/lib.rs so the modules are reachable under the doctate-desktop:: crate path -- needed for Wear-OS doc anchors that mirror Rust symbols. Bin target stays as `doctate` via [[bin]] name in Cargo.toml. This is the first of four stages preparing the server-vs-clients build-world split. Workspace still has 3 members; directory layout unchanged. All 508 workspace tests pass.
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
//! 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, 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user