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
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}