Skip to main content

pidge_core/
contacts.rs

1//! Local name → email index built from the user's own mail and calendar.
2//!
3//! The cache lives at `${XDG_CACHE_HOME:-~/.cache}/pidge/contacts.json` and
4//! mirrors the I/O patterns of `MessageCache` / `EventCache` (atomic write,
5//! lazy load, schema-tolerant via `#[serde(default)]`).
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13use crate::error::CoreError;
14
15/// One person known to pidge — collapsed from one or more mail / calendar
16/// observations of the same lowercase email address.
17#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
18pub struct Contact {
19    /// Canonical lowercase address. Used as the cache key.
20    pub email: String,
21    /// Display name as last observed. Empty until we see one — once set,
22    /// it is only replaced by another non-empty observation.
23    #[serde(default)]
24    pub display_name: String,
25    /// Most recent `received_at` (mail) or `start.at` (calendar) we saw.
26    pub last_seen: DateTime<Utc>,
27    /// How many inbox messages mentioned this address as the sender.
28    #[serde(default)]
29    pub seen_in_mail: u32,
30    /// How many calendar events mentioned this address as organizer or
31    /// attendee.
32    #[serde(default)]
33    pub seen_in_calendar: u32,
34}
35
36/// JSON-backed contact cache. Keyed by lowercase email.
37#[derive(Debug, Default, Clone, Serialize, Deserialize)]
38pub struct ContactsCache {
39    #[serde(default)]
40    pub by_email: HashMap<String, Contact>,
41    #[serde(default)]
42    pub last_refreshed: Option<DateTime<Utc>>,
43}
44
45/// Where a contact observation came from. Determines which `seen_in_*`
46/// counter gets incremented.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum ContactSource {
49    Mail,
50    Calendar,
51}
52
53impl ContactsCache {
54    /// `${XDG_CACHE_HOME:-~/.cache}/pidge/contacts.json`.
55    pub fn default_path() -> Result<PathBuf, CoreError> {
56        let dir = dirs::cache_dir()
57            .ok_or(CoreError::NoConfigDir)?
58            .join("pidge");
59        std::fs::create_dir_all(&dir)?;
60        Ok(dir.join("contacts.json"))
61    }
62
63    pub fn load() -> Result<Self, CoreError> {
64        Self::load_from(&Self::default_path()?)
65    }
66
67    pub fn load_from(path: &Path) -> Result<Self, CoreError> {
68        if !path.exists() {
69            return Ok(Self::default());
70        }
71        let text = std::fs::read_to_string(path)?;
72        let cache: ContactsCache = serde_json::from_str(&text)
73            .map_err(|e| CoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
74        Ok(cache)
75    }
76
77    pub fn save(&self) -> Result<(), CoreError> {
78        self.save_to(&Self::default_path()?)
79    }
80
81    pub fn save_to(&self, path: &Path) -> Result<(), CoreError> {
82        let text = serde_json::to_string_pretty(self)
83            .map_err(|e| CoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
84        std::fs::write(path, text)?;
85        Ok(())
86    }
87
88    /// Insert one observation. Email is lowercased; display name is only
89    /// applied when non-empty (we never overwrite a known name with `""`).
90    /// `last_seen` advances to the later of the existing and new values so
91    /// out-of-order refreshes converge to the right state.
92    pub fn upsert(
93        &mut self,
94        email: &str,
95        display_name: &str,
96        seen_at: DateTime<Utc>,
97        source: ContactSource,
98    ) {
99        let email = email.trim().to_lowercase();
100        if email.is_empty() {
101            return;
102        }
103        let entry = self
104            .by_email
105            .entry(email.clone())
106            .or_insert_with(|| Contact {
107                email: email.clone(),
108                display_name: String::new(),
109                last_seen: seen_at,
110                seen_in_mail: 0,
111                seen_in_calendar: 0,
112            });
113        let name = display_name.trim();
114        if !name.is_empty() {
115            entry.display_name = name.to_string();
116        }
117        if seen_at > entry.last_seen {
118            entry.last_seen = seen_at;
119        }
120        match source {
121            ContactSource::Mail => entry.seen_in_mail = entry.seen_in_mail.saturating_add(1),
122            ContactSource::Calendar => {
123                entry.seen_in_calendar = entry.seen_in_calendar.saturating_add(1)
124            }
125        }
126    }
127
128    pub fn mark_refreshed(&mut self, at: DateTime<Utc>) {
129        self.last_refreshed = Some(at);
130    }
131}
132
133#[cfg(test)]
134mod tests {
135    use super::*;
136    use chrono::TimeZone;
137
138    fn dt(y: i32, m: u32, d: u32) -> DateTime<Utc> {
139        Utc.with_ymd_and_hms(y, m, d, 12, 0, 0).unwrap()
140    }
141
142    #[test]
143    fn default_cache_is_empty() {
144        let c = ContactsCache::default();
145        assert!(c.by_email.is_empty());
146        assert!(c.last_refreshed.is_none());
147    }
148
149    #[test]
150    fn upsert_inserts_new_contact() {
151        let mut c = ContactsCache::default();
152        c.upsert(
153            "Dino@Needefy.SE",
154            "Dino Semovic",
155            dt(2026, 5, 21),
156            ContactSource::Calendar,
157        );
158        let entry = c.by_email.get("dino@needefy.se").expect("inserted");
159        assert_eq!(entry.email, "dino@needefy.se");
160        assert_eq!(entry.display_name, "Dino Semovic");
161        assert_eq!(entry.seen_in_calendar, 1);
162        assert_eq!(entry.seen_in_mail, 0);
163    }
164
165    #[test]
166    fn upsert_merges_by_lowercase_email() {
167        let mut c = ContactsCache::default();
168        c.upsert("Bob@x.com", "Bob B.", dt(2026, 5, 20), ContactSource::Mail);
169        c.upsert("bob@X.com", "Bob B.", dt(2026, 5, 21), ContactSource::Mail);
170        assert_eq!(c.by_email.len(), 1);
171        let entry = c.by_email.get("bob@x.com").unwrap();
172        assert_eq!(entry.seen_in_mail, 2);
173    }
174
175    #[test]
176    fn upsert_keeps_latest_last_seen_regardless_of_order() {
177        let mut c = ContactsCache::default();
178        c.upsert("a@b.com", "A", dt(2026, 5, 21), ContactSource::Mail);
179        c.upsert("a@b.com", "A", dt(2026, 5, 10), ContactSource::Mail);
180        assert_eq!(
181            c.by_email.get("a@b.com").unwrap().last_seen,
182            dt(2026, 5, 21)
183        );
184    }
185
186    #[test]
187    fn upsert_preserves_name_when_new_is_empty() {
188        let mut c = ContactsCache::default();
189        c.upsert("a@b.com", "Alice", dt(2026, 5, 20), ContactSource::Mail);
190        c.upsert("a@b.com", "", dt(2026, 5, 21), ContactSource::Mail);
191        assert_eq!(c.by_email.get("a@b.com").unwrap().display_name, "Alice");
192    }
193
194    #[test]
195    fn upsert_updates_name_when_new_provided() {
196        let mut c = ContactsCache::default();
197        c.upsert("a@b.com", "Alice", dt(2026, 5, 20), ContactSource::Mail);
198        c.upsert(
199            "a@b.com",
200            "Alice Andersson",
201            dt(2026, 5, 21),
202            ContactSource::Mail,
203        );
204        assert_eq!(
205            c.by_email.get("a@b.com").unwrap().display_name,
206            "Alice Andersson"
207        );
208    }
209
210    #[test]
211    fn upsert_skips_empty_email() {
212        let mut c = ContactsCache::default();
213        c.upsert("", "Ghost", dt(2026, 5, 21), ContactSource::Mail);
214        c.upsert("   ", "Whitespace", dt(2026, 5, 21), ContactSource::Mail);
215        assert!(c.by_email.is_empty());
216    }
217
218    #[test]
219    fn cache_roundtrips_through_file() {
220        let dir = tempfile::tempdir().unwrap();
221        let path = dir.path().join("contacts.json");
222        let mut c = ContactsCache::default();
223        c.upsert("x@y.com", "X Y", dt(2026, 5, 21), ContactSource::Calendar);
224        c.mark_refreshed(dt(2026, 5, 21));
225        c.save_to(&path).unwrap();
226        let loaded = ContactsCache::load_from(&path).unwrap();
227        assert_eq!(loaded.by_email.len(), 1);
228        assert_eq!(loaded.last_refreshed, Some(dt(2026, 5, 21)));
229        assert_eq!(loaded.by_email.get("x@y.com").unwrap().display_name, "X Y");
230    }
231}