Skip to main content

mps/
meta.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use serde::{Deserialize, Serialize};
4use crate::error::MpsError;
5
6fn default_true()  -> bool { true }
7fn five()          -> u64  { 5 }
8fn sixty()         -> u64  { 60 }
9fn seven()         -> u64  { 7 }
10
11/// Notification settings — shared between Config and MetaConfig.
12/// Defined here to avoid a circular import between config.rs and meta.rs.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct NotifyConfig {
15    #[serde(default = "default_true")]
16    pub enabled: bool,
17    /// How many minutes around a reminder time counts as "due now".
18    #[serde(default = "five")]
19    pub window_minutes: u64,
20    /// Send a morning briefing listing all open tasks.
21    #[serde(default = "default_true")]
22    pub notify_open_tasks: bool,
23    /// If non-empty, only open tasks with one of these tags are included.
24    #[serde(default)]
25    pub open_task_tags: Vec<String>,
26    /// Time-of-day for the morning task briefing, e.g. "9am".
27    #[serde(default)]
28    pub task_notify_at: Option<String>,
29    /// Minimum minutes between repeat notifications for the same reminder.
30    #[serde(default = "sixty")]
31    pub task_cooldown_minutes: u64,
32    /// How many past days to scan for overdue open tasks.
33    #[serde(default = "seven")]
34    pub overdue_days: u64,
35}
36
37impl Default for NotifyConfig {
38    fn default() -> Self {
39        Self {
40            enabled:               true,
41            window_minutes:        5,
42            notify_open_tasks:     true,
43            open_task_tags:        Vec::new(),
44            task_notify_at:        None,
45            task_cooldown_minutes: 60,
46            overdue_days:          7,
47        }
48    }
49}
50
51// ── Shared meta (.mps.meta — git-tracked) ────────────────────────────────────
52
53/// Machine-agnostic config layer stored in storage_dir/.mps.meta.
54/// Git-tracked: syncs across all devices on `mps autogit`.
55/// Fields are union-merged with ~/.mps_config.yaml at startup.
56#[derive(Debug, Clone, Serialize, Deserialize, Default)]
57pub struct MetaShared {
58    #[serde(default)]
59    pub version: u32,
60    #[serde(default)]
61    pub config: MetaConfig,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize, Default)]
65pub struct MetaConfig {
66    #[serde(default)]
67    pub type_aliases: HashMap<String, String>,
68    #[serde(default)]
69    pub command_aliases: HashMap<String, String>,
70    #[serde(default)]
71    pub default_command: Option<String>,
72    #[serde(default)]
73    pub custom_tags: Vec<String>,
74    #[serde(default)]
75    pub notify: NotifyConfig,
76}
77
78impl MetaShared {
79    pub fn filename() -> &'static str { ".mps.meta" }
80
81    pub fn path(storage_dir: &Path) -> PathBuf {
82        storage_dir.join(Self::filename())
83    }
84
85    /// Load from storage_dir/.mps.meta. Returns Default if file is absent or unparseable.
86    pub fn load(storage_dir: &Path) -> Self {
87        let path = Self::path(storage_dir);
88        if !path.exists() { return Self::default(); }
89        std::fs::read_to_string(&path)
90            .ok()
91            .and_then(|s| serde_json::from_str(&s).ok())
92            .unwrap_or_default()
93    }
94
95    /// Atomically write to storage_dir/.mps.meta (tmp + rename).
96    pub fn save(&self, storage_dir: &Path) -> Result<(), MpsError> {
97        let path = Self::path(storage_dir);
98        let tmp  = path.with_extension(format!("meta.tmp.{}", std::process::id()));
99        std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
100        std::fs::rename(&tmp, &path)?;
101        Ok(())
102    }
103}
104
105// ── Local meta (.mps.local — gitignored) ─────────────────────────────────────
106
107/// Per-device transient state: notification history and cache.
108/// Gitignored — never committed.
109#[derive(Debug, Clone, Serialize, Deserialize, Default)]
110pub struct MetaLocal {
111    #[serde(default)]
112    pub version: u32,
113    /// epoch_ref → unix timestamp (seconds) when notification was last sent.
114    #[serde(default)]
115    pub notified: HashMap<String, i64>,
116    /// "YYYY-MM-DD" of the last morning task briefing notification.
117    #[serde(default)]
118    pub last_task_date: Option<String>,
119    #[serde(default)]
120    pub cache: MetaCache,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize, Default)]
124pub struct MetaCache {
125    pub tag_counts_date: Option<String>,
126    #[serde(default)]
127    pub tag_counts: HashMap<String, u32>,
128}
129
130impl MetaLocal {
131    pub fn filename() -> &'static str { ".mps.local" }
132
133    pub fn path(storage_dir: &Path) -> PathBuf {
134        storage_dir.join(Self::filename())
135    }
136
137    /// Load from storage_dir/.mps.local. Returns Default if absent or unparseable.
138    pub fn load(storage_dir: &Path) -> Self {
139        let path = Self::path(storage_dir);
140        if !path.exists() { return Self::default(); }
141        std::fs::read_to_string(&path)
142            .ok()
143            .and_then(|s| serde_json::from_str(&s).ok())
144            .unwrap_or_default()
145    }
146
147    /// Atomically write to storage_dir/.mps.local (tmp + rename).
148    /// Also ensures .mps.local is listed in storage_dir/.gitignore.
149    pub fn save(&self, storage_dir: &Path) -> Result<(), MpsError> {
150        let path = Self::path(storage_dir);
151        let tmp  = path.with_extension(format!("local.tmp.{}", std::process::id()));
152        std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
153        std::fs::rename(&tmp, &path)?;
154        ensure_local_gitignored(storage_dir);
155        Ok(())
156    }
157
158    /// Returns true if `epoch_ref` was notified within the last `cooldown_secs` seconds.
159    pub fn was_notified(&self, epoch_ref: &str, cooldown_secs: i64) -> bool {
160        if let Some(&ts) = self.notified.get(epoch_ref) {
161            let now = chrono::Local::now().timestamp();
162            return now - ts < cooldown_secs;
163        }
164        false
165    }
166
167    /// Record that `epoch_ref` was notified right now.
168    pub fn mark_notified(&mut self, epoch_ref: &str) {
169        self.notified.insert(epoch_ref.to_string(), chrono::Local::now().timestamp());
170    }
171
172    /// Returns true if the task briefing has already been sent today.
173    pub fn task_briefing_done_today(&self) -> bool {
174        let today = chrono::Local::now().date_naive().format("%Y-%m-%d").to_string();
175        self.last_task_date.as_deref() == Some(today.as_str())
176    }
177
178    /// Record that the task briefing was sent today.
179    pub fn mark_task_briefing(&mut self) {
180        self.last_task_date = Some(chrono::Local::now().date_naive().format("%Y-%m-%d").to_string());
181    }
182
183    /// Remove notification entries older than `before_ts` (unix seconds).
184    pub fn prune(&mut self, before_ts: i64) {
185        self.notified.retain(|_, &mut ts| ts >= before_ts);
186    }
187}
188
189/// Add ".mps.local" to storage_dir/.gitignore if it isn't already there.
190/// Silently ignores I/O errors — gitignore is best-effort.
191fn ensure_local_gitignored(storage_dir: &Path) {
192    let gitignore = storage_dir.join(".gitignore");
193    let entry = ".mps.local";
194    let already_present = std::fs::read_to_string(&gitignore)
195        .map(|s| s.lines().any(|l| l.trim() == entry))
196        .unwrap_or(false);
197    if !already_present {
198        use std::io::Write;
199        if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&gitignore) {
200            let _ = writeln!(f, "{}", entry);
201        }
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208
209    fn tmp_store() -> (tempfile::TempDir, std::path::PathBuf) {
210        let dir = tempfile::tempdir().unwrap();
211        let p   = dir.path().to_path_buf();
212        (dir, p)
213    }
214
215    #[test]
216    fn test_meta_shared_load_absent_returns_default() {
217        let (_dir, p) = tmp_store();
218        let m = MetaShared::load(&p);
219        assert_eq!(m.version, 0);
220        assert!(m.config.type_aliases.is_empty());
221    }
222
223    #[test]
224    fn test_meta_shared_save_load_roundtrip() {
225        let (_dir, p) = tmp_store();
226        let mut m = MetaShared::default();
227        m.version = 1;
228        m.config.default_command = Some("list".into());
229        m.config.custom_tags = vec!["work".into(), "personal".into()];
230        m.config.type_aliases.insert("t".into(), "task".into());
231        m.save(&p).unwrap();
232
233        let m2 = MetaShared::load(&p);
234        assert_eq!(m2.version, 1);
235        assert_eq!(m2.config.default_command.as_deref(), Some("list"));
236        assert_eq!(m2.config.custom_tags, vec!["work", "personal"]);
237        assert_eq!(m2.config.type_aliases.get("t").map(|s| s.as_str()), Some("task"));
238    }
239
240    #[test]
241    fn test_meta_local_load_absent_returns_default() {
242        let (_dir, p) = tmp_store();
243        let m = MetaLocal::load(&p);
244        assert!(m.notified.is_empty());
245        assert!(m.last_task_date.is_none());
246    }
247
248    #[test]
249    fn test_meta_local_save_load_roundtrip() {
250        let (_dir, p) = tmp_store();
251        let mut m = MetaLocal::default();
252        m.notified.insert("20260524.1".into(), 1000000);
253        m.last_task_date = Some("2026-05-24".into());
254        m.save(&p).unwrap();
255
256        let m2 = MetaLocal::load(&p);
257        assert_eq!(m2.notified.get("20260524.1").copied(), Some(1000000));
258        assert_eq!(m2.last_task_date.as_deref(), Some("2026-05-24"));
259    }
260
261    #[test]
262    fn test_was_notified_within_cooldown() {
263        let mut m = MetaLocal::default();
264        let now = chrono::Local::now().timestamp();
265        m.notified.insert("ref-1".into(), now - 30); // notified 30s ago
266        assert!(m.was_notified("ref-1", 60));   // cooldown 60s → still fresh
267        assert!(!m.was_notified("ref-1", 20));  // cooldown 20s → expired
268    }
269
270    #[test]
271    fn test_was_notified_absent_returns_false() {
272        let m = MetaLocal::default();
273        assert!(!m.was_notified("no-such-ref", 3600));
274    }
275
276    #[test]
277    fn test_mark_notified_sets_timestamp() {
278        let mut m = MetaLocal::default();
279        assert!(!m.was_notified("ref-2", 60));
280        m.mark_notified("ref-2");
281        assert!(m.was_notified("ref-2", 60));
282    }
283
284    #[test]
285    fn test_task_briefing_done_today_false_by_default() {
286        let m = MetaLocal::default();
287        assert!(!m.task_briefing_done_today());
288    }
289
290    #[test]
291    fn test_mark_task_briefing_sets_today() {
292        let mut m = MetaLocal::default();
293        m.mark_task_briefing();
294        assert!(m.task_briefing_done_today());
295    }
296
297    #[test]
298    fn test_task_briefing_done_yesterday_is_false() {
299        let mut m = MetaLocal::default();
300        m.last_task_date = Some("2000-01-01".into()); // long past
301        assert!(!m.task_briefing_done_today());
302    }
303
304    #[test]
305    fn test_prune_removes_old_entries() {
306        let mut m = MetaLocal::default();
307        m.notified.insert("old".into(), 1000);
308        m.notified.insert("new".into(), 9_000_000_000);
309        m.prune(5_000_000);
310        assert!(!m.notified.contains_key("old"));
311        assert!(m.notified.contains_key("new"));
312    }
313
314    #[test]
315    fn test_prune_keeps_entries_at_boundary() {
316        let mut m = MetaLocal::default();
317        m.notified.insert("exact".into(), 5000);
318        m.prune(5000); // >= 5000 → kept
319        assert!(m.notified.contains_key("exact"));
320    }
321
322    // ── Iteration 11: MetaLocal save auto-gitignores .mps.local ──────────────
323
324    #[test]
325    fn test_save_auto_adds_mps_local_to_gitignore() {
326        let (_dir, p) = tmp_store();
327        let m = MetaLocal::default();
328        m.save(&p).unwrap();
329
330        let gitignore = p.join(".gitignore");
331        assert!(gitignore.exists(), ".gitignore must be created");
332        let content = std::fs::read_to_string(&gitignore).unwrap();
333        assert!(content.lines().any(|l| l.trim() == ".mps.local"),
334            ".gitignore must contain .mps.local");
335    }
336
337    #[test]
338    fn test_save_does_not_duplicate_gitignore_entry() {
339        let (_dir, p) = tmp_store();
340        // Write .gitignore with entry already present.
341        std::fs::write(p.join(".gitignore"), ".mps.local\n").unwrap();
342        let m = MetaLocal::default();
343        m.save(&p).unwrap();
344        m.save(&p).unwrap(); // save twice
345
346        let content = std::fs::read_to_string(p.join(".gitignore")).unwrap();
347        let count = content.lines().filter(|l| l.trim() == ".mps.local").count();
348        assert_eq!(count, 1, "entry must not be duplicated");
349    }
350
351    // ── Iteration 12: MetaShared corrupted JSON falls back to default ─────────
352
353    #[test]
354    fn test_meta_shared_corrupted_json_returns_default() {
355        let (_dir, p) = tmp_store();
356        std::fs::write(p.join(".mps.meta"), "this is not json {{{").unwrap();
357        let m = MetaShared::load(&p);
358        // Must return Default, not panic.
359        assert_eq!(m.version, 0);
360        assert!(m.config.type_aliases.is_empty());
361    }
362
363    #[test]
364    fn test_meta_local_corrupted_json_returns_default() {
365        let (_dir, p) = tmp_store();
366        std::fs::write(p.join(".mps.local"), "not json at all").unwrap();
367        let m = MetaLocal::load(&p);
368        assert!(m.notified.is_empty());
369    }
370
371    // ── Iteration 13: was_notified exactly at cooldown boundary ─────────────
372
373    #[test]
374    fn test_was_notified_exactly_at_cooldown_is_fresh() {
375        let mut m = MetaLocal::default();
376        let now = chrono::Local::now().timestamp();
377        // ts = now − cooldown → age == cooldown → age < cooldown is false → NOT fresh.
378        m.notified.insert("ref".into(), now - 60);
379        // was_notified uses `now - ts < cooldown_secs`, so at exactly the boundary it's false.
380        assert!(!m.was_notified("ref", 60), "at exactly cooldown, entry is expired");
381        // Just inside cooldown (59s ago) → still fresh.
382        m.notified.insert("ref".into(), now - 59);
383        assert!(m.was_notified("ref", 60), "59s ago with 60s cooldown → fresh");
384    }
385
386    // ── Iteration 14: MetaShared save is atomic (tmp file cleaned up) ─────────
387
388    #[test]
389    fn test_meta_shared_atomic_save_no_tmp_file_left() {
390        let (_dir, p) = tmp_store();
391        let m = MetaShared::default();
392        m.save(&p).unwrap();
393        // Confirm no .tmp file remains.
394        let leftovers: Vec<_> = std::fs::read_dir(&p).unwrap()
395            .filter_map(|e| e.ok())
396            .filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
397            .collect();
398        assert!(leftovers.is_empty(), "no .tmp files should remain after save");
399    }
400
401    #[test]
402    fn test_meta_local_atomic_save_no_tmp_file_left() {
403        let (_dir, p) = tmp_store();
404        let m = MetaLocal::default();
405        m.save(&p).unwrap();
406        let leftovers: Vec<_> = std::fs::read_dir(&p).unwrap()
407            .filter_map(|e| e.ok())
408            .filter(|e| {
409                let n = e.file_name();
410                let s = n.to_string_lossy();
411                s.contains(".tmp") && s.contains("local")
412            })
413            .collect();
414        assert!(leftovers.is_empty(), "no .tmp files should remain after save");
415    }
416}