@angus on Unix.SE implemented a tool in C to list upcoming cron jobs in response to a question. I've re-implemented the basic functionality (showing only the next cron job) in Rust, using rustfmt
, clippy
and TDD to keep it reasonably clean, but it's my first non-trivial program:
use std::ops::RangeInclusive;
pub struct DateTimeFieldParser {
range: RangeInclusive<u8>,
wrap_around_at_end: bool,
}
impl DateTimeFieldParser {
pub fn new(min: u8, max: u8) -> DateTimeFieldParser {
DateTimeFieldParser {
range: min..=max,
wrap_around_at_end: false,
}
}
pub fn new_with_wrap_around(min: u8, max: u8) -> DateTimeFieldParser {
DateTimeFieldParser {
range: min..=max,
wrap_around_at_end: true,
}
}
pub fn parse_field(&self, string_value: &str) -> Vec<u8> {
let mut values = Vec::with_capacity((self.range.end() - self.range.start()) as usize);
string_value
.split(',')
.for_each(|part| values.append(&mut self.parse_list_entry(part)));
values.sort_unstable();
values.dedup();
values
}
fn parse_list_entry(&self, string_value: &str) -> Vec<u8> {
let mut parts = string_value.splitn(2, '/');
let values = parts
.next()
.unwrap()
.replace("*", &format!("{}-{}", self.range.start(), self.range.end() - 1));
let values = match values.to_lowercase().as_ref() {
"sun" => "0",
"jan" | "mon" => "1",
"feb" | "tue" => "2",
"mar" | "wed" => "3",
"apr" | "thu" => "4",
"may" | "fri" => "5",
"jun" | "sat" => "6",
"jul" => "7",
"aug" => "8",
"sep" => "9",
"oct" => "10",
"nov" => "11",
"dec" => "12",
_ => &values,
};
let values: RangeInclusive<u8> = self.parse_range(values);
let step = match parts.next() {
Some(string_value) => string_value.parse::<u8>().unwrap(),
None => 1,
};
let mut values: Vec<u8> = values.step_by(step as usize).collect();
let last_value = values.pop().unwrap();
if last_value == *self.range.end() + 1 && self.wrap_around_at_end {
values.push(0);
} else {
values.push(last_value);
}
self.verify_range(values[0]);
self.verify_range(values[values.len() - 1]);
values
}
fn parse_range(&self, values: &str) -> RangeInclusive<u8> {
let mut range_or_value = values.splitn(2, '-').map(|part| part.parse().unwrap());
let first = range_or_value.next().expect("Empty range");
let last = match range_or_value.next() {
Some(value) => value,
None => first,
};
first..=last
}
fn verify_range(&self, value: u8) {
assert!(*self.range.start() <= value);
assert!(*self.range.end() >= value);
}
}
Tests:
#[cfg(test)]
mod tests {
use super::DateTimeFieldParser;
#[test]
fn should_parse_complex_pattern() {
let parser = DateTimeFieldParser::new(1, 12);
assert_eq!(parser.parse_field("5-9/2,1,*/5"), vec![1, 5, 6, 7, 9, 11]);
}
#[test]
fn should_parse_comma_separated_numbers() {
let parser = DateTimeFieldParser::new(0, 23);
assert_eq!(parser.parse_field("0,23"), vec![0, 23]);
}
#[test]
fn should_parse_range_with_step() {
let parser = DateTimeFieldParser::new(0, 23);
assert_eq!(parser.parse_list_entry("1-7/2"), vec![1, 3, 5, 7]);
}
#[test]
fn should_parse_asterisk() {
let parser = DateTimeFieldParser::new(1, 12);
assert_eq!(parser.parse_list_entry("*/4"), vec![1, 5, 9]);
}
#[test]
fn should_parse_january_name() {
let parser = DateTimeFieldParser::new(1, 12);
assert_eq!(parser.parse_list_entry("Jan"), vec![1]);
}
#[test]
fn should_parse_february_name() {
let parser = DateTimeFieldParser::new(1, 12);
assert_eq!(parser.parse_list_entry("Feb"), vec![2]);
}
#[test]
fn should_parse_march_name() {
let parser = DateTimeFieldParser::new(1, 12);
assert_eq!(parser.parse_list_entry("Mar"), vec![3]);
}
#[test]
fn should_parse_april_name() {
let parser = DateTimeFieldParser::new(1, 12);
assert_eq!(parser.parse_list_entry("Apr"), vec![4]);
}
#[test]
fn should_parse_may_name() {
let parser = DateTimeFieldParser::new(1, 12);
assert_eq!(parser.parse_list_entry("May"), vec![5]);
}
#[test]
fn should_parse_june_name() {
let parser = DateTimeFieldParser::new(1, 12);
assert_eq!(parser.parse_list_entry("Jun"), vec![6]);
}
#[test]
fn should_parse_july_name() {
let parser = DateTimeFieldParser::new(1, 12);
assert_eq!(parser.parse_list_entry("Jul"), vec![7]);
}
#[test]
fn should_parse_august_name() {
let parser = DateTimeFieldParser::new(1, 12);
assert_eq!(parser.parse_list_entry("Aug"), vec![8]);
}
#[test]
fn should_parse_september_name() {
let parser = DateTimeFieldParser::new(1, 12);
assert_eq!(parser.parse_list_entry("Sep"), vec![9]);
}
#[test]
fn should_parse_october_name() {
let parser = DateTimeFieldParser::new(1, 12);
assert_eq!(parser.parse_list_entry("Oct"), vec![10]);
}
#[test]
fn should_parse_november_name() {
let parser = DateTimeFieldParser::new(1, 12);
assert_eq!(parser.parse_list_entry("Nov"), vec![11]);
}
#[test]
fn should_parse_december_name() {
let parser = DateTimeFieldParser::new(1, 12);
assert_eq!(parser.parse_list_entry("Dec"), vec![12]);
}
#[test]
fn should_parse_month_name_case_insensitively() {
let parser = DateTimeFieldParser::new(1, 12);
assert_eq!(parser.parse_list_entry("dEC"), vec![12]);
}
#[test]
fn should_parse_sunday_name() {
let parser = DateTimeFieldParser::new_with_wrap_around(0, 6);
assert_eq!(parser.parse_list_entry("Sun"), vec![0]);
}
#[test]
fn should_parse_monday_name() {
let parser = DateTimeFieldParser::new_with_wrap_around(0, 6);
assert_eq!(parser.parse_list_entry("Mon"), vec![1]);
}
#[test]
fn should_parse_tuesday_name() {
let parser = DateTimeFieldParser::new_with_wrap_around(0, 6);
assert_eq!(parser.parse_list_entry("Tue"), vec![2]);
}
#[test]
fn should_parse_wednesday_name() {
let parser = DateTimeFieldParser::new_with_wrap_around(0, 6);
assert_eq!(parser.parse_list_entry("Wed"), vec![3]);
}
#[test]
fn should_parse_thursday_name() {
let parser = DateTimeFieldParser::new_with_wrap_around(0, 6);
assert_eq!(parser.parse_list_entry("Thu"), vec![4]);
}
#[test]
fn should_parse_friday_name() {
let parser = DateTimeFieldParser::new_with_wrap_around(0, 6);
assert_eq!(parser.parse_list_entry("Fri"), vec![5]);
}
#[test]
fn should_parse_saturday_name() {
let parser = DateTimeFieldParser::new_with_wrap_around(0, 6);
assert_eq!(parser.parse_list_entry("Sat"), vec![6]);
}
#[test]
fn should_parse_week_day_name_case_insensitively() {
let parser = DateTimeFieldParser::new_with_wrap_around(0, 6);
assert_eq!(parser.parse_list_entry("sAT"), vec![6]);
}
#[test]
fn should_remove_duplicates() {
let parser = DateTimeFieldParser::new(1, 2);
assert_eq!(parser.parse_field("1,1,2,2,2"), vec![1, 2]);
}
#[test]
fn should_sort_values() {
let parser = DateTimeFieldParser::new(1, 2);
assert_eq!(parser.parse_field("2,1"), vec![1, 2]);
}
#[test]
fn should_parse_wraparound_sunday() {
let parser = DateTimeFieldParser::new_with_wrap_around(0, 6);
assert_eq!(parser.parse_field("7"), vec![0]);
}
#[test]
fn should_parse_week_range_with_sunday_at_end() {
let parser = DateTimeFieldParser::new_with_wrap_around(0, 6);
assert_eq!(parser.parse_field("4-7"), vec![0, 4, 5, 6]);
}
#[test]
fn should_parse_week_range_with_sunday_at_both_sides() {
let parser = DateTimeFieldParser::new_with_wrap_around(0, 6);
assert_eq!(parser.parse_field("0-7"), vec![0, 1, 2, 3, 4, 5, 6]);
}
#[test]
fn should_parse_range() {
let parser = DateTimeFieldParser::new(0, 23);
assert_eq!(parser.parse_range("1-3"), { 1..=3 });
}
#[test]
#[should_panic]
fn should_fail_verification_below_min() {
let parser = DateTimeFieldParser::new(1, 12);
parser.verify_range(0)
}
#[test]
fn should_verify_at_min() {
let parser = DateTimeFieldParser::new(0, 23);
parser.verify_range(0);
}
#[test]
fn should_verify_between_min_and_max() {
let parser = DateTimeFieldParser::new(0, 23);
parser.verify_range(12);
}
#[test]
fn should_verify_at_both_min_and_max() {
let parser = DateTimeFieldParser::new(1, 1);
parser.verify_range(1);
}
#[test]
fn should_verify_at_max() {
let parser = DateTimeFieldParser::new(0, 23);
parser.verify_range(23);
}
#[test]
#[should_panic]
fn should_fail_verification_above_max() {
let parser = DateTimeFieldParser::new(0, 23);
parser.verify_range(24);
}
}
1 Answer 1
I’d go out on a limb and call this Good CodeTM️. I’m easily able to follow all the logic. It’s fairly idiomatic, using map/reduce instead of loops and pattern matching. The tests are all super tight and clear.
The only thing that catches my eye are those [should_panic]
annotations. I would expect a parse
function to return an Ok/Err Result
instead of hard panic. Personally, I prefer to defer decisions about how to handle an error as long as possible and push it as high up into the call stack as I can. Embrace the monad. It’s common in rust to have two variants of a function, one that may panic and one that returns a Result<T>
. Then the client code can decide which behavior it wants.
Explore related questions
See similar questions with these tags.