move contents of taskchampion repo to tc/
This commit is contained in:
61
rust/cli/src/argparse/args/arg_matching.rs
Normal file
61
rust/cli/src/argparse/args/arg_matching.rs
Normal file
@@ -0,0 +1,61 @@
|
||||
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<ArgList, O>
|
||||
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, so consider unconsumed
|
||||
// output to be an error.
|
||||
Ok((_, _)) => Err(Err::Error(Error {
|
||||
input,
|
||||
code: ErrorKind::Eof,
|
||||
})),
|
||||
// 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::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_arg_matching() {
|
||||
assert_eq!(
|
||||
arg_matching(plus_tag)(argv!["+foo", "bar"]).unwrap(),
|
||||
(argv!["bar"], tag!("foo"))
|
||||
);
|
||||
assert!(arg_matching(plus_tag)(argv!["foo", "bar"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_partial_arg_matching() {
|
||||
assert!(arg_matching(wait_colon)(argv!["wait:UNRECOGNIZED"]).is_err());
|
||||
}
|
||||
}
|
||||
98
rust/cli/src/argparse/args/colon.rs
Normal file
98
rust/cli/src/argparse/args/colon.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
use super::{any, id_list, timestamp, TaskId};
|
||||
use crate::argparse::NOW;
|
||||
use nom::bytes::complete::tag as nomtag;
|
||||
use nom::{branch::*, character::complete::*, combinator::*, sequence::*, IResult};
|
||||
use taskchampion::chrono::prelude::*;
|
||||
use taskchampion::Status;
|
||||
|
||||
/// Recognizes up to the colon of the common `<prefix>:...` 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<Status, ()> {
|
||||
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:<ts>` to `Some(ts)`
|
||||
pub(crate) fn wait_colon(input: &str) -> IResult<&str, Option<DateTime<Utc>>> {
|
||||
fn to_wait(input: DateTime<Utc>) -> Result<Option<DateTime<Utc>>, ()> {
|
||||
Ok(Some(input))
|
||||
}
|
||||
fn to_none(_: &str) -> Result<Option<DateTime<Utc>>, ()> {
|
||||
Ok(None)
|
||||
}
|
||||
preceded(
|
||||
nomtag("wait:"),
|
||||
alt((
|
||||
map_res(timestamp(*NOW, Local), to_wait),
|
||||
map_res(nomtag(""), to_none),
|
||||
)),
|
||||
)(input)
|
||||
}
|
||||
|
||||
/// Recognizes `depends:<task>` to `(true, <task>)` and `depends:-<task>` to `(false, <task>)`.
|
||||
pub(crate) fn depends_colon(input: &str) -> IResult<&str, (bool, Vec<TaskId>)> {
|
||||
fn to_bool(maybe_minus: Option<char>) -> Result<bool, ()> {
|
||||
Ok(maybe_minus.is_none()) // None -> true, Some -> false
|
||||
}
|
||||
preceded(
|
||||
nomtag("depends:"),
|
||||
pair(map_res(opt(char('-')), to_bool), id_list),
|
||||
)(input)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use taskchampion::chrono::Duration;
|
||||
|
||||
#[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 + Duration::days(1);
|
||||
assert_eq!(wait_colon("wait:1d").unwrap(), ("", Some(one_day)));
|
||||
|
||||
let one_day = *NOW + Duration::days(1);
|
||||
assert_eq!(wait_colon("wait:1d2").unwrap(), ("2", Some(one_day)));
|
||||
}
|
||||
}
|
||||
140
rust/cli/src/argparse/args/idlist.rs
Normal file
140
rust/cli/src/argparse/args/idlist.rs
Normal file
@@ -0,0 +1,140 @@
|
||||
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, Eq, Hash, 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<TaskId>> {
|
||||
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<TaskId, ()> {
|
||||
Ok(TaskId::Uuid(Uuid::parse_str(input).map_err(|_| ())?))
|
||||
}
|
||||
fn partial_uuid(input: &str) -> Result<TaskId, ()> {
|
||||
Ok(TaskId::PartialUuid(input.to_owned()))
|
||||
}
|
||||
fn working_set_id(input: &str) -> Result<TaskId, ()> {
|
||||
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::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[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()),
|
||||
]);
|
||||
}
|
||||
}
|
||||
42
rust/cli/src/argparse/args/misc.rs
Normal file
42
rust/cli/src/argparse/args/misc.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
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::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_arg_matching() {
|
||||
assert_eq!(
|
||||
arg_matching(plus_tag)(argv!["+foo", "bar"]).unwrap(),
|
||||
(argv!["bar"], tag!("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());
|
||||
}
|
||||
}
|
||||
16
rust/cli/src/argparse/args/mod.rs
Normal file
16
rust/cli/src/argparse/args/mod.rs
Normal file
@@ -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::{depends_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};
|
||||
35
rust/cli/src/argparse/args/tags.rs
Normal file
35
rust/cli/src/argparse/args/tags.rs
Normal file
@@ -0,0 +1,35 @@
|
||||
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, Tag> {
|
||||
preceded(char('+'), map_res(rest, Tag::try_from))(input)
|
||||
}
|
||||
|
||||
/// Recognizes a tag prefixed with `-` and returns the tag value
|
||||
pub(crate) fn minus_tag(input: &str) -> IResult<&str, Tag> {
|
||||
preceded(char('-'), map_res(rest, Tag::try_from))(input)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_plus_tag() {
|
||||
assert_eq!(plus_tag("+abc").unwrap().1, tag!("abc"));
|
||||
assert_eq!(plus_tag("+abc123").unwrap().1, tag!("abc123"));
|
||||
assert!(plus_tag("-abc123").is_err());
|
||||
assert!(plus_tag("+1abc").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_minus_tag() {
|
||||
assert_eq!(minus_tag("-abc").unwrap().1, tag!("abc"));
|
||||
assert_eq!(minus_tag("-abc123").unwrap().1, tag!("abc123"));
|
||||
assert!(minus_tag("+abc123").is_err());
|
||||
assert!(minus_tag("-1abc").is_err());
|
||||
}
|
||||
}
|
||||
492
rust/cli/src/argparse/args/time.rs
Normal file
492
rust/cli/src/argparse/args/time.rs
Normal file
@@ -0,0 +1,492 @@
|
||||
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;
|
||||
use taskchampion::chrono::{self, prelude::*, Duration};
|
||||
|
||||
// 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<DurationCase> = 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<f64, <f64 as FromStr>::Err> {
|
||||
let mul = input.parse::<f64>()?;
|
||||
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<Duration, ()> {
|
||||
// `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<Utc>> {
|
||||
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<Tz: TimeZone>(
|
||||
now: DateTime<Utc>,
|
||||
local: Tz,
|
||||
) -> impl Fn(&str) -> IResult<&str, DateTime<Utc>> {
|
||||
move |input: &str| {
|
||||
let local_today = now.with_timezone(&local).date();
|
||||
let remaining = &input[input.len()..];
|
||||
let day_index = local_today.weekday().num_days_from_monday();
|
||||
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!
|
||||
"eod" => Ok((remaining, local_today + Duration::days(1))),
|
||||
"sod" => Ok((remaining, local_today)),
|
||||
"eow" => Ok((
|
||||
remaining,
|
||||
local_today + Duration::days((6 - day_index).into()),
|
||||
)),
|
||||
"eoww" => Ok((
|
||||
remaining,
|
||||
local_today + Duration::days((5 - day_index).into()),
|
||||
)),
|
||||
"sow" => Ok((
|
||||
remaining,
|
||||
local_today + Duration::days((6 - day_index).into()),
|
||||
)),
|
||||
"soww" => Ok((
|
||||
remaining,
|
||||
local_today + Duration::days((7 - day_index).into()),
|
||||
)),
|
||||
_ => 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<Tz: TimeZone>(local: Tz) -> impl Fn(&str) -> IResult<&str, DateTime<Utc>> {
|
||||
move |input: &str| {
|
||||
fn parse_int<T: FromStr>(input: &str) -> Result<T, <T as FromStr>::Err> {
|
||||
input.parse::<T>()
|
||||
}
|
||||
map_res(
|
||||
tuple((
|
||||
map_res(recognize(count(digit, 4)), parse_int::<i32>),
|
||||
char('-'),
|
||||
map_res(recognize(many_m_n(1, 2, digit)), parse_int::<u32>),
|
||||
char('-'),
|
||||
map_res(recognize(many_m_n(1, 2, digit)), parse_int::<u32>),
|
||||
)),
|
||||
|input: (i32, char, u32, char, u32)| -> Result<DateTime<Utc>, ()> {
|
||||
// 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<Tz: TimeZone + Copy>(
|
||||
now: DateTime<Utc>,
|
||||
local: Tz,
|
||||
) -> impl Fn(&str) -> IResult<&str, DateTime<Utc>> {
|
||||
move |input: &str| {
|
||||
alt((
|
||||
// relative time
|
||||
map_res(
|
||||
duration,
|
||||
|duration: Duration| -> Result<DateTime<Utc>, ()> { 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 pretty_assertions::assert_eq;
|
||||
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<Tz: TimeZone + Copy>(
|
||||
now: DateTime<Utc>,
|
||||
local: Tz,
|
||||
) -> impl Fn(&str) -> IResult<&str, DateTime<Utc>> {
|
||||
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> {
|
||||
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<dyn Fn(FixedOffset) -> DateTime<Utc>> {
|
||||
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<dyn Fn(FixedOffset) -> DateTime<Utc>> {
|
||||
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<Utc>,
|
||||
#[case] input: &'static str,
|
||||
#[case] output: DateTime<Utc>,
|
||||
) {
|
||||
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))]
|
||||
#[case::end_of_week(ld(2021, 8, 25,), "eow", ld(2021, 8, 29))]
|
||||
#[case::end_of_work_week(ld(2021, 8, 25), "eoww", ld(2021, 8, 28))]
|
||||
#[case::start_of_week(ld(2021, 8, 25), "sow", ld(2021, 8, 29))]
|
||||
#[case::start_of_work_week(ld(2021, 8, 25), "soww", ld(2021, 8, 30))]
|
||||
#[case::end_of_today(ld(2021, 8, 25), "eod", ld(2021, 8, 26))]
|
||||
#[case::start_of_today(ld(2021, 8, 25), "sod", ld(2021, 8, 25))]
|
||||
fn test_local_timestamp(
|
||||
#[case] now: Box<dyn Fn(FixedOffset) -> DateTime<Utc>>,
|
||||
#[values(*IST, *UTC_FO, *HST)] tz: FixedOffset,
|
||||
#[case] input: &str,
|
||||
#[case] output: Box<dyn Fn(FixedOffset) -> DateTime<Utc>>,
|
||||
) {
|
||||
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<Utc>, #[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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user