> {
+ $body
+ }
+ };
+}
+
+// it!(should_do_the_trick, {
+// assert_eq!(2, 1);
+
+// Ok(())
+// });
diff --git a/lib/sched/README.md b/lib/sched/README.md
new file mode 100644
index 0000000..37464ee
--- /dev/null
+++ b/lib/sched/README.md
@@ -0,0 +1,42 @@
+
+
+
+
+
+
+
+lool » sched is a utility library that provides a way to schedule tasks in various ways. Supports std::thread and the tokio runtime (as a feature flag).
+
+
+
+
+
+
+
+# Installation
+
+This library is for internal use. And as such, it's only published privately.
+
+```bash
+cargo add lool --registry=lugit --features sched
+```
+
+# Additional Features
+
+- `sched-tokio`: Enables the `tokio` runtime support.
+- `sched-rule-recurrent`: Enables the "recurrent-rule" style for scheduling tasks.
+- `sched-rule-cron`: Enables the "cron-like" style for scheduling tasks
+
+> [!WARNING] Not implemented warning
+> although the `sched-rule-cron` feature is available, it's not yet implemented.
+
+## Planned Features
+
+- `sched-rule-cron`: Enables the "cron-like" style for scheduling tasks
+- `sched-rule-pysched`: Enables the "pysched-like" style for scheduling tasks
+
+# Usage
+
+
diff --git a/lib/sched/mod.rs b/lib/sched/mod.rs
new file mode 100644
index 0000000..3f3332e
--- /dev/null
+++ b/lib/sched/mod.rs
@@ -0,0 +1,4 @@
+mod rules;
+pub mod utils;
+
+pub use rules::*;
diff --git a/lib/sched/rules.rs b/lib/sched/rules.rs
new file mode 100644
index 0000000..ef4fa9f
--- /dev/null
+++ b/lib/sched/rules.rs
@@ -0,0 +1,51 @@
+#[cfg(feature = "sched-rule-cron")]
+mod cron;
+#[cfg(feature = "sched-rule-recurrent")]
+mod recurrent;
+#[cfg(feature = "sched-rule-recurrent")]
+pub use self::recurrent::{many, val, range, ranges, ruleset, RecurrenceRuleSet, Rule};
+
+use chrono::{DateTime, Local};
+
+/// 🧉 » a scheduling rule
+///
+/// can be:
+/// - `Once`: runs only at a specific `chrono::DateTime`
+/// - `Repeat`: runs at specific intervals defined by a `RecurrenceRule`
+/// - `Cron`: runs at specific intervals defined by a cron expression
+pub enum SchedulingRule {
+ /// 🧉 » a scheduling rule that makes the task run only once at a specific `chrono::DateTime`
+ Once(chrono::DateTime),
+
+ /// 🧉 » a scheduling rule expressed with a `RecurrenceRule` structure
+ #[cfg(feature = "sched-rule-recurrent")]
+ Repeat(RecurrenceRuleSet),
+
+ /// 🧉 » a scheduling rule expressed in cron format
+ #[cfg(feature = "sched-rule-cron")]
+ Cron(String),
+}
+
+impl SchedulingRule {
+ /// 🧉 » get the next execution time from now
+ pub fn next(&self) -> Option> {
+ self.next_from(Local::now())
+ }
+
+ /// 🧉 » get the next execution time from now
+ pub fn next_from(&self, _date: DateTime) -> Option> {
+ match self {
+ SchedulingRule::Once(_dt) => {
+ unimplemented!()
+ }
+ #[cfg(feature = "sched-rule-recurrent")]
+ SchedulingRule::Repeat(_rule) => {
+ unimplemented!()
+ }
+ #[cfg(feature = "sched-rule-cron")]
+ SchedulingRule::Cron(_cron) => {
+ unimplemented!()
+ }
+ }
+ }
+}
diff --git a/lib/sched/rules/cron.rs b/lib/sched/rules/cron.rs
new file mode 100644
index 0000000..c641e17
--- /dev/null
+++ b/lib/sched/rules/cron.rs
@@ -0,0 +1,2 @@
+
+// TODO: cron based scheduling
\ No newline at end of file
diff --git a/lib/sched/rules/recurrent/mod.rs b/lib/sched/rules/recurrent/mod.rs
new file mode 100644
index 0000000..8b45ed9
--- /dev/null
+++ b/lib/sched/rules/recurrent/mod.rs
@@ -0,0 +1,8 @@
+#[cfg(test)]
+mod tests;
+
+mod ruleset;
+mod rule_unit;
+
+pub use ruleset::{RecurrenceRuleSet, builder::ruleset};
+pub use rule_unit::{Rule, val, range, many, ranges};
\ No newline at end of file
diff --git a/lib/sched/rules/recurrent/rule_unit.rs b/lib/sched/rules/recurrent/rule_unit.rs
new file mode 100644
index 0000000..9d97354
--- /dev/null
+++ b/lib/sched/rules/recurrent/rule_unit.rs
@@ -0,0 +1,85 @@
+use num_traits::PrimInt;
+
+/// 🧉 » a recurrence rule unit
+///
+/// represents a single rule unit that can be used to match a value
+pub enum Rule
+where
+ T: PrimInt,
+{
+ /// a single value
+ Val(T),
+ /// range from `start` to `end` with `step` increment
+ Range(T, T, T),
+ /// a list of single values
+ Many(Vec),
+ /// a list of ranges
+ Ranges(Vec<(T, T, T)>),
+}
+
+impl Rule
+where
+ T: PrimInt,
+{
+ /// 🧉 » check if the value matches this `Rule` Unit
+ pub fn matches(&self, value: T) -> bool {
+ Self::_matches(self, value)
+ }
+
+ /// 🚧 internal
+ fn _matches(matcher: &Rule, value: T) -> bool {
+ match matcher {
+ Rule::Val(v) => value == *v,
+ Rule::Range(start, end, step) => {
+ if *step == T::one() {
+ value >= *start && value <= *end
+ } else {
+ let mut current = *start;
+ while current <= *end {
+ if current == value {
+ return true;
+ }
+ // Move to the next value based on the step size
+ current = current + *step;
+ }
+ false
+ }
+ }
+ Rule::Many(matcher) => matcher.iter().any(|v| Self::_matches(&Rule::Val(*v), value)),
+ Rule::Ranges(matcher) => matcher
+ .iter()
+ .any(|(start, end, step)| Self::_matches(&Rule::Range(*start, *end, *step), value)),
+ }
+ }
+
+ pub(crate) fn value_is_between(&self, min: T, max: T) -> bool {
+ match self {
+ Rule::Val(v) => *v >= min && *v <= max,
+ Rule::Range(start, end, _) => *start >= min && *end <= max,
+ Rule::Many(values) => values.iter().all(|v| *v >= min && *v <= max),
+ Rule::Ranges(ranges) => {
+ ranges.iter().all(|(start, end, _)| *start >= min && *end <= max)
+ }
+ }
+ }
+}
+
+/// 🧉 » create a `Rule` that will match a single value
+pub fn val(value: T) -> Rule {
+ Rule::Val(value)
+}
+
+/// 🧉 » create a `Rule` that will match a range of values
+pub fn range(start: T, end: T, step: T) -> Rule {
+ Rule::Range(start, end, step)
+}
+
+/// 🧉 » create a `Rule` that will match many values
+pub fn many(values: Vec) -> Rule {
+ Rule::Many(values)
+}
+
+/// 🧉 » create a `Rule` that will match a list of rages
+pub fn ranges(ranges: Vec<(T, T, T)>) -> Rule {
+ Rule::Ranges(ranges)
+}
diff --git a/lib/sched/rules/recurrent/ruleset.rs b/lib/sched/rules/recurrent/ruleset.rs
new file mode 100644
index 0000000..b5cd730
--- /dev/null
+++ b/lib/sched/rules/recurrent/ruleset.rs
@@ -0,0 +1,128 @@
+pub mod builder;
+
+use {
+ super::Rule,
+ crate::sched::utils::cron_date::LoolDate,
+ chrono::{DateTime, Datelike, Local},
+};
+
+/// 🧉 » a recurrence rule-set
+///
+/// sets rules that define a certain recurrence behavior
+///
+/// use the builder pattern to create a new `RecurrenceRuleSet`
+pub struct RecurrenceRuleSet {
+ /// second of the minute (0..59)
+ second: Option>,
+ /// minute of the hour (0..59)
+ minute: Option>,
+ /// hour of the day (0..23)
+ hour: Option>,
+ /// day of the week starting from sunday (`0=Sunday`, `1=Monday`, ..., `6=Saturday`)
+ dow: Option>,
+ /// day of the month (1..31)
+ day: Option>,
+ /// month of the year (1..12)
+ month: Option>,
+ /// year
+ year: Option>,
+}
+
+impl RecurrenceRuleSet {
+ /// 🧉 » returns the next match of the rule set from `now`
+ pub fn next_match(&self) -> Option> {
+ self.next_match_from(Local::now())
+ }
+
+ /// 🧉 » returns the next match of the rule set from a given `DateTime`
+ pub fn next_match_from(&self, from: DateTime) -> Option> {
+ let next = self._next_match(from);
+ match next {
+ Some(date) => Some(date.date()),
+ None => None,
+ }
+ }
+
+ /// 🚧 internal
+ fn _next_match(&self, from: DateTime) -> Option> {
+ if !self.is_valid() {
+ return None;
+ }
+
+ // check year
+ if let Some(Rule::Val(year)) = self.year {
+ if year < from.year().into() {
+ return None;
+ }
+ }
+
+ let mut next = LoolDate::new(from.clone());
+ next.add_second();
+
+ loop {
+ // check other possible year values
+ if let Some(year_unit) = &self.year {
+ if let Rule::Val(year) = year_unit {
+ if *year < next.year() {
+ return None;
+ }
+ }
+
+ if !year_unit.matches(next.year()) {
+ next.add_year();
+ next.set_md(1, 1);
+ next.set_hms(0, 0, 0);
+ continue;
+ }
+ }
+
+ if let Some(month_unit) = &self.month {
+ if !month_unit.matches(next.month()) {
+ next.add_month();
+ continue;
+ }
+ }
+
+ if let Some(day_unit) = &self.day {
+ if !day_unit.matches(next.day()) {
+ next.add_day();
+ continue;
+ }
+ }
+
+ if let Some(dow_unit) = &self.dow {
+ if !dow_unit.matches(next.weekday_from_sunday()) {
+ next.add_day();
+ continue;
+ }
+ }
+
+ if let Some(hour_unit) = &self.hour {
+ if !hour_unit.matches(next.hour()) {
+ next.add_hour();
+ continue;
+ }
+ }
+
+ if let Some(minute_unit) = &self.minute {
+ if !minute_unit.matches(next.minute()) {
+ next.add_minute();
+ continue;
+ }
+ }
+
+ if let Some(second_unit) = &self.second {
+ if !second_unit.matches(next.second()) {
+ next.add_second();
+ continue;
+ }
+ }
+
+ // finally, everything matches, so we get out of the loop
+ break;
+ }
+
+ Some(next)
+ }
+}
+
diff --git a/lib/sched/rules/recurrent/ruleset/builder.rs b/lib/sched/rules/recurrent/ruleset/builder.rs
new file mode 100644
index 0000000..a2670b1
--- /dev/null
+++ b/lib/sched/rules/recurrent/ruleset/builder.rs
@@ -0,0 +1,198 @@
+use chrono::Weekday;
+
+use super::RecurrenceRuleSet;
+use crate::sched::rules::Rule;
+
+pub fn ruleset() -> RecurrenceRuleSet {
+ let ruleset = RecurrenceRuleSet::recurring();
+ ruleset
+}
+
+impl RecurrenceRuleSet {
+ /// 🧉 » create a new `RecurrenceRuleSet
+ pub fn recurring() -> Self {
+ Self {
+ second: None,
+ minute: None,
+ hour: None,
+ dow: None,
+ day: None,
+ month: None,
+ year: None,
+ }
+ }
+
+ /// 🧉 » set the second rule
+ pub fn seconds_rule(&mut self, rule: Rule) -> &mut Self {
+ self.second = Some(rule);
+ self
+ }
+
+ /// 🧉 » set the minute rule
+ pub fn minutes_rule(&mut self, rule: Rule) -> &mut Self {
+ self.minute = Some(rule);
+ self
+ }
+
+ /// 🧉 » set the hour rule
+ pub fn hours_rule(&mut self, rule: Rule) -> &mut Self {
+ self.hour = Some(rule);
+ self
+ }
+
+ /// 🧉 » set the full time rule
+ pub fn time_rule(
+ &mut self,
+ hour: Rule,
+ minute: Rule,
+ second: Rule,
+ ) -> &mut Self {
+ self.hours_rule(hour).minutes_rule(minute).seconds_rule(second)
+ }
+
+ /// 🧉 » set the day of the week rule
+ pub fn dow_rule(&mut self, rule: Rule) -> &mut Self {
+ self.dow = Some(rule);
+ self
+ }
+
+ /// 🧉 » set the day of the month rule
+ pub fn day_ryle(&mut self, rule: Rule) -> &mut Self {
+ self.day = Some(rule);
+ self
+ }
+
+ /// 🧉 » set the month rule
+ pub fn month_rule(&mut self, rule: Rule) -> &mut Self {
+ self.month = Some(rule);
+ self
+ }
+
+ /// 🧉 » set the year rule
+ pub fn year_rule(&mut self, rule: Rule) -> &mut Self {
+ self.year = Some(rule);
+ self
+ }
+
+ /// 🧉 » set the second rule as a single value from primitive
+ pub fn at_second(&mut self, value: u32) -> &mut Self {
+ self.seconds_rule(Rule::Val(value))
+ }
+
+ /// 🧉 » set the minute rule as a single value from primitive
+ pub fn at_minute(&mut self, value: u32) -> &mut Self {
+ self.minutes_rule(Rule::Val(value))
+ }
+
+ /// 🧉 » set the hour rule as a single value from primitive
+ pub fn at_hour(&mut self, value: u32) -> &mut Self {
+ self.hours_rule(Rule::Val(value))
+ }
+
+ /// 🧉 » set the full time rule as a single value from primitives
+ pub fn at_time(&mut self, hour: u32, minute: u32, second: u32) -> &mut Self {
+ self.time_rule(Rule::Val(hour), Rule::Val(minute), Rule::Val(second))
+ }
+
+ /// 🧉 » set the day of the week rule as a single value from primitive
+ pub fn on_dow(&mut self, value: Weekday) -> &mut Self {
+ self.dow_rule(Rule::Val(value.num_days_from_sunday()))
+ }
+
+ /// 🧉 » set the day of the month rule as a single value from primitive
+ pub fn on_day(&mut self, value: u32) -> &mut Self {
+ self.day_ryle(Rule::Val(value))
+ }
+
+ /// 🧉 » set the month rule as a single value from primitive
+ pub fn in_month(&mut self, value: u32) -> &mut Self {
+ self.month_rule(Rule::Val(value))
+ }
+
+ /// 🧉 » set the year rule as a single value from primitive
+ pub fn in_year(&mut self, value: i32) -> &mut Self {
+ self.year_rule(Rule::Val(value))
+ }
+
+ /// 🧉 » set the full date as single values from primitives
+ pub fn on_date(&mut self, year: i32, month: u32, day: u32) -> &mut Self {
+ self.in_year(year).in_month(month).on_day(day)
+ }
+
+ /// 🧉 » set the full datetime as single values from primitives
+ pub fn on_datetime(
+ &mut self,
+ year: i32,
+ month: u32,
+ day: u32,
+ hour: u32,
+ minute: u32,
+ second: u32,
+ ) -> &mut Self {
+ self.on_date(year, month, day).at_time(hour, minute, second)
+ }
+
+ /// 🧉 » check if the rule set is valid
+ pub fn is_valid(&self) -> bool {
+ // at least one of the rules must be set
+ let mut valid = self.second.is_some()
+ || self.minute.is_some()
+ || self.hour.is_some()
+ || self.dow.is_some()
+ || self.day.is_some()
+ || self.month.is_some()
+ || self.year.is_some();
+
+ // month/s should be between 1 and 12
+ if let Some(month) = &self.month {
+ valid = valid && month.value_is_between(1, 12);
+ }
+
+ // day/s of week should be between 0 and 6
+ if let Some(dow) = &self.dow {
+ valid = valid && dow.value_is_between(0, 6);
+ }
+
+ // day/s of month should be between 1 and 31
+ if let Some(day) = &self.day {
+ // check month overflows if month is also set
+ // HACK: I'm already handling day overflows and even leap years at `CronDate`
+ // this might not be necessary anymore... we should test it and see how it goes
+ // without this check (we might want to check for 1..31 as a minimum and that's
+ // all)
+ match &self.month {
+ Some(month) => {
+ if month.matches(2) {
+ valid = valid && day.value_is_between(1, 29);
+ } else if month.matches(4)
+ || month.matches(6)
+ || month.matches(9)
+ || month.matches(11)
+ {
+ valid = valid && day.value_is_between(1, 30);
+ } else {
+ valid = valid && day.value_is_between(1, 31);
+ }
+ }
+ None => valid = valid && day.value_is_between(1, 31),
+ }
+ }
+
+ // hour/s should be between 0 and 23
+ if let Some(hour) = &self.hour {
+ valid = valid && hour.value_is_between(0, 23);
+ }
+
+ // minute/s should be between 0 and 59
+ if let Some(minute) = &self.minute {
+ valid = valid && minute.value_is_between(0, 59);
+ }
+
+ // second/s should be between 0 and 59
+ if let Some(second) = &self.second {
+ valid = valid && second.value_is_between(0, 59);
+ }
+
+ valid
+ }
+}
diff --git a/lib/sched/rules/recurrent/tests/mod.rs b/lib/sched/rules/recurrent/tests/mod.rs
new file mode 100644
index 0000000..be927a1
--- /dev/null
+++ b/lib/sched/rules/recurrent/tests/mod.rs
@@ -0,0 +1,3 @@
+mod recurrence_rules_by_val;
+mod recurrence_rules_by_range;
+mod recurrence_rules_by_many;
\ No newline at end of file
diff --git a/lib/sched/rules/recurrent/tests/recurrence_rules_by_many.rs b/lib/sched/rules/recurrent/tests/recurrence_rules_by_many.rs
new file mode 100644
index 0000000..957aa19
--- /dev/null
+++ b/lib/sched/rules/recurrent/tests/recurrence_rules_by_many.rs
@@ -0,0 +1,52 @@
+use chrono::{Datelike, Local, TimeZone, Utc};
+
+use crate::sched::rules::{many, range, ruleset};
+
+#[test]
+fn every_day_at_12_and_15() {
+ let date = Local.with_ymd_and_hms(2024, 4, 7, 13, 15, 05).unwrap();
+
+ let mut rules = ruleset();
+ rules.hours_rule(many(vec![12, 15])).at_minute(0).at_second(0);
+
+ let next = rules.next_match_from(date).unwrap();
+ assert_eq!(next, Local.with_ymd_and_hms(2024, 4, 7, 15, 0, 0).unwrap());
+
+ let next = rules.next_match_from(next).unwrap();
+ assert_eq!(next, Local.with_ymd_and_hms(2024, 4, 8, 12, 0, 0).unwrap());
+
+ let next = rules.next_match_from(next).unwrap();
+ assert_eq!(next, Local.with_ymd_and_hms(2024, 4, 8, 15, 0, 0).unwrap());
+
+ let next = rules.next_match_from(next).unwrap();
+ assert_eq!(next, Local.with_ymd_and_hms(2024, 4, 9, 12, 0, 0).unwrap());
+
+ let next = rules.next_match_from(next).unwrap();
+ assert_eq!(next, Local.with_ymd_and_hms(2024, 4, 9, 15, 0, 0).unwrap());
+}
+
+#[test]
+fn each_day_between_9_and_17_at_hour_start() {
+ // will start next day because it's already > 17:00:00
+ let date = Local.with_ymd_and_hms(2024, 4, 25, 19, 15, 05).unwrap();
+
+ let mut rules = ruleset();
+ rules.hours_rule(range(9, 17, 1)).at_minute(0).at_second(0);
+
+ let mut next = date.clone();
+ let initial_day = date.day() + 1;
+
+ for i in 0..18 {
+ let day = if i < 9 { initial_day } else { initial_day + 1 };
+ let addend = if i < 9 { i } else { i - 9 };
+
+ next = rules.next_match_from(next).unwrap();
+
+ println!("[{i}]: {:?}", next);
+
+ assert_eq!(
+ next,
+ Local.with_ymd_and_hms(2024, 4, day, 9 + addend, 0, 0).unwrap()
+ );
+ }
+}
diff --git a/lib/sched/rules/recurrent/tests/recurrence_rules_by_range.rs b/lib/sched/rules/recurrent/tests/recurrence_rules_by_range.rs
new file mode 100644
index 0000000..014c67e
--- /dev/null
+++ b/lib/sched/rules/recurrent/tests/recurrence_rules_by_range.rs
@@ -0,0 +1,57 @@
+use chrono::{Datelike, Local, TimeZone, Timelike};
+
+use crate::sched::rules::{range, ruleset};
+
+#[test]
+fn between_10_and_20_seconds() {
+ let date = Local.with_ymd_and_hms(2024, 4, 7, 16, 15, 05).unwrap();
+
+ let mut rules = ruleset();
+ rules.seconds_rule(range(10, 20, 1));
+
+ let mut next = date.clone();
+ let initial_minute = date.minute();
+
+ for i in 0..20 {
+ let addend = if i < 11 { i } else { i - 10 - 1 };
+ let minute = if i < 11 {
+ initial_minute
+ } else {
+ initial_minute + 1
+ };
+
+ next = rules.next_match_from(next).unwrap();
+ println!("[{i}]: {:?}", next);
+
+ assert_eq!(
+ next,
+ Local.with_ymd_and_hms(2024, 4, 7, 16, minute, 10 + addend).unwrap()
+ );
+ }
+}
+
+#[test]
+fn each_day_between_9_and_17_at_hour_start() {
+ // will start next day because it's already > 17:00:00
+ let date = Local.with_ymd_and_hms(2024, 4, 25, 19, 15, 05).unwrap();
+
+ let mut rules = ruleset();
+ rules.hours_rule(range(9, 17, 1)).at_minute(0).at_second(0);
+
+ let mut next = date.clone();
+ let initial_day = date.day() + 1;
+
+ for i in 0..18 {
+ let day = if i < 9 { initial_day } else { initial_day + 1 };
+ let addend = if i < 9 { i } else { i - 9 };
+
+ next = rules.next_match_from(next).unwrap();
+
+ println!("[{i}]: {:?}", next);
+
+ assert_eq!(
+ next,
+ Local.with_ymd_and_hms(2024, 4, day, 9 + addend, 0, 0).unwrap()
+ );
+ }
+}
diff --git a/lib/sched/rules/recurrent/tests/recurrence_rules_by_val.rs b/lib/sched/rules/recurrent/tests/recurrence_rules_by_val.rs
new file mode 100644
index 0000000..58ee585
--- /dev/null
+++ b/lib/sched/rules/recurrent/tests/recurrence_rules_by_val.rs
@@ -0,0 +1,179 @@
+use chrono::{Datelike, Local, TimeZone, Timelike, Weekday};
+
+use crate::sched::rules::ruleset;
+
+#[test]
+fn at_second_1_of_each_minute() {
+ // we have passed the second 1 of the minute
+ // so it should go to the next minute
+ let date = Local.with_ymd_and_hms(2024, 4, 7, 16, 15, 05).unwrap();
+
+ let mut rules = ruleset();
+ rules.at_second(1);
+
+ let mut next = date.clone();
+ let initial_minute = date.minute();
+
+ for i in 0..10 {
+ next = rules.next_match_from(next).unwrap();
+ println!("next: {:?}", next);
+ // should match 16:16:01, 16:17:01, 16:18:01, ...
+ assert_eq!(
+ next,
+ Local.with_ymd_and_hms(2024, 4, 7, 16, initial_minute + i + 1, 01).unwrap()
+ );
+ }
+}
+
+#[test]
+fn at_hour_1_of_each_day() {
+ // we have passed the hour 1 of the day
+ // so it should go to the next day
+ let date = Local.with_ymd_and_hms(2024, 4, 25, 16, 15, 05).unwrap();
+
+ let mut rules = ruleset();
+ rules.at_time(1, 0, 0);
+
+ let mut next = date.clone();
+ let mut initial_day = date.day() as i32;
+ let mut initial_month = date.month();
+
+ for i in 0..10 {
+ next = rules.next_match_from(next).unwrap();
+ println!("next: {:?}", next);
+
+ if initial_day + i + 1 == 31 {
+ initial_day = -i;
+ initial_month += 1;
+ }
+
+ // should match 2024-04-08 01:00:00, 2024-04-09 01:00:00, 2024-04-10 01:00:00, ...
+ assert_eq!(
+ next,
+ Local
+ .with_ymd_and_hms(
+ 2024,
+ initial_month,
+ (initial_day + i + 1) as u32,
+ 01,
+ 00,
+ 00
+ )
+ .unwrap()
+ );
+ }
+}
+
+#[test]
+fn each_1st_of_month() {
+ // we have passed the 1st of the month
+ // so it should go to the next month
+ let date = Local.with_ymd_and_hms(2024, 5, 7, 16, 15, 05).unwrap();
+
+ let mut rules = ruleset();
+ rules.on_day(1).at_time(0, 0, 0);
+
+ let mut next = date.clone();
+ let mut initial_month = date.month() as i32;
+ let mut initial_year = date.year();
+
+ for i in 0..10 {
+ next = rules.next_match_from(next).unwrap();
+ println!("next: {:?}", next);
+
+ if initial_month + i + 1 == 13 {
+ initial_month = -i;
+ initial_year += 1;
+ }
+
+ // should match 2024-05-01 00:00:00, 2024-06-01 00:00:00, 2024-07-01 00:00:00, ...
+ assert_eq!(
+ next,
+ Local
+ .with_ymd_and_hms(initial_year, (initial_month + i + 1) as u32, 01, 00, 00, 00)
+ .unwrap()
+ );
+ }
+}
+
+#[test]
+fn each_wednesday() {
+ // we have passed the Wednesday
+ // so it should go to the next Wednesday
+ let date = Local.with_ymd_and_hms(2024, 4, 7, 16, 15, 05).unwrap();
+ let mut next_wednesday = Local.with_ymd_and_hms(2024, 4, 10, 0, 0, 0).unwrap();
+
+ let mut rules = ruleset();
+ rules.on_dow(Weekday::Wed).at_time(0, 0, 0);
+
+ let mut next = date.clone();
+
+ for _ in 0..10 {
+ next = rules.next_match_from(next).unwrap();
+ println!("next: {:?}", next);
+
+ assert_eq!(next, next_wednesday);
+ next_wednesday = next_wednesday + chrono::Duration::days(7);
+ }
+}
+
+#[test]
+fn from_31th_may_schedule_first_of_each_june() {
+ // we have passed the 31th of May
+ // so it should go to the 1st of June
+ let date = Local.with_ymd_and_hms(2024, 5, 31, 16, 15, 05).unwrap();
+
+ let mut rules = ruleset();
+ rules.in_month(6).on_day(1).at_time(0, 0, 0);
+
+ let mut next = date.clone();
+
+ let initial_year = date.year();
+ let initial_month = date.month();
+
+ for i in 0..10 {
+ next = rules.next_match_from(next).unwrap();
+ println!("next: {:?}", next);
+
+ // should match 2024-06-01 00:00:00, 2024-07-01 00:00:00, 2024-08-01 00:00:00, ...
+ assert_eq!(
+ next,
+ Local.with_ymd_and_hms(initial_year + i, initial_month + 1, 01, 00, 00, 00).unwrap()
+ );
+ }
+}
+
+#[test]
+fn from_1st_may_schedule_first_of_each_june() {
+ let date = Local.with_ymd_and_hms(2024, 6, 1, 0, 0, 0).unwrap();
+
+ let mut rules = ruleset();
+ rules.in_month(6).on_day(1).at_time(0, 0, 0);
+
+ let mut next = date.clone();
+ let initial_year = date.year();
+
+ for i in 0..10 {
+ next = rules.next_match_from(next).unwrap();
+ println!("next: {:?}", next);
+
+ assert_eq!(
+ next,
+ Local.with_ymd_and_hms(initial_year + i + 1, 06, 01, 00, 00, 00).unwrap()
+ );
+ }
+}
+
+#[test]
+fn if_full_date_set_should_return_only_one_match_then_null() {
+ let date = Local.with_ymd_and_hms(2024, 5, 14, 19, 44, 15).unwrap();
+
+ let mut rules = ruleset();
+ rules.on_datetime(2024, 6, 1, 0, 0, 0);
+
+ let next = rules.next_match_from(date).unwrap();
+ assert_eq!(next, Local.with_ymd_and_hms(2024, 6, 1, 0, 0, 0).unwrap());
+
+ let next = rules.next_match_from(next);
+ assert!(next.is_none());
+}
diff --git a/lib/sched/utils/cron_date.rs b/lib/sched/utils/cron_date.rs
new file mode 100644
index 0000000..b5fcd5c
--- /dev/null
+++ b/lib/sched/utils/cron_date.rs
@@ -0,0 +1,547 @@
+use chrono::{
+ DateTime, Datelike, Duration, Local, Months, NaiveDate, TimeZone, Timelike, Utc, Weekday,
+};
+
+/// 🧉 » `LoolDate`
+/// --
+///
+/// wrapper around `chrono::DateTime` to provide more date manipulation methods
+pub struct LoolDate {
+ date: DateTime,
+}
+
+pub enum TimeUnit {
+ Year,
+ Month,
+ Day,
+ Hour,
+ Minute,
+ Second,
+}
+
+impl LoolDate {
+ /// creates a new `LoolDate` from a `DateTime`
+ pub fn new(date: DateTime) -> Self {
+ Self { date }
+ }
+
+ // creates a new `LoolDate` in the `Local` timezone
+ pub fn now() -> LoolDate {
+ LoolDate { date: Local::now() }
+ }
+
+ /// creates a new `LoolDate` in the `Utc` timezone
+ pub fn utc_now() -> LoolDate {
+ LoolDate { date: Utc::now() }
+ }
+
+ /// returns a clone of the inner `DateTime`
+ pub fn date(&self) -> DateTime {
+ self.date.clone()
+ }
+
+ /// adds `1` year to the current date
+ pub fn add_year(&mut self) {
+ self.date = self.date.clone() + Months::new(12);
+ self.set_start_of(TimeUnit::Year);
+ }
+
+ /// adds `1` month to the current date
+ pub fn add_month(&mut self) {
+ self.date = self.date.clone() + Months::new(1);
+ self.set_start_of(TimeUnit::Month);
+ }
+
+ /// adds `1` day to the current date
+ pub fn add_day(&mut self) {
+ self.date = self.date.clone() + Duration::days(1);
+ self.set_start_of(TimeUnit::Day);
+ }
+
+ /// adds `1` hour to the current date
+ pub fn add_hour(&mut self) {
+ self.date = self.date.clone() + Duration::hours(1);
+ self.set_start_of(TimeUnit::Hour);
+ }
+
+ /// adds `1` minute to the current date
+ pub fn add_minute(&mut self) {
+ self.date = self.date.clone() + Duration::minutes(1);
+ self.set_start_of(TimeUnit::Minute);
+ }
+
+ /// adds `1` second to the current date
+ pub fn add_second(&mut self) {
+ self.date = self.date.clone() + Duration::seconds(1);
+ self.set_start_of(TimeUnit::Second);
+ }
+
+ /// subtracts `1` year from the current date
+ pub fn subs_year(&mut self) {
+ self.date = self.date.clone() - Months::new(12);
+ }
+
+ /// subtracts `1` month from the current date
+ pub fn subs_month(&mut self) {
+ self.date = self.date.clone() - Months::new(1);
+ }
+
+ /// subtracts `1` day from the current date
+ pub fn subs_day(&mut self) {
+ self.date = self.date.clone() - Duration::days(1);
+ }
+
+ /// subtracts `1` hour from the current date
+ pub fn subs_hour(&mut self) {
+ self.date = self.date.clone() - Duration::hours(1);
+ }
+
+ /// subtracts `1` minute from the current date
+ pub fn subs_minute(&mut self) {
+ self.date = self.date.clone() - Duration::minutes(1);
+ }
+
+ /// subtracts `1` second from the current date
+ pub fn subs_second(&mut self) {
+ self.date = self.date.clone() - Duration::seconds(1);
+ }
+
+ /// returns the day of the month starting from `1`
+ pub fn day(&self) -> u32 {
+ self.date.day()
+ }
+
+ /// returns the day of the week
+ pub fn weekday(&self) -> Weekday {
+ self.date.weekday()
+ }
+
+ /// returns the day of the week starting from `0` (monday)
+ pub fn weekday_from_monday(&self) -> u32 {
+ self.date.weekday().num_days_from_monday()
+ }
+
+ /// returns the day of the week starting from `0` (sunday)
+ pub fn weekday_from_sunday(&self) -> u32 {
+ self.date.weekday().num_days_from_sunday()
+ }
+
+ /// returns the month starting from 1
+ pub fn month(&self) -> u32 {
+ self.date.month()
+ }
+
+ /// returns the year number in the [calendar date](./naive/struct.NaiveDate.html#calendar-date).
+ pub fn year(&self) -> i32 {
+ self.date.year()
+ }
+
+ /// returns the hour `(0..23)`
+ pub fn hour(&self) -> u32 {
+ self.date.hour()
+ }
+
+ /// returns the minute `(0..59)`
+ pub fn minute(&self) -> u32 {
+ self.date.minute()
+ }
+
+ /// returns the second number `(0..59)`
+ pub fn second(&self) -> u32 {
+ self.date.second()
+ }
+
+ /// returns the number of milliseconds since the last second boundary
+ ///
+ /// In event of a [leap second](https://en.wikipedia.org/wiki/Leap_second) this may exceed
+ /// `999`.
+ pub fn millis(&self) -> u64 {
+ self.date.timestamp_subsec_millis() as u64
+ }
+
+ /// Returns the number of microseconds since the last second boundary.
+ ///
+ /// In event of a [leap second](https://en.wikipedia.org/wiki/Leap_second) this may exceed
+ /// `999999`.
+ pub fn micros(&self) -> u32 {
+ self.date.timestamp_subsec_micros()
+ }
+
+ /// Returns the number of nanoseconds since the last second boundary.
+ ///
+ /// In event of a [leap second](https://en.wikipedia.org/wiki/Leap_second) this may exceed
+ /// `999999999`.
+ pub fn nanos(&self) -> u32 {
+ self.date.timestamp_subsec_nanos()
+ }
+
+ /// sets the nanoseconds since the last second change
+ ///
+ /// values greater than `2000,000,000` will be clamped to `1999,999,999`
+ pub fn set_nanos(&mut self, nanos: u32) {
+ // avoid `whith_nanosecond` returning None for > 2_000_000_000 values
+ // so we can safely unwrap the result
+ let nanos = if nanos > 2_000_000_000 { 1_999_999_999 } else { nanos };
+ self.date = self.date.with_nanosecond(nanos).unwrap();
+ }
+
+ /// sets the microseconds since the last second change
+ ///
+ /// values greater than `2,000,000` will be clamped to `1,999,999`
+ pub fn set_micros(&mut self, micros: u32) {
+ let micros = if micros > 2_000_000 { 1_999_999 } else { micros };
+ self.date = self.date.with_nanosecond(micros as u32 * 1_000).unwrap();
+ }
+
+ /// sets the milliseconds since the last second change
+ ///
+ /// values greater than `2,000` will be clamped to `1,999`
+ pub fn set_millis(&mut self, millis: u64) {
+ let millis = if millis > 2_000 { 1_999 } else { millis };
+ self.date = self.date.with_nanosecond(millis as u32 * 1_000_000).unwrap();
+ }
+
+ /// sets the second number of the date
+ ///
+ /// values >= `60` will be clamped to `0`
+ pub fn set_second(&mut self, second: u32) {
+ let second = if second >= 60 { 0 } else { second };
+ self.date = self.date.with_second(second).unwrap();
+ }
+
+ /// sets the minute number of the date
+ ///
+ /// values >= `60` will be clamped to `0`
+ pub fn set_minute(&mut self, minute: u32) {
+ let minute = if minute >= 60 { 0 } else { minute };
+ self.date = self.date.with_minute(minute).unwrap();
+ }
+
+ /// sets the hour number of the date `(0..23)`
+ ///
+ /// values >= `24` will be clamped to `0`
+ pub fn set_hour(&mut self, hour: u32) {
+ let hour = if hour >= 24 { 0 } else { hour };
+ self.date = self.date.with_hour(hour).unwrap();
+ }
+
+ /// sets the hour, minute and second of the date at once
+ ///
+ /// - `hour` must be in the range `(0..23)`
+ /// - `minute` must be in the range `(0..59)`
+ /// - `second` must be in the range `(0..59)`
+ ///
+ /// values greater than the maximum will be clamped (see `set_hour`, `set_minute`, `set_second`)
+ /// documentation for more information
+ pub fn set_hms(&mut self, hour: u32, minute: u32, second: u32) {
+ self.set_hour(hour);
+ self.set_minute(minute);
+ self.set_second(second);
+ }
+
+ /// sets the day of the month `(1..31)`
+ ///
+ /// values greater than the maximum day of the month will be clamped to the last day of the
+ /// month, depending on the current year and month and taking leap years into account.
+ pub fn set_day(&mut self, day: u32) {
+ let day = if day == 0 { 1 } else { day };
+ let days_in_month = get_days_from_month(self.date.year(), self.date.month());
+
+ if day > days_in_month {
+ self.date = self.date.with_day(days_in_month).unwrap();
+ } else {
+ self.date = self.date.with_day(day).unwrap();
+ }
+ }
+
+ /// sets the month of the year `(1..12)`
+ ///
+ /// - values greater than the maximum month will be clamped to `12`
+ /// - values less than `1` will be clamped to `1`
+ ///
+ /// if the current day is greater than the last day of the new month, the day will be clamped
+ /// to the last day of the month, depending on the current year and month and taking leap years
+ /// into account. So, be aware that changing the month may imply a change in the day.
+ /// This is done to avoid invalid dates like `2024-02-30` which would cause an error in the
+ /// `chrono` library.
+ pub fn set_month(&mut self, month: u32) {
+ let month = if month > 12 {
+ 12
+ } else if month == 0 {
+ 1
+ } else {
+ month
+ };
+
+ let day = self.date.day();
+ let days_in_month = get_days_from_month(self.date.year(), month);
+
+ if day > days_in_month {
+ self.date = self.date.with_day(days_in_month).unwrap();
+ self.date = self.date.with_month(month).unwrap();
+ } else {
+ self.date = self.date.with_month(month).unwrap();
+ }
+ }
+
+ /// sets the month and day of the date at once
+ ///
+ /// check the `set_month` and `set_day` documentation for more information about the clamping
+ /// behavior.
+ pub fn set_md(&mut self, month: u32, day: u32) {
+ self.set_month(month);
+ self.set_day(day);
+ }
+
+ /// sets the year of the date
+ ///
+ /// **warning**: changing the year may imply a change in the day.
+ /// For example, if the current date is `2024-02-29` and you set the year to `2023`, the date
+ /// date `2023-02-29` will be invalid, because `2023` is not a leap year. In this case, the day
+ /// will be clamped to `2023-02-28`.
+ ///
+ /// check the `set_day` documentation for more information about the clamping behavior.
+ pub fn set_year(&mut self, year: i32) {
+ let month = self.date.month();
+ let day = self.date.day();
+ let days_in_month = get_days_from_month(year, month);
+
+ if day > days_in_month {
+ self.date = self.date.with_day(days_in_month).unwrap();
+ self.date = self.date.with_year(year).unwrap();
+ } else {
+ self.date = self.date.with_year(year).unwrap();
+ }
+ }
+
+ /// sets the year, month and day of the date at once
+ ///
+ /// check the `set_year`, `set_month` and `set_day` documentation for more information about the
+ /// clamping behavior.
+ pub fn set_ymd(&mut self, year: i32, month: u32, day: u32) {
+ self.set_year(year);
+ self.set_month(month);
+ self.set_day(day);
+ }
+
+ /// returns true when the current date is the last day of the month.
+ pub fn is_last_day_of_month(&self) -> bool {
+ self.date.day() == get_days_from_month(self.date.year(), self.date.month())
+ }
+
+ /// returns true when the current weekday is the last occurrence of this weekday
+ /// for the present month.
+ pub fn is_last_weekday_of_month(&self) -> bool {
+ // check this by adding 7 days to the current date and seeing if it's
+ // a different month
+ let next_weekday = self.date.clone() + Duration::days(7);
+ self.date.month() != next_weekday.month()
+ }
+
+ pub fn set_start_of(&mut self, unit: TimeUnit) {
+ match unit {
+ TimeUnit::Year => {
+ self.set_month(1);
+ self.set_day(1);
+ self.set_hour(0);
+ self.set_minute(0);
+ self.set_second(0);
+ self.set_nanos(0);
+ },
+ TimeUnit::Month => {
+ self.set_day(1);
+ self.set_hour(0);
+ self.set_minute(0);
+ self.set_second(0);
+ self.set_nanos(0);
+ },
+ TimeUnit::Day => {
+ self.set_hour(0);
+ self.set_minute(0);
+ self.set_second(0);
+ self.set_nanos(0);
+ },
+ TimeUnit::Hour => {
+ self.set_minute(0);
+ self.set_second(0);
+ self.set_nanos(0);
+ },
+ TimeUnit::Minute => {
+ self.set_second(0);
+ self.set_nanos(0);
+ },
+ TimeUnit::Second => {
+ self.set_nanos(0);
+ },
+ };
+ }
+}
+
+fn get_days_from_month(year: i32, month: u32) -> u32 {
+ NaiveDate::from_ymd_opt(
+ match month {
+ 12 => year + 1,
+ _ => year,
+ },
+ match month {
+ 12 => 1,
+ _ => month + 1,
+ },
+ 1,
+ )
+ .unwrap()
+ .signed_duration_since(NaiveDate::from_ymd_opt(year, month, 1).unwrap())
+ .num_days() as u32
+}
+
+#[cfg(test)]
+mod tests {
+ use {super::*, chrono::Utc};
+
+ #[test]
+ fn test_get_days_from_month() {
+ assert_eq!(get_days_from_month(2024, 1), 31);
+ assert_eq!(get_days_from_month(2024, 2), 29);
+ assert_eq!(get_days_from_month(2024, 3), 31);
+ assert_eq!(get_days_from_month(2024, 4), 30);
+ assert_eq!(get_days_from_month(2024, 5), 31);
+ assert_eq!(get_days_from_month(2024, 6), 30);
+ assert_eq!(get_days_from_month(2024, 7), 31);
+ assert_eq!(get_days_from_month(2024, 8), 31);
+ assert_eq!(get_days_from_month(2024, 9), 30);
+ assert_eq!(get_days_from_month(2024, 10), 31);
+ assert_eq!(get_days_from_month(2024, 11), 30);
+ assert_eq!(get_days_from_month(2024, 12), 31);
+ }
+
+ #[test]
+ fn test_set_day() {
+ let mut date = LoolDate::new(Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap());
+ date.set_day(31);
+ assert_eq!(date.day(), 31);
+ assert_eq!(date.month(), 1);
+ assert_eq!(date.year(), 2024);
+
+ date.set_day(1);
+ assert_eq!(date.day(), 1);
+ assert_eq!(date.month(), 1);
+ assert_eq!(date.year(), 2024);
+ }
+
+ #[test]
+ fn test_set_day_invalid_values() {
+ let mut date = LoolDate::new(Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap());
+ date.set_day(35);
+ assert_eq!(date.day(), 31);
+ assert_eq!(date.month(), 1);
+ assert_eq!(date.year(), 2024);
+
+ date.set_day(0);
+ assert_eq!(date.day(), 1);
+ assert_eq!(date.month(), 1);
+ assert_eq!(date.year(), 2024);
+
+ let mut date = LoolDate::new(Utc.with_ymd_and_hms(2024, 2, 1, 0, 0, 0).unwrap());
+ date.set_day(30);
+ assert_eq!(date.day(), 29);
+ assert_eq!(date.month(), 2);
+ assert_eq!(date.year(), 2024);
+ }
+
+ #[test]
+ fn test_set_month() {
+ let mut date = LoolDate::new(Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap());
+ date.set_month(12);
+ assert_eq!(date.month(), 12);
+
+ date.set_month(13);
+ assert_eq!(date.month(), 12);
+
+ date.set_month(1);
+ assert_eq!(date.month(), 1);
+ }
+
+ #[test]
+ fn test_set_month_invalid_values() {
+ let mut date = LoolDate::new(Utc.with_ymd_and_hms(2024, 1, 10, 0, 0, 0).unwrap());
+ date.set_month(13);
+ assert_eq!(date.month(), 12);
+ assert_eq!(date.year(), 2024);
+ assert_eq!(date.day(), 10);
+
+ date.set_month(0);
+ assert_eq!(date.month(), 1);
+ assert_eq!(date.year(), 2024);
+ assert_eq!(date.day(), 10);
+ }
+
+ #[test]
+ fn test_set_month_leap_years() {
+ let mut date = LoolDate::new(Utc.with_ymd_and_hms(2023, 1, 29, 0, 0, 0).unwrap());
+ date.set_month(2);
+ assert_eq!(date.month(), 2);
+ assert_eq!(date.year(), 2023);
+ assert_eq!(date.day(), 28); // 2023 is not a leap year so, can't keep the original 29
+
+ let mut date = LoolDate::new(Utc.with_ymd_and_hms(2024, 1, 29, 0, 0, 0).unwrap());
+ date.set_month(2);
+ assert_eq!(date.month(), 2);
+ assert_eq!(date.year(), 2024);
+ assert_eq!(date.day(), 29); // 2024 is a leap year so, 29 is a valid day
+ }
+
+ #[test]
+ fn test_set_month_month_overflow() {
+ let mut date = LoolDate::new(Utc.with_ymd_and_hms(2024, 1, 31, 0, 0, 0).unwrap());
+ date.set_month(4);
+ assert_eq!(date.month(), 4);
+ assert_eq!(date.year(), 2024);
+ assert_eq!(date.day(), 30); // since april has 30 days, it cant keep the original 31
+
+ let mut date = LoolDate::new(Utc.with_ymd_and_hms(2024, 1, 31, 0, 0, 0).unwrap());
+ date.set_month(8);
+ assert_eq!(date.month(), 8);
+ assert_eq!(date.year(), 2024);
+ assert_eq!(date.day(), 31); // august has 31 days, so it can keep the original 31
+
+ let mut date = LoolDate::new(Utc.with_ymd_and_hms(2024, 1, 31, 0, 0, 0).unwrap());
+ date.set_month(11);
+ assert_eq!(date.month(), 11);
+ assert_eq!(date.year(), 2024);
+ assert_eq!(date.day(), 30); // november has 30 days, so it cant keep the original 31
+ }
+
+ #[test]
+ fn test_set_year() {
+ let mut date = LoolDate::new(Utc.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap());
+ date.set_year(2021);
+ assert_eq!(date.year(), 2021);
+ assert_eq!(date.month(), 1);
+ assert_eq!(date.day(), 1);
+
+ date.set_year(2050);
+ assert_eq!(date.year(), 2050);
+ assert_eq!(date.month(), 1);
+ assert_eq!(date.day(), 1);
+
+ date.set_year(-5);
+ assert_eq!(date.year(), -5);
+ assert_eq!(date.month(), 1);
+ assert_eq!(date.day(), 1);
+ }
+
+ #[test]
+ fn test_set_year_leap_years() {
+ let mut date = LoolDate::new(Utc.with_ymd_and_hms(2024, 2, 29, 0, 0, 0).unwrap());
+ date.set_year(2023);
+ assert_eq!(date.year(), 2023);
+ assert_eq!(date.month(), 2);
+ assert_eq!(date.day(), 28); // 2023 is not a leap year so, can't keep the original 29
+
+ let mut date = LoolDate::new(Utc.with_ymd_and_hms(2023, 2, 28, 0, 0, 0).unwrap());
+ date.set_year(2024);
+ assert_eq!(date.year(), 2024);
+ assert_eq!(date.month(), 2);
+ assert_eq!(date.day(), 28);
+ }
+}
diff --git a/lib/sched/utils/mod.rs b/lib/sched/utils/mod.rs
new file mode 100644
index 0000000..6c6a7c4
--- /dev/null
+++ b/lib/sched/utils/mod.rs
@@ -0,0 +1,117 @@
+use eyre::{ensure, eyre, Result};
+
+pub mod cron_date;
+
+const NO_SIGN_ERR: &str = "invalid timezone offset";
+const INVALID_OFFSET_ERR: &str = "invalid timezone offset (format should be `+hh{{:mm}}?`)";
+const INVALID_OFFSET_HS_ERR: &str = "invalid timezone offset (hour should be between 0 and 14)";
+const INVALID_OFFSET_MIN_ERR: &str = "invalid timezone offset (minute should be between 0 and 59)";
+
+/// 🧉 » converts `hours` and `minutes` durations to total seconds
+///
+/// e.g. `h=1, m=30` should return `5400`
+///
+/// meaning `1 hour and 30 minutes` is `5400` seconds
+pub fn hm_to_s(h: i32, m: i32) -> i32 {
+ h * 3600 + m * 60
+}
+
+/// 🧉 » converts timezone offset to seconds
+///
+/// eg:
+/// - `+01:00` -> `3600`
+/// - `-03:00` -> `-10800`
+/// - `+03` -> `10800`
+/// - `+00:00` -> `0`
+/// - `-03:30` -> `-12600`
+///
+/// returns an error if the offset is invalid or badly formatted
+pub fn tz_to_s(offset: &str) -> Result {
+ // if it doesn't start with '+' or '-', it's invalid
+ ensure!(
+ offset.starts_with('+') || offset.starts_with('-'),
+ NO_SIGN_ERR
+ );
+
+ let sign = if offset.starts_with('+') { 1 } else { -1 };
+ let parts: Vec<&str> = offset[1..].split(':').collect();
+
+ // it should have at least one part and at most two parts
+ ensure!(!parts.is_empty(), INVALID_OFFSET_ERR);
+ ensure!(parts.len() <= 2, INVALID_OFFSET_ERR);
+
+ let hours = parts[0].parse::().map_err(|_| eyre!(INVALID_OFFSET_ERR))?;
+
+ let minutes = if parts.len() == 2 {
+ parts[1].parse::().map_err(|_| eyre!(INVALID_OFFSET_ERR))?
+ } else {
+ 0
+ };
+
+ // offset hours cannot be greater than 14, minutes cannot be greater than 59 and
+ // seconds cannot be greater than 59
+ ensure!(hours <= 14, INVALID_OFFSET_HS_ERR);
+ ensure!(minutes <= 59, INVALID_OFFSET_MIN_ERR);
+
+ Ok(sign * hm_to_s(hours as i32, minutes as i32))
+}
+
+#[cfg(test)]
+mod tests {
+ use eyre::{set_hook, DefaultHandler};
+ use super::*;
+
+ fn setup_eyre() {
+ let _ = set_hook(Box::new(DefaultHandler::default_with));
+ }
+
+ #[test]
+ fn test_h_m_to_seconds() {
+ assert_eq!(hm_to_s(1, 30), 5400);
+ assert_eq!(hm_to_s(2, 0), 7200);
+ assert_eq!(hm_to_s(3, 0), 10800);
+ assert_eq!(hm_to_s(0, 0), 0);
+ }
+
+ #[test]
+ fn test_timezone_offset_to_seconds() -> Result<()> {
+ assert_eq!(tz_to_s("+01:00")?, 3600);
+ assert_eq!(tz_to_s("-03:00")?, -10800);
+ assert_eq!(tz_to_s("+03")?, 10800);
+ assert_eq!(tz_to_s("+00:00")?, 0);
+ assert_eq!(tz_to_s("-00:00")?, 0);
+ assert_eq!(tz_to_s("-3")?, -10800);
+ assert_eq!(tz_to_s("-3:30")?, -12600);
+
+ Ok(())
+ }
+
+ #[test]
+ fn test_timezone_offset_to_seconds_missing_sign_err() {
+ setup_eyre();
+ let err = tz_to_s("01:00").unwrap_err().to_string();
+ assert_eq!(err, NO_SIGN_ERR);
+ }
+
+ #[test]
+ fn test_timezone_offset_to_seconds_invalid_format_errs() {
+ setup_eyre();
+ let err = tz_to_s("+01:00:00").unwrap_err().to_string();
+ assert_eq!(err, INVALID_OFFSET_ERR);
+
+ let err = tz_to_s("+01:").unwrap_err().to_string();
+ assert_eq!(err, INVALID_OFFSET_ERR);
+
+ let err = tz_to_s("--01:00").unwrap_err().to_string();
+ assert_eq!(err, INVALID_OFFSET_ERR);
+
+ let err = tz_to_s("+01:-01").unwrap_err().to_string();
+ assert_eq!(err, INVALID_OFFSET_ERR);
+
+ let err = tz_to_s("+15:01").unwrap_err().to_string();
+ assert_eq!(err, INVALID_OFFSET_HS_ERR);
+
+ let err = tz_to_s("+01:60").unwrap_err().to_string();
+ assert_eq!(err, INVALID_OFFSET_MIN_ERR);
+ }
+}
diff --git a/🧳 lool.code-workspace b/🧳 lool.code-workspace
index 6142bee..d176ddc 100644
--- a/🧳 lool.code-workspace
+++ b/🧳 lool.code-workspace
@@ -12,11 +12,38 @@
"lucodear-icons.activeIconPack": "rust_ferris",
"lucodear-icons.folders.associations": {
".cargo": "rust",
- "stylize": "theme"
+ "stylize": "theme",
+ "ruleset": "rules",
+ "recurrent": "generator"
},
"lucodear-icons.files.associations": {
},
"rust-analyzer.cargo.features": "all",
+ "files.exclude": {
+ "**/.git": false,
+ "**/.svn": false,
+ "**/.hg": false,
+ "**/CVS": false,
+ "**/.DS_Store": false,
+ "**/Thumbs.db": true,
+ "**/.classpath": false,
+ "**/.factorypath": true,
+ "**/.project": false,
+ "**/.settings": true,
+ "*.ids": false,
+ "*.iml": false,
+ "*.ipr": false,
+ "*.iws": false,
+ "*.orig": false,
+ ".gradle": false,
+ ".idea/": false,
+ ".out/": false,
+ ".settings": false,
+ ".vscode": false,
+ "bin/": false,
+ "build/": false,
+ "out/": false
+ },
}
}