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/// Synced (non-sensitive) chat config fields stored in .mps.meta.
73/// api_key and sessions_dir are never written here — they stay local-only.
74#[derive(Debug, Clone, Serialize, Deserialize, Default)]
75pub struct ChatMetaConfig {
76    #[serde(default)]
77    pub url: Option<String>,
78    #[serde(default)]
79    pub model: Option<String>,
80    #[serde(default)]
81    pub context_days: Option<u64>,
82    #[serde(default)]
83    pub stream: Option<bool>,
84    #[serde(default)]
85    pub connect_timeout_secs: Option<u64>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize, Default)]
89pub struct MetaConfig {
90    #[serde(default)]
91    pub type_aliases: HashMap<String, String>,
92    #[serde(default)]
93    pub command_aliases: HashMap<String, String>,
94    #[serde(default)]
95    pub default_command: Option<String>,
96    #[serde(default)]
97    pub custom_tags: Vec<String>,
98    #[serde(default)]
99    pub notify: NotifyConfig,
100    #[serde(default)]
101    pub chat: ChatMetaConfig,
102}
103
104impl MetaShared {
105    pub fn filename() -> &'static str {
106        ".mps.meta"
107    }
108
109    pub fn path(storage_dir: &Path) -> PathBuf {
110        storage_dir.join(Self::filename())
111    }
112
113    /// Load from storage_dir/.mps.meta. Returns Default if file is absent or unparseable.
114    pub fn load(storage_dir: &Path) -> Self {
115        let path = Self::path(storage_dir);
116        if !path.exists() {
117            return Self::default();
118        }
119        std::fs::read_to_string(&path)
120            .ok()
121            .and_then(|s| serde_json::from_str(&s).ok())
122            .unwrap_or_default()
123    }
124
125    /// Atomically write to storage_dir/.mps.meta (tmp + rename).
126    pub fn save(&self, storage_dir: &Path) -> Result<(), MpsError> {
127        let path = Self::path(storage_dir);
128        let tmp = path.with_extension(format!("meta.tmp.{}", std::process::id()));
129        std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
130        std::fs::rename(&tmp, &path)?;
131        Ok(())
132    }
133}
134
135// ── Local meta (.mps.local — gitignored) ─────────────────────────────────────
136
137/// Per-device transient state: notification history and cache.
138/// Gitignored — never committed.
139#[derive(Debug, Clone, Serialize, Deserialize, Default)]
140pub struct MetaLocal {
141    #[serde(default)]
142    pub version: u32,
143    /// epoch_ref → unix timestamp (seconds) when notification was last sent.
144    #[serde(default)]
145    pub notified: HashMap<String, i64>,
146    /// "YYYY-MM-DD" of the last morning task briefing notification.
147    #[serde(default)]
148    pub last_task_date: Option<String>,
149    #[serde(default)]
150    pub cache: MetaCache,
151}
152
153/// Cached tag counts across all .mps files for `mps tags --all`.
154///
155/// The snapshot records `(filename, size_bytes)` for every .mps file at the time
156/// the cache was built.  Before using the cache the caller should check
157/// [`MetaCache::is_valid`] against the current file list; if any file was added,
158/// removed, or changed in size the cache is stale.
159#[derive(Debug, Clone, Serialize, Deserialize, Default)]
160pub struct MetaCache {
161    /// Sorted list of (filename, file_size_bytes) at cache-build time.
162    #[serde(default)]
163    pub files_snapshot: Vec<(String, u64)>,
164    /// Tag name → total occurrence count across all cached files.
165    #[serde(default)]
166    pub tag_counts: HashMap<String, u32>,
167}
168
169impl MetaCache {
170    /// Return true when `current_files` (sorted list of (filename, size)) matches
171    /// the stored snapshot exactly, meaning no file was added, removed, or changed.
172    pub fn is_valid(&self, current_files: &[(String, u64)]) -> bool {
173        self.files_snapshot == current_files
174    }
175
176    /// Build a snapshot from the current .mps file list.
177    pub fn build_snapshot(files: &std::path::Path) -> Vec<(String, u64)> {
178        let re = crate::constants::mps_file_name_regexp();
179        let mut snapshot: Vec<(String, u64)> = std::fs::read_dir(files)
180            .map(|rd| {
181                rd.filter_map(|e| e.ok())
182                    .filter_map(|e| {
183                        let name = e.file_name().to_string_lossy().into_owned();
184                        if !re.is_match(&name) {
185                            return None;
186                        }
187                        let size = e.metadata().ok()?.len();
188                        Some((name, size))
189                    })
190                    .collect()
191            })
192            .unwrap_or_default();
193        snapshot.sort_by(|a, b| a.0.cmp(&b.0));
194        snapshot
195    }
196}
197
198impl MetaLocal {
199    pub fn filename() -> &'static str {
200        ".mps.local"
201    }
202
203    pub fn path(storage_dir: &Path) -> PathBuf {
204        storage_dir.join(Self::filename())
205    }
206
207    /// Load from storage_dir/.mps.local. Returns Default if absent or unparseable.
208    pub fn load(storage_dir: &Path) -> Self {
209        let path = Self::path(storage_dir);
210        if !path.exists() {
211            return Self::default();
212        }
213        std::fs::read_to_string(&path)
214            .ok()
215            .and_then(|s| serde_json::from_str(&s).ok())
216            .unwrap_or_default()
217    }
218
219    /// Atomically write to storage_dir/.mps.local (tmp + rename).
220    /// Also ensures .mps.local is listed in storage_dir/.gitignore.
221    pub fn save(&self, storage_dir: &Path) -> Result<(), MpsError> {
222        let path = Self::path(storage_dir);
223        let tmp = path.with_extension(format!("local.tmp.{}", std::process::id()));
224        std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
225        std::fs::rename(&tmp, &path)?;
226        ensure_local_gitignored(storage_dir);
227        Ok(())
228    }
229
230    /// Returns true if `epoch_ref` was notified within the last `cooldown_secs` seconds.
231    pub fn was_notified(&self, epoch_ref: &str, cooldown_secs: i64) -> bool {
232        if let Some(&ts) = self.notified.get(epoch_ref) {
233            let now = chrono::Local::now().timestamp();
234            return now - ts < cooldown_secs;
235        }
236        false
237    }
238
239    /// Record that `epoch_ref` was notified right now.
240    pub fn mark_notified(&mut self, epoch_ref: &str) {
241        self.notified
242            .insert(epoch_ref.to_string(), chrono::Local::now().timestamp());
243    }
244
245    /// Returns true if the task briefing has already been sent today.
246    pub fn task_briefing_done_today(&self) -> bool {
247        let today = chrono::Local::now()
248            .date_naive()
249            .format("%Y-%m-%d")
250            .to_string();
251        self.last_task_date.as_deref() == Some(today.as_str())
252    }
253
254    /// Record that the task briefing was sent today.
255    pub fn mark_task_briefing(&mut self) {
256        self.last_task_date = Some(
257            chrono::Local::now()
258                .date_naive()
259                .format("%Y-%m-%d")
260                .to_string(),
261        );
262    }
263
264    /// Remove notification entries older than `before_ts` (unix seconds).
265    pub fn prune(&mut self, before_ts: i64) {
266        self.notified.retain(|_, &mut ts| ts >= before_ts);
267    }
268}
269
270/// Add ".mps.local" to storage_dir/.gitignore if it isn't already there.
271/// Silently ignores I/O errors — gitignore is best-effort.
272fn ensure_local_gitignored(storage_dir: &Path) {
273    let gitignore = storage_dir.join(".gitignore");
274    let entry = ".mps.local";
275    let already_present = std::fs::read_to_string(&gitignore)
276        .map(|s| s.lines().any(|l| l.trim() == entry))
277        .unwrap_or(false);
278    if !already_present {
279        use std::io::Write;
280        if let Ok(mut f) = std::fs::OpenOptions::new()
281            .create(true)
282            .append(true)
283            .open(&gitignore)
284        {
285            let _ = writeln!(f, "{}", entry);
286        }
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293
294    fn tmp_store() -> (tempfile::TempDir, std::path::PathBuf) {
295        let dir = tempfile::tempdir().unwrap();
296        let p = dir.path().to_path_buf();
297        (dir, p)
298    }
299
300    #[test]
301    fn test_meta_shared_load_absent_returns_default() {
302        let (_dir, p) = tmp_store();
303        let m = MetaShared::load(&p);
304        assert_eq!(m.version, 0);
305        assert!(m.config.type_aliases.is_empty());
306    }
307
308    #[test]
309    fn test_meta_shared_save_load_roundtrip() {
310        let (_dir, p) = tmp_store();
311        let mut m = MetaShared {
312            version: 1,
313            ..Default::default()
314        };
315        m.config.default_command = Some("list".into());
316        m.config.custom_tags = vec!["work".into(), "personal".into()];
317        m.config.type_aliases.insert("t".into(), "task".into());
318        m.save(&p).unwrap();
319
320        let m2 = MetaShared::load(&p);
321        assert_eq!(m2.version, 1);
322        assert_eq!(m2.config.default_command.as_deref(), Some("list"));
323        assert_eq!(m2.config.custom_tags, vec!["work", "personal"]);
324        assert_eq!(
325            m2.config.type_aliases.get("t").map(|s| s.as_str()),
326            Some("task")
327        );
328    }
329
330    #[test]
331    fn test_meta_local_load_absent_returns_default() {
332        let (_dir, p) = tmp_store();
333        let m = MetaLocal::load(&p);
334        assert!(m.notified.is_empty());
335        assert!(m.last_task_date.is_none());
336    }
337
338    #[test]
339    fn test_meta_local_save_load_roundtrip() {
340        let (_dir, p) = tmp_store();
341        let mut m = MetaLocal::default();
342        m.notified.insert("20260524.1".into(), 1000000);
343        m.last_task_date = Some("2026-05-24".into());
344        m.save(&p).unwrap();
345
346        let m2 = MetaLocal::load(&p);
347        assert_eq!(m2.notified.get("20260524.1").copied(), Some(1000000));
348        assert_eq!(m2.last_task_date.as_deref(), Some("2026-05-24"));
349    }
350
351    #[test]
352    fn test_was_notified_within_cooldown() {
353        let mut m = MetaLocal::default();
354        let now = chrono::Local::now().timestamp();
355        m.notified.insert("ref-1".into(), now - 30); // notified 30s ago
356        assert!(m.was_notified("ref-1", 60)); // cooldown 60s → still fresh
357        assert!(!m.was_notified("ref-1", 20)); // cooldown 20s → expired
358    }
359
360    #[test]
361    fn test_was_notified_absent_returns_false() {
362        let m = MetaLocal::default();
363        assert!(!m.was_notified("no-such-ref", 3600));
364    }
365
366    #[test]
367    fn test_mark_notified_sets_timestamp() {
368        let mut m = MetaLocal::default();
369        assert!(!m.was_notified("ref-2", 60));
370        m.mark_notified("ref-2");
371        assert!(m.was_notified("ref-2", 60));
372    }
373
374    #[test]
375    fn test_task_briefing_done_today_false_by_default() {
376        let m = MetaLocal::default();
377        assert!(!m.task_briefing_done_today());
378    }
379
380    #[test]
381    fn test_mark_task_briefing_sets_today() {
382        let mut m = MetaLocal::default();
383        m.mark_task_briefing();
384        assert!(m.task_briefing_done_today());
385    }
386
387    #[test]
388    fn test_task_briefing_done_yesterday_is_false() {
389        let m = MetaLocal {
390            last_task_date: Some("2000-01-01".into()),
391            ..Default::default()
392        }; // long past
393        assert!(!m.task_briefing_done_today());
394    }
395
396    #[test]
397    fn test_prune_removes_old_entries() {
398        let mut m = MetaLocal::default();
399        m.notified.insert("old".into(), 1000);
400        m.notified.insert("new".into(), 9_000_000_000);
401        m.prune(5_000_000);
402        assert!(!m.notified.contains_key("old"));
403        assert!(m.notified.contains_key("new"));
404    }
405
406    #[test]
407    fn test_prune_keeps_entries_at_boundary() {
408        let mut m = MetaLocal::default();
409        m.notified.insert("exact".into(), 5000);
410        m.prune(5000); // >= 5000 → kept
411        assert!(m.notified.contains_key("exact"));
412    }
413
414    // ── Iteration 11: MetaLocal save auto-gitignores .mps.local ──────────────
415
416    #[test]
417    fn test_save_auto_adds_mps_local_to_gitignore() {
418        let (_dir, p) = tmp_store();
419        let m = MetaLocal::default();
420        m.save(&p).unwrap();
421
422        let gitignore = p.join(".gitignore");
423        assert!(gitignore.exists(), ".gitignore must be created");
424        let content = std::fs::read_to_string(&gitignore).unwrap();
425        assert!(
426            content.lines().any(|l| l.trim() == ".mps.local"),
427            ".gitignore must contain .mps.local"
428        );
429    }
430
431    #[test]
432    fn test_save_does_not_duplicate_gitignore_entry() {
433        let (_dir, p) = tmp_store();
434        // Write .gitignore with entry already present.
435        std::fs::write(p.join(".gitignore"), ".mps.local\n").unwrap();
436        let m = MetaLocal::default();
437        m.save(&p).unwrap();
438        m.save(&p).unwrap(); // save twice
439
440        let content = std::fs::read_to_string(p.join(".gitignore")).unwrap();
441        let count = content.lines().filter(|l| l.trim() == ".mps.local").count();
442        assert_eq!(count, 1, "entry must not be duplicated");
443    }
444
445    // ── Iteration 12: MetaShared corrupted JSON falls back to default ─────────
446
447    #[test]
448    fn test_meta_shared_corrupted_json_returns_default() {
449        let (_dir, p) = tmp_store();
450        std::fs::write(p.join(".mps.meta"), "this is not json {{{").unwrap();
451        let m = MetaShared::load(&p);
452        // Must return Default, not panic.
453        assert_eq!(m.version, 0);
454        assert!(m.config.type_aliases.is_empty());
455    }
456
457    #[test]
458    fn test_meta_local_corrupted_json_returns_default() {
459        let (_dir, p) = tmp_store();
460        std::fs::write(p.join(".mps.local"), "not json at all").unwrap();
461        let m = MetaLocal::load(&p);
462        assert!(m.notified.is_empty());
463    }
464
465    // ── Iteration 13: was_notified exactly at cooldown boundary ─────────────
466
467    #[test]
468    fn test_was_notified_exactly_at_cooldown_is_fresh() {
469        let mut m = MetaLocal::default();
470        let now = chrono::Local::now().timestamp();
471        // ts = now − cooldown → age == cooldown → age < cooldown is false → NOT fresh.
472        m.notified.insert("ref".into(), now - 60);
473        // was_notified uses `now - ts < cooldown_secs`, so at exactly the boundary it's false.
474        assert!(
475            !m.was_notified("ref", 60),
476            "at exactly cooldown, entry is expired"
477        );
478        // Just inside cooldown (59s ago) → still fresh.
479        m.notified.insert("ref".into(), now - 59);
480        assert!(
481            m.was_notified("ref", 60),
482            "59s ago with 60s cooldown → fresh"
483        );
484    }
485
486    // ── Iteration 14: MetaShared save is atomic (tmp file cleaned up) ─────────
487
488    #[test]
489    fn test_meta_shared_atomic_save_no_tmp_file_left() {
490        let (_dir, p) = tmp_store();
491        let m = MetaShared::default();
492        m.save(&p).unwrap();
493        // Confirm no .tmp file remains.
494        let leftovers: Vec<_> = std::fs::read_dir(&p)
495            .unwrap()
496            .filter_map(|e| e.ok())
497            .filter(|e| e.file_name().to_string_lossy().contains(".tmp"))
498            .collect();
499        assert!(
500            leftovers.is_empty(),
501            "no .tmp files should remain after save"
502        );
503    }
504
505    #[test]
506    fn test_meta_local_atomic_save_no_tmp_file_left() {
507        let (_dir, p) = tmp_store();
508        let m = MetaLocal::default();
509        m.save(&p).unwrap();
510        let leftovers: Vec<_> = std::fs::read_dir(&p)
511            .unwrap()
512            .filter_map(|e| e.ok())
513            .filter(|e| {
514                let n = e.file_name();
515                let s = n.to_string_lossy();
516                s.contains(".tmp") && s.contains("local")
517            })
518            .collect();
519        assert!(
520            leftovers.is_empty(),
521            "no .tmp files should remain after save"
522        );
523    }
524
525    // ── MetaCache::is_valid / build_snapshot ──────────────────────────────────
526
527    #[test]
528    fn test_meta_cache_valid_when_snapshot_matches() {
529        let mut cache = crate::meta::MetaCache::default();
530        let snapshot = vec![("20260101.1000.mps".into(), 42u64)];
531        cache.files_snapshot = snapshot.clone();
532        assert!(cache.is_valid(&snapshot));
533    }
534
535    #[test]
536    fn test_meta_cache_invalid_when_file_size_changed() {
537        let cache = crate::meta::MetaCache {
538            files_snapshot: vec![("20260101.1000.mps".into(), 42u64)],
539            ..Default::default()
540        };
541        let current = vec![("20260101.1000.mps".into(), 99u64)]; // size changed
542        assert!(!cache.is_valid(&current));
543    }
544
545    #[test]
546    fn test_meta_cache_invalid_when_file_added() {
547        let cache = crate::meta::MetaCache {
548            files_snapshot: vec![("20260101.1000.mps".into(), 42u64)],
549            ..Default::default()
550        };
551        let current = vec![
552            ("20260101.1000.mps".into(), 42u64),
553            ("20260102.1001.mps".into(), 10u64), // new file
554        ];
555        assert!(!cache.is_valid(&current));
556    }
557
558    #[test]
559    fn test_meta_cache_invalid_when_empty_vs_nonempty() {
560        let cache = crate::meta::MetaCache::default(); // empty snapshot
561        let current = vec![("20260101.1000.mps".into(), 42u64)];
562        assert!(!cache.is_valid(&current));
563    }
564
565    #[test]
566    fn test_meta_cache_valid_when_both_empty() {
567        let cache = crate::meta::MetaCache::default();
568        assert!(cache.is_valid(&[]));
569    }
570
571    #[test]
572    fn test_build_snapshot_returns_sorted_mps_files_only() {
573        let (_dir, p) = tmp_store();
574        std::fs::write(p.join("20260101.1000000000.mps"), "content-a").unwrap();
575        std::fs::write(p.join("20260102.1000000001.mps"), "content-b").unwrap();
576        std::fs::write(p.join("not-an-mps.txt"), "ignored").unwrap();
577
578        let snapshot = crate::meta::MetaCache::build_snapshot(&p);
579        assert_eq!(snapshot.len(), 2, "only .mps files");
580        assert_eq!(snapshot[0].0, "20260101.1000000000.mps");
581        assert_eq!(snapshot[1].0, "20260102.1000000001.mps");
582        // sizes should match what we wrote
583        assert_eq!(snapshot[0].1, b"content-a".len() as u64);
584        assert_eq!(snapshot[1].1, b"content-b".len() as u64);
585    }
586
587    #[test]
588    fn test_build_snapshot_empty_dir() {
589        let (_dir, p) = tmp_store();
590        let snapshot = crate::meta::MetaCache::build_snapshot(&p);
591        assert!(snapshot.is_empty());
592    }
593}