use crate::config::Config;
use crate::elements::Element;
use crate::meta::MetaLocal;
use crate::store::Store;
use crate::time_parse;
use anyhow::Result;
use chrono::{Duration, Local, NaiveTime, Timelike};
use colored::Colorize;
enum NotifyEvent {
Reminder { epoch_ref: String, body: String },
Tasks { count: usize, lines: Vec<String> },
}
const MAX_TASK_LINES: usize = 10;
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 } => {
let shown = lines.len().min(MAX_TASK_LINES);
let mut out = format!("{} open task(s)\n{}", count, lines[..shown].join("\n"));
if *count > shown {
out.push_str(&format!("\n… and {} more", count - shown));
}
out
}
}
}
}
pub(crate) fn reminder_is_due(
now_time: NaiveTime,
reminder_time: NaiveTime,
window_mins: u64,
epoch_ref: &str,
local: &MetaLocal,
force: bool,
cooldown_secs: i64,
) -> bool {
let delta = (now_time.num_seconds_from_midnight() as i64
- reminder_time.num_seconds_from_midnight() as i64)
.abs();
if delta > (window_mins * 60) as i64 {
return false;
}
!(!force && local.was_notified(epoch_ref, cooldown_secs))
}
pub(crate) fn briefing_is_due(
now_time: NaiveTime,
briefing_time: NaiveTime,
window_mins: u64,
local: &MetaLocal,
force: bool,
) -> bool {
let in_window = (now_time.num_seconds_from_midnight() as i64
- briefing_time.num_seconds_from_midnight() as i64)
.abs()
<= (window_mins * 60) as i64;
in_window && (force || !local.task_briefing_done_today())
}
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();
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 cooldown_secs = (config.notify.task_cooldown_minutes * 60) as i64;
if !reminder_is_due(now_time, reminder_time, window_mins, epoch_ref, &local, force, cooldown_secs) {
continue;
}
queued.push(NotifyEvent::Reminder {
epoch_ref: epoch_ref.clone(),
body: el.body_str().trim().to_string(),
});
}
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) => briefing_is_due(now_time, briefing_time, window_mins, &local, force),
Err(_) => {
eprintln!(
" {} cannot parse task_notify_at '{}' — skipping task briefing",
"warn:".yellow(),
at_str
);
false
}
}
} else {
false };
if should_check {
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,
});
}
}
}
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)");
}
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);
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveTime;
fn t(h: u32, m: u32) -> NaiveTime {
NaiveTime::from_hms_opt(h, m, 0).unwrap()
}
fn fresh_local() -> MetaLocal {
MetaLocal::default()
}
#[test]
fn test_reminder_due_within_window() {
let local = fresh_local();
assert!(reminder_is_due(t(10, 2), t(10, 0), 5, "ref", &local, false, 3600));
}
#[test]
fn test_reminder_not_due_outside_window() {
let local = fresh_local();
assert!(!reminder_is_due(t(10, 10), t(10, 0), 5, "ref", &local, false, 3600));
}
#[test]
fn test_reminder_exactly_at_window_boundary() {
let local = fresh_local();
assert!(reminder_is_due(t(9, 55), t(10, 0), 5, "ref", &local, false, 3600));
}
#[test]
fn test_reminder_one_second_past_window() {
let local = fresh_local();
let past = NaiveTime::from_hms_opt(10, 5, 1).unwrap();
assert!(!reminder_is_due(past, t(10, 0), 5, "ref", &local, false, 3600));
}
#[test]
fn test_reminder_skipped_within_cooldown() {
let mut local = fresh_local();
let now_ts = chrono::Local::now().timestamp();
local.notified.insert("ref".into(), now_ts - 30 * 60);
assert!(!reminder_is_due(t(10, 0), t(10, 0), 5, "ref", &local, false, 3600));
}
#[test]
fn test_reminder_force_bypasses_cooldown() {
let mut local = fresh_local();
let now_ts = chrono::Local::now().timestamp();
local.notified.insert("ref".into(), now_ts - 60);
assert!(reminder_is_due(t(10, 0), t(10, 0), 5, "ref", &local, true, 3600));
}
#[test]
fn test_reminder_cooldown_expired_fires_again() {
let mut local = fresh_local();
let now_ts = chrono::Local::now().timestamp();
local.notified.insert("ref".into(), now_ts - 2 * 3600);
assert!(reminder_is_due(t(10, 0), t(10, 0), 5, "ref", &local, false, 3600));
}
#[test]
fn test_reminder_different_ref_not_suppressed() {
let mut local = fresh_local();
let now_ts = chrono::Local::now().timestamp();
local.notified.insert("ref-1".into(), now_ts - 5);
assert!(reminder_is_due(t(10, 0), t(10, 0), 5, "ref-2", &local, false, 3600));
}
#[test]
fn test_reminder_before_scheduled_time_within_window() {
let local = fresh_local();
assert!(reminder_is_due(t(9, 58), t(10, 0), 5, "ref", &local, false, 3600));
}
#[test]
fn test_briefing_due_within_window() {
let local = fresh_local();
assert!(briefing_is_due(t(9, 2), t(9, 0), 5, &local, false));
}
#[test]
fn test_briefing_not_due_outside_window() {
let local = fresh_local();
assert!(!briefing_is_due(t(11, 0), t(9, 0), 5, &local, false));
}
#[test]
fn test_briefing_skipped_if_already_sent_today() {
let mut local = fresh_local();
local.mark_task_briefing();
assert!(!briefing_is_due(t(9, 0), t(9, 0), 5, &local, false));
}
#[test]
fn test_briefing_force_resends_even_if_already_done() {
let mut local = fresh_local();
local.mark_task_briefing();
assert!(briefing_is_due(t(9, 0), t(9, 0), 5, &local, true));
}
#[test]
fn test_briefing_not_sent_yesterday_fires_today() {
let mut local = fresh_local();
local.last_task_date = Some("2000-01-01".into()); assert!(briefing_is_due(t(9, 0), t(9, 0), 5, &local, false));
}
#[test]
fn test_briefing_exactly_at_window_boundary_fires() {
let local = fresh_local();
let past = NaiveTime::from_hms_opt(8, 55, 0).unwrap(); assert!(briefing_is_due(past, t(9, 0), 5, &local, false));
}
#[test]
fn test_briefing_one_second_past_window_does_not_fire() {
let local = fresh_local();
let past = NaiveTime::from_hms_opt(8, 54, 59).unwrap(); assert!(!briefing_is_due(past, t(9, 0), 5, &local, false));
}
#[test]
fn test_tasks_body_truncates_at_max_lines() {
let count = 15usize;
let lines: Vec<String> = (1..=count).map(|i| format!("• task {}", i)).collect();
let event = NotifyEvent::Tasks { count, lines };
let body = event.body();
assert!(
body.contains("… and 5 more"),
"must show truncation suffix: {}",
body
);
assert!(
!body.contains("• task 11"),
"task 11 must not be shown (truncated)"
);
assert!(
body.contains("• task 10"),
"task 10 must be shown (last visible)"
);
}
#[test]
fn test_tasks_body_no_truncation_when_at_limit() {
let count = 10usize;
let lines: Vec<String> = (1..=count).map(|i| format!("• task {}", i)).collect();
let event = NotifyEvent::Tasks { count, lines };
let body = event.body();
assert!(
!body.contains("… and"),
"no truncation suffix when exactly at limit"
);
assert!(body.contains("• task 10"), "all 10 tasks shown");
}
#[test]
fn test_tasks_body_fewer_than_limit_no_truncation() {
let count = 3usize;
let lines: Vec<String> = (1..=count).map(|i| format!("• task {}", i)).collect();
let event = NotifyEvent::Tasks { count, lines };
let body = event.body();
assert!(
!body.contains("… and"),
"no truncation suffix for small list"
);
assert!(body.contains("3 open task(s)"));
}
#[test]
fn test_reminder_body_is_trimmed() {
let event = NotifyEvent::Reminder {
epoch_ref: "ref".into(),
body: " Team standup \n".into(),
};
assert_eq!(event.body(), "Team standup");
}
#[test]
fn test_reminder_title() {
let event = NotifyEvent::Reminder {
epoch_ref: "r".into(),
body: "b".into(),
};
assert_eq!(event.title(), "mps reminder");
}
#[test]
fn test_tasks_title() {
let event = NotifyEvent::Tasks {
count: 1,
lines: vec!["• x".into()],
};
assert_eq!(event.title(), "mps · open tasks");
}
}