1use 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#[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
248#[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 #[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 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 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 assert!(!mgr.is_fresh().unwrap());
368 }
369
370 #[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 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 let r1 = mgr.load_events().unwrap();
397 assert_eq!(r1.events.len(), 2);
398 assert_eq!(r1.source, LoadSource::FallbackRebuilt);
399
400 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 let _r1 = mgr.load_events().unwrap();
415
416 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 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 #[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 #[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); }
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 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 #[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 let r1 = mgr.load_events().unwrap();
518 assert_eq!(r1.source, LoadSource::FallbackRebuilt);
519
520 let r2 = mgr.load_events().unwrap();
522 assert_eq!(r2.source, LoadSource::Cache);
523
524 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}