├── .github └── workflows │ ├── publish.yml │ └── rust.yml ├── .gitignore ├── Cargo.toml ├── LICENSE.md ├── README.md ├── benches └── croner_bench.rs ├── examples ├── iter_demo.rs ├── simple_demo.rs └── timezone_demo.rs └── src ├── component.rs ├── errors.rs ├── iterator.rs ├── lib.rs └── pattern.rs /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | # Pattern matched against refs/tags 4 | tags: 5 | - 'v*' # Push events to every tag containing v 6 | workflow_dispatch: 7 | 8 | name: Publish 9 | 10 | jobs: 11 | publish: 12 | name: Publish 13 | runs-on: ubuntu-latest 14 | steps: 15 | - name: Checkout sources 16 | uses: actions/checkout@v2 17 | 18 | - name: Install stable toolchain 19 | uses: actions-rs/toolchain@v1 20 | with: 21 | profile: minimal 22 | toolchain: stable 23 | override: true 24 | 25 | - name: Run tests 26 | run: cargo test 27 | 28 | - name: Run tests with features 29 | run: cargo test --all-features 30 | 31 | - name: Publish 32 | run: cargo publish --token ${CRATES_TOKEN} --allow-dirty 33 | env: 34 | CRATES_TOKEN: ${{ secrets.CRATES_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Build 20 | run: cargo build --verbose 21 | - name: Run tests 22 | run: cargo test --verbose 23 | - name: Run tests with features 24 | run: cargo test --all-features --verbose 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "croner" 3 | version = "2.1.0" 4 | edition = "2021" 5 | license = "MIT" 6 | description = "Fully-featured, lightweight, and efficient Rust library designed for parsing and evaluating cron patterns" 7 | repository = "https://github.com/hexagon/croner-rust" 8 | documentation = "https://docs.rs/croner" 9 | readme = "README.md" 10 | keywords = ["cron", "scheduler", "job", "task", "time"] 11 | categories = ["date-and-time", "parser-implementations"] 12 | homepage = "https://github.com/hexagon/croner-rust" 13 | 14 | [lib] 15 | name = "croner" 16 | path = "src/lib.rs" 17 | 18 | [dependencies] 19 | chrono = "0.4.38" 20 | serde = { version = "1.0", optional = true } 21 | 22 | [dev-dependencies] 23 | chrono-tz = "0.10.0" 24 | criterion = "0.5.1" 25 | serde_test = "1.0" 26 | 27 | [features] 28 | serde = ["dep:serde"] 29 | 30 | [[bench]] 31 | name = "croner_bench" 32 | harness = false -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Hexagon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Croner 2 | 3 | Croner is a fully-featured, lightweight, and efficient Rust library designed for parsing and evaluating cron patterns. 4 | 5 | This is the Rust flavor of the popular JavaScript/TypeScript cron parser 6 | [croner](https://github.com/hexagon/croner). 7 | 8 | ## Features 9 | 10 | - Parse and evaluate [cron](https://en.wikipedia.org/wiki/Cron#CRON_expression) 11 | expressions to calculate upcoming execution times. 12 | - Follows POSIX/Vixie-cron standards, while extending it with additional specifiers such as `L` 13 | for the last day and weekday of the month, `#` for the nth weekday of the 14 | month, `W` for closest weekday to a day of month. 15 | - Evaluate cron expressions across different time zones. 16 | - Supports optional second granularity `.with_seconds_optional` or `.with_seconds_required` 17 | - Supports optional alternative weekday mode to use Quartz-style weekdays instead of POSIX using `with_alternative_weekdays` 18 | - Allows for flexible combination of DOM and DOW conditions, enabling patterns to match specific days of the week in specific weeks of the month or the closest weekday to a specific day. 19 | - Compatible with `chrono` and (optionally) `chrono-tz`. 20 | - Robust error handling. 21 | 22 | ## Crate Features 23 | 24 | - `serde`: Enables [`serde::Serialize`](https://docs.rs/serde/1/serde/trait.Serialize.html) and [`serde::Deserialize`](https://docs.rs/serde/1/serde/trait.Deserialize.html) implementations for [`Cron`](https://docs.rs/croner/2/croner/struct.Cron.html). This feature is disabled by default. 25 | 26 | ## Why croner instead of cron or saffron? 27 | 28 | Croner combines the features of cron and saffron, while following the POSIX/Vixie "standards" for the relevant parts. See this table: 29 | 30 | Feature | Croner | Cron | Saffron | 31 | ---------------------|-------------|-----------|---------| 32 | Time Zones | X | X | | 33 | Ranges (15-25)| X | X | X | 34 | Ranges with stepping (15-25/2)| X | X | X | X | 35 | `L` - Last day of month | X | | X | 36 | `5#L` - Last occurrence of weekday | X | X | | 37 | `5L` - Last occurrence of weekday | X | ? | X | 38 | `#` - Nth occurrence of weekday | X | | X | 39 | `W` - Closest weekday | X | | X | 40 | "Standards"-compliant weekdays (1 is monday) | X | | | 41 | Five part patterns (minute granularity) | X | | X | 42 | Six part patterns (second granularity)| X | X | | 43 | Weekday/Month text representations | X | X | X | 44 | Aliases (`@hourly` etc.) | X | X | | 45 | chrono `DateTime` compatibility | X | X | X | 46 | DOM-and-DOW option | X | | | 47 | 48 | > **Note** 49 | > Tests carried out at 2023-12-02 using `cron@0.12.0` and `saffron@.0.1.0` 50 | 51 | ## Getting Started 52 | 53 | ### Prerequisites 54 | 55 | Ensure you have Rust installed on your machine. If not, you can get it from 56 | [the official Rust website](https://www.rust-lang.org/). 57 | 58 | ### Installation 59 | 60 | Add `croner` to your `Cargo.toml` dependencies: 61 | 62 | ```toml 63 | [dependencies] 64 | croner = "2.0.6" # Adjust the version as necessary 65 | ``` 66 | 67 | ### Usage 68 | 69 | Here's a quick example to get you started with matching current time, and 70 | finding the next occurrence. `is_time_matching` takes a `chrono` `DateTime`: 71 | 72 | ```rust 73 | use croner::Cron; 74 | use chrono::Local; 75 | 76 | fn main() { 77 | 78 | // Parse cron expression 79 | let cron_all = Cron::new("18 * * * 5") 80 | .parse() 81 | .expect("Couldn't parse cron string"); 82 | 83 | // Compare cron pattern with current local time 84 | let time = Local::now(); 85 | let matches_all = cron_all.is_time_matching(&time).unwrap(); 86 | 87 | // Get next match 88 | let next = cron_all.find_next_occurrence(&time, false).unwrap(); 89 | 90 | // Output results 91 | println!("Time is: {}", time); 92 | println!("Pattern \"{}\" does {} time {}", cron_all.pattern.to_string(), if matches_all { "match" } else { "not match" }, time ); 93 | println!("Pattern \"{}\" will match next time at {}", cron_all.pattern.to_string(), next); 94 | 95 | } 96 | ``` 97 | 98 | To match against a non local timezone, croner supports zoned chrono DateTime's 99 | `DateTime`. To use a named time zone, you can utilize the `chrono-tz` crate. 100 | 101 | ```rust 102 | use croner::Cron; 103 | use chrono::Local; 104 | use chrono_tz::Tz; 105 | 106 | fn main() { 107 | // Parse cron expression 108 | let cron = Cron::new("18 * * * 5") 109 | .parse() 110 | .expect("Couldn't parse cron string"); 111 | 112 | // Choose a different time zone, for example America/New_York 113 | let est_timezone: Tz = "America/New_York".parse().expect("Invalid timezone"); 114 | 115 | // Find the next occurrence in EST 116 | let time_est = Local::now().with_timezone(&est_timezone); 117 | let next_est = cron.find_next_occurrence(&time_est, false).unwrap(); 118 | 119 | // Output results for EST 120 | println!("EST time is: {}", time_est); 121 | println!( 122 | "Pattern \"{}\" will match next time at (EST): {}", 123 | cron.pattern.to_string(), 124 | next_est 125 | ); 126 | } 127 | ``` 128 | 129 | This example demonstrates how to calculate the next 5 occurrences of New Year's Eve that fall on a Friday. We'll use a cron expression to match every Friday (`FRI`) in December (`12`) and use the `with_dom_and_dow` method to ensure both day of month and day of week conditions are met. 130 | 131 | ```rust 132 | use croner::Cron; 133 | use chrono::Local; 134 | 135 | fn main() { 136 | // Parse cron expression for Fridays in December 137 | let cron = Cron::new("0 0 0 31 12 FRI") 138 | // Include seconds in pattern 139 | .with_seconds_optional() 140 | // Ensure both day of month and day of week conditions are met 141 | .with_dom_and_dow() 142 | .parse() 143 | .expect("Couldn't parse cron string"); 144 | 145 | let time = Local::now(); 146 | 147 | println!("Finding the next 5 New Year's Eves on a Friday:"); 148 | for time in cron.iter_from(time).take(5) { 149 | println!("{}", time); 150 | } 151 | } 152 | ``` 153 | 154 | ### Pattern 155 | 156 | The expressions used by Croner are very similar to those of Vixie Cron, but with 157 | a few additions and changes as outlined below: 158 | 159 | ```javascript 160 | // ┌──────────────── (optional) second (0 - 59) 161 | // │ ┌────────────── minute (0 - 59) 162 | // │ │ ┌──────────── hour (0 - 23) 163 | // │ │ │ ┌────────── day of month (1 - 31) 164 | // │ │ │ │ ┌──────── month (1 - 12, JAN-DEC) 165 | // │ │ │ │ │ ┌────── day of week (0 - 6, SUN-Mon) 166 | // │ │ │ │ │ │ (0 to 6 are Sunday to Saturday; 7 is Sunday, the same as 0) 167 | // │ │ │ │ │ │ 168 | // * * * * * * 169 | ``` 170 | 171 | - Croner expressions have the following additional modifiers: 172 | - _?_: In the Rust version of croner, a questionmark behaves just as *, to 173 | allow for legacy cron patterns to be used. 174 | - _L_: The letter 'L' can be used in the day of the month field to indicate 175 | the last day of the month. When used in the day of the week field in 176 | conjunction with the # character, it denotes the last specific weekday of 177 | the month. For example, `5#L` represents the last Friday of the month. 178 | - _#_: The # character specifies the "nth" occurrence of a particular day 179 | within a month. For example, supplying `5#2` in the day of week field 180 | signifies the second Friday of the month. This can be combined with ranges 181 | and supports day names. For instance, MON-FRI#2 would match the Monday 182 | through Friday of the second week of the month. 183 | - _W_: The character 'W' is used to specify the closest weekday to a given day 184 | in the day of the month field. For example, 15W will match the closest 185 | weekday to the 15th of the month. If the specified day falls on a weekend 186 | (Saturday or Sunday), the pattern will match the closest weekday before or 187 | after that date. For instance, if the 15th is a Saturday, 15W will match the 188 | 14th (Friday), and if the 15th is a Sunday, it will match the 16th (Monday). 189 | 190 | | Field | Required | Allowed values | Allowed special characters | Remarks | 191 | | ------------ | -------- | --------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------- | 192 | | Seconds | Optional | 0-59 | * , - / ? | | 193 | | Minutes | Yes | 0-59 | * , - / ? | | 194 | | Hours | Yes | 0-23 | * , - / ? | | 195 | | Day of Month | Yes | 1-31 | * , - / ? L W | | 196 | | Month | Yes | 1-12 or JAN-DEC | * , - / ? | | 197 | | Day of Week | Yes | 0-7 or SUN-MON | * , - / ? # L | 0 to 6 are Sunday to Saturday
7 is Sunday, the same as 0
# is used to specify nth occurrence of a weekday | 198 | 199 | > **Note** Weekday and month names are case-insensitive. Both `MON` and `mon` 200 | > work. When using `L` in the Day of Week field, it affects all specified 201 | > weekdays. For example, `5-6#L` means the last Friday and Saturday in the 202 | > month." The # character can be used to specify the "nth" weekday of the month. 203 | > For example, 5#2 represents the second Friday of the month. 204 | 205 | It is also possible to use the following "nicknames" as pattern. 206 | 207 | | Nickname | Description | 208 | | ---------- | ---------------------------------- | 209 | | \@yearly | Run once a year, ie. "0 0 1 1 *". | 210 | | \@annually | Run once a year, ie. "0 0 1 1 *". | 211 | | \@monthly | Run once a month, ie. "0 0 1 * *". | 212 | | \@weekly | Run once a week, ie. "0 0 * * 0". | 213 | | \@daily | Run once a day, ie. "0 0 * * *". | 214 | | \@hourly | Run once an hour, ie. "0 * * * *". | 215 | 216 | ### Configuration 217 | 218 | Croner offers several configuration methods to change how patterns are interpreted: 219 | 220 | #### 1. `with_seconds_optional()` 221 | 222 | This method enables the inclusion of seconds in the cron pattern, but it's not mandatory. By using this method, you can create cron patterns that either include or omit the seconds field. This offers greater flexibility, allowing for more precise scheduling without imposing the strict requirement of defining seconds in every pattern. 223 | 224 | **Example Usage**: 225 | ```rust 226 | let cron = Cron::new("*/10 * * * * *") // Every 10 seconds 227 | .with_seconds_optional() 228 | .parse() 229 | .expect("Invalid cron pattern"); 230 | ``` 231 | 232 | #### 2. `with_seconds_required()` 233 | 234 | In contrast to `with_seconds_optional()`, the `with_seconds_required()` method requires the seconds field in every cron pattern. This enforces a high level of precision in task scheduling, ensuring that every pattern explicitly specifies the second at which the task should run. 235 | 236 | **Example Usage**: 237 | ```rust 238 | let cron = Cron::new("5 */2 * * * *") // At 5 seconds past every 2 minutes 239 | .with_seconds_required() 240 | .parse() 241 | .expect("Invalid cron pattern"); 242 | ``` 243 | 244 | #### 3. `with_dom_and_dow()` 245 | 246 | This method enables the combination of Day of Month (DOM) and Day of Week (DOW) conditions in your cron expressions. It's particularly useful for creating schedules that require specificity in terms of both the day of the month and the day of the week, such as running a task when the first of the month is a Monday, or christmas day is on a friday. 247 | 248 | **Example Usage**: 249 | ```rust 250 | let cron = Cron::new("0 0 25 * FRI") // When christmas day is on a friday 251 | .with_dom_and_dow() 252 | .parse() 253 | .expect("Invalid cron pattern"); 254 | ``` 255 | 256 | #### 4. `with_alternative_weekdays()` (Quartz mode) 257 | 258 | This configuration method switches the weekday mode from the POSIX standard to the Quartz-style, commonly used in Java-based scheduling systems. It's useful for those who are accustomed to Quartz's way of specifying weekdays or for ensuring compatibility with existing Quartz-based schedules. 259 | 260 | **Example Usage**: 261 | ```rust 262 | let cron = Cron::new("0 0 12 * * 6") // Every Friday (denoted with 6 in Quartz mode) at noon 263 | .with_alternative_weekdays() 264 | .parse() 265 | .expect("Invalid cron pattern"); 266 | ``` 267 | 268 | ### Documentation 269 | 270 | For detailed usage and API documentation, visit 271 | [Croner on docs.rs](https://docs.rs/croner/). 272 | 273 | ## Development 274 | 275 | To start developing in the Croner project: 276 | 277 | 1. Clone the repository. 278 | 2. Navigate into the project directory. 279 | 3. Build the project using `cargo build`. 280 | 4. Run tests with `cargo test`. 281 | 5. Run demo with `cargo run --example pattern_demo` 282 | 283 | ## Contributing 284 | 285 | We welcome contributions! Please feel free to submit a pull request or open an 286 | issue. 287 | 288 | ## License 289 | 290 | This project is licensed under the MIT License - see the 291 | [LICENSE.md](LICENSE.md) file for details. 292 | 293 | ## Disclaimer 294 | 295 | Please note that Croner is currently in its early stages of development. As 296 | such, the API is subject to change in future releases, adhering to semantic 297 | versioning principles. We recommend keeping this in mind when integrating Croner 298 | into your projects. 299 | 300 | ## Contact 301 | 302 | If you have any questions or feedback, please open an issue in the repository 303 | and we'll get back to you as soon as possible. 304 | -------------------------------------------------------------------------------- /benches/croner_bench.rs: -------------------------------------------------------------------------------- 1 | use chrono::Local; 2 | use criterion::{black_box, criterion_group, criterion_main, Criterion}; 3 | use croner::Cron; 4 | 5 | fn parse_take_100(_n: u64) { 6 | let cron: Cron = Cron::new("15 15 15 L 3 *") 7 | .with_seconds_optional() 8 | .parse() 9 | .expect("Couldn't parse cron string"); 10 | let time = Local::now(); 11 | for _time in cron.clone().iter_after(time).take(100) {} 12 | } 13 | 14 | pub fn criterion_benchmark(c: &mut Criterion) { 15 | c.bench_function("parse_take_100", |b| { 16 | b.iter(|| parse_take_100(black_box(20))) 17 | }); 18 | } 19 | 20 | criterion_group!(benches, criterion_benchmark); 21 | criterion_main!(benches); 22 | -------------------------------------------------------------------------------- /examples/iter_demo.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use croner::Cron; 3 | 4 | fn main() { 5 | // Parse cron expression 6 | let cron = Cron::new("* * * * * *") 7 | .with_seconds_optional() 8 | .parse() 9 | .expect("Couldn't parse cron string"); 10 | 11 | // Compare to UTC time now 12 | let time = Utc::now(); 13 | 14 | // (Or Local) 15 | // let time = Local::now(); 16 | 17 | // Get next 5 matches using iter_from 18 | // There is also iter_after, which does not match starting time 19 | println!( 20 | "Finding matches of pattern '{}' starting from {}:", 21 | cron.pattern.to_string(), 22 | time 23 | ); 24 | 25 | for time in cron.iter_from(time).take(5) { 26 | println!("{}", time); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/simple_demo.rs: -------------------------------------------------------------------------------- 1 | use chrono::Local; 2 | use croner::Cron; 3 | 4 | fn main() { 5 | // Example: Parse cron expression 6 | let cron = Cron::new("0 18 * * * FRI") 7 | .with_seconds_required() 8 | .parse() 9 | .expect("Couldn't parse cron string"); 10 | 11 | // Example: Compare cron pattern with current local time 12 | let time = Local::now(); 13 | let matches = cron.is_time_matching(&time).unwrap(); 14 | 15 | // Example: Get next match 16 | let next = cron.find_next_occurrence(&time, false).unwrap(); 17 | 18 | // Example: Output results 19 | println!("Current time is: {}", time); 20 | println!( 21 | "Pattern \"{}\" does {} time {}", 22 | cron.pattern.to_string(), 23 | if matches { "match" } else { "not match" }, 24 | time 25 | ); 26 | println!( 27 | "Pattern \"{}\" will match next time at {}", 28 | cron.pattern.to_string(), 29 | next 30 | ); 31 | 32 | // Example: Iterator 33 | println!("Next 5 matches:"); 34 | for time in cron.clone().iter_from(Local::now()).take(5) { 35 | println!("{}", time); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/timezone_demo.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use chrono_tz::Tz; 3 | use croner::Cron; 4 | 5 | fn main() { 6 | // Parse cron expression 7 | let cron = Cron::new("18 * * * 5") 8 | .parse() 9 | .expect("Couldn't parse cron string"); 10 | 11 | // Find the next occurrence in Europe/Stockholm 12 | let now_stockholm = Utc::now().with_timezone(&Tz::Europe__Stockholm); 13 | let next_stockholm = cron.find_next_occurrence(&now_stockholm, false).unwrap(); 14 | 15 | // Output results for Europe/Stockholm 16 | println!("UTC time is: {}", &Utc::now()); 17 | println!("Time in Europe/Stockholm time is: {}", &now_stockholm); 18 | println!( 19 | "Pattern \"{}\" will match next time at (Europe/Stockholm): {}", 20 | cron.pattern.to_string(), 21 | next_stockholm 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /src/component.rs: -------------------------------------------------------------------------------- 1 | use crate::errors::CronError; 2 | 3 | // Constants for flags 4 | pub const NONE_BIT: u8 = 0; 5 | pub const ALL_BIT: u8 = 1; 6 | 7 | // Used for nth weekday 8 | pub const NTH_1ST_BIT: u8 = 1 << 1; 9 | pub const NTH_2ND_BIT: u8 = 1 << 2; 10 | pub const NTH_3RD_BIT: u8 = 1 << 3; 11 | pub const NTH_4TH_BIT: u8 = 1 << 4; 12 | pub const NTH_5TH_BIT: u8 = 1 << 5; 13 | pub const NTH_ALL: u8 = NTH_1ST_BIT | NTH_2ND_BIT | NTH_3RD_BIT | NTH_4TH_BIT | NTH_5TH_BIT; 14 | 15 | // Used for closest weekday 16 | pub const CLOSEST_WEEKDAY_BIT: u8 = 1 << 7; 17 | 18 | // Used for last day of month 19 | pub const LAST_BIT: u8 = 1 << 6; 20 | 21 | /// Represents a component of a cron pattern, such as minute, hour, or day of week. 22 | /// 23 | /// Each `CronComponent` holds information about permissible values (min, max), 24 | /// features supported (like last day of the month), and specific bits set 25 | /// for scheduling purposes. 26 | /// 27 | /// # Examples (for internal use only, CronComponent isn't exported) 28 | /// 29 | /// let mut minute_component = CronComponent::new(0, 59, CronComponent::LAST_BIT); 30 | /// // Represents a minute component that supports the 'last' feature. 31 | /// 32 | /// // Parsing a cron expression for minute component 33 | /// // This sets specific bits in the component according to the cron syntax 34 | /// minute_component.parse("*/15").expect("Parsing failed"); 35 | /// // Sets the minute component to trigger at every 15th minute 36 | #[derive(Debug, Default, Clone)] 37 | pub struct CronComponent { 38 | bitfields: Vec, // Vector of u8 to act as multiple bitfields 39 | pub min: u8, // Minimum value this component can take 40 | pub max: u8, // Maximum value this component can take 41 | features: u8, // Single u8 bitfield to indicate supported special bits, like LAST_BIT 42 | enabled_features: u8, // Bitfield to hold component-wide special bits like LAST_BIT 43 | input_offset: u8, // Offset for numerical representation of weekdays. normally 0=SUN,1=MON etc, setting this to 1 makes 1=SUN... 44 | } 45 | 46 | impl CronComponent { 47 | /// Creates a new `CronComponent` with specified minimum and maximum values and features. 48 | /// 49 | /// `min` and `max` define the range of values this component can take. 50 | /// `features` is a bitfield specifying supported special features. 51 | /// 52 | /// # Parameters 53 | /// 54 | /// - `min`: The minimum permissible value for this component. 55 | /// - `max`: The maximum permissible value for this component. 56 | /// - `features`: Bitfield indicating special features like `LAST_BIT`. 57 | /// 58 | /// # Returns 59 | /// 60 | /// Returns a new instance of `CronComponent`. 61 | pub fn new(min: u8, max: u8, features: u8, input_offset: u8) -> Self { 62 | Self { 63 | // Vector of u8 to act as multiple bitfields. 64 | // - Initialized with NONE_BIT for each element. 65 | bitfields: vec![NONE_BIT; (max + 1) as usize], 66 | 67 | // Minimum value this component can take. 68 | // - Example: 0 for the minute-field 69 | min, 70 | 71 | // Maximum value this component can take. 72 | // - Example: 59 for the minute-field 73 | max, 74 | 75 | // Bitfield to indicate _supported_ special bits, like LAST_BIT. 76 | // - ALL_BIT and LAST_BIT is always allowed 77 | features: features | ALL_BIT | LAST_BIT, 78 | 79 | // Bitfield to indicate _enabled_ component-wide special bits like LAST_BIT. 80 | // - No features are enabled by default 81 | enabled_features: 0, 82 | 83 | // Offset for numerical representation of weekdays. normally 0=SUN,1=MON etc, setting this to 1 makes 1=SUN... 84 | input_offset, 85 | } 86 | } 87 | 88 | // Set a bit at a given position (0 to 59) 89 | pub fn set_bit(&mut self, mut pos: u8, bit: u8) -> Result<(), CronError> { 90 | if pos < self.input_offset { 91 | return Err(CronError::ComponentError(format!( 92 | "Position {} is less than the input offset {}.", 93 | pos, self.input_offset 94 | ))); 95 | } 96 | pos -= self.input_offset; 97 | if pos < self.min || pos > self.max { 98 | return Err(CronError::ComponentError(format!( 99 | "Position {} is out of bounds for the current range ({}-{}).", 100 | pos, self.min, self.max 101 | ))); 102 | } 103 | if self.features & bit != bit { 104 | return Err(CronError::ComponentError(format!( 105 | "Bit 0b{:08b} is not supported by the current features 0b{:08b}.", 106 | bit, self.features 107 | ))); 108 | } 109 | let index = pos as usize; // Convert the position to an index 110 | if index >= self.bitfields.len() { 111 | // In case the index is somehow out of the vector's bounds 112 | return Err(CronError::ComponentError(format!( 113 | "Position {} is out of the bitfields vector's bounds.", 114 | pos 115 | ))); 116 | } 117 | self.bitfields[index] |= bit; // Set the specific bit at the position 118 | Ok(()) 119 | } 120 | 121 | // Unset a specific bit at a given position 122 | pub fn unset_bit(&mut self, mut pos: u8, bit: u8) -> Result<(), CronError> { 123 | if pos < self.input_offset { 124 | return Err(CronError::ComponentError(format!( 125 | "Position {} is less than the input offset {}.", 126 | pos, self.input_offset 127 | ))); 128 | } 129 | pos -= self.input_offset; 130 | if pos < self.min || pos > self.max { 131 | return Err(CronError::ComponentError(format!( 132 | "Position {} is out of bounds for the current range ({}-{}).", 133 | pos, self.min, self.max 134 | ))); 135 | } 136 | if self.features & bit != bit { 137 | return Err(CronError::ComponentError(format!( 138 | "Bit 0b{:08b} is not supported by the current features 0b{:08b}.", 139 | bit, self.features 140 | ))); 141 | } 142 | let index = pos as usize; // Convert the position to an index 143 | if index >= self.bitfields.len() { 144 | // In case the index is somehow out of the vector's bounds 145 | return Err(CronError::ComponentError(format!( 146 | "Position {} is out of the bitfields vector's bounds.", 147 | pos 148 | ))); 149 | } 150 | self.bitfields[index] &= !bit; // Unset the specific bit at the position 151 | Ok(()) 152 | } 153 | 154 | // Check if a specific bit at a given position is set 155 | pub fn is_bit_set(&self, pos: u8, bit: u8) -> Result { 156 | if pos < self.min || pos > self.max { 157 | Err(CronError::ComponentError(format!( 158 | "Position {} is out of bounds for the current range ({}-{}).", 159 | pos, self.min, self.max 160 | ))) 161 | } else if self.features & bit != bit { 162 | Err(CronError::ComponentError(format!( 163 | "Bit 0b{:08b} is not supported by the current features 0b{:08b}.", 164 | bit, self.features 165 | ))) 166 | } else { 167 | let index = pos as usize; 168 | if index >= self.bitfields.len() { 169 | Err(CronError::ComponentError(format!( 170 | "Position {} is out of the bitfields vector's bounds.", 171 | pos 172 | ))) 173 | } else { 174 | Ok((self.bitfields[index] & bit) != 0) 175 | } 176 | } 177 | } 178 | 179 | // Method to enable a feature 180 | pub fn enable_feature(&mut self, feature: u8) -> Result<(), CronError> { 181 | if self.is_feature_allowed(feature) { 182 | self.enabled_features |= feature; 183 | Ok(()) 184 | } else { 185 | Err(CronError::ComponentError(format!( 186 | "Feature 0b{:08b} is not supported by the current features 0b{:08b}.", 187 | feature, self.features 188 | ))) 189 | } 190 | } 191 | 192 | pub fn is_feature_allowed(&mut self, feature: u8) -> bool { 193 | self.features & feature == feature 194 | } 195 | 196 | // Method to check if a feature is enabled 197 | pub fn is_feature_enabled(&self, feature: u8) -> bool { 198 | (self.enabled_features & feature) == feature 199 | } 200 | 201 | /// Parses a part of a cron expression string and sets the corresponding bits in the component. 202 | /// 203 | /// This method interprets the cron syntax provided in `field` and sets 204 | /// the relevant bits in the component. It supports standard cron patterns 205 | /// like '*', '-', '/', and 'w'. For example, '*/15' in a minute component 206 | /// would set the bits for every 15th minute. 207 | /// 208 | /// # Parameters 209 | /// 210 | /// - `field`: A string slice containing the cron expression part to parse. 211 | /// 212 | /// # Returns 213 | /// 214 | /// Returns `Ok(())` if parsing is successful, or `CronError` if the parsing fails. 215 | /// 216 | /// # Errors 217 | /// 218 | /// Returns `CronError::ComponentError` if the input string contains invalid 219 | /// cron syntax or values outside the permissible range of the component. 220 | /// 221 | /// # Examples (for internal use only, CronComponent isn't exported) 222 | /// 223 | /// use crate::component::CronComponent; 224 | /// let mut hour_component = CronComponent::new(0, 23, 0); 225 | /// hour_component.parse("*/3").expect("Parsing failed"); 226 | /// // Sets the hour component to trigger at every 3rd hour 227 | pub fn parse(&mut self, field: &str) -> Result<(), CronError> { 228 | if field == "*" { 229 | for value in self.min..=self.max { 230 | self.set_bit(value + self.input_offset, ALL_BIT)?; 231 | } 232 | return Ok(()); 233 | } 234 | 235 | for part in field.split(',') { 236 | let trimmed_part = part.trim(); 237 | if trimmed_part.is_empty() { 238 | continue; 239 | } 240 | 241 | let mut parsed_part = trimmed_part.to_string(); 242 | 243 | if parsed_part.contains('/') { 244 | self.handle_stepping(&parsed_part)?; 245 | } else if parsed_part.contains('-') { 246 | self.handle_range(&parsed_part)?; 247 | } else if parsed_part.contains('w') { 248 | self.handle_closest_weekday(&parsed_part)?; 249 | } else if parsed_part.eq_ignore_ascii_case("l") { 250 | // Handle "L" for the last bit 251 | self.enable_feature(LAST_BIT)?; 252 | } else { 253 | // Replace 'l' with 'L' 254 | parsed_part = parsed_part.replace('l', "L"); 255 | 256 | // If 'L' is contained without '#', like "5L", add the missing '#' 257 | if parsed_part.ends_with('L') && !parsed_part.contains('#') { 258 | parsed_part = parsed_part.replace('L', "#L"); 259 | } 260 | 261 | // If '#' is contained in the number, require feature NTH_ALL to be set 262 | if parsed_part.contains('#') && !self.is_feature_allowed(NTH_ALL) { 263 | return Err(CronError::ComponentError( 264 | "Nth specifier # not allowed in the current field.".to_string(), 265 | )); 266 | } 267 | 268 | // If 'L' is contained in the number, require feature NTH_ALL to be set 269 | if parsed_part.contains('L') && !self.is_feature_allowed(NTH_ALL) { 270 | return Err(CronError::ComponentError( 271 | "L not allowed in the current field.".to_string(), 272 | )); 273 | } 274 | 275 | self.handle_number(&parsed_part)?; 276 | } 277 | } 278 | 279 | Ok(()) 280 | } 281 | 282 | fn get_nth_bit(value: &str) -> Result { 283 | // If value ends with 'L', we set the LAST_BIT and exit early 284 | if value.ends_with('L') || value.ends_with('l') { 285 | return Ok(LAST_BIT); 286 | } 287 | if let Some(nth_pos) = value.find('#') { 288 | let nth = value[nth_pos + 1..] 289 | .parse::() 290 | .map_err(|_| CronError::ComponentError("Invalid nth specifier.".to_string()))?; 291 | 292 | if nth == 0 || nth > 5 { 293 | Err(CronError::ComponentError( 294 | "Nth specifier out of bounds.".to_string(), 295 | )) 296 | } else { 297 | match nth { 298 | 1 => Ok(NTH_1ST_BIT), 299 | 2 => Ok(NTH_2ND_BIT), 300 | 3 => Ok(NTH_3RD_BIT), 301 | 4 => Ok(NTH_4TH_BIT), 302 | 5 => Ok(NTH_5TH_BIT), 303 | _ => Err(CronError::ComponentError( 304 | "Invalid nth specifier.".to_string(), 305 | )), 306 | } 307 | } 308 | } else { 309 | Ok(ALL_BIT) 310 | } 311 | } 312 | 313 | // Removes everything after # 314 | fn strip_nth_part(value: &str) -> &str { 315 | value.split('#').next().unwrap_or("") 316 | } 317 | 318 | fn handle_closest_weekday(&mut self, value: &str) -> Result<(), CronError> { 319 | if let Some(day_pos) = value.find('w') { 320 | // Use a slice 321 | let day_str = &value[..day_pos]; 322 | 323 | // Parse the day from the slice 324 | let day = day_str.parse::().map_err(|_| { 325 | CronError::ComponentError("Invalid day for closest weekday.".to_string()) 326 | })?; 327 | 328 | // Check if the day is within the allowed range 329 | if day < self.min || day > self.max { 330 | return Err(CronError::ComponentError( 331 | "Day for closest weekday out of bounds.".to_string(), 332 | )); 333 | } 334 | 335 | // Set the bit for the closest weekday 336 | self.set_bit(day, CLOSEST_WEEKDAY_BIT)?; 337 | } else { 338 | // If 'w' is not found, handle the value as a regular number 339 | self.handle_number(value)?; 340 | } 341 | Ok(()) 342 | } 343 | 344 | fn handle_range(&mut self, range: &str) -> Result<(), CronError> { 345 | let bit_to_set = CronComponent::get_nth_bit(range)?; 346 | let str_clean = CronComponent::strip_nth_part(range); 347 | 348 | let parts: Vec<&str> = str_clean.split('-').map(str::trim).collect(); 349 | if parts.len() != 2 { 350 | return Err(CronError::ComponentError( 351 | "Invalid range syntax.".to_string(), 352 | )); 353 | } 354 | 355 | let start = parts[0] 356 | .parse::() 357 | .map_err(|_| CronError::ComponentError("Invalid start of range.".to_string()))?; 358 | let end = parts[1] 359 | .parse::() 360 | .map_err(|_| CronError::ComponentError("Invalid end of range.".to_string()))?; 361 | 362 | if start > end || start < self.min || end > self.max { 363 | return Err(CronError::ComponentError( 364 | "Range out of bounds.".to_string(), 365 | )); 366 | } 367 | 368 | for value in start..=end { 369 | self.set_bit(value, bit_to_set)?; 370 | } 371 | Ok(()) 372 | } 373 | 374 | fn handle_number(&mut self, value: &str) -> Result<(), CronError> { 375 | let bit_to_set = CronComponent::get_nth_bit(value)?; 376 | let value_clean = CronComponent::strip_nth_part(value); 377 | let num = value_clean 378 | .parse::() 379 | .map_err(|_| CronError::ComponentError("Invalid number.".to_string()))?; 380 | if num < self.min || num > self.max { 381 | return Err(CronError::ComponentError( 382 | "Number out of bounds.".to_string(), 383 | )); 384 | } 385 | 386 | self.set_bit(num, bit_to_set)?; 387 | Ok(()) 388 | } 389 | 390 | pub fn handle_stepping(&mut self, stepped_range: &str) -> Result<(), CronError> { 391 | let bit_to_set = CronComponent::get_nth_bit(stepped_range)?; 392 | let stepped_range_clean = CronComponent::strip_nth_part(stepped_range); 393 | 394 | let parts: Vec<&str> = stepped_range_clean.split('/').collect(); 395 | if parts.len() != 2 { 396 | return Err(CronError::ComponentError( 397 | "Invalid stepped range syntax.".to_string(), 398 | )); 399 | } 400 | 401 | let range_part = parts[0]; 402 | let step_str = parts[1]; 403 | let step = step_str 404 | .parse::() 405 | .map_err(|_| CronError::ComponentError("Invalid step.".to_string()))?; 406 | if step == 0 { 407 | return Err(CronError::ComponentError( 408 | "Step cannot be zero.".to_string(), 409 | )); 410 | } 411 | 412 | let (start, end) = if range_part == "*" { 413 | (self.min, self.max) 414 | } else if range_part.contains('-') { 415 | let bounds: Vec<&str> = range_part.split('-').collect(); 416 | if bounds.len() != 2 { 417 | return Err(CronError::ComponentError( 418 | "Invalid range syntax in stepping.".to_string(), 419 | )); 420 | } 421 | ( 422 | bounds[0] 423 | .parse::() 424 | .map_err(|_| CronError::ComponentError("Invalid range start.".to_string()))?, 425 | bounds[1] 426 | .parse::() 427 | .map_err(|_| CronError::ComponentError("Invalid range end.".to_string()))?, 428 | ) 429 | } else { 430 | let single_start = range_part 431 | .parse::() 432 | .map_err(|_| CronError::ComponentError("Invalid start.".to_string()))?; 433 | // If only one number is provided, set the range to go from the start value to the max value. 434 | (single_start, self.max) 435 | }; 436 | 437 | if start < self.min || end > self.max || start > end { 438 | return Err(CronError::ComponentError( 439 | "Range is out of bounds in stepping.".to_string(), 440 | )); 441 | } 442 | 443 | // Apply stepping within the range 444 | let mut value = start; 445 | while value <= end { 446 | self.set_bit(value, bit_to_set)?; 447 | value = value.checked_add(step).ok_or_else(|| { 448 | CronError::ComponentError("Value exceeded max after stepping.".to_string()) 449 | })?; 450 | } 451 | 452 | Ok(()) 453 | } 454 | } 455 | 456 | #[cfg(test)] 457 | mod tests { 458 | use super::*; 459 | use crate::errors::CronError; 460 | 461 | #[test] 462 | fn test_new_cron_component() { 463 | let component = CronComponent::new(0, 59, ALL_BIT | LAST_BIT, 0); 464 | assert_eq!(component.min, 0); 465 | assert_eq!(component.max, 59); 466 | // Ensure all bitfields are initialized to NONE_BIT 467 | assert!(component.bitfields.iter().all(|&b| b == NONE_BIT)); 468 | // Check that ALL_BIT and LAST_BIT are included in features 469 | assert!(component.features & (ALL_BIT | LAST_BIT) == (ALL_BIT | LAST_BIT)); 470 | } 471 | 472 | #[test] 473 | fn test_set_bit() { 474 | let mut component = CronComponent::new(0, 59, ALL_BIT, 0); 475 | assert!(component.set_bit(10, ALL_BIT).is_ok()); 476 | assert!(component.is_bit_set(10, ALL_BIT).unwrap()); 477 | } 478 | 479 | #[test] 480 | fn test_set_bit_out_of_bounds() { 481 | let mut component = CronComponent::new(0, 59, ALL_BIT, 0); 482 | assert!(matches!( 483 | component.set_bit(60, ALL_BIT), 484 | Err(CronError::ComponentError(_)) 485 | )); 486 | } 487 | 488 | #[test] 489 | fn test_unset_bit() { 490 | let mut component = CronComponent::new(0, 59, ALL_BIT, 0); 491 | component.set_bit(10, ALL_BIT).unwrap(); 492 | assert!(component.unset_bit(10, ALL_BIT).is_ok()); 493 | assert!(!component.is_bit_set(10, ALL_BIT).unwrap()); 494 | } 495 | 496 | #[test] 497 | fn test_is_feature_enabled() { 498 | let mut component = CronComponent::new(0, 59, LAST_BIT, 0); 499 | assert!(!component.is_feature_enabled(LAST_BIT)); 500 | component.enable_feature(LAST_BIT).unwrap(); 501 | assert!(component.is_feature_enabled(LAST_BIT)); 502 | } 503 | 504 | #[test] 505 | fn test_enable_feature_unsupported() { 506 | let mut component = CronComponent::new(0, 59, NONE_BIT, 0); 507 | assert!(matches!( 508 | component.enable_feature(NTH_1ST_BIT), 509 | Err(CronError::ComponentError(_)) 510 | )); 511 | } 512 | 513 | #[test] 514 | fn test_parse_asterisk() { 515 | let mut component = CronComponent::new(0, 59, ALL_BIT, 0); 516 | component.parse("*").unwrap(); 517 | for i in 0..=59 { 518 | assert!(component.is_bit_set(i, ALL_BIT).unwrap()); 519 | } 520 | } 521 | 522 | #[test] 523 | fn test_parse_range() { 524 | let mut component = CronComponent::new(0, 59, ALL_BIT, 0); 525 | component.parse("10-15").unwrap(); 526 | for i in 10..=15 { 527 | assert!(component.is_bit_set(i, ALL_BIT).unwrap()); 528 | } 529 | } 530 | 531 | #[test] 532 | fn test_parse_stepping() { 533 | let mut component = CronComponent::new(0, 59, ALL_BIT, 0); 534 | component.parse("*/5").unwrap(); 535 | for i in (0..=59).filter(|n| n % 5 == 0) { 536 | assert!(component.is_bit_set(i, ALL_BIT).unwrap()); 537 | } 538 | } 539 | 540 | #[test] 541 | fn test_parse_list() { 542 | let mut component = CronComponent::new(0, 59, ALL_BIT, 0); 543 | component.parse("5,10,15").unwrap(); 544 | for i in [5, 10, 15].iter() { 545 | assert!(component.is_bit_set(*i, ALL_BIT).unwrap()); 546 | } 547 | } 548 | 549 | #[test] 550 | fn test_parse_invalid_syntax() { 551 | let mut component = CronComponent::new(0, 59, ALL_BIT, 0); 552 | assert!(component.parse("10-").is_err()); 553 | assert!(component.parse("*/").is_err()); 554 | assert!(component.parse("60").is_err()); // out of bounds for the minute field 555 | } 556 | 557 | #[test] 558 | fn test_parse_closest_weekday() { 559 | let mut component = CronComponent::new(1, 31, CLOSEST_WEEKDAY_BIT, 0); 560 | component.parse("15w").unwrap(); 561 | assert!(component.is_bit_set(15, CLOSEST_WEEKDAY_BIT).unwrap()); 562 | // You might want to add more tests for edge cases 563 | } 564 | } 565 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | /// Represents errors that can occur while parsing and evaluating cron patterns. 2 | /// 3 | /// `CronError` is used throughout the `croner` crate to indicate various types of failures 4 | /// and is exported for consuming programs to use. 5 | #[derive(Debug)] 6 | pub enum CronError { 7 | /// The pattern string provided was empty. 8 | /// 9 | /// This error occurs if a cron pattern is set to an empty string, which is not a valid cron expression. 10 | EmptyPattern, 11 | 12 | /// Encountered an invalid date while parsing or evaluating a cron pattern. 13 | /// 14 | /// This might happen if a cron pattern results in a date that doesn't exist (e.g., February 30th). 15 | InvalidDate, 16 | 17 | /// Encountered an invalid time while parsing or evaluating a cron pattern. 18 | /// 19 | /// This can occur if a time component in the cron pattern is outside its valid range. 20 | InvalidTime, 21 | 22 | /// The search for the next valid time exceeded a reasonable limit. 23 | /// 24 | /// This is typically encountered with complex patterns that don't match any real-world times. 25 | TimeSearchLimitExceeded, 26 | 27 | /// The cron pattern provided is invalid. 28 | /// 29 | /// This error includes a message detailing the nature of the invalid pattern, 30 | /// such as "Pattern must consist of six fields, seconds can not be omitted." 31 | InvalidPattern(String), 32 | 33 | /// The pattern contains characters that are not allowed. 34 | /// 35 | /// This error includes a message indicating the illegal characters encountered in the pattern, 36 | /// such as "CronPattern contains illegal characters." 37 | IllegalCharacters(String), 38 | 39 | /// A component of the pattern is invalid. 40 | /// 41 | /// This variant is used for various errors that specifically arise from individual components of a cron pattern, 42 | /// such as "Position x is out of bounds for the current range (y-z).". 43 | ComponentError(String), 44 | } 45 | impl std::fmt::Display for CronError { 46 | fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { 47 | match self { 48 | CronError::TimeSearchLimitExceeded => { 49 | write!(f, "CronScheduler time search limit exceeded.") 50 | } 51 | CronError::EmptyPattern => write!(f, "CronPattern cannot be an empty string."), 52 | CronError::InvalidDate => write!(f, "CronScheduler encountered an invalid date."), 53 | CronError::InvalidTime => write!(f, "CronScheduler encountered an invalid time."), 54 | CronError::InvalidPattern(msg) => write!(f, "Invalid pattern: {}", msg), 55 | CronError::IllegalCharacters(msg) => { 56 | write!(f, "Pattern contains illegal characters: {}", msg) 57 | } 58 | CronError::ComponentError(msg) => write!(f, "Component error: {}", msg), 59 | } 60 | } 61 | } 62 | impl std::error::Error for CronError {} 63 | -------------------------------------------------------------------------------- /src/iterator.rs: -------------------------------------------------------------------------------- 1 | use crate::Cron; 2 | use chrono::{DateTime, Duration, TimeZone}; 3 | 4 | pub struct CronIterator 5 | where 6 | Tz: TimeZone, 7 | { 8 | cron: Cron, 9 | current_time: DateTime, 10 | } 11 | 12 | impl CronIterator 13 | where 14 | Tz: TimeZone, 15 | { 16 | pub fn new(cron: Cron, start_time: DateTime) -> Self { 17 | CronIterator { 18 | cron, 19 | current_time: start_time, 20 | } 21 | } 22 | } 23 | 24 | impl Iterator for CronIterator 25 | where 26 | Tz: TimeZone, 27 | { 28 | type Item = DateTime; 29 | 30 | fn next(&mut self) -> Option { 31 | match self.cron.find_next_occurrence(&self.current_time, true) { 32 | Ok(next_time) => { 33 | // Check if we can add one second without overflow 34 | let next_time_clone = next_time.clone(); 35 | if let Some(updated_time) = next_time.checked_add_signed(Duration::seconds(1)) { 36 | self.current_time = updated_time; 37 | Some(next_time_clone) // Return the next time 38 | } else { 39 | // If we hit an overflow, stop the iteration 40 | None 41 | } 42 | } 43 | Err(_) => None, // Stop the iteration if we cannot find the next occurrence 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # Croner 2 | //! 3 | //! Croner is a fully-featured, lightweight, and efficient Rust library designed for parsing and evaluating cron patterns. 4 | //! 5 | //! ## Features 6 | //! - Parses a wide range of cron expressions, including extended formats. 7 | //! - Evaluates cron patterns to calculate upcoming execution times. 8 | //! - Supports time zone-aware scheduling. 9 | //! - Offers granularity up to seconds for precise task scheduling. 10 | //! - Compatible with the `chrono` library for dealing with date and time in Rust. 11 | //! 12 | //! ## Crate Features 13 | //! - `serde`: Enables [`serde::Serialize`](https://docs.rs/serde/1/serde/trait.Serialize.html) and 14 | //! [`serde::Deserialize`](https://docs.rs/serde/1/serde/trait.Deserialize.html) implementations for 15 | //! [`Cron`](struct.Cron.html). This feature is disabled by default. 16 | //! 17 | //! ## Example 18 | //! The following example demonstrates how to use Croner to parse a cron expression and find the next occurrence of a specified time: 19 | //! 20 | //! ```rust 21 | //! use chrono::Utc; 22 | //! use croner::Cron; 23 | //! 24 | //! // Parse a cron expression to find the next occurrence at 00:00 on Friday 25 | //! let cron = Cron::new("0 0 * * FRI").parse().expect("Successful parsing"); 26 | //! 27 | //! // Get the next occurrence from the current time, excluding the current time 28 | //! let next = cron.find_next_occurrence(&Utc::now(), false).unwrap(); 29 | //! 30 | //! println!( 31 | //! "Pattern \"{}\" will match next at {}", 32 | //! cron.pattern.to_string(), 33 | //! next 34 | //! ); 35 | //! ``` 36 | //! 37 | //! In this example, `Cron::new("0 0 * * FRI")` creates a new Cron instance for the pattern that represents every Friday at midnight. The `find_next_occurrence` method calculates the next time this pattern will be true from the current moment. 38 | //! 39 | //! The `false` argument in `find_next_occurrence` specifies that the current time is not included in the calculation, ensuring that only future occurrences are considered. 40 | //! 41 | //! ## Getting Started 42 | //! To start using Croner, add it to your project's `Cargo.toml` and follow the examples to integrate cron pattern parsing and scheduling into your application. 43 | //! 44 | //! ## Pattern 45 | //! 46 | //! The expressions used by Croner are very similar to those of Vixie Cron, but with 47 | //! a few additions as outlined below: 48 | //! 49 | //! ```javascript 50 | //! // ┌──────────────── (optional) second (0 - 59) 51 | //! // │ ┌────────────── minute (0 - 59) 52 | //! // │ │ ┌──────────── hour (0 - 23) 53 | //! // │ │ │ ┌────────── day of month (1 - 31) 54 | //! // │ │ │ │ ┌──────── month (1 - 12, JAN-DEC) 55 | //! // │ │ │ │ │ ┌────── day of week (0 - 6, SUN-Mon) 56 | //! // │ │ │ │ │ │ (0 to 6 are Sunday to Saturday; 7 is Sunday, the same as 0) 57 | //! // │ │ │ │ │ │ 58 | //! // * * * * * * 59 | //! ``` 60 | //! 61 | //! | Field | Required | Allowed values | Allowed special characters | Remarks | 62 | //! | ------------ | -------- | --------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------- | 63 | //! | Seconds | Optional | 0-59 | * , - / ? | | 64 | //! | Minutes | Yes | 0-59 | * , - / ? | | 65 | //! | Hours | Yes | 0-23 | * , - / ? | | 66 | //! | Day of Month | Yes | 1-31 | * , - / ? L W | | 67 | //! | Month | Yes | 1-12 or JAN-DEC | * , - / ? | | 68 | //! | Day of Week | Yes | 0-7 or SUN-MON | * , - / ? # L | 0 to 6 are Sunday to Saturday, 7 is Sunday, the same as 0. '#' is used to specify the nth occurrence of a weekday | 69 | //! 70 | //! For more information, refer to the full [README](https://github.com/hexagon/croner-rust). 71 | 72 | pub mod errors; 73 | 74 | mod component; 75 | mod iterator; 76 | mod pattern; 77 | 78 | use errors::CronError; 79 | pub use iterator::CronIterator; 80 | use pattern::CronPattern; 81 | use std::str::FromStr; 82 | 83 | use chrono::{ 84 | DateTime, Datelike, Duration, NaiveDate, NaiveDateTime, NaiveTime, TimeZone, Timelike, 85 | }; 86 | 87 | #[cfg(feature = "serde")] 88 | use core::fmt; 89 | #[cfg(feature = "serde")] 90 | use serde::{ 91 | de::{self, Visitor}, 92 | Deserialize, Serialize, Serializer, 93 | }; 94 | 95 | const YEAR_UPPER_LIMIT: i32 = 5000; 96 | 97 | enum TimeComponent { 98 | Second = 1, 99 | Minute, 100 | Hour, 101 | Day, 102 | Month, 103 | Year, 104 | } 105 | 106 | // The Cron struct represents a cron schedule and provides methods to parse cron strings, 107 | // check if a datetime matches the cron pattern, and find the next occurrence. 108 | #[derive(Debug, Clone)] 109 | pub struct Cron { 110 | pub pattern: CronPattern, // Parsed cron pattern 111 | } 112 | impl Cron { 113 | // Constructor to create a new instance of Cron with default settings 114 | pub fn new(cron_string: &str) -> Self { 115 | Self { 116 | pattern: CronPattern::new(cron_string), 117 | } 118 | } 119 | 120 | // Tries to parse a given cron string into a Cron instance. 121 | pub fn parse(&mut self) -> Result { 122 | self.pattern.parse()?; 123 | Ok(self.clone()) 124 | } 125 | 126 | /// Evaluates if a given `DateTime` matches the cron pattern associated with this instance. 127 | /// 128 | /// The function checks each cron field (seconds, minutes, hours, day of month, month) against 129 | /// the provided `DateTime` to determine if it aligns with the cron pattern. Each field is 130 | /// checked for a match, and all fields must match for the entire pattern to be considered 131 | /// a match. 132 | /// 133 | /// # Parameters 134 | /// 135 | /// - `time`: A reference to the `DateTime` to be checked against the cron pattern. 136 | /// 137 | /// # Returns 138 | /// 139 | /// - `Ok(bool)`: `true` if `time` matches the cron pattern, `false` otherwise. 140 | /// - `Err(CronError)`: An error if there is a problem checking any of the pattern fields 141 | /// against the provided `DateTime`. 142 | /// 143 | /// # Errors 144 | /// 145 | /// This method may return `CronError` if an error occurs during the evaluation of the 146 | /// cron pattern fields. Errors can occur due to invalid bit operations or invalid dates. 147 | /// 148 | /// # Examples 149 | /// 150 | /// ``` 151 | /// use croner::Cron; 152 | /// use chrono::Utc; 153 | /// 154 | /// // Parse cron expression 155 | /// let cron: Cron = Cron::new("* * * * *").parse().expect("Couldn't parse cron string"); 156 | /// 157 | /// // Compare to time now 158 | /// let time = Utc::now(); 159 | /// let matches_all = cron.is_time_matching(&time).unwrap(); 160 | /// 161 | /// // Output results 162 | /// println!("Time is: {}", time); 163 | /// println!( 164 | /// "Pattern \"{}\" does {} time {}", 165 | /// cron.pattern.to_string(), 166 | /// if matches_all { "match" } else { "not match" }, 167 | /// time 168 | /// ); 169 | /// ``` 170 | pub fn is_time_matching(&self, time: &DateTime) -> Result { 171 | // Convert to NaiveDateTime 172 | let naive_time = time.naive_local(); 173 | 174 | // Use NaiveDateTime for the comparisons 175 | Ok(self.pattern.second_match(naive_time.second())? 176 | && self.pattern.minute_match(naive_time.minute())? 177 | && self.pattern.hour_match(naive_time.hour())? 178 | && self 179 | .pattern 180 | .day_match(naive_time.year(), naive_time.month(), naive_time.day())? 181 | && self.pattern.month_match(naive_time.month())?) 182 | } 183 | 184 | /// Finds the next occurrence of a scheduled date and time that matches the cron pattern, 185 | /// starting from a given `start_time`. If `inclusive` is `true`, the search includes the 186 | /// `start_time`; otherwise, it starts from the next second. 187 | /// 188 | /// This method performs a search through time, beginning at `start_time`, to find the 189 | /// next date and time that aligns with the cron pattern defined within the `Cron` instance. 190 | /// The search respects cron fields (seconds, minutes, hours, day of month, month, day of week) 191 | /// and iterates through time until a match is found or an error occurs. 192 | /// 193 | /// # Parameters 194 | /// 195 | /// - `start_time`: A reference to a `DateTime` indicating the start time for the search. 196 | /// - `inclusive`: A `bool` that specifies whether the search should include `start_time` itself. 197 | /// 198 | /// # Returns 199 | /// 200 | /// - `Ok(DateTime)`: The next occurrence that matches the cron pattern. 201 | /// - `Err(CronError)`: An error if the next occurrence cannot be found within a reasonable 202 | /// limit, if any of the date/time manipulations result in an invalid date, or if the 203 | /// cron pattern match fails. 204 | /// 205 | /// # Errors 206 | /// 207 | /// - `CronError::InvalidTime`: If the start time provided is invalid or adjustments to the 208 | /// time result in an invalid date/time. 209 | /// - `CronError::TimeSearchLimitExceeded`: If the search exceeds a reasonable time limit. 210 | /// This prevents infinite loops in case of patterns that cannot be matched. 211 | /// - Other errors as defined by the `CronError` enum may occur if the pattern match fails 212 | /// at any stage of the search. 213 | /// 214 | /// # Examples 215 | /// 216 | /// ``` 217 | /// use chrono::Utc; 218 | /// use croner::Cron; 219 | /// 220 | /// // Parse cron expression 221 | /// let cron: Cron = Cron::new("0 18 * * * 5").with_seconds_required().parse().expect("Success"); 222 | /// 223 | /// // Get next match 224 | /// let time = Utc::now(); 225 | /// let next = cron.find_next_occurrence(&time, false).unwrap(); 226 | /// 227 | /// println!( 228 | /// "Pattern \"{}\" will match next time at {}", 229 | /// cron.pattern.to_string(), 230 | /// next 231 | /// ); 232 | /// ``` 233 | pub fn find_next_occurrence( 234 | &self, 235 | start_time: &DateTime, 236 | inclusive: bool, 237 | ) -> Result, CronError> 238 | where 239 | Tz: TimeZone, 240 | { 241 | let mut naive_time = start_time.naive_local(); 242 | let originaltimezone = start_time.timezone(); 243 | 244 | if !inclusive { 245 | naive_time = naive_time 246 | .checked_add_signed(chrono::Duration::seconds(1)) 247 | .ok_or(CronError::InvalidTime)?; 248 | } 249 | 250 | loop { 251 | let mut updated = false; 252 | 253 | updated |= self.find_next_matching_month(&mut naive_time)?; 254 | updated |= self.find_next_matching_day(&mut naive_time)?; 255 | updated |= self.find_next_matching_hour(&mut naive_time)?; 256 | updated |= self.find_next_matching_minute(&mut naive_time)?; 257 | updated |= self.find_next_matching_second(&mut naive_time)?; 258 | 259 | if updated { 260 | continue; 261 | } 262 | 263 | // Convert back to original timezone 264 | let tz_datetime_result = from_naive(naive_time, &originaltimezone)?; 265 | 266 | // Check for match 267 | if self.is_time_matching(&tz_datetime_result)? { 268 | return Ok(tz_datetime_result); 269 | } else { 270 | return Err(CronError::TimeSearchLimitExceeded); 271 | } 272 | } 273 | } 274 | 275 | /// Creates a `CronIterator` starting from the specified time. 276 | /// 277 | /// This function will create an iterator that yields dates and times that 278 | /// match a cron schedule, beginning at `start_from`. The iterator will 279 | /// begin at the specified start time if it matches. 280 | /// 281 | /// # Examples 282 | /// 283 | /// ``` 284 | /// use chrono::Utc; 285 | /// use croner::Cron; 286 | /// 287 | /// // Parse cron expression 288 | /// let cron = Cron::new("* * * * *").parse().expect("Couldn't parse cron string"); 289 | /// 290 | /// // Compare to time now 291 | /// let time = Utc::now(); 292 | /// 293 | /// // Get next 5 matches using iter_from 294 | /// println!("Finding matches of pattern '{}' starting from {}:", cron.pattern.to_string(), time); 295 | /// 296 | /// for time in cron.clone().iter_from(time).take(5) { 297 | /// println!("{}", time); 298 | /// } 299 | /// ``` 300 | /// 301 | /// # Parameters 302 | /// 303 | /// - `start_from`: A `DateTime` that represents the starting point for the iterator. 304 | /// 305 | /// # Returns 306 | /// 307 | /// Returns a `CronIterator` that can be used to iterate over scheduled times. 308 | pub fn iter_from(&self, start_from: DateTime) -> CronIterator 309 | where 310 | Tz: TimeZone, 311 | { 312 | CronIterator::new(self.clone(), start_from) 313 | } 314 | 315 | /// Creates a `CronIterator` starting after the specified time. 316 | /// 317 | /// This function will create an iterator that yields dates and times that 318 | /// match a cron schedule, beginning after `start_after`. The iterator will 319 | /// not yield the specified start time; it will yield times that come 320 | /// after it according to the cron schedule. 321 | /// 322 | /// # Examples 323 | /// 324 | /// ``` 325 | /// use chrono::Utc; 326 | /// use croner::Cron; 327 | /// 328 | /// // Parse cron expression 329 | /// let cron = Cron::new("* * * * *").parse().expect("Couldn't parse cron string"); 330 | /// 331 | /// // Compare to time now 332 | /// let time = Utc::now(); 333 | /// 334 | /// // Get next 5 matches using iter_from 335 | /// println!("Finding matches of pattern '{}' starting from {}:", cron.pattern.to_string(), time); 336 | /// 337 | /// for time in cron.clone().iter_after(time).take(5) { 338 | /// println!("{}", time); 339 | /// } 340 | /// 341 | /// ``` 342 | /// 343 | /// # Parameters 344 | /// 345 | /// - `start_after`: A `DateTime` that represents the starting point for the iterator. 346 | /// 347 | /// # Returns 348 | /// 349 | /// Returns a `CronIterator` that can be used to iterate over scheduled times. 350 | pub fn iter_after(&self, start_after: DateTime) -> CronIterator 351 | where 352 | Tz: TimeZone, 353 | { 354 | let start_from = start_after 355 | .checked_add_signed(Duration::seconds(1)) 356 | .expect("Invalid date encountered when adding one second"); 357 | CronIterator::new(self.clone(), start_from) 358 | } 359 | 360 | // Internal functions to check for the next matching month/day/hour/minute/second and return the updated time. 361 | fn find_next_matching_month( 362 | &self, 363 | current_time: &mut NaiveDateTime, 364 | ) -> Result { 365 | let mut incremented = false; 366 | while !self.pattern.month_match(current_time.month())? { 367 | increment_time_component(current_time, TimeComponent::Month)?; 368 | incremented = true; 369 | } 370 | Ok(incremented) 371 | } 372 | 373 | fn find_next_matching_day(&self, current_time: &mut NaiveDateTime) -> Result { 374 | let mut incremented = false; 375 | while !self.pattern.day_match( 376 | current_time.year(), 377 | current_time.month(), 378 | current_time.day(), 379 | )? { 380 | increment_time_component(current_time, TimeComponent::Day)?; 381 | incremented = true; 382 | } 383 | 384 | Ok(incremented) 385 | } 386 | 387 | fn find_next_matching_hour(&self, current_time: &mut NaiveDateTime) -> Result { 388 | let mut incremented = false; 389 | let next_hour_result = self.pattern.next_hour_match(current_time.hour()); 390 | 391 | match next_hour_result { 392 | Ok(Some(next_match)) if next_match != current_time.hour() => { 393 | set_time_component(current_time, TimeComponent::Hour, next_match)?; 394 | } 395 | Ok(None) => { 396 | increment_time_component(current_time, TimeComponent::Day)?; 397 | incremented = true; 398 | } 399 | Err(e) => return Err(e), // Propagate any CronError 400 | _ => {} // No action needed if the current hour already matches 401 | } 402 | Ok(incremented) 403 | } 404 | 405 | fn find_next_matching_minute( 406 | &self, 407 | current_time: &mut NaiveDateTime, 408 | ) -> Result { 409 | let mut incremented = false; 410 | let next_minute_result = self.pattern.next_minute_match(current_time.minute()); 411 | 412 | match next_minute_result { 413 | Ok(Some(next_match)) if next_match != current_time.minute() => { 414 | incremented = true; 415 | set_time_component(current_time, TimeComponent::Minute, next_match)?; 416 | } 417 | Ok(None) => { 418 | incremented = true; 419 | increment_time_component(current_time, TimeComponent::Hour)?; 420 | } 421 | Err(e) => return Err(e), // Propagate the CronError 422 | _ => {} // No action needed if the current minute matches 423 | } 424 | Ok(incremented) 425 | } 426 | 427 | fn find_next_matching_second( 428 | &self, 429 | current_time: &mut NaiveDateTime, 430 | ) -> Result { 431 | let mut incremented = false; 432 | let next_second_result = self.pattern.next_second_match(current_time.second()); 433 | 434 | match next_second_result { 435 | Ok(Some(next_match)) => { 436 | // If a matching second is found, set it and mark as incremented. 437 | set_time_component(current_time, TimeComponent::Second, next_match)?; 438 | } 439 | Ok(None) => { 440 | // If no match is found in the current minute, increment the minute. 441 | increment_time_component(current_time, TimeComponent::Minute)?; 442 | incremented = true; 443 | } 444 | Err(e) => { 445 | // Propagate any errors encountered during the match process. 446 | return Err(e); 447 | } 448 | } 449 | Ok(incremented) 450 | } 451 | 452 | pub fn with_dom_and_dow(&mut self) -> &mut Self { 453 | self.pattern.with_dom_and_dow(); 454 | self 455 | } 456 | 457 | pub fn with_seconds_optional(&mut self) -> &mut Self { 458 | self.pattern.with_seconds_optional(); 459 | self 460 | } 461 | 462 | pub fn with_seconds_required(&mut self) -> &mut Self { 463 | self.pattern.with_seconds_required(); 464 | self 465 | } 466 | 467 | pub fn with_alternative_weekdays(&mut self) -> &mut Self { 468 | self.pattern.with_alternative_weekdays(); 469 | self 470 | } 471 | 472 | pub fn as_str(&self) -> &str { 473 | self.pattern.as_str() 474 | } 475 | } 476 | 477 | impl std::fmt::Display for Cron { 478 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 479 | write!(f, "{}", self.pattern) 480 | } 481 | } 482 | 483 | // Enables creating a Cron instance from a string slice, returning a CronError if parsing fails. 484 | impl FromStr for Cron { 485 | type Err = CronError; 486 | 487 | fn from_str(cron_string: &str) -> Result { 488 | let res = Cron::new(cron_string); 489 | Ok(res) 490 | } 491 | } 492 | 493 | #[cfg(feature = "serde")] 494 | impl Serialize for Cron { 495 | fn serialize(&self, serializer: S) -> Result 496 | where 497 | S: Serializer, 498 | { 499 | serializer.serialize_str(self.pattern.as_str()) 500 | } 501 | } 502 | 503 | #[cfg(feature = "serde")] 504 | impl<'de> Deserialize<'de> for Cron { 505 | fn deserialize(deserializer: D) -> Result 506 | where 507 | D: de::Deserializer<'de>, 508 | { 509 | struct CronVisitor; 510 | 511 | impl Visitor<'_> for CronVisitor { 512 | type Value = Cron; 513 | 514 | fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 515 | formatter.write_str("a valid cron pattern") 516 | } 517 | 518 | fn visit_str(self, value: &str) -> Result 519 | where 520 | E: de::Error, 521 | { 522 | Cron::new(value).parse().map_err(de::Error::custom) 523 | } 524 | } 525 | 526 | deserializer.deserialize_str(CronVisitor) 527 | } 528 | } 529 | 530 | // Recursive function to handle setting the time and managing overflows. 531 | #[allow(clippy::too_many_arguments)] 532 | fn set_time( 533 | current_time: &mut NaiveDateTime, 534 | year: i32, 535 | month: u32, 536 | day: u32, 537 | hour: u32, 538 | minute: u32, 539 | second: u32, 540 | component: TimeComponent, 541 | ) -> Result<(), CronError> { 542 | // First, try creating a NaiveDate and NaiveTime 543 | match ( 544 | NaiveDate::from_ymd_opt(year, month, day), 545 | NaiveTime::from_hms_opt(hour, minute, second), 546 | ) { 547 | (Some(date), Some(time)) => { 548 | // Combine date and time into NaiveDateTime 549 | *current_time = date.and_time(time); 550 | Ok(()) 551 | } 552 | _ => { 553 | // Handle invalid date or overflow by incrementing the next higher component. 554 | match component { 555 | TimeComponent::Second => set_time( 556 | current_time, 557 | year, 558 | month, 559 | day, 560 | hour, 561 | minute + 1, 562 | 0, 563 | TimeComponent::Minute, 564 | ), 565 | TimeComponent::Minute => set_time( 566 | current_time, 567 | year, 568 | month, 569 | day, 570 | hour + 1, 571 | 0, 572 | 0, 573 | TimeComponent::Hour, 574 | ), 575 | TimeComponent::Hour => set_time( 576 | current_time, 577 | year, 578 | month, 579 | day + 1, 580 | 0, 581 | 0, 582 | 0, 583 | TimeComponent::Day, 584 | ), 585 | TimeComponent::Day => set_time( 586 | current_time, 587 | year, 588 | month + 1, 589 | 1, 590 | 0, 591 | 0, 592 | 0, 593 | TimeComponent::Month, 594 | ), 595 | TimeComponent::Month => { 596 | set_time(current_time, year + 1, 1, 1, 0, 0, 0, TimeComponent::Year) 597 | } 598 | TimeComponent::Year => Err(CronError::InvalidDate), 599 | } 600 | } 601 | } 602 | } 603 | 604 | fn set_time_component( 605 | current_time: &mut NaiveDateTime, 606 | component: TimeComponent, 607 | set_to: u32, 608 | ) -> Result<(), CronError> { 609 | // Extract all parts 610 | let (year, month, day, hour, minute, _second) = ( 611 | current_time.year(), 612 | current_time.month(), 613 | current_time.day(), 614 | current_time.hour(), 615 | current_time.minute(), 616 | current_time.second(), 617 | ); 618 | 619 | match component { 620 | TimeComponent::Year => set_time(current_time, set_to as i32, 0, 0, 0, 0, 0, component), 621 | TimeComponent::Month => set_time(current_time, year, set_to, 0, 0, 0, 0, component), 622 | TimeComponent::Day => set_time(current_time, year, month, set_to, 0, 0, 0, component), 623 | TimeComponent::Hour => set_time(current_time, year, month, day, set_to, 0, 0, component), 624 | TimeComponent::Minute => { 625 | set_time(current_time, year, month, day, hour, set_to, 0, component) 626 | } 627 | TimeComponent::Second => set_time( 628 | current_time, 629 | year, 630 | month, 631 | day, 632 | hour, 633 | minute, 634 | set_to, 635 | component, 636 | ), 637 | } 638 | } 639 | 640 | // Convert `NaiveDateTime` back to `DateTime` 641 | pub fn from_naive( 642 | naive_time: NaiveDateTime, 643 | timezone: &Tz, 644 | ) -> Result, CronError> { 645 | match timezone.from_local_datetime(&naive_time) { 646 | chrono::LocalResult::Single(dt) => Ok(dt), 647 | _ => Err(CronError::InvalidTime), 648 | } 649 | } 650 | 651 | fn increment_time_component( 652 | current_time: &mut NaiveDateTime, 653 | component: TimeComponent, 654 | ) -> Result<(), CronError> { 655 | // Check for time overflow 656 | if current_time.year() >= YEAR_UPPER_LIMIT { 657 | return Err(CronError::TimeSearchLimitExceeded); 658 | } 659 | 660 | // Extract all parts 661 | let (year, month, day, hour, minute, second) = ( 662 | current_time.year(), 663 | current_time.month(), 664 | current_time.day(), 665 | current_time.hour(), 666 | current_time.minute(), 667 | current_time.second(), 668 | ); 669 | 670 | // Increment the component and try to set the new time. 671 | match component { 672 | TimeComponent::Year => set_time(current_time, year + 1, 1, 1, 0, 0, 0, component), 673 | TimeComponent::Month => set_time(current_time, year, month + 1, 1, 0, 0, 0, component), 674 | TimeComponent::Day => set_time(current_time, year, month, day + 1, 0, 0, 0, component), 675 | TimeComponent::Hour => set_time(current_time, year, month, day, hour + 1, 0, 0, component), 676 | TimeComponent::Minute => set_time( 677 | current_time, 678 | year, 679 | month, 680 | day, 681 | hour, 682 | minute + 1, 683 | 0, 684 | component, 685 | ), 686 | TimeComponent::Second => set_time( 687 | current_time, 688 | year, 689 | month, 690 | day, 691 | hour, 692 | minute, 693 | second + 1, 694 | component, 695 | ), 696 | } 697 | } 698 | 699 | #[cfg(test)] 700 | mod tests { 701 | use super::*; 702 | use chrono::{Local, TimeZone}; 703 | #[cfg(feature = "serde")] 704 | use serde_test::{assert_de_tokens_error, assert_tokens, Token}; 705 | #[test] 706 | fn test_is_time_matching() -> Result<(), CronError> { 707 | // This pattern is meant to match first second of 9 am on the first day of January. 708 | let cron = Cron::new("0 9 1 1 *").parse()?; 709 | let time_matching = Local.with_ymd_and_hms(2023, 1, 1, 9, 0, 0).unwrap(); 710 | let time_not_matching = Local.with_ymd_and_hms(2023, 1, 1, 10, 0, 0).unwrap(); 711 | 712 | assert!(cron.is_time_matching(&time_matching)?); 713 | assert!(!cron.is_time_matching(&time_not_matching)?); 714 | 715 | Ok(()) 716 | } 717 | 718 | #[test] 719 | fn test_last_day_of_february_non_leap_year() -> Result<(), CronError> { 720 | // This pattern is meant to match every second of 9 am on the last day of February in a non-leap year. 721 | let cron = Cron::new("0 9 L 2 *").parse()?; 722 | 723 | // February 28th, 2023 is the last day of February in a non-leap year. 724 | let time_matching = Local.with_ymd_and_hms(2023, 2, 28, 9, 0, 0).unwrap(); 725 | let time_not_matching = Local.with_ymd_and_hms(2023, 2, 28, 10, 0, 0).unwrap(); 726 | let time_not_matching_2 = Local.with_ymd_and_hms(2023, 2, 27, 9, 0, 0).unwrap(); 727 | 728 | assert!(cron.is_time_matching(&time_matching)?); 729 | assert!(!cron.is_time_matching(&time_not_matching)?); 730 | assert!(!cron.is_time_matching(&time_not_matching_2)?); 731 | 732 | Ok(()) 733 | } 734 | 735 | #[test] 736 | fn test_last_day_of_february_leap_year() -> Result<(), CronError> { 737 | // This pattern is meant to match every second of 9 am on the last day of February in a leap year. 738 | let cron = Cron::new("0 9 L 2 *").parse()?; 739 | 740 | // February 29th, 2024 is the last day of February in a leap year. 741 | let time_matching = Local.with_ymd_and_hms(2024, 2, 29, 9, 0, 0).unwrap(); 742 | let time_not_matching = Local.with_ymd_and_hms(2024, 2, 29, 10, 0, 0).unwrap(); 743 | let time_not_matching_2 = Local.with_ymd_and_hms(2024, 2, 28, 9, 0, 0).unwrap(); 744 | 745 | assert!(cron.is_time_matching(&time_matching)?); 746 | assert!(!cron.is_time_matching(&time_not_matching)?); 747 | assert!(!cron.is_time_matching(&time_not_matching_2)?); 748 | 749 | Ok(()) 750 | } 751 | 752 | #[test] 753 | fn test_last_friday_of_year() -> Result<(), CronError> { 754 | // This pattern is meant to match 0:00:00 last friday of current year 755 | let cron = Cron::new("0 0 * * FRI#L").parse()?; 756 | 757 | // February 29th, 2024 is the last day of February in a leap year. 758 | let time_matching = Local.with_ymd_and_hms(2023, 12, 29, 0, 0, 0).unwrap(); 759 | 760 | assert!(cron.is_time_matching(&time_matching)?); 761 | 762 | Ok(()) 763 | } 764 | 765 | #[test] 766 | fn test_last_friday_of_year_alternative_alpha_syntax() -> Result<(), CronError> { 767 | // This pattern is meant to match 0:00:00 last friday of current year 768 | let cron = Cron::new("0 0 * * FRIl").parse()?; 769 | 770 | // February 29th, 2024 is the last day of February in a leap year. 771 | let time_matching = Local.with_ymd_and_hms(2023, 12, 29, 0, 0, 0).unwrap(); 772 | 773 | assert!(cron.is_time_matching(&time_matching)?); 774 | 775 | Ok(()) 776 | } 777 | 778 | #[test] 779 | fn test_last_friday_of_year_alternative_number_syntax() -> Result<(), CronError> { 780 | // This pattern is meant to match 0:00:00 last friday of current year 781 | let cron = Cron::new("0 0 * * 5L").parse()?; 782 | 783 | // February 29th, 2024 is the last day of February in a leap year. 784 | let time_matching = Local.with_ymd_and_hms(2023, 12, 29, 0, 0, 0).unwrap(); 785 | 786 | assert!(cron.is_time_matching(&time_matching)?); 787 | 788 | Ok(()) 789 | } 790 | 791 | #[test] 792 | fn test_find_next_occurrence() -> Result<(), CronError> { 793 | // This pattern is meant to match every minute at 30 seconds past the minute. 794 | let cron = Cron::new("* * * * * *").with_seconds_optional().parse()?; 795 | 796 | // Set the start time to a known value. 797 | let start_time = Local.with_ymd_and_hms(2023, 1, 1, 0, 0, 29).unwrap(); 798 | // Calculate the next occurrence from the start time. 799 | let next_occurrence = cron.find_next_occurrence(&start_time, false)?; 800 | 801 | // Verify that the next occurrence is at the expected time. 802 | let expected_time = Local.with_ymd_and_hms(2023, 1, 1, 0, 0, 30).unwrap(); 803 | assert_eq!(next_occurrence, expected_time); 804 | 805 | Ok(()) 806 | } 807 | 808 | #[test] 809 | fn test_find_next_minute() -> Result<(), CronError> { 810 | let cron = Cron::new("* * * * *").parse()?; 811 | 812 | // Set the start time to a known value. 813 | let start_time = Local.with_ymd_and_hms(2023, 1, 1, 0, 0, 29).unwrap(); 814 | // Calculate the next occurrence from the start time. 815 | let next_occurrence = cron.find_next_occurrence(&start_time, false)?; 816 | 817 | // Verify that the next occurrence is at the expected time. 818 | let expected_time = Local.with_ymd_and_hms(2023, 1, 1, 0, 1, 0).unwrap(); 819 | assert_eq!(next_occurrence, expected_time); 820 | 821 | Ok(()) 822 | } 823 | 824 | #[test] 825 | fn test_wrap_month_and_year() -> Result<(), CronError> { 826 | // This pattern is meant to match every minute at 30 seconds past the minute. 827 | let cron = Cron::new("0 0 15 * * *").with_seconds_optional().parse()?; 828 | 829 | // Set the start time to a known value. 830 | let start_time = Local.with_ymd_and_hms(2023, 12, 31, 16, 0, 0).unwrap(); 831 | // Calculate the next occurrence from the start time. 832 | let next_occurrence = cron.find_next_occurrence(&start_time, false)?; 833 | 834 | // Verify that the next occurrence is at the expected time. 835 | let expected_time = Local.with_ymd_and_hms(2024, 1, 1, 15, 0, 0).unwrap(); 836 | assert_eq!(next_occurrence, expected_time); 837 | 838 | Ok(()) 839 | } 840 | 841 | #[test] 842 | fn test_weekday_pattern_correct_weekdays() -> Result<(), CronError> { 843 | let schedule = Cron::new("0 0 0 * * 5,6").with_seconds_optional().parse()?; 844 | let start_time = Local 845 | .with_ymd_and_hms(2022, 2, 17, 0, 0, 0) 846 | .single() 847 | .unwrap(); 848 | let mut next_runs = Vec::new(); 849 | 850 | for next in schedule.iter_after(start_time).take(6) { 851 | next_runs.push(next); 852 | } 853 | 854 | assert_eq!(next_runs[0].year(), 2022); 855 | assert_eq!(next_runs[0].month(), 2); 856 | assert_eq!(next_runs[0].day(), 18); 857 | 858 | assert_eq!(next_runs[1].day(), 19); 859 | assert_eq!(next_runs[2].day(), 25); 860 | assert_eq!(next_runs[3].day(), 26); 861 | 862 | assert_eq!(next_runs[4].month(), 3); 863 | assert_eq!(next_runs[4].day(), 4); 864 | assert_eq!(next_runs[5].day(), 5); 865 | 866 | Ok(()) 867 | } 868 | 869 | #[test] 870 | fn test_weekday_pattern_combined_with_day_of_month() -> Result<(), CronError> { 871 | let schedule = Cron::new("59 59 23 2 * 6") 872 | .with_seconds_optional() 873 | .parse()?; 874 | let start_time = Local 875 | .with_ymd_and_hms(2022, 1, 31, 0, 0, 0) 876 | .single() 877 | .unwrap(); 878 | let mut next_runs = Vec::new(); 879 | 880 | for next in schedule.iter_after(start_time).take(6) { 881 | next_runs.push(next); 882 | } 883 | 884 | assert_eq!(next_runs[0].year(), 2022); 885 | assert_eq!(next_runs[0].month(), 2); 886 | assert_eq!(next_runs[0].day(), 2); 887 | 888 | assert_eq!(next_runs[1].month(), 2); 889 | assert_eq!(next_runs[1].day(), 5); 890 | 891 | assert_eq!(next_runs[2].month(), 2); 892 | assert_eq!(next_runs[2].day(), 12); 893 | 894 | assert_eq!(next_runs[3].month(), 2); 895 | assert_eq!(next_runs[3].day(), 19); 896 | 897 | assert_eq!(next_runs[4].month(), 2); 898 | assert_eq!(next_runs[4].day(), 26); 899 | 900 | assert_eq!(next_runs[5].month(), 3); 901 | assert_eq!(next_runs[5].day(), 2); 902 | 903 | Ok(()) 904 | } 905 | 906 | #[test] 907 | fn test_weekday_pattern_alone() -> Result<(), CronError> { 908 | let schedule = Cron::new("15 9 * * mon").parse()?; 909 | let start_time = Local 910 | .with_ymd_and_hms(2022, 2, 28, 23, 59, 0) 911 | .single() 912 | .unwrap(); 913 | let mut next_runs = Vec::new(); 914 | 915 | for next in schedule.iter_after(start_time).take(3) { 916 | next_runs.push(next); 917 | } 918 | 919 | assert_eq!(next_runs[0].year(), 2022); 920 | assert_eq!(next_runs[0].month(), 3); 921 | assert_eq!(next_runs[0].day(), 7); 922 | assert_eq!(next_runs[0].hour(), 9); 923 | assert_eq!(next_runs[0].minute(), 15); 924 | 925 | assert_eq!(next_runs[1].day(), 14); 926 | assert_eq!(next_runs[1].hour(), 9); 927 | assert_eq!(next_runs[1].minute(), 15); 928 | 929 | assert_eq!(next_runs[2].day(), 21); 930 | assert_eq!(next_runs[2].hour(), 9); 931 | assert_eq!(next_runs[2].minute(), 15); 932 | 933 | Ok(()) 934 | } 935 | 936 | #[test] 937 | fn test_cron_expression_13w_wed() -> Result<(), CronError> { 938 | // Parse the cron expression 939 | let cron = Cron::new("0 0 13W * WED").parse()?; 940 | 941 | // Define the start date for the test 942 | let start_date = Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); 943 | 944 | // Define the expected matching dates 945 | let expected_dates = vec![ 946 | Local.with_ymd_and_hms(2024, 1, 3, 0, 0, 0).unwrap(), 947 | Local.with_ymd_and_hms(2024, 1, 10, 0, 0, 0).unwrap(), 948 | Local.with_ymd_and_hms(2024, 1, 12, 0, 0, 0).unwrap(), 949 | Local.with_ymd_and_hms(2024, 1, 17, 0, 0, 0).unwrap(), 950 | Local.with_ymd_and_hms(2024, 1, 24, 0, 0, 0).unwrap(), 951 | ]; 952 | 953 | // Iterate over the expected dates, checking each one 954 | let mut idx = 0; 955 | for current_date in cron.clone().iter_from(start_date).take(5) { 956 | assert_eq!(expected_dates[idx], current_date); 957 | idx = idx + 1; 958 | } 959 | 960 | Ok(()) 961 | } 962 | 963 | #[test] 964 | fn test_cron_expression_31dec_fri() -> Result<(), CronError> { 965 | // Parse the cron expression 966 | let cron = Cron::new("0 0 0 31 12 FRI") 967 | .with_seconds_required() 968 | .with_dom_and_dow() 969 | .parse()?; 970 | 971 | // Define the start date for the test 972 | let start_date = Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); 973 | 974 | // Define the expected matching dates 975 | let expected_dates = vec![ 976 | Local.with_ymd_and_hms(2027, 12, 31, 0, 0, 0).unwrap(), 977 | Local.with_ymd_and_hms(2032, 12, 31, 0, 0, 0).unwrap(), 978 | Local.with_ymd_and_hms(2038, 12, 31, 0, 0, 0).unwrap(), 979 | Local.with_ymd_and_hms(2049, 12, 31, 0, 0, 0).unwrap(), 980 | Local.with_ymd_and_hms(2055, 12, 31, 0, 0, 0).unwrap(), 981 | ]; 982 | 983 | // Iterate over the expected dates, checking each one 984 | let mut idx = 0; 985 | for current_date in cron.clone().iter_from(start_date).take(5) { 986 | assert_eq!(expected_dates[idx], current_date); 987 | idx = idx + 1; 988 | } 989 | 990 | Ok(()) 991 | } 992 | 993 | #[test] 994 | fn test_cron_parse_invalid_expressions() { 995 | let invalid_expressions = vec![ 996 | "* * *", 997 | "invalid", 998 | "123", 999 | "0 0 * * * * *", 1000 | "* * * *", 1001 | "* 60 * * * *", 1002 | "-1 59 * * * *", 1003 | "1- 59 * * * *", 1004 | "0 0 0 5L * *", 1005 | "0 0 0 5#L * *", 1006 | ]; 1007 | for expr in invalid_expressions { 1008 | assert!(Cron::new(expr).with_seconds_optional().parse().is_err()); 1009 | } 1010 | } 1011 | 1012 | #[test] 1013 | fn test_cron_parse_valid_expressions() { 1014 | let valid_expressions = vec![ 1015 | "* * * * *", 1016 | "0 0 * * *", 1017 | "*/10 * * * *", 1018 | "0 0 1 1 *", 1019 | "0 12 * * MON", 1020 | "0 0 * * 1", 1021 | "0 0 1 1,7 * ", 1022 | "00 00 01 * SUN ", 1023 | "0 0 1-7 * SUN", 1024 | "5-10/2 * * * *", 1025 | "0 0-23/2 * * *", 1026 | "0 12 15-21 * 1-FRI", 1027 | "0 0 29 2 *", 1028 | "0 0 31 * *", 1029 | "*/15 9-17 * * MON-FRI", 1030 | "0 12 * JAN-JUN *", 1031 | "0 0 1,15,L * SUN#L", 1032 | "0 0 2,1 1-6/2 *", 1033 | "0 0 5,L * 5L", 1034 | "0 0 5,L * 7#2", 1035 | ]; 1036 | for expr in valid_expressions { 1037 | assert!(Cron::new(expr).parse().is_ok()); 1038 | } 1039 | } 1040 | 1041 | #[test] 1042 | fn test_is_time_matching_different_time_zones() -> Result<(), CronError> { 1043 | use chrono::FixedOffset; 1044 | 1045 | let cron = Cron::new("0 12 * * *").parse()?; 1046 | let time_east_matching = FixedOffset::east_opt(3600) 1047 | .expect("Success") 1048 | .with_ymd_and_hms(2023, 1, 1, 12, 0, 0) 1049 | .unwrap(); // UTC+1 1050 | let time_west_matching = FixedOffset::west_opt(3600) 1051 | .expect("Success") 1052 | .with_ymd_and_hms(2023, 1, 1, 12, 0, 0) 1053 | .unwrap(); // UTC-1 1054 | 1055 | assert!(cron.is_time_matching(&time_east_matching)?); 1056 | assert!(cron.is_time_matching(&time_west_matching)?); 1057 | 1058 | Ok(()) 1059 | } 1060 | 1061 | #[test] 1062 | fn test_find_next_occurrence_edge_case_inclusive() -> Result<(), CronError> { 1063 | let cron = Cron::new("59 59 23 * * *") 1064 | .with_seconds_required() 1065 | .parse()?; 1066 | let start_time = Local.with_ymd_and_hms(2023, 3, 14, 23, 59, 59).unwrap(); 1067 | let next_occurrence = cron.find_next_occurrence(&start_time, true)?; 1068 | let expected_time = Local.with_ymd_and_hms(2023, 3, 14, 23, 59, 59).unwrap(); 1069 | assert_eq!(next_occurrence, expected_time); 1070 | Ok(()) 1071 | } 1072 | 1073 | #[test] 1074 | fn test_find_next_occurrence_edge_case_exclusive() -> Result<(), CronError> { 1075 | let cron = Cron::new("59 59 23 * * *") 1076 | .with_seconds_optional() 1077 | .parse()?; 1078 | let start_time = Local.with_ymd_and_hms(2023, 3, 14, 23, 59, 59).unwrap(); 1079 | let next_occurrence = cron.find_next_occurrence(&start_time, false)?; 1080 | let expected_time = Local.with_ymd_and_hms(2023, 3, 15, 23, 59, 59).unwrap(); 1081 | assert_eq!(next_occurrence, expected_time); 1082 | Ok(()) 1083 | } 1084 | 1085 | #[test] 1086 | fn test_cron_iterator_large_time_jumps() -> Result<(), CronError> { 1087 | let cron = Cron::new("0 0 * * *").parse()?; 1088 | let start_time = Local.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(); 1089 | let mut iterator = cron.iter_after(start_time); 1090 | let next_run = iterator.nth(365 * 5 + 1); // Jump 5 years ahead 1091 | let expected_time = Local.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(); 1092 | assert_eq!(next_run, Some(expected_time)); 1093 | Ok(()) 1094 | } 1095 | 1096 | #[test] 1097 | fn test_handling_different_month_lengths() -> Result<(), CronError> { 1098 | let cron = Cron::new("0 0 L * *").parse()?; // Last day of the month 1099 | let feb_non_leap_year = Local.with_ymd_and_hms(2023, 2, 1, 0, 0, 0).unwrap(); 1100 | let feb_leap_year = Local.with_ymd_and_hms(2024, 2, 1, 0, 0, 0).unwrap(); 1101 | let april = Local.with_ymd_and_hms(2023, 4, 1, 0, 0, 0).unwrap(); 1102 | 1103 | assert_eq!( 1104 | cron.find_next_occurrence(&feb_non_leap_year, false)?, 1105 | Local.with_ymd_and_hms(2023, 2, 28, 0, 0, 0).unwrap() 1106 | ); 1107 | assert_eq!( 1108 | cron.find_next_occurrence(&feb_leap_year, false)?, 1109 | Local.with_ymd_and_hms(2024, 2, 29, 0, 0, 0).unwrap() 1110 | ); 1111 | assert_eq!( 1112 | cron.find_next_occurrence(&april, false)?, 1113 | Local.with_ymd_and_hms(2023, 4, 30, 0, 0, 0).unwrap() 1114 | ); 1115 | 1116 | Ok(()) 1117 | } 1118 | 1119 | #[test] 1120 | fn test_cron_iterator_non_standard_intervals() -> Result<(), CronError> { 1121 | let cron = Cron::new("*/29 */13 * * * *") 1122 | .with_seconds_optional() 1123 | .parse()?; 1124 | let start_time = Local.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap(); 1125 | let mut iterator = cron.iter_after(start_time); 1126 | let first_run = iterator.next().unwrap(); 1127 | let second_run = iterator.next().unwrap(); 1128 | 1129 | assert_eq!(first_run.hour() % 13, 0); 1130 | assert_eq!(first_run.minute() % 29, 0); 1131 | assert_eq!(second_run.hour() % 13, 0); 1132 | assert_eq!(second_run.minute() % 29, 0); 1133 | 1134 | Ok(()) 1135 | } 1136 | 1137 | #[test] 1138 | fn test_cron_iterator_non_standard_intervals_with_offset() -> Result<(), CronError> { 1139 | let cron = Cron::new("7/29 2/13 * * *").parse()?; 1140 | let start_time = Local.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap(); 1141 | let mut iterator = cron.iter_after(start_time); 1142 | 1143 | let first_run = iterator.next().unwrap(); 1144 | // Expect the first run to be at 02:07 (2 hours and 7 minutes after midnight) 1145 | assert_eq!(first_run.hour(), 2); 1146 | assert_eq!(first_run.minute(), 7); 1147 | 1148 | let second_run = iterator.next().unwrap(); 1149 | // Expect the second run to be at 02:36 (29 minutes after the first run) 1150 | assert_eq!(second_run.hour(), 2); 1151 | assert_eq!(second_run.minute(), 36); 1152 | 1153 | Ok(()) 1154 | } 1155 | 1156 | // Unusual cron pattern found online, perfect for testing 1157 | #[test] 1158 | fn test_unusual_cron_expression_end_month_start_month_mon() -> Result<(), CronError> { 1159 | use chrono::TimeZone; 1160 | 1161 | // Parse the cron expression with specified options 1162 | let cron = Cron::new("0 0 */31,1-7 */1 MON").parse()?; 1163 | 1164 | // Define the start date for the test 1165 | let start_date = Local.with_ymd_and_hms(2023, 12, 24, 0, 0, 0).unwrap(); 1166 | 1167 | // Define the expected matching dates 1168 | let expected_dates = vec![ 1169 | Local.with_ymd_and_hms(2023, 12, 25, 0, 0, 0).unwrap(), 1170 | Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), 1171 | Local.with_ymd_and_hms(2024, 1, 2, 0, 0, 0).unwrap(), 1172 | Local.with_ymd_and_hms(2024, 1, 3, 0, 0, 0).unwrap(), 1173 | Local.with_ymd_and_hms(2024, 1, 4, 0, 0, 0).unwrap(), 1174 | Local.with_ymd_and_hms(2024, 1, 5, 0, 0, 0).unwrap(), 1175 | Local.with_ymd_and_hms(2024, 1, 6, 0, 0, 0).unwrap(), 1176 | Local.with_ymd_and_hms(2024, 1, 7, 0, 0, 0).unwrap(), 1177 | Local.with_ymd_and_hms(2024, 1, 8, 0, 0, 0).unwrap(), 1178 | Local.with_ymd_and_hms(2024, 1, 15, 0, 0, 0).unwrap(), 1179 | Local.with_ymd_and_hms(2024, 1, 22, 0, 0, 0).unwrap(), 1180 | Local.with_ymd_and_hms(2024, 1, 29, 0, 0, 0).unwrap(), 1181 | Local.with_ymd_and_hms(2024, 2, 1, 0, 0, 0).unwrap(), 1182 | ]; 1183 | 1184 | // Iterate over the expected dates, checking each one 1185 | let mut idx = 0; 1186 | for current_date in cron.iter_from(start_date).take(expected_dates.len()) { 1187 | assert_eq!(expected_dates[idx], current_date); 1188 | idx += 1; 1189 | } 1190 | 1191 | assert_eq!(idx, 13); 1192 | 1193 | Ok(()) 1194 | } 1195 | 1196 | // Unusual cron pattern found online, perfect for testing, with dom_and_dow 1197 | #[test] 1198 | fn test_unusual_cron_expression_end_month_start_month_mon_dom_and_dow() -> Result<(), CronError> 1199 | { 1200 | use chrono::TimeZone; 1201 | 1202 | // Parse the cron expression with specified options 1203 | let cron = Cron::new("0 0 */31,1-7 */1 MON") 1204 | .with_dom_and_dow() 1205 | .with_seconds_optional() // Just to differ as much from the non dom-and-dow test 1206 | .parse()?; 1207 | 1208 | // Define the start date for the test 1209 | let start_date = Local.with_ymd_and_hms(2023, 12, 24, 0, 0, 0).unwrap(); 1210 | 1211 | // Define the expected matching dates 1212 | let expected_dates = vec![ 1213 | Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(), 1214 | Local.with_ymd_and_hms(2024, 2, 5, 0, 0, 0).unwrap(), 1215 | Local.with_ymd_and_hms(2024, 3, 4, 0, 0, 0).unwrap(), 1216 | ]; 1217 | 1218 | // Iterate over the expected dates, checking each one 1219 | let mut idx = 0; 1220 | for current_date in cron.iter_from(start_date).take(expected_dates.len()) { 1221 | assert_eq!(expected_dates[idx], current_date); 1222 | idx += 1; 1223 | } 1224 | 1225 | assert_eq!(idx, 3); 1226 | 1227 | Ok(()) 1228 | } 1229 | 1230 | #[test] 1231 | fn test_cron_expression_29feb_march_fri() -> Result<(), CronError> { 1232 | use chrono::TimeZone; 1233 | 1234 | // Parse the cron expression with specified options 1235 | let cron = Cron::new("0 0 29 2-3 FRI") 1236 | .with_dom_and_dow() 1237 | .with_seconds_optional() 1238 | .parse()?; 1239 | 1240 | // Define the start date for the test 1241 | let start_date = Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); 1242 | 1243 | // Define the expected matching dates 1244 | let expected_dates = vec![ 1245 | Local.with_ymd_and_hms(2024, 3, 29, 0, 0, 0).unwrap(), 1246 | Local.with_ymd_and_hms(2030, 3, 29, 0, 0, 0).unwrap(), 1247 | Local.with_ymd_and_hms(2036, 2, 29, 0, 0, 0).unwrap(), 1248 | Local.with_ymd_and_hms(2041, 3, 29, 0, 0, 0).unwrap(), 1249 | Local.with_ymd_and_hms(2047, 3, 29, 0, 0, 0).unwrap(), 1250 | ]; 1251 | 1252 | // Iterate over the expected dates, checking each one 1253 | let mut idx = 0; 1254 | for current_date in cron.iter_from(start_date).take(5) { 1255 | assert_eq!(expected_dates[idx], current_date); 1256 | idx += 1; 1257 | } 1258 | 1259 | assert_eq!(idx, 5); 1260 | 1261 | Ok(()) 1262 | } 1263 | 1264 | #[test] 1265 | fn test_cron_expression_second_sunday_using_seven() -> Result<(), CronError> { 1266 | use chrono::TimeZone; 1267 | 1268 | // Parse the cron expression with specified options 1269 | let cron = Cron::new("0 0 0 * * 7#2").with_seconds_optional().parse()?; 1270 | 1271 | // Define the start date for the test 1272 | let start_date = Local.with_ymd_and_hms(2024, 10, 1, 0, 0, 0).unwrap(); 1273 | 1274 | // Define the expected matching dates 1275 | let expected_dates = vec![ 1276 | Local.with_ymd_and_hms(2024, 10, 13, 0, 0, 0).unwrap(), 1277 | Local.with_ymd_and_hms(2024, 11, 10, 0, 0, 0).unwrap(), 1278 | Local.with_ymd_and_hms(2024, 12, 8, 0, 0, 0).unwrap(), 1279 | Local.with_ymd_and_hms(2025, 1, 12, 0, 0, 0).unwrap(), 1280 | Local.with_ymd_and_hms(2025, 2, 9, 0, 0, 0).unwrap(), 1281 | ]; 1282 | 1283 | // Iterate over the expected dates, checking each one 1284 | let mut idx = 0; 1285 | for current_date in cron.iter_from(start_date).take(5) { 1286 | assert_eq!(expected_dates[idx], current_date); 1287 | idx += 1; 1288 | } 1289 | 1290 | assert_eq!(idx, 5); 1291 | 1292 | Ok(()) 1293 | } 1294 | 1295 | #[test] 1296 | fn test_specific_and_wildcard_entries() -> Result<(), CronError> { 1297 | let cron = Cron::new("15 */2 * 3,5 FRI").parse()?; 1298 | let matching_time = Local.with_ymd_and_hms(2023, 3, 3, 2, 15, 0).unwrap(); 1299 | let non_matching_time = Local.with_ymd_and_hms(2023, 3, 3, 3, 15, 0).unwrap(); 1300 | 1301 | assert!(cron.is_time_matching(&matching_time)?); 1302 | assert!(!cron.is_time_matching(&non_matching_time)?); 1303 | 1304 | Ok(()) 1305 | } 1306 | 1307 | #[test] 1308 | fn test_month_weekday_edge_cases() -> Result<(), CronError> { 1309 | let cron = Cron::new("0 0 * 2-3 SUN").parse()?; 1310 | 1311 | let matching_time = Local.with_ymd_and_hms(2023, 2, 5, 0, 0, 0).unwrap(); 1312 | let non_matching_time = Local.with_ymd_and_hms(2023, 2, 5, 0, 0, 1).unwrap(); 1313 | 1314 | assert!(cron.is_time_matching(&matching_time)?); 1315 | assert!(!cron.is_time_matching(&non_matching_time)?); 1316 | 1317 | Ok(()) 1318 | } 1319 | 1320 | #[test] 1321 | fn test_leap_year() -> Result<(), CronError> { 1322 | let cron = Cron::new("0 0 29 2 *").parse()?; 1323 | let leap_year_matching = Local.with_ymd_and_hms(2024, 2, 29, 0, 0, 0).unwrap(); 1324 | 1325 | assert!(cron.is_time_matching(&leap_year_matching)?); 1326 | 1327 | Ok(()) 1328 | } 1329 | 1330 | #[test] 1331 | fn test_time_overflow() -> Result<(), CronError> { 1332 | let cron_match = Cron::new("59 59 23 31 12 *") 1333 | .with_seconds_optional() 1334 | .parse()?; 1335 | let cron_next = Cron::new("0 0 0 1 1 *").with_seconds_optional().parse()?; 1336 | let time_matching = Local.with_ymd_and_hms(2023, 12, 31, 23, 59, 59).unwrap(); 1337 | let next_day = Local.with_ymd_and_hms(2024, 1, 1, 0, 0, 0).unwrap(); 1338 | let next_match = Local.with_ymd_and_hms(2024, 12, 31, 23, 59, 59).unwrap(); 1339 | 1340 | let is_matching = cron_match.is_time_matching(&time_matching)?; 1341 | let next_occurrence = cron_next.find_next_occurrence(&time_matching, false)?; 1342 | let next_match_occurrence = cron_match.find_next_occurrence(&time_matching, false)?; 1343 | 1344 | assert!(is_matching); 1345 | assert_eq!(next_occurrence, next_day); 1346 | assert_eq!(next_match_occurrence, next_match); 1347 | 1348 | Ok(()) 1349 | } 1350 | 1351 | #[test] 1352 | fn test_yearly_recurrence() -> Result<(), CronError> { 1353 | let cron = Cron::new("0 0 1 1 *").parse()?; 1354 | let matching_time = Local.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap(); 1355 | let non_matching_time = Local.with_ymd_and_hms(2023, 1, 2, 0, 0, 0).unwrap(); 1356 | 1357 | assert!(cron.is_time_matching(&matching_time)?); 1358 | assert!(!cron.is_time_matching(&non_matching_time)?); 1359 | 1360 | Ok(()) 1361 | } 1362 | 1363 | #[cfg(feature = "serde")] 1364 | #[test] 1365 | fn test_serde_tokens() { 1366 | let cron = Cron::new("0 0 * * *") 1367 | .parse() 1368 | .expect("should be valid pattern"); 1369 | assert_tokens(&cron.to_string(), &[Token::Str("0 0 * * *")]); 1370 | } 1371 | 1372 | #[cfg(feature = "serde")] 1373 | #[test] 1374 | fn test_shorthand_serde_tokens() { 1375 | let expressions = [ 1376 | ("@daily", "0 0 * * *"), 1377 | ("0 12 * * MON", "0 12 * * 1"), 1378 | ("*/15 9-17 * * MON-FRI", "*/15 9-17 * * 1-5"), 1379 | ]; 1380 | for (shorthand, expected) in expressions.iter() { 1381 | let cron = Cron::new(shorthand) 1382 | .parse() 1383 | .expect("should be valid pattern"); 1384 | assert_tokens(&cron.to_string(), &[Token::Str(expected)]); 1385 | } 1386 | } 1387 | 1388 | #[cfg(feature = "serde")] 1389 | #[test] 1390 | fn test_invalid_serde_tokens() { 1391 | assert_de_tokens_error::( 1392 | &[Token::Str("Invalid cron pattern")], 1393 | "Invalid pattern: Pattern must consist of five or six fields (minute, hour, day, month, day of week, and optional second)." 1394 | ); 1395 | } 1396 | } 1397 | -------------------------------------------------------------------------------- /src/pattern.rs: -------------------------------------------------------------------------------- 1 | use crate::component::{ 2 | CronComponent, ALL_BIT, CLOSEST_WEEKDAY_BIT, LAST_BIT, NONE_BIT, NTH_1ST_BIT, NTH_2ND_BIT, 3 | NTH_3RD_BIT, NTH_4TH_BIT, NTH_5TH_BIT, NTH_ALL, 4 | }; 5 | use crate::errors::CronError; 6 | use chrono::{Datelike, Duration, NaiveDate, Weekday}; 7 | 8 | // This struct is used for representing and validating cron pattern strings. 9 | // It supports parsing cron patterns with optional seconds field and provides functionality to check pattern matching against specific datetime. 10 | #[derive(Debug, Clone)] 11 | pub struct CronPattern { 12 | pattern: String, // The original pattern 13 | // 14 | pub seconds: CronComponent, // - 15 | pub minutes: CronComponent, // -- 16 | pub hours: CronComponent, // --- Each individual part of the cron expression 17 | pub days: CronComponent, // --- represented by a bitmask, min and max value 18 | pub months: CronComponent, // -- 19 | pub days_of_week: CronComponent, // - 20 | 21 | star_dom: bool, 22 | star_dow: bool, 23 | 24 | // Options 25 | pub dom_and_dow: bool, // Setting to alter how dom_and_dow is combined 26 | pub with_seconds_optional: bool, // Setting to alter if seconds (6-part patterns) are allowed or not 27 | pub with_seconds_required: bool, // Setting to alter if seconds (6-part patterns) are required or not 28 | pub with_alternative_weekdays: bool, // Setting to alter if weekdays are offset by one or not 29 | 30 | // Status 31 | is_parsed: bool, 32 | } 33 | 34 | // Implementation block for CronPattern struct, providing methods for creating and parsing cron pattern strings. 35 | impl CronPattern { 36 | pub fn new(pattern: &str) -> Self { 37 | Self { 38 | pattern: pattern.to_string(), 39 | seconds: CronComponent::new(0, 59, NONE_BIT, 0), 40 | minutes: CronComponent::new(0, 59, NONE_BIT, 0), 41 | hours: CronComponent::new(0, 23, NONE_BIT, 0), 42 | days: CronComponent::new(1, 31, LAST_BIT | CLOSEST_WEEKDAY_BIT, 0), // Special bit LAST_BIT is available 43 | months: CronComponent::new(1, 12, NONE_BIT, 0), 44 | days_of_week: CronComponent::new(0, 7, LAST_BIT | NTH_ALL, 0), // Actually 0-7 in pattern, 7 is converted to 0 in POSIX mode 45 | star_dom: false, 46 | star_dow: false, 47 | 48 | // Options 49 | dom_and_dow: false, 50 | with_seconds_optional: false, 51 | with_seconds_required: false, 52 | with_alternative_weekdays: false, 53 | 54 | // Status 55 | is_parsed: false, 56 | } 57 | } 58 | 59 | // Parses the cron pattern string into its respective fields. 60 | // Handles optional seconds field, named shortcuts, and determines if 'L' flag is used for last day of the month. 61 | pub fn parse(&mut self) -> Result { 62 | if self.pattern.trim().is_empty() { 63 | return Err(CronError::EmptyPattern); 64 | } 65 | 66 | // Replace any '?' with '*' in the cron pattern 67 | self.pattern = self.pattern.replace('?', "*"); 68 | 69 | // Handle @nicknames 70 | if self.pattern.contains('@') { 71 | self.pattern = Self::handle_nicknames(&self.pattern, self.with_seconds_required) 72 | .trim() 73 | .to_string(); 74 | } 75 | 76 | // Handle day-of-week and month aliases (MON... and JAN...) 77 | self.pattern = Self::replace_alpha_weekdays(&self.pattern, self.with_alternative_weekdays) 78 | .trim() 79 | .to_string(); 80 | self.pattern = Self::replace_alpha_months(&self.pattern).trim().to_string(); 81 | 82 | // Check that the pattern contains 5 or 6 parts 83 | let mut parts: Vec<&str> = self.pattern.split_whitespace().collect(); 84 | if parts.len() < 5 || parts.len() > 6 { 85 | return Err(CronError::InvalidPattern(String::from("Pattern must consist of five or six fields (minute, hour, day, month, day of week, and optional second)."))); 86 | } 87 | 88 | // Error if there is five parts and seconds are required 89 | if parts.len() == 5 && self.with_seconds_required { 90 | return Err(CronError::InvalidPattern(String::from( 91 | "Pattern must consist of six fields, seconds can not be omitted.", 92 | ))); 93 | } 94 | 95 | // Error if there is six parts and seconds are disallowed 96 | if parts.len() == 6 && !(self.with_seconds_optional || self.with_seconds_required) { 97 | return Err(CronError::InvalidPattern(String::from( 98 | "Pattern must consist of five fields, seconds are not allowed by configuration.", 99 | ))); 100 | } 101 | 102 | // Default seconds to "0" if omitted 103 | if parts.len() == 5 { 104 | parts.insert(0, "0"); // prepend "0" if the seconds part is missing 105 | 106 | // Error it there is an extra part and seconds are not allowed 107 | } 108 | 109 | // Handle star-dom and star-dow 110 | self.star_dom = parts[3].trim() == "*"; 111 | self.star_dow = parts[5].trim() == "*"; 112 | 113 | // Parse the individual components 114 | self.seconds.parse(parts[0])?; 115 | self.minutes.parse(parts[1])?; 116 | self.hours.parse(parts[2])?; 117 | self.days.parse(parts[3])?; 118 | self.months.parse(parts[4])?; 119 | self.days_of_week.parse(parts[5])?; 120 | 121 | // Handle conversion of 7 to 0 for day_of_week if necessary 122 | // this has to be done last because range could be 6-7 (sat-sun) 123 | if !self.with_alternative_weekdays { 124 | for nth_bit in [ 125 | ALL_BIT, 126 | NTH_1ST_BIT, 127 | NTH_2ND_BIT, 128 | NTH_3RD_BIT, 129 | NTH_4TH_BIT, 130 | NTH_5TH_BIT, 131 | ] { 132 | if self.days_of_week.is_bit_set(7, nth_bit)? { 133 | self.days_of_week.unset_bit(7, nth_bit)?; 134 | self.days_of_week.set_bit(0, nth_bit)?; 135 | } 136 | } 137 | } 138 | 139 | // Success! 140 | self.is_parsed = true; 141 | Ok(self.clone()) 142 | } 143 | 144 | // Validates that the cron pattern only contains legal characters for each field. 145 | // - ? is replaced with * before parsing, so it does not need to be included 146 | pub fn throw_at_illegal_characters(&self, parts: &[&str]) -> Result<(), CronError> { 147 | let base_allowed_characters = [ 148 | '*', '/', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ',', '-', 149 | ]; 150 | let day_of_week_additional_characters = ['#', 'W']; 151 | let day_of_month_additional_characters = ['L']; 152 | 153 | for (i, part) in parts.iter().enumerate() { 154 | // Decide which set of allowed characters to use 155 | let allowed = match i { 156 | 5 => [ 157 | base_allowed_characters.as_ref(), 158 | day_of_week_additional_characters.as_ref(), 159 | ] 160 | .concat(), 161 | 3 => [ 162 | base_allowed_characters.as_ref(), 163 | day_of_month_additional_characters.as_ref(), 164 | ] 165 | .concat(), 166 | _ => base_allowed_characters.to_vec(), 167 | }; 168 | 169 | for ch in part.chars() { 170 | if !allowed.contains(&ch) { 171 | return Err(CronError::IllegalCharacters(String::from( 172 | "CronPattern contains illegal characters.", 173 | ))); 174 | } 175 | } 176 | } 177 | 178 | Ok(()) 179 | } 180 | 181 | // Converts named cron pattern shortcuts like '@daily' into their equivalent standard cron pattern. 182 | fn handle_nicknames(pattern: &str, with_seconds_required: bool) -> String { 183 | let pattern = pattern.trim(); 184 | 185 | // Closure for case-insensitive comparison 186 | let eq_ignore_case = |a: &str, b: &str| a.eq_ignore_ascii_case(b); 187 | 188 | let base_pattern = match pattern { 189 | p if eq_ignore_case(p, "@yearly") || eq_ignore_case(p, "@annually") => "0 0 1 1 *", 190 | p if eq_ignore_case(p, "@monthly") => "0 0 1 * *", 191 | p if eq_ignore_case(p, "@weekly") => "0 0 * * 0", 192 | p if eq_ignore_case(p, "@daily") => "0 0 * * *", 193 | p if eq_ignore_case(p, "@hourly") => "0 * * * *", 194 | _ => pattern, 195 | }; 196 | 197 | if with_seconds_required { 198 | format!("0 {}", base_pattern) 199 | } else { 200 | base_pattern.to_string() 201 | } 202 | } 203 | 204 | // Converts day-of-week nicknames into their equivalent standard cron pattern. 205 | fn replace_alpha_weekdays(pattern: &str, alternative_weekdays: bool) -> String { 206 | // Day-of-week nicknames to their numeric values. 207 | let nicknames = if !alternative_weekdays { 208 | [ 209 | ("-sun", "-7"), // Use 7 for upper range sunday 210 | ("sun", "0"), 211 | ("mon", "1"), 212 | ("tue", "2"), 213 | ("wed", "3"), 214 | ("thu", "4"), 215 | ("fri", "5"), 216 | ("sat", "6"), 217 | ] 218 | } else { 219 | [ 220 | ("-sun", "-1"), 221 | ("sun", "1"), 222 | ("mon", "2"), 223 | ("tue", "3"), 224 | ("wed", "4"), 225 | ("thu", "5"), 226 | ("fri", "6"), 227 | ("sat", "7"), 228 | ] 229 | }; 230 | 231 | let mut replaced = pattern.trim().to_lowercase(); 232 | 233 | // Replace nicknames with their numeric values 234 | for &(nickname, value) in &nicknames { 235 | replaced = replaced.replace(nickname, value); 236 | } 237 | 238 | replaced 239 | } 240 | 241 | // Converts month nicknames into their equivalent standard cron pattern. 242 | fn replace_alpha_months(pattern: &str) -> String { 243 | // Day-of-week nicknames to their numeric values. 244 | let nicknames = [ 245 | ("jan", "1"), 246 | ("feb", "2"), 247 | ("mar", "3"), 248 | ("apr", "4"), 249 | ("may", "5"), 250 | ("jun", "6"), 251 | ("jul", "7"), 252 | ("aug", "8"), 253 | ("sep", "9"), 254 | ("oct", "10"), 255 | ("nov", "11"), 256 | ("dec", "12"), 257 | ]; 258 | 259 | let mut replaced = pattern.trim().to_lowercase(); 260 | 261 | // Replace nicknames with their numeric values 262 | for &(nickname, value) in &nicknames { 263 | replaced = replaced.replace(nickname, value); 264 | } 265 | 266 | replaced 267 | } 268 | 269 | // Additional method needed to determine the nth weekday of the month 270 | fn is_nth_weekday_of_month(date: chrono::NaiveDate, nth: u8, weekday: Weekday) -> bool { 271 | let mut count = 0; 272 | let mut current = date 273 | .with_day(1) 274 | .expect("Invalid date encountered while setting to first of the month"); 275 | while current.month() == date.month() { 276 | if current.weekday() == weekday { 277 | count += 1; 278 | if count == nth { 279 | return current.day() == date.day(); 280 | } 281 | } 282 | current += chrono::Duration::days(1); 283 | } 284 | false 285 | } 286 | 287 | // This method checks if a given year, month, and day match the day part of the cron pattern. 288 | pub fn day_match(&self, year: i32, month: u32, day: u32) -> Result { 289 | // First, check if the day is within the valid range 290 | if day == 0 || day > 31 || month == 0 || month > 12 { 291 | return Err(CronError::InvalidDate); 292 | } 293 | 294 | // Convert year, month, and day into a date object to work with 295 | let date = 296 | chrono::NaiveDate::from_ymd_opt(year, month, day).ok_or(CronError::InvalidDate)?; 297 | 298 | let mut day_matches = self.days.is_bit_set(day as u8, ALL_BIT)?; 299 | let mut dow_matches = false; 300 | 301 | // If the 'L' flag is used, we need to check if the given day is the last day of the month 302 | if !day_matches && self.days.is_feature_enabled(LAST_BIT) { 303 | let last_day = CronPattern::last_day_of_month(year, month)?; 304 | if !day_matches && day == last_day { 305 | day_matches = true; 306 | } 307 | } 308 | 309 | // Make an extra check if any adjacent day is matching through the closest-weekday flag 310 | if !day_matches && self.closest_weekday(year, month, day)? { 311 | day_matches = true; 312 | } 313 | 314 | // Check for nth weekday of the month flags 315 | for nth in 1..=5 { 316 | let nth_bit = match nth { 317 | 1 => NTH_1ST_BIT, 318 | 2 => NTH_2ND_BIT, 319 | 3 => NTH_3RD_BIT, 320 | 4 => NTH_4TH_BIT, 321 | 5 => NTH_5TH_BIT, 322 | _ => continue, // We have already validated that nth is between 1 and 5 323 | }; 324 | if self 325 | .days_of_week 326 | .is_bit_set(date.weekday().num_days_from_sunday() as u8, nth_bit)? 327 | && CronPattern::is_nth_weekday_of_month(date, nth, date.weekday()) 328 | { 329 | dow_matches = true; 330 | break; 331 | } 332 | } 333 | 334 | // If the 'L' flag is used for the day of the week, check if it's the last one of the month 335 | if !dow_matches 336 | && self 337 | .days_of_week 338 | .is_bit_set(date.weekday().num_days_from_sunday() as u8, LAST_BIT)? 339 | { 340 | let next_weekday = date + chrono::Duration::days(7); 341 | if !dow_matches && next_weekday.month() != date.month() { 342 | // If adding 7 days changes the month, then it is the last occurrence of the day of the week 343 | dow_matches = true; 344 | } 345 | } 346 | 347 | // Check if the specific day of the week is set in the bitset 348 | // Note: In chrono, Sunday is 0, Monday is 1, and so on... 349 | let day_of_week = date.weekday().num_days_from_sunday() as u8; // Adjust as necessary for your bitset 350 | dow_matches = dow_matches || self.days_of_week.is_bit_set(day_of_week, ALL_BIT)?; 351 | 352 | // The day matches if it's set in the days bitset or the days of the week bitset 353 | if (day_matches && self.star_dow) || (dow_matches && self.star_dom) { 354 | Ok(true) 355 | } else if !self.star_dom && !self.star_dow { 356 | if !self.dom_and_dow { 357 | Ok(day_matches || dow_matches) 358 | } else { 359 | Ok(day_matches && dow_matches) 360 | } 361 | } else { 362 | Ok(false) 363 | } 364 | } 365 | 366 | // Helper function to find the last day of a given month 367 | fn last_day_of_month(year: i32, month: u32) -> Result { 368 | if month == 0 || month > 12 { 369 | return Err(CronError::InvalidDate); 370 | } 371 | 372 | // Create a date that should be the first day of the next month 373 | let next_month_year = if month == 12 { year + 1 } else { year }; 374 | let next_month = if month == 12 { 1 } else { month + 1 }; 375 | 376 | let next_month_date = NaiveDate::from_ymd_opt(next_month_year, next_month, 1) 377 | .ok_or(CronError::InvalidDate)?; 378 | 379 | // Subtract one day to get the last day of the given month 380 | let last_day_date = next_month_date 381 | .checked_sub_signed(Duration::days(1)) 382 | .ok_or(CronError::InvalidDate)?; 383 | 384 | // Return only the day 385 | Ok(last_day_date.day()) 386 | } 387 | 388 | pub fn closest_weekday(&self, year: i32, month: u32, day: u32) -> Result { 389 | let candidate_date = 390 | NaiveDate::from_ymd_opt(year, month, day).ok_or(CronError::InvalidDate)?; 391 | let weekday = candidate_date.weekday(); 392 | 393 | // Only check weekdays 394 | if weekday != Weekday::Sat && weekday != Weekday::Sun { 395 | // Check if the current day has the CLOSEST_WEEKDAY_BIT set 396 | if self.days.is_bit_set(day as u8, CLOSEST_WEEKDAY_BIT)? { 397 | return Ok(true); 398 | } 399 | 400 | // Check the previous and next days if the current day is a weekday 401 | let previous_day = candidate_date - Duration::days(1); 402 | let next_day = candidate_date + Duration::days(1); 403 | 404 | let check_previous = previous_day.weekday() == Weekday::Sun 405 | && self 406 | .days 407 | .is_bit_set(previous_day.day() as u8, CLOSEST_WEEKDAY_BIT)?; 408 | let check_next = next_day.weekday() == Weekday::Sat 409 | && self 410 | .days 411 | .is_bit_set(next_day.day() as u8, CLOSEST_WEEKDAY_BIT)?; 412 | if check_previous || check_next { 413 | return Ok(true); 414 | } 415 | } 416 | 417 | Ok(false) 418 | } 419 | 420 | // Checks if a given month matches the month part of the cron pattern. 421 | pub fn month_match(&self, month: u32) -> Result { 422 | if month == 0 || month > 12 { 423 | return Err(CronError::InvalidDate); 424 | } 425 | self.months.is_bit_set(month as u8, ALL_BIT) 426 | } 427 | 428 | // Checks if a given hour matches the hour part of the cron pattern. 429 | pub fn hour_match(&self, hour: u32) -> Result { 430 | if hour > 23 { 431 | return Err(CronError::InvalidTime); 432 | } 433 | self.hours.is_bit_set(hour as u8, ALL_BIT) 434 | } 435 | 436 | // Checks if a given minute matches the minute part of the cron pattern. 437 | pub fn minute_match(&self, minute: u32) -> Result { 438 | if minute > 59 { 439 | return Err(CronError::InvalidTime); 440 | } 441 | self.minutes.is_bit_set(minute as u8, ALL_BIT) 442 | } 443 | 444 | // Checks if a given second matches the second part of the cron pattern. 445 | pub fn second_match(&self, second: u32) -> Result { 446 | if second > 59 { 447 | return Err(CronError::InvalidTime); 448 | } 449 | self.seconds.is_bit_set(second as u8, ALL_BIT) 450 | } 451 | 452 | // Finds the next hour that matches the hour part of the cron pattern. 453 | pub fn next_hour_match(&self, hour: u32) -> Result, CronError> { 454 | if hour > 23 { 455 | return Err(CronError::InvalidTime); 456 | } 457 | for next_hour in hour..=23 { 458 | if self.hours.is_bit_set(next_hour as u8, ALL_BIT)? { 459 | return Ok(Some(next_hour)); 460 | } 461 | } 462 | Ok(None) // No match found within the current range 463 | } 464 | 465 | // Finds the next minute that matches the minute part of the cron pattern. 466 | pub fn next_minute_match(&self, minute: u32) -> Result, CronError> { 467 | if minute > 59 { 468 | return Err(CronError::InvalidTime); 469 | } 470 | for next_minute in minute..=59 { 471 | if self.minutes.is_bit_set(next_minute as u8, ALL_BIT)? { 472 | return Ok(Some(next_minute)); 473 | } 474 | } 475 | Ok(None) // No match found within the current range 476 | } 477 | 478 | // Finds the next second that matches the second part of the cron pattern. 479 | pub fn next_second_match(&self, second: u32) -> Result, CronError> { 480 | if second > 59 { 481 | return Err(CronError::InvalidTime); 482 | } 483 | for next_second in second..=59 { 484 | if self.seconds.is_bit_set(next_second as u8, ALL_BIT)? { 485 | return Ok(Some(next_second)); 486 | } 487 | } 488 | Ok(None) // No match found within the current range 489 | } 490 | 491 | // Method to set the dom_and_dow flag 492 | pub fn with_dom_and_dow(&mut self) -> &mut Self { 493 | self.dom_and_dow = true; 494 | self 495 | } 496 | 497 | // Method to set wether seconds should be allowed 498 | pub fn with_seconds_optional(&mut self) -> &mut Self { 499 | self.with_seconds_optional = true; 500 | self 501 | } 502 | 503 | // Method to set wether seconds should be allowed 504 | pub fn with_seconds_required(&mut self) -> &mut Self { 505 | self.with_seconds_required = true; 506 | self 507 | } 508 | 509 | // Method to set if weekdays should be offset by one (Quartz Scheduler style) 510 | pub fn with_alternative_weekdays(&mut self) -> &mut Self { 511 | self.with_alternative_weekdays = true; 512 | // We need to recreate self.days_of_week 513 | self.days_of_week = CronComponent::new(0, 7, LAST_BIT | NTH_ALL, 1); 514 | self 515 | } 516 | 517 | // Get a reference to the original pattern 518 | pub fn as_str(&self) -> &str { 519 | &self.pattern 520 | } 521 | } 522 | 523 | impl std::fmt::Display for CronPattern { 524 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 525 | write!(f, "{}", self.pattern) 526 | } 527 | } 528 | 529 | #[cfg(test)] 530 | mod tests { 531 | use super::*; 532 | 533 | #[test] 534 | fn test_cron_pattern_new() { 535 | let pattern = CronPattern::new("*/5 * * * *").parse().unwrap(); 536 | assert_eq!(pattern.pattern, "*/5 * * * *"); 537 | assert!(pattern.seconds.is_bit_set(0, ALL_BIT).unwrap()); 538 | assert!(pattern.minutes.is_bit_set(5, ALL_BIT).unwrap()); 539 | } 540 | 541 | #[test] 542 | fn test_cron_pattern_new_with_seconds_optional() { 543 | let pattern = CronPattern::new("* */5 * * * *") 544 | .with_seconds_optional() 545 | .parse() 546 | .expect("Success"); 547 | assert_eq!(pattern.pattern, "* */5 * * * *"); 548 | assert!(pattern.seconds.is_bit_set(5, ALL_BIT).unwrap()); 549 | } 550 | 551 | #[test] 552 | fn test_cron_pattern_new_with_seconds_required() { 553 | let mut pattern = CronPattern::new("* */5 * * * *"); 554 | pattern.with_seconds_optional(); 555 | let result = pattern.parse(); 556 | assert!(result.is_ok()); 557 | assert_eq!(pattern.pattern, "* */5 * * * *"); 558 | assert!(pattern.seconds.is_bit_set(5, ALL_BIT).unwrap()); 559 | } 560 | 561 | #[test] 562 | fn test_last_day_of_month() -> Result<(), CronError> { 563 | // Check the last day of February for a non-leap year 564 | assert_eq!(CronPattern::last_day_of_month(2021, 2)?, 28); 565 | 566 | // Check the last day of February for a leap year 567 | assert_eq!(CronPattern::last_day_of_month(2020, 2)?, 29); 568 | 569 | // Check for an invalid month (0 or greater than 12) 570 | assert!(CronPattern::last_day_of_month(2023, 0).is_err()); 571 | assert!(CronPattern::last_day_of_month(2023, 13).is_err()); 572 | 573 | Ok(()) 574 | } 575 | 576 | #[test] 577 | fn test_cron_pattern_tostring() { 578 | let mut pattern = CronPattern::new("*/5 * * * *"); 579 | let result = pattern.parse(); 580 | assert!(result.is_ok()); 581 | assert_eq!(pattern.to_string(), "*/5 * * * *"); 582 | } 583 | 584 | #[test] 585 | fn test_cron_pattern_short() { 586 | let mut pattern = CronPattern::new("5/5 * * * *"); 587 | let result = pattern.parse(); 588 | assert!(result.is_ok()); 589 | assert_eq!(pattern.pattern, "5/5 * * * *"); 590 | assert!(pattern.seconds.is_bit_set(0, ALL_BIT).unwrap()); 591 | assert!(!pattern.seconds.is_bit_set(5, ALL_BIT).unwrap()); 592 | assert!(pattern.minutes.is_bit_set(5, ALL_BIT).unwrap()); 593 | assert!(!pattern.minutes.is_bit_set(0, ALL_BIT).unwrap()); 594 | } 595 | 596 | #[test] 597 | fn test_cron_pattern_parse() { 598 | let mut pattern = CronPattern::new("*/15 1 1,15 1 1-5"); 599 | assert!(pattern.parse().is_ok()); 600 | assert!(pattern.minutes.is_bit_set(0, ALL_BIT).unwrap()); 601 | assert!(pattern.hours.is_bit_set(1, ALL_BIT).unwrap()); 602 | assert!( 603 | pattern.days.is_bit_set(1, ALL_BIT).unwrap() 604 | && pattern.days.is_bit_set(15, ALL_BIT).unwrap() 605 | ); 606 | assert!( 607 | pattern.months.is_bit_set(1, ALL_BIT).unwrap() 608 | && !pattern.months.is_bit_set(2, ALL_BIT).unwrap() 609 | ); 610 | assert!( 611 | pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap() 612 | && pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap() 613 | ); 614 | } 615 | 616 | #[test] 617 | fn test_cron_pattern_extra_whitespace() { 618 | let mut pattern = CronPattern::new(" */15 1 1,15 1 1-5 "); 619 | assert!(pattern.parse().is_ok()); 620 | assert!(pattern.minutes.is_bit_set(0, ALL_BIT).unwrap()); 621 | assert!(pattern.hours.is_bit_set(1, ALL_BIT).unwrap()); 622 | assert!( 623 | pattern.days.is_bit_set(1, ALL_BIT).unwrap() 624 | && pattern.days.is_bit_set(15, ALL_BIT).unwrap() 625 | ); 626 | assert!( 627 | pattern.months.is_bit_set(1, ALL_BIT).unwrap() 628 | && !pattern.months.is_bit_set(2, ALL_BIT).unwrap() 629 | ); 630 | assert!( 631 | pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap() 632 | && pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap() 633 | ); 634 | } 635 | 636 | #[test] 637 | fn test_cron_pattern_leading_zeros() { 638 | let mut pattern = CronPattern::new(" */15 01 01,15 01 01-05 "); 639 | assert!(pattern.parse().is_ok()); 640 | assert!(pattern.minutes.is_bit_set(0, ALL_BIT).unwrap()); 641 | assert!(pattern.hours.is_bit_set(1, ALL_BIT).unwrap()); 642 | assert!( 643 | pattern.days.is_bit_set(1, ALL_BIT).unwrap() 644 | && pattern.days.is_bit_set(15, ALL_BIT).unwrap() 645 | ); 646 | assert!( 647 | pattern.months.is_bit_set(1, ALL_BIT).unwrap() 648 | && !pattern.months.is_bit_set(2, ALL_BIT).unwrap() 649 | ); 650 | assert!( 651 | pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap() 652 | && pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap() 653 | ); 654 | } 655 | 656 | #[test] 657 | fn test_cron_pattern_handle_nicknames() { 658 | assert_eq!(CronPattern::handle_nicknames("@yearly", false), "0 0 1 1 *"); 659 | assert_eq!( 660 | CronPattern::handle_nicknames("@monthly", false), 661 | "0 0 1 * *" 662 | ); 663 | assert_eq!(CronPattern::handle_nicknames("@weekly", false), "0 0 * * 0"); 664 | assert_eq!(CronPattern::handle_nicknames("@daily", false), "0 0 * * *"); 665 | assert_eq!(CronPattern::handle_nicknames("@hourly", false), "0 * * * *"); 666 | } 667 | 668 | #[test] 669 | fn test_cron_pattern_handle_nicknames_with_seconds_required() { 670 | assert_eq!( 671 | CronPattern::handle_nicknames("@yearly", true), 672 | "0 0 0 1 1 *" 673 | ); 674 | assert_eq!( 675 | CronPattern::handle_nicknames("@monthly", true), 676 | "0 0 0 1 * *" 677 | ); 678 | assert_eq!( 679 | CronPattern::handle_nicknames("@weekly", true), 680 | "0 0 0 * * 0" 681 | ); 682 | assert_eq!(CronPattern::handle_nicknames("@daily", true), "0 0 0 * * *"); 683 | assert_eq!( 684 | CronPattern::handle_nicknames("@hourly", true), 685 | "0 0 * * * *" 686 | ); 687 | } 688 | 689 | #[test] 690 | fn test_month_nickname_range() { 691 | let mut pattern = CronPattern::new("0 0 * FEB-MAR *"); 692 | assert!(pattern.parse().is_ok()); 693 | assert!(!pattern.months.is_bit_set(1, ALL_BIT).unwrap()); 694 | assert!(pattern.months.is_bit_set(2, ALL_BIT).unwrap()); // February 695 | assert!(pattern.months.is_bit_set(3, ALL_BIT).unwrap()); // March 696 | assert!(!pattern.months.is_bit_set(4, ALL_BIT).unwrap()); 697 | } 698 | 699 | #[test] 700 | fn test_weekday_range_sat_sun() { 701 | let mut pattern = CronPattern::new("0 0 * * SAT-SUN"); 702 | assert!(pattern.parse().is_ok()); 703 | assert!(pattern.days_of_week.is_bit_set(0, ALL_BIT).unwrap()); // Sunday 704 | assert!(pattern.days_of_week.is_bit_set(6, ALL_BIT).unwrap()); // Saturday 705 | } 706 | 707 | #[test] 708 | fn test_closest_weekday() -> Result<(), CronError> { 709 | // Example cron pattern: "0 0 15W * *" which means at 00:00 on the closest weekday to the 15th of each month 710 | let mut pattern = CronPattern::new("0 0 0 15W * *"); 711 | pattern.with_seconds_optional(); 712 | assert!(pattern.parse().is_ok()); 713 | 714 | // Test a month where the 15th is a weekday 715 | // Assuming 15th is Wednesday (a weekday), the closest weekday is the same day. 716 | let date = NaiveDate::from_ymd_opt(2023, 6, 15).expect("To work"); // 15th June 2023 717 | assert!(pattern.day_match(date.year(), date.month(), date.day())?); 718 | 719 | // Test a month where the 15th is a Saturday 720 | // The closest weekday would be Friday, 14th. 721 | let date = NaiveDate::from_ymd_opt(2024, 6, 14).expect("To work"); // 14th May 2023 722 | assert!(pattern.day_match(date.year(), date.month(), date.day())?); 723 | 724 | // Test a month where the 15th is a Sunday 725 | // The closest weekday would be Monday, 16th. 726 | let date = NaiveDate::from_ymd_opt(2023, 10, 16).expect("To work"); // 16th October 2023 727 | assert!(pattern.day_match(date.year(), date.month(), date.day())?); 728 | 729 | // Test a non-matching date 730 | let date = NaiveDate::from_ymd_opt(2023, 6, 16).expect("To work"); // 16th June 2023 731 | assert!(!pattern.day_match(date.year(), date.month(), date.day())?); 732 | 733 | Ok(()) 734 | } 735 | 736 | #[test] 737 | fn test_closest_weekday_with_alternative_weekdays() -> Result<(), CronError> { 738 | // Example cron pattern: "0 0 15W * *" which means at 00:00 on the closest weekday to the 15th of each month 739 | let mut pattern = CronPattern::new("0 0 0 15W * *"); 740 | pattern.with_seconds_required(); 741 | pattern.with_alternative_weekdays(); 742 | assert!(pattern.parse().is_ok()); 743 | 744 | // Test a month where the 15th is a weekday 745 | // Assuming 15th is Wednesday (a weekday), the closest weekday is the same day. 746 | let date = NaiveDate::from_ymd_opt(2023, 6, 15).expect("To work"); // 15th June 2023 747 | assert!(pattern.day_match(date.year(), date.month(), date.day())?); 748 | 749 | // Test a month where the 15th is a Saturday 750 | // The closest weekday would be Friday, 14th. 751 | let date = NaiveDate::from_ymd_opt(2024, 6, 14).expect("To work"); // 14th May 2023 752 | assert!(pattern.day_match(date.year(), date.month(), date.day())?); 753 | 754 | // Test a month where the 15th is a Sunday 755 | // The closest weekday would be Monday, 16th. 756 | let date = NaiveDate::from_ymd_opt(2023, 10, 16).expect("To work"); // 16th October 2023 757 | assert!(pattern.day_match(date.year(), date.month(), date.day())?); 758 | 759 | // Test a non-matching date 760 | let date = NaiveDate::from_ymd_opt(2023, 6, 16).expect("To work"); // 16th June 2023 761 | assert!(!pattern.day_match(date.year(), date.month(), date.day())?); 762 | 763 | Ok(()) 764 | } 765 | 766 | #[test] 767 | fn test_with_seconds_false() { 768 | // Test with a 6-part pattern when seconds are not allowed 769 | let mut pattern = CronPattern::new("* * * * * *"); 770 | assert!(matches!(pattern.parse(), Err(CronError::InvalidPattern(_)))); 771 | 772 | // Test with a 5-part pattern when seconds are not allowed 773 | let mut no_seconds_pattern = CronPattern::new("*/10 * * * *"); 774 | 775 | assert!(no_seconds_pattern.parse().is_ok()); 776 | 777 | assert_eq!(no_seconds_pattern.to_string(), "*/10 * * * *"); 778 | 779 | // Ensure seconds are defaulted to 0 for a 5-part pattern 780 | assert!(no_seconds_pattern.seconds.is_bit_set(0, ALL_BIT).unwrap()); 781 | } 782 | 783 | #[test] 784 | fn test_with_seconds_required() { 785 | // Test with a 5-part pattern when seconds are required 786 | let mut no_seconds_pattern = CronPattern::new("*/10 * * * *"); 787 | no_seconds_pattern.with_seconds_required(); 788 | 789 | assert!(matches!( 790 | no_seconds_pattern.parse(), 791 | Err(CronError::InvalidPattern(_)) 792 | )); 793 | 794 | // Test with a 6-part pattern when seconds are required 795 | let mut pattern = CronPattern::new("* * * * * *"); 796 | pattern.with_seconds_required(); 797 | 798 | assert!(pattern.parse().is_ok()); 799 | 800 | // Ensure the 6-part pattern retains seconds information 801 | // (This assertion depends on how your CronPattern is structured and how it stores seconds information) 802 | assert!(pattern.seconds.is_bit_set(0, ALL_BIT).unwrap()); 803 | } 804 | 805 | #[test] 806 | fn test_with_alternative_weekdays() { 807 | // Test with alternative weekdays enabled 808 | let mut pattern = CronPattern::new("* * * * MON-FRI"); 809 | pattern.with_alternative_weekdays(); 810 | 811 | // Parsing should succeed 812 | assert!(pattern.parse().is_ok()); 813 | 814 | // Ensure that the days of the week are offset correctly 815 | // Note: In this scenario, "MON-FRI" should be treated as "SUN-THU" 816 | assert!(pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap()); // Monday 817 | assert!(pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap()); // Friday 818 | assert!(!pattern.days_of_week.is_bit_set(6, ALL_BIT).unwrap()); // Saturday should not be set 819 | } 820 | 821 | #[test] 822 | fn test_with_alternative_weekdays_numeric() { 823 | // Test with alternative weekdays enabled 824 | let mut pattern = CronPattern::new("* * * * 2-6"); 825 | pattern.with_alternative_weekdays(); 826 | 827 | // Parsing should succeed 828 | assert!(pattern.parse().is_ok()); 829 | 830 | // Ensure that the days of the week are offset correctly 831 | // Note: In this scenario, "MON-FRI" should be treated as "SUN-THU" 832 | assert!(pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap()); // Monday 833 | assert!(pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap()); // Friday 834 | assert!(!pattern.days_of_week.is_bit_set(6, ALL_BIT).unwrap()); // Saturday should not be set 835 | } 836 | 837 | #[test] 838 | fn test_seven_to_zero() { 839 | // Test with alternative weekdays enabled 840 | let mut pattern = CronPattern::new("* * * * 7"); 841 | 842 | // Parsing should succeed 843 | assert!(pattern.parse().is_ok()); 844 | 845 | // Ensure that the days of the week are offset correctly 846 | // Note: In this scenario, "MON-FRI" should be treated as "SUN-THU" 847 | assert!(pattern.days_of_week.is_bit_set(0, ALL_BIT).unwrap()); // Monday 848 | } 849 | 850 | #[test] 851 | fn test_one_is_monday_alternative() { 852 | // Test with alternative weekdays enabled 853 | let mut pattern = CronPattern::new("* * * * 1"); 854 | pattern.with_alternative_weekdays(); 855 | 856 | // Parsing should succeed 857 | assert!(pattern.parse().is_ok()); 858 | 859 | // Ensure that the days of the week are offset correctly 860 | // Note: In this scenario, "MON-FRI" should be treated as "SUN-THU" 861 | assert!(pattern.days_of_week.is_bit_set(0, ALL_BIT).unwrap()); // Monday 862 | } 863 | 864 | #[test] 865 | fn test_zero_with_alternative_weekdays_fails() { 866 | // Test with alternative weekdays enabled 867 | let mut pattern = CronPattern::new("* * * * 0"); 868 | pattern.with_alternative_weekdays(); 869 | 870 | // Parsing should raise a ComponentError 871 | assert!(matches!(pattern.parse(), Err(CronError::ComponentError(_)))); 872 | } 873 | } 874 | --------------------------------------------------------------------------------