mps-rs 1.6.0

MPS — plain-text personal productivity CLI (Rust)
Documentation
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::error::MpsError;

fn default_true()  -> bool { true }
fn five()          -> u64  { 5 }
fn sixty()         -> u64  { 60 }
fn seven()         -> u64  { 7 }

/// Notification settings — shared between Config and MetaConfig.
/// Defined here to avoid a circular import between config.rs and meta.rs.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotifyConfig {
    #[serde(default = "default_true")]
    pub enabled: bool,
    /// How many minutes around a reminder time counts as "due now".
    #[serde(default = "five")]
    pub window_minutes: u64,
    /// Send a morning briefing listing all open tasks.
    #[serde(default = "default_true")]
    pub notify_open_tasks: bool,
    /// If non-empty, only open tasks with one of these tags are included.
    #[serde(default)]
    pub open_task_tags: Vec<String>,
    /// Time-of-day for the morning task briefing, e.g. "9am".
    #[serde(default)]
    pub task_notify_at: Option<String>,
    /// Minimum minutes between repeat notifications for the same reminder.
    #[serde(default = "sixty")]
    pub task_cooldown_minutes: u64,
    /// How many past days to scan for overdue open tasks.
    #[serde(default = "seven")]
    pub overdue_days: u64,
}

impl Default for NotifyConfig {
    fn default() -> Self {
        Self {
            enabled:               true,
            window_minutes:        5,
            notify_open_tasks:     true,
            open_task_tags:        Vec::new(),
            task_notify_at:        None,
            task_cooldown_minutes: 60,
            overdue_days:          7,
        }
    }
}

// ── Shared meta (.mps.meta — git-tracked) ────────────────────────────────────

/// Machine-agnostic config layer stored in storage_dir/.mps.meta.
/// Git-tracked: syncs across all devices on `mps autogit`.
/// Fields are union-merged with ~/.mps_config.yaml at startup.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MetaShared {
    #[serde(default)]
    pub version: u32,
    #[serde(default)]
    pub config: MetaConfig,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MetaConfig {
    #[serde(default)]
    pub type_aliases: HashMap<String, String>,
    #[serde(default)]
    pub command_aliases: HashMap<String, String>,
    #[serde(default)]
    pub default_command: Option<String>,
    #[serde(default)]
    pub custom_tags: Vec<String>,
    #[serde(default)]
    pub notify: NotifyConfig,
}

impl MetaShared {
    pub fn filename() -> &'static str { ".mps.meta" }

    pub fn path(storage_dir: &Path) -> PathBuf {
        storage_dir.join(Self::filename())
    }

    /// Load from storage_dir/.mps.meta. Returns Default if file is absent or unparseable.
    pub fn load(storage_dir: &Path) -> Self {
        let path = Self::path(storage_dir);
        if !path.exists() { return Self::default(); }
        std::fs::read_to_string(&path)
            .ok()
            .and_then(|s| serde_json::from_str(&s).ok())
            .unwrap_or_default()
    }

    /// Atomically write to storage_dir/.mps.meta (tmp + rename).
    pub fn save(&self, storage_dir: &Path) -> Result<(), MpsError> {
        let path = Self::path(storage_dir);
        let tmp  = path.with_extension(format!("meta.tmp.{}", std::process::id()));
        std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
        std::fs::rename(&tmp, &path)?;
        Ok(())
    }
}

// ── Local meta (.mps.local — gitignored) ─────────────────────────────────────

/// Per-device transient state: notification history and cache.
/// Gitignored — never committed.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MetaLocal {
    #[serde(default)]
    pub version: u32,
    /// epoch_ref → unix timestamp (seconds) when notification was last sent.
    #[serde(default)]
    pub notified: HashMap<String, i64>,
    /// "YYYY-MM-DD" of the last morning task briefing notification.
    #[serde(default)]
    pub last_task_date: Option<String>,
    #[serde(default)]
    pub cache: MetaCache,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct MetaCache {
    pub tag_counts_date: Option<String>,
    #[serde(default)]
    pub tag_counts: HashMap<String, u32>,
}

impl MetaLocal {
    pub fn filename() -> &'static str { ".mps.local" }

    pub fn path(storage_dir: &Path) -> PathBuf {
        storage_dir.join(Self::filename())
    }

    /// Load from storage_dir/.mps.local. Returns Default if absent or unparseable.
    pub fn load(storage_dir: &Path) -> Self {
        let path = Self::path(storage_dir);
        if !path.exists() { return Self::default(); }
        std::fs::read_to_string(&path)
            .ok()
            .and_then(|s| serde_json::from_str(&s).ok())
            .unwrap_or_default()
    }

    /// Atomically write to storage_dir/.mps.local (tmp + rename).
    /// Also ensures .mps.local is listed in storage_dir/.gitignore.
    pub fn save(&self, storage_dir: &Path) -> Result<(), MpsError> {
        let path = Self::path(storage_dir);
        let tmp  = path.with_extension(format!("local.tmp.{}", std::process::id()));
        std::fs::write(&tmp, serde_json::to_string_pretty(self)?)?;
        std::fs::rename(&tmp, &path)?;
        ensure_local_gitignored(storage_dir);
        Ok(())
    }

    /// Returns true if `epoch_ref` was notified within the last `cooldown_secs` seconds.
    pub fn was_notified(&self, epoch_ref: &str, cooldown_secs: i64) -> bool {
        if let Some(&ts) = self.notified.get(epoch_ref) {
            let now = chrono::Local::now().timestamp();
            return now - ts < cooldown_secs;
        }
        false
    }

