Import the TaskChampion repository at rust/

This commit is contained in:
Dustin J. Mitchell 2022-05-08 19:39:44 +00:00
commit 1df54125ea
No known key found for this signature in database
219 changed files with 32763 additions and 0 deletions

5
rust/.cargo/audit.toml Normal file
View file

@ -0,0 +1,5 @@
[advisories]
ignore = [
"RUSTSEC-2020-0159", # segfault in localtime_r - low risk to TC
"RUSTSEC-2020-0071", # same localtime_r bug as above
]

2
rust/.cargo/config Normal file
View file

@ -0,0 +1,2 @@
[alias]
xtask = "run --package xtask --"

0
rust/.changelogs/.gitignore vendored Normal file
View file

View file

@ -0,0 +1,2 @@
- The SQLite server storage schema has changed incompatibly, in order to add support for snapshots.
As this is not currently ready for production usage, no migration path is provided except deleting the existing database.

View file

@ -0,0 +1,2 @@
- The `avoid_snapshots` configuration value, if set, will cause the replica to
avoid creating snapshots unless required.

View file

@ -0,0 +1 @@
- The encryption format used for synchronization has changed incompatibly

View file

@ -0,0 +1 @@
- The details of how task start/stop is represented have changed. Any existing tasks will all be treated as inactive (stopped).

1
rust/.github/CODEOWNERS vendored Normal file
View file

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

11
rust/.github/dependabot.yml vendored Normal file
View file

@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "cargo"
directory: "/" # Location of package manifests
schedule:
interval: "daily"

19
rust/.github/workflows/audit.yml vendored Normal file
View file

@ -0,0 +1,19 @@
name: Security
on:
schedule:
- cron: '0 0 * * *'
push:
paths:
- '**/Cargo.toml'
- '**/Cargo.lock'
jobs:
audit:
runs-on: ubuntu-latest
name: "Audit Dependencies"
steps:
- uses: actions/checkout@v2
- uses: actions-rs/audit-check@v1
with:
token: ${{ secrets.GITHUB_TOKEN }}

102
rust/.github/workflows/checks.yml vendored Normal file
View file

@ -0,0 +1,102 @@
name: Checks
on:
push:
branches:
- main
pull_request:
types: [opened, reopened, synchronize]
jobs:
clippy:
runs-on: ubuntu-latest
name: "Clippy"
steps:
- uses: actions/checkout@v2
- name: Cache cargo registry
uses: actions/cache@v2
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v2
with:
path: target
key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
- uses: actions-rs/toolchain@v1
with:
# Fixed version for clippy lints. Bump this as necesary. It must not
# be older than the MSRV in tests.yml.
toolchain: "1.57"
override: true
- 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@v2
- name: Setup mdBook
uses: peaceiris/actions-mdbook@v1
with:
# if this changes, change it in cli/Cargo.toml and .github/workflows/publish-docs.yml as well
mdbook-version: '0.4.10'
- name: Cache cargo registry
uses: actions/cache@v2
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v2
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 test docs
- run: mdbook build docs
fmt:
runs-on: ubuntu-latest
name: "Formatting"
steps:
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
profile: minimal
components: rustfmt
toolchain: stable
override: true
- uses: actions-rs/cargo@v1
with:
command: fmt
args: --all -- --check

47
rust/.github/workflows/publish-docs.yml vendored Normal file
View file

@ -0,0 +1,47 @@
name: Docs
on:
push:
branches:
- main
jobs:
mdbook-deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Setup mdBook
uses: peaceiris/actions-mdbook@v1
with:
# if this changes, change it in cli/Cargo.toml and .github/workflows/publish-docs.yml as well
mdbook-version: '0.4.10'
- name: Cache cargo registry
uses: actions/cache@v2
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v2
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
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./docs/book

47
rust/.github/workflows/tests.yml vendored Normal file
View file

