cal: Add tool

This supports most of the important flags and gives bit-exact output
except for spacing in the header line.
This commit is contained in:
Tuomas Tynkkynen
2025-09-12 23:25:05 +03:00
parent 02901b2db6
commit f9340c3bdc
8 changed files with 591 additions and 0 deletions

18
src/uu/cal/Cargo.toml Normal file
View File

@@ -0,0 +1,18 @@
[package]
name = "uu_cal"
version = "0.0.1"
description = "cal ~ display a calendar"
edition = "2021"
[lib]
path = "src/cal.rs"
[[bin]]
name = "cal"
path = "src/main.rs"
[dependencies]
chrono = { workspace = true }
clap = { workspace = true }
uucore = { workspace = true }

7
src/uu/cal/cal.md Normal file
View File

@@ -0,0 +1,7 @@
# cal
```
cal [options] [[[day] month] year]
```
Display a calendar

354
src/uu/cal/src/cal.rs Normal file
View File

@@ -0,0 +1,354 @@
// This file is part of the uutils util-linux package.
//
// For the full copyright and license information, please view the LICENSE
// file that was distributed with this source code.
use chrono::{Datelike, Local, NaiveDate, Weekday};
use clap::{crate_version, value_parser, Arg, ArgAction, ArgMatches, Command};
use std::io::IsTerminal;
use uucore::{error::UResult, format_usage, help_about, help_usage};
const ABOUT: &str = help_about!("cal.md");
const USAGE: &str = help_usage!("cal.md");
#[derive(Debug, PartialEq, Eq)]
enum DisplayMode {
ThreeMonths,
Year,
NMonths(u32),
}
#[derive(Debug)]
struct CalOptions {
date: NaiveDate,
highlight_date: NaiveDate,
display_mode: DisplayMode,
monday_first: bool,
julian: bool,
week_numbers: bool,
color: bool,
}
const NUM_CALENDAR_LINES: usize = 8;
const NUM_SPACES_BETWEEN_CALENDARS: usize = 3;
const MAX_CALENDARS_SIDE_BY_SIDE: usize = 3;
fn calculate_field_widths(options: &CalOptions) -> (usize, usize) {
let day_width = if options.julian { 3 } else { 2 };
let mut line_width = 7 * (day_width + 1) - 1;
if options.week_numbers {
line_width += 3;
}
(day_width, line_width)
}
#[uucore::main]
pub fn uumain(args: impl uucore::Args) -> UResult<()> {
let matches = uu_app().try_get_matches_from(args)?;
let options = parse_options(&matches)?;
let date = NaiveDate::from_ymd_opt(options.date.year(), options.date.month(), 1).unwrap();
let months: Vec<NaiveDate> = match options.display_mode {
DisplayMode::Year => {
let (_, line_width) = calculate_field_widths(&options);
let total_width = MAX_CALENDARS_SIDE_BY_SIDE * line_width
+ (MAX_CALENDARS_SIDE_BY_SIDE - 1) * NUM_SPACES_BETWEEN_CALENDARS;
println!("{:^width$}", options.date.year(), width = total_width);
println!();
(1..=12)
.map(|month| NaiveDate::from_ymd_opt(options.date.year(), month, 1).unwrap())
.collect()
}
DisplayMode::ThreeMonths => {
vec![
date - chrono::Months::new(1),
date,
date + chrono::Months::new(1),
]
}
DisplayMode::NMonths(count) => (0..count).map(|x| date + chrono::Months::new(x)).collect(),
};
print_months_side_by_side(&months, &options);
Ok(())
}
fn parse_options(matches: &ArgMatches) -> UResult<CalOptions> {
let now = Local::now();
let args: Vec<&str> = matches
.get_many::<String>("args")
.unwrap_or_default()
.map(|s| s.as_str())
.collect();
let mut year_mode = false;
let mut full_date_provided = false;
let date = match args.len() {
0 => now.date_naive(),
1 => {
// One argument - if numeric, a year, else a month
if args[0].parse::<i32>().is_ok() {
year_mode = true;
try_parse_date(args[0], "1", "1")?
} else {
try_parse_date(&now.year().to_string(), args[0], "1")?
}
}
2 => {
// month year
try_parse_date(args[1], args[0], "1")?
}
3 => {
// day month year
full_date_provided = true;
try_parse_date(args[2], args[1], args[0])?
}
_ => unreachable!(),
};
let highlight_date = if full_date_provided {
date
} else {
now.date_naive()
};
let display_mode = if year_mode || matches.get_flag("year") {
DisplayMode::Year
} else if matches.get_flag("twelve") {
DisplayMode::NMonths(12)
} else if matches.get_flag("three") {
DisplayMode::ThreeMonths
} else if let Some(count) = matches.get_one::<u32>("months").cloned() {
DisplayMode::NMonths(count.max(1))
} else {
DisplayMode::NMonths(1)
};
Ok(CalOptions {
date,
highlight_date,
display_mode,
monday_first: matches.get_flag("monday"),
julian: matches.get_flag("julian"),
week_numbers: matches.get_flag("week"),
color: match matches.get_one::<String>("color").unwrap().as_str() {
"always" => true,
"never" => false,
"auto" => std::io::stdout().is_terminal(),
_ => unreachable!(),
},
})
}
fn try_parse_date(year: &str, month: &str, day: &str) -> UResult<NaiveDate> {
let date_str = format!("{}-{}-{}", year, month, day);
let formats = [
"%Y-%m-%d", // "1992-8-25"
"%Y-%B-%d", // "1992-august-25"
];
for format in &formats {
if let Ok(date) = NaiveDate::parse_from_str(&date_str, format) {
return Ok(date);
}
}
Err(uucore::error::USimpleError::new(1, "invalid date"))
}
fn print_months_side_by_side(months: &[NaiveDate], options: &CalOptions) {
for chunk in months.chunks(MAX_CALENDARS_SIDE_BY_SIDE) {
let all_calendars: Vec<_> = chunk
.iter()
.map(|&date| generate_month_lines(date, options))
.collect();
for line_idx in 0..NUM_CALENDAR_LINES {
let output_line = all_calendars
.iter()
.map(|calendar| calendar[line_idx].as_str())
.collect::<Vec<_>>()
.join(&" ".repeat(NUM_SPACES_BETWEEN_CALENDARS));
println!("{}", output_line);
}
}
}
fn us_week_number(date: NaiveDate) -> i64 {
let jan1 = NaiveDate::from_ymd_opt(date.year(), 1, 1).unwrap();
let days_before = jan1.weekday().num_days_from_sunday();
let first_sunday = jan1 - chrono::Duration::days(days_before as i64);
(date - first_sunday).num_days() / 7 + 1
}
fn get_weekday_abbreviations(start_weekday: Weekday, length: usize) -> Vec<String> {
let mut weekday = start_weekday;
let mut ret = vec![];
for _ in 0..7 {
ret.push(weekday.to_string()[..length].to_string());
weekday = weekday.succ();
}
ret
}
fn generate_month_lines(date: NaiveDate, options: &CalOptions) -> Vec<String> {
let (day_width, line_width) = calculate_field_widths(options);
// Year mode shows year number once at the very top, not per each month
let fmt = if options.display_mode == DisplayMode::Year {
"%B"
} else {
"%B %Y"
};
let mut lines = vec![format!("{:^width$}", date.format(fmt), width = line_width)];
let week_start = if options.monday_first {
Weekday::Mon
} else {
Weekday::Sun
};
lines.push(format!(
"{}{}",
if options.week_numbers { " " } else { "" },
get_weekday_abbreviations(week_start, day_width).join(" ")
));
let mut d = NaiveDate::from_ymd_opt(date.year(), date.month(), 1).unwrap();
let mut current_line = String::new();
while d.month() == date.month() {
if options.week_numbers && current_line.is_empty() {
if options.monday_first {
current_line.push_str(&format!("{:2} ", d.iso_week().week()));
} else {
current_line.push_str(&format!("{:2} ", us_week_number(d)));
}
}
// Space pad the days that belong to the same week but in previous month
if d.day() == 1 {
let num_padding_days = (d - d.week(week_start).first_day()).num_days() as usize;
current_line.push_str(&" ".repeat(num_padding_days * (day_width + 1)));
}
let day_str = if options.julian {
format!("{:width$}", d.ordinal(), width = day_width)
} else {
format!("{:width$}", d.day(), width = day_width)
};
// Apply reverse video attribute to the highlighted day
let formatted_day = if options.color && options.highlight_date == d {
format!("\x1b[7m{}\x1b[0m", day_str)
} else {
day_str
};
current_line.push_str(&format!("{} ", formatted_day));
d += chrono::Duration::days(1);
if d.weekday() == week_start {
lines.push(current_line.trim_end().to_string());
current_line.clear();
}
}
if !current_line.is_empty() {
// Original cal pads all lines to fixed length
// (also print_months_side_by_side relies on this).
lines.push(format!(
"{:<width$}",
current_line.trim_end(),
width = line_width
));
}
while lines.len() < NUM_CALENDAR_LINES {
lines.push(" ".repeat(line_width));
}
lines
}
pub fn uu_app() -> Command {
Command::new(uucore::util_name())
.version(crate_version!())
.about(ABOUT)
.override_usage(format_usage(USAGE))
.infer_long_args(true)
.arg(
Arg::new("year")
.short('y')
.long("year")
.help("show whole year")
.action(ArgAction::SetTrue)
.conflicts_with_all(["twelve", "months"]),
)
.arg(
Arg::new("three")
.short('3')
.long("three")
.help("show previous, current and next month")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("twelve")
.short('Y')
.long("twelve")
.help("show the next twelve months")
.action(ArgAction::SetTrue)
.conflicts_with_all(["year", "months"]),
)
.arg(
Arg::new("months")
.short('n')
.long("months")
.help("show this many months")
.value_parser(value_parser!(u32))
.action(ArgAction::Set)
.conflicts_with_all(["year", "twelve"]),
)
.arg(
Arg::new("monday")
.short('m')
.long("monday")
.help("Monday as first day of week")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("julian")
.short('j')
.long("julian")
.help("use day-of-year numbering")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("week")
.short('w')
.long("week")
.help("show week numbers")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("color")
.long("color")
.help("colorize the output")
.value_name("when")
.value_parser(["always", "auto", "never"])
.default_missing_value("auto")
.default_value("auto")
.require_equals(true)
.num_args(0..=1)
.action(ArgAction::Set),
)
.arg(
Arg::new("args")
.help("date arguments")
.action(ArgAction::Append)
.num_args(0..=3),
)
}

1
src/uu/cal/src/main.rs Normal file
View File

@@ -0,0 +1 @@
uucore::bin!(uu_cal);