    /// Record that `epoch_ref` was notified right now.
    pub fn mark_notified(&mut self, epoch_ref: &str) {
        self.notified.insert(epoch_ref.to_string(), chrono::Local::now().timestamp());
    }

    /// Returns true if the task briefing has already been sent today.
    pub fn task_briefing_done_today(&self) -> bool {
        let today = chrono::Local::now().date_naive().format("%Y-%m-%d").to_string();
        self.last_task_date.as_deref() == Some(today.as_str())
    }

    /// Record that the task briefing was sent today.
    pub fn mark_task_briefing(&mut self) {
        self.last_task_date = Some(chrono::Local::now().date_naive().format("%Y-%m-%d").to_string());
    }

    /// Remove notification entries older than `before_ts` (unix seconds).
    pub fn prune(&mut self, before_ts: i64) {
        self.notified.retain(|_, &mut ts| ts >= before_ts);
    }
}

/// Add ".mps.local" to storage_dir/.gitignore if it isn't already there.
/// Silently ignores I/O errors — gitignore is best-effort.
fn ensure_local_gitignored(storage_dir: &Path) {
    let gitignore = storage_dir.join(".gitignore");
    let entry = ".mps.local";
    let already_present = std::fs::read_to_string(&gitignore)
        .map(|s| s.lines().any(|l| l.trim() == entry))
        .unwrap_or(false);
    if !already_present {
        use std::io::Write;
        if let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&gitignore) {
            let _ = writeln!(f, "{}", entry);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn tmp_store() -> (tempfile::TempDir, std::path::PathBuf) {
        let dir = tempfile::tempdir().unwrap();
        let p   = dir.path().to_path_buf();
        (dir, p)
    }

    #[test]
    fn test_meta_shared_load_absent_returns_default() {
        let (_dir, p) = tmp_store();
        let m = MetaShared::load(&p);
        assert_eq!(m.version, 0);
        assert!(m.config.type_aliases.is_empty());
    }

    #[test]
    fn test_meta_shared_save_load_roundtrip() {
        let (_dir, p) = tmp_store();
        let mut m = MetaShared::default();
        m.version = 1;
        m.config.default_command = Some("list".into());
        m.config.custom_tags = vec!["work".into(), "personal".into()];
        m.config.type_aliases.insert("t".into(), "task".into());
        m.save(&p).unwrap();

        let m2 = MetaShared::load(&p);
        assert_eq!(m2.version, 1);
        assert_eq!(m2.config.default_command.as_deref(), Some("list"));
        assert_eq!(m2.config.custom_tags, vec!["work", "personal"]);
        assert_eq!(m2.config.type_aliases.get("t").map(|s| s.as_str()), Some("task"));
    }

    #[test]
    fn test_meta_local_load_absent_returns_default() {
        let (_dir, p) = tmp_store();
        let m = MetaLocal::load(&p);
        assert!(m.notified.is_empty());
        assert!(m.last_task_date.is_none());
    }

    #[test]
    fn test_meta_local_save_load_roundtrip() {
        let (_dir, p) = tmp_store();
        let mut m = MetaLocal::default();
        m.notified.insert("20260524.1".into(), 1000000);
        m.last_task_date = Some("2026-05-24".into());
        m.save(&p).unwrap();

        let m2 = MetaLocal::load(&p);
        assert_eq!(m2.notified.get("20260524.1").copied(), Some(1000000));
        assert_eq!(m2.last_task_date.as_deref(), Some("2026-05-24"));
    }

    #[test]
    fn test_was_notified_within_cooldown() {
        let mut m = MetaLocal::default();
        let now = chrono::Local::now().timestamp();
        m.notified.insert("ref-1".into(), now - 30); // notified 30s ago
        assert!(m.was_notified("ref-1", 60));   // cooldown 60s → still fresh
        assert!(!m.was_notified("ref-1", 20));  // cooldown 20s → expired
    }

    #[test]
    fn test_was_notified_absent_returns_false() {
        let m = MetaLocal::default();
        assert!(!m.was_notified("no-such-ref", 3600));
    }

    #[test]
    fn test_mark_notified_sets_timestamp() {
        let mut m = MetaLocal::default();
        assert!(!m.was_notified("ref-2", 60));
        m.mark_notified("ref-2");
        assert!(m.was_notified("ref-2", 60));
    }

    #[test]
    fn test_task_briefing_done_today_false_by_default() {
        let m = MetaLocal::default();
        assert!(!m.task_briefing_done_today());
    }

    #[test]
    fn test_mark_task_briefing_sets_today() {
        let mut m = MetaLocal::default();
        m.mark_task_briefing();
        assert!(m.task_briefing_done_today());
    }

    #[test]
    fn test_task_briefing_done_yesterday_is_false() {
        let mut m = MetaLocal::default();
        m.last_task_date = Some("2000-01-01".into()); // long past
        assert!(!m.task_briefing_done_today());
    }

    #[test]
    fn test_prune_removes_old_entries() {
        let mut m = MetaLocal::default();
        m.notified.insert("old".into(), 1000);
        m.notified.insert("new".into(), 9_000_000_000);
        m.prune(5_000_000);
        assert!(!m.notified.contains_key("old"));
        assert!(m.notified.contains_key("new"));
    }

    #[test]
    fn test_prune_keeps_entries_at_boundary() {
        let mut m = MetaLocal::default();
        m.notified.insert("exact".into(), 5000);
        m.prune(5000); // >= 5000 → kept
        assert!(m.notified.contains_key("exact"));
    }
}