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 oneThey 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
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.
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"
itertools
=
"0.10"
ansi_term
=
"0.12"
[dev-dependencies]
assert_cmd
=
"2"
predicates
=
"2"
The chrono
crate will provide access to date and time functions.
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.
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
>
,
year
:
i32
,
today
:
NaiveDate
,
}
type
MyResult
<
T
>
=
Result
<
T
,
Box
<
dyn
Error
>
>
;
The month
is an optional u32
value.
The year
is a required i32
value.
Today’s date will be useful in get_args
and in the main program, so I’ll store it here.
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::offset::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 }
The chrono
crate also has chrono::offset::Utc
to get time based on Coordinated Universal Time (UTC), which is the successor to Greenwich Mean Time (GMT) and is the time standard used for regulating the world’s clocks. You may be asking, “Why isn’t it abbreviated as CUT?” Apparently, it’s because the International Telecommunication Union and the International Astronomical Union wanted to have one universal acronym. The English speakers proposed CUT (for coordinated universal time), while the French speakers wanted TUC (for temps universel coordonné). Using the wisdom of Solomon, they compromised with UTC, which doesn’t mean anything in particular but conforms to the abbreviation convention for universal time.
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
"
)
;
assert
!
(
res
.
is_ok
(
)
)
;
assert_eq
!
(
res
.
unwrap
(
)
,
1
usize
)
;
// Parse negative int as i32
let
res
=
parse_int
:
:
<
i32
>
(
"
-1
"
)
;
assert
!
(
res
.
is_ok
(
)
)
;
assert_eq
!
(
res
.
unwrap
(
)
,
-
1
i32
)
;
// Fail on a string
let
res
=
parse_int
:
:
<
i64
>
(
"
foo
"
)
;
assert
!
(
res
.
is_err
(
)
)
;
assert_eq
!
(
res
.
unwrap_err
(
)
.
to_string
(
)
,
"
Invalid integer
\"
foo
\"
"
)
;
}
}
Use the turbofish on the function call to indicate the return type.
Use a numeric literal like 1usize
to specify the value 1
and type usize
.
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
}
;
#[
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
(
)
,
1
i32
)
;
let
res
=
parse_year
(
"
9999
"
)
;
assert
!
(
res
.
is_ok
(
)
)
;
assert_eq
!
(
res
.
unwrap
(
)
,
9999
i32
)
;
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
(
)
)
;
}
}
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
}
;
#[
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
(
)
,
1
u32
)
;
let
res
=
parse_month
(
"
12
"
)
;
assert
!
(
res
.
is_ok
(
)
)
;
assert_eq
!
(
res
.
unwrap
(
)
,
12
u32
)
;
let
res
=
parse_month
(
"
jan
"
)
;
assert
!
(
res
.
is_ok
(
)
)
;
assert_eq
!
(
res
.
unwrap
(
)
,
1
u32
)
;
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
\"
"
)
;
}
}
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
(
)
.
map_err
(
|
_
|
format
!
(
"
Invalid integer
\"
{}
\"
"
,
val
)
.
into
(
)
)
}
Use str::parse
to convert the string into the desired return type.
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
|
{
if
(
1
.
.
=
9999
)
.
contains
(
&
num
)
{
Ok
(
num
)
}
else
{
Err
(
format
!
(
"
year
\"
{}
\"
not in the range 1 through 9999
"
,
year
)
.
into
(
)
)
}
}
)
}
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
.
Check that the parsed number num
is in the range 1–9999, inclusive of the upper bound.
Return the parsed and validated number num
.
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
)
{
Ok
(
num
)
=
>
{
if
(
1
.
.
=
12
)
.
contains
(
&
num
)
{
Ok
(
num
)
}
else
{
Err
(
format
!
(
"
month
\"
{}
\"
not in the range 1 through 12
"
,
month
)
.
into
(
)
)
}
}
_
=
>
{
let
lower
=
&
month
.
to_lowercase
(
)
;
let
matches
:
Vec
<
_
>
=
MONTH_NAMES
.
iter
(
)
.
enumerate
(
)
.
filter_map
(
|
(
i
,
name
)
|
{
if
name
.
to_lowercase
(
)
.
starts_with
(
lower
)
{
Some
(
i
+
1
)
}
else
{
None
}
}
)
.
collect
(
)
;
if
matches
.
len
(
)
=
=
1
{
Ok
(
matches
[
0
]
as
u32
)
}
else
{
Err
(
format
!
(
"
Invalid month
\"
{}
\"
"
,
month
)
.
into
(
)
)
}
}
}
}
Attempt to parse a numeric argument.
If the number num
parsed is in the range 1–12, return the value.
Otherwise, create an informative error message.
If the month didn’t parse as an integer, compare the lowercased value to the month names.
Enumerate the month names to get the index and value.
See if the given value is the start of a month name.
If so, return the zero-based index position corrected to one-based counting.
Collect all the possible month values as a vector of usize
values.
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
(
)
?
;
let
mut
year
=
matches
.
value_of
(
"
year
"
)
.
map
(
parse_year
)
.
transpose
(
)
?
;
let
today
=
Local
::
today
(
)
;
if
matches
.
is_present
(
"
show_current_year
"
)
{
month
=
None
;
year
=
Some
(
today
.
year
(
)
)
;
}
else
if
month
.
is_none
(
)
&
&
year
.
is_none
(
)
{
month
=
Some
(
today
.
month
(
)
)
;
year
=
Some
(
today
.
year
(
)
)
;
}
Ok
(
Config
{
month
,
year
:
year
.
unwrap_or_else
(
|
|
today
.
year
(
)
)
,
today
:
today
.
naive_local
(
)
,
}
)
}
Parse and validate the month and year values.
Get today’s date.
If -y|--year
is present, set the year to the current year and the month to None
.
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
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
,
month
:
u32
,
print_year
:
bool
,
today
:
NaiveDate
,
)
->
Vec
<
String
>
{
unimplemented
!
(
)
;
}
The year
of the month.
The month
number to format.
Whether or not to include the year in the month’s header.
Today’s date, used to highlight today.
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
}
;
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
)
;
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
)
;
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
"
,
"
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
)
;
}
}
Import the format_month
function and the chrono::NaiveDate
struct.
This February month should include a blank line at the end and has 29 days because this is a leap year.
This May month should span the same number of lines as April.
ansi_term::Style::reverse
is used to create the highlighting of April 7 in this output.
Create a today
that falls in the given month and verify the output highlights the date.
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
)
);
}
Stop reading and write the code to pass cargo test test_format_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.
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
{
(
year
+
1
,
1
)
}
else
{
(
year
,
month
+
1
)
}
;
// ...is preceded by the last day of the original month
NaiveDate
::
from_ymd
(
y
,
m
,
1
)
.
pred
(
)
}
If this is December, then advance the year by one and set the month to January.
Otherwise, increment the month by one.
Use NaiveDate::from_ymd
to create a NaiveDate
, and then call NaiveDate::pred
to get the previous calendar date.
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
)
;
let
mut
days
:
Vec
<
String
>
=
(
1
.
.
first
.
weekday
(
)
.
number_from_sunday
(
)
)
.
into_iter
(
)
.
map
(
|
_
|
"
"
.
to_string
(
)
)
// Two spaces
.
collect
(
)
;
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
|
{
year
=
=
today
.
year
(
)
&
&
month
=
=
today
.
month
(
)
&
&
day
=
=
today
.
day
(
)
}
;
let
last
=
last_day_in_month
(
year
,
month
)
;
days
.
extend
(
(
first
.
day
(
)
.
.
=
last
.
day
(
)
)
.
into_iter
(
)
.
map
(
|
num
|
{
let
fmt
=
format
!
(
"
{:>2}
"
,
num
)
;
if
is_today
(
num
)
{
Style
::
new
(
)
.
reverse
(
)
.
paint
(
fmt
)
.
to_string
(
)
}
else
{
fmt
}
}
)
)
;
Create a closure to determine if a given day of the month is today.
Find the last day of this month.
Extend days
by iterating through each chrono::Datelike::day
from the first to the last of the month.
Format the day right-justified in two columns.
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
]
;
let
mut
lines
=
Vec
::
with_capacity
(
8
)
;
lines
.
push
(
format
!
(
"
{:^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
for
week
in
days
.
chunks
(
7
)
{
lines
.
push
(
format
!
(
"
{:width$}
"
,
// two trailing spaces
week
.
join
(
"
"
)
,
width
=
LINE_WIDTH
-
2
)
)
;
}
while
lines
.
len
(
)
<
8
{
lines
.
push
(
"
"
.
repeat
(
LINE_WIDTH
)
)
;
}
lines
}
Get the current month’s display name, which requires casting month
as a usize
and correcting for zero-offset counting.
Initialize an empty, mutable vector that can hold eight lines of text.
The month header may or may not have the year. Format the header centered in a space 20 characters wide followed by 2 spaces.
Add the days of the week.
Use Vec::chunks
to get seven weekdays at a time. This will start on Sunday because of the earlier buffer.
Join the days on a space and format the result into the correct width.
Pad with as many lines as needed to bring the total to eight.
Use str::repeat
to create a new String
by repeating a single space to the width of the line.
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
)
=
>
{
let
lines
=
format_month
(
config
.
year
,
month
,
true
,
config
.
today
)
;
println
!
(
"
{}
"
,
lines
.
join
(
"
\n
"
)
)
;
}
None
=
>
{
println
!
(
"
{:>32}
"
,
config
.
year
)
;
let
months
:
Vec
<
_
>
=
(
1
.
.
=
12
)
.
into_iter
(
)
.
map
(
|
month
|
{
format_month
(
config
.
year
,
month
,
false
,
config
.
today
)
}
)
.
collect
(
)
;
for
(
i
,
chunk
)
in
months
.
chunks
(
3
)
.
enumerate
(
)
{
if
let
[
m1
,
m2
,
m3
]
=
chunk
{
for
lines
in
izip
!
(
m1
,
m2
,
m3
)
{
println
!
(
"
{}{}{}
"
,
lines
.
0
,
lines
.
1
,
lines
.
2
)
;
}
if
i
<
3
{
println
!
(
)
;
}
}
}
}
}
Ok
(
(
)
)
}
Handle the case of a single month.
Format the one month with the year in the header.
Print the lines joined on newlines.
When there is no month, then print the whole year.
When printing all the months, first print the year as the first header.
Format all the months, leaving out the year from the headers.
Use Vec::chunks
to group into slices of three, and use Iterator::enumerate
to track the grouping numbers.
Use the pattern match [m1, m2, m3]
to destructure the slice into the three months.
Use itertools::izip
to create an iterator that combines the lines from the three months.
Print the lines from each of the three months.
If not on the last set of months, print a newline to separate the groupings.
Rust iterators have a zip
function that, according to the documentation, “returns a new iterator that will iterate over two other iterators, returning a tuple where the first element comes from the first iterator, and the second element comes from the second iterator.” Unfortunately, it only works
with two iterators. If you look closely, you’ll notice that the call to
izip!
is actually a macro. The documentation says, “The result
of this macro is in the general case an iterator composed of repeated .zip()
and a .map()
.”
With that, all the tests pass, and you can now visualize a calendar in the terminal.
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.
Here’s a recap of some of the things you learned in this chapter:
Sometimes you would like to generically indicate the return type of a function using a trait bound. In the case of parse_int
, I indicated that the function returns something of the type T
that implements the FromStr
trait; this includes u32
, which I used for the month, and i32
, which I used for the year.
The chrono
crate provides a way to find today’s date and perform basic date manipulations, such as finding the previous day of a given date (in last_day_in_month
).
The Vec::chunks
method will return groupings of elements as a slice. The challenge program used this to gather weekdays into groups of seven and the months of the year into groups of three.
The Iterator::zip
method will combine the elements from two iterators into a new iterator containing a tuple of values from the sources. The itertools::izip
macro allows you to expand this to any number of iterators.
colorize::AnsiColor
can create terminal text in various colors and styles, such as reversing the colors used for the text and background to highlight the current date.
In the next chapter, you will learn more about Unix file metadata and how to format text tables of output.
1 Technically, this function can parse any type that implements FromStr
, such as the floating-point type f64
.