Chapter 13. Rascalry

Time is flying like an arrow
And the clock hands go so fast, they make the wind blow
And it makes the pages of the calendar go flying out the window, one by one

They Might Be Giants, “Hovering Sombrero” (2001)

In this chapter, you will create a clone of cal, which will show you a text calendar in the terminal. I often don’t know what the date is (or even the day of the week), so I use this (along with date) to see vaguely where I am in the space-time continuum. As is commonly the case, what appears to be a simple app becomes much more complicated as you get into the specifics of implementation.

You will learn how to do the following:

  • Find today’s date and do basic date manipulations

  • Use Vec::chunks to create groupings of items

  • Combine elements from multiple iterators

  • Produce highlighted text in the terminal

How cal Works

I’ll start by showing you the manual page for BSD cal to consider what’s required. It’s rather long, so I’ll just include some parts relevant to the challenge program:

CAL(1)                    BSD General Commands Manual                   CAL(1)

NAME
     cal, ncal — displays a calendar and the date of Easter

SYNOPSIS
     cal [-31jy] [-A number] [-B number] [-d yyyy-mm] [[month] year]
     cal [-31j] [-A number] [-B number] [-d yyyy-mm] -m month [year]
     ncal [-C] [-31jy] [-A number] [-B number] [-d yyyy-mm] [[month] year]
     ncal [-C] [-31j] [-A number] [-B number] [-d yyyy-mm] -m month [year]
     ncal [-31bhjJpwySM] [-A number] [-B number] [-H yyyy-mm-dd] [-d yyyy-mm]
         [-s country_code] [[month] year]
     ncal [-31bhJeoSM] [-A number] [-B number] [-d yyyy-mm] [year]

DESCRIPTION
     The cal utility displays a simple calendar in traditional format and ncal
     offers an alternative layout, more options and the date of Easter.  The
     new format is a little cramped but it makes a year fit on a 25x80 termi‐
     nal.  If arguments are not specified, the current month is displayed.

     ...

     A single parameter specifies the year (1-9999) to be displayed; note the
     year must be fully specified: ``cal 89'' will not display a calendar for
     1989.  Two parameters denote the month and year; the month is either a
     number between 1 and 12, or a full or abbreviated name as specified by
     the current locale.  Month and year default to those of the current sys-
     tem clock and time zone (so ``cal -m 8'' will display a calendar for the
     month of August in the current year).

GNU cal responds to --help and has both short and long option names. Note that this version also allows the week to start on either Sunday or Monday, but the challenge program will start it on Sunday:

$ cal --help

Usage:
 cal [options] [[[day] month] year]

Options:
 -1, --one        show only current month (default)
 -3, --three      show previous, current and next month
 -s, --sunday     Sunday as first day of week
 -m, --monday     Monday as first day of week
 -j, --julian     output Julian dates
 -y, --year       show whole current year
 -V, --version    display version information and exit
 -h, --help       display this help text and exit

Given no arguments, cal will print the current month and will highlight the current day by reversing foreground and background colors in your terminal. I can’t show this in print, so I’ll show today’s date in bold, and you can pretend this is what you see when you run the command in your terminal:

$ cal
    October 2021
Su Mo Tu We Th Fr Sa
                1  2
 3  4  5  6  7  8  9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31

A single positional argument will be interpreted as the year. If this value is a valid integer in the range of 1–9999, cal will show the calendar for that year. For example, following is a calendar for the year 1066. Note that the year is shown centered on the first line in the following output:

$ cal 1066
                            1066
      January               February               March
Su Mo Tu We Th Fr Sa  Su Mo Tu We Th Fr Sa  Su Mo Tu We Th Fr Sa
 1  2  3  4  5  6  7            1  2  3  4            1  2  3  4
 8  9 10 11 12 13 14   5  6  7  8  9 10 11   5  6  7  8  9 10 11
15 16 17 18 19 20 21  12 13 14 15 16 17 18  12 13 14 15 16 17 18
22 23 24 25 26 27 28  19 20 21 22 23 24 25  19 20 21 22 23 24 25
29 30 31              26 27 28              26 27 28 29 30 31


       April                  May                   June
