Skip to main content

pidge_core/
cache.rs

1//! Persistent cache mapping short message hashes to Microsoft Graph IDs.
2//!
3//! Pidge identifies messages by an 8-char hex short hash derived from `sha256` of
4//! the Graph ID. The full Graph ID is opaque (~100+ characters) and not human-typable;
5//! the short hash is intended for `pidge mail show <fragment>` substring lookup.
6
7use 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/// Result of looking up a fragment against the cache.
19///
20/// Generic over the cached entry type so the same enum serves both
21/// `MessageCache` (default: `CachedMessageRef`) and `EventCache`
22/// (`CacheLookup<CachedEventRef>`).
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum CacheLookup<T = CachedMessageRef> {
25    NotFound,
26    One(String, T),
27    /// Two or more entries match the fragment.
28    ///
29    /// The inner vec is capped at 10 entries by `find_by_fragment`
30    /// to keep error messages readable. If more than 10 entries actually match,
31    /// only the first 10 are surfaced — callers should ask the user for a
32    /// longer fragment rather than try to enumerate all candidates.
33    Ambiguous(Vec<(String, T)>),
34}
35
36/// Compute the 8-char hex short hash for a Graph ID.
37/// Deterministic: same input -> same output. Stable across pidge runs and machines.
38pub 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    /// Default path: `${XDG_CACHE_HOME:-~/.cache}/pidge/messages.json`.
63    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    /// Load the cache from the default path. Missing file -> empty cache.
72    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    /// Insert messages and evict oldest entries if over MAX_ENTRIES.
100    /// Each tuple: (graph_id, account_email). Hashes are computed here.
101    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    /// Find a message by a fragment of its short hash. The fragment may be a
134    /// prefix, suffix, or any contiguous substring of the 8-char hash.
135    /// Empty fragment is treated as NotFound.
136    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/// Cached pointer to a Microsoft Graph event. Keyed in the cache by
160/// `short_hash("{account}|{event_id}")` so the same event ID under
161/// different signed-in accounts gets distinct hashes.
162#[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    /// Default path: `${XDG_CACHE_HOME:-~/.cache}/pidge/events.json`.
189    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    /// Insert event refs and evict oldest entries if over MAX_EVENT_ENTRIES.
225    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        // Pre-populate with MAX_ENTRIES old entries
341        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        // Insert one new entry — should evict one old entry
357        cache.insert_many(&[("new-graph-id".into(), "user@example.com".into())]);
358        assert_eq!(cache.entries.len(), MAX_ENTRIES);
359
360        // The new entry must be present
361        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}