Import the TaskChampion repository at rust/
5
rust/.cargo/audit.toml
Normal 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
|
@ -0,0 +1,2 @@
|
|||
[alias]
|
||||
xtask = "run --package xtask --"
|
0
rust/.changelogs/.gitignore
vendored
Normal file
2
rust/.changelogs/2021-10-03-server-storage.md
Normal 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.
|
2
rust/.changelogs/2021-10-11-issue23-client.md
Normal file
|
@ -0,0 +1,2 @@
|
|||
- The `avoid_snapshots` configuration value, if set, will cause the replica to
|
||||
avoid creating snapshots unless required.
|
1
rust/.changelogs/2021-10-16-issue299.md
Normal file
|
@ -0,0 +1 @@
|
|||
- The encryption format used for synchronization has changed incompatibly
|
1
rust/.changelogs/2021-10-25-issue23-integration.md
Normal 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
|
@ -0,0 +1 @@
|
|||
* @dbr @djmitche
|
11
rust/.github/dependabot.yml
vendored
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
**/*.rs.bk
|
26
rust/CHANGELOG.md
Normal 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
|
@ -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
|
@ -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
10
rust/Cargo.toml
Normal file
|
@ -0,0 +1,10 @@
|
|||
[workspace]
|
||||
|
||||
members = [
|
||||
"taskchampion",
|
||||
"cli",
|
||||
"sync-server",
|
||||
"lib",
|
||||
"integration-tests",
|
||||
"xtask",
|
||||
]
|
21
rust/LICENSE
Normal 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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -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
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
built::write_built_file().expect("Failed to acquire build-time information");
|
||||
}
|
61
rust/cli/src/argparse/args/arg_matching.rs
Normal 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());
|
||||
}
|
||||
}
|
98
rust/cli/src/argparse/args/colon.rs
Normal 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)));
|
||||
}
|
||||
}
|
140
rust/cli/src/argparse/args/idlist.rs
Normal 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()),
|
||||
]);
|
||||
}
|
||||
}
|
42
rust/cli/src/argparse/args/misc.rs
Normal 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());
|
||||
}
|
||||
}
|
16
rust/cli/src/argparse/args/mod.rs
Normal file
|
@ -0,0 +1,16 @@
|
|||
//! Parsers for single arguments (strings)
|
||||
|
||||
mod arg_matching;
|
||||
mod colon;
|
||||
mod idlist;
|
||||
mod misc;
|
||||
mod tags;
|
||||
mod time;
|
||||
|
||||
pub(crate) use arg_matching::arg_matching;
|
||||
pub(crate) use colon::{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};
|
35
rust/cli/src/argparse/args/tags.rs
Normal 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());
|
||||
}
|
||||
}
|
492
rust/cli/src/argparse/args/time.rs
Normal 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);
|
||||
}
|
||||
}
|
86
rust/cli/src/argparse/command.rs
Normal 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"),
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
44
rust/cli/src/argparse/config.rs
Normal 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",
|
||||
});
|
||||
}
|
||||
}
|
400
rust/cli/src/argparse/filter.rs
Normal 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")),
|
||||
],
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
48
rust/cli/src/argparse/mod.rs
Normal 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);
|
||||
}
|
342
rust/cli/src/argparse/modification.rs
Normal 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()
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
948
rust/cli/src/argparse/subcommand.rs
Normal 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
|
@ -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());
|
||||
}
|
||||
}
|
||||
}
|
53
rust/cli/src/bin/usage-docs.rs
Normal file
|
@ -0,0 +1,53 @@
|
|||
use mdbook::book::{Book, BookItem};
|
||||
use mdbook::errors::Error;
|
||||
use mdbook::preprocess::{CmdPreprocessor, PreprocessorContext};
|
||||
use std::io;
|
||||
use std::process;
|
||||
use taskchampion_cli::Usage;
|
||||
|
||||
/// This is a simple mdbook preprocessor designed to substitute information from the usage
|
||||
/// into the documentation.
|
||||
fn main() -> anyhow::Result<()> {
|
||||
// cheap way to detect the "supports" arg
|
||||
if std::env::args().len() > 1 {
|
||||
// sure, whatever, we support it all
|
||||
process::exit(0);
|
||||
}
|
||||
|
||||
let (ctx, book) = CmdPreprocessor::parse_input(io::stdin())?;
|
||||
|
||||
if ctx.mdbook_version != mdbook::MDBOOK_VERSION {
|
||||
eprintln!(
|
||||
"Warning: This mdbook preprocessor was built against version {} of mdbook, \
|
||||
but we're being called from version {}",
|
||||
mdbook::MDBOOK_VERSION,
|
||||
ctx.mdbook_version
|
||||
);
|
||||
}
|
||||
|
||||
let processed_book = process(&ctx, book)?;
|
||||
serde_json::to_writer(io::stdout(), &processed_book)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn process(_ctx: &PreprocessorContext, mut book: Book) -> Result<Book, Error> {
|
||||
let usage = Usage::new();
|
||||
|
||||
book.for_each_mut(|sect| {
|
||||
if let BookItem::Chapter(ref mut chapter) = sect {
|
||||
let new_content = usage.substitute_docs(&chapter.content).unwrap();
|
||||
if new_content != chapter.content {
|
||||
eprintln!(
|
||||
"Substituting usage in {:?}",
|
||||
chapter
|
||||
.source_path
|
||||
.as_ref()
|
||||
.unwrap_or_else(|| chapter.path.as_ref().unwrap())
|
||||
);
|
||||
}
|
||||
chapter.content = new_content;
|
||||
}
|
||||
});
|
||||
Ok(book)
|
||||
}
|
60
rust/cli/src/errors.rs
Normal 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);
|
||||
}
|
||||
}
|
77
rust/cli/src/invocation/cmd/add.rs
Normal 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()));
|
||||
}
|
||||
}
|
1
rust/cli/src/invocation/cmd/completed.data
Normal file
|
@ -0,0 +1 @@
|
|||
[description:"&open;TEST&close; foo" entry:"1554074416" modified:"1554074416" priority:"M" status:"completed" uuid:"4578fb67-359b-4483-afe4-fef15925ccd6"]
|
69
rust/cli/src/invocation/cmd/config.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
26
rust/cli/src/invocation/cmd/gc.rs
Normal 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")
|
||||
}
|
||||
}
|
30
rust/cli/src/invocation/cmd/help.rs
Normal 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();
|
||||
}
|
||||
}
|
142
rust/cli/src/invocation/cmd/import_tdb2.rs
Normal 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(())
|
||||
}
|
||||
}
|
265
rust/cli/src/invocation/cmd/import_tw.rs
Normal 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(())
|
||||
}
|
||||
}
|
117
rust/cli/src/invocation/cmd/info.rs
Normal 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"));
|
||||
}
|
||||
}
|
14
rust/cli/src/invocation/cmd/mod.rs
Normal 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;
|
106
rust/cli/src/invocation/cmd/modify.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
1
rust/cli/src/invocation/cmd/pending.data
Normal file
|
@ -0,0 +1 @@
|
|||
[description:"snake 🐍" entry:"1641670385" modified:"1641670385" priority:"M" status:"pending" tag_reptile:"" uuid:"f19086c2-1f8d-4a6c-9b8d-f94901fb8e62"]
|
43
rust/cli/src/invocation/cmd/report.rs
Normal 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"));
|
||||
}
|
||||
}
|
58
rust/cli/src/invocation/cmd/sync.rs
Normal 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")
|
||||
}
|
||||
}
|
28
rust/cli/src/invocation/cmd/undo.rs
Normal 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")
|
||||
}
|
||||
}
|
32
rust/cli/src/invocation/cmd/version.rs
Normal 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 "));
|
||||
}
|
||||
}
|
325
rust/cli/src/invocation/filter.rs
Normal 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);
|
||||
}
|
||||
}
|
179
rust/cli/src/invocation/mod.rs
Normal 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
|
||||
})
|
||||
}
|
247
rust/cli/src/invocation/modify.rs
Normal 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);
|
||||
}
|
||||
}
|
417
rust/cli/src/invocation/report.rs
Normal 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")
|
||||
);
|
||||
}
|
||||
}
|
51
rust/cli/src/invocation/test.rs
Normal 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![] }
|
||||
}
|
22
rust/cli/src/invocation/util.rs
Normal file
|
@ -0,0 +1,22 @@
|
|||
use dialoguer::Confirm;
|
||||
use taskchampion::{Replica, Task};
|
||||
|
||||
/// Print the prompt and ask the user to answer yes or no. If input is not from a terminal, the
|
||||
/// answer is assumed to be true.
|
||||
pub(super) fn confirm<S: Into<String>>(prompt: S) -> anyhow::Result<bool> {
|
||||
if !atty::is(atty::Stream::Stdin) {
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(Confirm::new().with_prompt(prompt).interact()?)
|
||||
}
|
||||
|
||||
/// Summarize a task in a single line
|
||||
pub(super) fn summarize_task(replica: &mut Replica, task: &Task) -> anyhow::Result<String> {
|
||||
let ws = replica.working_set()?;
|
||||
let uuid = task.get_uuid();
|
||||
if let Some(id) = ws.by_uuid(uuid) {
|
||||
Ok(format!("{} - {}", id, task.get_description()))
|
||||
} else {
|
||||
Ok(format!("{} - {}", uuid, task.get_description()))
|
||||
}
|
||||
}
|
79
rust/cli/src/lib.rs
Normal 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
|
@ -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() } };
|
||||
);
|
11
rust/cli/src/settings/mod.rs
Normal 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;
|
580
rust/cli/src/settings/report.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
449
rust/cli/src/settings/settings.rs
Normal 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());
|
||||
}
|
||||
}
|
42
rust/cli/src/settings/util.rs
Normal 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
|
@ -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
|
@ -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(),
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
6
rust/cli/src/tdb2/test.data
Normal 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
|
@ -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
|
@ -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
|
@ -0,0 +1,2 @@
|
|||
book
|
||||
tmp
|
10
rust/docs/README.md
Normal 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/
|
||||
```
|
2
rust/docs/assets/cgi/LICENSE.md
Normal 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.
|
BIN
rust/docs/assets/cgi/icon_rounded/icon_rounded_1024.png
Executable file
After Width: | Height: | Size: 554 KiB |
BIN
rust/docs/assets/cgi/icon_rounded/icon_rounded_128.png
Executable file
After Width: | Height: | Size: 17 KiB |
BIN
rust/docs/assets/cgi/icon_rounded/icon_rounded_16.png
Executable file
After Width: | Height: | Size: 1.2 KiB |
BIN
rust/docs/assets/cgi/icon_rounded/icon_rounded_256.png
Executable file
After Width: | Height: | Size: 51 KiB |
BIN
rust/docs/assets/cgi/icon_rounded/icon_rounded_32.png
Executable file
After Width: | Height: | Size: 2.3 KiB |
BIN
rust/docs/assets/cgi/icon_rounded/icon_rounded_512.png
Executable file
After Width: | Height: | Size: 166 KiB |
BIN
rust/docs/assets/cgi/icon_rounded/icon_rounded_64.png
Executable file
After Width: | Height: | Size: 5.7 KiB |
BIN
rust/docs/assets/cgi/icon_square/icon_square_1024.png
Executable file
After Width: | Height: | Size: 523 KiB |
BIN
rust/docs/assets/cgi/icon_square/icon_square_128.png
Executable file
After Width: | Height: | Size: 14 KiB |
BIN
rust/docs/assets/cgi/icon_square/icon_square_16.png
Executable file
After Width: | Height: | Size: 1 KiB |
BIN
rust/docs/assets/cgi/icon_square/icon_square_256.png
Executable file
After Width: | Height: | Size: 45 KiB |
BIN
rust/docs/assets/cgi/icon_square/icon_square_32.png
Executable file
After Width: | Height: | Size: 2 KiB |
BIN
rust/docs/assets/cgi/icon_square/icon_square_512.png
Executable file
After Width: | Height: | Size: 152 KiB |
BIN
rust/docs/assets/cgi/icon_square/icon_square_64.png
Executable file
After Width: | Height: | Size: 4.9 KiB |
BIN
rust/docs/assets/cgi/logo/logo_1024.png
Executable file
After Width: | Height: | Size: 807 KiB |
BIN
rust/docs/assets/cgi/logo/logo_128.png
Executable file
After Width: | Height: | Size: 21 KiB |
BIN
rust/docs/assets/cgi/logo/logo_16.png
Executable file
After Width: | Height: | Size: 1.3 KiB |
BIN
rust/docs/assets/cgi/logo/logo_256.png
Executable file
After Width: | Height: | Size: 67 KiB |
BIN
rust/docs/assets/cgi/logo/logo_32.png
Executable file
After Width: | Height: | Size: 2.8 KiB |
BIN
rust/docs/assets/cgi/logo/logo_512.png
Executable file
After Width: | Height: | Size: 229 KiB |
BIN
rust/docs/assets/cgi/logo/logo_64.png
Executable file
After Width: | Height: | Size: 7.2 KiB |
12
rust/docs/book.toml
Normal 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"
|