Su Mo Tu We Th Fr Sa  Su Mo Tu We Th Fr Sa  Su Mo Tu We Th Fr Sa
                   1      1  2  3  4  5  6               1  2  3
 2  3  4  5  6  7  8   7  8  9 10 11 12 13   4  5  6  7  8  9 10
 9 10 11 12 13 14 15  14 15 16 17 18 19 20  11 12 13 14 15 16 17
16 17 18 19 20 21 22  21 22 23 24 25 26 27  18 19 20 21 22 23 24
23 24 25 26 27 28 29  28 29 30 31           25 26 27 28 29 30
30

        July                 August              September
Su Mo Tu We Th Fr Sa  Su Mo Tu We Th Fr Sa  Su Mo Tu We Th Fr Sa
                   1         1  2  3  4  5                  1  2
 2  3  4  5  6  7  8   6  7  8  9 10 11 12   3  4  5  6  7  8  9
 9 10 11 12 13 14 15  13 14 15 16 17 18 19  10 11 12 13 14 15 16
16 17 18 19 20 21 22  20 21 22 23 24 25 26  17 18 19 20 21 22 23
23 24 25 26 27 28 29  27 28 29 30 31        24 25 26 27 28 29 30
30 31

      October               November              December
Su Mo Tu We Th Fr Sa  Su Mo Tu We Th Fr Sa  Su Mo Tu We Th Fr Sa
 1  2  3  4  5  6  7            1  2  3  4                  1  2
 8  9 10 11 12 13 14   5  6  7  8  9 10 11   3  4  5  6  7  8  9
15 16 17 18 19 20 21  12 13 14 15 16 17 18  10 11 12 13 14 15 16
22 23 24 25 26 27 28  19 20 21 22 23 24 25  17 18 19 20 21 22 23
29 30 31              26 27 28 29 30        24 25 26 27 28 29 30
                                            31

Both the BSD and GNU versions show similar error messages if the year is not in the acceptable range:

$ cal 0
cal: year `0' not in range 1..9999
$ cal 10000
cal: year `10000' not in range 1..9999

Both versions will interpret two integer values as the ordinal value of the month and year, respectively. For example, in the incantation cal 3 1066, the 3 will be interpreted as the third month, which is March. Note that when showing a single month, the year is included with the month name:

$ cal 3 1066
     March 1066
Su Mo Tu We Th Fr Sa
          1  2  3  4
 5  6  7  8  9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31

Use the -y|--year flag to show the whole current year, which I find useful because I often forget what year it is too. If both -y|--year and the positional year are present, cal will use the positional year argument, but the challenge program should consider this an error. Oddly, GNU cal will not complain if you combine -y with both a month and a year, but BSD cal will error out. This is as much as the challenge program will implement.

Getting Started

The program in this chapter should be called calr (pronounced cal-ar) for a Rust calendar. Use cargo new calr to get started, then add the following dependencies to Cargo.toml:

[dependencies]
clap = "2.33"
chrono = "0.4" 1
itertools = "0.10" 2
ansi_term = "0.12" 3

[dev-dependencies]
assert_cmd = "2"
predicates = "2"
1

The chrono crate will provide access to date and time functions.

2

The itertools crate will be used to join lines of text.

3

The ansi_term crate will be used to highlight today’s date.

Copy the book’s 13_calr/tests directory into your project, and run cargo test to build and test your program, which should fail most ignominiously.

Defining and Validating the Arguments

I suggest you change src/main.rs to the following:

fn main() {
    if let Err(e) = calr::get_args().and_then(calr::run) {
        eprintln!("{}", e);
        std::process::exit(1);
    }
}

The following Config struct uses chrono::naive::NaiveDate, which is an ISO 8601 calendar date without a time zone that can represent dates from January 1, 262145 BCE to December 31, 262143 CE. Naive dates are fine for this application as it does not require time zones. Here is how you can start your src/lib.rs:

use clap::{App, Arg};
use std::error::Error;
use chrono::NaiveDate;

#[derive(Debug)]
pub struct Config {
    month: Option<u32>, 1
    year: i32, 2
    today: NaiveDate, 3
}

type MyResult<T> = Result<T, Box<dyn Error>>;
1

The month is an optional u32 value.

2

The year is a required i32 value.

3

Today’s date will be useful in get_args and in the main program, so I’ll store it here.

Tip

