Skip to main content

linuxutils_misc/
cal.rs

1use linuxutils_common::man::ManContent;
2
3pub const MAN: ManContent = ManContent::empty();
4
5use clap::Parser;
6use std::{
7    io::{self, Write},
8    process::ExitCode,
9};
10
11const MONTH_NAMES: [&str; 12] = [
12    "January",
13    "February",
14    "March",
15    "April",
16    "May",
17    "June",
18    "July",
19    "August",
20    "September",
21    "October",
22    "November",
23    "December",
24];
25
26const MONTH_ABBREVS: [&str; 12] = [
27    "jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct",
28    "nov", "dec",
29];
30
31const DAYS_HEADER_SUN: &str = "Su Mo Tu We Th Fr Sa";
32const DAYS_HEADER_MON: &str = "Mo Tu We Th Fr Sa Su";
33const MONTH_WIDTH: usize = 20;
34const MONTH_GAP: &str = "  ";
35
36#[derive(Parser)]
37#[command(name = "cal", about = "Display a calendar")]
38pub struct Args {
39    /// Display single month output (default)
40    #[arg(short = '1', long)]
41    one: bool,
42
43    /// Display three months spanning the date
44    #[arg(short = '3', long)]
45    three: bool,
46
47    /// Display number of months starting from the date
48    #[arg(short = 'n', long)]
49    months: Option<u32>,
50
51    /// Display Sunday as the first day of the week
52    #[arg(short, long)]
53    sunday: bool,
54
55    /// Display Monday as the first day of the week
56    #[arg(short, long)]
57    monday: bool,
58
59    /// Display a calendar for the whole year
60    #[arg(short = 'y', long)]
61    year: bool,
62
63    /// Display a calendar for the next twelve months
64    #[arg(short = 'Y', long)]
65    twelve: bool,
66
67    /// Use day-of-year (ordinal) numbering
68    #[arg(short, long)]
69    julian: bool,
70
71    /// Number of columns to use
72    #[arg(short = 'c', long, default_value = "3")]
73    columns: u32,
74
75    /// Positional arguments: [[[day] month] year]
76    #[arg(trailing_var_arg = true)]
77    args: Vec<String>,
78}
79
80/// Center a string in a field of `width` chars, with extra padding on the
81/// left (matching the C cal behavior).
82fn center(s: &str, width: usize) -> String {
83    let len = s.len();
84    if len >= width {
85        return s.to_string();
86    }
87    let total_pad = width - len;
88    let left = total_pad.div_ceil(2);
89    let right = total_pad / 2;
90    format!("{:left$}{s}{:right$}", "", "")
91}
92
93fn today() -> (u32, u32, u32) {
94    let now = chrono::Local::now().date_naive();
95    use chrono::Datelike;
96    (now.year() as u32, now.month(), now.day())
97}
98
99fn is_leap_year(year: u32) -> bool {
100    (year.is_multiple_of(4) && !year.is_multiple_of(100))
101        || year.is_multiple_of(400)
102}
103
104fn days_in_month(year: u32, month: u32) -> u32 {
105    match month {
106        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
107        4 | 6 | 9 | 11 => 30,
108        2 => {
109            if is_leap_year(year) {
110                29
111            } else {
112                28
113            }
114        }
115        _ => 0,
116    }
117}
118
119/// Day of week for a given date (0=Sunday, 6=Saturday). Uses Zeller's formula
120/// for the Gregorian calendar.
121fn day_of_week(year: u32, month: u32, day: u32) -> u32 {
122    let (y, m) = if month <= 2 {
123        (year as i32 - 1, month as i32 + 12)
124    } else {
125        (year as i32, month as i32)
126    };
127    let q = day as i32;
128    let k = y % 100;
129    let j = y / 100;
130    let h = (q + (13 * (m + 1)) / 5 + k + k / 4 + j / 4 + 5 * j) % 7;
131    // h: 0=Saturday, 1=Sunday, ..., 6=Friday → convert to 0=Sunday
132    ((h + 6) % 7) as u32
133}
134
135/// Day of year (1-based).
136fn day_of_year(year: u32, month: u32, day: u32) -> u32 {
137    let mut doy = 0;
138    for m in 1..month {
139        doy += days_in_month(year, m);
140    }
141    doy + day
142}
143
144/// Render a single month as lines of exactly MONTH_WIDTH characters.
145/// If `show_year` is true, the title includes the year.
146fn render_month(
147    year: u32,
148    month: u32,
149    monday_first: bool,
150    julian: bool,
151    highlight_day: Option<u32>,
152    show_year: bool,
153) -> Vec<String> {
154    let mut lines = Vec::new();
155
156    // Title line: centered month name (+ year if requested).
157    let title = if show_year {
158        format!("{} {}", MONTH_NAMES[(month - 1) as usize], year)
159    } else {
160        MONTH_NAMES[(month - 1) as usize].to_string()
161    };
162    lines.push(center(&title, MONTH_WIDTH));
163
164    // Day-of-week header.
165    if monday_first {
166        lines.push(DAYS_HEADER_MON.to_string());
167    } else {
168        lines.push(DAYS_HEADER_SUN.to_string());
169    }
170
171    let ndays = days_in_month(year, month);
172    let first_dow = day_of_week(year, month, 1);
173    // Adjust for Monday-first: Monday=0 .. Sunday=6
174    let offset = if monday_first {
175        (first_dow + 6) % 7
176    } else {
177        first_dow
178    };
179
180    let mut line = String::new();
181    // Leading blanks.
182    for _ in 0..offset {
183        line.push_str("   ");
184    }
185
186    let _ = highlight_day; // TODO: terminal highlighting
187
188    for day in 1..=ndays {
189        if julian {
190            let doy = day_of_year(year, month, day);
191            line.push_str(&format!("{doy:>3}"));
192        } else {
193            line.push_str(&format!("{day:>2}"));
194        }
195
196        let col = (offset + day - 1) % 7;
197        if col == 6 || day == ndays {
198            // Pad to MONTH_WIDTH.
199            while line.len() < MONTH_WIDTH {
200                line.push(' ');
201            }
202            lines.push(line);
203            line = String::new();
204        } else {
205            line.push(' ');
206        }
207    }
208
209    // Ensure we always have 8 lines (title + header + 6 week rows) for
210    // consistent multi-month layout.
211    while lines.len() < 8 {
212        lines.push(" ".repeat(MONTH_WIDTH));
213    }
214
215    lines
216}
217
218fn parse_month_name(s: &str) -> Option<u32> {
219    let lower = s.to_lowercase();
220    for (i, abbrev) in MONTH_ABBREVS.iter().enumerate() {
221        if lower.starts_with(abbrev) {
222            return Some((i + 1) as u32);
223        }
224    }
225    None
226}
227
228/// Print multiple months side by side, `cols` columns wide.
229#[allow(clippy::too_many_arguments)]
230fn print_months(
231    months: &[(u32, u32)], // (year, month) pairs
232    cols: u32,
233    monday_first: bool,
234    julian: bool,
235    highlight: Option<(u32, u32, u32)>, // (year, month, day) to highlight
236    show_year: bool,
237    gap: &str,
238    out: &mut dyn Write,
239) -> io::Result<()> {
240    let rendered: Vec<Vec<String>> = months
241        .iter()
242        .map(|&(y, m)| {
243            let hl = highlight.and_then(|(hy, hm, hd)| {
244                if hy == y && hm == m { Some(hd) } else { None }
245            });
246            render_month(y, m, monday_first, julian, hl, show_year)
247        })
248        .collect();
249
250    for chunk in rendered.chunks(cols as usize) {
251        let max_lines = chunk.iter().map(|m| m.len()).max().unwrap_or(0);
252        for row in 0..max_lines {
253            for (ci, month) in chunk.iter().enumerate() {
254                if ci > 0 {
255                    write!(out, "{gap}")?;
256                }
257                if row < month.len() {
258                    write!(out, "{}", month[row])?;
259                } else {
260                    write!(out, "{:MONTH_WIDTH$}", "")?;
261                }
262            }
263            writeln!(out)?;
264        }
265    }
266    Ok(())
267}
268
269pub fn run(args: Args) -> ExitCode {
270    let (cur_year, cur_month, cur_day) = today();
271
272    let monday_first = args.monday && !args.sunday;
273
274    // Parse positional arguments.
275    let (target_year, target_month, target_day) = match args.args.len() {
276        0 => (cur_year, Some(cur_month), Some(cur_day)),
277        1 => {
278            let arg = &args.args[0];
279            if let Ok(n) = arg.parse::<u32>() {
280                if (1..=12).contains(&n) {
281                    // Ambiguous: could be month or year. `cal` treats a
282                    // single small number as a year (cal 12 shows year 12).
283                    // But month names are also accepted.
284                    (n, None, None) // treat as year
285                } else {
286                    (n, None, None)
287                }
288            } else if let Some(m) = parse_month_name(arg) {
289                (cur_year, Some(m), None)
290            } else if arg == "today" || arg == "now" {
291                (cur_year, Some(cur_month), Some(cur_day))
292            } else if arg == "tomorrow" {
293                // Simple: just advance one day.
294                let mut d = cur_day + 1;
295                let mut m = cur_month;
296                let mut y = cur_year;
297                if d > days_in_month(y, m) {
298                    d = 1;
299                    m += 1;
300                    if m > 12 {
301                        m = 1;
302                        y += 1;
303                    }
304                }
305                (y, Some(m), Some(d))
306            } else if arg == "yesterday" {
307                let mut d = cur_day as i32 - 1;
308                let mut m = cur_month;
309                let mut y = cur_year;
310                if d < 1 {
311                    m -= 1;
312                    if m < 1 {
313                        m = 12;
314                        y -= 1;
315                    }
316                    d = days_in_month(y, m) as i32;
317                }
318                (y, Some(m), Some(d as u32))
319            } else {
320                eprintln!("cal: invalid argument: {arg}");
321                return ExitCode::FAILURE;
322            }
323        }
324        2 => {
325            let month = match args.args[0].parse::<u32>() {
326                Ok(m) if (1..=12).contains(&m) => m,
327                _ => match parse_month_name(&args.args[0]) {
328                    Some(m) => m,
329                    None => {
330                        eprintln!("cal: invalid month: {}", args.args[0]);
331                        return ExitCode::FAILURE;
332                    }
333                },
334            };
335            let year = match args.args[1].parse::<u32>() {
336                Ok(y) => y,
337                Err(_) => {
338                    eprintln!("cal: invalid year: {}", args.args[1]);
339                    return ExitCode::FAILURE;
340                }
341            };
342            (year, Some(month), None)
343        }
344        3 => {
345            let day = match args.args[0].parse::<u32>() {
346                Ok(d) => d,
347                Err(_) => {
348                    eprintln!("cal: invalid day: {}", args.args[0]);
349                    return ExitCode::FAILURE;
350                }
351            };
352            let month = match args.args[1].parse::<u32>() {
353                Ok(m) if (1..=12).contains(&m) => m,
354                _ => match parse_month_name(&args.args[1]) {
355                    Some(m) => m,
356                    None => {
357                        eprintln!("cal: invalid month: {}", args.args[1]);
358                        return ExitCode::FAILURE;
359                    }
360                },
361            };
362            let year = match args.args[2].parse::<u32>() {
363                Ok(y) => y,
364                Err(_) => {
365                    eprintln!("cal: invalid year: {}", args.args[2]);
366                    return ExitCode::FAILURE;
367                }
368            };
369            (year, Some(month), Some(day))
370        }
371        _ => {
372            eprintln!("cal: too many arguments");
373            return ExitCode::FAILURE;
374        }
375    };
376
377    let stdout = io::stdout();
378    let mut out = stdout.lock();
379
380    let highlight =
381        target_day.map(|d| (target_year, target_month.unwrap_or(cur_month), d));
382
383    let cols = args.columns;
384
385    if args.year || target_month.is_none() {
386        // Year view.
387        let year_title = format!("{target_year}");
388        let year_gap = "   ";
389        let total_width =
390            cols as usize * MONTH_WIDTH + (cols as usize - 1) * year_gap.len();
391        if let Err(e) = writeln!(out, "{}", center(&year_title, total_width)) {
392            eprintln!("cal: {e}");
393            return ExitCode::FAILURE;
394        }
395        if let Err(e) = writeln!(out) {
396            eprintln!("cal: {e}");
397            return ExitCode::FAILURE;
398        }
399
400        let months: Vec<(u32, u32)> =
401            (1..=12).map(|m| (target_year, m)).collect();
402        if let Err(e) = print_months(
403            &months,
404            cols,
405            monday_first,
406            args.julian,
407            highlight,
408            false,
409            year_gap,
410            &mut out,
411        ) {
412            eprintln!("cal: {e}");
413            return ExitCode::FAILURE;
414        }
415    } else if args.twelve {
416        // Next 12 months.
417        let mut months = Vec::new();
418        let mut y = target_year;
419        let mut m = target_month.unwrap_or(cur_month);
420        for _ in 0..12 {
421            months.push((y, m));
422            m += 1;
423            if m > 12 {
424                m = 1;
425                y += 1;
426            }
427        }
428        if let Err(e) = print_months(
429            &months,
430            cols,
431            monday_first,
432            args.julian,
433            highlight,
434            true,
435            "  ",
436            &mut out,
437        ) {
438            eprintln!("cal: {e}");
439            return ExitCode::FAILURE;
440        }
441    } else if args.three {
442        // Three months centered on the target.
443        let tm = target_month.unwrap_or(cur_month);
444        let mut months = Vec::new();
445        for offset in [-1i32, 0, 1] {
446            let mut m = tm as i32 + offset;
447            let mut y = target_year as i32;
448            if m < 1 {
449                m += 12;
450                y -= 1;
451            } else if m > 12 {
452                m -= 12;
453                y += 1;
454            }
455            months.push((y as u32, m as u32));
456        }
457        if let Err(e) = print_months(
458            &months,
459            3,
460            monday_first,
461            args.julian,
462            highlight,
463            true,
464            MONTH_GAP,
465            &mut out,
466        ) {
467            eprintln!("cal: {e}");
468            return ExitCode::FAILURE;
469        }
470    } else if let Some(n) = args.months {
471        // N months starting from the target.
472        let tm = target_month.unwrap_or(cur_month);
473        let mut months = Vec::new();
474        let mut y = target_year;
475        let mut m = tm;
476        for _ in 0..n {
477            months.push((y, m));
478            m += 1;
479            if m > 12 {
480                m = 1;
481                y += 1;
482            }
483        }
484        if let Err(e) = print_months(
485            &months,
486            cols,
487            monday_first,
488            args.julian,
489            highlight,
490            true,
491            "  ",
492            &mut out,
493        ) {
494            eprintln!("cal: {e}");
495            return ExitCode::FAILURE;
496        }
497    } else {
498        // Single month.
499        let tm = target_month.unwrap_or(cur_month);
500        let rendered = render_month(
501            target_year,
502            tm,
503            monday_first,
504            args.julian,
505            target_day,
506            true,
507        );
508        for line in &rendered {
509            if let Err(e) = writeln!(out, "{line}") {
510                eprintln!("cal: {e}");
511                return ExitCode::FAILURE;
512            }
513        }
514    }
515
516    ExitCode::SUCCESS
517}