Skip to main content

template_model/
schedule.rs

1//! Validation of `AWS::Events::Rule` `ScheduleExpression` values (`rate(...)` / `cron(...)`).
2//!
3//! The service accepts a `rate(Value Unit)` where `Value` is a positive integer and `Unit` agrees
4//! in number with it (`minute`/`hour`/`day` when the value is 1, plural otherwise), or a
5//! `cron(...)` of exactly six space-separated fields that does not pin both day-of-month and
6//! day-of-week. Each returned string is a complete, human-readable reason a value is invalid.
7
8/// Return every reason `expression` is an invalid `ScheduleExpression`, or an empty vector when it
9/// is valid. More than one reason can apply at once (e.g. a zero value with a mismatched unit).
10#[must_use]
11pub fn schedule_expression_errors(expression: &str) -> Vec<String> {
12    if let Some(inner) = wrapped_body(expression, "rate(") {
13        check_rate(inner)
14    } else if let Some(inner) = wrapped_body(expression, "cron(") {
15        check_cron(inner)
16    } else {
17        vec![format!("'{expression}' has to be either 'cron()' or 'rate()'")]
18    }
19}
20
21/// The text between the parentheses when `expression` is `prefix...)`, else `None`.
22fn wrapped_body<'a>(expression: &'a str, prefix: &str) -> Option<&'a str> {
23    expression.strip_prefix(prefix).and_then(|rest| rest.strip_suffix(')'))
24}
25
26fn check_rate(body: &str) -> Vec<String> {
27    let mut errors = Vec::new();
28    if body.is_empty() {
29        errors.push("'' is not of type 'string'".to_string());
30        return errors;
31    }
32    let items: Vec<&str> = body.split(' ').collect();
33    if items.len() != 2 {
34        errors.push(format!("'{body}' has to be of format rate(Value Unit)"));
35        return errors;
36    }
37    let (value, unit) = (items[0], items[1]);
38    if !is_ascii_digits(value) {
39        errors.push(format!("'{value}' is not of type 'integer'"));
40        return errors;
41    }
42    let amount: u64 = value.parse().unwrap_or(0);
43    if amount == 0 {
44        errors.push(format!("'{value}' is less than the minimum of 0"));
45    }
46    let valid_units: [&str; 3] = if amount <= 1 { ["minute", "hour", "day"] } else { ["minutes", "hours", "days"] };
47    if !valid_units.contains(&unit) {
48        errors.push(format!("'{unit}' is not one of {valid_units:?}"));
49    }
50    errors
51}
52
53fn check_cron(body: &str) -> Vec<String> {
54    let mut errors = Vec::new();
55    if body.is_empty() {
56        errors.push("'' is not of type 'string'".to_string());
57        return errors;
58    }
59    let fields: Vec<&str> = body.split(' ').collect();
60    if fields.len() != 6 {
61        errors.push(format!("'{}' is not of length 6. (Minutes Hours Day-of-month Month Day-of-week Year)", fields[0]));
62        return errors;
63    }
64    let day_of_month = fields[2];
65    let day_of_week = fields[4];
66    if day_of_month != "?" && day_of_week != "?" {
67        errors.push(format!(
68            "'{}' specifies both Day-of-month and Day-of-week. (Minutes Hours Day-of-month Month Day-of-week Year)",
69            body.chars().next().unwrap_or_default()
70        ));
71    }
72    errors
73}
74
75/// A non-empty run of ASCII digits, matching Python's `str.isdigit` for the inputs that appear here
76/// (it rejects signs, decimals and whitespace, so `1.5` and `-1` are not integers).
77fn is_ascii_digits(value: &str) -> bool {
78    !value.is_empty() && value.bytes().all(|b| b.is_ascii_digit())
79}
80
81#[cfg(test)]
82mod tests {
83    use super::*;
84
85    #[test]
86    fn valid_rate_and_cron_have_no_errors() {
87        assert!(schedule_expression_errors("rate(1 minute)").is_empty());
88        assert!(schedule_expression_errors("rate(5 minutes)").is_empty());
89        assert!(schedule_expression_errors("cron(0 12 * * ? *)").is_empty());
90        assert!(schedule_expression_errors("cron(0 12 ? * MON *)").is_empty());
91    }
92
93    #[test]
94    fn rate_unit_must_agree_in_number_with_value() {
95        assert_eq!(
96            schedule_expression_errors("rate(1 minutes)"),
97            vec!["'minutes' is not one of [\"minute\", \"hour\", \"day\"]".to_string()]
98        );
99        assert_eq!(
100            schedule_expression_errors("rate(5 minute)"),
101            vec!["'minute' is not one of [\"minutes\", \"hours\", \"days\"]".to_string()]
102        );
103    }
104
105    #[test]
106    fn rate_value_must_be_positive_integer() {
107        assert_eq!(schedule_expression_errors("rate(1.5 hours)"), vec!["'1.5' is not of type 'integer'".to_string()]);
108        assert_eq!(
109            schedule_expression_errors("rate(0 minutes)"),
110            vec![
111                "'0' is less than the minimum of 0".to_string(),
112                "'minutes' is not one of [\"minute\", \"hour\", \"day\"]".to_string(),
113            ]
114        );
115    }
116
117    #[test]
118    fn rate_must_have_two_space_separated_items() {
119        assert_eq!(
120            schedule_expression_errors("rate(10  minutes)"),
121            vec!["'10  minutes' has to be of format rate(Value Unit)".to_string()]
122        );
123        assert_eq!(schedule_expression_errors("rate(5)"), vec!["'5' has to be of format rate(Value Unit)".to_string()]);
124    }
125
126    #[test]
127    fn empty_rate_reports_missing_string() {
128        assert_eq!(schedule_expression_errors("rate()"), vec!["'' is not of type 'string'".to_string()]);
129    }
130
131    #[test]
132    fn cron_must_have_six_fields() {
133        assert_eq!(
134            schedule_expression_errors("cron(bad)"),
135            vec!["'bad' is not of length 6. (Minutes Hours Day-of-month Month Day-of-week Year)".to_string()]
136        );
137    }
138
139    #[test]
140    fn cron_cannot_pin_both_day_of_month_and_day_of_week() {
141        assert_eq!(
142            schedule_expression_errors("cron(0 12 * * * *)"),
143            vec![
144                "'0' specifies both Day-of-month and Day-of-week. (Minutes Hours Day-of-month Month Day-of-week Year)"
145                    .to_string()
146            ]
147        );
148    }
149
150    #[test]
151    fn non_rate_non_cron_is_rejected() {
152        assert_eq!(
153            schedule_expression_errors("every minute"),
154            vec!["'every minute' has to be either 'cron()' or 'rate()'".to_string()]
155        );
156    }
157}