Since the month will only be in the range 1–12 and the year in the range 0–9999, these integer sizes may seem excessively large. I chose them because they are the types that the chrono crate uses for month and year. I found these to be the most convenient types, but you are welcome to use something else.

Here is a skeleton you can complete for your get_args function:

pub fn get_args() -> MyResult<Config> {
    let matches = App::new("calr")
        .version("0.1.0")
        .author("Ken Youens-Clark <kyclark@gmail.com>")
        .about("Rust cal")
        // What goes here?
        .get_matches();

    Ok(Config {
        month: ...,
        year: ...,
        today: ...,
    })
}

Begin your run by printing the config:

pub fn run(config: Config) -> MyResult<()> {
    println!("{:?}", config);
    Ok(())
}

Your program should be able to produce the following usage:

$ cargo run -- -h
calr 0.1.0
Ken Youens-Clark <kyclark@gmail.com>
Rust cal

USAGE:
    calr [FLAGS] [OPTIONS] [YEAR]

FLAGS:
    -h, --help       Prints help information
    -y, --year       Show whole current year
    -V, --version    Prints version information

OPTIONS:
    -m <MONTH>        Month name or number (1-12)

ARGS:
    <YEAR>    Year (1-9999)

When run with no arguments, the program should default to using the current month and year, which was October 2021 when I was writing this. To figure out the default values for year and month, I recommend that you use the chrono crate. If you add use chrono::Local to your src/lib.rs, you can call the chrono::off⁠set​::Local::today function to get the current chrono::Date struct set to your local time zone. You can then use methods like month and year to get integer values representing the current month and year. Update your src​/⁠lib.rs with the following code:

use chrono::{Datelike, Local};

pub fn get_args() -> MyResult<Config> {
    let matches = ...
    let today = Local::today();

    Ok(Config {
        month: Some(today.month()),
        year: today.year(),
        today: today.naive_local(),
    })
}

Now you should be able to see something like the following output:

$ cargo run
Config { month: Some(10), year: 2021, today: 2021-10-10 }

Next, update your get_args to parse the given arguments. For example, a single integer positional argument should be interpreted as the year, and the month should be None to show the entire year:

$ cargo run -- 1000
Config { month: None, year: 1000, today: 2021-10-10 }

The -y|--year flag should cause year to be set to the current year and month to be None, indicating that the entire year should be shown:

$ cargo run -- -y
Config { month: None, year: 2021, today: 2021-10-10 }

Your program should accept valid integer values for the month and year:

$ cargo run -- -m 7 1776
Config { month: Some(7), year: 1776, today: 2021-10-10 }

Note that months may be provided as any distinguishing starting substring, so Jul or July should work:

$ cargo run -- -m Jul 1776
Config { month: Some(7), year: 1776, today: 2021-10-10 }

The string Ju is not enough to disambiguate June and July:

$ cargo run -- -m Ju 1776
Invalid month "Ju"

Month names should also be case-insensitive, so s is enough to distinguish September:

$ cargo run -- -m s 1999
Config { month: Some(9), year: 1999, today: 2021-10-12 }

Ensure that the program will use the current month and year when given no arguments:

$ cargo run
Config { month: Some(10), year: 2021, today: 2021-10-10 }

Any month number outside the range 1–12 should be rejected:

$ cargo run -- -m 0
month "0" not in the range 1 through 12

Any unknown month name should be rejected:

$ cargo run -- -m Fortinbras
Invalid month "Fortinbras"

Any year outside the range 1–9999 should also be rejected:

$ cargo run -- 0
year "0" not in the range 1 through 9999

The -y|--year flag cannot be used with the month:

$ cargo run -- -m 1 -y
error: The argument '-m <MONTH>' cannot be used with '--year'

USAGE:
    calr -m <MONTH> --year

The program should also error out when combining the -y|--year flag with the year positional argument:

$ cargo run -- -y 1972
error: The argument '<YEAR>' cannot be used with '--year'

USAGE:
    calr --year

To validate the month and year, you will need to be able to parse a string into an integer value, which you’ve done several times before. In this case, the month must be a u32 while the year must be an i32 to match the types used by the chrono crate. I wrote functions called parse_year and parse_month to handle the year and month conversion and validation. Both rely on a parse_int function with the following signature that generically defines a return type T that implements std::str​::FromStr. This allows me to specify whether I want a u32 for the month or an i32 for the year when I call the function. If you plan to implement this function, be sure to add use std::str::FromStr to your imports:

fn parse_int<T: FromStr>(val: &str) -> MyResult<T> {
    unimplemented!();
}

Following is how you can start your tests module with the test_parse_int unit test for this function:

#[cfg(test)]
mod tests {
    use super::parse_int;

    #[test]
    fn test_parse_int() {
        // Parse positive int as usize
        let res = parse_int::<usize>("1"); 1
        assert!(res.is_ok());
        assert_eq!(res.unwrap(), 1usize); 2

        // Parse negative int as i32
        let res = parse_int::<i32>("-1");
        assert!(res.is_ok());
        assert_eq!(res.unwrap(), -1i32);

        // Fail on a string
        let res = parse_int::<i64>("foo");
        assert!(res.is_err());
        assert_eq!(res.unwrap_err().to_string(), "Invalid integer \"foo\"");
    }
}
1

Use the turbofish on the function call to indicate the return type.

2

Use a numeric literal like 1usize to specify the value 1 and type usize.

Note

Stop here and write the function that passes cargo test test_parse_int.

My parse_year takes a string and might return an i32. It starts like this:

fn parse_year(year: &str) -> MyResult<i32> {
    unimplemented!();
}

Expand your tests module with the following unit test, which checks that the bounds 1 and 9999 are accepted and that values outside this range are rejected:

#[cfg(test)]
mod tests {
    use super::{parse_int, parse_year}; 1

    #[test]
    fn test_parse_int() {} // Same as before

    #[test]
    fn test_parse_year() {
        let res = parse_year("1");
        assert!(res.is_ok());
        assert_eq!(res.unwrap(), 1i32);

        let res = parse_year("9999");
        assert!(res.is_ok());
        assert_eq!(res.unwrap(), 9999i32);

        let res = parse_year("0");
        assert!(res.is_err());
        assert_eq!(
            res.unwrap_err().to_string(),
            "year \"0\" not in the range 1 through 9999"
        );

        let res = parse_year("10000");
        assert!(res.is_err());
        assert_eq!(
            res.unwrap_err().to_string(),
            "year \"10000\" not in the range 1 through 9999"
        );

        let res = parse_year("foo");
        assert!(res.is_err());
    }
}
1

Add parse_year to the list of imports.

Note

Stop and write the function that will pass cargo test test_parse_year.

Next, you can start parse_month like so:

fn parse_month(month: &str) -> MyResult<u32> {
    unimplemented!();
}

The following unit test checks for success using the bounds 1 and 12 and a sample case-insensitive month like jan (for January). It then ensures that values outside 1–12 are rejected, as is an unknown month name:

#[cfg(test)]
mod tests {
    use super::{parse_int, parse_month, parse_year}; 1

    #[test]
    fn test_parse_int() {} // Same as before

    #[test]
    fn test_parse_year() {} // Same as before

    #[test]
    fn test_parse_month() {
        let res = parse_month("1");
        assert!(res.is_ok());
        assert_eq!(res.unwrap(), 1u32);

        let res = parse_month("12");
        assert!(res.is_ok());
        assert_eq!(res.unwrap(), 12u32);

        let res = parse_month("jan");
        assert!(res.is_ok());
        assert_eq!(res.unwrap(), 1u32);

        let res = parse_month("0");
        assert!(res.is_err());
        assert_eq!(
            res.unwrap_err().to_string(),
            "month \"0\" not in the range 1 through 12"
        );

        let res = parse_month("13");
        assert!(res.is_err());
        assert_eq!(
            res.unwrap_err().to_string(),
            "month \"13\" not in the range 1 through 12"
        );

        let res = parse_month("foo");
        assert!(res.is_err());
        assert_eq!(res.unwrap_err().to_string(), "Invalid month \"foo\"");
    }
}
1

Add parse_month to the list of imports.

Note

Stop reading here and write the function that passes cargo test test_parse_month.

At this point, your program should pass cargo test parse:

running 3 tests
test tests::test_parse_year ... ok
test tests::test_parse_int ... ok
test tests::test_parse_month ... ok

