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/// Typed fragment-resolution failure, used for exit-code classification
19/// (agents get exit 4 + a machine-readable code instead of prose-only).
20#[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/// Result of looking up a fragment against the cache.
30///
31/// Generic over the cached entry type so the same enum serves both
32/// `MessageCache` (default: `CachedMessageRef`) and `EventCache`
33/// (`CacheLookup<CachedEventRef>`).
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum CacheLookup<T = CachedMessageRef> {
36    NotFound,
37    One(String, T),
38    /// Two or more entries match the fragment.
39    ///
40    /// The inner vec is capped at 10 entries by `find_by_fragment`
41    /// to keep error messages readable. If more than 10 entries actually match,
42    /// only the first 10 are surfaced; callers should ask the user for a
43    /// longer fragment rather than try to enumerate all candidates.
44    Ambiguous(Vec<(String, T)>),
45}
46
47/// Compute the 8-char hex short hash for a Graph ID.
48/// Deterministic: same input -> same output. Stable across pidge runs and machines.
49pub 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    /// Default path: `${XDG_CACHE_HOME:-~/.cache}/pidge/messages.json`.
74    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    /// Load the cache from the default path. Missing file -> empty cache.
83    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    /// Insert messages and evict oldest entries if over MAX_ENTRIES.
111    /// Each tuple: (graph_id, account_email). Hashes are computed here.
112    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    /// Find a message by a fragment of its short hash. The fragment may be a
145    /// prefix, suffix, or any contiguous substring of the 8-char hash.
146    /// Empty fragment is treated as NotFound.
147    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/// Cached pointer to a Microsoft Graph event. Keyed in the cache by
171/// `short_hash("{account}|{event_id}")` so the same event ID under
172/// different signed-in accounts gets distinct hashes.
173#[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    /// Default path: `${XDG_CACHE_HOME:-~/.cache}/pidge/events.json`.
200    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    /// Insert event refs and evict oldest entries if over MAX_EVENT_ENTRIES.
236    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        // Pre-populate with MAX_ENTRIES old entries
352        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        // Insert one new entry, should evict one old entry
368        cache.insert_many(&[("new-graph-id".into(), "user@example.com".into())]);
369        assert_eq!(cache.entries.len(), MAX_ENTRIES);
370
371        // The new entry must be present
372        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}