Merge remote-tracking branch 'origin/main' into sqlstore

# Conflicts:
#	Cargo.lock
#	taskchampion/Cargo.toml
This commit is contained in:
dbr 2021-06-15 19:49:36 +10:00
commit 2f533d2f3a
74 changed files with 3491 additions and 861 deletions

1
.github/CODEOWNERS vendored Normal file
View file

@ -0,0 +1 @@
* @dbr @djmitche

View file

@ -1,4 +1,4 @@
name: Security audit
name: Security
on:
schedule:
@ -12,6 +12,7 @@ on:
jobs:
audit:
runs-on: ubuntu-latest
name: "Audit Dependencies"
steps:
- uses: actions/checkout@v2
- uses: actions-rs/audit-check@v1

View file

@ -1,4 +1,4 @@
name: taskchampion
name: Checks
on:
push:
@ -8,12 +8,50 @@ on:
types: [opened, reopened, synchronize]
jobs:
test:
clippy:
runs-on: ubuntu-latest
name: "Clippy"
steps:
- uses: actions/checkout@v1
- name: Cache cargo registry
uses: actions/cache@v1
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v1
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
- uses: actions-rs/cargo@v1.0.1
with:
command: check
- run: rustup component add clippy
- uses: actions-rs/clippy-check@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
args: --all-features
name: "Clippy Results"
mdbook:
runs-on: ubuntu-latest
name: "Documentation"
steps:
- uses: actions/checkout@v1
- name: Setup mdBook
uses: peaceiris/actions-mdbook@v1
with:
# if this changes, change it in cli/Cargo.toml and .github/workflows/publish-docs.yml as well
mdbook-version: '0.4.10'
- name: Cache cargo registry
uses: actions/cache@v1
with:
@ -31,49 +69,8 @@ jobs:
toolchain: stable
override: true
- uses: actions-rs/cargo@v1.0.1
with:
command: check
- name: test
run: cargo test
clippy:
runs-on: ubuntu-latest
needs: test
steps:
- uses: actions/checkout@v1
- name: Cache cargo registry
uses: actions/cache@v1
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v1
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
- run: rustup component add clippy
- uses: actions-rs/clippy-check@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}
args: --all-features
mdbook:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Setup mdBook
uses: peaceiris/actions-mdbook@v1
with:
mdbook-version: 'latest'
- name: Create usage-docs plugin
run: cargo build -p taskchampion-cli --features usage-docs --bin usage-docs
- run: mdbook test docs
- run: mdbook build docs

View file

@ -1,4 +1,4 @@
name: taskchampion
name: Docs
on:
push:
@ -15,7 +15,28 @@ jobs:
- name: Setup mdBook
uses: peaceiris/actions-mdbook@v1
with:
mdbook-version: 'latest'
# if this changes, change it in cli/Cargo.toml and .github/workflows/publish-docs.yml as well
mdbook-version: '0.4.10'
- name: Cache cargo registry
uses: actions/cache@v1
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v1
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
- uses: actions-rs/toolchain@v1
with:
toolchain: stable
override: true
- name: Create usage-docs plugin
run: cargo build -p taskchampion-cli --features usage-docs --bin usage-docs
- run: mdbook build docs

43
.github/workflows/tests.yml vendored Normal file
View file

