Skip to main content

jyn_core/
recurrence.rs

1// Copyright (c) 2026 Joydev GmbH (joydev.com)
2// SPDX-License-Identifier: MIT
3
4//! Recurrence rules (RFC 5545 RRULE) for jyn tasks.
5//!
6//! A recurring task stores its rule as an RRULE *body* string (the part
7//! after `RRULE:`, e.g. `FREQ=WEEKLY;BYDAY=MO`) in `Task.recurrence`.
8//! This module validates such strings and computes the next occurrence,
9//! delegating the calendar arithmetic to the `rrule` crate.
10//!
11//! Dates are anchored at UTC midnight: jyn tracks tasks by calendar date
12//! (`NaiveDate`), so the time-of-day and zone are irrelevant here and a
13//! fixed anchor keeps occurrences stable regardless of the user's clock.
14
15use chrono::{DateTime, NaiveDate, NaiveTime, TimeZone};
16use rrule::{RRule, Tz, Unvalidated};
17
18#[derive(Debug, thiserror::Error)]
19#[error("invalid recurrence rule '{rule}': {message}")]
20pub struct RecurrenceError {
21    rule: String,
22    message: String,
23}
24
25/// Validate an RRULE body string. Returns `Ok(())` when the rule parses
26/// and builds, otherwise a [`RecurrenceError`] describing the problem.
27pub fn validate(rule: &str) -> Result<(), RecurrenceError> {
28    // A reference anchor is only needed to surface build-time validation
29    // errors; the specific date does not affect whether a rule is valid.
30    let reference =
31        NaiveDate::from_ymd_opt(2000, 1, 1).ok_or_else(|| err(rule, "internal reference date"))?;
32    build_set(rule, reference).map(|_| ())
33}
34
35/// Compute the next occurrence strictly after `after`, for a rule whose
36/// series is anchored at `anchor` (typically the task's current due
37/// date). Returns `Ok(None)` when the series has no further occurrence
38/// (e.g. an exhausted `COUNT`/`UNTIL`).
39pub fn next_occurrence(
40    rule: &str,
41    anchor: NaiveDate,
42    after: NaiveDate,
43) -> Result<Option<NaiveDate>, RecurrenceError> {
44    let set = build_set(rule, anchor)?;
45    // `RRuleSet::after` is inclusive of an exact match, so ask for two and
46    // take the first occurrence whose date is strictly greater than
47    // `after`. Anchor occurrences are at a fixed midnight, so there is at
48    // most one per day and two results always cover the equal-plus-next
49    // case (and the exhausted case yields none).
50    let result = set.after(at_utc_midnight(after)).all(2);
51    Ok(result
52        .dates
53        .iter()
54        .map(DateTime::date_naive)
55        .find(|d| *d > after))
56}
57
58/// Parse an RRULE body and build its set anchored at `anchor`.
59fn build_set(rule: &str, anchor: NaiveDate) -> Result<rrule::RRuleSet, RecurrenceError> {
60    let parsed: RRule<Unvalidated> = rule.parse().map_err(|e| err(rule, e))?;
61    parsed
62        .build(at_utc_midnight(anchor))
63        .map_err(|e| err(rule, e))
64}
65
66/// Anchor a calendar date at UTC midnight as an `rrule` datetime.
67fn at_utc_midnight(date: NaiveDate) -> DateTime<Tz> {
68    Tz::UTC.from_utc_datetime(&date.and_time(NaiveTime::MIN))
69}
70
71fn err(rule: &str, e: impl std::fmt::Display) -> RecurrenceError {
72    RecurrenceError {
73        rule: rule.to_string(),
74        message: e.to_string(),
75    }
76}
77
78#[cfg(test)]
79mod tests {
80    use super::*;
81
82    fn d(y: i32, m: u32, day: u32) -> NaiveDate {
83        NaiveDate::from_ymd_opt(y, m, day).unwrap()
84    }
85
86    #[test]
87    fn validates_good_and_bad_rules() {
88        assert!(validate("FREQ=WEEKLY").is_ok());
89        assert!(validate("FREQ=MONTHLY;BYMONTHDAY=1").is_ok());
90        assert!(validate("FREQ=WEEKLY;BYDAY=MO,WE,FR").is_ok());
91        assert!(validate("FREQ=NONSENSE").is_err());
92        assert!(validate("not a rule").is_err());
93        assert!(validate("").is_err());
94    }
95
96    #[test]
97    fn next_occurrence_basic_frequencies() {
98        let anchor = d(2026, 4, 14); // Tuesday
99        assert_eq!(
100            next_occurrence("FREQ=DAILY", anchor, anchor).unwrap(),
101            Some(d(2026, 4, 15))
102        );
103        assert_eq!(
104            next_occurrence("FREQ=WEEKLY", anchor, anchor).unwrap(),
105            Some(d(2026, 4, 21))
106        );
107        assert_eq!(
108            next_occurrence("FREQ=MONTHLY", anchor, anchor).unwrap(),
109            Some(d(2026, 5, 14))
110        );
111        assert_eq!(
112            next_occurrence("FREQ=YEARLY", anchor, anchor).unwrap(),
113            Some(d(2027, 4, 14))
114        );
115    }
116
117    #[test]
118    fn next_occurrence_with_interval() {
119        let anchor = d(2026, 4, 14);
120        assert_eq!(
121            next_occurrence("FREQ=DAILY;INTERVAL=3", anchor, anchor).unwrap(),
122            Some(d(2026, 4, 17))
123        );
124        assert_eq!(
125            next_occurrence("FREQ=WEEKLY;INTERVAL=2", anchor, anchor).unwrap(),
126            Some(d(2026, 4, 28))
127        );
128    }
129
130    #[test]
131    fn next_occurrence_exhausted_series_is_none() {
132        let anchor = d(2026, 4, 14);
133        // Only one occurrence (the anchor); nothing strictly after it.
134        assert_eq!(
135            next_occurrence("FREQ=DAILY;COUNT=1", anchor, anchor).unwrap(),
136            None
137        );
138    }
139}