mps-rs 1.6.0

MPS — plain-text personal productivity CLI (Rust)
Documentation
use anyhow::Result;
use chrono::{Duration, Local, Timelike};
use colored::Colorize;
use crate::config::Config;
use crate::elements::Element;
use crate::meta::MetaLocal;
use crate::store::Store;
use crate::time_parse;

enum NotifyEvent {
    Reminder { epoch_ref: String, body: String },
    Tasks    { count: usize, lines: Vec<String> },
}

impl NotifyEvent {
    fn title(&self) -> &str {
        match self {
            NotifyEvent::Reminder { .. } => "mps reminder",
            NotifyEvent::Tasks    { .. } => "mps · open tasks",
        }
    }

    fn body(&self) -> String {
        match self {
            NotifyEvent::Reminder { body, .. } => body.trim().to_string(),
            NotifyEvent::Tasks { count, lines } => {
                format!("{} open task(s)\n{}", count, lines.join("\n"))
            }
        }
    }
}

pub fn run(
    config:          &Config,
    dry_run:         bool,
    window_override: Option<u64>,
    force:           bool,
) -> Result<()> {
    if !config.notify.enabled {
        if dry_run { println!("(notifications disabled in config)"); }
        return Ok(());
    }

    let mut local   = MetaLocal::load(&config.storage_dir);
    let store       = Store::new(&config.storage_dir);
    let window_mins = window_override.unwrap_or(config.notify.window_minutes);
    let now         = Local::now();
    let now_time    = now.time();
    let today       = now.date_naive();

    let mut queued: Vec<NotifyEvent> = Vec::new();

    // ── Reminders (today's file) ─────────────────────────────────────────────
    let elements = store.parse_date(today).unwrap_or_default();
    for (epoch_ref, el) in &elements {
        let data = match el {
            Element::Reminder { data, .. } => data,
            _ => continue,
        };
        let at_str = match &data.at {
            Some(s) => s.as_str(),
            None    => continue,
        };
        let reminder_time = match time_parse::parse_time(at_str) {
            Ok(t)  => t,
            Err(_) => {
                eprintln!("  {} cannot parse reminder time '{}' — skipping", "warn:".yellow(), at_str);
                continue;
            }
        };

        let delta = {
            let now_secs = now_time.num_seconds_from_midnight() as i64;
            let rem_secs = reminder_time.num_seconds_from_midnight() as i64;
            (now_secs - rem_secs).abs()
        };
        let window_secs = (window_mins * 60) as i64;

        if delta > window_secs { continue; }
        if !force && local.was_notified(epoch_ref, window_secs) { continue; }

        queued.push(NotifyEvent::Reminder {
            epoch_ref: epoch_ref.clone(),
            body:      el.body_str().trim().to_string(),
        });
    }

    // ── Open-task briefing (morning, once per day) ───────────────────────────
    if config.notify.notify_open_tasks {
        let should_check = if let Some(ref at_str) = config.notify.task_notify_at {
            match time_parse::parse_time(at_str) {
                Ok(briefing_time) => {
                    let now_secs = now_time.num_seconds_from_midnight() as i64;
                    let br_secs  = briefing_time.num_seconds_from_midnight() as i64;
                    (now_secs - br_secs).abs() <= (window_mins * 60) as i64
                }
                Err(_) => {
                    eprintln!("  {} cannot parse task_notify_at '{}' — skipping task briefing",
                        "warn:".yellow(), at_str);
                    false
                }
            }
        } else {
            false // briefing-style requires task_notify_at to be set
        };

        if should_check && (force || !local.task_briefing_done_today()) {
            let tag_filter = &config.notify.open_task_tags;
            let overdue_days = config.notify.overdue_days;
            let mut open_lines: Vec<String> = Vec::new();

            for day_offset in 0..=(overdue_days as i64) {
                let scan_date = today - Duration::days(day_offset);
                let day_els   = store.parse_date(scan_date).unwrap_or_default();
                for el in day_els.values() {
                    if let Element::Task { data, .. } = el {
                        if data.is_done() { continue; }
                        if !tag_filter.is_empty()
                            && !el.tags().iter().any(|t| tag_filter.contains(t)) {
                            continue;
                        }
                        let prefix = if day_offset == 0 { String::new() }
                                     else { format!("[{} days ago] ", day_offset) };
                        open_lines.push(format!("{}{}", prefix, el.body_str().trim()));
                    }
                }
            }

            if !open_lines.is_empty() {
                queued.push(NotifyEvent::Tasks {
                    count: open_lines.len(),
                    lines: open_lines,
                });
            }
        }
    }

    // ── Fire ─────────────────────────────────────────────────────────────────
    let mut fired = 0usize;
    for event in &queued {
        let title = event.title();
        let body  = event.body();

        if dry_run {
            println!("  {} [{}] {}", "would notify:".cyan(), title, body.lines().next().unwrap_or(""));
        } else {
            let status = std::process::Command::new("notify-send")
                .args(["--app-name=mps", "--urgency=normal", title, &body])
                .status();
            if let Ok(s) = status {
                if !s.success() {
                    write_log(config, &format!("notify-send exited {}", s));
                }
            } else {
                write_log(config, "notify-send not found or failed");
            }

            match event {
                NotifyEvent::Reminder { epoch_ref, .. } => local.mark_notified(epoch_ref),
                NotifyEvent::Tasks    { .. }             => local.mark_task_briefing(),
            }
        }

        println!("  {} {}", if dry_run { "dry-run:".dimmed() } else { "notified:".green() },
            body.lines().next().unwrap_or(""));
        fired += 1;
    }

    if fired == 0 && dry_run {
        println!("  (nothing due to notify)");
    }

    // ── Persist ──────────────────────────────────────────────────────────────
    if !dry_run {
        let cutoff = now.timestamp() - 14 * 86400;
        local.prune(cutoff);
        if let Err(e) = local.save(&config.storage_dir) {
            eprintln!("  {} could not save .mps.local: {}", "warn:".yellow(), e);
        }
        if fired > 0 {
            write_log(config, &format!("notify: {} event(s) fired", fired));
        }
    }

    Ok(())
}

fn write_log(config: &Config, msg: &str) {
    use std::io::Write;
    let ts = Local::now().format("%Y-%m-%d %H:%M:%S");
    if let Ok(mut f) = std::fs::OpenOptions::new()
        .create(true).append(true).open(&config.log_file)
    {
        let _ = writeln!(f, "[{}] {}", ts, msg);
    }
}