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 = 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum ResolveOutcome {
148 Literal(String),
150 One(String),
152 Unknown(String),
154 Ambiguous {
156 token: String,
157 candidates: Vec<Contact>,
158 },
159}
160
161pub 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
204pub 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}