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 = crate::paths::cache_dir().ok_or(CoreError::NoConfigDir)?;
57        std::fs::create_dir_all(&dir)?;
58        Ok(dir.join("contacts.json"))
59    }
60
61    pub fn load() -> Result<Self, CoreError> {
62        Self::load_from(&Self::default_path()?)
63    }
64
65    pub fn load_from(path: &Path) -> Result<Self, CoreError> {
66        if !path.exists() {
67            return Ok(Self::default());
68        }
69        let text = std::fs::read_to_string(path)?;
70        let cache: ContactsCache = serde_json::from_str(&text)
71            .map_err(|e| CoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
72        Ok(cache)
73    }
74
75    pub fn save(&self) -> Result<(), CoreError> {
76        self.save_to(&Self::default_path()?)
77    }
78
79    pub fn save_to(&self, path: &Path) -> Result<(), CoreError> {
80        let text = serde_json::to_string_pretty(self)
81            .map_err(|e| CoreError::Io(std::io::Error::new(std::io::ErrorKind::InvalidData, e)))?;
82        std::fs::write(path, text)?;
83        Ok(())
84    }
85
86    /// Insert one observation. Email is lowercased; display name is only
87    /// applied when non-empty (we never overwrite a known name with `""`).
88    /// `last_seen` advances to the later of the existing and new values so
89    /// out-of-order refreshes converge to the right state.
90    pub fn upsert(
91        &mut self,
92        email: &str,
93        display_name: &str,
94        seen_at: DateTime<Utc>,
95        source: ContactSource,
96    ) {
97        let email = email.trim().to_lowercase();
98        if email.is_empty() {
99            return;
100        }
101        let entry = self
102            .by_email
103            .entry(email.clone())
104            .or_insert_with(|| Contact {
105                email: email.clone(),
106                display_name: String::new(),
107                last_seen: seen_at,
108                seen_in_mail: 0,
109                seen_in_calendar: 0,
110            });
111        let name = display_name.trim();
112        if !name.is_empty() {
113            entry.display_name = name.to_string();
114        }
115        if seen_at > entry.last_seen {
116            entry.last_seen = seen_at;
117        }
118        match source {
119            ContactSource::Mail => entry.seen_in_mail = entry.seen_in_mail.saturating_add(1),
120            ContactSource::Calendar => {
121                entry.seen_in_calendar = entry.seen_in_calendar.saturating_add(1)
122            }
123        }
124    }
125
126    pub fn mark_refreshed(&mut self, at: DateTime<Utc>) {
127        self.last_refreshed = Some(at);
128    }
129
130    /// Resolve a token the way the MCP surface does: names don't need the
131    /// CLI's `@` prefix convention. A token containing `@` followed by a
132    /// `.` in the domain part is treated as a literal address; anything
133    /// else (with or without a leading `@`) is looked up as a name.
134    pub fn resolve_any(&self, token: &str) -> ResolveOutcome {
135        let t = token.trim();
136        let looks_like_address = t.split_once('@').is_some_and(|(_, d)| d.contains('.'));
137        if looks_like_address {
138            return ResolveOutcome::Literal(t.to_string());
139        }
140        let lookup = format!("@{}", t.trim_start_matches('@'));
141        resolve_one(&lookup, self)
142    }
143}
144
145/// Resolution outcome for a single token.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum ResolveOutcome {
148    /// Token had no `@` prefix; passed through verbatim.
149    Literal(String),
150    /// Token matched exactly one contact.
151    One(String),
152    /// Token matched zero contacts.
153    Unknown(String),
154    /// Token matched more than one contact (most recent first, capped at 8).
155    Ambiguous {
156        token: String,
157        candidates: Vec<Contact>,
158    },
159}
160
161/// Pure resolution function. No I/O.
162///
163/// Tokens follow this contract:
164/// - Without a leading `@`, the token is treated as a literal email address
165///   and passed through unchanged. Existing behaviour for `--invite
166///   alice@x.com` is preserved exactly.
167/// - With a leading `@`, the rest of the token is looked up in
168///   `ContactsCache`. Exact email matches win; otherwise a case-insensitive
169///   substring match runs over the email, its local-part, and the display
170///   name.
171///
172/// Multi-match resolution **errors** rather than prompting; the agent-first
173/// CLI design prefers deterministic failure with the candidate list over
174/// interactive picking that breaks scripting.
175pub fn resolve_one(token: &str, cache: &ContactsCache) -> ResolveOutcome {
176    let trimmed = token.trim();
177    let Some(query) = trimmed.strip_prefix('@') else {
178        return ResolveOutcome::Literal(trimmed.to_string());
179    };
180    let query_lc = query.to_lowercase();
181    if query_lc.is_empty() {
182        return ResolveOutcome::Unknown(token.to_string());
183    }
184    if let Some(c) = cache.by_email.get(&query_lc) {
185        return ResolveOutcome::One(c.email.clone());
186    }
187    let mut matches: Vec<Contact> = cache
188        .by_email
189        .values()
190        .filter(|c| contact_matches(c, &query_lc))
191        .cloned()
192        .collect();
193    matches.sort_by_key(|c| std::cmp::Reverse(c.last_seen));
194    match matches.len() {
195        0 => ResolveOutcome::Unknown(token.to_string()),
196        1 => ResolveOutcome::One(matches.remove(0).email),
197        _ => ResolveOutcome::Ambiguous {
198            token: token.to_string(),
199            candidates: matches.into_iter().take(8).collect(),
200        },
201    }
202}
203
204/// Whether a contact matches a query under the same rules used by
205/// `resolve_one` (case-insensitive substring on email, local-part, or name).
206/// Exposed for `contacts find` to keep the predicate consistent.
207pub fn contact_matches(c: &Contact, q: &str) -> bool {
208    if c.email.to_lowercase().contains(q) {
209        return true;
210    }
211    let local_part = c.email.split('@').next().unwrap_or("").to_lowercase();
212    if local_part.contains(q) {
213        return true;
214    }
215    c.display_name.to_lowercase().contains(q)
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use chrono::TimeZone;
222
223    fn dt(y: i32, m: u32, d: u32) -> DateTime<Utc> {
224        Utc.with_ymd_and_hms(y, m, d, 12, 0, 0).unwrap()
225    }
226
227    #[test]
228    fn default_cache_is_empty() {
229        let c = ContactsCache::default();
230        assert!(c.by_email.is_empty());
231        assert!(c.last_refreshed.is_none());
232    }
233
234    #[test]
235    fn upsert_inserts_new_contact() {
236        let mut c = ContactsCache::default();
237        c.upsert(
238            "Dino@Needefy.SE",
239            "Dino Semovic",
240            dt(2026, 5, 21),
241            ContactSource::Calendar,
242        );
243        let entry = c.by_email.get("dino@needefy.se").expect("inserted");
244        assert_eq!(entry.email, "dino@needefy.se");
245        assert_eq!(entry.display_name, "Dino Semovic");
246        assert_eq!(entry.seen_in_calendar, 1);
247        assert_eq!(entry.seen_in_mail, 0);
248    }
249
250    #[test]
251    fn upsert_merges_by_lowercase_email() {
252        let mut c = ContactsCache::default();
253        c.upsert("Bob@x.com", "Bob B.", dt(2026, 5, 20), ContactSource::Mail);
254        c.upsert("bob@X.com", "Bob B.", dt(2026, 5, 21), ContactSource::Mail);
255        assert_eq!(c.by_email.len(), 1);
256        let entry = c.by_email.get("bob@x.com").unwrap();
257        assert_eq!(entry.seen_in_mail, 2);
258    }
259
260    #[test]
261    fn upsert_keeps_latest_last_seen_regardless_of_order() {
262        let mut c = ContactsCache::default();
263        c.upsert("a@b.com", "A", dt(2026, 5, 21), ContactSource::Mail);
264        c.upsert("a@b.com", "A", dt(2026, 5, 10), ContactSource::Mail);
265        assert_eq!(
266            c.by_email.get("a@b.com").unwrap().last_seen,
267            dt(2026, 5, 21)
268        );
269    }
270
271    #[test]
272    fn upsert_preserves_name_when_new_is_empty() {
273        let mut c = ContactsCache::default();
274        c.upsert("a@b.com", "Alice", dt(2026, 5, 20), ContactSource::Mail);
275        c.upsert("a@b.com", "", dt(2026, 5, 21), ContactSource::Mail);
276        assert_eq!(c.by_email.get("a@b.com").unwrap().display_name, "Alice");
277    }
278
279    #[test]
280    fn upsert_updates_name_when_new_provided() {
281        let mut c = ContactsCache::default();
282        c.upsert("a@b.com", "Alice", dt(2026, 5, 20), ContactSource::Mail);
283        c.upsert(
284            "a@b.com",
285            "Alice Andersson",
286            dt(2026, 5, 21),
287            ContactSource::Mail,
288        );
289        assert_eq!(
290            c.by_email.get("a@b.com").unwrap().display_name,
291            "Alice Andersson"
292        );
293    }
294
295    #[test]
296    fn upsert_skips_empty_email() {
297        let mut c = ContactsCache::default();
298        c.upsert("", "Ghost", dt(2026, 5, 21), ContactSource::Mail);
299        c.upsert("   ", "Whitespace", dt(2026, 5, 21), ContactSource::Mail);
300        assert!(c.by_email.is_empty());
301    }
302
303    #[test]
304    fn cache_roundtrips_through_file() {
305        let dir = tempfile::tempdir().unwrap();
306        let path = dir.path().join("contacts.json");
307        let mut c = ContactsCache::default();
308        c.upsert("x@y.com", "X Y", dt(2026, 5, 21), ContactSource::Calendar);
309        c.mark_refreshed(dt(2026, 5, 21));
310        c.save_to(&path).unwrap();
311        let loaded = ContactsCache::load_from(&path).unwrap();
312        assert_eq!(loaded.by_email.len(), 1);
313        assert_eq!(loaded.last_refreshed, Some(dt(2026, 5, 21)));
314        assert_eq!(loaded.by_email.get("x@y.com").unwrap().display_name, "X Y");
315    }
316
317    fn cache_with(entries: &[(&str, &str, u32, u32, u32)]) -> ContactsCache {
318        let mut c = ContactsCache::default();
319        for (email, name, day, _mail_count, _cal_count) in entries {
320            c.upsert(email, name, dt(2026, 5, *day), ContactSource::Calendar);
321        }
322        c
323    }
324
325    #[test]
326    fn token_without_at_prefix_passes_through_literally() {
327        let cache = ContactsCache::default();
328        assert_eq!(
329            resolve_one("alice@x.com", &cache),
330            ResolveOutcome::Literal("alice@x.com".into())
331        );
332    }
333
334    #[test]
335    fn exact_email_match_after_at_prefix_wins() {
336        let cache = cache_with(&[
337            ("dino@needefy.se", "Dino Semovic", 20, 0, 1),
338            ("dino@elsewhere.com", "Dino Other", 19, 0, 1),
339        ]);
340        assert_eq!(
341            resolve_one("@dino@needefy.se", &cache),
342            ResolveOutcome::One("dino@needefy.se".into())
343        );
344    }
345
346    #[test]
347    fn substring_matches_display_name() {
348        let cache = cache_with(&[("dino@needefy.se", "Dino Semovic", 20, 0, 1)]);
349        assert_eq!(
350            resolve_one("@dino", &cache),
351            ResolveOutcome::One("dino@needefy.se".into())
352        );
353    }
354
355    #[test]
356    fn substring_matches_email_local_part() {
357        let cache = cache_with(&[("bob.smith@x.com", "", 20, 0, 1)]);
358        assert_eq!(
359            resolve_one("@smith", &cache),
360            ResolveOutcome::One("bob.smith@x.com".into())
361        );
362    }
363
364    #[test]
365    fn matching_is_case_insensitive() {
366        let cache = cache_with(&[("dino@needefy.se", "Dino Semovic", 20, 0, 1)]);
367        assert_eq!(
368            resolve_one("@DINO", &cache),
369            ResolveOutcome::One("dino@needefy.se".into())
370        );
371    }
372
373    #[test]
374    fn multiple_matches_return_ambiguous_with_recent_first() {
375        let cache = cache_with(&[
376            ("john.smith@a.com", "John Smith", 18, 0, 1),
377            ("john.doe@b.com", "John Doe", 20, 0, 1),
378        ]);
379        let r = resolve_one("@john", &cache);
380        match r {
381            ResolveOutcome::Ambiguous { token, candidates } => {
382                assert_eq!(token, "@john");
383                assert_eq!(candidates.len(), 2);
384                assert_eq!(candidates[0].email, "john.doe@b.com");
385                assert_eq!(candidates[1].email, "john.smith@a.com");
386            }
387            other => panic!("expected Ambiguous, got {other:?}"),
388        }
389    }
390
391    #[test]
392    fn no_match_returns_unknown_with_original_token() {
393        let cache = ContactsCache::default();
394        assert_eq!(
395            resolve_one("@nope", &cache),
396            ResolveOutcome::Unknown("@nope".into())
397        );
398    }
399
400    #[test]
401    fn bare_at_token_is_unknown() {
402        let cache = cache_with(&[("a@b.com", "A", 20, 0, 1)]);
403        assert_eq!(
404            resolve_one("@", &cache),
405            ResolveOutcome::Unknown("@".into())
406        );
407    }
408
409    #[test]
410    fn resolve_any_treats_bare_names_as_lookups() {
411        let mut c = ContactsCache::default();
412        c.upsert(
413            "anna@example.com",
414            "Anna Holmberg",
415            Utc::now(),
416            ContactSource::Mail,
417        );
418        assert_eq!(
419            c.resolve_any("anna"),
420            ResolveOutcome::One("anna@example.com".into())
421        );
422        assert_eq!(
423            c.resolve_any("bob@example.org"),
424            ResolveOutcome::Literal("bob@example.org".into())
425        );
426        assert!(matches!(
427            c.resolve_any("nobody"),
428            ResolveOutcome::Unknown(_)
429        ));
430    }
431}