Following is how I wrote my parse_int in such a way that it can return either an i32 or a u32:1

fn parse_int<T: FromStr>(val: &str) -> MyResult<T> {
    val.parse() 1
        .map_err(|_| format!("Invalid integer \"{}\"", val).into()) 2
}
1

Use str::parse to convert the string into the desired return type.

2

In the event of an error, create a useful error message.

Following is how I wrote parse_year:

fn parse_year(year: &str) -> MyResult<i32> {
    parse_int(year).and_then(|num| { 1
        if (1..=9999).contains(&num) { 2
            Ok(num) 3
        } else {
            Err(format!("year \"{}\" not in the range 1 through 9999", year) 4
                .into())
        }
    })
}
1

Rust infers the type for parse_int from the function’s return type, i32. Use Option::and_then to handle an Ok result from parse_int.

2

Check that the parsed number num is in the range 1–9999, inclusive of the upper bound.

3

Return the parsed and validated number num.

4

Return an informative error message.

My parse_month function needs a list of valid month names, so I declare a constant value at the top of my src/lib.rs:

const MONTH_NAMES: [&str; 12] = [
    "January",
    "February",
    "March",
    "April",
    "May",
    "June",
    "July",
    "August",
    "September",
    "October",
    "November",
    "December",
];

Following is how I use the month names to help figure out the given month:

fn parse_month(month: &str) -> MyResult<u32> {
    match parse_int(month) { 1
        Ok(num) => {
            if (1..=12).contains(&num) { 2
                Ok(num)
            } else {
                Err(format!(
                    "month \"{}\" not in the range 1 through 12", 3
                    month
                )
                .into())
            }
        }
        _ => {
            let lower = &month.to_lowercase(); 4
            let matches: Vec<_> = MONTH_NAMES
                .iter()
                .enumerate() 5
                .filter_map(|(i, name)| {
                    if name.to_lowercase().starts_with(lower) { 6
                        Some(i + 1) 7
                    } else {
                        None
                    }
                })
                .collect(); 8

            if matches.len() == 1 { 9
                Ok(matches[0] as u32)
            } else {
                Err(format!("Invalid month \"{}\"", month).into())
            }
        }
    }
}
1

Attempt to parse a numeric argument.

2

If the number num parsed is in the range 1–12, return the value.

3

Otherwise, create an informative error message.

4

If the month didn’t parse as an integer, compare the lowercased value to the month names.

5

Enumerate the month names to get the index and value.

6

See if the given value is the start of a month name.

7

If so, return the zero-based index position corrected to one-based counting.

8

Collect all the possible month values as a vector of usize values.

9

If there was exactly one possible month, return it as a u32 value; otherwise, return an informative error message.

Following is how I bring all these together in my get_args to parse and validate the command-line arguments and choose the defaults:

pub fn get_args() -> MyResult<Config> {
    let matches = App::new("calr")
        .version("0.1.0")
        .author("Ken Youens-Clark <kyclark@gmail.com>")
        .about("Rust cal")
        .arg(
            Arg::with_name("month")
                .value_name("MONTH")
                .short("m")
                .help("Month name or number (1-12)")
                .takes_value(true),
        )
        .arg(
            Arg::with_name("show_current_year")
                .value_name("SHOW_YEAR")
                .short("y")
                .long("year")
                .help("Show whole current year")
                .conflicts_with_all(&["month", "year"])
                .takes_value(false),
        )
        .arg(
            Arg::with_name("year")
                .value_name("YEAR")
                .help("Year (1-9999)"),
        )
        .get_matches();

    let mut month = matches.value_of("month").map(parse_month).transpose()?; 1
    let mut year = matches.value_of("year").map(parse_year).transpose()?;
    let today = Local::today(); 2
    if matches.is_present("show_current_year") { 3
        month = None;
        year = Some(today.year());
    } else if month.is_none() && year.is_none() { 4
        month = Some(today.month());
        year = Some(today.year());
    }

    Ok(Config {
        month,
        year: year.unwrap_or_else(|| today.year()),
        today: today.naive_local(),
    })
}
1

Parse and validate the month and year values.

2

Get today’s date.

3

If -y|--year is present, set the year to the current year and the month to None.

4

Otherwise, show the current month.

At this point, your program should pass cargo test dies:

