Merge remote-tracking branch 'origin/main' into sqlstore
# Conflicts: # Cargo.lock # taskchampion/Cargo.toml
This commit is contained in:
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:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
@@ -12,6 +12,7 @@ on:
|
|||||||
jobs:
|
jobs:
|
||||||
audit:
|
audit:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
name: "Audit Dependencies"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v2
|
||||||
- uses: actions-rs/audit-check@v1
|
- uses: actions-rs/audit-check@v1
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: taskchampion
|
name: Checks
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -8,12 +8,50 @@ on:
|
|||||||
types: [opened, reopened, synchronize]
|
types: [opened, reopened, synchronize]
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
clippy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
name: "Clippy"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v1
|
- 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
|
- name: Cache cargo registry
|
||||||
uses: actions/cache@v1
|
uses: actions/cache@v1
|
||||||
with:
|
with:
|
||||||
@@ -31,49 +69,8 @@ jobs:
|
|||||||
toolchain: stable
|
toolchain: stable
|
||||||
override: true
|
override: true
|
||||||
|
|
||||||
- uses: actions-rs/cargo@v1.0.1
|
- name: Create usage-docs plugin
|
||||||
with:
|
run: cargo build -p taskchampion-cli --features usage-docs --bin usage-docs
|
||||||
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'
|
|
||||||
|
|
||||||
- run: mdbook test docs
|
- run: mdbook test docs
|
||||||
- run: mdbook build 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:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -15,7 +15,28 @@ jobs:
|
|||||||
- name: Setup mdBook
|
- name: Setup mdBook
|
||||||
uses: peaceiris/actions-mdbook@v1
|
uses: peaceiris/actions-mdbook@v1
|
||||||
with:
|
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
|
- 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
|
## CLI exit codes
|
||||||
|
|
||||||
- `0` No errors, normal exit.
|
- `0` - No errors, normal exit.
|
||||||
- `1` Generic error.
|
- `1` - Generic error.
|
||||||
- `2` Never used to avoid conflicts with Bash.
|
- `2` - Never used to avoid conflicts with Bash.
|
||||||
- `3` Unable to execute with the given parameters.
|
- `3` - Command-line Syntax Error.
|
||||||
- `4` I/O error.
|
|
||||||
- `5` Database error.
|
|
||||||
|
|
||||||
# Security
|
# 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.
|
See [SECURITY.md](./SECURITY.md).
|
||||||
|
|
||||||
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.
|
|
||||||
|
|||||||
@@ -33,3 +33,8 @@ There are three crates here:
|
|||||||
* [taskchampion-cli](./cli) - the command-line binary
|
* [taskchampion-cli](./cli) - the command-line binary
|
||||||
* [taskchampion-sync-server](./sync-server) - the server against which `task sync` operates
|
* [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 tag vX.Y.Z`
|
||||||
1. Run `git push upstream`
|
1. Run `git push upstream`
|
||||||
1. Run `git push --tags 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. 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. 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
|
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"
|
name = "taskchampion-cli"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
|
|
||||||
|
build = "build.rs"
|
||||||
|
|
||||||
|
# Run 'ta' when doing 'cargo run' at repo root
|
||||||
|
default-run = "ta"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
dirs-next = "^2.0.0"
|
dirs-next = "^2.0.0"
|
||||||
env_logger = "^0.8.3"
|
env_logger = "^0.8.3"
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
|
thiserror = "1.0"
|
||||||
log = "^0.4.14"
|
log = "^0.4.14"
|
||||||
nom = "^6.1.2"
|
nom = "^6.1.2"
|
||||||
prettytable-rs = "^0.8.0"
|
prettytable-rs = "^0.8.0"
|
||||||
@@ -16,11 +22,35 @@ termcolor = "^1.1.2"
|
|||||||
atty = "^0.2.14"
|
atty = "^0.2.14"
|
||||||
toml = "^0.5.8"
|
toml = "^0.5.8"
|
||||||
toml_edit = "^0.2.0"
|
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]
|
[dependencies.taskchampion]
|
||||||
path = "../taskchampion"
|
path = "../taskchampion"
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
built = { version = "0.5", features = ["git2"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
assert_cmd = "^1.0.3"
|
assert_cmd = "^1.0.3"
|
||||||
predicates = "^1.0.7"
|
predicates = "^1.0.7"
|
||||||
tempfile = "3"
|
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::args::*;
|
||||||
use super::{ArgList, Subcommand};
|
use super::{ArgList, Subcommand};
|
||||||
use anyhow::bail;
|
|
||||||
use nom::{combinator::*, sequence::*, Err, IResult};
|
use nom::{combinator::*, sequence::*, Err, IResult};
|
||||||
|
|
||||||
/// A command is the overall command that the CLI should execute.
|
/// A command is the overall command that the CLI should execute.
|
||||||
@@ -16,8 +15,15 @@ pub(crate) struct Command {
|
|||||||
impl Command {
|
impl Command {
|
||||||
pub(super) fn parse(input: ArgList) -> IResult<ArgList, Command> {
|
pub(super) fn parse(input: ArgList) -> IResult<ArgList, Command> {
|
||||||
fn to_command(input: (&str, Subcommand)) -> Result<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 {
|
let command = Command {
|
||||||
command_name: input.0.to_owned(),
|
command_name,
|
||||||
subcommand: input.1,
|
subcommand: input.1,
|
||||||
};
|
};
|
||||||
Ok(command)
|
Ok(command)
|
||||||
@@ -29,13 +35,22 @@ impl Command {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse a command from the given list of strings.
|
/// 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) {
|
match Command::parse(argv) {
|
||||||
Ok((&[], cmd)) => Ok(cmd),
|
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::Incomplete(_)) => unreachable!(),
|
||||||
Err(Err::Error(e)) => bail!("command line not recognized: {:?}", e),
|
Err(Err::Error(e)) => Err(crate::Error::for_arguments(format!(
|
||||||
Err(Err::Failure(e)) => bail!("command line not recognized: {:?}", e),
|
"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::args::{any, arg_matching, literal};
|
||||||
use super::ArgList;
|
use super::ArgList;
|
||||||
use crate::usage;
|
use crate::usage;
|
||||||
use nom::{combinator::*, sequence::*, IResult};
|
use nom::{branch::alt, combinator::*, sequence::*, IResult};
|
||||||
|
|
||||||
#[derive(Debug, PartialEq)]
|
#[derive(Debug, PartialEq)]
|
||||||
/// A config operation
|
/// A config operation
|
||||||
pub(crate) enum ConfigOperation {
|
pub(crate) enum ConfigOperation {
|
||||||
/// Set a configuration value
|
/// Set a configuration value
|
||||||
Set(String, String),
|
Set(String, String),
|
||||||
|
/// Show configuration path
|
||||||
|
Path,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ConfigOperation {
|
impl ConfigOperation {
|
||||||
@@ -15,14 +17,20 @@ impl ConfigOperation {
|
|||||||
fn set_to_op(input: (&str, &str, &str)) -> Result<ConfigOperation, ()> {
|
fn set_to_op(input: (&str, &str, &str)) -> Result<ConfigOperation, ()> {
|
||||||
Ok(ConfigOperation::Set(input.1.to_owned(), input.2.to_owned()))
|
Ok(ConfigOperation::Set(input.1.to_owned(), input.2.to_owned()))
|
||||||
}
|
}
|
||||||
map_res(
|
fn path_to_op(_: &str) -> Result<ConfigOperation, ()> {
|
||||||
tuple((
|
Ok(ConfigOperation::Path)
|
||||||
arg_matching(literal("set")),
|
}
|
||||||
arg_matching(any),
|
alt((
|
||||||
arg_matching(any),
|
map_res(
|
||||||
)),
|
tuple((
|
||||||
set_to_op,
|
arg_matching(literal("set")),
|
||||||
)(input)
|
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) {
|
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 super::ArgList;
|
||||||
use crate::usage;
|
use crate::usage;
|
||||||
use anyhow::bail;
|
use anyhow::bail;
|
||||||
use nom::{branch::alt, combinator::*, multi::fold_many0, IResult};
|
use nom::{
|
||||||
use taskchampion::Status;
|
branch::alt,
|
||||||
|
combinator::*,
|
||||||
|
multi::{fold_many0, fold_many1},
|
||||||
|
IResult,
|
||||||
|
};
|
||||||
|
use taskchampion::{Status, Tag};
|
||||||
|
|
||||||
/// A filter represents a selection of a particular set of tasks.
|
/// A filter represents a selection of a particular set of tasks.
|
||||||
///
|
///
|
||||||
@@ -21,10 +26,10 @@ pub(crate) struct Filter {
|
|||||||
#[derive(Debug, PartialEq, Clone)]
|
#[derive(Debug, PartialEq, Clone)]
|
||||||
pub(crate) enum Condition {
|
pub(crate) enum Condition {
|
||||||
/// Task has the given tag
|
/// Task has the given tag
|
||||||
HasTag(String),
|
HasTag(Tag),
|
||||||
|
|
||||||
/// Task does not have the given tag
|
/// Task does not have the given tag
|
||||||
NoTag(String),
|
NoTag(Tag),
|
||||||
|
|
||||||
/// Task has the given status
|
/// Task has the given status
|
||||||
Status(Status),
|
Status(Status),
|
||||||
@@ -63,15 +68,15 @@ impl Condition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn parse_plus_tag(input: ArgList) -> IResult<ArgList, Condition> {
|
fn parse_plus_tag(input: ArgList) -> IResult<ArgList, Condition> {
|
||||||
fn to_condition(input: &str) -> Result<Condition, ()> {
|
fn to_condition(input: Tag) -> Result<Condition, ()> {
|
||||||
Ok(Condition::HasTag(input.to_owned()))
|
Ok(Condition::HasTag(input))
|
||||||
}
|
}
|
||||||
map_res(arg_matching(plus_tag), to_condition)(input)
|
map_res(arg_matching(plus_tag), to_condition)(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_minus_tag(input: ArgList) -> IResult<ArgList, Condition> {
|
fn parse_minus_tag(input: ArgList) -> IResult<ArgList, Condition> {
|
||||||
fn to_condition(input: &str) -> Result<Condition, ()> {
|
fn to_condition(input: Tag) -> Result<Condition, ()> {
|
||||||
Ok(Condition::NoTag(input.to_owned()))
|
Ok(Condition::NoTag(input))
|
||||||
}
|
}
|
||||||
map_res(arg_matching(minus_tag), to_condition)(input)
|
map_res(arg_matching(minus_tag), to_condition)(input)
|
||||||
}
|
}
|
||||||
@@ -85,7 +90,9 @@ impl Condition {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Filter {
|
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(
|
fold_many0(
|
||||||
Condition::parse,
|
Condition::parse,
|
||||||
Filter {
|
Filter {
|
||||||
@@ -95,6 +102,30 @@ impl Filter {
|
|||||||
)(input)
|
)(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
|
/// fold multiple filter args into a single Filter instance
|
||||||
fn with_arg(mut self, cond: Condition) -> Filter {
|
fn with_arg(mut self, cond: Condition) -> Filter {
|
||||||
if let Condition::IdList(mut id_list) = cond {
|
if let Condition::IdList(mut id_list) = cond {
|
||||||
@@ -157,6 +188,13 @@ impl Filter {
|
|||||||
description: "
|
description: "
|
||||||
Select tasks with the given status.",
|
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::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_empty() {
|
fn test_empty_parse0() {
|
||||||
let (input, filter) = Filter::parse(argv![]).unwrap();
|
let (input, filter) = Filter::parse0(argv![]).unwrap();
|
||||||
assert_eq!(input.len(), 0);
|
assert_eq!(input.len(), 0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
filter,
|
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]
|
#[test]
|
||||||
fn test_id_list_single() {
|
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!(input.len(), 0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
filter,
|
filter,
|
||||||
@@ -190,7 +265,7 @@ mod test {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_id_list_commas() {
|
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!(input.len(), 0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
filter,
|
filter,
|
||||||
@@ -206,7 +281,7 @@ mod test {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_id_list_multi_arg() {
|
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!(input.len(), 0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
filter,
|
filter,
|
||||||
@@ -223,7 +298,7 @@ mod test {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_id_list_uuids() {
|
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!(input.len(), 0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
filter,
|
filter,
|
||||||
@@ -238,15 +313,15 @@ mod test {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_tags() {
|
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!(input.len(), 0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
filter,
|
filter,
|
||||||
Filter {
|
Filter {
|
||||||
conditions: vec![
|
conditions: vec![
|
||||||
Condition::IdList(vec![TaskId::WorkingSetId(1),]),
|
Condition::IdList(vec![TaskId::WorkingSetId(1),]),
|
||||||
Condition::HasTag("yes".into()),
|
Condition::HasTag(tag!("yes")),
|
||||||
Condition::NoTag("no".into()),
|
Condition::NoTag(tag!("no")),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -254,7 +329,7 @@ mod test {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_status() {
|
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!(input.len(), 0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
filter,
|
filter,
|
||||||
@@ -269,8 +344,8 @@ mod test {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn intersect_idlist_idlist() {
|
fn intersect_idlist_idlist() {
|
||||||
let left = Filter::parse(argv!["1,2", "+yes"]).unwrap().1;
|
let left = Filter::parse0(argv!["1,2", "+yes"]).unwrap().1;
|
||||||
let right = Filter::parse(argv!["2,3", "+no"]).unwrap().1;
|
let right = Filter::parse0(argv!["2,3", "+no"]).unwrap().1;
|
||||||
let both = left.intersect(right);
|
let both = left.intersect(right);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
both,
|
both,
|
||||||
@@ -278,10 +353,10 @@ mod test {
|
|||||||
conditions: vec![
|
conditions: vec![
|
||||||
// from first filter
|
// from first filter
|
||||||
Condition::IdList(vec![TaskId::WorkingSetId(1), TaskId::WorkingSetId(2),]),
|
Condition::IdList(vec![TaskId::WorkingSetId(1), TaskId::WorkingSetId(2),]),
|
||||||
Condition::HasTag("yes".into()),
|
Condition::HasTag(tag!("yes")),
|
||||||
// from second filter
|
// from second filter
|
||||||
Condition::IdList(vec![TaskId::WorkingSetId(2), TaskId::WorkingSetId(3)]),
|
Condition::IdList(vec![TaskId::WorkingSetId(2), TaskId::WorkingSetId(3)]),
|
||||||
Condition::HasTag("no".into()),
|
Condition::HasTag(tag!("no")),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -289,8 +364,8 @@ mod test {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn intersect_idlist_alltasks() {
|
fn intersect_idlist_alltasks() {
|
||||||
let left = Filter::parse(argv!["1,2", "+yes"]).unwrap().1;
|
let left = Filter::parse0(argv!["1,2", "+yes"]).unwrap().1;
|
||||||
let right = Filter::parse(argv!["+no"]).unwrap().1;
|
let right = Filter::parse0(argv!["+no"]).unwrap().1;
|
||||||
let both = left.intersect(right);
|
let both = left.intersect(right);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
both,
|
both,
|
||||||
@@ -298,9 +373,9 @@ mod test {
|
|||||||
conditions: vec![
|
conditions: vec![
|
||||||
// from first filter
|
// from first filter
|
||||||
Condition::IdList(vec![TaskId::WorkingSetId(1), TaskId::WorkingSetId(2),]),
|
Condition::IdList(vec![TaskId::WorkingSetId(1), TaskId::WorkingSetId(2),]),
|
||||||
Condition::HasTag("yes".into()),
|
Condition::HasTag(tag!("yes")),
|
||||||
// from second filter
|
// from second filter
|
||||||
Condition::HasTag("no".into()),
|
Condition::HasTag(tag!("no")),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -308,15 +383,15 @@ mod test {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn intersect_alltasks_alltasks() {
|
fn intersect_alltasks_alltasks() {
|
||||||
let left = Filter::parse(argv!["+yes"]).unwrap().1;
|
let left = Filter::parse0(argv!["+yes"]).unwrap().1;
|
||||||
let right = Filter::parse(argv!["+no"]).unwrap().1;
|
let right = Filter::parse0(argv!["+no"]).unwrap().1;
|
||||||
let both = left.intersect(right);
|
let both = left.intersect(right);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
both,
|
both,
|
||||||
Filter {
|
Filter {
|
||||||
conditions: vec![
|
conditions: vec![
|
||||||
Condition::HasTag("yes".into()),
|
Condition::HasTag(tag!("yes")),
|
||||||
Condition::HasTag("no".into()),
|
Condition::HasTag(tag!("no")),
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ pub(crate) use modification::{DescriptionMod, Modification};
|
|||||||
pub(crate) use subcommand::Subcommand;
|
pub(crate) use subcommand::Subcommand;
|
||||||
|
|
||||||
use crate::usage::Usage;
|
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];
|
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 super::ArgList;
|
||||||
use crate::usage;
|
use crate::usage;
|
||||||
|
use chrono::prelude::*;
|
||||||
use nom::{branch::alt, combinator::*, multi::fold_many0, IResult};
|
use nom::{branch::alt, combinator::*, multi::fold_many0, IResult};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use taskchampion::Status;
|
use taskchampion::{Status, Tag};
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, Clone)]
|
#[derive(Debug, PartialEq, Clone)]
|
||||||
pub enum DescriptionMod {
|
pub enum DescriptionMod {
|
||||||
@@ -36,21 +37,25 @@ pub struct Modification {
|
|||||||
/// Set the status
|
/// Set the status
|
||||||
pub status: Option<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.
|
/// Set the "active" state, that is, start (true) or stop (false) the task.
|
||||||
pub active: Option<bool>,
|
pub active: Option<bool>,
|
||||||
|
|
||||||
/// Add tags
|
/// Add tags
|
||||||
pub add_tags: HashSet<String>,
|
pub add_tags: HashSet<Tag>,
|
||||||
|
|
||||||
/// Remove tags
|
/// 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
|
/// A single argument that is part of a modification, used internally to this module
|
||||||
enum ModArg<'a> {
|
enum ModArg<'a> {
|
||||||
Description(&'a str),
|
Description(&'a str),
|
||||||
PlusTag(&'a str),
|
PlusTag(Tag),
|
||||||
MinusTag(&'a str),
|
MinusTag(Tag),
|
||||||
|
Wait(Option<DateTime<Utc>>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Modification {
|
impl Modification {
|
||||||
@@ -66,10 +71,13 @@ impl Modification {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ModArg::PlusTag(tag) => {
|
ModArg::PlusTag(tag) => {
|
||||||
acc.add_tags.insert(tag.to_owned());
|
acc.add_tags.insert(tag);
|
||||||
}
|
}
|
||||||
ModArg::MinusTag(tag) => {
|
ModArg::MinusTag(tag) => {
|
||||||
acc.remove_tags.insert(tag.to_owned());
|
acc.remove_tags.insert(tag);
|
||||||
|
}
|
||||||
|
ModArg::Wait(wait) => {
|
||||||
|
acc.wait = Some(wait);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
acc
|
acc
|
||||||
@@ -78,6 +86,7 @@ impl Modification {
|
|||||||
alt((
|
alt((
|
||||||
Self::plus_tag,
|
Self::plus_tag,
|
||||||
Self::minus_tag,
|
Self::minus_tag,
|
||||||
|
Self::wait,
|
||||||
// this must come last
|
// this must come last
|
||||||
Self::description,
|
Self::description,
|
||||||
)),
|
)),
|
||||||
@@ -96,38 +105,58 @@ impl Modification {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn plus_tag(input: ArgList) -> IResult<ArgList, ModArg> {
|
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))
|
Ok(ModArg::PlusTag(input))
|
||||||
}
|
}
|
||||||
map_res(arg_matching(plus_tag), to_modarg)(input)
|
map_res(arg_matching(plus_tag), to_modarg)(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn minus_tag(input: ArgList) -> IResult<ArgList, ModArg> {
|
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))
|
Ok(ModArg::MinusTag(input))
|
||||||
}
|
}
|
||||||
map_res(arg_matching(minus_tag), to_modarg)(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) {
|
pub(super) fn get_usage(u: &mut usage::Usage) {
|
||||||
u.modifications.push(usage::Modification {
|
u.modifications.push(usage::Modification {
|
||||||
syntax: "DESCRIPTION",
|
syntax: "DESCRIPTION",
|
||||||
summary: "Set description",
|
summary: "Set description",
|
||||||
description: "
|
description: "
|
||||||
Set the task description. Multiple arguments are combined into a single
|
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 {
|
u.modifications.push(usage::Modification {
|
||||||
syntax: "+TAG",
|
syntax: "+TAG",
|
||||||
summary: "Tag task",
|
summary: "Tag task",
|
||||||
description: "
|
description: "Add the given tag to the task.",
|
||||||
Add the given tag to the task.",
|
|
||||||
});
|
});
|
||||||
u.modifications.push(usage::Modification {
|
u.modifications.push(usage::Modification {
|
||||||
syntax: "-TAG",
|
syntax: "-TAG",
|
||||||
summary: "Un-tag task",
|
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: "
|
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)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::argparse::NOW;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_empty() {
|
fn test_empty() {
|
||||||
@@ -168,7 +198,33 @@ mod test {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
modification,
|
modification,
|
||||||
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()
|
..Default::default()
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -196,8 +252,8 @@ mod test {
|
|||||||
modification,
|
modification,
|
||||||
Modification {
|
Modification {
|
||||||
description: DescriptionMod::Set(s!("new desc fun")),
|
description: DescriptionMod::Set(s!("new desc fun")),
|
||||||
add_tags: set![s!("next")],
|
add_tags: set![tag!("next")],
|
||||||
remove_tags: set![s!("daytime")],
|
remove_tags: set![tag!("daytime")],
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -217,7 +217,7 @@ impl Modify {
|
|||||||
}
|
}
|
||||||
map_res(
|
map_res(
|
||||||
tuple((
|
tuple((
|
||||||
Filter::parse,
|
Filter::parse1,
|
||||||
alt((
|
alt((
|
||||||
arg_matching(literal("modify")),
|
arg_matching(literal("modify")),
|
||||||
arg_matching(literal("prepend")),
|
arg_matching(literal("prepend")),
|
||||||
@@ -235,47 +235,47 @@ impl Modify {
|
|||||||
fn get_usage(u: &mut usage::Usage) {
|
fn get_usage(u: &mut usage::Usage) {
|
||||||
u.subcommands.push(usage::Subcommand {
|
u.subcommands.push(usage::Subcommand {
|
||||||
name: "modify",
|
name: "modify",
|
||||||
syntax: "[filter] modify [modification]",
|
syntax: "<filter> modify [modification]",
|
||||||
summary: "Modify tasks",
|
summary: "Modify tasks",
|
||||||
description: "
|
description: "
|
||||||
Modify all tasks matching the filter.",
|
Modify all tasks matching the required filter.",
|
||||||
});
|
});
|
||||||
u.subcommands.push(usage::Subcommand {
|
u.subcommands.push(usage::Subcommand {
|
||||||
name: "prepend",
|
name: "prepend",
|
||||||
syntax: "[filter] prepend [modification]",
|
syntax: "<filter> prepend [modification]",
|
||||||
summary: "Prepend task description",
|
summary: "Prepend task description",
|
||||||
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.",
|
task's description.",
|
||||||
});
|
});
|
||||||
u.subcommands.push(usage::Subcommand {
|
u.subcommands.push(usage::Subcommand {
|
||||||
name: "append",
|
name: "append",
|
||||||
syntax: "[filter] append [modification]",
|
syntax: "<filter> append [modification]",
|
||||||
summary: "Append task description",
|
summary: "Append task description",
|
||||||
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.",
|
of each task's description.",
|
||||||
});
|
});
|
||||||
u.subcommands.push(usage::Subcommand {
|
u.subcommands.push(usage::Subcommand {
|
||||||
name: "start",
|
name: "start",
|
||||||
syntax: "[filter] start [modification]",
|
syntax: "<filter> start [modification]",
|
||||||
summary: "Start tasks",
|
summary: "Start tasks",
|
||||||
description: "
|
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 {
|
u.subcommands.push(usage::Subcommand {
|
||||||
name: "stop",
|
name: "stop",
|
||||||
syntax: "[filter] stop [modification]",
|
syntax: "<filter> stop [modification]",
|
||||||
summary: "Stop tasks",
|
summary: "Stop tasks",
|
||||||
description: "
|
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 {
|
u.subcommands.push(usage::Subcommand {
|
||||||
name: "done",
|
name: "done",
|
||||||
syntax: "[filter] done [modification]",
|
syntax: "<filter> done [modification]",
|
||||||
summary: "Mark tasks as completed",
|
summary: "Mark tasks as completed",
|
||||||
description: "
|
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.",
|
modifications.",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -293,14 +293,14 @@ impl Report {
|
|||||||
}
|
}
|
||||||
// allow the filter expression before or after the report name
|
// allow the filter expression before or after the report name
|
||||||
alt((
|
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)
|
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)
|
to_subcommand(input.0, input.1)
|
||||||
}),
|
}),
|
||||||
// default to a "next" report
|
// default to a "next" report
|
||||||
map_res(Filter::parse, |input| to_subcommand(input, "next")),
|
map_res(Filter::parse0, |input| to_subcommand(input, "next")),
|
||||||
))(input)
|
))(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -335,7 +335,7 @@ impl Info {
|
|||||||
}
|
}
|
||||||
map_res(
|
map_res(
|
||||||
pair(
|
pair(
|
||||||
Filter::parse,
|
Filter::parse1,
|
||||||
alt((
|
alt((
|
||||||
arg_matching(literal("info")),
|
arg_matching(literal("info")),
|
||||||
arg_matching(literal("debug")),
|
arg_matching(literal("debug")),
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
use std::process::exit;
|
use std::process::exit;
|
||||||
|
|
||||||
pub fn main() {
|
pub fn main() {
|
||||||
if let Err(err) = taskchampion_cli::main() {
|
match taskchampion_cli::main() {
|
||||||
eprintln!("{:?}", err);
|
Ok(_) => exit(0),
|
||||||
exit(1);
|
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,
|
w: &mut W,
|
||||||
replica: &mut Replica,
|
replica: &mut Replica,
|
||||||
modification: Modification,
|
modification: Modification,
|
||||||
) -> anyhow::Result<()> {
|
) -> Result<(), crate::Error> {
|
||||||
let description = match modification.description {
|
let description = match modification.description {
|
||||||
DescriptionMod::Set(ref s) => s.clone(),
|
DescriptionMod::Set(ref s) => s.clone(),
|
||||||
_ => "(no description)".to_owned(),
|
_ => "(no description)".to_owned(),
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ pub(crate) fn execute<W: WriteColor>(
|
|||||||
w: &mut W,
|
w: &mut W,
|
||||||
config_operation: ConfigOperation,
|
config_operation: ConfigOperation,
|
||||||
settings: &Settings,
|
settings: &Settings,
|
||||||
) -> anyhow::Result<()> {
|
) -> Result<(), crate::Error> {
|
||||||
match config_operation {
|
match config_operation {
|
||||||
ConfigOperation::Set(key, value) => {
|
ConfigOperation::Set(key, value) => {
|
||||||
let filename = settings.set(&key, &value)?;
|
let filename = settings.set(&key, &value)?;
|
||||||
@@ -19,6 +19,13 @@ pub(crate) fn execute<W: WriteColor>(
|
|||||||
writeln!(w, "{:?}.", filename)?;
|
writeln!(w, "{:?}.", filename)?;
|
||||||
w.set_color(ColorSpec::new().set_bold(false))?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use taskchampion::Replica;
|
use taskchampion::Replica;
|
||||||
use termcolor::WriteColor;
|
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");
|
log::debug!("rebuilding working set");
|
||||||
replica.rebuild_working_set(true)?;
|
replica.rebuild_working_set(true)?;
|
||||||
writeln!(w, "garbage collected.")?;
|
writeln!(w, "garbage collected.")?;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ pub(crate) fn execute<W: WriteColor>(
|
|||||||
w: &mut W,
|
w: &mut W,
|
||||||
command_name: String,
|
command_name: String,
|
||||||
summary: bool,
|
summary: bool,
|
||||||
) -> anyhow::Result<()> {
|
) -> Result<(), crate::Error> {
|
||||||
let usage = Usage::new();
|
let usage = Usage::new();
|
||||||
usage.write_help(w, command_name.as_ref(), summary)?;
|
usage.write_help(w, command_name.as_ref(), summary)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ pub(crate) fn execute<W: WriteColor>(
|
|||||||
replica: &mut Replica,
|
replica: &mut Replica,
|
||||||
filter: Filter,
|
filter: Filter,
|
||||||
debug: bool,
|
debug: bool,
|
||||||
) -> anyhow::Result<()> {
|
) -> Result<(), crate::Error> {
|
||||||
let working_set = replica.working_set()?;
|
let working_set = replica.working_set()?;
|
||||||
|
|
||||||
for task in filtered_tasks(replica, &filter)? {
|
for task in filtered_tasks(replica, &filter)? {
|
||||||
@@ -36,6 +36,9 @@ pub(crate) fn execute<W: WriteColor>(
|
|||||||
tags.sort();
|
tags.sort();
|
||||||
t.add_row(row![b->"Tags", tags.join(" ")]);
|
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)?;
|
t.print(w)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,65 @@
|
|||||||
use crate::argparse::{Filter, Modification};
|
use crate::argparse::{Filter, Modification};
|
||||||
|
use crate::invocation::util::{confirm, summarize_task};
|
||||||
use crate::invocation::{apply_modification, filtered_tasks};
|
use crate::invocation::{apply_modification, filtered_tasks};
|
||||||
|
use crate::settings::Settings;
|
||||||
use taskchampion::Replica;
|
use taskchampion::Replica;
|
||||||
use termcolor::WriteColor;
|
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>(
|
pub(crate) fn execute<W: WriteColor>(
|
||||||
w: &mut W,
|
w: &mut W,
|
||||||
replica: &mut Replica,
|
replica: &mut Replica,
|
||||||
|
settings: &Settings,
|
||||||
filter: Filter,
|
filter: Filter,
|
||||||
modification: Modification,
|
modification: Modification,
|
||||||
) -> anyhow::Result<()> {
|
) -> Result<(), crate::Error> {
|
||||||
for task in filtered_tasks(replica, &filter)? {
|
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);
|
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(())
|
Ok(())
|
||||||
@@ -30,6 +77,7 @@ mod test {
|
|||||||
fn test_modify() {
|
fn test_modify() {
|
||||||
let mut w = test_writer();
|
let mut w = test_writer();
|
||||||
let mut replica = test_replica();
|
let mut replica = test_replica();
|
||||||
|
let settings = Settings::default();
|
||||||
|
|
||||||
let task = replica
|
let task = replica
|
||||||
.new_task(Status::Pending, s!("old description"))
|
.new_task(Status::Pending, s!("old description"))
|
||||||
@@ -42,7 +90,7 @@ mod test {
|
|||||||
description: DescriptionMod::Set(s!("new description")),
|
description: DescriptionMod::Set(s!("new description")),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
execute(&mut w, &mut replica, filter, modification).unwrap();
|
execute(&mut w, &mut replica, &settings, filter, modification).unwrap();
|
||||||
|
|
||||||
// check that the task appeared..
|
// check that the task appeared..
|
||||||
let task = replica.get_task(task.get_uuid()).unwrap().unwrap();
|
let task = replica.get_task(task.get_uuid()).unwrap().unwrap();
|
||||||
@@ -51,7 +99,7 @@ mod test {
|
|||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
w.into_string(),
|
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,
|
settings: &Settings,
|
||||||
report_name: String,
|
report_name: String,
|
||||||
filter: Filter,
|
filter: Filter,
|
||||||
) -> anyhow::Result<()> {
|
) -> Result<(), crate::Error> {
|
||||||
display_report(w, replica, settings, report_name, filter)
|
display_report(w, replica, settings, report_name, filter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ pub(crate) fn execute<W: WriteColor>(
|
|||||||
w: &mut W,
|
w: &mut W,
|
||||||
replica: &mut Replica,
|
replica: &mut Replica,
|
||||||
server: &mut Box<dyn Server>,
|
server: &mut Box<dyn Server>,
|
||||||
) -> anyhow::Result<()> {
|
) -> Result<(), crate::Error> {
|
||||||
replica.sync(server)?;
|
replica.sync(server)?;
|
||||||
writeln!(w, "sync complete.")?;
|
writeln!(w, "sync complete.")?;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -1,10 +1,20 @@
|
|||||||
|
use crate::built_info;
|
||||||
use termcolor::{ColorSpec, WriteColor};
|
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 ")?;
|
write!(w, "TaskChampion ")?;
|
||||||
w.set_color(ColorSpec::new().set_bold(true))?;
|
w.set_color(ColorSpec::new().set_bold(true))?;
|
||||||
writeln!(w, "{}", env!("CARGO_PKG_VERSION"))?;
|
write!(w, "{}", built_info::PKG_VERSION)?;
|
||||||
w.reset()?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
use crate::argparse::{Condition, Filter, TaskId};
|
use crate::argparse::{Condition, Filter, TaskId};
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::convert::TryInto;
|
use taskchampion::{Replica, Status, Task, Uuid, WorkingSet};
|
||||||
use taskchampion::{Replica, Status, Tag, Task, Uuid, WorkingSet};
|
|
||||||
|
|
||||||
fn match_task(filter: &Filter, task: &Task, uuid: Uuid, working_set: &WorkingSet) -> bool {
|
fn match_task(filter: &Filter, task: &Task, uuid: Uuid, working_set: &WorkingSet) -> bool {
|
||||||
for cond in &filter.conditions {
|
for cond in &filter.conditions {
|
||||||
match cond {
|
match cond {
|
||||||
Condition::HasTag(ref tag) => {
|
Condition::HasTag(ref tag) => {
|
||||||
// see #111 for the unwrap
|
if !task.has_tag(tag) {
|
||||||
let tag: Tag = tag.try_into().unwrap();
|
|
||||||
if !task.has_tag(&tag) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Condition::NoTag(ref tag) => {
|
Condition::NoTag(ref tag) => {
|
||||||
// see #111 for the unwrap
|
if task.has_tag(tag) {
|
||||||
let tag: Tag = tag.try_into().unwrap();
|
|
||||||
if task.has_tag(&tag) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -254,8 +249,8 @@ mod test {
|
|||||||
#[test]
|
#[test]
|
||||||
fn tag_filtering() -> anyhow::Result<()> {
|
fn tag_filtering() -> anyhow::Result<()> {
|
||||||
let mut replica = test_replica();
|
let mut replica = test_replica();
|
||||||
let yes: Tag = "yes".try_into()?;
|
let yes = tag!("yes");
|
||||||
let no: Tag = "no".try_into()?;
|
let no = tag!("no");
|
||||||
|
|
||||||
let mut t1 = replica
|
let mut t1 = replica
|
||||||
.new_task(Status::Pending, s!("A"))?
|
.new_task(Status::Pending, s!("A"))?
|
||||||
@@ -274,7 +269,7 @@ mod test {
|
|||||||
|
|
||||||
// look for just "yes" (A and B)
|
// look for just "yes" (A and B)
|
||||||
let filter = Filter {
|
let filter = Filter {
|
||||||
conditions: vec![Condition::HasTag(s!("yes"))],
|
conditions: vec![Condition::HasTag(tag!("yes"))],
|
||||||
};
|
};
|
||||||
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)?
|
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)?
|
||||||
.map(|t| t.get_description().to_owned())
|
.map(|t| t.get_description().to_owned())
|
||||||
@@ -284,7 +279,7 @@ mod test {
|
|||||||
|
|
||||||
// look for tags without "no" (A, D)
|
// look for tags without "no" (A, D)
|
||||||
let filter = Filter {
|
let filter = Filter {
|
||||||
conditions: vec![Condition::NoTag(s!("no"))],
|
conditions: vec![Condition::NoTag(tag!("no"))],
|
||||||
};
|
};
|
||||||
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)?
|
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)?
|
||||||
.map(|t| t.get_description().to_owned())
|
.map(|t| t.get_description().to_owned())
|
||||||
@@ -294,7 +289,10 @@ mod test {
|
|||||||
|
|
||||||
// look for tags with "yes" and "no" (B)
|
// look for tags with "yes" and "no" (B)
|
||||||
let filter = Filter {
|
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)?
|
let filtered: Vec<_> = filtered_tasks(&mut replica, &filter)?
|
||||||
.map(|t| t.get_description().to_owned())
|
.map(|t| t.get_description().to_owned())
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ mod cmd;
|
|||||||
mod filter;
|
mod filter;
|
||||||
mod modify;
|
mod modify;
|
||||||
mod report;
|
mod report;
|
||||||
|
mod util;
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod test;
|
mod test;
|
||||||
@@ -19,7 +20,7 @@ use report::display_report;
|
|||||||
|
|
||||||
/// Invoke the given Command in the context of the given settings
|
/// Invoke the given Command in the context of the given settings
|
||||||
#[allow(clippy::needless_return)]
|
#[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!("command: {:?}", command);
|
||||||
log::debug!("settings: {:?}", settings);
|
log::debug!("settings: {:?}", settings);
|
||||||
|
|
||||||
@@ -60,7 +61,7 @@ pub(crate) fn invoke(command: Command, settings: Settings) -> anyhow::Result<()>
|
|||||||
modification,
|
modification,
|
||||||
},
|
},
|
||||||
..
|
..
|
||||||
} => return cmd::modify::execute(&mut w, &mut replica, filter, modification),
|
} => return cmd::modify::execute(&mut w, &mut replica, &settings, filter, modification),
|
||||||
|
|
||||||
Command {
|
Command {
|
||||||
subcommand:
|
subcommand:
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
use crate::argparse::{DescriptionMod, Modification};
|
use crate::argparse::{DescriptionMod, Modification};
|
||||||
use std::convert::TryInto;
|
|
||||||
use taskchampion::TaskMut;
|
use taskchampion::TaskMut;
|
||||||
use termcolor::WriteColor;
|
|
||||||
|
|
||||||
/// Apply the given modification
|
/// Apply the given modification
|
||||||
pub(super) fn apply_modification<W: WriteColor>(
|
pub(super) fn apply_modification(
|
||||||
w: &mut W,
|
|
||||||
task: &mut TaskMut,
|
task: &mut TaskMut,
|
||||||
modification: &Modification,
|
modification: &Modification,
|
||||||
) -> anyhow::Result<()> {
|
) -> anyhow::Result<()> {
|
||||||
@@ -33,16 +30,16 @@ pub(super) fn apply_modification<W: WriteColor>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for tag in modification.add_tags.iter() {
|
for tag in modification.add_tags.iter() {
|
||||||
let tag = tag.try_into()?; // see #111
|
|
||||||
task.add_tag(&tag)?;
|
task.add_tag(&tag)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
for tag in modification.remove_tags.iter() {
|
for tag in modification.remove_tags.iter() {
|
||||||
let tag = tag.try_into()?; // see #111
|
|
||||||
task.remove_tag(&tag)?;
|
task.remove_tag(&tag)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
writeln!(w, "modified task {}", task.get_uuid())?;
|
if let Some(wait) = modification.wait {
|
||||||
|
task.set_wait(wait)?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
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::Uuid => a.get_uuid().cmp(&b.get_uuid()),
|
||||||
SortBy::Description => a.get_description().cmp(b.get_description()),
|
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 this sort property is equal, go on to the next..
|
||||||
if ord == Ordering::Equal {
|
if ord == Ordering::Equal {
|
||||||
@@ -71,6 +72,13 @@ fn task_column(task: &Task, column: &Column, working_set: &WorkingSet) -> String
|
|||||||
tags.sort();
|
tags.sort();
|
||||||
tags.join(" ")
|
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,
|
settings: &Settings,
|
||||||
report_name: String,
|
report_name: String,
|
||||||
filter: Filter,
|
filter: Filter,
|
||||||
) -> anyhow::Result<()> {
|
) -> Result<(), crate::Error> {
|
||||||
let mut t = Table::new();
|
let mut t = Table::new();
|
||||||
let working_set = replica.working_set()?;
|
let working_set = replica.working_set()?;
|
||||||
|
|
||||||
@@ -124,6 +132,7 @@ mod test {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::invocation::test::*;
|
use crate::invocation::test::*;
|
||||||
use crate::settings::Sort;
|
use crate::settings::Sort;
|
||||||
|
use chrono::prelude::*;
|
||||||
use std::convert::TryInto;
|
use std::convert::TryInto;
|
||||||
use taskchampion::{Status, Uuid};
|
use taskchampion::{Status, Uuid};
|
||||||
|
|
||||||
@@ -217,6 +226,50 @@ mod test {
|
|||||||
assert_eq!(got_uuids, exp_uuids);
|
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]
|
#[test]
|
||||||
fn sorting_by_multiple() {
|
fn sorting_by_multiple() {
|
||||||
let mut replica = test_replica();
|
let mut replica = test_replica();
|
||||||
@@ -350,8 +403,11 @@ mod test {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let task = replica.get_task(uuids[0]).unwrap().unwrap();
|
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();
|
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 macros;
|
||||||
|
|
||||||
mod argparse;
|
mod argparse;
|
||||||
|
mod errors;
|
||||||
mod invocation;
|
mod invocation;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod table;
|
mod table;
|
||||||
mod usage;
|
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;
|
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
|
/// 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.
|
/// 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();
|
env_logger::init();
|
||||||
|
|
||||||
// parse the command line into a vector of &str, failing if
|
// parse the command line into a vector of &str, failing if
|
||||||
// there are invalid utf-8 sequences.
|
// there are invalid utf-8 sequences.
|
||||||
let argv: Vec<String> = std::env::args_os()
|
let argv: Vec<String> = std::env::args_os()
|
||||||
.map(|oss| String::from_utf8(oss.into_vec()))
|
.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();
|
let argv: Vec<&str> = argv.iter().map(|s| s.as_ref()).collect();
|
||||||
|
|
||||||
// parse the command line
|
// parse the command line
|
||||||
|
|||||||
@@ -30,3 +30,9 @@ macro_rules! set(
|
|||||||
macro_rules! s(
|
macro_rules! s(
|
||||||
{ $s:expr } => { $s.to_owned() };
|
{ $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 settings;
|
||||||
mod util;
|
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;
|
pub(crate) use settings::Settings;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
use crate::argparse::{Condition, Filter};
|
use crate::argparse::{Condition, Filter};
|
||||||
use crate::settings::util::table_with_keys;
|
use crate::settings::util::table_with_keys;
|
||||||
|
use crate::usage::{self, Usage};
|
||||||
use anyhow::{anyhow, bail, Result};
|
use anyhow::{anyhow, bail, Result};
|
||||||
use std::convert::{TryFrom, TryInto};
|
use std::convert::{TryFrom, TryInto};
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ pub(crate) struct Column {
|
|||||||
/// Task property to display in a report
|
/// Task property to display in a report
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub(crate) enum Property {
|
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
|
/// The task's ID, either working-set index or Uuid if not in the working set
|
||||||
Id,
|
Id,
|
||||||
|
|
||||||
@@ -44,6 +46,9 @@ pub(crate) enum Property {
|
|||||||
|
|
||||||
/// The task's tags
|
/// The task's tags
|
||||||
Tags,
|
Tags,
|
||||||
|
|
||||||
|
/// The task's wait date
|
||||||
|
Wait,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A sorting criterion for a sort operation.
|
/// A sorting criterion for a sort operation.
|
||||||
@@ -59,6 +64,7 @@ pub(crate) struct Sort {
|
|||||||
/// Task property to sort by
|
/// Task property to sort by
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub(crate) enum SortBy {
|
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
|
/// The task's ID, either working-set index or a UUID prefix; working
|
||||||
/// set tasks sort before others.
|
/// set tasks sort before others.
|
||||||
Id,
|
Id,
|
||||||
@@ -68,6 +74,9 @@ pub(crate) enum SortBy {
|
|||||||
|
|
||||||
/// The task's description
|
/// The task's description
|
||||||
Description,
|
Description,
|
||||||
|
|
||||||
|
/// The task's wait date
|
||||||
|
Wait,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Conversions from settings::Settings.
|
// Conversions from settings::Settings.
|
||||||
@@ -171,6 +180,7 @@ impl TryFrom<&toml::Value> for Property {
|
|||||||
"active" => Property::Active,
|
"active" => Property::Active,
|
||||||
"description" => Property::Description,
|
"description" => Property::Description,
|
||||||
"tags" => Property::Tags,
|
"tags" => Property::Tags,
|
||||||
|
"wait" => Property::Wait,
|
||||||
_ => bail!(": unknown property {}", s),
|
_ => bail!(": unknown property {}", s),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -207,11 +217,45 @@ impl TryFrom<&toml::Value> for SortBy {
|
|||||||
"id" => SortBy::Id,
|
"id" => SortBy::Id,
|
||||||
"uuid" => SortBy::Uuid,
|
"uuid" => SortBy::Uuid,
|
||||||
"description" => SortBy::Description,
|
"description" => SortBy::Description,
|
||||||
|
"wait" => SortBy::Wait,
|
||||||
_ => bail!(": unknown sort_by value `{}`", s),
|
_ => 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)]
|
#[cfg(test)]
|
||||||
mod test {
|
mod test {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -13,21 +13,25 @@ use toml_edit::Document;
|
|||||||
|
|
||||||
#[derive(Debug, PartialEq)]
|
#[derive(Debug, PartialEq)]
|
||||||
pub(crate) struct Settings {
|
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>,
|
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,
|
pub(crate) data_dir: PathBuf,
|
||||||
|
|
||||||
// remote sync server
|
/// remote sync server
|
||||||
pub(crate) server_client_key: Option<String>,
|
pub(crate) server_client_key: Option<String>,
|
||||||
pub(crate) server_origin: Option<String>,
|
pub(crate) server_origin: Option<String>,
|
||||||
pub(crate) encryption_secret: Option<String>,
|
pub(crate) encryption_secret: Option<String>,
|
||||||
|
|
||||||
// local sync server
|
/// local sync server
|
||||||
pub(crate) server_dir: PathBuf,
|
pub(crate) server_dir: PathBuf,
|
||||||
|
|
||||||
// reports
|
/// reports
|
||||||
pub(crate) reports: HashMap<String, Report>,
|
pub(crate) reports: HashMap<String, Report>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +90,7 @@ impl Settings {
|
|||||||
fn update_from_toml(&mut self, config_toml: &toml::Value) -> Result<()> {
|
fn update_from_toml(&mut self, config_toml: &toml::Value) -> Result<()> {
|
||||||
let table_keys = [
|
let table_keys = [
|
||||||
"data_dir",
|
"data_dir",
|
||||||
|
"modification_count_prompt",
|
||||||
"server_client_key",
|
"server_client_key",
|
||||||
"server_origin",
|
"server_origin",
|
||||||
"encryption_secret",
|
"encryption_secret",
|
||||||
@@ -109,10 +114,24 @@ impl Settings {
|
|||||||
Ok(())
|
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| {
|
get_str_cfg(table, "data_dir", |v| {
|
||||||
self.data_dir = v.into();
|
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| {
|
get_str_cfg(table, "server_client_key", |v| {
|
||||||
self.server_client_key = Some(v);
|
self.server_client_key = Some(v);
|
||||||
})?;
|
})?;
|
||||||
@@ -142,10 +161,12 @@ impl Settings {
|
|||||||
Ok(())
|
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> {
|
pub(crate) fn set(&self, key: &str, value: &str) -> Result<PathBuf> {
|
||||||
let allowed_keys = [
|
let allowed_keys = [
|
||||||
"data_dir",
|
"data_dir",
|
||||||
|
"modification_count_prompt",
|
||||||
"server_client_key",
|
"server_client_key",
|
||||||
"server_origin",
|
"server_origin",
|
||||||
"encryption_secret",
|
"encryption_secret",
|
||||||
@@ -168,7 +189,17 @@ impl Settings {
|
|||||||
.parse::<Document>()
|
.parse::<Document>()
|
||||||
.context("Could not parse existing configuration file")?;
|
.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())
|
fs::write(filename.clone(), document.to_string())
|
||||||
.context("Could not write updated configuration file")?;
|
.context("Could not write updated configuration file")?;
|
||||||
@@ -218,6 +249,10 @@ impl Default for Settings {
|
|||||||
label: "tags".to_owned(),
|
label: "tags".to_owned(),
|
||||||
property: Property::Tags,
|
property: Property::Tags,
|
||||||
},
|
},
|
||||||
|
Column {
|
||||||
|
label: "wait".to_owned(),
|
||||||
|
property: Property::Wait,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
filter: Default::default(),
|
filter: Default::default(),
|
||||||
},
|
},
|
||||||
@@ -263,6 +298,7 @@ impl Default for Settings {
|
|||||||
Self {
|
Self {
|
||||||
filename: None,
|
filename: None,
|
||||||
data_dir,
|
data_dir,
|
||||||
|
modification_count_prompt: None,
|
||||||
server_client_key: None,
|
server_client_key: None,
|
||||||
server_origin: None,
|
server_origin: None,
|
||||||
encryption_secret: None,
|
encryption_secret: None,
|
||||||
@@ -312,6 +348,7 @@ mod test {
|
|||||||
fn test_update_from_toml_top_level_keys() {
|
fn test_update_from_toml_top_level_keys() {
|
||||||
let val = toml! {
|
let val = toml! {
|
||||||
data_dir = "/data"
|
data_dir = "/data"
|
||||||
|
modification_count_prompt = 42
|
||||||
server_client_key = "sck"
|
server_client_key = "sck"
|
||||||
server_origin = "so"
|
server_origin = "so"
|
||||||
encryption_secret = "es"
|
encryption_secret = "es"
|
||||||
@@ -321,6 +358,7 @@ mod test {
|
|||||||
settings.update_from_toml(&val).unwrap();
|
settings.update_from_toml(&val).unwrap();
|
||||||
|
|
||||||
assert_eq!(settings.data_dir, PathBuf::from("/data"));
|
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_client_key, Some("sck".to_owned()));
|
||||||
assert_eq!(settings.server_origin, Some("so".to_owned()));
|
assert_eq!(settings.server_origin, Some("so".to_owned()));
|
||||||
assert_eq!(settings.encryption_secret, Some("es".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();
|
let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap();
|
||||||
assert_eq!(settings.filename, Some(cfg_file.clone()));
|
assert_eq!(settings.filename, Some(cfg_file.clone()));
|
||||||
settings.set("data_dir", "/data").unwrap();
|
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();
|
let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap();
|
||||||
assert_eq!(settings.data_dir, PathBuf::from("/data"));
|
assert_eq!(settings.data_dir, PathBuf::from("/data"));
|
||||||
assert_eq!(settings.server_dir, PathBuf::from("/srv"));
|
assert_eq!(settings.server_dir, PathBuf::from("/srv"));
|
||||||
assert_eq!(settings.filename, Some(cfg_file));
|
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.
|
//! a way that puts the source of that documentation near its implementation.
|
||||||
|
|
||||||
use crate::argparse;
|
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.
|
/// A top-level structure containing usage/help information for the entire CLI.
|
||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub(crate) struct Usage {
|
pub struct Usage {
|
||||||
pub(crate) subcommands: Vec<Subcommand>,
|
pub(crate) subcommands: Vec<Subcommand>,
|
||||||
pub(crate) filters: Vec<Filter>,
|
pub(crate) filters: Vec<Filter>,
|
||||||
pub(crate) modifications: Vec<Modification>,
|
pub(crate) modifications: Vec<Modification>,
|
||||||
|
pub(crate) report_properties: Vec<ReportProperty>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Usage {
|
impl Usage {
|
||||||
/// Get a new, completely-filled-out usage object
|
/// Get a new, completely-filled-out usage object
|
||||||
pub(crate) fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
let mut rv = Self {
|
let mut rv = Self {
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
argparse::get_usage(&mut rv);
|
argparse::get_usage(&mut rv);
|
||||||
|
settings::get_usage(&mut rv);
|
||||||
// TODO: sort subcommands
|
|
||||||
|
|
||||||
rv
|
rv
|
||||||
}
|
}
|
||||||
@@ -77,6 +82,62 @@ impl Usage {
|
|||||||
}
|
}
|
||||||
Ok(())
|
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
|
/// wrap an indented string
|
||||||
@@ -122,6 +183,15 @@ impl Subcommand {
|
|||||||
}
|
}
|
||||||
Ok(())
|
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
|
/// Usage documentation for a filter argument
|
||||||
@@ -152,6 +222,15 @@ impl Filter {
|
|||||||
}
|
}
|
||||||
Ok(())
|
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
|
/// Usage documentation for a modification argument
|
||||||
@@ -182,4 +261,51 @@ impl Modification {
|
|||||||
}
|
}
|
||||||
Ok(())
|
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.arg("--no-such-option");
|
||||||
cmd.assert()
|
cmd.assert()
|
||||||
.failure()
|
.failure()
|
||||||
.stderr(predicate::str::contains("command line not recognized"));
|
.stderr(predicate::str::contains("command line not recognized"))
|
||||||
|
.code(predicate::eq(3));
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
This is an [mdbook](https://rust-lang.github.io/mdBook/index.html) book.
|
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.
|
Minor modifications can be made without installing the mdbook tool, as the content is simple Markdown.
|
||||||
Changes are verified on pull requests.
|
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]
|
[output.html]
|
||||||
default-theme = "ayu"
|
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)
|
- [Welcome to TaskChampion](./welcome.md)
|
||||||
* [Installation](./installation.md)
|
* [Installation](./installation.md)
|
||||||
* [Using the Task Command](./using-task-command.md)
|
* [Using the Task Command](./using-task-command.md)
|
||||||
* [Configuration](./config-file.md)
|
|
||||||
* [Reports](./reports.md)
|
* [Reports](./reports.md)
|
||||||
* [Tags](./tags.md)
|
* [Tags](./tags.md)
|
||||||
|
* [Filters](./filters.md)
|
||||||
|
* [Modifications](./modifications.md)
|
||||||
|
* [Dates and Durations](./time.md)
|
||||||
|
* [Configuration](./config-file.md)
|
||||||
* [Environment](./environment.md)
|
* [Environment](./environment.md)
|
||||||
* [Synchronization](./task-sync.md)
|
* [Synchronization](./task-sync.md)
|
||||||
* [Running the Sync Server](./running-sync-server.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).
|
* `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.
|
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
|
## Sync Server
|
||||||
|
|
||||||
If using a local 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.
|
The `filter` property is a list of [filters](./filters.md).
|
||||||
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.
|
||||||
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.
|
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.
|
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
|
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.
|
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.
|
Each element has a `label` and a `property`, as shown in the example above.
|
||||||
|
|
||||||
The avaliable properties are:
|
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 `+-*/(<>^! %=~`.
|
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.
|
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
|
* `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)
|
* `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)
|
* `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:
|
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`.
|
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.
|
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).
|
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.
|
> 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"
|
log = "^0.4.14"
|
||||||
tindercrypt = { version = "^0.2.2", default-features = false }
|
tindercrypt = { version = "^0.2.2", default-features = false }
|
||||||
rusqlite = { version = "0.25", features = ["bundled"] }
|
rusqlite = { version = "0.25", features = ["bundled"] }
|
||||||
|
strum = "0.21"
|
||||||
|
strum_macros = "0.21"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
proptest = "^1.0.0"
|
proptest = "^1.0.0"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
rstest = "0.10"
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
|
|
||||||
#[derive(Debug, Error, Eq, PartialEq, Clone)]
|
#[derive(Debug, Error, Eq, PartialEq, Clone)]
|
||||||
|
#[non_exhaustive]
|
||||||
|
/// Errors returned from taskchampion operations
|
||||||
pub enum Error {
|
pub enum Error {
|
||||||
#[error("Task Database Error: {}", _0)]
|
#[error("Task Database Error: {0}")]
|
||||||
DbError(String),
|
Database(String),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,10 @@ Users can define their own server impelementations.
|
|||||||
See the [TaskChampion Book](http://taskchampion.github.com/taskchampion)
|
See the [TaskChampion Book](http://taskchampion.github.com/taskchampion)
|
||||||
for more information about the design and usage of the tool.
|
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;
|
mod errors;
|
||||||
@@ -40,6 +44,7 @@ mod taskdb;
|
|||||||
mod utils;
|
mod utils;
|
||||||
mod workingset;
|
mod workingset;
|
||||||
|
|
||||||
|
pub use errors::Error;
|
||||||
pub use replica::Replica;
|
pub use replica::Replica;
|
||||||
pub use server::{Server, ServerConfig};
|
pub use server::{Server, ServerConfig};
|
||||||
pub use storage::StorageConfig;
|
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
|
// 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
|
// when this Create operation is finally sync'd with operations from other replicas
|
||||||
if self.taskdb.get_task(uuid)?.is_none() {
|
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 })?;
|
self.taskdb.apply(Operation::Delete { uuid })?;
|
||||||
trace!("task {} deleted", 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::replica::Replica;
|
||||||
use crate::storage::TaskMap;
|
use crate::storage::TaskMap;
|
||||||
use chrono::prelude::*;
|
use chrono::prelude::*;
|
||||||
use log::trace;
|
use log::trace;
|
||||||
use std::convert::{TryFrom, TryInto};
|
use std::convert::AsRef;
|
||||||
use std::fmt;
|
use std::convert::TryInto;
|
||||||
use uuid::Uuid;
|
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.
|
/// 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
|
/// 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("")
|
.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
|
/// Determine whether this task is active -- that is, that it has been started
|
||||||
/// and not stopped.
|
/// and not stopped.
|
||||||
pub fn is_active(&self) -> bool {
|
pub fn is_active(&self) -> bool {
|
||||||
@@ -219,22 +90,46 @@ impl Task {
|
|||||||
.any(|(k, v)| k.starts_with("start.") && v.is_empty())
|
.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
|
/// Check if this task has the given tag
|
||||||
pub fn has_tag(&self, tag: &Tag) -> bool {
|
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
|
/// Iterate over the task's tags
|
||||||
pub fn get_tags(&self) -> impl Iterator<Item = Tag> + '_ {
|
pub fn get_tags(&self) -> impl Iterator<Item = Tag> + '_ {
|
||||||
self.taskmap.iter().filter_map(|(k, _)| {
|
use strum::IntoEnumIterator;
|
||||||
if let Some(tag) = k.strip_prefix("tag.") {
|
|
||||||
if let Ok(tag) = tag.try_into() {
|
self.taskmap
|
||||||
return Some(tag);
|
.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>> {
|
pub fn get_modified(&self) -> Option<DateTime<Utc>> {
|
||||||
@@ -275,6 +170,10 @@ impl<'r> TaskMut<'r> {
|
|||||||
self.set_string("description", Some(description))
|
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<()> {
|
pub fn set_modified(&mut self, modified: DateTime<Utc>) -> anyhow::Result<()> {
|
||||||
self.set_timestamp("modified", Some(modified))
|
self.set_timestamp("modified", Some(modified))
|
||||||
}
|
}
|
||||||
@@ -306,13 +205,24 @@ impl<'r> TaskMut<'r> {
|
|||||||
Ok(())
|
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.
|
/// Add a tag to this task. Does nothing if the tag is already present.
|
||||||
pub fn add_tag(&mut self, tag: &Tag) -> anyhow::Result<()> {
|
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()))
|
self.set_string(format!("tag.{}", tag), Some("".to_owned()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove a tag from this task. Does nothing if the tag is not present.
|
/// Remove a tag from this task. Does nothing if the tag is not present.
|
||||||
pub fn remove_tag(&mut self, tag: &Tag) -> anyhow::Result<()> {
|
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)
|
self.set_string(format!("tag.{}", tag), None)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,28 +308,14 @@ mod test {
|
|||||||
f(task)
|
f(task)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
/// Create a user tag, without checking its validity
|
||||||
fn test_tag_from_str() {
|
fn utag(name: &'static str) -> Tag {
|
||||||
let tag: Tag = "abc".try_into().unwrap();
|
Tag::from_inner(TagInner::User(name.into()))
|
||||||
assert_eq!(tag, Tag("abc".to_owned()));
|
}
|
||||||
|
|
||||||
let tag: Tag = ":abc".try_into().unwrap();
|
/// Create a synthetic tag
|
||||||
assert_eq!(tag, Tag(":abc".to_owned()));
|
fn stag(synth: SyntheticTag) -> Tag {
|
||||||
|
Tag::from_inner(TagInner::Synthetic(synth))
|
||||||
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!!\"");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -453,16 +349,59 @@ mod test {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[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(
|
let task = Task::new(
|
||||||
Uuid::new_v4(),
|
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(..)
|
.drain(..)
|
||||||
.collect(),
|
.collect(),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert!(task.has_tag(&"abc".try_into().unwrap()));
|
assert!(task.is_waiting());
|
||||||
assert!(!task.has_tag(&"def".try_into().unwrap()));
|
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]
|
#[test]
|
||||||
@@ -472,6 +411,8 @@ mod test {
|
|||||||
vec![
|
vec![
|
||||||
(String::from("tag.abc"), String::from("")),
|
(String::from("tag.abc"), String::from("")),
|
||||||
(String::from("tag.def"), String::from("")),
|
(String::from("tag.def"), String::from("")),
|
||||||
|
// set `wait` so the synthetic tag WAITING is present
|
||||||
|
(String::from("wait"), String::from("33158909732")),
|
||||||
]
|
]
|
||||||
.drain(..)
|
.drain(..)
|
||||||
.collect(),
|
.collect(),
|
||||||
@@ -479,7 +420,14 @@ mod test {
|
|||||||
|
|
||||||
let mut tags: Vec<_> = task.get_tags().collect();
|
let mut tags: Vec<_> = task.get_tags().collect();
|
||||||
tags.sort();
|
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]
|
#[test]
|
||||||
@@ -498,7 +446,7 @@ mod test {
|
|||||||
|
|
||||||
// only "ok" is OK
|
// only "ok" is OK
|
||||||
let tags: Vec<_> = task.get_tags().collect();
|
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 {
|
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]
|
#[test]
|
||||||
fn test_stop_multiple() {
|
fn test_stop_multiple() {
|
||||||
with_mut_task(|mut task| {
|
with_mut_task(|mut task| {
|
||||||
@@ -593,12 +555,12 @@ mod test {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_add_tags() {
|
fn test_add_tags() {
|
||||||
with_mut_task(|mut task| {
|
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"));
|
assert!(task.taskmap.contains_key("tag.abc"));
|
||||||
task.reload().unwrap();
|
task.reload().unwrap();
|
||||||
assert!(task.taskmap.contains_key("tag.abc"));
|
assert!(task.taskmap.contains_key("tag.abc"));
|
||||||
// redundant add has no effect..
|
// 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"));
|
assert!(task.taskmap.contains_key("tag.abc"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -606,35 +568,15 @@ mod test {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_remove_tags() {
|
fn test_remove_tags() {
|
||||||
with_mut_task(|mut task| {
|
with_mut_task(|mut task| {
|
||||||
task.add_tag(&Tag("abc".to_owned())).unwrap();
|
task.add_tag(&utag("abc")).unwrap();
|
||||||
task.reload().unwrap();
|
task.reload().unwrap();
|
||||||
assert!(task.taskmap.contains_key("tag.abc"));
|
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"));
|
assert!(!task.taskmap.contains_key("tag.abc"));
|
||||||
// redundant remove has no effect..
|
// 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"));
|
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 } => {
|
Operation::Create { uuid } => {
|
||||||
// insert if the task does not already exist
|
// insert if the task does not already exist
|
||||||
if !txn.create_task(*uuid)? {
|
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 } => {
|
Operation::Delete { ref uuid } => {
|
||||||
if !txn.delete_task(*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 {
|
Operation::Update {
|
||||||
@@ -71,7 +71,7 @@ impl TaskDb {
|
|||||||
};
|
};
|
||||||
txn.set_task(*uuid, task)?;
|
txn.set_task(*uuid, task)?;
|
||||||
} else {
|
} else {
|
||||||
return Err(Error::DbError(format!("Task {} does not exist", uuid)).into());
|
return Err(Error::Database(format!("Task {} does not exist", uuid)).into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user