Skip to main content

mps/
meta.rs

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/// Notification settings — shared between Config and MetaConfig.
20/// Defined here to avoid a circular import between config.rs and meta.rs.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct NotifyConfig {
23    #[serde(default = "default_true")]
24    pub enabled: bool,
25    /// How many minutes around a reminder time counts as "due now".
26    #[serde(default = "five")]
27    pub window_minutes: u64,
28    /// Send a morning briefing listing all open tasks.
29    #[serde(default = "default_true")]
30    pub notify_open_tasks: bool,
31    /// If non-empty, only open tasks with one of these tags are included.
32    #[serde(default)]
33    pub open_task_tags: Vec<String>,
34    /// Time-of-day for the morning task briefing, e.g. "9am".
35    #[serde(default)]
36    pub task_notify_at: Option<String>,
37    /// Minimum minutes between repeat notifications for the same reminder.
38    #[serde(default = "sixty")]
39    pub task_cooldown_minutes: u64,
40    /// How many past days to scan for overdue open tasks.
41    #[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// ── Shared meta (.mps.meta — git-tracked) ────────────────────────────────────
60
61/// Machine-agnostic config layer stored in storage_dir/.mps.meta.
62/// Git-tracked: syncs across all devices on `mps autogit`.
63/// Fields are union-merged with ~/.mps_config.yaml at startup.
64#[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    /// Load from storage_dir/.mps.meta. Returns Default if file is absent or unparseable.
96    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    /// Atomically write to storage_dir/.mps.meta (tmp + rename).
108    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// ── Local meta (.mps.local — gitignored) ─────────────────────────────────────
118
119/// Per-device transient state: notification history and cache.
120/// Gitignored — never committed.
121#[derive(Debug, Clone, Serialize, Deserialize, Default)]
122pub struct MetaLocal {
123    #[serde(default)]
124    pub version: u32,
125    /// epoch_ref → unix timestamp (seconds) when notification was last sent.
126    #[serde(default)]
127    pub notified: HashMap<String, i64>,
128    /// "YYYY-MM-DD" of the last morning task briefing notification.
129    #[serde(default)]
130    pub last_task_date: Option<String>,
131    #[serde(default)]
132    pub cache: MetaCache,
133}
134
135/// Cached tag counts across all .mps files for `mps tags --all`.
136///
137/// The snapshot records `(filename, size_bytes)` for every .mps file at the time
138/// the cache was built.  Before using the cache the caller should check
139/// [`MetaCache::is_valid`] against the current file list; if any file was added,
140/// removed, or changed in size the cache is stale.
141#[derive(Debug, Clone, Serialize, Deserialize, Default)]
142pub struct MetaCache {
143    /// Sorted list of (filename, file_size_bytes) at cache-build time.
144    #[serde(default)]
145    pub files_snapshot: Vec<(String, u64)>,
146    /// Tag name → total occurrence count across all cached files.
147    #[serde(default)]
148    pub tag_counts: HashMap<String, u32>,
149}
150
151impl MetaCache {
152    /// Return true when `current_files` (sorted list of (filename, size)) matches
153    /// the stored snapshot exactly, meaning no file was added, removed, or changed.
154    pub fn is_valid(&self, current_files: &[(String, u64)]) -> bool {
155        self.files_snapshot == current_files
156    }
157
158    /// Build a snapshot from the current .mps file list.
159    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    /// Load from storage_dir/.mps.local. Returns Default if absent or unparseable.
190    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    /// Atomically write to storage_dir/.mps.local (tmp + rename).
202    /// Also ensures .mps.local is listed in storage_dir/.gitignore.
203    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    /// Returns true if `epoch_ref` was notified within the last `cooldown_secs` seconds.
213    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    /// Record that `epoch_ref` was notified right now.
222    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    /// Returns true if the task briefing has already been sent today.
228    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    /// Record that the task briefing was sent today.
237    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    /// Remove notification entries older than `before_ts` (unix seconds).
247    pub fn prune(&mut self, before_ts: i64) {
248        self.notified.retain(|_, &mut ts| ts >= before_ts);
249    }
250}
251
252/// Add ".mps.local" to storage_dir/.gitignore if it isn't already there.
253/// Silently ignores I/O errors — gitignore is best-effort.
254fn 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); // notified 30s ago
336        assert!(m.was_notified("ref-1", 60)); // cooldown 60s → still fresh
337        assert!(!m.was_notified("ref-1", 20)); // cooldown 20s → expired
338    }
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()); // long past
371        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); // >= 5000 → kept
389        assert!(m.notified.contains_key("exact"));
390    }
391
392    // ── Iteration 11: MetaLocal save auto-gitignores .mps.local ──────────────
393
394    #[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        // Write .gitignore with entry already present.
413        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(); // save twice
417
418        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    // ── Iteration 12: MetaShared corrupted JSON falls back to default ─────────
424
425    #[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        // Must return Default, not panic.
431        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    // ── Iteration 13: was_notified exactly at cooldown boundary ─────────────
444
445    #[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        // ts = now − cooldown → age == cooldown → age < cooldown is false → NOT fresh.
450        m.notified.insert("ref".into(), now - 60);
451        // was_notified uses `now - ts < cooldown_secs`, so at exactly the boundary it's false.
452        assert!(
453            !m.was_notified("ref", 60),
454            "at exactly cooldown, entry is expired"
455        );
456        // Just inside cooldown (59s ago) → still fresh.
457        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    // ── Iteration 14: MetaShared save is atomic (tmp file cleaned up) ─────────
465
466    #[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        // Confirm no .tmp file remains.
472        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    // ── MetaCache::is_valid / build_snapshot ──────────────────────────────────
504
505    #[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)]; // size changed
518        assert!(!cache.is_valid(&current));
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), // new file
528        ];
529        assert!(!cache.is_valid(&current));
530    }
531
532    #[test]
533    fn test_meta_cache_invalid_when_empty_vs_nonempty() {
534        let cache = crate::meta::MetaCache::default(); // empty snapshot
535        let current = vec![("20260101.1000.mps".into(), 42u64)];
536        assert!(!cache.is_valid(&current));
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        // sizes should match what we wrote
557        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}