running 8 tests
test dies_year_0 ... ok
test dies_invalid_year ... ok
test dies_invalid_month ... ok
test dies_year_13 ... ok
test dies_month_13 ... ok
test dies_month_0 ... ok
test dies_y_and_month ... ok
test dies_y_and_year ... ok

Writing the Program

Now that you have good input, it’s time to write the rest of the program. First, consider how to print just one month, like April 2016, which I will place beside the same month from 2017. I’ll pipe the output from cal into cat -e, which will show the dollar sign ($) for the ends of the lines. The following shows that each month has eight lines: one for the name of the month, one for the day headers, and six for the weeks of the month. Additionally, each line must be 22 columns wide:

$ cal -m 4 2016 | cat -e           $ cal -m 4 2017 | cat -e
     April 2016       $                 April 2017       $
Su Mo Tu We Th Fr Sa  $            Su Mo Tu We Th Fr Sa  $
                1  2  $                               1  $
 3  4  5  6  7  8  9  $             2  3  4  5  6  7  8  $
10 11 12 13 14 15 16  $             9 10 11 12 13 14 15  $
17 18 19 20 21 22 23  $            16 17 18 19 20 21 22  $
24 25 26 27 28 29 30  $            23 24 25 26 27 28 29  $
                      $            30                    $

I decided to create a function called format_month to create the output for one month:

fn format_month(
    year: i32, 1
    month: u32, 2
    print_year: bool, 3
    today: NaiveDate, 4
) -> Vec<String> { 5
    unimplemented!();
}
1

The year of the month.

2

The month number to format.

3

Whether or not to include the year in the month’s header.

4

Today’s date, used to highlight today.

5

The function returns a Vec<String>, which is the eight lines of text.

You can expand your tests module to include the following unit test:

#[cfg(test)]
mod tests {
    use super::{format_month, parse_int, parse_month, parse_year}; 1
    use chrono::NaiveDate;

    #[test]
    fn test_parse_int() {} // Same as before

    #[test]
    fn test_parse_year() {} // Same as before

    #[test]
    fn test_parse_month() {} // Same as before

    #[test]
    fn test_format_month() {
        let today = NaiveDate::from_ymd(0, 1, 1);
        let leap_february = vec![
            "   February 2020      ",
            "Su Mo Tu We Th Fr Sa  ",
            "                   1  ",
            " 2  3  4  5  6  7  8  ",
            " 9 10 11 12 13 14 15  ",
            "16 17 18 19 20 21 22  ",
            "23 24 25 26 27 28 29  ",
            "                      ",
        ];
        assert_eq!(format_month(2020, 2, true, today), leap_february); 2

        let may = vec![
            "        May           ",
            "Su Mo Tu We Th Fr Sa  ",
            "                1  2  ",
            " 3  4  5  6  7  8  9  ",
            "10 11 12 13 14 15 16  ",
            "17 18 19 20 21 22 23  ",
            "24 25 26 27 28 29 30  ",
            "31                    ",
        ];
        assert_eq!(format_month(2020, 5, false, today), may); 3

        let april_hl = vec![
            "     April 2021       ",
            "Su Mo Tu We Th Fr Sa  ",
            "             1  2  3  ",
            " 4  5  6 \u{1b}[7m 7\u{1b}[0m  8  9 10  ", 4
            "11 12 13 14 15 16 17  ",
            "18 19 20 21 22 23 24  ",
            "25 26 27 28 29 30     ",
            "                      ",
        ];
        let today = NaiveDate::from_ymd(2021, 4, 7);
        assert_eq!(format_month(2021, 4, true, today), april_hl); 5
    }
}
1

Import the format_month function and the chrono::NaiveDate struct.

2

This February month should include a blank line at the end and has 29 days because this is a leap year.

3

This May month should span the same number of lines as April.

4

ansi_term::Style::reverse is used to create the highlighting of April 7 in this output.

5

Create a today that falls in the given month and verify the output highlights the date.

Note

The escape sequences that Style::reverse creates are not exactly the same as BSD cal, but the effect is the same. You can choose any method of highlighting the current date you like, but be sure to update the test accordingly.

You might start your format_month function by numbering all the days in a month from one to the last day in the month. It’s not as trivial as the “thirty days hath September” mnemonic because February can have a different number of days depending on whether it’s a leap year. I wrote a function called last_day_in_month that will return a NaiveDate representing the last day of any month:

