use crate::types::Note; use crate::util::local_naive_to_utc; use anyhow::{Context, Result}; use chrono::{DateTime, Duration, Local, NaiveTime, Utc}; use std::process::Command; pub struct Scheduler; impl Scheduler { pub fn schedule(note: &Note) -> Result<()> { let fire_time = note.effective_time().context("note has no scheduled time")?; create_timer(¬e.id, fire_time) } pub fn cancel(note_id: &str) -> Result<()> { let timer_name = timer_unit_name(note_id); let service_name = service_unit_name(note_id); stop_unit(&timer_name)?; disable_unit(&timer_name)?; stop_unit(&service_name)?; Ok(()) } pub fn snooze(note: &mut Note, snooze_until: DateTime) -> Result<()> { Self::cancel(¬e.id).ok(); note.snoozed_until = Some(snooze_until); create_timer(¬e.id, snooze_until) } pub fn fire(note: &Note, missed_grace_minutes: i64) -> bool { let now = Utc::now(); if let Some(t) = note.effective_time() { let diff = now.signed_duration_since(t); if diff > Duration::minutes(missed_grace_minutes) { tracing::info!("reminder {} missed ({}m ago), skipping", note.id, diff.num_minutes()); return false; } } true } pub fn next_recurrence(note: &Note, default_morning: &str) -> Option> { let rrule = note.rrule.as_ref()?; parse_next_from_rrule(rrule.as_str(), default_morning) } } fn timer_unit_name(id: &str) -> String { format!("breadpad-reminder-{}.timer", id) } fn service_unit_name(id: &str) -> String { format!("breadpad-reminder-{}.service", id) } fn create_timer(id: &str, fire_time: DateTime) -> Result<()> { // Convert to local time for systemd OnCalendar let local: chrono::DateTime = fire_time.with_timezone(&Local); let on_calendar = local.format("%Y-%m-%d %H:%M:%S").to_string(); let timer_name = timer_unit_name(id); // Find the breadpad binary. Order of preference: // 1. $BREADPAD_BIN override, // 2. a `breadpad` next to the currently running executable, // 3. standard install locations. let breadpad_exe = std::env::var_os("BREADPAD_BIN") .map(std::path::PathBuf::from) .filter(|p| p.exists()) .or_else(|| { std::env::current_exe() .ok() .and_then(|exe| exe.parent().map(|p| p.join("breadpad"))) .filter(|p| p.exists()) }) .or_else(|| { let home_bin = dirs::home_dir().map(|h| h.join(".local/bin/breadpad")); ["/usr/local/bin/breadpad", "/usr/bin/breadpad"] .iter() .map(std::path::PathBuf::from) .chain(home_bin) .find(|p| p.exists()) }) .context("breadpad binary not found in $BREADPAD_BIN, alongside this executable, or in standard locations")?; // Use systemd-run to create both service + timer as a transient unit // Pass necessary environment variables for notifications to work let mut cmd = Command::new("systemd-run"); cmd.arg("--user") .arg("--unit") .arg(timer_name.strip_suffix(".timer").unwrap_or(&timer_name)) .arg("--timer-property") .arg(format!("OnCalendar={}", on_calendar)) .arg("--timer-property") .arg("Persistent=true"); // Pass DBUS and display environment variables so notify-send works if let Ok(dbus) = std::env::var("DBUS_SESSION_BUS_ADDRESS") { cmd.arg("--setenv").arg(format!("DBUS_SESSION_BUS_ADDRESS={}", dbus)); } if let Ok(display) = std::env::var("DISPLAY") { cmd.arg("--setenv").arg(format!("DISPLAY={}", display)); } if let Ok(wayland) = std::env::var("WAYLAND_DISPLAY") { cmd.arg("--setenv").arg(format!("WAYLAND_DISPLAY={}", wayland)); } cmd.arg("--") .arg(&breadpad_exe) .arg("fire") .arg(id); let status = cmd.status().context("failed to run systemd-run")?; if !status.success() { anyhow::bail!("systemd-run failed for reminder {}", id); } tracing::info!("scheduled reminder {} at {} using {}", id, on_calendar, breadpad_exe.display()); Ok(()) } fn stop_unit(unit: &str) -> Result<()> { Command::new("systemctl") .args(["--user", "stop", unit]) .status() .context("systemctl stop")?; Ok(()) } fn disable_unit(unit: &str) -> Result<()> { Command::new("systemctl") .args(["--user", "disable", "--now", unit]) .status() .context("systemctl disable")?; Ok(()) } pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> Option> { // (see tests module below for coverage) // Parse RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0 etc. // We extract FREQ, BYDAY, BYHOUR, BYMINUTE to compute next occurrence. if rrule_str.trim().is_empty() { return None; } let parts: std::collections::HashMap<&str, &str> = rrule_str .trim_start_matches("RRULE:") .split(';') .filter_map(|part| { let mut kv = part.splitn(2, '='); Some((kv.next()?, kv.next()?)) }) .collect(); let freq = parts.get("FREQ")?; let freq = *freq; let (default_h, default_m): (u32, u32) = { let mut it = default_morning.splitn(2, ':'); let h = it.next().and_then(|s| s.parse().ok()).unwrap_or(8); let m = it.next().and_then(|s| s.parse().ok()).unwrap_or(0); (h, m) }; let hour: u32 = parts.get("BYHOUR").and_then(|v| v.parse().ok()).unwrap_or(default_h); let minute: u32 = parts.get("BYMINUTE").and_then(|v| v.parse().ok()).unwrap_or(default_m); let now = Local::now(); let fire_time = NaiveTime::from_hms_opt(hour, minute, 0)?; match freq { "DAILY" => { let today = now.date_naive().and_time(fire_time); let naive = if now.naive_local() < today { today } else { (now.date_naive() + chrono::Duration::days(1)).and_time(fire_time) }; return Some(local_naive_to_utc(naive)); } "WEEKLY" => { use chrono::Datelike; let byday = parts.get("BYDAY").unwrap_or(&"MO"); let target_wd = match *byday { "MO" => chrono::Weekday::Mon, "TU" => chrono::Weekday::Tue, "WE" => chrono::Weekday::Wed, "TH" => chrono::Weekday::Thu, "FR" => chrono::Weekday::Fri, "SA" => chrono::Weekday::Sat, _ => chrono::Weekday::Sun, }; let days_ahead = (target_wd.num_days_from_monday() as i64 - now.weekday().num_days_from_monday() as i64) .rem_euclid(7); let days_ahead = if days_ahead == 0 { if now.time() < fire_time { 0 } else { 7 } } else { days_ahead }; let target_date = (now.date_naive() + chrono::Duration::days(days_ahead)).and_time(fire_time); return Some(local_naive_to_utc(target_date)); } "MONTHLY" => { use chrono::Datelike; // BYMONTHDAY isn't guaranteed to be present — breadman's note // editor lets a user type an arbitrary RRULE by hand, and // "FREQ=MONTHLY" alone is a perfectly valid (if under-specified) // one. Fall back to today's day-of-month, mirroring how WEEKLY // above defaults BYDAY to "MO" when absent. let day: u32 = parts .get("BYMONTHDAY") .and_then(|v| v.parse().ok()) .filter(|d: &u32| (1..=31).contains(d)) .unwrap_or_else(|| now.day()); let mut year = now.year(); let mut month = now.month(); // Walk forward month by month for the next calendar date that // (a) actually has this day-of-month (a 31st skips e.g. April) // and (b) is still in the future. Bounded to 24 months as a // defensive cap — every valid day (1-31) recurs well within a // year, so this should never come close to firing. for _ in 0..24 { if let Some(date) = chrono::NaiveDate::from_ymd_opt(year, month, day) { let candidate = date.and_time(fire_time); if now.naive_local() < candidate { return Some(local_naive_to_utc(candidate)); } } month += 1; if month > 12 { month = 1; year += 1; } } None } _ => None, } } #[cfg(test)] mod tests { use super::*; use crate::types::{Note, NoteType, RecurrenceRule}; use chrono::{Datelike, Local, Timelike, Utc}; fn reminder(id: &str) -> Note { let mut n = Note::new("test reminder".into(), NoteType::Reminder, None); // Override auto-generated id for readable test names n.id = id.to_string(); n } // ---- Scheduler::fire ---- #[test] fn fire_note_without_time_always_fires() { let note = reminder("no-time"); // effective_time() is None → fire returns true (no time = no missed check) assert!(Scheduler::fire(¬e, 60)); } #[test] fn fire_future_reminder_fires() { let mut note = reminder("future"); note.time = Some(Utc::now() + Duration::minutes(10)); assert!(Scheduler::fire(¬e, 60)); } #[test] fn fire_recent_past_reminder_fires() { let mut note = reminder("recent"); note.time = Some(Utc::now() - Duration::minutes(5)); // 5 min ago, grace = 60 min → should fire assert!(Scheduler::fire(¬e, 60)); } #[test] fn fire_exactly_at_grace_boundary_fires() { let mut note = reminder("boundary"); // Use 59 min (well inside the 60-min grace) to avoid a race with wall time note.time = Some(Utc::now() - Duration::minutes(59)); assert!(Scheduler::fire(¬e, 60)); } #[test] fn fire_missed_reminder_beyond_grace_skips() { let mut note = reminder("missed"); note.time = Some(Utc::now() - Duration::minutes(90)); // 90 min ago, grace = 60 → should NOT fire assert!(!Scheduler::fire(¬e, 60)); } #[test] fn fire_uses_snoozed_until_if_set() { let mut note = reminder("snoozed"); note.time = Some(Utc::now() - Duration::hours(5)); // original would be missed note.snoozed_until = Some(Utc::now() - Duration::minutes(5)); // snooze is recent // effective_time = snoozed_until (5 min ago), grace = 60 → fires assert!(Scheduler::fire(¬e, 60)); } #[test] fn fire_zero_grace_only_fires_future() { let mut future = reminder("zero-future"); future.time = Some(Utc::now() + Duration::seconds(1)); assert!(Scheduler::fire(&future, 0)); let mut past = reminder("zero-past"); past.time = Some(Utc::now() - Duration::seconds(1)); assert!(!Scheduler::fire(&past, 0)); } // ---- Scheduler::next_recurrence ---- #[test] fn next_recurrence_none_without_rrule() { let note = reminder("no-rrule"); assert!(Scheduler::next_recurrence(¬e, "08:00").is_none()); } #[test] fn next_recurrence_daily_in_future() { let mut note = reminder("daily"); note.rrule = Some(RecurrenceRule::new("RRULE:FREQ=DAILY;BYHOUR=8;BYMINUTE=0;BYSECOND=0")); let t = Scheduler::next_recurrence(¬e, "08:00").unwrap(); assert!(t >= Utc::now()); } #[test] fn next_recurrence_weekly_is_correct_weekday() { let mut note = reminder("weekly-mon"); note.rrule = Some(RecurrenceRule::new("RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0")); let t = Scheduler::next_recurrence(¬e, "08:00").unwrap(); let local: chrono::DateTime = t.into(); assert_eq!(local.weekday(), chrono::Weekday::Mon); } // ---- parse_next_from_rrule ---- #[test] fn daily_rrule_next_is_in_future() { let t = parse_next_from_rrule("RRULE:FREQ=DAILY;BYHOUR=14;BYMINUTE=0;BYSECOND=0", "08:00"); assert!(t.is_some()); assert!(t.unwrap() >= Utc::now()); } #[test] fn daily_rrule_correct_hour() { let t = parse_next_from_rrule("RRULE:FREQ=DAILY;BYHOUR=14;BYMINUTE=30;BYSECOND=0", "08:00").unwrap(); let local: chrono::DateTime = t.into(); assert_eq!(local.hour(), 14); assert_eq!(local.minute(), 30); } #[test] fn daily_rrule_defaults_hour_from_morning() { // No BYHOUR in rrule — should fall back to default_morning let t = parse_next_from_rrule("RRULE:FREQ=DAILY", "07:15").unwrap(); let local: chrono::DateTime = t.into(); assert_eq!(local.hour(), 7); assert_eq!(local.minute(), 15); } #[test] fn weekly_monday_is_monday() { let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0", "08:00").unwrap(); let local: chrono::DateTime = t.into(); assert_eq!(local.weekday(), chrono::Weekday::Mon); assert!(t >= Utc::now()); } #[test] fn weekly_friday_is_friday() { let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=FR;BYHOUR=12;BYMINUTE=0;BYSECOND=0", "08:00").unwrap(); let local: chrono::DateTime = t.into(); assert_eq!(local.weekday(), chrono::Weekday::Fri); } #[test] fn weekly_wednesday_correct_hour() { let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=WE;BYHOUR=15;BYMINUTE=45;BYSECOND=0", "08:00").unwrap(); let local: chrono::DateTime = t.into(); assert_eq!(local.hour(), 15); assert_eq!(local.minute(), 45); } #[test] fn weekly_saturday_is_saturday() { let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=SA;BYHOUR=10;BYMINUTE=0;BYSECOND=0", "08:00").unwrap(); let local: chrono::DateTime = t.into(); assert_eq!(local.weekday(), chrono::Weekday::Sat); } #[test] fn weekly_tuesday_is_tuesday() { let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=TU;BYHOUR=10;BYMINUTE=0;BYSECOND=0", "08:00").unwrap(); let local: chrono::DateTime = t.into(); assert_eq!(local.weekday(), chrono::Weekday::Tue); } #[test] fn weekly_thursday_is_thursday() { let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=TH;BYHOUR=11;BYMINUTE=30;BYSECOND=0", "08:00").unwrap(); let local: chrono::DateTime = t.into(); assert_eq!(local.weekday(), chrono::Weekday::Thu); assert_eq!(local.minute(), 30); } #[test] fn weekly_sunday_is_sunday() { let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=SU;BYHOUR=19;BYMINUTE=0;BYSECOND=0", "08:00").unwrap(); let local: chrono::DateTime = t.into(); assert_eq!(local.weekday(), chrono::Weekday::Sun); } #[test] fn weekly_unknown_byday_falls_back_to_sunday() { // The match arm `_ => Weekday::Sun` handles unrecognised BYDAY values let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=XX;BYHOUR=9;BYMINUTE=0;BYSECOND=0", "08:00").unwrap(); let local: chrono::DateTime = t.into(); assert_eq!(local.weekday(), chrono::Weekday::Sun); } #[test] fn daily_without_byhour_uses_default_morning() { let t = parse_next_from_rrule("RRULE:FREQ=DAILY", "06:45").unwrap(); let local: chrono::DateTime = t.into(); assert_eq!(local.hour(), 6); assert_eq!(local.minute(), 45); } #[test] fn unknown_freq_returns_none() { assert!(parse_next_from_rrule("RRULE:FREQ=YEARLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none()); } #[test] fn monthly_reschedules_instead_of_returning_none() { // This used to be the exact bug: MONTHLY fell into the `_ => None` // arm, so a monthly reminder fired once and never rescheduled. let t = parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYMONTHDAY=15;BYHOUR=9;BYMINUTE=0", "08:00"); assert!(t.is_some(), "MONTHLY must produce a next occurrence, not None"); let local: chrono::DateTime = t.unwrap().into(); assert_eq!(local.day(), 15); assert_eq!(local.hour(), 9); assert_eq!(local.minute(), 0); assert!(local > Local::now()); } #[test] fn monthly_without_bymonthday_uses_todays_day_of_month() { let t = parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=23;BYMINUTE=59", "08:00").unwrap(); let local: chrono::DateTime = t.into(); assert_eq!(local.day(), Local::now().day()); } #[test] fn monthly_on_the_31st_skips_shorter_months() { // Every candidate month/day combination this walks must actually // exist (from_ymd_opt returning None for e.g. April 31 is skipped // internally) — this mostly guards against a panic/infinite loop // regression rather than a specific date, since "next Feb 31" et al // must fall through to a month that really has a 31st. let t = parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYMONTHDAY=31;BYHOUR=9;BYMINUTE=0", "08:00"); assert!(t.is_some()); let local: chrono::DateTime = t.unwrap().into(); assert_eq!(local.day(), 31); } #[test] fn empty_rrule_string_returns_none() { assert!(parse_next_from_rrule("", "08:00").is_none()); } #[test] fn rrule_without_rrule_prefix_still_parses() { // trim_start_matches("RRULE:") handles the prefix; without it the key would be "RRULE:FREQ" which won't match // just verify we don't panic let _ = parse_next_from_rrule("FREQ=DAILY;BYHOUR=8;BYMINUTE=0", "08:00"); } // ---- unit name helpers ---- #[test] fn timer_unit_name_format() { assert_eq!(timer_unit_name("abc123"), "breadpad-reminder-abc123.timer"); } #[test] fn service_unit_name_format() { assert_eq!(service_unit_name("abc123"), "breadpad-reminder-abc123.service"); } }