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