diff --git a/Cargo.lock b/Cargo.lock index 6499dfbb7..d5b7e61c9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1415,6 +1415,15 @@ dependencies = [ "winreg", ] +[[package]] +name = "iso8601-duration" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b51dd97fa24074214b9eb14da518957573f4dec3189112610ae1ccec9ac464" +dependencies = [ + "nom 5.1.2", +] + [[package]] name = "itoa" version = "0.4.7" @@ -1776,6 +1785,17 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" +[[package]] +name = "nom" +version = "5.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb4262d26ed83a1c0a33a38fe2bb15797329c85770da05e6b828ddb782627af" +dependencies = [ + "lexical-core", + "memchr", + "version_check", +] + [[package]] name = "nom" version = "6.1.2" @@ -2413,6 +2433,19 @@ dependencies = [ "serde", ] +[[package]] +name = "rstest" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "041bb0202c14f6a158bbbf086afb03d0c6e975c2dec7d4912f8061ed44f290af" +dependencies = [ + "cfg-if 1.0.0", + "proc-macro2", + "quote", + "rustc_version 0.3.3", + "syn", +] + [[package]] name = "rust-argon2" version = "0.8.3" @@ -2431,7 +2464,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" dependencies = [ - "semver", + "semver 0.9.0", +] + +[[package]] +name = "rustc_version" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" +dependencies = [ + "semver 0.11.0", ] [[package]] @@ -2502,7 +2544,16 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" dependencies = [ - "semver-parser", + "semver-parser 0.7.0", +] + +[[package]] +name = "semver" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser 0.10.2", ] [[package]] @@ -2511,6 +2562,15 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" +[[package]] +name = "semver-parser" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0bef5b7f9e0df16536d3961cfb6e84331c065b4066afb39768d0e319411f7" +dependencies = [ + "pest", +] + [[package]] name = "serde" version = "1.0.125" @@ -2675,7 +2735,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d022496b16281348b52d0e30ae99e01a73d737b2f45d38fed4edf79f9325a1d5" dependencies = [ "discard", - "rustc_version", + "rustc_version 0.2.3", "stdweb-derive", "stdweb-internal-macros", "stdweb-internal-runtime", @@ -2768,9 +2828,9 @@ dependencies = [ [[package]] name = "syn" -version = "1.0.69" +version = "1.0.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48fe99c6bd8b1cc636890bcc071842de909d902c81ac7dab53ba33c421ab8ffb" +checksum = "a1e8cdbefb79a9a5a65e0db8b47b723ee907b7c7f8496c76a1770b5c310bab82" dependencies = [ "proc-macro2", "quote", @@ -2812,12 +2872,14 @@ dependencies = [ "chrono", "dirs-next", "env_logger 0.8.3", + "iso8601-duration", "lazy_static", "log", "mdbook", - "nom", + "nom 6.1.2", "predicates", "prettytable-rs", + "rstest", "serde_json", "taskchampion", "tempfile", diff --git a/cli/Cargo.toml b/cli/Cargo.toml index 53c26dd2b..05cfcb20c 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -22,8 +22,9 @@ termcolor = "^1.1.2" atty = "^0.2.14" toml = "^0.5.8" toml_edit = "^0.2.0" -chrono = "*" +chrono = "0.4" lazy_static = "1" +iso8601-duration = "0.1" # only needed for usage-docs mdbook = { version = "0.4", optional = true } @@ -36,6 +37,7 @@ path = "../taskchampion" assert_cmd = "^1.0.3" predicates = "^1.0.7" tempfile = "3" +rstest = "0.10" [features] usage-docs = [ "mdbook", "serde_json" ] diff --git a/cli/src/argparse/args/colon.rs b/cli/src/argparse/args/colon.rs index 8dde7c74c..ecf1af6c9 100644 --- a/cli/src/argparse/args/colon.rs +++ b/cli/src/argparse/args/colon.rs @@ -1,4 +1,4 @@ -use super::any; +use super::{any, timestamp}; use crate::argparse::NOW; use chrono::prelude::*; use nom::bytes::complete::tag as nomtag; @@ -31,16 +31,6 @@ pub(crate) fn status_colon(input: &str) -> IResult<&str, Status> { map_res(colon_prefixed("status"), to_status)(input) } -/// Recognizes timestamps -pub(crate) fn timestamp(input: &str) -> IResult<&str, DateTime> { - // TODO: full relative date language supported by TW - fn nn_d_to_timestamp(input: &str) -> Result, ()> { - // TODO: don't unwrap - Ok(*NOW + chrono::Duration::days(input.parse().unwrap())) - } - map_res(terminated(digit1, char('d')), nn_d_to_timestamp)(input) -} - /// Recognizes `wait:` to None and `wait:` to `Some(ts)` pub(crate) fn wait_colon(input: &str) -> IResult<&str, Option>> { fn to_wait(input: DateTime) -> Result>, ()> { @@ -51,7 +41,10 @@ pub(crate) fn wait_colon(input: &str) -> IResult<&str, Option>> { } preceded( nomtag("wait:"), - alt((map_res(timestamp, to_wait), map_res(nomtag(""), to_none))), + alt(( + map_res(timestamp(*NOW, Local), to_wait), + map_res(nomtag(""), to_none), + )), )(input) } diff --git a/cli/src/argparse/args/mod.rs b/cli/src/argparse/args/mod.rs index 8beaf08c1..f7124fa29 100644 --- a/cli/src/argparse/args/mod.rs +++ b/cli/src/argparse/args/mod.rs @@ -5,9 +5,12 @@ mod colon; mod idlist; mod misc; mod tags; +mod time; pub(crate) use arg_matching::arg_matching; pub(crate) use colon::{status_colon, wait_colon}; pub(crate) use idlist::{id_list, TaskId}; pub(crate) use misc::{any, literal, report_name}; pub(crate) use tags::{minus_tag, plus_tag}; +#[allow(unused_imports)] +pub(crate) use time::{duration, timestamp}; diff --git a/cli/src/argparse/args/time.rs b/cli/src/argparse/args/time.rs new file mode 100644 index 000000000..f17ebd880 --- /dev/null +++ b/cli/src/argparse/args/time.rs @@ -0,0 +1,529 @@ +use chrono::{prelude::*, Duration}; +use iso8601_duration::Duration as IsoDuration; +use lazy_static::lazy_static; +use nom::{ + branch::*, + bytes::complete::*, + character::complete::*, + character::*, + combinator::*, + error::{Error, ErrorKind}, + multi::*, + sequence::*, + Err, IResult, +}; +use std::str::FromStr; + +// https://taskwarrior.org/docs/dates.html +// https://taskwarrior.org/docs/named_dates.html +// https://taskwarrior.org/docs/durations.html + +/// A case for matching durations. If `.3` is true, then the value can be used +/// without a prefix, e.g., `minute`. If false, it cannot, e.g., `minutes` +#[derive(Debug)] +struct DurationCase(&'static str, Duration, bool); + +// https://github.com/GothenburgBitFactory/libshared/blob/9a5f24e2acb38d05afb8f8e316a966dee196a42a/src/Duration.cpp#L50 +// TODO: use const when chrono supports it +lazy_static! { + static ref DURATION_CASES: Vec = vec![ + DurationCase("annual", Duration::days(365), true), + DurationCase("biannual", Duration::days(730), true), + DurationCase("bimonthly", Duration::days(61), true), + DurationCase("biweekly", Duration::days(14), true), + DurationCase("biyearly", Duration::days(730), true), + DurationCase("daily", Duration::days(1), true), + DurationCase("days", Duration::days(1), false), + DurationCase("day", Duration::days(1), true), + DurationCase("d", Duration::days(1), false), + DurationCase("fortnight", Duration::days(14), true), + DurationCase("hours", Duration::hours(1), false), + DurationCase("hour", Duration::hours(1), true), + DurationCase("hrs", Duration::hours(1), false), + DurationCase("hr", Duration::hours(1), true), + DurationCase("h", Duration::hours(1), false), + DurationCase("minutes", Duration::minutes(1), false), + DurationCase("minute", Duration::minutes(1), true), + DurationCase("mins", Duration::minutes(1), false), + DurationCase("min", Duration::minutes(1), true), + DurationCase("monthly", Duration::days(30), true), + DurationCase("months", Duration::days(30), false), + DurationCase("month", Duration::days(30), true), + DurationCase("mnths", Duration::days(30), false), + DurationCase("mths", Duration::days(30), false), + DurationCase("mth", Duration::days(30), true), + DurationCase("mos", Duration::days(30), false), + DurationCase("mo", Duration::days(30), true), + DurationCase("m", Duration::days(30), false), + DurationCase("quarterly", Duration::days(91), true), + DurationCase("quarters", Duration::days(91), false), + DurationCase("quarter", Duration::days(91), true), + DurationCase("qrtrs", Duration::days(91), false), + DurationCase("qrtr", Duration::days(91), true), + DurationCase("qtrs", Duration::days(91), false), + DurationCase("qtr", Duration::days(91), true), + DurationCase("q", Duration::days(91), false), + DurationCase("semiannual", Duration::days(183), true), + DurationCase("sennight", Duration::days(14), false), + DurationCase("seconds", Duration::seconds(1), false), + DurationCase("second", Duration::seconds(1), true), + DurationCase("secs", Duration::seconds(1), false), + DurationCase("sec", Duration::seconds(1), true), + DurationCase("s", Duration::seconds(1), false), + DurationCase("weekdays", Duration::days(1), true), + DurationCase("weekly", Duration::days(7), true), + DurationCase("weeks", Duration::days(7), false), + DurationCase("week", Duration::days(7), true), + DurationCase("wks", Duration::days(7), false), + DurationCase("wk", Duration::days(7), true), + DurationCase("w", Duration::days(7), false), + DurationCase("yearly", Duration::days(365), true), + DurationCase("years", Duration::days(365), false), + DurationCase("year", Duration::days(365), true), + DurationCase("yrs", Duration::days(365), false), + DurationCase("yr", Duration::days(365), true), + DurationCase("y", Duration::days(365), false), + ]; +} + +/// Parses suffixes like 'min', and 'd'; standalone is true if there is no numeric prefix, in which +/// case plurals (like `days`) are not matched. +fn duration_suffix(has_prefix: bool) -> impl Fn(&str) -> IResult<&str, Duration> { + move |input: &str| { + // Rust wants this to have a default value, but it is not actually used + // because DURATION_CASES has at least one case with case.2 == `true` + let mut res = Err(Err::Failure(Error::new(input, ErrorKind::Tag))); + for case in DURATION_CASES.iter() { + if !case.2 && !has_prefix { + // this case requires a prefix, and input does not have one + continue; + } + res = tag(case.0)(input); + match res { + Ok((i, _)) => { + return Ok((i, case.1)); + } + Err(Err::Error(_)) => { + // recoverable error + continue; + } + Err(e) => { + // irrecoverable error + return Err(e); + } + } + } + + // return the last error + Err(res.unwrap_err()) + } +} +/// Calculate the multiplier for a decimal prefix; this uses integer math +/// where possible, falling back to floating-point math on seconds +fn decimal_prefix_multiplier(input: &str) -> IResult<&str, f64> { + map_res( + // recognize NN or NN.NN + alt((recognize(tuple((digit1, char('.'), digit1))), digit1)), + |input: &str| -> Result::Err> { + let mul = input.parse::()?; + Ok(mul) + }, + )(input) +} + +/// Parse an iso8601 duration, converting it to a [`chrono::Duration`] on the assumption +/// that a year is 365 days and a month is 30 days. +fn iso8601_dur(input: &str) -> IResult<&str, Duration> { + if let Ok(iso_dur) = IsoDuration::parse(input) { + // iso8601_duration uses f32, but f32 underflows seconds for values as small as + // a year. So we upgrade to f64 immediately. f64 has a 53-bit mantissa which can + // represent almost 300 million years without underflow, so it should be adequate. + let days = iso_dur.year as f64 * 365.0 + iso_dur.month as f64 * 30.0 + iso_dur.day as f64; + let hours = days * 24.0 + iso_dur.hour as f64; + let mins = hours * 60.0 + iso_dur.minute as f64; + let secs = mins * 60.0 + iso_dur.second as f64; + let dur = Duration::seconds(secs as i64); + Ok((&input[input.len()..], dur)) + } else { + Err(Err::Error(Error::new(input, ErrorKind::Tag))) + } +} + +/// Recognizes durations +pub(crate) fn duration(input: &str) -> IResult<&str, Duration> { + alt(( + map_res( + tuple(( + decimal_prefix_multiplier, + multispace0, + duration_suffix(true), + )), + |input: (f64, &str, Duration)| -> Result { + // `as i64` is saturating, so for large offsets this will + // just pick an imprecise very-futuristic date + let secs = (input.0 * input.2.num_seconds() as f64) as i64; + Ok(Duration::seconds(secs)) + }, + ), + duration_suffix(false), + iso8601_dur, + ))(input) +} + +/// Parse a rfc3339 datestamp +fn rfc3339_timestamp(input: &str) -> IResult<&str, DateTime> { + if let Ok(dt) = DateTime::parse_from_rfc3339(input) { + // convert to UTC and truncate seconds + let dt = dt.with_timezone(&Utc).trunc_subsecs(0); + Ok((&input[input.len()..], dt)) + } else { + Err(Err::Error(Error::new(input, ErrorKind::Tag))) + } +} + +fn named_date( + now: DateTime, + local: Tz, +) -> impl Fn(&str) -> IResult<&str, DateTime> { + move |input: &str| { + let local_today = now.with_timezone(&local).date(); + let remaining = &input[input.len()..]; + match input { + "yesterday" => Ok((remaining, local_today - Duration::days(1))), + "today" => Ok((remaining, local_today)), + "tomorrow" => Ok((remaining, local_today + Duration::days(1))), + // TODO: lots more! + _ => Err(Err::Error(Error::new(input, ErrorKind::Tag))), + } + .map(|(rem, dt)| (rem, dt.and_hms(0, 0, 0).with_timezone(&Utc))) + } +} + +/// recognize a digit +fn digit(input: &str) -> IResult<&str, char> { + satisfy(|c| is_digit(c as u8))(input) +} + +/// Parse yyyy-mm-dd as the given date, at the local midnight +fn yyyy_mm_dd(local: Tz) -> impl Fn(&str) -> IResult<&str, DateTime> { + move |input: &str| { + fn parse_int(input: &str) -> Result::Err> { + input.parse::() + } + map_res( + tuple(( + map_res(recognize(count(digit, 4)), parse_int::), + char('-'), + map_res(recognize(many_m_n(1, 2, digit)), parse_int::), + char('-'), + map_res(recognize(many_m_n(1, 2, digit)), parse_int::), + )), + |input: (i32, char, u32, char, u32)| -> Result, ()> { + // try to convert, handling out-of-bounds months or days as an error + let ymd = match local.ymd_opt(input.0, input.2, input.4) { + chrono::LocalResult::Single(ymd) => Ok(ymd), + _ => Err(()), + }?; + Ok(ymd.and_hms(0, 0, 0).with_timezone(&Utc)) + }, + )(input) + } +} + +/// Recognizes timestamps +pub(crate) fn timestamp( + now: DateTime, + local: Tz, +) -> impl Fn(&str) -> IResult<&str, DateTime> { + move |input: &str| { + alt(( + // relative time + map_res( + duration, + |duration: Duration| -> Result, ()> { Ok(now + duration) }, + ), + rfc3339_timestamp, + yyyy_mm_dd(local), + value(now, tag("now")), + named_date(now, local), + ))(input) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::argparse::NOW; + use rstest::rstest; + + const M: i64 = 60; + const H: i64 = M * 60; + const DAY: i64 = H * 24; + const MONTH: i64 = DAY * 30; + const YEAR: i64 = DAY * 365; + + // TODO: use const when chrono supports it + lazy_static! { + // India standard time (not an even multiple of hours) + static ref IST: FixedOffset = FixedOffset::east(5 * 3600 + 30 * 60); + // Utc, but as a FixedOffset TimeZone impl + static ref UTC_FO: FixedOffset = FixedOffset::east(0); + // Hawaii + static ref HST: FixedOffset = FixedOffset::west(10 * 3600); + } + + /// test helper to ensure that the entire input is consumed + fn complete_duration(input: &str) -> IResult<&str, Duration> { + all_consuming(duration)(input) + } + + /// test helper to ensure that the entire input is consumed + fn complete_timestamp( + now: DateTime, + local: Tz, + ) -> impl Fn(&str) -> IResult<&str, DateTime> { + move |input: &str| all_consuming(timestamp(now, local))(input) + } + + /// Shorthand day and time + fn dt(y: i32, m: u32, d: u32, hh: u32, mm: u32, ss: u32) -> DateTime { + Utc.ymd(y, m, d).and_hms(hh, mm, ss) + } + + /// Local day and time, parameterized on the timezone + fn ldt( + y: i32, + m: u32, + d: u32, + hh: u32, + mm: u32, + ss: u32, + ) -> Box DateTime> { + Box::new(move |tz| tz.ymd(y, m, d).and_hms(hh, mm, ss).with_timezone(&Utc)) + } + + fn ld(y: i32, m: u32, d: u32) -> Box DateTime> { + ldt(y, m, d, 0, 0, 0) + } + + #[rstest] + #[case::rel_hours_0(dt(2021, 5, 29, 1, 30, 0), "0h", dt(2021, 5, 29, 1, 30, 0))] + #[case::rel_hours_05(dt(2021, 5, 29, 1, 30, 0), "0.5h", dt(2021, 5, 29, 2, 0, 0))] + #[case::rel_hours_no_prefix(dt(2021, 5, 29, 1, 30, 0), "hour", dt(2021, 5, 29, 2, 30, 0))] + #[case::rel_hours_5(dt(2021, 5, 29, 1, 30, 0), "5h", dt(2021, 5, 29, 6, 30, 0))] + #[case::rel_days_0(dt(2021, 5, 29, 1, 30, 0), "0d", dt(2021, 5, 29, 1, 30, 0))] + #[case::rel_days_10(dt(2021, 5, 29, 1, 30, 0), "10d", dt(2021, 6, 8, 1, 30, 0))] + #[case::rfc3339_datetime(*NOW, "2019-10-12T07:20:50.12Z", dt(2019, 10, 12, 7, 20, 50))] + #[case::now(*NOW, "now", *NOW)] + /// Cases where the `local` parameter is ignored + fn test_nonlocal_timestamp( + #[case] now: DateTime, + #[case] input: &'static str, + #[case] output: DateTime, + ) { + let (_, res) = complete_timestamp(now, *IST)(input).unwrap(); + assert_eq!(res, output, "parsing {:?}", input); + } + + #[rstest] + /// Cases where the `local` parameter matters + #[case::yyyy_mm_dd(ld(2000, 1, 1), "2021-01-01", ld(2021, 1, 1))] + #[case::yyyy_m_d(ld(2000, 1, 1), "2021-1-1", ld(2021, 1, 1))] + #[case::yesterday(ld(2021, 3, 1), "yesterday", ld(2021, 2, 28))] + #[case::yesterday_from_evening(ldt(2021, 3, 1, 21, 30, 30), "yesterday", ld(2021, 2, 28))] + #[case::today(ld(2021, 3, 1), "today", ld(2021, 3, 1))] + #[case::today_from_evening(ldt(2021, 3, 1, 21, 30, 30), "today", ld(2021, 3, 1))] + #[case::tomorrow(ld(2021, 3, 1), "tomorrow", ld(2021, 3, 2))] + #[case::tomorow_from_evening(ldt(2021, 3, 1, 21, 30, 30), "tomorrow", ld(2021, 3, 2))] + fn test_local_timestamp( + #[case] now: Box DateTime>, + #[values(*IST, *UTC_FO, *HST)] tz: FixedOffset, + #[case] input: &str, + #[case] output: Box DateTime>, + ) { + let now = now(tz); + let output = output(tz); + let (_, res) = complete_timestamp(now, tz)(input).unwrap(); + assert_eq!( + res, output, + "parsing {:?} relative to {:?} in timezone {:?}", + input, now, tz + ); + } + + #[rstest] + #[case::rfc3339_datetime_bad_month(*NOW, "2019-10-99T07:20:50.12Z")] + #[case::yyyy_mm_dd_bad_month(*NOW, "2019-10-99")] + fn test_timestamp_err(#[case] now: DateTime, #[case] input: &'static str) { + let res = complete_timestamp(now, Utc)(input); + assert!( + res.is_err(), + "expected error parsing {:?}, got {:?}", + input, + res.unwrap() + ); + } + + // All test cases from + // https://github.com/GothenburgBitFactory/libshared/blob/9a5f24e2acb38d05afb8f8e316a966dee196a42a/test/duration.t.cpp#L136 + #[rstest] + #[case("0seconds", 0)] + #[case("2 seconds", 2)] + #[case("10seconds", 10)] + #[case("1.5seconds", 1)] + #[case("0second", 0)] + #[case("2 second", 2)] + #[case("10second", 10)] + #[case("1.5second", 1)] + #[case("0s", 0)] + #[case("2 s", 2)] + #[case("10s", 10)] + #[case("1.5s", 1)] + #[case("0minutes", 0)] + #[case("2 minutes", 2 * M)] + #[case("10minutes", 10 * M)] + #[case("1.5minutes", M + 30)] + #[case("0minute", 0)] + #[case("2 minute", 2 * M)] + #[case("10minute", 10 * M)] + #[case("1.5minute", M + 30)] + #[case("0min", 0)] + #[case("2 min", 2 * M)] + #[case("10min", 10 * M)] + #[case("1.5min", M + 30)] + #[case("0hours", 0)] + #[case("2 hours", 2 * H)] + #[case("10hours", 10 * H)] + #[case("1.5hours", H + 30 * M)] + #[case("0hour", 0)] + #[case("2 hour", 2 * H)] + #[case("10hour", 10 * H)] + #[case("1.5hour", H + 30 * M)] + #[case("0h", 0)] + #[case("2 h", 2 * H)] + #[case("10h", 10 * H)] + #[case("1.5h", H + 30 * M)] + #[case("weekdays", DAY)] + #[case("daily", DAY)] + #[case("0days", 0)] + #[case("2 days", 2 * DAY)] + #[case("10days", 10 * DAY)] + #[case("1.5days", DAY + 12 * H)] + #[case("0day", 0)] + #[case("2 day", 2 * DAY)] + #[case("10day", 10 * DAY)] + #[case("1.5day", DAY + 12 * H)] + #[case("0d", 0)] + #[case("2 d", 2 * DAY)] + #[case("10d", 10 * DAY)] + #[case("1.5d", DAY + 12 * H)] + #[case("weekly", 7 * DAY)] + #[case("0weeks", 0)] + #[case("2 weeks", 14 * DAY)] + #[case("10weeks", 70 * DAY)] + #[case("1.5weeks", 10 * DAY + 12 * H)] + #[case("0week", 0)] + #[case("2 week", 14 * DAY)] + #[case("10week", 70 * DAY)] + #[case("1.5week", 10 * DAY + 12 * H)] + #[case("0w", 0)] + #[case("2 w", 14 * DAY)] + #[case("10w", 70 * DAY)] + #[case("1.5w", 10 * DAY + 12 * H)] + #[case("monthly", 30 * DAY)] + #[case("0months", 0)] + #[case("2 months", 60 * DAY)] + #[case("10months", 300 * DAY)] + #[case("1.5months", 45 * DAY)] + #[case("0month", 0)] + #[case("2 month", 60 * DAY)] + #[case("10month", 300 * DAY)] + #[case("1.5month", 45 * DAY)] + #[case("0mo", 0)] + #[case("2 mo", 60 * DAY)] + #[case("10mo", 300 * DAY)] + #[case("1.5mo", 45 * DAY)] + #[case("quarterly", 91 * DAY)] + #[case("0quarters", 0)] + #[case("2 quarters", 182 * DAY)] + #[case("10quarters", 910 * DAY)] + #[case("1.5quarters", 136 * DAY + 12 * H)] + #[case("0quarter", 0)] + #[case("2 quarter", 182 * DAY)] + #[case("10quarter", 910 * DAY)] + #[case("1.5quarter", 136 * DAY + 12 * H)] + #[case("0q", 0)] + #[case("2 q", 182 * DAY)] + #[case("10q", 910 * DAY)] + #[case("1.5q", 136 * DAY + 12 * H)] + #[case("yearly", YEAR)] + #[case("0years", 0)] + #[case("2 years", 2 * YEAR)] + #[case("10years", 10 * YEAR)] + #[case("1.5years", 547 * DAY + 12 * H)] + #[case("0year", 0)] + #[case("2 year", 2 * YEAR)] + #[case("10year", 10 * YEAR)] + #[case("1.5year", 547 * DAY + 12 * H)] + #[case("0y", 0)] + #[case("2 y", 2 * YEAR)] + #[case("10y", 10 * YEAR)] + #[case("1.5y", 547 * DAY + 12 * H)] + #[case("annual", YEAR)] + #[case("biannual", 2 * YEAR)] + #[case("bimonthly", 61 * DAY)] + #[case("biweekly", 14 * DAY)] + #[case("biyearly", 2 * YEAR)] + #[case("fortnight", 14 * DAY)] + #[case("semiannual", 183 * DAY)] + #[case("0sennight", 0)] + #[case("2 sennight", 28 * DAY)] + #[case("10sennight", 140 * DAY)] + #[case("1.5sennight", 21 * DAY)] + fn test_duration_units(#[case] input: &'static str, #[case] seconds: i64) { + let (_, res) = complete_duration(input).expect(input); + assert_eq!(res.num_seconds(), seconds, "parsing {}", input); + } + + #[rstest] + #[case("years")] + #[case("minutes")] + #[case("eons")] + #[case("P1S")] // missing T + #[case("p1y")] // lower-case + fn test_duration_errors(#[case] input: &'static str) { + let res = complete_duration(input); + assert!( + res.is_err(), + "did not get expected error parsing duration {:?}; got {:?}", + input, + res.unwrap() + ); + } + + // https://github.com/GothenburgBitFactory/libshared/blob/9a5f24e2acb38d05afb8f8e316a966dee196a42a/test/duration.t.cpp#L115 + #[rstest] + #[case("P1Y", YEAR)] + #[case("P1M", MONTH)] + #[case("P1D", DAY)] + #[case("P1Y1M", YEAR + MONTH)] + #[case("P1Y1D", YEAR + DAY)] + #[case("P1M1D", MONTH + DAY)] + #[case("P1Y1M1D", YEAR + MONTH + DAY)] + #[case("PT1H", H)] + #[case("PT1M", M)] + #[case("PT1S", 1)] + #[case("PT1H1M", H + M)] + #[case("PT1H1S", H + 1)] + #[case("PT1M1S", M + 1)] + #[case("PT1H1M1S", H + M + 1)] + #[case("P1Y1M1DT1H1M1S", YEAR + MONTH + DAY + H + M + 1)] + #[case("PT24H", DAY)] + #[case("PT40000000S", 40000000)] + #[case("PT3600S", H)] + #[case("PT60M", H)] + fn test_duration_8601(#[case] input: &'static str, #[case] seconds: i64) { + let (_, res) = complete_duration(input).expect(input); + assert_eq!(res.num_seconds(), seconds, "parsing {}", input); + } +} diff --git a/cli/src/argparse/mod.rs b/cli/src/argparse/mod.rs index f837b0ecf..7f2607631 100644 --- a/cli/src/argparse/mod.rs +++ b/cli/src/argparse/mod.rs @@ -34,13 +34,13 @@ use crate::usage::Usage; use chrono::prelude::*; use lazy_static::lazy_static; -type ArgList<'a> = &'a [&'a str]; - lazy_static! { // A static value of NOW to make tests easier - pub(super) static ref NOW: DateTime = Utc::now(); + pub(crate) static ref NOW: DateTime = Utc::now(); } +type ArgList<'a> = &'a [&'a str]; + pub(crate) fn get_usage(usage: &mut Usage) { Subcommand::get_usage(usage); Filter::get_usage(usage); diff --git a/cli/src/argparse/modification.rs b/cli/src/argparse/modification.rs index 5b3cac3df..dcdc3d303 100644 --- a/cli/src/argparse/modification.rs +++ b/cli/src/argparse/modification.rs @@ -156,7 +156,7 @@ impl Modification { description: " Set the time before which the task is not actionable and should not be shown in reports. With `wait:`, the time - is un-set.", + is un-set. See the documentation for the timestamp syntax.", }); } } diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index 822dc4f7f..568eb57cf 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -7,6 +7,7 @@ * [Tags](./tags.md) * [Filters](./filters.md) * [Modifications](./modifications.md) + * [Dates and Durations](./time.md) * [Configuration](./config-file.md) * [Environment](./environment.md) * [Synchronization](./task-sync.md) diff --git a/docs/src/time.md b/docs/src/time.md new file mode 100644 index 000000000..c6dc5e282 --- /dev/null +++ b/docs/src/time.md @@ -0,0 +1,36 @@ +## Timestamps + +Times may be specified in a wide variety of convenient formats. + + * [RFC3339](https://datatracker.ietf.org/doc/html/rfc3339) timestamps, such as `2019-10-12 07:20:50.12Z` + * A date of the format `YYYY-MM-DD` is interpreted as _local_ midnight on the given date. + Single-digit month and day are accepted, but the year must contain four digits. + * `now` refers to the exact current time + * `yesterday`, `today`, and `tomorrow` refer to _local_ midnight on the given day + * Any duration (described below) may be used as a timestamp, and is considered relative to the current time. + +Times are stored internally as UTC. + +## Durations + +Durations can be given in a dizzying array of units. +Each can be preceded by a whole number or a decimal multiplier, e.g., `3days`. +The multiplier is optional with the singular forms of the units; for example `day` is allowed. +Some of the units allow an adjectival form, such as `daily` or `annually`; this form is more readable in some cases, but otherwise has the same meaning. + + * `s`, `sec`, `secs`, `second`, or `seconds` + * `min`, `mins`, `minute`, or `minutes` (note that `m` is a month!) + * `h`, `hr`, `hrs`, `hour`, or `hours` + * `d`, `day`, `days`, `daily`, or `weekdays` (note, weekdays includes weekends!) + * `w`, `wk`, `wks`, `week`, `weeks`, or `weekly` + * `biweekly`, `fornight` or `sennight` (14 days) + * `m`, `mo`, `mos`, `mth`, `mths`, `mnths`, `month`, `months`, or `monthly` (always 30 days, regardless of calendar month) + * `binmonthly` (61 days) + * `q`, `qtr`, `qtrs`, `qrtr`, `qrtrs`, `quarter`, `quarters`, or `quarterly` (91 days) + * `semiannual` (183 days) + * `y`, `yr`, `yrs`, `year`, `years`, `yearly`, or `annual` (365 days, regardless of leap days) + * `biannual` or `biyearly` (730 days) + +[ISO 8601 standard durations](https://en.wikipedia.org/wiki/ISO_8601#Durations) are also allowed. +While the standard does not specify the length of "P1Y" or "P1M", Taskchampion treats those as 365 and 30 days, respectively. +