fn last_day_in_month(year: i32, month: u32) -> NaiveDate {
    unimplemented!();
}

Following is a unit test you can add, which you might notice includes a leap year check. Be sure to add last_day_in_month to the imports at the top of the tests module:

#[test]
fn test_last_day_in_month() {
    assert_eq!(
        last_day_in_month(2020, 1),
        NaiveDate::from_ymd(2020, 1, 31)
    );
    assert_eq!(
        last_day_in_month(2020, 2),
        NaiveDate::from_ymd(2020, 2, 29)
    );
    assert_eq!(
        last_day_in_month(2020, 4),
        NaiveDate::from_ymd(2020, 4, 30)
    );
}
Note

Stop reading and write the code to pass cargo test test_for⁠mat​_month.

At this point, you should have all the pieces to finish the program. The challenge program will only ever print a single month or all 12 months, so start by getting your program to print the current month with the current day highlighted. Next, have it print all the months for a year, one month after the other. Then consider how you could create four rows that group three months side by side to mimic the output of cal. Because each month is a vector of lines, you need to combine all the first lines of each row, and then all the second lines, and so forth. This operation is often called a zip, and Rust iterators have a zip method you might find useful. Keep going until you pass all of cargo test. When you’re done, check out my solution.

Solution

I’ll walk you through how I built up my version of the program. Following are all the imports you’ll need:

use ansi_term::Style;
use chrono::{Datelike, Local, NaiveDate};
use clap::{App, Arg};
use itertools::izip;
use std::{error::Error, str::FromStr};

I also added another constant for the width of the lines:

const LINE_WIDTH: usize = 22;

I’ll start with my last_day_in_month function, which figures out the first day of the next month and then finds its predecessor:

fn last_day_in_month(year: i32, month: u32) -> NaiveDate {
    // The first day of the next month...
    let (y, m) = if month == 12 { 1
        (year + 1, 1)
    } else {
        (year, month + 1) 2
    };
    // ...is preceded by the last day of the original month
    NaiveDate::from_ymd(y, m, 1).pred() 3
}
1

If this is December, then advance the year by one and set the month to January.

2

Otherwise, increment the month by one.

3

Use NaiveDate::from_ymd to create a NaiveDate, and then call NaiveDate::pred to get the previous calendar date.

Tip

You might be tempted to roll your own solution rather than using the chrono crate, but the calculation of leap years could prove onerous. For instance, a leap year must be evenly divisible by 4—except for end-of-century years, which must be divisible by 400. This means that the year 2000 was a leap year but 1900 was not, and 2100 won’t be, either. It’s more advisable to stick with a library that has a good reputation and is well tested rather than creating your own implementation.

Next, I’ll break down my format_month function to format a given month:

fn format_month(
    year: i32,
    month: u32,
    print_year: bool,
    today: NaiveDate,
) -> Vec<String> {
    let first = NaiveDate::from_ymd(year, month, 1); 1
    let mut days: Vec<String> = (1..first.weekday().number_from_sunday()) 2
        .into_iter()
        .map(|_| "  ".to_string()) // Two spaces
        .collect();
1

Construct a NaiveDate for the start of the given month.

2

Initialize a Vec<String> with a buffer of the days from Sunday until the start of the month.

The initialization of days handles, for instance, the fact that April 2020 starts on a Wednesday. In this case, I want to fill up the days of the first week with two spaces for each day from Sunday through Tuesday. Continuing from there:

    let is_today = |day: u32| { 1
        year == today.year() && month == today.month() && day == today.day()
    };

    let last = last_day_in_month(year, month); 2
    days.extend((first.day()..=last.day()).into_iter().map(|num| { 3
        let fmt = format!("{:>2}", num); 4
        if is_today(num) { 5
            Style::new().reverse().paint(fmt).to_string()
        } else {
            fmt
        }
    }));
1

Create a closure to determine if a given day of the month is today.

2

Find the last day of this month.

3

Extend days by iterating through each chrono::Datelike::day from the first to the last of the month.

4

Format the day right-justified in two columns.

5

If the given day is today, use Style::reverse to highlight the text; otherwise, use the text as is.

Here is the last part of this function:

    let month_name = MONTH_NAMES[month as usize - 1]; 1
    let mut lines = Vec::with_capacity(8); 2
    lines.push(format!( 3
        "{:^20}  ", // two trailing spaces
        if print_year {
            format!("{} {}", month_name, year)
        } else {
            month_name.to_string()
        }
    ));

    lines.push("Su Mo Tu We Th Fr Sa  ".to_string()); // two trailing spaces 4

    for week in days.chunks(7) { 5
        lines.push(format!( 6
            "{:width$}  ", // two trailing spaces
            week.join(" "),
            width = LINE_WIDTH - 2
        ));
    }

    while lines.len() < 8 { 7
        lines.push(" ".repeat(LINE_WIDTH)); 8
    }

    lines 9
}
1

