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