@ -0,0 +1,47 @@
name: Tests
on:
push:
branches:
- main
pull_request:
types: [opened, reopened, synchronize]
jobs:
test:
strategy:
matrix:
rust:
# MSRV; most not be higher than the clippy rust version in checks.yml
- "1.47"
- "stable"
os:
- ubuntu-latest
- macOS-latest
- windows-latest
name: "Test - Rust ${{ matrix.rust }} on ${{ matrix.os }}"
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v1
- name: Cache cargo registry
uses: actions/cache@v2
with:
path: ~/.cargo/registry
key: ${{ runner.os }}-${{ matrix.rust }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
- name: Cache cargo build
uses: actions/cache@v2
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

2
rust/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/target
**/*.rs.bk

26
rust/CHANGELOG.md Normal file
View file

@ -0,0 +1,26 @@
# Changelog
## [Unreleased]
Note: unreleased change log entries are kept in `.changelogs/` directory in repo root, and can be added with `./script/changelog.py add "Added thing for reason"
## 0.4.1 - 2021-09-24
- Fix for the build process to include the serde feature "derive". 0.4.0 could not be published to crates.io due to this bug.
## 0.4.0 - 2021-09-25
- Breaking: Removed the KV based storage backend in client and server, and replaced with SQLite ([Issue #131](https://github.com/taskchampion/taskchampion/issues/131), [PR #206](https://github.com/taskchampion/taskchampion/pull/206))
## 0.3.0 - 2021-01-11
- Flexible named reports
- Updates to the TaskChampion crate API
- Usability improvements
## 0.2.0 - 2020-11-30
This release is the first "MVP" version of this tool. It can do basic task operations, and supports a synchronization. Major missing features are captured in issues, but briefly:
better command-line API, similar to TaskWarrior
authentication of the replica / server protocol
encryption of replica data before transmission to the server
lots of task features (tags, annotations, dependencies, ..)
lots of CLI features (filtering, modifying, ..)

76
rust/CODE_OF_CONDUCT.md Normal file
View file

@ -0,0 +1,76 @@
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
our community a harassment-free experience for everyone, regardless of age, body
size, disability, ethnicity, sex characteristics, gender identity and expression,
level of experience, education, socio-economic status, nationality, personal
appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment
include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or
advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a
professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or
reject comments, commits, code, wiki edits, issues, and other contributions
that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
representing a project or community include using an official project e-mail
address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at dustin@cs.uchicago.edu. All
complaints will be reviewed and investigated and will result in a response that
is deemed necessary and appropriate to the circumstances. The project team is
obligated to maintain confidentiality with regard to the reporter of an incident.
Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
[homepage]: https://www.contributor-covenant.org
For answers to common questions about this code of conduct, see
https://www.contributor-covenant.org/faq

65
rust/CONTRIBUTING.md Normal file
View file

@ -0,0 +1,65 @@
# Welcome
This application is still in a pre-release state.
That means it's very open to contributions, and we'd love to have your help!
It also means that things are changing quickly, and lots of stuff is planned that's not quite done yet.
If you would like to work on TaskChampion, please contact the developers (via the issue tracker) before spending a lot of time working on a pull request.
Doing so may save you some wasted time and frustration!
A good starting point might be one of the issues tagged with ["good first issue"][first].
[first]: https://github.com/taskchampion/taskchampion/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22
# Other Ways To Help
The best way to help this project to grow is to help spread awareness of it.
Tell your friends, post to social media, blog about it -- whatever works best!
Other ideas;
* Improve the documentation where it's unclear or lacking some information
* Build and maintain tools that integrate with TaskChampion
# Development Guide
TaskChampion is a typical Rust application.
To work on TaskChampion, you'll need to [install the latest version of Rust](https://www.rust-lang.org/tools/install).
Once you've done that, run `cargo build` at the top level of this repository to build the binaries.
This will build `task` and `taskchampion-sync-server` executables in the `./target/debug` directory.
You can build optimized versions of these binaries with `cargo build --release`, but the performance difference in the resulting binaries is not noticeable, and the build process will take a long time, so this is not recommended.
## Running Test
It's always a good idea to make sure tests run before you start hacking on a project.
Run `cargo test` from the top-level of this repository to run the tests.
## Read the Source
Aside from that, start reading the docs and the source to learn more!
The book documentation explains lots of the concepts in the design of TaskChampion.
It is linked from the README.
There are three crates in this repository.
You may be able to limit the scope of what you need to understand to just one crate.
* `taskchampion` is the core functionality of the application, implemented as a library
* `taskchampion-cli` implements the command-line interface (in `cli/`)
* `taskchampion-sync-server` implements the synchronization server (in `sync-server/`)
You can generate the documentation for the `taskchampion` crate with `cargo doc --release --open -p taskchampion`.
## Making a Pull Request
We expect contributors to follow the [GitHub Flow](https://guides.github.com/introduction/flow/).
Aside from that, we have no particular requirements on pull requests.
Make your patch, double-check that it's complete (tests? docs? documentation comments?), and make a new pull request.
Any non-trivial change (particularly those that change the behaviour of the application, or change the API) should be noted in the projects changelog.
In order to manage this, changelog entries are stored as text files in the `.changelog/` directory at the repository root.
To add a new changelog entry, you can simply run `python3 ./script/changelog.py add "Fixed thingo to increase zorbloxification [Issue #2](http://example.com)`
This creates a file named `./changelogs/yyyy-mm-dd-branchname.md` (timestamp, current git branch) which contains a markdown snippet.
If you don't have a Python 3 intepreter installed, you can simply create this file manually. It should contain a list item like `- Fixed thingo [...]`
Periodically (probably just before release), these changelog entries are concatenated combined together and added into the `CHANGELOG.md` file.

3808
rust/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

10
rust/Cargo.toml Normal file
View file

@ -0,0 +1,10 @@
[workspace]
members = [
"taskchampion",
"cli",
"sync-server",
"lib",
"integration-tests",
"xtask",
]

21
rust/LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2020 Dustin J. Mitchell
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

45
rust/POLICY.md Normal file
View file

@ -0,0 +1,45 @@
# Compatibility & deprecation
Until TaskChampion reaches [v1.0.0](https://github.com/taskchampion/taskchampion/milestone/7), nothing is set in stone. That being said, we aim for the following:
1. Major versions represent significant change and may be incompatible with previous major release.
2. Minor versions are always backwards compatible and might add some new functionality.
3. Patch versions should not introduce any new functionality and do what name implies — fix bugs.
As there are no major releases yet, we do not support any older versions. Users are encouraged to use the latest release.
## ABI policy
1. We target stable `rustc`.
2. TaskChampion will never upgrade any storage to a non-compatible version without explicit user's request.
## API policy
1. Deprecated features return a warning at least 1 minor version prior to being removed.
Example:
> If support of `--bar` is to be dropped in v2.0.0, we shall announce it in v1.9.0 at latest.
2. We aim to issue a notice of newly added functionality when appropriate.
Example:
> "NOTICE: Since v1.1.0 you can use `--foo` in conjunction with `--bar`. Foobar!"
3. TaskChampion always uses UTF-8.
## Command-line interface
Considered to be part of the API policy.
## CLI exit codes
- `0` - No errors, normal exit.
- `1` - Generic error.
- `2` - Never used to avoid conflicts with Bash.
- `3` - Command-line Syntax Error.
# Security
See [SECURITY.md](./SECURITY.md).

58
rust/README.md Normal file
View file

@ -0,0 +1,58 @@
TaskChampion
------------
TaskChampion is an open-source personal task-tracking application.
Use it to keep track of what you need to do, with a quick command-line interface and flexible sorting and filtering.
It is modeled on [TaskWarrior](https://taskwarrior.org), but not a drop-in replacement for that application.
See the [documentation](https://taskchampion.github.io/taskchampion/) for more!
## Status
TaskChampion currently functions as a "testbed" for new functionality that may later be incorporated into TaskWarrior.
It can be developed without the requirements of compatibliity, allowing us to explore and fix edge-cases in things like the replica-synchronization model.
While you are welcome to [help out](https://github.com/taskchampion/taskchampion/blob/main/CONTRIBUTING.md), you should do so with the awareness that your work might never be used.
But, if you just want to get some practice with Rust, we'd be happy to have you.
## Structure
There are five crates here:
* [taskchampion](./taskchampion) - the core of the tool
* [taskchampion-cli](./cli) - the command-line binary
* [taskchampion-sync-server](./sync-server) - the server against which `task sync` operates
* [taskchampion-lib](./lib) - glue code to use _taskchampion_ from C
* [integration-tests](./integration-tests) - integration tests covering _taskchampion-cli_, _taskchampion-sync-server_, and _taskchampion-lib_.
## Code Generation
The _taskchampion_lib_ crate uses a bit of code generation to create the `lib/taskchampion.h` header file.
To regenerate this file, run `cargo xtask codegen`.
## C libraries
NOTE: support for linking against taskchampion is a work in progress.
Contributions and pointers to best practices are appreciated!
The `taskchampion-lib` crate generates libraries suitable for use from C (or any C-compatible language).
The necessary bits are:
* a shared object in `target/$PROFILE/deps` (e.g., `target/debug/deps/libtaskchampion.so`)
* a static library in `target/$PROFILE` (e.g., `target/debug/libtaskchampion.a`)
* a header file, `lib/taskchampion.h`.
Downstream consumers may use either the static or dynamic library, as they prefer.
NOTE: on Windows, the "BCrypt" library must be included when linking to taskchampion.
### As a Rust dependency
If you would prefer to build Taskchampion directly into your project, and have a build system capable of building Rust libraries (such as CMake), the `taskchampion-lib` crate can be referenced as an `rlib` dependency.
## 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.
This preprocessor is not built by default.
To (re)build it, run `cargo build -p taskchampion-cli --features usage-docs --bin usage-docs`.

17
rust/RELEASING.md Normal file
View file

@ -0,0 +1,17 @@
# Release process
1. Ensure the changelog is updated with everything from the `.changelogs` directory. `python3 ./scripts/changelog.py build` will output a Markdown snippet to include in `CHANGELOG.md` then `rm .changelog/*.txt`
1. Run `git pull upstream main`
1. Run `cargo test`
1. Run `cargo clean && cargo clippy`
1. Run `mdbook test docs`
1. Update `version` in `*/Cargo.toml`. All versions should match.
1. Run `cargo build --release`
1. Commit the changes (Cargo.lock will change too) with comment `vX.Y.Z`.
1. Run `git tag vX.Y.Z`
1. Run `git push upstream`
1. Run `git push --tags upstream`
1. Run `( ./build-docs.sh )`
1. Run `(cd taskchampion; cargo publish)` (note that the other crates do not get published)
1. Navigate to the tag in the GitHub releases UI and create a release with general comments about the changes in the release
1. Upload `./target/release/task` and `./target/release/task-sync-server` to the release

11
rust/SECURITY.md Normal file
View file

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

36
rust/build-docs.sh Executable file
View file

@ -0,0 +1,36 @@
#! /bin/bash
set -x
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
git branch -f gh-pages $REMOTE/gh-pages
if ! [ -d ./docs/tmp ]; then
git worktree add -f 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
git worktree remove -f docs/tmp
rm -rf docs/tmp/*
mdbook build docs
mkdir docs/tmp
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)

57
rust/cli/Cargo.toml Normal file
View file

@ -0,0 +1,57 @@
[package]
authors = ["Dustin J. Mitchell <dustin@mozilla.com>"]
edition = "2018"
name = "taskchampion-cli"
version = "0.4.1"
build = "build.rs"
# Run 'ta' when doing 'cargo run' at repo root
default-run = "ta"
[dependencies]
dirs-next = "^2.0.0"
env_logger = "^0.8.3"
anyhow = "1.0"
thiserror = "1.0"
log = "^0.4.14"
nom = "^6.1.2"
prettytable-rs = "^0.8.0"
textwrap = { version="^0.13.4", features=["terminal_size"] }
termcolor = "^1.1.2"
atty = "^0.2.14"
toml = "^0.5.8"
toml_edit = "^0.2.0"
serde = { version = "^1.0.125", features = ["derive"] }
serde_json = "^1.0"
lazy_static = "1"
iso8601-duration = "0.1"
dialoguer = "0.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 }
[dependencies.taskchampion]
path = "../taskchampion"
[build-dependencies]
built = { version = "0.5", features = ["git2"] }
[dev-dependencies]
assert_cmd = "^1.0.3"
predicates = "^1.0.7"
tempfile = "3"
rstest = "0.10"
pretty_assertions = "1"
[features]
usage-docs = [ "mdbook" ]
[[bin]]
name = "ta"
[[bin]]
# this is an mdbook plugin and only needed when running `mdbook`
name = "usage-docs"
required-features = [ "usage-docs" ]

3
rust/cli/build.rs Normal file
View file

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

View file

@ -0,0 +1,61 @@
use crate::argparse::ArgList;
use nom::{
error::{Error, ErrorKind},
Err, IResult,
};
/// Consume a single argument from an argument list that matches the given string parser (one
/// of the other functions in this module). The given parser must consume the entire input.
pub(crate) fn arg_matching<'a, O, F>(f: F) -> impl Fn(ArgList<'a>) -> IResult<ArgList, O>
where
F: Fn(&'a str) -> IResult<&'a str, O>,
{
move |input: ArgList<'a>| {
if let Some(arg) = input.get(0) {
return match f(arg) {
Ok(("", rv)) => Ok((&input[1..], rv)),
// single-arg parsers must consume the entire arg, so consider unconsumed
// output to be an error.
Ok((_, _)) => Err(Err::Error(Error {
input,
code: ErrorKind::Eof,
})),
// single-arg parsers are all complete parsers
Err(Err::Incomplete(_)) => unreachable!(),
// for error and failure, rewrite to an error at this position in the arugment list
Err(Err::Error(Error { input: _, code })) => Err(Err::Error(Error { input, code })),
Err(Err::Failure(Error { input: _, code })) => {
Err(Err::Failure(Error { input, code }))
}
};
}
Err(Err::Error(Error {
input,
// since we're using nom's built-in Error, our choices here are limited, but tihs
// occurs when there's no argument where one is expected, so Eof seems appropriate
code: ErrorKind::Eof,
}))
}
}
#[cfg(test)]
mod test {
use super::super::*;
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_arg_matching() {
assert_eq!(
arg_matching(plus_tag)(argv!["+foo", "bar"]).unwrap(),
(argv!["bar"], tag!("foo"))
);
assert!(arg_matching(plus_tag)(argv!["foo", "bar"]).is_err());
}
#[test]
fn test_partial_arg_matching() {
assert!(arg_matching(wait_colon)(argv!["wait:UNRECOGNIZED"]).is_err());
}
}

View file

@ -0,0 +1,98 @@
use super::{any, id_list, timestamp, TaskId};
use crate::argparse::NOW;
use nom::bytes::complete::tag as nomtag;
use nom::{branch::*, character::complete::*, combinator::*, sequence::*, IResult};
use taskchampion::chrono::prelude::*;
use taskchampion::Status;
/// Recognizes up to the colon of the common `<prefix>:...` syntax
fn colon_prefix(prefix: &'static str) -> impl Fn(&str) -> IResult<&str, &str> {
fn to_suffix<'a>(input: (&'a str, char, &'a str)) -> Result<&'a str, ()> {
Ok(input.2)
}
move |input: &str| {
map_res(
all_consuming(tuple((nomtag(prefix), char(':'), any))),
to_suffix,
)(input)
}
}
/// Recognizes `status:{pending,completed,deleted}`
pub(crate) fn status_colon(input: &str) -> IResult<&str, Status> {
fn to_status(input: &str) -> Result<Status, ()> {
match input {
"pending" => Ok(Status::Pending),
"completed" => Ok(Status::Completed),
"deleted" => Ok(Status::Deleted),
_ => Err(()),
}
}
map_res(colon_prefix("status"), to_status)(input)
}
/// Recognizes `wait:` to None and `wait:<ts>` to `Some(ts)`
pub(crate) fn wait_colon(input: &str) -> IResult<&str, Option<DateTime<Utc>>> {
fn to_wait(input: DateTime<Utc>) -> Result<Option<DateTime<Utc>>, ()> {
Ok(Some(input))
}
fn to_none(_: &str) -> Result<Option<DateTime<Utc>>, ()> {
Ok(None)
}
preceded(
nomtag("wait:"),
alt((
map_res(timestamp(*NOW, Local), to_wait),
map_res(nomtag(""), to_none),
)),
)(input)
}
/// Recognizes `depends:<task>` to `(true, <task>)` and `depends:-<task>` to `(false, <task>)`.
pub(crate) fn depends_colon(input: &str) -> IResult<&str, (bool, Vec<TaskId>)> {
fn to_bool(maybe_minus: Option<char>) -> Result<bool, ()> {
Ok(maybe_minus.is_none()) // None -> true, Some -> false
}
preceded(
nomtag("depends:"),
pair(map_res(opt(char('-')), to_bool), id_list),
)(input)
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
use taskchampion::chrono::Duration;
#[test]
fn test_colon_prefix() {
assert_eq!(colon_prefix("foo")("foo:abc").unwrap().1, "abc");
assert_eq!(colon_prefix("foo")("foo:").unwrap().1, "");
assert!(colon_prefix("foo")("foo").is_err());
}
#[test]
fn test_status_colon() {
assert_eq!(status_colon("status:pending").unwrap().1, Status::Pending);
assert_eq!(
status_colon("status:completed").unwrap().1,
Status::Completed
);
assert_eq!(status_colon("status:deleted").unwrap().1, Status::Deleted);
assert!(status_colon("status:foo").is_err());
assert!(status_colon("status:complete").is_err());
assert!(status_colon("status").is_err());
}
#[test]
fn test_wait() {
assert_eq!(wait_colon("wait:").unwrap(), ("", None));
let one_day = *NOW + Duration::days(1);
assert_eq!(wait_colon("wait:1d").unwrap(), ("", Some(one_day)));
let one_day = *NOW + Duration::days(1);
assert_eq!(wait_colon("wait:1d2").unwrap(), ("2", Some(one_day)));
}
}

View file

@ -0,0 +1,140 @@
use nom::{branch::*, character::complete::*, combinator::*, multi::*, sequence::*, IResult};
use taskchampion::Uuid;
/// A task identifier, as given in a filter command-line expression
#[derive(Debug, PartialEq, Eq, Hash, Clone)]
pub(crate) enum TaskId {
/// A small integer identifying a working-set task
WorkingSetId(usize),
/// A full Uuid specifically identifying a task
Uuid(Uuid),
/// A prefix of a Uuid
PartialUuid(String),
}
/// Recognizes a comma-separated list of TaskIds
pub(crate) fn id_list(input: &str) -> IResult<&str, Vec<TaskId>> {
fn hex_n(n: usize) -> impl Fn(&str) -> IResult<&str, &str> {
move |input: &str| recognize(many_m_n(n, n, one_of(&b"0123456789abcdefABCDEF"[..])))(input)
}
fn uuid(input: &str) -> Result<TaskId, ()> {
Ok(TaskId::Uuid(Uuid::parse_str(input).map_err(|_| ())?))
}
fn partial_uuid(input: &str) -> Result<TaskId, ()> {
Ok(TaskId::PartialUuid(input.to_owned()))
}
fn working_set_id(input: &str) -> Result<TaskId, ()> {
Ok(TaskId::WorkingSetId(input.parse().map_err(|_| ())?))
}
all_consuming(separated_list1(
char(','),
alt((
map_res(
recognize(tuple((
hex_n(8),
char('-'),
hex_n(4),
char('-'),
hex_n(4),
char('-'),
hex_n(4),
char('-'),
hex_n(12),
))),
uuid,
),
map_res(
recognize(tuple((
hex_n(8),
char('-'),
hex_n(4),
char('-'),
hex_n(4),
char('-'),
hex_n(4),
))),
partial_uuid,
),
map_res(
recognize(tuple((hex_n(8), char('-'), hex_n(4), char('-'), hex_n(4)))),
partial_uuid,
),
map_res(
recognize(tuple((hex_n(8), char('-'), hex_n(4)))),
partial_uuid,
),
map_res(hex_n(8), partial_uuid),
// note that an 8-decimal-digit value will be treated as a UUID
map_res(digit1, working_set_id),
)),
))(input)
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_id_list_single() {
assert_eq!(id_list("123").unwrap().1, vec![TaskId::WorkingSetId(123)]);
}
#[test]
fn test_id_list_uuids() {
assert_eq!(
id_list("12341234").unwrap().1,
vec![TaskId::PartialUuid(s!("12341234"))]
);
assert_eq!(
id_list("1234abcd").unwrap().1,
vec![TaskId::PartialUuid(s!("1234abcd"))]
);
assert_eq!(
id_list("abcd1234").unwrap().1,
vec![TaskId::PartialUuid(s!("abcd1234"))]
);
assert_eq!(
id_list("abcd1234-1234").unwrap().1,
vec![TaskId::PartialUuid(s!("abcd1234-1234"))]
);
assert_eq!(
id_list("abcd1234-1234-2345").unwrap().1,
vec![TaskId::PartialUuid(s!("abcd1234-1234-2345"))]
);
assert_eq!(
id_list("abcd1234-1234-2345-3456").unwrap().1,
vec![TaskId::PartialUuid(s!("abcd1234-1234-2345-3456"))]
);
assert_eq!(
id_list("abcd1234-1234-2345-3456-0123456789ab").unwrap().1,
vec![TaskId::Uuid(
Uuid::parse_str("abcd1234-1234-2345-3456-0123456789ab").unwrap()
)]
);
}
#[test]
fn test_id_list_invalid_partial_uuids() {
assert!(id_list("abcd123").is_err());
assert!(id_list("abcd12345").is_err());
assert!(id_list("abcd1234-").is_err());
assert!(id_list("abcd1234-123").is_err());
assert!(id_list("abcd1234-1234-").is_err());
assert!(id_list("abcd1234-12345-").is_err());
assert!(id_list("abcd1234-1234-2345-3456-0123456789ab-").is_err());
}
#[test]
fn test_id_list_uuids_mixed() {
assert_eq!(id_list("abcd1234,abcd1234-1234,abcd1234-1234-2345,abcd1234-1234-2345-3456,abcd1234-1234-2345-3456-0123456789ab").unwrap().1,
vec![TaskId::PartialUuid(s!("abcd1234")),
TaskId::PartialUuid(s!("abcd1234-1234")),
TaskId::PartialUuid(s!("abcd1234-1234-2345")),
TaskId::PartialUuid(s!("abcd1234-1234-2345-3456")),
TaskId::Uuid(Uuid::parse_str("abcd1234-1234-2345-3456-0123456789ab").unwrap()),
]);
}
}

View file

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

View file

@ -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::{depends_colon, status_colon, wait_colon};
pub(crate) use idlist::{id_list, TaskId};
pub(crate) use misc::{any, literal, report_name};
pub(crate) use tags::{minus_tag, plus_tag};
#[allow(unused_imports)]
pub(crate) use time::{duration, timestamp};

View file

@ -0,0 +1,35 @@
use nom::{character::complete::*, combinator::*, sequence::*, IResult};
use std::convert::TryFrom;
use taskchampion::Tag;
/// Recognizes a tag prefixed with `+` and returns the tag value
pub(crate) fn plus_tag(input: &str) -> IResult<&str, Tag> {
preceded(char('+'), map_res(rest, Tag::try_from))(input)
}
/// Recognizes a tag prefixed with `-` and returns the tag value
pub(crate) fn minus_tag(input: &str) -> IResult<&str, Tag> {
preceded(char('-'), map_res(rest, Tag::try_from))(input)
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_plus_tag() {
assert_eq!(plus_tag("+abc").unwrap().1, tag!("abc"));
assert_eq!(plus_tag("+abc123").unwrap().1, tag!("abc123"));
assert!(plus_tag("-abc123").is_err());
assert!(plus_tag("+1abc").is_err());
}
#[test]
fn test_minus_tag() {
assert_eq!(minus_tag("-abc").unwrap().1, tag!("abc"));
assert_eq!(minus_tag("-abc123").unwrap().1, tag!("abc123"));
assert!(minus_tag("+abc123").is_err());
assert!(minus_tag("-1abc").is_err());
}
}

View file

@ -0,0 +1,492 @@
use iso8601_duration::Duration as IsoDuration;
use lazy_static::lazy_static;
use nom::{
branch::*,
bytes::complete::*,
character::complete::*,
character::*,
combinator::*,
error::{Error, ErrorKind},
multi::*,
sequence::*,
Err, IResult,
};
use std::str::FromStr;
use taskchampion::chrono::{self, prelude::*, Duration};
// https://taskwarrior.org/docs/dates.html
// https://taskwarrior.org/docs/named_dates.html
// https://taskwarrior.org/docs/durations.html
/// A case for matching durations. If `.3` is true, then the value can be used
/// without a prefix, e.g., `minute`. If false, it cannot, e.g., `minutes`
#[derive(Debug)]
struct DurationCase(&'static str, Duration, bool);
// https://github.com/GothenburgBitFactory/libshared/blob/9a5f24e2acb38d05afb8f8e316a966dee196a42a/src/Duration.cpp#L50
// TODO: use const when chrono supports it
lazy_static! {
static ref DURATION_CASES: Vec<DurationCase> = vec![
DurationCase("days", Duration::days(1), false),
DurationCase("day", Duration::days(1), true),
DurationCase("d", Duration::days(1), false),
DurationCase("hours", Duration::hours(1), false),
DurationCase("hour", Duration::hours(1), true),
DurationCase("h", Duration::hours(1), false),
DurationCase("minutes", Duration::minutes(1), false),
DurationCase("minute", Duration::minutes(1), true),
DurationCase("mins", Duration::minutes(1), false),
DurationCase("min", Duration::minutes(1), true),
DurationCase("months", Duration::days(30), false),
DurationCase("month", Duration::days(30), true),
DurationCase("mo", Duration::days(30), true),
DurationCase("seconds", Duration::seconds(1), false),
DurationCase("second", Duration::seconds(1), true),
DurationCase("s", Duration::seconds(1), false),
DurationCase("weeks", Duration::days(7), false),
DurationCase("week", Duration::days(7), true),
DurationCase("w", Duration::days(7), false),
DurationCase("years", Duration::days(365), false),
DurationCase("year", Duration::days(365), true),
DurationCase("y", Duration::days(365), false),
];
}
/// Parses suffixes like 'min', and 'd'; standalone is true if there is no numeric prefix, in which
/// case plurals (like `days`) are not matched.
fn duration_suffix(has_prefix: bool) -> impl Fn(&str) -> IResult<&str, Duration> {
move |input: &str| {
// Rust wants this to have a default value, but it is not actually used
// because DURATION_CASES has at least one case with case.2 == `true`
let mut res = Err(Err::Failure(Error::new(input, ErrorKind::Tag)));
for case in DURATION_CASES.iter() {
if !case.2 && !has_prefix {
// this case requires a prefix, and input does not have one
continue;
}
res = tag(case.0)(input);
match res {
Ok((i, _)) => {
return Ok((i, case.1));
}
Err(Err::Error(_)) => {
// recoverable error
continue;
}
Err(e) => {
// irrecoverable error
return Err(e);
}
}
}
// return the last error
Err(res.unwrap_err())
}
}
/// Calculate the multiplier for a decimal prefix; this uses integer math
/// where possible, falling back to floating-point math on seconds
fn decimal_prefix_multiplier(input: &str) -> IResult<&str, f64> {
map_res(
// recognize NN or NN.NN
alt((recognize(tuple((digit1, char('.'), digit1))), digit1)),
|input: &str| -> Result<f64, <f64 as FromStr>::Err> {
let mul = input.parse::<f64>()?;
Ok(mul)
},
)(input)
}
/// Parse an iso8601 duration, converting it to a [`chrono::Duration`] on the assumption
/// that a year is 365 days and a month is 30 days.
fn iso8601_dur(input: &str) -> IResult<&str, Duration> {
if let Ok(iso_dur) = IsoDuration::parse(input) {
// iso8601_duration uses f32, but f32 underflows seconds for values as small as
// a year. So we upgrade to f64 immediately. f64 has a 53-bit mantissa which can
// represent almost 300 million years without underflow, so it should be adequate.
let days = iso_dur.year as f64 * 365.0 + iso_dur.month as f64 * 30.0 + iso_dur.day as f64;
let hours = days * 24.0 + iso_dur.hour as f64;
let mins = hours * 60.0 + iso_dur.minute as f64;
let secs = mins * 60.0 + iso_dur.second as f64;
let dur = Duration::seconds(secs as i64);
Ok((&input[input.len()..], dur))
} else {
Err(Err::Error(Error::new(input, ErrorKind::Tag)))
}
}
/// Recognizes durations
pub(crate) fn duration(input: &str) -> IResult<&str, Duration> {
alt((
map_res(
tuple((
decimal_prefix_multiplier,
multispace0,
duration_suffix(true),
)),
|input: (f64, &str, Duration)| -> Result<Duration, ()> {
// `as i64` is saturating, so for large offsets this will
// just pick an imprecise very-futuristic date
let secs = (input.0 * input.2.num_seconds() as f64) as i64;
Ok(Duration::seconds(secs))
},
),
duration_suffix(false),
iso8601_dur,
))(input)
}
/// Parse a rfc3339 datestamp
fn rfc3339_timestamp(input: &str) -> IResult<&str, DateTime<Utc>> {
if let Ok(dt) = DateTime::parse_from_rfc3339(input) {
// convert to UTC and truncate seconds
let dt = dt.with_timezone(&Utc).trunc_subsecs(0);
Ok((&input[input.len()..], dt))
} else {
Err(Err::Error(Error::new(input, ErrorKind::Tag)))
}
}
fn named_date<Tz: TimeZone>(
now: DateTime<Utc>,
local: Tz,
) -> impl Fn(&str) -> IResult<&str, DateTime<Utc>> {
move |input: &str| {
let local_today = now.with_timezone(&local).date();
let remaining = &input[input.len()..];
let day_index = local_today.weekday().num_days_from_monday();
match input {
"yesterday" => Ok((remaining, local_today - Duration::days(1))),
"today" => Ok((remaining, local_today)),
"tomorrow" => Ok((remaining, local_today + Duration::days(1))),
// TODO: lots more!
"eod" => Ok((remaining, local_today + Duration::days(1))),
"sod" => Ok((remaining, local_today)),
"eow" => Ok((
remaining,
local_today + Duration::days((6 - day_index).into()),
)),
"eoww" => Ok((
remaining,
local_today + Duration::days((5 - day_index).into()),
)),
"sow" => Ok((
remaining,
local_today + Duration::days((6 - day_index).into()),
)),
"soww" => Ok((
remaining,
local_today + Duration::days((7 - day_index).into()),
)),
_ => Err(Err::Error(Error::new(input, ErrorKind::Tag))),
}
.map(|(rem, dt)| (rem, dt.and_hms(0, 0, 0).with_timezone(&Utc)))
}
}
/// recognize a digit
fn digit(input: &str) -> IResult<&str, char> {
satisfy(|c| is_digit(c as u8))(input)
}
/// Parse yyyy-mm-dd as the given date, at the local midnight
fn yyyy_mm_dd<Tz: TimeZone>(local: Tz) -> impl Fn(&str) -> IResult<&str, DateTime<Utc>> {
move |input: &str| {
fn parse_int<T: FromStr>(input: &str) -> Result<T, <T as FromStr>::Err> {
input.parse::<T>()
}
map_res(
tuple((
map_res(recognize(count(digit, 4)), parse_int::<i32>),
char('-'),
map_res(recognize(many_m_n(1, 2, digit)), parse_int::<u32>),
char('-'),
map_res(recognize(many_m_n(1, 2, digit)), parse_int::<u32>),
)),
|input: (i32, char, u32, char, u32)| -> Result<DateTime<Utc>, ()> {
// try to convert, handling out-of-bounds months or days as an error
let ymd = match local.ymd_opt(input.0, input.2, input.4) {
chrono::LocalResult::Single(ymd) => Ok(ymd),
_ => Err(()),
}?;
Ok(ymd.and_hms(0, 0, 0).with_timezone(&Utc))
},
)(input)
}
}
/// Recognizes timestamps
pub(crate) fn timestamp<Tz: TimeZone + Copy>(
now: DateTime<Utc>,
local: Tz,
) -> impl Fn(&str) -> IResult<&str, DateTime<Utc>> {
move |input: &str| {
alt((
// relative time
map_res(
duration,
|duration: Duration| -> Result<DateTime<Utc>, ()> { Ok(now + duration) },
),
rfc3339_timestamp,
yyyy_mm_dd(local),
value(now, tag("now")),
named_date(now, local),
))(input)
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::argparse::NOW;
use pretty_assertions::assert_eq;
use rstest::rstest;
const M: i64 = 60;
const H: i64 = M * 60;
const DAY: i64 = H * 24;
const MONTH: i64 = DAY * 30;
const YEAR: i64 = DAY * 365;
// TODO: use const when chrono supports it
lazy_static! {
// India standard time (not an even multiple of hours)
static ref IST: FixedOffset = FixedOffset::east(5 * 3600 + 30 * 60);
// Utc, but as a FixedOffset TimeZone impl
static ref UTC_FO: FixedOffset = FixedOffset::east(0);
// Hawaii
static ref HST: FixedOffset = FixedOffset::west(10 * 3600);
}
/// test helper to ensure that the entire input is consumed
fn complete_duration(input: &str) -> IResult<&str, Duration> {
all_consuming(duration)(input)
}
/// test helper to ensure that the entire input is consumed
fn complete_timestamp<Tz: TimeZone + Copy>(
now: DateTime<Utc>,
local: Tz,
) -> impl Fn(&str) -> IResult<&str, DateTime<Utc>> {
move |input: &str| all_consuming(timestamp(now, local))(input)
}
/// Shorthand day and time
fn dt(y: i32, m: u32, d: u32, hh: u32, mm: u32, ss: u32) -> DateTime<Utc> {
Utc.ymd(y, m, d).and_hms(hh, mm, ss)
}
/// Local day and time, parameterized on the timezone
fn ldt(
y: i32,
m: u32,
d: u32,
hh: u32,
mm: u32,
ss: u32,
) -> Box<dyn Fn(FixedOffset) -> DateTime<Utc>> {
Box::new(move |tz| tz.ymd(y, m, d).and_hms(hh, mm, ss).with_timezone(&Utc))
}
fn ld(y: i32, m: u32, d: u32) -> Box<dyn Fn(FixedOffset) -> DateTime<Utc>> {
ldt(y, m, d, 0, 0, 0)
}
#[rstest]
#[case::rel_hours_0(dt(2021, 5, 29, 1, 30, 0), "0h", dt(2021, 5, 29, 1, 30, 0))]
#[case::rel_hours_05(dt(2021, 5, 29, 1, 30, 0), "0.5h", dt(2021, 5, 29, 2, 0, 0))]
#[case::rel_hours_no_prefix(dt(2021, 5, 29, 1, 30, 0), "hour", dt(2021, 5, 29, 2, 30, 0))]
#[case::rel_hours_5(dt(2021, 5, 29, 1, 30, 0), "5h", dt(2021, 5, 29, 6, 30, 0))]
#[case::rel_days_0(dt(2021, 5, 29, 1, 30, 0), "0d", dt(2021, 5, 29, 1, 30, 0))]
#[case::rel_days_10(dt(2021, 5, 29, 1, 30, 0), "10d", dt(2021, 6, 8, 1, 30, 0))]
#[case::rfc3339_datetime(*NOW, "2019-10-12T07:20:50.12Z", dt(2019, 10, 12, 7, 20, 50))]
#[case::now(*NOW, "now", *NOW)]
/// Cases where the `local` parameter is ignored
fn test_nonlocal_timestamp(
#[case] now: DateTime<Utc>,
#[case] input: &'static str,
#[case] output: DateTime<Utc>,
) {
let (_, res) = complete_timestamp(now, *IST)(input).unwrap();
assert_eq!(res, output, "parsing {:?}", input);
}
#[rstest]
/// Cases where the `local` parameter matters
#[case::yyyy_mm_dd(ld(2000, 1, 1), "2021-01-01", ld(2021, 1, 1))]
#[case::yyyy_m_d(ld(2000, 1, 1), "2021-1-1", ld(2021, 1, 1))]
#[case::yesterday(ld(2021, 3, 1), "yesterday", ld(2021, 2, 28))]
#[case::yesterday_from_evening(ldt(2021, 3, 1, 21, 30, 30), "yesterday", ld(2021, 2, 28))]
#[case::today(ld(2021, 3, 1), "today", ld(2021, 3, 1))]
#[case::today_from_evening(ldt(2021, 3, 1, 21, 30, 30), "today", ld(2021, 3, 1))]
#[case::tomorrow(ld(2021, 3, 1), "tomorrow", ld(2021, 3, 2))]
#[case::tomorow_from_evening(ldt(2021, 3, 1, 21, 30, 30), "tomorrow", ld(2021, 3, 2))]
#[case::end_of_week(ld(2021, 8, 25,), "eow", ld(2021, 8, 29))]
#[case::end_of_work_week(ld(2021, 8, 25), "eoww", ld(2021, 8, 28))]
#[case::start_of_week(ld(2021, 8, 25), "sow", ld(2021, 8, 29))]
#[case::start_of_work_week(ld(2021, 8, 25), "soww", ld(2021, 8, 30))]
#[case::end_of_today(ld(2021, 8, 25), "eod", ld(2021, 8, 26))]
#[case::start_of_today(ld(2021, 8, 25), "sod", ld(2021, 8, 25))]
fn test_local_timestamp(
#[case] now: Box<dyn Fn(FixedOffset) -> DateTime<Utc>>,
#[values(*IST, *UTC_FO, *HST)] tz: FixedOffset,
#[case] input: &str,
#[case] output: Box<dyn Fn(FixedOffset) -> DateTime<Utc>>,
) {
let now = now(tz);
let output = output(tz);
let (_, res) = complete_timestamp(now, tz)(input).unwrap();
assert_eq!(
res, output,
"parsing {:?} relative to {:?} in timezone {:?}",
input, now, tz
);
}
#[rstest]
#[case::rfc3339_datetime_bad_month(*NOW, "2019-10-99T07:20:50.12Z")]
#[case::yyyy_mm_dd_bad_month(*NOW, "2019-10-99")]
fn test_timestamp_err(#[case] now: DateTime<Utc>, #[case] input: &'static str) {
let res = complete_timestamp(now, Utc)(input);
assert!(
res.is_err(),
"expected error parsing {:?}, got {:?}",
input,
res.unwrap()
);
}
// All test cases from
// https://github.com/GothenburgBitFactory/libshared/blob/9a5f24e2acb38d05afb8f8e316a966dee196a42a/test/duration.t.cpp#L136
#[rstest]
#[case("0seconds", 0)]
#[case("2 seconds", 2)]
#[case("10seconds", 10)]
#[case("1.5seconds", 1)]
#[case("0second", 0)]
#[case("2 second", 2)]
#[case("10second", 10)]
#[case("1.5second", 1)]
#[case("0s", 0)]
#[case("2 s", 2)]
#[case("10s", 10)]
#[case("1.5s", 1)]
#[case("0minutes", 0)]
#[case("2 minutes", 2 * M)]
#[case("10minutes", 10 * M)]
#[case("1.5minutes", M + 30)]
#[case("0minute", 0)]
#[case("2 minute", 2 * M)]
#[case("10minute", 10 * M)]
#[case("1.5minute", M + 30)]
#[case("0min", 0)]
#[case("2 min", 2 * M)]
#[case("10min", 10 * M)]
#[case("1.5min", M + 30)]
#[case("0hours", 0)]
#[case("2 hours", 2 * H)]
#[case("10hours", 10 * H)]
#[case("1.5hours", H + 30 * M)]
#[case("0hour", 0)]
#[case("2 hour", 2 * H)]
#[case("10hour", 10 * H)]
#[case("1.5hour", H + 30 * M)]
#[case("0h", 0)]
#[case("2 h", 2 * H)]
#[case("10h", 10 * H)]
#[case("1.5h", H + 30 * M)]
#[case("0days", 0)]
#[case("2 days", 2 * DAY)]
#[case("10days", 10 * DAY)]
#[case("1.5days", DAY + 12 * H)]
#[case("0day", 0)]
#[case("2 day", 2 * DAY)]
#[case("10day", 10 * DAY)]
#[case("1.5day", DAY + 12 * H)]
#[case("0d", 0)]
#[case("2 d", 2 * DAY)]
#[case("10d", 10 * DAY)]
#[case("1.5d", DAY + 12 * H)]
#[case("0weeks", 0)]
#[case("2 weeks", 14 * DAY)]
#[case("10weeks", 70 * DAY)]
#[case("1.5weeks", 10 * DAY + 12 * H)]
#[case("0week", 0)]
#[case("2 week", 14 * DAY)]
#[case("10week", 70 * DAY)]
#[case("1.5week", 10 * DAY + 12 * H)]
#[case("0w", 0)]
#[case("2 w", 14 * DAY)]
#[case("10w", 70 * DAY)]
#[case("1.5w", 10 * DAY + 12 * H)]
#[case("0months", 0)]
#[case("2 months", 60 * DAY)]
#[case("10months", 300 * DAY)]
#[case("1.5months", 45 * DAY)]
#[case("0month", 0)]
#[case("2 month", 60 * DAY)]
#[case("10month", 300 * DAY)]
#[case("1.5month", 45 * DAY)]
#[case("0mo", 0)]
#[case("2 mo", 60 * DAY)]
#[case("10mo", 300 * DAY)]
#[case("1.5mo", 45 * DAY)]
#[case("0years", 0)]
#[case("2 years", 2 * YEAR)]
#[case("10years", 10 * YEAR)]
#[case("1.5years", 547 * DAY + 12 * H)]
#[case("0year", 0)]
#[case("2 year", 2 * YEAR)]
#[case("10year", 10 * YEAR)]
#[case("1.5year", 547 * DAY + 12 * H)]
#[case("0y", 0)]
#[case("2 y", 2 * YEAR)]
#[case("10y", 10 * YEAR)]
#[case("1.5y", 547 * DAY + 12 * H)]
fn test_duration_units(#[case] input: &'static str, #[case] seconds: i64) {
let (_, res) = complete_duration(input).expect(input);
assert_eq!(res.num_seconds(), seconds, "parsing {}", input);
}
#[rstest]
#[case("years")]
#[case("minutes")]
#[case("eons")]
#[case("P1S")] // missing T
#[case("p1y")] // lower-case
fn test_duration_errors(#[case] input: &'static str) {
let res = complete_duration(input);
assert!(
res.is_err(),
"did not get expected error parsing duration {:?}; got {:?}",
input,
res.unwrap()
);
}
// https://github.com/GothenburgBitFactory/libshared/blob/9a5f24e2acb38d05afb8f8e316a966dee196a42a/test/duration.t.cpp#L115
#[rstest]
#[case("P1Y", YEAR)]
#[case("P1M", MONTH)]
#[case("P1D", DAY)]
#[case("P1Y1M", YEAR + MONTH)]
#[case("P1Y1D", YEAR + DAY)]
#[case("P1M1D", MONTH + DAY)]
#[case("P1Y1M1D", YEAR + MONTH + DAY)]
#[case("PT1H", H)]
#[case("PT1M", M)]
#[case("PT1S", 1)]
#[case("PT1H1M", H + M)]
#[case("PT1H1S", H + 1)]
#[case("PT1M1S", M + 1)]
#[case("PT1H1M1S", H + M + 1)]
#[case("P1Y1M1DT1H1M1S", YEAR + MONTH + DAY + H + M + 1)]
#[case("PT24H", DAY)]
#[case("PT40000000S", 40000000)]
#[case("PT3600S", H)]
#[case("PT60M", H)]
fn test_duration_8601(#[case] input: &'static str, #[case] seconds: i64) {
let (_, res) = complete_duration(input).expect(input);
assert_eq!(res.num_seconds(), seconds, "parsing {}", input);
}
}

View file

@ -0,0 +1,86 @@
use super::args::*;
use super::{ArgList, Subcommand};
use nom::{combinator::*, sequence::*, Err, IResult};
/// A command is the overall command that the CLI should execute.
///
/// It consists of some information common to all commands and a `Subcommand` identifying the
/// particular kind of behavior desired.
#[derive(Debug, PartialEq)]
pub(crate) struct Command {
pub(crate) command_name: String,
pub(crate) subcommand: Subcommand,
}
impl Command {
pub(super) fn parse(input: ArgList) -> IResult<ArgList, Command> {
fn to_command(input: (&str, Subcommand)) -> Result<Command, ()> {
// Clean up command name, so `./target/bin/ta` to `ta` etc
let command_name: String = std::path::PathBuf::from(&input.0)
.file_name()
// Convert to string, very unlikely to contain non-UTF8
.map(|x| x.to_string_lossy().to_string())
.unwrap_or_else(|| input.0.to_owned());
let command = Command {
command_name,
subcommand: input.1,
};
Ok(command)
}
map_res(
all_consuming(tuple((arg_matching(any), Subcommand::parse))),
to_command,
)(input)
}
/// Parse a command from the given list of strings.
pub fn from_argv(argv: &[&str]) -> Result<Command, crate::Error> {
match Command::parse(argv) {
Ok((&[], cmd)) => Ok(cmd),
Ok((trailing, _)) => Err(crate::Error::for_arguments(format!(
"command line has trailing arguments: {:?}",
trailing
))),
Err(Err::Incomplete(_)) => unreachable!(),
Err(Err::Error(e)) => Err(crate::Error::for_arguments(format!(
"command line not recognized: {:?}",
e
))),
Err(Err::Failure(e)) => Err(crate::Error::for_arguments(format!(
"command line not recognized: {:?}",
e
))),
}
}
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
// NOTE: most testing of specific subcommands is handled in `subcommand.rs`.
#[test]
fn test_version() {
assert_eq!(
Command::from_argv(argv!["ta", "version"]).unwrap(),
Command {
subcommand: Subcommand::Version,
command_name: s!("ta"),
}
);
}
#[test]
fn test_cleaning_command_name() {
assert_eq!(
Command::from_argv(argv!["/tmp/ta", "version"]).unwrap(),
Command {
subcommand: Subcommand::Version,
command_name: s!("ta"),
}
);
}
}

View file

@ -0,0 +1,44 @@
use super::args::{any, arg_matching, literal};
use super::ArgList;
use crate::usage;
use nom::{branch::alt, combinator::*, sequence::*, IResult};
#[derive(Debug, PartialEq)]
/// A config operation
pub(crate) enum ConfigOperation {
/// Set a configuration value
Set(String, String),
/// Show configuration path
Path,
}
impl ConfigOperation {
pub(super) fn parse(input: ArgList) -> IResult<ArgList, ConfigOperation> {
fn set_to_op(input: (&str, &str, &str)) -> Result<ConfigOperation, ()> {
Ok(ConfigOperation::Set(input.1.to_owned(), input.2.to_owned()))
}
fn path_to_op(_: &str) -> Result<ConfigOperation, ()> {
Ok(ConfigOperation::Path)
}
alt((
map_res(
tuple((
arg_matching(literal("set")),
arg_matching(any),
arg_matching(any),
)),
set_to_op,
),
map_res(arg_matching(literal("path")), path_to_op),
))(input)
}
pub(super) fn get_usage(u: &mut usage::Usage) {
u.subcommands.push(usage::Subcommand {
name: "config set",
syntax: "config set <key> <value>",
summary: "Set a configuration value",
description: "Update Taskchampion configuration file to set key = value",
});
}
}

View file

@ -0,0 +1,400 @@
use super::args::{arg_matching, id_list, literal, minus_tag, plus_tag, status_colon, TaskId};
use super::ArgList;
use crate::usage;
use anyhow::bail;
use nom::{
branch::alt,
combinator::*,
multi::{fold_many0, fold_many1},
IResult,
};
use taskchampion::{Status, Tag};
/// A filter represents a selection of a particular set of tasks.
///
/// A filter has a "universe" of tasks that might match, and a list of conditions
/// all of which tasks must match. The universe can be a set of task IDs, or just
/// pending tasks, or all tasks.
#[derive(Debug, PartialEq, Default, Clone)]
pub(crate) struct Filter {
/// A set of filter conditions, all of which must match a task in order for that task to be
/// selected.
pub(crate) conditions: Vec<Condition>,
}
/// A condition which tasks must match to be accepted by the filter.
#[derive(Debug, PartialEq, Clone)]
pub(crate) enum Condition {
/// Task has the given tag
HasTag(Tag),
/// Task does not have the given tag
NoTag(Tag),
/// Task has the given status
Status(Status),
/// Task has one of the given IDs
IdList(Vec<TaskId>),
}
impl Condition {
fn parse(input: ArgList) -> IResult<ArgList, Condition> {
alt((
Self::parse_id_list,
Self::parse_plus_tag,
Self::parse_minus_tag,
Self::parse_status,
))(input)
}
/// Parse a single condition string
pub(crate) fn parse_str(input: &str) -> anyhow::Result<Condition> {
let input = &[input];
Ok(match Condition::parse(input) {
Ok((&[], cond)) => cond,
Ok(_) => unreachable!(), // input only has one element
Err(nom::Err::Incomplete(_)) => unreachable!(),
Err(nom::Err::Error(e)) => bail!("invalid filter condition: {:?}", e),
Err(nom::Err::Failure(e)) => bail!("invalid filter condition: {:?}", e),
})
}
fn parse_id_list(input: ArgList) -> IResult<ArgList, Condition> {
fn to_condition(input: Vec<TaskId>) -> Result<Condition, ()> {
Ok(Condition::IdList(input))
}
map_res(arg_matching(id_list), to_condition)(input)
}
fn parse_plus_tag(input: ArgList) -> IResult<ArgList, Condition> {
fn to_condition(input: Tag) -> Result<Condition, ()> {
Ok(Condition::HasTag(input))
}
map_res(arg_matching(plus_tag), to_condition)(input)
}
fn parse_minus_tag(input: ArgList) -> IResult<ArgList, Condition> {
fn to_condition(input: Tag) -> Result<Condition, ()> {
Ok(Condition::NoTag(input))
}
map_res(arg_matching(minus_tag), to_condition)(input)
}
fn parse_status(input: ArgList) -> IResult<ArgList, Condition> {
fn to_condition(input: Status) -> Result<Condition, ()> {
Ok(Condition::Status(input))
}
map_res(arg_matching(status_colon), to_condition)(input)
}
}
impl Filter {
/// Parse a filter that can include an empty set of args (meaning
/// all tasks)
pub(super) fn parse0(input: ArgList) -> IResult<ArgList, Filter> {
fold_many0(
Condition::parse,
Filter {
..Default::default()
},
|acc, arg| acc.with_arg(arg),
)(input)
}
/// Parse a filter that must have at least one arg, which can be `all`
/// to mean all tasks
pub(super) fn parse1(input: ArgList) -> IResult<ArgList, Filter> {
alt((
Filter::parse_all,
fold_many1(
Condition::parse,
Filter {
..Default::default()
},
|acc, arg| acc.with_arg(arg),
),
))(input)
}
fn parse_all(input: ArgList) -> IResult<ArgList, Filter> {
fn to_filter(_: &str) -> Result<Filter, ()> {
Ok(Filter {
..Default::default()
})
}
map_res(arg_matching(literal("all")), to_filter)(input)
}
/// fold multiple filter args into a single Filter instance
fn with_arg(mut self, cond: Condition) -> Filter {
if let Condition::IdList(mut id_list) = cond {
// If there is already an IdList condition, concatenate this one
// to it. Thus multiple IdList command-line args represent an OR
// operation. This assumes that the filter is still being built
// from command-line arguments and thus has at most one IdList
// condition.
if let Some(Condition::IdList(existing)) = self
.conditions
.iter_mut()
.find(|c| matches!(c, Condition::IdList(_)))
{
existing.append(&mut id_list);
} else {
self.conditions.push(Condition::IdList(id_list));
}
} else {
// all other command-line conditions are AND'd together
self.conditions.push(cond);
}
self
}
/// combine this filter with another filter in an AND operation
pub(crate) fn intersect(mut self, mut other: Filter) -> Filter {
// simply concatenate the conditions
self.conditions.append(&mut other.conditions);
self
}
// usage
pub(super) fn get_usage(u: &mut usage::Usage) {
u.filters.push(usage::Filter {
syntax: "TASKID[,TASKID,..]",
summary: "Specific tasks",
description: "
Select only specific tasks. Multiple tasks can be specified either separated by
commas or as separate arguments. Each task may be specfied by its working-set
index (a small number) or by its UUID. Partial UUIDs, broken on a hyphen, are
also supported, such as `b5664ef8-423d` or `b5664ef8`.",
});
u.filters.push(usage::Filter {
syntax: "+TAG",
summary: "Tagged tasks",
description: "
Select tasks with the given tag.",
});
u.filters.push(usage::Filter {
syntax: "-TAG",
summary: "Un-tagged tasks",
description: "
Select tasks that do not have the given tag.",
});
u.filters.push(usage::Filter {
syntax: "status:pending, status:completed, status:deleted",
summary: "Task status",
description: "
Select tasks with the given status.",
});
u.filters.push(usage::Filter {
syntax: "all",
summary: "All tasks",
description: "
When specified alone for task-modification commands, `all` matches all tasks.
For example, `task all done` will mark all tasks as done.",
});
}
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn test_empty_parse0() {
let (input, filter) = Filter::parse0(argv![]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
filter,
Filter {
..Default::default()
}
);
}
#[test]
fn test_empty_parse1() {
// parse1 does not allow empty input
assert!(Filter::parse1(argv![]).is_err());
}
#[test]
fn test_all_parse0() {
let (input, _) = Filter::parse0(argv!["all"]).unwrap();
assert_eq!(input.len(), 1); // did not parse "all"
}
#[test]
fn test_all_parse1() {
let (input, filter) = Filter::parse1(argv!["all"]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
filter,
Filter {
..Default::default()
}
);
}
#[test]
fn test_all_with_other_stuff() {
let (input, filter) = Filter::parse1(argv!["all", "+foo"]).unwrap();
// filter ends after `all`
assert_eq!(input.len(), 1);
assert_eq!(
filter,
Filter {
..Default::default()
}
);
}
#[test]
fn test_id_list_single() {
let (input, filter) = Filter::parse0(argv!["1"]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
filter,
Filter {
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(1)])],
}
);
}
#[test]
fn test_id_list_commas() {
let (input, filter) = Filter::parse0(argv!["1,2,3"]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
filter,
Filter {
conditions: vec![Condition::IdList(vec![
TaskId::WorkingSetId(1),
TaskId::WorkingSetId(2),
TaskId::WorkingSetId(3),
])],
}
);
}
#[test]
fn test_id_list_multi_arg() {
let (input, filter) = Filter::parse0(argv!["1,2", "3,4"]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
filter,
Filter {
conditions: vec![Condition::IdList(vec![
TaskId::WorkingSetId(1),
TaskId::WorkingSetId(2),
TaskId::WorkingSetId(3),
TaskId::WorkingSetId(4),
])],
}
);
}
#[test]
fn test_id_list_uuids() {
let (input, filter) = Filter::parse0(argv!["1,abcd1234"]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
filter,
Filter {
conditions: vec![Condition::IdList(vec![
TaskId::WorkingSetId(1),
TaskId::PartialUuid(s!("abcd1234")),
])],
}
);
}
#[test]
fn test_tags() {
let (input, filter) = Filter::parse0(argv!["1", "+yes", "-no"]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
filter,
Filter {
conditions: vec![
Condition::IdList(vec![TaskId::WorkingSetId(1),]),
Condition::HasTag(tag!("yes")),
Condition::NoTag(tag!("no")),
],
}
);
}
#[test]
fn test_status() {
let (input, filter) = Filter::parse0(argv!["status:completed", "status:pending"]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
filter,
Filter {
conditions: vec![
Condition::Status(Status::Completed),
Condition::Status(Status::Pending),
],
}
);
}
#[test]
fn intersect_idlist_idlist() {
let left = Filter::parse0(argv!["1,2", "+yes"]).unwrap().1;
let right = Filter::parse0(argv!["2,3", "+no"]).unwrap().1;
let both = left.intersect(right);
assert_eq!(
both,
Filter {
conditions: vec![
// from first filter
Condition::IdList(vec![TaskId::WorkingSetId(1), TaskId::WorkingSetId(2),]),
Condition::HasTag(tag!("yes")),
// from second filter
Condition::IdList(vec![TaskId::WorkingSetId(2), TaskId::WorkingSetId(3)]),
Condition::HasTag(tag!("no")),
],
}
);
}
#[test]
fn intersect_idlist_alltasks() {
let left = Filter::parse0(argv!["1,2", "+yes"]).unwrap().1;
let right = Filter::parse0(argv!["+no"]).unwrap().1;
let both = left.intersect(right);
assert_eq!(
both,
Filter {
conditions: vec![
// from first filter
Condition::IdList(vec![TaskId::WorkingSetId(1), TaskId::WorkingSetId(2),]),
Condition::HasTag(tag!("yes")),
// from second filter
Condition::HasTag(tag!("no")),
],
}
);
}
#[test]
fn intersect_alltasks_alltasks() {
let left = Filter::parse0(argv!["+yes"]).unwrap().1;
let right = Filter::parse0(argv!["+no"]).unwrap().1;
let both = left.intersect(right);
assert_eq!(
both,
Filter {
conditions: vec![
Condition::HasTag(tag!("yes")),
Condition::HasTag(tag!("no")),
],
}
);
}
}

View file

@ -0,0 +1,48 @@
// Nested functions that always return Ok(..) are used as callbacks in a context where a Result is
// expected, so the unnecessary_wraps clippy lint is not useful here.
#![allow(clippy::unnecessary_wraps)]
/*!
This module is responsible for parsing command lines (`Arglist`, an alias for `&[&str]`) into `Command` instances.
It removes some redundancy from the command line, for example combining the multiple ways to modify a task into a single `Modification` struct.
The module is organized as a nom parser over ArgList, and each struct has a `parse` method to parse such a list.
The exception to this rule is the `args` sub-module, which contains string parsers that are applied to indivdual command-line elements.
All of the structs produced by this module are fully-owned, data-only structs.
That is, they contain no references, and have no methods to aid in their execution -- that is the `invocation` module's job.
*/
mod args;
mod command;
mod config;
mod filter;
mod modification;
mod subcommand;
pub(crate) use args::TaskId;
pub(crate) use command::Command;
pub(crate) use config::ConfigOperation;
pub(crate) use filter::{Condition, Filter};
pub(crate) use modification::{DescriptionMod, Modification};
pub(crate) use subcommand::Subcommand;
use crate::usage::Usage;
use lazy_static::lazy_static;
use taskchampion::chrono::prelude::*;
lazy_static! {
// A static value of NOW to make tests easier
pub(crate) static ref NOW: DateTime<Utc> = Utc::now();
}
type ArgList<'a> = &'a [&'a str];
pub(crate) fn get_usage(usage: &mut Usage) {
Subcommand::get_usage(usage);
Filter::get_usage(usage);
Modification::get_usage(usage);
}

View file

@ -0,0 +1,342 @@
use super::args::{any, arg_matching, depends_colon, minus_tag, plus_tag, wait_colon, TaskId};
use super::ArgList;
use crate::usage;
use nom::{branch::alt, combinator::*, multi::fold_many0, IResult};
use std::collections::HashSet;
use taskchampion::chrono::prelude::*;
use taskchampion::{Status, Tag};
#[derive(Debug, PartialEq, Clone)]
pub enum DescriptionMod {
/// do not change the description
None,
/// Prepend the given value to the description, with a space separator
Prepend(String),
/// Append the given value to the description, with a space separator
Append(String),
/// Set the description
Set(String),
}
impl Default for DescriptionMod {
fn default() -> Self {
Self::None
}
}
/// A modification represents a change to a task: adding or removing tags, setting the
/// description, and so on.
#[derive(Debug, PartialEq, Clone, Default)]
pub(crate) struct Modification {
/// Change the description
pub(crate) description: DescriptionMod,
/// Set the status
pub(crate) status: Option<Status>,
/// Set (or, with `Some(None)`, clear) the wait timestamp
pub(crate) wait: Option<Option<DateTime<Utc>>>,
/// Set the "active" state, that is, start (true) or stop (false) the task.
pub(crate) active: Option<bool>,
/// Add tags
pub(crate) add_tags: HashSet<Tag>,
/// Remove tags
pub(crate) remove_tags: HashSet<Tag>,
/// Add dependencies
pub(crate) add_dependencies: HashSet<TaskId>,
/// Remove dependencies
pub(crate) remove_dependencies: HashSet<TaskId>,
/// Add annotation
pub(crate) annotate: Option<String>,
}
/// A single argument that is part of a modification, used internally to this module
enum ModArg<'a> {
Description(&'a str),
PlusTag(Tag),
MinusTag(Tag),
Wait(Option<DateTime<Utc>>),
AddDependencies(Vec<TaskId>),
RemoveDependencies(Vec<TaskId>),
}
impl Modification {
pub(super) fn parse(input: ArgList) -> IResult<ArgList, Modification> {
fn fold(mut acc: Modification, mod_arg: ModArg) -> Modification {
match mod_arg {
ModArg::Description(description) => {
if let DescriptionMod::Set(existing) = acc.description {
acc.description =
DescriptionMod::Set(format!("{} {}", existing, description));
} else {
acc.description = DescriptionMod::Set(description.to_string());
}
}
ModArg::PlusTag(tag) => {
acc.add_tags.insert(tag);
}
ModArg::MinusTag(tag) => {
acc.remove_tags.insert(tag);
}
ModArg::Wait(wait) => {
acc.wait = Some(wait);
}
ModArg::AddDependencies(task_ids) => {
for tid in task_ids {
acc.add_dependencies.insert(tid);
}
}
ModArg::RemoveDependencies(task_ids) => {
for tid in task_ids {
acc.remove_dependencies.insert(tid);
}
}
}
acc
}
fold_many0(
alt((
Self::plus_tag,
Self::minus_tag,
Self::wait,
Self::dependencies,
// this must come last
Self::description,
)),
Modification {
..Default::default()
},
fold,
)(input)
}
fn description(input: ArgList) -> IResult<ArgList, ModArg> {
fn to_modarg(input: &str) -> Result<ModArg, ()> {
Ok(ModArg::Description(input))
}
map_res(arg_matching(any), to_modarg)(input)
}
fn plus_tag(input: ArgList) -> IResult<ArgList, ModArg> {
fn to_modarg(input: Tag) -> Result<ModArg<'static>, ()> {
Ok(ModArg::PlusTag(input))
}
map_res(arg_matching(plus_tag), to_modarg)(input)
}
fn minus_tag(input: ArgList) -> IResult<ArgList, ModArg> {
fn to_modarg(input: Tag) -> Result<ModArg<'static>, ()> {
Ok(ModArg::MinusTag(input))
}
map_res(arg_matching(minus_tag), to_modarg)(input)
}
fn wait(input: ArgList) -> IResult<ArgList, ModArg> {
fn to_modarg(input: Option<DateTime<Utc>>) -> Result<ModArg<'static>, ()> {
Ok(ModArg::Wait(input))
}
map_res(arg_matching(wait_colon), to_modarg)(input)
}
fn dependencies(input: ArgList) -> IResult<ArgList, ModArg> {
fn to_modarg(input: (bool, Vec<TaskId>)) -> Result<ModArg<'static>, ()> {
Ok(if input.0 {
ModArg::AddDependencies(input.1)
} else {
ModArg::RemoveDependencies(input.1)
})
}
map_res(arg_matching(depends_colon), to_modarg)(input)
}
pub(super) fn get_usage(u: &mut usage::Usage) {
u.modifications.push(usage::Modification {
syntax: "DESCRIPTION",
summary: "Set description/annotation",
description: "
Set the task description (or the task annotation for `ta annotate`). Multiple
arguments are combined into a single space-separated description. To avoid
surprises from shell quoting, prefer to use a single quoted argument, for example
`ta 19 modify \"return library books\"`",
});
u.modifications.push(usage::Modification {
syntax: "+TAG",
summary: "Tag task",
description: "Add the given tag to the task.",
});
u.modifications.push(usage::Modification {
syntax: "-TAG",
summary: "Un-tag task",
description: "Remove the given tag from the task.",
});
u.modifications.push(usage::Modification {
syntax: "status:{pending,completed,deleted}",
summary: "Set the task's status",
description: "Set the status of the task explicitly.",
});
u.modifications.push(usage::Modification {
syntax: "wait:<timestamp>",
summary: "Set or unset the task's wait time",
description: "
Set the time before which the task is not actionable and should not be shown in
reports, e.g., `wait:3day` to wait for three days. With `wait:`, the time is
un-set. See the documentation for the timestamp syntax.",
});
u.modifications.push(usage::Modification {
syntax: "depends:<task-list>",
summary: "Add task dependencies",
description: "
Add a dependency of this task on the given tasks. The tasks can be specified
in the same syntax as for filters, e.g., `depends:13,94500c95`.",
});
u.modifications.push(usage::Modification {
syntax: "depends:-<task-list>",
summary: "Remove task dependencies",
description: "
Remove the dependency of this task on the given tasks.",
});
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::argparse::NOW;
use pretty_assertions::assert_eq;
use taskchampion::chrono::Duration;
#[test]
fn test_empty() {
let (input, modification) = Modification::parse(argv![]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
modification,
Modification {
..Default::default()
}
);
}
#[test]
fn test_single_arg_description() {
let (input, modification) = Modification::parse(argv!["newdesc"]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
modification,
Modification {
description: DescriptionMod::Set(s!("newdesc")),
..Default::default()
}
);
}
#[test]
fn test_add_tags() {
let (input, modification) = Modification::parse(argv!["+abc", "+def"]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
modification,
Modification {
add_tags: set![tag!("abc"), tag!("def")],
..Default::default()
}
);
}
#[test]
fn test_set_wait() {
let (input, modification) = Modification::parse(argv!["wait:2d"]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
modification,
Modification {
wait: Some(Some(*NOW + Duration::days(2))),
..Default::default()
}
);
}
#[test]
fn test_add_deps() {
let (input, modification) = Modification::parse(argv!["depends:13,e72b73d1-9e88"]).unwrap();
assert_eq!(input.len(), 0);
let mut deps = HashSet::new();
deps.insert(TaskId::WorkingSetId(13));
deps.insert(TaskId::PartialUuid("e72b73d1-9e88".into()));
assert_eq!(
modification,
Modification {
add_dependencies: deps,
..Default::default()
}
);
}
#[test]
fn test_remove_deps() {
let (input, modification) =
Modification::parse(argv!["depends:-13,e72b73d1-9e88"]).unwrap();
assert_eq!(input.len(), 0);
let mut deps = HashSet::new();
deps.insert(TaskId::WorkingSetId(13));
deps.insert(TaskId::PartialUuid("e72b73d1-9e88".into()));
assert_eq!(
modification,
Modification {
remove_dependencies: deps,
..Default::default()
}
);
}
#[test]
fn test_unset_wait() {
let (input, modification) = Modification::parse(argv!["wait:"]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
modification,
Modification {
wait: Some(None),
..Default::default()
}
);
}
#[test]
fn test_multi_arg_description() {
let (input, modification) = Modification::parse(argv!["new", "desc", "fun"]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
modification,
Modification {
description: DescriptionMod::Set(s!("new desc fun")),
..Default::default()
}
);
}
#[test]
fn test_multi_arg_description_and_tags() {
let (input, modification) =
Modification::parse(argv!["new", "+next", "desc", "-daytime", "fun"]).unwrap();
assert_eq!(input.len(), 0);
assert_eq!(
modification,
Modification {
description: DescriptionMod::Set(s!("new desc fun")),
add_tags: set![tag!("next")],
remove_tags: set![tag!("daytime")],
..Default::default()
}
);
}
}

