1use chrono::{DateTime, Datelike, NaiveTime, Utc, Weekday};
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum Priority {
15 Critical = 1,
16 High = 2,
17 Medium = 3,
18 Low = 4,
19 Minimal = 5,
20}
21
22impl Default for Priority {
23 fn default() -> Self {
24 Self::Medium
25 }
26}
27
28impl Priority {
29 pub fn as_u8(self) -> u8 {
30 self as u8
31 }
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum ReminderStatus {
41 Pending,
43 Due,
45 AwaitingPermission,
47 InProgress,
49 Completed,
51 Snoozed,
53 Cancelled,
55}
56
57impl ReminderStatus {
58 pub fn is_terminal(self) -> bool {
60 matches!(self, Self::Completed | Self::Cancelled)
61 }
62
63 pub fn is_actionable(self) -> bool {
65 matches!(
66 self,
67 Self::Due | Self::AwaitingPermission | Self::InProgress
68 )
69 }
70}
71
72#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(tag = "kind", rename_all = "snake_case")]
82pub enum Schedule {
83 Once,
85 Interval { every_seconds: u64 },
87 Daily { time: NaiveTime },
89 Weekly { day: Weekday, time: NaiveTime },
91}
92
93impl Schedule {
94 pub fn next_due(&self, after: DateTime<Utc>) -> Option<DateTime<Utc>> {
97 use chrono::Duration;
98 match self {
99 Schedule::Once => None,
100 Schedule::Interval { every_seconds } => {
101 Some(after + Duration::seconds(*every_seconds as i64))
102 }
103 Schedule::Daily { time } => {
104 let candidate = after.date_naive().and_time(*time).and_utc();
105 Some(if candidate > after {
106 candidate
107 } else {
108 (after.date_naive() + chrono::Days::new(1))
109 .and_time(*time)
110 .and_utc()
111 })
112 }
113 Schedule::Weekly { day, time } => {
114 let mut date = after.date_naive() + chrono::Days::new(1);
116 for _ in 0..7 {
117 if date.weekday() == *day {
118 let candidate = date.and_time(*time).and_utc();
119 return Some(candidate);
120 }
121 date = date + chrono::Days::new(1);
122 }
123 None }
125 }
126 }
127}
128
129#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
135#[serde(tag = "kind", rename_all = "snake_case")]
136pub enum RefKind {
137 Branch,
139 Memory,
141 Plan,
143 Task,
145 StatePath,
147 External { scheme: String },
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
158pub struct ReminderRef {
159 pub kind: RefKind,
160 pub id: String,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
164 pub label: Option<String>,
165 #[serde(default)]
167 pub stale: bool,
168}
169
170impl ReminderRef {
171 pub fn branch(id: impl Into<String>, label: impl Into<String>) -> Self {
172 Self {
173 kind: RefKind::Branch,
174 id: id.into(),
175 label: Some(label.into()),
176 stale: false,
177 }
178 }
179
180 pub fn memory(id: impl Into<String>) -> Self {
181 Self {
182 kind: RefKind::Memory,
183 id: id.into(),
184 label: None,
185 stale: false,
186 }
187 }
188
189 pub fn plan(id: impl Into<String>, label: impl Into<String>) -> Self {
190 Self {
191 kind: RefKind::Plan,
192 id: id.into(),
193 label: Some(label.into()),
194 stale: false,
195 }
196 }
197
198 pub fn task(id: impl Into<String>, label: impl Into<String>) -> Self {
199 Self {
200 kind: RefKind::Task,
201 id: id.into(),
202 label: Some(label.into()),
203 stale: false,
204 }
205 }
206
207 pub fn state_path(path: impl Into<String>) -> Self {
208 Self {
209 kind: RefKind::StatePath,
210 id: path.into(),
211 label: None,
212 stale: false,
213 }
214 }
215
216 pub fn external(url: impl Into<String>, scheme: impl Into<String>) -> Self {
217 Self {
218 kind: RefKind::External {
219 scheme: scheme.into(),
220 },
221 id: url.into(),
222 label: None,
223 stale: false,
224 }
225 }
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
234#[serde(rename_all = "snake_case")]
235pub enum ExecutionResult {
236 Success,
238 Failed,
240 Deferred,
242 Snoozed,
244 Cancelled,
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
250pub struct ExecutionRecord {
251 pub started_at: DateTime<Utc>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
253 pub completed_at: Option<DateTime<Utc>>,
254 pub agent_id: String,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub approved_by: Option<String>,
258 pub result: ExecutionResult,
259 #[serde(default)]
260 pub notes: Vec<String>,
261 #[serde(default, skip_serializing_if = "Option::is_none")]
263 pub task_id: Option<String>,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
272pub struct Reminder {
273 pub id: String,
274 pub title: String,
275 pub instructions: String,
277 #[serde(default)]
279 pub commands: Vec<String>,
280 #[serde(default)]
282 pub refs: Vec<ReminderRef>,
283 pub priority: Priority,
284 pub due_at: DateTime<Utc>,
285 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub schedule: Option<Schedule>,
288 pub autonomous: bool,
291 pub created_by: String,
292 pub created_at: DateTime<Utc>,
293 pub status: ReminderStatus,
294 #[serde(default, skip_serializing_if = "Option::is_none")]
297 pub snoozed_until: Option<DateTime<Utc>>,
298 #[serde(default)]
300 pub executions: Vec<ExecutionRecord>,
301 #[serde(default)]
302 pub tags: Vec<String>,
303}
304
305#[derive(Debug, Clone, Serialize, Deserialize)]
311pub struct CreateReminder {
312 pub title: String,
313 pub instructions: String,
314 #[serde(default)]
315 pub commands: Vec<String>,
316 #[serde(default)]
317 pub refs: Vec<ReminderRef>,
318 #[serde(default)]
319 pub priority: Priority,
320 pub due_at: DateTime<Utc>,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub schedule: Option<Schedule>,
323 #[serde(default = "default_true")]
324 pub autonomous: bool,
325 pub created_by: String,
326 #[serde(default)]
327 pub tags: Vec<String>,
328}
329
330fn default_true() -> bool {
331 true
332}
333
334impl CreateReminder {
335 pub fn new(
336 title: impl Into<String>,
337 instructions: impl Into<String>,
338 due_at: DateTime<Utc>,
339 created_by: impl Into<String>,
340 ) -> Self {
341 Self {
342 title: title.into(),
343 instructions: instructions.into(),
344 commands: Vec::new(),
345 refs: Vec::new(),
346 priority: Priority::default(),
347 due_at,
348 schedule: None,
349 autonomous: true,
350 created_by: created_by.into(),
351 tags: Vec::new(),
352 }
353 }
354
355 pub fn with_priority(mut self, p: Priority) -> Self {
356 self.priority = p;
357 self
358 }
359 pub fn with_schedule(mut self, s: Schedule) -> Self {
360 self.schedule = Some(s);
361 self
362 }
363 pub fn with_autonomous(mut self, a: bool) -> Self {
364 self.autonomous = a;
365 self
366 }
367 pub fn with_commands(mut self, cmds: Vec<String>) -> Self {
368 self.commands = cmds;
369 self
370 }
371 pub fn with_refs(mut self, refs: Vec<ReminderRef>) -> Self {
372 self.refs = refs;
373 self
374 }
375 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
376 self.tags = tags;
377 self
378 }
379
380 pub fn into_reminder(self) -> Reminder {
382 let now = Utc::now();
383 Reminder {
384 id: Uuid::new_v4().to_string(),
385 title: self.title,
386 instructions: self.instructions,
387 commands: self.commands,
388 refs: self.refs,
389 priority: self.priority,
390 due_at: self.due_at,
391 schedule: self.schedule,
392 autonomous: self.autonomous,
393 created_by: self.created_by,
394 created_at: now,
395 status: ReminderStatus::Pending,
396 snoozed_until: None,
397 executions: Vec::new(),
398 tags: self.tags,
399 }
400 }
401}
402
403#[derive(Debug, Clone, Default, Serialize, Deserialize)]
405pub struct ReminderFilter {
406 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub status: Option<ReminderStatus>,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub priority_at_most: Option<Priority>,
410 #[serde(default, skip_serializing_if = "Option::is_none")]
411 pub created_by: Option<String>,
412 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub due_before: Option<DateTime<Utc>>,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub ref_id: Option<String>,
418 #[serde(default, skip_serializing_if = "Vec::is_empty")]
419 pub tags: Vec<String>,
420}
421
422impl ReminderFilter {
423 pub fn matches(&self, r: &Reminder) -> bool {
424 if let Some(s) = self.status {
425 if r.status != s {
426 return false;
427 }
428 }
429 if let Some(p) = self.priority_at_most {
430 if r.priority > p {
431 return false;
432 }
433 }
434 if let Some(ref cb) = self.created_by {
435 if &r.created_by != cb {
436 return false;
437 }
438 }
439 if let Some(due) = self.due_before {
440 if r.due_at > due {
441 return false;
442 }
443 }
444 if let Some(ref rid) = self.ref_id {
445 if !r.refs.iter().any(|rf| &rf.id == rid) {
446 return false;
447 }
448 }
449 for tag in &self.tags {
450 if !r.tags.contains(tag) {
451 return false;
452 }
453 }
454 true
455 }
456}
457
458#[cfg(test)]
463mod tests {
464 use super::*;
465 use chrono::Duration;
466
467 fn due_in(secs: i64) -> DateTime<Utc> {
468 Utc::now() + Duration::seconds(secs)
469 }
470
471 fn past(secs: i64) -> DateTime<Utc> {
472 Utc::now() - Duration::seconds(secs)
473 }
474
475 #[test]
478 fn priority_ordering() {
479 assert!(Priority::Critical < Priority::High);
480 assert!(Priority::High < Priority::Medium);
481 assert!(Priority::Medium < Priority::Low);
482 assert!(Priority::Low < Priority::Minimal);
483 }
484
485 #[test]
486 fn priority_default_is_medium() {
487 assert_eq!(Priority::default(), Priority::Medium);
488 }
489
490 #[test]
491 fn priority_roundtrips_json() {
492 for p in [
493 Priority::Critical,
494 Priority::High,
495 Priority::Medium,
496 Priority::Low,
497 Priority::Minimal,
498 ] {
499 let j = serde_json::to_value(p).unwrap();
500 let back: Priority = serde_json::from_value(j).unwrap();
501 assert_eq!(p, back);
502 }
503 }
504
505 #[test]
508 fn terminal_statuses() {
509 assert!(ReminderStatus::Completed.is_terminal());
510 assert!(ReminderStatus::Cancelled.is_terminal());
511 assert!(!ReminderStatus::Pending.is_terminal());
512 assert!(!ReminderStatus::Due.is_terminal());
513 }
514
515 #[test]
516 fn actionable_statuses() {
517 assert!(ReminderStatus::Due.is_actionable());
518 assert!(ReminderStatus::AwaitingPermission.is_actionable());
519 assert!(ReminderStatus::InProgress.is_actionable());
520 assert!(!ReminderStatus::Pending.is_actionable());
521 assert!(!ReminderStatus::Snoozed.is_actionable());
522 }
523
524 #[test]
527 fn schedule_once_returns_none() {
528 let now = Utc::now();
529 assert!(Schedule::Once.next_due(now).is_none());
530 }
531
532 #[test]
533 fn schedule_interval_advances_by_given_seconds() {
534 let now = Utc::now();
535 let next = Schedule::Interval {
536 every_seconds: 3600,
537 }
538 .next_due(now)
539 .unwrap();
540 let diff = (next - now).num_seconds();
541 assert_eq!(diff, 3600);
542 }
543
544 #[test]
545 fn schedule_daily_advances_to_next_occurrence() {
546 let time = NaiveTime::from_hms_opt(9, 0, 0).unwrap();
547 let now = Utc::now();
548 let next = Schedule::Daily { time }.next_due(now).unwrap();
549 assert!(next > now);
550 assert!(next <= now + Duration::days(2));
551 assert_eq!(next.time(), time);
552 }
553
554 #[test]
555 fn schedule_weekly_lands_on_correct_day() {
556 let time = NaiveTime::from_hms_opt(10, 0, 0).unwrap();
557 let now = Utc::now();
558 let next = Schedule::Weekly {
559 day: Weekday::Mon,
560 time,
561 }
562 .next_due(now)
563 .unwrap();
564 assert!(next > now);
565 assert_eq!(next.weekday(), Weekday::Mon);
566 }
567
568 #[test]
571 fn ref_builders_set_kind_and_stale_false() {
572 let b = ReminderRef::branch("feature/x", "Feature X cleanup");
573 assert_eq!(b.kind, RefKind::Branch);
574 assert!(!b.stale);
575 assert_eq!(b.label.as_deref(), Some("Feature X cleanup"));
576
577 let t = ReminderRef::task("t-042", "Deploy task");
578 assert_eq!(t.kind, RefKind::Task);
579 assert!(!t.stale);
580
581 let sp = ReminderRef::state_path("/_sessions/dev-server");
582 assert_eq!(sp.kind, RefKind::StatePath);
583 assert!(sp.label.is_none());
584 }
585
586 #[test]
587 fn ref_stale_can_be_set() {
588 let mut r = ReminderRef::memory("mem-001");
589 assert!(!r.stale);
590 r.stale = true;
591 assert!(r.stale);
592 }
593
594 #[test]
595 fn ref_roundtrips_json() {
596 let r = ReminderRef::branch("main", "main branch");
597 let j = serde_json::to_value(&r).unwrap();
598 let back: ReminderRef = serde_json::from_value(j).unwrap();
599 assert_eq!(r, back);
600 }
601
602 #[test]
605 fn create_reminder_builder_defaults() {
606 let cr = CreateReminder::new("title", "do the thing", due_in(3600), "agent/test");
607 assert!(cr.autonomous);
608 assert_eq!(cr.priority, Priority::Medium);
609 assert!(cr.schedule.is_none());
610 assert!(cr.refs.is_empty());
611 assert!(cr.tags.is_empty());
612 }
613
614 #[test]
615 fn into_reminder_generates_id_and_pending_status() {
616 let due = due_in(3600);
617 let r = CreateReminder::new("test", "instructions", due, "agent/a").into_reminder();
618 assert!(!r.id.is_empty());
619 assert_eq!(r.status, ReminderStatus::Pending);
620 assert_eq!(r.due_at, due);
621 assert!(r.executions.is_empty());
622 }
623
624 #[test]
625 fn create_reminder_builder_chain() {
626 let due = due_in(100);
627 let r = CreateReminder::new("t", "i", due, "a")
628 .with_priority(Priority::Critical)
629 .with_autonomous(false)
630 .with_tags(vec!["cleanup".into()])
631 .with_schedule(Schedule::Interval {
632 every_seconds: 86400,
633 })
634 .with_refs(vec![ReminderRef::branch("feat/x", "Feature X")])
635 .into_reminder();
636
637 assert_eq!(r.priority, Priority::Critical);
638 assert!(!r.autonomous);
639 assert_eq!(r.tags, vec!["cleanup"]);
640 assert!(matches!(r.schedule, Some(Schedule::Interval { .. })));
641 assert_eq!(r.refs.len(), 1);
642 }
643
644 fn make_reminder(
647 status: ReminderStatus,
648 priority: Priority,
649 due_at: DateTime<Utc>,
650 ) -> Reminder {
651 let mut r = CreateReminder::new("t", "i", due_at, "agent/a")
652 .with_priority(priority)
653 .into_reminder();
654 r.status = status;
655 r
656 }
657
658 #[test]
659 fn filter_by_status() {
660 let r = make_reminder(ReminderStatus::Due, Priority::Medium, past(1));
661 assert!(
662 ReminderFilter {
663 status: Some(ReminderStatus::Due),
664 ..Default::default()
665 }
666 .matches(&r)
667 );
668 assert!(
669 !ReminderFilter {
670 status: Some(ReminderStatus::Pending),
671 ..Default::default()
672 }
673 .matches(&r)
674 );
675 }
676
677 #[test]
678 fn filter_by_priority_at_most() {
679 let r = make_reminder(ReminderStatus::Due, Priority::High, past(1));
680 assert!(
682 ReminderFilter {
683 priority_at_most: Some(Priority::Medium),
684 ..Default::default()
685 }
686 .matches(&r)
687 );
688 assert!(
690 !ReminderFilter {
691 priority_at_most: Some(Priority::Critical),
692 ..Default::default()
693 }
694 .matches(&r)
695 );
696 }
697
698 #[test]
699 fn filter_by_due_before() {
700 let now = Utc::now();
701 let r = make_reminder(
702 ReminderStatus::Pending,
703 Priority::Medium,
704 now + Duration::hours(2),
705 );
706 assert!(
707 !ReminderFilter {
708 due_before: Some(now + Duration::hours(1)),
709 ..Default::default()
710 }
711 .matches(&r)
712 );
713 assert!(
714 ReminderFilter {
715 due_before: Some(now + Duration::hours(3)),
716 ..Default::default()
717 }
718 .matches(&r)
719 );
720 }
721
722 #[test]
723 fn filter_by_ref_id() {
724 let mut r = make_reminder(ReminderStatus::Pending, Priority::Medium, due_in(100));
725 r.refs
726 .push(ReminderRef::branch("feature/web-demo", "web demo"));
727 assert!(
728 ReminderFilter {
729 ref_id: Some("feature/web-demo".into()),
730 ..Default::default()
731 }
732 .matches(&r)
733 );
734 assert!(
735 !ReminderFilter {
736 ref_id: Some("other-branch".into()),
737 ..Default::default()
738 }
739 .matches(&r)
740 );
741 }
742
743 #[test]
744 fn filter_by_tags_all_must_match() {
745 let mut r = make_reminder(ReminderStatus::Pending, Priority::Medium, due_in(100));
746 r.tags = vec!["cleanup".into(), "infra".into()];
747 assert!(
748 ReminderFilter {
749 tags: vec!["cleanup".into()],
750 ..Default::default()
751 }
752 .matches(&r)
753 );
754 assert!(
755 ReminderFilter {
756 tags: vec!["cleanup".into(), "infra".into()],
757 ..Default::default()
758 }
759 .matches(&r)
760 );
761 assert!(
762 !ReminderFilter {
763 tags: vec!["cleanup".into(), "missing".into()],
764 ..Default::default()
765 }
766 .matches(&r)
767 );
768 }
769
770 #[test]
771 fn empty_filter_matches_everything() {
772 let r = make_reminder(ReminderStatus::Due, Priority::Critical, past(1));
773 assert!(ReminderFilter::default().matches(&r));
774 }
775}