1use crate::error::MpsError;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6fn default_true() -> bool {
7 true
8}
9fn five() -> u64 {
10 5
11}
12fn sixty() -> u64 {
13 60
14}
15fn seven() -> u64 {
16 7
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct NotifyConfig {
23 #[serde(default = "default_true")]
24 pub enabled: bool,
25 #[serde(default = "five")]
27 pub window_minutes: u64,
28 #[serde(default = "default_true")]
30 pub notify_open_tasks: bool,
31 #[serde(default)]
33 pub open_task_tags: Vec<String>,
34 #[serde(default)]
36 pub task_notify_at: Option<String>,
37 #[serde(default = "sixty")]
39 pub task_cooldown_minutes: u64,
40 #[serde(default = "seven")]
42 pub overdue_days: u64,
43}
44
45impl Default for NotifyConfig {
46 fn default() -> Self {
47 Self {
48 enabled: true,
49 window_minutes: 5,
50 notify_open_tasks: true,
51 open_task_tags: Vec::new(),
52 task_notify_at: None,
53 task_cooldown_minutes: 60,
54 overdue_days: 7,
55 }
56 }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize, Default)]
65pub struct MetaShared {
66 #[serde(default)]
67 pub version: u32,
68 #[serde(default)]
69 pub config: MetaConfig,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize, Default)]
73pub struct MetaConfig {
74 #[serde(default)]
75 pub type_aliases: HashMap<String, String>,
76 #[serde(default)]
77 pub command_aliases: HashMap<String, String>,
78 #[serde(default)]
79 pub default_command: Option<String>,
80 #[serde(default)]
81 pub custom_tags: Vec<String>,
82 #[serde(default)]
83 pub notify: NotifyConfig,
84}
85
86impl MetaShared {
87 pub fn filename() -> &'static str {
88 ".mps.meta"
89 }
90
91 pub fn path(storage_dir: &Path) -> PathBuf {
92 storage_dir.join(Self::filename())
93 }
94
95 pub fn load(storage_dir: &Path) -> Self {
97 let path = Self::path(storage_dir);
98 if !path.exists() {
99 return Self::default();
100 }
101 std::fs::read_to_string(&path)
102 .ok()
103 .and_then(|s| serde_json::from_str(&s).ok())
104 .unwrap_or_default()
105 }
106
107 pub fn save(&self, storage_dir: &Path) -> Result<(), MpsError> {
109 let path = Self::path(storage_dir);
110 let tmp = path.with_extension(format!("meta.tmp.{}", std::process::id()));
111 std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
112 std::fs::rename(&tmp, &path)?;
113 Ok(())
114 }
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize, Default)]
122pub struct MetaLocal {
123 #[serde(default)]
124 pub version: u32,
125 #[serde(default)]
127 pub notified: HashMap<String, i64>,
128 #[serde(default)]
130 pub last_task_date: Option<String>,
131 #[serde(default)]
132 pub cache: MetaCache,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, Default)]
136pub struct MetaCache {
137 pub tag_counts_date: Option<String>,
138 #[serde(default)]
139 pub tag_counts: HashMap<String, u32>,
140}
141
142impl MetaLocal {
143 pub fn filename() -> &'static str {
144 ".mps.local"
145 }
146
147 pub fn path(storage_dir: &Path) -> PathBuf {
148 storage_dir.join(Self::filename())
149 }
150
151 pub fn load(storage_dir: &Path) -> Self {
153 let path = Self::path(storage_dir);
154 if !path.exists() {
155 return Self::default();
156 }
157 std::fs::read_to_string(&path)
158 .ok()
159 .and_then(|s| serde_json::from_str(&s).ok())
160 .unwrap_or_default()
161 }
162
163 pub fn save(&self, storage_dir: &Path) -> Result<(), MpsError> {
166 let path = Self::path(storage_dir);
167 let tmp = path.with_extension(format!("local.tmp.{}", std::process::id()));
168 std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
169 std::fs::rename(&tmp, &path)?;
170 ensure_local_gitignored(storage_dir);
171 Ok(())
172 }
173
174 pub fn was_notified(&self, epoch_ref: &str, cooldown_secs: i64) -> bool {
176 if let Some(&ts) = self.notified.get(epoch_ref) {
177 let now = chrono::Local::now().timestamp();
178 return now - ts < cooldown_secs;
179 }
180 false
181 }
182
183 pub fn mark_notified(&mut self, epoch_ref: &str) {
185 self.notified
186 .insert(epoch_ref.to_string(), chrono::Local::now().timestamp());
187 }
188
189 pub fn task_briefing_done_today(&self) -> bool {
191 let today = chrono::Local::now()
192 .date_naive()
193 .format("%Y-%m-%d")
194 .to_string();
195 self.last_task_date.as_deref() == Some(today.as_str())
196 }
197
198 pub fn mark_task_briefing(&mut self) {
200 self.last_task_date = Some(
201 chrono::Local::now()
202 .date_naive()
203 .format("%Y-%m-%d")
204 .to_string(),
205 );
206 }
207
208 pub fn prune(&mut self, before_ts: i64) {
210 self.notified.retain(|_, &mut ts| ts >= before_ts);
211 }
212}
213
214fn ensure_local_gitignored(storage_dir: &Path) {
217 let gitignore = storage_dir.join(".gitignore");
218 let entry = ".mps.local";
219 let already_present = std::fs::read_to_string(&gitignore)
220 .map(|s| s.lines().any(|l| l.trim() == entry))
221 .unwrap_or(false);
222 if !already_present {
223 use std::io::Write;
224 if let Ok(mut f) = std::fs::OpenOptions::new()
225 .create(true)
226 .append(true)
227 .open(&gitignore)
228 {
229 let _ = writeln!(f, "{}", entry);
230 }
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 fn tmp_store() -> (tempfile::TempDir, std::path::PathBuf) {
239 let dir = tempfile::tempdir().unwrap();
240 let p = dir.path().to_path_buf();
241 (dir, p)
242 }
243
244 #[test]
245 fn test_meta_shared_load_absent_returns_default() {
246 let (_dir, p) = tmp_store();
247 let m = MetaShared::load(&p);
248 assert_eq!(m.version, 0);
249 assert!(m.config.type_aliases.is_empty());
250 }
251
252 #[test]
253 fn test_meta_shared_save_load_roundtrip() {
254 let (_dir, p) = tmp_store();
255 let mut m = MetaShared::default();
256 m.version = 1;
257 m.config.default_command = Some("list".into());
258 m.config.custom_tags = vec!["work".into(), "personal".into()];
259 m.config.type_aliases.insert("t".into(), "task".into());
260 m.save(&p).unwrap();
261
262 let m2 = MetaShared::load(&p);
263 assert_eq!(m2.version, 1);
264 assert_eq!(m2.config.default_command.as_deref(), Some("list"));
265 assert_eq!(m2.config.custom_tags, vec!["work", "personal"]);
266 assert_eq!(
267 m2.config.type_aliases.get("t").map(|s| s.as_str()),
268 Some("task")
269 );
270 }
271
272 #[test]
273 fn test_meta_local_load_absent_returns_default() {
274 let (_dir, p) = tmp_store();
275 let m = MetaLocal::load(&p);
276 assert!(m.notified.is_empty());
277 assert!(m.last_task_date.is_none());
278 }
279
280 #[test]
281 fn test_meta_local_save_load_roundtrip() {
282 let (_dir, p) = tmp_store();
283 let mut m = MetaLocal::default();
284 m.notified.insert("20260524.1".into(), 1000000);
285 m.last_task_date = Some("2026-05-24".into());
286 m.save(&p).unwrap();
287
288 let m2 = MetaLocal::load(&p);
289 assert_eq!(m2.notified.get("20260524.1").copied(), Some(1000000));
290 assert_eq!(m2.last_task_date.as_deref(), Some("2026-05-24"));
291 }
292
293 #[test]
294 fn test_was_notified_within_cooldown() {
295 let mut m = MetaLocal::default();
296 let now = chrono::Local::now().timestamp();
297 m.notified.insert("ref-1".into(), now - 30); assert!(m.was_notified("ref-1", 60)); assert!(!m.was_notified("ref-1", 20)); }
301
302 #[test]
303 fn test_was_notified_absent_returns_false() {
304 let m = MetaLocal::default();
305 assert!(!m.was_notified("no-such-ref", 3600));
306 }
307
308 #[test]
309 fn test_mark_notified_sets_timestamp() {
310 let mut m = MetaLocal::default();
311 assert!(!m.was_notified("ref-2", 60));
312 m.mark_notified("ref-2");
313 assert!(m.was_notified("ref-2", 60));
314 }
315
316 #[test]
317 fn test_task_briefing_done_today_false_by_default() {
318 let m = MetaLocal::default();
319 assert!(!m.task_briefing_done_today());
320 }
321
322 #[test]
323 fn test_mark_task_briefing_sets_today() {
324 let mut m = MetaLocal::default();
325 m.mark_task_briefing();
326 assert!(m.task_briefing_done_today());
327 }
328
329 #[test]
330 fn test_task_briefing_done_yesterday_is_false() {
331 let mut m = MetaLocal::default();
332 m.last_task_date = Some("2000-01-01".into()); assert!(!m.task_briefing_done_today());
334 }
335
336 #[test]
337 fn test_prune_removes_old_entries() {
338 let mut m = MetaLocal::default();
339 m.notified.insert("old".into(), 1000);
340 m.notified.insert("new".into(), 9_000_000_000);
341 m.prune(5_000_000);
342 assert!(!m.notified.contains_key("old"));
343 assert!(m.notified.contains_key("new"));
344 }
345
346 #[test]
347 fn test_prune_keeps_entries_at_boundary() {
348 let mut m = MetaLocal::default();
349 m.notified.insert("exact".into(), 5000);
350 m.prune(5000); assert!(m.notified.contains_key("exact"));
352 }
353
354 #[test]
357 fn test_save_auto_adds_mps_local_to_gitignore() {
358 let (_dir, p) = tmp_store();
359 let m = MetaLocal::default();
360 m.save(&p).unwrap();
361
362 let gitignore = p.join(".gitignore");
363 assert!(gitignore.exists(), ".gitignore must be created");
364 let content = std::fs::read_to_string(&gitignore).unwrap();
365 assert!(
366 content.lines().any(|l| l.trim() == ".mps.local"),
367 ".gitignore must contain .mps.local"
368 );
369 }
370
371 #[test]
372 fn test_save_does_not_duplicate_gitignore_entry() {
373 let (_dir, p) = tmp_store();
374 std::fs::write(p.join(".gitignore"), ".mps.local\n").unwrap();
376 let m = MetaLocal::default();
377 m.save(&p).unwrap();
378 m.save(&p).unwrap(); let content = std::fs::read_to_string(p.join(".gitignore")).unwrap();
381 let count = content.lines().filter(|l| l.trim() == ".mps.local").count();
382 assert_eq!(count, 1, "entry must not be duplicated");
383 }
384
385 #[test]
388 fn test_meta_shared_corrupted_json_returns_default() {
389 let (_dir, p) = tmp_store();
390 std::fs::write(p.join(".mps.meta"), "this is not json {{{").unwrap();
391 let m = MetaShared::load(&p);
392 assert_eq!(m.version, 0);
394 assert!(m.config.type_aliases.is_empty());
395 }
396
397 #[test]
398 fn test_meta_local_corrupted_json_returns_default() {
399 let (_dir, p) = tmp_store();
400 std::fs::write(p.join(".mps.local"), "not json at all").unwrap();
401 let m = MetaLocal::load(&p);
402 assert!(m.notified.is_empty());
403 }
404
405 #[test]
408 fn test_was_notified_exactly_at_cooldown_is_fresh() {
409 let mut m = MetaLocal::default();
410 let now = chrono::Local::now().timestamp();
411 m.notified.insert("ref".into(), now - 60);
413 assert!(
415 !m.was_notified("ref", 60),
416 "at exactly cooldown, entry is expired"
417 );
418 m.notified.insert("ref".into(), now - 59);
420 assert!(
421 m.was_notified("ref", 60),
422 "59s ago with 60s cooldown → fresh"
423 );
424 }
425
426 #[test]
429 fn test_meta_shared_atomic_save_no_tmp_file_left() {
430 let (_dir, p) = tmp_store();
431 let m = MetaShared::default();
432 m.save(&p).unwrap();
433 let leftovers: Vec<_> = std::fs::read_dir(&p)
435 .unwrap()
436 .filter_map(|e| e.ok())
437 .filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
438 .collect();
439 assert!(
440 leftovers.is_empty(),
441 "no .tmp files should remain after save"
442 );
443 }
444
445 #[test]
446 fn test_meta_local_atomic_save_no_tmp_file_left() {
447 let (_dir, p) = tmp_store();
448 let m = MetaLocal::default();
449 m.save(&p).unwrap();
450 let leftovers: Vec<_> = std::fs::read_dir(&p)
451 .unwrap()
452 .filter_map(|e| e.ok())
453 .filter(|e| {
454 let n = e.file_name();
455 let s = n.to_string_lossy();
456 s.contains(".tmp") && s.contains("local")
457 })
458 .collect();
459 assert!(
460 leftovers.is_empty(),
461 "no .tmp files should remain after save"
462 );
463 }
464}