View file

@ -0,0 +1,948 @@
use super::args::*;
use super::{ArgList, ConfigOperation, DescriptionMod, Filter, Modification};
use crate::usage;
use nom::{branch::alt, combinator::*, sequence::*, IResult};
use taskchampion::Status;
// IMPLEMENTATION NOTE:
//
// For each variant of Subcommand, there is a private, empty type of the same name with a `parse`
// method and a `get_usage` method. The parse methods may handle several subcommands, but always
// produce the variant of the same name as the type.
//
// This organization helps to gather the parsing and usage information into
// comprehensible chunks of code, to ensure that everything is documented.
/// A subcommand is the specific operation that the CLI should execute.
#[derive(Debug, PartialEq)]
pub(crate) enum Subcommand {
/// Display the tool version
Version,
/// Display the help output
Help {
/// Give the summary help (fitting on a few lines)
summary: bool,
},
/// Manipulate configuration
Config {
config_operation: ConfigOperation,
},
/// Add a new task
Add {
modification: Modification,
},
/// Modify existing tasks
Modify {
filter: Filter,
modification: Modification,
},
/// Lists (reports)
Report {
/// The name of the report to show
report_name: String,
/// Additional filter terms beyond those in the report
filter: Filter,
},
/// Per-task information (typically one task)
Info {
filter: Filter,
debug: bool,
},
/// Basic operations without args
Gc,
Sync,
ImportTW,
ImportTDB2 {
path: String,
},
Undo,
}
impl Subcommand {
pub(super) fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
all_consuming(alt((
Version::parse,
Help::parse,
Config::parse,
Add::parse,
Modify::parse,
Info::parse,
Gc::parse,
Sync::parse,
ImportTW::parse,
ImportTDB2::parse,
Undo::parse,
// This must come last since it accepts arbitrary report names
Report::parse,
)))(input)
}
pub(super) fn get_usage(u: &mut usage::Usage) {
Version::get_usage(u);
Help::get_usage(u);
Config::get_usage(u);
Add::get_usage(u);
Modify::get_usage(u);
Info::get_usage(u);
Gc::get_usage(u);
Sync::get_usage(u);
ImportTW::get_usage(u);
ImportTDB2::get_usage(u);
Undo::get_usage(u);
Report::get_usage(u);
}
}
struct Version;
impl Version {
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
fn to_subcommand(_: &str) -> Result<Subcommand, ()> {
Ok(Subcommand::Version)
}
map_res(
alt((
arg_matching(literal("version")),
arg_matching(literal("--version")),
)),
to_subcommand,
)(input)
}
fn get_usage(u: &mut usage::Usage) {
u.subcommands.push(usage::Subcommand {
name: "version",
syntax: "version",
summary: "Show the TaskChampion version",
description: "Show the version of the TaskChampion binary",
});
}
}
struct Help;
impl Help {
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
fn to_subcommand(input: &str) -> Result<Subcommand, ()> {
Ok(Subcommand::Help {
summary: input == "-h",
})
}
map_res(
alt((
arg_matching(literal("help")),
arg_matching(literal("--help")),
arg_matching(literal("-h")),
)),
to_subcommand,
)(input)
}
fn get_usage(_u: &mut usage::Usage) {}
}
struct Config;
impl Config {
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
fn to_subcommand(input: (&str, ConfigOperation)) -> Result<Subcommand, ()> {
Ok(Subcommand::Config {
config_operation: input.1,
})
}
map_res(
tuple((arg_matching(literal("config")), ConfigOperation::parse)),
to_subcommand,
)(input)
}
fn get_usage(u: &mut usage::Usage) {
ConfigOperation::get_usage(u);
}
}
struct Add;
impl Add {
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
fn to_subcommand(input: (&str, Modification)) -> Result<Subcommand, ()> {
Ok(Subcommand::Add {
modification: input.1,
})
}
map_res(
pair(arg_matching(literal("add")), Modification::parse),
to_subcommand,
)(input)
}
fn get_usage(u: &mut usage::Usage) {
u.subcommands.push(usage::Subcommand {
name: "add",
syntax: "add [modification]",
summary: "Add a new task",
description: "
Add a new, pending task to the list of tasks. The modification must include a
description.",
});
}
}
struct Modify;
impl Modify {
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
fn to_subcommand(input: (Filter, &str, Modification)) -> Result<Subcommand, ()> {
let filter = input.0;
let mut modification = input.2;
match input.1 {
"prepend" => {
if let DescriptionMod::Set(s) = modification.description {
modification.description = DescriptionMod::Prepend(s)
}
}
"append" => {
if let DescriptionMod::Set(s) = modification.description {
modification.description = DescriptionMod::Append(s)
}
}
"start" => modification.active = Some(true),
"stop" => modification.active = Some(false),
"done" => modification.status = Some(Status::Completed),
"delete" => modification.status = Some(Status::Deleted),
"annotate" => {
// what would be parsed as a description is, here, used as the annotation
if let DescriptionMod::Set(s) = modification.description {
modification.description = DescriptionMod::None;
modification.annotate = Some(s);
}
}
_ => {}
}
Ok(Subcommand::Modify {
filter,
modification,
})
}
map_res(
tuple((
Filter::parse1,
alt((
arg_matching(literal("modify")),
arg_matching(literal("prepend")),
arg_matching(literal("append")),
arg_matching(literal("start")),
arg_matching(literal("stop")),
arg_matching(literal("done")),
arg_matching(literal("delete")),
arg_matching(literal("annotate")),
)),
Modification::parse,
)),
to_subcommand,
)(input)
}
fn get_usage(u: &mut usage::Usage) {
u.subcommands.push(usage::Subcommand {
name: "modify",
syntax: "<filter> modify [modification]",
summary: "Modify tasks",
description: "
Modify all tasks matching the required filter.",
});
u.subcommands.push(usage::Subcommand {
name: "prepend",
syntax: "<filter> prepend [modification]",
summary: "Prepend task description",
description: "
Modify all tasks matching the required filter by inserting the given description before each
task's description.",
});
u.subcommands.push(usage::Subcommand {
name: "append",
syntax: "<filter> append [modification]",
summary: "Append task description",
description: "
Modify all tasks matching the required filter by adding the given description to the end
of each task's description.",
});
u.subcommands.push(usage::Subcommand {
name: "start",
syntax: "<filter> start [modification]",
summary: "Start tasks",
description: "
Start all tasks matching the required filter, additionally applying any given modifications."
});
u.subcommands.push(usage::Subcommand {
name: "stop",
syntax: "<filter> stop [modification]",
summary: "Stop tasks",
description: "
Stop all tasks matching the required filter, additionally applying any given modifications.",
});
u.subcommands.push(usage::Subcommand {
name: "done",
syntax: "<filter> done [modification]",
summary: "Mark tasks as completed",
description: "
Mark all tasks matching the required filter as completed, additionally applying any given
modifications.",
});
u.subcommands.push(usage::Subcommand {
name: "delete",
syntax: "<filter> delete [modification]",
summary: "Mark tasks as deleted",
description: "
Mark all tasks matching the required filter as deleted, additionally applying any given
modifications. Deleted tasks remain until they are expired in a 'ta gc' operation at
least six months after their last modification.",
});
u.subcommands.push(usage::Subcommand {
name: "annotate",
syntax: "<filter> annotate [modification]",
summary: "Annotate a task",
description: "
Add an annotation to all tasks matching the required filter.",
});
}
}
struct Report;
impl Report {
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
fn to_subcommand(filter: Filter, report_name: &str) -> Result<Subcommand, ()> {
Ok(Subcommand::Report {
filter,
report_name: report_name.to_owned(),
})
}
// allow the filter expression before or after the report name
alt((
map_res(pair(arg_matching(report_name), Filter::parse0), |input| {
to_subcommand(input.1, input.0)
}),
map_res(pair(Filter::parse0, arg_matching(report_name)), |input| {
to_subcommand(input.0, input.1)
}),
// default to a "next" report
map_res(Filter::parse0, |input| to_subcommand(input, "next")),
))(input)
}
fn get_usage(u: &mut usage::Usage) {
u.subcommands.push(usage::Subcommand {
name: "report",
syntax: "[filter] [report-name] *or* [report-name] [filter]",
summary: "Show a report",
description: "
Show the named report, including only tasks matching the filter",
});
u.subcommands.push(usage::Subcommand {
name: "next",
syntax: "[filter]",
summary: "Show the 'next' report",
description: "
Show the report named 'next', including only tasks matching the filter",
});
}
}
struct Info;
impl Info {
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
fn to_subcommand(input: (Filter, &str)) -> Result<Subcommand, ()> {
let debug = input.1 == "debug";
Ok(Subcommand::Info {
filter: input.0,
debug,
})
}
map_res(
pair(
Filter::parse1,
alt((
arg_matching(literal("info")),
arg_matching(literal("debug")),
)),
),
to_subcommand,
)(input)
}
fn get_usage(u: &mut usage::Usage) {
u.subcommands.push(usage::Subcommand {
name: "info",
syntax: "[filter] info",
summary: "Show tasks",
description: " Show information about all tasks matching the fiter.",
});
u.subcommands.push(usage::Subcommand {
name: "debug",
syntax: "[filter] debug",
summary: "Show task debug details",
description: " Show all key/value properties of the tasks matching the fiter.",
});
}
}
struct Gc;
impl Gc {
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
fn to_subcommand(_: &str) -> Result<Subcommand, ()> {
Ok(Subcommand::Gc)
}
map_res(arg_matching(literal("gc")), to_subcommand)(input)
}
fn get_usage(u: &mut usage::Usage) {
u.subcommands.push(usage::Subcommand {
name: "gc",
syntax: "gc",
summary: "Perform 'garbage collection'",
description: "
Perform 'garbage collection'. This refreshes the list of pending tasks
and their short id's.",
});
}
}
struct Sync;
impl Sync {
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
fn to_subcommand(_: &str) -> Result<Subcommand, ()> {
Ok(Subcommand::Sync)
}
map_res(arg_matching(literal("sync")), to_subcommand)(input)
}
fn get_usage(u: &mut usage::Usage) {
u.subcommands.push(usage::Subcommand {
name: "sync",
syntax: "sync",
summary: "Synchronize this replica",
description: "
Synchronize this replica locally or against a remote server, as configured.
Synchronization is a critical part of maintaining the task database, and should
be done regularly, even if only locally. It is typically run in a crontask.",
})
}
}
struct ImportTW;
impl ImportTW {
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
fn to_subcommand(_: &str) -> Result<Subcommand, ()> {
Ok(Subcommand::ImportTW)
}
map_res(arg_matching(literal("import-tw")), to_subcommand)(input)
}
fn get_usage(u: &mut usage::Usage) {
u.subcommands.push(usage::Subcommand {
name: "import-tw",
syntax: "import-tw",
summary: "Import tasks from TaskWarrior export",
description: "
Import tasks into this replica.
The tasks must be provided in the TaskWarrior JSON format on stdin. If tasks
in the import already exist, they are 'merged'.
Because TaskChampion lacks the information about the types of UDAs that is stored
in the TaskWarrior configuration, UDA values are imported as simple strings, in the
format they appear in the JSON export. This may cause undesirable results.
",
})
}
}
struct ImportTDB2;
impl ImportTDB2 {
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
fn to_subcommand(input: (&str, &str)) -> Result<Subcommand, ()> {
Ok(Subcommand::ImportTDB2 {
path: input.1.into(),
})
}
map_res(
pair(arg_matching(literal("import-tdb2")), arg_matching(any)),
to_subcommand,
)(input)
}
fn get_usage(u: &mut usage::Usage) {
u.subcommands.push(usage::Subcommand {
name: "import-tdb2",
syntax: "import-tdb2 <directory>",
summary: "Import tasks from the TaskWarrior data directory",
description: "
Import tasks into this replica from a TaskWarrior data directory. If tasks in the
import already exist, they are 'merged'. This mode of import supports UDAs better
than the `import` subcommand, but requires access to the \"raw\" TaskWarrior data.
This command supports task directories written by TaskWarrior-2.6.1 or later.
",
})
}
}
struct Undo;
impl Undo {
fn parse(input: ArgList) -> IResult<ArgList, Subcommand> {
fn to_subcommand(_: &str) -> Result<Subcommand, ()> {
Ok(Subcommand::Undo)
}
map_res(arg_matching(literal("undo")), to_subcommand)(input)
}
fn get_usage(u: &mut usage::Usage) {
u.subcommands.push(usage::Subcommand {
name: "undo",
syntax: "undo",
summary: "Undo the latest change made on this replica",
description: "
Undo the latest change made on this replica.
Changes cannot be undone once they have been synchronized.",
})
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::argparse::Condition;
use pretty_assertions::assert_eq;
const EMPTY: Vec<&str> = vec![];
#[test]
fn test_version() {
assert_eq!(
Subcommand::parse(argv!["version"]).unwrap(),
(&EMPTY[..], Subcommand::Version)
);
}
#[test]
fn test_dd_version() {
assert_eq!(
Subcommand::parse(argv!["--version"]).unwrap(),
(&EMPTY[..], Subcommand::Version)
);
}
#[test]
fn test_d_h() {
assert_eq!(
Subcommand::parse(argv!["-h"]).unwrap(),
(&EMPTY[..], Subcommand::Help { summary: true })
);
}
#[test]
fn test_help() {
assert_eq!(
Subcommand::parse(argv!["help"]).unwrap(),
(&EMPTY[..], Subcommand::Help { summary: false })
);
}
#[test]
fn test_dd_help() {
assert_eq!(
Subcommand::parse(argv!["--help"]).unwrap(),
(&EMPTY[..], Subcommand::Help { summary: false })
);
}
#[test]
fn test_config_set() {
assert_eq!(
Subcommand::parse(argv!["config", "set", "x", "y"]).unwrap(),
(
&EMPTY[..],
Subcommand::Config {
config_operation: ConfigOperation::Set("x".to_owned(), "y".to_owned())
}
)
);
}
#[test]
fn test_add_description() {
let subcommand = Subcommand::Add {
modification: Modification {
description: DescriptionMod::Set(s!("foo")),
..Default::default()
},
};
assert_eq!(
Subcommand::parse(argv!["add", "foo"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_add_description_multi() {
let subcommand = Subcommand::Add {
modification: Modification {
description: DescriptionMod::Set(s!("foo bar")),
..Default::default()
},
};
assert_eq!(
Subcommand::parse(argv!["add", "foo", "bar"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_modify_description_multi() {
let subcommand = Subcommand::Modify {
filter: Filter {
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
},
modification: Modification {
description: DescriptionMod::Set(s!("foo bar")),
..Default::default()
},
};
assert_eq!(
Subcommand::parse(argv!["123", "modify", "foo", "bar"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_append() {
let subcommand = Subcommand::Modify {
filter: Filter {
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
},
modification: Modification {
description: DescriptionMod::Append(s!("foo bar")),
..Default::default()
},
};
assert_eq!(
Subcommand::parse(argv!["123", "append", "foo", "bar"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_prepend() {
let subcommand = Subcommand::Modify {
filter: Filter {
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
},
modification: Modification {
description: DescriptionMod::Prepend(s!("foo bar")),
..Default::default()
},
};
assert_eq!(
Subcommand::parse(argv!["123", "prepend", "foo", "bar"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_done() {
let subcommand = Subcommand::Modify {
filter: Filter {
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
},
modification: Modification {
status: Some(Status::Completed),
..Default::default()
},
};
assert_eq!(
Subcommand::parse(argv!["123", "done"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_done_modify() {
let subcommand = Subcommand::Modify {
filter: Filter {
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
},
modification: Modification {
description: DescriptionMod::Set(s!("now-finished")),
status: Some(Status::Completed),
..Default::default()
},
};
assert_eq!(
Subcommand::parse(argv!["123", "done", "now-finished"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_start() {
let subcommand = Subcommand::Modify {
filter: Filter {
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
},
modification: Modification {
active: Some(true),
..Default::default()
},
};
assert_eq!(
Subcommand::parse(argv!["123", "start"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_start_modify() {
let subcommand = Subcommand::Modify {
filter: Filter {
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
},
modification: Modification {
active: Some(true),
description: DescriptionMod::Set(s!("mod")),
..Default::default()
},
};
assert_eq!(
Subcommand::parse(argv!["123", "start", "mod"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_stop() {
let subcommand = Subcommand::Modify {
filter: Filter {
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
},
modification: Modification {
active: Some(false),
..Default::default()
},
};
assert_eq!(
Subcommand::parse(argv!["123", "stop"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_stop_modify() {
let subcommand = Subcommand::Modify {
filter: Filter {
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
},
modification: Modification {
description: DescriptionMod::Set(s!("mod")),
active: Some(false),
..Default::default()
},
};
assert_eq!(
Subcommand::parse(argv!["123", "stop", "mod"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_delete() {
let subcommand = Subcommand::Modify {
filter: Filter {
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
},
modification: Modification {
status: Some(Status::Deleted),
..Default::default()
},
};
assert_eq!(
Subcommand::parse(argv!["123", "delete"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_annotate() {
let subcommand = Subcommand::Modify {
filter: Filter {
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])],
},
modification: Modification {
annotate: Some("sent invoice".into()),
..Default::default()
},
};
assert_eq!(
Subcommand::parse(argv!["123", "annotate", "sent", "invoice"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_report() {
let subcommand = Subcommand::Report {
filter: Default::default(),
report_name: "myreport".to_owned(),
};
assert_eq!(
Subcommand::parse(argv!["myreport"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_report_filter_before() {
let subcommand = Subcommand::Report {
filter: Filter {
conditions: vec![Condition::IdList(vec![
TaskId::WorkingSetId(12),
TaskId::WorkingSetId(13),
])],
},
report_name: "foo".to_owned(),
};
assert_eq!(
Subcommand::parse(argv!["12,13", "foo"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_report_filter_after() {
let subcommand = Subcommand::Report {
filter: Filter {
conditions: vec![Condition::IdList(vec![
TaskId::WorkingSetId(12),
TaskId::WorkingSetId(13),
])],
},
report_name: "foo".to_owned(),
};
assert_eq!(
Subcommand::parse(argv!["foo", "12,13"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_report_filter_next() {
let subcommand = Subcommand::Report {
filter: Filter {
conditions: vec![Condition::IdList(vec![
TaskId::WorkingSetId(12),
TaskId::WorkingSetId(13),
])],
},
report_name: "next".to_owned(),
};
assert_eq!(
Subcommand::parse(argv!["12,13"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_report_next() {
let subcommand = Subcommand::Report {
filter: Filter {
..Default::default()
},
report_name: "next".to_owned(),
};
assert_eq!(
Subcommand::parse(argv![]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_info_filter() {
let subcommand = Subcommand::Info {
debug: false,
filter: Filter {
conditions: vec![Condition::IdList(vec![
TaskId::WorkingSetId(12),
TaskId::WorkingSetId(13),
])],
},
};
assert_eq!(
Subcommand::parse(argv!["12,13", "info"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_debug_filter() {
let subcommand = Subcommand::Info {
debug: true,
filter: Filter {
conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(12)])],
},
};
assert_eq!(
Subcommand::parse(argv!["12", "debug"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_gc() {
let subcommand = Subcommand::Gc;
assert_eq!(
Subcommand::parse(argv!["gc"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_gc_extra_args() {
assert!(Subcommand::parse(argv!["gc", "foo"]).is_err());
}
#[test]
fn test_sync() {
let subcommand = Subcommand::Sync;
assert_eq!(
Subcommand::parse(argv!["sync"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
#[test]
fn test_undo() {
let subcommand = Subcommand::Undo;
assert_eq!(
Subcommand::parse(argv!["undo"]).unwrap(),
(&EMPTY[..], subcommand)
);
}
}

11
rust/cli/src/bin/ta.rs Normal file
View file

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

View file

@ -0,0 +1,53 @@
use mdbook::book::{Book, BookItem};
use mdbook::errors::Error;
use mdbook::preprocess::{CmdPreprocessor, PreprocessorContext};
use std::io;
use std::process;
use taskchampion_cli::Usage;
/// This is a simple mdbook preprocessor designed to substitute information from the usage
/// into the documentation.
fn main() -> anyhow::Result<()> {
// cheap way to detect the "supports" arg
if std::env::args().len() > 1 {
// sure, whatever, we support it all
process::exit(0);
}
let (ctx, book) = CmdPreprocessor::parse_input(io::stdin())?;
if ctx.mdbook_version != mdbook::MDBOOK_VERSION {
eprintln!(
"Warning: This mdbook preprocessor was built against version {} of mdbook, \
but we're being called from version {}",
mdbook::MDBOOK_VERSION,
ctx.mdbook_version
);
}
let processed_book = process(&ctx, book)?;
serde_json::to_writer(io::stdout(), &processed_book)?;
Ok(())
}
fn process(_ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
let usage = Usage::new();
book.for_each_mut(|sect| {
if let BookItem::Chapter(ref mut chapter) = sect {
let new_content = usage.substitute_docs(&chapter.content).unwrap();
if new_content != chapter.content {
eprintln!(
"Substituting usage in {:?}",
chapter
.source_path
.as_ref()
.unwrap_or_else(|| chapter.path.as_ref().unwrap())
);
}
chapter.content = new_content;
}
});
Ok(book)
}

60
rust/cli/src/errors.rs Normal file
View file

@ -0,0 +1,60 @@
use taskchampion::Error as TcError;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error("Command-Line Syntax Error: {0}")]
Arguments(String),
#[error(transparent)]
TaskChampion(#[from] TcError),
#[error(transparent)]
Other(#[from] anyhow::Error),
}
impl Error {
/// Construct a new command-line argument error
pub(crate) fn for_arguments<S: ToString>(msg: S) -> Self {
Error::Arguments(msg.to_string())
}
/// Determine the exit status for this error, as documented.
pub fn exit_status(&self) -> i32 {
match *self {
Error::Arguments(_) => 3,
_ => 1,
}
}
}
impl From<std::io::Error> for Error {
fn from(err: std::io::Error) -> Self {
let err: anyhow::Error = err.into();
Error::Other(err)
}
}
#[cfg(test)]
mod test {
use super::*;
use anyhow::anyhow;
use pretty_assertions::assert_eq;
#[test]
fn test_exit_status() {
let mut err: Error;
err = anyhow!("uhoh").into();
assert_eq!(err.exit_status(), 1);
err = Error::Arguments("uhoh".to_string());
assert_eq!(err.exit_status(), 3);
err = std::io::Error::last_os_error().into();
assert_eq!(err.exit_status(), 1);
err = TcError::Database("uhoh".to_string()).into();
assert_eq!(err.exit_status(), 1);
}
}

View file

@ -0,0 +1,77 @@
use crate::argparse::DescriptionMod;
use crate::invocation::{apply_modification, ResolvedModification};
use taskchampion::{Replica, Status};
use termcolor::WriteColor;
pub(in crate::invocation) fn execute<W: WriteColor>(
w: &mut W,
replica: &mut Replica,
mut modification: ResolvedModification,
) -> Result<(), crate::Error> {
// extract the description from the modification to handle it specially
let description = match modification.0.description {
DescriptionMod::Set(ref s) => s.clone(),
_ => "(no description)".to_owned(),
};
modification.0.description = DescriptionMod::None;
let task = replica.new_task(Status::Pending, description).unwrap();
let mut task = task.into_mut(replica);
apply_modification(&mut task, &modification)?;
writeln!(w, "added task {}", task.get_uuid())?;
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
use crate::argparse::Modification;
use crate::invocation::test::*;
use pretty_assertions::assert_eq;
#[test]
fn test_add() {
let mut w = test_writer();
let mut replica = test_replica();
let modification = ResolvedModification(Modification {
description: DescriptionMod::Set(s!("my description")),
..Default::default()
});
execute(&mut w, &mut replica, modification).unwrap();
// check that the task appeared..
let working_set = replica.working_set().unwrap();
let task = replica
.get_task(working_set.by_index(1).unwrap())
.unwrap()
.unwrap();
assert_eq!(task.get_description(), "my description");
assert_eq!(task.get_status(), Status::Pending);
assert_eq!(w.into_string(), format!("added task {}\n", task.get_uuid()));
}
#[test]
fn test_add_with_tags() {
let mut w = test_writer();
let mut replica = test_replica();
let modification = ResolvedModification(Modification {
description: DescriptionMod::Set(s!("my description")),
add_tags: vec![tag!("tag1")].drain(..).collect(),
..Default::default()
});
execute(&mut w, &mut replica, modification).unwrap();
// check that the task appeared..
let working_set = replica.working_set().unwrap();
let task = replica
.get_task(working_set.by_index(1).unwrap())
.unwrap()
.unwrap();
assert_eq!(task.get_description(), "my description");
assert_eq!(task.get_status(), Status::Pending);
assert!(task.has_tag(&tag!("tag1")));
assert_eq!(w.into_string(), format!("added task {}\n", task.get_uuid()));
}
}

View file

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

View file

@ -0,0 +1,69 @@
use crate::argparse::ConfigOperation;
use crate::settings::Settings;
use termcolor::{ColorSpec, WriteColor};
pub(crate) fn execute<W: WriteColor>(
w: &mut W,
config_operation: ConfigOperation,
settings: &Settings,
) -> Result<(), crate::Error> {
match config_operation {
ConfigOperation::Set(key, value) => {
let filename = settings.set(&key, &value)?;
write!(w, "Set configuration value ")?;
w.set_color(ColorSpec::new().set_bold(true))?;
write!(w, "{}", &key)?;
w.set_color(ColorSpec::new().set_bold(false))?;
write!(w, " in ")?;
w.set_color(ColorSpec::new().set_bold(true))?;
writeln!(w, "{:?}.", filename)?;
w.set_color(ColorSpec::new().set_bold(false))?;
}
ConfigOperation::Path => {
if let Some(ref filename) = settings.filename {
writeln!(w, "{}", filename.to_string_lossy())?;
} else {
return Err(anyhow::anyhow!("No configuration filename found").into());
}
}
}
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
use crate::invocation::test::*;
use pretty_assertions::assert_eq;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_config_set() {
let cfg_dir = TempDir::new().unwrap();
let cfg_file = cfg_dir.path().join("foo.toml");
fs::write(
cfg_file.clone(),
"# store data everywhere\ndata_dir = \"/nowhere\"\n",
)
.unwrap();
let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap();
let mut w = test_writer();
execute(
&mut w,
ConfigOperation::Set("data_dir".to_owned(), "/somewhere".to_owned()),
&settings,
)
.unwrap();
assert!(w.into_string().starts_with("Set configuration value "));
let updated_toml = fs::read_to_string(cfg_file.clone()).unwrap();
assert_eq!(
updated_toml,
"# store data everywhere\ndata_dir = \"/somewhere\"\n"
);
}
}

View file

@ -0,0 +1,26 @@
use taskchampion::Replica;
use termcolor::WriteColor;
pub(crate) fn execute<W: WriteColor>(w: &mut W, replica: &mut Replica) -> Result<(), crate::Error> {
log::debug!("rebuilding working set");
replica.rebuild_working_set(true)?;
log::debug!("expiring old tasks");
replica.expire_tasks()?;
writeln!(w, "garbage collected.")?;
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
use crate::invocation::test::*;
use pretty_assertions::assert_eq;
#[test]
fn test_gc() {
let mut w = test_writer();
let mut replica = test_replica();
execute(&mut w, &mut replica).unwrap();
assert_eq!(&w.into_string(), "garbage collected.\n")
}
}

View file

@ -0,0 +1,30 @@
use crate::usage::Usage;
use termcolor::WriteColor;
pub(crate) fn execute<W: WriteColor>(
w: &mut W,
command_name: String,
summary: bool,
) -> Result<(), crate::Error> {
let usage = Usage::new();
usage.write_help(w, command_name.as_ref(), summary)?;
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
use crate::invocation::test::*;
#[test]
fn test_summary() {
let mut w = test_writer();
execute(&mut w, s!("ta"), true).unwrap();
}
#[test]
fn test_long() {
let mut w = test_writer();
execute(&mut w, s!("ta"), false).unwrap();
}
}

View file

@ -0,0 +1,142 @@
use crate::tdb2;
use anyhow::anyhow;
use std::fs;
use std::path::PathBuf;
use taskchampion::{Replica, Uuid};
use termcolor::{Color, ColorSpec, WriteColor};
pub(crate) fn execute<W: WriteColor>(
w: &mut W,
replica: &mut Replica,
path: &str,
) -> Result<(), crate::Error> {
let path: PathBuf = path.into();
let mut count = 0;
for file in &["pending.data", "completed.data"] {
let file = path.join(file);
w.set_color(ColorSpec::new().set_bold(true))?;
writeln!(w, "Importing tasks from {:?}.", file)?;
w.reset()?;
let data = fs::read_to_string(file)?;
let content =
tdb2::File::from_str(&data).map_err(|_| anyhow!("Could not parse TDB2 file format"))?;
count += content.lines.len();
for line in content.lines {
import_task(w, replica, line)?;
}
}
w.set_color(ColorSpec::new().set_bold(true))?;
writeln!(w, "{} tasks imported.", count)?;
w.reset()?;
Ok(())
}
fn import_task<W: WriteColor>(
w: &mut W,
replica: &mut Replica,
mut line: tdb2::Line,
) -> anyhow::Result<()> {
let mut uuid = None;
for attr in line.attrs.iter() {
if &attr.name == "uuid" {
uuid = Some(Uuid::parse_str(&attr.value)?);
break;
}
}
let uuid = uuid.ok_or_else(|| anyhow!("task has no uuid"))?;
replica.import_task_with_uuid(uuid)?;
let mut description = None;
for attr in line.attrs.drain(..) {
// oddly, TaskWarrior represents [ and ] with their HTML entity equivalents
let value = attr.value.replace("&open;", "[").replace("&close;", "]");
match attr.name.as_ref() {
// `uuid` was already handled
"uuid" => {}
// everything else is inserted directly
_ => {
if attr.name == "description" {
// keep a copy of the description for console output
description = Some(value.clone());
}
replica.update_task(uuid, attr.name, Some(value))?;
}
}
}
w.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)))?;
write!(w, "{}", uuid)?;
w.reset()?;
writeln!(
w,
" {}",
description.unwrap_or_else(|| "(no description)".into())
)?;
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
use crate::invocation::test::*;
use pretty_assertions::assert_eq;
use std::convert::TryInto;
use taskchampion::chrono::{TimeZone, Utc};
use taskchampion::Status;
use tempfile::TempDir;
#[test]
fn test_import() -> anyhow::Result<()> {
let mut w = test_writer();
let mut replica = test_replica();
let tmp_dir = TempDir::new()?;
fs::write(
tmp_dir.path().join("pending.data"),
include_bytes!("pending.data"),
)?;
fs::write(
tmp_dir.path().join("completed.data"),
include_bytes!("completed.data"),
)?;
execute(&mut w, &mut replica, tmp_dir.path().to_str().unwrap())?;
let task = replica
.get_task(Uuid::parse_str("f19086c2-1f8d-4a6c-9b8d-f94901fb8e62").unwrap())
.unwrap()
.unwrap();
assert_eq!(task.get_description(), "snake 🐍");
assert_eq!(task.get_status(), Status::Pending);
assert_eq!(task.get_priority(), "M");
assert_eq!(task.get_wait(), None);
assert_eq!(
task.get_modified(),
Some(Utc.ymd(2022, 1, 8).and_hms(19, 33, 5))
);
assert!(task.has_tag(&"reptile".try_into().unwrap()));
assert!(!task.has_tag(&"COMPLETED".try_into().unwrap()));
let task = replica
.get_task(Uuid::parse_str("4578fb67-359b-4483-afe4-fef15925ccd6").unwrap())
.unwrap()
.unwrap();
assert_eq!(task.get_description(), "[TEST] foo");
assert_eq!(task.get_status(), Status::Completed);
assert_eq!(task.get_priority(), "M".to_string());
assert_eq!(task.get_wait(), None);
assert_eq!(
task.get_modified(),
Some(Utc.ymd(2019, 3, 31).and_hms(23, 20, 16))
);
assert!(!task.has_tag(&"reptile".try_into().unwrap()));
assert!(task.has_tag(&"COMPLETED".try_into().unwrap()));
Ok(())
}
}

View file

@ -0,0 +1,265 @@
use anyhow::{anyhow, bail};
use serde::{self, Deserialize, Deserializer};
use serde_json::Value;
use std::collections::HashMap;
use taskchampion::chrono::{DateTime, TimeZone, Utc};
use taskchampion::{Replica, Uuid};
use termcolor::{Color, ColorSpec, WriteColor};
pub(crate) fn execute<W: WriteColor>(w: &mut W, replica: &mut Replica) -> Result<(), crate::Error> {
w.set_color(ColorSpec::new().set_bold(true))?;
writeln!(w, "Importing tasks from stdin.")?;
w.reset()?;
let mut tasks: Vec<HashMap<String, Value>> =
serde_json::from_reader(std::io::stdin()).map_err(|_| anyhow!("Invalid JSON"))?;
for task_json in tasks.drain(..) {
import_task(w, replica, task_json)?;
}
w.set_color(ColorSpec::new().set_bold(true))?;
writeln!(w, "{} tasks imported.", tasks.len())?;
w.reset()?;
Ok(())
}
/// Convert the given value to a string, failing on compound types (arrays
/// and objects).
fn stringify(v: Value) -> anyhow::Result<String> {
Ok(match v {
Value::String(s) => s,
Value::Number(n) => n.to_string(),
Value::Bool(true) => "true".to_string(),
Value::Bool(false) => "false".to_string(),
Value::Null => "null".to_string(),
_ => bail!("{:?} cannot be converted to a string", v),
})
}
pub fn deserialize_tw_datetime<'de, D>(deserializer: D) -> Result<DateTime<Utc>, D::Error>
where
D: Deserializer<'de>,
{
const FORMAT: &str = "%Y%m%dT%H%M%SZ";
let s = String::deserialize(deserializer)?;
Utc.datetime_from_str(&s, FORMAT)
.map_err(serde::de::Error::custom)
}
/// Deserialize a string in the TaskWarrior format into a DateTime
#[derive(Deserialize)]
struct TwDateTime(#[serde(deserialize_with = "deserialize_tw_datetime")] DateTime<Utc>);
impl TwDateTime {
/// Generate the data-model style UNIX timestamp for this DateTime
fn tc_timestamp(&self) -> String {
self.0.timestamp().to_string()
}
}
#[derive(Deserialize)]
struct Annotation {
entry: TwDateTime,
description: String,
}
fn import_task<W: WriteColor>(
w: &mut W,
replica: &mut Replica,
mut task_json: HashMap<String, Value>,
) -> anyhow::Result<()> {
let uuid = task_json
.get("uuid")
.ok_or_else(|| anyhow!("task has no uuid"))?;
let uuid = uuid
.as_str()
.ok_or_else(|| anyhow!("uuid is not a string"))?;
let uuid = Uuid::parse_str(uuid)?;
replica.import_task_with_uuid(uuid)?;
let mut description = None;
for (k, v) in task_json.drain() {
match k.as_ref() {
// `id` is the working-set ID and is not stored
"id" => {}
// `urgency` is also calculated and not stored
"urgency" => {}
// `uuid` was already handled
"uuid" => {}
// `annotations` is a sub-aray
"annotations" => {
let annotations: Vec<Annotation> = serde_json::from_value(v)?;
for ann in annotations {
let k = format!("annotation_{}", ann.entry.tc_timestamp());
replica.update_task(uuid, k, Some(ann.description))?;
}
}
// `depends` is a sub-aray
"depends" => {
let deps: Vec<String> = serde_json::from_value(v)?;
for dep in deps {
let k = format!("dep_{}", dep);
replica.update_task(uuid, k, Some("".to_owned()))?;
}
}
// `tags` is a sub-aray
"tags" => {
let tags: Vec<String> = serde_json::from_value(v)?;
for tag in tags {
let k = format!("tag_{}", tag);
replica.update_task(uuid, k, Some("".to_owned()))?;
}
}
// convert all datetimes -> epoch integers
"end" | "entry" | "modified" | "wait" | "due" => {
let v: TwDateTime = serde_json::from_value(v)?;
replica.update_task(uuid, k, Some(v.tc_timestamp()))?;
}
// everything else is inserted directly
_ => {
let v = stringify(v)?;
if k == "description" {
// keep a copy of the description for console output
description = Some(v.clone());
}
replica.update_task(uuid, k, Some(v))?;
}
}
}
w.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)))?;
write!(w, "{}", uuid)?;
w.reset()?;
writeln!(
w,
" {}",
description.unwrap_or_else(|| "(no description)".into())
)?;
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
use crate::invocation::test::*;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::convert::TryInto;
use taskchampion::chrono::{TimeZone, Utc};
use taskchampion::Status;
#[test]
fn stringify_string() {
assert_eq!(stringify(json!("foo")).unwrap(), "foo".to_string());
}
#[test]
fn stringify_number() {
assert_eq!(stringify(json!(2.14)).unwrap(), "2.14".to_string());
}
#[test]
fn stringify_bool() {
assert_eq!(stringify(json!(true)).unwrap(), "true".to_string());
assert_eq!(stringify(json!(false)).unwrap(), "false".to_string());
}
#[test]
fn stringify_null() {
assert_eq!(stringify(json!(null)).unwrap(), "null".to_string());
}
#[test]
fn stringify_invalid() {
assert!(stringify(json!([1])).is_err());
assert!(stringify(json!({"a": 1})).is_err());
}
#[test]
fn test_import() -> anyhow::Result<()> {
let mut w = test_writer();
let mut replica = test_replica();
let task_json = serde_json::from_value(json!({
"id": 0,
"description": "repair window",
"end": "20211231T175614Z", // TODO (#327)
"entry": "20211117T022410Z", // TODO (#326)
"modified": "20211231T175614Z",
"priority": "M",
"status": "completed",
"uuid": "fa01e916-1587-4c7d-a646-f7be62be8ee7",
"wait": "20211225T001523Z",
"due": "20211225T040000Z", // TODO (#82)
// TODO: recurrence (#81)
"imask": 2,
"recur": "monthly",
"rtype": "periodic",
"mask": "+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--",
// (legacy) UDAs
"githubcreatedon": "20211110T175919Z",
"githubnamespace": "djmitche",
"githubnumber": 228,
"tags": [
"house"
],
"depends": [ // TODO (#84)
"4f71035d-1704-47f0-885c-6f9134bcefb2"
],
"annotations": [
{
"entry": "20211223T142031Z",
"description": "ordered from website"
}
],
"urgency": 4.16849
}))?;
import_task(&mut w, &mut replica, task_json)?;
let task = replica
.get_task(Uuid::parse_str("fa01e916-1587-4c7d-a646-f7be62be8ee7").unwrap())
.unwrap()
.unwrap();
assert_eq!(task.get_description(), "repair window");
assert_eq!(task.get_status(), Status::Completed);
assert_eq!(task.get_priority(), "M".to_string());
assert_eq!(
task.get_wait(),
Some(Utc.ymd(2021, 12, 25).and_hms(00, 15, 23))
);
assert_eq!(
task.get_modified(),
Some(Utc.ymd(2021, 12, 31).and_hms(17, 56, 14))
);
assert!(task.has_tag(&"house".try_into().unwrap()));
assert!(!task.has_tag(&"PENDING".try_into().unwrap()));
assert_eq!(
task.get_annotations().collect::<Vec<_>>(),
vec![taskchampion::Annotation {
entry: Utc.ymd(2021, 12, 23).and_hms(14, 20, 31),
description: "ordered from website".into(),
}]
);
assert_eq!(
task.get_legacy_uda("githubcreatedon"),
Some("20211110T175919Z")
);
assert_eq!(task.get_legacy_uda("githubnamespace"), Some("djmitche"));
assert_eq!(task.get_legacy_uda("githubnumber"), Some("228"));
Ok(())
}
}

View file

@ -0,0 +1,117 @@
use crate::argparse::Filter;
use crate::invocation::filtered_tasks;
use crate::table;
use prettytable::{cell, row, Table};
use taskchampion::{Replica, Status};
use termcolor::WriteColor;
pub(crate) fn execute<W: WriteColor>(
w: &mut W,
replica: &mut Replica,
filter: Filter,
debug: bool,
) -> Result<(), crate::Error> {
let working_set = replica.working_set()?;
for task in filtered_tasks(replica, &filter)? {
let uuid = task.get_uuid();
let mut t = Table::new();
t.set_format(table::format());
if debug {
t.set_titles(row![b->"key", b->"value"]);
for (k, v) in task.get_taskmap().iter() {
t.add_row(row![k, v]);
}
} else {
t.add_row(row![b->"Uuid", uuid]);
if let Some(i) = working_set.by_uuid(uuid) {
t.add_row(row![b->"Id", i]);
}
t.add_row(row![b->"Description", task.get_description()]);
t.add_row(row![b->"Status", task.get_status()]);
t.add_row(row![b->"Active", task.is_active()]);
let mut tags: Vec<_> = task.get_tags().map(|t| format!("+{}", t)).collect();
if !tags.is_empty() {
tags.sort();
t.add_row(row![b->"Tags", tags.join(" ")]);
}
if let Some(wait) = task.get_wait() {
t.add_row(row![b->"Wait", wait]);
}
let mut annotations: Vec<_> = task.get_annotations().collect();
annotations.sort();
for ann in annotations {
t.add_row(row![b->"Annotation", format!("{}: {}", ann.entry, ann.description)]);
}
let mut deps: Vec<_> = task.get_dependencies().collect();
deps.sort();
for dep in deps {
let mut descr = None;
if let Some(task) = replica.get_task(dep)? {
if task.get_status() == Status::Pending {
if let Some(i) = working_set.by_uuid(dep) {
descr = Some(format!("{} - {}", i, task.get_description()))
} else {
descr = Some(format!("{} - {}", dep, task.get_description()))
}
}
}
if let Some(descr) = descr {
t.add_row(row![b->"Depends On", descr]);
}
}
}
t.print(w)?;
}
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
use crate::argparse::{Condition, TaskId};
use crate::invocation::test::*;
use taskchampion::Status;
#[test]
fn test_info() {
let mut w = test_writer();
let mut replica = test_replica();
replica.new_task(Status::Pending, s!("my task")).unwrap();
let filter = Filter {
..Default::default()
};
let debug = false;
execute(&mut w, &mut replica, filter, debug).unwrap();
assert!(w.into_string().contains("my task"));
}
#[test]
fn test_deps() {
let mut w = test_writer();
let mut replica = test_replica();
let t1 = replica.new_task(Status::Pending, s!("my task")).unwrap();
let t2 = replica
.new_task(Status::Pending, s!("dunno, depends"))
.unwrap();
let mut t2 = t2.into_mut(&mut replica);
t2.add_dependency(t1.get_uuid()).unwrap();
let t2 = t2.into_immut();
let filter = Filter {
conditions: vec![Condition::IdList(vec![TaskId::Uuid(t2.get_uuid())])],
};
let debug = false;
execute(&mut w, &mut replica, filter, debug).unwrap();
let s = w.into_string();
// length of whitespace between these two strings is not important
assert!(s.contains("Depends On"));
assert!(s.contains("1 - my task"));
}
}

View file

@ -0,0 +1,14 @@
//! Responsible for executing commands as parsed by [`crate::argparse`].
pub(crate) mod add;
pub(crate) mod config;
pub(crate) mod gc;
pub(crate) mod help;
pub(crate) mod import_tdb2;
pub(crate) mod import_tw;
pub(crate) mod info;
pub(crate) mod modify;
pub(crate) mod report;
pub(crate) mod sync;
pub(crate) mod undo;
pub(crate) mod version;

View file

@ -0,0 +1,106 @@
use crate::argparse::Filter;
use crate::invocation::util::{confirm, summarize_task};
use crate::invocation::{apply_modification, filtered_tasks, ResolvedModification};
use crate::settings::Settings;
use taskchampion::Replica;
use termcolor::WriteColor;
/// confirm modification of more than `modificationt_count_prompt` tasks, defaulting to 3
fn check_modification<W: WriteColor>(
w: &mut W,
settings: &Settings,
affected_tasks: usize,
) -> Result<bool, crate::Error> {
let setting = settings.modification_count_prompt.unwrap_or(3);
if setting == 0 || affected_tasks <= setting as usize {
return Ok(true);
}
let prompt = format!("Operation will modify {} tasks; continue?", affected_tasks,);
if confirm(&prompt)? {
return Ok(true);
}
writeln!(w, "Cancelled")?;
// only show this help if the setting is not set
if settings.modification_count_prompt.is_none() {
writeln!(
w,
"Set the `modification_count_prompt` setting to avoid this prompt:"
)?;
writeln!(
w,
" ta config set modification_count_prompt {}",
affected_tasks + 1
)?;
writeln!(w, "Set it to 0 to disable the prompt entirely")?;
}
Ok(false)
}
pub(in crate::invocation) fn execute<W: WriteColor>(
w: &mut W,
replica: &mut Replica,
settings: &Settings,
filter: Filter,
modification: ResolvedModification,
) -> Result<(), crate::Error> {
let tasks = filtered_tasks(replica, &filter)?;
if !check_modification(w, settings, tasks.size_hint().0)? {
return Ok(());
}
for task in tasks {
let mut task = task.into_mut(replica);
apply_modification(&mut task, &modification)?;
let task = task.into_immut();
let summary = summarize_task(replica, &task)?;
writeln!(w, "modified task {}", summary)?;
}
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
use crate::argparse::{DescriptionMod, Modification};
use crate::invocation::test::test_replica;
use crate::invocation::test::*;
use pretty_assertions::assert_eq;
use taskchampion::Status;
#[test]
fn test_modify() {
let mut w = test_writer();
let mut replica = test_replica();
let settings = Settings::default();
let task = replica
.new_task(Status::Pending, s!("old description"))
.unwrap();
let filter = Filter {
..Default::default()
};
let modification = ResolvedModification(Modification {
description: DescriptionMod::Set(s!("new description")),
..Default::default()
});
execute(&mut w, &mut replica, &settings, filter, modification).unwrap();
// check that the task appeared..
let task = replica.get_task(task.get_uuid()).unwrap().unwrap();
assert_eq!(task.get_description(), "new description");
assert_eq!(task.get_status(), Status::Pending);
assert_eq!(
w.into_string(),
format!("modified task 1 - new description\n")
);
}
}

View file

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

View file

@ -0,0 +1,43 @@
use crate::argparse::Filter;
use crate::invocation::display_report;
use crate::settings::Settings;
use taskchampion::Replica;
use termcolor::WriteColor;
pub(crate) fn execute<W: WriteColor>(
w: &mut W,
replica: &mut Replica,
settings: &Settings,
report_name: String,
filter: Filter,
) -> Result<(), crate::Error> {
display_report(w, replica, settings, report_name, filter)
}
#[cfg(test)]
mod test {
use super::*;
use crate::argparse::Filter;
use crate::invocation::test::*;
use taskchampion::Status;
#[test]
fn test_report() {
let mut w = test_writer();
let mut replica = test_replica();
replica.new_task(Status::Pending, s!("my task")).unwrap();
// The function being tested is only one line long, so this is sort of an integration test
// for display_report.
let settings = Default::default();
let report_name = "next".to_owned();
let filter = Filter {
..Default::default()
};
execute(&mut w, &mut replica, &settings, report_name, filter).unwrap();
assert!(w.into_string().contains("my task"));
}
}

View file

@ -0,0 +1,58 @@
use crate::settings::Settings;
use taskchampion::{server::Server, Error as TCError, Replica};
use termcolor::WriteColor;
pub(crate) fn execute<W: WriteColor>(
w: &mut W,
replica: &mut Replica,
settings: &Settings,
server: &mut Box<dyn Server>,
) -> Result<(), crate::Error> {
match replica.sync(server, settings.avoid_snapshots) {
Ok(()) => {
writeln!(w, "sync complete.")?;
Ok(())
}
Err(e) => match e.downcast() {
Ok(TCError::OutOfSync) => {
writeln!(w, "This replica cannot be synchronized with the server.")?;
writeln!(
w,
"It may be too old, or some other failure may have occurred."
)?;
writeln!(
w,
"To start fresh, remove the local task database and run `ta sync` again."
)?;
writeln!(
w,
"Note that doing so will lose any un-synchronized local changes."
)?;
Ok(())
}
Ok(e) => Err(e.into()),
Err(e) => Err(e.into()),
},
}
}
#[cfg(test)]
mod test {
use super::*;
use crate::invocation::test::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
#[test]
fn test_add() {
let mut w = test_writer();
let mut replica = test_replica();
let server_dir = TempDir::new().unwrap();
let mut server = test_server(&server_dir);
let settings = Settings::default();
// Note that the details of the actual sync are tested thoroughly in the taskchampion crate
execute(&mut w, &mut replica, &settings, &mut server).unwrap();
assert_eq!(&w.into_string(), "sync complete.\n")
}
}

View file

@ -0,0 +1,28 @@
use taskchampion::Replica;
use termcolor::WriteColor;
pub(crate) fn execute<W: WriteColor>(w: &mut W, replica: &mut Replica) -> Result<(), crate::Error> {
if replica.undo()? {
writeln!(w, "Undo successful.")?;
} else {
writeln!(w, "Nothing to undo.")?;
}
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
use crate::invocation::test::*;
use pretty_assertions::assert_eq;
#[test]
fn test_undo() {
let mut w = test_writer();
let mut replica = test_replica();
// Note that the details of the actual undo operation are tested thoroughly in the taskchampion crate
execute(&mut w, &mut replica).unwrap();
assert_eq!(&w.into_string(), "Nothing to undo.\n")
}
}

View file

@ -0,0 +1,32 @@
use crate::built_info;
use termcolor::{ColorSpec, WriteColor};
pub(crate) fn execute<W: WriteColor>(w: &mut W) -> Result<(), crate::Error> {
write!(w, "TaskChampion ")?;
w.set_color(ColorSpec::new().set_bold(true))?;
write!(w, "{}", built_info::PKG_VERSION)?;
w.reset()?;
if let (Some(version), Some(dirty)) = (built_info::GIT_VERSION, built_info::GIT_DIRTY) {
if dirty {
write!(w, " (git version: {} with un-committed changes)", version)?;
} else {
write!(w, " (git version: {})", version)?;
};
}
writeln!(w)?;
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
use crate::invocation::test::*;
#[test]
fn test_version() {
let mut w = test_writer();
execute(&mut w).unwrap();
assert!(w.into_string().starts_with("TaskChampion "));
}
}

View file

@ -0,0 +1,325 @@
use crate::argparse::{Condition, Filter, TaskId};
use std::collections::HashSet;
use taskchampion::{Replica, Status, Task, Uuid, WorkingSet};
fn match_task(filter: &Filter, task: &Task, uuid: Uuid, working_set: &WorkingSet) -> bool {
for cond in &filter.conditions {
match cond {
Condition::HasTag(ref tag) => {
if !task.has_tag(tag) {
return false;
}
}
Condition::NoTag(ref tag) => {
if task.has_tag(tag) {
return false;
}
}
Condition::Status(status) => {
if task.get_status() != *status {
return false;
}
}
Condition::IdList(ids) => {
let uuid_str = uuid.to_string();
let mut found = false;
let working_set_id = working_set.by_uuid(uuid);
for id in ids {
if match id {
TaskId::WorkingSetId(i) => Some(*i) == working_set_id,
TaskId::PartialUuid(partial) => uuid_str.starts_with(partial),
TaskId::Uuid(i) => *i == uuid,
} {
found = true;
break;
}
}
if !found {
return false;
}
}
}
}
true
}
// the universe of tasks we must consider
enum Universe {
/// Scan all the tasks
AllTasks,
/// Scan the working set (for pending tasks)
WorkingSet,
/// Scan an explicit set of tasks, "Absolute" meaning either full UUID or a working set
/// index
AbsoluteIdList(Vec<TaskId>),
}
/// Determine the universe for the given filter; avoiding the need to scan all tasks in most cases.
fn universe_for_filter(filter: &Filter) -> Universe {
/// If there is a condition with Status::Pending, return true
fn has_pending_condition(filter: &Filter) -> bool {
filter
.conditions
.iter()
.any(|cond| matches!(cond, Condition::Status(Status::Pending)))
}
/// If there is a condition with an IdList containing no partial UUIDs,
/// return that.
fn absolute_id_list_condition(filter: &Filter) -> Option<Vec<TaskId>> {
filter
.conditions
.iter()
.find(|cond| {
if let Condition::IdList(ids) = cond {
!ids.iter().any(|id| matches!(id, TaskId::PartialUuid(_)))
} else {
false
}
})
.map(|cond| {
if let Condition::IdList(ids) = cond {
ids.to_vec()
} else {
unreachable!() // any condition found above must be an IdList(_)
}
})
}
if let Some(ids) = absolute_id_list_condition(filter) {
Universe::AbsoluteIdList(ids)
} else if has_pending_condition(filter) {
Universe::WorkingSet
} else {
Universe::AllTasks
}
}
/// Return the tasks matching the given filter. This will return each matching
/// task once, even if the user specified the same task multiple times on the
/// command line.
pub(super) fn filtered_tasks(
replica: &mut Replica,
filter: &Filter,
) -> anyhow::Result<impl Iterator<Item = Task>> {
let mut res = vec![];
log::debug!("Applying filter {:?}", filter);
let working_set = replica.working_set()?;
// We will enumerate the universe of tasks for this filter, checking
// each resulting task with match_task
match universe_for_filter(filter) {
// A list of IDs, but some are partial so we need to iterate over
// all tasks and pattern-match their Uuids
Universe::AbsoluteIdList(ref ids) => {
log::debug!("Scanning only the tasks specified in the filter");
// this is the only case where we might accidentally return the same task
// several times, so we must track the seen tasks.
let mut seen = HashSet::new();
for id in ids {
let task = match id {
TaskId::WorkingSetId(id) => working_set
.by_index(*id)
.map(|uuid| replica.get_task(uuid))
.transpose()?
.flatten(),
TaskId::PartialUuid(_) => unreachable!(), // not present in absolute id list
TaskId::Uuid(id) => replica.get_task(*id)?,
};
if let Some(task) = task {
// if we have already seen this task, skip ahead..
let uuid = task.get_uuid();
if seen.contains(&uuid) {
continue;
}
seen.insert(uuid);
if match_task(filter, &task, uuid, &working_set) {
res.push(task);
}
}
}
}
// All tasks -- iterate over the full set
Universe::AllTasks => {
log::debug!("Scanning all tasks in the task database");
for (uuid, task) in replica.all_tasks()?.drain() {
if match_task(filter, &task, uuid, &working_set) {
res.push(task);
}
}
}
Universe::WorkingSet => {
log::debug!("Scanning only the working set (pending tasks)");
for (_, uuid) in working_set.iter() {
if let Some(task) = replica.get_task(uuid)? {
if match_task(filter, &task, uuid, &working_set) {
res.push(task);
}
}
}
}
}
Ok(res.into_iter())
}
#[cfg(test)]
mod test {
use super::*;
use crate::invocation::test::*;
use pretty_assertions::assert_eq;
use taskchampion::Status;
#[test]
fn exact_ids() {
let mut replica = test_replica();
let t1 = replica.new_task(Status::Pending, s!("A")).unwrap();
let t2 = replica.new_task(Status::Completed, s!("B")).unwrap();
let _t = replica.new_task(Status::Pending, s!("C")).unwrap();
replica.rebuild_working_set(true).unwrap();
let t1uuid = t1.get_uuid();
let filter = Filter {
conditions: vec![Condition::IdList(vec![
TaskId::Uuid(t1uuid), // A
TaskId::WorkingSetId(1), // A (again, dups filtered)
TaskId::Uuid(t2.get_uuid()), // B
])],
};
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)
.unwrap()
.map(|t| t.get_description().to_owned())
.collect();
filtered.sort();
assert_eq!(vec![s!("A"), s!("B")], filtered);
}
#[test]
fn partial_ids() {
let mut replica = test_replica();
let t1 = replica.new_task(Status::Pending, s!("A")).unwrap();
let t2 = replica.new_task(Status::Completed, s!("B")).unwrap();
let _t = replica.new_task(Status::Pending, s!("C")).unwrap();
replica.rebuild_working_set(true).unwrap();
let t1uuid = t1.get_uuid();
let t2uuid = t2.get_uuid().to_string();
let t2partial = t2uuid[..13].to_owned();
let filter = Filter {
conditions: vec![Condition::IdList(vec![
TaskId::Uuid(t1uuid), // A
TaskId::WorkingSetId(1), // A (again, dups filtered)
TaskId::PartialUuid(t2partial), // B
])],
};
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)
.unwrap()
.map(|t| t.get_description().to_owned())
.collect();
filtered.sort();
assert_eq!(vec![s!("A"), s!("B")], filtered);
}
#[test]
fn all_tasks() {
let mut replica = test_replica();
replica.new_task(Status::Pending, s!("A")).unwrap();
replica.new_task(Status::Completed, s!("B")).unwrap();
replica.new_task(Status::Deleted, s!("C")).unwrap();
replica.rebuild_working_set(true).unwrap();
let filter = Filter { conditions: vec![] };
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)
.unwrap()
.map(|t| t.get_description().to_owned())
.collect();
filtered.sort();
assert_eq!(vec![s!("A"), s!("B"), s!("C")], filtered);
}
#[test]
fn tag_filtering() -> anyhow::Result<()> {
let mut replica = test_replica();
let yes = tag!("yes");
let no = tag!("no");
let mut t1 = replica
.new_task(Status::Pending, s!("A"))?
.into_mut(&mut replica);
t1.add_tag(&yes)?;
let mut t2 = replica
.new_task(Status::Pending, s!("B"))?
.into_mut(&mut replica);
t2.add_tag(&yes)?;
t2.add_tag(&no)?;
let mut t3 = replica
.new_task(Status::Pending, s!("C"))?
.into_mut(&mut replica);
t3.add_tag(&no)?;
let _t4 = replica.new_task(Status::Pending, s!("D"))?;
// look for just "yes" (A and B)
let filter = Filter {
conditions: vec![Condition::HasTag(tag!("yes"))],
};
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)?
.map(|t| t.get_description().to_owned())
.collect();
filtered.sort();
assert_eq!(vec![s!("A"), s!("B")], filtered);
// look for tags without "no" (A, D)
let filter = Filter {
conditions: vec![Condition::NoTag(tag!("no"))],
};
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)?
.map(|t| t.get_description().to_owned())
.collect();
filtered.sort();
assert_eq!(vec![s!("A"), s!("D")], filtered);
// look for tags with "yes" and "no" (B)
let filter = Filter {
conditions: vec![
Condition::HasTag(tag!("yes")),
Condition::HasTag(tag!("no")),
],
};
let filtered: Vec<_> = filtered_tasks(&mut replica, &filter)?
.map(|t| t.get_description().to_owned())
.collect();
assert_eq!(vec![s!("B")], filtered);
Ok(())
}
#[test]
fn pending_tasks() {
let mut replica = test_replica();
replica.new_task(Status::Pending, s!("A")).unwrap();
replica.new_task(Status::Completed, s!("B")).unwrap();
replica.new_task(Status::Deleted, s!("C")).unwrap();
replica.rebuild_working_set(true).unwrap();
let filter = Filter {
conditions: vec![Condition::Status(Status::Pending)],
};
let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)
.unwrap()
.map(|t| t.get_description().to_owned())
.collect();
filtered.sort();
assert_eq!(vec![s!("A")], filtered);
}
}

View file

@ -0,0 +1,179 @@
//! The invocation module handles invoking the commands parsed by the argparse module.
use crate::argparse::{Command, Subcommand};
use crate::settings::Settings;
use taskchampion::{Replica, Server, ServerConfig, StorageConfig, Uuid};
use termcolor::{ColorChoice, StandardStream};
mod cmd;
mod filter;
mod modify;
mod report;
mod util;
#[cfg(test)]
mod test;
use filter::filtered_tasks;
use modify::{apply_modification, resolve_modification, ResolvedModification};
use report::display_report;
/// Invoke the given Command in the context of the given settings
#[allow(clippy::needless_return)]
pub(crate) fn invoke(command: Command, settings: Settings) -> Result<(), crate::Error> {
log::debug!("command: {:?}", command);
log::debug!("settings: {:?}", settings);
let mut w = get_writer();
// This function examines the command and breaks out the necessary bits to call one of the
// `execute` functions in a submodule of `cmd`.
// match the subcommands that do not require a replica first, before
// getting the replica
match command {
Command {
subcommand: Subcommand::Help { summary },
command_name,
} => return cmd::help::execute(&mut w, command_name, summary),
Command {
subcommand: Subcommand::Config { config_operation },
..
} => return cmd::config::execute(&mut w, config_operation, &settings),
Command {
subcommand: Subcommand::Version,
..
} => return cmd::version::execute(&mut w),
_ => {}
};
let mut replica = get_replica(&settings)?;
match command {
Command {
subcommand: Subcommand::Add { modification },
..
} => {
let modification = resolve_modification(modification, &mut replica)?;
return cmd::add::execute(&mut w, &mut replica, modification);
}
Command {
subcommand:
Subcommand::Modify {
filter,
modification,
},
..
} => {
let modification = resolve_modification(modification, &mut replica)?;
return cmd::modify::execute(&mut w, &mut replica, &settings, filter, modification);
}
Command {
subcommand:
Subcommand::Report {
report_name,
filter,
},
..
} => return cmd::report::execute(&mut w, &mut replica, &settings, report_name, filter),
Command {
subcommand: Subcommand::Info { filter, debug },
..
} => return cmd::info::execute(&mut w, &mut replica, filter, debug),
Command {
subcommand: Subcommand::Gc,
..
} => return cmd::gc::execute(&mut w, &mut replica),
Command {
subcommand: Subcommand::Sync,
..
} => {
let mut server = get_server(&settings)?;
return cmd::sync::execute(&mut w, &mut replica, &settings, &mut server);
}
Command {
subcommand: Subcommand::ImportTW,
..
} => {
return cmd::import_tw::execute(&mut w, &mut replica);
}
Command {
subcommand: Subcommand::ImportTDB2 { path },
..
} => {
return cmd::import_tdb2::execute(&mut w, &mut replica, path.as_ref());
}
Command {
subcommand: Subcommand::Undo,
..
} => {
return cmd::undo::execute(&mut w, &mut replica);
}
// handled in the first match, but here to ensure this match is exhaustive
Command {
subcommand: Subcommand::Help { .. },
..
} => unreachable!(),
Command {
subcommand: Subcommand::Config { .. },
..
} => unreachable!(),
Command {
subcommand: Subcommand::Version,
..
} => unreachable!(),
};
}
// utilities for invoke
/// Get the replica for this invocation
fn get_replica(settings: &Settings) -> anyhow::Result<Replica> {
let taskdb_dir = settings.data_dir.clone();
log::debug!("Replica data_dir: {:?}", taskdb_dir);
let storage_config = StorageConfig::OnDisk { taskdb_dir };
Ok(Replica::new(storage_config.into_storage()?))
}
/// Get the server for this invocation
fn get_server(settings: &Settings) -> anyhow::Result<Box<dyn Server>> {
// if server_client_key and server_origin are both set, use
// the remote server
let config = if let (Some(client_key), Some(origin), Some(encryption_secret)) = (
settings.server_client_key.as_ref(),
settings.server_origin.as_ref(),
settings.encryption_secret.as_ref(),
) {
let client_key = Uuid::parse_str(client_key)?;
log::debug!("Using sync-server with origin {}", origin);
log::debug!("Sync client ID: {}", client_key);
ServerConfig::Remote {
origin: origin.clone(),
client_key,
encryption_secret: encryption_secret.as_bytes().to_vec(),
}
} else {
let server_dir = settings.server_dir.clone();
log::debug!("Using local sync-server at `{:?}`", server_dir);
ServerConfig::Local { server_dir }
};
config.into_server()
}
/// Get a WriteColor implementation based on whether the output is a tty.
fn get_writer() -> StandardStream {
StandardStream::stdout(if atty::is(atty::Stream::Stdout) {
ColorChoice::Auto
} else {
ColorChoice::Never
})
}

View file

@ -0,0 +1,247 @@
use crate::argparse::{DescriptionMod, Modification, TaskId};
use std::collections::HashSet;
use taskchampion::chrono::Utc;
use taskchampion::{Annotation, Replica, TaskMut};
/// A wrapper for Modification, promising that all TaskId instances are of variant TaskId::Uuid.
pub(super) struct ResolvedModification(pub(super) Modification);
/// Resolve a Modification to a ResolvedModification, based on access to a Replica.
///
/// This is not automatically done in `apply_modification` because, by that time, the TaskMut being
/// modified has an exclusive reference to the Replica, so it is impossible to search for matching
/// tasks.
pub(super) fn resolve_modification(
unres: Modification,
replica: &mut Replica,
) -> anyhow::Result<ResolvedModification> {
Ok(ResolvedModification(Modification {
description: unres.description,
status: unres.status,
wait: unres.wait,
active: unres.active,
add_tags: unres.add_tags,
remove_tags: unres.remove_tags,
add_dependencies: resolve_task_ids(replica, unres.add_dependencies)?,
remove_dependencies: resolve_task_ids(replica, unres.remove_dependencies)?,
annotate: unres.annotate,
}))
}
/// Convert a set of arbitrary TaskId's into TaskIds containing only TaskId::Uuid.
fn resolve_task_ids(
replica: &mut Replica,
task_ids: HashSet<TaskId>,
) -> anyhow::Result<HashSet<TaskId>> {
// already all UUIDs (or empty)?
if task_ids.iter().all(|tid| matches!(tid, TaskId::Uuid(_))) {
return Ok(task_ids);
}
let mut result = HashSet::new();
let mut working_set = None;
let mut all_tasks = None;
for tid in task_ids {
match tid {
TaskId::WorkingSetId(i) => {
let ws = match working_set {
Some(ref ws) => ws,
None => {
working_set = Some(replica.working_set()?);
working_set.as_ref().unwrap()
}
};
if let Some(u) = ws.by_index(i) {
result.insert(TaskId::Uuid(u));
}
}
TaskId::PartialUuid(partial) => {
let ts = match all_tasks {
Some(ref ts) => ts,
None => {
all_tasks = Some(
replica
.all_task_uuids()?
.drain(..)
.map(|u| (u, u.to_string()))
.collect::<Vec<_>>(),
);
all_tasks.as_ref().unwrap()
}
};
for (u, ustr) in ts {
if ustr.starts_with(&partial) {
result.insert(TaskId::Uuid(*u));
}
}
}
TaskId::Uuid(u) => {
result.insert(TaskId::Uuid(u));
}
}
}
Ok(result)
}
/// Apply the given modification
pub(super) fn apply_modification(
task: &mut TaskMut,
modification: &ResolvedModification,
) -> anyhow::Result<()> {
// unwrap the "Resolved" promise
let modification = &modification.0;
match modification.description {
DescriptionMod::Set(ref description) => task.set_description(description.clone())?,
DescriptionMod::Prepend(ref description) => {
task.set_description(format!("{} {}", description, task.get_description()))?
}
DescriptionMod::Append(ref description) => {
task.set_description(format!("{} {}", task.get_description(), description))?
}
DescriptionMod::None => {}
}
if let Some(ref status) = modification.status {
task.set_status(status.clone())?;
}
if let Some(true) = modification.active {
task.start()?;
}
if let Some(false) = modification.active {
task.stop()?;
}
for tag in modification.add_tags.iter() {
task.add_tag(tag)?;
}
for tag in modification.remove_tags.iter() {
task.remove_tag(tag)?;
}
if let Some(wait) = modification.wait {
task.set_wait(wait)?;
}
if let Some(ref ann) = modification.annotate {
task.add_annotation(Annotation {
entry: Utc::now(),
description: ann.into(),
})?;
}
for tid in &modification.add_dependencies {
if let TaskId::Uuid(u) = tid {
task.add_dependency(*u)?;
} else {
// this Modification is resolved, so all TaskIds should
// be the Uuid variant.
unreachable!();
}
}
for tid in &modification.remove_dependencies {
if let TaskId::Uuid(u) = tid {
task.remove_dependency(*u)?;
} else {
// this Modification is resolved, so all TaskIds should
// be the Uuid variant.
unreachable!();
}
}
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
use crate::invocation::test::*;
use pretty_assertions::assert_eq;
use taskchampion::{Status, Uuid};
#[test]
fn test_resolve_modifications() {
let mut replica = test_replica();
let u1 = Uuid::new_v4();
let t1 = replica.new_task(Status::Pending, "a task".into()).unwrap();
replica.rebuild_working_set(true).unwrap();
let modi = Modification {
add_dependencies: set![TaskId::Uuid(u1), TaskId::WorkingSetId(1)],
..Default::default()
};
let res = resolve_modification(modi, &mut replica).unwrap();
assert_eq!(
res.0.add_dependencies,
set![TaskId::Uuid(u1), TaskId::Uuid(t1.get_uuid())],
);
}
#[test]
fn test_resolve_task_ids_empty() {
let mut replica = test_replica();
assert_eq!(
resolve_task_ids(&mut replica, HashSet::new()).unwrap(),
HashSet::new()
);
}
#[test]
fn test_resolve_task_ids_all_uuids() {
let mut replica = test_replica();
let uuid = Uuid::new_v4();
let tids = set![TaskId::Uuid(uuid)];
assert_eq!(resolve_task_ids(&mut replica, tids.clone()).unwrap(), tids);
}
#[test]
fn test_resolve_task_ids_working_set_not_found() {
let mut replica = test_replica();
let tids = set![TaskId::WorkingSetId(13)];
assert_eq!(
resolve_task_ids(&mut replica, tids.clone()).unwrap(),
HashSet::new()
);
}
#[test]
fn test_resolve_task_ids_working_set() {
let mut replica = test_replica();
let t1 = replica.new_task(Status::Pending, "a task".into()).unwrap();
let t2 = replica
.new_task(Status::Pending, "another task".into())
.unwrap();
replica.rebuild_working_set(true).unwrap();
let tids = set![TaskId::WorkingSetId(1), TaskId::WorkingSetId(2)];
let resolved = set![TaskId::Uuid(t1.get_uuid()), TaskId::Uuid(t2.get_uuid())];
assert_eq!(resolve_task_ids(&mut replica, tids).unwrap(), resolved);
}
#[test]
fn test_resolve_task_ids_partial_not_found() {
let mut replica = test_replica();
let tids = set![TaskId::PartialUuid("abcd".into())];
assert_eq!(
resolve_task_ids(&mut replica, tids.clone()).unwrap(),
HashSet::new()
);
}
#[test]
fn test_resolve_task_ids_partial() {
let mut replica = test_replica();
let t1 = replica.new_task(Status::Pending, "a task".into()).unwrap();
let uuid_str = t1.get_uuid().to_string();
let tids = set![TaskId::PartialUuid(uuid_str[..8].into())];
let resolved = set![TaskId::Uuid(t1.get_uuid())];
assert_eq!(resolve_task_ids(&mut replica, tids).unwrap(), resolved);
}
}

View file

@ -0,0 +1,417 @@
use crate::argparse::Filter;
use crate::invocation::filtered_tasks;
use crate::settings::{Column, Property, Report, Settings, SortBy};
use crate::table;
use anyhow::anyhow;
use prettytable::{Row, Table};
use std::cmp::Ordering;
use taskchampion::{Replica, Task, WorkingSet};
use termcolor::WriteColor;
/// Sort tasks for the given report.
fn sort_tasks(tasks: &mut Vec<Task>, report: &Report, working_set: &WorkingSet) {
tasks.sort_by(|a, b| {
for s in &report.sort {
let ord = match s.sort_by {
SortBy::Id => {
let a_uuid = a.get_uuid();
let b_uuid = b.get_uuid();
let a_id = working_set.by_uuid(a_uuid);
let b_id = working_set.by_uuid(b_uuid);
match (a_id, b_id) {
(Some(a_id), Some(b_id)) => a_id.cmp(&b_id),
(Some(_), None) => Ordering::Less,
(None, Some(_)) => Ordering::Greater,
(None, None) => a_uuid.cmp(&b_uuid),
}
}
SortBy::Uuid => a.get_uuid().cmp(&b.get_uuid()),
SortBy::Description => a.get_description().cmp(b.get_description()),
SortBy::Wait => a.get_wait().cmp(&b.get_wait()),
};
// If this sort property is equal, go on to the next..
if ord == Ordering::Equal {
continue;
}
// Reverse order if not ascending
if s.ascending {
return ord;
} else {
return ord.reverse();
}
}
Ordering::Equal
});
}
/// Generate the string representation for the given task and column.
fn task_column(task: &Task, column: &Column, working_set: &WorkingSet) -> String {
match column.property {
Property::Id => {
let uuid = task.get_uuid();
let mut id = uuid.to_string();
if let Some(i) = working_set.by_uuid(uuid) {
id = i.to_string();
}
id
}
Property::Uuid => {
let uuid = task.get_uuid();
uuid.to_string()
}
Property::Active => match task.is_active() {
true => "*".to_owned(),
false => "".to_owned(),
},
Property::Description => task.get_description().to_owned(),
Property::Tags => {
let mut tags = task
.get_tags()
.map(|t| format!("+{}", t))
.collect::<Vec<_>>();
tags.sort();
tags.join(" ")
}
Property::Wait => {
if task.is_waiting() {
task.get_wait().unwrap().format("%Y-%m-%d").to_string()
} else {
"".to_owned()
}
}
}
}
pub(super) fn display_report<W: WriteColor>(
w: &mut W,
replica: &mut Replica,
settings: &Settings,
report_name: String,
filter: Filter,
) -> Result<(), crate::Error> {
let mut t = Table::new();
let working_set = replica.working_set()?;
// Get the report from settings
let mut report = settings
.reports
.get(&report_name)
.ok_or_else(|| anyhow!("report `{}` not defined", report_name))?
.clone();
// include any user-supplied filter conditions
report.filter = report.filter.intersect(filter);
// Get the tasks from the filter
let mut tasks: Vec<_> = filtered_tasks(replica, &report.filter)?.collect();
// ..sort them as desired
sort_tasks(&mut tasks, &report, &working_set);
// ..set up the column titles
t.set_format(table::format());
t.set_titles(report.columns.iter().map(|col| col.label.clone()).into());
// ..insert the data
for task in &tasks {
let row: Row = report
.columns
.iter()
.map(|col| task_column(task, col, &working_set))
.collect::<Row>();
t.add_row(row);
}
// ..and display it
t.print(w)?;
Ok(())
}
#[cfg(test)]
mod test {
use super::*;
use crate::invocation::test::*;
use crate::settings::Sort;
use pretty_assertions::assert_eq;
use std::convert::TryInto;
use taskchampion::chrono::{prelude::*, Duration};
use taskchampion::{Status, Uuid};
fn create_tasks(replica: &mut Replica) -> [Uuid; 3] {
let t1 = replica.new_task(Status::Pending, s!("A")).unwrap();
let t2 = replica.new_task(Status::Pending, s!("B")).unwrap();
let t3 = replica.new_task(Status::Pending, s!("C")).unwrap();
// t2 is comleted and not in the working set
let mut t2 = t2.into_mut(replica);
t2.set_status(Status::Completed).unwrap();
let t2 = t2.into_immut();
replica.rebuild_working_set(true).unwrap();
[t1.get_uuid(), t2.get_uuid(), t3.get_uuid()]
}
#[test]
fn sorting_by_descr() {
let mut replica = test_replica();
create_tasks(&mut replica);
let working_set = replica.working_set().unwrap();
let mut report = Report {
sort: vec![Sort {
ascending: true,
sort_by: SortBy::Description,
}],
..Default::default()
};
// ascending
let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect();
sort_tasks(&mut tasks, &report, &working_set);
let descriptions: Vec<_> = tasks.iter().map(|t| t.get_description()).collect();
assert_eq!(descriptions, vec!["A", "B", "C"]);
// ascending
report.sort[0].ascending = false;
let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect();
sort_tasks(&mut tasks, &report, &working_set);
let descriptions: Vec<_> = tasks.iter().map(|t| t.get_description()).collect();
assert_eq!(descriptions, vec!["C", "B", "A"]);
}
#[test]
fn sorting_by_id() {
let mut replica = test_replica();
create_tasks(&mut replica);
let working_set = replica.working_set().unwrap();
let mut report = Report {
sort: vec![Sort {
ascending: true,
sort_by: SortBy::Id,
}],
..Default::default()
};
// ascending
let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect();
sort_tasks(&mut tasks, &report, &working_set);
let descriptions: Vec<_> = tasks.iter().map(|t| t.get_description()).collect();
assert_eq!(descriptions, vec!["A", "C", "B"]);
// ascending
report.sort[0].ascending = false;
let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect();
sort_tasks(&mut tasks, &report, &working_set);
let descriptions: Vec<_> = tasks.iter().map(|t| t.get_description()).collect();
assert_eq!(descriptions, vec!["B", "C", "A"]);
}
#[test]
fn sorting_by_uuid() {
let mut replica = test_replica();
let uuids = create_tasks(&mut replica);
let working_set = replica.working_set().unwrap();
let report = Report {
sort: vec![Sort {
ascending: true,
sort_by: SortBy::Uuid,
}],
..Default::default()
};
let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect();
sort_tasks(&mut tasks, &report, &working_set);
let got_uuids: Vec<_> = tasks.iter().map(|t| t.get_uuid()).collect();
let mut exp_uuids = uuids.to_vec();
exp_uuids.sort();
assert_eq!(got_uuids, exp_uuids);
}
#[test]
fn sorting_by_wait() {
let mut replica = test_replica();
let uuids = create_tasks(&mut replica);
replica
.get_task(uuids[0])
.unwrap()
.unwrap()
.into_mut(&mut replica)
.set_wait(Some(Utc::now() + Duration::days(2)))
.unwrap();
replica
.get_task(uuids[1])
.unwrap()
.unwrap()
.into_mut(&mut replica)
.set_wait(Some(Utc::now() + Duration::days(3)))
.unwrap();
let working_set = replica.working_set().unwrap();
let report = Report {
sort: vec![Sort {
ascending: true,
sort_by: SortBy::Wait,
}],
..Default::default()
};
let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect();
sort_tasks(&mut tasks, &report, &working_set);
let got_uuids: Vec<_> = tasks.iter().map(|t| t.get_uuid()).collect();
let exp_uuids = vec![
uuids[2], // no wait
uuids[0], // wait:2d
uuids[1], // wait:3d
];
assert_eq!(got_uuids, exp_uuids);
}
#[test]
fn sorting_by_multiple() {
let mut replica = test_replica();
create_tasks(&mut replica);
// make a second task named A with a larger ID than the first
let t = replica.new_task(Status::Pending, s!("A")).unwrap();
t.into_mut(&mut replica)
.add_tag(&("second".try_into().unwrap()))
.unwrap();
let working_set = replica.working_set().unwrap();
let report = Report {
sort: vec![
Sort {
ascending: false,
sort_by: SortBy::Description,
},
Sort {
ascending: true,
sort_by: SortBy::Id,
},
],
..Default::default()
};
let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect();
sort_tasks(&mut tasks, &report, &working_set);
let descriptions: Vec<_> = tasks.iter().map(|t| t.get_description()).collect();
assert_eq!(descriptions, vec!["C", "B", "A", "A"]);
assert!(tasks[3].has_tag(&("second".try_into().unwrap())));
}
#[test]
fn task_column_id() {
let mut replica = test_replica();
let uuids = create_tasks(&mut replica);
let working_set = replica.working_set().unwrap();
let task = replica.get_task(uuids[0]).unwrap().unwrap();
let column = Column {
label: s!(""),
property: Property::Id,
};
assert_eq!(task_column(&task, &column, &working_set), s!("1"));
// get the task that's not in the working set, which should show
// a uuid for its id column
let task = replica.get_task(uuids[1]).unwrap().unwrap();
assert_eq!(
task_column(&task, &column, &working_set),
uuids[1].to_string()
);
}
#[test]
fn task_column_uuid() {
let mut replica = test_replica();
let uuids = create_tasks(&mut replica);
let working_set = replica.working_set().unwrap();
let task = replica.get_task(uuids[0]).unwrap().unwrap();
let column = Column {
label: s!(""),
property: Property::Uuid,
};
assert_eq!(
task_column(&task, &column, &working_set),
task.get_uuid().to_string()
);
}
#[test]
fn task_column_active() {
let mut replica = test_replica();
let uuids = create_tasks(&mut replica);
let working_set = replica.working_set().unwrap();
// make task A active
replica
.get_task(uuids[0])
.unwrap()
.unwrap()
.into_mut(&mut replica)
.start()
.unwrap();
let column = Column {
label: s!(""),
property: Property::Active,
};
let task = replica.get_task(uuids[0]).unwrap().unwrap();
assert_eq!(task_column(&task, &column, &working_set), s!("*"));
let task = replica.get_task(uuids[2]).unwrap().unwrap();
assert_eq!(task_column(&task, &column, &working_set), s!(""));
}
#[test]
fn task_column_description() {
let mut replica = test_replica();
let uuids = create_tasks(&mut replica);
let working_set = replica.working_set().unwrap();
let task = replica.get_task(uuids[2]).unwrap().unwrap();
let column = Column {
label: s!(""),
property: Property::Description,
};
assert_eq!(task_column(&task, &column, &working_set), s!("C"));
}
#[test]
fn task_column_tags() {
let mut replica = test_replica();
let uuids = create_tasks(&mut replica);
let working_set = replica.working_set().unwrap();
// add some tags to task A
let mut t1 = replica
.get_task(uuids[0])
.unwrap()
.unwrap()
.into_mut(&mut replica);
t1.add_tag(&("foo".try_into().unwrap())).unwrap();
t1.add_tag(&("bar".try_into().unwrap())).unwrap();
let column = Column {
label: s!(""),
property: Property::Tags,
};
let task = replica.get_task(uuids[0]).unwrap().unwrap();
assert_eq!(
task_column(&task, &column, &working_set),
s!("+PENDING +UNBLOCKED +bar +foo")
);
let task = replica.get_task(uuids[2]).unwrap().unwrap();
assert_eq!(
task_column(&task, &column, &working_set),
s!("+PENDING +UNBLOCKED")
);
}
}

View file

@ -0,0 +1,51 @@
use std::io;
use taskchampion::{storage, Replica, Server, ServerConfig};
use tempfile::TempDir;
pub(super) fn test_replica() -> Replica {
let storage = storage::InMemoryStorage::new();
Replica::new(Box::new(storage))
}
pub(super) fn test_server(dir: &TempDir) -> Box<dyn Server> {
ServerConfig::Local {
server_dir: dir.path().to_path_buf(),
}
.into_server()
.unwrap()
}
pub(super) struct TestWriter {
data: Vec<u8>,
}
impl TestWriter {
pub(super) fn into_string(self) -> String {
String::from_utf8(self.data).unwrap()
}
}
impl io::Write for TestWriter {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.data.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.data.flush()
}
}
impl termcolor::WriteColor for TestWriter {
fn supports_color(&self) -> bool {
false
}
fn set_color(&mut self, _spec: &termcolor::ColorSpec) -> io::Result<()> {
Ok(())
}
fn reset(&mut self) -> io::Result<()> {
Ok(())
}
}
pub(super) fn test_writer() -> TestWriter {
TestWriter { data: vec![] }
}

View file

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

79
rust/cli/src/lib.rs Normal file
View file

@ -0,0 +1,79 @@
#![deny(clippy::all)]
#![allow(clippy::unnecessary_wraps)] // for Rust 1.50, https://github.com/rust-lang/rust-clippy/pull/6765
#![allow(clippy::module_inception)] // we use re-exports to shorten stuttering paths like settings::settings::Settings
/*!
This crate implements the command-line interface to TaskChampion.
## Design
The crate is split into two parts: argument parsing (`argparse`) and command invocation (`invocation`).
Both are fairly complex operations, and the split serves both to isolate that complexity and to facilitate testing.
### Argparse
The TaskChampion command line API is modeled on TaskWarrior's API, which is far from that of a typical UNIX command.
Tools like `clap` and `structopt` are not flexible enough to handle this syntax.
Instead, the `argparse` module uses [nom](https://crates.io/crates/nom) to parse command lines as a sequence of words.
These parsers act on a list of strings, `&[&str]`, and at the top level return a `crate::argparse::Command`.
This is a wholly-owned repesentation of the command line's meaning, but with some interpretation.
For example, `task start`, `task stop`, and `task append` all map to a `crate::argparse::Subcommand::Modify` variant.
### Invocation
The `invocation` module executes a `Command`, given some settings and other ancillary data.
Most of its functionality is in common functions to handle filtering tasks, modifying tasks, and so on.
## Rust API
Note that this crate does not expose a Rust API for use from other crates.
For the public TaskChampion Rust API, see the `taskchampion` crate.
*/
use std::ffi::OsString;
// NOTE: it's important that this 'mod' comes first so that the macros can be used in other modules
mod macros;
mod argparse;
mod errors;
mod invocation;
mod settings;
mod table;
mod tdb2;
mod usage;
/// See https://docs.rs/built
pub(crate) mod built_info {
include!(concat!(env!("OUT_DIR"), "/built.rs"));
}
pub(crate) use errors::Error;
use settings::Settings;
// used by the `generate` command
pub use usage::Usage;
/// The main entry point for the command-line interface. This builds an Invocation
/// from the particulars of the operating-system interface, and then executes it.
pub fn main() -> Result<(), Error> {
env_logger::init();
// parse the command line into a vector of &str, failing if
// there are invalid utf-8 sequences.
let argv: Vec<String> = std::env::args_os()
.map(|oss| oss.into_string())
.collect::<Result<_, OsString>>()
.map_err(|_| Error::for_arguments("arguments must be valid utf-8"))?;
let argv: Vec<&str> = argv.iter().map(|s| s.as_ref()).collect();
// parse the command line
let command = argparse::Command::from_argv(&argv[..])?;
// load the application settings
let settings = Settings::read()?;
invocation::invoke(command, settings)?;
Ok(())
}

40
rust/cli/src/macros.rs Normal file
View file

@ -0,0 +1,40 @@
#![macro_use]
/// create a &[&str] from vec notation
#[cfg(test)]
macro_rules! argv {
() => (
&[][..]
);
($($x:expr),* $(,)?) => (
&[$($x),*][..]
);
}
/// Create a hashset, similar to vec!
// NOTE: in Rust 1.56.0, this can be changed to HashSet::from([..])
#[cfg(test)]
macro_rules! set(
{ $($key:expr),* $(,)? } => {
{
#[allow(unused_mut)]
let mut s = ::std::collections::HashSet::new();
$(
s.insert($key);
)*
s
}
};
);
/// Create a String from an &str; just a testing shorthand
#[cfg(test)]
macro_rules! s(
{ $s:expr } => { $s.to_owned() };
);
/// Create a Tag from an &str; just a testing shorthand
#[cfg(test)]
macro_rules! tag(
{ $s:expr } => { { use std::convert::TryFrom; taskchampion::Tag::try_from($s).unwrap() } };
);

View file

@ -0,0 +1,11 @@
//! Support for the CLI's configuration file, including default settings.
//!
//! Configuration is stored in a "parsed" format, meaning that any syntax errors will be caught on
//! startup and not just when those values are used.
mod report;
mod settings;
mod util;
pub(crate) use report::{get_usage, Column, Property, Report, Sort, SortBy};
pub(crate) use settings::Settings;

View file

@ -0,0 +1,580 @@
//! This module contains the data structures used to define reports.
use crate::argparse::{Condition, Filter};
use crate::settings::util::table_with_keys;
use crate::usage::{self, Usage};
use anyhow::{anyhow, bail, Result};
use std::convert::{TryFrom, TryInto};
/// A report specifies a filter as well as a sort order and information about which
/// task attributes to display
#[derive(Clone, Debug, PartialEq, Default)]
pub(crate) struct Report {
/// Columns to display in this report
pub columns: Vec<Column>,
/// Sort order for this report
pub sort: Vec<Sort>,
/// Filter selecting tasks for this report
pub filter: Filter,
}
/// A column to display in a report
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Column {
/// The label for this column
pub label: String,
/// The property to display
pub property: Property,
}
/// Task property to display in a report
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum Property {
// NOTE: when adding a property here, add it to get_usage, below, as well.
/// The task's ID, either working-set index or Uuid if not in the working set
Id,
/// The task's full UUID
Uuid,
/// Whether the task is active or not
Active,
/// The task's description
Description,
/// The task's tags
Tags,
/// The task's wait date
Wait,
}
/// A sorting criterion for a sort operation.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct Sort {
/// True if the sort should be "ascending" (a -> z, 0 -> 9, etc.)
pub ascending: bool,
/// The property to sort on
pub sort_by: SortBy,
}
/// Task property to sort by
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum SortBy {
// NOTE: when adding a property here, add it to get_usage, below, as well.
/// The task's ID, either working-set index or a UUID prefix; working
/// set tasks sort before others.
Id,
/// The task's full UUID
Uuid,
/// The task's description
Description,
/// The task's wait date
Wait,
}
// Conversions from settings::Settings.
impl TryFrom<toml::Value> for Report {
type Error = anyhow::Error;
fn try_from(cfg: toml::Value) -> Result<Report> {
Report::try_from(&cfg)
}
}
impl TryFrom<&toml::Value> for Report {
type Error = anyhow::Error;
/// Create a Report from a toml value. This should be the `report.<report_name>` value.
/// The error message begins with any additional path information, e.g., `.sort[1].sort_by:
/// ..`.
fn try_from(cfg: &toml::Value) -> Result<Report> {
let keys = ["sort", "columns", "filter"];
let table = table_with_keys(cfg, &keys).map_err(|e| anyhow!(": {}", e))?;
let sort = match table.get("sort") {
Some(v) => v
.as_array()
.ok_or_else(|| anyhow!(".sort: not an array"))?
.iter()
.enumerate()
.map(|(i, v)| v.try_into().map_err(|e| anyhow!(".sort[{}]{}", i, e)))
.collect::<Result<Vec<_>>>()?,
None => vec![],
};
let columns = match table.get("columns") {
Some(v) => v
.as_array()
.ok_or_else(|| anyhow!(".columns: not an array"))?
.iter()
.enumerate()
.map(|(i, v)| v.try_into().map_err(|e| anyhow!(".columns[{}]{}", i, e)))
.collect::<Result<Vec<_>>>()?,
None => bail!(": `columns` property is required"),
};
let conditions = match table.get("filter") {
Some(v) => v
.as_array()
.ok_or_else(|| anyhow!(".filter: not an array"))?
.iter()
.enumerate()
.map(|(i, v)| {
v.as_str()
.ok_or_else(|| anyhow!(".filter[{}]: not a string", i))
.and_then(Condition::parse_str)
.map_err(|e| anyhow!(".filter[{}]: {}", i, e))
})
.collect::<Result<Vec<_>>>()?,
None => vec![],
};
Ok(Report {
columns,
sort,
filter: Filter { conditions },
})
}
}
impl TryFrom<&toml::Value> for Column {
type Error = anyhow::Error;
fn try_from(cfg: &toml::Value) -> Result<Column> {
let keys = ["label", "property"];
let table = table_with_keys(cfg, &keys).map_err(|e| anyhow!(": {}", e))?;
let label = match table.get("label") {
Some(v) => v
.as_str()
.ok_or_else(|| anyhow!(".label: not a string"))?
.to_owned(),
None => bail!(": `label` property is required"),
};
let property = match table.get("property") {
Some(v) => v.try_into().map_err(|e| anyhow!(".property{}", e))?,
None => bail!(": `property` property is required"),
};
Ok(Column { label, property })
}
}
impl TryFrom<&toml::Value> for Property {
type Error = anyhow::Error;
fn try_from(cfg: &toml::Value) -> Result<Property> {
let s = cfg.as_str().ok_or_else(|| anyhow!(": not a string"))?;
Ok(match s {
"id" => Property::Id,
"uuid" => Property::Uuid,
"active" => Property::Active,
"description" => Property::Description,
"tags" => Property::Tags,
"wait" => Property::Wait,
_ => bail!(": unknown property {}", s),
})
}
}
impl TryFrom<&toml::Value> for Sort {
type Error = anyhow::Error;
fn try_from(cfg: &toml::Value) -> Result<Sort> {
let keys = ["ascending", "sort_by"];
let table = table_with_keys(cfg, &keys).map_err(|e| anyhow!(": {}", e))?;
let ascending = match table.get("ascending") {
Some(v) => v
.as_bool()
.ok_or_else(|| anyhow!(".ascending: not a boolean value"))?,
None => true, // default
};
let sort_by = match table.get("sort_by") {
Some(v) => v.try_into().map_err(|e| anyhow!(".sort_by{}", e))?,
None => bail!(": `sort_by` property is required"),
};
Ok(Sort { ascending, sort_by })
}
}
impl TryFrom<&toml::Value> for SortBy {
type Error = anyhow::Error;
fn try_from(cfg: &toml::Value) -> Result<SortBy> {
let s = cfg.as_str().ok_or_else(|| anyhow!(": not a string"))?;
Ok(match s {
"id" => SortBy::Id,
"uuid" => SortBy::Uuid,
"description" => SortBy::Description,
"wait" => SortBy::Wait,
_ => bail!(": unknown sort_by value `{}`", s),
})
}
}
pub(crate) fn get_usage(u: &mut Usage) {
u.report_properties.push(usage::ReportProperty {
name: "id",
as_sort_by: Some("Sort by the task's shorthand ID"),
as_column: Some("The task's shorthand ID"),
});
u.report_properties.push(usage::ReportProperty {
name: "uuid",
as_sort_by: Some("Sort by the task's full UUID"),
as_column: Some("The task's full UUID"),
});
u.report_properties.push(usage::ReportProperty {
name: "active",
as_sort_by: None,
as_column: Some("`*` if the task is active (started)"),
});
u.report_properties.push(usage::ReportProperty {
name: "wait",
as_sort_by: Some("Sort by the task's wait date, with non-waiting tasks first"),
as_column: Some("Wait date of the task"),
});
u.report_properties.push(usage::ReportProperty {
name: "description",
as_sort_by: Some("Sort by the task's description"),
as_column: Some("The task's description"),
});
u.report_properties.push(usage::ReportProperty {
name: "tags",
as_sort_by: None,
as_column: Some("The task's tags"),
});
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
use taskchampion::Status;
use toml::toml;
#[test]
fn test_report_ok() {
let val = toml! {
sort = []
columns = []
filter = ["status:pending"]
};
let report: Report = TryInto::try_into(val).unwrap();
assert_eq!(
report.filter,
Filter {
conditions: vec![Condition::Status(Status::Pending),],
}
);
assert_eq!(report.columns, vec![]);
assert_eq!(report.sort, vec![]);
}
#[test]
fn test_report_no_sort() {
let val = toml! {
filter = []
columns = []
};
let report = Report::try_from(val).unwrap();
assert_eq!(report.sort, vec![]);
}
#[test]
fn test_report_sort_not_array() {
let val = toml! {
filter = []
sort = true
columns = []
};
let err = Report::try_from(val).unwrap_err().to_string();
assert_eq!(&err, ".sort: not an array");
}
#[test]
fn test_report_sort_error() {
let val = toml! {
filter = []
sort = [ { sort_by = "id" }, true ]
columns = []
};
let err = Report::try_from(val).unwrap_err().to_string();
assert!(err.starts_with(".sort[1]"));
}
#[test]
fn test_report_unknown_prop() {
let val = toml! {
columns = []
filter = []
sort = []
nosuch = true
};
let err = Report::try_from(val).unwrap_err().to_string();
assert_eq!(&err, ": unknown table key `nosuch`");
}
#[test]
fn test_report_no_columns() {
let val = toml! {
filter = []
sort = []
};
let err = Report::try_from(val).unwrap_err().to_string();
assert_eq!(&err, ": `columns` property is required");
}
#[test]
fn test_report_columns_not_array() {
let val = toml! {
filter = []
sort = []
columns = true
};
let err = Report::try_from(val).unwrap_err().to_string();
assert_eq!(&err, ".columns: not an array");
}
#[test]
fn test_report_column_error() {
let val = toml! {
filter = []
sort = []
[[columns]]
label = "ID"
property = "id"
[[columns]]
foo = 10
};
let err = Report::try_from(val).unwrap_err().to_string();
assert_eq!(&err, ".columns[1]: unknown table key `foo`");
}
#[test]
fn test_report_filter_not_array() {
let val = toml! {
filter = "foo"
sort = []
columns = []
};
let err = Report::try_from(val).unwrap_err().to_string();
assert_eq!(&err, ".filter: not an array");
}
#[test]
fn test_report_filter_error() {
let val = toml! {
sort = []
columns = []
filter = [ "nosuchfilter" ]
};
let err = Report::try_from(val).unwrap_err().to_string();
assert!(err.starts_with(".filter[0]: invalid filter condition:"));
}
#[test]
fn test_column() {
let val = toml! {
label = "ID"
property = "id"
};
let column = Column::try_from(&val).unwrap();
assert_eq!(
column,
Column {
label: "ID".to_owned(),
property: Property::Id,
}
);
}
#[test]
fn test_column_unknown_prop() {
let val = toml! {
label = "ID"
property = "id"
nosuch = "foo"
};
assert_eq!(
&Column::try_from(&val).unwrap_err().to_string(),
": unknown table key `nosuch`"
);
}
#[test]
fn test_column_no_label() {
let val = toml! {
property = "id"
};
assert_eq!(
&Column::try_from(&val).unwrap_err().to_string(),
": `label` property is required"
);
}
#[test]
fn test_column_invalid_label() {
let val = toml! {
label = []
property = "id"
};
assert_eq!(
&Column::try_from(&val).unwrap_err().to_string(),
".label: not a string"
);
}
#[test]
fn test_column_no_property() {
let val = toml! {
label = "ID"
};
assert_eq!(
&Column::try_from(&val).unwrap_err().to_string(),
": `property` property is required"
);
}
#[test]
fn test_column_invalid_property() {
let val = toml! {
label = "ID"
property = []
};
assert_eq!(
&Column::try_from(&val).unwrap_err().to_string(),
".property: not a string"
);
}
#[test]
fn test_property() {
let val = toml::Value::String("uuid".to_owned());
let prop = Property::try_from(&val).unwrap();
assert_eq!(prop, Property::Uuid);
}
#[test]
fn test_property_invalid_type() {
let val = toml::Value::Array(vec![]);
assert_eq!(
&Property::try_from(&val).unwrap_err().to_string(),
": not a string"
);
}
#[test]
fn test_sort() {
let val = toml! {
ascending = false
sort_by = "id"
};
let sort = Sort::try_from(&val).unwrap();
assert_eq!(
sort,
Sort {
ascending: false,
sort_by: SortBy::Id,
}
);
}
#[test]
fn test_sort_no_ascending() {
let val = toml! {
sort_by = "id"
};
let sort = Sort::try_from(&val).unwrap();
assert_eq!(
sort,
Sort {
ascending: true,
sort_by: SortBy::Id,
}
);
}
#[test]
fn test_sort_unknown_prop() {
let val = toml! {
sort_by = "id"
nosuch = true
};
assert_eq!(
&Sort::try_from(&val).unwrap_err().to_string(),
": unknown table key `nosuch`"
);
}
#[test]
fn test_sort_no_sort_by() {
let val = toml! {
ascending = true
};
assert_eq!(
&Sort::try_from(&val).unwrap_err().to_string(),
": `sort_by` property is required"
);
}
#[test]
fn test_sort_invalid_ascending() {
let val = toml! {
sort_by = "id"
ascending = {}
};
assert_eq!(
&Sort::try_from(&val).unwrap_err().to_string(),
".ascending: not a boolean value"
);
}
#[test]
fn test_sort_invalid_sort_by() {
let val = toml! {
sort_by = {}
};
assert_eq!(
&Sort::try_from(&val).unwrap_err().to_string(),
".sort_by: not a string"
);
}
#[test]
fn test_sort_by() {
let val = toml::Value::String("uuid".to_string());
let prop = SortBy::try_from(&val).unwrap();
assert_eq!(prop, SortBy::Uuid);
}
#[test]
fn test_sort_by_unknown() {
let val = toml::Value::String("nosuch".to_string());
assert_eq!(
&SortBy::try_from(&val).unwrap_err().to_string(),
": unknown sort_by value `nosuch`"
);
}
#[test]
fn test_sort_by_invalid_type() {
let val = toml::Value::Array(vec![]);
assert_eq!(
&SortBy::try_from(&val).unwrap_err().to_string(),
": not a string"
);
}
}

View file

@ -0,0 +1,449 @@
use super::util::table_with_keys;
use super::{Column, Property, Report, Sort, SortBy};
use crate::argparse::{Condition, Filter};
use anyhow::{anyhow, bail, Context, Result};
use std::collections::HashMap;
use std::convert::TryFrom;
use std::env;
use std::fs;
use std::path::PathBuf;
use taskchampion::Status;
use toml::value::Table;
use toml_edit::Document;
#[derive(Debug, PartialEq)]
pub(crate) struct Settings {
/// filename from which this configuration was loaded, if any
pub(crate) filename: Option<PathBuf>,
/// Maximum number of tasks to modify without a confirmation prompt; `Some(0)` means to never
/// prompt, and `None` means to use the default value.
pub(crate) modification_count_prompt: Option<i64>,
/// replica
pub(crate) data_dir: PathBuf,
pub(crate) avoid_snapshots: bool,
/// remote sync server
pub(crate) server_client_key: Option<String>,
pub(crate) server_origin: Option<String>,
pub(crate) encryption_secret: Option<String>,
/// local sync server
pub(crate) server_dir: PathBuf,
/// reports
pub(crate) reports: HashMap<String, Report>,
}
impl Settings {
pub(crate) fn read() -> Result<Self> {
if let Some(config_file) = env::var_os("TASKCHAMPION_CONFIG") {
log::debug!("Loading configuration from {:?}", config_file);
env::remove_var("TASKCHAMPION_CONFIG");
Self::load_from_file(config_file.into(), true)
} else if let Some(filename) = Settings::default_filename() {
log::debug!("Loading configuration from {:?} (optional)", filename);
Self::load_from_file(filename, false)
} else {
Ok(Default::default())
}
}
/// Get the default filename for the configuration, or None if that cannot
/// be determined.
fn default_filename() -> Option<PathBuf> {
dirs_next::config_dir().map(|dir| dir.join("taskchampion.toml"))
}
/// Update this settings object with the contents of the given TOML file. Top-level settings
/// are overwritten, and reports are overwritten by name.
pub(crate) fn load_from_file(config_file: PathBuf, required: bool) -> Result<Self> {
let mut settings = Self::default();
let config_toml = match fs::read_to_string(config_file.clone()) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return if required {
Err(e.into())
} else {
settings.filename = Some(config_file);
Ok(settings)
};
}
Err(e) => return Err(e.into()),
Ok(s) => s,
};
let config_toml = config_toml
.parse::<toml::Value>()
.with_context(|| format!("error while reading {:?}", config_file))?;
settings.filename = Some(config_file.clone());
settings
.update_from_toml(&config_toml)
.with_context(|| format!("error while parsing {:?}", config_file))?;
Ok(settings)
}
/// Update this object with configuration from the given config file. This is
/// broken out mostly for convenience in error handling
fn update_from_toml(&mut self, config_toml: &toml::Value) -> Result<()> {
let table_keys = [
"data_dir",
"modification_count_prompt",
"avoid_snapshots",
"server_client_key",
"server_origin",
"encryption_secret",
"server_dir",
"reports",
];
let table = table_with_keys(config_toml, &table_keys)?;
fn get_str_cfg<F: FnOnce(String)>(
table: &Table,
name: &'static str,
setter: F,
) -> Result<()> {
if let Some(v) = table.get(name) {
setter(
v.as_str()
.ok_or_else(|| anyhow!(".{}: not a string", name))?
.to_owned(),
);
}
Ok(())
}
fn get_i64_cfg<F: FnOnce(i64)>(table: &Table, name: &'static str, setter: F) -> Result<()> {
if let Some(v) = table.get(name) {
setter(
v.as_integer()
.ok_or_else(|| anyhow!(".{}: not a number", name))?,
);
}
Ok(())
}
fn get_bool_cfg<F: FnOnce(bool)>(
table: &Table,
name: &'static str,
setter: F,
) -> Result<()> {
if let Some(v) = table.get(name) {
setter(
v.as_bool()
.ok_or_else(|| anyhow!(".{}: not a boolean value", name))?,
);
}
Ok(())
}
get_str_cfg(table, "data_dir", |v| {
self.data_dir = v.into();
})?;
get_i64_cfg(table, "modification_count_prompt", |v| {
self.modification_count_prompt = Some(v);
})?;
get_bool_cfg(table, "avoid_snapshots", |v| {
self.avoid_snapshots = v;
})?;
get_str_cfg(table, "server_client_key", |v| {
self.server_client_key = Some(v);
})?;
get_str_cfg(table, "server_origin", |v| {
self.server_origin = Some(v);
})?;
get_str_cfg(table, "encryption_secret", |v| {
self.encryption_secret = Some(v);
})?;
get_str_cfg(table, "server_dir", |v| {
self.server_dir = v.into();
})?;
if let Some(v) = table.get("reports") {
let report_cfgs = v
.as_table()
.ok_or_else(|| anyhow!(".reports: not a table"))?;
for (name, cfg) in report_cfgs {
let report = Report::try_from(cfg).map_err(|e| anyhow!("reports.{}{}", name, e))?;
self.reports.insert(name.clone(), report);
}
}
Ok(())
}
/// Set a value in the config file, modifying it in place. Returns the filename. The value is
/// interpreted as the appropriate type for the configuration setting.
pub(crate) fn set(&self, key: &str, value: &str) -> Result<PathBuf> {
let allowed_keys = [
"data_dir",
"modification_count_prompt",
"server_client_key",
"server_origin",
"encryption_secret",
"server_dir",
// reports is not allowed, since it is not a string
];
if !allowed_keys.contains(&key) {
bail!("No such configuration key {}", key);
}
let filename = if let Some(ref f) = self.filename {
f.clone()
} else {
Settings::default_filename()
.ok_or_else(|| anyhow!("Could not determine config file name"))?
};
let exists = filename.exists();
// try to create the parent directory if the file does not exist
if !exists {
if let Some(dir) = filename.parent() {
fs::create_dir_all(dir)?;
}
}
// start with the existing document, or a blank document
let mut document = if exists {
fs::read_to_string(filename.clone())
.context("Could not read existing configuration file")?
.parse::<Document>()
.context("Could not parse existing configuration file")?
} else {
Document::new()
};
// set the value as the correct type
match key {
// integers
"modification_count_prompt" => {
let value: i64 = value.parse()?;
document[key] = toml_edit::value(value);
}
// most keys are strings
_ => document[key] = toml_edit::value(value),
}
fs::write(filename.clone(), document.to_string())
.context("Could not write updated configuration file")?;
Ok(filename)
}
}
impl Default for Settings {
fn default() -> Self {
let data_dir;
let server_dir;
if let Some(dir) = dirs_next::data_local_dir() {
data_dir = dir.join("taskchampion");
server_dir = dir.join("taskchampion-sync-server");
} else {
// fallback
data_dir = PathBuf::from(".");
server_dir = PathBuf::from(".");
}
// define the default reports
let mut reports = HashMap::new();
reports.insert(
"list".to_owned(),
Report {
sort: vec![Sort {
ascending: true,
sort_by: SortBy::Uuid,
}],
columns: vec![
Column {
label: "id".to_owned(),
property: Property::Id,
},
Column {
label: "description".to_owned(),
property: Property::Description,
},
Column {
label: "active".to_owned(),
property: Property::Active,
},
Column {
label: "tags".to_owned(),
property: Property::Tags,
},
Column {
label: "wait".to_owned(),
property: Property::Wait,
},
],
filter: Default::default(),
},
);
reports.insert(
"next".to_owned(),
Report {
sort: vec![
Sort {
ascending: true,
sort_by: SortBy::Id,
},
Sort {
ascending: true,
sort_by: SortBy::Uuid,
},
],
columns: vec![
Column {
label: "id".to_owned(),
property: Property::Id,
},
Column {
label: "description".to_owned(),
property: Property::Description,
},
Column {
label: "active".to_owned(),
property: Property::Active,
},
Column {
label: "tags".to_owned(),
property: Property::Tags,
},
],
filter: Filter {
conditions: vec![Condition::Status(Status::Pending)],
},
},
);
Self {
filename: None,
data_dir,
modification_count_prompt: None,
avoid_snapshots: false,
server_client_key: None,
server_origin: None,
encryption_secret: None,
server_dir,
reports,
}
}
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use toml::toml;
#[test]
fn test_load_from_file_not_required() {
let cfg_dir = TempDir::new().unwrap();
let cfg_file = cfg_dir.path().join("foo.toml");
let settings = Settings::load_from_file(cfg_file.clone(), false).unwrap();
let mut expected = Settings::default();
expected.filename = Some(cfg_file.clone());
assert_eq!(settings, expected);
}
#[test]
fn test_load_from_file_required() {
let cfg_dir = TempDir::new().unwrap();
assert!(Settings::load_from_file(cfg_dir.path().join("foo.toml"), true).is_err());
}
#[test]
fn test_load_from_file_exists() {
let cfg_dir = TempDir::new().unwrap();
let cfg_file = cfg_dir.path().join("foo.toml");
fs::write(cfg_file.clone(), "data_dir = \"/nowhere\"").unwrap();
let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap();
assert_eq!(settings.data_dir, PathBuf::from("/nowhere"));
assert_eq!(settings.filename, Some(cfg_file));
}
#[test]
fn test_update_from_toml_top_level_keys() {
let val = toml! {
data_dir = "/data"
modification_count_prompt = 42
server_client_key = "sck"
server_origin = "so"
encryption_secret = "es"
server_dir = "/server"
};
let mut settings = Settings::default();
settings.update_from_toml(&val).unwrap();
assert_eq!(settings.data_dir, PathBuf::from("/data"));
assert_eq!(settings.modification_count_prompt, Some(42));
assert_eq!(settings.server_client_key, Some("sck".to_owned()));
assert_eq!(settings.server_origin, Some("so".to_owned()));
assert_eq!(settings.encryption_secret, Some("es".to_owned()));
assert_eq!(settings.server_dir, PathBuf::from("/server"));
}
#[test]
fn test_update_from_toml_report() {
let val = toml! {
[reports.foo]
sort = [ { sort_by = "id" } ]
columns = [ { label = "ID", property = "id" } ]
};
let mut settings = Settings::default();
settings.update_from_toml(&val).unwrap();
assert!(settings.reports.get("foo").is_some());
// beyond existence of this report, we can rely on Report's unit tests
}
#[test]
fn test_set_valid_key() {
let cfg_dir = TempDir::new().unwrap();
let cfg_file = cfg_dir.path().join("foo.toml");
fs::write(cfg_file.clone(), "server_dir = \"/srv\"").unwrap();
let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap();
assert_eq!(settings.filename, Some(cfg_file.clone()));
settings.set("data_dir", "/data").unwrap();
settings.set("modification_count_prompt", "42").unwrap();
// load the file again and see the changes
let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap();
assert_eq!(settings.data_dir, PathBuf::from("/data"));
assert_eq!(settings.server_dir, PathBuf::from("/srv"));
assert_eq!(settings.filename, Some(cfg_file));
assert_eq!(settings.modification_count_prompt, Some(42));
}
#[test]
fn test_set_invalid_key() {
let cfg_dir = TempDir::new().unwrap();
let cfg_file = cfg_dir.path().join("foo.toml");
fs::write(cfg_file.clone(), "server_dir = \"/srv\"").unwrap();
let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap();
assert_eq!(settings.filename, Some(cfg_file.clone()));
assert!(settings
.set("modification_count_prompt", "a string?")
.is_err());
}
}

View file

@ -0,0 +1,42 @@
use anyhow::{anyhow, bail, Result};
use toml::value::Table;
/// Check that the input is a table and contains no keys not in the given list, returning
/// the table.
pub(super) fn table_with_keys<'a>(cfg: &'a toml::Value, keys: &[&str]) -> Result<&'a Table> {
let table = cfg.as_table().ok_or_else(|| anyhow!("not a table"))?;
for tk in table.keys() {
if !keys.iter().any(|k| k == tk) {
bail!("unknown table key `{}`", tk);
}
}
Ok(table)
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
use toml::toml;
#[test]
fn test_dissect_table_missing() {
let val = toml! { bar = true };
let diss = table_with_keys(&val, &["foo", "bar"]).unwrap();
assert_eq!(diss.get("bar"), Some(&toml::Value::Boolean(true)));
assert_eq!(diss.get("foo"), None);
}
#[test]
fn test_dissect_table_extra() {
let val = toml! { nosuch = 10 };
assert!(table_with_keys(&val, &["foo", "bar"]).is_err());
}
#[test]
fn test_dissect_table_not_a_table() {
let val = toml::Value::Array(vec![]);
assert!(table_with_keys(&val, &["foo", "bar"]).is_err());
}
}

8
rust/cli/src/table.rs Normal file
View file

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

326
rust/cli/src/tdb2/mod.rs Normal file
View file

@ -0,0 +1,326 @@
//! TDB2 is TaskWarrior's on-disk database format. The set of tasks is represented in
//! `pending.data` and `completed.data`. There are other `.data` files as well, but those are not
//! used in TaskChampion.
use nom::{branch::*, character::complete::*, combinator::*, multi::*, sequence::*, IResult};
use std::fmt;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct File {
pub(crate) lines: Vec<Line>,
}
#[derive(Clone, PartialEq)]
pub(crate) struct Line {
pub(crate) attrs: Vec<Attr>,
}
#[derive(Clone, PartialEq)]
pub(crate) struct Attr {
pub(crate) name: String,
pub(crate) value: String,
}
impl File {
pub(crate) fn from_str(input: &str) -> Result<File, ()> {
File::parse(input).map(|(_, res)| res).map_err(|_| ())
}
fn parse(input: &str) -> IResult<&str, File> {
all_consuming(fold_many0(
// allow windows or normal newlines
terminated(Line::parse, pair(opt(char('\r')), char('\n'))),
File { lines: vec![] },
|mut file, line| {
file.lines.push(line);
file
},
))(input)
}
}
impl Line {
/// Parse a line in a TDB2 file. See TaskWarrior's Task::Parse.
fn parse(input: &str) -> IResult<&str, Line> {
fn to_line(input: Vec<Attr>) -> Result<Line, ()> {
Ok(Line { attrs: input })
}
map_res(
delimited(
char('['),
separated_list0(char(' '), Attr::parse),
char(']'),
),
to_line,
)(input)
}
}
impl fmt::Debug for Line {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("line!")?;
f.debug_list().entries(self.attrs.iter()).finish()
}
}
impl Attr {
/// Parse an attribute (name-value pair).
fn parse(input: &str) -> IResult<&str, Attr> {
fn to_attr(input: (&str, String)) -> Result<Attr, ()> {
Ok(Attr {
name: input.0.into(),
value: input.1,
})
}
map_res(
separated_pair(Attr::parse_name, char(':'), Attr::parse_value),
to_attr,
)(input)
}
/// Parse an attribute name, which is composed of any character but `:`.
fn parse_name(input: &str) -> IResult<&str, &str> {
recognize(many1(none_of(":")))(input)
}
/// Parse and interpret a quoted string. Note that this does _not_ reverse the effects of
fn parse_value(input: &str) -> IResult<&str, String> {
// For the parsing part of the job, see Pig::getQuoted in TaskWarrior's libshared, which
// merely finds the end of a string.
//
// The interpretation is defined in json::decode in libshared. Fortunately, the data we
// are reading was created with json::encode, which does not perform unicode escaping.
fn escaped_string_char(input: &str) -> IResult<&str, char> {
alt((
// reverse the escaping performed in json::encode
preceded(
char('\\'),
alt((
// some characters are simply escaped
one_of(r#""\/"#),
// others translate to control characters
value('\x08', char('b')),
value('\x0c', char('f')),
value('\n', char('n')),
value('\r', char('r')),
value('\t', char('t')),
)),
),
// not a backslash or double-quote
none_of("\"\\"),
))(input)
}
let inner = fold_many0(
escaped_string_char,
String::new(),
|mut string, fragment| {
string.push(fragment);
string
},
);
delimited(char('"'), inner, char('"'))(input)
}
}
impl fmt::Debug for Attr {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_fmt(format_args!("{:?} => {:?}", self.name, self.value))
}
}
#[cfg(test)]
mod test {
use super::*;
use pretty_assertions::assert_eq;
macro_rules! line {
($($n:expr => $v:expr),* $(,)?) => (
Line{attrs: vec![$(Attr{name: $n.into(), value: $v.into()}),*]}
);
}
#[test]
fn file() {
assert_eq!(
File::parse(include_str!("test.data")).unwrap(),
(
"",
File {
lines: vec![
line![
"description" => "snake 🐍",
"entry" => "1641670385",
"modified" => "1641670385",
"priority" => "M",
"status" => "pending",
"uuid" => "f19086c2-1f8d-4a6c-9b8d-f94901fb8e62",
],
line![
"annotation_1585711454" =>
"https://blog.tensorflow.org/2020/03/face-and-hand-tracking-in-browser-with-mediapipe-and-tensorflowjs.html?linkId=83993617",
"description" => "try facemesh",
"entry" => "1585711451",
"modified" => "1592947544",
"priority" => "M",
"project" => "lists",
"status" => "pending",
"tags" => "idea",
"tags_idea" => "x",
"uuid" => "ee855dc7-6f61-408c-bc95-ebb52f7d529c",
],
line![
"description" => "testing",
"entry" => "1554074416",
"modified" => "1554074416",
"priority" => "M",
"status" => "pending",
"uuid" => "4578fb67-359b-4483-afe4-fef15925ccd6",
],
line![
"description" => "testing2",
"entry" => "1576352411",
"modified" => "1576352411",
"priority" => "M",
"status" => "pending",
"uuid" => "f5982cca-2ea1-4bfd-832c-9bd571dc0743",
],
line![
"description" => "new-task",
"entry" => "1576352696",
"modified" => "1576352696",
"priority" => "M",
"status" => "pending",
"uuid" => "cfee3170-f153-4075-aa1d-e20bcac2841b",
],
line![
"description" => "foo",
"entry" => "1579398776",
"modified" => "1579398776",
"priority" => "M",
"status" => "pending",
"uuid" => "df74ea94-5122-44fa-965a-637412fbbffc",
],
]
}
)
);
}
#[test]
fn empty_line() {
assert_eq!(Line::parse("[]").unwrap(), ("", line![]));
}
#[test]
fn escaped_line() {
assert_eq!(
Line::parse(r#"[annotation_1585711454:"\"\\\"" abc:"xx"]"#).unwrap(),
(
"",
line!["annotation_1585711454" => "\"\\\"", "abc" => "xx"]
)
);
}
#[test]
fn escaped_line_backslash() {
assert_eq!(
Line::parse(r#"[abc:"xx" 123:"x\\x"]"#).unwrap(),
("", line!["abc" => "xx", "123" => "x\\x"])
);
}
#[test]
fn escaped_line_quote() {
assert_eq!(
Line::parse(r#"[abc:"xx" 123:"x\"x"]"#).unwrap(),
("", line!["abc" => "xx", "123" => "x\"x"])
);
}
#[test]
fn unicode_line() {
assert_eq!(
Line::parse(r#"[description:"snake 🐍" entry:"1641670385" modified:"1641670385" priority:"M" status:"pending" uuid:"f19086c2-1f8d-4a6c-9b8d-f94901fb8e62"]"#).unwrap(),
("", line![
"description" => "snake 🐍",
"entry" => "1641670385",
"modified" => "1641670385",
"priority" => "M",
"status" => "pending",
"uuid" => "f19086c2-1f8d-4a6c-9b8d-f94901fb8e62",
]));
}
#[test]
fn backslashed_attr() {
assert!(Attr::parse(r#"one:"\""#).is_err());
assert_eq!(
Attr::parse(r#"two:"\\""#).unwrap(),
(
"",
Attr {
name: "two".into(),
value: r#"\"#.into(),
}
)
);
assert!(Attr::parse(r#"three:"\\\""#).is_err());
assert_eq!(
Attr::parse(r#"four:"\\\\""#).unwrap(),
(
"",
Attr {
name: "four".into(),
value: r#"\\"#.into(),
}
)
);
}
#[test]
fn backslash_frontslash() {
assert_eq!(
Attr::parse(r#"front:"\/""#).unwrap(),
(
"",
Attr {
name: "front".into(),
value: r#"/"#.into(),
}
)
);
}
#[test]
fn backslash_control_chars() {
assert_eq!(
Attr::parse(r#"control:"\b\f\n\r\t""#).unwrap(),
(
"",
Attr {
name: "control".into(),
value: "\x08\x0c\x0a\x0d\x09".into(),
}
)
);
}
#[test]
fn url_attr() {
assert_eq!(
Attr::parse(r#"annotation_1585711454:"https:\/\/blog.tensorflow.org\/2020\/03\/""#)
.unwrap(),
(
"",
Attr {
name: "annotation_1585711454".into(),
value: "https://blog.tensorflow.org/2020/03/".into(),
}
)
);
}
}

View file

@ -0,0 +1,6 @@
[description:"snake 🐍" entry:"1641670385" modified:"1641670385" priority:"M" status:"pending" uuid:"f19086c2-1f8d-4a6c-9b8d-f94901fb8e62"]
[annotation_1585711454:"https:\/\/blog.tensorflow.org\/2020\/03\/face-and-hand-tracking-in-browser-with-mediapipe-and-tensorflowjs.html?linkId=83993617" description:"try facemesh" entry:"1585711451" modified:"1592947544" priority:"M" project:"lists" status:"pending" tags:"idea" tags_idea:"x" uuid:"ee855dc7-6f61-408c-bc95-ebb52f7d529c"]
[description:"testing" entry:"1554074416" modified:"1554074416" priority:"M" status:"pending" uuid:"4578fb67-359b-4483-afe4-fef15925ccd6"]
[description:"testing2" entry:"1576352411" modified:"1576352411" priority:"M" status:"pending" uuid:"f5982cca-2ea1-4bfd-832c-9bd571dc0743"]
[description:"new-task" entry:"1576352696" modified:"1576352696" priority:"M" status:"pending" uuid:"cfee3170-f153-4075-aa1d-e20bcac2841b"]
[description:"foo" entry:"1579398776" modified:"1579398776" priority:"M" status:"pending" uuid:"df74ea94-5122-44fa-965a-637412fbbffc"]

312
rust/cli/src/usage.rs Normal file
View file

@ -0,0 +1,312 @@
//! This module handles creation of CLI usage documents (--help, manpages, etc.) in
//! a way that puts the source of that documentation near its implementation.
use crate::argparse;
use crate::settings;
use anyhow::Result;
use std::io::Write;
#[cfg(feature = "usage-docs")]
use std::fmt::Write as FmtWrite;
/// A top-level structure containing usage/help information for the entire CLI.
#[derive(Debug, Default)]
pub struct Usage {
pub(crate) subcommands: Vec<Subcommand>,
pub(crate) filters: Vec<Filter>,
pub(crate) modifications: Vec<Modification>,
pub(crate) report_properties: Vec<ReportProperty>,
}
impl Usage {
/// Get a new, completely-filled-out usage object
pub fn new() -> Self {
let mut rv = Self {
..Default::default()
};
argparse::get_usage(&mut rv);
settings::get_usage(&mut rv);
rv
}
/// Write this usage to the given output as a help string, writing a short version if `summary`
/// is true.
pub(crate) fn write_help<W: Write>(
&self,
mut w: W,
command_name: &str,
summary: bool,
) -> Result<()> {
write!(
w,
"TaskChampion {}: Personal task-tracking\n\n",
env!("CARGO_PKG_VERSION")
)?;
writeln!(w, "USAGE:\n {} [args]\n", command_name)?;
writeln!(w, "TaskChampion subcommands:")?;
for subcommand in self.subcommands.iter() {
subcommand.write_help(&mut w, command_name, summary)?;
}
writeln!(w, "Filter Expressions:\n")?;
writeln!(
w,
"{}",
indented(
"
Where [filter] appears above, zero or more of the following arguments can be used
to limit the tasks addressed by the subcommand.",
""
)
)?;
for filter in self.filters.iter() {
filter.write_help(&mut w, command_name, summary)?;
}
writeln!(w, "Modifications:\n")?;
writeln!(
w,
"{}",
indented(
"
Where [modification] appears above, zero or more of the following arguments can be
used to modify the selected tasks.",
""
)
)?;
for modification in self.modifications.iter() {
modification.write_help(&mut w, command_name, summary)?;
}
if !summary {
writeln!(w, "\nSee `{} help` for more detail", command_name)?;
}
Ok(())
}
#[cfg(feature = "usage-docs")]
/// Substitute strings matching
///
/// ```text
/// <!-- INSERT GENERATED DOCUMENTATION - $type -->
/// ```
///
/// With the appropriate documentation.
pub fn substitute_docs(&self, content: &str) -> Result<String> {
// this is not efficient, but it doesn't need to be
let lines = content.lines();
let mut w = String::new();
const DOC_HEADER_PREFIX: &str = "<!-- INSERT GENERATED DOCUMENTATION - ";
const DOC_HEADER_SUFFIX: &str = " -->";
for line in lines {
if line.starts_with(DOC_HEADER_PREFIX) && line.ends_with(DOC_HEADER_SUFFIX) {
let doc_type = &line[DOC_HEADER_PREFIX.len()..line.len() - DOC_HEADER_SUFFIX.len()];
match doc_type {
"subcommands" => {
for subcommand in self.subcommands.iter() {
subcommand.write_markdown(&mut w)?;
}
}
"filters" => {
for filter in self.filters.iter() {
filter.write_markdown(&mut w)?;
}
}
"modifications" => {
for modification in self.modifications.iter() {
modification.write_markdown(&mut w)?;
}
}
"report-columns" => {
for prop in self.report_properties.iter() {
prop.write_column_markdown(&mut w)?;
}
}
"report-sort-by" => {
for prop in self.report_properties.iter() {
prop.write_sort_by_markdown(&mut w)?;
}
}
_ => anyhow::bail!("Unkonwn doc type {}", doc_type),
}
} else {
writeln!(w, "{}", line)?;
}
}
Ok(w)
}
}
/// wrap an indented string
fn indented(string: &str, indent: &str) -> String {
let termwidth = textwrap::termwidth();
let words: Vec<&str> = string.split_whitespace().collect();
let string = words.join(" ");
textwrap::indent(
textwrap::fill(string.trim(), termwidth - indent.len()).as_ref(),
indent,
)
}
/// Usage documentation for a subcommand
#[derive(Debug, Default)]
pub(crate) struct Subcommand {
/// Name of the subcommand
pub(crate) name: &'static str,
/// Syntax summary, without command_name
pub(crate) syntax: &'static str,
/// One-line description of the subcommand. Use an initial capital and no trailing period.
pub(crate) summary: &'static str,
/// Multi-line description of the subcommand. It's OK for this to duplicate summary, as the
/// two are not displayed together.
pub(crate) description: &'static str,
}
impl Subcommand {
fn write_help<W: Write>(&self, mut w: W, command_name: &str, summary: bool) -> Result<()> {
if summary {
writeln!(w, " {} {} - {}", command_name, self.name, self.summary)?;
} else {
writeln!(
w,
" {} {}\n{}",
command_name,
self.syntax,
indented(self.description, " ")
)?;
}
Ok(())
}
#[cfg(feature = "usage-docs")]
fn write_markdown<W: FmtWrite>(&self, mut w: W) -> Result<()> {
writeln!(w, "### `ta {}` - {}", self.name, self.summary)?;
writeln!(w, "```shell\nta {}\n```", self.syntax)?;
writeln!(w, "{}", indented(self.description, ""))?;
writeln!(w)?;
Ok(())
}
}
/// Usage documentation for a filter argument
#[derive(Debug, Default)]
pub(crate) struct Filter {
/// Syntax summary
pub(crate) syntax: &'static str,
/// One-line description of the filter. Use all-caps words for placeholders.
pub(crate) summary: &'static str,
/// Multi-line description of the filter. It's OK for this to duplicate summary, as the
/// two are not displayed together.
pub(crate) description: &'static str,
}
impl Filter {
fn write_help<W: Write>(&self, mut w: W, _: &str, summary: bool) -> Result<()> {
if summary {
writeln!(w, " {} - {}", self.syntax, self.summary)?;
} else {
write!(
w,
" {}\n{}\n",
self.syntax,
indented(self.description, " ")
)?;
}
Ok(())
}
#[cfg(feature = "usage-docs")]
fn write_markdown<W: FmtWrite>(&self, mut w: W) -> Result<()> {
writeln!(w, "* `{}` - {}", self.syntax, self.summary)?;
writeln!(w)?;
writeln!(w, "{}", indented(self.description, " "))?;
writeln!(w)?;
Ok(())
}
}
/// Usage documentation for a modification argument
#[derive(Debug, Default)]
pub(crate) struct Modification {
/// Syntax summary
pub(crate) syntax: &'static str,
/// One-line description of the modification. Use all-caps words for placeholders.
pub(crate) summary: &'static str,
/// Multi-line description of the modification. It's OK for this to duplicate summary, as the
/// two are not displayed together.
pub(crate) description: &'static str,
}
impl Modification {
fn write_help<W: Write>(&self, mut w: W, _: &str, summary: bool) -> Result<()> {
if summary {
writeln!(w, " {} - {}", self.syntax, self.summary)?;
} else {
writeln!(
w,
" {}\n{}",
self.syntax,
indented(self.description, " ")
)?;
}
Ok(())
}
#[cfg(feature = "usage-docs")]
fn write_markdown<W: FmtWrite>(&self, mut w: W) -> Result<()> {
writeln!(w, "* `{}` - {}", self.syntax, self.summary)?;
writeln!(w)?;
writeln!(w, "{}", indented(self.description, " "))?;
writeln!(w)?;
Ok(())
}
}
/// Usage documentation for a report property (which may be used for sorting, as a column, or
/// both).
#[allow(dead_code)]
#[derive(Debug, Default)]
pub(crate) struct ReportProperty {
/// Name of the property
pub(crate) name: &'static str,
/// Usage description for sorting, if any
pub(crate) as_sort_by: Option<&'static str>,
/// Usage description as a column, if any
pub(crate) as_column: Option<&'static str>,
}
impl ReportProperty {
#[cfg(feature = "usage-docs")]
fn write_sort_by_markdown<W: FmtWrite>(&self, mut w: W) -> Result<()> {
if let Some(as_sort_by) = self.as_sort_by {
writeln!(w, "* `{}`", self.name)?;
writeln!(w)?;
writeln!(w, "{}", indented(as_sort_by, " "))?;
writeln!(w)?;
}
Ok(())
}
#[cfg(feature = "usage-docs")]
fn write_column_markdown<W: FmtWrite>(&self, mut w: W) -> Result<()> {
if let Some(as_column) = self.as_column {
writeln!(w, "* `{}`", self.name)?;
writeln!(w)?;
writeln!(w, "{}", indented(as_column, " "))?;
writeln!(w)?;
}
Ok(())
}
}

63
rust/cli/tests/cli.rs Normal file
View file

@ -0,0 +1,63 @@
use assert_cmd::prelude::*;
use predicates::prelude::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
// NOTE: This tests that the `ta` binary is running and parsing arguments. The details of
// subcommands are handled with unit tests.
/// These tests force config to be read via TASKCHAMPION_CONFIG so that a user's own config file
/// (in their homedir) does not interfere with tests.
fn test_cmd(dir: &TempDir) -> Result<Command, Box<dyn std::error::Error>> {
let config_filename = dir.path().join("config.toml");
fs::write(
config_filename.clone(),
format!("data_dir = {:?}", dir.path()),
)?;
let config_filename = config_filename.to_str().unwrap();
let mut cmd = Command::cargo_bin("ta")?;
cmd.env("TASKCHAMPION_CONFIG", config_filename);
Ok(cmd)
}
#[test]
fn help() -> Result<(), Box<dyn std::error::Error>> {
let dir = TempDir::new().unwrap();
let mut cmd = test_cmd(&dir)?;
cmd.arg("--help");
cmd.assert()
.success()
.stdout(predicate::str::contains("Personal task-tracking"));
Ok(())
}
#[test]
fn version() -> Result<(), Box<dyn std::error::Error>> {
let dir = TempDir::new().unwrap();
let mut cmd = test_cmd(&dir)?;
cmd.arg("--version");
cmd.assert()
.success()
.stdout(predicate::str::contains("TaskChampion"));
Ok(())
}
#[test]
fn invalid_option() -> Result<(), Box<dyn std::error::Error>> {
let dir = TempDir::new().unwrap();
let mut cmd = test_cmd(&dir)?;
cmd.arg("--no-such-option");
cmd.assert()
.failure()
.stderr(predicate::str::contains("command line not recognized"))
.code(predicate::eq(3));
Ok(())
}

2
rust/docs/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
book
tmp

10
rust/docs/README.md Normal file
View file

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

View file

@ -0,0 +1,2 @@
Copyright (C) Andrew Savchenko - All Rights Reserved
All files within this folder are proprietary and reserved for the use by TaskChampion project.

Binary file not shown.

After

Width:  |  Height:  |  Size: 554 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 523 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 807 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 229 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

12
rust/docs/book.toml Normal file
View file

@ -0,0 +1,12 @@
[book]
authors = ["Dustin J. Mitchell"]
language = "en"
multilingual = false
src = "src"
title = "TaskChampion"
[output.html]
default-theme = "ayu"
[preprocessor.usage-docs]
command = "target/debug/usage-docs"

Some files were not shown because too many files have changed in this diff Show more