use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::cache::fingerprint_dir;
use crate::cache::reader::CacheReader;
use crate::cache::writer::rebuild_cache;
use crate::event::Event;
use crate::event::parser::parse_lines;
use crate::shard::ShardManager;
#[derive(Debug, Clone)]
pub struct CacheManager {
events_dir: PathBuf,
cache_path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct LoadResult {
pub events: Vec<Event>,
pub source: LoadSource,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoadSource {
Cache,
FallbackRebuilt,
FallbackRebuildFailed,
}
impl CacheManager {
pub fn new(events_dir: impl Into<PathBuf>, cache_path: impl Into<PathBuf>) -> Self {
Self {
events_dir: events_dir.into(),
cache_path: cache_path.into(),
}
}
pub fn is_fresh(&self) -> Result<bool> {
let current_fp = self
.compute_fingerprint()
.context("compute shard fingerprint")?;
CacheReader::open(&self.cache_path).map_or_else(
|_| Ok(false),
|reader| Ok(reader.created_at_us() == current_fp),
)
}
pub fn load_events(&self) -> Result<LoadResult> {
let current_fp = self
.compute_fingerprint()
.context("compute shard fingerprint")?;
if let Ok(reader) = CacheReader::open(&self.cache_path) {
if reader.created_at_us() == current_fp {
match reader.read_all() {
Ok(events) => {
tracing::debug!(count = events.len(), "loaded events from binary cache");
return Ok(LoadResult {
events,
source: LoadSource::Cache,
});
}
Err(e) => {
tracing::warn!("cache decode failed, falling back to TSJSON: {e}");
}
}
} else {
tracing::debug!("cache fingerprint mismatch, falling back to TSJSON");
}
}
let events = self.parse_tsjson()?;
let source = match self.rebuild_with_fingerprint(current_fp) {
Ok(_stats) => {
tracing::debug!("rebuilt binary cache after TSJSON fallback");
LoadSource::FallbackRebuilt
}
Err(e) => {
tracing::warn!("cache rebuild failed (non-fatal): {e}");
LoadSource::FallbackRebuildFailed
}
};
Ok(LoadResult { events, source })
}
pub fn rebuild(&self) -> Result<crate::cache::CacheStats> {
rebuild_cache(&self.events_dir, &self.cache_path)
}
#[must_use]
pub fn events_dir(&self) -> &Path {
&self.events_dir
}
#[must_use]
pub fn cache_path(&self) -> &Path {
&self.cache_path
}
fn compute_fingerprint(&self) -> Result<u64> {
fingerprint_dir(&self.events_dir)
}
fn parse_tsjson(&self) -> Result<Vec<Event>> {
let bones_dir = self.events_dir.parent().unwrap_or_else(|| Path::new("."));
let shard_mgr = ShardManager::new(bones_dir);
let content = shard_mgr
.replay()
.map_err(|e| anyhow::anyhow!("replay shards: {e}"))?;
let events = parse_lines(&content)
.map_err(|(line, e)| anyhow::anyhow!("parse error at line {line}: {e}"))?;
Ok(events)
}
fn rebuild_with_fingerprint(&self, fingerprint: u64) -> Result<crate::cache::CacheStats> {
let events = self.parse_tsjson()?;
let cols = crate::cache::CacheColumns::from_events(&events)
.map_err(|e| anyhow::anyhow!("encode columns: {e}"))?;
let mut header = crate::cache::CacheHeader::new(events.len() as u64, fingerprint);
let bytes = header
.encode(&cols)
.map_err(|e| anyhow::anyhow!("encode cache: {e}"))?;
if let Some(parent) = self.cache_path.parent() {
fs::create_dir_all(parent)
.with_context(|| format!("create cache dir {}", parent.display()))?;
}
fs::write(&self.cache_path, &bytes)
.with_context(|| format!("write cache file {}", self.cache_path.display()))?;
Ok(crate::cache::CacheStats {
total_events: events.len(),
file_size_bytes: bytes.len() as u64,
compression_ratio: 1.0, })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::data::{CreateData, MoveData};
use crate::event::writer;
use crate::event::{Event, EventData, EventType};
use crate::model::item::{Kind, State, Urgency};
use crate::model::item_id::ItemId;
use crate::shard::ShardManager;
use std::collections::BTreeMap;
use tempfile::TempDir;
fn setup_bones(events: &[Event]) -> (TempDir, PathBuf, PathBuf) {
let tmp = TempDir::new().unwrap();
let bones_dir = tmp.path().join(".bones");
let shard_mgr = ShardManager::new(&bones_dir);
shard_mgr.ensure_dirs().unwrap();
shard_mgr.init().unwrap();
for event in events {
let line = writer::write_line(event).unwrap();
let (year, month) = shard_mgr.active_shard().unwrap().unwrap();
shard_mgr.append_raw(year, month, &line).unwrap();
}
let events_dir = bones_dir.join("events");
let cache_path = bones_dir.join("cache/events.bin");
(tmp, events_dir, cache_path)
}
fn make_event(id: &str, ts: i64) -> Event {
let mut event = Event {
wall_ts_us: ts,
agent: "test-agent".to_string(),
itc: "itc:AQ".to_string(),
parents: vec![],
event_type: EventType::Create,
item_id: ItemId::new_unchecked(id),
data: EventData::Create(CreateData {
title: format!("Item {id}"),
kind: Kind::Task,
size: None,
urgency: Urgency::Default,
labels: vec![],
parent: None,
causation: None,
description: None,
extra: BTreeMap::new(),
}),
event_hash: String::new(),
};
writer::write_event(&mut event).unwrap();
event
}
fn make_move(id: &str, ts: i64, parent_hash: &str) -> Event {
let mut event = Event {
wall_ts_us: ts,
agent: "test-agent".to_string(),
itc: "itc:AQ".to_string(),
parents: vec![parent_hash.to_string()],
event_type: EventType::Move,
item_id: ItemId::new_unchecked(id),
data: EventData::Move(MoveData {
state: State::Doing,
reason: None,
extra: BTreeMap::new(),
}),
event_hash: String::new(),
};
writer::write_event(&mut event).unwrap();
event
}
#[test]
fn is_fresh_returns_false_when_no_cache() {
let e1 = make_event("bn-001", 1000);
let (_tmp, events_dir, cache_path) = setup_bones(&[e1]);
let mgr = CacheManager::new(&events_dir, &cache_path);
assert!(!mgr.is_fresh().unwrap());
}
#[test]
fn is_fresh_returns_true_after_load() {
let e1 = make_event("bn-001", 1000);
let (_tmp, events_dir, cache_path) = setup_bones(&[e1]);
let mgr = CacheManager::new(&events_dir, &cache_path);
let _result = mgr.load_events().unwrap();
assert!(mgr.is_fresh().unwrap());
}
#[test]
fn is_fresh_returns_false_after_shard_modification() {
let e1 = make_event("bn-001", 1000);
let (_tmp, events_dir, cache_path) = setup_bones(&[e1]);
let mgr = CacheManager::new(&events_dir, &cache_path);
let _result = mgr.load_events().unwrap();
assert!(mgr.is_fresh().unwrap());
let bones_dir = events_dir.parent().unwrap();
let shard_mgr = ShardManager::new(bones_dir);
let e2 = make_event("bn-002", 2000);
let line = writer::write_line(&e2).unwrap();
let (year, month) = shard_mgr.active_shard().unwrap().unwrap();
shard_mgr.append_raw(year, month, &line).unwrap();
assert!(!mgr.is_fresh().unwrap());
}
#[test]
fn load_events_from_tsjson_when_no_cache() {
let e1 = make_event("bn-001", 1000);
let (_tmp, events_dir, cache_path) = setup_bones(&[e1]);
let mgr = CacheManager::new(&events_dir, &cache_path);
let result = mgr.load_events().unwrap();
assert_eq!(result.events.len(), 1);
assert_eq!(result.source, LoadSource::FallbackRebuilt);
assert!(cache_path.exists());
}
#[test]
fn load_events_from_cache_when_fresh() {
let e1 = make_event("bn-001", 1000);
let e2 = make_event("bn-002", 2000);
let (_tmp, events_dir, cache_path) = setup_bones(&[e1, e2]);
let mgr = CacheManager::new(&events_dir, &cache_path);
let r1 = mgr.load_events().unwrap();
assert_eq!(r1.events.len(), 2);
assert_eq!(r1.source, LoadSource::FallbackRebuilt);
let r2 = mgr.load_events().unwrap();
assert_eq!(r2.events.len(), 2);
assert_eq!(r2.source, LoadSource::Cache);
}
#[test]
fn load_events_falls_back_on_stale_cache() {
let e1 = make_event("bn-001", 1000);
let (_tmp, events_dir, cache_path) = setup_bones(&[e1]);
let mgr = CacheManager::new(&events_dir, &cache_path);
let _r1 = mgr.load_events().unwrap();
let bones_dir = events_dir.parent().unwrap();
let shard_mgr = ShardManager::new(bones_dir);
let e2 = make_event("bn-002", 2000);
let line = writer::write_line(&e2).unwrap();
let (year, month) = shard_mgr.active_shard().unwrap().unwrap();
shard_mgr.append_raw(year, month, &line).unwrap();
let r2 = mgr.load_events().unwrap();
assert_eq!(r2.events.len(), 2);
assert_eq!(r2.source, LoadSource::FallbackRebuilt);
}
#[test]
fn load_events_empty_shard() {
let (_tmp, events_dir, cache_path) = setup_bones(&[]);
let mgr = CacheManager::new(&events_dir, &cache_path);
let result = mgr.load_events().unwrap();
assert!(result.events.is_empty());
}
#[test]
fn rebuild_creates_cache_file() {
let e1 = make_event("bn-001", 1000);
let (_tmp, events_dir, cache_path) = setup_bones(&[e1]);
let mgr = CacheManager::new(&events_dir, &cache_path);
let stats = mgr.rebuild().unwrap();
assert_eq!(stats.total_events, 1);
assert!(cache_path.exists());
}
#[test]
fn rebuild_creates_fresh_cache_for_manager_fast_path() {
let e1 = make_event("bn-001", 1000);
let e2 = make_event("bn-002", 2000);
let (_tmp, events_dir, cache_path) = setup_bones(&[e1, e2]);
let mgr = CacheManager::new(&events_dir, &cache_path);
mgr.rebuild().unwrap();
assert!(
mgr.is_fresh().unwrap(),
"manual rebuild should write the freshness fingerprint expected by CacheManager"
);
let result = mgr.load_events().unwrap();
assert_eq!(result.source, LoadSource::Cache);
assert_eq!(result.events.len(), 2);
}
#[test]
fn fingerprint_empty_dir_is_zero() {
let tmp = TempDir::new().unwrap();
let fp = fingerprint_dir(tmp.path()).unwrap();
assert_eq!(fp, 0xcbf2_9ce4_8422_2325); }
#[test]
fn fingerprint_nonexistent_dir_is_zero() {
let fp = fingerprint_dir(Path::new("/tmp/nonexistent-bones-fp-dir")).unwrap();
assert_eq!(fp, 0);
}
#[test]
fn fingerprint_changes_when_file_added() {
let e1 = make_event("bn-001", 1000);
let (_tmp, events_dir, _cache_path) = setup_bones(&[e1]);
let fp1 = fingerprint_dir(&events_dir).unwrap();
let bones_dir = events_dir.parent().unwrap();
let shard_mgr = ShardManager::new(bones_dir);
let e2 = make_event("bn-002", 2000);
let line = writer::write_line(&e2).unwrap();
let (year, month) = shard_mgr.active_shard().unwrap().unwrap();
shard_mgr.append_raw(year, month, &line).unwrap();
let fp2 = fingerprint_dir(&events_dir).unwrap();
assert_ne!(fp1, fp2);
}
#[test]
fn cache_output_matches_tsjson_parse() {
let e1 = make_event("bn-001", 1000);
let e2 = make_move("bn-001", 2000, &e1.event_hash);
let e3 = make_event("bn-002", 3000);
let (_tmp, events_dir, cache_path) = setup_bones(&[e1, e2, e3]);
let mgr = CacheManager::new(&events_dir, &cache_path);
let r1 = mgr.load_events().unwrap();
assert_eq!(r1.source, LoadSource::FallbackRebuilt);
let r2 = mgr.load_events().unwrap();
assert_eq!(r2.source, LoadSource::Cache);
assert_eq!(r1.events.len(), r2.events.len());
for (a, b) in r1.events.iter().zip(r2.events.iter()) {
assert_eq!(a.wall_ts_us, b.wall_ts_us);
assert_eq!(a.agent, b.agent);
assert_eq!(a.event_type, b.event_type);
assert_eq!(a.item_id, b.item_id);
}
}
}