From 9b0faace043ae8a4f957943d7679f38a1bad2241 Mon Sep 17 00:00:00 2001 From: Lucas Colombo Date: Sun, 7 Apr 2024 22:57:02 -0300 Subject: [PATCH] =?UTF-8?q?feat(sched):=20=E2=9C=A8=20recurrence=20ruleset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/img/logo-tokio-sched.svg | 14 + Cargo.lock | 346 +++++++++++ Cargo.toml | 16 +- README.md | 3 +- lib/lib.rs | 3 + lib/lutest/mod.rs | 19 + lib/sched/README.md | 42 ++ lib/sched/mod.rs | 4 + lib/sched/rules.rs | 51 ++ lib/sched/rules/cron.rs | 2 + lib/sched/rules/recurrent/mod.rs | 8 + lib/sched/rules/recurrent/rule_unit.rs | 85 +++ lib/sched/rules/recurrent/ruleset.rs | 128 ++++ lib/sched/rules/recurrent/ruleset/builder.rs | 198 +++++++ lib/sched/rules/recurrent/tests/mod.rs | 3 + .../tests/recurrence_rules_by_many.rs | 52 ++ .../tests/recurrence_rules_by_range.rs | 57 ++ .../tests/recurrence_rules_by_val.rs | 179 ++++++ lib/sched/utils/cron_date.rs | 547 ++++++++++++++++++ lib/sched/utils/mod.rs | 117 ++++ 🧳 lool.code-workspace | 29 +- 21 files changed, 1899 insertions(+), 4 deletions(-) create mode 100644 .github/img/logo-tokio-sched.svg create mode 100644 lib/lutest/mod.rs create mode 100644 lib/sched/README.md create mode 100644 lib/sched/mod.rs create mode 100644 lib/sched/rules.rs create mode 100644 lib/sched/rules/cron.rs create mode 100644 lib/sched/rules/recurrent/mod.rs create mode 100644 lib/sched/rules/recurrent/rule_unit.rs create mode 100644 lib/sched/rules/recurrent/ruleset.rs create mode 100644 lib/sched/rules/recurrent/ruleset/builder.rs create mode 100644 lib/sched/rules/recurrent/tests/mod.rs create mode 100644 lib/sched/rules/recurrent/tests/recurrence_rules_by_many.rs create mode 100644 lib/sched/rules/recurrent/tests/recurrence_rules_by_range.rs create mode 100644 lib/sched/rules/recurrent/tests/recurrence_rules_by_val.rs create mode 100644 lib/sched/utils/cron_date.rs create mode 100644 lib/sched/utils/mod.rs diff --git a/.github/img/logo-tokio-sched.svg b/.github/img/logo-tokio-sched.svg new file mode 100644 index 0000000..3be5aeb --- /dev/null +++ b/.github/img/logo-tokio-sched.svg @@ -0,0 +1,14 @@ + + + + + + + + + \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index a2c58ac..9e6625a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,101 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "autocfg" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1fdabc7756949593fe60f30ec81974b613357de856987752631dea1e3394c80" + +[[package]] +name = "backtrace" +version = "0.3.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b05800d2e817c8b3b4b54abd461726265fa9789ae34330622f2db9ee696f9d" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + [[package]] name = "bitflags" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf4b9d6a944f767f8e5e0db018570623c85f3d925ac718db4e06d0187adb21c1" +[[package]] +name = "bumpalo" +version = "3.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ff69b9dd49fd426c69a0db9fc04dd934cdb6645ff000864d98f7e2af8830eaa" + +[[package]] +name = "cc" +version = "1.0.90" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cd6604a82acf3039f1144f54b8eb34e91ffba622051189e71b781822d5ee1f5" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a0d04d43504c61aa6c7531f1871dd0d418d91130162063b789da00fd7057a5e" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-targets", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + [[package]] name = "eyre" version = "0.6.12" @@ -18,12 +107,56 @@ dependencies = [ "once_cell", ] +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "indenter" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce23b50ad8242c51a442f3ff322d56b02f08852c77e4c0b4d3fd684abc89c683" +[[package]] +name = "js-sys" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29c15563dc2726973df627357ce0c9ddddbea194836909d655df6a75d2cf296d" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + [[package]] name = "log" version = "0.4.21" @@ -35,8 +168,44 @@ name = "lool" version = "0.0.5" dependencies = [ "bitflags", + "chrono", "eyre", "log", + "num-traits", + "tokio", +] + +[[package]] +name = "memchr" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" + +[[package]] +name = "miniz_oxide" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" +dependencies = [ + "adler", +] + +[[package]] +name = "num-traits" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", ] [[package]] @@ -44,3 +213,180 @@ name = "once_cell" version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "pin-project-lite" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" + +[[package]] +name = "proc-macro2" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "syn" +version = "2.0.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44cfb93f38070beee36b3fef7d4f5a16f27751d94b187b666a5cc5e9b0d30687" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tokio" +version = "1.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1adbebffeca75fcfd058afa480fb6c0b81e165a0323f9c9d39c9697e37c46787" +dependencies = [ + "backtrace", + "pin-project-lite", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4be2531df63900aeb2bca0daaaddec08491ee64ceecbee5076636a3b026795a8" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "614d787b966d3989fa7bb98a654e369c762374fd3213d212cfc0251257e747da" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1f8823de937b71b9460c0c34e25f3da88250760bec0ebac694b49997550d726" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.92" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af190c94f2773fdb3729c55b007a722abb5384da03bc0986df4c289bf5567e96" + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" diff --git a/Cargo.toml b/Cargo.toml index 15a4142..0cbfa65 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,9 +23,21 @@ path = "lib/lib.rs" cli-stylize = ["dep:bitflags"] logger = ["dep:log"] macros = [] +sched = ["dep:chrono"] +sched-tokio = ["dep:tokio", "tokio?/time", "tokio?/rt"] +sched-rule-recurrent = [] +sched-rule-cron = [] + [dependencies] -bitflags = { version = "2.5.0", optional = true } -log = { version = "0.4.21", optional = true } +# default eyre = { version = "0.6.12", default-features = false } +# optional +bitflags = { version = "2.5.0", optional = true } +chrono = { version = "0.4.37", optional = true } +log = { version = "0.4.21", optional = true } +tokio = { version = "1.37.0", optional = true } +num-traits = "0.2.18" + + diff --git a/README.md b/README.md index 5fe6edf..9dd0f0a 100644 --- a/README.md +++ b/README.md @@ -15,4 +15,5 @@ - [x] [cli/stylize](lib/cli/stylize) - [x] [logging](lib/logger) -- [x] [macros](lib/macros) \ No newline at end of file +- [x] [macros](lib/macros) +- [x] [tokio/sched](lib/tokio/sched) \ No newline at end of file diff --git a/lib/lib.rs b/lib/lib.rs index 8e3eb3b..b60a4ad 100644 --- a/lib/lib.rs +++ b/lib/lib.rs @@ -1,5 +1,8 @@ pub mod cli; +#[cfg(feature = "sched")] +pub mod sched; + #[cfg(feature = "logger")] pub mod logger; diff --git a/lib/lutest/mod.rs b/lib/lutest/mod.rs new file mode 100644 index 0000000..9802c5c --- /dev/null +++ b/lib/lutest/mod.rs @@ -0,0 +1,19 @@ +#[macro_export] +macro_rules! it { + // Entry point for the macro, takes the struct definition + ( + $fn_name:tt, $body:expr + // $fn_name:ident $($rest:ident)+, $body:expr + ) => { + #[test] + fn $fn_name () -> Result<(), Box> { + $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 + }, } }