cal_tea/
lib.rs

1use ansi_term::Style;
2use chrono::{Datelike, Local, NaiveDate};
3use clap::{App, Arg};
4use itertools::izip;
5use std::{error::Error, str::FromStr};
6
7#[derive(Debug)]
8pub struct Config {
9    month: Option<u32>,
10    year: i32,
11    today: NaiveDate,
12}
13
14type MyResult<T> = Result<T, Box<dyn Error>>;
15
16const LINE_WIDTH: usize = 22;
17const MONTH_NAMES: [&str; 12] = [
18    "January",
19    "February",
20    "March",
21    "April",
22    "May",
23    "June",
24    "July",
25    "August",
26    "September",
27    "October",
28    "November",
29    "December",
30];
31
32// --------------------------------------------------
33pub fn get_args() -> MyResult<Config> {
34    let matches = App::new("calr")
35        .version("0.1.0")
36        .author("Ken Youens-Clark <kyclark@gmail.com>")
37        .about("Rust cal")
38        .arg(
39            Arg::with_name("month")
40                .value_name("MONTH")
41                .short("m")
42                .help("Month name or number (1-12)")
43                .takes_value(true),
44        )
45        .arg(
46            Arg::with_name("show_current_year")
47                .value_name("SHOW_YEAR")
48                .short("y")
49                .long("year")
50                .help("Show whole current year")
51                .conflicts_with_all(&["month", "year"])
52                .takes_value(false),
53        )
54        .arg(
55            Arg::with_name("year")
56                .value_name("YEAR")
57                .help("Year (1-9999)"),
58        )
59        .get_matches();
60
61    let mut month = matches.value_of("month").map(parse_month).transpose()?;
62    let mut year = matches.value_of("year").map(parse_year).transpose()?;
63
64    let today = Local::today();
65    if matches.is_present("show_current_year") {
66        month = None;
67        year = Some(today.year());
68    } else if month.is_none() && year.is_none() {
69        month = Some(today.month());
70        year = Some(today.year());
71    }
72
73    Ok(Config {
74        month,
75        year: year.unwrap_or_else(|| today.year()),
76        today: today.naive_local(),
77    })
78}
79
80// --------------------------------------------------
81pub fn run(config: Config) -> MyResult<()> {
82    match config.month {
83        Some(month) => {
84            let lines = format_month(config.year, month, true, config.today);
85            println!("{}", lines.join("\n"));
86        }
87        None => {
88            println!("{:>32}", config.year);
89            let months: Vec<_> = (1..=12)
90                .into_iter()
91                .map(|month| format_month(config.year, month, false, config.today))
92                .collect();
93
94            for (i, chunk) in months.chunks(3).enumerate() {
95                if let [m1, m2, m3] = chunk {
96                    for lines in izip!(m1, m2, m3) {
97                        println!("{}{}{}", lines.0, lines.1, lines.2);
98                    }
99                    if i < 3 {
100                        println!();
101                    }
102                }
103            }
104        }
105    }
106
107    Ok(())
108}
109
110// --------------------------------------------------
111fn parse_int<T: FromStr>(val: &str) -> MyResult<T> {
112    val.parse()
113        .map_err(|_| format!("Invalid integer \"{}\"", val).into())
114}
115
116// --------------------------------------------------
117fn parse_year(year: &str) -> MyResult<i32> {
118    parse_int(year).and_then(|num| {
119        if (1..=9999).contains(&num) {
120            Ok(num)
121        } else {
122            Err(format!("year \"{}\" not in the range 1 through 9999", year).into())
123        }
124    })
125}
126
127// --------------------------------------------------
128fn parse_month(month: &str) -> MyResult<u32> {
129    match parse_int(month) {
130        Ok(num) => {
131            if (1..=12).contains(&num) {
132                Ok(num)
133            } else {
134                Err(format!("month \"{}\" not in the range 1 through 12", month).into())
135            }
136        }
137        _ => {
138            let lower = &month.to_lowercase();
139            let matches: Vec<_> = MONTH_NAMES
140                .iter()
141                .enumerate()
142                .filter_map(|(i, name)| {
143                    if name.to_lowercase().starts_with(lower) {
144                        Some(i + 1)
145                    } else {
146                        None
147                    }
148                })
149                .collect();
150
151            if matches.len() == 1 {
152                Ok(matches[0] as u32)
153            } else {
154                Err(format!("Invalid month \"{}\"", month).into())
155            }
156        }
157    }
158}
159
160// --------------------------------------------------
161fn last_day_in_month(year: i32, month: u32) -> NaiveDate {
162    // The first day of the next month...
163    let (y, m) = if month == 12 {
164        (year + 1, 1)
165    } else {
166        (year, month + 1)
167    };
168    // ...is preceded by the last day of the original month
169    NaiveDate::from_ymd(y, m, 1).pred()
170}
171
172// --------------------------------------------------
173fn format_month(year: i32, month: u32, print_year: bool, today: NaiveDate) -> Vec<String> {
174    let first = NaiveDate::from_ymd(year, month, 1);
175    let mut days: Vec<String> = (1..first.weekday().number_from_sunday())
176        .into_iter()
177        .map(|_| "  ".to_string()) // two spaces
178        .collect();
179
180    let is_today = |day: u32| year == today.year() && month == today.month() && day == today.day();
181
182    let last = last_day_in_month(year, month);
183    days.extend((first.day()..=last.day()).into_iter().map(|num| {
184        let fmt = format!("{:>2}", num);
185        if is_today(num) {
186            Style::new().reverse().paint(fmt).to_string()
187        } else {
188            fmt
189        }
190    }));
191
192    let month_name = MONTH_NAMES[month as usize - 1];
193    let mut lines = Vec::with_capacity(8);
194    lines.push(format!(
195        "{:^20}  ", // two trailing spaces
196        if print_year {
197            format!("{} {}", month_name, year)
198        } else {
199            month_name.to_string()
200        }
201    ));
202
203    lines.push("Su Mo Tu We Th Fr Sa  ".to_string()); // two trailing spaces
204
205    for week in days.chunks(7) {
206        lines.push(format!(
207            "{:width$}  ", // two trailing spaces
208            week.join(" "),
209            width = LINE_WIDTH - 2
210        ));
211    }
212
213    while lines.len() < 8 {
214        lines.push(" ".repeat(LINE_WIDTH));
215    }
216
217    lines
218}
219
220// --------------------------------------------------
221#[cfg(test)]
222mod tests {
223    use super::{format_month, last_day_in_month, parse_int, parse_month, parse_year};
224    use chrono::NaiveDate;
225
226    #[test]
227    fn test_parse_int() {
228        // Parse positive int as usize
229        let res = parse_int::<usize>("1");
230        assert!(res.is_ok());
231        assert_eq!(res.unwrap(), 1usize);
232
233        // Parse negative int as i32
234        let res = parse_int::<i32>("-1");
235        assert!(res.is_ok());
236        assert_eq!(res.unwrap(), -1i32);
237
238        // Fail on a string
239        let res = parse_int::<i64>("foo");
240        assert!(res.is_err());
241        assert_eq!(res.unwrap_err().to_string(), "Invalid integer \"foo\"");
242    }
243
244    #[test]
245    fn test_parse_year() {
246        let res = parse_year("1");
247        assert!(res.is_ok());
248        assert_eq!(res.unwrap(), 1i32);
249
250        let res = parse_year("9999");
251        assert!(res.is_ok());
252        assert_eq!(res.unwrap(), 9999i32);
253
254        let res = parse_year("0");
255        assert!(res.is_err());
256        assert_eq!(
257            res.unwrap_err().to_string(),
258            "year \"0\" not in the range 1 through 9999"
259        );
260
261        let res = parse_year("10000");
262        assert!(res.is_err());
263        assert_eq!(
264            res.unwrap_err().to_string(),
265            "year \"10000\" not in the range 1 through 9999"
266        );
267
268        let res = parse_year("foo");
269        assert!(res.is_err());
270        assert_eq!(res.unwrap_err().to_string(), "Invalid integer \"foo\"");
271    }
272
273    #[test]
274    fn test_parse_month() {
275        let res = parse_month("1");
276        assert!(res.is_ok());
277        assert_eq!(res.unwrap(), 1u32);
278
279        let res = parse_month("12");
280        assert!(res.is_ok());
281        assert_eq!(res.unwrap(), 12u32);
282
283        let res = parse_month("jan");
284        assert!(res.is_ok());
285        assert_eq!(res.unwrap(), 1u32);
286
287        let res = parse_month("0");
288        assert!(res.is_err());
289        assert_eq!(
290            res.unwrap_err().to_string(),
291            "month \"0\" not in the range 1 through 12"
292        );
293
294        let res = parse_month("13");
295        assert!(res.is_err());
296        assert_eq!(
297            res.unwrap_err().to_string(),
298            "month \"13\" not in the range 1 through 12"
299        );
300
301        let res = parse_month("foo");
302        assert!(res.is_err());
303        assert_eq!(res.unwrap_err().to_string(), "Invalid month \"foo\"");
304    }
305
306    #[test]
307    fn test_format_month() {
308        let today = NaiveDate::from_ymd(0, 1, 1);
309        let leap_february = vec![
310            "   February 2020      ",
311            "Su Mo Tu We Th Fr Sa  ",
312            "                   1  ",
313            " 2  3  4  5  6  7  8  ",
314            " 9 10 11 12 13 14 15  ",
315            "16 17 18 19 20 21 22  ",
316            "23 24 25 26 27 28 29  ",
317            "                      ",
318        ];
319        assert_eq!(format_month(2020, 2, true, today), leap_february);
320
321        let may = vec![
322            "        May           ",
323            "Su Mo Tu We Th Fr Sa  ",
324            "                1  2  ",
325            " 3  4  5  6  7  8  9  ",
326            "10 11 12 13 14 15 16  ",
327            "17 18 19 20 21 22 23  ",
328            "24 25 26 27 28 29 30  ",
329            "31                    ",
330        ];
331        assert_eq!(format_month(2020, 5, false, today), may);
332
333        let april_hl = vec![
334            "     April 2021       ",
335            "Su Mo Tu We Th Fr Sa  ",
336            "             1  2  3  ",
337            " 4  5  6 \u{1b}[7m 7\u{1b}[0m  8  9 10  ",
338            "11 12 13 14 15 16 17  ",
339            "18 19 20 21 22 23 24  ",
340            "25 26 27 28 29 30     ",
341            "                      ",
342        ];
343        let today = NaiveDate::from_ymd(2021, 4, 7);
344        assert_eq!(format_month(2021, 4, true, today), april_hl);
345    }
346
347    #[test]
348    fn test_last_day_in_month() {
349        assert_eq!(last_day_in_month(2020, 1), NaiveDate::from_ymd(2020, 1, 31));
350        assert_eq!(last_day_in_month(2020, 2), NaiveDate::from_ymd(2020, 2, 29));
351        assert_eq!(last_day_in_month(2020, 4), NaiveDate::from_ymd(2020, 4, 30));
352    }
353}