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
32pub 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
80pub 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
110fn parse_int<T: FromStr>(val: &str) -> MyResult<T> {
112 val.parse()
113 .map_err(|_| format!("Invalid integer \"{}\"", val).into())
114}
115
116fn 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
127fn 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
160fn last_day_in_month(year: i32, month: u32) -> NaiveDate {
162 let (y, m) = if month == 12 {
164 (year + 1, 1)
165 } else {
166 (year, month + 1)
167 };
168 NaiveDate::from_ymd(y, m, 1).pred()
170}
171
172fn 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()) .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} ", 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()); for week in days.chunks(7) {
206 lines.push(format!(
207 "{:width$} ", 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#[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 let res = parse_int::<usize>("1");
230 assert!(res.is_ok());
231 assert_eq!(res.unwrap(), 1usize);
232
233 let res = parse_int::<i32>("-1");
235 assert!(res.is_ok());
236 assert_eq!(res.unwrap(), -1i32);
237
238 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}