diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..f1bb9bff4 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @dbr @djmitche diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 778016544..18e26f337 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -1,4 +1,4 @@ -name: Security audit +name: Security on: schedule: @@ -12,6 +12,7 @@ on: jobs: audit: runs-on: ubuntu-latest + name: "Audit Dependencies" steps: - uses: actions/checkout@v2 - uses: actions-rs/audit-check@v1 diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/checks.yml similarity index 81% rename from .github/workflows/rust-tests.yml rename to .github/workflows/checks.yml index 8d9eba149..dc11d46a8 100644 --- a/.github/workflows/rust-tests.yml +++ b/.github/workflows/checks.yml @@ -1,4 +1,4 @@ -name: taskchampion +name: Checks on: push: @@ -8,12 +8,50 @@ on: types: [opened, reopened, synchronize] jobs: - test: + clippy: runs-on: ubuntu-latest + name: "Clippy" steps: - uses: actions/checkout@v1 + - name: Cache cargo registry + uses: actions/cache@v1 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v1 + with: + path: target + key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - uses: actions-rs/cargo@v1.0.1 + with: + command: check + + - run: rustup component add clippy + + - uses: actions-rs/clippy-check@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + args: --all-features + name: "Clippy Results" + + mdbook: + runs-on: ubuntu-latest + name: "Documentation" + + steps: + - uses: actions/checkout@v1 + + - name: Setup mdBook + uses: peaceiris/actions-mdbook@v1 + with: + # if this changes, change it in cli/Cargo.toml and .github/workflows/publish-docs.yml as well + mdbook-version: '0.4.10' + - name: Cache cargo registry uses: actions/cache@v1 with: @@ -31,49 +69,8 @@ jobs: toolchain: stable override: true - - uses: actions-rs/cargo@v1.0.1 - with: - command: check - - - name: test - run: cargo test - - clippy: - runs-on: ubuntu-latest - needs: test - - steps: - - uses: actions/checkout@v1 - - - name: Cache cargo registry - uses: actions/cache@v1 - with: - path: ~/.cargo/registry - key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} - - - name: Cache cargo build - uses: actions/cache@v1 - with: - path: target - key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} - - - run: rustup component add clippy - - - uses: actions-rs/clippy-check@v1 - with: - token: ${{ secrets.GITHUB_TOKEN }} - args: --all-features - - mdbook: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v1 - - - name: Setup mdBook - uses: peaceiris/actions-mdbook@v1 - with: - mdbook-version: 'latest' + - name: Create usage-docs plugin + run: cargo build -p taskchampion-cli --features usage-docs --bin usage-docs - run: mdbook test docs - run: mdbook build docs diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml index a9fed18ba..8bb43f89c 100644 --- a/.github/workflows/publish-docs.yml +++ b/.github/workflows/publish-docs.yml @@ -1,4 +1,4 @@ -name: taskchampion +name: Docs on: push: @@ -15,7 +15,28 @@ jobs: - name: Setup mdBook uses: peaceiris/actions-mdbook@v1 with: - mdbook-version: 'latest' + # if this changes, change it in cli/Cargo.toml and .github/workflows/publish-docs.yml as well + mdbook-version: '0.4.10' + + - name: Cache cargo registry + uses: actions/cache@v1 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v1 + with: + path: target + key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + + - name: Create usage-docs plugin + run: cargo build -p taskchampion-cli --features usage-docs --bin usage-docs - run: mdbook build docs diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 000000000..1ea9f23c0 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,43 @@ +name: Tests + +on: + push: + branches: + - main + pull_request: + types: [opened, reopened, synchronize] + +jobs: + test: + runs-on: ubuntu-latest + + strategy: + matrix: + rust: + - "1.47" # MSRV + - "stable" + + name: "Test - Rust ${{ matrix.rust }}" + + steps: + - uses: actions/checkout@v1 + + - name: Cache cargo registry + uses: actions/cache@v1 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-${{ matrix.rust }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v1 + with: + path: target + key: ${{ runner.os }}-${{ matrix.rust }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - uses: actions-rs/toolchain@v1 + with: + toolchain: "${{ matrix.rust }}" + override: true + + - name: test + run: cargo test diff --git a/Cargo.lock b/Cargo.lock index c65d01171..28bed3b3e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,7 +47,7 @@ dependencies = [ "actix-service", "actix-threadpool", "actix-utils", - "base64", + "base64 0.13.0", "bitflags", "brotli2", "bytes 0.5.6", @@ -76,10 +76,10 @@ dependencies = [ "regex", "serde", "serde_json", - "serde_urlencoded", - "sha-1", + "serde_urlencoded 0.7.0", + "sha-1 0.9.6", "slab", - "time 0.2.26", + "time 0.2.27", ] [[package]] @@ -94,9 +94,9 @@ dependencies = [ [[package]] name = "actix-macros" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbcb2b608f0accc2f5bcf3dd872194ce13d94ee45b571487035864cf966b04ef" +checksum = "c2f86cd6857c135e6e9fe57b1619a88d1f94a7df34c00e11fe13e64fd3438837" dependencies = [ "quote", "syn", @@ -136,9 +136,9 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc7d7cd957c9ed92288a7c3c96af81fa5291f65247a76a34dac7b6af74e52ba0" dependencies = [ - "actix-macros 0.2.0", + "actix-macros 0.2.1", "futures-core", - "tokio 1.6.0", + "tokio 1.6.2", ] [[package]] @@ -264,9 +264,9 @@ dependencies = [ "regex", "serde", "serde_json", - "serde_urlencoded", + "serde_urlencoded 0.7.0", "socket2", - "time 0.2.26", + "time 0.2.27", "tinyvec", "url", ] @@ -308,6 +308,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "ammonia" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ee7d6eb157f337c5cedc95ddf17f0cbc36d36eb7763c8e0d1c1aeb3722f6279" +dependencies = [ + "html5ever", + "lazy_static", + "maplit", + "markup5ever_rcdom", + "matches", + "tendril", + "url", +] + [[package]] name = "ansi_term" version = "0.11.0" @@ -319,9 +334,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.40" +version = "1.0.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28b2cd92db5cbd74e8e5028f7e27dd7aa3090e89e4f2a197cc7c8dfb69c7063b" +checksum = "15af2628f6890fe2609a3b91bef4c83450512802e59489f9c1cb1fa5df064a61" [[package]] name = "arrayref" @@ -335,17 +350,11 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" -[[package]] -name = "ascii" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eab1c04a571841102f5345a8fc0f6bb3d31c315dec879b5c6e42e40ce7ffa34e" - [[package]] name = "assert_cmd" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f57fec1ac7e4de72dcc69811795f1a7172ed06012f80a5d1ee651b62484f588" +checksum = "a88b6bd5df287567ffdf4ddf4d33060048e1068308e5f62d81c6f9824a045a48" dependencies = [ "bstr", "doc-comment", @@ -393,7 +402,7 @@ dependencies = [ "actix-http", "actix-rt 1.1.1", "actix-service", - "base64", + "base64 0.13.0", "bytes 0.5.6", "cfg-if 1.0.0", "derive_more", @@ -404,7 +413,7 @@ dependencies = [ "rand 0.7.3", "serde", "serde_json", - "serde_urlencoded", + "serde_urlencoded 0.7.0", ] [[package]] @@ -413,6 +422,12 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4521f3e3d031370679b3b140beb36dfe4801b09ac77e30c61941f97df3ef28b" +[[package]] +name = "base64" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3441f0f7b02788e948e47f457ca01f1d7e6d92c693bc132c22b087d3141c03ff" + [[package]] name = "base64" version = "0.13.0" @@ -463,13 +478,34 @@ dependencies = [ "constant_time_eq", ] +[[package]] +name = "block-buffer" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0940dc441f31689269e10ac70eb1002a3a1d3ad1390e030043662eb7fe4688b" +dependencies = [ + "block-padding", + "byte-tools", + "byteorder", + "generic-array 0.12.4", +] + [[package]] name = "block-buffer" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" dependencies = [ - "generic-array", + "generic-array 0.14.4", +] + +[[package]] +name = "block-padding" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa79dedbb091f449f1f39e53edf88d5dbe95f895dae6135a8d7b881fb5af73f5" +dependencies = [ + "byte-tools", ] [[package]] @@ -505,10 +541,26 @@ dependencies = [ ] [[package]] -name = "bumpalo" -version = "3.6.1" +name = "built" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63396b8a4b9de3f4fdfb320ab6080762242f66a8ef174c49d8e19b674db4cdbe" +checksum = "4f346b6890a0dfa7266974910e7df2d5088120dd54721b9b0e5aae1ae5e05715" +dependencies = [ + "cargo-lock", + "git2", +] + +[[package]] +name = "bumpalo" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c59e7af012c713f529e7a3ee57ce9b31ddd858d4b512923602f74608b009631" + +[[package]] +name = "byte-tools" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3b5ca7a04898ad4bcd41c90c5285445ff5b791899bb1b0abdd2a2aa791211d7" [[package]] name = "byteorder" @@ -537,11 +589,26 @@ dependencies = [ "bytes 1.0.1", ] +[[package]] +name = "cargo-lock" +version = "7.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a19807e9f4f8af2c8ece4236ed7d229b9179da1f3f2ba44e765c7ba934748f99" +dependencies = [ + "semver 1.0.3", + "serde", + "toml", + "url", +] + [[package]] name = "cc" version = "1.0.68" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a72c244c1ff497a746a7e1fb3d14bd08420ecda70c8f25c7112f2781652d787" +dependencies = [ + "jobserver", +] [[package]] name = "cfg-if" @@ -592,15 +659,27 @@ dependencies = [ [[package]] name = "combine" -version = "3.8.1" +version = "4.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3da6baa321ec19e1cc41d31bf599f00c783d0517095cdaf0332e3fe8d20680" +checksum = "cc4369b5e4c0cddf64ad8981c0111e7df4f7078f4d6ba98fb31f2e17c4c57b7e" dependencies = [ - "ascii", - "byteorder", - "either", + "bytes 1.0.1", "memchr", - "unreachable", +] + +[[package]] +name = "console" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3993e6445baa160675931ec041a5e03ca84b9c6e32a056150d3aa2bdda0a1f45" +dependencies = [ + "encode_unicode", + "lazy_static", + "libc", + "regex", + "terminal_size", + "unicode-width", + "winapi 0.3.9", ] [[package]] @@ -628,7 +707,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03a5d7b21829bc7b4bf4754a978a241ae54ea55a40f92bb20216e54096f4b951" dependencies = [ "percent-encoding", - "time 0.2.26", + "time 0.2.27", "version_check", ] @@ -658,11 +737,10 @@ dependencies = [ [[package]] name = "crossbeam-utils" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4feb231f0d4d6af81aed15928e58ecf5816aa62a2393e2c82f46973e92a9a278" +checksum = "d82cfc11ce7f2c3faef78d8a684447b40d503d9681acebed6cb728d45940c4db" dependencies = [ - "autocfg", "cfg-if 1.0.0", "lazy_static", ] @@ -701,19 +779,40 @@ dependencies = [ "syn", ] +[[package]] +name = "dialoguer" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9dd058f8b65922819fabb4a41e7d1964e56344042c26efbccd465202c23fa0c" +dependencies = [ + "console", + "lazy_static", + "tempfile", + "zeroize", +] + [[package]] name = "difference" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "524cbf6897b527295dff137cec09ecf3a05f4fddffd7dfcd1585403449e74198" +[[package]] +name = "digest" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3d0c8c8752312f9713efd397ff63acb9f85585afbf179282e720e7704954dd5" +dependencies = [ + "generic-array 0.12.4", +] + [[package]] name = "digest" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" dependencies = [ - "generic-array", + "generic-array 0.14.4", ] [[package]] @@ -760,12 +859,33 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fea41bba32d969b513997752735605054bc0dfa92b4c56bf1189f2e174be7a10" +[[package]] +name = "dtoa" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56899898ce76aaf4a0f24d914c97ea6ed976d42fec6ad33fcbb0a1103e07b2b0" + [[package]] name = "either" version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" +[[package]] +name = "elasticlunr-rs" +version = "2.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f8cf73b19a7aece6942f5745a2fc1ae3c8b0533569707d596b5d6baa7d6c600" +dependencies = [ + "lazy_static", + "regex", + "serde", + "serde_derive", + "serde_json", + "strum 0.18.0", + "strum_macros 0.18.0", +] + [[package]] name = "encode_unicode" version = "0.3.6" @@ -795,17 +915,36 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.8.3" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17392a012ea30ef05a610aa97dfb49496e71c9f676b27879922ea5bdf60d9d3f" +checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36" dependencies = [ "atty", - "humantime", + "humantime 1.3.0", "log", "regex", "termcolor", ] +[[package]] +name = "env_logger" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a19187fea3ac7e84da7dacf48de0c45d63c6a76f9490dae389aead16c243fce3" +dependencies = [ + "atty", + "humantime 2.1.0", + "log", + "regex", + "termcolor", +] + +[[package]] +name = "fake-simd" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e88a8acf291dafb59c2d96e8f59828f3838bb1a70398823ade51a84de6a6deed" + [[package]] name = "fallible-iterator" version = "0.2.0" @@ -818,6 +957,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "filetime" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d34cfa13a63ae058bfa601fe9e313bbdb3746427c1459185464ce0fcf62e1e8" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "redox_syscall 0.2.8", + "winapi 0.3.9", +] + [[package]] name = "flate2" version = "1.0.20" @@ -855,6 +1006,25 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fsevent" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ab7d1bd1bd33cc98b0889831b72da23c0aa4df9cec7e0702f46ecea04b35db6" +dependencies = [ + "bitflags", + "fsevent-sys", +] + +[[package]] +name = "fsevent-sys" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f41b048a94555da0f42f1d632e2e19510084fb8e303b0daa2816e733fb3644a0" +dependencies = [ + "libc", +] + [[package]] name = "fuchsia-zircon" version = "0.3.3" @@ -877,6 +1047,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fed34cd105917e91daa4da6b3728c47b068749d6a62c59811f06ed2ac71d9da7" +[[package]] +name = "futf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c9c1ce3fa9336301af935ab852c437817d14cd33690446569392e65170aac3b" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.15" @@ -980,6 +1160,15 @@ dependencies = [ "byteorder", ] +[[package]] +name = "generic-array" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffdf9f34f1447443d37393cc6c2b8313aebddcd96906caf34e54c68d8e57d7bd" +dependencies = [ + "typenum", +] + [[package]] name = "generic-array" version = "0.14.4" @@ -990,6 +1179,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -1012,6 +1210,34 @@ dependencies = [ "wasi 0.10.2+wasi-snapshot-preview1", ] +[[package]] +name = "git2" +version = "0.13.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9831e983241f8c5591ed53f17d874833e2fa82cac2625f3888c50cbfe136cba" +dependencies = [ + "bitflags", + "libc", + "libgit2-sys", + "log", + "url", +] + +[[package]] +name = "gitignore" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78aa90e4620c1498ac434c06ba6e521b525794bbdacf085d490cc794b4a2f9a4" +dependencies = [ + "glob", +] + +[[package]] +name = "glob" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b919933a397b79c37e33b77bb2aa3dc8eb6e165ad809e58ff75bc7db2e34574" + [[package]] name = "h2" version = "0.2.7" @@ -1032,6 +1258,20 @@ dependencies = [ "tracing-futures", ] +[[package]] +name = "handlebars" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28f0fe89affef47e2c9729030a8f6e79df34cb66b8a44ecf91dad43f31150559" +dependencies = [ + "log", + "pest", + "pest_derive", + "quick-error 2.0.1", + "serde", + "serde_json", +] + [[package]] name = "hashbrown" version = "0.9.1" @@ -1057,10 +1297,35 @@ dependencies = [ ] [[package]] -name = "heck" -version = "0.3.2" +name = "headers" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87cbf45460356b7deeb5e3415b5563308c0a9b057c85e12b06ad551f98d0a6ac" +checksum = "f0b7591fb62902706ae8e7aaff416b1b0fa2c0fd0878b46dc13baa3712d8a855" +dependencies = [ + "base64 0.13.0", + "bitflags", + "bytes 1.0.1", + "headers-core", + "http", + "mime", + "sha-1 0.9.6", + "time 0.1.43", +] + +[[package]] +name = "headers-core" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7f66481bfee273957b1f20485a4ff3362987f85b2c236580d81b4eb7a326429" +dependencies = [ + "http", +] + +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" dependencies = [ "unicode-segmentation", ] @@ -1085,6 +1350,20 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "html5ever" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aafcf38a1a36118242d29b92e1b08ef84e67e4a5ed06e0a80be20e6a32bfed6b" +dependencies = [ + "log", + "mac", + "markup5ever", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "http" version = "0.2.4" @@ -1096,18 +1375,67 @@ dependencies = [ "itoa", ] +[[package]] +name = "http-body" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d5ff830006f7646652e057693569bfe0d51760c0085a071769d142a205111b" +dependencies = [ + "bytes 0.5.6", + "http", +] + [[package]] name = "httparse" version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3a87b616e37e93c22fb19bcd386f02f3af5ea98a25670ad0fce773de23c5e68" +[[package]] +name = "httpdate" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494b4d60369511e7dea41cf646832512a94e542f68bb9c49e54518e0f468eb47" + +[[package]] +name = "humantime" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df004cfca50ef23c36850aaaa59ad52cc70d0e90243c3c7737a4dd32dc7a3c4f" +dependencies = [ + "quick-error 1.2.3", +] + [[package]] name = "humantime" version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" +[[package]] +name = "hyper" +version = "0.13.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a6f157065790a3ed2f88679250419b5cdd96e714a0d65f7797fd337186e96bb" +dependencies = [ + "bytes 0.5.6", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project 1.0.7", + "socket2", + "tokio 0.2.25", + "tower-service", + "tracing", + "want", +] + [[package]] name = "idna" version = "0.2.3" @@ -1129,6 +1457,35 @@ dependencies = [ "hashbrown 0.9.1", ] +[[package]] +name = "inotify" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4816c66d2c8ae673df83366c18341538f234a26d65a9ecea5c348b453ac1d02f" +dependencies = [ + "bitflags", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +dependencies = [ + "libc", +] + +[[package]] +name = "input_buffer" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19a8a95243d5a0398cae618ec29477c6e3cb631152be5c19481f80bc71559754" +dependencies = [ + "bytes 0.5.6", +] + [[package]] name = "instant" version = "0.1.9" @@ -1159,12 +1516,30 @@ dependencies = [ "winreg", ] +[[package]] +name = "iso8601-duration" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60b51dd97fa24074214b9eb14da518957573f4dec3189112610ae1ccec9ac464" +dependencies = [ + "nom 5.1.2", +] + [[package]] name = "itoa" version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd25036021b0de88a0aff6b850051563c6516d0bf53f8638938edbb9de732736" +[[package]] +name = "jobserver" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "972f5ae5d1cb9c6ae417789196c803205313edde988685da5e3aae0827b9e7fd" +dependencies = [ + "libc", +] + [[package]] name = "js-sys" version = "0.3.51" @@ -1196,6 +1571,12 @@ version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +[[package]] +name = "lazycell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" + [[package]] name = "lexical-core" version = "0.7.6" @@ -1211,9 +1592,21 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.95" +version = "0.2.97" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "789da6d93f1b866ffe175afc5322a4d76c038605a1c3319bb57b06967ca98a36" +checksum = "12b8adadd720df158f4d70dfe7ccc6adb0472d7c55ca83445f6a5ab3e36f8fb6" + +[[package]] +name = "libgit2-sys" +version = "0.12.21+1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "86271bacd72b2b9e854c3dcfb82efd538f15f870e4c11af66900effb462f6825" +dependencies = [ + "cc", + "libc", + "libz-sys", + "pkg-config", +] [[package]] name = "libsqlite3-sys" @@ -1226,6 +1619,18 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libz-sys" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de5435b8549c16d423ed0c03dbaafe57cf6c3344744f1242520d59c9d8ecec66" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "linked-hash-map" version = "0.5.4" @@ -1259,6 +1664,44 @@ dependencies = [ "linked-hash-map", ] +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a24f40fb03852d1cdd84330cddcaf98e9ec08a7b7768e952fad3b4cf048ec8fd" +dependencies = [ + "log", + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", + "tendril", +] + +[[package]] +name = "markup5ever_rcdom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f015da43bcd8d4f144559a3423f4591d69b8ce0652c905374da7205df336ae2b" +dependencies = [ + "html5ever", + "markup5ever", + "tendril", + "xml5ever", +] + [[package]] name = "match_cfg" version = "0.1.0" @@ -1271,6 +1714,38 @@ version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ffc5c5338469d4d3ea17d269fa8ea3512ad247247c30bd2df69e68309ed0a08" +[[package]] +name = "mdbook" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6da0e609de0d4a7e0d42367d91b87117e3dce74d3d1699efeda1fefb2a6fa85" +dependencies = [ + "ammonia", + "anyhow", + "chrono", + "clap", + "elasticlunr-rs", + "env_logger 0.7.1", + "futures-util", + "gitignore", + "handlebars", + "lazy_static", + "log", + "memchr", + "notify", + "open", + "pulldown-cmark", + "regex", + "serde", + "serde_derive", + "serde_json", + "shlex", + "tempfile", + "tokio 0.2.25", + "toml", + "warp", +] + [[package]] name = "memchr" version = "2.4.0" @@ -1283,6 +1758,16 @@ version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a60c7ce501c71e03a9c9c0d35b861413ae925bd979cc7a4e30d060069aaac8d" +[[package]] +name = "mime_guess" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2684d4c2e97d99848d30b324b00c8fcc7e5c897b7cbb5819b09e7c90e8baf212" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "miniz_oxide" version = "0.4.4" @@ -1314,9 +1799,9 @@ dependencies = [ [[package]] name = "mio" -version = "0.7.11" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf80d3e903b34e0bd7282b218398aec54e082c840d9baf8339e0080a0c542956" +checksum = "8c2bdb6314ec10835cd3293dd268473a835c02b7b352e788be788b3c6ca6bb16" dependencies = [ "libc", "log", @@ -1325,6 +1810,18 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "mio-extras" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52403fe290012ce777c4626790c8951324a2b9e3316b3143779c72b029742f19" +dependencies = [ + "lazycell", + "log", + "mio 0.6.23", + "slab", +] + [[package]] name = "mio-uds" version = "0.6.8" @@ -1368,6 +1865,23 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "new_debug_unreachable" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a24736216ec316047a1fc4252e27dabb04218aa4a3f37c6e7ddbf1f9782b54" + +[[package]] +name = "nom" +version = "5.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffb4262d26ed83a1c0a33a38fe2bb15797329c85770da05e6b828ddb782627af" +dependencies = [ + "lexical-core", + "memchr", + "version_check", +] + [[package]] name = "nom" version = "6.1.2" @@ -1387,6 +1901,24 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" +[[package]] +name = "notify" +version = "4.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae03c8c853dba7bfd23e571ff0cff7bc9dceb40a4cd684cd1681824183f45257" +dependencies = [ + "bitflags", + "filetime", + "fsevent", + "fsevent-sys", + "inotify", + "libc", + "mio 0.6.23", + "mio-extras", + "walkdir", + "winapi 0.3.9", +] + [[package]] name = "ntapi" version = "0.3.6" @@ -1427,9 +1959,15 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.7.2" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af8b08b04175473088b46763e51ee54da5f9a164bc162f615b91bc179dbf15a3" +checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56" + +[[package]] +name = "opaque-debug" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c" [[package]] name = "opaque-debug" @@ -1437,6 +1975,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" +[[package]] +name = "open" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1711eb4b31ce4ad35b0f316d8dfba4fe5c7ad601c448446d84aae7a896627b20" +dependencies = [ + "which", + "winapi 0.3.9", +] + [[package]] name = "parking_lot" version = "0.11.1" @@ -1468,6 +2016,87 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d4fd5641d01c8f18a23da7b6fe29298ff4b55afcccdf78973b24cf3175fee32e" +[[package]] +name = "pest" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10f4872ae94d7b90ae48754df22fd42ad52ce740b8f370b03da4835417403e53" +dependencies = [ + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "833d1ae558dc601e9a60366421196a8d94bc0ac980476d0b67e1d0988d72b2d0" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99b8db626e31e5b81787b9783425769681b347011cc59471e33ea46d2ea0cf55" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pest_meta" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54be6e404f5317079812fc8f9f5279de376d8856929e21c184ecf6bbd692a11d" +dependencies = [ + "maplit", + "pest", + "sha-1 0.8.2", +] + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared", + "rand 0.7.3", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" version = "0.4.28" @@ -1538,6 +2167,12 @@ version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac74c624d6b2d21f425f752262f42188365d7b8ff1aff74c82e45136510a4857" +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "predicates" version = "1.0.8" @@ -1615,7 +2250,7 @@ dependencies = [ "num-traits", "quick-error 2.0.1", "rand 0.8.3", - "rand_chacha 0.3.0", + "rand_chacha 0.3.1", "rand_xorshift", "regex-syntax", "rusty-fork", @@ -1624,9 +2259,21 @@ dependencies = [ [[package]] name = "protobuf" -version = "2.23.0" +version = "2.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "45604fc7a88158e7d514d8e22e14ac746081e7a70d7690074dd0029ee37458d6" +checksum = "db50e77ae196458ccd3dc58a31ea1a90b0698ab1b7928d89f644c25d72070267" + +[[package]] +name = "pulldown-cmark" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca36dea94d187597e104a5c8e4b07576a8a45aa5db48a65e12940d3eb7461f55" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "unicase", +] [[package]] name = "quick-error" @@ -1666,6 +2313,7 @@ dependencies = [ "rand_chacha 0.2.2", "rand_core 0.5.1", "rand_hc 0.2.0", + "rand_pcg", ] [[package]] @@ -1675,7 +2323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ef9e7e66b4468674bfcb0c81af8b7fa0bb154fa9f28eb840da5c447baeb8d7e" dependencies = [ "libc", - "rand_chacha 0.3.0", + "rand_chacha 0.3.1", "rand_core 0.6.2", "rand_hc 0.3.0", ] @@ -1692,9 +2340,9 @@ dependencies = [ [[package]] name = "rand_chacha" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e12735cf05c9e10bf21534da50a147b924d555dc7a547c42e6bb2d5b6017ae0d" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ "ppv-lite86", "rand_core 0.6.2", @@ -1736,6 +2384,15 @@ dependencies = [ "rand_core 0.6.2", ] +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + [[package]] name = "rand_xorshift" version = "0.3.0" @@ -1794,12 +2451,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae1ded71d66a4a97f5e961fd0cb25a5f366a42a41570d16a763a69c092c26ae4" -dependencies = [ - "byteorder", -] +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" [[package]] name = "regex-syntax" @@ -1841,6 +2495,19 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "rstest" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "041bb0202c14f6a158bbbf086afb03d0c6e975c2dec7d4912f8061ed44f290af" +dependencies = [ + "cfg-if 1.0.0", + "proc-macro2", + "quote", + "rustc_version 0.3.3", + "syn", +] + [[package]] name = "rusqlite" version = "0.25.3" @@ -1862,7 +2529,7 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb" dependencies = [ - "base64", + "base64 0.13.0", "blake2b_simd", "constant_time_eq", "crossbeam-utils", @@ -1874,7 +2541,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" dependencies = [ - "semver", + "semver 0.9.0", +] + +[[package]] +name = "rustc_version" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0dfe2087c51c460008730de8b57e6a320782fbfb312e1f4d520e6c6fae155ee" +dependencies = [ + "semver 0.11.0", ] [[package]] @@ -1883,7 +2559,7 @@ version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35edb675feee39aec9c99fa5ff985081995a06d594114ae14cbe797ad7b7a6d7" dependencies = [ - "base64", + "base64 0.13.0", "log", "ring", "sct", @@ -1908,6 +2584,21 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71d301d4193d031abdd79ff7e3dd721168a9572ef3fe51a1517aba235bd8f86e" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6a9290e3c9cf0f18145ef7ffa62d68ee0bf5fcd651017e586dc7fd5da448c2" + [[package]] name = "scopeguard" version = "1.1.0" @@ -1930,7 +2621,25 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" dependencies = [ - "semver-parser", + "semver-parser 0.7.0", +] + +[[package]] +name = "semver" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f301af10236f6df4160f7c3f04eec6dbc70ace82d23326abad5edee88801c6b6" +dependencies = [ + "semver-parser 0.10.2", +] + +[[package]] +name = "semver" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f3aac57ee7f3272d8395c6e4f502f434f0e289fcd62876f70daa008c20dcabe" +dependencies = [ + "serde", ] [[package]] @@ -1939,6 +2648,15 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "388a1df253eca08550bef6c72392cfe7c30914bf41df5269b68cbd6ff8f570a3" +[[package]] +name = "semver-parser" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0bef5b7f9e0df16536d3961cfb6e84331c065b4066afb39768d0e319411f7" +dependencies = [ + "pest", +] + [[package]] name = "serde" version = "1.0.126" @@ -1970,6 +2688,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ec5d77e2d4c73717816afac02670d5c4f534ea95ed430442cad02e7a6e32c97" +dependencies = [ + "dtoa", + "itoa", + "serde", + "url", +] + [[package]] name = "serde_urlencoded" version = "0.7.0" @@ -1982,17 +2712,29 @@ dependencies = [ "serde", ] +[[package]] +name = "sha-1" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d94d0bede923b3cea61f3f1ff57ff8cdfd77b400fb8f9998949e0cf04163df" +dependencies = [ + "block-buffer 0.7.3", + "digest 0.8.1", + "fake-simd", + "opaque-debug 0.2.3", +] + [[package]] name = "sha-1" version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c4cfa741c5832d0ef7fab46cabed29c2aae926db0b11bb2069edd8db5e64e16" dependencies = [ - "block-buffer", + "block-buffer 0.9.0", "cfg-if 1.0.0", "cpufeatures", - "digest", - "opaque-debug", + "digest 0.9.0", + "opaque-debug 0.3.0", ] [[package]] @@ -2002,14 +2744,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2579985fda508104f7587689507983eadd6a6e84dd35d6d115361f530916fa0d" [[package]] -name = "signal-hook-registry" -version = "1.3.0" +name = "shlex" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16f1d0fef1604ba8f7a073c7e701f213e056707210e9020af4528e0101ce11a6" +checksum = "42a568c8f2cd051a4d283bd6eb0343ac214c1b0f1ac19f93e1175b2dee38c73d" + +[[package]] +name = "signal-hook-registry" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51e73328dc4ac0c7ccbda3a494dfa03df1de2f46018127f60c693f2648455b0" dependencies = [ "libc", ] +[[package]] +name = "siphasher" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbce6d4507c7e4a3962091436e56e95290cb71fa302d0d270e32130b75fbff27" + [[package]] name = "slab" version = "0.4.3" @@ -2067,7 +2821,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d022496b16281348b52d0e30ae99e01a73d737b2f45d38fed4edf79f9325a1d5" dependencies = [ "discard", - "rustc_version", + "rustc_version 0.2.3", "stdweb-derive", "stdweb-internal-macros", "stdweb-internal-runtime", @@ -2109,6 +2863,31 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "213701ba3370744dcd1a12960caa4843b3d68b4d1c0a5d575e0d65b2ee9d16c0" +[[package]] +name = "string_cache" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb1139b5353f96e429e1a5e19fbaf663bddedaa06d1dbd49f82e352601209a" +dependencies = [ + "lazy_static", + "new_debug_unreachable", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f24c8e5e19d22a726626f1a5e16fe15b132dcf21d10177fa5a45ce7962996b97" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "strsim" version = "0.8.0" @@ -2116,10 +2895,46 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" [[package]] -name = "syn" -version = "1.0.72" +name = "strum" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e8cdbefb79a9a5a65e0db8b47b723ee907b7c7f8496c76a1770b5c310bab82" +checksum = "57bd81eb48f4c437cadc685403cad539345bf703d78e63707418431cecd4522b" + +[[package]] +name = "strum" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aaf86bbcfd1fa9670b7a129f64fc0c9fcbbfe4f1bc4210e9e98fe71ffc12cde2" + +[[package]] +name = "strum_macros" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87c85aa3f8ea653bfd3ddf25f7ee357ee4d204731f6aa9ad04002306f6e2774c" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "strum_macros" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d06aaeeee809dbc59eb4556183dd927df67db1540de5be8d3ec0b6636358a5ec" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "1.0.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f71489ff30030d2ae598524f61326b902466f72a0fb1a8564c001cc63425bcc7" dependencies = [ "proc-macro2", "quote", @@ -2140,9 +2955,12 @@ dependencies = [ "chrono", "log", "proptest", + "rstest", "rusqlite", "serde", "serde_json", + "strum 0.21.0", + "strum_macros 0.21.1", "tempfile", "thiserror", "tindercrypt", @@ -2157,16 +2975,25 @@ dependencies = [ "anyhow", "assert_cmd", "atty", + "built", + "chrono", + "dialoguer", "dirs-next", - "env_logger", + "env_logger 0.8.4", + "iso8601-duration", + "lazy_static", "log", - "nom", + "mdbook", + "nom 6.1.2", "predicates", "prettytable-rs", + "rstest", + "serde_json", "taskchampion", "tempfile", "termcolor", "textwrap 0.13.4", + "thiserror", "toml", "toml_edit", ] @@ -2179,7 +3006,7 @@ dependencies = [ "actix-web", "anyhow", "clap", - "env_logger", + "env_logger 0.8.4", "futures", "log", "rusqlite", @@ -2204,6 +3031,17 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "tendril" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9ef557cb397a4f0a5a3a628f06515f78563f2209e64d47055d9dc6052bf5e33" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "term" version = "0.5.2" @@ -2295,9 +3133,9 @@ dependencies = [ [[package]] name = "time" -version = "0.2.26" +version = "0.2.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08a8cbfbf47955132d0202d1662f49b2423ae35862aee471f3ba4b133358f372" +checksum = "4752a97f8eebd6854ff91f1c1824cd6160626ac4bd44287f7f4ea2035a02a242" dependencies = [ "const_fn", "libc", @@ -2320,9 +3158,9 @@ dependencies = [ [[package]] name = "time-macros-impl" -version = "0.1.1" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5c3be1edfad6027c69f5491cf4cb310d1a71ecd6af742788c6ff8bced86b8fa" +checksum = "fd3c141a1b43194f3f56a1411225df8646c55781d5f26db825b3d98507eb482f" dependencies = [ "proc-macro-hack", "proc-macro2", @@ -2365,6 +3203,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6703a273949a90131b290be1fe7b039d0fc884aa1935860dfcbe056f28cd8092" dependencies = [ "bytes 0.5.6", + "fnv", "futures-core", "iovec", "lazy_static", @@ -2375,18 +3214,19 @@ dependencies = [ "pin-project-lite 0.1.12", "signal-hook-registry", "slab", + "tokio-macros", "winapi 0.3.9", ] [[package]] name = "tokio" -version = "1.6.0" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd3076b5c8cc18138b8f8814895c11eb4de37114a5d127bafdc5e55798ceef37" +checksum = "aea337f72e96efe29acc234d803a5981cd9a2b6ed21655cd7fc21cfe021e8ec7" dependencies = [ "autocfg", "libc", - "mio 0.7.11", + "mio 0.7.13", "once_cell", "parking_lot", "pin-project-lite 0.2.6", @@ -2394,6 +3234,30 @@ dependencies = [ "winapi 0.3.9", ] +[[package]] +name = "tokio-macros" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e44da00bfc73a25f814cd8d7e57a68a5c31b74b3152a0a1d1f590c97ed06265a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d9e878ad426ca286e4dcae09cbd4e1973a7f8987d97570e2469703dd7f5720c" +dependencies = [ + "futures-util", + "log", + "pin-project 0.4.28", + "tokio 0.2.25", + "tungstenite", +] + [[package]] name = "tokio-util" version = "0.3.1" @@ -2419,15 +3283,21 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09391a441b373597cf0888d2b052dcf82c5be4fee05da3636ae30fb57aad8484" +checksum = "dbbdcf4f749dd33b1f1ea19b547bf789d87442ec40767d6015e5e2d39158d69a" dependencies = [ "chrono", "combine", "linked-hash-map", ] +[[package]] +name = "tower-service" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "360dfd1d6d30e05fda32ace2c8c70e9c0a9da713275777f5a4dbb8a1893930c6" + [[package]] name = "tracing" version = "0.1.26" @@ -2504,12 +3374,52 @@ dependencies = [ "trust-dns-proto", ] +[[package]] +name = "try-lock" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59547bce71d9c38b83d9c0e92b6066c4253371f15005def0c30d9657f50c7642" + +[[package]] +name = "tungstenite" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0308d80d86700c5878b9ef6321f020f29b1bb9d5ff3cab25e75e23f3a492a23" +dependencies = [ + "base64 0.12.3", + "byteorder", + "bytes 0.5.6", + "http", + "httparse", + "input_buffer", + "log", + "rand 0.7.3", + "sha-1 0.9.6", + "url", + "utf-8", +] + [[package]] name = "typenum" version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f6906492a7cd215bfa4cf595b600146ccfac0c79bcbd1f3000162af5e8b06" +[[package]] +name = "ucd-trie" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56dee185309b50d1f11bfedef0fe6d036842e3fb77413abef29f8f8d1c5d4c1c" + +[[package]] +name = "unicase" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50f37be617794602aabbeee0be4f259dc1778fabe05e2d67ee8f79326d5cb4f6" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.5" @@ -2521,9 +3431,9 @@ dependencies = [ [[package]] name = "unicode-normalization" -version = "0.1.17" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07fbfce1c8a97d547e8b5334978438d9d6ec8c20e38f56d4a4374d181493eaef" +checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" dependencies = [ "tinyvec", ] @@ -2546,15 +3456,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" -[[package]] -name = "unreachable" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "382810877fe448991dfc7f0dd6e3ae5d58088fd0ea5e35189655f84e6814fa56" -dependencies = [ - "void", -] - [[package]] name = "untrusted" version = "0.7.1" @@ -2567,7 +3468,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2475a6781e9bc546e7b64f4013d2f4032c8c6a40fcffd7c6f4ee734a890972ab" dependencies = [ - "base64", + "base64 0.13.0", "chunked_transfer", "log", "once_cell", @@ -2589,6 +3490,18 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "urlencoding" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a1f0175e03a0973cf4afd476bef05c26e228520400eb1fd473ad417b1c00ffb" + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "uuid" version = "0.8.2" @@ -2617,12 +3530,6 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" -[[package]] -name = "void" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a02e4885ed3bc0f2de90ea6dd45ebcbb66dacffe03547fadbb0eeae2770887d" - [[package]] name = "wait-timeout" version = "0.2.0" @@ -2632,6 +3539,54 @@ dependencies = [ "libc", ] +[[package]] +name = "walkdir" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" +dependencies = [ + "same-file", + "winapi 0.3.9", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ce8a968cb1cd110d136ff8b819a556d6fb6d919363c61534f6860c7eb172ba0" +dependencies = [ + "log", + "try-lock", +] + +[[package]] +name = "warp" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f41be6df54c97904af01aa23e613d4521eed7ab23537cede692d4058f6449407" +dependencies = [ + "bytes 0.5.6", + "futures", + "headers", + "http", + "hyper", + "log", + "mime", + "mime_guess", + "pin-project 0.4.28", + "scoped-tls", + "serde", + "serde_json", + "serde_urlencoded 0.6.1", + "tokio 0.2.25", + "tokio-tungstenite", + "tower-service", + "tracing", + "tracing-futures", + "urlencoding", +] + [[package]] name = "wasi" version = "0.9.0+wasi-snapshot-preview1" @@ -2727,6 +3682,16 @@ dependencies = [ "webpki", ] +[[package]] +name = "which" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b55551e42cbdf2ce2bedd2203d0cc08dba002c27510f86dab6d0ce304cba3dfe" +dependencies = [ + "either", + "libc", +] + [[package]] name = "widestring" version = "0.4.3" @@ -2800,3 +3765,21 @@ name = "wyz" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "85e60b0d1b5f99db2556934e21937020776a5d31520bf169e851ac44e6420214" + +[[package]] +name = "xml5ever" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1b52e6e8614d4a58b8e70cf51ec0cc21b256ad8206708bcff8139b5bbd6a59" +dependencies = [ + "log", + "mac", + "markup5ever", + "time 0.1.43", +] + +[[package]] +name = "zeroize" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4756f7db3f7b5574938c3eb1c117038b8e07f95ee6718c0efad4ac21508f1efd" diff --git a/POLICY.md b/POLICY.md index 9f9af590b..3d84cbb82 100644 --- a/POLICY.md +++ b/POLICY.md @@ -35,21 +35,11 @@ Considered to be part of the API policy. ## CLI exit codes -- `0` No errors, normal exit. -- `1` Generic error. -- `2` Never used to avoid conflicts with Bash. -- `3` Unable to execute with the given parameters. -- `4` I/O error. -- `5` Database error. +- `0` - No errors, normal exit. +- `1` - Generic error. +- `2` - Never used to avoid conflicts with Bash. +- `3` - Command-line Syntax Error. # Security -To report a vulnerability, please contact [dustin@cs.uchicago.edu](dustin@cs.uchicago.edu), you may use GPG public-key `D8097934A92E4B4210368102FF8B7AC6154E3226` which is available [here](https://keybase.io/djmitche/pgp_keys.asc?fingerprint=d8097934a92e4b4210368102ff8b7ac6154e3226). Initial response is expected within ~48h. - -We kinldy ask to follow the responsible disclosure model and refrain from sharing information until: -1. Vulnerabilities are patched in TaskChampion + 60 days to coordinate with distributions. -2. 90 days since the vulnerability is disclosed to us. - -We recognise the legitimacy of public interest and accept that security researchers can publish information after 90-days deadline unilaterally. - -We will assist with obtaining CVE and acknowledge the vulnerabilites reported. +See [SECURITY.md](./SECURITY.md). diff --git a/README.md b/README.md index 6a0715ccb..1b4e1d664 100644 --- a/README.md +++ b/README.md @@ -33,3 +33,8 @@ There are three crates here: * [taskchampion-cli](./cli) - the command-line binary * [taskchampion-sync-server](./sync-server) - the server against which `task sync` operates +## Documentation Generation + +The `mdbook` configuration contains a "preprocessor" implemented in the `taskchampion-cli` crate in order to reflect CLI usage information into the generated book. +Tihs preprocessor is not built by default. +To (re)build it, run `cargo build -p taskchampion-cli --features usage-docs --bin usage-docs`. diff --git a/RELEASING.md b/RELEASING.md index 160f2c230..e58e51da7 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -10,7 +10,7 @@ 1. Run `git tag vX.Y.Z` 1. Run `git push upstream` 1. Run `git push --tags upstream` -1. Run `( cd docs; ./build.sh )` +1. Run `( ./build-docs.sh )` 1. Run `(cd taskchampion; cargo publish)` (note that the other crates do not get published) 1. Navigate to the tag in the GitHub releases UI and create a release with general comments about the changes in the release 1. Upload `./target/release/task` and `./target/release/task-sync-server` to the release diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 000000000..9d8d975d9 --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/build-docs.sh b/build-docs.sh new file mode 100755 index 000000000..b03c22ab9 --- /dev/null +++ b/build-docs.sh @@ -0,0 +1,31 @@ +#! /bin/bash + +REMOTE=origin + +set -e + +if ! [ -f "docs/src/SUMMARY.md" ]; then + echo "Run this from the root of the repo" + exit 1 +fi + +# build the latest version of the mdbook plugin +cargo build -p taskchampion-cli --features usage-docs --bin usage-docs + +# create a worktree of this repo, with the `gh-pages` branch checked out +if ! [ -d ./docs/tmp ]; then + git worktree add docs/tmp gh-pages +fi + +# update the wortree +(cd docs/tmp && git pull $REMOTE gh-pages) + +# remove all files in the worktree and regenerate the book there +rm -rf docs/tmp/* +mdbook build docs +cp -rp docs/book/* docs/tmp + +# add everything in the worktree, commit, and push +(cd docs/tmp && git add -A) +(cd docs/tmp && git commit -am "update docs") +(cd docs/tmp && git push $REMOTE gh-pages:gh-pages) diff --git a/cli/Cargo.toml b/cli/Cargo.toml index d66cc63b9..feada5597 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -4,10 +4,16 @@ edition = "2018" name = "taskchampion-cli" version = "0.3.0" +build = "build.rs" + +# Run 'ta' when doing 'cargo run' at repo root +default-run = "ta" + [dependencies] dirs-next = "^2.0.0" env_logger = "^0.8.3" anyhow = "1.0" +thiserror = "1.0" log = "^0.4.14" nom = "^6.1.2" prettytable-rs = "^0.8.0" @@ -16,11 +22,35 @@ termcolor = "^1.1.2" atty = "^0.2.14" toml = "^0.5.8" toml_edit = "^0.2.0" +chrono = "0.4" +lazy_static = "1" +iso8601-duration = "0.1" +dialoguer = "0.8" + +# only needed for usage-docs +# if the mdbook version changes, change it in .github/workflows/publish-docs.yml and .github/workflows/checks.yml as well +mdbook = { version = "0.4.10", optional = true } +serde_json = { version = "*", optional = true } [dependencies.taskchampion] path = "../taskchampion" +[build-dependencies] +built = { version = "0.5", features = ["git2"] } + [dev-dependencies] assert_cmd = "^1.0.3" predicates = "^1.0.7" tempfile = "3" +rstest = "0.10" + +[features] +usage-docs = [ "mdbook", "serde_json" ] + +[[bin]] +name = "ta" + +[[bin]] +# this is an mdbook plugin and only needed when running `mdbook` +name = "usage-docs" +required-features = [ "usage-docs" ] diff --git a/cli/build.rs b/cli/build.rs new file mode 100644 index 000000000..d8f91cb91 --- /dev/null +++ b/cli/build.rs @@ -0,0 +1,3 @@ +fn main() { + built::write_built_file().expect("Failed to acquire build-time information"); +} diff --git a/cli/src/argparse/args.rs b/cli/src/argparse/args.rs deleted file mode 100644 index a902fd690..000000000 --- a/cli/src/argparse/args.rs +++ /dev/null @@ -1,309 +0,0 @@ -//! Parsers for argument lists -- arrays of strings -use super::ArgList; -use nom::bytes::complete::tag as nomtag; -use nom::{ - branch::*, - character::complete::*, - combinator::*, - error::{Error, ErrorKind}, - multi::*, - sequence::*, - Err, IResult, -}; -use std::convert::TryFrom; -use taskchampion::{Status, Tag, Uuid}; - -/// A task identifier, as given in a filter command-line expression -#[derive(Debug, PartialEq, Clone)] -pub(crate) enum TaskId { - /// A small integer identifying a working-set task - WorkingSetId(usize), - - /// A full Uuid specifically identifying a task - Uuid(Uuid), - - /// A prefix of a Uuid - PartialUuid(String), -} - -/// Recognizes any argument -pub(super) fn any(input: &str) -> IResult<&str, &str> { - rest(input) -} - -/// Recognizes a report name -pub(super) fn report_name(input: &str) -> IResult<&str, &str> { - all_consuming(recognize(pair(alpha1, alphanumeric0)))(input) -} - -/// Recognizes a literal string -pub(super) fn literal(literal: &'static str) -> impl Fn(&str) -> IResult<&str, &str> { - move |input: &str| all_consuming(nomtag(literal))(input) -} - -/// Recognizes a colon-prefixed pair -pub(super) fn colon_prefixed(prefix: &'static str) -> impl Fn(&str) -> IResult<&str, &str> { - fn to_suffix<'a>(input: (&'a str, char, &'a str)) -> Result<&'a str, ()> { - Ok(input.2) - } - move |input: &str| { - map_res( - all_consuming(tuple((nomtag(prefix), char(':'), any))), - to_suffix, - )(input) - } -} - -/// Recognizes `status:{pending,completed,deleted}` -pub(super) fn status_colon(input: &str) -> IResult<&str, Status> { - fn to_status(input: &str) -> Result { - match input { - "pending" => Ok(Status::Pending), - "completed" => Ok(Status::Completed), - "deleted" => Ok(Status::Deleted), - _ => Err(()), - } - } - map_res(colon_prefixed("status"), to_status)(input) -} - -/// Recognizes a comma-separated list of TaskIds -pub(super) fn id_list(input: &str) -> IResult<&str, Vec> { - 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 { - Ok(TaskId::Uuid(Uuid::parse_str(input).map_err(|_| ())?)) - } - fn partial_uuid(input: &str) -> Result { - Ok(TaskId::PartialUuid(input.to_owned())) - } - fn working_set_id(input: &str) -> Result { - Ok(TaskId::WorkingSetId(input.parse().map_err(|_| ())?)) - } - all_consuming(separated_list1( - char(','), - alt(( - map_res( - recognize(tuple(( - hex_n(8), - char('-'), - hex_n(4), - char('-'), - hex_n(4), - char('-'), - hex_n(4), - char('-'), - hex_n(12), - ))), - uuid, - ), - map_res( - recognize(tuple(( - hex_n(8), - char('-'), - hex_n(4), - char('-'), - hex_n(4), - char('-'), - hex_n(4), - ))), - partial_uuid, - ), - map_res( - recognize(tuple((hex_n(8), char('-'), hex_n(4), char('-'), hex_n(4)))), - partial_uuid, - ), - map_res( - recognize(tuple((hex_n(8), char('-'), hex_n(4)))), - partial_uuid, - ), - map_res(hex_n(8), partial_uuid), - // note that an 8-decimal-digit value will be treated as a UUID - map_res(digit1, working_set_id), - )), - ))(input) -} - -/// Recognizes a tag prefixed with `+` and returns the tag value -pub(super) fn plus_tag(input: &str) -> IResult<&str, &str> { - fn to_tag(input: (char, &str)) -> Result<&str, ()> { - Ok(input.1) - } - map_res( - all_consuming(tuple(( - char('+'), - recognize(verify(rest, |s: &str| Tag::try_from(s).is_ok())), - ))), - to_tag, - )(input) -} - -/// Recognizes a tag prefixed with `-` and returns the tag value -pub(super) fn minus_tag(input: &str) -> IResult<&str, &str> { - fn to_tag(input: (char, &str)) -> Result<&str, ()> { - Ok(input.1) - } - map_res( - all_consuming(tuple(( - char('-'), - recognize(verify(rest, |s: &str| Tag::try_from(s).is_ok())), - ))), - to_tag, - )(input) -} - -/// Consume a single argument from an argument list that matches the given string parser (one -/// of the other functions in this module). The given parser must consume the entire input. -pub(super) fn arg_matching<'a, O, F>(f: F) -> impl Fn(ArgList<'a>) -> IResult -where - F: Fn(&'a str) -> IResult<&'a str, O>, -{ - move |input: ArgList<'a>| { - if let Some(arg) = input.get(0) { - return match f(arg) { - Ok(("", rv)) => Ok((&input[1..], rv)), - // single-arg parsers must consume the entire arg - Ok((unconsumed, _)) => panic!("unconsumed argument input {}", unconsumed), - // single-arg parsers are all complete parsers - Err(Err::Incomplete(_)) => unreachable!(), - // for error and failure, rewrite to an error at this position in the arugment list - Err(Err::Error(Error { input: _, code })) => Err(Err::Error(Error { input, code })), - Err(Err::Failure(Error { input: _, code })) => { - Err(Err::Failure(Error { input, code })) - } - }; - } - - Err(Err::Error(Error { - input, - // since we're using nom's built-in Error, our choices here are limited, but tihs - // occurs when there's no argument where one is expected, so Eof seems appropriate - code: ErrorKind::Eof, - })) - } -} - -#[cfg(test)] -mod test { - use super::*; - - #[test] - fn test_arg_matching() { - assert_eq!( - arg_matching(plus_tag)(argv!["+foo", "bar"]).unwrap(), - (argv!["bar"], "foo") - ); - assert!(arg_matching(plus_tag)(argv!["foo", "bar"]).is_err()); - } - - #[test] - fn test_colon_prefixed() { - assert_eq!(colon_prefixed("foo")("foo:abc").unwrap().1, "abc"); - assert_eq!(colon_prefixed("foo")("foo:").unwrap().1, ""); - assert!(colon_prefixed("foo")("foo").is_err()); - } - - #[test] - fn test_status_colon() { - assert_eq!(status_colon("status:pending").unwrap().1, Status::Pending); - assert_eq!( - status_colon("status:completed").unwrap().1, - Status::Completed - ); - assert_eq!(status_colon("status:deleted").unwrap().1, Status::Deleted); - assert!(status_colon("status:foo").is_err()); - assert!(status_colon("status:complete").is_err()); - assert!(status_colon("status").is_err()); - } - - #[test] - fn test_plus_tag() { - assert_eq!(plus_tag("+abc").unwrap().1, "abc"); - assert_eq!(plus_tag("+abc123").unwrap().1, "abc123"); - assert!(plus_tag("-abc123").is_err()); - assert!(plus_tag("+abc123 ").is_err()); - assert!(plus_tag(" +abc123").is_err()); - assert!(plus_tag("+1abc").is_err()); - } - - #[test] - fn test_minus_tag() { - assert_eq!(minus_tag("-abc").unwrap().1, "abc"); - assert_eq!(minus_tag("-abc123").unwrap().1, "abc123"); - assert!(minus_tag("+abc123").is_err()); - assert!(minus_tag("-abc123 ").is_err()); - assert!(minus_tag(" -abc123").is_err()); - assert!(minus_tag("-1abc").is_err()); - } - - #[test] - fn test_literal() { - assert_eq!(literal("list")("list").unwrap().1, "list"); - assert!(literal("list")("listicle").is_err()); - assert!(literal("list")(" list ").is_err()); - assert!(literal("list")("LiSt").is_err()); - assert!(literal("list")("denylist").is_err()); - } - - #[test] - fn test_id_list_single() { - assert_eq!(id_list("123").unwrap().1, vec![TaskId::WorkingSetId(123)]); - } - - #[test] - fn test_id_list_uuids() { - assert_eq!( - id_list("12341234").unwrap().1, - vec![TaskId::PartialUuid(s!("12341234"))] - ); - assert_eq!( - id_list("1234abcd").unwrap().1, - vec![TaskId::PartialUuid(s!("1234abcd"))] - ); - assert_eq!( - id_list("abcd1234").unwrap().1, - vec![TaskId::PartialUuid(s!("abcd1234"))] - ); - assert_eq!( - id_list("abcd1234-1234").unwrap().1, - vec![TaskId::PartialUuid(s!("abcd1234-1234"))] - ); - assert_eq!( - id_list("abcd1234-1234-2345").unwrap().1, - vec![TaskId::PartialUuid(s!("abcd1234-1234-2345"))] - ); - assert_eq!( - id_list("abcd1234-1234-2345-3456").unwrap().1, - vec![TaskId::PartialUuid(s!("abcd1234-1234-2345-3456"))] - ); - assert_eq!( - id_list("abcd1234-1234-2345-3456-0123456789ab").unwrap().1, - vec![TaskId::Uuid( - Uuid::parse_str("abcd1234-1234-2345-3456-0123456789ab").unwrap() - )] - ); - } - - #[test] - fn test_id_list_invalid_partial_uuids() { - assert!(id_list("abcd123").is_err()); - assert!(id_list("abcd12345").is_err()); - assert!(id_list("abcd1234-").is_err()); - assert!(id_list("abcd1234-123").is_err()); - assert!(id_list("abcd1234-1234-").is_err()); - assert!(id_list("abcd1234-12345-").is_err()); - assert!(id_list("abcd1234-1234-2345-3456-0123456789ab-").is_err()); - } - - #[test] - fn test_id_list_uuids_mixed() { - assert_eq!(id_list("abcd1234,abcd1234-1234,abcd1234-1234-2345,abcd1234-1234-2345-3456,abcd1234-1234-2345-3456-0123456789ab").unwrap().1, - vec![TaskId::PartialUuid(s!("abcd1234")), - TaskId::PartialUuid(s!("abcd1234-1234")), - TaskId::PartialUuid(s!("abcd1234-1234-2345")), - TaskId::PartialUuid(s!("abcd1234-1234-2345-3456")), - TaskId::Uuid(Uuid::parse_str("abcd1234-1234-2345-3456-0123456789ab").unwrap()), - ]); - } -} diff --git a/cli/src/argparse/args/arg_matching.rs b/cli/src/argparse/args/arg_matching.rs new file mode 100644 index 000000000..f9582bbbb --- /dev/null +++ b/cli/src/argparse/args/arg_matching.rs @@ -0,0 +1,60 @@ +use crate::argparse::ArgList; +use nom::{ + error::{Error, ErrorKind}, + Err, IResult, +}; + +/// Consume a single argument from an argument list that matches the given string parser (one +/// of the other functions in this module). The given parser must consume the entire input. +pub(crate) fn arg_matching<'a, O, F>(f: F) -> impl Fn(ArgList<'a>) -> IResult +where + F: Fn(&'a str) -> IResult<&'a str, O>, +{ + move |input: ArgList<'a>| { + if let Some(arg) = input.get(0) { + return match f(arg) { + Ok(("", rv)) => Ok((&input[1..], rv)), + // single-arg parsers must consume the entire arg, so consider unconsumed + // output to be an error. + Ok((_, _)) => Err(Err::Error(Error { + input, + code: ErrorKind::Eof, + })), + // single-arg parsers are all complete parsers + Err(Err::Incomplete(_)) => unreachable!(), + // for error and failure, rewrite to an error at this position in the arugment list + Err(Err::Error(Error { input: _, code })) => Err(Err::Error(Error { input, code })), + Err(Err::Failure(Error { input: _, code })) => { + Err(Err::Failure(Error { input, code })) + } + }; + } + + Err(Err::Error(Error { + input, + // since we're using nom's built-in Error, our choices here are limited, but tihs + // occurs when there's no argument where one is expected, so Eof seems appropriate + code: ErrorKind::Eof, + })) + } +} + +#[cfg(test)] +mod test { + use super::super::*; + use super::*; + + #[test] + fn test_arg_matching() { + assert_eq!( + arg_matching(plus_tag)(argv!["+foo", "bar"]).unwrap(), + (argv!["bar"], tag!("foo")) + ); + assert!(arg_matching(plus_tag)(argv!["foo", "bar"]).is_err()); + } + + #[test] + fn test_partial_arg_matching() { + assert!(arg_matching(wait_colon)(argv!["wait:UNRECOGNIZED"]).is_err()); + } +} diff --git a/cli/src/argparse/args/colon.rs b/cli/src/argparse/args/colon.rs new file mode 100644 index 000000000..3fa17ce97 --- /dev/null +++ b/cli/src/argparse/args/colon.rs @@ -0,0 +1,85 @@ +use super::{any, timestamp}; +use crate::argparse::NOW; +use chrono::prelude::*; +use nom::bytes::complete::tag as nomtag; +use nom::{branch::*, character::complete::*, combinator::*, sequence::*, IResult}; +use taskchampion::Status; + +/// Recognizes up to the colon of the common `:...` 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 { + 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:` to `Some(ts)` +pub(crate) fn wait_colon(input: &str) -> IResult<&str, Option>> { + fn to_wait(input: DateTime) -> Result>, ()> { + Ok(Some(input)) + } + fn to_none(_: &str) -> Result>, ()> { + Ok(None) + } + preceded( + nomtag("wait:"), + alt(( + map_res(timestamp(*NOW, Local), to_wait), + map_res(nomtag(""), to_none), + )), + )(input) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_colon_prefix() { + assert_eq!(colon_prefix("foo")("foo:abc").unwrap().1, "abc"); + assert_eq!(colon_prefix("foo")("foo:").unwrap().1, ""); + assert!(colon_prefix("foo")("foo").is_err()); + } + + #[test] + fn test_status_colon() { + assert_eq!(status_colon("status:pending").unwrap().1, Status::Pending); + assert_eq!( + status_colon("status:completed").unwrap().1, + Status::Completed + ); + assert_eq!(status_colon("status:deleted").unwrap().1, Status::Deleted); + assert!(status_colon("status:foo").is_err()); + assert!(status_colon("status:complete").is_err()); + assert!(status_colon("status").is_err()); + } + + #[test] + fn test_wait() { + assert_eq!(wait_colon("wait:").unwrap(), ("", None)); + + let one_day = *NOW + chrono::Duration::days(1); + assert_eq!(wait_colon("wait:1d").unwrap(), ("", Some(one_day))); + + let one_day = *NOW + chrono::Duration::days(1); + assert_eq!(wait_colon("wait:1d2").unwrap(), ("2", Some(one_day))); + } +} diff --git a/cli/src/argparse/args/idlist.rs b/cli/src/argparse/args/idlist.rs new file mode 100644 index 000000000..f8c09ae04 --- /dev/null +++ b/cli/src/argparse/args/idlist.rs @@ -0,0 +1,139 @@ +use nom::{branch::*, character::complete::*, combinator::*, multi::*, sequence::*, IResult}; +use taskchampion::Uuid; + +/// A task identifier, as given in a filter command-line expression +#[derive(Debug, PartialEq, Clone)] +pub(crate) enum TaskId { + /// A small integer identifying a working-set task + WorkingSetId(usize), + + /// A full Uuid specifically identifying a task + Uuid(Uuid), + + /// A prefix of a Uuid + PartialUuid(String), +} + +/// Recognizes a comma-separated list of TaskIds +pub(crate) fn id_list(input: &str) -> IResult<&str, Vec> { + 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 { + Ok(TaskId::Uuid(Uuid::parse_str(input).map_err(|_| ())?)) + } + fn partial_uuid(input: &str) -> Result { + Ok(TaskId::PartialUuid(input.to_owned())) + } + fn working_set_id(input: &str) -> Result { + Ok(TaskId::WorkingSetId(input.parse().map_err(|_| ())?)) + } + all_consuming(separated_list1( + char(','), + alt(( + map_res( + recognize(tuple(( + hex_n(8), + char('-'), + hex_n(4), + char('-'), + hex_n(4), + char('-'), + hex_n(4), + char('-'), + hex_n(12), + ))), + uuid, + ), + map_res( + recognize(tuple(( + hex_n(8), + char('-'), + hex_n(4), + char('-'), + hex_n(4), + char('-'), + hex_n(4), + ))), + partial_uuid, + ), + map_res( + recognize(tuple((hex_n(8), char('-'), hex_n(4), char('-'), hex_n(4)))), + partial_uuid, + ), + map_res( + recognize(tuple((hex_n(8), char('-'), hex_n(4)))), + partial_uuid, + ), + map_res(hex_n(8), partial_uuid), + // note that an 8-decimal-digit value will be treated as a UUID + map_res(digit1, working_set_id), + )), + ))(input) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_id_list_single() { + assert_eq!(id_list("123").unwrap().1, vec![TaskId::WorkingSetId(123)]); + } + + #[test] + fn test_id_list_uuids() { + assert_eq!( + id_list("12341234").unwrap().1, + vec![TaskId::PartialUuid(s!("12341234"))] + ); + assert_eq!( + id_list("1234abcd").unwrap().1, + vec![TaskId::PartialUuid(s!("1234abcd"))] + ); + assert_eq!( + id_list("abcd1234").unwrap().1, + vec![TaskId::PartialUuid(s!("abcd1234"))] + ); + assert_eq!( + id_list("abcd1234-1234").unwrap().1, + vec![TaskId::PartialUuid(s!("abcd1234-1234"))] + ); + assert_eq!( + id_list("abcd1234-1234-2345").unwrap().1, + vec![TaskId::PartialUuid(s!("abcd1234-1234-2345"))] + ); + assert_eq!( + id_list("abcd1234-1234-2345-3456").unwrap().1, + vec![TaskId::PartialUuid(s!("abcd1234-1234-2345-3456"))] + ); + assert_eq!( + id_list("abcd1234-1234-2345-3456-0123456789ab").unwrap().1, + vec![TaskId::Uuid( + Uuid::parse_str("abcd1234-1234-2345-3456-0123456789ab").unwrap() + )] + ); + } + + #[test] + fn test_id_list_invalid_partial_uuids() { + assert!(id_list("abcd123").is_err()); + assert!(id_list("abcd12345").is_err()); + assert!(id_list("abcd1234-").is_err()); + assert!(id_list("abcd1234-123").is_err()); + assert!(id_list("abcd1234-1234-").is_err()); + assert!(id_list("abcd1234-12345-").is_err()); + assert!(id_list("abcd1234-1234-2345-3456-0123456789ab-").is_err()); + } + + #[test] + fn test_id_list_uuids_mixed() { + assert_eq!(id_list("abcd1234,abcd1234-1234,abcd1234-1234-2345,abcd1234-1234-2345-3456,abcd1234-1234-2345-3456-0123456789ab").unwrap().1, + vec![TaskId::PartialUuid(s!("abcd1234")), + TaskId::PartialUuid(s!("abcd1234-1234")), + TaskId::PartialUuid(s!("abcd1234-1234-2345")), + TaskId::PartialUuid(s!("abcd1234-1234-2345-3456")), + TaskId::Uuid(Uuid::parse_str("abcd1234-1234-2345-3456-0123456789ab").unwrap()), + ]); + } +} diff --git a/cli/src/argparse/args/misc.rs b/cli/src/argparse/args/misc.rs new file mode 100644 index 000000000..27d2a1315 --- /dev/null +++ b/cli/src/argparse/args/misc.rs @@ -0,0 +1,41 @@ +use nom::bytes::complete::tag as nomtag; +use nom::{character::complete::*, combinator::*, sequence::*, IResult}; + +/// Recognizes any argument +pub(crate) fn any(input: &str) -> IResult<&str, &str> { + rest(input) +} + +/// Recognizes a report name +pub(crate) fn report_name(input: &str) -> IResult<&str, &str> { + all_consuming(recognize(pair(alpha1, alphanumeric0)))(input) +} + +/// Recognizes a literal string +pub(crate) fn literal(literal: &'static str) -> impl Fn(&str) -> IResult<&str, &str> { + move |input: &str| all_consuming(nomtag(literal))(input) +} + +#[cfg(test)] +mod test { + use super::super::*; + use super::*; + + #[test] + fn test_arg_matching() { + assert_eq!( + arg_matching(plus_tag)(argv!["+foo", "bar"]).unwrap(), + (argv!["bar"], tag!("foo")) + ); + assert!(arg_matching(plus_tag)(argv!["foo", "bar"]).is_err()); + } + + #[test] + fn test_literal() { + assert_eq!(literal("list")("list").unwrap().1, "list"); + assert!(literal("list")("listicle").is_err()); + assert!(literal("list")(" list ").is_err()); + assert!(literal("list")("LiSt").is_err()); + assert!(literal("list")("denylist").is_err()); + } +} diff --git a/cli/src/argparse/args/mod.rs b/cli/src/argparse/args/mod.rs new file mode 100644 index 000000000..f7124fa29 --- /dev/null +++ b/cli/src/argparse/args/mod.rs @@ -0,0 +1,16 @@ +//! Parsers for single arguments (strings) + +mod arg_matching; +mod colon; +mod idlist; +mod misc; +mod tags; +mod time; + +pub(crate) use arg_matching::arg_matching; +pub(crate) use colon::{status_colon, wait_colon}; +pub(crate) use idlist::{id_list, TaskId}; +pub(crate) use misc::{any, literal, report_name}; +pub(crate) use tags::{minus_tag, plus_tag}; +#[allow(unused_imports)] +pub(crate) use time::{duration, timestamp}; diff --git a/cli/src/argparse/args/tags.rs b/cli/src/argparse/args/tags.rs new file mode 100644 index 000000000..c15dae6bf --- /dev/null +++ b/cli/src/argparse/args/tags.rs @@ -0,0 +1,34 @@ +use nom::{character::complete::*, combinator::*, sequence::*, IResult}; +use std::convert::TryFrom; +use taskchampion::Tag; + +/// Recognizes a tag prefixed with `+` and returns the tag value +pub(crate) fn plus_tag(input: &str) -> IResult<&str, Tag> { + preceded(char('+'), map_res(rest, Tag::try_from))(input) +} + +/// Recognizes a tag prefixed with `-` and returns the tag value +pub(crate) fn minus_tag(input: &str) -> IResult<&str, Tag> { + preceded(char('-'), map_res(rest, Tag::try_from))(input) +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_plus_tag() { + assert_eq!(plus_tag("+abc").unwrap().1, tag!("abc")); + assert_eq!(plus_tag("+abc123").unwrap().1, tag!("abc123")); + assert!(plus_tag("-abc123").is_err()); + assert!(plus_tag("+1abc").is_err()); + } + + #[test] + fn test_minus_tag() { + assert_eq!(minus_tag("-abc").unwrap().1, tag!("abc")); + assert_eq!(minus_tag("-abc123").unwrap().1, tag!("abc123")); + assert!(minus_tag("+abc123").is_err()); + assert!(minus_tag("-1abc").is_err()); + } +} diff --git a/cli/src/argparse/args/time.rs b/cli/src/argparse/args/time.rs new file mode 100644 index 000000000..4581d0a41 --- /dev/null +++ b/cli/src/argparse/args/time.rs @@ -0,0 +1,466 @@ +use chrono::{prelude::*, Duration}; +use iso8601_duration::Duration as IsoDuration; +use lazy_static::lazy_static; +use nom::{ + branch::*, + bytes::complete::*, + character::complete::*, + character::*, + combinator::*, + error::{Error, ErrorKind}, + multi::*, + sequence::*, + Err, IResult, +}; +use std::str::FromStr; + +// https://taskwarrior.org/docs/dates.html +// https://taskwarrior.org/docs/named_dates.html +// https://taskwarrior.org/docs/durations.html + +/// A case for matching durations. If `.3` is true, then the value can be used +/// without a prefix, e.g., `minute`. If false, it cannot, e.g., `minutes` +#[derive(Debug)] +struct DurationCase(&'static str, Duration, bool); + +// https://github.com/GothenburgBitFactory/libshared/blob/9a5f24e2acb38d05afb8f8e316a966dee196a42a/src/Duration.cpp#L50 +// TODO: use const when chrono supports it +lazy_static! { + static ref DURATION_CASES: Vec = 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::Err> { + let mul = input.parse::()?; + 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 { + // `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> { + 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( + now: DateTime, + local: Tz, +) -> impl Fn(&str) -> IResult<&str, DateTime> { + move |input: &str| { + let local_today = now.with_timezone(&local).date(); + let remaining = &input[input.len()..]; + match input { + "yesterday" => Ok((remaining, local_today - Duration::days(1))), + "today" => Ok((remaining, local_today)), + "tomorrow" => Ok((remaining, local_today + Duration::days(1))), + // TODO: lots more! + _ => Err(Err::Error(Error::new(input, ErrorKind::Tag))), + } + .map(|(rem, dt)| (rem, dt.and_hms(0, 0, 0).with_timezone(&Utc))) + } +} + +/// recognize a digit +fn digit(input: &str) -> IResult<&str, char> { + satisfy(|c| is_digit(c as u8))(input) +} + +/// Parse yyyy-mm-dd as the given date, at the local midnight +fn yyyy_mm_dd(local: Tz) -> impl Fn(&str) -> IResult<&str, DateTime> { + move |input: &str| { + fn parse_int(input: &str) -> Result::Err> { + input.parse::() + } + map_res( + tuple(( + map_res(recognize(count(digit, 4)), parse_int::), + char('-'), + map_res(recognize(many_m_n(1, 2, digit)), parse_int::), + char('-'), + map_res(recognize(many_m_n(1, 2, digit)), parse_int::), + )), + |input: (i32, char, u32, char, u32)| -> Result, ()> { + // 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( + now: DateTime, + local: Tz, +) -> impl Fn(&str) -> IResult<&str, DateTime> { + move |input: &str| { + alt(( + // relative time + map_res( + duration, + |duration: Duration| -> Result, ()> { Ok(now + duration) }, + ), + rfc3339_timestamp, + yyyy_mm_dd(local), + value(now, tag("now")), + named_date(now, local), + ))(input) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::argparse::NOW; + use rstest::rstest; + + const M: i64 = 60; + const H: i64 = M * 60; + const DAY: i64 = H * 24; + const MONTH: i64 = DAY * 30; + const YEAR: i64 = DAY * 365; + + // TODO: use const when chrono supports it + lazy_static! { + // India standard time (not an even multiple of hours) + static ref IST: FixedOffset = FixedOffset::east(5 * 3600 + 30 * 60); + // Utc, but as a FixedOffset TimeZone impl + static ref UTC_FO: FixedOffset = FixedOffset::east(0); + // Hawaii + static ref HST: FixedOffset = FixedOffset::west(10 * 3600); + } + + /// test helper to ensure that the entire input is consumed + fn complete_duration(input: &str) -> IResult<&str, Duration> { + all_consuming(duration)(input) + } + + /// test helper to ensure that the entire input is consumed + fn complete_timestamp( + now: DateTime, + local: Tz, + ) -> impl Fn(&str) -> IResult<&str, DateTime> { + 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.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 DateTime> { + 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 DateTime> { + 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, + #[case] input: &'static str, + #[case] output: DateTime, + ) { + let (_, res) = complete_timestamp(now, *IST)(input).unwrap(); + assert_eq!(res, output, "parsing {:?}", input); + } + + #[rstest] + /// Cases where the `local` parameter matters + #[case::yyyy_mm_dd(ld(2000, 1, 1), "2021-01-01", ld(2021, 1, 1))] + #[case::yyyy_m_d(ld(2000, 1, 1), "2021-1-1", ld(2021, 1, 1))] + #[case::yesterday(ld(2021, 3, 1), "yesterday", ld(2021, 2, 28))] + #[case::yesterday_from_evening(ldt(2021, 3, 1, 21, 30, 30), "yesterday", ld(2021, 2, 28))] + #[case::today(ld(2021, 3, 1), "today", ld(2021, 3, 1))] + #[case::today_from_evening(ldt(2021, 3, 1, 21, 30, 30), "today", ld(2021, 3, 1))] + #[case::tomorrow(ld(2021, 3, 1), "tomorrow", ld(2021, 3, 2))] + #[case::tomorow_from_evening(ldt(2021, 3, 1, 21, 30, 30), "tomorrow", ld(2021, 3, 2))] + fn test_local_timestamp( + #[case] now: Box DateTime>, + #[values(*IST, *UTC_FO, *HST)] tz: FixedOffset, + #[case] input: &str, + #[case] output: Box DateTime>, + ) { + 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, #[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); + } +} diff --git a/cli/src/argparse/command.rs b/cli/src/argparse/command.rs index 79a715ec7..1748b86d0 100644 --- a/cli/src/argparse/command.rs +++ b/cli/src/argparse/command.rs @@ -1,6 +1,5 @@ use super::args::*; use super::{ArgList, Subcommand}; -use anyhow::bail; use nom::{combinator::*, sequence::*, Err, IResult}; /// A command is the overall command that the CLI should execute. @@ -16,8 +15,15 @@ pub(crate) struct Command { impl Command { pub(super) fn parse(input: ArgList) -> IResult { fn to_command(input: (&str, Subcommand)) -> Result { + // Clean up command name, so `./target/bin/ta` to `ta` etc + let command_name: String = std::path::PathBuf::from(&input.0) + .file_name() + // Convert to string, very unlikely to contain non-UTF8 + .map(|x| x.to_string_lossy().to_string()) + .unwrap_or_else(|| input.0.to_owned()); + let command = Command { - command_name: input.0.to_owned(), + command_name, subcommand: input.1, }; Ok(command) @@ -29,13 +35,22 @@ impl Command { } /// Parse a command from the given list of strings. - pub fn from_argv(argv: &[&str]) -> anyhow::Result { + pub fn from_argv(argv: &[&str]) -> Result { match Command::parse(argv) { Ok((&[], cmd)) => Ok(cmd), - Ok((trailing, _)) => bail!("command line has trailing arguments: {:?}", trailing), + Ok((trailing, _)) => Err(crate::Error::for_arguments(format!( + "command line has trailing arguments: {:?}", + trailing + ))), Err(Err::Incomplete(_)) => unreachable!(), - Err(Err::Error(e)) => bail!("command line not recognized: {:?}", e), - Err(Err::Failure(e)) => bail!("command line not recognized: {:?}", e), + Err(Err::Error(e)) => Err(crate::Error::for_arguments(format!( + "command line not recognized: {:?}", + e + ))), + Err(Err::Failure(e)) => Err(crate::Error::for_arguments(format!( + "command line not recognized: {:?}", + e + ))), } } } @@ -56,4 +71,16 @@ mod test { } ); } + + + #[test] + fn test_cleaning_command_name() { + assert_eq!( + Command::from_argv(argv!["/tmp/ta", "version"]).unwrap(), + Command { + subcommand: Subcommand::Version, + command_name: s!("ta"), + } + ); + } } diff --git a/cli/src/argparse/config.rs b/cli/src/argparse/config.rs index 924164564..209000a4e 100644 --- a/cli/src/argparse/config.rs +++ b/cli/src/argparse/config.rs @@ -1,13 +1,15 @@ use super::args::{any, arg_matching, literal}; use super::ArgList; use crate::usage; -use nom::{combinator::*, sequence::*, IResult}; +use nom::{branch::alt, combinator::*, sequence::*, IResult}; #[derive(Debug, PartialEq)] /// A config operation pub(crate) enum ConfigOperation { /// Set a configuration value Set(String, String), + /// Show configuration path + Path, } impl ConfigOperation { @@ -15,14 +17,20 @@ impl ConfigOperation { fn set_to_op(input: (&str, &str, &str)) -> Result { Ok(ConfigOperation::Set(input.1.to_owned(), input.2.to_owned())) } - map_res( - tuple(( - arg_matching(literal("set")), - arg_matching(any), - arg_matching(any), - )), - set_to_op, - )(input) + fn path_to_op(_: &str) -> Result { + 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) { diff --git a/cli/src/argparse/filter.rs b/cli/src/argparse/filter.rs index 49273999b..fa2746bb1 100644 --- a/cli/src/argparse/filter.rs +++ b/cli/src/argparse/filter.rs @@ -1,9 +1,14 @@ -use super::args::{arg_matching, id_list, minus_tag, plus_tag, status_colon, TaskId}; +use super::args::{arg_matching, id_list, literal, minus_tag, plus_tag, status_colon, TaskId}; use super::ArgList; use crate::usage; use anyhow::bail; -use nom::{branch::alt, combinator::*, multi::fold_many0, IResult}; -use taskchampion::Status; +use nom::{ + branch::alt, + combinator::*, + multi::{fold_many0, fold_many1}, + IResult, +}; +use taskchampion::{Status, Tag}; /// A filter represents a selection of a particular set of tasks. /// @@ -21,10 +26,10 @@ pub(crate) struct Filter { #[derive(Debug, PartialEq, Clone)] pub(crate) enum Condition { /// Task has the given tag - HasTag(String), + HasTag(Tag), /// Task does not have the given tag - NoTag(String), + NoTag(Tag), /// Task has the given status Status(Status), @@ -63,15 +68,15 @@ impl Condition { } fn parse_plus_tag(input: ArgList) -> IResult { - fn to_condition(input: &str) -> Result { - Ok(Condition::HasTag(input.to_owned())) + fn to_condition(input: Tag) -> Result { + Ok(Condition::HasTag(input)) } map_res(arg_matching(plus_tag), to_condition)(input) } fn parse_minus_tag(input: ArgList) -> IResult { - fn to_condition(input: &str) -> Result { - Ok(Condition::NoTag(input.to_owned())) + fn to_condition(input: Tag) -> Result { + Ok(Condition::NoTag(input)) } map_res(arg_matching(minus_tag), to_condition)(input) } @@ -85,7 +90,9 @@ impl Condition { } impl Filter { - pub(super) fn parse(input: ArgList) -> IResult { + /// Parse a filter that can include an empty set of args (meaning + /// all tasks) + pub(super) fn parse0(input: ArgList) -> IResult { fold_many0( Condition::parse, Filter { @@ -95,6 +102,30 @@ impl Filter { )(input) } + /// Parse a filter that must have at least one arg, which can be `all` + /// to mean all tasks + pub(super) fn parse1(input: ArgList) -> IResult { + alt(( + Filter::parse_all, + fold_many1( + Condition::parse, + Filter { + ..Default::default() + }, + |acc, arg| acc.with_arg(arg), + ), + ))(input) + } + + fn parse_all(input: ArgList) -> IResult { + fn to_filter(_: &str) -> Result { + Ok(Filter { + ..Default::default() + }) + } + map_res(arg_matching(literal("all")), to_filter)(input) + } + /// fold multiple filter args into a single Filter instance fn with_arg(mut self, cond: Condition) -> Filter { if let Condition::IdList(mut id_list) = cond { @@ -157,6 +188,13 @@ impl Filter { description: " Select tasks with the given status.", }); + u.filters.push(usage::Filter { + syntax: "all", + summary: "All tasks", + description: " + When specified alone for task-modification commands, `all` matches all tasks. + For example, `task all done` will mark all tasks as done.", + }); } } @@ -165,8 +203,8 @@ mod test { use super::*; #[test] - fn test_empty() { - let (input, filter) = Filter::parse(argv![]).unwrap(); + fn test_empty_parse0() { + let (input, filter) = Filter::parse0(argv![]).unwrap(); assert_eq!(input.len(), 0); assert_eq!( filter, @@ -176,9 +214,46 @@ mod test { ); } + #[test] + fn test_empty_parse1() { + // parse1 does not allow empty input + assert!(Filter::parse1(argv![]).is_err()); + } + + #[test] + fn test_all_parse0() { + let (input, _) = Filter::parse0(argv!["all"]).unwrap(); + assert_eq!(input.len(), 1); // did not parse "all" + } + + #[test] + fn test_all_parse1() { + let (input, filter) = Filter::parse1(argv!["all"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + filter, + Filter { + ..Default::default() + } + ); + } + + #[test] + fn test_all_with_other_stuff() { + let (input, filter) = Filter::parse1(argv!["all", "+foo"]).unwrap(); + // filter ends after `all` + assert_eq!(input.len(), 1); + assert_eq!( + filter, + Filter { + ..Default::default() + } + ); + } + #[test] fn test_id_list_single() { - let (input, filter) = Filter::parse(argv!["1"]).unwrap(); + let (input, filter) = Filter::parse0(argv!["1"]).unwrap(); assert_eq!(input.len(), 0); assert_eq!( filter, @@ -190,7 +265,7 @@ mod test { #[test] fn test_id_list_commas() { - let (input, filter) = Filter::parse(argv!["1,2,3"]).unwrap(); + let (input, filter) = Filter::parse0(argv!["1,2,3"]).unwrap(); assert_eq!(input.len(), 0); assert_eq!( filter, @@ -206,7 +281,7 @@ mod test { #[test] fn test_id_list_multi_arg() { - let (input, filter) = Filter::parse(argv!["1,2", "3,4"]).unwrap(); + let (input, filter) = Filter::parse0(argv!["1,2", "3,4"]).unwrap(); assert_eq!(input.len(), 0); assert_eq!( filter, @@ -223,7 +298,7 @@ mod test { #[test] fn test_id_list_uuids() { - let (input, filter) = Filter::parse(argv!["1,abcd1234"]).unwrap(); + let (input, filter) = Filter::parse0(argv!["1,abcd1234"]).unwrap(); assert_eq!(input.len(), 0); assert_eq!( filter, @@ -238,15 +313,15 @@ mod test { #[test] fn test_tags() { - let (input, filter) = Filter::parse(argv!["1", "+yes", "-no"]).unwrap(); + let (input, filter) = Filter::parse0(argv!["1", "+yes", "-no"]).unwrap(); assert_eq!(input.len(), 0); assert_eq!( filter, Filter { conditions: vec![ Condition::IdList(vec![TaskId::WorkingSetId(1),]), - Condition::HasTag("yes".into()), - Condition::NoTag("no".into()), + Condition::HasTag(tag!("yes")), + Condition::NoTag(tag!("no")), ], } ); @@ -254,7 +329,7 @@ mod test { #[test] fn test_status() { - let (input, filter) = Filter::parse(argv!["status:completed", "status:pending"]).unwrap(); + let (input, filter) = Filter::parse0(argv!["status:completed", "status:pending"]).unwrap(); assert_eq!(input.len(), 0); assert_eq!( filter, @@ -269,8 +344,8 @@ mod test { #[test] fn intersect_idlist_idlist() { - let left = Filter::parse(argv!["1,2", "+yes"]).unwrap().1; - let right = Filter::parse(argv!["2,3", "+no"]).unwrap().1; + let left = Filter::parse0(argv!["1,2", "+yes"]).unwrap().1; + let right = Filter::parse0(argv!["2,3", "+no"]).unwrap().1; let both = left.intersect(right); assert_eq!( both, @@ -278,10 +353,10 @@ mod test { conditions: vec![ // from first filter Condition::IdList(vec![TaskId::WorkingSetId(1), TaskId::WorkingSetId(2),]), - Condition::HasTag("yes".into()), + Condition::HasTag(tag!("yes")), // from second filter Condition::IdList(vec![TaskId::WorkingSetId(2), TaskId::WorkingSetId(3)]), - Condition::HasTag("no".into()), + Condition::HasTag(tag!("no")), ], } ); @@ -289,8 +364,8 @@ mod test { #[test] fn intersect_idlist_alltasks() { - let left = Filter::parse(argv!["1,2", "+yes"]).unwrap().1; - let right = Filter::parse(argv!["+no"]).unwrap().1; + let left = Filter::parse0(argv!["1,2", "+yes"]).unwrap().1; + let right = Filter::parse0(argv!["+no"]).unwrap().1; let both = left.intersect(right); assert_eq!( both, @@ -298,9 +373,9 @@ mod test { conditions: vec![ // from first filter Condition::IdList(vec![TaskId::WorkingSetId(1), TaskId::WorkingSetId(2),]), - Condition::HasTag("yes".into()), + Condition::HasTag(tag!("yes")), // from second filter - Condition::HasTag("no".into()), + Condition::HasTag(tag!("no")), ], } ); @@ -308,15 +383,15 @@ mod test { #[test] fn intersect_alltasks_alltasks() { - let left = Filter::parse(argv!["+yes"]).unwrap().1; - let right = Filter::parse(argv!["+no"]).unwrap().1; + let left = Filter::parse0(argv!["+yes"]).unwrap().1; + let right = Filter::parse0(argv!["+no"]).unwrap().1; let both = left.intersect(right); assert_eq!( both, Filter { conditions: vec![ - Condition::HasTag("yes".into()), - Condition::HasTag("no".into()), + Condition::HasTag(tag!("yes")), + Condition::HasTag(tag!("no")), ], } ); diff --git a/cli/src/argparse/mod.rs b/cli/src/argparse/mod.rs index 88de59046..7f2607631 100644 --- a/cli/src/argparse/mod.rs +++ b/cli/src/argparse/mod.rs @@ -31,6 +31,13 @@ pub(crate) use modification::{DescriptionMod, Modification}; pub(crate) use subcommand::Subcommand; use crate::usage::Usage; +use chrono::prelude::*; +use lazy_static::lazy_static; + +lazy_static! { + // A static value of NOW to make tests easier + pub(crate) static ref NOW: DateTime = Utc::now(); +} type ArgList<'a> = &'a [&'a str]; diff --git a/cli/src/argparse/modification.rs b/cli/src/argparse/modification.rs index d449f1ef6..bd37db928 100644 --- a/cli/src/argparse/modification.rs +++ b/cli/src/argparse/modification.rs @@ -1,9 +1,10 @@ -use super::args::{any, arg_matching, minus_tag, plus_tag}; +use super::args::{any, arg_matching, minus_tag, plus_tag, wait_colon}; use super::ArgList; use crate::usage; +use chrono::prelude::*; use nom::{branch::alt, combinator::*, multi::fold_many0, IResult}; use std::collections::HashSet; -use taskchampion::Status; +use taskchampion::{Status, Tag}; #[derive(Debug, PartialEq, Clone)] pub enum DescriptionMod { @@ -36,21 +37,25 @@ pub struct Modification { /// Set the status pub status: Option, + /// Set (or, with `Some(None)`, clear) the wait timestamp + pub wait: Option>>, + /// Set the "active" state, that is, start (true) or stop (false) the task. pub active: Option, /// Add tags - pub add_tags: HashSet, + pub add_tags: HashSet, /// Remove tags - pub remove_tags: HashSet, + pub remove_tags: HashSet, } /// A single argument that is part of a modification, used internally to this module enum ModArg<'a> { Description(&'a str), - PlusTag(&'a str), - MinusTag(&'a str), + PlusTag(Tag), + MinusTag(Tag), + Wait(Option>), } impl Modification { @@ -66,10 +71,13 @@ impl Modification { } } ModArg::PlusTag(tag) => { - acc.add_tags.insert(tag.to_owned()); + acc.add_tags.insert(tag); } ModArg::MinusTag(tag) => { - acc.remove_tags.insert(tag.to_owned()); + acc.remove_tags.insert(tag); + } + ModArg::Wait(wait) => { + acc.wait = Some(wait); } } acc @@ -78,6 +86,7 @@ impl Modification { alt(( Self::plus_tag, Self::minus_tag, + Self::wait, // this must come last Self::description, )), @@ -96,38 +105,58 @@ impl Modification { } fn plus_tag(input: ArgList) -> IResult { - fn to_modarg(input: &str) -> Result { + fn to_modarg(input: Tag) -> Result, ()> { Ok(ModArg::PlusTag(input)) } map_res(arg_matching(plus_tag), to_modarg)(input) } fn minus_tag(input: ArgList) -> IResult { - fn to_modarg(input: &str) -> Result { + fn to_modarg(input: Tag) -> Result, ()> { Ok(ModArg::MinusTag(input)) } map_res(arg_matching(minus_tag), to_modarg)(input) } + fn wait(input: ArgList) -> IResult { + fn to_modarg(input: Option>) -> Result, ()> { + Ok(ModArg::Wait(input)) + } + map_res(arg_matching(wait_colon), to_modarg)(input) + } + pub(super) fn get_usage(u: &mut usage::Usage) { u.modifications.push(usage::Modification { syntax: "DESCRIPTION", summary: "Set description", description: " Set the task description. Multiple arguments are combined into a single - space-separated description.", + space-separated description. To avoid surprises from shell quoting, prefer + to use a single quoted argument, for example `ta 19 modify \"return library + books\"`", }); u.modifications.push(usage::Modification { syntax: "+TAG", summary: "Tag task", - description: " - Add the given tag to the task.", + description: "Add the given tag to the task.", }); u.modifications.push(usage::Modification { syntax: "-TAG", summary: "Un-tag task", + description: "Remove the given tag from the task.", + }); + u.modifications.push(usage::Modification { + syntax: "status:{pending,completed,deleted}", + summary: "Set the task's status", + description: "Set the status of the task explicitly.", + }); + u.modifications.push(usage::Modification { + syntax: "wait:", + summary: "Set or unset the task's wait time", description: " - Remove the given tag from the task.", + Set the time before which the task is not actionable and should not be shown in + reports, e.g., `wait:3day` to wait for three days. With `wait:`, the time is + un-set. See the documentation for the timestamp syntax.", }); } } @@ -135,6 +164,7 @@ impl Modification { #[cfg(test)] mod test { use super::*; + use crate::argparse::NOW; #[test] fn test_empty() { @@ -168,7 +198,33 @@ mod test { assert_eq!( modification, Modification { - add_tags: set![s!("abc"), s!("def")], + add_tags: set![tag!("abc"), tag!("def")], + ..Default::default() + } + ); + } + + #[test] + fn test_set_wait() { + let (input, modification) = Modification::parse(argv!["wait:2d"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + modification, + Modification { + wait: Some(Some(*NOW + chrono::Duration::days(2))), + ..Default::default() + } + ); + } + + #[test] + fn test_unset_wait() { + let (input, modification) = Modification::parse(argv!["wait:"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + modification, + Modification { + wait: Some(None), ..Default::default() } ); @@ -196,8 +252,8 @@ mod test { modification, Modification { description: DescriptionMod::Set(s!("new desc fun")), - add_tags: set![s!("next")], - remove_tags: set![s!("daytime")], + add_tags: set![tag!("next")], + remove_tags: set![tag!("daytime")], ..Default::default() } ); diff --git a/cli/src/argparse/subcommand.rs b/cli/src/argparse/subcommand.rs index 5609192b7..53d67556f 100644 --- a/cli/src/argparse/subcommand.rs +++ b/cli/src/argparse/subcommand.rs @@ -217,7 +217,7 @@ impl Modify { } map_res( tuple(( - Filter::parse, + Filter::parse1, alt(( arg_matching(literal("modify")), arg_matching(literal("prepend")), @@ -235,47 +235,47 @@ impl Modify { fn get_usage(u: &mut usage::Usage) { u.subcommands.push(usage::Subcommand { name: "modify", - syntax: "[filter] modify [modification]", + syntax: " modify [modification]", summary: "Modify tasks", description: " - Modify all tasks matching the filter.", + Modify all tasks matching the required filter.", }); u.subcommands.push(usage::Subcommand { name: "prepend", - syntax: "[filter] prepend [modification]", + syntax: " prepend [modification]", summary: "Prepend task description", description: " - Modify all tasks matching the filter by inserting the given description before each + Modify all tasks matching the required filter by inserting the given description before each task's description.", }); u.subcommands.push(usage::Subcommand { name: "append", - syntax: "[filter] append [modification]", + syntax: " append [modification]", summary: "Append task description", description: " - Modify all tasks matching the filter by adding the given description to the end + Modify all tasks matching the required filter by adding the given description to the end of each task's description.", }); u.subcommands.push(usage::Subcommand { name: "start", - syntax: "[filter] start [modification]", + syntax: " start [modification]", summary: "Start tasks", description: " - Start all tasks matching the filter, additionally applying any given modifications." + Start all tasks matching the required filter, additionally applying any given modifications." }); u.subcommands.push(usage::Subcommand { name: "stop", - syntax: "[filter] stop [modification]", + syntax: " stop [modification]", summary: "Stop tasks", description: " - Stop all tasks matching the filter, additionally applying any given modifications.", + Stop all tasks matching the required filter, additionally applying any given modifications.", }); u.subcommands.push(usage::Subcommand { name: "done", - syntax: "[filter] done [modification]", + syntax: " done [modification]", summary: "Mark tasks as completed", description: " - Mark all tasks matching the filter as completed, additionally applying any given + Mark all tasks matching the required filter as completed, additionally applying any given modifications.", }); } @@ -293,14 +293,14 @@ impl Report { } // allow the filter expression before or after the report name alt(( - map_res(pair(arg_matching(report_name), Filter::parse), |input| { + map_res(pair(arg_matching(report_name), Filter::parse0), |input| { to_subcommand(input.1, input.0) }), - map_res(pair(Filter::parse, arg_matching(report_name)), |input| { + map_res(pair(Filter::parse0, arg_matching(report_name)), |input| { to_subcommand(input.0, input.1) }), // default to a "next" report - map_res(Filter::parse, |input| to_subcommand(input, "next")), + map_res(Filter::parse0, |input| to_subcommand(input, "next")), ))(input) } @@ -335,7 +335,7 @@ impl Info { } map_res( pair( - Filter::parse, + Filter::parse1, alt(( arg_matching(literal("info")), arg_matching(literal("debug")), diff --git a/cli/src/bin/ta.rs b/cli/src/bin/ta.rs index ecf529be3..efdee99da 100644 --- a/cli/src/bin/ta.rs +++ b/cli/src/bin/ta.rs @@ -1,8 +1,11 @@ use std::process::exit; pub fn main() { - if let Err(err) = taskchampion_cli::main() { - eprintln!("{:?}", err); - exit(1); + match taskchampion_cli::main() { + Ok(_) => exit(0), + Err(e) => { + eprintln!("{:?}", e); + exit(e.exit_status()); + } } } diff --git a/cli/src/bin/usage-docs.rs b/cli/src/bin/usage-docs.rs new file mode 100644 index 000000000..cf3998b5b --- /dev/null +++ b/cli/src/bin/usage-docs.rs @@ -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 { + 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) +} diff --git a/cli/src/errors.rs b/cli/src/errors.rs new file mode 100644 index 000000000..16ac96285 --- /dev/null +++ b/cli/src/errors.rs @@ -0,0 +1,59 @@ +use taskchampion::Error as TcError; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("Command-Line Syntax Error: {0}")] + Arguments(String), + + #[error(transparent)] + TaskChampion(#[from] TcError), + + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +impl Error { + /// Construct a new command-line argument error + pub(crate) fn for_arguments(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 for Error { + fn from(err: std::io::Error) -> Self { + let err: anyhow::Error = err.into(); + Error::Other(err) + } +} + +#[cfg(test)] +mod test { + use super::*; + use anyhow::anyhow; + + #[test] + fn test_exit_status() { + let mut err: Error; + + err = anyhow!("uhoh").into(); + assert_eq!(err.exit_status(), 1); + + err = Error::Arguments("uhoh".to_string()); + assert_eq!(err.exit_status(), 3); + + err = std::io::Error::last_os_error().into(); + assert_eq!(err.exit_status(), 1); + + err = TcError::Database("uhoh".to_string()).into(); + assert_eq!(err.exit_status(), 1); + } +} diff --git a/cli/src/invocation/cmd/add.rs b/cli/src/invocation/cmd/add.rs index 8a4456282..abee1bf4d 100644 --- a/cli/src/invocation/cmd/add.rs +++ b/cli/src/invocation/cmd/add.rs @@ -6,7 +6,7 @@ pub(crate) fn execute( w: &mut W, replica: &mut Replica, modification: Modification, -) -> anyhow::Result<()> { +) -> Result<(), crate::Error> { let description = match modification.description { DescriptionMod::Set(ref s) => s.clone(), _ => "(no description)".to_owned(), diff --git a/cli/src/invocation/cmd/config.rs b/cli/src/invocation/cmd/config.rs index 0f34defae..fc8aa6a3f 100644 --- a/cli/src/invocation/cmd/config.rs +++ b/cli/src/invocation/cmd/config.rs @@ -6,7 +6,7 @@ pub(crate) fn execute( w: &mut W, config_operation: ConfigOperation, settings: &Settings, -) -> anyhow::Result<()> { +) -> Result<(), crate::Error> { match config_operation { ConfigOperation::Set(key, value) => { let filename = settings.set(&key, &value)?; @@ -19,6 +19,13 @@ pub(crate) fn execute( 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(()) } diff --git a/cli/src/invocation/cmd/gc.rs b/cli/src/invocation/cmd/gc.rs index 775b3096f..9b14b9fbb 100644 --- a/cli/src/invocation/cmd/gc.rs +++ b/cli/src/invocation/cmd/gc.rs @@ -1,7 +1,7 @@ use taskchampion::Replica; use termcolor::WriteColor; -pub(crate) fn execute(w: &mut W, replica: &mut Replica) -> anyhow::Result<()> { +pub(crate) fn execute(w: &mut W, replica: &mut Replica) -> Result<(), crate::Error> { log::debug!("rebuilding working set"); replica.rebuild_working_set(true)?; writeln!(w, "garbage collected.")?; diff --git a/cli/src/invocation/cmd/help.rs b/cli/src/invocation/cmd/help.rs index 421c140a7..2f81a08f8 100644 --- a/cli/src/invocation/cmd/help.rs +++ b/cli/src/invocation/cmd/help.rs @@ -5,7 +5,7 @@ pub(crate) fn execute( w: &mut W, command_name: String, summary: bool, -) -> anyhow::Result<()> { +) -> Result<(), crate::Error> { let usage = Usage::new(); usage.write_help(w, command_name.as_ref(), summary)?; Ok(()) diff --git a/cli/src/invocation/cmd/info.rs b/cli/src/invocation/cmd/info.rs index 1f90b79f3..c77476a69 100644 --- a/cli/src/invocation/cmd/info.rs +++ b/cli/src/invocation/cmd/info.rs @@ -10,7 +10,7 @@ pub(crate) fn execute( replica: &mut Replica, filter: Filter, debug: bool, -) -> anyhow::Result<()> { +) -> Result<(), crate::Error> { let working_set = replica.working_set()?; for task in filtered_tasks(replica, &filter)? { @@ -36,6 +36,9 @@ pub(crate) fn execute( tags.sort(); t.add_row(row![b->"Tags", tags.join(" ")]); } + if let Some(wait) = task.get_wait() { + t.add_row(row![b->"Wait", wait]); + } } t.print(w)?; } diff --git a/cli/src/invocation/cmd/modify.rs b/cli/src/invocation/cmd/modify.rs index 06b4ccc0e..0fa803441 100644 --- a/cli/src/invocation/cmd/modify.rs +++ b/cli/src/invocation/cmd/modify.rs @@ -1,18 +1,65 @@ use crate::argparse::{Filter, Modification}; +use crate::invocation::util::{confirm, summarize_task}; use crate::invocation::{apply_modification, filtered_tasks}; +use crate::settings::Settings; use taskchampion::Replica; use termcolor::WriteColor; +/// confirm modification of more than `modificationt_count_prompt` tasks, defaulting to 3 +fn check_modification( + w: &mut W, + settings: &Settings, + affected_tasks: usize, +) -> Result { + let setting = settings.modification_count_prompt.unwrap_or(3); + if setting == 0 || affected_tasks <= setting as usize { + return Ok(true); + } + + let prompt = format!("Operation will modify {} tasks; continue?", affected_tasks,); + if confirm(&prompt)? { + return Ok(true); + } + + writeln!(w, "Cancelled")?; + + // only show this help if the setting is not set + if settings.modification_count_prompt.is_none() { + writeln!( + w, + "Set the `modification_count_prompt` setting to avoid this prompt:" + )?; + writeln!( + w, + " ta config set modification_count_prompt {}", + affected_tasks + 1 + )?; + writeln!(w, "Set it to 0 to disable the prompt entirely")?; + } + Ok(false) +} + pub(crate) fn execute( w: &mut W, replica: &mut Replica, + settings: &Settings, filter: Filter, modification: Modification, -) -> anyhow::Result<()> { - for task in filtered_tasks(replica, &filter)? { +) -> Result<(), crate::Error> { + let tasks = filtered_tasks(replica, &filter)?; + + if !check_modification(w, settings, tasks.size_hint().0)? { + return Ok(()); + } + + for task in tasks { let mut task = task.into_mut(replica); - apply_modification(w, &mut task, &modification)?; + apply_modification(&mut task, &modification)?; + + let task = task.into_immut(); + let summary = summarize_task(replica, &task)?; + writeln!(w, "modified task {}", summary)?; } Ok(()) @@ -30,6 +77,7 @@ mod test { fn test_modify() { let mut w = test_writer(); let mut replica = test_replica(); + let settings = Settings::default(); let task = replica .new_task(Status::Pending, s!("old description")) @@ -42,7 +90,7 @@ mod test { description: DescriptionMod::Set(s!("new description")), ..Default::default() }; - execute(&mut w, &mut replica, filter, modification).unwrap(); + execute(&mut w, &mut replica, &settings, filter, modification).unwrap(); // check that the task appeared.. let task = replica.get_task(task.get_uuid()).unwrap().unwrap(); @@ -51,7 +99,7 @@ mod test { assert_eq!( w.into_string(), - format!("modified task {}\n", task.get_uuid()) + format!("modified task 1 - new description\n") ); } } diff --git a/cli/src/invocation/cmd/report.rs b/cli/src/invocation/cmd/report.rs index 7123f0353..ab079af28 100644 --- a/cli/src/invocation/cmd/report.rs +++ b/cli/src/invocation/cmd/report.rs @@ -10,7 +10,7 @@ pub(crate) fn execute( settings: &Settings, report_name: String, filter: Filter, -) -> anyhow::Result<()> { +) -> Result<(), crate::Error> { display_report(w, replica, settings, report_name, filter) } diff --git a/cli/src/invocation/cmd/sync.rs b/cli/src/invocation/cmd/sync.rs index 2e9400642..ce213a5ba 100644 --- a/cli/src/invocation/cmd/sync.rs +++ b/cli/src/invocation/cmd/sync.rs @@ -5,7 +5,7 @@ pub(crate) fn execute( w: &mut W, replica: &mut Replica, server: &mut Box, -) -> anyhow::Result<()> { +) -> Result<(), crate::Error> { replica.sync(server)?; writeln!(w, "sync complete.")?; Ok(()) diff --git a/cli/src/invocation/cmd/version.rs b/cli/src/invocation/cmd/version.rs index baef94161..af1f77c56 100644 --- a/cli/src/invocation/cmd/version.rs +++ b/cli/src/invocation/cmd/version.rs @@ -1,10 +1,20 @@ +use crate::built_info; use termcolor::{ColorSpec, WriteColor}; -pub(crate) fn execute(w: &mut W) -> anyhow::Result<()> { +pub(crate) fn execute(w: &mut W) -> Result<(), crate::Error> { write!(w, "TaskChampion ")?; w.set_color(ColorSpec::new().set_bold(true))?; - writeln!(w, "{}", env!("CARGO_PKG_VERSION"))?; + write!(w, "{}", built_info::PKG_VERSION)?; w.reset()?; + + if let (Some(version), Some(dirty)) = (built_info::GIT_VERSION, built_info::GIT_DIRTY) { + if dirty { + write!(w, " (git version: {} with un-committed changes)", version)?; + } else { + write!(w, " (git version: {})", version)?; + }; + } + writeln!(w)?; Ok(()) } diff --git a/cli/src/invocation/filter.rs b/cli/src/invocation/filter.rs index 0cc6e31fe..0d4add799 100644 --- a/cli/src/invocation/filter.rs +++ b/cli/src/invocation/filter.rs @@ -1,22 +1,17 @@ use crate::argparse::{Condition, Filter, TaskId}; use std::collections::HashSet; -use std::convert::TryInto; -use taskchampion::{Replica, Status, Tag, Task, Uuid, WorkingSet}; +use taskchampion::{Replica, Status, Task, Uuid, WorkingSet}; fn match_task(filter: &Filter, task: &Task, uuid: Uuid, working_set: &WorkingSet) -> bool { for cond in &filter.conditions { match cond { Condition::HasTag(ref tag) => { - // see #111 for the unwrap - let tag: Tag = tag.try_into().unwrap(); - if !task.has_tag(&tag) { + if !task.has_tag(tag) { return false; } } Condition::NoTag(ref tag) => { - // see #111 for the unwrap - let tag: Tag = tag.try_into().unwrap(); - if task.has_tag(&tag) { + if task.has_tag(tag) { return false; } } @@ -254,8 +249,8 @@ mod test { #[test] fn tag_filtering() -> anyhow::Result<()> { let mut replica = test_replica(); - let yes: Tag = "yes".try_into()?; - let no: Tag = "no".try_into()?; + let yes = tag!("yes"); + let no = tag!("no"); let mut t1 = replica .new_task(Status::Pending, s!("A"))? @@ -274,7 +269,7 @@ mod test { // look for just "yes" (A and B) let filter = Filter { - conditions: vec![Condition::HasTag(s!("yes"))], + conditions: vec![Condition::HasTag(tag!("yes"))], }; let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)? .map(|t| t.get_description().to_owned()) @@ -284,7 +279,7 @@ mod test { // look for tags without "no" (A, D) let filter = Filter { - conditions: vec![Condition::NoTag(s!("no"))], + conditions: vec![Condition::NoTag(tag!("no"))], }; let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)? .map(|t| t.get_description().to_owned()) @@ -294,7 +289,10 @@ mod test { // look for tags with "yes" and "no" (B) let filter = Filter { - conditions: vec![Condition::HasTag(s!("yes")), Condition::HasTag(s!("no"))], + conditions: vec![ + Condition::HasTag(tag!("yes")), + Condition::HasTag(tag!("no")), + ], }; let filtered: Vec<_> = filtered_tasks(&mut replica, &filter)? .map(|t| t.get_description().to_owned()) diff --git a/cli/src/invocation/mod.rs b/cli/src/invocation/mod.rs index a9723fe9a..b2335a345 100644 --- a/cli/src/invocation/mod.rs +++ b/cli/src/invocation/mod.rs @@ -9,6 +9,7 @@ mod cmd; mod filter; mod modify; mod report; +mod util; #[cfg(test)] mod test; @@ -19,7 +20,7 @@ use report::display_report; /// Invoke the given Command in the context of the given settings #[allow(clippy::needless_return)] -pub(crate) fn invoke(command: Command, settings: Settings) -> anyhow::Result<()> { +pub(crate) fn invoke(command: Command, settings: Settings) -> Result<(), crate::Error> { log::debug!("command: {:?}", command); log::debug!("settings: {:?}", settings); @@ -60,7 +61,7 @@ pub(crate) fn invoke(command: Command, settings: Settings) -> anyhow::Result<()> modification, }, .. - } => return cmd::modify::execute(&mut w, &mut replica, filter, modification), + } => return cmd::modify::execute(&mut w, &mut replica, &settings, filter, modification), Command { subcommand: diff --git a/cli/src/invocation/modify.rs b/cli/src/invocation/modify.rs index b67620788..dd943fdd1 100644 --- a/cli/src/invocation/modify.rs +++ b/cli/src/invocation/modify.rs @@ -1,11 +1,8 @@ use crate::argparse::{DescriptionMod, Modification}; -use std::convert::TryInto; use taskchampion::TaskMut; -use termcolor::WriteColor; /// Apply the given modification -pub(super) fn apply_modification( - w: &mut W, +pub(super) fn apply_modification( task: &mut TaskMut, modification: &Modification, ) -> anyhow::Result<()> { @@ -33,16 +30,16 @@ pub(super) fn apply_modification( } for tag in modification.add_tags.iter() { - let tag = tag.try_into()?; // see #111 task.add_tag(&tag)?; } for tag in modification.remove_tags.iter() { - let tag = tag.try_into()?; // see #111 task.remove_tag(&tag)?; } - writeln!(w, "modified task {}", task.get_uuid())?; + if let Some(wait) = modification.wait { + task.set_wait(wait)?; + } Ok(()) } diff --git a/cli/src/invocation/report.rs b/cli/src/invocation/report.rs index 36d15574a..d67dae4fb 100644 --- a/cli/src/invocation/report.rs +++ b/cli/src/invocation/report.rs @@ -27,6 +27,7 @@ fn sort_tasks(tasks: &mut Vec, report: &Report, working_set: &WorkingSet) } SortBy::Uuid => a.get_uuid().cmp(&b.get_uuid()), SortBy::Description => a.get_description().cmp(b.get_description()), + SortBy::Wait => a.get_wait().cmp(&b.get_wait()), }; // If this sort property is equal, go on to the next.. if ord == Ordering::Equal { @@ -71,6 +72,13 @@ fn task_column(task: &Task, column: &Column, working_set: &WorkingSet) -> String tags.sort(); tags.join(" ") } + Property::Wait => { + if task.is_waiting() { + task.get_wait().unwrap().format("%Y-%m-%d").to_string() + } else { + "".to_owned() + } + } } } @@ -80,7 +88,7 @@ pub(super) fn display_report( settings: &Settings, report_name: String, filter: Filter, -) -> anyhow::Result<()> { +) -> Result<(), crate::Error> { let mut t = Table::new(); let working_set = replica.working_set()?; @@ -124,6 +132,7 @@ mod test { use super::*; use crate::invocation::test::*; use crate::settings::Sort; + use chrono::prelude::*; use std::convert::TryInto; use taskchampion::{Status, Uuid}; @@ -217,6 +226,50 @@ mod test { assert_eq!(got_uuids, exp_uuids); } + #[test] + fn sorting_by_wait() { + let mut replica = test_replica(); + let uuids = create_tasks(&mut replica); + + replica + .get_task(uuids[0]) + .unwrap() + .unwrap() + .into_mut(&mut replica) + .set_wait(Some(Utc::now() + chrono::Duration::days(2))) + .unwrap(); + + replica + .get_task(uuids[1]) + .unwrap() + .unwrap() + .into_mut(&mut replica) + .set_wait(Some(Utc::now() + chrono::Duration::days(3))) + .unwrap(); + + let working_set = replica.working_set().unwrap(); + + let report = Report { + sort: vec![Sort { + ascending: true, + sort_by: SortBy::Wait, + }], + ..Default::default() + }; + + let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect(); + sort_tasks(&mut tasks, &report, &working_set); + let got_uuids: Vec<_> = tasks.iter().map(|t| t.get_uuid()).collect(); + + let exp_uuids = vec![ + uuids[2], // no wait + uuids[0], // wait:2d + uuids[1], // wait:3d + ]; + + assert_eq!(got_uuids, exp_uuids); + } + #[test] fn sorting_by_multiple() { let mut replica = test_replica(); @@ -350,8 +403,11 @@ mod test { }; let task = replica.get_task(uuids[0]).unwrap().unwrap(); - assert_eq!(task_column(&task, &column, &working_set), s!("+bar +foo")); + assert_eq!( + task_column(&task, &column, &working_set), + s!("+PENDING +bar +foo") + ); let task = replica.get_task(uuids[2]).unwrap().unwrap(); - assert_eq!(task_column(&task, &column, &working_set), s!("")); + assert_eq!(task_column(&task, &column, &working_set), s!("+PENDING")); } } diff --git a/cli/src/invocation/util.rs b/cli/src/invocation/util.rs new file mode 100644 index 000000000..12f4535e8 --- /dev/null +++ b/cli/src/invocation/util.rs @@ -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>(prompt: S) -> anyhow::Result { + 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 { + 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())) + } +} diff --git a/cli/src/lib.rs b/cli/src/lib.rs index 2890e4b9f..b247bfee6 100644 --- a/cli/src/lib.rs +++ b/cli/src/lib.rs @@ -38,23 +38,34 @@ use std::string::FromUtf8Error; mod macros; mod argparse; +mod errors; mod invocation; mod settings; mod table; mod usage; +/// See https://docs.rs/built +pub(crate) mod built_info { + include!(concat!(env!("OUT_DIR"), "/built.rs")); +} + +pub(crate) use errors::Error; use settings::Settings; +// used by the `generate` command +pub use usage::Usage; + /// The main entry point for the command-line interface. This builds an Invocation /// from the particulars of the operating-system interface, and then executes it. -pub fn main() -> anyhow::Result<()> { +pub fn main() -> Result<(), Error> { env_logger::init(); // parse the command line into a vector of &str, failing if // there are invalid utf-8 sequences. let argv: Vec = std::env::args_os() .map(|oss| String::from_utf8(oss.into_vec())) - .collect::>()?; + .collect::>() + .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 diff --git a/cli/src/macros.rs b/cli/src/macros.rs index 02e11e5cf..1a3024c13 100644 --- a/cli/src/macros.rs +++ b/cli/src/macros.rs @@ -30,3 +30,9 @@ macro_rules! set( macro_rules! s( { $s:expr } => { $s.to_owned() }; ); + +/// Create a Tag from an &str; just a testing shorthand +#[cfg(test)] +macro_rules! tag( + { $s:expr } => { { use std::convert::TryFrom; taskchampion::Tag::try_from($s).unwrap() } }; +); diff --git a/cli/src/settings/mod.rs b/cli/src/settings/mod.rs index 896de38b8..c6a6ddd2f 100644 --- a/cli/src/settings/mod.rs +++ b/cli/src/settings/mod.rs @@ -7,5 +7,5 @@ mod report; mod settings; mod util; -pub(crate) use report::{Column, Property, Report, Sort, SortBy}; +pub(crate) use report::{get_usage, Column, Property, Report, Sort, SortBy}; pub(crate) use settings::Settings; diff --git a/cli/src/settings/report.rs b/cli/src/settings/report.rs index 959494e5d..1911bf84e 100644 --- a/cli/src/settings/report.rs +++ b/cli/src/settings/report.rs @@ -2,6 +2,7 @@ use crate::argparse::{Condition, Filter}; use crate::settings::util::table_with_keys; +use crate::usage::{self, Usage}; use anyhow::{anyhow, bail, Result}; use std::convert::{TryFrom, TryInto}; @@ -30,6 +31,7 @@ pub(crate) struct Column { /// Task property to display in a report #[derive(Clone, Debug, PartialEq)] pub(crate) enum Property { + // NOTE: when adding a property here, add it to get_usage, below, as well. /// The task's ID, either working-set index or Uuid if not in the working set Id, @@ -44,6 +46,9 @@ pub(crate) enum Property { /// The task's tags Tags, + + /// The task's wait date + Wait, } /// A sorting criterion for a sort operation. @@ -59,6 +64,7 @@ pub(crate) struct Sort { /// Task property to sort by #[derive(Clone, Debug, PartialEq)] pub(crate) enum SortBy { + // NOTE: when adding a property here, add it to get_usage, below, as well. /// The task's ID, either working-set index or a UUID prefix; working /// set tasks sort before others. Id, @@ -68,6 +74,9 @@ pub(crate) enum SortBy { /// The task's description Description, + + /// The task's wait date + Wait, } // Conversions from settings::Settings. @@ -171,6 +180,7 @@ impl TryFrom<&toml::Value> for Property { "active" => Property::Active, "description" => Property::Description, "tags" => Property::Tags, + "wait" => Property::Wait, _ => bail!(": unknown property {}", s), }) } @@ -207,11 +217,45 @@ impl TryFrom<&toml::Value> for SortBy { "id" => SortBy::Id, "uuid" => SortBy::Uuid, "description" => SortBy::Description, + "wait" => SortBy::Wait, _ => bail!(": unknown sort_by value `{}`", s), }) } } +pub(crate) fn get_usage(u: &mut Usage) { + u.report_properties.push(usage::ReportProperty { + name: "id", + as_sort_by: Some("Sort by the task's shorthand ID"), + as_column: Some("The task's shorthand ID"), + }); + u.report_properties.push(usage::ReportProperty { + name: "uuid", + as_sort_by: Some("Sort by the task's full UUID"), + as_column: Some("The task's full UUID"), + }); + u.report_properties.push(usage::ReportProperty { + name: "active", + as_sort_by: None, + as_column: Some("`*` if the task is active (started)"), + }); + u.report_properties.push(usage::ReportProperty { + name: "wait", + as_sort_by: Some("Sort by the task's wait date, with non-waiting tasks first"), + as_column: Some("Wait date of the task"), + }); + u.report_properties.push(usage::ReportProperty { + name: "description", + as_sort_by: Some("Sort by the task's description"), + as_column: Some("The task's description"), + }); + u.report_properties.push(usage::ReportProperty { + name: "tags", + as_sort_by: None, + as_column: Some("The task's tags"), + }); +} + #[cfg(test)] mod test { use super::*; diff --git a/cli/src/settings/settings.rs b/cli/src/settings/settings.rs index b17750fcb..95d0e4d2e 100644 --- a/cli/src/settings/settings.rs +++ b/cli/src/settings/settings.rs @@ -13,21 +13,25 @@ use toml_edit::Document; #[derive(Debug, PartialEq)] pub(crate) struct Settings { - // filename from which this configuration was loaded, if any + /// filename from which this configuration was loaded, if any pub(crate) filename: Option, - // replica + /// Maximum number of tasks to modify without a confirmation prompt; `Some(0)` means to never + /// prompt, and `None` means to use the default value. + pub(crate) modification_count_prompt: Option, + + /// replica pub(crate) data_dir: PathBuf, - // remote sync server + /// remote sync server pub(crate) server_client_key: Option, pub(crate) server_origin: Option, pub(crate) encryption_secret: Option, - // local sync server + /// local sync server pub(crate) server_dir: PathBuf, - // reports + /// reports pub(crate) reports: HashMap, } @@ -86,6 +90,7 @@ impl Settings { fn update_from_toml(&mut self, config_toml: &toml::Value) -> Result<()> { let table_keys = [ "data_dir", + "modification_count_prompt", "server_client_key", "server_origin", "encryption_secret", @@ -109,10 +114,24 @@ impl Settings { Ok(()) } + fn get_i64_cfg(table: &Table, name: &'static str, setter: F) -> Result<()> { + if let Some(v) = table.get(name) { + setter( + v.as_integer() + .ok_or_else(|| anyhow!(".{}: not a number", name))?, + ); + } + Ok(()) + } + get_str_cfg(table, "data_dir", |v| { self.data_dir = v.into(); })?; + get_i64_cfg(table, "modification_count_prompt", |v| { + self.modification_count_prompt = Some(v); + })?; + get_str_cfg(table, "server_client_key", |v| { self.server_client_key = Some(v); })?; @@ -142,10 +161,12 @@ impl Settings { Ok(()) } - /// Set a value in the config file, modifying it in place. Returns the filename. + /// Set a value in the config file, modifying it in place. Returns the filename. The value is + /// interpreted as the appropriate type for the configuration setting. pub(crate) fn set(&self, key: &str, value: &str) -> Result { let allowed_keys = [ "data_dir", + "modification_count_prompt", "server_client_key", "server_origin", "encryption_secret", @@ -168,7 +189,17 @@ impl Settings { .parse::() .context("Could not parse existing configuration file")?; - document[key] = toml_edit::value(value); + // set the value as the correct type + match key { + // integers + "modification_count_prompt" => { + let value: i64 = value.parse()?; + document[key] = toml_edit::value(value); + } + + // most keys are strings + _ => document[key] = toml_edit::value(value), + } fs::write(filename.clone(), document.to_string()) .context("Could not write updated configuration file")?; @@ -218,6 +249,10 @@ impl Default for Settings { label: "tags".to_owned(), property: Property::Tags, }, + Column { + label: "wait".to_owned(), + property: Property::Wait, + }, ], filter: Default::default(), }, @@ -263,6 +298,7 @@ impl Default for Settings { Self { filename: None, data_dir, + modification_count_prompt: None, server_client_key: None, server_origin: None, encryption_secret: None, @@ -312,6 +348,7 @@ mod test { fn test_update_from_toml_top_level_keys() { let val = toml! { data_dir = "/data" + modification_count_prompt = 42 server_client_key = "sck" server_origin = "so" encryption_secret = "es" @@ -321,6 +358,7 @@ mod test { settings.update_from_toml(&val).unwrap(); assert_eq!(settings.data_dir, PathBuf::from("/data")); + assert_eq!(settings.modification_count_prompt, Some(42)); assert_eq!(settings.server_client_key, Some("sck".to_owned())); assert_eq!(settings.server_origin, Some("so".to_owned())); assert_eq!(settings.encryption_secret, Some("es".to_owned())); @@ -350,11 +388,26 @@ mod test { let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap(); assert_eq!(settings.filename, Some(cfg_file.clone())); settings.set("data_dir", "/data").unwrap(); + settings.set("modification_count_prompt", "42").unwrap(); - // load the file again and see the change + // load the file again and see the changes let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap(); assert_eq!(settings.data_dir, PathBuf::from("/data")); assert_eq!(settings.server_dir, PathBuf::from("/srv")); assert_eq!(settings.filename, Some(cfg_file)); + assert_eq!(settings.modification_count_prompt, Some(42)); + } + + #[test] + fn test_set_invalid_key() { + let cfg_dir = TempDir::new().unwrap(); + let cfg_file = cfg_dir.path().join("foo.toml"); + fs::write(cfg_file.clone(), "server_dir = \"/srv\"").unwrap(); + + let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap(); + assert_eq!(settings.filename, Some(cfg_file.clone())); + assert!(settings + .set("modification_count_prompt", "a string?") + .is_err()); } } diff --git a/cli/src/usage.rs b/cli/src/usage.rs index f1bacf674..b3f688909 100644 --- a/cli/src/usage.rs +++ b/cli/src/usage.rs @@ -2,26 +2,31 @@ //! a way that puts the source of that documentation near its implementation. use crate::argparse; -use std::io::{Result, Write}; +use crate::settings; +use anyhow::Result; +use std::io::Write; + +#[cfg(feature = "usage-docs")] +use std::fmt::Write as FmtWrite; /// A top-level structure containing usage/help information for the entire CLI. #[derive(Debug, Default)] -pub(crate) struct Usage { +pub struct Usage { pub(crate) subcommands: Vec, pub(crate) filters: Vec, pub(crate) modifications: Vec, + pub(crate) report_properties: Vec, } impl Usage { /// Get a new, completely-filled-out usage object - pub(crate) fn new() -> Self { + pub fn new() -> Self { let mut rv = Self { ..Default::default() }; argparse::get_usage(&mut rv); - - // TODO: sort subcommands + settings::get_usage(&mut rv); rv } @@ -77,6 +82,62 @@ impl Usage { } Ok(()) } + + #[cfg(feature = "usage-docs")] + /// Substitute strings matching + /// + /// ```text + /// + /// ``` + /// + /// With the appropriate documentation. + pub fn substitute_docs(&self, content: &str) -> Result { + // 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 = ""; + + for line in lines { + if line.starts_with(DOC_HEADER_PREFIX) && line.ends_with(DOC_HEADER_SUFFIX) { + let doc_type = &line[DOC_HEADER_PREFIX.len()..line.len() - DOC_HEADER_SUFFIX.len()]; + + match doc_type { + "subcommands" => { + for subcommand in self.subcommands.iter() { + subcommand.write_markdown(&mut w)?; + } + } + "filters" => { + for filter in self.filters.iter() { + filter.write_markdown(&mut w)?; + } + } + "modifications" => { + for modification in self.modifications.iter() { + modification.write_markdown(&mut w)?; + } + } + "report-columns" => { + for prop in self.report_properties.iter() { + prop.write_column_markdown(&mut w)?; + } + } + "report-sort-by" => { + for prop in self.report_properties.iter() { + prop.write_sort_by_markdown(&mut w)?; + } + } + _ => anyhow::bail!("Unkonwn doc type {}", doc_type), + } + } else { + writeln!(w, "{}", line)?; + } + } + + Ok(w) + } } /// wrap an indented string @@ -122,6 +183,15 @@ impl Subcommand { } Ok(()) } + + #[cfg(feature = "usage-docs")] + fn write_markdown(&self, mut w: W) -> Result<()> { + writeln!(w, "### `ta {}` - {}", self.name, self.summary)?; + writeln!(w, "```shell\nta {}\n```", self.syntax)?; + writeln!(w, "{}", indented(self.description, ""))?; + writeln!(w)?; + Ok(()) + } } /// Usage documentation for a filter argument @@ -152,6 +222,15 @@ impl Filter { } Ok(()) } + + #[cfg(feature = "usage-docs")] + fn write_markdown(&self, mut w: W) -> Result<()> { + writeln!(w, "* `{}` - {}", self.syntax, self.summary)?; + writeln!(w)?; + writeln!(w, "{}", indented(self.description, " "))?; + writeln!(w)?; + Ok(()) + } } /// Usage documentation for a modification argument @@ -182,4 +261,51 @@ impl Modification { } Ok(()) } + + #[cfg(feature = "usage-docs")] + fn write_markdown(&self, mut w: W) -> Result<()> { + writeln!(w, "* `{}` - {}", self.syntax, self.summary)?; + writeln!(w)?; + writeln!(w, "{}", indented(self.description, " "))?; + writeln!(w)?; + Ok(()) + } +} + +/// Usage documentation for a report property (which may be used for sorting, as a column, or +/// both). +#[derive(Debug, Default)] +pub(crate) struct ReportProperty { + /// Name of the property + pub(crate) name: &'static str, + + /// Usage description for sorting, if any + pub(crate) as_sort_by: Option<&'static str>, + + /// Usage description as a column, if any + pub(crate) as_column: Option<&'static str>, +} + +impl ReportProperty { + #[cfg(feature = "usage-docs")] + fn write_sort_by_markdown(&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(&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(()) + } } diff --git a/cli/tests/cli.rs b/cli/tests/cli.rs index 7eabe2c77..6325a6d3e 100644 --- a/cli/tests/cli.rs +++ b/cli/tests/cli.rs @@ -56,7 +56,8 @@ fn invalid_option() -> Result<(), Box> { cmd.arg("--no-such-option"); cmd.assert() .failure() - .stderr(predicate::str::contains("command line not recognized")); + .stderr(predicate::str::contains("command line not recognized")) + .code(predicate::eq(3)); Ok(()) } diff --git a/docs/README.md b/docs/README.md index 7aaa35c16..586cf2663 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,3 +1,10 @@ This is an [mdbook](https://rust-lang.github.io/mdBook/index.html) book. Minor modifications can be made without installing the mdbook tool, as the content is simple Markdown. Changes are verified on pull requests. + +To build the docs locally, you will need to build `usage-docs`: + +``` +cargo build -p taskchampion-cli --feature usage-docs --bin usage-docs +mdbook build docs/ +``` diff --git a/docs/book.toml b/docs/book.toml index 7e2fa9820..3ab678ad0 100644 --- a/docs/book.toml +++ b/docs/book.toml @@ -7,3 +7,6 @@ title = "TaskChampion" [output.html] default-theme = "ayu" + +[preprocessor.usage-docs] +command = "target/debug/usage-docs" diff --git a/docs/build.sh b/docs/build.sh deleted file mode 100755 index 06dc662c7..000000000 --- a/docs/build.sh +++ /dev/null @@ -1,23 +0,0 @@ -#! /bin/bash - -REMOTE=origin - -set -e - -if ! [ -f "./src/SUMMARY.md" ]; then - echo "Run this from the docs/ dir" - exit 1 -fi - -if ! [ -d ./tmp ]; then - git worktree add tmp gh-pages -fi - -(cd tmp && git pull $REMOTE gh-pages) - -rm -rf tmp/* -mdbook build -cp -rp book/* tmp -(cd tmp && git add -A) -(cd tmp && git commit -am "update docs") -(cd tmp && git push $REMOTE gh-pages:gh-pages) diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index f69280b27..568eb57cf 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -3,9 +3,12 @@ - [Welcome to TaskChampion](./welcome.md) * [Installation](./installation.md) * [Using the Task Command](./using-task-command.md) - * [Configuration](./config-file.md) * [Reports](./reports.md) * [Tags](./tags.md) + * [Filters](./filters.md) + * [Modifications](./modifications.md) + * [Dates and Durations](./time.md) + * [Configuration](./config-file.md) * [Environment](./environment.md) * [Synchronization](./task-sync.md) * [Running the Sync Server](./running-sync-server.md) diff --git a/docs/src/config-file.md b/docs/src/config-file.md index 6968e6e51..03639546f 100644 --- a/docs/src/config-file.md +++ b/docs/src/config-file.md @@ -20,6 +20,12 @@ data_dir = "/home/myuser/.tasks" * `data_dir` - path to a directory containing the replica's task data (which will be created if necessary). Default: `taskchampion` in the local data directory. +## Command-Line Preferences + +* `modification_count_prompt` - when a modification will affect more than this many tasks, the `ta` command will prompt for confirmation. + A value of `0` will disable the prompts entirely. + Default: 3. + ## Sync Server If using a local server: diff --git a/docs/src/filters.md b/docs/src/filters.md new file mode 100644 index 000000000..e2be669d7 --- /dev/null +++ b/docs/src/filters.md @@ -0,0 +1,9 @@ +# Filters + +Filters are used to select specific tasks for reports or to specify tasks to be modified. +When more than one filter is given, only tasks which match all of the filters are selected. +When no filter is given, the command implicitly selects all tasks. + +Filters can have the following forms: + + diff --git a/docs/src/modifications.md b/docs/src/modifications.md new file mode 100644 index 000000000..017969951 --- /dev/null +++ b/docs/src/modifications.md @@ -0,0 +1,5 @@ +# Modifications + +Modifications can have the following forms: + + diff --git a/docs/src/reports.md b/docs/src/reports.md index 05026da6f..4f106e35b 100644 --- a/docs/src/reports.md +++ b/docs/src/reports.md @@ -49,9 +49,8 @@ columns = [ ] ``` -The filter is a list of filter arguments, just like those that can be used on the command line. -See the `ta help` output for more details on this syntax. -It will be merged with any filters provided on the command line, when the report is invoked. +The `filter` property is a list of [filters](./filters.md). +It will be merged with any filters provided on the command line when the report is invoked. The sort order is defined by an array of tables containing a `sort_by` property and an optional `ascending` property. Tasks are compared by the first criterion, and if that is equal by the second, and so on. @@ -70,11 +69,11 @@ sort = [ The available values of `sort_by` are -(TODO: generate automatically) + Finally, the `columns` configuration specifies the list of columns to display. Each element has a `label` and a `property`, as shown in the example above. The avaliable properties are: -(TODO: generate automatically) + diff --git a/docs/src/tags.md b/docs/src/tags.md index 7c159c644..cf9d80102 100644 --- a/docs/src/tags.md +++ b/docs/src/tags.md @@ -10,3 +10,17 @@ For example, when it's time to continue the job search, `ta +jobsearch` will sho Specifically, tags must be at least one character long and cannot contain whitespace or any of the characters `+-*/(<>^! %=~`. The first character cannot be a digit, and `:` is not allowed after the first character. +All-capital tags are reserved for synthetic tags (below) and cannot be added or removed from tasks. + +## Synthetic Tags + +Synthetic tags are present on tasks that meet specific criteria, that are commonly used for filtering. +For example, `WAITING` is set for tasks that are currently waiting. +These tags cannot be added or removed from a task, but appear and disappear as the task changes. +The following synthetic tags are defined: + +* `WAITING` - set if the task is waiting (has a `wait` property with a date in the future) +* `ACTIVE` - set if the task is active (has been started and not stopped) +* `PENDING` - set if the task is pending (not completed or deleted) +* `COMPLETED` - set if the task has been completed +* `DELETED` - set if the task has been deleted (but not yet flushed from the task list) diff --git a/docs/src/tasks.md b/docs/src/tasks.md index 8bdf8fa72..ae354c62c 100644 --- a/docs/src/tasks.md +++ b/docs/src/tasks.md @@ -32,6 +32,7 @@ The following keys, and key formats, are defined: * `modified` - the time of the last modification of this task * `start.` - either an empty string (representing work on the task to the task that has not been stopped) or a timestamp (representing the time that work stopped) * `tag.` - indicates this task has tag `` (value is an empty string) +* `wait` - indicates the time before which this task should be hidden, as it is not actionable The following are not yet implemented: diff --git a/docs/src/time.md b/docs/src/time.md new file mode 100644 index 000000000..b82e1b2e1 --- /dev/null +++ b/docs/src/time.md @@ -0,0 +1,30 @@ +## Timestamps + +Times may be specified in a wide variety of convenient formats. + + * [RFC3339](https://datatracker.ietf.org/doc/html/rfc3339) timestamps, such as `2019-10-12 07:20:50.12Z` + * A date of the format `YYYY-MM-DD` is interpreted as the _local_ midnight at the beginning of the given date. + Single-digit month and day are accepted, but the year must contain four digits. + * `now` refers to the exact current time + * `yesterday`, `today`, and `tomorrow` refer to the _local_ midnight at the beginning of the given day + * Any duration (described below) may be used as a timestamp, and is considered relative to the current time. + +Times are stored internally as UTC. + +## Durations + +Durations can be given in a dizzying array of units. +Each can be preceded by a whole number or a decimal multiplier, e.g., `3days`. +The multiplier is optional with the singular forms of the units; for example `day` is allowed. +Some of the units allow an adjectival form, such as `daily` or `annually`; this form is more readable in some cases, but otherwise has the same meaning. + + * `s`, `second`, or `seconds` + * `min`, `mins`, `minute`, or `minutes` (note that `m` not allowed, as it might also mean `month`) + * `h`, `hour`, or `hours` + * `d`, `day`, or `days` + * `w`, `week`, or `weeks` + * `mo`, or `months` (always 30 days, regardless of calendar month) + * `y`, `year`, or `years` (365 days, regardless of leap days) + +[ISO 8601 standard durations](https://en.wikipedia.org/wiki/ISO_8601#Durations) are also allowed. +While the standard does not specify the length of "P1Y" or "P1M", Taskchampion treats those as 365 and 30 days, respectively. diff --git a/docs/src/using-task-command.md b/docs/src/using-task-command.md index d2e7f0ca0..355dcc898 100644 --- a/docs/src/using-task-command.md +++ b/docs/src/using-task-command.md @@ -4,6 +4,13 @@ The main interface to your tasks is the `ta` command, which supports various sub Customizable [reports](./reports.md) are also available as subcommands, such as `next`. The command reads a [configuration file](./config-file.md) for its settings, including where to find the task database. And the `sync` subcommand [synchronizes tasks with a sync server](./task-sync.md). -You can find a list of all subcommands, as well as the built-in reports, with `ta help`. > NOTE: the `task` interface does not precisely match that of TaskWarrior. + +## Subcommands + +The sections below describe each subcommand of the `ta` command. +The syntax of `[filter]` is defined in [filters](./filters.md), and that of `[modification]` in [modifications](./modifications.md). +You can also find a summary of all subcommands, as well as filters, built-in reports, and so on, with `ta help`. + + diff --git a/taskchampion/Cargo.toml b/taskchampion/Cargo.toml index 2231395fa..fe51874c9 100644 --- a/taskchampion/Cargo.toml +++ b/taskchampion/Cargo.toml @@ -21,7 +21,10 @@ ureq = "^2.1.0" log = "^0.4.14" tindercrypt = { version = "^0.2.2", default-features = false } rusqlite = { version = "0.25", features = ["bundled"] } +strum = "0.21" +strum_macros = "0.21" [dev-dependencies] proptest = "^1.0.0" tempfile = "3" +rstest = "0.10" diff --git a/taskchampion/src/errors.rs b/taskchampion/src/errors.rs index 9e2a712a5..44bad9881 100644 --- a/taskchampion/src/errors.rs +++ b/taskchampion/src/errors.rs @@ -1,6 +1,9 @@ use thiserror::Error; + #[derive(Debug, Error, Eq, PartialEq, Clone)] +#[non_exhaustive] +/// Errors returned from taskchampion operations pub enum Error { - #[error("Task Database Error: {}", _0)] - DbError(String), + #[error("Task Database Error: {0}")] + Database(String), } diff --git a/taskchampion/src/lib.rs b/taskchampion/src/lib.rs index da61235d9..665fe3a64 100644 --- a/taskchampion/src/lib.rs +++ b/taskchampion/src/lib.rs @@ -29,6 +29,10 @@ Users can define their own server impelementations. See the [TaskChampion Book](http://taskchampion.github.com/taskchampion) for more information about the design and usage of the tool. +# Minimum Supported Rust Version + +This crate supports Rust version 1.47 and higher. + */ mod errors; @@ -40,6 +44,7 @@ mod taskdb; mod utils; mod workingset; +pub use errors::Error; pub use replica::Replica; pub use server::{Server, ServerConfig}; pub use storage::StorageConfig; diff --git a/taskchampion/src/replica.rs b/taskchampion/src/replica.rs index 1e0a47382..361476951 100644 --- a/taskchampion/src/replica.rs +++ b/taskchampion/src/replica.rs @@ -113,7 +113,7 @@ impl Replica { // check that it already exists; this is a convenience check, as the task may already exist // when this Create operation is finally sync'd with operations from other replicas if self.taskdb.get_task(uuid)?.is_none() { - return Err(Error::DbError(format!("Task {} does not exist", uuid)).into()); + return Err(Error::Database(format!("Task {} does not exist", uuid)).into()); } self.taskdb.apply(Operation::Delete { uuid })?; trace!("task {} deleted", uuid); diff --git a/taskchampion/src/task/annotation.rs b/taskchampion/src/task/annotation.rs new file mode 100644 index 000000000..dadb72b0f --- /dev/null +++ b/taskchampion/src/task/annotation.rs @@ -0,0 +1,10 @@ +use super::Timestamp; + +/// An annotation for a task +#[derive(Debug, PartialEq)] +pub struct Annotation { + /// Time the annotation was made + pub entry: Timestamp, + /// Content of the annotation + pub description: String, +} diff --git a/taskchampion/src/task/mod.rs b/taskchampion/src/task/mod.rs new file mode 100644 index 000000000..ecb755c29 --- /dev/null +++ b/taskchampion/src/task/mod.rs @@ -0,0 +1,16 @@ +#![allow(clippy::module_inception)] +use chrono::prelude::*; + +mod annotation; +mod priority; +mod status; +mod tag; +mod task; + +pub use annotation::Annotation; +pub use priority::Priority; +pub use status::Status; +pub use tag::{Tag, INVALID_TAG_CHARACTERS}; +pub use task::{Task, TaskMut}; + +pub type Timestamp = DateTime; diff --git a/taskchampion/src/task/priority.rs b/taskchampion/src/task/priority.rs new file mode 100644 index 000000000..5e3f29d0d --- /dev/null +++ b/taskchampion/src/task/priority.rs @@ -0,0 +1,48 @@ +/// The priority of a task +#[derive(Debug, PartialEq)] +pub enum Priority { + /// Low + L, + /// Medium + M, + /// High + H, +} + +#[allow(dead_code)] +impl Priority { + /// Get a Priority from the 1-character value in a TaskMap, + /// defaulting to M + pub(crate) fn from_taskmap(s: &str) -> Priority { + match s { + "L" => Priority::L, + "M" => Priority::M, + "H" => Priority::H, + _ => Priority::M, + } + } + + /// Get the 1-character value for this priority to use in the TaskMap. + pub(crate) fn to_taskmap(&self) -> &str { + match self { + Priority::L => "L", + Priority::M => "M", + Priority::H => "H", + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_priority() { + assert_eq!(Priority::L.to_taskmap(), "L"); + assert_eq!(Priority::M.to_taskmap(), "M"); + assert_eq!(Priority::H.to_taskmap(), "H"); + assert_eq!(Priority::from_taskmap("L"), Priority::L); + assert_eq!(Priority::from_taskmap("M"), Priority::M); + assert_eq!(Priority::from_taskmap("H"), Priority::H); + } +} diff --git a/taskchampion/src/task/status.rs b/taskchampion/src/task/status.rs new file mode 100644 index 000000000..f9f9fe773 --- /dev/null +++ b/taskchampion/src/task/status.rs @@ -0,0 +1,54 @@ +/// The status of a task. The default status in "Pending". +#[derive(Debug, PartialEq, Clone)] +pub enum Status { + Pending, + Completed, + Deleted, +} + +impl Status { + /// Get a Status from the 1-character value in a TaskMap, + /// defaulting to Pending + pub(crate) fn from_taskmap(s: &str) -> Status { + match s { + "P" => Status::Pending, + "C" => Status::Completed, + "D" => Status::Deleted, + _ => Status::Pending, + } + } + + /// Get the 1-character value for this status to use in the TaskMap. + pub(crate) fn to_taskmap(&self) -> &str { + match self { + Status::Pending => "P", + Status::Completed => "C", + Status::Deleted => "D", + } + } + + /// Get the full-name value for this status to use in the TaskMap. + pub fn to_string(&self) -> &str { + // TODO: should be impl Display + match self { + Status::Pending => "Pending", + Status::Completed => "Completed", + Status::Deleted => "Deleted", + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn test_status() { + assert_eq!(Status::Pending.to_taskmap(), "P"); + assert_eq!(Status::Completed.to_taskmap(), "C"); + assert_eq!(Status::Deleted.to_taskmap(), "D"); + assert_eq!(Status::from_taskmap("P"), Status::Pending); + assert_eq!(Status::from_taskmap("C"), Status::Completed); + assert_eq!(Status::from_taskmap("D"), Status::Deleted); + } +} diff --git a/taskchampion/src/task/tag.rs b/taskchampion/src/task/tag.rs new file mode 100644 index 000000000..d3a4842e7 --- /dev/null +++ b/taskchampion/src/task/tag.rs @@ -0,0 +1,169 @@ +use std::convert::TryFrom; +use std::fmt; +use std::str::FromStr; + +/// A Tag is a descriptor for a task, that is either present or absent, and can be used for +/// filtering. Tags composed of all uppercase letters are reserved for synthetic tags. +/// +/// Valid tags must not contain whitespace or any of the characters in [`INVALID_TAG_CHARACTERS`]. +/// The first characters additionally cannot be a digit, and subsequent characters cannot be `:`. +/// This definition is based on [that of +/// TaskWarrior](https://github.com/GothenburgBitFactory/taskwarrior/blob/663c6575ceca5bd0135ae884879339dac89d3142/src/Lexer.cpp#L146-L164). +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] +pub struct Tag(TagInner); + +/// Inner type to hide the implementation +#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug)] +pub(super) enum TagInner { + User(String), + Synthetic(SyntheticTag), +} + +pub const INVALID_TAG_CHARACTERS: &str = "+-*/(<>^! %=~"; + +impl Tag { + /// True if this tag is a synthetic tag + pub fn is_synthetic(&self) -> bool { + matches!(self.0, TagInner::Synthetic(_)) + } + + /// True if this tag is a user-provided tag (not synthetic) + pub fn is_user(&self) -> bool { + matches!(self.0, TagInner::User(_)) + } + + pub(super) fn inner(&self) -> &TagInner { + &self.0 + } + + pub(super) fn from_inner(inner: TagInner) -> Self { + Self(inner) + } +} + +impl FromStr for Tag { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + fn err(value: &str) -> Result { + anyhow::bail!("invalid tag {:?}", value) + } + + // first, look for synthetic tags + if value.chars().all(|c| c.is_ascii_uppercase()) { + if let Ok(st) = SyntheticTag::from_str(value) { + return Ok(Self(TagInner::Synthetic(st))); + } + // all uppercase, but not a valid synthetic tag + return err(value); + } + + if let Some(c) = value.chars().next() { + if c.is_whitespace() || c.is_ascii_digit() || INVALID_TAG_CHARACTERS.contains(c) { + return err(value); + } + } else { + return err(value); + } + if !value + .chars() + .skip(1) + .all(|c| !(c.is_whitespace() || c == ':' || INVALID_TAG_CHARACTERS.contains(c))) + { + return err(value); + } + Ok(Self(TagInner::User(String::from(value)))) + } +} + +impl TryFrom<&str> for Tag { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + Self::from_str(value) + } +} + +impl TryFrom<&String> for Tag { + type Error = anyhow::Error; + + fn try_from(value: &String) -> Result { + Self::from_str(&value[..]) + } +} + +impl fmt::Display for Tag { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match &self.0 { + TagInner::User(s) => s.fmt(f), + TagInner::Synthetic(st) => st.as_ref().fmt(f), + } + } +} + +impl AsRef for Tag { + fn as_ref(&self) -> &str { + match &self.0 { + TagInner::User(s) => s.as_ref(), + TagInner::Synthetic(st) => st.as_ref(), + } + } +} + +/// A synthetic tag, represented as an `enum`. This type is used directly by +/// [`taskchampion::task::task`] for efficiency. +#[derive( + Debug, + Clone, + Eq, + PartialEq, + Ord, + PartialOrd, + Hash, + strum_macros::EnumString, + strum_macros::AsRefStr, + strum_macros::EnumIter, +)] +#[strum(serialize_all = "SCREAMING_SNAKE_CASE")] +pub(super) enum SyntheticTag { + // When adding items here, also implement and test them in `task.rs` and document them in + // `docs/src/tags.md`. + Waiting, + Active, + Pending, + Completed, + Deleted, +} + +#[cfg(test)] +mod test { + use super::*; + use rstest::rstest; + use std::convert::TryInto; + + #[rstest] + #[case::simple("abc")] + #[case::colon_prefix(":abc")] + #[case::letters_and_numbers("a123_456")] + #[case::synthetic("WAITING")] + fn test_tag_try_into_success(#[case] s: &'static str) { + let tag: Tag = s.try_into().unwrap(); + // check Display (via to_string) and AsRef while we're here + assert_eq!(tag.to_string(), s.to_owned()); + assert_eq!(tag.as_ref(), s); + } + + #[rstest] + #[case::empty("")] + #[case::colon_infix("a:b")] + #[case::digits("999")] + #[case::bangs("abc!!!")] + #[case::no_such_synthetic("NOSUCH")] + fn test_tag_try_into_err(#[case] s: &'static str) { + let tag: Result = s.try_into(); + assert_eq!( + tag.unwrap_err().to_string(), + format!("invalid tag \"{}\"", s) + ); + } +} diff --git a/taskchampion/src/task.rs b/taskchampion/src/task/task.rs similarity index 67% rename from taskchampion/src/task.rs rename to taskchampion/src/task/task.rs index 07bb19909..40b2d6b0f 100644 --- a/taskchampion/src/task.rs +++ b/taskchampion/src/task/task.rs @@ -1,156 +1,13 @@ +use super::tag::{SyntheticTag, TagInner}; +use super::{Status, Tag}; use crate::replica::Replica; use crate::storage::TaskMap; use chrono::prelude::*; use log::trace; -use std::convert::{TryFrom, TryInto}; -use std::fmt; +use std::convert::AsRef; +use std::convert::TryInto; use uuid::Uuid; -pub type Timestamp = DateTime; - -/// The priority of a task -#[derive(Debug, PartialEq)] -pub enum Priority { - /// Low - L, - /// Medium - M, - /// High - H, -} - -#[allow(dead_code)] -impl Priority { - /// Get a Priority from the 1-character value in a TaskMap, - /// defaulting to M - pub(crate) fn from_taskmap(s: &str) -> Priority { - match s { - "L" => Priority::L, - "M" => Priority::M, - "H" => Priority::H, - _ => Priority::M, - } - } - - /// Get the 1-character value for this priority to use in the TaskMap. - pub(crate) fn to_taskmap(&self) -> &str { - match self { - Priority::L => "L", - Priority::M => "M", - Priority::H => "H", - } - } -} - -/// The status of a task. The default status in "Pending". -#[derive(Debug, PartialEq, Clone)] -pub enum Status { - Pending, - Completed, - Deleted, -} - -impl Status { - /// Get a Status from the 1-character value in a TaskMap, - /// defaulting to Pending - pub(crate) fn from_taskmap(s: &str) -> Status { - match s { - "P" => Status::Pending, - "C" => Status::Completed, - "D" => Status::Deleted, - _ => Status::Pending, - } - } - - /// Get the 1-character value for this status to use in the TaskMap. - pub(crate) fn to_taskmap(&self) -> &str { - match self { - Status::Pending => "P", - Status::Completed => "C", - Status::Deleted => "D", - } - } - - /// Get the full-name value for this status to use in the TaskMap. - pub fn to_string(&self) -> &str { - // TODO: should be impl Display - match self { - Status::Pending => "Pending", - Status::Completed => "Completed", - Status::Deleted => "Deleted", - } - } -} - -/// A Tag is a newtype around a String that limits its values to valid tags. -/// -/// Valid tags must not contain whitespace or any of the characters in [`INVALID_TAG_CHARACTERS`]. -/// The first characters additionally cannot be a digit, and subsequent characters cannot be `:`. -/// This definition is based on [that of -/// TaskWarrior](https://github.com/GothenburgBitFactory/taskwarrior/blob/663c6575ceca5bd0135ae884879339dac89d3142/src/Lexer.cpp#L146-L164). -#[derive(Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] -pub struct Tag(String); - -pub const INVALID_TAG_CHARACTERS: &str = "+-*/(<>^! %=~"; - -impl Tag { - fn from_str(value: &str) -> Result { - fn err(value: &str) -> Result { - anyhow::bail!("invalid tag {:?}", value) - } - - if let Some(c) = value.chars().next() { - if c.is_whitespace() || c.is_ascii_digit() || INVALID_TAG_CHARACTERS.contains(c) { - return err(value); - } - } else { - return err(value); - } - if !value - .chars() - .skip(1) - .all(|c| !(c.is_whitespace() || c == ':' || INVALID_TAG_CHARACTERS.contains(c))) - { - return err(value); - } - Ok(Self(String::from(value))) - } -} - -impl TryFrom<&str> for Tag { - type Error = anyhow::Error; - - fn try_from(value: &str) -> Result { - Self::from_str(value) - } -} - -impl TryFrom<&String> for Tag { - type Error = anyhow::Error; - - fn try_from(value: &String) -> Result { - Self::from_str(&value[..]) - } -} - -impl fmt::Display for Tag { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - self.0.fmt(f) - } -} - -impl AsRef for Tag { - fn as_ref(&self) -> &str { - self.0.as_ref() - } -} - -#[derive(Debug, PartialEq)] -pub struct Annotation { - pub entry: Timestamp, - pub description: String, -} - /// A task, as publicly exposed by this crate. /// /// Note that Task objects represent a snapshot of the task at a moment in time, and are not @@ -211,6 +68,20 @@ impl Task { .unwrap_or("") } + /// Get the wait time. If this value is set, it will be returned, even + /// if it is in the past. + pub fn get_wait(&self) -> Option> { + self.get_timestamp("wait") + } + + /// Determine whether this task is waiting now. + pub fn is_waiting(&self) -> bool { + if let Some(ts) = self.get_wait() { + return ts > Utc::now(); + } + false + } + /// Determine whether this task is active -- that is, that it has been started /// and not stopped. pub fn is_active(&self) -> bool { @@ -219,22 +90,46 @@ impl Task { .any(|(k, v)| k.starts_with("start.") && v.is_empty()) } + /// Determine whether a given synthetic tag is present on this task. All other + /// synthetic tag calculations are based on this one. + fn has_synthetic_tag(&self, synth: &SyntheticTag) -> bool { + match synth { + SyntheticTag::Waiting => self.is_waiting(), + SyntheticTag::Active => self.is_active(), + SyntheticTag::Pending => self.get_status() == Status::Pending, + SyntheticTag::Completed => self.get_status() == Status::Completed, + SyntheticTag::Deleted => self.get_status() == Status::Deleted, + } + } + /// Check if this task has the given tag pub fn has_tag(&self, tag: &Tag) -> bool { - self.taskmap.contains_key(&format!("tag.{}", tag)) + match tag.inner() { + TagInner::User(s) => self.taskmap.contains_key(&format!("tag.{}", s)), + TagInner::Synthetic(st) => self.has_synthetic_tag(st), + } } /// Iterate over the task's tags pub fn get_tags(&self) -> impl Iterator + '_ { - self.taskmap.iter().filter_map(|(k, _)| { - if let Some(tag) = k.strip_prefix("tag.") { - if let Ok(tag) = tag.try_into() { - return Some(tag); + use strum::IntoEnumIterator; + + self.taskmap + .iter() + .filter_map(|(k, _)| { + if let Some(tag) = k.strip_prefix("tag.") { + if let Ok(tag) = tag.try_into() { + return Some(tag); + } + // note that invalid "tag.*" are ignored } - // note that invalid "tag.*" are ignored - } - None - }) + None + }) + .chain( + SyntheticTag::iter() + .filter(move |st| self.has_synthetic_tag(st)) + .map(|st| Tag::from_inner(TagInner::Synthetic(st))), + ) } pub fn get_modified(&self) -> Option> { @@ -275,6 +170,10 @@ impl<'r> TaskMut<'r> { self.set_string("description", Some(description)) } + pub fn set_wait(&mut self, wait: Option>) -> anyhow::Result<()> { + self.set_timestamp("wait", wait) + } + pub fn set_modified(&mut self, modified: DateTime) -> anyhow::Result<()> { self.set_timestamp("modified", Some(modified)) } @@ -306,13 +205,24 @@ impl<'r> TaskMut<'r> { Ok(()) } + /// Mark this task as complete + pub fn done(&mut self) -> anyhow::Result<()> { + self.set_status(Status::Completed) + } + /// Add a tag to this task. Does nothing if the tag is already present. pub fn add_tag(&mut self, tag: &Tag) -> anyhow::Result<()> { + if tag.is_synthetic() { + anyhow::bail!("Synthetic tags cannot be modified"); + } self.set_string(format!("tag.{}", tag), Some("".to_owned())) } /// Remove a tag from this task. Does nothing if the tag is not present. pub fn remove_tag(&mut self, tag: &Tag) -> anyhow::Result<()> { + if tag.is_synthetic() { + anyhow::bail!("Synthetic tags cannot be modified"); + } self.set_string(format!("tag.{}", tag), None) } @@ -398,28 +308,14 @@ mod test { f(task) } - #[test] - fn test_tag_from_str() { - let tag: Tag = "abc".try_into().unwrap(); - assert_eq!(tag, Tag("abc".to_owned())); + /// Create a user tag, without checking its validity + fn utag(name: &'static str) -> Tag { + Tag::from_inner(TagInner::User(name.into())) + } - let tag: Tag = ":abc".try_into().unwrap(); - assert_eq!(tag, Tag(":abc".to_owned())); - - let tag: Tag = "a123_456".try_into().unwrap(); - assert_eq!(tag, Tag("a123_456".to_owned())); - - let tag: Result = "".try_into(); - assert_eq!(tag.unwrap_err().to_string(), "invalid tag \"\""); - - let tag: Result = "a:b".try_into(); - assert_eq!(tag.unwrap_err().to_string(), "invalid tag \"a:b\""); - - let tag: Result = "999".try_into(); - assert_eq!(tag.unwrap_err().to_string(), "invalid tag \"999\""); - - let tag: Result = "abc!!".try_into(); - assert_eq!(tag.unwrap_err().to_string(), "invalid tag \"abc!!\""); + /// Create a synthetic tag + fn stag(synth: SyntheticTag) -> Tag { + Tag::from_inner(TagInner::Synthetic(synth)) } #[test] @@ -453,16 +349,59 @@ mod test { } #[test] - fn test_has_tag() { + fn test_wait_not_set() { + let task = Task::new(Uuid::new_v4(), TaskMap::new()); + + assert!(!task.is_waiting()); + assert_eq!(task.get_wait(), None); + } + + #[test] + fn test_wait_in_past() { + let ts = Utc.ymd(1970, 1, 1).and_hms(0, 0, 0); let task = Task::new( Uuid::new_v4(), - vec![(String::from("tag.abc"), String::from(""))] + vec![(String::from("wait"), format!("{}", ts.timestamp()))] + .drain(..) + .collect(), + ); + dbg!(&task); + + assert!(!task.is_waiting()); + assert_eq!(task.get_wait(), Some(ts)); + } + + #[test] + fn test_wait_in_future() { + let ts = Utc.ymd(3000, 1, 1).and_hms(0, 0, 0); + let task = Task::new( + Uuid::new_v4(), + vec![(String::from("wait"), format!("{}", ts.timestamp()))] .drain(..) .collect(), ); - assert!(task.has_tag(&"abc".try_into().unwrap())); - assert!(!task.has_tag(&"def".try_into().unwrap())); + assert!(task.is_waiting()); + assert_eq!(task.get_wait(), Some(ts)); + } + + #[test] + fn test_has_tag() { + let task = Task::new( + Uuid::new_v4(), + vec![ + (String::from("tag.abc"), String::from("")), + (String::from("start.1234"), String::from("")), + ] + .drain(..) + .collect(), + ); + + assert!(task.has_tag(&utag("abc"))); + assert!(!task.has_tag(&utag("def"))); + assert!(task.has_tag(&stag(SyntheticTag::Active))); + assert!(task.has_tag(&stag(SyntheticTag::Pending))); + assert!(!task.has_tag(&stag(SyntheticTag::Waiting))); } #[test] @@ -472,6 +411,8 @@ mod test { vec![ (String::from("tag.abc"), String::from("")), (String::from("tag.def"), String::from("")), + // set `wait` so the synthetic tag WAITING is present + (String::from("wait"), String::from("33158909732")), ] .drain(..) .collect(), @@ -479,7 +420,14 @@ mod test { let mut tags: Vec<_> = task.get_tags().collect(); tags.sort(); - assert_eq!(tags, vec![Tag("abc".to_owned()), Tag("def".to_owned())]); + let mut exp = vec![ + utag("abc"), + utag("def"), + stag(SyntheticTag::Pending), + stag(SyntheticTag::Waiting), + ]; + exp.sort(); + assert_eq!(tags, exp); } #[test] @@ -498,7 +446,7 @@ mod test { // only "ok" is OK let tags: Vec<_> = task.get_tags().collect(); - assert_eq!(tags, vec![Tag("ok".to_owned())]); + assert_eq!(tags, vec![utag("ok"), stag(SyntheticTag::Pending)]); } fn count_taskmap(task: &TaskMut, f: fn(&(&String, &String)) -> bool) -> usize { @@ -558,6 +506,20 @@ mod test { }); } + #[test] + fn test_done() { + with_mut_task(|mut task| { + task.done().unwrap(); + assert_eq!(task.get_status(), Status::Completed); + assert!(task.has_tag(&stag(SyntheticTag::Completed))); + + // redundant call does nothing.. + task.done().unwrap(); + assert_eq!(task.get_status(), Status::Completed); + assert!(task.has_tag(&stag(SyntheticTag::Completed))); + }); + } + #[test] fn test_stop_multiple() { with_mut_task(|mut task| { @@ -593,12 +555,12 @@ mod test { #[test] fn test_add_tags() { with_mut_task(|mut task| { - task.add_tag(&Tag("abc".to_owned())).unwrap(); + task.add_tag(&utag("abc")).unwrap(); assert!(task.taskmap.contains_key("tag.abc")); task.reload().unwrap(); assert!(task.taskmap.contains_key("tag.abc")); // redundant add has no effect.. - task.add_tag(&Tag("abc".to_owned())).unwrap(); + task.add_tag(&utag("abc")).unwrap(); assert!(task.taskmap.contains_key("tag.abc")); }); } @@ -606,35 +568,15 @@ mod test { #[test] fn test_remove_tags() { with_mut_task(|mut task| { - task.add_tag(&Tag("abc".to_owned())).unwrap(); + task.add_tag(&utag("abc")).unwrap(); task.reload().unwrap(); assert!(task.taskmap.contains_key("tag.abc")); - task.remove_tag(&Tag("abc".to_owned())).unwrap(); + task.remove_tag(&utag("abc")).unwrap(); assert!(!task.taskmap.contains_key("tag.abc")); // redundant remove has no effect.. - task.remove_tag(&Tag("abc".to_owned())).unwrap(); + task.remove_tag(&utag("abc")).unwrap(); assert!(!task.taskmap.contains_key("tag.abc")); }); } - - #[test] - fn test_priority() { - assert_eq!(Priority::L.to_taskmap(), "L"); - assert_eq!(Priority::M.to_taskmap(), "M"); - assert_eq!(Priority::H.to_taskmap(), "H"); - assert_eq!(Priority::from_taskmap("L"), Priority::L); - assert_eq!(Priority::from_taskmap("M"), Priority::M); - assert_eq!(Priority::from_taskmap("H"), Priority::H); - } - - #[test] - fn test_status() { - assert_eq!(Status::Pending.to_taskmap(), "P"); - assert_eq!(Status::Completed.to_taskmap(), "C"); - assert_eq!(Status::Deleted.to_taskmap(), "D"); - assert_eq!(Status::from_taskmap("P"), Status::Pending); - assert_eq!(Status::from_taskmap("C"), Status::Completed); - assert_eq!(Status::from_taskmap("D"), Status::Deleted); - } } diff --git a/taskchampion/src/taskdb.rs b/taskchampion/src/taskdb.rs index ef5fb6c8d..b373bc582 100644 --- a/taskchampion/src/taskdb.rs +++ b/taskchampion/src/taskdb.rs @@ -49,12 +49,12 @@ impl TaskDb { Operation::Create { uuid } => { // insert if the task does not already exist if !txn.create_task(*uuid)? { - return Err(Error::DbError(format!("Task {} already exists", uuid)).into()); + return Err(Error::Database(format!("Task {} already exists", uuid)).into()); } } Operation::Delete { ref uuid } => { if !txn.delete_task(*uuid)? { - return Err(Error::DbError(format!("Task {} does not exist", uuid)).into()); + return Err(Error::Database(format!("Task {} does not exist", uuid)).into()); } } Operation::Update { @@ -71,7 +71,7 @@ impl TaskDb { }; txn.set_task(*uuid, task)?; } else { - return Err(Error::DbError(format!("Task {} does not exist", uuid)).into()); + return Err(Error::Database(format!("Task {} does not exist", uuid)).into()); } } }