Rust · 27630 bytes Raw Blame History
1 use std::{
2 collections::HashMap,
3 error::Error,
4 fmt, fs, io,
5 path::{Path, PathBuf},
6 thread,
7 time::Duration as StdDuration,
8 };
9
10 use directories::ProjectDirs;
11 use fs2::FileExt;
12 #[cfg(not(target_os = "macos"))]
13 use notify_rust::Notification;
14 use serde::{Deserialize, Serialize};
15 #[cfg(target_os = "macos")]
16 use std::process::Command;
17 use time::{Date, Duration, Month, OffsetDateTime, PrimitiveDateTime, Time};
18
19 use crate::{
20 agenda::{
21 AgendaSource, ConfiguredAgendaSource, DateRange, Event, EventDateTime, EventTiming,
22 HolidayProvider,
23 },
24 calendar::CalendarDate,
25 providers::ProviderConfig,
26 };
27
28 const STATE_VERSION: u8 = 1;
29 const GRACE_MINUTES: i64 = 10;
30 const PRUNE_AFTER_DAYS: i32 = 30;
31 const LOOKAHEAD_DAYS: i32 = 47;
32 const POLL_INTERVAL: StdDuration = StdDuration::from_secs(30);
33
34 #[derive(Debug, Clone, PartialEq, Eq)]
35 pub struct ReminderDaemonConfig {
36 pub events_file: PathBuf,
37 pub state_file: PathBuf,
38 pub providers: ProviderConfig,
39 pub poll_interval: StdDuration,
40 pub grace: Duration,
41 }
42
43 impl ReminderDaemonConfig {
44 pub fn new(events_file: PathBuf, state_file: PathBuf) -> Self {
45 Self {
46 events_file,
47 state_file,
48 providers: ProviderConfig::default(),
49 poll_interval: POLL_INTERVAL,
50 grace: Duration::minutes(GRACE_MINUTES),
51 }
52 }
53
54 pub fn with_providers(mut self, providers: ProviderConfig) -> Self {
55 self.providers = providers;
56 self
57 }
58
59 pub fn lock_file(&self) -> PathBuf {
60 self.state_file.with_extension("lock")
61 }
62 }
63
64 #[derive(Debug, Clone, PartialEq, Eq)]
65 pub struct ReminderInstance {
66 pub key: String,
67 pub event_id: String,
68 pub title: String,
69 pub location: Option<String>,
70 pub event_start: EventDateTime,
71 pub fire_at: PrimitiveDateTime,
72 pub minutes_before: u16,
73 pub all_day: bool,
74 }
75
76 impl ReminderInstance {
77 pub fn notification_title(&self) -> String {
78 format!("Reminder: {}", self.title)
79 }
80
81 pub fn notification_body(&self) -> String {
82 let when = if self.all_day {
83 format!("All day on {}", self.event_start.date)
84 } else {
85 format!(
86 "Starts at {:02}:{:02} on {}",
87 self.event_start.time.hour(),
88 self.event_start.time.minute(),
89 self.event_start.date
90 )
91 };
92
93 if let Some(location) = &self.location {
94 format!("{when}\nLocation: {location}")
95 } else {
96 when
97 }
98 }
99 }
100
101 #[derive(Debug, Clone, PartialEq, Eq)]
102 pub struct ReminderRunSummary {
103 pub delivered: usize,
104 pub skipped: usize,
105 pub failed: usize,
106 }
107
108 pub trait Notifier {
109 fn notify(&mut self, reminder: &ReminderInstance) -> Result<(), ReminderError>;
110 }
111
112 #[derive(Debug, Default)]
113 pub struct SystemNotifier;
114
115 impl Notifier for SystemNotifier {
116 fn notify(&mut self, reminder: &ReminderInstance) -> Result<(), ReminderError> {
117 show_system_notification(
118 &reminder.notification_title(),
119 &reminder.notification_body(),
120 )
121 }
122 }
123
124 pub const fn notification_backend_name() -> &'static str {
125 #[cfg(target_os = "macos")]
126 {
127 "macos-osascript"
128 }
129 #[cfg(not(target_os = "macos"))]
130 {
131 "notify-rust"
132 }
133 }
134
135 #[cfg(target_os = "macos")]
136 fn show_system_notification(summary: &str, body: &str) -> Result<(), ReminderError> {
137 let output = Command::new("osascript")
138 .args(macos_display_notification_args(summary, body))
139 .output()
140 .map_err(|err| ReminderError::Notification(format!("osascript failed: {err}")))?;
141
142 if output.status.success() {
143 return Ok(());
144 }
145
146 let stderr = String::from_utf8_lossy(&output.stderr);
147 Err(ReminderError::Notification(format!(
148 "osascript exited with {}: {}",
149 output.status,
150 stderr.trim()
151 )))
152 }
153
154 #[cfg(target_os = "macos")]
155 fn macos_display_notification_args(summary: &str, body: &str) -> Vec<String> {
156 [
157 "-e",
158 "on run argv",
159 "-e",
160 "display notification (item 2 of argv) with title (item 1 of argv)",
161 "-e",
162 "end run",
163 summary,
164 body,
165 ]
166 .into_iter()
167 .map(str::to_string)
168 .collect()
169 }
170
171 #[cfg(not(target_os = "macos"))]
172 fn show_system_notification(summary: &str, body: &str) -> Result<(), ReminderError> {
173 Notification::new()
174 .summary(summary)
175 .body(body)
176 .show()
177 .map(|_| ())
178 .map_err(|err| ReminderError::Notification(err.to_string()))
179 }
180
181 pub fn default_state_file() -> PathBuf {
182 if let Some(project_dirs) = ProjectDirs::from("com", "tenseleyFlow", "rcal") {
183 if let Some(state_dir) = project_dirs.state_dir() {
184 return state_dir.join("reminders-state.json");
185 }
186
187 return project_dirs.data_local_dir().join("reminders-state.json");
188 }
189
190 std::env::temp_dir()
191 .join("rcal")
192 .join("reminders-state.json")
193 }
194
195 pub fn default_log_file() -> PathBuf {
196 if let Some(project_dirs) = ProjectDirs::from("com", "tenseleyFlow", "rcal") {
197 return project_dirs.cache_dir().join("reminders.log");
198 }
199
200 std::env::temp_dir().join("rcal").join("reminders.log")
201 }
202
203 pub fn current_local_datetime() -> PrimitiveDateTime {
204 let now = OffsetDateTime::now_local().unwrap_or_else(|_| OffsetDateTime::now_utc());
205 PrimitiveDateTime::new(now.date(), now.time())
206 }
207
208 pub fn run_daemon(
209 config: ReminderDaemonConfig,
210 notifier: &mut dyn Notifier,
211 ) -> Result<(), ReminderError> {
212 let _lock = DaemonLock::acquire(&config.lock_file())?;
213 loop {
214 let now = current_local_datetime();
215 let _ = run_once(&config, now, notifier)?;
216 thread::sleep(config.poll_interval);
217 }
218 }
219
220 pub fn run_once(
221 config: &ReminderDaemonConfig,
222 now: PrimitiveDateTime,
223 notifier: &mut dyn Notifier,
224 ) -> Result<ReminderRunSummary, ReminderError> {
225 let source =
226 ConfiguredAgendaSource::from_events_file(&config.events_file, HolidayProvider::off())
227 .and_then(|source| {
228 source.with_microsoft_provider(
229 config.providers.microsoft.clone(),
230 config.providers.create_target,
231 )
232 })
233 .and_then(|source| {
234 source.with_google_provider(
235 config.providers.google.clone(),
236 config.providers.create_target,
237 )
238 })
239 .map_err(|err| ReminderError::Events(err.to_string()))?;
240 let mut state = ReminderState::load(&config.state_file)?;
241 let instances = reminder_instances(&source, now);
242 let mut delivered = 0;
243 let mut skipped = 0;
244 let mut failed = 0;
245 let expires_before = now - config.grace;
246
247 for instance in instances {
248 if state.contains(&instance.key) {
249 continue;
250 }
251
252 if instance.fire_at > now {
253 continue;
254 }
255
256 if instance.fire_at < expires_before {
257 state.record(instance, ReminderStatus::Skipped);
258 skipped += 1;
259 continue;
260 }
261
262 match notifier.notify(&instance) {
263 Ok(()) => {
264 state.record(instance, ReminderStatus::Delivered);
265 delivered += 1;
266 }
267 Err(_) => {
268 failed += 1;
269 }
270 }
271 }
272
273 state.prune(now.date());
274 state.save(&config.state_file)?;
275 Ok(ReminderRunSummary {
276 delivered,
277 skipped,
278 failed,
279 })
280 }
281
282 pub fn test_notification(notifier: &mut dyn Notifier) -> Result<(), ReminderError> {
283 let date = CalendarDate::from(current_local_datetime().date());
284 let reminder = ReminderInstance {
285 key: "test".to_string(),
286 event_id: "test".to_string(),
287 title: "rcal reminder test".to_string(),
288 location: None,
289 event_start: EventDateTime::new(date, current_local_datetime().time()),
290 fire_at: current_local_datetime(),
291 minutes_before: 0,
292 all_day: false,
293 };
294 notifier.notify(&reminder)
295 }
296
297 pub fn reminder_instances(
298 source: &dyn AgendaSource,
299 now: PrimitiveDateTime,
300 ) -> Vec<ReminderInstance> {
301 let range = DateRange::new(
302 CalendarDate::from(now.date()).add_days(-PRUNE_AFTER_DAYS),
303 CalendarDate::from(now.date()).add_days(LOOKAHEAD_DAYS),
304 )
305 .expect("reminder scan range is valid");
306 let mut instances = source
307 .events_intersecting(range)
308 .into_iter()
309 .filter(Event::is_editable)
310 .flat_map(reminders_for_event)
311 .collect::<Vec<_>>();
312 instances.sort_by(|left, right| {
313 left.fire_at
314 .cmp(&right.fire_at)
315 .then(left.title.cmp(&right.title))
316 .then(left.key.cmp(&right.key))
317 });
318 instances
319 }
320
321 fn reminders_for_event(event: Event) -> Vec<ReminderInstance> {
322 let Some(event_start) = reminder_event_start(&event) else {
323 return Vec::new();
324 };
325 let all_day = matches!(event.timing, EventTiming::AllDay { .. });
326 let start_at = event_datetime_to_primitive(event_start);
327
328 event
329 .reminders
330 .iter()
331 .map(|reminder| {
332 let fire_at = start_at - Duration::minutes(i64::from(reminder.minutes_before));
333 ReminderInstance {
334 key: reminder_key(&event, event_start, reminder.minutes_before),
335 event_id: event.id.clone(),
336 title: event.title.clone(),
337 location: event.location.clone(),
338 event_start,
339 fire_at,
340 minutes_before: reminder.minutes_before,
341 all_day,
342 }
343 })
344 .collect()
345 }
346
347 fn reminder_event_start(event: &Event) -> Option<EventDateTime> {
348 match event.timing {
349 EventTiming::AllDay { date } => Some(EventDateTime::new(date, Time::MIDNIGHT)),
350 EventTiming::Timed { start, .. } => Some(start),
351 }
352 }
353
354 fn event_datetime_to_primitive(datetime: EventDateTime) -> PrimitiveDateTime {
355 PrimitiveDateTime::new(datetime.date.into(), datetime.time)
356 }
357
358 fn reminder_key(event: &Event, event_start: EventDateTime, minutes_before: u16) -> String {
359 format!(
360 "{}|{}T{:02}:{:02}|{}m",
361 event.id,
362 event_start.date,
363 event_start.time.hour(),
364 event_start.time.minute(),
365 minutes_before
366 )
367 }
368
369 #[derive(Debug, Clone, PartialEq, Eq)]
370 struct ReminderState {
371 records: HashMap<String, ReminderStateRecord>,
372 }
373
374 impl ReminderState {
375 fn empty() -> Self {
376 Self {
377 records: HashMap::new(),
378 }
379 }
380
381 fn load(path: &Path) -> Result<Self, ReminderError> {
382 let body = match fs::read_to_string(path) {
383 Ok(body) => body,
384 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::empty()),
385 Err(err) => {
386 return Err(ReminderError::StateRead {
387 path: path.to_path_buf(),
388 reason: err.to_string(),
389 });
390 }
391 };
392 let file = serde_json::from_str::<ReminderStateFile>(&body).map_err(|err| {
393 ReminderError::StateParse {
394 path: path.to_path_buf(),
395 reason: err.to_string(),
396 }
397 })?;
398 if file.version != STATE_VERSION {
399 return Err(ReminderError::StateParse {
400 path: path.to_path_buf(),
401 reason: format!("unsupported reminder state version {}", file.version),
402 });
403 }
404 for record in &file.reminders {
405 if parse_date(&record.fire_date).is_none() || parse_time(&record.fire_time).is_none() {
406 return Err(ReminderError::StateParse {
407 path: path.to_path_buf(),
408 reason: format!("invalid reminder fire time for key '{}'", record.key),
409 });
410 }
411 }
412
413 Ok(Self {
414 records: file
415 .reminders
416 .into_iter()
417 .map(|record| (record.key.clone(), record))
418 .collect(),
419 })
420 }
421
422 fn save(&self, path: &Path) -> Result<(), ReminderError> {
423 if let Some(parent) = path.parent() {
424 fs::create_dir_all(parent).map_err(|err| ReminderError::StateWrite {
425 path: parent.to_path_buf(),
426 reason: err.to_string(),
427 })?;
428 }
429
430 let mut reminders = self.records.values().cloned().collect::<Vec<_>>();
431 reminders.sort_by(|left, right| left.key.cmp(&right.key));
432 let file = ReminderStateFile {
433 version: STATE_VERSION,
434 reminders,
435 };
436 let body =
437 serde_json::to_string_pretty(&file).map_err(|err| ReminderError::StateWrite {
438 path: path.to_path_buf(),
439 reason: err.to_string(),
440 })?;
441 let temp_path = path.with_extension(format!(
442 "{}.tmp",
443 path.extension()
444 .and_then(|extension| extension.to_str())
445 .unwrap_or("json")
446 ));
447 fs::write(&temp_path, body).map_err(|err| ReminderError::StateWrite {
448 path: temp_path.clone(),
449 reason: err.to_string(),
450 })?;
451 fs::rename(&temp_path, path).map_err(|err| ReminderError::StateWrite {
452 path: path.to_path_buf(),
453 reason: err.to_string(),
454 })
455 }
456
457 fn contains(&self, key: &str) -> bool {
458 self.records.contains_key(key)
459 }
460
461 fn record(&mut self, instance: ReminderInstance, status: ReminderStatus) {
462 self.records.insert(
463 instance.key.clone(),
464 ReminderStateRecord {
465 key: instance.key,
466 status,
467 fire_date: instance.fire_at.date().to_string(),
468 fire_time: format_time(instance.fire_at.time()),
469 },
470 );
471 }
472
473 fn prune(&mut self, today: Date) {
474 let cutoff = CalendarDate::from(today).add_days(-PRUNE_AFTER_DAYS);
475 self.records.retain(|_, record| {
476 record
477 .fire_date_value()
478 .map(|date| date >= cutoff)
479 .unwrap_or(true)
480 });
481 }
482 }
483
484 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
485 struct ReminderStateFile {
486 version: u8,
487 #[serde(default)]
488 reminders: Vec<ReminderStateRecord>,
489 }
490
491 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
492 struct ReminderStateRecord {
493 key: String,
494 status: ReminderStatus,
495 fire_date: String,
496 fire_time: String,
497 }
498
499 impl ReminderStateRecord {
500 fn fire_date_value(&self) -> Option<CalendarDate> {
501 parse_date(&self.fire_date)
502 }
503 }
504
505 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
506 #[serde(rename_all = "snake_case")]
507 enum ReminderStatus {
508 Delivered,
509 Skipped,
510 }
511
512 struct DaemonLock {
513 _file: fs::File,
514 }
515
516 impl DaemonLock {
517 fn acquire(path: &Path) -> Result<Self, ReminderError> {
518 if let Some(parent) = path.parent() {
519 fs::create_dir_all(parent).map_err(|err| ReminderError::Lock {
520 path: parent.to_path_buf(),
521 reason: err.to_string(),
522 })?;
523 }
524 let file = fs::OpenOptions::new()
525 .read(true)
526 .write(true)
527 .create(true)
528 .truncate(false)
529 .open(path)
530 .map_err(|err| ReminderError::Lock {
531 path: path.to_path_buf(),
532 reason: err.to_string(),
533 })?;
534 file.try_lock_exclusive()
535 .map_err(|err| ReminderError::Lock {
536 path: path.to_path_buf(),
537 reason: err.to_string(),
538 })?;
539
540 Ok(Self { _file: file })
541 }
542 }
543
544 #[derive(Debug)]
545 pub enum ReminderError {
546 Events(String),
547 Notification(String),
548 StateRead { path: PathBuf, reason: String },
549 StateParse { path: PathBuf, reason: String },
550 StateWrite { path: PathBuf, reason: String },
551 Lock { path: PathBuf, reason: String },
552 }
553
554 impl fmt::Display for ReminderError {
555 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
556 match self {
557 Self::Events(reason) => write!(f, "failed to load reminder events: {reason}"),
558 Self::Notification(reason) => write!(f, "failed to send notification: {reason}"),
559 Self::StateRead { path, reason } => {
560 write!(
561 f,
562 "failed to read reminder state {}: {reason}",
563 path.display()
564 )
565 }
566 Self::StateParse { path, reason } => {
567 write!(
568 f,
569 "failed to parse reminder state {}: {reason}",
570 path.display()
571 )
572 }
573 Self::StateWrite { path, reason } => {
574 write!(
575 f,
576 "failed to write reminder state {}: {reason}",
577 path.display()
578 )
579 }
580 Self::Lock { path, reason } => {
581 write!(
582 f,
583 "failed to lock reminder daemon {}: {reason}",
584 path.display()
585 )
586 }
587 }
588 }
589 }
590
591 impl Error for ReminderError {}
592
593 fn format_time(time: Time) -> String {
594 format!("{:02}:{:02}", time.hour(), time.minute())
595 }
596
597 fn parse_date(value: &str) -> Option<CalendarDate> {
598 let mut parts = value.split('-');
599 let year = parts.next()?.parse::<i32>().ok()?;
600 let month = parts.next()?.parse::<u8>().ok()?;
601 let day = parts.next()?.parse::<u8>().ok()?;
602 if parts.next().is_some() {
603 return None;
604 }
605 CalendarDate::from_ymd(year, Month::try_from(month).ok()?, day).ok()
606 }
607
608 fn parse_time(value: &str) -> Option<Time> {
609 let mut parts = value.split(':');
610 let hour = parts.next()?.parse::<u8>().ok()?;
611 let minute = parts.next()?.parse::<u8>().ok()?;
612 if parts.next().is_some() {
613 return None;
614 }
615 Time::from_hms(hour, minute, 0).ok()
616 }
617
618 #[cfg(test)]
619 mod tests {
620 use super::*;
621 use time::Month;
622
623 use crate::agenda::{
624 CreateEventDraft, CreateEventTiming, Event, InMemoryAgendaSource, RecurrenceEnd,
625 RecurrenceFrequency, RecurrenceRule, Reminder, SourceMetadata,
626 };
627
628 #[derive(Default)]
629 struct FakeNotifier {
630 sent: Vec<String>,
631 fail: bool,
632 }
633
634 impl Notifier for FakeNotifier {
635 fn notify(&mut self, reminder: &ReminderInstance) -> Result<(), ReminderError> {
636 if self.fail {
637 Err(ReminderError::Notification("boom".to_string()))
638 } else {
639 self.sent.push(reminder.key.clone());
640 Ok(())
641 }
642 }
643 }
644
645 fn date(day: u8) -> CalendarDate {
646 CalendarDate::from_ymd(2026, Month::April, day).expect("valid test date")
647 }
648
649 fn at(date: CalendarDate, hour: u8, minute: u8) -> EventDateTime {
650 EventDateTime::new(date, Time::from_hms(hour, minute, 0).expect("valid time"))
651 }
652
653 fn now(day: u8, hour: u8, minute: u8) -> PrimitiveDateTime {
654 PrimitiveDateTime::new(
655 date(day).into(),
656 Time::from_hms(hour, minute, 0).expect("valid time"),
657 )
658 }
659
660 fn timed_event(id: &str, title: &str, start: EventDateTime, end: EventDateTime) -> Event {
661 Event::timed(id, title, start, end, SourceMetadata::local()).expect("valid timed event")
662 }
663
664 fn temp_path(name: &str, file: &str) -> PathBuf {
665 std::env::temp_dir()
666 .join(format!("rcal-reminders-test-{}", std::process::id()))
667 .join(name)
668 .join(file)
669 }
670
671 #[test]
672 fn reminder_fire_times_cover_timed_all_day_cross_midnight_and_recurring_events() {
673 let day = date(23);
674 let timed = timed_event("timed", "Timed", at(day, 9, 0), at(day, 10, 0))
675 .with_reminders(vec![Reminder::minutes_before(15)]);
676 let all_day = Event::all_day("all-day", "All day", day, SourceMetadata::local())
677 .with_reminders(vec![Reminder::minutes_before(60)]);
678 let cross_midnight =
679 timed_event("late", "Late", at(day, 23, 30), at(day.add_days(1), 1, 0))
680 .with_reminders(vec![Reminder::minutes_before(30)]);
681 let recurring = timed_event("daily", "Daily", at(day, 8, 0), at(day, 8, 30))
682 .with_reminders(vec![Reminder::minutes_before(10)])
683 .with_recurrence(RecurrenceRule {
684 frequency: RecurrenceFrequency::Daily,
685 interval: 1,
686 end: RecurrenceEnd::Count(2),
687 weekdays: Vec::new(),
688 monthly: None,
689 yearly: None,
690 });
691 let source = InMemoryAgendaSource::with_events_and_holidays(
692 vec![timed, all_day, cross_midnight, recurring],
693 Vec::new(),
694 );
695
696 let instances = reminder_instances(&source, now(23, 9, 0));
697 let keys = instances
698 .iter()
699 .map(|instance| (instance.event_id.as_str(), instance.fire_at))
700 .collect::<Vec<_>>();
701
702 assert!(keys.contains(&("timed", now(23, 8, 45))));
703 assert!(keys.contains(&("all-day", now(22, 23, 0))));
704 assert!(keys.contains(&("late", now(23, 23, 0))));
705 assert!(
706 keys.iter()
707 .any(|(id, fire_at)| id.starts_with("daily#") && *fire_at == now(24, 7, 50))
708 );
709 }
710
711 #[test]
712 fn run_once_delivers_within_grace_and_skips_older_reminders() {
713 let dir = temp_path("grace", "events.json");
714 let _ = std::fs::remove_dir_all(dir.parent().expect("path has parent"));
715 let state_file = temp_path("grace", "state.json");
716 let mut source = ConfiguredAgendaSource::from_events_file(&dir, HolidayProvider::off())
717 .expect("events load");
718 source
719 .create_event(CreateEventDraft {
720 title: "Recent".to_string(),
721 timing: CreateEventTiming::Timed {
722 start: at(date(23), 9, 5),
723 end: at(date(23), 10, 0),
724 },
725 location: None,
726 notes: None,
727 reminders: vec![Reminder::minutes_before(10)],
728 recurrence: None,
729 })
730 .expect("event saves");
731 source
732 .create_event(CreateEventDraft {
733 title: "Old".to_string(),
734 timing: CreateEventTiming::Timed {
735 start: at(date(23), 8, 30),
736 end: at(date(23), 9, 0),
737 },
738 location: None,
739 notes: None,
740 reminders: vec![Reminder::minutes_before(30)],
741 recurrence: None,
742 })
743 .expect("event saves");
744 let config = ReminderDaemonConfig::new(dir.clone(), state_file);
745 let mut notifier = FakeNotifier::default();
746
747 let summary = run_once(&config, now(23, 9, 0), &mut notifier).expect("run succeeds");
748
749 let _ = std::fs::remove_dir_all(dir.parent().expect("test dir exists"));
750 assert_eq!(summary.delivered, 1);
751 assert_eq!(summary.skipped, 1);
752 assert_eq!(notifier.sent.len(), 1);
753 }
754
755 #[test]
756 fn delivered_state_dedupes_across_runs() {
757 let events_file = temp_path("dedupe", "events.json");
758 let _ = std::fs::remove_dir_all(events_file.parent().expect("path has parent"));
759 let state_file = temp_path("dedupe", "state.json");
760 let mut source =
761 ConfiguredAgendaSource::from_events_file(&events_file, HolidayProvider::off())
762 .expect("events load");
763 source
764 .create_event(CreateEventDraft {
765 title: "Planning".to_string(),
766 timing: CreateEventTiming::Timed {
767 start: at(date(23), 9, 5),
768 end: at(date(23), 10, 0),
769 },
770 location: None,
771 notes: None,
772 reminders: vec![Reminder::minutes_before(10)],
773 recurrence: None,
774 })
775 .expect("event saves");
776 let config = ReminderDaemonConfig::new(events_file.clone(), state_file);
777 let mut notifier = FakeNotifier::default();
778 let first = run_once(&config, now(23, 9, 0), &mut notifier).expect("first run");
779 let second = run_once(&config, now(23, 9, 1), &mut notifier).expect("second run");
780
781 let _ = std::fs::remove_dir_all(events_file.parent().expect("test dir exists"));
782 assert_eq!(first.delivered, 1);
783 assert_eq!(second.delivered, 0);
784 assert_eq!(notifier.sent.len(), 1);
785 }
786
787 #[test]
788 fn malformed_state_is_a_clear_error_and_prune_drops_old_records() {
789 let state_file = temp_path("state", "state.json");
790 let _ = std::fs::remove_dir_all(state_file.parent().expect("path has parent"));
791 std::fs::create_dir_all(state_file.parent().expect("path has parent"))
792 .expect("dir creates");
793 std::fs::write(&state_file, "not-json").expect("state writes");
794 let err = ReminderState::load(&state_file).expect_err("malformed state fails");
795 assert!(err.to_string().contains("failed to parse reminder state"));
796
797 let mut state = ReminderState::empty();
798 state.records.insert(
799 "old".to_string(),
800 ReminderStateRecord {
801 key: "old".to_string(),
802 status: ReminderStatus::Delivered,
803 fire_date: "2026-03-01".to_string(),
804 fire_time: "09:00".to_string(),
805 },
806 );
807 state.records.insert(
808 "new".to_string(),
809 ReminderStateRecord {
810 key: "new".to_string(),
811 status: ReminderStatus::Delivered,
812 fire_date: "2026-04-20".to_string(),
813 fire_time: "09:00".to_string(),
814 },
815 );
816 state.prune(date(23).into());
817
818 let _ = std::fs::remove_dir_all(state_file.parent().expect("test dir exists"));
819 assert!(!state.records.contains_key("old"));
820 assert!(state.records.contains_key("new"));
821 }
822
823 #[test]
824 fn notification_body_includes_time_and_location() {
825 let reminder = ReminderInstance {
826 key: "key".to_string(),
827 event_id: "event".to_string(),
828 title: "Planning".to_string(),
829 location: Some("Room 1".to_string()),
830 event_start: at(date(23), 9, 0),
831 fire_at: now(23, 8, 50),
832 minutes_before: 10,
833 all_day: false,
834 };
835
836 assert_eq!(reminder.notification_title(), "Reminder: Planning");
837 assert!(reminder.notification_body().contains("Starts at 09:00"));
838 assert!(reminder.notification_body().contains("Location: Room 1"));
839 }
840
841 #[cfg(target_os = "macos")]
842 #[test]
843 fn macos_notification_uses_osascript_with_separate_user_text_args() {
844 let args = macos_display_notification_args(
845 "Reminder: Planning \"review\"",
846 "Starts now\nLocation: Room 1",
847 );
848
849 assert_eq!(args[0], "-e");
850 assert_eq!(args[1], "on run argv");
851 assert_eq!(
852 args[3],
853 "display notification (item 2 of argv) with title (item 1 of argv)"
854 );
855 assert_eq!(args[5], "end run");
856 assert_eq!(args[6], "Reminder: Planning \"review\"");
857 assert_eq!(args[7], "Starts now\nLocation: Room 1");
858 }
859 }
860