├── .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 ├── describe │ ├── lang │ │ ├── english.rs │ │ ├── mod.rs │ │ └── swedish.rs │ └── mod.rs ├── errors.rs ├── iterator.rs ├── lib.rs ├── parser.rs └── pattern.rs └── tests └── ocps.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 = "3.0.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 | derive_builder = "0.20.2" 21 | serde = { version = "1.0", optional = true } 22 | strum = { version = "0.27.1", features = ["derive"] } 23 | 24 | [dev-dependencies] 25 | chrono-tz = "0.10.0" 26 | criterion = "0.5.1" 27 | rstest = "0.25.0" 28 | serde_test = "1.0" 29 | 30 | [features] 31 | serde = ["dep:serde"] 32 | 33 | [[bench]] 34 | name = "croner_bench" 35 | harness = false 36 | -------------------------------------------------------------------------------- /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 | - Generates human-readable descriptions of cron patterns. 13 | - Follows POSIX/Vixie-cron standards, while extending it with additional specifiers such as `L` 14 | for the last day and weekday of the month, `#` for the nth weekday of the 15 | month, `W` for closest weekday to a day of month. 16 | - Evaluate cron expressions across different time zones. 17 | - Supports optional second-, and year granularity 18 | - Supports optional alternative weekday mode to use Quartz-style weekdays instead of POSIX using `with_alternative_weekdays` 19 | - 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. 20 | - Compatible with `chrono` and (optionally) `chrono-tz`. 21 | - Robust error handling. 22 | 23 | ## Crate Features 24 | 25 | - `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. 26 | 27 | ## Why croner instead of cron or saffron? 28 | 29 | Croner combines the features of cron and saffron, while following the POSIX/Vixie "standards" for the relevant parts. See this table: 30 | 31 | | Feature | Croner | Cron | Saffron | 32 | |----------------------|-------------|-----------|---------| 33 | | Time Zones | X | X | | 34 | | Ranges (15-25)| X | X | X | 35 | | Ranges with stepping (15-25/2)| X | X | X | 36 | | `L` - Last day of month | X | | X | 37 | | `5#L` - Last occurrence of weekday | X | X | | 38 | | `5L` - Last occurrence of weekday | X | ? | X | 39 | | `#` - Nth occurrence of weekday | X | | X | 40 | | `W` - Closest weekday | X | | X | 41 | | `+` - dom-AND-dow through pattern | X | | | 42 | | "Standards"-compliant weekdays (1 is monday) | X | | | 43 | | Five part patterns (minute granularity) | X | | X | 44 | | Six part patterns (second granularity)| X | X | | 45 | | Weekday/Month text representations | X | X | X | 46 | | Aliases (`@hourly` etc.) | X | X | | 47 | | chrono `DateTime` compatibility | X | X | X | 48 | | Option to force DOM-and-DOW | X | | | 49 | | Generate human readable string | X | | X | 50 | 51 | > [!NOTE] 52 | > Tests carried out at 2023-12-02 using `cron@0.12.0` and `saffron@.0.1.0` 53 | 54 | ## Getting Started 55 | 56 | ### Prerequisites 57 | 58 | Ensure you have Rust installed on your machine. If not, you can get it from 59 | [the official Rust website](https://www.rust-lang.org/). 60 | 61 | ### Installation 62 | 63 | Add `croner` to your `Cargo.toml` dependencies: 64 | 65 | ```toml 66 | [dependencies] 67 | croner = "3.0.0" # Adjust the version as necessary 68 | ``` 69 | 70 | ### Usage 71 | 72 | Here's a quick example to get you started with matching current time, and 73 | finding the next occurrence. `is_time_matching` takes a `chrono` `DateTime`: 74 | 75 | ```rust 76 | use croner::Cron; 77 | use chrono::Local; 78 | 79 | fn main() { 80 | 81 | // Parse cron expression 82 | let cron_all = Cron::from_str("18 * * * 5") 83 | .expect("Couldn't parse cron string"); 84 | 85 | // Compare cron pattern with current local time 86 | let time = Local::now(); 87 | let matches_all = cron_all.is_time_matching(&time).unwrap(); 88 | 89 | // Get next match 90 | let next = cron_all.find_next_occurrence(&time, false).unwrap(); 91 | 92 | // Output results 93 | println!("Description: {}", cron.describe()); 94 | println!("Time is: {}", time); 95 | println!("Pattern \"{}\" does {} time {}", cron_all.pattern.to_string(), if matches_all { "match" } else { "not match" }, time ); 96 | println!("Pattern \"{}\" will match next time at {}", cron_all.pattern.to_string(), next); 97 | 98 | } 99 | ``` 100 | 101 | To match against a non local timezone, croner supports zoned chrono DateTime's 102 | `DateTime`. To use a named time zone, you can utilize the `chrono-tz` crate. 103 | 104 | ```rust 105 | use croner::Cron; 106 | use chrono::Local; 107 | use chrono_tz::Tz; 108 | 109 | fn main() { 110 | // Parse cron expression 111 | let cron = Cron::from_str("18 * * * 5") 112 | .expect("Couldn't parse cron string"); 113 | 114 | // Choose a different time zone, for example America/New_York 115 | let est_timezone: Tz = "America/New_York".parse().expect("Invalid timezone"); 116 | 117 | // Find the next occurrence in EST 118 | let time_est = Local::now().with_timezone(&est_timezone); 119 | let next_est = cron.find_next_occurrence(&time_est, false).unwrap(); 120 | 121 | // Output results for EST 122 | println!("EST time is: {}", time_est); 123 | println!( 124 | "Pattern \"{}\" will match next time at (EST): {}", 125 | cron.pattern.to_string(), 126 | next_est 127 | ); 128 | } 129 | ``` 130 | 131 | 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 configure `dom_and_dow` to ensure both day-of-month and day-of-week conditions are met (see [configuration](#configuration) for more details). 132 | 133 | ```rust 134 | use chrono::Local; 135 | use croner::parser::CronParser; 136 | 137 | fn main() { 138 | // Parse cron expression for Fridays in December 139 | let cron = CronParser::builder() 140 | // Include seconds in pattern 141 | .seconds(croner::parser::Seconds::Optional) 142 | // Ensure both day of month and day of week conditions are met 143 | .dom_and_dow(true) 144 | .build() 145 | .parse("0 0 0 31 12 FRI") 146 | .expect("Couldn't parse cron string"); 147 | 148 | let time = Local::now(); 149 | 150 | println!("Finding the next 5 New Year's Eves on a Friday:"); 151 | for time in cron.iter_from(time).take(5) { 152 | println!("{time}"); 153 | } 154 | } 155 | ``` 156 | 157 | ### Pattern 158 | 159 | The expressions used by Croner are very similar to those of Vixie Cron, but with 160 | a few additions and changes as outlined below: 161 | 162 | ```javascript 163 | // ┌──────────────── (optional) second (0 - 59) 164 | // │ ┌────────────── minute (0 - 59) 165 | // │ │ ┌──────────── hour (0 - 23) 166 | // │ │ │ ┌────────── day of month (1 - 31) 167 | // │ │ │ │ ┌──────── month (1 - 12, JAN-DEC) 168 | // │ │ │ │ │ ┌────── day of week (0 - 6, SUN-Mon) 169 | // │ │ │ │ │ │ (0 to 6 are Sunday to Saturday; 7 is Sunday, the same as 0) 170 | // │ │ │ │ │ │ 171 | // * * * * * * 172 | ``` 173 | 174 | - Croner expressions have the following additional modifiers: 175 | - _?_: In the Rust version of croner, a questionmark in the day-of-month or 176 | day-of-week field behaves just as `*`. This allow for legacy cron patterns 177 | to be used. 178 | - _L_: The letter 'L' can be used in the day of the month field to indicate 179 | the last day of the month. When used in the day of the week field in 180 | conjunction with the # character, it denotes the last specific weekday of 181 | the month. For example, `5#L` represents the last Friday of the month. 182 | - _#_: The # character specifies the "nth" occurrence of a particular day 183 | within a month. For example, supplying `5#2` in the day of week field 184 | signifies the second Friday of the month. This can be combined with ranges 185 | and supports day names. For instance, MON-FRI#2 would match the Monday 186 | through Friday of the second week of the month. 187 | - _W_: The character 'W' is used to specify the closest weekday to a given day 188 | in the day of the month field. For example, 15W will match the closest 189 | weekday to the 15th of the month. If the specified day falls on a weekend 190 | (Saturday or Sunday), the pattern will match the closest weekday before or 191 | after that date. For instance, if the 15th is a Saturday, 15W will match the 192 | 14th (Friday), and if the 15th is a Sunday, it will match the 16th (Monday). 193 | - _+_: The plus sign can be used as a prefix to the day-of-week field to create 194 | a logical AND between the day-of-month and day-of-week fields. By default, 195 | the relationship is a logical OR. For example, `0 0 1 * +MON` will run only 196 | if the 1st of the month is also a Monday. 197 | 198 | | Field | Required | Allowed values | Allowed special characters | Remarks | 199 | | ------------ | -------- | --------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------- | 200 | | Seconds | Optional | 0-59 | * , - / | | 201 | | Minutes | Yes | 0-59 | * , - / | | 202 | | Hours | Yes | 0-23 | * , - / | | 203 | | Day of Month | Yes | 1-31 | * , - / ? L W | | 204 | | Month | Yes | 1-12 or JAN-DEC | * , - / | | 205 | | 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 | 206 | 207 | > [!NOTE] 208 | > Weekday and month names are case-insensitive. Both `MON` and `mon` 209 | > work. When using `L` in the Day of Week field, it affects all specified 210 | > weekdays. For example, `5-6#L` means the last Friday and Saturday in the 211 | > month." The # character can be used to specify the "nth" weekday of the month. 212 | > For example, 5#2 represents the second Friday of the month. 213 | 214 | > [!NOTE] 215 | > The `W` feature is constrained within the given month. The search for 216 | > the closest weekday will not cross into a previous or subsequent month. For 217 | > example, if the 1st of the month is a Saturday, 1W will trigger on Monday 218 | > the 3rd, not the last Friday of the previous month. 219 | 220 | It is also possible to use the following "nicknames" as pattern. 221 | 222 | | Nickname | Description | 223 | | ---------- | ---------------------------------- | 224 | | \@yearly | Run once a year, ie. "0 0 1 1 *". | 225 | | \@annually | Run once a year, ie. "0 0 1 1 *". | 226 | | \@monthly | Run once a month, ie. "0 0 1 * *". | 227 | | \@weekly | Run once a week, ie. "0 0 * * 0". | 228 | | \@daily | Run once a day, ie. "0 0 * * *". | 229 | | \@hourly | Run once an hour, ie. "0 * * * *". | 230 | 231 | ### Configuration 232 | 233 | Croner uses `CronParser` to parse the cron expression. Invoking 234 | `Cron::from_str("pattern")` is equivalent to 235 | `CronParser::new().parse("pattern")`. You can customise the parser by creating a 236 | parser builder using `CronParser::builder`. 237 | 238 | #### 1. Making seconds optional 239 | 240 | This option enables the inclusion of seconds in the cron pattern, but it's not mandatory. By using this option, 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. 241 | 242 | **Example Usage**: 243 | 244 | ```rust 245 | use croner::parser::{CronParser, Seconds}; 246 | 247 | // Configure the parser to allow seconds. 248 | let parser = CronParser::builder().seconds(Seconds::Optional).build(); 249 | 250 | let cron = parser 251 | .parse("*/10 * * * * *") // Every 10 seconds 252 | .expect("Invalid cron pattern"); 253 | ``` 254 | 255 | #### 2. Making seconds optional required 256 | 257 | In contrast to `Seconds::Optional`, the `Seconds::Required` variant 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. 258 | 259 | **Example Usage**: 260 | 261 | ```rust 262 | use croner::parser::{CronParser, Seconds}; 263 | 264 | // Configure the parser to require seconds. 265 | let parser = CronParser::builder().seconds(Seconds::Required).build(); 266 | 267 | let cron = parser 268 | .parse("5 */2 * * * *") // At 5 seconds past every 2 minutes 269 | .expect("Invalid cron pattern"); 270 | ``` 271 | 272 | #### 3. `dom_and_dow` 273 | 274 | This method forces 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. Certain libraries use this mode by default. 275 | 276 | > [!NOTE] 277 | > While this method provides a way to globally enforce AND logic, the recommended approach is to use the `+` modifier directly in the cron pattern (e.g., `0 0 1 * +MON`). This pattern-level configuration gives you more granular control and is enabled by default. 278 | 279 | **Example Usage**: 280 | 281 | ```rust 282 | use croner::parser::CronParser; 283 | 284 | // Configure the parser to enable DOM and DOW. 285 | let parser = CronParser::builder().dom_and_dow(true).build(); 286 | 287 | let cron = parser 288 | .parse("0 0 25 * FRI") // When christmas day is on a friday 289 | .expect("Invalid cron pattern"); 290 | ``` 291 | 292 | #### 4. `alternative_weekdays` (Quartz mode) 293 | 294 | 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. 295 | 296 | **Example Usage**: 297 | 298 | ```rust 299 | use croner::parser::CronParser; 300 | 301 | // Configure the parser to use Quartz-style weekday mode. 302 | let parser = CronParser::builder().alternative_weekdays(true).build(); 303 | 304 | let cron = parser 305 | .parse("0 0 12 * * 6") // Every Friday (denoted with 6 in Quartz mode) at noon 306 | .expect("Invalid cron pattern"); 307 | ``` 308 | 309 | ### Documentation 310 | 311 | For detailed usage and API documentation, visit 312 | [Croner on docs.rs](https://docs.rs/croner/). 313 | 314 | ## Time and Calendar System 315 | 316 | Croner uses the `chrono` crate, which operates on a **proleptic Gregorian calendar**. This means it treats all dates, historical or future, as if the Gregorian calendar has always been in effect. Consequently, it does not account for historical calendar reforms (e.g., skipped days during the 1582 Gregorian adoption) and will iterate through all dates uniformly. 317 | For stability and practical use, Croner supports dates from **year 1 AD/CE** up to the beginning of **year 5000**, preventing searches that are too far into the past or future. 318 | 319 | ### Daylight Saving Time (DST) Handling 320 | 321 | Croner-rust provides robust and predictable handling of Daylight Saving Time (DST) transitions, aligning with the Open Cron Pattern Specification (OCPS) and Vixie-cron's time-tested behavior. Jobs are categorized based on their time-unit field specifications: 322 | 323 | * **Fixed-Time Jobs**: Jobs with specific numerical values for seconds, minutes, and hours (e.g., `0 30 2 * * *`). 324 | * **Interval/Wildcard Jobs**: Jobs using wildcards (`*`) or step values (`*/N`) in their seconds, minutes, or hours fields (e.g., `*/5 * * * * *`). 325 | 326 | During DST transitions, Croner-rust behaves as follows: 327 | 328 | * **DST Gap (Spring Forward)**: When a scheduled time falls into a non-existent interval (e.g., 2:00 AM jumps to 3:00 AM): 329 | * Fixed-Time Jobs: Will execute at the first valid second/minute immediately following the gap on the same calendar day. 330 | * Interval/Wildcard Jobs: Occurrences within the gap are skipped. Subsequent executions resume at the next regularly scheduled interval relative to the new wall clock time. 331 | * **DST Overlap (Fall Back)**: When a scheduled time interval occurs twice (e.g., 2:00 AM falls back to 1:00 AM): 332 | * Fixed-Time Jobs: Will execute only once, at its first occurrence in wall clock time. 333 | * Interval/Wildcard Jobs: Will execute for each occurrence that matches its pattern in wall clock time within the duplicated hour. 334 | 335 | ## Development 336 | 337 | To start developing in the Croner project: 338 | 339 | 1. Clone the repository. 340 | 2. Navigate into the project directory. 341 | 3. Build the project using `cargo build`. 342 | 4. Run tests with `cargo test --all-features`. 343 | 5. Run demo with `cargo run --example simple_demo` 344 | 345 | ## Contributing 346 | 347 | We welcome contributions! Please feel free to submit a pull request or open an 348 | issue. 349 | 350 | ## License 351 | 352 | This project is licensed under the MIT License - see the 353 | [LICENSE.md](LICENSE.md) file for details. 354 | 355 | ## Disclaimer 356 | 357 | Please note that Croner is currently in its early stages of development. As 358 | such, the API is subject to change in future releases, adhering to semantic 359 | versioning principles. We recommend keeping this in mind when integrating Croner 360 | into your projects. 361 | 362 | ## Contact 363 | 364 | If you have any questions or feedback, please open an issue in the repository 365 | and we'll get back to you as soon as possible. 366 | -------------------------------------------------------------------------------- /benches/croner_bench.rs: -------------------------------------------------------------------------------- 1 | use chrono::Local; 2 | use criterion::{black_box, criterion_group, criterion_main, Criterion}; 3 | use croner::{parser::CronParser, Cron}; 4 | 5 | fn parse_take_100(_n: u64) { 6 | let cron: Cron = CronParser::builder() 7 | .seconds(croner::parser::Seconds::Optional) 8 | .build() 9 | .parse("15 15 15 L 3 *") 10 | .expect("Couldn't parse cron string"); 11 | let time = Local::now(); 12 | for _time in cron.clone().iter_after(time).take(100) {} 13 | } 14 | 15 | pub fn criterion_benchmark(c: &mut Criterion) { 16 | c.bench_function("parse_take_100", |b| { 17 | b.iter(|| parse_take_100(black_box(20))) 18 | }); 19 | } 20 | 21 | criterion_group!(benches, criterion_benchmark); 22 | criterion_main!(benches); 23 | -------------------------------------------------------------------------------- /examples/iter_demo.rs: -------------------------------------------------------------------------------- 1 | use chrono::Utc; 2 | use croner::parser::CronParser; 3 | 4 | fn main() { 5 | // Parse cron expression 6 | let cron = CronParser::builder() 7 | .seconds(croner::parser::Seconds::Optional) 8 | .build() 9 | .parse("* * * * * *") 10 | .expect("Couldn't parse cron string"); 11 | 12 | // Compare to UTC time now 13 | let time = Utc::now(); 14 | 15 | // (Or Local) 16 | // let time = Local::now(); 17 | 18 | // Get next 5 matches using iter_after 19 | // There is also iter_after, which does not match starting time 20 | println!( 21 | "Finding matches of pattern '{}' starting from {}:", 22 | cron.pattern, time 23 | ); 24 | 25 | for time in cron.iter_after(time).take(5) { 26 | println!("{time}"); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /examples/simple_demo.rs: -------------------------------------------------------------------------------- 1 | use chrono::Local; 2 | use croner::parser::CronParser; 3 | use croner::describe::lang::swedish::Swedish; // For demonstrating translation 4 | 5 | fn main() { 6 | // Example: Parse cron expression 7 | let cron = CronParser::builder() 8 | .seconds(croner::parser::Seconds::Required) // Example of configuration - make seconds required 9 | .build() 10 | .parse("0 18 * * * FRI") 11 | .expect("Couldn't parse cron string"); 12 | 13 | // Example: Compare cron pattern with current local time 14 | let time = Local::now(); 15 | let matches = cron.is_time_matching(&time).unwrap(); 16 | 17 | // Example: Get next match 18 | let next = cron.find_next_occurrence(&time, false).unwrap(); 19 | 20 | // Example: Get and print the human-readable description 21 | let description = cron.describe(); 22 | println!("Description: {description}"); 23 | 24 | // Example: Get and print the human-readable description in Swedish 25 | let swedish_description = cron.describe_lang(Swedish); // 2. Call describe_lang() with Swedish 26 | println!("Swedish Description: {swedish_description}"); 27 | 28 | // Example: Output results 29 | println!("Current time is: {time}"); 30 | println!( 31 | "Pattern \"{}\" does {} time {}", 32 | cron.pattern, 33 | if matches { "match" } else { "not match" }, 34 | time 35 | ); 36 | println!( 37 | "Pattern \"{}\" will match next time at {}", 38 | cron.pattern, next 39 | ); 40 | 41 | // Example: Iterator 42 | println!("Next 5 matches:"); 43 | for time in cron.clone().iter_after(Local::now()).take(5) { 44 | println!("{time}"); 45 | } 46 | 47 | // Example: Reverse Iterator 48 | println!("Previous 5 matches:"); 49 | for time in cron.clone().iter_before(Local::now()).take(5) { 50 | println!("{time}"); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /examples/timezone_demo.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr as _; 2 | 3 | use chrono::Utc; 4 | use chrono_tz::Tz; 5 | use croner::Cron; 6 | 7 | fn main() { 8 | // Parse cron expression 9 | let cron = Cron::from_str("18 * * * 5").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, next_stockholm 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /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, PartialEq, Eq, PartialOrd, Ord, Hash)] 37 | pub struct CronComponent { 38 | bitfields: Vec, // Vector of u8 to act as multiple bitfields 39 | pub min: u16, // Minimum value this component can take 40 | pub max: u16, // Maximum value this component can take 41 | pub step: u16, // Steps to skip in this component 42 | pub from_wildcard: bool, // Wildcard used 43 | features: u8, // Single u8 bitfield to indicate supported special bits, like LAST_BIT 44 | enabled_features: u8, // Bitfield to hold component-wide special bits like LAST_BIT 45 | input_offset: u16, // Offset for numerical representation 46 | } 47 | 48 | impl CronComponent { 49 | /// Creates a new `CronComponent` with specified minimum and maximum values and features. 50 | /// 51 | /// `min` and `max` define the range of values this component can take. 52 | /// `features` is a bitfield specifying supported special features. 53 | /// 54 | /// # Parameters 55 | /// 56 | /// - `min`: The minimum permissible value for this component. 57 | /// - `max`: The maximum permissible value for this component. 58 | /// - `features`: Bitfield indicating special features like `LAST_BIT`. 59 | /// 60 | /// # Returns 61 | /// 62 | /// Returns a new instance of `CronComponent`. 63 | pub fn new(min: u16, max: u16, features: u8, input_offset: u16) -> Self { 64 | // Handle the case where max might make usize overflow if not checked 65 | let bitfields_size = if max > 0 { max as usize + 1 } else { 0 }; 66 | Self { 67 | // Vector of u8 to act as multiple bitfields. 68 | // - Initialized with NONE_BIT for each element. 69 | bitfields: vec![NONE_BIT; bitfields_size], 70 | 71 | // Minimum value this component can take. 72 | // - Example: 0 for the minute-field 73 | min, 74 | 75 | // Maximum value this component can take. 76 | // - Example: 59 for the minute-field 77 | max, 78 | 79 | // Bitfield to indicate _supported_ special bits, like LAST_BIT. 80 | // - ALL_BIT and LAST_BIT is always allowed 81 | features: features | ALL_BIT | LAST_BIT, 82 | 83 | // Bitfield to indicate _enabled_ component-wide special bits like LAST_BIT. 84 | // - No features are enabled by default 85 | enabled_features: 0, 86 | 87 | // Offset for numerical representation of weekdays. normally 0=SUN,1=MON etc, setting this to 1 makes 1=SUN... 88 | input_offset, 89 | 90 | step: 1, // Used by .describe() 91 | 92 | from_wildcard: false, // Used by .describe() 93 | } 94 | } 95 | 96 | // Method primarily used by .describe() to evaluate if all bits are set 97 | pub fn is_all_set(&self) -> bool { 98 | // A component is "all set" if it's a '*' with no step. 99 | // We check if all bits in its range are set for the ALL_BIT flag. 100 | for i in self.min..=self.max { 101 | if !self.is_bit_set(i, ALL_BIT).unwrap_or(false) { 102 | return false; 103 | } 104 | } 105 | true 106 | } 107 | 108 | // Set a bit at a given position (e.g., 0 to 9999 for year) 109 | pub fn set_bit(&mut self, mut pos: u16, bit: u8) -> Result<(), CronError> { 110 | if pos < self.input_offset { 111 | return Err(CronError::ComponentError(format!( 112 | "Position {} is less than the input offset {}.", 113 | pos, self.input_offset 114 | ))); 115 | } 116 | pos -= self.input_offset; 117 | if pos < self.min || pos > self.max { 118 | return Err(CronError::ComponentError(format!( 119 | "Position {} is out of bounds for the current range ({}-{}).", 120 | pos, self.min, self.max 121 | ))); 122 | } 123 | if self.features & bit != bit { 124 | return Err(CronError::ComponentError(format!( 125 | "Bit 0b{:08b} is not supported by the current features 0b{:08b}.", 126 | bit, self.features 127 | ))); 128 | } 129 | let index = pos as usize; // Convert the position to an index 130 | if index >= self.bitfields.len() { 131 | // In case the index is somehow out of the vector's bounds 132 | return Err(CronError::ComponentError(format!( 133 | "Position {pos} is out of the bitfields vector's bounds." 134 | ))); 135 | } 136 | self.bitfields[index] |= bit; // Set the specific bit at the position 137 | Ok(()) 138 | } 139 | 140 | // Unset a specific bit at a given position 141 | pub fn unset_bit(&mut self, mut pos: u16, bit: u8) -> Result<(), CronError> { 142 | if pos < self.input_offset { 143 | return Err(CronError::ComponentError(format!( 144 | "Position {} is less than the input offset {}.", 145 | pos, self.input_offset 146 | ))); 147 | } 148 | pos -= self.input_offset; 149 | if pos < self.min || pos > self.max { 150 | return Err(CronError::ComponentError(format!( 151 | "Position {} is out of bounds for the current range ({}-{}).", 152 | pos, self.min, self.max 153 | ))); 154 | } 155 | if self.features & bit != bit { 156 | return Err(CronError::ComponentError(format!( 157 | "Bit 0b{:08b} is not supported by the current features 0b{:08b}.", 158 | bit, self.features 159 | ))); 160 | } 161 | let index = pos as usize; // Convert the position to an index 162 | if index >= self.bitfields.len() { 163 | // In case the index is somehow out of the vector's bounds 164 | return Err(CronError::ComponentError(format!( 165 | "Position {pos} is out of the bitfields vector's bounds." 166 | ))); 167 | } 168 | self.bitfields[index] &= !bit; // Unset the specific bit at the position 169 | Ok(()) 170 | } 171 | 172 | // Check if a specific bit at a given position is set 173 | pub fn is_bit_set(&self, pos: u16, bit: u8) -> Result { 174 | if pos < self.min || pos > self.max { 175 | Err(CronError::ComponentError(format!( 176 | "Position {} is out of bounds for the current range ({}-{}).", 177 | pos, self.min, self.max 178 | ))) 179 | } else if self.features & bit != bit { 180 | Err(CronError::ComponentError(format!( 181 | "Bit 0b{:08b} is not supported by the current features 0b{:08b}.", 182 | bit, self.features 183 | ))) 184 | } else { 185 | let index = pos as usize; 186 | if index >= self.bitfields.len() { 187 | Err(CronError::ComponentError(format!( 188 | "Position {pos} is out of the bitfields vector's bounds." 189 | ))) 190 | } else { 191 | Ok((self.bitfields[index] & bit) != 0) 192 | } 193 | } 194 | } 195 | 196 | // Method to enable a feature 197 | pub fn enable_feature(&mut self, feature: u8) -> Result<(), CronError> { 198 | if self.is_feature_allowed(feature) { 199 | self.enabled_features |= feature; 200 | Ok(()) 201 | } else { 202 | Err(CronError::ComponentError(format!( 203 | "Feature 0b{:08b} is not supported by the current features 0b{:08b}.", 204 | feature, self.features 205 | ))) 206 | } 207 | } 208 | 209 | pub fn is_feature_allowed(&mut self, feature: u8) -> bool { 210 | self.features & feature == feature 211 | } 212 | 213 | // Method to check if a feature is enabled 214 | pub fn is_feature_enabled(&self, feature: u8) -> bool { 215 | (self.enabled_features & feature) == feature 216 | } 217 | 218 | /// Parses a part of a cron expression string and sets the corresponding bits in the component. 219 | /// 220 | /// This method interprets the cron syntax provided in `field` and sets 221 | /// the relevant bits in the component. It supports standard cron patterns 222 | /// like '*', '-', '/', and 'w'. For example, '*/15' in a minute component 223 | /// would set the bits for every 15th minute. 224 | /// 225 | /// # Parameters 226 | /// 227 | /// - `field`: A string slice containing the cron expression part to parse. 228 | /// 229 | /// # Returns 230 | /// 231 | /// Returns `Ok(())` if parsing is successful, or `CronError` if the parsing fails. 232 | /// 233 | /// # Errors 234 | /// 235 | /// Returns `CronError::ComponentError` if the input string contains invalid 236 | /// cron syntax or values outside the permissible range of the component. 237 | /// 238 | /// # Examples (for internal use only, CronComponent isn't exported) 239 | /// 240 | /// use crate::component::CronComponent; 241 | /// let mut hour_component = CronComponent::new(0, 23, 0); 242 | /// hour_component.parse("*/3").expect("Parsing failed"); 243 | /// // Sets the hour component to trigger at every 3rd hour 244 | pub fn parse(&mut self, field: &str) -> Result<(), CronError> { 245 | if field == "*" { 246 | self.from_wildcard = true; 247 | for value in self.min..=self.max { 248 | self.set_bit(value + self.input_offset, ALL_BIT)?; 249 | } 250 | return Ok(()); 251 | } 252 | 253 | for part in field.split(',') { 254 | let trimmed_part = part.trim(); 255 | if trimmed_part.is_empty() { 256 | continue; 257 | } 258 | 259 | let mut parsed_part = trimmed_part.to_string(); 260 | 261 | if parsed_part.contains('/') { 262 | self.handle_stepping(&parsed_part)?; 263 | } else if parsed_part.contains('-') { 264 | self.handle_range(&parsed_part)?; 265 | } else if parsed_part.contains('W') { 266 | self.handle_closest_weekday(&parsed_part)?; 267 | } else if parsed_part.eq_ignore_ascii_case("L") { 268 | // Handle "L" for the last bit 269 | self.enable_feature(LAST_BIT)?; 270 | } else { 271 | // If 'L' is contained without '#', like "5L", add the missing '#' 272 | if parsed_part.ends_with('L') && !parsed_part.contains('#') { 273 | parsed_part = parsed_part.replace('L', "#L"); 274 | } 275 | 276 | // If '#' is contained in the number, require feature NTH_ALL to be set 277 | if parsed_part.contains('#') && !self.is_feature_allowed(NTH_ALL) { 278 | return Err(CronError::ComponentError( 279 | "Nth specifier # not allowed in the current field.".to_string(), 280 | )); 281 | } 282 | 283 | // If 'L' is contained in the number, require feature NTH_ALL to be set 284 | if parsed_part.contains('L') && !self.is_feature_allowed(NTH_ALL) { 285 | return Err(CronError::ComponentError( 286 | "L not allowed in the current field.".to_string(), 287 | )); 288 | } 289 | 290 | self.handle_number(&parsed_part)?; 291 | } 292 | } 293 | 294 | Ok(()) 295 | } 296 | 297 | /// Returns a vector of u16 values for all bits set in the component for a given bitflag. 298 | pub fn get_set_values(&self, bit: u8) -> Vec { 299 | (self.min..=self.max) 300 | .filter(|i| self.is_bit_set(*i, bit).unwrap_or(false)) 301 | .collect() 302 | } 303 | 304 | fn get_nth_bit(value: &str) -> Result { 305 | // If value ends with 'L', we set the LAST_BIT and exit early 306 | if value.ends_with('L') { 307 | return Ok(LAST_BIT); 308 | } 309 | if let Some(nth_pos) = value.find('#') { 310 | let nth = value[nth_pos + 1..] 311 | .parse::() 312 | .map_err(|_| CronError::ComponentError("Invalid nth specifier.".to_string()))?; 313 | 314 | if nth == 0 || nth > 5 { 315 | Err(CronError::ComponentError( 316 | "Nth specifier out of bounds.".to_string(), 317 | )) 318 | } else { 319 | match nth { 320 | 1 => Ok(NTH_1ST_BIT), 321 | 2 => Ok(NTH_2ND_BIT), 322 | 3 => Ok(NTH_3RD_BIT), 323 | 4 => Ok(NTH_4TH_BIT), 324 | 5 => Ok(NTH_5TH_BIT), 325 | _ => Err(CronError::ComponentError( 326 | "Invalid nth specifier.".to_string(), 327 | )), 328 | } 329 | } 330 | } else { 331 | Ok(ALL_BIT) 332 | } 333 | } 334 | 335 | // Removes everything after # 336 | fn strip_nth_part(value: &str) -> &str { 337 | value.split('#').next().unwrap_or("") 338 | } 339 | 340 | fn handle_closest_weekday(&mut self, value: &str) -> Result<(), CronError> { 341 | if let Some(day_pos) = value.find('W') { 342 | // Use a slice 343 | let day_str = &value[..day_pos]; 344 | 345 | // Parse the day from the slice 346 | let day = day_str.parse::().map_err(|_| { 347 | CronError::ComponentError("Invalid day for closest weekday.".to_string()) 348 | })?; 349 | 350 | // Check if the day is within the allowed range 351 | if day < self.min || day > self.max { 352 | return Err(CronError::ComponentError( 353 | "Day for closest weekday out of bounds.".to_string(), 354 | )); 355 | } 356 | 357 | // Set the bit for the closest weekday 358 | self.set_bit(day, CLOSEST_WEEKDAY_BIT)?; 359 | } else { 360 | // If 'W' is not found, handle the value as a regular number 361 | self.handle_number(value)?; 362 | } 363 | Ok(()) 364 | } 365 | 366 | fn handle_range(&mut self, range: &str) -> Result<(), CronError> { 367 | let bit_to_set = CronComponent::get_nth_bit(range)?; 368 | let str_clean = CronComponent::strip_nth_part(range); 369 | 370 | let parts: Vec<&str> = str_clean.split('-').map(str::trim).collect(); 371 | if parts.len() != 2 { 372 | return Err(CronError::ComponentError( 373 | "Invalid range syntax.".to_string(), 374 | )); 375 | } 376 | 377 | let start = parts[0] 378 | .parse::() 379 | .map_err(|_| CronError::ComponentError("Invalid start of range.".to_string()))?; 380 | let end = parts[1] 381 | .parse::() 382 | .map_err(|_| CronError::ComponentError("Invalid end of range.".to_string()))?; 383 | 384 | if start > end || start < self.min || end > self.max { 385 | return Err(CronError::ComponentError( 386 | "Range out of bounds.".to_string(), 387 | )); 388 | } 389 | 390 | for value in start..=end { 391 | self.set_bit(value, bit_to_set)?; 392 | } 393 | Ok(()) 394 | } 395 | 396 | fn handle_number(&mut self, value: &str) -> Result<(), CronError> { 397 | let bit_to_set = CronComponent::get_nth_bit(value)?; 398 | let value_clean = CronComponent::strip_nth_part(value); 399 | let num = value_clean 400 | .parse::() 401 | .map_err(|_| CronError::ComponentError("Invalid number.".to_string()))?; 402 | if num < self.min || num > self.max { 403 | return Err(CronError::ComponentError( 404 | "Number out of bounds.".to_string(), 405 | )); 406 | } 407 | 408 | self.set_bit(num, bit_to_set)?; 409 | Ok(()) 410 | } 411 | 412 | pub fn handle_stepping(&mut self, stepped_range: &str) -> Result<(), CronError> { 413 | let bit_to_set = CronComponent::get_nth_bit(stepped_range)?; 414 | let stepped_range_clean = CronComponent::strip_nth_part(stepped_range); 415 | 416 | let parts: Vec<&str> = stepped_range_clean.split('/').collect(); 417 | if parts.len() != 2 { 418 | return Err(CronError::ComponentError( 419 | "Invalid stepped range syntax.".to_string(), 420 | )); 421 | } 422 | 423 | let range_part = parts[0]; 424 | let step_str = parts[1]; 425 | let step = step_str 426 | .parse::() 427 | .map_err(|_| CronError::ComponentError("Invalid step.".to_string()))?; 428 | 429 | self.step = step; 430 | 431 | if step == 0 { 432 | return Err(CronError::ComponentError( 433 | "Step cannot be zero.".to_string(), 434 | )); 435 | } 436 | 437 | let (start, end) = if range_part == "*" { 438 | self.from_wildcard = true; 439 | (self.min, self.max) 440 | } else if range_part.contains('-') { 441 | let bounds: Vec<&str> = range_part.split('-').collect(); 442 | if bounds.len() != 2 { 443 | return Err(CronError::ComponentError( 444 | "Invalid range syntax in stepping.".to_string(), 445 | )); 446 | } 447 | ( 448 | bounds[0] 449 | .parse::() 450 | .map_err(|_| CronError::ComponentError("Invalid range start.".to_string()))?, 451 | bounds[1] 452 | .parse::() 453 | .map_err(|_| CronError::ComponentError("Invalid range end.".to_string()))?, 454 | ) 455 | } else { 456 | let single_start = range_part 457 | .parse::() 458 | .map_err(|_| CronError::ComponentError("Invalid start.".to_string()))?; 459 | // If only one number is provided, set the range to go from the start value to the max value. 460 | (single_start, self.max) 461 | }; 462 | 463 | if start < self.min || end > self.max || start > end { 464 | return Err(CronError::ComponentError( 465 | "Range is out of bounds in stepping.".to_string(), 466 | )); 467 | } 468 | 469 | // Apply stepping within the range 470 | let mut value = start; 471 | while value <= end { 472 | self.set_bit(value, bit_to_set)?; 473 | value = value.checked_add(step).ok_or_else(|| { 474 | CronError::ComponentError("Value exceeded max after stepping.".to_string()) 475 | })?; 476 | } 477 | 478 | Ok(()) 479 | } 480 | } 481 | 482 | #[cfg(test)] 483 | mod tests { 484 | use super::*; 485 | use crate::errors::CronError; 486 | 487 | #[test] 488 | fn test_new_cron_component() { 489 | let component = CronComponent::new(0, 59, ALL_BIT | LAST_BIT, 0); 490 | assert_eq!(component.min, 0); 491 | assert_eq!(component.max, 59); 492 | // Ensure all bitfields are initialized to NONE_BIT 493 | assert!(component.bitfields.iter().all(|&b| b == NONE_BIT)); 494 | // Check that ALL_BIT and LAST_BIT are included in features 495 | assert!(component.features & (ALL_BIT | LAST_BIT) == (ALL_BIT | LAST_BIT)); 496 | } 497 | 498 | #[test] 499 | fn test_set_bit() { 500 | let mut component = CronComponent::new(0, 59, ALL_BIT, 0); 501 | assert!(component.set_bit(10, ALL_BIT).is_ok()); 502 | assert!(component.is_bit_set(10, ALL_BIT).unwrap()); 503 | } 504 | 505 | #[test] 506 | fn test_set_bit_out_of_bounds() { 507 | let mut component = CronComponent::new(0, 59, ALL_BIT, 0); 508 | assert!(matches!( 509 | component.set_bit(60, ALL_BIT), 510 | Err(CronError::ComponentError(_)) 511 | )); 512 | } 513 | 514 | #[test] 515 | fn test_unset_bit() { 516 | let mut component = CronComponent::new(0, 59, ALL_BIT, 0); 517 | component.set_bit(10, ALL_BIT).unwrap(); 518 | assert!(component.unset_bit(10, ALL_BIT).is_ok()); 519 | assert!(!component.is_bit_set(10, ALL_BIT).unwrap()); 520 | } 521 | 522 | #[test] 523 | fn test_is_feature_enabled() { 524 | let mut component = CronComponent::new(0, 59, LAST_BIT, 0); 525 | assert!(!component.is_feature_enabled(LAST_BIT)); 526 | component.enable_feature(LAST_BIT).unwrap(); 527 | assert!(component.is_feature_enabled(LAST_BIT)); 528 | } 529 | 530 | #[test] 531 | fn test_enable_feature_unsupported() { 532 | let mut component = CronComponent::new(0, 59, NONE_BIT, 0); 533 | assert!(matches!( 534 | component.enable_feature(NTH_1ST_BIT), 535 | Err(CronError::ComponentError(_)) 536 | )); 537 | } 538 | 539 | #[test] 540 | fn test_parse_asterisk() { 541 | let mut component = CronComponent::new(0, 59, ALL_BIT, 0); 542 | component.parse("*").unwrap(); 543 | for i in 0..=59 { 544 | assert!(component.is_bit_set(i, ALL_BIT).unwrap()); 545 | } 546 | } 547 | 548 | #[test] 549 | fn test_parse_range() { 550 | let mut component = CronComponent::new(0, 59, ALL_BIT, 0); 551 | component.parse("10-15").unwrap(); 552 | for i in 10..=15 { 553 | assert!(component.is_bit_set(i, ALL_BIT).unwrap()); 554 | } 555 | } 556 | 557 | #[test] 558 | fn test_parse_stepping() { 559 | let mut component = CronComponent::new(0, 59, ALL_BIT, 0); 560 | component.parse("*/5").unwrap(); 561 | for i in (0..=59).filter(|n| n % 5 == 0) { 562 | assert!(component.is_bit_set(i, ALL_BIT).unwrap()); 563 | } 564 | } 565 | 566 | #[test] 567 | fn test_parse_list() { 568 | let mut component = CronComponent::new(0, 59, ALL_BIT, 0); 569 | component.parse("5,10,15").unwrap(); 570 | for i in [5, 10, 15].iter() { 571 | assert!(component.is_bit_set(*i, ALL_BIT).unwrap()); 572 | } 573 | } 574 | 575 | #[test] 576 | fn test_parse_invalid_syntax() { 577 | let mut component = CronComponent::new(0, 59, ALL_BIT, 0); 578 | assert!(component.parse("10-").is_err()); 579 | assert!(component.parse("*/").is_err()); 580 | assert!(component.parse("60").is_err()); // out of bounds for the minute field 581 | } 582 | 583 | #[test] 584 | fn test_parse_closest_weekday() { 585 | let mut component = CronComponent::new(1, 31, CLOSEST_WEEKDAY_BIT, 0); 586 | component.parse("15W").unwrap(); 587 | assert!(component.is_bit_set(15, CLOSEST_WEEKDAY_BIT).unwrap()); 588 | // You might want to add more tests for edge cases 589 | } 590 | } 591 | -------------------------------------------------------------------------------- /src/describe/lang/english.rs: -------------------------------------------------------------------------------- 1 | use crate::describe::Language; 2 | 3 | #[derive(Default, Clone, Copy)] 4 | pub struct English; 5 | 6 | impl Language for English { 7 | fn every_minute(&self) -> &'static str { "Every minute" } 8 | fn every_second_phrase(&self) -> &'static str { "Every second" } 9 | fn every_x_minutes(&self, s: u16) -> String { format!("every {s} minutes") } 10 | fn every_x_seconds(&self, s: u16) -> String { format!("every {s} seconds") } 11 | fn every_x_hours(&self, s: u16) -> String { format!("of every {s} hours") } 12 | fn every_minute_of_every_x_hours(&self, s: u16) -> String { format!("Every minute, of every {s} hours") } 13 | 14 | fn at_time(&self, time: &str) -> String { format!("At {time}") } 15 | fn at_time_and_every_x_seconds(&self, time: &str, step: u16) -> String { format!("At {time}, every {step} seconds") } 16 | fn at_time_at_second(&self, time: &str, second: &str) -> String { format!("At {time}, at second {second}") } 17 | 18 | fn at_phrase(&self, phrase: &str) -> String { format!("At {phrase}") } 19 | fn on_phrase(&self, phrase: &str) -> String { format!("on {phrase}") } 20 | fn in_phrase(&self, phrase: &str) -> String { format!("in {phrase}") } 21 | 22 | fn second_phrase(&self, s: &str) -> String { format!("second {s}") } 23 | fn minute_phrase(&self, s: &str) -> String { format!("minute {s}") } 24 | fn minute_past_every_hour_phrase(&self, s: &str) -> String { format!("{s} past every hour") } 25 | fn hour_phrase(&self, s: &str) -> String { format!("of hour {s}") } 26 | fn year_phrase(&self, s: &str) -> String { format!("year {s}") } 27 | 28 | fn day_phrase(&self, s: &str) -> String { format!("day {s}") } 29 | fn the_last_day_of_the_month(&self) -> &'static str { "the last day of the month" } 30 | fn the_weekday_nearest_day(&self, day: &str) -> String { format!("the weekday nearest day {day}") } 31 | fn the_last_weekday_of_the_month(&self, day: &str) -> String { format!("the last {day} of the month") } 32 | 33 | fn the_nth_weekday_of_the_month(&self, n: u8, day: &str) -> String { 34 | let suffix = match n { 35 | 1 => "st", 36 | 2 => "nd", 37 | 3 => "rd", 38 | _ => "th", 39 | }; 40 | let num_str = format!("{n}{suffix}"); 41 | format!("the {num_str} {day} of the month") 42 | } 43 | 44 | fn dom_and_dow_if_also(&self, dow: &str) -> String { format!("(if it is also {dow})") } 45 | fn dom_and_dow_if_also_one_of(&self, dow: &str) -> String { format!("(if it is also one of: {dow})") } 46 | 47 | fn list_conjunction_and(&self) -> &'static str { "and" } 48 | fn list_conjunction_or(&self) -> &'static str { "or" } 49 | fn list_conjunction_and_comma(&self) -> &'static str { ", and" } 50 | 51 | fn day_of_week_names(&self) -> [&'static str; 7] { ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] } 52 | fn month_names(&self) -> [&'static str; 12] { ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] } 53 | } -------------------------------------------------------------------------------- /src/describe/lang/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod english; 2 | pub mod swedish; -------------------------------------------------------------------------------- /src/describe/lang/swedish.rs: -------------------------------------------------------------------------------- 1 | use crate::describe::Language; 2 | 3 | #[derive(Default, Clone, Copy)] 4 | pub struct Swedish; 5 | 6 | impl Language for Swedish { 7 | fn every_minute(&self) -> &'static str { "Varje minut" } 8 | fn every_second_phrase(&self) -> &'static str { "Varje sekund" } 9 | fn every_x_minutes(&self, s: u16) -> String { format!("var {s}:e minut") } 10 | fn every_x_seconds(&self, s: u16) -> String { format!("var {s}:e sekund") } 11 | fn every_x_hours(&self, s: u16) -> String { format!("var {s}:e timme") } 12 | fn every_minute_of_every_x_hours(&self, s: u16) -> String { format!("Varje minut, var {s}:e timme") } 13 | 14 | fn at_time(&self, time: &str) -> String { format!("Klockan {time}") } 15 | fn at_time_and_every_x_seconds(&self, time: &str, step: u16) -> String { format!("Klockan {time}, var {step}:e sekund") } 16 | fn at_time_at_second(&self, time: &str, second: &str) -> String { format!("Klockan {time}, på sekund {second}") } 17 | 18 | fn at_phrase(&self, phrase: &str) -> String { format!("Vid {phrase}") } 19 | fn on_phrase(&self, phrase: &str) -> String { format!("på {phrase}") } 20 | fn in_phrase(&self, phrase: &str) -> String { format!("i {phrase}") } 21 | 22 | fn second_phrase(&self, s: &str) -> String { format!("sekund {s}") } 23 | fn minute_phrase(&self, s: &str) -> String { format!("minut {s}") } 24 | fn minute_past_every_hour_phrase(&self, s: &str) -> String { format!("{s} över varje heltimme") } 25 | fn hour_phrase(&self, s: &str) -> String { format!("timme {s}") } 26 | fn year_phrase(&self, s: &str) -> String { format!("år {s}") } 27 | 28 | fn day_phrase(&self, s: &str) -> String { format!("dag {s}") } 29 | fn the_last_day_of_the_month(&self) -> &'static str { "sista dagen i månaden" } 30 | fn the_weekday_nearest_day(&self, day: &str) -> String { format!("veckodagen närmast dag {day}") } 31 | fn the_last_weekday_of_the_month(&self, day: &str) -> String { format!("sista {day} i månaden") } 32 | 33 | fn the_nth_weekday_of_the_month(&self, n: u8, day: &str) -> String { 34 | let ordinal = match n { 35 | 1 => "första", 36 | 2 => "andra", 37 | 3 => "tredje", 38 | 4 => "fjärde", 39 | 5 => "femte", 40 | _ => "", // Should not happen with cron's # specifier 41 | }; 42 | format!("den {ordinal} {day} i månaden") 43 | } 44 | 45 | fn dom_and_dow_if_also(&self, dow: &str) -> String { format!("(om det också är {dow})") } 46 | fn dom_and_dow_if_also_one_of(&self, dow: &str) -> String { format!("(om det också är en av: {dow})") } 47 | 48 | fn list_conjunction_and(&self) -> &'static str { "och" } 49 | fn list_conjunction_or(&self) -> &'static str { "eller" } 50 | fn list_conjunction_and_comma(&self) -> &'static str { "och" } // Oxford comma is not used in Swedish 51 | 52 | fn day_of_week_names(&self) -> [&'static str; 7] { ["söndag", "måndag", "tisdag", "onsdag", "torsdag", "fredag", "lördag"] } 53 | fn month_names(&self) -> [&'static str; 12] { ["januari", "februari", "mars", "april", "maj", "juni", "juli", "augusti", "september", "oktober", "november", "december"] } 54 | } -------------------------------------------------------------------------------- /src/describe/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod lang; 2 | pub use lang::english::English; 3 | 4 | use crate::component::{ 5 | CronComponent, ALL_BIT, CLOSEST_WEEKDAY_BIT, LAST_BIT, NTH_1ST_BIT, NTH_2ND_BIT, NTH_3RD_BIT, 6 | NTH_4TH_BIT, NTH_5TH_BIT, 7 | }; 8 | use crate::pattern::CronPattern; 9 | 10 | // This defines the contract for providing localized strings. 11 | pub trait Language { 12 | fn every_minute(&self) -> &'static str; 13 | fn every_second_phrase(&self) -> &'static str; 14 | fn every_x_minutes(&self, step: u16) -> String; // Changed to u16 15 | fn every_x_seconds(&self, step: u16) -> String; // Changed to u16 16 | fn every_x_hours(&self, step: u16) -> String; // Changed to u16 17 | fn every_minute_of_every_x_hours(&self, step: u16) -> String; // Changed to u16 18 | 19 | fn at_time(&self, time: &str) -> String; 20 | fn at_time_and_every_x_seconds(&self, time: &str, step: u16) -> String; // Changed to u16 21 | fn at_time_at_second(&self, time: &str, second: &str) -> String; 22 | 23 | fn at_phrase(&self, phrase: &str) -> String; 24 | fn on_phrase(&self, phrase: &str) -> String; 25 | fn in_phrase(&self, phrase: &str) -> String; 26 | 27 | fn second_phrase(&self, s: &str) -> String; 28 | fn minute_phrase(&self, s: &str) -> String; 29 | fn minute_past_every_hour_phrase(&self, s: &str) -> String; 30 | fn hour_phrase(&self, s: &str) -> String; 31 | fn year_phrase(&self, s: &str) -> String; // New for year 32 | 33 | fn day_phrase(&self, s: &str) -> String; 34 | fn the_last_day_of_the_month(&self) -> &'static str; 35 | fn the_weekday_nearest_day(&self, day: &str) -> String; 36 | fn the_last_weekday_of_the_month(&self, day: &str) -> String; 37 | fn the_nth_weekday_of_the_month(&self, n: u8, day: &str) -> String; 38 | 39 | fn dom_and_dow_if_also(&self, dow: &str) -> String; 40 | fn dom_and_dow_if_also_one_of(&self, dow: &str) -> String; 41 | 42 | fn list_conjunction_and(&self) -> &'static str; 43 | fn list_conjunction_or(&self) -> &'static str; 44 | fn list_conjunction_and_comma(&self) -> &'static str; 45 | 46 | fn day_of_week_names(&self) -> [&'static str; 7]; 47 | fn month_names(&self) -> [&'static str; 12]; 48 | } 49 | 50 | /// Generates a human-readable description for a `CronPattern`. 51 | pub fn describe(pattern: &CronPattern, lang: &L) -> String { 52 | let time_desc = describe_time(pattern, lang); 53 | let day_desc = describe_day(pattern, lang); 54 | let month_desc = describe_month(pattern, lang); 55 | let year_desc = describe_year(pattern, lang); // Add year description 56 | 57 | let mut parts = vec![]; 58 | if !time_desc.is_empty() { 59 | parts.push(time_desc); 60 | } 61 | if !day_desc.is_empty() { 62 | parts.push(day_desc); 63 | } 64 | if !month_desc.is_empty() { 65 | parts.push(month_desc); 66 | } 67 | if !year_desc.is_empty() { 68 | parts.push(year_desc); 69 | } 70 | 71 | let mut description = parts.join(", "); 72 | if !description.is_empty() { 73 | let mut chars = description.chars(); 74 | description = match chars.next() { 75 | None => String::new(), 76 | Some(f) => f.to_uppercase().collect::() + chars.as_str(), 77 | }; 78 | description.push('.'); 79 | } 80 | description 81 | } 82 | 83 | /// Helper function to determine if a component is fully set (like a wildcard `*`). 84 | fn is_all_set(component: &CronComponent) -> bool { 85 | // We can't just check for a wildcard flag anymore. 86 | // A component is "all set" if every possible value is included. 87 | if component.step != 1 { 88 | return false; 89 | } 90 | let total_values = (component.max - component.min + 1) as usize; 91 | // Handle large ranges efficiently 92 | if total_values > 10000 { // Heuristic for very large ranges like year 93 | return component.from_wildcard; 94 | } 95 | let set_values = (component.min..=component.max) 96 | .filter(|i| component.is_bit_set(*i, ALL_BIT).unwrap_or(false)) // Corrected 97 | .count(); 98 | total_values == set_values 99 | } 100 | 101 | fn describe_time(pattern: &CronPattern, lang: &L) -> String { 102 | 103 | let sec_vals = get_set_values(&pattern.seconds, ALL_BIT); 104 | let min_vals = get_set_values(&pattern.minutes, ALL_BIT); 105 | let hour_vals = get_set_values(&pattern.hours, ALL_BIT); 106 | 107 | let is_default_seconds = pattern.seconds.step == 1 && sec_vals.len() == 1 && sec_vals[0] == 0; 108 | let is_every_second = is_all_set(&pattern.seconds); 109 | 110 | // Heuristic to detect `*/step` patterns, replacing `from_wildcard`. 111 | let is_stepped_from_start = 112 | |step: u16, vals: &[u16], min: u16| step > 1 && !vals.is_empty() && vals[0] == min; 113 | 114 | // Handle simplest cases first 115 | if is_every_second && is_all_set(&pattern.minutes) && is_all_set(&pattern.hours) { 116 | return lang.every_second_phrase().to_string(); 117 | } 118 | if is_default_seconds && is_all_set(&pattern.minutes) && is_all_set(&pattern.hours) { 119 | return lang.every_minute().to_string(); 120 | } 121 | if is_default_seconds 122 | && is_stepped_from_start(pattern.minutes.step, &min_vals, pattern.minutes.min) 123 | && is_all_set(&pattern.hours) 124 | { 125 | return lang.at_phrase(&lang.every_x_minutes(pattern.minutes.step)); 126 | } 127 | 128 | // Handle specific HH:MM time 129 | if !is_every_second 130 | && pattern.hours.step == 1 && hour_vals.len() == 1 131 | && pattern.minutes.step == 1 && min_vals.len() == 1 132 | { 133 | let time_str = format!("{:02}:{:02}", hour_vals[0], min_vals[0]); 134 | 135 | if !is_default_seconds { 136 | if is_stepped_from_start(pattern.seconds.step, &sec_vals, pattern.seconds.min) { 137 | return lang.at_time_and_every_x_seconds(&time_str, pattern.seconds.step); 138 | } 139 | if sec_vals.len() == 1 { 140 | return lang.at_time(&format!("{}:{:02}", time_str, sec_vals[0])); 141 | } 142 | return lang.at_time_at_second(&time_str, &format_number_list(&sec_vals, lang)); 143 | } 144 | return lang.at_time(&time_str); 145 | } 146 | 147 | // Handle all other complex combinations 148 | let mut parts = vec![]; 149 | 150 | if is_every_second { 151 | parts.push(lang.every_second_phrase().to_string()); 152 | } else if !is_default_seconds { 153 | if is_stepped_from_start(pattern.seconds.step, &sec_vals, pattern.seconds.min) { 154 | parts.push(lang.every_x_seconds(pattern.seconds.step)); 155 | } else { 156 | parts.push(lang.second_phrase(&format_number_list(&sec_vals, lang))); 157 | } 158 | } 159 | 160 | if is_stepped_from_start(pattern.minutes.step, &min_vals, pattern.minutes.min) { 161 | parts.push(lang.every_x_minutes(pattern.minutes.step)); 162 | } else if !is_all_set(&pattern.minutes) { 163 | let min_desc = lang.minute_phrase(&format_number_list(&min_vals, lang)); 164 | if is_all_set(&pattern.hours) && pattern.hours.step == 1 { 165 | parts.push(lang.minute_past_every_hour_phrase(&min_desc)); 166 | } else { 167 | parts.push(min_desc); 168 | } 169 | } 170 | 171 | if !is_all_set(&pattern.hours) { 172 | if is_stepped_from_start(pattern.hours.step, &hour_vals, pattern.hours.min) { 173 | parts.push(lang.every_x_hours(pattern.hours.step)); 174 | } else { 175 | parts.push(lang.hour_phrase(&format_number_list(&hour_vals, lang))); 176 | } 177 | } 178 | 179 | if parts.is_empty() { 180 | return lang.every_minute().to_string(); 181 | } 182 | 183 | if parts.len() > 1 && parts[0] == lang.every_second_phrase() { 184 | return parts.join(", "); 185 | } 186 | 187 | lang.at_phrase(&parts.join(", ")) 188 | } 189 | 190 | fn get_set_values(component: &CronComponent, bit: u8) -> Vec { 191 | (component.min..=component.max) 192 | .filter(|i| component.is_bit_set(*i, bit).unwrap_or(false)) // Corrected 193 | .collect() 194 | } 195 | 196 | fn format_text_list(items: Vec, lang: &L) -> String { 197 | match items.len() { 198 | 0 => String::new(), 199 | 1 => items[0].clone(), 200 | 2 => format!("{} {} {}", items[0], lang.list_conjunction_and(), items[1]), 201 | _ => { 202 | if let Some(last) = items.last() { 203 | let front = &items[..items.len() - 1]; 204 | format!("{}, {} {}", front.join(", "), lang.list_conjunction_and(), last) 205 | } else { 206 | String::new() 207 | } 208 | } 209 | } 210 | } 211 | 212 | fn format_number_list(values: &[u16], lang: &L) -> String { 213 | if values.is_empty() { 214 | return String::new(); 215 | } 216 | let mut sorted_values = values.to_vec(); 217 | sorted_values.sort_unstable(); 218 | 219 | let mut items = vec![]; 220 | let mut i = 0; 221 | while i < sorted_values.len() { 222 | let start = sorted_values[i]; 223 | let mut j = i; 224 | while j + 1 < sorted_values.len() && sorted_values[j + 1] == sorted_values[j] + 1 { 225 | j += 1; 226 | } 227 | if j > i + 1 { // Only create a range for 3 or more consecutive numbers 228 | items.push(format!("{}-{}", start, sorted_values[j])); 229 | } else { 230 | for k in sorted_values.iter().take(j + 1).skip(i) { 231 | items.push(k.to_string()); 232 | } 233 | } 234 | i = j + 1; 235 | } 236 | format_text_list(items, lang) 237 | } 238 | 239 | fn describe_day(pattern: &CronPattern, lang: &L) -> String { 240 | let dom_desc = describe_dom(pattern, lang); 241 | let dow_parts = describe_dow_parts(pattern, lang); 242 | 243 | if pattern.star_dom && pattern.star_dow { 244 | return "".to_string(); 245 | } 246 | 247 | if !pattern.star_dom && pattern.star_dow { 248 | return lang.on_phrase(&dom_desc); 249 | } 250 | 251 | let dow_desc = format_text_list(dow_parts.clone(), lang); 252 | if pattern.star_dom && !pattern.star_dow { 253 | return lang.on_phrase(&dow_desc); 254 | } 255 | 256 | if pattern.dom_and_dow { 257 | let final_phrase = if dow_parts.len() > 1 { 258 | lang.dom_and_dow_if_also_one_of(&dow_desc) 259 | } else { 260 | lang.dom_and_dow_if_also(&dow_desc) 261 | }; 262 | format!("{} {}", lang.on_phrase(&dom_desc), final_phrase) 263 | } else { 264 | format!("{} {} {}", lang.on_phrase(&dom_desc), lang.list_conjunction_or(), dow_desc) 265 | } 266 | } 267 | 268 | fn describe_dom(pattern: &CronPattern, lang: &L) -> String { 269 | let mut parts = vec![]; 270 | 271 | let regular_days = get_set_values(&pattern.days, ALL_BIT); 272 | if !regular_days.is_empty() { 273 | parts.push(lang.day_phrase(&format_number_list(®ular_days, lang))); 274 | } 275 | 276 | if pattern.days.is_feature_enabled(LAST_BIT) { 277 | parts.push(lang.the_last_day_of_the_month().to_string()); 278 | } 279 | let weekday_values = get_set_values(&pattern.days, CLOSEST_WEEKDAY_BIT); 280 | if !weekday_values.is_empty() { 281 | parts.push(lang.the_weekday_nearest_day(&format_number_list(&weekday_values, lang))); 282 | } 283 | 284 | format_text_list(parts, lang) 285 | } 286 | 287 | fn describe_dow_parts(pattern: &CronPattern, lang: &L) -> Vec { 288 | let mut parts = vec![]; 289 | let dow_names_map = lang.day_of_week_names(); 290 | 291 | // The `with_alternative_weekdays` flag is gone. Parser normalizes DOW. 292 | // This mapping handles the 0-7 range where 7 is Sunday. 293 | let dow_names = &[ 294 | dow_names_map[0], dow_names_map[1], dow_names_map[2], dow_names_map[3], 295 | dow_names_map[4], dow_names_map[5], dow_names_map[6], dow_names_map[0], 296 | ]; 297 | 298 | let last_values = get_set_values(&pattern.days_of_week, LAST_BIT); 299 | if !last_values.is_empty() { 300 | let days = last_values.iter().map(|v| dow_names[*v as usize].to_string()).collect::>(); // Corrected 301 | parts.push(lang.the_last_weekday_of_the_month(&format_text_list(days, lang))); 302 | } 303 | 304 | for (i, nth_bit) in [NTH_1ST_BIT, NTH_2ND_BIT, NTH_3RD_BIT, NTH_4TH_BIT, NTH_5TH_BIT].iter().enumerate() { 305 | let values = get_set_values(&pattern.days_of_week, *nth_bit); 306 | if !values.is_empty() { 307 | let days = values.iter().map(|v| dow_names[*v as usize].to_string()).collect::>(); // Corrected 308 | parts.push(lang.the_nth_weekday_of_the_month((i + 1) as u8, &format_text_list(days, lang))); 309 | } 310 | } 311 | 312 | let regular_values = get_set_values(&pattern.days_of_week, ALL_BIT); 313 | if !regular_values.is_empty() { 314 | let list = regular_values.iter().map(|v| dow_names[*v as usize].to_string()).collect::>(); // Corrected 315 | parts.push(format_text_list(list, lang)); 316 | } 317 | parts 318 | } 319 | 320 | fn describe_month(pattern: &CronPattern, lang: &L) -> String { 321 | if is_all_set(&pattern.months) { 322 | return "".to_string(); 323 | } 324 | let month_names = lang.month_names(); 325 | 326 | if pattern.months.step > 1 { 327 | return lang.in_phrase(&format!("every {} months", pattern.months.step)); 328 | } 329 | 330 | let values = get_set_values(&pattern.months, ALL_BIT); 331 | let list = values 332 | .iter() 333 | .map(|v| month_names[*v as usize - 1].to_string()) // Corrected 334 | .collect::>(); 335 | lang.in_phrase(&format_text_list(list, lang)) 336 | } 337 | 338 | fn describe_year(pattern: &CronPattern, lang: &L) -> String { 339 | if is_all_set(&pattern.years) { 340 | return "".to_string(); 341 | } 342 | 343 | if pattern.years.step > 1 { 344 | return lang.in_phrase(&lang.year_phrase(&format!("every {}", pattern.years.step))); 345 | } 346 | 347 | let values = get_set_values(&pattern.years, ALL_BIT); 348 | lang.in_phrase(&lang.year_phrase(&format_number_list(&values, lang))) 349 | } 350 | 351 | 352 | #[cfg(test)] 353 | mod tests { 354 | use super::lang::english::English; 355 | use crate::parser::{CronParser, Seconds, Year}; 356 | use super::Language; 357 | 358 | // Updated helper to use the new parser API 359 | fn get_description_lang_config( 360 | pattern_str: &str, 361 | lang: L, 362 | seconds: Seconds, 363 | year: Year, 364 | dom_and_dow: bool, 365 | ) -> String { 366 | let cron = CronParser::builder() 367 | .seconds(seconds) 368 | .year(year) 369 | .dom_and_dow(dom_and_dow) 370 | .build() 371 | .parse(pattern_str) 372 | .expect("Failed to parse pattern for test"); 373 | 374 | // The user wants to test the describe function in this module 375 | super::describe(&cron.pattern, &lang) 376 | } 377 | 378 | // Simplified helper for common cases. 379 | // It uses a permissive parser config that can handle any pattern 380 | // since the parser normalizes the pattern before describe is called. 381 | fn get_description(pattern_str: &str) -> String { 382 | get_description_lang_config( 383 | pattern_str, 384 | English, 385 | Seconds::Optional, // Be permissive 386 | Year::Optional, // Be permissive 387 | false, 388 | ) 389 | } 390 | 391 | #[test] 392 | fn test_time_descriptions() { 393 | assert_eq!(get_description("* * * * *"), "Every minute."); 394 | assert_eq!(get_description("*/15 * * * *"), "At every 15 minutes."); 395 | assert_eq!(get_description("0 * * * *"), "At minute 0 past every hour."); 396 | assert_eq!(get_description("0 14 * * *"), "At 14:00."); 397 | assert_eq!( 398 | get_description("2,4,6 * * * *"), 399 | "At minute 2, 4, and 6 past every hour." 400 | ); 401 | assert_eq!( 402 | get_description("0 0-6 * * *"), 403 | "At minute 0, of hour 0-6." 404 | ); 405 | assert_eq!( 406 | get_description("0 */2 * * *"), 407 | "At minute 0, of every 2 hours." 408 | ); 409 | } 410 | 411 | #[test] 412 | fn test_seconds_descriptions() { 413 | assert_eq!(get_description("*/10 * * * * *"), "At every 10 seconds."); 414 | assert_eq!(get_description("30 0 14 * * *"), "At 14:00:30."); 415 | assert_eq!( 416 | get_description("10-20 0 14 * * *"), 417 | "At 14:00, at second 10-20." 418 | ); 419 | } 420 | 421 | #[test] 422 | fn test_year_descriptions() { 423 | assert_eq!( 424 | get_description("0 0 0 1 1 * 2025"), 425 | "At 00:00, on day 1, in January, in year 2025." 426 | ); 427 | assert_eq!( 428 | get_description("0 0 0 1 1 * 2025-2030"), 429 | "At 00:00, on day 1, in January, in year 2025-2030." 430 | ); 431 | } 432 | 433 | #[test] 434 | fn test_day_descriptions() { 435 | assert_eq!(get_description("0 12 * * MON"), "At 12:00, on Monday."); 436 | assert_eq!( 437 | get_description("0 12 * * 1-5"), 438 | "At 12:00, on Monday, Tuesday, Wednesday, Thursday, and Friday." 439 | ); 440 | assert_eq!(get_description("0 12 15 * *"), "At 12:00, on day 15."); 441 | assert_eq!( 442 | get_description("0 12 L * *"), 443 | "At 12:00, on the last day of the month." 444 | ); 445 | assert_eq!( 446 | get_description("0 12 1,15 * *"), 447 | "At 12:00, on day 1 and 15." 448 | ); 449 | } 450 | 451 | #[test] 452 | fn test_month_descriptions() { 453 | assert_eq!(get_description("* * * JAN *"), "Every minute, in January."); 454 | assert_eq!( 455 | get_description("* * * 1,3,5 *"), 456 | "Every minute, in January, March, and May." 457 | ); 458 | } 459 | 460 | #[test] 461 | fn test_special_char_descriptions() { 462 | assert_eq!( 463 | get_description("* * * * 5L"), 464 | "Every minute, on the last Friday of the month." 465 | ); 466 | assert_eq!( 467 | get_description("* * * * TUE#3"), 468 | "Every minute, on the 3rd Tuesday of the month." 469 | ); 470 | assert_eq!( 471 | get_description("* * 15W * *"), 472 | "Every minute, on the weekday nearest day 15." 473 | ); 474 | } 475 | 476 | #[test] 477 | fn test_dom_and_dow_logic() { 478 | // Default behavior (OR) 479 | let or_desc = get_description("0 0 15 * FRI"); 480 | assert_eq!(or_desc, "At 00:00, on day 15 or Friday."); 481 | 482 | // AND behavior 483 | let and_desc = 484 | get_description_lang_config("0 0 15 * FRI", English, Seconds::Optional, Year::Optional, true); 485 | assert_eq!( 486 | and_desc, 487 | "At 00:00, on day 15 (if it is also Friday)." 488 | ); 489 | } 490 | 491 | #[test] 492 | fn test_complex_combinations() { 493 | assert_eq!( 494 | get_description("30 18 15,L MAR *"), 495 | "At 18:30, on day 15 and the last day of the month, in March." 496 | ); 497 | 498 | let and_desc = get_description_lang_config("30 18 15,L MAR FRI", English, Seconds::Optional, Year::Optional, true); 499 | assert_eq!( 500 | and_desc, 501 | "At 18:30, on day 15 and the last day of the month (if it is also Friday), in March." 502 | ); 503 | } 504 | 505 | #[test] 506 | fn test_second_and_minute_steps() { 507 | assert_eq!( 508 | get_description("* */2 * * * *"), 509 | "Every second, every 2 minutes." 510 | ) 511 | } 512 | 513 | #[test] 514 | fn test_ranged_steps() { 515 | assert_eq!( 516 | get_description("18-28/2 * * * * *"), 517 | "At second 18, 20, 22, 24, 26, and 28." 518 | ); 519 | } 520 | 521 | #[test] 522 | fn test_complex_dom_and_dow() { 523 | let desc = get_description_lang_config("0 0 1 * FRI#L,MON#1", English, Seconds::Optional, Year::Optional, true); 524 | assert_eq!( 525 | desc, 526 | "At 00:00, on day 1 (if it is also one of: the last Friday of the month and the 1st Monday of the month)." 527 | ); 528 | } 529 | } -------------------------------------------------------------------------------- /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, CronError, Direction}; 2 | use chrono::{DateTime, TimeZone, Duration}; 3 | 4 | #[derive(Debug, Clone, PartialEq, PartialOrd, Hash)] 5 | pub struct CronIterator 6 | where 7 | Tz: TimeZone, 8 | { 9 | cron: Cron, 10 | current_time: DateTime, 11 | is_first: bool, 12 | inclusive: bool, 13 | direction: Direction, 14 | pending_ambiguous_dt: Option>, 15 | } 16 | 17 | impl CronIterator 18 | where 19 | Tz: TimeZone, 20 | { 21 | /// Creates a new `CronIterator`. 22 | /// 23 | /// # Arguments 24 | /// 25 | /// * `cron` - The `Cron` schedule instance. 26 | /// * `start_time` - The `DateTime` to start iterating from. 27 | /// * `inclusive` - Whether the `start_time` should be included in the results if it matches. 28 | /// * `direction` - The direction to iterate in (Forward or Backward). 29 | pub fn new( 30 | cron: Cron, 31 | start_time: DateTime, 32 | inclusive: bool, 33 | direction: Direction, 34 | ) -> Self { 35 | CronIterator { 36 | cron, 37 | current_time: start_time, 38 | is_first: true, 39 | inclusive, 40 | direction, 41 | pending_ambiguous_dt: None, 42 | } 43 | } 44 | } 45 | 46 | impl Iterator for CronIterator 47 | where 48 | Tz: TimeZone + Clone + Copy, 49 | { 50 | type Item = DateTime; 51 | 52 | fn next(&mut self) -> Option { 53 | // Step 1: Check for and yield a pending ambiguous datetime first. 54 | // This handles the second occurrence of a time during DST fallback. 55 | if let Some(pending_dt_to_yield) = self.pending_ambiguous_dt.take() { 56 | // After yielding the second ambiguous time, advance current_time past it. 57 | // Clone pending_dt_to_yield because it's about to be returned, 58 | // but we need its value to calculate the next `self.current_time`. 59 | self.current_time = pending_dt_to_yield.clone().checked_add_signed(match self.direction { // Fixed E0382: pending_dt_to_yield 60 | Direction::Forward => Duration::seconds(1), 61 | Direction::Backward => Duration::seconds(-1), 62 | }).ok_or(CronError::InvalidTime).ok()?; 63 | return Some(pending_dt_to_yield); 64 | } 65 | 66 | // Determine if the search should be inclusive based on whether it's the first run. 67 | let inclusive_search = if self.is_first { 68 | self.is_first = false; 69 | self.inclusive 70 | } else { 71 | false // Subsequent searches are always exclusive of the last actual point in time. 72 | }; 73 | 74 | let result = self.cron.find_occurrence(&self.current_time, inclusive_search, self.direction); 75 | 76 | match result { 77 | Ok((found_time, optional_second_ambiguous_dt)) => { 78 | // This `found_time` is the one we will return in this iteration. 79 | 80 | // If there's a second ambiguous datetime (for interval jobs), 81 | // store it to be yielded on the *next* call to next(). 82 | // And importantly, set `self.current_time` to advance *past* this second ambiguous time 83 | // so the *next* search for a *new* naive time is correct. 84 | if let Some(second_ambiguous_dt) = optional_second_ambiguous_dt { 85 | // Clone second_ambiguous_dt because it's stored in self.pending_ambiguous_dt 86 | // AND used to calculate the next self.current_time. 87 | self.pending_ambiguous_dt = Some(second_ambiguous_dt.clone()); // Fixed E0382: second_ambiguous_dt 88 | 89 | // Advance `self.current_time` past the latest of the ambiguous pair. 90 | // This ensures the next `find_occurrence` call searches for the next unique naive time. 91 | self.current_time = second_ambiguous_dt.checked_add_signed(match self.direction { 92 | Direction::Forward => Duration::seconds(1), 93 | Direction::Backward => Duration::seconds(-1), 94 | }).ok_or(CronError::InvalidTime).ok()?; 95 | 96 | } else { 97 | // Case: No second ambiguous time (either not an overlap, or fixed-time job). 98 | // Advance `self.current_time` simply past the `found_time`. 99 | // Clone found_time because it's used to calculate the next self.current_time 100 | // AND returned at the end of this block. 101 | self.current_time = found_time.clone().checked_add_signed(match self.direction { // Fixed E0382: found_time 102 | Direction::Forward => Duration::seconds(1), 103 | Direction::Backward => Duration::seconds(-1), 104 | }).ok_or(CronError::InvalidTime).ok()?; 105 | } 106 | 107 | // Finally, return the found_time for the current iteration. 108 | // This `found_time` is the original value received from `find_occurrence`. 109 | Some(found_time) 110 | } 111 | Err(CronError::TimeSearchLimitExceeded) => None, 112 | Err(e) => { 113 | eprintln!("CronIterator encountered an error: {e:?}"); 114 | None 115 | } 116 | } 117 | } 118 | } -------------------------------------------------------------------------------- /src/parser.rs: -------------------------------------------------------------------------------- 1 | //! Parser for Cron patterns. 2 | //! 3 | //! Croner uses [`CronParser`] to parse the cron expression. Invoking 4 | //! 5 | //! ```rust 6 | //! # use std::str::FromStr as _; 7 | //! # 8 | //! # use croner::{Cron, parser::CronParser}; 9 | //! # 10 | //! Cron::from_str("pattern"); 11 | //! ``` 12 | //! 13 | //! is equivalent to 14 | //! 15 | //! ```rust 16 | //! # use std::str::FromStr as _; 17 | //! # 18 | //! # use croner::{Cron, parser::CronParser}; 19 | //! # 20 | //! CronParser::new().parse("pattern"); 21 | //! ``` 22 | //! 23 | //! You can customise the parser by creating a parser builder using 24 | //! [`CronParser::builder`]. So, for example, to parse cron patterns with 25 | //! optional seconds do something like this: 26 | //! 27 | //! ```rust 28 | //! use croner::parser::{CronParser, Seconds}; 29 | //! 30 | //! // Configure the parser to allow seconds. 31 | //! let parser = CronParser::builder().seconds(Seconds::Optional).build(); 32 | //! 33 | //! let cron_with_seconds = parser 34 | //! .parse("*/10 * * * * *") 35 | //! .unwrap(); 36 | //! let cron_without_seconds = parser 37 | //! .parse("* * * * *") 38 | //! .unwrap(); 39 | //! ``` 40 | 41 | use derive_builder::Builder; 42 | use strum::EnumIs; 43 | 44 | use crate::{ 45 | component::{ 46 | CronComponent, ALL_BIT, CLOSEST_WEEKDAY_BIT, LAST_BIT, NONE_BIT, NTH_1ST_BIT, NTH_2ND_BIT, 47 | NTH_3RD_BIT, NTH_4TH_BIT, NTH_5TH_BIT, NTH_ALL, 48 | }, 49 | errors::CronError, 50 | pattern::CronPattern, 51 | Cron, YEAR_LOWER_LIMIT, YEAR_UPPER_LIMIT, 52 | }; 53 | 54 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, EnumIs)] 55 | pub enum Seconds { 56 | #[default] 57 | Optional, 58 | Required, 59 | Disallowed, 60 | } 61 | 62 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, EnumIs)] 63 | pub enum Year { 64 | #[default] 65 | Optional, 66 | Required, 67 | Disallowed, 68 | } 69 | 70 | /// Parser for Cron patterns. 71 | /// 72 | /// In order to build a custom cron parser use [`CronParser::builder`]. 73 | #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Builder)] 74 | #[builder(default, build_fn(skip), pattern = "owned")] 75 | pub struct CronParser { 76 | /// Configure how seconds should be handled. 77 | seconds: Seconds, 78 | /// Configure how years should be handled. 79 | year: Year, 80 | /// Enable the combination of Day of Month (DOM) and Day of Week (DOW) conditions. 81 | dom_and_dow: bool, 82 | /// Use the Quartz-style weekday mode. 83 | alternative_weekdays: bool, 84 | } 85 | 86 | impl CronParser { 87 | /// Create a new parser. 88 | /// 89 | /// You should probably be using [`Cron`]'s implementation of 90 | /// [`FromStr`][std::str::FromStr] instead of invoking this. 91 | pub fn new() -> Self { 92 | Self::default() 93 | } 94 | 95 | /// Construct a builder for custom parsing. 96 | /// 97 | /// Equivalent to [`CronParserBuilder::default`]. 98 | pub fn builder() -> CronParserBuilder { 99 | CronParserBuilder::default() 100 | } 101 | 102 | /// Parses the cron pattern string. 103 | pub fn parse(&self, pattern: &str) -> Result { 104 | // Ensure upper case in parsing, and trim it 105 | let mut pattern: String = pattern.to_uppercase().trim().to_string(); 106 | 107 | // Should already be trimmed 108 | if pattern.is_empty() { 109 | return Err(CronError::EmptyPattern); 110 | } 111 | 112 | // Handle @nicknames 113 | if pattern.contains('@') { 114 | pattern = Self::handle_nicknames(&pattern, self.seconds.is_required(), self.year.is_required()).to_string(); 115 | } 116 | 117 | // Handle day-of-week and month aliases (MON... and JAN...) 118 | pattern = Self::replace_alpha_weekdays(&pattern, self.alternative_weekdays).to_string(); 119 | pattern = Self::replace_alpha_months(&pattern).to_string(); 120 | 121 | // Split the pattern into parts 122 | let mut parts: Vec<&str> = pattern.split_whitespace().collect(); 123 | let num_parts = parts.len(); 124 | 125 | // Default seconds to "0" if omitted in an optional context 126 | if num_parts == 5 { 127 | parts.insert(0, "0"); 128 | } else if self.seconds.is_disallowed() { 129 | return Err(CronError::InvalidPattern("Pattern must have 5 fields when seconds are disallowed.".to_string())); 130 | } 131 | 132 | // Default year to "*" if omitted in an optional context 133 | if parts.len() == 6 { 134 | parts.push("*"); 135 | } else if self.year.is_disallowed() { 136 | return Err(CronError::InvalidPattern("Pattern must have 5 or 6 fields when years are disallowed.".to_string())); 137 | } 138 | 139 | // Validate pattern length based on configuration 140 | if self.seconds.is_required() { 141 | if self.year.is_required() && num_parts != 7 { 142 | return Err(CronError::InvalidPattern("Pattern must have 7 fields when seconds and years are required.".to_string())); 143 | } 144 | if self.year.is_disallowed() && num_parts != 6 { 145 | return Err(CronError::InvalidPattern("Pattern must have 6 fields when seconds are required and years are disallowed.".to_string())); 146 | } 147 | if self.year.is_optional() && !(6..=7).contains(&num_parts) { 148 | return Err(CronError::InvalidPattern("Pattern must have 6 or 7 fields when seconds are required and years are optional.".to_string())); 149 | } 150 | } else if self.year.is_required() && num_parts != 7 { 151 | return Err(CronError::InvalidPattern("Pattern must have 7 fields when years are required.".to_string())); 152 | } else if !(5..=7).contains(&num_parts) { 153 | return Err(CronError::InvalidPattern("Pattern must have between 5 and 7 fields.".to_string())); 154 | } 155 | 156 | // Replace ? with * in day-of-month and day-of-week 157 | let mut owned_parts = parts.iter().map(|s| s.to_string()).collect::>(); 158 | if owned_parts.get(3).is_some_and(|p| p.contains('?')) { 159 | owned_parts[3] = owned_parts[3].replace('?', "*"); 160 | } 161 | if owned_parts.get(5).is_some_and(|p| p.contains('?')) { 162 | owned_parts[5] = owned_parts[5].replace('?', "*"); 163 | } 164 | 165 | // Check for the '+' (AND) modifier in the day-of-week field. 166 | // This must be done before illegal character validation. 167 | let mut dom_and_dow_from_pattern = false; 168 | if let Some(dow_part) = owned_parts.get_mut(5) { 169 | if dow_part.starts_with('+') { 170 | dom_and_dow_from_pattern = true; 171 | // Remove the '+' so the rest of the field can be parsed normally. 172 | *dow_part = dow_part[1..].to_string(); 173 | } 174 | } 175 | parts = owned_parts.iter().map(|s| s.as_str()).collect(); 176 | 177 | // Throw at illegal characters 178 | self.throw_at_illegal_characters(&parts)?; 179 | 180 | // Handle star-dom and star-dow 181 | let star_dom = parts.get(3).is_some_and(|&p| p == "*"); 182 | let star_dow = parts.get(5).is_some_and(|&p| p == "*"); 183 | 184 | // Parse the individual components 185 | let mut seconds = CronComponent::new(0, 59, NONE_BIT, 0); 186 | seconds.parse(parts[0])?; 187 | 188 | let mut minutes = CronComponent::new(0, 59, NONE_BIT, 0); 189 | minutes.parse(parts[1])?; 190 | 191 | let mut hours = CronComponent::new(0, 23, NONE_BIT, 0); 192 | hours.parse(parts[2])?; 193 | let mut days = CronComponent::new(1, 31, LAST_BIT | CLOSEST_WEEKDAY_BIT, 0); 194 | days.parse(parts[3])?; 195 | let mut months = CronComponent::new(1, 12, NONE_BIT, 0); 196 | months.parse(parts[4])?; 197 | 198 | let mut days_of_week = if self.alternative_weekdays { 199 | CronComponent::new(0, 7, LAST_BIT | NTH_ALL, 1) 200 | } else { 201 | CronComponent::new(0, 7, LAST_BIT | NTH_ALL, 0) 202 | }; 203 | days_of_week.parse(parts[5])?; 204 | 205 | let mut years = CronComponent::new(YEAR_LOWER_LIMIT as u16, YEAR_UPPER_LIMIT as u16, NONE_BIT, 0); // Placeholder, real limits are i32 206 | years.parse(parts[6])?; 207 | 208 | // Handle conversion of 7 to 0 for day_of_week if necessary 209 | if !self.alternative_weekdays { 210 | for nth_bit in [ 211 | ALL_BIT, 212 | NTH_1ST_BIT, 213 | NTH_2ND_BIT, 214 | NTH_3RD_BIT, 215 | NTH_4TH_BIT, 216 | NTH_5TH_BIT, 217 | ] { 218 | if days_of_week.is_bit_set(7, nth_bit)? { 219 | days_of_week.unset_bit(7, nth_bit)?; 220 | days_of_week.set_bit(0, nth_bit)?; 221 | } 222 | } 223 | } 224 | 225 | Ok(Cron { 226 | pattern: CronPattern { 227 | pattern, 228 | seconds, 229 | minutes, 230 | hours, 231 | days, 232 | months, 233 | days_of_week, 234 | years, 235 | star_dom, 236 | star_dow, 237 | dom_and_dow: self.dom_and_dow || dom_and_dow_from_pattern, 238 | }, 239 | }) 240 | } 241 | 242 | // Validates that the cron pattern only contains legal characters for each field. 243 | fn throw_at_illegal_characters(&self, parts: &[&str]) -> Result<(), CronError> { 244 | let base_allowed_characters = [ 245 | '*', '/', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ',', '-', 246 | ]; 247 | let day_of_week_additional_characters = ['#', 'L', '?']; 248 | let day_of_month_additional_characters = ['L', 'W', '?']; 249 | 250 | for (i, part) in parts.iter().enumerate() { 251 | // Decide which set of allowed characters to use 252 | let allowed = match i { 253 | 3 => [base_allowed_characters.as_ref(), day_of_month_additional_characters.as_ref()].concat(), 254 | 5 => [base_allowed_characters.as_ref(), day_of_week_additional_characters.as_ref()].concat(), 255 | // All other fields, including year (index 6) use base characters 256 | _ => base_allowed_characters.to_vec(), 257 | }; 258 | 259 | for ch in part.chars() { 260 | if !allowed.contains(&ch) { 261 | return Err(CronError::IllegalCharacters(format!( 262 | "CronPattern contains illegal character '{ch}' in part '{part}'" 263 | ))); 264 | } 265 | } 266 | } 267 | Ok(()) 268 | } 269 | 270 | // Converts named cron pattern shortcuts into their equivalent standard cron pattern. 271 | fn handle_nicknames(pattern: &str, with_seconds: bool, with_year: bool) -> String { 272 | let pattern = pattern.trim(); 273 | let eq_ignore_case = |a: &str, b: &str| a.eq_ignore_ascii_case(b); 274 | 275 | let base_pattern = match pattern { 276 | p if eq_ignore_case(p, "@yearly") || eq_ignore_case(p, "@annually") => "0 0 1 1 *", 277 | p if eq_ignore_case(p, "@monthly") => "0 0 1 * *", 278 | p if eq_ignore_case(p, "@weekly") => "0 0 * * 0", 279 | p if eq_ignore_case(p, "@daily") => "0 0 * * *", 280 | p if eq_ignore_case(p, "@hourly") => "0 * * * *", 281 | _ => pattern, 282 | }; 283 | 284 | let mut final_pattern = String::new(); 285 | if with_seconds { 286 | final_pattern.push_str("0 "); 287 | } 288 | final_pattern.push_str(base_pattern); 289 | if with_year { 290 | final_pattern.push_str(" *"); 291 | } 292 | 293 | final_pattern 294 | } 295 | 296 | 297 | // Converts day-of-week nicknames into their equivalent standard cron pattern. 298 | fn replace_alpha_weekdays(pattern: &str, alternative_weekdays: bool) -> String { 299 | let nicknames = if !alternative_weekdays { 300 | [ 301 | ("-SUN", "-7"), 302 | ("SUN", "0"), 303 | ("MON", "1"), 304 | ("TUE", "2"), 305 | ("WED", "3"), 306 | ("THU", "4"), 307 | ("FRI", "5"), 308 | ("SAT", "6"), 309 | ] 310 | } else { 311 | [ 312 | ("-SUN", "-1"), 313 | ("SUN", "1"), 314 | ("MON", "2"), 315 | ("TUE", "3"), 316 | ("WED", "4"), 317 | ("THU", "5"), 318 | ("FRI", "6"), 319 | ("SAT", "7"), 320 | ] 321 | }; 322 | let mut replaced = pattern.to_string(); 323 | 324 | // Replace nicknames with their numeric values 325 | for &(nickname, value) in &nicknames { 326 | replaced = replaced.replace(nickname, value); 327 | } 328 | 329 | replaced 330 | } 331 | 332 | // Converts month nicknames into their equivalent standard cron pattern. 333 | fn replace_alpha_months(pattern: &str) -> String { 334 | let nicknames = [ 335 | ("JAN", "1"), 336 | ("FEB", "2"), 337 | ("MAR", "3"), 338 | ("APR", "4"), 339 | ("MAY", "5"), 340 | ("JUN", "6"), 341 | ("JUL", "7"), 342 | ("AUG", "8"), 343 | ("SEP", "9"), 344 | ("OCT", "10"), 345 | ("NOV", "11"), 346 | ("DEC", "12"), 347 | ]; 348 | 349 | let mut replaced = pattern.to_string(); 350 | 351 | // Replace nicknames with their numeric values 352 | for &(nickname, value) in &nicknames { 353 | replaced = replaced.replace(nickname, value); 354 | } 355 | 356 | replaced 357 | } 358 | } 359 | 360 | impl CronParserBuilder { 361 | pub fn build(self) -> CronParser { 362 | let CronParserBuilder { 363 | seconds, 364 | year, 365 | dom_and_dow, 366 | alternative_weekdays, 367 | } = self; 368 | CronParser { 369 | seconds: seconds.unwrap_or_default(), 370 | year: year.unwrap_or_default(), 371 | dom_and_dow: dom_and_dow.unwrap_or_default(), 372 | alternative_weekdays: alternative_weekdays.unwrap_or_default(), 373 | } 374 | } 375 | } 376 | 377 | #[cfg(test)] 378 | mod tests { 379 | use std::str::FromStr as _; 380 | 381 | use super::*; 382 | 383 | 384 | #[test] 385 | fn test_cron_pattern_new() { 386 | let cron = Cron::from_str("*/5 * * * *").unwrap(); 387 | assert_eq!(cron.pattern.pattern, "*/5 * * * *"); 388 | assert!(cron.pattern.seconds.is_bit_set(0, ALL_BIT).unwrap()); 389 | assert!(cron.pattern.minutes.is_bit_set(5, ALL_BIT).unwrap()); 390 | } 391 | 392 | #[test] 393 | fn test_cron_pattern_new_with_seconds_optional() { 394 | let cron = CronParser::builder() 395 | .seconds(Seconds::Optional) 396 | .build() 397 | .parse("* */5 * * * *") 398 | .expect("Success"); 399 | assert_eq!(cron.pattern.pattern, "* */5 * * * *"); 400 | assert!(cron.pattern.seconds.is_bit_set(5, ALL_BIT).unwrap()); 401 | } 402 | 403 | #[test] 404 | fn test_cron_pattern_new_with_seconds_required() { 405 | let cron = CronParser::builder() 406 | .seconds(Seconds::Optional) 407 | .build() 408 | .parse("* */5 * * * *") 409 | .unwrap(); 410 | assert_eq!(cron.pattern.pattern, "* */5 * * * *"); 411 | assert!(cron.pattern.seconds.is_bit_set(5, ALL_BIT).unwrap()); 412 | } 413 | 414 | #[test] 415 | fn test_cron_pattern_tostring() { 416 | let cron = Cron::from_str("*/5 * * * *").unwrap(); 417 | assert_eq!(cron.to_string(), "*/5 * * * *"); 418 | } 419 | 420 | #[test] 421 | fn test_cron_pattern_short() { 422 | let cron = Cron::from_str("5/5 * * * *").unwrap(); 423 | assert_eq!(cron.pattern.pattern, "5/5 * * * *"); 424 | assert!(cron.pattern.seconds.is_bit_set(0, ALL_BIT).unwrap()); 425 | assert!(!cron.pattern.seconds.is_bit_set(5, ALL_BIT).unwrap()); 426 | assert!(cron.pattern.minutes.is_bit_set(5, ALL_BIT).unwrap()); 427 | assert!(!cron.pattern.minutes.is_bit_set(0, ALL_BIT).unwrap()); 428 | } 429 | 430 | #[test] 431 | fn test_cron_pattern_parse() { 432 | let cron = Cron::from_str("*/15 1 1,15 1 1-5").unwrap(); 433 | assert!(cron.pattern.minutes.is_bit_set(0, ALL_BIT).unwrap()); 434 | assert!(cron.pattern.hours.is_bit_set(1, ALL_BIT).unwrap()); 435 | assert!( 436 | cron.pattern.days.is_bit_set(1, ALL_BIT).unwrap() 437 | && cron.pattern.days.is_bit_set(15, ALL_BIT).unwrap() 438 | ); 439 | assert!( 440 | cron.pattern.months.is_bit_set(1, ALL_BIT).unwrap() 441 | && !cron.pattern.months.is_bit_set(2, ALL_BIT).unwrap() 442 | ); 443 | assert!( 444 | cron.pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap() 445 | && cron.pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap() 446 | ); 447 | } 448 | 449 | #[test] 450 | fn test_cron_pattern_extra_whitespace() { 451 | let cron = Cron::from_str(" */15 1 1,15 1 1-5 ").unwrap(); 452 | assert!(cron.pattern.minutes.is_bit_set(0, ALL_BIT).unwrap()); 453 | assert!(cron.pattern.hours.is_bit_set(1, ALL_BIT).unwrap()); 454 | assert!( 455 | cron.pattern.days.is_bit_set(1, ALL_BIT).unwrap() 456 | && cron.pattern.days.is_bit_set(15, ALL_BIT).unwrap() 457 | ); 458 | assert!( 459 | cron.pattern.months.is_bit_set(1, ALL_BIT).unwrap() 460 | && !cron.pattern.months.is_bit_set(2, ALL_BIT).unwrap() 461 | ); 462 | assert!( 463 | cron.pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap() 464 | && cron.pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap() 465 | ); 466 | } 467 | 468 | #[test] 469 | fn test_cron_pattern_leading_zeros() { 470 | let cron = Cron::from_str(" */15 01 01,15 01 01-05 ").unwrap(); 471 | assert!(cron.pattern.minutes.is_bit_set(0, ALL_BIT).unwrap()); 472 | assert!(cron.pattern.hours.is_bit_set(1, ALL_BIT).unwrap()); 473 | assert!( 474 | cron.pattern.days.is_bit_set(1, ALL_BIT).unwrap() 475 | && cron.pattern.days.is_bit_set(15, ALL_BIT).unwrap() 476 | ); 477 | assert!( 478 | cron.pattern.months.is_bit_set(1, ALL_BIT).unwrap() 479 | && !cron.pattern.months.is_bit_set(2, ALL_BIT).unwrap() 480 | ); 481 | assert!( 482 | cron.pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap() 483 | && cron.pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap() 484 | ); 485 | } 486 | 487 | #[test] 488 | fn test_cron_pattern_handle_nicknames() { 489 | assert_eq!(CronParser::handle_nicknames("@yearly", false, false), "0 0 1 1 *"); 490 | assert_eq!(CronParser::handle_nicknames("@monthly", false, false), "0 0 1 * *"); 491 | assert_eq!(CronParser::handle_nicknames("@weekly", false, false), "0 0 * * 0"); 492 | assert_eq!(CronParser::handle_nicknames("@daily", false, false), "0 0 * * *"); 493 | assert_eq!(CronParser::handle_nicknames("@hourly", false, false), "0 * * * *"); 494 | } 495 | 496 | #[test] 497 | fn test_cron_pattern_handle_nicknames_with_seconds_required() { 498 | assert_eq!(CronParser::handle_nicknames("@yearly", true, false), "0 0 0 1 1 *"); 499 | assert_eq!( 500 | CronParser::handle_nicknames("@monthly", true, false), 501 | "0 0 0 1 * *" 502 | ); 503 | assert_eq!(CronParser::handle_nicknames("@weekly", true, false), "0 0 0 * * 0"); 504 | assert_eq!(CronParser::handle_nicknames("@daily", true, false), "0 0 0 * * *"); 505 | assert_eq!(CronParser::handle_nicknames("@hourly", true, false), "0 0 * * * *"); 506 | } 507 | 508 | #[test] 509 | fn test_month_nickname_range() { 510 | let cron = Cron::from_str("0 0 * FEB-MAR *").unwrap(); 511 | assert!(!cron.pattern.months.is_bit_set(1, ALL_BIT).unwrap()); 512 | assert!(cron.pattern.months.is_bit_set(2, ALL_BIT).unwrap()); // February 513 | assert!(cron.pattern.months.is_bit_set(3, ALL_BIT).unwrap()); // March 514 | assert!(!cron.pattern.months.is_bit_set(4, ALL_BIT).unwrap()); 515 | } 516 | 517 | #[test] 518 | fn test_weekday_range_sat_sun() { 519 | let cron = Cron::from_str("0 0 * * SAT-SUN").unwrap(); 520 | assert!(cron.pattern.days_of_week.is_bit_set(0, ALL_BIT).unwrap()); // Sunday 521 | assert!(cron.pattern.days_of_week.is_bit_set(6, ALL_BIT).unwrap()); // Saturday 522 | } 523 | 524 | #[test] 525 | fn test_with_seconds_false() { 526 | // Explicitly create a parser that disallows seconds 527 | let parser = CronParser::builder() 528 | .seconds(Seconds::Disallowed) 529 | .build(); 530 | 531 | // Test with a 6-part pattern when seconds are not allowed 532 | let error = parser.parse("* * * * * *").unwrap_err(); 533 | assert!(matches!(error, CronError::InvalidPattern(_))); 534 | 535 | // Test with a 5-part pattern when seconds are not allowed 536 | let no_seconds_pattern = parser.parse("*/10 * * * *").unwrap(); 537 | 538 | assert_eq!(no_seconds_pattern.to_string(), "*/10 * * * *"); 539 | 540 | // Ensure seconds are defaulted to 0 for a 5-part pattern 541 | assert!(no_seconds_pattern 542 | .pattern 543 | .seconds 544 | .is_bit_set(0, ALL_BIT) 545 | .unwrap()); 546 | } 547 | 548 | #[test] 549 | fn test_with_seconds_required() { 550 | // Test with a 5-part pattern when seconds are required 551 | let no_seconds_pattern = CronParser::builder() 552 | .seconds(Seconds::Required) 553 | .build() 554 | .parse("*/10 * * * *") 555 | .unwrap_err(); 556 | 557 | assert!(matches!(no_seconds_pattern, CronError::InvalidPattern(_))); 558 | 559 | // Test with a 6-part pattern when seconds are required 560 | let cron = CronParser::builder() 561 | .seconds(Seconds::Required) 562 | .build() 563 | .parse("* * * * * *") 564 | .unwrap(); 565 | 566 | // Ensure the 6-part pattern retains seconds information 567 | // (This assertion depends on how your CronPattern is structured and how it stores seconds information) 568 | assert!(cron.pattern.seconds.is_bit_set(0, ALL_BIT).unwrap()); 569 | } 570 | 571 | #[test] 572 | fn test_with_alternative_weekdays() { 573 | // Test with alternative weekdays enabled 574 | let cron = CronParser::builder() 575 | .alternative_weekdays(true) 576 | .build() 577 | .parse("* * * * MON-FRI") 578 | .unwrap(); 579 | 580 | // Ensure that the days of the week are offset correctly 581 | // Note: In this scenario, "MON-FRI" should be treated as "SUN-THU" 582 | assert!(cron.pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap()); // Monday 583 | assert!(cron.pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap()); // Friday 584 | assert!(!cron.pattern.days_of_week.is_bit_set(6, ALL_BIT).unwrap()); // Saturday should not be set 585 | } 586 | 587 | #[test] 588 | fn test_with_alternative_weekdays_numeric() { 589 | // Test with alternative weekdays enabled 590 | let cron = CronParser::builder() 591 | .alternative_weekdays(true) 592 | .build() 593 | .parse("* * * * 2-6") 594 | .unwrap(); 595 | 596 | // Ensure that the days of the week are offset correctly 597 | // Note: In this scenario, "MON-FRI" should be treated as "SUN-THU" 598 | assert!(cron.pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap()); // Monday 599 | assert!(cron.pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap()); // Friday 600 | assert!(!cron.pattern.days_of_week.is_bit_set(6, ALL_BIT).unwrap()); // Saturday should not be set 601 | } 602 | 603 | #[test] 604 | fn test_seven_to_zero() { 605 | // Test with alternative weekdays enabled 606 | let cron = Cron::from_str("* * * * 7").unwrap(); 607 | 608 | // Ensure that the days of the week are offset correctly 609 | // Note: In this scenario, "MON-FRI" should be treated as "SUN-THU" 610 | assert!(cron.pattern.days_of_week.is_bit_set(0, ALL_BIT).unwrap()); // Monday 611 | } 612 | 613 | #[test] 614 | fn test_one_is_monday_alternative() { 615 | // Test with alternative weekdays enabled 616 | let cron = CronParser::builder() 617 | .alternative_weekdays(true) 618 | .build() 619 | .parse("* * * * 1") 620 | .unwrap(); 621 | 622 | // Ensure that the days of the week are offset correctly 623 | // Note: In this scenario, "MON-FRI" should be treated as "SUN-THU" 624 | assert!(cron.pattern.days_of_week.is_bit_set(0, ALL_BIT).unwrap()); // Monday 625 | } 626 | 627 | #[test] 628 | fn test_zero_with_alternative_weekdays_fails() { 629 | // Test with alternative weekdays enabled 630 | let error = CronParser::builder() 631 | .alternative_weekdays(true) 632 | .build() 633 | .parse("* * * * 0") 634 | .unwrap_err(); 635 | 636 | // Parsing should raise a ComponentError 637 | assert!(matches!(error, CronError::ComponentError(_))); 638 | } 639 | 640 | #[test] 641 | fn test_question_mark_allowed_in_day_of_month() { 642 | let pattern = "* * ? * *"; 643 | assert!( 644 | Cron::from_str(pattern).is_ok(), 645 | "Should allow '?' in the day-of-month field." 646 | ); 647 | } 648 | 649 | #[test] 650 | fn test_question_mark_allowed_in_day_of_week() { 651 | let pattern = "* * * * ?"; 652 | assert!( 653 | Cron::from_str(pattern).is_ok(), 654 | "Should allow '?' in the day-of-week field." 655 | ); 656 | } 657 | 658 | #[test] 659 | fn test_question_mark_disallowed_in_minute() { 660 | let pattern = "? * * * *"; 661 | let result = Cron::from_str(pattern); 662 | assert!( 663 | matches!(result.err(), Some(CronError::IllegalCharacters(_))), 664 | "Should not allow '?' in the minute field." 665 | ); 666 | } 667 | 668 | #[test] 669 | fn test_question_mark_disallowed_in_hour() { 670 | let pattern = "* ? * * *"; 671 | let result = Cron::from_str(pattern); 672 | assert!( 673 | matches!(result.err(), Some(CronError::IllegalCharacters(_))), 674 | "Should not allow '?' in the hour field." 675 | ); 676 | } 677 | 678 | #[test] 679 | fn test_question_mark_disallowed_in_month() { 680 | let pattern = "* * * ? *"; 681 | let result = Cron::from_str(pattern); 682 | assert!( 683 | matches!(result.err(), Some(CronError::IllegalCharacters(_))), 684 | "Should not allow '?' in the month field." 685 | ); 686 | } 687 | 688 | #[test] 689 | fn test_case_sensitivity_lowercase_special_character_ok() { 690 | let pattern = "* * 15w * *"; 691 | let result = Cron::from_str(pattern); 692 | assert!( 693 | result.is_ok(), 694 | "Should allow lowercase special character w." 695 | ); 696 | } 697 | 698 | #[test] 699 | fn test_case_sensitivity_uppercase_special_character_ok() { 700 | let pattern = "* * 15W * *"; 701 | let result: Result = Cron::from_str(pattern); 702 | assert!( 703 | result.is_ok(), 704 | "Should allow uppercase special character W." 705 | ); 706 | } 707 | 708 | #[test] 709 | fn test_year_support() { 710 | let parser = CronParser::builder() 711 | .seconds(Seconds::Optional) 712 | .year(Year::Optional) 713 | .build(); 714 | // 7-field pattern 715 | assert!(parser.parse("0 0 0 1 1 * 2025").is_ok()); 716 | // 6-field pattern (year defaults to *) 717 | assert!(parser.parse("0 0 0 1 1 *").is_ok()); 718 | // 5-field pattern (seconds defaults to 0, year to *) 719 | assert!(parser.parse("0 0 1 1 *").is_ok()); 720 | } 721 | 722 | #[test] 723 | fn test_year_required() { 724 | let parser = CronParser::builder() 725 | .seconds(Seconds::Required) 726 | .year(Year::Required) 727 | .build(); 728 | // Must have 7 fields 729 | assert!(parser.parse("0 0 0 1 1 * 2025").is_ok()); 730 | // 6 fields should fail 731 | assert!(parser.parse("0 0 0 1 1 *").is_err()); 732 | } 733 | 734 | #[test] 735 | fn test_optional_seconds_and_required_year_fails_on_six_parts() { 736 | // This parser configuration should only accept 7-part patterns. 737 | let parser = CronParser::builder() 738 | .seconds(Seconds::Optional) 739 | .year(Year::Required) 740 | .build(); 741 | 742 | // A 6-part pattern should fail because the year is missing but required. 743 | let result = parser.parse("* * * * * *"); 744 | 745 | assert!(matches!(result, Err(CronError::InvalidPattern(_))), "Should fail when year is required but not provided."); 746 | } 747 | 748 | } -------------------------------------------------------------------------------- /src/pattern.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | use std::hash::Hasher; 3 | 4 | use crate::component::{ 5 | CronComponent, ALL_BIT, CLOSEST_WEEKDAY_BIT, LAST_BIT, NONE_BIT, NTH_1ST_BIT, NTH_2ND_BIT, 6 | NTH_3RD_BIT, NTH_4TH_BIT, NTH_5TH_BIT, NTH_ALL, 7 | }; 8 | use crate::errors::CronError; 9 | use crate::{Direction, TimeComponent, YEAR_LOWER_LIMIT, YEAR_UPPER_LIMIT}; 10 | use chrono::{Datelike, Duration, NaiveDate, Weekday}; 11 | 12 | // This struct is used for representing and validating cron pattern strings. 13 | #[derive(Debug, Clone, Eq)] 14 | pub struct CronPattern { 15 | pub(crate) pattern: String, // The original pattern 16 | 17 | pub seconds: CronComponent, // - 18 | pub minutes: CronComponent, // -- 19 | pub hours: CronComponent, // --- Each individual part of the cron expression 20 | pub days: CronComponent, // --- represented by a bitmask, min and max value 21 | pub months: CronComponent, // --- 22 | pub days_of_week: CronComponent, // -- 23 | pub years: CronComponent, // - 24 | 25 | pub(crate) star_dom: bool, 26 | pub(crate) star_dow: bool, 27 | 28 | pub(crate) dom_and_dow: bool, 29 | } 30 | 31 | // Implementation block for CronPattern struct 32 | impl CronPattern { 33 | pub fn new(pattern: &str) -> Self { 34 | Self { 35 | pattern: pattern.to_string(), 36 | seconds: CronComponent::new(0, 59, NONE_BIT, 0), 37 | minutes: CronComponent::new(0, 59, NONE_BIT, 0), 38 | hours: CronComponent::new(0, 23, NONE_BIT, 0), 39 | days: CronComponent::new(1, 31, LAST_BIT | CLOSEST_WEEKDAY_BIT, 0), 40 | months: CronComponent::new(1, 12, NONE_BIT, 0), 41 | days_of_week: CronComponent::new(0, 7, LAST_BIT | NTH_ALL, 0), 42 | years: CronComponent::new(YEAR_LOWER_LIMIT as u16, YEAR_UPPER_LIMIT as u16, NONE_BIT, 0), // Use u16 for year range 43 | star_dom: false, 44 | star_dow: false, 45 | dom_and_dow: false, 46 | } 47 | } 48 | 49 | // Checks if a given year matches the year part of the cron pattern. 50 | pub fn year_match(&self, year: i32) -> Result { 51 | if !(YEAR_LOWER_LIMIT..=YEAR_UPPER_LIMIT).contains(&year) { 52 | // This case should ideally be prevented by search limits, but serves as a safeguard. 53 | return Ok(false); 54 | } 55 | self.years.is_bit_set(year as u16, ALL_BIT) // Use u16 cast 56 | } 57 | 58 | 59 | // Determines the nth weekday of the month 60 | fn is_nth_weekday_of_month(date: chrono::NaiveDate, nth: u8, weekday: Weekday) -> bool { 61 | let mut count = 0; 62 | let mut current = date.with_day(1).unwrap(); 63 | while current.month() == date.month() { 64 | if current.weekday() == weekday { 65 | count += 1; 66 | if count == nth { 67 | return current.day() == date.day(); 68 | } 69 | } 70 | current += chrono::Duration::days(1); 71 | } 72 | false 73 | } 74 | 75 | // Checks if a given year, month, and day match the day part of the cron pattern. 76 | pub fn day_match(&self, year: i32, month: u32, day: u32) -> Result { 77 | if day == 0 || day > 31 || month == 0 || month > 12 { 78 | return Err(CronError::InvalidDate); 79 | } 80 | 81 | let date = NaiveDate::from_ymd_opt(year, month, day).ok_or(CronError::InvalidDate)?; 82 | let mut day_matches = self.days.is_bit_set(day as u16, ALL_BIT)?; // Use u16 83 | let mut dow_matches = false; 84 | 85 | if !day_matches 86 | && self.days.is_feature_enabled(LAST_BIT) 87 | && day == Self::last_day_of_month(year, month)? 88 | { 89 | day_matches = true; 90 | } 91 | 92 | if !day_matches && self.closest_weekday(year, month, day)? { 93 | day_matches = true; 94 | } 95 | 96 | for nth in 1..=5 { 97 | let nth_bit = match nth { 98 | 1 => NTH_1ST_BIT, 99 | 2 => NTH_2ND_BIT, 100 | 3 => NTH_3RD_BIT, 101 | 4 => NTH_4TH_BIT, 102 | 5 => NTH_5TH_BIT, 103 | _ => continue, 104 | }; 105 | if self 106 | .days_of_week 107 | .is_bit_set(date.weekday().num_days_from_sunday() as u16, nth_bit)? // Use u16 108 | && Self::is_nth_weekday_of_month(date, nth, date.weekday()) 109 | { 110 | dow_matches = true; 111 | break; 112 | } 113 | } 114 | 115 | if !dow_matches 116 | && self 117 | .days_of_week 118 | .is_bit_set(date.weekday().num_days_from_sunday() as u16, LAST_BIT)? // Use u16 119 | && (date + chrono::Duration::days(7)).month() != date.month() 120 | { 121 | dow_matches = true; 122 | } 123 | 124 | dow_matches = dow_matches 125 | || self 126 | .days_of_week 127 | .is_bit_set(date.weekday().num_days_from_sunday() as u16, ALL_BIT)?; // Use u16 128 | 129 | if (day_matches && self.star_dow) || (dow_matches && self.star_dom) { 130 | Ok(true) 131 | } else if !self.star_dom && !self.star_dow { 132 | if !self.dom_and_dow { 133 | Ok(day_matches || dow_matches) 134 | } else { 135 | Ok(day_matches && dow_matches) 136 | } 137 | } else { 138 | Ok(false) 139 | } 140 | } 141 | 142 | // Helper function to find the last day of a given month 143 | fn last_day_of_month(year: i32, month: u32) -> Result { 144 | if !(1..=12).contains(&month) { 145 | return Err(CronError::InvalidDate); 146 | } 147 | let (y, m) = if month == 12 { 148 | (year + 1, 1) 149 | } else { 150 | (year, month + 1) 151 | }; 152 | Ok(NaiveDate::from_ymd_opt(y, m, 1) 153 | .unwrap() 154 | .pred_opt() 155 | .unwrap() 156 | .day()) 157 | } 158 | 159 | pub fn closest_weekday(&self, year: i32, month: u32, day: u32) -> Result { 160 | // Iterate through all possible days to see if any have the 'W' flag. 161 | for pattern_day_u16 in 1..=31 { 162 | if self.days.is_bit_set(pattern_day_u16, CLOSEST_WEEKDAY_BIT)? { 163 | // A 'W' day exists in the pattern. Check if it resolves to the function's date argument. 164 | let pattern_day = pattern_day_u16 as u32; 165 | 166 | // Ensure the 'W' day is a valid calendar date for the given month/year. 167 | if let Some(pattern_date) = NaiveDate::from_ymd_opt(year, month, pattern_day) { 168 | let weekday = pattern_date.weekday(); 169 | 170 | // Determine the actual trigger date based on the 'W' rule. 171 | let target_date = match weekday { 172 | // If the pattern day is a weekday, it triggers on that day. 173 | Weekday::Mon 174 | | Weekday::Tue 175 | | Weekday::Wed 176 | | Weekday::Thu 177 | | Weekday::Fri => pattern_date, 178 | // If it's a Saturday, find the nearest weekday within the month. 179 | Weekday::Sat => { 180 | // The nearest weekday is Friday, but check if it's in the same month. 181 | let adjusted_date = pattern_date - Duration::days(1); 182 | if adjusted_date.month() == month { 183 | adjusted_date // It's Friday of the same month. 184 | } else { 185 | // Crossed boundary (e.g., 1st was Sat), so move forward to Monday. 186 | pattern_date + Duration::days(2) 187 | } 188 | } 189 | // If it's a Sunday, find the nearest weekday within the month. 190 | Weekday::Sun => { 191 | // The nearest weekday is Monday, but check if it's in the same month. 192 | let adjusted_date = pattern_date + Duration::days(1); 193 | if adjusted_date.month() == month { 194 | adjusted_date // It's Monday of the same month. 195 | } else { 196 | // Crossed boundary (e.g., 31st was Sun), so move back to Friday. 197 | pattern_date - Duration::days(2) 198 | } 199 | } 200 | }; 201 | 202 | // Check if the calculated target day is the day we're currently testing. 203 | if target_date.day() == day && target_date.month() == month { 204 | return Ok(true); 205 | } 206 | } 207 | } 208 | } 209 | 210 | // No 'W' pattern matched the current day. 211 | Ok(false) 212 | } 213 | 214 | pub fn month_match(&self, month: u32) -> Result { 215 | if !(1..=12).contains(&month) { 216 | return Err(CronError::InvalidDate); 217 | } 218 | self.months.is_bit_set(month as u16, ALL_BIT) 219 | } 220 | 221 | pub fn hour_match(&self, hour: u32) -> Result { 222 | if hour > 23 { 223 | return Err(CronError::InvalidTime); 224 | } 225 | self.hours.is_bit_set(hour as u16, ALL_BIT) 226 | } 227 | 228 | pub fn minute_match(&self, minute: u32) -> Result { 229 | if minute > 59 { 230 | return Err(CronError::InvalidTime); 231 | } 232 | self.minutes.is_bit_set(minute as u16, ALL_BIT) 233 | } 234 | 235 | pub fn second_match(&self, second: u32) -> Result { 236 | if second > 59 { 237 | return Err(CronError::InvalidTime); 238 | } 239 | self.seconds.is_bit_set(second as u16, ALL_BIT) 240 | } 241 | 242 | /// Finds the next or previous matching value for a given time component based on direction. 243 | pub fn find_match_in_component( 244 | &self, 245 | value: u32, 246 | component_type: TimeComponent, 247 | direction: Direction, 248 | ) -> Result, CronError> { 249 | let component = match component_type { 250 | TimeComponent::Second => &self.seconds, 251 | TimeComponent::Minute => &self.minutes, 252 | TimeComponent::Hour => &self.hours, 253 | _ => { 254 | return Err(CronError::ComponentError( 255 | "Invalid component type for match search".to_string(), 256 | )) 257 | } 258 | }; 259 | 260 | let value_u16 = value as u16; 261 | if value_u16 > component.max { 262 | return Err(CronError::ComponentError(format!( 263 | "Input value {} is out of bounds for the component (max: {}).", 264 | value, component.max 265 | ))); 266 | } 267 | 268 | match direction { 269 | Direction::Forward => { 270 | for next_value in value_u16..=component.max { 271 | if component.is_bit_set(next_value, ALL_BIT)? { 272 | return Ok(Some(next_value as u32)); 273 | } 274 | } 275 | } 276 | Direction::Backward => { 277 | for prev_value in (component.min..=value_u16).rev() { 278 | if component.is_bit_set(prev_value, ALL_BIT)? { 279 | return Ok(Some(prev_value as u32)); 280 | } 281 | } 282 | } 283 | } 284 | Ok(None) 285 | } 286 | 287 | /// Returns a human-readable description of the cron pattern. 288 | /// 289 | /// This method provides a best-effort English description of the cron schedule. 290 | /// Note: The pattern must be parsed successfully before calling this method. 291 | /// Returns a human-readable description of the cron pattern in English. 292 | pub fn describe(&self) -> String { 293 | self.describe_lang(crate::describe::English) 294 | } 295 | 296 | /// Returns a human-readable description using a provided language provider. 297 | /// 298 | /// # Arguments 299 | /// 300 | /// * `lang` - An object that implements the `Language` trait. 301 | pub fn describe_lang(&self, lang: L) -> String { 302 | crate::describe::describe(self, &lang) 303 | } 304 | 305 | // Get a reference to the original pattern 306 | pub fn as_str(&self) -> &str { 307 | &self.pattern 308 | } 309 | } 310 | 311 | impl std::fmt::Display for CronPattern { 312 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 313 | write!(f, "{}", self.pattern) 314 | } 315 | } 316 | 317 | impl PartialEq for CronPattern { 318 | /// Checks for functional equality between two CronPattern instances. 319 | /// 320 | /// Two patterns are considered equal if they have been parsed and their 321 | /// resulting schedule components and behavioral options are identical. 322 | /// The original pattern string is ignored in this comparison. 323 | /// 324 | /// Returns `false` if either pattern has not been parsed. 325 | fn eq(&self, other: &Self) -> bool { 326 | // Compare all components and boolean flags that define the schedule. 327 | self.seconds == other.seconds 328 | && self.minutes == other.minutes 329 | && self.hours == other.hours 330 | && self.days == other.days 331 | && self.months == other.months 332 | && self.days_of_week == other.days_of_week 333 | && self.years == other.years 334 | && self.star_dom == other.star_dom 335 | && self.star_dow == other.star_dow 336 | && self.dom_and_dow == other.dom_and_dow 337 | } 338 | } 339 | 340 | // To implement Ord, we must first implement PartialOrd. 341 | // For types where comparison never fails, this is the standard way to do it. 342 | impl PartialOrd for CronPattern { 343 | fn partial_cmp(&self, other: &Self) -> Option { 344 | Some(self.cmp(other)) 345 | } 346 | } 347 | 348 | // The primary implementation for Ord. 349 | impl Ord for CronPattern { 350 | /// Implements the total ordering for `CronPattern`. 351 | /// 352 | /// This allows for consistent, deterministic sorting of cron patterns based on 353 | /// their functional schedule, not their string representation. The comparison 354 | /// is performed lexicographically on the parsed time components and behavioral flags. 355 | /// 356 | /// An unparsed pattern is always considered less than a parsed one. 357 | fn cmp(&self, other: &Self) -> Ordering { 358 | // Compare the time components in logical order, from most to least 359 | // significant. 360 | self.seconds 361 | .cmp(&other.seconds) 362 | .then_with(|| self.minutes.cmp(&other.minutes)) 363 | .then_with(|| self.hours.cmp(&other.hours)) 364 | .then_with(|| self.days.cmp(&other.days)) 365 | .then_with(|| self.months.cmp(&other.months)) 366 | .then_with(|| self.days_of_week.cmp(&other.days_of_week)) 367 | .then_with(|| self.years.cmp(&other.years)) 368 | // Finally, compare the boolean flags to ensure a stable order 369 | // for patterns that are otherwise identical. 370 | .then_with(|| self.star_dom.cmp(&other.star_dom)) 371 | .then_with(|| self.star_dow.cmp(&other.star_dow)) 372 | .then_with(|| self.dom_and_dow.cmp(&other.dom_and_dow)) 373 | } 374 | } 375 | 376 | impl std::hash::Hash for CronPattern { 377 | /// Hashes the functionally significant fields of the CronPattern. 378 | /// 379 | /// This implementation is consistent with the `PartialEq` implementation, 380 | /// ensuring that functionally identical patterns produce the same hash. 381 | /// The original pattern string is not included in the hash. 382 | fn hash(&self, state: &mut H) { 383 | self.seconds.hash(state); 384 | self.minutes.hash(state); 385 | self.hours.hash(state); 386 | self.days.hash(state); 387 | self.months.hash(state); 388 | self.days_of_week.hash(state); 389 | self.years.hash(state); 390 | self.star_dom.hash(state); 391 | self.star_dow.hash(state); 392 | self.dom_and_dow.hash(state); 393 | } 394 | } 395 | 396 | #[cfg(test)] 397 | mod tests { 398 | use crate::parser::{CronParser, Seconds}; 399 | 400 | use super::*; 401 | 402 | #[test] 403 | fn test_last_day_of_month() -> Result<(), CronError> { 404 | // Check the last day of February for a non-leap year 405 | assert_eq!(CronPattern::last_day_of_month(2021, 2)?, 28); 406 | 407 | // Check the last day of February for a leap year 408 | assert_eq!(CronPattern::last_day_of_month(2020, 2)?, 29); 409 | 410 | // Check for an invalid month (0 or greater than 12) 411 | assert!(CronPattern::last_day_of_month(2023, 0).is_err()); 412 | assert!(CronPattern::last_day_of_month(2023, 13).is_err()); 413 | 414 | Ok(()) 415 | } 416 | 417 | #[test] 418 | fn test_closest_weekday() -> Result<(), CronError> { 419 | // Example cron pattern: "0 0 15W * *" which means at 00:00 on the closest weekday to the 15th of each month 420 | let cron = CronParser::builder() 421 | .seconds(Seconds::Optional) 422 | .build() 423 | .parse("0 0 0 15W * *")?; 424 | 425 | // Test a month where the 15th is a weekday 426 | // Assuming 15th is Wednesday (a weekday), the closest weekday is the same day. 427 | let date = NaiveDate::from_ymd_opt(2023, 6, 15).expect("To work"); // 15th June 2023 428 | assert!(cron 429 | .pattern 430 | .day_match(date.year(), date.month(), date.day())?); 431 | 432 | // Test a month where the 15th is a Saturday 433 | // The closest weekday would be Friday, 14th. 434 | let date = NaiveDate::from_ymd_opt(2024, 6, 14).expect("To work"); // 14th May 2023 435 | assert!(cron 436 | .pattern 437 | .day_match(date.year(), date.month(), date.day())?); 438 | 439 | // Test a month where the 15th is a Sunday 440 | // The closest weekday would be Monday, 16th. 441 | let date = NaiveDate::from_ymd_opt(2023, 10, 16).expect("To work"); // 16th October 2023 442 | assert!(cron 443 | .pattern 444 | .day_match(date.year(), date.month(), date.day())?); 445 | 446 | // Test a non-matching date 447 | let date = NaiveDate::from_ymd_opt(2023, 6, 16).expect("To work"); // 16th June 2023 448 | assert!(!cron 449 | .pattern 450 | .day_match(date.year(), date.month(), date.day())?); 451 | 452 | Ok(()) 453 | } 454 | 455 | #[test] 456 | fn test_closest_weekday_with_alternative_weekdays() -> Result<(), CronError> { 457 | // Example cron pattern: "0 0 15W * *" which means at 00:00 on the closest weekday to the 15th of each month 458 | let cron = CronParser::builder() 459 | .seconds(Seconds::Required) 460 | .alternative_weekdays(true) 461 | .build() 462 | .parse("0 0 0 15W * *")?; 463 | 464 | // Test a month where the 15th is a weekday 465 | // Assuming 15th is Wednesday (a weekday), the closest weekday is the same day. 466 | let date = NaiveDate::from_ymd_opt(2023, 6, 15).expect("To work"); // 15th June 2023 467 | assert!(cron 468 | .pattern 469 | .day_match(date.year(), date.month(), date.day())?); 470 | 471 | // Test a month where the 15th is a Saturday 472 | // The closest weekday would be Friday, 14th. 473 | let date = NaiveDate::from_ymd_opt(2024, 6, 14).expect("To work"); // 14th May 2023 474 | assert!(cron 475 | .pattern 476 | .day_match(date.year(), date.month(), date.day())?); 477 | 478 | // Test a month where the 15th is a Sunday 479 | // The closest weekday would be Monday, 16th. 480 | let date = NaiveDate::from_ymd_opt(2023, 10, 16).expect("To work"); // 16th October 2023 481 | assert!(cron 482 | .pattern 483 | .day_match(date.year(), date.month(), date.day())?); 484 | 485 | // Test a non-matching date 486 | let date = NaiveDate::from_ymd_opt(2023, 6, 16).expect("To work"); // 16th June 2023 487 | assert!(!cron 488 | .pattern 489 | .day_match(date.year(), date.month(), date.day())?); 490 | 491 | Ok(()) 492 | } 493 | 494 | #[test] 495 | fn test_closest_weekday_month_boundary() -> Result<(), CronError> { 496 | // --- TEST START OF MONTH --- 497 | let cron = CronParser::builder() 498 | .seconds(Seconds::Optional) 499 | .build() 500 | .parse("0 0 0 1W * *")?; 501 | 502 | // Case 1: The 1st is a Saturday (Nov 2025). 503 | // Should trigger on Monday the 3rd, not jump back to October. 504 | assert!( 505 | !cron.pattern.day_match(2025, 10, 31)?, 506 | "Should not trigger on previous month" 507 | ); 508 | assert!( 509 | cron.pattern.day_match(2025, 11, 3)?, 510 | "Should trigger on Mon 3rd for Sat 1st" 511 | ); 512 | assert!( 513 | !cron.pattern.day_match(2025, 11, 1)?, 514 | "Should not trigger on Sat 1st itself" 515 | ); 516 | 517 | // Case 2: The 1st is a Sunday (June 2025). 518 | // Should trigger on Monday the 2nd. 519 | assert!( 520 | cron.pattern.day_match(2025, 6, 2)?, 521 | "Should trigger on Mon 2nd for Sun 1st" 522 | ); 523 | assert!( 524 | !cron.pattern.day_match(2025, 6, 3)?, 525 | "Should NOT trigger on Tue 3rd for Sun 1st" 526 | ); 527 | 528 | // --- TEST END OF MONTH --- 529 | let cron_end = CronParser::builder() 530 | .seconds(Seconds::Optional) 531 | .build() 532 | .parse("0 0 0 31W * *")?; 533 | 534 | // Case 3: The 31st is a Sunday (Aug 2025). 535 | // Should trigger on Friday the 29th, not jump forward to September. 536 | assert!( 537 | cron_end.pattern.day_match(2025, 8, 29)?, 538 | "Should trigger on Fri 29th for Sun 31st" 539 | ); 540 | assert!( 541 | !cron_end.pattern.day_match(2025, 9, 1)?, 542 | "Should not trigger on next month" 543 | ); 544 | 545 | Ok(()) 546 | } 547 | } -------------------------------------------------------------------------------- /tests/ocps.rs: -------------------------------------------------------------------------------- 1 | // OCPS Compliance Test Suite 2 | // 3 | // This file contains a separate test suite for verifying compliance 4 | // with the Open Cron Pattern Specification (OCPS) 1.4 draft. 5 | // 6 | // Specification Reference: github.com/open-source-cron/ocsp 7 | // 8 | // Each module in this suite corresponds to a specific version of the OCPS, 9 | // allowing for targeted testing of features as they were introduced. 10 | 11 | use croner::parser::{CronParser}; 12 | use croner::Cron; 13 | use std::str::FromStr; 14 | 15 | /// Helper function to parse with a specific configuration. 16 | fn custom_parse(pattern: &str, dom_and_dow: bool) -> Result { 17 | CronParser::builder() 18 | .dom_and_dow(dom_and_dow) 19 | .build() 20 | .parse(pattern) 21 | } 22 | 23 | #[cfg(test)] 24 | mod ocps_1_0_tests { 25 | use super::*; 26 | use chrono::{Local, TimeZone}; // Import trait for this module 27 | 28 | #[test] 29 | fn test_5_field_baseline() { 30 | assert!(Cron::from_str("15 10 1 10 *").is_ok(), "Should parse a 5-field pattern."); 31 | } 32 | 33 | #[test] 34 | fn test_special_chars_wildcard_list_range_step() { 35 | assert!(Cron::from_str("*/15 0-4,8-12 * JAN-MAR,DEC MON-FRI").is_ok(), "Should handle *, /, -, and , correctly."); 36 | } 37 | 38 | #[test] 39 | fn test_logical_or_for_date_fields() { 40 | // Should match on the 1st AND on every Monday. 41 | let cron = Cron::from_str("0 12 1 * MON").unwrap(); 42 | let first_of_month = Local.with_ymd_and_hms(2025, 7, 1, 12, 0, 0).unwrap(); // A Tuesday 43 | let a_monday = Local.with_ymd_and_hms(2025, 7, 14, 12, 0, 0).unwrap(); // Not the 1st 44 | 45 | assert!(cron.is_time_matching(&first_of_month).unwrap()); 46 | assert!(cron.is_time_matching(&a_monday).unwrap()); 47 | } 48 | } 49 | 50 | #[cfg(test)] 51 | mod ocps_1_1_tests { 52 | use super::*; 53 | 54 | #[test] 55 | fn test_nicknames() { 56 | assert_eq!(custom_parse("@yearly", false).unwrap().pattern.to_string().to_uppercase(), "0 0 1 1 *"); 57 | assert_eq!(custom_parse("@monthly", false).unwrap().pattern.to_string().to_uppercase(), "0 0 1 * *"); 58 | assert_eq!(custom_parse("@weekly", false).unwrap().pattern.to_string().to_uppercase(), "0 0 * * 0"); 59 | assert_eq!(custom_parse("@daily", false).unwrap().pattern.to_string().to_uppercase(), "0 0 * * *"); 60 | assert_eq!(custom_parse("@hourly", false).unwrap().pattern.to_string().to_uppercase(), "0 * * * *"); 61 | } 62 | 63 | #[test] 64 | #[ignore] // Ignored until @reboot is implemented 65 | fn test_reboot_nickname() { 66 | // The parser should accept @reboot without crashing. 67 | // A call to find_next_occurrence could then return a specific error if not supported at runtime. 68 | assert!(custom_parse("@reboot", false).is_ok()); 69 | } 70 | } 71 | 72 | #[cfg(test)] 73 | mod ocps_1_2_tests { 74 | use super::*; 75 | 76 | #[test] 77 | fn test_6_field_with_seconds() { 78 | let cron = custom_parse("30 15 10 1 10 *", false).unwrap(); 79 | assert!(cron.pattern.seconds.is_bit_set(30, 1).unwrap()); 80 | assert!(!cron.pattern.seconds.is_bit_set(0, 1).unwrap()); 81 | } 82 | 83 | #[test] 84 | fn test_7_field_with_year() { 85 | let cron = custom_parse("0 0 12 1 1 * 2025", false).unwrap(); 86 | assert!(cron.pattern.years.is_bit_set(2025, 1).unwrap()); 87 | } 88 | } 89 | 90 | #[cfg(test)] 91 | mod ocps_1_3_tests { 92 | use super::*; 93 | use chrono::{Local, TimeZone}; // Import trait for this module 94 | 95 | #[test] 96 | fn test_last_day_of_month() { 97 | let cron = Cron::from_str("0 0 L * *").unwrap(); 98 | let last_of_july = Local.with_ymd_and_hms(2025, 7, 31, 0, 0, 0).unwrap(); 99 | let not_last_of_july = Local.with_ymd_and_hms(2025, 7, 30, 0, 0, 0).unwrap(); 100 | assert!(cron.is_time_matching(&last_of_july).unwrap()); 101 | assert!(!cron.is_time_matching(¬_last_of_july).unwrap()); 102 | } 103 | 104 | #[test] 105 | fn test_last_weekday_of_month() { 106 | // The last Friday in July 2025 is the 25th. 107 | let cron = Cron::from_str("0 0 * * 5L").unwrap(); 108 | let last_friday = Local.with_ymd_and_hms(2025, 7, 25, 0, 0, 0).unwrap(); 109 | let not_last_friday = Local.with_ymd_and_hms(2025, 7, 18, 0, 0, 0).unwrap(); 110 | assert!(cron.is_time_matching(&last_friday).unwrap()); 111 | assert!(!cron.is_time_matching(¬_last_friday).unwrap()); 112 | } 113 | 114 | #[test] 115 | fn test_nth_weekday_of_month() { 116 | // The second Tuesday in July 2025 is the 8th. 117 | let cron = Cron::from_str("0 0 * * 2#2").unwrap(); 118 | let second_tuesday = Local.with_ymd_and_hms(2025, 7, 8, 0, 0, 0).unwrap(); 119 | let first_tuesday = Local.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap(); 120 | assert!(cron.is_time_matching(&second_tuesday).unwrap()); 121 | assert!(!cron.is_time_matching(&first_tuesday).unwrap()); 122 | } 123 | 124 | #[test] 125 | fn test_closest_weekday() { 126 | // July 5th, 2025 is a Saturday. The closest weekday is Friday the 4th. 127 | let cron = Cron::from_str("0 0 5W 7 *").unwrap(); 128 | let closest_weekday = Local.with_ymd_and_hms(2025, 7, 4, 0, 0, 0).unwrap(); 129 | assert!(cron.is_time_matching(&closest_weekday).unwrap()); 130 | } 131 | } 132 | 133 | #[cfg(test)] 134 | mod ocps_1_4_tests { 135 | use super::*; 136 | use chrono::{Local, TimeZone}; // Import trait for this module 137 | 138 | #[test] 139 | fn test_question_mark_is_alias_for_wildcard() { 140 | let cron_star = Cron::from_str("0 0 1 * *").unwrap(); 141 | let cron_q = Cron::from_str("0 0 1 * ?").unwrap(); 142 | assert_eq!(cron_star, cron_q); 143 | } 144 | 145 | #[test] 146 | fn test_and_modifier() { 147 | // Should ONLY match if the 1st of the month is a Monday. 148 | let cron = custom_parse("0 12 1 * +MON", false).unwrap(); 149 | 150 | // September 1st, 2025 is a Monday. 151 | let first_is_monday = Local.with_ymd_and_hms(2025, 9, 1, 12, 0, 0).unwrap(); 152 | // July 1st, 2025 is a Tuesday. 153 | let first_is_not_monday = Local.with_ymd_and_hms(2025, 7, 1, 12, 0, 0).unwrap(); 154 | 155 | assert!(cron.is_time_matching(&first_is_monday).unwrap()); 156 | assert!(!cron.is_time_matching(&first_is_not_monday).unwrap()); 157 | } 158 | 159 | #[test] 160 | fn test_global_and_mode() { 161 | let cron = custom_parse("0 12 1 * MON", true).unwrap(); 162 | 163 | // Should ONLY match if the 1st of the month is a Monday (due to global setting). 164 | let first_is_monday = Local.with_ymd_and_hms(2025, 9, 1, 12, 0, 0).unwrap(); 165 | let first_is_not_monday = Local.with_ymd_and_hms(2025, 7, 1, 12, 0, 0).unwrap(); 166 | let a_monday_not_first = Local.with_ymd_and_hms(2025, 7, 14, 12, 0, 0).unwrap(); 167 | 168 | assert!(cron.is_time_matching(&first_is_monday).unwrap()); 169 | assert!(!cron.is_time_matching(&first_is_not_monday).unwrap()); 170 | assert!(!cron.is_time_matching(&a_monday_not_first).unwrap(), "Should not match a Monday that is not the 1st in AND mode."); 171 | } 172 | 173 | #[test] 174 | fn test_plus_modifier_invalid_field() { 175 | // Using '+' in the day-of-month field should result in an error. 176 | let result = custom_parse("0 0 +1 * *", false); 177 | assert!(matches!(result, Err(croner::errors::CronError::IllegalCharacters(_)))); 178 | } 179 | 180 | } --------------------------------------------------------------------------------