mirror of
https://github.com/GothenburgBitFactory/taskwarrior.git
synced 2025-08-22 02:03:08 +02:00
Merge remote-tracking branch 'origin/main' into sqlstore
# Conflicts: # Cargo.lock # taskchampion/Cargo.toml
This commit is contained in:
commit
2f533d2f3a
74 changed files with 3491 additions and 861 deletions
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
|
@ -0,0 +1 @@
|
|||
* @dbr @djmitche
|
3
.github/workflows/audit.yml
vendored
3
.github/workflows/audit.yml
vendored
|
@ -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
|
||||
|
|
|
@ -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
|
25
.github/workflows/publish-docs.yml
vendored
25
.github/workflows/publish-docs.yml
vendored
|
@ -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
43
.github/workflows/tests.yml
vendored
Normal 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
1191
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
20
POLICY.md
20
POLICY.md
|
@ -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).
|
||||
|
|
|
@ -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`.
|
||||
|
|
|
@ -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
11
SECURITY.md
Normal 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
31
build-docs.sh
Executable 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)
|
|
@ -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
3
cli/build.rs
Normal file
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
built::write_built_file().expect("Failed to acquire build-time information");
|
||||
}
|
|
@ -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()),
|
||||
]);
|
||||
}
|
||||
}
|
60
cli/src/argparse/args/arg_matching.rs
Normal file
60
cli/src/argparse/args/arg_matching.rs
Normal 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());
|
||||
}
|
||||
}
|
85
cli/src/argparse/args/colon.rs
Normal file
85
cli/src/argparse/args/colon.rs
Normal 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)));
|
||||
}
|
||||
}
|
139
cli/src/argparse/args/idlist.rs
Normal file
139
cli/src/argparse/args/idlist.rs
Normal 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()),
|
||||
]);
|
||||
}
|
||||
}
|
41
cli/src/argparse/args/misc.rs
Normal file
41
cli/src/argparse/args/misc.rs
Normal 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());
|
||||
}
|
||||
}
|
16
cli/src/argparse/args/mod.rs
Normal file
16
cli/src/argparse/args/mod.rs
Normal file
|
@ -0,0 +1,16 @@
|
|||
//! Parsers for single arguments (strings)
|
||||
|
||||
mod arg_matching;
|
||||
mod colon;
|
||||
mod idlist;
|
||||
mod misc;
|
||||
mod tags;
|
||||
mod time;
|
||||
|
||||
pub(crate) use arg_matching::arg_matching;
|
||||
pub(crate) use colon::{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};
|
34
cli/src/argparse/args/tags.rs
Normal file
34
cli/src/argparse/args/tags.rs
Normal 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());
|
||||
}
|
||||
}
|
466
cli/src/argparse/args/time.rs
Normal file
466
cli/src/argparse/args/time.rs
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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"),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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")),
|
||||
],
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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];
|
||||
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
);
|
||||
|
|
|
@ -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")),
|
||||
|
|
|
@ -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
53
cli/src/bin/usage-docs.rs
Normal 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
59
cli/src/errors.rs
Normal 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);
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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.")?;
|
||||
|
|
|
@ -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(())
|
||||
|
|
|
@ -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)?;
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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(())
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
||||
|
|
|
@ -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())
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
22
cli/src/invocation/util.rs
Normal file
22
cli/src/invocation/util.rs
Normal 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()))
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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() } };
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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::*;
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
136
cli/src/usage.rs
136
cli/src/usage.rs
|
@ -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(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
@ -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/
|
||||
```
|
||||
|
|
|
@ -7,3 +7,6 @@ title = "TaskChampion"
|
|||
|
||||
[output.html]
|
||||
default-theme = "ayu"
|
||||
|
||||
[preprocessor.usage-docs]
|
||||
command = "target/debug/usage-docs"
|
||||
|
|
|
@ -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)
|
|
@ -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)
|
||||
|
|
|
@ -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
9
docs/src/filters.md
Normal 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 -->
|
5
docs/src/modifications.md
Normal file
5
docs/src/modifications.md
Normal file
|
@ -0,0 +1,5 @@
|
|||
# Modifications
|
||||
|
||||
Modifications can have the following forms:
|
||||
|
||||
<!-- INSERT GENERATED DOCUMENTATION - modifications-->
|
|
@ -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 -->
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
30
docs/src/time.md
Normal 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.
|
|
@ -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 -->
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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),
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
10
taskchampion/src/task/annotation.rs
Normal file
10
taskchampion/src/task/annotation.rs
Normal 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,
|
||||
}
|
16
taskchampion/src/task/mod.rs
Normal file
16
taskchampion/src/task/mod.rs
Normal 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>;
|
48
taskchampion/src/task/priority.rs
Normal file
48
taskchampion/src/task/priority.rs
Normal 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);
|
||||
}
|
||||
}
|
54
taskchampion/src/task/status.rs
Normal file
54
taskchampion/src/task/status.rs
Normal 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);
|
||||
}
|
||||
}
|
169
taskchampion/src/task/tag.rs
Normal file
169
taskchampion/src/task/tag.rs
Normal 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)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue