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 = crate::paths::cache_dir().ok_or(CoreError::NoConfigDir)?;
76        std::fs::create_dir_all(&dir)?;
77        Ok(dir.join("messages.json"))
78    }
79
80    /// Load the cache from the default path. Missing file -> empty cache.
81    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    /// Insert messages and evict oldest entries if over MAX_ENTRIES.
109    /// Each tuple: (graph_id, account_email). Hashes are computed here.
110    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    /// Find a message by a fragment of its short hash. The fragment may be a
143    /// prefix, suffix, or any contiguous substring of the 8-char hash.
144    /// Empty fragment is treated as NotFound.
145    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/// Cached pointer to a Microsoft Graph event. Keyed in the cache by
169/// `short_hash("{account}|{event_id}")` so the same event ID under
170/// different signed-in accounts gets distinct hashes.
171#[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    /// Default path: `${XDG_CACHE_HOME:-~/.cache}/pidge/events.json`.
198    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    /// Insert event refs and evict oldest entries if over MAX_EVENT_ENTRIES.
232    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        // Pre-populate with MAX_ENTRIES old entries
348        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        // Insert one new entry, should evict one old entry
364        cache.insert_many(&[("new-graph-id".into(), "user@example.com".into())]);
365        assert_eq!(cache.entries.len(), MAX_ENTRIES);
366
367        // The new entry must be present
368        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}