├── .github └── workflows │ └── rust.yml ├── .gitignore ├── .vscode └── settings.json ├── CONTRIBUTING.md ├── Cargo.toml ├── LICENSE ├── Makefile ├── README.md ├── examples ├── basic.rs ├── ffi │ └── c │ │ ├── README.md │ │ └── main.c └── readme.rs ├── rust-toolchain.toml ├── rustfmt.toml └── src ├── callable.rs ├── error.rs ├── ffi.rs ├── job.rs ├── lib.rs ├── scheduler.rs └── time.rs /.github/workflows/rust.yml: -------------------------------------------------------------------------------- 1 | name: Rust 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Build 13 | run: cargo build --verbose 14 | - name: Run tests 15 | run: cargo test --verbose 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | Cargo.lock 3 | skedge_demo 4 | /.vscode/*.log -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": ["skedge"] 3 | } 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | When contributing to this repository, please first discuss the change you wish to make via issue, 4 | email, or any other method with the owners of this repository before making a change. 5 | 6 | Please note we have a code of conduct, please follow it in all your interactions with the project. 7 | 8 | ## Pull Request Process 9 | 10 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a 11 | build. 12 | 1. Update the README.md with details of changes to the interface, this includes new environment 13 | variables, exposed ports, useful file locations and container parameters. 14 | 1. Execute `cargo fmt` and `cargo clippy` before submitting. 15 | 1. Increase the version numbers in any examples files and the README.md to the new version that this 16 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/). 17 | 18 | ## Code of Conduct 19 | 20 | ### Our Pledge 21 | 22 | We as members, contributors, and leaders pledge to make participation in our 23 | community a harassment-free experience for everyone, regardless of age, body 24 | size, visible or invisible disability, ethnicity, sex characteristics, gender 25 | identity and expression, level of experience, education, socio-economic status, 26 | nationality, personal appearance, race, caste, color, religion, or sexual identity 27 | and orientation. 28 | 29 | We pledge to act and interact in ways that contribute to an open, welcoming, 30 | diverse, inclusive, and healthy community. 31 | 32 | ### Our Standards 33 | 34 | Examples of behavior that contributes to a positive environment for our 35 | community include: 36 | 37 | * Demonstrating empathy and kindness toward other people 38 | * Being respectful of differing opinions, viewpoints, and experiences 39 | * Giving and gracefully accepting constructive feedback 40 | * Accepting responsibility and apologizing to those affected by our mistakes, 41 | and learning from the experience 42 | * Focusing on what is best not just for us as individuals, but for the 43 | overall community 44 | 45 | Examples of unacceptable behavior include: 46 | 47 | * The use of sexualized language or imagery, and sexual attention or 48 | advances of any kind 49 | * Trolling, insulting or derogatory comments, and personal or political attacks 50 | * Public or private harassment 51 | * Publishing others' private information, such as a physical or email 52 | address, without their explicit permission 53 | * Other conduct which could reasonably be considered inappropriate in a 54 | professional setting 55 | 56 | ### Enforcement Responsibilities 57 | 58 | Community leaders are responsible for clarifying and enforcing our standards of 59 | acceptable behavior and will take appropriate and fair corrective action in 60 | response to any behavior that they deem inappropriate, threatening, offensive, 61 | or harmful. 62 | 63 | Community leaders have the right and responsibility to remove, edit, or reject 64 | comments, commits, code, wiki edits, issues, and other contributions that are 65 | not aligned to this Code of Conduct, and will communicate reasons for moderation 66 | decisions when appropriate. 67 | 68 | ### Scope 69 | 70 | This Code of Conduct applies within all community spaces, and also applies when 71 | an individual is officially representing the community in public spaces. 72 | Examples of representing our community include using an official e-mail address, 73 | posting via an official social media account, or acting as an appointed 74 | representative at an online or offline event. 75 | 76 | ### Enforcement 77 | 78 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 79 | reported to the community leaders responsible for enforcement at 80 | [ben@deciduously.com](mailto:ben@deciduously.com). 81 | All complaints will be reviewed and investigated promptly and fairly. 82 | 83 | All community leaders are obligated to respect the privacy and security of the 84 | reporter of any incident. 85 | 86 | ### Enforcement Guidelines 87 | 88 | Community leaders will follow these Community Impact Guidelines in determining 89 | the consequences for any action they deem in violation of this Code of Conduct: 90 | 91 | #### 1. Correction 92 | 93 | **Community Impact**: Use of inappropriate language or other behavior deemed 94 | unprofessional or unwelcome in the community. 95 | 96 | **Consequence**: A private, written warning from community leaders, providing 97 | clarity around the nature of the violation and an explanation of why the 98 | behavior was inappropriate. A public apology may be requested. 99 | 100 | #### 2. Warning 101 | 102 | **Community Impact**: A violation through a single incident or series 103 | of actions. 104 | 105 | **Consequence**: A warning with consequences for continued behavior. No 106 | interaction with the people involved, including unsolicited interaction with 107 | those enforcing the Code of Conduct, for a specified period of time. This 108 | includes avoiding interactions in community spaces as well as external channels 109 | like social media. Violating these terms may lead to a temporary or 110 | permanent ban. 111 | 112 | #### 3. Temporary Ban 113 | 114 | **Community Impact**: A serious violation of community standards, including 115 | sustained inappropriate behavior. 116 | 117 | **Consequence**: A temporary ban from any sort of interaction or public 118 | communication with the community for a specified period of time. No public or 119 | private interaction with the people involved, including unsolicited interaction 120 | with those enforcing the Code of Conduct, is allowed during this period. 121 | Violating these terms may lead to a permanent ban. 122 | 123 | #### 4. Permanent Ban 124 | 125 | **Community Impact**: Demonstrating a pattern of violation of community 126 | standards, including sustained inappropriate behavior, harassment of an 127 | individual, or aggression toward or disparagement of classes of individuals. 128 | 129 | **Consequence**: A permanent ban from any sort of public interaction within 130 | the community. 131 | 132 | ### Attribution 133 | 134 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 135 | version 2.1, available at 136 | [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. 137 | 138 | Community Impact Guidelines were inspired by 139 | [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. 140 | 141 | For answers to common questions about this code of conduct, see the FAQ at 142 | [https://www.contributor-covenant.org/faq][FAQ]. Translations are available 143 | at [https://www.contributor-covenant.org/translations][translations]. 144 | 145 | [homepage]: https://www.contributor-covenant.org 146 | [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html 147 | [Mozilla CoC]: https://github.com/mozilla/diversity 148 | [FAQ]: https://www.contributor-covenant.org/faq 149 | [translations]: https://www.contributor-covenant.org/translations 150 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = ["Ben Lovy "] 3 | description = "Ergonomic single-process job scheduling for Rust programs." 4 | documentation = "https://docs.rs/skedge" 5 | edition = "2021" 6 | homepage = "https://crates.io/crates/skedge" 7 | include = ["**/*.rs", "Cargo.toml"] 8 | keywords = ["utility", "scheduling"] 9 | license = "MIT" 10 | name = "skedge" 11 | readme = "README.md" 12 | repository = "https://github.com/deciduously/skedge" 13 | version = "0.3.1" 14 | rust-version = "1.80" 15 | 16 | [lib] 17 | crate-type = ["rlib", "cdylib"] 18 | 19 | [features] 20 | default = [] 21 | random = ["dep:rand"] 22 | ffi = ["dep:libc"] 23 | 24 | [dependencies] 25 | jiff = "0.1" 26 | libc = { version = "0.2", optional = true } 27 | rand = { version = "0.8", optional = true } 28 | regex = "1.5" 29 | thiserror = "1.0" 30 | tracing = "0.1" 31 | 32 | [dev-dependencies] 33 | pretty_assertions = "1" 34 | 35 | [profile.release] 36 | lto = true 37 | strip = true 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Ben Lovy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean run 2 | 3 | LIBNAME=skedge 4 | EXE=$(LIBNAME)_demo 5 | SRC=./examples/ffi/c/main.c 6 | RUSTBUILD=cargo build 7 | RUSTFLAGS=--release --features ffi 8 | CC=gcc 9 | FLAGS=-std=c11 -Wall -Werror -pedantic 10 | LD_PATH=./target/release 11 | SO = lib$(LIBNAME).so 12 | SO_PATH=$(LD_PATH)/$(SO) 13 | LD=-L $(LD_PATH) -l $(LIBNAME) 14 | 15 | $(EXE): $(SO_PATH) 16 | $(CC) $(FLAGS) $(LD) $(SRC) -o $(EXE) 17 | 18 | $(SO_PATH): 19 | $(RUSTBUILD) $(RUSTFLAGS) 20 | 21 | clean: 22 | @rm -f $(SO_PATH) 23 | @rm -f $(EXE) 24 | 25 | run: clean $(EXE) 26 | LD_LIBRARY_PATH=$(LD_PATH) ./$(EXE) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # skedge 2 | 3 | [![Crates.io](https://img.shields.io/crates/v/skedge.svg)](https://crates.io/crates/skedge) 4 | [![rust action](https://github.com/deciduously/skedge/actions/workflows/rust.yml/badge.svg)](https://github.com/deciduously/skedge/actions/workflows/rust.yml) 5 | [![docs.rs](https://img.shields.io/docsrs/skedge)](https://docs.rs/skedge) 6 | 7 | Rust single-process scheduling. Ported from [`schedule`](https://github.com/dbader/schedule) for Python, in turn inspired by [`clockwork`](https://github.com/Rykian/clockwork) (Ruby), and ["Rethinking Cron"](https://adam.herokuapp.com/past/2010/4/13/rethinking_cron/) by [Adam Wiggins](https://github.com/adamwiggins). 8 | 9 | ## Usage 10 | 11 | Documentation can be found on [docs.rs](https://docs.rs/skedge). 12 | 13 | This library uses the Builder pattern to define jobs. Instantiate a fresh `Scheduler`, then use the `every()` and `every_single()` functions to begin defining a job. Finalize configuration by calling `Job::run()` to add the new job to the scheduler. The `Scheduler::run_pending()` method is used to fire any jobs that have arrived at their next scheduled run time. Currently, precision can only be specified to the second, no smaller. 14 | 15 | ```rust 16 | use skedge::{every, Scheduler}; 17 | use std::{ 18 | thread, 19 | time::{Duration, SystemTime}, 20 | }; 21 | 22 | fn seconds_from_epoch() -> u64 { 23 | SystemTime::now() 24 | .duration_since(SystemTime::UNIX_EPOCH) 25 | .unwrap() 26 | .as_secs() 27 | } 28 | 29 | fn greet(name: &str) { 30 | let timestamp = seconds_from_epoch(); 31 | println!("Hello {name}, it's been {timestamp} seconds since the Unix epoch!"); 32 | } 33 | 34 | fn main() -> Result<(), Box> { 35 | let mut schedule = Scheduler::new(); 36 | 37 | every(10) 38 | .minutes()? 39 | .at(":17")? 40 | .until( 41 | SystemTime::now() 42 | .checked_add(Duration::from_secs(2 * 60 * 60)) 43 | .unwrap() 44 | .try_into()?, 45 | )? 46 | .run_one_arg(&mut schedule, greet, "Cool Person")?; 47 | 48 | let now = seconds_from_epoch(); 49 | println!("Starting at {now}"); 50 | loop { 51 | if let Err(e) = schedule.run_pending() { 52 | eprintln!("Error: {e}"); 53 | } 54 | thread::sleep(Duration::from_secs(1)); 55 | } 56 | } 57 | ``` 58 | 59 | Check out the [example script](https://github.com/deciduously/skedge/blob/main/examples/basic.rs) to see more configuration options. Try `cargo run --example readme` or `cargo run --example basic` to see it in action. 60 | 61 | ### CFFI 62 | 63 | There is an **experimental** C foreign function interface, which is feature-gated and not included by default. To build the library with this feature, use `cargo build --features ffi`. See the [Makefile](https://github.com/deciduously/skedge/blob/main/Makefile) and [examples/ffi/c](https://github.com/deciduously/skedge/tree/main/examples/ffi/c) directory for details on using this library from C. Execute `make run` to build and execute the included example C program. It currently **only** supports work functions which take no arguments. 64 | 65 | ## Development 66 | 67 | Clone this repo. See [`CONTRIBUTING.md`](https://github.com/deciduously/skedge/blob/main/CONTRIBUTING.md) for contribution guidelines. 68 | 69 | ### Dependencies 70 | 71 | - **Stable [Rust](https://www.rust-lang.org/tools/install)**. Obtainable via `rustup` using the instructions at this link. 72 | 73 | ### Crates 74 | 75 | - [jiff](https://github.com/BurntSushi/jiff) - Date and time handling 76 | - [libc](https://github.com/rust-lang/libc) - libc bindings for CFFI (optional) 77 | - [rand](https://rust-random.github.io/book/) - Random number generation (optional) 78 | - [regex](https://github.com/rust-lang/regex) - Regular expressions 79 | - [thiserror](https://github.com/dtolnay/thiserror) - Error derive macro 80 | - [tracing](https://github.com/tokio-rs/tracing) - what it says on the tin 81 | 82 | #### Development-Only 83 | 84 | - [pretty_assertions](https://github.com/colin-kiegel/rust-pretty-assertions) - Colorful assertion output 85 | -------------------------------------------------------------------------------- /examples/basic.rs: -------------------------------------------------------------------------------- 1 | // Some more varied usage examples. 2 | 3 | #[cfg(feature = "random")] 4 | use jiff::ToSpan as _; 5 | use jiff::Zoned; 6 | use skedge::{every, every_single, Scheduler}; 7 | use std::thread::sleep; 8 | use std::time::Duration; 9 | 10 | fn job() { 11 | let now = Zoned::now(); 12 | println!("Hello, it's {now}!"); 13 | } 14 | 15 | fn main() -> Result<(), Box> { 16 | let mut schedule = Scheduler::new(); 17 | 18 | every(10).seconds()?.run(&mut schedule, job)?; 19 | 20 | every(10).minutes()?.run(&mut schedule, job)?; 21 | 22 | every_single().hour()?.run(&mut schedule, job)?; 23 | 24 | every_single().day()?.at("10:30")?.run(&mut schedule, job)?; 25 | 26 | #[cfg(feature = "random")] 27 | every(5).to(10)?.minutes()?.run(&mut schedule, job)?; 28 | 29 | every_single().monday()?.run(&mut schedule, job)?; 30 | 31 | every_single() 32 | .wednesday()? 33 | .at("13:15")? 34 | .run(&mut schedule, job)?; 35 | 36 | #[cfg(feature = "random")] 37 | every(2) 38 | .to(8)? 39 | .seconds()? 40 | .until(Zoned::now().checked_add(5.seconds()).unwrap())? 41 | .run(&mut schedule, job)?; 42 | 43 | let now = Zoned::now(); 44 | println!("Starting at {now}"); 45 | loop { 46 | if let Err(e) = schedule.run_pending() { 47 | eprintln!("Error: {e}"); 48 | } 49 | sleep(Duration::from_secs(1)); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /examples/ffi/c/README.md: -------------------------------------------------------------------------------- 1 | # CFFI 2 | 3 | This directory demonstrates how to use `skedge` from C. Must have GCC and GNU Make installed. 4 | 5 | ## Usage 6 | 7 | ``` 8 | $ git clone https://github.com/deciduously/skedge 9 | $ cd skedge 10 | $ make run 11 | ``` 12 | -------------------------------------------------------------------------------- /examples/ffi/c/main.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | 7 | // Declare all of these - currently, only void to void is supported 8 | typedef struct scheduler scheduler_t; 9 | typedef struct job job_t; 10 | typedef void (*unit_to_unit_t)(void); 11 | 12 | extern scheduler_t *scheduler_new(void); 13 | extern void scheduler_free(scheduler_t *); 14 | 15 | extern void run(job_t *, scheduler_t *, unit_to_unit_t); 16 | extern void run_pending(scheduler_t *); 17 | 18 | // Declare one or both of these 19 | extern job_t *every(uint32_t); 20 | extern job_t *every_single(void); 21 | 22 | // Declare one of these for each method you need 23 | extern job_t *seconds(job_t *); 24 | extern job_t *minute(job_t *); 25 | 26 | // Helper function to grab the current time 27 | char *now(void) { 28 | time_t rawtime; 29 | struct tm *timeinfo; 30 | time(&rawtime); 31 | timeinfo = localtime(&rawtime); 32 | return asctime(timeinfo); 33 | } 34 | 35 | // Define a job 36 | void job(void) { 37 | printf("Hello! It is now %s\n", now()); 38 | fflush(stdout); 39 | } 40 | 41 | // // NOTE: not sure how to do this - can't use generic interface arguments. 42 | // void greet(char *name) 43 | // { 44 | // printf("Hello, %s! It's now %s", name, now()); 45 | // } 46 | 47 | // You can't return anything, must be void return type 48 | 49 | int main(void) { 50 | // Instantiate 51 | scheduler_t *scheduler = scheduler_new(); 52 | printf("Starting at %s\n", now()); 53 | 54 | // Schedule some jobs - it's a little inside-out 55 | run(seconds(every(8)), scheduler, job); 56 | run(minute(every_single()), scheduler, job); 57 | 58 | // Run some jobs 59 | for (int i = 0; i < 100; i++) { 60 | run_pending(scheduler); 61 | sleep(1); 62 | } 63 | 64 | // Free 65 | scheduler_free(scheduler); 66 | } 67 | -------------------------------------------------------------------------------- /examples/readme.rs: -------------------------------------------------------------------------------- 1 | // This is the exact code from the README.md example 2 | 3 | use skedge::{every, Scheduler}; 4 | use std::{ 5 | thread, 6 | time::{Duration, SystemTime}, 7 | }; 8 | 9 | fn seconds_from_epoch() -> u64 { 10 | SystemTime::now() 11 | .duration_since(SystemTime::UNIX_EPOCH) 12 | .unwrap() 13 | .as_secs() 14 | } 15 | 16 | fn greet(name: &str) { 17 | let timestamp = seconds_from_epoch(); 18 | println!("Hello {name}, it's been {timestamp} seconds since the Unix epoch!"); 19 | } 20 | 21 | fn main() -> Result<(), Box> { 22 | let mut schedule = Scheduler::new(); 23 | 24 | every(10) 25 | .minutes()? 26 | .at(":17")? 27 | .until( 28 | SystemTime::now() 29 | .checked_add(Duration::from_secs(2 * 60 * 60)) 30 | .unwrap() 31 | .try_into()?, 32 | )? 33 | .run_one_arg(&mut schedule, greet, "Cool Person")?; 34 | 35 | let now = seconds_from_epoch(); 36 | println!("Starting at {now}"); 37 | loop { 38 | if let Err(e) = schedule.run_pending() { 39 | eprintln!("Error: {e}"); 40 | } 41 | thread::sleep(Duration::from_secs(1)); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "stable" 3 | components = ["clippy", "rustfmt", "rust-src"] -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | hard_tabs = true 2 | use_field_init_shorthand = true 3 | match_block_trailing_comma = true 4 | -------------------------------------------------------------------------------- /src/callable.rs: -------------------------------------------------------------------------------- 1 | //! The work functions that can be scheduled must implement the `Callable` trait. 2 | 3 | use std::fmt; 4 | 5 | /// A job is anything that implements this trait 6 | pub trait Callable { 7 | /// Execute this callable 8 | fn call(&self) -> Option; 9 | /// Get the name of this callable 10 | fn name(&self) -> &str; 11 | } 12 | 13 | impl fmt::Debug for dyn Callable { 14 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 15 | let name = self.name(); 16 | write!(f, "Callable(name={name})") 17 | } 18 | } 19 | 20 | impl PartialEq for dyn Callable { 21 | fn eq(&self, other: &Self) -> bool { 22 | // Callable objects are equal if their names are equal 23 | // FIXME: this seems fishy 24 | self.name() == other.name() 25 | } 26 | } 27 | 28 | impl Eq for dyn Callable {} 29 | 30 | /// A named callable function taking no parameters and returning nothing. 31 | #[derive(Debug)] 32 | pub struct UnitToUnit { 33 | name: String, 34 | work: fn() -> (), 35 | } 36 | 37 | impl UnitToUnit { 38 | pub fn new(name: &str, work: fn() -> ()) -> Self { 39 | Self { 40 | name: name.into(), 41 | work, 42 | } 43 | } 44 | } 45 | 46 | impl Callable for UnitToUnit { 47 | fn call(&self) -> Option { 48 | (self.work)(); 49 | None 50 | } 51 | fn name(&self) -> &str { 52 | &self.name 53 | } 54 | } 55 | 56 | /// A named callable function taking one parameter and returning nothing. 57 | #[derive(Debug)] 58 | pub struct OneToUnit 59 | where 60 | T: Clone, 61 | { 62 | name: String, 63 | work: fn(T) -> (), 64 | arg: T, 65 | } 66 | 67 | impl OneToUnit 68 | where 69 | T: Clone, 70 | { 71 | pub fn new(name: &str, work: fn(T) -> (), arg: T) -> Self { 72 | Self { 73 | name: name.into(), 74 | work, 75 | arg, 76 | } 77 | } 78 | } 79 | 80 | impl Callable for OneToUnit 81 | where 82 | T: Clone, 83 | { 84 | fn call(&self) -> Option { 85 | (self.work)(self.arg.clone()); 86 | None 87 | } 88 | fn name(&self) -> &str { 89 | &self.name 90 | } 91 | } 92 | 93 | /// A named callable function taking two parameters and returning nothing. 94 | #[derive(Debug)] 95 | pub struct TwoToUnit 96 | where 97 | T: Clone, 98 | U: Clone, 99 | { 100 | name: String, 101 | work: fn(T, U) -> (), 102 | arg_one: T, 103 | arg_two: U, 104 | } 105 | 106 | impl TwoToUnit 107 | where 108 | T: Clone, 109 | U: Clone, 110 | { 111 | pub fn new(name: &str, work: fn(T, U) -> (), arg_one: T, arg_two: U) -> Self { 112 | Self { 113 | name: name.into(), 114 | work, 115 | arg_one, 116 | arg_two, 117 | } 118 | } 119 | } 120 | 121 | impl Callable for TwoToUnit 122 | where 123 | T: Clone, 124 | U: Clone, 125 | { 126 | fn call(&self) -> Option { 127 | (self.work)(self.arg_one.clone(), self.arg_two.clone()); 128 | None 129 | } 130 | fn name(&self) -> &str { 131 | &self.name 132 | } 133 | } 134 | 135 | /// A named callable function taking three parameters and returning nothing. 136 | #[derive(Debug)] 137 | pub struct ThreeToUnit 138 | where 139 | T: Clone, 140 | U: Clone, 141 | V: Clone, 142 | { 143 | name: String, 144 | work: fn(T, U, V) -> (), 145 | arg_one: T, 146 | arg_two: U, 147 | arg_three: V, 148 | } 149 | 150 | impl ThreeToUnit 151 | where 152 | T: Clone, 153 | U: Clone, 154 | V: Clone, 155 | { 156 | pub fn new(name: &str, work: fn(T, U, V) -> (), arg_one: T, arg_two: U, arg_three: V) -> Self { 157 | Self { 158 | name: name.into(), 159 | work, 160 | arg_one, 161 | arg_two, 162 | arg_three, 163 | } 164 | } 165 | } 166 | 167 | impl Callable for ThreeToUnit 168 | where 169 | T: Clone, 170 | U: Clone, 171 | V: Clone, 172 | { 173 | fn call(&self) -> Option { 174 | (self.work)( 175 | self.arg_one.clone(), 176 | self.arg_two.clone(), 177 | self.arg_three.clone(), 178 | ); 179 | None 180 | } 181 | fn name(&self) -> &str { 182 | &self.name 183 | } 184 | } 185 | 186 | /// A named callable function taking three parameters and returning nothing. 187 | #[derive(Debug)] 188 | pub struct FourToUnit 189 | where 190 | T: Clone, 191 | U: Clone, 192 | V: Clone, 193 | W: Clone, 194 | { 195 | name: String, 196 | work: fn(T, U, V, W) -> (), 197 | arg_one: T, 198 | arg_two: U, 199 | arg_three: V, 200 | arg_four: W, 201 | } 202 | 203 | impl FourToUnit 204 | where 205 | T: Clone, 206 | U: Clone, 207 | V: Clone, 208 | W: Clone, 209 | { 210 | pub fn new( 211 | name: &str, 212 | work: fn(T, U, V, W) -> (), 213 | arg_one: T, 214 | arg_two: U, 215 | arg_three: V, 216 | arg_four: W, 217 | ) -> Self { 218 | Self { 219 | name: name.into(), 220 | work, 221 | arg_one, 222 | arg_two, 223 | arg_three, 224 | arg_four, 225 | } 226 | } 227 | } 228 | 229 | impl Callable for FourToUnit 230 | where 231 | T: Clone, 232 | U: Clone, 233 | V: Clone, 234 | W: Clone, 235 | { 236 | fn call(&self) -> Option { 237 | (self.work)( 238 | self.arg_one.clone(), 239 | self.arg_two.clone(), 240 | self.arg_three.clone(), 241 | self.arg_four.clone(), 242 | ); 243 | None 244 | } 245 | fn name(&self) -> &str { 246 | &self.name 247 | } 248 | } 249 | 250 | /// A named callable function taking three parameters and returning nothing. 251 | #[derive(Debug)] 252 | pub struct FiveToUnit 253 | where 254 | T: Clone, 255 | U: Clone, 256 | V: Clone, 257 | W: Clone, 258 | X: Clone, 259 | { 260 | name: String, 261 | work: fn(T, U, V, W, X) -> (), 262 | arg_one: T, 263 | arg_two: U, 264 | arg_three: V, 265 | arg_four: W, 266 | arg_five: X, 267 | } 268 | 269 | impl FiveToUnit 270 | where 271 | T: Clone, 272 | U: Clone, 273 | V: Clone, 274 | W: Clone, 275 | X: Clone, 276 | { 277 | pub fn new( 278 | name: &str, 279 | work: fn(T, U, V, W, X) -> (), 280 | arg_one: T, 281 | arg_two: U, 282 | arg_three: V, 283 | arg_four: W, 284 | arg_five: X, 285 | ) -> Self { 286 | Self { 287 | name: name.into(), 288 | work, 289 | arg_one, 290 | arg_two, 291 | arg_three, 292 | arg_four, 293 | arg_five, 294 | } 295 | } 296 | } 297 | 298 | impl Callable for FiveToUnit 299 | where 300 | T: Clone, 301 | U: Clone, 302 | V: Clone, 303 | W: Clone, 304 | X: Clone, 305 | { 306 | fn call(&self) -> Option { 307 | (self.work)( 308 | self.arg_one.clone(), 309 | self.arg_two.clone(), 310 | self.arg_three.clone(), 311 | self.arg_four.clone(), 312 | self.arg_five.clone(), 313 | ); 314 | None 315 | } 316 | fn name(&self) -> &str { 317 | &self.name 318 | } 319 | } 320 | 321 | /// A named callable function taking three parameters and returning nothing. 322 | #[derive(Debug)] 323 | pub struct SixToUnit 324 | where 325 | T: Clone, 326 | U: Clone, 327 | V: Clone, 328 | W: Clone, 329 | X: Clone, 330 | Y: Clone, 331 | { 332 | name: String, 333 | work: fn(T, U, V, W, X, Y) -> (), 334 | arg_one: T, 335 | arg_two: U, 336 | arg_three: V, 337 | arg_four: W, 338 | arg_five: X, 339 | arg_six: Y, 340 | } 341 | 342 | impl SixToUnit 343 | where 344 | T: Clone, 345 | U: Clone, 346 | V: Clone, 347 | W: Clone, 348 | X: Clone, 349 | Y: Clone, 350 | { 351 | #[allow(clippy::too_many_arguments)] 352 | pub fn new( 353 | name: &str, 354 | work: fn(T, U, V, W, X, Y) -> (), 355 | arg_one: T, 356 | arg_two: U, 357 | arg_three: V, 358 | arg_four: W, 359 | arg_five: X, 360 | arg_six: Y, 361 | ) -> Self { 362 | Self { 363 | name: name.into(), 364 | work, 365 | arg_one, 366 | arg_two, 367 | arg_three, 368 | arg_four, 369 | arg_five, 370 | arg_six, 371 | } 372 | } 373 | } 374 | 375 | impl Callable for SixToUnit 376 | where 377 | T: Clone, 378 | U: Clone, 379 | V: Clone, 380 | W: Clone, 381 | X: Clone, 382 | Y: Clone, 383 | { 384 | fn call(&self) -> Option { 385 | (self.work)( 386 | self.arg_one.clone(), 387 | self.arg_two.clone(), 388 | self.arg_three.clone(), 389 | self.arg_four.clone(), 390 | self.arg_five.clone(), 391 | self.arg_six.clone(), 392 | ); 393 | None 394 | } 395 | fn name(&self) -> &str { 396 | &self.name 397 | } 398 | } 399 | 400 | #[cfg(feature = "ffi")] 401 | pub mod ffi { 402 | //! The CFFI feature requires different types, defined here 403 | 404 | use super::Callable; 405 | 406 | /// A named callable function taking no parameters and returning nothing. 407 | #[derive(Debug)] 408 | pub struct ExternUnitToUnit { 409 | name: String, 410 | work: extern "C" fn() -> (), 411 | } 412 | 413 | impl ExternUnitToUnit { 414 | pub fn new(name: &str, work: extern "C" fn() -> ()) -> Self { 415 | Self { 416 | name: name.into(), 417 | work, 418 | } 419 | } 420 | } 421 | 422 | impl Callable for ExternUnitToUnit { 423 | fn call(&self) -> Option { 424 | (self.work)(); 425 | None 426 | } 427 | fn name(&self) -> &str { 428 | &self.name 429 | } 430 | } 431 | /* 432 | 433 | NOTE: This doesn't work - can't use generic interface across boundary, must be mangled 434 | 435 | /// A named callable function taking one parameter and returning nothing. 436 | #[derive(Debug)] 437 | pub struct ExternOneToUnit 438 | where 439 | T: Clone, 440 | { 441 | name: String, 442 | work: extern "C" fn(T) -> (), 443 | arg: T, 444 | } 445 | 446 | impl ExternOneToUnit 447 | where 448 | T: Clone, 449 | { 450 | pub fn new(name: &str, work: extern "C" fn(T) -> (), arg: T) -> Self { 451 | Self { 452 | name: name.into(), 453 | work, 454 | arg, 455 | } 456 | } 457 | } 458 | 459 | impl Callable for ExternOneToUnit 460 | where 461 | T: Clone, 462 | { 463 | fn call(&self) -> Option { 464 | (self.work)(self.arg.clone()); 465 | None 466 | } 467 | fn name(&self) -> &str { 468 | &self.name 469 | } 470 | } 471 | */ 472 | } 473 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | //! This module defines the error type and Result alias. 2 | 3 | use crate::Unit; 4 | use jiff::civil::Weekday; 5 | use thiserror::Error; 6 | 7 | #[derive(Debug, Error)] 8 | pub enum Error { 9 | #[error("Tried to reference this job's inner subroutine but failed")] 10 | CallableUnreachable, 11 | #[error("Use {0}s() instead of {0}()")] 12 | Interval(Unit), 13 | #[error("Cannot set {0}s mode, already using {1}s")] 14 | Unit(Unit, Unit), 15 | #[error("Latest val is greater than interval val")] 16 | InvalidInterval, 17 | #[error("Invalid unit (valid units are `days`, `hours`, and `minutes`)")] 18 | InvalidUnit, 19 | #[error("Invalid hour ({0} is not between 0 and 23)")] 20 | InvalidHour(i8), 21 | #[error("Invalid time format for daily job (valid format is HH:MM(:SS)?)")] 22 | InvalidDailyAtStr, 23 | #[error("Invalid time format for hourly job (valid format is (MM)?:SS)")] 24 | InvalidHourlyAtStr, 25 | #[error("Invalid time format for minutely job (valid format is :SS)")] 26 | InvalidMinuteAtStr, 27 | #[error("Invalid string format for until()")] 28 | InvalidUntilStr, 29 | #[error("Cannot schedule a job to run until a time in the past")] 30 | InvalidUntilTime, 31 | #[error("Attempted to reference the next run time but failed")] 32 | NextRunUnreachable, 33 | #[error("Attempted to reference the last run time but failed")] 34 | LastRunUnreachable, 35 | #[error("Attempted to reference the period but failed")] 36 | PeriodUnreachable, 37 | #[error("Attempted to reference the period but failed")] 38 | UnitUnreachable, 39 | #[error("Attempted to use a start day for a unit other than `weeks`")] 40 | StartDayError, 41 | #[error("{0}")] 42 | Jiff(#[from] jiff::Error), 43 | #[error("{0}")] 44 | ParseInt(#[from] std::num::ParseIntError), 45 | #[error("Scheduling jobs on {0:?} is only allowed for weekly jobs. Using specific days on a job scheduled to run every 2 or more weeks is not supported")] 46 | Weekday(Weekday), 47 | #[error("Cannot schedule {0:?} job, already scheduled for {1:?}")] 48 | WeekdayCollision(Weekday, Weekday), 49 | #[error("Invalid unit without specifying start day")] 50 | UnspecifiedStartDay, 51 | } 52 | 53 | /// Construct a new Unit error. 54 | pub(crate) fn unit_error(intended: Unit, existing: Unit) -> Error { 55 | Error::Unit(intended, existing) 56 | } 57 | 58 | /// Construct a new invalid hour error. 59 | pub(crate) fn invalid_hour_error(hour: i8) -> Error { 60 | Error::InvalidHour(hour) 61 | } 62 | 63 | /// Construct a new Interval error. 64 | pub(crate) fn interval_error(interval: Unit) -> Error { 65 | Error::Interval(interval) 66 | } 67 | 68 | /// Construct a new Weekday error. 69 | pub(crate) fn weekday_error(weekday: Weekday) -> Error { 70 | Error::Weekday(weekday) 71 | } 72 | 73 | /// Construct a new Weekday collision error. 74 | pub(crate) fn weekday_collision_error(intended: Weekday, existing: Weekday) -> Error { 75 | Error::WeekdayCollision(intended, existing) 76 | } 77 | 78 | pub type Result = std::result::Result; 79 | -------------------------------------------------------------------------------- /src/ffi.rs: -------------------------------------------------------------------------------- 1 | //! The C foreign-function interface allows any CFFI-compatible language to utilize skedge. 2 | 3 | use crate::{Job, Scheduler}; 4 | use libc::{c_char, c_uint}; 5 | use std::ffi::CString; 6 | 7 | /// Helper function to free strings generated by Rust. 8 | /// Clients need to call this to ensure proper cleanup. 9 | /// It is recommended to do so in a way such that your binding performs this automatically. 10 | /// See the [Rust FFI Omnibus](http://jakegoulding.com/rust-ffi-omnibus/string_return/) for examples. 11 | /// # Safety 12 | /// 13 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 14 | #[allow(clippy::module_name_repetitions)] 15 | #[no_mangle] 16 | pub unsafe extern "C" fn ffi_string_free(s: *mut c_char) { 17 | { 18 | if s.is_null() { 19 | // nothing to do 20 | return; 21 | } 22 | // Otherwise, trigger the Rust destructor when it goes out of scope 23 | let _ = CString::from_raw(s); 24 | }; 25 | } 26 | 27 | /// Instantiate a new Scheduler, returning a raw pointer 28 | /// # Safety 29 | /// 30 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 31 | #[no_mangle] 32 | pub unsafe extern "C" fn scheduler_new() -> *mut Scheduler { 33 | Box::into_raw(Box::new(Scheduler::new())) 34 | } 35 | 36 | /// Free the scheduler 37 | /// # Safety 38 | /// 39 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 40 | #[no_mangle] 41 | pub unsafe extern "C" fn scheduler_free(ptr: *mut Scheduler) { 42 | if ptr.is_null() { 43 | return; 44 | } 45 | // Capture it - moves into this function, which then drops at the end 46 | let _ = Box::from_raw(ptr); 47 | } 48 | 49 | /// Start configuring a job 50 | /// # Safety 51 | /// 52 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 53 | #[no_mangle] 54 | pub unsafe extern "C" fn every(interval: c_uint) -> *mut Job { 55 | Box::into_raw(Box::new(Job::new(interval))) 56 | } 57 | 58 | /// Start configuring a job with interval 1 59 | /// # Safety 60 | /// 61 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 62 | #[no_mangle] 63 | pub unsafe extern "C" fn every_single() -> *mut Job { 64 | Box::into_raw(Box::new(Job::new(1))) 65 | } 66 | 67 | /// # Safety 68 | /// 69 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 70 | /// 71 | /// # Panics 72 | /// 73 | /// Panics if passed any null pointer. 74 | #[no_mangle] 75 | pub unsafe extern "C" fn run( 76 | job: *mut Job, 77 | scheduler: *mut Scheduler, 78 | work: extern "C" fn() -> (), 79 | ) { 80 | let job = { 81 | assert!(!job.is_null()); 82 | Box::from_raw(job) 83 | }; 84 | let scheduler = { 85 | assert!(!scheduler.is_null()); 86 | &mut *scheduler 87 | }; 88 | 89 | job.run_extern(scheduler, work) 90 | .unwrap_or_else(|e| eprintln!("Error: {e}")); 91 | } 92 | 93 | /// Run pending scheduler jobs 94 | /// # Safety 95 | /// 96 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 97 | /// 98 | /// # Panics 99 | /// 100 | /// Panics if passed any null pointer. 101 | #[no_mangle] 102 | pub unsafe extern "C" fn run_pending(ptr: *mut Scheduler) { 103 | let scheduler = { 104 | assert!(!ptr.is_null()); 105 | &mut *ptr 106 | }; 107 | 108 | scheduler 109 | .run_pending() 110 | .unwrap_or_else(|e| eprintln!("Error: {e}")); 111 | } 112 | 113 | /// # Safety 114 | /// 115 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 116 | /// 117 | /// # Panics 118 | /// 119 | /// Panics if passed any null pointer. 120 | #[no_mangle] 121 | pub unsafe extern "C" fn seconds(ptr: *mut Job) -> *mut Job { 122 | let job = { 123 | assert!(!ptr.is_null()); 124 | Box::from_raw(ptr) 125 | }; 126 | Box::into_raw(Box::new(job.seconds().unwrap_or_else(|e| { 127 | eprintln!("Error: {e}"); 128 | std::process::exit(1); 129 | }))) 130 | } 131 | 132 | /// # Safety 133 | /// 134 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 135 | /// 136 | /// # Panics 137 | /// 138 | /// Panics if passed any null pointer. 139 | #[no_mangle] 140 | pub unsafe extern "C" fn second(ptr: *mut Job) -> *mut Job { 141 | let job = { 142 | assert!(!ptr.is_null()); 143 | Box::from_raw(ptr) 144 | }; 145 | Box::into_raw(Box::new(job.second().unwrap_or_else(|e| { 146 | eprintln!("Error: {e}"); 147 | std::process::exit(1); 148 | }))) 149 | } 150 | 151 | /// # Safety 152 | /// 153 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 154 | /// 155 | /// # Panics 156 | /// 157 | /// Panics if passed any null pointer. 158 | #[no_mangle] 159 | pub unsafe extern "C" fn minutes(ptr: *mut Job) -> *mut Job { 160 | let job = { 161 | assert!(!ptr.is_null()); 162 | Box::from_raw(ptr) 163 | }; 164 | Box::into_raw(Box::new(job.minutes().unwrap_or_else(|e| { 165 | eprintln!("Error: {e}"); 166 | std::process::exit(1); 167 | }))) 168 | } 169 | 170 | /// # Safety 171 | /// 172 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 173 | /// 174 | /// # Panics 175 | /// 176 | /// Panics if passed any null pointer. 177 | #[no_mangle] 178 | pub unsafe extern "C" fn minute(ptr: *mut Job) -> *mut Job { 179 | let job = { 180 | assert!(!ptr.is_null()); 181 | Box::from_raw(ptr) 182 | }; 183 | Box::into_raw(Box::new(job.minute().unwrap_or_else(|e| { 184 | eprintln!("Error: {e}"); 185 | std::process::exit(1); 186 | }))) 187 | } 188 | 189 | /// # Safety 190 | /// 191 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 192 | /// 193 | /// # Panics 194 | /// 195 | /// Panics if passed any null pointer. 196 | #[no_mangle] 197 | pub unsafe extern "C" fn hours(ptr: *mut Job) -> *mut Job { 198 | let job = { 199 | assert!(!ptr.is_null()); 200 | Box::from_raw(ptr) 201 | }; 202 | Box::into_raw(Box::new(job.hours().unwrap_or_else(|e| { 203 | eprintln!("Error: {e}"); 204 | std::process::exit(1); 205 | }))) 206 | } 207 | 208 | /// # Safety 209 | /// 210 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 211 | /// 212 | /// # Panics 213 | /// 214 | /// Panics if passed any null pointer. 215 | #[no_mangle] 216 | pub unsafe extern "C" fn hour(ptr: *mut Job) -> *mut Job { 217 | let job = { 218 | assert!(!ptr.is_null()); 219 | Box::from_raw(ptr) 220 | }; 221 | Box::into_raw(Box::new(job.hour().unwrap_or_else(|e| { 222 | eprintln!("Error: {e}"); 223 | std::process::exit(1); 224 | }))) 225 | } 226 | 227 | /// # Safety 228 | /// 229 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 230 | /// 231 | /// # Panics 232 | /// 233 | /// Panics if passed any null pointer. 234 | #[no_mangle] 235 | pub unsafe extern "C" fn days(ptr: *mut Job) -> *mut Job { 236 | let job = { 237 | assert!(!ptr.is_null()); 238 | Box::from_raw(ptr) 239 | }; 240 | Box::into_raw(Box::new(job.days().unwrap_or_else(|e| { 241 | eprintln!("Error: {e}"); 242 | std::process::exit(1); 243 | }))) 244 | } 245 | 246 | /// # Safety 247 | /// 248 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 249 | /// 250 | /// # Panics 251 | /// 252 | /// Panics if passed any null pointer. 253 | #[no_mangle] 254 | pub unsafe extern "C" fn day(ptr: *mut Job) -> *mut Job { 255 | let job = { 256 | assert!(!ptr.is_null()); 257 | Box::from_raw(ptr) 258 | }; 259 | Box::into_raw(Box::new(job.day().unwrap_or_else(|e| { 260 | eprintln!("Error: {e}"); 261 | std::process::exit(1); 262 | }))) 263 | } 264 | 265 | /// # Safety 266 | /// 267 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 268 | /// 269 | /// # Panics 270 | /// 271 | /// Panics if passed any null pointer. 272 | #[no_mangle] 273 | pub unsafe extern "C" fn weeks(ptr: *mut Job) -> *mut Job { 274 | let job = { 275 | assert!(!ptr.is_null()); 276 | Box::from_raw(ptr) 277 | }; 278 | Box::into_raw(Box::new(job.weeks().unwrap_or_else(|e| { 279 | eprintln!("Error: {e}"); 280 | std::process::exit(1); 281 | }))) 282 | } 283 | 284 | /// # Safety 285 | /// 286 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 287 | /// 288 | /// # Panics 289 | /// 290 | /// Panics if passed any null pointer. 291 | #[no_mangle] 292 | pub unsafe extern "C" fn week(ptr: *mut Job) -> *mut Job { 293 | let job = { 294 | assert!(!ptr.is_null()); 295 | Box::from_raw(ptr) 296 | }; 297 | Box::into_raw(Box::new(job.week().unwrap_or_else(|e| { 298 | eprintln!("Error: {e}"); 299 | std::process::exit(1); 300 | }))) 301 | } 302 | 303 | /// # Safety 304 | /// 305 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 306 | /// 307 | /// # Panics 308 | /// 309 | /// Panics if passed any null pointer. 310 | #[no_mangle] 311 | pub unsafe extern "C" fn months(ptr: *mut Job) -> *mut Job { 312 | let job = { 313 | assert!(!ptr.is_null()); 314 | Box::from_raw(ptr) 315 | }; 316 | Box::into_raw(Box::new(job.months().unwrap_or_else(|e| { 317 | eprintln!("Error: {e}"); 318 | std::process::exit(1); 319 | }))) 320 | } 321 | 322 | /// # Safety 323 | /// 324 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 325 | /// 326 | /// # Panics 327 | /// 328 | /// Panics if passed any null pointer. 329 | #[no_mangle] 330 | pub unsafe extern "C" fn month(ptr: *mut Job) -> *mut Job { 331 | let job = { 332 | assert!(!ptr.is_null()); 333 | Box::from_raw(ptr) 334 | }; 335 | Box::into_raw(Box::new(job.month().unwrap_or_else(|e| { 336 | eprintln!("Error: {e}"); 337 | std::process::exit(1); 338 | }))) 339 | } 340 | 341 | /// # Safety 342 | /// 343 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 344 | /// 345 | /// # Panics 346 | /// 347 | /// Panics if passed any null pointer. 348 | #[no_mangle] 349 | pub unsafe extern "C" fn years(ptr: *mut Job) -> *mut Job { 350 | let job = { 351 | assert!(!ptr.is_null()); 352 | Box::from_raw(ptr) 353 | }; 354 | Box::into_raw(Box::new(job.years().unwrap_or_else(|e| { 355 | eprintln!("Error: {e}"); 356 | std::process::exit(1); 357 | }))) 358 | } 359 | 360 | /// # Safety 361 | /// 362 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 363 | /// 364 | /// # Panics 365 | /// 366 | /// Panics if passed any null pointer. 367 | #[no_mangle] 368 | pub unsafe extern "C" fn year(ptr: *mut Job) -> *mut Job { 369 | let job = { 370 | assert!(!ptr.is_null()); 371 | Box::from_raw(ptr) 372 | }; 373 | Box::into_raw(Box::new(job.year().unwrap_or_else(|e| { 374 | eprintln!("Error: {e}"); 375 | std::process::exit(1); 376 | }))) 377 | } 378 | 379 | /// # Safety 380 | /// 381 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 382 | /// 383 | /// # Panics 384 | /// 385 | /// Panics if passed any null pointer. 386 | #[no_mangle] 387 | pub unsafe extern "C" fn monday(ptr: *mut Job) -> *mut Job { 388 | let job = { 389 | assert!(!ptr.is_null()); 390 | Box::from_raw(ptr) 391 | }; 392 | Box::into_raw(Box::new(job.monday().unwrap_or_else(|e| { 393 | eprintln!("Error: {e}"); 394 | std::process::exit(1); 395 | }))) 396 | } 397 | 398 | /// # Safety 399 | /// 400 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 401 | /// 402 | /// # Panics 403 | /// 404 | /// Panics if passed any null pointer. 405 | #[no_mangle] 406 | pub unsafe extern "C" fn tuesday(ptr: *mut Job) -> *mut Job { 407 | let job = { 408 | assert!(!ptr.is_null()); 409 | Box::from_raw(ptr) 410 | }; 411 | Box::into_raw(Box::new(job.tuesday().unwrap_or_else(|e| { 412 | eprintln!("Error: {e}"); 413 | std::process::exit(1); 414 | }))) 415 | } 416 | 417 | /// # Safety 418 | /// 419 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 420 | /// 421 | /// # Panics 422 | /// 423 | /// Panics if passed any null pointer. 424 | #[no_mangle] 425 | pub unsafe extern "C" fn wednesday(ptr: *mut Job) -> *mut Job { 426 | let job = { 427 | assert!(!ptr.is_null()); 428 | Box::from_raw(ptr) 429 | }; 430 | Box::into_raw(Box::new(job.wednesday().unwrap_or_else(|e| { 431 | eprintln!("Error: {e}"); 432 | std::process::exit(1); 433 | }))) 434 | } 435 | 436 | /// # Safety 437 | /// 438 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 439 | /// 440 | /// # Panics 441 | /// 442 | /// Panics if passed any null pointer. 443 | #[no_mangle] 444 | pub unsafe extern "C" fn thursday(ptr: *mut Job) -> *mut Job { 445 | let job = { 446 | assert!(!ptr.is_null()); 447 | Box::from_raw(ptr) 448 | }; 449 | Box::into_raw(Box::new(job.thursday().unwrap_or_else(|e| { 450 | eprintln!("Error: {e}"); 451 | std::process::exit(1); 452 | }))) 453 | } 454 | 455 | /// # Safety 456 | /// 457 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 458 | /// 459 | /// # Panics 460 | /// 461 | /// Panics if passed any null pointer. 462 | #[no_mangle] 463 | pub unsafe extern "C" fn friday(ptr: *mut Job) -> *mut Job { 464 | let job = { 465 | assert!(!ptr.is_null()); 466 | Box::from_raw(ptr) 467 | }; 468 | Box::into_raw(Box::new(job.friday().unwrap_or_else(|e| { 469 | eprintln!("Error: {e}"); 470 | std::process::exit(1); 471 | }))) 472 | } 473 | 474 | /// # Safety 475 | /// 476 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 477 | /// 478 | /// # Panics 479 | /// 480 | /// Panics if passed any null pointer. 481 | #[no_mangle] 482 | pub unsafe extern "C" fn saturday(ptr: *mut Job) -> *mut Job { 483 | let job = { 484 | assert!(!ptr.is_null()); 485 | Box::from_raw(ptr) 486 | }; 487 | Box::into_raw(Box::new(job.saturday().unwrap_or_else(|e| { 488 | eprintln!("Error: {e}"); 489 | std::process::exit(1); 490 | }))) 491 | } 492 | 493 | /// # Safety 494 | /// 495 | /// This function crosses the FFI barrier and is therefore inherently unsafe. 496 | /// 497 | /// # Panics 498 | /// 499 | /// Panics if passed any null pointer. 500 | #[no_mangle] 501 | pub unsafe extern "C" fn sunday(ptr: *mut Job) -> *mut Job { 502 | let job = { 503 | assert!(!ptr.is_null()); 504 | Box::from_raw(ptr) 505 | }; 506 | Box::into_raw(Box::new(job.sunday().unwrap_or_else(|e| { 507 | eprintln!("Error: {e}"); 508 | std::process::exit(1); 509 | }))) 510 | } 511 | -------------------------------------------------------------------------------- /src/job.rs: -------------------------------------------------------------------------------- 1 | //! A Job is a piece of work that can be configured and added to the scheduler 2 | 3 | use jiff::{civil, Span, Zoned}; 4 | #[cfg(feature = "random")] 5 | use rand::prelude::*; 6 | use regex::Regex; 7 | use std::{ 8 | cmp::{Ord, Ordering}, 9 | collections::HashSet, 10 | fmt, 11 | sync::LazyLock, 12 | }; 13 | use tracing::debug; 14 | 15 | #[cfg(feature = "ffi")] 16 | use crate::callable::ffi::ExternUnitToUnit; 17 | use crate::{ 18 | interval_error, invalid_hour_error, unit_error, weekday_collision_error, weekday_error, 19 | Callable, Error, FiveToUnit, FourToUnit, OneToUnit, Result, Scheduler, SixToUnit, ThreeToUnit, 20 | Timekeeper, TwoToUnit, Unit, UnitToUnit, 21 | }; 22 | 23 | /// A Tag is used to categorize a job. 24 | pub type Tag = String; 25 | 26 | /// Each interval value is an unsigned 32-bit integer 27 | pub type Interval = u32; 28 | 29 | // Regexes for validating `.at()` strings are only computed once 30 | static DAILY_RE: LazyLock = 31 | LazyLock::new(|| Regex::new(r"^([0-2]\d:)?[0-5]\d:[0-5]\d$").unwrap()); 32 | static HOURLY_RE: LazyLock = LazyLock::new(|| Regex::new(r"^([0-5]\d)?:[0-5]\d$").unwrap()); 33 | static MINUTE_RE: LazyLock = LazyLock::new(|| Regex::new(r"^:[0-5]\d$").unwrap()); 34 | 35 | /// Convenience function wrapping the Job constructor. 36 | /// 37 | /// E.g.: `every(10).seconds()?.run(&schedule, job)`; 38 | #[inline] 39 | #[must_use] 40 | pub fn every(interval: Interval) -> Job { 41 | Job::new(interval) 42 | } 43 | 44 | /// Convenience function wrapping the Job constructor with a default of 1. 45 | /// 46 | /// Equivalent to `every(1)`. 47 | #[inline] 48 | #[allow(clippy::module_name_repetitions)] 49 | #[must_use] 50 | pub fn every_single() -> Job { 51 | Job::new(1) 52 | } 53 | 54 | /// A Job is anything that can be scheduled to run periodically. 55 | /// 56 | /// Usually created by the `every` function. 57 | #[derive(Debug, PartialEq, Eq)] 58 | pub struct Job { 59 | /// A quantity of a given time unit 60 | interval: Interval, // pause interval * unit between runs 61 | /// Upper limit to interval for randomized job timing 62 | #[cfg(feature = "random")] 63 | latest: Option, 64 | /// The actual function to execute 65 | job: Option>, 66 | /// Tags used to group jobs 67 | tags: HashSet, 68 | /// Unit of time described by intervals 69 | unit: Option, 70 | /// Optional set time at which this job runs 71 | at_time: Option, 72 | /// Timestamp of last run 73 | last_run: Option, 74 | /// Timestamp of next run 75 | pub(crate) next_run: Option, 76 | /// Time delta between runs 77 | period: Option, 78 | /// Specific day of the week to start on 79 | start_day: Option, 80 | /// Optional time of final run 81 | pub(crate) cancel_after: Option, 82 | // Track number of times run, for testing 83 | #[cfg(test)] 84 | pub(crate) call_count: u64, 85 | } 86 | 87 | impl Job { 88 | #[must_use] 89 | pub fn new(interval: Interval) -> Self { 90 | Self { 91 | interval, 92 | #[cfg(feature = "random")] 93 | latest: None, 94 | job: None, 95 | tags: HashSet::new(), 96 | unit: None, 97 | at_time: None, 98 | last_run: None, 99 | next_run: None, 100 | period: None, 101 | start_day: None, 102 | cancel_after: None, 103 | #[cfg(test)] 104 | call_count: 0, 105 | } 106 | } 107 | 108 | /// Tag the job with one or more unique identifiers 109 | pub fn tag(&mut self, tags: &[&str]) { 110 | for &t in tags { 111 | let new_tag = t.to_string(); 112 | if !self.tags.contains(&new_tag) { 113 | self.tags.insert(new_tag); 114 | } 115 | } 116 | } 117 | 118 | /// Check if the job has the given tag 119 | pub(crate) fn has_tag(&self, tag: &str) -> bool { 120 | self.tags.contains(tag) 121 | } 122 | 123 | /// Specify a particular concrete time to run the job. 124 | /// 125 | /// * Daily jobs: `HH:MM:SS` or `HH:MM` 126 | /// 127 | /// * Hourly jobs: `MM:SS` or `:MM` 128 | /// 129 | /// * Minute jobs: `:SS` 130 | /// 131 | /// Not supported on weekly, monthly, or yearly jobs. 132 | /// 133 | /// ```rust 134 | /// # use skedge::*; 135 | /// # fn job() {} 136 | /// # fn main() -> Result<()> { 137 | /// # let mut scheduler = Scheduler::new(); 138 | /// every(3).minutes()?.at(":15")?.run(&mut scheduler, job)?; 139 | /// every_single().hour()?.at(":30")?.run(&mut scheduler, job)?; 140 | /// every(12).hours()?.at("08:45")?.run(&mut scheduler, job)?; 141 | /// every_single().wednesday()?.at("13:30")?.run(&mut scheduler, job)?; 142 | /// every(10).days()?.at("00:00:12")?.run(&mut scheduler, job)?; 143 | /// # Ok(()) 144 | /// # } 145 | /// ``` 146 | /// 147 | /// # Errors 148 | /// 149 | /// Returns an error if passed an invalid or nonsensical date string. 150 | pub fn at(mut self, time_str: &str) -> Result { 151 | // FIXME - can this whole fun just use jiff? 152 | use Unit::{Day, Hour, Minute, Week, Year}; 153 | 154 | // Validate time unit 155 | if ![Week, Day, Hour, Minute].contains(&self.unit.unwrap_or(Year)) { 156 | return Err(Error::InvalidUnit); 157 | } 158 | 159 | // Validate time_str for set time unit 160 | if (self.unit == Some(Day) || self.start_day.is_some()) && !DAILY_RE.is_match(time_str) { 161 | return Err(Error::InvalidDailyAtStr); 162 | } 163 | 164 | if self.unit == Some(Hour) && !HOURLY_RE.is_match(time_str) { 165 | return Err(Error::InvalidHourlyAtStr); 166 | } 167 | 168 | if self.unit == Some(Minute) && !MINUTE_RE.is_match(time_str) { 169 | return Err(Error::InvalidMinuteAtStr); 170 | } 171 | 172 | // Parse time_str and store timestamp 173 | let time_vals = time_str.split(':').collect::>(); 174 | let mut hour = 0; 175 | let mut minute = 0; 176 | let mut second = 0; 177 | // ALl unwraps are safe - already validated by regex 178 | let num_vals = time_vals.len(); 179 | if num_vals == 3 { 180 | hour = time_vals[0].parse()?; 181 | minute = time_vals[1].parse()?; 182 | second = time_vals[2].parse()?; 183 | } else if num_vals == 2 && self.unit == Some(Minute) { 184 | second = time_vals[1].parse()?; 185 | } else if num_vals == 2 && self.unit == Some(Hour) { 186 | minute = if time_vals[0].is_empty() { 187 | 0 188 | } else { 189 | time_vals[0].parse()? 190 | }; 191 | second = time_vals[1].parse()?; 192 | } else { 193 | hour = time_vals[0].parse()?; 194 | minute = time_vals[1].parse()?; 195 | } 196 | 197 | if self.unit == Some(Day) || self.start_day.is_some() { 198 | if hour > 23 { 199 | return Err(invalid_hour_error(hour)); 200 | } 201 | } else if self.unit == Some(Hour) { 202 | hour = 0; 203 | } else if self.unit == Some(Minute) { 204 | hour = 0; 205 | minute = 0; 206 | } 207 | 208 | // Store timestamp and return 209 | self.at_time = Some(civil::time(hour, minute, second, 0)); 210 | Ok(self) 211 | } 212 | 213 | /// Schedule the job to run at a randomized interval between two extremes. 214 | /// 215 | /// ```rust 216 | /// # use skedge::*; 217 | /// # fn job() {} 218 | /// # fn main() -> Result<()> { 219 | /// # let mut scheduler = Scheduler::new(); 220 | /// every(3).to(6)?.seconds()?.run(&mut scheduler, job)?; 221 | /// # Ok(()) 222 | /// # } 223 | /// ``` 224 | /// 225 | /// # Errors 226 | /// 227 | /// Returns an error if the upper bound passed is smaller than the original. 228 | #[cfg(feature = "random")] 229 | pub fn to(mut self, latest: Interval) -> Result { 230 | if latest <= self.interval { 231 | Err(Error::InvalidInterval) 232 | } else { 233 | self.latest = Some(latest); 234 | Ok(self) 235 | } 236 | } 237 | 238 | /// Schedule job to run until the specified moment. 239 | /// 240 | /// The job is canceled whenever the next run is calculated and it turns out the 241 | /// next run is after the `until_time`. The job is also canceled right before it runs, 242 | /// if the current time is after `until_time`. This latter case can happen when the 243 | /// the job was scheduled to run before `until_time`, but runs afte`until_time`me. 244 | /// If `until_time` is a moment in the past, returns an error. 245 | /// 246 | /// ```rust 247 | /// # use skedge::*; 248 | /// # fn job() {} 249 | /// # fn main() -> Result<()> { 250 | /// # let mut scheduler = Scheduler::new(); 251 | /// use jiff::{ToSpan, Zoned}; 252 | /// let deadline = Zoned::now().checked_add(10.minutes())?; 253 | /// every_single().minute()?.at(":15")?.until(deadline)?.run(&mut scheduler, job)?; 254 | /// # Ok(()) 255 | /// # } 256 | /// ``` 257 | /// 258 | /// # Errors 259 | /// 260 | /// Returns an error if the `until_time` is before the current time. 261 | pub fn until(mut self, until_time: Zoned) -> Result { 262 | if let Some(ref last_run) = self.last_run { 263 | if until_time < *last_run { 264 | return Err(Error::InvalidUntilTime); 265 | } 266 | } 267 | self.cancel_after = Some(until_time); 268 | Ok(self) 269 | } 270 | 271 | /// Specify the work function that will execute when this job runs and add it to the schedule 272 | /// 273 | /// ```rust 274 | /// # use skedge::*; 275 | /// fn job() { 276 | /// println!("Hello!"); 277 | /// } 278 | /// # fn main() -> Result<()> { 279 | /// # let mut scheduler = Scheduler::new(); 280 | /// 281 | /// every(10).seconds()?.run(&mut scheduler, job)?; 282 | /// # Ok(()) 283 | /// # } 284 | /// ``` 285 | /// 286 | /// # Errors 287 | /// 288 | /// Returns an error if unable to schedule the run. 289 | // FIXME this also goes on scheduler? 290 | pub fn run(mut self, scheduler: &mut Scheduler, job: fn() -> ()) -> Result<()> { 291 | self.job = Some(Box::new(UnitToUnit::new("job", job))); 292 | self.schedule_next_run(&scheduler.now())?; 293 | scheduler.add_job(self); 294 | Ok(()) 295 | } 296 | 297 | #[cfg(feature = "ffi")] 298 | /// # Errors 299 | /// 300 | /// Returns an error if unable to schedule the run. 301 | pub fn run_extern( 302 | mut self, 303 | scheduler: &mut Scheduler, 304 | job: extern "C" fn() -> (), 305 | ) -> Result<()> { 306 | self.job = Some(Box::new(ExternUnitToUnit::new("job", job))); 307 | self.schedule_next_run(&scheduler.now())?; 308 | scheduler.add_job(self); 309 | Ok(()) 310 | } 311 | 312 | /// Specify the work function with one argument that will execute when this job runs and add it to the schedule 313 | /// 314 | /// ```rust 315 | /// # use skedge::*; 316 | /// fn job(name: &str) { 317 | /// println!("Hello, {name}!"); 318 | /// } 319 | /// # fn main() -> Result<()> { 320 | /// # let mut scheduler = Scheduler::new(); 321 | /// 322 | /// every(10) 323 | /// .seconds()? 324 | /// .run_one_arg(&mut scheduler, job, "Good-Looking")?; 325 | /// # Ok(()) 326 | /// # } 327 | /// ``` 328 | /// 329 | /// # Errors 330 | /// 331 | /// Returns an error if unable to schedule the run. 332 | pub fn run_one_arg( 333 | mut self, 334 | scheduler: &mut Scheduler, 335 | job: fn(T) -> (), 336 | arg: T, 337 | ) -> Result<()> 338 | where 339 | T: 'static + Clone, 340 | { 341 | self.job = Some(Box::new(OneToUnit::new("job_one_arg", job, arg))); 342 | self.schedule_next_run(&scheduler.now())?; 343 | scheduler.add_job(self); 344 | Ok(()) 345 | } 346 | 347 | // NOTE: Doesn't work, can't use a generic fn as FFI boundary interface 348 | // #[cfg(feature = "ffi")] 349 | // pub fn run_one_arg_extern( 350 | // mut self, 351 | // scheduler: &mut Scheduler, 352 | // job: extern "C" fn(T) -> (), 353 | // arg: T, 354 | // ) -> Result<()> 355 | // where 356 | // T: 'static + Clone, 357 | // { 358 | // self.job = Some(Box::new(ExternOneToUnit::new("job_one_arg", job, arg))); 359 | // self.schedule_next_run()?; 360 | // scheduler.add_job(self); 361 | // Ok(()) 362 | // } 363 | 364 | /// Specify the work function with two arguments that will execute when this job runs and add it to the schedule 365 | /// ```rust 366 | /// # use skedge::*; 367 | /// fn job(name: &str, time: &str) { 368 | /// println!("Hello, {name}! What are you doing {time}?"); 369 | /// } 370 | /// # fn main() -> Result<()> { 371 | /// # let mut scheduler = Scheduler::new(); 372 | /// 373 | /// every(10) 374 | /// .seconds()? 375 | /// .run_two_args(&mut scheduler, job, "Good-Looking", "this weekend")?; 376 | /// # Ok(()) 377 | /// # } 378 | /// ``` 379 | /// 380 | /// # Errors 381 | /// 382 | /// Returns an error if unable to schedule the run. 383 | pub fn run_two_args( 384 | mut self, 385 | scheduler: &mut Scheduler, 386 | job: fn(T, U) -> (), 387 | arg_one: T, 388 | arg_two: U, 389 | ) -> Result<()> 390 | where 391 | T: 'static + Clone, 392 | U: 'static + Clone, 393 | { 394 | self.job = Some(Box::new(TwoToUnit::new( 395 | "job_two_args", 396 | job, 397 | arg_one, 398 | arg_two, 399 | ))); 400 | self.schedule_next_run(&scheduler.now())?; 401 | scheduler.add_job(self); 402 | Ok(()) 403 | } 404 | 405 | /// Specify the work function with three arguments that will execute when this job runs and add it to the schedule 406 | /// ```rust 407 | /// # use skedge::*; 408 | /// fn job(name: &str, time: &str, hour: u8) { 409 | /// println!( 410 | /// "Hello, {name}! What are you doing {time}? I'm free around {hour}." 411 | /// ); 412 | /// } 413 | /// # fn main() -> Result<()> { 414 | /// # let mut scheduler = Scheduler::new(); 415 | /// 416 | /// every(10) 417 | /// .seconds()? 418 | /// .run_three_args(&mut scheduler, job, "Good-Looking", "Friday", 7)?; 419 | /// # Ok(()) 420 | /// # } 421 | /// ``` 422 | /// 423 | /// # Errors 424 | /// 425 | /// Returns an error if unable to schedule the run. 426 | pub fn run_three_args( 427 | mut self, 428 | scheduler: &mut Scheduler, 429 | job: fn(T, U, V) -> (), 430 | arg_one: T, 431 | arg_two: U, 432 | arg_three: V, 433 | ) -> Result<()> 434 | where 435 | T: 'static + Clone, 436 | U: 'static + Clone, 437 | V: 'static + Clone, 438 | { 439 | self.job = Some(Box::new(ThreeToUnit::new( 440 | "job_three_args", 441 | job, 442 | arg_one, 443 | arg_two, 444 | arg_three, 445 | ))); 446 | self.schedule_next_run(&scheduler.now())?; 447 | scheduler.add_job(self); 448 | Ok(()) 449 | } 450 | 451 | /// Specify the work function with four arguments that will execute when this job runs and add it to the schedule 452 | /// ```rust 453 | /// # use skedge::*; 454 | /// fn job(name: &str, time: &str, hour: u8, jackpot: i32) { 455 | /// println!( 456 | /// "Hello, {name}! What are you doing {time}? I'm free around {hour}. \ 457 | /// I just won ${jackpot} off a scratch ticket, you can get anything you want." 458 | /// ); 459 | /// } 460 | /// 461 | /// # fn main() -> Result<()> { 462 | /// # let mut scheduler = Scheduler::new(); 463 | /// 464 | /// every(10) 465 | /// .seconds()? 466 | /// .run_four_args(&mut scheduler, job, "Good-Looking", "Friday", 7, 40)?; 467 | /// # Ok(()) 468 | /// # } 469 | /// ``` 470 | /// 471 | /// # Errors 472 | /// 473 | /// Returns an error if unable to schedule the run. 474 | pub fn run_four_args( 475 | mut self, 476 | scheduler: &mut Scheduler, 477 | job: fn(T, U, V, W) -> (), 478 | arg_one: T, 479 | arg_two: U, 480 | arg_three: V, 481 | arg_four: W, 482 | ) -> Result<()> 483 | where 484 | T: 'static + Clone, 485 | U: 'static + Clone, 486 | V: 'static + Clone, 487 | W: 'static + Clone, 488 | { 489 | self.job = Some(Box::new(FourToUnit::new( 490 | "job_four_args", 491 | job, 492 | arg_one, 493 | arg_two, 494 | arg_three, 495 | arg_four, 496 | ))); 497 | self.schedule_next_run(&scheduler.now())?; 498 | scheduler.add_job(self); 499 | Ok(()) 500 | } 501 | 502 | /// Specify the work function with five arguments that will execute when this job runs and add it to the schedule 503 | /// ```rust 504 | /// # use skedge::*; 505 | /// fn job(name: &str, time: &str, hour: u8, jackpot: i32, restaurant: &str) { 506 | /// println!( 507 | /// "Hello, {name}! What are you doing {time}? I'm free around {hour}. \ 508 | /// I just won ${jackpot} off a scratch ticket, you can get anything you want. \ 509 | /// Have you ever been to {restaurant}? It's getting rave reviews." 510 | /// ); 511 | /// } 512 | /// 513 | /// # fn main() -> Result<()> { 514 | /// # let mut scheduler = Scheduler::new(); 515 | /// 516 | /// every(10) 517 | /// .seconds()? 518 | /// .run_five_args(&mut scheduler, job, "Good-Looking", "Friday", 7, 40, "Dorsia")?; 519 | /// # Ok(()) 520 | /// # } 521 | /// ``` 522 | /// 523 | /// # Errors 524 | /// 525 | /// Returns an error if unable to schedule the run. 526 | #[allow(clippy::too_many_arguments)] 527 | pub fn run_five_args( 528 | mut self, 529 | scheduler: &mut Scheduler, 530 | job: fn(T, U, V, W, X) -> (), 531 | arg_one: T, 532 | arg_two: U, 533 | arg_three: V, 534 | arg_four: W, 535 | arg_five: X, 536 | ) -> Result<()> 537 | where 538 | T: 'static + Clone, 539 | U: 'static + Clone, 540 | V: 'static + Clone, 541 | W: 'static + Clone, 542 | X: 'static + Clone, 543 | { 544 | self.job = Some(Box::new(FiveToUnit::new( 545 | "job_four_args", 546 | job, 547 | arg_one, 548 | arg_two, 549 | arg_three, 550 | arg_four, 551 | arg_five, 552 | ))); 553 | self.schedule_next_run(&scheduler.now())?; 554 | scheduler.add_job(self); 555 | Ok(()) 556 | } 557 | 558 | /// Specify the work function with six arguments that will execute when this job runs and add it to the schedule 559 | /// ```rust 560 | /// # use skedge::*; 561 | /// fn job(name: &str, time: &str, hour: u8, jackpot: i32, restaurant: &str, meal: &str) { 562 | /// println!( 563 | /// "Hello, {name}! What are you doing {time}? I'm free around {hour}. \ 564 | /// I just won ${jackpot} off a scratch ticket, you can get anything you want. \ 565 | /// Have you ever been to {restaurant}? They're getting rave reviews over their {meal}." 566 | /// ); 567 | /// } 568 | /// 569 | /// # fn main() -> Result<()> { 570 | /// # let mut scheduler = Scheduler::new(); 571 | /// 572 | /// every(10) 573 | /// .seconds()? 574 | /// .run_six_args( 575 | /// &mut scheduler, 576 | /// job, 577 | /// "Good-Looking", 578 | /// "Friday", 579 | /// 7, 580 | /// 40, 581 | /// "Dorsia", 582 | /// "foraged chanterelle croque monsieur", 583 | /// )?; 584 | /// # Ok(()) 585 | /// # } 586 | /// ``` 587 | /// 588 | /// # Errors 589 | /// 590 | /// Returns an error if unable to schedule the run. 591 | #[allow(clippy::too_many_arguments)] 592 | pub fn run_six_args( 593 | mut self, 594 | scheduler: &mut Scheduler, 595 | job: fn(T, U, V, W, X, Y) -> (), 596 | arg_one: T, 597 | arg_two: U, 598 | arg_three: V, 599 | arg_four: W, 600 | arg_five: X, 601 | arg_six: Y, 602 | ) -> Result<()> 603 | where 604 | T: 'static + Clone, 605 | U: 'static + Clone, 606 | V: 'static + Clone, 607 | W: 'static + Clone, 608 | X: 'static + Clone, 609 | Y: 'static + Clone, 610 | { 611 | self.job = Some(Box::new(SixToUnit::new( 612 | "job_four_args", 613 | job, 614 | arg_one, 615 | arg_two, 616 | arg_three, 617 | arg_four, 618 | arg_five, 619 | arg_six, 620 | ))); 621 | self.schedule_next_run(&scheduler.now())?; 622 | scheduler.add_job(self); 623 | Ok(()) 624 | } 625 | 626 | /// Check whether this job should be run now 627 | // FIXME I think this belongs on Scheduler 628 | pub(crate) fn should_run(&self, now: &Zoned) -> bool { 629 | self.next_run.is_some() && now >= self.next_run.as_ref().unwrap() 630 | } 631 | 632 | /// Run this job and immediately reschedule it, returning true. If job should cancel, return false. 633 | /// 634 | /// If the job's deadline has arrived already, the job does not run and returns false. 635 | /// 636 | /// If this execution causes the deadline to reach, it will run once and then return false. 637 | /// 638 | /// # Errors 639 | /// 640 | /// Returns an error if unable to schedule the run. 641 | // FIXME: if we support return values from job fns, this fn should return that. 642 | // FIXME: I think this also belongs on scheduler 643 | pub fn execute(&mut self, now: &Zoned) -> Result { 644 | if self.is_overdue(now) { 645 | debug!("Deadline already reached, cancelling job {self}"); 646 | return Ok(false); 647 | } 648 | 649 | debug!("Running job {self}"); 650 | if self.job.is_none() { 651 | debug!("No work scheduled, moving on..."); 652 | return Ok(true); 653 | } 654 | // FIXME - here's the return value capture 655 | let _ = self.job.as_ref().ok_or(Error::CallableUnreachable)?.call(); 656 | #[cfg(test)] 657 | { 658 | self.call_count += 1; 659 | } 660 | self.last_run = Some(now.clone()); 661 | self.schedule_next_run(now)?; 662 | 663 | if self.is_overdue(now) { 664 | debug!("Execution went over deadline, cancelling job {self}",); 665 | return Ok(false); 666 | } 667 | 668 | Ok(true) 669 | } 670 | 671 | /// Shared logic for setting the job to a particular unit 672 | fn set_unit_mode(mut self, unit: Unit) -> Result { 673 | if let Some(u) = self.unit { 674 | Err(unit_error(unit, u)) 675 | } else { 676 | self.unit = Some(unit); 677 | Ok(self) 678 | } 679 | } 680 | 681 | /// Shared logic for setting single-interval units: second(), minute(), etc. 682 | fn set_single_unit_mode(self, unit: Unit) -> Result { 683 | if self.interval == 1 { 684 | self.set_unit_mode(unit) 685 | } else { 686 | Err(interval_error(unit)) 687 | } 688 | } 689 | 690 | /// Set single second mode 691 | /// # Errors 692 | /// 693 | /// Returns an error if this assignment is incompatible with the current configuration. 694 | pub fn second(self) -> Result { 695 | self.set_single_unit_mode(Unit::Second) 696 | } 697 | 698 | /// Set seconds mode 699 | /// # Errors 700 | /// 701 | /// Returns an error if this assignment is incompatible with the current configuration. 702 | pub fn seconds(self) -> Result { 703 | self.set_unit_mode(Unit::Second) 704 | } 705 | 706 | /// Set single minute mode 707 | /// # Errors 708 | /// 709 | /// Returns an error if this assignment is incompatible with the current configuration. 710 | pub fn minute(self) -> Result { 711 | self.set_single_unit_mode(Unit::Minute) 712 | } 713 | 714 | /// Set minutes mode 715 | /// # Errors 716 | /// 717 | /// Returns an error if this assignment is incompatible with the current configuration. 718 | pub fn minutes(self) -> Result { 719 | self.set_unit_mode(Unit::Minute) 720 | } 721 | 722 | /// Set single hour mode 723 | /// # Errors 724 | /// 725 | /// Returns an error if this assignment is incompatible with the current configuration. 726 | pub fn hour(self) -> Result { 727 | self.set_single_unit_mode(Unit::Hour) 728 | } 729 | 730 | /// Set hours mode 731 | /// # Errors 732 | /// 733 | /// Returns an error if this assignment is incompatible with the current configuration. 734 | pub fn hours(self) -> Result { 735 | self.set_unit_mode(Unit::Hour) 736 | } 737 | 738 | /// Set single day mode 739 | /// # Errors 740 | /// 741 | /// Returns an error if this assignment is incompatible with the current configuration. 742 | pub fn day(self) -> Result { 743 | self.set_single_unit_mode(Unit::Day) 744 | } 745 | 746 | /// Set days mode 747 | /// # Errors 748 | /// 749 | /// Returns an error if this assignment is incompatible with the current configuration. 750 | pub fn days(self) -> Result { 751 | self.set_unit_mode(Unit::Day) 752 | } 753 | 754 | /// Set single week mode 755 | /// # Errors 756 | /// 757 | /// Returns an error if this assignment is incompatible with the current configuration. 758 | pub fn week(self) -> Result { 759 | self.set_single_unit_mode(Unit::Week) 760 | } 761 | 762 | /// Set weeks mode 763 | /// # Errors 764 | /// 765 | /// Returns an error if this assignment is incompatible with the current configuration. 766 | pub fn weeks(self) -> Result { 767 | self.set_unit_mode(Unit::Week) 768 | } 769 | 770 | /// Set single month mode 771 | /// # Errors 772 | /// 773 | /// Returns an error if this assignment is incompatible with the current configuration. 774 | pub fn month(self) -> Result { 775 | self.set_single_unit_mode(Unit::Month) 776 | } 777 | 778 | /// Set months mode 779 | /// # Errors 780 | /// 781 | /// Returns an error if this assignment is incompatible with the current configuration. 782 | pub fn months(self) -> Result { 783 | self.set_unit_mode(Unit::Month) 784 | } 785 | 786 | /// Set single year mode 787 | /// # Errors 788 | /// 789 | /// Returns an error if this assignment is incompatible with the current configuration. 790 | pub fn year(self) -> Result { 791 | self.set_single_unit_mode(Unit::Year) 792 | } 793 | 794 | /// Set years mode 795 | /// # Errors 796 | /// 797 | /// Returns an error if this assignment is incompatible with the current configuration. 798 | pub fn years(self) -> Result { 799 | self.set_unit_mode(Unit::Year) 800 | } 801 | 802 | /// Set weekly mode on a specific day of the week 803 | /// # Errors 804 | /// 805 | /// Returns an error if this assignment is incompatible with the current configuration. 806 | fn set_weekday_mode(mut self, weekday: civil::Weekday) -> Result { 807 | if self.interval != 1 { 808 | Err(weekday_error(weekday)) 809 | } else if let Some(w) = self.start_day { 810 | Err(weekday_collision_error(weekday, w)) 811 | } else { 812 | self.start_day = Some(weekday); 813 | self.weeks() 814 | } 815 | } 816 | 817 | /// Set weekly mode on Monday 818 | /// # Errors 819 | /// 820 | /// Returns an error if this assignment is incompatible with the current configuration. 821 | pub fn monday(self) -> Result { 822 | self.set_weekday_mode(civil::Weekday::Monday) 823 | } 824 | 825 | /// Set weekly mode on Tuesday 826 | /// # Errors 827 | /// 828 | /// Returns an error if this assignment is incompatible with the current configuration. 829 | pub fn tuesday(self) -> Result { 830 | self.set_weekday_mode(civil::Weekday::Tuesday) 831 | } 832 | 833 | /// Set weekly mode on Wednesday 834 | /// # Errors 835 | /// 836 | /// Returns an error if this assignment is incompatible with the current configuration. 837 | pub fn wednesday(self) -> Result { 838 | self.set_weekday_mode(civil::Weekday::Wednesday) 839 | } 840 | 841 | /// Set weekly mode on Thursday 842 | /// # Errors 843 | /// 844 | /// Returns an error if this assignment is incompatible with the current configuration. 845 | pub fn thursday(self) -> Result { 846 | self.set_weekday_mode(civil::Weekday::Thursday) 847 | } 848 | 849 | /// Set weekly mode on Friday 850 | /// # Errors 851 | /// 852 | /// Returns an error if this assignment is incompatible with the current configuration. 853 | pub fn friday(self) -> Result { 854 | self.set_weekday_mode(civil::Weekday::Friday) 855 | } 856 | 857 | /// Set weekly mode on Saturday 858 | /// # Errors 859 | /// 860 | /// Returns an error if this assignment is incompatible with the current configuration. 861 | pub fn saturday(self) -> Result { 862 | self.set_weekday_mode(civil::Weekday::Saturday) 863 | } 864 | 865 | /// Set weekly mode on Sunday 866 | /// # Errors 867 | /// 868 | /// Returns an error if this assignment is incompatible with the current configuration. 869 | pub fn sunday(self) -> Result { 870 | self.set_weekday_mode(civil::Weekday::Sunday) 871 | } 872 | 873 | /// Compute the timestamp for the next run 874 | fn schedule_next_run(&mut self, now: &Zoned) -> Result<()> { 875 | // If "latest" is set, find the actual interval for this run, otherwise just used stored val 876 | let interval = { 877 | #[cfg(feature = "random")] 878 | match self.latest { 879 | Some(v) => { 880 | if v < self.interval { 881 | return Err(Error::InvalidInterval); 882 | } 883 | thread_rng().gen_range(self.interval..v) 884 | }, 885 | None => self.interval, 886 | } 887 | #[cfg(not(feature = "random"))] 888 | self.interval 889 | }; 890 | 891 | // Calculate period (Duration) 892 | let period = self.unit()?.duration(interval); 893 | self.period = Some(period); 894 | self.next_run = Some(now + period); 895 | 896 | // Handle start day for weekly jobs 897 | if let Some(w) = self.start_day { 898 | // This only makes sense for weekly jobs 899 | if self.unit != Some(Unit::Week) { 900 | return Err(Error::StartDayError); 901 | } 902 | 903 | let weekday_num = w.to_monday_zero_offset(); 904 | let mut days_ahead = i64::from(weekday_num) 905 | - i64::from( 906 | self.next_run 907 | .as_ref() 908 | .ok_or(Error::NextRunUnreachable)? 909 | .date() 910 | .weekday() 911 | .to_monday_zero_offset(), 912 | ); 913 | 914 | // Check if the weekday already happened this week, advance a week if so 915 | if days_ahead <= 0 { 916 | days_ahead += 7; 917 | } 918 | 919 | self.next_run = Some( 920 | self.next_run()? 921 | .checked_add(Unit::Day.duration(u32::try_from(days_ahead).unwrap())) 922 | .unwrap() 923 | .checked_sub(&self.period()?) 924 | .unwrap(), 925 | ); 926 | } 927 | 928 | // Handle specified at_time 929 | if let Some(at_t) = self.at_time { 930 | use Unit::{Day, Hour, Minute}; 931 | // Validate configuration 932 | if ![Some(Day), Some(Hour), Some(Minute)].contains(&self.unit) 933 | && self.start_day.is_none() 934 | { 935 | return Err(Error::UnspecifiedStartDay); 936 | } 937 | 938 | // Update next_run appropriately 939 | let next_run = self.next_run()?; 940 | let second = at_t.second(); 941 | let hour = if self.unit == Some(Day) || self.start_day.is_some() { 942 | at_t.hour() 943 | } else { 944 | next_run.hour() 945 | }; 946 | let minute = if [Some(Day), Some(Hour)].contains(&self.unit) || self.start_day.is_some() 947 | { 948 | at_t.minute() 949 | } else { 950 | next_run.minute() 951 | }; 952 | let naive_time = civil::time(hour, minute, second, 0); 953 | let naive_date = next_run.date(); 954 | let tz = next_run.time_zone(); 955 | let local_datetime = civil::DateTime::from_parts(naive_date, naive_time) 956 | .to_zoned(tz.clone()) 957 | .unwrap(); 958 | self.next_run = Some(local_datetime); 959 | 960 | // Make sure job gets run TODAY or THIS HOUR 961 | // Accounting for jobs take long enough that they finish in the next period 962 | if self.last_run.is_none() 963 | || self 964 | .next_run()? 965 | .since(&self.last_run()?) 966 | .unwrap() 967 | .compare(self.period()?) 968 | .unwrap() == std::cmp::Ordering::Greater 969 | { 970 | if self.unit == Some(Day) 971 | && self.at_time.unwrap() > now.time() 972 | && self.interval == 1 973 | { 974 | // FIXME all of this should be jiffier 975 | self.next_run = Some( 976 | self.next_run 977 | .as_ref() 978 | .unwrap() 979 | .checked_sub(Day.duration(1)) 980 | .unwrap(), 981 | ); 982 | } else if self.unit == Some(Hour) 983 | && (self.at_time.unwrap().minute() > now.minute() 984 | || self.at_time.unwrap().minute() == now.minute() 985 | && self.at_time.unwrap().second() > now.second()) 986 | { 987 | self.next_run = Some(self.next_run()?.checked_sub(Hour.duration(1)).unwrap()); 988 | } else if self.unit == Some(Minute) && self.at_time.unwrap().second() > now.second() 989 | { 990 | self.next_run = Some(self.next_run()?.checked_sub(Minute.duration(1)).unwrap()); 991 | } 992 | } 993 | } 994 | 995 | // Check if at_time on given day should fire today or next week 996 | if self.start_day.is_some() && self.at_time.is_some() { 997 | // unwraps are safe, we already set them in this function 998 | let next = self.next_run.as_ref().unwrap(); // safe, we already set it 999 | if now.until(next).unwrap().get_days() >= 7 { 1000 | self.next_run = Some(next.checked_sub(self.period.unwrap()).unwrap()); 1001 | } 1002 | } 1003 | 1004 | Ok(()) 1005 | } 1006 | 1007 | /// Check if given time is after the `cancel_after` time 1008 | fn is_overdue(&self, when: &Zoned) -> bool { 1009 | self.cancel_after.is_some() && when > self.cancel_after.as_ref().unwrap() 1010 | } 1011 | 1012 | pub(crate) fn last_run(&self) -> Result { 1013 | self.last_run.clone().ok_or(Error::LastRunUnreachable) 1014 | } 1015 | 1016 | pub(crate) fn next_run(&self) -> Result { 1017 | self.next_run.clone().ok_or(Error::NextRunUnreachable) 1018 | } 1019 | 1020 | pub(crate) fn period(&self) -> Result { 1021 | self.period.ok_or(Error::PeriodUnreachable) 1022 | } 1023 | 1024 | pub(crate) fn unit(&self) -> Result { 1025 | self.unit.ok_or(Error::UnitUnreachable) 1026 | } 1027 | } 1028 | 1029 | impl PartialOrd for Job { 1030 | fn partial_cmp(&self, other: &Self) -> Option { 1031 | Some(self.cmp(other)) 1032 | } 1033 | } 1034 | 1035 | impl Ord for Job { 1036 | fn cmp(&self, other: &Self) -> Ordering { 1037 | // Sorting is based on the next scheduled run 1038 | self.next_run.cmp(&other.next_run) 1039 | } 1040 | } 1041 | 1042 | impl fmt::Display for Job { 1043 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 1044 | let name = if self.job.is_none() { 1045 | "No Job" 1046 | } else { 1047 | let j = self.job.as_ref().unwrap(); 1048 | j.name() 1049 | }; 1050 | let interval = self.interval; 1051 | let unit = self.unit; 1052 | write!(f, "Job(interval={interval}, unit={unit:?}, run={name})") 1053 | } 1054 | } 1055 | 1056 | #[cfg(test)] 1057 | mod tests { 1058 | use super::*; 1059 | use pretty_assertions::assert_eq; 1060 | 1061 | #[test] 1062 | fn test_plural_time_units() -> Result<()> { 1063 | use Unit::{Day, Hour, Minute, Month, Second, Week, Year}; 1064 | assert_eq!(every(2).seconds()?.unit, Some(Second)); 1065 | assert_eq!(every(2).minutes()?.unit, Some(Minute)); 1066 | assert_eq!(every(2).hours()?.unit, Some(Hour)); 1067 | assert_eq!(every(2).days()?.unit, Some(Day)); 1068 | assert_eq!(every(2).weeks()?.unit, Some(Week)); 1069 | assert_eq!(every(2).months()?.unit, Some(Month)); 1070 | assert_eq!(every(2).years()?.unit, Some(Year)); 1071 | // Okay to use plural method with singular interval: 1072 | assert_eq!(every(1).seconds()?.unit, Some(Second)); 1073 | assert_eq!(every(1).minutes()?.unit, Some(Minute)); 1074 | assert_eq!(every(1).hours()?.unit, Some(Hour)); 1075 | assert_eq!(every(1).days()?.unit, Some(Day)); 1076 | assert_eq!(every(1).weeks()?.unit, Some(Week)); 1077 | assert_eq!(every(1).months()?.unit, Some(Month)); 1078 | assert_eq!(every(1).years()?.unit, Some(Year)); 1079 | Ok(()) 1080 | } 1081 | 1082 | #[test] 1083 | fn test_singular_time_units() -> Result<()> { 1084 | use Unit::{Day, Hour, Minute, Month, Second, Week, Year}; 1085 | assert_eq!(every(1), every_single()); 1086 | assert_eq!(every_single().second()?.unit, Some(Second)); 1087 | assert_eq!(every_single().minute()?.unit, Some(Minute)); 1088 | assert_eq!(every_single().hour()?.unit, Some(Hour)); 1089 | assert_eq!(every_single().day()?.unit, Some(Day)); 1090 | assert_eq!(every_single().week()?.unit, Some(Week)); 1091 | assert_eq!(every_single().month()?.unit, Some(Month)); 1092 | assert_eq!(every_single().year()?.unit, Some(Year)); 1093 | Ok(()) 1094 | } 1095 | 1096 | #[test] 1097 | fn test_singular_unit_plural_interval_mismatch() { 1098 | assert_eq!( 1099 | every(2).second().unwrap_err().to_string(), 1100 | "Use seconds() instead of second()".to_string() 1101 | ); 1102 | assert_eq!( 1103 | every(2).minute().unwrap_err().to_string(), 1104 | "Use minutes() instead of minute()".to_string() 1105 | ); 1106 | assert_eq!( 1107 | every(2).hour().unwrap_err().to_string(), 1108 | "Use hours() instead of hour()".to_string() 1109 | ); 1110 | assert_eq!( 1111 | every(2).day().unwrap_err().to_string(), 1112 | "Use days() instead of day()".to_string() 1113 | ); 1114 | assert_eq!( 1115 | every(2).week().unwrap_err().to_string(), 1116 | "Use weeks() instead of week()".to_string() 1117 | ); 1118 | assert_eq!( 1119 | every(2).month().unwrap_err().to_string(), 1120 | "Use months() instead of month()".to_string() 1121 | ); 1122 | assert_eq!( 1123 | every(2).year().unwrap_err().to_string(), 1124 | "Use years() instead of year()".to_string() 1125 | ); 1126 | } 1127 | 1128 | #[test] 1129 | fn test_singular_units_match_plural_units() -> Result<()> { 1130 | assert_eq!(every(1).second()?.unit, every(1).seconds()?.unit); 1131 | assert_eq!(every(1).minute()?.unit, every(1).minutes()?.unit); 1132 | assert_eq!(every(1).hour()?.unit, every(1).hours()?.unit); 1133 | assert_eq!(every(1).day()?.unit, every(1).days()?.unit); 1134 | assert_eq!(every(1).week()?.unit, every(1).weeks()?.unit); 1135 | assert_eq!(every(1).month()?.unit, every(1).months()?.unit); 1136 | assert_eq!(every(1).year()?.unit, every(1).years()?.unit); 1137 | Ok(()) 1138 | } 1139 | 1140 | #[test] 1141 | fn test_reject_weekday_multiple_weeks() { 1142 | assert_eq!( 1143 | every(2).monday().unwrap_err().to_string(), 1144 | "Scheduling jobs on Monday is only allowed for weekly jobs. Using specific days on a job scheduled to run every 2 or more weeks is not supported".to_string() 1145 | ); 1146 | assert_eq!( 1147 | every(2).tuesday().unwrap_err().to_string(), 1148 | "Scheduling jobs on Tuesday is only allowed for weekly jobs. Using specific days on a job scheduled to run every 2 or more weeks is not supported".to_string() 1149 | ); 1150 | assert_eq!( 1151 | every(2).wednesday().unwrap_err().to_string(), 1152 | "Scheduling jobs on Wednesday is only allowed for weekly jobs. Using specific days on a job scheduled to run every 2 or more weeks is not supported".to_string() 1153 | ); 1154 | assert_eq!( 1155 | every(2).thursday().unwrap_err().to_string(), 1156 | "Scheduling jobs on Thursday is only allowed for weekly jobs. Using specific days on a job scheduled to run every 2 or more weeks is not supported".to_string() 1157 | ); 1158 | assert_eq!( 1159 | every(2).friday().unwrap_err().to_string(), 1160 | "Scheduling jobs on Friday is only allowed for weekly jobs. Using specific days on a job scheduled to run every 2 or more weeks is not supported".to_string() 1161 | ); 1162 | assert_eq!( 1163 | every(2).saturday().unwrap_err().to_string(), 1164 | "Scheduling jobs on Saturday is only allowed for weekly jobs. Using specific days on a job scheduled to run every 2 or more weeks is not supported".to_string() 1165 | ); 1166 | assert_eq!( 1167 | every(2).sunday().unwrap_err().to_string(), 1168 | "Scheduling jobs on Sunday is only allowed for weekly jobs. Using specific days on a job scheduled to run every 2 or more weeks is not supported".to_string() 1169 | ); 1170 | } 1171 | 1172 | #[test] 1173 | fn test_reject_start_day_unless_weekly() { 1174 | let mut job = every_single(); 1175 | let expected = "Attempted to use a start day for a unit other than `weeks`".to_string(); 1176 | job.unit = Some(Unit::Day); 1177 | job.start_day = Some(civil::Weekday::Wednesday); 1178 | assert_eq!( 1179 | job.schedule_next_run(&Zoned::now()) 1180 | .unwrap_err() 1181 | .to_string(), 1182 | expected 1183 | ); 1184 | } 1185 | 1186 | #[test] 1187 | fn test_reject_multiple_time_units() -> Result<()> { 1188 | assert_eq!( 1189 | every_single().day()?.wednesday().unwrap_err().to_string(), 1190 | "Cannot set weeks mode, already using days".to_string() 1191 | ); 1192 | assert_eq!( 1193 | every_single().minute()?.second().unwrap_err().to_string(), 1194 | "Cannot set seconds mode, already using minutes".to_string() 1195 | ); 1196 | // TODO etc... 1197 | Ok(()) 1198 | } 1199 | 1200 | #[test] 1201 | fn test_reject_invalid_at_time() -> Result<()> { 1202 | let bad_hour = "Invalid hour (25 is not between 0 and 23)".to_string(); 1203 | let bad_daily = 1204 | "Invalid time format for daily job (valid format is HH:MM(:SS)?)".to_string(); 1205 | let bad_hourly = 1206 | "Invalid time format for hourly job (valid format is (MM)?:SS)".to_string(); 1207 | let bad_minutely = "Invalid time format for minutely job (valid format is :SS)".to_string(); 1208 | let bad_unit = "Invalid unit (valid units are `days`, `hours`, and `minutes`)".to_string(); 1209 | assert_eq!( 1210 | every_single() 1211 | .second()? 1212 | .at("13:15") 1213 | .unwrap_err() 1214 | .to_string(), 1215 | bad_unit 1216 | ); 1217 | assert_eq!( 1218 | every_single() 1219 | .day()? 1220 | .at("25:00:00") 1221 | .unwrap_err() 1222 | .to_string(), 1223 | bad_hour 1224 | ); 1225 | assert_eq!( 1226 | every_single() 1227 | .day()? 1228 | .at("00:61:00") 1229 | .unwrap_err() 1230 | .to_string(), 1231 | bad_daily 1232 | ); 1233 | assert_eq!( 1234 | every_single() 1235 | .day()? 1236 | .at("00:00:61") 1237 | .unwrap_err() 1238 | .to_string(), 1239 | bad_daily 1240 | ); 1241 | assert_eq!( 1242 | every_single() 1243 | .day()? 1244 | .at("00:61:00") 1245 | .unwrap_err() 1246 | .to_string(), 1247 | bad_daily 1248 | ); 1249 | assert_eq!( 1250 | every_single().day()?.at("25:0:0").unwrap_err().to_string(), 1251 | bad_daily 1252 | ); 1253 | assert_eq!( 1254 | every_single().day()?.at("0:61:0").unwrap_err().to_string(), 1255 | bad_daily 1256 | ); 1257 | assert_eq!( 1258 | every_single().day()?.at("0:0:61").unwrap_err().to_string(), 1259 | bad_daily 1260 | ); 1261 | assert_eq!( 1262 | every_single() 1263 | .hour()? 1264 | .at("23:59:29") 1265 | .unwrap_err() 1266 | .to_string(), 1267 | bad_hourly 1268 | ); 1269 | assert_eq!( 1270 | every_single().hour()?.at("61:00").unwrap_err().to_string(), 1271 | bad_hourly 1272 | ); 1273 | assert_eq!( 1274 | every_single().hour()?.at("00:61").unwrap_err().to_string(), 1275 | bad_hourly 1276 | ); 1277 | assert_eq!( 1278 | every_single().hour()?.at(":61").unwrap_err().to_string(), 1279 | bad_hourly 1280 | ); 1281 | assert_eq!( 1282 | every_single() 1283 | .minute()? 1284 | .at("22:45:34") 1285 | .unwrap_err() 1286 | .to_string(), 1287 | bad_minutely 1288 | ); 1289 | assert_eq!( 1290 | every_single().minute()?.at(":61").unwrap_err().to_string(), 1291 | bad_minutely 1292 | ); 1293 | Ok(()) 1294 | } 1295 | 1296 | #[test] 1297 | #[cfg(feature = "random")] 1298 | fn test_latest_greater_than_interval() { 1299 | assert_eq!( 1300 | every(2).to(1).unwrap_err().to_string(), 1301 | "Latest val is greater than interval val".to_string() 1302 | ); 1303 | assert_eq!(every(2).to(3).unwrap().latest, Some(3)); 1304 | } 1305 | } 1306 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | //! # skedge 2 | //! 3 | //! `skedge` is a single-process job scheduler. 4 | //! To use the optional CFFI, enable the "ffi" feature. 5 | //! 6 | //! Define a work function: 7 | //! ```rust 8 | //! fn job() { 9 | //! println!("Hello, it's {}!", jiff::Zoned::now()); 10 | //! } 11 | //! ``` 12 | //! You can use up to six arguments: 13 | //! ```rust 14 | //! fn greet(name: &str) { 15 | //! println!("Hello, {}!", name); 16 | //! } 17 | //! ``` 18 | //! Instantiate a `Scheduler` and schedule jobs: 19 | //! ```rust 20 | //! # use skedge::{Scheduler, every, every_single}; 21 | //! # use jiff::{ToSpan as _, Zoned}; 22 | //! # use std::time::Duration; 23 | //! # use std::thread::sleep; 24 | //! # fn job() { 25 | //! # println!("Hello, it's {}!", Zoned::now()); 26 | //! # } 27 | //! # fn greet(name: &str) { 28 | //! # println!("Hello, {}!", name); 29 | //! # } 30 | //! # fn main() -> Result<(), Box> { 31 | //! let mut schedule = Scheduler::new(); 32 | //! 33 | //! every(10).seconds()?.run(&mut schedule, job)?; 34 | //! 35 | //! every(10).minutes()?.run(&mut schedule, job)?; 36 | //! 37 | //! every_single().hour()?.run(&mut schedule, job)?; 38 | //! 39 | //! every_single().day()?.at("10:30")?.run(&mut schedule, job)?; 40 | //! 41 | //! #[cfg(feature = "random")] 42 | //! every(5).to(10)?.minutes()?.run(&mut schedule, job)?; 43 | //! 44 | //! every_single().monday()?.run(&mut schedule, job)?; 45 | //! 46 | //! every_single().wednesday()?.at("13:15")?.run(&mut schedule, job)?; 47 | //! 48 | //! every_single().minute()?.at(":17")?.run(&mut schedule, job)?; 49 | //! 50 | //! #[cfg(feature = "random")] 51 | //! every(2) 52 | //! .to(8)? 53 | //! .seconds()? 54 | //! .until(Zoned::now().checked_add(30.seconds())?)? 55 | //! .run_one_arg(&mut schedule, greet, "Cool Person")?; 56 | //! # Ok(()) 57 | //! # } 58 | //! ``` 59 | //! Note that you must use the appropriate `run_x_args()` method for job functions taking multiple arguments. 60 | //! In your main loop, you can use `Scheduler::run_pending()` to fire all scheduled jobs at the proper time: 61 | //! ```no_run 62 | //! # use skedge::Scheduler; 63 | //! # let mut schedule = Scheduler::new(); 64 | //! loop { 65 | //! if let Err(e) = schedule.run_pending() { 66 | //! eprintln!("Error: {e}"); 67 | //! } 68 | //! std::thread::sleep(std::time::Duration::from_secs(1)); 69 | //! } 70 | //! ``` 71 | 72 | #![warn(clippy::pedantic)] 73 | 74 | mod callable; 75 | mod error; 76 | mod job; 77 | mod scheduler; 78 | mod time; 79 | 80 | use callable::{ 81 | Callable, FiveToUnit, FourToUnit, OneToUnit, SixToUnit, ThreeToUnit, TwoToUnit, UnitToUnit, 82 | }; 83 | pub use error::*; 84 | pub use job::{every, every_single, Interval, Job, Tag}; 85 | pub use scheduler::Scheduler; 86 | use time::{Clock, Timekeeper, Unit}; 87 | 88 | #[cfg(feature = "ffi")] 89 | mod ffi; 90 | #[cfg(feature = "ffi")] 91 | pub use ffi::*; 92 | -------------------------------------------------------------------------------- /src/scheduler.rs: -------------------------------------------------------------------------------- 1 | //! The scheduler is responsible for managing all scheduled jobs. 2 | 3 | use crate::{Clock, Job, Result, Tag, Timekeeper}; 4 | use jiff::{SpanRound, Unit, Zoned}; 5 | use tracing::debug; 6 | 7 | /// A Scheduler creates jobs, tracks recorded jobs, and executes jobs. 8 | #[derive(Debug, Default)] 9 | pub struct Scheduler { 10 | /// The currently scheduled lob list 11 | jobs: Vec, 12 | /// Interface to current time 13 | clock: Clock, 14 | } 15 | 16 | impl Scheduler { 17 | /// Instantiate a Scheduler 18 | #[must_use] 19 | pub fn new() -> Self { 20 | Self::default() 21 | } 22 | 23 | /// Instantiate with mocked time 24 | #[cfg(test)] 25 | fn with_mock_time(clock: crate::time::mock::Mock) -> Self { 26 | Self { 27 | clock: Clock::Mock(clock), 28 | ..Default::default() 29 | } 30 | } 31 | 32 | /// Add a new job to the list 33 | pub(crate) fn add_job(&mut self, job: Job) { 34 | self.jobs.push(job); 35 | } 36 | 37 | /// Run all jobs that are scheduled to run. Does NOT run missed jobs! 38 | /// ```rust 39 | /// # use skedge::{every, Scheduler}; 40 | /// # fn job() {} 41 | /// # fn main() -> Result<(), Box> { 42 | /// let mut scheduler = Scheduler::new(); 43 | /// every(5).seconds()?.run(&mut scheduler, job)?; 44 | /// scheduler.run_pending()?; 45 | /// # Ok(()) 46 | /// # } 47 | /// ``` 48 | /// 49 | /// # Errors 50 | /// 51 | /// Returns an error if any job failes to execute. 52 | pub fn run_pending(&mut self) -> Result<()> { 53 | //let mut jobs_to_run: Vec<&Job> = self.jobs.iter().filter(|el| el.should_run()).collect(); 54 | self.jobs.sort(); 55 | let mut to_remove = Vec::new(); 56 | let now = self.now(); 57 | for (idx, job) in self.jobs.iter_mut().enumerate() { 58 | if job.should_run(&now) { 59 | let keep_going = job.execute(&now)?; 60 | if !keep_going { 61 | debug!("Cancelling job {job}"); 62 | to_remove.push(idx); 63 | } 64 | } 65 | } 66 | // Remove any cancelled jobs 67 | to_remove.sort_unstable(); 68 | to_remove.reverse(); 69 | for &idx in &to_remove { 70 | self.jobs.remove(idx); 71 | } 72 | 73 | Ok(()) 74 | } 75 | 76 | /// Run all jobs, regardless of schedule. 77 | pub fn run_all(&mut self, delay_seconds: u64) { 78 | let num_jobs = self.jobs.len(); 79 | debug!("Running all {num_jobs} jobs with {delay_seconds}s delay"); 80 | let now = self.now(); 81 | for job in &mut self.jobs { 82 | if let Err(e) = job.execute(&now) { 83 | eprintln!("Error: {e}"); 84 | } 85 | std::thread::sleep(std::time::Duration::from_secs(delay_seconds)); 86 | } 87 | } 88 | 89 | /// Get all jobs, optionally with a given tag. 90 | /// ```rust 91 | /// # use skedge::{every, Scheduler}; 92 | /// # fn job() {} 93 | /// # fn main() -> Result<(), Box> { 94 | /// let mut scheduler = Scheduler::new(); 95 | /// every(5).seconds()?.run(&mut scheduler, job)?; 96 | /// every(10).minutes()?.run(&mut scheduler, job)?; 97 | /// let jobs = scheduler.get_jobs(None); 98 | /// assert_eq!(jobs.len(), 2); 99 | /// # Ok(()) 100 | /// # } 101 | /// ``` 102 | #[must_use] 103 | pub fn get_jobs(&self, tag: Option) -> Vec<&Job> { 104 | if let Some(t) = tag { 105 | self.jobs 106 | .iter() 107 | .filter(|el| el.has_tag(&t)) 108 | .collect::>() 109 | } else { 110 | self.jobs.iter().collect::>() 111 | } 112 | } 113 | 114 | /// Clear all jobs, optionally only with given tag. 115 | /// ```rust 116 | /// # use skedge::{every, Scheduler}; 117 | /// # fn job() {} 118 | /// # fn main() -> Result<(), Box> { 119 | /// let mut scheduler = Scheduler::new(); 120 | /// every(5).seconds()?.run(&mut scheduler, job)?; 121 | /// every(10).minutes()?.run(&mut scheduler, job)?; 122 | /// assert_eq!(scheduler.get_jobs(None).len(), 2); 123 | /// scheduler.clear(None); 124 | /// assert_eq!(scheduler.get_jobs(None).len(), 0); 125 | /// # Ok(()) 126 | /// # } 127 | /// ``` 128 | pub fn clear(&mut self, tag: Option) { 129 | if let Some(t) = tag { 130 | debug!("Deleting all jobs tagged {t}"); 131 | self.jobs.retain(|el| !el.has_tag(&t)); 132 | } else { 133 | debug!("Deleting ALL jobs!!"); 134 | drop(self.jobs.drain(..)); 135 | } 136 | } 137 | 138 | /// Grab the next upcoming timestamp 139 | /// ```rust 140 | /// # use skedge::{every, Scheduler}; 141 | /// # use jiff::ToSpan as _; 142 | /// # fn job() {} 143 | /// # fn main() -> Result<(), Box> { 144 | /// let mut scheduler = Scheduler::new(); 145 | /// every(10).minutes()?.run(&mut scheduler, job)?; 146 | /// let expected = jiff::Zoned::now().checked_add(10.minutes())?; 147 | /// assert!(scheduler.next_run().unwrap() == expected); 148 | /// # Ok(()) 149 | /// # } 150 | /// ``` 151 | /// 152 | /// # Panics 153 | /// 154 | /// Would panic if it can't call `min()` on an array that we know has at least one element. 155 | #[must_use] 156 | pub fn next_run(&self) -> Option { 157 | if self.jobs.is_empty() { 158 | None 159 | } else { 160 | // unwrap is safe, we know there's at least one job 161 | self.jobs.iter().min().unwrap().next_run.clone() 162 | } 163 | } 164 | 165 | /// Number of whole seconds until next run. None if no jobs scheduled 166 | /// ```rust 167 | /// # use skedge::{every, Scheduler}; 168 | /// # fn job() {} 169 | /// # fn main() -> Result<(), Box> { 170 | /// let mut scheduler = Scheduler::new(); 171 | /// every(10).minutes()?.run(&mut scheduler, job)?; 172 | /// // Subtract one - we're already partway through the first second, so there's 599 left. 173 | /// assert_eq!(scheduler.idle_seconds().unwrap(), 10 * 60 - 1); 174 | /// # Ok(()) 175 | /// # } 176 | /// ``` 177 | #[must_use] 178 | pub fn idle_seconds(&self) -> Option { 179 | println!("now: {}", self.now()); 180 | println!("next_run: {}", self.next_run().unwrap_or_default()); 181 | Some( 182 | self.now() 183 | .until(&self.next_run()?) 184 | .unwrap() 185 | .round(SpanRound::new().largest(Unit::Second)) 186 | .unwrap() 187 | .get_seconds(), 188 | ) 189 | } 190 | 191 | /// Get the most recently added job, for testing 192 | #[cfg(test)] 193 | fn most_recent_job(&self) -> Option<&Job> { 194 | if self.jobs.is_empty() { 195 | return None; 196 | } 197 | Some(&self.jobs[self.jobs.len() - 1]) 198 | } 199 | } 200 | 201 | impl Timekeeper for Scheduler { 202 | fn now(&self) -> Zoned { 203 | self.clock.now() 204 | } 205 | 206 | #[cfg(test)] 207 | fn add_duration(&mut self, duration: impl Into) { 208 | self.clock.add_duration(duration) 209 | } 210 | } 211 | 212 | #[cfg(test)] 213 | mod tests { 214 | use super::*; 215 | use crate::{ 216 | error::Result, 217 | every, every_single, 218 | time::mock::{Mock, START}, 219 | }; 220 | use jiff::{civil, ToSpan as _}; 221 | use pretty_assertions::assert_eq; 222 | 223 | /// Overshadow scheduler, `every()` and `every_single()` to use our clock instead 224 | fn setup() -> Scheduler { 225 | let clock = Mock::default(); 226 | let scheduler = Scheduler::with_mock_time(clock); 227 | 228 | scheduler 229 | } 230 | 231 | /// Empty mock job 232 | fn job() {} 233 | 234 | #[test] 235 | fn test_two_jobs() -> Result<()> { 236 | let mut scheduler = setup(); 237 | 238 | assert_eq!(scheduler.idle_seconds(), None); 239 | 240 | every(17).seconds()?.run(&mut scheduler, job)?; 241 | assert_eq!(scheduler.idle_seconds(), Some(17)); 242 | 243 | every_single().minute()?.run(&mut scheduler, job)?; 244 | assert_eq!(scheduler.idle_seconds(), Some(17)); 245 | assert_eq!( 246 | scheduler.next_run(), 247 | Some(START.checked_add(17.seconds()).unwrap()) 248 | ); 249 | 250 | scheduler.add_duration(17.seconds()); 251 | scheduler.run_pending()?; 252 | println!("after one: {}", scheduler.now()); 253 | assert_eq!( 254 | scheduler.next_run(), 255 | Some(START.checked_add((17 * 2).seconds()).unwrap()) 256 | ); 257 | 258 | scheduler.add_duration(17.seconds()); 259 | scheduler.run_pending()?; 260 | assert_eq!( 261 | scheduler.next_run(), 262 | Some(START.checked_add((17 * 3).seconds()).unwrap()) 263 | ); 264 | 265 | // This time, we should hit the minute mark next, not the next 17 second mark 266 | scheduler.add_duration(17.seconds()); 267 | scheduler.run_pending()?; 268 | assert_eq!(scheduler.idle_seconds(), Some(9)); 269 | assert_eq!( 270 | scheduler.next_run(), 271 | Some(START.checked_add(1.minutes()).unwrap()) 272 | ); 273 | 274 | // Afterwards, back to the 17 second job 275 | scheduler.add_duration(9.seconds()); 276 | scheduler.run_pending()?; 277 | assert_eq!(scheduler.idle_seconds(), Some(8)); 278 | assert_eq!( 279 | scheduler.next_run(), 280 | Some(START.checked_add((17 * 4).seconds()).unwrap()) 281 | ); 282 | 283 | Ok(()) 284 | } 285 | 286 | #[test] 287 | #[cfg(feature = "random")] 288 | fn test_time_range() -> Result<()> { 289 | let mut scheduler = setup(); 290 | 291 | // Set up 100 jobs, store the minute of the next run 292 | let num_jobs = 100; 293 | let mut minutes = std::collections::HashSet::with_capacity(num_jobs); 294 | for _ in 0..num_jobs { 295 | every(5).to(30)?.minutes()?.run(&mut scheduler, job)?; 296 | minutes.insert( 297 | scheduler 298 | .most_recent_job() 299 | .unwrap() 300 | .next_run 301 | .as_ref() 302 | .unwrap() 303 | .minute(), 304 | ); 305 | } 306 | 307 | // Make sure each job got a run time within the specified bounds 308 | assert!(minutes.len() > 1); 309 | assert!(minutes.iter().min().unwrap() >= &5); 310 | assert!(minutes.iter().max().unwrap() <= &30); 311 | 312 | Ok(()) 313 | } 314 | 315 | // TODO - job repr 316 | // #[test] 317 | // fn test_time_range_debug() -> Result<()> { 318 | // let (mut scheduler, every, _) = setup(); 319 | // 320 | // every(5).to(30)?.minutes()?.run(&mut &mut scheduler, job)?; 321 | // 322 | // assert_eq!( 323 | // scheduler.most_recent_job().to_string(), 324 | // "Every 5 to 30 minutes do job()" 325 | // ); 326 | // 327 | // Ok(()) 328 | // } 329 | 330 | #[test] 331 | fn test_at_time() -> Result<()> { 332 | let mut scheduler = setup(); 333 | 334 | every_single() 335 | .day()? 336 | .at("10:30:50")? 337 | .run(&mut scheduler, job)?; 338 | assert_eq!( 339 | scheduler 340 | .most_recent_job() 341 | .unwrap() 342 | .next_run 343 | .as_ref() 344 | .unwrap() 345 | .hour(), 346 | 10 347 | ); 348 | assert_eq!( 349 | scheduler 350 | .most_recent_job() 351 | .unwrap() 352 | .next_run 353 | .as_ref() 354 | .unwrap() 355 | .minute(), 356 | 30 357 | ); 358 | assert_eq!( 359 | scheduler 360 | .most_recent_job() 361 | .unwrap() 362 | .next_run 363 | .as_ref() 364 | .unwrap() 365 | .second(), 366 | 50 367 | ); 368 | 369 | Ok(()) 370 | } 371 | 372 | #[test] 373 | fn test_clear_scheduler() -> Result<()> { 374 | let mut scheduler = setup(); 375 | 376 | every_single().day()?.run(&mut scheduler, job)?; 377 | every_single().minute()?.run(&mut scheduler, job)?; 378 | assert_eq!(scheduler.jobs.len(), 2); 379 | scheduler.clear(None); 380 | assert_eq!(scheduler.jobs.len(), 0); 381 | 382 | Ok(()) 383 | } 384 | 385 | #[test] 386 | fn test_until_time() -> Result<()> { 387 | let mut scheduler = setup(); 388 | 389 | // Make sure it stores a deadline 390 | 391 | let deadline = civil::date(3000, 1, 1) 392 | .at(12, 0, 0, 0) 393 | .intz("America/New_York") 394 | .unwrap(); 395 | every_single() 396 | .day()? 397 | .until(deadline.clone())? 398 | .run(&mut scheduler, job)?; 399 | assert_eq!( 400 | scheduler 401 | .most_recent_job() 402 | .unwrap() 403 | .cancel_after 404 | .clone() 405 | .unwrap(), 406 | deadline 407 | ); 408 | 409 | // Make sure it cancels a job after next_run passes the deadline 410 | // FIXME - this test fails? call count never increments 411 | 412 | scheduler.clear(None); 413 | let deadline = civil::date(2024, 1, 1) 414 | .at(7, 0, 10, 0) 415 | .intz("America/New_York") 416 | .unwrap(); 417 | every(5) 418 | .seconds()? 419 | .until(deadline)? 420 | .run(&mut scheduler, job)?; 421 | assert_eq!(scheduler.most_recent_job().unwrap().call_count, 0); 422 | scheduler.add_duration(5.seconds()); 423 | scheduler.run_pending()?; 424 | assert_eq!(scheduler.most_recent_job().unwrap().call_count, 1); 425 | assert_eq!(scheduler.jobs.len(), 1); 426 | scheduler.add_duration(5.seconds()); 427 | scheduler.run_pending()?; 428 | assert_eq!(scheduler.jobs.len(), 1); 429 | assert_eq!(scheduler.most_recent_job().unwrap().call_count, 2); 430 | scheduler.add_duration(5.seconds()); 431 | scheduler.run_pending()?; 432 | // TODO - how to test to ensure the job did not run? 433 | // FIXME - job doesnt disappear? 434 | assert_eq!(scheduler.jobs.len(), 0); 435 | 436 | // Make sure it cancels a job if current execution passes the deadline 437 | 438 | scheduler.clear(None); 439 | let deadline = START.clone(); 440 | every(5) 441 | .seconds()? 442 | .until(deadline)? 443 | .run(&mut scheduler, job)?; 444 | scheduler.add_duration(5.seconds()); 445 | scheduler.run_pending()?; 446 | // TODO - how to test to ensure the job did not run? 447 | assert_eq!(scheduler.jobs.len(), 0); 448 | 449 | Ok(()) 450 | } 451 | 452 | #[test] 453 | fn test_weekday_at_time() -> Result<()> { 454 | let mut scheduler = setup(); 455 | 456 | every_single() 457 | .wednesday()? 458 | .at("22:38:10")? 459 | .run(&mut scheduler, job)?; 460 | let j = scheduler.most_recent_job().unwrap(); 461 | 462 | assert_eq!(j.next_run.as_ref().unwrap().year(), 2024); 463 | assert_eq!(j.next_run.as_ref().unwrap().month(), 1); 464 | assert_eq!(j.next_run.as_ref().unwrap().day(), 3); 465 | assert_eq!(j.next_run.as_ref().unwrap().hour(), 22); 466 | assert_eq!(j.next_run.as_ref().unwrap().minute(), 38); 467 | assert_eq!(j.next_run.as_ref().unwrap().second(), 10); 468 | 469 | scheduler.clear(None); 470 | 471 | every_single() 472 | .wednesday()? 473 | .at("22:39")? 474 | .run(&mut scheduler, job)?; 475 | let j = scheduler.most_recent_job().unwrap(); 476 | 477 | assert_eq!(j.next_run.as_ref().unwrap().year(), 2024); 478 | assert_eq!(j.next_run.as_ref().unwrap().month(), 1); 479 | assert_eq!(j.next_run.as_ref().unwrap().day(), 3); 480 | assert_eq!(j.next_run.as_ref().unwrap().hour(), 22); 481 | assert_eq!(j.next_run.as_ref().unwrap().minute(), 39); 482 | assert_eq!(j.next_run.as_ref().unwrap().second(), 0); 483 | 484 | Ok(()) 485 | } 486 | } 487 | -------------------------------------------------------------------------------- /src/time.rs: -------------------------------------------------------------------------------- 1 | //! For mocking purposes, access to the current time is controlled directed through this struct. 2 | 3 | use jiff::{Span, ToSpan as _, Zoned}; 4 | use std::fmt; 5 | 6 | pub(crate) trait Timekeeper: std::fmt::Debug { 7 | /// Return the current time 8 | fn now(&self) -> Zoned; 9 | /// Add a specific duration for testing purposes 10 | #[cfg(test)] 11 | fn add_duration(&mut self, duration: impl Into); 12 | } 13 | 14 | #[derive(Debug, Default)] 15 | pub(crate) enum Clock { 16 | #[default] 17 | Real, 18 | #[cfg(test)] 19 | Mock(mock::Mock), 20 | } 21 | 22 | impl Timekeeper for Clock { 23 | fn now(&self) -> Zoned { 24 | match self { 25 | Clock::Real => Zoned::now(), 26 | #[cfg(test)] 27 | Clock::Mock(mock) => mock.now(), 28 | } 29 | } 30 | 31 | #[cfg(test)] 32 | fn add_duration(&mut self, duration: impl Into) { 33 | match self { 34 | Clock::Real => unreachable!(), 35 | Clock::Mock(mock) => mock.add_duration(duration), 36 | } 37 | } 38 | } 39 | 40 | /// Jobs can be periodic over one of these units of time 41 | #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] 42 | pub enum Unit { 43 | Second, 44 | Minute, 45 | Hour, 46 | Day, 47 | Week, 48 | Month, 49 | Year, 50 | } 51 | 52 | impl Unit { 53 | /// Get a [`jiff::SignedDuration`] from an interval based on time unit. 54 | pub fn duration(self, interval: u32) -> Span { 55 | use Unit::{Day, Hour, Minute, Month, Second, Week, Year}; 56 | let interval = i64::from(interval); 57 | match self { 58 | Second => interval.seconds(), 59 | Minute => interval.minutes(), 60 | Hour => interval.hours(), 61 | Day => interval.days(), 62 | Week => interval.weeks(), 63 | Month => interval.months(), 64 | Year => interval.years(), 65 | } 66 | } 67 | } 68 | 69 | impl fmt::Display for Unit { 70 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 71 | use Unit::{Day, Hour, Minute, Month, Second, Week, Year}; 72 | let s = match self { 73 | Second => "second", 74 | Minute => "minute", 75 | Hour => "hour", 76 | Day => "day", 77 | Week => "week", 78 | Month => "month", 79 | Year => "year", 80 | }; 81 | write!(f, "{s}") 82 | } 83 | } 84 | 85 | #[cfg(test)] 86 | pub mod mock { 87 | use super::Timekeeper; 88 | use jiff::{Zoned, ZonedArithmetic}; 89 | use std::sync::LazyLock; 90 | 91 | pub(crate) static START: LazyLock = 92 | LazyLock::new(|| "2024-01-01T07:00:00[America/New_York]".parse().unwrap()); 93 | 94 | /// Mock the datetime for predictable results. 95 | #[derive(Debug)] 96 | pub struct Mock { 97 | instant: Zoned, 98 | } 99 | 100 | impl Mock { 101 | pub fn new(stamp: Zoned) -> Self { 102 | Self { instant: stamp } 103 | } 104 | } 105 | 106 | impl Default for Mock { 107 | fn default() -> Self { 108 | Self::new(START.clone()) 109 | } 110 | } 111 | 112 | impl Timekeeper for Mock { 113 | fn now(&self) -> Zoned { 114 | self.instant.clone() 115 | } 116 | 117 | fn add_duration(&mut self, duration: impl Into) { 118 | self.instant = self.instant.checked_add(duration).unwrap(); 119 | } 120 | } 121 | } 122 | --------------------------------------------------------------------------------