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