Get the current month’s display name, which requires casting month as a usize and correcting for zero-offset counting.

2

Initialize an empty, mutable vector that can hold eight lines of text.

3

The month header may or may not have the year. Format the header centered in a space 20 characters wide followed by 2 spaces.

4

Add the days of the week.

5

Use Vec::chunks to get seven weekdays at a time. This will start on Sunday because of the earlier buffer.

6

Join the days on a space and format the result into the correct width.

7

Pad with as many lines as needed to bring the total to eight.

8

Use str::repeat to create a new String by repeating a single space to the width of the line.

9

Return the lines.

Finally, here is how I bring everything together in my run:

pub fn run(config: Config) -> MyResult<()> {
    match config.month {
        Some(month) => { 1
            let lines = format_month(config.year, month, true, config.today); 2
            println!("{}", lines.join("\n")); 3
        }
        None => { 4
            println!("{:>32}", config.year); 5
            let months: Vec<_> = (1..=12) 6
                .into_iter()
                .map(|month| {
                    format_month(config.year, month, false, config.today)
                })
                .collect();

            for (i, chunk) in months.chunks(3).enumerate() { 7
                if let [m1, m2, m3] = chunk { 8
                    for lines in izip!(m1, m2, m3) { 9
                        println!("{}{}{}", lines.0, lines.1, lines.2); 10
                    }
                    if i < 3 { 11
                        println!();
                    }
                }
            }
        }
    }

    Ok(())
}
1

Handle the case of a single month.

2

Format the one month with the year in the header.

3

Print the lines joined on newlines.

4

When there is no month, then print the whole year.

5

When printing all the months, first print the year as the first header.

6

Format all the months, leaving out the year from the headers.

7

Use Vec::chunks to group into slices of three, and use Iterator::enumerate to track the grouping numbers.

8

Use the pattern match [m1, m2, m3] to destructure the slice into the three months.

9

Use itertools::izip to create an iterator that combines the lines from the three months.

10

Print the lines from each of the three months.

11

If not on the last set of months, print a newline to separate the groupings.

With that, all the tests pass, and you can now visualize a calendar in the terminal.

Going Further

You could further customize this program. For instance, you could check for the existence of a $HOME/.calr configuration file that lists special dates such as holidays, birthdays, and anniversaries. Use your new terminal colorizing skills to highlight these dates using bold, reversed, or colored text.

The manual page mentions the program ncal, which will format the months vertically rather than horizontally. When displaying a full year, ncal prints three rows of four months as opposed to four rows of three months like cal. Create an option to change the output of calr to match the output from ncal, being sure that you add tests for all the possibilities.

Consider how you could internationalize the output. It’s common to have a LANG or LANGUAGE environment variable that you could use to select month names in the user’s preferred language. Alternatively, you might allow the user to customize the months using the aforementioned configuration file. How could you handle languages that use different scripts, such as Chinese, Japanese, or Cyrillic? Try making a Hebrew calendar that reads right to left or a Mongolian one that reads top to bottom.

The original cal shows only one month or the entire year. Allow the user to select multiple months, perhaps using the ranges from cutr. This would allow something like -m 4,1,7-9 to show April, January, and July through September.

Finally, I mentioned the date command at the beginning of the chapter. This is a program that shows just the current date and time, among many other things. Use man date to read the manual page, and then write a Rust version that implements whichever options you find tantalizing.

1 Technically, this function can parse any type that implements FromStr, such as the floating-point type f64.