1use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13use crate::error::CoreError;
14
15#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
18pub struct Contact {
19 pub email: String,
21 #[serde(default)]
24 pub display_name: String,
25 pub last_seen: DateTime<Utc>,
27 #[serde(default)]
29 pub seen_in_mail: u32,
30 #[serde(default)]
33 pub seen_in_calendar: u32,
34}
35
36#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum ContactSource {
49 Mail,
50 Calendar,
51}
52
53impl ContactsCache {
54 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
149pub enum ResolveOutcome {
150 Literal(String),
152 One(String),
154 Unknown(String),
156 Ambiguous {
158 token: String,
159 candidates: Vec<Contact>,
160 },
161}
162
163pub 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
206pub 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}