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#[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    /// Load from storage_dir/.mps.local. Returns Default if absent or unparseable.
152    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    /// Atomically write to storage_dir/.mps.local (tmp + rename).
164    /// Also ensures .mps.local is listed in storage_dir/.gitignore.
165    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    /// Returns true if `epoch_ref` was notified within the last `cooldown_secs` seconds.
175    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    /// Record that `epoch_ref` was notified right now.
184    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    /// Returns true if the task briefing has already been sent today.
190    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    /// Record that the task briefing was sent today.
199    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    /// Remove notification entries older than `before_ts` (unix seconds).
209    pub fn prune(&mut self, before_ts: i64) {
210        self.notified.retain(|_, &mut ts| ts >= before_ts);
211    }
212}
213
214/// Add ".mps.local" to storage_dir/.gitignore if it isn't already there.
215/// Silently ignores I/O errors — gitignore is best-effort.
216fn 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); // notified 30s ago
298        assert!(m.was_notified("ref-1", 60)); // cooldown 60s → still fresh
299        assert!(!m.was_notified("ref-1", 20)); // cooldown 20s → expired
300    }
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()); // long past
333        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); // >= 5000 → kept
351        assert!(m.notified.contains_key("exact"));
352    }
353
354    // ── Iteration 11: MetaLocal save auto-gitignores .mps.local ──────────────
355
356    #[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        // Write .gitignore with entry already present.
375        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(); // save twice
379
380        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    // ── Iteration 12: MetaShared corrupted JSON falls back to default ─────────
386
387    #[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        // Must return Default, not panic.
393        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    // ── Iteration 13: was_notified exactly at cooldown boundary ─────────────
406
407    #[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        // ts = now − cooldown → age == cooldown → age < cooldown is false → NOT fresh.
412        m.notified.insert("ref".into(), now - 60);
413        // was_notified uses `now - ts < cooldown_secs`, so at exactly the boundary it's false.
414        assert!(
415            !m.was_notified("ref", 60),
416            "at exactly cooldown, entry is expired"
417        );
418        // Just inside cooldown (59s ago) → still fresh.
419        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    // ── Iteration 14: MetaShared save is atomic (tmp file cleaned up) ─────────
427
428    #[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        // Confirm no .tmp file remains.
434        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}