Skip to main content

bones_core/cache/
manager.rs

1//! Cache lifecycle management: freshness check, rebuild trigger, fallback.
2//!
3//! [`CacheManager`] is the primary entry point for loading events. It
4//! transparently picks the fastest available source:
5//!
6//! 1. **Binary cache** — if fresh (fingerprint matches), decode directly.
7//! 2. **TSJSON fallback** — parse the event shards, then rebuild the cache
8//!    so the next load is fast.
9//!
10//! # Freshness fingerprint
11//!
12//! A "fingerprint" is a fast hash over the list of shard files and their
13//! sizes + modification times. If any shard is added, removed, or modified,
14//! the fingerprint changes and the cache is considered stale.
15//!
16//! The fingerprint is stored as the `created_at_us` field of the cache
17//! header (repurposed — the actual wall-clock creation time is not critical).
18//! This avoids adding a separate metadata file.
19
20use 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// ---------------------------------------------------------------------------
33// CacheManager
34// ---------------------------------------------------------------------------
35
36/// Manages cache lifecycle: freshness check, rebuild, and fallback to TSJSON.
37///
38/// # Usage
39///
40/// ```rust,no_run
41/// use bones_core::cache::manager::CacheManager;
42///
43/// let mgr = CacheManager::new(".bones/events", ".bones/cache/events.bin");
44/// let events = mgr.load_events().unwrap();
45/// ```
46#[derive(Debug, Clone)]
47pub struct CacheManager {
48    /// Path to the events directory (`.bones/events/`).
49    events_dir: PathBuf,
50    /// Path to the binary cache file (`.bones/cache/events.bin`).
51    cache_path: PathBuf,
52}
53
54/// Result of a [`CacheManager::load_events`] call, including provenance
55/// metadata for diagnostics.
56#[derive(Debug, Clone)]
57pub struct LoadResult {
58    /// The loaded events.
59    pub events: Vec<Event>,
60    /// How the events were loaded.
61    pub source: LoadSource,
62}
63
64/// Where events were loaded from.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66pub enum LoadSource {
67    /// Events were decoded from the binary cache (fast path).
68    Cache,
69    /// Cache was stale or missing; events were parsed from TSJSON shards.
70    /// The cache was rebuilt afterwards.
71    FallbackRebuilt,
72    /// Cache was stale or missing; events were parsed from TSJSON shards.
73    /// Cache rebuild was attempted but failed (non-fatal).
74    FallbackRebuildFailed,
75}
76
77impl CacheManager {
78    /// Create a new cache manager.
79    ///
80    /// # Arguments
81    ///
82    /// * `events_dir` — Path to `.bones/events/` shard directory.
83    /// * `cache_path` — Path to `.bones/cache/events.bin`.
84    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    /// Check whether the cache file exists and is fresh.
92    ///
93    /// Returns `true` if:
94    /// - The cache file exists and can be opened.
95    /// - The stored fingerprint matches the current shard fingerprint.
96    ///
97    /// Returns `false` otherwise (missing, corrupt, stale, or on any error).
98    ///
99    /// # Errors
100    ///
101    /// Returns an error if computing the shard fingerprint fails.
102    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    /// Load events, preferring the binary cache when fresh.
114    ///
115    /// 1. Compute a fingerprint over event shard files.
116    /// 2. If cache exists and fingerprint matches → decode from cache.
117    /// 3. Otherwise → parse TSJSON, then rebuild the cache in the
118    ///    foreground (so next call is fast). Cache rebuild failures are
119    ///    logged but do not cause the load to fail.
120    ///
121    /// # Errors
122    ///
123    /// Returns an error only if TSJSON parsing itself fails. Cache errors
124    /// are handled by falling back to TSJSON.
125    pub fn load_events(&self) -> Result<LoadResult> {
126        let current_fp = self
127            .compute_fingerprint()
128            .context("compute shard fingerprint")?;
129
130        // Try cache fast path
131        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        // Fallback: parse TSJSON
151        let events = self.parse_tsjson()?;
152
153        // Rebuild cache for next time (best-effort)
154        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    /// Force a cache rebuild from TSJSON event shards.
169    ///
170    /// Returns statistics about the rebuilt cache.
171    ///
172    /// # Errors
173    ///
174    /// Returns an error if shard replay, parsing, or cache writing fails.
175    pub fn rebuild(&self) -> Result<crate::cache::CacheStats> {
176        rebuild_cache(&self.events_dir, &self.cache_path)
177    }
178
179    /// Return the path to the events directory.
180    #[must_use]
181    pub fn events_dir(&self) -> &Path {
182        &self.events_dir
183    }
184
185    /// Return the path to the cache file.
186    #[must_use]
187    pub fn cache_path(&self) -> &Path {
188        &self.cache_path
189    }
190
191    // -----------------------------------------------------------------------
192    // Private helpers
193    // -----------------------------------------------------------------------
194
195    /// Compute a fingerprint over the shard directory contents.
196    ///
197    /// The fingerprint is a hash of (filename, size, mtime) tuples for all
198    /// `.events` files, sorted by name. This is cheap (no content reading)
199    /// and catches additions, deletions, and modifications.
200    fn compute_fingerprint(&self) -> Result<u64> {
201        fingerprint_dir(&self.events_dir)
202    }
203
204    /// Parse TSJSON events from shards using the standard shard replay
205    /// pipeline.
206    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    /// Rebuild the cache with a specific fingerprint stored in the header's
221    /// `created_at_us` field.
222    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, // approximate
244        })
245    }
246}
247
248// ---------------------------------------------------------------------------
249// Fingerprinting
250// ---------------------------------------------------------------------------
251
252/// Compute a fingerprint over `.events` files in a directory.
253///
254/// Uses a sorted `BTreeMap` of (filename → (size, `mtime_ns`)) tuples, then
255/// hashes them with a simple FNV-1a-style combiner. Returns 0 if the
256/// directory doesn't exist or is empty.
257fn 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        // Only consider .events files (skip manifests, symlinks, etc.)
271        if !name.ends_with(".events") {
272            continue;
273        }
274
275        // Resolve symlinks for metadata
276        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    // Hash the sorted entries
288    let mut hash: u64 = 0xcbf2_9ce4_8422_2325; // FNV-1a offset basis
289    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); // FNV-1a prime
293        }
294        // Mix in size
295        for byte in size.to_le_bytes() {
296            hash ^= u64::from(byte);
297            hash = hash.wrapping_mul(0x0100_0000_01b3);
298        }
299        // Mix in mtime
300        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// ---------------------------------------------------------------------------
310// Tests
311// ---------------------------------------------------------------------------
312
313#[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    // === is_fresh =========================================================
388
389    #[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        // Now cache should be fresh
407        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        // Modify a shard file (append a new event)
420        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        // Cache should now be stale
428        assert!(!mgr.is_fresh().unwrap());
429    }
430
431    // === load_events ======================================================
432
433    #[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        // Cache file should now exist
445        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        // First load: from TSJSON (builds cache)
457        let r1 = mgr.load_events().unwrap();
458        assert_eq!(r1.events.len(), 2);
459        assert_eq!(r1.source, LoadSource::FallbackRebuilt);
460
461        // Second load: from cache
462        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        // Build initial cache
475        let _r1 = mgr.load_events().unwrap();
476
477        // Add a new event to make cache stale
478        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        // Second load: should fall back to TSJSON and rebuild
486        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    // === rebuild ==========================================================
501
502    #[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    // === fingerprinting ===================================================
515
516    #[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); // FNV offset basis with no data mixed in
521    }
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        // Add another event
537        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    // === integration: cache matches TSJSON ================================
549
550    #[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        // Load from TSJSON (builds cache)
560        let r1 = mgr.load_events().unwrap();
561        assert_eq!(r1.source, LoadSource::FallbackRebuilt);
562
563        // Load from cache
564        let r2 = mgr.load_events().unwrap();
565        assert_eq!(r2.source, LoadSource::Cache);
566
567        // Compare event data (excluding event_hash which may differ)
568        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}