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:
18
src/uu/cal/Cargo.toml
Normal file
18
src/uu/cal/Cargo.toml
Normal 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
7
src/uu/cal/cal.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# cal
|
||||
|
||||
```
|
||||
cal [options] [[[day] month] year]
|
||||
```
|
||||
|
||||
Display a calendar
|
||||
354
src/uu/cal/src/cal.rs
Normal file
354
src/uu/cal/src/cal.rs
Normal 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
1
src/uu/cal/src/main.rs
Normal file
@@ -0,0 +1 @@
|
||||
uucore::bin!(uu_cal);
|
||||
Reference in New Issue
Block a user