stemedb/applications/aphoria/src/remote/cache.rs
jordan ad07a75d0a feat: add source content to source registry, signed assertions, feed endpoint, dashboard enhancements
- Add `content: Option<String>` to SourceRecord with rkyv schema evolution
  (LegacySourceRecord compat deserializer for backward compatibility)
- Add MAX_SOURCE_CONTENT_LEN (1MB) limit with API validation
- Strip content from list responses, include in single-source GET
- Update Go SDK RegisterSourceRequest with Content field
- FCM pipeline extracts PDF text via pdftotext and passes to registration
- Dashboard impact panel fetches and displays source content with expand/collapse
- Add feed endpoint, dashboard feed panel, and signed assertion support
- Update data-structures.md, API docs, and storage docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 21:54:27 -07:00

215 lines
6.3 KiB
Rust

//! Local cache for remote claims (offline fallback).
//!
//! When remote mode is enabled, Aphoria caches fetched claims locally
//! in `.aphoria/cache.toml`. This allows scans to continue when the
//! remote server is unreachable.
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use serde::{Deserialize, Serialize};
use crate::types::AuthoredClaim;
use crate::AphoriaError;
/// Local cache for claims fetched from remote server.
pub struct ClaimCache {
cache_path: PathBuf,
}
/// Cache file structure (TOML).
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ClaimCacheFile {
/// Timestamp when cache was last updated (Unix seconds).
last_updated: u64,
/// URL of the remote server (for staleness detection).
remote_url: String,
/// Cached claims.
claims: Vec<AuthoredClaim>,
}
impl ClaimCache {
/// Create a new claim cache.
///
/// Defaults to `.aphoria/cache.toml` in the current directory.
pub fn new() -> Self {
Self { cache_path: PathBuf::from(".aphoria/cache.toml") }
}
/// Create a claim cache with a custom path.
pub fn with_path(cache_path: PathBuf) -> Self {
Self { cache_path }
}
/// Save claims to the cache.
pub fn save(&self, claims: &[AuthoredClaim], remote_url: &str) -> Result<(), AphoriaError> {
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|e| AphoriaError::Io(std::io::Error::other(e)))?
.as_secs();
let cache = ClaimCacheFile {
last_updated: now,
remote_url: remote_url.to_string(),
claims: claims.to_vec(),
};
// Ensure .aphoria directory exists
if let Some(parent) = self.cache_path.parent() {
std::fs::create_dir_all(parent)?;
}
let toml = toml::to_string_pretty(&cache)
.map_err(|e| AphoriaError::Config(format!("Failed to serialize cache: {e}")))?;
std::fs::write(&self.cache_path, toml)?;
Ok(())
}
/// Load claims from the cache.
///
/// Returns an empty vector if the cache doesn't exist.
pub fn load(&self) -> Result<Vec<AuthoredClaim>, AphoriaError> {
if !self.cache_path.exists() {
return Ok(vec![]);
}
let toml = std::fs::read_to_string(&self.cache_path)?;
let cache: ClaimCacheFile = toml::from_str(&toml)
.map_err(|e| AphoriaError::Config(format!("Failed to parse cache: {e}")))?;
Ok(cache.claims)
}
/// Check if the cache is stale (older than max_age).
pub fn is_stale(&self, max_age: Duration) -> bool {
let metadata = match self.cache_path.metadata() {
Ok(m) => m,
Err(_) => return true, // Missing cache is stale
};
let modified = match metadata.modified() {
Ok(t) => t,
Err(_) => return true, // Can't get modified time = stale
};
let age = match SystemTime::now().duration_since(modified) {
Ok(d) => d,
Err(_) => return true, // Clock skew = stale
};
age > max_age
}
/// Get the cache path.
pub fn path(&self) -> &Path {
&self.cache_path
}
/// Check if the cache exists.
pub fn exists(&self) -> bool {
self.cache_path.exists()
}
}
impl Default for ClaimCache {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{AuthoredValue, ClaimStatus};
use std::time::Duration;
use tempfile::TempDir;
fn make_test_claim(id: &str) -> AuthoredClaim {
AuthoredClaim {
id: id.to_string(),
concept_path: "test/path".to_string(),
predicate: "enabled".to_string(),
value: AuthoredValue::Bool(true),
comparison: Default::default(),
provenance: "test".to_string(),
invariant: "test invariant".to_string(),
consequence: "test consequence".to_string(),
authority_tier: "expert".to_string(),
evidence: vec![],
category: "test".to_string(),
status: ClaimStatus::Active,
supersedes: None,
created_by: "test-user".to_string(),
created_at: "2026-02-13T00:00:00Z".to_string(),
updated_at: None,
}
}
#[test]
fn test_cache_save_and_load() {
let temp_dir = TempDir::new().unwrap();
let cache_path = temp_dir.path().join("cache.toml");
let cache = ClaimCache::with_path(cache_path);
let claims = vec![make_test_claim("test-001"), make_test_claim("test-002")];
// Save
cache.save(&claims, "https://example.com").unwrap();
// Load
let loaded = cache.load().unwrap();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded[0].id, "test-001");
assert_eq!(loaded[1].id, "test-002");
}
#[test]
fn test_cache_load_missing_returns_empty() {
let temp_dir = TempDir::new().unwrap();
let cache_path = temp_dir.path().join("nonexistent.toml");
let cache = ClaimCache::with_path(cache_path);
let loaded = cache.load().unwrap();
assert_eq!(loaded.len(), 0);
}
#[test]
fn test_cache_staleness() {
let temp_dir = TempDir::new().unwrap();
let cache_path = temp_dir.path().join("cache.toml");
let cache = ClaimCache::with_path(cache_path);
// Save
let claims = vec![make_test_claim("test-001")];
cache.save(&claims, "https://example.com").unwrap();
// Fresh cache is not stale
assert!(!cache.is_stale(Duration::from_secs(3600)));
// But it is stale if we set max_age to 0
assert!(cache.is_stale(Duration::from_secs(0)));
}
#[test]
fn test_cache_nonexistent_is_stale() {
let temp_dir = TempDir::new().unwrap();
let cache_path = temp_dir.path().join("nonexistent.toml");
let cache = ClaimCache::with_path(cache_path);
assert!(cache.is_stale(Duration::from_secs(3600)));
}
#[test]
fn test_cache_accessors() {
let cache_path = PathBuf::from("/tmp/test-cache.toml");
let cache = ClaimCache::with_path(cache_path.clone());
assert_eq!(cache.path(), cache_path.as_path());
assert!(!cache.exists());
}
}