mirror of
https://github.com/GothenburgBitFactory/taskwarrior.git
synced 2025-07-07 20:06:36 +02:00
Drop the 'taskchampion-cli' crate
It was fun while it lasted, but we'll be using TaskWarrior for the CLI!
This commit is contained in:
parent
2b9a389636
commit
c0ce1fe059
52 changed files with 21 additions and 8479 deletions
1397
Cargo.lock
generated
1397
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
@ -2,7 +2,6 @@
|
|||
|
||||
members = [
|
||||
"rust/taskchampion",
|
||||
"rust/cli",
|
||||
"rust/sync-server",
|
||||
"rust/lib",
|
||||
"rust/integration-tests",
|
||||
|
|
|
@ -1,57 +0,0 @@
|
|||
[package]
|
||||
authors = ["Dustin J. Mitchell <dustin@mozilla.com>"]
|
||||
edition = "2018"
|
||||
name = "taskchampion-cli"
|
||||
version = "0.4.1"
|
||||
|
||||
build = "build.rs"
|
||||
|
||||
# Run 'ta' when doing 'cargo run' at repo root
|
||||
default-run = "ta"
|
||||
|
||||
[dependencies]
|
||||
dirs-next = "^2.0.0"
|
||||
env_logger = "^0.8.3"
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
log = "^0.4.17"
|
||||
nom = "^6.1.2"
|
||||
prettytable-rs = "^0.8.0"
|
||||
textwrap = { version="^0.13.4", features=["terminal_size"] }
|
||||
termcolor = "^1.1.2"
|
||||
atty = "^0.2.14"
|
||||
toml = "^0.5.8"
|
||||
toml_edit = "^0.2.0"
|
||||
serde = { version = "^1.0.125", features = ["derive"] }
|
||||
serde_json = "^1.0"
|
||||
lazy_static = "1"
|
||||
iso8601-duration = "0.1"
|
||||
dialoguer = "0.10"
|
||||
|
||||
# only needed for usage-docs
|
||||
# if the mdbook version changes, change it in .github/workflows/publish-docs.yml and .github/workflows/checks.yml as well
|
||||
mdbook = { version = "0.4.10", optional = true }
|
||||
|
||||
[dependencies.taskchampion]
|
||||
path = "../taskchampion"
|
||||
|
||||
[build-dependencies]
|
||||
built = { version = "0.5", features = ["git2"] }
|
||||
|
||||
[dev-dependencies]
|
||||
assert_cmd = "^1.0.3"
|
||||
predicates = "^2.1.1"
|
||||
tempfile = "3"
|
||||
rstest = "0.10"
|
||||
pretty_assertions = "1"
|
||||
|
||||
[features]
|
||||
usage-docs = [ "mdbook" ]
|
||||
|
||||
[[bin]]
|
||||
name = "ta"
|
||||
|
||||
[[bin]]
|
||||
# this is an mdbook plugin and only needed when running `mdbook`
|
||||
name = "usage-docs"
|
||||
required-features = [ "usage-docs" ]
|
|
@ -1,3 +0,0 @@
|
|||
fn main() {
|
||||
built::write_built_file().expect("Failed to acquire build-time information");
|
||||
}
|
|
@ -1,61 +0,0 @@
|
|||
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());
|
||||
}
|
||||
}
|
|
@ -1,98 +0,0 @@
|
|||
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)));
|
||||
}
|
||||
}
|
|
@ -1,140 +0,0 @@
|
|||
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()),
|
||||
]);
|
||||
}
|
||||
}
|
|
@ -1,42 +0,0 @@
|
|||
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());
|
||||
}
|
||||
}
|
|
@ -1,16 +0,0 @@
|
|||
//! 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};
|
|
@ -1,35 +0,0 @@
|
|||
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());
|
||||
}
|
||||
}
|
|
@ -1,492 +0,0 @@
|
|||
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);
|
||||
}
|
||||
}
|
|
@ -1,86 +0,0 @@
|
|||
use super::args::*;
|
||||
use super::{ArgList, Subcommand};
|
||||
use nom::{combinator::*, sequence::*, Err, IResult};
|
||||
|
||||
/// A command is the overall command that the CLI should execute.
|
||||
///
|
||||
/// It consists of some information common to all commands and a `Subcommand` identifying the
|
||||
/// particular kind of behavior desired.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) struct Command {
|
||||
pub(crate) command_name: String,
|
||||
pub(crate) subcommand: Subcommand,
|
||||
}
|
||||
|
||||
impl Command {
|
||||
pub(super) fn parse(input: ArgList) -> IResult<ArgList, Command> {
|
||||
fn to_command(input: (&str, Subcommand)) -> Result<Command, ()> {
|
||||
// Clean up command name, so `./target/bin/ta` to `ta` etc
|
||||
let command_name: String = std::path::PathBuf::from(&input.0)
|
||||
.file_name()
|
||||
// Convert to string, very unlikely to contain non-UTF8
|
||||
.map(|x| x.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| input.0.to_owned());
|
||||
|
||||
let command = Command {
|
||||
command_name,
|
||||
subcommand: input.1,
|
||||
};
|
||||
Ok(command)
|
||||
}
|
||||
map_res(
|
||||
all_consuming(tuple((arg_matching(any), Subcommand::parse))),
|
||||
to_command,
|
||||
)(input)
|
||||
}
|
||||
|
||||
/// Parse a command from the given list of strings.
|
||||
pub fn from_argv(argv: &[&str]) -> Result<Command, crate::Error> {
|
||||
match Command::parse(argv) {
|
||||
Ok((&[], cmd)) => Ok(cmd),
|
||||
Ok((trailing, _)) => Err(crate::Error::for_arguments(format!(
|
||||
"command line has trailing arguments: {:?}",
|
||||
trailing
|
||||
))),
|
||||
Err(Err::Incomplete(_)) => unreachable!(),
|
||||
Err(Err::Error(e)) => Err(crate::Error::for_arguments(format!(
|
||||
"command line not recognized: {:?}",
|
||||
e
|
||||
))),
|
||||
Err(Err::Failure(e)) => Err(crate::Error::for_arguments(format!(
|
||||
"command line not recognized: {:?}",
|
||||
e
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
// NOTE: most testing of specific subcommands is handled in `subcommand.rs`.
|
||||
|
||||
#[test]
|
||||
fn test_version() {
|
||||
assert_eq!(
|
||||
Command::from_argv(argv!["ta", "version"]).unwrap(),
|
||||
Command {
|
||||
subcommand: Subcommand::Version,
|
||||
command_name: s!("ta"),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleaning_command_name() {
|
||||
assert_eq!(
|
||||
Command::from_argv(argv!["/tmp/ta", "version"]).unwrap(),
|
||||
Command {
|
||||
subcommand: Subcommand::Version,
|
||||
command_name: s!("ta"),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
use super::args::{any, arg_matching, literal};
|
||||
use super::ArgList;
|
||||
use crate::usage;
|
||||
use nom::{branch::alt, combinator::*, sequence::*, IResult};
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
/// A config operation
|
||||
pub(crate) enum ConfigOperation {
|
||||
/// Set a configuration value
|
||||
Set(String, String),
|
||||
/// Show configuration path
|
||||
Path,
|
||||
}
|
||||
|
||||
impl ConfigOperation {
|
||||
pub(super) fn parse(input: ArgList) -> IResult<ArgList, ConfigOperation> {
|
||||
fn set_to_op(input: (&str, &str, &str)) -> Result<ConfigOperation, ()> {
|
||||
Ok(ConfigOperation::Set(input.1.to_owned(), input.2.to_owned()))
|
||||
}
|
||||
fn path_to_op(_: &str) -> Result<ConfigOperation, ()> {
|
||||
Ok(ConfigOperation::Path)
|
||||
}
|
||||
alt((
|
||||
map_res(
|
||||
tuple((
|
||||
arg_matching(literal("set")),
|
||||
arg_matching(any),
|
||||
arg_matching(any),
|
||||
)),
|
||||
set_to_op,
|
||||
),
|
||||
map_res(arg_matching(literal("path")), path_to_op),
|
||||
))(input)
|
||||
}
|
||||
|
||||
pub(super) fn get_usage(u: &mut usage::Usage) {
|
||||
u.subcommands.push(usage::Subcommand {
|
||||
name: "config set",
|
||||
syntax: "config set <key> <value>",
|
||||
summary: "Set a configuration value",
|
||||
description: "Update Taskchampion configuration file to set key = value",
|
||||
});
|
||||
}
|
||||
}
|
|
@ -1,400 +0,0 @@
|
|||
use super::args::{arg_matching, id_list, literal, minus_tag, plus_tag, status_colon, TaskId};
|
||||
use super::ArgList;
|
||||
use crate::usage;
|
||||
use anyhow::bail;
|
||||
use nom::{
|
||||
branch::alt,
|
||||
combinator::*,
|
||||
multi::{fold_many0, fold_many1},
|
||||
IResult,
|
||||
};
|
||||
use taskchampion::{Status, Tag};
|
||||
|
||||
/// A filter represents a selection of a particular set of tasks.
|
||||
///
|
||||
/// A filter has a "universe" of tasks that might match, and a list of conditions
|
||||
/// all of which tasks must match. The universe can be a set of task IDs, or just
|
||||
/// pending tasks, or all tasks.
|
||||
#[derive(Debug, PartialEq, Default, Clone)]
|
||||
pub(crate) struct Filter {
|
||||
/// A set of filter conditions, all of which must match a task in order for that task to be
|
||||
/// selected.
|
||||
pub(crate) conditions: Vec<Condition>,
|
||||
}
|
||||
|
||||
/// A condition which tasks must match to be accepted by the filter.
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub(crate) enum Condition {
|
||||
/// Task has the given tag
|
||||
HasTag(Tag),
|
||||
|
||||
/// Task does not have the given tag
|
||||
NoTag(Tag),
|
||||
|
||||
/// Task has the given status
|
||||
Status(Status),
|
||||
|
||||
/// Task has one of the given IDs
|
||||
IdList(Vec<TaskId>),
|
||||
}
|
||||
|
||||
impl Condition {
|
||||
fn parse(input: ArgList) -> IResult<ArgList, Condition> {
|
||||
alt((
|
||||
Self::parse_id_list,
|
||||
Self::parse_plus_tag,
|
||||
Self::parse_minus_tag,
|
||||
Self::parse_status,
|
||||
))(input)
|
||||
}
|
||||
|
||||
/// Parse a single condition string
|
||||
pub(crate) fn parse_str(input: &str) -> anyhow::Result<Condition> {
|
||||
let input = &[input];
|
||||
Ok(match Condition::parse(input) {
|
||||
Ok((&[], cond)) => cond,
|
||||
Ok(_) => unreachable!(), // input only has one element
|
||||
Err(nom::Err::Incomplete(_)) => unreachable!(),
|
||||
Err(nom::Err::Error(e)) => bail!("invalid filter condition: {:?}", e),
|
||||
Err(nom::Err::Failure(e)) => bail!("invalid filter condition: {:?}", e),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_id_list(input: ArgList) -> IResult<ArgList, Condition> {
|
||||
fn to_condition(input: Vec<TaskId>) -> Result<Condition, ()> {
|
||||
Ok(Condition::IdList(input))
|
||||
}
|
||||
map_res(arg_matching(id_list), to_condition)(input)
|
||||
}
|
||||
|
||||
fn parse_plus_tag(input: ArgList) -> IResult<ArgList, Condition> {
|
||||
fn to_condition(input: Tag) -> Result<Condition, ()> {
|
||||
Ok(Condition::HasTag(input))
|
||||
}
|
||||
map_res(arg_matching(plus_tag), to_condition)(input)
|
||||
}
|
||||
|
||||
fn parse_minus_tag(input: ArgList) -> IResult<ArgList, Condition> {
|
||||
fn to_condition(input: Tag) -> Result<Condition, ()> {
|
||||
Ok(Condition::NoTag(input))
|
||||
}
|
||||
map_res(arg_matching(minus_tag), to_condition)(input)
|
||||
}
|
||||
|
||||
fn parse_status(input: ArgList) -> IResult<ArgList, Condition> {
|
||||
fn to_condition(input: Status) -> Result<Condition, ()> {
|
||||
Ok(Condition::Status(input))
|
||||
}
|
||||
map_res(arg_matching(status_colon), to_condition)(input)
|
||||
}
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
/// Parse a filter that can include an empty set of args (meaning
|
||||
/// all tasks)
|
||||
pub(super) fn parse0(input: ArgList) -> IResult<ArgList, Filter> {
|
||||
fold_many0(
|
||||
Condition::parse,
|
||||
Filter {
|
||||
..Default::default()
|
||||
},
|
||||
|acc, arg| acc.with_arg(arg),
|
||||
)(input)
|
||||
}
|
||||
|
||||
/// Parse a filter that must have at least one arg, which can be `all`
|
||||
/// to mean all tasks
|
||||
pub(super) fn parse1(input: ArgList) -> IResult<ArgList, Filter> {
|
||||
alt((
|
||||
Filter::parse_all,
|
||||
fold_many1(
|
||||
Condition::parse,
|
||||
Filter {
|
||||
..Default::default()
|
||||
},
|
||||
|acc, arg| acc.with_arg(arg),
|
||||
),
|
||||
))(input)
|
||||
}
|
||||
|
||||
fn parse_all(input: ArgList) -> IResult<ArgList, Filter> {
|
||||
fn to_filter(_: &str) -> Result<Filter, ()> {
|
||||
Ok(Filter {
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
map_res(arg_matching(literal("all")), to_filter)(input)
|
||||
}
|
||||
|
||||
/// fold multiple filter args into a single Filter instance
|
||||
fn with_arg(mut self, cond: Condition) -> Filter {
|
||||
if let Condition::IdList(mut id_list) = cond {
|
||||
// If there is already an IdList condition, concatenate this one
|
||||
// to it. Thus multiple IdList command-line args represent an OR
|
||||
// operation. This assumes that the filter is still being built
|
||||
// from command-line arguments and thus has at most one IdList
|
||||
// condition.
|
||||
if let Some(Condition::IdList(existing)) = self
|
||||
.conditions
|
||||
.iter_mut()
|
||||
.find(|c| matches!(c, Condition::IdList(_)))
|
||||
{
|
||||
existing.append(&mut id_list);
|
||||
} else {
|
||||
self.conditions.push(Condition::IdList(id_list));
|
||||
}
|
||||
} else {
|
||||
// all other command-line conditions are AND'd together
|
||||
self.conditions.push(cond);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// combine this filter with another filter in an AND operation
|
||||
pub(crate) fn intersect(mut self, mut other: Filter) -> Filter {
|
||||
// simply concatenate the conditions
|
||||
self.conditions.append(&mut other.conditions);
|
||||
|
||||
self
|
||||
}
|
||||
|
||||
// usage
|
||||
|
||||
pub(super) fn get_usage(u: &mut usage::Usage) {
|
||||
u.filters.push(usage::Filter {
|
||||
syntax: "TASKID[,TASKID,..]",
|
||||
summary: "Specific tasks",
|
||||
description: "
|
||||
Select only specific tasks. Multiple tasks can be specified either separated by
|
||||
commas or as separate arguments. Each task may be specfied by its working-set
|
||||
index (a small number) or by its UUID. Partial UUIDs, broken on a hyphen, are
|
||||
also supported, such as `b5664ef8-423d` or `b5664ef8`.",
|
||||
});
|
||||
u.filters.push(usage::Filter {
|
||||
syntax: "+TAG",
|
||||
summary: "Tagged tasks",
|
||||
description: "
|
||||
Select tasks with the given tag.",
|
||||
});
|
||||
u.filters.push(usage::Filter {
|
||||
syntax: "-TAG",
|
||||
summary: "Un-tagged tasks",
|
||||
description: "
|
||||
Select tasks that do not have the given tag.",
|
||||
});
|
||||
u.filters.push(usage::Filter {
|
||||
syntax: "status:pending, status:completed, status:deleted",
|
||||
summary: "Task status",
|
||||
description: "
|
||||
Select tasks with the given status.",
|
||||
});
|
||||
u.filters.push(usage::Filter {
|
||||
syntax: "all",
|
||||
summary: "All tasks",
|
||||
description: "
|
||||
When specified alone for task-modification commands, `all` matches all tasks.
|
||||
For example, `task all done` will mark all tasks as done.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_empty_parse0() {
|
||||
let (input, filter) = Filter::parse0(argv![]).unwrap();
|
||||
assert_eq!(input.len(), 0);
|
||||
assert_eq!(
|
||||
filter,
|
||||
Filter {
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_parse1() {
|
||||
// parse1 does not allow empty input
|
||||
assert!(Filter::parse1(argv![]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_parse0() {
|
||||
let (input, _) = Filter::parse0(argv!["all"]).unwrap();
|
||||
assert_eq!(input.len(), 1); // did not parse "all"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_parse1() {
|
||||
let (input, filter) = Filter::parse1(argv!["all"]).unwrap();
|
||||
assert_eq!(input.len(), 0);
|
||||
assert_eq!(
|
||||
filter,
|
||||
Filter {
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_all_with_other_stuff() {
|
||||
let (input, filter) = Filter::parse1(argv!["all", "+foo"]).unwrap();
|
||||
// filter ends after `all`
|
||||
assert_eq!(input.len(), 1);
|
||||
assert_eq!(
|
||||
filter,
|
||||
Filter {
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_id_list_single() {
|
||||
let (input, filter) = Filter::parse0(argv!["1"]).unwrap();
|
||||
assert_eq!(input.len(), 0);
|
||||
assert_eq!(
|
||||
filter,
|
||||
Filter {
|
||||
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(1)])],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_id_list_commas() {
|
||||
let (input, filter) = Filter::parse0(argv!["1,2,3"]).unwrap();
|
||||
assert_eq!(input.len(), 0);
|
||||
assert_eq!(
|
||||
filter,
|
||||
Filter {
|
||||
conditions: vec![Condition::IdList(vec![
|
||||
TaskId::WorkingSetId(1),
|
||||
TaskId::WorkingSetId(2),
|
||||
TaskId::WorkingSetId(3),
|
||||
])],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_id_list_multi_arg() {
|
||||
let (input, filter) = Filter::parse0(argv!["1,2", "3,4"]).unwrap();
|
||||
assert_eq!(input.len(), 0);
|
||||
assert_eq!(
|
||||
filter,
|
||||
Filter {
|
||||
conditions: vec![Condition::IdList(vec![
|
||||
TaskId::WorkingSetId(1),
|
||||
TaskId::WorkingSetId(2),
|
||||
TaskId::WorkingSetId(3),
|
||||
TaskId::WorkingSetId(4),
|
||||
])],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_id_list_uuids() {
|
||||
let (input, filter) = Filter::parse0(argv!["1,abcd1234"]).unwrap();
|
||||
assert_eq!(input.len(), 0);
|
||||
assert_eq!(
|
||||
filter,
|
||||
Filter {
|
||||
conditions: vec![Condition::IdList(vec![
|
||||
TaskId::WorkingSetId(1),
|
||||
TaskId::PartialUuid(s!("abcd1234")),
|
||||
])],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tags() {
|
||||
let (input, filter) = Filter::parse0(argv!["1", "+yes", "-no"]).unwrap();
|
||||
assert_eq!(input.len(), 0);
|
||||
assert_eq!(
|
||||
filter,
|
||||
Filter {
|
||||
conditions: vec![
|
||||
Condition::IdList(vec![TaskId::WorkingSetId(1),]),
|
||||
Condition::HasTag(tag!("yes")),
|
||||
Condition::NoTag(tag!("no")),
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_status() {
|
||||
let (input, filter) = Filter::parse0(argv!["status:completed", "status:pending"]).unwrap();
|
||||
assert_eq!(input.len(), 0);
|
||||
assert_eq!(
|
||||
filter,
|
||||
Filter {
|
||||
conditions: vec![
|
||||
Condition::Status(Status::Completed),
|
||||
Condition::Status(Status::Pending),
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersect_idlist_idlist() {
|
||||
let left = Filter::parse0(argv!["1,2", "+yes"]).unwrap().1;
|
||||
let right = Filter::parse0(argv!["2,3", "+no"]).unwrap().1;
|
||||
let both = left.intersect(right);
|
||||
assert_eq!(
|
||||
both,
|
||||
Filter {
|
||||
conditions: vec![
|
||||
// from first filter
|
||||
Condition::IdList(vec![TaskId::WorkingSetId(1), TaskId::WorkingSetId(2),]),
|
||||
Condition::HasTag(tag!("yes")),
|
||||
// from second filter
|
||||
Condition::IdList(vec![TaskId::WorkingSetId(2), TaskId::WorkingSetId(3)]),
|
||||
Condition::HasTag(tag!("no")),
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersect_idlist_alltasks() {
|
||||
let left = Filter::parse0(argv!["1,2", "+yes"]).unwrap().1;
|
||||
let right = Filter::parse0(argv!["+no"]).unwrap().1;
|
||||
let both = left.intersect(right);
|
||||
assert_eq!(
|
||||
both,
|
||||
Filter {
|
||||
conditions: vec![
|
||||
// from first filter
|
||||
Condition::IdList(vec![TaskId::WorkingSetId(1), TaskId::WorkingSetId(2),]),
|
||||
Condition::HasTag(tag!("yes")),
|
||||
// from second filter
|
||||
Condition::HasTag(tag!("no")),
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn intersect_alltasks_alltasks() {
|
||||
let left = Filter::parse0(argv!["+yes"]).unwrap().1;
|
||||
let right = Filter::parse0(argv!["+no"]).unwrap().1;
|
||||
let both = left.intersect(right);
|
||||
assert_eq!(
|
||||
both,
|
||||
Filter {
|
||||
conditions: vec![
|
||||
Condition::HasTag(tag!("yes")),
|
||||
Condition::HasTag(tag!("no")),
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,48 +0,0 @@
|
|||
// Nested functions that always return Ok(..) are used as callbacks in a context where a Result is
|
||||
// expected, so the unnecessary_wraps clippy lint is not useful here.
|
||||
|
||||
#![allow(clippy::unnecessary_wraps)]
|
||||
|
||||
/*!
|
||||
|
||||
This module is responsible for parsing command lines (`Arglist`, an alias for `&[&str]`) into `Command` instances.
|
||||
It removes some redundancy from the command line, for example combining the multiple ways to modify a task into a single `Modification` struct.
|
||||
|
||||
The module is organized as a nom parser over ArgList, and each struct has a `parse` method to parse such a list.
|
||||
|
||||
The exception to this rule is the `args` sub-module, which contains string parsers that are applied to indivdual command-line elements.
|
||||
|
||||
All of the structs produced by this module are fully-owned, data-only structs.
|
||||
That is, they contain no references, and have no methods to aid in their execution -- that is the `invocation` module's job.
|
||||
|
||||
*/
|
||||
mod args;
|
||||
mod command;
|
||||
mod config;
|
||||
mod filter;
|
||||
mod modification;
|
||||
mod subcommand;
|
||||
|
||||
pub(crate) use args::TaskId;
|
||||
pub(crate) use command::Command;
|
||||
pub(crate) use config::ConfigOperation;
|
||||
pub(crate) use filter::{Condition, Filter};
|
||||
pub(crate) use modification::{DescriptionMod, Modification};
|
||||
pub(crate) use subcommand::Subcommand;
|
||||
|
||||
use crate::usage::Usage;
|
||||
use lazy_static::lazy_static;
|
||||
use taskchampion::chrono::prelude::*;
|
||||
|
||||
lazy_static! {
|
||||
// A static value of NOW to make tests easier
|
||||
pub(crate) static ref NOW: DateTime<Utc> = Utc::now();
|
||||
}
|
||||
|
||||
type ArgList<'a> = &'a [&'a str];
|
||||
|
||||
pub(crate) fn get_usage(usage: &mut Usage) {
|
||||
Subcommand::get_usage(usage);
|
||||
Filter::get_usage(usage);
|
||||
Modification::get_usage(usage);
|
||||
}
|
|
@ -1,342 +0,0 @@
|
|||
use super::args::{any, arg_matching, depends_colon, minus_tag, plus_tag, wait_colon, TaskId};
|
||||
use super::ArgList;
|
||||
use crate::usage;
|
||||
use nom::{branch::alt, combinator::*, multi::fold_many0, IResult};
|
||||
use std::collections::HashSet;
|
||||
use taskchampion::chrono::prelude::*;
|
||||
use taskchampion::{Status, Tag};
|
||||
|
||||
#[derive(Debug, PartialEq, Clone)]
|
||||
pub enum DescriptionMod {
|
||||
/// do not change the description
|
||||
None,
|
||||
|
||||
/// Prepend the given value to the description, with a space separator
|
||||
Prepend(String),
|
||||
|
||||
/// Append the given value to the description, with a space separator
|
||||
Append(String),
|
||||
|
||||
/// Set the description
|
||||
Set(String),
|
||||
}
|
||||
|
||||
impl Default for DescriptionMod {
|
||||
fn default() -> Self {
|
||||
Self::None
|
||||
}
|
||||
}
|
||||
|
||||
/// A modification represents a change to a task: adding or removing tags, setting the
|
||||
/// description, and so on.
|
||||
#[derive(Debug, PartialEq, Clone, Default)]
|
||||
pub(crate) struct Modification {
|
||||
/// Change the description
|
||||
pub(crate) description: DescriptionMod,
|
||||
|
||||
/// Set the status
|
||||
pub(crate) status: Option<Status>,
|
||||
|
||||
/// Set (or, with `Some(None)`, clear) the wait timestamp
|
||||
pub(crate) wait: Option<Option<DateTime<Utc>>>,
|
||||
|
||||
/// Set the "active" state, that is, start (true) or stop (false) the task.
|
||||
pub(crate) active: Option<bool>,
|
||||
|
||||
/// Add tags
|
||||
pub(crate) add_tags: HashSet<Tag>,
|
||||
|
||||
/// Remove tags
|
||||
pub(crate) remove_tags: HashSet<Tag>,
|
||||
|
||||
/// Add dependencies
|
||||
pub(crate) add_dependencies: HashSet<TaskId>,
|
||||
|
||||
/// Remove dependencies
|
||||
pub(crate) remove_dependencies: HashSet<TaskId>,
|
||||
|
||||
/// Add annotation
|
||||
pub(crate) annotate: Option<String>,
|
||||
}
|
||||
|
||||
/// A single argument that is part of a modification, used internally to this module
|
||||
enum ModArg<'a> {
|
||||
Description(&'a str),
|
||||
PlusTag(Tag),
|
||||
MinusTag(Tag),
|
||||
Wait(Option<DateTime<Utc>>),
|
||||
AddDependencies(Vec<TaskId>),
|
||||
RemoveDependencies(Vec<TaskId>),
|
||||
}
|
||||
|
||||
impl Modification {
|
||||
pub(super) fn parse(input: ArgList) -> IResult<ArgList, Modification> {
|
||||
fn fold(mut acc: Modification, mod_arg: ModArg) -> Modification {
|
||||
match mod_arg {
|
||||
ModArg::Description(description) => {
|
||||
if let DescriptionMod::Set(existing) = acc.description {
|
||||
acc.description =
|
||||
DescriptionMod::Set(format!("{} {}", existing, description));
|
||||
} else {
|
||||
acc.description = DescriptionMod::Set(description.to_string());
|
||||
}
|
||||
}
|
||||
ModArg::PlusTag(tag) => {
|
||||
acc.add_tags.insert(tag);
|
||||
}
|
||||
ModArg::MinusTag(tag) => {
|
||||
acc.remove_tags.insert(tag);
|
||||
}
|
||||
ModArg::Wait(wait) => {
|
||||
acc.wait = Some(wait);
|
||||
}
|
||||
ModArg::AddDependencies(task_ids) => {
|
||||
for tid in task_ids {
|
||||
acc.add_dependencies.insert(tid);
|
||||
}
|
||||
}
|
||||
ModArg::RemoveDependencies(task_ids) => {
|
||||
for tid in task_ids {
|
||||
acc.remove_dependencies.insert(tid);
|
||||
}
|
||||
}
|
||||
}
|
||||
acc
|
||||
}
|
||||
fold_many0(
|
||||
alt((
|
||||
Self::plus_tag,
|
||||
Self::minus_tag,
|
||||
Self::wait,
|
||||
Self::dependencies,
|
||||
// this must come last
|
||||
Self::description,
|
||||
)),
|
||||
Modification {
|
||||
..Default::default()
|
||||
},
|
||||
fold,
|
||||
)(input)
|
||||
}
|
||||
|
||||
fn description(input: ArgList) -> IResult<ArgList, ModArg> {
|
||||
fn to_modarg(input: &str) -> Result<ModArg, ()> {
|
||||
Ok(ModArg::Description(input))
|
||||
}
|
||||
map_res(arg_matching(any), to_modarg)(input)
|
||||
}
|
||||
|
||||
fn plus_tag(input: ArgList) -> IResult<ArgList, ModArg> {
|
||||
fn to_modarg(input: Tag) -> Result<ModArg<'static>, ()> {
|
||||
Ok(ModArg::PlusTag(input))
|
||||
}
|
||||
map_res(arg_matching(plus_tag), to_modarg)(input)
|
||||
}
|
||||
|
||||
fn minus_tag(input: ArgList) -> IResult<ArgList, ModArg> {
|
||||
fn to_modarg(input: Tag) -> Result<ModArg<'static>, ()> {
|
||||
Ok(ModArg::MinusTag(input))
|
||||
}
|
||||
map_res(arg_matching(minus_tag), to_modarg)(input)
|
||||
}
|
||||
|
||||
fn wait(input: ArgList) -> IResult<ArgList, ModArg> {
|
||||
fn to_modarg(input: Option<DateTime<Utc>>) -> Result<ModArg<'static>, ()> {
|
||||
Ok(ModArg::Wait(input))
|
||||
}
|
||||
map_res(arg_matching(wait_colon), to_modarg)(input)
|
||||
}
|
||||
|
||||
fn dependencies(input: ArgList) -> IResult<ArgList, ModArg> {
|
||||
fn to_modarg(input: (bool, Vec<TaskId>)) -> Result<ModArg<'static>, ()> {
|
||||
Ok(if input.0 {
|
||||
ModArg::AddDependencies(input.1)
|
||||
} else {
|
||||
ModArg::RemoveDependencies(input.1)
|
||||
})
|
||||
}
|
||||
map_res(arg_matching(depends_colon), to_modarg)(input)
|
||||
}
|
||||
|
||||
pub(super) fn get_usage(u: &mut usage::Usage) {
|
||||
u.modifications.push(usage::Modification {
|
||||
syntax: "DESCRIPTION",
|
||||
summary: "Set description/annotation",
|
||||
description: "
|
||||
Set the task description (or the task annotation for `ta annotate`). Multiple
|
||||
arguments are combined into a single space-separated description. To avoid
|
||||
surprises from shell quoting, prefer to use a single quoted argument, for example
|
||||
`ta 19 modify \"return library books\"`",
|
||||
});
|
||||
u.modifications.push(usage::Modification {
|
||||
syntax: "+TAG",
|
||||
summary: "Tag 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:<timestamp>",
|
||||
summary: "Set or unset the task's wait time",
|
||||
description: "
|
||||
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.",
|
||||
});
|
||||
u.modifications.push(usage::Modification {
|
||||
syntax: "depends:<task-list>",
|
||||
summary: "Add task dependencies",
|
||||
description: "
|
||||
Add a dependency of this task on the given tasks. The tasks can be specified
|
||||
in the same syntax as for filters, e.g., `depends:13,94500c95`.",
|
||||
});
|
||||
u.modifications.push(usage::Modification {
|
||||
syntax: "depends:-<task-list>",
|
||||
summary: "Remove task dependencies",
|
||||
description: "
|
||||
Remove the dependency of this task on the given tasks.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::argparse::NOW;
|
||||
use pretty_assertions::assert_eq;
|
||||
use taskchampion::chrono::Duration;
|
||||
|
||||
#[test]
|
||||
fn test_empty() {
|
||||
let (input, modification) = Modification::parse(argv![]).unwrap();
|
||||
assert_eq!(input.len(), 0);
|
||||
assert_eq!(
|
||||
modification,
|
||||
Modification {
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_single_arg_description() {
|
||||
let (input, modification) = Modification::parse(argv!["newdesc"]).unwrap();
|
||||
assert_eq!(input.len(), 0);
|
||||
assert_eq!(
|
||||
modification,
|
||||
Modification {
|
||||
description: DescriptionMod::Set(s!("newdesc")),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_tags() {
|
||||
let (input, modification) = Modification::parse(argv!["+abc", "+def"]).unwrap();
|
||||
assert_eq!(input.len(), 0);
|
||||
assert_eq!(
|
||||
modification,
|
||||
Modification {
|
||||
add_tags: set![tag!("abc"), tag!("def")],
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[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 + Duration::days(2))),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_deps() {
|
||||
let (input, modification) = Modification::parse(argv!["depends:13,e72b73d1-9e88"]).unwrap();
|
||||
assert_eq!(input.len(), 0);
|
||||
let mut deps = HashSet::new();
|
||||
deps.insert(TaskId::WorkingSetId(13));
|
||||
deps.insert(TaskId::PartialUuid("e72b73d1-9e88".into()));
|
||||
assert_eq!(
|
||||
modification,
|
||||
Modification {
|
||||
add_dependencies: deps,
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_deps() {
|
||||
let (input, modification) =
|
||||
Modification::parse(argv!["depends:-13,e72b73d1-9e88"]).unwrap();
|
||||
assert_eq!(input.len(), 0);
|
||||
let mut deps = HashSet::new();
|
||||
deps.insert(TaskId::WorkingSetId(13));
|
||||
deps.insert(TaskId::PartialUuid("e72b73d1-9e88".into()));
|
||||
assert_eq!(
|
||||
modification,
|
||||
Modification {
|
||||
remove_dependencies: deps,
|
||||
..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();
|
||||
assert_eq!(input.len(), 0);
|
||||
assert_eq!(
|
||||
modification,
|
||||
Modification {
|
||||
description: DescriptionMod::Set(s!("new desc fun")),
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_arg_description_and_tags() {
|
||||
let (input, modification) =
|
||||
Modification::parse(argv!["new", "+next", "desc", "-daytime", "fun"]).unwrap();
|
||||
assert_eq!(input.len(), 0);
|
||||
assert_eq!(
|
||||
modification,
|
||||
Modification {
|
||||
description: DescriptionMod::Set(s!("new desc fun")),
|
||||
add_tags: set![tag!("next")],
|
||||
remove_tags: set![tag!("daytime")],
|
||||
..Default::default()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,948 +0,0 @@
|
|||
use super::args::*;
|
||||
use super::{ArgList, ConfigOperation, DescriptionMod, Filter, Modification};
|
||||
use crate::usage;
|
||||
use nom::{branch::alt, combinator::*, sequence::*, IResult};
|
||||
use taskchampion::Status;
|
||||
|
||||
// IMPLEMENTATION NOTE:
|
||||
//
|
||||
// For each variant of Subcommand, there is a private, empty type of the same name with a `parse`
|
||||
// method and a `get_usage` method. The parse methods may handle several subcommands, but always
|
||||
// produce the variant of the same name as the type.
|
||||
//
|
||||
// This organization helps to gather the parsing and usage information into
|
||||
// comprehensible chunks of code, to ensure that everything is documented.
|
||||
|
||||
/// A subcommand is the specific operation that the CLI should execute.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) enum Subcommand {
|
||||
/// Display the tool version
|
||||
Version,
|
||||
|
||||
/// Display the help output
|
||||
Help {
|
||||
/// Give the summary help (fitting on a few lines)
|
||||
summary: bool,
|
||||
},
|
||||
|
||||
/// Manipulate configuration
|
||||
Config {
|
||||
config_operation: ConfigOperation,
|
||||
},
|
||||
|
||||
/// Add a new task
|
||||
Add {
|
||||
modification: Modification,
|
||||
},
|
||||
|
||||
/// Modify existing tasks
|
||||
Modify {
|
||||
filter: Filter,
|
||||
modification: Modification,
|
||||
},
|
||||
|
||||
/// Lists (reports)
|
||||
Report {
|
||||
/// The name of the report to show
|
||||
report_name: String,
|
||||
|
||||
/// Additional filter terms beyond those in the report
|
||||
filter: Filter,
|
||||
},
|
||||
|
||||
/// Per-task information (typically one task)
|
||||
Info {
|
||||
filter: Filter,
|
||||
debug: bool,
|
||||
},
|
||||
|
||||
/// Basic operations without args
|
||||
Gc,
|
||||
Sync,
|
||||
ImportTW,
|
||||
ImportTDB2 {
|
||||
path: String,
|
||||
},
|
||||
Undo,
|
||||
}
|
||||
|
||||
impl Subcommand {
|
||||
pub(super) fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
|
||||
all_consuming(alt((
|
||||
Version::parse,
|
||||
Help::parse,
|
||||
Config::parse,
|
||||
Add::parse,
|
||||
Modify::parse,
|
||||
Info::parse,
|
||||
Gc::parse,
|
||||
Sync::parse,
|
||||
ImportTW::parse,
|
||||
ImportTDB2::parse,
|
||||
Undo::parse,
|
||||
// This must come last since it accepts arbitrary report names
|
||||
Report::parse,
|
||||
)))(input)
|
||||
}
|
||||
|
||||
pub(super) fn get_usage(u: &mut usage::Usage) {
|
||||
Version::get_usage(u);
|
||||
Help::get_usage(u);
|
||||
Config::get_usage(u);
|
||||
Add::get_usage(u);
|
||||
Modify::get_usage(u);
|
||||
Info::get_usage(u);
|
||||
Gc::get_usage(u);
|
||||
Sync::get_usage(u);
|
||||
ImportTW::get_usage(u);
|
||||
ImportTDB2::get_usage(u);
|
||||
Undo::get_usage(u);
|
||||
Report::get_usage(u);
|
||||
}
|
||||
}
|
||||
|
||||
struct Version;
|
||||
|
||||
impl Version {
|
||||
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
|
||||
fn to_subcommand(_: &str) -> Result<Subcommand, ()> {
|
||||
Ok(Subcommand::Version)
|
||||
}
|
||||
map_res(
|
||||
alt((
|
||||
arg_matching(literal("version")),
|
||||
arg_matching(literal("--version")),
|
||||
)),
|
||||
to_subcommand,
|
||||
)(input)
|
||||
}
|
||||
|
||||
fn get_usage(u: &mut usage::Usage) {
|
||||
u.subcommands.push(usage::Subcommand {
|
||||
name: "version",
|
||||
syntax: "version",
|
||||
summary: "Show the TaskChampion version",
|
||||
description: "Show the version of the TaskChampion binary",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
struct Help;
|
||||
|
||||
impl Help {
|
||||
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
|
||||
fn to_subcommand(input: &str) -> Result<Subcommand, ()> {
|
||||
Ok(Subcommand::Help {
|
||||
summary: input == "-h",
|
||||
})
|
||||
}
|
||||
map_res(
|
||||
alt((
|
||||
arg_matching(literal("help")),
|
||||
arg_matching(literal("--help")),
|
||||
arg_matching(literal("-h")),
|
||||
)),
|
||||
to_subcommand,
|
||||
)(input)
|
||||
}
|
||||
|
||||
fn get_usage(_u: &mut usage::Usage) {}
|
||||
}
|
||||
|
||||
struct Config;
|
||||
|
||||
impl Config {
|
||||
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
|
||||
fn to_subcommand(input: (&str, ConfigOperation)) -> Result<Subcommand, ()> {
|
||||
Ok(Subcommand::Config {
|
||||
config_operation: input.1,
|
||||
})
|
||||
}
|
||||
map_res(
|
||||
tuple((arg_matching(literal("config")), ConfigOperation::parse)),
|
||||
to_subcommand,
|
||||
)(input)
|
||||
}
|
||||
|
||||
fn get_usage(u: &mut usage::Usage) {
|
||||
ConfigOperation::get_usage(u);
|
||||
}
|
||||
}
|
||||
|
||||
struct Add;
|
||||
|
||||
impl Add {
|
||||
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
|
||||
fn to_subcommand(input: (&str, Modification)) -> Result<Subcommand, ()> {
|
||||
Ok(Subcommand::Add {
|
||||
modification: input.1,
|
||||
})
|
||||
}
|
||||
map_res(
|
||||
pair(arg_matching(literal("add")), Modification::parse),
|
||||
to_subcommand,
|
||||
)(input)
|
||||
}
|
||||
|
||||
fn get_usage(u: &mut usage::Usage) {
|
||||
u.subcommands.push(usage::Subcommand {
|
||||
name: "add",
|
||||
syntax: "add [modification]",
|
||||
summary: "Add a new task",
|
||||
description: "
|
||||
Add a new, pending task to the list of tasks. The modification must include a
|
||||
description.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
struct Modify;
|
||||
|
||||
impl Modify {
|
||||
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
|
||||
fn to_subcommand(input: (Filter, &str, Modification)) -> Result<Subcommand, ()> {
|
||||
let filter = input.0;
|
||||
let mut modification = input.2;
|
||||
|
||||
match input.1 {
|
||||
"prepend" => {
|
||||
if let DescriptionMod::Set(s) = modification.description {
|
||||
modification.description = DescriptionMod::Prepend(s)
|
||||
}
|
||||
}
|
||||
"append" => {
|
||||
if let DescriptionMod::Set(s) = modification.description {
|
||||
modification.description = DescriptionMod::Append(s)
|
||||
}
|
||||
}
|
||||
"start" => modification.active = Some(true),
|
||||
"stop" => modification.active = Some(false),
|
||||
"done" => modification.status = Some(Status::Completed),
|
||||
"delete" => modification.status = Some(Status::Deleted),
|
||||
"annotate" => {
|
||||
// what would be parsed as a description is, here, used as the annotation
|
||||
if let DescriptionMod::Set(s) = modification.description {
|
||||
modification.description = DescriptionMod::None;
|
||||
modification.annotate = Some(s);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Ok(Subcommand::Modify {
|
||||
filter,
|
||||
modification,
|
||||
})
|
||||
}
|
||||
map_res(
|
||||
tuple((
|
||||
Filter::parse1,
|
||||
alt((
|
||||
arg_matching(literal("modify")),
|
||||
arg_matching(literal("prepend")),
|
||||
arg_matching(literal("append")),
|
||||
arg_matching(literal("start")),
|
||||
arg_matching(literal("stop")),
|
||||
arg_matching(literal("done")),
|
||||
arg_matching(literal("delete")),
|
||||
arg_matching(literal("annotate")),
|
||||
)),
|
||||
Modification::parse,
|
||||
)),
|
||||
to_subcommand,
|
||||
)(input)
|
||||
}
|
||||
|
||||
fn get_usage(u: &mut usage::Usage) {
|
||||
u.subcommands.push(usage::Subcommand {
|
||||
name: "modify",
|
||||
syntax: "<filter> modify [modification]",
|
||||
summary: "Modify tasks",
|
||||
description: "
|
||||
Modify all tasks matching the required filter.",
|
||||
});
|
||||
u.subcommands.push(usage::Subcommand {
|
||||
name: "prepend",
|
||||
syntax: "<filter> prepend [modification]",
|
||||
summary: "Prepend task description",
|
||||
description: "
|
||||
Modify all tasks matching the required filter by inserting the given description before each
|
||||
task's description.",
|
||||
});
|
||||
u.subcommands.push(usage::Subcommand {
|
||||
name: "append",
|
||||
syntax: "<filter> append [modification]",
|
||||
summary: "Append task description",
|
||||
description: "
|
||||
Modify all tasks matching the required filter by adding the given description to the end
|
||||
of each task's description.",
|
||||
});
|
||||
u.subcommands.push(usage::Subcommand {
|
||||
name: "start",
|
||||
syntax: "<filter> start [modification]",
|
||||
summary: "Start tasks",
|
||||
description: "
|
||||
Start all tasks matching the required filter, additionally applying any given modifications."
|
||||
});
|
||||
u.subcommands.push(usage::Subcommand {
|
||||
name: "stop",
|
||||
syntax: "<filter> stop [modification]",
|
||||
summary: "Stop tasks",
|
||||
description: "
|
||||
Stop all tasks matching the required filter, additionally applying any given modifications.",
|
||||
});
|
||||
u.subcommands.push(usage::Subcommand {
|
||||
name: "done",
|
||||
syntax: "<filter> done [modification]",
|
||||
summary: "Mark tasks as completed",
|
||||
description: "
|
||||
Mark all tasks matching the required filter as completed, additionally applying any given
|
||||
modifications.",
|
||||
});
|
||||
u.subcommands.push(usage::Subcommand {
|
||||
name: "delete",
|
||||
syntax: "<filter> delete [modification]",
|
||||
summary: "Mark tasks as deleted",
|
||||
description: "
|
||||
Mark all tasks matching the required filter as deleted, additionally applying any given
|
||||
modifications. Deleted tasks remain until they are expired in a 'ta gc' operation at
|
||||
least six months after their last modification.",
|
||||
});
|
||||
u.subcommands.push(usage::Subcommand {
|
||||
name: "annotate",
|
||||
syntax: "<filter> annotate [modification]",
|
||||
summary: "Annotate a task",
|
||||
description: "
|
||||
Add an annotation to all tasks matching the required filter.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
struct Report;
|
||||
|
||||
impl Report {
|
||||
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
|
||||
fn to_subcommand(filter: Filter, report_name: &str) -> Result<Subcommand, ()> {
|
||||
Ok(Subcommand::Report {
|
||||
filter,
|
||||
report_name: report_name.to_owned(),
|
||||
})
|
||||
}
|
||||
// allow the filter expression before or after the report name
|
||||
alt((
|
||||
map_res(pair(arg_matching(report_name), Filter::parse0), |input| {
|
||||
to_subcommand(input.1, input.0)
|
||||
}),
|
||||
map_res(pair(Filter::parse0, arg_matching(report_name)), |input| {
|
||||
to_subcommand(input.0, input.1)
|
||||
}),
|
||||
// default to a "next" report
|
||||
map_res(Filter::parse0, |input| to_subcommand(input, "next")),
|
||||
))(input)
|
||||
}
|
||||
|
||||
fn get_usage(u: &mut usage::Usage) {
|
||||
u.subcommands.push(usage::Subcommand {
|
||||
name: "report",
|
||||
syntax: "[filter] [report-name] *or* [report-name] [filter]",
|
||||
summary: "Show a report",
|
||||
description: "
|
||||
Show the named report, including only tasks matching the filter",
|
||||
});
|
||||
u.subcommands.push(usage::Subcommand {
|
||||
name: "next",
|
||||
syntax: "[filter]",
|
||||
summary: "Show the 'next' report",
|
||||
description: "
|
||||
Show the report named 'next', including only tasks matching the filter",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
struct Info;
|
||||
|
||||
impl Info {
|
||||
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
|
||||
fn to_subcommand(input: (Filter, &str)) -> Result<Subcommand, ()> {
|
||||
let debug = input.1 == "debug";
|
||||
Ok(Subcommand::Info {
|
||||
filter: input.0,
|
||||
debug,
|
||||
})
|
||||
}
|
||||
map_res(
|
||||
pair(
|
||||
Filter::parse1,
|
||||
alt((
|
||||
arg_matching(literal("info")),
|
||||
arg_matching(literal("debug")),
|
||||
)),
|
||||
),
|
||||
to_subcommand,
|
||||
)(input)
|
||||
}
|
||||
|
||||
fn get_usage(u: &mut usage::Usage) {
|
||||
u.subcommands.push(usage::Subcommand {
|
||||
name: "info",
|
||||
syntax: "[filter] info",
|
||||
summary: "Show tasks",
|
||||
description: " Show information about all tasks matching the fiter.",
|
||||
});
|
||||
u.subcommands.push(usage::Subcommand {
|
||||
name: "debug",
|
||||
syntax: "[filter] debug",
|
||||
summary: "Show task debug details",
|
||||
description: " Show all key/value properties of the tasks matching the fiter.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
struct Gc;
|
||||
|
||||
impl Gc {
|
||||
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
|
||||
fn to_subcommand(_: &str) -> Result<Subcommand, ()> {
|
||||
Ok(Subcommand::Gc)
|
||||
}
|
||||
map_res(arg_matching(literal("gc")), to_subcommand)(input)
|
||||
}
|
||||
|
||||
fn get_usage(u: &mut usage::Usage) {
|
||||
u.subcommands.push(usage::Subcommand {
|
||||
name: "gc",
|
||||
syntax: "gc",
|
||||
summary: "Perform 'garbage collection'",
|
||||
description: "
|
||||
Perform 'garbage collection'. This refreshes the list of pending tasks
|
||||
and their short id's.",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
struct Sync;
|
||||
|
||||
impl Sync {
|
||||
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
|
||||
fn to_subcommand(_: &str) -> Result<Subcommand, ()> {
|
||||
Ok(Subcommand::Sync)
|
||||
}
|
||||
map_res(arg_matching(literal("sync")), to_subcommand)(input)
|
||||
}
|
||||
|
||||
fn get_usage(u: &mut usage::Usage) {
|
||||
u.subcommands.push(usage::Subcommand {
|
||||
name: "sync",
|
||||
syntax: "sync",
|
||||
summary: "Synchronize this replica",
|
||||
description: "
|
||||
Synchronize this replica locally or against a remote server, as configured.
|
||||
|
||||
Synchronization is a critical part of maintaining the task database, and should
|
||||
be done regularly, even if only locally. It is typically run in a crontask.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct ImportTW;
|
||||
|
||||
impl ImportTW {
|
||||
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
|
||||
fn to_subcommand(_: &str) -> Result<Subcommand, ()> {
|
||||
Ok(Subcommand::ImportTW)
|
||||
}
|
||||
map_res(arg_matching(literal("import-tw")), to_subcommand)(input)
|
||||
}
|
||||
|
||||
fn get_usage(u: &mut usage::Usage) {
|
||||
u.subcommands.push(usage::Subcommand {
|
||||
name: "import-tw",
|
||||
syntax: "import-tw",
|
||||
summary: "Import tasks from TaskWarrior export",
|
||||
description: "
|
||||
Import tasks into this replica.
|
||||
|
||||
The tasks must be provided in the TaskWarrior JSON format on stdin. If tasks
|
||||
in the import already exist, they are 'merged'.
|
||||
|
||||
Because TaskChampion lacks the information about the types of UDAs that is stored
|
||||
in the TaskWarrior configuration, UDA values are imported as simple strings, in the
|
||||
format they appear in the JSON export. This may cause undesirable results.
|
||||
",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct ImportTDB2;
|
||||
|
||||
impl ImportTDB2 {
|
||||
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
|
||||
fn to_subcommand(input: (&str, &str)) -> Result<Subcommand, ()> {
|
||||
Ok(Subcommand::ImportTDB2 {
|
||||
path: input.1.into(),
|
||||
})
|
||||
}
|
||||
map_res(
|
||||
pair(arg_matching(literal("import-tdb2")), arg_matching(any)),
|
||||
to_subcommand,
|
||||
)(input)
|
||||
}
|
||||
|
||||
fn get_usage(u: &mut usage::Usage) {
|
||||
u.subcommands.push(usage::Subcommand {
|
||||
name: "import-tdb2",
|
||||
syntax: "import-tdb2 <directory>",
|
||||
summary: "Import tasks from the TaskWarrior data directory",
|
||||
description: "
|
||||
Import tasks into this replica from a TaskWarrior data directory. If tasks in the
|
||||
import already exist, they are 'merged'. This mode of import supports UDAs better
|
||||
than the `import` subcommand, but requires access to the \"raw\" TaskWarrior data.
|
||||
|
||||
This command supports task directories written by TaskWarrior-2.6.1 or later.
|
||||
",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Undo;
|
||||
|
||||
impl Undo {
|
||||
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
|
||||
fn to_subcommand(_: &str) -> Result<Subcommand, ()> {
|
||||
Ok(Subcommand::Undo)
|
||||
}
|
||||
map_res(arg_matching(literal("undo")), to_subcommand)(input)
|
||||
}
|
||||
|
||||
fn get_usage(u: &mut usage::Usage) {
|
||||
u.subcommands.push(usage::Subcommand {
|
||||
name: "undo",
|
||||
syntax: "undo",
|
||||
summary: "Undo the latest change made on this replica",
|
||||
description: "
|
||||
Undo the latest change made on this replica.
|
||||
|
||||
Changes cannot be undone once they have been synchronized.",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::argparse::Condition;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
const EMPTY: Vec<&str> = vec![];
|
||||
|
||||
#[test]
|
||||
fn test_version() {
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["version"]).unwrap(),
|
||||
(&EMPTY[..], Subcommand::Version)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dd_version() {
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["--version"]).unwrap(),
|
||||
(&EMPTY[..], Subcommand::Version)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_d_h() {
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["-h"]).unwrap(),
|
||||
(&EMPTY[..], Subcommand::Help { summary: true })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_help() {
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["help"]).unwrap(),
|
||||
(&EMPTY[..], Subcommand::Help { summary: false })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dd_help() {
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["--help"]).unwrap(),
|
||||
(&EMPTY[..], Subcommand::Help { summary: false })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_set() {
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["config", "set", "x", "y"]).unwrap(),
|
||||
(
|
||||
&EMPTY[..],
|
||||
Subcommand::Config {
|
||||
config_operation: ConfigOperation::Set("x".to_owned(), "y".to_owned())
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_description() {
|
||||
let subcommand = Subcommand::Add {
|
||||
modification: Modification {
|
||||
description: DescriptionMod::Set(s!("foo")),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["add", "foo"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_description_multi() {
|
||||
let subcommand = Subcommand::Add {
|
||||
modification: Modification {
|
||||
description: DescriptionMod::Set(s!("foo bar")),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["add", "foo", "bar"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_modify_description_multi() {
|
||||
let subcommand = Subcommand::Modify {
|
||||
filter: Filter {
|
||||
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
|
||||
},
|
||||
modification: Modification {
|
||||
description: DescriptionMod::Set(s!("foo bar")),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["123", "modify", "foo", "bar"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_append() {
|
||||
let subcommand = Subcommand::Modify {
|
||||
filter: Filter {
|
||||
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
|
||||
},
|
||||
modification: Modification {
|
||||
description: DescriptionMod::Append(s!("foo bar")),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["123", "append", "foo", "bar"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prepend() {
|
||||
let subcommand = Subcommand::Modify {
|
||||
filter: Filter {
|
||||
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
|
||||
},
|
||||
modification: Modification {
|
||||
description: DescriptionMod::Prepend(s!("foo bar")),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["123", "prepend", "foo", "bar"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_done() {
|
||||
let subcommand = Subcommand::Modify {
|
||||
filter: Filter {
|
||||
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
|
||||
},
|
||||
modification: Modification {
|
||||
status: Some(Status::Completed),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["123", "done"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_done_modify() {
|
||||
let subcommand = Subcommand::Modify {
|
||||
filter: Filter {
|
||||
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
|
||||
},
|
||||
modification: Modification {
|
||||
description: DescriptionMod::Set(s!("now-finished")),
|
||||
status: Some(Status::Completed),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["123", "done", "now-finished"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_start() {
|
||||
let subcommand = Subcommand::Modify {
|
||||
filter: Filter {
|
||||
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
|
||||
},
|
||||
modification: Modification {
|
||||
active: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["123", "start"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_start_modify() {
|
||||
let subcommand = Subcommand::Modify {
|
||||
filter: Filter {
|
||||
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
|
||||
},
|
||||
modification: Modification {
|
||||
active: Some(true),
|
||||
description: DescriptionMod::Set(s!("mod")),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["123", "start", "mod"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stop() {
|
||||
let subcommand = Subcommand::Modify {
|
||||
filter: Filter {
|
||||
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
|
||||
},
|
||||
modification: Modification {
|
||||
active: Some(false),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["123", "stop"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stop_modify() {
|
||||
let subcommand = Subcommand::Modify {
|
||||
filter: Filter {
|
||||
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
|
||||
},
|
||||
modification: Modification {
|
||||
description: DescriptionMod::Set(s!("mod")),
|
||||
active: Some(false),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["123", "stop", "mod"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete() {
|
||||
let subcommand = Subcommand::Modify {
|
||||
filter: Filter {
|
||||
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
|
||||
},
|
||||
modification: Modification {
|
||||
status: Some(Status::Deleted),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["123", "delete"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_annotate() {
|
||||
let subcommand = Subcommand::Modify {
|
||||
filter: Filter {
|
||||
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
|
||||
},
|
||||
modification: Modification {
|
||||
annotate: Some("sent invoice".into()),
|
||||
..Default::default()
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["123", "annotate", "sent", "invoice"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_report() {
|
||||
let subcommand = Subcommand::Report {
|
||||
filter: Default::default(),
|
||||
report_name: "myreport".to_owned(),
|
||||
};
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["myreport"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_report_filter_before() {
|
||||
let subcommand = Subcommand::Report {
|
||||
filter: Filter {
|
||||
conditions: vec![Condition::IdList(vec![
|
||||
TaskId::WorkingSetId(12),
|
||||
TaskId::WorkingSetId(13),
|
||||
])],
|
||||
},
|
||||
report_name: "foo".to_owned(),
|
||||
};
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["12,13", "foo"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_report_filter_after() {
|
||||
let subcommand = Subcommand::Report {
|
||||
filter: Filter {
|
||||
conditions: vec![Condition::IdList(vec![
|
||||
TaskId::WorkingSetId(12),
|
||||
TaskId::WorkingSetId(13),
|
||||
])],
|
||||
},
|
||||
report_name: "foo".to_owned(),
|
||||
};
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["foo", "12,13"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_report_filter_next() {
|
||||
let subcommand = Subcommand::Report {
|
||||
filter: Filter {
|
||||
conditions: vec![Condition::IdList(vec![
|
||||
TaskId::WorkingSetId(12),
|
||||
TaskId::WorkingSetId(13),
|
||||
])],
|
||||
},
|
||||
report_name: "next".to_owned(),
|
||||
};
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["12,13"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_report_next() {
|
||||
let subcommand = Subcommand::Report {
|
||||
filter: Filter {
|
||||
..Default::default()
|
||||
},
|
||||
report_name: "next".to_owned(),
|
||||
};
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv![]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_info_filter() {
|
||||
let subcommand = Subcommand::Info {
|
||||
debug: false,
|
||||
filter: Filter {
|
||||
conditions: vec![Condition::IdList(vec![
|
||||
TaskId::WorkingSetId(12),
|
||||
TaskId::WorkingSetId(13),
|
||||
])],
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["12,13", "info"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_debug_filter() {
|
||||
let subcommand = Subcommand::Info {
|
||||
debug: true,
|
||||
filter: Filter {
|
||||
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(12)])],
|
||||
},
|
||||
};
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["12", "debug"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gc() {
|
||||
let subcommand = Subcommand::Gc;
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["gc"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gc_extra_args() {
|
||||
assert!(Subcommand::parse(argv!["gc", "foo"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sync() {
|
||||
let subcommand = Subcommand::Sync;
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["sync"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_undo() {
|
||||
let subcommand = Subcommand::Undo;
|
||||
assert_eq!(
|
||||
Subcommand::parse(argv!["undo"]).unwrap(),
|
||||
(&EMPTY[..], subcommand)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,11 +0,0 @@
|
|||
use std::process::exit;
|
||||
|
||||
pub fn main() {
|
||||
match taskchampion_cli::main() {
|
||||
Ok(_) => exit(0),
|
||||
Err(e) => {
|
||||
eprintln!("{:?}", e);
|
||||
exit(e.exit_status());
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,53 +0,0 @@
|
|||
use mdbook::book::{Book, BookItem};
|
||||
use mdbook::errors::Error;
|
||||
use mdbook::preprocess::{CmdPreprocessor, PreprocessorContext};
|
||||
use std::io;
|
||||
use std::process;
|
||||
use taskchampion_cli::Usage;
|
||||
|
||||
/// This is a simple mdbook preprocessor designed to substitute information from the usage
|
||||
/// into the documentation.
|
||||
fn main() -> anyhow::Result<()> {
|
||||
// cheap way to detect the "supports" arg
|
||||
if std::env::args().len() > 1 {
|
||||
// sure, whatever, we support it all
|
||||
process::exit(0);
|
||||
}
|
||||
|
||||
let (ctx, book) = CmdPreprocessor::parse_input(io::stdin())?;
|
||||
|
||||
if ctx.mdbook_version != mdbook::MDBOOK_VERSION {
|
||||
eprintln!(
|
||||
"Warning: This mdbook preprocessor was built against version {} of mdbook, \
|
||||
but we're being called from version {}",
|
||||
mdbook::MDBOOK_VERSION,
|
||||
ctx.mdbook_version
|
||||
);
|
||||
}
|
||||
|
||||
let processed_book = process(&ctx, book)?;
|
||||
serde_json::to_writer(io::stdout(), &processed_book)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process(_ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
|
||||
let usage = Usage::new();
|
||||
|
||||
book.for_each_mut(|sect| {
|
||||
if let BookItem::Chapter(ref mut chapter) = sect {
|
||||
let new_content = usage.substitute_docs(&chapter.content).unwrap();
|
||||
if new_content != chapter.content {
|
||||
eprintln!(
|
||||
"Substituting usage in {:?}",
|
||||
chapter
|
||||
.source_path
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| chapter.path.as_ref().unwrap())
|
||||
);
|
||||
}
|
||||
chapter.content = new_content;
|
||||
}
|
||||
});
|
||||
Ok(book)
|
||||
}
|
|
@ -1,60 +0,0 @@
|
|||
use taskchampion::Error as TcError;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum Error {
|
||||
#[error("Command-Line Syntax Error: {0}")]
|
||||
Arguments(String),
|
||||
|
||||
#[error(transparent)]
|
||||
TaskChampion(#[from] TcError),
|
||||
|
||||
#[error(transparent)]
|
||||
Other(#[from] anyhow::Error),
|
||||
}
|
||||
|
||||
impl Error {
|
||||
/// Construct a new command-line argument error
|
||||
pub(crate) fn for_arguments<S: ToString>(msg: S) -> Self {
|
||||
Error::Arguments(msg.to_string())
|
||||
}
|
||||
|
||||
/// Determine the exit status for this error, as documented.
|
||||
pub fn exit_status(&self) -> i32 {
|
||||
match *self {
|
||||
Error::Arguments(_) => 3,
|
||||
_ => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for Error {
|
||||
fn from(err: std::io::Error) -> Self {
|
||||
let err: anyhow::Error = err.into();
|
||||
Error::Other(err)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use anyhow::anyhow;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_exit_status() {
|
||||
let mut err: Error;
|
||||
|
||||
err = anyhow!("uhoh").into();
|
||||
assert_eq!(err.exit_status(), 1);
|
||||
|
||||
err = Error::Arguments("uhoh".to_string());
|
||||
assert_eq!(err.exit_status(), 3);
|
||||
|
||||
err = std::io::Error::last_os_error().into();
|
||||
assert_eq!(err.exit_status(), 1);
|
||||
|
||||
err = TcError::Database("uhoh".to_string()).into();
|
||||
assert_eq!(err.exit_status(), 1);
|
||||
}
|
||||
}
|
|
@ -1,77 +0,0 @@
|
|||
use crate::argparse::DescriptionMod;
|
||||
use crate::invocation::{apply_modification, ResolvedModification};
|
||||
use taskchampion::{Replica, Status};
|
||||
use termcolor::WriteColor;
|
||||
|
||||
pub(in crate::invocation) fn execute<W: WriteColor>(
|
||||
w: &mut W,
|
||||
replica: &mut Replica,
|
||||
mut modification: ResolvedModification,
|
||||
) -> Result<(), crate::Error> {
|
||||
// extract the description from the modification to handle it specially
|
||||
let description = match modification.0.description {
|
||||
DescriptionMod::Set(ref s) => s.clone(),
|
||||
_ => "(no description)".to_owned(),
|
||||
};
|
||||
modification.0.description = DescriptionMod::None;
|
||||
|
||||
let task = replica.new_task(Status::Pending, description).unwrap();
|
||||
let mut task = task.into_mut(replica);
|
||||
apply_modification(&mut task, &modification)?;
|
||||
writeln!(w, "added task {}", task.get_uuid())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::argparse::Modification;
|
||||
use crate::invocation::test::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_add() {
|
||||
let mut w = test_writer();
|
||||
let mut replica = test_replica();
|
||||
let modification = ResolvedModification(Modification {
|
||||
description: DescriptionMod::Set(s!("my description")),
|
||||
..Default::default()
|
||||
});
|
||||
execute(&mut w, &mut replica, modification).unwrap();
|
||||
|
||||
// check that the task appeared..
|
||||
let working_set = replica.working_set().unwrap();
|
||||
let task = replica
|
||||
.get_task(working_set.by_index(1).unwrap())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(task.get_description(), "my description");
|
||||
assert_eq!(task.get_status(), Status::Pending);
|
||||
|
||||
assert_eq!(w.into_string(), format!("added task {}\n", task.get_uuid()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_with_tags() {
|
||||
let mut w = test_writer();
|
||||
let mut replica = test_replica();
|
||||
let modification = ResolvedModification(Modification {
|
||||
description: DescriptionMod::Set(s!("my description")),
|
||||
add_tags: vec![tag!("tag1")].drain(..).collect(),
|
||||
..Default::default()
|
||||
});
|
||||
execute(&mut w, &mut replica, modification).unwrap();
|
||||
|
||||
// check that the task appeared..
|
||||
let working_set = replica.working_set().unwrap();
|
||||
let task = replica
|
||||
.get_task(working_set.by_index(1).unwrap())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(task.get_description(), "my description");
|
||||
assert_eq!(task.get_status(), Status::Pending);
|
||||
assert!(task.has_tag(&tag!("tag1")));
|
||||
|
||||
assert_eq!(w.into_string(), format!("added task {}\n", task.get_uuid()));
|
||||
}
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
[description:"&open;TEST&close; foo" entry:"1554074416" modified:"1554074416" priority:"M" status:"completed" uuid:"4578fb67-359b-4483-afe4-fef15925ccd6"]
|
|
@ -1,69 +0,0 @@
|
|||
use crate::argparse::ConfigOperation;
|
||||
use crate::settings::Settings;
|
||||
use termcolor::{ColorSpec, WriteColor};
|
||||
|
||||
pub(crate) fn execute<W: WriteColor>(
|
||||
w: &mut W,
|
||||
config_operation: ConfigOperation,
|
||||
settings: &Settings,
|
||||
) -> Result<(), crate::Error> {
|
||||
match config_operation {
|
||||
ConfigOperation::Set(key, value) => {
|
||||
let filename = settings.set(&key, &value)?;
|
||||
write!(w, "Set configuration value ")?;
|
||||
w.set_color(ColorSpec::new().set_bold(true))?;
|
||||
write!(w, "{}", &key)?;
|
||||
w.set_color(ColorSpec::new().set_bold(false))?;
|
||||
write!(w, " in ")?;
|
||||
w.set_color(ColorSpec::new().set_bold(true))?;
|
||||
writeln!(w, "{:?}.", filename)?;
|
||||
w.set_color(ColorSpec::new().set_bold(false))?;
|
||||
}
|
||||
ConfigOperation::Path => {
|
||||
if let Some(ref filename) = settings.filename {
|
||||
writeln!(w, "{}", filename.to_string_lossy())?;
|
||||
} else {
|
||||
return Err(anyhow::anyhow!("No configuration filename found").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::invocation::test::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_config_set() {
|
||||
let cfg_dir = TempDir::new().unwrap();
|
||||
let cfg_file = cfg_dir.path().join("foo.toml");
|
||||
fs::write(
|
||||
cfg_file.clone(),
|
||||
"# store data everywhere\ndata_dir = \"/nowhere\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap();
|
||||
|
||||
let mut w = test_writer();
|
||||
|
||||
execute(
|
||||
&mut w,
|
||||
ConfigOperation::Set("data_dir".to_owned(), "/somewhere".to_owned()),
|
||||
&settings,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(w.into_string().starts_with("Set configuration value "));
|
||||
|
||||
let updated_toml = fs::read_to_string(cfg_file.clone()).unwrap();
|
||||
assert_eq!(
|
||||
updated_toml,
|
||||
"# store data everywhere\ndata_dir = \"/somewhere\"\n"
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
use taskchampion::Replica;
|
||||
use termcolor::WriteColor;
|
||||
|
||||
pub(crate) fn execute<W: WriteColor>(w: &mut W, replica: &mut Replica) -> Result<(), crate::Error> {
|
||||
log::debug!("rebuilding working set");
|
||||
replica.rebuild_working_set(true)?;
|
||||
log::debug!("expiring old tasks");
|
||||
replica.expire_tasks()?;
|
||||
writeln!(w, "garbage collected.")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::invocation::test::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_gc() {
|
||||
let mut w = test_writer();
|
||||
let mut replica = test_replica();
|
||||
execute(&mut w, &mut replica).unwrap();
|
||||
assert_eq!(&w.into_string(), "garbage collected.\n")
|
||||
}
|
||||
}
|
|
@ -1,30 +0,0 @@
|
|||
use crate::usage::Usage;
|
||||
use termcolor::WriteColor;
|
||||
|
||||
pub(crate) fn execute<W: WriteColor>(
|
||||
w: &mut W,
|
||||
command_name: String,
|
||||
summary: bool,
|
||||
) -> Result<(), crate::Error> {
|
||||
let usage = Usage::new();
|
||||
usage.write_help(w, command_name.as_ref(), summary)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::invocation::test::*;
|
||||
|
||||
#[test]
|
||||
fn test_summary() {
|
||||
let mut w = test_writer();
|
||||
execute(&mut w, s!("ta"), true).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_long() {
|
||||
let mut w = test_writer();
|
||||
execute(&mut w, s!("ta"), false).unwrap();
|
||||
}
|
||||
}
|
|
@ -1,142 +0,0 @@
|
|||
use crate::tdb2;
|
||||
use anyhow::anyhow;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use taskchampion::{Replica, Uuid};
|
||||
use termcolor::{Color, ColorSpec, WriteColor};
|
||||
|
||||
pub(crate) fn execute<W: WriteColor>(
|
||||
w: &mut W,
|
||||
replica: &mut Replica,
|
||||
path: &str,
|
||||
) -> Result<(), crate::Error> {
|
||||
let path: PathBuf = path.into();
|
||||
|
||||
let mut count = 0;
|
||||
for file in &["pending.data", "completed.data"] {
|
||||
let file = path.join(file);
|
||||
w.set_color(ColorSpec::new().set_bold(true))?;
|
||||
writeln!(w, "Importing tasks from {:?}.", file)?;
|
||||
w.reset()?;
|
||||
|
||||
let data = fs::read_to_string(file)?;
|
||||
let content =
|
||||
tdb2::File::from_str(&data).map_err(|_| anyhow!("Could not parse TDB2 file format"))?;
|
||||
count += content.lines.len();
|
||||
for line in content.lines {
|
||||
import_task(w, replica, line)?;
|
||||
}
|
||||
}
|
||||
w.set_color(ColorSpec::new().set_bold(true))?;
|
||||
writeln!(w, "{} tasks imported.", count)?;
|
||||
w.reset()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn import_task<W: WriteColor>(
|
||||
w: &mut W,
|
||||
replica: &mut Replica,
|
||||
mut line: tdb2::Line,
|
||||
) -> anyhow::Result<()> {
|
||||
let mut uuid = None;
|
||||
for attr in line.attrs.iter() {
|
||||
if &attr.name == "uuid" {
|
||||
uuid = Some(Uuid::parse_str(&attr.value)?);
|
||||
break;
|
||||
}
|
||||
}
|
||||
let uuid = uuid.ok_or_else(|| anyhow!("task has no uuid"))?;
|
||||
replica.import_task_with_uuid(uuid)?;
|
||||
|
||||
let mut description = None;
|
||||
for attr in line.attrs.drain(..) {
|
||||
// oddly, TaskWarrior represents [ and ] with their HTML entity equivalents
|
||||
let value = attr.value.replace("&open;", "[").replace("&close;", "]");
|
||||
match attr.name.as_ref() {
|
||||
// `uuid` was already handled
|
||||
"uuid" => {}
|
||||
|
||||
// everything else is inserted directly
|
||||
_ => {
|
||||
if attr.name == "description" {
|
||||
// keep a copy of the description for console output
|
||||
description = Some(value.clone());
|
||||
}
|
||||
replica.update_task(uuid, attr.name, Some(value))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)))?;
|
||||
write!(w, "{}", uuid)?;
|
||||
w.reset()?;
|
||||
writeln!(
|
||||
w,
|
||||
" {}",
|
||||
description.unwrap_or_else(|| "(no description)".into())
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::invocation::test::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::convert::TryInto;
|
||||
use taskchampion::chrono::{TimeZone, Utc};
|
||||
use taskchampion::Status;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_import() -> anyhow::Result<()> {
|
||||
let mut w = test_writer();
|
||||
let mut replica = test_replica();
|
||||
let tmp_dir = TempDir::new()?;
|
||||
|
||||
fs::write(
|
||||
tmp_dir.path().join("pending.data"),
|
||||
include_bytes!("pending.data"),
|
||||
)?;
|
||||
fs::write(
|
||||
tmp_dir.path().join("completed.data"),
|
||||
include_bytes!("completed.data"),
|
||||
)?;
|
||||
|
||||
execute(&mut w, &mut replica, tmp_dir.path().to_str().unwrap())?;
|
||||
|
||||
let task = replica
|
||||
.get_task(Uuid::parse_str("f19086c2-1f8d-4a6c-9b8d-f94901fb8e62").unwrap())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(task.get_description(), "snake 🐍");
|
||||
assert_eq!(task.get_status(), Status::Pending);
|
||||
assert_eq!(task.get_priority(), "M");
|
||||
assert_eq!(task.get_wait(), None);
|
||||
assert_eq!(
|
||||
task.get_modified(),
|
||||
Some(Utc.ymd(2022, 1, 8).and_hms(19, 33, 5))
|
||||
);
|
||||
assert!(task.has_tag(&"reptile".try_into().unwrap()));
|
||||
assert!(!task.has_tag(&"COMPLETED".try_into().unwrap()));
|
||||
|
||||
let task = replica
|
||||
.get_task(Uuid::parse_str("4578fb67-359b-4483-afe4-fef15925ccd6").unwrap())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(task.get_description(), "[TEST] foo");
|
||||
assert_eq!(task.get_status(), Status::Completed);
|
||||
assert_eq!(task.get_priority(), "M".to_string());
|
||||
assert_eq!(task.get_wait(), None);
|
||||
assert_eq!(
|
||||
task.get_modified(),
|
||||
Some(Utc.ymd(2019, 3, 31).and_hms(23, 20, 16))
|
||||
);
|
||||
assert!(!task.has_tag(&"reptile".try_into().unwrap()));
|
||||
assert!(task.has_tag(&"COMPLETED".try_into().unwrap()));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -1,265 +0,0 @@
|
|||
use anyhow::{anyhow, bail};
|
||||
use serde::{self, Deserialize, Deserializer};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use taskchampion::chrono::{DateTime, TimeZone, Utc};
|
||||
use taskchampion::{Replica, Uuid};
|
||||
use termcolor::{Color, ColorSpec, WriteColor};
|
||||
|
||||
pub(crate) fn execute<W: WriteColor>(w: &mut W, replica: &mut Replica) -> Result<(), crate::Error> {
|
||||
w.set_color(ColorSpec::new().set_bold(true))?;
|
||||
writeln!(w, "Importing tasks from stdin.")?;
|
||||
w.reset()?;
|
||||
|
||||
let mut tasks: Vec<HashMap<String, Value>> =
|
||||
serde_json::from_reader(std::io::stdin()).map_err(|_| anyhow!("Invalid JSON"))?;
|
||||
|
||||
for task_json in tasks.drain(..) {
|
||||
import_task(w, replica, task_json)?;
|
||||
}
|
||||
|
||||
w.set_color(ColorSpec::new().set_bold(true))?;
|
||||
writeln!(w, "{} tasks imported.", tasks.len())?;
|
||||
w.reset()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Convert the given value to a string, failing on compound types (arrays
|
||||
/// and objects).
|
||||
fn stringify(v: Value) -> anyhow::Result<String> {
|
||||
Ok(match v {
|
||||
Value::String(s) => s,
|
||||
Value::Number(n) => n.to_string(),
|
||||
Value::Bool(true) => "true".to_string(),
|
||||
Value::Bool(false) => "false".to_string(),
|
||||
Value::Null => "null".to_string(),
|
||||
_ => bail!("{:?} cannot be converted to a string", v),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn deserialize_tw_datetime<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
{
|
||||
const FORMAT: &str = "%Y%m%dT%H%M%SZ";
|
||||
let s = String::deserialize(deserializer)?;
|
||||
Utc.datetime_from_str(&s, FORMAT)
|
||||
.map_err(serde::de::Error::custom)
|
||||
}
|
||||
|
||||
/// Deserialize a string in the TaskWarrior format into a DateTime
|
||||
#[derive(Deserialize)]
|
||||
struct TwDateTime(#[serde(deserialize_with = "deserialize_tw_datetime")] DateTime<Utc>);
|
||||
|
||||
impl TwDateTime {
|
||||
/// Generate the data-model style UNIX timestamp for this DateTime
|
||||
fn tc_timestamp(&self) -> String {
|
||||
self.0.timestamp().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Annotation {
|
||||
entry: TwDateTime,
|
||||
description: String,
|
||||
}
|
||||
|
||||
fn import_task<W: WriteColor>(
|
||||
w: &mut W,
|
||||
replica: &mut Replica,
|
||||
mut task_json: HashMap<String, Value>,
|
||||
) -> anyhow::Result<()> {
|
||||
let uuid = task_json
|
||||
.get("uuid")
|
||||
.ok_or_else(|| anyhow!("task has no uuid"))?;
|
||||
let uuid = uuid
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("uuid is not a string"))?;
|
||||
let uuid = Uuid::parse_str(uuid)?;
|
||||
replica.import_task_with_uuid(uuid)?;
|
||||
|
||||
let mut description = None;
|
||||
for (k, v) in task_json.drain() {
|
||||
match k.as_ref() {
|
||||
// `id` is the working-set ID and is not stored
|
||||
"id" => {}
|
||||
|
||||
// `urgency` is also calculated and not stored
|
||||
"urgency" => {}
|
||||
|
||||
// `uuid` was already handled
|
||||
"uuid" => {}
|
||||
|
||||
// `annotations` is a sub-aray
|
||||
"annotations" => {
|
||||
let annotations: Vec<Annotation> = serde_json::from_value(v)?;
|
||||
for ann in annotations {
|
||||
let k = format!("annotation_{}", ann.entry.tc_timestamp());
|
||||
replica.update_task(uuid, k, Some(ann.description))?;
|
||||
}
|
||||
}
|
||||
|
||||
// `depends` is a sub-aray
|
||||
"depends" => {
|
||||
let deps: Vec<String> = serde_json::from_value(v)?;
|
||||
for dep in deps {
|
||||
let k = format!("dep_{}", dep);
|
||||
replica.update_task(uuid, k, Some("".to_owned()))?;
|
||||
}
|
||||
}
|
||||
|
||||
// `tags` is a sub-aray
|
||||
"tags" => {
|
||||
let tags: Vec<String> = serde_json::from_value(v)?;
|
||||
for tag in tags {
|
||||
let k = format!("tag_{}", tag);
|
||||
replica.update_task(uuid, k, Some("".to_owned()))?;
|
||||
}
|
||||
}
|
||||
|
||||
// convert all datetimes -> epoch integers
|
||||
"end" | "entry" | "modified" | "wait" | "due" => {
|
||||
let v: TwDateTime = serde_json::from_value(v)?;
|
||||
replica.update_task(uuid, k, Some(v.tc_timestamp()))?;
|
||||
}
|
||||
|
||||
// everything else is inserted directly
|
||||
_ => {
|
||||
let v = stringify(v)?;
|
||||
if k == "description" {
|
||||
// keep a copy of the description for console output
|
||||
description = Some(v.clone());
|
||||
}
|
||||
replica.update_task(uuid, k, Some(v))?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)))?;
|
||||
write!(w, "{}", uuid)?;
|
||||
w.reset()?;
|
||||
writeln!(
|
||||
w,
|
||||
" {}",
|
||||
description.unwrap_or_else(|| "(no description)".into())
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::invocation::test::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::convert::TryInto;
|
||||
use taskchampion::chrono::{TimeZone, Utc};
|
||||
use taskchampion::Status;
|
||||
|
||||
#[test]
|
||||
fn stringify_string() {
|
||||
assert_eq!(stringify(json!("foo")).unwrap(), "foo".to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stringify_number() {
|
||||
assert_eq!(stringify(json!(2.14)).unwrap(), "2.14".to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stringify_bool() {
|
||||
assert_eq!(stringify(json!(true)).unwrap(), "true".to_string());
|
||||
assert_eq!(stringify(json!(false)).unwrap(), "false".to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stringify_null() {
|
||||
assert_eq!(stringify(json!(null)).unwrap(), "null".to_string());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stringify_invalid() {
|
||||
assert!(stringify(json!([1])).is_err());
|
||||
assert!(stringify(json!({"a": 1})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_import() -> anyhow::Result<()> {
|
||||
let mut w = test_writer();
|
||||
let mut replica = test_replica();
|
||||
|
||||
let task_json = serde_json::from_value(json!({
|
||||
"id": 0,
|
||||
"description": "repair window",
|
||||
"end": "20211231T175614Z", // TODO (#327)
|
||||
"entry": "20211117T022410Z", // TODO (#326)
|
||||
"modified": "20211231T175614Z",
|
||||
"priority": "M",
|
||||
"status": "completed",
|
||||
"uuid": "fa01e916-1587-4c7d-a646-f7be62be8ee7",
|
||||
"wait": "20211225T001523Z",
|
||||
"due": "20211225T040000Z", // TODO (#82)
|
||||
|
||||
// TODO: recurrence (#81)
|
||||
"imask": 2,
|
||||
"recur": "monthly",
|
||||
"rtype": "periodic",
|
||||
"mask": "+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--",
|
||||
|
||||
// (legacy) UDAs
|
||||
"githubcreatedon": "20211110T175919Z",
|
||||
"githubnamespace": "djmitche",
|
||||
"githubnumber": 228,
|
||||
|
||||
"tags": [
|
||||
"house"
|
||||
],
|
||||
"depends": [ // TODO (#84)
|
||||
"4f71035d-1704-47f0-885c-6f9134bcefb2"
|
||||
],
|
||||
"annotations": [
|
||||
{
|
||||
"entry": "20211223T142031Z",
|
||||
"description": "ordered from website"
|
||||
}
|
||||
],
|
||||
"urgency": 4.16849
|
||||
}))?;
|
||||
import_task(&mut w, &mut replica, task_json)?;
|
||||
|
||||
let task = replica
|
||||
.get_task(Uuid::parse_str("fa01e916-1587-4c7d-a646-f7be62be8ee7").unwrap())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(task.get_description(), "repair window");
|
||||
assert_eq!(task.get_status(), Status::Completed);
|
||||
assert_eq!(task.get_priority(), "M".to_string());
|
||||
assert_eq!(
|
||||
task.get_wait(),
|
||||
Some(Utc.ymd(2021, 12, 25).and_hms(00, 15, 23))
|
||||
);
|
||||
assert_eq!(
|
||||
task.get_modified(),
|
||||
Some(Utc.ymd(2021, 12, 31).and_hms(17, 56, 14))
|
||||
);
|
||||
assert!(task.has_tag(&"house".try_into().unwrap()));
|
||||
assert!(!task.has_tag(&"PENDING".try_into().unwrap()));
|
||||
assert_eq!(
|
||||
task.get_annotations().collect::<Vec<_>>(),
|
||||
vec![taskchampion::Annotation {
|
||||
entry: Utc.ymd(2021, 12, 23).and_hms(14, 20, 31),
|
||||
description: "ordered from website".into(),
|
||||
}]
|
||||
);
|
||||
assert_eq!(
|
||||
task.get_legacy_uda("githubcreatedon"),
|
||||
Some("20211110T175919Z")
|
||||
);
|
||||
assert_eq!(task.get_legacy_uda("githubnamespace"), Some("djmitche"));
|
||||
assert_eq!(task.get_legacy_uda("githubnumber"), Some("228"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -1,117 +0,0 @@
|
|||
use crate::argparse::Filter;
|
||||
use crate::invocation::filtered_tasks;
|
||||
use crate::table;
|
||||
use prettytable::{cell, row, Table};
|
||||
use taskchampion::{Replica, Status};
|
||||
use termcolor::WriteColor;
|
||||
|
||||
pub(crate) fn execute<W: WriteColor>(
|
||||
w: &mut W,
|
||||
replica: &mut Replica,
|
||||
filter: Filter,
|
||||
debug: bool,
|
||||
) -> Result<(), crate::Error> {
|
||||
let working_set = replica.working_set()?;
|
||||
|
||||
for task in filtered_tasks(replica, &filter)? {
|
||||
let uuid = task.get_uuid();
|
||||
|
||||
let mut t = Table::new();
|
||||
t.set_format(table::format());
|
||||
if debug {
|
||||
t.set_titles(row![b->"key", b->"value"]);
|
||||
for (k, v) in task.get_taskmap().iter() {
|
||||
t.add_row(row![k, v]);
|
||||
}
|
||||
} else {
|
||||
t.add_row(row![b->"Uuid", uuid]);
|
||||
if let Some(i) = working_set.by_uuid(uuid) {
|
||||
t.add_row(row![b->"Id", i]);
|
||||
}
|
||||
t.add_row(row![b->"Description", task.get_description()]);
|
||||
t.add_row(row![b->"Status", task.get_status()]);
|
||||
t.add_row(row![b->"Active", task.is_active()]);
|
||||
let mut tags: Vec<_> = task.get_tags().map(|t| format!("+{}", t)).collect();
|
||||
if !tags.is_empty() {
|
||||
tags.sort();
|
||||
t.add_row(row![b->"Tags", tags.join(" ")]);
|
||||
}
|
||||
if let Some(wait) = task.get_wait() {
|
||||
t.add_row(row![b->"Wait", wait]);
|
||||
}
|
||||
let mut annotations: Vec<_> = task.get_annotations().collect();
|
||||
annotations.sort();
|
||||
for ann in annotations {
|
||||
t.add_row(row![b->"Annotation", format!("{}: {}", ann.entry, ann.description)]);
|
||||
}
|
||||
|
||||
let mut deps: Vec<_> = task.get_dependencies().collect();
|
||||
deps.sort();
|
||||
for dep in deps {
|
||||
let mut descr = None;
|
||||
if let Some(task) = replica.get_task(dep)? {
|
||||
if task.get_status() == Status::Pending {
|
||||
if let Some(i) = working_set.by_uuid(dep) {
|
||||
descr = Some(format!("{} - {}", i, task.get_description()))
|
||||
} else {
|
||||
descr = Some(format!("{} - {}", dep, task.get_description()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(descr) = descr {
|
||||
t.add_row(row![b->"Depends On", descr]);
|
||||
}
|
||||
}
|
||||
}
|
||||
t.print(w)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::argparse::{Condition, TaskId};
|
||||
use crate::invocation::test::*;
|
||||
|
||||
use taskchampion::Status;
|
||||
|
||||
#[test]
|
||||
fn test_info() {
|
||||
let mut w = test_writer();
|
||||
let mut replica = test_replica();
|
||||
replica.new_task(Status::Pending, s!("my task")).unwrap();
|
||||
|
||||
let filter = Filter {
|
||||
..Default::default()
|
||||
};
|
||||
let debug = false;
|
||||
execute(&mut w, &mut replica, filter, debug).unwrap();
|
||||
assert!(w.into_string().contains("my task"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deps() {
|
||||
let mut w = test_writer();
|
||||
let mut replica = test_replica();
|
||||
let t1 = replica.new_task(Status::Pending, s!("my task")).unwrap();
|
||||
let t2 = replica
|
||||
.new_task(Status::Pending, s!("dunno, depends"))
|
||||
.unwrap();
|
||||
let mut t2 = t2.into_mut(&mut replica);
|
||||
t2.add_dependency(t1.get_uuid()).unwrap();
|
||||
let t2 = t2.into_immut();
|
||||
|
||||
let filter = Filter {
|
||||
conditions: vec![Condition::IdList(vec![TaskId::Uuid(t2.get_uuid())])],
|
||||
};
|
||||
let debug = false;
|
||||
execute(&mut w, &mut replica, filter, debug).unwrap();
|
||||
let s = w.into_string();
|
||||
// length of whitespace between these two strings is not important
|
||||
assert!(s.contains("Depends On"));
|
||||
assert!(s.contains("1 - my task"));
|
||||
}
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
//! Responsible for executing commands as parsed by [`crate::argparse`].
|
||||
|
||||
pub(crate) mod add;
|
||||
pub(crate) mod config;
|
||||
pub(crate) mod gc;
|
||||
pub(crate) mod help;
|
||||
pub(crate) mod import_tdb2;
|
||||
pub(crate) mod import_tw;
|
||||
pub(crate) mod info;
|
||||
pub(crate) mod modify;
|
||||
pub(crate) mod report;
|
||||
pub(crate) mod sync;
|
||||
pub(crate) mod undo;
|
||||
pub(crate) mod version;
|
|
@ -1,106 +0,0 @@
|
|||
use crate::argparse::Filter;
|
||||
use crate::invocation::util::{confirm, summarize_task};
|
||||
use crate::invocation::{apply_modification, filtered_tasks, ResolvedModification};
|
||||
use crate::settings::Settings;
|
||||
use taskchampion::Replica;
|
||||
use termcolor::WriteColor;
|
||||
|
||||
/// confirm modification of more than `modificationt_count_prompt` tasks, defaulting to 3
|
||||
fn check_modification<W: WriteColor>(
|
||||
w: &mut W,
|
||||
settings: &Settings,
|
||||
affected_tasks: usize,
|
||||
) -> Result<bool, crate::Error> {
|
||||
let setting = settings.modification_count_prompt.unwrap_or(3);
|
||||
if setting == 0 || affected_tasks <= setting as usize {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let prompt = format!("Operation will modify {} tasks; continue?", affected_tasks,);
|
||||
if confirm(&prompt)? {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
writeln!(w, "Cancelled")?;
|
||||
|
||||
// only show this help if the setting is not set
|
||||
if settings.modification_count_prompt.is_none() {
|
||||
writeln!(
|
||||
w,
|
||||
"Set the `modification_count_prompt` setting to avoid this prompt:"
|
||||
)?;
|
||||
writeln!(
|
||||
w,
|
||||
" ta config set modification_count_prompt {}",
|
||||
affected_tasks + 1
|
||||
)?;
|
||||
writeln!(w, "Set it to 0 to disable the prompt entirely")?;
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub(in crate::invocation) fn execute<W: WriteColor>(
|
||||
w: &mut W,
|
||||
replica: &mut Replica,
|
||||
settings: &Settings,
|
||||
filter: Filter,
|
||||
modification: ResolvedModification,
|
||||
) -> Result<(), crate::Error> {
|
||||
let tasks = filtered_tasks(replica, &filter)?;
|
||||
|
||||
if !check_modification(w, settings, tasks.size_hint().0)? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for task in tasks {
|
||||
let mut task = task.into_mut(replica);
|
||||
|
||||
apply_modification(&mut task, &modification)?;
|
||||
|
||||
let task = task.into_immut();
|
||||
let summary = summarize_task(replica, &task)?;
|
||||
writeln!(w, "modified task {}", summary)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::argparse::{DescriptionMod, Modification};
|
||||
use crate::invocation::test::test_replica;
|
||||
use crate::invocation::test::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use taskchampion::Status;
|
||||
|
||||
#[test]
|
||||
fn test_modify() {
|
||||
let mut w = test_writer();
|
||||
let mut replica = test_replica();
|
||||
let settings = Settings::default();
|
||||
|
||||
let task = replica
|
||||
.new_task(Status::Pending, s!("old description"))
|
||||
.unwrap();
|
||||
|
||||
let filter = Filter {
|
||||
..Default::default()
|
||||
};
|
||||
let modification = ResolvedModification(Modification {
|
||||
description: DescriptionMod::Set(s!("new description")),
|
||||
..Default::default()
|
||||
});
|
||||
execute(&mut w, &mut replica, &settings, filter, modification).unwrap();
|
||||
|
||||
// check that the task appeared..
|
||||
let task = replica.get_task(task.get_uuid()).unwrap().unwrap();
|
||||
assert_eq!(task.get_description(), "new description");
|
||||
assert_eq!(task.get_status(), Status::Pending);
|
||||
|
||||
assert_eq!(
|
||||
w.into_string(),
|
||||
format!("modified task 1 - new description\n")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1 +0,0 @@
|
|||
[description:"snake 🐍" entry:"1641670385" modified:"1641670385" priority:"M" status:"pending" tag_reptile:"" uuid:"f19086c2-1f8d-4a6c-9b8d-f94901fb8e62"]
|
|
@ -1,43 +0,0 @@
|
|||
use crate::argparse::Filter;
|
||||
use crate::invocation::display_report;
|
||||
use crate::settings::Settings;
|
||||
use taskchampion::Replica;
|
||||
use termcolor::WriteColor;
|
||||
|
||||
pub(crate) fn execute<W: WriteColor>(
|
||||
w: &mut W,
|
||||
replica: &mut Replica,
|
||||
settings: &Settings,
|
||||
report_name: String,
|
||||
filter: Filter,
|
||||
) -> Result<(), crate::Error> {
|
||||
display_report(w, replica, settings, report_name, filter)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::argparse::Filter;
|
||||
use crate::invocation::test::*;
|
||||
|
||||
use taskchampion::Status;
|
||||
|
||||
#[test]
|
||||
fn test_report() {
|
||||
let mut w = test_writer();
|
||||
let mut replica = test_replica();
|
||||
replica.new_task(Status::Pending, s!("my task")).unwrap();
|
||||
|
||||
// The function being tested is only one line long, so this is sort of an integration test
|
||||
// for display_report.
|
||||
|
||||
let settings = Default::default();
|
||||
let report_name = "next".to_owned();
|
||||
let filter = Filter {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
execute(&mut w, &mut replica, &settings, report_name, filter).unwrap();
|
||||
assert!(w.into_string().contains("my task"));
|
||||
}
|
||||
}
|
|
@ -1,58 +0,0 @@
|
|||
use crate::settings::Settings;
|
||||
use taskchampion::{server::Server, Error as TCError, Replica};
|
||||
use termcolor::WriteColor;
|
||||
|
||||
pub(crate) fn execute<W: WriteColor>(
|
||||
w: &mut W,
|
||||
replica: &mut Replica,
|
||||
settings: &Settings,
|
||||
server: &mut Box<dyn Server>,
|
||||
) -> Result<(), crate::Error> {
|
||||
match replica.sync(server, settings.avoid_snapshots) {
|
||||
Ok(()) => {
|
||||
writeln!(w, "sync complete.")?;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => match e.downcast() {
|
||||
Ok(TCError::OutOfSync) => {
|
||||
writeln!(w, "This replica cannot be synchronized with the server.")?;
|
||||
writeln!(
|
||||
w,
|
||||
"It may be too old, or some other failure may have occurred."
|
||||
)?;
|
||||
writeln!(
|
||||
w,
|
||||
"To start fresh, remove the local task database and run `ta sync` again."
|
||||
)?;
|
||||
writeln!(
|
||||
w,
|
||||
"Note that doing so will lose any un-synchronized local changes."
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
Ok(e) => Err(e.into()),
|
||||
Err(e) => Err(e.into()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::invocation::test::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn test_add() {
|
||||
let mut w = test_writer();
|
||||
let mut replica = test_replica();
|
||||
let server_dir = TempDir::new().unwrap();
|
||||
let mut server = test_server(&server_dir);
|
||||
let settings = Settings::default();
|
||||
|
||||
// Note that the details of the actual sync are tested thoroughly in the taskchampion crate
|
||||
execute(&mut w, &mut replica, &settings, &mut server).unwrap();
|
||||
assert_eq!(&w.into_string(), "sync complete.\n")
|
||||
}
|
||||
}
|
|
@ -1,28 +0,0 @@
|
|||
use taskchampion::Replica;
|
||||
use termcolor::WriteColor;
|
||||
|
||||
pub(crate) fn execute<W: WriteColor>(w: &mut W, replica: &mut Replica) -> Result<(), crate::Error> {
|
||||
if replica.undo()? {
|
||||
writeln!(w, "Undo successful.")?;
|
||||
} else {
|
||||
writeln!(w, "Nothing to undo.")?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::invocation::test::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_undo() {
|
||||
let mut w = test_writer();
|
||||
let mut replica = test_replica();
|
||||
|
||||
// Note that the details of the actual undo operation are tested thoroughly in the taskchampion crate
|
||||
execute(&mut w, &mut replica).unwrap();
|
||||
assert_eq!(&w.into_string(), "Nothing to undo.\n")
|
||||
}
|
||||
}
|
|
@ -1,32 +0,0 @@
|
|||
use crate::built_info;
|
||||
use termcolor::{ColorSpec, WriteColor};
|
||||
|
||||
pub(crate) fn execute<W: WriteColor>(w: &mut W) -> Result<(), crate::Error> {
|
||||
write!(w, "TaskChampion ")?;
|
||||
w.set_color(ColorSpec::new().set_bold(true))?;
|
||||
write!(w, "{}", built_info::PKG_VERSION)?;
|
||||
w.reset()?;
|
||||
|
||||
if let (Some(version), Some(dirty)) = (built_info::GIT_VERSION, built_info::GIT_DIRTY) {
|
||||
if dirty {
|
||||
write!(w, " (git version: {} with un-committed changes)", version)?;
|
||||
} else {
|
||||
write!(w, " (git version: {})", version)?;
|
||||
};
|
||||
}
|
||||
writeln!(w)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::invocation::test::*;
|
||||
|
||||
#[test]
|
||||
fn test_version() {
|
||||
let mut w = test_writer();
|
||||
execute(&mut w).unwrap();
|
||||
assert!(w.into_string().starts_with("TaskChampion "));
|
||||
}
|
||||
}
|
|
@ -1,325 +0,0 @@
|
|||
use crate::argparse::{Condition, Filter, TaskId};
|
||||
use std::collections::HashSet;
|
||||
use taskchampion::{Replica, Status, Task, Uuid, WorkingSet};
|
||||
|
||||
fn match_task(filter: &Filter, task: &Task, uuid: Uuid, working_set: &WorkingSet) -> bool {
|
||||
for cond in &filter.conditions {
|
||||
match cond {
|
||||
Condition::HasTag(ref tag) => {
|
||||
if !task.has_tag(tag) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Condition::NoTag(ref tag) => {
|
||||
if task.has_tag(tag) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Condition::Status(status) => {
|
||||
if task.get_status() != *status {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Condition::IdList(ids) => {
|
||||
let uuid_str = uuid.to_string();
|
||||
let mut found = false;
|
||||
let working_set_id = working_set.by_uuid(uuid);
|
||||
|
||||
for id in ids {
|
||||
if match id {
|
||||
TaskId::WorkingSetId(i) => Some(*i) == working_set_id,
|
||||
TaskId::PartialUuid(partial) => uuid_str.starts_with(partial),
|
||||
TaskId::Uuid(i) => *i == uuid,
|
||||
} {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
// the universe of tasks we must consider
|
||||
enum Universe {
|
||||
/// Scan all the tasks
|
||||
AllTasks,
|
||||
/// Scan the working set (for pending tasks)
|
||||
WorkingSet,
|
||||
/// Scan an explicit set of tasks, "Absolute" meaning either full UUID or a working set
|
||||
/// index
|
||||
AbsoluteIdList(Vec<TaskId>),
|
||||
}
|
||||
|
||||
/// Determine the universe for the given filter; avoiding the need to scan all tasks in most cases.
|
||||
fn universe_for_filter(filter: &Filter) -> Universe {
|
||||
/// If there is a condition with Status::Pending, return true
|
||||
fn has_pending_condition(filter: &Filter) -> bool {
|
||||
filter
|
||||
.conditions
|
||||
.iter()
|
||||
.any(|cond| matches!(cond, Condition::Status(Status::Pending)))
|
||||
}
|
||||
|
||||
/// If there is a condition with an IdList containing no partial UUIDs,
|
||||
/// return that.
|
||||
fn absolute_id_list_condition(filter: &Filter) -> Option<Vec<TaskId>> {
|
||||
filter
|
||||
.conditions
|
||||
.iter()
|
||||
.find(|cond| {
|
||||
if let Condition::IdList(ids) = cond {
|
||||
!ids.iter().any(|id| matches!(id, TaskId::PartialUuid(_)))
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
.map(|cond| {
|
||||
if let Condition::IdList(ids) = cond {
|
||||
ids.to_vec()
|
||||
} else {
|
||||
unreachable!() // any condition found above must be an IdList(_)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if let Some(ids) = absolute_id_list_condition(filter) {
|
||||
Universe::AbsoluteIdList(ids)
|
||||
} else if has_pending_condition(filter) {
|
||||
Universe::WorkingSet
|
||||
} else {
|
||||
Universe::AllTasks
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the tasks matching the given filter. This will return each matching
|
||||
/// task once, even if the user specified the same task multiple times on the
|
||||
/// command line.
|
||||
pub(super) fn filtered_tasks(
|
||||
replica: &mut Replica,
|
||||
filter: &Filter,
|
||||
) -> anyhow::Result<impl Iterator<Item = Task>> {
|
||||
let mut res = vec![];
|
||||
|
||||
log::debug!("Applying filter {:?}", filter);
|
||||
|
||||
let working_set = replica.working_set()?;
|
||||
|
||||
// We will enumerate the universe of tasks for this filter, checking
|
||||
// each resulting task with match_task
|
||||
match universe_for_filter(filter) {
|
||||
// A list of IDs, but some are partial so we need to iterate over
|
||||
// all tasks and pattern-match their Uuids
|
||||
Universe::AbsoluteIdList(ref ids) => {
|
||||
log::debug!("Scanning only the tasks specified in the filter");
|
||||
// this is the only case where we might accidentally return the same task
|
||||
// several times, so we must track the seen tasks.
|
||||
let mut seen = HashSet::new();
|
||||
for id in ids {
|
||||
let task = match id {
|
||||
TaskId::WorkingSetId(id) => working_set
|
||||
.by_index(*id)
|
||||
.map(|uuid| replica.get_task(uuid))
|
||||
.transpose()?
|
||||
.flatten(),
|
||||
TaskId::PartialUuid(_) => unreachable!(), // not present in absolute id list
|
||||
TaskId::Uuid(id) => replica.get_task(*id)?,
|
||||
};
|
||||
|
||||
if let Some(task) = task {
|
||||
// if we have already seen this task, skip ahead..
|
||||
let uuid = task.get_uuid();
|
||||
if seen.contains(&uuid) {
|
||||
continue;
|
||||
}
|
||||
seen.insert(uuid);
|
||||
|
||||
if match_task(filter, &task, uuid, &working_set) {
|
||||
res.push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// All tasks -- iterate over the full set
|
||||
Universe::AllTasks => {
|
||||
log::debug!("Scanning all tasks in the task database");
|
||||
for (uuid, task) in replica.all_tasks()?.drain() {
|
||||
if match_task(filter, &task, uuid, &working_set) {
|
||||
res.push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
Universe::WorkingSet => {
|
||||
log::debug!("Scanning only the working set (pending tasks)");
|
||||
for (_, uuid) in working_set.iter() {
|
||||
if let Some(task) = replica.get_task(uuid)? {
|
||||
if match_task(filter, &task, uuid, &working_set) {
|
||||
res.push(task);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(res.into_iter())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::invocation::test::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use taskchampion::Status;
|
||||
|
||||
#[test]
|
||||
fn exact_ids() {
|
||||
let mut replica = test_replica();
|
||||
|
||||
let t1 = replica.new_task(Status::Pending, s!("A")).unwrap();
|
||||
let t2 = replica.new_task(Status::Completed, s!("B")).unwrap();
|
||||
let _t = replica.new_task(Status::Pending, s!("C")).unwrap();
|
||||
replica.rebuild_working_set(true).unwrap();
|
||||
|
||||
let t1uuid = t1.get_uuid();
|
||||
|
||||
let filter = Filter {
|
||||
conditions: vec![Condition::IdList(vec![
|
||||
TaskId::Uuid(t1uuid), // A
|
||||
TaskId::WorkingSetId(1), // A (again, dups filtered)
|
||||
TaskId::Uuid(t2.get_uuid()), // B
|
||||
])],
|
||||
};
|
||||
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)
|
||||
.unwrap()
|
||||
.map(|t| t.get_description().to_owned())
|
||||
.collect();
|
||||
filtered.sort();
|
||||
assert_eq!(vec![s!("A"), s!("B")], filtered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_ids() {
|
||||
let mut replica = test_replica();
|
||||
|
||||
let t1 = replica.new_task(Status::Pending, s!("A")).unwrap();
|
||||
let t2 = replica.new_task(Status::Completed, s!("B")).unwrap();
|
||||
let _t = replica.new_task(Status::Pending, s!("C")).unwrap();
|
||||
replica.rebuild_working_set(true).unwrap();
|
||||
|
||||
let t1uuid = t1.get_uuid();
|
||||
let t2uuid = t2.get_uuid().to_string();
|
||||
let t2partial = t2uuid[..13].to_owned();
|
||||
|
||||
let filter = Filter {
|
||||
conditions: vec![Condition::IdList(vec![
|
||||
TaskId::Uuid(t1uuid), // A
|
||||
TaskId::WorkingSetId(1), // A (again, dups filtered)
|
||||
TaskId::PartialUuid(t2partial), // B
|
||||
])],
|
||||
};
|
||||
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)
|
||||
.unwrap()
|
||||
.map(|t| t.get_description().to_owned())
|
||||
.collect();
|
||||
filtered.sort();
|
||||
assert_eq!(vec![s!("A"), s!("B")], filtered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_tasks() {
|
||||
let mut replica = test_replica();
|
||||
|
||||
replica.new_task(Status::Pending, s!("A")).unwrap();
|
||||
replica.new_task(Status::Completed, s!("B")).unwrap();
|
||||
replica.new_task(Status::Deleted, s!("C")).unwrap();
|
||||
replica.rebuild_working_set(true).unwrap();
|
||||
|
||||
let filter = Filter { conditions: vec![] };
|
||||
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)
|
||||
.unwrap()
|
||||
.map(|t| t.get_description().to_owned())
|
||||
.collect();
|
||||
filtered.sort();
|
||||
assert_eq!(vec![s!("A"), s!("B"), s!("C")], filtered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_filtering() -> anyhow::Result<()> {
|
||||
let mut replica = test_replica();
|
||||
let yes = tag!("yes");
|
||||
let no = tag!("no");
|
||||
|
||||
let mut t1 = replica
|
||||
.new_task(Status::Pending, s!("A"))?
|
||||
.into_mut(&mut replica);
|
||||
t1.add_tag(&yes)?;
|
||||
let mut t2 = replica
|
||||
.new_task(Status::Pending, s!("B"))?
|
||||
.into_mut(&mut replica);
|
||||
t2.add_tag(&yes)?;
|
||||
t2.add_tag(&no)?;
|
||||
let mut t3 = replica
|
||||
.new_task(Status::Pending, s!("C"))?
|
||||
.into_mut(&mut replica);
|
||||
t3.add_tag(&no)?;
|
||||
let _t4 = replica.new_task(Status::Pending, s!("D"))?;
|
||||
|
||||
// look for just "yes" (A and B)
|
||||
let filter = Filter {
|
||||
conditions: vec![Condition::HasTag(tag!("yes"))],
|
||||
};
|
||||
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)?
|
||||
.map(|t| t.get_description().to_owned())
|
||||
.collect();
|
||||
filtered.sort();
|
||||
assert_eq!(vec![s!("A"), s!("B")], filtered);
|
||||
|
||||
// look for tags without "no" (A, D)
|
||||
let filter = Filter {
|
||||
conditions: vec![Condition::NoTag(tag!("no"))],
|
||||
};
|
||||
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)?
|
||||
.map(|t| t.get_description().to_owned())
|
||||
.collect();
|
||||
filtered.sort();
|
||||
assert_eq!(vec![s!("A"), s!("D")], filtered);
|
||||
|
||||
// look for tags with "yes" and "no" (B)
|
||||
let filter = Filter {
|
||||
conditions: vec![
|
||||
Condition::HasTag(tag!("yes")),
|
||||
Condition::HasTag(tag!("no")),
|
||||
],
|
||||
};
|
||||
let filtered: Vec<_> = filtered_tasks(&mut replica, &filter)?
|
||||
.map(|t| t.get_description().to_owned())
|
||||
.collect();
|
||||
assert_eq!(vec![s!("B")], filtered);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pending_tasks() {
|
||||
let mut replica = test_replica();
|
||||
|
||||
replica.new_task(Status::Pending, s!("A")).unwrap();
|
||||
replica.new_task(Status::Completed, s!("B")).unwrap();
|
||||
replica.new_task(Status::Deleted, s!("C")).unwrap();
|
||||
replica.rebuild_working_set(true).unwrap();
|
||||
|
||||
let filter = Filter {
|
||||
conditions: vec![Condition::Status(Status::Pending)],
|
||||
};
|
||||
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)
|
||||
.unwrap()
|
||||
.map(|t| t.get_description().to_owned())
|
||||
.collect();
|
||||
filtered.sort();
|
||||
assert_eq!(vec![s!("A")], filtered);
|
||||
}
|
||||
}
|
|
@ -1,179 +0,0 @@
|
|||
//! The invocation module handles invoking the commands parsed by the argparse module.
|
||||
|
||||
use crate::argparse::{Command, Subcommand};
|
||||
use crate::settings::Settings;
|
||||
use taskchampion::{Replica, Server, ServerConfig, StorageConfig, Uuid};
|
||||
use termcolor::{ColorChoice, StandardStream};
|
||||
|
||||
mod cmd;
|
||||
mod filter;
|
||||
mod modify;
|
||||
mod report;
|
||||
mod util;
|
||||
|
||||
#[cfg(test)]
|
||||
mod test;
|
||||
|
||||
use filter::filtered_tasks;
|
||||
use modify::{apply_modification, resolve_modification, ResolvedModification};
|
||||
use report::display_report;
|
||||
|
||||
/// Invoke the given Command in the context of the given settings
|
||||
#[allow(clippy::needless_return)]
|
||||
pub(crate) fn invoke(command: Command, settings: Settings) -> Result<(), crate::Error> {
|
||||
log::debug!("command: {:?}", command);
|
||||
log::debug!("settings: {:?}", settings);
|
||||
|
||||
let mut w = get_writer();
|
||||
|
||||
// This function examines the command and breaks out the necessary bits to call one of the
|
||||
// `execute` functions in a submodule of `cmd`.
|
||||
|
||||
// match the subcommands that do not require a replica first, before
|
||||
// getting the replica
|
||||
match command {
|
||||
Command {
|
||||
subcommand: Subcommand::Help { summary },
|
||||
command_name,
|
||||
} => return cmd::help::execute(&mut w, command_name, summary),
|
||||
Command {
|
||||
subcommand: Subcommand::Config { config_operation },
|
||||
..
|
||||
} => return cmd::config::execute(&mut w, config_operation, &settings),
|
||||
Command {
|
||||
subcommand: Subcommand::Version,
|
||||
..
|
||||
} => return cmd::version::execute(&mut w),
|
||||
_ => {}
|
||||
};
|
||||
|
||||
let mut replica = get_replica(&settings)?;
|
||||
match command {
|
||||
Command {
|
||||
subcommand: Subcommand::Add { modification },
|
||||
..
|
||||
} => {
|
||||
let modification = resolve_modification(modification, &mut replica)?;
|
||||
return cmd::add::execute(&mut w, &mut replica, modification);
|
||||
}
|
||||
|
||||
Command {
|
||||
subcommand:
|
||||
Subcommand::Modify {
|
||||
filter,
|
||||
modification,
|
||||
},
|
||||
..
|
||||
} => {
|
||||
let modification = resolve_modification(modification, &mut replica)?;
|
||||
return cmd::modify::execute(&mut w, &mut replica, &settings, filter, modification);
|
||||
}
|
||||
|
||||
Command {
|
||||
subcommand:
|
||||
Subcommand::Report {
|
||||
report_name,
|
||||
filter,
|
||||
},
|
||||
..
|
||||
} => return cmd::report::execute(&mut w, &mut replica, &settings, report_name, filter),
|
||||
|
||||
Command {
|
||||
subcommand: Subcommand::Info { filter, debug },
|
||||
..
|
||||
} => return cmd::info::execute(&mut w, &mut replica, filter, debug),
|
||||
|
||||
Command {
|
||||
subcommand: Subcommand::Gc,
|
||||
..
|
||||
} => return cmd::gc::execute(&mut w, &mut replica),
|
||||
|
||||
Command {
|
||||
subcommand: Subcommand::Sync,
|
||||
..
|
||||
} => {
|
||||
let mut server = get_server(&settings)?;
|
||||
return cmd::sync::execute(&mut w, &mut replica, &settings, &mut server);
|
||||
}
|
||||
|
||||
Command {
|
||||
subcommand: Subcommand::ImportTW,
|
||||
..
|
||||
} => {
|
||||
return cmd::import_tw::execute(&mut w, &mut replica);
|
||||
}
|
||||
|
||||
Command {
|
||||
subcommand: Subcommand::ImportTDB2 { path },
|
||||
..
|
||||
} => {
|
||||
return cmd::import_tdb2::execute(&mut w, &mut replica, path.as_ref());
|
||||
}
|
||||
|
||||
Command {
|
||||
subcommand: Subcommand::Undo,
|
||||
..
|
||||
} => {
|
||||
return cmd::undo::execute(&mut w, &mut replica);
|
||||
}
|
||||
|
||||
// handled in the first match, but here to ensure this match is exhaustive
|
||||
Command {
|
||||
subcommand: Subcommand::Help { .. },
|
||||
..
|
||||
} => unreachable!(),
|
||||
Command {
|
||||
subcommand: Subcommand::Config { .. },
|
||||
..
|
||||
} => unreachable!(),
|
||||
Command {
|
||||
subcommand: Subcommand::Version,
|
||||
..
|
||||
} => unreachable!(),
|
||||
};
|
||||
}
|
||||
|
||||
// utilities for invoke
|
||||
|
||||
/// Get the replica for this invocation
|
||||
fn get_replica(settings: &Settings) -> anyhow::Result<Replica> {
|
||||
let taskdb_dir = settings.data_dir.clone();
|
||||
log::debug!("Replica data_dir: {:?}", taskdb_dir);
|
||||
let storage_config = StorageConfig::OnDisk { taskdb_dir };
|
||||
Ok(Replica::new(storage_config.into_storage()?))
|
||||
}
|
||||
|
||||
/// Get the server for this invocation
|
||||
fn get_server(settings: &Settings) -> anyhow::Result<Box<dyn Server>> {
|
||||
// if server_client_key and server_origin are both set, use
|
||||
// the remote server
|
||||
let config = if let (Some(client_key), Some(origin), Some(encryption_secret)) = (
|
||||
settings.server_client_key.as_ref(),
|
||||
settings.server_origin.as_ref(),
|
||||
settings.encryption_secret.as_ref(),
|
||||
) {
|
||||
let client_key = Uuid::parse_str(client_key)?;
|
||||
|
||||
log::debug!("Using sync-server with origin {}", origin);
|
||||
log::debug!("Sync client ID: {}", client_key);
|
||||
ServerConfig::Remote {
|
||||
origin: origin.clone(),
|
||||
client_key,
|
||||
encryption_secret: encryption_secret.as_bytes().to_vec(),
|
||||
}
|
||||
} else {
|
||||
let server_dir = settings.server_dir.clone();
|
||||
log::debug!("Using local sync-server at `{:?}`", server_dir);
|
||||
ServerConfig::Local { server_dir }
|
||||
};
|
||||
config.into_server()
|
||||
}
|
||||
|
||||
/// Get a WriteColor implementation based on whether the output is a tty.
|
||||
fn get_writer() -> StandardStream {
|
||||
StandardStream::stdout(if atty::is(atty::Stream::Stdout) {
|
||||
ColorChoice::Auto
|
||||
} else {
|
||||
ColorChoice::Never
|
||||
})
|
||||
}
|
|
@ -1,247 +0,0 @@
|
|||
use crate::argparse::{DescriptionMod, Modification, TaskId};
|
||||
use std::collections::HashSet;
|
||||
use taskchampion::chrono::Utc;
|
||||
use taskchampion::{Annotation, Replica, TaskMut};
|
||||
|
||||
/// A wrapper for Modification, promising that all TaskId instances are of variant TaskId::Uuid.
|
||||
pub(super) struct ResolvedModification(pub(super) Modification);
|
||||
|
||||
/// Resolve a Modification to a ResolvedModification, based on access to a Replica.
|
||||
///
|
||||
/// This is not automatically done in `apply_modification` because, by that time, the TaskMut being
|
||||
/// modified has an exclusive reference to the Replica, so it is impossible to search for matching
|
||||
/// tasks.
|
||||
pub(super) fn resolve_modification(
|
||||
unres: Modification,
|
||||
replica: &mut Replica,
|
||||
) -> anyhow::Result<ResolvedModification> {
|
||||
Ok(ResolvedModification(Modification {
|
||||
description: unres.description,
|
||||
status: unres.status,
|
||||
wait: unres.wait,
|
||||
active: unres.active,
|
||||
add_tags: unres.add_tags,
|
||||
remove_tags: unres.remove_tags,
|
||||
add_dependencies: resolve_task_ids(replica, unres.add_dependencies)?,
|
||||
remove_dependencies: resolve_task_ids(replica, unres.remove_dependencies)?,
|
||||
annotate: unres.annotate,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Convert a set of arbitrary TaskId's into TaskIds containing only TaskId::Uuid.
|
||||
fn resolve_task_ids(
|
||||
replica: &mut Replica,
|
||||
task_ids: HashSet<TaskId>,
|
||||
) -> anyhow::Result<HashSet<TaskId>> {
|
||||
// already all UUIDs (or empty)?
|
||||
if task_ids.iter().all(|tid| matches!(tid, TaskId::Uuid(_))) {
|
||||
return Ok(task_ids);
|
||||
}
|
||||
|
||||
let mut result = HashSet::new();
|
||||
let mut working_set = None;
|
||||
let mut all_tasks = None;
|
||||
for tid in task_ids {
|
||||
match tid {
|
||||
TaskId::WorkingSetId(i) => {
|
||||
let ws = match working_set {
|
||||
Some(ref ws) => ws,
|
||||
None => {
|
||||
working_set = Some(replica.working_set()?);
|
||||
working_set.as_ref().unwrap()
|
||||
}
|
||||
};
|
||||
if let Some(u) = ws.by_index(i) {
|
||||
result.insert(TaskId::Uuid(u));
|
||||
}
|
||||
}
|
||||
TaskId::PartialUuid(partial) => {
|
||||
let ts = match all_tasks {
|
||||
Some(ref ts) => ts,
|
||||
None => {
|
||||
all_tasks = Some(
|
||||
replica
|
||||
.all_task_uuids()?
|
||||
.drain(..)
|
||||
.map(|u| (u, u.to_string()))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
all_tasks.as_ref().unwrap()
|
||||
}
|
||||
};
|
||||
for (u, ustr) in ts {
|
||||
if ustr.starts_with(&partial) {
|
||||
result.insert(TaskId::Uuid(*u));
|
||||
}
|
||||
}
|
||||
}
|
||||
TaskId::Uuid(u) => {
|
||||
result.insert(TaskId::Uuid(u));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Apply the given modification
|
||||
pub(super) fn apply_modification(
|
||||
task: &mut TaskMut,
|
||||
modification: &ResolvedModification,
|
||||
) -> anyhow::Result<()> {
|
||||
// unwrap the "Resolved" promise
|
||||
let modification = &modification.0;
|
||||
|
||||
match modification.description {
|
||||
DescriptionMod::Set(ref description) => task.set_description(description.clone())?,
|
||||
DescriptionMod::Prepend(ref description) => {
|
||||
task.set_description(format!("{} {}", description, task.get_description()))?
|
||||
}
|
||||
DescriptionMod::Append(ref description) => {
|
||||
task.set_description(format!("{} {}", task.get_description(), description))?
|
||||
}
|
||||
DescriptionMod::None => {}
|
||||
}
|
||||
|
||||
if let Some(ref status) = modification.status {
|
||||
task.set_status(status.clone())?;
|
||||
}
|
||||
|
||||
if let Some(true) = modification.active {
|
||||
task.start()?;
|
||||
}
|
||||
|
||||
if let Some(false) = modification.active {
|
||||
task.stop()?;
|
||||
}
|
||||
|
||||
for tag in modification.add_tags.iter() {
|
||||
task.add_tag(tag)?;
|
||||
}
|
||||
|
||||
for tag in modification.remove_tags.iter() {
|
||||
task.remove_tag(tag)?;
|
||||
}
|
||||
|
||||
if let Some(wait) = modification.wait {
|
||||
task.set_wait(wait)?;
|
||||
}
|
||||
|
||||
if let Some(ref ann) = modification.annotate {
|
||||
task.add_annotation(Annotation {
|
||||
entry: Utc::now(),
|
||||
description: ann.into(),
|
||||
})?;
|
||||
}
|
||||
|
||||
for tid in &modification.add_dependencies {
|
||||
if let TaskId::Uuid(u) = tid {
|
||||
task.add_dependency(*u)?;
|
||||
} else {
|
||||
// this Modification is resolved, so all TaskIds should
|
||||
// be the Uuid variant.
|
||||
unreachable!();
|
||||
}
|
||||
}
|
||||
|
||||
for tid in &modification.remove_dependencies {
|
||||
if let TaskId::Uuid(u) = tid {
|
||||
task.remove_dependency(*u)?;
|
||||
} else {
|
||||
// this Modification is resolved, so all TaskIds should
|
||||
// be the Uuid variant.
|
||||
unreachable!();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::invocation::test::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use taskchampion::{Status, Uuid};
|
||||
|
||||
#[test]
|
||||
fn test_resolve_modifications() {
|
||||
let mut replica = test_replica();
|
||||
let u1 = Uuid::new_v4();
|
||||
let t1 = replica.new_task(Status::Pending, "a task".into()).unwrap();
|
||||
replica.rebuild_working_set(true).unwrap();
|
||||
|
||||
let modi = Modification {
|
||||
add_dependencies: set![TaskId::Uuid(u1), TaskId::WorkingSetId(1)],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let res = resolve_modification(modi, &mut replica).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
res.0.add_dependencies,
|
||||
set![TaskId::Uuid(u1), TaskId::Uuid(t1.get_uuid())],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_task_ids_empty() {
|
||||
let mut replica = test_replica();
|
||||
|
||||
assert_eq!(
|
||||
resolve_task_ids(&mut replica, HashSet::new()).unwrap(),
|
||||
HashSet::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_task_ids_all_uuids() {
|
||||
let mut replica = test_replica();
|
||||
let uuid = Uuid::new_v4();
|
||||
let tids = set![TaskId::Uuid(uuid)];
|
||||
assert_eq!(resolve_task_ids(&mut replica, tids.clone()).unwrap(), tids);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_task_ids_working_set_not_found() {
|
||||
let mut replica = test_replica();
|
||||
let tids = set![TaskId::WorkingSetId(13)];
|
||||
assert_eq!(
|
||||
resolve_task_ids(&mut replica, tids.clone()).unwrap(),
|
||||
HashSet::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_task_ids_working_set() {
|
||||
let mut replica = test_replica();
|
||||
let t1 = replica.new_task(Status::Pending, "a task".into()).unwrap();
|
||||
let t2 = replica
|
||||
.new_task(Status::Pending, "another task".into())
|
||||
.unwrap();
|
||||
replica.rebuild_working_set(true).unwrap();
|
||||
let tids = set![TaskId::WorkingSetId(1), TaskId::WorkingSetId(2)];
|
||||
let resolved = set![TaskId::Uuid(t1.get_uuid()), TaskId::Uuid(t2.get_uuid())];
|
||||
assert_eq!(resolve_task_ids(&mut replica, tids).unwrap(), resolved);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_task_ids_partial_not_found() {
|
||||
let mut replica = test_replica();
|
||||
let tids = set![TaskId::PartialUuid("abcd".into())];
|
||||
assert_eq!(
|
||||
resolve_task_ids(&mut replica, tids.clone()).unwrap(),
|
||||
HashSet::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_resolve_task_ids_partial() {
|
||||
let mut replica = test_replica();
|
||||
let t1 = replica.new_task(Status::Pending, "a task".into()).unwrap();
|
||||
let uuid_str = t1.get_uuid().to_string();
|
||||
let tids = set![TaskId::PartialUuid(uuid_str[..8].into())];
|
||||
let resolved = set![TaskId::Uuid(t1.get_uuid())];
|
||||
assert_eq!(resolve_task_ids(&mut replica, tids).unwrap(), resolved);
|
||||
}
|
||||
}
|
|
@ -1,417 +0,0 @@
|
|||
use crate::argparse::Filter;
|
||||
use crate::invocation::filtered_tasks;
|
||||
use crate::settings::{Column, Property, Report, Settings, SortBy};
|
||||
use crate::table;
|
||||
use anyhow::anyhow;
|
||||
use prettytable::{Row, Table};
|
||||
use std::cmp::Ordering;
|
||||
use taskchampion::{Replica, Task, WorkingSet};
|
||||
use termcolor::WriteColor;
|
||||
|
||||
/// Sort tasks for the given report.
|
||||
fn sort_tasks(tasks: &mut Vec<Task>, report: &Report, working_set: &WorkingSet) {
|
||||
tasks.sort_by(|a, b| {
|
||||
for s in &report.sort {
|
||||
let ord = match s.sort_by {
|
||||
SortBy::Id => {
|
||||
let a_uuid = a.get_uuid();
|
||||
let b_uuid = b.get_uuid();
|
||||
let a_id = working_set.by_uuid(a_uuid);
|
||||
let b_id = working_set.by_uuid(b_uuid);
|
||||
match (a_id, b_id) {
|
||||
(Some(a_id), Some(b_id)) => a_id.cmp(&b_id),
|
||||
(Some(_), None) => Ordering::Less,
|
||||
(None, Some(_)) => Ordering::Greater,
|
||||
(None, None) => a_uuid.cmp(&b_uuid),
|
||||
}
|
||||
}
|
||||
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 {
|
||||
continue;
|
||||
}
|
||||
// Reverse order if not ascending
|
||||
if s.ascending {
|
||||
return ord;
|
||||
} else {
|
||||
return ord.reverse();
|
||||
}
|
||||
}
|
||||
Ordering::Equal
|
||||
});
|
||||
}
|
||||
|
||||
/// Generate the string representation for the given task and column.
|
||||
fn task_column(task: &Task, column: &Column, working_set: &WorkingSet) -> String {
|
||||
match column.property {
|
||||
Property::Id => {
|
||||
let uuid = task.get_uuid();
|
||||
let mut id = uuid.to_string();
|
||||
if let Some(i) = working_set.by_uuid(uuid) {
|
||||
id = i.to_string();
|
||||
}
|
||||
id
|
||||
}
|
||||
Property::Uuid => {
|
||||
let uuid = task.get_uuid();
|
||||
uuid.to_string()
|
||||
}
|
||||
Property::Active => match task.is_active() {
|
||||
true => "*".to_owned(),
|
||||
false => "".to_owned(),
|
||||
},
|
||||
Property::Description => task.get_description().to_owned(),
|
||||
Property::Tags => {
|
||||
let mut tags = task
|
||||
.get_tags()
|
||||
.map(|t| format!("+{}", t))
|
||||
.collect::<Vec<_>>();
|
||||
tags.sort();
|
||||
tags.join(" ")
|
||||
}
|
||||
Property::Wait => {
|
||||
if task.is_waiting() {
|
||||
task.get_wait().unwrap().format("%Y-%m-%d").to_string()
|
||||
} else {
|
||||
"".to_owned()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn display_report<W: WriteColor>(
|
||||
w: &mut W,
|
||||
replica: &mut Replica,
|
||||
settings: &Settings,
|
||||
report_name: String,
|
||||
filter: Filter,
|
||||
) -> Result<(), crate::Error> {
|
||||
let mut t = Table::new();
|
||||
let working_set = replica.working_set()?;
|
||||
|
||||
// Get the report from settings
|
||||
let mut report = settings
|
||||
.reports
|
||||
.get(&report_name)
|
||||
.ok_or_else(|| anyhow!("report `{}` not defined", report_name))?
|
||||
.clone();
|
||||
|
||||
// include any user-supplied filter conditions
|
||||
report.filter = report.filter.intersect(filter);
|
||||
|
||||
// Get the tasks from the filter
|
||||
let mut tasks: Vec<_> = filtered_tasks(replica, &report.filter)?.collect();
|
||||
|
||||
// ..sort them as desired
|
||||
sort_tasks(&mut tasks, &report, &working_set);
|
||||
|
||||
// ..set up the column titles
|
||||
t.set_format(table::format());
|
||||
t.set_titles(report.columns.iter().map(|col| col.label.clone()).into());
|
||||
|
||||
// ..insert the data
|
||||
for task in &tasks {
|
||||
let row: Row = report
|
||||
.columns
|
||||
.iter()
|
||||
.map(|col| task_column(task, col, &working_set))
|
||||
.collect::<Row>();
|
||||
t.add_row(row);
|
||||
}
|
||||
|
||||
// ..and display it
|
||||
t.print(w)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::invocation::test::*;
|
||||
use crate::settings::Sort;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::convert::TryInto;
|
||||
use taskchampion::chrono::{prelude::*, Duration};
|
||||
use taskchampion::{Status, Uuid};
|
||||
|
||||
fn create_tasks(replica: &mut Replica) -> [Uuid; 3] {
|
||||
let t1 = replica.new_task(Status::Pending, s!("A")).unwrap();
|
||||
let t2 = replica.new_task(Status::Pending, s!("B")).unwrap();
|
||||
let t3 = replica.new_task(Status::Pending, s!("C")).unwrap();
|
||||
|
||||
// t2 is comleted and not in the working set
|
||||
let mut t2 = t2.into_mut(replica);
|
||||
t2.set_status(Status::Completed).unwrap();
|
||||
let t2 = t2.into_immut();
|
||||
|
||||
replica.rebuild_working_set(true).unwrap();
|
||||
|
||||
[t1.get_uuid(), t2.get_uuid(), t3.get_uuid()]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_by_descr() {
|
||||
let mut replica = test_replica();
|
||||
create_tasks(&mut replica);
|
||||
let working_set = replica.working_set().unwrap();
|
||||
let mut report = Report {
|
||||
sort: vec![Sort {
|
||||
ascending: true,
|
||||
sort_by: SortBy::Description,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// ascending
|
||||
let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect();
|
||||
sort_tasks(&mut tasks, &report, &working_set);
|
||||
let descriptions: Vec<_> = tasks.iter().map(|t| t.get_description()).collect();
|
||||
assert_eq!(descriptions, vec!["A", "B", "C"]);
|
||||
|
||||
// ascending
|
||||
report.sort[0].ascending = false;
|
||||
let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect();
|
||||
sort_tasks(&mut tasks, &report, &working_set);
|
||||
let descriptions: Vec<_> = tasks.iter().map(|t| t.get_description()).collect();
|
||||
assert_eq!(descriptions, vec!["C", "B", "A"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_by_id() {
|
||||
let mut replica = test_replica();
|
||||
create_tasks(&mut replica);
|
||||
let working_set = replica.working_set().unwrap();
|
||||
let mut report = Report {
|
||||
sort: vec![Sort {
|
||||
ascending: true,
|
||||
sort_by: SortBy::Id,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// ascending
|
||||
let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect();
|
||||
sort_tasks(&mut tasks, &report, &working_set);
|
||||
let descriptions: Vec<_> = tasks.iter().map(|t| t.get_description()).collect();
|
||||
assert_eq!(descriptions, vec!["A", "C", "B"]);
|
||||
|
||||
// ascending
|
||||
report.sort[0].ascending = false;
|
||||
let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect();
|
||||
sort_tasks(&mut tasks, &report, &working_set);
|
||||
let descriptions: Vec<_> = tasks.iter().map(|t| t.get_description()).collect();
|
||||
assert_eq!(descriptions, vec!["B", "C", "A"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sorting_by_uuid() {
|
||||
let mut replica = test_replica();
|
||||
let uuids = create_tasks(&mut replica);
|
||||
let working_set = replica.working_set().unwrap();
|
||||
let report = Report {
|
||||
sort: vec![Sort {
|
||||
ascending: true,
|
||||
sort_by: SortBy::Uuid,
|
||||
}],
|
||||
..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 mut exp_uuids = uuids.to_vec();
|
||||
exp_uuids.sort();
|
||||
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() + Duration::days(2)))
|
||||
.unwrap();
|
||||
|
||||
replica
|
||||
.get_task(uuids[1])
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.into_mut(&mut replica)
|
||||
.set_wait(Some(Utc::now() + 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();
|
||||
create_tasks(&mut replica);
|
||||
|
||||
// make a second task named A with a larger ID than the first
|
||||
let t = replica.new_task(Status::Pending, s!("A")).unwrap();
|
||||
t.into_mut(&mut replica)
|
||||
.add_tag(&("second".try_into().unwrap()))
|
||||
.unwrap();
|
||||
|
||||
let working_set = replica.working_set().unwrap();
|
||||
let report = Report {
|
||||
sort: vec![
|
||||
Sort {
|
||||
ascending: false,
|
||||
sort_by: SortBy::Description,
|
||||
},
|
||||
Sort {
|
||||
ascending: true,
|
||||
sort_by: SortBy::Id,
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect();
|
||||
sort_tasks(&mut tasks, &report, &working_set);
|
||||
let descriptions: Vec<_> = tasks.iter().map(|t| t.get_description()).collect();
|
||||
assert_eq!(descriptions, vec!["C", "B", "A", "A"]);
|
||||
assert!(tasks[3].has_tag(&("second".try_into().unwrap())));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_column_id() {
|
||||
let mut replica = test_replica();
|
||||
let uuids = create_tasks(&mut replica);
|
||||
let working_set = replica.working_set().unwrap();
|
||||
|
||||
let task = replica.get_task(uuids[0]).unwrap().unwrap();
|
||||
let column = Column {
|
||||
label: s!(""),
|
||||
property: Property::Id,
|
||||
};
|
||||
assert_eq!(task_column(&task, &column, &working_set), s!("1"));
|
||||
|
||||
// get the task that's not in the working set, which should show
|
||||
// a uuid for its id column
|
||||
let task = replica.get_task(uuids[1]).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
task_column(&task, &column, &working_set),
|
||||
uuids[1].to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_column_uuid() {
|
||||
let mut replica = test_replica();
|
||||
let uuids = create_tasks(&mut replica);
|
||||
let working_set = replica.working_set().unwrap();
|
||||
|
||||
let task = replica.get_task(uuids[0]).unwrap().unwrap();
|
||||
let column = Column {
|
||||
label: s!(""),
|
||||
property: Property::Uuid,
|
||||
};
|
||||
assert_eq!(
|
||||
task_column(&task, &column, &working_set),
|
||||
task.get_uuid().to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_column_active() {
|
||||
let mut replica = test_replica();
|
||||
let uuids = create_tasks(&mut replica);
|
||||
let working_set = replica.working_set().unwrap();
|
||||
|
||||
// make task A active
|
||||
replica
|
||||
.get_task(uuids[0])
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.into_mut(&mut replica)
|
||||
.start()
|
||||
.unwrap();
|
||||
|
||||
let column = Column {
|
||||
label: s!(""),
|
||||
property: Property::Active,
|
||||
};
|
||||
|
||||
let task = replica.get_task(uuids[0]).unwrap().unwrap();
|
||||
assert_eq!(task_column(&task, &column, &working_set), s!("*"));
|
||||
let task = replica.get_task(uuids[2]).unwrap().unwrap();
|
||||
assert_eq!(task_column(&task, &column, &working_set), s!(""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_column_description() {
|
||||
let mut replica = test_replica();
|
||||
let uuids = create_tasks(&mut replica);
|
||||
let working_set = replica.working_set().unwrap();
|
||||
|
||||
let task = replica.get_task(uuids[2]).unwrap().unwrap();
|
||||
let column = Column {
|
||||
label: s!(""),
|
||||
property: Property::Description,
|
||||
};
|
||||
assert_eq!(task_column(&task, &column, &working_set), s!("C"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_column_tags() {
|
||||
let mut replica = test_replica();
|
||||
let uuids = create_tasks(&mut replica);
|
||||
let working_set = replica.working_set().unwrap();
|
||||
|
||||
// add some tags to task A
|
||||
let mut t1 = replica
|
||||
.get_task(uuids[0])
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.into_mut(&mut replica);
|
||||
t1.add_tag(&("foo".try_into().unwrap())).unwrap();
|
||||
t1.add_tag(&("bar".try_into().unwrap())).unwrap();
|
||||
|
||||
let column = Column {
|
||||
label: s!(""),
|
||||
property: Property::Tags,
|
||||
};
|
||||
|
||||
let task = replica.get_task(uuids[0]).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
task_column(&task, &column, &working_set),
|
||||
s!("+PENDING +UNBLOCKED +bar +foo")
|
||||
);
|
||||
let task = replica.get_task(uuids[2]).unwrap().unwrap();
|
||||
assert_eq!(
|
||||
task_column(&task, &column, &working_set),
|
||||
s!("+PENDING +UNBLOCKED")
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,51 +0,0 @@
|
|||
use std::io;
|
||||
use taskchampion::{storage, Replica, Server, ServerConfig};
|
||||
use tempfile::TempDir;
|
||||
|
||||
pub(super) fn test_replica() -> Replica {
|
||||
let storage = storage::InMemoryStorage::new();
|
||||
Replica::new(Box::new(storage))
|
||||
}
|
||||
|
||||
pub(super) fn test_server(dir: &TempDir) -> Box<dyn Server> {
|
||||
ServerConfig::Local {
|
||||
server_dir: dir.path().to_path_buf(),
|
||||
}
|
||||
.into_server()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub(super) struct TestWriter {
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl TestWriter {
|
||||
pub(super) fn into_string(self) -> String {
|
||||
String::from_utf8(self.data).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
impl io::Write for TestWriter {
|
||||
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
||||
self.data.write(buf)
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.data.flush()
|
||||
}
|
||||
}
|
||||
|
||||
impl termcolor::WriteColor for TestWriter {
|
||||
fn supports_color(&self) -> bool {
|
||||
false
|
||||
}
|
||||
fn set_color(&mut self, _spec: &termcolor::ColorSpec) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn reset(&mut self) -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn test_writer() -> TestWriter {
|
||||
TestWriter { data: vec![] }
|
||||
}
|
|
@ -1,22 +0,0 @@
|
|||
use dialoguer::Confirm;
|
||||
use taskchampion::{Replica, Task};
|
||||
|
||||
/// Print the prompt and ask the user to answer yes or no. If input is not from a terminal, the
|
||||
/// answer is assumed to be true.
|
||||
pub(super) fn confirm<S: Into<String>>(prompt: S) -> anyhow::Result<bool> {
|
||||
if !atty::is(atty::Stream::Stdin) {
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(Confirm::new().with_prompt(prompt).interact()?)
|
||||
}
|
||||
|
||||
/// Summarize a task in a single line
|
||||
pub(super) fn summarize_task(replica: &mut Replica, task: &Task) -> anyhow::Result<String> {
|
||||
let ws = replica.working_set()?;
|
||||
let uuid = task.get_uuid();
|
||||
if let Some(id) = ws.by_uuid(uuid) {
|
||||
Ok(format!("{} - {}", id, task.get_description()))
|
||||
} else {
|
||||
Ok(format!("{} - {}", uuid, task.get_description()))
|
||||
}
|
||||
}
|
|
@ -1,79 +0,0 @@
|
|||
#![deny(clippy::all)]
|
||||
#![allow(clippy::unnecessary_wraps)] // for Rust 1.50, https://github.com/rust-lang/rust-clippy/pull/6765
|
||||
#![allow(clippy::module_inception)] // we use re-exports to shorten stuttering paths like settings::settings::Settings
|
||||
/*!
|
||||
This crate implements the command-line interface to TaskChampion.
|
||||
|
||||
## Design
|
||||
|
||||
The crate is split into two parts: argument parsing (`argparse`) and command invocation (`invocation`).
|
||||
Both are fairly complex operations, and the split serves both to isolate that complexity and to facilitate testing.
|
||||
|
||||
### Argparse
|
||||
|
||||
The TaskChampion command line API is modeled on TaskWarrior's API, which is far from that of a typical UNIX command.
|
||||
Tools like `clap` and `structopt` are not flexible enough to handle this syntax.
|
||||
|
||||
Instead, the `argparse` module uses [nom](https://crates.io/crates/nom) to parse command lines as a sequence of words.
|
||||
These parsers act on a list of strings, `&[&str]`, and at the top level return a `crate::argparse::Command`.
|
||||
This is a wholly-owned repesentation of the command line's meaning, but with some interpretation.
|
||||
For example, `task start`, `task stop`, and `task append` all map to a `crate::argparse::Subcommand::Modify` variant.
|
||||
|
||||
### Invocation
|
||||
|
||||
The `invocation` module executes a `Command`, given some settings and other ancillary data.
|
||||
Most of its functionality is in common functions to handle filtering tasks, modifying tasks, and so on.
|
||||
|
||||
## Rust API
|
||||
|
||||
Note that this crate does not expose a Rust API for use from other crates.
|
||||
For the public TaskChampion Rust API, see the `taskchampion` crate.
|
||||
|
||||
*/
|
||||
|
||||
use std::ffi::OsString;
|
||||
|
||||
// NOTE: it's important that this 'mod' comes first so that the macros can be used in other modules
|
||||
mod macros;
|
||||
|
||||
mod argparse;
|
||||
mod errors;
|
||||
mod invocation;
|
||||
mod settings;
|
||||
mod table;
|
||||
mod tdb2;
|
||||
mod usage;
|
||||
|
||||
/// See https://docs.rs/built
|
||||
pub(crate) mod built_info {
|
||||
include!(concat!(env!("OUT_DIR"), "/built.rs"));
|
||||
}
|
||||
|
||||
pub(crate) use errors::Error;
|
||||
use settings::Settings;
|
||||
|
||||
// used by the `generate` command
|
||||
pub use usage::Usage;
|
||||
|
||||
/// The main entry point for the command-line interface. This builds an Invocation
|
||||
/// from the particulars of the operating-system interface, and then executes it.
|
||||
pub fn main() -> Result<(), Error> {
|
||||
env_logger::init();
|
||||
|
||||
// parse the command line into a vector of &str, failing if
|
||||
// there are invalid utf-8 sequences.
|
||||
let argv: Vec<String> = std::env::args_os()
|
||||
.map(|oss| oss.into_string())
|
||||
.collect::<Result<_, OsString>>()
|
||||
.map_err(|_| Error::for_arguments("arguments must be valid utf-8"))?;
|
||||
let argv: Vec<&str> = argv.iter().map(|s| s.as_ref()).collect();
|
||||
|
||||
// parse the command line
|
||||
let command = argparse::Command::from_argv(&argv[..])?;
|
||||
|
||||
// load the application settings
|
||||
let settings = Settings::read()?;
|
||||
|
||||
invocation::invoke(command, settings)?;
|
||||
Ok(())
|
||||
}
|
|
@ -1,40 +0,0 @@
|
|||
#![macro_use]
|
||||
|
||||
/// create a &[&str] from vec notation
|
||||
#[cfg(test)]
|
||||
macro_rules! argv {
|
||||
() => (
|
||||
&[][..]
|
||||
);
|
||||
($($x:expr),* $(,)?) => (
|
||||
&[$($x),*][..]
|
||||
);
|
||||
}
|
||||
|
||||
/// Create a hashset, similar to vec!
|
||||
// NOTE: in Rust 1.56.0, this can be changed to HashSet::from([..])
|
||||
#[cfg(test)]
|
||||
macro_rules! set(
|
||||
{ $($key:expr),* $(,)? } => {
|
||||
{
|
||||
#[allow(unused_mut)]
|
||||
let mut s = ::std::collections::HashSet::new();
|
||||
$(
|
||||
s.insert($key);
|
||||
)*
|
||||
s
|
||||
}
|
||||
};
|
||||
);
|
||||
|
||||
/// Create a String from an &str; just a testing shorthand
|
||||
#[cfg(test)]
|
||||
macro_rules! s(
|
||||
{ $s:expr } => { $s.to_owned() };
|
||||
);
|
||||
|
||||
/// Create a Tag from an &str; just a testing shorthand
|
||||
#[cfg(test)]
|
||||
macro_rules! tag(
|
||||
{ $s:expr } => { { use std::convert::TryFrom; taskchampion::Tag::try_from($s).unwrap() } };
|
||||
);
|
|
@ -1,11 +0,0 @@
|
|||
//! Support for the CLI's configuration file, including default settings.
|
||||
//!
|
||||
//! Configuration is stored in a "parsed" format, meaning that any syntax errors will be caught on
|
||||
//! startup and not just when those values are used.
|
||||
|
||||
mod report;
|
||||
mod settings;
|
||||
mod util;
|
||||
|
||||
pub(crate) use report::{get_usage, Column, Property, Report, Sort, SortBy};
|
||||
pub(crate) use settings::Settings;
|
|
@ -1,580 +0,0 @@
|
|||
//! This module contains the data structures used to define reports.
|
||||
|
||||
use crate::argparse::{Condition, Filter};
|
||||
use crate::settings::util::table_with_keys;
|
||||
use crate::usage::{self, Usage};
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use std::convert::{TryFrom, TryInto};
|
||||
|
||||
/// A report specifies a filter as well as a sort order and information about which
|
||||
/// task attributes to display
|
||||
#[derive(Clone, Debug, PartialEq, Default)]
|
||||
pub(crate) struct Report {
|
||||
/// Columns to display in this report
|
||||
pub columns: Vec<Column>,
|
||||
/// Sort order for this report
|
||||
pub sort: Vec<Sort>,
|
||||
/// Filter selecting tasks for this report
|
||||
pub filter: Filter,
|
||||
}
|
||||
|
||||
/// A column to display in a report
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Column {
|
||||
/// The label for this column
|
||||
pub label: String,
|
||||
|
||||
/// The property to display
|
||||
pub property: Property,
|
||||
}
|
||||
|
||||
/// Task property to display in a report
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum Property {
|
||||
// NOTE: when adding a property here, add it to get_usage, below, as well.
|
||||
/// The task's ID, either working-set index or Uuid if not in the working set
|
||||
Id,
|
||||
|
||||
/// The task's full UUID
|
||||
Uuid,
|
||||
|
||||
/// Whether the task is active or not
|
||||
Active,
|
||||
|
||||
/// The task's description
|
||||
Description,
|
||||
|
||||
/// The task's tags
|
||||
Tags,
|
||||
|
||||
/// The task's wait date
|
||||
Wait,
|
||||
}
|
||||
|
||||
/// A sorting criterion for a sort operation.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) struct Sort {
|
||||
/// True if the sort should be "ascending" (a -> z, 0 -> 9, etc.)
|
||||
pub ascending: bool,
|
||||
|
||||
/// The property to sort on
|
||||
pub sort_by: SortBy,
|
||||
}
|
||||
|
||||
/// Task property to sort by
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub(crate) enum SortBy {
|
||||
// NOTE: when adding a property here, add it to get_usage, below, as well.
|
||||
/// The task's ID, either working-set index or a UUID prefix; working
|
||||
/// set tasks sort before others.
|
||||
Id,
|
||||
|
||||
/// The task's full UUID
|
||||
Uuid,
|
||||
|
||||
/// The task's description
|
||||
Description,
|
||||
|
||||
/// The task's wait date
|
||||
Wait,
|
||||
}
|
||||
|
||||
// Conversions from settings::Settings.
|
||||
|
||||
impl TryFrom<toml::Value> for Report {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(cfg: toml::Value) -> Result<Report> {
|
||||
Report::try_from(&cfg)
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&toml::Value> for Report {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
/// Create a Report from a toml value. This should be the `report.<report_name>` value.
|
||||
/// The error message begins with any additional path information, e.g., `.sort[1].sort_by:
|
||||
/// ..`.
|
||||
fn try_from(cfg: &toml::Value) -> Result<Report> {
|
||||
let keys = ["sort", "columns", "filter"];
|
||||
let table = table_with_keys(cfg, &keys).map_err(|e| anyhow!(": {}", e))?;
|
||||
|
||||
let sort = match table.get("sort") {
|
||||
Some(v) => v
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow!(".sort: not an array"))?
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| v.try_into().map_err(|e| anyhow!(".sort[{}]{}", i, e)))
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
let columns = match table.get("columns") {
|
||||
Some(v) => v
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow!(".columns: not an array"))?
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| v.try_into().map_err(|e| anyhow!(".columns[{}]{}", i, e)))
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
None => bail!(": `columns` property is required"),
|
||||
};
|
||||
|
||||
let conditions = match table.get("filter") {
|
||||
Some(v) => v
|
||||
.as_array()
|
||||
.ok_or_else(|| anyhow!(".filter: not an array"))?
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, v)| {
|
||||
v.as_str()
|
||||
.ok_or_else(|| anyhow!(".filter[{}]: not a string", i))
|
||||
.and_then(Condition::parse_str)
|
||||
.map_err(|e| anyhow!(".filter[{}]: {}", i, e))
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?,
|
||||
None => vec![],
|
||||
};
|
||||
|
||||
Ok(Report {
|
||||
columns,
|
||||
sort,
|
||||
filter: Filter { conditions },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&toml::Value> for Column {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(cfg: &toml::Value) -> Result<Column> {
|
||||
let keys = ["label", "property"];
|
||||
let table = table_with_keys(cfg, &keys).map_err(|e| anyhow!(": {}", e))?;
|
||||
|
||||
let label = match table.get("label") {
|
||||
Some(v) => v
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!(".label: not a string"))?
|
||||
.to_owned(),
|
||||
None => bail!(": `label` property is required"),
|
||||
};
|
||||
|
||||
let property = match table.get("property") {
|
||||
Some(v) => v.try_into().map_err(|e| anyhow!(".property{}", e))?,
|
||||
None => bail!(": `property` property is required"),
|
||||
};
|
||||
|
||||
Ok(Column { label, property })
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&toml::Value> for Property {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(cfg: &toml::Value) -> Result<Property> {
|
||||
let s = cfg.as_str().ok_or_else(|| anyhow!(": not a string"))?;
|
||||
Ok(match s {
|
||||
"id" => Property::Id,
|
||||
"uuid" => Property::Uuid,
|
||||
"active" => Property::Active,
|
||||
"description" => Property::Description,
|
||||
"tags" => Property::Tags,
|
||||
"wait" => Property::Wait,
|
||||
_ => bail!(": unknown property {}", s),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&toml::Value> for Sort {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(cfg: &toml::Value) -> Result<Sort> {
|
||||
let keys = ["ascending", "sort_by"];
|
||||
let table = table_with_keys(cfg, &keys).map_err(|e| anyhow!(": {}", e))?;
|
||||
let ascending = match table.get("ascending") {
|
||||
Some(v) => v
|
||||
.as_bool()
|
||||
.ok_or_else(|| anyhow!(".ascending: not a boolean value"))?,
|
||||
None => true, // default
|
||||
};
|
||||
|
||||
let sort_by = match table.get("sort_by") {
|
||||
Some(v) => v.try_into().map_err(|e| anyhow!(".sort_by{}", e))?,
|
||||
None => bail!(": `sort_by` property is required"),
|
||||
};
|
||||
|
||||
Ok(Sort { ascending, sort_by })
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<&toml::Value> for SortBy {
|
||||
type Error = anyhow::Error;
|
||||
|
||||
fn try_from(cfg: &toml::Value) -> Result<SortBy> {
|
||||
let s = cfg.as_str().ok_or_else(|| anyhow!(": not a string"))?;
|
||||
Ok(match s {
|
||||
"id" => SortBy::Id,
|
||||
"uuid" => SortBy::Uuid,
|
||||
"description" => SortBy::Description,
|
||||
"wait" => SortBy::Wait,
|
||||
_ => bail!(": unknown sort_by value `{}`", s),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn get_usage(u: &mut Usage) {
|
||||
u.report_properties.push(usage::ReportProperty {
|
||||
name: "id",
|
||||
as_sort_by: Some("Sort by the task's shorthand ID"),
|
||||
as_column: Some("The task's shorthand ID"),
|
||||
});
|
||||
u.report_properties.push(usage::ReportProperty {
|
||||
name: "uuid",
|
||||
as_sort_by: Some("Sort by the task's full UUID"),
|
||||
as_column: Some("The task's full UUID"),
|
||||
});
|
||||
u.report_properties.push(usage::ReportProperty {
|
||||
name: "active",
|
||||
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"),
|
||||
as_column: Some("The task's description"),
|
||||
});
|
||||
u.report_properties.push(usage::ReportProperty {
|
||||
name: "tags",
|
||||
as_sort_by: None,
|
||||
as_column: Some("The task's tags"),
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use taskchampion::Status;
|
||||
use toml::toml;
|
||||
|
||||
#[test]
|
||||
fn test_report_ok() {
|
||||
let val = toml! {
|
||||
sort = []
|
||||
columns = []
|
||||
filter = ["status:pending"]
|
||||
};
|
||||
let report: Report = TryInto::try_into(val).unwrap();
|
||||
assert_eq!(
|
||||
report.filter,
|
||||
Filter {
|
||||
conditions: vec![Condition::Status(Status::Pending),],
|
||||
}
|
||||
);
|
||||
assert_eq!(report.columns, vec![]);
|
||||
assert_eq!(report.sort, vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_report_no_sort() {
|
||||
let val = toml! {
|
||||
filter = []
|
||||
columns = []
|
||||
};
|
||||
let report = Report::try_from(val).unwrap();
|
||||
assert_eq!(report.sort, vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_report_sort_not_array() {
|
||||
let val = toml! {
|
||||
filter = []
|
||||
sort = true
|
||||
columns = []
|
||||
};
|
||||
let err = Report::try_from(val).unwrap_err().to_string();
|
||||
assert_eq!(&err, ".sort: not an array");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_report_sort_error() {
|
||||
let val = toml! {
|
||||
filter = []
|
||||
sort = [ { sort_by = "id" }, true ]
|
||||
columns = []
|
||||
};
|
||||
let err = Report::try_from(val).unwrap_err().to_string();
|
||||
assert!(err.starts_with(".sort[1]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_report_unknown_prop() {
|
||||
let val = toml! {
|
||||
columns = []
|
||||
filter = []
|
||||
sort = []
|
||||
nosuch = true
|
||||
};
|
||||
let err = Report::try_from(val).unwrap_err().to_string();
|
||||
assert_eq!(&err, ": unknown table key `nosuch`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_report_no_columns() {
|
||||
let val = toml! {
|
||||
filter = []
|
||||
sort = []
|
||||
};
|
||||
let err = Report::try_from(val).unwrap_err().to_string();
|
||||
assert_eq!(&err, ": `columns` property is required");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_report_columns_not_array() {
|
||||
let val = toml! {
|
||||
filter = []
|
||||
sort = []
|
||||
columns = true
|
||||
};
|
||||
let err = Report::try_from(val).unwrap_err().to_string();
|
||||
assert_eq!(&err, ".columns: not an array");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_report_column_error() {
|
||||
let val = toml! {
|
||||
filter = []
|
||||
sort = []
|
||||
|
||||
[[columns]]
|
||||
label = "ID"
|
||||
property = "id"
|
||||
|
||||
[[columns]]
|
||||
foo = 10
|
||||
};
|
||||
let err = Report::try_from(val).unwrap_err().to_string();
|
||||
assert_eq!(&err, ".columns[1]: unknown table key `foo`");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_report_filter_not_array() {
|
||||
let val = toml! {
|
||||
filter = "foo"
|
||||
sort = []
|
||||
columns = []
|
||||
};
|
||||
let err = Report::try_from(val).unwrap_err().to_string();
|
||||
assert_eq!(&err, ".filter: not an array");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_report_filter_error() {
|
||||
let val = toml! {
|
||||
sort = []
|
||||
columns = []
|
||||
filter = [ "nosuchfilter" ]
|
||||
};
|
||||
let err = Report::try_from(val).unwrap_err().to_string();
|
||||
assert!(err.starts_with(".filter[0]: invalid filter condition:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_column() {
|
||||
let val = toml! {
|
||||
label = "ID"
|
||||
property = "id"
|
||||
};
|
||||
let column = Column::try_from(&val).unwrap();
|
||||
assert_eq!(
|
||||
column,
|
||||
Column {
|
||||
label: "ID".to_owned(),
|
||||
property: Property::Id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_column_unknown_prop() {
|
||||
let val = toml! {
|
||||
label = "ID"
|
||||
property = "id"
|
||||
nosuch = "foo"
|
||||
};
|
||||
assert_eq!(
|
||||
&Column::try_from(&val).unwrap_err().to_string(),
|
||||
": unknown table key `nosuch`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_column_no_label() {
|
||||
let val = toml! {
|
||||
property = "id"
|
||||
};
|
||||
assert_eq!(
|
||||
&Column::try_from(&val).unwrap_err().to_string(),
|
||||
": `label` property is required"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_column_invalid_label() {
|
||||
let val = toml! {
|
||||
label = []
|
||||
property = "id"
|
||||
};
|
||||
assert_eq!(
|
||||
&Column::try_from(&val).unwrap_err().to_string(),
|
||||
".label: not a string"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_column_no_property() {
|
||||
let val = toml! {
|
||||
label = "ID"
|
||||
};
|
||||
assert_eq!(
|
||||
&Column::try_from(&val).unwrap_err().to_string(),
|
||||
": `property` property is required"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_column_invalid_property() {
|
||||
let val = toml! {
|
||||
label = "ID"
|
||||
property = []
|
||||
};
|
||||
assert_eq!(
|
||||
&Column::try_from(&val).unwrap_err().to_string(),
|
||||
".property: not a string"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_property() {
|
||||
let val = toml::Value::String("uuid".to_owned());
|
||||
let prop = Property::try_from(&val).unwrap();
|
||||
assert_eq!(prop, Property::Uuid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_property_invalid_type() {
|
||||
let val = toml::Value::Array(vec![]);
|
||||
assert_eq!(
|
||||
&Property::try_from(&val).unwrap_err().to_string(),
|
||||
": not a string"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort() {
|
||||
let val = toml! {
|
||||
ascending = false
|
||||
sort_by = "id"
|
||||
};
|
||||
let sort = Sort::try_from(&val).unwrap();
|
||||
assert_eq!(
|
||||
sort,
|
||||
Sort {
|
||||
ascending: false,
|
||||
sort_by: SortBy::Id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_no_ascending() {
|
||||
let val = toml! {
|
||||
sort_by = "id"
|
||||
};
|
||||
let sort = Sort::try_from(&val).unwrap();
|
||||
assert_eq!(
|
||||
sort,
|
||||
Sort {
|
||||
ascending: true,
|
||||
sort_by: SortBy::Id,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_unknown_prop() {
|
||||
let val = toml! {
|
||||
sort_by = "id"
|
||||
nosuch = true
|
||||
};
|
||||
assert_eq!(
|
||||
&Sort::try_from(&val).unwrap_err().to_string(),
|
||||
": unknown table key `nosuch`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_no_sort_by() {
|
||||
let val = toml! {
|
||||
ascending = true
|
||||
};
|
||||
assert_eq!(
|
||||
&Sort::try_from(&val).unwrap_err().to_string(),
|
||||
": `sort_by` property is required"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_invalid_ascending() {
|
||||
let val = toml! {
|
||||
sort_by = "id"
|
||||
ascending = {}
|
||||
};
|
||||
assert_eq!(
|
||||
&Sort::try_from(&val).unwrap_err().to_string(),
|
||||
".ascending: not a boolean value"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_invalid_sort_by() {
|
||||
let val = toml! {
|
||||
sort_by = {}
|
||||
};
|
||||
assert_eq!(
|
||||
&Sort::try_from(&val).unwrap_err().to_string(),
|
||||
".sort_by: not a string"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_by() {
|
||||
let val = toml::Value::String("uuid".to_string());
|
||||
let prop = SortBy::try_from(&val).unwrap();
|
||||
assert_eq!(prop, SortBy::Uuid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_by_unknown() {
|
||||
let val = toml::Value::String("nosuch".to_string());
|
||||
assert_eq!(
|
||||
&SortBy::try_from(&val).unwrap_err().to_string(),
|
||||
": unknown sort_by value `nosuch`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sort_by_invalid_type() {
|
||||
let val = toml::Value::Array(vec![]);
|
||||
assert_eq!(
|
||||
&SortBy::try_from(&val).unwrap_err().to_string(),
|
||||
": not a string"
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,449 +0,0 @@
|
|||
use super::util::table_with_keys;
|
||||
use super::{Column, Property, Report, Sort, SortBy};
|
||||
use crate::argparse::{Condition, Filter};
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use std::collections::HashMap;
|
||||
use std::convert::TryFrom;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use taskchampion::Status;
|
||||
use toml::value::Table;
|
||||
use toml_edit::Document;
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) struct Settings {
|
||||
/// filename from which this configuration was loaded, if any
|
||||
pub(crate) filename: Option<PathBuf>,
|
||||
|
||||
/// Maximum number of tasks to modify without a confirmation prompt; `Some(0)` means to never
|
||||
/// prompt, and `None` means to use the default value.
|
||||
pub(crate) modification_count_prompt: Option<i64>,
|
||||
|
||||
/// replica
|
||||
pub(crate) data_dir: PathBuf,
|
||||
pub(crate) avoid_snapshots: bool,
|
||||
|
||||
/// remote sync server
|
||||
pub(crate) server_client_key: Option<String>,
|
||||
pub(crate) server_origin: Option<String>,
|
||||
pub(crate) encryption_secret: Option<String>,
|
||||
|
||||
/// local sync server
|
||||
pub(crate) server_dir: PathBuf,
|
||||
|
||||
/// reports
|
||||
pub(crate) reports: HashMap<String, Report>,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
pub(crate) fn read() -> Result<Self> {
|
||||
if let Some(config_file) = env::var_os("TASKCHAMPION_CONFIG") {
|
||||
log::debug!("Loading configuration from {:?}", config_file);
|
||||
env::remove_var("TASKCHAMPION_CONFIG");
|
||||
Self::load_from_file(config_file.into(), true)
|
||||
} else if let Some(filename) = Settings::default_filename() {
|
||||
log::debug!("Loading configuration from {:?} (optional)", filename);
|
||||
Self::load_from_file(filename, false)
|
||||
} else {
|
||||
Ok(Default::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the default filename for the configuration, or None if that cannot
|
||||
/// be determined.
|
||||
fn default_filename() -> Option<PathBuf> {
|
||||
dirs_next::config_dir().map(|dir| dir.join("taskchampion.toml"))
|
||||
}
|
||||
|
||||
/// Update this settings object with the contents of the given TOML file. Top-level settings
|
||||
/// are overwritten, and reports are overwritten by name.
|
||||
pub(crate) fn load_from_file(config_file: PathBuf, required: bool) -> Result<Self> {
|
||||
let mut settings = Self::default();
|
||||
|
||||
let config_toml = match fs::read_to_string(config_file.clone()) {
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
return if required {
|
||||
Err(e.into())
|
||||
} else {
|
||||
settings.filename = Some(config_file);
|
||||
Ok(settings)
|
||||
};
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
Ok(s) => s,
|
||||
};
|
||||
|
||||
let config_toml = config_toml
|
||||
.parse::<toml::Value>()
|
||||
.with_context(|| format!("error while reading {:?}", config_file))?;
|
||||
|
||||
settings.filename = Some(config_file.clone());
|
||||
settings
|
||||
.update_from_toml(&config_toml)
|
||||
.with_context(|| format!("error while parsing {:?}", config_file))?;
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
/// Update this object with configuration from the given config file. This is
|
||||
/// broken out mostly for convenience in error handling
|
||||
fn update_from_toml(&mut self, config_toml: &toml::Value) -> Result<()> {
|
||||
let table_keys = [
|
||||
"data_dir",
|
||||
"modification_count_prompt",
|
||||
"avoid_snapshots",
|
||||
"server_client_key",
|
||||
"server_origin",
|
||||
"encryption_secret",
|
||||
"server_dir",
|
||||
"reports",
|
||||
];
|
||||
let table = table_with_keys(config_toml, &table_keys)?;
|
||||
|
||||
fn get_str_cfg<F: FnOnce(String)>(
|
||||
table: &Table,
|
||||
name: &'static str,
|
||||
setter: F,
|
||||
) -> Result<()> {
|
||||
if let Some(v) = table.get(name) {
|
||||
setter(
|
||||
v.as_str()
|
||||
.ok_or_else(|| anyhow!(".{}: not a string", name))?
|
||||
.to_owned(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_i64_cfg<F: FnOnce(i64)>(table: &Table, name: &'static str, setter: F) -> Result<()> {
|
||||
if let Some(v) = table.get(name) {
|
||||
setter(
|
||||
v.as_integer()
|
||||
.ok_or_else(|| anyhow!(".{}: not a number", name))?,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_bool_cfg<F: FnOnce(bool)>(
|
||||
table: &Table,
|
||||
name: &'static str,
|
||||
setter: F,
|
||||
) -> Result<()> {
|
||||
if let Some(v) = table.get(name) {
|
||||
setter(
|
||||
v.as_bool()
|
||||
.ok_or_else(|| anyhow!(".{}: not a boolean value", name))?,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
get_str_cfg(table, "data_dir", |v| {
|
||||
self.data_dir = v.into();
|
||||
})?;
|
||||
|
||||
get_i64_cfg(table, "modification_count_prompt", |v| {
|
||||
self.modification_count_prompt = Some(v);
|
||||
})?;
|
||||
|
||||
get_bool_cfg(table, "avoid_snapshots", |v| {
|
||||
self.avoid_snapshots = v;
|
||||
})?;
|
||||
|
||||
get_str_cfg(table, "server_client_key", |v| {
|
||||
self.server_client_key = Some(v);
|
||||
})?;
|
||||
|
||||
get_str_cfg(table, "server_origin", |v| {
|
||||
self.server_origin = Some(v);
|
||||
})?;
|
||||
|
||||
get_str_cfg(table, "encryption_secret", |v| {
|
||||
self.encryption_secret = Some(v);
|
||||
})?;
|
||||
|
||||
get_str_cfg(table, "server_dir", |v| {
|
||||
self.server_dir = v.into();
|
||||
})?;
|
||||
|
||||
if let Some(v) = table.get("reports") {
|
||||
let report_cfgs = v
|
||||
.as_table()
|
||||
.ok_or_else(|| anyhow!(".reports: not a table"))?;
|
||||
for (name, cfg) in report_cfgs {
|
||||
let report = Report::try_from(cfg).map_err(|e| anyhow!("reports.{}{}", name, e))?;
|
||||
self.reports.insert(name.clone(), report);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Set a value in the config file, modifying it in place. Returns the filename. The value is
|
||||
/// interpreted as the appropriate type for the configuration setting.
|
||||
pub(crate) fn set(&self, key: &str, value: &str) -> Result<PathBuf> {
|
||||
let allowed_keys = [
|
||||
"data_dir",
|
||||
"modification_count_prompt",
|
||||
"server_client_key",
|
||||
"server_origin",
|
||||
"encryption_secret",
|
||||
"server_dir",
|
||||
// reports is not allowed, since it is not a string
|
||||
];
|
||||
if !allowed_keys.contains(&key) {
|
||||
bail!("No such configuration key {}", key);
|
||||
}
|
||||
|
||||
let filename = if let Some(ref f) = self.filename {
|
||||
f.clone()
|
||||
} else {
|
||||
Settings::default_filename()
|
||||
.ok_or_else(|| anyhow!("Could not determine config file name"))?
|
||||
};
|
||||
|
||||
let exists = filename.exists();
|
||||
|
||||
// try to create the parent directory if the file does not exist
|
||||
if !exists {
|
||||
if let Some(dir) = filename.parent() {
|
||||
fs::create_dir_all(dir)?;
|
||||
}
|
||||
}
|
||||
|
||||
// start with the existing document, or a blank document
|
||||
let mut document = if exists {
|
||||
fs::read_to_string(filename.clone())
|
||||
.context("Could not read existing configuration file")?
|
||||
.parse::<Document>()
|
||||
.context("Could not parse existing configuration file")?
|
||||
} else {
|
||||
Document::new()
|
||||
};
|
||||
|
||||
// set the value as the correct type
|
||||
match key {
|
||||
// integers
|
||||
"modification_count_prompt" => {
|
||||
let value: i64 = value.parse()?;
|
||||
document[key] = toml_edit::value(value);
|
||||
}
|
||||
|
||||
// most keys are strings
|
||||
_ => document[key] = toml_edit::value(value),
|
||||
}
|
||||
|
||||
fs::write(filename.clone(), document.to_string())
|
||||
.context("Could not write updated configuration file")?;
|
||||
|
||||
Ok(filename)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
let data_dir;
|
||||
let server_dir;
|
||||
|
||||
if let Some(dir) = dirs_next::data_local_dir() {
|
||||
data_dir = dir.join("taskchampion");
|
||||
server_dir = dir.join("taskchampion-sync-server");
|
||||
} else {
|
||||
// fallback
|
||||
data_dir = PathBuf::from(".");
|
||||
server_dir = PathBuf::from(".");
|
||||
}
|
||||
|
||||
// define the default reports
|
||||
let mut reports = HashMap::new();
|
||||
|
||||
reports.insert(
|
||||
"list".to_owned(),
|
||||
Report {
|
||||
sort: vec![Sort {
|
||||
ascending: true,
|
||||
sort_by: SortBy::Uuid,
|
||||
}],
|
||||
columns: vec![
|
||||
Column {
|
||||
label: "id".to_owned(),
|
||||
property: Property::Id,
|
||||
},
|
||||
Column {
|
||||
label: "description".to_owned(),
|
||||
property: Property::Description,
|
||||
},
|
||||
Column {
|
||||
label: "active".to_owned(),
|
||||
property: Property::Active,
|
||||
},
|
||||
Column {
|
||||
label: "tags".to_owned(),
|
||||
property: Property::Tags,
|
||||
},
|
||||
Column {
|
||||
label: "wait".to_owned(),
|
||||
property: Property::Wait,
|
||||
},
|
||||
],
|
||||
filter: Default::default(),
|
||||
},
|
||||
);
|
||||
|
||||
reports.insert(
|
||||
"next".to_owned(),
|
||||
Report {
|
||||
sort: vec![
|
||||
Sort {
|
||||
ascending: true,
|
||||
sort_by: SortBy::Id,
|
||||
},
|
||||
Sort {
|
||||
ascending: true,
|
||||
sort_by: SortBy::Uuid,
|
||||
},
|
||||
],
|
||||
columns: vec![
|
||||
Column {
|
||||
label: "id".to_owned(),
|
||||
property: Property::Id,
|
||||
},
|
||||
Column {
|
||||
label: "description".to_owned(),
|
||||
property: Property::Description,
|
||||
},
|
||||
Column {
|
||||
label: "active".to_owned(),
|
||||
property: Property::Active,
|
||||
},
|
||||
Column {
|
||||
label: "tags".to_owned(),
|
||||
property: Property::Tags,
|
||||
},
|
||||
],
|
||||
filter: Filter {
|
||||
conditions: vec![Condition::Status(Status::Pending)],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
Self {
|
||||
filename: None,
|
||||
data_dir,
|
||||
modification_count_prompt: None,
|
||||
avoid_snapshots: false,
|
||||
server_client_key: None,
|
||||
server_origin: None,
|
||||
encryption_secret: None,
|
||||
server_dir,
|
||||
reports,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use toml::toml;
|
||||
|
||||
#[test]
|
||||
fn test_load_from_file_not_required() {
|
||||
let cfg_dir = TempDir::new().unwrap();
|
||||
let cfg_file = cfg_dir.path().join("foo.toml");
|
||||
|
||||
let settings = Settings::load_from_file(cfg_file.clone(), false).unwrap();
|
||||
|
||||
let mut expected = Settings::default();
|
||||
expected.filename = Some(cfg_file.clone());
|
||||
assert_eq!(settings, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_from_file_required() {
|
||||
let cfg_dir = TempDir::new().unwrap();
|
||||
|
||||
assert!(Settings::load_from_file(cfg_dir.path().join("foo.toml"), true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_from_file_exists() {
|
||||
let cfg_dir = TempDir::new().unwrap();
|
||||
let cfg_file = cfg_dir.path().join("foo.toml");
|
||||
fs::write(cfg_file.clone(), "data_dir = \"/nowhere\"").unwrap();
|
||||
|
||||
let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap();
|
||||
assert_eq!(settings.data_dir, PathBuf::from("/nowhere"));
|
||||
assert_eq!(settings.filename, Some(cfg_file));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_from_toml_top_level_keys() {
|
||||
let val = toml! {
|
||||
data_dir = "/data"
|
||||
modification_count_prompt = 42
|
||||
server_client_key = "sck"
|
||||
server_origin = "so"
|
||||
encryption_secret = "es"
|
||||
server_dir = "/server"
|
||||
};
|
||||
let mut settings = Settings::default();
|
||||
settings.update_from_toml(&val).unwrap();
|
||||
|
||||
assert_eq!(settings.data_dir, PathBuf::from("/data"));
|
||||
assert_eq!(settings.modification_count_prompt, Some(42));
|
||||
assert_eq!(settings.server_client_key, Some("sck".to_owned()));
|
||||
assert_eq!(settings.server_origin, Some("so".to_owned()));
|
||||
assert_eq!(settings.encryption_secret, Some("es".to_owned()));
|
||||
assert_eq!(settings.server_dir, PathBuf::from("/server"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_from_toml_report() {
|
||||
let val = toml! {
|
||||
[reports.foo]
|
||||
sort = [ { sort_by = "id" } ]
|
||||
columns = [ { label = "ID", property = "id" } ]
|
||||
};
|
||||
let mut settings = Settings::default();
|
||||
settings.update_from_toml(&val).unwrap();
|
||||
|
||||
assert!(settings.reports.get("foo").is_some());
|
||||
// beyond existence of this report, we can rely on Report's unit tests
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_valid_key() {
|
||||
let cfg_dir = TempDir::new().unwrap();
|
||||
let cfg_file = cfg_dir.path().join("foo.toml");
|
||||
fs::write(cfg_file.clone(), "server_dir = \"/srv\"").unwrap();
|
||||
|
||||
let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap();
|
||||
assert_eq!(settings.filename, Some(cfg_file.clone()));
|
||||
settings.set("data_dir", "/data").unwrap();
|
||||
settings.set("modification_count_prompt", "42").unwrap();
|
||||
|
||||
// load the file again and see the changes
|
||||
let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap();
|
||||
assert_eq!(settings.data_dir, PathBuf::from("/data"));
|
||||
assert_eq!(settings.server_dir, PathBuf::from("/srv"));
|
||||
assert_eq!(settings.filename, Some(cfg_file));
|
||||
assert_eq!(settings.modification_count_prompt, Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_invalid_key() {
|
||||
let cfg_dir = TempDir::new().unwrap();
|
||||
let cfg_file = cfg_dir.path().join("foo.toml");
|
||||
fs::write(cfg_file.clone(), "server_dir = \"/srv\"").unwrap();
|
||||
|
||||
let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap();
|
||||
assert_eq!(settings.filename, Some(cfg_file.clone()));
|
||||
assert!(settings
|
||||
.set("modification_count_prompt", "a string?")
|
||||
.is_err());
|
||||
}
|
||||
}
|
|
@ -1,42 +0,0 @@
|
|||
use anyhow::{anyhow, bail, Result};
|
||||
use toml::value::Table;
|
||||
|
||||
/// Check that the input is a table and contains no keys not in the given list, returning
|
||||
/// the table.
|
||||
pub(super) fn table_with_keys<'a>(cfg: &'a toml::Value, keys: &[&str]) -> Result<&'a Table> {
|
||||
let table = cfg.as_table().ok_or_else(|| anyhow!("not a table"))?;
|
||||
|
||||
for tk in table.keys() {
|
||||
if !keys.iter().any(|k| k == tk) {
|
||||
bail!("unknown table key `{}`", tk);
|
||||
}
|
||||
}
|
||||
Ok(table)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use toml::toml;
|
||||
|
||||
#[test]
|
||||
fn test_dissect_table_missing() {
|
||||
let val = toml! { bar = true };
|
||||
let diss = table_with_keys(&val, &["foo", "bar"]).unwrap();
|
||||
assert_eq!(diss.get("bar"), Some(&toml::Value::Boolean(true)));
|
||||
assert_eq!(diss.get("foo"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dissect_table_extra() {
|
||||
let val = toml! { nosuch = 10 };
|
||||
assert!(table_with_keys(&val, &["foo", "bar"]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dissect_table_not_a_table() {
|
||||
let val = toml::Value::Array(vec![]);
|
||||
assert!(table_with_keys(&val, &["foo", "bar"]).is_err());
|
||||
}
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
use prettytable::format;
|
||||
|
||||
pub(crate) fn format() -> format::TableFormat {
|
||||
format::FormatBuilder::new()
|
||||
.column_separator(' ')
|
||||
.borders(' ')
|
||||
.build()
|
||||
}
|
|
@ -1,326 +0,0 @@
|
|||
//! TDB2 is TaskWarrior's on-disk database format. The set of tasks is represented in
|
||||
//! `pending.data` and `completed.data`. There are other `.data` files as well, but those are not
|
||||
//! used in TaskChampion.
|
||||
use nom::{branch::*, character::complete::*, combinator::*, multi::*, sequence::*, IResult};
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub(crate) struct File {
|
||||
pub(crate) lines: Vec<Line>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub(crate) struct Line {
|
||||
pub(crate) attrs: Vec<Attr>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub(crate) struct Attr {
|
||||
pub(crate) name: String,
|
||||
pub(crate) value: String,
|
||||
}
|
||||
|
||||
impl File {
|
||||
pub(crate) fn from_str(input: &str) -> Result<File, ()> {
|
||||
File::parse(input).map(|(_, res)| res).map_err(|_| ())
|
||||
}
|
||||
|
||||
fn parse(input: &str) -> IResult<&str, File> {
|
||||
all_consuming(fold_many0(
|
||||
// allow windows or normal newlines
|
||||
terminated(Line::parse, pair(opt(char('\r')), char('\n'))),
|
||||
File { lines: vec![] },
|
||||
|mut file, line| {
|
||||
file.lines.push(line);
|
||||
file
|
||||
},
|
||||
))(input)
|
||||
}
|
||||
}
|
||||
|
||||
impl Line {
|
||||
/// Parse a line in a TDB2 file. See TaskWarrior's Task::Parse.
|
||||
fn parse(input: &str) -> IResult<&str, Line> {
|
||||
fn to_line(input: Vec<Attr>) -> Result<Line, ()> {
|
||||
Ok(Line { attrs: input })
|
||||
}
|
||||
map_res(
|
||||
delimited(
|
||||
char('['),
|
||||
separated_list0(char(' '), Attr::parse),
|
||||
char(']'),
|
||||
),
|
||||
to_line,
|
||||
)(input)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Line {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("line!")?;
|
||||
f.debug_list().entries(self.attrs.iter()).finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Attr {
|
||||
/// Parse an attribute (name-value pair).
|
||||
fn parse(input: &str) -> IResult<&str, Attr> {
|
||||
fn to_attr(input: (&str, String)) -> Result<Attr, ()> {
|
||||
Ok(Attr {
|
||||
name: input.0.into(),
|
||||
value: input.1,
|
||||
})
|
||||
}
|
||||
map_res(
|
||||
separated_pair(Attr::parse_name, char(':'), Attr::parse_value),
|
||||
to_attr,
|
||||
)(input)
|
||||
}
|
||||
|
||||
/// Parse an attribute name, which is composed of any character but `:`.
|
||||
fn parse_name(input: &str) -> IResult<&str, &str> {
|
||||
recognize(many1(none_of(":")))(input)
|
||||
}
|
||||
|
||||
/// Parse and interpret a quoted string. Note that this does _not_ reverse the effects of
|
||||
|
||||
fn parse_value(input: &str) -> IResult<&str, String> {
|
||||
// For the parsing part of the job, see Pig::getQuoted in TaskWarrior's libshared, which
|
||||
// merely finds the end of a string.
|
||||
//
|
||||
// The interpretation is defined in json::decode in libshared. Fortunately, the data we
|
||||
// are reading was created with json::encode, which does not perform unicode escaping.
|
||||
|
||||
fn escaped_string_char(input: &str) -> IResult<&str, char> {
|
||||
alt((
|
||||
// reverse the escaping performed in json::encode
|
||||
preceded(
|
||||
char('\\'),
|
||||
alt((
|
||||
// some characters are simply escaped
|
||||
one_of(r#""\/"#),
|
||||
// others translate to control characters
|
||||
value('\x08', char('b')),
|
||||
value('\x0c', char('f')),
|
||||
value('\n', char('n')),
|
||||
value('\r', char('r')),
|
||||
value('\t', char('t')),
|
||||
)),
|
||||
),
|
||||
// not a backslash or double-quote
|
||||
none_of("\"\\"),
|
||||
))(input)
|
||||
}
|
||||
|
||||
let inner = fold_many0(
|
||||
escaped_string_char,
|
||||
String::new(),
|
||||
|mut string, fragment| {
|
||||
string.push(fragment);
|
||||
string
|
||||
},
|
||||
);
|
||||
|
||||
delimited(char('"'), inner, char('"'))(input)
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Attr {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_fmt(format_args!("{:?} => {:?}", self.name, self.value))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
macro_rules! line {
|
||||
($($n:expr => $v:expr),* $(,)?) => (
|
||||
Line{attrs: vec![$(Attr{name: $n.into(), value: $v.into()}),*]}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file() {
|
||||
assert_eq!(
|
||||
File::parse(include_str!("test.data")).unwrap(),
|
||||
(
|
||||
"",
|
||||
File {
|
||||
lines: vec![
|
||||
line![
|
||||
"description" => "snake 🐍",
|
||||
"entry" => "1641670385",
|
||||
"modified" => "1641670385",
|
||||
"priority" => "M",
|
||||
"status" => "pending",
|
||||
"uuid" => "f19086c2-1f8d-4a6c-9b8d-f94901fb8e62",
|
||||
],
|
||||
line![
|
||||
"annotation_1585711454" =>
|
||||
"https://blog.tensorflow.org/2020/03/face-and-hand-tracking-in-browser-with-mediapipe-and-tensorflowjs.html?linkId=83993617",
|
||||
"description" => "try facemesh",
|
||||
"entry" => "1585711451",
|
||||
"modified" => "1592947544",
|
||||
"priority" => "M",
|
||||
"project" => "lists",
|
||||
"status" => "pending",
|
||||
"tags" => "idea",
|
||||
"tags_idea" => "x",
|
||||
"uuid" => "ee855dc7-6f61-408c-bc95-ebb52f7d529c",
|
||||
],
|
||||
line![
|
||||
"description" => "testing",
|
||||
"entry" => "1554074416",
|
||||
"modified" => "1554074416",
|
||||
"priority" => "M",
|
||||
"status" => "pending",
|
||||
"uuid" => "4578fb67-359b-4483-afe4-fef15925ccd6",
|
||||
],
|
||||
line![
|
||||
"description" => "testing2",
|
||||
"entry" => "1576352411",
|
||||
"modified" => "1576352411",
|
||||
"priority" => "M",
|
||||
"status" => "pending",
|
||||
"uuid" => "f5982cca-2ea1-4bfd-832c-9bd571dc0743",
|
||||
],
|
||||
line![
|
||||
"description" => "new-task",
|
||||
"entry" => "1576352696",
|
||||
"modified" => "1576352696",
|
||||
"priority" => "M",
|
||||
"status" => "pending",
|
||||
"uuid" => "cfee3170-f153-4075-aa1d-e20bcac2841b",
|
||||
],
|
||||
line![
|
||||
"description" => "foo",
|
||||
"entry" => "1579398776",
|
||||
"modified" => "1579398776",
|
||||
"priority" => "M",
|
||||
"status" => "pending",
|
||||
"uuid" => "df74ea94-5122-44fa-965a-637412fbbffc",
|
||||
],
|
||||
]
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_line() {
|
||||
assert_eq!(Line::parse("[]").unwrap(), ("", line![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_line() {
|
||||
assert_eq!(
|
||||
Line::parse(r#"[annotation_1585711454:"\"\\\"" abc:"xx"]"#).unwrap(),
|
||||
(
|
||||
"",
|
||||
line!["annotation_1585711454" => "\"\\\"", "abc" => "xx"]
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_line_backslash() {
|
||||
assert_eq!(
|
||||
Line::parse(r#"[abc:"xx" 123:"x\\x"]"#).unwrap(),
|
||||
("", line!["abc" => "xx", "123" => "x\\x"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escaped_line_quote() {
|
||||
assert_eq!(
|
||||
Line::parse(r#"[abc:"xx" 123:"x\"x"]"#).unwrap(),
|
||||
("", line!["abc" => "xx", "123" => "x\"x"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_line() {
|
||||
assert_eq!(
|
||||
Line::parse(r#"[description:"snake 🐍" entry:"1641670385" modified:"1641670385" priority:"M" status:"pending" uuid:"f19086c2-1f8d-4a6c-9b8d-f94901fb8e62"]"#).unwrap(),
|
||||
("", line![
|
||||
"description" => "snake 🐍",
|
||||
"entry" => "1641670385",
|
||||
"modified" => "1641670385",
|
||||
"priority" => "M",
|
||||
"status" => "pending",
|
||||
"uuid" => "f19086c2-1f8d-4a6c-9b8d-f94901fb8e62",
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backslashed_attr() {
|
||||
assert!(Attr::parse(r#"one:"\""#).is_err());
|
||||
assert_eq!(
|
||||
Attr::parse(r#"two:"\\""#).unwrap(),
|
||||
(
|
||||
"",
|
||||
Attr {
|
||||
name: "two".into(),
|
||||
value: r#"\"#.into(),
|
||||
}
|
||||
)
|
||||
);
|
||||
assert!(Attr::parse(r#"three:"\\\""#).is_err());
|
||||
assert_eq!(
|
||||
Attr::parse(r#"four:"\\\\""#).unwrap(),
|
||||
(
|
||||
"",
|
||||
Attr {
|
||||
name: "four".into(),
|
||||
value: r#"\\"#.into(),
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backslash_frontslash() {
|
||||
assert_eq!(
|
||||
Attr::parse(r#"front:"\/""#).unwrap(),
|
||||
(
|
||||
"",
|
||||
Attr {
|
||||
name: "front".into(),
|
||||
value: r#"/"#.into(),
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backslash_control_chars() {
|
||||
assert_eq!(
|
||||
Attr::parse(r#"control:"\b\f\n\r\t""#).unwrap(),
|
||||
(
|
||||
"",
|
||||
Attr {
|
||||
name: "control".into(),
|
||||
value: "\x08\x0c\x0a\x0d\x09".into(),
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_attr() {
|
||||
assert_eq!(
|
||||
Attr::parse(r#"annotation_1585711454:"https:\/\/blog.tensorflow.org\/2020\/03\/""#)
|
||||
.unwrap(),
|
||||
(
|
||||
"",
|
||||
Attr {
|
||||
name: "annotation_1585711454".into(),
|
||||
value: "https://blog.tensorflow.org/2020/03/".into(),
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
[description:"snake 🐍" entry:"1641670385" modified:"1641670385" priority:"M" status:"pending" uuid:"f19086c2-1f8d-4a6c-9b8d-f94901fb8e62"]
|
||||
[annotation_1585711454:"https:\/\/blog.tensorflow.org\/2020\/03\/face-and-hand-tracking-in-browser-with-mediapipe-and-tensorflowjs.html?linkId=83993617" description:"try facemesh" entry:"1585711451" modified:"1592947544" priority:"M" project:"lists" status:"pending" tags:"idea" tags_idea:"x" uuid:"ee855dc7-6f61-408c-bc95-ebb52f7d529c"]
|
||||
[description:"testing" entry:"1554074416" modified:"1554074416" priority:"M" status:"pending" uuid:"4578fb67-359b-4483-afe4-fef15925ccd6"]
|
||||
[description:"testing2" entry:"1576352411" modified:"1576352411" priority:"M" status:"pending" uuid:"f5982cca-2ea1-4bfd-832c-9bd571dc0743"]
|
||||
[description:"new-task" entry:"1576352696" modified:"1576352696" priority:"M" status:"pending" uuid:"cfee3170-f153-4075-aa1d-e20bcac2841b"]
|
||||
[description:"foo" entry:"1579398776" modified:"1579398776" priority:"M" status:"pending" uuid:"df74ea94-5122-44fa-965a-637412fbbffc"]
|
|
@ -1,312 +0,0 @@
|
|||
//! This module handles creation of CLI usage documents (--help, manpages, etc.) in
|
||||
//! a way that puts the source of that documentation near its implementation.
|
||||
|
||||
use crate::argparse;
|
||||
use crate::settings;
|
||||
use anyhow::Result;
|
||||
use std::io::Write;
|
||||
|
||||
#[cfg(feature = "usage-docs")]
|
||||
use std::fmt::Write as FmtWrite;
|
||||
|
||||
/// A top-level structure containing usage/help information for the entire CLI.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Usage {
|
||||
pub(crate) subcommands: Vec<Subcommand>,
|
||||
pub(crate) filters: Vec<Filter>,
|
||||
pub(crate) modifications: Vec<Modification>,
|
||||
pub(crate) report_properties: Vec<ReportProperty>,
|
||||
}
|
||||
|
||||
impl Usage {
|
||||
/// Get a new, completely-filled-out usage object
|
||||
pub fn new() -> Self {
|
||||
let mut rv = Self {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
argparse::get_usage(&mut rv);
|
||||
settings::get_usage(&mut rv);
|
||||
|
||||
rv
|
||||
}
|
||||
|
||||
/// Write this usage to the given output as a help string, writing a short version if `summary`
|
||||
/// is true.
|
||||
pub(crate) fn write_help<W: Write>(
|
||||
&self,
|
||||
mut w: W,
|
||||
command_name: &str,
|
||||
summary: bool,
|
||||
) -> Result<()> {
|
||||
write!(
|
||||
w,
|
||||
"TaskChampion {}: Personal task-tracking\n\n",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
)?;
|
||||
writeln!(w, "USAGE:\n {} [args]\n", command_name)?;
|
||||
writeln!(w, "TaskChampion subcommands:")?;
|
||||
for subcommand in self.subcommands.iter() {
|
||||
subcommand.write_help(&mut w, command_name, summary)?;
|
||||
}
|
||||
writeln!(w, "Filter Expressions:\n")?;
|
||||
writeln!(
|
||||
w,
|
||||
"{}",
|
||||
indented(
|
||||
"
|
||||
Where [filter] appears above, zero or more of the following arguments can be used
|
||||
to limit the tasks addressed by the subcommand.",
|
||||
""
|
||||
)
|
||||
)?;
|
||||
for filter in self.filters.iter() {
|
||||
filter.write_help(&mut w, command_name, summary)?;
|
||||
}
|
||||
writeln!(w, "Modifications:\n")?;
|
||||
writeln!(
|
||||
w,
|
||||
"{}",
|
||||
indented(
|
||||
"
|
||||
Where [modification] appears above, zero or more of the following arguments can be
|
||||
used to modify the selected tasks.",
|
||||
""
|
||||
)
|
||||
)?;
|
||||
for modification in self.modifications.iter() {
|
||||
modification.write_help(&mut w, command_name, summary)?;
|
||||
}
|
||||
if !summary {
|
||||
writeln!(w, "\nSee `{} help` for more detail", command_name)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "usage-docs")]
|
||||
/// Substitute strings matching
|
||||
///
|
||||
/// ```text
|
||||
/// <!-- INSERT GENERATED DOCUMENTATION - $type -->
|
||||
/// ```
|
||||
///
|
||||
/// With the appropriate documentation.
|
||||
pub fn substitute_docs(&self, content: &str) -> Result<String> {
|
||||
// this is not efficient, but it doesn't need to be
|
||||
let lines = content.lines();
|
||||
let mut w = String::new();
|
||||
|
||||
const DOC_HEADER_PREFIX: &str = "<!-- INSERT GENERATED DOCUMENTATION - ";
|
||||
const DOC_HEADER_SUFFIX: &str = " -->";
|
||||
|
||||
for line in lines {
|
||||
if line.starts_with(DOC_HEADER_PREFIX) && line.ends_with(DOC_HEADER_SUFFIX) {
|
||||
let doc_type = &line[DOC_HEADER_PREFIX.len()..line.len() - DOC_HEADER_SUFFIX.len()];
|
||||
|
||||
match doc_type {
|
||||
"subcommands" => {
|
||||
for subcommand in self.subcommands.iter() {
|
||||
subcommand.write_markdown(&mut w)?;
|
||||
}
|
||||
}
|
||||
"filters" => {
|
||||
for filter in self.filters.iter() {
|
||||
filter.write_markdown(&mut w)?;
|
||||
}
|
||||
}
|
||||
"modifications" => {
|
||||
for modification in self.modifications.iter() {
|
||||
modification.write_markdown(&mut w)?;
|
||||
}
|
||||
}
|
||||
"report-columns" => {
|
||||
for prop in self.report_properties.iter() {
|
||||
prop.write_column_markdown(&mut w)?;
|
||||
}
|
||||
}
|
||||
"report-sort-by" => {
|
||||
for prop in self.report_properties.iter() {
|
||||
prop.write_sort_by_markdown(&mut w)?;
|
||||
}
|
||||
}
|
||||
_ => anyhow::bail!("Unkonwn doc type {}", doc_type),
|
||||
}
|
||||
} else {
|
||||
writeln!(w, "{}", line)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(w)
|
||||
}
|
||||
}
|
||||
|
||||
/// wrap an indented string
|
||||
fn indented(string: &str, indent: &str) -> String {
|
||||
let termwidth = textwrap::termwidth();
|
||||
let words: Vec<&str> = string.split_whitespace().collect();
|
||||
let string = words.join(" ");
|
||||
textwrap::indent(
|
||||
textwrap::fill(string.trim(), termwidth - indent.len()).as_ref(),
|
||||
indent,
|
||||
)
|
||||
}
|
||||
|
||||
/// Usage documentation for a subcommand
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct Subcommand {
|
||||
/// Name of the subcommand
|
||||
pub(crate) name: &'static str,
|
||||
|
||||
/// Syntax summary, without command_name
|
||||
pub(crate) syntax: &'static str,
|
||||
|
||||
/// One-line description of the subcommand. Use an initial capital and no trailing period.
|
||||
pub(crate) summary: &'static str,
|
||||
|
||||
/// Multi-line description of the subcommand. It's OK for this to duplicate summary, as the
|
||||
/// two are not displayed together.
|
||||
pub(crate) description: &'static str,
|
||||
}
|
||||
|
||||
impl Subcommand {
|
||||
fn write_help<W: Write>(&self, mut w: W, command_name: &str, summary: bool) -> Result<()> {
|
||||
if summary {
|
||||
writeln!(w, " {} {} - {}", command_name, self.name, self.summary)?;
|
||||
} else {
|
||||
writeln!(
|
||||
w,
|
||||
" {} {}\n{}",
|
||||
command_name,
|
||||
self.syntax,
|
||||
indented(self.description, " ")
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "usage-docs")]
|
||||
fn write_markdown<W: FmtWrite>(&self, mut w: W) -> Result<()> {
|
||||
writeln!(w, "### `ta {}` - {}", self.name, self.summary)?;
|
||||
writeln!(w, "```shell\nta {}\n```", self.syntax)?;
|
||||
writeln!(w, "{}", indented(self.description, ""))?;
|
||||
writeln!(w)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage documentation for a filter argument
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct Filter {
|
||||
/// Syntax summary
|
||||
pub(crate) syntax: &'static str,
|
||||
|
||||
/// One-line description of the filter. Use all-caps words for placeholders.
|
||||
pub(crate) summary: &'static str,
|
||||
|
||||
/// Multi-line description of the filter. It's OK for this to duplicate summary, as the
|
||||
/// two are not displayed together.
|
||||
pub(crate) description: &'static str,
|
||||
}
|
||||
|
||||
impl Filter {
|
||||
fn write_help<W: Write>(&self, mut w: W, _: &str, summary: bool) -> Result<()> {
|
||||
if summary {
|
||||
writeln!(w, " {} - {}", self.syntax, self.summary)?;
|
||||
} else {
|
||||
write!(
|
||||
w,
|
||||
" {}\n{}\n",
|
||||
self.syntax,
|
||||
indented(self.description, " ")
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "usage-docs")]
|
||||
fn write_markdown<W: FmtWrite>(&self, mut w: W) -> Result<()> {
|
||||
writeln!(w, "* `{}` - {}", self.syntax, self.summary)?;
|
||||
writeln!(w)?;
|
||||
writeln!(w, "{}", indented(self.description, " "))?;
|
||||
writeln!(w)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage documentation for a modification argument
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct Modification {
|
||||
/// Syntax summary
|
||||
pub(crate) syntax: &'static str,
|
||||
|
||||
/// One-line description of the modification. Use all-caps words for placeholders.
|
||||
pub(crate) summary: &'static str,
|
||||
|
||||
/// Multi-line description of the modification. It's OK for this to duplicate summary, as the
|
||||
/// two are not displayed together.
|
||||
pub(crate) description: &'static str,
|
||||
}
|
||||
|
||||
impl Modification {
|
||||
fn write_help<W: Write>(&self, mut w: W, _: &str, summary: bool) -> Result<()> {
|
||||
if summary {
|
||||
writeln!(w, " {} - {}", self.syntax, self.summary)?;
|
||||
} else {
|
||||
writeln!(
|
||||
w,
|
||||
" {}\n{}",
|
||||
self.syntax,
|
||||
indented(self.description, " ")
|
||||
)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "usage-docs")]
|
||||
fn write_markdown<W: FmtWrite>(&self, mut w: W) -> Result<()> {
|
||||
writeln!(w, "* `{}` - {}", self.syntax, self.summary)?;
|
||||
writeln!(w)?;
|
||||
writeln!(w, "{}", indented(self.description, " "))?;
|
||||
writeln!(w)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Usage documentation for a report property (which may be used for sorting, as a column, or
|
||||
/// both).
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct ReportProperty {
|
||||
/// Name of the property
|
||||
pub(crate) name: &'static str,
|
||||
|
||||
/// Usage description for sorting, if any
|
||||
pub(crate) as_sort_by: Option<&'static str>,
|
||||
|
||||
/// Usage description as a column, if any
|
||||
pub(crate) as_column: Option<&'static str>,
|
||||
}
|
||||
|
||||
impl ReportProperty {
|
||||
#[cfg(feature = "usage-docs")]
|
||||
fn write_sort_by_markdown<W: FmtWrite>(&self, mut w: W) -> Result<()> {
|
||||
if let Some(as_sort_by) = self.as_sort_by {
|
||||
writeln!(w, "* `{}`", self.name)?;
|
||||
writeln!(w)?;
|
||||
writeln!(w, "{}", indented(as_sort_by, " "))?;
|
||||
writeln!(w)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(feature = "usage-docs")]
|
||||
fn write_column_markdown<W: FmtWrite>(&self, mut w: W) -> Result<()> {
|
||||
if let Some(as_column) = self.as_column {
|
||||
writeln!(w, "* `{}`", self.name)?;
|
||||
writeln!(w)?;
|
||||
writeln!(w, "{}", indented(as_column, " "))?;
|
||||
writeln!(w)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
|
@ -1,63 +0,0 @@
|
|||
use assert_cmd::prelude::*;
|
||||
use predicates::prelude::*;
|
||||
use std::fs;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// NOTE: This tests that the `ta` binary is running and parsing arguments. The details of
|
||||
// subcommands are handled with unit tests.
|
||||
|
||||
/// These tests force config to be read via TASKCHAMPION_CONFIG so that a user's own config file
|
||||
/// (in their homedir) does not interfere with tests.
|
||||
fn test_cmd(dir: &TempDir) -> Result<Command, Box<dyn std::error::Error>> {
|
||||
let config_filename = dir.path().join("config.toml");
|
||||
fs::write(
|
||||
config_filename.clone(),
|
||||
format!("data_dir = {:?}", dir.path()),
|
||||
)?;
|
||||
|
||||
let config_filename = config_filename.to_str().unwrap();
|
||||
let mut cmd = Command::cargo_bin("ta")?;
|
||||
cmd.env("TASKCHAMPION_CONFIG", config_filename);
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let mut cmd = test_cmd(&dir)?;
|
||||
|
||||
cmd.arg("--help");
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Personal task-tracking"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let mut cmd = test_cmd(&dir)?;
|
||||
|
||||
cmd.arg("--version");
|
||||
cmd.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("TaskChampion"));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_option() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let mut cmd = test_cmd(&dir)?;
|
||||
|
||||
cmd.arg("--no-such-option");
|
||||
cmd.assert()
|
||||
.failure()
|
||||
.stderr(predicate::str::contains("command line not recognized"))
|
||||
.code(predicate::eq(3));
|
||||
|
||||
Ok(())
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue