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:
Dustin J. Mitchell 2022-05-22 01:40:32 +00:00 committed by Tomas Babej
parent 2b9a389636
commit c0ce1fe059
52 changed files with 21 additions and 8479 deletions

1397
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -2,7 +2,6 @@
members = [
"rust/taskchampion",
"rust/cli",
"rust/sync-server",
"rust/lib",
"rust/integration-tests",

View file

@ -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" ]

View file

@ -1,3 +0,0 @@
fn main() {
built::write_built_file().expect("Failed to acquire build-time information");
}

View file

@ -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());
}
}

View file

@ -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)));
}
}

View file

@ -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()),
]);
}
}

View file

@ -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());
}
}

View file

@ -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};

View file

@ -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());
}
}

View file

@ -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);
}
}

View file

@ -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"),
}
);
}
}

View file

@ -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",
});
}
}

View file

@ -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")),
],
}
);
}
}

View file

@ -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);
}

View file

@ -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()
}
);
}
}

View file

@ -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)
);
}
}

View file

@ -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());
}
}
}

View file

@ -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)
}

View file

@ -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);
}
}

View file

@ -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()));
}
}

View file

@ -1 +0,0 @@
[description:"&open;TEST&close; foo" entry:"1554074416" modified:"1554074416" priority:"M" status:"completed" uuid:"4578fb67-359b-4483-afe4-fef15925ccd6"]

View file

@ -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"
);
}
}

View file

@ -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")
}
}

View file

@ -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();
}
}

View file

@ -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(())
}
}

View file

@ -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(())
}
}

View file

@ -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"));
}
}

View file

@ -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;

View file

@ -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")
);
}
}

View file

@ -1 +0,0 @@
[description:"snake 🐍" entry:"1641670385" modified:"1641670385" priority:"M" status:"pending" tag_reptile:"" uuid:"f19086c2-1f8d-4a6c-9b8d-f94901fb8e62"]

View file

@ -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"));
}
}

View file

@ -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")
}
}

View file

@ -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")
}
}

View file

@ -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 "));
}
}

View file

@ -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);
}
}

View file

@ -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
})
}

View file

@ -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);
}
}

View file

@ -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")
);
}
}

View file

@ -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![] }
}

View file

@ -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()))
}
}

View file

@ -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(())
}

View file

@ -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() } };
);

View file

@ -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;

View file

@ -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"
);
}
}

View file

@ -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());
}
}

View file

@ -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());
}
}

View file

@ -1,8 +0,0 @@
use prettytable::format;
pub(crate) fn format() -> format::TableFormat {
format::FormatBuilder::new()
.column_separator(' ')
.borders(' ')
.build()
}

View file

@ -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(),
}
)
);
}
}

View file

@ -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"]

View file

@ -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(())
}
}

View file

@ -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(())
}