rusti_cal/
lib.rs

1mod locale;
2
3use ansi_term::{
4    Color::{Black, Cyan, Purple, Red, Yellow, RGB},
5    Style,
6};
7use chrono::Datelike;
8
9const REFORM_YEAR: u32 = 1099;
10
11// SPECIAL_LEAP_YEARS are years before REFORM_YEAR that are divisible by 100, but not by 400.
12const SPECIAL_LEAP_YEARS: u32 = (REFORM_YEAR / 100) - (REFORM_YEAR / 400);
13
14const MONTHS: usize = 12;
15const WEEKDAYS: u32 = 7;
16
17const COLUMN: usize = 3;
18const ROWS: usize = 4;
19const ROW_SIZE: usize = 7;
20
21static TOKEN: &str = "\n";
22
23fn is_leap_year(year: u32) -> bool {
24    if year <= REFORM_YEAR {
25        return year % 4 == 0;
26    }
27    (year % 4 == 0) ^ (year % 100 == 0) ^ (year % 400 == 0)
28}
29
30fn count_leap_years(year: u32) -> u32 {
31    if year <= REFORM_YEAR {
32        (year - 1) / 4
33    } else {
34        ((year - 1) / 4) - ((year - 1) / 100) + ((year - 1) / 400) + SPECIAL_LEAP_YEARS
35    }
36}
37
38fn days_by_year(year: u32) -> u32 {
39    if year < 1 {
40        0
41    } else {
42        (year - 1) * 365 + count_leap_years(year)
43    }
44}
45
46fn days_by_month(year: u32) -> Vec<u32> {
47    let feb_day: u32 = if is_leap_year(year) { 29 } else { 28 };
48    vec![0, 31, feb_day, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
49}
50
51fn days_by_date(
52    day: u32,
53    month: usize,
54    year: u32,
55    months_memoized: Vec<u32>,
56    year_memoized: u32,
57) -> u32 {
58    day + (if month > 1 {
59        months_memoized[month - 1]
60    } else {
61        0
62    }) + (if year > 1 { year_memoized } else { 0 })
63}
64
65fn get_days_accumulated_by_month(year: u32) -> (Vec<u32>, Vec<u32>) {
66    let days: Vec<u32> = days_by_month(year);
67    let accum = days
68        .iter()
69        .scan(0, |acc, &x| {
70            *acc = *acc + x;
71            Some(*acc)
72        })
73        .collect();
74    (accum, days)
75}
76
77fn first_day_printable(day_year: u32, starting_day: u32) -> String {
78    let mut printable = format!("");
79
80    if (day_year - starting_day) % WEEKDAYS == 0 {
81        printable.push_str("                  ");
82    }
83    for i in 2..WEEKDAYS {
84        if (day_year - starting_day) % WEEKDAYS == i {
85            printable.push_str(&"   ".repeat(i as usize - 1));
86            break;
87        }
88    }
89    printable
90}
91
92fn remain_day_printable(day: u32, day_year: u32, starting_day: u32) -> String {
93    let base = if ((day_year - starting_day) % WEEKDAYS) == 0 {
94        format!("{:3}{}", day, TOKEN)
95    } else {
96        String::default()
97    };
98
99    let complement = (1..WEEKDAYS)
100        .find_map(|i| ((day_year - starting_day) % WEEKDAYS == i).then(|| format!("{:3}", day)))
101        .unwrap_or_default();
102
103    format!("{}{}", base, complement)
104}
105
106fn body_printable(
107    year: u32,
108    month: usize,
109    days: u32,
110    months_memoized: Vec<u32>,
111    year_memoized: u32,
112    starting_day: u32,
113    week_numbers: bool,
114) -> Vec<String> {
115    let mut result = Vec::<String>::new();
116    let mut result_days = format!("");
117
118    // display month formatted
119    (1..days + 1).for_each(|day| {
120        if day == 1 {
121            let first_day = days_by_date(1, month, year, months_memoized.clone(), year_memoized);
122            result_days.push_str(&first_day_printable(first_day, starting_day))
123        }
124        let day_year = days_by_date(day, month, year, months_memoized.clone(), year_memoized);
125        result_days.push_str(&remain_day_printable(day, day_year, starting_day))
126    });
127
128    // lines splitted by '\n' TOKEN
129    result_days
130        .split(TOKEN)
131        .collect::<Vec<&str>>()
132        .into_iter()
133        .for_each(|i| result.push(i.to_string()));
134
135    for line in 0..result.len() {
136        let spaces =
137            21 - result[line].len() + (3 * (result[line].is_empty() && week_numbers) as usize);
138        result[line] += &" ".repeat(spaces);
139    }
140    // all bodies should have at least 7 lines
141    if result.len() < 7 {
142        result.push(" ".repeat(21 + (3 * week_numbers as usize)));
143    }
144    result
145}
146
147fn month_printable(
148    year: u32,
149    month: usize,
150    days: u32,
151    months_memoized: Vec<u32>,
152    year_memoized: u32,
153    starting_day: u32,
154    month_names: Vec<String>,
155    week_names: Vec<String>,
156    week_numbers: bool,
157) -> Vec<String> {
158    let mut result = Vec::<String>::new();
159    let body = body_printable(
160        year,
161        month,
162        days,
163        months_memoized,
164        year_memoized,
165        starting_day,
166        week_numbers,
167    );
168    let month_name = &month_names[month - 1];
169    result.push(format!(" {:^20}", month_name));
170    let header = circular_week_name(week_names, starting_day as usize);
171    result.push(header);
172
173    body.into_iter().for_each(|item| {
174        result.push(item);
175    });
176    result
177}
178
179fn circular_week_name(week_name: Vec<String>, idx: usize) -> String {
180    let mut s = " ".to_string();
181    for i in idx..(ROW_SIZE - 1 + idx) {
182        s.push_str(&format!("{} ", week_name[i % ROW_SIZE]));
183    }
184    s.push_str(week_name[(ROW_SIZE - 1 + idx) % ROW_SIZE].as_str());
185    s.to_string()
186}
187
188pub fn calendar(
189    year: u32,
190    locale_str: &str,
191    starting_day: u32,
192    week_numbers: bool,
193) -> Vec<Vec<Vec<String>>> {
194    let mut rows: Vec<Vec<Vec<String>>> = vec![vec![vec![String::from("")]; COLUMN]; ROWS];
195    let mut row_counter = 0;
196    let mut column_counter = 0;
197    let mut week_counter = 1;
198    let (months_memoized, months) = get_days_accumulated_by_month(year);
199    let year_memoized = days_by_year(year);
200    let locale_info = locale::LocaleInfo::new(locale_str);
201
202    (1..MONTHS + 1).for_each(|month| {
203        rows[row_counter][column_counter] = month_printable(
204            year,
205            month,
206            months[month],
207            months_memoized.clone(),
208            year_memoized,
209            starting_day,
210            locale_info.month_names(),
211            locale_info.week_day_names(),
212            week_numbers,
213        );
214
215        if week_numbers {
216            for line in 0..rows[row_counter][column_counter].len() {
217                if line < 2 {
218                    rows[row_counter][column_counter][line] = format!(
219                        "{}{}",
220                        "   ".to_string(),
221                        &rows[row_counter][column_counter][line]
222                    )
223                } else if !&rows[row_counter][column_counter][line].trim().is_empty() {
224                    rows[row_counter][column_counter][line] = format!(
225                        "{}{}{}",
226                        &" ".repeat(1 + (week_counter < 10) as usize),
227                        &week_counter.to_string(),
228                        &rows[row_counter][column_counter][line]
229                    );
230
231                    if rows[row_counter][column_counter][line]
232                        .chars()
233                        .last()
234                        .unwrap()
235                        != ' '
236                    {
237                        week_counter += 1;
238                    }
239                }
240            }
241        }
242
243        column_counter = month % COLUMN;
244        if column_counter == 0 {
245            row_counter += 1;
246        }
247    });
248    rows
249}
250
251fn print_row(
252    row: &str,
253    starting_day: u32,
254    today_included: bool,
255    pos_today: u32,
256    monochromatic: bool,
257    week_numbers: bool,
258) {
259    let pos_saturday = (((6 - starting_day as i32) % 7) + 7) % 7 + (week_numbers as i32);
260    let pos_sunday = (((7 - starting_day as i32) % 7) + 7) % 7 + (week_numbers as i32);
261
262    let char_saturday = (1 + 3 * pos_saturday) as usize;
263    let char_sunday = (1 + 3 * pos_sunday) as usize;
264    let char_today = (1 + 3 * (pos_today + week_numbers as u32)) as usize;
265
266    let row = row
267        .split("")
268        .filter(|s| !s.is_empty())
269        .enumerate()
270        .map(|(i, s)| {
271            if monochromatic {
272                if today_included && (i == char_today || i == char_today + 1) {
273                    Black.on(RGB(200, 200, 200)).paint(s)
274                } else {
275                    ansi_term::Style::default().paint(s)
276                }
277            } else {
278                if today_included && (i == char_today || i == char_today + 1) {
279                    Black.on(RGB(200, 200, 200)).paint(s)
280                } else if i == char_saturday || i == char_saturday + 1 {
281                    Yellow.bold().paint(s)
282                } else if i == char_sunday || i == char_sunday + 1 {
283                    Red.bold().paint(s)
284                } else if week_numbers && i < 3 {
285                    Purple.bold().paint(s)
286                } else {
287                    ansi_term::Style::default().paint(s)
288                }
289            }
290        })
291        .collect::<Vec<ansi_term::ANSIString>>();
292
293    print!("{} ", ansi_term::ANSIStrings(&row));
294}
295
296/// calculates the positions of the given day within the overall grid
297///
298/// Returns a tuple
299///
300/// (month row, month column, day x position, line of month)
301fn get_today_position(year: u32, month: u32, day: u32, starting_day: u32) -> (u32, u32, u32, u32) {
302    let (months_memoized, _) = get_days_accumulated_by_month(year);
303
304    let first_of_month = days_by_date(1, month as usize, year, months_memoized, days_by_year(year));
305    let first_offset = (first_of_month - starting_day - 1) % WEEKDAYS;
306
307    let row_index = (month - 1) / 3;
308    let col_index = (month - 1) % 3;
309
310    let absolute_pos = first_offset + day - 1;
311    let x = absolute_pos % 7;
312    let y = absolute_pos / 7;
313
314    (row_index, col_index, x, y)
315}
316
317pub fn display(
318    year: u32,
319    locale_str: &str,
320    starting_day: u32,
321    monochromatic: bool,
322    week_numbers: bool,
323) {
324    let rows = calendar(year, locale_str, starting_day, week_numbers);
325
326    let today = {
327        let now = chrono::Local::now();
328        (now.year() as u32, now.month(), now.day())
329    };
330
331    let t_pos = if today.0 == year {
332        Some(get_today_position(today.0, today.1, today.2, starting_day))
333    } else {
334        None
335    };
336
337    // print the year
338    println!(
339        "{}{}",
340        " ".repeat(6 * week_numbers as usize),
341        Style::new().bold().paint(format!(" {:^63}", year))
342    );
343
344    for (r, row) in rows.iter().enumerate() {
345        for line in 0..8 {
346            for col in 0..3 {
347                if line == 0 {
348                    if monochromatic {
349                        print!("{} ", &row[col][line]);
350                    } else {
351                        print!("{} ", Cyan.bold().paint(&row[col][line]));
352                    }
353                } else {
354                    // check if today is part of this line
355                    let (today_included, x) = {
356                        if let Some(p) = t_pos {
357                            // check for the correct row, col and line
358                            if p.0 == r as u32 && p.1 == col as u32 && p.3 + 2 == line as u32 {
359                                (true, p.2)
360                            } else {
361                                (false, 0)
362                            }
363                        } else {
364                            (false, 0)
365                        }
366                    };
367                    // print the colored line
368                    print_row(
369                        &row[col][line],
370                        starting_day,
371                        today_included,
372                        x,
373                        monochromatic,
374                        week_numbers,
375                    );
376                }
377            }
378            println!();
379        }
380    }
381}
382
383#[test]
384fn test_circular_week_name() {
385    let locale_str = "en_US";
386    let locale_info = locale::LocaleInfo::new(locale_str);
387    let week_name = locale_info.week_day_names();
388    assert_eq!(
389        circular_week_name(week_name.clone(), 0),
390        " Su Mo Tu We Th Fr Sa"
391    );
392    assert_eq!(
393        circular_week_name(week_name.clone(), 1),
394        " Mo Tu We Th Fr Sa Su"
395    );
396    assert_eq!(
397        circular_week_name(week_name.clone(), 2),
398        " Tu We Th Fr Sa Su Mo"
399    );
400    assert_eq!(
401        circular_week_name(week_name.clone(), 3),
402        " We Th Fr Sa Su Mo Tu"
403    );
404    assert_eq!(
405        circular_week_name(week_name.clone(), 4),
406        " Th Fr Sa Su Mo Tu We"
407    );
408    assert_eq!(
409        circular_week_name(week_name.clone(), 5),
410        " Fr Sa Su Mo Tu We Th"
411    );
412    assert_eq!(
413        circular_week_name(week_name.clone(), 6),
414        " Sa Su Mo Tu We Th Fr"
415    );
416}
417
418#[test]
419fn test_circular_week_name_pt_br() {
420    let locale_str = "pt_BR";
421    let locale_info = locale::LocaleInfo::new(locale_str);
422    let week_name = locale_info.week_day_names();
423    assert_eq!(circular_week_name(week_name, 0), " Do Se Te Qu Qu Se Sá");
424}
425
426#[test]
427fn test_is_leap_year() {
428    let test_cases = [
429        (100, true),
430        (400, true),
431        (1000, true),
432        (1100, false),
433        (2022, false),
434        (2023, false),
435        (2024, true),
436        (2025, false),
437        (2300, false),
438    ];
439    for test_case in test_cases.iter() {
440        assert_eq!(
441            is_leap_year(test_case.0),
442            test_case.1,
443            "{} is {} a leap year",
444            test_case.0,
445            if test_case.1 { "" } else { "not" }
446        );
447    }
448}
449
450#[test]
451fn test_count_leap_years() {
452    let test_cases = [(400, 99), (401, 100), (1100, 274), (1200, 298), (2022, 498)];
453    for test_case in test_cases.iter() {
454        assert_eq!(
455            count_leap_years(test_case.0),
456            test_case.1,
457            "Year {}",
458            test_case.0
459        );
460    }
461}
462
463#[test]
464fn test_days_by_year() {
465    let test_cases = [
466        (0, 0),
467        (1, 0),
468        (2, 365),
469        (3, 730),
470        (4, 1095),
471        (5, 1461),
472        (6, 1826),
473        (7, 2191),
474        (8, 2556),
475        (9, 2922),
476        (10, 3287),
477        (400, 145734),
478        (401, 146100),
479        (402, 146465),
480        (403, 146830),
481        (404, 147195),
482        (800, 291834),
483        (801, 292200),
484        (802, 292565),
485        (803, 292930),
486        (804, 293295),
487        (2022, 738163),
488        (2023, 738528),
489        (2024, 738893),
490        (2025, 739259),
491    ];
492    for test_case in test_cases.iter() {
493        assert_eq!(
494            days_by_year(test_case.0),
495            test_case.1,
496            "Year {}",
497            test_case.0
498        );
499    }
500}
501
502#[test]
503fn test_days_by_month() {
504    let not_leap = vec![0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
505    let leap = vec![0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
506    let test_cases = [(2022, not_leap), (2024, leap)];
507    for test_case in test_cases.iter() {
508        assert_eq!(
509            days_by_month(test_case.0),
510            test_case.1,
511            "Year {}",
512            test_case.0
513        );
514    }
515}
516
517#[test]
518fn test_days_by_date() {
519    assert_eq!(
520        days_by_date(
521            0,
522            0,
523            0,
524            vec![0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31],
525            1
526        ),
527        0
528    );
529    assert_eq!(
530        days_by_date(
531            7,
532            4,
533            0,
534            vec![0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31],
535            1
536        ),
537        38
538    );
539}
540
541#[test]
542fn test_get_days_accumulated_by_month() {
543    assert_eq!(
544        get_days_accumulated_by_month(2000),
545        (
546            vec![0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366],
547            vec![0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
548        )
549    );
550    assert_eq!(
551        get_days_accumulated_by_month(1600),
552        (
553            vec![0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366],
554            vec![0, 31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
555        )
556    );
557    assert_eq!(
558        get_days_accumulated_by_month(1700),
559        (
560            vec![0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365],
561            vec![0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
562        )
563    );
564}
565
566#[test]
567fn test_remain_day_printable() {
568    assert_eq!(remain_day_printable(1, 1, 1), "  1\n");
569    assert_eq!(remain_day_printable(1, 2, 1), "  1");
570    assert_eq!(remain_day_printable(2, 2, 1), "  2");
571    assert_eq!(remain_day_printable(31, 31, 1), " 31");
572    assert_eq!(remain_day_printable(31, 31, 7), " 31");
573}