├── rust-toolchain ├── .gitignore ├── .github ├── CODEOWNERS └── workflows │ ├── lint.yml │ └── semgrep.yml ├── saffron ├── fuzz │ ├── .gitignore │ ├── fuzz_targets │ │ └── default.rs │ └── Cargo.toml ├── examples │ ├── describe.rs │ └── future-times.rs ├── benches │ └── cron.rs ├── Cargo.toml ├── src │ ├── describe │ │ ├── mod.rs │ │ └── english.rs │ └── parse.rs └── LICENSE ├── saffron-web ├── .cargo │ └── config.toml ├── .gitignore ├── jest.config.js ├── webpack.config.js ├── package.json ├── Cargo.toml ├── pkg │ ├── package.json │ ├── README.md │ ├── LICENSE │ ├── saffron.d.ts │ └── saffron.js ├── scripts │ └── wasm-build.mjs ├── README.md ├── LICENSE ├── tests │ └── saffron.test.js └── src │ └── lib.rs ├── saffron-worker ├── .cargo │ └── config.toml ├── .gitignore ├── worker │ ├── metadata_wasm.json │ └── worker.js ├── wrangler.toml ├── Cargo.toml ├── LICENSE ├── README.md └── src │ └── lib.rs ├── saffron-c ├── cbindgen.toml ├── Cargo.toml ├── LICENSE ├── include │ └── saffron.h └── src │ └── lib.rs ├── .vscode ├── settings.json └── tasks.json ├── README.md └── LICENSE /rust-toolchain: -------------------------------------------------------------------------------- 1 | 1.51.0 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | **/target 2 | **/*.rs.bk 3 | Cargo.lock -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @ObsidianMinor @harrishancock @mkevanz 2 | -------------------------------------------------------------------------------- /saffron/fuzz/.gitignore: -------------------------------------------------------------------------------- 1 | 2 | target 3 | corpus 4 | artifacts 5 | -------------------------------------------------------------------------------- /saffron-web/.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | target = "wasm32-unknown-unknown" -------------------------------------------------------------------------------- /saffron-worker/.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | target = "wasm32-unknown-unknown" -------------------------------------------------------------------------------- /saffron-worker/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | bin/ 4 | pkg/ 5 | wasm-pack.log 6 | worker/generated/ 7 | -------------------------------------------------------------------------------- /saffron-c/cbindgen.toml: -------------------------------------------------------------------------------- 1 | language = "C" 2 | include_guard = "SAFFRON_H" 3 | no_includes = true 4 | sys_includes = ["stdbool.h", "stdint.h", "stdlib.h"] 5 | pragma_once = true 6 | cpp_compat = true -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "rust-analyzer.checkOnSave.enable": false, 3 | "editor.formatOnSave": true, 4 | "[rust]": { 5 | "editor.defaultFormatter": "matklad.rust-analyzer" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /saffron-worker/worker/metadata_wasm.json: -------------------------------------------------------------------------------- 1 | { 2 | "body_part": "script", 3 | "bindings": [ 4 | { 5 | "name": "wasm", 6 | "type": "wasm_module", 7 | "part": "wasmprogram" 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /saffron/fuzz/fuzz_targets/default.rs: -------------------------------------------------------------------------------- 1 | #![no_main] 2 | use libfuzzer_sys::fuzz_target; 3 | use saffron::parse::CronExpr; 4 | 5 | fuzz_target!(|data: &[u8]| { 6 | if let Ok(s) = std::str::from_utf8(data) { 7 | let _ = s.parse::(); 8 | } 9 | }); 10 | -------------------------------------------------------------------------------- /saffron-web/.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | **/*.rs.bk 3 | Cargo.lock 4 | bin/ 5 | wasm-pack.log 6 | out/ 7 | node_modules/ 8 | .yarn/ 9 | .yarnrc 10 | 11 | /pkg/* 12 | !/pkg/saffron.d.ts 13 | !/pkg/saffron.js 14 | !/pkg/LICENSE 15 | !/pkg/package.json 16 | !/pkg/README.md 17 | 18 | /tests/bundle -------------------------------------------------------------------------------- /saffron-web/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // A list of paths to directories that Jest should use to search for files in 3 | roots: [ 4 | __dirname + "/tests/bundle" 5 | ], 6 | 7 | // The test environment that will be used for testing 8 | testEnvironment: "node", 9 | }; 10 | -------------------------------------------------------------------------------- /saffron-worker/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "cron-triggers-dev" 2 | type = "rust" 3 | 4 | account_id = "615f1f0479e7014f0bebcd10d379f10e" 5 | workers_dev = true 6 | vars = { env = "dev" } 7 | 8 | [env.production] 9 | name = "cron-triggers" 10 | vars = { env = "production" } 11 | zone_id = "78d4687c15196489e76b228c6056168c" 12 | route = "cron-triggers.cloudflareworkers.com/*" -------------------------------------------------------------------------------- /saffron-web/webpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | target: "node14.15", 3 | entry: { 4 | "saffron.test": "./tests/saffron.test.js", 5 | }, 6 | output: { 7 | filename: "[name].js", 8 | path: __dirname + "/tests/bundle", 9 | }, 10 | optimization: { 11 | minimize: false, 12 | }, 13 | experiments: { 14 | syncWebAssembly: true, 15 | }, 16 | }; 17 | -------------------------------------------------------------------------------- /saffron-c/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Aaron Loyd "] 3 | edition = "2018" 4 | license-file = "LICENSE" 5 | name = "saffron-c" 6 | repository = "https://github.com/cloudflare/saffron" 7 | version = "0.1.0" 8 | 9 | [lib] 10 | name = "saffron" 11 | crate-type = ["staticlib"] 12 | 13 | [dependencies] 14 | saffron = {path = "../saffron", version = "0.1"} 15 | chrono = "0.4" 16 | libc = "0.2" 17 | 18 | [profile.release] 19 | lto = "fat" 20 | panic = "abort" -------------------------------------------------------------------------------- /saffron-web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "engines": { 4 | "node": ">=14.0.0", 5 | "yarn": ">=1.22.0" 6 | }, 7 | "scripts": { 8 | "wasm-build": "node ./scripts/wasm-build.mjs", 9 | "pretest": "yarn wasm-build && yarn install --force && webpack", 10 | "test": "jest" 11 | }, 12 | "devDependencies": { 13 | "@cloudflare/saffron": "file:./pkg", 14 | "jest": "^26.6.3", 15 | "webpack": "^5.9.0", 16 | "webpack-cli": "^4.2.0" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /saffron/fuzz/Cargo.toml: -------------------------------------------------------------------------------- 1 | 2 | [package] 3 | name = "saffron-fuzz" 4 | version = "0.0.0" 5 | authors = ["Automatically generated"] 6 | publish = false 7 | edition = "2018" 8 | 9 | [package.metadata] 10 | cargo-fuzz = true 11 | 12 | [dependencies] 13 | libfuzzer-sys = "0.3" 14 | 15 | [dependencies.saffron] 16 | path = ".." 17 | 18 | # Prevent this from interfering with workspaces 19 | [workspace] 20 | members = ["."] 21 | 22 | [[bin]] 23 | name = "default" 24 | path = "fuzz_targets/default.rs" 25 | test = false 26 | doc = false 27 | -------------------------------------------------------------------------------- /saffron/examples/describe.rs: -------------------------------------------------------------------------------- 1 | //! Prints a description of the given cron expression 2 | 3 | use saffron::parse::{CronExpr, English}; 4 | 5 | fn main() { 6 | let args: Vec = std::env::args().collect(); 7 | match args 8 | .get(1) 9 | .map(|s| s.as_str().parse::()) 10 | .transpose() 11 | { 12 | Ok(Some(cron)) => println!("{}", cron.describe(English::default())), 13 | Ok(None) => println!("Usage: cargo run --example describe -- \"[cron expression]\""), 14 | Err(err) => println!("{}", err), 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /saffron/benches/cron.rs: -------------------------------------------------------------------------------- 1 | use criterion::{criterion_group, criterion_main, BenchmarkId, Criterion}; 2 | 3 | fn cron_benchmark(c: &mut Criterion) { 4 | let mut group = c.benchmark_group("Cron.from_str"); 5 | let inputs = ["* * * * *", "1 12 3 6 *", "12-35 1-23 2-5 1-11 *"]; 6 | for input in inputs.iter() { 7 | group.bench_with_input(BenchmarkId::from_parameter(input), input, |b, input| { 8 | b.iter(|| input.parse::().unwrap()) 9 | }); 10 | } 11 | group.finish() 12 | } 13 | 14 | criterion_group!(benches, cron_benchmark); 15 | criterion_main!(benches); 16 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | rustfmt: 7 | name: Formatter 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | 12 | - name: Install Rust 13 | run: | 14 | rustup update stable 15 | rustup default stable 16 | rustup component add rustfmt 17 | rustup component add clippy 18 | 19 | - name: Check Formatting 20 | run: cargo fmt --all -- --check 21 | 22 | - name: Check for idiomatic code 23 | run: cargo clippy --all --all-features -- -D warnings 24 | -------------------------------------------------------------------------------- /saffron-web/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Aaron Loyd "] 3 | description = """ 4 | A web wrapper around the saffron Rust library powering Cron Triggers on Cloudflare Workers. 5 | """ 6 | edition = "2018" 7 | license-file = "LICENSE" 8 | name = "saffron-web" 9 | repository = "https://github.com/cloudflare/saffron" 10 | version = "0.1.0" 11 | 12 | [lib] 13 | crate-type = ["cdylib"] 14 | 15 | [dependencies] 16 | saffron = {path = "../saffron", version = "0.1.0"} 17 | chrono = {version = "0.4", features = ["wasmbind"]} 18 | js-sys = "0.3" 19 | wasm-bindgen = "=0.2.65" 20 | 21 | [profile.release] 22 | lto = "fat" 23 | opt-level = "s" 24 | -------------------------------------------------------------------------------- /.github/workflows/semgrep.yml: -------------------------------------------------------------------------------- 1 | 2 | on: 3 | pull_request: {} 4 | workflow_dispatch: {} 5 | push: 6 | branches: 7 | - main 8 | - master 9 | schedule: 10 | - cron: '0 0 * * *' 11 | name: Semgrep config 12 | jobs: 13 | semgrep: 14 | name: semgrep/ci 15 | runs-on: ubuntu-20.04 16 | env: 17 | SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }} 18 | SEMGREP_URL: https://cloudflare.semgrep.dev 19 | SEMGREP_APP_URL: https://cloudflare.semgrep.dev 20 | SEMGREP_VERSION_CHECK_URL: https://cloudflare.semgrep.dev/api/check-version 21 | container: 22 | image: returntocorp/semgrep 23 | steps: 24 | - uses: actions/checkout@v3 25 | - run: semgrep ci 26 | -------------------------------------------------------------------------------- /saffron-web/pkg/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cloudflare/saffron", 3 | "version": "0.1.0", 4 | "description": "A wasm wrapper around the cron parser powering Cron Triggers for Cloudflare Workers", 5 | "contributors": [ 6 | "Aaron Loyd " 7 | ], 8 | "homepage": "https://github.com/cloudflare/saffron#readme", 9 | "repository": "github:cloudflare/saffron", 10 | "bugs": "https://github.com/cloudflare/saffron/issues", 11 | "type": "module", 12 | "main": "saffron.js", 13 | "module": "saffron.js", 14 | "types": "saffron.d.ts", 15 | "files": [ 16 | "saffron_bg.d.ts", 17 | "saffron_bg.js", 18 | "saffron_bg.wasm", 19 | "saffron.d.ts", 20 | "saffron.js" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /saffron/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Aaron Loyd "] 3 | description = "A Quartz-like cron parser used as part of Cron Triggers in Cloudflare Workers" 4 | edition = "2018" 5 | license-file = "LICENSE" 6 | name = "saffron" 7 | repository = "https://github.com/cloudflare/saffron" 8 | version = "0.1.0" 9 | 10 | [features] 11 | default = [] 12 | std = [] 13 | 14 | [[bench]] 15 | harness = false 16 | name = "cron" 17 | 18 | [[example]] 19 | name = "future-times" 20 | required-features = ["chrono/clock"] 21 | 22 | [dependencies] 23 | chrono = {version = "0.4", default-features = false, features = ["alloc"]} 24 | nom = {version = "5.1", default-features = false} 25 | 26 | [dev-dependencies] 27 | criterion = "0.3" 28 | -------------------------------------------------------------------------------- /saffron-worker/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Aaron Loyd "] 3 | description = """ 4 | A fallback Worker for validating, describing, and 5 | getting the next execution time of cron strings. 6 | """ 7 | edition = "2018" 8 | license-file = "LICENSE" 9 | name = "saffron-worker" 10 | repository = "https://github.com/cloudflare/saffron" 11 | version = "0.1.0" 12 | 13 | [lib] 14 | crate-type = ["cdylib", "rlib"] 15 | 16 | [dependencies] 17 | saffron = {path = "../saffron", version = "0.1"} 18 | chrono = {version = "0.4", features = ["wasmbind"]} 19 | console_error_panic_hook = {version = "0.1"} 20 | js-sys = "0.3" 21 | wasm-bindgen = {version = "=0.2.65"} 22 | 23 | [dev-dependencies] 24 | wasm-bindgen-test = "0.2" 25 | 26 | [profile.release] 27 | lto = "fat" 28 | opt-level = "s" 29 | -------------------------------------------------------------------------------- /saffron/src/describe/mod.rs: -------------------------------------------------------------------------------- 1 | mod english; 2 | 3 | pub use english::{English, HourFormat}; 4 | 5 | use crate::parse::CronExpr; 6 | use core::fmt::{self, Display, Formatter}; 7 | 8 | /// A language formatting configuration 9 | pub trait Language { 10 | /// Formats a cron expression into the specified formatter 11 | fn fmt_expr(&self, expr: &CronExpr, f: &mut Formatter) -> fmt::Result; 12 | } 13 | 14 | impl<'a, L: Language> Language for &'a L { 15 | fn fmt_expr(&self, expr: &CronExpr, f: &mut Formatter) -> fmt::Result { 16 | (*self).fmt_expr(expr, f) 17 | } 18 | } 19 | 20 | struct Displayer(pub F); 21 | impl Display for Displayer 22 | where 23 | F: Fn(&mut Formatter) -> fmt::Result, 24 | { 25 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 26 | self.0(f) 27 | } 28 | } 29 | 30 | fn display(f: F) -> Displayer 31 | where 32 | F: Fn(&mut Formatter) -> fmt::Result, 33 | { 34 | Displayer(f) 35 | } 36 | -------------------------------------------------------------------------------- /saffron/examples/future-times.rs: -------------------------------------------------------------------------------- 1 | //! Prints an list of times from a cron iterator until the DateTime container is maxed out 2 | 3 | use chrono::Utc; 4 | use saffron::Cron; 5 | 6 | fn main() { 7 | let args: Vec = std::env::args().collect(); 8 | match args.get(1).map(|s| s.as_str().parse::()).transpose() { 9 | Ok(Some(cron)) => { 10 | if !cron.any() { 11 | println!("Cron will never match any given time!"); 12 | return; 13 | } 14 | 15 | let futures = cron.clone().iter_from(Utc::now()); 16 | for time in futures { 17 | if !cron.contains(time) { 18 | println!("Failed check! Cron does not contain {}.", time); 19 | break; 20 | } 21 | println!("{}", time.format("%F %R")); 22 | } 23 | } 24 | Ok(None) => println!("Usage: cargo run --example future-times -- \"[cron expression]\""), 25 | Err(err) => println!("{}", err), 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /saffron-web/scripts/wasm-build.mjs: -------------------------------------------------------------------------------- 1 | import { execSync } from "child_process"; 2 | import fs from "fs"; 3 | import path from "path"; 4 | import { fileURLToPath } from "url"; 5 | 6 | // return value is the first directory created 7 | const scripts_dir = path.dirname(fileURLToPath(import.meta.url)); 8 | const saffron_web_dir = path.join(scripts_dir, ".."); 9 | const pkg_dir = path.join(saffron_web_dir, "pkg"); 10 | const out_dir = path.join(saffron_web_dir, "out"); 11 | const wasm_pack_dir = path.join(out_dir, "wasm-pack"); 12 | 13 | execSync( 14 | `wasm-pack build --target bundler --out-dir ${wasm_pack_dir} --out-name saffron`, 15 | { 16 | cwd: saffron_web_dir, 17 | shell: true, 18 | stdio: "inherit", 19 | } 20 | ); 21 | 22 | const bindgen_files = [ 23 | "saffron_bg.d.ts", 24 | "saffron_bg.js", 25 | "saffron_bg.wasm", 26 | ].map((file) => [path.join(wasm_pack_dir, file), path.join(pkg_dir, file)]); 27 | 28 | if (!fs.existsSync(pkg_dir)) { 29 | fs.mkdirSync(pkg_dir); 30 | } 31 | 32 | for (const [srcFile, destFile] of bindgen_files) { 33 | fs.copyFileSync(srcFile, destFile); 34 | } 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | saffron is a cron parser used as part of the backend for Cron Triggers in Cloudflare Workers. It 2 | provides APIs for the complete stack, allowing us to use the same parser everywhere. It's made in 3 | two parts: 4 | 5 | 1. The parser, which is responsible for reading cron expressions into an easy to understand format, 6 | which can be simplified with the compiler, or described with `CronExpr::describe`. 7 | 8 | 2. The compiler, which simplifies expressions into their most compact form. This compact form 9 | can check if a chrono date time is contained in a given expression in constant time, no matter 10 | the size of the original expression. It can also be used to get future times that match 11 | efficiently as an iterator. 12 | 13 | The project itself is divided into 4 Rust workspace members: 14 | 15 | 1. saffron - the parser itself 16 | 2. saffron-c - the C API used internally by the Workers API 17 | 3. saffron-web - the web API used on the dash in the browser 18 | 4. saffron-worker - the Rust Worker which provides the validate/describe endpoint in the dash API on 19 | the edge as a fallback if WASM can't be used in the browser 20 | -------------------------------------------------------------------------------- /saffron-web/README.md: -------------------------------------------------------------------------------- 1 | # saffron-web 2 | 3 | `saffron-web` is a set of web bindings of `saffron` compiled to wasm for use with webpack. 4 | 5 | ## 🚴 Usage 6 | 7 | ### Parse a cron string and check if it contains a specific time 8 | 9 | ```ts 10 | import Cron from "@cloudflare/saffron"; 11 | 12 | let cron = new Cron("0 0 L 2 *"); 13 | 14 | console.log(cron.contains(new Date("2020-02-28T00:00:00"))); // false 15 | console.log(cron.contains(new Date("2020-02-29T00:00:00"))); // true 16 | console.log(cron.contains(new Date("2021-02-28T00:00:00"))); // true 17 | 18 | // be sure to free the wasm memory when you're done with the expression! 19 | cron.free(); 20 | ``` 21 | 22 | ### Parse a cron string and get the next 5 matching times 23 | 24 | ```ts 25 | import Cron from "@cloudflare/saffron"; 26 | 27 | let cron = new Cron("0 0 L * *"); 28 | let iter = cron.iterFrom(new Date("1970-01-01T00:00:00")); 29 | 30 | let array = []; 31 | let i = 0; 32 | for (let next of iter) { 33 | array[i] = next; 34 | if (++i >= 5) { 35 | break; 36 | } 37 | } 38 | console.log(array); 39 | 40 | // be sure to free the wasm memory when you're done with the iterator! 41 | iter.free(); 42 | cron.free(); 43 | ``` 44 | -------------------------------------------------------------------------------- /saffron-web/pkg/README.md: -------------------------------------------------------------------------------- 1 | # saffron-web 2 | 3 | `saffron-web` is a set of web bindings of `saffron` compiled to wasm for use with webpack. 4 | 5 | ## 🚴 Usage 6 | 7 | ### Parse a cron string and check if it contains a specific time 8 | 9 | ```ts 10 | import Cron from "@cloudflare/saffron"; 11 | 12 | let cron = new Cron("0 0 L 2 *"); 13 | 14 | console.log(cron.contains(new Date("2020-02-28T00:00:00"))); // false 15 | console.log(cron.contains(new Date("2020-02-29T00:00:00"))); // true 16 | console.log(cron.contains(new Date("2021-02-28T00:00:00"))); // true 17 | 18 | // be sure to free the wasm memory when you're done with the expression! 19 | cron.free(); 20 | ``` 21 | 22 | ### Parse a cron string and get the next 5 matching times 23 | 24 | ```ts 25 | import Cron from "@cloudflare/saffron"; 26 | 27 | let cron = new Cron("0 0 L * *"); 28 | let iter = cron.iterFrom(new Date("1970-01-01T00:00:00")); 29 | 30 | let array = []; 31 | let i = 0; 32 | for (let next of iter) { 33 | array[i] = next; 34 | if (++i >= 5) { 35 | break; 36 | } 37 | } 38 | console.log(array); 39 | 40 | // be sure to free the wasm memory when you're done with the iterator! 41 | iter.free(); 42 | cron.free(); 43 | ``` 44 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Cloudflare, Inc. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted 4 | provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions 7 | and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions 10 | and the following disclaimer in the documentation and/or other materials provided with the distribution. 11 | 12 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse 13 | or promote products derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR 16 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND 17 | FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 18 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 20 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER 21 | IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF 22 | THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /saffron-c/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Cloudflare, Inc. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted 4 | provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions 7 | and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions 10 | and the following disclaimer in the documentation and/or other materials provided with the distribution. 11 | 12 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse 13 | or promote products derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR 16 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND 17 | FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 18 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 20 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER 21 | IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF 22 | THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /saffron/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Cloudflare, Inc. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted 4 | provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions 7 | and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions 10 | and the following disclaimer in the documentation and/or other materials provided with the distribution. 11 | 12 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse 13 | or promote products derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR 16 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND 17 | FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 18 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 20 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER 21 | IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF 22 | THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /saffron-web/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Cloudflare, Inc. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted 4 | provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions 7 | and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions 10 | and the following disclaimer in the documentation and/or other materials provided with the distribution. 11 | 12 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse 13 | or promote products derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR 16 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND 17 | FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 18 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 20 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER 21 | IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF 22 | THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /saffron-worker/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Cloudflare, Inc. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted 4 | provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions 7 | and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions 10 | and the following disclaimer in the documentation and/or other materials provided with the distribution. 11 | 12 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse 13 | or promote products derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR 16 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND 17 | FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 18 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 20 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER 21 | IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF 22 | THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /saffron-web/pkg/LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 Cloudflare, Inc. All rights reserved. 2 | 3 | Redistribution and use in source and binary forms, with or without modification, are permitted 4 | provided that the following conditions are met: 5 | 6 | 1. Redistributions of source code must retain the above copyright notice, this list of conditions 7 | and the following disclaimer. 8 | 9 | 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions 10 | and the following disclaimer in the documentation and/or other materials provided with the distribution. 11 | 12 | 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse 13 | or promote products derived from this software without specific prior written permission. 14 | 15 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR 16 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND 17 | FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR 18 | CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 19 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 20 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER 21 | IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF 22 | THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Watch saffron", 6 | "group": "build", 7 | "type": "shell", 8 | "options": { 9 | "cwd": "./saffron" 10 | }, 11 | "runOptions": { 12 | "runOn": "folderOpen" 13 | }, 14 | "command": "cargo watch -x clippy", 15 | "problemMatcher": "$rustc-watch", 16 | "isBackground": true 17 | }, 18 | { 19 | "label": "Watch saffron-c", 20 | "group": "build", 21 | "type": "shell", 22 | "options": { 23 | "cwd": "./saffron-c" 24 | }, 25 | "runOptions": { 26 | "runOn": "folderOpen" 27 | }, 28 | "command": "cargo watch -x clippy", 29 | "problemMatcher": "$rustc-watch", 30 | "isBackground": true 31 | }, 32 | { 33 | "label": "Watch saffron-web", 34 | "group": "build", 35 | "type": "shell", 36 | "options": { 37 | "cwd": "./saffron-web" 38 | }, 39 | "runOptions": { 40 | "runOn": "folderOpen" 41 | }, 42 | "command": "cargo watch -x clippy", 43 | "problemMatcher": "$rustc-watch", 44 | "isBackground": true 45 | }, 46 | { 47 | "label": "Watch saffron-worker", 48 | "group": "build", 49 | "type": "shell", 50 | "options": { 51 | "cwd": "./saffron-worker" 52 | }, 53 | "runOptions": { 54 | "runOn": "folderOpen" 55 | }, 56 | "command": "cargo watch -x clippy", 57 | "problemMatcher": "$rustc-watch", 58 | "isBackground": true 59 | } 60 | ] 61 | } -------------------------------------------------------------------------------- /saffron-worker/README.md: -------------------------------------------------------------------------------- 1 | # 👷‍♀️🦀🕸️ `saffron cron validation Worker` 2 | 3 | A fallback Worker for validating, describing, and getting the next execution time of cron strings. 4 | 5 | Used in the dash if wasm isn't supported in a user's browser 6 | 7 | ## 🚴 Usage 8 | 9 | --- 10 | 11 | ### Try it locally with `wrangler dev` 12 | 13 | --- 14 | 15 | #### POST to the worker on `/describe` 16 | 17 | The worker describes the cron string with a list of estimated future execution times and a human 18 | readable description 19 | 20 | ``` 21 | curl http://localhost:8787/describe -X POST -H "Content-Type: application/json" -d '{"cron": "0 0 * * MON"}' 22 | ``` 23 | 24 | ```json 25 | { 26 | "result": { 27 | "est_future_times": [ 28 | "2020-10-19T00:00:00.000Z", 29 | "2020-10-26T00:00:00.000Z", 30 | "2020-11-02T00:00:00.000Z", 31 | "2020-11-09T00:00:00.000Z", 32 | "2020-11-16T00:00:00.000Z" 33 | ], 34 | "description": "At 12:00 AM on Monday" 35 | }, 36 | "success": true, 37 | "errors": null, 38 | "messages": null 39 | } 40 | ``` 41 | 42 | --- 43 | 44 | #### POST to the worker on `/validate` 45 | 46 | The worker validates multiple cron strings, checking to see if all of them are valid and making sure 47 | no effective duplicates exist. 48 | 49 | ``` 50 | curl http://localhost:8787/validate -X POST -H "Content-Type: application/json" -d '{"crons": ["0 0 * * MON"]}' 51 | ``` 52 | 53 | ```json 54 | { 55 | "result": {}, 56 | "success": true, 57 | "errors": null, 58 | "messages": null 59 | } 60 | ``` 61 | 62 | If a duplicate exists, an error is returned detailing which expressions are duplicates 63 | 64 | ``` 65 | curl http://localhost:8787/validate -X POST -H "Content-Type: application/json" -d '{"crons":["0 0 * * MON", "0-0 0-0/1 * * MON,Mon"]}' 66 | ``` 67 | 68 | ```json 69 | { 70 | "result": {}, 71 | "success": false, 72 | "errors": [ 73 | "Expression '0-0 0-0/1 * * MON,Mon' already exists in the form of '0 0 * * MON'" 74 | ], 75 | "messages": null 76 | } 77 | ``` 78 | 79 | --- 80 | 81 | #### POST to the worker on `/next` 82 | 83 | The worker returns the next matching time for the cron expression 84 | 85 | ``` 86 | curl http://localhost:8787/next -X POST -H "Content-Type: application/json" -d '{"cron":"0 0 * * MON"}' 87 | ``` 88 | 89 | ```json 90 | { 91 | "result": { 92 | "next": "2020-10-19T00:00:00.000Z" 93 | }, 94 | "success": true, 95 | "errors": null, 96 | "messages": null 97 | } 98 | ``` 99 | 100 | --- 101 | 102 | ### 🛠️ Build with `wrangler build` 103 | 104 | ``` 105 | wrangler build 106 | ``` 107 | 108 | --- 109 | 110 | ### 🔬 Deploy with `wrangler deploy` 111 | 112 | ``` 113 | wrangler deploy 114 | ``` 115 | -------------------------------------------------------------------------------- /saffron-web/tests/saffron.test.js: -------------------------------------------------------------------------------- 1 | let Cron; 2 | 3 | const startDate = new Date("2020-12-01T00:00:00Z"); 4 | 5 | beforeAll(async () => { 6 | ({ default: Cron } = await import("@cloudflare/saffron")); 7 | }) 8 | 9 | it("parses * * * * *", () => { 10 | let cron = new Cron("* * * * *"); 11 | cron.free(); 12 | }) 13 | 14 | it("parses and describes * * * * *", () => { 15 | let [cron, description] = Cron.parseAndDescribe("* * * * *"); 16 | try { 17 | expect(description).toBe("Every minute"); 18 | } finally { 19 | cron.free(); 20 | } 21 | }) 22 | 23 | it("throws on invalid cron", () => { 24 | expect(() => new Cron("invalid")).toThrow(); 25 | }) 26 | 27 | it("gets next time", () => { 28 | let cron = new Cron("* * * * *"); 29 | try { 30 | expect(cron.nextFrom(startDate)).toStrictEqual(startDate); 31 | } finally { 32 | cron.free(); 33 | } 34 | }) 35 | 36 | it("gets next after time", () => { 37 | let cron = new Cron("* * * * *"); 38 | try { 39 | expect(cron.nextAfter(startDate)).toStrictEqual(new Date("2020-12-01T00:01:00Z")); 40 | } finally { 41 | cron.free(); 42 | } 43 | }) 44 | 45 | it("checks if any values are contained", () => { 46 | let cron = new Cron("* * 29 2 *"); 47 | try { 48 | expect(cron.any()).toBe(true) 49 | } finally { 50 | cron.free(); 51 | } 52 | 53 | cron = new Cron("* * 31 11 *"); 54 | try { 55 | expect(cron.any()).toBe(false) 56 | } finally { 57 | cron.free(); 58 | } 59 | }) 60 | 61 | it("iterates after the next 5 minutes", () => { 62 | let cron = new Cron("* * * * *"); 63 | let arr = []; 64 | let i = 0; 65 | let iter = cron.iterAfter(startDate); 66 | try { 67 | for (const date of iter) { 68 | arr.push(date); 69 | if (++i >= 5) { 70 | break; 71 | } 72 | } 73 | } finally { 74 | iter.free(); 75 | cron.free(); 76 | } 77 | 78 | expect(arr).toStrictEqual([ 79 | new Date("2020-12-01T00:01:00Z"), 80 | new Date("2020-12-01T00:02:00Z"), 81 | new Date("2020-12-01T00:03:00Z"), 82 | new Date("2020-12-01T00:04:00Z"), 83 | new Date("2020-12-01T00:05:00Z"), 84 | ]) 85 | }) 86 | 87 | it("iterates from the next 5 minutes", () => { 88 | let cron = new Cron("* * * * *"); 89 | let arr = []; 90 | let i = 0; 91 | let iter = cron.iterFrom(startDate); 92 | try { 93 | for (const date of iter) { 94 | arr.push(date); 95 | if (++i >= 5) { 96 | break; 97 | } 98 | } 99 | } finally { 100 | iter.free(); 101 | cron.free(); 102 | } 103 | 104 | expect(arr).toStrictEqual([ 105 | new Date("2020-12-01T00:00:00Z"), 106 | new Date("2020-12-01T00:01:00Z"), 107 | new Date("2020-12-01T00:02:00Z"), 108 | new Date("2020-12-01T00:03:00Z"), 109 | new Date("2020-12-01T00:04:00Z"), 110 | ]) 111 | }) 112 | -------------------------------------------------------------------------------- /saffron-web/src/lib.rs: -------------------------------------------------------------------------------- 1 | use chrono::prelude::*; 2 | use js_sys::{Array as JsArray, Date as JsDate, JsString}; 3 | use saffron::parse::{CronExpr, English}; 4 | use saffron::{Cron, CronTimesIter}; 5 | use wasm_bindgen::prelude::*; 6 | 7 | fn chrono_to_js_date(date: DateTime) -> JsDate { 8 | let js_millis = JsValue::from_f64(date.timestamp_millis() as f64); 9 | JsDate::new(&js_millis) 10 | } 11 | 12 | /// @private 13 | #[wasm_bindgen] 14 | #[derive(Clone, Debug)] 15 | pub struct WasmCron { 16 | inner: Cron, 17 | } 18 | 19 | #[wasm_bindgen] 20 | impl WasmCron { 21 | #[wasm_bindgen(constructor)] 22 | pub fn new(s: &str) -> Result { 23 | s.parse() 24 | .map(|inner| Self { inner }) 25 | .map_err(|e| JsString::from(e.to_string()).into()) 26 | } 27 | 28 | #[wasm_bindgen(js_name = parseAndDescribe)] 29 | pub fn parse_and_describe(s: &str) -> Result { 30 | s.parse() 31 | .map(move |expr: CronExpr| { 32 | let description = expr.describe(English::default()).to_string(); 33 | let cron = Self { 34 | inner: Cron::new(expr), 35 | }; 36 | 37 | let array = JsArray::new_with_length(2); 38 | array.set(0, cron.into()); 39 | array.set(1, JsString::from(description).into()); 40 | array 41 | }) 42 | .map_err(|e| JsString::from(e.to_string()).into()) 43 | } 44 | 45 | pub fn any(&self) -> bool { 46 | self.inner.any() 47 | } 48 | 49 | pub fn contains(&self, date: JsDate) -> bool { 50 | self.inner.contains(date.into()) 51 | } 52 | 53 | #[wasm_bindgen(js_name = nextFrom)] 54 | pub fn next_from(&self, date: JsDate) -> Option { 55 | self.inner.next_from(date.into()).map(chrono_to_js_date) 56 | } 57 | 58 | #[wasm_bindgen(js_name = nextAfter)] 59 | pub fn next_after(&self, date: JsDate) -> Option { 60 | self.inner.next_after(date.into()).map(chrono_to_js_date) 61 | } 62 | } 63 | 64 | // Build a iter type that just returns an optional Date on next. 65 | // This doesn't conform to iterator standards, but we can't build 66 | // a conformant iterator with wasm anyway, so let's just export what 67 | // we need to do it fast and build our iterator type in js. 68 | 69 | /// @private 70 | #[wasm_bindgen] 71 | pub struct WasmCronTimesIter { 72 | inner: CronTimesIter, 73 | } 74 | 75 | #[wasm_bindgen] 76 | impl WasmCronTimesIter { 77 | #[wasm_bindgen(js_name = startFrom)] 78 | pub fn start_from(cron: &WasmCron, date: JsDate) -> Self { 79 | Self { 80 | inner: cron.inner.clone().iter_from(date.into()), 81 | } 82 | } 83 | 84 | #[wasm_bindgen(js_name = startAfter)] 85 | pub fn start_after(cron: &WasmCron, date: JsDate) -> Self { 86 | Self { 87 | inner: cron.inner.clone().iter_after(date.into()), 88 | } 89 | } 90 | 91 | #[allow(clippy::should_implement_trait)] 92 | pub fn next(&mut self) -> Option { 93 | self.inner.next().map(chrono_to_js_date) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /saffron-c/include/saffron.h: -------------------------------------------------------------------------------- 1 | #ifndef SAFFRON_H 2 | #define SAFFRON_H 3 | 4 | #pragma once 5 | 6 | #include 7 | #include 8 | #include 9 | 10 | /** 11 | * A cron value managed by Rust. 12 | * 13 | * Created with a UTF-8 string using `saffron_cron_parse`. Freed using `saffron_cron_free`. 14 | */ 15 | typedef struct Cron Cron; 16 | 17 | /** 18 | * A future times iterator managed by Rust. 19 | * 20 | * Created with an existing cron value using `saffron_cron_iter_from` or `saffron_cron_iter_after`. 21 | * Freed using `saffron_cron_iter_free`. 22 | */ 23 | typedef struct CronTimesIter CronTimesIter; 24 | 25 | #ifdef __cplusplus 26 | extern "C" { 27 | #endif // __cplusplus 28 | 29 | /** 30 | * Parses a UTF-8 string `s` with length `l` (without a null terminator) into a Cron value. 31 | * Returns null if: 32 | * 33 | * * `s` is null, 34 | * 35 | * * `s` is not valid UTF-8, 36 | * 37 | * * `s` is not a valid cron expression, 38 | */ 39 | const struct Cron *saffron_cron_parse(const char *s, size_t l); 40 | 41 | /** 42 | * Frees a previously created cron value. 43 | */ 44 | void saffron_cron_free(const struct Cron *c); 45 | 46 | /** 47 | * Returns a bool indicating if the cron value contains any matching times. 48 | */ 49 | bool saffron_cron_any(const struct Cron *c); 50 | 51 | /** 52 | * Returns a bool indicating if the cron value contains the given time in UTC non-leap seconds 53 | * since January 1st, 1970, 00:00:00. 54 | * 55 | * The valid range for `s` is -8334632851200 <= `s` <= 8210298412799. 56 | */ 57 | bool saffron_cron_contains(const struct Cron *c, int64_t s); 58 | 59 | /** 60 | * Gets the next matching time in the cron value starting from the given time in UTC non-leap 61 | * seconds `s`. Returns a bool indicating if a next time exists, inserting the new timestamp into `s`. 62 | * 63 | * The valid range for `s` is -8334632851200 <= `s` <= 8210298412799. 64 | */ 65 | bool saffron_cron_next_from(const struct Cron *c, 66 | int64_t *s); 67 | 68 | /** 69 | * Gets the next matching time in the cron value after the given time in UTC non-leap seconds `s`. 70 | * Returns a bool indicating if a next time exists, inserting the new timestamp into `s`. 71 | * 72 | * The valid range for `s` is -8334632851200 <= `s` <= 8210298412799. 73 | */ 74 | bool saffron_cron_next_after(const struct Cron *c, int64_t *s); 75 | 76 | /** 77 | * Returns an iterator of future times starting from the specified timestamp `s` in UTC non-leap 78 | * seconds, or null if `s` is out of range of valid values. 79 | * 80 | * The valid range for `s` is -8334632851200 <= `s` <= 8210298412799. 81 | */ 82 | struct CronTimesIter *saffron_cron_iter_from(const struct Cron *c, int64_t s); 83 | 84 | /** 85 | * Returns an iterator of future times starting after the specified timestamp `s` in UTC non-leap 86 | * seconds, or null if `s` is out of range of valid values. 87 | * 88 | * The valid range for `s` is -8334632851200 <= `s` <= 8210298412799. 89 | */ 90 | struct CronTimesIter *saffron_cron_iter_after(const struct Cron *c, int64_t s); 91 | 92 | /** 93 | * Gets the next timestamp in an cron times iterator, writing it to `s`. Returns a bool indicating 94 | * if a next time was written to `s`. 95 | */ 96 | bool saffron_cron_iter_next(struct CronTimesIter *c, int64_t *s); 97 | 98 | /** 99 | * Frees a previously created cron times iterator value. 100 | */ 101 | void saffron_cron_iter_free(struct CronTimesIter *c); 102 | 103 | #ifdef __cplusplus 104 | } // extern "C" 105 | #endif // __cplusplus 106 | 107 | #endif /* SAFFRON_H */ 108 | -------------------------------------------------------------------------------- /saffron-worker/worker/worker.js: -------------------------------------------------------------------------------- 1 | const { describe, next, next_of_many, validate } = wasm_bindgen; 2 | 3 | function status(code, text) { 4 | return new Response(text, { status: code }); 5 | } 6 | 7 | function corsHeaders() { 8 | return { 9 | "Access-Control-Allow-Origin": "*", 10 | "Access-Control-Allow-Methods": ["POST", "OPTIONS"], 11 | "Access-Control-Allow-Headers": "Content-Type", 12 | "Access-Control-Max-Age": 86400 13 | } 14 | } 15 | 16 | function jsonResponseHeaders() { 17 | return { 18 | "Content-Type": "application/json", 19 | ...corsHeaders() 20 | } 21 | } 22 | 23 | function apiResponse(result, success, errors) { 24 | let json = JSON.stringify({ 25 | result, 26 | success, 27 | errors, 28 | messages: null, 29 | }); 30 | let status; 31 | if (success) { 32 | status = 200; 33 | } else { 34 | status = 400; 35 | } 36 | return new Response(json, { 37 | status, 38 | headers: jsonResponseHeaders(), 39 | }); 40 | } 41 | 42 | addEventListener('fetch', event => { 43 | event.respondWith(handleRequest(event.request).catch((e) => { 44 | if (env == "dev") { 45 | console.log(e.stack) 46 | return status(500, e.message || e.toString()); 47 | } else { 48 | return status(500, "Internal Server Error"); 49 | } 50 | })) 51 | }) 52 | 53 | async function handleRequest(request) { 54 | await wasm_bindgen(wasm); 55 | 56 | if (request.method == "OPTIONS") { 57 | return new Response(null, { 58 | status: 204, 59 | headers: corsHeaders(), 60 | }) 61 | } 62 | 63 | if (request.method != "POST") { 64 | return status(405, "Method Not Allowed"); 65 | } 66 | 67 | if (request.headers.get("Content-Type") != "application/json") { 68 | return status(400, "Bad Request"); 69 | } 70 | 71 | const path = new URL(request.url).pathname; 72 | switch (path) { 73 | case "/validate": { 74 | let body; 75 | try { 76 | body = await request.json() 77 | } catch (e) { 78 | return status(400, "Bad Request"); 79 | } 80 | let crons = body.crons; 81 | if (!Array.isArray(crons)) { 82 | return status(400, "Bad Request"); 83 | } 84 | 85 | let result = validate(crons).errors(); 86 | let success = result == null; 87 | return apiResponse({}, success, result); 88 | } 89 | case "/describe": { 90 | let body; 91 | try { 92 | body = await request.json() 93 | } catch (e) { 94 | return status(400, "Bad Request"); 95 | } 96 | let cron = body.cron; 97 | if (cron == null) { 98 | return status(400, "Bad Request"); 99 | } 100 | let result = describe(cron); 101 | let success = result.errors == null; 102 | return apiResponse(success ? { 103 | est_future_times: result.description.est_future_executions, 104 | description: result.description.text, 105 | } : {}, success, result.errors || null); 106 | } 107 | case "/next": { 108 | let body; 109 | try { 110 | body = await request.json() 111 | } catch (e) { 112 | return status(400, "Bad Request"); 113 | } 114 | let result; 115 | if (body.crons != null) { 116 | result = next_of_many(body.crons); 117 | } else if (body.cron != null) { 118 | result = next(body.cron); 119 | } else { 120 | return status(400, "Bad Request"); 121 | } 122 | let success = result.errors == null; 123 | return apiResponse(success ? { 124 | next: result.next 125 | } : {}, success, result.errors || null); 126 | } 127 | default: 128 | return status(404, "Not Found"); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /saffron-web/pkg/saffron.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * An iterator over all matching dates for a cron value starting at or after a specific date. 3 | */ 4 | export class CronTimesIter { 5 | /** 6 | * Frees the underlying wasm memory associated with this object. 7 | */ 8 | free(): void; 9 | /** 10 | * Gets the next matching time in the cron times iterator. 11 | * @returns {{ value: Date | undefined, done: boolean }} 12 | */ 13 | next(): { 14 | value: Date | undefined, 15 | done: boolean 16 | }; 17 | /** 18 | * Returns this instance. 19 | * @returns {CronTimesIter} 20 | */ 21 | [Symbol.iterator](): CronTimesIter; 22 | } 23 | 24 | /** 25 | * A parsed cron value. This can be used to check if a time matches the cron value or get an iterator 26 | * of all future times. 27 | */ 28 | export default class Cron { 29 | /** 30 | * Parses a cron expression into a cron value. 31 | * 32 | * @param {string} s The string value to parse 33 | * @throws If the string is not a valid cron expression 34 | */ 35 | constructor(s: string); 36 | /** 37 | * Parses a cron expression into a cron value and string description. 38 | * 39 | * @param {string} s The string value to parse 40 | * @returns {[Cron, string]} A cron value and a string description 41 | * @throws If the string is not a valid cron expression 42 | */ 43 | static parseAndDescribe(s: string): [Cron, string]; 44 | /** 45 | * Frees the underlying wasm memory associated with this object. 46 | */ 47 | free(): void; 48 | /** 49 | * Returns whether this cron value will match on any one time. 50 | * 51 | * If a cron value is used that only matches on a day of the month that's not contained in any 52 | * month specified, that cron value will technically be valid, but will never match a given time. 53 | * 54 | * @returns {boolean} `true` if the cron value contains at least one matching time, `false` otherwise 55 | * 56 | * @example 57 | * // returns true 58 | * new Cron("* * 29 2 *").any() 59 | * 60 | * // returns false, November doesn't have a 31st day 61 | * new Cron("* * 31 11 *").any() 62 | */ 63 | any(): boolean; 64 | /** 65 | * Returns whether this cron value matches on the specified date. 66 | * @param {Date} date The date to check 67 | * @returns {boolean} `true` if the cron value matches on this date, `false` otherwise 68 | */ 69 | contains(date: Date): boolean; 70 | /** 71 | * Returns the next matching date starting from the given date. This includes the date given, 72 | * which will have seconds truncated if the minute matches the cron value. 73 | * 74 | * @param {Date} date The start date 75 | * @returns {Date | undefined} The next matching date starting from the start date, or `undefined` 76 | * if no date was found. 77 | */ 78 | nextFrom(date: Date): Date | undefined; 79 | /** 80 | * Returns the next matching date starting after the given date. 81 | * 82 | * @param {Date} date The start date 83 | * @returns {Date | undefined} The next matching date after the start date, or `undefined` if no 84 | * date was found. 85 | */ 86 | nextAfter(date: Date): Date | undefined; 87 | /** 88 | * Returns an iterator of all times starting at the specified date. 89 | * @param {Date} date The date to start the iterator from 90 | * @returns {CronTimesIter} An iterator of all times starting at the specified date 91 | */ 92 | iterFrom(date: Date): CronTimesIter; 93 | /** 94 | * Returns an iterator of all times starting after the specified date. 95 | * @param {Date} date The date to start the iterator after 96 | * @returns {CronTimesIter} An iterator of all times starting after the specified date 97 | */ 98 | iterAfter(date: Date): CronTimesIter; 99 | } -------------------------------------------------------------------------------- /saffron-web/pkg/saffron.js: -------------------------------------------------------------------------------- 1 | import * as _ from "./saffron_bg.wasm"; // unused because the wasm/js story sucks: https://github.com/rustwasm/wasm-bindgen/pull/2110 2 | import { WasmCron, WasmCronTimesIter } from "./saffron_bg.js"; 3 | 4 | /** 5 | * An iterator over all matching dates for a cron value starting at or after a specific date. 6 | */ 7 | export class CronTimesIter { 8 | /** @private */ 9 | static __wrap(iter) { 10 | const obj = Object.create(CronTimesIter.prototype); 11 | obj.iter = iter; 12 | 13 | return obj; 14 | } 15 | 16 | /** 17 | * Frees the underlying wasm memory associated with this object. 18 | */ 19 | free() { 20 | const iter = this.iter; 21 | this.iter = null; 22 | 23 | iter.free(); 24 | } 25 | 26 | /** 27 | * Gets the next matching time in the cron times iterator. 28 | * @returns {{ value: Date | undefined, done: boolean }} 29 | */ 30 | next() { 31 | const next = this.iter.next(); 32 | return { 33 | value: next, 34 | done: next == null, 35 | }; 36 | } 37 | 38 | /** 39 | * Returns this instance. 40 | * @returns {CronTimesIter} 41 | */ 42 | [Symbol.iterator]() { 43 | return this; 44 | } 45 | } 46 | 47 | /** 48 | * A parsed cron value. This can be used to check if a time matches the cron value or get an iterator 49 | * of all future times. 50 | */ 51 | export default class Cron { 52 | /** 53 | * Parses a cron expression into a cron value. 54 | * 55 | * @param {string} s The string value to parse 56 | * @throws If the string is not a valid cron expression 57 | */ 58 | constructor(s) { 59 | this.value = new WasmCron(s); 60 | } 61 | 62 | /** 63 | * Parses a cron expression into a cron value and string description. 64 | * 65 | * @param {string} s The string value to parse 66 | * @returns {[Cron, string]} A cron value and a string description 67 | * @throws If the string is not a valid cron expression 68 | */ 69 | static parseAndDescribe(s) { 70 | let [cron, description] = WasmCron.parseAndDescribe(s); 71 | 72 | const obj = Object.create(Cron.prototype); 73 | obj.value = cron; 74 | 75 | return [obj, description]; 76 | } 77 | 78 | /** 79 | * Frees the underlying wasm memory associated with this object. 80 | */ 81 | free() { 82 | const value = this.value; 83 | this.value = null; 84 | 85 | value.free(); 86 | } 87 | 88 | /** 89 | * Returns whether this cron value will match on any one time. 90 | * 91 | * If a cron value is used that only matches on a day of the month that's not contained in any 92 | * month specified, that cron value will technically be valid, but will never match a given time. 93 | * 94 | * @returns {boolean} `true` if the cron value contains at least one matching time, `false` otherwise 95 | * 96 | * @example 97 | * // returns true 98 | * new Cron("* * 29 2 *").any() 99 | * 100 | * // returns false, November doesn't have a 31st day 101 | * new Cron("* * 31 11 *").any() 102 | */ 103 | any() { 104 | return this.value.any(); 105 | } 106 | 107 | /** 108 | * Returns whether this cron value matches on the specified date. 109 | * @param {Date} date The date to check 110 | * @returns {boolean} `true` if the cron value matches on this date, `false` otherwise 111 | */ 112 | contains(date) { 113 | return this.value.contains(date); 114 | } 115 | 116 | /** 117 | * Returns the next matching date starting from the given date. This includes the date given, 118 | * which will have seconds truncated if the minute matches the cron value. 119 | * 120 | * @param {Date} date The start date 121 | * @returns {Date | undefined} The next matching date starting from the start date, or `undefined` 122 | * if no date was found. 123 | */ 124 | nextFrom(date) { 125 | return this.value.nextFrom(date); 126 | } 127 | 128 | /** 129 | * Returns the next matching date starting after the given date. 130 | * 131 | * @param {Date} date The start date 132 | * @returns {Date | undefined} The next matching date after the start date, or `undefined` if no 133 | * date was found. 134 | */ 135 | nextAfter(date) { 136 | return this.value.nextAfter(date); 137 | } 138 | 139 | /** 140 | * Returns an iterator of all times starting at the specified date. 141 | * @param {Date} date The date to start the iterator from 142 | * @returns {CronTimesIter} An iterator of all times starting at the specified date 143 | */ 144 | iterFrom(date) { 145 | const iter = WasmCronTimesIter.startFrom(this.value, date); 146 | return CronTimesIter.__wrap(iter); 147 | } 148 | 149 | /** 150 | * Returns an iterator of all times starting after the specified date. 151 | * @param {Date} date The date to start the iterator after 152 | * @returns {CronTimesIter} An iterator of all times starting after the specified date 153 | */ 154 | iterAfter(date) { 155 | const iter = WasmCronTimesIter.startAfter(this.value, date); 156 | return CronTimesIter.__wrap(iter); 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /saffron-c/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(clippy::missing_safety_doc)] 2 | 3 | use chrono::prelude::*; 4 | use libc::{c_char, size_t}; 5 | use std::ptr; 6 | 7 | /// A cron value managed by Rust. 8 | /// 9 | /// Created with a UTF-8 string using `saffron_cron_parse`. Freed using `saffron_cron_free`. 10 | pub struct Cron(saffron::Cron); 11 | 12 | /// A future times iterator managed by Rust. 13 | /// 14 | /// Created with an existing cron value using `saffron_cron_iter_from` or `saffron_cron_iter_after`. 15 | /// Freed using `saffron_cron_iter_free`. 16 | pub struct CronTimesIter(saffron::CronTimesIter); 17 | 18 | fn box_it(val: T) -> *mut T { 19 | Box::into_raw(val.into()) 20 | } 21 | 22 | unsafe fn rebox_it(ptr: *mut T) -> Box { 23 | Box::from_raw(ptr) 24 | } 25 | 26 | /// Parses a UTF-8 string `s` with length `l` (without a null terminator) into a Cron value. 27 | /// Returns null if: 28 | /// 29 | /// * `s` is null, 30 | /// 31 | /// * `s` is not valid UTF-8, 32 | /// 33 | /// * `s` is not a valid cron expression, 34 | #[no_mangle] 35 | pub unsafe extern "C" fn saffron_cron_parse(s: *const c_char, l: size_t) -> *const Cron { 36 | if s.is_null() { 37 | return ptr::null(); 38 | } 39 | 40 | let slice = std::slice::from_raw_parts(s as *const u8, l); 41 | let string = match std::str::from_utf8(slice) { 42 | Ok(s) => s, 43 | Err(_) => return ptr::null(), 44 | }; 45 | 46 | match string.parse() { 47 | Ok(cron) => box_it(Cron(cron)) as _, 48 | Err(_) => ptr::null(), 49 | } 50 | } 51 | 52 | /// Frees a previously created cron value. 53 | #[no_mangle] 54 | pub unsafe extern "C" fn saffron_cron_free(c: *const Cron) { 55 | drop(rebox_it(c as *mut Cron)) 56 | } 57 | 58 | /// Returns a bool indicating if the cron value contains any matching times. 59 | #[no_mangle] 60 | pub unsafe extern "C" fn saffron_cron_any(c: *const Cron) -> bool { 61 | (*c).0.any() 62 | } 63 | 64 | /// Returns a bool indicating if the cron value contains the given time in UTC non-leap seconds 65 | /// since January 1st, 1970, 00:00:00. 66 | /// 67 | /// The valid range for `s` is -8334632851200 <= `s` <= 8210298412799. 68 | #[no_mangle] 69 | pub unsafe extern "C" fn saffron_cron_contains(c: *const Cron, s: i64) -> bool { 70 | let cron = &*c; 71 | if let Some(time) = Utc.timestamp_opt(s, 0).single() { 72 | cron.0.contains(time) 73 | } else { 74 | false 75 | } 76 | } 77 | 78 | /// Gets the next matching time in the cron value starting from the given time in UTC non-leap 79 | /// seconds `s`. Returns a bool indicating if a next time exists, inserting the new timestamp into `s`. 80 | /// 81 | /// The valid range for `s` is -8334632851200 <= `s` <= 8210298412799. 82 | #[no_mangle] 83 | pub unsafe extern "C" fn saffron_cron_next_from(c: *const Cron, s: *mut i64) -> bool { 84 | let cron = &*c; 85 | if let Some(time) = Utc 86 | .timestamp_opt(*s, 0) 87 | .single() 88 | .and_then(|time| cron.0.next_from(time)) 89 | { 90 | *s = time.timestamp(); 91 | true 92 | } else { 93 | false 94 | } 95 | } 96 | 97 | /// Gets the next matching time in the cron value after the given time in UTC non-leap seconds `s`. 98 | /// Returns a bool indicating if a next time exists, inserting the new timestamp into `s`. 99 | /// 100 | /// The valid range for `s` is -8334632851200 <= `s` <= 8210298412799. 101 | #[no_mangle] 102 | pub unsafe extern "C" fn saffron_cron_next_after(c: *const Cron, s: *mut i64) -> bool { 103 | let cron = &*c; 104 | if let Some(time) = Utc 105 | .timestamp_opt(*s, 0) 106 | .single() 107 | .and_then(|time| cron.0.next_after(time)) 108 | { 109 | *s = time.timestamp(); 110 | true 111 | } else { 112 | false 113 | } 114 | } 115 | 116 | /// Returns an iterator of future times starting from the specified timestamp `s` in UTC non-leap 117 | /// seconds, or null if `s` is out of range of valid values. 118 | /// 119 | /// The valid range for `s` is -8334632851200 <= `s` <= 8210298412799. 120 | #[no_mangle] 121 | pub unsafe extern "C" fn saffron_cron_iter_from(c: *const Cron, s: i64) -> *mut CronTimesIter { 122 | let cron = &*c; 123 | if let Some(time) = Utc.timestamp_opt(s, 0).single() { 124 | box_it(CronTimesIter(cron.0.clone().iter_from(time))) 125 | } else { 126 | ptr::null_mut() 127 | } 128 | } 129 | 130 | /// Returns an iterator of future times starting after the specified timestamp `s` in UTC non-leap 131 | /// seconds, or null if `s` is out of range of valid values. 132 | /// 133 | /// The valid range for `s` is -8334632851200 <= `s` <= 8210298412799. 134 | #[no_mangle] 135 | pub unsafe extern "C" fn saffron_cron_iter_after(c: *const Cron, s: i64) -> *mut CronTimesIter { 136 | let cron = &*c; 137 | if let Some(time) = Utc.timestamp_opt(s, 0).single() { 138 | box_it(CronTimesIter(cron.0.clone().iter_after(time))) 139 | } else { 140 | ptr::null_mut() 141 | } 142 | } 143 | 144 | /// Gets the next timestamp in an cron times iterator, writing it to `s`. Returns a bool indicating 145 | /// if a next time was written to `s`. 146 | #[no_mangle] 147 | pub unsafe extern "C" fn saffron_cron_iter_next(c: *mut CronTimesIter, s: *mut i64) -> bool { 148 | match (*c).0.next() { 149 | Some(time) => { 150 | *s = time.timestamp(); 151 | true 152 | } 153 | None => false, 154 | } 155 | } 156 | 157 | /// Frees a previously created cron times iterator value. 158 | #[no_mangle] 159 | pub unsafe extern "C" fn saffron_cron_iter_free(c: *mut CronTimesIter) { 160 | drop(rebox_it(c)) 161 | } 162 | -------------------------------------------------------------------------------- /saffron-worker/src/lib.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use js_sys::{Array as JsArray, Date as JsDate, JsString}; 3 | use saffron::parse::{CronExpr, English}; 4 | use saffron::Cron; 5 | use wasm_bindgen::prelude::*; 6 | 7 | use std::collections::HashMap; 8 | 9 | #[wasm_bindgen] 10 | extern "C" { 11 | static env: String; 12 | } 13 | 14 | fn set_panic_hook() { 15 | if *env == "dev" { 16 | console_error_panic_hook::set_once(); 17 | } 18 | } 19 | 20 | #[wasm_bindgen] 21 | #[derive(Clone, Debug)] 22 | pub struct Description { 23 | text: String, 24 | est_future_executions: Vec>, 25 | } 26 | 27 | #[wasm_bindgen] 28 | impl Description { 29 | #[wasm_bindgen(getter)] 30 | pub fn text(&self) -> JsString { 31 | JsString::from(self.text.as_str()) 32 | } 33 | 34 | #[wasm_bindgen(getter)] 35 | pub fn est_future_executions(&self) -> JsArray { 36 | self.est_future_executions 37 | .iter() 38 | .copied() 39 | .map(JsDate::from) 40 | .collect() 41 | } 42 | } 43 | 44 | #[wasm_bindgen] 45 | #[derive(Clone, Debug, Default)] 46 | pub struct DescriptionResult { 47 | description: Option, 48 | errors: Option>, 49 | } 50 | 51 | #[wasm_bindgen] 52 | impl DescriptionResult { 53 | #[wasm_bindgen(getter)] 54 | pub fn errors(&self) -> Option { 55 | self.errors 56 | .as_ref() 57 | .map(|lst| lst.iter().map(JsValue::from).collect()) 58 | } 59 | 60 | #[wasm_bindgen(getter)] 61 | pub fn description(&self) -> JsValue { 62 | JsValue::from(self.description.clone()) 63 | } 64 | } 65 | 66 | /// Describes a given cron string. Used for live cron previews on the dash if wasm isn't available. 67 | #[wasm_bindgen] 68 | pub fn describe(cron: &str) -> DescriptionResult { 69 | set_panic_hook(); 70 | 71 | match cron.parse::() { 72 | Ok(expr) => { 73 | let description = expr.describe(English::default()).to_string(); 74 | let compiled = Cron::new(expr); 75 | let est_future_executions = compiled.iter_from(Utc::now()).take(5).collect(); 76 | 77 | DescriptionResult { 78 | description: Some(Description { 79 | text: description, 80 | est_future_executions, 81 | }), 82 | ..DescriptionResult::default() 83 | } 84 | } 85 | Err(err) => DescriptionResult { 86 | errors: Some(vec![format!("{}", err)]), 87 | ..DescriptionResult::default() 88 | }, 89 | } 90 | } 91 | 92 | #[wasm_bindgen] 93 | #[derive(Clone, Debug)] 94 | pub struct ValidationResult { 95 | errors: Option>, 96 | } 97 | 98 | #[wasm_bindgen] 99 | impl ValidationResult { 100 | #[wasm_bindgen] 101 | pub fn errors(&self) -> Option { 102 | self.errors 103 | .as_ref() 104 | .map(|lst| lst.iter().map(JsValue::from).collect()) 105 | } 106 | } 107 | 108 | /// Validates multiple strings. This checks for duplicate expressions and makes sure all expressions 109 | /// can properly compile. The Cloudflare API will perform this check as well. 110 | #[wasm_bindgen] 111 | pub fn validate(crons: JsArray) -> ValidationResult { 112 | set_panic_hook(); 113 | 114 | let len = crons.length(); 115 | let mut map = HashMap::with_capacity(len as usize); 116 | for i in 0..len { 117 | let string = match crons.get(i).as_string() { 118 | Some(string) => string, 119 | None => { 120 | return ValidationResult { 121 | errors: Some(vec![format!("Element '{}' is not a string", i)]), 122 | } 123 | } 124 | }; 125 | 126 | let cron: Cron = match string.parse() { 127 | Ok(cron) => cron, 128 | Err(err) => { 129 | return ValidationResult { 130 | errors: Some(vec![format!( 131 | "Failed to parse expression at index '{}': {}", 132 | i, err 133 | )]), 134 | } 135 | } 136 | }; 137 | 138 | if let Some(old_str) = map.insert(cron, string.clone()) { 139 | return ValidationResult { 140 | errors: Some(vec![format!( 141 | "Expression '{}' already exists in the form of '{}'", 142 | string, old_str 143 | )]), 144 | }; 145 | } 146 | } 147 | 148 | ValidationResult { errors: None } 149 | } 150 | 151 | #[wasm_bindgen] 152 | #[derive(Clone, Debug, Default)] 153 | pub struct NextResult { 154 | next: Option>, 155 | errors: Option>, 156 | } 157 | 158 | #[wasm_bindgen] 159 | impl NextResult { 160 | #[wasm_bindgen(getter)] 161 | pub fn errors(&self) -> Option { 162 | self.errors 163 | .as_ref() 164 | .map(|lst| lst.iter().map(JsValue::from).collect()) 165 | } 166 | 167 | #[wasm_bindgen(getter)] 168 | pub fn next(&self) -> Option { 169 | self.next.map(JsDate::from) 170 | } 171 | } 172 | 173 | #[wasm_bindgen] 174 | pub fn next(cron: &str) -> NextResult { 175 | set_panic_hook(); 176 | 177 | match cron.parse::() { 178 | Ok(expr) => NextResult { 179 | next: expr.next_from(Utc::now()), 180 | ..NextResult::default() 181 | }, 182 | Err(err) => NextResult { 183 | errors: Some(vec![err.to_string()]), 184 | ..NextResult::default() 185 | }, 186 | } 187 | } 188 | 189 | #[wasm_bindgen] 190 | pub fn next_of_many(crons: JsArray) -> NextResult { 191 | set_panic_hook(); 192 | 193 | let now = Utc::now(); 194 | let mut next = None; 195 | for (i, value) in (0..crons.length()).map(|i| (i, crons.get(i))) { 196 | if let Some(string) = value.as_string() { 197 | match string.parse::() { 198 | Ok(expr) => { 199 | if let Some(expr_next) = expr.next_from(now) { 200 | match &mut next { 201 | Some(next) => *next = std::cmp::min(*next, expr_next), 202 | next @ None => *next = Some(expr_next), 203 | } 204 | } 205 | } 206 | Err(err) => { 207 | return NextResult { 208 | errors: Some(vec![err.to_string()]), 209 | ..NextResult::default() 210 | } 211 | } 212 | } 213 | } else { 214 | return NextResult { 215 | errors: Some(vec![format!("Element '{}' is not a string", i)]), 216 | ..NextResult::default() 217 | }; 218 | } 219 | } 220 | 221 | NextResult { 222 | next, 223 | ..NextResult::default() 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /saffron/src/describe/english.rs: -------------------------------------------------------------------------------- 1 | use crate::describe::{display, Language}; 2 | use crate::parse::*; 3 | use chrono::NaiveTime; 4 | use core::fmt::{self, Display, Formatter}; 5 | 6 | fn postfixed>(x: T) -> impl Display { 7 | let x: usize = x.into(); 8 | display(move |f| match x % 100 { 9 | 1 => write!(f, "{}st", x), 10 | 2 => write!(f, "{}nd", x), 11 | 3 => write!(f, "{}rd", x), 12 | 20..=99 => match x % 10 { 13 | 1 => write!(f, "{}st", x), 14 | 2 => write!(f, "{}nd", x), 15 | 3 => write!(f, "{}rd", x), 16 | _ => write!(f, "{}th", x), 17 | }, 18 | _ => write!(f, "{}th", x), 19 | }) 20 | } 21 | 22 | fn weekday>(x: T) -> impl Display { 23 | use chrono::Weekday::*; 24 | let x: chrono::Weekday = x.into(); 25 | display(move |f| match x { 26 | Mon => write!(f, "Monday"), 27 | Tue => write!(f, "Tuesday"), 28 | Wed => write!(f, "Wednesday"), 29 | Thu => write!(f, "Thursday"), 30 | Fri => write!(f, "Friday"), 31 | Sat => write!(f, "Saturday"), 32 | Sun => write!(f, "Sunday"), 33 | }) 34 | } 35 | 36 | /// Specifies whether to display times with a 12 hour or 24 hour clock. 37 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 38 | pub enum HourFormat { 39 | /// Format using a 12 hour clock (i.e. 6:30 PM) 40 | Hour12, 41 | /// Format using a 24 hour clock (i.e. 18:30) 42 | Hour24, 43 | } 44 | 45 | impl Default for HourFormat { 46 | fn default() -> Self { 47 | HourFormat::Hour12 48 | } 49 | } 50 | 51 | /// English language formatting 52 | #[derive(Clone, Debug)] 53 | #[non_exhaustive] 54 | pub struct English { 55 | /// Configures how hours are formatted in descriptions 56 | pub hour: HourFormat, 57 | } 58 | 59 | impl English { 60 | /// Creates a new instance of the english configuration with its default values 61 | pub const fn new() -> Self { 62 | Self { 63 | hour: HourFormat::Hour12, 64 | } 65 | } 66 | } 67 | 68 | impl Default for English { 69 | fn default() -> Self { 70 | Self::new() 71 | } 72 | } 73 | 74 | impl English { 75 | fn minute(&self, h: OrsExpr) -> impl Display { 76 | display(move |f| match h { 77 | OrsExpr::One(minute) => write!(f, "{}", u8::from(minute)), 78 | OrsExpr::Range(start, end) => { 79 | write!(f, "{} through {}", u8::from(start), u8::from(end)) 80 | } 81 | OrsExpr::Step { start, end, step } => write!( 82 | f, 83 | "every {} minute from {} through {}", 84 | postfixed(u8::from(step)), 85 | u8::from(start), 86 | u8::from(end) 87 | ), 88 | }) 89 | } 90 | fn hour<'a>(&'a self, h: OrsExpr) -> impl Display + 'a { 91 | display(move |f| match h { 92 | OrsExpr::One(hour) => write!( 93 | f, 94 | "between {} and {}", 95 | self.time(hour, 0), 96 | self.time(hour, 59) 97 | ), 98 | OrsExpr::Range(start, end) => write!( 99 | f, 100 | "between {} and {}", 101 | self.time(start, 0), 102 | self.time(end, 59) 103 | ), 104 | OrsExpr::Step { start, end, step } => write!( 105 | f, 106 | "every {} hour between {} and {}", 107 | postfixed(u8::from(step)), 108 | self.time(start, 0), 109 | self.time(end, 59) 110 | ), 111 | }) 112 | } 113 | fn month(&self, h: OrsExpr) -> impl Display { 114 | display(move |f| match h { 115 | OrsExpr::One(month) => write!(f, "{}", chrono::Month::from(month).name()), 116 | OrsExpr::Range(start, end) => write!( 117 | f, 118 | "{} to {}", 119 | chrono::Month::from(start).name(), 120 | chrono::Month::from(end).name() 121 | ), 122 | OrsExpr::Step { start, end, step } => write!( 123 | f, 124 | "every {} month from {} to {}", 125 | postfixed(u8::from(step)), 126 | chrono::Month::from(start).name(), 127 | chrono::Month::from(end).name() 128 | ), 129 | }) 130 | } 131 | fn day_of_week(&self, h: OrsExpr) -> impl Display { 132 | display(move |f| match h { 133 | OrsExpr::One(dow) => write!(f, "{}", weekday(dow)), 134 | OrsExpr::Range(start, end) => write!(f, "{} through {}", weekday(start), weekday(end)), 135 | OrsExpr::Step { start, end, step } => write!( 136 | f, 137 | "every {} weekday {} through {}", 138 | postfixed(u8::from(step)), 139 | weekday(start), 140 | weekday(end) 141 | ), 142 | }) 143 | } 144 | fn day_of_month(&self, h: OrsExpr) -> impl Display { 145 | display(move |f| match h { 146 | OrsExpr::One(dom) => write!(f, "{}", postfixed(u8::from(dom) + 1)), 147 | OrsExpr::Range(start, end) => write!( 148 | f, 149 | "{} to {}", 150 | postfixed(u8::from(start) + 1), 151 | postfixed(u8::from(end) + 1) 152 | ), 153 | OrsExpr::Step { start, end, step } => write!( 154 | f, 155 | "every {} day from the {} to the {}", 156 | postfixed(u8::from(step)), 157 | postfixed(u8::from(start) + 1), 158 | postfixed(u8::from(end) + 1) 159 | ), 160 | }) 161 | } 162 | fn time, M: Into>(&self, hour: H, minute: M) -> impl Display { 163 | let time = NaiveTime::from_hms(hour.into() as u32, minute.into() as u32, 0); 164 | let fmt = match self.hour { 165 | HourFormat::Hour12 => "%-I:%M %p", 166 | HourFormat::Hour24 => "%H:%M", 167 | }; 168 | time.format(fmt) 169 | } 170 | } 171 | impl Language for English { 172 | fn fmt_expr(&self, expr: &CronExpr, f: &mut Formatter) -> fmt::Result { 173 | match (&expr.minutes, &expr.hours) { 174 | (Expr::All, Expr::All) => write!(f, "Every minute")?, 175 | (Expr::All, Expr::Many(Exprs { first, tail })) => { 176 | let first = first.normalize(); 177 | write!(f, "Every minute ")?; 178 | match tail.as_slice() { 179 | [] => write!(f, "{}", self.hour(first))?, 180 | [second] => write!( 181 | f, 182 | "{} and {}", 183 | self.hour(first), 184 | self.hour(second.normalize()) 185 | )?, 186 | [middle @ .., last] => { 187 | write!(f, "{}, ", self.hour(first))?; 188 | for expr in middle { 189 | write!(f, "{}, ", self.hour(expr.normalize()))?; 190 | } 191 | write!(f, "and {}", self.hour(last.normalize()))?; 192 | } 193 | } 194 | } 195 | (Expr::Many(Exprs { first, tail }), Expr::All) => { 196 | let first = first.normalize(); 197 | match tail.as_slice() { 198 | [] => match first { 199 | OrsExpr::One(value) => match u8::from(value) { 200 | 0 => write!(f, "Every hour"), 201 | 1 => write!(f, "At 1 minute past the hour"), 202 | v => write!(f, "At {} minutes past the hour", v), 203 | }?, 204 | OrsExpr::Range(start, end) => write!( 205 | f, 206 | "Minutes {} through {} past the hour", 207 | u8::from(start), 208 | u8::from(end) 209 | )?, 210 | OrsExpr::Step { start, end, step } => write!( 211 | f, 212 | "Every {} minute starting from minute {} to minute {} past the hour", 213 | postfixed(u8::from(step)), 214 | u8::from(start), 215 | u8::from(end), 216 | )?, 217 | }, 218 | [second] => write!( 219 | f, 220 | "At {} and {} minutes past the hour", 221 | self.minute(first), 222 | self.minute(second.normalize()) 223 | )?, 224 | [middle @ .., last] => { 225 | write!(f, "At {}, ", self.minute(first))?; 226 | for expr in middle { 227 | write!(f, "{}, ", self.minute(expr.normalize()))?; 228 | } 229 | write!( 230 | f, 231 | "and {} minutes past the hour", 232 | self.minute(last.normalize()) 233 | )?; 234 | } 235 | } 236 | } 237 | ( 238 | Expr::Many(Exprs { 239 | first: first_minute, 240 | tail: tail_minutes, 241 | }), 242 | Expr::Many(Exprs { 243 | first: first_hour, 244 | tail: tail_hours, 245 | }), 246 | ) => { 247 | let first_minute = first_minute.normalize(); 248 | let tail_minutes = tail_minutes.as_slice(); 249 | let first_hour = first_hour.normalize(); 250 | let tail_hours = tail_hours.as_slice(); 251 | if let (OrsExpr::One(minute), [], OrsExpr::One(hour), []) = 252 | (first_minute, tail_minutes, first_hour, tail_hours) 253 | { 254 | write!(f, "At {}", self.time(hour, minute))?; 255 | } else { 256 | match tail_minutes { 257 | [] => write!( 258 | f, 259 | "At {} minutes past the hour, ", 260 | self.minute(first_minute) 261 | )?, 262 | [second] => write!( 263 | f, 264 | "At {} and {} minutes past the hour, ", 265 | self.minute(first_minute), 266 | self.minute(second.normalize()) 267 | )?, 268 | [middle @ .., last] => { 269 | write!(f, "At {}, ", self.minute(first_minute))?; 270 | for expr in middle { 271 | write!(f, "{}, ", self.minute(expr.normalize()))?; 272 | } 273 | write!(f, "and {}, ", self.minute(last.normalize()))?; 274 | } 275 | } 276 | 277 | match tail_hours { 278 | [] => write!(f, "{}", self.hour(first_hour))?, 279 | [second] => write!( 280 | f, 281 | "{} and {}", 282 | self.hour(first_hour), 283 | self.hour(second.normalize()) 284 | )?, 285 | [middle @ .., last] => { 286 | write!(f, "{}, ", self.hour(first_hour))?; 287 | for expr in middle { 288 | write!(f, "{}, ", self.hour(expr.normalize()))?; 289 | } 290 | write!(f, "and {}", self.hour(last.normalize()))?; 291 | } 292 | } 293 | } 294 | } 295 | } 296 | 297 | match &expr.doms { 298 | DayOfMonthExpr::All => {} 299 | &DayOfMonthExpr::ClosestWeekday(day) => write!( 300 | f, 301 | " on the closest weekday to the {}", 302 | postfixed(u8::from(day) + 1) 303 | )?, 304 | DayOfMonthExpr::Last(Last::Day) => write!(f, " on the last day")?, 305 | DayOfMonthExpr::Last(Last::Weekday) => write!(f, " on the last weekday")?, 306 | &DayOfMonthExpr::Last(Last::Offset(offset)) => { 307 | write!(f, " on the {} to last day", postfixed(u8::from(offset) + 1))? 308 | } 309 | &DayOfMonthExpr::Last(Last::OffsetWeekday(offset)) => write!( 310 | f, 311 | " on the closest weekday to the {} to last day", 312 | postfixed(u8::from(offset) + 1) 313 | )?, 314 | DayOfMonthExpr::Many(Exprs { first, tail }) => { 315 | let first = first.normalize(); 316 | match tail.as_slice() { 317 | [] => write!(f, " on the {}", self.day_of_month(first))?, 318 | [second] => write!( 319 | f, 320 | " on the {} and {}", 321 | self.day_of_month(first), 322 | self.day_of_month(second.normalize()) 323 | )?, 324 | [middle @ .., last] => { 325 | write!(f, " on the {}, ", self.day_of_month(first))?; 326 | for expr in middle { 327 | write!(f, "{}, ", self.day_of_month(expr.normalize()))?; 328 | } 329 | write!(f, "and {}", self.day_of_month(last.normalize()))?; 330 | } 331 | } 332 | } 333 | } 334 | 335 | match (&expr.doms, &expr.dows) { 336 | (DayOfMonthExpr::All, _) | (_, DayOfWeekExpr::All) => {} 337 | _ => write!(f, " and")?, 338 | } 339 | 340 | match &expr.dows { 341 | DayOfWeekExpr::All => {} 342 | &DayOfWeekExpr::Last(day) => write!(f, " on the last {}", weekday(day))?, 343 | &DayOfWeekExpr::Nth(day, nth) => { 344 | write!(f, " on the {} {}", postfixed(u8::from(nth)), weekday(day))? 345 | } 346 | DayOfWeekExpr::Many(Exprs { first, tail }) => { 347 | let first = first.normalize(); 348 | match tail.as_slice() { 349 | [] => write!(f, " on {}", self.day_of_week(first))?, 350 | [second] => write!( 351 | f, 352 | " on {} and {}", 353 | self.day_of_week(first), 354 | self.day_of_week(second.normalize()) 355 | )?, 356 | [middle @ .., last] => { 357 | write!(f, " on {}, ", self.day_of_week(first))?; 358 | for expr in middle { 359 | write!(f, "{}, ", self.day_of_week(expr.normalize()))?; 360 | } 361 | write!(f, "and {}", self.day_of_week(last.normalize()))?; 362 | } 363 | } 364 | } 365 | } 366 | 367 | let Exprs { first, tail } = match (&expr.doms, &expr.months, &expr.dows) { 368 | (DayOfMonthExpr::All, Expr::All, DayOfWeekExpr::All) 369 | | (DayOfMonthExpr::All, Expr::All, DayOfWeekExpr::Many(_)) => return Ok(()), 370 | (_, Expr::All, _) => { 371 | write!(f, " of every month")?; 372 | return Ok(()); 373 | } 374 | (DayOfMonthExpr::All, Expr::Many(exprs), DayOfWeekExpr::All) => { 375 | write!(f, " every day in ")?; 376 | exprs 377 | } 378 | (_, Expr::Many(exprs), _) => { 379 | write!(f, " of ")?; 380 | exprs 381 | } 382 | }; 383 | 384 | let first = first.normalize(); 385 | match tail.as_slice() { 386 | [] => write!(f, "{}", self.month(first))?, 387 | [second] => write!( 388 | f, 389 | "{} and {}", 390 | self.month(first), 391 | self.month(second.normalize()) 392 | )?, 393 | [middle @ .., last] => { 394 | write!(f, "{}, ", self.month(first))?; 395 | for expr in middle { 396 | write!(f, "{}, ", self.month(expr.normalize()))?; 397 | } 398 | write!(f, "and {}", self.month(last.normalize()))?; 399 | } 400 | } 401 | 402 | Ok(()) 403 | } 404 | } 405 | 406 | #[cfg(test)] 407 | mod tests { 408 | use super::*; 409 | 410 | #[cfg(not(feature = "std"))] 411 | use alloc::string::ToString; 412 | 413 | const CFG_24_HOURS: English = English { 414 | hour: HourFormat::Hour24, 415 | ..English::new() 416 | }; 417 | 418 | #[track_caller] 419 | fn assert_cfg(cfg: English, cron: &str, expected: &str) { 420 | let expr: CronExpr = cron.parse().expect("Valid cron expression"); 421 | let description = expr.describe(cfg).to_string(); 422 | 423 | assert_eq!(description, expected); 424 | } 425 | 426 | #[track_caller] 427 | fn assert(cron: &str, expected: &str) { 428 | let expr: CronExpr = cron.parse().expect("Valid cron expression"); 429 | let description = expr.describe(English::new()).to_string(); 430 | 431 | assert_eq!(description, expected); 432 | } 433 | 434 | #[test] 435 | fn time() { 436 | assert("* * * * *", "Every minute"); 437 | assert("0 * * * *", "Every hour"); 438 | assert("0 0 * * *", "At 12:00 AM"); 439 | assert_cfg(CFG_24_HOURS, "0 0 * * *", "At 00:00"); 440 | assert("0,1 * * * *", "At 0 and 1 minutes past the hour"); 441 | assert( 442 | "0,1-5,10-30/2 * * * *", 443 | "At 0, 1 through 5, and every 2nd minute from 10 through 30 minutes past the hour", 444 | ); 445 | assert( 446 | "0 2,3 * * *", 447 | "At 0 minutes past the hour, between 2:00 AM and 2:59 AM and between 3:00 AM and 3:59 AM", 448 | ); 449 | assert( 450 | "0 2,5-10,*/2 * * *", 451 | "At 0 minutes past the hour, between 2:00 AM and 2:59 AM, between 5:00 AM and 10:59 AM, and every 2nd hour between 12:00 AM and 11:59 PM", 452 | ); 453 | } 454 | 455 | #[test] 456 | fn day_of_month() { 457 | assert("* * L * *", "Every minute on the last day of every month"); 458 | assert( 459 | "* * LW * *", 460 | "Every minute on the last weekday of every month", 461 | ); 462 | assert( 463 | "* * L-1 * *", 464 | "Every minute on the 2nd to last day of every month", 465 | ); 466 | assert( 467 | "* * L-1W * *", 468 | "Every minute on the closest weekday to the 2nd to last day of every month", 469 | ); 470 | assert( 471 | "* * 15W * *", 472 | "Every minute on the closest weekday to the 15th of every month", 473 | ); 474 | assert("* * 15 * *", "Every minute on the 15th of every month"); 475 | assert( 476 | "* * 1,15 * *", 477 | "Every minute on the 1st and 15th of every month", 478 | ); 479 | assert( 480 | "* * 1,10-20,20/2 * *", 481 | "Every minute on the 1st, 10th to 20th, and every 2nd day from the 20th to the 31st of every month" 482 | ); 483 | } 484 | 485 | #[test] 486 | fn months() { 487 | assert("* * * FEB *", "Every minute every day in February"); 488 | assert( 489 | "* * * JAN,FEB *", 490 | "Every minute every day in January and February", 491 | ); 492 | assert( 493 | "* * * JAN,JUN-AUG,*/2 *", 494 | "Every minute every day in January, June to August, and every 2nd month from January to December" 495 | ); 496 | } 497 | 498 | #[test] 499 | fn complex() { 500 | // test some complex expressions with all fields filled 501 | assert( 502 | "0 0 LW */2 FRIL", 503 | "At 12:00 AM on the last weekday and on the last Friday of every 2nd month from January to December" 504 | ); 505 | assert( 506 | "0 0,12 L FEB FRI", 507 | "At 0 minutes past the hour, between 12:00 AM and 12:59 AM and between 12:00 PM and 12:59 PM on the last day and on Friday of February" 508 | ); 509 | } 510 | 511 | #[test] 512 | fn day_of_week() { 513 | assert( 514 | "* * * * MONL", 515 | "Every minute on the last Monday of every month", 516 | ); 517 | assert( 518 | "* * * * MON#5", 519 | "Every minute on the 5th Monday of every month", 520 | ); 521 | assert("* * * * MON", "Every minute on Monday"); 522 | assert("* * * * SUN,SAT", "Every minute on Sunday and Saturday"); 523 | assert("* * * * */3,SAT,MON-FRI", "Every minute on every 3rd weekday Sunday through Saturday, Saturday, and Monday through Friday"); 524 | } 525 | } 526 | -------------------------------------------------------------------------------- /saffron/src/parse.rs: -------------------------------------------------------------------------------- 1 | //! A module allowing for inspection of a parsed cron expression. This can be used to 2 | //! accurately describe an expression without reducing it into a cron value. 3 | 4 | #[cfg(not(feature = "std"))] 5 | use alloc::vec::{self, Vec}; 6 | 7 | use crate::internal::Sealed; 8 | use core::cmp::Ordering; 9 | use core::convert::TryFrom; 10 | use core::fmt::{self, Display, Formatter}; 11 | use core::iter::{Chain, Once}; 12 | use core::marker::PhantomData; 13 | use core::slice; 14 | use core::str::FromStr; 15 | use nom::{ 16 | branch::alt, 17 | bytes::complete::tag_no_case, 18 | character::complete::{char, digit1, space1}, 19 | combinator::{all_consuming, map, map_res, opt}, 20 | sequence::tuple, 21 | IResult, 22 | }; 23 | 24 | #[cfg(feature = "std")] 25 | use std::vec; 26 | 27 | pub use crate::describe::*; 28 | 29 | /// An error returned if an expression type value is out of range. 30 | #[derive(Debug)] 31 | pub struct ValueOutOfRangeError; 32 | 33 | impl Display for ValueOutOfRangeError { 34 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 35 | "The expression value is out range of valid values".fmt(f) 36 | } 37 | } 38 | 39 | #[cfg(feature = "std")] 40 | impl std::error::Error for ValueOutOfRangeError {} 41 | 42 | /// A trait implemented for expression values that defines a MIN value and a MAX value. 43 | pub trait ExprValue: Sized + Sealed { 44 | /// The max value for an expression value 45 | const MAX: u8; 46 | /// The min value for an expression value 47 | const MIN: u8; 48 | 49 | /// The max value as this expression value type 50 | fn max() -> Self; 51 | /// The min value as this expression value type 52 | fn min() -> Self; 53 | } 54 | 55 | /// A minute value, 0-59 56 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 57 | pub struct Minute(u8); 58 | impl Sealed for Minute {} 59 | impl ExprValue for Minute { 60 | const MAX: u8 = 59; 61 | const MIN: u8 = 0; 62 | 63 | fn max() -> Self { 64 | Self(Self::MAX) 65 | } 66 | fn min() -> Self { 67 | Self(Self::MIN) 68 | } 69 | } 70 | impl From for u8 { 71 | /// Returns the value, 0-59 72 | #[inline] 73 | fn from(m: Minute) -> Self { 74 | m.0 75 | } 76 | } 77 | impl TryFrom for Minute { 78 | type Error = ValueOutOfRangeError; 79 | 80 | #[inline] 81 | fn try_from(value: u8) -> Result { 82 | if value <= Self::MAX { 83 | Ok(Self(value)) 84 | } else { 85 | Err(ValueOutOfRangeError) 86 | } 87 | } 88 | } 89 | impl PartialEq for Minute { 90 | #[inline] 91 | fn eq(&self, other: &u8) -> bool { 92 | &self.0 == other 93 | } 94 | } 95 | 96 | /// An hour value, 0-23 97 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 98 | pub struct Hour(u8); 99 | impl Sealed for Hour {} 100 | impl ExprValue for Hour { 101 | const MAX: u8 = 23; 102 | const MIN: u8 = 0; 103 | 104 | fn max() -> Self { 105 | Self(Self::MAX) 106 | } 107 | fn min() -> Self { 108 | Self(Self::MIN) 109 | } 110 | } 111 | impl From for u8 { 112 | #[inline] 113 | /// Returns the value, 0-23 114 | fn from(m: Hour) -> Self { 115 | m.0 116 | } 117 | } 118 | impl TryFrom for Hour { 119 | type Error = ValueOutOfRangeError; 120 | 121 | #[inline] 122 | fn try_from(value: u8) -> Result { 123 | if value <= Self::MAX { 124 | Ok(Self(value)) 125 | } else { 126 | Err(ValueOutOfRangeError) 127 | } 128 | } 129 | } 130 | impl PartialEq for Hour { 131 | #[inline] 132 | fn eq(&self, other: &u8) -> bool { 133 | &self.0 == other 134 | } 135 | } 136 | 137 | /// A day of the month, 1-31 138 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 139 | pub struct DayOfMonth(u8); 140 | impl Sealed for DayOfMonth {} 141 | impl ExprValue for DayOfMonth { 142 | const MAX: u8 = 31; 143 | const MIN: u8 = 1; 144 | 145 | fn max() -> Self { 146 | Self(Self::MAX) 147 | } 148 | fn min() -> Self { 149 | Self(Self::MIN) 150 | } 151 | } 152 | impl From for u8 { 153 | #[inline] 154 | /// Returns the zero based day of the month, 0-30 155 | fn from(m: DayOfMonth) -> Self { 156 | m.0 - 1 157 | } 158 | } 159 | impl TryFrom for DayOfMonth { 160 | type Error = ValueOutOfRangeError; 161 | 162 | #[inline] 163 | fn try_from(value: u8) -> Result { 164 | if value >= Self::MIN && value <= Self::MAX { 165 | Ok(Self(value)) 166 | } else { 167 | Err(ValueOutOfRangeError) 168 | } 169 | } 170 | } 171 | impl PartialEq for DayOfMonth { 172 | #[inline] 173 | fn eq(&self, other: &u8) -> bool { 174 | &self.0 == other 175 | } 176 | } 177 | /// A last day of the month offset, 1-30 178 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 179 | pub struct DayOfMonthOffset(u8); 180 | impl Sealed for DayOfMonthOffset {} 181 | impl ExprValue for DayOfMonthOffset { 182 | const MAX: u8 = 30; 183 | const MIN: u8 = 1; 184 | 185 | fn max() -> Self { 186 | Self(Self::MAX) 187 | } 188 | fn min() -> Self { 189 | Self(Self::MIN) 190 | } 191 | } 192 | impl From for u8 { 193 | #[inline] 194 | /// Returns the zero based day of the month, 0-30 195 | fn from(m: DayOfMonthOffset) -> Self { 196 | m.0 197 | } 198 | } 199 | impl TryFrom for DayOfMonthOffset { 200 | type Error = ValueOutOfRangeError; 201 | 202 | #[inline] 203 | fn try_from(value: u8) -> Result { 204 | if value >= Self::MIN && value <= Self::MAX { 205 | Ok(Self(value)) 206 | } else { 207 | Err(ValueOutOfRangeError) 208 | } 209 | } 210 | } 211 | impl PartialEq for DayOfMonthOffset { 212 | #[inline] 213 | fn eq(&self, other: &u8) -> bool { 214 | &self.0 == other 215 | } 216 | } 217 | 218 | /// A month, 1-12 219 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 220 | pub struct Month(u8); 221 | impl Sealed for Month {} 222 | impl ExprValue for Month { 223 | const MAX: u8 = 12; 224 | const MIN: u8 = 1; 225 | 226 | fn max() -> Self { 227 | Self(Self::MAX) 228 | } 229 | fn min() -> Self { 230 | Self(Self::MIN) 231 | } 232 | } 233 | impl From for u8 { 234 | #[inline] 235 | /// Returns the zero based month, 0-11 236 | fn from(m: Month) -> Self { 237 | m.0 - 1 238 | } 239 | } 240 | impl From for Month { 241 | fn from(m: chrono::Month) -> Self { 242 | use chrono::Month::*; 243 | match m { 244 | January => Self(1), 245 | February => Self(2), 246 | March => Self(3), 247 | April => Self(4), 248 | May => Self(5), 249 | June => Self(6), 250 | July => Self(7), 251 | August => Self(8), 252 | September => Self(9), 253 | October => Self(10), 254 | November => Self(11), 255 | December => Self(12), 256 | } 257 | } 258 | } 259 | impl From for chrono::Month { 260 | fn from(Month(m): Month) -> chrono::Month { 261 | use chrono::Month::*; 262 | match m { 263 | 1 => January, 264 | 2 => February, 265 | 3 => March, 266 | 4 => April, 267 | 5 => May, 268 | 6 => June, 269 | 7 => July, 270 | 8 => August, 271 | 9 => September, 272 | 10 => October, 273 | 11 => November, 274 | 12 => December, 275 | _ => unreachable!(), 276 | } 277 | } 278 | } 279 | impl TryFrom for Month { 280 | type Error = ValueOutOfRangeError; 281 | 282 | #[inline] 283 | fn try_from(value: u8) -> Result { 284 | if value >= Self::MIN && value <= Self::MAX { 285 | Ok(Self(value)) 286 | } else { 287 | Err(ValueOutOfRangeError) 288 | } 289 | } 290 | } 291 | impl PartialEq for Month { 292 | #[inline] 293 | fn eq(&self, other: &u8) -> bool { 294 | &self.0 == other 295 | } 296 | } 297 | 298 | /// An "nth" day, 1-5 299 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 300 | pub struct NthDay(u8); 301 | impl Sealed for NthDay {} 302 | impl ExprValue for NthDay { 303 | const MAX: u8 = 5; 304 | const MIN: u8 = 1; 305 | 306 | fn max() -> Self { 307 | Self(Self::MAX) 308 | } 309 | fn min() -> Self { 310 | Self(Self::MIN) 311 | } 312 | } 313 | impl From for u8 { 314 | #[inline] 315 | fn from(m: NthDay) -> Self { 316 | m.0 317 | } 318 | } 319 | impl TryFrom for NthDay { 320 | type Error = ValueOutOfRangeError; 321 | 322 | #[inline] 323 | fn try_from(value: u8) -> Result { 324 | if value >= Self::MIN && value <= Self::MAX { 325 | Ok(Self(value)) 326 | } else { 327 | Err(ValueOutOfRangeError) 328 | } 329 | } 330 | } 331 | impl PartialEq for NthDay { 332 | #[inline] 333 | fn eq(&self, other: &u8) -> bool { 334 | &self.0 == other 335 | } 336 | } 337 | 338 | /// A day of the week, 1-7 (Sun-Sat) 339 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 340 | pub struct DayOfWeek(chrono::Weekday); 341 | impl Sealed for DayOfWeek {} 342 | impl ExprValue for DayOfWeek { 343 | const MAX: u8 = 7; 344 | const MIN: u8 = 1; 345 | 346 | fn max() -> Self { 347 | Self(chrono::Weekday::Sat) 348 | } 349 | fn min() -> Self { 350 | Self(chrono::Weekday::Sun) 351 | } 352 | } 353 | impl PartialOrd for DayOfWeek { 354 | #[inline] 355 | fn partial_cmp(&self, other: &Self) -> Option { 356 | self.0 357 | .number_from_sunday() 358 | .partial_cmp(&other.0.number_from_sunday()) 359 | } 360 | } 361 | impl Ord for DayOfWeek { 362 | #[inline] 363 | fn cmp(&self, other: &Self) -> Ordering { 364 | self.0 365 | .number_from_sunday() 366 | .cmp(&other.0.number_from_sunday()) 367 | } 368 | } 369 | impl From for u8 { 370 | #[inline] 371 | /// Returns the zero based day of the week, 0-6 372 | fn from(m: DayOfWeek) -> Self { 373 | m.0.num_days_from_sunday() as u8 374 | } 375 | } 376 | impl From for DayOfWeek { 377 | #[inline] 378 | fn from(w: chrono::Weekday) -> Self { 379 | Self(w) 380 | } 381 | } 382 | impl From for chrono::Weekday { 383 | #[inline] 384 | fn from(DayOfWeek(w): DayOfWeek) -> Self { 385 | w 386 | } 387 | } 388 | impl TryFrom for DayOfWeek { 389 | type Error = ValueOutOfRangeError; 390 | 391 | #[inline] 392 | fn try_from(value: u8) -> Result { 393 | use chrono::Weekday::*; 394 | 395 | Ok(Self(match value { 396 | 1 => Sun, 397 | 2 => Mon, 398 | 3 => Tue, 399 | 4 => Wed, 400 | 5 => Thu, 401 | 6 => Fri, 402 | 7 => Sat, 403 | _ => return Err(ValueOutOfRangeError), 404 | })) 405 | } 406 | } 407 | impl PartialEq for DayOfWeek { 408 | #[inline] 409 | fn eq(&self, other: &chrono::Weekday) -> bool { 410 | &self.0 == other 411 | } 412 | } 413 | 414 | /// A step value constrained by a expression value. The max value of this type differs depending 415 | /// on the type `E`. The minimum value is always 1. 416 | /// 417 | /// | Type | Max | 418 | /// | -------------- | --- | 419 | /// | [`Minute`] | 59 | 420 | /// | [`Hour`] | 23 | 421 | /// | [`DayOfMonth`] | 30 | 422 | /// | [`Month`] | 11 | 423 | /// | [`DayOfWeek`] | 6 | 424 | /// 425 | /// [`Minute`]: struct.Minute.html 426 | /// [`Hour`]: struct.Hour.html 427 | /// [`DayOfMonth`]: struct.DayOfMonth.html 428 | /// [`Month`]: struct.Month.html 429 | /// [`DayOfWeek`]: struct.DayOfWeek.html 430 | #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] 431 | pub struct Step { 432 | e: PhantomData E>, 433 | value: u8, 434 | } 435 | impl Sealed for Step {} 436 | impl ExprValue for Step { 437 | const MAX: u8 = E::MAX - E::MIN; 438 | // This assumes every MIN value is 0 or 1. If that changes this breaks and it's the 439 | // problem of whoever reads this. Hopefully the const-eval story of Rust is better 440 | // when you're fixing this 441 | const MIN: u8 = E::MIN | 1; 442 | 443 | fn max() -> Self { 444 | Self { 445 | e: PhantomData, 446 | value: Self::MAX, 447 | } 448 | } 449 | fn min() -> Self { 450 | Self { 451 | e: PhantomData, 452 | value: Self::MIN, 453 | } 454 | } 455 | } 456 | impl From> for u8 { 457 | #[inline] 458 | fn from(s: Step) -> Self { 459 | s.value 460 | } 461 | } 462 | impl TryFrom for Step { 463 | type Error = ValueOutOfRangeError; 464 | 465 | #[inline] 466 | fn try_from(value: u8) -> Result { 467 | if value >= Self::MIN && value <= Self::MAX { 468 | Ok(Self { 469 | e: PhantomData, 470 | value, 471 | }) 472 | } else { 473 | Err(ValueOutOfRangeError) 474 | } 475 | } 476 | } 477 | 478 | /// A day of the week expression. 479 | #[derive(Debug, Clone, PartialEq, Eq)] 480 | #[non_exhaustive] 481 | pub enum DayOfWeekExpr { 482 | /// A '*' character 483 | All, 484 | /// A `L` character, the last day of the week for the month, paired with a value 485 | Last(DayOfWeek), 486 | /// A '#' character 487 | Nth(DayOfWeek, NthDay), 488 | /// Possibly multiple unique, ranges, or steps 489 | Many(Exprs), 490 | } 491 | 492 | /// A "last" expression for [`DayOfMonthExpr`] 493 | /// 494 | /// [`DayOfMonthExpr`]: enum.DayOfMonthExpr.html 495 | #[derive(Debug, Clone, PartialEq, Eq)] 496 | #[non_exhaustive] 497 | pub enum Last { 498 | /// An `L` expression. The last day of the month. 499 | Day, 500 | /// An `LW` expression. The last weekday of the month. 501 | Weekday, 502 | /// The last day of the month offsetted by a value. 503 | /// For example, a `L-3`, the 3rd to last day of the month 504 | Offset(DayOfMonthOffset), 505 | /// The closest weekday to the last day of the month offsetted by a value. 506 | /// For example, a `L-3W`, the weekday closest to the 3rd to last day of the month. 507 | OffsetWeekday(DayOfMonthOffset), 508 | } 509 | 510 | /// A day of the month expression. 511 | #[derive(Debug, Clone, PartialEq, Eq)] 512 | #[non_exhaustive] 513 | pub enum DayOfMonthExpr { 514 | /// A '*' character 515 | All, 516 | /// An expression containing an 'L' character. 517 | Last(Last), 518 | /// A 'W' expression, used to mean the closest weekday to the specified day of the month 519 | ClosestWeekday(DayOfMonth), 520 | /// Possibly multiple unique, ranges, or steps 521 | Many(Exprs), 522 | } 523 | 524 | /// A generic expression that can take a '*' or many exprs. 525 | #[derive(Debug, Clone, PartialEq, Eq)] 526 | #[non_exhaustive] 527 | pub enum Expr { 528 | /// A '*' character 529 | All, 530 | /// Possibly multiple unique, ranges, or steps 531 | Many(Exprs), 532 | } 533 | 534 | /// Either one value, a range, or a step expression 535 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 536 | #[non_exhaustive] 537 | pub enum OrsExpr { 538 | /// One value 539 | One(E), 540 | /// A '-' character. 541 | Range(E, E), 542 | /// A '/' character. 543 | Step { 544 | /// The start value. If the start value is '*', this is the min value of E. 545 | start: E, 546 | /// The end value. If the step expression does not specify a value, this is the max value of E. 547 | end: E, 548 | /// The step value. 549 | step: Step, 550 | }, 551 | } 552 | 553 | impl OrsExpr { 554 | /// Normalizes the expression, simplifying it. 555 | /// 556 | /// Normalizations: 557 | /// * A range of equal start and end points (i.e. 1-1) is simplified into one value (1) 558 | /// * A step of equal start and end points (i.e. 1-1/3) is simplified into one value (1) 559 | /// * A step where the start is equal to the max value of E (i.e. 59/3) is simplified into one value (59) 560 | /// * A step where the step value is one (i.e. 5/1 or 5-30/1) is simplified into a range (5-59 or 5-30) 561 | pub fn normalize(self) -> OrsExpr { 562 | match self { 563 | OrsExpr::Range(a, b) 564 | | OrsExpr::Step { 565 | start: a, end: b, .. 566 | } if a == b => OrsExpr::One(a), 567 | OrsExpr::Step { step, start, end } if u8::from(step) == 1 => OrsExpr::Range(start, end), 568 | x => x, 569 | } 570 | } 571 | } 572 | 573 | /// A set of expressions with at least one item. 574 | #[derive(Debug, Clone, PartialEq, Eq)] 575 | pub struct Exprs { 576 | /// The first expression 577 | pub first: OrsExpr, 578 | /// The rest of the other expressions in the set. 579 | pub tail: Vec>, 580 | } 581 | 582 | /// An immutable iterator over all expressions in a set of [`Exprs`] 583 | /// 584 | /// [`Exprs`]: struct.Exprs.html 585 | pub type ExprsIter<'a, E> = Chain>, slice::Iter<'a, OrsExpr>>; 586 | 587 | /// An owned iterator over all expressions in a set of [`Exprs`] 588 | /// 589 | /// [`Exprs`]: struct.Exprs.html 590 | pub type IntoExprsIter = Chain>, vec::IntoIter>>; 591 | 592 | impl Exprs { 593 | /// Creates a new set of [`Exprs`] using the first given [`OrsExpr`] 594 | /// 595 | /// [`Exprs`]: struct.Exprs.html 596 | /// [`OrsExpr`]: enum.OrsExpr.html 597 | pub fn new(first: OrsExpr) -> Self { 598 | Self { 599 | first, 600 | tail: Vec::new(), 601 | } 602 | } 603 | 604 | /// Iterates over all expressions in this set 605 | pub fn iter(&self) -> ExprsIter { 606 | core::iter::once(&self.first).chain(self.tail.iter()) 607 | } 608 | } 609 | 610 | impl IntoIterator for Exprs { 611 | type Item = OrsExpr; 612 | type IntoIter = IntoExprsIter; 613 | 614 | fn into_iter(self) -> Self::IntoIter { 615 | core::iter::once(self.first).chain(self.tail.into_iter()) 616 | } 617 | } 618 | 619 | impl<'a, E> IntoIterator for &'a Exprs { 620 | type Item = &'a OrsExpr; 621 | type IntoIter = ExprsIter<'a, E>; 622 | 623 | fn into_iter(self) -> Self::IntoIter { 624 | self.iter() 625 | } 626 | } 627 | 628 | /// A parsed cron expression. This can be used to describe the expression or reduce it into a 629 | /// [`Cron`](../struct.Cron.html) value. 630 | #[derive(Debug, Clone, PartialEq, Eq)] 631 | #[non_exhaustive] 632 | pub struct CronExpr { 633 | /// The minute part of the expression 634 | pub minutes: Expr, 635 | /// The hour part of the expression 636 | pub hours: Expr, 637 | /// The day of the month part of the expression 638 | pub doms: DayOfMonthExpr, 639 | /// The month part of the expression 640 | pub months: Expr, 641 | /// The day of the week part of the expression. 642 | pub dows: DayOfWeekExpr, 643 | } 644 | 645 | /// A formatter for displaying a cron expression description in a specified language 646 | #[derive(Debug, Clone, Copy)] 647 | pub struct LanguageFormatter<'a, L> { 648 | expr: &'a CronExpr, 649 | lang: L, 650 | } 651 | 652 | impl<'a, L: Language> Display for LanguageFormatter<'a, L> { 653 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 654 | self.lang.fmt_expr(self.expr, f) 655 | } 656 | } 657 | 658 | impl CronExpr { 659 | /// Returns a formatter to display the cron expression in the provided language 660 | /// 661 | /// # Example 662 | /// ``` 663 | /// use saffron::parse::{CronExpr, English}; 664 | /// 665 | /// let cron: CronExpr = "* * * * *".parse().expect("Valid cron expression"); 666 | /// 667 | /// let description = cron.describe(English::default()).to_string(); 668 | /// assert_eq!("Every minute", description); 669 | /// ``` 670 | pub fn describe(&self, lang: L) -> LanguageFormatter { 671 | LanguageFormatter { expr: self, lang } 672 | } 673 | } 674 | 675 | /// An error indicating that the provided cron expression failed to parse 676 | #[derive(Debug)] 677 | pub struct CronParseError(()); 678 | 679 | impl Display for CronParseError { 680 | #[inline] 681 | fn fmt(&self, f: &mut Formatter) -> fmt::Result { 682 | "Failed to parse cron expression".fmt(f) 683 | } 684 | } 685 | 686 | #[cfg(feature = "std")] 687 | impl std::error::Error for CronParseError {} 688 | 689 | /// A parser that can parse a single value, a range of values, or a step expression 690 | fn ors_expr(f: F) -> impl Fn(&str) -> IResult<&str, OrsExpr> 691 | where 692 | E: ExprValue + TryFrom + Ord + Copy, 693 | F: Fn(&str) -> IResult<&str, E>, 694 | { 695 | move |input: &str| { 696 | let (input, value) = alt((&f, map(char('*'), |_| ExprValue::min())))(input)?; 697 | match opt(alt((char('/'), char('-'))))(input)? { 698 | (input, Some('/')) => map(step_digit::(), |step| OrsExpr::Step { 699 | start: value, 700 | end: ExprValue::max(), 701 | step, 702 | })(input), 703 | (input, Some('-')) => { 704 | let (input, end) = f(input)?; 705 | match opt(char('/'))(input)? { 706 | (input, Some(_)) => map(step_digit::(), |step| OrsExpr::Step { 707 | start: value, 708 | end, 709 | step, 710 | })(input), 711 | (input, None) => Ok((input, OrsExpr::Range(value, end))), 712 | } 713 | } 714 | (input, _) => Ok((input, OrsExpr::One(value))), 715 | } 716 | } 717 | } 718 | 719 | /// Consumes a set of trailing ORS expressions 720 | fn tail_ors_exprs<'a, E, F>( 721 | mut input: &'a str, 722 | f: F, 723 | mut exprs: Exprs, 724 | ) -> IResult<&'a str, Exprs> 725 | where 726 | E: ExprValue + TryFrom + Ord + Copy, 727 | F: Fn(&str) -> IResult<&str, E>, 728 | { 729 | loop { 730 | let comma = opt(char(','))(input)?; 731 | input = comma.0; 732 | if comma.1.is_none() { 733 | break Ok((input, exprs)); 734 | } 735 | 736 | let expr = ors_expr::(&f)(input)?; 737 | input = expr.0; 738 | exprs.tail.push(expr.1); 739 | } 740 | } 741 | 742 | /// A parser that can parse delimited expressions given a parser for that part. 743 | /// This can't parse day of the month or week expressions. 744 | fn expr(f: F) -> impl Fn(&str) -> IResult<&str, Expr> 745 | where 746 | E: ExprValue + TryFrom + Ord + Copy, 747 | F: Fn(&str) -> IResult<&str, E>, 748 | { 749 | move |mut input: &str| { 750 | let expressions: Exprs; 751 | // Attempt to read a `*`. If that succeeds, 752 | // try to read a `/` for a step expr. 753 | // If this isn't a step expr, return Expr::All, 754 | // If it's not a `*`, initialize the expressions 755 | // list with an ors_expr. 756 | let star = opt(char('*'))(input)?; 757 | input = star.0; 758 | if star.1.is_some() { 759 | let slash = opt(char('/'))(input)?; 760 | input = slash.0; 761 | // If there is no slash after this, just return All and expect the next 762 | // parser to fail if it's invalid 763 | if slash.1.is_none() { 764 | return Ok((input, Expr::All)); 765 | } 766 | let step = step_digit::()(input)?; 767 | input = step.0; 768 | expressions = Exprs::new(OrsExpr::Step { 769 | start: ExprValue::min(), 770 | end: ExprValue::max(), 771 | step: step.1, 772 | }) 773 | } else { 774 | let expr = ors_expr::(&f)(input)?; 775 | input = expr.0; 776 | expressions = Exprs::new(expr.1) 777 | } 778 | 779 | let (input, exprs) = tail_ors_exprs(input, &f, expressions)?; 780 | 781 | Ok((input, Expr::Many(exprs))) 782 | } 783 | } 784 | 785 | #[inline] 786 | fn map_digit1() -> impl Fn(&str) -> IResult<&str, E> 787 | where 788 | E: ExprValue + TryFrom, 789 | { 790 | move |input: &str| { 791 | map_res(digit1, |s: &str| { 792 | let value = s 793 | .parse::() 794 | // discard error, we won't see it anyway 795 | .map_err(|_| ValueOutOfRangeError)?; 796 | 797 | E::try_from(value) 798 | })(input) 799 | } 800 | } 801 | 802 | #[inline] 803 | fn step_digit() -> impl Fn(&str) -> IResult<&str, Step> 804 | where 805 | E: ExprValue, 806 | { 807 | map_digit1() 808 | } 809 | 810 | fn month(s: &str) -> IResult<&str, Month> { 811 | alt(( 812 | map_digit1::(), 813 | map(tag_no_case("JAN"), |_| Month(1)), 814 | map(tag_no_case("FEB"), |_| Month(2)), 815 | map(tag_no_case("MAR"), |_| Month(3)), 816 | map(tag_no_case("APR"), |_| Month(4)), 817 | map(tag_no_case("MAY"), |_| Month(5)), 818 | map(tag_no_case("JUN"), |_| Month(6)), 819 | map(tag_no_case("JUL"), |_| Month(7)), 820 | map(tag_no_case("AUG"), |_| Month(8)), 821 | map(tag_no_case("SEP"), |_| Month(9)), 822 | map(tag_no_case("OCT"), |_| Month(10)), 823 | map(tag_no_case("NOV"), |_| Month(11)), 824 | map(tag_no_case("DEC"), |_| Month(12)), 825 | ))(s) 826 | } 827 | 828 | #[inline] 829 | fn minutes_expr(s: &str) -> IResult<&str, Expr> { 830 | expr(map_digit1())(s) 831 | } 832 | 833 | #[inline] 834 | fn hours_expr(s: &str) -> IResult<&str, Expr> { 835 | expr(map_digit1())(s) 836 | } 837 | 838 | fn dom_expr(input: &str) -> IResult<&str, DayOfMonthExpr> { 839 | let dom = map_digit1::(); 840 | 841 | let (input, start) = opt(alt((char('*'), char('L'))))(input)?; 842 | match start { 843 | Some('*') => { 844 | let (input, maybe_step) = opt(tuple((char('/'), step_digit::())))(input)?; 845 | 846 | if let Some((_, step)) = maybe_step { 847 | let exprs = Exprs::new(OrsExpr::Step { 848 | start: DayOfMonth(1), 849 | end: ExprValue::max(), 850 | step, 851 | }); 852 | 853 | let (input, exprs) = tail_ors_exprs(input, dom, exprs)?; 854 | Ok((input, DayOfMonthExpr::Many(exprs))) 855 | } else { 856 | Ok((input, DayOfMonthExpr::All)) 857 | } 858 | } 859 | Some('L') => { 860 | let (input, modifier) = opt(alt((char('-'), char('W'))))(input)?; 861 | match modifier { 862 | Some('-') => { 863 | let offset = map_digit1::(); 864 | let (input, (offset, weekday)) = tuple((offset, opt(char('W'))))(input)?; 865 | 866 | if weekday.is_some() { 867 | Ok((input, DayOfMonthExpr::Last(Last::OffsetWeekday(offset)))) 868 | } else { 869 | Ok((input, DayOfMonthExpr::Last(Last::Offset(offset)))) 870 | } 871 | } 872 | Some('W') => Ok((input, DayOfMonthExpr::Last(Last::Weekday))), 873 | _ => Ok((input, DayOfMonthExpr::Last(Last::Day))), 874 | } 875 | } 876 | _ => { 877 | let (input, day) = dom(input)?; 878 | 879 | let (input, maybe_char) = opt(alt((char('W'), char('-'), char('/'))))(input)?; 880 | match maybe_char { 881 | Some('W') => Ok((input, DayOfMonthExpr::ClosestWeekday(day))), 882 | Some('-') => { 883 | let (input, (end, slash)) = tuple((&dom, opt(char('/'))))(input)?; 884 | 885 | let (input, exprs) = if slash.is_none() { 886 | (input, Exprs::new(OrsExpr::Range(day, end))) 887 | } else { 888 | let (input, step) = step_digit::()(input)?; 889 | ( 890 | input, 891 | Exprs::new(OrsExpr::Step { 892 | start: day, 893 | end, 894 | step, 895 | }), 896 | ) 897 | }; 898 | 899 | let (input, exprs) = tail_ors_exprs(input, dom, exprs)?; 900 | Ok((input, DayOfMonthExpr::Many(exprs))) 901 | } 902 | Some('/') => { 903 | let (input, step) = step_digit::()(input)?; 904 | let exprs = Exprs::new(OrsExpr::Step { 905 | start: day, 906 | end: ExprValue::max(), 907 | step, 908 | }); 909 | 910 | let (input, exprs) = tail_ors_exprs(input, dom, exprs)?; 911 | Ok((input, DayOfMonthExpr::Many(exprs))) 912 | } 913 | _ => { 914 | let (input, exprs) = tail_ors_exprs(input, dom, Exprs::new(OrsExpr::One(day)))?; 915 | Ok((input, DayOfMonthExpr::Many(exprs))) 916 | } 917 | } 918 | } 919 | } 920 | } 921 | 922 | #[inline] 923 | fn months_expr(s: &str) -> IResult<&str, Expr> { 924 | expr(month)(s) 925 | } 926 | 927 | fn dow_expr(input: &str) -> IResult<&str, DayOfWeekExpr> { 928 | fn dow(s: &str) -> IResult<&str, DayOfWeek> { 929 | alt(( 930 | map_digit1::(), 931 | map(tag_no_case("SUN"), |_| DayOfWeek(chrono::Weekday::Sun)), 932 | map(tag_no_case("MON"), |_| DayOfWeek(chrono::Weekday::Mon)), 933 | map(tag_no_case("TUE"), |_| DayOfWeek(chrono::Weekday::Tue)), 934 | map(tag_no_case("WED"), |_| DayOfWeek(chrono::Weekday::Wed)), 935 | map(tag_no_case("THU"), |_| DayOfWeek(chrono::Weekday::Thu)), 936 | map(tag_no_case("FRI"), |_| DayOfWeek(chrono::Weekday::Fri)), 937 | map(tag_no_case("SAT"), |_| DayOfWeek(chrono::Weekday::Sat)), 938 | ))(s) 939 | } 940 | 941 | let (input, start) = opt(alt((char('*'), char('L'))))(input)?; 942 | 943 | match start { 944 | Some('*') => { 945 | let (input, maybe_step) = opt(tuple((char('/'), step_digit::())))(input)?; 946 | if let Some((_, step)) = maybe_step { 947 | let exprs = Exprs::new(OrsExpr::Step { 948 | start: DayOfWeek(chrono::Weekday::Sun), 949 | end: ExprValue::max(), 950 | step, 951 | }); 952 | 953 | let (input, exprs) = tail_ors_exprs(input, dow, exprs)?; 954 | Ok((input, DayOfWeekExpr::Many(exprs))) 955 | } else { 956 | Ok((input, DayOfWeekExpr::All)) 957 | } 958 | } 959 | Some('L') => Ok(( 960 | input, 961 | DayOfWeekExpr::Many(Exprs::new(OrsExpr::One(DayOfWeek(chrono::Weekday::Sat)))), 962 | )), 963 | _ => { 964 | let (input, day) = dow(input)?; 965 | let (input, maybe_char) = 966 | opt(alt((char('L'), char('#'), char('-'), char('/'))))(input)?; 967 | 968 | match maybe_char { 969 | Some('L') => Ok((input, DayOfWeekExpr::Last(day))), 970 | Some('#') => map(map_digit1::(), move |nth| { 971 | DayOfWeekExpr::Nth(day, nth) 972 | })(input), 973 | Some('-') => { 974 | let (input, (end, slash)) = tuple((&dow, opt(char('/'))))(input)?; 975 | 976 | let (input, exprs) = if slash.is_none() { 977 | (input, Exprs::new(OrsExpr::Range(day, end))) 978 | } else { 979 | let (input, step) = step_digit::()(input)?; 980 | ( 981 | input, 982 | Exprs::new(OrsExpr::Step { 983 | start: day, 984 | end, 985 | step, 986 | }), 987 | ) 988 | }; 989 | 990 | let (input, exprs) = tail_ors_exprs(input, dow, exprs)?; 991 | Ok((input, DayOfWeekExpr::Many(exprs))) 992 | } 993 | Some('/') => { 994 | let (input, step) = step_digit::()(input)?; 995 | let exprs = Exprs::new(OrsExpr::Step { 996 | start: day, 997 | end: ExprValue::max(), 998 | step, 999 | }); 1000 | 1001 | let (input, exprs) = tail_ors_exprs(input, dow, exprs)?; 1002 | Ok((input, DayOfWeekExpr::Many(exprs))) 1003 | } 1004 | _ => { 1005 | let (input, exprs) = tail_ors_exprs(input, dow, Exprs::new(OrsExpr::One(day)))?; 1006 | Ok((input, DayOfWeekExpr::Many(exprs))) 1007 | } 1008 | } 1009 | } 1010 | } 1011 | } 1012 | 1013 | impl FromStr for CronExpr { 1014 | type Err = CronParseError; 1015 | 1016 | #[inline] 1017 | fn from_str(s: &str) -> Result { 1018 | let (_, expr) = all_consuming(map( 1019 | tuple(( 1020 | minutes_expr, 1021 | space1, 1022 | hours_expr, 1023 | space1, 1024 | dom_expr, 1025 | space1, 1026 | months_expr, 1027 | space1, 1028 | dow_expr, 1029 | )), 1030 | |(minutes, _, hours, _, doms, _, months, _, dows)| CronExpr { 1031 | minutes, 1032 | hours, 1033 | doms, 1034 | months, 1035 | dows, 1036 | }, 1037 | ))(s) 1038 | .map_err(|_| CronParseError(()))?; 1039 | 1040 | Ok(expr) 1041 | } 1042 | } 1043 | 1044 | #[cfg(test)] 1045 | mod tests { 1046 | use core::convert::TryFrom; 1047 | use core::fmt::Debug; 1048 | 1049 | #[cfg(not(feature = "std"))] 1050 | use alloc::vec; 1051 | 1052 | use super::*; 1053 | 1054 | fn exprs(iter: I) -> Exprs 1055 | where 1056 | I: IntoIterator>, 1057 | { 1058 | let mut iter = iter.into_iter(); 1059 | let first = iter.next().expect("Iterator must have at least one item"); 1060 | let tail = iter.collect(); 1061 | Exprs { first, tail } 1062 | } 1063 | 1064 | fn e(value: u8) -> E 1065 | where 1066 | E: TryFrom, 1067 | E::Error: Debug, 1068 | { 1069 | E::try_from(value).unwrap() 1070 | } 1071 | 1072 | fn o(value: u8) -> OrsExpr 1073 | where 1074 | E: TryFrom, 1075 | E::Error: Debug, 1076 | { 1077 | OrsExpr::One(e(value)) 1078 | } 1079 | 1080 | fn r(start: u8, end: u8) -> OrsExpr 1081 | where 1082 | E: TryFrom, 1083 | E::Error: Debug, 1084 | { 1085 | let start = e(start); 1086 | let end = e(end); 1087 | OrsExpr::Range(start, end) 1088 | } 1089 | 1090 | fn s(value: u8, step: u8) -> OrsExpr 1091 | where 1092 | E: TryFrom + ExprValue, 1093 | E::Error: Debug, 1094 | { 1095 | let start = e(value); 1096 | let step = e(step); 1097 | OrsExpr::Step { 1098 | start, 1099 | end: E::max(), 1100 | step, 1101 | } 1102 | } 1103 | 1104 | fn rs(start: u8, end: u8, step: u8) -> OrsExpr 1105 | where 1106 | E: TryFrom + ExprValue, 1107 | E::Error: Debug, 1108 | { 1109 | let start = e(start); 1110 | let end = e(end); 1111 | let step = e(step); 1112 | OrsExpr::Step { start, end, step } 1113 | } 1114 | 1115 | mod minutes { 1116 | use super::*; 1117 | 1118 | #[test] 1119 | fn all() { 1120 | assert_eq!(minutes_expr("*"), Ok(("", Expr::All))) 1121 | } 1122 | 1123 | #[test] 1124 | fn only_match_first_star() { 1125 | // make sure we only match the first star. 1126 | // it'll fail on the next parser 1127 | assert_eq!(minutes_expr("*,*"), Ok((",*", Expr::All))) 1128 | } 1129 | 1130 | #[test] 1131 | fn star_step() { 1132 | assert_eq!( 1133 | minutes_expr("*/5"), 1134 | Ok(("", Expr::Many(exprs(vec![s(0, 5)])))) 1135 | ) 1136 | } 1137 | 1138 | #[test] 1139 | fn multi_star_step() { 1140 | assert_eq!( 1141 | minutes_expr("*/5,*/3"), 1142 | Ok(("", Expr::Many(exprs(vec![s(0, 5), s(0, 3)])))) 1143 | ) 1144 | } 1145 | 1146 | #[test] 1147 | fn star_range_doesnt_make_sense() { 1148 | // make sure we only match the first star. 1149 | // it'll fail on the next parser 1150 | assert_eq!(minutes_expr("*-30/5,*/3"), Ok(("-30/5,*/3", Expr::All))) 1151 | } 1152 | 1153 | #[test] 1154 | fn one_value() { 1155 | assert_eq!(minutes_expr("0"), Ok(("", Expr::Many(exprs(vec![o(0)]))))) 1156 | } 1157 | 1158 | #[test] 1159 | fn many_one_value() { 1160 | assert_eq!( 1161 | minutes_expr("5,15,25,35,45,55"), 1162 | Ok(( 1163 | "", 1164 | Expr::Many(exprs(vec![o(5), o(15), o(25), o(35), o(45), o(55)])) 1165 | )) 1166 | ) 1167 | } 1168 | 1169 | #[test] 1170 | fn one_range() { 1171 | assert_eq!( 1172 | minutes_expr("0-30"), 1173 | Ok(("", Expr::Many(exprs(vec![r(0, 30)])))) 1174 | ) 1175 | } 1176 | 1177 | #[test] 1178 | fn overflow_range() { 1179 | assert_eq!( 1180 | minutes_expr("50-10"), 1181 | Ok(("", Expr::Many(exprs(vec![r(50, 10)])))) 1182 | ) 1183 | } 1184 | 1185 | #[test] 1186 | fn many_range() { 1187 | assert_eq!( 1188 | minutes_expr("0-5,10-15,20-25,30-35,40-45,50-55"), 1189 | Ok(( 1190 | "", 1191 | Expr::Many(exprs(vec![ 1192 | r(0, 5), 1193 | r(10, 15), 1194 | r(20, 25), 1195 | r(30, 35), 1196 | r(40, 45), 1197 | r(50, 55) 1198 | ])) 1199 | )) 1200 | ) 1201 | } 1202 | 1203 | #[test] 1204 | fn step() { 1205 | assert_eq!( 1206 | minutes_expr("0/5"), 1207 | Ok(("", Expr::Many(exprs(vec![s(0, 5)])))) 1208 | ) 1209 | } 1210 | 1211 | #[test] 1212 | fn step_with_star_step() { 1213 | assert_eq!( 1214 | minutes_expr("1/3,*/5"), 1215 | Ok(("", Expr::Many(exprs(vec![s(1, 3), s(0, 5)])))) 1216 | ) 1217 | } 1218 | 1219 | #[test] 1220 | fn many_steps() { 1221 | assert_eq!( 1222 | minutes_expr("1/3,2/3,5/10"), 1223 | Ok(("", Expr::Many(exprs(vec![s(1, 3), s(2, 3), s(5, 10)])))) 1224 | ) 1225 | } 1226 | 1227 | #[test] 1228 | fn range_step() { 1229 | assert_eq!( 1230 | minutes_expr("0-30/5"), 1231 | Ok(("", Expr::Many(exprs(vec![rs(0, 30, 5)])))) 1232 | ) 1233 | } 1234 | 1235 | #[test] 1236 | fn many_range_step() { 1237 | assert_eq!( 1238 | minutes_expr("0-30/5,30-59/3"), 1239 | Ok(("", Expr::Many(exprs(vec![rs(0, 30, 5), rs(30, 59, 3)])))) 1240 | ) 1241 | } 1242 | 1243 | #[test] 1244 | fn values_ranges_steps_and_ranges() { 1245 | assert_eq!( 1246 | minutes_expr("0,5-10,10-30/3,30/3"), 1247 | Ok(( 1248 | "", 1249 | Expr::Many(exprs(vec![o(0), r(5, 10), rs(10, 30, 3), s(30, 3)])) 1250 | )) 1251 | ) 1252 | } 1253 | 1254 | #[test] 1255 | fn limits() { 1256 | assert!(matches!(minutes_expr("60"), Err(_))); 1257 | assert!(matches!(minutes_expr("0-60"), Err(_))); 1258 | // a step greater than the max value is not allowed (since it doesn't make sense) 1259 | assert!(matches!(minutes_expr("0/60"), Err(_))); 1260 | assert!(matches!(minutes_expr("0-60/5"), Err(_))); 1261 | // a step of 0 is not allowed (since it doesn't make sense) 1262 | assert!(matches!(minutes_expr("0/0"), Err(_))); 1263 | assert!(matches!(minutes_expr("0-59/0"), Err(_))); 1264 | } 1265 | } 1266 | 1267 | mod hours { 1268 | use super::*; 1269 | 1270 | #[test] 1271 | fn all() { 1272 | assert_eq!(hours_expr("*"), Ok(("", Expr::All))) 1273 | } 1274 | 1275 | #[test] 1276 | fn only_match_first_star() { 1277 | // make sure we only match the first star. 1278 | // it'll fail on the next parser 1279 | assert_eq!(hours_expr("*,*"), Ok((",*", Expr::All))) 1280 | } 1281 | 1282 | #[test] 1283 | fn star_step() { 1284 | assert_eq!( 1285 | hours_expr("*/3"), 1286 | Ok(("", Expr::Many(exprs(vec![s(0, 3)])))) 1287 | ) 1288 | } 1289 | 1290 | #[test] 1291 | fn multi_star_step() { 1292 | assert_eq!( 1293 | hours_expr("*/3,*/4"), 1294 | Ok(("", Expr::Many(exprs(vec![s(0, 3), s(0, 4)])))) 1295 | ) 1296 | } 1297 | 1298 | #[test] 1299 | fn star_range_doesnt_make_sense() { 1300 | // make sure we only match the first star. 1301 | // it'll fail on the next parser 1302 | assert_eq!(hours_expr("*-6/3,*/4"), Ok(("-6/3,*/4", Expr::All))) 1303 | } 1304 | 1305 | #[test] 1306 | fn one_value() { 1307 | assert_eq!(hours_expr("0"), Ok(("", Expr::Many(exprs(vec![o(0)]))))) 1308 | } 1309 | 1310 | #[test] 1311 | fn many_one_value() { 1312 | assert_eq!( 1313 | hours_expr("0,3,6,9,12,15,18,21"), 1314 | Ok(( 1315 | "", 1316 | Expr::Many(exprs(vec![ 1317 | o(0), 1318 | o(3), 1319 | o(6), 1320 | o(9), 1321 | o(12), 1322 | o(15), 1323 | o(18), 1324 | o(21) 1325 | ])) 1326 | )) 1327 | ) 1328 | } 1329 | 1330 | #[test] 1331 | fn one_range() { 1332 | assert_eq!( 1333 | hours_expr("0-12"), 1334 | Ok(("", Expr::Many(exprs(vec![r(0, 12)])))) 1335 | ) 1336 | } 1337 | 1338 | #[test] 1339 | fn overflow_range() { 1340 | assert_eq!( 1341 | hours_expr("22-2"), 1342 | Ok(("", Expr::Many(exprs(vec![r(22, 2)])))) 1343 | ) 1344 | } 1345 | 1346 | #[test] 1347 | fn many_range() { 1348 | assert_eq!( 1349 | hours_expr("0-3,6-9,12-15,18-21"), 1350 | Ok(( 1351 | "", 1352 | Expr::Many(exprs(vec![r(0, 3), r(6, 9), r(12, 15), r(18, 21)])) 1353 | )) 1354 | ) 1355 | } 1356 | 1357 | #[test] 1358 | fn step() { 1359 | assert_eq!( 1360 | hours_expr("0/3"), 1361 | Ok(("", Expr::Many(exprs(vec![s(0, 3)])))) 1362 | ) 1363 | } 1364 | 1365 | #[test] 1366 | fn step_with_star_step() { 1367 | assert_eq!( 1368 | hours_expr("1/2,*/4"), 1369 | Ok(("", Expr::Many(exprs(vec![s(1, 2), s(0, 4)])))) 1370 | ) 1371 | } 1372 | 1373 | #[test] 1374 | fn many_steps() { 1375 | assert_eq!( 1376 | hours_expr("1/2,2/3,3/4"), 1377 | Ok(("", Expr::Many(exprs(vec![s(1, 2), s(2, 3), s(3, 4)])))) 1378 | ) 1379 | } 1380 | 1381 | #[test] 1382 | fn range_step() { 1383 | assert_eq!( 1384 | hours_expr("0-12/4"), 1385 | Ok(("", Expr::Many(exprs(vec![rs(0, 12, 4)])))) 1386 | ) 1387 | } 1388 | 1389 | #[test] 1390 | fn many_range_step() { 1391 | assert_eq!( 1392 | hours_expr("0-12/4,12-23/3"), 1393 | Ok(("", Expr::Many(exprs(vec![rs(0, 12, 4), rs(12, 23, 3)])))) 1394 | ) 1395 | } 1396 | 1397 | #[test] 1398 | fn values_ranges_steps_and_ranges() { 1399 | assert_eq!( 1400 | hours_expr("0,0-6/3,6-12,12/3"), 1401 | Ok(( 1402 | "", 1403 | Expr::Many(exprs(vec![o(0), rs(0, 6, 3), r(6, 12), s(12, 3)])) 1404 | )) 1405 | ) 1406 | } 1407 | 1408 | #[test] 1409 | fn limits() { 1410 | assert!(matches!(hours_expr("24"), Err(_))); 1411 | assert!(matches!(hours_expr("0-24"), Err(_))); 1412 | // a step greater than the max value is not allowed (since it doesn't make sense) 1413 | assert!(matches!(hours_expr("0/24"), Err(_))); 1414 | assert!(matches!(hours_expr("0-24/2"), Err(_))); 1415 | // a step of 0 is not allowed (since it doesn't make sense) 1416 | assert!(matches!(hours_expr("0/0"), Err(_))); 1417 | assert!(matches!(hours_expr("0-23/0"), Err(_))); 1418 | } 1419 | } 1420 | 1421 | mod months { 1422 | use super::*; 1423 | 1424 | #[test] 1425 | fn all() { 1426 | assert_eq!(months_expr("*"), Ok(("", Expr::All))) 1427 | } 1428 | 1429 | #[test] 1430 | fn only_match_first_star() { 1431 | // make sure we only match the first star. 1432 | // it'll fail on the next parser 1433 | assert_eq!(months_expr("*,*"), Ok((",*", Expr::All))) 1434 | } 1435 | 1436 | #[test] 1437 | fn star_step() { 1438 | assert_eq!( 1439 | months_expr("*/3"), 1440 | Ok(("", Expr::Many(exprs(vec![s(1, 3)])))) 1441 | ) 1442 | } 1443 | 1444 | #[test] 1445 | fn multi_star_step() { 1446 | assert_eq!( 1447 | months_expr("*/3,*/4"), 1448 | Ok(("", Expr::Many(exprs(vec![s(1, 3), s(1, 4)])))) 1449 | ) 1450 | } 1451 | 1452 | #[test] 1453 | fn star_range_doesnt_make_sense() { 1454 | // make sure we only match the first star. 1455 | // it'll fail on the next parser 1456 | assert_eq!(months_expr("*-6/3,*/4"), Ok(("-6/3,*/4", Expr::All))) 1457 | } 1458 | 1459 | #[test] 1460 | fn one_value() { 1461 | assert_eq!(months_expr("1"), Ok(("", Expr::Many(exprs(vec![o(1)]))))) 1462 | } 1463 | 1464 | #[test] 1465 | fn word_values() { 1466 | // caps 1467 | assert_eq!(months_expr("JAN"), Ok(("", Expr::Many(exprs(vec![o(1)]))))); 1468 | assert_eq!(months_expr("FEB"), Ok(("", Expr::Many(exprs(vec![o(2)]))))); 1469 | assert_eq!(months_expr("MAR"), Ok(("", Expr::Many(exprs(vec![o(3)]))))); 1470 | assert_eq!(months_expr("APR"), Ok(("", Expr::Many(exprs(vec![o(4)]))))); 1471 | 1472 | // lower 1473 | assert_eq!(months_expr("may"), Ok(("", Expr::Many(exprs(vec![o(5)]))))); 1474 | assert_eq!(months_expr("jun"), Ok(("", Expr::Many(exprs(vec![o(6)]))))); 1475 | assert_eq!(months_expr("jul"), Ok(("", Expr::Many(exprs(vec![o(7)]))))); 1476 | assert_eq!(months_expr("aug"), Ok(("", Expr::Many(exprs(vec![o(8)]))))); 1477 | 1478 | // mixed 1479 | assert_eq!(months_expr("sEp"), Ok(("", Expr::Many(exprs(vec![o(9)]))))); 1480 | assert_eq!(months_expr("ocT"), Ok(("", Expr::Many(exprs(vec![o(10)]))))); 1481 | assert_eq!(months_expr("NOv"), Ok(("", Expr::Many(exprs(vec![o(11)]))))); 1482 | assert_eq!(months_expr("Dec"), Ok(("", Expr::Many(exprs(vec![o(12)]))))); 1483 | } 1484 | 1485 | #[test] 1486 | fn many_one_value() { 1487 | assert_eq!( 1488 | months_expr("1,MAR,6,SEP,12"), 1489 | Ok(("", Expr::Many(exprs(vec![o(1), o(3), o(6), o(9), o(12)])))) 1490 | ) 1491 | } 1492 | 1493 | #[test] 1494 | fn one_range() { 1495 | assert_eq!( 1496 | months_expr("1-12"), 1497 | Ok(("", Expr::Many(exprs(vec![r(1, 12)])))) 1498 | ); 1499 | assert_eq!( 1500 | months_expr("JAN-DEC"), 1501 | Ok(("", Expr::Many(exprs(vec![r(1, 12)])))) 1502 | ) 1503 | } 1504 | 1505 | #[test] 1506 | fn overflow_range() { 1507 | assert_eq!( 1508 | months_expr("11-FEB"), 1509 | Ok(("", Expr::Many(exprs(vec![r(11, 2)])))) 1510 | ); 1511 | assert_eq!( 1512 | months_expr("NOV-2"), 1513 | Ok(("", Expr::Many(exprs(vec![r(11, 2)])))) 1514 | ) 1515 | } 1516 | 1517 | #[test] 1518 | fn many_range() { 1519 | assert_eq!( 1520 | months_expr("1-MAR,MAY-7,SEP-11"), 1521 | Ok(("", Expr::Many(exprs(vec![r(1, 3), r(5, 7), r(9, 11)])))) 1522 | ) 1523 | } 1524 | 1525 | #[test] 1526 | fn step() { 1527 | assert_eq!( 1528 | months_expr("1/3"), 1529 | Ok(("", Expr::Many(exprs(vec![s(1, 3)])))) 1530 | ) 1531 | } 1532 | 1533 | #[test] 1534 | fn step_with_star_step() { 1535 | assert_eq!( 1536 | months_expr("2/2,*/4"), 1537 | Ok(("", Expr::Many(exprs(vec![s(2, 2), s(1, 4)])))) 1538 | ) 1539 | } 1540 | 1541 | #[test] 1542 | fn many_steps() { 1543 | assert_eq!( 1544 | months_expr("1/2,FEB/3,3/4"), 1545 | Ok(("", Expr::Many(exprs(vec![s(1, 2), s(2, 3), s(3, 4)])))) 1546 | ) 1547 | } 1548 | 1549 | #[test] 1550 | fn range_step() { 1551 | assert_eq!( 1552 | months_expr("1-DEC/4"), 1553 | Ok(("", Expr::Many(exprs(vec![rs(1, 12, 4)])))) 1554 | ) 1555 | } 1556 | 1557 | #[test] 1558 | fn many_range_step() { 1559 | assert_eq!( 1560 | months_expr("1-JUN/4,JUN-12/3"), 1561 | Ok(("", Expr::Many(exprs(vec![rs(1, 6, 4), rs(6, 12, 3)])))) 1562 | ) 1563 | } 1564 | 1565 | #[test] 1566 | fn values_ranges_steps_and_ranges() { 1567 | assert_eq!( 1568 | months_expr("1,JAN-6/3,JUN-12,DEC/3"), 1569 | Ok(( 1570 | "", 1571 | Expr::Many(exprs(vec![o(1), rs(1, 6, 3), r(6, 12), s(12, 3)])) 1572 | )) 1573 | ) 1574 | } 1575 | 1576 | #[test] 1577 | fn limits() { 1578 | assert!(matches!(months_expr("0"), Err(_))); 1579 | assert!(matches!(months_expr("13"), Err(_))); 1580 | assert!(matches!(months_expr("0-12"), Err(_))); 1581 | assert!(matches!(months_expr("1-13"), Err(_))); 1582 | // a step greater than the max value is not allowed (since it doesn't make sense) 1583 | assert!(matches!(months_expr("1/13"), Err(_))); 1584 | assert!(matches!(months_expr("1-13/2"), Err(_))); 1585 | assert!(matches!(months_expr("0/12"), Err(_))); 1586 | assert!(matches!(months_expr("0-12/2"), Err(_))); 1587 | // a step of 0 is not allowed (since it doesn't make sense) 1588 | assert!(matches!(months_expr("1/0"), Err(_))); 1589 | assert!(matches!(months_expr("1-12/0"), Err(_))); 1590 | } 1591 | } 1592 | 1593 | mod days_of_month { 1594 | use super::*; 1595 | 1596 | #[test] 1597 | fn all() { 1598 | assert_eq!(dom_expr("*"), Ok(("", DayOfMonthExpr::All))) 1599 | } 1600 | 1601 | #[test] 1602 | fn only_match_first_star() { 1603 | // make sure we only match the first star. 1604 | // it'll fail on the next parser 1605 | assert_eq!(dom_expr("*,*"), Ok((",*", DayOfMonthExpr::All))) 1606 | } 1607 | 1608 | #[test] 1609 | fn last() { 1610 | assert_eq!(dom_expr("L"), Ok(("", DayOfMonthExpr::Last(Last::Day)))) 1611 | } 1612 | 1613 | #[test] 1614 | fn last_weekday() { 1615 | assert_eq!( 1616 | dom_expr("LW"), 1617 | Ok(("", DayOfMonthExpr::Last(Last::Weekday))) 1618 | ) 1619 | } 1620 | 1621 | #[test] 1622 | fn last_offset() { 1623 | assert_eq!( 1624 | dom_expr("L-3"), 1625 | Ok(("", DayOfMonthExpr::Last(Last::Offset(e(3))))) 1626 | ) 1627 | } 1628 | 1629 | // a zero offset makes no sense, should just be L 1630 | // a 32 offset will never execute 1631 | #[test] 1632 | fn last_offset_limit() { 1633 | assert!(matches!(dom_expr("L-0"), Err(_))); 1634 | assert!(matches!(dom_expr("L-31"), Err(_))); 1635 | assert!(matches!(dom_expr("L-0W"), Err(_))); 1636 | assert!(matches!(dom_expr("L-31W"), Err(_))); 1637 | } 1638 | 1639 | #[test] 1640 | fn last_offset_weekday() { 1641 | assert_eq!( 1642 | dom_expr("L-3W"), 1643 | Ok(("", DayOfMonthExpr::Last(Last::OffsetWeekday(e(3))))) 1644 | ) 1645 | } 1646 | 1647 | // last is not allowed with other expressions 1648 | #[test] 1649 | fn last_with_other_exprs() { 1650 | assert!(matches!(dom_expr("3,L"), Err(_))) 1651 | } 1652 | 1653 | #[test] 1654 | fn closest_weekday() { 1655 | assert_eq!( 1656 | dom_expr("1W"), 1657 | Ok(("", DayOfMonthExpr::ClosestWeekday(e(1)))) 1658 | ) 1659 | } 1660 | 1661 | #[test] 1662 | fn closest_weekday_with_other_exprs() { 1663 | // make sure we only match the 1W. 1664 | // it'll fail on the next parser 1665 | assert_eq!( 1666 | dom_expr("1W,3"), 1667 | Ok((",3", DayOfMonthExpr::ClosestWeekday(e(1)))) 1668 | ) 1669 | } 1670 | 1671 | #[test] 1672 | fn star_step() { 1673 | assert_eq!( 1674 | dom_expr("*/3"), 1675 | Ok(("", DayOfMonthExpr::Many(exprs(vec![s(1, 3)])))) 1676 | ) 1677 | } 1678 | 1679 | #[test] 1680 | fn multi_star_step() { 1681 | assert_eq!( 1682 | dom_expr("*/3,*/4"), 1683 | Ok(("", DayOfMonthExpr::Many(exprs(vec![s(1, 3), s(1, 4)])))) 1684 | ) 1685 | } 1686 | 1687 | #[test] 1688 | fn star_range_doesnt_make_sense() { 1689 | // make sure we only match the first star. 1690 | // it'll fail on the next parser 1691 | assert_eq!(dom_expr("*-6/3,*/4"), Ok(("-6/3,*/4", DayOfMonthExpr::All))) 1692 | } 1693 | 1694 | #[test] 1695 | fn one_value() { 1696 | assert_eq!( 1697 | dom_expr("1"), 1698 | Ok(("", DayOfMonthExpr::Many(exprs(vec![o(1)])))) 1699 | ) 1700 | } 1701 | 1702 | #[test] 1703 | fn many_one_value() { 1704 | assert_eq!( 1705 | dom_expr("1,4,7,10,13,16,19,22,25,28,31"), 1706 | Ok(( 1707 | "", 1708 | DayOfMonthExpr::Many(exprs(vec![ 1709 | o(1), 1710 | o(4), 1711 | o(7), 1712 | o(10), 1713 | o(13), 1714 | o(16), 1715 | o(19), 1716 | o(22), 1717 | o(25), 1718 | o(28), 1719 | o(31), 1720 | ])) 1721 | )) 1722 | ) 1723 | } 1724 | 1725 | #[test] 1726 | fn one_range() { 1727 | assert_eq!( 1728 | dom_expr("1-15"), 1729 | Ok(("", DayOfMonthExpr::Many(exprs(vec![r(1, 15)])))) 1730 | ) 1731 | } 1732 | 1733 | #[test] 1734 | fn overflow_range() { 1735 | assert_eq!( 1736 | dom_expr("30-1"), 1737 | Ok(("", DayOfMonthExpr::Many(exprs(vec![r(30, 1)])))) 1738 | ) 1739 | } 1740 | 1741 | #[test] 1742 | fn many_range() { 1743 | assert_eq!( 1744 | dom_expr("1-4,5-8,9-12,13-15"), 1745 | Ok(( 1746 | "", 1747 | DayOfMonthExpr::Many(exprs(vec![r(1, 4), r(5, 8), r(9, 12), r(13, 15)])) 1748 | )) 1749 | ) 1750 | } 1751 | 1752 | #[test] 1753 | fn step() { 1754 | assert_eq!( 1755 | dom_expr("1/3"), 1756 | Ok(("", DayOfMonthExpr::Many(exprs(vec![s(1, 3)])))) 1757 | ) 1758 | } 1759 | 1760 | #[test] 1761 | fn step_with_star_step() { 1762 | assert_eq!( 1763 | dom_expr("2/2,*/4"), 1764 | Ok(("", DayOfMonthExpr::Many(exprs(vec![s(2, 2), s(1, 4)])))) 1765 | ) 1766 | } 1767 | 1768 | #[test] 1769 | fn many_steps() { 1770 | assert_eq!( 1771 | dom_expr("1/2,2/3,3/4"), 1772 | Ok(( 1773 | "", 1774 | DayOfMonthExpr::Many(exprs(vec![s(1, 2), s(2, 3), s(3, 4)])) 1775 | )) 1776 | ) 1777 | } 1778 | 1779 | #[test] 1780 | fn range_step() { 1781 | assert_eq!( 1782 | dom_expr("1-15/4"), 1783 | Ok(("", DayOfMonthExpr::Many(exprs(vec![rs(1, 15, 4)])))) 1784 | ) 1785 | } 1786 | 1787 | #[test] 1788 | fn many_range_step() { 1789 | assert_eq!( 1790 | dom_expr("1-15/3,16-31/4"), 1791 | Ok(( 1792 | "", 1793 | DayOfMonthExpr::Many(exprs(vec![rs(1, 15, 3), rs(16, 31, 4)])) 1794 | )) 1795 | ) 1796 | } 1797 | 1798 | #[test] 1799 | fn values_ranges_steps_and_ranges() { 1800 | assert_eq!( 1801 | dom_expr("1,1-10/3,10-20,20/3"), 1802 | Ok(( 1803 | "", 1804 | DayOfMonthExpr::Many(exprs(vec![o(1), rs(1, 10, 3), r(10, 20), s(20, 3)])) 1805 | )) 1806 | ) 1807 | } 1808 | 1809 | #[test] 1810 | fn limits() { 1811 | assert!(matches!(dom_expr("32"), Err(_))); 1812 | assert!(matches!(dom_expr("0-31"), Err(_))); 1813 | assert!(matches!(dom_expr("1-32"), Err(_))); 1814 | // a step greater than the max value is not allowed (since it doesn't make sense) 1815 | assert!(matches!(dom_expr("1/32"), Err(_))); 1816 | assert!(matches!(dom_expr("0/31"), Err(_))); 1817 | assert!(matches!(dom_expr("1-31/32"), Err(_))); 1818 | assert!(matches!(dom_expr("0-31/32"), Err(_))); 1819 | assert!(matches!(dom_expr("0-32/31"), Err(_))); 1820 | // a step of 0 is not allowed (since it doesn't make sense) 1821 | assert!(matches!(dom_expr("0/0"), Err(_))); 1822 | assert!(matches!(dom_expr("0-23/0"), Err(_))); 1823 | } 1824 | } 1825 | 1826 | mod days_of_week { 1827 | use super::*; 1828 | 1829 | #[test] 1830 | fn all() { 1831 | assert_eq!(dow_expr("*"), Ok(("", DayOfWeekExpr::All))) 1832 | } 1833 | 1834 | #[test] 1835 | fn only_match_first_star() { 1836 | // make sure we only match the first star. 1837 | // it'll fail on the next parser 1838 | assert_eq!(dow_expr("*,*"), Ok((",*", DayOfWeekExpr::All))) 1839 | } 1840 | 1841 | #[test] 1842 | fn last() { 1843 | assert_eq!( 1844 | dow_expr("L"), 1845 | Ok(("", DayOfWeekExpr::Many(exprs(vec![o(7)])))) 1846 | ) 1847 | } 1848 | 1849 | #[test] 1850 | fn last_day() { 1851 | assert_eq!(dow_expr("3L"), Ok(("", DayOfWeekExpr::Last(e(3))))) 1852 | } 1853 | 1854 | // last is not allowed with other expressions 1855 | #[test] 1856 | fn last_with_other_exprs() { 1857 | assert!(matches!(dow_expr("3,L"), Err(_))) 1858 | } 1859 | 1860 | #[test] 1861 | fn nth() { 1862 | assert_eq!(dow_expr("MON#1"), Ok(("", DayOfWeekExpr::Nth(e(2), e(1))))); 1863 | assert_eq!(dow_expr("5#4"), Ok(("", DayOfWeekExpr::Nth(e(5), e(4))))); 1864 | } 1865 | 1866 | #[test] 1867 | fn star_step() { 1868 | assert_eq!( 1869 | dow_expr("*/3"), 1870 | Ok(("", DayOfWeekExpr::Many(exprs(vec![s(1, 3)])))) 1871 | ) 1872 | } 1873 | 1874 | #[test] 1875 | fn multi_star_step() { 1876 | assert_eq!( 1877 | dow_expr("*/3,*/4"), 1878 | Ok(("", DayOfWeekExpr::Many(exprs(vec![s(1, 3), s(1, 4)])))) 1879 | ) 1880 | } 1881 | 1882 | #[test] 1883 | fn star_range_doesnt_make_sense() { 1884 | // make sure we only match the first star. 1885 | // it'll fail on the next parser 1886 | assert_eq!(dow_expr("*-6/3,*/4"), Ok(("-6/3,*/4", DayOfWeekExpr::All))) 1887 | } 1888 | 1889 | #[test] 1890 | fn one_value() { 1891 | assert_eq!( 1892 | dow_expr("1"), 1893 | Ok(("", DayOfWeekExpr::Many(exprs(vec![o(1)])))) 1894 | ) 1895 | } 1896 | 1897 | #[test] 1898 | fn word_values() { 1899 | // caps 1900 | assert_eq!( 1901 | dow_expr("SUN"), 1902 | Ok(("", DayOfWeekExpr::Many(exprs(vec![o(1)])))) 1903 | ); 1904 | assert_eq!( 1905 | dow_expr("MON"), 1906 | Ok(("", DayOfWeekExpr::Many(exprs(vec![o(2)])))) 1907 | ); 1908 | assert_eq!( 1909 | dow_expr("TUE"), 1910 | Ok(("", DayOfWeekExpr::Many(exprs(vec![o(3)])))) 1911 | ); 1912 | 1913 | // lower 1914 | assert_eq!( 1915 | dow_expr("WED"), 1916 | Ok(("", DayOfWeekExpr::Many(exprs(vec![o(4)])))) 1917 | ); 1918 | assert_eq!( 1919 | dow_expr("THU"), 1920 | Ok(("", DayOfWeekExpr::Many(exprs(vec![o(5)])))) 1921 | ); 1922 | assert_eq!( 1923 | dow_expr("FRI"), 1924 | Ok(("", DayOfWeekExpr::Many(exprs(vec![o(6)])))) 1925 | ); 1926 | 1927 | // mixed 1928 | assert_eq!( 1929 | dow_expr("SaT"), 1930 | Ok(("", DayOfWeekExpr::Many(exprs(vec![o(7)])))) 1931 | ); 1932 | } 1933 | 1934 | #[test] 1935 | fn many_one_value() { 1936 | assert_eq!( 1937 | dow_expr("2,WED,FRI,7"), 1938 | Ok(("", DayOfWeekExpr::Many(exprs(vec![o(2), o(4), o(6), o(7)])))) 1939 | ) 1940 | } 1941 | 1942 | #[test] 1943 | fn one_range() { 1944 | assert_eq!( 1945 | dow_expr("MON-5"), 1946 | Ok(("", DayOfWeekExpr::Many(exprs(vec![r(2, 5)])))) 1947 | ) 1948 | } 1949 | 1950 | #[test] 1951 | fn overflow_range() { 1952 | assert_eq!( 1953 | dow_expr("7-1"), 1954 | Ok(("", DayOfWeekExpr::Many(exprs(vec![r(7, 1)])))) 1955 | ) 1956 | } 1957 | 1958 | #[test] 1959 | fn many_range() { 1960 | assert_eq!( 1961 | dow_expr("1-3,4-4,5-7"), 1962 | Ok(( 1963 | "", 1964 | DayOfWeekExpr::Many(exprs(vec![r(1, 3), r(4, 4), r(5, 7)])) 1965 | )) 1966 | ) 1967 | } 1968 | 1969 | #[test] 1970 | fn step() { 1971 | assert_eq!( 1972 | dow_expr("2/2"), 1973 | Ok(("", DayOfWeekExpr::Many(exprs(vec![s(2, 2)])))) 1974 | ) 1975 | } 1976 | 1977 | #[test] 1978 | fn step_with_star_step() { 1979 | assert_eq!( 1980 | dow_expr("2/2,*/4"), 1981 | Ok(("", DayOfWeekExpr::Many(exprs(vec![s(2, 2), s(1, 4)])))) 1982 | ) 1983 | } 1984 | 1985 | #[test] 1986 | fn many_steps() { 1987 | assert_eq!( 1988 | dow_expr("1/2,2/3,3/4"), 1989 | Ok(( 1990 | "", 1991 | DayOfWeekExpr::Many(exprs(vec![s(1, 2), s(2, 3), s(3, 4)])) 1992 | )) 1993 | ) 1994 | } 1995 | 1996 | #[test] 1997 | fn range_step() { 1998 | assert_eq!( 1999 | dow_expr("2-5/2"), 2000 | Ok(("", DayOfWeekExpr::Many(exprs(vec![rs(2, 5, 2)])))) 2001 | ) 2002 | } 2003 | 2004 | #[test] 2005 | fn many_range_step() { 2006 | assert_eq!( 2007 | dow_expr("1-4/2,5-7/2"), 2008 | Ok(( 2009 | "", 2010 | DayOfWeekExpr::Many(exprs(vec![rs(1, 4, 2), rs(5, 7, 2)])) 2011 | )) 2012 | ) 2013 | } 2014 | 2015 | #[test] 2016 | fn values_ranges_steps_and_ranges() { 2017 | assert_eq!( 2018 | dow_expr("1,2-FRI/2,6-7,3/3"), 2019 | Ok(( 2020 | "", 2021 | DayOfWeekExpr::Many(exprs(vec![o(1), rs(2, 6, 2), r(6, 7), s(3, 3)])) 2022 | )) 2023 | ) 2024 | } 2025 | 2026 | #[test] 2027 | fn limits() { 2028 | assert!(matches!(dow_expr("8"), Err(_))); 2029 | assert!(matches!(dow_expr("0"), Err(_))); 2030 | assert!(matches!(dow_expr("0-7"), Err(_))); 2031 | assert!(matches!(dow_expr("1-8"), Err(_))); 2032 | // a step greater than the max value is not allowed (since it doesn't make sense) 2033 | assert!(matches!(dow_expr("1/8"), Err(_))); 2034 | assert!(matches!(dow_expr("0/7"), Err(_))); 2035 | assert!(matches!(dow_expr("1-7/8"), Err(_))); 2036 | assert!(matches!(dow_expr("0-7/7"), Err(_))); 2037 | assert!(matches!(dow_expr("0-8/7"), Err(_))); 2038 | // a step of 0 is not allowed (since it doesn't make sense) 2039 | assert!(matches!(dow_expr("1/0"), Err(_))); 2040 | assert!(matches!(dow_expr("1-5/0"), Err(_))); 2041 | // 0th day doesn't make sense 2042 | assert!(matches!(dow_expr("SUN#0"), Err(_))); 2043 | // 6th day of the month will never happen 2044 | assert!(matches!(dow_expr("MON#6"), Err(_))); 2045 | } 2046 | } 2047 | } 2048 | --------------------------------------------------------------------------------