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)]
142pub struct MetaCache {
143 #[serde(default)]
145 pub files_snapshot: Vec<(String, u64)>,
146 #[serde(default)]
148 pub tag_counts: HashMap<String, u32>,
149}
150
151impl MetaCache {
152 pub fn is_valid(&self, current_files: &[(String, u64)]) -> bool {
155 self.files_snapshot == current_files
156 }
157
158 pub fn build_snapshot(files: &std::path::Path) -> Vec<(String, u64)> {
160 let re = crate::constants::mps_file_name_regexp();
161 let mut snapshot: Vec<(String, u64)> = std::fs::read_dir(files)
162 .map(|rd| {
163 rd.filter_map(|e| e.ok())
164 .filter_map(|e| {
165 let name = e.file_name().to_string_lossy().into_owned();
166 if !re.is_match(&name) {
167 return None;
168 }
169 let size = e.metadata().ok()?.len();
170 Some((name, size))
171 })
172 .collect()
173 })
174 .unwrap_or_default();
175 snapshot.sort_by(|a, b| a.0.cmp(&b.0));
176 snapshot
177 }
178}
179
180impl MetaLocal {
181 pub fn filename() -> &'static str {
182 ".mps.local"
183 }
184
185 pub fn path(storage_dir: &Path) -> PathBuf {
186 storage_dir.join(Self::filename())
187 }
188
189 pub fn load(storage_dir: &Path) -> Self {
191 let path = Self::path(storage_dir);
192 if !path.exists() {
193 return Self::default();
194 }
195 std::fs::read_to_string(&path)
196 .ok()
197 .and_then(|s| serde_json::from_str(&s).ok())
198 .unwrap_or_default()
199 }
200
201 pub fn save(&self, storage_dir: &Path) -> Result<(), MpsError> {
204 let path = Self::path(storage_dir);
205 let tmp = path.with_extension(format!("local.tmp.{}", std::process::id()));
206 std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
207 std::fs::rename(&tmp, &path)?;
208 ensure_local_gitignored(storage_dir);
209 Ok(())
210 }
211
212 pub fn was_notified(&self, epoch_ref: &str, cooldown_secs: i64) -> bool {
214 if let Some(&ts) = self.notified.get(epoch_ref) {
215 let now = chrono::Local::now().timestamp();
216 return now - ts < cooldown_secs;
217 }
218 false
219 }
220
221 pub fn mark_notified(&mut self, epoch_ref: &str) {
223 self.notified
224 .insert(epoch_ref.to_string(), chrono::Local::now().timestamp());
225 }
226
227 pub fn task_briefing_done_today(&self) -> bool {
229 let today = chrono::Local::now()
230 .date_naive()
231 .format("%Y-%m-%d")
232 .to_string();
233 self.last_task_date.as_deref() == Some(today.as_str())
234 }
235
236 pub fn mark_task_briefing(&mut self) {
238 self.last_task_date = Some(
239 chrono::Local::now()
240 .date_naive()
241 .format("%Y-%m-%d")
242 .to_string(),
243 );
244 }
245
246 pub fn prune(&mut self, before_ts: i64) {
248 self.notified.retain(|_, &mut ts| ts >= before_ts);
249 }
250}
251
252fn ensure_local_gitignored(storage_dir: &Path) {
255 let gitignore = storage_dir.join(".gitignore");
256 let entry = ".mps.local";
257 let already_present = std::fs::read_to_string(&gitignore)
258 .map(|s| s.lines().any(|l| l.trim() == entry))
259 .unwrap_or(false);
260 if !already_present {
261 use std::io::Write;
262 if let Ok(mut f) = std::fs::OpenOptions::new()
263 .create(true)
264 .append(true)
265 .open(&gitignore)
266 {
267 let _ = writeln!(f, "{}", entry);
268 }
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275
276 fn tmp_store() -> (tempfile::TempDir, std::path::PathBuf) {
277 let dir = tempfile::tempdir().unwrap();
278 let p = dir.path().to_path_buf();
279 (dir, p)
280 }
281
282 #[test]
283 fn test_meta_shared_load_absent_returns_default() {
284 let (_dir, p) = tmp_store();
285 let m = MetaShared::load(&p);
286 assert_eq!(m.version, 0);
287 assert!(m.config.type_aliases.is_empty());
288 }
289
290 #[test]
291 fn test_meta_shared_save_load_roundtrip() {
292 let (_dir, p) = tmp_store();
293 let mut m = MetaShared::default();
294 m.version = 1;
295 m.config.default_command = Some("list".into());
296 m.config.custom_tags = vec!["work".into(), "personal".into()];
297 m.config.type_aliases.insert("t".into(), "task".into());
298 m.save(&p).unwrap();
299
300 let m2 = MetaShared::load(&p);
301 assert_eq!(m2.version, 1);
302 assert_eq!(m2.config.default_command.as_deref(), Some("list"));
303 assert_eq!(m2.config.custom_tags, vec!["work", "personal"]);
304 assert_eq!(
305 m2.config.type_aliases.get("t").map(|s| s.as_str()),
306 Some("task")
307 );
308 }
309
310 #[test]
311 fn test_meta_local_load_absent_returns_default() {
312 let (_dir, p) = tmp_store();
313 let m = MetaLocal::load(&p);
314 assert!(m.notified.is_empty());
315 assert!(m.last_task_date.is_none());
316 }
317
318 #[test]
319 fn test_meta_local_save_load_roundtrip() {
320 let (_dir, p) = tmp_store();
321 let mut m = MetaLocal::default();
322 m.notified.insert("20260524.1".into(), 1000000);
323 m.last_task_date = Some("2026-05-24".into());
324 m.save(&p).unwrap();
325
326 let m2 = MetaLocal::load(&p);
327 assert_eq!(m2.notified.get("20260524.1").copied(), Some(1000000));
328 assert_eq!(m2.last_task_date.as_deref(), Some("2026-05-24"));
329 }
330
331 #[test]
332 fn test_was_notified_within_cooldown() {
333 let mut m = MetaLocal::default();
334 let now = chrono::Local::now().timestamp();
335 m.notified.insert("ref-1".into(), now - 30); assert!(m.was_notified("ref-1", 60)); assert!(!m.was_notified("ref-1", 20)); }
339
340 #[test]
341 fn test_was_notified_absent_returns_false() {
342 let m = MetaLocal::default();
343 assert!(!m.was_notified("no-such-ref", 3600));
344 }
345
346 #[test]
347 fn test_mark_notified_sets_timestamp() {
348 let mut m = MetaLocal::default();
349 assert!(!m.was_notified("ref-2", 60));
350 m.mark_notified("ref-2");
351 assert!(m.was_notified("ref-2", 60));
352 }
353
354 #[test]
355 fn test_task_briefing_done_today_false_by_default() {
356 let m = MetaLocal::default();
357 assert!(!m.task_briefing_done_today());
358 }
359
360 #[test]
361 fn test_mark_task_briefing_sets_today() {
362 let mut m = MetaLocal::default();
363 m.mark_task_briefing();
364 assert!(m.task_briefing_done_today());
365 }
366
367 #[test]
368 fn test_task_briefing_done_yesterday_is_false() {
369 let mut m = MetaLocal::default();
370 m.last_task_date = Some("2000-01-01".into()); assert!(!m.task_briefing_done_today());
372 }
373
374 #[test]
375 fn test_prune_removes_old_entries() {
376 let mut m = MetaLocal::default();
377 m.notified.insert("old".into(), 1000);
378 m.notified.insert("new".into(), 9_000_000_000);
379 m.prune(5_000_000);
380 assert!(!m.notified.contains_key("old"));
381 assert!(m.notified.contains_key("new"));
382 }
383
384 #[test]
385 fn test_prune_keeps_entries_at_boundary() {
386 let mut m = MetaLocal::default();
387 m.notified.insert("exact".into(), 5000);
388 m.prune(5000); assert!(m.notified.contains_key("exact"));
390 }
391
392 #[test]
395 fn test_save_auto_adds_mps_local_to_gitignore() {
396 let (_dir, p) = tmp_store();
397 let m = MetaLocal::default();
398 m.save(&p).unwrap();
399
400 let gitignore = p.join(".gitignore");
401 assert!(gitignore.exists(), ".gitignore must be created");
402 let content = std::fs::read_to_string(&gitignore).unwrap();
403 assert!(
404 content.lines().any(|l| l.trim() == ".mps.local"),
405 ".gitignore must contain .mps.local"
406 );
407 }
408
409 #[test]
410 fn test_save_does_not_duplicate_gitignore_entry() {
411 let (_dir, p) = tmp_store();
412 std::fs::write(p.join(".gitignore"), ".mps.local\n").unwrap();
414 let m = MetaLocal::default();
415 m.save(&p).unwrap();
416 m.save(&p).unwrap(); let content = std::fs::read_to_string(p.join(".gitignore")).unwrap();
419 let count = content.lines().filter(|l| l.trim() == ".mps.local").count();
420 assert_eq!(count, 1, "entry must not be duplicated");
421 }
422
423 #[test]
426 fn test_meta_shared_corrupted_json_returns_default() {
427 let (_dir, p) = tmp_store();
428 std::fs::write(p.join(".mps.meta"), "this is not json {{{").unwrap();
429 let m = MetaShared::load(&p);
430 assert_eq!(m.version, 0);
432 assert!(m.config.type_aliases.is_empty());
433 }
434
435 #[test]
436 fn test_meta_local_corrupted_json_returns_default() {
437 let (_dir, p) = tmp_store();
438 std::fs::write(p.join(".mps.local"), "not json at all").unwrap();
439 let m = MetaLocal::load(&p);
440 assert!(m.notified.is_empty());
441 }
442
443 #[test]
446 fn test_was_notified_exactly_at_cooldown_is_fresh() {
447 let mut m = MetaLocal::default();
448 let now = chrono::Local::now().timestamp();
449 m.notified.insert("ref".into(), now - 60);
451 assert!(
453 !m.was_notified("ref", 60),
454 "at exactly cooldown, entry is expired"
455 );
456 m.notified.insert("ref".into(), now - 59);
458 assert!(
459 m.was_notified("ref", 60),
460 "59s ago with 60s cooldown → fresh"
461 );
462 }
463
464 #[test]
467 fn test_meta_shared_atomic_save_no_tmp_file_left() {
468 let (_dir, p) = tmp_store();
469 let m = MetaShared::default();
470 m.save(&p).unwrap();
471 let leftovers: Vec<_> = std::fs::read_dir(&p)
473 .unwrap()
474 .filter_map(|e| e.ok())
475 .filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
476 .collect();
477 assert!(
478 leftovers.is_empty(),
479 "no .tmp files should remain after save"
480 );
481 }
482
483 #[test]
484 fn test_meta_local_atomic_save_no_tmp_file_left() {
485 let (_dir, p) = tmp_store();
486 let m = MetaLocal::default();
487 m.save(&p).unwrap();
488 let leftovers: Vec<_> = std::fs::read_dir(&p)
489 .unwrap()
490 .filter_map(|e| e.ok())
491 .filter(|e| {
492 let n = e.file_name();
493 let s = n.to_string_lossy();
494 s.contains(".tmp") && s.contains("local")
495 })
496 .collect();
497 assert!(
498 leftovers.is_empty(),
499 "no .tmp files should remain after save"
500 );
501 }
502
503 #[test]
506 fn test_meta_cache_valid_when_snapshot_matches() {
507 let mut cache = crate::meta::MetaCache::default();
508 let snapshot = vec![("20260101.1000.mps".into(), 42u64)];
509 cache.files_snapshot = snapshot.clone();
510 assert!(cache.is_valid(&snapshot));
511 }
512
513 #[test]
514 fn test_meta_cache_invalid_when_file_size_changed() {
515 let mut cache = crate::meta::MetaCache::default();
516 cache.files_snapshot = vec![("20260101.1000.mps".into(), 42u64)];
517 let current = vec![("20260101.1000.mps".into(), 99u64)]; assert!(!cache.is_valid(¤t));
519 }
520
521 #[test]
522 fn test_meta_cache_invalid_when_file_added() {
523 let mut cache = crate::meta::MetaCache::default();
524 cache.files_snapshot = vec![("20260101.1000.mps".into(), 42u64)];
525 let current = vec![
526 ("20260101.1000.mps".into(), 42u64),
527 ("20260102.1001.mps".into(), 10u64), ];
529 assert!(!cache.is_valid(¤t));
530 }
531
532 #[test]
533 fn test_meta_cache_invalid_when_empty_vs_nonempty() {
534 let cache = crate::meta::MetaCache::default(); let current = vec![("20260101.1000.mps".into(), 42u64)];
536 assert!(!cache.is_valid(¤t));
537 }
538
539 #[test]
540 fn test_meta_cache_valid_when_both_empty() {
541 let cache = crate::meta::MetaCache::default();
542 assert!(cache.is_valid(&[]));
543 }
544
545 #[test]
546 fn test_build_snapshot_returns_sorted_mps_files_only() {
547 let (_dir, p) = tmp_store();
548 std::fs::write(p.join("20260101.1000000000.mps"), "content-a").unwrap();
549 std::fs::write(p.join("20260102.1000000001.mps"), "content-b").unwrap();
550 std::fs::write(p.join("not-an-mps.txt"), "ignored").unwrap();
551
552 let snapshot = crate::meta::MetaCache::build_snapshot(&p);
553 assert_eq!(snapshot.len(), 2, "only .mps files");
554 assert_eq!(snapshot[0].0, "20260101.1000000000.mps");
555 assert_eq!(snapshot[1].0, "20260102.1000000001.mps");
556 assert_eq!(snapshot[0].1, b"content-a".len() as u64);
558 assert_eq!(snapshot[1].1, b"content-b".len() as u64);
559 }
560
561 #[test]
562 fn test_build_snapshot_empty_dir() {
563 let (_dir, p) = tmp_store();
564 let snapshot = crate::meta::MetaCache::build_snapshot(&p);
565 assert!(snapshot.is_empty());
566 }
567}