@ -0,0 +1,43 @@
name: Tests
on:
push:
branches:
- main
pull_request:
types: [opened, reopened, synchronize]
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
rust:
- "1.47" # MSRV
- "stable"
name: "Test - Rust ${{ matrix.rust }}"
steps:
- uses: actions/checkout@v1
- name: Cache cargo registry
uses: actions/cache@v1
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-${{ matrix.rust }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v1
with:
path: target
key: ${{ runner.os }}-${{ matrix.rust }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
- uses: actions-rs/toolchain@v1
with:
toolchain: "${{ matrix.rust }}"
override: true
- name: test
run: cargo test

1191
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -35,21 +35,11 @@ Considered to be part of the API policy.
## CLI exit codes
- `0` No errors, normal exit.
- `1` Generic error.
- `2` Never used to avoid conflicts with Bash.
- `3` Unable to execute with the given parameters.
- `4` I/O error.
- `5` Database error.
- `0` - No errors, normal exit.
- `1` - Generic error.
- `2` - Never used to avoid conflicts with Bash.
- `3` - Command-line Syntax Error.
# Security
To report a vulnerability, please contact [dustin@cs.uchicago.edu](dustin@cs.uchicago.edu), you may use GPG public-key `D8097934A92E4B4210368102FF8B7AC6154E3226` which is available [here](https://keybase.io/djmitche/pgp_keys.asc?fingerprint=d8097934a92e4b4210368102ff8b7ac6154e3226). Initial response is expected within ~48h.
We kinldy ask to follow the responsible disclosure model and refrain from sharing information until:
1. Vulnerabilities are patched in TaskChampion + 60 days to coordinate with distributions.
2. 90 days since the vulnerability is disclosed to us.
We recognise the legitimacy of public interest and accept that security researchers can publish information after 90-days deadline unilaterally.
We will assist with obtaining CVE and acknowledge the vulnerabilites reported.
See [SECURITY.md](./SECURITY.md).

View file

@ -33,3 +33,8 @@ There are three crates here:
* [taskchampion-cli](./cli) - the command-line binary
* [taskchampion-sync-server](./sync-server) - the server against which `task sync` operates
## Documentation Generation
The `mdbook` configuration contains a "preprocessor" implemented in the `taskchampion-cli` crate in order to reflect CLI usage information into the generated book.
Tihs preprocessor is not built by default.
To (re)build it, run `cargo build -p taskchampion-cli --features usage-docs --bin usage-docs`.

View file

@ -10,7 +10,7 @@
1. Run `git tag vX.Y.Z`
1. Run `git push upstream`
1. Run `git push --tags upstream`
1. Run `( cd docs; ./build.sh )`
1. Run `( ./build-docs.sh )`
1. Run `(cd taskchampion; cargo publish)` (note that the other crates do not get published)
1. Navigate to the tag in the GitHub releases UI and create a release with general comments about the changes in the release
1. Upload `./target/release/task` and `./target/release/task-sync-server` to the release

11
SECURITY.md Normal file
View file

@ -0,0 +1,11 @@
# Security
To report a vulnerability, please contact [dustin@cs.uchicago.edu](dustin@cs.uchicago.edu), you may use GPG public-key `D8097934A92E4B4210368102FF8B7AC6154E3226` which is available [here](https://keybase.io/djmitche/pgp_keys.asc?fingerprint=d8097934a92e4b4210368102ff8b7ac6154e3226). Initial response is expected within ~48h.
We kindly ask to follow the responsible disclosure model and refrain from sharing information until:
1. Vulnerabilities are patched in TaskChampion + 60 days to coordinate with distributions.
2. 90 days since the vulnerability is disclosed to us.
We recognise the legitimacy of public interest and accept that security researchers can publish information after 90-days deadline unilaterally.
We will assist with obtaining CVE and acknowledge the vulnerabilites reported.

31
build-docs.sh Executable file
View file

@ -0,0 +1,31 @@
#! /bin/bash
REMOTE=origin
set -e
if ! [ -f "docs/src/SUMMARY.md" ]; then
echo "Run this from the root of the repo"
exit 1
fi
# build the latest version of the mdbook plugin
cargo build -p taskchampion-cli --features usage-docs --bin usage-docs
# create a worktree of this repo, with the `gh-pages` branch checked out
if ! [ -d ./docs/tmp ]; then
git worktree add docs/tmp gh-pages
fi
# update the wortree
(cd docs/tmp && git pull $REMOTE gh-pages)
# remove all files in the worktree and regenerate the book there
rm -rf docs/tmp/*
mdbook build docs
cp -rp docs/book/* docs/tmp
# add everything in the worktree, commit, and push
(cd docs/tmp && git add -A)
(cd docs/tmp && git commit -am "update docs")
(cd docs/tmp && git push $REMOTE gh-pages:gh-pages)

View file

@ -4,10 +4,16 @@ edition = "2018"
name = "taskchampion-cli"
version = "0.3.0"
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.14"
nom = "^6.1.2"
prettytable-rs = "^0.8.0"
@ -16,11 +22,35 @@ termcolor = "^1.1.2"
atty = "^0.2.14"
toml = "^0.5.8"
toml_edit = "^0.2.0"
chrono = "0.4"
lazy_static = "1"
iso8601-duration = "0.1"
dialoguer = "0.8"
# 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 }
serde_json = { version = "*", optional = true }
[dependencies.taskchampion]
path = "../taskchampion"
[build-dependencies]
built = { version = "0.5", features = ["git2"] }
[dev-dependencies]
assert_cmd = "^1.0.3"
predicates = "^1.0.7"
tempfile = "3"
rstest = "0.10"
[features]
usage-docs = [ "mdbook", "serde_json" ]
[[bin]]
name = "ta"
[[bin]]
# this is an mdbook plugin and only needed when running `mdbook`
name = "usage-docs"
required-features = [ "usage-docs" ]

3
cli/build.rs Normal file
View file

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

View file

@ -1,309 +0,0 @@
//! Parsers for argument lists -- arrays of strings
use super::ArgList;
use nom::bytes::complete::tag as nomtag;
use nom::{
branch::*,
character::complete::*,
combinator::*,
error::{Error, ErrorKind},
multi::*,
sequence::*,
Err, IResult,
};
use std::convert::TryFrom;
use taskchampion::{Status, Tag, Uuid};
/// A task identifier, as given in a filter command-line expression
#[derive(Debug, PartialEq, Clone)]
pub(crate) enum TaskId {
/// A small integer identifying a working-set task
WorkingSetId(usize),
/// A full Uuid specifically identifying a task
Uuid(Uuid),
/// A prefix of a Uuid
PartialUuid(String),
}
/// Recognizes any argument
pub(super) fn any(input: &str) -> IResult<&str, &str> {
rest(input)
}
/// Recognizes a report name
pub(super) fn report_name(input: &str) -> IResult<&str, &str> {
all_consuming(recognize(pair(alpha1, alphanumeric0)))(input)
}
/// Recognizes a literal string
pub(super) fn literal(literal: &'static str) -> impl Fn(&str) -> IResult<&str, &str> {
move |input: &str| all_consuming(nomtag(literal))(input)
}
/// Recognizes a colon-prefixed pair
pub(super) fn colon_prefixed(prefix: &'static str) -> impl Fn(&str) -> IResult<&str, &str> {
fn to_suffix<'a>(input: (&'a str, char, &'a str)) -> Result<&'a str, ()> {
Ok(input.2)
}
move |input: &str| {
map_res(
all_consuming(tuple((nomtag(prefix), char(':'), any))),
to_suffix,
)(input)
}
}
/// Recognizes `status:{pending,completed,deleted}`
pub(super) fn status_colon(input: &str) -> IResult<&str, Status> {
fn to_status(input: &str) -> Result<Status, ()> {
match input {
"pending" => Ok(Status::Pending),
"completed" => Ok(Status::Completed),
"deleted" => Ok(Status::Deleted),
_ => Err(()),
}
}
map_res(colon_prefixed("status"), to_status)(input)
}
/// Recognizes a comma-separated list of TaskIds
pub(super) fn id_list(input: &str) -> IResult<&str, Vec<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)
}
/// Recognizes a tag prefixed with `+` and returns the tag value
pub(super) fn plus_tag(input: &str) -> IResult<&str, &str> {
fn to_tag(input: (char, &str)) -> Result<&str, ()> {
Ok(input.1)
}
map_res(
all_consuming(tuple((
char('+'),
recognize(verify(rest, |s: &str| Tag::try_from(s).is_ok())),
))),
to_tag,
)(input)
}
/// Recognizes a tag prefixed with `-` and returns the tag value
pub(super) fn minus_tag(input: &str) -> IResult<&str, &str> {
fn to_tag(input: (char, &str)) -> Result<&str, ()> {
Ok(input.1)
}
map_res(
all_consuming(tuple((
char('-'),
recognize(verify(rest, |s: &str| Tag::try_from(s).is_ok())),
))),
to_tag,
)(input)
}
/// Consume a single argument from an argument list that matches the given string parser (one
/// of the other functions in this module). The given parser must consume the entire input.
pub(super) fn arg_matching<'a, O, F>(f: F) -> impl Fn(ArgList<'a>) -> IResult<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
Ok((unconsumed, _)) => panic!("unconsumed argument input {}", unconsumed),
// single-arg parsers are all complete parsers
Err(Err::Incomplete(_)) => unreachable!(),
// for error and failure, rewrite to an error at this position in the arugment list
Err(Err::Error(Error { input: _, code })) => Err(Err::Error(Error { input, code })),
Err(Err::Failure(Error { input: _, code })) => {
Err(Err::Failure(Error { input, code }))
}
};
}
Err(Err::Error(Error {
input,
// since we're using nom's built-in Error, our choices here are limited, but tihs
// occurs when there's no argument where one is expected, so Eof seems appropriate
code: ErrorKind::Eof,
}))
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_arg_matching() {
assert_eq!(
arg_matching(plus_tag)(argv!["+foo", "bar"]).unwrap(),
(argv!["bar"], "foo")
);
assert!(arg_matching(plus_tag)(argv!["foo", "bar"]).is_err());
}
#[test]
fn test_colon_prefixed() {
assert_eq!(colon_prefixed("foo")("foo:abc").unwrap().1, "abc");
assert_eq!(colon_prefixed("foo")("foo:").unwrap().1, "");
assert!(colon_prefixed("foo")("foo").is_err());
}
#[test]
fn test_status_colon() {
assert_eq!(status_colon("status:pending").unwrap().1, Status::Pending);
assert_eq!(
status_colon("status:completed").unwrap().1,
Status::Completed
);
assert_eq!(status_colon("status:deleted").unwrap().1, Status::Deleted);
assert!(status_colon("status:foo").is_err());
assert!(status_colon("status:complete").is_err());
assert!(status_colon("status").is_err());
}
#[test]
fn test_plus_tag() {
assert_eq!(plus_tag("+abc").unwrap().1, "abc");
assert_eq!(plus_tag("+abc123").unwrap().1, "abc123");
assert!(plus_tag("-abc123").is_err());
assert!(plus_tag("+abc123 ").is_err());
assert!(plus_tag(" +abc123").is_err());
assert!(plus_tag("+1abc").is_err());
}
#[test]
fn test_minus_tag() {
assert_eq!(minus_tag("-abc").unwrap().1, "abc");
assert_eq!(minus_tag("-abc123").unwrap().1, "abc123");
assert!(minus_tag("+abc123").is_err());
assert!(minus_tag("-abc123 ").is_err());
assert!(minus_tag(" -abc123").is_err());
assert!(minus_tag("-1abc").is_err());
}
#[test]
fn test_literal() {
assert_eq!(literal("list")("list").unwrap().1, "list");
assert!(literal("list")("listicle").is_err());
assert!(literal("list")(" list ").is_err());
assert!(literal("list")("LiSt").is_err());
assert!(literal("list")("denylist").is_err());
}
#[test]
fn test_id_list_single() {
assert_eq!(id_list("123").unwrap().1, vec![TaskId::WorkingSetId(123)]);
}
#[test]
fn test_id_list_uuids() {
assert_eq!(
id_list("12341234").unwrap().1,
vec![TaskId::PartialUuid(s!("12341234"))]
);
assert_eq!(
id_list("1234abcd").unwrap().1,
vec![TaskId::PartialUuid(s!("1234abcd"))]
);
assert_eq!(
id_list("abcd1234").unwrap().1,
vec![TaskId::PartialUuid(s!("abcd1234"))]
);
assert_eq!(
id_list("abcd1234-1234").unwrap().1,
vec![TaskId::PartialUuid(s!("abcd1234-1234"))]
);
assert_eq!(
id_list("abcd1234-1234-2345").unwrap().1,
vec![TaskId::PartialUuid(s!("abcd1234-1234-2345"))]
);
assert_eq!(
id_list("abcd1234-1234-2345-3456").unwrap().1,
vec![TaskId::PartialUuid(s!("abcd1234-1234-2345-3456"))]
);
assert_eq!(
id_list("abcd1234-1234-2345-3456-0123456789ab").unwrap().1,
vec![TaskId::Uuid(
Uuid::parse_str("abcd1234-1234-2345-3456-0123456789ab").unwrap()
)]
);
}
#[test]
fn test_id_list_invalid_partial_uuids() {
assert!(id_list("abcd123").is_err());
assert!(id_list("abcd12345").is_err());
assert!(id_list("abcd1234-").is_err());
assert!(id_list("abcd1234-123").is_err());
assert!(id_list("abcd1234-1234-").is_err());
assert!(id_list("abcd1234-12345-").is_err());
assert!(id_list("abcd1234-1234-2345-3456-0123456789ab-").is_err());
}
#[test]
fn test_id_list_uuids_mixed() {
assert_eq!(id_list("abcd1234,abcd1234-1234,abcd1234-1234-2345,abcd1234-1234-2345-3456,abcd1234-1234-2345-3456-0123456789ab").unwrap().1,
vec![TaskId::PartialUuid(s!("abcd1234")),
TaskId::PartialUuid(s!("abcd1234-1234")),
TaskId::PartialUuid(s!("abcd1234-1234-2345")),
TaskId::PartialUuid(s!("abcd1234-1234-2345-3456")),
TaskId::Uuid(Uuid::parse_str("abcd1234-1234-2345-3456-0123456789ab").unwrap()),
]);
}
}

View file

@ -0,0 +1,60 @@
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::*;
#[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

@ -0,0 +1,85 @@
use super::{any, timestamp};
use crate::argparse::NOW;
use chrono::prelude::*;
use nom::bytes::complete::tag as nomtag;
use nom::{branch::*, character::complete::*, combinator::*, sequence::*, IResult};
use taskchampion::Status;
/// Recognizes up to the colon of the common `<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)
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_colon_prefix() {
assert_eq!(colon_prefix("foo")("foo:abc").unwrap().1, "abc");
assert_eq!(colon_prefix("foo")("foo:").unwrap().1, "");
assert!(colon_prefix("foo")("foo").is_err());
}
#[test]
fn test_status_colon() {
assert_eq!(status_colon("status:pending").unwrap().1, Status::Pending);
assert_eq!(
status_colon("status:completed").unwrap().1,
Status::Completed
);
assert_eq!(status_colon("status:deleted").unwrap().1, Status::Deleted);
assert!(status_colon("status:foo").is_err());
assert!(status_colon("status:complete").is_err());
assert!(status_colon("status").is_err());
}
#[test]
fn test_wait() {
assert_eq!(wait_colon("wait:").unwrap(), ("", None));
let one_day = *NOW + chrono::Duration::days(1);
assert_eq!(wait_colon("wait:1d").unwrap(), ("", Some(one_day)));
let one_day = *NOW + chrono::Duration::days(1);
assert_eq!(wait_colon("wait:1d2").unwrap(), ("2", Some(one_day)));
}
}

View file

@ -0,0 +1,139 @@
use nom::{branch::*, character::complete::*, combinator::*, multi::*, sequence::*, IResult};
use taskchampion::Uuid;
/// A task identifier, as given in a filter command-line expression
#[derive(Debug, PartialEq, Clone)]
pub(crate) enum TaskId {
/// A small integer identifying a working-set task
WorkingSetId(usize),
/// A full Uuid specifically identifying a task
Uuid(Uuid),
/// A prefix of a Uuid
PartialUuid(String),
}
/// Recognizes a comma-separated list of TaskIds
pub(crate) fn id_list(input: &str) -> IResult<&str, Vec<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::*;
#[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

@ -0,0 +1,41 @@
use nom::bytes::complete::tag as nomtag;
use nom::{character::complete::*, combinator::*, sequence::*, IResult};
/// Recognizes any argument
pub(crate) fn any(input: &str) -> IResult<&str, &str> {
rest(input)
}
/// Recognizes a report name
pub(crate) fn report_name(input: &str) -> IResult<&str, &str> {
all_consuming(recognize(pair(alpha1, alphanumeric0)))(input)
}
/// Recognizes a literal string
pub(crate) fn literal(literal: &'static str) -> impl Fn(&str) -> IResult<&str, &str> {
move |input: &str| all_consuming(nomtag(literal))(input)
}
#[cfg(test)]
mod test {
use super::super::*;
use super::*;
#[test]
fn test_arg_matching() {
assert_eq!(
arg_matching(plus_tag)(argv!["+foo", "bar"]).unwrap(),
(argv!["bar"], 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

@ -0,0 +1,16 @@
//! Parsers for single arguments (strings)
mod arg_matching;
mod colon;
mod idlist;
mod misc;
mod tags;
mod time;
pub(crate) use arg_matching::arg_matching;
pub(crate) use colon::{status_colon, wait_colon};
pub(crate) use idlist::{id_list, TaskId};
pub(crate) use misc::{any, literal, report_name};
pub(crate) use tags::{minus_tag, plus_tag};
#[allow(unused_imports)]
pub(crate) use time::{duration, timestamp};

View file

@ -0,0 +1,34 @@
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::*;
#[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

@ -0,0 +1,466 @@
use chrono::{prelude::*, Duration};
use iso8601_duration::Duration as IsoDuration;
use lazy_static::lazy_static;
use nom::{
branch::*,
bytes::complete::*,
character::complete::*,
character::*,
combinator::*,
error::{Error, ErrorKind},
multi::*,
sequence::*,
Err, IResult,
};
use std::str::FromStr;
// https://taskwarrior.org/docs/dates.html
// https://taskwarrior.org/docs/named_dates.html
// https://taskwarrior.org/docs/durations.html
/// A case for matching durations. If `.3` is true, then the value can be used
/// without a prefix, e.g., `minute`. If false, it cannot, e.g., `minutes`
#[derive(Debug)]
struct DurationCase(&'static str, Duration, bool);
// https://github.com/GothenburgBitFactory/libshared/blob/9a5f24e2acb38d05afb8f8e316a966dee196a42a/src/Duration.cpp#L50
// TODO: use const when chrono supports it
lazy_static! {
static ref DURATION_CASES: Vec<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()..];
match input {
"yesterday" => Ok((remaining, local_today - Duration::days(1))),
"today" => Ok((remaining, local_today)),
"tomorrow" => Ok((remaining, local_today + Duration::days(1))),
// TODO: lots more!
_ => Err(Err::Error(Error::new(input, ErrorKind::Tag))),
}
.map(|(rem, dt)| (rem, dt.and_hms(0, 0, 0).with_timezone(&Utc)))
}
}
/// recognize a digit
fn digit(input: &str) -> IResult<&str, char> {
satisfy(|c| is_digit(c as u8))(input)
}
/// Parse yyyy-mm-dd as the given date, at the local midnight
fn yyyy_mm_dd<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 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))]
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,6 +1,5 @@
use super::args::*;
use super::{ArgList, Subcommand};
use anyhow::bail;
use nom::{combinator::*, sequence::*, Err, IResult};
/// A command is the overall command that the CLI should execute.
@ -16,8 +15,15 @@ pub(crate) struct Command {
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: input.0.to_owned(),
command_name,
subcommand: input.1,
};
Ok(command)
@ -29,13 +35,22 @@ impl Command {
}
/// Parse a command from the given list of strings.
pub fn from_argv(argv: &[&str]) -> anyhow::Result<Command> {
pub fn from_argv(argv: &[&str]) -> Result<Command, crate::Error> {
match Command::parse(argv) {
Ok((&[], cmd)) => Ok(cmd),
Ok((trailing, _)) => bail!("command line has trailing arguments: {:?}", trailing),
Ok((trailing, _)) => Err(crate::Error::for_arguments(format!(
"command line has trailing arguments: {:?}",
trailing
))),
Err(Err::Incomplete(_)) => unreachable!(),
Err(Err::Error(e)) => bail!("command line not recognized: {:?}", e),
Err(Err::Failure(e)) => bail!("command line not recognized: {:?}", e),
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
))),
}
}
}
@ -56,4 +71,16 @@ mod test {
}
);
}
#[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,13 +1,15 @@
use super::args::{any, arg_matching, literal};
use super::ArgList;
use crate::usage;
use nom::{combinator::*, sequence::*, IResult};
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 {
@ -15,14 +17,20 @@ impl ConfigOperation {
fn set_to_op(input: (&str, &str, &str)) -> Result<ConfigOperation, ()> {
Ok(ConfigOperation::Set(input.1.to_owned(), input.2.to_owned()))
}
map_res(
tuple((
arg_matching(literal("set")),
arg_matching(any),
arg_matching(any),
)),
set_to_op,
)(input)
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) {

View file

@ -1,9 +1,14 @@
use super::args::{arg_matching, id_list, minus_tag, plus_tag, status_colon, TaskId};
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, IResult};
use taskchampion::Status;
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.
///
@ -21,10 +26,10 @@ pub(crate) struct Filter {
#[derive(Debug, PartialEq, Clone)]
pub(crate) enum Condition {
/// Task has the given tag
HasTag(String),
HasTag(Tag),
/// Task does not have the given tag
NoTag(String),
NoTag(Tag),
/// Task has the given status
Status(Status),
@ -63,15 +68,15 @@ impl Condition {
}
fn parse_plus_tag(input: ArgList) -> IResult<ArgList, Condition> {
fn to_condition(input: &str) -> Result<Condition, ()> {
Ok(Condition::HasTag(input.to_owned()))
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: &str) -> Result<Condition, ()> {
Ok(Condition::NoTag(input.to_owned()))
fn to_condition(input: Tag) -> Result<Condition, ()> {
Ok(Condition::NoTag(input))
}
map_res(arg_matching(minus_tag), to_condition)(input)
}
@ -85,7 +90,9 @@ impl Condition {
}
impl Filter {
pub(super) fn parse(input: ArgList) -> IResult<ArgList, 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 {
@ -95,6 +102,30 @@ impl Filter {
)(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 {
@ -157,6 +188,13 @@ impl Filter {
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.",
});
}
}
@ -165,8 +203,8 @@ mod test {
use super::*;
#[test]
fn test_empty() {
let (input, filter) = Filter::parse(argv![]).unwrap();
fn test_empty_parse0() {
let (input, filter) = Filter::parse0(argv![]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
filter,
@ -176,9 +214,46 @@ mod test {
);
}
#[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::parse(argv!["1"]).unwrap();
let (input, filter) = Filter::parse0(argv!["1"]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
filter,
@ -190,7 +265,7 @@ mod test {
#[test]
fn test_id_list_commas() {
let (input, filter) = Filter::parse(argv!["1,2,3"]).unwrap();
let (input, filter) = Filter::parse0(argv!["1,2,3"]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
filter,
@ -206,7 +281,7 @@ mod test {
#[test]
fn test_id_list_multi_arg() {
let (input, filter) = Filter::parse(argv!["1,2", "3,4"]).unwrap();
let (input, filter) = Filter::parse0(argv!["1,2", "3,4"]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
filter,
@ -223,7 +298,7 @@ mod test {
#[test]
fn test_id_list_uuids() {
let (input, filter) = Filter::parse(argv!["1,abcd1234"]).unwrap();
let (input, filter) = Filter::parse0(argv!["1,abcd1234"]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
filter,
@ -238,15 +313,15 @@ mod test {
#[test]
fn test_tags() {
let (input, filter) = Filter::parse(argv!["1", "+yes", "-no"]).unwrap();
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("yes".into()),
Condition::NoTag("no".into()),
Condition::HasTag(tag!("yes")),
Condition::NoTag(tag!("no")),
],
}
);
@ -254,7 +329,7 @@ mod test {
#[test]
fn test_status() {
let (input, filter) = Filter::parse(argv!["status:completed", "status:pending"]).unwrap();
let (input, filter) = Filter::parse0(argv!["status:completed", "status:pending"]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
filter,
@ -269,8 +344,8 @@ mod test {
#[test]
fn intersect_idlist_idlist() {
let left = Filter::parse(argv!["1,2", "+yes"]).unwrap().1;
let right = Filter::parse(argv!["2,3", "+no"]).unwrap().1;
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,
@ -278,10 +353,10 @@ mod test {
conditions: vec![
// from first filter
Condition::IdList(vec![TaskId::WorkingSetId(1), TaskId::WorkingSetId(2),]),
Condition::HasTag("yes".into()),
Condition::HasTag(tag!("yes")),
// from second filter
Condition::IdList(vec![TaskId::WorkingSetId(2), TaskId::WorkingSetId(3)]),
Condition::HasTag("no".into()),
Condition::HasTag(tag!("no")),
],
}
);
@ -289,8 +364,8 @@ mod test {
#[test]
fn intersect_idlist_alltasks() {
let left = Filter::parse(argv!["1,2", "+yes"]).unwrap().1;
let right = Filter::parse(argv!["+no"]).unwrap().1;
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,
@ -298,9 +373,9 @@ mod test {
conditions: vec![
// from first filter
Condition::IdList(vec![TaskId::WorkingSetId(1), TaskId::WorkingSetId(2),]),
Condition::HasTag("yes".into()),
Condition::HasTag(tag!("yes")),
// from second filter
Condition::HasTag("no".into()),
Condition::HasTag(tag!("no")),
],
}
);
@ -308,15 +383,15 @@ mod test {
#[test]
fn intersect_alltasks_alltasks() {
let left = Filter::parse(argv!["+yes"]).unwrap().1;
let right = Filter::parse(argv!["+no"]).unwrap().1;
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("yes".into()),
Condition::HasTag("no".into()),
Condition::HasTag(tag!("yes")),
Condition::HasTag(tag!("no")),
],
}
);

View file

@ -31,6 +31,13 @@ pub(crate) use modification::{DescriptionMod, Modification};
pub(crate) use subcommand::Subcommand;
use crate::usage::Usage;
use chrono::prelude::*;
use lazy_static::lazy_static;
lazy_static! {
// A static value of NOW to make tests easier
pub(crate) static ref NOW: DateTime<Utc> = Utc::now();
}
type ArgList<'a> = &'a [&'a str];

View file

@ -1,9 +1,10 @@
use super::args::{any, arg_matching, minus_tag, plus_tag};
use super::args::{any, arg_matching, minus_tag, plus_tag, wait_colon};
use super::ArgList;
use crate::usage;
use chrono::prelude::*;
use nom::{branch::alt, combinator::*, multi::fold_many0, IResult};
use std::collections::HashSet;
use taskchampion::Status;
use taskchampion::{Status, Tag};
#[derive(Debug, PartialEq, Clone)]
pub enum DescriptionMod {
@ -36,21 +37,25 @@ pub struct Modification {
/// Set the status
pub status: Option<Status>,
/// Set (or, with `Some(None)`, clear) the wait timestamp
pub wait: Option<Option<DateTime<Utc>>>,
/// Set the "active" state, that is, start (true) or stop (false) the task.
pub active: Option<bool>,
/// Add tags
pub add_tags: HashSet<String>,
pub add_tags: HashSet<Tag>,
/// Remove tags
pub remove_tags: HashSet<String>,
pub remove_tags: HashSet<Tag>,
}
/// A single argument that is part of a modification, used internally to this module
enum ModArg<'a> {
Description(&'a str),
PlusTag(&'a str),
MinusTag(&'a str),
PlusTag(Tag),
MinusTag(Tag),
Wait(Option<DateTime<Utc>>),
}
impl Modification {
@ -66,10 +71,13 @@ impl Modification {
}
}
ModArg::PlusTag(tag) => {
acc.add_tags.insert(tag.to_owned());
acc.add_tags.insert(tag);
}
ModArg::MinusTag(tag) => {
acc.remove_tags.insert(tag.to_owned());
acc.remove_tags.insert(tag);
}
ModArg::Wait(wait) => {
acc.wait = Some(wait);
}
}
acc
@ -78,6 +86,7 @@ impl Modification {
alt((
Self::plus_tag,
Self::minus_tag,
Self::wait,
// this must come last
Self::description,
)),
@ -96,38 +105,58 @@ impl Modification {
}
fn plus_tag(input: ArgList) -> IResult<ArgList, ModArg> {
fn to_modarg(input: &str) -> Result<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: &str) -> Result<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)
}
pub(super) fn get_usage(u: &mut usage::Usage) {
u.modifications.push(usage::Modification {
syntax: "DESCRIPTION",
summary: "Set description",
description: "
Set the task description. Multiple arguments are combined into a single
space-separated description.",
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.",
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: "
Remove the given tag from the task.",
Set the time before which the task is not actionable and should not be shown in
reports, e.g., `wait:3day` to wait for three days. With `wait:`, the time is
un-set. See the documentation for the timestamp syntax.",
});
}
}
@ -135,6 +164,7 @@ impl Modification {
#[cfg(test)]
mod test {
use super::*;
use crate::argparse::NOW;
#[test]
fn test_empty() {
@ -168,7 +198,33 @@ mod test {
assert_eq!(
modification,
Modification {
add_tags: set![s!("abc"), s!("def")],
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 + chrono::Duration::days(2))),
..Default::default()
}
);
}
#[test]
fn test_unset_wait() {
let (input, modification) = Modification::parse(argv!["wait:"]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
modification,
Modification {
wait: Some(None),
..Default::default()
}
);
@ -196,8 +252,8 @@ mod test {
modification,
Modification {
description: DescriptionMod::Set(s!("new desc fun")),
add_tags: set![s!("next")],
remove_tags: set![s!("daytime")],
add_tags: set![tag!("next")],
remove_tags: set![tag!("daytime")],
..Default::default()
}
);

View file

@ -217,7 +217,7 @@ impl Modify {
}
map_res(
tuple((
Filter::parse,
Filter::parse1,
alt((
arg_matching(literal("modify")),
arg_matching(literal("prepend")),
@ -235,47 +235,47 @@ impl Modify {
fn get_usage(u: &mut usage::Usage) {
u.subcommands.push(usage::Subcommand {
name: "modify",
syntax: "[filter] modify [modification]",
syntax: "<filter> modify [modification]",
summary: "Modify tasks",
description: "
Modify all tasks matching the filter.",
Modify all tasks matching the required filter.",
});
u.subcommands.push(usage::Subcommand {
name: "prepend",
syntax: "[filter] prepend [modification]",
syntax: "<filter> prepend [modification]",
summary: "Prepend task description",
description: "
Modify all tasks matching the filter by inserting the given description before each
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]",
syntax: "<filter> append [modification]",
summary: "Append task description",
description: "
Modify all tasks matching the filter by adding the given description to the end
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]",
syntax: "<filter> start [modification]",
summary: "Start tasks",
description: "
Start all tasks matching the filter, additionally applying any given modifications."
Start all tasks matching the required filter, additionally applying any given modifications."
});
u.subcommands.push(usage::Subcommand {
name: "stop",
syntax: "[filter] stop [modification]",
syntax: "<filter> stop [modification]",
summary: "Stop tasks",
description: "
Stop all tasks matching the filter, additionally applying any given modifications.",
Stop all tasks matching the required filter, additionally applying any given modifications.",
});
u.subcommands.push(usage::Subcommand {
name: "done",
syntax: "[filter] done [modification]",
syntax: "<filter> done [modification]",
summary: "Mark tasks as completed",
description: "
Mark all tasks matching the filter as completed, additionally applying any given
Mark all tasks matching the required filter as completed, additionally applying any given
modifications.",
});
}
@ -293,14 +293,14 @@ impl Report {
}
// allow the filter expression before or after the report name
alt((
map_res(pair(arg_matching(report_name), Filter::parse), |input| {
map_res(pair(arg_matching(report_name), Filter::parse0), |input| {
to_subcommand(input.1, input.0)
}),
map_res(pair(Filter::parse, arg_matching(report_name)), |input| {
map_res(pair(Filter::parse0, arg_matching(report_name)), |input| {
to_subcommand(input.0, input.1)
}),
// default to a "next" report
map_res(Filter::parse, |input| to_subcommand(input, "next")),
map_res(Filter::parse0, |input| to_subcommand(input, "next")),
))(input)
}
@ -335,7 +335,7 @@ impl Info {
}
map_res(
pair(
Filter::parse,
Filter::parse1,
alt((
arg_matching(literal("info")),
arg_matching(literal("debug")),

View file

@ -1,8 +1,11 @@
use std::process::exit;
pub fn main() {
if let Err(err) = taskchampion_cli::main() {
eprintln!("{:?}", err);
exit(1);
match taskchampion_cli::main() {
Ok(_) => exit(0),
Err(e) => {
eprintln!("{:?}", e);
exit(e.exit_status());
}
}
}

53
cli/src/bin/usage-docs.rs Normal file
View file

@ -0,0 +1,53 @@
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)
}

59
cli/src/errors.rs Normal file
View file

@ -0,0 +1,59 @@
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;
#[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

@ -6,7 +6,7 @@ pub(crate) fn execute<W: WriteColor>(
w: &mut W,
replica: &mut Replica,
modification: Modification,
) -> anyhow::Result<()> {
) -> Result<(), crate::Error> {
let description = match modification.description {
DescriptionMod::Set(ref s) => s.clone(),
_ => "(no description)".to_owned(),

View file

@ -6,7 +6,7 @@ pub(crate) fn execute<W: WriteColor>(
w: &mut W,
config_operation: ConfigOperation,
settings: &Settings,
) -> anyhow::Result<()> {
) -> Result<(), crate::Error> {
match config_operation {
ConfigOperation::Set(key, value) => {
let filename = settings.set(&key, &value)?;
@ -19,6 +19,13 @@ pub(crate) fn execute<W: WriteColor>(
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(())
}

View file

@ -1,7 +1,7 @@
use taskchampion::Replica;
use termcolor::WriteColor;
pub(crate) fn execute<W: WriteColor>(w: &mut W, replica: &mut Replica) -> anyhow::Result<()> {
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)?;
writeln!(w, "garbage collected.")?;

View file

@ -5,7 +5,7 @@ pub(crate) fn execute<W: WriteColor>(
w: &mut W,
command_name: String,
summary: bool,
) -> anyhow::Result<()> {
) -> Result<(), crate::Error> {
let usage = Usage::new();
usage.write_help(w, command_name.as_ref(), summary)?;
Ok(())

View file

@ -10,7 +10,7 @@ pub(crate) fn execute<W: WriteColor>(
replica: &mut Replica,
filter: Filter,
debug: bool,
) -> anyhow::Result<()> {
) -> Result<(), crate::Error> {
let working_set = replica.working_set()?;
for task in filtered_tasks(replica, &filter)? {
@ -36,6 +36,9 @@ pub(crate) fn execute<W: WriteColor>(
tags.sort();
t.add_row(row![b->"Tags", tags.join(" ")]);
}
if let Some(wait) = task.get_wait() {
t.add_row(row![b->"Wait", wait]);
}
}
t.print(w)?;
}

View file

@ -1,18 +1,65 @@
use crate::argparse::{Filter, Modification};
use crate::invocation::util::{confirm, summarize_task};
use crate::invocation::{apply_modification, filtered_tasks};
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(crate) fn execute<W: WriteColor>(
w: &mut W,
replica: &mut Replica,
settings: &Settings,
filter: Filter,
modification: Modification,
) -> anyhow::Result<()> {
for task in filtered_tasks(replica, &filter)? {
) -> 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(w, &mut task, &modification)?;
apply_modification(&mut task, &modification)?;
let task = task.into_immut();
let summary = summarize_task(replica, &task)?;
writeln!(w, "modified task {}", summary)?;
}
Ok(())
@ -30,6 +77,7 @@ mod 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"))
@ -42,7 +90,7 @@ mod test {
description: DescriptionMod::Set(s!("new description")),
..Default::default()
};
execute(&mut w, &mut replica, filter, modification).unwrap();
execute(&mut w, &mut replica, &settings, filter, modification).unwrap();
// check that the task appeared..
let task = replica.get_task(task.get_uuid()).unwrap().unwrap();
@ -51,7 +99,7 @@ mod test {
assert_eq!(
w.into_string(),
format!("modified task {}\n", task.get_uuid())
format!("modified task 1 - new description\n")
);
}
}

View file

@ -10,7 +10,7 @@ pub(crate) fn execute<W: WriteColor>(
settings: &Settings,
report_name: String,
filter: Filter,
) -> anyhow::Result<()> {
) -> Result<(), crate::Error> {
display_report(w, replica, settings, report_name, filter)
}

View file

@ -5,7 +5,7 @@ pub(crate) fn execute<W: WriteColor>(
w: &mut W,
replica: &mut Replica,
server: &mut Box<dyn Server>,
) -> anyhow::Result<()> {
) -> Result<(), crate::Error> {
replica.sync(server)?;
writeln!(w, "sync complete.")?;
Ok(())

View file

@ -1,10 +1,20 @@
use crate::built_info;
use termcolor::{ColorSpec, WriteColor};
pub(crate) fn execute<W: WriteColor>(w: &mut W) -> anyhow::Result<()> {
pub(crate) fn execute<W: WriteColor>(w: &mut W) -> Result<(), crate::Error> {
write!(w, "TaskChampion ")?;
w.set_color(ColorSpec::new().set_bold(true))?;
writeln!(w, "{}", env!("CARGO_PKG_VERSION"))?;
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(())
}

View file

@ -1,22 +1,17 @@
use crate::argparse::{Condition, Filter, TaskId};
use std::collections::HashSet;
use std::convert::TryInto;
use taskchampion::{Replica, Status, Tag, Task, Uuid, WorkingSet};
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) => {
// see #111 for the unwrap
let tag: Tag = tag.try_into().unwrap();
if !task.has_tag(&tag) {
if !task.has_tag(tag) {
return false;
}
}
Condition::NoTag(ref tag) => {
// see #111 for the unwrap
let tag: Tag = tag.try_into().unwrap();
if task.has_tag(&tag) {
if task.has_tag(tag) {
return false;
}
}
@ -254,8 +249,8 @@ mod test {
#[test]
fn tag_filtering() -> anyhow::Result<()> {
let mut replica = test_replica();
let yes: Tag = "yes".try_into()?;
let no: Tag = "no".try_into()?;
let yes = tag!("yes");
let no = tag!("no");
let mut t1 = replica
.new_task(Status::Pending, s!("A"))?
@ -274,7 +269,7 @@ mod test {
// look for just "yes" (A and B)
let filter = Filter {
conditions: vec![Condition::HasTag(s!("yes"))],
conditions: vec![Condition::HasTag(tag!("yes"))],
};
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)?
.map(|t| t.get_description().to_owned())
@ -284,7 +279,7 @@ mod test {
// look for tags without "no" (A, D)
let filter = Filter {
conditions: vec![Condition::NoTag(s!("no"))],
conditions: vec![Condition::NoTag(tag!("no"))],
};
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)?
.map(|t| t.get_description().to_owned())
@ -294,7 +289,10 @@ mod test {
// look for tags with "yes" and "no" (B)
let filter = Filter {
conditions: vec![Condition::HasTag(s!("yes")), Condition::HasTag(s!("no"))],
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())

View file

@ -9,6 +9,7 @@ mod cmd;
mod filter;
mod modify;
mod report;
mod util;
#[cfg(test)]
mod test;
@ -19,7 +20,7 @@ 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) -> anyhow::Result<()> {
pub(crate) fn invoke(command: Command, settings: Settings) -> Result<(), crate::Error> {
log::debug!("command: {:?}", command);
log::debug!("settings: {:?}", settings);
@ -60,7 +61,7 @@ pub(crate) fn invoke(command: Command, settings: Settings) -> anyhow::Result<()>
modification,
},
..
} => return cmd::modify::execute(&mut w, &mut replica, filter, modification),
} => return cmd::modify::execute(&mut w, &mut replica, &settings, filter, modification),
Command {
subcommand:

View file

@ -1,11 +1,8 @@
use crate::argparse::{DescriptionMod, Modification};
use std::convert::TryInto;
use taskchampion::TaskMut;
use termcolor::WriteColor;
/// Apply the given modification
pub(super) fn apply_modification<W: WriteColor>(
w: &mut W,
pub(super) fn apply_modification(
task: &mut TaskMut,
modification: &Modification,
) -> anyhow::Result<()> {
@ -33,16 +30,16 @@ pub(super) fn apply_modification<W: WriteColor>(
}
for tag in modification.add_tags.iter() {
let tag = tag.try_into()?; // see #111
task.add_tag(&tag)?;
}
for tag in modification.remove_tags.iter() {
let tag = tag.try_into()?; // see #111
task.remove_tag(&tag)?;
}
writeln!(w, "modified task {}", task.get_uuid())?;
if let Some(wait) = modification.wait {
task.set_wait(wait)?;
}
Ok(())
}

View file

@ -27,6 +27,7 @@ fn sort_tasks(tasks: &mut Vec<Task>, report: &Report, working_set: &WorkingSet)
}
SortBy::Uuid => a.get_uuid().cmp(&b.get_uuid()),
SortBy::Description => a.get_description().cmp(b.get_description()),
SortBy::Wait => a.get_wait().cmp(&b.get_wait()),
};
// If this sort property is equal, go on to the next..
if ord == Ordering::Equal {
@ -71,6 +72,13 @@ fn task_column(task: &Task, column: &Column, working_set: &WorkingSet) -> String
tags.sort();
tags.join(" ")
}
Property::Wait => {
if task.is_waiting() {
task.get_wait().unwrap().format("%Y-%m-%d").to_string()
} else {
"".to_owned()
}
}
}
}
@ -80,7 +88,7 @@ pub(super) fn display_report<W: WriteColor>(
settings: &Settings,
report_name: String,
filter: Filter,
) -> anyhow::Result<()> {
) -> Result<(), crate::Error> {
let mut t = Table::new();
let working_set = replica.working_set()?;
@ -124,6 +132,7 @@ mod test {
use super::*;
use crate::invocation::test::*;
use crate::settings::Sort;
use chrono::prelude::*;
use std::convert::TryInto;
use taskchampion::{Status, Uuid};
@ -217,6 +226,50 @@ mod test {
assert_eq!(got_uuids, exp_uuids);
}
#[test]
fn sorting_by_wait() {
let mut replica = test_replica();
let uuids = create_tasks(&mut replica);
replica
.get_task(uuids[0])
.unwrap()
.unwrap()
.into_mut(&mut replica)
.set_wait(Some(Utc::now() + chrono::Duration::days(2)))
.unwrap();
replica
.get_task(uuids[1])
.unwrap()
.unwrap()
.into_mut(&mut replica)
.set_wait(Some(Utc::now() + chrono::Duration::days(3)))
.unwrap();
let working_set = replica.working_set().unwrap();
let report = Report {
sort: vec![Sort {
ascending: true,
sort_by: SortBy::Wait,
}],
..Default::default()
};
let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect();
sort_tasks(&mut tasks, &report, &working_set);
let got_uuids: Vec<_> = tasks.iter().map(|t| t.get_uuid()).collect();
let exp_uuids = vec![
uuids[2], // no wait
uuids[0], // wait:2d
uuids[1], // wait:3d
];
assert_eq!(got_uuids, exp_uuids);
}
#[test]
fn sorting_by_multiple() {
let mut replica = test_replica();
@ -350,8 +403,11 @@ mod test {
};
let task = replica.get_task(uuids[0]).unwrap().unwrap();
assert_eq!(task_column(&task, &column, &working_set), s!("+bar +foo"));
assert_eq!(
task_column(&task, &column, &working_set),
s!("+PENDING +bar +foo")
);
let task = replica.get_task(uuids[2]).unwrap().unwrap();
assert_eq!(task_column(&task, &column, &working_set), s!(""));
assert_eq!(task_column(&task, &column, &working_set), s!("+PENDING"));
}
}

View file

@ -0,0 +1,22 @@
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

@ -38,23 +38,34 @@ use std::string::FromUtf8Error;
mod macros;
mod argparse;
mod errors;
mod invocation;
mod settings;
mod table;
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() -> anyhow::Result<()> {
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| String::from_utf8(oss.into_vec()))
.collect::<Result<_, FromUtf8Error>>()?;
.collect::<Result<_, FromUtf8Error>>()
.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

View file

@ -30,3 +30,9 @@ macro_rules! set(
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

@ -7,5 +7,5 @@ mod report;
mod settings;
mod util;
pub(crate) use report::{Column, Property, Report, Sort, SortBy};
pub(crate) use report::{get_usage, Column, Property, Report, Sort, SortBy};
pub(crate) use settings::Settings;

View file

@ -2,6 +2,7 @@
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};
@ -30,6 +31,7 @@ pub(crate) struct Column {
/// 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,
@ -44,6 +46,9 @@ pub(crate) enum Property {
/// The task's tags
Tags,
/// The task's wait date
Wait,
}
/// A sorting criterion for a sort operation.
@ -59,6 +64,7 @@ pub(crate) struct Sort {
/// 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,
@ -68,6 +74,9 @@ pub(crate) enum SortBy {
/// The task's description
Description,
/// The task's wait date
Wait,
}
// Conversions from settings::Settings.
@ -171,6 +180,7 @@ impl TryFrom<&toml::Value> for Property {
"active" => Property::Active,
"description" => Property::Description,
"tags" => Property::Tags,
"wait" => Property::Wait,
_ => bail!(": unknown property {}", s),
})
}
@ -207,11 +217,45 @@ impl TryFrom<&toml::Value> for SortBy {
"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::*;

View file

@ -13,21 +13,25 @@ use toml_edit::Document;
#[derive(Debug, PartialEq)]
pub(crate) struct Settings {
// filename from which this configuration was loaded, if any
/// filename from which this configuration was loaded, if any
pub(crate) filename: Option<PathBuf>,
// replica
/// 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,
// remote sync server
/// 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
/// local sync server
pub(crate) server_dir: PathBuf,
// reports
/// reports
pub(crate) reports: HashMap<String, Report>,
}
@ -86,6 +90,7 @@ impl Settings {
fn update_from_toml(&mut self, config_toml: &toml::Value) -> Result<()> {
let table_keys = [
"data_dir",
"modification_count_prompt",
"server_client_key",
"server_origin",
"encryption_secret",
@ -109,10 +114,24 @@ impl Settings {
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(())
}
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_str_cfg(table, "server_client_key", |v| {
self.server_client_key = Some(v);
})?;
@ -142,10 +161,12 @@ impl Settings {
Ok(())
}
/// Set a value in the config file, modifying it in place. Returns the filename.
/// 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",
@ -168,7 +189,17 @@ impl Settings {
.parse::<Document>()
.context("Could not parse existing configuration file")?;
document[key] = toml_edit::value(value);
// 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")?;
@ -218,6 +249,10 @@ impl Default for Settings {
label: "tags".to_owned(),
property: Property::Tags,
},
Column {
label: "wait".to_owned(),
property: Property::Wait,
},
],
filter: Default::default(),
},
@ -263,6 +298,7 @@ impl Default for Settings {
Self {
filename: None,
data_dir,
modification_count_prompt: None,
server_client_key: None,
server_origin: None,
encryption_secret: None,
@ -312,6 +348,7 @@ mod 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"
@ -321,6 +358,7 @@ mod test {
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()));
@ -350,11 +388,26 @@ mod test {
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 change
// 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

@ -2,26 +2,31 @@
//! a way that puts the source of that documentation near its implementation.
use crate::argparse;
use std::io::{Result, Write};
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(crate) struct Usage {
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(crate) fn new() -> Self {
pub fn new() -> Self {
let mut rv = Self {
..Default::default()
};
argparse::get_usage(&mut rv);
// TODO: sort subcommands
settings::get_usage(&mut rv);
rv
}
@ -77,6 +82,62 @@ impl Usage {
}
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
@ -122,6 +183,15 @@ impl Subcommand {
}
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
@ -152,6 +222,15 @@ impl Filter {
}
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
@ -182,4 +261,51 @@ impl Modification {
}
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).
#[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

@ -56,7 +56,8 @@ fn invalid_option() -> Result<(), Box<dyn std::error::Error>> {
cmd.arg("--no-such-option");
cmd.assert()
.failure()
.stderr(predicate::str::contains("command line not recognized"));
.stderr(predicate::str::contains("command line not recognized"))
.code(predicate::eq(3));
Ok(())
}

View file

@ -1,3 +1,10 @@
This is an [mdbook](https://rust-lang.github.io/mdBook/index.html) book.
Minor modifications can be made without installing the mdbook tool, as the content is simple Markdown.
Changes are verified on pull requests.
To build the docs locally, you will need to build `usage-docs`:
```
cargo build -p taskchampion-cli --feature usage-docs --bin usage-docs
mdbook build docs/
```

View file

@ -7,3 +7,6 @@ title = "TaskChampion"
[output.html]
default-theme = "ayu"
[preprocessor.usage-docs]
command = "target/debug/usage-docs"

View file

@ -1,23 +0,0 @@
#! /bin/bash
REMOTE=origin
set -e
if ! [ -f "./src/SUMMARY.md" ]; then
echo "Run this from the docs/ dir"
exit 1
fi
if ! [ -d ./tmp ]; then
git worktree add tmp gh-pages
fi
(cd tmp && git pull $REMOTE gh-pages)
rm -rf tmp/*
mdbook build
cp -rp book/* tmp
(cd tmp && git add -A)
(cd tmp && git commit -am "update docs")
(cd tmp && git push $REMOTE gh-pages:gh-pages)

View file

@ -3,9 +3,12 @@
- [Welcome to TaskChampion](./welcome.md)
* [Installation](./installation.md)
* [Using the Task Command](./using-task-command.md)
* [Configuration](./config-file.md)
* [Reports](./reports.md)
* [Tags](./tags.md)
* [Filters](./filters.md)
* [Modifications](./modifications.md)
* [Dates and Durations](./time.md)
* [Configuration](./config-file.md)
* [Environment](./environment.md)
* [Synchronization](./task-sync.md)
* [Running the Sync Server](./running-sync-server.md)

View file

@ -20,6 +20,12 @@ data_dir = "/home/myuser/.tasks"
* `data_dir` - path to a directory containing the replica's task data (which will be created if necessary).
Default: `taskchampion` in the local data directory.
## Command-Line Preferences
* `modification_count_prompt` - when a modification will affect more than this many tasks, the `ta` command will prompt for confirmation.
A value of `0` will disable the prompts entirely.
Default: 3.
## Sync Server
If using a local server:

9
docs/src/filters.md Normal file
View file

@ -0,0 +1,9 @@
# Filters
Filters are used to select specific tasks for reports or to specify tasks to be modified.
When more than one filter is given, only tasks which match all of the filters are selected.
When no filter is given, the command implicitly selects all tasks.
Filters can have the following forms:
<!-- INSERT GENERATED DOCUMENTATION - filters -->

View file

@ -0,0 +1,5 @@
# Modifications
Modifications can have the following forms:
<!-- INSERT GENERATED DOCUMENTATION - modifications-->

View file

@ -49,9 +49,8 @@ columns = [
]
```
The filter is a list of filter arguments, just like those that can be used on the command line.
See the `ta help` output for more details on this syntax.
It will be merged with any filters provided on the command line, when the report is invoked.
The `filter` property is a list of [filters](./filters.md).
It will be merged with any filters provided on the command line when the report is invoked.
The sort order is defined by an array of tables containing a `sort_by` property and an optional `ascending` property.
Tasks are compared by the first criterion, and if that is equal by the second, and so on.
@ -70,11 +69,11 @@ sort = [
The available values of `sort_by` are
(TODO: generate automatically)
<!-- INSERT GENERATED DOCUMENTATION - report-sort-by -->
Finally, the `columns` configuration specifies the list of columns to display.
Each element has a `label` and a `property`, as shown in the example above.
The avaliable properties are:
(TODO: generate automatically)
<!-- INSERT GENERATED DOCUMENTATION - report-columns -->

View file

@ -10,3 +10,17 @@ For example, when it's time to continue the job search, `ta +jobsearch` will sho
Specifically, tags must be at least one character long and cannot contain whitespace or any of the characters `+-*/(<>^! %=~`.
The first character cannot be a digit, and `:` is not allowed after the first character.
All-capital tags are reserved for synthetic tags (below) and cannot be added or removed from tasks.
## Synthetic Tags
Synthetic tags are present on tasks that meet specific criteria, that are commonly used for filtering.
For example, `WAITING` is set for tasks that are currently waiting.
These tags cannot be added or removed from a task, but appear and disappear as the task changes.
The following synthetic tags are defined:
* `WAITING` - set if the task is waiting (has a `wait` property with a date in the future)
* `ACTIVE` - set if the task is active (has been started and not stopped)
* `PENDING` - set if the task is pending (not completed or deleted)
* `COMPLETED` - set if the task has been completed
* `DELETED` - set if the task has been deleted (but not yet flushed from the task list)

View file

@ -32,6 +32,7 @@ The following keys, and key formats, are defined:
* `modified` - the time of the last modification of this task
* `start.<timestamp>` - either an empty string (representing work on the task to the task that has not been stopped) or a timestamp (representing the time that work stopped)
* `tag.<tag>` - indicates this task has tag `<tag>` (value is an empty string)
* `wait` - indicates the time before which this task should be hidden, as it is not actionable
The following are not yet implemented:

30
docs/src/time.md Normal file
View file

@ -0,0 +1,30 @@
## Timestamps
Times may be specified in a wide variety of convenient formats.
* [RFC3339](https://datatracker.ietf.org/doc/html/rfc3339) timestamps, such as `2019-10-12 07:20:50.12Z`
* A date of the format `YYYY-MM-DD` is interpreted as the _local_ midnight at the beginning of the given date.
Single-digit month and day are accepted, but the year must contain four digits.
* `now` refers to the exact current time
* `yesterday`, `today`, and `tomorrow` refer to the _local_ midnight at the beginning of the given day
* Any duration (described below) may be used as a timestamp, and is considered relative to the current time.
Times are stored internally as UTC.
## Durations
Durations can be given in a dizzying array of units.
Each can be preceded by a whole number or a decimal multiplier, e.g., `3days`.
The multiplier is optional with the singular forms of the units; for example `day` is allowed.
Some of the units allow an adjectival form, such as `daily` or `annually`; this form is more readable in some cases, but otherwise has the same meaning.
* `s`, `second`, or `seconds`
* `min`, `mins`, `minute`, or `minutes` (note that `m` not allowed, as it might also mean `month`)
* `h`, `hour`, or `hours`
* `d`, `day`, or `days`
* `w`, `week`, or `weeks`
* `mo`, or `months` (always 30 days, regardless of calendar month)
* `y`, `year`, or `years` (365 days, regardless of leap days)
[ISO 8601 standard durations](https://en.wikipedia.org/wiki/ISO_8601#Durations) are also allowed.
While the standard does not specify the length of "P1Y" or "P1M", Taskchampion treats those as 365 and 30 days, respectively.

View file

@ -4,6 +4,13 @@ The main interface to your tasks is the `ta` command, which supports various sub
Customizable [reports](./reports.md) are also available as subcommands, such as `next`.
The command reads a [configuration file](./config-file.md) for its settings, including where to find the task database.
And the `sync` subcommand [synchronizes tasks with a sync server](./task-sync.md).
You can find a list of all subcommands, as well as the built-in reports, with `ta help`.
> NOTE: the `task` interface does not precisely match that of TaskWarrior.
## Subcommands
The sections below describe each subcommand of the `ta` command.
The syntax of `[filter]` is defined in [filters](./filters.md), and that of `[modification]` in [modifications](./modifications.md).
You can also find a summary of all subcommands, as well as filters, built-in reports, and so on, with `ta help`.
<!-- INSERT GENERATED DOCUMENTATION - subcommands -->

View file

@ -21,7 +21,10 @@ ureq = "^2.1.0"
log = "^0.4.14"
tindercrypt = { version = "^0.2.2", default-features = false }
rusqlite = { version = "0.25", features = ["bundled"] }
strum = "0.21"
strum_macros = "0.21"
[dev-dependencies]
proptest = "^1.0.0"
tempfile = "3"
rstest = "0.10"

View file

@ -1,6 +1,9 @@
use thiserror::Error;
#[derive(Debug, Error, Eq, PartialEq, Clone)]
#[non_exhaustive]
/// Errors returned from taskchampion operations
pub enum Error {
#[error("Task Database Error: {}", _0)]
DbError(String),
#[error("Task Database Error: {0}")]
Database(String),
}

View file

@ -29,6 +29,10 @@ Users can define their own server impelementations.
See the [TaskChampion Book](http://taskchampion.github.com/taskchampion)
for more information about the design and usage of the tool.
# Minimum Supported Rust Version
This crate supports Rust version 1.47 and higher.
*/
mod errors;
@ -40,6 +44,7 @@ mod taskdb;
mod utils;
mod workingset;
pub use errors::Error;
pub use replica::Replica;
pub use server::{Server, ServerConfig};
pub use storage::StorageConfig;

View file

@ -113,7 +113,7 @@ impl Replica {
// check that it already exists; this is a convenience check, as the task may already exist
// when this Create operation is finally sync'd with operations from other replicas
if self.taskdb.get_task(uuid)?.is_none() {
return Err(Error::DbError(format!("Task {} does not exist", uuid)).into());
return Err(Error::Database(format!("Task {} does not exist", uuid)).into());
}
self.taskdb.apply(Operation::Delete { uuid })?;
trace!("task {} deleted", uuid);

View file

@ -0,0 +1,10 @@
use super::Timestamp;
/// An annotation for a task
#[derive(Debug, PartialEq)]
pub struct Annotation {
/// Time the annotation was made
pub entry: Timestamp,
/// Content of the annotation
pub description: String,
}

View file

@ -0,0 +1,16 @@
#![allow(clippy::module_inception)]
use chrono::prelude::*;
mod annotation;
mod priority;
mod status;
mod tag;
mod task;
pub use annotation::Annotation;
pub use priority::Priority;
pub use status::Status;
pub use tag::{Tag, INVALID_TAG_CHARACTERS};
pub use task::{Task, TaskMut};
pub type Timestamp = DateTime<Utc>;

View file

@ -0,0 +1,48 @@
/// The priority of a task
#[derive(Debug, PartialEq)]
pub enum Priority {
/// Low
L,
/// Medium
M,
/// High
H,
}
#[allow(dead_code)]
impl Priority {
/// Get a Priority from the 1-character value in a TaskMap,
/// defaulting to M
pub(crate) fn from_taskmap(s: &str) -> Priority {
match s {
"L" => Priority::L,
"M" => Priority::M,
"H" => Priority::H,
_ => Priority::M,
}
}
/// Get the 1-character value for this priority to use in the TaskMap.
pub(crate) fn to_taskmap(&self) -> &str {
match self {
Priority::L => "L",
Priority::M => "M",
Priority::H => "H",
}
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_priority() {
assert_eq!(Priority::L.to_taskmap(), "L");
assert_eq!(Priority::M.to_taskmap(), "M");
assert_eq!(Priority::H.to_taskmap(), "H");
assert_eq!(Priority::from_taskmap("L"), Priority::L);
assert_eq!(Priority::from_taskmap("M"), Priority::M);
assert_eq!(Priority::from_taskmap("H"), Priority::H);
}
}

View file

@ -0,0 +1,54 @@
/// The status of a task. The default status in "Pending".
#[derive(Debug, PartialEq, Clone)]
pub enum Status {
Pending,
Completed,
Deleted,
}
impl Status {
/// Get a Status from the 1-character value in a TaskMap,
/// defaulting to Pending
pub(crate) fn from_taskmap(s: &str) -> Status {
match s {
"P" => Status::Pending,
"C" => Status::Completed,
"D" => Status::Deleted,
_ => Status::Pending,
}
}
/// Get the 1-character value for this status to use in the TaskMap.
pub(crate) fn to_taskmap(&self) -> &str {
match self {
Status::Pending => "P",
Status::Completed => "C",
Status::Deleted => "D",
}
}
/// Get the full-name value for this status to use in the TaskMap.
pub fn to_string(&self) -> &str {
// TODO: should be impl Display
match self {
Status::Pending => "Pending",
Status::Completed => "Completed",
Status::Deleted => "Deleted",
}
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_status() {
assert_eq!(Status::Pending.to_taskmap(), "P");
assert_eq!(Status::Completed.to_taskmap(), "C");
assert_eq!(Status::Deleted.to_taskmap(), "D");
assert_eq!(Status::from_taskmap("P"), Status::Pending);
assert_eq!(Status::from_taskmap("C"), Status::Completed);
assert_eq!(Status::from_taskmap("D"), Status::Deleted);
}
}

View file

@ -0,0 +1,169 @@
use std::convert::TryFrom;
use std::fmt;
use std::str::FromStr;
/// A Tag is a descriptor for a task, that is either present or absent, and can be used for
/// filtering. Tags composed of all uppercase letters are reserved for synthetic tags.
///
/// Valid tags must not contain whitespace or any of the characters in [`INVALID_TAG_CHARACTERS`].
/// The first characters additionally cannot be a digit, and subsequent characters cannot be `:`.
/// This definition is based on [that of
/// TaskWarrior](https://github.com/GothenburgBitFactory/taskwarrior/blob/663c6575ceca5bd0135ae884879339dac89d3142/src/Lexer.cpp#L146-L164).
#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
pub struct Tag(TagInner);
/// Inner type to hide the implementation
#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)]
pub(super) enum TagInner {
User(String),
Synthetic(SyntheticTag),
}
pub const INVALID_TAG_CHARACTERS: &str = "+-*/(<>^! %=~";
impl Tag {
/// True if this tag is a synthetic tag
pub fn is_synthetic(&self) -> bool {
matches!(self.0, TagInner::Synthetic(_))
}
/// True if this tag is a user-provided tag (not synthetic)
pub fn is_user(&self) -> bool {
matches!(self.0, TagInner::User(_))
}
pub(super) fn inner(&self) -> &TagInner {
&self.0
}
pub(super) fn from_inner(inner: TagInner) -> Self {
Self(inner)
}
}
impl FromStr for Tag {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Tag, anyhow::Error> {
fn err(value: &str) -> Result<Tag, anyhow::Error> {
anyhow::bail!("invalid tag {:?}", value)
}
// first, look for synthetic tags
if value.chars().all(|c| c.is_ascii_uppercase()) {
if let Ok(st) = SyntheticTag::from_str(value) {
return Ok(Self(TagInner::Synthetic(st)));
}
// all uppercase, but not a valid synthetic tag
return err(value);
}
if let Some(c) = value.chars().next() {
if c.is_whitespace() || c.is_ascii_digit() || INVALID_TAG_CHARACTERS.contains(c) {
return err(value);
}
} else {
return err(value);
}
if !value
.chars()
.skip(1)
.all(|c| !(c.is_whitespace() || c == ':' || INVALID_TAG_CHARACTERS.contains(c)))
{
return err(value);
}
Ok(Self(TagInner::User(String::from(value))))
}
}
impl TryFrom<&str> for Tag {
type Error = anyhow::Error;
fn try_from(value: &str) -> Result<Tag, Self::Error> {
Self::from_str(value)
}
}
impl TryFrom<&String> for Tag {
type Error = anyhow::Error;
fn try_from(value: &String) -> Result<Tag, Self::Error> {
Self::from_str(&value[..])
}
}
impl fmt::Display for Tag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match &self.0 {
TagInner::User(s) => s.fmt(f),
TagInner::Synthetic(st) => st.as_ref().fmt(f),
}
}
}
impl AsRef<str> for Tag {
fn as_ref(&self) -> &str {
match &self.0 {
TagInner::User(s) => s.as_ref(),
TagInner::Synthetic(st) => st.as_ref(),
}
}
}
/// A synthetic tag, represented as an `enum`. This type is used directly by
/// [`taskchampion::task::task`] for efficiency.
#[derive(
Debug,
Clone,
Eq,
PartialEq,
Ord,
PartialOrd,
Hash,
strum_macros::EnumString,
strum_macros::AsRefStr,
strum_macros::EnumIter,
)]
#[strum(serialize_all = "SCREAMING_SNAKE_CASE")]
pub(super) enum SyntheticTag {
// When adding items here, also implement and test them in `task.rs` and document them in
// `docs/src/tags.md`.
Waiting,
Active,
Pending,
Completed,
Deleted,
}
#[cfg(test)]
mod test {
use super::*;
use rstest::rstest;
use std::convert::TryInto;
#[rstest]
#[case::simple("abc")]
#[case::colon_prefix(":abc")]
#[case::letters_and_numbers("a123_456")]
#[case::synthetic("WAITING")]
fn test_tag_try_into_success(#[case] s: &'static str) {
let tag: Tag = s.try_into().unwrap();
// check Display (via to_string) and AsRef while we're here
assert_eq!(tag.to_string(), s.to_owned());
assert_eq!(tag.as_ref(), s);
}
#[rstest]
#[case::empty("")]
#[case::colon_infix("a:b")]
#[case::digits("999")]
#[case::bangs("abc!!!")]
#[case::no_such_synthetic("NOSUCH")]
fn test_tag_try_into_err(#[case] s: &'static str) {
let tag: Result<Tag, _> = s.try_into();
assert_eq!(
tag.unwrap_err().to_string(),
format!("invalid tag \"{}\"", s)
);
}
}

View file

@ -1,156 +1,13 @@
use super::tag::{SyntheticTag, TagInner};
use super::{Status, Tag};
use crate::replica::Replica;
use crate::storage::TaskMap;
use chrono::prelude::*;
use log::trace;
use std::convert::{TryFrom, TryInto};
use std::fmt;
use std::convert::AsRef;
use std::convert::TryInto;
use uuid::Uuid;
pub type Timestamp = DateTime<Utc>;
/// The priority of a task
#[derive(Debug, PartialEq)]
pub enum Priority {
/// Low
L,
/// Medium
M,
/// High
H,
}
#[allow(dead_code)]
impl Priority {
/// Get a Priority from the 1-character value in a TaskMap,
/// defaulting to M
pub(crate) fn from_taskmap(s: &str) -> Priority {
match s {
"L" => Priority::L,
"M" => Priority::M,
"H" => Priority::H,
_ => Priority::M,
}
}
/// Get the 1-character value for this priority to use in the TaskMap.
pub(crate) fn to_taskmap(&self) -> &str {
match self {
Priority::L => "L",
Priority::M => "M",
Priority::H => "H",
}
}
}
/// The status of a task. The default status in "Pending".
#[derive(Debug, PartialEq, Clone)]
pub enum Status {
Pending,
Completed,
Deleted,
}
impl Status {
/// Get a Status from the 1-character value in a TaskMap,
/// defaulting to Pending
pub(crate) fn from_taskmap(s: &str) -> Status {
match s {
"P" => Status::Pending,
"C" => Status::Completed,
"D" => Status::Deleted,
_ => Status::Pending,
}
}
/// Get the 1-character value for this status to use in the TaskMap.
pub(crate) fn to_taskmap(&self) -> &str {
match self {
Status::Pending => "P",
Status::Completed => "C",
Status::Deleted => "D",
}
}
/// Get the full-name value for this status to use in the TaskMap.
pub fn to_string(&self) -> &str {
// TODO: should be impl Display
match self {
Status::Pending => "Pending",
Status::Completed => "Completed",
Status::Deleted => "Deleted",
}
}
}
/// A Tag is a newtype around a String that limits its values to valid tags.
///
/// Valid tags must not contain whitespace or any of the characters in [`INVALID_TAG_CHARACTERS`].
/// The first characters additionally cannot be a digit, and subsequent characters cannot be `:`.
/// This definition is based on [that of
/// TaskWarrior](https://github.com/GothenburgBitFactory/taskwarrior/blob/663c6575ceca5bd0135ae884879339dac89d3142/src/Lexer.cpp#L146-L164).
#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)]
pub struct Tag(String);
pub const INVALID_TAG_CHARACTERS: &str = "+-*/(<>^! %=~";
impl Tag {
fn from_str(value: &str) -> Result<Tag, anyhow::Error> {
fn err(value: &str) -> Result<Tag, anyhow::Error> {
anyhow::bail!("invalid tag {:?}", value)
}
if let Some(c) = value.chars().next() {
if c.is_whitespace() || c.is_ascii_digit() || INVALID_TAG_CHARACTERS.contains(c) {
return err(value);
}
} else {
return err(value);
}
if !value
.chars()
.skip(1)
.all(|c| !(c.is_whitespace() || c == ':' || INVALID_TAG_CHARACTERS.contains(c)))
{
return err(value);
}
Ok(Self(String::from(value)))
}
}
impl TryFrom<&str> for Tag {
type Error = anyhow::Error;
fn try_from(value: &str) -> Result<Tag, Self::Error> {
Self::from_str(value)
}
}
impl TryFrom<&String> for Tag {
type Error = anyhow::Error;
fn try_from(value: &String) -> Result<Tag, Self::Error> {
Self::from_str(&value[..])
}
}
impl fmt::Display for Tag {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
self.0.fmt(f)
}
}
impl AsRef<str> for Tag {
fn as_ref(&self) -> &str {
self.0.as_ref()
}
}
#[derive(Debug, PartialEq)]
pub struct Annotation {
pub entry: Timestamp,
pub description: String,
}
/// A task, as publicly exposed by this crate.
///
/// Note that Task objects represent a snapshot of the task at a moment in time, and are not
@ -211,6 +68,20 @@ impl Task {
.unwrap_or("")
}
/// Get the wait time. If this value is set, it will be returned, even
/// if it is in the past.
pub fn get_wait(&self) -> Option<DateTime<Utc>> {
self.get_timestamp("wait")
}
/// Determine whether this task is waiting now.
pub fn is_waiting(&self) -> bool {
if let Some(ts) = self.get_wait() {
return ts > Utc::now();
}
false
}
/// Determine whether this task is active -- that is, that it has been started
/// and not stopped.
pub fn is_active(&self) -> bool {
@ -219,22 +90,46 @@ impl Task {
.any(|(k, v)| k.starts_with("start.") && v.is_empty())
}
/// Determine whether a given synthetic tag is present on this task. All other
/// synthetic tag calculations are based on this one.
fn has_synthetic_tag(&self, synth: &SyntheticTag) -> bool {
match synth {
SyntheticTag::Waiting => self.is_waiting(),
SyntheticTag::Active => self.is_active(),
SyntheticTag::Pending => self.get_status() == Status::Pending,
SyntheticTag::Completed => self.get_status() == Status::Completed,
SyntheticTag::Deleted => self.get_status() == Status::Deleted,
}
}
/// Check if this task has the given tag
pub fn has_tag(&self, tag: &Tag) -> bool {
self.taskmap.contains_key(&format!("tag.{}", tag))
match tag.inner() {
TagInner::User(s) => self.taskmap.contains_key(&format!("tag.{}", s)),
TagInner::Synthetic(st) => self.has_synthetic_tag(st),
}
}
/// Iterate over the task's tags
pub fn get_tags(&self) -> impl Iterator<Item = Tag> + '_ {
self.taskmap.iter().filter_map(|(k, _)| {
if let Some(tag) = k.strip_prefix("tag.") {
if let Ok(tag) = tag.try_into() {
return Some(tag);
use strum::IntoEnumIterator;
self.taskmap
.iter()
.filter_map(|(k, _)| {
if let Some(tag) = k.strip_prefix("tag.") {
if let Ok(tag) = tag.try_into() {
return Some(tag);
}
// note that invalid "tag.*" are ignored
}
// note that invalid "tag.*" are ignored
}
None
})
None
})
.chain(
SyntheticTag::iter()
.filter(move |st| self.has_synthetic_tag(st))
.map(|st| Tag::from_inner(TagInner::Synthetic(st))),
)
}
pub fn get_modified(&self) -> Option<DateTime<Utc>> {
@ -275,6 +170,10 @@ impl<'r> TaskMut<'r> {
self.set_string("description", Some(description))
}
pub fn set_wait(&mut self, wait: Option<DateTime<Utc>>) -> anyhow::Result<()> {
self.set_timestamp("wait", wait)
}
pub fn set_modified(&mut self, modified: DateTime<Utc>) -> anyhow::Result<()> {
self.set_timestamp("modified", Some(modified))
}
@ -306,13 +205,24 @@ impl<'r> TaskMut<'r> {
Ok(())
}
/// Mark this task as complete
pub fn done(&mut self) -> anyhow::Result<()> {
self.set_status(Status::Completed)
}
/// Add a tag to this task. Does nothing if the tag is already present.
pub fn add_tag(&mut self, tag: &Tag) -> anyhow::Result<()> {
if tag.is_synthetic() {
anyhow::bail!("Synthetic tags cannot be modified");
}
self.set_string(format!("tag.{}", tag), Some("".to_owned()))
}
/// Remove a tag from this task. Does nothing if the tag is not present.
pub fn remove_tag(&mut self, tag: &Tag) -> anyhow::Result<()> {
if tag.is_synthetic() {
anyhow::bail!("Synthetic tags cannot be modified");
}
self.set_string(format!("tag.{}", tag), None)
}
@ -398,28 +308,14 @@ mod test {
f(task)
}
#[test]
fn test_tag_from_str() {
let tag: Tag = "abc".try_into().unwrap();
assert_eq!(tag, Tag("abc".to_owned()));
/// Create a user tag, without checking its validity
fn utag(name: &'static str) -> Tag {
Tag::from_inner(TagInner::User(name.into()))
}
let tag: Tag = ":abc".try_into().unwrap();
assert_eq!(tag, Tag(":abc".to_owned()));
let tag: Tag = "a123_456".try_into().unwrap();
assert_eq!(tag, Tag("a123_456".to_owned()));
let tag: Result<Tag, _> = "".try_into();
assert_eq!(tag.unwrap_err().to_string(), "invalid tag \"\"");
let tag: Result<Tag, _> = "a:b".try_into();
assert_eq!(tag.unwrap_err().to_string(), "invalid tag \"a:b\"");
let tag: Result<Tag, _> = "999".try_into();
assert_eq!(tag.unwrap_err().to_string(), "invalid tag \"999\"");
let tag: Result<Tag, _> = "abc!!".try_into();
assert_eq!(tag.unwrap_err().to_string(), "invalid tag \"abc!!\"");
/// Create a synthetic tag
fn stag(synth: SyntheticTag) -> Tag {
Tag::from_inner(TagInner::Synthetic(synth))
}
#[test]
@ -453,16 +349,59 @@ mod test {
}
#[test]
fn test_has_tag() {
fn test_wait_not_set() {
let task = Task::new(Uuid::new_v4(), TaskMap::new());
assert!(!task.is_waiting());
assert_eq!(task.get_wait(), None);
}
#[test]
fn test_wait_in_past() {
let ts = Utc.ymd(1970, 1, 1).and_hms(0, 0, 0);
let task = Task::new(
Uuid::new_v4(),
vec![(String::from("tag.abc"), String::from(""))]
vec![(String::from("wait"), format!("{}", ts.timestamp()))]
.drain(..)
.collect(),
);
dbg!(&task);
assert!(!task.is_waiting());
assert_eq!(task.get_wait(), Some(ts));
}
#[test]
fn test_wait_in_future() {
let ts = Utc.ymd(3000, 1, 1).and_hms(0, 0, 0);
let task = Task::new(
Uuid::new_v4(),
vec![(String::from("wait"), format!("{}", ts.timestamp()))]
.drain(..)
.collect(),
);
assert!(task.has_tag(&"abc".try_into().unwrap()));
assert!(!task.has_tag(&"def".try_into().unwrap()));
assert!(task.is_waiting());
assert_eq!(task.get_wait(), Some(ts));
}
#[test]
fn test_has_tag() {
let task = Task::new(
Uuid::new_v4(),
vec![
(String::from("tag.abc"), String::from("")),
(String::from("start.1234"), String::from("")),
]
.drain(..)
.collect(),
);
assert!(task.has_tag(&utag("abc")));
assert!(!task.has_tag(&utag("def")));
assert!(task.has_tag(&stag(SyntheticTag::Active)));
assert!(task.has_tag(&stag(SyntheticTag::Pending)));
assert!(!task.has_tag(&stag(SyntheticTag::Waiting)));
}
#[test]
@ -472,6 +411,8 @@ mod test {
vec![
(String::from("tag.abc"), String::from("")),
(String::from("tag.def"), String::from("")),
// set `wait` so the synthetic tag WAITING is present
(String::from("wait"), String::from("33158909732")),
]
.drain(..)
.collect(),
@ -479,7 +420,14 @@ mod test {
let mut tags: Vec<_> = task.get_tags().collect();
tags.sort();
assert_eq!(tags, vec![Tag("abc".to_owned()), Tag("def".to_owned())]);
let mut exp = vec![
utag("abc"),
utag("def"),
stag(SyntheticTag::Pending),
stag(SyntheticTag::Waiting),
];
exp.sort();
assert_eq!(tags, exp);
}
#[test]
@ -498,7 +446,7 @@ mod test {
// only "ok" is OK
let tags: Vec<_> = task.get_tags().collect();
assert_eq!(tags, vec![Tag("ok".to_owned())]);
assert_eq!(tags, vec![utag("ok"), stag(SyntheticTag::Pending)]);
}
fn count_taskmap(task: &TaskMut, f: fn(&(&String, &String)) -> bool) -> usize {
@ -558,6 +506,20 @@ mod test {
});
}
#[test]
fn test_done() {
with_mut_task(|mut task| {
task.done().unwrap();
assert_eq!(task.get_status(), Status::Completed);
assert!(task.has_tag(&stag(SyntheticTag::Completed)));
// redundant call does nothing..
task.done().unwrap();
assert_eq!(task.get_status(), Status::Completed);
assert!(task.has_tag(&stag(SyntheticTag::Completed)));
});
}
#[test]
fn test_stop_multiple() {
with_mut_task(|mut task| {
@ -593,12 +555,12 @@ mod test {
#[test]
fn test_add_tags() {
with_mut_task(|mut task| {
task.add_tag(&Tag("abc".to_owned())).unwrap();
task.add_tag(&utag("abc")).unwrap();
assert!(task.taskmap.contains_key("tag.abc"));
task.reload().unwrap();
assert!(task.taskmap.contains_key("tag.abc"));
// redundant add has no effect..
task.add_tag(&Tag("abc".to_owned())).unwrap();
task.add_tag(&utag("abc")).unwrap();
assert!(task.taskmap.contains_key("tag.abc"));
});
}
@ -606,35 +568,15 @@ mod test {
#[test]
fn test_remove_tags() {
with_mut_task(|mut task| {
task.add_tag(&Tag("abc".to_owned())).unwrap();
task.add_tag(&utag("abc")).unwrap();
task.reload().unwrap();
assert!(task.taskmap.contains_key("tag.abc"));
task.remove_tag(&Tag("abc".to_owned())).unwrap();
task.remove_tag(&utag("abc")).unwrap();
assert!(!task.taskmap.contains_key("tag.abc"));
// redundant remove has no effect..
task.remove_tag(&Tag("abc".to_owned())).unwrap();
task.remove_tag(&utag("abc")).unwrap();
assert!(!task.taskmap.contains_key("tag.abc"));
});
}
#[test]
fn test_priority() {
assert_eq!(Priority::L.to_taskmap(), "L");
assert_eq!(Priority::M.to_taskmap(), "M");
assert_eq!(Priority::H.to_taskmap(), "H");
assert_eq!(Priority::from_taskmap("L"), Priority::L);
assert_eq!(Priority::from_taskmap("M"), Priority::M);
assert_eq!(Priority::from_taskmap("H"), Priority::H);
}
#[test]
fn test_status() {
assert_eq!(Status::Pending.to_taskmap(), "P");
assert_eq!(Status::Completed.to_taskmap(), "C");
assert_eq!(Status::Deleted.to_taskmap(), "D");
assert_eq!(Status::from_taskmap("P"), Status::Pending);
assert_eq!(Status::from_taskmap("C"), Status::Completed);
assert_eq!(Status::from_taskmap("D"), Status::Deleted);
}
}

View file

@ -49,12 +49,12 @@ impl TaskDb {
Operation::Create { uuid } => {
// insert if the task does not already exist
if !txn.create_task(*uuid)? {
return Err(Error::DbError(format!("Task {} already exists", uuid)).into());
return Err(Error::Database(format!("Task {} already exists", uuid)).into());
}
}
Operation::Delete { ref uuid } => {
if !txn.delete_task(*uuid)? {
return Err(Error::DbError(format!("Task {} does not exist", uuid)).into());
return Err(Error::Database(format!("Task {} does not exist", uuid)).into());
}
}
Operation::Update {
@ -71,7 +71,7 @@ impl TaskDb {
};
txn.set_task(*uuid, task)?;
} else {
return Err(Error::DbError(format!("Task {} does not exist", uuid)).into());
return Err(Error::Database(format!("Task {} does not exist", uuid)).into());
}
}
}