1use std::collections::BTreeMap;
21use std::fs;
22use std::path::{Path, PathBuf};
23
24use anyhow::{Context, Result};
25
26use crate::cache::reader::CacheReader;
27use crate::cache::writer::rebuild_cache;
28use crate::event::Event;
29use crate::event::parser::parse_lines;
30use crate::shard::ShardManager;
31
32#[derive(Debug, Clone)]
47pub struct CacheManager {
48 events_dir: PathBuf,
50 cache_path: PathBuf,
52}
53
54#[derive(Debug, Clone)]
57pub struct LoadResult {
58 pub events: Vec<Event>,
60 pub source: LoadSource,
62}
63
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum LoadSource {
67 Cache,
69 FallbackRebuilt,
72 FallbackRebuildFailed,
75}
76
77impl CacheManager {
78 pub fn new(events_dir: impl Into<PathBuf>, cache_path: impl Into<PathBuf>) -> Self {
85 Self {
86 events_dir: events_dir.into(),
87 cache_path: cache_path.into(),
88 }
89 }
90
91 pub fn is_fresh(&self) -> Result<bool> {
103 let current_fp = self
104 .compute_fingerprint()
105 .context("compute shard fingerprint")?;
106
107 CacheReader::open(&self.cache_path).map_or_else(
108 |_| Ok(false),
109 |reader| Ok(reader.created_at_us() == current_fp),
110 )
111 }
112
113 pub fn load_events(&self) -> Result<LoadResult> {
126 let current_fp = self
127 .compute_fingerprint()
128 .context("compute shard fingerprint")?;
129
130 if let Ok(reader) = CacheReader::open(&self.cache_path) {
132 if reader.created_at_us() == current_fp {
133 match reader.read_all() {
134 Ok(events) => {
135 tracing::debug!(count = events.len(), "loaded events from binary cache");
136 return Ok(LoadResult {
137 events,
138 source: LoadSource::Cache,
139 });
140 }
141 Err(e) => {
142 tracing::warn!("cache decode failed, falling back to TSJSON: {e}");
143 }
144 }
145 } else {
146 tracing::debug!("cache fingerprint mismatch, falling back to TSJSON");
147 }
148 }
149
150 let events = self.parse_tsjson()?;
152
153 let source = match self.rebuild_with_fingerprint(current_fp) {
155 Ok(_stats) => {
156 tracing::debug!("rebuilt binary cache after TSJSON fallback");
157 LoadSource::FallbackRebuilt
158 }
159 Err(e) => {
160 tracing::warn!("cache rebuild failed (non-fatal): {e}");
161 LoadSource::FallbackRebuildFailed
162 }
163 };
164
165 Ok(LoadResult { events, source })
166 }
167
168 pub fn rebuild(&self) -> Result<crate::cache::CacheStats> {
176 rebuild_cache(&self.events_dir, &self.cache_path)
177 }
178
179 #[must_use]
181 pub fn events_dir(&self) -> &Path {
182 &self.events_dir
183 }
184
185 #[must_use]
187 pub fn cache_path(&self) -> &Path {
188 &self.cache_path
189 }
190
191 fn compute_fingerprint(&self) -> Result<u64> {
201 fingerprint_dir(&self.events_dir)
202 }
203
204 fn parse_tsjson(&self) -> Result<Vec<Event>> {
207 let bones_dir = self.events_dir.parent().unwrap_or_else(|| Path::new("."));
208 let shard_mgr = ShardManager::new(bones_dir);
209
210 let content = shard_mgr
211 .replay()
212 .map_err(|e| anyhow::anyhow!("replay shards: {e}"))?;
213
214 let events = parse_lines(&content)
215 .map_err(|(line, e)| anyhow::anyhow!("parse error at line {line}: {e}"))?;
216
217 Ok(events)
218 }
219
220 fn rebuild_with_fingerprint(&self, fingerprint: u64) -> Result<crate::cache::CacheStats> {
223 let events = self.parse_tsjson()?;
224
225 let cols = crate::cache::CacheColumns::from_events(&events)
226 .map_err(|e| anyhow::anyhow!("encode columns: {e}"))?;
227 let mut header = crate::cache::CacheHeader::new(events.len() as u64, fingerprint);
228 let bytes = header
229 .encode(&cols)
230 .map_err(|e| anyhow::anyhow!("encode cache: {e}"))?;
231
232 if let Some(parent) = self.cache_path.parent() {
233 fs::create_dir_all(parent)
234 .with_context(|| format!("create cache dir {}", parent.display()))?;
235 }
236
237 fs::write(&self.cache_path, &bytes)
238 .with_context(|| format!("write cache file {}", self.cache_path.display()))?;
239
240 Ok(crate::cache::CacheStats {
241 total_events: events.len(),
242 file_size_bytes: bytes.len() as u64,
243 compression_ratio: 1.0, })
245 }
246}
247
248fn fingerprint_dir(dir: &Path) -> Result<u64> {
258 if !dir.exists() {
259 return Ok(0);
260 }
261
262 let mut entries: BTreeMap<String, (u64, u64)> = BTreeMap::new();
263
264 let read_dir = fs::read_dir(dir).with_context(|| format!("read dir {}", dir.display()))?;
265
266 for entry in read_dir {
267 let entry = entry?;
268 let name = entry.file_name().to_string_lossy().to_string();
269
270 if !name.ends_with(".events") {
272 continue;
273 }
274
275 let meta = entry.metadata()?;
277 let size = meta.len();
278 let mtime_ns = meta
279 .modified()
280 .ok()
281 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
282 .map_or(0, |d| u64::try_from(d.as_nanos()).unwrap_or(u64::MAX));
283
284 entries.insert(name, (size, mtime_ns));
285 }
286
287 let mut hash: u64 = 0xcbf2_9ce4_8422_2325; for (name, (size, mtime)) in &entries {
290 for byte in name.bytes() {
291 hash ^= u64::from(byte);
292 hash = hash.wrapping_mul(0x0100_0000_01b3); }
294 for byte in size.to_le_bytes() {
296 hash ^= u64::from(byte);
297 hash = hash.wrapping_mul(0x0100_0000_01b3);
298 }
299 for byte in mtime.to_le_bytes() {
301 hash ^= u64::from(byte);
302 hash = hash.wrapping_mul(0x0100_0000_01b3);
303 }
304 }
305
306 Ok(hash)
307}
308
309#[cfg(test)]
314mod tests {
315 use super::*;
316 use crate::event::data::{CreateData, MoveData};
317 use crate::event::writer;
318 use crate::event::{Event, EventData, EventType};
319 use crate::model::item::{Kind, State, Urgency};
320 use crate::model::item_id::ItemId;
321 use crate::shard::ShardManager;
322 use std::collections::BTreeMap;
323 use tempfile::TempDir;
324
325 fn setup_bones(events: &[Event]) -> (TempDir, PathBuf, PathBuf) {
326 let tmp = TempDir::new().unwrap();
327 let bones_dir = tmp.path().join(".bones");
328 let shard_mgr = ShardManager::new(&bones_dir);
329 shard_mgr.ensure_dirs().unwrap();
330 shard_mgr.init().unwrap();
331
332 for event in events {
333 let line = writer::write_line(event).unwrap();
334 let (year, month) = shard_mgr.active_shard().unwrap().unwrap();
335 shard_mgr.append_raw(year, month, &line).unwrap();
336 }
337
338 let events_dir = bones_dir.join("events");
339 let cache_path = bones_dir.join("cache/events.bin");
340 (tmp, events_dir, cache_path)
341 }
342
343 fn make_event(id: &str, ts: i64) -> Event {
344 let mut event = Event {
345 wall_ts_us: ts,
346 agent: "test-agent".to_string(),
347 itc: "itc:AQ".to_string(),
348 parents: vec![],
349 event_type: EventType::Create,
350 item_id: ItemId::new_unchecked(id),
351 data: EventData::Create(CreateData {
352 title: format!("Item {id}"),
353 kind: Kind::Task,
354 size: None,
355 urgency: Urgency::Default,
356 labels: vec![],
357 parent: None,
358 causation: None,
359 description: None,
360 extra: BTreeMap::new(),
361 }),
362 event_hash: String::new(),
363 };
364 writer::write_event(&mut event).unwrap();
365 event
366 }
367
368 fn make_move(id: &str, ts: i64, parent_hash: &str) -> Event {
369 let mut event = Event {
370 wall_ts_us: ts,
371 agent: "test-agent".to_string(),
372 itc: "itc:AQ".to_string(),
373 parents: vec![parent_hash.to_string()],
374 event_type: EventType::Move,
375 item_id: ItemId::new_unchecked(id),
376 data: EventData::Move(MoveData {
377 state: State::Doing,
378 reason: None,
379 extra: BTreeMap::new(),
380 }),
381 event_hash: String::new(),
382 };
383 writer::write_event(&mut event).unwrap();
384 event
385 }
386
387 #[test]
390 fn is_fresh_returns_false_when_no_cache() {
391 let e1 = make_event("bn-001", 1000);
392 let (_tmp, events_dir, cache_path) = setup_bones(&[e1]);
393
394 let mgr = CacheManager::new(&events_dir, &cache_path);
395 assert!(!mgr.is_fresh().unwrap());
396 }
397
398 #[test]
399 fn is_fresh_returns_true_after_load() {
400 let e1 = make_event("bn-001", 1000);
401 let (_tmp, events_dir, cache_path) = setup_bones(&[e1]);
402
403 let mgr = CacheManager::new(&events_dir, &cache_path);
404 let _result = mgr.load_events().unwrap();
405
406 assert!(mgr.is_fresh().unwrap());
408 }
409
410 #[test]
411 fn is_fresh_returns_false_after_shard_modification() {
412 let e1 = make_event("bn-001", 1000);
413 let (_tmp, events_dir, cache_path) = setup_bones(&[e1]);
414
415 let mgr = CacheManager::new(&events_dir, &cache_path);
416 let _result = mgr.load_events().unwrap();
417 assert!(mgr.is_fresh().unwrap());
418
419 let bones_dir = events_dir.parent().unwrap();
421 let shard_mgr = ShardManager::new(bones_dir);
422 let e2 = make_event("bn-002", 2000);
423 let line = writer::write_line(&e2).unwrap();
424 let (year, month) = shard_mgr.active_shard().unwrap().unwrap();
425 shard_mgr.append_raw(year, month, &line).unwrap();
426
427 assert!(!mgr.is_fresh().unwrap());
429 }
430
431 #[test]
434 fn load_events_from_tsjson_when_no_cache() {
435 let e1 = make_event("bn-001", 1000);
436 let (_tmp, events_dir, cache_path) = setup_bones(&[e1]);
437
438 let mgr = CacheManager::new(&events_dir, &cache_path);
439 let result = mgr.load_events().unwrap();
440
441 assert_eq!(result.events.len(), 1);
442 assert_eq!(result.source, LoadSource::FallbackRebuilt);
443
444 assert!(cache_path.exists());
446 }
447
448 #[test]
449 fn load_events_from_cache_when_fresh() {
450 let e1 = make_event("bn-001", 1000);
451 let e2 = make_event("bn-002", 2000);
452 let (_tmp, events_dir, cache_path) = setup_bones(&[e1, e2]);
453
454 let mgr = CacheManager::new(&events_dir, &cache_path);
455
456 let r1 = mgr.load_events().unwrap();
458 assert_eq!(r1.events.len(), 2);
459 assert_eq!(r1.source, LoadSource::FallbackRebuilt);
460
461 let r2 = mgr.load_events().unwrap();
463 assert_eq!(r2.events.len(), 2);
464 assert_eq!(r2.source, LoadSource::Cache);
465 }
466
467 #[test]
468 fn load_events_falls_back_on_stale_cache() {
469 let e1 = make_event("bn-001", 1000);
470 let (_tmp, events_dir, cache_path) = setup_bones(&[e1]);
471
472 let mgr = CacheManager::new(&events_dir, &cache_path);
473
474 let _r1 = mgr.load_events().unwrap();
476
477 let bones_dir = events_dir.parent().unwrap();
479 let shard_mgr = ShardManager::new(bones_dir);
480 let e2 = make_event("bn-002", 2000);
481 let line = writer::write_line(&e2).unwrap();
482 let (year, month) = shard_mgr.active_shard().unwrap().unwrap();
483 shard_mgr.append_raw(year, month, &line).unwrap();
484
485 let r2 = mgr.load_events().unwrap();
487 assert_eq!(r2.events.len(), 2);
488 assert_eq!(r2.source, LoadSource::FallbackRebuilt);
489 }
490
491 #[test]
492 fn load_events_empty_shard() {
493 let (_tmp, events_dir, cache_path) = setup_bones(&[]);
494
495 let mgr = CacheManager::new(&events_dir, &cache_path);
496 let result = mgr.load_events().unwrap();
497 assert!(result.events.is_empty());
498 }
499
500 #[test]
503 fn rebuild_creates_cache_file() {
504 let e1 = make_event("bn-001", 1000);
505 let (_tmp, events_dir, cache_path) = setup_bones(&[e1]);
506
507 let mgr = CacheManager::new(&events_dir, &cache_path);
508 let stats = mgr.rebuild().unwrap();
509
510 assert_eq!(stats.total_events, 1);
511 assert!(cache_path.exists());
512 }
513
514 #[test]
517 fn fingerprint_empty_dir_is_zero() {
518 let tmp = TempDir::new().unwrap();
519 let fp = fingerprint_dir(tmp.path()).unwrap();
520 assert_eq!(fp, 0xcbf2_9ce4_8422_2325); }
522
523 #[test]
524 fn fingerprint_nonexistent_dir_is_zero() {
525 let fp = fingerprint_dir(Path::new("/tmp/nonexistent-bones-fp-dir")).unwrap();
526 assert_eq!(fp, 0);
527 }
528
529 #[test]
530 fn fingerprint_changes_when_file_added() {
531 let e1 = make_event("bn-001", 1000);
532 let (_tmp, events_dir, _cache_path) = setup_bones(&[e1]);
533
534 let fp1 = fingerprint_dir(&events_dir).unwrap();
535
536 let bones_dir = events_dir.parent().unwrap();
538 let shard_mgr = ShardManager::new(bones_dir);
539 let e2 = make_event("bn-002", 2000);
540 let line = writer::write_line(&e2).unwrap();
541 let (year, month) = shard_mgr.active_shard().unwrap().unwrap();
542 shard_mgr.append_raw(year, month, &line).unwrap();
543
544 let fp2 = fingerprint_dir(&events_dir).unwrap();
545 assert_ne!(fp1, fp2);
546 }
547
548 #[test]
551 fn cache_output_matches_tsjson_parse() {
552 let e1 = make_event("bn-001", 1000);
553 let e2 = make_move("bn-001", 2000, &e1.event_hash);
554 let e3 = make_event("bn-002", 3000);
555 let (_tmp, events_dir, cache_path) = setup_bones(&[e1, e2, e3]);
556
557 let mgr = CacheManager::new(&events_dir, &cache_path);
558
559 let r1 = mgr.load_events().unwrap();
561 assert_eq!(r1.source, LoadSource::FallbackRebuilt);
562
563 let r2 = mgr.load_events().unwrap();
565 assert_eq!(r2.source, LoadSource::Cache);
566
567 assert_eq!(r1.events.len(), r2.events.len());
569 for (a, b) in r1.events.iter().zip(r2.events.iter()) {
570 assert_eq!(a.wall_ts_us, b.wall_ts_us);
571 assert_eq!(a.agent, b.agent);
572 assert_eq!(a.event_type, b.event_type);
573 assert_eq!(a.item_id, b.item_id);
574 }
575 }
576}