diff --git a/Cargo.lock b/Cargo.lock index d36fa15..e916801 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1316,6 +1316,7 @@ dependencies = [ "tempfile", "textwrap", "uu_blockdev", + "uu_cal", "uu_chcpu", "uu_ctrlaltdel", "uu_dmesg", @@ -1351,6 +1352,15 @@ dependencies = [ "uucore", ] +[[package]] +name = "uu_cal" +version = "0.0.1" +dependencies = [ + "chrono", + "clap", + "uucore", +] + [[package]] name = "uu_chcpu" version = "0.0.1" diff --git a/Cargo.toml b/Cargo.toml index e195a02..b419122 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ uudoc = [] feat_common_core = [ "blockdev", + "cal", "chcpu", "ctrlaltdel", "dmesg", @@ -48,6 +49,7 @@ feat_common_core = [ ] [workspace.dependencies] +chrono = "0.4" clap = { version = "4.4", features = ["wrap_help", "cargo"] } clap_complete = "4.4" clap_mangen = "0.2" @@ -92,6 +94,7 @@ uucore = { workspace = true } # blockdev = { optional = true, version = "0.0.1", package = "uu_blockdev", path = "src/uu/blockdev" } +cal = { optional = true, version = "0.0.1", package = "uu_cal", path = "src/uu/cal" } chcpu = { optional = true, version = "0.0.1", package = "uu_chcpu", path = "src/uu/chcpu" } ctrlaltdel = { optional = true, version = "0.0.1", package = "uu_ctrlaltdel", path = "src/uu/ctrlaltdel" } dmesg = { optional = true, version = "0.0.1", package = "uu_dmesg", path = "src/uu/dmesg" } diff --git a/src/uu/cal/Cargo.toml b/src/uu/cal/Cargo.toml new file mode 100644 index 0000000..ddcfefc --- /dev/null +++ b/src/uu/cal/Cargo.toml @@ -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 } diff --git a/src/uu/cal/cal.md b/src/uu/cal/cal.md new file mode 100644 index 0000000..f03ff41 --- /dev/null +++ b/src/uu/cal/cal.md @@ -0,0 +1,7 @@ +# cal + +``` +cal [options] [[[day] month] year] +``` + +Display a calendar diff --git a/src/uu/cal/src/cal.rs b/src/uu/cal/src/cal.rs new file mode 100644 index 0000000..40895fc --- /dev/null +++ b/src/uu/cal/src/cal.rs @@ -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 = 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 { + let now = Local::now(); + + let args: Vec<&str> = matches + .get_many::("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::().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::("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::("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 { + 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::>() + .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 { + 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 { + 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!( + "{: 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), + ) +} diff --git a/src/uu/cal/src/main.rs b/src/uu/cal/src/main.rs new file mode 100644 index 0000000..69a1122 --- /dev/null +++ b/src/uu/cal/src/main.rs @@ -0,0 +1 @@ +uucore::bin!(uu_cal); diff --git a/tests/by-util/test_cal.rs b/tests/by-util/test_cal.rs new file mode 100644 index 0000000..76ecd4d --- /dev/null +++ b/tests/by-util/test_cal.rs @@ -0,0 +1,194 @@ +// 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 regex::Regex; +use uutests::new_ucmd; + +#[test] +fn test_invalid_arg() { + new_ucmd!().arg("--definitely-invalid").fails().code_is(1); +} + +#[test] +fn test_invalid_dates() { + new_ucmd!().args(&["31", "2", "2000"]).fails().code_is(1); + new_ucmd!().args(&["13", "2000"]).fails().code_is(1); +} + +#[test] +fn test_iso_week_numbers() { + let expected = vec![ + " January 2021 \n", + " Mo Tu We Th Fr Sa Su\n", + "53 1 2 3\n", + " 1 4 5 6 7 8 9 10\n", + " 2 11 12 13 14 15 16 17\n", + " 3 18 19 20 21 22 23 24\n", + " 4 25 26 27 28 29 30 31\n", + " \n", + ]; + new_ucmd!() + .args(&["-w", "-m", "1", "2021"]) + .succeeds() + .stdout_is(expected.join("")); + + let expected = vec![ + " January 2015 \n", + " Mo Tu We Th Fr Sa Su\n", + " 1 1 2 3 4\n", + " 2 5 6 7 8 9 10 11\n", + " 3 12 13 14 15 16 17 18\n", + " 4 19 20 21 22 23 24 25\n", + " 5 26 27 28 29 30 31 \n", + " \n", + ]; + new_ucmd!() + .args(&["-w", "-m", "1", "2015"]) + .succeeds() + .stdout_is(expected.join("")); +} + +#[test] +fn test_us_week_numbers() { + let expected = vec![ + " January 2021 \n", + " Su Mo Tu We Th Fr Sa\n", + " 1 1 2\n", + " 2 3 4 5 6 7 8 9\n", + " 3 10 11 12 13 14 15 16\n", + " 4 17 18 19 20 21 22 23\n", + " 5 24 25 26 27 28 29 30\n", + " 6 31 \n", + ]; + new_ucmd!() + .args(&["-w", "1", "2021"]) + .succeeds() + .stdout_is(expected.join("")); +} + +#[test] +fn test_julian() { + let expected = vec![ + " December 2000 \n", + "Sun Mon Tue Wed Thu Fri Sat\n", + " 336 337\n", + "338 339 340 341 342 343 344\n", + "345 346 347 348 349 350 351\n", + "352 353 354 355 356 357 358\n", + "359 360 361 362 363 364 365\n", + "366 \n", + ]; + new_ucmd!() + .args(&["-j", "12", "2000"]) + .succeeds() + .stdout_is(expected.join("")); + + let expected = vec![ + " February 2024 \n", + " Mon Tue Wed Thu Fri Sat Sun\n", + " 5 32 33 34 35\n", + " 6 36 37 38 39 40 41 42\n", + " 7 43 44 45 46 47 48 49\n", + " 8 50 51 52 53 54 55 56\n", + " 9 57 58 59 60 \n", + " \n", + ]; + new_ucmd!() + .args(&["-j", "-w", "-m", "2", "2024"]) + .succeeds() + .stdout_is(expected.join("")); +} + +#[test] +fn test_single_month_param() { + new_ucmd!() + .args(&["aug"]) + .succeeds() + .stdout_contains("August"); +} + +#[test] +fn test_single_year_param() { + new_ucmd!() + .args(&["2024"]) + .succeeds() + .stdout_contains(" 2024 ") + .stdout_matches(&Regex::new("January +February +March").unwrap()) + .stdout_matches(&Regex::new("October +November +December").unwrap()); +} + +#[test] +fn test_year_option() { + new_ucmd!() + .args(&["-y", "3", "2024"]) + .succeeds() + .stdout_matches(&Regex::new("January +February +March").unwrap()) + .stdout_matches(&Regex::new("October +November +December").unwrap()); +} + +#[test] +fn test_three_option() { + let re = Regex::new("December 2023 +January 2024 +February 2024").unwrap(); + new_ucmd!() + .args(&["-3", "1", "2024"]) + .succeeds() + .stdout_matches(&re); + + let re = Regex::new("November 2023 +December 2023 +January 2024").unwrap(); + new_ucmd!() + .args(&["-3", "12", "2023"]) + .succeeds() + .stdout_matches(&re); +} + +#[test] +fn test_twelve_option() { + new_ucmd!() + .args(&["-Y", "15", "3", "2024"]) + .succeeds() + .stdout_contains("March 2024") + .stdout_contains("February 2025"); +} + +#[test] +fn test_zero_months_displays_one() { + new_ucmd!() + .args(&["-n", "0", "12", "2023"]) + .succeeds() + .stdout_contains("Su Mo Tu We Th Fr Sa"); +} + +#[test] +fn test_color() { + let expected = vec![ + " March 2024 \n", + "Su Mo Tu We Th Fr Sa\n", + " 1 2\n", + " 3 4 5 6 7 8 9\n", + "10 11 12 13 14 15 16\n", + "17 18 19 20 21 22 23\n", + "24 25 26 27 28 29 30\n", + "31 \n", + ]; + new_ucmd!() + .args(&["--color=never", "15", "3", "2024"]) + .succeeds() + .stdout_is(expected.join("")); + + let expected = vec![ + " March 2024 \n", + "Su Mo Tu We Th Fr Sa\n", + " 1 2\n", + " 3 4 5 6 7 8 9\n", + "10 11 12 13 14 \x1b[7m15\x1b[0m 16\n", + "17 18 19 20 21 22 23\n", + "24 25 26 27 28 29 30\n", + "31 \n", + ]; + new_ucmd!() + .args(&["--color=always", "15", "3", "2024"]) + .succeeds() + .stdout_is(expected.join("")); +} diff --git a/tests/tests.rs b/tests/tests.rs index 2aa4324..3b1d577 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -43,6 +43,10 @@ mod test_nologin; #[path = "by-util/test_blockdev.rs"] mod test_blockdev; +#[cfg(feature = "cal")] +#[path = "by-util/test_cal.rs"] +mod test_cal; + #[cfg(feature = "ctrlaltdel")] #[path = "by-util/test_ctrlaltdel.rs"] mod test_ctrlaltdel;