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::fs;
21use std::path::{Path, PathBuf};
22
23use anyhow::{Context, Result};
24
25use crate::cache::fingerprint_dir;
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// Tests
250// ---------------------------------------------------------------------------
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::event::data::{CreateData, MoveData};
256    use crate::event::writer;
257    use crate::event::{Event, EventData, EventType};
258    use crate::model::item::{Kind, State, Urgency};
259    use crate::model::item_id::ItemId;
260    use crate::shard::ShardManager;
261    use std::collections::BTreeMap;
262    use tempfile::TempDir;
263
264    fn setup_bones(events: &[Event]) -> (TempDir, PathBuf, PathBuf) {
265        let tmp = TempDir::new().unwrap();
266        let bones_dir = tmp.path().join(".bones");
267        let shard_mgr = ShardManager::new(&bones_dir);
268        shard_mgr.ensure_dirs().unwrap();
269        shard_mgr.init().unwrap();
270
271        for event in events {
272            let line = writer::write_line(event).unwrap();
273            let (year, month) = shard_mgr.active_shard().unwrap().unwrap();
274            shard_mgr.append_raw(year, month, &line).unwrap();
275        }
276
277        let events_dir = bones_dir.join("events");
278        let cache_path = bones_dir.join("cache/events.bin");
279        (tmp, events_dir, cache_path)
280    }
281
282    fn make_event(id: &str, ts: i64) -> Event {
283        let mut event = Event {
284            wall_ts_us: ts,
285            agent: "test-agent".to_string(),
286            itc: "itc:AQ".to_string(),
287            parents: vec![],
288            event_type: EventType::Create,
289            item_id: ItemId::new_unchecked(id),
290            data: EventData::Create(CreateData {
291                title: format!("Item {id}"),
292                kind: Kind::Task,
293                size: None,
294                urgency: Urgency::Default,
295                labels: vec![],
296                parent: None,
297                causation: None,
298                description: None,
299                extra: BTreeMap::new(),
300            }),
301            event_hash: String::new(),
302        };
303        writer::write_event(&mut event).unwrap();
304        event
305    }
306
307    fn make_move(id: &str, ts: i64, parent_hash: &str) -> Event {
308        let mut event = Event {
309            wall_ts_us: ts,
310            agent: "test-agent".to_string(),
311            itc: "itc:AQ".to_string(),
312            parents: vec![parent_hash.to_string()],
313            event_type: EventType::Move,
314            item_id: ItemId::new_unchecked(id),
315            data: EventData::Move(MoveData {
316                state: State::Doing,
317                reason: None,
318                extra: BTreeMap::new(),
319            }),
320            event_hash: String::new(),
321        };
322        writer::write_event(&mut event).unwrap();
323        event
324    }
325
326    // === is_fresh =========================================================
327
328    #[test]
329    fn is_fresh_returns_false_when_no_cache() {
330        let e1 = make_event("bn-001", 1000);
331        let (_tmp, events_dir, cache_path) = setup_bones(&[e1]);
332
333        let mgr = CacheManager::new(&events_dir, &cache_path);
334        assert!(!mgr.is_fresh().unwrap());
335    }
336
337    #[test]
338    fn is_fresh_returns_true_after_load() {
339        let e1 = make_event("bn-001", 1000);
340        let (_tmp, events_dir, cache_path) = setup_bones(&[e1]);
341
342        let mgr = CacheManager::new(&events_dir, &cache_path);
343        let _result = mgr.load_events().unwrap();
344
345        // Now cache should be fresh
346        assert!(mgr.is_fresh().unwrap());
347    }
348
349    #[test]
350    fn is_fresh_returns_false_after_shard_modification() {
351        let e1 = make_event("bn-001", 1000);
352        let (_tmp, events_dir, cache_path) = setup_bones(&[e1]);
353
354        let mgr = CacheManager::new(&events_dir, &cache_path);
355        let _result = mgr.load_events().unwrap();
356        assert!(mgr.is_fresh().unwrap());
357
358        // Modify a shard file (append a new event)
359        let bones_dir = events_dir.parent().unwrap();
360        let shard_mgr = ShardManager::new(bones_dir);
361        let e2 = make_event("bn-002", 2000);
362        let line = writer::write_line(&e2).unwrap();
363        let (year, month) = shard_mgr.active_shard().unwrap().unwrap();
364        shard_mgr.append_raw(year, month, &line).unwrap();
365
366        // Cache should now be stale
367        assert!(!mgr.is_fresh().unwrap());
368    }
369
370    // === load_events ======================================================
371
372    #[test]
373    fn load_events_from_tsjson_when_no_cache() {
374        let e1 = make_event("bn-001", 1000);
375        let (_tmp, events_dir, cache_path) = setup_bones(&[e1]);
376
377        let mgr = CacheManager::new(&events_dir, &cache_path);
378        let result = mgr.load_events().unwrap();
379
380        assert_eq!(result.events.len(), 1);
381        assert_eq!(result.source, LoadSource::FallbackRebuilt);
382
383        // Cache file should now exist
384        assert!(cache_path.exists());
385    }
386
387    #[test]
388    fn load_events_from_cache_when_fresh() {
389        let e1 = make_event("bn-001", 1000);
390        let e2 = make_event("bn-002", 2000);
391        let (_tmp, events_dir, cache_path) = setup_bones(&[e1, e2]);
392
393        let mgr = CacheManager::new(&events_dir, &cache_path);
394
395        // First load: from TSJSON (builds cache)
396        let r1 = mgr.load_events().unwrap();
397        assert_eq!(r1.events.len(), 2);
398        assert_eq!(r1.source, LoadSource::FallbackRebuilt);
399
400        // Second load: from cache
401        let r2 = mgr.load_events().unwrap();
402        assert_eq!(r2.events.len(), 2);
403        assert_eq!(r2.source, LoadSource::Cache);
404    }
405
406    #[test]
407    fn load_events_falls_back_on_stale_cache() {
408        let e1 = make_event("bn-001", 1000);
409        let (_tmp, events_dir, cache_path) = setup_bones(&[e1]);
410
411        let mgr = CacheManager::new(&events_dir, &cache_path);
412
413        // Build initial cache
414        let _r1 = mgr.load_events().unwrap();
415
416        // Add a new event to make cache stale
417        let bones_dir = events_dir.parent().unwrap();
418        let shard_mgr = ShardManager::new(bones_dir);
419        let e2 = make_event("bn-002", 2000);
420        let line = writer::write_line(&e2).unwrap();
421        let (year, month) = shard_mgr.active_shard().unwrap().unwrap();
422        shard_mgr.append_raw(year, month, &line).unwrap();
423
424        // Second load: should fall back to TSJSON and rebuild
425        let r2 = mgr.load_events().unwrap();
426        assert_eq!(r2.events.len(), 2);
427        assert_eq!(r2.source, LoadSource::FallbackRebuilt);
428    }
429
430    #[test]
431    fn load_events_empty_shard() {
432        let (_tmp, events_dir, cache_path) = setup_bones(&[]);
433
434        let mgr = CacheManager::new(&events_dir, &cache_path);
435        let result = mgr.load_events().unwrap();
436        assert!(result.events.is_empty());
437    }
438
439    // === rebuild ==========================================================
440
441    #[test]
442    fn rebuild_creates_cache_file() {
443        let e1 = make_event("bn-001", 1000);
444        let (_tmp, events_dir, cache_path) = setup_bones(&[e1]);
445
446        let mgr = CacheManager::new(&events_dir, &cache_path);
447        let stats = mgr.rebuild().unwrap();
448
449        assert_eq!(stats.total_events, 1);
450        assert!(cache_path.exists());
451    }
452
453    #[test]
454    fn rebuild_creates_fresh_cache_for_manager_fast_path() {
455        let e1 = make_event("bn-001", 1000);
456        let e2 = make_event("bn-002", 2000);
457        let (_tmp, events_dir, cache_path) = setup_bones(&[e1, e2]);
458
459        let mgr = CacheManager::new(&events_dir, &cache_path);
460        mgr.rebuild().unwrap();
461
462        assert!(
463            mgr.is_fresh().unwrap(),
464            "manual rebuild should write the freshness fingerprint expected by CacheManager"
465        );
466        let result = mgr.load_events().unwrap();
467        assert_eq!(result.source, LoadSource::Cache);
468        assert_eq!(result.events.len(), 2);
469    }
470
471    // === fingerprinting ===================================================
472
473    #[test]
474    fn fingerprint_empty_dir_is_zero() {
475        let tmp = TempDir::new().unwrap();
476        let fp = fingerprint_dir(tmp.path()).unwrap();
477        assert_eq!(fp, 0xcbf2_9ce4_8422_2325); // FNV offset basis with no data mixed in
478    }
479
480    #[test]
481    fn fingerprint_nonexistent_dir_is_zero() {
482        let fp = fingerprint_dir(Path::new("/tmp/nonexistent-bones-fp-dir")).unwrap();
483        assert_eq!(fp, 0);
484    }
485
486    #[test]
487    fn fingerprint_changes_when_file_added() {
488        let e1 = make_event("bn-001", 1000);
489        let (_tmp, events_dir, _cache_path) = setup_bones(&[e1]);
490
491        let fp1 = fingerprint_dir(&events_dir).unwrap();
492
493        // Add another event
494        let bones_dir = events_dir.parent().unwrap();
495        let shard_mgr = ShardManager::new(bones_dir);
496        let e2 = make_event("bn-002", 2000);
497        let line = writer::write_line(&e2).unwrap();
498        let (year, month) = shard_mgr.active_shard().unwrap().unwrap();
499        shard_mgr.append_raw(year, month, &line).unwrap();
500
501        let fp2 = fingerprint_dir(&events_dir).unwrap();
502        assert_ne!(fp1, fp2);
503    }
504
505    // === integration: cache matches TSJSON ================================
506
507    #[test]
508    fn cache_output_matches_tsjson_parse() {
509        let e1 = make_event("bn-001", 1000);
510        let e2 = make_move("bn-001", 2000, &e1.event_hash);
511        let e3 = make_event("bn-002", 3000);
512        let (_tmp, events_dir, cache_path) = setup_bones(&[e1, e2, e3]);
513
514        let mgr = CacheManager::new(&events_dir, &cache_path);
515
516        // Load from TSJSON (builds cache)
517        let r1 = mgr.load_events().unwrap();
518        assert_eq!(r1.source, LoadSource::FallbackRebuilt);
519
520        // Load from cache
521        let r2 = mgr.load_events().unwrap();
522        assert_eq!(r2.source, LoadSource::Cache);
523
524        // Compare event data (excluding event_hash which may differ)
525        assert_eq!(r1.events.len(), r2.events.len());
526        for (a, b) in r1.events.iter().zip(r2.events.iter()) {
527            assert_eq!(a.wall_ts_us, b.wall_ts_us);
528            assert_eq!(a.agent, b.agent);
529            assert_eq!(a.event_type, b.event_type);
530            assert_eq!(a.item_id, b.item_id);
531        }
532    }
533}