diff --git a/Cargo.lock b/Cargo.lock index 144f8e56f..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", @@ -2809,13 +2869,17 @@ dependencies = [ "anyhow", "assert_cmd", "atty", + "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 591d068a9..05cfcb20c 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -22,6 +22,9 @@ termcolor = "^1.1.2" atty = "^0.2.14" toml = "^0.5.8" toml_edit = "^0.2.0" +chrono = "0.4" +lazy_static = "1" +iso8601-duration = "0.1" # only needed for usage-docs mdbook = { version = "0.4", optional = true } @@ -34,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.rs b/cli/src/argparse/args.rs deleted file mode 100644 index a902fd690..000000000 --- a/cli/src/argparse/args.rs +++ /dev/null @@ -1,309 +0,0 @@ -//! Parsers for argument lists -- arrays of strings -use super::ArgList; -use nom::bytes::complete::tag as nomtag; -use nom::{ - branch::*, - character::complete::*, - combinator::*, - error::{Error, ErrorKind}, - multi::*, - sequence::*, - Err, IResult, -}; -use std::convert::TryFrom; -use taskchampion::{Status, Tag, Uuid}; - -/// A task identifier, as given in a filter command-line expression -#[derive(Debug, PartialEq, Clone)] -pub(crate) enum TaskId { - /// A small integer identifying a working-set task - WorkingSetId(usize), - - /// A full Uuid specifically identifying a task - Uuid(Uuid), - - /// A prefix of a Uuid - PartialUuid(String), -} - -/// Recognizes any argument -pub(super) fn any(input: &str) -> IResult<&str, &str> { - rest(input) -} - -/// Recognizes a report name -pub(super) fn report_name(input: &str) -> IResult<&str, &str> { - all_consuming(recognize(pair(alpha1, alphanumeric0)))(input) -} - -/// Recognizes a literal string -pub(super) fn literal(literal: &'static str) -> impl Fn(&str) -> IResult<&str, &str> { - move |input: &str| all_consuming(nomtag(literal))(input) -} - -/// Recognizes a colon-prefixed pair -pub(super) fn colon_prefixed(prefix: &'static str) -> impl Fn(&str) -> IResult<&str, &str> { - fn to_suffix<'a>(input: (&'a str, char, &'a str)) -> Result<&'a str, ()> { - Ok(input.2) - } - move |input: &str| { - map_res( - all_consuming(tuple((nomtag(prefix), char(':'), any))), - to_suffix, - )(input) - } -} - -/// Recognizes `status:{pending,completed,deleted}` -pub(super) fn status_colon(input: &str) -> IResult<&str, Status> { - fn to_status(input: &str) -> Result { - match input { - "pending" => Ok(Status::Pending), - "completed" => Ok(Status::Completed), - "deleted" => Ok(Status::Deleted), - _ => Err(()), - } - } - map_res(colon_prefixed("status"), to_status)(input) -} - -/// Recognizes a comma-separated list of TaskIds -pub(super) fn id_list(input: &str) -> IResult<&str, Vec> { - fn hex_n(n: usize) -> impl Fn(&str) -> IResult<&str, &str> { - move |input: &str| recognize(many_m_n(n, n, one_of(&b"0123456789abcdefABCDEF"[..])))(input) - } - fn uuid(input: &str) -> Result { - Ok(TaskId::Uuid(Uuid::parse_str(input).map_err(|_| ())?)) - } - fn partial_uuid(input: &str) -> Result { - Ok(TaskId::PartialUuid(input.to_owned())) - } - fn working_set_id(input: &str) -> Result { - Ok(TaskId::WorkingSetId(input.parse().map_err(|_| ())?)) - } - all_consuming(separated_list1( - char(','), - alt(( - map_res( - recognize(tuple(( - hex_n(8), - char('-'), - hex_n(4), - char('-'), - hex_n(4), - char('-'), - hex_n(4), - char('-'), - hex_n(12), - ))), - uuid, - ), - map_res( - recognize(tuple(( - hex_n(8), - char('-'), - hex_n(4), - char('-'), - hex_n(4), - char('-'), - hex_n(4), - ))), - partial_uuid, - ), - map_res( - recognize(tuple((hex_n(8), char('-'), hex_n(4), char('-'), hex_n(4)))), - partial_uuid, - ), - map_res( - recognize(tuple((hex_n(8), char('-'), hex_n(4)))), - partial_uuid, - ), - map_res(hex_n(8), partial_uuid), - // note that an 8-decimal-digit value will be treated as a UUID - map_res(digit1, working_set_id), - )), - ))(input) -} - -/// Recognizes a tag prefixed with `+` and returns the tag value -pub(super) fn plus_tag(input: &str) -> IResult<&str, &str> { - fn to_tag(input: (char, &str)) -> Result<&str, ()> { - Ok(input.1) - } - map_res( - all_consuming(tuple(( - char('+'), - recognize(verify(rest, |s: &str| Tag::try_from(s).is_ok())), - ))), - to_tag, - )(input) -} - -/// Recognizes a tag prefixed with `-` and returns the tag value -pub(super) fn minus_tag(input: &str) -> IResult<&str, &str> { - fn to_tag(input: (char, &str)) -> Result<&str, ()> { - Ok(input.1) - } - map_res( - all_consuming(tuple(( - char('-'), - recognize(verify(rest, |s: &str| Tag::try_from(s).is_ok())), - ))), - to_tag, - )(input) -} - -/// Consume a single argument from an argument list that matches the given string parser (one -/// of the other functions in this module). The given parser must consume the entire input. -pub(super) fn arg_matching<'a, O, F>(f: F) -> impl Fn(ArgList<'a>) -> IResult -where - F: Fn(&'a str) -> IResult<&'a str, O>, -{ - move |input: ArgList<'a>| { - if let Some(arg) = input.get(0) { - return match f(arg) { - Ok(("", rv)) => Ok((&input[1..], rv)), - // single-arg parsers must consume the entire arg - Ok((unconsumed, _)) => panic!("unconsumed argument input {}", unconsumed), - // single-arg parsers are all complete parsers - Err(Err::Incomplete(_)) => unreachable!(), - // for error and failure, rewrite to an error at this position in the arugment list - Err(Err::Error(Error { input: _, code })) => Err(Err::Error(Error { input, code })), - Err(Err::Failure(Error { input: _, code })) => { - Err(Err::Failure(Error { input, code })) - } - }; - } - - Err(Err::Error(Error { - input, - // since we're using nom's built-in Error, our choices here are limited, but tihs - // occurs when there's no argument where one is expected, so Eof seems appropriate - code: ErrorKind::Eof, - })) - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn test_arg_matching() { - assert_eq!( - arg_matching(plus_tag)(argv!["+foo", "bar"]).unwrap(), - (argv!["bar"], "foo") - ); - assert!(arg_matching(plus_tag)(argv!["foo", "bar"]).is_err()); - } - - #[test] - fn test_colon_prefixed() { - assert_eq!(colon_prefixed("foo")("foo:abc").unwrap().1, "abc"); - assert_eq!(colon_prefixed("foo")("foo:").unwrap().1, ""); - assert!(colon_prefixed("foo")("foo").is_err()); - } - - #[test] - fn test_status_colon() { - assert_eq!(status_colon("status:pending").unwrap().1, Status::Pending); - assert_eq!( - status_colon("status:completed").unwrap().1, - Status::Completed - ); - assert_eq!(status_colon("status:deleted").unwrap().1, Status::Deleted); - assert!(status_colon("status:foo").is_err()); - assert!(status_colon("status:complete").is_err()); - assert!(status_colon("status").is_err()); - } - - #[test] - fn test_plus_tag() { - assert_eq!(plus_tag("+abc").unwrap().1, "abc"); - assert_eq!(plus_tag("+abc123").unwrap().1, "abc123"); - assert!(plus_tag("-abc123").is_err()); - assert!(plus_tag("+abc123 ").is_err()); - assert!(plus_tag(" +abc123").is_err()); - assert!(plus_tag("+1abc").is_err()); - } - - #[test] - fn test_minus_tag() { - assert_eq!(minus_tag("-abc").unwrap().1, "abc"); - assert_eq!(minus_tag("-abc123").unwrap().1, "abc123"); - assert!(minus_tag("+abc123").is_err()); - assert!(minus_tag("-abc123 ").is_err()); - assert!(minus_tag(" -abc123").is_err()); - assert!(minus_tag("-1abc").is_err()); - } - - #[test] - fn test_literal() { - assert_eq!(literal("list")("list").unwrap().1, "list"); - assert!(literal("list")("listicle").is_err()); - assert!(literal("list")(" list ").is_err()); - assert!(literal("list")("LiSt").is_err()); - assert!(literal("list")("denylist").is_err()); - } - - #[test] - fn test_id_list_single() { - assert_eq!(id_list("123").unwrap().1, vec![TaskId::WorkingSetId(123)]); - } - - #[test] - fn test_id_list_uuids() { - assert_eq!( - id_list("12341234").unwrap().1, - vec![TaskId::PartialUuid(s!("12341234"))] - ); - assert_eq!( - id_list("1234abcd").unwrap().1, - vec![TaskId::PartialUuid(s!("1234abcd"))] - ); - assert_eq!( - id_list("abcd1234").unwrap().1, - vec![TaskId::PartialUuid(s!("abcd1234"))] - ); - assert_eq!( - id_list("abcd1234-1234").unwrap().1, - vec![TaskId::PartialUuid(s!("abcd1234-1234"))] - ); - assert_eq!( - id_list("abcd1234-1234-2345").unwrap().1, - vec![TaskId::PartialUuid(s!("abcd1234-1234-2345"))] - ); - assert_eq!( - id_list("abcd1234-1234-2345-3456").unwrap().1, - vec![TaskId::PartialUuid(s!("abcd1234-1234-2345-3456"))] - ); - assert_eq!( - id_list("abcd1234-1234-2345-3456-0123456789ab").unwrap().1, - vec![TaskId::Uuid( - Uuid::parse_str("abcd1234-1234-2345-3456-0123456789ab").unwrap() - )] - ); - } - - #[test] - fn test_id_list_invalid_partial_uuids() { - assert!(id_list("abcd123").is_err()); - assert!(id_list("abcd12345").is_err()); - assert!(id_list("abcd1234-").is_err()); - assert!(id_list("abcd1234-123").is_err()); - assert!(id_list("abcd1234-1234-").is_err()); - assert!(id_list("abcd1234-12345-").is_err()); - assert!(id_list("abcd1234-1234-2345-3456-0123456789ab-").is_err()); - } - - #[test] - fn test_id_list_uuids_mixed() { - assert_eq!(id_list("abcd1234,abcd1234-1234,abcd1234-1234-2345,abcd1234-1234-2345-3456,abcd1234-1234-2345-3456-0123456789ab").unwrap().1, - vec![TaskId::PartialUuid(s!("abcd1234")), - TaskId::PartialUuid(s!("abcd1234-1234")), - TaskId::PartialUuid(s!("abcd1234-1234-2345")), - TaskId::PartialUuid(s!("abcd1234-1234-2345-3456")), - TaskId::Uuid(Uuid::parse_str("abcd1234-1234-2345-3456-0123456789ab").unwrap()), - ]); - } -} diff --git a/cli/src/argparse/args/arg_matching.rs b/cli/src/argparse/args/arg_matching.rs new file mode 100644 index 000000000..e1161738e --- /dev/null +++ b/cli/src/argparse/args/arg_matching.rs @@ -0,0 +1,51 @@ +use crate::argparse::ArgList; +use nom::{ + error::{Error, ErrorKind}, + Err, IResult, +}; + +/// Consume a single argument from an argument list that matches the given string parser (one +/// of the other functions in this module). The given parser must consume the entire input. +pub(crate) fn arg_matching<'a, O, F>(f: F) -> impl Fn(ArgList<'a>) -> IResult +where + F: Fn(&'a str) -> IResult<&'a str, O>, +{ + move |input: ArgList<'a>| { + if let Some(arg) = input.get(0) { + return match f(arg) { + Ok(("", rv)) => Ok((&input[1..], rv)), + // single-arg parsers must consume the entire arg + Ok((unconsumed, _)) => panic!("unconsumed argument input {}", unconsumed), + // single-arg parsers are all complete parsers + Err(Err::Incomplete(_)) => unreachable!(), + // for error and failure, rewrite to an error at this position in the arugment list + Err(Err::Error(Error { input: _, code })) => Err(Err::Error(Error { input, code })), + Err(Err::Failure(Error { input: _, code })) => { + Err(Err::Failure(Error { input, code })) + } + }; + } + + Err(Err::Error(Error { + input, + // since we're using nom's built-in Error, our choices here are limited, but tihs + // occurs when there's no argument where one is expected, so Eof seems appropriate + code: ErrorKind::Eof, + })) + } +} + +#[cfg(test)] +mod test { + use super::super::*; + use super::*; + + #[test] + fn test_arg_matching() { + assert_eq!( + arg_matching(plus_tag)(argv!["+foo", "bar"]).unwrap(), + (argv!["bar"], "foo") + ); + assert!(arg_matching(plus_tag)(argv!["foo", "bar"]).is_err()); + } +} diff --git a/cli/src/argparse/args/colon.rs b/cli/src/argparse/args/colon.rs new file mode 100644 index 000000000..3fa17ce97 --- /dev/null +++ b/cli/src/argparse/args/colon.rs @@ -0,0 +1,85 @@ +use super::{any, timestamp}; +use crate::argparse::NOW; +use chrono::prelude::*; +use nom::bytes::complete::tag as nomtag; +use nom::{branch::*, character::complete::*, combinator::*, sequence::*, IResult}; +use taskchampion::Status; + +/// Recognizes up to the colon of the common `:...` syntax +fn colon_prefix(prefix: &'static str) -> impl Fn(&str) -> IResult<&str, &str> { + fn to_suffix<'a>(input: (&'a str, char, &'a str)) -> Result<&'a str, ()> { + Ok(input.2) + } + move |input: &str| { + map_res( + all_consuming(tuple((nomtag(prefix), char(':'), any))), + to_suffix, + )(input) + } +} + +/// Recognizes `status:{pending,completed,deleted}` +pub(crate) fn status_colon(input: &str) -> IResult<&str, Status> { + fn to_status(input: &str) -> Result { + match input { + "pending" => Ok(Status::Pending), + "completed" => Ok(Status::Completed), + "deleted" => Ok(Status::Deleted), + _ => Err(()), + } + } + map_res(colon_prefix("status"), to_status)(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>, ()> { + Ok(Some(input)) + } + fn to_none(_: &str) -> Result>, ()> { + Ok(None) + } + preceded( + nomtag("wait:"), + alt(( + map_res(timestamp(*NOW, Local), to_wait), + map_res(nomtag(""), to_none), + )), + )(input) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_colon_prefix() { + assert_eq!(colon_prefix("foo")("foo:abc").unwrap().1, "abc"); + assert_eq!(colon_prefix("foo")("foo:").unwrap().1, ""); + assert!(colon_prefix("foo")("foo").is_err()); + } + + #[test] + fn test_status_colon() { + assert_eq!(status_colon("status:pending").unwrap().1, Status::Pending); + assert_eq!( + status_colon("status:completed").unwrap().1, + Status::Completed + ); + assert_eq!(status_colon("status:deleted").unwrap().1, Status::Deleted); + assert!(status_colon("status:foo").is_err()); + assert!(status_colon("status:complete").is_err()); + assert!(status_colon("status").is_err()); + } + + #[test] + fn test_wait() { + assert_eq!(wait_colon("wait:").unwrap(), ("", None)); + + let one_day = *NOW + chrono::Duration::days(1); + assert_eq!(wait_colon("wait:1d").unwrap(), ("", Some(one_day))); + + let one_day = *NOW + chrono::Duration::days(1); + assert_eq!(wait_colon("wait:1d2").unwrap(), ("2", Some(one_day))); + } +} diff --git a/cli/src/argparse/args/idlist.rs b/cli/src/argparse/args/idlist.rs new file mode 100644 index 000000000..f8c09ae04 --- /dev/null +++ b/cli/src/argparse/args/idlist.rs @@ -0,0 +1,139 @@ +use nom::{branch::*, character::complete::*, combinator::*, multi::*, sequence::*, IResult}; +use taskchampion::Uuid; + +/// A task identifier, as given in a filter command-line expression +#[derive(Debug, PartialEq, Clone)] +pub(crate) enum TaskId { + /// A small integer identifying a working-set task + WorkingSetId(usize), + + /// A full Uuid specifically identifying a task + Uuid(Uuid), + + /// A prefix of a Uuid + PartialUuid(String), +} + +/// Recognizes a comma-separated list of TaskIds +pub(crate) fn id_list(input: &str) -> IResult<&str, Vec> { + fn hex_n(n: usize) -> impl Fn(&str) -> IResult<&str, &str> { + move |input: &str| recognize(many_m_n(n, n, one_of(&b"0123456789abcdefABCDEF"[..])))(input) + } + fn uuid(input: &str) -> Result { + Ok(TaskId::Uuid(Uuid::parse_str(input).map_err(|_| ())?)) + } + fn partial_uuid(input: &str) -> Result { + Ok(TaskId::PartialUuid(input.to_owned())) + } + fn working_set_id(input: &str) -> Result { + Ok(TaskId::WorkingSetId(input.parse().map_err(|_| ())?)) + } + all_consuming(separated_list1( + char(','), + alt(( + map_res( + recognize(tuple(( + hex_n(8), + char('-'), + hex_n(4), + char('-'), + hex_n(4), + char('-'), + hex_n(4), + char('-'), + hex_n(12), + ))), + uuid, + ), + map_res( + recognize(tuple(( + hex_n(8), + char('-'), + hex_n(4), + char('-'), + hex_n(4), + char('-'), + hex_n(4), + ))), + partial_uuid, + ), + map_res( + recognize(tuple((hex_n(8), char('-'), hex_n(4), char('-'), hex_n(4)))), + partial_uuid, + ), + map_res( + recognize(tuple((hex_n(8), char('-'), hex_n(4)))), + partial_uuid, + ), + map_res(hex_n(8), partial_uuid), + // note that an 8-decimal-digit value will be treated as a UUID + map_res(digit1, working_set_id), + )), + ))(input) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_id_list_single() { + assert_eq!(id_list("123").unwrap().1, vec![TaskId::WorkingSetId(123)]); + } + + #[test] + fn test_id_list_uuids() { + assert_eq!( + id_list("12341234").unwrap().1, + vec![TaskId::PartialUuid(s!("12341234"))] + ); + assert_eq!( + id_list("1234abcd").unwrap().1, + vec![TaskId::PartialUuid(s!("1234abcd"))] + ); + assert_eq!( + id_list("abcd1234").unwrap().1, + vec![TaskId::PartialUuid(s!("abcd1234"))] + ); + assert_eq!( + id_list("abcd1234-1234").unwrap().1, + vec![TaskId::PartialUuid(s!("abcd1234-1234"))] + ); + assert_eq!( + id_list("abcd1234-1234-2345").unwrap().1, + vec![TaskId::PartialUuid(s!("abcd1234-1234-2345"))] + ); + assert_eq!( + id_list("abcd1234-1234-2345-3456").unwrap().1, + vec![TaskId::PartialUuid(s!("abcd1234-1234-2345-3456"))] + ); + assert_eq!( + id_list("abcd1234-1234-2345-3456-0123456789ab").unwrap().1, + vec![TaskId::Uuid( + Uuid::parse_str("abcd1234-1234-2345-3456-0123456789ab").unwrap() + )] + ); + } + + #[test] + fn test_id_list_invalid_partial_uuids() { + assert!(id_list("abcd123").is_err()); + assert!(id_list("abcd12345").is_err()); + assert!(id_list("abcd1234-").is_err()); + assert!(id_list("abcd1234-123").is_err()); + assert!(id_list("abcd1234-1234-").is_err()); + assert!(id_list("abcd1234-12345-").is_err()); + assert!(id_list("abcd1234-1234-2345-3456-0123456789ab-").is_err()); + } + + #[test] + fn test_id_list_uuids_mixed() { + assert_eq!(id_list("abcd1234,abcd1234-1234,abcd1234-1234-2345,abcd1234-1234-2345-3456,abcd1234-1234-2345-3456-0123456789ab").unwrap().1, + vec![TaskId::PartialUuid(s!("abcd1234")), + TaskId::PartialUuid(s!("abcd1234-1234")), + TaskId::PartialUuid(s!("abcd1234-1234-2345")), + TaskId::PartialUuid(s!("abcd1234-1234-2345-3456")), + TaskId::Uuid(Uuid::parse_str("abcd1234-1234-2345-3456-0123456789ab").unwrap()), + ]); + } +} diff --git a/cli/src/argparse/args/misc.rs b/cli/src/argparse/args/misc.rs new file mode 100644 index 000000000..006a0b939 --- /dev/null +++ b/cli/src/argparse/args/misc.rs @@ -0,0 +1,41 @@ +use nom::bytes::complete::tag as nomtag; +use nom::{character::complete::*, combinator::*, sequence::*, IResult}; + +/// Recognizes any argument +pub(crate) fn any(input: &str) -> IResult<&str, &str> { + rest(input) +} + +/// Recognizes a report name +pub(crate) fn report_name(input: &str) -> IResult<&str, &str> { + all_consuming(recognize(pair(alpha1, alphanumeric0)))(input) +} + +/// Recognizes a literal string +pub(crate) fn literal(literal: &'static str) -> impl Fn(&str) -> IResult<&str, &str> { + move |input: &str| all_consuming(nomtag(literal))(input) +} + +#[cfg(test)] +mod test { + use super::super::*; + use super::*; + + #[test] + fn test_arg_matching() { + assert_eq!( + arg_matching(plus_tag)(argv!["+foo", "bar"]).unwrap(), + (argv!["bar"], "foo") + ); + assert!(arg_matching(plus_tag)(argv!["foo", "bar"]).is_err()); + } + + #[test] + fn test_literal() { + assert_eq!(literal("list")("list").unwrap().1, "list"); + assert!(literal("list")("listicle").is_err()); + assert!(literal("list")(" list ").is_err()); + assert!(literal("list")("LiSt").is_err()); + assert!(literal("list")("denylist").is_err()); + } +} diff --git a/cli/src/argparse/args/mod.rs b/cli/src/argparse/args/mod.rs new file mode 100644 index 000000000..f7124fa29 --- /dev/null +++ b/cli/src/argparse/args/mod.rs @@ -0,0 +1,16 @@ +//! Parsers for single arguments (strings) + +mod arg_matching; +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/tags.rs b/cli/src/argparse/args/tags.rs new file mode 100644 index 000000000..8c2cbd9c1 --- /dev/null +++ b/cli/src/argparse/args/tags.rs @@ -0,0 +1,56 @@ +use nom::{character::complete::*, combinator::*, sequence::*, IResult}; +use std::convert::TryFrom; +use taskchampion::Tag; + +/// Recognizes a tag prefixed with `+` and returns the tag value +pub(crate) fn plus_tag(input: &str) -> IResult<&str, &str> { + fn to_tag(input: (char, &str)) -> Result<&str, ()> { + Ok(input.1) + } + map_res( + all_consuming(tuple(( + char('+'), + recognize(verify(rest, |s: &str| Tag::try_from(s).is_ok())), + ))), + to_tag, + )(input) +} + +/// Recognizes a tag prefixed with `-` and returns the tag value +pub(crate) fn minus_tag(input: &str) -> IResult<&str, &str> { + fn to_tag(input: (char, &str)) -> Result<&str, ()> { + Ok(input.1) + } + map_res( + all_consuming(tuple(( + char('-'), + recognize(verify(rest, |s: &str| Tag::try_from(s).is_ok())), + ))), + to_tag, + )(input) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_plus_tag() { + assert_eq!(plus_tag("+abc").unwrap().1, "abc"); + assert_eq!(plus_tag("+abc123").unwrap().1, "abc123"); + assert!(plus_tag("-abc123").is_err()); + assert!(plus_tag("+abc123 ").is_err()); + assert!(plus_tag(" +abc123").is_err()); + assert!(plus_tag("+1abc").is_err()); + } + + #[test] + fn test_minus_tag() { + assert_eq!(minus_tag("-abc").unwrap().1, "abc"); + assert_eq!(minus_tag("-abc123").unwrap().1, "abc123"); + assert!(minus_tag("+abc123").is_err()); + assert!(minus_tag("-abc123 ").is_err()); + assert!(minus_tag(" -abc123").is_err()); + assert!(minus_tag("-1abc").is_err()); + } +} diff --git a/cli/src/argparse/args/time.rs b/cli/src/argparse/args/time.rs new file mode 100644 index 000000000..4581d0a41 --- /dev/null +++ b/cli/src/argparse/args/time.rs @@ -0,0 +1,466 @@ +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("days", Duration::days(1), false), + DurationCase("day", Duration::days(1), true), + DurationCase("d", Duration::days(1), false), + DurationCase("hours", Duration::hours(1), false), + DurationCase("hour", 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("months", Duration::days(30), false), + DurationCase("month", Duration::days(30), true), + DurationCase("mo", Duration::days(30), true), + DurationCase("seconds", Duration::seconds(1), false), + DurationCase("second", Duration::seconds(1), true), + DurationCase("s", Duration::seconds(1), false), + DurationCase("weeks", Duration::days(7), false), + DurationCase("week", Duration::days(7), true), + DurationCase("w", Duration::days(7), false), + DurationCase("years", Duration::days(365), false), + DurationCase("year", 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("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("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("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("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)] + 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 88de59046..7f2607631 100644 --- a/cli/src/argparse/mod.rs +++ b/cli/src/argparse/mod.rs @@ -31,6 +31,13 @@ pub(crate) use modification::{DescriptionMod, Modification}; pub(crate) use subcommand::Subcommand; use crate::usage::Usage; +use chrono::prelude::*; +use lazy_static::lazy_static; + +lazy_static! { + // A static value of NOW to make tests easier + pub(crate) static ref NOW: DateTime = Utc::now(); +} type ArgList<'a> = &'a [&'a str]; diff --git a/cli/src/argparse/modification.rs b/cli/src/argparse/modification.rs index 9628f2352..083f7fc8e 100644 --- a/cli/src/argparse/modification.rs +++ b/cli/src/argparse/modification.rs @@ -1,6 +1,7 @@ -use super::args::{any, arg_matching, minus_tag, plus_tag}; +use super::args::{any, arg_matching, minus_tag, plus_tag, wait_colon}; use super::ArgList; use crate::usage; +use chrono::prelude::*; use nom::{branch::alt, combinator::*, multi::fold_many0, IResult}; use std::collections::HashSet; use taskchampion::Status; @@ -36,6 +37,9 @@ pub struct Modification { /// Set the status pub status: Option, + /// Set (or, with `Some(None)`, clear) the wait timestamp + pub wait: Option>>, + /// Set the "active" state, that is, start (true) or stop (false) the task. pub active: Option, @@ -51,6 +55,7 @@ enum ModArg<'a> { Description(&'a str), PlusTag(&'a str), MinusTag(&'a str), + Wait(Option>), } impl Modification { @@ -71,6 +76,9 @@ impl Modification { ModArg::MinusTag(tag) => { acc.remove_tags.insert(tag.to_owned()); } + ModArg::Wait(wait) => { + acc.wait = Some(wait); + } } acc } @@ -78,6 +86,7 @@ impl Modification { alt(( Self::plus_tag, Self::minus_tag, + Self::wait, // this must come last Self::description, )), @@ -109,6 +118,13 @@ impl Modification { map_res(arg_matching(minus_tag), to_modarg)(input) } + fn wait(input: ArgList) -> IResult { + fn to_modarg(input: Option>) -> Result, ()> { + Ok(ModArg::Wait(input)) + } + map_res(arg_matching(wait_colon), to_modarg)(input) + } + pub(super) fn get_usage(u: &mut usage::Usage) { u.modifications.push(usage::Modification { syntax: "DESCRIPTION", @@ -122,14 +138,25 @@ impl Modification { u.modifications.push(usage::Modification { syntax: "+TAG", summary: "Tag task", - description: " - Add the given tag to the task.", + description: "Add the given tag to the task.", }); u.modifications.push(usage::Modification { syntax: "-TAG", summary: "Un-tag task", + description: "Remove the given tag from the task.", + }); + u.modifications.push(usage::Modification { + syntax: "status:{pending,completed,deleted}", + summary: "Set the task's status", + description: "Set the status of the task explicitly.", + }); + u.modifications.push(usage::Modification { + syntax: "wait:", + summary: "Set or unset the task's wait time", description: " - Remove the given tag from the task.", + Set the time before which the task is not actionable and should not be shown in + reports, e.g., `wait:3day` to wait for three days. With `wait:`, the time is + un-set. See the documentation for the timestamp syntax.", }); } } @@ -137,6 +164,7 @@ impl Modification { #[cfg(test)] mod test { use super::*; + use crate::argparse::NOW; #[test] fn test_empty() { @@ -176,6 +204,32 @@ mod test { ); } + #[test] + fn test_set_wait() { + let (input, modification) = Modification::parse(argv!["wait:2d"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + modification, + Modification { + wait: Some(Some(*NOW + chrono::Duration::days(2))), + ..Default::default() + } + ); + } + + #[test] + fn test_unset_wait() { + let (input, modification) = Modification::parse(argv!["wait:"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + modification, + Modification { + wait: Some(None), + ..Default::default() + } + ); + } + #[test] fn test_multi_arg_description() { let (input, modification) = Modification::parse(argv!["new", "desc", "fun"]).unwrap(); diff --git a/cli/src/invocation/cmd/info.rs b/cli/src/invocation/cmd/info.rs index 5d26213d1..c77476a69 100644 --- a/cli/src/invocation/cmd/info.rs +++ b/cli/src/invocation/cmd/info.rs @@ -36,6 +36,9 @@ pub(crate) fn execute( tags.sort(); t.add_row(row![b->"Tags", tags.join(" ")]); } + if let Some(wait) = task.get_wait() { + t.add_row(row![b->"Wait", wait]); + } } t.print(w)?; } diff --git a/cli/src/invocation/modify.rs b/cli/src/invocation/modify.rs index cbbb65e1c..7ef6d758c 100644 --- a/cli/src/invocation/modify.rs +++ b/cli/src/invocation/modify.rs @@ -40,5 +40,9 @@ pub(super) fn apply_modification( task.remove_tag(&tag)?; } + if let Some(wait) = modification.wait { + task.set_wait(wait)?; + } + Ok(()) } diff --git a/cli/src/invocation/report.rs b/cli/src/invocation/report.rs index 512866381..cf4008562 100644 --- a/cli/src/invocation/report.rs +++ b/cli/src/invocation/report.rs @@ -27,6 +27,7 @@ fn sort_tasks(tasks: &mut Vec, report: &Report, working_set: &WorkingSet) } SortBy::Uuid => a.get_uuid().cmp(&b.get_uuid()), SortBy::Description => a.get_description().cmp(b.get_description()), + SortBy::Wait => a.get_wait().cmp(&b.get_wait()), }; // If this sort property is equal, go on to the next.. if ord == Ordering::Equal { @@ -71,6 +72,13 @@ fn task_column(task: &Task, column: &Column, working_set: &WorkingSet) -> String tags.sort(); tags.join(" ") } + Property::Wait => { + if task.is_waiting() { + task.get_wait().unwrap().format("%Y-%m-%d").to_string() + } else { + "".to_owned() + } + } } } @@ -124,6 +132,7 @@ mod test { use super::*; use crate::invocation::test::*; use crate::settings::Sort; + use chrono::prelude::*; use std::convert::TryInto; use taskchampion::{Status, Uuid}; @@ -217,6 +226,50 @@ mod test { assert_eq!(got_uuids, exp_uuids); } + #[test] + fn sorting_by_wait() { + let mut replica = test_replica(); + let uuids = create_tasks(&mut replica); + + replica + .get_task(uuids[0]) + .unwrap() + .unwrap() + .into_mut(&mut replica) + .set_wait(Some(Utc::now() + chrono::Duration::days(2))) + .unwrap(); + + replica + .get_task(uuids[1]) + .unwrap() + .unwrap() + .into_mut(&mut replica) + .set_wait(Some(Utc::now() + chrono::Duration::days(3))) + .unwrap(); + + let working_set = replica.working_set().unwrap(); + + let report = Report { + sort: vec![Sort { + ascending: true, + sort_by: SortBy::Wait, + }], + ..Default::default() + }; + + let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect(); + sort_tasks(&mut tasks, &report, &working_set); + let got_uuids: Vec<_> = tasks.iter().map(|t| t.get_uuid()).collect(); + + let exp_uuids = vec![ + uuids[2], // no wait + uuids[0], // wait:2d + uuids[1], // wait:3d + ]; + + assert_eq!(got_uuids, exp_uuids); + } + #[test] fn sorting_by_multiple() { let mut replica = test_replica(); diff --git a/cli/src/settings/report.rs b/cli/src/settings/report.rs index f73dfe58d..1911bf84e 100644 --- a/cli/src/settings/report.rs +++ b/cli/src/settings/report.rs @@ -46,6 +46,9 @@ pub(crate) enum Property { /// The task's tags Tags, + + /// The task's wait date + Wait, } /// A sorting criterion for a sort operation. @@ -71,6 +74,9 @@ pub(crate) enum SortBy { /// The task's description Description, + + /// The task's wait date + Wait, } // Conversions from settings::Settings. @@ -174,6 +180,7 @@ impl TryFrom<&toml::Value> for Property { "active" => Property::Active, "description" => Property::Description, "tags" => Property::Tags, + "wait" => Property::Wait, _ => bail!(": unknown property {}", s), }) } @@ -210,6 +217,7 @@ impl TryFrom<&toml::Value> for SortBy { "id" => SortBy::Id, "uuid" => SortBy::Uuid, "description" => SortBy::Description, + "wait" => SortBy::Wait, _ => bail!(": unknown sort_by value `{}`", s), }) } @@ -231,6 +239,11 @@ pub(crate) fn get_usage(u: &mut Usage) { as_sort_by: None, as_column: Some("`*` if the task is active (started)"), }); + u.report_properties.push(usage::ReportProperty { + name: "wait", + as_sort_by: Some("Sort by the task's wait date, with non-waiting tasks first"), + as_column: Some("Wait date of the task"), + }); u.report_properties.push(usage::ReportProperty { name: "description", as_sort_by: Some("Sort by the task's description"), diff --git a/cli/src/settings/settings.rs b/cli/src/settings/settings.rs index b17750fcb..955520299 100644 --- a/cli/src/settings/settings.rs +++ b/cli/src/settings/settings.rs @@ -218,6 +218,10 @@ impl Default for Settings { label: "tags".to_owned(), property: Property::Tags, }, + Column { + label: "wait".to_owned(), + property: Property::Wait, + }, ], filter: Default::default(), }, 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/tasks.md b/docs/src/tasks.md index 8bdf8fa72..ae354c62c 100644 --- a/docs/src/tasks.md +++ b/docs/src/tasks.md @@ -32,6 +32,7 @@ The following keys, and key formats, are defined: * `modified` - the time of the last modification of this task * `start.` - either an empty string (representing work on the task to the task that has not been stopped) or a timestamp (representing the time that work stopped) * `tag.` - indicates this task has tag `` (value is an empty string) +* `wait` - indicates the time before which this task should be hidden, as it is not actionable The following are not yet implemented: diff --git a/docs/src/time.md b/docs/src/time.md new file mode 100644 index 000000000..b82e1b2e1 --- /dev/null +++ b/docs/src/time.md @@ -0,0 +1,30 @@ +## 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 the _local_ midnight at the beginning of 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 the _local_ midnight at the beginning of 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`, `second`, or `seconds` + * `min`, `mins`, `minute`, or `minutes` (note that `m` not allowed, as it might also mean `month`) + * `h`, `hour`, or `hours` + * `d`, `day`, or `days` + * `w`, `week`, or `weeks` + * `mo`, or `months` (always 30 days, regardless of calendar month) + * `y`, `year`, or `years` (365 days, regardless of leap 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. diff --git a/taskchampion/src/task.rs b/taskchampion/src/task.rs index 07bb19909..2cebf11ae 100644 --- a/taskchampion/src/task.rs +++ b/taskchampion/src/task.rs @@ -211,6 +211,20 @@ impl Task { .unwrap_or("") } + /// Get the wait time. If this value is set, it will be returned, even + /// if it is in the past. + pub fn get_wait(&self) -> Option> { + self.get_timestamp("wait") + } + + /// Determine whether this task is waiting now. + pub fn is_waiting(&self) -> bool { + if let Some(ts) = self.get_wait() { + return ts > Utc::now(); + } + false + } + /// Determine whether this task is active -- that is, that it has been started /// and not stopped. pub fn is_active(&self) -> bool { @@ -275,6 +289,10 @@ impl<'r> TaskMut<'r> { self.set_string("description", Some(description)) } + pub fn set_wait(&mut self, wait: Option>) -> anyhow::Result<()> { + self.set_timestamp("wait", wait) + } + pub fn set_modified(&mut self, modified: DateTime) -> anyhow::Result<()> { self.set_timestamp("modified", Some(modified)) } @@ -452,6 +470,43 @@ mod test { assert!(!task.is_active()); } + #[test] + fn test_wait_not_set() { + let task = Task::new(Uuid::new_v4(), TaskMap::new()); + + assert!(!task.is_waiting()); + assert_eq!(task.get_wait(), None); + } + + #[test] + fn test_wait_in_past() { + let ts = Utc.ymd(1970, 1, 1).and_hms(0, 0, 0); + let task = Task::new( + Uuid::new_v4(), + vec![(String::from("wait"), format!("{}", ts.timestamp()))] + .drain(..) + .collect(), + ); + dbg!(&task); + + assert!(!task.is_waiting()); + assert_eq!(task.get_wait(), Some(ts)); + } + + #[test] + fn test_wait_in_future() { + let ts = Utc.ymd(3000, 1, 1).and_hms(0, 0, 0); + let task = Task::new( + Uuid::new_v4(), + vec![(String::from("wait"), format!("{}", ts.timestamp()))] + .drain(..) + .collect(), + ); + + assert!(task.is_waiting()); + assert_eq!(task.get_wait(), Some(ts)); + } + #[test] fn test_has_tag() { let task = Task::new(