Skip to main content

xtask_todo_lib/
repeat.rs

1//! Recurrence rule for repeating tasks.
2
3use std::fmt;
4use std::str::FromStr;
5
6use chrono::Datelike;
7use chrono::NaiveDate;
8
9/// Recurrence rule for repeating tasks.
10#[derive(Clone, Debug, Eq, PartialEq)]
11pub enum RepeatRule {
12    Daily,
13    Weekly,
14    Monthly,
15    Yearly,
16    /// Weekdays (Mon–Fri).
17    Weekdays,
18    /// Every n days.
19    Custom(u32),
20}
21
22impl RepeatRule {
23    /// Returns the next due date (YYYY-MM-DD) from a given date string, or None if base is invalid.
24    #[must_use]
25    pub fn next_due_date(&self, from: &str) -> Option<String> {
26        let base = NaiveDate::parse_from_str(from.trim(), "%Y-%m-%d").ok()?;
27        let next = match self {
28            Self::Daily => base.succ_opt()?,
29            Self::Weekly => base + chrono::Duration::days(7),
30            Self::Monthly => {
31                let (y, m, d) = (base.year(), base.month(), base.day());
32                let (next_y, next_m) = if m == 12 { (y + 1, 1u32) } else { (y, m + 1) };
33                NaiveDate::from_ymd_opt(next_y, next_m, std::cmp::min(d, 28))?
34            }
35            Self::Yearly => NaiveDate::from_ymd_opt(base.year() + 1, base.month(), base.day())?,
36            Self::Weekdays => {
37                let mut d = base.succ_opt()?;
38                for _ in 0..7 {
39                    if d.weekday() != chrono::Weekday::Sat && d.weekday() != chrono::Weekday::Sun {
40                        break;
41                    }
42                    d = d.succ_opt()?;
43                }
44                d
45            }
46            Self::Custom(n) => base + chrono::Duration::days(i64::from(*n)),
47        };
48        Some(next.to_string())
49    }
50}
51
52impl fmt::Display for RepeatRule {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        match self {
55            Self::Daily => f.write_str("daily"),
56            Self::Weekly => f.write_str("weekly"),
57            Self::Monthly => f.write_str("monthly"),
58            Self::Yearly => f.write_str("yearly"),
59            Self::Weekdays => f.write_str("weekdays"),
60            Self::Custom(n) => write!(f, "custom:{n}"),
61        }
62    }
63}
64
65impl FromStr for RepeatRule {
66    type Err = ();
67
68    fn from_str(s: &str) -> Result<Self, Self::Err> {
69        let s = s.trim().to_lowercase();
70        if s == "daily" {
71            Ok(Self::Daily)
72        } else if s == "weekly" {
73            Ok(Self::Weekly)
74        } else if s == "monthly" {
75            Ok(Self::Monthly)
76        } else if s == "yearly" {
77            Ok(Self::Yearly)
78        } else if s == "weekdays" {
79            Ok(Self::Weekdays)
80        } else if let Some(n) = s.strip_prefix("custom:") {
81            n.parse::<u32>().map(Self::Custom).map_err(|_| ())
82        } else if let Some(d) = s.strip_suffix('d') {
83            // e.g. 2d = every 2 days
84            d.parse::<u32>().map(Self::Custom).map_err(|_| ())
85        } else if let Some(w) = s.strip_suffix('w') {
86            // e.g. 3w = every 3 weeks = 21 days
87            w.parse::<u32>()
88                .ok()
89                .and_then(|n| n.checked_mul(7))
90                .map(Self::Custom)
91                .ok_or(())
92        } else {
93            Err(())
94        }
95    }
96}
97
98#[cfg(test)]
99mod tests {
100    use super::RepeatRule;
101
102    #[test]
103    fn from_str_2d_and_3w() {
104        assert_eq!("2d".parse::<RepeatRule>().unwrap(), RepeatRule::Custom(2));
105        assert_eq!("3w".parse::<RepeatRule>().unwrap(), RepeatRule::Custom(21));
106    }
107
108    #[test]
109    fn from_str_weekdays() {
110        assert_eq!(
111            "weekdays".parse::<RepeatRule>().unwrap(),
112            RepeatRule::Weekdays
113        );
114    }
115
116    #[test]
117    fn from_str_invalid_returns_err() {
118        assert!("invalid".parse::<RepeatRule>().is_err());
119        assert!("".parse::<RepeatRule>().is_err());
120        assert!("custom:".parse::<RepeatRule>().is_err());
121    }
122}