diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index f1bc27e..fc7d822 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -18,7 +18,9 @@ jobs: - uses: actions/checkout@v2 - name: Build run: cargo build --verbose - - name: Run tests + - name: Run tests (default features) run: cargo test --verbose + - name: Run tests (all features) + run: cargo test --verbose --all-features - name: Build docs run: cargo doc --no-deps --verbose diff --git a/Cargo.toml b/Cargo.toml index de773b2..25b994d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,18 @@ name = "cron" chrono = { version = "~0.4", default-features = false, features = ["clock"] } nom = "~7" once_cell = "1.10" +serde = {version = "1.0.164", optional = true } [dev-dependencies] chrono-tz = "~0.6" +serde_test = "1.0.164" + +# Dev-dependency for feature "serde". +# Optional dev-dependencies are not supported yet. +# Cargo feature request is available at https://github.com/rust-lang/cargo/issues/1596 +postcard = { version = "1.0.10", default-features = false, features = ["use-std"] } + +[features] +serde = ["dep:serde"] + + diff --git a/src/schedule.rs b/src/schedule.rs index eb833fd..893a2f6 100644 --- a/src/schedule.rs +++ b/src/schedule.rs @@ -3,6 +3,14 @@ use chrono::{DateTime, Datelike, Timelike, Utc}; use std::fmt::{Display, Formatter, Result as FmtResult}; use std::ops::Bound::{Included, Unbounded}; +#[cfg(feature = "serde")] +use core::fmt; +#[cfg(feature = "serde")] +use serde::{ + de::{self, Visitor}, + Deserialize, Serialize, Serializer, +}; + use crate::ordinal::*; use crate::queries::*; use crate::time_unit::*; @@ -351,6 +359,11 @@ impl Schedule { pub fn timeunitspec_eq(&self, other: &Schedule) -> bool { self.fields == other.fields } + + /// Returns a reference to the source cron expression. + pub fn source(&self) -> &str { + &self.source + } } impl Display for Schedule { @@ -522,13 +535,143 @@ fn days_in_month(month: Ordinal, year: Ordinal) -> u32 { } } +#[cfg(feature = "serde")] +struct ScheduleVisitor; + +#[cfg(feature = "serde")] +impl<'de> Visitor<'de> for ScheduleVisitor { + type Value = Schedule; + + fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("a valid cron expression") + } + + // Supporting `Deserializer`s shall provide an owned `String`. + // + // The `Schedule` will decode from a `&str` to it, + // then store the owned `String` as `Schedule::source`. + fn visit_string(self, v: String) -> Result + where + E: de::Error, + { + Schedule::try_from(v).map_err(de::Error::custom) + } + + // `Deserializer`s not providing an owned `String` + // shall provide a `&str`. + // + // The `Schedule` will decode from the `&str`, + // then clone into the heap to store as an owned `String` + // as `Schedule::source`. + fn visit_str(self, v: &str) -> Result + where + E: de::Error, + { + Schedule::try_from(v).map_err(de::Error::custom) + } +} + +#[cfg(feature = "serde")] +impl Serialize for Schedule { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(self.source()) + } +} + +#[cfg(feature = "serde")] +impl<'de> Deserialize<'de> for Schedule { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + // Hint that the `Deserialize` type `Schedule` + // would benefit from taking ownership of + // buffered data owned by the `Deserializer`: + // + // The deserialization "happy path" decodes from a `&str`, + // then stores the source as owned `String`. + // + // Thus, the optimized happy path receives an owned `String` + // if the `Deserializer` in use supports providing one. + deserializer.deserialize_string(ScheduleVisitor) + } +} + #[cfg(test)] mod test { use chrono::Duration; + #[cfg(feature = "serde")] + use serde_test::{assert_tokens, Token}; use super::*; use std::str::FromStr; + #[cfg(feature = "serde")] + #[test] + fn test_ser_de_schedule_tokens() { + let schedule = Schedule::from_str("* * * * * * *").expect("valid format"); + assert_tokens(&schedule, &[Token::String("* * * * * * *")]) + } + + #[cfg(feature = "serde")] + #[test] + fn test_invalid_ser_de_schedule_tokens() { + use serde_test::assert_de_tokens_error; + + assert_de_tokens_error::( + &[Token::String( + "definitively an invalid value for a cron schedule!", + )], + "Invalid expression: Invalid cron expression.", + ); + } + + #[cfg(feature = "serde")] + #[test] + fn test_ser_de_schedule_shorthand() { + let serialized = postcard::to_stdvec(&Schedule::try_from("@hourly").expect("valid format")) + .expect("serializable schedule"); + + let schedule: Schedule = + postcard::from_bytes(&serialized).expect("deserializable schedule"); + + let starting_date = Utc.with_ymd_and_hms(2017, 2, 25, 22, 29, 36).unwrap(); + assert!([ + Utc.with_ymd_and_hms(2017, 2, 25, 23, 0, 0).unwrap(), + Utc.with_ymd_and_hms(2017, 2, 26, 0, 0, 0).unwrap(), + Utc.with_ymd_and_hms(2017, 2, 26, 1, 0, 0).unwrap(), + ] + .into_iter() + .eq(schedule.after(&starting_date).take(3))); + } + + #[cfg(feature = "serde")] + #[test] + fn test_ser_de_schedule_period_values_range() { + let serialized = + postcard::to_stdvec(&Schedule::try_from("0 0 0 1-31/10 * ?").expect("valid format")) + .expect("serializable schedule"); + + let schedule: Schedule = + postcard::from_bytes(&serialized).expect("deserializable schedule"); + + let starting_date = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(); + assert!([ + Utc.with_ymd_and_hms(2020, 1, 11, 0, 0, 0).unwrap(), + Utc.with_ymd_and_hms(2020, 1, 21, 0, 0, 0).unwrap(), + Utc.with_ymd_and_hms(2020, 1, 31, 0, 0, 0).unwrap(), + Utc.with_ymd_and_hms(2020, 2, 1, 0, 0, 0).unwrap(), + Utc.with_ymd_and_hms(2020, 2, 11, 0, 0, 0).unwrap(), + Utc.with_ymd_and_hms(2020, 2, 21, 0, 0, 0).unwrap(), + Utc.with_ymd_and_hms(2020, 3, 1, 0, 0, 0).unwrap(), + ] + .into_iter() + .eq(schedule.after(&starting_date).take(7))); + } + #[test] fn test_next_and_prev_from() { let expression = "0 5,13,40-42 17 1 Jan *"; @@ -556,9 +699,7 @@ mod test { #[test] fn test_next_after_past_date_next_year() { // Schedule after 2021-10-27 - let starting_point = Utc - .with_ymd_and_hms(2021, 10, 27, 0, 0, 0) - .unwrap(); + let starting_point = Utc.with_ymd_and_hms(2021, 10, 27, 0, 0, 0).unwrap(); // Triggers on 2022-06-01. Note that the month and day are smaller than // the month and day in `starting_point`.