Vendor dependencies

This commit is contained in:
2026-08-01 16:11:49 +03:00
parent 7f139a0241
commit 6b5e7f0f8b
29706 changed files with 9575646 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
{"$comment":"This file only protects against accidental modifications. It is not a security mechanism and does not protect against malicious changes.","files":{".cargo_vcs_info.json":"6c80bb6c47cb7b88c092bd78433b281cda0e32266e4605fb22542fd809ad162e",".github/workflows/publish.yml":"60b64ca79838bfacf35ccee9692af82755e969c173693289c57e30cf7c7b1a56",".github/workflows/rust.yml":"71edfcfd978ab63295492edf9cd9aabc169ddbc71e5c598722d692a6217bfec9","Cargo.lock":"d02c7d325c93eebc1a29aac11cbe55ebbe893805dcb8399d3d1f3caf4d285ecb","Cargo.toml":"168a3fad627d9363c8ff74d1f6a63c040580471ad0a51692ae8f21ce12402e95","Cargo.toml.orig":"eedf16bb070467552f835054e18e839221b087f2b019cc2ab3a778bcc26dbd62","LICENSE.md":"d05b85506ae3a967ef41de797e43fa345e57214b05d1185a3b323f9d64f2de9b","README.md":"155d6cd37f7530663db38814940e2799c446bc61ad6f62f048a6ad3e77542988","benches/croner_bench.rs":"1677a40fcd704a2df66d3b910ada350b3b0d6cc112080ca70d38b0bde21b0daf","examples/iter_demo.rs":"a8038f0828153136d275695e61c080c6203ce7f120ec34e3607a33e4739efe01","examples/simple_demo.rs":"1fd3f5de8e5e08ac6a7df02c97626260beac774f0f1c1bf0f9c795666cd3c269","examples/timezone_demo.rs":"ed7810b9afea63b6cf8479908c64f4eee08d0d5f8fbe095ec9d39db14f0e726e","src/component.rs":"775b4a0fecb0cf74afdc0987713c579328e2be2c6d80d3d4a59544879508c92a","src/describe/lang/english.rs":"c7b02d4bc3ae9b77c84b03c99f8267105d28902d38d29da13eee28d2c5b92d56","src/describe/lang/mod.rs":"3ce2c323a114869eef8544b8d22eaa2f9999cc71c2faf6ecdbfec1a4d4fd8440","src/describe/lang/swedish.rs":"12192f44fbcd556b76df224fdc68f286cff60c38e1f8e55a564fdf47f73c2834","src/describe/mod.rs":"8548d8eb1f6ebeb085087de21f777df411ef3169cef5824345c454696bda9356","src/errors.rs":"ee593708c788c0126ed88274ff0ac35e700bf075be2786e6c822b0f41b977ace","src/iterator.rs":"9432cbdeef111ea9dd509179e4df228ea0cf647f3ba8c66402e61e16d4c39bfa","src/lib.rs":"d328374179a8717b5c6a607d41bb3b0b13ce71bee23f8838b8c624608432cdd8","src/parser.rs":"a0e1a3f188b4ba5088ca608c2e943b51f0dc3f99afb9b2f141574f5ce8344ff6","src/pattern.rs":"db0055f85b445c6f7ff29b1668052402068a1ebeefd3e8e814bb228366ae384e","tests/ocps.rs":"7f0054494f8d66b9e07d08cd966190f051401ec4ec55b1b01d82553ff7150f0d"},"package":"4aa42bcd3d846ebf66e15bd528d1087f75d1c6c1c66ebff626178a106353c576"}
+6
View File
@@ -0,0 +1,6 @@
{
"git": {
"sha1": "2d9085cdc0fef309a7f06e9963035e2bd52b83fe"
},
"path_in_vcs": ""
}
@@ -0,0 +1,34 @@
on:
push:
# Pattern matched against refs/tags
tags:
- 'v*' # Push events to every tag containing v
workflow_dispatch:
name: Publish
jobs:
publish:
name: Publish
runs-on: ubuntu-latest
steps:
- name: Checkout sources
uses: actions/checkout@v2
- name: Install stable toolchain
uses: actions-rs/toolchain@v1
with:
profile: minimal
toolchain: stable
override: true
- name: Run tests
run: cargo test
- name: Run tests with features
run: cargo test --all-features
- name: Publish
run: cargo publish --token ${CRATES_TOKEN} --allow-dirty
env:
CRATES_TOKEN: ${{ secrets.CRATES_TOKEN }}
+24
View File
@@ -0,0 +1,24 @@
name: Rust
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
env:
CARGO_TERM_COLOR: always
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
- name: Run tests with features
run: cargo test --all-features --verbose
+1022
View File
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2021"
name = "croner"
version = "3.0.1"
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Fully-featured, lightweight, and efficient Rust library designed for parsing and evaluating cron patterns"
homepage = "https://github.com/hexagon/croner-rust"
documentation = "https://docs.rs/croner"
readme = "README.md"
keywords = [
"cron",
"scheduler",
"job",
"task",
"time",
]
categories = [
"date-and-time",
"parser-implementations",
]
license = "MIT"
repository = "https://github.com/hexagon/croner-rust"
[features]
serde = ["dep:serde"]
[lib]
name = "croner"
path = "src/lib.rs"
[[example]]
name = "iter_demo"
path = "examples/iter_demo.rs"
[[example]]
name = "simple_demo"
path = "examples/simple_demo.rs"
[[example]]
name = "timezone_demo"
path = "examples/timezone_demo.rs"
[[test]]
name = "ocps"
path = "tests/ocps.rs"
[[bench]]
name = "croner_bench"
path = "benches/croner_bench.rs"
harness = false
[dependencies.chrono]
version = "0.4.42"
[dependencies.derive_builder]
version = "0.20.2"
[dependencies.serde]
version = "1.0"
optional = true
[dependencies.strum]
version = "0.27.2"
features = ["derive"]
[dev-dependencies.chrono-tz]
version = "0.10.4"
[dev-dependencies.criterion]
version = "0.7.0"
[dev-dependencies.rstest]
version = "0.26.1"
[dev-dependencies.serde_test]
version = "1.0"
+35
View File
@@ -0,0 +1,35 @@
[package]
name = "croner"
version = "3.0.1"
edition = "2021"
license = "MIT"
description = "Fully-featured, lightweight, and efficient Rust library designed for parsing and evaluating cron patterns"
repository = "https://github.com/hexagon/croner-rust"
documentation = "https://docs.rs/croner"
readme = "README.md"
keywords = ["cron", "scheduler", "job", "task", "time"]
categories = ["date-and-time", "parser-implementations"]
homepage = "https://github.com/hexagon/croner-rust"
[lib]
name = "croner"
path = "src/lib.rs"
[dependencies]
chrono = "0.4.42"
derive_builder = "0.20.2"
serde = { version = "1.0", optional = true }
strum = { version = "0.27.2", features = ["derive"] }
[dev-dependencies]
chrono-tz = "0.10.4"
criterion = "0.7.0"
rstest = "0.26.1"
serde_test = "1.0"
[features]
serde = ["dep:serde"]
[[bench]]
name = "croner_bench"
harness = false
+20
View File
@@ -0,0 +1,20 @@
The MIT License (MIT)
Copyright (c) 2023 Hexagon <github.com/Hexagon>
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+365
View File
@@ -0,0 +1,365 @@
# Croner
Croner is a fully-featured, lightweight, and efficient Rust library designed for parsing and evaluating cron patterns.
This is the Rust flavor of the popular JavaScript/TypeScript cron parser
[croner](https://github.com/hexagon/croner).
## Features
- Parse and evaluate [cron](https://en.wikipedia.org/wiki/Cron#CRON_expression)
expressions to calculate upcoming execution times.
- Generates human-readable descriptions of cron patterns.
- Follows POSIX/Vixie-cron standards, while extending it with additional specifiers such as `L`
for the last day and weekday of the month, `#` for the nth weekday of the
month, `W` for closest weekday to a day of month.
- Evaluate cron expressions across different time zones.
- Supports optional second-, and year granularity
- Supports optional alternative weekday mode to use Quartz-style weekdays instead of POSIX using `with_alternative_weekdays`
- 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.
- Compatible with `chrono` and (optionally) `chrono-tz`.
- Robust error handling.
## Crate Features
- `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.
## Why croner instead of cron or saffron?
Croner combines the features of cron and saffron, while following the POSIX/Vixie "standards" for the relevant parts. See this table:
| Feature | Croner | Cron | Saffron |
|----------------------|-------------|-----------|---------|
| Time Zones | X | X | |
| Ranges (15-25)| X | X | X |
| Ranges with stepping (15-25/2)| X | X | X |
| `L` - Last day of month | X | | X |
| `5#L` - Last occurrence of weekday | X | X | |
| `5L` - Last occurrence of weekday | X | ? | X |
| `#` - Nth occurrence of weekday | X | | X |
| `W` - Closest weekday | X | | X |
| `+` - dom-AND-dow through pattern | X | | |
| "Standards"-compliant weekdays (1 is monday) | X | | |
| Five part patterns (minute granularity) | X | | X |
| Six part patterns (second granularity)| X | X | |
| Weekday/Month text representations | X | X | X |
| Aliases (`@hourly` etc.) | X | X | |
| chrono `DateTime` compatibility | X | X | X |
| Option to force DOM-and-DOW | X | | |
| Generate human readable string | X | | X |
> [!NOTE]
> Tests carried out at 2023-12-02 using `cron@0.12.0` and `saffron@.0.1.0`
## Getting Started
### Prerequisites
Ensure you have Rust installed on your machine. If not, you can get it from
[the official Rust website](https://www.rust-lang.org/).
### Installation
Add `croner` to your `Cargo.toml` dependencies:
```toml
[dependencies]
croner = "3.0.1" # Adjust the version as necessary
```
### Usage
Here's a quick example to get you started with matching current time, and
finding the next occurrence. `is_time_matching` takes a `chrono` `DateTime`:
```rust
use croner::Cron;
use chrono::Local;
fn main() {
// Parse cron expression
let cron_all = Cron::from_str("18 * * * 5")
.expect("Couldn't parse cron string");
// Compare cron pattern with current local time
let time = Local::now();
let matches_all = cron_all.is_time_matching(&time).unwrap();
// Get next match
let next = cron_all.find_next_occurrence(&time, false).unwrap();
// Output results
println!("Description: {}", cron.describe());
println!("Time is: {}", time);
println!("Pattern \"{}\" does {} time {}", cron_all.pattern.to_string(), if matches_all { "match" } else { "not match" }, time );
println!("Pattern \"{}\" will match next time at {}", cron_all.pattern.to_string(), next);
}
```
To match against a non local timezone, croner supports zoned chrono DateTime's
`DateTime<Tz>`. To use a named time zone, you can utilize the `chrono-tz` crate.
```rust
use croner::Cron;
use chrono::Local;
use chrono_tz::Tz;
fn main() {
// Parse cron expression
let cron = Cron::from_str("18 * * * 5")
.expect("Couldn't parse cron string");
// Choose a different time zone, for example America/New_York
let est_timezone: Tz = "America/New_York".parse().expect("Invalid timezone");
// Find the next occurrence in EST
let time_est = Local::now().with_timezone(&est_timezone);
let next_est = cron.find_next_occurrence(&time_est, false).unwrap();
// Output results for EST
println!("EST time is: {}", time_est);
println!(
"Pattern \"{}\" will match next time at (EST): {}",
cron.pattern.to_string(),
next_est
);
}
```
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).
```rust
use chrono::Local;
use croner::parser::CronParser;
fn main() {
// Parse cron expression for Fridays in December
let cron = CronParser::builder()
// Include seconds in pattern
.seconds(croner::parser::Seconds::Optional)
// Ensure both day of month and day of week conditions are met
.dom_and_dow(true)
.build()
.parse("0 0 0 31 12 FRI")
.expect("Couldn't parse cron string");
let time = Local::now();
println!("Finding the next 5 New Year's Eves on a Friday:");
for time in cron.iter_from(time).take(5) {
println!("{time}");
}
}
```
### Pattern
The expressions used by Croner are very similar to those of Vixie Cron, but with
a few additions and changes as outlined below:
```javascript
// ┌──────────────── (optional) second (0 - 59)
// │ ┌────────────── minute (0 - 59)
// │ │ ┌──────────── hour (0 - 23)
// │ │ │ ┌────────── day of month (1 - 31)
// │ │ │ │ ┌──────── month (1 - 12, JAN-DEC)
// │ │ │ │ │ ┌────── day of week (0 - 6, SUN-Mon)
// │ │ │ │ │ │ (0 to 6 are Sunday to Saturday; 7 is Sunday, the same as 0)
// │ │ │ │ │ │
// * * * * * *
```
- Croner expressions have the following additional modifiers:
- _?_: In the Rust version of croner, a questionmark in the day-of-month or
day-of-week field behaves just as `*`. This allow for legacy cron patterns
to be used.
- _L_: The letter 'L' can be used in the day of the month field to indicate
the last day of the month. When used in the day of the week field in
conjunction with the # character, it denotes the last specific weekday of
the month. For example, `5#L` represents the last Friday of the month.
- _#_: The # character specifies the "nth" occurrence of a particular day
within a month. For example, supplying `5#2` in the day of week field
signifies the second Friday of the month. This can be combined with ranges
and supports day names. For instance, MON-FRI#2 would match the Monday
through Friday of the second week of the month.
- _W_: The character 'W' is used to specify the closest weekday to a given day
in the day of the month field. For example, 15W will match the closest
weekday to the 15th of the month. If the specified day falls on a weekend
(Saturday or Sunday), the pattern will match the closest weekday before or
after that date. For instance, if the 15th is a Saturday, 15W will match the
14th (Friday), and if the 15th is a Sunday, it will match the 16th (Monday).
- _+_: The plus sign can be used as a prefix to the day-of-week field to create
a logical AND between the day-of-month and day-of-week fields. By default,
the relationship is a logical OR. For example, `0 0 1 * +MON` will run only
if the 1st of the month is also a Monday.
| Field | Required | Allowed values | Allowed special characters | Remarks |
| ------------ | -------- | --------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------- |
| Seconds | Optional | 0-59 | * , - / | |
| Minutes | Yes | 0-59 | * , - / | |
| Hours | Yes | 0-23 | * , - / | |
| Day of Month | Yes | 1-31 | * , - / ? L W | |
| Month | Yes | 1-12 or JAN-DEC | * , - / | |
| Day of Week | Yes | 0-7 or SUN-MON | * , - / ? # L + | 0 to 6 are Sunday to Saturday<br>7 is Sunday, the same as 0<br># is used to specify nth occurrence of a weekday |
> [!NOTE]
> Weekday and month names are case-insensitive. Both `MON` and `mon`
> work. When using `L` in the Day of Week field, it affects all specified
> weekdays. For example, `5-6#L` means the last Friday and Saturday in the
> month." The # character can be used to specify the "nth" weekday of the month.
> For example, 5#2 represents the second Friday of the month.
> [!NOTE]
> The `W` feature is constrained within the given month. The search for
> the closest weekday will not cross into a previous or subsequent month. For
> example, if the 1st of the month is a Saturday, 1W will trigger on Monday
> the 3rd, not the last Friday of the previous month.
It is also possible to use the following "nicknames" as pattern.
| Nickname | Description |
| ---------- | ---------------------------------- |
| \@yearly | Run once a year, ie. "0 0 1 1 *". |
| \@annually | Run once a year, ie. "0 0 1 1 *". |
| \@monthly | Run once a month, ie. "0 0 1 * *". |
| \@weekly | Run once a week, ie. "0 0 * * 0". |
| \@daily | Run once a day, ie. "0 0 * * *". |
| \@hourly | Run once an hour, ie. "0 * * * *". |
### Configuration
Croner uses `CronParser` to parse the cron expression. Invoking
`Cron::from_str("pattern")` is equivalent to
`CronParser::new().parse("pattern")`. You can customise the parser by creating a
parser builder using `CronParser::builder`.
#### 1. Making seconds optional
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.
**Example Usage**:
```rust
use croner::parser::{CronParser, Seconds};
// Configure the parser to allow seconds.
let parser = CronParser::builder().seconds(Seconds::Optional).build();
let cron = parser
.parse("*/10 * * * * *") // Every 10 seconds
.expect("Invalid cron pattern");
```
#### 2. Making seconds optional required
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.
**Example Usage**:
```rust
use croner::parser::{CronParser, Seconds};
// Configure the parser to require seconds.
let parser = CronParser::builder().seconds(Seconds::Required).build();
let cron = parser
.parse("5 */2 * * * *") // At 5 seconds past every 2 minutes
.expect("Invalid cron pattern");
```
#### 3. `dom_and_dow`
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.
> [!NOTE]
> 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.
**Example Usage**:
```rust
use croner::parser::CronParser;
// Configure the parser to enable DOM and DOW.
let parser = CronParser::builder().dom_and_dow(true).build();
let cron = parser
.parse("0 0 25 * FRI") // When christmas day is on a friday
.expect("Invalid cron pattern");
```
#### 4. `alternative_weekdays` (Quartz mode)
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.
**Example Usage**:
```rust
use croner::parser::CronParser;
// Configure the parser to use Quartz-style weekday mode.
let parser = CronParser::builder().alternative_weekdays(true).build();
let cron = parser
.parse("0 0 12 * * 6") // Every Friday (denoted with 6 in Quartz mode) at noon
.expect("Invalid cron pattern");
```
### Documentation
For detailed usage and API documentation, visit
[Croner on docs.rs](https://docs.rs/croner/).
## Time and Calendar System
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.
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.
### Daylight Saving Time (DST) Handling
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:
* **Fixed-Time Jobs**: Jobs with specific numerical values for seconds, minutes, and hours (e.g., `0 30 2 * * *`).
* **Interval/Wildcard Jobs**: Jobs using wildcards (`*`) or step values (`*/N`) in their seconds, minutes, or hours fields (e.g., `*/5 * * * * *`).
During DST transitions, Croner-rust behaves as follows:
* **DST Gap (Spring Forward)**: When a scheduled time falls into a non-existent interval (e.g., 2:00 AM jumps to 3:00 AM):
* Fixed-Time Jobs: Will execute at the first valid second/minute immediately following the gap on the same calendar day.
* 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.
* **DST Overlap (Fall Back)**: When a scheduled time interval occurs twice (e.g., 2:00 AM falls back to 1:00 AM):
* Fixed-Time Jobs: Will execute only once, at its first occurrence in wall clock time.
* Interval/Wildcard Jobs: Will execute for each occurrence that matches its pattern in wall clock time within the duplicated hour.
## Development
To start developing in the Croner project:
1. Clone the repository.
2. Navigate into the project directory.
3. Build the project using `cargo build`.
4. Run tests with `cargo test --all-features`.
5. Run demo with `cargo run --example simple_demo`
## Contributing
We welcome contributions! Please feel free to submit a pull request or open an
issue.
## License
This project is licensed under the MIT License - see the
[LICENSE.md](LICENSE.md) file for details.
## Disclaimer
Please note that Croner is currently in its early stages of development. As
such, the API is subject to change in future releases, adhering to semantic
versioning principles. We recommend keeping this in mind when integrating Croner
into your projects.
## Contact
If you have any questions or feedback, please open an issue in the repository
and we'll get back to you as soon as possible.
+22
View File
@@ -0,0 +1,22 @@
use chrono::Local;
use criterion::{black_box, criterion_group, criterion_main, Criterion};
use croner::{parser::CronParser, Cron};
fn parse_take_100(_n: u64) {
let cron: Cron = CronParser::builder()
.seconds(croner::parser::Seconds::Optional)
.build()
.parse("15 15 15 L 3 *")
.expect("Couldn't parse cron string");
let time = Local::now();
for _time in cron.clone().iter_after(time).take(100) {}
}
pub fn criterion_benchmark(c: &mut Criterion) {
c.bench_function("parse_take_100", |b| {
b.iter(|| parse_take_100(black_box(20)))
});
}
criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);
+28
View File
@@ -0,0 +1,28 @@
use chrono::Utc;
use croner::parser::CronParser;
fn main() {
// Parse cron expression
let cron = CronParser::builder()
.seconds(croner::parser::Seconds::Optional)
.build()
.parse("* * * * * *")
.expect("Couldn't parse cron string");
// Compare to UTC time now
let time = Utc::now();
// (Or Local)
// let time = Local::now();
// Get next 5 matches using iter_after
// There is also iter_after, which does not match starting time
println!(
"Finding matches of pattern '{}' starting from {}:",
cron.pattern, time
);
for time in cron.iter_after(time).take(5) {
println!("{time}");
}
}
+52
View File
@@ -0,0 +1,52 @@
use chrono::Local;
use croner::parser::CronParser;
use croner::describe::lang::swedish::Swedish; // For demonstrating translation
fn main() {
// Example: Parse cron expression
let cron = CronParser::builder()
.seconds(croner::parser::Seconds::Required) // Example of configuration - make seconds required
.build()
.parse("0 18 * * * FRI")
.expect("Couldn't parse cron string");
// Example: Compare cron pattern with current local time
let time = Local::now();
let matches = cron.is_time_matching(&time).unwrap();
// Example: Get next match
let next = cron.find_next_occurrence(&time, false).unwrap();
// Example: Get and print the human-readable description
let description = cron.describe();
println!("Description: {description}");
// Example: Get and print the human-readable description in Swedish
let swedish_description = cron.describe_lang(Swedish); // 2. Call describe_lang() with Swedish
println!("Swedish Description: {swedish_description}");
// Example: Output results
println!("Current time is: {time}");
println!(
"Pattern \"{}\" does {} time {}",
cron.pattern,
if matches { "match" } else { "not match" },
time
);
println!(
"Pattern \"{}\" will match next time at {}",
cron.pattern, next
);
// Example: Iterator
println!("Next 5 matches:");
for time in cron.clone().iter_after(Local::now()).take(5) {
println!("{time}");
}
// Example: Reverse Iterator
println!("Previous 5 matches:");
for time in cron.clone().iter_before(Local::now()).take(5) {
println!("{time}");
}
}
+22
View File
@@ -0,0 +1,22 @@
use std::str::FromStr as _;
use chrono::Utc;
use chrono_tz::Tz;
use croner::Cron;
fn main() {
// Parse cron expression
let cron = Cron::from_str("18 * * * 5").expect("Couldn't parse cron string");
// Find the next occurrence in Europe/Stockholm
let now_stockholm = Utc::now().with_timezone(&Tz::Europe__Stockholm);
let next_stockholm = cron.find_next_occurrence(&now_stockholm, false).unwrap();
// Output results for Europe/Stockholm
println!("UTC time is: {}", &Utc::now());
println!("Time in Europe/Stockholm time is: {}", &now_stockholm);
println!(
"Pattern \"{}\" will match next time at (Europe/Stockholm): {}",
cron.pattern, next_stockholm
);
}
+590
View File
@@ -0,0 +1,590 @@
use crate::errors::CronError;
// Constants for flags
pub const NONE_BIT: u8 = 0;
pub const ALL_BIT: u8 = 1;
// Used for nth weekday
pub const NTH_1ST_BIT: u8 = 1 << 1;
pub const NTH_2ND_BIT: u8 = 1 << 2;
pub const NTH_3RD_BIT: u8 = 1 << 3;
pub const NTH_4TH_BIT: u8 = 1 << 4;
pub const NTH_5TH_BIT: u8 = 1 << 5;
pub const NTH_ALL: u8 = NTH_1ST_BIT | NTH_2ND_BIT | NTH_3RD_BIT | NTH_4TH_BIT | NTH_5TH_BIT;
// Used for closest weekday
pub const CLOSEST_WEEKDAY_BIT: u8 = 1 << 7;
// Used for last day of month
pub const LAST_BIT: u8 = 1 << 6;
/// Represents a component of a cron pattern, such as minute, hour, or day of week.
///
/// Each `CronComponent` holds information about permissible values (min, max),
/// features supported (like last day of the month), and specific bits set
/// for scheduling purposes.
///
/// # Examples (for internal use only, CronComponent isn't exported)
///
/// let mut minute_component = CronComponent::new(0, 59, CronComponent::LAST_BIT);
/// // Represents a minute component that supports the 'last' feature.
///
/// // Parsing a cron expression for minute component
/// // This sets specific bits in the component according to the cron syntax
/// minute_component.parse("*/15").expect("Parsing failed");
/// // Sets the minute component to trigger at every 15th minute
#[derive(Debug, Default, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CronComponent {
bitfields: Vec<u8>, // Vector of u8 to act as multiple bitfields
pub min: u16, // Minimum value this component can take
pub max: u16, // Maximum value this component can take
pub step: u16, // Steps to skip in this component
pub from_wildcard: bool, // Wildcard used
features: u8, // Single u8 bitfield to indicate supported special bits, like LAST_BIT
enabled_features: u8, // Bitfield to hold component-wide special bits like LAST_BIT
input_offset: u16, // Offset for numerical representation
}
impl CronComponent {
/// Creates a new `CronComponent` with specified minimum and maximum values and features.
///
/// `min` and `max` define the range of values this component can take.
/// `features` is a bitfield specifying supported special features.
///
/// # Parameters
///
/// - `min`: The minimum permissible value for this component.
/// - `max`: The maximum permissible value for this component.
/// - `features`: Bitfield indicating special features like `LAST_BIT`.
///
/// # Returns
///
/// Returns a new instance of `CronComponent`.
pub fn new(min: u16, max: u16, features: u8, input_offset: u16) -> Self {
// Handle the case where max might make usize overflow if not checked
let bitfields_size = if max > 0 { max as usize + 1 } else { 0 };
Self {
// Vector of u8 to act as multiple bitfields.
// - Initialized with NONE_BIT for each element.
bitfields: vec![NONE_BIT; bitfields_size],
// Minimum value this component can take.
// - Example: 0 for the minute-field
min,
// Maximum value this component can take.
// - Example: 59 for the minute-field
max,
// Bitfield to indicate _supported_ special bits, like LAST_BIT.
// - ALL_BIT and LAST_BIT is always allowed
features: features | ALL_BIT | LAST_BIT,
// Bitfield to indicate _enabled_ component-wide special bits like LAST_BIT.
// - No features are enabled by default
enabled_features: 0,
// Offset for numerical representation of weekdays. normally 0=SUN,1=MON etc, setting this to 1 makes 1=SUN...
input_offset,
step: 1, // Used by .describe()
from_wildcard: false, // Used by .describe()
}
}
// Method primarily used by .describe() to evaluate if all bits are set
pub fn is_all_set(&self) -> bool {
// A component is "all set" if it's a '*' with no step.
// We check if all bits in its range are set for the ALL_BIT flag.
for i in self.min..=self.max {
if !self.is_bit_set(i, ALL_BIT).unwrap_or(false) {
return false;
}
}
true
}
// Set a bit at a given position (e.g., 0 to 9999 for year)
pub fn set_bit(&mut self, mut pos: u16, bit: u8) -> Result<(), CronError> {
if pos < self.input_offset {
return Err(CronError::ComponentError(format!(
"Position {} is less than the input offset {}.",
pos, self.input_offset
)));
}
pos -= self.input_offset;
if pos < self.min || pos > self.max {
return Err(CronError::ComponentError(format!(
"Position {} is out of bounds for the current range ({}-{}).",
pos, self.min, self.max
)));
}
if self.features & bit != bit {
return Err(CronError::ComponentError(format!(
"Bit 0b{:08b} is not supported by the current features 0b{:08b}.",
bit, self.features
)));
}
let index = pos as usize; // Convert the position to an index
if index >= self.bitfields.len() {
// In case the index is somehow out of the vector's bounds
return Err(CronError::ComponentError(format!(
"Position {pos} is out of the bitfields vector's bounds."
)));
}
self.bitfields[index] |= bit; // Set the specific bit at the position
Ok(())
}
// Unset a specific bit at a given position
pub fn unset_bit(&mut self, mut pos: u16, bit: u8) -> Result<(), CronError> {
if pos < self.input_offset {
return Err(CronError::ComponentError(format!(
"Position {} is less than the input offset {}.",
pos, self.input_offset
)));
}
pos -= self.input_offset;
if pos < self.min || pos > self.max {
return Err(CronError::ComponentError(format!(
"Position {} is out of bounds for the current range ({}-{}).",
pos, self.min, self.max
)));
}
if self.features & bit != bit {
return Err(CronError::ComponentError(format!(
"Bit 0b{:08b} is not supported by the current features 0b{:08b}.",
bit, self.features
)));
}
let index = pos as usize; // Convert the position to an index
if index >= self.bitfields.len() {
// In case the index is somehow out of the vector's bounds
return Err(CronError::ComponentError(format!(
"Position {pos} is out of the bitfields vector's bounds."
)));
}
self.bitfields[index] &= !bit; // Unset the specific bit at the position
Ok(())
}
// Check if a specific bit at a given position is set
pub fn is_bit_set(&self, pos: u16, bit: u8) -> Result<bool, CronError> {
if pos < self.min || pos > self.max {
Err(CronError::ComponentError(format!(
"Position {} is out of bounds for the current range ({}-{}).",
pos, self.min, self.max
)))
} else if self.features & bit != bit {
Err(CronError::ComponentError(format!(
"Bit 0b{:08b} is not supported by the current features 0b{:08b}.",
bit, self.features
)))
} else {
let index = pos as usize;
if index >= self.bitfields.len() {
Err(CronError::ComponentError(format!(
"Position {pos} is out of the bitfields vector's bounds."
)))
} else {
Ok((self.bitfields[index] & bit) != 0)
}
}
}
// Method to enable a feature
pub fn enable_feature(&mut self, feature: u8) -> Result<(), CronError> {
if self.is_feature_allowed(feature) {
self.enabled_features |= feature;
Ok(())
} else {
Err(CronError::ComponentError(format!(
"Feature 0b{:08b} is not supported by the current features 0b{:08b}.",
feature, self.features
)))
}
}
pub fn is_feature_allowed(&mut self, feature: u8) -> bool {
self.features & feature == feature
}
// Method to check if a feature is enabled
pub fn is_feature_enabled(&self, feature: u8) -> bool {
(self.enabled_features & feature) == feature
}
/// Parses a part of a cron expression string and sets the corresponding bits in the component.
///
/// This method interprets the cron syntax provided in `field` and sets
/// the relevant bits in the component. It supports standard cron patterns
/// like '*', '-', '/', and 'w'. For example, '*/15' in a minute component
/// would set the bits for every 15th minute.
///
/// # Parameters
///
/// - `field`: A string slice containing the cron expression part to parse.
///
/// # Returns
///
/// Returns `Ok(())` if parsing is successful, or `CronError` if the parsing fails.
///
/// # Errors
///
/// Returns `CronError::ComponentError` if the input string contains invalid
/// cron syntax or values outside the permissible range of the component.
///
/// # Examples (for internal use only, CronComponent isn't exported)
///
/// use crate::component::CronComponent;
/// let mut hour_component = CronComponent::new(0, 23, 0);
/// hour_component.parse("*/3").expect("Parsing failed");
/// // Sets the hour component to trigger at every 3rd hour
pub fn parse(&mut self, field: &str) -> Result<(), CronError> {
if field == "*" {
self.from_wildcard = true;
for value in self.min..=self.max {
self.set_bit(value + self.input_offset, ALL_BIT)?;
}
return Ok(());
}
for part in field.split(',') {
let trimmed_part = part.trim();
if trimmed_part.is_empty() {
continue;
}
let mut parsed_part = trimmed_part.to_string();
if parsed_part.contains('/') {
self.handle_stepping(&parsed_part)?;
} else if parsed_part.contains('-') {
self.handle_range(&parsed_part)?;
} else if parsed_part.contains('W') {
self.handle_closest_weekday(&parsed_part)?;
} else if parsed_part.eq_ignore_ascii_case("L") {
// Handle "L" for the last bit
self.enable_feature(LAST_BIT)?;
} else {
// If 'L' is contained without '#', like "5L", add the missing '#'
if parsed_part.ends_with('L') && !parsed_part.contains('#') {
parsed_part = parsed_part.replace('L', "#L");
}
// If '#' is contained in the number, require feature NTH_ALL to be set
if parsed_part.contains('#') && !self.is_feature_allowed(NTH_ALL) {
return Err(CronError::ComponentError(
"Nth specifier # not allowed in the current field.".to_string(),
));
}
// If 'L' is contained in the number, require feature NTH_ALL to be set
if parsed_part.contains('L') && !self.is_feature_allowed(NTH_ALL) {
return Err(CronError::ComponentError(
"L not allowed in the current field.".to_string(),
));
}
self.handle_number(&parsed_part)?;
}
}
Ok(())
}
/// Returns a vector of u16 values for all bits set in the component for a given bitflag.
pub fn get_set_values(&self, bit: u8) -> Vec<u16> {
(self.min..=self.max)
.filter(|i| self.is_bit_set(*i, bit).unwrap_or(false))
.collect()
}
fn get_nth_bit(value: &str) -> Result<u8, CronError> {
// If value ends with 'L', we set the LAST_BIT and exit early
if value.ends_with('L') {
return Ok(LAST_BIT);
}
if let Some(nth_pos) = value.find('#') {
let nth = value[nth_pos + 1..]
.parse::<u8>()
.map_err(|_| CronError::ComponentError("Invalid nth specifier.".to_string()))?;
if nth == 0 || nth > 5 {
Err(CronError::ComponentError(
"Nth specifier out of bounds.".to_string(),
))
} else {
match nth {
1 => Ok(NTH_1ST_BIT),
2 => Ok(NTH_2ND_BIT),
3 => Ok(NTH_3RD_BIT),
4 => Ok(NTH_4TH_BIT),
5 => Ok(NTH_5TH_BIT),
_ => Err(CronError::ComponentError(
"Invalid nth specifier.".to_string(),
)),
}
}
} else {
Ok(ALL_BIT)
}
}
// Removes everything after #
fn strip_nth_part(value: &str) -> &str {
value.split('#').next().unwrap_or("")
}
fn handle_closest_weekday(&mut self, value: &str) -> Result<(), CronError> {
if let Some(day_pos) = value.find('W') {
// Use a slice
let day_str = &value[..day_pos];
// Parse the day from the slice
let day = day_str.parse::<u16>().map_err(|_| {
CronError::ComponentError("Invalid day for closest weekday.".to_string())
})?;
// Check if the day is within the allowed range
if day < self.min || day > self.max {
return Err(CronError::ComponentError(
"Day for closest weekday out of bounds.".to_string(),
));
}
// Set the bit for the closest weekday
self.set_bit(day, CLOSEST_WEEKDAY_BIT)?;
} else {
// If 'W' is not found, handle the value as a regular number
self.handle_number(value)?;
}
Ok(())
}
fn handle_range(&mut self, range: &str) -> Result<(), CronError> {
let bit_to_set = CronComponent::get_nth_bit(range)?;
let str_clean = CronComponent::strip_nth_part(range);
let parts: Vec<&str> = str_clean.split('-').map(str::trim).collect();
if parts.len() != 2 {
return Err(CronError::ComponentError(
"Invalid range syntax.".to_string(),
));
}
let start = parts[0]
.parse::<u16>()
.map_err(|_| CronError::ComponentError("Invalid start of range.".to_string()))?;
let end = parts[1]
.parse::<u16>()
.map_err(|_| CronError::ComponentError("Invalid end of range.".to_string()))?;
if start > end || start < self.min || end > self.max {
return Err(CronError::ComponentError(
"Range out of bounds.".to_string(),
));
}
for value in start..=end {
self.set_bit(value, bit_to_set)?;
}
Ok(())
}
fn handle_number(&mut self, value: &str) -> Result<(), CronError> {
let bit_to_set = CronComponent::get_nth_bit(value)?;
let value_clean = CronComponent::strip_nth_part(value);
let num = value_clean
.parse::<u16>()
.map_err(|_| CronError::ComponentError("Invalid number.".to_string()))?;
if num < self.min || num > self.max {
return Err(CronError::ComponentError(
"Number out of bounds.".to_string(),
));
}
self.set_bit(num, bit_to_set)?;
Ok(())
}
pub fn handle_stepping(&mut self, stepped_range: &str) -> Result<(), CronError> {
let bit_to_set = CronComponent::get_nth_bit(stepped_range)?;
let stepped_range_clean = CronComponent::strip_nth_part(stepped_range);
let parts: Vec<&str> = stepped_range_clean.split('/').collect();
if parts.len() != 2 {
return Err(CronError::ComponentError(
"Invalid stepped range syntax.".to_string(),
));
}
let range_part = parts[0];
let step_str = parts[1];
let step = step_str
.parse::<u16>()
.map_err(|_| CronError::ComponentError("Invalid step.".to_string()))?;
self.step = step;
if step == 0 {
return Err(CronError::ComponentError(
"Step cannot be zero.".to_string(),
));
}
let (start, end) = if range_part == "*" {
self.from_wildcard = true;
(self.min, self.max)
} else if range_part.contains('-') {
let bounds: Vec<&str> = range_part.split('-').collect();
if bounds.len() != 2 {
return Err(CronError::ComponentError(
"Invalid range syntax in stepping.".to_string(),
));
}
(
bounds[0]
.parse::<u16>()
.map_err(|_| CronError::ComponentError("Invalid range start.".to_string()))?,
bounds[1]
.parse::<u16>()
.map_err(|_| CronError::ComponentError("Invalid range end.".to_string()))?,
)
} else {
let single_start = range_part
.parse::<u16>()
.map_err(|_| CronError::ComponentError("Invalid start.".to_string()))?;
// If only one number is provided, set the range to go from the start value to the max value.
(single_start, self.max)
};
if start < self.min || end > self.max || start > end {
return Err(CronError::ComponentError(
"Range is out of bounds in stepping.".to_string(),
));
}
// Apply stepping within the range
let mut value = start;
while value <= end {
self.set_bit(value, bit_to_set)?;
value = value.checked_add(step).ok_or_else(|| {
CronError::ComponentError("Value exceeded max after stepping.".to_string())
})?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::errors::CronError;
#[test]
fn test_new_cron_component() {
let component = CronComponent::new(0, 59, ALL_BIT | LAST_BIT, 0);
assert_eq!(component.min, 0);
assert_eq!(component.max, 59);
// Ensure all bitfields are initialized to NONE_BIT
assert!(component.bitfields.iter().all(|&b| b == NONE_BIT));
// Check that ALL_BIT and LAST_BIT are included in features
assert!(component.features & (ALL_BIT | LAST_BIT) == (ALL_BIT | LAST_BIT));
}
#[test]
fn test_set_bit() {
let mut component = CronComponent::new(0, 59, ALL_BIT, 0);
assert!(component.set_bit(10, ALL_BIT).is_ok());
assert!(component.is_bit_set(10, ALL_BIT).unwrap());
}
#[test]
fn test_set_bit_out_of_bounds() {
let mut component = CronComponent::new(0, 59, ALL_BIT, 0);
assert!(matches!(
component.set_bit(60, ALL_BIT),
Err(CronError::ComponentError(_))
));
}
#[test]
fn test_unset_bit() {
let mut component = CronComponent::new(0, 59, ALL_BIT, 0);
component.set_bit(10, ALL_BIT).unwrap();
assert!(component.unset_bit(10, ALL_BIT).is_ok());
assert!(!component.is_bit_set(10, ALL_BIT).unwrap());
}
#[test]
fn test_is_feature_enabled() {
let mut component = CronComponent::new(0, 59, LAST_BIT, 0);
assert!(!component.is_feature_enabled(LAST_BIT));
component.enable_feature(LAST_BIT).unwrap();
assert!(component.is_feature_enabled(LAST_BIT));
}
#[test]
fn test_enable_feature_unsupported() {
let mut component = CronComponent::new(0, 59, NONE_BIT, 0);
assert!(matches!(
component.enable_feature(NTH_1ST_BIT),
Err(CronError::ComponentError(_))
));
}
#[test]
fn test_parse_asterisk() {
let mut component = CronComponent::new(0, 59, ALL_BIT, 0);
component.parse("*").unwrap();
for i in 0..=59 {
assert!(component.is_bit_set(i, ALL_BIT).unwrap());
}
}
#[test]
fn test_parse_range() {
let mut component = CronComponent::new(0, 59, ALL_BIT, 0);
component.parse("10-15").unwrap();
for i in 10..=15 {
assert!(component.is_bit_set(i, ALL_BIT).unwrap());
}
}
#[test]
fn test_parse_stepping() {
let mut component = CronComponent::new(0, 59, ALL_BIT, 0);
component.parse("*/5").unwrap();
for i in (0..=59).filter(|n| n % 5 == 0) {
assert!(component.is_bit_set(i, ALL_BIT).unwrap());
}
}
#[test]
fn test_parse_list() {
let mut component = CronComponent::new(0, 59, ALL_BIT, 0);
component.parse("5,10,15").unwrap();
for i in [5, 10, 15].iter() {
assert!(component.is_bit_set(*i, ALL_BIT).unwrap());
}
}
#[test]
fn test_parse_invalid_syntax() {
let mut component = CronComponent::new(0, 59, ALL_BIT, 0);
assert!(component.parse("10-").is_err());
assert!(component.parse("*/").is_err());
assert!(component.parse("60").is_err()); // out of bounds for the minute field
}
#[test]
fn test_parse_closest_weekday() {
let mut component = CronComponent::new(1, 31, CLOSEST_WEEKDAY_BIT, 0);
component.parse("15W").unwrap();
assert!(component.is_bit_set(15, CLOSEST_WEEKDAY_BIT).unwrap());
// You might want to add more tests for edge cases
}
}
@@ -0,0 +1,53 @@
use crate::describe::Language;
#[derive(Default, Clone, Copy)]
pub struct English;
impl Language for English {
fn every_minute(&self) -> &'static str { "Every minute" }
fn every_second_phrase(&self) -> &'static str { "Every second" }
fn every_x_minutes(&self, s: u16) -> String { format!("every {s} minutes") }
fn every_x_seconds(&self, s: u16) -> String { format!("every {s} seconds") }
fn every_x_hours(&self, s: u16) -> String { format!("of every {s} hours") }
fn every_minute_of_every_x_hours(&self, s: u16) -> String { format!("Every minute, of every {s} hours") }
fn at_time(&self, time: &str) -> String { format!("At {time}") }
fn at_time_and_every_x_seconds(&self, time: &str, step: u16) -> String { format!("At {time}, every {step} seconds") }
fn at_time_at_second(&self, time: &str, second: &str) -> String { format!("At {time}, at second {second}") }
fn at_phrase(&self, phrase: &str) -> String { format!("At {phrase}") }
fn on_phrase(&self, phrase: &str) -> String { format!("on {phrase}") }
fn in_phrase(&self, phrase: &str) -> String { format!("in {phrase}") }
fn second_phrase(&self, s: &str) -> String { format!("second {s}") }
fn minute_phrase(&self, s: &str) -> String { format!("minute {s}") }
fn minute_past_every_hour_phrase(&self, s: &str) -> String { format!("{s} past every hour") }
fn hour_phrase(&self, s: &str) -> String { format!("of hour {s}") }
fn year_phrase(&self, s: &str) -> String { format!("year {s}") }
fn day_phrase(&self, s: &str) -> String { format!("day {s}") }
fn the_last_day_of_the_month(&self) -> &'static str { "the last day of the month" }
fn the_weekday_nearest_day(&self, day: &str) -> String { format!("the weekday nearest day {day}") }
fn the_last_weekday_of_the_month(&self, day: &str) -> String { format!("the last {day} of the month") }
fn the_nth_weekday_of_the_month(&self, n: u8, day: &str) -> String {
let suffix = match n {
1 => "st",
2 => "nd",
3 => "rd",
_ => "th",
};
let num_str = format!("{n}{suffix}");
format!("the {num_str} {day} of the month")
}
fn dom_and_dow_if_also(&self, dow: &str) -> String { format!("(if it is also {dow})") }
fn dom_and_dow_if_also_one_of(&self, dow: &str) -> String { format!("(if it is also one of: {dow})") }
fn list_conjunction_and(&self) -> &'static str { "and" }
fn list_conjunction_or(&self) -> &'static str { "or" }
fn list_conjunction_and_comma(&self) -> &'static str { ", and" }
fn day_of_week_names(&self) -> [&'static str; 7] { ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"] }
fn month_names(&self) -> [&'static str; 12] { ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"] }
}
+2
View File
@@ -0,0 +1,2 @@
pub mod english;
pub mod swedish;
@@ -0,0 +1,54 @@
use crate::describe::Language;
#[derive(Default, Clone, Copy)]
pub struct Swedish;
impl Language for Swedish {
fn every_minute(&self) -> &'static str { "Varje minut" }
fn every_second_phrase(&self) -> &'static str { "Varje sekund" }
fn every_x_minutes(&self, s: u16) -> String { format!("var {s}:e minut") }
fn every_x_seconds(&self, s: u16) -> String { format!("var {s}:e sekund") }
fn every_x_hours(&self, s: u16) -> String { format!("var {s}:e timme") }
fn every_minute_of_every_x_hours(&self, s: u16) -> String { format!("Varje minut, var {s}:e timme") }
fn at_time(&self, time: &str) -> String { format!("Klockan {time}") }
fn at_time_and_every_x_seconds(&self, time: &str, step: u16) -> String { format!("Klockan {time}, var {step}:e sekund") }
fn at_time_at_second(&self, time: &str, second: &str) -> String { format!("Klockan {time}, på sekund {second}") }
fn at_phrase(&self, phrase: &str) -> String { format!("Vid {phrase}") }
fn on_phrase(&self, phrase: &str) -> String { format!("på {phrase}") }
fn in_phrase(&self, phrase: &str) -> String { format!("i {phrase}") }
fn second_phrase(&self, s: &str) -> String { format!("sekund {s}") }
fn minute_phrase(&self, s: &str) -> String { format!("minut {s}") }
fn minute_past_every_hour_phrase(&self, s: &str) -> String { format!("{s} över varje heltimme") }
fn hour_phrase(&self, s: &str) -> String { format!("timme {s}") }
fn year_phrase(&self, s: &str) -> String { format!("år {s}") }
fn day_phrase(&self, s: &str) -> String { format!("dag {s}") }
fn the_last_day_of_the_month(&self) -> &'static str { "sista dagen i månaden" }
fn the_weekday_nearest_day(&self, day: &str) -> String { format!("veckodagen närmast dag {day}") }
fn the_last_weekday_of_the_month(&self, day: &str) -> String { format!("sista {day} i månaden") }
fn the_nth_weekday_of_the_month(&self, n: u8, day: &str) -> String {
let ordinal = match n {
1 => "första",
2 => "andra",
3 => "tredje",
4 => "fjärde",
5 => "femte",
_ => "", // Should not happen with cron's # specifier
};
format!("den {ordinal} {day} i månaden")
}
fn dom_and_dow_if_also(&self, dow: &str) -> String { format!("(om det också är {dow})") }
fn dom_and_dow_if_also_one_of(&self, dow: &str) -> String { format!("(om det också är en av: {dow})") }
fn list_conjunction_and(&self) -> &'static str { "och" }
fn list_conjunction_or(&self) -> &'static str { "eller" }
fn list_conjunction_and_comma(&self) -> &'static str { "och" } // Oxford comma is not used in Swedish
fn day_of_week_names(&self) -> [&'static str; 7] { ["söndag", "måndag", "tisdag", "onsdag", "torsdag", "fredag", "lördag"] }
fn month_names(&self) -> [&'static str; 12] { ["januari", "februari", "mars", "april", "maj", "juni", "juli", "augusti", "september", "oktober", "november", "december"] }
}
+642
View File
@@ -0,0 +1,642 @@
pub mod lang;
pub use lang::english::English;
use crate::component::{
CronComponent, ALL_BIT, CLOSEST_WEEKDAY_BIT, LAST_BIT, NTH_1ST_BIT, NTH_2ND_BIT, NTH_3RD_BIT,
NTH_4TH_BIT, NTH_5TH_BIT,
};
use crate::pattern::CronPattern;
// This defines the contract for providing localized strings.
pub trait Language {
fn every_minute(&self) -> &'static str;
fn every_second_phrase(&self) -> &'static str;
fn every_x_minutes(&self, step: u16) -> String; // Changed to u16
fn every_x_seconds(&self, step: u16) -> String; // Changed to u16
fn every_x_hours(&self, step: u16) -> String; // Changed to u16
fn every_minute_of_every_x_hours(&self, step: u16) -> String; // Changed to u16
fn at_time(&self, time: &str) -> String;
fn at_time_and_every_x_seconds(&self, time: &str, step: u16) -> String; // Changed to u16
fn at_time_at_second(&self, time: &str, second: &str) -> String;
fn at_phrase(&self, phrase: &str) -> String;
fn on_phrase(&self, phrase: &str) -> String;
fn in_phrase(&self, phrase: &str) -> String;
fn second_phrase(&self, s: &str) -> String;
fn minute_phrase(&self, s: &str) -> String;
fn minute_past_every_hour_phrase(&self, s: &str) -> String;
fn hour_phrase(&self, s: &str) -> String;
fn year_phrase(&self, s: &str) -> String; // New for year
fn day_phrase(&self, s: &str) -> String;
fn the_last_day_of_the_month(&self) -> &'static str;
fn the_weekday_nearest_day(&self, day: &str) -> String;
fn the_last_weekday_of_the_month(&self, day: &str) -> String;
fn the_nth_weekday_of_the_month(&self, n: u8, day: &str) -> String;
fn dom_and_dow_if_also(&self, dow: &str) -> String;
fn dom_and_dow_if_also_one_of(&self, dow: &str) -> String;
fn list_conjunction_and(&self) -> &'static str;
fn list_conjunction_or(&self) -> &'static str;
fn list_conjunction_and_comma(&self) -> &'static str;
fn day_of_week_names(&self) -> [&'static str; 7];
fn month_names(&self) -> [&'static str; 12];
}
/// Generates a human-readable description for a `CronPattern`.
pub fn describe<L: Language>(pattern: &CronPattern, lang: &L) -> String {
let time_desc = describe_time(pattern, lang);
let day_desc = describe_day(pattern, lang);
let month_desc = describe_month(pattern, lang);
let year_desc = describe_year(pattern, lang); // Add year description
let mut parts = vec![];
if !time_desc.is_empty() {
parts.push(time_desc);
}
if !day_desc.is_empty() {
parts.push(day_desc);
}
if !month_desc.is_empty() {
parts.push(month_desc);
}
if !year_desc.is_empty() {
parts.push(year_desc);
}
let mut description = parts.join(", ");
if !description.is_empty() {
let mut chars = description.chars();
description = match chars.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + chars.as_str(),
};
description.push('.');
}
description
}
/// Helper function to determine if a component is fully set (like a wildcard `*`).
fn is_all_set(component: &CronComponent) -> bool {
// We can't just check for a wildcard flag anymore.
// A component is "all set" if every possible value is included.
if component.step != 1 {
return false;
}
let total_values = (component.max - component.min + 1) as usize;
// Handle large ranges efficiently
if total_values > 10000 { // Heuristic for very large ranges like year
return component.from_wildcard;
}
let set_values = (component.min..=component.max)
.filter(|i| component.is_bit_set(*i, ALL_BIT).unwrap_or(false)) // Corrected
.count();
total_values == set_values
}
fn describe_time<L: Language>(pattern: &CronPattern, lang: &L) -> String {
let sec_vals = get_set_values(&pattern.seconds, ALL_BIT);
let min_vals = get_set_values(&pattern.minutes, ALL_BIT);
let hour_vals = get_set_values(&pattern.hours, ALL_BIT);
let is_default_seconds = pattern.seconds.step == 1 && sec_vals.len() == 1 && sec_vals[0] == 0;
let is_every_second = is_all_set(&pattern.seconds);
// Heuristic to detect `*/step` patterns, replacing `from_wildcard`.
let is_stepped_from_start =
|step: u16, vals: &[u16], min: u16| step > 1 && !vals.is_empty() && vals[0] == min;
// Handle simplest cases first
if is_every_second && is_all_set(&pattern.minutes) && is_all_set(&pattern.hours) {
return lang.every_second_phrase().to_string();
}
if is_default_seconds && is_all_set(&pattern.minutes) && is_all_set(&pattern.hours) {
return lang.every_minute().to_string();
}
if is_default_seconds
&& is_stepped_from_start(pattern.minutes.step, &min_vals, pattern.minutes.min)
&& is_all_set(&pattern.hours)
{
return lang.at_phrase(&lang.every_x_minutes(pattern.minutes.step));
}
// Handle specific HH:MM time
if !is_every_second
&& pattern.hours.step == 1 && hour_vals.len() == 1
&& pattern.minutes.step == 1 && min_vals.len() == 1
{
let time_str = format!("{:02}:{:02}", hour_vals[0], min_vals[0]);
if !is_default_seconds {
if is_stepped_from_start(pattern.seconds.step, &sec_vals, pattern.seconds.min) {
return lang.at_time_and_every_x_seconds(&time_str, pattern.seconds.step);
}
if sec_vals.len() == 1 {
return lang.at_time(&format!("{}:{:02}", time_str, sec_vals[0]));
}
return lang.at_time_at_second(&time_str, &format_number_list(&sec_vals, lang));
}
return lang.at_time(&time_str);
}
// Special case: "* 0 * * *" -> "Every minute past hour 0"
// When minutes are all set (wildcard) and hours are specific (not all set)
if is_default_seconds && is_all_set(&pattern.minutes) && !is_all_set(&pattern.hours) {
let hour_desc = if is_stepped_from_start(pattern.hours.step, &hour_vals, pattern.hours.min) {
lang.every_x_hours(pattern.hours.step)
} else {
format!("hour {}", format_number_list(&hour_vals, lang))
};
return format!("{} past {}", lang.every_minute(), hour_desc);
}
// Special case: "* * 0 * * *" -> "Every second past hour 0"
// When seconds and minutes are all set (wildcard) and hours are specific (not all set)
if is_every_second && is_all_set(&pattern.minutes) && !is_all_set(&pattern.hours) {
let hour_desc = if is_stepped_from_start(pattern.hours.step, &hour_vals, pattern.hours.min) {
lang.every_x_hours(pattern.hours.step)
} else {
format!("hour {}", format_number_list(&hour_vals, lang))
};
return format!("{} past {}", lang.every_second_phrase(), hour_desc);
}
// Handle all other complex combinations
let mut parts = vec![];
if is_every_second {
parts.push(lang.every_second_phrase().to_string());
} else if !is_default_seconds {
if is_stepped_from_start(pattern.seconds.step, &sec_vals, pattern.seconds.min) {
parts.push(lang.every_x_seconds(pattern.seconds.step));
} else {
parts.push(lang.second_phrase(&format_number_list(&sec_vals, lang)));
}
}
if is_stepped_from_start(pattern.minutes.step, &min_vals, pattern.minutes.min) {
parts.push(lang.every_x_minutes(pattern.minutes.step));
} else if !is_all_set(&pattern.minutes) {
let min_desc = lang.minute_phrase(&format_number_list(&min_vals, lang));
if is_all_set(&pattern.hours) && pattern.hours.step == 1 {
parts.push(lang.minute_past_every_hour_phrase(&min_desc));
} else {
parts.push(min_desc);
}
}
if !is_all_set(&pattern.hours) {
if is_stepped_from_start(pattern.hours.step, &hour_vals, pattern.hours.min) {
parts.push(lang.every_x_hours(pattern.hours.step));
} else {
parts.push(lang.hour_phrase(&format_number_list(&hour_vals, lang)));
}
}
if parts.is_empty() {
return lang.every_minute().to_string();
}
if parts.len() > 1 && parts[0] == lang.every_second_phrase() {
return parts.join(", ");
}
lang.at_phrase(&parts.join(", "))
}
fn get_set_values(component: &CronComponent, bit: u8) -> Vec<u16> {
(component.min..=component.max)
.filter(|i| component.is_bit_set(*i, bit).unwrap_or(false)) // Corrected
.collect()
}
fn format_text_list<L: Language>(items: Vec<String>, lang: &L) -> String {
match items.len() {
0 => String::new(),
1 => items[0].clone(),
2 => format!("{} {} {}", items[0], lang.list_conjunction_and(), items[1]),
_ => {
if let Some(last) = items.last() {
let front = &items[..items.len() - 1];
format!("{}, {} {}", front.join(", "), lang.list_conjunction_and(), last)
} else {
String::new()
}
}
}
}
fn format_number_list<L: Language>(values: &[u16], lang: &L) -> String {
if values.is_empty() {
return String::new();
}
let mut sorted_values = values.to_vec();
sorted_values.sort_unstable();
let mut items = vec![];
let mut i = 0;
while i < sorted_values.len() {
let start = sorted_values[i];
let mut j = i;
while j + 1 < sorted_values.len() && sorted_values[j + 1] == sorted_values[j] + 1 {
j += 1;
}
if j > i + 1 { // Only create a range for 3 or more consecutive numbers
items.push(format!("{}-{}", start, sorted_values[j]));
} else {
for k in sorted_values.iter().take(j + 1).skip(i) {
items.push(k.to_string());
}
}
i = j + 1;
}
format_text_list(items, lang)
}
fn describe_day<L: Language>(pattern: &CronPattern, lang: &L) -> String {
let dom_desc = describe_dom(pattern, lang);
let dow_parts = describe_dow_parts(pattern, lang);
if pattern.star_dom && pattern.star_dow {
return "".to_string();
}
if !pattern.star_dom && pattern.star_dow {
return lang.on_phrase(&dom_desc);
}
let dow_desc = format_text_list(dow_parts.clone(), lang);
if pattern.star_dom && !pattern.star_dow {
return lang.on_phrase(&dow_desc);
}
if pattern.dom_and_dow {
let final_phrase = if dow_parts.len() > 1 {
lang.dom_and_dow_if_also_one_of(&dow_desc)
} else {
lang.dom_and_dow_if_also(&dow_desc)
};
format!("{} {}", lang.on_phrase(&dom_desc), final_phrase)
} else {
format!("{} {} {}", lang.on_phrase(&dom_desc), lang.list_conjunction_or(), dow_desc)
}
}
fn describe_dom<L: Language>(pattern: &CronPattern, lang: &L) -> String {
let mut parts = vec![];
let regular_days = get_set_values(&pattern.days, ALL_BIT);
if !regular_days.is_empty() {
parts.push(lang.day_phrase(&format_number_list(&regular_days, lang)));
}
if pattern.days.is_feature_enabled(LAST_BIT) {
parts.push(lang.the_last_day_of_the_month().to_string());
}
let weekday_values = get_set_values(&pattern.days, CLOSEST_WEEKDAY_BIT);
if !weekday_values.is_empty() {
parts.push(lang.the_weekday_nearest_day(&format_number_list(&weekday_values, lang)));
}
format_text_list(parts, lang)
}
fn describe_dow_parts<L: Language>(pattern: &CronPattern, lang: &L) -> Vec<String> {
let mut parts = vec![];
let dow_names_map = lang.day_of_week_names();
// The `with_alternative_weekdays` flag is gone. Parser normalizes DOW.
// This mapping handles the 0-7 range where 7 is Sunday.
let dow_names = &[
dow_names_map[0], dow_names_map[1], dow_names_map[2], dow_names_map[3],
dow_names_map[4], dow_names_map[5], dow_names_map[6], dow_names_map[0],
];
let last_values = get_set_values(&pattern.days_of_week, LAST_BIT);
if !last_values.is_empty() {
let days = last_values.iter().map(|v| dow_names[*v as usize].to_string()).collect::<Vec<_>>(); // Corrected
parts.push(lang.the_last_weekday_of_the_month(&format_text_list(days, lang)));
}
for (i, nth_bit) in [NTH_1ST_BIT, NTH_2ND_BIT, NTH_3RD_BIT, NTH_4TH_BIT, NTH_5TH_BIT].iter().enumerate() {
let values = get_set_values(&pattern.days_of_week, *nth_bit);
if !values.is_empty() {
let days = values.iter().map(|v| dow_names[*v as usize].to_string()).collect::<Vec<_>>(); // Corrected
parts.push(lang.the_nth_weekday_of_the_month((i + 1) as u8, &format_text_list(days, lang)));
}
}
let regular_values = get_set_values(&pattern.days_of_week, ALL_BIT);
if !regular_values.is_empty() {
let list = regular_values.iter().map(|v| dow_names[*v as usize].to_string()).collect::<Vec<_>>(); // Corrected
parts.push(format_text_list(list, lang));
}
parts
}
fn describe_month<L: Language>(pattern: &CronPattern, lang: &L) -> String {
if is_all_set(&pattern.months) {
return "".to_string();
}
let month_names = lang.month_names();
if pattern.months.step > 1 {
return lang.in_phrase(&format!("every {} months", pattern.months.step));
}
let values = get_set_values(&pattern.months, ALL_BIT);
let list = values
.iter()
.map(|v| month_names[*v as usize - 1].to_string()) // Corrected
.collect::<Vec<_>>();
lang.in_phrase(&format_text_list(list, lang))
}
fn describe_year<L: Language>(pattern: &CronPattern, lang: &L) -> String {
if is_all_set(&pattern.years) {
return "".to_string();
}
if pattern.years.step > 1 {
return lang.in_phrase(&lang.year_phrase(&format!("every {}", pattern.years.step)));
}
let values = get_set_values(&pattern.years, ALL_BIT);
lang.in_phrase(&lang.year_phrase(&format_number_list(&values, lang)))
}
#[cfg(test)]
mod tests {
use super::lang::english::English;
use crate::parser::{CronParser, Seconds, Year};
use super::Language;
// Updated helper to use the new parser API
fn get_description_lang_config<L: Language + Default>(
pattern_str: &str,
lang: L,
seconds: Seconds,
year: Year,
dom_and_dow: bool,
) -> String {
let cron = CronParser::builder()
.seconds(seconds)
.year(year)
.dom_and_dow(dom_and_dow)
.build()
.parse(pattern_str)
.expect("Failed to parse pattern for test");
// The user wants to test the describe function in this module
super::describe(&cron.pattern, &lang)
}
// Simplified helper for common cases.
// It uses a permissive parser config that can handle any pattern
// since the parser normalizes the pattern before describe is called.
fn get_description(pattern_str: &str) -> String {
get_description_lang_config(
pattern_str,
English,
Seconds::Optional, // Be permissive
Year::Optional, // Be permissive
false,
)
}
#[test]
fn test_time_descriptions() {
assert_eq!(get_description("* * * * *"), "Every minute.");
assert_eq!(get_description("*/15 * * * *"), "At every 15 minutes.");
assert_eq!(get_description("0 * * * *"), "At minute 0 past every hour.");
assert_eq!(get_description("0 14 * * *"), "At 14:00.");
assert_eq!(
get_description("2,4,6 * * * *"),
"At minute 2, 4, and 6 past every hour."
);
assert_eq!(
get_description("0 0-6 * * *"),
"At minute 0, of hour 0-6."
);
assert_eq!(
get_description("0 */2 * * *"),
"At minute 0, of every 2 hours."
);
// Test for issue #35: "* 0 * * *" should describe properly
assert_eq!(
get_description("* 0 * * *"),
"Every minute past hour 0."
);
assert_eq!(
get_description("* 0,12 * * *"),
"Every minute past hour 0 and 12."
);
}
#[test]
fn test_seconds_descriptions() {
assert_eq!(get_description("*/10 * * * * *"), "At every 10 seconds.");
assert_eq!(get_description("30 0 14 * * *"), "At 14:00:30.");
assert_eq!(
get_description("10-20 0 14 * * *"),
"At 14:00, at second 10-20."
);
// Test for similar issue as #35 with seconds
assert_eq!(
get_description("* * 0 * * *"),
"Every second past hour 0."
);
assert_eq!(
get_description("* * 5 * * *"),
"Every second past hour 5."
);
}
#[test]
fn test_year_descriptions() {
assert_eq!(
get_description("0 0 0 1 1 * 2025"),
"At 00:00, on day 1, in January, in year 2025."
);
assert_eq!(
get_description("0 0 0 1 1 * 2025-2030"),
"At 00:00, on day 1, in January, in year 2025-2030."
);
}
#[test]
fn test_day_descriptions() {
assert_eq!(get_description("0 12 * * MON"), "At 12:00, on Monday.");
assert_eq!(
get_description("0 12 * * 1-5"),
"At 12:00, on Monday, Tuesday, Wednesday, Thursday, and Friday."
);
assert_eq!(get_description("0 12 15 * *"), "At 12:00, on day 15.");
assert_eq!(
get_description("0 12 L * *"),
"At 12:00, on the last day of the month."
);
assert_eq!(
get_description("0 12 1,15 * *"),
"At 12:00, on day 1 and 15."
);
}
#[test]
fn test_month_descriptions() {
assert_eq!(get_description("* * * JAN *"), "Every minute, in January.");
assert_eq!(
get_description("* * * 1,3,5 *"),
"Every minute, in January, March, and May."
);
}
#[test]
fn test_special_char_descriptions() {
assert_eq!(
get_description("* * * * 5L"),
"Every minute, on the last Friday of the month."
);
assert_eq!(
get_description("* * * * TUE#3"),
"Every minute, on the 3rd Tuesday of the month."
);
assert_eq!(
get_description("* * 15W * *"),
"Every minute, on the weekday nearest day 15."
);
}
#[test]
fn test_dom_and_dow_logic() {
// Default behavior (OR)
let or_desc = get_description("0 0 15 * FRI");
assert_eq!(or_desc, "At 00:00, on day 15 or Friday.");
// AND behavior
let and_desc =
get_description_lang_config("0 0 15 * FRI", English, Seconds::Optional, Year::Optional, true);
assert_eq!(
and_desc,
"At 00:00, on day 15 (if it is also Friday)."
);
}
#[test]
fn test_complex_combinations() {
assert_eq!(
get_description("30 18 15,L MAR *"),
"At 18:30, on day 15 and the last day of the month, in March."
);
let and_desc = get_description_lang_config("30 18 15,L MAR FRI", English, Seconds::Optional, Year::Optional, true);
assert_eq!(
and_desc,
"At 18:30, on day 15 and the last day of the month (if it is also Friday), in March."
);
}
#[test]
fn test_second_and_minute_steps() {
assert_eq!(
get_description("* */2 * * * *"),
"Every second, every 2 minutes."
)
}
#[test]
fn test_ranged_steps() {
assert_eq!(
get_description("18-28/2 * * * * *"),
"At second 18, 20, 22, 24, 26, and 28."
);
}
#[test]
fn test_complex_dom_and_dow() {
let desc = get_description_lang_config("0 0 1 * FRI#L,MON#1", English, Seconds::Optional, Year::Optional, true);
assert_eq!(
desc,
"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)."
);
}
// Issue #35: Incorrect descriptor
// https://github.com/Hexagon/croner-rust/issues/35
// Pattern "* 0 * * *" was producing "At of hour 0." instead of "Every minute past hour 0."
#[test]
fn test_issue_35_wildcard_minutes_specific_hours() {
// Original bug: "* 0 * * *" produced "At of hour 0."
assert_eq!(
get_description("* 0 * * *"),
"Every minute past hour 0."
);
assert_eq!(
get_description("* 5 * * *"),
"Every minute past hour 5."
);
assert_eq!(
get_description("* 0-5 * * *"),
"Every minute past hour 0-5."
);
}
#[test]
fn test_issue_35_seconds_variant() {
// Similar issue with seconds: "* * 0 * * *" was producing "Every second, of hour 0."
assert_eq!(
get_description("* * 0 * * *"),
"Every second past hour 0."
);
assert_eq!(
get_description("* * 5 * * *"),
"Every second past hour 5."
);
assert_eq!(
get_description("* * 0,12 * * *"),
"Every second past hour 0 and 12."
);
}
#[test]
fn test_issue_35_with_other_fields() {
// Test combinations with days, months, weekdays
assert_eq!(
get_description("* 0 * 1 *"),
"Every minute past hour 0, in January."
);
assert_eq!(
get_description("* 0 * * MON"),
"Every minute past hour 0, on Monday."
);
}
#[test]
fn test_no_grammatical_errors() {
// Ensure no grammatical errors like "At of", "At ,", etc.
let patterns = vec![
"* 0 * * *",
"* * 0 * * *",
"0 * 0 * * *",
"* 0 * 1 *",
"* 0 * * MON",
];
for pattern in patterns {
let desc = get_description(pattern);
assert!(
!desc.contains("At of") && !desc.contains("At ,") && !desc.starts_with("At ."),
"Pattern '{}' produced grammatically incorrect description: '{}'",
pattern,
desc
);
}
}
}
+62
View File
@@ -0,0 +1,62 @@
/// Represents errors that can occur while parsing and evaluating cron patterns.
///
/// `CronError` is used throughout the `croner` crate to indicate various types of failures
/// and is exported for consuming programs to use.
#[derive(Debug)]
pub enum CronError {
/// The pattern string provided was empty.
///
/// This error occurs if a cron pattern is set to an empty string, which is not a valid cron expression.
EmptyPattern,
/// Encountered an invalid date while parsing or evaluating a cron pattern.
///
/// This might happen if a cron pattern results in a date that doesn't exist (e.g., February 30th).
InvalidDate,
/// Encountered an invalid time while parsing or evaluating a cron pattern.
///
/// This can occur if a time component in the cron pattern is outside its valid range.
InvalidTime,
/// The search for the next valid time exceeded a reasonable limit.
///
/// This is typically encountered with complex patterns that don't match any real-world times.
TimeSearchLimitExceeded,
/// The cron pattern provided is invalid.
///
/// This error includes a message detailing the nature of the invalid pattern,
/// such as "Pattern must consist of six fields, seconds can not be omitted."
InvalidPattern(String),
/// The pattern contains characters that are not allowed.
///
/// This error includes a message indicating the illegal characters encountered in the pattern,
/// such as "CronPattern contains illegal characters."
IllegalCharacters(String),
/// A component of the pattern is invalid.
///
/// This variant is used for various errors that specifically arise from individual components of a cron pattern,
/// such as "Position x is out of bounds for the current range (y-z).".
ComponentError(String),
}
impl std::fmt::Display for CronError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
CronError::TimeSearchLimitExceeded => {
write!(f, "CronScheduler time search limit exceeded.")
}
CronError::EmptyPattern => write!(f, "CronPattern cannot be an empty string."),
CronError::InvalidDate => write!(f, "CronScheduler encountered an invalid date."),
CronError::InvalidTime => write!(f, "CronScheduler encountered an invalid time."),
CronError::InvalidPattern(msg) => write!(f, "Invalid pattern: {msg}"),
CronError::IllegalCharacters(msg) => {
write!(f, "Pattern contains illegal characters: {msg}")
}
CronError::ComponentError(msg) => write!(f, "Component error: {msg}"),
}
}
}
impl std::error::Error for CronError {}
+118
View File
@@ -0,0 +1,118 @@
use crate::{Cron, CronError, Direction};
use chrono::{DateTime, TimeZone, Duration};
#[derive(Debug, Clone, PartialEq, PartialOrd, Hash)]
pub struct CronIterator<Tz>
where
Tz: TimeZone,
{
cron: Cron,
current_time: DateTime<Tz>,
is_first: bool,
inclusive: bool,
direction: Direction,
pending_ambiguous_dt: Option<DateTime<Tz>>,
}
impl<Tz> CronIterator<Tz>
where
Tz: TimeZone,
{
/// Creates a new `CronIterator`.
///
/// # Arguments
///
/// * `cron` - The `Cron` schedule instance.
/// * `start_time` - The `DateTime` to start iterating from.
/// * `inclusive` - Whether the `start_time` should be included in the results if it matches.
/// * `direction` - The direction to iterate in (Forward or Backward).
pub fn new(
cron: Cron,
start_time: DateTime<Tz>,
inclusive: bool,
direction: Direction,
) -> Self {
CronIterator {
cron,
current_time: start_time,
is_first: true,
inclusive,
direction,
pending_ambiguous_dt: None,
}
}
}
impl<Tz> Iterator for CronIterator<Tz>
where
Tz: TimeZone + Clone + Copy,
{
type Item = DateTime<Tz>;
fn next(&mut self) -> Option<Self::Item> {
// Step 1: Check for and yield a pending ambiguous datetime first.
// This handles the second occurrence of a time during DST fallback.
if let Some(pending_dt_to_yield) = self.pending_ambiguous_dt.take() {
// After yielding the second ambiguous time, advance current_time past it.
// Clone pending_dt_to_yield because it's about to be returned,
// but we need its value to calculate the next `self.current_time`.
self.current_time = pending_dt_to_yield.clone().checked_add_signed(match self.direction { // Fixed E0382: pending_dt_to_yield
Direction::Forward => Duration::seconds(1),
Direction::Backward => Duration::seconds(-1),
}).ok_or(CronError::InvalidTime).ok()?;
return Some(pending_dt_to_yield);
}
// Determine if the search should be inclusive based on whether it's the first run.
let inclusive_search = if self.is_first {
self.is_first = false;
self.inclusive
} else {
false // Subsequent searches are always exclusive of the last actual point in time.
};
let result = self.cron.find_occurrence(&self.current_time, inclusive_search, self.direction);
match result {
Ok((found_time, optional_second_ambiguous_dt)) => {
// This `found_time` is the one we will return in this iteration.
// If there's a second ambiguous datetime (for interval jobs),
// store it to be yielded on the *next* call to next().
// And importantly, set `self.current_time` to advance *past* this second ambiguous time
// so the *next* search for a *new* naive time is correct.
if let Some(second_ambiguous_dt) = optional_second_ambiguous_dt {
// Clone second_ambiguous_dt because it's stored in self.pending_ambiguous_dt
// AND used to calculate the next self.current_time.
self.pending_ambiguous_dt = Some(second_ambiguous_dt.clone()); // Fixed E0382: second_ambiguous_dt
// Advance `self.current_time` past the latest of the ambiguous pair.
// This ensures the next `find_occurrence` call searches for the next unique naive time.
self.current_time = second_ambiguous_dt.checked_add_signed(match self.direction {
Direction::Forward => Duration::seconds(1),
Direction::Backward => Duration::seconds(-1),
}).ok_or(CronError::InvalidTime).ok()?;
} else {
// Case: No second ambiguous time (either not an overlap, or fixed-time job).
// Advance `self.current_time` simply past the `found_time`.
// Clone found_time because it's used to calculate the next self.current_time
// AND returned at the end of this block.
self.current_time = found_time.clone().checked_add_signed(match self.direction { // Fixed E0382: found_time
Direction::Forward => Duration::seconds(1),
Direction::Backward => Duration::seconds(-1),
}).ok_or(CronError::InvalidTime).ok()?;
}
// Finally, return the found_time for the current iteration.
// This `found_time` is the original value received from `find_occurrence`.
Some(found_time)
}
Err(CronError::TimeSearchLimitExceeded) => None,
Err(e) => {
eprintln!("CronIterator encountered an error: {e:?}");
None
}
}
}
}
File diff suppressed because it is too large Load Diff
+748
View File
@@ -0,0 +1,748 @@
//! Parser for Cron patterns.
//!
//! Croner uses [`CronParser`] to parse the cron expression. Invoking
//!
//! ```rust
//! # use std::str::FromStr as _;
//! #
//! # use croner::{Cron, parser::CronParser};
//! #
//! Cron::from_str("pattern");
//! ```
//!
//! is equivalent to
//!
//! ```rust
//! # use std::str::FromStr as _;
//! #
//! # use croner::{Cron, parser::CronParser};
//! #
//! CronParser::new().parse("pattern");
//! ```
//!
//! You can customise the parser by creating a parser builder using
//! [`CronParser::builder`]. So, for example, to parse cron patterns with
//! optional seconds do something like this:
//!
//! ```rust
//! use croner::parser::{CronParser, Seconds};
//!
//! // Configure the parser to allow seconds.
//! let parser = CronParser::builder().seconds(Seconds::Optional).build();
//!
//! let cron_with_seconds = parser
//! .parse("*/10 * * * * *")
//! .unwrap();
//! let cron_without_seconds = parser
//! .parse("* * * * *")
//! .unwrap();
//! ```
use derive_builder::Builder;
use strum::EnumIs;
use crate::{
component::{
CronComponent, ALL_BIT, CLOSEST_WEEKDAY_BIT, LAST_BIT, NONE_BIT, NTH_1ST_BIT, NTH_2ND_BIT,
NTH_3RD_BIT, NTH_4TH_BIT, NTH_5TH_BIT, NTH_ALL,
},
errors::CronError,
pattern::CronPattern,
Cron, YEAR_LOWER_LIMIT, YEAR_UPPER_LIMIT,
};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, EnumIs)]
pub enum Seconds {
#[default]
Optional,
Required,
Disallowed,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, EnumIs)]
pub enum Year {
#[default]
Optional,
Required,
Disallowed,
}
/// Parser for Cron patterns.
///
/// In order to build a custom cron parser use [`CronParser::builder`].
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Builder)]
#[builder(default, build_fn(skip), pattern = "owned")]
pub struct CronParser {
/// Configure how seconds should be handled.
seconds: Seconds,
/// Configure how years should be handled.
year: Year,
/// Enable the combination of Day of Month (DOM) and Day of Week (DOW) conditions.
dom_and_dow: bool,
/// Use the Quartz-style weekday mode.
alternative_weekdays: bool,
}
impl CronParser {
/// Create a new parser.
///
/// You should probably be using [`Cron`]'s implementation of
/// [`FromStr`][std::str::FromStr] instead of invoking this.
pub fn new() -> Self {
Self::default()
}
/// Construct a builder for custom parsing.
///
/// Equivalent to [`CronParserBuilder::default`].
pub fn builder() -> CronParserBuilder {
CronParserBuilder::default()
}
/// Parses the cron pattern string.
pub fn parse(&self, pattern: &str) -> Result<Cron, CronError> {
// Ensure upper case in parsing, and trim it
let mut pattern: String = pattern.to_uppercase().trim().to_string();
// Should already be trimmed
if pattern.is_empty() {
return Err(CronError::EmptyPattern);
}
// Handle @nicknames
if pattern.contains('@') {
pattern = Self::handle_nicknames(&pattern, self.seconds.is_required(), self.year.is_required()).to_string();
}
// Handle day-of-week and month aliases (MON... and JAN...)
pattern = Self::replace_alpha_weekdays(&pattern, self.alternative_weekdays).to_string();
pattern = Self::replace_alpha_months(&pattern).to_string();
// Split the pattern into parts
let mut parts: Vec<&str> = pattern.split_whitespace().collect();
let num_parts = parts.len();
// Default seconds to "0" if omitted in an optional context
if num_parts == 5 {
parts.insert(0, "0");
} else if self.seconds.is_disallowed() {
return Err(CronError::InvalidPattern("Pattern must have 5 fields when seconds are disallowed.".to_string()));
}
// Default year to "*" if omitted in an optional context
if parts.len() == 6 {
parts.push("*");
} else if self.year.is_disallowed() {
return Err(CronError::InvalidPattern("Pattern must have 5 or 6 fields when years are disallowed.".to_string()));
}
// Validate pattern length based on configuration
if self.seconds.is_required() {
if self.year.is_required() && num_parts != 7 {
return Err(CronError::InvalidPattern("Pattern must have 7 fields when seconds and years are required.".to_string()));
}
if self.year.is_disallowed() && num_parts != 6 {
return Err(CronError::InvalidPattern("Pattern must have 6 fields when seconds are required and years are disallowed.".to_string()));
}
if self.year.is_optional() && !(6..=7).contains(&num_parts) {
return Err(CronError::InvalidPattern("Pattern must have 6 or 7 fields when seconds are required and years are optional.".to_string()));
}
} else if self.year.is_required() && num_parts != 7 {
return Err(CronError::InvalidPattern("Pattern must have 7 fields when years are required.".to_string()));
} else if !(5..=7).contains(&num_parts) {
return Err(CronError::InvalidPattern("Pattern must have between 5 and 7 fields.".to_string()));
}
// Replace ? with * in day-of-month and day-of-week
let mut owned_parts = parts.iter().map(|s| s.to_string()).collect::<Vec<String>>();
if owned_parts.get(3).is_some_and(|p| p.contains('?')) {
owned_parts[3] = owned_parts[3].replace('?', "*");
}
if owned_parts.get(5).is_some_and(|p| p.contains('?')) {
owned_parts[5] = owned_parts[5].replace('?', "*");
}
// Check for the '+' (AND) modifier in the day-of-week field.
// This must be done before illegal character validation.
let mut dom_and_dow_from_pattern = false;
if let Some(dow_part) = owned_parts.get_mut(5) {
if dow_part.starts_with('+') {
dom_and_dow_from_pattern = true;
// Remove the '+' so the rest of the field can be parsed normally.
*dow_part = dow_part[1..].to_string();
}
}
parts = owned_parts.iter().map(|s| s.as_str()).collect();
// Throw at illegal characters
self.throw_at_illegal_characters(&parts)?;
// Handle star-dom and star-dow
let star_dom = parts.get(3).is_some_and(|&p| p == "*");
let star_dow = parts.get(5).is_some_and(|&p| p == "*");
// Parse the individual components
let mut seconds = CronComponent::new(0, 59, NONE_BIT, 0);
seconds.parse(parts[0])?;
let mut minutes = CronComponent::new(0, 59, NONE_BIT, 0);
minutes.parse(parts[1])?;
let mut hours = CronComponent::new(0, 23, NONE_BIT, 0);
hours.parse(parts[2])?;
let mut days = CronComponent::new(1, 31, LAST_BIT | CLOSEST_WEEKDAY_BIT, 0);
days.parse(parts[3])?;
let mut months = CronComponent::new(1, 12, NONE_BIT, 0);
months.parse(parts[4])?;
let mut days_of_week = if self.alternative_weekdays {
CronComponent::new(0, 7, LAST_BIT | NTH_ALL, 1)
} else {
CronComponent::new(0, 7, LAST_BIT | NTH_ALL, 0)
};
days_of_week.parse(parts[5])?;
let mut years = CronComponent::new(YEAR_LOWER_LIMIT as u16, YEAR_UPPER_LIMIT as u16, NONE_BIT, 0); // Placeholder, real limits are i32
years.parse(parts[6])?;
// Handle conversion of 7 to 0 for day_of_week if necessary
if !self.alternative_weekdays {
for nth_bit in [
ALL_BIT,
NTH_1ST_BIT,
NTH_2ND_BIT,
NTH_3RD_BIT,
NTH_4TH_BIT,
NTH_5TH_BIT,
] {
if days_of_week.is_bit_set(7, nth_bit)? {
days_of_week.unset_bit(7, nth_bit)?;
days_of_week.set_bit(0, nth_bit)?;
}
}
}
Ok(Cron {
pattern: CronPattern {
pattern,
seconds,
minutes,
hours,
days,
months,
days_of_week,
years,
star_dom,
star_dow,
dom_and_dow: self.dom_and_dow || dom_and_dow_from_pattern,
},
})
}
// Validates that the cron pattern only contains legal characters for each field.
fn throw_at_illegal_characters(&self, parts: &[&str]) -> Result<(), CronError> {
let base_allowed_characters = [
'*', '/', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ',', '-',
];
let day_of_week_additional_characters = ['#', 'L', '?'];
let day_of_month_additional_characters = ['L', 'W', '?'];
for (i, part) in parts.iter().enumerate() {
// Decide which set of allowed characters to use
let allowed = match i {
3 => [base_allowed_characters.as_ref(), day_of_month_additional_characters.as_ref()].concat(),
5 => [base_allowed_characters.as_ref(), day_of_week_additional_characters.as_ref()].concat(),
// All other fields, including year (index 6) use base characters
_ => base_allowed_characters.to_vec(),
};
for ch in part.chars() {
if !allowed.contains(&ch) {
return Err(CronError::IllegalCharacters(format!(
"CronPattern contains illegal character '{ch}' in part '{part}'"
)));
}
}
}
Ok(())
}
// Converts named cron pattern shortcuts into their equivalent standard cron pattern.
fn handle_nicknames(pattern: &str, with_seconds: bool, with_year: bool) -> String {
let pattern = pattern.trim();
let eq_ignore_case = |a: &str, b: &str| a.eq_ignore_ascii_case(b);
let base_pattern = match pattern {
p if eq_ignore_case(p, "@yearly") || eq_ignore_case(p, "@annually") => "0 0 1 1 *",
p if eq_ignore_case(p, "@monthly") => "0 0 1 * *",
p if eq_ignore_case(p, "@weekly") => "0 0 * * 0",
p if eq_ignore_case(p, "@daily") => "0 0 * * *",
p if eq_ignore_case(p, "@hourly") => "0 * * * *",
_ => pattern,
};
let mut final_pattern = String::new();
if with_seconds {
final_pattern.push_str("0 ");
}
final_pattern.push_str(base_pattern);
if with_year {
final_pattern.push_str(" *");
}
final_pattern
}
// Converts day-of-week nicknames into their equivalent standard cron pattern.
fn replace_alpha_weekdays(pattern: &str, alternative_weekdays: bool) -> String {
let nicknames = if !alternative_weekdays {
[
("-SUN", "-7"),
("SUN", "0"),
("MON", "1"),
("TUE", "2"),
("WED", "3"),
("THU", "4"),
("FRI", "5"),
("SAT", "6"),
]
} else {
[
("-SUN", "-1"),
("SUN", "1"),
("MON", "2"),
("TUE", "3"),
("WED", "4"),
("THU", "5"),
("FRI", "6"),
("SAT", "7"),
]
};
let mut replaced = pattern.to_string();
// Replace nicknames with their numeric values
for &(nickname, value) in &nicknames {
replaced = replaced.replace(nickname, value);
}
replaced
}
// Converts month nicknames into their equivalent standard cron pattern.
fn replace_alpha_months(pattern: &str) -> String {
let nicknames = [
("JAN", "1"),
("FEB", "2"),
("MAR", "3"),
("APR", "4"),
("MAY", "5"),
("JUN", "6"),
("JUL", "7"),
("AUG", "8"),
("SEP", "9"),
("OCT", "10"),
("NOV", "11"),
("DEC", "12"),
];
let mut replaced = pattern.to_string();
// Replace nicknames with their numeric values
for &(nickname, value) in &nicknames {
replaced = replaced.replace(nickname, value);
}
replaced
}
}
impl CronParserBuilder {
pub fn build(self) -> CronParser {
let CronParserBuilder {
seconds,
year,
dom_and_dow,
alternative_weekdays,
} = self;
CronParser {
seconds: seconds.unwrap_or_default(),
year: year.unwrap_or_default(),
dom_and_dow: dom_and_dow.unwrap_or_default(),
alternative_weekdays: alternative_weekdays.unwrap_or_default(),
}
}
}
#[cfg(test)]
mod tests {
use std::str::FromStr as _;
use super::*;
#[test]
fn test_cron_pattern_new() {
let cron = Cron::from_str("*/5 * * * *").unwrap();
assert_eq!(cron.pattern.pattern, "*/5 * * * *");
assert!(cron.pattern.seconds.is_bit_set(0, ALL_BIT).unwrap());
assert!(cron.pattern.minutes.is_bit_set(5, ALL_BIT).unwrap());
}
#[test]
fn test_cron_pattern_new_with_seconds_optional() {
let cron = CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse("* */5 * * * *")
.expect("Success");
assert_eq!(cron.pattern.pattern, "* */5 * * * *");
assert!(cron.pattern.seconds.is_bit_set(5, ALL_BIT).unwrap());
}
#[test]
fn test_cron_pattern_new_with_seconds_required() {
let cron = CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse("* */5 * * * *")
.unwrap();
assert_eq!(cron.pattern.pattern, "* */5 * * * *");
assert!(cron.pattern.seconds.is_bit_set(5, ALL_BIT).unwrap());
}
#[test]
fn test_cron_pattern_tostring() {
let cron = Cron::from_str("*/5 * * * *").unwrap();
assert_eq!(cron.to_string(), "*/5 * * * *");
}
#[test]
fn test_cron_pattern_short() {
let cron = Cron::from_str("5/5 * * * *").unwrap();
assert_eq!(cron.pattern.pattern, "5/5 * * * *");
assert!(cron.pattern.seconds.is_bit_set(0, ALL_BIT).unwrap());
assert!(!cron.pattern.seconds.is_bit_set(5, ALL_BIT).unwrap());
assert!(cron.pattern.minutes.is_bit_set(5, ALL_BIT).unwrap());
assert!(!cron.pattern.minutes.is_bit_set(0, ALL_BIT).unwrap());
}
#[test]
fn test_cron_pattern_parse() {
let cron = Cron::from_str("*/15 1 1,15 1 1-5").unwrap();
assert!(cron.pattern.minutes.is_bit_set(0, ALL_BIT).unwrap());
assert!(cron.pattern.hours.is_bit_set(1, ALL_BIT).unwrap());
assert!(
cron.pattern.days.is_bit_set(1, ALL_BIT).unwrap()
&& cron.pattern.days.is_bit_set(15, ALL_BIT).unwrap()
);
assert!(
cron.pattern.months.is_bit_set(1, ALL_BIT).unwrap()
&& !cron.pattern.months.is_bit_set(2, ALL_BIT).unwrap()
);
assert!(
cron.pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap()
&& cron.pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap()
);
}
#[test]
fn test_cron_pattern_extra_whitespace() {
let cron = Cron::from_str(" */15 1 1,15 1 1-5 ").unwrap();
assert!(cron.pattern.minutes.is_bit_set(0, ALL_BIT).unwrap());
assert!(cron.pattern.hours.is_bit_set(1, ALL_BIT).unwrap());
assert!(
cron.pattern.days.is_bit_set(1, ALL_BIT).unwrap()
&& cron.pattern.days.is_bit_set(15, ALL_BIT).unwrap()
);
assert!(
cron.pattern.months.is_bit_set(1, ALL_BIT).unwrap()
&& !cron.pattern.months.is_bit_set(2, ALL_BIT).unwrap()
);
assert!(
cron.pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap()
&& cron.pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap()
);
}
#[test]
fn test_cron_pattern_leading_zeros() {
let cron = Cron::from_str(" */15 01 01,15 01 01-05 ").unwrap();
assert!(cron.pattern.minutes.is_bit_set(0, ALL_BIT).unwrap());
assert!(cron.pattern.hours.is_bit_set(1, ALL_BIT).unwrap());
assert!(
cron.pattern.days.is_bit_set(1, ALL_BIT).unwrap()
&& cron.pattern.days.is_bit_set(15, ALL_BIT).unwrap()
);
assert!(
cron.pattern.months.is_bit_set(1, ALL_BIT).unwrap()
&& !cron.pattern.months.is_bit_set(2, ALL_BIT).unwrap()
);
assert!(
cron.pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap()
&& cron.pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap()
);
}
#[test]
fn test_cron_pattern_handle_nicknames() {
assert_eq!(CronParser::handle_nicknames("@yearly", false, false), "0 0 1 1 *");
assert_eq!(CronParser::handle_nicknames("@monthly", false, false), "0 0 1 * *");
assert_eq!(CronParser::handle_nicknames("@weekly", false, false), "0 0 * * 0");
assert_eq!(CronParser::handle_nicknames("@daily", false, false), "0 0 * * *");
assert_eq!(CronParser::handle_nicknames("@hourly", false, false), "0 * * * *");
}
#[test]
fn test_cron_pattern_handle_nicknames_with_seconds_required() {
assert_eq!(CronParser::handle_nicknames("@yearly", true, false), "0 0 0 1 1 *");
assert_eq!(
CronParser::handle_nicknames("@monthly", true, false),
"0 0 0 1 * *"
);
assert_eq!(CronParser::handle_nicknames("@weekly", true, false), "0 0 0 * * 0");
assert_eq!(CronParser::handle_nicknames("@daily", true, false), "0 0 0 * * *");
assert_eq!(CronParser::handle_nicknames("@hourly", true, false), "0 0 * * * *");
}
#[test]
fn test_month_nickname_range() {
let cron = Cron::from_str("0 0 * FEB-MAR *").unwrap();
assert!(!cron.pattern.months.is_bit_set(1, ALL_BIT).unwrap());
assert!(cron.pattern.months.is_bit_set(2, ALL_BIT).unwrap()); // February
assert!(cron.pattern.months.is_bit_set(3, ALL_BIT).unwrap()); // March
assert!(!cron.pattern.months.is_bit_set(4, ALL_BIT).unwrap());
}
#[test]
fn test_weekday_range_sat_sun() {
let cron = Cron::from_str("0 0 * * SAT-SUN").unwrap();
assert!(cron.pattern.days_of_week.is_bit_set(0, ALL_BIT).unwrap()); // Sunday
assert!(cron.pattern.days_of_week.is_bit_set(6, ALL_BIT).unwrap()); // Saturday
}
#[test]
fn test_with_seconds_false() {
// Explicitly create a parser that disallows seconds
let parser = CronParser::builder()
.seconds(Seconds::Disallowed)
.build();
// Test with a 6-part pattern when seconds are not allowed
let error = parser.parse("* * * * * *").unwrap_err();
assert!(matches!(error, CronError::InvalidPattern(_)));
// Test with a 5-part pattern when seconds are not allowed
let no_seconds_pattern = parser.parse("*/10 * * * *").unwrap();
assert_eq!(no_seconds_pattern.to_string(), "*/10 * * * *");
// Ensure seconds are defaulted to 0 for a 5-part pattern
assert!(no_seconds_pattern
.pattern
.seconds
.is_bit_set(0, ALL_BIT)
.unwrap());
}
#[test]
fn test_with_seconds_required() {
// Test with a 5-part pattern when seconds are required
let no_seconds_pattern = CronParser::builder()
.seconds(Seconds::Required)
.build()
.parse("*/10 * * * *")
.unwrap_err();
assert!(matches!(no_seconds_pattern, CronError::InvalidPattern(_)));
// Test with a 6-part pattern when seconds are required
let cron = CronParser::builder()
.seconds(Seconds::Required)
.build()
.parse("* * * * * *")
.unwrap();
// Ensure the 6-part pattern retains seconds information
// (This assertion depends on how your CronPattern is structured and how it stores seconds information)
assert!(cron.pattern.seconds.is_bit_set(0, ALL_BIT).unwrap());
}
#[test]
fn test_with_alternative_weekdays() {
// Test with alternative weekdays enabled
let cron = CronParser::builder()
.alternative_weekdays(true)
.build()
.parse("* * * * MON-FRI")
.unwrap();
// Ensure that the days of the week are offset correctly
// Note: In this scenario, "MON-FRI" should be treated as "SUN-THU"
assert!(cron.pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap()); // Monday
assert!(cron.pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap()); // Friday
assert!(!cron.pattern.days_of_week.is_bit_set(6, ALL_BIT).unwrap()); // Saturday should not be set
}
#[test]
fn test_with_alternative_weekdays_numeric() {
// Test with alternative weekdays enabled
let cron = CronParser::builder()
.alternative_weekdays(true)
.build()
.parse("* * * * 2-6")
.unwrap();
// Ensure that the days of the week are offset correctly
// Note: In this scenario, "MON-FRI" should be treated as "SUN-THU"
assert!(cron.pattern.days_of_week.is_bit_set(1, ALL_BIT).unwrap()); // Monday
assert!(cron.pattern.days_of_week.is_bit_set(5, ALL_BIT).unwrap()); // Friday
assert!(!cron.pattern.days_of_week.is_bit_set(6, ALL_BIT).unwrap()); // Saturday should not be set
}
#[test]
fn test_seven_to_zero() {
// Test with alternative weekdays enabled
let cron = Cron::from_str("* * * * 7").unwrap();
// Ensure that the days of the week are offset correctly
// Note: In this scenario, "MON-FRI" should be treated as "SUN-THU"
assert!(cron.pattern.days_of_week.is_bit_set(0, ALL_BIT).unwrap()); // Monday
}
#[test]
fn test_one_is_monday_alternative() {
// Test with alternative weekdays enabled
let cron = CronParser::builder()
.alternative_weekdays(true)
.build()
.parse("* * * * 1")
.unwrap();
// Ensure that the days of the week are offset correctly
// Note: In this scenario, "MON-FRI" should be treated as "SUN-THU"
assert!(cron.pattern.days_of_week.is_bit_set(0, ALL_BIT).unwrap()); // Monday
}
#[test]
fn test_zero_with_alternative_weekdays_fails() {
// Test with alternative weekdays enabled
let error = CronParser::builder()
.alternative_weekdays(true)
.build()
.parse("* * * * 0")
.unwrap_err();
// Parsing should raise a ComponentError
assert!(matches!(error, CronError::ComponentError(_)));
}
#[test]
fn test_question_mark_allowed_in_day_of_month() {
let pattern = "* * ? * *";
assert!(
Cron::from_str(pattern).is_ok(),
"Should allow '?' in the day-of-month field."
);
}
#[test]
fn test_question_mark_allowed_in_day_of_week() {
let pattern = "* * * * ?";
assert!(
Cron::from_str(pattern).is_ok(),
"Should allow '?' in the day-of-week field."
);
}
#[test]
fn test_question_mark_disallowed_in_minute() {
let pattern = "? * * * *";
let result = Cron::from_str(pattern);
assert!(
matches!(result.err(), Some(CronError::IllegalCharacters(_))),
"Should not allow '?' in the minute field."
);
}
#[test]
fn test_question_mark_disallowed_in_hour() {
let pattern = "* ? * * *";
let result = Cron::from_str(pattern);
assert!(
matches!(result.err(), Some(CronError::IllegalCharacters(_))),
"Should not allow '?' in the hour field."
);
}
#[test]
fn test_question_mark_disallowed_in_month() {
let pattern = "* * * ? *";
let result = Cron::from_str(pattern);
assert!(
matches!(result.err(), Some(CronError::IllegalCharacters(_))),
"Should not allow '?' in the month field."
);
}
#[test]
fn test_case_sensitivity_lowercase_special_character_ok() {
let pattern = "* * 15w * *";
let result = Cron::from_str(pattern);
assert!(
result.is_ok(),
"Should allow lowercase special character w."
);
}
#[test]
fn test_case_sensitivity_uppercase_special_character_ok() {
let pattern = "* * 15W * *";
let result: Result<Cron, CronError> = Cron::from_str(pattern);
assert!(
result.is_ok(),
"Should allow uppercase special character W."
);
}
#[test]
fn test_year_support() {
let parser = CronParser::builder()
.seconds(Seconds::Optional)
.year(Year::Optional)
.build();
// 7-field pattern
assert!(parser.parse("0 0 0 1 1 * 2025").is_ok());
// 6-field pattern (year defaults to *)
assert!(parser.parse("0 0 0 1 1 *").is_ok());
// 5-field pattern (seconds defaults to 0, year to *)
assert!(parser.parse("0 0 1 1 *").is_ok());
}
#[test]
fn test_year_required() {
let parser = CronParser::builder()
.seconds(Seconds::Required)
.year(Year::Required)
.build();
// Must have 7 fields
assert!(parser.parse("0 0 0 1 1 * 2025").is_ok());
// 6 fields should fail
assert!(parser.parse("0 0 0 1 1 *").is_err());
}
#[test]
fn test_optional_seconds_and_required_year_fails_on_six_parts() {
// This parser configuration should only accept 7-part patterns.
let parser = CronParser::builder()
.seconds(Seconds::Optional)
.year(Year::Required)
.build();
// A 6-part pattern should fail because the year is missing but required.
let result = parser.parse("* * * * * *");
assert!(matches!(result, Err(CronError::InvalidPattern(_))), "Should fail when year is required but not provided.");
}
}
+547
View File
@@ -0,0 +1,547 @@
use std::cmp::Ordering;
use std::hash::Hasher;
use crate::component::{
CronComponent, ALL_BIT, CLOSEST_WEEKDAY_BIT, LAST_BIT, NONE_BIT, NTH_1ST_BIT, NTH_2ND_BIT,
NTH_3RD_BIT, NTH_4TH_BIT, NTH_5TH_BIT, NTH_ALL,
};
use crate::errors::CronError;
use crate::{Direction, TimeComponent, YEAR_LOWER_LIMIT, YEAR_UPPER_LIMIT};
use chrono::{Datelike, Duration, NaiveDate, Weekday};
// This struct is used for representing and validating cron pattern strings.
#[derive(Debug, Clone, Eq)]
pub struct CronPattern {
pub(crate) pattern: String, // The original pattern
pub seconds: CronComponent, // -
pub minutes: CronComponent, // --
pub hours: CronComponent, // --- Each individual part of the cron expression
pub days: CronComponent, // --- represented by a bitmask, min and max value
pub months: CronComponent, // ---
pub days_of_week: CronComponent, // --
pub years: CronComponent, // -
pub(crate) star_dom: bool,
pub(crate) star_dow: bool,
pub(crate) dom_and_dow: bool,
}
// Implementation block for CronPattern struct
impl CronPattern {
pub fn new(pattern: &str) -> Self {
Self {
pattern: pattern.to_string(),
seconds: CronComponent::new(0, 59, NONE_BIT, 0),
minutes: CronComponent::new(0, 59, NONE_BIT, 0),
hours: CronComponent::new(0, 23, NONE_BIT, 0),
days: CronComponent::new(1, 31, LAST_BIT | CLOSEST_WEEKDAY_BIT, 0),
months: CronComponent::new(1, 12, NONE_BIT, 0),
days_of_week: CronComponent::new(0, 7, LAST_BIT | NTH_ALL, 0),
years: CronComponent::new(YEAR_LOWER_LIMIT as u16, YEAR_UPPER_LIMIT as u16, NONE_BIT, 0), // Use u16 for year range
star_dom: false,
star_dow: false,
dom_and_dow: false,
}
}
// Checks if a given year matches the year part of the cron pattern.
pub fn year_match(&self, year: i32) -> Result<bool, CronError> {
if !(YEAR_LOWER_LIMIT..=YEAR_UPPER_LIMIT).contains(&year) {
// This case should ideally be prevented by search limits, but serves as a safeguard.
return Ok(false);
}
self.years.is_bit_set(year as u16, ALL_BIT) // Use u16 cast
}
// Determines the nth weekday of the month
fn is_nth_weekday_of_month(date: chrono::NaiveDate, nth: u8, weekday: Weekday) -> bool {
let mut count = 0;
let mut current = date.with_day(1).unwrap();
while current.month() == date.month() {
if current.weekday() == weekday {
count += 1;
if count == nth {
return current.day() == date.day();
}
}
current += chrono::Duration::days(1);
}
false
}
// Checks if a given year, month, and day match the day part of the cron pattern.
pub fn day_match(&self, year: i32, month: u32, day: u32) -> Result<bool, CronError> {
if day == 0 || day > 31 || month == 0 || month > 12 {
return Err(CronError::InvalidDate);
}
let date = NaiveDate::from_ymd_opt(year, month, day).ok_or(CronError::InvalidDate)?;
let mut day_matches = self.days.is_bit_set(day as u16, ALL_BIT)?; // Use u16
let mut dow_matches = false;
if !day_matches
&& self.days.is_feature_enabled(LAST_BIT)
&& day == Self::last_day_of_month(year, month)?
{
day_matches = true;
}
if !day_matches && self.closest_weekday(year, month, day)? {
day_matches = true;
}
for nth in 1..=5 {
let nth_bit = match nth {
1 => NTH_1ST_BIT,
2 => NTH_2ND_BIT,
3 => NTH_3RD_BIT,
4 => NTH_4TH_BIT,
5 => NTH_5TH_BIT,
_ => continue,
};
if self
.days_of_week
.is_bit_set(date.weekday().num_days_from_sunday() as u16, nth_bit)? // Use u16
&& Self::is_nth_weekday_of_month(date, nth, date.weekday())
{
dow_matches = true;
break;
}
}
if !dow_matches
&& self
.days_of_week
.is_bit_set(date.weekday().num_days_from_sunday() as u16, LAST_BIT)? // Use u16
&& (date + chrono::Duration::days(7)).month() != date.month()
{
dow_matches = true;
}
dow_matches = dow_matches
|| self
.days_of_week
.is_bit_set(date.weekday().num_days_from_sunday() as u16, ALL_BIT)?; // Use u16
if (day_matches && self.star_dow) || (dow_matches && self.star_dom) {
Ok(true)
} else if !self.star_dom && !self.star_dow {
if !self.dom_and_dow {
Ok(day_matches || dow_matches)
} else {
Ok(day_matches && dow_matches)
}
} else {
Ok(false)
}
}
// Helper function to find the last day of a given month
fn last_day_of_month(year: i32, month: u32) -> Result<u32, CronError> {
if !(1..=12).contains(&month) {
return Err(CronError::InvalidDate);
}
let (y, m) = if month == 12 {
(year + 1, 1)
} else {
(year, month + 1)
};
Ok(NaiveDate::from_ymd_opt(y, m, 1)
.unwrap()
.pred_opt()
.unwrap()
.day())
}
pub fn closest_weekday(&self, year: i32, month: u32, day: u32) -> Result<bool, CronError> {
// Iterate through all possible days to see if any have the 'W' flag.
for pattern_day_u16 in 1..=31 {
if self.days.is_bit_set(pattern_day_u16, CLOSEST_WEEKDAY_BIT)? {
// A 'W' day exists in the pattern. Check if it resolves to the function's date argument.
let pattern_day = pattern_day_u16 as u32;
// Ensure the 'W' day is a valid calendar date for the given month/year.
if let Some(pattern_date) = NaiveDate::from_ymd_opt(year, month, pattern_day) {
let weekday = pattern_date.weekday();
// Determine the actual trigger date based on the 'W' rule.
let target_date = match weekday {
// If the pattern day is a weekday, it triggers on that day.
Weekday::Mon
| Weekday::Tue
| Weekday::Wed
| Weekday::Thu
| Weekday::Fri => pattern_date,
// If it's a Saturday, find the nearest weekday within the month.
Weekday::Sat => {
// The nearest weekday is Friday, but check if it's in the same month.
let adjusted_date = pattern_date - Duration::days(1);
if adjusted_date.month() == month {
adjusted_date // It's Friday of the same month.
} else {
// Crossed boundary (e.g., 1st was Sat), so move forward to Monday.
pattern_date + Duration::days(2)
}
}
// If it's a Sunday, find the nearest weekday within the month.
Weekday::Sun => {
// The nearest weekday is Monday, but check if it's in the same month.
let adjusted_date = pattern_date + Duration::days(1);
if adjusted_date.month() == month {
adjusted_date // It's Monday of the same month.
} else {
// Crossed boundary (e.g., 31st was Sun), so move back to Friday.
pattern_date - Duration::days(2)
}
}
};
// Check if the calculated target day is the day we're currently testing.
if target_date.day() == day && target_date.month() == month {
return Ok(true);
}
}
}
}
// No 'W' pattern matched the current day.
Ok(false)
}
pub fn month_match(&self, month: u32) -> Result<bool, CronError> {
if !(1..=12).contains(&month) {
return Err(CronError::InvalidDate);
}
self.months.is_bit_set(month as u16, ALL_BIT)
}
pub fn hour_match(&self, hour: u32) -> Result<bool, CronError> {
if hour > 23 {
return Err(CronError::InvalidTime);
}
self.hours.is_bit_set(hour as u16, ALL_BIT)
}
pub fn minute_match(&self, minute: u32) -> Result<bool, CronError> {
if minute > 59 {
return Err(CronError::InvalidTime);
}
self.minutes.is_bit_set(minute as u16, ALL_BIT)
}
pub fn second_match(&self, second: u32) -> Result<bool, CronError> {
if second > 59 {
return Err(CronError::InvalidTime);
}
self.seconds.is_bit_set(second as u16, ALL_BIT)
}
/// Finds the next or previous matching value for a given time component based on direction.
pub fn find_match_in_component(
&self,
value: u32,
component_type: TimeComponent,
direction: Direction,
) -> Result<Option<u32>, CronError> {
let component = match component_type {
TimeComponent::Second => &self.seconds,
TimeComponent::Minute => &self.minutes,
TimeComponent::Hour => &self.hours,
_ => {
return Err(CronError::ComponentError(
"Invalid component type for match search".to_string(),
))
}
};
let value_u16 = value as u16;
if value_u16 > component.max {
return Err(CronError::ComponentError(format!(
"Input value {} is out of bounds for the component (max: {}).",
value, component.max
)));
}
match direction {
Direction::Forward => {
for next_value in value_u16..=component.max {
if component.is_bit_set(next_value, ALL_BIT)? {
return Ok(Some(next_value as u32));
}
}
}
Direction::Backward => {
for prev_value in (component.min..=value_u16).rev() {
if component.is_bit_set(prev_value, ALL_BIT)? {
return Ok(Some(prev_value as u32));
}
}
}
}
Ok(None)
}
/// Returns a human-readable description of the cron pattern.
///
/// This method provides a best-effort English description of the cron schedule.
/// Note: The pattern must be parsed successfully before calling this method.
/// Returns a human-readable description of the cron pattern in English.
pub fn describe(&self) -> String {
self.describe_lang(crate::describe::English)
}
/// Returns a human-readable description using a provided language provider.
///
/// # Arguments
///
/// * `lang` - An object that implements the `Language` trait.
pub fn describe_lang<L: crate::describe::Language>(&self, lang: L) -> String {
crate::describe::describe(self, &lang)
}
// Get a reference to the original pattern
pub fn as_str(&self) -> &str {
&self.pattern
}
}
impl std::fmt::Display for CronPattern {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.pattern)
}
}
impl PartialEq for CronPattern {
/// Checks for functional equality between two CronPattern instances.
///
/// Two patterns are considered equal if they have been parsed and their
/// resulting schedule components and behavioral options are identical.
/// The original pattern string is ignored in this comparison.
///
/// Returns `false` if either pattern has not been parsed.
fn eq(&self, other: &Self) -> bool {
// Compare all components and boolean flags that define the schedule.
self.seconds == other.seconds
&& self.minutes == other.minutes
&& self.hours == other.hours
&& self.days == other.days
&& self.months == other.months
&& self.days_of_week == other.days_of_week
&& self.years == other.years
&& self.star_dom == other.star_dom
&& self.star_dow == other.star_dow
&& self.dom_and_dow == other.dom_and_dow
}
}
// To implement Ord, we must first implement PartialOrd.
// For types where comparison never fails, this is the standard way to do it.
impl PartialOrd for CronPattern {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
// The primary implementation for Ord.
impl Ord for CronPattern {
/// Implements the total ordering for `CronPattern`.
///
/// This allows for consistent, deterministic sorting of cron patterns based on
/// their functional schedule, not their string representation. The comparison
/// is performed lexicographically on the parsed time components and behavioral flags.
///
/// An unparsed pattern is always considered less than a parsed one.
fn cmp(&self, other: &Self) -> Ordering {
// Compare the time components in logical order, from most to least
// significant.
self.seconds
.cmp(&other.seconds)
.then_with(|| self.minutes.cmp(&other.minutes))
.then_with(|| self.hours.cmp(&other.hours))
.then_with(|| self.days.cmp(&other.days))
.then_with(|| self.months.cmp(&other.months))
.then_with(|| self.days_of_week.cmp(&other.days_of_week))
.then_with(|| self.years.cmp(&other.years))
// Finally, compare the boolean flags to ensure a stable order
// for patterns that are otherwise identical.
.then_with(|| self.star_dom.cmp(&other.star_dom))
.then_with(|| self.star_dow.cmp(&other.star_dow))
.then_with(|| self.dom_and_dow.cmp(&other.dom_and_dow))
}
}
impl std::hash::Hash for CronPattern {
/// Hashes the functionally significant fields of the CronPattern.
///
/// This implementation is consistent with the `PartialEq` implementation,
/// ensuring that functionally identical patterns produce the same hash.
/// The original pattern string is not included in the hash.
fn hash<H: Hasher>(&self, state: &mut H) {
self.seconds.hash(state);
self.minutes.hash(state);
self.hours.hash(state);
self.days.hash(state);
self.months.hash(state);
self.days_of_week.hash(state);
self.years.hash(state);
self.star_dom.hash(state);
self.star_dow.hash(state);
self.dom_and_dow.hash(state);
}
}
#[cfg(test)]
mod tests {
use crate::parser::{CronParser, Seconds};
use super::*;
#[test]
fn test_last_day_of_month() -> Result<(), CronError> {
// Check the last day of February for a non-leap year
assert_eq!(CronPattern::last_day_of_month(2021, 2)?, 28);
// Check the last day of February for a leap year
assert_eq!(CronPattern::last_day_of_month(2020, 2)?, 29);
// Check for an invalid month (0 or greater than 12)
assert!(CronPattern::last_day_of_month(2023, 0).is_err());
assert!(CronPattern::last_day_of_month(2023, 13).is_err());
Ok(())
}
#[test]
fn test_closest_weekday() -> Result<(), CronError> {
// Example cron pattern: "0 0 15W * *" which means at 00:00 on the closest weekday to the 15th of each month
let cron = CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse("0 0 0 15W * *")?;
// Test a month where the 15th is a weekday
// Assuming 15th is Wednesday (a weekday), the closest weekday is the same day.
let date = NaiveDate::from_ymd_opt(2023, 6, 15).expect("To work"); // 15th June 2023
assert!(cron
.pattern
.day_match(date.year(), date.month(), date.day())?);
// Test a month where the 15th is a Saturday
// The closest weekday would be Friday, 14th.
let date = NaiveDate::from_ymd_opt(2024, 6, 14).expect("To work"); // 14th May 2023
assert!(cron
.pattern
.day_match(date.year(), date.month(), date.day())?);
// Test a month where the 15th is a Sunday
// The closest weekday would be Monday, 16th.
let date = NaiveDate::from_ymd_opt(2023, 10, 16).expect("To work"); // 16th October 2023
assert!(cron
.pattern
.day_match(date.year(), date.month(), date.day())?);
// Test a non-matching date
let date = NaiveDate::from_ymd_opt(2023, 6, 16).expect("To work"); // 16th June 2023
assert!(!cron
.pattern
.day_match(date.year(), date.month(), date.day())?);
Ok(())
}
#[test]
fn test_closest_weekday_with_alternative_weekdays() -> Result<(), CronError> {
// Example cron pattern: "0 0 15W * *" which means at 00:00 on the closest weekday to the 15th of each month
let cron = CronParser::builder()
.seconds(Seconds::Required)
.alternative_weekdays(true)
.build()
.parse("0 0 0 15W * *")?;
// Test a month where the 15th is a weekday
// Assuming 15th is Wednesday (a weekday), the closest weekday is the same day.
let date = NaiveDate::from_ymd_opt(2023, 6, 15).expect("To work"); // 15th June 2023
assert!(cron
.pattern
.day_match(date.year(), date.month(), date.day())?);
// Test a month where the 15th is a Saturday
// The closest weekday would be Friday, 14th.
let date = NaiveDate::from_ymd_opt(2024, 6, 14).expect("To work"); // 14th May 2023
assert!(cron
.pattern
.day_match(date.year(), date.month(), date.day())?);
// Test a month where the 15th is a Sunday
// The closest weekday would be Monday, 16th.
let date = NaiveDate::from_ymd_opt(2023, 10, 16).expect("To work"); // 16th October 2023
assert!(cron
.pattern
.day_match(date.year(), date.month(), date.day())?);
// Test a non-matching date
let date = NaiveDate::from_ymd_opt(2023, 6, 16).expect("To work"); // 16th June 2023
assert!(!cron
.pattern
.day_match(date.year(), date.month(), date.day())?);
Ok(())
}
#[test]
fn test_closest_weekday_month_boundary() -> Result<(), CronError> {
// --- TEST START OF MONTH ---
let cron = CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse("0 0 0 1W * *")?;
// Case 1: The 1st is a Saturday (Nov 2025).
// Should trigger on Monday the 3rd, not jump back to October.
assert!(
!cron.pattern.day_match(2025, 10, 31)?,
"Should not trigger on previous month"
);
assert!(
cron.pattern.day_match(2025, 11, 3)?,
"Should trigger on Mon 3rd for Sat 1st"
);
assert!(
!cron.pattern.day_match(2025, 11, 1)?,
"Should not trigger on Sat 1st itself"
);
// Case 2: The 1st is a Sunday (June 2025).
// Should trigger on Monday the 2nd.
assert!(
cron.pattern.day_match(2025, 6, 2)?,
"Should trigger on Mon 2nd for Sun 1st"
);
assert!(
!cron.pattern.day_match(2025, 6, 3)?,
"Should NOT trigger on Tue 3rd for Sun 1st"
);
// --- TEST END OF MONTH ---
let cron_end = CronParser::builder()
.seconds(Seconds::Optional)
.build()
.parse("0 0 0 31W * *")?;
// Case 3: The 31st is a Sunday (Aug 2025).
// Should trigger on Friday the 29th, not jump forward to September.
assert!(
cron_end.pattern.day_match(2025, 8, 29)?,
"Should trigger on Fri 29th for Sun 31st"
);
assert!(
!cron_end.pattern.day_match(2025, 9, 1)?,
"Should not trigger on next month"
);
Ok(())
}
}
+180
View File
@@ -0,0 +1,180 @@
// OCPS Compliance Test Suite
//
// This file contains a separate test suite for verifying compliance
// with the Open Cron Pattern Specification (OCPS) 1.4 draft.
//
// Specification Reference: github.com/open-source-cron/ocsp
//
// Each module in this suite corresponds to a specific version of the OCPS,
// allowing for targeted testing of features as they were introduced.
use croner::parser::{CronParser};
use croner::Cron;
use std::str::FromStr;
/// Helper function to parse with a specific configuration.
fn custom_parse(pattern: &str, dom_and_dow: bool) -> Result<Cron, croner::errors::CronError> {
CronParser::builder()
.dom_and_dow(dom_and_dow)
.build()
.parse(pattern)
}
#[cfg(test)]
mod ocps_1_0_tests {
use super::*;
use chrono::{Local, TimeZone}; // Import trait for this module
#[test]
fn test_5_field_baseline() {
assert!(Cron::from_str("15 10 1 10 *").is_ok(), "Should parse a 5-field pattern.");
}
#[test]
fn test_special_chars_wildcard_list_range_step() {
assert!(Cron::from_str("*/15 0-4,8-12 * JAN-MAR,DEC MON-FRI").is_ok(), "Should handle *, /, -, and , correctly.");
}
#[test]
fn test_logical_or_for_date_fields() {
// Should match on the 1st AND on every Monday.
let cron = Cron::from_str("0 12 1 * MON").unwrap();
let first_of_month = Local.with_ymd_and_hms(2025, 7, 1, 12, 0, 0).unwrap(); // A Tuesday
let a_monday = Local.with_ymd_and_hms(2025, 7, 14, 12, 0, 0).unwrap(); // Not the 1st
assert!(cron.is_time_matching(&first_of_month).unwrap());
assert!(cron.is_time_matching(&a_monday).unwrap());
}
}
#[cfg(test)]
mod ocps_1_1_tests {
use super::*;
#[test]
fn test_nicknames() {
assert_eq!(custom_parse("@yearly", false).unwrap().pattern.to_string().to_uppercase(), "0 0 1 1 *");
assert_eq!(custom_parse("@monthly", false).unwrap().pattern.to_string().to_uppercase(), "0 0 1 * *");
assert_eq!(custom_parse("@weekly", false).unwrap().pattern.to_string().to_uppercase(), "0 0 * * 0");
assert_eq!(custom_parse("@daily", false).unwrap().pattern.to_string().to_uppercase(), "0 0 * * *");
assert_eq!(custom_parse("@hourly", false).unwrap().pattern.to_string().to_uppercase(), "0 * * * *");
}
#[test]
#[ignore] // Ignored until @reboot is implemented
fn test_reboot_nickname() {
// The parser should accept @reboot without crashing.
// A call to find_next_occurrence could then return a specific error if not supported at runtime.
assert!(custom_parse("@reboot", false).is_ok());
}
}
#[cfg(test)]
mod ocps_1_2_tests {
use super::*;
#[test]
fn test_6_field_with_seconds() {
let cron = custom_parse("30 15 10 1 10 *", false).unwrap();
assert!(cron.pattern.seconds.is_bit_set(30, 1).unwrap());
assert!(!cron.pattern.seconds.is_bit_set(0, 1).unwrap());
}
#[test]
fn test_7_field_with_year() {
let cron = custom_parse("0 0 12 1 1 * 2025", false).unwrap();
assert!(cron.pattern.years.is_bit_set(2025, 1).unwrap());
}
}
#[cfg(test)]
mod ocps_1_3_tests {
use super::*;
use chrono::{Local, TimeZone}; // Import trait for this module
#[test]
fn test_last_day_of_month() {
let cron = Cron::from_str("0 0 L * *").unwrap();
let last_of_july = Local.with_ymd_and_hms(2025, 7, 31, 0, 0, 0).unwrap();
let not_last_of_july = Local.with_ymd_and_hms(2025, 7, 30, 0, 0, 0).unwrap();
assert!(cron.is_time_matching(&last_of_july).unwrap());
assert!(!cron.is_time_matching(&not_last_of_july).unwrap());
}
#[test]
fn test_last_weekday_of_month() {
// The last Friday in July 2025 is the 25th.
let cron = Cron::from_str("0 0 * * 5L").unwrap();
let last_friday = Local.with_ymd_and_hms(2025, 7, 25, 0, 0, 0).unwrap();
let not_last_friday = Local.with_ymd_and_hms(2025, 7, 18, 0, 0, 0).unwrap();
assert!(cron.is_time_matching(&last_friday).unwrap());
assert!(!cron.is_time_matching(&not_last_friday).unwrap());
}
#[test]
fn test_nth_weekday_of_month() {
// The second Tuesday in July 2025 is the 8th.
let cron = Cron::from_str("0 0 * * 2#2").unwrap();
let second_tuesday = Local.with_ymd_and_hms(2025, 7, 8, 0, 0, 0).unwrap();
let first_tuesday = Local.with_ymd_and_hms(2025, 7, 1, 0, 0, 0).unwrap();
assert!(cron.is_time_matching(&second_tuesday).unwrap());
assert!(!cron.is_time_matching(&first_tuesday).unwrap());
}
#[test]
fn test_closest_weekday() {
// July 5th, 2025 is a Saturday. The closest weekday is Friday the 4th.
let cron = Cron::from_str("0 0 5W 7 *").unwrap();
let closest_weekday = Local.with_ymd_and_hms(2025, 7, 4, 0, 0, 0).unwrap();
assert!(cron.is_time_matching(&closest_weekday).unwrap());
}
}
#[cfg(test)]
mod ocps_1_4_tests {
use super::*;
use chrono::{Local, TimeZone}; // Import trait for this module
#[test]
fn test_question_mark_is_alias_for_wildcard() {
let cron_star = Cron::from_str("0 0 1 * *").unwrap();
let cron_q = Cron::from_str("0 0 1 * ?").unwrap();
assert_eq!(cron_star, cron_q);
}
#[test]
fn test_and_modifier() {
// Should ONLY match if the 1st of the month is a Monday.
let cron = custom_parse("0 12 1 * +MON", false).unwrap();
// September 1st, 2025 is a Monday.
let first_is_monday = Local.with_ymd_and_hms(2025, 9, 1, 12, 0, 0).unwrap();
// July 1st, 2025 is a Tuesday.
let first_is_not_monday = Local.with_ymd_and_hms(2025, 7, 1, 12, 0, 0).unwrap();
assert!(cron.is_time_matching(&first_is_monday).unwrap());
assert!(!cron.is_time_matching(&first_is_not_monday).unwrap());
}
#[test]
fn test_global_and_mode() {
let cron = custom_parse("0 12 1 * MON", true).unwrap();
// Should ONLY match if the 1st of the month is a Monday (due to global setting).
let first_is_monday = Local.with_ymd_and_hms(2025, 9, 1, 12, 0, 0).unwrap();
let first_is_not_monday = Local.with_ymd_and_hms(2025, 7, 1, 12, 0, 0).unwrap();
let a_monday_not_first = Local.with_ymd_and_hms(2025, 7, 14, 12, 0, 0).unwrap();
assert!(cron.is_time_matching(&first_is_monday).unwrap());
assert!(!cron.is_time_matching(&first_is_not_monday).unwrap());
assert!(!cron.is_time_matching(&a_monday_not_first).unwrap(), "Should not match a Monday that is not the 1st in AND mode.");
}
#[test]
fn test_plus_modifier_invalid_field() {
// Using '+' in the day-of-month field should result in an error.
let result = custom_parse("0 0 +1 * *", false);
assert!(matches!(result, Err(croner::errors::CronError::IllegalCharacters(_))));
}
}