1use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub struct CachedMessageRef {
13 pub graph_id: String,
14 pub account: String,
15 pub cached_at: DateTime<Utc>,
16}
17
18#[derive(Debug, thiserror::Error)]
21pub enum FragmentError {
22 #[error("no match found for fragment '{fragment}'")]
23 NotFound { fragment: String },
24
25 #[error("fragment '{fragment}' matches {count} entries; provide more characters")]
26 Ambiguous { fragment: String, count: usize },
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum CacheLookup<T = CachedMessageRef> {
36 NotFound,
37 One(String, T),
38 Ambiguous(Vec<(String, T)>),
45}
46
47pub fn short_hash(graph_id: &str) -> String {
50 let mut h = Sha256::new();
51 h.update(graph_id.as_bytes());
52 let result = h.finalize();
53 format!(
54 "{:02x}{:02x}{:02x}{:02x}",
55 result[0], result[1], result[2], result[3]
56 )
57}
58
59use std::collections::HashMap;
60use std::path::{Path, PathBuf};
61
62use crate::error::CoreError;
63
64const MAX_ENTRIES: usize = 1000;
65
66#[derive(Debug, Default, Clone, Serialize, Deserialize)]
67pub struct MessageCache {
68 #[serde(default)]
69 pub entries: HashMap<String, CachedMessageRef>,
70}
71
72impl MessageCache {
73 pub fn default_path() -> Result<PathBuf, CoreError> {
75 let dir = crate::paths::cache_dir().ok_or(CoreError::NoConfigDir)?;
76 std::fs::create_dir_all(&dir)?;
77 Ok(dir.join("messages.json"))
78 }
79
80 pub fn load() -> Result<Self, CoreError> {
82 let path = Self::default_path()?;
83 Self::load_from(&path)
84 }
85
86 pub fn load_from(path: &Path) -> Result<Self, CoreError> {
87 if !path.exists() {
88 return Ok(Self::default());
89 }
90 let text = std::fs::read_to_string(path)?;
91 let cache: MessageCache = serde_json::from_str(&text)
92 .map_err(|e| CoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
93 Ok(cache)
94 }
95
96 pub fn save(&self) -> Result<(), CoreError> {
97 let path = Self::default_path()?;
98 self.save_to(&path)
99 }
100
101 pub fn save_to(&self, path: &Path) -> Result<(), CoreError> {
102 let text = serde_json::to_string_pretty(self)
103 .map_err(|e| CoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
104 std::fs::write(path, text)?;
105 Ok(())
106 }
107
108 pub fn insert_many(&mut self, msgs: &[(String, String)]) {
111 let now = Utc::now();
112 for (graph_id, account) in msgs {
113 let hash = short_hash(graph_id);
114 self.entries.insert(
115 hash,
116 CachedMessageRef {
117 graph_id: graph_id.clone(),
118 account: account.clone(),
119 cached_at: now,
120 },
121 );
122 }
123 self.evict_oldest_if_needed();
124 }
125
126 fn evict_oldest_if_needed(&mut self) {
127 if self.entries.len() <= MAX_ENTRIES {
128 return;
129 }
130 let excess = self.entries.len() - MAX_ENTRIES;
131 let mut sorted: Vec<(String, DateTime<Utc>)> = self
132 .entries
133 .iter()
134 .map(|(k, v)| (k.clone(), v.cached_at))
135 .collect();
136 sorted.sort_by_key(|(_, t)| *t);
137 for (k, _) in sorted.into_iter().take(excess) {
138 self.entries.remove(&k);
139 }
140 }
141
142 pub fn find_by_fragment(&self, fragment: &str) -> CacheLookup {
146 if fragment.is_empty() {
147 return CacheLookup::NotFound;
148 }
149 let mut matches: Vec<(String, CachedMessageRef)> = self
150 .entries
151 .iter()
152 .filter(|(k, _)| k.contains(fragment))
153 .map(|(k, v)| (k.clone(), v.clone()))
154 .collect();
155 match matches.len() {
156 0 => CacheLookup::NotFound,
157 1 => {
158 let (k, v) = matches.remove(0);
159 CacheLookup::One(k, v)
160 }
161 _ => CacheLookup::Ambiguous(matches.into_iter().take(10).collect()),
162 }
163 }
164}
165
166const MAX_EVENT_ENTRIES: usize = 1000;
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
172pub struct CachedEventRef {
173 pub event_id: String,
174 pub calendar_id: String,
175 pub account: String,
176 pub cached_at: DateTime<Utc>,
177}
178
179impl CachedEventRef {
180 pub fn new(event_id: String, calendar_id: String, account: String) -> Self {
181 Self {
182 event_id,
183 calendar_id,
184 account,
185 cached_at: Utc::now(),
186 }
187 }
188}
189
190#[derive(Debug, Default, Clone, Serialize, Deserialize)]
191pub struct EventCache {
192 #[serde(default)]
193 pub entries: HashMap<String, CachedEventRef>,
194}
195
196impl EventCache {
197 pub fn default_path() -> Result<PathBuf, CoreError> {
199 let dir = crate::paths::cache_dir().ok_or(CoreError::NoConfigDir)?;
200 std::fs::create_dir_all(&dir)?;
201 Ok(dir.join("events.json"))
202 }
203
204 pub fn load() -> Result<Self, CoreError> {
205 let path = Self::default_path()?;
206 Self::load_from(&path)
207 }
208
209 pub fn load_from(path: &Path) -> Result<Self, CoreError> {
210 if !path.exists() {
211 return Ok(Self::default());
212 }
213 let text = std::fs::read_to_string(path)?;
214 let cache: EventCache = serde_json::from_str(&text)
215 .map_err(|e| CoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
216 Ok(cache)
217 }
218
219 pub fn save(&self) -> Result<(), CoreError> {
220 let path = Self::default_path()?;
221 self.save_to(&path)
222 }
223
224 pub fn save_to(&self, path: &Path) -> Result<(), CoreError> {
225 let text = serde_json::to_string_pretty(self)
226 .map_err(|e| CoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
227 std::fs::write(path, text)?;
228 Ok(())
229 }
230
231 pub fn insert_many(&mut self, events: &[CachedEventRef]) {
233 let now = Utc::now();
234 for e in events {
235 let key = format!("{}|{}", e.account, e.event_id);
236 let hash = short_hash(&key);
237 let mut entry = e.clone();
238 entry.cached_at = now;
239 self.entries.insert(hash, entry);
240 }
241 self.evict_oldest_if_needed();
242 }
243
244 fn evict_oldest_if_needed(&mut self) {
245 if self.entries.len() <= MAX_EVENT_ENTRIES {
246 return;
247 }
248 let excess = self.entries.len() - MAX_EVENT_ENTRIES;
249 let mut sorted: Vec<(String, DateTime<Utc>)> = self
250 .entries
251 .iter()
252 .map(|(k, v)| (k.clone(), v.cached_at))
253 .collect();
254 sorted.sort_by_key(|(_, t)| *t);
255 for (k, _) in sorted.into_iter().take(excess) {
256 self.entries.remove(&k);
257 }
258 }
259
260 pub fn find_by_fragment(&self, fragment: &str) -> CacheLookup<CachedEventRef> {
261 if fragment.is_empty() {
262 return CacheLookup::NotFound;
263 }
264 let mut matches: Vec<(String, CachedEventRef)> = self
265 .entries
266 .iter()
267 .filter(|(k, _)| k.contains(fragment))
268 .map(|(k, v)| (k.clone(), v.clone()))
269 .collect();
270 match matches.len() {
271 0 => CacheLookup::NotFound,
272 1 => {
273 let (k, v) = matches.remove(0);
274 CacheLookup::One(k, v)
275 }
276 _ => CacheLookup::Ambiguous(matches.into_iter().take(10).collect()),
277 }
278 }
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn short_hash_is_deterministic() {
287 let a = short_hash("AAMkAGI2TG93AAA=");
288 let b = short_hash("AAMkAGI2TG93AAA=");
289 assert_eq!(a, b);
290 }
291
292 #[test]
293 fn short_hash_is_8_hex_chars() {
294 let h = short_hash("anything");
295 assert_eq!(h.len(), 8);
296 assert!(h.chars().all(|c| c.is_ascii_hexdigit()));
297 }
298
299 #[test]
300 fn short_hash_differs_for_different_inputs() {
301 assert_ne!(short_hash("x"), short_hash("y"));
302 }
303
304 #[test]
305 fn empty_cache_roundtrips_through_file() {
306 let tmp = tempfile::TempDir::new().unwrap();
307 let path = tmp.path().join("messages.json");
308
309 let cache = MessageCache::default();
310 cache.save_to(&path).unwrap();
311 let loaded = MessageCache::load_from(&path).unwrap();
312 assert_eq!(loaded.entries.len(), 0);
313 }
314
315 #[test]
316 fn populated_cache_roundtrips_through_file() {
317 let tmp = tempfile::TempDir::new().unwrap();
318 let path = tmp.path().join("messages.json");
319
320 let mut cache = MessageCache::default();
321 cache.insert_many(&[
322 ("AAA".to_string(), "user@example.com".to_string()),
323 ("BBB".to_string(), "user@example.com".to_string()),
324 ]);
325 cache.save_to(&path).unwrap();
326
327 let loaded = MessageCache::load_from(&path).unwrap();
328 assert_eq!(loaded.entries.len(), 2);
329 let hash_aaa = short_hash("AAA");
330 let entry = loaded.entries.get(&hash_aaa).unwrap();
331 assert_eq!(entry.graph_id, "AAA");
332 assert_eq!(entry.account, "user@example.com");
333 }
334
335 #[test]
336 fn load_from_missing_file_returns_empty_cache() {
337 let tmp = tempfile::TempDir::new().unwrap();
338 let path = tmp.path().join("nonexistent.json");
339 let cache = MessageCache::load_from(&path).unwrap();
340 assert_eq!(cache.entries.len(), 0);
341 }
342
343 #[test]
344 fn insert_many_evicts_oldest_when_over_max() {
345 let mut cache = MessageCache::default();
346
347 let now = Utc::now();
349 let old_time = now - chrono::Duration::seconds(3600);
350 for i in 0..MAX_ENTRIES {
351 let hash = format!("{:08x}", i);
352 cache.entries.insert(
353 hash,
354 CachedMessageRef {
355 graph_id: format!("old-{}", i),
356 account: "user@example.com".into(),
357 cached_at: old_time,
358 },
359 );
360 }
361 assert_eq!(cache.entries.len(), MAX_ENTRIES);
362
363 cache.insert_many(&[("new-graph-id".into(), "user@example.com".into())]);
365 assert_eq!(cache.entries.len(), MAX_ENTRIES);
366
367 let new_hash = short_hash("new-graph-id");
369 assert!(cache.entries.contains_key(&new_hash));
370 }
371
372 fn cache_with(entries: &[(&str, &str)]) -> MessageCache {
373 let mut cache = MessageCache::default();
374 let pairs: Vec<(String, String)> = entries
375 .iter()
376 .map(|(g, a)| (g.to_string(), a.to_string()))
377 .collect();
378 cache.insert_many(&pairs);
379 cache
380 }
381
382 #[test]
383 fn find_by_fragment_returns_not_found_when_no_match() {
384 let cache = cache_with(&[("hello", "u@e.com")]);
385 assert!(matches!(
386 cache.find_by_fragment("zzzzzz"),
387 CacheLookup::NotFound
388 ));
389 }
390
391 #[test]
392 fn find_by_fragment_returns_not_found_for_empty_fragment() {
393 let cache = cache_with(&[("hello", "u@e.com")]);
394 assert!(matches!(cache.find_by_fragment(""), CacheLookup::NotFound));
395 }
396
397 #[test]
398 fn find_by_fragment_returns_one_for_exact_match() {
399 let cache = cache_with(&[("hello", "u@e.com")]);
400 let hash = short_hash("hello");
401 match cache.find_by_fragment(&hash) {
402 CacheLookup::One(h, _) => assert_eq!(h, hash),
403 other => panic!("expected One, got {other:?}"),
404 }
405 }
406
407 #[test]
408 fn find_by_fragment_matches_prefix() {
409 let cache = cache_with(&[("hello", "u@e.com")]);
410 let hash = short_hash("hello");
411 let prefix = &hash[..3];
412 assert!(matches!(
413 cache.find_by_fragment(prefix),
414 CacheLookup::One(_, _)
415 ));
416 }
417
418 #[test]
419 fn find_by_fragment_matches_suffix() {
420 let cache = cache_with(&[("hello", "u@e.com")]);
421 let hash = short_hash("hello");
422 let suffix = &hash[hash.len() - 3..];
423 assert!(matches!(
424 cache.find_by_fragment(suffix),
425 CacheLookup::One(_, _)
426 ));
427 }
428
429 #[test]
430 fn find_by_fragment_matches_middle_substring() {
431 let cache = cache_with(&[("hello", "u@e.com")]);
432 let hash = short_hash("hello");
433 let middle = &hash[2..5];
434 assert!(matches!(
435 cache.find_by_fragment(middle),
436 CacheLookup::One(_, _)
437 ));
438 }
439
440 #[test]
441 fn event_cache_roundtrips_through_file() {
442 let tmp = tempfile::TempDir::new().unwrap();
443 let path = tmp.path().join("events.json");
444
445 let mut cache = EventCache::default();
446 cache.insert_many(&[CachedEventRef::new(
447 "evt-1".into(),
448 "cal-1".into(),
449 "u@e.com".into(),
450 )]);
451 cache.save_to(&path).unwrap();
452
453 let loaded = EventCache::load_from(&path).unwrap();
454 assert_eq!(loaded.entries.len(), 1);
455 let h = short_hash("u@e.com|evt-1");
456 let e = loaded.entries.get(&h).unwrap();
457 assert_eq!(e.event_id, "evt-1");
458 assert_eq!(e.calendar_id, "cal-1");
459 assert_eq!(e.account, "u@e.com");
460 }
461
462 #[test]
463 fn event_hash_differs_for_different_accounts() {
464 let a = short_hash("user-a@x|same-event");
465 let b = short_hash("user-b@x|same-event");
466 assert_ne!(a, b);
467 }
468
469 #[test]
470 fn event_cache_find_by_fragment_returns_one_on_exact_match() {
471 let mut cache = EventCache::default();
472 cache.insert_many(&[CachedEventRef::new(
473 "evt-1".into(),
474 "cal-1".into(),
475 "u@e.com".into(),
476 )]);
477 let h = short_hash("u@e.com|evt-1");
478 match cache.find_by_fragment(&h) {
479 CacheLookup::One(found, _) => assert_eq!(found, h),
480 other => panic!("expected One, got {other:?}"),
481 }
482 }
483
484 #[test]
485 fn event_cache_load_from_missing_file_returns_empty() {
486 let tmp = tempfile::TempDir::new().unwrap();
487 let path = tmp.path().join("none.json");
488 let cache = EventCache::load_from(&path).unwrap();
489 assert!(cache.entries.is_empty());
490 }
491
492 #[test]
493 fn event_cache_evicts_oldest_when_over_max() {
494 let mut cache = EventCache::default();
495 let now = Utc::now();
496 let old = now - chrono::Duration::seconds(3600);
497 for i in 0..MAX_EVENT_ENTRIES {
498 let hash = format!("{:08x}", i);
499 cache.entries.insert(
500 hash,
501 CachedEventRef {
502 event_id: format!("old-{i}"),
503 calendar_id: "cal".into(),
504 account: "u@e.com".into(),
505 cached_at: old,
506 },
507 );
508 }
509 cache.insert_many(&[CachedEventRef::new(
510 "new".into(),
511 "cal".into(),
512 "u@e.com".into(),
513 )]);
514 assert_eq!(cache.entries.len(), MAX_EVENT_ENTRIES);
515 let new_hash = short_hash("u@e.com|new");
516 assert!(cache.entries.contains_key(&new_hash));
517 }
518
519 #[test]
520 fn find_by_fragment_returns_ambiguous_when_two_hashes_share_fragment() {
521 let mut cache = MessageCache::default();
522 let mut entries = Vec::new();
523 for i in 0..200 {
524 entries.push((format!("graph-{i}"), "u@e.com".to_string()));
525 }
526 cache.insert_many(&entries);
527
528 let hashes: Vec<String> = cache.entries.keys().cloned().collect();
529 let mut found_ambiguous = false;
530 'outer: for h in &hashes {
531 for start in 0..h.len() - 1 {
532 let frag = &h[start..start + 2];
533 let count = hashes.iter().filter(|other| other.contains(frag)).count();
534 if count >= 2
535 && let CacheLookup::Ambiguous(matches) = cache.find_by_fragment(frag)
536 {
537 assert!(matches.len() >= 2);
538 found_ambiguous = true;
539 break 'outer;
540 }
541 }
542 }
543 assert!(
544 found_ambiguous,
545 "expected at least one ambiguous fragment in 200-entry cache"
546 );
547 }
548}