diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..b8c3783e9 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +rust/* @dbr @djmitche diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..14441fc55 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# To get started with Dependabot version updates, you'll need to specify which +# package ecosystems to update and where the package manifests are located. +# Please see the documentation for all configuration options: +# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates + +version: 2 +updates: + - package-ecosystem: "cargo" + directory: "/" # Location of package manifests + schedule: + interval: "daily" diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml new file mode 100644 index 000000000..1e45f0828 --- /dev/null +++ b/.github/workflows/checks.yml @@ -0,0 +1,102 @@ +name: checks + +on: + push: + branches: + - main + pull_request: + types: [opened, reopened, synchronize] + +jobs: + clippy: + runs-on: ubuntu-latest + name: "Check & Clippy" + + steps: + - uses: actions/checkout@v2 + + - name: Cache cargo registry + uses: actions/cache@v2 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v2 + with: + path: target + key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - uses: actions-rs/toolchain@v1 + with: + # Fixed version for clippy lints. Bump this as necesary. It must not + # be older than the MSRV in tests.yml. + toolchain: "1.57" + override: true + + - uses: actions-rs/cargo@v1.0.1 + with: + command: check + + - run: rustup component add clippy + + - uses: actions-rs/clippy-check@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + args: --all-features + name: "Clippy Results" + + mdbook: + runs-on: ubuntu-latest + name: "Documentation" + + steps: + - uses: actions/checkout@v2 + + - name: Setup mdBook + uses: peaceiris/actions-mdbook@v1 + with: + # if this changes, change it in cli/Cargo.toml and .github/workflows/publish-docs.yml as well + mdbook-version: '0.4.10' + + - name: Cache cargo registry + uses: actions/cache@v2 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v2 + with: + path: target + key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + + - name: Create usage-docs plugin + run: cargo build -p taskchampion-cli --features usage-docs --bin usage-docs + + - run: mdbook test rust/docs + - run: mdbook build rust/docs + + fmt: + runs-on: ubuntu-latest + name: "Formatting" + steps: + - uses: actions/checkout@v2 + + - uses: actions-rs/toolchain@v1 + with: + profile: minimal + components: rustfmt + toolchain: stable + override: true + + - uses: actions-rs/cargo@v1 + with: + command: fmt + args: --all -- --check + diff --git a/.github/workflows/publish-docs.yml b/.github/workflows/publish-docs.yml new file mode 100644 index 000000000..3362bdb57 --- /dev/null +++ b/.github/workflows/publish-docs.yml @@ -0,0 +1,47 @@ +name: docs + +on: + push: + branches: + - main + +jobs: + mdbook-deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + + - name: Setup mdBook + uses: peaceiris/actions-mdbook@v1 + with: + # if this changes, change it in cli/Cargo.toml and .github/workflows/publish-docs.yml as well + mdbook-version: '0.4.10' + + - name: Cache cargo registry + uses: actions/cache@v2 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v2 + with: + path: target + key: ${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + + - name: Create usage-docs plugin + run: cargo build -p taskchampion-cli --features usage-docs --bin usage-docs + + - run: mdbook build docs + + - name: Deploy + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: ./docs/book diff --git a/.github/workflows/rust-tests.yml b/.github/workflows/rust-tests.yml new file mode 100644 index 000000000..206eb88d4 --- /dev/null +++ b/.github/workflows/rust-tests.yml @@ -0,0 +1,47 @@ +name: tests - rust + +on: + push: + branches: + - main + pull_request: + types: [opened, reopened, synchronize] + +jobs: + test: + strategy: + matrix: + rust: + # MSRV; most not be higher than the clippy rust version in checks.yml + - "1.47" + - "stable" + os: + - ubuntu-latest + - macOS-latest + - windows-latest + + name: "rust ${{ matrix.rust }} on ${{ matrix.os }}" + runs-on: ${{ matrix.os }} + + steps: + - uses: actions/checkout@v1 + + - name: Cache cargo registry + uses: actions/cache@v2 + with: + path: ~/.cargo/registry + key: ${{ runner.os }}-${{ matrix.rust }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + + - name: Cache cargo build + uses: actions/cache@v2 + with: + path: target + key: ${{ runner.os }}-${{ matrix.rust }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }} + + - uses: actions-rs/toolchain@v1 + with: + toolchain: "${{ matrix.rust }}" + override: true + + - name: test + run: cargo test diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml new file mode 100644 index 000000000..19ebbba8f --- /dev/null +++ b/.github/workflows/security.yml @@ -0,0 +1,19 @@ +name: security + +on: + schedule: + - cron: '0 0 * * *' + push: + paths: + - '**/Cargo.toml' + - '**/Cargo.lock' + +jobs: + audit: + runs-on: ubuntu-latest + name: "Audit Dependencies" + steps: + - uses: actions/checkout@v2 + - uses: actions-rs/audit-check@v1 + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index 03300d5c8..e056d115a 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,4 @@ patches *.exe tutorials .prove +/target/ diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 000000000..1418d3bbc --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3808 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "actix-codec" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78d1833b3838dbe990df0f1f87baf640cf6146e898166afe401839d1b001e570" +dependencies = [ + "bitflags", + "bytes 0.5.6", + "futures-core", + "futures-sink", + "log", + "pin-project 0.4.28", + "tokio", + "tokio-util", +] + +[[package]] +name = "actix-connect" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "177837a10863f15ba8d3ae3ec12fac1099099529ed20083a27fdfe247381d0dc" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "derive_more", + "either", + "futures-util", + "http", + "log", + "trust-dns-proto", + "trust-dns-resolver", +] + +[[package]] +name = "actix-http" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "452299e87817ae5673910e53c243484ca38be3828db819b6011736fc6982e874" +dependencies = [ + "actix-codec", + "actix-connect", + "actix-rt", + "actix-service", + "actix-threadpool", + "actix-utils", + "base64 0.13.0", + "bitflags", + "brotli2", + "bytes 0.5.6", + "cookie", + "copyless", + "derive_more", + "either", + "encoding_rs", + "flate2", + "futures-channel", + "futures-core", + "futures-util", + "fxhash", + "h2", + "http", + "httparse", + "indexmap", + "itoa", + "language-tags", + "lazy_static", + "log", + "mime", + "percent-encoding", + "pin-project 1.0.7", + "rand 0.7.3", + "regex", + "serde", + "serde_json", + "serde_urlencoded 0.7.0", + "sha-1 0.9.6", + "slab", + "time 0.2.27", +] + +[[package]] +name = "actix-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ca8ce00b267af8ccebbd647de0d61e0674b6e61185cc7a592ff88772bed655" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "actix-router" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ad299af73649e1fc893e333ccf86f377751eb95ff875d095131574c6f43452c" +dependencies = [ + "bytestring", + "http", + "log", + "regex", + "serde", +] + +[[package]] +name = "actix-rt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "143fcc2912e0d1de2bcf4e2f720d2a60c28652ab4179685a1ee159e0fb3db227" +dependencies = [ + "actix-macros", + "actix-threadpool", + "copyless", + "futures-channel", + "futures-util", + "smallvec", + "tokio", +] + +[[package]] +name = "actix-server" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45407e6e672ca24784baa667c5d32ef109ccdd8d5e0b5ebb9ef8a67f4dfb708e" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "futures-channel", + "futures-util", + "log", + "mio", + "mio-uds", + "num_cpus", + "slab", + "socket2", +] + +[[package]] +name = "actix-service" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0052435d581b5be835d11f4eb3bce417c8af18d87ddf8ace99f8e67e595882bb" +dependencies = [ + "futures-util", + "pin-project 0.4.28", +] + +[[package]] +name = "actix-testing" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47239ca38799ab74ee6a8a94d1ce857014b2ac36f242f70f3f75a66f691e791c" +dependencies = [ + "actix-macros", + "actix-rt", + "actix-server", + "actix-service", + "log", + "socket2", +] + +[[package]] +name = "actix-threadpool" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d209f04d002854b9afd3743032a27b066158817965bf5d036824d19ac2cc0e30" +dependencies = [ + "derive_more", + "futures-channel", + "lazy_static", + "log", + "num_cpus", + "parking_lot", + "threadpool", +] + +[[package]] +name = "actix-tls" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24789b7d7361cf5503a504ebe1c10806896f61e96eca9a7350e23001aca715fb" +dependencies = [ + "actix-codec", + "actix-service", + "actix-utils", + "futures-util", +] + +[[package]] +name = "actix-utils" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e9022dec56632d1d7979e59af14f0597a28a830a9c1c7fec8b2327eb9f16b5a" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "bitflags", + "bytes 0.5.6", + "either", + "futures-channel", + "futures-sink", + "futures-util", + "log", + "pin-project 0.4.28", + "slab", +] + +[[package]] +name = "actix-web" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e641d4a172e7faa0862241a20ff4f1f5ab0ab7c279f00c2d4587b77483477b86" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-testing", + "actix-threadpool", + "actix-tls", + "actix-utils", + "actix-web-codegen", + "awc", + "bytes 0.5.6", + "derive_more", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "fxhash", + "log", + "mime", + "pin-project 1.0.7", + "regex", + "serde", + "serde_json", + "serde_urlencoded 0.7.0", + "socket2", + "time 0.2.27", + "tinyvec", + "url", +] + +[[package]] +name = "actix-web-codegen" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad26f77093333e0e7c6ffe54ebe3582d908a104e448723eec6d43d08b07143fb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "ahash" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb833f0bf979d8475d38fbf09ed3b8a55e1885fe93ad3f93239fc6a4f17b98" +dependencies = [ + "getrandom 0.2.3", + "once_cell", + "version_check", +] + +[[package]] +name = "aho-corasick" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e37cfd5e7657ada45f742d6e99ca5788580b5c529dc78faf11ece6dc702656f" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" +dependencies = [ + "winapi 0.3.9", +] + +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi 0.3.9", +] + +[[package]] +name = "anyhow" +version = "1.0.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15af2628f6890fe2609a3b91bef4c83450512802e59489f9c1cb1fa5df064a61" + +[[package]] +name = "arrayref" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4c527152e37cf757a3f78aae5a06fbeefdb07ccc535c980a3208ee3060dd544" + +[[package]] +name = "arrayvec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" + +[[package]] +name = "assert_cmd" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88b6bd5df287567ffdf4ddf4d33060048e1068308e5f62d81c6f9824a045a48" +dependencies = [ + "bstr", + "doc-comment", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "async-trait" +version = "0.1.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b98e84bbb4cbcdd97da190ba0c58a1bb0de2c1fdf67d159e192ed766aeca722" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi", + "libc", + "winapi 0.3.9", +] + +[[package]] +name = "autocfg" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" + +[[package]] +name = "awc" +version = "2.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b381e490e7b0cfc37ebc54079b0413d8093ef43d14a4e4747083f7fa47a9e691" +dependencies = [ + "actix-codec", + "actix-http", + "actix-rt", + "actix-service", + "base64 0.13.0", + "bytes 0.5.6", + "cfg-if 1.0.0", + "derive_more", + "futures-core", + "log", + "mime", + "percent-encoding", + "rand 0.7.3", + "serde", + "serde_json", + "serde_urlencoded 0.7.0", +] + +[[package]] +name = "base-x" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" + +[[package]] +name = "bit-set" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e11e16035ea35e4e5997b393eacbf6f63983188f7a2ad25bfb13465f5ad59de" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf1de2fe8c75bc145a2f577add951f8134889b4795d47466a54a5c846d691693" + +[[package]] +name = "bitvec" +version = "0.19.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8942c8d352ae1838c9dda0b0ca2ab657696ef2232a20147cf1b30ae1a9cb4321" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2b_simd" +version = "0.5.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afa748e348ad3be8263be728124b24a24f268266f6f5d58af9d75f6a40b5c587" +dependencies = [ + "arrayref", + "arrayvec", + "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 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]] +name = "brotli-sys" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4445dea95f4c2b41cde57cc9fee236ae4dbae88d8fcbdb4750fc1bb5d86aaecd" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "brotli2" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cb036c3eade309815c15ddbacec5b22c4d1f3983a774ab2eac2e3e9ea85568e" +dependencies = [ + "brotli-sys", + "libc", +] + +[[package]] +name = "bstr" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90682c8d613ad3373e66de8c6411e0ae2ab2571e879d2efbf73558cc66f21279" +dependencies = [ + "lazy_static", + "memchr", + "regex-automata", + "serde", +] + +[[package]] +name = "built" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +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" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c189c53d098945499cdfa7ecc63567cf3886b3332b312a5b4585d8d3a6a610" + +[[package]] +name = "bytes" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e4cec68f03f32e44924783795810fa50a7035d8c8ebe78580ad7e6c703fba38" + +[[package]] +name = "bytes" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b700ce4376041dcd0a327fd0097c41095743c4c8af8887265942faf1100bd040" + +[[package]] +name = "bytestring" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90706ba19e97b90786e19dc0d5e2abd80008d99d4c0c5d1ad0b5e72cec7c494d" +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 = "cbindgen" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51e3973b165dc0f435831a9e426de67e894de532754ff7a3f307c03ee5dec7dc" +dependencies = [ + "clap", + "heck", + "indexmap", + "log", + "proc-macro2", + "quote", + "serde", + "serde_json", + "syn", + "tempfile", + "toml", +] + +[[package]] +name = "cc" +version = "1.0.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fff2a6927b3bb87f9595d67196a70493f627687a71d87a0d692242c33f58c11" +dependencies = [ + "jobserver", +] + +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670ad68c9088c2a963aaa298cb369688cf3f9465ce5e2d4ca10e6e0098a1ce73" +dependencies = [ + "libc", + "num-integer", + "num-traits", + "serde", + "time 0.1.43", + "winapi 0.3.9", +] + +[[package]] +name = "chunked_transfer" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fff857943da45f546682664a79488be82e69e43c1a7a2307679ab9afb3a66d2e" + +[[package]] +name = "clap" +version = "2.33.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" +dependencies = [ + "ansi_term 0.11.0", + "atty", + "bitflags", + "strsim", + "textwrap 0.11.0", + "unicode-width", + "vec_map", +] + +[[package]] +name = "combine" +version = "4.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4369b5e4c0cddf64ad8981c0111e7df4f7078f4d6ba98fb31f2e17c4c57b7e" +dependencies = [ + "bytes 1.0.1", + "memchr", +] + +[[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]] +name = "const_fn" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f92cfa0fd5690b3cf8c1ef2cabbd9b7ef22fa53cf5e1f92b05103f6d5d1cf6e7" + +[[package]] +name = "constant_time_eq" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03a5d7b21829bc7b4bf4754a978a241ae54ea55a40f92bb20216e54096f4b951" +dependencies = [ + "percent-encoding", + "time 0.2.27", + "version_check", +] + +[[package]] +name = "copyless" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2df960f5d869b2dd8532793fde43eb5427cceb126c929747a26823ab0eeb536" + +[[package]] +name = "cpufeatures" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed00c67cb5d0a7d64a44f6ad2668db7e7530311dd53ea79bcd4fb022c64911c8" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81156fece84ab6a9f2afdb109ce3ae577e42b1228441eded99bd77f627953b1a" +dependencies = [ + "cfg-if 1.0.0", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d82cfc11ce7f2c3faef78d8a684447b40d503d9681acebed6cb728d45940c4db" +dependencies = [ + "cfg-if 1.0.0", + "lazy_static", +] + +[[package]] +name = "csv" +version = "1.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22813a6dc45b335f9bade10bf7271dc477e81113e89eb251a0bc2a8a81c536e1" +dependencies = [ + "bstr", + "csv-core", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b2466559f260f48ad25fe6317b3c8dac77b5bdb5763ac7d9d6103530663bc90" +dependencies = [ + "memchr", +] + +[[package]] +name = "ctor" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccc0a48a9b826acdf4028595adc9db92caea352f7af011a3034acd172a52a0aa" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "derive_more" +version = "0.99.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc7b9cef1e351660e5443924e4f43ab25fbbed3e9a5f052df3677deb4d6b320" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "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 = "diff" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e25ea47919b1560c4e3b7fe0aaab9becf5b84a10325ddf7db0f0ba5e1026499" + +[[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 0.14.4", +] + +[[package]] +name = "dirs" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fd78930633bd1c6e35c4b42b1df7b0cbc6bc191146e512bb3bedf243fcc3901" +dependencies = [ + "libc", + "redox_users 0.3.5", + "winapi 0.3.9", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if 1.0.0", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.0", + "winapi 0.3.9", +] + +[[package]] +name = "discard" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "212d0f5754cb6769937f4501cc0e67f4f4483c8d2c3e1e922ee9edbe4ab4c7c0" + +[[package]] +name = "doc-comment" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" + +[[package]] +name = "encoding_rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80df024fbc5ac80f87dfef0d9f5209a252f2a497f7f42944cff24d8253cac065" +dependencies = [ + "cfg-if 1.0.0", +] + +[[package]] +name = "enum-as-inner" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c5f0096a91d210159eceb2ff5e1c4da18388a170e1e3ce948aac9c8fdbbf595" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "env_logger" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36" +dependencies = [ + "atty", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" + +[[package]] +name = "fallible-streaming-iterator" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd3aec53de10fe96d7d8c565eb17f2c687bb5518a2ec453b5b1252964526abe0" +dependencies = [ + "cfg-if 1.0.0", + "crc32fast", + "libc", + "miniz_oxide", +] + +[[package]] +name = "float-cmp" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1267f4ac4f343772758f7b1bdcbe767c218bbab93bb432acbf5162bbf85a6c4" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fc25a87fa4fd2094bffb06925852034d90a17f0d1e05197d4956d3555752191" +dependencies = [ + "matches", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e9763c69ebaae630ba35f74888db465e49e259ba1bc0eda7d06f4a067615d82" +dependencies = [ + "bitflags", + "fuchsia-zircon-sys", +] + +[[package]] +name = "fuchsia-zircon-sys" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dcaa9ae7725d12cdb85b3ad99a434db70b468c09ded17e012d86b5c1010f7a7" + +[[package]] +name = "funty" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7e43a803dae2fa37c1f6a8fe121e1f7bf9548b4dfc0522a42f34145dadfc27" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e682a68b29a882df0545c143dc3646daefe80ba479bcdede94d5a703de2871e2" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0402f765d8a89a26043b889b26ce3c4679d268fa6bb22cd7c6aad98340e179d1" + +[[package]] +name = "futures-executor" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "badaa6a909fac9e7236d0620a2f57f7664640c56575b71a7552fbd68deafab79" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acc499defb3b348f8d8f3f66415835a9131856ff7714bf10dadfc4ec4bdb29a1" + +[[package]] +name = "futures-macro" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4c40298486cdf52cc00cd6d6987892ba502c7656a16a4192a9992b1ccedd121" +dependencies = [ + "autocfg", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a57bead0ceff0d6dde8f465ecd96c9338121bb7717d3e7b108059531870c4282" + +[[package]] +name = "futures-task" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a16bef9fc1a4dddb5bee51c989e3fbba26569cbb0e31f5b303c184e3dd33dae" + +[[package]] +name = "futures-util" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "feb5c238d27e2bf94ffdfd27b2c29e3df4a68c4193bb6427384259e2bf191967" +dependencies = [ + "autocfg", + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite 0.2.6", + "pin-utils", + "proc-macro-hack", + "proc-macro-nested", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501466ecc8a30d1d3b7fc9229b122b2ce8ed6e9d9223f1138d4babb253e51817" +dependencies = [ + "typenum", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fcd999463524c52659517fe2cea98493cfe485d10565e7b0fb07dbba7ad2753" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e4728fd124914ad25e99e3d15a9361a879f6620f63cb56bbb08f95abb97a535" +dependencies = [ + "bytes 0.5.6", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7afe4a420e3fe79967a00898cc1f4db7c8a49a9333a29f8a4bd76a253d5cd04" + +[[package]] +name = "hashbrown" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashlink" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7249a3129cbc1ffccd74857f81464a323a152173cdb134e0fd81bc803b29facf" +dependencies = [ + "hashbrown 0.11.2", +] + +[[package]] +name = "headers" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +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", +] + +[[package]] +name = "hermit-abi" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "322f4de77956e22ed0e5032c359a0f1273f1f7f0d79bfa3b8ffbc730d7fbcc5c" +dependencies = [ + "libc", +] + +[[package]] +name = "hostname" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c731c3e10504cc8ed35cfe2f1db4c9274c3d35fa486e3b31df46f068ef3e867" +dependencies = [ + "libc", + "match_cfg", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527e8c9ac747e28542699a951517aa9a6945af506cd1f2e1b53a576c17b6cc11" +dependencies = [ + "bytes 1.0.1", + "fnv", + "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", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "idna" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418a0a6fab821475f634efe3ccc45c013f742efe03d853e8d3355d5cb850ecf8" +dependencies = [ + "matches", + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824845a0bf897a9042383849b02c1bc219c2383772efcd5c6f9766fa4b81aef3" +dependencies = [ + "autocfg", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61124eeebbd69b8190558df225adf7e4caafce0d743919e5d6b19652314ec5ec" +dependencies = [ + "cfg-if 1.0.0", +] + +[[package]] +name = "integration-tests" +version = "0.4.1" +dependencies = [ + "actix-rt", + "actix-web", + "anyhow", + "cc", + "env_logger 0.8.4", + "lazy_static", + "log", + "pretty_assertions", + "taskchampion", + "taskchampion-lib", + "taskchampion-sync-server", + "tempfile", +] + +[[package]] +name = "iovec" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2b3ea6ff95e175473f8ffe6a7eb7c00d054240321b84c57051175fe3c1e075e" +dependencies = [ + "libc", +] + +[[package]] +name = "ipconfig" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7e2f18aece9709094573a9f24f483c4f65caa4298e2f7ae1b71cc65d853fad7" +dependencies = [ + "socket2", + "widestring", + "winapi 0.3.9", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83bdfbace3a0e81a4253f73b49e960b053e396a11012cbd49b9b74d6a2b67062" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "kernel32-sys" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7507624b29483431c0ba2d82aece8ca6cdba9382bff4ddd0f7490560c056098d" +dependencies = [ + "winapi 0.2.8", + "winapi-build", +] + +[[package]] +name = "language-tags" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a91d884b6667cd606bb5a69aa0c99ba811a115fc68915e7056ec08a46e93199a" + +[[package]] +name = "lazy_static" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6607c62aa161d23d17a9072cc5da0be67cdfc89d3afb1e8d9c842bebc2525ffe" +dependencies = [ + "arrayvec", + "bitflags", + "cfg-if 1.0.0", + "ryu", + "static_assertions", +] + +[[package]] +name = "libc" +version = "0.2.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eef78b64d87775463c549fbd80e19249ef436ea3bf1de2a1eb7e717ec7fab1e9" + +[[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" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290b64917f8b0cb885d9de0f9959fe1f775d7fa12f1da2db9001c1c8ab60f89d" +dependencies = [ + "cc", + "pkg-config", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fb9b38af92608140b86b693604b9ffcc5824240a484d1ecd4795bacb2fe88f3" + +[[package]] +name = "lock_api" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0382880606dff6d15c9476c416d18690b72742aa7b605bb6dd6ec9030fbf07eb" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51b9bbe6c47d51fc3e1a9b945965946b4c44142ab8792c50835a980d362c2710" +dependencies = [ + "cfg-if 1.0.0", +] + +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffbee8634e0d45d258acb448e7eaab3fce7a0a467395d4d9f228e3c1f01fb2e4" + +[[package]] +name = "matches" +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", + "toml", + "warp", +] + +[[package]] +name = "memchr" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b16bd47d9e329435e309c58469fe0791c2d0d1ba96ec0954152a5ae2b04387dc" + +[[package]] +name = "mime" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92518e98c078586bc6c934028adcca4c92a53d6a958196de835170a01d84e4b" +dependencies = [ + "adler", + "autocfg", +] + +[[package]] +name = "mio" +version = "0.6.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4afd66f5b91bf2a3bc13fad0e21caedac168ca4c707504e75585648ae80e4cc4" +dependencies = [ + "cfg-if 0.1.10", + "fuchsia-zircon", + "fuchsia-zircon-sys", + "iovec", + "kernel32-sys", + "libc", + "log", + "miow", + "net2", + "slab", + "winapi 0.2.8", +] + +[[package]] +name = "mio-extras" +version = "2.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52403fe290012ce777c4626790c8951324a2b9e3316b3143779c72b029742f19" +dependencies = [ + "lazycell", + "log", + "mio", + "slab", +] + +[[package]] +name = "mio-uds" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "afcb699eb26d4332647cc848492bbc15eafb26f08d0304550d5aa1f612e066f0" +dependencies = [ + "iovec", + "libc", + "mio", +] + +[[package]] +name = "miow" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebd808424166322d4a38da87083bfddd3ac4c131334ed55856112eb06d46944d" +dependencies = [ + "kernel32-sys", + "net2", + "winapi 0.2.8", + "ws2_32-sys", +] + +[[package]] +name = "net2" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "391630d12b68002ae1e25e8f974306474966550ad82dac6886fb8910c19568ae" +dependencies = [ + "cfg-if 0.1.10", + "libc", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7413f999671bd4745a7b624bd370a569fb6bc574b23c83a3c5ed2e453f3d5e2" +dependencies = [ + "bitvec", + "funty", + "lexical-core", + "memchr", + "version_check", +] + +[[package]] +name = "normalize-line-endings" +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", + "mio-extras", + "walkdir", + "winapi 0.3.9", +] + +[[package]] +name = "num-integer" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2cc698a63b549a70bc047073d2949cce27cd1c7b0a4a862d08a8031bc2801db" +dependencies = [ + "autocfg", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a64b1ec5cda2586e284722486d802acf1f7dbdc623e2bfc57e65ca1cd099290" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05499f3756671c15885fee9034446956fff3f243d6077b91e5767df161f766b3" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "once_cell" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +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" +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 = "output_vt100" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cdc5b785b7a58c5aad8216b3dfa114df64b0b06ae6e1501cef91df2fbdf8f9" +dependencies = [ + "winapi 0.3.9", +] + +[[package]] +name = "parking_lot" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d7744ac029df22dca6284efe4e898991d28e3085c706c972bcd7da4a27a15eb" +dependencies = [ + "instant", + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7a782938e745763fe6907fc6ba86946d72f49fe7e21de074e08128a99fb018" +dependencies = [ + "cfg-if 1.0.0", + "instant", + "libc", + "redox_syscall 0.2.8", + "smallvec", + "winapi 0.3.9", +] + +[[package]] +name = "percent-encoding" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918192b5c59119d51e0cd221f4d49dde9112824ba717369e903c97d076083d0f" +dependencies = [ + "pin-project-internal 0.4.28", +] + +[[package]] +name = "pin-project" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7509cc106041c40a4518d2af7a61530e1eed0e6285296a3d8c5472806ccc4a4" +dependencies = [ + "pin-project-internal 1.0.7", +] + +[[package]] +name = "pin-project-internal" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3be26700300be6d9d23264c73211d8190e755b6b5ca7a1b28230025511b52a5e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-internal" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c950132583b500556b1efd71d45b319029f2b71518d979fcc208e16b42426f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "pin-project-lite" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "257b64915a082f7811703966789728173279bdebb956b143dbcd23f6f970a777" + +[[package]] +name = "pin-project-lite" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0e1f259c92177c30a4c9d177246edd0a3568b25756a977d0632cf8fa37e905" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "pkg-config" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3831453b3449ceb48b6d9c7ad7c96d5ea673e9b470a1dc578c2ce6521230884c" + +[[package]] +name = "ppv-lite86" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f49cfaf7fdaa3bfacc6fa3e7054e65148878354a5cfddcf661df4c851f8021df" +dependencies = [ + "difference", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57e35a3326b75e49aa85f5dc6ec15b41108cf5aee58eabb1f274dd18b73c2451" + +[[package]] +name = "predicates-tree" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15f553275e5721409451eb85e15fd9a860a6e5ab4496eb215987502b5f5391f2" +dependencies = [ + "predicates-core", + "treeline", +] + +[[package]] +name = "pretty_assertions" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0cfe1b2403f172ba0f234e500906ee0a3e493fb81092dac23ebefe129301cc" +dependencies = [ + "ansi_term 0.12.1", + "ctor", + "diff", + "output_vt100", +] + +[[package]] +name = "prettytable-rs" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fd04b170004fa2daccf418a7f8253aaf033c27760b5f225889024cf66d7ac2e" +dependencies = [ + "atty", + "csv", + "encode_unicode", + "lazy_static", + "term", + "unicode-width", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbf0c48bc1d91375ae5c3cd81e3722dff1abcf81a30960240640d223f59fe0e5" + +[[package]] +name = "proc-macro-nested" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc881b2c22681370c6a780e47af9840ef841837bc98118431d4e1868bd0c1086" + +[[package]] +name = "proc-macro2" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0d8caf72986c1a598726adc988bb5984792ef84f5ee5aa50209145ee8077038" +dependencies = [ + "unicode-xid", +] + +[[package]] +name = "proptest" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0d9cc07f18492d879586c92b485def06bc850da3118075cd45d50e9c95b0e5" +dependencies = [ + "bit-set", + "bitflags", + "byteorder", + "lazy_static", + "num-traits", + "quick-error 2.0.1", + "rand 0.8.3", + "rand_chacha 0.3.1", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", +] + +[[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" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d0b9745dc2debf507c8422de05d7226cc1f0644216dfdfead988f9b1ab32a7" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "941ba9d78d8e2f7ce474c015eea4d9c6d25b6a3327f9832ee29a4de27f91bbb8" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc 0.2.0", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ef9e7e66b4468674bfcb0c81af8b7fa0bb154fa9f28eb840da5c447baeb8d7e" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.2", + "rand_hc 0.3.0", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.2", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34cf66eb183df1c5876e2dcf6b13d57340741e8dc255b48e40a26de954d06ae7" +dependencies = [ + "getrandom 0.2.3", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_hc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3190ef7066a446f2e7f42e239d161e905420ccab01eb967c9eb27d21b2322a73" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25bf25ec5ae4a3f1b92f929810509a2f53d7dca2f50b794ff57e3face536c8f" +dependencies = [ + "rand_core 0.6.2", +] + +[[package]] +name = "redox_syscall" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41cc0f7e4d5d4544e8861606a285bb08d3e70712ccc7d2b84d7c0ccfaf4b05ce" + +[[package]] +name = "redox_syscall" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "742739e41cd49414de871ea5e549afb7e2a3ac77b589bcbebe8c82fab37147fc" +dependencies = [ + "bitflags", +] + +[[package]] +name = "redox_users" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de0737333e7a9502c789a36d7c7fa6092a49895d4faa31ca5df163857ded2e9d" +dependencies = [ + "getrandom 0.1.16", + "redox_syscall 0.1.57", + "rust-argon2", +] + +[[package]] +name = "redox_users" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" +dependencies = [ + "getrandom 0.2.3", + "redox_syscall 0.2.8", +] + +[[package]] +name = "regex" +version = "1.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a11647b6b25ff05a515cb92c365cec08801e83423a235b51e231e1808747286" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" + +[[package]] +name = "regex-syntax" +version = "0.6.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f497285884f3fcff424ffc933e56d7cbca511def0c9831a7f9b5f6153e3cc89b" + +[[package]] +name = "remove_dir_all" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" +dependencies = [ + "winapi 0.3.9", +] + +[[package]] +name = "resolv-conf" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52e44394d2086d010551b14b53b1f24e31647570cd1deb0379e2c21b329aba00" +dependencies = [ + "hostname", + "quick-error 1.2.3", +] + +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin", + "untrusted", + "web-sys", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57adcf67c8faaf96f3248c2a7b419a0dbc52ebe36ba83dd57fe83827c1ea4eb3" +dependencies = [ + "bitflags", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "memchr", + "smallvec", +] + +[[package]] +name = "rust-argon2" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b18820d944b33caa75a71378964ac46f58517c92b6ae5f762636247c09e78fb" +dependencies = [ + "base64 0.13.0", + "blake2b_simd", + "constant_time_eq", + "crossbeam-utils", +] + +[[package]] +name = "rustc_version" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "138e3e0acb6c9fb258b19b67cb8abd63c00679d2851805ea151465464fe9030a" +dependencies = [ + "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]] +name = "rustls" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35edb675feee39aec9c99fa5ff985081995a06d594114ae14cbe797ad7b7a6d7" +dependencies = [ + "base64 0.13.0", + "log", + "ring", + "sct", + "webpki", +] + +[[package]] +name = "rusty-fork" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb3dcc6e454c328bb824492db107ab7c0ae8fcffe4ad210136ef014458c1bc4f" +dependencies = [ + "fnv", + "quick-error 1.2.3", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "ryu" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" + +[[package]] +name = "sct" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b362b83898e0e69f38515b82ee15aa80636befe47c3b6d3d89a911e78fc228ce" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "semver" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d7eb9ef2c18661902cc47e535f9bc51b78acd254da71d375c2f6720d9a40403" +dependencies = [ + "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]] +name = "semver-parser" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec7505abeacaec74ae4778d9d9328fe5a5d04253220a85c4ee022239fc996d03" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "963a7dbc9895aeac7ac90e74f34a5d5261828f79df35cbed41e10189d3804d43" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799e97dc9fdae36a5c8b8f2cae9ce2ee9fdce2058c57a93e6099d919fd982f79" +dependencies = [ + "itoa", + "ryu", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edfa57a7f8d9c1d260a549e7224100f6c43d43f9103e06dd8b4095a9b2b43ce9" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "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 0.9.0", + "cfg-if 1.0.0", + "cpufeatures", + "digest 0.9.0", + "opaque-debug 0.3.0", +] + +[[package]] +name = "sha1" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2579985fda508104f7587689507983eadd6a6e84dd35d6d115361f530916fa0d" + +[[package]] +name = "shlex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f173ac3d1a7e3b28003f40de0b5ce7fe2710f9b9dc3fc38664cebee46b3b6527" + +[[package]] +name = "smallvec" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe0f37c9e8f3c5a4a66ad655a93c74daac4ad00c441533bf5c6e7990bb42604e" + +[[package]] +name = "smawk" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f67ad224767faa3c7d8b6d91985b78e70a1324408abcb1cfcc2be4c06bc06043" + +[[package]] +name = "socket2" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "122e570113d28d773067fab24266b66753f6ea915758651696b6e35e49f88d6e" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "winapi 0.3.9", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + +[[package]] +name = "standback" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e113fb6f3de07a243d434a56ec6f186dfd51cb08448239fe7bcae73f87ff28ff" +dependencies = [ + "version_check", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "stdweb" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d022496b16281348b52d0e30ae99e01a73d737b2f45d38fed4edf79f9325a1d5" +dependencies = [ + "discard", + "rustc_version 0.2.3", + "stdweb-derive", + "stdweb-internal-macros", + "stdweb-internal-runtime", + "wasm-bindgen", +] + +[[package]] +name = "stdweb-derive" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c87a60a40fccc84bef0652345bbbbbe20a605bf5d0ce81719fc476f5c03b50ef" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "serde_derive", + "syn", +] + +[[package]] +name = "stdweb-internal-macros" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fa5ff6ad0d98d1ffa8cb115892b6e69d67799f6763e162a1c9db421dc22e11" +dependencies = [ + "base-x", + "proc-macro2", + "quote", + "serde", + "serde_derive", + "serde_json", + "sha1", + "syn", +] + +[[package]] +name = "stdweb-internal-runtime" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + +[[package]] +name = "strum" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +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", + "unicode-xid", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "taskchampion" +version = "0.4.1" +dependencies = [ + "anyhow", + "byteorder", + "chrono", + "flate2", + "log", + "pretty_assertions", + "proptest", + "ring", + "rstest", + "rusqlite", + "serde", + "serde_json", + "strum 0.21.0", + "strum_macros 0.21.1", + "tempfile", + "thiserror", + "ureq", + "uuid", +] + +[[package]] +name = "taskchampion-cli" +version = "0.4.1" +dependencies = [ + "anyhow", + "assert_cmd", + "atty", + "built", + "dialoguer", + "dirs-next", + "env_logger 0.8.4", + "iso8601-duration", + "lazy_static", + "log", + "mdbook", + "nom 6.1.2", + "predicates", + "pretty_assertions", + "prettytable-rs", + "rstest", + "serde", + "serde_json", + "taskchampion", + "tempfile", + "termcolor", + "textwrap 0.13.4", + "thiserror", + "toml", + "toml_edit", +] + +[[package]] +name = "taskchampion-lib" +version = "0.1.0" +dependencies = [ + "anyhow", + "libc", + "pretty_assertions", + "taskchampion", +] + +[[package]] +name = "taskchampion-sync-server" +version = "0.4.1" +dependencies = [ + "actix-rt", + "actix-web", + "anyhow", + "chrono", + "clap", + "env_logger 0.8.4", + "futures", + "log", + "pretty_assertions", + "rusqlite", + "serde", + "serde_json", + "tempfile", + "thiserror", + "uuid", +] + +[[package]] +name = "tempfile" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dac1c663cfc93810f88aed9b8941d48cabf856a1b111c29a40439018d870eb22" +dependencies = [ + "cfg-if 1.0.0", + "libc", + "rand 0.8.3", + "redox_syscall 0.2.8", + "remove_dir_all", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd106a334b7657c10b7c540a0106114feadeb4dc314513e97df481d5d966f42" +dependencies = [ + "byteorder", + "dirs", + "winapi 0.3.9", +] + +[[package]] +name = "termcolor" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "terminal_size" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633c1a546cee861a1a6d0dc69ebeca693bf4296661ba7852b9d21d159e0506df" +dependencies = [ + "libc", + "winapi 0.3.9", +] + +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "textwrap" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd05616119e612a8041ef58f2b578906cc2531a6069047ae092cfb86a325d835" +dependencies = [ + "smawk", + "terminal_size", + "unicode-width", +] + +[[package]] +name = "thiserror" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa6f76457f59514c7eeb4e59d891395fab0b2fd1d40723ae737d64153392e9c6" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a36768c0fbf1bb15eca10defa29526bda730a2376c2ab4393ccfa16fb1a318d" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "threadpool" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d050e60b33d41c19108b32cea32164033a9013fe3b46cbd4457559bfbf77afaa" +dependencies = [ + "num_cpus", +] + +[[package]] +name = "time" +version = "0.1.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca8a50ef2360fbd1eeb0ecd46795a87a19024eb4b53c5dc916ca1fd95fe62438" +dependencies = [ + "libc", + "winapi 0.3.9", +] + +[[package]] +name = "time" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4752a97f8eebd6854ff91f1c1824cd6160626ac4bd44287f7f4ea2035a02a242" +dependencies = [ + "const_fn", + "libc", + "standback", + "stdweb", + "time-macros", + "version_check", + "winapi 0.3.9", +] + +[[package]] +name = "time-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "957e9c6e26f12cb6d0dd7fc776bb67a706312e7299aed74c8dd5b17ebb27e2f1" +dependencies = [ + "proc-macro-hack", + "time-macros-impl", +] + +[[package]] +name = "time-macros-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd3c141a1b43194f3f56a1411225df8646c55781d5f26db825b3d98507eb482f" +dependencies = [ + "proc-macro-hack", + "proc-macro2", + "quote", + "standback", + "syn", +] + +[[package]] +name = "tinyvec" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b5220f05bb7de7f3f53c7c065e1199b3172696fe2db9f9c4d8ad9b4ee74c342" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cda74da7e1a664f795bb1f8a87ec406fb89a02522cf6e50620d016add6dbbf5c" + +[[package]] +name = "tokio" +version = "0.2.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6703a273949a90131b290be1fe7b039d0fc884aa1935860dfcbe056f28cd8092" +dependencies = [ + "bytes 0.5.6", + "fnv", + "futures-core", + "iovec", + "lazy_static", + "libc", + "memchr", + "mio", + "mio-uds", + "pin-project-lite 0.1.12", + "signal-hook-registry", + "slab", + "tokio-macros", + "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", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be8242891f2b6cbef26a2d7e8605133c2c554cd35b3e4948ea892d6d68436499" +dependencies = [ + "bytes 0.5.6", + "futures-core", + "futures-sink", + "log", + "pin-project-lite 0.1.12", + "tokio", +] + +[[package]] +name = "toml" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a31142970826733df8241ef35dc040ef98c679ab14d7c3e54d827099b3acecaa" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09adeb8c97449311ccd28a427f96fb563e7fd31aabf994189879d9da2394b89d" +dependencies = [ + "cfg-if 1.0.0", + "log", + "pin-project-lite 0.2.6", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9ff14f98b1a4b289c6248a023c1c2fa1491062964e9fed67ab29c4e4da4a052" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "pin-project 1.0.7", + "tracing", +] + +[[package]] +name = "treeline" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7f741b240f1a48843f9b8e0444fb55fb2a4ff67293b50a9179dfd5ea67f8d41" + +[[package]] +name = "trust-dns-proto" +version = "0.19.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cad71a0c0d68ab9941d2fb6e82f8fb2e86d9945b94e1661dd0aaea2b88215a9" +dependencies = [ + "async-trait", + "cfg-if 1.0.0", + "enum-as-inner", + "futures", + "idna", + "lazy_static", + "log", + "rand 0.7.3", + "smallvec", + "thiserror", + "tokio", + "url", +] + +[[package]] +name = "trust-dns-resolver" +version = "0.19.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "710f593b371175db53a26d0b38ed2978fafb9e9e8d3868b1acd753ea18df0ceb" +dependencies = [ + "cfg-if 0.1.10", + "futures", + "ipconfig", + "lazy_static", + "log", + "lru-cache", + "resolv-conf", + "smallvec", + "thiserror", + "tokio", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeb8be209bb1c96b7c177c7420d26e04eccacb0eeae6b980e35fcb74678107e0" +dependencies = [ + "matches", +] + +[[package]] +name = "unicode-normalization" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54590932941a9e9266f0832deed84ebe1bf2e4c9e4a3554d393d18f5e854bf9" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0d2e7be6ae3a5fa87eed5fb451aff96f2573d2694942e40543ae0bbe19c796" + +[[package]] +name = "unicode-width" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9337591893a19b88d8d87f2cec1e73fad5cdfd10e5a6f349f498ad6ea2ffb1e3" + +[[package]] +name = "unicode-xid" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ccb82d61f80a663efe1f787a51b16b5a51e3314d6ac365b08639f52387b33f3" + +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + +[[package]] +name = "ureq" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2475a6781e9bc546e7b64f4013d2f4032c8c6a40fcffd7c6f4ee734a890972ab" +dependencies = [ + "base64 0.13.0", + "chunked_transfer", + "log", + "once_cell", + "rustls", + "url", + "webpki", + "webpki-roots", +] + +[[package]] +name = "url" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a507c383b2d33b5fc35d1861e77e6b383d158b2da5e14fe51b83dfedf6fd578c" +dependencies = [ + "form_urlencoded", + "idna", + "matches", + "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.3", + "serde", +] + +[[package]] +name = "vcpkg" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "025ce40a007e1907e58d5bc1a594def78e5573bb0b1160bc389634e8f12e4faa" + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + +[[package]] +name = "version_check" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fecdca9a5291cc2b8dcf7dc02453fee791a280f3743cb0905f8822ae463b3fe" + +[[package]] +name = "wait-timeout" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f200f5b12eb75f8c1ed65abd4b2db8a6e1b138a20de009dacee265a2498f3f6" +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", + "tokio-tungstenite", + "tower-service", + "tracing", + "tracing-futures", + "urlencoding", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.10.2+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd6fbd9a79829dd1ad0cc20627bf1ed606756a7f77edff7b66b7064f9cb327c6" + +[[package]] +name = "wasm-bindgen" +version = "0.2.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d54ee1d4ed486f78874278e63e4069fc1ab9f6a18ca492076ffb90c5eb2997fd" +dependencies = [ + "cfg-if 1.0.0", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b33f6a0694ccfea53d94db8b2ed1c3a8a4c86dd936b13b9f0a15ec4a451b900" +dependencies = [ + "bumpalo", + "lazy_static", + "log", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "088169ca61430fe1e58b8096c24975251700e7b1f6fd91cc9d59b04fb9b18bd4" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be2241542ff3d9f241f5e2cb6dd09b37efe786df8851c54957683a49f0987a97" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.74" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7cff876b8f18eed75a66cf49b65e7f967cb354a7aa16003fb55dbfd25b44b4f" + +[[package]] +name = "web-sys" +version = "0.3.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e828417b379f3df7111d3a2a9e5753706cae29c41f7c4029ee9fd77f3e09e582" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki" +version = "0.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e38c0608262c46d4a56202ebabdeb094cef7e560ca7a226c6bf055188aa4ea" +dependencies = [ + "ring", + "untrusted", +] + +[[package]] +name = "webpki-roots" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aabe153544e473b775453675851ecc86863d2a81d786d741f6b76778f2a48940" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c168940144dd21fd8046987c16a46a33d5fc84eec29ef9dcddc2ac9e31526b7c" + +[[package]] +name = "winapi" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167dc9d6949a9b857f3451275e911c3f44255842c1f7a76f33c55103a909087a" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-build" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d315eee3b34aca4797b2da6b13ed88266e6d612562a0c46390af8299fc699bc" + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +dependencies = [ + "winapi 0.3.9", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "winreg" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2986deb581c4fe11b621998a5e53361efe6b48a151178d0cd9eeffa4dc6acc9" +dependencies = [ + "winapi 0.3.9", +] + +[[package]] +name = "ws2_32-sys" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d59cefebd0c892fa2dd6de581e937301d8552cb44489cdff035c6187cb63fa5e" +dependencies = [ + "winapi 0.2.8", + "winapi-build", +] + +[[package]] +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 = "xtask" +version = "0.4.1" +dependencies = [ + "anyhow", + "cbindgen", +] + +[[package]] +name = "zeroize" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4756f7db3f7b5574938c3eb1c117038b8e07f95ee6718c0efad4ac21508f1efd" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 000000000..32e10dd3d --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[workspace] + +members = [ + "rust/taskchampion", + "rust/cli", + "rust/sync-server", + "rust/lib", + "rust/integration-tests", + "rust/xtask", +] diff --git a/rust/.cargo/audit.toml b/rust/.cargo/audit.toml new file mode 100644 index 000000000..948236ca9 --- /dev/null +++ b/rust/.cargo/audit.toml @@ -0,0 +1,5 @@ +[advisories] +ignore = [ + "RUSTSEC-2020-0159", # segfault in localtime_r - low risk to TC + "RUSTSEC-2020-0071", # same localtime_r bug as above +] diff --git a/rust/.cargo/config b/rust/.cargo/config new file mode 100644 index 000000000..35049cbcb --- /dev/null +++ b/rust/.cargo/config @@ -0,0 +1,2 @@ +[alias] +xtask = "run --package xtask --" diff --git a/rust/.changelogs/.gitignore b/rust/.changelogs/.gitignore new file mode 100644 index 000000000..e69de29bb diff --git a/rust/.changelogs/2021-10-03-server-storage.md b/rust/.changelogs/2021-10-03-server-storage.md new file mode 100644 index 000000000..7834601d5 --- /dev/null +++ b/rust/.changelogs/2021-10-03-server-storage.md @@ -0,0 +1,2 @@ +- The SQLite server storage schema has changed incompatibly, in order to add support for snapshots. + As this is not currently ready for production usage, no migration path is provided except deleting the existing database. diff --git a/rust/.changelogs/2021-10-11-issue23-client.md b/rust/.changelogs/2021-10-11-issue23-client.md new file mode 100644 index 000000000..91a6b0f9e --- /dev/null +++ b/rust/.changelogs/2021-10-11-issue23-client.md @@ -0,0 +1,2 @@ +- The `avoid_snapshots` configuration value, if set, will cause the replica to + avoid creating snapshots unless required. diff --git a/rust/.changelogs/2021-10-16-issue299.md b/rust/.changelogs/2021-10-16-issue299.md new file mode 100644 index 000000000..a74af24c5 --- /dev/null +++ b/rust/.changelogs/2021-10-16-issue299.md @@ -0,0 +1 @@ +- The encryption format used for synchronization has changed incompatibly diff --git a/rust/.changelogs/2021-10-25-issue23-integration.md b/rust/.changelogs/2021-10-25-issue23-integration.md new file mode 100644 index 000000000..d10a4d0ec --- /dev/null +++ b/rust/.changelogs/2021-10-25-issue23-integration.md @@ -0,0 +1 @@ +- The details of how task start/stop is represented have changed. Any existing tasks will all be treated as inactive (stopped). diff --git a/rust/.gitignore b/rust/.gitignore new file mode 100644 index 000000000..72429aeef --- /dev/null +++ b/rust/.gitignore @@ -0,0 +1 @@ +**/*.rs.bk diff --git a/rust/CHANGELOG.md b/rust/CHANGELOG.md new file mode 100644 index 000000000..91a692b5a --- /dev/null +++ b/rust/CHANGELOG.md @@ -0,0 +1,26 @@ +# Changelog + +## [Unreleased] + +Note: unreleased change log entries are kept in `.changelogs/` directory in repo root, and can be added with `./script/changelog.py add "Added thing for reason" + +## 0.4.1 - 2021-09-24 +- Fix for the build process to include the serde feature "derive". 0.4.0 could not be published to crates.io due to this bug. + +## 0.4.0 - 2021-09-25 +- Breaking: Removed the KV based storage backend in client and server, and replaced with SQLite ([Issue #131](https://github.com/taskchampion/taskchampion/issues/131), [PR #206](https://github.com/taskchampion/taskchampion/pull/206)) + +## 0.3.0 - 2021-01-11 +- Flexible named reports +- Updates to the TaskChampion crate API +- Usability improvements + +## 0.2.0 - 2020-11-30 + +This release is the first "MVP" version of this tool. It can do basic task operations, and supports a synchronization. Major missing features are captured in issues, but briefly: + + better command-line API, similar to TaskWarrior + authentication of the replica / server protocol + encryption of replica data before transmission to the server + lots of task features (tags, annotations, dependencies, ..) + lots of CLI features (filtering, modifying, ..) diff --git a/rust/CODE_OF_CONDUCT.md b/rust/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..807def586 --- /dev/null +++ b/rust/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team at dustin@cs.uchicago.edu. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/rust/CONTRIBUTING.md b/rust/CONTRIBUTING.md new file mode 100644 index 000000000..2adfeba3e --- /dev/null +++ b/rust/CONTRIBUTING.md @@ -0,0 +1,65 @@ +# Welcome + +This application is still in a pre-release state. +That means it's very open to contributions, and we'd love to have your help! + +It also means that things are changing quickly, and lots of stuff is planned that's not quite done yet. +If you would like to work on TaskChampion, please contact the developers (via the issue tracker) before spending a lot of time working on a pull request. +Doing so may save you some wasted time and frustration! + +A good starting point might be one of the issues tagged with ["good first issue"][first]. + +[first]: https://github.com/taskchampion/taskchampion/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22 + +# Other Ways To Help + +The best way to help this project to grow is to help spread awareness of it. +Tell your friends, post to social media, blog about it -- whatever works best! + +Other ideas; + * Improve the documentation where it's unclear or lacking some information + * Build and maintain tools that integrate with TaskChampion + +# Development Guide + +TaskChampion is a typical Rust application. +To work on TaskChampion, you'll need to [install the latest version of Rust](https://www.rust-lang.org/tools/install). +Once you've done that, run `cargo build` at the top level of this repository to build the binaries. +This will build `task` and `taskchampion-sync-server` executables in the `./target/debug` directory. +You can build optimized versions of these binaries with `cargo build --release`, but the performance difference in the resulting binaries is not noticeable, and the build process will take a long time, so this is not recommended. + +## Running Test + +It's always a good idea to make sure tests run before you start hacking on a project. +Run `cargo test` from the top-level of this repository to run the tests. + +## Read the Source + +Aside from that, start reading the docs and the source to learn more! +The book documentation explains lots of the concepts in the design of TaskChampion. +It is linked from the README. + +There are three crates in this repository. +You may be able to limit the scope of what you need to understand to just one crate. + * `taskchampion` is the core functionality of the application, implemented as a library + * `taskchampion-cli` implements the command-line interface (in `cli/`) + * `taskchampion-sync-server` implements the synchronization server (in `sync-server/`) + + You can generate the documentation for the `taskchampion` crate with `cargo doc --release --open -p taskchampion`. + +## Making a Pull Request + +We expect contributors to follow the [GitHub Flow](https://guides.github.com/introduction/flow/). +Aside from that, we have no particular requirements on pull requests. +Make your patch, double-check that it's complete (tests? docs? documentation comments?), and make a new pull request. + +Any non-trivial change (particularly those that change the behaviour of the application, or change the API) should be noted in the projects changelog. +In order to manage this, changelog entries are stored as text files in the `.changelog/` directory at the repository root. + +To add a new changelog entry, you can simply run `python3 ./script/changelog.py add "Fixed thingo to increase zorbloxification [Issue #2](http://example.com)` + +This creates a file named `./changelogs/yyyy-mm-dd-branchname.md` (timestamp, current git branch) which contains a markdown snippet. + +If you don't have a Python 3 intepreter installed, you can simply create this file manually. It should contain a list item like `- Fixed thingo [...]` + +Periodically (probably just before release), these changelog entries are concatenated combined together and added into the `CHANGELOG.md` file. diff --git a/rust/LICENSE b/rust/LICENSE new file mode 100644 index 000000000..f0c9756e1 --- /dev/null +++ b/rust/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Dustin J. Mitchell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/rust/POLICY.md b/rust/POLICY.md new file mode 100644 index 000000000..3d84cbb82 --- /dev/null +++ b/rust/POLICY.md @@ -0,0 +1,45 @@ +# Compatibility & deprecation + +Until TaskChampion reaches [v1.0.0](https://github.com/taskchampion/taskchampion/milestone/7), nothing is set in stone. That being said, we aim for the following: + +1. Major versions represent significant change and may be incompatible with previous major release. +2. Minor versions are always backwards compatible and might add some new functionality. +3. Patch versions should not introduce any new functionality and do what name implies — fix bugs. + +As there are no major releases yet, we do not support any older versions. Users are encouraged to use the latest release. + +## ABI policy + +1. We target stable `rustc`. +2. TaskChampion will never upgrade any storage to a non-compatible version without explicit user's request. + +## API policy + +1. Deprecated features return a warning at least 1 minor version prior to being removed. + + Example: + + > If support of `--bar` is to be dropped in v2.0.0, we shall announce it in v1.9.0 at latest. + +2. We aim to issue a notice of newly added functionality when appropriate. + + Example: + + > "NOTICE: Since v1.1.0 you can use `--foo` in conjunction with `--bar`. Foobar!" + +3. TaskChampion always uses UTF-8. + +## Command-line interface + +Considered to be part of the API policy. + +## CLI exit codes + +- `0` - No errors, normal exit. +- `1` - Generic error. +- `2` - Never used to avoid conflicts with Bash. +- `3` - Command-line Syntax Error. + +# Security + +See [SECURITY.md](./SECURITY.md). diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 000000000..bc076c792 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,58 @@ +TaskChampion +------------ + +TaskChampion is an open-source personal task-tracking application. +Use it to keep track of what you need to do, with a quick command-line interface and flexible sorting and filtering. +It is modeled on [TaskWarrior](https://taskwarrior.org), but not a drop-in replacement for that application. + +See the [documentation](https://taskchampion.github.io/taskchampion/) for more! + +## Status + +TaskChampion currently functions as a "testbed" for new functionality that may later be incorporated into TaskWarrior. +It can be developed without the requirements of compatibliity, allowing us to explore and fix edge-cases in things like the replica-synchronization model. + +While you are welcome to [help out](https://github.com/taskchampion/taskchampion/blob/main/CONTRIBUTING.md), you should do so with the awareness that your work might never be used. +But, if you just want to get some practice with Rust, we'd be happy to have you. + +## Structure + +There are five crates here: + + * [taskchampion](./taskchampion) - the core of the tool + * [taskchampion-cli](./cli) - the command-line binary + * [taskchampion-sync-server](./sync-server) - the server against which `task sync` operates + * [taskchampion-lib](./lib) - glue code to use _taskchampion_ from C + * [integration-tests](./integration-tests) - integration tests covering _taskchampion-cli_, _taskchampion-sync-server_, and _taskchampion-lib_. + +## Code Generation + +The _taskchampion_lib_ crate uses a bit of code generation to create the `lib/taskchampion.h` header file. +To regenerate this file, run `cargo xtask codegen`. + +## C libraries + +NOTE: support for linking against taskchampion is a work in progress. +Contributions and pointers to best practices are appreciated! + +The `taskchampion-lib` crate generates libraries suitable for use from C (or any C-compatible language). + +The necessary bits are: + +* a shared object in `target/$PROFILE/deps` (e.g., `target/debug/deps/libtaskchampion.so`) +* a static library in `target/$PROFILE` (e.g., `target/debug/libtaskchampion.a`) +* a header file, `lib/taskchampion.h`. + +Downstream consumers may use either the static or dynamic library, as they prefer. + +NOTE: on Windows, the "BCrypt" library must be included when linking to taskchampion. + +### As a Rust dependency + +If you would prefer to build Taskchampion directly into your project, and have a build system capable of building Rust libraries (such as CMake), the `taskchampion-lib` crate can be referenced as an `rlib` dependency. + +## Documentation Generation + +The `mdbook` configuration contains a "preprocessor" implemented in the `taskchampion-cli` crate in order to reflect CLI usage information into the generated book. +This preprocessor is not built by default. +To (re)build it, run `cargo build -p taskchampion-cli --features usage-docs --bin usage-docs`. diff --git a/rust/RELEASING.md b/rust/RELEASING.md new file mode 100644 index 000000000..b60ab7a22 --- /dev/null +++ b/rust/RELEASING.md @@ -0,0 +1,17 @@ +# Release process + +1. Ensure the changelog is updated with everything from the `.changelogs` directory. `python3 ./scripts/changelog.py build` will output a Markdown snippet to include in `CHANGELOG.md` then `rm .changelog/*.txt` +1. Run `git pull upstream main` +1. Run `cargo test` +1. Run `cargo clean && cargo clippy` +1. Run `mdbook test docs` +1. Update `version` in `*/Cargo.toml`. All versions should match. +1. Run `cargo build --release` +1. Commit the changes (Cargo.lock will change too) with comment `vX.Y.Z`. +1. Run `git tag vX.Y.Z` +1. Run `git push upstream` +1. Run `git push --tags upstream` +1. Run `( ./build-docs.sh )` +1. Run `(cd taskchampion; cargo publish)` (note that the other crates do not get published) +1. Navigate to the tag in the GitHub releases UI and create a release with general comments about the changes in the release +1. Upload `./target/release/task` and `./target/release/task-sync-server` to the release diff --git a/rust/SECURITY.md b/rust/SECURITY.md new file mode 100644 index 000000000..9d8d975d9 --- /dev/null +++ b/rust/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/rust/build-docs.sh b/rust/build-docs.sh new file mode 100755 index 000000000..f693349c7 --- /dev/null +++ b/rust/build-docs.sh @@ -0,0 +1,36 @@ +#! /bin/bash + +set -x + +REMOTE=origin + +set -e + +if ! [ -f "docs/src/SUMMARY.md" ]; then + echo "Run this from the root of the repo" + exit 1 +fi + +# build the latest version of the mdbook plugin +cargo build -p taskchampion-cli --features usage-docs --bin usage-docs + +# create a worktree of this repo, with the `gh-pages` branch checked out +git branch -f gh-pages $REMOTE/gh-pages +if ! [ -d ./docs/tmp ]; then + git worktree add -f docs/tmp gh-pages +fi + +# update the wortree +(cd docs/tmp && git pull $REMOTE gh-pages) + +# remove all files in the worktree and regenerate the book there +git worktree remove -f docs/tmp +rm -rf docs/tmp/* +mdbook build docs +mkdir docs/tmp +cp -rp docs/book/* docs/tmp + +# add everything in the worktree, commit, and push +(cd docs/tmp && git add -A) +(cd docs/tmp && git commit -am "update docs") +(cd docs/tmp && git push $REMOTE gh-pages:gh-pages) diff --git a/rust/cli/Cargo.toml b/rust/cli/Cargo.toml new file mode 100644 index 000000000..621b004b5 --- /dev/null +++ b/rust/cli/Cargo.toml @@ -0,0 +1,57 @@ +[package] +authors = ["Dustin J. Mitchell "] +edition = "2018" +name = "taskchampion-cli" +version = "0.4.1" + +build = "build.rs" + +# Run 'ta' when doing 'cargo run' at repo root +default-run = "ta" + +[dependencies] +dirs-next = "^2.0.0" +env_logger = "^0.8.3" +anyhow = "1.0" +thiserror = "1.0" +log = "^0.4.14" +nom = "^6.1.2" +prettytable-rs = "^0.8.0" +textwrap = { version="^0.13.4", features=["terminal_size"] } +termcolor = "^1.1.2" +atty = "^0.2.14" +toml = "^0.5.8" +toml_edit = "^0.2.0" +serde = { version = "^1.0.125", features = ["derive"] } +serde_json = "^1.0" +lazy_static = "1" +iso8601-duration = "0.1" +dialoguer = "0.8" + +# only needed for usage-docs +# if the mdbook version changes, change it in .github/workflows/publish-docs.yml and .github/workflows/checks.yml as well +mdbook = { version = "0.4.10", optional = true } + +[dependencies.taskchampion] +path = "../taskchampion" + +[build-dependencies] +built = { version = "0.5", features = ["git2"] } + +[dev-dependencies] +assert_cmd = "^1.0.3" +predicates = "^1.0.7" +tempfile = "3" +rstest = "0.10" +pretty_assertions = "1" + +[features] +usage-docs = [ "mdbook" ] + +[[bin]] +name = "ta" + +[[bin]] +# this is an mdbook plugin and only needed when running `mdbook` +name = "usage-docs" +required-features = [ "usage-docs" ] diff --git a/rust/cli/build.rs b/rust/cli/build.rs new file mode 100644 index 000000000..d8f91cb91 --- /dev/null +++ b/rust/cli/build.rs @@ -0,0 +1,3 @@ +fn main() { + built::write_built_file().expect("Failed to acquire build-time information"); +} diff --git a/rust/cli/src/argparse/args/arg_matching.rs b/rust/cli/src/argparse/args/arg_matching.rs new file mode 100644 index 000000000..a95e4ec1e --- /dev/null +++ b/rust/cli/src/argparse/args/arg_matching.rs @@ -0,0 +1,61 @@ +use crate::argparse::ArgList; +use nom::{ + error::{Error, ErrorKind}, + Err, IResult, +}; + +/// Consume a single argument from an argument list that matches the given string parser (one +/// of the other functions in this module). The given parser must consume the entire input. +pub(crate) fn arg_matching<'a, O, F>(f: F) -> impl Fn(ArgList<'a>) -> IResult +where + F: Fn(&'a str) -> IResult<&'a str, O>, +{ + move |input: ArgList<'a>| { + if let Some(arg) = input.get(0) { + return match f(arg) { + Ok(("", rv)) => Ok((&input[1..], rv)), + // single-arg parsers must consume the entire arg, so consider unconsumed + // output to be an error. + Ok((_, _)) => Err(Err::Error(Error { + input, + code: ErrorKind::Eof, + })), + // single-arg parsers are all complete parsers + Err(Err::Incomplete(_)) => unreachable!(), + // for error and failure, rewrite to an error at this position in the arugment list + Err(Err::Error(Error { input: _, code })) => Err(Err::Error(Error { input, code })), + Err(Err::Failure(Error { input: _, code })) => { + Err(Err::Failure(Error { input, code })) + } + }; + } + + Err(Err::Error(Error { + input, + // since we're using nom's built-in Error, our choices here are limited, but tihs + // occurs when there's no argument where one is expected, so Eof seems appropriate + code: ErrorKind::Eof, + })) + } +} + +#[cfg(test)] +mod test { + use super::super::*; + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_arg_matching() { + assert_eq!( + arg_matching(plus_tag)(argv!["+foo", "bar"]).unwrap(), + (argv!["bar"], tag!("foo")) + ); + assert!(arg_matching(plus_tag)(argv!["foo", "bar"]).is_err()); + } + + #[test] + fn test_partial_arg_matching() { + assert!(arg_matching(wait_colon)(argv!["wait:UNRECOGNIZED"]).is_err()); + } +} diff --git a/rust/cli/src/argparse/args/colon.rs b/rust/cli/src/argparse/args/colon.rs new file mode 100644 index 000000000..bca014081 --- /dev/null +++ b/rust/cli/src/argparse/args/colon.rs @@ -0,0 +1,98 @@ +use super::{any, id_list, timestamp, TaskId}; +use crate::argparse::NOW; +use nom::bytes::complete::tag as nomtag; +use nom::{branch::*, character::complete::*, combinator::*, sequence::*, IResult}; +use taskchampion::chrono::prelude::*; +use taskchampion::Status; + +/// Recognizes up to the colon of the common `:...` 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) +} + +/// Recognizes `depends:` to `(true, )` and `depends:-` to `(false, )`. +pub(crate) fn depends_colon(input: &str) -> IResult<&str, (bool, Vec)> { + fn to_bool(maybe_minus: Option) -> Result { + Ok(maybe_minus.is_none()) // None -> true, Some -> false + } + preceded( + nomtag("depends:"), + pair(map_res(opt(char('-')), to_bool), id_list), + )(input) +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + use taskchampion::chrono::Duration; + + #[test] + fn test_colon_prefix() { + assert_eq!(colon_prefix("foo")("foo:abc").unwrap().1, "abc"); + assert_eq!(colon_prefix("foo")("foo:").unwrap().1, ""); + assert!(colon_prefix("foo")("foo").is_err()); + } + + #[test] + fn test_status_colon() { + assert_eq!(status_colon("status:pending").unwrap().1, Status::Pending); + assert_eq!( + status_colon("status:completed").unwrap().1, + Status::Completed + ); + assert_eq!(status_colon("status:deleted").unwrap().1, Status::Deleted); + assert!(status_colon("status:foo").is_err()); + assert!(status_colon("status:complete").is_err()); + assert!(status_colon("status").is_err()); + } + + #[test] + fn test_wait() { + assert_eq!(wait_colon("wait:").unwrap(), ("", None)); + + let one_day = *NOW + Duration::days(1); + assert_eq!(wait_colon("wait:1d").unwrap(), ("", Some(one_day))); + + let one_day = *NOW + Duration::days(1); + assert_eq!(wait_colon("wait:1d2").unwrap(), ("2", Some(one_day))); + } +} diff --git a/rust/cli/src/argparse/args/idlist.rs b/rust/cli/src/argparse/args/idlist.rs new file mode 100644 index 000000000..a7ea71e0e --- /dev/null +++ b/rust/cli/src/argparse/args/idlist.rs @@ -0,0 +1,140 @@ +use nom::{branch::*, character::complete::*, combinator::*, multi::*, sequence::*, IResult}; +use taskchampion::Uuid; + +/// A task identifier, as given in a filter command-line expression +#[derive(Debug, PartialEq, Eq, Hash, Clone)] +pub(crate) enum TaskId { + /// A small integer identifying a working-set task + WorkingSetId(usize), + + /// A full Uuid specifically identifying a task + Uuid(Uuid), + + /// A prefix of a Uuid + PartialUuid(String), +} + +/// Recognizes a comma-separated list of TaskIds +pub(crate) fn id_list(input: &str) -> IResult<&str, Vec> { + 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::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_id_list_single() { + assert_eq!(id_list("123").unwrap().1, vec![TaskId::WorkingSetId(123)]); + } + + #[test] + fn test_id_list_uuids() { + assert_eq!( + id_list("12341234").unwrap().1, + vec![TaskId::PartialUuid(s!("12341234"))] + ); + assert_eq!( + id_list("1234abcd").unwrap().1, + vec![TaskId::PartialUuid(s!("1234abcd"))] + ); + assert_eq!( + id_list("abcd1234").unwrap().1, + vec![TaskId::PartialUuid(s!("abcd1234"))] + ); + assert_eq!( + id_list("abcd1234-1234").unwrap().1, + vec![TaskId::PartialUuid(s!("abcd1234-1234"))] + ); + assert_eq!( + id_list("abcd1234-1234-2345").unwrap().1, + vec![TaskId::PartialUuid(s!("abcd1234-1234-2345"))] + ); + assert_eq!( + id_list("abcd1234-1234-2345-3456").unwrap().1, + vec![TaskId::PartialUuid(s!("abcd1234-1234-2345-3456"))] + ); + assert_eq!( + id_list("abcd1234-1234-2345-3456-0123456789ab").unwrap().1, + vec![TaskId::Uuid( + Uuid::parse_str("abcd1234-1234-2345-3456-0123456789ab").unwrap() + )] + ); + } + + #[test] + fn test_id_list_invalid_partial_uuids() { + assert!(id_list("abcd123").is_err()); + assert!(id_list("abcd12345").is_err()); + assert!(id_list("abcd1234-").is_err()); + assert!(id_list("abcd1234-123").is_err()); + assert!(id_list("abcd1234-1234-").is_err()); + assert!(id_list("abcd1234-12345-").is_err()); + assert!(id_list("abcd1234-1234-2345-3456-0123456789ab-").is_err()); + } + + #[test] + fn test_id_list_uuids_mixed() { + assert_eq!(id_list("abcd1234,abcd1234-1234,abcd1234-1234-2345,abcd1234-1234-2345-3456,abcd1234-1234-2345-3456-0123456789ab").unwrap().1, + vec![TaskId::PartialUuid(s!("abcd1234")), + TaskId::PartialUuid(s!("abcd1234-1234")), + TaskId::PartialUuid(s!("abcd1234-1234-2345")), + TaskId::PartialUuid(s!("abcd1234-1234-2345-3456")), + TaskId::Uuid(Uuid::parse_str("abcd1234-1234-2345-3456-0123456789ab").unwrap()), + ]); + } +} diff --git a/rust/cli/src/argparse/args/misc.rs b/rust/cli/src/argparse/args/misc.rs new file mode 100644 index 000000000..5cb957f10 --- /dev/null +++ b/rust/cli/src/argparse/args/misc.rs @@ -0,0 +1,42 @@ +use nom::bytes::complete::tag as nomtag; +use nom::{character::complete::*, combinator::*, sequence::*, IResult}; + +/// Recognizes any argument +pub(crate) fn any(input: &str) -> IResult<&str, &str> { + rest(input) +} + +/// Recognizes a report name +pub(crate) fn report_name(input: &str) -> IResult<&str, &str> { + all_consuming(recognize(pair(alpha1, alphanumeric0)))(input) +} + +/// Recognizes a literal string +pub(crate) fn literal(literal: &'static str) -> impl Fn(&str) -> IResult<&str, &str> { + move |input: &str| all_consuming(nomtag(literal))(input) +} + +#[cfg(test)] +mod test { + use super::super::*; + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_arg_matching() { + assert_eq!( + arg_matching(plus_tag)(argv!["+foo", "bar"]).unwrap(), + (argv!["bar"], tag!("foo")) + ); + assert!(arg_matching(plus_tag)(argv!["foo", "bar"]).is_err()); + } + + #[test] + fn test_literal() { + assert_eq!(literal("list")("list").unwrap().1, "list"); + assert!(literal("list")("listicle").is_err()); + assert!(literal("list")(" list ").is_err()); + assert!(literal("list")("LiSt").is_err()); + assert!(literal("list")("denylist").is_err()); + } +} diff --git a/rust/cli/src/argparse/args/mod.rs b/rust/cli/src/argparse/args/mod.rs new file mode 100644 index 000000000..b9c20e9cd --- /dev/null +++ b/rust/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::{depends_colon, status_colon, wait_colon}; +pub(crate) use idlist::{id_list, TaskId}; +pub(crate) use misc::{any, literal, report_name}; +pub(crate) use tags::{minus_tag, plus_tag}; +#[allow(unused_imports)] +pub(crate) use time::{duration, timestamp}; diff --git a/rust/cli/src/argparse/args/tags.rs b/rust/cli/src/argparse/args/tags.rs new file mode 100644 index 000000000..1dbea5ee2 --- /dev/null +++ b/rust/cli/src/argparse/args/tags.rs @@ -0,0 +1,35 @@ +use nom::{character::complete::*, combinator::*, sequence::*, IResult}; +use std::convert::TryFrom; +use taskchampion::Tag; + +/// Recognizes a tag prefixed with `+` and returns the tag value +pub(crate) fn plus_tag(input: &str) -> IResult<&str, Tag> { + preceded(char('+'), map_res(rest, Tag::try_from))(input) +} + +/// Recognizes a tag prefixed with `-` and returns the tag value +pub(crate) fn minus_tag(input: &str) -> IResult<&str, Tag> { + preceded(char('-'), map_res(rest, Tag::try_from))(input) +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_plus_tag() { + assert_eq!(plus_tag("+abc").unwrap().1, tag!("abc")); + assert_eq!(plus_tag("+abc123").unwrap().1, tag!("abc123")); + assert!(plus_tag("-abc123").is_err()); + assert!(plus_tag("+1abc").is_err()); + } + + #[test] + fn test_minus_tag() { + assert_eq!(minus_tag("-abc").unwrap().1, tag!("abc")); + assert_eq!(minus_tag("-abc123").unwrap().1, tag!("abc123")); + assert!(minus_tag("+abc123").is_err()); + assert!(minus_tag("-1abc").is_err()); + } +} diff --git a/rust/cli/src/argparse/args/time.rs b/rust/cli/src/argparse/args/time.rs new file mode 100644 index 000000000..4848e3888 --- /dev/null +++ b/rust/cli/src/argparse/args/time.rs @@ -0,0 +1,492 @@ +use iso8601_duration::Duration as IsoDuration; +use lazy_static::lazy_static; +use nom::{ + branch::*, + bytes::complete::*, + character::complete::*, + character::*, + combinator::*, + error::{Error, ErrorKind}, + multi::*, + sequence::*, + Err, IResult, +}; +use std::str::FromStr; +use taskchampion::chrono::{self, prelude::*, Duration}; + +// https://taskwarrior.org/docs/dates.html +// https://taskwarrior.org/docs/named_dates.html +// https://taskwarrior.org/docs/durations.html + +/// A case for matching durations. If `.3` is true, then the value can be used +/// without a prefix, e.g., `minute`. If false, it cannot, e.g., `minutes` +#[derive(Debug)] +struct DurationCase(&'static str, Duration, bool); + +// https://github.com/GothenburgBitFactory/libshared/blob/9a5f24e2acb38d05afb8f8e316a966dee196a42a/src/Duration.cpp#L50 +// TODO: use const when chrono supports it +lazy_static! { + static ref DURATION_CASES: Vec = 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()..]; + let day_index = local_today.weekday().num_days_from_monday(); + match input { + "yesterday" => Ok((remaining, local_today - Duration::days(1))), + "today" => Ok((remaining, local_today)), + "tomorrow" => Ok((remaining, local_today + Duration::days(1))), + // TODO: lots more! + "eod" => Ok((remaining, local_today + Duration::days(1))), + "sod" => Ok((remaining, local_today)), + "eow" => Ok(( + remaining, + local_today + Duration::days((6 - day_index).into()), + )), + "eoww" => Ok(( + remaining, + local_today + Duration::days((5 - day_index).into()), + )), + "sow" => Ok(( + remaining, + local_today + Duration::days((6 - day_index).into()), + )), + "soww" => Ok(( + remaining, + local_today + Duration::days((7 - day_index).into()), + )), + _ => Err(Err::Error(Error::new(input, ErrorKind::Tag))), + } + .map(|(rem, dt)| (rem, dt.and_hms(0, 0, 0).with_timezone(&Utc))) + } +} + +/// recognize a digit +fn digit(input: &str) -> IResult<&str, char> { + satisfy(|c| is_digit(c as u8))(input) +} + +/// Parse yyyy-mm-dd as the given date, at the local midnight +fn yyyy_mm_dd(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 pretty_assertions::assert_eq; + use rstest::rstest; + + const M: i64 = 60; + const H: i64 = M * 60; + const DAY: i64 = H * 24; + const MONTH: i64 = DAY * 30; + const YEAR: i64 = DAY * 365; + + // TODO: use const when chrono supports it + lazy_static! { + // India standard time (not an even multiple of hours) + static ref IST: FixedOffset = FixedOffset::east(5 * 3600 + 30 * 60); + // Utc, but as a FixedOffset TimeZone impl + static ref UTC_FO: FixedOffset = FixedOffset::east(0); + // Hawaii + static ref HST: FixedOffset = FixedOffset::west(10 * 3600); + } + + /// test helper to ensure that the entire input is consumed + fn complete_duration(input: &str) -> IResult<&str, Duration> { + all_consuming(duration)(input) + } + + /// test helper to ensure that the entire input is consumed + fn complete_timestamp( + 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))] + #[case::end_of_week(ld(2021, 8, 25,), "eow", ld(2021, 8, 29))] + #[case::end_of_work_week(ld(2021, 8, 25), "eoww", ld(2021, 8, 28))] + #[case::start_of_week(ld(2021, 8, 25), "sow", ld(2021, 8, 29))] + #[case::start_of_work_week(ld(2021, 8, 25), "soww", ld(2021, 8, 30))] + #[case::end_of_today(ld(2021, 8, 25), "eod", ld(2021, 8, 26))] + #[case::start_of_today(ld(2021, 8, 25), "sod", ld(2021, 8, 25))] + fn test_local_timestamp( + #[case] now: Box 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/rust/cli/src/argparse/command.rs b/rust/cli/src/argparse/command.rs new file mode 100644 index 000000000..712114f21 --- /dev/null +++ b/rust/cli/src/argparse/command.rs @@ -0,0 +1,86 @@ +use super::args::*; +use super::{ArgList, Subcommand}; +use nom::{combinator::*, sequence::*, Err, IResult}; + +/// A command is the overall command that the CLI should execute. +/// +/// It consists of some information common to all commands and a `Subcommand` identifying the +/// particular kind of behavior desired. +#[derive(Debug, PartialEq)] +pub(crate) struct Command { + pub(crate) command_name: String, + pub(crate) subcommand: Subcommand, +} + +impl Command { + pub(super) fn parse(input: ArgList) -> IResult { + 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, + subcommand: input.1, + }; + Ok(command) + } + map_res( + all_consuming(tuple((arg_matching(any), Subcommand::parse))), + to_command, + )(input) + } + + /// Parse a command from the given list of strings. + pub fn from_argv(argv: &[&str]) -> Result { + match Command::parse(argv) { + Ok((&[], cmd)) => Ok(cmd), + Ok((trailing, _)) => Err(crate::Error::for_arguments(format!( + "command line has trailing arguments: {:?}", + trailing + ))), + Err(Err::Incomplete(_)) => unreachable!(), + Err(Err::Error(e)) => Err(crate::Error::for_arguments(format!( + "command line not recognized: {:?}", + e + ))), + Err(Err::Failure(e)) => Err(crate::Error::for_arguments(format!( + "command line not recognized: {:?}", + e + ))), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + + // NOTE: most testing of specific subcommands is handled in `subcommand.rs`. + + #[test] + fn test_version() { + assert_eq!( + Command::from_argv(argv!["ta", "version"]).unwrap(), + Command { + subcommand: Subcommand::Version, + command_name: s!("ta"), + } + ); + } + + #[test] + fn test_cleaning_command_name() { + assert_eq!( + Command::from_argv(argv!["/tmp/ta", "version"]).unwrap(), + Command { + subcommand: Subcommand::Version, + command_name: s!("ta"), + } + ); + } +} diff --git a/rust/cli/src/argparse/config.rs b/rust/cli/src/argparse/config.rs new file mode 100644 index 000000000..209000a4e --- /dev/null +++ b/rust/cli/src/argparse/config.rs @@ -0,0 +1,44 @@ +use super::args::{any, arg_matching, literal}; +use super::ArgList; +use crate::usage; +use nom::{branch::alt, combinator::*, sequence::*, IResult}; + +#[derive(Debug, PartialEq)] +/// A config operation +pub(crate) enum ConfigOperation { + /// Set a configuration value + Set(String, String), + /// Show configuration path + Path, +} + +impl ConfigOperation { + pub(super) fn parse(input: ArgList) -> IResult { + fn set_to_op(input: (&str, &str, &str)) -> Result { + Ok(ConfigOperation::Set(input.1.to_owned(), input.2.to_owned())) + } + 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) { + u.subcommands.push(usage::Subcommand { + name: "config set", + syntax: "config set ", + summary: "Set a configuration value", + description: "Update Taskchampion configuration file to set key = value", + }); + } +} diff --git a/rust/cli/src/argparse/filter.rs b/rust/cli/src/argparse/filter.rs new file mode 100644 index 000000000..2895c03b7 --- /dev/null +++ b/rust/cli/src/argparse/filter.rs @@ -0,0 +1,400 @@ +use super::args::{arg_matching, id_list, literal, minus_tag, plus_tag, status_colon, TaskId}; +use super::ArgList; +use crate::usage; +use anyhow::bail; +use nom::{ + branch::alt, + combinator::*, + multi::{fold_many0, fold_many1}, + IResult, +}; +use taskchampion::{Status, Tag}; + +/// A filter represents a selection of a particular set of tasks. +/// +/// A filter has a "universe" of tasks that might match, and a list of conditions +/// all of which tasks must match. The universe can be a set of task IDs, or just +/// pending tasks, or all tasks. +#[derive(Debug, PartialEq, Default, Clone)] +pub(crate) struct Filter { + /// A set of filter conditions, all of which must match a task in order for that task to be + /// selected. + pub(crate) conditions: Vec, +} + +/// A condition which tasks must match to be accepted by the filter. +#[derive(Debug, PartialEq, Clone)] +pub(crate) enum Condition { + /// Task has the given tag + HasTag(Tag), + + /// Task does not have the given tag + NoTag(Tag), + + /// Task has the given status + Status(Status), + + /// Task has one of the given IDs + IdList(Vec), +} + +impl Condition { + fn parse(input: ArgList) -> IResult { + alt(( + Self::parse_id_list, + Self::parse_plus_tag, + Self::parse_minus_tag, + Self::parse_status, + ))(input) + } + + /// Parse a single condition string + pub(crate) fn parse_str(input: &str) -> anyhow::Result { + let input = &[input]; + Ok(match Condition::parse(input) { + Ok((&[], cond)) => cond, + Ok(_) => unreachable!(), // input only has one element + Err(nom::Err::Incomplete(_)) => unreachable!(), + Err(nom::Err::Error(e)) => bail!("invalid filter condition: {:?}", e), + Err(nom::Err::Failure(e)) => bail!("invalid filter condition: {:?}", e), + }) + } + + fn parse_id_list(input: ArgList) -> IResult { + fn to_condition(input: Vec) -> Result { + Ok(Condition::IdList(input)) + } + map_res(arg_matching(id_list), to_condition)(input) + } + + fn parse_plus_tag(input: ArgList) -> IResult { + 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: Tag) -> Result { + Ok(Condition::NoTag(input)) + } + map_res(arg_matching(minus_tag), to_condition)(input) + } + + fn parse_status(input: ArgList) -> IResult { + fn to_condition(input: Status) -> Result { + Ok(Condition::Status(input)) + } + map_res(arg_matching(status_colon), to_condition)(input) + } +} + +impl Filter { + /// Parse a filter that can include an empty set of args (meaning + /// all tasks) + pub(super) fn parse0(input: ArgList) -> IResult { + fold_many0( + Condition::parse, + Filter { + ..Default::default() + }, + |acc, arg| acc.with_arg(arg), + )(input) + } + + /// Parse a filter that must have at least one arg, which can be `all` + /// to mean all tasks + pub(super) fn parse1(input: ArgList) -> IResult { + 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 { + // If there is already an IdList condition, concatenate this one + // to it. Thus multiple IdList command-line args represent an OR + // operation. This assumes that the filter is still being built + // from command-line arguments and thus has at most one IdList + // condition. + if let Some(Condition::IdList(existing)) = self + .conditions + .iter_mut() + .find(|c| matches!(c, Condition::IdList(_))) + { + existing.append(&mut id_list); + } else { + self.conditions.push(Condition::IdList(id_list)); + } + } else { + // all other command-line conditions are AND'd together + self.conditions.push(cond); + } + self + } + + /// combine this filter with another filter in an AND operation + pub(crate) fn intersect(mut self, mut other: Filter) -> Filter { + // simply concatenate the conditions + self.conditions.append(&mut other.conditions); + + self + } + + // usage + + pub(super) fn get_usage(u: &mut usage::Usage) { + u.filters.push(usage::Filter { + syntax: "TASKID[,TASKID,..]", + summary: "Specific tasks", + description: " + Select only specific tasks. Multiple tasks can be specified either separated by + commas or as separate arguments. Each task may be specfied by its working-set + index (a small number) or by its UUID. Partial UUIDs, broken on a hyphen, are + also supported, such as `b5664ef8-423d` or `b5664ef8`.", + }); + u.filters.push(usage::Filter { + syntax: "+TAG", + summary: "Tagged tasks", + description: " + Select tasks with the given tag.", + }); + u.filters.push(usage::Filter { + syntax: "-TAG", + summary: "Un-tagged tasks", + description: " + Select tasks that do not have the given tag.", + }); + u.filters.push(usage::Filter { + syntax: "status:pending, status:completed, status:deleted", + summary: "Task status", + description: " + Select tasks with the given status.", + }); + u.filters.push(usage::Filter { + syntax: "all", + summary: "All tasks", + description: " + When specified alone for task-modification commands, `all` matches all tasks. + For example, `task all done` will mark all tasks as done.", + }); + } +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_empty_parse0() { + let (input, filter) = Filter::parse0(argv![]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + filter, + Filter { + ..Default::default() + } + ); + } + + #[test] + fn test_empty_parse1() { + // parse1 does not allow empty input + assert!(Filter::parse1(argv![]).is_err()); + } + + #[test] + fn test_all_parse0() { + let (input, _) = Filter::parse0(argv!["all"]).unwrap(); + assert_eq!(input.len(), 1); // did not parse "all" + } + + #[test] + fn test_all_parse1() { + let (input, filter) = Filter::parse1(argv!["all"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + filter, + Filter { + ..Default::default() + } + ); + } + + #[test] + fn test_all_with_other_stuff() { + let (input, filter) = Filter::parse1(argv!["all", "+foo"]).unwrap(); + // filter ends after `all` + assert_eq!(input.len(), 1); + assert_eq!( + filter, + Filter { + ..Default::default() + } + ); + } + + #[test] + fn test_id_list_single() { + let (input, filter) = Filter::parse0(argv!["1"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + filter, + Filter { + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(1)])], + } + ); + } + + #[test] + fn test_id_list_commas() { + let (input, filter) = Filter::parse0(argv!["1,2,3"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + filter, + Filter { + conditions: vec![Condition::IdList(vec![ + TaskId::WorkingSetId(1), + TaskId::WorkingSetId(2), + TaskId::WorkingSetId(3), + ])], + } + ); + } + + #[test] + fn test_id_list_multi_arg() { + let (input, filter) = Filter::parse0(argv!["1,2", "3,4"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + filter, + Filter { + conditions: vec![Condition::IdList(vec![ + TaskId::WorkingSetId(1), + TaskId::WorkingSetId(2), + TaskId::WorkingSetId(3), + TaskId::WorkingSetId(4), + ])], + } + ); + } + + #[test] + fn test_id_list_uuids() { + let (input, filter) = Filter::parse0(argv!["1,abcd1234"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + filter, + Filter { + conditions: vec![Condition::IdList(vec![ + TaskId::WorkingSetId(1), + TaskId::PartialUuid(s!("abcd1234")), + ])], + } + ); + } + + #[test] + fn test_tags() { + let (input, filter) = Filter::parse0(argv!["1", "+yes", "-no"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + filter, + Filter { + conditions: vec![ + Condition::IdList(vec![TaskId::WorkingSetId(1),]), + Condition::HasTag(tag!("yes")), + Condition::NoTag(tag!("no")), + ], + } + ); + } + + #[test] + fn test_status() { + let (input, filter) = Filter::parse0(argv!["status:completed", "status:pending"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + filter, + Filter { + conditions: vec![ + Condition::Status(Status::Completed), + Condition::Status(Status::Pending), + ], + } + ); + } + + #[test] + fn intersect_idlist_idlist() { + let left = Filter::parse0(argv!["1,2", "+yes"]).unwrap().1; + let right = Filter::parse0(argv!["2,3", "+no"]).unwrap().1; + let both = left.intersect(right); + assert_eq!( + both, + Filter { + conditions: vec![ + // from first filter + Condition::IdList(vec![TaskId::WorkingSetId(1), TaskId::WorkingSetId(2),]), + Condition::HasTag(tag!("yes")), + // from second filter + Condition::IdList(vec![TaskId::WorkingSetId(2), TaskId::WorkingSetId(3)]), + Condition::HasTag(tag!("no")), + ], + } + ); + } + + #[test] + fn intersect_idlist_alltasks() { + let left = Filter::parse0(argv!["1,2", "+yes"]).unwrap().1; + let right = Filter::parse0(argv!["+no"]).unwrap().1; + let both = left.intersect(right); + assert_eq!( + both, + Filter { + conditions: vec![ + // from first filter + Condition::IdList(vec![TaskId::WorkingSetId(1), TaskId::WorkingSetId(2),]), + Condition::HasTag(tag!("yes")), + // from second filter + Condition::HasTag(tag!("no")), + ], + } + ); + } + + #[test] + fn intersect_alltasks_alltasks() { + let left = Filter::parse0(argv!["+yes"]).unwrap().1; + let right = Filter::parse0(argv!["+no"]).unwrap().1; + let both = left.intersect(right); + assert_eq!( + both, + Filter { + conditions: vec![ + Condition::HasTag(tag!("yes")), + Condition::HasTag(tag!("no")), + ], + } + ); + } +} diff --git a/rust/cli/src/argparse/mod.rs b/rust/cli/src/argparse/mod.rs new file mode 100644 index 000000000..678c9424e --- /dev/null +++ b/rust/cli/src/argparse/mod.rs @@ -0,0 +1,48 @@ +// Nested functions that always return Ok(..) are used as callbacks in a context where a Result is +// expected, so the unnecessary_wraps clippy lint is not useful here. + +#![allow(clippy::unnecessary_wraps)] + +/*! + +This module is responsible for parsing command lines (`Arglist`, an alias for `&[&str]`) into `Command` instances. +It removes some redundancy from the command line, for example combining the multiple ways to modify a task into a single `Modification` struct. + +The module is organized as a nom parser over ArgList, and each struct has a `parse` method to parse such a list. + +The exception to this rule is the `args` sub-module, which contains string parsers that are applied to indivdual command-line elements. + +All of the structs produced by this module are fully-owned, data-only structs. +That is, they contain no references, and have no methods to aid in their execution -- that is the `invocation` module's job. + +*/ +mod args; +mod command; +mod config; +mod filter; +mod modification; +mod subcommand; + +pub(crate) use args::TaskId; +pub(crate) use command::Command; +pub(crate) use config::ConfigOperation; +pub(crate) use filter::{Condition, Filter}; +pub(crate) use modification::{DescriptionMod, Modification}; +pub(crate) use subcommand::Subcommand; + +use crate::usage::Usage; +use lazy_static::lazy_static; +use taskchampion::chrono::prelude::*; + +lazy_static! { + // A static value of NOW to make tests easier + pub(crate) static ref NOW: DateTime = Utc::now(); +} + +type ArgList<'a> = &'a [&'a str]; + +pub(crate) fn get_usage(usage: &mut Usage) { + Subcommand::get_usage(usage); + Filter::get_usage(usage); + Modification::get_usage(usage); +} diff --git a/rust/cli/src/argparse/modification.rs b/rust/cli/src/argparse/modification.rs new file mode 100644 index 000000000..21ddeef8e --- /dev/null +++ b/rust/cli/src/argparse/modification.rs @@ -0,0 +1,342 @@ +use super::args::{any, arg_matching, depends_colon, minus_tag, plus_tag, wait_colon, TaskId}; +use super::ArgList; +use crate::usage; +use nom::{branch::alt, combinator::*, multi::fold_many0, IResult}; +use std::collections::HashSet; +use taskchampion::chrono::prelude::*; +use taskchampion::{Status, Tag}; + +#[derive(Debug, PartialEq, Clone)] +pub enum DescriptionMod { + /// do not change the description + None, + + /// Prepend the given value to the description, with a space separator + Prepend(String), + + /// Append the given value to the description, with a space separator + Append(String), + + /// Set the description + Set(String), +} + +impl Default for DescriptionMod { + fn default() -> Self { + Self::None + } +} + +/// A modification represents a change to a task: adding or removing tags, setting the +/// description, and so on. +#[derive(Debug, PartialEq, Clone, Default)] +pub(crate) struct Modification { + /// Change the description + pub(crate) description: DescriptionMod, + + /// Set the status + pub(crate) status: Option, + + /// Set (or, with `Some(None)`, clear) the wait timestamp + pub(crate) wait: Option>>, + + /// Set the "active" state, that is, start (true) or stop (false) the task. + pub(crate) active: Option, + + /// Add tags + pub(crate) add_tags: HashSet, + + /// Remove tags + pub(crate) remove_tags: HashSet, + + /// Add dependencies + pub(crate) add_dependencies: HashSet, + + /// Remove dependencies + pub(crate) remove_dependencies: HashSet, + + /// Add annotation + pub(crate) annotate: Option, +} + +/// A single argument that is part of a modification, used internally to this module +enum ModArg<'a> { + Description(&'a str), + PlusTag(Tag), + MinusTag(Tag), + Wait(Option>), + AddDependencies(Vec), + RemoveDependencies(Vec), +} + +impl Modification { + pub(super) fn parse(input: ArgList) -> IResult { + fn fold(mut acc: Modification, mod_arg: ModArg) -> Modification { + match mod_arg { + ModArg::Description(description) => { + if let DescriptionMod::Set(existing) = acc.description { + acc.description = + DescriptionMod::Set(format!("{} {}", existing, description)); + } else { + acc.description = DescriptionMod::Set(description.to_string()); + } + } + ModArg::PlusTag(tag) => { + acc.add_tags.insert(tag); + } + ModArg::MinusTag(tag) => { + acc.remove_tags.insert(tag); + } + ModArg::Wait(wait) => { + acc.wait = Some(wait); + } + ModArg::AddDependencies(task_ids) => { + for tid in task_ids { + acc.add_dependencies.insert(tid); + } + } + ModArg::RemoveDependencies(task_ids) => { + for tid in task_ids { + acc.remove_dependencies.insert(tid); + } + } + } + acc + } + fold_many0( + alt(( + Self::plus_tag, + Self::minus_tag, + Self::wait, + Self::dependencies, + // this must come last + Self::description, + )), + Modification { + ..Default::default() + }, + fold, + )(input) + } + + fn description(input: ArgList) -> IResult { + fn to_modarg(input: &str) -> Result { + Ok(ModArg::Description(input)) + } + map_res(arg_matching(any), to_modarg)(input) + } + + fn plus_tag(input: ArgList) -> IResult { + 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: 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) + } + + fn dependencies(input: ArgList) -> IResult { + fn to_modarg(input: (bool, Vec)) -> Result, ()> { + Ok(if input.0 { + ModArg::AddDependencies(input.1) + } else { + ModArg::RemoveDependencies(input.1) + }) + } + map_res(arg_matching(depends_colon), to_modarg)(input) + } + + pub(super) fn get_usage(u: &mut usage::Usage) { + u.modifications.push(usage::Modification { + syntax: "DESCRIPTION", + summary: "Set description/annotation", + description: " + Set the task description (or the task annotation for `ta annotate`). Multiple + arguments are combined into a single space-separated description. To avoid + surprises from shell quoting, prefer to use a single quoted argument, for example + `ta 19 modify \"return library books\"`", + }); + u.modifications.push(usage::Modification { + syntax: "+TAG", + summary: "Tag task", + description: "Add the given tag to the task.", + }); + u.modifications.push(usage::Modification { + syntax: "-TAG", + summary: "Un-tag task", + description: "Remove the given tag from the task.", + }); + u.modifications.push(usage::Modification { + syntax: "status:{pending,completed,deleted}", + summary: "Set the task's status", + description: "Set the status of the task explicitly.", + }); + u.modifications.push(usage::Modification { + syntax: "wait:", + summary: "Set or unset the task's wait time", + description: " + Set the time before which the task is not actionable and should not be shown in + reports, e.g., `wait:3day` to wait for three days. With `wait:`, the time is + un-set. See the documentation for the timestamp syntax.", + }); + u.modifications.push(usage::Modification { + syntax: "depends:", + summary: "Add task dependencies", + description: " + Add a dependency of this task on the given tasks. The tasks can be specified + in the same syntax as for filters, e.g., `depends:13,94500c95`.", + }); + u.modifications.push(usage::Modification { + syntax: "depends:-", + summary: "Remove task dependencies", + description: " + Remove the dependency of this task on the given tasks.", + }); + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::argparse::NOW; + use pretty_assertions::assert_eq; + use taskchampion::chrono::Duration; + + #[test] + fn test_empty() { + let (input, modification) = Modification::parse(argv![]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + modification, + Modification { + ..Default::default() + } + ); + } + + #[test] + fn test_single_arg_description() { + let (input, modification) = Modification::parse(argv!["newdesc"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + modification, + Modification { + description: DescriptionMod::Set(s!("newdesc")), + ..Default::default() + } + ); + } + + #[test] + fn test_add_tags() { + let (input, modification) = Modification::parse(argv!["+abc", "+def"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + modification, + Modification { + add_tags: set![tag!("abc"), tag!("def")], + ..Default::default() + } + ); + } + + #[test] + fn test_set_wait() { + let (input, modification) = Modification::parse(argv!["wait:2d"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + modification, + Modification { + wait: Some(Some(*NOW + Duration::days(2))), + ..Default::default() + } + ); + } + + #[test] + fn test_add_deps() { + let (input, modification) = Modification::parse(argv!["depends:13,e72b73d1-9e88"]).unwrap(); + assert_eq!(input.len(), 0); + let mut deps = HashSet::new(); + deps.insert(TaskId::WorkingSetId(13)); + deps.insert(TaskId::PartialUuid("e72b73d1-9e88".into())); + assert_eq!( + modification, + Modification { + add_dependencies: deps, + ..Default::default() + } + ); + } + + #[test] + fn test_remove_deps() { + let (input, modification) = + Modification::parse(argv!["depends:-13,e72b73d1-9e88"]).unwrap(); + assert_eq!(input.len(), 0); + let mut deps = HashSet::new(); + deps.insert(TaskId::WorkingSetId(13)); + deps.insert(TaskId::PartialUuid("e72b73d1-9e88".into())); + assert_eq!( + modification, + Modification { + remove_dependencies: deps, + ..Default::default() + } + ); + } + + #[test] + fn test_unset_wait() { + let (input, modification) = Modification::parse(argv!["wait:"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + modification, + Modification { + wait: Some(None), + ..Default::default() + } + ); + } + + #[test] + fn test_multi_arg_description() { + let (input, modification) = Modification::parse(argv!["new", "desc", "fun"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + modification, + Modification { + description: DescriptionMod::Set(s!("new desc fun")), + ..Default::default() + } + ); + } + + #[test] + fn test_multi_arg_description_and_tags() { + let (input, modification) = + Modification::parse(argv!["new", "+next", "desc", "-daytime", "fun"]).unwrap(); + assert_eq!(input.len(), 0); + assert_eq!( + modification, + Modification { + description: DescriptionMod::Set(s!("new desc fun")), + add_tags: set![tag!("next")], + remove_tags: set![tag!("daytime")], + ..Default::default() + } + ); + } +} diff --git a/rust/cli/src/argparse/subcommand.rs b/rust/cli/src/argparse/subcommand.rs new file mode 100644 index 000000000..553350740 --- /dev/null +++ b/rust/cli/src/argparse/subcommand.rs @@ -0,0 +1,948 @@ +use super::args::*; +use super::{ArgList, ConfigOperation, DescriptionMod, Filter, Modification}; +use crate::usage; +use nom::{branch::alt, combinator::*, sequence::*, IResult}; +use taskchampion::Status; + +// IMPLEMENTATION NOTE: +// +// For each variant of Subcommand, there is a private, empty type of the same name with a `parse` +// method and a `get_usage` method. The parse methods may handle several subcommands, but always +// produce the variant of the same name as the type. +// +// This organization helps to gather the parsing and usage information into +// comprehensible chunks of code, to ensure that everything is documented. + +/// A subcommand is the specific operation that the CLI should execute. +#[derive(Debug, PartialEq)] +pub(crate) enum Subcommand { + /// Display the tool version + Version, + + /// Display the help output + Help { + /// Give the summary help (fitting on a few lines) + summary: bool, + }, + + /// Manipulate configuration + Config { + config_operation: ConfigOperation, + }, + + /// Add a new task + Add { + modification: Modification, + }, + + /// Modify existing tasks + Modify { + filter: Filter, + modification: Modification, + }, + + /// Lists (reports) + Report { + /// The name of the report to show + report_name: String, + + /// Additional filter terms beyond those in the report + filter: Filter, + }, + + /// Per-task information (typically one task) + Info { + filter: Filter, + debug: bool, + }, + + /// Basic operations without args + Gc, + Sync, + ImportTW, + ImportTDB2 { + path: String, + }, + Undo, +} + +impl Subcommand { + pub(super) fn parse(input: ArgList) -> IResult { + all_consuming(alt(( + Version::parse, + Help::parse, + Config::parse, + Add::parse, + Modify::parse, + Info::parse, + Gc::parse, + Sync::parse, + ImportTW::parse, + ImportTDB2::parse, + Undo::parse, + // This must come last since it accepts arbitrary report names + Report::parse, + )))(input) + } + + pub(super) fn get_usage(u: &mut usage::Usage) { + Version::get_usage(u); + Help::get_usage(u); + Config::get_usage(u); + Add::get_usage(u); + Modify::get_usage(u); + Info::get_usage(u); + Gc::get_usage(u); + Sync::get_usage(u); + ImportTW::get_usage(u); + ImportTDB2::get_usage(u); + Undo::get_usage(u); + Report::get_usage(u); + } +} + +struct Version; + +impl Version { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(_: &str) -> Result { + Ok(Subcommand::Version) + } + map_res( + alt(( + arg_matching(literal("version")), + arg_matching(literal("--version")), + )), + to_subcommand, + )(input) + } + + fn get_usage(u: &mut usage::Usage) { + u.subcommands.push(usage::Subcommand { + name: "version", + syntax: "version", + summary: "Show the TaskChampion version", + description: "Show the version of the TaskChampion binary", + }); + } +} + +struct Help; + +impl Help { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(input: &str) -> Result { + Ok(Subcommand::Help { + summary: input == "-h", + }) + } + map_res( + alt(( + arg_matching(literal("help")), + arg_matching(literal("--help")), + arg_matching(literal("-h")), + )), + to_subcommand, + )(input) + } + + fn get_usage(_u: &mut usage::Usage) {} +} + +struct Config; + +impl Config { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(input: (&str, ConfigOperation)) -> Result { + Ok(Subcommand::Config { + config_operation: input.1, + }) + } + map_res( + tuple((arg_matching(literal("config")), ConfigOperation::parse)), + to_subcommand, + )(input) + } + + fn get_usage(u: &mut usage::Usage) { + ConfigOperation::get_usage(u); + } +} + +struct Add; + +impl Add { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(input: (&str, Modification)) -> Result { + Ok(Subcommand::Add { + modification: input.1, + }) + } + map_res( + pair(arg_matching(literal("add")), Modification::parse), + to_subcommand, + )(input) + } + + fn get_usage(u: &mut usage::Usage) { + u.subcommands.push(usage::Subcommand { + name: "add", + syntax: "add [modification]", + summary: "Add a new task", + description: " + Add a new, pending task to the list of tasks. The modification must include a + description.", + }); + } +} + +struct Modify; + +impl Modify { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(input: (Filter, &str, Modification)) -> Result { + let filter = input.0; + let mut modification = input.2; + + match input.1 { + "prepend" => { + if let DescriptionMod::Set(s) = modification.description { + modification.description = DescriptionMod::Prepend(s) + } + } + "append" => { + if let DescriptionMod::Set(s) = modification.description { + modification.description = DescriptionMod::Append(s) + } + } + "start" => modification.active = Some(true), + "stop" => modification.active = Some(false), + "done" => modification.status = Some(Status::Completed), + "delete" => modification.status = Some(Status::Deleted), + "annotate" => { + // what would be parsed as a description is, here, used as the annotation + if let DescriptionMod::Set(s) = modification.description { + modification.description = DescriptionMod::None; + modification.annotate = Some(s); + } + } + _ => {} + } + + Ok(Subcommand::Modify { + filter, + modification, + }) + } + map_res( + tuple(( + Filter::parse1, + alt(( + arg_matching(literal("modify")), + arg_matching(literal("prepend")), + arg_matching(literal("append")), + arg_matching(literal("start")), + arg_matching(literal("stop")), + arg_matching(literal("done")), + arg_matching(literal("delete")), + arg_matching(literal("annotate")), + )), + Modification::parse, + )), + to_subcommand, + )(input) + } + + fn get_usage(u: &mut usage::Usage) { + u.subcommands.push(usage::Subcommand { + name: "modify", + syntax: " modify [modification]", + summary: "Modify tasks", + description: " + Modify all tasks matching the required filter.", + }); + u.subcommands.push(usage::Subcommand { + name: "prepend", + syntax: " prepend [modification]", + summary: "Prepend task description", + description: " + Modify all tasks matching the required filter by inserting the given description before each + task's description.", + }); + u.subcommands.push(usage::Subcommand { + name: "append", + syntax: " append [modification]", + summary: "Append task description", + description: " + Modify all tasks matching the required filter by adding the given description to the end + of each task's description.", + }); + u.subcommands.push(usage::Subcommand { + name: "start", + syntax: " start [modification]", + summary: "Start tasks", + description: " + Start all tasks matching the required filter, additionally applying any given modifications." + }); + u.subcommands.push(usage::Subcommand { + name: "stop", + syntax: " stop [modification]", + summary: "Stop tasks", + description: " + Stop all tasks matching the required filter, additionally applying any given modifications.", + }); + u.subcommands.push(usage::Subcommand { + name: "done", + syntax: " done [modification]", + summary: "Mark tasks as completed", + description: " + Mark all tasks matching the required filter as completed, additionally applying any given + modifications.", + }); + u.subcommands.push(usage::Subcommand { + name: "delete", + syntax: " delete [modification]", + summary: "Mark tasks as deleted", + description: " + Mark all tasks matching the required filter as deleted, additionally applying any given + modifications. Deleted tasks remain until they are expired in a 'ta gc' operation at + least six months after their last modification.", + }); + u.subcommands.push(usage::Subcommand { + name: "annotate", + syntax: " annotate [modification]", + summary: "Annotate a task", + description: " + Add an annotation to all tasks matching the required filter.", + }); + } +} + +struct Report; + +impl Report { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(filter: Filter, report_name: &str) -> Result { + Ok(Subcommand::Report { + filter, + report_name: report_name.to_owned(), + }) + } + // allow the filter expression before or after the report name + alt(( + map_res(pair(arg_matching(report_name), Filter::parse0), |input| { + to_subcommand(input.1, input.0) + }), + map_res(pair(Filter::parse0, arg_matching(report_name)), |input| { + to_subcommand(input.0, input.1) + }), + // default to a "next" report + map_res(Filter::parse0, |input| to_subcommand(input, "next")), + ))(input) + } + + fn get_usage(u: &mut usage::Usage) { + u.subcommands.push(usage::Subcommand { + name: "report", + syntax: "[filter] [report-name] *or* [report-name] [filter]", + summary: "Show a report", + description: " + Show the named report, including only tasks matching the filter", + }); + u.subcommands.push(usage::Subcommand { + name: "next", + syntax: "[filter]", + summary: "Show the 'next' report", + description: " + Show the report named 'next', including only tasks matching the filter", + }); + } +} + +struct Info; + +impl Info { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(input: (Filter, &str)) -> Result { + let debug = input.1 == "debug"; + Ok(Subcommand::Info { + filter: input.0, + debug, + }) + } + map_res( + pair( + Filter::parse1, + alt(( + arg_matching(literal("info")), + arg_matching(literal("debug")), + )), + ), + to_subcommand, + )(input) + } + + fn get_usage(u: &mut usage::Usage) { + u.subcommands.push(usage::Subcommand { + name: "info", + syntax: "[filter] info", + summary: "Show tasks", + description: " Show information about all tasks matching the fiter.", + }); + u.subcommands.push(usage::Subcommand { + name: "debug", + syntax: "[filter] debug", + summary: "Show task debug details", + description: " Show all key/value properties of the tasks matching the fiter.", + }); + } +} + +struct Gc; + +impl Gc { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(_: &str) -> Result { + Ok(Subcommand::Gc) + } + map_res(arg_matching(literal("gc")), to_subcommand)(input) + } + + fn get_usage(u: &mut usage::Usage) { + u.subcommands.push(usage::Subcommand { + name: "gc", + syntax: "gc", + summary: "Perform 'garbage collection'", + description: " + Perform 'garbage collection'. This refreshes the list of pending tasks + and their short id's.", + }); + } +} + +struct Sync; + +impl Sync { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(_: &str) -> Result { + Ok(Subcommand::Sync) + } + map_res(arg_matching(literal("sync")), to_subcommand)(input) + } + + fn get_usage(u: &mut usage::Usage) { + u.subcommands.push(usage::Subcommand { + name: "sync", + syntax: "sync", + summary: "Synchronize this replica", + description: " + Synchronize this replica locally or against a remote server, as configured. + + Synchronization is a critical part of maintaining the task database, and should + be done regularly, even if only locally. It is typically run in a crontask.", + }) + } +} + +struct ImportTW; + +impl ImportTW { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(_: &str) -> Result { + Ok(Subcommand::ImportTW) + } + map_res(arg_matching(literal("import-tw")), to_subcommand)(input) + } + + fn get_usage(u: &mut usage::Usage) { + u.subcommands.push(usage::Subcommand { + name: "import-tw", + syntax: "import-tw", + summary: "Import tasks from TaskWarrior export", + description: " + Import tasks into this replica. + + The tasks must be provided in the TaskWarrior JSON format on stdin. If tasks + in the import already exist, they are 'merged'. + + Because TaskChampion lacks the information about the types of UDAs that is stored + in the TaskWarrior configuration, UDA values are imported as simple strings, in the + format they appear in the JSON export. This may cause undesirable results. + ", + }) + } +} + +struct ImportTDB2; + +impl ImportTDB2 { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(input: (&str, &str)) -> Result { + Ok(Subcommand::ImportTDB2 { + path: input.1.into(), + }) + } + map_res( + pair(arg_matching(literal("import-tdb2")), arg_matching(any)), + to_subcommand, + )(input) + } + + fn get_usage(u: &mut usage::Usage) { + u.subcommands.push(usage::Subcommand { + name: "import-tdb2", + syntax: "import-tdb2 ", + summary: "Import tasks from the TaskWarrior data directory", + description: " + Import tasks into this replica from a TaskWarrior data directory. If tasks in the + import already exist, they are 'merged'. This mode of import supports UDAs better + than the `import` subcommand, but requires access to the \"raw\" TaskWarrior data. + + This command supports task directories written by TaskWarrior-2.6.1 or later. + ", + }) + } +} + +struct Undo; + +impl Undo { + fn parse(input: ArgList) -> IResult { + fn to_subcommand(_: &str) -> Result { + Ok(Subcommand::Undo) + } + map_res(arg_matching(literal("undo")), to_subcommand)(input) + } + + fn get_usage(u: &mut usage::Usage) { + u.subcommands.push(usage::Subcommand { + name: "undo", + syntax: "undo", + summary: "Undo the latest change made on this replica", + description: " + Undo the latest change made on this replica. + + Changes cannot be undone once they have been synchronized.", + }) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::argparse::Condition; + use pretty_assertions::assert_eq; + + const EMPTY: Vec<&str> = vec![]; + + #[test] + fn test_version() { + assert_eq!( + Subcommand::parse(argv!["version"]).unwrap(), + (&EMPTY[..], Subcommand::Version) + ); + } + + #[test] + fn test_dd_version() { + assert_eq!( + Subcommand::parse(argv!["--version"]).unwrap(), + (&EMPTY[..], Subcommand::Version) + ); + } + + #[test] + fn test_d_h() { + assert_eq!( + Subcommand::parse(argv!["-h"]).unwrap(), + (&EMPTY[..], Subcommand::Help { summary: true }) + ); + } + + #[test] + fn test_help() { + assert_eq!( + Subcommand::parse(argv!["help"]).unwrap(), + (&EMPTY[..], Subcommand::Help { summary: false }) + ); + } + + #[test] + fn test_dd_help() { + assert_eq!( + Subcommand::parse(argv!["--help"]).unwrap(), + (&EMPTY[..], Subcommand::Help { summary: false }) + ); + } + + #[test] + fn test_config_set() { + assert_eq!( + Subcommand::parse(argv!["config", "set", "x", "y"]).unwrap(), + ( + &EMPTY[..], + Subcommand::Config { + config_operation: ConfigOperation::Set("x".to_owned(), "y".to_owned()) + } + ) + ); + } + + #[test] + fn test_add_description() { + let subcommand = Subcommand::Add { + modification: Modification { + description: DescriptionMod::Set(s!("foo")), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["add", "foo"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_add_description_multi() { + let subcommand = Subcommand::Add { + modification: Modification { + description: DescriptionMod::Set(s!("foo bar")), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["add", "foo", "bar"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_modify_description_multi() { + let subcommand = Subcommand::Modify { + filter: Filter { + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])], + }, + modification: Modification { + description: DescriptionMod::Set(s!("foo bar")), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["123", "modify", "foo", "bar"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_append() { + let subcommand = Subcommand::Modify { + filter: Filter { + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])], + }, + modification: Modification { + description: DescriptionMod::Append(s!("foo bar")), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["123", "append", "foo", "bar"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_prepend() { + let subcommand = Subcommand::Modify { + filter: Filter { + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])], + }, + modification: Modification { + description: DescriptionMod::Prepend(s!("foo bar")), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["123", "prepend", "foo", "bar"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_done() { + let subcommand = Subcommand::Modify { + filter: Filter { + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])], + }, + modification: Modification { + status: Some(Status::Completed), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["123", "done"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_done_modify() { + let subcommand = Subcommand::Modify { + filter: Filter { + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])], + }, + modification: Modification { + description: DescriptionMod::Set(s!("now-finished")), + status: Some(Status::Completed), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["123", "done", "now-finished"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_start() { + let subcommand = Subcommand::Modify { + filter: Filter { + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])], + }, + modification: Modification { + active: Some(true), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["123", "start"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_start_modify() { + let subcommand = Subcommand::Modify { + filter: Filter { + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])], + }, + modification: Modification { + active: Some(true), + description: DescriptionMod::Set(s!("mod")), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["123", "start", "mod"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_stop() { + let subcommand = Subcommand::Modify { + filter: Filter { + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])], + }, + modification: Modification { + active: Some(false), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["123", "stop"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_stop_modify() { + let subcommand = Subcommand::Modify { + filter: Filter { + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])], + }, + modification: Modification { + description: DescriptionMod::Set(s!("mod")), + active: Some(false), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["123", "stop", "mod"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_delete() { + let subcommand = Subcommand::Modify { + filter: Filter { + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])], + }, + modification: Modification { + status: Some(Status::Deleted), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["123", "delete"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_annotate() { + let subcommand = Subcommand::Modify { + filter: Filter { + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(123)])], + }, + modification: Modification { + annotate: Some("sent invoice".into()), + ..Default::default() + }, + }; + assert_eq!( + Subcommand::parse(argv!["123", "annotate", "sent", "invoice"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_report() { + let subcommand = Subcommand::Report { + filter: Default::default(), + report_name: "myreport".to_owned(), + }; + assert_eq!( + Subcommand::parse(argv!["myreport"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_report_filter_before() { + let subcommand = Subcommand::Report { + filter: Filter { + conditions: vec![Condition::IdList(vec![ + TaskId::WorkingSetId(12), + TaskId::WorkingSetId(13), + ])], + }, + report_name: "foo".to_owned(), + }; + assert_eq!( + Subcommand::parse(argv!["12,13", "foo"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_report_filter_after() { + let subcommand = Subcommand::Report { + filter: Filter { + conditions: vec![Condition::IdList(vec![ + TaskId::WorkingSetId(12), + TaskId::WorkingSetId(13), + ])], + }, + report_name: "foo".to_owned(), + }; + assert_eq!( + Subcommand::parse(argv!["foo", "12,13"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_report_filter_next() { + let subcommand = Subcommand::Report { + filter: Filter { + conditions: vec![Condition::IdList(vec![ + TaskId::WorkingSetId(12), + TaskId::WorkingSetId(13), + ])], + }, + report_name: "next".to_owned(), + }; + assert_eq!( + Subcommand::parse(argv!["12,13"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_report_next() { + let subcommand = Subcommand::Report { + filter: Filter { + ..Default::default() + }, + report_name: "next".to_owned(), + }; + assert_eq!( + Subcommand::parse(argv![]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_info_filter() { + let subcommand = Subcommand::Info { + debug: false, + filter: Filter { + conditions: vec![Condition::IdList(vec![ + TaskId::WorkingSetId(12), + TaskId::WorkingSetId(13), + ])], + }, + }; + assert_eq!( + Subcommand::parse(argv!["12,13", "info"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_debug_filter() { + let subcommand = Subcommand::Info { + debug: true, + filter: Filter { + conditions: vec![Condition::IdList(vec![TaskId::WorkingSetId(12)])], + }, + }; + assert_eq!( + Subcommand::parse(argv!["12", "debug"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_gc() { + let subcommand = Subcommand::Gc; + assert_eq!( + Subcommand::parse(argv!["gc"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_gc_extra_args() { + assert!(Subcommand::parse(argv!["gc", "foo"]).is_err()); + } + + #[test] + fn test_sync() { + let subcommand = Subcommand::Sync; + assert_eq!( + Subcommand::parse(argv!["sync"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } + + #[test] + fn test_undo() { + let subcommand = Subcommand::Undo; + assert_eq!( + Subcommand::parse(argv!["undo"]).unwrap(), + (&EMPTY[..], subcommand) + ); + } +} diff --git a/rust/cli/src/bin/ta.rs b/rust/cli/src/bin/ta.rs new file mode 100644 index 000000000..efdee99da --- /dev/null +++ b/rust/cli/src/bin/ta.rs @@ -0,0 +1,11 @@ +use std::process::exit; + +pub fn main() { + match taskchampion_cli::main() { + Ok(_) => exit(0), + Err(e) => { + eprintln!("{:?}", e); + exit(e.exit_status()); + } + } +} diff --git a/rust/cli/src/bin/usage-docs.rs b/rust/cli/src/bin/usage-docs.rs new file mode 100644 index 000000000..cf3998b5b --- /dev/null +++ b/rust/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/rust/cli/src/errors.rs b/rust/cli/src/errors.rs new file mode 100644 index 000000000..6da512136 --- /dev/null +++ b/rust/cli/src/errors.rs @@ -0,0 +1,60 @@ +use taskchampion::Error as TcError; +use thiserror::Error; + +#[derive(Debug, Error)] +pub enum Error { + #[error("Command-Line Syntax Error: {0}")] + Arguments(String), + + #[error(transparent)] + TaskChampion(#[from] TcError), + + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +impl Error { + /// Construct a new command-line argument error + pub(crate) fn for_arguments(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; + use pretty_assertions::assert_eq; + + #[test] + fn test_exit_status() { + let mut err: Error; + + err = anyhow!("uhoh").into(); + assert_eq!(err.exit_status(), 1); + + err = Error::Arguments("uhoh".to_string()); + assert_eq!(err.exit_status(), 3); + + err = std::io::Error::last_os_error().into(); + assert_eq!(err.exit_status(), 1); + + err = TcError::Database("uhoh".to_string()).into(); + assert_eq!(err.exit_status(), 1); + } +} diff --git a/rust/cli/src/invocation/cmd/add.rs b/rust/cli/src/invocation/cmd/add.rs new file mode 100644 index 000000000..5dfd5c215 --- /dev/null +++ b/rust/cli/src/invocation/cmd/add.rs @@ -0,0 +1,77 @@ +use crate::argparse::DescriptionMod; +use crate::invocation::{apply_modification, ResolvedModification}; +use taskchampion::{Replica, Status}; +use termcolor::WriteColor; + +pub(in crate::invocation) fn execute( + w: &mut W, + replica: &mut Replica, + mut modification: ResolvedModification, +) -> Result<(), crate::Error> { + // extract the description from the modification to handle it specially + let description = match modification.0.description { + DescriptionMod::Set(ref s) => s.clone(), + _ => "(no description)".to_owned(), + }; + modification.0.description = DescriptionMod::None; + + let task = replica.new_task(Status::Pending, description).unwrap(); + let mut task = task.into_mut(replica); + apply_modification(&mut task, &modification)?; + writeln!(w, "added task {}", task.get_uuid())?; + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::argparse::Modification; + use crate::invocation::test::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_add() { + let mut w = test_writer(); + let mut replica = test_replica(); + let modification = ResolvedModification(Modification { + description: DescriptionMod::Set(s!("my description")), + ..Default::default() + }); + execute(&mut w, &mut replica, modification).unwrap(); + + // check that the task appeared.. + let working_set = replica.working_set().unwrap(); + let task = replica + .get_task(working_set.by_index(1).unwrap()) + .unwrap() + .unwrap(); + assert_eq!(task.get_description(), "my description"); + assert_eq!(task.get_status(), Status::Pending); + + assert_eq!(w.into_string(), format!("added task {}\n", task.get_uuid())); + } + + #[test] + fn test_add_with_tags() { + let mut w = test_writer(); + let mut replica = test_replica(); + let modification = ResolvedModification(Modification { + description: DescriptionMod::Set(s!("my description")), + add_tags: vec![tag!("tag1")].drain(..).collect(), + ..Default::default() + }); + execute(&mut w, &mut replica, modification).unwrap(); + + // check that the task appeared.. + let working_set = replica.working_set().unwrap(); + let task = replica + .get_task(working_set.by_index(1).unwrap()) + .unwrap() + .unwrap(); + assert_eq!(task.get_description(), "my description"); + assert_eq!(task.get_status(), Status::Pending); + assert!(task.has_tag(&tag!("tag1"))); + + assert_eq!(w.into_string(), format!("added task {}\n", task.get_uuid())); + } +} diff --git a/rust/cli/src/invocation/cmd/completed.data b/rust/cli/src/invocation/cmd/completed.data new file mode 100644 index 000000000..3a48b9cd1 --- /dev/null +++ b/rust/cli/src/invocation/cmd/completed.data @@ -0,0 +1 @@ +[description:"&open;TEST&close; foo" entry:"1554074416" modified:"1554074416" priority:"M" status:"completed" uuid:"4578fb67-359b-4483-afe4-fef15925ccd6"] diff --git a/rust/cli/src/invocation/cmd/config.rs b/rust/cli/src/invocation/cmd/config.rs new file mode 100644 index 000000000..ce4d0f27a --- /dev/null +++ b/rust/cli/src/invocation/cmd/config.rs @@ -0,0 +1,69 @@ +use crate::argparse::ConfigOperation; +use crate::settings::Settings; +use termcolor::{ColorSpec, WriteColor}; + +pub(crate) fn execute( + w: &mut W, + config_operation: ConfigOperation, + settings: &Settings, +) -> Result<(), crate::Error> { + match config_operation { + ConfigOperation::Set(key, value) => { + let filename = settings.set(&key, &value)?; + write!(w, "Set configuration value ")?; + w.set_color(ColorSpec::new().set_bold(true))?; + write!(w, "{}", &key)?; + w.set_color(ColorSpec::new().set_bold(false))?; + write!(w, " in ")?; + w.set_color(ColorSpec::new().set_bold(true))?; + writeln!(w, "{:?}.", filename)?; + w.set_color(ColorSpec::new().set_bold(false))?; + } + ConfigOperation::Path => { + if let Some(ref filename) = settings.filename { + writeln!(w, "{}", filename.to_string_lossy())?; + } else { + return Err(anyhow::anyhow!("No configuration filename found").into()); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::invocation::test::*; + use pretty_assertions::assert_eq; + use std::fs; + use tempfile::TempDir; + + #[test] + fn test_config_set() { + let cfg_dir = TempDir::new().unwrap(); + let cfg_file = cfg_dir.path().join("foo.toml"); + fs::write( + cfg_file.clone(), + "# store data everywhere\ndata_dir = \"/nowhere\"\n", + ) + .unwrap(); + + let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap(); + + let mut w = test_writer(); + + execute( + &mut w, + ConfigOperation::Set("data_dir".to_owned(), "/somewhere".to_owned()), + &settings, + ) + .unwrap(); + assert!(w.into_string().starts_with("Set configuration value ")); + + let updated_toml = fs::read_to_string(cfg_file.clone()).unwrap(); + assert_eq!( + updated_toml, + "# store data everywhere\ndata_dir = \"/somewhere\"\n" + ); + } +} diff --git a/rust/cli/src/invocation/cmd/gc.rs b/rust/cli/src/invocation/cmd/gc.rs new file mode 100644 index 000000000..e9b8a2828 --- /dev/null +++ b/rust/cli/src/invocation/cmd/gc.rs @@ -0,0 +1,26 @@ +use taskchampion::Replica; +use termcolor::WriteColor; + +pub(crate) fn execute(w: &mut W, replica: &mut Replica) -> Result<(), crate::Error> { + log::debug!("rebuilding working set"); + replica.rebuild_working_set(true)?; + log::debug!("expiring old tasks"); + replica.expire_tasks()?; + writeln!(w, "garbage collected.")?; + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::invocation::test::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_gc() { + let mut w = test_writer(); + let mut replica = test_replica(); + execute(&mut w, &mut replica).unwrap(); + assert_eq!(&w.into_string(), "garbage collected.\n") + } +} diff --git a/rust/cli/src/invocation/cmd/help.rs b/rust/cli/src/invocation/cmd/help.rs new file mode 100644 index 000000000..2f81a08f8 --- /dev/null +++ b/rust/cli/src/invocation/cmd/help.rs @@ -0,0 +1,30 @@ +use crate::usage::Usage; +use termcolor::WriteColor; + +pub(crate) fn execute( + w: &mut W, + command_name: String, + summary: bool, +) -> Result<(), crate::Error> { + let usage = Usage::new(); + usage.write_help(w, command_name.as_ref(), summary)?; + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::invocation::test::*; + + #[test] + fn test_summary() { + let mut w = test_writer(); + execute(&mut w, s!("ta"), true).unwrap(); + } + + #[test] + fn test_long() { + let mut w = test_writer(); + execute(&mut w, s!("ta"), false).unwrap(); + } +} diff --git a/rust/cli/src/invocation/cmd/import_tdb2.rs b/rust/cli/src/invocation/cmd/import_tdb2.rs new file mode 100644 index 000000000..c3eb9ba5f --- /dev/null +++ b/rust/cli/src/invocation/cmd/import_tdb2.rs @@ -0,0 +1,142 @@ +use crate::tdb2; +use anyhow::anyhow; +use std::fs; +use std::path::PathBuf; +use taskchampion::{Replica, Uuid}; +use termcolor::{Color, ColorSpec, WriteColor}; + +pub(crate) fn execute( + w: &mut W, + replica: &mut Replica, + path: &str, +) -> Result<(), crate::Error> { + let path: PathBuf = path.into(); + + let mut count = 0; + for file in &["pending.data", "completed.data"] { + let file = path.join(file); + w.set_color(ColorSpec::new().set_bold(true))?; + writeln!(w, "Importing tasks from {:?}.", file)?; + w.reset()?; + + let data = fs::read_to_string(file)?; + let content = + tdb2::File::from_str(&data).map_err(|_| anyhow!("Could not parse TDB2 file format"))?; + count += content.lines.len(); + for line in content.lines { + import_task(w, replica, line)?; + } + } + w.set_color(ColorSpec::new().set_bold(true))?; + writeln!(w, "{} tasks imported.", count)?; + w.reset()?; + + Ok(()) +} + +fn import_task( + w: &mut W, + replica: &mut Replica, + mut line: tdb2::Line, +) -> anyhow::Result<()> { + let mut uuid = None; + for attr in line.attrs.iter() { + if &attr.name == "uuid" { + uuid = Some(Uuid::parse_str(&attr.value)?); + break; + } + } + let uuid = uuid.ok_or_else(|| anyhow!("task has no uuid"))?; + replica.import_task_with_uuid(uuid)?; + + let mut description = None; + for attr in line.attrs.drain(..) { + // oddly, TaskWarrior represents [ and ] with their HTML entity equivalents + let value = attr.value.replace("&open;", "[").replace("&close;", "]"); + match attr.name.as_ref() { + // `uuid` was already handled + "uuid" => {} + + // everything else is inserted directly + _ => { + if attr.name == "description" { + // keep a copy of the description for console output + description = Some(value.clone()); + } + replica.update_task(uuid, attr.name, Some(value))?; + } + } + } + + w.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)))?; + write!(w, "{}", uuid)?; + w.reset()?; + writeln!( + w, + " {}", + description.unwrap_or_else(|| "(no description)".into()) + )?; + + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::invocation::test::*; + use pretty_assertions::assert_eq; + use std::convert::TryInto; + use taskchampion::chrono::{TimeZone, Utc}; + use taskchampion::Status; + use tempfile::TempDir; + + #[test] + fn test_import() -> anyhow::Result<()> { + let mut w = test_writer(); + let mut replica = test_replica(); + let tmp_dir = TempDir::new()?; + + fs::write( + tmp_dir.path().join("pending.data"), + include_bytes!("pending.data"), + )?; + fs::write( + tmp_dir.path().join("completed.data"), + include_bytes!("completed.data"), + )?; + + execute(&mut w, &mut replica, tmp_dir.path().to_str().unwrap())?; + + let task = replica + .get_task(Uuid::parse_str("f19086c2-1f8d-4a6c-9b8d-f94901fb8e62").unwrap()) + .unwrap() + .unwrap(); + assert_eq!(task.get_description(), "snake ðŸ"); + assert_eq!(task.get_status(), Status::Pending); + assert_eq!(task.get_priority(), "M"); + assert_eq!(task.get_wait(), None); + assert_eq!( + task.get_modified(), + Some(Utc.ymd(2022, 1, 8).and_hms(19, 33, 5)) + ); + assert!(task.has_tag(&"reptile".try_into().unwrap())); + assert!(!task.has_tag(&"COMPLETED".try_into().unwrap())); + + let task = replica + .get_task(Uuid::parse_str("4578fb67-359b-4483-afe4-fef15925ccd6").unwrap()) + .unwrap() + .unwrap(); + assert_eq!(task.get_description(), "[TEST] foo"); + assert_eq!(task.get_status(), Status::Completed); + assert_eq!(task.get_priority(), "M".to_string()); + assert_eq!(task.get_wait(), None); + assert_eq!( + task.get_modified(), + Some(Utc.ymd(2019, 3, 31).and_hms(23, 20, 16)) + ); + assert!(!task.has_tag(&"reptile".try_into().unwrap())); + assert!(task.has_tag(&"COMPLETED".try_into().unwrap())); + + Ok(()) + } +} diff --git a/rust/cli/src/invocation/cmd/import_tw.rs b/rust/cli/src/invocation/cmd/import_tw.rs new file mode 100644 index 000000000..aaee30d90 --- /dev/null +++ b/rust/cli/src/invocation/cmd/import_tw.rs @@ -0,0 +1,265 @@ +use anyhow::{anyhow, bail}; +use serde::{self, Deserialize, Deserializer}; +use serde_json::Value; +use std::collections::HashMap; +use taskchampion::chrono::{DateTime, TimeZone, Utc}; +use taskchampion::{Replica, Uuid}; +use termcolor::{Color, ColorSpec, WriteColor}; + +pub(crate) fn execute(w: &mut W, replica: &mut Replica) -> Result<(), crate::Error> { + w.set_color(ColorSpec::new().set_bold(true))?; + writeln!(w, "Importing tasks from stdin.")?; + w.reset()?; + + let mut tasks: Vec> = + serde_json::from_reader(std::io::stdin()).map_err(|_| anyhow!("Invalid JSON"))?; + + for task_json in tasks.drain(..) { + import_task(w, replica, task_json)?; + } + + w.set_color(ColorSpec::new().set_bold(true))?; + writeln!(w, "{} tasks imported.", tasks.len())?; + w.reset()?; + + Ok(()) +} + +/// Convert the given value to a string, failing on compound types (arrays +/// and objects). +fn stringify(v: Value) -> anyhow::Result { + Ok(match v { + Value::String(s) => s, + Value::Number(n) => n.to_string(), + Value::Bool(true) => "true".to_string(), + Value::Bool(false) => "false".to_string(), + Value::Null => "null".to_string(), + _ => bail!("{:?} cannot be converted to a string", v), + }) +} + +pub fn deserialize_tw_datetime<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + const FORMAT: &str = "%Y%m%dT%H%M%SZ"; + let s = String::deserialize(deserializer)?; + Utc.datetime_from_str(&s, FORMAT) + .map_err(serde::de::Error::custom) +} + +/// Deserialize a string in the TaskWarrior format into a DateTime +#[derive(Deserialize)] +struct TwDateTime(#[serde(deserialize_with = "deserialize_tw_datetime")] DateTime); + +impl TwDateTime { + /// Generate the data-model style UNIX timestamp for this DateTime + fn tc_timestamp(&self) -> String { + self.0.timestamp().to_string() + } +} + +#[derive(Deserialize)] +struct Annotation { + entry: TwDateTime, + description: String, +} + +fn import_task( + w: &mut W, + replica: &mut Replica, + mut task_json: HashMap, +) -> anyhow::Result<()> { + let uuid = task_json + .get("uuid") + .ok_or_else(|| anyhow!("task has no uuid"))?; + let uuid = uuid + .as_str() + .ok_or_else(|| anyhow!("uuid is not a string"))?; + let uuid = Uuid::parse_str(uuid)?; + replica.import_task_with_uuid(uuid)?; + + let mut description = None; + for (k, v) in task_json.drain() { + match k.as_ref() { + // `id` is the working-set ID and is not stored + "id" => {} + + // `urgency` is also calculated and not stored + "urgency" => {} + + // `uuid` was already handled + "uuid" => {} + + // `annotations` is a sub-aray + "annotations" => { + let annotations: Vec = serde_json::from_value(v)?; + for ann in annotations { + let k = format!("annotation_{}", ann.entry.tc_timestamp()); + replica.update_task(uuid, k, Some(ann.description))?; + } + } + + // `depends` is a sub-aray + "depends" => { + let deps: Vec = serde_json::from_value(v)?; + for dep in deps { + let k = format!("dep_{}", dep); + replica.update_task(uuid, k, Some("".to_owned()))?; + } + } + + // `tags` is a sub-aray + "tags" => { + let tags: Vec = serde_json::from_value(v)?; + for tag in tags { + let k = format!("tag_{}", tag); + replica.update_task(uuid, k, Some("".to_owned()))?; + } + } + + // convert all datetimes -> epoch integers + "end" | "entry" | "modified" | "wait" | "due" => { + let v: TwDateTime = serde_json::from_value(v)?; + replica.update_task(uuid, k, Some(v.tc_timestamp()))?; + } + + // everything else is inserted directly + _ => { + let v = stringify(v)?; + if k == "description" { + // keep a copy of the description for console output + description = Some(v.clone()); + } + replica.update_task(uuid, k, Some(v))?; + } + } + } + + w.set_color(ColorSpec::new().set_fg(Some(Color::Yellow)))?; + write!(w, "{}", uuid)?; + w.reset()?; + writeln!( + w, + " {}", + description.unwrap_or_else(|| "(no description)".into()) + )?; + + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::invocation::test::*; + use pretty_assertions::assert_eq; + use serde_json::json; + use std::convert::TryInto; + use taskchampion::chrono::{TimeZone, Utc}; + use taskchampion::Status; + + #[test] + fn stringify_string() { + assert_eq!(stringify(json!("foo")).unwrap(), "foo".to_string()); + } + + #[test] + fn stringify_number() { + assert_eq!(stringify(json!(2.14)).unwrap(), "2.14".to_string()); + } + + #[test] + fn stringify_bool() { + assert_eq!(stringify(json!(true)).unwrap(), "true".to_string()); + assert_eq!(stringify(json!(false)).unwrap(), "false".to_string()); + } + + #[test] + fn stringify_null() { + assert_eq!(stringify(json!(null)).unwrap(), "null".to_string()); + } + + #[test] + fn stringify_invalid() { + assert!(stringify(json!([1])).is_err()); + assert!(stringify(json!({"a": 1})).is_err()); + } + + #[test] + fn test_import() -> anyhow::Result<()> { + let mut w = test_writer(); + let mut replica = test_replica(); + + let task_json = serde_json::from_value(json!({ + "id": 0, + "description": "repair window", + "end": "20211231T175614Z", // TODO (#327) + "entry": "20211117T022410Z", // TODO (#326) + "modified": "20211231T175614Z", + "priority": "M", + "status": "completed", + "uuid": "fa01e916-1587-4c7d-a646-f7be62be8ee7", + "wait": "20211225T001523Z", + "due": "20211225T040000Z", // TODO (#82) + + // TODO: recurrence (#81) + "imask": 2, + "recur": "monthly", + "rtype": "periodic", + "mask": "+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--", + + // (legacy) UDAs + "githubcreatedon": "20211110T175919Z", + "githubnamespace": "djmitche", + "githubnumber": 228, + + "tags": [ + "house" + ], + "depends": [ // TODO (#84) + "4f71035d-1704-47f0-885c-6f9134bcefb2" + ], + "annotations": [ + { + "entry": "20211223T142031Z", + "description": "ordered from website" + } + ], + "urgency": 4.16849 + }))?; + import_task(&mut w, &mut replica, task_json)?; + + let task = replica + .get_task(Uuid::parse_str("fa01e916-1587-4c7d-a646-f7be62be8ee7").unwrap()) + .unwrap() + .unwrap(); + assert_eq!(task.get_description(), "repair window"); + assert_eq!(task.get_status(), Status::Completed); + assert_eq!(task.get_priority(), "M".to_string()); + assert_eq!( + task.get_wait(), + Some(Utc.ymd(2021, 12, 25).and_hms(00, 15, 23)) + ); + assert_eq!( + task.get_modified(), + Some(Utc.ymd(2021, 12, 31).and_hms(17, 56, 14)) + ); + assert!(task.has_tag(&"house".try_into().unwrap())); + assert!(!task.has_tag(&"PENDING".try_into().unwrap())); + assert_eq!( + task.get_annotations().collect::>(), + vec![taskchampion::Annotation { + entry: Utc.ymd(2021, 12, 23).and_hms(14, 20, 31), + description: "ordered from website".into(), + }] + ); + assert_eq!( + task.get_legacy_uda("githubcreatedon"), + Some("20211110T175919Z") + ); + assert_eq!(task.get_legacy_uda("githubnamespace"), Some("djmitche")); + assert_eq!(task.get_legacy_uda("githubnumber"), Some("228")); + + Ok(()) + } +} diff --git a/rust/cli/src/invocation/cmd/info.rs b/rust/cli/src/invocation/cmd/info.rs new file mode 100644 index 000000000..2766e6267 --- /dev/null +++ b/rust/cli/src/invocation/cmd/info.rs @@ -0,0 +1,117 @@ +use crate::argparse::Filter; +use crate::invocation::filtered_tasks; +use crate::table; +use prettytable::{cell, row, Table}; +use taskchampion::{Replica, Status}; +use termcolor::WriteColor; + +pub(crate) fn execute( + w: &mut W, + replica: &mut Replica, + filter: Filter, + debug: bool, +) -> Result<(), crate::Error> { + let working_set = replica.working_set()?; + + for task in filtered_tasks(replica, &filter)? { + let uuid = task.get_uuid(); + + let mut t = Table::new(); + t.set_format(table::format()); + if debug { + t.set_titles(row![b->"key", b->"value"]); + for (k, v) in task.get_taskmap().iter() { + t.add_row(row![k, v]); + } + } else { + t.add_row(row![b->"Uuid", uuid]); + if let Some(i) = working_set.by_uuid(uuid) { + t.add_row(row![b->"Id", i]); + } + t.add_row(row![b->"Description", task.get_description()]); + t.add_row(row![b->"Status", task.get_status()]); + t.add_row(row![b->"Active", task.is_active()]); + let mut tags: Vec<_> = task.get_tags().map(|t| format!("+{}", t)).collect(); + if !tags.is_empty() { + tags.sort(); + t.add_row(row![b->"Tags", tags.join(" ")]); + } + if let Some(wait) = task.get_wait() { + t.add_row(row![b->"Wait", wait]); + } + let mut annotations: Vec<_> = task.get_annotations().collect(); + annotations.sort(); + for ann in annotations { + t.add_row(row![b->"Annotation", format!("{}: {}", ann.entry, ann.description)]); + } + + let mut deps: Vec<_> = task.get_dependencies().collect(); + deps.sort(); + for dep in deps { + let mut descr = None; + if let Some(task) = replica.get_task(dep)? { + if task.get_status() == Status::Pending { + if let Some(i) = working_set.by_uuid(dep) { + descr = Some(format!("{} - {}", i, task.get_description())) + } else { + descr = Some(format!("{} - {}", dep, task.get_description())) + } + } + } + + if let Some(descr) = descr { + t.add_row(row![b->"Depends On", descr]); + } + } + } + t.print(w)?; + } + + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::argparse::{Condition, TaskId}; + use crate::invocation::test::*; + + use taskchampion::Status; + + #[test] + fn test_info() { + let mut w = test_writer(); + let mut replica = test_replica(); + replica.new_task(Status::Pending, s!("my task")).unwrap(); + + let filter = Filter { + ..Default::default() + }; + let debug = false; + execute(&mut w, &mut replica, filter, debug).unwrap(); + assert!(w.into_string().contains("my task")); + } + + #[test] + fn test_deps() { + let mut w = test_writer(); + let mut replica = test_replica(); + let t1 = replica.new_task(Status::Pending, s!("my task")).unwrap(); + let t2 = replica + .new_task(Status::Pending, s!("dunno, depends")) + .unwrap(); + let mut t2 = t2.into_mut(&mut replica); + t2.add_dependency(t1.get_uuid()).unwrap(); + let t2 = t2.into_immut(); + + let filter = Filter { + conditions: vec![Condition::IdList(vec![TaskId::Uuid(t2.get_uuid())])], + }; + let debug = false; + execute(&mut w, &mut replica, filter, debug).unwrap(); + let s = w.into_string(); + // length of whitespace between these two strings is not important + assert!(s.contains("Depends On")); + assert!(s.contains("1 - my task")); + } +} diff --git a/rust/cli/src/invocation/cmd/mod.rs b/rust/cli/src/invocation/cmd/mod.rs new file mode 100644 index 000000000..b5d1a21d6 --- /dev/null +++ b/rust/cli/src/invocation/cmd/mod.rs @@ -0,0 +1,14 @@ +//! Responsible for executing commands as parsed by [`crate::argparse`]. + +pub(crate) mod add; +pub(crate) mod config; +pub(crate) mod gc; +pub(crate) mod help; +pub(crate) mod import_tdb2; +pub(crate) mod import_tw; +pub(crate) mod info; +pub(crate) mod modify; +pub(crate) mod report; +pub(crate) mod sync; +pub(crate) mod undo; +pub(crate) mod version; diff --git a/rust/cli/src/invocation/cmd/modify.rs b/rust/cli/src/invocation/cmd/modify.rs new file mode 100644 index 000000000..cb8eaf5b1 --- /dev/null +++ b/rust/cli/src/invocation/cmd/modify.rs @@ -0,0 +1,106 @@ +use crate::argparse::Filter; +use crate::invocation::util::{confirm, summarize_task}; +use crate::invocation::{apply_modification, filtered_tasks, ResolvedModification}; +use crate::settings::Settings; +use taskchampion::Replica; +use termcolor::WriteColor; + +/// confirm modification of more than `modificationt_count_prompt` tasks, defaulting to 3 +fn check_modification( + w: &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(in crate::invocation) fn execute( + w: &mut W, + replica: &mut Replica, + settings: &Settings, + filter: Filter, + modification: ResolvedModification, +) -> Result<(), crate::Error> { + let tasks = filtered_tasks(replica, &filter)?; + + if !check_modification(w, settings, tasks.size_hint().0)? { + return Ok(()); + } + + for task in tasks { + let mut task = task.into_mut(replica); + + apply_modification(&mut task, &modification)?; + + let task = task.into_immut(); + let summary = summarize_task(replica, &task)?; + writeln!(w, "modified task {}", summary)?; + } + + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::argparse::{DescriptionMod, Modification}; + use crate::invocation::test::test_replica; + use crate::invocation::test::*; + use pretty_assertions::assert_eq; + use taskchampion::Status; + + #[test] + fn test_modify() { + let mut w = test_writer(); + let mut replica = test_replica(); + let settings = Settings::default(); + + let task = replica + .new_task(Status::Pending, s!("old description")) + .unwrap(); + + let filter = Filter { + ..Default::default() + }; + let modification = ResolvedModification(Modification { + description: DescriptionMod::Set(s!("new description")), + ..Default::default() + }); + execute(&mut w, &mut replica, &settings, filter, modification).unwrap(); + + // check that the task appeared.. + let task = replica.get_task(task.get_uuid()).unwrap().unwrap(); + assert_eq!(task.get_description(), "new description"); + assert_eq!(task.get_status(), Status::Pending); + + assert_eq!( + w.into_string(), + format!("modified task 1 - new description\n") + ); + } +} diff --git a/rust/cli/src/invocation/cmd/pending.data b/rust/cli/src/invocation/cmd/pending.data new file mode 100644 index 000000000..5f5590945 --- /dev/null +++ b/rust/cli/src/invocation/cmd/pending.data @@ -0,0 +1 @@ +[description:"snake ðŸ" entry:"1641670385" modified:"1641670385" priority:"M" status:"pending" tag_reptile:"" uuid:"f19086c2-1f8d-4a6c-9b8d-f94901fb8e62"] diff --git a/rust/cli/src/invocation/cmd/report.rs b/rust/cli/src/invocation/cmd/report.rs new file mode 100644 index 000000000..9be4030a7 --- /dev/null +++ b/rust/cli/src/invocation/cmd/report.rs @@ -0,0 +1,43 @@ +use crate::argparse::Filter; +use crate::invocation::display_report; +use crate::settings::Settings; +use taskchampion::Replica; +use termcolor::WriteColor; + +pub(crate) fn execute( + w: &mut W, + replica: &mut Replica, + settings: &Settings, + report_name: String, + filter: Filter, +) -> Result<(), crate::Error> { + display_report(w, replica, settings, report_name, filter) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::argparse::Filter; + use crate::invocation::test::*; + + use taskchampion::Status; + + #[test] + fn test_report() { + let mut w = test_writer(); + let mut replica = test_replica(); + replica.new_task(Status::Pending, s!("my task")).unwrap(); + + // The function being tested is only one line long, so this is sort of an integration test + // for display_report. + + let settings = Default::default(); + let report_name = "next".to_owned(); + let filter = Filter { + ..Default::default() + }; + + execute(&mut w, &mut replica, &settings, report_name, filter).unwrap(); + assert!(w.into_string().contains("my task")); + } +} diff --git a/rust/cli/src/invocation/cmd/sync.rs b/rust/cli/src/invocation/cmd/sync.rs new file mode 100644 index 000000000..7a3708421 --- /dev/null +++ b/rust/cli/src/invocation/cmd/sync.rs @@ -0,0 +1,58 @@ +use crate::settings::Settings; +use taskchampion::{server::Server, Error as TCError, Replica}; +use termcolor::WriteColor; + +pub(crate) fn execute( + w: &mut W, + replica: &mut Replica, + settings: &Settings, + server: &mut Box, +) -> Result<(), crate::Error> { + match replica.sync(server, settings.avoid_snapshots) { + Ok(()) => { + writeln!(w, "sync complete.")?; + Ok(()) + } + Err(e) => match e.downcast() { + Ok(TCError::OutOfSync) => { + writeln!(w, "This replica cannot be synchronized with the server.")?; + writeln!( + w, + "It may be too old, or some other failure may have occurred." + )?; + writeln!( + w, + "To start fresh, remove the local task database and run `ta sync` again." + )?; + writeln!( + w, + "Note that doing so will lose any un-synchronized local changes." + )?; + Ok(()) + } + Ok(e) => Err(e.into()), + Err(e) => Err(e.into()), + }, + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::invocation::test::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn test_add() { + let mut w = test_writer(); + let mut replica = test_replica(); + let server_dir = TempDir::new().unwrap(); + let mut server = test_server(&server_dir); + let settings = Settings::default(); + + // Note that the details of the actual sync are tested thoroughly in the taskchampion crate + execute(&mut w, &mut replica, &settings, &mut server).unwrap(); + assert_eq!(&w.into_string(), "sync complete.\n") + } +} diff --git a/rust/cli/src/invocation/cmd/undo.rs b/rust/cli/src/invocation/cmd/undo.rs new file mode 100644 index 000000000..d3f688e7a --- /dev/null +++ b/rust/cli/src/invocation/cmd/undo.rs @@ -0,0 +1,28 @@ +use taskchampion::Replica; +use termcolor::WriteColor; + +pub(crate) fn execute(w: &mut W, replica: &mut Replica) -> Result<(), crate::Error> { + if replica.undo()? { + writeln!(w, "Undo successful.")?; + } else { + writeln!(w, "Nothing to undo.")?; + } + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::invocation::test::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_undo() { + let mut w = test_writer(); + let mut replica = test_replica(); + + // Note that the details of the actual undo operation are tested thoroughly in the taskchampion crate + execute(&mut w, &mut replica).unwrap(); + assert_eq!(&w.into_string(), "Nothing to undo.\n") + } +} diff --git a/rust/cli/src/invocation/cmd/version.rs b/rust/cli/src/invocation/cmd/version.rs new file mode 100644 index 000000000..af1f77c56 --- /dev/null +++ b/rust/cli/src/invocation/cmd/version.rs @@ -0,0 +1,32 @@ +use crate::built_info; +use termcolor::{ColorSpec, WriteColor}; + +pub(crate) fn execute(w: &mut W) -> Result<(), crate::Error> { + write!(w, "TaskChampion ")?; + w.set_color(ColorSpec::new().set_bold(true))?; + write!(w, "{}", built_info::PKG_VERSION)?; + w.reset()?; + + if let (Some(version), Some(dirty)) = (built_info::GIT_VERSION, built_info::GIT_DIRTY) { + if dirty { + write!(w, " (git version: {} with un-committed changes)", version)?; + } else { + write!(w, " (git version: {})", version)?; + }; + } + writeln!(w)?; + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::invocation::test::*; + + #[test] + fn test_version() { + let mut w = test_writer(); + execute(&mut w).unwrap(); + assert!(w.into_string().starts_with("TaskChampion ")); + } +} diff --git a/rust/cli/src/invocation/filter.rs b/rust/cli/src/invocation/filter.rs new file mode 100644 index 000000000..6e2378ae4 --- /dev/null +++ b/rust/cli/src/invocation/filter.rs @@ -0,0 +1,325 @@ +use crate::argparse::{Condition, Filter, TaskId}; +use std::collections::HashSet; +use taskchampion::{Replica, Status, Task, Uuid, WorkingSet}; + +fn match_task(filter: &Filter, task: &Task, uuid: Uuid, working_set: &WorkingSet) -> bool { + for cond in &filter.conditions { + match cond { + Condition::HasTag(ref tag) => { + if !task.has_tag(tag) { + return false; + } + } + Condition::NoTag(ref tag) => { + if task.has_tag(tag) { + return false; + } + } + Condition::Status(status) => { + if task.get_status() != *status { + return false; + } + } + Condition::IdList(ids) => { + let uuid_str = uuid.to_string(); + let mut found = false; + let working_set_id = working_set.by_uuid(uuid); + + for id in ids { + if match id { + TaskId::WorkingSetId(i) => Some(*i) == working_set_id, + TaskId::PartialUuid(partial) => uuid_str.starts_with(partial), + TaskId::Uuid(i) => *i == uuid, + } { + found = true; + break; + } + } + if !found { + return false; + } + } + } + } + true +} + +// the universe of tasks we must consider +enum Universe { + /// Scan all the tasks + AllTasks, + /// Scan the working set (for pending tasks) + WorkingSet, + /// Scan an explicit set of tasks, "Absolute" meaning either full UUID or a working set + /// index + AbsoluteIdList(Vec), +} + +/// Determine the universe for the given filter; avoiding the need to scan all tasks in most cases. +fn universe_for_filter(filter: &Filter) -> Universe { + /// If there is a condition with Status::Pending, return true + fn has_pending_condition(filter: &Filter) -> bool { + filter + .conditions + .iter() + .any(|cond| matches!(cond, Condition::Status(Status::Pending))) + } + + /// If there is a condition with an IdList containing no partial UUIDs, + /// return that. + fn absolute_id_list_condition(filter: &Filter) -> Option> { + filter + .conditions + .iter() + .find(|cond| { + if let Condition::IdList(ids) = cond { + !ids.iter().any(|id| matches!(id, TaskId::PartialUuid(_))) + } else { + false + } + }) + .map(|cond| { + if let Condition::IdList(ids) = cond { + ids.to_vec() + } else { + unreachable!() // any condition found above must be an IdList(_) + } + }) + } + + if let Some(ids) = absolute_id_list_condition(filter) { + Universe::AbsoluteIdList(ids) + } else if has_pending_condition(filter) { + Universe::WorkingSet + } else { + Universe::AllTasks + } +} + +/// Return the tasks matching the given filter. This will return each matching +/// task once, even if the user specified the same task multiple times on the +/// command line. +pub(super) fn filtered_tasks( + replica: &mut Replica, + filter: &Filter, +) -> anyhow::Result> { + let mut res = vec![]; + + log::debug!("Applying filter {:?}", filter); + + let working_set = replica.working_set()?; + + // We will enumerate the universe of tasks for this filter, checking + // each resulting task with match_task + match universe_for_filter(filter) { + // A list of IDs, but some are partial so we need to iterate over + // all tasks and pattern-match their Uuids + Universe::AbsoluteIdList(ref ids) => { + log::debug!("Scanning only the tasks specified in the filter"); + // this is the only case where we might accidentally return the same task + // several times, so we must track the seen tasks. + let mut seen = HashSet::new(); + for id in ids { + let task = match id { + TaskId::WorkingSetId(id) => working_set + .by_index(*id) + .map(|uuid| replica.get_task(uuid)) + .transpose()? + .flatten(), + TaskId::PartialUuid(_) => unreachable!(), // not present in absolute id list + TaskId::Uuid(id) => replica.get_task(*id)?, + }; + + if let Some(task) = task { + // if we have already seen this task, skip ahead.. + let uuid = task.get_uuid(); + if seen.contains(&uuid) { + continue; + } + seen.insert(uuid); + + if match_task(filter, &task, uuid, &working_set) { + res.push(task); + } + } + } + } + + // All tasks -- iterate over the full set + Universe::AllTasks => { + log::debug!("Scanning all tasks in the task database"); + for (uuid, task) in replica.all_tasks()?.drain() { + if match_task(filter, &task, uuid, &working_set) { + res.push(task); + } + } + } + Universe::WorkingSet => { + log::debug!("Scanning only the working set (pending tasks)"); + for (_, uuid) in working_set.iter() { + if let Some(task) = replica.get_task(uuid)? { + if match_task(filter, &task, uuid, &working_set) { + res.push(task); + } + } + } + } + } + Ok(res.into_iter()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::invocation::test::*; + use pretty_assertions::assert_eq; + use taskchampion::Status; + + #[test] + fn exact_ids() { + let mut replica = test_replica(); + + let t1 = replica.new_task(Status::Pending, s!("A")).unwrap(); + let t2 = replica.new_task(Status::Completed, s!("B")).unwrap(); + let _t = replica.new_task(Status::Pending, s!("C")).unwrap(); + replica.rebuild_working_set(true).unwrap(); + + let t1uuid = t1.get_uuid(); + + let filter = Filter { + conditions: vec![Condition::IdList(vec![ + TaskId::Uuid(t1uuid), // A + TaskId::WorkingSetId(1), // A (again, dups filtered) + TaskId::Uuid(t2.get_uuid()), // B + ])], + }; + let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter) + .unwrap() + .map(|t| t.get_description().to_owned()) + .collect(); + filtered.sort(); + assert_eq!(vec![s!("A"), s!("B")], filtered); + } + + #[test] + fn partial_ids() { + let mut replica = test_replica(); + + let t1 = replica.new_task(Status::Pending, s!("A")).unwrap(); + let t2 = replica.new_task(Status::Completed, s!("B")).unwrap(); + let _t = replica.new_task(Status::Pending, s!("C")).unwrap(); + replica.rebuild_working_set(true).unwrap(); + + let t1uuid = t1.get_uuid(); + let t2uuid = t2.get_uuid().to_string(); + let t2partial = t2uuid[..13].to_owned(); + + let filter = Filter { + conditions: vec![Condition::IdList(vec![ + TaskId::Uuid(t1uuid), // A + TaskId::WorkingSetId(1), // A (again, dups filtered) + TaskId::PartialUuid(t2partial), // B + ])], + }; + let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter) + .unwrap() + .map(|t| t.get_description().to_owned()) + .collect(); + filtered.sort(); + assert_eq!(vec![s!("A"), s!("B")], filtered); + } + + #[test] + fn all_tasks() { + let mut replica = test_replica(); + + replica.new_task(Status::Pending, s!("A")).unwrap(); + replica.new_task(Status::Completed, s!("B")).unwrap(); + replica.new_task(Status::Deleted, s!("C")).unwrap(); + replica.rebuild_working_set(true).unwrap(); + + let filter = Filter { conditions: vec![] }; + let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter) + .unwrap() + .map(|t| t.get_description().to_owned()) + .collect(); + filtered.sort(); + assert_eq!(vec![s!("A"), s!("B"), s!("C")], filtered); + } + + #[test] + fn tag_filtering() -> anyhow::Result<()> { + let mut replica = test_replica(); + let yes = tag!("yes"); + let no = tag!("no"); + + let mut t1 = replica + .new_task(Status::Pending, s!("A"))? + .into_mut(&mut replica); + t1.add_tag(&yes)?; + let mut t2 = replica + .new_task(Status::Pending, s!("B"))? + .into_mut(&mut replica); + t2.add_tag(&yes)?; + t2.add_tag(&no)?; + let mut t3 = replica + .new_task(Status::Pending, s!("C"))? + .into_mut(&mut replica); + t3.add_tag(&no)?; + let _t4 = replica.new_task(Status::Pending, s!("D"))?; + + // look for just "yes" (A and B) + let filter = Filter { + conditions: vec![Condition::HasTag(tag!("yes"))], + }; + let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)? + .map(|t| t.get_description().to_owned()) + .collect(); + filtered.sort(); + assert_eq!(vec![s!("A"), s!("B")], filtered); + + // look for tags without "no" (A, D) + let filter = Filter { + conditions: vec![Condition::NoTag(tag!("no"))], + }; + let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter)? + .map(|t| t.get_description().to_owned()) + .collect(); + filtered.sort(); + assert_eq!(vec![s!("A"), s!("D")], filtered); + + // look for tags with "yes" and "no" (B) + let filter = Filter { + conditions: vec![ + Condition::HasTag(tag!("yes")), + Condition::HasTag(tag!("no")), + ], + }; + let filtered: Vec<_> = filtered_tasks(&mut replica, &filter)? + .map(|t| t.get_description().to_owned()) + .collect(); + assert_eq!(vec![s!("B")], filtered); + + Ok(()) + } + + #[test] + fn pending_tasks() { + let mut replica = test_replica(); + + replica.new_task(Status::Pending, s!("A")).unwrap(); + replica.new_task(Status::Completed, s!("B")).unwrap(); + replica.new_task(Status::Deleted, s!("C")).unwrap(); + replica.rebuild_working_set(true).unwrap(); + + let filter = Filter { + conditions: vec![Condition::Status(Status::Pending)], + }; + let mut filtered: Vec<_> = filtered_tasks(&mut replica, &filter) + .unwrap() + .map(|t| t.get_description().to_owned()) + .collect(); + filtered.sort(); + assert_eq!(vec![s!("A")], filtered); + } +} diff --git a/rust/cli/src/invocation/mod.rs b/rust/cli/src/invocation/mod.rs new file mode 100644 index 000000000..0b75799f4 --- /dev/null +++ b/rust/cli/src/invocation/mod.rs @@ -0,0 +1,179 @@ +//! The invocation module handles invoking the commands parsed by the argparse module. + +use crate::argparse::{Command, Subcommand}; +use crate::settings::Settings; +use taskchampion::{Replica, Server, ServerConfig, StorageConfig, Uuid}; +use termcolor::{ColorChoice, StandardStream}; + +mod cmd; +mod filter; +mod modify; +mod report; +mod util; + +#[cfg(test)] +mod test; + +use filter::filtered_tasks; +use modify::{apply_modification, resolve_modification, ResolvedModification}; +use report::display_report; + +/// Invoke the given Command in the context of the given settings +#[allow(clippy::needless_return)] +pub(crate) fn invoke(command: Command, settings: Settings) -> Result<(), crate::Error> { + log::debug!("command: {:?}", command); + log::debug!("settings: {:?}", settings); + + let mut w = get_writer(); + + // This function examines the command and breaks out the necessary bits to call one of the + // `execute` functions in a submodule of `cmd`. + + // match the subcommands that do not require a replica first, before + // getting the replica + match command { + Command { + subcommand: Subcommand::Help { summary }, + command_name, + } => return cmd::help::execute(&mut w, command_name, summary), + Command { + subcommand: Subcommand::Config { config_operation }, + .. + } => return cmd::config::execute(&mut w, config_operation, &settings), + Command { + subcommand: Subcommand::Version, + .. + } => return cmd::version::execute(&mut w), + _ => {} + }; + + let mut replica = get_replica(&settings)?; + match command { + Command { + subcommand: Subcommand::Add { modification }, + .. + } => { + let modification = resolve_modification(modification, &mut replica)?; + return cmd::add::execute(&mut w, &mut replica, modification); + } + + Command { + subcommand: + Subcommand::Modify { + filter, + modification, + }, + .. + } => { + let modification = resolve_modification(modification, &mut replica)?; + return cmd::modify::execute(&mut w, &mut replica, &settings, filter, modification); + } + + Command { + subcommand: + Subcommand::Report { + report_name, + filter, + }, + .. + } => return cmd::report::execute(&mut w, &mut replica, &settings, report_name, filter), + + Command { + subcommand: Subcommand::Info { filter, debug }, + .. + } => return cmd::info::execute(&mut w, &mut replica, filter, debug), + + Command { + subcommand: Subcommand::Gc, + .. + } => return cmd::gc::execute(&mut w, &mut replica), + + Command { + subcommand: Subcommand::Sync, + .. + } => { + let mut server = get_server(&settings)?; + return cmd::sync::execute(&mut w, &mut replica, &settings, &mut server); + } + + Command { + subcommand: Subcommand::ImportTW, + .. + } => { + return cmd::import_tw::execute(&mut w, &mut replica); + } + + Command { + subcommand: Subcommand::ImportTDB2 { path }, + .. + } => { + return cmd::import_tdb2::execute(&mut w, &mut replica, path.as_ref()); + } + + Command { + subcommand: Subcommand::Undo, + .. + } => { + return cmd::undo::execute(&mut w, &mut replica); + } + + // handled in the first match, but here to ensure this match is exhaustive + Command { + subcommand: Subcommand::Help { .. }, + .. + } => unreachable!(), + Command { + subcommand: Subcommand::Config { .. }, + .. + } => unreachable!(), + Command { + subcommand: Subcommand::Version, + .. + } => unreachable!(), + }; +} + +// utilities for invoke + +/// Get the replica for this invocation +fn get_replica(settings: &Settings) -> anyhow::Result { + let taskdb_dir = settings.data_dir.clone(); + log::debug!("Replica data_dir: {:?}", taskdb_dir); + let storage_config = StorageConfig::OnDisk { taskdb_dir }; + Ok(Replica::new(storage_config.into_storage()?)) +} + +/// Get the server for this invocation +fn get_server(settings: &Settings) -> anyhow::Result> { + // if server_client_key and server_origin are both set, use + // the remote server + let config = if let (Some(client_key), Some(origin), Some(encryption_secret)) = ( + settings.server_client_key.as_ref(), + settings.server_origin.as_ref(), + settings.encryption_secret.as_ref(), + ) { + let client_key = Uuid::parse_str(client_key)?; + + log::debug!("Using sync-server with origin {}", origin); + log::debug!("Sync client ID: {}", client_key); + ServerConfig::Remote { + origin: origin.clone(), + client_key, + encryption_secret: encryption_secret.as_bytes().to_vec(), + } + } else { + let server_dir = settings.server_dir.clone(); + log::debug!("Using local sync-server at `{:?}`", server_dir); + ServerConfig::Local { server_dir } + }; + config.into_server() +} + +/// Get a WriteColor implementation based on whether the output is a tty. +fn get_writer() -> StandardStream { + StandardStream::stdout(if atty::is(atty::Stream::Stdout) { + ColorChoice::Auto + } else { + ColorChoice::Never + }) +} diff --git a/rust/cli/src/invocation/modify.rs b/rust/cli/src/invocation/modify.rs new file mode 100644 index 000000000..934ab1bc2 --- /dev/null +++ b/rust/cli/src/invocation/modify.rs @@ -0,0 +1,247 @@ +use crate::argparse::{DescriptionMod, Modification, TaskId}; +use std::collections::HashSet; +use taskchampion::chrono::Utc; +use taskchampion::{Annotation, Replica, TaskMut}; + +/// A wrapper for Modification, promising that all TaskId instances are of variant TaskId::Uuid. +pub(super) struct ResolvedModification(pub(super) Modification); + +/// Resolve a Modification to a ResolvedModification, based on access to a Replica. +/// +/// This is not automatically done in `apply_modification` because, by that time, the TaskMut being +/// modified has an exclusive reference to the Replica, so it is impossible to search for matching +/// tasks. +pub(super) fn resolve_modification( + unres: Modification, + replica: &mut Replica, +) -> anyhow::Result { + Ok(ResolvedModification(Modification { + description: unres.description, + status: unres.status, + wait: unres.wait, + active: unres.active, + add_tags: unres.add_tags, + remove_tags: unres.remove_tags, + add_dependencies: resolve_task_ids(replica, unres.add_dependencies)?, + remove_dependencies: resolve_task_ids(replica, unres.remove_dependencies)?, + annotate: unres.annotate, + })) +} + +/// Convert a set of arbitrary TaskId's into TaskIds containing only TaskId::Uuid. +fn resolve_task_ids( + replica: &mut Replica, + task_ids: HashSet, +) -> anyhow::Result> { + // already all UUIDs (or empty)? + if task_ids.iter().all(|tid| matches!(tid, TaskId::Uuid(_))) { + return Ok(task_ids); + } + + let mut result = HashSet::new(); + let mut working_set = None; + let mut all_tasks = None; + for tid in task_ids { + match tid { + TaskId::WorkingSetId(i) => { + let ws = match working_set { + Some(ref ws) => ws, + None => { + working_set = Some(replica.working_set()?); + working_set.as_ref().unwrap() + } + }; + if let Some(u) = ws.by_index(i) { + result.insert(TaskId::Uuid(u)); + } + } + TaskId::PartialUuid(partial) => { + let ts = match all_tasks { + Some(ref ts) => ts, + None => { + all_tasks = Some( + replica + .all_task_uuids()? + .drain(..) + .map(|u| (u, u.to_string())) + .collect::>(), + ); + all_tasks.as_ref().unwrap() + } + }; + for (u, ustr) in ts { + if ustr.starts_with(&partial) { + result.insert(TaskId::Uuid(*u)); + } + } + } + TaskId::Uuid(u) => { + result.insert(TaskId::Uuid(u)); + } + } + } + + Ok(result) +} + +/// Apply the given modification +pub(super) fn apply_modification( + task: &mut TaskMut, + modification: &ResolvedModification, +) -> anyhow::Result<()> { + // unwrap the "Resolved" promise + let modification = &modification.0; + + match modification.description { + DescriptionMod::Set(ref description) => task.set_description(description.clone())?, + DescriptionMod::Prepend(ref description) => { + task.set_description(format!("{} {}", description, task.get_description()))? + } + DescriptionMod::Append(ref description) => { + task.set_description(format!("{} {}", task.get_description(), description))? + } + DescriptionMod::None => {} + } + + if let Some(ref status) = modification.status { + task.set_status(status.clone())?; + } + + if let Some(true) = modification.active { + task.start()?; + } + + if let Some(false) = modification.active { + task.stop()?; + } + + for tag in modification.add_tags.iter() { + task.add_tag(tag)?; + } + + for tag in modification.remove_tags.iter() { + task.remove_tag(tag)?; + } + + if let Some(wait) = modification.wait { + task.set_wait(wait)?; + } + + if let Some(ref ann) = modification.annotate { + task.add_annotation(Annotation { + entry: Utc::now(), + description: ann.into(), + })?; + } + + for tid in &modification.add_dependencies { + if let TaskId::Uuid(u) = tid { + task.add_dependency(*u)?; + } else { + // this Modification is resolved, so all TaskIds should + // be the Uuid variant. + unreachable!(); + } + } + + for tid in &modification.remove_dependencies { + if let TaskId::Uuid(u) = tid { + task.remove_dependency(*u)?; + } else { + // this Modification is resolved, so all TaskIds should + // be the Uuid variant. + unreachable!(); + } + } + + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::invocation::test::*; + use pretty_assertions::assert_eq; + use taskchampion::{Status, Uuid}; + + #[test] + fn test_resolve_modifications() { + let mut replica = test_replica(); + let u1 = Uuid::new_v4(); + let t1 = replica.new_task(Status::Pending, "a task".into()).unwrap(); + replica.rebuild_working_set(true).unwrap(); + + let modi = Modification { + add_dependencies: set![TaskId::Uuid(u1), TaskId::WorkingSetId(1)], + ..Default::default() + }; + + let res = resolve_modification(modi, &mut replica).unwrap(); + + assert_eq!( + res.0.add_dependencies, + set![TaskId::Uuid(u1), TaskId::Uuid(t1.get_uuid())], + ); + } + + #[test] + fn test_resolve_task_ids_empty() { + let mut replica = test_replica(); + + assert_eq!( + resolve_task_ids(&mut replica, HashSet::new()).unwrap(), + HashSet::new() + ); + } + + #[test] + fn test_resolve_task_ids_all_uuids() { + let mut replica = test_replica(); + let uuid = Uuid::new_v4(); + let tids = set![TaskId::Uuid(uuid)]; + assert_eq!(resolve_task_ids(&mut replica, tids.clone()).unwrap(), tids); + } + + #[test] + fn test_resolve_task_ids_working_set_not_found() { + let mut replica = test_replica(); + let tids = set![TaskId::WorkingSetId(13)]; + assert_eq!( + resolve_task_ids(&mut replica, tids.clone()).unwrap(), + HashSet::new() + ); + } + + #[test] + fn test_resolve_task_ids_working_set() { + let mut replica = test_replica(); + let t1 = replica.new_task(Status::Pending, "a task".into()).unwrap(); + let t2 = replica + .new_task(Status::Pending, "another task".into()) + .unwrap(); + replica.rebuild_working_set(true).unwrap(); + let tids = set![TaskId::WorkingSetId(1), TaskId::WorkingSetId(2)]; + let resolved = set![TaskId::Uuid(t1.get_uuid()), TaskId::Uuid(t2.get_uuid())]; + assert_eq!(resolve_task_ids(&mut replica, tids).unwrap(), resolved); + } + + #[test] + fn test_resolve_task_ids_partial_not_found() { + let mut replica = test_replica(); + let tids = set![TaskId::PartialUuid("abcd".into())]; + assert_eq!( + resolve_task_ids(&mut replica, tids.clone()).unwrap(), + HashSet::new() + ); + } + + #[test] + fn test_resolve_task_ids_partial() { + let mut replica = test_replica(); + let t1 = replica.new_task(Status::Pending, "a task".into()).unwrap(); + let uuid_str = t1.get_uuid().to_string(); + let tids = set![TaskId::PartialUuid(uuid_str[..8].into())]; + let resolved = set![TaskId::Uuid(t1.get_uuid())]; + assert_eq!(resolve_task_ids(&mut replica, tids).unwrap(), resolved); + } +} diff --git a/rust/cli/src/invocation/report.rs b/rust/cli/src/invocation/report.rs new file mode 100644 index 000000000..81b4c8622 --- /dev/null +++ b/rust/cli/src/invocation/report.rs @@ -0,0 +1,417 @@ +use crate::argparse::Filter; +use crate::invocation::filtered_tasks; +use crate::settings::{Column, Property, Report, Settings, SortBy}; +use crate::table; +use anyhow::anyhow; +use prettytable::{Row, Table}; +use std::cmp::Ordering; +use taskchampion::{Replica, Task, WorkingSet}; +use termcolor::WriteColor; + +/// Sort tasks for the given report. +fn sort_tasks(tasks: &mut Vec, report: &Report, working_set: &WorkingSet) { + tasks.sort_by(|a, b| { + for s in &report.sort { + let ord = match s.sort_by { + SortBy::Id => { + let a_uuid = a.get_uuid(); + let b_uuid = b.get_uuid(); + let a_id = working_set.by_uuid(a_uuid); + let b_id = working_set.by_uuid(b_uuid); + match (a_id, b_id) { + (Some(a_id), Some(b_id)) => a_id.cmp(&b_id), + (Some(_), None) => Ordering::Less, + (None, Some(_)) => Ordering::Greater, + (None, None) => a_uuid.cmp(&b_uuid), + } + } + SortBy::Uuid => a.get_uuid().cmp(&b.get_uuid()), + SortBy::Description => a.get_description().cmp(b.get_description()), + SortBy::Wait => a.get_wait().cmp(&b.get_wait()), + }; + // If this sort property is equal, go on to the next.. + if ord == Ordering::Equal { + continue; + } + // Reverse order if not ascending + if s.ascending { + return ord; + } else { + return ord.reverse(); + } + } + Ordering::Equal + }); +} + +/// Generate the string representation for the given task and column. +fn task_column(task: &Task, column: &Column, working_set: &WorkingSet) -> String { + match column.property { + Property::Id => { + let uuid = task.get_uuid(); + let mut id = uuid.to_string(); + if let Some(i) = working_set.by_uuid(uuid) { + id = i.to_string(); + } + id + } + Property::Uuid => { + let uuid = task.get_uuid(); + uuid.to_string() + } + Property::Active => match task.is_active() { + true => "*".to_owned(), + false => "".to_owned(), + }, + Property::Description => task.get_description().to_owned(), + Property::Tags => { + let mut tags = task + .get_tags() + .map(|t| format!("+{}", t)) + .collect::>(); + tags.sort(); + tags.join(" ") + } + Property::Wait => { + if task.is_waiting() { + task.get_wait().unwrap().format("%Y-%m-%d").to_string() + } else { + "".to_owned() + } + } + } +} + +pub(super) fn display_report( + w: &mut W, + replica: &mut Replica, + settings: &Settings, + report_name: String, + filter: Filter, +) -> Result<(), crate::Error> { + let mut t = Table::new(); + let working_set = replica.working_set()?; + + // Get the report from settings + let mut report = settings + .reports + .get(&report_name) + .ok_or_else(|| anyhow!("report `{}` not defined", report_name))? + .clone(); + + // include any user-supplied filter conditions + report.filter = report.filter.intersect(filter); + + // Get the tasks from the filter + let mut tasks: Vec<_> = filtered_tasks(replica, &report.filter)?.collect(); + + // ..sort them as desired + sort_tasks(&mut tasks, &report, &working_set); + + // ..set up the column titles + t.set_format(table::format()); + t.set_titles(report.columns.iter().map(|col| col.label.clone()).into()); + + // ..insert the data + for task in &tasks { + let row: Row = report + .columns + .iter() + .map(|col| task_column(task, col, &working_set)) + .collect::(); + t.add_row(row); + } + + // ..and display it + t.print(w)?; + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::invocation::test::*; + use crate::settings::Sort; + use pretty_assertions::assert_eq; + use std::convert::TryInto; + use taskchampion::chrono::{prelude::*, Duration}; + use taskchampion::{Status, Uuid}; + + fn create_tasks(replica: &mut Replica) -> [Uuid; 3] { + let t1 = replica.new_task(Status::Pending, s!("A")).unwrap(); + let t2 = replica.new_task(Status::Pending, s!("B")).unwrap(); + let t3 = replica.new_task(Status::Pending, s!("C")).unwrap(); + + // t2 is comleted and not in the working set + let mut t2 = t2.into_mut(replica); + t2.set_status(Status::Completed).unwrap(); + let t2 = t2.into_immut(); + + replica.rebuild_working_set(true).unwrap(); + + [t1.get_uuid(), t2.get_uuid(), t3.get_uuid()] + } + + #[test] + fn sorting_by_descr() { + let mut replica = test_replica(); + create_tasks(&mut replica); + let working_set = replica.working_set().unwrap(); + let mut report = Report { + sort: vec![Sort { + ascending: true, + sort_by: SortBy::Description, + }], + ..Default::default() + }; + + // ascending + let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect(); + sort_tasks(&mut tasks, &report, &working_set); + let descriptions: Vec<_> = tasks.iter().map(|t| t.get_description()).collect(); + assert_eq!(descriptions, vec!["A", "B", "C"]); + + // ascending + report.sort[0].ascending = false; + let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect(); + sort_tasks(&mut tasks, &report, &working_set); + let descriptions: Vec<_> = tasks.iter().map(|t| t.get_description()).collect(); + assert_eq!(descriptions, vec!["C", "B", "A"]); + } + + #[test] + fn sorting_by_id() { + let mut replica = test_replica(); + create_tasks(&mut replica); + let working_set = replica.working_set().unwrap(); + let mut report = Report { + sort: vec![Sort { + ascending: true, + sort_by: SortBy::Id, + }], + ..Default::default() + }; + + // ascending + let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect(); + sort_tasks(&mut tasks, &report, &working_set); + let descriptions: Vec<_> = tasks.iter().map(|t| t.get_description()).collect(); + assert_eq!(descriptions, vec!["A", "C", "B"]); + + // ascending + report.sort[0].ascending = false; + let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect(); + sort_tasks(&mut tasks, &report, &working_set); + let descriptions: Vec<_> = tasks.iter().map(|t| t.get_description()).collect(); + assert_eq!(descriptions, vec!["B", "C", "A"]); + } + + #[test] + fn sorting_by_uuid() { + let mut replica = test_replica(); + let uuids = create_tasks(&mut replica); + let working_set = replica.working_set().unwrap(); + let report = Report { + sort: vec![Sort { + ascending: true, + sort_by: SortBy::Uuid, + }], + ..Default::default() + }; + + let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect(); + sort_tasks(&mut tasks, &report, &working_set); + let got_uuids: Vec<_> = tasks.iter().map(|t| t.get_uuid()).collect(); + let mut exp_uuids = uuids.to_vec(); + exp_uuids.sort(); + assert_eq!(got_uuids, exp_uuids); + } + + #[test] + fn sorting_by_wait() { + let mut replica = test_replica(); + let uuids = create_tasks(&mut replica); + + replica + .get_task(uuids[0]) + .unwrap() + .unwrap() + .into_mut(&mut replica) + .set_wait(Some(Utc::now() + Duration::days(2))) + .unwrap(); + + replica + .get_task(uuids[1]) + .unwrap() + .unwrap() + .into_mut(&mut replica) + .set_wait(Some(Utc::now() + Duration::days(3))) + .unwrap(); + + let working_set = replica.working_set().unwrap(); + + let report = Report { + sort: vec![Sort { + ascending: true, + sort_by: SortBy::Wait, + }], + ..Default::default() + }; + + let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect(); + sort_tasks(&mut tasks, &report, &working_set); + let got_uuids: Vec<_> = tasks.iter().map(|t| t.get_uuid()).collect(); + + let exp_uuids = vec![ + uuids[2], // no wait + uuids[0], // wait:2d + uuids[1], // wait:3d + ]; + + assert_eq!(got_uuids, exp_uuids); + } + + #[test] + fn sorting_by_multiple() { + let mut replica = test_replica(); + create_tasks(&mut replica); + + // make a second task named A with a larger ID than the first + let t = replica.new_task(Status::Pending, s!("A")).unwrap(); + t.into_mut(&mut replica) + .add_tag(&("second".try_into().unwrap())) + .unwrap(); + + let working_set = replica.working_set().unwrap(); + let report = Report { + sort: vec![ + Sort { + ascending: false, + sort_by: SortBy::Description, + }, + Sort { + ascending: true, + sort_by: SortBy::Id, + }, + ], + ..Default::default() + }; + + let mut tasks: Vec<_> = replica.all_tasks().unwrap().values().cloned().collect(); + sort_tasks(&mut tasks, &report, &working_set); + let descriptions: Vec<_> = tasks.iter().map(|t| t.get_description()).collect(); + assert_eq!(descriptions, vec!["C", "B", "A", "A"]); + assert!(tasks[3].has_tag(&("second".try_into().unwrap()))); + } + + #[test] + fn task_column_id() { + let mut replica = test_replica(); + let uuids = create_tasks(&mut replica); + let working_set = replica.working_set().unwrap(); + + let task = replica.get_task(uuids[0]).unwrap().unwrap(); + let column = Column { + label: s!(""), + property: Property::Id, + }; + assert_eq!(task_column(&task, &column, &working_set), s!("1")); + + // get the task that's not in the working set, which should show + // a uuid for its id column + let task = replica.get_task(uuids[1]).unwrap().unwrap(); + assert_eq!( + task_column(&task, &column, &working_set), + uuids[1].to_string() + ); + } + + #[test] + fn task_column_uuid() { + let mut replica = test_replica(); + let uuids = create_tasks(&mut replica); + let working_set = replica.working_set().unwrap(); + + let task = replica.get_task(uuids[0]).unwrap().unwrap(); + let column = Column { + label: s!(""), + property: Property::Uuid, + }; + assert_eq!( + task_column(&task, &column, &working_set), + task.get_uuid().to_string() + ); + } + + #[test] + fn task_column_active() { + let mut replica = test_replica(); + let uuids = create_tasks(&mut replica); + let working_set = replica.working_set().unwrap(); + + // make task A active + replica + .get_task(uuids[0]) + .unwrap() + .unwrap() + .into_mut(&mut replica) + .start() + .unwrap(); + + let column = Column { + label: s!(""), + property: Property::Active, + }; + + let task = replica.get_task(uuids[0]).unwrap().unwrap(); + assert_eq!(task_column(&task, &column, &working_set), s!("*")); + let task = replica.get_task(uuids[2]).unwrap().unwrap(); + assert_eq!(task_column(&task, &column, &working_set), s!("")); + } + + #[test] + fn task_column_description() { + let mut replica = test_replica(); + let uuids = create_tasks(&mut replica); + let working_set = replica.working_set().unwrap(); + + let task = replica.get_task(uuids[2]).unwrap().unwrap(); + let column = Column { + label: s!(""), + property: Property::Description, + }; + assert_eq!(task_column(&task, &column, &working_set), s!("C")); + } + + #[test] + fn task_column_tags() { + let mut replica = test_replica(); + let uuids = create_tasks(&mut replica); + let working_set = replica.working_set().unwrap(); + + // add some tags to task A + let mut t1 = replica + .get_task(uuids[0]) + .unwrap() + .unwrap() + .into_mut(&mut replica); + t1.add_tag(&("foo".try_into().unwrap())).unwrap(); + t1.add_tag(&("bar".try_into().unwrap())).unwrap(); + + let column = Column { + label: s!(""), + property: Property::Tags, + }; + + let task = replica.get_task(uuids[0]).unwrap().unwrap(); + assert_eq!( + task_column(&task, &column, &working_set), + s!("+PENDING +UNBLOCKED +bar +foo") + ); + let task = replica.get_task(uuids[2]).unwrap().unwrap(); + assert_eq!( + task_column(&task, &column, &working_set), + s!("+PENDING +UNBLOCKED") + ); + } +} diff --git a/rust/cli/src/invocation/test.rs b/rust/cli/src/invocation/test.rs new file mode 100644 index 000000000..72f11e137 --- /dev/null +++ b/rust/cli/src/invocation/test.rs @@ -0,0 +1,51 @@ +use std::io; +use taskchampion::{storage, Replica, Server, ServerConfig}; +use tempfile::TempDir; + +pub(super) fn test_replica() -> Replica { + let storage = storage::InMemoryStorage::new(); + Replica::new(Box::new(storage)) +} + +pub(super) fn test_server(dir: &TempDir) -> Box { + ServerConfig::Local { + server_dir: dir.path().to_path_buf(), + } + .into_server() + .unwrap() +} + +pub(super) struct TestWriter { + data: Vec, +} + +impl TestWriter { + pub(super) fn into_string(self) -> String { + String::from_utf8(self.data).unwrap() + } +} + +impl io::Write for TestWriter { + fn write(&mut self, buf: &[u8]) -> io::Result { + self.data.write(buf) + } + fn flush(&mut self) -> io::Result<()> { + self.data.flush() + } +} + +impl termcolor::WriteColor for TestWriter { + fn supports_color(&self) -> bool { + false + } + fn set_color(&mut self, _spec: &termcolor::ColorSpec) -> io::Result<()> { + Ok(()) + } + fn reset(&mut self) -> io::Result<()> { + Ok(()) + } +} + +pub(super) fn test_writer() -> TestWriter { + TestWriter { data: vec![] } +} diff --git a/rust/cli/src/invocation/util.rs b/rust/cli/src/invocation/util.rs new file mode 100644 index 000000000..12f4535e8 --- /dev/null +++ b/rust/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/rust/cli/src/lib.rs b/rust/cli/src/lib.rs new file mode 100644 index 000000000..3b3258f21 --- /dev/null +++ b/rust/cli/src/lib.rs @@ -0,0 +1,79 @@ +#![deny(clippy::all)] +#![allow(clippy::unnecessary_wraps)] // for Rust 1.50, https://github.com/rust-lang/rust-clippy/pull/6765 +#![allow(clippy::module_inception)] // we use re-exports to shorten stuttering paths like settings::settings::Settings +/*! +This crate implements the command-line interface to TaskChampion. + +## Design + +The crate is split into two parts: argument parsing (`argparse`) and command invocation (`invocation`). +Both are fairly complex operations, and the split serves both to isolate that complexity and to facilitate testing. + +### Argparse + +The TaskChampion command line API is modeled on TaskWarrior's API, which is far from that of a typical UNIX command. +Tools like `clap` and `structopt` are not flexible enough to handle this syntax. + +Instead, the `argparse` module uses [nom](https://crates.io/crates/nom) to parse command lines as a sequence of words. +These parsers act on a list of strings, `&[&str]`, and at the top level return a `crate::argparse::Command`. +This is a wholly-owned repesentation of the command line's meaning, but with some interpretation. +For example, `task start`, `task stop`, and `task append` all map to a `crate::argparse::Subcommand::Modify` variant. + +### Invocation + +The `invocation` module executes a `Command`, given some settings and other ancillary data. +Most of its functionality is in common functions to handle filtering tasks, modifying tasks, and so on. + +## Rust API + +Note that this crate does not expose a Rust API for use from other crates. +For the public TaskChampion Rust API, see the `taskchampion` crate. + +*/ + +use std::ffi::OsString; + +// NOTE: it's important that this 'mod' comes first so that the macros can be used in other modules +mod macros; + +mod argparse; +mod errors; +mod invocation; +mod settings; +mod table; +mod tdb2; +mod usage; + +/// See https://docs.rs/built +pub(crate) mod built_info { + include!(concat!(env!("OUT_DIR"), "/built.rs")); +} + +pub(crate) use errors::Error; +use settings::Settings; + +// used by the `generate` command +pub use usage::Usage; + +/// The main entry point for the command-line interface. This builds an Invocation +/// from the particulars of the operating-system interface, and then executes it. +pub fn main() -> Result<(), Error> { + env_logger::init(); + + // parse the command line into a vector of &str, failing if + // there are invalid utf-8 sequences. + let argv: Vec = std::env::args_os() + .map(|oss| oss.into_string()) + .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 + let command = argparse::Command::from_argv(&argv[..])?; + + // load the application settings + let settings = Settings::read()?; + + invocation::invoke(command, settings)?; + Ok(()) +} diff --git a/rust/cli/src/macros.rs b/rust/cli/src/macros.rs new file mode 100644 index 000000000..f2cbe803b --- /dev/null +++ b/rust/cli/src/macros.rs @@ -0,0 +1,40 @@ +#![macro_use] + +/// create a &[&str] from vec notation +#[cfg(test)] +macro_rules! argv { + () => ( + &[][..] + ); + ($($x:expr),* $(,)?) => ( + &[$($x),*][..] + ); +} + +/// Create a hashset, similar to vec! +// NOTE: in Rust 1.56.0, this can be changed to HashSet::from([..]) +#[cfg(test)] +macro_rules! set( + { $($key:expr),* $(,)? } => { + { + #[allow(unused_mut)] + let mut s = ::std::collections::HashSet::new(); + $( + s.insert($key); + )* + s + } + }; +); + +/// Create a String from an &str; just a testing shorthand +#[cfg(test)] +macro_rules! s( + { $s:expr } => { $s.to_owned() }; +); + +/// Create a Tag from an &str; just a testing shorthand +#[cfg(test)] +macro_rules! tag( + { $s:expr } => { { use std::convert::TryFrom; taskchampion::Tag::try_from($s).unwrap() } }; +); diff --git a/rust/cli/src/settings/mod.rs b/rust/cli/src/settings/mod.rs new file mode 100644 index 000000000..c6a6ddd2f --- /dev/null +++ b/rust/cli/src/settings/mod.rs @@ -0,0 +1,11 @@ +//! Support for the CLI's configuration file, including default settings. +//! +//! Configuration is stored in a "parsed" format, meaning that any syntax errors will be caught on +//! startup and not just when those values are used. + +mod report; +mod settings; +mod util; + +pub(crate) use report::{get_usage, Column, Property, Report, Sort, SortBy}; +pub(crate) use settings::Settings; diff --git a/rust/cli/src/settings/report.rs b/rust/cli/src/settings/report.rs new file mode 100644 index 000000000..3cc5e3e2c --- /dev/null +++ b/rust/cli/src/settings/report.rs @@ -0,0 +1,580 @@ +//! This module contains the data structures used to define reports. + +use crate::argparse::{Condition, Filter}; +use crate::settings::util::table_with_keys; +use crate::usage::{self, Usage}; +use anyhow::{anyhow, bail, Result}; +use std::convert::{TryFrom, TryInto}; + +/// A report specifies a filter as well as a sort order and information about which +/// task attributes to display +#[derive(Clone, Debug, PartialEq, Default)] +pub(crate) struct Report { + /// Columns to display in this report + pub columns: Vec, + /// Sort order for this report + pub sort: Vec, + /// Filter selecting tasks for this report + pub filter: Filter, +} + +/// A column to display in a report +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Column { + /// The label for this column + pub label: String, + + /// The property to display + pub property: Property, +} + +/// Task property to display in a report +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum Property { + // NOTE: when adding a property here, add it to get_usage, below, as well. + /// The task's ID, either working-set index or Uuid if not in the working set + Id, + + /// The task's full UUID + Uuid, + + /// Whether the task is active or not + Active, + + /// The task's description + Description, + + /// The task's tags + Tags, + + /// The task's wait date + Wait, +} + +/// A sorting criterion for a sort operation. +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct Sort { + /// True if the sort should be "ascending" (a -> z, 0 -> 9, etc.) + pub ascending: bool, + + /// The property to sort on + pub sort_by: SortBy, +} + +/// Task property to sort by +#[derive(Clone, Debug, PartialEq)] +pub(crate) enum SortBy { + // NOTE: when adding a property here, add it to get_usage, below, as well. + /// The task's ID, either working-set index or a UUID prefix; working + /// set tasks sort before others. + Id, + + /// The task's full UUID + Uuid, + + /// The task's description + Description, + + /// The task's wait date + Wait, +} + +// Conversions from settings::Settings. + +impl TryFrom for Report { + type Error = anyhow::Error; + + fn try_from(cfg: toml::Value) -> Result { + Report::try_from(&cfg) + } +} + +impl TryFrom<&toml::Value> for Report { + type Error = anyhow::Error; + + /// Create a Report from a toml value. This should be the `report.` value. + /// The error message begins with any additional path information, e.g., `.sort[1].sort_by: + /// ..`. + fn try_from(cfg: &toml::Value) -> Result { + let keys = ["sort", "columns", "filter"]; + let table = table_with_keys(cfg, &keys).map_err(|e| anyhow!(": {}", e))?; + + let sort = match table.get("sort") { + Some(v) => v + .as_array() + .ok_or_else(|| anyhow!(".sort: not an array"))? + .iter() + .enumerate() + .map(|(i, v)| v.try_into().map_err(|e| anyhow!(".sort[{}]{}", i, e))) + .collect::>>()?, + None => vec![], + }; + + let columns = match table.get("columns") { + Some(v) => v + .as_array() + .ok_or_else(|| anyhow!(".columns: not an array"))? + .iter() + .enumerate() + .map(|(i, v)| v.try_into().map_err(|e| anyhow!(".columns[{}]{}", i, e))) + .collect::>>()?, + None => bail!(": `columns` property is required"), + }; + + let conditions = match table.get("filter") { + Some(v) => v + .as_array() + .ok_or_else(|| anyhow!(".filter: not an array"))? + .iter() + .enumerate() + .map(|(i, v)| { + v.as_str() + .ok_or_else(|| anyhow!(".filter[{}]: not a string", i)) + .and_then(Condition::parse_str) + .map_err(|e| anyhow!(".filter[{}]: {}", i, e)) + }) + .collect::>>()?, + None => vec![], + }; + + Ok(Report { + columns, + sort, + filter: Filter { conditions }, + }) + } +} + +impl TryFrom<&toml::Value> for Column { + type Error = anyhow::Error; + + fn try_from(cfg: &toml::Value) -> Result { + let keys = ["label", "property"]; + let table = table_with_keys(cfg, &keys).map_err(|e| anyhow!(": {}", e))?; + + let label = match table.get("label") { + Some(v) => v + .as_str() + .ok_or_else(|| anyhow!(".label: not a string"))? + .to_owned(), + None => bail!(": `label` property is required"), + }; + + let property = match table.get("property") { + Some(v) => v.try_into().map_err(|e| anyhow!(".property{}", e))?, + None => bail!(": `property` property is required"), + }; + + Ok(Column { label, property }) + } +} + +impl TryFrom<&toml::Value> for Property { + type Error = anyhow::Error; + + fn try_from(cfg: &toml::Value) -> Result { + let s = cfg.as_str().ok_or_else(|| anyhow!(": not a string"))?; + Ok(match s { + "id" => Property::Id, + "uuid" => Property::Uuid, + "active" => Property::Active, + "description" => Property::Description, + "tags" => Property::Tags, + "wait" => Property::Wait, + _ => bail!(": unknown property {}", s), + }) + } +} + +impl TryFrom<&toml::Value> for Sort { + type Error = anyhow::Error; + + fn try_from(cfg: &toml::Value) -> Result { + let keys = ["ascending", "sort_by"]; + let table = table_with_keys(cfg, &keys).map_err(|e| anyhow!(": {}", e))?; + let ascending = match table.get("ascending") { + Some(v) => v + .as_bool() + .ok_or_else(|| anyhow!(".ascending: not a boolean value"))?, + None => true, // default + }; + + let sort_by = match table.get("sort_by") { + Some(v) => v.try_into().map_err(|e| anyhow!(".sort_by{}", e))?, + None => bail!(": `sort_by` property is required"), + }; + + Ok(Sort { ascending, sort_by }) + } +} + +impl TryFrom<&toml::Value> for SortBy { + type Error = anyhow::Error; + + fn try_from(cfg: &toml::Value) -> Result { + let s = cfg.as_str().ok_or_else(|| anyhow!(": not a string"))?; + Ok(match s { + "id" => SortBy::Id, + "uuid" => SortBy::Uuid, + "description" => SortBy::Description, + "wait" => SortBy::Wait, + _ => bail!(": unknown sort_by value `{}`", s), + }) + } +} + +pub(crate) fn get_usage(u: &mut Usage) { + u.report_properties.push(usage::ReportProperty { + name: "id", + as_sort_by: Some("Sort by the task's shorthand ID"), + as_column: Some("The task's shorthand ID"), + }); + u.report_properties.push(usage::ReportProperty { + name: "uuid", + as_sort_by: Some("Sort by the task's full UUID"), + as_column: Some("The task's full UUID"), + }); + u.report_properties.push(usage::ReportProperty { + name: "active", + as_sort_by: None, + as_column: Some("`*` if the task is active (started)"), + }); + u.report_properties.push(usage::ReportProperty { + name: "wait", + as_sort_by: Some("Sort by the task's wait date, with non-waiting tasks first"), + as_column: Some("Wait date of the task"), + }); + u.report_properties.push(usage::ReportProperty { + name: "description", + as_sort_by: Some("Sort by the task's description"), + as_column: Some("The task's description"), + }); + u.report_properties.push(usage::ReportProperty { + name: "tags", + as_sort_by: None, + as_column: Some("The task's tags"), + }); +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + use taskchampion::Status; + use toml::toml; + + #[test] + fn test_report_ok() { + let val = toml! { + sort = [] + columns = [] + filter = ["status:pending"] + }; + let report: Report = TryInto::try_into(val).unwrap(); + assert_eq!( + report.filter, + Filter { + conditions: vec![Condition::Status(Status::Pending),], + } + ); + assert_eq!(report.columns, vec![]); + assert_eq!(report.sort, vec![]); + } + + #[test] + fn test_report_no_sort() { + let val = toml! { + filter = [] + columns = [] + }; + let report = Report::try_from(val).unwrap(); + assert_eq!(report.sort, vec![]); + } + + #[test] + fn test_report_sort_not_array() { + let val = toml! { + filter = [] + sort = true + columns = [] + }; + let err = Report::try_from(val).unwrap_err().to_string(); + assert_eq!(&err, ".sort: not an array"); + } + + #[test] + fn test_report_sort_error() { + let val = toml! { + filter = [] + sort = [ { sort_by = "id" }, true ] + columns = [] + }; + let err = Report::try_from(val).unwrap_err().to_string(); + assert!(err.starts_with(".sort[1]")); + } + + #[test] + fn test_report_unknown_prop() { + let val = toml! { + columns = [] + filter = [] + sort = [] + nosuch = true + }; + let err = Report::try_from(val).unwrap_err().to_string(); + assert_eq!(&err, ": unknown table key `nosuch`"); + } + + #[test] + fn test_report_no_columns() { + let val = toml! { + filter = [] + sort = [] + }; + let err = Report::try_from(val).unwrap_err().to_string(); + assert_eq!(&err, ": `columns` property is required"); + } + + #[test] + fn test_report_columns_not_array() { + let val = toml! { + filter = [] + sort = [] + columns = true + }; + let err = Report::try_from(val).unwrap_err().to_string(); + assert_eq!(&err, ".columns: not an array"); + } + + #[test] + fn test_report_column_error() { + let val = toml! { + filter = [] + sort = [] + + [[columns]] + label = "ID" + property = "id" + + [[columns]] + foo = 10 + }; + let err = Report::try_from(val).unwrap_err().to_string(); + assert_eq!(&err, ".columns[1]: unknown table key `foo`"); + } + + #[test] + fn test_report_filter_not_array() { + let val = toml! { + filter = "foo" + sort = [] + columns = [] + }; + let err = Report::try_from(val).unwrap_err().to_string(); + assert_eq!(&err, ".filter: not an array"); + } + + #[test] + fn test_report_filter_error() { + let val = toml! { + sort = [] + columns = [] + filter = [ "nosuchfilter" ] + }; + let err = Report::try_from(val).unwrap_err().to_string(); + assert!(err.starts_with(".filter[0]: invalid filter condition:")); + } + + #[test] + fn test_column() { + let val = toml! { + label = "ID" + property = "id" + }; + let column = Column::try_from(&val).unwrap(); + assert_eq!( + column, + Column { + label: "ID".to_owned(), + property: Property::Id, + } + ); + } + + #[test] + fn test_column_unknown_prop() { + let val = toml! { + label = "ID" + property = "id" + nosuch = "foo" + }; + assert_eq!( + &Column::try_from(&val).unwrap_err().to_string(), + ": unknown table key `nosuch`" + ); + } + + #[test] + fn test_column_no_label() { + let val = toml! { + property = "id" + }; + assert_eq!( + &Column::try_from(&val).unwrap_err().to_string(), + ": `label` property is required" + ); + } + + #[test] + fn test_column_invalid_label() { + let val = toml! { + label = [] + property = "id" + }; + assert_eq!( + &Column::try_from(&val).unwrap_err().to_string(), + ".label: not a string" + ); + } + + #[test] + fn test_column_no_property() { + let val = toml! { + label = "ID" + }; + assert_eq!( + &Column::try_from(&val).unwrap_err().to_string(), + ": `property` property is required" + ); + } + + #[test] + fn test_column_invalid_property() { + let val = toml! { + label = "ID" + property = [] + }; + assert_eq!( + &Column::try_from(&val).unwrap_err().to_string(), + ".property: not a string" + ); + } + + #[test] + fn test_property() { + let val = toml::Value::String("uuid".to_owned()); + let prop = Property::try_from(&val).unwrap(); + assert_eq!(prop, Property::Uuid); + } + + #[test] + fn test_property_invalid_type() { + let val = toml::Value::Array(vec![]); + assert_eq!( + &Property::try_from(&val).unwrap_err().to_string(), + ": not a string" + ); + } + + #[test] + fn test_sort() { + let val = toml! { + ascending = false + sort_by = "id" + }; + let sort = Sort::try_from(&val).unwrap(); + assert_eq!( + sort, + Sort { + ascending: false, + sort_by: SortBy::Id, + } + ); + } + + #[test] + fn test_sort_no_ascending() { + let val = toml! { + sort_by = "id" + }; + let sort = Sort::try_from(&val).unwrap(); + assert_eq!( + sort, + Sort { + ascending: true, + sort_by: SortBy::Id, + } + ); + } + + #[test] + fn test_sort_unknown_prop() { + let val = toml! { + sort_by = "id" + nosuch = true + }; + assert_eq!( + &Sort::try_from(&val).unwrap_err().to_string(), + ": unknown table key `nosuch`" + ); + } + + #[test] + fn test_sort_no_sort_by() { + let val = toml! { + ascending = true + }; + assert_eq!( + &Sort::try_from(&val).unwrap_err().to_string(), + ": `sort_by` property is required" + ); + } + + #[test] + fn test_sort_invalid_ascending() { + let val = toml! { + sort_by = "id" + ascending = {} + }; + assert_eq!( + &Sort::try_from(&val).unwrap_err().to_string(), + ".ascending: not a boolean value" + ); + } + + #[test] + fn test_sort_invalid_sort_by() { + let val = toml! { + sort_by = {} + }; + assert_eq!( + &Sort::try_from(&val).unwrap_err().to_string(), + ".sort_by: not a string" + ); + } + + #[test] + fn test_sort_by() { + let val = toml::Value::String("uuid".to_string()); + let prop = SortBy::try_from(&val).unwrap(); + assert_eq!(prop, SortBy::Uuid); + } + + #[test] + fn test_sort_by_unknown() { + let val = toml::Value::String("nosuch".to_string()); + assert_eq!( + &SortBy::try_from(&val).unwrap_err().to_string(), + ": unknown sort_by value `nosuch`" + ); + } + + #[test] + fn test_sort_by_invalid_type() { + let val = toml::Value::Array(vec![]); + assert_eq!( + &SortBy::try_from(&val).unwrap_err().to_string(), + ": not a string" + ); + } +} diff --git a/rust/cli/src/settings/settings.rs b/rust/cli/src/settings/settings.rs new file mode 100644 index 000000000..3dba86409 --- /dev/null +++ b/rust/cli/src/settings/settings.rs @@ -0,0 +1,449 @@ +use super::util::table_with_keys; +use super::{Column, Property, Report, Sort, SortBy}; +use crate::argparse::{Condition, Filter}; +use anyhow::{anyhow, bail, Context, Result}; +use std::collections::HashMap; +use std::convert::TryFrom; +use std::env; +use std::fs; +use std::path::PathBuf; +use taskchampion::Status; +use toml::value::Table; +use toml_edit::Document; + +#[derive(Debug, PartialEq)] +pub(crate) struct Settings { + /// filename from which this configuration was loaded, if any + pub(crate) filename: Option, + + /// 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, + pub(crate) avoid_snapshots: bool, + + /// remote sync server + pub(crate) server_client_key: Option, + pub(crate) server_origin: Option, + pub(crate) encryption_secret: Option, + + /// local sync server + pub(crate) server_dir: PathBuf, + + /// reports + pub(crate) reports: HashMap, +} + +impl Settings { + pub(crate) fn read() -> Result { + if let Some(config_file) = env::var_os("TASKCHAMPION_CONFIG") { + log::debug!("Loading configuration from {:?}", config_file); + env::remove_var("TASKCHAMPION_CONFIG"); + Self::load_from_file(config_file.into(), true) + } else if let Some(filename) = Settings::default_filename() { + log::debug!("Loading configuration from {:?} (optional)", filename); + Self::load_from_file(filename, false) + } else { + Ok(Default::default()) + } + } + + /// Get the default filename for the configuration, or None if that cannot + /// be determined. + fn default_filename() -> Option { + dirs_next::config_dir().map(|dir| dir.join("taskchampion.toml")) + } + + /// Update this settings object with the contents of the given TOML file. Top-level settings + /// are overwritten, and reports are overwritten by name. + pub(crate) fn load_from_file(config_file: PathBuf, required: bool) -> Result { + let mut settings = Self::default(); + + let config_toml = match fs::read_to_string(config_file.clone()) { + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return if required { + Err(e.into()) + } else { + settings.filename = Some(config_file); + Ok(settings) + }; + } + Err(e) => return Err(e.into()), + Ok(s) => s, + }; + + let config_toml = config_toml + .parse::() + .with_context(|| format!("error while reading {:?}", config_file))?; + + settings.filename = Some(config_file.clone()); + settings + .update_from_toml(&config_toml) + .with_context(|| format!("error while parsing {:?}", config_file))?; + + Ok(settings) + } + + /// Update this object with configuration from the given config file. This is + /// broken out mostly for convenience in error handling + fn update_from_toml(&mut self, config_toml: &toml::Value) -> Result<()> { + let table_keys = [ + "data_dir", + "modification_count_prompt", + "avoid_snapshots", + "server_client_key", + "server_origin", + "encryption_secret", + "server_dir", + "reports", + ]; + let table = table_with_keys(config_toml, &table_keys)?; + + fn get_str_cfg( + table: &Table, + name: &'static str, + setter: F, + ) -> Result<()> { + if let Some(v) = table.get(name) { + setter( + v.as_str() + .ok_or_else(|| anyhow!(".{}: not a string", name))? + .to_owned(), + ); + } + Ok(()) + } + + fn get_i64_cfg(table: &Table, name: &'static str, setter: F) -> Result<()> { + if let Some(v) = table.get(name) { + setter( + v.as_integer() + .ok_or_else(|| anyhow!(".{}: not a number", name))?, + ); + } + Ok(()) + } + + fn get_bool_cfg( + table: &Table, + name: &'static str, + setter: F, + ) -> Result<()> { + if let Some(v) = table.get(name) { + setter( + v.as_bool() + .ok_or_else(|| anyhow!(".{}: not a boolean value", name))?, + ); + } + Ok(()) + } + + get_str_cfg(table, "data_dir", |v| { + self.data_dir = v.into(); + })?; + + get_i64_cfg(table, "modification_count_prompt", |v| { + self.modification_count_prompt = Some(v); + })?; + + get_bool_cfg(table, "avoid_snapshots", |v| { + self.avoid_snapshots = v; + })?; + + get_str_cfg(table, "server_client_key", |v| { + self.server_client_key = Some(v); + })?; + + get_str_cfg(table, "server_origin", |v| { + self.server_origin = Some(v); + })?; + + get_str_cfg(table, "encryption_secret", |v| { + self.encryption_secret = Some(v); + })?; + + get_str_cfg(table, "server_dir", |v| { + self.server_dir = v.into(); + })?; + + if let Some(v) = table.get("reports") { + let report_cfgs = v + .as_table() + .ok_or_else(|| anyhow!(".reports: not a table"))?; + for (name, cfg) in report_cfgs { + let report = Report::try_from(cfg).map_err(|e| anyhow!("reports.{}{}", name, e))?; + self.reports.insert(name.clone(), report); + } + } + + Ok(()) + } + + /// Set a value in the config file, modifying it in place. Returns the filename. The value is + /// interpreted as the appropriate type for the configuration setting. + pub(crate) fn set(&self, key: &str, value: &str) -> Result { + let allowed_keys = [ + "data_dir", + "modification_count_prompt", + "server_client_key", + "server_origin", + "encryption_secret", + "server_dir", + // reports is not allowed, since it is not a string + ]; + if !allowed_keys.contains(&key) { + bail!("No such configuration key {}", key); + } + + let filename = if let Some(ref f) = self.filename { + f.clone() + } else { + Settings::default_filename() + .ok_or_else(|| anyhow!("Could not determine config file name"))? + }; + + let exists = filename.exists(); + + // try to create the parent directory if the file does not exist + if !exists { + if let Some(dir) = filename.parent() { + fs::create_dir_all(dir)?; + } + } + + // start with the existing document, or a blank document + let mut document = if exists { + fs::read_to_string(filename.clone()) + .context("Could not read existing configuration file")? + .parse::() + .context("Could not parse existing configuration file")? + } else { + Document::new() + }; + + // set the value as the correct type + match key { + // integers + "modification_count_prompt" => { + let value: i64 = value.parse()?; + document[key] = toml_edit::value(value); + } + + // most keys are strings + _ => document[key] = toml_edit::value(value), + } + + fs::write(filename.clone(), document.to_string()) + .context("Could not write updated configuration file")?; + + Ok(filename) + } +} + +impl Default for Settings { + fn default() -> Self { + let data_dir; + let server_dir; + + if let Some(dir) = dirs_next::data_local_dir() { + data_dir = dir.join("taskchampion"); + server_dir = dir.join("taskchampion-sync-server"); + } else { + // fallback + data_dir = PathBuf::from("."); + server_dir = PathBuf::from("."); + } + + // define the default reports + let mut reports = HashMap::new(); + + reports.insert( + "list".to_owned(), + Report { + sort: vec![Sort { + ascending: true, + sort_by: SortBy::Uuid, + }], + columns: vec![ + Column { + label: "id".to_owned(), + property: Property::Id, + }, + Column { + label: "description".to_owned(), + property: Property::Description, + }, + Column { + label: "active".to_owned(), + property: Property::Active, + }, + Column { + label: "tags".to_owned(), + property: Property::Tags, + }, + Column { + label: "wait".to_owned(), + property: Property::Wait, + }, + ], + filter: Default::default(), + }, + ); + + reports.insert( + "next".to_owned(), + Report { + sort: vec![ + Sort { + ascending: true, + sort_by: SortBy::Id, + }, + Sort { + ascending: true, + sort_by: SortBy::Uuid, + }, + ], + columns: vec![ + Column { + label: "id".to_owned(), + property: Property::Id, + }, + Column { + label: "description".to_owned(), + property: Property::Description, + }, + Column { + label: "active".to_owned(), + property: Property::Active, + }, + Column { + label: "tags".to_owned(), + property: Property::Tags, + }, + ], + filter: Filter { + conditions: vec![Condition::Status(Status::Pending)], + }, + }, + ); + + Self { + filename: None, + data_dir, + modification_count_prompt: None, + avoid_snapshots: false, + server_client_key: None, + server_origin: None, + encryption_secret: None, + server_dir, + reports, + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + use toml::toml; + + #[test] + fn test_load_from_file_not_required() { + let cfg_dir = TempDir::new().unwrap(); + let cfg_file = cfg_dir.path().join("foo.toml"); + + let settings = Settings::load_from_file(cfg_file.clone(), false).unwrap(); + + let mut expected = Settings::default(); + expected.filename = Some(cfg_file.clone()); + assert_eq!(settings, expected); + } + + #[test] + fn test_load_from_file_required() { + let cfg_dir = TempDir::new().unwrap(); + + assert!(Settings::load_from_file(cfg_dir.path().join("foo.toml"), true).is_err()); + } + + #[test] + fn test_load_from_file_exists() { + let cfg_dir = TempDir::new().unwrap(); + let cfg_file = cfg_dir.path().join("foo.toml"); + fs::write(cfg_file.clone(), "data_dir = \"/nowhere\"").unwrap(); + + let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap(); + assert_eq!(settings.data_dir, PathBuf::from("/nowhere")); + assert_eq!(settings.filename, Some(cfg_file)); + } + + #[test] + fn test_update_from_toml_top_level_keys() { + let val = toml! { + data_dir = "/data" + modification_count_prompt = 42 + server_client_key = "sck" + server_origin = "so" + encryption_secret = "es" + server_dir = "/server" + }; + let mut settings = Settings::default(); + settings.update_from_toml(&val).unwrap(); + + assert_eq!(settings.data_dir, PathBuf::from("/data")); + assert_eq!(settings.modification_count_prompt, Some(42)); + assert_eq!(settings.server_client_key, Some("sck".to_owned())); + assert_eq!(settings.server_origin, Some("so".to_owned())); + assert_eq!(settings.encryption_secret, Some("es".to_owned())); + assert_eq!(settings.server_dir, PathBuf::from("/server")); + } + + #[test] + fn test_update_from_toml_report() { + let val = toml! { + [reports.foo] + sort = [ { sort_by = "id" } ] + columns = [ { label = "ID", property = "id" } ] + }; + let mut settings = Settings::default(); + settings.update_from_toml(&val).unwrap(); + + assert!(settings.reports.get("foo").is_some()); + // beyond existence of this report, we can rely on Report's unit tests + } + + #[test] + fn test_set_valid_key() { + let cfg_dir = TempDir::new().unwrap(); + let cfg_file = cfg_dir.path().join("foo.toml"); + fs::write(cfg_file.clone(), "server_dir = \"/srv\"").unwrap(); + + let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap(); + assert_eq!(settings.filename, Some(cfg_file.clone())); + settings.set("data_dir", "/data").unwrap(); + settings.set("modification_count_prompt", "42").unwrap(); + + // load the file again and see the changes + let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap(); + assert_eq!(settings.data_dir, PathBuf::from("/data")); + assert_eq!(settings.server_dir, PathBuf::from("/srv")); + assert_eq!(settings.filename, Some(cfg_file)); + assert_eq!(settings.modification_count_prompt, Some(42)); + } + + #[test] + fn test_set_invalid_key() { + let cfg_dir = TempDir::new().unwrap(); + let cfg_file = cfg_dir.path().join("foo.toml"); + fs::write(cfg_file.clone(), "server_dir = \"/srv\"").unwrap(); + + let settings = Settings::load_from_file(cfg_file.clone(), true).unwrap(); + assert_eq!(settings.filename, Some(cfg_file.clone())); + assert!(settings + .set("modification_count_prompt", "a string?") + .is_err()); + } +} diff --git a/rust/cli/src/settings/util.rs b/rust/cli/src/settings/util.rs new file mode 100644 index 000000000..85d2e1d52 --- /dev/null +++ b/rust/cli/src/settings/util.rs @@ -0,0 +1,42 @@ +use anyhow::{anyhow, bail, Result}; +use toml::value::Table; + +/// Check that the input is a table and contains no keys not in the given list, returning +/// the table. +pub(super) fn table_with_keys<'a>(cfg: &'a toml::Value, keys: &[&str]) -> Result<&'a Table> { + let table = cfg.as_table().ok_or_else(|| anyhow!("not a table"))?; + + for tk in table.keys() { + if !keys.iter().any(|k| k == tk) { + bail!("unknown table key `{}`", tk); + } + } + Ok(table) +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + use toml::toml; + + #[test] + fn test_dissect_table_missing() { + let val = toml! { bar = true }; + let diss = table_with_keys(&val, &["foo", "bar"]).unwrap(); + assert_eq!(diss.get("bar"), Some(&toml::Value::Boolean(true))); + assert_eq!(diss.get("foo"), None); + } + + #[test] + fn test_dissect_table_extra() { + let val = toml! { nosuch = 10 }; + assert!(table_with_keys(&val, &["foo", "bar"]).is_err()); + } + + #[test] + fn test_dissect_table_not_a_table() { + let val = toml::Value::Array(vec![]); + assert!(table_with_keys(&val, &["foo", "bar"]).is_err()); + } +} diff --git a/rust/cli/src/table.rs b/rust/cli/src/table.rs new file mode 100644 index 000000000..9fdb9b833 --- /dev/null +++ b/rust/cli/src/table.rs @@ -0,0 +1,8 @@ +use prettytable::format; + +pub(crate) fn format() -> format::TableFormat { + format::FormatBuilder::new() + .column_separator(' ') + .borders(' ') + .build() +} diff --git a/rust/cli/src/tdb2/mod.rs b/rust/cli/src/tdb2/mod.rs new file mode 100644 index 000000000..0ff59a311 --- /dev/null +++ b/rust/cli/src/tdb2/mod.rs @@ -0,0 +1,326 @@ +//! TDB2 is TaskWarrior's on-disk database format. The set of tasks is represented in +//! `pending.data` and `completed.data`. There are other `.data` files as well, but those are not +//! used in TaskChampion. +use nom::{branch::*, character::complete::*, combinator::*, multi::*, sequence::*, IResult}; +use std::fmt; + +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct File { + pub(crate) lines: Vec, +} + +#[derive(Clone, PartialEq)] +pub(crate) struct Line { + pub(crate) attrs: Vec, +} + +#[derive(Clone, PartialEq)] +pub(crate) struct Attr { + pub(crate) name: String, + pub(crate) value: String, +} + +impl File { + pub(crate) fn from_str(input: &str) -> Result { + File::parse(input).map(|(_, res)| res).map_err(|_| ()) + } + + fn parse(input: &str) -> IResult<&str, File> { + all_consuming(fold_many0( + // allow windows or normal newlines + terminated(Line::parse, pair(opt(char('\r')), char('\n'))), + File { lines: vec![] }, + |mut file, line| { + file.lines.push(line); + file + }, + ))(input) + } +} + +impl Line { + /// Parse a line in a TDB2 file. See TaskWarrior's Task::Parse. + fn parse(input: &str) -> IResult<&str, Line> { + fn to_line(input: Vec) -> Result { + Ok(Line { attrs: input }) + } + map_res( + delimited( + char('['), + separated_list0(char(' '), Attr::parse), + char(']'), + ), + to_line, + )(input) + } +} + +impl fmt::Debug for Line { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("line!")?; + f.debug_list().entries(self.attrs.iter()).finish() + } +} + +impl Attr { + /// Parse an attribute (name-value pair). + fn parse(input: &str) -> IResult<&str, Attr> { + fn to_attr(input: (&str, String)) -> Result { + Ok(Attr { + name: input.0.into(), + value: input.1, + }) + } + map_res( + separated_pair(Attr::parse_name, char(':'), Attr::parse_value), + to_attr, + )(input) + } + + /// Parse an attribute name, which is composed of any character but `:`. + fn parse_name(input: &str) -> IResult<&str, &str> { + recognize(many1(none_of(":")))(input) + } + + /// Parse and interpret a quoted string. Note that this does _not_ reverse the effects of + + fn parse_value(input: &str) -> IResult<&str, String> { + // For the parsing part of the job, see Pig::getQuoted in TaskWarrior's libshared, which + // merely finds the end of a string. + // + // The interpretation is defined in json::decode in libshared. Fortunately, the data we + // are reading was created with json::encode, which does not perform unicode escaping. + + fn escaped_string_char(input: &str) -> IResult<&str, char> { + alt(( + // reverse the escaping performed in json::encode + preceded( + char('\\'), + alt(( + // some characters are simply escaped + one_of(r#""\/"#), + // others translate to control characters + value('\x08', char('b')), + value('\x0c', char('f')), + value('\n', char('n')), + value('\r', char('r')), + value('\t', char('t')), + )), + ), + // not a backslash or double-quote + none_of("\"\\"), + ))(input) + } + + let inner = fold_many0( + escaped_string_char, + String::new(), + |mut string, fragment| { + string.push(fragment); + string + }, + ); + + delimited(char('"'), inner, char('"'))(input) + } +} + +impl fmt::Debug for Attr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_fmt(format_args!("{:?} => {:?}", self.name, self.value)) + } +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + + macro_rules! line { + ($($n:expr => $v:expr),* $(,)?) => ( + Line{attrs: vec![$(Attr{name: $n.into(), value: $v.into()}),*]} + ); + } + + #[test] + fn file() { + assert_eq!( + File::parse(include_str!("test.data")).unwrap(), + ( + "", + File { + lines: vec![ + line![ + "description" => "snake ðŸ", + "entry" => "1641670385", + "modified" => "1641670385", + "priority" => "M", + "status" => "pending", + "uuid" => "f19086c2-1f8d-4a6c-9b8d-f94901fb8e62", + ], + line![ + "annotation_1585711454" => + "https://blog.tensorflow.org/2020/03/face-and-hand-tracking-in-browser-with-mediapipe-and-tensorflowjs.html?linkId=83993617", + "description" => "try facemesh", + "entry" => "1585711451", + "modified" => "1592947544", + "priority" => "M", + "project" => "lists", + "status" => "pending", + "tags" => "idea", + "tags_idea" => "x", + "uuid" => "ee855dc7-6f61-408c-bc95-ebb52f7d529c", + ], + line![ + "description" => "testing", + "entry" => "1554074416", + "modified" => "1554074416", + "priority" => "M", + "status" => "pending", + "uuid" => "4578fb67-359b-4483-afe4-fef15925ccd6", + ], + line![ + "description" => "testing2", + "entry" => "1576352411", + "modified" => "1576352411", + "priority" => "M", + "status" => "pending", + "uuid" => "f5982cca-2ea1-4bfd-832c-9bd571dc0743", + ], + line![ + "description" => "new-task", + "entry" => "1576352696", + "modified" => "1576352696", + "priority" => "M", + "status" => "pending", + "uuid" => "cfee3170-f153-4075-aa1d-e20bcac2841b", + ], + line![ + "description" => "foo", + "entry" => "1579398776", + "modified" => "1579398776", + "priority" => "M", + "status" => "pending", + "uuid" => "df74ea94-5122-44fa-965a-637412fbbffc", + ], + ] + } + ) + ); + } + + #[test] + fn empty_line() { + assert_eq!(Line::parse("[]").unwrap(), ("", line![])); + } + + #[test] + fn escaped_line() { + assert_eq!( + Line::parse(r#"[annotation_1585711454:"\"\\\"" abc:"xx"]"#).unwrap(), + ( + "", + line!["annotation_1585711454" => "\"\\\"", "abc" => "xx"] + ) + ); + } + + #[test] + fn escaped_line_backslash() { + assert_eq!( + Line::parse(r#"[abc:"xx" 123:"x\\x"]"#).unwrap(), + ("", line!["abc" => "xx", "123" => "x\\x"]) + ); + } + + #[test] + fn escaped_line_quote() { + assert_eq!( + Line::parse(r#"[abc:"xx" 123:"x\"x"]"#).unwrap(), + ("", line!["abc" => "xx", "123" => "x\"x"]) + ); + } + + #[test] + fn unicode_line() { + assert_eq!( + Line::parse(r#"[description:"snake ðŸ" entry:"1641670385" modified:"1641670385" priority:"M" status:"pending" uuid:"f19086c2-1f8d-4a6c-9b8d-f94901fb8e62"]"#).unwrap(), + ("", line![ + "description" => "snake ðŸ", + "entry" => "1641670385", + "modified" => "1641670385", + "priority" => "M", + "status" => "pending", + "uuid" => "f19086c2-1f8d-4a6c-9b8d-f94901fb8e62", + ])); + } + + #[test] + fn backslashed_attr() { + assert!(Attr::parse(r#"one:"\""#).is_err()); + assert_eq!( + Attr::parse(r#"two:"\\""#).unwrap(), + ( + "", + Attr { + name: "two".into(), + value: r#"\"#.into(), + } + ) + ); + assert!(Attr::parse(r#"three:"\\\""#).is_err()); + assert_eq!( + Attr::parse(r#"four:"\\\\""#).unwrap(), + ( + "", + Attr { + name: "four".into(), + value: r#"\\"#.into(), + } + ) + ); + } + + #[test] + fn backslash_frontslash() { + assert_eq!( + Attr::parse(r#"front:"\/""#).unwrap(), + ( + "", + Attr { + name: "front".into(), + value: r#"/"#.into(), + } + ) + ); + } + + #[test] + fn backslash_control_chars() { + assert_eq!( + Attr::parse(r#"control:"\b\f\n\r\t""#).unwrap(), + ( + "", + Attr { + name: "control".into(), + value: "\x08\x0c\x0a\x0d\x09".into(), + } + ) + ); + } + + #[test] + fn url_attr() { + assert_eq!( + Attr::parse(r#"annotation_1585711454:"https:\/\/blog.tensorflow.org\/2020\/03\/""#) + .unwrap(), + ( + "", + Attr { + name: "annotation_1585711454".into(), + value: "https://blog.tensorflow.org/2020/03/".into(), + } + ) + ); + } +} diff --git a/rust/cli/src/tdb2/test.data b/rust/cli/src/tdb2/test.data new file mode 100644 index 000000000..f57b9101b --- /dev/null +++ b/rust/cli/src/tdb2/test.data @@ -0,0 +1,6 @@ +[description:"snake ðŸ" entry:"1641670385" modified:"1641670385" priority:"M" status:"pending" uuid:"f19086c2-1f8d-4a6c-9b8d-f94901fb8e62"] +[annotation_1585711454:"https:\/\/blog.tensorflow.org\/2020\/03\/face-and-hand-tracking-in-browser-with-mediapipe-and-tensorflowjs.html?linkId=83993617" description:"try facemesh" entry:"1585711451" modified:"1592947544" priority:"M" project:"lists" status:"pending" tags:"idea" tags_idea:"x" uuid:"ee855dc7-6f61-408c-bc95-ebb52f7d529c"] +[description:"testing" entry:"1554074416" modified:"1554074416" priority:"M" status:"pending" uuid:"4578fb67-359b-4483-afe4-fef15925ccd6"] +[description:"testing2" entry:"1576352411" modified:"1576352411" priority:"M" status:"pending" uuid:"f5982cca-2ea1-4bfd-832c-9bd571dc0743"] +[description:"new-task" entry:"1576352696" modified:"1576352696" priority:"M" status:"pending" uuid:"cfee3170-f153-4075-aa1d-e20bcac2841b"] +[description:"foo" entry:"1579398776" modified:"1579398776" priority:"M" status:"pending" uuid:"df74ea94-5122-44fa-965a-637412fbbffc"] diff --git a/rust/cli/src/usage.rs b/rust/cli/src/usage.rs new file mode 100644 index 000000000..d341c3b52 --- /dev/null +++ b/rust/cli/src/usage.rs @@ -0,0 +1,312 @@ +//! This module handles creation of CLI usage documents (--help, manpages, etc.) in +//! a way that puts the source of that documentation near its implementation. + +use crate::argparse; +use crate::settings; +use anyhow::Result; +use std::io::Write; + +#[cfg(feature = "usage-docs")] +use std::fmt::Write as FmtWrite; + +/// A top-level structure containing usage/help information for the entire CLI. +#[derive(Debug, Default)] +pub struct Usage { + pub(crate) subcommands: Vec, + pub(crate) filters: Vec, + pub(crate) modifications: Vec, + pub(crate) report_properties: Vec, +} + +impl Usage { + /// Get a new, completely-filled-out usage object + pub fn new() -> Self { + let mut rv = Self { + ..Default::default() + }; + + argparse::get_usage(&mut rv); + settings::get_usage(&mut rv); + + rv + } + + /// Write this usage to the given output as a help string, writing a short version if `summary` + /// is true. + pub(crate) fn write_help( + &self, + mut w: W, + command_name: &str, + summary: bool, + ) -> Result<()> { + write!( + w, + "TaskChampion {}: Personal task-tracking\n\n", + env!("CARGO_PKG_VERSION") + )?; + writeln!(w, "USAGE:\n {} [args]\n", command_name)?; + writeln!(w, "TaskChampion subcommands:")?; + for subcommand in self.subcommands.iter() { + subcommand.write_help(&mut w, command_name, summary)?; + } + writeln!(w, "Filter Expressions:\n")?; + writeln!( + w, + "{}", + indented( + " + Where [filter] appears above, zero or more of the following arguments can be used + to limit the tasks addressed by the subcommand.", + "" + ) + )?; + for filter in self.filters.iter() { + filter.write_help(&mut w, command_name, summary)?; + } + writeln!(w, "Modifications:\n")?; + writeln!( + w, + "{}", + indented( + " + Where [modification] appears above, zero or more of the following arguments can be + used to modify the selected tasks.", + "" + ) + )?; + for modification in self.modifications.iter() { + modification.write_help(&mut w, command_name, summary)?; + } + if !summary { + writeln!(w, "\nSee `{} help` for more detail", command_name)?; + } + Ok(()) + } + + #[cfg(feature = "usage-docs")] + /// Substitute strings matching + /// + /// ```text + /// + /// ``` + /// + /// 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 +fn indented(string: &str, indent: &str) -> String { + let termwidth = textwrap::termwidth(); + let words: Vec<&str> = string.split_whitespace().collect(); + let string = words.join(" "); + textwrap::indent( + textwrap::fill(string.trim(), termwidth - indent.len()).as_ref(), + indent, + ) +} + +/// Usage documentation for a subcommand +#[derive(Debug, Default)] +pub(crate) struct Subcommand { + /// Name of the subcommand + pub(crate) name: &'static str, + + /// Syntax summary, without command_name + pub(crate) syntax: &'static str, + + /// One-line description of the subcommand. Use an initial capital and no trailing period. + pub(crate) summary: &'static str, + + /// Multi-line description of the subcommand. It's OK for this to duplicate summary, as the + /// two are not displayed together. + pub(crate) description: &'static str, +} + +impl Subcommand { + fn write_help(&self, mut w: W, command_name: &str, summary: bool) -> Result<()> { + if summary { + writeln!(w, " {} {} - {}", command_name, self.name, self.summary)?; + } else { + writeln!( + w, + " {} {}\n{}", + command_name, + self.syntax, + indented(self.description, " ") + )?; + } + Ok(()) + } + + #[cfg(feature = "usage-docs")] + fn write_markdown(&self, mut w: W) -> Result<()> { + writeln!(w, "### `ta {}` - {}", self.name, self.summary)?; + writeln!(w, "```shell\nta {}\n```", self.syntax)?; + writeln!(w, "{}", indented(self.description, ""))?; + writeln!(w)?; + Ok(()) + } +} + +/// Usage documentation for a filter argument +#[derive(Debug, Default)] +pub(crate) struct Filter { + /// Syntax summary + pub(crate) syntax: &'static str, + + /// One-line description of the filter. Use all-caps words for placeholders. + pub(crate) summary: &'static str, + + /// Multi-line description of the filter. It's OK for this to duplicate summary, as the + /// two are not displayed together. + pub(crate) description: &'static str, +} + +impl Filter { + fn write_help(&self, mut w: W, _: &str, summary: bool) -> Result<()> { + if summary { + writeln!(w, " {} - {}", self.syntax, self.summary)?; + } else { + write!( + w, + " {}\n{}\n", + self.syntax, + indented(self.description, " ") + )?; + } + Ok(()) + } + + #[cfg(feature = "usage-docs")] + fn write_markdown(&self, mut w: W) -> Result<()> { + writeln!(w, "* `{}` - {}", self.syntax, self.summary)?; + writeln!(w)?; + writeln!(w, "{}", indented(self.description, " "))?; + writeln!(w)?; + Ok(()) + } +} + +/// Usage documentation for a modification argument +#[derive(Debug, Default)] +pub(crate) struct Modification { + /// Syntax summary + pub(crate) syntax: &'static str, + + /// One-line description of the modification. Use all-caps words for placeholders. + pub(crate) summary: &'static str, + + /// Multi-line description of the modification. It's OK for this to duplicate summary, as the + /// two are not displayed together. + pub(crate) description: &'static str, +} + +impl Modification { + fn write_help(&self, mut w: W, _: &str, summary: bool) -> Result<()> { + if summary { + writeln!(w, " {} - {}", self.syntax, self.summary)?; + } else { + writeln!( + w, + " {}\n{}", + self.syntax, + indented(self.description, " ") + )?; + } + Ok(()) + } + + #[cfg(feature = "usage-docs")] + fn write_markdown(&self, mut w: W) -> Result<()> { + writeln!(w, "* `{}` - {}", self.syntax, self.summary)?; + writeln!(w)?; + writeln!(w, "{}", indented(self.description, " "))?; + writeln!(w)?; + Ok(()) + } +} + +/// Usage documentation for a report property (which may be used for sorting, as a column, or +/// both). +#[allow(dead_code)] +#[derive(Debug, Default)] +pub(crate) struct ReportProperty { + /// Name of the property + pub(crate) name: &'static str, + + /// Usage description for sorting, if any + pub(crate) as_sort_by: Option<&'static str>, + + /// Usage description as a column, if any + pub(crate) as_column: Option<&'static str>, +} + +impl ReportProperty { + #[cfg(feature = "usage-docs")] + fn write_sort_by_markdown(&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/rust/cli/tests/cli.rs b/rust/cli/tests/cli.rs new file mode 100644 index 000000000..6325a6d3e --- /dev/null +++ b/rust/cli/tests/cli.rs @@ -0,0 +1,63 @@ +use assert_cmd::prelude::*; +use predicates::prelude::*; +use std::fs; +use std::process::Command; +use tempfile::TempDir; + +// NOTE: This tests that the `ta` binary is running and parsing arguments. The details of +// subcommands are handled with unit tests. + +/// These tests force config to be read via TASKCHAMPION_CONFIG so that a user's own config file +/// (in their homedir) does not interfere with tests. +fn test_cmd(dir: &TempDir) -> Result> { + let config_filename = dir.path().join("config.toml"); + fs::write( + config_filename.clone(), + format!("data_dir = {:?}", dir.path()), + )?; + + let config_filename = config_filename.to_str().unwrap(); + let mut cmd = Command::cargo_bin("ta")?; + cmd.env("TASKCHAMPION_CONFIG", config_filename); + Ok(cmd) +} + +#[test] +fn help() -> Result<(), Box> { + let dir = TempDir::new().unwrap(); + let mut cmd = test_cmd(&dir)?; + + cmd.arg("--help"); + cmd.assert() + .success() + .stdout(predicate::str::contains("Personal task-tracking")); + + Ok(()) +} + +#[test] +fn version() -> Result<(), Box> { + let dir = TempDir::new().unwrap(); + let mut cmd = test_cmd(&dir)?; + + cmd.arg("--version"); + cmd.assert() + .success() + .stdout(predicate::str::contains("TaskChampion")); + + Ok(()) +} + +#[test] +fn invalid_option() -> Result<(), Box> { + let dir = TempDir::new().unwrap(); + let mut cmd = test_cmd(&dir)?; + + cmd.arg("--no-such-option"); + cmd.assert() + .failure() + .stderr(predicate::str::contains("command line not recognized")) + .code(predicate::eq(3)); + + Ok(()) +} diff --git a/rust/docs/.gitignore b/rust/docs/.gitignore new file mode 100644 index 000000000..d2479eb14 --- /dev/null +++ b/rust/docs/.gitignore @@ -0,0 +1,2 @@ +book +tmp diff --git a/rust/docs/README.md b/rust/docs/README.md new file mode 100644 index 000000000..586cf2663 --- /dev/null +++ b/rust/docs/README.md @@ -0,0 +1,10 @@ +This is an [mdbook](https://rust-lang.github.io/mdBook/index.html) book. +Minor modifications can be made without installing the mdbook tool, as the content is simple Markdown. +Changes are verified on pull requests. + +To build the docs locally, you will need to build `usage-docs`: + +``` +cargo build -p taskchampion-cli --feature usage-docs --bin usage-docs +mdbook build docs/ +``` diff --git a/rust/docs/assets/cgi/LICENSE.md b/rust/docs/assets/cgi/LICENSE.md new file mode 100644 index 000000000..1d4dbe059 --- /dev/null +++ b/rust/docs/assets/cgi/LICENSE.md @@ -0,0 +1,2 @@ +Copyright (C) Andrew Savchenko - All Rights Reserved +All files within this folder are proprietary and reserved for the use by TaskChampion project. diff --git a/rust/docs/assets/cgi/icon_rounded/icon_rounded_1024.png b/rust/docs/assets/cgi/icon_rounded/icon_rounded_1024.png new file mode 100755 index 000000000..d4a4a9e13 Binary files /dev/null and b/rust/docs/assets/cgi/icon_rounded/icon_rounded_1024.png differ diff --git a/rust/docs/assets/cgi/icon_rounded/icon_rounded_128.png b/rust/docs/assets/cgi/icon_rounded/icon_rounded_128.png new file mode 100755 index 000000000..c6f6ccf87 Binary files /dev/null and b/rust/docs/assets/cgi/icon_rounded/icon_rounded_128.png differ diff --git a/rust/docs/assets/cgi/icon_rounded/icon_rounded_16.png b/rust/docs/assets/cgi/icon_rounded/icon_rounded_16.png new file mode 100755 index 000000000..225b311de Binary files /dev/null and b/rust/docs/assets/cgi/icon_rounded/icon_rounded_16.png differ diff --git a/rust/docs/assets/cgi/icon_rounded/icon_rounded_256.png b/rust/docs/assets/cgi/icon_rounded/icon_rounded_256.png new file mode 100755 index 000000000..9717c8d70 Binary files /dev/null and b/rust/docs/assets/cgi/icon_rounded/icon_rounded_256.png differ diff --git a/rust/docs/assets/cgi/icon_rounded/icon_rounded_32.png b/rust/docs/assets/cgi/icon_rounded/icon_rounded_32.png new file mode 100755 index 000000000..2832f0b3a Binary files /dev/null and b/rust/docs/assets/cgi/icon_rounded/icon_rounded_32.png differ diff --git a/rust/docs/assets/cgi/icon_rounded/icon_rounded_512.png b/rust/docs/assets/cgi/icon_rounded/icon_rounded_512.png new file mode 100755 index 000000000..986236490 Binary files /dev/null and b/rust/docs/assets/cgi/icon_rounded/icon_rounded_512.png differ diff --git a/rust/docs/assets/cgi/icon_rounded/icon_rounded_64.png b/rust/docs/assets/cgi/icon_rounded/icon_rounded_64.png new file mode 100755 index 000000000..1c831acce Binary files /dev/null and b/rust/docs/assets/cgi/icon_rounded/icon_rounded_64.png differ diff --git a/rust/docs/assets/cgi/icon_square/icon_square_1024.png b/rust/docs/assets/cgi/icon_square/icon_square_1024.png new file mode 100755 index 000000000..275f3b206 Binary files /dev/null and b/rust/docs/assets/cgi/icon_square/icon_square_1024.png differ diff --git a/rust/docs/assets/cgi/icon_square/icon_square_128.png b/rust/docs/assets/cgi/icon_square/icon_square_128.png new file mode 100755 index 000000000..2600bae3b Binary files /dev/null and b/rust/docs/assets/cgi/icon_square/icon_square_128.png differ diff --git a/rust/docs/assets/cgi/icon_square/icon_square_16.png b/rust/docs/assets/cgi/icon_square/icon_square_16.png new file mode 100755 index 000000000..e11979d52 Binary files /dev/null and b/rust/docs/assets/cgi/icon_square/icon_square_16.png differ diff --git a/rust/docs/assets/cgi/icon_square/icon_square_256.png b/rust/docs/assets/cgi/icon_square/icon_square_256.png new file mode 100755 index 000000000..25cf7f694 Binary files /dev/null and b/rust/docs/assets/cgi/icon_square/icon_square_256.png differ diff --git a/rust/docs/assets/cgi/icon_square/icon_square_32.png b/rust/docs/assets/cgi/icon_square/icon_square_32.png new file mode 100755 index 000000000..24e9a6097 Binary files /dev/null and b/rust/docs/assets/cgi/icon_square/icon_square_32.png differ diff --git a/rust/docs/assets/cgi/icon_square/icon_square_512.png b/rust/docs/assets/cgi/icon_square/icon_square_512.png new file mode 100755 index 000000000..da117347a Binary files /dev/null and b/rust/docs/assets/cgi/icon_square/icon_square_512.png differ diff --git a/rust/docs/assets/cgi/icon_square/icon_square_64.png b/rust/docs/assets/cgi/icon_square/icon_square_64.png new file mode 100755 index 000000000..d9d63e823 Binary files /dev/null and b/rust/docs/assets/cgi/icon_square/icon_square_64.png differ diff --git a/rust/docs/assets/cgi/logo/logo_1024.png b/rust/docs/assets/cgi/logo/logo_1024.png new file mode 100755 index 000000000..8f2cf7724 Binary files /dev/null and b/rust/docs/assets/cgi/logo/logo_1024.png differ diff --git a/rust/docs/assets/cgi/logo/logo_128.png b/rust/docs/assets/cgi/logo/logo_128.png new file mode 100755 index 000000000..c32d2abe4 Binary files /dev/null and b/rust/docs/assets/cgi/logo/logo_128.png differ diff --git a/rust/docs/assets/cgi/logo/logo_16.png b/rust/docs/assets/cgi/logo/logo_16.png new file mode 100755 index 000000000..867dda789 Binary files /dev/null and b/rust/docs/assets/cgi/logo/logo_16.png differ diff --git a/rust/docs/assets/cgi/logo/logo_256.png b/rust/docs/assets/cgi/logo/logo_256.png new file mode 100755 index 000000000..a01735a0d Binary files /dev/null and b/rust/docs/assets/cgi/logo/logo_256.png differ diff --git a/rust/docs/assets/cgi/logo/logo_32.png b/rust/docs/assets/cgi/logo/logo_32.png new file mode 100755 index 000000000..b180de372 Binary files /dev/null and b/rust/docs/assets/cgi/logo/logo_32.png differ diff --git a/rust/docs/assets/cgi/logo/logo_512.png b/rust/docs/assets/cgi/logo/logo_512.png new file mode 100755 index 000000000..2a2fb1502 Binary files /dev/null and b/rust/docs/assets/cgi/logo/logo_512.png differ diff --git a/rust/docs/assets/cgi/logo/logo_64.png b/rust/docs/assets/cgi/logo/logo_64.png new file mode 100755 index 000000000..47e028add Binary files /dev/null and b/rust/docs/assets/cgi/logo/logo_64.png differ diff --git a/rust/docs/book.toml b/rust/docs/book.toml new file mode 100644 index 000000000..3ab678ad0 --- /dev/null +++ b/rust/docs/book.toml @@ -0,0 +1,12 @@ +[book] +authors = ["Dustin J. Mitchell"] +language = "en" +multilingual = false +src = "src" +title = "TaskChampion" + +[output.html] +default-theme = "ayu" + +[preprocessor.usage-docs] +command = "target/debug/usage-docs" diff --git a/rust/docs/src/SUMMARY.md b/rust/docs/src/SUMMARY.md new file mode 100644 index 000000000..942db92d2 --- /dev/null +++ b/rust/docs/src/SUMMARY.md @@ -0,0 +1,25 @@ +# Summary + +- [Welcome to TaskChampion](./welcome.md) + * [Installation](./installation.md) + * [Using the Task Command](./using-task-command.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) + * [Undo](./undo.md) + * [Synchronization](./task-sync.md) + * [Running the Sync Server](./running-sync-server.md) +- [Internal Details](./internals.md) + * [Data Model](./data-model.md) + * [Replica Storage](./storage.md) + * [Task Database](./taskdb.md) + * [Tasks](./tasks.md) + * [Synchronization and the Sync Server](./sync.md) + * [Synchronization Model](./sync-model.md) + * [Snapshots](./snapshots.md) + * [Server-Replica Protocol](./sync-protocol.md) + * [Planned Functionality](./plans.md) diff --git a/rust/docs/src/config-file.md b/rust/docs/src/config-file.md new file mode 100644 index 000000000..73dc28b60 --- /dev/null +++ b/rust/docs/src/config-file.md @@ -0,0 +1,66 @@ +# Configuration + +The `ta` command will work out-of-the-box with no configuration file, using default values. + +Configuration is read from `taskchampion.toml` in your config directory. +On Linux systems, that directory is `~/.config`. +On OS X, it's `~/Library/Preferences`. +On Windows, it's `AppData/Roaming` in your home directory. +This can be overridden by setting `TASKCHAMPION_CONFIG` to the configuration filename. + +The file format is [TOML](https://toml.io/). +For example: + +```toml +data_dir = "/home/myuser/.tasks" +``` + +## Directories + +* `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: + +* `server_dir` - path to a directory containing the local server's data. + This is only used if `server_origin` or `server_client_key` are not set. + Default: `taskchampion-sync-server` in the local data directory. + +If using a remote server: + +* `server_origin` - Origin of the TaskChampion sync server, e.g., `https://taskchampion.example.com`. + If not set, then sync is done to a local server. +* `encryption_secret` - Secret value used to encrypt all data stored on the server. + This should be a long random string. + If you have `openssl` installed, a command like `openssl rand -hex 35` will generate a suitable value. + This value is only used when synchronizing with a remote server -- local servers are unencrypted. + Treat this value as a password. +* `server_client_key` - Client key to identify this replica to the sync server (a UUID) + If not set, then sync is done to a local server. + +## Snapshots + +* `avoid_snapshots` - If running on a CPU-, memory-, or bandwidth-constrained + device, set this to true. The effect is that this replica will wait longer + to produce a snapshot, in the hopes that other replicas will do so first. + +## Reports + +* `reports` - a mapping of each report's name to its definition. + See [Reports](./reports.md) for details. + +## Editing + +As a shortcut, the simple, top-level configuration values can be edited from the command line: + +```shell +ta config set data_dir /home/myuser/.taskchampion +``` diff --git a/rust/docs/src/data-model.md b/rust/docs/src/data-model.md new file mode 100644 index 000000000..2a43df62b --- /dev/null +++ b/rust/docs/src/data-model.md @@ -0,0 +1,5 @@ +# Data Model + +A client manages a single offline instance of a single user's task list, called a replica. +This section covers the structure of that data. +Note that this data model is visible only on the client; the server does not have access to client data. diff --git a/rust/docs/src/environment.md b/rust/docs/src/environment.md new file mode 100644 index 000000000..14fea8aba --- /dev/null +++ b/rust/docs/src/environment.md @@ -0,0 +1,21 @@ +# Environment Variables + +## Configuration + +Set `TASKCHAMPION_CONFIG` to the location of a configuration file in order to override the default location. + +## Terminal Output + +Taskchampion uses [termcolor](https://github.com/BurntSushi/termcolor) to color its output. +This library interprets [`TERM` and `NO_COLOR`](https://github.com/BurntSushi/termcolor#automatic-color-selection) to determine how it should behave, when writing to a tty. +Set `NO_COLOR` to any value to force plain-text output. + +## Debugging + +Both `ta` and `taskchampion-sync-server` use [env-logger](https://docs.rs/env_logger) and can be configured to log at various levels with the `RUST_LOG` environment variable. +For example: +```shell +$ RUST_LOG=taskchampion=trace ta add foo +``` + +The output may provide valuable clues in debugging problems. diff --git a/rust/docs/src/filters.md b/rust/docs/src/filters.md new file mode 100644 index 000000000..e2be669d7 --- /dev/null +++ b/rust/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/rust/docs/src/images/name_timestamp.png b/rust/docs/src/images/name_timestamp.png new file mode 100644 index 000000000..d829039b7 Binary files /dev/null and b/rust/docs/src/images/name_timestamp.png differ diff --git a/rust/docs/src/installation.md b/rust/docs/src/installation.md new file mode 100644 index 000000000..a597a11da --- /dev/null +++ b/rust/docs/src/installation.md @@ -0,0 +1,3 @@ +# Installation + +As this is currently in development, installation is by cloning the repository and running "cargo build". diff --git a/rust/docs/src/internals.md b/rust/docs/src/internals.md new file mode 100644 index 000000000..7cc36ac29 --- /dev/null +++ b/rust/docs/src/internals.md @@ -0,0 +1,5 @@ +# Internal Details + +The following sections get into the details of how TaskChampion works. +None of this information is necessary to use TaskChampion, but might be helpful in understanding its behavior. +Developers of TaskChampion and of tools that integrate with TaskChampion should be familiar with this information. diff --git a/rust/docs/src/modifications.md b/rust/docs/src/modifications.md new file mode 100644 index 000000000..017969951 --- /dev/null +++ b/rust/docs/src/modifications.md @@ -0,0 +1,5 @@ +# Modifications + +Modifications can have the following forms: + + diff --git a/rust/docs/src/plans.md b/rust/docs/src/plans.md new file mode 100644 index 000000000..4ee20c5d0 --- /dev/null +++ b/rust/docs/src/plans.md @@ -0,0 +1,35 @@ +# Planned Functionality + +This section is a bit of a to-do list for additional functionality to add to the synchronzation system. +Each feature has some discussion of how it might be implemented. + +## Snapshots + +As designed, storage required on the server would grow with time, as would the time required for new clients to update to the latest version. +As an optimization, the server also stores "snapshots" containing a full copy of the task database at a given version. +Based on configurable heuristics, it may delete older operations and snapshots, as long as enough data remains for active clients to synchronize and for new clients to initialize. + +Since snapshots must be computed by clients, the server may "request" a snapshot when providing the latest version to a client. +This request comes with a number indicating how much it 'wants" the snapshot. +Clients which can easily generate and transmit a snapshot should be generous to the server, while clients with more limited resources can wait until the server's requests are more desperate. +The intent is, where possible, to request snapshots created on well-connected desktop clients over mobile and low-power clients. + +## Encryption and Signing + +From the server's perspective, all data except for version numbers are opaque binary blobs. +Clients encrypt and sign these blobs using a symmetric key known only to the clients. +This secures the data at-rest on the server. +Note that privacy is not complete, as the server still has some information about users, including source and frequency of synchronization transactions and size of those transactions. + +## Backups + +In this design, the server is little more than an authenticated storage for encrypted blobs provided by the client. +To allow for failure or data loss on the server, clients are expected to cache these blobs locally for a short time (a week), along with a server-provided HMAC signature. +When data loss is detected -- such as when a client expects the server to have a version N or higher, and the server only has N-1, the client can send those blobs to the server. +The server can validate the HMAC and, if successful, add the blobs to its datastore. + +## Expiration + +Deleted tasks remain in the task database, and are simply hidden in most views. +All tasks have an expiration time after which they may be flushed, preventing unbounded increase in task database size. +However, purging of a task does not satisfy the necessary OT guarantees, so some further formal design work is required before this is implemented. diff --git a/rust/docs/src/reports.md b/rust/docs/src/reports.md new file mode 100644 index 000000000..4f106e35b --- /dev/null +++ b/rust/docs/src/reports.md @@ -0,0 +1,79 @@ +# Reports + +As a to-do list manager, listing tasks is an important TaskChampion feature. +Reports are tabular displays of tasks, and allow very flexible filtering, sorting, and customization of columns. + +TaskChampion includes several "built-in" reports, as well as supporting custom reports in the [configuration file](./config-file.md). + +## Built-In Reports + +The `next` report is the default, and lists all pending tasks: + +```text +$ ta +Id Description Active Tags +1 learn about TaskChampion +next +2 buy wedding gift * +buy +3 plant tomatoes +garden +``` + +The `Id` column contains short numeric IDs that are assigned to pending tasks. +These IDs are easy to type, such as to mark task 2 done (`ta 2 done`). + +The `list` report lists all tasks, with a similar set of columns. + +## Custom Reports + +Custom reports are defined in the configuration file's `reports` table. +This is a mapping from each report's name to its definition. +Each definition has the following properties: + +* `filter` - criteria for the tasks to include in the report (optional) +* `sort` - how to order the tasks (optional) +* `columns` - the columns of information to display for each task + +For example: + +```toml +[reports.garden] +sort = [ + { sort_by = "description" } +] +filter = [ + "status:pending", + "+garden" +] +columns = [ + { label = "ID", property = "id" }, + { label = "Description", property = "description" }, +] +``` + +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. +If `ascending` is given, it can be `true` for the default sort order, or `false` for the reverse. + +In most cases tasks are just sorted by one criterion, but a more advanced example might look like: + +```toml +[reports.garden] +sort = [ + { sort_by = "description" } + { sort_by = "uuid", ascending = false } +] +... +``` + +The available values of `sort_by` are + + + +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: + + diff --git a/rust/docs/src/running-sync-server.md b/rust/docs/src/running-sync-server.md new file mode 100644 index 000000000..e8b8c56ce --- /dev/null +++ b/rust/docs/src/running-sync-server.md @@ -0,0 +1,11 @@ +# Running the Sync Server + +> NOTE: TaskChampion is still in development and not yet feature-complete. +> The server is functional, but lacks any administrative features. + +Run `taskchampion-sync-server` to start the sync server. +Use `--port` to specify the port it should listen on, and `--data-dir` to specify the directory which it should store its data. +It only serves HTTP; the expectation is that a frontend proxy will be used for HTTPS support. + +The server has optional parameters `--snapshot-days` and `--snapshot-version`, giving the target number of days and versions, respectively, between snapshots of the client state. +The default values for these parameters are generally adequate. diff --git a/rust/docs/src/snapshots.md b/rust/docs/src/snapshots.md new file mode 100644 index 000000000..1e608dba3 --- /dev/null +++ b/rust/docs/src/snapshots.md @@ -0,0 +1,48 @@ +# Snapshots + +The basic synchronization model described in the previous page has a few shortcomings: + * servers must store an ever-increasing quantity of versions + * a new replica must download all versions since the beginning in order to derive the current state + +Snapshots allow TaskChampion to avoid both of these issues. +A snapshot is a copy of the task database at a specific version. +It is created by a replica, encrypted, and stored on the server. +A new replica can simply download a recent snapshot and apply any additional versions synchronized since that snapshot was made. +Servers can delete and reclaim space used by older versions, as long as newer snapshots are available. + +## Snapshot Heuristics + +A server implementation must answer a few questions: + * How often should snapshots be made? + * When can versions be deleted? + * When can snapshots be deleted? + +A critical invariant is that at least one snapshot must exist for any database that does not have a child of the nil version. +This ensures that a new replica can always derive the latest state. + +Aside from that invariant, the server implementation can vary in its answers to these questions, with the following considerations: + +Snapshots should be made frequently enough that a new replica can initialize quickly. + +Existing replicas will fail to synchronize if they request a child version that has been deleted. +This failure can cause data loss if the replica had local changes. +It's conceivable that replicas may not sync for weeks or months if, for example, they are located on a home computer while the user is on holiday. + +## Requesting New Snapshots + +The server requests snapshots from replicas, indicating an urgency for the request. +Some replicas, such as those running on PCs or servers, can produce a snapshot even at low urgency. +Other replicas, in more restricted environments such as mobile devices, will only produce a snapshot at high urgency. +This saves resources in these restricted environments. + +A snapshot must be made on a replica with no unsynchronized operations. +As such, it only makes sense to request a snapshot in response to a successful AddVersion request. + +## Handling Deleted Versions + +When a replica requests a child version, the response must distinguish two cases: + + 1. No such child version exists because the replica is up-to-date. + 1. No such child version exists because it has been deleted, and the replica must re-initialize itself. + +The details of this logic are covered in the [Server-Replica Protocol](./sync-protocol.md). diff --git a/rust/docs/src/storage.md b/rust/docs/src/storage.md new file mode 100644 index 000000000..f733f4f98 --- /dev/null +++ b/rust/docs/src/storage.md @@ -0,0 +1,83 @@ +# Replica Storage + +Each replica has a storage backend. +The interface for this backend is given in `crate::taskstorage::Storage` and `StorageTxn`. + +The storage is transaction-protected, with the expectation of a serializable isolation level. +The storage contains the following information: + +- `tasks`: a set of tasks, indexed by UUID +- `base_version`: the number of the last version sync'd from the server (a single integer) +- `operations`: all operations performed since base_version +- `working_set`: a mapping from integer -> UUID, used to keep stable small-integer indexes into the tasks for users' convenience. This data is not synchronized with the server and does not affect any consistency guarantees. + +## Tasks + +The tasks are stored as an un-ordered collection, keyed by task UUID. +Each task in the database has represented by a key-value map. +See [Tasks](./tasks.md) for details on the content of that map. + +## Operations + +Every change to the task database is captured as an operation. +In other words, operations act as deltas between database states. +Operations are crucial to synchronization of replicas, described in [Synchronization Model](./sync-model.md). + +Operations are entirely managed by the replica, and some combinations of operations are described as "invalid" here. +A replica must not create invalid operations, but should be resilient to receiving invalid operations during a synchronization operation. + +Each operation has one of the forms + + * `Create(uuid)` + * `Delete(uuid, oldTask)` + * `Update(uuid, property, oldValue, newValue, timestamp)` + * `UndoPoint()` + +The Create form creates a new task. +It is invalid to create a task that already exists. + +Similarly, the Delete form deletes an existing task. +It is invalid to delete a task that does not exist. +The `oldTask` property contains the task data from before it was deleted. + +The Update form updates the given property of the given task, where the property and values are strings. +The `oldValue` gives the old value of the property (or None to create a new property), while `newValue` gives the new value (or None to delete a property). +It is invalid to update a task that does not exist. +The timestamp on updates serves as additional metadata and is used to resolve conflicts. + +### Application + +Each operation can be "applied" to a task database in a natural way: + + * Applying `Create` creates a new, empty task in the task database. + * Applying `Delete` deletes a task, including all of its properties, from the task database. + * Applying `Update` modifies the properties of a task. + * Applying `UndoPoint` does nothing. + +### Undo + +Each operation also contains enough information to reverse its application: + + * Undoing `Create` deletes a task. + * Undoing `Delete` creates a task, including all of the properties in `oldTask`. + * Undoing `Update` modifies the properties of a task, reverting to `oldValue`. + * Undoing `UndoPoint` does nothing. + +The `UndoPoint` operation serves as a marker of points in the operation sequence to which the user might wish to undo. +For example, creation of a new task with several properities involves several operations, but is a single step from the user's perspective. +An "undo" command reverses operations, removing them from the operations sequence, until it reaches an `UndoPoint` operation. + +### Synchronizing Operations + +After operations are synchronized to the server, they can no longer be undone. +As such, the [synchronization model](./sync-model.md) uses simpler operations. +Replica operations are converted to sync operations as follows: + + * `Create(uuid)` -> `Create(uuid)` (no change) + * `Delete(uuid, oldTask)` -> `Delete(uuid)` + * `Update(uuid, property, oldValue, newValue, timestamp)` -> `Update(uuid, property, newValue, timestamp)` + * `UndoPoint()` -> Ø (dropped from operation sequence) + +Once a sequence of operations has been synchronized, there is no need to store those operations on the replica. +The current implementation deletes operations at that time. +An alternative approach is to keep operations for existing tasks, and provide access to those operations as a "history" of modifications to the task. diff --git a/rust/docs/src/sync-model.md b/rust/docs/src/sync-model.md new file mode 100644 index 000000000..11d6e0855 --- /dev/null +++ b/rust/docs/src/sync-model.md @@ -0,0 +1,138 @@ +# Synchronization Model + +The [task database](./taskdb.md) also implements synchronization. +Synchronization occurs between disconnected replicas, mediated by a server. +The replicas never communicate directly with one another. +The server does not have access to the task data; it sees only opaque blobs of data with a small amount of metadata. + +The synchronization process is a critical part of the task database's functionality, and it cannot function efficiently without occasional synchronization operations + +## Operational Transforms + +Synchronization is based on [operational transformation](https://en.wikipedia.org/wiki/Operational_transformation). +This section will assume some familiarity with the concept. + +## State and Operations + +At a given time, the set of tasks in a replica's storage is the essential "state" of that replica. +All modifications to that state occur via operations, as defined in [Replica Storage](./storage.md). +We can draw a network, or graph, with the nodes representing states and the edges representing operations. +For example: + +```text + o -- State: {abc-d123: 'get groceries', priority L} + | + | -- Operation: set abc-d123 priority to H + | + o -- State: {abc-d123: 'get groceries', priority H} +``` + +For those familiar with distributed version control systems, a state is analogous to a revision, while an operation is analogous to a commit. + +Fundamentally, synchronization involves all replicas agreeing on a single, linear sequence of operations and the state that those operations create. +Since the replicas are not connected, each may have additional operations that have been applied locally, but which have not yet been agreed on. +The synchronization process uses operational transformation to "linearize" those operations. +This process is analogous (vaguely) to rebasing a sequence of Git commits. + +### Sync Operations + +The [Replica Storage](./storage.md) model contains additional information in its operations that is not included in operations synchronized to other replicas. +In this document, we will be discussing "sync operations" of the form + + * `Create(uuid)` + * `Delete(uuid)` + * `Update(uuid, property, value, timestamp)` + + +### Versions + +Occasionally, database states are given a name (that takes the form of a UUID). +The system as a whole (all replicas) constructs a branch-free sequence of versions and the operations that separate each version from the next. +The version with the nil UUID is implicitly the empty database. + +The server stores the operations to change a state from a "parent" version to a "child" version, and provides that information as needed to replicas. +Replicas use this information to update their local task databases, and to generate new versions to send to the server. + +Replicas generate a new version to transmit local changes to the server. +The changes are represented as a sequence of operations with the state resulting from the final operation corresponding to the version. +In order to keep the versions in a single sequence, the server will only accept a proposed version from a replica if its parent version matches the latest version on the server. + +In the non-conflict case (such as with a single replica), then, a replica's synchronization process involves gathering up the operations it has accumulated since its last synchronization; bundling those operations into a version; and sending that version to the server. + +### Replica Invariant + +The replica's [storage](./storage.md) contains the current state in `tasks`, the as-yet un-synchronized operations in `operations`, and the last version at which synchronization occurred in `base_version`. + +The replica's un-synchronized operations are already reflected in its local `tasks`, so the following invariant holds: + +> Applying `operations` to the set of tasks at `base_version` gives a set of tasks identical +> to `tasks`. + +### Transformation + +When the latest version on the server contains operations that are not present in the replica, then the states have diverged. +For example: + +```text + o -- version N + w|\a + o o + x| \b + o o + y| \c + o o -- replica's local state + z| + o -- version N+1 +``` + +(diagram notation: `o` designates a state, lower-case letters designate operations, and versions are presented as if they were numbered sequentially) + +In this situation, the replica must "rebase" the local operations onto the latest version from the server and try again. +This process is performed using operational transformation (OT). +The result of this transformation is a sequence of operations based on the latest version, and a sequence of operations the replica can apply to its local task database to reach the same state +Continuing the example above, the resulting operations are shown with `'`: + +```text + o -- version N + w|\a + o o + x| \b + o o + y| \c + o o -- replica's intermediate local state + z| |w' + o-N+1 o + a'\ |x' + o o + b'\ |y' + o o + c'\|z' + o -- version N+2 +``` + +The replica applies w' through z' locally, and sends a' through c' to the server as the operations to generate version N+2. +Either path through this graph, a-b-c-w'-x'-y'-z' or a'-b'-c'-w-x-y-z, must generate *precisely* the same final state at version N+2. +Careful selection of the operations and the transformation function ensure this. + +See the comments in the source code for the details of how this transformation process is implemented. + +## Synchronization Process + +To perform a synchronization, the replica first requests the child version of `base_version` from the server (GetChildVersion). +It applies that version to its local `tasks`, rebases its local `operations` as described above, and updates `base_version`. +The replica repeats this process until the server indicates no additional child versions exist. +If there are no un-synchronized local operations, the process is complete. + +Otherwise, the replica creates a new version containing its local operations, giving its `base_version` as the parent version, and transmits that to the server (AddVersion). +In most cases, this will succeed, but if another replica has created a new version in the interim, then the new version will conflict with that other replica's new version and the server will respond with the new expected parent version. +In this case, the process repeats. +If the server indicates a conflict twice with the same expected base version, that is an indication that the replica has diverged (something serious has gone wrong). + +## Servers + +A replica depends on periodic synchronization for performant operation. +Without synchronization, its list of pending operations would grow indefinitely, and tasks could never be expired. +So all replicas, even "singleton" replicas which do not replicate task data with any other replica, must synchronize periodically. + +TaskChampion provides a `LocalServer` for this purpose. +It implements the `get_child_version` and `add_version` operations as described, storing data on-disk locally, all within the `ta` binary. diff --git a/rust/docs/src/sync-protocol.md b/rust/docs/src/sync-protocol.md new file mode 100644 index 000000000..0bb588383 --- /dev/null +++ b/rust/docs/src/sync-protocol.md @@ -0,0 +1,232 @@ +# Server-Replica Protocol + +The server-replica protocol is defined abstractly in terms of request/response transactions from the replica to the server. +This is made concrete in an HTTP representation. + +The protocol builds on the model presented in the previous chapter, and in particular on the synchronization process. + +## Clients + +From the server's perspective, replicas accessing the same task history are indistinguishable, so this protocol uses the term "client" to refer generically to all replicas replicating a single task history. + +Each client is identified and authenticated with a "client key", known only to the server and to the replicas replicating the task history. + +## Server + +For each client, the server is responsible for storing the task history, in the form of a branch-free sequence of versions. +It also stores the latest snapshot, if any exists. + + * versions: a set of {versionId: UUID, parentVersionId: UUID, historySegment: bytes} + * latestVersionId: UUID + * snapshotVersionId: UUID + * snapshot: bytes + +For each client, it stores a set of versions as well as the latest version ID, defaulting to the nil UUID. +Each version has a version ID, a parent version ID, and a history segment (opaque data containing the operations for that version). +The server should maintain the following invariants for each client: + +1. latestVersionId is nil or exists in the set of versions. +2. Given versions v1 and v2 for a client, with v1.versionId != v2.versionId and v1.parentVersionId != nil, v1.parentVersionId != v2.parentVersionId. + In other words, versions do not branch. +3. If snapshotVersionId is nil, then there is a version with parentVersionId == nil. +4. If snapshotVersionId is not nil, then there is a version with parentVersionId = snapshotVersionId. + +Note that versions form a linked list beginning with the latestVersionId stored for the client. +This linked list need not continue back to a version with v.parentVersionId = nil. +It may end at any point when v.parentVersionId is not found in the set of Versions. +This observation allows the server to discard older versions. +The third invariant prevents the server from discarding versions if there is no snapshot. +The fourth invariant prevents the server from discarding versions newer than the snapshot. + +## Data Formats + +### Encryption + +The client configuration includes an encryption secret of arbitrary length and a clientId to identify itself. +This section describes how that information is used to encrypt and decrypt data sent to the server (versions and snapshots). + +#### Key Derivation + +The client derives the 32-byte encryption key from the configured encryption secret using PBKDF2 with HMAC-SHA256 and 100,000 iterations. +The salt is the SHA256 hash of the 16-byte form of the client key. + +#### Encryption + +The client uses [AEAD](https://commondatastorage.googleapis.com/chromium-boringssl-docs/aead.h.html), with algorithm CHACHA20_POLY1305. +The client should generate a random nonce, noting that AEAD is _not secure_ if a nonce is used repeatedly for the same key. + +AEAD supports additional authenticated data (AAD) which must be provided for both open and seal operations. +In this protocol, the AAD is always 17 bytes of the form: + * `app_id` (byte) - always 1 + * `version_id` (16 bytes) - 16-byte form of the version ID associated with this data + * for versions (AddVersion, GetChildVersion), the _parent_ version_id + * for snapshots (AddSnapshot, GetSnapshot), the snapshot version_id + +The `app_id` field is for future expansion to handle other, non-task data using this protocol. +Including it in the AAD ensures that such data cannot be confused with task data. + +Although the AEAD specification distinguishes ciphertext and tags, for purposes of this specification they are considered concatenated into a single bytestring as in BoringSSL's `EVP_AEAD_CTX_seal`. + +#### Representation + +The final byte-stream is comprised of the following structure: + +* `version` (byte) - format version (always 1) +* `nonce` (12 bytes) - encryption nonce +* `ciphertext` (remaining bytes) - ciphertext from sealing operation + +The `version` field identifies this data format, and future formats will have a value other than 1 in this position. + +### Version + +The decrypted form of a version is a JSON array containing operations in the order they should be applied. +Each operation has the form `{TYPE: DATA}`, for example: + + * `{"Create":{"uuid":"56e0be07-c61f-494c-a54c-bdcfdd52d2a7"}}` + * `{"Delete":{"uuid":"56e0be07-c61f-494c-a54c-bdcfdd52d2a7"}}` + * `{"Update":{"uuid":"56e0be07-c61f-494c-a54c-bdcfdd52d2a7","property":"prop","value":"v","timestamp":"2021-10-11T12:47:07.188090948Z"}}` + * `{"Update":{"uuid":"56e0be07-c61f-494c-a54c-bdcfdd52d2a7","property":"prop","value":null,"timestamp":"2021-10-11T12:47:07.188090948Z"}}` (to delete a property) + +Timestamps are in RFC3339 format with a `Z` suffix. + +### Snapshot + +The decrypted form of a snapshot is a JSON object mapping task IDs to task properties. +For example (pretty-printed for clarity): + +```json +{ + "56e0be07-c61f-494c-a54c-bdcfdd52d2a7": { + "description": "a task", + "priority": "H" + }, + "4b7ed904-f7b0-4293-8a10-ad452422c7b3": { + "description": "another task" + } +} +``` + +## Transactions + +### AddVersion + +The AddVersion transaction requests that the server add a new version to the client's task history. +The request contains the following; + + * parent version ID + * history segment + +The server determines whether the new version is acceptable, atomically with respect to other requests for the same client. +If it has no versions for the client, it accepts the version. +If it already has one or more versions for the client, then it accepts the version only if the given parent version ID matches its stored latest parent ID. + +If the version is accepted, the server generates a new version ID for it. +The version is added to the set of versions for the client, the client's latest version ID is set to the new version ID. +The new version ID is returned in the response to the client. +The response may also include a request for a snapshot, with associated urgency. + +If the version is not accepted, the server makes no changes, but responds to the client with a conflict indication containing the latest version ID. +The client may then "rebase" its operations and try again. +Note that if a client receives two conflict responses with the same parent version ID, it is an indication that the client's version history has diverged from that on the server. + +### GetChildVersion + +The GetChildVersion transaction is a read-only request for a version. +The request consists of a parent version ID. +The server searches its set of versions for a version with the given parent ID. +If found, it returns the version's + + * version ID, + * parent version ID (matching that in the request), and + * history segment. + +The response is either a version (success, _not-found_, or _gone_, as determined by the first of the following to apply: +* If a version with parentVersionId equal to the requested parentVersionId exists, it is returned. +* If the requested parentVersionId is the nil UUID .. + * ..and snapshotVersionId is nil, the response is _not-found_ (the client has no versions). + * ..and snapshotVersionId is not nil, the response is _gone_ (the first version has been deleted). +* If a version with versionId equal to the requested parentVersionId exists, the response is _not-found_ (the client is up-to-date) +* Otherwise, the response is _gone_ (the requested version has been deleted). + +### AddSnapshot + +The AddSnapshot transaction requests that the server store a new snapshot, generated by the client. +The request contains the following: + + * version ID at which the snapshot was made + * snapshot data (opaque to the server) + +The server should validate that the snapshot is for an existing version and is newer than any existing snapshot. +It may also validate that the snapshot is for a "recent" version (e.g., one of the last 5 versions). +If a snapshot already exists for the given version, the server may keep or discard the new snapshot but should return a success indication to the client. + +The server response is empty. + +### GetSnapshot + +The GetSnapshot transaction requests that the server provide the latest snapshot. +The response contains the snapshot version ID and the snapshot data, if those exist. + +## HTTP Representation + +The transactions above are realized for an HTTP server at `` using the HTTP requests and responses described here. +The `origin` *should* be an HTTPS endpoint on general principle, but nothing in the functonality or security of the protocol depends on connection encryption. + +The replica identifies itself to the server using a `clientKey` in the form of a UUID. +This value is passed with every request in the `X-Client-Id` header, in its dashed-hex format. + +### AddVersion + +The request is a `POST` to `/v1/client/add-version/`. +The request body contains the history segment, optionally encoded using any encoding supported by actix-web. +The content-type must be `application/vnd.taskchampion.history-segment`. + +The success response is a 200 OK with an empty body. +The new version ID appears in the `X-Version-Id` header. +If included, a snapshot request appears in the `X-Snapshot-Request` header with value `urgency=low` or `urgency=high`. + +On conflict, the response is a 409 CONFLICT with an empty body. +The expected parent version ID appears in the `X-Parent-Version-Id` header. + +Other error responses (4xx or 5xx) may be returned and should be treated appropriately to their meanings in the HTTP specification. + +### GetChildVersion + +The request is a `GET` to `/v1/client/get-child-version/`. + +The response is determined as described above. +The _not-found_ response is 404 NOT FOUND. +The _gone_ response is 410 GONE. +Neither has a response body. + +On success, the response is a 200 OK. +The version's history segment is returned in the response body, with content-type `application/vnd.taskchampion.history-segment`. +The version ID appears in the `X-Version-Id` header. +The response body may be encoded, in accordance with any `Accept-Encoding` header in the request. + +On failure, a client should treat a 404 NOT FOUND as indicating that it is up-to-date. +Clients should treat a 410 GONE as a synchronization error. +If the client has pending changes to send to the server, based on a now-removed version, then those changes cannot be reconciled and will be lost. +The client should, optionally after consulting the user, download and apply the latest snapshot. + +### AddSnapshot + +The request is a `POST` to `/v1/client/add-snapshot/`. +The request body contains the snapshot data, optionally encoded using any encoding supported by actix-web. +The content-type must be `application/vnd.taskchampion.snapshot`. + +If the version is invalid, as described above, the response should be 400 BAD REQUEST. +The server response should be 200 OK on success. + +### GetSnapshot + +The request is a `GET` to `/v1/client/snapshot`. + +The response is a 200 OK. +The snapshot is returned in the response body, with content-type `application/vnd.taskchampion.snapshot`. +The version ID appears in the `X-Version-Id` header. +The response body may be encoded, in accordance with any `Accept-Encoding` header in the request. + +After downloading and decrypting a snapshot, a client must replace its entire local task database with the content of the snapshot. +Any local operations that had not yet been synchronized must be discarded. +After the snapshot is applied, the client should begin the synchronization process again, starting from the snapshot version. diff --git a/rust/docs/src/sync.md b/rust/docs/src/sync.md new file mode 100644 index 000000000..fed75d17f --- /dev/null +++ b/rust/docs/src/sync.md @@ -0,0 +1,7 @@ +# Synchronization and the Sync Server + +This section covers *synchronization* of *replicas* containing the same set of tasks. +A replica is can perform all operations locally without connecting to a sync server, then share those operations with other replicas when it connects. +Sync is a critical feature of TaskChampion, allowing users to consult and update the same task list on multiple devices, without requiring constant connection. + +This is a complex topic, and the section is broken into several chapters, beginning at the lower levels of the implementation and working up. diff --git a/rust/docs/src/tags.md b/rust/docs/src/tags.md new file mode 100644 index 000000000..cf9d80102 --- /dev/null +++ b/rust/docs/src/tags.md @@ -0,0 +1,26 @@ +# Tags + +Each task has a collection of associated tags. +Tags are short words that categorize tasks, typically written with a leading `+`, such as `+next` or `+jobsearch`. + +Tags are useful for filtering tasks in reports or on the command line. +For example, when it's time to continue the job search, `ta +jobsearch` will show pending tasks with the `jobsearch` tag. + +## Allowed Tags + +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/rust/docs/src/task-sync.md b/rust/docs/src/task-sync.md new file mode 100644 index 000000000..82200ae48 --- /dev/null +++ b/rust/docs/src/task-sync.md @@ -0,0 +1,49 @@ +# Synchronization + +A single TaskChampion task database is known as a "replica". +A replica "synchronizes" its local information with other replicas via a sync server. +Many replicas can thus share the same task history. + +This operation is triggered by running `ta sync`. +Typically this runs frequently in a cron task. +Synchronization is quick, especially if no changes have occurred. + +Each replica expects to be synchronized frequently, even if no server is involved. +Without periodic syncs, the storage space used for the task database will grow quickly, and performance will suffer. + +## Local Sync + +By default, TaskChampion syncs to a "local server", as specified by the `server_dir` configuration parameter. +This defaults to `taskchampion-sync-server` in your [data directory](https://docs.rs/dirs-next/2.0.0/dirs_next/fn.data_dir.html), but can be customized in the configuration file. + +## Remote Sync + +For remote synchronization, you will need a few pieces of information. +From the server operator, you will need an origin and a client key. +Configure these with + +```shell +ta config set server_origin "" +ta config set server_client_key "" +``` + +You will need to generate your own encryption secret. +This is used to encrypt your task history, so treat it as a password. +The following will use the `openssl` utility to generate a suitable value: + +```shell +ta config set encryption_secret $(openssl rand -hex 35) +``` + +Every replica sharing a task history should have precisely the same configuration for `server_origin`, `server_client_key`, and `encryption_secret`. + +### Adding a New Replica + +Synchronizing a new replica to an existing task history is easy: begin with an empty replica, configured for the remote server, and run `ta sync`. +The replica will download the entire task history. + +### Upgrading a Locally-Sync'd Replica + +It is possible to switch a single replica to a remote server by simply configuring for the remote server and running `ta sync`. +The replica will upload the entire task history to the server. +Once this is complete, additional replicas can be configured with the same settings in order to share the task history. diff --git a/rust/docs/src/taskdb.md b/rust/docs/src/taskdb.md new file mode 100644 index 000000000..b70e9bf3c --- /dev/null +++ b/rust/docs/src/taskdb.md @@ -0,0 +1,32 @@ +# Task Database + +The task database is a layer of abstraction above the replica storage layer, responsible for maintaining some important invariants. +While the storage is pluggable, there is only one implementation of the task database. + +## Reading Data + +The task database provides read access to the data in the replica's storage through a variety of methods on the struct. +Each read operation is executed in a transaction, so data may not be consistent between read operations. +In practice, this is not an issue for TaskChampion's purposes. + +## Working Set + +The task database maintains the working set. +The working set maps small integers to current tasks, for easy reference by command-line users. +This is done in such a way that the task numbers remain stable until the working set is rebuilt, at which point gaps in the numbering, such as for completed tasks, are removed by shifting all higher-numbered tasks downward. + +The working set is not replicated, and is not considered a part of any consistency guarantees in the task database. + +## Modifying Data + +Modifications to the data set are made by applying operations. +Operations are described in [Replica Storage](./storage.md). + +Each operation is added to the list of operations in the storage, and simultaneously applied to the tasks in that storage. +Operations are checked for validity as they are applied. + +## Deletion and Expiration + +Deletion of a task merely changes the task's status to "deleted", leaving it in the Task database. +Actual removal of tasks from the task database takes place as part of _expiration_, triggered by the user as part of a garbage-collection process. +Expiration removes tasks with a `modified` property more than 180 days in the past, by creating a `Delete(uuid)` operation. diff --git a/rust/docs/src/tasks.md b/rust/docs/src/tasks.md new file mode 100644 index 000000000..ba303fb66 --- /dev/null +++ b/rust/docs/src/tasks.md @@ -0,0 +1,59 @@ +# Tasks + +Tasks are stored internally as a key/value map with string keys and values. +All fields are optional: the `Create` operation creates an empty task. +Display layers should apply appropriate defaults where necessary. + +## Atomicity + +The synchronization process does not support read-modify-write operations. +For example, suppose tags are updated by reading a list of tags, adding a tag, and writing the result back. +This would be captured as an `Update` operation containing the amended list of tags. +Suppose two such `Update` operations are made in different replicas and must be reconciled: + * `Update("d394be59-60e6-499e-b7e7-ca0142648409", "tags", "oldtag,newtag1", "2020-11-23T14:21:22Z")` + * `Update("d394be59-60e6-499e-b7e7-ca0142648409", "tags", "oldtag,newtag2", "2020-11-23T15:08:57Z")` + +The result of this reconciliation will be `oldtag,newtag2`, while the user almost certainly intended `oldtag,newtag1,newtag2`. + +The key names given below avoid this issue, allowing user updates such as adding a tag or deleting a dependency to be represented in a single `Update` operation. + +## Validity + +_Any_ key/value map is a valid task. +Consumers of task data must make a best effort to interpret any map, even if it contains apparently contradictory information. +For example, a task with status "completed" but no "end" key present should be interpreted as completed at an unknown time. + +## Representations + +Integers are stored in decimal notation. + +Timestamps are stored as UNIX epoch timestamps, in the form of an integer. + +## Keys + +The following keys, and key formats, are defined: + +* `status` - one of `P` for a pending task (the default), `C` for completed or `D` for deleted +* `description` - the one-line summary of the task +* `modified` - the time of the last modification of this task +* `start` - the most recent time at which this task was started (a task with no `start` key is not active) +* `end` - if present, the time at which this task was completed or deleted (note that this key may not agree with `status`: it may be present for a pending task, or absent for a deleted or completed task) +* `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 +* `entry` - the time at which the task was created +* `annotation_` - value is an annotation created at the given time + +The following are not yet implemented: + +* `dep_` - indicates this task depends on `` (value is an empty string) + +### UDAs + +Any unrecognized keys are treated as "user-defined attributes" (UDAs). +These attributes can be used to store additional data associated with a task. +For example, applications that synchronize tasks with other systems such as calendars or team planning services might store unique identifiers for those systems as UDAs. +The application defining a UDA defines the format of the value. + +UDAs _should_ have a namespaced structure of the form `.`, where `` identifies the application defining the UDA. +For example, a service named "DevSync" synchronizing tasks from GitHub might use UDAs like `devsync.github.issue-id`. +Note that many existing UDAs for Taskwarrior integrations do not follow this pattern; these are referred to as legacy UDAs. diff --git a/rust/docs/src/time.md b/rust/docs/src/time.md new file mode 100644 index 000000000..4053aea29 --- /dev/null +++ b/rust/docs/src/time.md @@ -0,0 +1,48 @@ +## 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. + + +## Named Timestamps + +Some commonly used named timestamps + + * `today` Start of today + * `yesterday` Start of yesterday + * `tomorrow` Start of tomorrow + * `sod` Start of today + * `eod` End of today + * `sow` Start of the next week + * `eow` End of the week + * `eoww` End of work week + * `soww` Start of the next work week + + +![named timestamp](images/name_timestamp.png) diff --git a/rust/docs/src/undo.md b/rust/docs/src/undo.md new file mode 100644 index 000000000..3d6702240 --- /dev/null +++ b/rust/docs/src/undo.md @@ -0,0 +1,7 @@ +# Undo + +It's easy to make a mistake: mark the wrong task as done, or hit enter before noticing a typo in a tag name. +The `ta undo` command makes it just as easy to fix the mistake, by effectively reversing the most recent change. +Multiple invocations of `ta undo` can be used to undo multiple changes. + +The limit of this functionality is that changes which have been synchronized to the server (via `ta sync`) cannot be undone. diff --git a/rust/docs/src/using-task-command.md b/rust/docs/src/using-task-command.md new file mode 100644 index 000000000..355dcc898 --- /dev/null +++ b/rust/docs/src/using-task-command.md @@ -0,0 +1,16 @@ +# Using the Task Command + +The main interface to your tasks is the `ta` command, which supports various subcommands such as `add`, `modify`, `start`, and `done`. +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). + +> 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/rust/docs/src/welcome.md b/rust/docs/src/welcome.md new file mode 100644 index 000000000..0c21e9f53 --- /dev/null +++ b/rust/docs/src/welcome.md @@ -0,0 +1,63 @@ +# TaskChampion + +TaskChampion is a personal task-tracking tool. +It works from the command line, with simple commands like `ta add "fix the kitchen sink"`. +It can synchronize tasks on multiple devices, and does so in an "offline" mode so you can update your tasks even when you can't reach the server. +If you've heard of [TaskWarrior](https://taskwarrior.org/), this tool is very similar, but with some different design choices and greater reliability. + +## Getting Started + +> NOTE: TaskChampion is still in development and not yet feature-complete. +> This section is limited to completed functionality. + +Once you've [installed TaskChampion](./installation.md), your interface will be via the `ta` command. +Start by adding a task: + +```shell +$ ta add learn how to use taskchampion +added task ba57deaf-f97b-4e9c-b9ab-04bc1ecb22b8 +``` + +You can see all of your pending tasks with `ta next`, or just `ta` for short: + +```shell +$ ta + Id Description Active Tags + 1 learn how to use taskchampion +``` + +Tell TaskChampion you're working on the task, using the shorthand id: + +```shell +$ ta start 1 +``` + +and when you're done with the task, mark it as complete: + +```shell +$ ta done 1 +``` + +## Synchronizing + +Even if you don't have a server, it's a good idea to sync your task database periodically. +This acts as a backup and also enables some internal house-cleaning. + +```shell +$ ta sync +``` + +Typically sync is run from a crontab, on whatever schedule fits your needs. + +To synchronize multiple replicas of your tasks, you will need a sync server and a client key for that server. +Configure these in `~/.config/taskchampion.yml`, for example: + +```yaml +server_client_key: "f8d4d09d-f6c7-4dd2-ab50-634ed20a3ff2" +server_origin: "https://taskchampion.example.com" +``` + +The next run of `ta sync` will upload your task history to that server. +Configuring another device identically and running `ta sync` will download that task history, and continue to stay in sync with subsequent runs of the command. + +See [Usage](./using-task-command.md) for more detailed information on using TaskChampion. diff --git a/rust/integration-tests/.gitignore b/rust/integration-tests/.gitignore new file mode 100644 index 000000000..140a35ffe --- /dev/null +++ b/rust/integration-tests/.gitignore @@ -0,0 +1,2 @@ +test-db +test-sync-server diff --git a/rust/integration-tests/Cargo.toml b/rust/integration-tests/Cargo.toml new file mode 100644 index 000000000..1d57b1a7d --- /dev/null +++ b/rust/integration-tests/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "integration-tests" +version = "0.4.1" +authors = ["Dustin J. Mitchell "] +edition = "2018" +publish = false +build = "build.rs" + +[dependencies] +taskchampion = { path = "../taskchampion" } +taskchampion-sync-server = { path = "../sync-server" } + +[dev-dependencies] +anyhow = "1.0" +actix-web = "^3.3.2" +actix-rt = "^1.1.1" +tempfile = "3" +pretty_assertions = "1" +log = "^0.4.14" +env_logger = "^0.8.3" +lazy_static = "1" + +[build-dependencies] +cc = "1.0.73" +taskchampion-lib = { path = "../lib" } diff --git a/rust/integration-tests/README.md b/rust/integration-tests/README.md new file mode 100644 index 000000000..cbec3515a --- /dev/null +++ b/rust/integration-tests/README.md @@ -0,0 +1,30 @@ +# Integration Tests for Taskchampion + +## "Regular" Tests + +Some of the tests in `tests/` are just regular integration tests. +Nothing exciting to see. + +## Bindings Tests + +The bindings tests are a bit more interesting, since they are written in C. +They are composed of a collection of "suites", each in one C file in `integration-tests/src/bindings_tests/`. +Each suite contains a number of tests (using [Unity](http://www.throwtheswitch.org/unity)) and an exported function named after the suite that returns an exit status (1 = failure). + +The build script (`integration-tests/build.rs`) builds these files into a library that is linked with the `integration_tests` library crate. +This crate contains a `bindings_tests` module with a pub function for each suite. + +Finally, the `integration-tests/tests/bindings.rs` test file calls each of those functions in a separate test case. + +### Adding Tests + +To add a test, select a suite and add a new test-case function. +Add a `RUN_TEST` invocation for your new function to the `.._tests` function at the bottom. +Keep the `RUN_TEST`s in the same order as the functions they call. + +### Adding Suites + +To add a suite, + +1. Add a new C file in `integration-tests/src/bindings_tests/`, based off of one of the others. +1. Add a the suite name to `suites` in `integration-tests/build.rs`. diff --git a/rust/integration-tests/build.rs b/rust/integration-tests/build.rs new file mode 100644 index 000000000..face5701d --- /dev/null +++ b/rust/integration-tests/build.rs @@ -0,0 +1,73 @@ +use std::env; +use std::fs; +use std::path::Path; + +/// Link to the libtaskchampion library produced by the `taskchampion-lib` crate. This is done as +/// a build dependency, rather than a cargo dependency, so that the symbols are available to +/// bindings-tests. +fn link_libtaskchampion() { + // This crate has taskchampion-lib in its build-dependencies, so libtaskchampion.so should be + // built already. + // + // Shared libraries (crate-type=cdylib) appear to be placed in target/$PROFILE/deps. + let mut libtc_dir = env::current_dir().unwrap(); + libtc_dir.pop(); + libtc_dir.push("target"); + libtc_dir.push(env::var("PROFILE").unwrap()); + libtc_dir.push("deps"); + + let libtc_dir = libtc_dir.to_str().expect("path is valid utf-8"); + println!("cargo:rustc-link-search={}", libtc_dir); + println!("cargo:rustc-link-lib=dylib=taskchampionlib"); + + // on windows, it appears that rust std requires BCrypt + if cfg!(target_os = "windows") { + println!("cargo:rustc-link-lib=dylib=bcrypt"); + } +} + +/// Build the Unity-based C test suite in `src/bindings_tests`, linking the result with this +/// package's library crate. +fn build_bindings_tests(suites: &[&'static str]) { + let mut build = cc::Build::new(); + build.include("../lib"); // include path for taskchampion.h + build.include("src/bindings_tests/unity"); + build.define("UNITY_OUTPUT_CHAR", "test_output"); + build.define( + "UNITY_OUTPUT_CHAR_HEADER_DECLARATION", + "test_output(char c)", + ); + build.file("src/bindings_tests/unity/unity.c"); + + let mut files = vec!["src/bindings_tests/test.c".to_string()]; + for suite in suites { + files.push(format!("src/bindings_tests/{}.c", suite)); + } + for file in files { + build.file(&file); + println!("cargo:rerun-if-changed={}", file); + } + + build.compile("bindings-tests"); +} + +/// Make `bindings_test_suites.rs` listing all of the test suites, for use in building the +/// bindings-test binary. +fn make_suite_file(suites: &[&'static str]) { + let out_dir = env::var_os("OUT_DIR").unwrap(); + let dest_path = Path::new(&out_dir).join("bindings_test_suites.rs"); + let mut content = String::new(); + for suite in suites { + content.push_str(format!("suite!({}_tests);\n", suite).as_ref()); + } + fs::write(&dest_path, content).unwrap(); +} + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + + let suites = &["uuid", "string", "task", "replica"]; + link_libtaskchampion(); + build_bindings_tests(suites); + make_suite_file(suites); +} diff --git a/rust/integration-tests/src/bindings_tests/mod.rs b/rust/integration-tests/src/bindings_tests/mod.rs new file mode 100644 index 000000000..327ca83fe --- /dev/null +++ b/rust/integration-tests/src/bindings_tests/mod.rs @@ -0,0 +1,30 @@ +use std::fs; + +extern "C" { + // set up to send test output to TEST-OUTPUT + fn setup_output(); + // close the output file + fn finish_output(); +} + +// Each suite is represented by a _tests C function in .c. +// All of these C files are built into a library that is linked to the crate -- but not to test +// crates. So, this macro produces a "glue function" that calls the C function, and that can be +// called from test crates. +macro_rules! suite( + { $s:ident } => { + pub fn $s() -> (i32, String) { + extern "C" { + fn $s() -> i32; + } + unsafe { setup_output() }; + let res = unsafe { $s() }; + unsafe { finish_output() }; + let output = fs::read_to_string("TEST-OUTPUT") + .unwrap_or_else(|e| format!("could not open TEST-OUTPUT: {}", e)); + (res, output) + } + }; +); + +include!(concat!(env!("OUT_DIR"), "/bindings_test_suites.rs")); diff --git a/rust/integration-tests/src/bindings_tests/replica.c b/rust/integration-tests/src/bindings_tests/replica.c new file mode 100644 index 000000000..639cf5f48 --- /dev/null +++ b/rust/integration-tests/src/bindings_tests/replica.c @@ -0,0 +1,330 @@ +#include +#include +#include +#include "taskchampion.h" +#include "unity.h" + +// creating an in-memory replica does not crash +static void test_replica_creation(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NOT_NULL(rep); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + tc_replica_free(rep); +} + +// creating an on-disk replica does not crash +static void test_replica_creation_disk(void) { + TCReplica *rep = tc_replica_new_on_disk(tc_string_borrow("test-db"), NULL); + TEST_ASSERT_NOT_NULL(rep); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + tc_replica_free(rep); +} + +// undo on an empty in-memory TCReplica does nothing +static void test_replica_undo_empty(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + int undone; + int rv = tc_replica_undo(rep, &undone); + TEST_ASSERT_EQUAL(TC_RESULT_OK, rv); + TEST_ASSERT_EQUAL(0, undone); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + tc_replica_free(rep); +} + +// adding an undo point succeeds +static void test_replica_add_undo_point(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_replica_add_undo_point(rep, true)); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + tc_replica_free(rep); +} + +// working set operations succeed +static void test_replica_working_set(void) { + TCWorkingSet *ws; + TCTask *task1, *task2, *task3; + TCUuid uuid, uuid1, uuid2, uuid3; + + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_replica_rebuild_working_set(rep, true)); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + ws = tc_replica_working_set(rep); + TEST_ASSERT_EQUAL(0, tc_working_set_len(ws)); + tc_working_set_free(ws); + + task1 = tc_replica_new_task(rep, TC_STATUS_PENDING, tc_string_borrow("task1")); + TEST_ASSERT_NOT_NULL(task1); + uuid1 = tc_task_get_uuid(task1); + + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_replica_rebuild_working_set(rep, true)); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + task2 = tc_replica_new_task(rep, TC_STATUS_PENDING, tc_string_borrow("task2")); + TEST_ASSERT_NOT_NULL(task2); + uuid2 = tc_task_get_uuid(task2); + + task3 = tc_replica_new_task(rep, TC_STATUS_PENDING, tc_string_borrow("task3")); + TEST_ASSERT_NOT_NULL(task3); + uuid3 = tc_task_get_uuid(task3); + + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_replica_rebuild_working_set(rep, false)); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + // finish task2 to leave a "hole" + tc_task_to_mut(task2, rep); + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_done(task2)); + tc_task_to_immut(task2); + + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_replica_rebuild_working_set(rep, false)); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + tc_task_free(task1); + tc_task_free(task2); + tc_task_free(task3); + + // working set should now be + // 0 -> None + // 1 -> uuid1 + // 2 -> None + // 3 -> uuid3 + ws = tc_replica_working_set(rep); + TEST_ASSERT_EQUAL(2, tc_working_set_len(ws)); + TEST_ASSERT_EQUAL(3, tc_working_set_largest_index(ws)); + + TEST_ASSERT_FALSE(tc_working_set_by_index(ws, 0, &uuid)); + TEST_ASSERT_TRUE(tc_working_set_by_index(ws, 1, &uuid)); + TEST_ASSERT_EQUAL_MEMORY(uuid1.bytes, uuid.bytes, sizeof(uuid)); + TEST_ASSERT_FALSE(tc_working_set_by_index(ws, 2, &uuid)); + TEST_ASSERT_TRUE(tc_working_set_by_index(ws, 3, &uuid)); + TEST_ASSERT_EQUAL_MEMORY(uuid3.bytes, uuid.bytes, sizeof(uuid)); + + TEST_ASSERT_EQUAL(1, tc_working_set_by_uuid(ws, uuid1)); + TEST_ASSERT_EQUAL(0, tc_working_set_by_uuid(ws, uuid2)); + TEST_ASSERT_EQUAL(3, tc_working_set_by_uuid(ws, uuid3)); + + tc_working_set_free(ws); + + TEST_ASSERT_EQUAL(19, tc_replica_num_local_operations(rep)); + + tc_replica_free(rep); +} + +// When tc_replica_undo is passed NULL for undone_out, it still succeeds +static void test_replica_undo_empty_null_undone_out(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + int rv = tc_replica_undo(rep, NULL); + TEST_ASSERT_EQUAL(TC_RESULT_OK, rv); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + tc_replica_free(rep); +} + +// creating a task succeeds and the resulting task looks good +static void test_replica_task_creation(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + TCTask *task = tc_replica_new_task( + rep, + TC_STATUS_PENDING, + tc_string_borrow("my task")); + TEST_ASSERT_NOT_NULL(task); + + TCUuid uuid = tc_task_get_uuid(task); + TEST_ASSERT_EQUAL(TC_STATUS_PENDING, tc_task_get_status(task)); + + TCString desc = tc_task_get_description(task); + TEST_ASSERT_NOT_NULL(desc.ptr); + TEST_ASSERT_EQUAL_STRING("my task", tc_string_content(&desc)); + tc_string_free(&desc); + + tc_task_free(task); + + // get the task again and verify it + task = tc_replica_get_task(rep, uuid); + TEST_ASSERT_NOT_NULL(task); + TEST_ASSERT_EQUAL_MEMORY(uuid.bytes, tc_task_get_uuid(task).bytes, sizeof(uuid.bytes)); + TEST_ASSERT_EQUAL(TC_STATUS_PENDING, tc_task_get_status(task)); + + tc_task_free(task); + + tc_replica_free(rep); +} + +// When tc_replica_undo is passed NULL for undone_out, it still succeeds +static void test_replica_sync_local(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + mkdir("test-sync-server", 0755); // ignore error, if dir already exists + + TCString err; + TCServer *server = tc_server_new_local(tc_string_borrow("test-sync-server"), &err); + TEST_ASSERT_NOT_NULL(server); + TEST_ASSERT_NULL(err.ptr); + + int rv = tc_replica_sync(rep, server, false); + TEST_ASSERT_EQUAL(TC_RESULT_OK, rv); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + tc_server_free(server); + tc_replica_free(rep); + + // test error handling + server = tc_server_new_local(tc_string_borrow("/no/such/directory"), &err); + TEST_ASSERT_NULL(server); + TEST_ASSERT_NOT_NULL(err.ptr); + tc_string_free(&err); +} + +// When tc_replica_undo is passed NULL for undone_out, it still succeeds +static void test_replica_remote_server(void) { + TCString err; + TCServer *server = tc_server_new_remote( + tc_string_borrow("tc.freecinc.com"), + tc_uuid_new_v4(), + tc_string_borrow("\xf0\x28\x8c\x28"), // NOTE: not utf-8 + &err); + TEST_ASSERT_NOT_NULL(server); + TEST_ASSERT_NULL(err.ptr); + + // can't actually do anything with this server! + + tc_server_free(server); +} + +// a replica with tasks in it returns an appropriate list of tasks and list of uuids +static void test_replica_all_tasks(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + TCTask *task1 = tc_replica_new_task( + rep, + TC_STATUS_PENDING, + tc_string_borrow("task1")); + TEST_ASSERT_NOT_NULL(task1); + TCUuid uuid1 = tc_task_get_uuid(task1); + tc_task_free(task1); + + TCTask *task2 = tc_replica_new_task( + rep, + TC_STATUS_PENDING, + tc_string_borrow("task2")); + TEST_ASSERT_NOT_NULL(task2); + TCUuid uuid2 = tc_task_get_uuid(task2); + tc_task_free(task2); + + { + TCTaskList tasks = tc_replica_all_tasks(rep); + TEST_ASSERT_NOT_NULL(tasks.items); + TEST_ASSERT_EQUAL(2, tasks.len); + + bool seen1, seen2 = false; + for (size_t i = 0; i < tasks.len; i++) { + TCTask *task = tasks.items[i]; + TCString descr = tc_task_get_description(task); + if (0 == strcmp(tc_string_content(&descr), "task1")) { + seen1 = true; + } else if (0 == strcmp(tc_string_content(&descr), "task2")) { + seen2 = true; + } + tc_string_free(&descr); + } + TEST_ASSERT_TRUE(seen1); + TEST_ASSERT_TRUE(seen2); + + tc_task_list_free(&tasks); + TEST_ASSERT_NULL(tasks.items); + } + + { + TCUuidList uuids = tc_replica_all_task_uuids(rep); + TEST_ASSERT_NOT_NULL(uuids.items); + TEST_ASSERT_EQUAL(2, uuids.len); + + bool seen1, seen2 = false; + for (size_t i = 0; i < uuids.len; i++) { + TCUuid uuid = uuids.items[i]; + if (0 == memcmp(&uuid1, &uuid, sizeof(TCUuid))) { + seen1 = true; + } else if (0 == memcmp(&uuid2, &uuid, sizeof(TCUuid))) { + seen2 = true; + } + } + TEST_ASSERT_TRUE(seen1); + TEST_ASSERT_TRUE(seen2); + + tc_uuid_list_free(&uuids); + TEST_ASSERT_NULL(uuids.items); + } + + tc_replica_free(rep); +} + +// importing a task succeeds and the resulting task looks good +static void test_replica_task_import(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + TCUuid uuid; + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_uuid_from_str(tc_string_borrow("23cb25e0-5d1a-4932-8131-594ac6d3a843"), &uuid)); + TCTask *task = tc_replica_import_task_with_uuid(rep, uuid); + TEST_ASSERT_NOT_NULL(task); + + TEST_ASSERT_EQUAL_MEMORY(uuid.bytes, tc_task_get_uuid(task).bytes, sizeof(uuid.bytes)); + TEST_ASSERT_EQUAL(TC_STATUS_PENDING, tc_task_get_status(task)); + + TCString desc = tc_task_get_description(task); + TEST_ASSERT_NOT_NULL(desc.ptr); + TEST_ASSERT_EQUAL_STRING("", tc_string_content(&desc)); // default value + tc_string_free(&desc); + + tc_task_free(task); + + // get the task again and verify it + task = tc_replica_get_task(rep, uuid); + TEST_ASSERT_NOT_NULL(task); + TEST_ASSERT_EQUAL_MEMORY(uuid.bytes, tc_task_get_uuid(task).bytes, sizeof(uuid.bytes)); + TEST_ASSERT_EQUAL(TC_STATUS_PENDING, tc_task_get_status(task)); + + tc_task_free(task); + + tc_replica_free(rep); +} + +// importing a task succeeds and the resulting task looks good +static void test_replica_get_task_not_found(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + TCUuid uuid; + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_uuid_from_str(tc_string_borrow("23cb25e0-5d1a-4932-8131-594ac6d3a843"), &uuid)); + TCTask *task = tc_replica_get_task(rep, uuid); + TEST_ASSERT_NULL(task); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + tc_replica_free(rep); +} + +int replica_tests(void) { + UNITY_BEGIN(); + // each test case above should be named here, in order. + RUN_TEST(test_replica_creation); + RUN_TEST(test_replica_creation_disk); + RUN_TEST(test_replica_undo_empty); + RUN_TEST(test_replica_add_undo_point); + RUN_TEST(test_replica_working_set); + RUN_TEST(test_replica_undo_empty_null_undone_out); + RUN_TEST(test_replica_task_creation); + RUN_TEST(test_replica_sync_local); + RUN_TEST(test_replica_remote_server); + RUN_TEST(test_replica_all_tasks); + RUN_TEST(test_replica_task_import); + RUN_TEST(test_replica_get_task_not_found); + return UNITY_END(); +} diff --git a/rust/integration-tests/src/bindings_tests/string.c b/rust/integration-tests/src/bindings_tests/string.c new file mode 100644 index 000000000..2bd2749c0 --- /dev/null +++ b/rust/integration-tests/src/bindings_tests/string.c @@ -0,0 +1,125 @@ +#include +#include +#include "unity.h" +#include "taskchampion.h" + +// creating strings does not crash +static void test_string_creation(void) { + TCString s = tc_string_borrow("abcdef"); + tc_string_free(&s); + TEST_ASSERT_NULL(s.ptr); +} + +// creating cloned strings does not crash +static void test_string_cloning(void) { + char *abcdef = strdup("abcdef"); + TCString s = tc_string_clone(abcdef); + TEST_ASSERT_NOT_NULL(s.ptr); + free(abcdef); + + TEST_ASSERT_EQUAL_STRING("abcdef", tc_string_content(&s)); + tc_string_free(&s); + TEST_ASSERT_NULL(s.ptr); +} + +// creating cloned strings with invalid utf-8 does not crash +// ..but content is NULL and content_and_len returns the value +static void test_string_cloning_invalid_utf8(void) { + TCString s = tc_string_clone("\xf0\x28\x8c\x28"); + TEST_ASSERT_NOT_NULL(s.ptr); + + // NOTE: this is not one of the cases where invalid UTF-8 results in NULL, + // but that may change. + + size_t len; + const char *buf = tc_string_content_with_len(&s, &len); + TEST_ASSERT_NOT_NULL(buf); + TEST_ASSERT_EQUAL(4, len); + TEST_ASSERT_EQUAL_MEMORY("\xf0\x28\x8c\x28", buf, len); + + tc_string_free(&s); + TEST_ASSERT_NULL(s.ptr); +} + +// borrowed strings echo back their content +static void test_string_borrowed_strings_echo(void) { + TCString s = tc_string_borrow("abcdef"); + TEST_ASSERT_NOT_NULL(s.ptr); + + TEST_ASSERT_EQUAL_STRING("abcdef", tc_string_content(&s)); + + size_t len; + const char *buf = tc_string_content_with_len(&s, &len); + TEST_ASSERT_NOT_NULL(buf); + TEST_ASSERT_EQUAL(6, len); + TEST_ASSERT_EQUAL_MEMORY("abcdef", buf, len); + + tc_string_free(&s); + TEST_ASSERT_NULL(s.ptr); +} + +// cloned strings echo back their content +static void test_string_cloned_strings_echo(void) { + char *orig = strdup("abcdef"); + TCString s = tc_string_clone(orig); + TEST_ASSERT_NOT_NULL(s.ptr); + free(orig); + + TEST_ASSERT_EQUAL_STRING("abcdef", tc_string_content(&s)); + + size_t len; + const char *buf = tc_string_content_with_len(&s, &len); + TEST_ASSERT_NOT_NULL(buf); + TEST_ASSERT_EQUAL(6, len); + TEST_ASSERT_EQUAL_MEMORY("abcdef", buf, len); + + tc_string_free(&s); + TEST_ASSERT_NULL(s.ptr); +} + +// tc_clone_with_len can have NULs, and tc_string_content returns NULL for +// strings containing embedded NULs +static void test_string_content_null_for_embedded_nuls(void) { + TCString s = tc_string_clone_with_len("ab\0de", 5); + TEST_ASSERT_NOT_NULL(s.ptr); + + TEST_ASSERT_NULL(tc_string_content(&s)); + + size_t len; + const char *buf = tc_string_content_with_len(&s, &len); + TEST_ASSERT_NOT_NULL(buf); + TEST_ASSERT_EQUAL(5, len); + TEST_ASSERT_EQUAL_MEMORY("ab\0de", buf, len); + tc_string_free(&s); + TEST_ASSERT_NULL(s.ptr); +} + +// tc_string_clone_with_len will accept invalid utf-8, but then tc_string_content +// returns NULL. +static void test_string_clone_with_len_invalid_utf8(void) { + TCString s = tc_string_clone_with_len("\xf0\x28\x8c\x28", 4); + TEST_ASSERT_NOT_NULL(s.ptr); + + TEST_ASSERT_NULL(tc_string_content(&s)); + + size_t len; + const char *buf = tc_string_content_with_len(&s, &len); + TEST_ASSERT_NOT_NULL(buf); + TEST_ASSERT_EQUAL(4, len); + TEST_ASSERT_EQUAL_MEMORY("\xf0\x28\x8c\x28", buf, len); + tc_string_free(&s); + TEST_ASSERT_NULL(s.ptr); +} + +int string_tests(void) { + UNITY_BEGIN(); + // each test case above should be named here, in order. + RUN_TEST(test_string_creation); + RUN_TEST(test_string_cloning); + RUN_TEST(test_string_cloning_invalid_utf8); + RUN_TEST(test_string_borrowed_strings_echo); + RUN_TEST(test_string_cloned_strings_echo); + RUN_TEST(test_string_content_null_for_embedded_nuls); + RUN_TEST(test_string_clone_with_len_invalid_utf8); + return UNITY_END(); +} diff --git a/rust/integration-tests/src/bindings_tests/task.c b/rust/integration-tests/src/bindings_tests/task.c new file mode 100644 index 000000000..4a30f8e1c --- /dev/null +++ b/rust/integration-tests/src/bindings_tests/task.c @@ -0,0 +1,668 @@ +#include +#include +#include "unity.h" +#include "taskchampion.h" + +// creating a task succeeds and the resulting task looks good +static void test_task_creation(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + TCTask *task = tc_replica_new_task( + rep, + TC_STATUS_PENDING, + tc_string_borrow("my task")); + TEST_ASSERT_NOT_NULL(task); + + TEST_ASSERT_EQUAL(TC_STATUS_PENDING, tc_task_get_status(task)); + + TCString desc = tc_task_get_description(task); + TEST_ASSERT_NOT_NULL(desc.ptr); + TEST_ASSERT_EQUAL_STRING("my task", tc_string_content(&desc)); + tc_string_free(&desc); + + tc_task_free(task); + + tc_replica_free(rep); +} + +// freeing a mutable task works, marking it immutable +static void test_task_free_mutable_task(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + TCTask *task = tc_replica_new_task( + rep, + TC_STATUS_PENDING, + tc_string_borrow("my task")); + TEST_ASSERT_NOT_NULL(task); + + TEST_ASSERT_EQUAL(TC_STATUS_PENDING, tc_task_get_status(task)); + TCUuid uuid = tc_task_get_uuid(task); + + tc_task_to_mut(task, rep); + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_set_status(task, TC_STATUS_DELETED)); + TEST_ASSERT_EQUAL(TC_STATUS_DELETED, tc_task_get_status(task)); + + tc_task_free(task); // implicitly converts to immut + + task = tc_replica_get_task(rep, uuid); + TEST_ASSERT_NOT_NULL(task); + TEST_ASSERT_EQUAL(TC_STATUS_DELETED, tc_task_get_status(task)); + tc_task_free(task); + + tc_replica_free(rep); +} + +// updating status on a task works +static void test_task_get_set_status(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + TCTask *task = tc_replica_new_task( + rep, + TC_STATUS_PENDING, + tc_string_borrow("my task")); + TEST_ASSERT_NOT_NULL(task); + + TEST_ASSERT_EQUAL(TC_STATUS_PENDING, tc_task_get_status(task)); + + tc_task_to_mut(task, rep); + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_set_status(task, TC_STATUS_DELETED)); + TEST_ASSERT_EQUAL(TC_STATUS_DELETED, tc_task_get_status(task)); // while mut + tc_task_to_immut(task); + TEST_ASSERT_EQUAL(TC_STATUS_DELETED, tc_task_get_status(task)); // while immut + + tc_task_free(task); + + tc_replica_free(rep); +} + +// updating description on a task works +static void test_task_get_set_description(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + TCTask *task = tc_replica_new_task( + rep, + TC_STATUS_PENDING, + tc_string_borrow("my task")); + TEST_ASSERT_NOT_NULL(task); + + TCString desc; + + tc_task_to_mut(task, rep); + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_set_description(task, tc_string_borrow("updated"))); + + desc = tc_task_get_description(task); + TEST_ASSERT_NOT_NULL(desc.ptr); + TEST_ASSERT_EQUAL_STRING("updated", tc_string_content(&desc)); + tc_string_free(&desc); + + tc_task_to_immut(task); + + desc = tc_task_get_description(task); + TEST_ASSERT_NOT_NULL(desc.ptr); + TEST_ASSERT_EQUAL_STRING("updated", tc_string_content(&desc)); + tc_string_free(&desc); + + tc_task_free(task); + + tc_replica_free(rep); +} + +// updating entry on a task works +static void test_task_get_set_entry(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + TCTask *task = tc_replica_new_task( + rep, + TC_STATUS_PENDING, + tc_string_borrow("my task")); + TEST_ASSERT_NOT_NULL(task); + + // creation of a task sets entry to current time + TEST_ASSERT_NOT_EQUAL(0, tc_task_get_entry(task)); + + tc_task_to_mut(task, rep); + + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_set_entry(task, 1643679997)); + TEST_ASSERT_EQUAL(1643679997, tc_task_get_entry(task)); + + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_set_entry(task, 0)); + TEST_ASSERT_EQUAL(0, tc_task_get_entry(task)); + + tc_task_free(task); + + tc_replica_free(rep); +} + +// updating wait on a task works +static void test_task_get_set_wait_and_is_waiting(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + TCTask *task = tc_replica_new_task( + rep, + TC_STATUS_PENDING, + tc_string_borrow("my task")); + TEST_ASSERT_NOT_NULL(task); + + // wait is not set on creation + TEST_ASSERT_EQUAL(0, tc_task_get_wait(task)); + TEST_ASSERT_FALSE(tc_task_is_waiting(task)); + + tc_task_to_mut(task, rep); + + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_set_wait(task, 3643679997)); // 2085 + TEST_ASSERT_EQUAL(3643679997, tc_task_get_wait(task)); + TEST_ASSERT_TRUE(tc_task_is_waiting(task)); + + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_set_wait(task, 643679997)); // THE PAST! + TEST_ASSERT_EQUAL(643679997, tc_task_get_wait(task)); + TEST_ASSERT_FALSE(tc_task_is_waiting(task)); + + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_set_wait(task, 0)); + TEST_ASSERT_EQUAL(0, tc_task_get_wait(task)); + TEST_ASSERT_FALSE(tc_task_is_waiting(task)); + + tc_task_free(task); + + tc_replica_free(rep); +} + +// updating modified on a task works +static void test_task_get_set_modified(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + TCTask *task = tc_replica_new_task( + rep, + TC_STATUS_PENDING, + tc_string_borrow("my task")); + TEST_ASSERT_NOT_NULL(task); + + // creation of a task sets modified to current time + TEST_ASSERT_NOT_EQUAL(0, tc_task_get_modified(task)); + + tc_task_to_mut(task, rep); + + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_set_modified(task, 1643679997)); + TEST_ASSERT_EQUAL(1643679997, tc_task_get_modified(task)); + + // zero is not allowed + TEST_ASSERT_EQUAL(TC_RESULT_ERROR, tc_task_set_modified(task, 0)); + + tc_task_free(task); + + tc_replica_free(rep); +} + +// starting and stopping a task works, as seen by tc_task_is_active +static void test_task_start_stop_is_active(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + TCTask *task = tc_replica_new_task( + rep, + TC_STATUS_PENDING, + tc_string_borrow("my task")); + TEST_ASSERT_NOT_NULL(task); + + TEST_ASSERT_FALSE(tc_task_is_active(task)); + + tc_task_to_mut(task, rep); + + TEST_ASSERT_FALSE(tc_task_is_active(task)); + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_start(task)); + TEST_ASSERT_TRUE(tc_task_is_active(task)); + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_stop(task)); + TEST_ASSERT_FALSE(tc_task_is_active(task)); + + tc_task_free(task); + tc_replica_free(rep); +} + +// tc_task_done and delete work and set the status +static void test_task_done_and_delete(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + TCTask *task = tc_replica_new_task( + rep, + TC_STATUS_PENDING, + tc_string_borrow("my task")); + TEST_ASSERT_NOT_NULL(task); + + tc_task_to_mut(task, rep); + + TEST_ASSERT_EQUAL(TC_STATUS_PENDING, tc_task_get_status(task)); + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_done(task)); + TEST_ASSERT_EQUAL(TC_STATUS_COMPLETED, tc_task_get_status(task)); + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_delete(task)); + TEST_ASSERT_EQUAL(TC_STATUS_DELETED, tc_task_get_status(task)); + + tc_task_free(task); + tc_replica_free(rep); +} + +// adding and removing tags to a task works, and invalid tags are rejected +static void test_task_add_remove_has_tag(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + TCTask *task = tc_replica_new_task( + rep, + TC_STATUS_PENDING, + tc_string_borrow("my task")); + TEST_ASSERT_NOT_NULL(task); + + tc_task_to_mut(task, rep); + + TEST_ASSERT_FALSE(tc_task_has_tag(task, tc_string_borrow("next"))); + + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_add_tag(task, tc_string_borrow("next"))); + TEST_ASSERT_NULL(tc_task_error(task).ptr); + + TEST_ASSERT_TRUE(tc_task_has_tag(task, tc_string_borrow("next"))); + + // invalid - synthetic tag + TEST_ASSERT_EQUAL(TC_RESULT_ERROR, tc_task_add_tag(task, tc_string_borrow("PENDING"))); + TCString err = tc_task_error(task); + TEST_ASSERT_NOT_NULL(err.ptr); + tc_string_free(&err); + + // invald - not a valid tag string + TEST_ASSERT_EQUAL(TC_RESULT_ERROR, tc_task_add_tag(task, tc_string_borrow("my tag"))); + err = tc_task_error(task); + TEST_ASSERT_NOT_NULL(err.ptr); + tc_string_free(&err); + + // invald - not utf-8 + TEST_ASSERT_EQUAL(TC_RESULT_ERROR, tc_task_add_tag(task, tc_string_borrow("\xf0\x28\x8c\x28"))); + err = tc_task_error(task); + TEST_ASSERT_NOT_NULL(err.ptr); + tc_string_free(&err); + + TEST_ASSERT_TRUE(tc_task_has_tag(task, tc_string_borrow("next"))); + + // remove the tag + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_remove_tag(task, tc_string_borrow("next"))); + TEST_ASSERT_NULL(tc_task_error(task).ptr); + + TEST_ASSERT_FALSE(tc_task_has_tag(task, tc_string_borrow("next"))); + + tc_task_free(task); + tc_replica_free(rep); +} + +// get_tags returns the list of tags +static void test_task_get_tags(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + TCTask *task = tc_replica_new_task( + rep, + TC_STATUS_PENDING, + tc_string_borrow("my task")); + TEST_ASSERT_NOT_NULL(task); + + tc_task_to_mut(task, rep); + + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_add_tag(task, tc_string_borrow("next"))); + + TCStringList tags = tc_task_get_tags(task); + + int found_pending = false, found_next = false; + for (size_t i = 0; i < tags.len; i++) { + if (strcmp("PENDING", tc_string_content(&tags.items[i])) == 0) { + found_pending = true; + } + if (strcmp("next", tc_string_content(&tags.items[i])) == 0) { + found_next = true; + } + } + TEST_ASSERT_TRUE(found_pending); + TEST_ASSERT_TRUE(found_next); + + tc_string_list_free(&tags); + TEST_ASSERT_NULL(tags.items); + + tc_task_free(task); + tc_replica_free(rep); +} + +// annotation manipulation (add, remove, list, free) +static void test_task_annotations(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + TCTask *task = tc_replica_new_task( + rep, + TC_STATUS_PENDING, + tc_string_borrow("my task")); + TEST_ASSERT_NOT_NULL(task); + + TCAnnotationList anns = tc_task_get_annotations(task); + TEST_ASSERT_EQUAL(0, anns.len); + TEST_ASSERT_NOT_NULL(anns.items); + tc_annotation_list_free(&anns); + + tc_task_to_mut(task, rep); + + TCAnnotation ann; + + ann.entry = 1644623411; + ann.description = tc_string_borrow("ann1"); + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_add_annotation(task, &ann)); + TEST_ASSERT_NULL(ann.description.ptr); + + ann.entry = 1644623422; + ann.description = tc_string_borrow("ann2"); + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_add_annotation(task, &ann)); + TEST_ASSERT_NULL(ann.description.ptr); + + anns = tc_task_get_annotations(task); + + int found1 = false, found2 = false; + for (size_t i = 0; i < anns.len; i++) { + if (0 == strcmp("ann1", tc_string_content(&anns.items[i].description))) { + TEST_ASSERT_EQUAL(anns.items[i].entry, 1644623411); + found1 = true; + } + if (0 == strcmp("ann2", tc_string_content(&anns.items[i].description))) { + TEST_ASSERT_EQUAL(anns.items[i].entry, 1644623422); + found2 = true; + } + } + TEST_ASSERT_TRUE(found1); + TEST_ASSERT_TRUE(found2); + + tc_annotation_list_free(&anns); + TEST_ASSERT_NULL(anns.items); + + tc_task_free(task); + tc_replica_free(rep); +} + +// UDA manipulation (add, remove, list, free) +static void test_task_udas(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + TCTask *task = tc_replica_new_task( + rep, + TC_STATUS_PENDING, + tc_string_borrow("my task")); + TEST_ASSERT_NOT_NULL(task); + + tc_task_to_mut(task, rep); + + TCString value; + TCUdaList udas; + + TEST_ASSERT_NULL(tc_task_get_uda(task, tc_string_borrow("ns"), tc_string_borrow("u1")).ptr); + TEST_ASSERT_NULL(tc_task_get_legacy_uda(task, tc_string_borrow("leg1")).ptr); + + udas = tc_task_get_udas(task); + TEST_ASSERT_NOT_NULL(udas.items); + TEST_ASSERT_EQUAL(0, udas.len); + tc_uda_list_free(&udas); + + udas = tc_task_get_legacy_udas(task); + TEST_ASSERT_NOT_NULL(udas.items); + TEST_ASSERT_EQUAL(0, udas.len); + tc_uda_list_free(&udas); + + TEST_ASSERT_EQUAL(TC_RESULT_OK, + tc_task_set_uda(task, + tc_string_borrow("ns"), + tc_string_borrow("u1"), + tc_string_borrow("vvv"))); + + value = tc_task_get_uda(task, tc_string_borrow("ns"), tc_string_borrow("u1")); + TEST_ASSERT_NOT_NULL(value.ptr); + TEST_ASSERT_EQUAL_STRING("vvv", tc_string_content(&value)); + tc_string_free(&value); + TEST_ASSERT_NULL(tc_task_get_legacy_uda(task, tc_string_borrow("leg1")).ptr); + + udas = tc_task_get_udas(task); + TEST_ASSERT_NOT_NULL(udas.items); + TEST_ASSERT_EQUAL(1, udas.len); + TEST_ASSERT_EQUAL_STRING("ns", tc_string_content(&udas.items[0].ns)); + TEST_ASSERT_EQUAL_STRING("u1", tc_string_content(&udas.items[0].key)); + TEST_ASSERT_EQUAL_STRING("vvv", tc_string_content(&udas.items[0].value)); + tc_uda_list_free(&udas); + + udas = tc_task_get_legacy_udas(task); + TEST_ASSERT_NOT_NULL(udas.items); + TEST_ASSERT_EQUAL(1, udas.len); + TEST_ASSERT_NULL(udas.items[0].ns.ptr); + TEST_ASSERT_EQUAL_STRING("ns.u1", tc_string_content(&udas.items[0].key)); + TEST_ASSERT_EQUAL_STRING("vvv", tc_string_content(&udas.items[0].value)); + tc_uda_list_free(&udas); + + TEST_ASSERT_EQUAL(TC_RESULT_OK, + tc_task_set_legacy_uda(task, + tc_string_borrow("leg1"), + tc_string_borrow("legv"))); + + value = tc_task_get_uda(task, tc_string_borrow("ns"), tc_string_borrow("u1")); + TEST_ASSERT_NOT_NULL(value.ptr); + TEST_ASSERT_EQUAL_STRING("vvv", tc_string_content(&value)); + tc_string_free(&value); + + value = tc_task_get_legacy_uda(task, tc_string_borrow("leg1")); + TEST_ASSERT_NOT_NULL(value.ptr); + TEST_ASSERT_EQUAL_STRING("legv", tc_string_content(&value)); + tc_string_free(&value); + + udas = tc_task_get_udas(task); + TEST_ASSERT_NOT_NULL(udas.items); + TEST_ASSERT_EQUAL(2, udas.len); + tc_uda_list_free(&udas); + + udas = tc_task_get_legacy_udas(task); + TEST_ASSERT_NOT_NULL(udas.items); + TEST_ASSERT_EQUAL(2, udas.len); + tc_uda_list_free(&udas); + + TEST_ASSERT_EQUAL(TC_RESULT_OK, + tc_task_remove_uda(task, + tc_string_borrow("ns"), + tc_string_borrow("u1"))); + + TEST_ASSERT_NULL(tc_task_get_uda(task, tc_string_borrow("ns"), tc_string_borrow("u1")).ptr); + + TEST_ASSERT_EQUAL(TC_RESULT_OK, + tc_task_remove_uda(task, + tc_string_borrow("ns"), + tc_string_borrow("u1"))); + + udas = tc_task_get_udas(task); + TEST_ASSERT_NOT_NULL(udas.items); + TEST_ASSERT_EQUAL(1, udas.len); + TEST_ASSERT_EQUAL_STRING("", tc_string_content(&udas.items[0].ns)); + TEST_ASSERT_EQUAL_STRING("leg1", tc_string_content(&udas.items[0].key)); + TEST_ASSERT_EQUAL_STRING("legv", tc_string_content(&udas.items[0].value)); + tc_uda_list_free(&udas); + + udas = tc_task_get_legacy_udas(task); + TEST_ASSERT_NOT_NULL(udas.items); + TEST_ASSERT_EQUAL(1, udas.len); + TEST_ASSERT_NULL(udas.items[0].ns.ptr); + TEST_ASSERT_EQUAL_STRING("leg1", tc_string_content(&udas.items[0].key)); + TEST_ASSERT_EQUAL_STRING("legv", tc_string_content(&udas.items[0].value)); + tc_uda_list_free(&udas); + + TEST_ASSERT_EQUAL(TC_RESULT_OK, + tc_task_remove_legacy_uda(task, + tc_string_borrow("leg1"))); + + TEST_ASSERT_NULL(tc_task_get_legacy_uda(task, tc_string_borrow("leg1")).ptr); + + TEST_ASSERT_EQUAL(TC_RESULT_OK, + tc_task_remove_legacy_uda(task, + tc_string_borrow("leg1"))); + + udas = tc_task_get_udas(task); + TEST_ASSERT_NOT_NULL(udas.items); + TEST_ASSERT_EQUAL(0, udas.len); + tc_uda_list_free(&udas); + + udas = tc_task_get_legacy_udas(task); + TEST_ASSERT_NOT_NULL(udas.items); + TEST_ASSERT_EQUAL(0, udas.len); + tc_uda_list_free(&udas); + + tc_task_free(task); + tc_replica_free(rep); +} + +// dependency manipulation +static void test_task_dependencies(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + TCTask *task1 = tc_replica_new_task(rep, TC_STATUS_PENDING, tc_string_borrow("task 1")); + TEST_ASSERT_NOT_NULL(task1); + TCTask *task2 = tc_replica_new_task(rep, TC_STATUS_PENDING, tc_string_borrow("task 2")); + TEST_ASSERT_NOT_NULL(task2); + + TCUuidList deps; + + deps = tc_task_get_dependencies(task1); + TEST_ASSERT_EQUAL(0, deps.len); + tc_uuid_list_free(&deps); + + tc_task_to_mut(task1, rep); + TEST_ASSERT_EQUAL(TC_RESULT_OK, + tc_task_add_dependency(task1, tc_task_get_uuid(task2))); + + deps = tc_task_get_dependencies(task1); + TEST_ASSERT_EQUAL(1, deps.len); + TEST_ASSERT_EQUAL_MEMORY(tc_task_get_uuid(task2).bytes, deps.items[0].bytes, 16); + tc_uuid_list_free(&deps); + + TEST_ASSERT_EQUAL(TC_RESULT_OK, + tc_task_remove_dependency(task1, tc_task_get_uuid(task2))); + + deps = tc_task_get_dependencies(task1); + TEST_ASSERT_EQUAL(0, deps.len); + tc_uuid_list_free(&deps); + + tc_task_free(task1); + tc_task_free(task2); + tc_replica_free(rep); +} + +static void tckvlist_assert_key(TCKVList *list, char *key, char *value) { + TEST_ASSERT_NOT_NULL(list); + for (size_t i = 0; i < list->len; i++) { + if (0 == strcmp(tc_string_content(&list->items[i].key), key)) { + TEST_ASSERT_EQUAL_STRING(value, tc_string_content(&list->items[i].value)); + return; + } + } + TEST_FAIL_MESSAGE("key not found"); +} + +// get_tags returns the list of tags +static void test_task_taskmap(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + TCTask *task = tc_replica_new_task(rep, TC_STATUS_PENDING, tc_string_borrow("my task")); + TEST_ASSERT_NOT_NULL(task); + + tc_task_to_mut(task, rep); + + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_add_tag(task, tc_string_borrow("next"))); + + TCAnnotation ann; + ann.entry = 1644623411; + ann.description = tc_string_borrow("ann1"); + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_add_annotation(task, &ann)); + + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_task_set_wait(task, 3643679997)); // 2085 + + TCKVList taskmap = tc_task_get_taskmap(task); + tckvlist_assert_key(&taskmap, "annotation_1644623411", "ann1"); + tckvlist_assert_key(&taskmap, "tag_next", ""); + tckvlist_assert_key(&taskmap, "status", "pending"); + tckvlist_assert_key(&taskmap, "description", "my task"); + tc_kv_list_free(&taskmap); + + tc_task_free(task); + tc_replica_free(rep); +} + +// taking from a task list behaves correctly +static void test_task_list_take(void) { + TCReplica *rep = tc_replica_new_in_memory(); + TEST_ASSERT_NULL(tc_replica_error(rep).ptr); + + TCTask *task1 = tc_replica_new_task(rep, TC_STATUS_PENDING, tc_string_borrow("t")); + TEST_ASSERT_NOT_NULL(task1); + + TCTask *task2 = tc_replica_new_task(rep, TC_STATUS_PENDING, tc_string_borrow("t")); + TEST_ASSERT_NOT_NULL(task2); + tc_task_free(task2); + + TCString desc; + TCTaskList tasks = tc_replica_all_tasks(rep); + TEST_ASSERT_NOT_NULL(tasks.items); + TEST_ASSERT_EQUAL(2, tasks.len); + + task1 = tc_task_list_take(&tasks, 5); // out of bounds + TEST_ASSERT_NULL(task1); + + task1 = tc_task_list_take(&tasks, 0); + TEST_ASSERT_NOT_NULL(task1); + desc = tc_task_get_description(task1); + TEST_ASSERT_EQUAL_STRING("t", tc_string_content(&desc)); + tc_string_free(&desc); + + task2 = tc_task_list_take(&tasks, 1); + TEST_ASSERT_NOT_NULL(task2); + desc = tc_task_get_description(task2); + TEST_ASSERT_EQUAL_STRING("t", tc_string_content(&desc)); + tc_string_free(&desc); + + tc_task_free(task1); + tc_task_free(task2); + + task1 = tc_task_list_take(&tasks, 0); // already taken + TEST_ASSERT_NULL(task1); + + task1 = tc_task_list_take(&tasks, 5); // out of bounds + TEST_ASSERT_NULL(task1); + + tc_task_list_free(&tasks); + TEST_ASSERT_NULL(tasks.items); + + tc_replica_free(rep); +} + +int task_tests(void) { + UNITY_BEGIN(); + // each test case above should be named here, in order. + RUN_TEST(test_task_creation); + RUN_TEST(test_task_free_mutable_task); + RUN_TEST(test_task_get_set_status); + RUN_TEST(test_task_get_set_description); + RUN_TEST(test_task_get_set_entry); + RUN_TEST(test_task_get_set_modified); + RUN_TEST(test_task_get_set_wait_and_is_waiting); + RUN_TEST(test_task_start_stop_is_active); + RUN_TEST(test_task_done_and_delete); + RUN_TEST(test_task_add_remove_has_tag); + RUN_TEST(test_task_get_tags); + RUN_TEST(test_task_annotations); + RUN_TEST(test_task_udas); + RUN_TEST(test_task_dependencies); + RUN_TEST(test_task_taskmap); + RUN_TEST(test_task_list_take); + return UNITY_END(); +} diff --git a/rust/integration-tests/src/bindings_tests/test.c b/rust/integration-tests/src/bindings_tests/test.c new file mode 100644 index 000000000..5afa236ca --- /dev/null +++ b/rust/integration-tests/src/bindings_tests/test.c @@ -0,0 +1,30 @@ +#include +#include "unity.h" + +// these functions are shared between all test "suites" +// and cannot be customized per-suite. +void setUp(void) { } +void tearDown(void) { } + +static FILE *output = NULL; + +// Set up for test_output, writing output to "TEST-OUTPUT" in the +// current directory. The Rust test harness reads this file to get +// the output and display it only on failure. This is called by +// the Rust test harness +void setup_output(void) { + output = fopen("TEST-OUTPUT", "w"); +} + +// Close the output file. Called by the Rust test harness. +void finish_output(void) { + fclose(output); + output = NULL; +} + +// this replaces UNITY_OUTPUT_CHAR, and writes output to +// TEST-OUTPUT in the current directory; the Rust test harness +// will read this data if the test fails. +void test_output(char c) { + fputc(c, output); +} diff --git a/rust/integration-tests/src/bindings_tests/unity/LICENSE.txt b/rust/integration-tests/src/bindings_tests/unity/LICENSE.txt new file mode 100644 index 000000000..b9a329dde --- /dev/null +++ b/rust/integration-tests/src/bindings_tests/unity/LICENSE.txt @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2007-21 Mike Karlesky, Mark VanderVoord, Greg Williams + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/rust/integration-tests/src/bindings_tests/unity/README.md b/rust/integration-tests/src/bindings_tests/unity/README.md new file mode 100644 index 000000000..6f755ced0 --- /dev/null +++ b/rust/integration-tests/src/bindings_tests/unity/README.md @@ -0,0 +1,3 @@ +# Unity + +This directory contains the src from https://github.com/ThrowTheSwitch/Unity, revision 8ba01386008196a92ef4fdbdb0b00f2434c79563. diff --git a/rust/integration-tests/src/bindings_tests/unity/unity.c b/rust/integration-tests/src/bindings_tests/unity/unity.c new file mode 100644 index 000000000..b88024875 --- /dev/null +++ b/rust/integration-tests/src/bindings_tests/unity/unity.c @@ -0,0 +1,2119 @@ +/* ========================================================================= + Unity Project - A Test Framework for C + Copyright (c) 2007-21 Mike Karlesky, Mark VanderVoord, Greg Williams + [Released under MIT License. Please refer to license.txt for details] +============================================================================ */ + +#include "unity.h" +#include + +#ifdef AVR +#include +#else +#define PROGMEM +#endif + +/* If omitted from header, declare overrideable prototypes here so they're ready for use */ +#ifdef UNITY_OMIT_OUTPUT_CHAR_HEADER_DECLARATION +void UNITY_OUTPUT_CHAR(int); +#endif + +/* Helpful macros for us to use here in Assert functions */ +#define UNITY_FAIL_AND_BAIL do { Unity.CurrentTestFailed = 1; UNITY_OUTPUT_FLUSH(); TEST_ABORT(); } while (0) +#define UNITY_IGNORE_AND_BAIL do { Unity.CurrentTestIgnored = 1; UNITY_OUTPUT_FLUSH(); TEST_ABORT(); } while (0) +#define RETURN_IF_FAIL_OR_IGNORE do { if (Unity.CurrentTestFailed || Unity.CurrentTestIgnored) { TEST_ABORT(); } } while (0) + +struct UNITY_STORAGE_T Unity; + +#ifdef UNITY_OUTPUT_COLOR +const char PROGMEM UnityStrOk[] = "\033[42mOK\033[00m"; +const char PROGMEM UnityStrPass[] = "\033[42mPASS\033[00m"; +const char PROGMEM UnityStrFail[] = "\033[41mFAIL\033[00m"; +const char PROGMEM UnityStrIgnore[] = "\033[43mIGNORE\033[00m"; +#else +const char PROGMEM UnityStrOk[] = "OK"; +const char PROGMEM UnityStrPass[] = "PASS"; +const char PROGMEM UnityStrFail[] = "FAIL"; +const char PROGMEM UnityStrIgnore[] = "IGNORE"; +#endif +static const char PROGMEM UnityStrNull[] = "NULL"; +static const char PROGMEM UnityStrSpacer[] = ". "; +static const char PROGMEM UnityStrExpected[] = " Expected "; +static const char PROGMEM UnityStrWas[] = " Was "; +static const char PROGMEM UnityStrGt[] = " to be greater than "; +static const char PROGMEM UnityStrLt[] = " to be less than "; +static const char PROGMEM UnityStrOrEqual[] = "or equal to "; +static const char PROGMEM UnityStrNotEqual[] = " to be not equal to "; +static const char PROGMEM UnityStrElement[] = " Element "; +static const char PROGMEM UnityStrByte[] = " Byte "; +static const char PROGMEM UnityStrMemory[] = " Memory Mismatch."; +static const char PROGMEM UnityStrDelta[] = " Values Not Within Delta "; +static const char PROGMEM UnityStrPointless[] = " You Asked Me To Compare Nothing, Which Was Pointless."; +static const char PROGMEM UnityStrNullPointerForExpected[] = " Expected pointer to be NULL"; +static const char PROGMEM UnityStrNullPointerForActual[] = " Actual pointer was NULL"; +#ifndef UNITY_EXCLUDE_FLOAT +static const char PROGMEM UnityStrNot[] = "Not "; +static const char PROGMEM UnityStrInf[] = "Infinity"; +static const char PROGMEM UnityStrNegInf[] = "Negative Infinity"; +static const char PROGMEM UnityStrNaN[] = "NaN"; +static const char PROGMEM UnityStrDet[] = "Determinate"; +static const char PROGMEM UnityStrInvalidFloatTrait[] = "Invalid Float Trait"; +#endif +const char PROGMEM UnityStrErrShorthand[] = "Unity Shorthand Support Disabled"; +const char PROGMEM UnityStrErrFloat[] = "Unity Floating Point Disabled"; +const char PROGMEM UnityStrErrDouble[] = "Unity Double Precision Disabled"; +const char PROGMEM UnityStrErr64[] = "Unity 64-bit Support Disabled"; +static const char PROGMEM UnityStrBreaker[] = "-----------------------"; +static const char PROGMEM UnityStrResultsTests[] = " Tests "; +static const char PROGMEM UnityStrResultsFailures[] = " Failures "; +static const char PROGMEM UnityStrResultsIgnored[] = " Ignored "; +#ifndef UNITY_EXCLUDE_DETAILS +static const char PROGMEM UnityStrDetail1Name[] = UNITY_DETAIL1_NAME " "; +static const char PROGMEM UnityStrDetail2Name[] = " " UNITY_DETAIL2_NAME " "; +#endif +/*----------------------------------------------- + * Pretty Printers & Test Result Output Handlers + *-----------------------------------------------*/ + +/*-----------------------------------------------*/ +/* Local helper function to print characters. */ +static void UnityPrintChar(const char* pch) +{ + /* printable characters plus CR & LF are printed */ + if ((*pch <= 126) && (*pch >= 32)) + { + UNITY_OUTPUT_CHAR(*pch); + } + /* write escaped carriage returns */ + else if (*pch == 13) + { + UNITY_OUTPUT_CHAR('\\'); + UNITY_OUTPUT_CHAR('r'); + } + /* write escaped line feeds */ + else if (*pch == 10) + { + UNITY_OUTPUT_CHAR('\\'); + UNITY_OUTPUT_CHAR('n'); + } + /* unprintable characters are shown as codes */ + else + { + UNITY_OUTPUT_CHAR('\\'); + UNITY_OUTPUT_CHAR('x'); + UnityPrintNumberHex((UNITY_UINT)*pch, 2); + } +} + +/*-----------------------------------------------*/ +/* Local helper function to print ANSI escape strings e.g. "\033[42m". */ +#ifdef UNITY_OUTPUT_COLOR +static UNITY_UINT UnityPrintAnsiEscapeString(const char* string) +{ + const char* pch = string; + UNITY_UINT count = 0; + + while (*pch && (*pch != 'm')) + { + UNITY_OUTPUT_CHAR(*pch); + pch++; + count++; + } + UNITY_OUTPUT_CHAR('m'); + count++; + + return count; +} +#endif + +/*-----------------------------------------------*/ +void UnityPrint(const char* string) +{ + const char* pch = string; + + if (pch != NULL) + { + while (*pch) + { +#ifdef UNITY_OUTPUT_COLOR + /* print ANSI escape code */ + if ((*pch == 27) && (*(pch + 1) == '[')) + { + pch += UnityPrintAnsiEscapeString(pch); + continue; + } +#endif + UnityPrintChar(pch); + pch++; + } + } +} +/*-----------------------------------------------*/ +void UnityPrintLen(const char* string, const UNITY_UINT32 length) +{ + const char* pch = string; + + if (pch != NULL) + { + while (*pch && ((UNITY_UINT32)(pch - string) < length)) + { + /* printable characters plus CR & LF are printed */ + if ((*pch <= 126) && (*pch >= 32)) + { + UNITY_OUTPUT_CHAR(*pch); + } + /* write escaped carriage returns */ + else if (*pch == 13) + { + UNITY_OUTPUT_CHAR('\\'); + UNITY_OUTPUT_CHAR('r'); + } + /* write escaped line feeds */ + else if (*pch == 10) + { + UNITY_OUTPUT_CHAR('\\'); + UNITY_OUTPUT_CHAR('n'); + } + /* unprintable characters are shown as codes */ + else + { + UNITY_OUTPUT_CHAR('\\'); + UNITY_OUTPUT_CHAR('x'); + UnityPrintNumberHex((UNITY_UINT)*pch, 2); + } + pch++; + } + } +} + +/*-----------------------------------------------*/ +void UnityPrintNumberByStyle(const UNITY_INT number, const UNITY_DISPLAY_STYLE_T style) +{ + if ((style & UNITY_DISPLAY_RANGE_INT) == UNITY_DISPLAY_RANGE_INT) + { + if (style == UNITY_DISPLAY_STYLE_CHAR) + { + /* printable characters plus CR & LF are printed */ + UNITY_OUTPUT_CHAR('\''); + if ((number <= 126) && (number >= 32)) + { + UNITY_OUTPUT_CHAR((int)number); + } + /* write escaped carriage returns */ + else if (number == 13) + { + UNITY_OUTPUT_CHAR('\\'); + UNITY_OUTPUT_CHAR('r'); + } + /* write escaped line feeds */ + else if (number == 10) + { + UNITY_OUTPUT_CHAR('\\'); + UNITY_OUTPUT_CHAR('n'); + } + /* unprintable characters are shown as codes */ + else + { + UNITY_OUTPUT_CHAR('\\'); + UNITY_OUTPUT_CHAR('x'); + UnityPrintNumberHex((UNITY_UINT)number, 2); + } + UNITY_OUTPUT_CHAR('\''); + } + else + { + UnityPrintNumber(number); + } + } + else if ((style & UNITY_DISPLAY_RANGE_UINT) == UNITY_DISPLAY_RANGE_UINT) + { + UnityPrintNumberUnsigned((UNITY_UINT)number); + } + else + { + UNITY_OUTPUT_CHAR('0'); + UNITY_OUTPUT_CHAR('x'); + UnityPrintNumberHex((UNITY_UINT)number, (char)((style & 0xF) * 2)); + } +} + +/*-----------------------------------------------*/ +void UnityPrintNumber(const UNITY_INT number_to_print) +{ + UNITY_UINT number = (UNITY_UINT)number_to_print; + + if (number_to_print < 0) + { + /* A negative number, including MIN negative */ + UNITY_OUTPUT_CHAR('-'); + number = (~number) + 1; + } + UnityPrintNumberUnsigned(number); +} + +/*----------------------------------------------- + * basically do an itoa using as little ram as possible */ +void UnityPrintNumberUnsigned(const UNITY_UINT number) +{ + UNITY_UINT divisor = 1; + + /* figure out initial divisor */ + while (number / divisor > 9) + { + divisor *= 10; + } + + /* now mod and print, then divide divisor */ + do + { + UNITY_OUTPUT_CHAR((char)('0' + (number / divisor % 10))); + divisor /= 10; + } while (divisor > 0); +} + +/*-----------------------------------------------*/ +void UnityPrintNumberHex(const UNITY_UINT number, const char nibbles_to_print) +{ + int nibble; + char nibbles = nibbles_to_print; + + if ((unsigned)nibbles > UNITY_MAX_NIBBLES) + { + nibbles = UNITY_MAX_NIBBLES; + } + + while (nibbles > 0) + { + nibbles--; + nibble = (int)(number >> (nibbles * 4)) & 0x0F; + if (nibble <= 9) + { + UNITY_OUTPUT_CHAR((char)('0' + nibble)); + } + else + { + UNITY_OUTPUT_CHAR((char)('A' - 10 + nibble)); + } + } +} + +/*-----------------------------------------------*/ +void UnityPrintMask(const UNITY_UINT mask, const UNITY_UINT number) +{ + UNITY_UINT current_bit = (UNITY_UINT)1 << (UNITY_INT_WIDTH - 1); + UNITY_INT32 i; + + for (i = 0; i < UNITY_INT_WIDTH; i++) + { + if (current_bit & mask) + { + if (current_bit & number) + { + UNITY_OUTPUT_CHAR('1'); + } + else + { + UNITY_OUTPUT_CHAR('0'); + } + } + else + { + UNITY_OUTPUT_CHAR('X'); + } + current_bit = current_bit >> 1; + } +} + +/*-----------------------------------------------*/ +#ifndef UNITY_EXCLUDE_FLOAT_PRINT +/* + * This function prints a floating-point value in a format similar to + * printf("%.7g") on a single-precision machine or printf("%.9g") on a + * double-precision machine. The 7th digit won't always be totally correct + * in single-precision operation (for that level of accuracy, a more + * complicated algorithm would be needed). + */ +void UnityPrintFloat(const UNITY_DOUBLE input_number) +{ +#ifdef UNITY_INCLUDE_DOUBLE + static const int sig_digits = 9; + static const UNITY_INT32 min_scaled = 100000000; + static const UNITY_INT32 max_scaled = 1000000000; +#else + static const int sig_digits = 7; + static const UNITY_INT32 min_scaled = 1000000; + static const UNITY_INT32 max_scaled = 10000000; +#endif + + UNITY_DOUBLE number = input_number; + + /* print minus sign (does not handle negative zero) */ + if (number < 0.0f) + { + UNITY_OUTPUT_CHAR('-'); + number = -number; + } + + /* handle zero, NaN, and +/- infinity */ + if (number == 0.0f) + { + UnityPrint("0"); + } + else if (isnan(number)) + { + UnityPrint("nan"); + } + else if (isinf(number)) + { + UnityPrint("inf"); + } + else + { + UNITY_INT32 n_int = 0; + UNITY_INT32 n; + int exponent = 0; + int decimals; + int digits; + char buf[16] = {0}; + + /* + * Scale up or down by powers of 10. To minimize rounding error, + * start with a factor/divisor of 10^10, which is the largest + * power of 10 that can be represented exactly. Finally, compute + * (exactly) the remaining power of 10 and perform one more + * multiplication or division. + */ + if (number < 1.0f) + { + UNITY_DOUBLE factor = 1.0f; + + while (number < (UNITY_DOUBLE)max_scaled / 1e10f) { number *= 1e10f; exponent -= 10; } + while (number * factor < (UNITY_DOUBLE)min_scaled) { factor *= 10.0f; exponent--; } + + number *= factor; + } + else if (number > (UNITY_DOUBLE)max_scaled) + { + UNITY_DOUBLE divisor = 1.0f; + + while (number > (UNITY_DOUBLE)min_scaled * 1e10f) { number /= 1e10f; exponent += 10; } + while (number / divisor > (UNITY_DOUBLE)max_scaled) { divisor *= 10.0f; exponent++; } + + number /= divisor; + } + else + { + /* + * In this range, we can split off the integer part before + * doing any multiplications. This reduces rounding error by + * freeing up significant bits in the fractional part. + */ + UNITY_DOUBLE factor = 1.0f; + n_int = (UNITY_INT32)number; + number -= (UNITY_DOUBLE)n_int; + + while (n_int < min_scaled) { n_int *= 10; factor *= 10.0f; exponent--; } + + number *= factor; + } + + /* round to nearest integer */ + n = ((UNITY_INT32)(number + number) + 1) / 2; + +#ifndef UNITY_ROUND_TIES_AWAY_FROM_ZERO + /* round to even if exactly between two integers */ + if ((n & 1) && (((UNITY_DOUBLE)n - number) == 0.5f)) + n--; +#endif + + n += n_int; + + if (n >= max_scaled) + { + n = min_scaled; + exponent++; + } + + /* determine where to place decimal point */ + decimals = ((exponent <= 0) && (exponent >= -(sig_digits + 3))) ? (-exponent) : (sig_digits - 1); + exponent += decimals; + + /* truncate trailing zeroes after decimal point */ + while ((decimals > 0) && ((n % 10) == 0)) + { + n /= 10; + decimals--; + } + + /* build up buffer in reverse order */ + digits = 0; + while ((n != 0) || (digits <= decimals)) + { + buf[digits++] = (char)('0' + n % 10); + n /= 10; + } + + /* print out buffer (backwards) */ + while (digits > 0) + { + if (digits == decimals) + { + UNITY_OUTPUT_CHAR('.'); + } + UNITY_OUTPUT_CHAR(buf[--digits]); + } + + /* print exponent if needed */ + if (exponent != 0) + { + UNITY_OUTPUT_CHAR('e'); + + if (exponent < 0) + { + UNITY_OUTPUT_CHAR('-'); + exponent = -exponent; + } + else + { + UNITY_OUTPUT_CHAR('+'); + } + + digits = 0; + while ((exponent != 0) || (digits < 2)) + { + buf[digits++] = (char)('0' + exponent % 10); + exponent /= 10; + } + while (digits > 0) + { + UNITY_OUTPUT_CHAR(buf[--digits]); + } + } + } +} +#endif /* ! UNITY_EXCLUDE_FLOAT_PRINT */ + +/*-----------------------------------------------*/ +static void UnityTestResultsBegin(const char* file, const UNITY_LINE_TYPE line) +{ +#ifdef UNITY_OUTPUT_FOR_ECLIPSE + UNITY_OUTPUT_CHAR('('); + UnityPrint(file); + UNITY_OUTPUT_CHAR(':'); + UnityPrintNumber((UNITY_INT)line); + UNITY_OUTPUT_CHAR(')'); + UNITY_OUTPUT_CHAR(' '); + UnityPrint(Unity.CurrentTestName); + UNITY_OUTPUT_CHAR(':'); +#else +#ifdef UNITY_OUTPUT_FOR_IAR_WORKBENCH + UnityPrint("'); + UnityPrint(Unity.CurrentTestName); + UnityPrint(" "); +#else +#ifdef UNITY_OUTPUT_FOR_QT_CREATOR + UnityPrint("file://"); + UnityPrint(file); + UNITY_OUTPUT_CHAR(':'); + UnityPrintNumber((UNITY_INT)line); + UNITY_OUTPUT_CHAR(' '); + UnityPrint(Unity.CurrentTestName); + UNITY_OUTPUT_CHAR(':'); +#else + UnityPrint(file); + UNITY_OUTPUT_CHAR(':'); + UnityPrintNumber((UNITY_INT)line); + UNITY_OUTPUT_CHAR(':'); + UnityPrint(Unity.CurrentTestName); + UNITY_OUTPUT_CHAR(':'); +#endif +#endif +#endif +} + +/*-----------------------------------------------*/ +static void UnityTestResultsFailBegin(const UNITY_LINE_TYPE line) +{ + UnityTestResultsBegin(Unity.TestFile, line); + UnityPrint(UnityStrFail); + UNITY_OUTPUT_CHAR(':'); +} + +/*-----------------------------------------------*/ +void UnityConcludeTest(void) +{ + if (Unity.CurrentTestIgnored) + { + Unity.TestIgnores++; + } + else if (!Unity.CurrentTestFailed) + { + UnityTestResultsBegin(Unity.TestFile, Unity.CurrentTestLineNumber); + UnityPrint(UnityStrPass); + } + else + { + Unity.TestFailures++; + } + + Unity.CurrentTestFailed = 0; + Unity.CurrentTestIgnored = 0; + UNITY_PRINT_EXEC_TIME(); + UNITY_PRINT_EOL(); + UNITY_FLUSH_CALL(); +} + +/*-----------------------------------------------*/ +static void UnityAddMsgIfSpecified(const char* msg) +{ + if (msg) + { + UnityPrint(UnityStrSpacer); + +#ifdef UNITY_PRINT_TEST_CONTEXT + UNITY_PRINT_TEST_CONTEXT(); +#endif +#ifndef UNITY_EXCLUDE_DETAILS + if (Unity.CurrentDetail1) + { + UnityPrint(UnityStrDetail1Name); + UnityPrint(Unity.CurrentDetail1); + if (Unity.CurrentDetail2) + { + UnityPrint(UnityStrDetail2Name); + UnityPrint(Unity.CurrentDetail2); + } + UnityPrint(UnityStrSpacer); + } +#endif + UnityPrint(msg); + } +} + +/*-----------------------------------------------*/ +static void UnityPrintExpectedAndActualStrings(const char* expected, const char* actual) +{ + UnityPrint(UnityStrExpected); + if (expected != NULL) + { + UNITY_OUTPUT_CHAR('\''); + UnityPrint(expected); + UNITY_OUTPUT_CHAR('\''); + } + else + { + UnityPrint(UnityStrNull); + } + UnityPrint(UnityStrWas); + if (actual != NULL) + { + UNITY_OUTPUT_CHAR('\''); + UnityPrint(actual); + UNITY_OUTPUT_CHAR('\''); + } + else + { + UnityPrint(UnityStrNull); + } +} + +/*-----------------------------------------------*/ +static void UnityPrintExpectedAndActualStringsLen(const char* expected, + const char* actual, + const UNITY_UINT32 length) +{ + UnityPrint(UnityStrExpected); + if (expected != NULL) + { + UNITY_OUTPUT_CHAR('\''); + UnityPrintLen(expected, length); + UNITY_OUTPUT_CHAR('\''); + } + else + { + UnityPrint(UnityStrNull); + } + UnityPrint(UnityStrWas); + if (actual != NULL) + { + UNITY_OUTPUT_CHAR('\''); + UnityPrintLen(actual, length); + UNITY_OUTPUT_CHAR('\''); + } + else + { + UnityPrint(UnityStrNull); + } +} + +/*----------------------------------------------- + * Assertion & Control Helpers + *-----------------------------------------------*/ + +/*-----------------------------------------------*/ +static int UnityIsOneArrayNull(UNITY_INTERNAL_PTR expected, + UNITY_INTERNAL_PTR actual, + const UNITY_LINE_TYPE lineNumber, + const char* msg) +{ + /* Both are NULL or same pointer */ + if (expected == actual) { return 0; } + + /* print and return true if just expected is NULL */ + if (expected == NULL) + { + UnityTestResultsFailBegin(lineNumber); + UnityPrint(UnityStrNullPointerForExpected); + UnityAddMsgIfSpecified(msg); + return 1; + } + + /* print and return true if just actual is NULL */ + if (actual == NULL) + { + UnityTestResultsFailBegin(lineNumber); + UnityPrint(UnityStrNullPointerForActual); + UnityAddMsgIfSpecified(msg); + return 1; + } + + return 0; /* return false if neither is NULL */ +} + +/*----------------------------------------------- + * Assertion Functions + *-----------------------------------------------*/ + +/*-----------------------------------------------*/ +void UnityAssertBits(const UNITY_INT mask, + const UNITY_INT expected, + const UNITY_INT actual, + const char* msg, + const UNITY_LINE_TYPE lineNumber) +{ + RETURN_IF_FAIL_OR_IGNORE; + + if ((mask & expected) != (mask & actual)) + { + UnityTestResultsFailBegin(lineNumber); + UnityPrint(UnityStrExpected); + UnityPrintMask((UNITY_UINT)mask, (UNITY_UINT)expected); + UnityPrint(UnityStrWas); + UnityPrintMask((UNITY_UINT)mask, (UNITY_UINT)actual); + UnityAddMsgIfSpecified(msg); + UNITY_FAIL_AND_BAIL; + } +} + +/*-----------------------------------------------*/ +void UnityAssertEqualNumber(const UNITY_INT expected, + const UNITY_INT actual, + const char* msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_DISPLAY_STYLE_T style) +{ + RETURN_IF_FAIL_OR_IGNORE; + + if (expected != actual) + { + UnityTestResultsFailBegin(lineNumber); + UnityPrint(UnityStrExpected); + UnityPrintNumberByStyle(expected, style); + UnityPrint(UnityStrWas); + UnityPrintNumberByStyle(actual, style); + UnityAddMsgIfSpecified(msg); + UNITY_FAIL_AND_BAIL; + } +} + +/*-----------------------------------------------*/ +void UnityAssertGreaterOrLessOrEqualNumber(const UNITY_INT threshold, + const UNITY_INT actual, + const UNITY_COMPARISON_T compare, + const char *msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_DISPLAY_STYLE_T style) +{ + int failed = 0; + RETURN_IF_FAIL_OR_IGNORE; + + if ((threshold == actual) && (compare & UNITY_EQUAL_TO)) { return; } + if ((threshold == actual)) { failed = 1; } + + if ((style & UNITY_DISPLAY_RANGE_INT) == UNITY_DISPLAY_RANGE_INT) + { + if ((actual > threshold) && (compare & UNITY_SMALLER_THAN)) { failed = 1; } + if ((actual < threshold) && (compare & UNITY_GREATER_THAN)) { failed = 1; } + } + else /* UINT or HEX */ + { + if (((UNITY_UINT)actual > (UNITY_UINT)threshold) && (compare & UNITY_SMALLER_THAN)) { failed = 1; } + if (((UNITY_UINT)actual < (UNITY_UINT)threshold) && (compare & UNITY_GREATER_THAN)) { failed = 1; } + } + + if (failed) + { + UnityTestResultsFailBegin(lineNumber); + UnityPrint(UnityStrExpected); + UnityPrintNumberByStyle(actual, style); + if (compare & UNITY_GREATER_THAN) { UnityPrint(UnityStrGt); } + if (compare & UNITY_SMALLER_THAN) { UnityPrint(UnityStrLt); } + if (compare & UNITY_EQUAL_TO) { UnityPrint(UnityStrOrEqual); } + if (compare == UNITY_NOT_EQUAL) { UnityPrint(UnityStrNotEqual); } + UnityPrintNumberByStyle(threshold, style); + UnityAddMsgIfSpecified(msg); + UNITY_FAIL_AND_BAIL; + } +} + +#define UnityPrintPointlessAndBail() \ +do { \ + UnityTestResultsFailBegin(lineNumber); \ + UnityPrint(UnityStrPointless); \ + UnityAddMsgIfSpecified(msg); \ + UNITY_FAIL_AND_BAIL; \ +} while (0) + +/*-----------------------------------------------*/ +void UnityAssertEqualIntArray(UNITY_INTERNAL_PTR expected, + UNITY_INTERNAL_PTR actual, + const UNITY_UINT32 num_elements, + const char* msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_DISPLAY_STYLE_T style, + const UNITY_FLAGS_T flags) +{ + UNITY_UINT32 elements = num_elements; + unsigned int length = style & 0xF; + unsigned int increment = 0; + + RETURN_IF_FAIL_OR_IGNORE; + + if (num_elements == 0) + { + UnityPrintPointlessAndBail(); + } + + if (expected == actual) + { + return; /* Both are NULL or same pointer */ + } + + if (UnityIsOneArrayNull(expected, actual, lineNumber, msg)) + { + UNITY_FAIL_AND_BAIL; + } + + while ((elements > 0) && (elements--)) + { + UNITY_INT expect_val; + UNITY_INT actual_val; + + switch (length) + { + case 1: + expect_val = *(UNITY_PTR_ATTRIBUTE const UNITY_INT8*)expected; + actual_val = *(UNITY_PTR_ATTRIBUTE const UNITY_INT8*)actual; + increment = sizeof(UNITY_INT8); + break; + + case 2: + expect_val = *(UNITY_PTR_ATTRIBUTE const UNITY_INT16*)expected; + actual_val = *(UNITY_PTR_ATTRIBUTE const UNITY_INT16*)actual; + increment = sizeof(UNITY_INT16); + break; + +#ifdef UNITY_SUPPORT_64 + case 8: + expect_val = *(UNITY_PTR_ATTRIBUTE const UNITY_INT64*)expected; + actual_val = *(UNITY_PTR_ATTRIBUTE const UNITY_INT64*)actual; + increment = sizeof(UNITY_INT64); + break; +#endif + + default: /* default is length 4 bytes */ + case 4: + expect_val = *(UNITY_PTR_ATTRIBUTE const UNITY_INT32*)expected; + actual_val = *(UNITY_PTR_ATTRIBUTE const UNITY_INT32*)actual; + increment = sizeof(UNITY_INT32); + length = 4; + break; + } + + if (expect_val != actual_val) + { + if ((style & UNITY_DISPLAY_RANGE_UINT) && (length < (UNITY_INT_WIDTH / 8))) + { /* For UINT, remove sign extension (padding 1's) from signed type casts above */ + UNITY_INT mask = 1; + mask = (mask << 8 * length) - 1; + expect_val &= mask; + actual_val &= mask; + } + UnityTestResultsFailBegin(lineNumber); + UnityPrint(UnityStrElement); + UnityPrintNumberUnsigned(num_elements - elements - 1); + UnityPrint(UnityStrExpected); + UnityPrintNumberByStyle(expect_val, style); + UnityPrint(UnityStrWas); + UnityPrintNumberByStyle(actual_val, style); + UnityAddMsgIfSpecified(msg); + UNITY_FAIL_AND_BAIL; + } + /* Walk through array by incrementing the pointers */ + if (flags == UNITY_ARRAY_TO_ARRAY) + { + expected = (UNITY_INTERNAL_PTR)((const char*)expected + increment); + } + actual = (UNITY_INTERNAL_PTR)((const char*)actual + increment); + } +} + +/*-----------------------------------------------*/ +#ifndef UNITY_EXCLUDE_FLOAT +/* Wrap this define in a function with variable types as float or double */ +#define UNITY_FLOAT_OR_DOUBLE_WITHIN(delta, expected, actual, diff) \ + if (isinf(expected) && isinf(actual) && (((expected) < 0) == ((actual) < 0))) return 1; \ + if (UNITY_NAN_CHECK) return 1; \ + (diff) = (actual) - (expected); \ + if ((diff) < 0) (diff) = -(diff); \ + if ((delta) < 0) (delta) = -(delta); \ + return !(isnan(diff) || isinf(diff) || ((diff) > (delta))) + /* This first part of this condition will catch any NaN or Infinite values */ +#ifndef UNITY_NAN_NOT_EQUAL_NAN + #define UNITY_NAN_CHECK isnan(expected) && isnan(actual) +#else + #define UNITY_NAN_CHECK 0 +#endif + +#ifndef UNITY_EXCLUDE_FLOAT_PRINT + #define UNITY_PRINT_EXPECTED_AND_ACTUAL_FLOAT(expected, actual) \ + do { \ + UnityPrint(UnityStrExpected); \ + UnityPrintFloat(expected); \ + UnityPrint(UnityStrWas); \ + UnityPrintFloat(actual); \ + } while (0) +#else + #define UNITY_PRINT_EXPECTED_AND_ACTUAL_FLOAT(expected, actual) \ + UnityPrint(UnityStrDelta) +#endif /* UNITY_EXCLUDE_FLOAT_PRINT */ + +/*-----------------------------------------------*/ +static int UnityFloatsWithin(UNITY_FLOAT delta, UNITY_FLOAT expected, UNITY_FLOAT actual) +{ + UNITY_FLOAT diff; + UNITY_FLOAT_OR_DOUBLE_WITHIN(delta, expected, actual, diff); +} + +/*-----------------------------------------------*/ +void UnityAssertEqualFloatArray(UNITY_PTR_ATTRIBUTE const UNITY_FLOAT* expected, + UNITY_PTR_ATTRIBUTE const UNITY_FLOAT* actual, + const UNITY_UINT32 num_elements, + const char* msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_FLAGS_T flags) +{ + UNITY_UINT32 elements = num_elements; + UNITY_PTR_ATTRIBUTE const UNITY_FLOAT* ptr_expected = expected; + UNITY_PTR_ATTRIBUTE const UNITY_FLOAT* ptr_actual = actual; + + RETURN_IF_FAIL_OR_IGNORE; + + if (elements == 0) + { + UnityPrintPointlessAndBail(); + } + + if (expected == actual) + { + return; /* Both are NULL or same pointer */ + } + + if (UnityIsOneArrayNull((UNITY_INTERNAL_PTR)expected, (UNITY_INTERNAL_PTR)actual, lineNumber, msg)) + { + UNITY_FAIL_AND_BAIL; + } + + while (elements--) + { + if (!UnityFloatsWithin(*ptr_expected * UNITY_FLOAT_PRECISION, *ptr_expected, *ptr_actual)) + { + UnityTestResultsFailBegin(lineNumber); + UnityPrint(UnityStrElement); + UnityPrintNumberUnsigned(num_elements - elements - 1); + UNITY_PRINT_EXPECTED_AND_ACTUAL_FLOAT((UNITY_DOUBLE)*ptr_expected, (UNITY_DOUBLE)*ptr_actual); + UnityAddMsgIfSpecified(msg); + UNITY_FAIL_AND_BAIL; + } + if (flags == UNITY_ARRAY_TO_ARRAY) + { + ptr_expected++; + } + ptr_actual++; + } +} + +/*-----------------------------------------------*/ +void UnityAssertFloatsWithin(const UNITY_FLOAT delta, + const UNITY_FLOAT expected, + const UNITY_FLOAT actual, + const char* msg, + const UNITY_LINE_TYPE lineNumber) +{ + RETURN_IF_FAIL_OR_IGNORE; + + + if (!UnityFloatsWithin(delta, expected, actual)) + { + UnityTestResultsFailBegin(lineNumber); + UNITY_PRINT_EXPECTED_AND_ACTUAL_FLOAT((UNITY_DOUBLE)expected, (UNITY_DOUBLE)actual); + UnityAddMsgIfSpecified(msg); + UNITY_FAIL_AND_BAIL; + } +} + +/*-----------------------------------------------*/ +void UnityAssertFloatSpecial(const UNITY_FLOAT actual, + const char* msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_FLOAT_TRAIT_T style) +{ + const char* trait_names[] = {UnityStrInf, UnityStrNegInf, UnityStrNaN, UnityStrDet}; + UNITY_INT should_be_trait = ((UNITY_INT)style & 1); + UNITY_INT is_trait = !should_be_trait; + UNITY_INT trait_index = (UNITY_INT)(style >> 1); + + RETURN_IF_FAIL_OR_IGNORE; + + switch (style) + { + case UNITY_FLOAT_IS_INF: + case UNITY_FLOAT_IS_NOT_INF: + is_trait = isinf(actual) && (actual > 0); + break; + case UNITY_FLOAT_IS_NEG_INF: + case UNITY_FLOAT_IS_NOT_NEG_INF: + is_trait = isinf(actual) && (actual < 0); + break; + + case UNITY_FLOAT_IS_NAN: + case UNITY_FLOAT_IS_NOT_NAN: + is_trait = isnan(actual) ? 1 : 0; + break; + + case UNITY_FLOAT_IS_DET: /* A determinate number is non infinite and not NaN. */ + case UNITY_FLOAT_IS_NOT_DET: + is_trait = !isinf(actual) && !isnan(actual); + break; + + default: /* including UNITY_FLOAT_INVALID_TRAIT */ + trait_index = 0; + trait_names[0] = UnityStrInvalidFloatTrait; + break; + } + + if (is_trait != should_be_trait) + { + UnityTestResultsFailBegin(lineNumber); + UnityPrint(UnityStrExpected); + if (!should_be_trait) + { + UnityPrint(UnityStrNot); + } + UnityPrint(trait_names[trait_index]); + UnityPrint(UnityStrWas); +#ifndef UNITY_EXCLUDE_FLOAT_PRINT + UnityPrintFloat((UNITY_DOUBLE)actual); +#else + if (should_be_trait) + { + UnityPrint(UnityStrNot); + } + UnityPrint(trait_names[trait_index]); +#endif + UnityAddMsgIfSpecified(msg); + UNITY_FAIL_AND_BAIL; + } +} + +#endif /* not UNITY_EXCLUDE_FLOAT */ + +/*-----------------------------------------------*/ +#ifndef UNITY_EXCLUDE_DOUBLE +static int UnityDoublesWithin(UNITY_DOUBLE delta, UNITY_DOUBLE expected, UNITY_DOUBLE actual) +{ + UNITY_DOUBLE diff; + UNITY_FLOAT_OR_DOUBLE_WITHIN(delta, expected, actual, diff); +} + +/*-----------------------------------------------*/ +void UnityAssertEqualDoubleArray(UNITY_PTR_ATTRIBUTE const UNITY_DOUBLE* expected, + UNITY_PTR_ATTRIBUTE const UNITY_DOUBLE* actual, + const UNITY_UINT32 num_elements, + const char* msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_FLAGS_T flags) +{ + UNITY_UINT32 elements = num_elements; + UNITY_PTR_ATTRIBUTE const UNITY_DOUBLE* ptr_expected = expected; + UNITY_PTR_ATTRIBUTE const UNITY_DOUBLE* ptr_actual = actual; + + RETURN_IF_FAIL_OR_IGNORE; + + if (elements == 0) + { + UnityPrintPointlessAndBail(); + } + + if (expected == actual) + { + return; /* Both are NULL or same pointer */ + } + + if (UnityIsOneArrayNull((UNITY_INTERNAL_PTR)expected, (UNITY_INTERNAL_PTR)actual, lineNumber, msg)) + { + UNITY_FAIL_AND_BAIL; + } + + while (elements--) + { + if (!UnityDoublesWithin(*ptr_expected * UNITY_DOUBLE_PRECISION, *ptr_expected, *ptr_actual)) + { + UnityTestResultsFailBegin(lineNumber); + UnityPrint(UnityStrElement); + UnityPrintNumberUnsigned(num_elements - elements - 1); + UNITY_PRINT_EXPECTED_AND_ACTUAL_FLOAT(*ptr_expected, *ptr_actual); + UnityAddMsgIfSpecified(msg); + UNITY_FAIL_AND_BAIL; + } + if (flags == UNITY_ARRAY_TO_ARRAY) + { + ptr_expected++; + } + ptr_actual++; + } +} + +/*-----------------------------------------------*/ +void UnityAssertDoublesWithin(const UNITY_DOUBLE delta, + const UNITY_DOUBLE expected, + const UNITY_DOUBLE actual, + const char* msg, + const UNITY_LINE_TYPE lineNumber) +{ + RETURN_IF_FAIL_OR_IGNORE; + + if (!UnityDoublesWithin(delta, expected, actual)) + { + UnityTestResultsFailBegin(lineNumber); + UNITY_PRINT_EXPECTED_AND_ACTUAL_FLOAT(expected, actual); + UnityAddMsgIfSpecified(msg); + UNITY_FAIL_AND_BAIL; + } +} + +/*-----------------------------------------------*/ +void UnityAssertDoubleSpecial(const UNITY_DOUBLE actual, + const char* msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_FLOAT_TRAIT_T style) +{ + const char* trait_names[] = {UnityStrInf, UnityStrNegInf, UnityStrNaN, UnityStrDet}; + UNITY_INT should_be_trait = ((UNITY_INT)style & 1); + UNITY_INT is_trait = !should_be_trait; + UNITY_INT trait_index = (UNITY_INT)(style >> 1); + + RETURN_IF_FAIL_OR_IGNORE; + + switch (style) + { + case UNITY_FLOAT_IS_INF: + case UNITY_FLOAT_IS_NOT_INF: + is_trait = isinf(actual) && (actual > 0); + break; + case UNITY_FLOAT_IS_NEG_INF: + case UNITY_FLOAT_IS_NOT_NEG_INF: + is_trait = isinf(actual) && (actual < 0); + break; + + case UNITY_FLOAT_IS_NAN: + case UNITY_FLOAT_IS_NOT_NAN: + is_trait = isnan(actual) ? 1 : 0; + break; + + case UNITY_FLOAT_IS_DET: /* A determinate number is non infinite and not NaN. */ + case UNITY_FLOAT_IS_NOT_DET: + is_trait = !isinf(actual) && !isnan(actual); + break; + + default: /* including UNITY_FLOAT_INVALID_TRAIT */ + trait_index = 0; + trait_names[0] = UnityStrInvalidFloatTrait; + break; + } + + if (is_trait != should_be_trait) + { + UnityTestResultsFailBegin(lineNumber); + UnityPrint(UnityStrExpected); + if (!should_be_trait) + { + UnityPrint(UnityStrNot); + } + UnityPrint(trait_names[trait_index]); + UnityPrint(UnityStrWas); +#ifndef UNITY_EXCLUDE_FLOAT_PRINT + UnityPrintFloat(actual); +#else + if (should_be_trait) + { + UnityPrint(UnityStrNot); + } + UnityPrint(trait_names[trait_index]); +#endif + UnityAddMsgIfSpecified(msg); + UNITY_FAIL_AND_BAIL; + } +} + +#endif /* not UNITY_EXCLUDE_DOUBLE */ + +/*-----------------------------------------------*/ +void UnityAssertNumbersWithin(const UNITY_UINT delta, + const UNITY_INT expected, + const UNITY_INT actual, + const char* msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_DISPLAY_STYLE_T style) +{ + RETURN_IF_FAIL_OR_IGNORE; + + if ((style & UNITY_DISPLAY_RANGE_INT) == UNITY_DISPLAY_RANGE_INT) + { + if (actual > expected) + { + Unity.CurrentTestFailed = (((UNITY_UINT)actual - (UNITY_UINT)expected) > delta); + } + else + { + Unity.CurrentTestFailed = (((UNITY_UINT)expected - (UNITY_UINT)actual) > delta); + } + } + else + { + if ((UNITY_UINT)actual > (UNITY_UINT)expected) + { + Unity.CurrentTestFailed = (((UNITY_UINT)actual - (UNITY_UINT)expected) > delta); + } + else + { + Unity.CurrentTestFailed = (((UNITY_UINT)expected - (UNITY_UINT)actual) > delta); + } + } + + if (Unity.CurrentTestFailed) + { + UnityTestResultsFailBegin(lineNumber); + UnityPrint(UnityStrDelta); + UnityPrintNumberByStyle((UNITY_INT)delta, style); + UnityPrint(UnityStrExpected); + UnityPrintNumberByStyle(expected, style); + UnityPrint(UnityStrWas); + UnityPrintNumberByStyle(actual, style); + UnityAddMsgIfSpecified(msg); + UNITY_FAIL_AND_BAIL; + } +} + +/*-----------------------------------------------*/ +void UnityAssertNumbersArrayWithin(const UNITY_UINT delta, + UNITY_INTERNAL_PTR expected, + UNITY_INTERNAL_PTR actual, + const UNITY_UINT32 num_elements, + const char* msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_DISPLAY_STYLE_T style, + const UNITY_FLAGS_T flags) +{ + UNITY_UINT32 elements = num_elements; + unsigned int length = style & 0xF; + unsigned int increment = 0; + + RETURN_IF_FAIL_OR_IGNORE; + + if (num_elements == 0) + { + UnityPrintPointlessAndBail(); + } + + if (expected == actual) + { + return; /* Both are NULL or same pointer */ + } + + if (UnityIsOneArrayNull(expected, actual, lineNumber, msg)) + { + UNITY_FAIL_AND_BAIL; + } + + while ((elements > 0) && (elements--)) + { + UNITY_INT expect_val; + UNITY_INT actual_val; + + switch (length) + { + case 1: + expect_val = *(UNITY_PTR_ATTRIBUTE const UNITY_INT8*)expected; + actual_val = *(UNITY_PTR_ATTRIBUTE const UNITY_INT8*)actual; + increment = sizeof(UNITY_INT8); + break; + + case 2: + expect_val = *(UNITY_PTR_ATTRIBUTE const UNITY_INT16*)expected; + actual_val = *(UNITY_PTR_ATTRIBUTE const UNITY_INT16*)actual; + increment = sizeof(UNITY_INT16); + break; + +#ifdef UNITY_SUPPORT_64 + case 8: + expect_val = *(UNITY_PTR_ATTRIBUTE const UNITY_INT64*)expected; + actual_val = *(UNITY_PTR_ATTRIBUTE const UNITY_INT64*)actual; + increment = sizeof(UNITY_INT64); + break; +#endif + + default: /* default is length 4 bytes */ + case 4: + expect_val = *(UNITY_PTR_ATTRIBUTE const UNITY_INT32*)expected; + actual_val = *(UNITY_PTR_ATTRIBUTE const UNITY_INT32*)actual; + increment = sizeof(UNITY_INT32); + length = 4; + break; + } + + if ((style & UNITY_DISPLAY_RANGE_INT) == UNITY_DISPLAY_RANGE_INT) + { + if (actual_val > expect_val) + { + Unity.CurrentTestFailed = (((UNITY_UINT)actual_val - (UNITY_UINT)expect_val) > delta); + } + else + { + Unity.CurrentTestFailed = (((UNITY_UINT)expect_val - (UNITY_UINT)actual_val) > delta); + } + } + else + { + if ((UNITY_UINT)actual_val > (UNITY_UINT)expect_val) + { + Unity.CurrentTestFailed = (((UNITY_UINT)actual_val - (UNITY_UINT)expect_val) > delta); + } + else + { + Unity.CurrentTestFailed = (((UNITY_UINT)expect_val - (UNITY_UINT)actual_val) > delta); + } + } + + if (Unity.CurrentTestFailed) + { + if ((style & UNITY_DISPLAY_RANGE_UINT) && (length < (UNITY_INT_WIDTH / 8))) + { /* For UINT, remove sign extension (padding 1's) from signed type casts above */ + UNITY_INT mask = 1; + mask = (mask << 8 * length) - 1; + expect_val &= mask; + actual_val &= mask; + } + UnityTestResultsFailBegin(lineNumber); + UnityPrint(UnityStrDelta); + UnityPrintNumberByStyle((UNITY_INT)delta, style); + UnityPrint(UnityStrElement); + UnityPrintNumberUnsigned(num_elements - elements - 1); + UnityPrint(UnityStrExpected); + UnityPrintNumberByStyle(expect_val, style); + UnityPrint(UnityStrWas); + UnityPrintNumberByStyle(actual_val, style); + UnityAddMsgIfSpecified(msg); + UNITY_FAIL_AND_BAIL; + } + /* Walk through array by incrementing the pointers */ + if (flags == UNITY_ARRAY_TO_ARRAY) + { + expected = (UNITY_INTERNAL_PTR)((const char*)expected + increment); + } + actual = (UNITY_INTERNAL_PTR)((const char*)actual + increment); + } +} + +/*-----------------------------------------------*/ +void UnityAssertEqualString(const char* expected, + const char* actual, + const char* msg, + const UNITY_LINE_TYPE lineNumber) +{ + UNITY_UINT32 i; + + RETURN_IF_FAIL_OR_IGNORE; + + /* if both pointers not null compare the strings */ + if (expected && actual) + { + for (i = 0; expected[i] || actual[i]; i++) + { + if (expected[i] != actual[i]) + { + Unity.CurrentTestFailed = 1; + break; + } + } + } + else + { /* handle case of one pointers being null (if both null, test should pass) */ + if (expected != actual) + { + Unity.CurrentTestFailed = 1; + } + } + + if (Unity.CurrentTestFailed) + { + UnityTestResultsFailBegin(lineNumber); + UnityPrintExpectedAndActualStrings(expected, actual); + UnityAddMsgIfSpecified(msg); + UNITY_FAIL_AND_BAIL; + } +} + +/*-----------------------------------------------*/ +void UnityAssertEqualStringLen(const char* expected, + const char* actual, + const UNITY_UINT32 length, + const char* msg, + const UNITY_LINE_TYPE lineNumber) +{ + UNITY_UINT32 i; + + RETURN_IF_FAIL_OR_IGNORE; + + /* if both pointers not null compare the strings */ + if (expected && actual) + { + for (i = 0; (i < length) && (expected[i] || actual[i]); i++) + { + if (expected[i] != actual[i]) + { + Unity.CurrentTestFailed = 1; + break; + } + } + } + else + { /* handle case of one pointers being null (if both null, test should pass) */ + if (expected != actual) + { + Unity.CurrentTestFailed = 1; + } + } + + if (Unity.CurrentTestFailed) + { + UnityTestResultsFailBegin(lineNumber); + UnityPrintExpectedAndActualStringsLen(expected, actual, length); + UnityAddMsgIfSpecified(msg); + UNITY_FAIL_AND_BAIL; + } +} + +/*-----------------------------------------------*/ +void UnityAssertEqualStringArray(UNITY_INTERNAL_PTR expected, + const char** actual, + const UNITY_UINT32 num_elements, + const char* msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_FLAGS_T flags) +{ + UNITY_UINT32 i = 0; + UNITY_UINT32 j = 0; + const char* expd = NULL; + const char* act = NULL; + + RETURN_IF_FAIL_OR_IGNORE; + + /* if no elements, it's an error */ + if (num_elements == 0) + { + UnityPrintPointlessAndBail(); + } + + if ((const void*)expected == (const void*)actual) + { + return; /* Both are NULL or same pointer */ + } + + if (UnityIsOneArrayNull((UNITY_INTERNAL_PTR)expected, (UNITY_INTERNAL_PTR)actual, lineNumber, msg)) + { + UNITY_FAIL_AND_BAIL; + } + + if (flags != UNITY_ARRAY_TO_ARRAY) + { + expd = (const char*)expected; + } + + do + { + act = actual[j]; + if (flags == UNITY_ARRAY_TO_ARRAY) + { + expd = ((const char* const*)expected)[j]; + } + + /* if both pointers not null compare the strings */ + if (expd && act) + { + for (i = 0; expd[i] || act[i]; i++) + { + if (expd[i] != act[i]) + { + Unity.CurrentTestFailed = 1; + break; + } + } + } + else + { /* handle case of one pointers being null (if both null, test should pass) */ + if (expd != act) + { + Unity.CurrentTestFailed = 1; + } + } + + if (Unity.CurrentTestFailed) + { + UnityTestResultsFailBegin(lineNumber); + if (num_elements > 1) + { + UnityPrint(UnityStrElement); + UnityPrintNumberUnsigned(j); + } + UnityPrintExpectedAndActualStrings(expd, act); + UnityAddMsgIfSpecified(msg); + UNITY_FAIL_AND_BAIL; + } + } while (++j < num_elements); +} + +/*-----------------------------------------------*/ +void UnityAssertEqualMemory(UNITY_INTERNAL_PTR expected, + UNITY_INTERNAL_PTR actual, + const UNITY_UINT32 length, + const UNITY_UINT32 num_elements, + const char* msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_FLAGS_T flags) +{ + UNITY_PTR_ATTRIBUTE const unsigned char* ptr_exp = (UNITY_PTR_ATTRIBUTE const unsigned char*)expected; + UNITY_PTR_ATTRIBUTE const unsigned char* ptr_act = (UNITY_PTR_ATTRIBUTE const unsigned char*)actual; + UNITY_UINT32 elements = num_elements; + UNITY_UINT32 bytes; + + RETURN_IF_FAIL_OR_IGNORE; + + if ((elements == 0) || (length == 0)) + { + UnityPrintPointlessAndBail(); + } + + if (expected == actual) + { + return; /* Both are NULL or same pointer */ + } + + if (UnityIsOneArrayNull(expected, actual, lineNumber, msg)) + { + UNITY_FAIL_AND_BAIL; + } + + while (elements--) + { + bytes = length; + while (bytes--) + { + if (*ptr_exp != *ptr_act) + { + UnityTestResultsFailBegin(lineNumber); + UnityPrint(UnityStrMemory); + if (num_elements > 1) + { + UnityPrint(UnityStrElement); + UnityPrintNumberUnsigned(num_elements - elements - 1); + } + UnityPrint(UnityStrByte); + UnityPrintNumberUnsigned(length - bytes - 1); + UnityPrint(UnityStrExpected); + UnityPrintNumberByStyle(*ptr_exp, UNITY_DISPLAY_STYLE_HEX8); + UnityPrint(UnityStrWas); + UnityPrintNumberByStyle(*ptr_act, UNITY_DISPLAY_STYLE_HEX8); + UnityAddMsgIfSpecified(msg); + UNITY_FAIL_AND_BAIL; + } + ptr_exp++; + ptr_act++; + } + if (flags == UNITY_ARRAY_TO_VAL) + { + ptr_exp = (UNITY_PTR_ATTRIBUTE const unsigned char*)expected; + } + } +} + +/*-----------------------------------------------*/ + +static union +{ + UNITY_INT8 i8; + UNITY_INT16 i16; + UNITY_INT32 i32; +#ifdef UNITY_SUPPORT_64 + UNITY_INT64 i64; +#endif +#ifndef UNITY_EXCLUDE_FLOAT + float f; +#endif +#ifndef UNITY_EXCLUDE_DOUBLE + double d; +#endif +} UnityQuickCompare; + +UNITY_INTERNAL_PTR UnityNumToPtr(const UNITY_INT num, const UNITY_UINT8 size) +{ + switch(size) + { + case 1: + UnityQuickCompare.i8 = (UNITY_INT8)num; + return (UNITY_INTERNAL_PTR)(&UnityQuickCompare.i8); + + case 2: + UnityQuickCompare.i16 = (UNITY_INT16)num; + return (UNITY_INTERNAL_PTR)(&UnityQuickCompare.i16); + +#ifdef UNITY_SUPPORT_64 + case 8: + UnityQuickCompare.i64 = (UNITY_INT64)num; + return (UNITY_INTERNAL_PTR)(&UnityQuickCompare.i64); +#endif + + default: /* 4 bytes */ + UnityQuickCompare.i32 = (UNITY_INT32)num; + return (UNITY_INTERNAL_PTR)(&UnityQuickCompare.i32); + } +} + +#ifndef UNITY_EXCLUDE_FLOAT +/*-----------------------------------------------*/ +UNITY_INTERNAL_PTR UnityFloatToPtr(const float num) +{ + UnityQuickCompare.f = num; + return (UNITY_INTERNAL_PTR)(&UnityQuickCompare.f); +} +#endif + +#ifndef UNITY_EXCLUDE_DOUBLE +/*-----------------------------------------------*/ +UNITY_INTERNAL_PTR UnityDoubleToPtr(const double num) +{ + UnityQuickCompare.d = num; + return (UNITY_INTERNAL_PTR)(&UnityQuickCompare.d); +} +#endif + +/*----------------------------------------------- + * printf helper function + *-----------------------------------------------*/ +#ifdef UNITY_INCLUDE_PRINT_FORMATTED +static void UnityPrintFVA(const char* format, va_list va) +{ + const char* pch = format; + if (pch != NULL) + { + while (*pch) + { + /* format identification character */ + if (*pch == '%') + { + pch++; + + if (pch != NULL) + { + switch (*pch) + { + case 'd': + case 'i': + { + const int number = va_arg(va, int); + UnityPrintNumber((UNITY_INT)number); + break; + } +#ifndef UNITY_EXCLUDE_FLOAT_PRINT + case 'f': + case 'g': + { + const double number = va_arg(va, double); + UnityPrintFloat((UNITY_DOUBLE)number); + break; + } +#endif + case 'u': + { + const unsigned int number = va_arg(va, unsigned int); + UnityPrintNumberUnsigned((UNITY_UINT)number); + break; + } + case 'b': + { + const unsigned int number = va_arg(va, unsigned int); + const UNITY_UINT mask = (UNITY_UINT)0 - (UNITY_UINT)1; + UNITY_OUTPUT_CHAR('0'); + UNITY_OUTPUT_CHAR('b'); + UnityPrintMask(mask, (UNITY_UINT)number); + break; + } + case 'x': + case 'X': + case 'p': + { + const unsigned int number = va_arg(va, unsigned int); + UNITY_OUTPUT_CHAR('0'); + UNITY_OUTPUT_CHAR('x'); + UnityPrintNumberHex((UNITY_UINT)number, 8); + break; + } + case 'c': + { + const int ch = va_arg(va, int); + UnityPrintChar((const char *)&ch); + break; + } + case 's': + { + const char * string = va_arg(va, const char *); + UnityPrint(string); + break; + } + case '%': + { + UnityPrintChar(pch); + break; + } + default: + { + /* print the unknown format character */ + UNITY_OUTPUT_CHAR('%'); + UnityPrintChar(pch); + break; + } + } + } + } +#ifdef UNITY_OUTPUT_COLOR + /* print ANSI escape code */ + else if ((*pch == 27) && (*(pch + 1) == '[')) + { + pch += UnityPrintAnsiEscapeString(pch); + continue; + } +#endif + else if (*pch == '\n') + { + UNITY_PRINT_EOL(); + } + else + { + UnityPrintChar(pch); + } + + pch++; + } + } +} + +void UnityPrintF(const UNITY_LINE_TYPE line, const char* format, ...) +{ + UnityTestResultsBegin(Unity.TestFile, line); + UnityPrint("INFO"); + if(format != NULL) + { + UnityPrint(": "); + va_list va; + va_start(va, format); + UnityPrintFVA(format, va); + va_end(va); + } + UNITY_PRINT_EOL(); +} +#endif /* ! UNITY_INCLUDE_PRINT_FORMATTED */ + + +/*----------------------------------------------- + * Control Functions + *-----------------------------------------------*/ + +/*-----------------------------------------------*/ +void UnityFail(const char* msg, const UNITY_LINE_TYPE line) +{ + RETURN_IF_FAIL_OR_IGNORE; + + UnityTestResultsBegin(Unity.TestFile, line); + UnityPrint(UnityStrFail); + if (msg != NULL) + { + UNITY_OUTPUT_CHAR(':'); + +#ifdef UNITY_PRINT_TEST_CONTEXT + UNITY_PRINT_TEST_CONTEXT(); +#endif +#ifndef UNITY_EXCLUDE_DETAILS + if (Unity.CurrentDetail1) + { + UnityPrint(UnityStrDetail1Name); + UnityPrint(Unity.CurrentDetail1); + if (Unity.CurrentDetail2) + { + UnityPrint(UnityStrDetail2Name); + UnityPrint(Unity.CurrentDetail2); + } + UnityPrint(UnityStrSpacer); + } +#endif + if (msg[0] != ' ') + { + UNITY_OUTPUT_CHAR(' '); + } + UnityPrint(msg); + } + + UNITY_FAIL_AND_BAIL; +} + +/*-----------------------------------------------*/ +void UnityIgnore(const char* msg, const UNITY_LINE_TYPE line) +{ + RETURN_IF_FAIL_OR_IGNORE; + + UnityTestResultsBegin(Unity.TestFile, line); + UnityPrint(UnityStrIgnore); + if (msg != NULL) + { + UNITY_OUTPUT_CHAR(':'); + UNITY_OUTPUT_CHAR(' '); + UnityPrint(msg); + } + UNITY_IGNORE_AND_BAIL; +} + +/*-----------------------------------------------*/ +void UnityMessage(const char* msg, const UNITY_LINE_TYPE line) +{ + UnityTestResultsBegin(Unity.TestFile, line); + UnityPrint("INFO"); + if (msg != NULL) + { + UNITY_OUTPUT_CHAR(':'); + UNITY_OUTPUT_CHAR(' '); + UnityPrint(msg); + } + UNITY_PRINT_EOL(); +} + +/*-----------------------------------------------*/ +/* If we have not defined our own test runner, then include our default test runner to make life easier */ +#ifndef UNITY_SKIP_DEFAULT_RUNNER +void UnityDefaultTestRun(UnityTestFunction Func, const char* FuncName, const int FuncLineNum) +{ + Unity.CurrentTestName = FuncName; + Unity.CurrentTestLineNumber = (UNITY_LINE_TYPE)FuncLineNum; + Unity.NumberOfTests++; + UNITY_CLR_DETAILS(); + UNITY_EXEC_TIME_START(); + if (TEST_PROTECT()) + { + setUp(); + Func(); + } + if (TEST_PROTECT()) + { + tearDown(); + } + UNITY_EXEC_TIME_STOP(); + UnityConcludeTest(); +} +#endif + +/*-----------------------------------------------*/ +void UnitySetTestFile(const char* filename) +{ + Unity.TestFile = filename; +} + +/*-----------------------------------------------*/ +void UnityBegin(const char* filename) +{ + Unity.TestFile = filename; + Unity.CurrentTestName = NULL; + Unity.CurrentTestLineNumber = 0; + Unity.NumberOfTests = 0; + Unity.TestFailures = 0; + Unity.TestIgnores = 0; + Unity.CurrentTestFailed = 0; + Unity.CurrentTestIgnored = 0; + + UNITY_CLR_DETAILS(); + UNITY_OUTPUT_START(); +} + +/*-----------------------------------------------*/ +int UnityEnd(void) +{ + UNITY_PRINT_EOL(); + UnityPrint(UnityStrBreaker); + UNITY_PRINT_EOL(); + UnityPrintNumber((UNITY_INT)(Unity.NumberOfTests)); + UnityPrint(UnityStrResultsTests); + UnityPrintNumber((UNITY_INT)(Unity.TestFailures)); + UnityPrint(UnityStrResultsFailures); + UnityPrintNumber((UNITY_INT)(Unity.TestIgnores)); + UnityPrint(UnityStrResultsIgnored); + UNITY_PRINT_EOL(); + if (Unity.TestFailures == 0U) + { + UnityPrint(UnityStrOk); + } + else + { + UnityPrint(UnityStrFail); +#ifdef UNITY_DIFFERENTIATE_FINAL_FAIL + UNITY_OUTPUT_CHAR('E'); UNITY_OUTPUT_CHAR('D'); +#endif + } + UNITY_PRINT_EOL(); + UNITY_FLUSH_CALL(); + UNITY_OUTPUT_COMPLETE(); + return (int)(Unity.TestFailures); +} + +/*----------------------------------------------- + * Command Line Argument Support + *-----------------------------------------------*/ +#ifdef UNITY_USE_COMMAND_LINE_ARGS + +char* UnityOptionIncludeNamed = NULL; +char* UnityOptionExcludeNamed = NULL; +int UnityVerbosity = 1; + +/*-----------------------------------------------*/ +int UnityParseOptions(int argc, char** argv) +{ + int i; + UnityOptionIncludeNamed = NULL; + UnityOptionExcludeNamed = NULL; + + for (i = 1; i < argc; i++) + { + if (argv[i][0] == '-') + { + switch (argv[i][1]) + { + case 'l': /* list tests */ + return -1; + case 'n': /* include tests with name including this string */ + case 'f': /* an alias for -n */ + if (argv[i][2] == '=') + { + UnityOptionIncludeNamed = &argv[i][3]; + } + else if (++i < argc) + { + UnityOptionIncludeNamed = argv[i]; + } + else + { + UnityPrint("ERROR: No Test String to Include Matches For"); + UNITY_PRINT_EOL(); + return 1; + } + break; + case 'q': /* quiet */ + UnityVerbosity = 0; + break; + case 'v': /* verbose */ + UnityVerbosity = 2; + break; + case 'x': /* exclude tests with name including this string */ + if (argv[i][2] == '=') + { + UnityOptionExcludeNamed = &argv[i][3]; + } + else if (++i < argc) + { + UnityOptionExcludeNamed = argv[i]; + } + else + { + UnityPrint("ERROR: No Test String to Exclude Matches For"); + UNITY_PRINT_EOL(); + return 1; + } + break; + default: + UnityPrint("ERROR: Unknown Option "); + UNITY_OUTPUT_CHAR(argv[i][1]); + UNITY_PRINT_EOL(); + return 1; + } + } + } + + return 0; +} + +/*-----------------------------------------------*/ +int IsStringInBiggerString(const char* longstring, const char* shortstring) +{ + const char* lptr = longstring; + const char* sptr = shortstring; + const char* lnext = lptr; + + if (*sptr == '*') + { + return 1; + } + + while (*lptr) + { + lnext = lptr + 1; + + /* If they current bytes match, go on to the next bytes */ + while (*lptr && *sptr && (*lptr == *sptr)) + { + lptr++; + sptr++; + + /* We're done if we match the entire string or up to a wildcard */ + if (*sptr == '*') + return 1; + if (*sptr == ',') + return 1; + if (*sptr == '"') + return 1; + if (*sptr == '\'') + return 1; + if (*sptr == ':') + return 2; + if (*sptr == 0) + return 1; + } + + /* Otherwise we start in the long pointer 1 character further and try again */ + lptr = lnext; + sptr = shortstring; + } + + return 0; +} + +/*-----------------------------------------------*/ +int UnityStringArgumentMatches(const char* str) +{ + int retval; + const char* ptr1; + const char* ptr2; + const char* ptrf; + + /* Go through the options and get the substrings for matching one at a time */ + ptr1 = str; + while (ptr1[0] != 0) + { + if ((ptr1[0] == '"') || (ptr1[0] == '\'')) + { + ptr1++; + } + + /* look for the start of the next partial */ + ptr2 = ptr1; + ptrf = 0; + do + { + ptr2++; + if ((ptr2[0] == ':') && (ptr2[1] != 0) && (ptr2[0] != '\'') && (ptr2[0] != '"') && (ptr2[0] != ',')) + { + ptrf = &ptr2[1]; + } + } while ((ptr2[0] != 0) && (ptr2[0] != '\'') && (ptr2[0] != '"') && (ptr2[0] != ',')); + + while ((ptr2[0] != 0) && ((ptr2[0] == ':') || (ptr2[0] == '\'') || (ptr2[0] == '"') || (ptr2[0] == ','))) + { + ptr2++; + } + + /* done if complete filename match */ + retval = IsStringInBiggerString(Unity.TestFile, ptr1); + if (retval == 1) + { + return retval; + } + + /* done if testname match after filename partial match */ + if ((retval == 2) && (ptrf != 0)) + { + if (IsStringInBiggerString(Unity.CurrentTestName, ptrf)) + { + return 1; + } + } + + /* done if complete testname match */ + if (IsStringInBiggerString(Unity.CurrentTestName, ptr1) == 1) + { + return 1; + } + + ptr1 = ptr2; + } + + /* we couldn't find a match for any substrings */ + return 0; +} + +/*-----------------------------------------------*/ +int UnityTestMatches(void) +{ + /* Check if this test name matches the included test pattern */ + int retval; + if (UnityOptionIncludeNamed) + { + retval = UnityStringArgumentMatches(UnityOptionIncludeNamed); + } + else + { + retval = 1; + } + + /* Check if this test name matches the excluded test pattern */ + if (UnityOptionExcludeNamed) + { + if (UnityStringArgumentMatches(UnityOptionExcludeNamed)) + { + retval = 0; + } + } + + return retval; +} + +#endif /* UNITY_USE_COMMAND_LINE_ARGS */ +/*-----------------------------------------------*/ diff --git a/rust/integration-tests/src/bindings_tests/unity/unity.h b/rust/integration-tests/src/bindings_tests/unity/unity.h new file mode 100644 index 000000000..14225a354 --- /dev/null +++ b/rust/integration-tests/src/bindings_tests/unity/unity.h @@ -0,0 +1,661 @@ +/* ========================================== + Unity Project - A Test Framework for C + Copyright (c) 2007-21 Mike Karlesky, Mark VanderVoord, Greg Williams + [Released under MIT License. Please refer to license.txt for details] +========================================== */ + +#ifndef UNITY_FRAMEWORK_H +#define UNITY_FRAMEWORK_H +#define UNITY + +#define UNITY_VERSION_MAJOR 2 +#define UNITY_VERSION_MINOR 5 +#define UNITY_VERSION_BUILD 4 +#define UNITY_VERSION ((UNITY_VERSION_MAJOR << 16) | (UNITY_VERSION_MINOR << 8) | UNITY_VERSION_BUILD) + +#ifdef __cplusplus +extern "C" +{ +#endif + +#include "unity_internals.h" + +/*------------------------------------------------------- + * Test Setup / Teardown + *-------------------------------------------------------*/ + +/* These functions are intended to be called before and after each test. + * If using unity directly, these will need to be provided for each test + * executable built. If you are using the test runner generator and/or + * Ceedling, these are optional. */ +void setUp(void); +void tearDown(void); + +/* These functions are intended to be called at the beginning and end of an + * entire test suite. suiteTearDown() is passed the number of tests that + * failed, and its return value becomes the exit code of main(). If using + * Unity directly, you're in charge of calling these if they are desired. + * If using Ceedling or the test runner generator, these will be called + * automatically if they exist. */ +void suiteSetUp(void); +int suiteTearDown(int num_failures); + +/*------------------------------------------------------- + * Test Reset and Verify + *-------------------------------------------------------*/ + +/* These functions are intended to be called before during tests in order + * to support complex test loops, etc. Both are NOT built into Unity. Instead + * the test runner generator will create them. resetTest will run teardown and + * setup again, verifying any end-of-test needs between. verifyTest will only + * run the verification. */ +void resetTest(void); +void verifyTest(void); + +/*------------------------------------------------------- + * Configuration Options + *------------------------------------------------------- + * All options described below should be passed as a compiler flag to all files using Unity. If you must add #defines, place them BEFORE the #include above. + + * Integers/longs/pointers + * - Unity attempts to automatically discover your integer sizes + * - define UNITY_EXCLUDE_STDINT_H to stop attempting to look in + * - define UNITY_EXCLUDE_LIMITS_H to stop attempting to look in + * - If you cannot use the automatic methods above, you can force Unity by using these options: + * - define UNITY_SUPPORT_64 + * - set UNITY_INT_WIDTH + * - set UNITY_LONG_WIDTH + * - set UNITY_POINTER_WIDTH + + * Floats + * - define UNITY_EXCLUDE_FLOAT to disallow floating point comparisons + * - define UNITY_FLOAT_PRECISION to specify the precision to use when doing TEST_ASSERT_EQUAL_FLOAT + * - define UNITY_FLOAT_TYPE to specify doubles instead of single precision floats + * - define UNITY_INCLUDE_DOUBLE to allow double floating point comparisons + * - define UNITY_EXCLUDE_DOUBLE to disallow double floating point comparisons (default) + * - define UNITY_DOUBLE_PRECISION to specify the precision to use when doing TEST_ASSERT_EQUAL_DOUBLE + * - define UNITY_DOUBLE_TYPE to specify something other than double + * - define UNITY_EXCLUDE_FLOAT_PRINT to trim binary size, won't print floating point values in errors + + * Output + * - by default, Unity prints to standard out with putchar. define UNITY_OUTPUT_CHAR(a) with a different function if desired + * - define UNITY_DIFFERENTIATE_FINAL_FAIL to print FAILED (vs. FAIL) at test end summary - for automated search for failure + + * Optimization + * - by default, line numbers are stored in unsigned shorts. Define UNITY_LINE_TYPE with a different type if your files are huge + * - by default, test and failure counters are unsigned shorts. Define UNITY_COUNTER_TYPE with a different type if you want to save space or have more than 65535 Tests. + + * Test Cases + * - define UNITY_SUPPORT_TEST_CASES to include the TEST_CASE macro, though really it's mostly about the runner generator script + + * Parameterized Tests + * - you'll want to create a define of TEST_CASE(...) which basically evaluates to nothing + + * Tests with Arguments + * - you'll want to define UNITY_USE_COMMAND_LINE_ARGS if you have the test runner passing arguments to Unity + + *------------------------------------------------------- + * Basic Fail and Ignore + *-------------------------------------------------------*/ + +#define TEST_FAIL_MESSAGE(message) UNITY_TEST_FAIL(__LINE__, (message)) +#define TEST_FAIL() UNITY_TEST_FAIL(__LINE__, NULL) +#define TEST_IGNORE_MESSAGE(message) UNITY_TEST_IGNORE(__LINE__, (message)) +#define TEST_IGNORE() UNITY_TEST_IGNORE(__LINE__, NULL) +#define TEST_MESSAGE(message) UnityMessage((message), __LINE__) +#define TEST_ONLY() +#ifdef UNITY_INCLUDE_PRINT_FORMATTED +#define TEST_PRINTF(message, ...) UnityPrintF(__LINE__, (message), __VA_ARGS__) +#endif + +/* It is not necessary for you to call PASS. A PASS condition is assumed if nothing fails. + * This method allows you to abort a test immediately with a PASS state, ignoring the remainder of the test. */ +#define TEST_PASS() TEST_ABORT() +#define TEST_PASS_MESSAGE(message) do { UnityMessage((message), __LINE__); TEST_ABORT(); } while (0) + +/* This macro does nothing, but it is useful for build tools (like Ceedling) to make use of this to figure out + * which files should be linked to in order to perform a test. Use it like TEST_FILE("sandwiches.c") */ +#define TEST_FILE(a) + +/*------------------------------------------------------- + * Test Asserts (simple) + *-------------------------------------------------------*/ + +/* Boolean */ +#define TEST_ASSERT(condition) UNITY_TEST_ASSERT( (condition), __LINE__, " Expression Evaluated To FALSE") +#define TEST_ASSERT_TRUE(condition) UNITY_TEST_ASSERT( (condition), __LINE__, " Expected TRUE Was FALSE") +#define TEST_ASSERT_UNLESS(condition) UNITY_TEST_ASSERT( !(condition), __LINE__, " Expression Evaluated To TRUE") +#define TEST_ASSERT_FALSE(condition) UNITY_TEST_ASSERT( !(condition), __LINE__, " Expected FALSE Was TRUE") +#define TEST_ASSERT_NULL(pointer) UNITY_TEST_ASSERT_NULL( (pointer), __LINE__, " Expected NULL") +#define TEST_ASSERT_NOT_NULL(pointer) UNITY_TEST_ASSERT_NOT_NULL((pointer), __LINE__, " Expected Non-NULL") +#define TEST_ASSERT_EMPTY(pointer) UNITY_TEST_ASSERT_EMPTY( (pointer), __LINE__, " Expected Empty") +#define TEST_ASSERT_NOT_EMPTY(pointer) UNITY_TEST_ASSERT_NOT_EMPTY((pointer), __LINE__, " Expected Non-Empty") + +/* Integers (of all sizes) */ +#define TEST_ASSERT_EQUAL_INT(expected, actual) UNITY_TEST_ASSERT_EQUAL_INT((expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_INT8(expected, actual) UNITY_TEST_ASSERT_EQUAL_INT8((expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_INT16(expected, actual) UNITY_TEST_ASSERT_EQUAL_INT16((expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_INT32(expected, actual) UNITY_TEST_ASSERT_EQUAL_INT32((expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_INT64(expected, actual) UNITY_TEST_ASSERT_EQUAL_INT64((expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_UINT(expected, actual) UNITY_TEST_ASSERT_EQUAL_UINT( (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_UINT8(expected, actual) UNITY_TEST_ASSERT_EQUAL_UINT8( (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_UINT16(expected, actual) UNITY_TEST_ASSERT_EQUAL_UINT16( (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_UINT32(expected, actual) UNITY_TEST_ASSERT_EQUAL_UINT32( (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_UINT64(expected, actual) UNITY_TEST_ASSERT_EQUAL_UINT64( (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_size_t(expected, actual) UNITY_TEST_ASSERT_EQUAL_UINT((expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_HEX(expected, actual) UNITY_TEST_ASSERT_EQUAL_HEX32((expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_HEX8(expected, actual) UNITY_TEST_ASSERT_EQUAL_HEX8( (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_HEX16(expected, actual) UNITY_TEST_ASSERT_EQUAL_HEX16((expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_HEX32(expected, actual) UNITY_TEST_ASSERT_EQUAL_HEX32((expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_HEX64(expected, actual) UNITY_TEST_ASSERT_EQUAL_HEX64((expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_CHAR(expected, actual) UNITY_TEST_ASSERT_EQUAL_CHAR((expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_BITS(mask, expected, actual) UNITY_TEST_ASSERT_BITS((mask), (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_BITS_HIGH(mask, actual) UNITY_TEST_ASSERT_BITS((mask), (UNITY_UINT)(-1), (actual), __LINE__, NULL) +#define TEST_ASSERT_BITS_LOW(mask, actual) UNITY_TEST_ASSERT_BITS((mask), (UNITY_UINT)(0), (actual), __LINE__, NULL) +#define TEST_ASSERT_BIT_HIGH(bit, actual) UNITY_TEST_ASSERT_BITS(((UNITY_UINT)1 << (bit)), (UNITY_UINT)(-1), (actual), __LINE__, NULL) +#define TEST_ASSERT_BIT_LOW(bit, actual) UNITY_TEST_ASSERT_BITS(((UNITY_UINT)1 << (bit)), (UNITY_UINT)(0), (actual), __LINE__, NULL) + +/* Integer Not Equal To (of all sizes) */ +#define TEST_ASSERT_NOT_EQUAL_INT(threshold, actual) UNITY_TEST_ASSERT_NOT_EQUAL_INT((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_NOT_EQUAL_INT8(threshold, actual) UNITY_TEST_ASSERT_NOT_EQUAL_INT8((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_NOT_EQUAL_INT16(threshold, actual) UNITY_TEST_ASSERT_NOT_EQUAL_INT16((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_NOT_EQUAL_INT32(threshold, actual) UNITY_TEST_ASSERT_NOT_EQUAL_INT32((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_NOT_EQUAL_INT64(threshold, actual) UNITY_TEST_ASSERT_NOT_EQUAL_INT64((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_NOT_EQUAL_UINT(threshold, actual) UNITY_TEST_ASSERT_NOT_EQUAL_UINT((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_NOT_EQUAL_UINT8(threshold, actual) UNITY_TEST_ASSERT_NOT_EQUAL_UINT8((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_NOT_EQUAL_UINT16(threshold, actual) UNITY_TEST_ASSERT_NOT_EQUAL_UINT16((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_NOT_EQUAL_UINT32(threshold, actual) UNITY_TEST_ASSERT_NOT_EQUAL_UINT32((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_NOT_EQUAL_UINT64(threshold, actual) UNITY_TEST_ASSERT_NOT_EQUAL_UINT64((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_NOT_EQUAL_size_t(threshold, actual) UNITY_TEST_ASSERT_NOT_EQUAL_UINT((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_NOT_EQUAL_HEX8(threshold, actual) UNITY_TEST_ASSERT_NOT_EQUAL_HEX8((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_NOT_EQUAL_HEX16(threshold, actual) UNITY_TEST_ASSERT_NOT_EQUAL_HEX16((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_NOT_EQUAL_HEX32(threshold, actual) UNITY_TEST_ASSERT_NOT_EQUAL_HEX32((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_NOT_EQUAL_HEX64(threshold, actual) UNITY_TEST_ASSERT_NOT_EQUAL_HEX64((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_NOT_EQUAL_CHAR(threshold, actual) UNITY_TEST_ASSERT_NOT_EQUAL_CHAR((threshold), (actual), __LINE__, NULL) + +/* Integer Greater Than/ Less Than (of all sizes) */ +#define TEST_ASSERT_GREATER_THAN(threshold, actual) UNITY_TEST_ASSERT_GREATER_THAN_INT((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_THAN_INT(threshold, actual) UNITY_TEST_ASSERT_GREATER_THAN_INT((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_THAN_INT8(threshold, actual) UNITY_TEST_ASSERT_GREATER_THAN_INT8((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_THAN_INT16(threshold, actual) UNITY_TEST_ASSERT_GREATER_THAN_INT16((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_THAN_INT32(threshold, actual) UNITY_TEST_ASSERT_GREATER_THAN_INT32((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_THAN_INT64(threshold, actual) UNITY_TEST_ASSERT_GREATER_THAN_INT64((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_THAN_UINT(threshold, actual) UNITY_TEST_ASSERT_GREATER_THAN_UINT((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_THAN_UINT8(threshold, actual) UNITY_TEST_ASSERT_GREATER_THAN_UINT8((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_THAN_UINT16(threshold, actual) UNITY_TEST_ASSERT_GREATER_THAN_UINT16((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_THAN_UINT32(threshold, actual) UNITY_TEST_ASSERT_GREATER_THAN_UINT32((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_THAN_UINT64(threshold, actual) UNITY_TEST_ASSERT_GREATER_THAN_UINT64((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_THAN_size_t(threshold, actual) UNITY_TEST_ASSERT_GREATER_THAN_UINT((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_THAN_HEX8(threshold, actual) UNITY_TEST_ASSERT_GREATER_THAN_HEX8((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_THAN_HEX16(threshold, actual) UNITY_TEST_ASSERT_GREATER_THAN_HEX16((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_THAN_HEX32(threshold, actual) UNITY_TEST_ASSERT_GREATER_THAN_HEX32((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_THAN_HEX64(threshold, actual) UNITY_TEST_ASSERT_GREATER_THAN_HEX64((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_THAN_CHAR(threshold, actual) UNITY_TEST_ASSERT_GREATER_THAN_CHAR((threshold), (actual), __LINE__, NULL) + +#define TEST_ASSERT_LESS_THAN(threshold, actual) UNITY_TEST_ASSERT_SMALLER_THAN_INT((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_THAN_INT(threshold, actual) UNITY_TEST_ASSERT_SMALLER_THAN_INT((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_THAN_INT8(threshold, actual) UNITY_TEST_ASSERT_SMALLER_THAN_INT8((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_THAN_INT16(threshold, actual) UNITY_TEST_ASSERT_SMALLER_THAN_INT16((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_THAN_INT32(threshold, actual) UNITY_TEST_ASSERT_SMALLER_THAN_INT32((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_THAN_INT64(threshold, actual) UNITY_TEST_ASSERT_SMALLER_THAN_INT64((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_THAN_UINT(threshold, actual) UNITY_TEST_ASSERT_SMALLER_THAN_UINT((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_THAN_UINT8(threshold, actual) UNITY_TEST_ASSERT_SMALLER_THAN_UINT8((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_THAN_UINT16(threshold, actual) UNITY_TEST_ASSERT_SMALLER_THAN_UINT16((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_THAN_UINT32(threshold, actual) UNITY_TEST_ASSERT_SMALLER_THAN_UINT32((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_THAN_UINT64(threshold, actual) UNITY_TEST_ASSERT_SMALLER_THAN_UINT64((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_THAN_size_t(threshold, actual) UNITY_TEST_ASSERT_SMALLER_THAN_UINT((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_THAN_HEX8(threshold, actual) UNITY_TEST_ASSERT_SMALLER_THAN_HEX8((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_THAN_HEX16(threshold, actual) UNITY_TEST_ASSERT_SMALLER_THAN_HEX16((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_THAN_HEX32(threshold, actual) UNITY_TEST_ASSERT_SMALLER_THAN_HEX32((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_THAN_HEX64(threshold, actual) UNITY_TEST_ASSERT_SMALLER_THAN_HEX64((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_THAN_CHAR(threshold, actual) UNITY_TEST_ASSERT_SMALLER_THAN_CHAR((threshold), (actual), __LINE__, NULL) + +#define TEST_ASSERT_GREATER_OR_EQUAL(threshold, actual) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_INT((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_OR_EQUAL_INT(threshold, actual) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_INT((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_OR_EQUAL_INT8(threshold, actual) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_INT8((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_OR_EQUAL_INT16(threshold, actual) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_INT16((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_OR_EQUAL_INT32(threshold, actual) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_INT32((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_OR_EQUAL_INT64(threshold, actual) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_INT64((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_OR_EQUAL_UINT(threshold, actual) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_UINT((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_OR_EQUAL_UINT8(threshold, actual) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_UINT8((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_OR_EQUAL_UINT16(threshold, actual) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_UINT16((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_OR_EQUAL_UINT32(threshold, actual) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_UINT32((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_OR_EQUAL_UINT64(threshold, actual) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_UINT64((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_OR_EQUAL_size_t(threshold, actual) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_UINT((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_OR_EQUAL_HEX8(threshold, actual) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_HEX8((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_OR_EQUAL_HEX16(threshold, actual) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_HEX16((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_OR_EQUAL_HEX32(threshold, actual) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_HEX32((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_OR_EQUAL_HEX64(threshold, actual) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_HEX64((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_GREATER_OR_EQUAL_CHAR(threshold, actual) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_CHAR((threshold), (actual), __LINE__, NULL) + +#define TEST_ASSERT_LESS_OR_EQUAL(threshold, actual) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_INT((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_OR_EQUAL_INT(threshold, actual) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_INT((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_OR_EQUAL_INT8(threshold, actual) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_INT8((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_OR_EQUAL_INT16(threshold, actual) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_INT16((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_OR_EQUAL_INT32(threshold, actual) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_INT32((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_OR_EQUAL_INT64(threshold, actual) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_INT64((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_OR_EQUAL_UINT(threshold, actual) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_UINT((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_OR_EQUAL_UINT8(threshold, actual) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_UINT8((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_OR_EQUAL_UINT16(threshold, actual) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_UINT16((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_OR_EQUAL_UINT32(threshold, actual) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_UINT32((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_OR_EQUAL_UINT64(threshold, actual) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_UINT64((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_OR_EQUAL_size_t(threshold, actual) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_UINT((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_OR_EQUAL_HEX8(threshold, actual) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_HEX8((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_OR_EQUAL_HEX16(threshold, actual) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_HEX16((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_OR_EQUAL_HEX32(threshold, actual) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_HEX32((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_OR_EQUAL_HEX64(threshold, actual) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_HEX64((threshold), (actual), __LINE__, NULL) +#define TEST_ASSERT_LESS_OR_EQUAL_CHAR(threshold, actual) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_CHAR((threshold), (actual), __LINE__, NULL) + +/* Integer Ranges (of all sizes) */ +#define TEST_ASSERT_INT_WITHIN(delta, expected, actual) UNITY_TEST_ASSERT_INT_WITHIN((delta), (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_INT8_WITHIN(delta, expected, actual) UNITY_TEST_ASSERT_INT8_WITHIN((delta), (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_INT16_WITHIN(delta, expected, actual) UNITY_TEST_ASSERT_INT16_WITHIN((delta), (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_INT32_WITHIN(delta, expected, actual) UNITY_TEST_ASSERT_INT32_WITHIN((delta), (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_INT64_WITHIN(delta, expected, actual) UNITY_TEST_ASSERT_INT64_WITHIN((delta), (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_UINT_WITHIN(delta, expected, actual) UNITY_TEST_ASSERT_UINT_WITHIN((delta), (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_UINT8_WITHIN(delta, expected, actual) UNITY_TEST_ASSERT_UINT8_WITHIN((delta), (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_UINT16_WITHIN(delta, expected, actual) UNITY_TEST_ASSERT_UINT16_WITHIN((delta), (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_UINT32_WITHIN(delta, expected, actual) UNITY_TEST_ASSERT_UINT32_WITHIN((delta), (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_UINT64_WITHIN(delta, expected, actual) UNITY_TEST_ASSERT_UINT64_WITHIN((delta), (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_size_t_WITHIN(delta, expected, actual) UNITY_TEST_ASSERT_UINT_WITHIN((delta), (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_HEX_WITHIN(delta, expected, actual) UNITY_TEST_ASSERT_HEX32_WITHIN((delta), (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_HEX8_WITHIN(delta, expected, actual) UNITY_TEST_ASSERT_HEX8_WITHIN((delta), (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_HEX16_WITHIN(delta, expected, actual) UNITY_TEST_ASSERT_HEX16_WITHIN((delta), (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_HEX32_WITHIN(delta, expected, actual) UNITY_TEST_ASSERT_HEX32_WITHIN((delta), (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_HEX64_WITHIN(delta, expected, actual) UNITY_TEST_ASSERT_HEX64_WITHIN((delta), (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_CHAR_WITHIN(delta, expected, actual) UNITY_TEST_ASSERT_CHAR_WITHIN((delta), (expected), (actual), __LINE__, NULL) + +/* Integer Array Ranges (of all sizes) */ +#define TEST_ASSERT_INT_ARRAY_WITHIN(delta, expected, actual, num_elements) UNITY_TEST_ASSERT_INT_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, NULL) +#define TEST_ASSERT_INT8_ARRAY_WITHIN(delta, expected, actual, num_elements) UNITY_TEST_ASSERT_INT8_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, NULL) +#define TEST_ASSERT_INT16_ARRAY_WITHIN(delta, expected, actual, num_elements) UNITY_TEST_ASSERT_INT16_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, NULL) +#define TEST_ASSERT_INT32_ARRAY_WITHIN(delta, expected, actual, num_elements) UNITY_TEST_ASSERT_INT32_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, NULL) +#define TEST_ASSERT_INT64_ARRAY_WITHIN(delta, expected, actual, num_elements) UNITY_TEST_ASSERT_INT64_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, NULL) +#define TEST_ASSERT_UINT_ARRAY_WITHIN(delta, expected, actual, num_elements) UNITY_TEST_ASSERT_UINT_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, NULL) +#define TEST_ASSERT_UINT8_ARRAY_WITHIN(delta, expected, actual, num_elements) UNITY_TEST_ASSERT_UINT8_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, NULL) +#define TEST_ASSERT_UINT16_ARRAY_WITHIN(delta, expected, actual, num_elements) UNITY_TEST_ASSERT_UINT16_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, NULL) +#define TEST_ASSERT_UINT32_ARRAY_WITHIN(delta, expected, actual, num_elements) UNITY_TEST_ASSERT_UINT32_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, NULL) +#define TEST_ASSERT_UINT64_ARRAY_WITHIN(delta, expected, actual, num_elements) UNITY_TEST_ASSERT_UINT64_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, NULL) +#define TEST_ASSERT_size_t_ARRAY_WITHIN(delta, expected, actual, num_elements) UNITY_TEST_ASSERT_UINT_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, NULL) +#define TEST_ASSERT_HEX_ARRAY_WITHIN(delta, expected, actual, num_elements) UNITY_TEST_ASSERT_HEX32_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, NULL) +#define TEST_ASSERT_HEX8_ARRAY_WITHIN(delta, expected, actual, num_elements) UNITY_TEST_ASSERT_HEX8_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, NULL) +#define TEST_ASSERT_HEX16_ARRAY_WITHIN(delta, expected, actual, num_elements) UNITY_TEST_ASSERT_HEX16_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, NULL) +#define TEST_ASSERT_HEX32_ARRAY_WITHIN(delta, expected, actual, num_elements) UNITY_TEST_ASSERT_HEX32_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, NULL) +#define TEST_ASSERT_HEX64_ARRAY_WITHIN(delta, expected, actual, num_elements) UNITY_TEST_ASSERT_HEX64_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, NULL) +#define TEST_ASSERT_CHAR_ARRAY_WITHIN(delta, expected, actual, num_elements) UNITY_TEST_ASSERT_CHAR_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, NULL) + + +/* Structs and Strings */ +#define TEST_ASSERT_EQUAL_PTR(expected, actual) UNITY_TEST_ASSERT_EQUAL_PTR((expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_STRING(expected, actual) UNITY_TEST_ASSERT_EQUAL_STRING((expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_STRING_LEN(expected, actual, len) UNITY_TEST_ASSERT_EQUAL_STRING_LEN((expected), (actual), (len), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_MEMORY(expected, actual, len) UNITY_TEST_ASSERT_EQUAL_MEMORY((expected), (actual), (len), __LINE__, NULL) + +/* Arrays */ +#define TEST_ASSERT_EQUAL_INT_ARRAY(expected, actual, num_elements) UNITY_TEST_ASSERT_EQUAL_INT_ARRAY((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_INT8_ARRAY(expected, actual, num_elements) UNITY_TEST_ASSERT_EQUAL_INT8_ARRAY((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_INT16_ARRAY(expected, actual, num_elements) UNITY_TEST_ASSERT_EQUAL_INT16_ARRAY((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_INT32_ARRAY(expected, actual, num_elements) UNITY_TEST_ASSERT_EQUAL_INT32_ARRAY((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_INT64_ARRAY(expected, actual, num_elements) UNITY_TEST_ASSERT_EQUAL_INT64_ARRAY((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_UINT_ARRAY(expected, actual, num_elements) UNITY_TEST_ASSERT_EQUAL_UINT_ARRAY((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, actual, num_elements) UNITY_TEST_ASSERT_EQUAL_UINT8_ARRAY((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_UINT16_ARRAY(expected, actual, num_elements) UNITY_TEST_ASSERT_EQUAL_UINT16_ARRAY((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_UINT32_ARRAY(expected, actual, num_elements) UNITY_TEST_ASSERT_EQUAL_UINT32_ARRAY((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_UINT64_ARRAY(expected, actual, num_elements) UNITY_TEST_ASSERT_EQUAL_UINT64_ARRAY((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_size_t_ARRAY(expected, actual, num_elements) UNITY_TEST_ASSERT_EQUAL_UINT_ARRAY((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_HEX_ARRAY(expected, actual, num_elements) UNITY_TEST_ASSERT_EQUAL_HEX32_ARRAY((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_HEX8_ARRAY(expected, actual, num_elements) UNITY_TEST_ASSERT_EQUAL_HEX8_ARRAY((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_HEX16_ARRAY(expected, actual, num_elements) UNITY_TEST_ASSERT_EQUAL_HEX16_ARRAY((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_HEX32_ARRAY(expected, actual, num_elements) UNITY_TEST_ASSERT_EQUAL_HEX32_ARRAY((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_HEX64_ARRAY(expected, actual, num_elements) UNITY_TEST_ASSERT_EQUAL_HEX64_ARRAY((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_PTR_ARRAY(expected, actual, num_elements) UNITY_TEST_ASSERT_EQUAL_PTR_ARRAY((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_STRING_ARRAY(expected, actual, num_elements) UNITY_TEST_ASSERT_EQUAL_STRING_ARRAY((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_MEMORY_ARRAY(expected, actual, len, num_elements) UNITY_TEST_ASSERT_EQUAL_MEMORY_ARRAY((expected), (actual), (len), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_CHAR_ARRAY(expected, actual, num_elements) UNITY_TEST_ASSERT_EQUAL_CHAR_ARRAY((expected), (actual), (num_elements), __LINE__, NULL) + +/* Arrays Compared To Single Value */ +#define TEST_ASSERT_EACH_EQUAL_INT(expected, actual, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_INT((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EACH_EQUAL_INT8(expected, actual, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_INT8((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EACH_EQUAL_INT16(expected, actual, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_INT16((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EACH_EQUAL_INT32(expected, actual, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_INT32((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EACH_EQUAL_INT64(expected, actual, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_INT64((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EACH_EQUAL_UINT(expected, actual, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_UINT((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EACH_EQUAL_UINT8(expected, actual, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_UINT8((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EACH_EQUAL_UINT16(expected, actual, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_UINT16((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EACH_EQUAL_UINT32(expected, actual, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_UINT32((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EACH_EQUAL_UINT64(expected, actual, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_UINT64((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EACH_EQUAL_size_t(expected, actual, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_UINT((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EACH_EQUAL_HEX(expected, actual, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_HEX32((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EACH_EQUAL_HEX8(expected, actual, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_HEX8((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EACH_EQUAL_HEX16(expected, actual, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_HEX16((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EACH_EQUAL_HEX32(expected, actual, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_HEX32((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EACH_EQUAL_HEX64(expected, actual, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_HEX64((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EACH_EQUAL_PTR(expected, actual, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_PTR((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EACH_EQUAL_STRING(expected, actual, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_STRING((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EACH_EQUAL_MEMORY(expected, actual, len, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_MEMORY((expected), (actual), (len), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EACH_EQUAL_CHAR(expected, actual, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_CHAR((expected), (actual), (num_elements), __LINE__, NULL) + +/* Floating Point (If Enabled) */ +#define TEST_ASSERT_FLOAT_WITHIN(delta, expected, actual) UNITY_TEST_ASSERT_FLOAT_WITHIN((delta), (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_FLOAT(expected, actual) UNITY_TEST_ASSERT_EQUAL_FLOAT((expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_FLOAT_ARRAY(expected, actual, num_elements) UNITY_TEST_ASSERT_EQUAL_FLOAT_ARRAY((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EACH_EQUAL_FLOAT(expected, actual, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_FLOAT((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_FLOAT_IS_INF(actual) UNITY_TEST_ASSERT_FLOAT_IS_INF((actual), __LINE__, NULL) +#define TEST_ASSERT_FLOAT_IS_NEG_INF(actual) UNITY_TEST_ASSERT_FLOAT_IS_NEG_INF((actual), __LINE__, NULL) +#define TEST_ASSERT_FLOAT_IS_NAN(actual) UNITY_TEST_ASSERT_FLOAT_IS_NAN((actual), __LINE__, NULL) +#define TEST_ASSERT_FLOAT_IS_DETERMINATE(actual) UNITY_TEST_ASSERT_FLOAT_IS_DETERMINATE((actual), __LINE__, NULL) +#define TEST_ASSERT_FLOAT_IS_NOT_INF(actual) UNITY_TEST_ASSERT_FLOAT_IS_NOT_INF((actual), __LINE__, NULL) +#define TEST_ASSERT_FLOAT_IS_NOT_NEG_INF(actual) UNITY_TEST_ASSERT_FLOAT_IS_NOT_NEG_INF((actual), __LINE__, NULL) +#define TEST_ASSERT_FLOAT_IS_NOT_NAN(actual) UNITY_TEST_ASSERT_FLOAT_IS_NOT_NAN((actual), __LINE__, NULL) +#define TEST_ASSERT_FLOAT_IS_NOT_DETERMINATE(actual) UNITY_TEST_ASSERT_FLOAT_IS_NOT_DETERMINATE((actual), __LINE__, NULL) + +/* Double (If Enabled) */ +#define TEST_ASSERT_DOUBLE_WITHIN(delta, expected, actual) UNITY_TEST_ASSERT_DOUBLE_WITHIN((delta), (expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_DOUBLE(expected, actual) UNITY_TEST_ASSERT_EQUAL_DOUBLE((expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_EQUAL_DOUBLE_ARRAY(expected, actual, num_elements) UNITY_TEST_ASSERT_EQUAL_DOUBLE_ARRAY((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_EACH_EQUAL_DOUBLE(expected, actual, num_elements) UNITY_TEST_ASSERT_EACH_EQUAL_DOUBLE((expected), (actual), (num_elements), __LINE__, NULL) +#define TEST_ASSERT_DOUBLE_IS_INF(actual) UNITY_TEST_ASSERT_DOUBLE_IS_INF((actual), __LINE__, NULL) +#define TEST_ASSERT_DOUBLE_IS_NEG_INF(actual) UNITY_TEST_ASSERT_DOUBLE_IS_NEG_INF((actual), __LINE__, NULL) +#define TEST_ASSERT_DOUBLE_IS_NAN(actual) UNITY_TEST_ASSERT_DOUBLE_IS_NAN((actual), __LINE__, NULL) +#define TEST_ASSERT_DOUBLE_IS_DETERMINATE(actual) UNITY_TEST_ASSERT_DOUBLE_IS_DETERMINATE((actual), __LINE__, NULL) +#define TEST_ASSERT_DOUBLE_IS_NOT_INF(actual) UNITY_TEST_ASSERT_DOUBLE_IS_NOT_INF((actual), __LINE__, NULL) +#define TEST_ASSERT_DOUBLE_IS_NOT_NEG_INF(actual) UNITY_TEST_ASSERT_DOUBLE_IS_NOT_NEG_INF((actual), __LINE__, NULL) +#define TEST_ASSERT_DOUBLE_IS_NOT_NAN(actual) UNITY_TEST_ASSERT_DOUBLE_IS_NOT_NAN((actual), __LINE__, NULL) +#define TEST_ASSERT_DOUBLE_IS_NOT_DETERMINATE(actual) UNITY_TEST_ASSERT_DOUBLE_IS_NOT_DETERMINATE((actual), __LINE__, NULL) + +/* Shorthand */ +#ifdef UNITY_SHORTHAND_AS_OLD +#define TEST_ASSERT_EQUAL(expected, actual) UNITY_TEST_ASSERT_EQUAL_INT((expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_NOT_EQUAL(expected, actual) UNITY_TEST_ASSERT(((expected) != (actual)), __LINE__, " Expected Not-Equal") +#endif +#ifdef UNITY_SHORTHAND_AS_INT +#define TEST_ASSERT_EQUAL(expected, actual) UNITY_TEST_ASSERT_EQUAL_INT((expected), (actual), __LINE__, NULL) +#define TEST_ASSERT_NOT_EQUAL(expected, actual) UNITY_TEST_FAIL(__LINE__, UnityStrErrShorthand) +#endif +#ifdef UNITY_SHORTHAND_AS_MEM +#define TEST_ASSERT_EQUAL(expected, actual) UNITY_TEST_ASSERT_EQUAL_MEMORY((&expected), (&actual), sizeof(expected), __LINE__, NULL) +#define TEST_ASSERT_NOT_EQUAL(expected, actual) UNITY_TEST_FAIL(__LINE__, UnityStrErrShorthand) +#endif +#ifdef UNITY_SHORTHAND_AS_RAW +#define TEST_ASSERT_EQUAL(expected, actual) UNITY_TEST_ASSERT(((expected) == (actual)), __LINE__, " Expected Equal") +#define TEST_ASSERT_NOT_EQUAL(expected, actual) UNITY_TEST_ASSERT(((expected) != (actual)), __LINE__, " Expected Not-Equal") +#endif +#ifdef UNITY_SHORTHAND_AS_NONE +#define TEST_ASSERT_EQUAL(expected, actual) UNITY_TEST_FAIL(__LINE__, UnityStrErrShorthand) +#define TEST_ASSERT_NOT_EQUAL(expected, actual) UNITY_TEST_FAIL(__LINE__, UnityStrErrShorthand) +#endif + +/*------------------------------------------------------- + * Test Asserts (with additional messages) + *-------------------------------------------------------*/ + +/* Boolean */ +#define TEST_ASSERT_MESSAGE(condition, message) UNITY_TEST_ASSERT( (condition), __LINE__, (message)) +#define TEST_ASSERT_TRUE_MESSAGE(condition, message) UNITY_TEST_ASSERT( (condition), __LINE__, (message)) +#define TEST_ASSERT_UNLESS_MESSAGE(condition, message) UNITY_TEST_ASSERT( !(condition), __LINE__, (message)) +#define TEST_ASSERT_FALSE_MESSAGE(condition, message) UNITY_TEST_ASSERT( !(condition), __LINE__, (message)) +#define TEST_ASSERT_NULL_MESSAGE(pointer, message) UNITY_TEST_ASSERT_NULL( (pointer), __LINE__, (message)) +#define TEST_ASSERT_NOT_NULL_MESSAGE(pointer, message) UNITY_TEST_ASSERT_NOT_NULL((pointer), __LINE__, (message)) +#define TEST_ASSERT_EMPTY_MESSAGE(pointer, message) UNITY_TEST_ASSERT_EMPTY( (pointer), __LINE__, (message)) +#define TEST_ASSERT_NOT_EMPTY_MESSAGE(pointer, message) UNITY_TEST_ASSERT_NOT_EMPTY((pointer), __LINE__, (message)) + +/* Integers (of all sizes) */ +#define TEST_ASSERT_EQUAL_INT_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_INT((expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_INT8_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_INT8((expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_INT16_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_INT16((expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_INT32_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_INT32((expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_INT64_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_INT64((expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_UINT_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_UINT( (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_UINT8_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_UINT8( (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_UINT16_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_UINT16( (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_UINT32_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_UINT32( (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_UINT64_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_UINT64( (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_size_t_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_UINT( (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_HEX_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_HEX32((expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_HEX8_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_HEX8( (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_HEX16_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_HEX16((expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_HEX32_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_HEX32((expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_HEX64_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_HEX64((expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_BITS_MESSAGE(mask, expected, actual, message) UNITY_TEST_ASSERT_BITS((mask), (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_BITS_HIGH_MESSAGE(mask, actual, message) UNITY_TEST_ASSERT_BITS((mask), (UNITY_UINT32)(-1), (actual), __LINE__, (message)) +#define TEST_ASSERT_BITS_LOW_MESSAGE(mask, actual, message) UNITY_TEST_ASSERT_BITS((mask), (UNITY_UINT32)(0), (actual), __LINE__, (message)) +#define TEST_ASSERT_BIT_HIGH_MESSAGE(bit, actual, message) UNITY_TEST_ASSERT_BITS(((UNITY_UINT32)1 << (bit)), (UNITY_UINT32)(-1), (actual), __LINE__, (message)) +#define TEST_ASSERT_BIT_LOW_MESSAGE(bit, actual, message) UNITY_TEST_ASSERT_BITS(((UNITY_UINT32)1 << (bit)), (UNITY_UINT32)(0), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_CHAR_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_CHAR((expected), (actual), __LINE__, (message)) + +/* Integer Not Equal To (of all sizes) */ +#define TEST_ASSERT_NOT_EQUAL_INT_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_NOT_EQUAL_INT((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_NOT_EQUAL_INT8_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_NOT_EQUAL_INT8((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_NOT_EQUAL_INT16_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_NOT_EQUAL_INT16((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_NOT_EQUAL_INT32_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_NOT_EQUAL_INT32((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_NOT_EQUAL_INT64_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_NOT_EQUAL_INT64((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_NOT_EQUAL_UINT_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_NOT_EQUAL_UINT((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_NOT_EQUAL_UINT8_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_NOT_EQUAL_UINT8((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_NOT_EQUAL_UINT16_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_NOT_EQUAL_UINT16((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_NOT_EQUAL_UINT32_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_NOT_EQUAL_UINT32((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_NOT_EQUAL_UINT64_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_NOT_EQUAL_UINT64((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_NOT_EQUAL_size_t_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_NOT_EQUAL_UINT((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_NOT_EQUAL_HEX8_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_NOT_EQUAL_HEX8((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_NOT_EQUAL_HEX16_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_NOT_EQUAL_HEX16((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_NOT_EQUAL_HEX32_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_NOT_EQUAL_HEX32((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_NOT_EQUAL_HEX64_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_NOT_EQUAL_HEX64((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_NOT_EQUAL_CHAR_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_NOT_EQUAL_CHAR((threshold), (actual), __LINE__, (message)) + + +/* Integer Greater Than/ Less Than (of all sizes) */ +#define TEST_ASSERT_GREATER_THAN_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_THAN_INT((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_THAN_INT_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_THAN_INT((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_THAN_INT8_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_THAN_INT8((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_THAN_INT16_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_THAN_INT16((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_THAN_INT32_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_THAN_INT32((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_THAN_INT64_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_THAN_INT64((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_THAN_UINT_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_THAN_UINT((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_THAN_UINT8_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_THAN_UINT8((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_THAN_UINT16_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_THAN_UINT16((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_THAN_UINT32_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_THAN_UINT32((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_THAN_UINT64_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_THAN_UINT64((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_THAN_size_t_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_THAN_UINT((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_THAN_HEX8_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_THAN_HEX8((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_THAN_HEX16_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_THAN_HEX16((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_THAN_HEX32_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_THAN_HEX32((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_THAN_HEX64_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_THAN_HEX64((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_THAN_CHAR_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_THAN_CHAR((threshold), (actual), __LINE__, (message)) + +#define TEST_ASSERT_LESS_THAN_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_THAN_INT((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_THAN_INT_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_THAN_INT((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_THAN_INT8_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_THAN_INT8((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_THAN_INT16_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_THAN_INT16((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_THAN_INT32_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_THAN_INT32((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_THAN_INT64_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_THAN_INT64((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_THAN_UINT_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_THAN_UINT((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_THAN_UINT8_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_THAN_UINT8((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_THAN_UINT16_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_THAN_UINT16((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_THAN_UINT32_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_THAN_UINT32((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_THAN_UINT64_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_THAN_UINT64((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_THAN_size_t_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_THAN_UINT((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_THAN_HEX8_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_THAN_HEX8((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_THAN_HEX16_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_THAN_HEX16((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_THAN_HEX32_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_THAN_HEX32((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_THAN_HEX64_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_THAN_HEX64((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_THAN_CHAR_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_THAN_CHAR((threshold), (actual), __LINE__, (message)) + +#define TEST_ASSERT_GREATER_OR_EQUAL_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_INT((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_OR_EQUAL_INT_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_INT((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_OR_EQUAL_INT8_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_INT8((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_OR_EQUAL_INT16_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_INT16((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_OR_EQUAL_INT32_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_INT32((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_OR_EQUAL_INT64_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_INT64((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_OR_EQUAL_UINT_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_UINT((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_OR_EQUAL_UINT8_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_UINT8((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_OR_EQUAL_UINT16_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_UINT16((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_OR_EQUAL_UINT32_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_UINT32((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_OR_EQUAL_UINT64_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_UINT64((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_OR_EQUAL_size_t_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_UINT((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_OR_EQUAL_HEX8_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_HEX8((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_OR_EQUAL_HEX16_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_HEX16((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_OR_EQUAL_HEX32_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_HEX32((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_OR_EQUAL_HEX64_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_HEX64((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_GREATER_OR_EQUAL_CHAR_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_GREATER_OR_EQUAL_CHAR((threshold), (actual), __LINE__, (message)) + +#define TEST_ASSERT_LESS_OR_EQUAL_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_INT((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_OR_EQUAL_INT_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_INT((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_OR_EQUAL_INT8_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_INT8((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_OR_EQUAL_INT16_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_INT16((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_OR_EQUAL_INT32_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_INT32((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_OR_EQUAL_INT64_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_INT64((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_OR_EQUAL_UINT_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_UINT((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_OR_EQUAL_UINT8_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_UINT8((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_OR_EQUAL_UINT16_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_UINT16((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_OR_EQUAL_UINT32_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_UINT32((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_OR_EQUAL_UINT64_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_UINT64((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_OR_EQUAL_size_t_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_UINT((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_OR_EQUAL_HEX8_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_HEX8((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_OR_EQUAL_HEX16_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_HEX16((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_OR_EQUAL_HEX32_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_HEX32((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_OR_EQUAL_HEX64_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_HEX64((threshold), (actual), __LINE__, (message)) +#define TEST_ASSERT_LESS_OR_EQUAL_CHAR_MESSAGE(threshold, actual, message) UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_CHAR((threshold), (actual), __LINE__, (message)) + +/* Integer Ranges (of all sizes) */ +#define TEST_ASSERT_INT_WITHIN_MESSAGE(delta, expected, actual, message) UNITY_TEST_ASSERT_INT_WITHIN((delta), (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_INT8_WITHIN_MESSAGE(delta, expected, actual, message) UNITY_TEST_ASSERT_INT8_WITHIN((delta), (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_INT16_WITHIN_MESSAGE(delta, expected, actual, message) UNITY_TEST_ASSERT_INT16_WITHIN((delta), (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_INT32_WITHIN_MESSAGE(delta, expected, actual, message) UNITY_TEST_ASSERT_INT32_WITHIN((delta), (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_INT64_WITHIN_MESSAGE(delta, expected, actual, message) UNITY_TEST_ASSERT_INT64_WITHIN((delta), (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_UINT_WITHIN_MESSAGE(delta, expected, actual, message) UNITY_TEST_ASSERT_UINT_WITHIN((delta), (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_UINT8_WITHIN_MESSAGE(delta, expected, actual, message) UNITY_TEST_ASSERT_UINT8_WITHIN((delta), (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_UINT16_WITHIN_MESSAGE(delta, expected, actual, message) UNITY_TEST_ASSERT_UINT16_WITHIN((delta), (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_UINT32_WITHIN_MESSAGE(delta, expected, actual, message) UNITY_TEST_ASSERT_UINT32_WITHIN((delta), (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_UINT64_WITHIN_MESSAGE(delta, expected, actual, message) UNITY_TEST_ASSERT_UINT64_WITHIN((delta), (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_size_t_WITHIN_MESSAGE(delta, expected, actual, message) UNITY_TEST_ASSERT_UINT_WITHIN((delta), (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_HEX_WITHIN_MESSAGE(delta, expected, actual, message) UNITY_TEST_ASSERT_HEX32_WITHIN((delta), (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_HEX8_WITHIN_MESSAGE(delta, expected, actual, message) UNITY_TEST_ASSERT_HEX8_WITHIN((delta), (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_HEX16_WITHIN_MESSAGE(delta, expected, actual, message) UNITY_TEST_ASSERT_HEX16_WITHIN((delta), (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_HEX32_WITHIN_MESSAGE(delta, expected, actual, message) UNITY_TEST_ASSERT_HEX32_WITHIN((delta), (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_HEX64_WITHIN_MESSAGE(delta, expected, actual, message) UNITY_TEST_ASSERT_HEX64_WITHIN((delta), (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_CHAR_WITHIN_MESSAGE(delta, expected, actual, message) UNITY_TEST_ASSERT_CHAR_WITHIN((delta), (expected), (actual), __LINE__, (message)) + +/* Integer Array Ranges (of all sizes) */ +#define TEST_ASSERT_INT_ARRAY_WITHIN_MESSAGE(delta, expected, actual, num_elements, message) UNITY_TEST_ASSERT_INT_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, (message)) +#define TEST_ASSERT_INT8_ARRAY_WITHIN_MESSAGE(delta, expected, actual, num_elements, message) UNITY_TEST_ASSERT_INT8_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, (message)) +#define TEST_ASSERT_INT16_ARRAY_WITHIN_MESSAGE(delta, expected, actual, num_elements, message) UNITY_TEST_ASSERT_INT16_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, (message)) +#define TEST_ASSERT_INT32_ARRAY_WITHIN_MESSAGE(delta, expected, actual, num_elements, message) UNITY_TEST_ASSERT_INT32_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, (message)) +#define TEST_ASSERT_INT64_ARRAY_WITHIN_MESSAGE(delta, expected, actual, num_elements, message) UNITY_TEST_ASSERT_INT64_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, (message)) +#define TEST_ASSERT_UINT_ARRAY_WITHIN_MESSAGE(delta, expected, actual, num_elements, message) UNITY_TEST_ASSERT_UINT_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, (message)) +#define TEST_ASSERT_UINT8_ARRAY_WITHIN_MESSAGE(delta, expected, actual, num_elements, message) UNITY_TEST_ASSERT_UINT8_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, (message)) +#define TEST_ASSERT_UINT16_ARRAY_WITHIN_MESSAGE(delta, expected, actual, num_elements, message) UNITY_TEST_ASSERT_UINT16_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, (message)) +#define TEST_ASSERT_UINT32_ARRAY_WITHIN_MESSAGE(delta, expected, actual, num_elements, message) UNITY_TEST_ASSERT_UINT32_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, (message)) +#define TEST_ASSERT_UINT64_ARRAY_WITHIN_MESSAGE(delta, expected, actual, num_elements, message) UNITY_TEST_ASSERT_UINT64_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, (message)) +#define TEST_ASSERT_size_t_ARRAY_WITHIN_MESSAGE(delta, expected, actual, num_elements, message) UNITY_TEST_ASSERT_UINT_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, (message)) +#define TEST_ASSERT_HEX_ARRAY_WITHIN_MESSAGE(delta, expected, actual, num_elements, message) UNITY_TEST_ASSERT_HEX32_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, (message)) +#define TEST_ASSERT_HEX8_ARRAY_WITHIN_MESSAGE(delta, expected, actual, num_elements, message) UNITY_TEST_ASSERT_HEX8_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, (message)) +#define TEST_ASSERT_HEX16_ARRAY_WITHIN_MESSAGE(delta, expected, actual, num_elements, message) UNITY_TEST_ASSERT_HEX16_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, (message)) +#define TEST_ASSERT_HEX32_ARRAY_WITHIN_MESSAGE(delta, expected, actual, num_elements, message) UNITY_TEST_ASSERT_HEX32_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, (message)) +#define TEST_ASSERT_HEX64_ARRAY_WITHIN_MESSAGE(delta, expected, actual, num_elements, message) UNITY_TEST_ASSERT_HEX64_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, (message)) +#define TEST_ASSERT_CHAR_ARRAY_WITHIN_MESSAGE(delta, expected, actual, num_elements, message) UNITY_TEST_ASSERT_CHAR_ARRAY_WITHIN((delta), (expected), (actual), num_elements, __LINE__, (message)) + + +/* Structs and Strings */ +#define TEST_ASSERT_EQUAL_PTR_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_PTR((expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_STRING_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_STRING((expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_STRING_LEN_MESSAGE(expected, actual, len, message) UNITY_TEST_ASSERT_EQUAL_STRING_LEN((expected), (actual), (len), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_MEMORY_MESSAGE(expected, actual, len, message) UNITY_TEST_ASSERT_EQUAL_MEMORY((expected), (actual), (len), __LINE__, (message)) + +/* Arrays */ +#define TEST_ASSERT_EQUAL_INT_ARRAY_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EQUAL_INT_ARRAY((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_INT8_ARRAY_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EQUAL_INT8_ARRAY((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_INT16_ARRAY_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EQUAL_INT16_ARRAY((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_INT32_ARRAY_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EQUAL_INT32_ARRAY((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_INT64_ARRAY_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EQUAL_INT64_ARRAY((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_UINT_ARRAY_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EQUAL_UINT_ARRAY((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_UINT8_ARRAY_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EQUAL_UINT8_ARRAY((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_UINT16_ARRAY_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EQUAL_UINT16_ARRAY((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_UINT32_ARRAY_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EQUAL_UINT32_ARRAY((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_UINT64_ARRAY_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EQUAL_UINT64_ARRAY((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_size_t_ARRAY_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EQUAL_UINT_ARRAY((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_HEX_ARRAY_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EQUAL_HEX32_ARRAY((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_HEX8_ARRAY_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EQUAL_HEX8_ARRAY((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_HEX16_ARRAY_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EQUAL_HEX16_ARRAY((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_HEX32_ARRAY_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EQUAL_HEX32_ARRAY((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_HEX64_ARRAY_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EQUAL_HEX64_ARRAY((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_PTR_ARRAY_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EQUAL_PTR_ARRAY((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_STRING_ARRAY_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EQUAL_STRING_ARRAY((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_MEMORY_ARRAY_MESSAGE(expected, actual, len, num_elements, message) UNITY_TEST_ASSERT_EQUAL_MEMORY_ARRAY((expected), (actual), (len), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_CHAR_ARRAY_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EQUAL_CHAR_ARRAY((expected), (actual), (num_elements), __LINE__, (message)) + +/* Arrays Compared To Single Value*/ +#define TEST_ASSERT_EACH_EQUAL_INT_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_INT((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EACH_EQUAL_INT8_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_INT8((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EACH_EQUAL_INT16_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_INT16((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EACH_EQUAL_INT32_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_INT32((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EACH_EQUAL_INT64_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_INT64((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EACH_EQUAL_UINT_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_UINT((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EACH_EQUAL_UINT8_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_UINT8((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EACH_EQUAL_UINT16_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_UINT16((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EACH_EQUAL_UINT32_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_UINT32((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EACH_EQUAL_UINT64_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_UINT64((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EACH_EQUAL_size_t_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_UINT((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EACH_EQUAL_HEX_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_HEX32((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EACH_EQUAL_HEX8_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_HEX8((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EACH_EQUAL_HEX16_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_HEX16((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EACH_EQUAL_HEX32_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_HEX32((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EACH_EQUAL_HEX64_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_HEX64((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EACH_EQUAL_PTR_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_PTR((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EACH_EQUAL_STRING_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_STRING((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EACH_EQUAL_MEMORY_MESSAGE(expected, actual, len, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_MEMORY((expected), (actual), (len), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EACH_EQUAL_CHAR_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_CHAR((expected), (actual), (num_elements), __LINE__, (message)) + +/* Floating Point (If Enabled) */ +#define TEST_ASSERT_FLOAT_WITHIN_MESSAGE(delta, expected, actual, message) UNITY_TEST_ASSERT_FLOAT_WITHIN((delta), (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_FLOAT_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_FLOAT((expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_FLOAT_ARRAY_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EQUAL_FLOAT_ARRAY((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EACH_EQUAL_FLOAT_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_FLOAT((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_FLOAT_IS_INF_MESSAGE(actual, message) UNITY_TEST_ASSERT_FLOAT_IS_INF((actual), __LINE__, (message)) +#define TEST_ASSERT_FLOAT_IS_NEG_INF_MESSAGE(actual, message) UNITY_TEST_ASSERT_FLOAT_IS_NEG_INF((actual), __LINE__, (message)) +#define TEST_ASSERT_FLOAT_IS_NAN_MESSAGE(actual, message) UNITY_TEST_ASSERT_FLOAT_IS_NAN((actual), __LINE__, (message)) +#define TEST_ASSERT_FLOAT_IS_DETERMINATE_MESSAGE(actual, message) UNITY_TEST_ASSERT_FLOAT_IS_DETERMINATE((actual), __LINE__, (message)) +#define TEST_ASSERT_FLOAT_IS_NOT_INF_MESSAGE(actual, message) UNITY_TEST_ASSERT_FLOAT_IS_NOT_INF((actual), __LINE__, (message)) +#define TEST_ASSERT_FLOAT_IS_NOT_NEG_INF_MESSAGE(actual, message) UNITY_TEST_ASSERT_FLOAT_IS_NOT_NEG_INF((actual), __LINE__, (message)) +#define TEST_ASSERT_FLOAT_IS_NOT_NAN_MESSAGE(actual, message) UNITY_TEST_ASSERT_FLOAT_IS_NOT_NAN((actual), __LINE__, (message)) +#define TEST_ASSERT_FLOAT_IS_NOT_DETERMINATE_MESSAGE(actual, message) UNITY_TEST_ASSERT_FLOAT_IS_NOT_DETERMINATE((actual), __LINE__, (message)) + +/* Double (If Enabled) */ +#define TEST_ASSERT_DOUBLE_WITHIN_MESSAGE(delta, expected, actual, message) UNITY_TEST_ASSERT_DOUBLE_WITHIN((delta), (expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_DOUBLE_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_DOUBLE((expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_EQUAL_DOUBLE_ARRAY_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EQUAL_DOUBLE_ARRAY((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_EACH_EQUAL_DOUBLE_MESSAGE(expected, actual, num_elements, message) UNITY_TEST_ASSERT_EACH_EQUAL_DOUBLE((expected), (actual), (num_elements), __LINE__, (message)) +#define TEST_ASSERT_DOUBLE_IS_INF_MESSAGE(actual, message) UNITY_TEST_ASSERT_DOUBLE_IS_INF((actual), __LINE__, (message)) +#define TEST_ASSERT_DOUBLE_IS_NEG_INF_MESSAGE(actual, message) UNITY_TEST_ASSERT_DOUBLE_IS_NEG_INF((actual), __LINE__, (message)) +#define TEST_ASSERT_DOUBLE_IS_NAN_MESSAGE(actual, message) UNITY_TEST_ASSERT_DOUBLE_IS_NAN((actual), __LINE__, (message)) +#define TEST_ASSERT_DOUBLE_IS_DETERMINATE_MESSAGE(actual, message) UNITY_TEST_ASSERT_DOUBLE_IS_DETERMINATE((actual), __LINE__, (message)) +#define TEST_ASSERT_DOUBLE_IS_NOT_INF_MESSAGE(actual, message) UNITY_TEST_ASSERT_DOUBLE_IS_NOT_INF((actual), __LINE__, (message)) +#define TEST_ASSERT_DOUBLE_IS_NOT_NEG_INF_MESSAGE(actual, message) UNITY_TEST_ASSERT_DOUBLE_IS_NOT_NEG_INF((actual), __LINE__, (message)) +#define TEST_ASSERT_DOUBLE_IS_NOT_NAN_MESSAGE(actual, message) UNITY_TEST_ASSERT_DOUBLE_IS_NOT_NAN((actual), __LINE__, (message)) +#define TEST_ASSERT_DOUBLE_IS_NOT_DETERMINATE_MESSAGE(actual, message) UNITY_TEST_ASSERT_DOUBLE_IS_NOT_DETERMINATE((actual), __LINE__, (message)) + +/* Shorthand */ +#ifdef UNITY_SHORTHAND_AS_OLD +#define TEST_ASSERT_EQUAL_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_INT((expected), (actual), __LINE__, (message)) +#define TEST_ASSERT_NOT_EQUAL_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT(((expected) != (actual)), __LINE__, (message)) +#endif +#ifdef UNITY_SHORTHAND_AS_INT +#define TEST_ASSERT_EQUAL_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_INT((expected), (actual), __LINE__, message) +#define TEST_ASSERT_NOT_EQUAL_MESSAGE(expected, actual, message) UNITY_TEST_FAIL(__LINE__, UnityStrErrShorthand) +#endif +#ifdef UNITY_SHORTHAND_AS_MEM +#define TEST_ASSERT_EQUAL_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT_EQUAL_MEMORY((&expected), (&actual), sizeof(expected), __LINE__, message) +#define TEST_ASSERT_NOT_EQUAL_MESSAGE(expected, actual, message) UNITY_TEST_FAIL(__LINE__, UnityStrErrShorthand) +#endif +#ifdef UNITY_SHORTHAND_AS_RAW +#define TEST_ASSERT_EQUAL_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT(((expected) == (actual)), __LINE__, message) +#define TEST_ASSERT_NOT_EQUAL_MESSAGE(expected, actual, message) UNITY_TEST_ASSERT(((expected) != (actual)), __LINE__, message) +#endif +#ifdef UNITY_SHORTHAND_AS_NONE +#define TEST_ASSERT_EQUAL_MESSAGE(expected, actual, message) UNITY_TEST_FAIL(__LINE__, UnityStrErrShorthand) +#define TEST_ASSERT_NOT_EQUAL_MESSAGE(expected, actual, message) UNITY_TEST_FAIL(__LINE__, UnityStrErrShorthand) +#endif + +/* end of UNITY_FRAMEWORK_H */ +#ifdef __cplusplus +} +#endif +#endif diff --git a/rust/integration-tests/src/bindings_tests/unity/unity_internals.h b/rust/integration-tests/src/bindings_tests/unity/unity_internals.h new file mode 100644 index 000000000..d303e8fe7 --- /dev/null +++ b/rust/integration-tests/src/bindings_tests/unity/unity_internals.h @@ -0,0 +1,1053 @@ +/* ========================================== + Unity Project - A Test Framework for C + Copyright (c) 2007-21 Mike Karlesky, Mark VanderVoord, Greg Williams + [Released under MIT License. Please refer to license.txt for details] +========================================== */ + +#ifndef UNITY_INTERNALS_H +#define UNITY_INTERNALS_H + +#ifdef UNITY_INCLUDE_CONFIG_H +#include "unity_config.h" +#endif + +#ifndef UNITY_EXCLUDE_SETJMP_H +#include +#endif + +#ifndef UNITY_EXCLUDE_MATH_H +#include +#endif + +#ifndef UNITY_EXCLUDE_STDDEF_H +#include +#endif + +#ifdef UNITY_INCLUDE_PRINT_FORMATTED +#include +#endif + +/* Unity Attempts to Auto-Detect Integer Types + * Attempt 1: UINT_MAX, ULONG_MAX in , or default to 32 bits + * Attempt 2: UINTPTR_MAX in , or default to same size as long + * The user may override any of these derived constants: + * UNITY_INT_WIDTH, UNITY_LONG_WIDTH, UNITY_POINTER_WIDTH */ +#ifndef UNITY_EXCLUDE_STDINT_H +#include +#endif + +#ifndef UNITY_EXCLUDE_LIMITS_H +#include +#endif + +#if defined(__GNUC__) || defined(__clang__) + #define UNITY_FUNCTION_ATTR(a) __attribute__((a)) +#else + #define UNITY_FUNCTION_ATTR(a) /* ignore */ +#endif + +#ifndef UNITY_NORETURN + #if defined(__cplusplus) + #if __cplusplus >= 201103L + #define UNITY_NORETURN [[ noreturn ]] + #endif + #elif defined(__STDC_VERSION__) && __STDC_VERSION__ >= 201112L + #include + #define UNITY_NORETURN noreturn + #endif +#endif +#ifndef UNITY_NORETURN + #define UNITY_NORETURN UNITY_FUNCTION_ATTR(noreturn) +#endif + +/*------------------------------------------------------- + * Guess Widths If Not Specified + *-------------------------------------------------------*/ + +/* Determine the size of an int, if not already specified. + * We cannot use sizeof(int), because it is not yet defined + * at this stage in the translation of the C program. + * Also sizeof(int) does return the size in addressable units on all platforms, + * which may not necessarily be the size in bytes. + * Therefore, infer it from UINT_MAX if possible. */ +#ifndef UNITY_INT_WIDTH + #ifdef UINT_MAX + #if (UINT_MAX == 0xFFFF) + #define UNITY_INT_WIDTH (16) + #elif (UINT_MAX == 0xFFFFFFFF) + #define UNITY_INT_WIDTH (32) + #elif (UINT_MAX == 0xFFFFFFFFFFFFFFFF) + #define UNITY_INT_WIDTH (64) + #endif + #else /* Set to default */ + #define UNITY_INT_WIDTH (32) + #endif /* UINT_MAX */ +#endif + +/* Determine the size of a long, if not already specified. */ +#ifndef UNITY_LONG_WIDTH + #ifdef ULONG_MAX + #if (ULONG_MAX == 0xFFFF) + #define UNITY_LONG_WIDTH (16) + #elif (ULONG_MAX == 0xFFFFFFFF) + #define UNITY_LONG_WIDTH (32) + #elif (ULONG_MAX == 0xFFFFFFFFFFFFFFFF) + #define UNITY_LONG_WIDTH (64) + #endif + #else /* Set to default */ + #define UNITY_LONG_WIDTH (32) + #endif /* ULONG_MAX */ +#endif + +/* Determine the size of a pointer, if not already specified. */ +#ifndef UNITY_POINTER_WIDTH + #ifdef UINTPTR_MAX + #if (UINTPTR_MAX <= 0xFFFF) + #define UNITY_POINTER_WIDTH (16) + #elif (UINTPTR_MAX <= 0xFFFFFFFF) + #define UNITY_POINTER_WIDTH (32) + #elif (UINTPTR_MAX <= 0xFFFFFFFFFFFFFFFF) + #define UNITY_POINTER_WIDTH (64) + #endif + #else /* Set to default */ + #define UNITY_POINTER_WIDTH UNITY_LONG_WIDTH + #endif /* UINTPTR_MAX */ +#endif + +/*------------------------------------------------------- + * Int Support (Define types based on detected sizes) + *-------------------------------------------------------*/ + +#if (UNITY_INT_WIDTH == 32) + typedef unsigned char UNITY_UINT8; + typedef unsigned short UNITY_UINT16; + typedef unsigned int UNITY_UINT32; + typedef signed char UNITY_INT8; + typedef signed short UNITY_INT16; + typedef signed int UNITY_INT32; +#elif (UNITY_INT_WIDTH == 16) + typedef unsigned char UNITY_UINT8; + typedef unsigned int UNITY_UINT16; + typedef unsigned long UNITY_UINT32; + typedef signed char UNITY_INT8; + typedef signed int UNITY_INT16; + typedef signed long UNITY_INT32; +#else + #error Invalid UNITY_INT_WIDTH specified! (16 or 32 are supported) +#endif + +/*------------------------------------------------------- + * 64-bit Support + *-------------------------------------------------------*/ + +/* Auto-detect 64 Bit Support */ +#ifndef UNITY_SUPPORT_64 + #if UNITY_LONG_WIDTH == 64 || UNITY_POINTER_WIDTH == 64 + #define UNITY_SUPPORT_64 + #endif +#endif + +/* 64-Bit Support Dependent Configuration */ +#ifndef UNITY_SUPPORT_64 + /* No 64-bit Support */ + typedef UNITY_UINT32 UNITY_UINT; + typedef UNITY_INT32 UNITY_INT; + #define UNITY_MAX_NIBBLES (8) /* Maximum number of nibbles in a UNITY_(U)INT */ +#else + /* 64-bit Support */ + #if (UNITY_LONG_WIDTH == 32) + typedef unsigned long long UNITY_UINT64; + typedef signed long long UNITY_INT64; + #elif (UNITY_LONG_WIDTH == 64) + typedef unsigned long UNITY_UINT64; + typedef signed long UNITY_INT64; + #else + #error Invalid UNITY_LONG_WIDTH specified! (32 or 64 are supported) + #endif + typedef UNITY_UINT64 UNITY_UINT; + typedef UNITY_INT64 UNITY_INT; + #define UNITY_MAX_NIBBLES (16) /* Maximum number of nibbles in a UNITY_(U)INT */ +#endif + +/*------------------------------------------------------- + * Pointer Support + *-------------------------------------------------------*/ + +#if (UNITY_POINTER_WIDTH == 32) + #define UNITY_PTR_TO_INT UNITY_INT32 + #define UNITY_DISPLAY_STYLE_POINTER UNITY_DISPLAY_STYLE_HEX32 +#elif (UNITY_POINTER_WIDTH == 64) + #define UNITY_PTR_TO_INT UNITY_INT64 + #define UNITY_DISPLAY_STYLE_POINTER UNITY_DISPLAY_STYLE_HEX64 +#elif (UNITY_POINTER_WIDTH == 16) + #define UNITY_PTR_TO_INT UNITY_INT16 + #define UNITY_DISPLAY_STYLE_POINTER UNITY_DISPLAY_STYLE_HEX16 +#else + #error Invalid UNITY_POINTER_WIDTH specified! (16, 32 or 64 are supported) +#endif + +#ifndef UNITY_PTR_ATTRIBUTE + #define UNITY_PTR_ATTRIBUTE +#endif + +#ifndef UNITY_INTERNAL_PTR + #define UNITY_INTERNAL_PTR UNITY_PTR_ATTRIBUTE const void* +#endif + +/*------------------------------------------------------- + * Float Support + *-------------------------------------------------------*/ + +#ifdef UNITY_EXCLUDE_FLOAT + +/* No Floating Point Support */ +#ifndef UNITY_EXCLUDE_DOUBLE +#define UNITY_EXCLUDE_DOUBLE /* Remove double when excluding float support */ +#endif +#ifndef UNITY_EXCLUDE_FLOAT_PRINT +#define UNITY_EXCLUDE_FLOAT_PRINT +#endif + +#else + +/* Floating Point Support */ +#ifndef UNITY_FLOAT_PRECISION +#define UNITY_FLOAT_PRECISION (0.00001f) +#endif +#ifndef UNITY_FLOAT_TYPE +#define UNITY_FLOAT_TYPE float +#endif +typedef UNITY_FLOAT_TYPE UNITY_FLOAT; + +/* isinf & isnan macros should be provided by math.h */ +#ifndef isinf +/* The value of Inf - Inf is NaN */ +#define isinf(n) (isnan((n) - (n)) && !isnan(n)) +#endif + +#ifndef isnan +/* NaN is the only floating point value that does NOT equal itself. + * Therefore if n != n, then it is NaN. */ +#define isnan(n) ((n != n) ? 1 : 0) +#endif + +#endif + +/*------------------------------------------------------- + * Double Float Support + *-------------------------------------------------------*/ + +/* unlike float, we DON'T include by default */ +#if defined(UNITY_EXCLUDE_DOUBLE) || !defined(UNITY_INCLUDE_DOUBLE) + + /* No Floating Point Support */ + #ifndef UNITY_EXCLUDE_DOUBLE + #define UNITY_EXCLUDE_DOUBLE + #else + #undef UNITY_INCLUDE_DOUBLE + #endif + + #ifndef UNITY_EXCLUDE_FLOAT + #ifndef UNITY_DOUBLE_TYPE + #define UNITY_DOUBLE_TYPE double + #endif + typedef UNITY_FLOAT UNITY_DOUBLE; + /* For parameter in UnityPrintFloat(UNITY_DOUBLE), which aliases to double or float */ + #endif + +#else + + /* Double Floating Point Support */ + #ifndef UNITY_DOUBLE_PRECISION + #define UNITY_DOUBLE_PRECISION (1e-12) + #endif + + #ifndef UNITY_DOUBLE_TYPE + #define UNITY_DOUBLE_TYPE double + #endif + typedef UNITY_DOUBLE_TYPE UNITY_DOUBLE; + +#endif + +/*------------------------------------------------------- + * Output Method: stdout (DEFAULT) + *-------------------------------------------------------*/ +#ifndef UNITY_OUTPUT_CHAR + /* Default to using putchar, which is defined in stdio.h */ + #include + #define UNITY_OUTPUT_CHAR(a) (void)putchar(a) +#else + /* If defined as something else, make sure we declare it here so it's ready for use */ + #ifdef UNITY_OUTPUT_CHAR_HEADER_DECLARATION + extern void UNITY_OUTPUT_CHAR_HEADER_DECLARATION; + #endif +#endif + +#ifndef UNITY_OUTPUT_FLUSH + #ifdef UNITY_USE_FLUSH_STDOUT + /* We want to use the stdout flush utility */ + #include + #define UNITY_OUTPUT_FLUSH() (void)fflush(stdout) + #else + /* We've specified nothing, therefore flush should just be ignored */ + #define UNITY_OUTPUT_FLUSH() (void)0 + #endif +#else + /* If defined as something else, make sure we declare it here so it's ready for use */ + #ifdef UNITY_OUTPUT_FLUSH_HEADER_DECLARATION + extern void UNITY_OUTPUT_FLUSH_HEADER_DECLARATION; + #endif +#endif + +#ifndef UNITY_OUTPUT_FLUSH +#define UNITY_FLUSH_CALL() +#else +#define UNITY_FLUSH_CALL() UNITY_OUTPUT_FLUSH() +#endif + +#ifndef UNITY_PRINT_EOL +#define UNITY_PRINT_EOL() UNITY_OUTPUT_CHAR('\n') +#endif + +#ifndef UNITY_OUTPUT_START +#define UNITY_OUTPUT_START() +#endif + +#ifndef UNITY_OUTPUT_COMPLETE +#define UNITY_OUTPUT_COMPLETE() +#endif + +#ifdef UNITY_INCLUDE_EXEC_TIME + #if !defined(UNITY_EXEC_TIME_START) && \ + !defined(UNITY_EXEC_TIME_STOP) && \ + !defined(UNITY_PRINT_EXEC_TIME) && \ + !defined(UNITY_TIME_TYPE) + /* If none any of these macros are defined then try to provide a default implementation */ + + #if defined(UNITY_CLOCK_MS) + /* This is a simple way to get a default implementation on platforms that support getting a millisecond counter */ + #define UNITY_TIME_TYPE UNITY_UINT + #define UNITY_EXEC_TIME_START() Unity.CurrentTestStartTime = UNITY_CLOCK_MS() + #define UNITY_EXEC_TIME_STOP() Unity.CurrentTestStopTime = UNITY_CLOCK_MS() + #define UNITY_PRINT_EXEC_TIME() { \ + UNITY_UINT execTimeMs = (Unity.CurrentTestStopTime - Unity.CurrentTestStartTime); \ + UnityPrint(" ("); \ + UnityPrintNumberUnsigned(execTimeMs); \ + UnityPrint(" ms)"); \ + } + #elif defined(_WIN32) + #include + #define UNITY_TIME_TYPE clock_t + #define UNITY_GET_TIME(t) t = (clock_t)((clock() * 1000) / CLOCKS_PER_SEC) + #define UNITY_EXEC_TIME_START() UNITY_GET_TIME(Unity.CurrentTestStartTime) + #define UNITY_EXEC_TIME_STOP() UNITY_GET_TIME(Unity.CurrentTestStopTime) + #define UNITY_PRINT_EXEC_TIME() { \ + UNITY_UINT execTimeMs = (Unity.CurrentTestStopTime - Unity.CurrentTestStartTime); \ + UnityPrint(" ("); \ + UnityPrintNumberUnsigned(execTimeMs); \ + UnityPrint(" ms)"); \ + } + #elif defined(__unix__) || defined(__APPLE__) + #include + #define UNITY_TIME_TYPE struct timespec + #define UNITY_GET_TIME(t) clock_gettime(CLOCK_MONOTONIC, &t) + #define UNITY_EXEC_TIME_START() UNITY_GET_TIME(Unity.CurrentTestStartTime) + #define UNITY_EXEC_TIME_STOP() UNITY_GET_TIME(Unity.CurrentTestStopTime) + #define UNITY_PRINT_EXEC_TIME() { \ + UNITY_UINT execTimeMs = ((Unity.CurrentTestStopTime.tv_sec - Unity.CurrentTestStartTime.tv_sec) * 1000L); \ + execTimeMs += ((Unity.CurrentTestStopTime.tv_nsec - Unity.CurrentTestStartTime.tv_nsec) / 1000000L); \ + UnityPrint(" ("); \ + UnityPrintNumberUnsigned(execTimeMs); \ + UnityPrint(" ms)"); \ + } + #endif + #endif +#endif + +#ifndef UNITY_EXEC_TIME_START +#define UNITY_EXEC_TIME_START() do { /* nothing*/ } while (0) +#endif + +#ifndef UNITY_EXEC_TIME_STOP +#define UNITY_EXEC_TIME_STOP() do { /* nothing*/ } while (0) +#endif + +#ifndef UNITY_TIME_TYPE +#define UNITY_TIME_TYPE UNITY_UINT +#endif + +#ifndef UNITY_PRINT_EXEC_TIME +#define UNITY_PRINT_EXEC_TIME() do { /* nothing*/ } while (0) +#endif + +/*------------------------------------------------------- + * Footprint + *-------------------------------------------------------*/ + +#ifndef UNITY_LINE_TYPE +#define UNITY_LINE_TYPE UNITY_UINT +#endif + +#ifndef UNITY_COUNTER_TYPE +#define UNITY_COUNTER_TYPE UNITY_UINT +#endif + +/*------------------------------------------------------- + * Internal Structs Needed + *-------------------------------------------------------*/ + +typedef void (*UnityTestFunction)(void); + +#define UNITY_DISPLAY_RANGE_INT (0x10) +#define UNITY_DISPLAY_RANGE_UINT (0x20) +#define UNITY_DISPLAY_RANGE_HEX (0x40) +#define UNITY_DISPLAY_RANGE_CHAR (0x80) + +typedef enum +{ + UNITY_DISPLAY_STYLE_INT = (UNITY_INT_WIDTH / 8) + UNITY_DISPLAY_RANGE_INT, + UNITY_DISPLAY_STYLE_INT8 = 1 + UNITY_DISPLAY_RANGE_INT, + UNITY_DISPLAY_STYLE_INT16 = 2 + UNITY_DISPLAY_RANGE_INT, + UNITY_DISPLAY_STYLE_INT32 = 4 + UNITY_DISPLAY_RANGE_INT, +#ifdef UNITY_SUPPORT_64 + UNITY_DISPLAY_STYLE_INT64 = 8 + UNITY_DISPLAY_RANGE_INT, +#endif + + UNITY_DISPLAY_STYLE_UINT = (UNITY_INT_WIDTH / 8) + UNITY_DISPLAY_RANGE_UINT, + UNITY_DISPLAY_STYLE_UINT8 = 1 + UNITY_DISPLAY_RANGE_UINT, + UNITY_DISPLAY_STYLE_UINT16 = 2 + UNITY_DISPLAY_RANGE_UINT, + UNITY_DISPLAY_STYLE_UINT32 = 4 + UNITY_DISPLAY_RANGE_UINT, +#ifdef UNITY_SUPPORT_64 + UNITY_DISPLAY_STYLE_UINT64 = 8 + UNITY_DISPLAY_RANGE_UINT, +#endif + + UNITY_DISPLAY_STYLE_HEX8 = 1 + UNITY_DISPLAY_RANGE_HEX, + UNITY_DISPLAY_STYLE_HEX16 = 2 + UNITY_DISPLAY_RANGE_HEX, + UNITY_DISPLAY_STYLE_HEX32 = 4 + UNITY_DISPLAY_RANGE_HEX, +#ifdef UNITY_SUPPORT_64 + UNITY_DISPLAY_STYLE_HEX64 = 8 + UNITY_DISPLAY_RANGE_HEX, +#endif + + UNITY_DISPLAY_STYLE_CHAR = 1 + UNITY_DISPLAY_RANGE_CHAR + UNITY_DISPLAY_RANGE_INT, + + UNITY_DISPLAY_STYLE_UNKNOWN +} UNITY_DISPLAY_STYLE_T; + +typedef enum +{ + UNITY_WITHIN = 0x0, + UNITY_EQUAL_TO = 0x1, + UNITY_GREATER_THAN = 0x2, + UNITY_GREATER_OR_EQUAL = 0x2 + UNITY_EQUAL_TO, + UNITY_SMALLER_THAN = 0x4, + UNITY_SMALLER_OR_EQUAL = 0x4 + UNITY_EQUAL_TO, + UNITY_NOT_EQUAL = 0x0, + UNITY_UNKNOWN +} UNITY_COMPARISON_T; + +#ifndef UNITY_EXCLUDE_FLOAT +typedef enum UNITY_FLOAT_TRAIT +{ + UNITY_FLOAT_IS_NOT_INF = 0, + UNITY_FLOAT_IS_INF, + UNITY_FLOAT_IS_NOT_NEG_INF, + UNITY_FLOAT_IS_NEG_INF, + UNITY_FLOAT_IS_NOT_NAN, + UNITY_FLOAT_IS_NAN, + UNITY_FLOAT_IS_NOT_DET, + UNITY_FLOAT_IS_DET, + UNITY_FLOAT_INVALID_TRAIT +} UNITY_FLOAT_TRAIT_T; +#endif + +typedef enum +{ + UNITY_ARRAY_TO_VAL = 0, + UNITY_ARRAY_TO_ARRAY, + UNITY_ARRAY_UNKNOWN +} UNITY_FLAGS_T; + +struct UNITY_STORAGE_T +{ + const char* TestFile; + const char* CurrentTestName; +#ifndef UNITY_EXCLUDE_DETAILS + const char* CurrentDetail1; + const char* CurrentDetail2; +#endif + UNITY_LINE_TYPE CurrentTestLineNumber; + UNITY_COUNTER_TYPE NumberOfTests; + UNITY_COUNTER_TYPE TestFailures; + UNITY_COUNTER_TYPE TestIgnores; + UNITY_COUNTER_TYPE CurrentTestFailed; + UNITY_COUNTER_TYPE CurrentTestIgnored; +#ifdef UNITY_INCLUDE_EXEC_TIME + UNITY_TIME_TYPE CurrentTestStartTime; + UNITY_TIME_TYPE CurrentTestStopTime; +#endif +#ifndef UNITY_EXCLUDE_SETJMP_H + jmp_buf AbortFrame; +#endif +}; + +extern struct UNITY_STORAGE_T Unity; + +/*------------------------------------------------------- + * Test Suite Management + *-------------------------------------------------------*/ + +void UnityBegin(const char* filename); +int UnityEnd(void); +void UnitySetTestFile(const char* filename); +void UnityConcludeTest(void); + +#ifndef RUN_TEST +void UnityDefaultTestRun(UnityTestFunction Func, const char* FuncName, const int FuncLineNum); +#else +#define UNITY_SKIP_DEFAULT_RUNNER +#endif + +/*------------------------------------------------------- + * Details Support + *-------------------------------------------------------*/ + +#ifdef UNITY_EXCLUDE_DETAILS +#define UNITY_CLR_DETAILS() +#define UNITY_SET_DETAIL(d1) +#define UNITY_SET_DETAILS(d1,d2) +#else +#define UNITY_CLR_DETAILS() do { Unity.CurrentDetail1 = 0; Unity.CurrentDetail2 = 0; } while (0) +#define UNITY_SET_DETAIL(d1) do { Unity.CurrentDetail1 = (d1); Unity.CurrentDetail2 = 0; } while (0) +#define UNITY_SET_DETAILS(d1,d2) do { Unity.CurrentDetail1 = (d1); Unity.CurrentDetail2 = (d2); } while (0) + +#ifndef UNITY_DETAIL1_NAME +#define UNITY_DETAIL1_NAME "Function" +#endif + +#ifndef UNITY_DETAIL2_NAME +#define UNITY_DETAIL2_NAME "Argument" +#endif +#endif + +#ifdef UNITY_PRINT_TEST_CONTEXT +void UNITY_PRINT_TEST_CONTEXT(void); +#endif + +/*------------------------------------------------------- + * Test Output + *-------------------------------------------------------*/ + +void UnityPrint(const char* string); + +#ifdef UNITY_INCLUDE_PRINT_FORMATTED +void UnityPrintF(const UNITY_LINE_TYPE line, const char* format, ...); +#endif + +void UnityPrintLen(const char* string, const UNITY_UINT32 length); +void UnityPrintMask(const UNITY_UINT mask, const UNITY_UINT number); +void UnityPrintNumberByStyle(const UNITY_INT number, const UNITY_DISPLAY_STYLE_T style); +void UnityPrintNumber(const UNITY_INT number_to_print); +void UnityPrintNumberUnsigned(const UNITY_UINT number); +void UnityPrintNumberHex(const UNITY_UINT number, const char nibbles_to_print); + +#ifndef UNITY_EXCLUDE_FLOAT_PRINT +void UnityPrintFloat(const UNITY_DOUBLE input_number); +#endif + +/*------------------------------------------------------- + * Test Assertion Functions + *------------------------------------------------------- + * Use the macros below this section instead of calling + * these directly. The macros have a consistent naming + * convention and will pull in file and line information + * for you. */ + +void UnityAssertEqualNumber(const UNITY_INT expected, + const UNITY_INT actual, + const char* msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_DISPLAY_STYLE_T style); + +void UnityAssertGreaterOrLessOrEqualNumber(const UNITY_INT threshold, + const UNITY_INT actual, + const UNITY_COMPARISON_T compare, + const char *msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_DISPLAY_STYLE_T style); + +void UnityAssertEqualIntArray(UNITY_INTERNAL_PTR expected, + UNITY_INTERNAL_PTR actual, + const UNITY_UINT32 num_elements, + const char* msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_DISPLAY_STYLE_T style, + const UNITY_FLAGS_T flags); + +void UnityAssertBits(const UNITY_INT mask, + const UNITY_INT expected, + const UNITY_INT actual, + const char* msg, + const UNITY_LINE_TYPE lineNumber); + +void UnityAssertEqualString(const char* expected, + const char* actual, + const char* msg, + const UNITY_LINE_TYPE lineNumber); + +void UnityAssertEqualStringLen(const char* expected, + const char* actual, + const UNITY_UINT32 length, + const char* msg, + const UNITY_LINE_TYPE lineNumber); + +void UnityAssertEqualStringArray( UNITY_INTERNAL_PTR expected, + const char** actual, + const UNITY_UINT32 num_elements, + const char* msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_FLAGS_T flags); + +void UnityAssertEqualMemory( UNITY_INTERNAL_PTR expected, + UNITY_INTERNAL_PTR actual, + const UNITY_UINT32 length, + const UNITY_UINT32 num_elements, + const char* msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_FLAGS_T flags); + +void UnityAssertNumbersWithin(const UNITY_UINT delta, + const UNITY_INT expected, + const UNITY_INT actual, + const char* msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_DISPLAY_STYLE_T style); + +void UnityAssertNumbersArrayWithin(const UNITY_UINT delta, + UNITY_INTERNAL_PTR expected, + UNITY_INTERNAL_PTR actual, + const UNITY_UINT32 num_elements, + const char* msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_DISPLAY_STYLE_T style, + const UNITY_FLAGS_T flags); + +#ifndef UNITY_EXCLUDE_SETJMP_H +UNITY_NORETURN void UnityFail(const char* message, const UNITY_LINE_TYPE line); +UNITY_NORETURN void UnityIgnore(const char* message, const UNITY_LINE_TYPE line); +#else +void UnityFail(const char* message, const UNITY_LINE_TYPE line); +void UnityIgnore(const char* message, const UNITY_LINE_TYPE line); +#endif + +void UnityMessage(const char* message, const UNITY_LINE_TYPE line); + +#ifndef UNITY_EXCLUDE_FLOAT +void UnityAssertFloatsWithin(const UNITY_FLOAT delta, + const UNITY_FLOAT expected, + const UNITY_FLOAT actual, + const char* msg, + const UNITY_LINE_TYPE lineNumber); + +void UnityAssertEqualFloatArray(UNITY_PTR_ATTRIBUTE const UNITY_FLOAT* expected, + UNITY_PTR_ATTRIBUTE const UNITY_FLOAT* actual, + const UNITY_UINT32 num_elements, + const char* msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_FLAGS_T flags); + +void UnityAssertFloatSpecial(const UNITY_FLOAT actual, + const char* msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_FLOAT_TRAIT_T style); +#endif + +#ifndef UNITY_EXCLUDE_DOUBLE +void UnityAssertDoublesWithin(const UNITY_DOUBLE delta, + const UNITY_DOUBLE expected, + const UNITY_DOUBLE actual, + const char* msg, + const UNITY_LINE_TYPE lineNumber); + +void UnityAssertEqualDoubleArray(UNITY_PTR_ATTRIBUTE const UNITY_DOUBLE* expected, + UNITY_PTR_ATTRIBUTE const UNITY_DOUBLE* actual, + const UNITY_UINT32 num_elements, + const char* msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_FLAGS_T flags); + +void UnityAssertDoubleSpecial(const UNITY_DOUBLE actual, + const char* msg, + const UNITY_LINE_TYPE lineNumber, + const UNITY_FLOAT_TRAIT_T style); +#endif + +/*------------------------------------------------------- + * Helpers + *-------------------------------------------------------*/ + +UNITY_INTERNAL_PTR UnityNumToPtr(const UNITY_INT num, const UNITY_UINT8 size); +#ifndef UNITY_EXCLUDE_FLOAT +UNITY_INTERNAL_PTR UnityFloatToPtr(const float num); +#endif +#ifndef UNITY_EXCLUDE_DOUBLE +UNITY_INTERNAL_PTR UnityDoubleToPtr(const double num); +#endif + +/*------------------------------------------------------- + * Error Strings We Might Need + *-------------------------------------------------------*/ + +extern const char UnityStrOk[]; +extern const char UnityStrPass[]; +extern const char UnityStrFail[]; +extern const char UnityStrIgnore[]; + +extern const char UnityStrErrFloat[]; +extern const char UnityStrErrDouble[]; +extern const char UnityStrErr64[]; +extern const char UnityStrErrShorthand[]; + +/*------------------------------------------------------- + * Test Running Macros + *-------------------------------------------------------*/ + +#ifndef UNITY_EXCLUDE_SETJMP_H +#define TEST_PROTECT() (setjmp(Unity.AbortFrame) == 0) +#define TEST_ABORT() longjmp(Unity.AbortFrame, 1) +#else +#define TEST_PROTECT() 1 +#define TEST_ABORT() return +#endif + +/* This tricky series of macros gives us an optional line argument to treat it as RUN_TEST(func, num=__LINE__) */ +#ifndef RUN_TEST +#ifdef __STDC_VERSION__ +#if __STDC_VERSION__ >= 199901L +#define UNITY_SUPPORT_VARIADIC_MACROS +#endif +#endif +#ifdef UNITY_SUPPORT_VARIADIC_MACROS +#define RUN_TEST(...) RUN_TEST_AT_LINE(__VA_ARGS__, __LINE__, throwaway) +#define RUN_TEST_AT_LINE(func, line, ...) UnityDefaultTestRun(func, #func, line) +#endif +#endif + +/* If we can't do the tricky version, we'll just have to require them to always include the line number */ +#ifndef RUN_TEST +#ifdef CMOCK +#define RUN_TEST(func, num) UnityDefaultTestRun(func, #func, num) +#else +#define RUN_TEST(func) UnityDefaultTestRun(func, #func, __LINE__) +#endif +#endif + +#define TEST_LINE_NUM (Unity.CurrentTestLineNumber) +#define TEST_IS_IGNORED (Unity.CurrentTestIgnored) +#define UNITY_NEW_TEST(a) \ + Unity.CurrentTestName = (a); \ + Unity.CurrentTestLineNumber = (UNITY_LINE_TYPE)(__LINE__); \ + Unity.NumberOfTests++; + +#ifndef UNITY_BEGIN +#define UNITY_BEGIN() UnityBegin(__FILE__) +#endif + +#ifndef UNITY_END +#define UNITY_END() UnityEnd() +#endif + +#ifndef UNITY_SHORTHAND_AS_INT +#ifndef UNITY_SHORTHAND_AS_MEM +#ifndef UNITY_SHORTHAND_AS_NONE +#ifndef UNITY_SHORTHAND_AS_RAW +#define UNITY_SHORTHAND_AS_OLD +#endif +#endif +#endif +#endif + +/*----------------------------------------------- + * Command Line Argument Support + *-----------------------------------------------*/ + +#ifdef UNITY_USE_COMMAND_LINE_ARGS +int UnityParseOptions(int argc, char** argv); +int UnityTestMatches(void); +#endif + +/*------------------------------------------------------- + * Basic Fail and Ignore + *-------------------------------------------------------*/ + +#define UNITY_TEST_FAIL(line, message) UnityFail( (message), (UNITY_LINE_TYPE)(line)) +#define UNITY_TEST_IGNORE(line, message) UnityIgnore( (message), (UNITY_LINE_TYPE)(line)) + +/*------------------------------------------------------- + * Test Asserts + *-------------------------------------------------------*/ + +#define UNITY_TEST_ASSERT(condition, line, message) do { if (condition) { /* nothing*/ } else { UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), (message)); } } while (0) +#define UNITY_TEST_ASSERT_NULL(pointer, line, message) UNITY_TEST_ASSERT(((pointer) == NULL), (UNITY_LINE_TYPE)(line), (message)) +#define UNITY_TEST_ASSERT_NOT_NULL(pointer, line, message) UNITY_TEST_ASSERT(((pointer) != NULL), (UNITY_LINE_TYPE)(line), (message)) +#define UNITY_TEST_ASSERT_EMPTY(pointer, line, message) UNITY_TEST_ASSERT(((pointer[0]) == 0), (UNITY_LINE_TYPE)(line), (message)) +#define UNITY_TEST_ASSERT_NOT_EMPTY(pointer, line, message) UNITY_TEST_ASSERT(((pointer[0]) != 0), (UNITY_LINE_TYPE)(line), (message)) + +#define UNITY_TEST_ASSERT_EQUAL_INT(expected, actual, line, message) UnityAssertEqualNumber((UNITY_INT)(expected), (UNITY_INT)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT) +#define UNITY_TEST_ASSERT_EQUAL_INT8(expected, actual, line, message) UnityAssertEqualNumber((UNITY_INT)(UNITY_INT8 )(expected), (UNITY_INT)(UNITY_INT8 )(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT8) +#define UNITY_TEST_ASSERT_EQUAL_INT16(expected, actual, line, message) UnityAssertEqualNumber((UNITY_INT)(UNITY_INT16)(expected), (UNITY_INT)(UNITY_INT16)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT16) +#define UNITY_TEST_ASSERT_EQUAL_INT32(expected, actual, line, message) UnityAssertEqualNumber((UNITY_INT)(UNITY_INT32)(expected), (UNITY_INT)(UNITY_INT32)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT32) +#define UNITY_TEST_ASSERT_EQUAL_UINT(expected, actual, line, message) UnityAssertEqualNumber((UNITY_INT)(expected), (UNITY_INT)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT) +#define UNITY_TEST_ASSERT_EQUAL_UINT8(expected, actual, line, message) UnityAssertEqualNumber((UNITY_INT)(UNITY_UINT8 )(expected), (UNITY_INT)(UNITY_UINT8 )(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT8) +#define UNITY_TEST_ASSERT_EQUAL_UINT16(expected, actual, line, message) UnityAssertEqualNumber((UNITY_INT)(UNITY_UINT16)(expected), (UNITY_INT)(UNITY_UINT16)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT16) +#define UNITY_TEST_ASSERT_EQUAL_UINT32(expected, actual, line, message) UnityAssertEqualNumber((UNITY_INT)(UNITY_UINT32)(expected), (UNITY_INT)(UNITY_UINT32)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT32) +#define UNITY_TEST_ASSERT_EQUAL_HEX8(expected, actual, line, message) UnityAssertEqualNumber((UNITY_INT)(UNITY_INT8 )(expected), (UNITY_INT)(UNITY_INT8 )(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX8) +#define UNITY_TEST_ASSERT_EQUAL_HEX16(expected, actual, line, message) UnityAssertEqualNumber((UNITY_INT)(UNITY_INT16)(expected), (UNITY_INT)(UNITY_INT16)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX16) +#define UNITY_TEST_ASSERT_EQUAL_HEX32(expected, actual, line, message) UnityAssertEqualNumber((UNITY_INT)(UNITY_INT32)(expected), (UNITY_INT)(UNITY_INT32)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX32) +#define UNITY_TEST_ASSERT_EQUAL_CHAR(expected, actual, line, message) UnityAssertEqualNumber((UNITY_INT)(UNITY_INT8 )(expected), (UNITY_INT)(UNITY_INT8 )(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_CHAR) +#define UNITY_TEST_ASSERT_BITS(mask, expected, actual, line, message) UnityAssertBits((UNITY_INT)(mask), (UNITY_INT)(expected), (UNITY_INT)(actual), (message), (UNITY_LINE_TYPE)(line)) + +#define UNITY_TEST_ASSERT_NOT_EQUAL_INT(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(threshold), (UNITY_INT)(actual), UNITY_NOT_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT) +#define UNITY_TEST_ASSERT_NOT_EQUAL_INT8(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_INT8 )(threshold), (UNITY_INT)(UNITY_INT8 )(actual), UNITY_NOT_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT8) +#define UNITY_TEST_ASSERT_NOT_EQUAL_INT16(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_INT16)(threshold), (UNITY_INT)(UNITY_INT16)(actual), UNITY_NOT_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT16) +#define UNITY_TEST_ASSERT_NOT_EQUAL_INT32(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_INT32)(threshold), (UNITY_INT)(UNITY_INT32)(actual), UNITY_NOT_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT32) +#define UNITY_TEST_ASSERT_NOT_EQUAL_UINT(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(threshold), (UNITY_INT)(actual), UNITY_NOT_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT) +#define UNITY_TEST_ASSERT_NOT_EQUAL_UINT8(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT8 )(threshold), (UNITY_INT)(UNITY_UINT8 )(actual), UNITY_NOT_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT8) +#define UNITY_TEST_ASSERT_NOT_EQUAL_UINT16(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT16)(threshold), (UNITY_INT)(UNITY_UINT16)(actual), UNITY_NOT_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT16) +#define UNITY_TEST_ASSERT_NOT_EQUAL_UINT32(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT32)(threshold), (UNITY_INT)(UNITY_UINT32)(actual), UNITY_NOT_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT32) +#define UNITY_TEST_ASSERT_NOT_EQUAL_HEX8(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT8 )(threshold), (UNITY_INT)(UNITY_UINT8 )(actual), UNITY_NOT_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX8) +#define UNITY_TEST_ASSERT_NOT_EQUAL_HEX16(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT16)(threshold), (UNITY_INT)(UNITY_UINT16)(actual), UNITY_NOT_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX16) +#define UNITY_TEST_ASSERT_NOT_EQUAL_HEX32(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT32)(threshold), (UNITY_INT)(UNITY_UINT32)(actual), UNITY_NOT_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX32) +#define UNITY_TEST_ASSERT_NOT_EQUAL_CHAR(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_INT8 )(threshold), (UNITY_INT)(UNITY_INT8 )(actual), UNITY_NOT_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_CHAR) + +#define UNITY_TEST_ASSERT_GREATER_THAN_INT(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(threshold), (UNITY_INT)(actual), UNITY_GREATER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT) +#define UNITY_TEST_ASSERT_GREATER_THAN_INT8(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_INT8 )(threshold), (UNITY_INT)(UNITY_INT8 )(actual), UNITY_GREATER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT8) +#define UNITY_TEST_ASSERT_GREATER_THAN_INT16(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_INT16)(threshold), (UNITY_INT)(UNITY_INT16)(actual), UNITY_GREATER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT16) +#define UNITY_TEST_ASSERT_GREATER_THAN_INT32(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_INT32)(threshold), (UNITY_INT)(UNITY_INT32)(actual), UNITY_GREATER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT32) +#define UNITY_TEST_ASSERT_GREATER_THAN_UINT(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(threshold), (UNITY_INT)(actual), UNITY_GREATER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT) +#define UNITY_TEST_ASSERT_GREATER_THAN_UINT8(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT8 )(threshold), (UNITY_INT)(UNITY_UINT8 )(actual), UNITY_GREATER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT8) +#define UNITY_TEST_ASSERT_GREATER_THAN_UINT16(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT16)(threshold), (UNITY_INT)(UNITY_UINT16)(actual), UNITY_GREATER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT16) +#define UNITY_TEST_ASSERT_GREATER_THAN_UINT32(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT32)(threshold), (UNITY_INT)(UNITY_UINT32)(actual), UNITY_GREATER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT32) +#define UNITY_TEST_ASSERT_GREATER_THAN_HEX8(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT8 )(threshold), (UNITY_INT)(UNITY_UINT8 )(actual), UNITY_GREATER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX8) +#define UNITY_TEST_ASSERT_GREATER_THAN_HEX16(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT16)(threshold), (UNITY_INT)(UNITY_UINT16)(actual), UNITY_GREATER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX16) +#define UNITY_TEST_ASSERT_GREATER_THAN_HEX32(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT32)(threshold), (UNITY_INT)(UNITY_UINT32)(actual), UNITY_GREATER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX32) +#define UNITY_TEST_ASSERT_GREATER_THAN_CHAR(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_INT8 )(threshold), (UNITY_INT)(UNITY_INT8 )(actual), UNITY_GREATER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_CHAR) + +#define UNITY_TEST_ASSERT_SMALLER_THAN_INT(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(threshold), (UNITY_INT)(actual), UNITY_SMALLER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT) +#define UNITY_TEST_ASSERT_SMALLER_THAN_INT8(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_INT8 )(threshold), (UNITY_INT)(UNITY_INT8 )(actual), UNITY_SMALLER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT8) +#define UNITY_TEST_ASSERT_SMALLER_THAN_INT16(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_INT16)(threshold), (UNITY_INT)(UNITY_INT16)(actual), UNITY_SMALLER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT16) +#define UNITY_TEST_ASSERT_SMALLER_THAN_INT32(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_INT32)(threshold), (UNITY_INT)(UNITY_INT32)(actual), UNITY_SMALLER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT32) +#define UNITY_TEST_ASSERT_SMALLER_THAN_UINT(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(threshold), (UNITY_INT)(actual), UNITY_SMALLER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT) +#define UNITY_TEST_ASSERT_SMALLER_THAN_UINT8(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT8 )(threshold), (UNITY_INT)(UNITY_UINT8 )(actual), UNITY_SMALLER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT8) +#define UNITY_TEST_ASSERT_SMALLER_THAN_UINT16(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT16)(threshold), (UNITY_INT)(UNITY_UINT16)(actual), UNITY_SMALLER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT16) +#define UNITY_TEST_ASSERT_SMALLER_THAN_UINT32(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT32)(threshold), (UNITY_INT)(UNITY_UINT32)(actual), UNITY_SMALLER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT32) +#define UNITY_TEST_ASSERT_SMALLER_THAN_HEX8(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT8 )(threshold), (UNITY_INT)(UNITY_UINT8 )(actual), UNITY_SMALLER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX8) +#define UNITY_TEST_ASSERT_SMALLER_THAN_HEX16(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT16)(threshold), (UNITY_INT)(UNITY_UINT16)(actual), UNITY_SMALLER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX16) +#define UNITY_TEST_ASSERT_SMALLER_THAN_HEX32(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT32)(threshold), (UNITY_INT)(UNITY_UINT32)(actual), UNITY_SMALLER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX32) +#define UNITY_TEST_ASSERT_SMALLER_THAN_CHAR(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_INT8 )(threshold), (UNITY_INT)(UNITY_INT8 )(actual), UNITY_SMALLER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_CHAR) + +#define UNITY_TEST_ASSERT_GREATER_OR_EQUAL_INT(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT) (threshold), (UNITY_INT) (actual), UNITY_GREATER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT) +#define UNITY_TEST_ASSERT_GREATER_OR_EQUAL_INT8(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_INT8 ) (threshold), (UNITY_INT)(UNITY_INT8 ) (actual), UNITY_GREATER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT8) +#define UNITY_TEST_ASSERT_GREATER_OR_EQUAL_INT16(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_INT16) (threshold), (UNITY_INT)(UNITY_INT16) (actual), UNITY_GREATER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT16) +#define UNITY_TEST_ASSERT_GREATER_OR_EQUAL_INT32(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_INT32) (threshold), (UNITY_INT)(UNITY_INT32) (actual), UNITY_GREATER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT32) +#define UNITY_TEST_ASSERT_GREATER_OR_EQUAL_UINT(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT) (threshold), (UNITY_INT) (actual), UNITY_GREATER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT) +#define UNITY_TEST_ASSERT_GREATER_OR_EQUAL_UINT8(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT8 )(threshold), (UNITY_INT)(UNITY_UINT8 )(actual), UNITY_GREATER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT8) +#define UNITY_TEST_ASSERT_GREATER_OR_EQUAL_UINT16(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT16)(threshold), (UNITY_INT)(UNITY_UINT16)(actual), UNITY_GREATER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT16) +#define UNITY_TEST_ASSERT_GREATER_OR_EQUAL_UINT32(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT32)(threshold), (UNITY_INT)(UNITY_UINT32)(actual), UNITY_GREATER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT32) +#define UNITY_TEST_ASSERT_GREATER_OR_EQUAL_HEX8(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT8 )(threshold), (UNITY_INT)(UNITY_UINT8 )(actual), UNITY_GREATER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX8) +#define UNITY_TEST_ASSERT_GREATER_OR_EQUAL_HEX16(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT16)(threshold), (UNITY_INT)(UNITY_UINT16)(actual), UNITY_GREATER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX16) +#define UNITY_TEST_ASSERT_GREATER_OR_EQUAL_HEX32(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT32)(threshold), (UNITY_INT)(UNITY_UINT32)(actual), UNITY_GREATER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX32) +#define UNITY_TEST_ASSERT_GREATER_OR_EQUAL_CHAR(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_INT8 ) (threshold), (UNITY_INT)(UNITY_INT8 ) (actual), UNITY_GREATER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_CHAR) + +#define UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_INT(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT) (threshold), (UNITY_INT) (actual), UNITY_SMALLER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT) +#define UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_INT8(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_INT8 )(threshold), (UNITY_INT)(UNITY_INT8 ) (actual), UNITY_SMALLER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT8) +#define UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_INT16(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_INT16)(threshold), (UNITY_INT)(UNITY_INT16) (actual), UNITY_SMALLER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT16) +#define UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_INT32(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_INT32)(threshold), (UNITY_INT)(UNITY_INT32) (actual), UNITY_SMALLER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT32) +#define UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_UINT(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT) (threshold), (UNITY_INT) (actual), UNITY_SMALLER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT) +#define UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_UINT8(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT8 )(threshold), (UNITY_INT)(UNITY_UINT8 )(actual), UNITY_SMALLER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT8) +#define UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_UINT16(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT16)(threshold), (UNITY_INT)(UNITY_UINT16)(actual), UNITY_SMALLER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT16) +#define UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_UINT32(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT32)(threshold), (UNITY_INT)(UNITY_UINT32)(actual), UNITY_SMALLER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT32) +#define UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_HEX8(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT8 )(threshold), (UNITY_INT)(UNITY_UINT8 )(actual), UNITY_SMALLER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX8) +#define UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_HEX16(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT16)(threshold), (UNITY_INT)(UNITY_UINT16)(actual), UNITY_SMALLER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX16) +#define UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_HEX32(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_UINT32)(threshold), (UNITY_INT)(UNITY_UINT32)(actual), UNITY_SMALLER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX32) +#define UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_CHAR(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(UNITY_INT8 )(threshold), (UNITY_INT)(UNITY_INT8 ) (actual), UNITY_SMALLER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_CHAR) + +#define UNITY_TEST_ASSERT_INT_WITHIN(delta, expected, actual, line, message) UnityAssertNumbersWithin( (delta), (UNITY_INT) (expected), (UNITY_INT) (actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT) +#define UNITY_TEST_ASSERT_INT8_WITHIN(delta, expected, actual, line, message) UnityAssertNumbersWithin((UNITY_UINT8 )(delta), (UNITY_INT)(UNITY_INT8 ) (expected), (UNITY_INT)(UNITY_INT8 ) (actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT8) +#define UNITY_TEST_ASSERT_INT16_WITHIN(delta, expected, actual, line, message) UnityAssertNumbersWithin((UNITY_UINT16)(delta), (UNITY_INT)(UNITY_INT16) (expected), (UNITY_INT)(UNITY_INT16) (actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT16) +#define UNITY_TEST_ASSERT_INT32_WITHIN(delta, expected, actual, line, message) UnityAssertNumbersWithin((UNITY_UINT32)(delta), (UNITY_INT)(UNITY_INT32) (expected), (UNITY_INT)(UNITY_INT32) (actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT32) +#define UNITY_TEST_ASSERT_UINT_WITHIN(delta, expected, actual, line, message) UnityAssertNumbersWithin( (delta), (UNITY_INT) (expected), (UNITY_INT) (actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT) +#define UNITY_TEST_ASSERT_UINT8_WITHIN(delta, expected, actual, line, message) UnityAssertNumbersWithin((UNITY_UINT8 )(delta), (UNITY_INT)(UNITY_UINT)(UNITY_UINT8 )(expected), (UNITY_INT)(UNITY_UINT)(UNITY_UINT8 )(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT8) +#define UNITY_TEST_ASSERT_UINT16_WITHIN(delta, expected, actual, line, message) UnityAssertNumbersWithin((UNITY_UINT16)(delta), (UNITY_INT)(UNITY_UINT)(UNITY_UINT16)(expected), (UNITY_INT)(UNITY_UINT)(UNITY_UINT16)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT16) +#define UNITY_TEST_ASSERT_UINT32_WITHIN(delta, expected, actual, line, message) UnityAssertNumbersWithin((UNITY_UINT32)(delta), (UNITY_INT)(UNITY_UINT)(UNITY_UINT32)(expected), (UNITY_INT)(UNITY_UINT)(UNITY_UINT32)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT32) +#define UNITY_TEST_ASSERT_HEX8_WITHIN(delta, expected, actual, line, message) UnityAssertNumbersWithin((UNITY_UINT8 )(delta), (UNITY_INT)(UNITY_UINT)(UNITY_UINT8 )(expected), (UNITY_INT)(UNITY_UINT)(UNITY_UINT8 )(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX8) +#define UNITY_TEST_ASSERT_HEX16_WITHIN(delta, expected, actual, line, message) UnityAssertNumbersWithin((UNITY_UINT16)(delta), (UNITY_INT)(UNITY_UINT)(UNITY_UINT16)(expected), (UNITY_INT)(UNITY_UINT)(UNITY_UINT16)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX16) +#define UNITY_TEST_ASSERT_HEX32_WITHIN(delta, expected, actual, line, message) UnityAssertNumbersWithin((UNITY_UINT32)(delta), (UNITY_INT)(UNITY_UINT)(UNITY_UINT32)(expected), (UNITY_INT)(UNITY_UINT)(UNITY_UINT32)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX32) +#define UNITY_TEST_ASSERT_CHAR_WITHIN(delta, expected, actual, line, message) UnityAssertNumbersWithin((UNITY_UINT8 )(delta), (UNITY_INT)(UNITY_INT8 ) (expected), (UNITY_INT)(UNITY_INT8 ) (actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_CHAR) + +#define UNITY_TEST_ASSERT_INT_ARRAY_WITHIN(delta, expected, actual, num_elements, line, message) UnityAssertNumbersArrayWithin( (delta), (UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), ((UNITY_UINT32)(num_elements)), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_INT8_ARRAY_WITHIN(delta, expected, actual, num_elements, line, message) UnityAssertNumbersArrayWithin((UNITY_UINT8 )(delta), (UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), ((UNITY_UINT32)(num_elements)), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT8, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_INT16_ARRAY_WITHIN(delta, expected, actual, num_elements, line, message) UnityAssertNumbersArrayWithin((UNITY_UINT16)(delta), (UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), ((UNITY_UINT32)(num_elements)), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT16, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_INT32_ARRAY_WITHIN(delta, expected, actual, num_elements, line, message) UnityAssertNumbersArrayWithin((UNITY_UINT32)(delta), (UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), ((UNITY_UINT32)(num_elements)), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT32, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_UINT_ARRAY_WITHIN(delta, expected, actual, num_elements, line, message) UnityAssertNumbersArrayWithin( (delta), (UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), ((UNITY_UINT32)(num_elements)), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_UINT8_ARRAY_WITHIN(delta, expected, actual, num_elements, line, message) UnityAssertNumbersArrayWithin((UNITY_UINT16)(delta), (UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), ((UNITY_UINT32)(num_elements)), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT8, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_UINT16_ARRAY_WITHIN(delta, expected, actual, num_elements, line, message) UnityAssertNumbersArrayWithin((UNITY_UINT16)(delta), (UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), ((UNITY_UINT32)(num_elements)), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT16, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_UINT32_ARRAY_WITHIN(delta, expected, actual, num_elements, line, message) UnityAssertNumbersArrayWithin((UNITY_UINT32)(delta), (UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), ((UNITY_UINT32)(num_elements)), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT32, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_HEX8_ARRAY_WITHIN(delta, expected, actual, num_elements, line, message) UnityAssertNumbersArrayWithin((UNITY_UINT8 )(delta), (UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), ((UNITY_UINT32)(num_elements)), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX8, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_HEX16_ARRAY_WITHIN(delta, expected, actual, num_elements, line, message) UnityAssertNumbersArrayWithin((UNITY_UINT16)(delta), (UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), ((UNITY_UINT32)(num_elements)), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX16, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_HEX32_ARRAY_WITHIN(delta, expected, actual, num_elements, line, message) UnityAssertNumbersArrayWithin((UNITY_UINT32)(delta), (UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), ((UNITY_UINT32)(num_elements)), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX32, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_CHAR_ARRAY_WITHIN(delta, expected, actual, num_elements, line, message) UnityAssertNumbersArrayWithin((UNITY_UINT8 )(delta), (UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), ((UNITY_UINT32)(num_elements)), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_CHAR, UNITY_ARRAY_TO_ARRAY) + + +#define UNITY_TEST_ASSERT_EQUAL_PTR(expected, actual, line, message) UnityAssertEqualNumber((UNITY_PTR_TO_INT)(expected), (UNITY_PTR_TO_INT)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_POINTER) +#define UNITY_TEST_ASSERT_EQUAL_STRING(expected, actual, line, message) UnityAssertEqualString((const char*)(expected), (const char*)(actual), (message), (UNITY_LINE_TYPE)(line)) +#define UNITY_TEST_ASSERT_EQUAL_STRING_LEN(expected, actual, len, line, message) UnityAssertEqualStringLen((const char*)(expected), (const char*)(actual), (UNITY_UINT32)(len), (message), (UNITY_LINE_TYPE)(line)) +#define UNITY_TEST_ASSERT_EQUAL_MEMORY(expected, actual, len, line, message) UnityAssertEqualMemory((UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(len), 1, (message), (UNITY_LINE_TYPE)(line), UNITY_ARRAY_TO_ARRAY) + +#define UNITY_TEST_ASSERT_EQUAL_INT_ARRAY(expected, actual, num_elements, line, message) UnityAssertEqualIntArray((UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_EQUAL_INT8_ARRAY(expected, actual, num_elements, line, message) UnityAssertEqualIntArray((UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT8, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_EQUAL_INT16_ARRAY(expected, actual, num_elements, line, message) UnityAssertEqualIntArray((UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT16, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_EQUAL_INT32_ARRAY(expected, actual, num_elements, line, message) UnityAssertEqualIntArray((UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT32, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_EQUAL_UINT_ARRAY(expected, actual, num_elements, line, message) UnityAssertEqualIntArray((UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_EQUAL_UINT8_ARRAY(expected, actual, num_elements, line, message) UnityAssertEqualIntArray((UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT8, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_EQUAL_UINT16_ARRAY(expected, actual, num_elements, line, message) UnityAssertEqualIntArray((UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT16, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_EQUAL_UINT32_ARRAY(expected, actual, num_elements, line, message) UnityAssertEqualIntArray((UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT32, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_EQUAL_HEX8_ARRAY(expected, actual, num_elements, line, message) UnityAssertEqualIntArray((UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX8, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_EQUAL_HEX16_ARRAY(expected, actual, num_elements, line, message) UnityAssertEqualIntArray((UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX16, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_EQUAL_HEX32_ARRAY(expected, actual, num_elements, line, message) UnityAssertEqualIntArray((UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX32, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_EQUAL_PTR_ARRAY(expected, actual, num_elements, line, message) UnityAssertEqualIntArray((UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_POINTER, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_EQUAL_STRING_ARRAY(expected, actual, num_elements, line, message) UnityAssertEqualStringArray((UNITY_INTERNAL_PTR)(expected), (const char**)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_EQUAL_MEMORY_ARRAY(expected, actual, len, num_elements, line, message) UnityAssertEqualMemory((UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(len), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_EQUAL_CHAR_ARRAY(expected, actual, num_elements, line, message) UnityAssertEqualIntArray((UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_CHAR, UNITY_ARRAY_TO_ARRAY) + +#define UNITY_TEST_ASSERT_EACH_EQUAL_INT(expected, actual, num_elements, line, message) UnityAssertEqualIntArray(UnityNumToPtr((UNITY_INT) (expected), (UNITY_INT_WIDTH / 8)), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT, UNITY_ARRAY_TO_VAL) +#define UNITY_TEST_ASSERT_EACH_EQUAL_INT8(expected, actual, num_elements, line, message) UnityAssertEqualIntArray(UnityNumToPtr((UNITY_INT)(UNITY_INT8 )(expected), 1), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT8, UNITY_ARRAY_TO_VAL) +#define UNITY_TEST_ASSERT_EACH_EQUAL_INT16(expected, actual, num_elements, line, message) UnityAssertEqualIntArray(UnityNumToPtr((UNITY_INT)(UNITY_INT16 )(expected), 2), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT16, UNITY_ARRAY_TO_VAL) +#define UNITY_TEST_ASSERT_EACH_EQUAL_INT32(expected, actual, num_elements, line, message) UnityAssertEqualIntArray(UnityNumToPtr((UNITY_INT)(UNITY_INT32 )(expected), 4), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT32, UNITY_ARRAY_TO_VAL) +#define UNITY_TEST_ASSERT_EACH_EQUAL_UINT(expected, actual, num_elements, line, message) UnityAssertEqualIntArray(UnityNumToPtr((UNITY_INT) (expected), (UNITY_INT_WIDTH / 8)), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT, UNITY_ARRAY_TO_VAL) +#define UNITY_TEST_ASSERT_EACH_EQUAL_UINT8(expected, actual, num_elements, line, message) UnityAssertEqualIntArray(UnityNumToPtr((UNITY_INT)(UNITY_UINT8 )(expected), 1), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT8, UNITY_ARRAY_TO_VAL) +#define UNITY_TEST_ASSERT_EACH_EQUAL_UINT16(expected, actual, num_elements, line, message) UnityAssertEqualIntArray(UnityNumToPtr((UNITY_INT)(UNITY_UINT16)(expected), 2), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT16, UNITY_ARRAY_TO_VAL) +#define UNITY_TEST_ASSERT_EACH_EQUAL_UINT32(expected, actual, num_elements, line, message) UnityAssertEqualIntArray(UnityNumToPtr((UNITY_INT)(UNITY_UINT32)(expected), 4), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT32, UNITY_ARRAY_TO_VAL) +#define UNITY_TEST_ASSERT_EACH_EQUAL_HEX8(expected, actual, num_elements, line, message) UnityAssertEqualIntArray(UnityNumToPtr((UNITY_INT)(UNITY_INT8 )(expected), 1), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX8, UNITY_ARRAY_TO_VAL) +#define UNITY_TEST_ASSERT_EACH_EQUAL_HEX16(expected, actual, num_elements, line, message) UnityAssertEqualIntArray(UnityNumToPtr((UNITY_INT)(UNITY_INT16 )(expected), 2), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX16, UNITY_ARRAY_TO_VAL) +#define UNITY_TEST_ASSERT_EACH_EQUAL_HEX32(expected, actual, num_elements, line, message) UnityAssertEqualIntArray(UnityNumToPtr((UNITY_INT)(UNITY_INT32 )(expected), 4), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX32, UNITY_ARRAY_TO_VAL) +#define UNITY_TEST_ASSERT_EACH_EQUAL_PTR(expected, actual, num_elements, line, message) UnityAssertEqualIntArray(UnityNumToPtr((UNITY_PTR_TO_INT) (expected), (UNITY_POINTER_WIDTH / 8)), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_POINTER, UNITY_ARRAY_TO_VAL) +#define UNITY_TEST_ASSERT_EACH_EQUAL_STRING(expected, actual, num_elements, line, message) UnityAssertEqualStringArray((UNITY_INTERNAL_PTR)(expected), (const char**)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_ARRAY_TO_VAL) +#define UNITY_TEST_ASSERT_EACH_EQUAL_MEMORY(expected, actual, len, num_elements, line, message) UnityAssertEqualMemory((UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(len), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_ARRAY_TO_VAL) +#define UNITY_TEST_ASSERT_EACH_EQUAL_CHAR(expected, actual, num_elements, line, message) UnityAssertEqualIntArray(UnityNumToPtr((UNITY_INT)(UNITY_INT8 )(expected), 1), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_CHAR, UNITY_ARRAY_TO_VAL) + +#ifdef UNITY_SUPPORT_64 +#define UNITY_TEST_ASSERT_EQUAL_INT64(expected, actual, line, message) UnityAssertEqualNumber((UNITY_INT)(expected), (UNITY_INT)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT64) +#define UNITY_TEST_ASSERT_EQUAL_UINT64(expected, actual, line, message) UnityAssertEqualNumber((UNITY_INT)(expected), (UNITY_INT)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT64) +#define UNITY_TEST_ASSERT_EQUAL_HEX64(expected, actual, line, message) UnityAssertEqualNumber((UNITY_INT)(expected), (UNITY_INT)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX64) +#define UNITY_TEST_ASSERT_EQUAL_INT64_ARRAY(expected, actual, num_elements, line, message) UnityAssertEqualIntArray((UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT64, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_EQUAL_UINT64_ARRAY(expected, actual, num_elements, line, message) UnityAssertEqualIntArray((UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT64, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_EQUAL_HEX64_ARRAY(expected, actual, num_elements, line, message) UnityAssertEqualIntArray((UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX64, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_EACH_EQUAL_INT64(expected, actual, num_elements, line, message) UnityAssertEqualIntArray(UnityNumToPtr((UNITY_INT)(UNITY_INT64)(expected), 8), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT64, UNITY_ARRAY_TO_VAL) +#define UNITY_TEST_ASSERT_EACH_EQUAL_UINT64(expected, actual, num_elements, line, message) UnityAssertEqualIntArray(UnityNumToPtr((UNITY_INT)(UNITY_UINT64)(expected), 8), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT64, UNITY_ARRAY_TO_VAL) +#define UNITY_TEST_ASSERT_EACH_EQUAL_HEX64(expected, actual, num_elements, line, message) UnityAssertEqualIntArray(UnityNumToPtr((UNITY_INT)(UNITY_INT64)(expected), 8), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX64, UNITY_ARRAY_TO_VAL) +#define UNITY_TEST_ASSERT_INT64_WITHIN(delta, expected, actual, line, message) UnityAssertNumbersWithin((delta), (UNITY_INT)(expected), (UNITY_INT)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT64) +#define UNITY_TEST_ASSERT_UINT64_WITHIN(delta, expected, actual, line, message) UnityAssertNumbersWithin((delta), (UNITY_INT)(expected), (UNITY_INT)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT64) +#define UNITY_TEST_ASSERT_HEX64_WITHIN(delta, expected, actual, line, message) UnityAssertNumbersWithin((delta), (UNITY_INT)(expected), (UNITY_INT)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX64) +#define UNITY_TEST_ASSERT_NOT_EQUAL_INT64(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(threshold), (UNITY_INT)(actual), UNITY_NOT_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT64) +#define UNITY_TEST_ASSERT_NOT_EQUAL_UINT64(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(threshold), (UNITY_INT)(actual), UNITY_NOT_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT64) +#define UNITY_TEST_ASSERT_NOT_EQUAL_HEX64(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(threshold), (UNITY_INT)(actual), UNITY_NOT_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX64) +#define UNITY_TEST_ASSERT_GREATER_THAN_INT64(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(threshold), (UNITY_INT)(actual), UNITY_GREATER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT64) +#define UNITY_TEST_ASSERT_GREATER_THAN_UINT64(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(threshold), (UNITY_INT)(actual), UNITY_GREATER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT64) +#define UNITY_TEST_ASSERT_GREATER_THAN_HEX64(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(threshold), (UNITY_INT)(actual), UNITY_GREATER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX64) +#define UNITY_TEST_ASSERT_GREATER_OR_EQUAL_INT64(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(threshold), (UNITY_INT)(actual), UNITY_GREATER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT64) +#define UNITY_TEST_ASSERT_GREATER_OR_EQUAL_UINT64(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(threshold), (UNITY_INT)(actual), UNITY_GREATER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT64) +#define UNITY_TEST_ASSERT_GREATER_OR_EQUAL_HEX64(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(threshold), (UNITY_INT)(actual), UNITY_GREATER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX64) +#define UNITY_TEST_ASSERT_SMALLER_THAN_INT64(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(threshold), (UNITY_INT)(actual), UNITY_SMALLER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT64) +#define UNITY_TEST_ASSERT_SMALLER_THAN_UINT64(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(threshold), (UNITY_INT)(actual), UNITY_SMALLER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT64) +#define UNITY_TEST_ASSERT_SMALLER_THAN_HEX64(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(threshold), (UNITY_INT)(actual), UNITY_SMALLER_THAN, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX64) +#define UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_INT64(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(threshold), (UNITY_INT)(actual), UNITY_SMALLER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT64) +#define UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_UINT64(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(threshold), (UNITY_INT)(actual), UNITY_SMALLER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT64) +#define UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_HEX64(threshold, actual, line, message) UnityAssertGreaterOrLessOrEqualNumber((UNITY_INT)(threshold), (UNITY_INT)(actual), UNITY_SMALLER_OR_EQUAL, (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX64) +#define UNITY_TEST_ASSERT_INT64_ARRAY_WITHIN(delta, expected, actual, num_elements, line, message) UnityAssertNumbersArrayWithin((UNITY_UINT64)(delta), (UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_INT64, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_UINT64_ARRAY_WITHIN(delta, expected, actual, num_elements, line, message) UnityAssertNumbersArrayWithin((UNITY_UINT64)(delta), (UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_UINT64, UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_HEX64_ARRAY_WITHIN(delta, expected, actual, num_elements, line, message) UnityAssertNumbersArrayWithin((UNITY_UINT64)(delta), (UNITY_INTERNAL_PTR)(expected), (UNITY_INTERNAL_PTR)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_DISPLAY_STYLE_HEX64, UNITY_ARRAY_TO_ARRAY) +#else +#define UNITY_TEST_ASSERT_EQUAL_INT64(expected, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_EQUAL_UINT64(expected, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_EQUAL_HEX64(expected, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_EQUAL_INT64_ARRAY(expected, actual, num_elements, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_EQUAL_UINT64_ARRAY(expected, actual, num_elements, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_EQUAL_HEX64_ARRAY(expected, actual, num_elements, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_INT64_WITHIN(delta, expected, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_UINT64_WITHIN(delta, expected, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_HEX64_WITHIN(delta, expected, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_GREATER_THAN_INT64(threshold, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_GREATER_THAN_UINT64(threshold, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_GREATER_THAN_HEX64(threshold, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_GREATER_OR_EQUAL_INT64(threshold, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_GREATER_OR_EQUAL_UINT64(threshold, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_GREATER_OR_EQUAL_HEX64(threshold, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_SMALLER_THAN_INT64(threshold, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_SMALLER_THAN_UINT64(threshold, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_SMALLER_THAN_HEX64(threshold, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_INT64(threshold, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_UINT64(threshold, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_SMALLER_OR_EQUAL_HEX64(threshold, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_INT64_ARRAY_WITHIN(delta, expected, actual, num_elements, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_UINT64_ARRAY_WITHIN(delta, expected, actual, num_elements, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#define UNITY_TEST_ASSERT_HEX64_ARRAY_WITHIN(delta, expected, actual, num_elements, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErr64) +#endif + +#ifdef UNITY_EXCLUDE_FLOAT +#define UNITY_TEST_ASSERT_FLOAT_WITHIN(delta, expected, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrFloat) +#define UNITY_TEST_ASSERT_EQUAL_FLOAT(expected, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrFloat) +#define UNITY_TEST_ASSERT_EQUAL_FLOAT_ARRAY(expected, actual, num_elements, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrFloat) +#define UNITY_TEST_ASSERT_EACH_EQUAL_FLOAT(expected, actual, num_elements, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrFloat) +#define UNITY_TEST_ASSERT_FLOAT_IS_INF(actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrFloat) +#define UNITY_TEST_ASSERT_FLOAT_IS_NEG_INF(actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrFloat) +#define UNITY_TEST_ASSERT_FLOAT_IS_NAN(actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrFloat) +#define UNITY_TEST_ASSERT_FLOAT_IS_DETERMINATE(actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrFloat) +#define UNITY_TEST_ASSERT_FLOAT_IS_NOT_INF(actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrFloat) +#define UNITY_TEST_ASSERT_FLOAT_IS_NOT_NEG_INF(actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrFloat) +#define UNITY_TEST_ASSERT_FLOAT_IS_NOT_NAN(actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrFloat) +#define UNITY_TEST_ASSERT_FLOAT_IS_NOT_DETERMINATE(actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrFloat) +#else +#define UNITY_TEST_ASSERT_FLOAT_WITHIN(delta, expected, actual, line, message) UnityAssertFloatsWithin((UNITY_FLOAT)(delta), (UNITY_FLOAT)(expected), (UNITY_FLOAT)(actual), (message), (UNITY_LINE_TYPE)(line)) +#define UNITY_TEST_ASSERT_EQUAL_FLOAT(expected, actual, line, message) UNITY_TEST_ASSERT_FLOAT_WITHIN((UNITY_FLOAT)(expected) * (UNITY_FLOAT)UNITY_FLOAT_PRECISION, (UNITY_FLOAT)(expected), (UNITY_FLOAT)(actual), (UNITY_LINE_TYPE)(line), (message)) +#define UNITY_TEST_ASSERT_EQUAL_FLOAT_ARRAY(expected, actual, num_elements, line, message) UnityAssertEqualFloatArray((UNITY_FLOAT*)(expected), (UNITY_FLOAT*)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_EACH_EQUAL_FLOAT(expected, actual, num_elements, line, message) UnityAssertEqualFloatArray(UnityFloatToPtr(expected), (UNITY_FLOAT*)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_ARRAY_TO_VAL) +#define UNITY_TEST_ASSERT_FLOAT_IS_INF(actual, line, message) UnityAssertFloatSpecial((UNITY_FLOAT)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_FLOAT_IS_INF) +#define UNITY_TEST_ASSERT_FLOAT_IS_NEG_INF(actual, line, message) UnityAssertFloatSpecial((UNITY_FLOAT)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_FLOAT_IS_NEG_INF) +#define UNITY_TEST_ASSERT_FLOAT_IS_NAN(actual, line, message) UnityAssertFloatSpecial((UNITY_FLOAT)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_FLOAT_IS_NAN) +#define UNITY_TEST_ASSERT_FLOAT_IS_DETERMINATE(actual, line, message) UnityAssertFloatSpecial((UNITY_FLOAT)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_FLOAT_IS_DET) +#define UNITY_TEST_ASSERT_FLOAT_IS_NOT_INF(actual, line, message) UnityAssertFloatSpecial((UNITY_FLOAT)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_FLOAT_IS_NOT_INF) +#define UNITY_TEST_ASSERT_FLOAT_IS_NOT_NEG_INF(actual, line, message) UnityAssertFloatSpecial((UNITY_FLOAT)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_FLOAT_IS_NOT_NEG_INF) +#define UNITY_TEST_ASSERT_FLOAT_IS_NOT_NAN(actual, line, message) UnityAssertFloatSpecial((UNITY_FLOAT)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_FLOAT_IS_NOT_NAN) +#define UNITY_TEST_ASSERT_FLOAT_IS_NOT_DETERMINATE(actual, line, message) UnityAssertFloatSpecial((UNITY_FLOAT)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_FLOAT_IS_NOT_DET) +#endif + +#ifdef UNITY_EXCLUDE_DOUBLE +#define UNITY_TEST_ASSERT_DOUBLE_WITHIN(delta, expected, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrDouble) +#define UNITY_TEST_ASSERT_EQUAL_DOUBLE(expected, actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrDouble) +#define UNITY_TEST_ASSERT_EQUAL_DOUBLE_ARRAY(expected, actual, num_elements, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrDouble) +#define UNITY_TEST_ASSERT_EACH_EQUAL_DOUBLE(expected, actual, num_elements, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrDouble) +#define UNITY_TEST_ASSERT_DOUBLE_IS_INF(actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrDouble) +#define UNITY_TEST_ASSERT_DOUBLE_IS_NEG_INF(actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrDouble) +#define UNITY_TEST_ASSERT_DOUBLE_IS_NAN(actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrDouble) +#define UNITY_TEST_ASSERT_DOUBLE_IS_DETERMINATE(actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrDouble) +#define UNITY_TEST_ASSERT_DOUBLE_IS_NOT_INF(actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrDouble) +#define UNITY_TEST_ASSERT_DOUBLE_IS_NOT_NEG_INF(actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrDouble) +#define UNITY_TEST_ASSERT_DOUBLE_IS_NOT_NAN(actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrDouble) +#define UNITY_TEST_ASSERT_DOUBLE_IS_NOT_DETERMINATE(actual, line, message) UNITY_TEST_FAIL((UNITY_LINE_TYPE)(line), UnityStrErrDouble) +#else +#define UNITY_TEST_ASSERT_DOUBLE_WITHIN(delta, expected, actual, line, message) UnityAssertDoublesWithin((UNITY_DOUBLE)(delta), (UNITY_DOUBLE)(expected), (UNITY_DOUBLE)(actual), (message), (UNITY_LINE_TYPE)(line)) +#define UNITY_TEST_ASSERT_EQUAL_DOUBLE(expected, actual, line, message) UNITY_TEST_ASSERT_DOUBLE_WITHIN((UNITY_DOUBLE)(expected) * (UNITY_DOUBLE)UNITY_DOUBLE_PRECISION, (UNITY_DOUBLE)(expected), (UNITY_DOUBLE)(actual), (UNITY_LINE_TYPE)(line), (message)) +#define UNITY_TEST_ASSERT_EQUAL_DOUBLE_ARRAY(expected, actual, num_elements, line, message) UnityAssertEqualDoubleArray((UNITY_DOUBLE*)(expected), (UNITY_DOUBLE*)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_ARRAY_TO_ARRAY) +#define UNITY_TEST_ASSERT_EACH_EQUAL_DOUBLE(expected, actual, num_elements, line, message) UnityAssertEqualDoubleArray(UnityDoubleToPtr(expected), (UNITY_DOUBLE*)(actual), (UNITY_UINT32)(num_elements), (message), (UNITY_LINE_TYPE)(line), UNITY_ARRAY_TO_VAL) +#define UNITY_TEST_ASSERT_DOUBLE_IS_INF(actual, line, message) UnityAssertDoubleSpecial((UNITY_DOUBLE)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_FLOAT_IS_INF) +#define UNITY_TEST_ASSERT_DOUBLE_IS_NEG_INF(actual, line, message) UnityAssertDoubleSpecial((UNITY_DOUBLE)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_FLOAT_IS_NEG_INF) +#define UNITY_TEST_ASSERT_DOUBLE_IS_NAN(actual, line, message) UnityAssertDoubleSpecial((UNITY_DOUBLE)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_FLOAT_IS_NAN) +#define UNITY_TEST_ASSERT_DOUBLE_IS_DETERMINATE(actual, line, message) UnityAssertDoubleSpecial((UNITY_DOUBLE)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_FLOAT_IS_DET) +#define UNITY_TEST_ASSERT_DOUBLE_IS_NOT_INF(actual, line, message) UnityAssertDoubleSpecial((UNITY_DOUBLE)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_FLOAT_IS_NOT_INF) +#define UNITY_TEST_ASSERT_DOUBLE_IS_NOT_NEG_INF(actual, line, message) UnityAssertDoubleSpecial((UNITY_DOUBLE)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_FLOAT_IS_NOT_NEG_INF) +#define UNITY_TEST_ASSERT_DOUBLE_IS_NOT_NAN(actual, line, message) UnityAssertDoubleSpecial((UNITY_DOUBLE)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_FLOAT_IS_NOT_NAN) +#define UNITY_TEST_ASSERT_DOUBLE_IS_NOT_DETERMINATE(actual, line, message) UnityAssertDoubleSpecial((UNITY_DOUBLE)(actual), (message), (UNITY_LINE_TYPE)(line), UNITY_FLOAT_IS_NOT_DET) +#endif + +/* End of UNITY_INTERNALS_H */ +#endif diff --git a/rust/integration-tests/src/bindings_tests/uuid.c b/rust/integration-tests/src/bindings_tests/uuid.c new file mode 100644 index 000000000..83b02482d --- /dev/null +++ b/rust/integration-tests/src/bindings_tests/uuid.c @@ -0,0 +1,72 @@ +#include +#include "unity.h" +#include "taskchampion.h" + +// creating UUIDs does not crash +static void test_uuid_creation(void) { + tc_uuid_new_v4(); + tc_uuid_nil(); +} + +// converting UUIDs to a buf works +static void test_uuid_to_buf(void) { + TEST_ASSERT_EQUAL(TC_UUID_STRING_BYTES, 36); + + TCUuid u2 = tc_uuid_nil(); + + char u2str[TC_UUID_STRING_BYTES]; + tc_uuid_to_buf(u2, u2str); + TEST_ASSERT_EQUAL_MEMORY("00000000-0000-0000-0000-000000000000", u2str, TC_UUID_STRING_BYTES); +} + +// converting UUIDs to a buf works +static void test_uuid_to_str(void) { + TCUuid u = tc_uuid_nil(); + TCString s = tc_uuid_to_str(u); + TEST_ASSERT_EQUAL_STRING( + "00000000-0000-0000-0000-000000000000", + tc_string_content(&s)); + tc_string_free(&s); +} + +// converting valid UUIDs from string works +static void test_uuid_valid_from_str(void) { + TCUuid u; + char *ustr = "23cb25e0-5d1a-4932-8131-594ac6d3a843"; + TEST_ASSERT_EQUAL(TC_RESULT_OK, tc_uuid_from_str(tc_string_borrow(ustr), &u)); + TEST_ASSERT_EQUAL(0x23, u.bytes[0]); + TEST_ASSERT_EQUAL(0x43, u.bytes[15]); +} + +// converting invalid UUIDs from string fails as expected +static void test_uuid_invalid_string_fails(void) { + TCUuid u; + char *ustr = "not-a-valid-uuid"; + TEST_ASSERT_EQUAL(TC_RESULT_ERROR, tc_uuid_from_str(tc_string_borrow(ustr), &u)); +} + +// converting invalid UTF-8 UUIDs from string fails as expected +static void test_uuid_bad_utf8(void) { + TCUuid u; + char *ustr = "\xf0\x28\x8c\xbc"; + TEST_ASSERT_EQUAL(TC_RESULT_ERROR, tc_uuid_from_str(tc_string_borrow(ustr), &u)); +} + +// converting a string with embedded NUL fails as expected +static void test_uuid_embedded_nul(void) { + TCUuid u; + TEST_ASSERT_EQUAL(TC_RESULT_ERROR, tc_uuid_from_str(tc_string_clone_with_len("ab\0de", 5), &u)); +} + +int uuid_tests(void) { + UNITY_BEGIN(); + // each test case above should be named here, in order. + RUN_TEST(test_uuid_creation); + RUN_TEST(test_uuid_valid_from_str); + RUN_TEST(test_uuid_to_buf); + RUN_TEST(test_uuid_to_str); + RUN_TEST(test_uuid_invalid_string_fails); + RUN_TEST(test_uuid_bad_utf8); + RUN_TEST(test_uuid_embedded_nul); + return UNITY_END(); +} diff --git a/rust/integration-tests/src/lib.rs b/rust/integration-tests/src/lib.rs new file mode 100644 index 000000000..71b0b274c --- /dev/null +++ b/rust/integration-tests/src/lib.rs @@ -0,0 +1 @@ +pub mod bindings_tests; diff --git a/rust/integration-tests/tests/bindings.rs b/rust/integration-tests/tests/bindings.rs new file mode 100644 index 000000000..a121dd721 --- /dev/null +++ b/rust/integration-tests/tests/bindings.rs @@ -0,0 +1,31 @@ +use lazy_static::lazy_static; +use std::sync::Mutex; +use tempfile::TempDir; + +lazy_static! { + // the C library running the tests is not reentrant, so we use a mutex to ensure that only one + // test runs at a time. + static ref MUTEX: Mutex<()> = Mutex::new(()); +} + +macro_rules! suite( + { $s:ident } => { + #[test] + fn $s() { + let tmp_dir = TempDir::new().expect("TempDir failed"); + let (res, output) = { + let _guard = MUTEX.lock().unwrap(); + // run the tests in the temp dir (NOTE: this must be inside + // the mutex guard!) + std::env::set_current_dir(tmp_dir.as_ref()).unwrap(); + integration_tests::bindings_tests::$s() + }; + println!("{}", output); + if res != 0 { + assert!(false, "test failed"); + } + } + }; +); + +include!(concat!(env!("OUT_DIR"), "/bindings_test_suites.rs")); diff --git a/rust/integration-tests/tests/cross-sync.rs b/rust/integration-tests/tests/cross-sync.rs new file mode 100644 index 000000000..10b3317d2 --- /dev/null +++ b/rust/integration-tests/tests/cross-sync.rs @@ -0,0 +1,90 @@ +use actix_web::{App, HttpServer}; +use pretty_assertions::assert_eq; +use taskchampion::{Replica, ServerConfig, Status, StorageConfig, Uuid}; +use taskchampion_sync_server::{storage::InMemoryStorage, Server}; + +#[actix_rt::test] +async fn cross_sync() -> anyhow::Result<()> { + let _ = env_logger::builder() + .is_test(true) + .filter_level(log::LevelFilter::Trace) + .try_init(); + + let server = Server::new(Default::default(), Box::new(InMemoryStorage::new())); + let httpserver = + HttpServer::new(move || App::new().configure(|sc| server.config(sc))).bind("0.0.0.0:0")?; + + // bind was to :0, so the kernel will have selected an unused port + let port = httpserver.addrs()[0].port(); + + httpserver.run(); + + // set up two replicas, and demonstrate replication between them + let mut rep1 = Replica::new(StorageConfig::InMemory.into_storage()?); + let mut rep2 = Replica::new(StorageConfig::InMemory.into_storage()?); + + let client_key = Uuid::new_v4(); + let encryption_secret = b"abc123".to_vec(); + let make_server = || { + ServerConfig::Remote { + origin: format!("http://127.0.0.1:{}", port), + client_key, + encryption_secret: encryption_secret.clone(), + } + .into_server() + }; + + let mut serv1 = make_server()?; + let mut serv2 = make_server()?; + + // add some tasks on rep1 + let t1 = rep1.new_task(Status::Pending, "test 1".into())?; + let t2 = rep1.new_task(Status::Pending, "test 2".into())?; + + // modify t1 + let mut t1 = t1.into_mut(&mut rep1); + t1.start()?; + let t1 = t1.into_immut(); + + rep1.sync(&mut serv1, false)?; + rep2.sync(&mut serv2, false)?; + + // those tasks should exist on rep2 now + let t12 = rep2 + .get_task(t1.get_uuid())? + .expect("expected task 1 on rep2"); + let t22 = rep2 + .get_task(t2.get_uuid())? + .expect("expected task 2 on rep2"); + + assert_eq!(t12.get_description(), "test 1"); + assert_eq!(t12.is_active(), true); + assert_eq!(t22.get_description(), "test 2"); + assert_eq!(t22.is_active(), false); + + // make non-conflicting changes on the two replicas + let mut t2 = t2.into_mut(&mut rep1); + t2.set_status(Status::Completed)?; + let t2 = t2.into_immut(); + + let mut t12 = t12.into_mut(&mut rep2); + t12.set_status(Status::Completed)?; + + // sync those changes back and forth + rep1.sync(&mut serv1, false)?; // rep1 -> server + rep2.sync(&mut serv2, false)?; // server -> rep2, rep2 -> server + rep1.sync(&mut serv1, false)?; // server -> rep1 + + let t1 = rep1 + .get_task(t1.get_uuid())? + .expect("expected task 1 on rep1"); + assert_eq!(t1.get_status(), Status::Completed); + + let t22 = rep2 + .get_task(t2.get_uuid())? + .expect("expected task 2 on rep2"); + assert_eq!(t22.get_status(), Status::Completed); + + // note that we just drop the server here.. + Ok(()) +} diff --git a/rust/integration-tests/tests/snapshots.rs b/rust/integration-tests/tests/snapshots.rs new file mode 100644 index 000000000..dbe889231 --- /dev/null +++ b/rust/integration-tests/tests/snapshots.rs @@ -0,0 +1,94 @@ +use actix_web::{App, HttpServer}; +use pretty_assertions::assert_eq; +use taskchampion::{Replica, ServerConfig, Status, StorageConfig, Uuid}; +use taskchampion_sync_server::{ + storage::InMemoryStorage, Server, ServerConfig as SyncServerConfig, +}; + +const NUM_VERSIONS: u32 = 50; + +#[actix_rt::test] +async fn sync_with_snapshots() -> anyhow::Result<()> { + let _ = env_logger::builder() + .is_test(true) + .filter_level(log::LevelFilter::Trace) + .try_init(); + + let sync_server_config = SyncServerConfig { + snapshot_days: 100, + snapshot_versions: 3, + }; + let server = Server::new(sync_server_config, Box::new(InMemoryStorage::new())); + let httpserver = + HttpServer::new(move || App::new().configure(|sc| server.config(sc))).bind("0.0.0.0:0")?; + + // bind was to :0, so the kernel will have selected an unused port + let port = httpserver.addrs()[0].port(); + + httpserver.run(); + + let client_key = Uuid::new_v4(); + let encryption_secret = b"abc123".to_vec(); + let make_server = || { + ServerConfig::Remote { + origin: format!("http://127.0.0.1:{}", port), + client_key, + encryption_secret: encryption_secret.clone(), + } + .into_server() + }; + + // first we set up a single replica and sync it a lot of times, to establish a sync history. + let mut rep1 = Replica::new(StorageConfig::InMemory.into_storage()?); + let mut serv1 = make_server()?; + + let mut t1 = rep1.new_task(Status::Pending, "test 1".into())?; + log::info!("Applying modifications on replica 1"); + for i in 0..=NUM_VERSIONS { + let mut t1m = t1.into_mut(&mut rep1); + t1m.start()?; + t1m.stop()?; + t1m.set_description(format!("revision {}", i))?; + t1 = t1m.into_immut(); + + rep1.sync(&mut serv1, false)?; + } + + // now set up a second replica and sync it; it should catch up on that history, using a + // snapshot. Note that we can't verify that it used a snapshot, because the server currently + // keeps all versions (so rep2 could sync from the beginning of the version history). You can + // manually verify that it is applying a snapshot by adding `assert!(false)` below and skimming + // the logs. + + let mut rep2 = Replica::new(StorageConfig::InMemory.into_storage()?); + let mut serv2 = make_server()?; + + log::info!("Syncing replica 2"); + rep2.sync(&mut serv2, false)?; + + // those tasks should exist on rep2 now + let t12 = rep2 + .get_task(t1.get_uuid())? + .expect("expected task 1 on rep2"); + + assert_eq!(t12.get_description(), format!("revision {}", NUM_VERSIONS)); + assert_eq!(t12.is_active(), false); + + // sync that back to replica 1 + t12.into_mut(&mut rep2) + .set_description("sync-back".to_owned())?; + rep2.sync(&mut serv2, false)?; + rep1.sync(&mut serv1, false)?; + + let t11 = rep1 + .get_task(t1.get_uuid())? + .expect("expected task 1 on rep1"); + + assert_eq!(t11.get_description(), "sync-back"); + + // uncomment this to force a failure and see the logs + // assert!(false); + + // note that we just drop the server here.. + Ok(()) +} diff --git a/rust/integration-tests/tests/update-and-delete-sync.rs b/rust/integration-tests/tests/update-and-delete-sync.rs new file mode 100644 index 000000000..91727c7e1 --- /dev/null +++ b/rust/integration-tests/tests/update-and-delete-sync.rs @@ -0,0 +1,72 @@ +use taskchampion::chrono::{TimeZone, Utc}; +use taskchampion::{Replica, ServerConfig, Status, StorageConfig}; +use tempfile::TempDir; + +#[test] +fn update_and_delete_sync_delete_first() -> anyhow::Result<()> { + update_and_delete_sync(true) +} + +#[test] +fn update_and_delete_sync_update_first() -> anyhow::Result<()> { + update_and_delete_sync(false) +} + +/// Test what happens when an update is sync'd into a repo after a task is deleted. +/// If delete_first, then the deletion is sync'd to the server first; otherwise +/// the update is sync'd first. Either way, the task is gone. +fn update_and_delete_sync(delete_first: bool) -> anyhow::Result<()> { + // set up two replicas, and demonstrate replication between them + let mut rep1 = Replica::new(StorageConfig::InMemory.into_storage()?); + let mut rep2 = Replica::new(StorageConfig::InMemory.into_storage()?); + + let tmp_dir = TempDir::new().expect("TempDir failed"); + let mut server = ServerConfig::Local { + server_dir: tmp_dir.path().to_path_buf(), + } + .into_server()?; + + // add a task on rep1, and sync it to rep2 + let t = rep1.new_task(Status::Pending, "test task".into())?; + let u = t.get_uuid(); + + rep1.sync(&mut server, false)?; + rep2.sync(&mut server, false)?; + + // mark the task as deleted, long in the past, on rep2 + { + let mut t = rep2.get_task(u)?.unwrap().into_mut(&mut rep2); + t.delete()?; + t.set_modified(Utc.ymd(1980, 1, 1).and_hms(0, 0, 0))?; + } + + // sync it back to rep1 + rep2.sync(&mut server, false)?; + rep1.sync(&mut server, false)?; + + // expire the task on rep1 and check that it is gone locally + rep1.expire_tasks()?; + assert!(rep1.get_task(u)?.is_none()); + + // modify the task on rep2 + { + let mut t = rep2.get_task(u)?.unwrap().into_mut(&mut rep2); + t.set_description("modified".to_string())?; + } + + // sync back and forth + if delete_first { + rep1.sync(&mut server, false)?; + } + rep2.sync(&mut server, false)?; + rep1.sync(&mut server, false)?; + if !delete_first { + rep2.sync(&mut server, false)?; + } + + // check that the task is gone on both replicas + assert!(rep1.get_task(u)?.is_none()); + assert!(rep2.get_task(u)?.is_none()); + + Ok(()) +} diff --git a/rust/lib/Cargo.toml b/rust/lib/Cargo.toml new file mode 100644 index 000000000..bfc8cc829 --- /dev/null +++ b/rust/lib/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "taskchampion-lib" +version = "0.1.0" +edition = "2018" + +[lib] +name = "taskchampionlib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[dependencies] +libc = "0.2.113" +taskchampion = { path = "../taskchampion" } +anyhow = "1.0" + +[dev-dependencies] +pretty_assertions = "1" diff --git a/rust/lib/Makefile b/rust/lib/Makefile new file mode 100644 index 000000000..d2dbe101b --- /dev/null +++ b/rust/lib/Makefile @@ -0,0 +1,2 @@ +taskchampion.h: cbindgen.toml ../target/debug/libtaskchampion.so + cbindgen --config cbindgen.toml --crate taskchampion-lib --output $@ diff --git a/rust/lib/header-intro.h b/rust/lib/header-intro.h new file mode 100644 index 000000000..c0dd01153 --- /dev/null +++ b/rust/lib/header-intro.h @@ -0,0 +1,76 @@ +/** + * TaskChampion + * + * This file defines the C interface to libtaskchampion. This is a thin + * wrapper around the Rust `taskchampion` crate. Refer to the documentation + * for that crate at https://docs.rs/taskchampion/latest/taskchampion/ for API + * details. The comments in this file focus mostly on the low-level details of + * passing values to and from TaskChampion. + * + * # Overview + * + * This library defines two major types used to interact with the API, that map directly + * to Rust types. + * + * * TCReplica - see https://docs.rs/taskchampion/latest/taskchampion/struct.Replica.html + * * TCTask - see https://docs.rs/taskchampion/latest/taskchampion/struct.Task.html + * * TCServer - see https://docs.rs/taskchampion/latest/taskchampion/trait.Server.html + * * TCWorkingSet - see https://docs.rs/taskchampion/latest/taskchampion/struct.WorkingSet.html + * + * It also defines a few utility types: + * + * * TCString - a wrapper around both C (NUL-terminated) and Rust (always utf-8) strings. + * * TC…List - a list of objects represented as a C array + * * see below for the remainder + * + * # Safety + * + * Each type contains specific instructions to ensure memory safety. + * The general rules are as follows. + * + * No types in this library are threadsafe. All values should be used in only + * one thread for their entire lifetime. It is safe to use unrelated values in + * different threads (for example, different threads may use different + * TCReplica values concurrently). + * + * ## Pass by Pointer + * + * Several types such as TCReplica and TCString are "opaque" types and always + * handled as pointers in C. The bytes these pointers address are private to + * the Rust implemetation and must not be accessed from C. + * + * Pass-by-pointer values have exactly one owner, and that owner is responsible + * for freeing the value (using a `tc_…_free` function), or transferring + * ownership elsewhere. Except where documented otherwise, when a value is + * passed to C, ownership passes to C as well. When a value is passed to Rust, + * ownership stays with the C code. The exception is TCString, ownership of + * which passes to Rust when it is used as a function argument. + * + * The limited circumstances where one value must not outlive another, due to + * pointer references between them, are documented below. + * + * ## Pass by Value + * + * Types such as TCUuid and TC…List are passed by value, and contain fields + * that are accessible from C. C code is free to access the content of these + * types in a _read_only_ fashion. + * + * Pass-by-value values that contain pointers also have exactly one owner, + * responsible for freeing the value or transferring ownership. The tc_…_free + * functions for these types will replace the pointers with NULL to guard + * against use-after-free errors. The interior pointers in such values should + * never be freed directly (for example, `tc_string_free(tcuda.value)` is an + * error). + * + * TCUuid is a special case, because it does not contain pointers. It can be + * freely copied and need not be freed. + * + * ## Lists + * + * Lists are a special kind of pass-by-value type. Each contains `len` and + * `items`, where `items` is an array of length `len`. Lists, and the values + * in the `items` array, must be treated as read-only. On return from an API + * function, a list's ownership is with the C caller, which must eventually + * free the list. List data must be freed with the `tc_…_list_free` function. + * It is an error to free any value in the `items` array of a list. + */ diff --git a/rust/lib/src/annotation.rs b/rust/lib/src/annotation.rs new file mode 100644 index 000000000..5b28caf9d --- /dev/null +++ b/rust/lib/src/annotation.rs @@ -0,0 +1,152 @@ +use crate::traits::*; +use crate::types::*; +use taskchampion::chrono::prelude::*; + +/// TCAnnotation contains the details of an annotation. +/// +/// # Safety +/// +/// An annotation must be initialized from a tc_.. function, and later freed +/// with `tc_annotation_free` or `tc_annotation_list_free`. +/// +/// Any function taking a `*TCAnnotation` requires: +/// - the pointer must not be NUL; +/// - the pointer must be one previously returned from a tc_… function; +/// - the memory referenced by the pointer must never be modified by C code; and +/// - ownership transfers to the called function, and the value must not be used +/// after the call returns. In fact, the value will be zeroed out to ensure this. +/// +/// TCAnnotations are not threadsafe. +#[repr(C)] +pub struct TCAnnotation { + /// Time the annotation was made. Must be nonzero. + pub entry: libc::time_t, + /// Content of the annotation. Must not be NULL. + pub description: TCString, +} + +impl PassByValue for TCAnnotation { + // NOTE: we cannot use `RustType = Annotation` here because conversion of the + // Rust to a String can fail. + type RustType = (DateTime, RustString<'static>); + + unsafe fn from_ctype(mut self) -> Self::RustType { + // SAFETY: + // - any time_t value is valid + // - time_t is copy, so ownership is not important + let entry = unsafe { libc::time_t::val_from_arg(self.entry) }.unwrap(); + // SAFETY: + // - self.description is valid (came from return_val in as_ctype) + // - self is owned, so we can take ownership of this TCString + let description = + unsafe { TCString::take_val_from_arg(&mut self.description, TCString::default()) }; + (entry, description) + } + + fn as_ctype((entry, description): Self::RustType) -> Self { + TCAnnotation { + entry: libc::time_t::as_ctype(Some(entry)), + // SAFETY: + // - ownership of the TCString tied to ownership of Self + description: unsafe { TCString::return_val(description) }, + } + } +} + +impl Default for TCAnnotation { + fn default() -> Self { + TCAnnotation { + entry: 0 as libc::time_t, + description: TCString::default(), + } + } +} + +/// TCAnnotationList represents a list of annotations. +/// +/// The content of this struct must be treated as read-only. +#[repr(C)] +pub struct TCAnnotationList { + /// number of annotations in items + len: libc::size_t, + + /// total size of items (internal use only) + _capacity: libc::size_t, + + /// array of annotations. these remain owned by the TCAnnotationList instance and will be freed by + /// tc_annotation_list_free. This pointer is never NULL for a valid TCAnnotationList. + items: *mut TCAnnotation, +} + +impl CList for TCAnnotationList { + type Element = TCAnnotation; + + unsafe fn from_raw_parts(items: *mut Self::Element, len: usize, cap: usize) -> Self { + TCAnnotationList { + len, + _capacity: cap, + items, + } + } + + fn slice(&mut self) -> &mut [Self::Element] { + // SAFETY: + // - because we have &mut self, we have read/write access to items[0..len] + // - all items are properly initialized Element's + // - return value lifetime is equal to &mmut self's, so access is exclusive + // - items and len came from Vec, so total size is < isize::MAX + unsafe { std::slice::from_raw_parts_mut(self.items, self.len) } + } + + fn into_raw_parts(self) -> (*mut Self::Element, usize, usize) { + (self.items, self.len, self._capacity) + } +} + +/// Free a TCAnnotation instance. The instance, and the TCString it contains, must not be used +/// after this call. +#[no_mangle] +pub unsafe extern "C" fn tc_annotation_free(tcann: *mut TCAnnotation) { + debug_assert!(!tcann.is_null()); + // SAFETY: + // - tcann is not NULL + // - *tcann is a valid TCAnnotation (caller promised to treat it as read-only) + let annotation = unsafe { TCAnnotation::take_val_from_arg(tcann, TCAnnotation::default()) }; + drop(annotation); +} + +/// Free a TCAnnotationList instance. The instance, and all TCAnnotations it contains, must not be used after +/// this call. +/// +/// When this call returns, the `items` pointer will be NULL, signalling an invalid TCAnnotationList. +#[no_mangle] +pub unsafe extern "C" fn tc_annotation_list_free(tcanns: *mut TCAnnotationList) { + // SAFETY: + // - tcanns is not NULL and points to a valid TCAnnotationList (caller is not allowed to + // modify the list) + // - caller promises not to use the value after return + unsafe { drop_value_list(tcanns) } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn empty_list_has_non_null_pointer() { + let tcanns = unsafe { TCAnnotationList::return_val(Vec::new()) }; + assert!(!tcanns.items.is_null()); + assert_eq!(tcanns.len, 0); + assert_eq!(tcanns._capacity, 0); + } + + #[test] + fn free_sets_null_pointer() { + let mut tcanns = unsafe { TCAnnotationList::return_val(Vec::new()) }; + // SAFETY: testing expected behavior + unsafe { tc_annotation_list_free(&mut tcanns) }; + assert!(tcanns.items.is_null()); + assert_eq!(tcanns.len, 0); + assert_eq!(tcanns._capacity, 0); + } +} diff --git a/rust/lib/src/atomic.rs b/rust/lib/src/atomic.rs new file mode 100644 index 000000000..21f7bc282 --- /dev/null +++ b/rust/lib/src/atomic.rs @@ -0,0 +1,34 @@ +//! Trait implementations for a few atomic types + +use crate::traits::*; +use taskchampion::chrono::prelude::*; + +impl PassByValue for usize { + type RustType = usize; + + unsafe fn from_ctype(self) -> usize { + self + } + + fn as_ctype(arg: usize) -> usize { + arg + } +} + +/// Convert an Option> to a libc::time_t, or zero if not set. +impl PassByValue for libc::time_t { + type RustType = Option>; + + unsafe fn from_ctype(self) -> Option> { + if self == 0 { + None + } else { + Some(Utc.timestamp(self as i64, 0)) + } + } + + fn as_ctype(arg: Option>) -> libc::time_t { + arg.map(|ts| ts.timestamp() as libc::time_t) + .unwrap_or(0 as libc::time_t) + } +} diff --git a/rust/lib/src/kv.rs b/rust/lib/src/kv.rs new file mode 100644 index 000000000..8fcbaabbe --- /dev/null +++ b/rust/lib/src/kv.rs @@ -0,0 +1,115 @@ +use crate::traits::*; +use crate::types::*; + +/// TCKV contains a key/value pair that is part of a task. +/// +/// Neither key nor value are ever NULL. They remain owned by the TCKV and +/// will be freed when it is freed with tc_kv_list_free. +#[repr(C)] +pub struct TCKV { + pub key: TCString, + pub value: TCString, +} + +impl PassByValue for TCKV { + type RustType = (RustString<'static>, RustString<'static>); + + unsafe fn from_ctype(self) -> Self::RustType { + // SAFETY: + // - self.key is not NULL (field docstring) + // - self.key came from return_ptr in as_ctype + // - self is owned, so we can take ownership of this TCString + let key = unsafe { TCString::val_from_arg(self.key) }; + // SAFETY: (same) + let value = unsafe { TCString::val_from_arg(self.value) }; + (key, value) + } + + fn as_ctype((key, value): Self::RustType) -> Self { + TCKV { + // SAFETY: + // - ownership of the TCString tied to ownership of Self + key: unsafe { TCString::return_val(key) }, + // SAFETY: + // - ownership of the TCString tied to ownership of Self + value: unsafe { TCString::return_val(value) }, + } + } +} + +/// TCKVList represents a list of key/value pairs. +/// +/// The content of this struct must be treated as read-only. +#[repr(C)] +pub struct TCKVList { + /// number of key/value pairs in items + len: libc::size_t, + + /// total size of items (internal use only) + _capacity: libc::size_t, + + /// array of TCKV's. these remain owned by the TCKVList instance and will be freed by + /// tc_kv_list_free. This pointer is never NULL for a valid TCKVList. + items: *mut TCKV, +} + +impl CList for TCKVList { + type Element = TCKV; + + unsafe fn from_raw_parts(items: *mut Self::Element, len: usize, cap: usize) -> Self { + TCKVList { + len, + _capacity: cap, + items, + } + } + + fn slice(&mut self) -> &mut [Self::Element] { + // SAFETY: + // - because we have &mut self, we have read/write access to items[0..len] + // - all items are properly initialized Element's + // - return value lifetime is equal to &mmut self's, so access is exclusive + // - items and len came from Vec, so total size is < isize::MAX + unsafe { std::slice::from_raw_parts_mut(self.items, self.len) } + } + + fn into_raw_parts(self) -> (*mut Self::Element, usize, usize) { + (self.items, self.len, self._capacity) + } +} + +/// Free a TCKVList instance. The instance, and all TCKVs it contains, must not be used after +/// this call. +/// +/// When this call returns, the `items` pointer will be NULL, signalling an invalid TCKVList. +#[no_mangle] +pub unsafe extern "C" fn tc_kv_list_free(tckvs: *mut TCKVList) { + // SAFETY: + // - tckvs is not NULL and points to a valid TCKVList (caller is not allowed to + // modify the list) + // - caller promises not to use the value after return + unsafe { drop_value_list(tckvs) } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn empty_list_has_non_null_pointer() { + let tckvs = unsafe { TCKVList::return_val(Vec::new()) }; + assert!(!tckvs.items.is_null()); + assert_eq!(tckvs.len, 0); + assert_eq!(tckvs._capacity, 0); + } + + #[test] + fn free_sets_null_pointer() { + let mut tckvs = unsafe { TCKVList::return_val(Vec::new()) }; + // SAFETY: testing expected behavior + unsafe { tc_kv_list_free(&mut tckvs) }; + assert!(tckvs.items.is_null()); + assert_eq!(tckvs.len, 0); + assert_eq!(tckvs._capacity, 0); + } +} diff --git a/rust/lib/src/lib.rs b/rust/lib/src/lib.rs new file mode 100644 index 000000000..69b83d64b --- /dev/null +++ b/rust/lib/src/lib.rs @@ -0,0 +1,56 @@ +// Not compatible with the MSRV +// #![warn(unsafe_op_in_unsafe_fn)] +#![allow(unused_unsafe)] +// Not working yet in stable - https://github.com/rust-lang/rust-clippy/issues/8020 +// #![warn(clippy::undocumented_unsafe_blocks)] + +// docstrings for extern "C" functions are reflected into C, and do not benefit +// from safety docs. +#![allow(clippy::missing_safety_doc)] +// deny some things that are typically warnings +#![deny(clippy::derivable_impls)] +#![deny(clippy::wrong_self_convention)] +#![deny(clippy::extra_unused_lifetimes)] +#![deny(clippy::unnecessary_to_owned)] + +mod traits; +mod util; + +pub mod annotation; +pub use annotation::*; +pub mod atomic; +pub use atomic::*; +pub mod kv; +pub use kv::*; +pub mod replica; +pub use replica::*; +pub mod result; +pub use result::*; +pub mod server; +pub use server::*; +pub mod status; +pub use status::*; +pub mod string; +pub use string::*; +pub mod task; +pub use task::*; +pub mod uda; +pub use uda::*; +pub mod uuid; +pub use uuid::*; +pub mod workingset; +pub use workingset::*; + +pub(crate) mod types { + pub(crate) use crate::annotation::{TCAnnotation, TCAnnotationList}; + pub(crate) use crate::kv::{TCKVList, TCKV}; + pub(crate) use crate::replica::TCReplica; + pub(crate) use crate::result::TCResult; + pub(crate) use crate::server::TCServer; + pub(crate) use crate::status::TCStatus; + pub(crate) use crate::string::{RustString, TCString, TCStringList}; + pub(crate) use crate::task::{TCTask, TCTaskList}; + pub(crate) use crate::uda::{TCUda, TCUdaList, Uda}; + pub(crate) use crate::uuid::{TCUuid, TCUuidList}; + pub(crate) use crate::workingset::TCWorkingSet; +} diff --git a/rust/lib/src/replica.rs b/rust/lib/src/replica.rs new file mode 100644 index 000000000..9ad5f29db --- /dev/null +++ b/rust/lib/src/replica.rs @@ -0,0 +1,443 @@ +use crate::traits::*; +use crate::types::*; +use crate::util::err_to_ruststring; +use std::ptr::NonNull; +use taskchampion::{Replica, StorageConfig}; + +/// A replica represents an instance of a user's task data, providing an easy interface +/// for querying and modifying that data. +/// +/// # Error Handling +/// +/// When a `tc_replica_..` function that returns a TCResult returns TC_RESULT_ERROR, then +/// `tc_replica_error` will return the error message. +/// +/// # Safety +/// +/// The `*TCReplica` returned from `tc_replica_new…` functions is owned by the caller and +/// must later be freed to avoid a memory leak. +/// +/// Any function taking a `*TCReplica` requires: +/// - the pointer must not be NUL; +/// - the pointer must be one previously returned from a tc_… function; +/// - the memory referenced by the pointer must never be modified by C code; and +/// - except for `tc_replica_free`, ownership of a `*TCReplica` remains with the caller. +/// +/// Once passed to `tc_replica_free`, a `*TCReplica` becomes invalid and must not be used again. +/// +/// TCReplicas are not threadsafe. +pub struct TCReplica { + /// The wrapped Replica + inner: Replica, + + /// If true, this replica has an outstanding &mut (for a TaskMut) + mut_borrowed: bool, + + /// The error from the most recent operation, if any + error: Option>, +} + +impl PassByPointer for TCReplica {} + +impl TCReplica { + /// Mutably borrow the inner Replica + pub(crate) fn borrow_mut(&mut self) -> &mut Replica { + if self.mut_borrowed { + panic!("replica is already borrowed"); + } + self.mut_borrowed = true; + &mut self.inner + } + + /// Release the borrow made by [`borrow_mut`] + pub(crate) fn release_borrow(&mut self) { + if !self.mut_borrowed { + panic!("replica is not borrowed"); + } + self.mut_borrowed = false; + } +} + +impl From for TCReplica { + fn from(rep: Replica) -> TCReplica { + TCReplica { + inner: rep, + mut_borrowed: false, + error: None, + } + } +} + +/// Utility function to allow using `?` notation to return an error value. This makes +/// a mutable borrow, because most Replica methods require a `&mut`. +fn wrap(rep: *mut TCReplica, f: F, err_value: T) -> T +where + F: FnOnce(&mut Replica) -> anyhow::Result, +{ + debug_assert!(!rep.is_null()); + // SAFETY: + // - rep is not NULL (promised by caller) + // - *rep is a valid TCReplica (promised by caller) + // - rep is valid for the duration of this function + // - rep is not modified by anything else (not threadsafe) + let rep: &mut TCReplica = unsafe { TCReplica::from_ptr_arg_ref_mut(rep) }; + if rep.mut_borrowed { + panic!("replica is borrowed and cannot be used"); + } + rep.error = None; + match f(&mut rep.inner) { + Ok(v) => v, + Err(e) => { + rep.error = Some(err_to_ruststring(e)); + err_value + } + } +} + +/// Utility function to allow using `?` notation to return an error value in the constructor. +fn wrap_constructor(f: F, error_out: *mut TCString, err_value: T) -> T +where + F: FnOnce() -> anyhow::Result, +{ + if !error_out.is_null() { + // SAFETY: + // - error_out is not NULL (just checked) + // - properly aligned and valid (promised by caller) + unsafe { *error_out = TCString::default() }; + } + + match f() { + Ok(v) => v, + Err(e) => { + if !error_out.is_null() { + // SAFETY: + // - error_out is not NULL (just checked) + // - properly aligned and valid (promised by caller) + unsafe { + TCString::val_to_arg_out(err_to_ruststring(e), error_out); + } + } + err_value + } + } +} + +/// Create a new TCReplica with an in-memory database. The contents of the database will be +/// lost when it is freed with tc_replica_free. +#[no_mangle] +pub unsafe extern "C" fn tc_replica_new_in_memory() -> *mut TCReplica { + let storage = StorageConfig::InMemory + .into_storage() + .expect("in-memory always succeeds"); + // SAFETY: + // - caller promises to free this value + unsafe { TCReplica::from(Replica::new(storage)).return_ptr() } +} + +/// Create a new TCReplica with an on-disk database having the given filename. On error, a string +/// is written to the error_out parameter (if it is not NULL) and NULL is returned. The caller +/// must free this string. +#[no_mangle] +pub unsafe extern "C" fn tc_replica_new_on_disk( + path: TCString, + error_out: *mut TCString, +) -> *mut TCReplica { + wrap_constructor( + || { + // SAFETY: + // - path is valid (promised by caller) + // - caller will not use path after this call (convention) + let mut path = unsafe { TCString::val_from_arg(path) }; + let storage = StorageConfig::OnDisk { + taskdb_dir: path.to_path_buf_mut()?, + } + .into_storage()?; + + // SAFETY: + // - caller promises to free this value + Ok(unsafe { TCReplica::from(Replica::new(storage)).return_ptr() }) + }, + error_out, + std::ptr::null_mut(), + ) +} + +/// Get a list of all tasks in the replica. +/// +/// Returns a TCTaskList with a NULL items field on error. +#[no_mangle] +pub unsafe extern "C" fn tc_replica_all_tasks(rep: *mut TCReplica) -> TCTaskList { + wrap( + rep, + |rep| { + // note that the Replica API returns a hashmap here, but we discard + // the keys and return a simple list. The task UUIDs are available + // from task.get_uuid(), so information is not lost. + let tasks: Vec<_> = rep + .all_tasks()? + .drain() + .map(|(_uuid, t)| { + Some( + NonNull::new( + // SAFETY: + // - caller promises to free this value (via freeing the list) + unsafe { TCTask::from(t).return_ptr() }, + ) + .expect("TCTask::return_ptr returned NULL"), + ) + }) + .collect(); + // SAFETY: + // - value is not allocated and need not be freed + Ok(unsafe { TCTaskList::return_val(tasks) }) + }, + TCTaskList::null_value(), + ) +} + +/// Get a list of all uuids for tasks in the replica. +/// +/// Returns a TCUuidList with a NULL items field on error. +/// +/// The caller must free the UUID list with `tc_uuid_list_free`. +#[no_mangle] +pub unsafe extern "C" fn tc_replica_all_task_uuids(rep: *mut TCReplica) -> TCUuidList { + wrap( + rep, + |rep| { + let uuids: Vec<_> = rep + .all_task_uuids()? + .drain(..) + // SAFETY: + // - value is not allocated and need not be freed + .map(|uuid| unsafe { TCUuid::return_val(uuid) }) + .collect(); + // SAFETY: + // - value will be freed (promised by caller) + Ok(unsafe { TCUuidList::return_val(uuids) }) + }, + TCUuidList::null_value(), + ) +} + +/// Get the current working set for this replica. The resulting value must be freed +/// with tc_working_set_free. +/// +/// Returns NULL on error. +#[no_mangle] +pub unsafe extern "C" fn tc_replica_working_set(rep: *mut TCReplica) -> *mut TCWorkingSet { + wrap( + rep, + |rep| { + let ws = rep.working_set()?; + // SAFETY: + // - caller promises to free this value + Ok(unsafe { TCWorkingSet::return_ptr(ws.into()) }) + }, + std::ptr::null_mut(), + ) +} + +/// Get an existing task by its UUID. +/// +/// Returns NULL when the task does not exist, and on error. Consult tc_replica_error +/// to distinguish the two conditions. +#[no_mangle] +pub unsafe extern "C" fn tc_replica_get_task(rep: *mut TCReplica, tcuuid: TCUuid) -> *mut TCTask { + wrap( + rep, + |rep| { + // SAFETY: + // - tcuuid is a valid TCUuid (all bytes are valid) + // - tcuuid is Copy so ownership doesn't matter + let uuid = unsafe { TCUuid::val_from_arg(tcuuid) }; + if let Some(task) = rep.get_task(uuid)? { + // SAFETY: + // - caller promises to free this task + Ok(unsafe { TCTask::from(task).return_ptr() }) + } else { + Ok(std::ptr::null_mut()) + } + }, + std::ptr::null_mut(), + ) +} + +/// Create a new task. The task must not already exist. +/// +/// Returns the task, or NULL on error. +#[no_mangle] +pub unsafe extern "C" fn tc_replica_new_task( + rep: *mut TCReplica, + status: TCStatus, + description: TCString, +) -> *mut TCTask { + // SAFETY: + // - description is valid (promised by caller) + // - caller will not use description after this call (convention) + let mut description = unsafe { TCString::val_from_arg(description) }; + wrap( + rep, + |rep| { + let task = rep.new_task(status.into(), description.as_str()?.to_string())?; + // SAFETY: + // - caller promises to free this task + Ok(unsafe { TCTask::from(task).return_ptr() }) + }, + std::ptr::null_mut(), + ) +} + +/// Create a new task. The task must not already exist. +/// +/// Returns the task, or NULL on error. +#[no_mangle] +pub unsafe extern "C" fn tc_replica_import_task_with_uuid( + rep: *mut TCReplica, + tcuuid: TCUuid, +) -> *mut TCTask { + wrap( + rep, + |rep| { + // SAFETY: + // - tcuuid is a valid TCUuid (all bytes are valid) + // - tcuuid is Copy so ownership doesn't matter + let uuid = unsafe { TCUuid::val_from_arg(tcuuid) }; + let task = rep.import_task_with_uuid(uuid)?; + // SAFETY: + // - caller promises to free this task + Ok(unsafe { TCTask::from(task).return_ptr() }) + }, + std::ptr::null_mut(), + ) +} + +/// Synchronize this replica with a server. +/// +/// The `server` argument remains owned by the caller, and must be freed explicitly. +#[no_mangle] +pub unsafe extern "C" fn tc_replica_sync( + rep: *mut TCReplica, + server: *mut TCServer, + avoid_snapshots: bool, +) -> TCResult { + wrap( + rep, + |rep| { + debug_assert!(!server.is_null()); + // SAFETY: + // - server is not NULL + // - *server is a valid TCServer (promised by caller) + // - server is valid for the lifetime of tc_replica_sync (not threadsafe) + // - server will not be accessed simultaneously (not threadsafe) + let server = unsafe { TCServer::from_ptr_arg_ref_mut(server) }; + rep.sync(server.as_mut(), avoid_snapshots)?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Undo local operations until the most recent UndoPoint. +/// +/// If undone_out is not NULL, then on success it is set to 1 if operations were undone, or 0 if +/// there are no operations that can be done. +#[no_mangle] +pub unsafe extern "C" fn tc_replica_undo(rep: *mut TCReplica, undone_out: *mut i32) -> TCResult { + wrap( + rep, + |rep| { + let undone = if rep.undo()? { 1 } else { 0 }; + if !undone_out.is_null() { + // SAFETY: + // - undone_out is not NULL (just checked) + // - undone_out is properly aligned (implicitly promised by caller) + unsafe { *undone_out = undone }; + } + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Get the number of local, un-synchronized operations, or -1 on error +#[no_mangle] +pub unsafe extern "C" fn tc_replica_num_local_operations(rep: *mut TCReplica) -> i64 { + wrap( + rep, + |rep| { + let count = rep.num_local_operations()? as i64; + Ok(count) + }, + -1, + ) +} + +/// Add an UndoPoint, if one has not already been added by this Replica. This occurs automatically +/// when a change is made. The `force` flag allows forcing a new UndoPoint even if one has already +/// been created by this Replica, and may be useful when a Replica instance is held for a long time +/// and used to apply more than one user-visible change. +#[no_mangle] +pub unsafe extern "C" fn tc_replica_add_undo_point(rep: *mut TCReplica, force: bool) -> TCResult { + wrap( + rep, + |rep| { + rep.add_undo_point(force)?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Rebuild this replica's working set, based on whether tasks are pending or not. If `renumber` +/// is true, then existing tasks may be moved to new working-set indices; in any case, on +/// completion all pending tasks are in the working set and all non- pending tasks are not. +#[no_mangle] +pub unsafe extern "C" fn tc_replica_rebuild_working_set( + rep: *mut TCReplica, + renumber: bool, +) -> TCResult { + wrap( + rep, + |rep| { + rep.rebuild_working_set(renumber)?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Get the latest error for a replica, or a string with NULL ptr if no error exists. Subsequent +/// calls to this function will return NULL. The rep pointer must not be NULL. The caller must +/// free the returned string. +#[no_mangle] +pub unsafe extern "C" fn tc_replica_error(rep: *mut TCReplica) -> TCString { + // SAFETY: + // - rep is not NULL (promised by caller) + // - *rep is a valid TCReplica (promised by caller) + // - rep is valid for the duration of this function + // - rep is not modified by anything else (not threadsafe) + let rep: &mut TCReplica = unsafe { TCReplica::from_ptr_arg_ref_mut(rep) }; + if let Some(rstring) = rep.error.take() { + // SAFETY: + // - caller promises to free this string + unsafe { TCString::return_val(rstring) } + } else { + TCString::default() + } +} + +/// Free a replica. The replica may not be used after this function returns and must not be freed +/// more than once. +#[no_mangle] +pub unsafe extern "C" fn tc_replica_free(rep: *mut TCReplica) { + // SAFETY: + // - replica is not NULL (promised by caller) + // - replica is valid (promised by caller) + // - caller will not use description after this call (promised by caller) + let replica = unsafe { TCReplica::take_from_ptr_arg(rep) }; + if replica.mut_borrowed { + panic!("replica is borrowed and cannot be freed"); + } + drop(replica); +} diff --git a/rust/lib/src/result.rs b/rust/lib/src/result.rs new file mode 100644 index 000000000..a7d53ea8d --- /dev/null +++ b/rust/lib/src/result.rs @@ -0,0 +1,9 @@ +/// A result from a TC operation. Typically if this value is TC_RESULT_ERROR, +/// the associated object's `tc_.._error` method will return an error message. +/// cbindgen:prefix-with-name +/// cbindgen:rename-all=ScreamingSnakeCase +#[repr(i32)] +pub enum TCResult { + Error = -1, + Ok = 0, +} diff --git a/rust/lib/src/server.rs b/rust/lib/src/server.rs new file mode 100644 index 000000000..dc1160d0c --- /dev/null +++ b/rust/lib/src/server.rs @@ -0,0 +1,143 @@ +use crate::traits::*; +use crate::types::*; +use crate::util::err_to_ruststring; +use taskchampion::{Server, ServerConfig}; + +/// TCServer represents an interface to a sync server. Aside from new and free, a server +/// has no C-accessible API, but is designed to be passed to `tc_replica_sync`. +/// +/// ## Safety +/// +/// TCServer are not threadsafe, and must not be used with multiple replicas simultaneously. +pub struct TCServer(Box); + +impl PassByPointer for TCServer {} + +impl From> for TCServer { + fn from(server: Box) -> TCServer { + TCServer(server) + } +} + +impl AsMut> for TCServer { + fn as_mut(&mut self) -> &mut Box { + &mut self.0 + } +} + +/// Utility function to allow using `?` notation to return an error value. +fn wrap(f: F, error_out: *mut TCString, err_value: T) -> T +where + F: FnOnce() -> anyhow::Result, +{ + if !error_out.is_null() { + // SAFETY: + // - error_out is not NULL (just checked) + // - properly aligned and valid (promised by caller) + unsafe { *error_out = TCString::default() }; + } + + match f() { + Ok(v) => v, + Err(e) => { + if !error_out.is_null() { + // SAFETY: + // - error_out is not NULL (just checked) + // - properly aligned and valid (promised by caller) + unsafe { + TCString::val_to_arg_out(err_to_ruststring(e), error_out); + } + } + err_value + } + } +} + +/// Create a new TCServer that operates locally (on-disk). See the TaskChampion docs for the +/// description of the arguments. +/// +/// On error, a string is written to the error_out parameter (if it is not NULL) and NULL is +/// returned. The caller must free this string. +/// +/// The server must be freed after it is used - tc_replica_sync does not automatically free it. +#[no_mangle] +pub unsafe extern "C" fn tc_server_new_local( + server_dir: TCString, + error_out: *mut TCString, +) -> *mut TCServer { + wrap( + || { + // SAFETY: + // - server_dir is valid (promised by caller) + // - caller will not use server_dir after this call (convention) + let mut server_dir = unsafe { TCString::val_from_arg(server_dir) }; + let server_config = ServerConfig::Local { + server_dir: server_dir.to_path_buf_mut()?, + }; + let server = server_config.into_server()?; + // SAFETY: caller promises to free this server. + Ok(unsafe { TCServer::return_ptr(server.into()) }) + }, + error_out, + std::ptr::null_mut(), + ) +} + +/// Create a new TCServer that connects to a remote server. See the TaskChampion docs for the +/// description of the arguments. +/// +/// On error, a string is written to the error_out parameter (if it is not NULL) and NULL is +/// returned. The caller must free this string. +/// +/// The server must be freed after it is used - tc_replica_sync does not automatically free it. +#[no_mangle] +pub unsafe extern "C" fn tc_server_new_remote( + origin: TCString, + client_key: TCUuid, + encryption_secret: TCString, + error_out: *mut TCString, +) -> *mut TCServer { + wrap( + || { + // SAFETY: + // - origin is valid (promised by caller) + // - origin ownership is transferred to this function + let origin = unsafe { TCString::val_from_arg(origin) }.into_string()?; + + // SAFETY: + // - client_key is a valid Uuid (any 8-byte sequence counts) + + let client_key = unsafe { TCUuid::val_from_arg(client_key) }; + // SAFETY: + // - encryption_secret is valid (promised by caller) + // - encryption_secret ownership is transferred to this function + let encryption_secret = unsafe { TCString::val_from_arg(encryption_secret) } + .as_bytes() + .to_vec(); + + let server_config = ServerConfig::Remote { + origin, + client_key, + encryption_secret, + }; + let server = server_config.into_server()?; + // SAFETY: caller promises to free this server. + Ok(unsafe { TCServer::return_ptr(server.into()) }) + }, + error_out, + std::ptr::null_mut(), + ) +} + +/// Free a server. The server may not be used after this function returns and must not be freed +/// more than once. +#[no_mangle] +pub unsafe extern "C" fn tc_server_free(server: *mut TCServer) { + debug_assert!(!server.is_null()); + // SAFETY: + // - server is not NULL + // - server came from tc_server_new_.., which used return_ptr + // - server will not be used after (promised by caller) + let server = unsafe { TCServer::take_from_ptr_arg(server) }; + drop(server); +} diff --git a/rust/lib/src/status.rs b/rust/lib/src/status.rs new file mode 100644 index 000000000..306f27630 --- /dev/null +++ b/rust/lib/src/status.rs @@ -0,0 +1,36 @@ +pub use taskchampion::Status; + +/// The status of a task, as defined by the task data model. +/// cbindgen:prefix-with-name +/// cbindgen:rename-all=ScreamingSnakeCase +#[repr(C)] +pub enum TCStatus { + Pending, + Completed, + Deleted, + /// Unknown signifies a status in the task DB that was not + /// recognized. + Unknown, +} + +impl From for Status { + fn from(status: TCStatus) -> Status { + match status { + TCStatus::Pending => Status::Pending, + TCStatus::Completed => Status::Completed, + TCStatus::Deleted => Status::Deleted, + TCStatus::Unknown => Status::Unknown("unknown".to_string()), + } + } +} + +impl From for TCStatus { + fn from(status: Status) -> TCStatus { + match status { + Status::Pending => TCStatus::Pending, + Status::Completed => TCStatus::Completed, + Status::Deleted => TCStatus::Deleted, + Status::Unknown(_) => TCStatus::Unknown, + } + } +} diff --git a/rust/lib/src/string.rs b/rust/lib/src/string.rs new file mode 100644 index 000000000..de090116b --- /dev/null +++ b/rust/lib/src/string.rs @@ -0,0 +1,712 @@ +use crate::traits::*; +use crate::util::{string_into_raw_parts, vec_into_raw_parts}; +use std::ffi::{CStr, CString, OsString}; +use std::os::raw::c_char; +use std::path::PathBuf; + +/// TCString supports passing strings into and out of the TaskChampion API. +/// +/// # Rust Strings and C Strings +/// +/// A Rust string can contain embedded NUL characters, while C considers such a character to mark +/// the end of a string. Strings containing embedded NULs cannot be represented as a "C string" +/// and must be accessed using `tc_string_content_and_len` and `tc_string_clone_with_len`. In +/// general, these two functions should be used for handling arbitrary data, while more convenient +/// forms may be used where embedded NUL characters are impossible, such as in static strings. +/// +/// # UTF-8 +/// +/// TaskChampion expects all strings to be valid UTF-8. `tc_string_…` functions will fail if given +/// a `*TCString` containing invalid UTF-8. +/// +/// # Safety +/// +/// The `ptr` field may be checked for NULL, where documentation indicates this is possible. All +/// other fields in a TCString are private and must not be used from C. They exist in the struct +/// to ensure proper allocation and alignment. +/// +/// When a `TCString` appears as a return value or output argument, ownership is passed to the +/// caller. The caller must pass that ownership back to another function or free the string. +/// +/// Any function taking a `TCString` requires: +/// - the pointer must not be NUL; +/// - the pointer must be one previously returned from a tc_… function; and +/// - the memory referenced by the pointer must never be modified by C code. +/// +/// Unless specified otherwise, TaskChampion functions take ownership of a `TCString` when it is +/// given as a function argument, and the caller must not use or free TCStrings after passing them +/// to such API functions. +/// +/// A TCString with a NULL `ptr` field need not be freed, although tc_free_string will not fail +/// for such a value. +/// +/// TCString is not threadsafe. +/// cbindgen:field-names=[ptr, _u1, _u2, _u3] +#[repr(C)] +pub struct TCString { + // defined based on the type + ptr: *mut libc::c_void, + len: usize, + cap: usize, + + // type of TCString this represents + ty: u8, +} + +// TODO: figure out how to ignore this but still use it in TCString +/// A discriminator for TCString +#[repr(u8)] +enum TCStringType { + /// Null. Nothing is contained in this string. + /// + /// * `ptr` is NULL. + /// * `len` and `cap` are zero. + Null = 0, + + /// A CString. + /// + /// * `ptr` is the result of CString::into_raw, containing a terminating NUL. It may not be + /// valid UTF-8. + /// * `len` and `cap` are zero. + CString, + + /// A CStr, referencing memory borrowed from C + /// + /// * `ptr` points to the string, containing a terminating NUL. It may not be valid UTF-8. + /// * `len` and `cap` are zero. + CStr, + + /// A String. + /// + /// * `ptr`, `len`, and `cap` are as would be returned from String::into_raw_parts. + String, + + /// A byte sequence. + /// + /// * `ptr`, `len`, and `cap` are as would be returned from Vec::into_raw_parts. + Bytes, +} + +impl Default for TCString { + fn default() -> Self { + TCString { + ptr: std::ptr::null_mut(), + len: 0, + cap: 0, + ty: TCStringType::Null as u8, + } + } +} + +impl TCString { + pub(crate) fn is_null(&self) -> bool { + self.ptr.is_null() + } +} + +#[derive(PartialEq, Debug)] +pub enum RustString<'a> { + Null, + CString(CString), + CStr(&'a CStr), + String(String), + Bytes(Vec), +} + +impl<'a> Default for RustString<'a> { + fn default() -> Self { + RustString::Null + } +} + +impl PassByValue for TCString { + type RustType = RustString<'static>; + + unsafe fn from_ctype(self) -> Self::RustType { + match self.ty { + ty if ty == TCStringType::CString as u8 => { + // SAFETY: + // - ptr was derived from CString::into_raw + // - data was not modified since that time (caller promises) + RustString::CString(unsafe { CString::from_raw(self.ptr as *mut c_char) }) + } + ty if ty == TCStringType::CStr as u8 => { + // SAFETY: + // - ptr was created by CStr::as_ptr + // - data was not modified since that time (caller promises) + RustString::CStr(unsafe { CStr::from_ptr(self.ptr as *mut c_char) }) + } + ty if ty == TCStringType::String as u8 => { + // SAFETY: + // - ptr was created by string_into_raw_parts + // - data was not modified since that time (caller promises) + RustString::String(unsafe { + String::from_raw_parts(self.ptr as *mut u8, self.len, self.cap) + }) + } + ty if ty == TCStringType::Bytes as u8 => { + // SAFETY: + // - ptr was created by vec_into_raw_parts + // - data was not modified since that time (caller promises) + RustString::Bytes(unsafe { + Vec::from_raw_parts(self.ptr as *mut u8, self.len, self.cap) + }) + } + _ => RustString::Null, + } + } + + fn as_ctype(arg: Self::RustType) -> Self { + match arg { + RustString::Null => Self { + ty: TCStringType::Null as u8, + ..Default::default() + }, + RustString::CString(cstring) => Self { + ty: TCStringType::CString as u8, + ptr: cstring.into_raw() as *mut libc::c_void, + ..Default::default() + }, + RustString::CStr(cstr) => Self { + ty: TCStringType::CStr as u8, + ptr: cstr.as_ptr() as *mut libc::c_void, + ..Default::default() + }, + RustString::String(string) => { + let (ptr, len, cap) = string_into_raw_parts(string); + Self { + ty: TCStringType::String as u8, + ptr: ptr as *mut libc::c_void, + len, + cap, + } + } + RustString::Bytes(bytes) => { + let (ptr, len, cap) = vec_into_raw_parts(bytes); + Self { + ty: TCStringType::Bytes as u8, + ptr: ptr as *mut libc::c_void, + len, + cap, + } + } + } + } +} + +impl<'a> RustString<'a> { + /// Get a regular Rust &str for this value. + pub(crate) fn as_str(&mut self) -> Result<&str, std::str::Utf8Error> { + match self { + RustString::CString(cstring) => cstring.as_c_str().to_str(), + RustString::CStr(cstr) => cstr.to_str(), + RustString::String(ref string) => Ok(string.as_ref()), + RustString::Bytes(_) => { + self.bytes_to_string()?; + self.as_str() // now the String variant, so won't recurse + } + RustString::Null => unreachable!(), + } + } + + /// Consume this RustString and return an equivalent String, or an error if not + /// valid UTF-8. In the error condition, the original data is lost. + pub(crate) fn into_string(mut self) -> Result { + match self { + RustString::CString(cstring) => cstring.into_string().map_err(|e| e.utf8_error()), + RustString::CStr(cstr) => cstr.to_str().map(|s| s.to_string()), + RustString::String(string) => Ok(string), + RustString::Bytes(_) => { + self.bytes_to_string()?; + self.into_string() // now the String variant, so won't recurse + } + RustString::Null => unreachable!(), + } + } + + pub(crate) fn as_bytes(&self) -> &[u8] { + match self { + RustString::CString(cstring) => cstring.as_bytes(), + RustString::CStr(cstr) => cstr.to_bytes(), + RustString::String(string) => string.as_bytes(), + RustString::Bytes(bytes) => bytes.as_ref(), + RustString::Null => unreachable!(), + } + } + + /// Convert the RustString, in place, from the Bytes to String variant. On successful return, + /// the RustString has variant RustString::String. + fn bytes_to_string(&mut self) -> Result<(), std::str::Utf8Error> { + let mut owned = RustString::Null; + // temporarily swap a Null value into self; we'll swap that back + // shortly. + std::mem::swap(self, &mut owned); + match owned { + RustString::Bytes(bytes) => match String::from_utf8(bytes) { + Ok(string) => { + *self = RustString::String(string); + Ok(()) + } + Err(e) => { + let (e, bytes) = (e.utf8_error(), e.into_bytes()); + // put self back as we found it + *self = RustString::Bytes(bytes); + Err(e) + } + }, + _ => { + // not bytes, so just swap back + std::mem::swap(self, &mut owned); + Ok(()) + } + } + } + + /// Convert the RustString, in place, into one of the C variants. If this is not + /// possible, such as if the string contains an embedded NUL, then the string + /// remains unchanged. + fn string_to_cstring(&mut self) { + let mut owned = RustString::Null; + // temporarily swap a Null value into self; we'll swap that back shortly + std::mem::swap(self, &mut owned); + match owned { + RustString::String(string) => { + match CString::new(string) { + Ok(cstring) => { + *self = RustString::CString(cstring); + } + Err(nul_err) => { + // recover the underlying String from the NulError and restore + // the RustString + let original_bytes = nul_err.into_vec(); + // SAFETY: original_bytes came from a String moments ago, so still valid utf8 + let string = unsafe { String::from_utf8_unchecked(original_bytes) }; + *self = RustString::String(string); + } + } + } + _ => { + // not a CString, so just swap back + std::mem::swap(self, &mut owned); + } + } + } + + pub(crate) fn to_path_buf_mut(&mut self) -> Result { + #[cfg(unix)] + let path: OsString = { + // on UNIX, we can use the bytes directly, without requiring that they + // be valid UTF-8. + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + OsStr::from_bytes(self.as_bytes()).to_os_string() + }; + #[cfg(windows)] + let path: OsString = { + // on Windows, we assume the filename is valid Unicode, so it can be + // represented as UTF-8. + OsString::from(self.as_str()?.to_string()) + }; + Ok(path.into()) + } +} + +impl<'a> From for RustString<'a> { + fn from(string: String) -> RustString<'a> { + RustString::String(string) + } +} + +impl<'a> From<&str> for RustString<'static> { + fn from(string: &str) -> RustString<'static> { + RustString::String(string.to_string()) + } +} + +/// Utility function to borrow a TCString from a pointer arg, modify it, +/// and restore it. +/// +/// This implements a kind of "interior mutability", relying on the +/// single-threaded use of all TC* types. +/// +/// # SAFETY +/// +/// - tcstring must not be NULL +/// - *tcstring must be a valid TCString +/// - *tcstring must not be accessed by anything else, despite the *const +unsafe fn wrap(tcstring: *const TCString, f: F) -> T +where + F: FnOnce(&mut RustString) -> T, +{ + debug_assert!(!tcstring.is_null()); + + // SAFETY: + // - we have exclusive to *tcstring (promised by caller) + let tcstring = tcstring as *mut TCString; + + // SAFETY: + // - tcstring is not NULL + // - *tcstring is a valid string (promised by caller) + let mut rstring = unsafe { TCString::take_val_from_arg(tcstring, TCString::default()) }; + + let rv = f(&mut rstring); + + // update the caller's TCString with the updated RustString + // SAFETY: + // - tcstring is not NULL (we just took from it) + // - tcstring points to valid memory (we just took from it) + unsafe { TCString::val_to_arg_out(rstring, tcstring) }; + + rv +} + +/// TCStringList represents a list of strings. +/// +/// The content of this struct must be treated as read-only. +#[repr(C)] +pub struct TCStringList { + /// number of strings in items + len: libc::size_t, + + /// total size of items (internal use only) + _capacity: libc::size_t, + + /// TCStringList representing each string. these remain owned by the TCStringList instance and will + /// be freed by tc_string_list_free. This pointer is never NULL for a valid TCStringList, and the + /// *TCStringList at indexes 0..len-1 are not NULL. + items: *mut TCString, +} + +impl CList for TCStringList { + type Element = TCString; + + unsafe fn from_raw_parts(items: *mut Self::Element, len: usize, cap: usize) -> Self { + TCStringList { + len, + _capacity: cap, + items, + } + } + + fn slice(&mut self) -> &mut [Self::Element] { + // SAFETY: + // - because we have &mut self, we have read/write access to items[0..len] + // - all items are properly initialized Element's + // - return value lifetime is equal to &mmut self's, so access is exclusive + // - items and len came from Vec, so total size is < isize::MAX + unsafe { std::slice::from_raw_parts_mut(self.items, self.len) } + } + + fn into_raw_parts(self) -> (*mut Self::Element, usize, usize) { + (self.items, self.len, self._capacity) + } +} + +/// Create a new TCString referencing the given C string. The C string must remain valid and +/// unchanged until after the TCString is freed. It's typically easiest to ensure this by using a +/// static string. +/// +/// NOTE: this function does _not_ take responsibility for freeing the given C string. The +/// given string can be freed once the TCString referencing it has been freed. +/// +/// For example: +/// +/// ```text +/// char *url = get_item_url(..); // dynamically allocate C string +/// tc_task_annotate(task, tc_string_borrow(url)); // TCString created, passed, and freed +/// free(url); // string is no longer referenced and can be freed +/// ``` +#[no_mangle] +pub unsafe extern "C" fn tc_string_borrow(cstr: *const libc::c_char) -> TCString { + debug_assert!(!cstr.is_null()); + // SAFETY: + // - cstr is not NULL (promised by caller, verified by assertion) + // - cstr's lifetime exceeds that of the TCString (promised by caller) + // - cstr contains a valid NUL terminator (promised by caller) + // - cstr's content will not change before it is destroyed (promised by caller) + let cstr: &CStr = unsafe { CStr::from_ptr(cstr) }; + // SAFETY: + // - caller promises to free this string + unsafe { TCString::return_val(RustString::CStr(cstr)) } +} + +/// Create a new TCString by cloning the content of the given C string. The resulting TCString +/// is independent of the given string, which can be freed or overwritten immediately. +#[no_mangle] +pub unsafe extern "C" fn tc_string_clone(cstr: *const libc::c_char) -> TCString { + debug_assert!(!cstr.is_null()); + // SAFETY: + // - cstr is not NULL (promised by caller, verified by assertion) + // - cstr's lifetime exceeds that of this function (by C convention) + // - cstr contains a valid NUL terminator (promised by caller) + // - cstr's content will not change before it is destroyed (by C convention) + let cstr: &CStr = unsafe { CStr::from_ptr(cstr) }; + let cstring: CString = cstr.into(); + // SAFETY: + // - caller promises to free this string + unsafe { TCString::return_val(RustString::CString(cstring)) } +} + +/// Create a new TCString containing the given string with the given length. This allows creation +/// of strings containing embedded NUL characters. As with `tc_string_clone`, the resulting +/// TCString is independent of the passed buffer, which may be reused or freed immediately. +/// +/// The length should _not_ include any trailing NUL. +/// +/// The given length must be less than half the maximum value of usize. +#[no_mangle] +pub unsafe extern "C" fn tc_string_clone_with_len( + buf: *const libc::c_char, + len: usize, +) -> TCString { + debug_assert!(!buf.is_null()); + debug_assert!(len < isize::MAX as usize); + // SAFETY: + // - buf is valid for len bytes (by C convention) + // - (no alignment requirements for a byte slice) + // - content of buf will not be mutated during the lifetime of this slice (lifetime + // does not outlive this function call) + // - the length of the buffer is less than isize::MAX (promised by caller) + let slice = unsafe { std::slice::from_raw_parts(buf as *const u8, len) }; + + // allocate and copy into Rust-controlled memory + let vec = slice.to_vec(); + + // SAFETY: + // - caller promises to free this string + unsafe { TCString::return_val(RustString::Bytes(vec)) } +} + +/// Get the content of the string as a regular C string. The given string must be valid. The +/// returned value is NULL if the string contains NUL bytes or (in some cases) invalid UTF-8. The +/// returned C string is valid until the TCString is freed or passed to another TC API function. +/// +/// In general, prefer [`tc_string_content_with_len`] except when it's certain that the string is +/// valid and NUL-free. +/// +/// This function takes the TCString by pointer because it may be modified in-place to add a NUL +/// terminator. The pointer must not be NULL. +/// +/// This function does _not_ take ownership of the TCString. +#[no_mangle] +pub unsafe extern "C" fn tc_string_content(tcstring: *const TCString) -> *const libc::c_char { + // SAFETY; + // - tcstring is not NULL (promised by caller) + // - *tcstring is valid (promised by caller) + // - *tcstring is not accessed concurrently (single-threaded) + unsafe { + wrap(tcstring, |rstring| { + // try to eliminate the Bytes variant. If this fails, we'll return NULL + // below, so the error is ignorable. + let _ = rstring.bytes_to_string(); + + // and eliminate the String variant + rstring.string_to_cstring(); + + match &rstring { + RustString::CString(cstring) => cstring.as_ptr(), + RustString::String(_) => std::ptr::null(), // string_to_cstring failed + RustString::CStr(cstr) => cstr.as_ptr(), + RustString::Bytes(_) => std::ptr::null(), // already returned above + RustString::Null => unreachable!(), + } + }) + } +} + +/// Get the content of the string as a pointer and length. The given string must not be NULL. +/// This function can return any string, even one including NUL bytes or invalid UTF-8. The +/// returned buffer is valid until the TCString is freed or passed to another TaskChampio +/// function. +/// +/// This function takes the TCString by pointer because it may be modified in-place to add a NUL +/// terminator. The pointer must not be NULL. +/// +/// This function does _not_ take ownership of the TCString. +#[no_mangle] +pub unsafe extern "C" fn tc_string_content_with_len( + tcstring: *const TCString, + len_out: *mut usize, +) -> *const libc::c_char { + // SAFETY; + // - tcstring is not NULL (promised by caller) + // - *tcstring is valid (promised by caller) + // - *tcstring is not accessed concurrently (single-threaded) + unsafe { + wrap(tcstring, |rstring| { + let bytes = rstring.as_bytes(); + + // SAFETY: + // - len_out is not NULL (promised by caller) + // - len_out points to valid memory (promised by caller) + // - len_out is properly aligned (C convention) + usize::val_to_arg_out(bytes.len(), len_out); + bytes.as_ptr() as *const libc::c_char + }) + } +} + +/// Free a TCString. The given string must not be NULL. The string must not be used +/// after this function returns, and must not be freed more than once. +#[no_mangle] +pub unsafe extern "C" fn tc_string_free(tcstring: *mut TCString) { + // SAFETY: + // - tcstring is not NULL (promised by caller) + // - caller is exclusive owner of tcstring (promised by caller) + drop(unsafe { TCString::take_val_from_arg(tcstring, TCString::default()) }); +} + +/// Free a TCStringList instance. The instance, and all TCStringList it contains, must not be used after +/// this call. +/// +/// When this call returns, the `items` pointer will be NULL, signalling an invalid TCStringList. +#[no_mangle] +pub unsafe extern "C" fn tc_string_list_free(tcstrings: *mut TCStringList) { + // SAFETY: + // - tcstrings is not NULL and points to a valid TCStringList (caller is not allowed to + // modify the list) + // - caller promises not to use the value after return + unsafe { drop_value_list(tcstrings) }; +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn empty_list_has_non_null_pointer() { + let tcstrings = unsafe { TCStringList::return_val(Vec::new()) }; + assert!(!tcstrings.items.is_null()); + assert_eq!(tcstrings.len, 0); + assert_eq!(tcstrings._capacity, 0); + } + + #[test] + fn free_sets_null_pointer() { + let mut tcstrings = unsafe { TCStringList::return_val(Vec::new()) }; + // SAFETY: testing expected behavior + unsafe { tc_string_list_free(&mut tcstrings) }; + assert!(tcstrings.items.is_null()); + assert_eq!(tcstrings.len, 0); + assert_eq!(tcstrings._capacity, 0); + } + + const INVALID_UTF8: &[u8] = b"abc\xf0\x28\x8c\x28"; + + fn make_cstring() -> RustString<'static> { + RustString::CString(CString::new("a string").unwrap()) + } + + fn make_cstr() -> RustString<'static> { + let cstr = CStr::from_bytes_with_nul(b"a string\0").unwrap(); + RustString::CStr(&cstr) + } + + fn make_string() -> RustString<'static> { + RustString::String("a string".into()) + } + + fn make_string_with_nul() -> RustString<'static> { + RustString::String("a \0 nul!".into()) + } + + fn make_invalid_bytes() -> RustString<'static> { + RustString::Bytes(INVALID_UTF8.to_vec()) + } + + fn make_bytes() -> RustString<'static> { + RustString::Bytes(b"bytes".to_vec()) + } + + #[test] + fn cstring_as_str() { + assert_eq!(make_cstring().as_str().unwrap(), "a string"); + } + + #[test] + fn cstr_as_str() { + assert_eq!(make_cstr().as_str().unwrap(), "a string"); + } + + #[test] + fn string_as_str() { + assert_eq!(make_string().as_str().unwrap(), "a string"); + } + + #[test] + fn string_with_nul_as_str() { + assert_eq!(make_string_with_nul().as_str().unwrap(), "a \0 nul!"); + } + + #[test] + fn invalid_bytes_as_str() { + let as_str_err = make_invalid_bytes().as_str().unwrap_err(); + assert_eq!(as_str_err.valid_up_to(), 3); // "abc" is valid + } + + #[test] + fn valid_bytes_as_str() { + assert_eq!(make_bytes().as_str().unwrap(), "bytes"); + } + + #[test] + fn cstring_as_bytes() { + assert_eq!(make_cstring().as_bytes(), b"a string"); + } + + #[test] + fn cstr_as_bytes() { + assert_eq!(make_cstr().as_bytes(), b"a string"); + } + + #[test] + fn string_as_bytes() { + assert_eq!(make_string().as_bytes(), b"a string"); + } + + #[test] + fn string_with_nul_as_bytes() { + assert_eq!(make_string_with_nul().as_bytes(), b"a \0 nul!"); + } + + #[test] + fn invalid_bytes_as_bytes() { + assert_eq!(make_invalid_bytes().as_bytes(), INVALID_UTF8); + } + + #[test] + fn cstring_string_to_cstring() { + let mut tcstring = make_cstring(); + tcstring.string_to_cstring(); + assert_eq!(tcstring, make_cstring()); // unchanged + } + + #[test] + fn cstr_string_to_cstring() { + let mut tcstring = make_cstr(); + tcstring.string_to_cstring(); + assert_eq!(tcstring, make_cstr()); // unchanged + } + + #[test] + fn string_string_to_cstring() { + let mut tcstring = make_string(); + tcstring.string_to_cstring(); + assert_eq!(tcstring, make_cstring()); // converted to CString, same content + } + + #[test] + fn string_with_nul_string_to_cstring() { + let mut tcstring = make_string_with_nul(); + tcstring.string_to_cstring(); + assert_eq!(tcstring, make_string_with_nul()); // unchanged + } + + #[test] + fn bytes_string_to_cstring() { + let mut tcstring = make_bytes(); + tcstring.string_to_cstring(); + assert_eq!(tcstring, make_bytes()); // unchanged + } +} diff --git a/rust/lib/src/task.rs b/rust/lib/src/task.rs new file mode 100644 index 000000000..dce84d7aa --- /dev/null +++ b/rust/lib/src/task.rs @@ -0,0 +1,934 @@ +use crate::traits::*; +use crate::types::*; +use crate::util::err_to_ruststring; +use std::convert::TryFrom; +use std::ops::Deref; +use std::ptr::NonNull; +use std::str::FromStr; +use taskchampion::chrono::{TimeZone, Utc}; +use taskchampion::{Annotation, Tag, Task, TaskMut, Uuid}; + +/// A task, as publicly exposed by this library. +/// +/// A task begins in "immutable" mode. It must be converted to "mutable" mode +/// to make any changes, and doing so requires exclusive access to the replica +/// until the task is freed or converted back to immutable mode. +/// +/// An immutable task carries no reference to the replica that created it, and can be used until it +/// is freed or converted to a TaskMut. A mutable task carries a reference to the replica and +/// must be freed or made immutable before the replica is freed. +/// +/// All `tc_task_..` functions taking a task as an argument require that it not be NULL. +/// +/// When a `tc_task_..` function that returns a TCResult returns TC_RESULT_ERROR, then +/// `tc_task_error` will return the error message. +/// +/// # Safety +/// +/// A task is an owned object, and must be freed with tc_task_free (or, if part of a list, +/// with tc_task_list_free). +/// +/// Any function taking a `*TCTask` requires: +/// - the pointer must not be NUL; +/// - the pointer must be one previously returned from a tc_… function; +/// - the memory referenced by the pointer must never be modified by C code; and +/// - except for `tc_{task,task_list}_free`, ownership of a `*TCTask` remains with the caller. +/// +/// Once passed to tc_task_free, a `*TCTask` becomes invalid and must not be used again. +/// +/// TCTasks are not threadsafe. +pub struct TCTask { + /// The wrapped Task or TaskMut + inner: Inner, + + /// The error from the most recent operation, if any + error: Option>, +} + +enum Inner { + /// A regular, immutable task + Immutable(Task), + + /// A mutable task, together with the replica to which it holds an exclusive + /// reference. + Mutable(TaskMut<'static>, *mut TCReplica), + + /// A transitional state for a TCTask as it goes from mutable to immutable and back. A task + /// can only be in this state outside of [`to_mut`] and [`to_immut`] if a panic occurs during + /// one of those methods. + Invalid, +} + +impl PassByPointer for TCTask {} + +impl TCTask { + /// Make an immutable TCTask into a mutable TCTask. Does nothing if the task + /// is already mutable. + /// + /// # Safety + /// + /// The tcreplica pointer must not be NULL, and the replica it points to must not + /// be freed before TCTask.to_immut completes. + unsafe fn to_mut(&mut self, tcreplica: *mut TCReplica) { + self.inner = match std::mem::replace(&mut self.inner, Inner::Invalid) { + Inner::Immutable(task) => { + // SAFETY: + // - tcreplica is not null (promised by caller) + // - tcreplica outlives the pointer in this variant (promised by caller) + let tcreplica_ref: &mut TCReplica = + unsafe { TCReplica::from_ptr_arg_ref_mut(tcreplica) }; + let rep_ref = tcreplica_ref.borrow_mut(); + Inner::Mutable(task.into_mut(rep_ref), tcreplica) + } + Inner::Mutable(task, tcreplica) => Inner::Mutable(task, tcreplica), + Inner::Invalid => unreachable!(), + } + } + + /// Make an mutable TCTask into a immutable TCTask. Does nothing if the task + /// is already immutable. + #[allow(clippy::wrong_self_convention)] // to_immut_mut is not better! + fn to_immut(&mut self) { + self.inner = match std::mem::replace(&mut self.inner, Inner::Invalid) { + Inner::Immutable(task) => Inner::Immutable(task), + Inner::Mutable(task, tcreplica) => { + // SAFETY: + // - tcreplica is not null (promised by caller of to_mut, which created this + // variant) + // - tcreplica is still alive (promised by caller of to_mut) + let tcreplica_ref: &mut TCReplica = + unsafe { TCReplica::from_ptr_arg_ref_mut(tcreplica) }; + tcreplica_ref.release_borrow(); + Inner::Immutable(task.into_immut()) + } + Inner::Invalid => unreachable!(), + } + } +} + +impl From for TCTask { + fn from(task: Task) -> TCTask { + TCTask { + inner: Inner::Immutable(task), + error: None, + } + } +} + +/// Utility function to get a shared reference to the underlying Task. All Task getters +/// are error-free, so this does not handle errors. +fn wrap(task: *mut TCTask, f: F) -> T +where + F: FnOnce(&Task) -> T, +{ + // SAFETY: + // - task is not null (promised by caller) + // - task outlives this function (promised by caller) + let tctask: &mut TCTask = unsafe { TCTask::from_ptr_arg_ref_mut(task) }; + let task: &Task = match &tctask.inner { + Inner::Immutable(t) => t, + Inner::Mutable(t, _) => t.deref(), + Inner::Invalid => unreachable!(), + }; + tctask.error = None; + f(task) +} + +/// Utility function to get a mutable reference to the underlying Task. The +/// TCTask must be mutable. The inner function may use `?` syntax to return an +/// error, which will be represented with the `err_value` returned to C. +fn wrap_mut(task: *mut TCTask, f: F, err_value: T) -> T +where + F: FnOnce(&mut TaskMut) -> anyhow::Result, +{ + // SAFETY: + // - task is not null (promised by caller) + // - task outlives this function (promised by caller) + let tctask: &mut TCTask = unsafe { TCTask::from_ptr_arg_ref_mut(task) }; + let task: &mut TaskMut = match tctask.inner { + Inner::Immutable(_) => panic!("Task is immutable"), + Inner::Mutable(ref mut t, _) => t, + Inner::Invalid => unreachable!(), + }; + tctask.error = None; + match f(task) { + Ok(rv) => rv, + Err(e) => { + tctask.error = Some(err_to_ruststring(e)); + err_value + } + } +} + +impl TryFrom> for Tag { + type Error = anyhow::Error; + + fn try_from(mut rstring: RustString) -> Result { + let tagstr = rstring.as_str()?; + Tag::from_str(tagstr) + } +} + +/// TCTaskList represents a list of tasks. +/// +/// The content of this struct must be treated as read-only: no fields or anything they reference +/// should be modified directly by C code. +/// +/// When an item is taken from this list, its pointer in `items` is set to NULL. +#[repr(C)] +pub struct TCTaskList { + /// number of tasks in items + len: libc::size_t, + + /// total size of items (internal use only) + _capacity: libc::size_t, + + /// array of pointers representing each task. these remain owned by the TCTaskList instance and + /// will be freed by tc_task_list_free. This pointer is never NULL for a valid TCTaskList, + /// and the *TCTaskList at indexes 0..len-1 are not NULL. + items: *mut Option>, +} + +impl CList for TCTaskList { + type Element = Option>; + + unsafe fn from_raw_parts(items: *mut Self::Element, len: usize, cap: usize) -> Self { + TCTaskList { + len, + _capacity: cap, + items, + } + } + + fn slice(&mut self) -> &mut [Self::Element] { + // SAFETY: + // - because we have &mut self, we have read/write access to items[0..len] + // - all items are properly initialized Element's + // - return value lifetime is equal to &mmut self's, so access is exclusive + // - items and len came from Vec, so total size is < isize::MAX + unsafe { std::slice::from_raw_parts_mut(self.items, self.len) } + } + + fn into_raw_parts(self) -> (*mut Self::Element, usize, usize) { + (self.items, self.len, self._capacity) + } +} + +/// Convert an immutable task into a mutable task. +/// +/// The task must not be NULL. It is modified in-place, and becomes mutable. +/// +/// The replica must not be NULL. After this function returns, the replica _cannot be used at all_ +/// until this task is made immutable again. This implies that it is not allowed for more than one +/// task associated with a replica to be mutable at any time. +/// +/// Typical mutation of tasks is bracketed with `tc_task_to_mut` and `tc_task_to_immut`: +/// +/// ```c +/// tc_task_to_mut(task, rep); +/// success = tc_task_done(task); +/// tc_task_to_immut(task, rep); +/// if (!success) { ... } +/// ``` +#[no_mangle] +pub unsafe extern "C" fn tc_task_to_mut(task: *mut TCTask, tcreplica: *mut TCReplica) { + // SAFETY: + // - task is not null (promised by caller) + // - task outlives 'a (promised by caller) + let tctask: &mut TCTask = unsafe { TCTask::from_ptr_arg_ref_mut(task) }; + // SAFETY: + // - tcreplica is not NULL (promised by caller) + // - tcreplica lives until later call to to_immut via tc_task_to_immut (promised by caller, + // who cannot call tc_replica_free during this time) + unsafe { tctask.to_mut(tcreplica) }; +} + +/// Convert a mutable task into an immutable task. +/// +/// The task must not be NULL. It is modified in-place, and becomes immutable. +/// +/// The replica passed to `tc_task_to_mut` may be used freely after this call. +#[no_mangle] +pub unsafe extern "C" fn tc_task_to_immut(task: *mut TCTask) { + // SAFETY: + // - task is not null (promised by caller) + // - task outlives 'a (promised by caller) + let tctask: &mut TCTask = unsafe { TCTask::from_ptr_arg_ref_mut(task) }; + tctask.to_immut(); +} + +/// Get a task's UUID. +#[no_mangle] +pub unsafe extern "C" fn tc_task_get_uuid(task: *mut TCTask) -> TCUuid { + wrap(task, |task| { + // SAFETY: + // - value is not allocated and need not be freed + unsafe { TCUuid::return_val(task.get_uuid()) } + }) +} + +/// Get a task's status. +#[no_mangle] +pub unsafe extern "C" fn tc_task_get_status(task: *mut TCTask) -> TCStatus { + wrap(task, |task| task.get_status().into()) +} + +/// Get the underlying key/value pairs for this task. The returned TCKVList is +/// a "snapshot" of the task and will not be updated if the task is subsequently +/// modified. It is the caller's responsibility to free the TCKVList. +#[no_mangle] +pub unsafe extern "C" fn tc_task_get_taskmap(task: *mut TCTask) -> TCKVList { + wrap(task, |task| { + let vec: Vec = task + .get_taskmap() + .iter() + .map(|(k, v)| { + let key = RustString::from(k.as_ref()); + let value = RustString::from(v.as_ref()); + TCKV::as_ctype((key, value)) + }) + .collect(); + // SAFETY: + // - caller will free this list + unsafe { TCKVList::return_val(vec) } + }) +} + +/// Get a task's description, or NULL if the task cannot be represented as a C string (e.g., if it +/// contains embedded NUL characters). +#[no_mangle] +pub unsafe extern "C" fn tc_task_get_description(task: *mut TCTask) -> TCString { + wrap(task, |task| { + let descr = task.get_description(); + // SAFETY: + // - caller promises to free this string + unsafe { TCString::return_val(descr.into()) } + }) +} + +/// Get the entry timestamp for a task (when it was created), or 0 if not set. +#[no_mangle] +pub unsafe extern "C" fn tc_task_get_entry(task: *mut TCTask) -> libc::time_t { + wrap(task, |task| libc::time_t::as_ctype(task.get_entry())) +} + +/// Get the wait timestamp for a task, or 0 if not set. +#[no_mangle] +pub unsafe extern "C" fn tc_task_get_wait(task: *mut TCTask) -> libc::time_t { + wrap(task, |task| libc::time_t::as_ctype(task.get_wait())) +} + +/// Get the modified timestamp for a task, or 0 if not set. +#[no_mangle] +pub unsafe extern "C" fn tc_task_get_modified(task: *mut TCTask) -> libc::time_t { + wrap(task, |task| libc::time_t::as_ctype(task.get_modified())) +} + +/// Check if a task is waiting. +#[no_mangle] +pub unsafe extern "C" fn tc_task_is_waiting(task: *mut TCTask) -> bool { + wrap(task, |task| task.is_waiting()) +} + +/// Check if a task is active (started and not stopped). +#[no_mangle] +pub unsafe extern "C" fn tc_task_is_active(task: *mut TCTask) -> bool { + wrap(task, |task| task.is_active()) +} + +/// Check if a task has the given tag. If the tag is invalid, this function will return false, as +/// that (invalid) tag is not present. No error will be reported via `tc_task_error`. +#[no_mangle] +pub unsafe extern "C" fn tc_task_has_tag(task: *mut TCTask, tag: TCString) -> bool { + // SAFETY: + // - tag is valid (promised by caller) + // - caller will not use tag after this call (convention) + let tcstring = unsafe { TCString::val_from_arg(tag) }; + wrap(task, |task| { + if let Ok(tag) = Tag::try_from(tcstring) { + task.has_tag(&tag) + } else { + false + } + }) +} + +/// Get the tags for the task. +/// +/// The caller must free the returned TCStringList instance. The TCStringList instance does not +/// reference the task and the two may be freed in any order. +#[no_mangle] +pub unsafe extern "C" fn tc_task_get_tags(task: *mut TCTask) -> TCStringList { + wrap(task, |task| { + let vec: Vec = task + .get_tags() + .map(|t| { + // SAFETY: + // - this TCString will be freed via tc_string_list_free. + unsafe { TCString::return_val(t.as_ref().into()) } + }) + .collect(); + // SAFETY: + // - caller will free the list + unsafe { TCStringList::return_val(vec) } + }) +} + +/// Get the annotations for the task. +/// +/// The caller must free the returned TCAnnotationList instance. The TCStringList instance does not +/// reference the task and the two may be freed in any order. +#[no_mangle] +pub unsafe extern "C" fn tc_task_get_annotations(task: *mut TCTask) -> TCAnnotationList { + wrap(task, |task| { + let vec: Vec = task + .get_annotations() + .map(|a| { + let description = RustString::from(a.description); + TCAnnotation::as_ctype((a.entry, description)) + }) + .collect(); + // SAFETY: + // - caller will free the list + unsafe { TCAnnotationList::return_val(vec) } + }) +} + +/// Get the named UDA from the task. +/// +/// Returns a TCString with NULL ptr field if the UDA does not exist. +#[no_mangle] +pub unsafe extern "C" fn tc_task_get_uda( + task: *mut TCTask, + ns: TCString, + key: TCString, +) -> TCString { + wrap(task, |task| { + // SAFETY: + // - ns is valid (promised by caller) + // - caller will not use ns after this call (convention) + if let Ok(ns) = unsafe { TCString::val_from_arg(ns) }.as_str() { + // SAFETY: same + if let Ok(key) = unsafe { TCString::val_from_arg(key) }.as_str() { + if let Some(value) = task.get_uda(ns, key) { + // SAFETY: + // - caller will free this string (caller promises) + return unsafe { TCString::return_val(value.into()) }; + } + } + } + TCString::default() + }) +} + +/// Get the named legacy UDA from the task. +/// +/// Returns NULL if the UDA does not exist. +#[no_mangle] +pub unsafe extern "C" fn tc_task_get_legacy_uda(task: *mut TCTask, key: TCString) -> TCString { + wrap(task, |task| { + // SAFETY: + // - key is valid (promised by caller) + // - caller will not use key after this call (convention) + if let Ok(key) = unsafe { TCString::val_from_arg(key) }.as_str() { + if let Some(value) = task.get_legacy_uda(key) { + // SAFETY: + // - caller will free this string (caller promises) + return unsafe { TCString::return_val(value.into()) }; + } + } + TCString::default() + }) +} + +/// Get all UDAs for this task. +/// +/// Legacy UDAs are represented with an empty string in the ns field. +#[no_mangle] +pub unsafe extern "C" fn tc_task_get_udas(task: *mut TCTask) -> TCUdaList { + wrap(task, |task| { + let vec: Vec = task + .get_udas() + .map(|((ns, key), value)| { + // SAFETY: + // - will be freed by tc_uda_list_free + unsafe { + TCUda::return_val(Uda { + ns: Some(ns.into()), + key: key.into(), + value: value.into(), + }) + } + }) + .collect(); + // SAFETY: + // - caller will free this list + unsafe { TCUdaList::return_val(vec) } + }) +} + +/// Get all UDAs for this task. +/// +/// All TCUdas in this list have a NULL ns field. The entire UDA key is +/// included in the key field. The caller must free the returned list. +#[no_mangle] +pub unsafe extern "C" fn tc_task_get_legacy_udas(task: *mut TCTask) -> TCUdaList { + wrap(task, |task| { + let vec: Vec = task + .get_legacy_udas() + .map(|(key, value)| { + // SAFETY: + // - will be freed by tc_uda_list_free + unsafe { + TCUda::return_val(Uda { + ns: None, + key: key.into(), + value: value.into(), + }) + } + }) + .collect(); + // SAFETY: + // - caller will free this list + unsafe { TCUdaList::return_val(vec) } + }) +} + +/// Set a mutable task's status. +#[no_mangle] +pub unsafe extern "C" fn tc_task_set_status(task: *mut TCTask, status: TCStatus) -> TCResult { + wrap_mut( + task, + |task| { + task.set_status(status.into())?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Set a mutable task's description. +#[no_mangle] +pub unsafe extern "C" fn tc_task_set_description( + task: *mut TCTask, + description: TCString, +) -> TCResult { + // SAFETY: + // - description is valid (promised by caller) + // - caller will not use description after this call (convention) + let mut description = unsafe { TCString::val_from_arg(description) }; + wrap_mut( + task, + |task| { + task.set_description(description.as_str()?.to_string())?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Set a mutable task's entry (creation time). Pass entry=0 to unset +/// the entry field. +#[no_mangle] +pub unsafe extern "C" fn tc_task_set_entry(task: *mut TCTask, entry: libc::time_t) -> TCResult { + wrap_mut( + task, + |task| { + // SAFETY: any time_t value is a valid timestamp + task.set_entry(unsafe { entry.from_ctype() })?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Set a mutable task's wait timestamp. Pass wait=0 to unset the wait field. +#[no_mangle] +pub unsafe extern "C" fn tc_task_set_wait(task: *mut TCTask, wait: libc::time_t) -> TCResult { + wrap_mut( + task, + |task| { + // SAFETY: any time_t value is a valid timestamp + task.set_wait(unsafe { wait.from_ctype() })?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Set a mutable task's modified timestamp. The value cannot be zero. +#[no_mangle] +pub unsafe extern "C" fn tc_task_set_modified( + task: *mut TCTask, + modified: libc::time_t, +) -> TCResult { + wrap_mut( + task, + |task| { + task.set_modified( + // SAFETY: any time_t value is a valid timestamp + unsafe { modified.from_ctype() } + .ok_or_else(|| anyhow::anyhow!("modified cannot be zero"))?, + )?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Start a task. +#[no_mangle] +pub unsafe extern "C" fn tc_task_start(task: *mut TCTask) -> TCResult { + wrap_mut( + task, + |task| { + task.start()?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Stop a task. +#[no_mangle] +pub unsafe extern "C" fn tc_task_stop(task: *mut TCTask) -> TCResult { + wrap_mut( + task, + |task| { + task.stop()?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Mark a task as done. +#[no_mangle] +pub unsafe extern "C" fn tc_task_done(task: *mut TCTask) -> TCResult { + wrap_mut( + task, + |task| { + task.done()?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Mark a task as deleted. +#[no_mangle] +pub unsafe extern "C" fn tc_task_delete(task: *mut TCTask) -> TCResult { + wrap_mut( + task, + |task| { + task.delete()?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Add a tag to a mutable task. +#[no_mangle] +pub unsafe extern "C" fn tc_task_add_tag(task: *mut TCTask, tag: TCString) -> TCResult { + // SAFETY: + // - tag is valid (promised by caller) + // - caller will not use tag after this call (convention) + let tcstring = unsafe { TCString::val_from_arg(tag) }; + wrap_mut( + task, + |task| { + let tag = Tag::try_from(tcstring)?; + task.add_tag(&tag)?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Remove a tag from a mutable task. +#[no_mangle] +pub unsafe extern "C" fn tc_task_remove_tag(task: *mut TCTask, tag: TCString) -> TCResult { + // SAFETY: + // - tag is valid (promised by caller) + // - caller will not use tag after this call (convention) + let tcstring = unsafe { TCString::val_from_arg(tag) }; + wrap_mut( + task, + |task| { + let tag = Tag::try_from(tcstring)?; + task.remove_tag(&tag)?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Add an annotation to a mutable task. This call takes ownership of the +/// passed annotation, which must not be used after the call returns. +#[no_mangle] +pub unsafe extern "C" fn tc_task_add_annotation( + task: *mut TCTask, + annotation: *mut TCAnnotation, +) -> TCResult { + // SAFETY: + // - annotation is not NULL (promised by caller) + // - annotation is return from a tc_string_.. so is valid + // - caller will not use annotation after this call + let (entry, description) = + unsafe { TCAnnotation::take_val_from_arg(annotation, TCAnnotation::default()) }; + wrap_mut( + task, + |task| { + let description = description.into_string()?; + task.add_annotation(Annotation { entry, description })?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Remove an annotation from a mutable task. +#[no_mangle] +pub unsafe extern "C" fn tc_task_remove_annotation(task: *mut TCTask, entry: i64) -> TCResult { + wrap_mut( + task, + |task| { + task.remove_annotation(Utc.timestamp(entry, 0))?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Set a UDA on a mutable task. +#[no_mangle] +pub unsafe extern "C" fn tc_task_set_uda( + task: *mut TCTask, + ns: TCString, + key: TCString, + value: TCString, +) -> TCResult { + // safety: + // - ns is valid (promised by caller) + // - caller will not use ns after this call (convention) + let mut ns = unsafe { TCString::val_from_arg(ns) }; + // SAFETY: same + let mut key = unsafe { TCString::val_from_arg(key) }; + // SAFETY: same + let mut value = unsafe { TCString::val_from_arg(value) }; + wrap_mut( + task, + |task| { + task.set_uda(ns.as_str()?, key.as_str()?, value.as_str()?.to_string())?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Remove a UDA fraom a mutable task. +#[no_mangle] +pub unsafe extern "C" fn tc_task_remove_uda( + task: *mut TCTask, + ns: TCString, + key: TCString, +) -> TCResult { + // safety: + // - ns is valid (promised by caller) + // - caller will not use ns after this call (convention) + let mut ns = unsafe { TCString::val_from_arg(ns) }; + // SAFETY: same + let mut key = unsafe { TCString::val_from_arg(key) }; + wrap_mut( + task, + |task| { + task.remove_uda(ns.as_str()?, key.as_str()?)?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Set a legacy UDA on a mutable task. +#[no_mangle] +pub unsafe extern "C" fn tc_task_set_legacy_uda( + task: *mut TCTask, + key: TCString, + value: TCString, +) -> TCResult { + // safety: + // - key is valid (promised by caller) + // - caller will not use key after this call (convention) + let mut key = unsafe { TCString::val_from_arg(key) }; + // SAFETY: same + let mut value = unsafe { TCString::val_from_arg(value) }; + wrap_mut( + task, + |task| { + task.set_legacy_uda(key.as_str()?.to_string(), value.as_str()?.to_string())?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Remove a UDA fraom a mutable task. +#[no_mangle] +pub unsafe extern "C" fn tc_task_remove_legacy_uda(task: *mut TCTask, key: TCString) -> TCResult { + // safety: + // - key is valid (promised by caller) + // - caller will not use key after this call (convention) + let mut key = unsafe { TCString::val_from_arg(key) }; + wrap_mut( + task, + |task| { + task.remove_legacy_uda(key.as_str()?.to_string())?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Get all dependencies for a task. +#[no_mangle] +pub unsafe extern "C" fn tc_task_get_dependencies(task: *mut TCTask) -> TCUuidList { + wrap(task, |task| { + let vec: Vec = task + .get_dependencies() + .map(|u| { + // SAFETY: + // - value is not allocated + unsafe { TCUuid::return_val(u) } + }) + .collect(); + // SAFETY: + // - caller will free this list + unsafe { TCUuidList::return_val(vec) } + }) +} + +/// Add a dependency. +#[no_mangle] +pub unsafe extern "C" fn tc_task_add_dependency(task: *mut TCTask, dep: TCUuid) -> TCResult { + // SAFETY: + // - tcuuid is a valid TCUuid (all byte patterns are valid) + let dep: Uuid = unsafe { TCUuid::val_from_arg(dep) }; + wrap_mut( + task, + |task| { + task.add_dependency(dep)?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Remove a dependency. +#[no_mangle] +pub unsafe extern "C" fn tc_task_remove_dependency(task: *mut TCTask, dep: TCUuid) -> TCResult { + // SAFETY: + // - tcuuid is a valid TCUuid (all byte patterns are valid) + let dep: Uuid = unsafe { TCUuid::val_from_arg(dep) }; + wrap_mut( + task, + |task| { + task.remove_dependency(dep)?; + Ok(TCResult::Ok) + }, + TCResult::Error, + ) +} + +/// Get the latest error for a task, or a string NULL ptr field if the last operation succeeded. +/// Subsequent calls to this function will return NULL. The task pointer must not be NULL. The +/// caller must free the returned string. +#[no_mangle] +pub unsafe extern "C" fn tc_task_error(task: *mut TCTask) -> TCString { + // SAFETY: + // - task is not null (promised by caller) + // - task outlives 'a (promised by caller) + let task: &mut TCTask = unsafe { TCTask::from_ptr_arg_ref_mut(task) }; + if let Some(rstring) = task.error.take() { + // SAFETY: + // - caller promises to free this value + unsafe { TCString::return_val(rstring) } + } else { + TCString::default() + } +} + +/// Free a task. The given task must not be NULL. The task must not be used after this function +/// returns, and must not be freed more than once. +/// +/// If the task is currently mutable, it will first be made immutable. +#[no_mangle] +pub unsafe extern "C" fn tc_task_free(task: *mut TCTask) { + // SAFETY: + // - task is not NULL (promised by caller) + // - caller will not use the TCTask after this (promised by caller) + let mut tctask = unsafe { TCTask::take_from_ptr_arg(task) }; + + // convert to immut if it was mutable + tctask.to_immut(); + + drop(tctask); +} + +/// Take an item from a TCTaskList. After this call, the indexed item is no longer associated +/// with the list and becomes the caller's responsibility, just as if it had been returned from +/// `tc_replica_get_task`. +/// +/// The corresponding element in the `items` array will be set to NULL. If that field is already +/// NULL (that is, if the item has already been taken), this function will return NULL. If the +/// index is out of bounds, this function will also return NULL. +/// +/// The passed TCTaskList remains owned by the caller. +#[no_mangle] +pub unsafe extern "C" fn tc_task_list_take(tasks: *mut TCTaskList, index: usize) -> *mut TCTask { + // SAFETY: + // - tasks is not NULL and points to a valid TCTaskList (caller is not allowed to + // modify the list directly, and tc_task_list_take leaves the list valid) + let p = unsafe { take_optional_pointer_list_item(tasks, index) }; + if let Some(p) = p { + p.as_ptr() + } else { + std::ptr::null_mut() + } +} + +/// Free a TCTaskList instance. The instance, and all TCTaskList it contains, must not be used after +/// this call. +/// +/// When this call returns, the `items` pointer will be NULL, signalling an invalid TCTaskList. +#[no_mangle] +pub unsafe extern "C" fn tc_task_list_free(tasks: *mut TCTaskList) { + // SAFETY: + // - tasks is not NULL and points to a valid TCTaskList (caller is not allowed to + // modify the list directly, and tc_task_list_take leaves the list valid) + // - caller promises not to use the value after return + unsafe { drop_optional_pointer_list(tasks) }; +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn empty_list_has_non_null_pointer() { + let tasks = unsafe { TCTaskList::return_val(Vec::new()) }; + assert!(!tasks.items.is_null()); + assert_eq!(tasks.len, 0); + assert_eq!(tasks._capacity, 0); + } + + #[test] + fn free_sets_null_pointer() { + let mut tasks = unsafe { TCTaskList::return_val(Vec::new()) }; + // SAFETY: testing expected behavior + unsafe { tc_task_list_free(&mut tasks) }; + assert!(tasks.items.is_null()); + assert_eq!(tasks.len, 0); + assert_eq!(tasks._capacity, 0); + } +} diff --git a/rust/lib/src/traits.rs b/rust/lib/src/traits.rs new file mode 100644 index 000000000..5322eccb0 --- /dev/null +++ b/rust/lib/src/traits.rs @@ -0,0 +1,353 @@ +use crate::util::vec_into_raw_parts; +use std::ptr::NonNull; + +/// Support for values passed to Rust by value. These are represented as full structs in C. Such +/// values are implicitly copyable, via C's struct assignment. +/// +/// The Rust and C types may differ, with from_ctype and as_ctype converting between them. +/// Implement this trait for the C type. +/// +/// The RustType must be droppable (not containing raw pointers). +pub(crate) trait PassByValue: Sized { + type RustType; + + /// Convert a C value to a Rust value. + /// + /// # Safety + /// + /// `self` must be a valid CType. + #[allow(clippy::wrong_self_convention)] + unsafe fn from_ctype(self) -> Self::RustType; + + /// Convert a Rust value to a C value. + fn as_ctype(arg: Self::RustType) -> Self; + + /// Take a value from C as an argument. + /// + /// # Safety + /// + /// - `self` must be a valid instance of the C type. This is typically ensured either by + /// requiring that C code not modify it, or by defining the valid values in C comments. + unsafe fn val_from_arg(arg: Self) -> Self::RustType { + // SAFETY: + // - arg is a valid CType (promised by caller) + unsafe { arg.from_ctype() } + } + + /// Take a value from C as a pointer argument, replacing it with the given value. This is used + /// to invalidate the C value as an additional assurance against subsequent use of the value. + /// + /// # Safety + /// + /// - arg must not be NULL + /// - *arg must be a valid, properly aligned instance of the C type + unsafe fn take_val_from_arg(arg: *mut Self, mut replacement: Self) -> Self::RustType { + // SAFETY: + // - arg is valid (promised by caller) + // - replacement is valid and aligned (guaranteed by Rust) + unsafe { std::ptr::swap(arg, &mut replacement) }; + // SAFETY: + // - replacement (formerly *arg) is a valid CType (promised by caller) + unsafe { PassByValue::val_from_arg(replacement) } + } + + /// Return a value to C + /// + /// # Safety + /// + /// - if the value is allocated, the caller must ensure that the value is eventually freed + unsafe fn return_val(arg: Self::RustType) -> Self { + Self::as_ctype(arg) + } + + /// Return a value to C, via an "output parameter" + /// + /// # Safety + /// + /// - `arg_out` must not be NULL and must be properly aligned and pointing to valid memory + /// of the size of CType. + unsafe fn val_to_arg_out(val: Self::RustType, arg_out: *mut Self) { + debug_assert!(!arg_out.is_null()); + // SAFETY: + // - arg_out is not NULL (promised by caller, asserted) + // - arg_out is properly aligned and points to valid memory (promised by caller) + unsafe { *arg_out = Self::as_ctype(val) }; + } +} + +/// Support for values passed to Rust by pointer. These are represented as opaque structs in C, +/// and always handled as pointers. +pub(crate) trait PassByPointer: Sized { + /// Take a value from C as an argument. + /// + /// # Safety + /// + /// - arg must not be NULL + /// - arg must be a value returned from Box::into_raw (via return_ptr or ptr_to_arg_out) + /// - arg becomes invalid and must not be used after this call + unsafe fn take_from_ptr_arg(arg: *mut Self) -> Self { + debug_assert!(!arg.is_null()); + // SAFETY: see docstring + unsafe { *(Box::from_raw(arg)) } + } + + /// Borrow a value from C as an argument. + /// + /// # Safety + /// + /// - arg must not be NULL + /// - *arg must be a valid instance of Self + /// - arg must be valid for the lifetime assigned by the caller + /// - arg must not be modified by anything else during that lifetime + unsafe fn from_ptr_arg_ref<'a>(arg: *const Self) -> &'a Self { + debug_assert!(!arg.is_null()); + // SAFETY: see docstring + unsafe { &*arg } + } + + /// Mutably borrow a value from C as an argument. + /// + /// # Safety + /// + /// - arg must not be NULL + /// - *arg must be a valid instance of Self + /// - arg must be valid for the lifetime assigned by the caller + /// - arg must not be accessed by anything else during that lifetime + unsafe fn from_ptr_arg_ref_mut<'a>(arg: *mut Self) -> &'a mut Self { + debug_assert!(!arg.is_null()); + // SAFETY: see docstring + unsafe { &mut *arg } + } + + /// Return a value to C, transferring ownership + /// + /// # Safety + /// + /// - the caller must ensure that the value is eventually freed + unsafe fn return_ptr(self) -> *mut Self { + Box::into_raw(Box::new(self)) + } + + /// Return a value to C, transferring ownership, via an "output parameter". + /// + /// # Safety + /// + /// - the caller must ensure that the value is eventually freed + /// - arg_out must not be NULL + /// - arg_out must point to valid, properly aligned memory for a pointer value + unsafe fn ptr_to_arg_out(self, arg_out: *mut *mut Self) { + debug_assert!(!arg_out.is_null()); + // SAFETY: see docstring + unsafe { *arg_out = self.return_ptr() }; + } +} + +/// Support for C lists of objects referenced by value. +/// +/// The underlying C type should have three fields, containing items, length, and capacity. The +/// required trait functions just fetch and set these fields. +/// +/// The PassByValue trait will be implemented automatically, converting between the C type and +/// `Vec`. +/// +/// The element type can be PassByValue or PassByPointer. If the latter, it should use either +/// `NonNull` or `Option>` to represent the element. The latter is an "optional +/// pointer list", where elements can be omitted. +/// +/// For most cases, it is only necessary to implement `tc_.._free` that calls one of the +/// drop_..._list functions. +/// +/// # Safety +/// +/// The C type must be documented as read-only. None of the fields may be modified, nor anything +/// accessible via the `items` array. The exception is modification via "taking" elements. +/// +/// This class guarantees that the items pointer is non-NULL for any valid list (even when len=0). +pub(crate) trait CList: Sized { + type Element; + + /// Create a new CList from the given items, len, and capacity. + /// + /// # Safety + /// + /// The arguments must either: + /// - be NULL, 0, and 0, respectively; or + /// - be valid for Vec::from_raw_parts + unsafe fn from_raw_parts(items: *mut Self::Element, len: usize, cap: usize) -> Self; + + /// Return a mutable slice representing the elements in this list. + fn slice(&mut self) -> &mut [Self::Element]; + + /// Get the items, len, and capacity (in that order) for this instance. These must be + /// precisely the same values passed tearlier to `from_raw_parts`. + fn into_raw_parts(self) -> (*mut Self::Element, usize, usize); + + /// Generate a NULL value. By default this is a NULL items pointer with zero length and + /// capacity. + fn null_value() -> Self { + // SAFETY: + // - satisfies the first case in from_raw_parts' safety documentation + unsafe { Self::from_raw_parts(std::ptr::null_mut(), 0, 0) } + } +} + +/// Given a CList containing pass-by-value values, drop all of the values and +/// the list. +/// +/// This is a convenience function for `tc_.._list_free` functions. +/// +/// # Safety +/// +/// - List must be non-NULL and point to a valid CL instance +/// - The caller must not use the value array points to after this function, as +/// it has been freed. It will be replaced with the null value. +pub(crate) unsafe fn drop_value_list(list: *mut CL) +where + CL: CList, + T: PassByValue, +{ + debug_assert!(!list.is_null()); + + // SAFETY: + // - *list is a valid CL (promised by caller) + let mut vec = unsafe { CL::take_val_from_arg(list, CL::null_value()) }; + + // first, drop each of the elements in turn + for e in vec.drain(..) { + // SAFETY: + // - e is a valid Element (promised by caller) + // - e is owned + drop(unsafe { PassByValue::val_from_arg(e) }); + } + // then drop the vector + drop(vec); +} + +/// Given a CList containing NonNull pointers, drop all of the pointed-to values and the list. +/// +/// This is a convenience function for `tc_.._list_free` functions. +/// +/// # Safety +/// +/// - List must be non-NULL and point to a valid CL instance +/// - The caller must not use the value array points to after this function, as +/// it has been freed. It will be replaced with the null value. +#[allow(dead_code)] // this was useful once, and might be again? +pub(crate) unsafe fn drop_pointer_list(list: *mut CL) +where + CL: CList>, + T: PassByPointer, +{ + debug_assert!(!list.is_null()); + // SAFETY: + // - *list is a valid CL (promised by caller) + let mut vec = unsafe { CL::take_val_from_arg(list, CL::null_value()) }; + + // first, drop each of the elements in turn + for e in vec.drain(..) { + // SAFETY: + // - e is a valid Element (promised by caller) + // - e is owned + drop(unsafe { PassByPointer::take_from_ptr_arg(e.as_ptr()) }); + } + // then drop the vector + drop(vec); +} + +/// Given a CList containing optional pointers, drop all of the non-null pointed-to values and the +/// list. +/// +/// This is a convenience function for `tc_.._list_free` functions, for lists from which items +/// can be taken. +/// +/// # Safety +/// +/// - List must be non-NULL and point to a valid CL instance +/// - The caller must not use the value array points to after this function, as +/// it has been freed. It will be replaced with the null value. +pub(crate) unsafe fn drop_optional_pointer_list(list: *mut CL) +where + CL: CList>>, + T: PassByPointer, +{ + debug_assert!(!list.is_null()); + // SAFETY: + // - *list is a valid CL (promised by caller) + let mut vec = unsafe { CL::take_val_from_arg(list, CL::null_value()) }; + + // first, drop each of the elements in turn + for e in vec.drain(..) { + if let Some(e) = e { + // SAFETY: + // - e is a valid Element (promised by caller) + // - e is owned + drop(unsafe { PassByPointer::take_from_ptr_arg(e.as_ptr()) }); + } + } + // then drop the vector + drop(vec); +} + +/// Take a value from an optional pointer list, returning the value and replacing its array +/// element with NULL. +/// +/// This is a convenience function for `tc_.._list_take` functions, for lists from which items +/// can be taken. +/// +/// The returned value will be None if the element has already been taken, or if the index is +/// out of bounds. +/// +/// # Safety +/// +/// - List must be non-NULL and point to a valid CL instance +pub(crate) unsafe fn take_optional_pointer_list_item( + list: *mut CL, + index: usize, +) -> Option> +where + CL: CList>>, + T: PassByPointer, +{ + debug_assert!(!list.is_null()); + + // SAFETy: + // - list is properly aligned, dereferencable, and points to an initialized CL, since it is valid + // - the lifetime of the resulting reference is limited to this function, during which time + // nothing else refers to this memory. + let slice = list.as_mut().unwrap().slice(); + if let Some(elt_ref) = slice.get_mut(index) { + let mut rv = None; + if let Some(elt) = elt_ref.as_mut() { + rv = Some(*elt); + *elt_ref = None; // clear out the array element + } + rv + } else { + None // index out of bounds + } +} + +impl PassByValue for A +where + A: CList, +{ + type RustType = Vec; + + unsafe fn from_ctype(self) -> Self::RustType { + let (items, len, cap) = self.into_raw_parts(); + debug_assert!(!items.is_null()); + // SAFETY: + // - CList::from_raw_parts requires that items, len, and cap be valid for + // Vec::from_raw_parts if not NULL, and they are not NULL (as promised by caller) + // - CList::into_raw_parts returns precisely the values passed to from_raw_parts. + // - those parts are passed to Vec::from_raw_parts here. + unsafe { Vec::from_raw_parts(items as *mut _, len, cap) } + } + + fn as_ctype(arg: Self::RustType) -> Self { + let (items, len, cap) = vec_into_raw_parts(arg); + // SAFETY: + // - satisfies the second case in from_raw_parts' safety documentation + unsafe { Self::from_raw_parts(items, len, cap) } + } +} diff --git a/rust/lib/src/uda.rs b/rust/lib/src/uda.rs new file mode 100644 index 000000000..679557504 --- /dev/null +++ b/rust/lib/src/uda.rs @@ -0,0 +1,148 @@ +use crate::traits::*; +use crate::types::*; + +/// TCUda contains the details of a UDA. +#[repr(C)] +#[derive(Default)] +pub struct TCUda { + /// Namespace of the UDA. For legacy UDAs, this may have a NULL ptr field. + pub ns: TCString, + /// UDA key. Must not be NULL. + pub key: TCString, + /// Content of the UDA. Must not be NULL. + pub value: TCString, +} + +pub(crate) struct Uda { + pub ns: Option>, + pub key: RustString<'static>, + pub value: RustString<'static>, +} + +impl PassByValue for TCUda { + type RustType = Uda; + + unsafe fn from_ctype(self) -> Self::RustType { + Uda { + ns: if self.ns.is_null() { + None + } else { + // SAFETY: + // - self is owned, so we can take ownership of this TCString + // - self.ns is a valid, non-null TCString (NULL just checked) + Some(unsafe { TCString::val_from_arg(self.ns) }) + }, + // SAFETY: + // - self is owned, so we can take ownership of this TCString + // - self.key is a valid, non-null TCString (see type docstring) + key: unsafe { TCString::val_from_arg(self.key) }, + // SAFETY: + // - self is owned, so we can take ownership of this TCString + // - self.value is a valid, non-null TCString (see type docstring) + value: unsafe { TCString::val_from_arg(self.value) }, + } + } + + fn as_ctype(uda: Uda) -> Self { + TCUda { + // SAFETY: caller assumes ownership of this value + ns: if let Some(ns) = uda.ns { + unsafe { TCString::return_val(ns) } + } else { + TCString::default() + }, + // SAFETY: caller assumes ownership of this value + key: unsafe { TCString::return_val(uda.key) }, + // SAFETY: caller assumes ownership of this value + value: unsafe { TCString::return_val(uda.value) }, + } + } +} + +/// TCUdaList represents a list of UDAs. +/// +/// The content of this struct must be treated as read-only. +#[repr(C)] +pub struct TCUdaList { + /// number of UDAs in items + len: libc::size_t, + + /// total size of items (internal use only) + _capacity: libc::size_t, + + /// array of UDAs. These remain owned by the TCUdaList instance and will be freed by + /// tc_uda_list_free. This pointer is never NULL for a valid TCUdaList. + items: *mut TCUda, +} + +impl CList for TCUdaList { + type Element = TCUda; + + unsafe fn from_raw_parts(items: *mut Self::Element, len: usize, cap: usize) -> Self { + TCUdaList { + len, + _capacity: cap, + items, + } + } + + fn slice(&mut self) -> &mut [Self::Element] { + // SAFETY: + // - because we have &mut self, we have read/write access to items[0..len] + // - all items are properly initialized Element's + // - return value lifetime is equal to &mmut self's, so access is exclusive + // - items and len came from Vec, so total size is < isize::MAX + unsafe { std::slice::from_raw_parts_mut(self.items, self.len) } + } + + fn into_raw_parts(self) -> (*mut Self::Element, usize, usize) { + (self.items, self.len, self._capacity) + } +} + +/// Free a TCUda instance. The instance, and the TCStrings it contains, must not be used +/// after this call. +#[no_mangle] +pub unsafe extern "C" fn tc_uda_free(tcuda: *mut TCUda) { + debug_assert!(!tcuda.is_null()); + // SAFETY: + // - *tcuda is a valid TCUda (caller promises to treat it as read-only) + let uda = unsafe { TCUda::take_val_from_arg(tcuda, TCUda::default()) }; + drop(uda); +} + +/// Free a TCUdaList instance. The instance, and all TCUdas it contains, must not be used after +/// this call. +/// +/// When this call returns, the `items` pointer will be NULL, signalling an invalid TCUdaList. +#[no_mangle] +pub unsafe extern "C" fn tc_uda_list_free(tcudas: *mut TCUdaList) { + // SAFETY: + // - tcudas is not NULL and points to a valid TCUdaList (caller is not allowed to + // modify the list) + // - caller promises not to use the value after return + unsafe { drop_value_list(tcudas) } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn empty_list_has_non_null_pointer() { + let tcudas = unsafe { TCUdaList::return_val(Vec::new()) }; + assert!(!tcudas.items.is_null()); + assert_eq!(tcudas.len, 0); + assert_eq!(tcudas._capacity, 0); + } + + #[test] + fn free_sets_null_pointer() { + let mut tcudas = unsafe { TCUdaList::return_val(Vec::new()) }; + // SAFETY: testing expected behavior + unsafe { tc_uda_list_free(&mut tcudas) }; + assert!(tcudas.items.is_null()); + assert_eq!(tcudas.len, 0); + assert_eq!(tcudas._capacity, 0); + } +} diff --git a/rust/lib/src/util.rs b/rust/lib/src/util.rs new file mode 100644 index 000000000..bfd739282 --- /dev/null +++ b/rust/lib/src/util.rs @@ -0,0 +1,23 @@ +use crate::string::RustString; + +pub(crate) fn err_to_ruststring(e: impl std::string::ToString) -> RustString<'static> { + RustString::from(e.to_string()) +} + +/// An implementation of Vec::into_raw_parts, which is still unstable. Returns ptr, len, cap. +pub(crate) fn vec_into_raw_parts(vec: Vec) -> (*mut T, usize, usize) { + // emulate Vec::into_raw_parts(): + // - disable dropping the Vec with ManuallyDrop + // - extract ptr, len, and capacity using those methods + let mut vec = std::mem::ManuallyDrop::new(vec); + (vec.as_mut_ptr(), vec.len(), vec.capacity()) +} + +/// An implementation of String::into_raw_parts, which is still unstable. Returns ptr, len, cap. +pub(crate) fn string_into_raw_parts(string: String) -> (*mut u8, usize, usize) { + // emulate String::into_raw_parts(): + // - disable dropping the String with ManuallyDrop + // - extract ptr, len, and capacity using those methods + let mut string = std::mem::ManuallyDrop::new(string); + (string.as_mut_ptr(), string.len(), string.capacity()) +} diff --git a/rust/lib/src/uuid.rs b/rust/lib/src/uuid.rs new file mode 100644 index 000000000..8284caac2 --- /dev/null +++ b/rust/lib/src/uuid.rs @@ -0,0 +1,176 @@ +use crate::traits::*; +use crate::types::*; +use libc; +use taskchampion::Uuid; + +// NOTE: this must be a simple constant so that cbindgen can evaluate it +/// Length, in bytes, of the string representation of a UUID (without NUL terminator) +pub const TC_UUID_STRING_BYTES: usize = 36; + +/// TCUuid is used as a task identifier. Uuids do not contain any pointers and need not be freed. +/// Uuids are typically treated as opaque, but the bytes are available in big-endian format. +/// +/// cbindgen:field-names=[bytes] +#[repr(C)] +pub struct TCUuid([u8; 16]); + +impl PassByValue for TCUuid { + type RustType = Uuid; + + unsafe fn from_ctype(self) -> Self::RustType { + // SAFETY: + // - any 16-byte value is a valid Uuid + Uuid::from_bytes(self.0) + } + + fn as_ctype(arg: Uuid) -> Self { + TCUuid(*arg.as_bytes()) + } +} + +/// Create a new, randomly-generated UUID. +#[no_mangle] +pub unsafe extern "C" fn tc_uuid_new_v4() -> TCUuid { + // SAFETY: + // - value is not allocated + unsafe { TCUuid::return_val(Uuid::new_v4()) } +} + +/// Create a new UUID with the nil value. +#[no_mangle] +pub unsafe extern "C" fn tc_uuid_nil() -> TCUuid { + // SAFETY: + // - value is not allocated + unsafe { TCUuid::return_val(Uuid::nil()) } +} + +/// TCUuidList represents a list of uuids. +/// +/// The content of this struct must be treated as read-only. +#[repr(C)] +pub struct TCUuidList { + /// number of uuids in items + len: libc::size_t, + + /// total size of items (internal use only) + _capacity: libc::size_t, + + /// array of uuids. these remain owned by the TCUuidList instance and will be freed by + /// tc_uuid_list_free. This pointer is never NULL for a valid TCUuidList. + items: *mut TCUuid, +} + +impl CList for TCUuidList { + type Element = TCUuid; + + unsafe fn from_raw_parts(items: *mut Self::Element, len: usize, cap: usize) -> Self { + TCUuidList { + len, + _capacity: cap, + items, + } + } + + fn slice(&mut self) -> &mut [Self::Element] { + // SAFETY: + // - because we have &mut self, we have read/write access to items[0..len] + // - all items are properly initialized Element's + // - return value lifetime is equal to &mmut self's, so access is exclusive + // - items and len came from Vec, so total size is < isize::MAX + unsafe { std::slice::from_raw_parts_mut(self.items, self.len) } + } + + fn into_raw_parts(self) -> (*mut Self::Element, usize, usize) { + (self.items, self.len, self._capacity) + } +} + +/// Write the string representation of a TCUuid into the given buffer, which must be +/// at least TC_UUID_STRING_BYTES long. No NUL terminator is added. +#[no_mangle] +pub unsafe extern "C" fn tc_uuid_to_buf(tcuuid: TCUuid, buf: *mut libc::c_char) { + debug_assert!(!buf.is_null()); + // SAFETY: + // - buf is valid for len bytes (by C convention) + // - (no alignment requirements for a byte slice) + // - content of buf will not be mutated during the lifetime of this slice (lifetime + // does not outlive this function call) + // - the length of the buffer is less than isize::MAX (promised by caller) + let buf: &mut [u8] = + unsafe { std::slice::from_raw_parts_mut(buf as *mut u8, TC_UUID_STRING_BYTES) }; + // SAFETY: + // - tcuuid is a valid TCUuid (all byte patterns are valid) + let uuid: Uuid = unsafe { TCUuid::val_from_arg(tcuuid) }; + uuid.to_hyphenated().encode_lower(buf); +} + +/// Return the hyphenated string representation of a TCUuid. The returned string +/// must be freed with tc_string_free. +#[no_mangle] +pub unsafe extern "C" fn tc_uuid_to_str(tcuuid: TCUuid) -> TCString { + // SAFETY: + // - tcuuid is a valid TCUuid (all byte patterns are valid) + let uuid: Uuid = unsafe { TCUuid::val_from_arg(tcuuid) }; + let s = uuid.to_string(); + // SAFETY: + // - caller promises to free this value. + unsafe { TCString::return_val(s.into()) } +} + +/// Parse the given string as a UUID. Returns TC_RESULT_ERROR on parse failure or if the given +/// string is not valid. +#[no_mangle] +pub unsafe extern "C" fn tc_uuid_from_str(s: TCString, uuid_out: *mut TCUuid) -> TCResult { + debug_assert!(!s.is_null()); + debug_assert!(!uuid_out.is_null()); + // SAFETY: + // - s is valid (promised by caller) + // - caller will not use s after this call (convention) + let mut s = unsafe { TCString::val_from_arg(s) }; + if let Ok(s) = s.as_str() { + if let Ok(u) = Uuid::parse_str(s) { + // SAFETY: + // - uuid_out is not NULL (promised by caller) + // - alignment is not required + unsafe { TCUuid::val_to_arg_out(u, uuid_out) }; + return TCResult::Ok; + } + } + TCResult::Error +} + +/// Free a TCUuidList instance. The instance, and all TCUuids it contains, must not be used after +/// this call. +/// +/// When this call returns, the `items` pointer will be NULL, signalling an invalid TCUuidList. +#[no_mangle] +pub unsafe extern "C" fn tc_uuid_list_free(tcuuids: *mut TCUuidList) { + // SAFETY: + // - tcuuids is not NULL and points to a valid TCUuidList (caller is not allowed to + // modify the list) + // - caller promises not to use the value after return + unsafe { drop_value_list(tcuuids) }; +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn empty_list_has_non_null_pointer() { + let tcuuids = unsafe { TCUuidList::return_val(Vec::new()) }; + assert!(!tcuuids.items.is_null()); + assert_eq!(tcuuids.len, 0); + assert_eq!(tcuuids._capacity, 0); + } + + #[test] + fn free_sets_null_pointer() { + let mut tcuuids = unsafe { TCUuidList::return_val(Vec::new()) }; + // SAFETY: testing expected behavior + unsafe { tc_uuid_list_free(&mut tcuuids) }; + assert!(tcuuids.items.is_null()); + assert_eq!(tcuuids.len, 0); + assert_eq!(tcuuids._capacity, 0); + } +} diff --git a/rust/lib/src/workingset.rs b/rust/lib/src/workingset.rs new file mode 100644 index 000000000..672194886 --- /dev/null +++ b/rust/lib/src/workingset.rs @@ -0,0 +1,103 @@ +use crate::traits::*; +use crate::types::*; +use taskchampion::{Uuid, WorkingSet}; + +/// A TCWorkingSet represents a snapshot of the working set for a replica. It is not automatically +/// updated based on changes in the replica. Its lifetime is independent of the replica and it can +/// be freed at any time. +/// +/// To iterate over a working set, search indexes 1 through largest_index. +/// +/// # Safety +/// +/// The `*TCWorkingSet` returned from `tc_replica_working_set` is owned by the caller and +/// must later be freed to avoid a memory leak. Its lifetime is independent of the replica +/// from which it was generated. +/// +/// Any function taking a `*TCWorkingSet` requires: +/// - the pointer must not be NUL; +/// - the pointer must be one previously returned from `tc_replica_working_set` +/// - the memory referenced by the pointer must never be accessed by C code; and +/// - except for `tc_replica_free`, ownership of a `*TCWorkingSet` remains with the caller. +/// +/// Once passed to `tc_replica_free`, a `*TCWorkingSet` becomes invalid and must not be used again. +/// +/// TCWorkingSet is not threadsafe. +pub struct TCWorkingSet(WorkingSet); + +impl PassByPointer for TCWorkingSet {} + +impl From for TCWorkingSet { + fn from(ws: WorkingSet) -> TCWorkingSet { + TCWorkingSet(ws) + } +} + +/// Utility function to get a shared reference to the underlying WorkingSet. +fn wrap(ws: *mut TCWorkingSet, f: F) -> T +where + F: FnOnce(&WorkingSet) -> T, +{ + // SAFETY: + // - ws is not null (promised by caller) + // - ws outlives 'a (promised by caller) + let tcws: &TCWorkingSet = unsafe { TCWorkingSet::from_ptr_arg_ref(ws) }; + f(&tcws.0) +} + +/// Get the working set's length, or the number of UUIDs it contains. +#[no_mangle] +pub unsafe extern "C" fn tc_working_set_len(ws: *mut TCWorkingSet) -> usize { + wrap(ws, |ws| ws.len()) +} + +/// Get the working set's largest index. +#[no_mangle] +pub unsafe extern "C" fn tc_working_set_largest_index(ws: *mut TCWorkingSet) -> usize { + wrap(ws, |ws| ws.largest_index()) +} + +/// Get the UUID for the task at the given index. Returns true if the UUID exists in the working +/// set. If not, returns false and does not change uuid_out. +#[no_mangle] +pub unsafe extern "C" fn tc_working_set_by_index( + ws: *mut TCWorkingSet, + index: usize, + uuid_out: *mut TCUuid, +) -> bool { + debug_assert!(!uuid_out.is_null()); + wrap(ws, |ws| { + if let Some(uuid) = ws.by_index(index) { + // SAFETY: + // - uuid_out is not NULL (promised by caller) + // - alignment is not required + unsafe { TCUuid::val_to_arg_out(uuid, uuid_out) }; + true + } else { + false + } + }) +} + +/// Get the working set index for the task with the given UUID. Returns 0 if the task is not in +/// the working set. +#[no_mangle] +pub unsafe extern "C" fn tc_working_set_by_uuid(ws: *mut TCWorkingSet, uuid: TCUuid) -> usize { + wrap(ws, |ws| { + // SAFETY: + // - tcuuid is a valid TCUuid (all byte patterns are valid) + let uuid: Uuid = unsafe { TCUuid::val_from_arg(uuid) }; + ws.by_uuid(uuid).unwrap_or(0) + }) +} + +/// Free a TCWorkingSet. The given value must not be NULL. The value must not be used after this +/// function returns, and must not be freed more than once. +#[no_mangle] +pub unsafe extern "C" fn tc_working_set_free(ws: *mut TCWorkingSet) { + // SAFETY: + // - rep is not NULL (promised by caller) + // - caller will not use the TCWorkingSet after this (promised by caller) + let ws = unsafe { TCWorkingSet::take_from_ptr_arg(ws) }; + drop(ws); +} diff --git a/rust/lib/taskchampion.h b/rust/lib/taskchampion.h new file mode 100644 index 000000000..4286e705f --- /dev/null +++ b/rust/lib/taskchampion.h @@ -0,0 +1,1052 @@ +/** + * TaskChampion + * + * This file defines the C interface to libtaskchampion. This is a thin + * wrapper around the Rust `taskchampion` crate. Refer to the documentation + * for that crate at https://docs.rs/taskchampion/latest/taskchampion/ for API + * details. The comments in this file focus mostly on the low-level details of + * passing values to and from TaskChampion. + * + * # Overview + * + * This library defines two major types used to interact with the API, that map directly + * to Rust types. + * + * * TCReplica - see https://docs.rs/taskchampion/latest/taskchampion/struct.Replica.html + * * TCTask - see https://docs.rs/taskchampion/latest/taskchampion/struct.Task.html + * * TCServer - see https://docs.rs/taskchampion/latest/taskchampion/trait.Server.html + * * TCWorkingSet - see https://docs.rs/taskchampion/latest/taskchampion/struct.WorkingSet.html + * + * It also defines a few utility types: + * + * * TCString - a wrapper around both C (NUL-terminated) and Rust (always utf-8) strings. + * * TC…List - a list of objects represented as a C array + * * see below for the remainder + * + * # Safety + * + * Each type contains specific instructions to ensure memory safety. + * The general rules are as follows. + * + * No types in this library are threadsafe. All values should be used in only + * one thread for their entire lifetime. It is safe to use unrelated values in + * different threads (for example, different threads may use different + * TCReplica values concurrently). + * + * ## Pass by Pointer + * + * Several types such as TCReplica and TCString are "opaque" types and always + * handled as pointers in C. The bytes these pointers address are private to + * the Rust implemetation and must not be accessed from C. + * + * Pass-by-pointer values have exactly one owner, and that owner is responsible + * for freeing the value (using a `tc_…_free` function), or transferring + * ownership elsewhere. Except where documented otherwise, when a value is + * passed to C, ownership passes to C as well. When a value is passed to Rust, + * ownership stays with the C code. The exception is TCString, ownership of + * which passes to Rust when it is used as a function argument. + * + * The limited circumstances where one value must not outlive another, due to + * pointer references between them, are documented below. + * + * ## Pass by Value + * + * Types such as TCUuid and TC…List are passed by value, and contain fields + * that are accessible from C. C code is free to access the content of these + * types in a _read_only_ fashion. + * + * Pass-by-value values that contain pointers also have exactly one owner, + * responsible for freeing the value or transferring ownership. The tc_…_free + * functions for these types will replace the pointers with NULL to guard + * against use-after-free errors. The interior pointers in such values should + * never be freed directly (for example, `tc_string_free(tcuda.value)` is an + * error). + * + * TCUuid is a special case, because it does not contain pointers. It can be + * freely copied and need not be freed. + * + * ## Lists + * + * Lists are a special kind of pass-by-value type. Each contains `len` and + * `items`, where `items` is an array of length `len`. Lists, and the values + * in the `items` array, must be treated as read-only. On return from an API + * function, a list's ownership is with the C caller, which must eventually + * free the list. List data must be freed with the `tc_…_list_free` function. + * It is an error to free any value in the `items` array of a list. + */ + + +#ifndef TASKCHAMPION_H +#define TASKCHAMPION_H + +#include +#include +#include + +/** + * Length, in bytes, of the string representation of a UUID (without NUL terminator) + */ +#define TC_UUID_STRING_BYTES 36 + +/** + * A result from a TC operation. Typically if this value is TC_RESULT_ERROR, + * the associated object's `tc_.._error` method will return an error message. + */ +enum TCResult +#ifdef __cplusplus + : int32_t +#endif // __cplusplus + { + TC_RESULT_ERROR = -1, + TC_RESULT_OK = 0, +}; +#ifndef __cplusplus +typedef int32_t TCResult; +#endif // __cplusplus + +/** + * The status of a task, as defined by the task data model. + */ +typedef enum TCStatus { + TC_STATUS_PENDING, + TC_STATUS_COMPLETED, + TC_STATUS_DELETED, + /** + * Unknown signifies a status in the task DB that was not + * recognized. + */ + TC_STATUS_UNKNOWN, +} TCStatus; + +/** + * A replica represents an instance of a user's task data, providing an easy interface + * for querying and modifying that data. + * + * # Error Handling + * + * When a `tc_replica_..` function that returns a TCResult returns TC_RESULT_ERROR, then + * `tc_replica_error` will return the error message. + * + * # Safety + * + * The `*TCReplica` returned from `tc_replica_new…` functions is owned by the caller and + * must later be freed to avoid a memory leak. + * + * Any function taking a `*TCReplica` requires: + * - the pointer must not be NUL; + * - the pointer must be one previously returned from a tc_… function; + * - the memory referenced by the pointer must never be modified by C code; and + * - except for `tc_replica_free`, ownership of a `*TCReplica` remains with the caller. + * + * Once passed to `tc_replica_free`, a `*TCReplica` becomes invalid and must not be used again. + * + * TCReplicas are not threadsafe. + */ +typedef struct TCReplica TCReplica; + +/** + * TCServer represents an interface to a sync server. Aside from new and free, a server + * has no C-accessible API, but is designed to be passed to `tc_replica_sync`. + * + * ## Safety + * + * TCServer are not threadsafe, and must not be used with multiple replicas simultaneously. + */ +typedef struct TCServer TCServer; + +/** + * A task, as publicly exposed by this library. + * + * A task begins in "immutable" mode. It must be converted to "mutable" mode + * to make any changes, and doing so requires exclusive access to the replica + * until the task is freed or converted back to immutable mode. + * + * An immutable task carries no reference to the replica that created it, and can be used until it + * is freed or converted to a TaskMut. A mutable task carries a reference to the replica and + * must be freed or made immutable before the replica is freed. + * + * All `tc_task_..` functions taking a task as an argument require that it not be NULL. + * + * When a `tc_task_..` function that returns a TCResult returns TC_RESULT_ERROR, then + * `tc_task_error` will return the error message. + * + * # Safety + * + * A task is an owned object, and must be freed with tc_task_free (or, if part of a list, + * with tc_task_list_free). + * + * Any function taking a `*TCTask` requires: + * - the pointer must not be NUL; + * - the pointer must be one previously returned from a tc_… function; + * - the memory referenced by the pointer must never be modified by C code; and + * - except for `tc_{task,task_list}_free`, ownership of a `*TCTask` remains with the caller. + * + * Once passed to tc_task_free, a `*TCTask` becomes invalid and must not be used again. + * + * TCTasks are not threadsafe. + */ +typedef struct TCTask TCTask; + +/** + * A TCWorkingSet represents a snapshot of the working set for a replica. It is not automatically + * updated based on changes in the replica. Its lifetime is independent of the replica and it can + * be freed at any time. + * + * To iterate over a working set, search indexes 1 through largest_index. + * + * # Safety + * + * The `*TCWorkingSet` returned from `tc_replica_working_set` is owned by the caller and + * must later be freed to avoid a memory leak. Its lifetime is independent of the replica + * from which it was generated. + * + * Any function taking a `*TCWorkingSet` requires: + * - the pointer must not be NUL; + * - the pointer must be one previously returned from `tc_replica_working_set` + * - the memory referenced by the pointer must never be accessed by C code; and + * - except for `tc_replica_free`, ownership of a `*TCWorkingSet` remains with the caller. + * + * Once passed to `tc_replica_free`, a `*TCWorkingSet` becomes invalid and must not be used again. + * + * TCWorkingSet is not threadsafe. + */ +typedef struct TCWorkingSet TCWorkingSet; + +/** + * TCString supports passing strings into and out of the TaskChampion API. + * + * # Rust Strings and C Strings + * + * A Rust string can contain embedded NUL characters, while C considers such a character to mark + * the end of a string. Strings containing embedded NULs cannot be represented as a "C string" + * and must be accessed using `tc_string_content_and_len` and `tc_string_clone_with_len`. In + * general, these two functions should be used for handling arbitrary data, while more convenient + * forms may be used where embedded NUL characters are impossible, such as in static strings. + * + * # UTF-8 + * + * TaskChampion expects all strings to be valid UTF-8. `tc_string_…` functions will fail if given + * a `*TCString` containing invalid UTF-8. + * + * # Safety + * + * The `ptr` field may be checked for NULL, where documentation indicates this is possible. All + * other fields in a TCString are private and must not be used from C. They exist in the struct + * to ensure proper allocation and alignment. + * + * When a `TCString` appears as a return value or output argument, ownership is passed to the + * caller. The caller must pass that ownership back to another function or free the string. + * + * Any function taking a `TCString` requires: + * - the pointer must not be NUL; + * - the pointer must be one previously returned from a tc_… function; and + * - the memory referenced by the pointer must never be modified by C code. + * + * Unless specified otherwise, TaskChampion functions take ownership of a `TCString` when it is + * given as a function argument, and the caller must not use or free TCStrings after passing them + * to such API functions. + * + * A TCString with a NULL `ptr` field need not be freed, although tc_free_string will not fail + * for such a value. + * + * TCString is not threadsafe. + */ +typedef struct TCString { + void *ptr; + size_t _u1; + size_t _u2; + uint8_t _u3; +} TCString; + +/** + * TCAnnotation contains the details of an annotation. + * + * # Safety + * + * An annotation must be initialized from a tc_.. function, and later freed + * with `tc_annotation_free` or `tc_annotation_list_free`. + * + * Any function taking a `*TCAnnotation` requires: + * - the pointer must not be NUL; + * - the pointer must be one previously returned from a tc_… function; + * - the memory referenced by the pointer must never be modified by C code; and + * - ownership transfers to the called function, and the value must not be used + * after the call returns. In fact, the value will be zeroed out to ensure this. + * + * TCAnnotations are not threadsafe. + */ +typedef struct TCAnnotation { + /** + * Time the annotation was made. Must be nonzero. + */ + time_t entry; + /** + * Content of the annotation. Must not be NULL. + */ + struct TCString description; +} TCAnnotation; + +/** + * TCAnnotationList represents a list of annotations. + * + * The content of this struct must be treated as read-only. + */ +typedef struct TCAnnotationList { + /** + * number of annotations in items + */ + size_t len; + /** + * total size of items (internal use only) + */ + size_t _capacity; + /** + * array of annotations. these remain owned by the TCAnnotationList instance and will be freed by + * tc_annotation_list_free. This pointer is never NULL for a valid TCAnnotationList. + */ + struct TCAnnotation *items; +} TCAnnotationList; + +/** + * TCKV contains a key/value pair that is part of a task. + * + * Neither key nor value are ever NULL. They remain owned by the TCKV and + * will be freed when it is freed with tc_kv_list_free. + */ +typedef struct TCKV { + struct TCString key; + struct TCString value; +} TCKV; + +/** + * TCKVList represents a list of key/value pairs. + * + * The content of this struct must be treated as read-only. + */ +typedef struct TCKVList { + /** + * number of key/value pairs in items + */ + size_t len; + /** + * total size of items (internal use only) + */ + size_t _capacity; + /** + * array of TCKV's. these remain owned by the TCKVList instance and will be freed by + * tc_kv_list_free. This pointer is never NULL for a valid TCKVList. + */ + struct TCKV *items; +} TCKVList; + +/** + * TCTaskList represents a list of tasks. + * + * The content of this struct must be treated as read-only: no fields or anything they reference + * should be modified directly by C code. + * + * When an item is taken from this list, its pointer in `items` is set to NULL. + */ +typedef struct TCTaskList { + /** + * number of tasks in items + */ + size_t len; + /** + * total size of items (internal use only) + */ + size_t _capacity; + /** + * array of pointers representing each task. these remain owned by the TCTaskList instance and + * will be freed by tc_task_list_free. This pointer is never NULL for a valid TCTaskList, + * and the *TCTaskList at indexes 0..len-1 are not NULL. + */ + struct TCTask **items; +} TCTaskList; + +/** + * TCUuid is used as a task identifier. Uuids do not contain any pointers and need not be freed. + * Uuids are typically treated as opaque, but the bytes are available in big-endian format. + * + */ +typedef struct TCUuid { + uint8_t bytes[16]; +} TCUuid; + +/** + * TCUuidList represents a list of uuids. + * + * The content of this struct must be treated as read-only. + */ +typedef struct TCUuidList { + /** + * number of uuids in items + */ + size_t len; + /** + * total size of items (internal use only) + */ + size_t _capacity; + /** + * array of uuids. these remain owned by the TCUuidList instance and will be freed by + * tc_uuid_list_free. This pointer is never NULL for a valid TCUuidList. + */ + struct TCUuid *items; +} TCUuidList; + +/** + * TCStringList represents a list of strings. + * + * The content of this struct must be treated as read-only. + */ +typedef struct TCStringList { + /** + * number of strings in items + */ + size_t len; + /** + * total size of items (internal use only) + */ + size_t _capacity; + /** + * TCStringList representing each string. these remain owned by the TCStringList instance and will + * be freed by tc_string_list_free. This pointer is never NULL for a valid TCStringList, and the + * *TCStringList at indexes 0..len-1 are not NULL. + */ + struct TCString *items; +} TCStringList; + +/** + * TCUda contains the details of a UDA. + */ +typedef struct TCUda { + /** + * Namespace of the UDA. For legacy UDAs, this may have a NULL ptr field. + */ + struct TCString ns; + /** + * UDA key. Must not be NULL. + */ + struct TCString key; + /** + * Content of the UDA. Must not be NULL. + */ + struct TCString value; +} TCUda; + +/** + * TCUdaList represents a list of UDAs. + * + * The content of this struct must be treated as read-only. + */ +typedef struct TCUdaList { + /** + * number of UDAs in items + */ + size_t len; + /** + * total size of items (internal use only) + */ + size_t _capacity; + /** + * array of UDAs. These remain owned by the TCUdaList instance and will be freed by + * tc_uda_list_free. This pointer is never NULL for a valid TCUdaList. + */ + struct TCUda *items; +} TCUdaList; + +#ifdef __cplusplus +extern "C" { +#endif // __cplusplus + +/** + * Free a TCAnnotation instance. The instance, and the TCString it contains, must not be used + * after this call. + */ +void tc_annotation_free(struct TCAnnotation *tcann); + +/** + * Free a TCAnnotationList instance. The instance, and all TCAnnotations it contains, must not be used after + * this call. + * + * When this call returns, the `items` pointer will be NULL, signalling an invalid TCAnnotationList. + */ +void tc_annotation_list_free(struct TCAnnotationList *tcanns); + +/** + * Free a TCKVList instance. The instance, and all TCKVs it contains, must not be used after + * this call. + * + * When this call returns, the `items` pointer will be NULL, signalling an invalid TCKVList. + */ +void tc_kv_list_free(struct TCKVList *tckvs); + +/** + * Create a new TCReplica with an in-memory database. The contents of the database will be + * lost when it is freed with tc_replica_free. + */ +struct TCReplica *tc_replica_new_in_memory(void); + +/** + * Create a new TCReplica with an on-disk database having the given filename. On error, a string + * is written to the error_out parameter (if it is not NULL) and NULL is returned. The caller + * must free this string. + */ +struct TCReplica *tc_replica_new_on_disk(struct TCString path, struct TCString *error_out); + +/** + * Get a list of all tasks in the replica. + * + * Returns a TCTaskList with a NULL items field on error. + */ +struct TCTaskList tc_replica_all_tasks(struct TCReplica *rep); + +/** + * Get a list of all uuids for tasks in the replica. + * + * Returns a TCUuidList with a NULL items field on error. + * + * The caller must free the UUID list with `tc_uuid_list_free`. + */ +struct TCUuidList tc_replica_all_task_uuids(struct TCReplica *rep); + +/** + * Get the current working set for this replica. The resulting value must be freed + * with tc_working_set_free. + * + * Returns NULL on error. + */ +struct TCWorkingSet *tc_replica_working_set(struct TCReplica *rep); + +/** + * Get an existing task by its UUID. + * + * Returns NULL when the task does not exist, and on error. Consult tc_replica_error + * to distinguish the two conditions. + */ +struct TCTask *tc_replica_get_task(struct TCReplica *rep, struct TCUuid tcuuid); + +/** + * Create a new task. The task must not already exist. + * + * Returns the task, or NULL on error. + */ +struct TCTask *tc_replica_new_task(struct TCReplica *rep, + enum TCStatus status, + struct TCString description); + +/** + * Create a new task. The task must not already exist. + * + * Returns the task, or NULL on error. + */ +struct TCTask *tc_replica_import_task_with_uuid(struct TCReplica *rep, struct TCUuid tcuuid); + +/** + * Synchronize this replica with a server. + * + * The `server` argument remains owned by the caller, and must be freed explicitly. + */ +TCResult tc_replica_sync(struct TCReplica *rep, struct TCServer *server, bool avoid_snapshots); + +/** + * Undo local operations until the most recent UndoPoint. + * + * If undone_out is not NULL, then on success it is set to 1 if operations were undone, or 0 if + * there are no operations that can be done. + */ +TCResult tc_replica_undo(struct TCReplica *rep, int32_t *undone_out); + +/** + * Get the number of local, un-synchronized operations, or -1 on error + */ +int64_t tc_replica_num_local_operations(struct TCReplica *rep); + +/** + * Add an UndoPoint, if one has not already been added by this Replica. This occurs automatically + * when a change is made. The `force` flag allows forcing a new UndoPoint even if one has already + * been created by this Replica, and may be useful when a Replica instance is held for a long time + * and used to apply more than one user-visible change. + */ +TCResult tc_replica_add_undo_point(struct TCReplica *rep, bool force); + +/** + * Rebuild this replica's working set, based on whether tasks are pending or not. If `renumber` + * is true, then existing tasks may be moved to new working-set indices; in any case, on + * completion all pending tasks are in the working set and all non- pending tasks are not. + */ +TCResult tc_replica_rebuild_working_set(struct TCReplica *rep, bool renumber); + +/** + * Get the latest error for a replica, or a string with NULL ptr if no error exists. Subsequent + * calls to this function will return NULL. The rep pointer must not be NULL. The caller must + * free the returned string. + */ +struct TCString tc_replica_error(struct TCReplica *rep); + +/** + * Free a replica. The replica may not be used after this function returns and must not be freed + * more than once. + */ +void tc_replica_free(struct TCReplica *rep); + +/** + * Create a new TCServer that operates locally (on-disk). See the TaskChampion docs for the + * description of the arguments. + * + * On error, a string is written to the error_out parameter (if it is not NULL) and NULL is + * returned. The caller must free this string. + * + * The server must be freed after it is used - tc_replica_sync does not automatically free it. + */ +struct TCServer *tc_server_new_local(struct TCString server_dir, struct TCString *error_out); + +/** + * Create a new TCServer that connects to a remote server. See the TaskChampion docs for the + * description of the arguments. + * + * On error, a string is written to the error_out parameter (if it is not NULL) and NULL is + * returned. The caller must free this string. + * + * The server must be freed after it is used - tc_replica_sync does not automatically free it. + */ +struct TCServer *tc_server_new_remote(struct TCString origin, + struct TCUuid client_key, + struct TCString encryption_secret, + struct TCString *error_out); + +/** + * Free a server. The server may not be used after this function returns and must not be freed + * more than once. + */ +void tc_server_free(struct TCServer *server); + +/** + * Create a new TCString referencing the given C string. The C string must remain valid and + * unchanged until after the TCString is freed. It's typically easiest to ensure this by using a + * static string. + * + * NOTE: this function does _not_ take responsibility for freeing the given C string. The + * given string can be freed once the TCString referencing it has been freed. + * + * For example: + * + * ```text + * char *url = get_item_url(..); // dynamically allocate C string + * tc_task_annotate(task, tc_string_borrow(url)); // TCString created, passed, and freed + * free(url); // string is no longer referenced and can be freed + * ``` + */ +struct TCString tc_string_borrow(const char *cstr); + +/** + * Create a new TCString by cloning the content of the given C string. The resulting TCString + * is independent of the given string, which can be freed or overwritten immediately. + */ +struct TCString tc_string_clone(const char *cstr); + +/** + * Create a new TCString containing the given string with the given length. This allows creation + * of strings containing embedded NUL characters. As with `tc_string_clone`, the resulting + * TCString is independent of the passed buffer, which may be reused or freed immediately. + * + * The length should _not_ include any trailing NUL. + * + * The given length must be less than half the maximum value of usize. + */ +struct TCString tc_string_clone_with_len(const char *buf, size_t len); + +/** + * Get the content of the string as a regular C string. The given string must be valid. The + * returned value is NULL if the string contains NUL bytes or (in some cases) invalid UTF-8. The + * returned C string is valid until the TCString is freed or passed to another TC API function. + * + * In general, prefer [`tc_string_content_with_len`] except when it's certain that the string is + * valid and NUL-free. + * + * This function takes the TCString by pointer because it may be modified in-place to add a NUL + * terminator. The pointer must not be NULL. + * + * This function does _not_ take ownership of the TCString. + */ +const char *tc_string_content(const struct TCString *tcstring); + +/** + * Get the content of the string as a pointer and length. The given string must not be NULL. + * This function can return any string, even one including NUL bytes or invalid UTF-8. The + * returned buffer is valid until the TCString is freed or passed to another TaskChampio + * function. + * + * This function takes the TCString by pointer because it may be modified in-place to add a NUL + * terminator. The pointer must not be NULL. + * + * This function does _not_ take ownership of the TCString. + */ +const char *tc_string_content_with_len(const struct TCString *tcstring, size_t *len_out); + +/** + * Free a TCString. The given string must not be NULL. The string must not be used + * after this function returns, and must not be freed more than once. + */ +void tc_string_free(struct TCString *tcstring); + +/** + * Free a TCStringList instance. The instance, and all TCStringList it contains, must not be used after + * this call. + * + * When this call returns, the `items` pointer will be NULL, signalling an invalid TCStringList. + */ +void tc_string_list_free(struct TCStringList *tcstrings); + +/** + * Convert an immutable task into a mutable task. + * + * The task must not be NULL. It is modified in-place, and becomes mutable. + * + * The replica must not be NULL. After this function returns, the replica _cannot be used at all_ + * until this task is made immutable again. This implies that it is not allowed for more than one + * task associated with a replica to be mutable at any time. + * + * Typical mutation of tasks is bracketed with `tc_task_to_mut` and `tc_task_to_immut`: + * + * ```c + * tc_task_to_mut(task, rep); + * success = tc_task_done(task); + * tc_task_to_immut(task, rep); + * if (!success) { ... } + * ``` + */ +void tc_task_to_mut(struct TCTask *task, struct TCReplica *tcreplica); + +/** + * Convert a mutable task into an immutable task. + * + * The task must not be NULL. It is modified in-place, and becomes immutable. + * + * The replica passed to `tc_task_to_mut` may be used freely after this call. + */ +void tc_task_to_immut(struct TCTask *task); + +/** + * Get a task's UUID. + */ +struct TCUuid tc_task_get_uuid(struct TCTask *task); + +/** + * Get a task's status. + */ +enum TCStatus tc_task_get_status(struct TCTask *task); + +/** + * Get the underlying key/value pairs for this task. The returned TCKVList is + * a "snapshot" of the task and will not be updated if the task is subsequently + * modified. It is the caller's responsibility to free the TCKVList. + */ +struct TCKVList tc_task_get_taskmap(struct TCTask *task); + +/** + * Get a task's description, or NULL if the task cannot be represented as a C string (e.g., if it + * contains embedded NUL characters). + */ +struct TCString tc_task_get_description(struct TCTask *task); + +/** + * Get the entry timestamp for a task (when it was created), or 0 if not set. + */ +time_t tc_task_get_entry(struct TCTask *task); + +/** + * Get the wait timestamp for a task, or 0 if not set. + */ +time_t tc_task_get_wait(struct TCTask *task); + +/** + * Get the modified timestamp for a task, or 0 if not set. + */ +time_t tc_task_get_modified(struct TCTask *task); + +/** + * Check if a task is waiting. + */ +bool tc_task_is_waiting(struct TCTask *task); + +/** + * Check if a task is active (started and not stopped). + */ +bool tc_task_is_active(struct TCTask *task); + +/** + * Check if a task has the given tag. If the tag is invalid, this function will return false, as + * that (invalid) tag is not present. No error will be reported via `tc_task_error`. + */ +bool tc_task_has_tag(struct TCTask *task, struct TCString tag); + +/** + * Get the tags for the task. + * + * The caller must free the returned TCStringList instance. The TCStringList instance does not + * reference the task and the two may be freed in any order. + */ +struct TCStringList tc_task_get_tags(struct TCTask *task); + +/** + * Get the annotations for the task. + * + * The caller must free the returned TCAnnotationList instance. The TCStringList instance does not + * reference the task and the two may be freed in any order. + */ +struct TCAnnotationList tc_task_get_annotations(struct TCTask *task); + +/** + * Get the named UDA from the task. + * + * Returns a TCString with NULL ptr field if the UDA does not exist. + */ +struct TCString tc_task_get_uda(struct TCTask *task, struct TCString ns, struct TCString key); + +/** + * Get the named legacy UDA from the task. + * + * Returns NULL if the UDA does not exist. + */ +struct TCString tc_task_get_legacy_uda(struct TCTask *task, struct TCString key); + +/** + * Get all UDAs for this task. + * + * Legacy UDAs are represented with an empty string in the ns field. + */ +struct TCUdaList tc_task_get_udas(struct TCTask *task); + +/** + * Get all UDAs for this task. + * + * All TCUdas in this list have a NULL ns field. The entire UDA key is + * included in the key field. The caller must free the returned list. + */ +struct TCUdaList tc_task_get_legacy_udas(struct TCTask *task); + +/** + * Set a mutable task's status. + */ +TCResult tc_task_set_status(struct TCTask *task, enum TCStatus status); + +/** + * Set a mutable task's description. + */ +TCResult tc_task_set_description(struct TCTask *task, struct TCString description); + +/** + * Set a mutable task's entry (creation time). Pass entry=0 to unset + * the entry field. + */ +TCResult tc_task_set_entry(struct TCTask *task, time_t entry); + +/** + * Set a mutable task's wait timestamp. Pass wait=0 to unset the wait field. + */ +TCResult tc_task_set_wait(struct TCTask *task, time_t wait); + +/** + * Set a mutable task's modified timestamp. The value cannot be zero. + */ +TCResult tc_task_set_modified(struct TCTask *task, time_t modified); + +/** + * Start a task. + */ +TCResult tc_task_start(struct TCTask *task); + +/** + * Stop a task. + */ +TCResult tc_task_stop(struct TCTask *task); + +/** + * Mark a task as done. + */ +TCResult tc_task_done(struct TCTask *task); + +/** + * Mark a task as deleted. + */ +TCResult tc_task_delete(struct TCTask *task); + +/** + * Add a tag to a mutable task. + */ +TCResult tc_task_add_tag(struct TCTask *task, struct TCString tag); + +/** + * Remove a tag from a mutable task. + */ +TCResult tc_task_remove_tag(struct TCTask *task, struct TCString tag); + +/** + * Add an annotation to a mutable task. This call takes ownership of the + * passed annotation, which must not be used after the call returns. + */ +TCResult tc_task_add_annotation(struct TCTask *task, struct TCAnnotation *annotation); + +/** + * Remove an annotation from a mutable task. + */ +TCResult tc_task_remove_annotation(struct TCTask *task, int64_t entry); + +/** + * Set a UDA on a mutable task. + */ +TCResult tc_task_set_uda(struct TCTask *task, + struct TCString ns, + struct TCString key, + struct TCString value); + +/** + * Remove a UDA fraom a mutable task. + */ +TCResult tc_task_remove_uda(struct TCTask *task, struct TCString ns, struct TCString key); + +/** + * Set a legacy UDA on a mutable task. + */ +TCResult tc_task_set_legacy_uda(struct TCTask *task, struct TCString key, struct TCString value); + +/** + * Remove a UDA fraom a mutable task. + */ +TCResult tc_task_remove_legacy_uda(struct TCTask *task, struct TCString key); + +/** + * Get all dependencies for a task. + */ +struct TCUuidList tc_task_get_dependencies(struct TCTask *task); + +/** + * Add a dependency. + */ +TCResult tc_task_add_dependency(struct TCTask *task, struct TCUuid dep); + +/** + * Remove a dependency. + */ +TCResult tc_task_remove_dependency(struct TCTask *task, struct TCUuid dep); + +/** + * Get the latest error for a task, or a string NULL ptr field if the last operation succeeded. + * Subsequent calls to this function will return NULL. The task pointer must not be NULL. The + * caller must free the returned string. + */ +struct TCString tc_task_error(struct TCTask *task); + +/** + * Free a task. The given task must not be NULL. The task must not be used after this function + * returns, and must not be freed more than once. + * + * If the task is currently mutable, it will first be made immutable. + */ +void tc_task_free(struct TCTask *task); + +/** + * Take an item from a TCTaskList. After this call, the indexed item is no longer associated + * with the list and becomes the caller's responsibility, just as if it had been returned from + * `tc_replica_get_task`. + * + * The corresponding element in the `items` array will be set to NULL. If that field is already + * NULL (that is, if the item has already been taken), this function will return NULL. If the + * index is out of bounds, this function will also return NULL. + * + * The passed TCTaskList remains owned by the caller. + */ +struct TCTask *tc_task_list_take(struct TCTaskList *tasks, size_t index); + +/** + * Free a TCTaskList instance. The instance, and all TCTaskList it contains, must not be used after + * this call. + * + * When this call returns, the `items` pointer will be NULL, signalling an invalid TCTaskList. + */ +void tc_task_list_free(struct TCTaskList *tasks); + +/** + * Free a TCUda instance. The instance, and the TCStrings it contains, must not be used + * after this call. + */ +void tc_uda_free(struct TCUda *tcuda); + +/** + * Free a TCUdaList instance. The instance, and all TCUdas it contains, must not be used after + * this call. + * + * When this call returns, the `items` pointer will be NULL, signalling an invalid TCUdaList. + */ +void tc_uda_list_free(struct TCUdaList *tcudas); + +/** + * Create a new, randomly-generated UUID. + */ +struct TCUuid tc_uuid_new_v4(void); + +/** + * Create a new UUID with the nil value. + */ +struct TCUuid tc_uuid_nil(void); + +/** + * Write the string representation of a TCUuid into the given buffer, which must be + * at least TC_UUID_STRING_BYTES long. No NUL terminator is added. + */ +void tc_uuid_to_buf(struct TCUuid tcuuid, char *buf); + +/** + * Return the hyphenated string representation of a TCUuid. The returned string + * must be freed with tc_string_free. + */ +struct TCString tc_uuid_to_str(struct TCUuid tcuuid); + +/** + * Parse the given string as a UUID. Returns TC_RESULT_ERROR on parse failure or if the given + * string is not valid. + */ +TCResult tc_uuid_from_str(struct TCString s, struct TCUuid *uuid_out); + +/** + * Free a TCUuidList instance. The instance, and all TCUuids it contains, must not be used after + * this call. + * + * When this call returns, the `items` pointer will be NULL, signalling an invalid TCUuidList. + */ +void tc_uuid_list_free(struct TCUuidList *tcuuids); + +/** + * Get the working set's length, or the number of UUIDs it contains. + */ +size_t tc_working_set_len(struct TCWorkingSet *ws); + +/** + * Get the working set's largest index. + */ +size_t tc_working_set_largest_index(struct TCWorkingSet *ws); + +/** + * Get the UUID for the task at the given index. Returns true if the UUID exists in the working + * set. If not, returns false and does not change uuid_out. + */ +bool tc_working_set_by_index(struct TCWorkingSet *ws, size_t index, struct TCUuid *uuid_out); + +/** + * Get the working set index for the task with the given UUID. Returns 0 if the task is not in + * the working set. + */ +size_t tc_working_set_by_uuid(struct TCWorkingSet *ws, struct TCUuid uuid); + +/** + * Free a TCWorkingSet. The given value must not be NULL. The value must not be used after this + * function returns, and must not be freed more than once. + */ +void tc_working_set_free(struct TCWorkingSet *ws); + +#ifdef __cplusplus +} // extern "C" +#endif // __cplusplus + +#endif /* TASKCHAMPION_H */ diff --git a/rust/scripts/changelog.py b/rust/scripts/changelog.py new file mode 100755 index 000000000..0eac4fa35 --- /dev/null +++ b/rust/scripts/changelog.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +import os +import argparse +import datetime +import subprocess +from typing import List + +def ymd(): + return datetime.datetime.now().strftime("%Y-%m-%d") + +def git_current_branch() -> str : + out = subprocess.check_output(["git", "branch", "--show-current"]) + return out.strip().decode("utf-8") + +def get_dir() -> str: + here = os.path.dirname(os.path.abspath(__file__)) + return os.path.join( + here, + "../.changelogs") + +def get_changefiles() -> List[str]: + changedir = get_dir() + changefiles = [] + for f in os.listdir(changedir): + if f.endswith(".md") and not f.startswith("."): + changefiles.append(os.path.join(changedir, f)) + + return changefiles + +def cmd_add(args): + text = args.text.strip() + if not text.startswith("- "): + text = "- %s" % text + + timestamp = ymd() + branchname = git_current_branch() + fname = os.path.join(get_dir(), "%s-%s.md" % (timestamp, branchname)) + with open(fname, "a") as f: + f.write(text) + f.write("\n") + +def cmd_build(args): + print("## x.y.z - %s" % (ymd())) + for e in get_changefiles(): + print(open(e).read().strip()) + +def main() -> None: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(title='Sub commands', dest='command') + subparsers.required = True + + parser_add = subparsers.add_parser('add') + parser_add.add_argument("text") + parser_add.set_defaults(func=cmd_add) + + parser_build = subparsers.add_parser('build') + parser_build.set_defaults(func=cmd_build) + + args = parser.parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/rust/sync-server/Cargo.toml b/rust/sync-server/Cargo.toml new file mode 100644 index 000000000..25ac111bf --- /dev/null +++ b/rust/sync-server/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "taskchampion-sync-server" +version = "0.4.1" +authors = ["Dustin J. Mitchell "] +edition = "2018" +publish = false + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +uuid = { version = "^0.8.2", features = ["serde", "v4"] } +actix-web = "^3.3.2" +anyhow = "1.0" +thiserror = "1.0" +futures = "^0.3.8" +serde = "^1.0.125" +serde_json = "^1.0" +clap = "^2.33.0" +log = "^0.4.14" +env_logger = "^0.8.3" +rusqlite = { version = "0.25", features = ["bundled"] } +chrono = { version = "^0.4.10", features = ["serde"] } + +[dev-dependencies] +actix-rt = "^1.1.1" +tempfile = "3" +pretty_assertions = "1" diff --git a/rust/sync-server/src/api/add_snapshot.rs b/rust/sync-server/src/api/add_snapshot.rs new file mode 100644 index 000000000..5c47b6c38 --- /dev/null +++ b/rust/sync-server/src/api/add_snapshot.rs @@ -0,0 +1,204 @@ +use crate::api::{client_key_header, failure_to_ise, ServerState, SNAPSHOT_CONTENT_TYPE}; +use crate::server::{add_snapshot, VersionId, NIL_VERSION_ID}; +use actix_web::{error, post, web, HttpMessage, HttpRequest, HttpResponse, Result}; +use futures::StreamExt; +use std::sync::Arc; + +/// Max snapshot size: 100MB +const MAX_SIZE: usize = 100 * 1024 * 1024; + +/// Add a new snapshot, after checking prerequisites. The snapshot should be transmitted in the +/// request entity body and must have content-type `application/vnd.taskchampion.snapshot`. The +/// content can be encoded in any of the formats supported by actix-web. +/// +/// On success, the response is a 200 OK. Even in a 200 OK, the snapshot may not appear in a +/// subsequent `GetSnapshot` call. +/// +/// Returns other 4xx or 5xx responses on other errors. +#[post("/v1/client/add-snapshot/{version_id}")] +pub(crate) async fn service( + req: HttpRequest, + server_state: web::Data>, + web::Path((version_id,)): web::Path<(VersionId,)>, + mut payload: web::Payload, +) -> Result { + // check content-type + if req.content_type() != SNAPSHOT_CONTENT_TYPE { + return Err(error::ErrorBadRequest("Bad content-type")); + } + + let client_key = client_key_header(&req)?; + + // read the body in its entirety + let mut body = web::BytesMut::new(); + while let Some(chunk) = payload.next().await { + let chunk = chunk?; + // limit max size of in-memory payload + if (body.len() + chunk.len()) > MAX_SIZE { + return Err(error::ErrorBadRequest("Snapshot over maximum allowed size")); + } + body.extend_from_slice(&chunk); + } + + if body.is_empty() { + return Err(error::ErrorBadRequest("No snapshot supplied")); + } + + // note that we do not open the transaction until the body has been read + // completely, to avoid blocking other storage access while that data is + // in transit. + let mut txn = server_state.storage.txn().map_err(failure_to_ise)?; + + // get, or create, the client + let client = match txn.get_client(client_key).map_err(failure_to_ise)? { + Some(client) => client, + None => { + txn.new_client(client_key, NIL_VERSION_ID) + .map_err(failure_to_ise)?; + txn.get_client(client_key).map_err(failure_to_ise)?.unwrap() + } + }; + + add_snapshot( + txn, + &server_state.config, + client_key, + client, + version_id, + body.to_vec(), + ) + .map_err(failure_to_ise)?; + Ok(HttpResponse::Ok().body("")) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::storage::{InMemoryStorage, Storage}; + use crate::Server; + use actix_web::{http::StatusCode, test, App}; + use pretty_assertions::assert_eq; + use uuid::Uuid; + + #[actix_rt::test] + async fn test_success() -> anyhow::Result<()> { + let client_key = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + + // set up the storage contents.. + { + let mut txn = storage.txn().unwrap(); + txn.new_client(client_key, version_id).unwrap(); + txn.add_version(client_key, version_id, NIL_VERSION_ID, vec![])?; + } + + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = format!("/v1/client/add-snapshot/{}", version_id); + let req = test::TestRequest::post() + .uri(&uri) + .header("Content-Type", "application/vnd.taskchampion.snapshot") + .header("X-Client-Key", client_key.to_string()) + .set_payload(b"abcd".to_vec()) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + + // read back that snapshot + let uri = "/v1/client/snapshot"; + let req = test::TestRequest::get() + .uri(uri) + .header("X-Client-Key", client_key.to_string()) + .to_request(); + let mut resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + + use futures::StreamExt; + let (bytes, _) = resp.take_body().into_future().await; + assert_eq!(bytes.unwrap().unwrap().as_ref(), b"abcd"); + + Ok(()) + } + + #[actix_rt::test] + async fn test_not_added_200() { + let client_key = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + + // set up the storage contents.. + { + let mut txn = storage.txn().unwrap(); + txn.new_client(client_key, NIL_VERSION_ID).unwrap(); + } + + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + // add a snapshot for a nonexistent version + let uri = format!("/v1/client/add-snapshot/{}", version_id); + let req = test::TestRequest::post() + .uri(&uri) + .header("Content-Type", "application/vnd.taskchampion.snapshot") + .header("X-Client-Key", client_key.to_string()) + .set_payload(b"abcd".to_vec()) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + + // read back, seeing no snapshot + let uri = "/v1/client/snapshot"; + let req = test::TestRequest::get() + .uri(uri) + .header("X-Client-Key", client_key.to_string()) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[actix_rt::test] + async fn test_bad_content_type() { + let client_key = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = format!("/v1/client/add-snapshot/{}", version_id); + let req = test::TestRequest::post() + .uri(&uri) + .header("Content-Type", "not/correct") + .header("X-Client-Key", client_key.to_string()) + .set_payload(b"abcd".to_vec()) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[actix_rt::test] + async fn test_empty_body() { + let client_key = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = format!("/v1/client/add-snapshot/{}", version_id); + let req = test::TestRequest::post() + .uri(&uri) + .header( + "Content-Type", + "application/vnd.taskchampion.history-segment", + ) + .header("X-Client-Key", client_key.to_string()) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } +} diff --git a/rust/sync-server/src/api/add_version.rs b/rust/sync-server/src/api/add_version.rs new file mode 100644 index 000000000..ecf8a18dc --- /dev/null +++ b/rust/sync-server/src/api/add_version.rs @@ -0,0 +1,230 @@ +use crate::api::{ + client_key_header, failure_to_ise, ServerState, HISTORY_SEGMENT_CONTENT_TYPE, + PARENT_VERSION_ID_HEADER, SNAPSHOT_REQUEST_HEADER, VERSION_ID_HEADER, +}; +use crate::server::{add_version, AddVersionResult, SnapshotUrgency, VersionId, NIL_VERSION_ID}; +use actix_web::{error, post, web, HttpMessage, HttpRequest, HttpResponse, Result}; +use futures::StreamExt; +use std::sync::Arc; + +/// Max history segment size: 100MB +const MAX_SIZE: usize = 100 * 1024 * 1024; + +/// Add a new version, after checking prerequisites. The history segment should be transmitted in +/// the request entity body and must have content-type +/// `application/vnd.taskchampion.history-segment`. The content can be encoded in any of the +/// formats supported by actix-web. +/// +/// On success, the response is a 200 OK with the new version ID in the `X-Version-Id` header. If +/// the version cannot be added due to a conflict, the response is a 409 CONFLICT with the expected +/// parent version ID in the `X-Parent-Version-Id` header. +/// +/// If included, a snapshot request appears in the `X-Snapshot-Request` header with value +/// `urgency=low` or `urgency=high`. +/// +/// Returns other 4xx or 5xx responses on other errors. +#[post("/v1/client/add-version/{parent_version_id}")] +pub(crate) async fn service( + req: HttpRequest, + server_state: web::Data>, + web::Path((parent_version_id,)): web::Path<(VersionId,)>, + mut payload: web::Payload, +) -> Result { + // check content-type + if req.content_type() != HISTORY_SEGMENT_CONTENT_TYPE { + return Err(error::ErrorBadRequest("Bad content-type")); + } + + let client_key = client_key_header(&req)?; + + // read the body in its entirety + let mut body = web::BytesMut::new(); + while let Some(chunk) = payload.next().await { + let chunk = chunk?; + // limit max size of in-memory payload + if (body.len() + chunk.len()) > MAX_SIZE { + return Err(error::ErrorBadRequest("overflow")); + } + body.extend_from_slice(&chunk); + } + + if body.is_empty() { + return Err(error::ErrorBadRequest("Empty body")); + } + + // note that we do not open the transaction until the body has been read + // completely, to avoid blocking other storage access while that data is + // in transit. + let mut txn = server_state.storage.txn().map_err(failure_to_ise)?; + + // get, or create, the client + let client = match txn.get_client(client_key).map_err(failure_to_ise)? { + Some(client) => client, + None => { + txn.new_client(client_key, NIL_VERSION_ID) + .map_err(failure_to_ise)?; + txn.get_client(client_key).map_err(failure_to_ise)?.unwrap() + } + }; + + let (result, snap_urgency) = add_version( + txn, + &server_state.config, + client_key, + client, + parent_version_id, + body.to_vec(), + ) + .map_err(failure_to_ise)?; + + Ok(match result { + AddVersionResult::Ok(version_id) => { + let mut rb = HttpResponse::Ok(); + rb.header(VERSION_ID_HEADER, version_id.to_string()); + match snap_urgency { + SnapshotUrgency::None => {} + SnapshotUrgency::Low => { + rb.header(SNAPSHOT_REQUEST_HEADER, "urgency=low"); + } + SnapshotUrgency::High => { + rb.header(SNAPSHOT_REQUEST_HEADER, "urgency=high"); + } + }; + rb.finish() + } + AddVersionResult::ExpectedParentVersion(parent_version_id) => { + let mut rb = HttpResponse::Conflict(); + rb.header(PARENT_VERSION_ID_HEADER, parent_version_id.to_string()); + rb.finish() + } + }) +} + +#[cfg(test)] +mod test { + use crate::storage::{InMemoryStorage, Storage}; + use crate::Server; + use actix_web::{http::StatusCode, test, App}; + use pretty_assertions::assert_eq; + use uuid::Uuid; + + #[actix_rt::test] + async fn test_success() { + let client_key = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let parent_version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + + // set up the storage contents.. + { + let mut txn = storage.txn().unwrap(); + txn.new_client(client_key, Uuid::nil()).unwrap(); + } + + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = format!("/v1/client/add-version/{}", parent_version_id); + let req = test::TestRequest::post() + .uri(&uri) + .header( + "Content-Type", + "application/vnd.taskchampion.history-segment", + ) + .header("X-Client-Key", client_key.to_string()) + .set_payload(b"abcd".to_vec()) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + + // the returned version ID is random, but let's check that it's not + // the passed parent version ID, at least + let new_version_id = resp.headers().get("X-Version-Id").unwrap(); + assert!(new_version_id != &version_id.to_string()); + + // Shapshot should be requested, since there is no existing snapshot + let snapshot_request = resp.headers().get("X-Snapshot-Request").unwrap(); + assert_eq!(snapshot_request, "urgency=high"); + + assert_eq!(resp.headers().get("X-Parent-Version-Id"), None); + } + + #[actix_rt::test] + async fn test_conflict() { + let client_key = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let parent_version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + + // set up the storage contents.. + { + let mut txn = storage.txn().unwrap(); + txn.new_client(client_key, version_id).unwrap(); + } + + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = format!("/v1/client/add-version/{}", parent_version_id); + let req = test::TestRequest::post() + .uri(&uri) + .header( + "Content-Type", + "application/vnd.taskchampion.history-segment", + ) + .header("X-Client-Key", client_key.to_string()) + .set_payload(b"abcd".to_vec()) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::CONFLICT); + assert_eq!(resp.headers().get("X-Version-Id"), None); + assert_eq!( + resp.headers().get("X-Parent-Version-Id").unwrap(), + &version_id.to_string() + ); + } + + #[actix_rt::test] + async fn test_bad_content_type() { + let client_key = Uuid::new_v4(); + let parent_version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = format!("/v1/client/add-version/{}", parent_version_id); + let req = test::TestRequest::post() + .uri(&uri) + .header("Content-Type", "not/correct") + .header("X-Client-Key", client_key.to_string()) + .set_payload(b"abcd".to_vec()) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } + + #[actix_rt::test] + async fn test_empty_body() { + let client_key = Uuid::new_v4(); + let parent_version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = format!("/v1/client/add-version/{}", parent_version_id); + let req = test::TestRequest::post() + .uri(&uri) + .header( + "Content-Type", + "application/vnd.taskchampion.history-segment", + ) + .header("X-Client-Key", client_key.to_string()) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + } +} diff --git a/rust/sync-server/src/api/get_child_version.rs b/rust/sync-server/src/api/get_child_version.rs new file mode 100644 index 000000000..2c2f6651e --- /dev/null +++ b/rust/sync-server/src/api/get_child_version.rs @@ -0,0 +1,167 @@ +use crate::api::{ + client_key_header, failure_to_ise, ServerState, HISTORY_SEGMENT_CONTENT_TYPE, + PARENT_VERSION_ID_HEADER, VERSION_ID_HEADER, +}; +use crate::server::{get_child_version, GetVersionResult, VersionId}; +use actix_web::{error, get, web, HttpRequest, HttpResponse, Result}; +use std::sync::Arc; + +/// Get a child version. +/// +/// On succcess, the response is the same sequence of bytes originally sent to the server, +/// with content-type `application/vnd.taskchampion.history-segment`. The `X-Version-Id` and +/// `X-Parent-Version-Id` headers contain the corresponding values. +/// +/// If no such child exists, returns a 404 with no content. +/// Returns other 4xx or 5xx responses on other errors. +#[get("/v1/client/get-child-version/{parent_version_id}")] +pub(crate) async fn service( + req: HttpRequest, + server_state: web::Data>, + web::Path((parent_version_id,)): web::Path<(VersionId,)>, +) -> Result { + let mut txn = server_state.storage.txn().map_err(failure_to_ise)?; + + let client_key = client_key_header(&req)?; + + let client = txn + .get_client(client_key) + .map_err(failure_to_ise)? + .ok_or_else(|| error::ErrorNotFound("no such client"))?; + + return match get_child_version( + txn, + &server_state.config, + client_key, + client, + parent_version_id, + ) + .map_err(failure_to_ise)? + { + GetVersionResult::Success { + version_id, + parent_version_id, + history_segment, + } => Ok(HttpResponse::Ok() + .content_type(HISTORY_SEGMENT_CONTENT_TYPE) + .header(VERSION_ID_HEADER, version_id.to_string()) + .header(PARENT_VERSION_ID_HEADER, parent_version_id.to_string()) + .body(history_segment)), + GetVersionResult::NotFound => Err(error::ErrorNotFound("no such version")), + GetVersionResult::Gone => Err(error::ErrorGone("version has been deleted")), + }; +} + +#[cfg(test)] +mod test { + use crate::server::NIL_VERSION_ID; + use crate::storage::{InMemoryStorage, Storage}; + use crate::Server; + use actix_web::{http::StatusCode, test, App}; + use pretty_assertions::assert_eq; + use uuid::Uuid; + + #[actix_rt::test] + async fn test_success() { + let client_key = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let parent_version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + + // set up the storage contents.. + { + let mut txn = storage.txn().unwrap(); + txn.new_client(client_key, Uuid::new_v4()).unwrap(); + txn.add_version(client_key, version_id, parent_version_id, b"abcd".to_vec()) + .unwrap(); + } + + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = format!("/v1/client/get-child-version/{}", parent_version_id); + let req = test::TestRequest::get() + .uri(&uri) + .header("X-Client-Key", client_key.to_string()) + .to_request(); + let mut resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!( + resp.headers().get("X-Version-Id").unwrap(), + &version_id.to_string() + ); + assert_eq!( + resp.headers().get("X-Parent-Version-Id").unwrap(), + &parent_version_id.to_string() + ); + assert_eq!( + resp.headers().get("Content-Type").unwrap(), + &"application/vnd.taskchampion.history-segment".to_string() + ); + + use futures::StreamExt; + let (bytes, _) = resp.take_body().into_future().await; + assert_eq!(bytes.unwrap().unwrap().as_ref(), b"abcd"); + } + + #[actix_rt::test] + async fn test_client_not_found() { + let client_key = Uuid::new_v4(); + let parent_version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = format!("/v1/client/get-child-version/{}", parent_version_id); + let req = test::TestRequest::get() + .uri(&uri) + .header("X-Client-Key", client_key.to_string()) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + assert_eq!(resp.headers().get("X-Version-Id"), None); + assert_eq!(resp.headers().get("X-Parent-Version-Id"), None); + } + + #[actix_rt::test] + async fn test_version_not_found_and_gone() { + let client_key = Uuid::new_v4(); + let parent_version_id = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + + // create the client, but not the version + { + let mut txn = storage.txn().unwrap(); + txn.new_client(client_key, Uuid::new_v4()).unwrap(); + } + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + // the child of an unknown parent_version_id is GONE + let uri = format!("/v1/client/get-child-version/{}", parent_version_id); + let req = test::TestRequest::get() + .uri(&uri) + .header("X-Client-Key", client_key.to_string()) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::GONE); + assert_eq!(resp.headers().get("X-Version-Id"), None); + assert_eq!(resp.headers().get("X-Parent-Version-Id"), None); + + // but the child of the nil parent_version_id is NOT FOUND, since + // there is no snapshot. The tests in crate::server test more + // corner cases. + let uri = format!("/v1/client/get-child-version/{}", NIL_VERSION_ID); + let req = test::TestRequest::get() + .uri(&uri) + .header("X-Client-Key", client_key.to_string()) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + assert_eq!(resp.headers().get("X-Version-Id"), None); + assert_eq!(resp.headers().get("X-Parent-Version-Id"), None); + } +} diff --git a/rust/sync-server/src/api/get_snapshot.rs b/rust/sync-server/src/api/get_snapshot.rs new file mode 100644 index 000000000..cf76655c8 --- /dev/null +++ b/rust/sync-server/src/api/get_snapshot.rs @@ -0,0 +1,114 @@ +use crate::api::{ + client_key_header, failure_to_ise, ServerState, SNAPSHOT_CONTENT_TYPE, VERSION_ID_HEADER, +}; +use crate::server::get_snapshot; +use actix_web::{error, get, web, HttpRequest, HttpResponse, Result}; +use std::sync::Arc; + +/// Get a snapshot. +/// +/// If a snapshot for this client exists, it is returned with content-type +/// `application/vnd.taskchampion.snapshot`. The `X-Version-Id` header contains the version of the +/// snapshot. +/// +/// If no snapshot exists, returns a 404 with no content. Returns other 4xx or 5xx responses on +/// other errors. +#[get("/v1/client/snapshot")] +pub(crate) async fn service( + req: HttpRequest, + server_state: web::Data>, +) -> Result { + let mut txn = server_state.storage.txn().map_err(failure_to_ise)?; + + let client_key = client_key_header(&req)?; + + let client = txn + .get_client(client_key) + .map_err(failure_to_ise)? + .ok_or_else(|| error::ErrorNotFound("no such client"))?; + + if let Some((version_id, data)) = + get_snapshot(txn, &server_state.config, client_key, client).map_err(failure_to_ise)? + { + Ok(HttpResponse::Ok() + .content_type(SNAPSHOT_CONTENT_TYPE) + .header(VERSION_ID_HEADER, version_id.to_string()) + .body(data)) + } else { + Err(error::ErrorNotFound("no snapshot")) + } +} + +#[cfg(test)] +mod test { + use crate::storage::{InMemoryStorage, Snapshot, Storage}; + use crate::Server; + use actix_web::{http::StatusCode, test, App}; + use chrono::{TimeZone, Utc}; + use pretty_assertions::assert_eq; + use uuid::Uuid; + + #[actix_rt::test] + async fn test_not_found() { + let client_key = Uuid::new_v4(); + let storage: Box = Box::new(InMemoryStorage::new()); + + // set up the storage contents.. + { + let mut txn = storage.txn().unwrap(); + txn.new_client(client_key, Uuid::new_v4()).unwrap(); + } + + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = "/v1/client/snapshot"; + let req = test::TestRequest::get() + .uri(uri) + .header("X-Client-Key", client_key.to_string()) + .to_request(); + let resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[actix_rt::test] + async fn test_success() { + let client_key = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let snapshot_data = vec![1, 2, 3, 4]; + let storage: Box = Box::new(InMemoryStorage::new()); + + // set up the storage contents.. + { + let mut txn = storage.txn().unwrap(); + txn.new_client(client_key, Uuid::new_v4()).unwrap(); + txn.set_snapshot( + client_key, + Snapshot { + version_id, + versions_since: 3, + timestamp: Utc.ymd(2001, 9, 9).and_hms(1, 46, 40), + }, + snapshot_data.clone(), + ) + .unwrap(); + } + + let server = Server::new(Default::default(), storage); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let uri = "/v1/client/snapshot"; + let req = test::TestRequest::get() + .uri(uri) + .header("X-Client-Key", client_key.to_string()) + .to_request(); + let mut resp = test::call_service(&mut app, req).await; + assert_eq!(resp.status(), StatusCode::OK); + + use futures::StreamExt; + let (bytes, _) = resp.take_body().into_future().await; + assert_eq!(bytes.unwrap().unwrap().as_ref(), snapshot_data); + } +} diff --git a/rust/sync-server/src/api/mod.rs b/rust/sync-server/src/api/mod.rs new file mode 100644 index 000000000..1a12b2826 --- /dev/null +++ b/rust/sync-server/src/api/mod.rs @@ -0,0 +1,61 @@ +use crate::server::ClientKey; +use crate::storage::Storage; +use crate::ServerConfig; +use actix_web::{error, http::StatusCode, web, HttpRequest, Result, Scope}; + +mod add_snapshot; +mod add_version; +mod get_child_version; +mod get_snapshot; + +/// The content-type for history segments (opaque blobs of bytes) +pub(crate) const HISTORY_SEGMENT_CONTENT_TYPE: &str = + "application/vnd.taskchampion.history-segment"; + +/// The content-type for snapshots (opaque blobs of bytes) +pub(crate) const SNAPSHOT_CONTENT_TYPE: &str = "application/vnd.taskchampion.snapshot"; + +/// The header name for version ID +pub(crate) const VERSION_ID_HEADER: &str = "X-Version-Id"; + +/// The header name for client key +pub(crate) const CLIENT_KEY_HEADER: &str = "X-Client-Key"; + +/// The header name for parent version ID +pub(crate) const PARENT_VERSION_ID_HEADER: &str = "X-Parent-Version-Id"; + +/// The header name for parent version ID +pub(crate) const SNAPSHOT_REQUEST_HEADER: &str = "X-Snapshot-Request"; + +/// The type containing a reference to the persistent state for the server +pub(crate) struct ServerState { + pub(crate) storage: Box, + pub(crate) config: ServerConfig, +} + +pub(crate) fn api_scope() -> Scope { + web::scope("") + .service(get_child_version::service) + .service(add_version::service) + .service(get_snapshot::service) + .service(add_snapshot::service) +} + +/// Convert a failure::Error to an Actix ISE +fn failure_to_ise(err: anyhow::Error) -> impl actix_web::ResponseError { + error::InternalError::new(err, StatusCode::INTERNAL_SERVER_ERROR) +} + +/// Get the client key +fn client_key_header(req: &HttpRequest) -> Result { + fn badrequest() -> error::Error { + error::ErrorBadRequest("bad x-client-id") + } + if let Some(client_key_hdr) = req.headers().get(CLIENT_KEY_HEADER) { + let client_key = client_key_hdr.to_str().map_err(|_| badrequest())?; + let client_key = ClientKey::parse_str(client_key).map_err(|_| badrequest())?; + Ok(client_key) + } else { + Err(badrequest()) + } +} diff --git a/rust/sync-server/src/bin/taskchampion-sync-server.rs b/rust/sync-server/src/bin/taskchampion-sync-server.rs new file mode 100644 index 000000000..8ee1e2390 --- /dev/null +++ b/rust/sync-server/src/bin/taskchampion-sync-server.rs @@ -0,0 +1,93 @@ +#![deny(clippy::all)] + +use actix_web::{middleware::Logger, App, HttpServer}; +use clap::Arg; +use taskchampion_sync_server::storage::SqliteStorage; +use taskchampion_sync_server::{Server, ServerConfig}; + +#[actix_web::main] +async fn main() -> anyhow::Result<()> { + env_logger::init(); + let defaults = ServerConfig::default(); + let default_snapshot_versions = defaults.snapshot_versions.to_string(); + let default_snapshot_days = defaults.snapshot_days.to_string(); + let matches = clap::App::new("taskchampion-sync-server") + .version(env!("CARGO_PKG_VERSION")) + .about("Server for TaskChampion") + .arg( + Arg::with_name("port") + .short("p") + .long("port") + .value_name("PORT") + .help("Port on which to serve") + .default_value("8080") + .takes_value(true) + .required(true), + ) + .arg( + Arg::with_name("data-dir") + .short("d") + .long("data-dir") + .value_name("DIR") + .help("Directory in which to store data") + .default_value("/var/lib/taskchampion-sync-server") + .takes_value(true) + .required(true), + ) + .arg( + Arg::with_name("snapshot-versions") + .long("snapshot-versions") + .value_name("NUM") + .help("Target number of versions between snapshots") + .default_value(&default_snapshot_versions) + .takes_value(true) + .required(false), + ) + .arg( + Arg::with_name("snapshot-days") + .long("snapshot-days") + .value_name("NUM") + .help("Target number of days between snapshots") + .default_value(&default_snapshot_days) + .takes_value(true) + .required(false), + ) + .get_matches(); + + let data_dir = matches.value_of("data-dir").unwrap(); + let port = matches.value_of("port").unwrap(); + let snapshot_versions = matches.value_of("snapshot-versions").unwrap(); + let snapshot_days = matches.value_of("snapshot-versions").unwrap(); + + let config = ServerConfig::from_args(snapshot_days, snapshot_versions)?; + let server = Server::new(config, Box::new(SqliteStorage::new(data_dir)?)); + + log::warn!("Serving on port {}", port); + HttpServer::new(move || { + App::new() + .wrap(Logger::default()) + .configure(|cfg| server.config(cfg)) + }) + .bind(format!("0.0.0.0:{}", port))? + .run() + .await?; + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use actix_web::{test, App}; + use taskchampion_sync_server::storage::InMemoryStorage; + + #[actix_rt::test] + async fn test_index_get() { + let server = Server::new(Default::default(), Box::new(InMemoryStorage::new())); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let req = test::TestRequest::get().uri("/").to_request(); + let resp = test::call_service(&mut app, req).await; + assert!(resp.status().is_success()); + } +} diff --git a/rust/sync-server/src/lib.rs b/rust/sync-server/src/lib.rs new file mode 100644 index 000000000..0ce33dbfe --- /dev/null +++ b/rust/sync-server/src/lib.rs @@ -0,0 +1,73 @@ +#![deny(clippy::all)] + +mod api; +mod server; +pub mod storage; + +use crate::storage::Storage; +use actix_web::{get, middleware, web, Responder}; +use api::{api_scope, ServerState}; +use std::sync::Arc; + +pub use server::ServerConfig; + +#[get("/")] +async fn index() -> impl Responder { + format!("TaskChampion sync server v{}", env!("CARGO_PKG_VERSION")) +} + +/// A Server represents a sync server. +#[derive(Clone)] +pub struct Server { + server_state: Arc, +} + +impl Server { + /// Create a new sync server with the given storage implementation. + pub fn new(config: ServerConfig, storage: Box) -> Self { + Self { + server_state: Arc::new(ServerState { config, storage }), + } + } + + /// Get an Actix-web service for this server. + pub fn config(&self, cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("") + .data(self.server_state.clone()) + .wrap( + middleware::DefaultHeaders::new() + .header("Cache-Control", "no-store, max-age=0"), + ) + .service(index) + .service(api_scope()), + ); + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::storage::InMemoryStorage; + use actix_web::{test, App}; + use pretty_assertions::assert_eq; + + pub(crate) fn init_logging() { + let _ = env_logger::builder().is_test(true).try_init(); + } + + #[actix_rt::test] + async fn test_cache_control() { + let server = Server::new(Default::default(), Box::new(InMemoryStorage::new())); + let app = App::new().configure(|sc| server.config(sc)); + let mut app = test::init_service(app).await; + + let req = test::TestRequest::get().uri("/").to_request(); + let resp = test::call_service(&mut app, req).await; + assert!(resp.status().is_success()); + assert_eq!( + resp.headers().get("Cache-Control").unwrap(), + &"no-store, max-age=0".to_string() + ) + } +} diff --git a/rust/sync-server/src/server.rs b/rust/sync-server/src/server.rs new file mode 100644 index 000000000..fea649e9d --- /dev/null +++ b/rust/sync-server/src/server.rs @@ -0,0 +1,1053 @@ +//! This module implements the core logic of the server: handling transactions, upholding +//! invariants, and so on. This does not implement the HTTP-specific portions; those +//! are in [`crate::api`]. See the protocol documentation for details. +use crate::storage::{Client, Snapshot, StorageTxn}; +use anyhow::Context; +use chrono::Utc; +use uuid::Uuid; + +/// The distinguished value for "no version" +pub const NIL_VERSION_ID: VersionId = Uuid::nil(); + +/// Number of versions to search back from the latest to find the +/// version for a newly-added snapshot. Snapshots for versions older +/// than this will be rejected. +const SNAPSHOT_SEARCH_LEN: i32 = 5; + +pub(crate) type HistorySegment = Vec; +pub(crate) type ClientKey = Uuid; +pub(crate) type VersionId = Uuid; + +/// ServerConfig contains configuration parameters for the server. +pub struct ServerConfig { + /// Target number of days between snapshots. + pub snapshot_days: i64, + + /// Target number of versions between snapshots. + pub snapshot_versions: u32, +} + +impl Default for ServerConfig { + fn default() -> Self { + ServerConfig { + snapshot_days: 14, + snapshot_versions: 100, + } + } +} + +impl ServerConfig { + pub fn from_args(snapshot_days: &str, snapshot_versions: &str) -> anyhow::Result { + Ok(ServerConfig { + snapshot_days: snapshot_days + .parse() + .context("--snapshot-days must be a number")?, + snapshot_versions: snapshot_versions + .parse() + .context("--snapshot-days must be a number")?, + }) + } +} + +/// Response to get_child_version. See the protocol documentation. +#[derive(Clone, PartialEq, Debug)] +pub(crate) enum GetVersionResult { + NotFound, + Gone, + Success { + version_id: Uuid, + parent_version_id: Uuid, + history_segment: HistorySegment, + }, +} + +/// Implementation of the GetChildVersion protocol transaction +pub(crate) fn get_child_version<'a>( + mut txn: Box, + _config: &ServerConfig, + client_key: ClientKey, + client: Client, + parent_version_id: VersionId, +) -> anyhow::Result { + // If a version with parentVersionId equal to the requested parentVersionId exists, it is returned. + if let Some(version) = txn.get_version_by_parent(client_key, parent_version_id)? { + return Ok(GetVersionResult::Success { + version_id: version.version_id, + parent_version_id: version.parent_version_id, + history_segment: version.history_segment, + }); + } + + // If the requested parentVersionId is the nil UUID .. + if parent_version_id == NIL_VERSION_ID { + return Ok(match client.snapshot { + // ..and snapshotVersionId is nil, the response is _not-found_ (the client has no + // versions). + None => GetVersionResult::NotFound, + // ..and snapshotVersionId is not nil, the response is _gone_ (the first version has + // been deleted). + Some(_) => GetVersionResult::Gone, + }); + } + + // If a version with versionId equal to the requested parentVersionId exists, the response is _not-found_ (the client is up-to-date) + if txn.get_version(client_key, parent_version_id)?.is_some() { + return Ok(GetVersionResult::NotFound); + } + + // Otherwise, the response is _gone_ (the requested version has been deleted). + Ok(GetVersionResult::Gone) +} + +/// Response to add_version +#[derive(Clone, PartialEq, Debug)] +pub(crate) enum AddVersionResult { + /// OK, version added with the given ID + Ok(VersionId), + /// Rejected; expected a version with the given parent version + ExpectedParentVersion(VersionId), +} + +/// Urgency of a snapshot for a client; used to create the `X-Snapshot-Request` header. +#[derive(PartialEq, Debug, Clone, Copy, Eq, PartialOrd, Ord)] +pub(crate) enum SnapshotUrgency { + /// Don't need a snapshot right now. + None, + + /// A snapshot would be good, but can wait for other replicas to provide it. + Low, + + /// A snapshot is needed right now. + High, +} + +impl SnapshotUrgency { + /// Calculate the urgency for a snapshot based on its age in days + fn for_days(config: &ServerConfig, days: i64) -> Self { + if days >= config.snapshot_days * 3 / 2 { + SnapshotUrgency::High + } else if days >= config.snapshot_days { + SnapshotUrgency::Low + } else { + SnapshotUrgency::None + } + } + + /// Calculate the urgency for a snapshot based on its age in versions + fn for_versions_since(config: &ServerConfig, versions_since: u32) -> Self { + if versions_since >= config.snapshot_versions * 3 / 2 { + SnapshotUrgency::High + } else if versions_since >= config.snapshot_versions { + SnapshotUrgency::Low + } else { + SnapshotUrgency::None + } + } +} + +/// Implementation of the AddVersion protocol transaction +pub(crate) fn add_version<'a>( + mut txn: Box, + config: &ServerConfig, + client_key: ClientKey, + client: Client, + parent_version_id: VersionId, + history_segment: HistorySegment, +) -> anyhow::Result<(AddVersionResult, SnapshotUrgency)> { + log::debug!( + "add_version(client_key: {}, parent_version_id: {})", + client_key, + parent_version_id, + ); + + // check if this version is acceptable, under the protection of the transaction + if client.latest_version_id != NIL_VERSION_ID && parent_version_id != client.latest_version_id { + log::debug!("add_version request rejected: mismatched latest_version_id"); + return Ok(( + AddVersionResult::ExpectedParentVersion(client.latest_version_id), + SnapshotUrgency::None, + )); + } + + // invent a version ID + let version_id = Uuid::new_v4(); + log::debug!( + "add_version request accepted: new version_id: {}", + version_id + ); + + // update the DB + txn.add_version(client_key, version_id, parent_version_id, history_segment)?; + txn.commit()?; + + // calculate the urgency + let time_urgency = match client.snapshot { + None => SnapshotUrgency::High, + Some(Snapshot { timestamp, .. }) => { + SnapshotUrgency::for_days(config, (Utc::now() - timestamp).num_days()) + } + }; + + let version_urgency = match client.snapshot { + None => SnapshotUrgency::High, + Some(Snapshot { versions_since, .. }) => { + SnapshotUrgency::for_versions_since(config, versions_since) + } + }; + + Ok(( + AddVersionResult::Ok(version_id), + std::cmp::max(time_urgency, version_urgency), + )) +} + +/// Implementation of the AddSnapshot protocol transaction +pub(crate) fn add_snapshot<'a>( + mut txn: Box, + _config: &ServerConfig, + client_key: ClientKey, + client: Client, + version_id: VersionId, + data: Vec, +) -> anyhow::Result<()> { + log::debug!( + "add_snapshot(client_key: {}, version_id: {})", + client_key, + version_id, + ); + + // NOTE: if the snapshot is rejected, this function logs about it and returns + // Ok(()), as there's no reason to report an errot to the client / user. + + let last_snapshot = client.snapshot.map(|snap| snap.version_id); + if Some(version_id) == last_snapshot { + log::debug!( + "rejecting snapshot for version {}: already exists", + version_id + ); + return Ok(()); + } + + // look for this version in the history of this client, starting at the latest version, and + // only iterating for a limited number of versions. + let mut search_len = SNAPSHOT_SEARCH_LEN; + let mut vid = client.latest_version_id; + + loop { + if vid == version_id && version_id != NIL_VERSION_ID { + // the new snapshot is for a recent version, so proceed + break; + } + + if Some(vid) == last_snapshot { + // the new snapshot is older than the last snapshot, so ignore it + log::debug!( + "rejecting snapshot for version {}: newer snapshot already exists or no such version", + version_id + ); + return Ok(()); + } + + search_len -= 1; + if search_len <= 0 || vid == NIL_VERSION_ID { + // this should not happen in normal operation, so warn about it + log::warn!( + "rejecting snapshot for version {}: version is too old or no such version", + version_id + ); + return Ok(()); + } + + // get the parent version ID + if let Some(parent) = txn.get_version(client_key, vid)? { + vid = parent.parent_version_id; + } else { + // this version does not exist; "this should not happen" but if it does, + // we don't need a snapshot earlier than the missing version. + log::warn!( + "rejecting snapshot for version {}: newer versions have already been deleted", + version_id + ); + return Ok(()); + } + } + + log::warn!("accepting snapshot for version {}", version_id); + txn.set_snapshot( + client_key, + Snapshot { + version_id, + timestamp: Utc::now(), + versions_since: 0, + }, + data, + )?; + txn.commit()?; + Ok(()) +} + +/// Implementation of the GetSnapshot protocol transaction +pub(crate) fn get_snapshot<'a>( + mut txn: Box, + _config: &ServerConfig, + client_key: ClientKey, + client: Client, +) -> anyhow::Result)>> { + Ok(if let Some(snap) = client.snapshot { + txn.get_snapshot_data(client_key, snap.version_id)? + .map(|data| (snap.version_id, data)) + } else { + None + }) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::storage::{InMemoryStorage, Snapshot, Storage}; + use crate::test::init_logging; + use chrono::{Duration, TimeZone, Utc}; + use pretty_assertions::assert_eq; + + #[test] + fn snapshot_urgency_max() { + use SnapshotUrgency::*; + assert_eq!(std::cmp::max(None, None), None); + assert_eq!(std::cmp::max(None, Low), Low); + assert_eq!(std::cmp::max(None, High), High); + assert_eq!(std::cmp::max(Low, None), Low); + assert_eq!(std::cmp::max(Low, Low), Low); + assert_eq!(std::cmp::max(Low, High), High); + assert_eq!(std::cmp::max(High, None), High); + assert_eq!(std::cmp::max(High, Low), High); + assert_eq!(std::cmp::max(High, High), High); + } + + #[test] + fn snapshot_urgency_for_days() { + use SnapshotUrgency::*; + let config = ServerConfig::default(); + assert_eq!(SnapshotUrgency::for_days(&config, 0), None); + assert_eq!( + SnapshotUrgency::for_days(&config, config.snapshot_days), + Low + ); + assert_eq!( + SnapshotUrgency::for_days(&config, config.snapshot_days * 2), + High + ); + } + + #[test] + fn snapshot_urgency_for_versions_since() { + use SnapshotUrgency::*; + let config = ServerConfig::default(); + assert_eq!(SnapshotUrgency::for_versions_since(&config, 0), None); + assert_eq!( + SnapshotUrgency::for_versions_since(&config, config.snapshot_versions), + Low + ); + assert_eq!( + SnapshotUrgency::for_versions_since(&config, config.snapshot_versions * 2), + High + ); + } + + #[test] + fn get_child_version_not_found_initial() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_key = Uuid::new_v4(); + txn.new_client(client_key, NIL_VERSION_ID)?; + + // when no snapshot exists, the first version is NotFound + let client = txn.get_client(client_key)?.unwrap(); + assert_eq!( + get_child_version( + txn, + &ServerConfig::default(), + client_key, + client, + NIL_VERSION_ID + )?, + GetVersionResult::NotFound + ); + Ok(()) + } + + #[test] + fn get_child_version_gone_initial() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_key = Uuid::new_v4(); + + txn.new_client(client_key, Uuid::new_v4())?; + txn.set_snapshot( + client_key, + Snapshot { + version_id: Uuid::new_v4(), + versions_since: 0, + timestamp: Utc.ymd(2001, 9, 9).and_hms(1, 46, 40), + }, + vec![1, 2, 3], + )?; + + // when a snapshot exists, the first version is GONE + let client = txn.get_client(client_key)?.unwrap(); + assert_eq!( + get_child_version( + txn, + &ServerConfig::default(), + client_key, + client, + NIL_VERSION_ID + )?, + GetVersionResult::Gone + ); + Ok(()) + } + + #[test] + fn get_child_version_not_found_up_to_date() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_key = Uuid::new_v4(); + + // add a parent version, but not the requested child version + let parent_version_id = Uuid::new_v4(); + txn.new_client(client_key, parent_version_id)?; + txn.add_version(client_key, parent_version_id, NIL_VERSION_ID, vec![])?; + + let client = txn.get_client(client_key)?.unwrap(); + assert_eq!( + get_child_version( + txn, + &ServerConfig::default(), + client_key, + client, + parent_version_id + )?, + GetVersionResult::NotFound + ); + Ok(()) + } + + #[test] + fn get_child_version_gone() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_key = Uuid::new_v4(); + + // make up a parent version id, but neither that version + // nor its child exists (presumed to have been deleted) + let parent_version_id = Uuid::new_v4(); + txn.new_client(client_key, Uuid::new_v4())?; + + let client = txn.get_client(client_key)?.unwrap(); + assert_eq!( + get_child_version( + txn, + &ServerConfig::default(), + client_key, + client, + parent_version_id + )?, + GetVersionResult::Gone + ); + Ok(()) + } + + #[test] + fn get_child_version_found() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_key = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let parent_version_id = Uuid::new_v4(); + let history_segment = b"abcd".to_vec(); + + txn.new_client(client_key, version_id)?; + txn.add_version( + client_key, + version_id, + parent_version_id, + history_segment.clone(), + )?; + + let client = txn.get_client(client_key)?.unwrap(); + assert_eq!( + get_child_version( + txn, + &ServerConfig::default(), + client_key, + client, + parent_version_id + )?, + GetVersionResult::Success { + version_id, + parent_version_id, + history_segment, + } + ); + Ok(()) + } + + /// Utility setup function for add_version tests + fn av_setup( + storage: &InMemoryStorage, + num_versions: u32, + snapshot_version: Option, + snapshot_days_ago: Option, + ) -> anyhow::Result<(Uuid, Vec)> { + init_logging(); + let mut txn = storage.txn()?; + let client_key = Uuid::new_v4(); + let mut versions = vec![]; + + let mut version_id = Uuid::nil(); + txn.new_client(client_key, Uuid::nil())?; + for vnum in 0..num_versions { + let parent_version_id = version_id; + version_id = Uuid::new_v4(); + versions.push(version_id); + txn.add_version( + client_key, + version_id, + parent_version_id, + vec![0, 0, vnum as u8], + )?; + if Some(vnum) == snapshot_version { + txn.set_snapshot( + client_key, + Snapshot { + version_id, + versions_since: 0, + timestamp: Utc::now() - Duration::days(snapshot_days_ago.unwrap_or(0)), + }, + vec![vnum as u8], + )?; + } + } + + Ok((client_key, versions)) + } + + /// Utility function to check the results of an add_version call + fn av_success_check( + storage: &InMemoryStorage, + client_key: Uuid, + existing_versions: &[Uuid], + result: (AddVersionResult, SnapshotUrgency), + expected_history: Vec, + expected_urgency: SnapshotUrgency, + ) -> anyhow::Result<()> { + if let AddVersionResult::Ok(new_version_id) = result.0 { + // check that it invented a new version ID + for v in existing_versions { + assert_ne!(&new_version_id, v); + } + + // verify that the storage was updated + let mut txn = storage.txn()?; + let client = txn.get_client(client_key)?.unwrap(); + assert_eq!(client.latest_version_id, new_version_id); + + let parent_version_id = existing_versions.last().cloned().unwrap_or_else(Uuid::nil); + let version = txn.get_version(client_key, new_version_id)?.unwrap(); + assert_eq!(version.version_id, new_version_id); + assert_eq!(version.parent_version_id, parent_version_id); + assert_eq!(version.history_segment, expected_history); + } else { + panic!("did not get Ok from add_version: {:?}", result); + } + + assert_eq!(result.1, expected_urgency); + + Ok(()) + } + + #[test] + fn add_version_conflict() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + let (client_key, versions) = av_setup(&storage, 3, None, None)?; + + let mut txn = storage.txn()?; + let client = txn.get_client(client_key)?.unwrap(); + + // try to add a child of a version other than the latest + assert_eq!( + add_version( + txn, + &ServerConfig::default(), + client_key, + client, + versions[1], + vec![3, 6, 9] + )? + .0, + AddVersionResult::ExpectedParentVersion(versions[2]) + ); + + // verify that the storage wasn't updated + txn = storage.txn()?; + assert_eq!( + txn.get_client(client_key)?.unwrap().latest_version_id, + versions[2] + ); + assert_eq!(txn.get_version_by_parent(client_key, versions[2])?, None); + + Ok(()) + } + + #[test] + fn add_version_with_existing_history() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + let (client_key, versions) = av_setup(&storage, 1, None, None)?; + + let mut txn = storage.txn()?; + let client = txn.get_client(client_key)?.unwrap(); + + let result = add_version( + txn, + &ServerConfig::default(), + client_key, + client, + versions[0], + vec![3, 6, 9], + )?; + + av_success_check( + &storage, + client_key, + &versions, + result, + vec![3, 6, 9], + // urgency=high because there are no snapshots yet + SnapshotUrgency::High, + )?; + + Ok(()) + } + + #[test] + fn add_version_with_no_history() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + let (client_key, versions) = av_setup(&storage, 0, None, None)?; + + let mut txn = storage.txn()?; + let client = txn.get_client(client_key)?.unwrap(); + + let parent_version_id = Uuid::nil(); + let result = add_version( + txn, + &ServerConfig::default(), + client_key, + client, + parent_version_id, + vec![3, 6, 9], + )?; + + av_success_check( + &storage, + client_key, + &versions, + result, + vec![3, 6, 9], + // urgency=high because there are no snapshots yet + SnapshotUrgency::High, + )?; + + Ok(()) + } + + #[test] + fn add_version_success_recent_snapshot() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + let (client_key, versions) = av_setup(&storage, 1, Some(0), None)?; + + let mut txn = storage.txn()?; + let client = txn.get_client(client_key)?.unwrap(); + + let result = add_version( + txn, + &ServerConfig::default(), + client_key, + client, + versions[0], + vec![1, 2, 3], + )?; + + av_success_check( + &storage, + client_key, + &versions, + result, + vec![1, 2, 3], + // no snapshot request since the previous version has a snapshot + SnapshotUrgency::None, + )?; + + Ok(()) + } + + #[test] + fn add_version_success_aged_snapshot() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + // one snapshot, but it was 50 days ago + let (client_key, versions) = av_setup(&storage, 1, Some(0), Some(50))?; + + let mut txn = storage.txn()?; + let client = txn.get_client(client_key)?.unwrap(); + + let result = add_version( + txn, + &ServerConfig::default(), + client_key, + client, + versions[0], + vec![1, 2, 3], + )?; + + av_success_check( + &storage, + client_key, + &versions, + result, + vec![1, 2, 3], + // urgency=high due to days since the snapshot + SnapshotUrgency::High, + )?; + + Ok(()) + } + + #[test] + fn add_version_success_snapshot_many_versions_ago() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + // one snapshot, but it was 50 versions ago + let (client_key, versions) = av_setup(&storage, 50, Some(0), None)?; + + let mut txn = storage.txn()?; + let client = txn.get_client(client_key)?.unwrap(); + + let result = add_version( + txn, + &ServerConfig { + snapshot_versions: 30, + ..ServerConfig::default() + }, + client_key, + client, + versions[49], + vec![1, 2, 3], + )?; + + av_success_check( + &storage, + client_key, + &versions, + result, + vec![1, 2, 3], + // urgency=high due to number of versions since the snapshot + SnapshotUrgency::High, + )?; + + Ok(()) + } + + #[test] + fn add_snapshot_success_latest() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_key = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + + // set up a task DB with one version in it + txn.new_client(client_key, version_id)?; + txn.add_version(client_key, version_id, NIL_VERSION_ID, vec![])?; + + // add a snapshot for that version + let client = txn.get_client(client_key)?.unwrap(); + add_snapshot( + txn, + &ServerConfig::default(), + client_key, + client, + version_id, + vec![1, 2, 3], + )?; + + // verify the snapshot + let mut txn = storage.txn()?; + let client = txn.get_client(client_key)?.unwrap(); + let snapshot = client.snapshot.unwrap(); + assert_eq!(snapshot.version_id, version_id); + assert_eq!(snapshot.versions_since, 0); + assert_eq!( + txn.get_snapshot_data(client_key, version_id).unwrap(), + Some(vec![1, 2, 3]) + ); + + Ok(()) + } + + #[test] + fn add_snapshot_success_older() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_key = Uuid::new_v4(); + let version_id_1 = Uuid::new_v4(); + let version_id_2 = Uuid::new_v4(); + + // set up a task DB with two versions in it + txn.new_client(client_key, version_id_2)?; + txn.add_version(client_key, version_id_1, NIL_VERSION_ID, vec![])?; + txn.add_version(client_key, version_id_2, version_id_1, vec![])?; + + // add a snapshot for version 1 + let client = txn.get_client(client_key)?.unwrap(); + add_snapshot( + txn, + &ServerConfig::default(), + client_key, + client, + version_id_1, + vec![1, 2, 3], + )?; + + // verify the snapshot + let mut txn = storage.txn()?; + let client = txn.get_client(client_key)?.unwrap(); + let snapshot = client.snapshot.unwrap(); + assert_eq!(snapshot.version_id, version_id_1); + assert_eq!(snapshot.versions_since, 0); + assert_eq!( + txn.get_snapshot_data(client_key, version_id_1).unwrap(), + Some(vec![1, 2, 3]) + ); + + Ok(()) + } + + #[test] + fn add_snapshot_fails_no_such() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_key = Uuid::new_v4(); + let version_id_1 = Uuid::new_v4(); + let version_id_2 = Uuid::new_v4(); + + // set up a task DB with two versions in it + txn.new_client(client_key, version_id_2)?; + txn.add_version(client_key, version_id_1, NIL_VERSION_ID, vec![])?; + txn.add_version(client_key, version_id_2, version_id_1, vec![])?; + + // add a snapshot for unknown version + let client = txn.get_client(client_key)?.unwrap(); + let version_id_unk = Uuid::new_v4(); + add_snapshot( + txn, + &ServerConfig::default(), + client_key, + client, + version_id_unk, + vec![1, 2, 3], + )?; + + // verify the snapshot does not exist + let mut txn = storage.txn()?; + let client = txn.get_client(client_key)?.unwrap(); + assert!(client.snapshot.is_none()); + + Ok(()) + } + + #[test] + fn add_snapshot_fails_too_old() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_key = Uuid::new_v4(); + let mut version_id = Uuid::new_v4(); + let mut parent_version_id = Uuid::nil(); + let mut version_ids = vec![]; + + // set up a task DB with 10 versions in it (oldest to newest) + txn.new_client(client_key, Uuid::nil())?; + for _ in 0..10 { + txn.add_version(client_key, version_id, parent_version_id, vec![])?; + version_ids.push(version_id); + parent_version_id = version_id; + version_id = Uuid::new_v4(); + } + + // add a snapshot for the earliest of those + let client = txn.get_client(client_key)?.unwrap(); + add_snapshot( + txn, + &ServerConfig::default(), + client_key, + client, + version_ids[0], + vec![1, 2, 3], + )?; + + // verify the snapshot does not exist + let mut txn = storage.txn()?; + let client = txn.get_client(client_key)?.unwrap(); + assert!(client.snapshot.is_none()); + + Ok(()) + } + + #[test] + fn add_snapshot_fails_newer_exists() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_key = Uuid::new_v4(); + let mut version_id = Uuid::new_v4(); + let mut parent_version_id = Uuid::nil(); + let mut version_ids = vec![]; + + // set up a task DB with 5 versions in it (oldest to newest) and a snapshot of the middle + // one + txn.new_client(client_key, Uuid::nil())?; + for _ in 0..5 { + txn.add_version(client_key, version_id, parent_version_id, vec![])?; + version_ids.push(version_id); + parent_version_id = version_id; + version_id = Uuid::new_v4(); + } + txn.set_snapshot( + client_key, + Snapshot { + version_id: version_ids[2], + versions_since: 2, + timestamp: Utc.ymd(2001, 9, 9).and_hms(1, 46, 40), + }, + vec![1, 2, 3], + )?; + + // add a snapshot for the earliest of those + let client = txn.get_client(client_key)?.unwrap(); + add_snapshot( + txn, + &ServerConfig::default(), + client_key, + client, + version_ids[0], + vec![9, 9, 9], + )?; + + // verify the snapshot was not replaced + let mut txn = storage.txn()?; + let client = txn.get_client(client_key)?.unwrap(); + let snapshot = client.snapshot.unwrap(); + assert_eq!(snapshot.version_id, version_ids[2]); + assert_eq!(snapshot.versions_since, 2); + assert_eq!( + txn.get_snapshot_data(client_key, version_ids[2]).unwrap(), + Some(vec![1, 2, 3]) + ); + + Ok(()) + } + + #[test] + fn add_snapshot_fails_nil_version() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_key = Uuid::new_v4(); + + // just set up the client + txn.new_client(client_key, NIL_VERSION_ID)?; + + // add a snapshot for the nil version + let client = txn.get_client(client_key)?.unwrap(); + add_snapshot( + txn, + &ServerConfig::default(), + client_key, + client, + NIL_VERSION_ID, + vec![9, 9, 9], + )?; + + // verify the snapshot does not exist + let mut txn = storage.txn()?; + let client = txn.get_client(client_key)?.unwrap(); + assert!(client.snapshot.is_none()); + + Ok(()) + } + + #[test] + fn get_snapshot_found() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_key = Uuid::new_v4(); + let data = vec![1, 2, 3]; + let snapshot_version_id = Uuid::new_v4(); + + txn.new_client(client_key, snapshot_version_id)?; + txn.set_snapshot( + client_key, + Snapshot { + version_id: snapshot_version_id, + versions_since: 3, + timestamp: Utc.ymd(2001, 9, 9).and_hms(1, 46, 40), + }, + data.clone(), + )?; + + let client = txn.get_client(client_key)?.unwrap(); + assert_eq!( + get_snapshot(txn, &ServerConfig::default(), client_key, client)?, + Some((snapshot_version_id, data.clone())) + ); + + Ok(()) + } + + #[test] + fn get_snapshot_not_found() -> anyhow::Result<()> { + init_logging(); + + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let client_key = Uuid::new_v4(); + + txn.new_client(client_key, NIL_VERSION_ID)?; + let client = txn.get_client(client_key)?.unwrap(); + + assert_eq!( + get_snapshot(txn, &ServerConfig::default(), client_key, client)?, + None + ); + + Ok(()) + } +} diff --git a/rust/sync-server/src/storage/inmemory.rs b/rust/sync-server/src/storage/inmemory.rs new file mode 100644 index 000000000..03b36c47a --- /dev/null +++ b/rust/sync-server/src/storage/inmemory.rs @@ -0,0 +1,287 @@ +use super::{Client, Snapshot, Storage, StorageTxn, Uuid, Version}; +use std::collections::HashMap; +use std::sync::{Mutex, MutexGuard}; + +struct Inner { + /// Clients, indexed by client_key + clients: HashMap, + + /// Snapshot data, indexed by client key + snapshots: HashMap>, + + /// Versions, indexed by (client_key, version_id) + versions: HashMap<(Uuid, Uuid), Version>, + + /// Child versions, indexed by (client_key, parent_version_id) + children: HashMap<(Uuid, Uuid), Uuid>, +} + +pub struct InMemoryStorage(Mutex); + +impl InMemoryStorage { + #[allow(clippy::new_without_default)] + pub fn new() -> Self { + Self(Mutex::new(Inner { + clients: HashMap::new(), + snapshots: HashMap::new(), + versions: HashMap::new(), + children: HashMap::new(), + })) + } +} + +struct InnerTxn<'a>(MutexGuard<'a, Inner>); + +/// In-memory storage for testing and experimentation. +/// +/// NOTE: this does not implement transaction rollback. +impl Storage for InMemoryStorage { + fn txn<'a>(&'a self) -> anyhow::Result> { + Ok(Box::new(InnerTxn(self.0.lock().expect("poisoned lock")))) + } +} + +impl<'a> StorageTxn for InnerTxn<'a> { + fn get_client(&mut self, client_key: Uuid) -> anyhow::Result> { + Ok(self.0.clients.get(&client_key).cloned()) + } + + fn new_client(&mut self, client_key: Uuid, latest_version_id: Uuid) -> anyhow::Result<()> { + if self.0.clients.get(&client_key).is_some() { + return Err(anyhow::anyhow!("Client {} already exists", client_key)); + } + self.0.clients.insert( + client_key, + Client { + latest_version_id, + snapshot: None, + }, + ); + Ok(()) + } + + fn set_snapshot( + &mut self, + client_key: Uuid, + snapshot: Snapshot, + data: Vec, + ) -> anyhow::Result<()> { + let mut client = self + .0 + .clients + .get_mut(&client_key) + .ok_or_else(|| anyhow::anyhow!("no such client"))?; + client.snapshot = Some(snapshot); + self.0.snapshots.insert(client_key, data); + Ok(()) + } + + fn get_snapshot_data( + &mut self, + client_key: Uuid, + version_id: Uuid, + ) -> anyhow::Result>> { + // sanity check + let client = self.0.clients.get(&client_key); + let client = client.ok_or_else(|| anyhow::anyhow!("no such client"))?; + if Some(&version_id) != client.snapshot.as_ref().map(|snap| &snap.version_id) { + return Err(anyhow::anyhow!("unexpected snapshot_version_id")); + } + Ok(self.0.snapshots.get(&client_key).cloned()) + } + + fn get_version_by_parent( + &mut self, + client_key: Uuid, + parent_version_id: Uuid, + ) -> anyhow::Result> { + if let Some(parent_version_id) = self.0.children.get(&(client_key, parent_version_id)) { + Ok(self + .0 + .versions + .get(&(client_key, *parent_version_id)) + .cloned()) + } else { + Ok(None) + } + } + + fn get_version( + &mut self, + client_key: Uuid, + version_id: Uuid, + ) -> anyhow::Result> { + Ok(self.0.versions.get(&(client_key, version_id)).cloned()) + } + + fn add_version( + &mut self, + client_key: Uuid, + version_id: Uuid, + parent_version_id: Uuid, + history_segment: Vec, + ) -> anyhow::Result<()> { + // TODO: verify it doesn't exist (`.entry`?) + let version = Version { + version_id, + parent_version_id, + history_segment, + }; + + if let Some(client) = self.0.clients.get_mut(&client_key) { + client.latest_version_id = version_id; + if let Some(ref mut snap) = client.snapshot { + snap.versions_since += 1; + } + } else { + return Err(anyhow::anyhow!("Client {} does not exist", client_key)); + } + + self.0 + .children + .insert((client_key, parent_version_id), version_id); + self.0.versions.insert((client_key, version_id), version); + + Ok(()) + } + + fn commit(&mut self) -> anyhow::Result<()> { + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use chrono::Utc; + + #[test] + fn test_get_client_empty() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let maybe_client = txn.get_client(Uuid::new_v4())?; + assert!(maybe_client.is_none()); + Ok(()) + } + + #[test] + fn test_client_storage() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + + let client_key = Uuid::new_v4(); + let latest_version_id = Uuid::new_v4(); + txn.new_client(client_key, latest_version_id)?; + + let client = txn.get_client(client_key)?.unwrap(); + assert_eq!(client.latest_version_id, latest_version_id); + assert!(client.snapshot.is_none()); + + let latest_version_id = Uuid::new_v4(); + txn.add_version(client_key, latest_version_id, Uuid::new_v4(), vec![1, 1])?; + + let client = txn.get_client(client_key)?.unwrap(); + assert_eq!(client.latest_version_id, latest_version_id); + assert!(client.snapshot.is_none()); + + let snap = Snapshot { + version_id: Uuid::new_v4(), + timestamp: Utc::now(), + versions_since: 4, + }; + txn.set_snapshot(client_key, snap.clone(), vec![1, 2, 3])?; + + let client = txn.get_client(client_key)?.unwrap(); + assert_eq!(client.latest_version_id, latest_version_id); + assert_eq!(client.snapshot.unwrap(), snap); + + Ok(()) + } + + #[test] + fn test_gvbp_empty() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + let maybe_version = txn.get_version_by_parent(Uuid::new_v4(), Uuid::new_v4())?; + assert!(maybe_version.is_none()); + Ok(()) + } + + #[test] + fn test_add_version_and_get_version() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + + let client_key = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let parent_version_id = Uuid::new_v4(); + let history_segment = b"abc".to_vec(); + + txn.new_client(client_key, parent_version_id)?; + txn.add_version( + client_key, + version_id, + parent_version_id, + history_segment.clone(), + )?; + + let expected = Version { + version_id, + parent_version_id, + history_segment, + }; + + let version = txn + .get_version_by_parent(client_key, parent_version_id)? + .unwrap(); + assert_eq!(version, expected); + + let version = txn.get_version(client_key, version_id)?.unwrap(); + assert_eq!(version, expected); + + Ok(()) + } + + #[test] + fn test_snapshots() -> anyhow::Result<()> { + let storage = InMemoryStorage::new(); + let mut txn = storage.txn()?; + + let client_key = Uuid::new_v4(); + + txn.new_client(client_key, Uuid::new_v4())?; + assert!(txn.get_client(client_key)?.unwrap().snapshot.is_none()); + + let snap = Snapshot { + version_id: Uuid::new_v4(), + timestamp: Utc::now(), + versions_since: 3, + }; + txn.set_snapshot(client_key, snap.clone(), vec![9, 8, 9])?; + + assert_eq!( + txn.get_snapshot_data(client_key, snap.version_id)?.unwrap(), + vec![9, 8, 9] + ); + assert_eq!(txn.get_client(client_key)?.unwrap().snapshot, Some(snap)); + + let snap2 = Snapshot { + version_id: Uuid::new_v4(), + timestamp: Utc::now(), + versions_since: 10, + }; + txn.set_snapshot(client_key, snap2.clone(), vec![0, 2, 4, 6])?; + + assert_eq!( + txn.get_snapshot_data(client_key, snap2.version_id)? + .unwrap(), + vec![0, 2, 4, 6] + ); + assert_eq!(txn.get_client(client_key)?.unwrap().snapshot, Some(snap2)); + + // check that mismatched version is detected + assert!(txn.get_snapshot_data(client_key, Uuid::new_v4()).is_err()); + + Ok(()) + } +} diff --git a/rust/sync-server/src/storage/mod.rs b/rust/sync-server/src/storage/mod.rs new file mode 100644 index 000000000..c52624898 --- /dev/null +++ b/rust/sync-server/src/storage/mod.rs @@ -0,0 +1,98 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +#[cfg(debug_assertions)] +mod inmemory; + +#[cfg(debug_assertions)] +pub use inmemory::InMemoryStorage; + +mod sqlite; +pub use self::sqlite::SqliteStorage; + +#[derive(Clone, PartialEq, Debug)] +pub struct Client { + /// The latest version for this client (may be the nil version) + pub latest_version_id: Uuid, + /// Data about the latest snapshot for this client + pub snapshot: Option, +} + +#[derive(Clone, PartialEq, Debug)] +pub struct Snapshot { + /// ID of the version at which this snapshot was made + pub version_id: Uuid, + + /// Timestamp at which this snapshot was set + pub timestamp: DateTime, + + /// Number of versions since this snapshot was made + pub versions_since: u32, +} + +#[derive(Clone, PartialEq, Debug)] +pub struct Version { + pub version_id: Uuid, + pub parent_version_id: Uuid, + pub history_segment: Vec, +} + +pub trait StorageTxn { + /// Get information about the given client + fn get_client(&mut self, client_key: Uuid) -> anyhow::Result>; + + /// Create a new client with the given latest_version_id + fn new_client(&mut self, client_key: Uuid, latest_version_id: Uuid) -> anyhow::Result<()>; + + /// Set the client's most recent snapshot. + fn set_snapshot( + &mut self, + client_key: Uuid, + snapshot: Snapshot, + data: Vec, + ) -> anyhow::Result<()>; + + /// Get the data for the most recent snapshot. The version_id + /// is used to verify that the snapshot is for the correct version. + fn get_snapshot_data( + &mut self, + client_key: Uuid, + version_id: Uuid, + ) -> anyhow::Result>>; + + /// Get a version, indexed by parent version id + fn get_version_by_parent( + &mut self, + client_key: Uuid, + parent_version_id: Uuid, + ) -> anyhow::Result>; + + /// Get a version, indexed by its own version id + fn get_version( + &mut self, + client_key: Uuid, + version_id: Uuid, + ) -> anyhow::Result>; + + /// Add a version (that must not already exist), and + /// - update latest_version_id + /// - increment snapshot.versions_since + fn add_version( + &mut self, + client_key: Uuid, + version_id: Uuid, + parent_version_id: Uuid, + history_segment: Vec, + ) -> anyhow::Result<()>; + + /// Commit any changes made in the transaction. It is an error to call this more than + /// once. It is safe to skip this call for read-only operations. + fn commit(&mut self) -> anyhow::Result<()>; +} + +/// A trait for objects able to act as storage. Most of the interesting behavior is in the +/// [`crate::storage::StorageTxn`] trait. +pub trait Storage: Send + Sync { + /// Begin a transaction + fn txn<'a>(&'a self) -> anyhow::Result>; +} diff --git a/rust/sync-server/src/storage/sqlite.rs b/rust/sync-server/src/storage/sqlite.rs new file mode 100644 index 000000000..47a07014a --- /dev/null +++ b/rust/sync-server/src/storage/sqlite.rs @@ -0,0 +1,452 @@ +use super::{Client, Snapshot, Storage, StorageTxn, Uuid, Version}; +use anyhow::Context; +use chrono::{TimeZone, Utc}; +use rusqlite::types::{FromSql, ToSql}; +use rusqlite::{params, Connection, OptionalExtension}; +use std::path::Path; + +#[derive(Debug, thiserror::Error)] +enum SqliteError { + #[error("Failed to create SQLite transaction")] + CreateTransactionFailed, +} + +/// Newtype to allow implementing `FromSql` for foreign `uuid::Uuid` +struct StoredUuid(Uuid); + +/// Conversion from Uuid stored as a string (rusqlite's uuid feature stores as binary blob) +impl FromSql for StoredUuid { + fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult { + let u = Uuid::parse_str(value.as_str()?) + .map_err(|_| rusqlite::types::FromSqlError::InvalidType)?; + Ok(StoredUuid(u)) + } +} + +/// Store Uuid as string in database +impl ToSql for StoredUuid { + fn to_sql(&self) -> rusqlite::Result> { + let s = self.0.to_string(); + Ok(s.into()) + } +} + +/// An on-disk storage backend which uses SQLite +pub struct SqliteStorage { + db_file: std::path::PathBuf, +} + +impl SqliteStorage { + fn new_connection(&self) -> anyhow::Result { + Ok(Connection::open(&self.db_file)?) + } + + pub fn new>(directory: P) -> anyhow::Result { + std::fs::create_dir_all(&directory)?; + let db_file = directory.as_ref().join("taskchampion-sync-server.sqlite3"); + + let o = SqliteStorage { db_file }; + + { + let mut con = o.new_connection()?; + let txn = con.transaction()?; + + let queries = vec![ + "CREATE TABLE IF NOT EXISTS clients ( + client_key STRING PRIMARY KEY, + latest_version_id STRING, + snapshot_version_id STRING, + versions_since_snapshot INTEGER, + snapshot_timestamp INTEGER, + snapshot BLOB);", + "CREATE TABLE IF NOT EXISTS versions (version_id STRING PRIMARY KEY, client_key STRING, parent_version_id STRING, history_segment BLOB);", + "CREATE INDEX IF NOT EXISTS versions_by_parent ON versions (parent_version_id);", + ]; + for q in queries { + txn.execute(q, []) + .context("Error while creating SQLite tables")?; + } + txn.commit()?; + } + + Ok(o) + } +} + +impl Storage for SqliteStorage { + fn txn<'a>(&'a self) -> anyhow::Result> { + let con = self.new_connection()?; + let t = Txn { con }; + Ok(Box::new(t)) + } +} + +struct Txn { + con: Connection, +} + +impl Txn { + fn get_txn(&mut self) -> Result { + self.con + .transaction() + .map_err(|_e| SqliteError::CreateTransactionFailed) + } + + /// Implementation for queries from the versions table + fn get_version_impl( + &mut self, + query: &'static str, + client_key: Uuid, + version_id_arg: Uuid, + ) -> anyhow::Result> { + let t = self.get_txn()?; + let r = t + .query_row( + query, + params![&StoredUuid(version_id_arg), &StoredUuid(client_key)], + |r| { + let version_id: StoredUuid = r.get("version_id")?; + let parent_version_id: StoredUuid = r.get("parent_version_id")?; + + Ok(Version { + version_id: version_id.0, + parent_version_id: parent_version_id.0, + history_segment: r.get("history_segment")?, + }) + }, + ) + .optional() + .context("Error getting version")?; + Ok(r) + } +} + +impl StorageTxn for Txn { + fn get_client(&mut self, client_key: Uuid) -> anyhow::Result> { + let t = self.get_txn()?; + let result: Option = t + .query_row( + "SELECT + latest_version_id, + snapshot_timestamp, + versions_since_snapshot, + snapshot_version_id + FROM clients + WHERE client_key = ? + LIMIT 1", + [&StoredUuid(client_key)], + |r| { + let latest_version_id: StoredUuid = r.get(0)?; + let snapshot_timestamp: Option = r.get(1)?; + let versions_since_snapshot: Option = r.get(2)?; + let snapshot_version_id: Option = r.get(3)?; + + // if all of the relevant fields are non-NULL, return a snapshot + let snapshot = match ( + snapshot_timestamp, + versions_since_snapshot, + snapshot_version_id, + ) { + (Some(ts), Some(vs), Some(v)) => Some(Snapshot { + version_id: v.0, + timestamp: Utc.timestamp(ts, 0), + versions_since: vs, + }), + _ => None, + }; + Ok(Client { + latest_version_id: latest_version_id.0, + snapshot, + }) + }, + ) + .optional() + .context("Error getting client")?; + + Ok(result) + } + + fn new_client(&mut self, client_key: Uuid, latest_version_id: Uuid) -> anyhow::Result<()> { + let t = self.get_txn()?; + + t.execute( + "INSERT OR REPLACE INTO clients (client_key, latest_version_id) VALUES (?, ?)", + params![&StoredUuid(client_key), &StoredUuid(latest_version_id)], + ) + .context("Error creating/updating client")?; + t.commit()?; + Ok(()) + } + + fn set_snapshot( + &mut self, + client_key: Uuid, + snapshot: Snapshot, + data: Vec, + ) -> anyhow::Result<()> { + let t = self.get_txn()?; + + t.execute( + "UPDATE clients + SET + snapshot_version_id = ?, + snapshot_timestamp = ?, + versions_since_snapshot = ?, + snapshot = ? + WHERE client_key = ?", + params![ + &StoredUuid(snapshot.version_id), + snapshot.timestamp.timestamp(), + snapshot.versions_since, + data, + &StoredUuid(client_key), + ], + ) + .context("Error creating/updating snapshot")?; + t.commit()?; + Ok(()) + } + + fn get_snapshot_data( + &mut self, + client_key: Uuid, + version_id: Uuid, + ) -> anyhow::Result>> { + let t = self.get_txn()?; + let r = t + .query_row( + "SELECT snapshot, snapshot_version_id FROM clients WHERE client_key = ?", + params![&StoredUuid(client_key)], + |r| { + let v: StoredUuid = r.get("snapshot_version_id")?; + let d: Vec = r.get("snapshot")?; + Ok((v.0, d)) + }, + ) + .optional() + .context("Error getting snapshot")?; + r.map(|(v, d)| { + if v != version_id { + return Err(anyhow::anyhow!("unexpected snapshot_version_id")); + } + + Ok(d) + }) + .transpose() + } + + fn get_version_by_parent( + &mut self, + client_key: Uuid, + parent_version_id: Uuid, + ) -> anyhow::Result> { + self.get_version_impl( + "SELECT version_id, parent_version_id, history_segment FROM versions WHERE parent_version_id = ? AND client_key = ?", + client_key, + parent_version_id) + } + + fn get_version( + &mut self, + client_key: Uuid, + version_id: Uuid, + ) -> anyhow::Result> { + self.get_version_impl( + "SELECT version_id, parent_version_id, history_segment FROM versions WHERE version_id = ? AND client_key = ?", + client_key, + version_id) + } + + fn add_version( + &mut self, + client_key: Uuid, + version_id: Uuid, + parent_version_id: Uuid, + history_segment: Vec, + ) -> anyhow::Result<()> { + let t = self.get_txn()?; + + t.execute( + "INSERT INTO versions (version_id, client_key, parent_version_id, history_segment) VALUES(?, ?, ?, ?)", + params![ + StoredUuid(version_id), + StoredUuid(client_key), + StoredUuid(parent_version_id), + history_segment + ] + ) + .context("Error adding version")?; + t.execute( + "UPDATE clients + SET + latest_version_id = ?, + versions_since_snapshot = versions_since_snapshot + 1 + WHERE client_key = ?", + params![StoredUuid(version_id), StoredUuid(client_key),], + ) + .context("Error updating client for new version")?; + + t.commit()?; + Ok(()) + } + + fn commit(&mut self) -> anyhow::Result<()> { + // FIXME: Note the queries aren't currently run in a + // transaction, as storing the transaction object and a pooled + // connection in the `Txn` object is complex. + // https://github.com/taskchampion/taskchampion/pull/206#issuecomment-860336073 + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use chrono::DateTime; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn test_emtpy_dir() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let non_existant = tmp_dir.path().join("subdir"); + let storage = SqliteStorage::new(&non_existant)?; + let mut txn = storage.txn()?; + let maybe_client = txn.get_client(Uuid::new_v4())?; + assert!(maybe_client.is_none()); + Ok(()) + } + + #[test] + fn test_get_client_empty() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let storage = SqliteStorage::new(&tmp_dir.path())?; + let mut txn = storage.txn()?; + let maybe_client = txn.get_client(Uuid::new_v4())?; + assert!(maybe_client.is_none()); + Ok(()) + } + + #[test] + fn test_client_storage() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let storage = SqliteStorage::new(&tmp_dir.path())?; + let mut txn = storage.txn()?; + + let client_key = Uuid::new_v4(); + let latest_version_id = Uuid::new_v4(); + txn.new_client(client_key, latest_version_id)?; + + let client = txn.get_client(client_key)?.unwrap(); + assert_eq!(client.latest_version_id, latest_version_id); + assert!(client.snapshot.is_none()); + + let latest_version_id = Uuid::new_v4(); + txn.add_version(client_key, latest_version_id, Uuid::new_v4(), vec![1, 1])?; + + let client = txn.get_client(client_key)?.unwrap(); + assert_eq!(client.latest_version_id, latest_version_id); + assert!(client.snapshot.is_none()); + + let snap = Snapshot { + version_id: Uuid::new_v4(), + timestamp: "2014-11-28T12:00:09Z".parse::>().unwrap(), + versions_since: 4, + }; + txn.set_snapshot(client_key, snap.clone(), vec![1, 2, 3])?; + + let client = txn.get_client(client_key)?.unwrap(); + assert_eq!(client.latest_version_id, latest_version_id); + assert_eq!(client.snapshot.unwrap(), snap); + + Ok(()) + } + + #[test] + fn test_gvbp_empty() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let storage = SqliteStorage::new(&tmp_dir.path())?; + let mut txn = storage.txn()?; + let maybe_version = txn.get_version_by_parent(Uuid::new_v4(), Uuid::new_v4())?; + assert!(maybe_version.is_none()); + Ok(()) + } + + #[test] + fn test_add_version_and_get_version() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let storage = SqliteStorage::new(&tmp_dir.path())?; + let mut txn = storage.txn()?; + + let client_key = Uuid::new_v4(); + let version_id = Uuid::new_v4(); + let parent_version_id = Uuid::new_v4(); + let history_segment = b"abc".to_vec(); + txn.add_version( + client_key, + version_id, + parent_version_id, + history_segment.clone(), + )?; + + let expected = Version { + version_id, + parent_version_id, + history_segment, + }; + + let version = txn + .get_version_by_parent(client_key, parent_version_id)? + .unwrap(); + assert_eq!(version, expected); + + let version = txn.get_version(client_key, version_id)?.unwrap(); + assert_eq!(version, expected); + + Ok(()) + } + + #[test] + fn test_snapshots() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let storage = SqliteStorage::new(&tmp_dir.path())?; + let mut txn = storage.txn()?; + + let client_key = Uuid::new_v4(); + + txn.new_client(client_key, Uuid::new_v4())?; + assert!(txn.get_client(client_key)?.unwrap().snapshot.is_none()); + + let snap = Snapshot { + version_id: Uuid::new_v4(), + timestamp: "2013-10-08T12:00:09Z".parse::>().unwrap(), + versions_since: 3, + }; + txn.set_snapshot(client_key, snap.clone(), vec![9, 8, 9])?; + + assert_eq!( + txn.get_snapshot_data(client_key, snap.version_id)?.unwrap(), + vec![9, 8, 9] + ); + assert_eq!(txn.get_client(client_key)?.unwrap().snapshot, Some(snap)); + + let snap2 = Snapshot { + version_id: Uuid::new_v4(), + timestamp: "2014-11-28T12:00:09Z".parse::>().unwrap(), + versions_since: 10, + }; + txn.set_snapshot(client_key, snap2.clone(), vec![0, 2, 4, 6])?; + + assert_eq!( + txn.get_snapshot_data(client_key, snap2.version_id)? + .unwrap(), + vec![0, 2, 4, 6] + ); + assert_eq!(txn.get_client(client_key)?.unwrap().snapshot, Some(snap2)); + + // check that mismatched version is detected + assert!(txn.get_snapshot_data(client_key, Uuid::new_v4()).is_err()); + + Ok(()) + } +} diff --git a/rust/taskchampion/Cargo.toml b/rust/taskchampion/Cargo.toml new file mode 100644 index 000000000..18caeaadf --- /dev/null +++ b/rust/taskchampion/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "taskchampion" +version = "0.4.1" +authors = ["Dustin J. Mitchell "] +description = "Personal task-tracking" +homepage = "https://taskchampion.github.io/taskchampion/" +documentation = "https://docs.rs/crate/taskchampion" +repository = "https://github.com/taskchampion/taskchampion" +readme = "../README.md" +license = "MIT" +edition = "2018" + +[dependencies] +uuid = { version = "^0.8.2", features = ["serde", "v4"] } +serde = { version = "^1.0.125", features = ["derive"] } +serde_json = "^1.0" +chrono = { version = "^0.4.10", features = ["serde"] } +anyhow = "1.0" +thiserror = "1.0" +ureq = "^2.1.0" +log = "^0.4.14" +rusqlite = { version = "0.25", features = ["bundled"] } +strum = "0.21" +strum_macros = "0.21" +flate2 = "1" +byteorder = "1.0" +ring = "0.16" + +[dev-dependencies] +proptest = "^1.0.0" +tempfile = "3" +rstest = "0.10" +pretty_assertions = "1" diff --git a/rust/taskchampion/src/depmap.rs b/rust/taskchampion/src/depmap.rs new file mode 100644 index 000000000..d2f6225bf --- /dev/null +++ b/rust/taskchampion/src/depmap.rs @@ -0,0 +1,81 @@ +use uuid::Uuid; + +/// DependencyMap stores information on task dependencies between pending tasks. +/// +/// This information requires a scan of the working set to generate, so it is +/// typically calculated once and re-used. +#[derive(Debug, PartialEq)] +pub struct DependencyMap { + /// Edges of the dependency graph. If (a, b) is in this array, then task a depends on tsak b. + edges: Vec<(Uuid, Uuid)>, +} + +impl DependencyMap { + /// Create a new, empty DependencyMap. + pub(super) fn new() -> Self { + Self { edges: Vec::new() } + } + + /// Add a dependency of a on b. + pub(super) fn add_dependency(&mut self, a: Uuid, b: Uuid) { + self.edges.push((a, b)); + } + + /// Return an iterator of Uuids on which task `deps_of` depends. This is equivalent to + /// `task.get_dependencies()`. + pub fn dependencies(&self, dep_of: Uuid) -> impl Iterator + '_ { + self.edges + .iter() + .filter_map(move |(a, b)| if a == &dep_of { Some(*b) } else { None }) + } + + /// Return an iterator of Uuids of tasks that depend on `dep_on` + /// `task.get_dependencies()`. + pub fn dependents(&self, dep_on: Uuid) -> impl Iterator + '_ { + self.edges + .iter() + .filter_map(move |(a, b)| if b == &dep_on { Some(*a) } else { None }) + } +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + use std::collections::HashSet; + + #[test] + fn dependencies() { + let t = Uuid::new_v4(); + let uuid1 = Uuid::new_v4(); + let uuid2 = Uuid::new_v4(); + let mut dm = DependencyMap::new(); + + dm.add_dependency(t, uuid1); + dm.add_dependency(t, uuid2); + dm.add_dependency(Uuid::new_v4(), t); + dm.add_dependency(Uuid::new_v4(), uuid1); + dm.add_dependency(uuid2, Uuid::new_v4()); + + assert_eq!( + dm.dependencies(t).collect::>(), + set![uuid1, uuid2] + ); + } + + #[test] + fn dependents() { + let t = Uuid::new_v4(); + let uuid1 = Uuid::new_v4(); + let uuid2 = Uuid::new_v4(); + let mut dm = DependencyMap::new(); + + dm.add_dependency(uuid1, t); + dm.add_dependency(uuid2, t); + dm.add_dependency(t, Uuid::new_v4()); + dm.add_dependency(Uuid::new_v4(), uuid1); + dm.add_dependency(uuid2, Uuid::new_v4()); + + assert_eq!(dm.dependents(t).collect::>(), set![uuid1, uuid2]); + } +} diff --git a/rust/taskchampion/src/errors.rs b/rust/taskchampion/src/errors.rs new file mode 100644 index 000000000..3209b6ea9 --- /dev/null +++ b/rust/taskchampion/src/errors.rs @@ -0,0 +1,15 @@ +use thiserror::Error; + +#[derive(Debug, Error, Eq, PartialEq, Clone)] +#[non_exhaustive] +/// Errors returned from taskchampion operations +pub enum Error { + /// A task-database-related error + #[error("Task Database Error: {0}")] + Database(String), + /// An error specifically indicating that the local replica cannot + /// be synchronized with the sever, due to being out of date or some + /// other irrecoverable error. + #[error("Local replica is out of sync with the server")] + OutOfSync, +} diff --git a/rust/taskchampion/src/lib.rs b/rust/taskchampion/src/lib.rs new file mode 100644 index 000000000..63e5c20ea --- /dev/null +++ b/rust/taskchampion/src/lib.rs @@ -0,0 +1,73 @@ +#![deny(clippy::all)] +/*! + +This crate implements the core of TaskChampion, the [replica](crate::Replica). + +Users of this crate can manipulate a task database using this API, including synchronizing that task database with others via a synchronization server. + +Example uses of this crate: + * user interfaces for task management, such as mobile apps, web apps, or command-line interfaces + * integrations for task management, such as synchronization with ticket-tracking systems or + request forms. + +# Replica + +A TaskChampion replica is a local copy of a user's task data. As the name suggests, several +replicas of the same data can exist (such as on a user's laptop and on their phone) and can +synchronize with one another. + +Replicas are accessed using the [`Replica`](crate::Replica) type. + +# Task Storage + +Replicas access the task database via a [storage object](crate::storage::Storage). +Create a storage object with [`StorageConfig`](crate::storage::StorageConfig). + +The [`storage`](crate::storage) module supports pluggable storage for a replica's data. +An implementation is provided, but users of this crate can provide their own implementation as well. + +# Server + +Replica synchronization takes place against a server. +Create a server with [`ServerConfig`](crate::ServerConfig). + +The [`server`](crate::server) module defines the interface a server must meet. +Users can define their own server impelementations. + +# See Also + +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. + + */ + +// NOTE: it's important that this 'mod' comes first so that the macros can be used in other modules +mod macros; + +mod depmap; +mod errors; +mod replica; +pub mod server; +pub mod storage; +mod task; +mod taskdb; +mod utils; +mod workingset; + +pub use depmap::DependencyMap; +pub use errors::Error; +pub use replica::Replica; +pub use server::{Server, ServerConfig}; +pub use storage::StorageConfig; +pub use task::{Annotation, Status, Tag, Task, TaskMut}; +pub use workingset::WorkingSet; + +/// Re-exported type from the `uuid` crate, for ease of compatibility for consumers of this crate. +pub use uuid::Uuid; + +/// Re-exported chrono module. +pub use chrono; diff --git a/rust/taskchampion/src/macros.rs b/rust/taskchampion/src/macros.rs new file mode 100644 index 000000000..eb34b4640 --- /dev/null +++ b/rust/taskchampion/src/macros.rs @@ -0,0 +1,17 @@ +#![macro_use] + +/// Create a hashset, similar to vec! +// NOTE: in Rust 1.56.0, this can be changed to HashSet::from([..]) +#[cfg(test)] +macro_rules! set( + { $($key:expr),* $(,)? } => { + { + #[allow(unused_mut)] + let mut s = ::std::collections::HashSet::new(); + $( + s.insert($key); + )* + s + } + }; +); diff --git a/rust/taskchampion/src/replica.rs b/rust/taskchampion/src/replica.rs new file mode 100644 index 000000000..4bbbc9c32 --- /dev/null +++ b/rust/taskchampion/src/replica.rs @@ -0,0 +1,565 @@ +use crate::depmap::DependencyMap; +use crate::server::{Server, SyncOp}; +use crate::storage::{Storage, TaskMap}; +use crate::task::{Status, Task}; +use crate::taskdb::TaskDb; +use crate::workingset::WorkingSet; +use anyhow::Context; +use chrono::{Duration, Utc}; +use log::trace; +use std::collections::HashMap; +use std::rc::Rc; +use uuid::Uuid; + +/// A replica represents an instance of a user's task data, providing an easy interface +/// for querying and modifying that data. +/// +/// ## Tasks +/// +/// Tasks are uniquely identified by UUIDs. +/// Most task modifications are performed via the [`Task`](crate::Task) and +/// [`TaskMut`](crate::TaskMut) types. Use of two types for tasks allows easy +/// read-only manipulation of lots of tasks, with exclusive access required only +/// for modifications. +/// +/// ## Working Set +/// +/// A replica maintains a "working set" of tasks that are of current concern to the user, +/// specifically pending tasks. These are indexed with small, easy-to-type integers. Newly +/// pending tasks are automatically added to the working set, and the working set is "renumbered" +/// during the garbage-collection process. +pub struct Replica { + taskdb: TaskDb, + + /// If true, this replica has already added an undo point. + added_undo_point: bool, + + /// The dependency map for this replica, if it has been calculated. + depmap: Option>, +} + +impl Replica { + pub fn new(storage: Box) -> Replica { + Replica { + taskdb: TaskDb::new(storage), + added_undo_point: false, + depmap: None, + } + } + + #[cfg(test)] + pub fn new_inmemory() -> Replica { + Replica::new(Box::new(crate::storage::InMemoryStorage::new())) + } + + /// Update an existing task. If the value is Some, the property is added or updated. If the + /// value is None, the property is deleted. It is not an error to delete a nonexistent + /// property. + /// + /// This is a low-level method, and requires knowledge of the Task data model. Prefer to + /// use the [`TaskMut`] methods to modify tasks, where possible. + pub fn update_task( + &mut self, + uuid: Uuid, + property: S1, + value: Option, + ) -> anyhow::Result + where + S1: Into, + S2: Into, + { + self.add_undo_point(false)?; + self.taskdb.apply(SyncOp::Update { + uuid, + property: property.into(), + value: value.map(|v| v.into()), + timestamp: Utc::now(), + }) + } + + /// Add the given uuid to the working set, returning its index. + pub(crate) fn add_to_working_set(&mut self, uuid: Uuid) -> anyhow::Result { + self.taskdb.add_to_working_set(uuid) + } + + /// Get all tasks represented as a map keyed by UUID + pub fn all_tasks(&mut self) -> anyhow::Result> { + let depmap = self.dependency_map(false)?; + let mut res = HashMap::new(); + for (uuid, tm) in self.taskdb.all_tasks()?.drain(..) { + res.insert(uuid, Task::new(uuid, tm, depmap.clone())); + } + Ok(res) + } + + /// Get the UUIDs of all tasks + pub fn all_task_uuids(&mut self) -> anyhow::Result> { + self.taskdb.all_task_uuids() + } + + /// Get the "working set" for this replica. This is a snapshot of the current state, + /// and it is up to the caller to decide how long to store this value. + pub fn working_set(&mut self) -> anyhow::Result { + Ok(WorkingSet::new(self.taskdb.working_set()?)) + } + + /// Get the dependency map for all pending tasks. + /// + /// The data in this map is cached when it is first requested and may not contain modifications + /// made locally in this Replica instance. The result is reference-counted and may + /// outlive the Replica. + /// + /// If `force` is true, then the result is re-calculated from the current state of the replica, + /// although previously-returned dependency maps are not updated. + pub fn dependency_map(&mut self, force: bool) -> anyhow::Result> { + if force || self.depmap.is_none() { + let mut dm = DependencyMap::new(); + let ws = self.working_set()?; + for i in 1..=ws.largest_index() { + if let Some(u) = ws.by_index(i) { + // note: we can't use self.get_task here, as that depends on a + // DependencyMap + if let Some(taskmap) = self.taskdb.get_task(u)? { + for p in taskmap.keys() { + if let Some(dep_str) = p.strip_prefix("dep_") { + if let Ok(dep) = Uuid::parse_str(dep_str) { + dm.add_dependency(u, dep); + } + } + } + } + } + } + self.depmap = Some(Rc::new(dm)); + } + + // at this point self.depmap is guaranteed to be Some(_) + Ok(self.depmap.as_ref().unwrap().clone()) + } + + /// Get an existing task by its UUID + pub fn get_task(&mut self, uuid: Uuid) -> anyhow::Result> { + let depmap = self.dependency_map(false)?; + Ok(self + .taskdb + .get_task(uuid)? + .map(move |tm| Task::new(uuid, tm, depmap))) + } + + /// Create a new task. + pub fn new_task(&mut self, status: Status, description: String) -> anyhow::Result { + let uuid = Uuid::new_v4(); + self.add_undo_point(false)?; + let taskmap = self.taskdb.apply(SyncOp::Create { uuid })?; + let depmap = self.dependency_map(false)?; + let mut task = Task::new(uuid, taskmap, depmap).into_mut(self); + task.set_description(description)?; + task.set_status(status)?; + task.set_entry(Some(Utc::now()))?; + trace!("task {} created", uuid); + Ok(task.into_immut()) + } + + /// Create a new, empty task with the given UUID. This is useful for importing tasks, but + /// otherwise should be avoided in favor of `new_task`. If the task already exists, this + /// does nothing and returns the existing task. + pub fn import_task_with_uuid(&mut self, uuid: Uuid) -> anyhow::Result { + self.add_undo_point(false)?; + let taskmap = self.taskdb.apply(SyncOp::Create { uuid })?; + let depmap = self.dependency_map(false)?; + Ok(Task::new(uuid, taskmap, depmap)) + } + + /// Delete a task. The task must exist. Note that this is different from setting status to + /// Deleted; this is the final purge of the task. This is not a public method as deletion + /// should only occur through expiration. + fn delete_task(&mut self, uuid: Uuid) -> anyhow::Result<()> { + self.add_undo_point(false)?; + self.taskdb.apply(SyncOp::Delete { uuid })?; + trace!("task {} deleted", uuid); + Ok(()) + } + + /// Synchronize this replica against the given server. The working set is rebuilt after + /// this occurs, but without renumbering, so any newly-pending tasks should appear in + /// the working set. + /// + /// If `avoid_snapshots` is true, the sync operations produces a snapshot only when the server + /// indicate it is urgent (snapshot urgency "high"). This allows time for other replicas to + /// create a snapshot before this one does. + /// + /// Set this to true on systems more constrained in CPU, memory, or bandwidth than a typical desktop + /// system + pub fn sync( + &mut self, + server: &mut Box, + avoid_snapshots: bool, + ) -> anyhow::Result<()> { + self.taskdb + .sync(server, avoid_snapshots) + .context("Failed to synchronize with server")?; + self.rebuild_working_set(false) + .context("Failed to rebuild working set after sync")?; + Ok(()) + } + + /// Undo local operations until the most recent UndoPoint, returning false if there are no + /// local operations to undo. + pub fn undo(&mut self) -> anyhow::Result { + self.taskdb.undo() + } + + /// Rebuild this replica's working set, based on whether tasks are pending or not. If + /// `renumber` is true, then existing tasks may be moved to new working-set indices; in any + /// case, on completion all pending tasks are in the working set and all non- pending tasks are + /// not. + pub fn rebuild_working_set(&mut self, renumber: bool) -> anyhow::Result<()> { + let pending = String::from(Status::Pending.to_taskmap()); + self.taskdb + .rebuild_working_set(|t| t.get("status") == Some(&pending), renumber)?; + Ok(()) + } + + /// Expire old, deleted tasks. + /// + /// Expiration entails removal of tasks from the replica. Any modifications that occur after + /// the deletion (such as operations synchronized from other replicas) will do nothing. + /// + /// Tasks are eligible for expiration when they have status Deleted and have not been modified + /// for 180 days (about six months). Note that completed tasks are not eligible. + pub fn expire_tasks(&mut self) -> anyhow::Result<()> { + let six_mos_ago = Utc::now() - Duration::days(180); + self.all_tasks()? + .iter() + .filter(|(_, t)| t.get_status() == Status::Deleted) + .filter(|(_, t)| { + if let Some(m) = t.get_modified() { + m < six_mos_ago + } else { + false + } + }) + .try_for_each(|(u, _)| self.delete_task(*u))?; + Ok(()) + } + + /// Add an UndoPoint, if one has not already been added by this Replica. This occurs + /// automatically when a change is made. The `force` flag allows forcing a new UndoPoint + /// even if one has already been created by this Replica, and may be useful when a Replica + /// instance is held for a long time and used to apply more than one user-visible change. + pub fn add_undo_point(&mut self, force: bool) -> anyhow::Result<()> { + if force || !self.added_undo_point { + self.taskdb.add_undo_point()?; + self.added_undo_point = true; + } + Ok(()) + } + + /// Get the number of operations local to this replica and not yet synchronized to the server. + pub fn num_local_operations(&mut self) -> anyhow::Result { + self.taskdb.num_operations() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::ReplicaOp; + use crate::task::Status; + use chrono::TimeZone; + use pretty_assertions::assert_eq; + use std::collections::HashSet; + use uuid::Uuid; + + #[test] + fn new_task() { + let mut rep = Replica::new_inmemory(); + + let t = rep.new_task(Status::Pending, "a task".into()).unwrap(); + assert_eq!(t.get_description(), String::from("a task")); + assert_eq!(t.get_status(), Status::Pending); + assert!(t.get_modified().is_some()); + } + + #[test] + fn modify_task() { + let mut rep = Replica::new_inmemory(); + + let t = rep.new_task(Status::Pending, "a task".into()).unwrap(); + + let mut t = t.into_mut(&mut rep); + t.set_description(String::from("past tense")).unwrap(); + t.set_status(Status::Completed).unwrap(); + // check that values have changed on the TaskMut + assert_eq!(t.get_description(), "past tense"); + assert_eq!(t.get_status(), Status::Completed); + + // check that values have changed after into_immut + let t = t.into_immut(); + assert_eq!(t.get_description(), "past tense"); + assert_eq!(t.get_status(), Status::Completed); + + // check that values have changed in storage, too + let t = rep.get_task(t.get_uuid()).unwrap().unwrap(); + assert_eq!(t.get_description(), "past tense"); + assert_eq!(t.get_status(), Status::Completed); + + // and check for the corresponding operations, cleaning out the timestamps + // and modified properties as these are based on the current time + let now = Utc::now(); + let clean_op = |op: ReplicaOp| { + if let ReplicaOp::Update { + uuid, + property, + mut old_value, + mut value, + .. + } = op + { + // rewrite automatically-created dates to "just-now" for ease + // of testing + if property == "modified" || property == "end" || property == "entry" { + if value.is_some() { + value = Some("just-now".into()); + } + if old_value.is_some() { + old_value = Some("just-now".into()); + } + } + ReplicaOp::Update { + uuid, + property, + old_value, + value, + timestamp: now, + } + } else { + op + } + }; + assert_eq!( + rep.taskdb + .operations() + .drain(..) + .map(clean_op) + .collect::>(), + vec![ + ReplicaOp::UndoPoint, + ReplicaOp::Create { uuid: t.get_uuid() }, + ReplicaOp::Update { + uuid: t.get_uuid(), + property: "modified".into(), + old_value: None, + value: Some("just-now".into()), + timestamp: now, + }, + ReplicaOp::Update { + uuid: t.get_uuid(), + property: "description".into(), + old_value: None, + value: Some("a task".into()), + timestamp: now, + }, + ReplicaOp::Update { + uuid: t.get_uuid(), + property: "status".into(), + old_value: None, + value: Some("pending".into()), + timestamp: now, + }, + ReplicaOp::Update { + uuid: t.get_uuid(), + property: "entry".into(), + old_value: None, + value: Some("just-now".into()), + timestamp: now, + }, + ReplicaOp::Update { + uuid: t.get_uuid(), + property: "modified".into(), + old_value: Some("just-now".into()), + value: Some("just-now".into()), + timestamp: now, + }, + ReplicaOp::Update { + uuid: t.get_uuid(), + property: "description".into(), + old_value: Some("a task".into()), + value: Some("past tense".into()), + timestamp: now, + }, + ReplicaOp::Update { + uuid: t.get_uuid(), + property: "end".into(), + old_value: None, + value: Some("just-now".into()), + timestamp: now, + }, + ReplicaOp::Update { + uuid: t.get_uuid(), + property: "status".into(), + old_value: Some("pending".into()), + value: Some("completed".into()), + timestamp: now, + }, + ] + ); + + assert_eq!(rep.num_local_operations().unwrap(), 10); + } + + #[test] + fn delete_task() { + let mut rep = Replica::new_inmemory(); + + let t = rep.new_task(Status::Pending, "a task".into()).unwrap(); + let uuid = t.get_uuid(); + + rep.delete_task(uuid).unwrap(); + assert_eq!(rep.get_task(uuid).unwrap(), None); + } + + #[test] + fn get_and_modify() { + let mut rep = Replica::new_inmemory(); + + let t = rep + .new_task(Status::Pending, "another task".into()) + .unwrap(); + let uuid = t.get_uuid(); + + let t = rep.get_task(uuid).unwrap().unwrap(); + assert_eq!(t.get_description(), String::from("another task")); + + let mut t = t.into_mut(&mut rep); + t.set_status(Status::Deleted).unwrap(); + t.set_description("gone".into()).unwrap(); + + let t = rep.get_task(uuid).unwrap().unwrap(); + assert_eq!(t.get_status(), Status::Deleted); + assert_eq!(t.get_description(), "gone"); + + rep.rebuild_working_set(true).unwrap(); + + let ws = rep.working_set().unwrap(); + assert!(ws.by_uuid(t.get_uuid()).is_none()); + } + + #[test] + fn new_pending_adds_to_working_set() { + let mut rep = Replica::new_inmemory(); + + let t = rep + .new_task(Status::Pending, "to-be-pending".into()) + .unwrap(); + let uuid = t.get_uuid(); + + let ws = rep.working_set().unwrap(); + assert_eq!(ws.len(), 1); // only one non-none value + assert!(ws.by_index(0).is_none()); + assert_eq!(ws.by_index(1), Some(uuid)); + + let ws = rep.working_set().unwrap(); + assert_eq!(ws.by_uuid(t.get_uuid()), Some(1)); + } + + #[test] + fn get_does_not_exist() { + let mut rep = Replica::new_inmemory(); + let uuid = Uuid::new_v4(); + assert_eq!(rep.get_task(uuid).unwrap(), None); + } + + #[test] + fn expire() { + let mut rep = Replica::new_inmemory(); + let mut t; + + rep.new_task(Status::Pending, "keeper 1".into()).unwrap(); + rep.new_task(Status::Completed, "keeper 2".into()).unwrap(); + + t = rep.new_task(Status::Deleted, "keeper 3".into()).unwrap(); + { + let mut t = t.into_mut(&mut rep); + // set entry, with modification set as a side-effect + t.set_entry(Some(Utc::now())).unwrap(); + } + + t = rep.new_task(Status::Deleted, "goner".into()).unwrap(); + { + let mut t = t.into_mut(&mut rep); + t.set_modified(Utc.ymd(1980, 1, 1).and_hms(0, 0, 0)) + .unwrap(); + } + + rep.expire_tasks().unwrap(); + + for (_, t) in rep.all_tasks().unwrap() { + println!("got task {}", t.get_description()); + assert!(t.get_description().starts_with("keeper")); + } + } + + #[test] + fn dependency_map() { + let mut rep = Replica::new_inmemory(); + + let mut tasks = vec![]; + for _ in 0..4 { + tasks.push(rep.new_task(Status::Pending, "t".into()).unwrap()); + } + + let uuids: Vec<_> = tasks.iter().map(|t| t.get_uuid()).collect(); + + // t[3] depends on t[2], and t[1] + { + let mut t = tasks.pop().unwrap().into_mut(&mut rep); + t.add_dependency(uuids[2]).unwrap(); + t.add_dependency(uuids[1]).unwrap(); + } + + // t[2] depends on t[0] + { + let mut t = tasks.pop().unwrap().into_mut(&mut rep); + t.add_dependency(uuids[0]).unwrap(); + } + + // t[1] depends on t[0] + { + let mut t = tasks.pop().unwrap().into_mut(&mut rep); + t.add_dependency(uuids[0]).unwrap(); + } + + // generate the dependency map, forcing an update based on the newly-added + // dependencies + let dm = rep.dependency_map(true).unwrap(); + + assert_eq!( + dm.dependencies(uuids[3]).collect::>(), + set![uuids[1], uuids[2]] + ); + assert_eq!( + dm.dependencies(uuids[2]).collect::>(), + set![uuids[0]] + ); + assert_eq!( + dm.dependencies(uuids[1]).collect::>(), + set![uuids[0]] + ); + assert_eq!(dm.dependencies(uuids[0]).collect::>(), set![]); + + assert_eq!(dm.dependents(uuids[3]).collect::>(), set![]); + assert_eq!( + dm.dependents(uuids[2]).collect::>(), + set![uuids[3]] + ); + assert_eq!( + dm.dependents(uuids[1]).collect::>(), + set![uuids[3]] + ); + assert_eq!( + dm.dependents(uuids[0]).collect::>(), + set![uuids[1], uuids[2]] + ); + } +} diff --git a/rust/taskchampion/src/server/config.rs b/rust/taskchampion/src/server/config.rs new file mode 100644 index 000000000..68ed00b68 --- /dev/null +++ b/rust/taskchampion/src/server/config.rs @@ -0,0 +1,39 @@ +use super::types::Server; +use super::{LocalServer, RemoteServer}; +use std::path::PathBuf; +use uuid::Uuid; + +/// The configuration for a replica's access to a sync server. +pub enum ServerConfig { + /// A local task database, for situations with a single replica. + Local { + /// Path containing the server's DB + server_dir: PathBuf, + }, + /// A remote taskchampion-sync-server instance + Remote { + /// Sync server "origin"; a URL with schema and hostname but no path or trailing `/` + origin: String, + + /// Client Key to identify and authenticate this replica to the server + client_key: Uuid, + + /// Private encryption secret used to encrypt all data sent to the server. This can + /// be any suitably un-guessable string of bytes. + encryption_secret: Vec, + }, +} + +impl ServerConfig { + /// Get a server based on this configuration + pub fn into_server(self) -> anyhow::Result> { + Ok(match self { + ServerConfig::Local { server_dir } => Box::new(LocalServer::new(server_dir)?), + ServerConfig::Remote { + origin, + client_key, + encryption_secret, + } => Box::new(RemoteServer::new(origin, client_key, encryption_secret)?), + }) + } +} diff --git a/rust/taskchampion/src/server/crypto.rs b/rust/taskchampion/src/server/crypto.rs new file mode 100644 index 000000000..978a38fd8 --- /dev/null +++ b/rust/taskchampion/src/server/crypto.rs @@ -0,0 +1,412 @@ +/// This module implements the encryption specified in the sync-protocol +/// document. +use ring::{aead, digest, pbkdf2, rand, rand::SecureRandom}; +use std::io::Read; +use uuid::Uuid; + +const PBKDF2_ITERATIONS: u32 = 100000; +const ENVELOPE_VERSION: u8 = 1; +const AAD_LEN: usize = 17; +const TASK_APP_ID: u8 = 1; + +/// An Cryptor stores a secret and allows sealing and unsealing. It derives a key from the secret, +/// which takes a nontrivial amount of time, so it should be created once and re-used for the given +/// client_key. +pub(super) struct Cryptor { + key: aead::LessSafeKey, + rng: rand::SystemRandom, +} + +impl Cryptor { + pub(super) fn new(client_key: Uuid, secret: &Secret) -> anyhow::Result { + Ok(Cryptor { + key: Self::derive_key(client_key, secret)?, + rng: rand::SystemRandom::new(), + }) + } + + /// Derive a key as specified for version 1. Note that this may take 10s of ms. + fn derive_key(client_key: Uuid, secret: &Secret) -> anyhow::Result { + let salt = digest::digest(&digest::SHA256, client_key.as_bytes()); + + let mut key_bytes = vec![0u8; aead::CHACHA20_POLY1305.key_len()]; + pbkdf2::derive( + pbkdf2::PBKDF2_HMAC_SHA256, + std::num::NonZeroU32::new(PBKDF2_ITERATIONS).unwrap(), + salt.as_ref(), + secret.as_ref(), + &mut key_bytes, + ); + + let unbound_key = aead::UnboundKey::new(&aead::CHACHA20_POLY1305, &key_bytes) + .map_err(|_| anyhow::anyhow!("error while creating AEAD key"))?; + Ok(aead::LessSafeKey::new(unbound_key)) + } + + /// Encrypt the given payload. + pub(super) fn seal(&self, payload: Unsealed) -> anyhow::Result { + let Unsealed { + version_id, + mut payload, + } = payload; + + let mut nonce_buf = [0u8; aead::NONCE_LEN]; + self.rng + .fill(&mut nonce_buf) + .map_err(|_| anyhow::anyhow!("error generating random nonce"))?; + let nonce = aead::Nonce::assume_unique_for_key(nonce_buf); + + let aad = self.make_aad(version_id); + + let tag = self + .key + .seal_in_place_separate_tag(nonce, aad, &mut payload) + .map_err(|_| anyhow::anyhow!("error while sealing"))?; + payload.extend_from_slice(tag.as_ref()); + + let env = Envelope { + nonce: &nonce_buf, + payload: payload.as_ref(), + }; + + Ok(Sealed { + version_id, + payload: env.to_bytes(), + }) + } + + /// Decrypt the given payload, verifying it was created for the given version_id + pub(super) fn unseal(&self, payload: Sealed) -> anyhow::Result { + let Sealed { + version_id, + payload, + } = payload; + + let env = Envelope::from_bytes(&payload)?; + + let mut nonce = [0u8; aead::NONCE_LEN]; + nonce.copy_from_slice(env.nonce); + let nonce = aead::Nonce::assume_unique_for_key(nonce); + let aad = self.make_aad(version_id); + + let mut payload = env.payload.to_vec(); + let plaintext = self + .key + .open_in_place(nonce, aad, payload.as_mut()) + .map_err(|_| anyhow::anyhow!("error while creating AEAD key"))?; + + Ok(Unsealed { + version_id, + payload: plaintext.to_vec(), + }) + } + + fn make_aad(&self, version_id: Uuid) -> aead::Aad<[u8; AAD_LEN]> { + let mut aad = [0u8; AAD_LEN]; + aad[0] = TASK_APP_ID; + aad[1..].copy_from_slice(version_id.as_bytes()); + aead::Aad::from(aad) + } +} + +/// Secret represents a secret key as used for encryption and decryption. +pub(super) struct Secret(pub(super) Vec); + +impl From> for Secret { + fn from(bytes: Vec) -> Self { + Self(bytes) + } +} + +impl AsRef<[u8]> for Secret { + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +/// Envelope for the data stored on the server, containing the information +/// required to decrypt. +#[derive(Debug, PartialEq, Eq)] +struct Envelope<'a> { + nonce: &'a [u8], + payload: &'a [u8], +} + +impl<'a> Envelope<'a> { + fn from_bytes(buf: &'a [u8]) -> anyhow::Result> { + if buf.len() <= 1 + aead::NONCE_LEN { + anyhow::bail!("envelope is too small"); + } + + let version = buf[0]; + if version != ENVELOPE_VERSION { + anyhow::bail!("unrecognized encryption envelope version {}", version); + } + + Ok(Envelope { + nonce: &buf[1..1 + aead::NONCE_LEN], + payload: &buf[1 + aead::NONCE_LEN..], + }) + } + + fn to_bytes(&self) -> Vec { + let mut buf = Vec::with_capacity(1 + self.nonce.len() + self.payload.len()); + + buf.push(ENVELOPE_VERSION); + buf.extend_from_slice(self.nonce); + buf.extend_from_slice(self.payload); + buf + } +} + +/// A unsealed payload with an attached version_id. The version_id is used to +/// validate the context of the payload on unsealing. +pub(super) struct Unsealed { + pub(super) version_id: Uuid, + pub(super) payload: Vec, +} + +/// An encrypted payload +pub(super) struct Sealed { + pub(super) version_id: Uuid, + pub(super) payload: Vec, +} + +impl Sealed { + pub(super) fn from_resp( + resp: ureq::Response, + version_id: Uuid, + content_type: &str, + ) -> Result { + if resp.header("Content-Type") == Some(content_type) { + let mut reader = resp.into_reader(); + let mut payload = vec![]; + reader.read_to_end(&mut payload)?; + Ok(Self { + version_id, + payload, + }) + } else { + Err(anyhow::anyhow!( + "Response did not have expected content-type" + )) + } + } +} + +impl AsRef<[u8]> for Sealed { + fn as_ref(&self) -> &[u8] { + self.payload.as_ref() + } +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn envelope_round_trip() { + let env = Envelope { + nonce: &[2; 12], + payload: b"HELLO", + }; + + let bytes = env.to_bytes(); + let env2 = Envelope::from_bytes(&bytes).unwrap(); + assert_eq!(env, env2); + } + + #[test] + fn envelope_bad_version() { + let env = Envelope { + nonce: &[2; 12], + payload: b"HELLO", + }; + + let mut bytes = env.to_bytes(); + bytes[0] = 99; + assert!(Envelope::from_bytes(&bytes).is_err()); + } + + #[test] + fn envelope_too_short() { + let env = Envelope { + nonce: &[2; 12], + payload: b"HELLO", + }; + + let bytes = env.to_bytes(); + let bytes = &bytes[..10]; + assert!(Envelope::from_bytes(bytes).is_err()); + } + + #[test] + fn round_trip() { + let version_id = Uuid::new_v4(); + let payload = b"HISTORY REPEATS ITSELF".to_vec(); + + let secret = Secret(b"SEKRIT".to_vec()); + let cryptor = Cryptor::new(Uuid::new_v4(), &secret).unwrap(); + + let unsealed = Unsealed { + version_id, + payload: payload.clone(), + }; + let sealed = cryptor.seal(unsealed).unwrap(); + let unsealed = cryptor.unseal(sealed).unwrap(); + + assert_eq!(unsealed.payload, payload); + assert_eq!(unsealed.version_id, version_id); + } + + #[test] + fn round_trip_bad_key() { + let version_id = Uuid::new_v4(); + let payload = b"HISTORY REPEATS ITSELF".to_vec(); + let client_key = Uuid::new_v4(); + + let secret = Secret(b"SEKRIT".to_vec()); + let cryptor = Cryptor::new(client_key, &secret).unwrap(); + + let unsealed = Unsealed { + version_id, + payload: payload.clone(), + }; + let sealed = cryptor.seal(unsealed).unwrap(); + + let secret = Secret(b"DIFFERENT_SECRET".to_vec()); + let cryptor = Cryptor::new(client_key, &secret).unwrap(); + assert!(cryptor.unseal(sealed).is_err()); + } + + #[test] + fn round_trip_bad_version() { + let version_id = Uuid::new_v4(); + let payload = b"HISTORY REPEATS ITSELF".to_vec(); + let client_key = Uuid::new_v4(); + + let secret = Secret(b"SEKRIT".to_vec()); + let cryptor = Cryptor::new(client_key, &secret).unwrap(); + + let unsealed = Unsealed { + version_id, + payload: payload.clone(), + }; + let mut sealed = cryptor.seal(unsealed).unwrap(); + sealed.version_id = Uuid::new_v4(); // change the version_id + assert!(cryptor.unseal(sealed).is_err()); + } + + #[test] + fn round_trip_bad_client_key() { + let version_id = Uuid::new_v4(); + let payload = b"HISTORY REPEATS ITSELF".to_vec(); + let client_key = Uuid::new_v4(); + + let secret = Secret(b"SEKRIT".to_vec()); + let cryptor = Cryptor::new(client_key, &secret).unwrap(); + + let unsealed = Unsealed { + version_id, + payload: payload.clone(), + }; + let sealed = cryptor.seal(unsealed).unwrap(); + + let client_key = Uuid::new_v4(); + let cryptor = Cryptor::new(client_key, &secret).unwrap(); + assert!(cryptor.unseal(sealed).is_err()); + } + + mod externally_valid { + // validate data generated by generate-test-data.py. The intent is to + // validate that this format matches the specification by implementing + // the specification in a second language + use super::*; + use pretty_assertions::assert_eq; + + /// The values in generate-test-data.py + fn defaults() -> (Uuid, Uuid, Vec) { + ( + Uuid::parse_str("b0517957-f912-4d49-8330-f612e73030c4").unwrap(), + Uuid::parse_str("0666d464-418a-4a08-ad53-6f15c78270cd").unwrap(), + b"b4a4e6b7b811eda1dc1a2693ded".to_vec(), + ) + } + + #[test] + fn good() { + let (version_id, client_key, encryption_secret) = defaults(); + let sealed = Sealed { + version_id, + payload: include_bytes!("test-good.data").to_vec(), + }; + + let cryptor = Cryptor::new(client_key, &Secret(encryption_secret)).unwrap(); + let unsealed = cryptor.unseal(sealed).unwrap(); + + assert_eq!(unsealed.payload, b"SUCCESS"); + assert_eq!(unsealed.version_id, version_id); + } + + #[test] + fn bad_version_id() { + let (version_id, client_key, encryption_secret) = defaults(); + let sealed = Sealed { + version_id, + payload: include_bytes!("test-bad-version-id.data").to_vec(), + }; + + let cryptor = Cryptor::new(client_key, &Secret(encryption_secret)).unwrap(); + assert!(cryptor.unseal(sealed).is_err()); + } + + #[test] + fn bad_client_key() { + let (version_id, client_key, encryption_secret) = defaults(); + let sealed = Sealed { + version_id, + payload: include_bytes!("test-bad-client-key.data").to_vec(), + }; + + let cryptor = Cryptor::new(client_key, &Secret(encryption_secret)).unwrap(); + assert!(cryptor.unseal(sealed).is_err()); + } + + #[test] + fn bad_secret() { + let (version_id, client_key, encryption_secret) = defaults(); + let sealed = Sealed { + version_id, + payload: include_bytes!("test-bad-secret.data").to_vec(), + }; + + let cryptor = Cryptor::new(client_key, &Secret(encryption_secret)).unwrap(); + assert!(cryptor.unseal(sealed).is_err()); + } + + #[test] + fn bad_version() { + let (version_id, client_key, encryption_secret) = defaults(); + let sealed = Sealed { + version_id, + payload: include_bytes!("test-bad-version.data").to_vec(), + }; + + let cryptor = Cryptor::new(client_key, &Secret(encryption_secret)).unwrap(); + assert!(cryptor.unseal(sealed).is_err()); + } + + #[test] + fn bad_app_id() { + let (version_id, client_key, encryption_secret) = defaults(); + let sealed = Sealed { + version_id, + payload: include_bytes!("test-bad-app-id.data").to_vec(), + }; + + let cryptor = Cryptor::new(client_key, &Secret(encryption_secret)).unwrap(); + assert!(cryptor.unseal(sealed).is_err()); + } + } +} diff --git a/rust/taskchampion/src/server/generate-test-data.py b/rust/taskchampion/src/server/generate-test-data.py new file mode 100644 index 000000000..366597813 --- /dev/null +++ b/rust/taskchampion/src/server/generate-test-data.py @@ -0,0 +1,77 @@ +# This file generates test-encrypted.data. To run it: +# - pip install cryptography pbkdf2 +# - python taskchampion/src/server/generate-test-data.py taskchampion/src/server/ + +import os +import hashlib +import pbkdf2 +import secrets +import sys +import uuid + +from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 + +# these values match values used in the rust tests +client_key = "0666d464-418a-4a08-ad53-6f15c78270cd" +encryption_secret = b"b4a4e6b7b811eda1dc1a2693ded" +version_id = "b0517957-f912-4d49-8330-f612e73030c4" + +def gen( + version_id=version_id, client_key=client_key, encryption_secret=encryption_secret, + app_id=1, version=1): + # first, generate the encryption key + salt = hashlib.sha256(uuid.UUID(client_key).bytes).digest() + key = pbkdf2.PBKDF2( + encryption_secret, + salt, + digestmodule=hashlib.sha256, + iterations=100000, + ).read(32) + + # create a nonce + nonce = secrets.token_bytes(12) + + assert len(b"\x01") == 1 + # create the AAD + aad = b''.join([ + bytes([app_id]), + uuid.UUID(version_id).bytes, + ]) + + # encrypt using AEAD + chacha = ChaCha20Poly1305(key) + ciphertext = chacha.encrypt(nonce, b"SUCCESS", aad) + + # create the envelope + envelope = b''.join([ + bytes([version]), + nonce, + ciphertext, + ]) + + return envelope + + +def main(): + dir = sys.argv[1] + + with open(os.path.join(dir, 'test-good.data'), "wb") as f: + f.write(gen()) + + with open(os.path.join(dir, 'test-bad-version-id.data'), "wb") as f: + f.write(gen(version_id=uuid.uuid4().hex)) + + with open(os.path.join(dir, 'test-bad-client-key.data'), "wb") as f: + f.write(gen(client_key=uuid.uuid4().hex)) + + with open(os.path.join(dir, 'test-bad-secret.data'), "wb") as f: + f.write(gen(encryption_secret=b"xxxxxxxxxxxxxxxxxxxxx")) + + with open(os.path.join(dir, 'test-bad-version.data'), "wb") as f: + f.write(gen(version=99)) + + with open(os.path.join(dir, 'test-bad-app-id.data'), "wb") as f: + f.write(gen(app_id=99)) + + +main() diff --git a/rust/taskchampion/src/server/local.rs b/rust/taskchampion/src/server/local.rs new file mode 100644 index 000000000..37cb06614 --- /dev/null +++ b/rust/taskchampion/src/server/local.rs @@ -0,0 +1,260 @@ +use crate::server::{ + AddVersionResult, GetVersionResult, HistorySegment, Server, Snapshot, SnapshotUrgency, + VersionId, NIL_VERSION_ID, +}; +use crate::storage::sqlite::StoredUuid; +use anyhow::Context; +use rusqlite::params; +use rusqlite::OptionalExtension; +use serde::{Deserialize, Serialize}; +use std::path::Path; +use uuid::Uuid; + +#[derive(Serialize, Deserialize, Debug)] +struct Version { + version_id: VersionId, + parent_version_id: VersionId, + history_segment: HistorySegment, +} + +pub struct LocalServer { + con: rusqlite::Connection, +} + +impl LocalServer { + fn txn(&mut self) -> anyhow::Result { + let txn = self.con.transaction()?; + Ok(txn) + } + + /// A server which has no notion of clients, signatures, encryption, etc. + pub fn new>(directory: P) -> anyhow::Result { + let db_file = directory + .as_ref() + .join("taskchampion-local-sync-server.sqlite3"); + let con = rusqlite::Connection::open(&db_file)?; + + let queries = vec![ + "CREATE TABLE IF NOT EXISTS data (key STRING PRIMARY KEY, value STRING);", + "CREATE TABLE IF NOT EXISTS versions (version_id STRING PRIMARY KEY, parent_version_id STRING, data STRING);", + ]; + for q in queries { + con.execute(q, []).context("Creating table")?; + } + + Ok(LocalServer { con }) + } + + fn get_latest_version_id(&mut self) -> anyhow::Result { + let t = self.txn()?; + let result: Option = t + .query_row( + "SELECT value FROM data WHERE key = 'latest_version_id' LIMIT 1", + rusqlite::params![], + |r| r.get(0), + ) + .optional()?; + Ok(result.map(|x| x.0).unwrap_or(NIL_VERSION_ID)) + } + + fn set_latest_version_id(&mut self, version_id: VersionId) -> anyhow::Result<()> { + let t = self.txn()?; + t.execute( + "INSERT OR REPLACE INTO data (key, value) VALUES ('latest_version_id', ?)", + params![&StoredUuid(version_id)], + ) + .context("Update task query")?; + t.commit()?; + Ok(()) + } + + fn get_version_by_parent_version_id( + &mut self, + parent_version_id: VersionId, + ) -> anyhow::Result> { + let t = self.txn()?; + let r = t.query_row( + "SELECT version_id, parent_version_id, data FROM versions WHERE parent_version_id = ?", + params![&StoredUuid(parent_version_id)], + |r| { + let version_id: StoredUuid = r.get("version_id")?; + let parent_version_id: StoredUuid = r.get("parent_version_id")?; + + Ok(Version{ + version_id: version_id.0, + parent_version_id: parent_version_id.0, + history_segment: r.get("data")?, + })} + ) + .optional() + .context("Get version query") + ?; + Ok(r) + } + + fn add_version_by_parent_version_id(&mut self, version: Version) -> anyhow::Result<()> { + let t = self.txn()?; + t.execute( + "INSERT INTO versions (version_id, parent_version_id, data) VALUES (?, ?, ?)", + params![ + StoredUuid(version.version_id), + StoredUuid(version.parent_version_id), + version.history_segment + ], + )?; + t.commit()?; + Ok(()) + } +} + +impl Server for LocalServer { + // TODO: better transaction isolation for add_version (gets and sets should be in the same + // transaction) + + fn add_version( + &mut self, + parent_version_id: VersionId, + history_segment: HistorySegment, + ) -> anyhow::Result<(AddVersionResult, SnapshotUrgency)> { + // no client lookup + // no signature validation + + // check the parent_version_id for linearity + let latest_version_id = self.get_latest_version_id()?; + if latest_version_id != NIL_VERSION_ID && parent_version_id != latest_version_id { + return Ok(( + AddVersionResult::ExpectedParentVersion(latest_version_id), + SnapshotUrgency::None, + )); + } + + // invent a new ID for this version + let version_id = Uuid::new_v4(); + + self.add_version_by_parent_version_id(Version { + version_id, + parent_version_id, + history_segment, + })?; + self.set_latest_version_id(version_id)?; + + Ok((AddVersionResult::Ok(version_id), SnapshotUrgency::None)) + } + + fn get_child_version( + &mut self, + parent_version_id: VersionId, + ) -> anyhow::Result { + if let Some(version) = self.get_version_by_parent_version_id(parent_version_id)? { + Ok(GetVersionResult::Version { + version_id: version.version_id, + parent_version_id: version.parent_version_id, + history_segment: version.history_segment, + }) + } else { + Ok(GetVersionResult::NoSuchVersion) + } + } + + fn add_snapshot(&mut self, _version_id: VersionId, _snapshot: Snapshot) -> anyhow::Result<()> { + // the local server never requests a snapshot, so it should never get one + unreachable!() + } + + fn get_snapshot(&mut self) -> anyhow::Result> { + Ok(None) + } +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn test_empty() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut server = LocalServer::new(&tmp_dir.path())?; + let child_version = server.get_child_version(NIL_VERSION_ID)?; + assert_eq!(child_version, GetVersionResult::NoSuchVersion); + Ok(()) + } + + #[test] + fn test_add_zero_base() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut server = LocalServer::new(&tmp_dir.path())?; + let history = b"1234".to_vec(); + match server.add_version(NIL_VERSION_ID, history.clone())?.0 { + AddVersionResult::ExpectedParentVersion(_) => { + panic!("should have accepted the version") + } + AddVersionResult::Ok(version_id) => { + let new_version = server.get_child_version(NIL_VERSION_ID)?; + assert_eq!( + new_version, + GetVersionResult::Version { + version_id, + parent_version_id: NIL_VERSION_ID, + history_segment: history, + } + ); + } + } + + Ok(()) + } + + #[test] + fn test_add_nonzero_base() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut server = LocalServer::new(&tmp_dir.path())?; + let history = b"1234".to_vec(); + let parent_version_id = Uuid::new_v4() as VersionId; + + // This is OK because the server has no latest_version_id yet + match server.add_version(parent_version_id, history.clone())?.0 { + AddVersionResult::ExpectedParentVersion(_) => { + panic!("should have accepted the version") + } + AddVersionResult::Ok(version_id) => { + let new_version = server.get_child_version(parent_version_id)?; + assert_eq!( + new_version, + GetVersionResult::Version { + version_id, + parent_version_id, + history_segment: history, + } + ); + } + } + + Ok(()) + } + + #[test] + fn test_add_nonzero_base_forbidden() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut server = LocalServer::new(&tmp_dir.path())?; + let history = b"1234".to_vec(); + let parent_version_id = Uuid::new_v4() as VersionId; + + // add a version + if let (AddVersionResult::ExpectedParentVersion(_), SnapshotUrgency::None) = + server.add_version(parent_version_id, history.clone())? + { + panic!("should have accepted the version") + } + + // then add another, not based on that one + if let (AddVersionResult::Ok(_), SnapshotUrgency::None) = + server.add_version(parent_version_id, history.clone())? + { + panic!("should not have accepted the version") + } + + Ok(()) + } +} diff --git a/rust/taskchampion/src/server/mod.rs b/rust/taskchampion/src/server/mod.rs new file mode 100644 index 000000000..f97b9181b --- /dev/null +++ b/rust/taskchampion/src/server/mod.rs @@ -0,0 +1,26 @@ +/** + +This module defines the client interface to TaskChampion sync servers. +It defines a [trait](crate::server::Server) for servers, and implements both local and remote servers. + +Typical uses of this crate do not interact directly with this module; [`ServerConfig`](crate::ServerConfig) is sufficient. +However, users who wish to implement their own server interfaces can implement the traits defined here and pass the result to [`Replica`](crate::Replica). + +*/ + +#[cfg(test)] +pub(crate) mod test; + +mod config; +mod crypto; +mod local; +mod op; +mod remote; +mod types; + +pub use config::ServerConfig; +pub use local::LocalServer; +pub use remote::RemoteServer; +pub use types::*; + +pub(crate) use op::SyncOp; diff --git a/rust/taskchampion/src/server/op.rs b/rust/taskchampion/src/server/op.rs new file mode 100644 index 000000000..c2906700f --- /dev/null +++ b/rust/taskchampion/src/server/op.rs @@ -0,0 +1,420 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// A SyncOp defines a single change to the task database, that can be synchronized +/// via a server. +#[derive(PartialEq, Clone, Debug, Serialize, Deserialize)] +pub enum SyncOp { + /// Create a new task. + /// + /// On application, if the task already exists, the operation does nothing. + Create { uuid: Uuid }, + + /// Delete an existing task. + /// + /// On application, if the task does not exist, the operation does nothing. + Delete { uuid: Uuid }, + + /// Update an existing task, setting the given property to the given value. If the value is + /// None, then the corresponding property is deleted. + /// + /// If the given task does not exist, the operation does nothing. + Update { + uuid: Uuid, + property: String, + value: Option, + timestamp: DateTime, + }, +} + +use SyncOp::*; + +impl SyncOp { + // Transform takes two operations A and B that happened concurrently and produces two + // operations A' and B' such that `apply(apply(S, A), B') = apply(apply(S, B), A')`. This + // function is used to serialize operations in a process similar to a Git "rebase". + // + // * + // / \ + // op1 / \ op2 + // / \ + // * * + // + // this function "completes the diamond: + // + // * * + // \ / + // op2' \ / op1' + // \ / + // * + // + // such that applying op2' after op1 has the same effect as applying op1' after op2. This + // allows two different systems which have already applied op1 and op2, respectively, and thus + // reached different states, to return to the same state by applying op2' and op1', + // respectively. + pub fn transform(operation1: SyncOp, operation2: SyncOp) -> (Option, Option) { + match (&operation1, &operation2) { + // Two creations or deletions of the same uuid reach the same state, so there's no need + // for any further operations to bring the state together. + (&Create { uuid: uuid1 }, &Create { uuid: uuid2 }) if uuid1 == uuid2 => (None, None), + (&Delete { uuid: uuid1 }, &Delete { uuid: uuid2 }) if uuid1 == uuid2 => (None, None), + + // Given a create and a delete of the same task, one of the operations is invalid: the + // create implies the task does not exist, but the delete implies it exists. Somewhat + // arbitrarily, we prefer the Create + (&Create { uuid: uuid1 }, &Delete { uuid: uuid2 }) if uuid1 == uuid2 => { + (Some(operation1), None) + } + (&Delete { uuid: uuid1 }, &Create { uuid: uuid2 }) if uuid1 == uuid2 => { + (None, Some(operation2)) + } + + // And again from an Update and a Create, prefer the Update + (&Update { uuid: uuid1, .. }, &Create { uuid: uuid2 }) if uuid1 == uuid2 => { + (Some(operation1), None) + } + (&Create { uuid: uuid1 }, &Update { uuid: uuid2, .. }) if uuid1 == uuid2 => { + (None, Some(operation2)) + } + + // Given a delete and an update, prefer the delete + (&Update { uuid: uuid1, .. }, &Delete { uuid: uuid2 }) if uuid1 == uuid2 => { + (None, Some(operation2)) + } + (&Delete { uuid: uuid1 }, &Update { uuid: uuid2, .. }) if uuid1 == uuid2 => { + (Some(operation1), None) + } + + // Two updates to the same property of the same task might conflict. + ( + &Update { + uuid: ref uuid1, + property: ref property1, + value: ref value1, + timestamp: ref timestamp1, + }, + &Update { + uuid: ref uuid2, + property: ref property2, + value: ref value2, + timestamp: ref timestamp2, + }, + ) if uuid1 == uuid2 && property1 == property2 => { + // if the value is the same, there's no conflict + if value1 == value2 { + (None, None) + } else if timestamp1 < timestamp2 { + // prefer the later modification + (None, Some(operation2)) + } else { + // prefer the later modification or, if the modifications are the same, + // just choose one of them + (Some(operation1), None) + } + } + + // anything else is not a conflict of any sort, so return the operations unchanged + (_, _) => (Some(operation1), Some(operation2)), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::storage::InMemoryStorage; + use crate::taskdb::TaskDb; + use chrono::{Duration, Utc}; + use pretty_assertions::assert_eq; + use proptest::prelude::*; + + #[test] + fn test_json_create() -> anyhow::Result<()> { + let uuid = Uuid::new_v4(); + let op = Create { uuid }; + let json = serde_json::to_string(&op)?; + assert_eq!(json, format!(r#"{{"Create":{{"uuid":"{}"}}}}"#, uuid)); + let deser: SyncOp = serde_json::from_str(&json)?; + assert_eq!(deser, op); + Ok(()) + } + + #[test] + fn test_json_delete() -> anyhow::Result<()> { + let uuid = Uuid::new_v4(); + let op = Delete { uuid }; + let json = serde_json::to_string(&op)?; + assert_eq!(json, format!(r#"{{"Delete":{{"uuid":"{}"}}}}"#, uuid)); + let deser: SyncOp = serde_json::from_str(&json)?; + assert_eq!(deser, op); + Ok(()) + } + + #[test] + fn test_json_update() -> anyhow::Result<()> { + let uuid = Uuid::new_v4(); + let timestamp = Utc::now(); + + let op = Update { + uuid, + property: "abc".into(), + value: Some("false".into()), + timestamp, + }; + + let json = serde_json::to_string(&op)?; + assert_eq!( + json, + format!( + r#"{{"Update":{{"uuid":"{}","property":"abc","value":"false","timestamp":"{:?}"}}}}"#, + uuid, timestamp, + ) + ); + let deser: SyncOp = serde_json::from_str(&json)?; + assert_eq!(deser, op); + Ok(()) + } + + #[test] + fn test_json_update_none() -> anyhow::Result<()> { + let uuid = Uuid::new_v4(); + let timestamp = Utc::now(); + + let op = Update { + uuid, + property: "abc".into(), + value: None, + timestamp, + }; + + let json = serde_json::to_string(&op)?; + assert_eq!( + json, + format!( + r#"{{"Update":{{"uuid":"{}","property":"abc","value":null,"timestamp":"{:?}"}}}}"#, + uuid, timestamp, + ) + ); + let deser: SyncOp = serde_json::from_str(&json)?; + assert_eq!(deser, op); + Ok(()) + } + + fn test_transform( + setup: Option, + o1: SyncOp, + o2: SyncOp, + exp1p: Option, + exp2p: Option, + ) { + let (o1p, o2p) = SyncOp::transform(o1.clone(), o2.clone()); + assert_eq!((&o1p, &o2p), (&exp1p, &exp2p)); + + // check that the two operation sequences have the same effect, enforcing the invariant of + // the transform function. + let mut db1 = TaskDb::new_inmemory(); + if let Some(ref o) = setup { + db1.apply(o.clone()).unwrap(); + } + db1.apply(o1).unwrap(); + if let Some(o) = o2p { + db1.apply(o).unwrap(); + } + + let mut db2 = TaskDb::new_inmemory(); + if let Some(ref o) = setup { + db2.apply(o.clone()).unwrap(); + } + db2.apply(o2).unwrap(); + if let Some(o) = o1p { + db2.apply(o).unwrap(); + } + + assert_eq!(db1.sorted_tasks(), db2.sorted_tasks()); + } + + #[test] + fn test_unrelated_create() { + let uuid1 = Uuid::new_v4(); + let uuid2 = Uuid::new_v4(); + + test_transform( + None, + Create { uuid: uuid1 }, + Create { uuid: uuid2 }, + Some(Create { uuid: uuid1 }), + Some(Create { uuid: uuid2 }), + ); + } + + #[test] + fn test_related_updates_different_props() { + let uuid = Uuid::new_v4(); + let timestamp = Utc::now(); + + test_transform( + Some(Create { uuid }), + Update { + uuid, + property: "abc".into(), + value: Some("true".into()), + timestamp, + }, + Update { + uuid, + property: "def".into(), + value: Some("false".into()), + timestamp, + }, + Some(Update { + uuid, + property: "abc".into(), + value: Some("true".into()), + timestamp, + }), + Some(Update { + uuid, + property: "def".into(), + value: Some("false".into()), + timestamp, + }), + ); + } + + #[test] + fn test_related_updates_same_prop() { + let uuid = Uuid::new_v4(); + let timestamp1 = Utc::now(); + let timestamp2 = timestamp1 + Duration::seconds(10); + + test_transform( + Some(Create { uuid }), + Update { + uuid, + property: "abc".into(), + value: Some("true".into()), + timestamp: timestamp1, + }, + Update { + uuid, + property: "abc".into(), + value: Some("false".into()), + timestamp: timestamp2, + }, + None, + Some(Update { + uuid, + property: "abc".into(), + value: Some("false".into()), + timestamp: timestamp2, + }), + ); + } + + #[test] + fn test_related_updates_same_prop_same_time() { + let uuid = Uuid::new_v4(); + let timestamp = Utc::now(); + + test_transform( + Some(Create { uuid }), + Update { + uuid, + property: "abc".into(), + value: Some("true".into()), + timestamp, + }, + Update { + uuid, + property: "abc".into(), + value: Some("false".into()), + timestamp, + }, + Some(Update { + uuid, + property: "abc".into(), + value: Some("true".into()), + timestamp, + }), + None, + ); + } + + fn uuid_strategy() -> impl Strategy { + prop_oneof![ + Just(Uuid::parse_str("83a2f9ef-f455-4195-b92e-a54c161eebfc").unwrap()), + Just(Uuid::parse_str("56e0be07-c61f-494c-a54c-bdcfdd52d2a7").unwrap()), + Just(Uuid::parse_str("4b7ed904-f7b0-4293-8a10-ad452422c7b3").unwrap()), + Just(Uuid::parse_str("9bdd0546-07c8-4e1f-a9bc-9d6299f4773b").unwrap()), + ] + } + + fn operation_strategy() -> impl Strategy { + prop_oneof![ + uuid_strategy().prop_map(|uuid| Create { uuid }), + uuid_strategy().prop_map(|uuid| Delete { uuid }), + (uuid_strategy(), "(title|project|status)").prop_map(|(uuid, property)| { + Update { + uuid, + property, + value: Some("true".into()), + timestamp: Utc::now(), + } + }), + ] + } + + proptest! { + #![proptest_config(ProptestConfig { + cases: 1024, .. ProptestConfig::default() + })] + #[test] + // check that the two operation sequences have the same effect, enforcing the invariant of + // the transform function. + fn transform_invariant_holds(o1 in operation_strategy(), o2 in operation_strategy()) { + let (o1p, o2p) = SyncOp::transform(o1.clone(), o2.clone()); + + let mut db1 = TaskDb::new(Box::new(InMemoryStorage::new())); + let mut db2 = TaskDb::new(Box::new(InMemoryStorage::new())); + + // Ensure that any expected tasks already exist + if let Update{ uuid, .. } = o1 { + let _ = db1.apply(Create{uuid}); + let _ = db2.apply(Create{uuid}); + } + + if let Update{ uuid, .. } = o2 { + let _ = db1.apply(Create{uuid}); + let _ = db2.apply(Create{uuid}); + } + + if let Delete{ uuid } = o1 { + let _ = db1.apply(Create{uuid}); + let _ = db2.apply(Create{uuid}); + } + + if let Delete{ uuid } = o2 { + let _ = db1.apply(Create{uuid}); + let _ = db2.apply(Create{uuid}); + } + + // if applying the initial operations fail, that indicates the operation was invalid + // in the base state, so consider the case successful. + if db1.apply(o1).is_err() { + return Ok(()); + } + if db2.apply(o2).is_err() { + return Ok(()); + } + + if let Some(o) = o2p { + db1.apply(o).map_err(|e| TestCaseError::Fail(format!("Applying to db1: {}", e).into()))?; + } + if let Some(o) = o1p { + db2.apply(o).map_err(|e| TestCaseError::Fail(format!("Applying to db2: {}", e).into()))?; + } + assert_eq!(db1.sorted_tasks(), db2.sorted_tasks()); + } + } +} diff --git a/rust/taskchampion/src/server/remote/mod.rs b/rust/taskchampion/src/server/remote/mod.rs new file mode 100644 index 000000000..9f3e92f95 --- /dev/null +++ b/rust/taskchampion/src/server/remote/mod.rs @@ -0,0 +1,176 @@ +use crate::server::{ + AddVersionResult, GetVersionResult, HistorySegment, Server, Snapshot, SnapshotUrgency, + VersionId, +}; +use std::time::Duration; +use uuid::Uuid; + +use super::crypto::{Cryptor, Sealed, Secret, Unsealed}; + +pub struct RemoteServer { + origin: String, + client_key: Uuid, + cryptor: Cryptor, + agent: ureq::Agent, +} + +/// The content-type for history segments (opaque blobs of bytes) +const HISTORY_SEGMENT_CONTENT_TYPE: &str = "application/vnd.taskchampion.history-segment"; + +/// The content-type for snapshots (opaque blobs of bytes) +const SNAPSHOT_CONTENT_TYPE: &str = "application/vnd.taskchampion.snapshot"; + +/// A RemoeServer communicates with a remote server over HTTP (such as with +/// taskchampion-sync-server). +impl RemoteServer { + /// Construct a new RemoteServer. The `origin` is the sync server's protocol and hostname + /// without a trailing slash, such as `https://tcsync.example.com`. Pass a client_key to + /// identify this client to the server. Multiple replicas synchronizing the same task history + /// should use the same client_key. + pub fn new( + origin: String, + client_key: Uuid, + encryption_secret: Vec, + ) -> anyhow::Result { + Ok(RemoteServer { + origin, + client_key, + cryptor: Cryptor::new(client_key, &Secret(encryption_secret.to_vec()))?, + agent: ureq::AgentBuilder::new() + .timeout_connect(Duration::from_secs(10)) + .timeout_read(Duration::from_secs(60)) + .build(), + }) + } +} + +/// Read a UUID-bearing header or fail trying +fn get_uuid_header(resp: &ureq::Response, name: &str) -> anyhow::Result { + let value = resp + .header(name) + .ok_or_else(|| anyhow::anyhow!("Response does not have {} header", name))?; + let value = Uuid::parse_str(value) + .map_err(|e| anyhow::anyhow!("{} header is not a valid UUID: {}", name, e))?; + Ok(value) +} + +/// Read the X-Snapshot-Request header and return a SnapshotUrgency +fn get_snapshot_urgency(resp: &ureq::Response) -> SnapshotUrgency { + match resp.header("X-Snapshot-Request") { + None => SnapshotUrgency::None, + Some(hdr) => match hdr { + "urgency=low" => SnapshotUrgency::Low, + "urgency=high" => SnapshotUrgency::High, + _ => SnapshotUrgency::None, + }, + } +} + +impl Server for RemoteServer { + fn add_version( + &mut self, + parent_version_id: VersionId, + history_segment: HistorySegment, + ) -> anyhow::Result<(AddVersionResult, SnapshotUrgency)> { + let url = format!( + "{}/v1/client/add-version/{}", + self.origin, parent_version_id + ); + let unsealed = Unsealed { + version_id: parent_version_id, + payload: history_segment, + }; + let sealed = self.cryptor.seal(unsealed)?; + match self + .agent + .post(&url) + .set("Content-Type", HISTORY_SEGMENT_CONTENT_TYPE) + .set("X-Client-Key", &self.client_key.to_string()) + .send_bytes(sealed.as_ref()) + { + Ok(resp) => { + let version_id = get_uuid_header(&resp, "X-Version-Id")?; + Ok(( + AddVersionResult::Ok(version_id), + get_snapshot_urgency(&resp), + )) + } + Err(ureq::Error::Status(status, resp)) if status == 409 => { + let parent_version_id = get_uuid_header(&resp, "X-Parent-Version-Id")?; + Ok(( + AddVersionResult::ExpectedParentVersion(parent_version_id), + SnapshotUrgency::None, + )) + } + Err(err) => Err(err.into()), + } + } + + fn get_child_version( + &mut self, + parent_version_id: VersionId, + ) -> anyhow::Result { + let url = format!( + "{}/v1/client/get-child-version/{}", + self.origin, parent_version_id + ); + match self + .agent + .get(&url) + .set("X-Client-Key", &self.client_key.to_string()) + .call() + { + Ok(resp) => { + let parent_version_id = get_uuid_header(&resp, "X-Parent-Version-Id")?; + let version_id = get_uuid_header(&resp, "X-Version-Id")?; + let sealed = + Sealed::from_resp(resp, parent_version_id, HISTORY_SEGMENT_CONTENT_TYPE)?; + let history_segment = self.cryptor.unseal(sealed)?.payload; + Ok(GetVersionResult::Version { + version_id, + parent_version_id, + history_segment, + }) + } + Err(ureq::Error::Status(status, _)) if status == 404 => { + Ok(GetVersionResult::NoSuchVersion) + } + Err(err) => Err(err.into()), + } + } + + fn add_snapshot(&mut self, version_id: VersionId, snapshot: Snapshot) -> anyhow::Result<()> { + let url = format!("{}/v1/client/add-snapshot/{}", self.origin, version_id); + let unsealed = Unsealed { + version_id, + payload: snapshot, + }; + let sealed = self.cryptor.seal(unsealed)?; + Ok(self + .agent + .post(&url) + .set("Content-Type", SNAPSHOT_CONTENT_TYPE) + .set("X-Client-Key", &self.client_key.to_string()) + .send_bytes(sealed.as_ref()) + .map(|_| ())?) + } + + fn get_snapshot(&mut self) -> anyhow::Result> { + let url = format!("{}/v1/client/snapshot", self.origin); + match self + .agent + .get(&url) + .set("X-Client-Key", &self.client_key.to_string()) + .call() + { + Ok(resp) => { + let version_id = get_uuid_header(&resp, "X-Version-Id")?; + let sealed = Sealed::from_resp(resp, version_id, SNAPSHOT_CONTENT_TYPE)?; + let snapshot = self.cryptor.unseal(sealed)?.payload; + Ok(Some((version_id, snapshot))) + } + Err(ureq::Error::Status(status, _)) if status == 404 => Ok(None), + Err(err) => Err(err.into()), + } + } +} diff --git a/rust/taskchampion/src/server/test-bad-app-id.data b/rust/taskchampion/src/server/test-bad-app-id.data new file mode 100644 index 000000000..a1c6832a6 --- /dev/null +++ b/rust/taskchampion/src/server/test-bad-app-id.data @@ -0,0 +1,2 @@ +#§$á­ +†—Õ^~B>n)j›i†¯1—î9™|µœÓ~ \ No newline at end of file diff --git a/rust/taskchampion/src/server/test-bad-client-key.data b/rust/taskchampion/src/server/test-bad-client-key.data new file mode 100644 index 000000000..bfdd9635e --- /dev/null +++ b/rust/taskchampion/src/server/test-bad-client-key.data @@ -0,0 +1 @@ +ÍA4ö¯Ãè t;Äô õçp¦Ï¦x^Áýreü…œJÔ¤ \ No newline at end of file diff --git a/rust/taskchampion/src/server/test-bad-secret.data b/rust/taskchampion/src/server/test-bad-secret.data new file mode 100644 index 000000000..696da066f --- /dev/null +++ b/rust/taskchampion/src/server/test-bad-secret.data @@ -0,0 +1 @@ +/}åd E°‡dIcÁXéè-‡!V°Û%è4îáòd]³ÃÇ} \ No newline at end of file diff --git a/rust/taskchampion/src/server/test-bad-version-id.data b/rust/taskchampion/src/server/test-bad-version-id.data new file mode 100644 index 000000000..2ccd8c638 --- /dev/null +++ b/rust/taskchampion/src/server/test-bad-version-id.data @@ -0,0 +1 @@ +làæäa|‚ï@ÏS_‚¬…ãzÝV9£q¦Ñ…‘)+¦… \ No newline at end of file diff --git a/rust/taskchampion/src/server/test-bad-version.data b/rust/taskchampion/src/server/test-bad-version.data new file mode 100644 index 000000000..20fed792e --- /dev/null +++ b/rust/taskchampion/src/server/test-bad-version.data @@ -0,0 +1 @@ +cª¶TH¨çp>¥«æº¦m4ï¹Ë~×1µ0PIö´W¢ \ No newline at end of file diff --git a/rust/taskchampion/src/server/test-bad-version_id.data b/rust/taskchampion/src/server/test-bad-version_id.data new file mode 100644 index 000000000..4a228ec4a --- /dev/null +++ b/rust/taskchampion/src/server/test-bad-version_id.data @@ -0,0 +1,2 @@ +B• +áÔ-×3%¦j£,*ߺ7ê©–QØKúO¦œFPZÝ \ No newline at end of file diff --git a/rust/taskchampion/src/server/test-good.data b/rust/taskchampion/src/server/test-good.data new file mode 100644 index 000000000..9efec7577 --- /dev/null +++ b/rust/taskchampion/src/server/test-good.data @@ -0,0 +1 @@ +pÑ¿µÒŸ½V²ûÝäToë"}cT·äY7Æ ˆÀ@ÙdLTý`Ò \ No newline at end of file diff --git a/rust/taskchampion/src/server/test.rs b/rust/taskchampion/src/server/test.rs new file mode 100644 index 000000000..1fff611cc --- /dev/null +++ b/rust/taskchampion/src/server/test.rs @@ -0,0 +1,133 @@ +use crate::server::{ + AddVersionResult, GetVersionResult, HistorySegment, Server, Snapshot, SnapshotUrgency, + VersionId, NIL_VERSION_ID, +}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use uuid::Uuid; + +struct Version { + version_id: VersionId, + parent_version_id: VersionId, + history_segment: HistorySegment, +} + +/// TestServer implements the Server trait with a test implementation. +#[derive(Clone)] +pub(crate) struct TestServer(Arc>); + +pub(crate) struct Inner { + latest_version_id: VersionId, + // NOTE: indexed by parent_version_id! + versions: HashMap, + snapshot_urgency: SnapshotUrgency, + snapshot: Option<(VersionId, Snapshot)>, +} + +impl TestServer { + /// A test server has no notion of clients, signatures, encryption, etc. + pub(crate) fn new() -> TestServer { + TestServer(Arc::new(Mutex::new(Inner { + latest_version_id: NIL_VERSION_ID, + versions: HashMap::new(), + snapshot_urgency: SnapshotUrgency::None, + snapshot: None, + }))) + } + // feel free to add any test utility functions here + + /// Get a boxed Server implementation referring to this TestServer + pub(crate) fn server(&self) -> Box { + Box::new(self.clone()) + } + + pub(crate) fn set_snapshot_urgency(&self, urgency: SnapshotUrgency) { + let mut inner = self.0.lock().unwrap(); + inner.snapshot_urgency = urgency; + } + + /// Get the latest snapshot added to this server + pub(crate) fn snapshot(&self) -> Option<(VersionId, Snapshot)> { + let inner = self.0.lock().unwrap(); + inner.snapshot.as_ref().cloned() + } + + /// Delete a version from storage + pub(crate) fn delete_version(&mut self, parent_version_id: VersionId) { + let mut inner = self.0.lock().unwrap(); + inner.versions.remove(&parent_version_id); + } +} + +impl Server for TestServer { + /// Add a new version. If the given version number is incorrect, this responds with the + /// appropriate version and expects the caller to try again. + fn add_version( + &mut self, + parent_version_id: VersionId, + history_segment: HistorySegment, + ) -> anyhow::Result<(AddVersionResult, SnapshotUrgency)> { + let mut inner = self.0.lock().unwrap(); + + // no client lookup + // no signature validation + + // check the parent_version_id for linearity + if inner.latest_version_id != NIL_VERSION_ID && parent_version_id != inner.latest_version_id + { + return Ok(( + AddVersionResult::ExpectedParentVersion(inner.latest_version_id), + SnapshotUrgency::None, + )); + } + + // invent a new ID for this version + let version_id = Uuid::new_v4(); + + inner.versions.insert( + parent_version_id, + Version { + version_id, + parent_version_id, + history_segment, + }, + ); + inner.latest_version_id = version_id; + + // reply with the configured urgency and reset it to None + let urgency = inner.snapshot_urgency; + inner.snapshot_urgency = SnapshotUrgency::None; + Ok((AddVersionResult::Ok(version_id), urgency)) + } + + /// Get a vector of all versions after `since_version` + fn get_child_version( + &mut self, + parent_version_id: VersionId, + ) -> anyhow::Result { + let inner = self.0.lock().unwrap(); + + if let Some(version) = inner.versions.get(&parent_version_id) { + Ok(GetVersionResult::Version { + version_id: version.version_id, + parent_version_id: version.parent_version_id, + history_segment: version.history_segment.clone(), + }) + } else { + Ok(GetVersionResult::NoSuchVersion) + } + } + + fn add_snapshot(&mut self, version_id: VersionId, snapshot: Snapshot) -> anyhow::Result<()> { + let mut inner = self.0.lock().unwrap(); + + // test implementation -- does not perform any validation + inner.snapshot = Some((version_id, snapshot)); + Ok(()) + } + + fn get_snapshot(&mut self) -> anyhow::Result> { + let inner = self.0.lock().unwrap(); + Ok(inner.snapshot.clone()) + } +} diff --git a/rust/taskchampion/src/server/types.rs b/rust/taskchampion/src/server/types.rs new file mode 100644 index 000000000..fada6c04a --- /dev/null +++ b/rust/taskchampion/src/server/types.rs @@ -0,0 +1,70 @@ +use uuid::Uuid; + +/// Versions are referred to with sha2 hashes. +pub type VersionId = Uuid; + +/// The distinguished value for "no version" +pub const NIL_VERSION_ID: VersionId = Uuid::nil(); + +/// A segment in the history of this task database, in the form of a sequence of operations. This +/// data is pre-encoded, and from the protocol level appears as a sequence of bytes. +pub type HistorySegment = Vec; + +/// A snapshot of the state of the task database. This is encoded by the taskdb implementation +/// and treated as a sequence of bytes by the server implementation. +pub type Snapshot = Vec; + +/// AddVersionResult is the response type from [`crate::server::Server::add_version`]. +#[derive(Debug, PartialEq)] +pub enum AddVersionResult { + /// OK, version added with the given ID + Ok(VersionId), + /// Rejected; expected a version with the given parent version + ExpectedParentVersion(VersionId), +} + +/// SnapshotUrgency indicates how much the server would like this replica to send a snapshot. +#[derive(PartialEq, Debug, Clone, Copy, Eq, PartialOrd, Ord)] +pub enum SnapshotUrgency { + /// Don't need a snapshot right now. + None, + /// A snapshot would be good, but can wait for other replicas to provide it. + Low, + /// A snapshot is needed right now. + High, +} + +/// A version as downloaded from the server +#[derive(Debug, PartialEq)] +pub enum GetVersionResult { + /// No such version exists + NoSuchVersion, + + /// The requested version + Version { + version_id: VersionId, + parent_version_id: VersionId, + history_segment: HistorySegment, + }, +} + +/// A value implementing this trait can act as a server against which a replica can sync. +pub trait Server { + /// Add a new version. + fn add_version( + &mut self, + parent_version_id: VersionId, + history_segment: HistorySegment, + ) -> anyhow::Result<(AddVersionResult, SnapshotUrgency)>; + + /// Get the version with the given parent VersionId + fn get_child_version( + &mut self, + parent_version_id: VersionId, + ) -> anyhow::Result; + + /// Add a snapshot on the server + fn add_snapshot(&mut self, version_id: VersionId, snapshot: Snapshot) -> anyhow::Result<()>; + + fn get_snapshot(&mut self) -> anyhow::Result>; +} diff --git a/rust/taskchampion/src/storage/config.rs b/rust/taskchampion/src/storage/config.rs new file mode 100644 index 000000000..a802e4a09 --- /dev/null +++ b/rust/taskchampion/src/storage/config.rs @@ -0,0 +1,22 @@ +use super::{InMemoryStorage, SqliteStorage, Storage}; +use std::path::PathBuf; + +/// The configuration required for a replica's storage. +pub enum StorageConfig { + /// Store the data on disk. This is the common choice. + OnDisk { + /// Path containing the task DB. + taskdb_dir: PathBuf, + }, + /// Store the data in memory. This is only useful for testing. + InMemory, +} + +impl StorageConfig { + pub fn into_storage(self) -> anyhow::Result> { + Ok(match self { + StorageConfig::OnDisk { taskdb_dir } => Box::new(SqliteStorage::new(taskdb_dir)?), + StorageConfig::InMemory => Box::new(InMemoryStorage::new()), + }) + } +} diff --git a/rust/taskchampion/src/storage/inmemory.rs b/rust/taskchampion/src/storage/inmemory.rs new file mode 100644 index 000000000..bde13637b --- /dev/null +++ b/rust/taskchampion/src/storage/inmemory.rs @@ -0,0 +1,242 @@ +#![allow(clippy::new_without_default)] + +use crate::storage::{ReplicaOp, Storage, StorageTxn, TaskMap, VersionId, DEFAULT_BASE_VERSION}; +use std::collections::hash_map::Entry; +use std::collections::HashMap; +use uuid::Uuid; + +#[derive(PartialEq, Debug, Clone)] +struct Data { + tasks: HashMap, + base_version: VersionId, + operations: Vec, + working_set: Vec>, +} + +struct Txn<'t> { + storage: &'t mut InMemoryStorage, + new_data: Option, +} + +impl<'t> Txn<'t> { + fn mut_data_ref(&mut self) -> &mut Data { + if self.new_data.is_none() { + self.new_data = Some(self.storage.data.clone()); + } + if let Some(ref mut data) = self.new_data { + data + } else { + unreachable!(); + } + } + + fn data_ref(&mut self) -> &Data { + if let Some(ref data) = self.new_data { + data + } else { + &self.storage.data + } + } +} + +impl<'t> StorageTxn for Txn<'t> { + fn get_task(&mut self, uuid: Uuid) -> anyhow::Result> { + match self.data_ref().tasks.get(&uuid) { + None => Ok(None), + Some(t) => Ok(Some(t.clone())), + } + } + + fn create_task(&mut self, uuid: Uuid) -> anyhow::Result { + if let ent @ Entry::Vacant(_) = self.mut_data_ref().tasks.entry(uuid) { + ent.or_insert_with(TaskMap::new); + Ok(true) + } else { + Ok(false) + } + } + + fn set_task(&mut self, uuid: Uuid, task: TaskMap) -> anyhow::Result<()> { + self.mut_data_ref().tasks.insert(uuid, task); + Ok(()) + } + + fn delete_task(&mut self, uuid: Uuid) -> anyhow::Result { + Ok(self.mut_data_ref().tasks.remove(&uuid).is_some()) + } + + fn all_tasks<'a>(&mut self) -> anyhow::Result> { + Ok(self + .data_ref() + .tasks + .iter() + .map(|(u, t)| (*u, t.clone())) + .collect()) + } + + fn all_task_uuids<'a>(&mut self) -> anyhow::Result> { + Ok(self.data_ref().tasks.keys().copied().collect()) + } + + fn base_version(&mut self) -> anyhow::Result { + Ok(self.data_ref().base_version) + } + + fn set_base_version(&mut self, version: VersionId) -> anyhow::Result<()> { + self.mut_data_ref().base_version = version; + Ok(()) + } + + fn operations(&mut self) -> anyhow::Result> { + Ok(self.data_ref().operations.clone()) + } + + fn num_operations(&mut self) -> anyhow::Result { + Ok(self.data_ref().operations.len()) + } + + fn add_operation(&mut self, op: ReplicaOp) -> anyhow::Result<()> { + self.mut_data_ref().operations.push(op); + Ok(()) + } + + fn set_operations(&mut self, ops: Vec) -> anyhow::Result<()> { + self.mut_data_ref().operations = ops; + Ok(()) + } + + fn get_working_set(&mut self) -> anyhow::Result>> { + Ok(self.data_ref().working_set.clone()) + } + + fn add_to_working_set(&mut self, uuid: Uuid) -> anyhow::Result { + let working_set = &mut self.mut_data_ref().working_set; + working_set.push(Some(uuid)); + Ok(working_set.len()) + } + + fn set_working_set_item(&mut self, index: usize, uuid: Option) -> anyhow::Result<()> { + let working_set = &mut self.mut_data_ref().working_set; + if index >= working_set.len() { + anyhow::bail!("Index {} is not in the working set", index); + } + working_set[index] = uuid; + Ok(()) + } + + fn clear_working_set(&mut self) -> anyhow::Result<()> { + self.mut_data_ref().working_set = vec![None]; + Ok(()) + } + + fn commit(&mut self) -> anyhow::Result<()> { + // copy the new_data back into storage to commit the transaction + if let Some(data) = self.new_data.take() { + self.storage.data = data; + } + Ok(()) + } +} + +/// InMemoryStorage is a simple in-memory task storage implementation. It is not useful for +/// production data, but is useful for testing purposes. +#[derive(PartialEq, Debug, Clone)] +pub struct InMemoryStorage { + data: Data, +} + +impl InMemoryStorage { + pub fn new() -> InMemoryStorage { + InMemoryStorage { + data: Data { + tasks: HashMap::new(), + base_version: DEFAULT_BASE_VERSION, + operations: vec![], + working_set: vec![None], + }, + } + } +} + +impl Storage for InMemoryStorage { + fn txn<'a>(&'a mut self) -> anyhow::Result> { + Ok(Box::new(Txn { + storage: self, + new_data: None, + })) + } +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + + // (note: this module is heavily used in tests so most of its functionality is well-tested + // elsewhere and not tested here) + + #[test] + fn get_working_set_empty() -> anyhow::Result<()> { + let mut storage = InMemoryStorage::new(); + + { + let mut txn = storage.txn()?; + let ws = txn.get_working_set()?; + assert_eq!(ws, vec![None]); + } + + Ok(()) + } + + #[test] + fn add_to_working_set() -> anyhow::Result<()> { + let mut storage = InMemoryStorage::new(); + let uuid1 = Uuid::new_v4(); + let uuid2 = Uuid::new_v4(); + + { + let mut txn = storage.txn()?; + txn.add_to_working_set(uuid1)?; + txn.add_to_working_set(uuid2)?; + txn.commit()?; + } + + { + let mut txn = storage.txn()?; + let ws = txn.get_working_set()?; + assert_eq!(ws, vec![None, Some(uuid1), Some(uuid2)]); + } + + Ok(()) + } + + #[test] + fn clear_working_set() -> anyhow::Result<()> { + let mut storage = InMemoryStorage::new(); + let uuid1 = Uuid::new_v4(); + let uuid2 = Uuid::new_v4(); + + { + let mut txn = storage.txn()?; + txn.add_to_working_set(uuid1)?; + txn.add_to_working_set(uuid2)?; + txn.commit()?; + } + + { + let mut txn = storage.txn()?; + txn.clear_working_set()?; + txn.add_to_working_set(uuid2)?; + txn.add_to_working_set(uuid1)?; + txn.commit()?; + } + + { + let mut txn = storage.txn()?; + let ws = txn.get_working_set()?; + assert_eq!(ws, vec![None, Some(uuid2), Some(uuid1)]); + } + + Ok(()) + } +} diff --git a/rust/taskchampion/src/storage/mod.rs b/rust/taskchampion/src/storage/mod.rs new file mode 100644 index 000000000..d577103e5 --- /dev/null +++ b/rust/taskchampion/src/storage/mod.rs @@ -0,0 +1,133 @@ +/** +This module defines the backend storage used by [`Replica`](crate::Replica). +It defines a [trait](crate::storage::Storage) for storage implementations, and provides a default on-disk implementation as well as an in-memory implementation for testing. + +Typical uses of this crate do not interact directly with this module; [`StorageConfig`](crate::StorageConfig) is sufficient. +However, users who wish to implement their own storage backends can implement the traits defined here and pass the result to [`Replica`](crate::Replica). +*/ +use anyhow::Result; +use std::collections::HashMap; +use uuid::Uuid; + +mod config; +mod inmemory; +mod op; +pub(crate) mod sqlite; + +pub use config::StorageConfig; +pub use inmemory::InMemoryStorage; +pub use sqlite::SqliteStorage; + +pub use op::ReplicaOp; + +/// An in-memory representation of a task as a simple hashmap +pub type TaskMap = HashMap; + +#[cfg(test)] +fn taskmap_with(mut properties: Vec<(String, String)>) -> TaskMap { + let mut rv = TaskMap::new(); + for (p, v) in properties.drain(..) { + rv.insert(p, v); + } + rv +} + +/// The type of VersionIds +pub use crate::server::VersionId; + +/// The default for base_version. +pub(crate) const DEFAULT_BASE_VERSION: Uuid = crate::server::NIL_VERSION_ID; + +/// A Storage transaction, in which storage operations are performed. +/// +/// # Concurrency +/// +/// Serializable consistency must be maintained. Concurrent access is unusual +/// and some implementations may simply apply a mutex to limit access to +/// one transaction at a time. +/// +/// # Commiting and Aborting +/// +/// A transaction is not visible to other readers until it is committed with +/// [`crate::storage::StorageTxn::commit`]. Transactions are aborted if they are dropped. +/// It is safe and performant to drop transactions that did not modify any data without committing. +pub trait StorageTxn { + /// Get an (immutable) task, if it is in the storage + fn get_task(&mut self, uuid: Uuid) -> Result>; + + /// Create an (empty) task, only if it does not already exist. Returns true if + /// the task was created (did not already exist). + fn create_task(&mut self, uuid: Uuid) -> Result; + + /// Set a task, overwriting any existing task. If the task does not exist, this implicitly + /// creates it (use `get_task` to check first, if necessary). + fn set_task(&mut self, uuid: Uuid, task: TaskMap) -> Result<()>; + + /// Delete a task, if it exists. Returns true if the task was deleted (already existed) + fn delete_task(&mut self, uuid: Uuid) -> Result; + + /// Get the uuids and bodies of all tasks in the storage, in undefined order. + fn all_tasks(&mut self) -> Result>; + + /// Get the uuids of all tasks in the storage, in undefined order. + fn all_task_uuids(&mut self) -> Result>; + + /// Get the current base_version for this storage -- the last version synced from the server. + fn base_version(&mut self) -> Result; + + /// Set the current base_version for this storage. + fn set_base_version(&mut self, version: VersionId) -> Result<()>; + + /// Get the current set of outstanding operations (operations that have not been sync'd to the + /// server yet) + fn operations(&mut self) -> Result>; + + /// Get the current set of outstanding operations (operations that have not been sync'd to the + /// server yet) + fn num_operations(&mut self) -> Result; + + /// Add an operation to the end of the list of operations in the storage. Note that this + /// merely *stores* the operation; it is up to the TaskDb to apply it. + fn add_operation(&mut self, op: ReplicaOp) -> Result<()>; + + /// Replace the current list of operations with a new list. + fn set_operations(&mut self, ops: Vec) -> Result<()>; + + /// Get the entire working set, with each task UUID at its appropriate (1-based) index. + /// Element 0 is always None. + fn get_working_set(&mut self) -> Result>>; + + /// Add a task to the working set and return its (one-based) index. This index will be one greater + /// than the highest used index. + fn add_to_working_set(&mut self, uuid: Uuid) -> Result; + + /// Update the working set task at the given index. This cannot add a new item to the + /// working set. + fn set_working_set_item(&mut self, index: usize, uuid: Option) -> Result<()>; + + /// Clear all tasks from the working set in preparation for a garbage-collection operation. + /// Note that this is the only way items are removed from the set. + fn clear_working_set(&mut self) -> Result<()>; + + /// Check whether this storage is entirely empty + #[allow(clippy::wrong_self_convention)] // mut is required here for storage access + fn is_empty(&mut self) -> Result { + let mut empty = true; + empty = empty && self.all_tasks()?.is_empty(); + empty = empty && self.get_working_set()? == vec![None]; + empty = empty && self.base_version()? == Uuid::nil(); + empty = empty && self.operations()?.is_empty(); + Ok(empty) + } + + /// Commit any changes made in the transaction. It is an error to call this more than + /// once. + fn commit(&mut self) -> Result<()>; +} + +/// A trait for objects able to act as task storage. Most of the interesting behavior is in the +/// [`crate::storage::StorageTxn`] trait. +pub trait Storage { + /// Begin a transaction + fn txn<'a>(&'a mut self) -> Result>; +} diff --git a/rust/taskchampion/src/storage/op.rs b/rust/taskchampion/src/storage/op.rs new file mode 100644 index 000000000..bc20d99e9 --- /dev/null +++ b/rust/taskchampion/src/storage/op.rs @@ -0,0 +1,283 @@ +use crate::server::SyncOp; +use crate::storage::TaskMap; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// A ReplicaOp defines a single change to the task database, as stored locally in the replica. +/// This contains additional information not included in SyncOp. +#[derive(PartialEq, Clone, Debug, Serialize, Deserialize)] +pub enum ReplicaOp { + /// Create a new task. + /// + /// On undo, the task is deleted. + Create { uuid: Uuid }, + + /// Delete an existing task. + /// + /// On undo, the task's data is restored from old_task. + Delete { uuid: Uuid, old_task: TaskMap }, + + /// Update an existing task, setting the given property to the given value. If the value is + /// None, then the corresponding property is deleted. + /// + /// On undo, the property is set back to its previous value. + Update { + uuid: Uuid, + property: String, + old_value: Option, + value: Option, + timestamp: DateTime, + }, + + /// Mark a point in the operations history to which the user might like to undo. Users + /// typically want to undo more than one operation at a time (for example, most changes update + /// both the `modified` property and some other task property -- the user would like to "undo" + /// both updates at the same time). Applying an UndoPoint does nothing. + UndoPoint, +} + +impl ReplicaOp { + /// Convert this operation into a [`SyncOp`]. + pub fn into_sync(self) -> Option { + match self { + Self::Create { uuid } => Some(SyncOp::Create { uuid }), + Self::Delete { uuid, .. } => Some(SyncOp::Delete { uuid }), + Self::Update { + uuid, + property, + value, + timestamp, + .. + } => Some(SyncOp::Update { + uuid, + property, + value, + timestamp, + }), + Self::UndoPoint => None, + } + } + + /// Generate a sequence of SyncOp's to reverse the effects of this ReplicaOp. + pub fn reverse_ops(self) -> Vec { + match self { + Self::Create { uuid } => vec![SyncOp::Delete { uuid }], + Self::Delete { uuid, mut old_task } => { + let mut ops = vec![SyncOp::Create { uuid }]; + // We don't have the original update timestamp, but it doesn't + // matter because this SyncOp will just be applied and discarded. + let timestamp = Utc::now(); + for (property, value) in old_task.drain() { + ops.push(SyncOp::Update { + uuid, + property, + value: Some(value), + timestamp, + }); + } + ops + } + Self::Update { + uuid, + property, + old_value, + timestamp, + .. + } => vec![SyncOp::Update { + uuid, + property, + value: old_value, + timestamp, + }], + Self::UndoPoint => vec![], + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::storage::taskmap_with; + use chrono::Utc; + use pretty_assertions::assert_eq; + + use ReplicaOp::*; + + #[test] + fn test_json_create() -> anyhow::Result<()> { + let uuid = Uuid::new_v4(); + let op = Create { uuid }; + let json = serde_json::to_string(&op)?; + assert_eq!(json, format!(r#"{{"Create":{{"uuid":"{}"}}}}"#, uuid)); + let deser: ReplicaOp = serde_json::from_str(&json)?; + assert_eq!(deser, op); + Ok(()) + } + + #[test] + fn test_json_delete() -> anyhow::Result<()> { + let uuid = Uuid::new_v4(); + let old_task = vec![("foo".into(), "bar".into())].drain(..).collect(); + let op = Delete { uuid, old_task }; + let json = serde_json::to_string(&op)?; + assert_eq!( + json, + format!( + r#"{{"Delete":{{"uuid":"{}","old_task":{{"foo":"bar"}}}}}}"#, + uuid + ) + ); + let deser: ReplicaOp = serde_json::from_str(&json)?; + assert_eq!(deser, op); + Ok(()) + } + + #[test] + fn test_json_update() -> anyhow::Result<()> { + let uuid = Uuid::new_v4(); + let timestamp = Utc::now(); + + let op = Update { + uuid, + property: "abc".into(), + old_value: Some("true".into()), + value: Some("false".into()), + timestamp, + }; + + let json = serde_json::to_string(&op)?; + assert_eq!( + json, + format!( + r#"{{"Update":{{"uuid":"{}","property":"abc","old_value":"true","value":"false","timestamp":"{:?}"}}}}"#, + uuid, timestamp, + ) + ); + let deser: ReplicaOp = serde_json::from_str(&json)?; + assert_eq!(deser, op); + Ok(()) + } + + #[test] + fn test_json_update_none() -> anyhow::Result<()> { + let uuid = Uuid::new_v4(); + let timestamp = Utc::now(); + + let op = Update { + uuid, + property: "abc".into(), + old_value: None, + value: None, + timestamp, + }; + + let json = serde_json::to_string(&op)?; + assert_eq!( + json, + format!( + r#"{{"Update":{{"uuid":"{}","property":"abc","old_value":null,"value":null,"timestamp":"{:?}"}}}}"#, + uuid, timestamp, + ) + ); + let deser: ReplicaOp = serde_json::from_str(&json)?; + assert_eq!(deser, op); + Ok(()) + } + + #[test] + fn test_into_sync_create() { + let uuid = Uuid::new_v4(); + assert_eq!(Create { uuid }.into_sync(), Some(SyncOp::Create { uuid })); + } + + #[test] + fn test_into_sync_delete() { + let uuid = Uuid::new_v4(); + assert_eq!( + Delete { + uuid, + old_task: TaskMap::new() + } + .into_sync(), + Some(SyncOp::Delete { uuid }) + ); + } + + #[test] + fn test_into_sync_update() { + let uuid = Uuid::new_v4(); + let timestamp = Utc::now(); + assert_eq!( + Update { + uuid, + property: "prop".into(), + old_value: Some("foo".into()), + value: Some("v".into()), + timestamp, + } + .into_sync(), + Some(SyncOp::Update { + uuid, + property: "prop".into(), + value: Some("v".into()), + timestamp, + }) + ); + } + + #[test] + fn test_into_sync_undo_point() { + assert_eq!(UndoPoint.into_sync(), None); + } + + #[test] + fn test_reverse_create() { + let uuid = Uuid::new_v4(); + assert_eq!(Create { uuid }.reverse_ops(), vec![SyncOp::Delete { uuid }]); + } + + #[test] + fn test_reverse_delete() { + let uuid = Uuid::new_v4(); + let reversed = Delete { + uuid, + old_task: taskmap_with(vec![("prop1".into(), "v1".into())]), + } + .reverse_ops(); + assert_eq!(reversed.len(), 2); + assert_eq!(reversed[0], SyncOp::Create { uuid }); + assert!(matches!( + &reversed[1], + SyncOp::Update { uuid: u, property: p, value: Some(v), ..} + if u == &uuid && p == "prop1" && v == "v1" + )); + } + + #[test] + fn test_reverse_update() { + let uuid = Uuid::new_v4(); + let timestamp = Utc::now(); + assert_eq!( + Update { + uuid, + property: "prop".into(), + old_value: Some("foo".into()), + value: Some("v".into()), + timestamp, + } + .reverse_ops(), + vec![SyncOp::Update { + uuid, + property: "prop".into(), + value: Some("foo".into()), + timestamp, + }] + ); + } + + #[test] + fn test_reverse_undo_point() { + assert_eq!(UndoPoint.reverse_ops(), vec![]); + } +} diff --git a/rust/taskchampion/src/storage/sqlite.rs b/rust/taskchampion/src/storage/sqlite.rs new file mode 100644 index 000000000..7e30931a4 --- /dev/null +++ b/rust/taskchampion/src/storage/sqlite.rs @@ -0,0 +1,810 @@ +use crate::storage::{ReplicaOp, Storage, StorageTxn, TaskMap, VersionId, DEFAULT_BASE_VERSION}; +use anyhow::Context; +use rusqlite::types::{FromSql, ToSql}; +use rusqlite::{params, Connection, OptionalExtension}; +use std::path::Path; +use uuid::Uuid; + +#[derive(Debug, thiserror::Error)] +enum SqliteError { + #[error("SQLite transaction already committted")] + TransactionAlreadyCommitted, +} + +/// Newtype to allow implementing `FromSql` for foreign `uuid::Uuid` +pub(crate) struct StoredUuid(pub(crate) Uuid); + +/// Conversion from Uuid stored as a string (rusqlite's uuid feature stores as binary blob) +impl FromSql for StoredUuid { + fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult { + let u = Uuid::parse_str(value.as_str()?) + .map_err(|_| rusqlite::types::FromSqlError::InvalidType)?; + Ok(StoredUuid(u)) + } +} + +/// Store Uuid as string in database +impl ToSql for StoredUuid { + fn to_sql(&self) -> rusqlite::Result> { + let s = self.0.to_string(); + Ok(s.into()) + } +} + +/// Wraps [`TaskMap`] (type alias for HashMap) so we can implement rusqlite conversion traits for it +struct StoredTaskMap(TaskMap); + +/// Parses TaskMap stored as JSON in string column +impl FromSql for StoredTaskMap { + fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult { + let o: TaskMap = serde_json::from_str(value.as_str()?) + .map_err(|_| rusqlite::types::FromSqlError::InvalidType)?; + Ok(StoredTaskMap(o)) + } +} + +/// Stores TaskMap in string column +impl ToSql for StoredTaskMap { + fn to_sql(&self) -> rusqlite::Result> { + let s = serde_json::to_string(&self.0) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + Ok(s.into()) + } +} + +/// Stores [`ReplicaOp`] in SQLite +impl FromSql for ReplicaOp { + fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult { + let o: ReplicaOp = serde_json::from_str(value.as_str()?) + .map_err(|_| rusqlite::types::FromSqlError::InvalidType)?; + Ok(o) + } +} + +/// Parses ReplicaOp stored as JSON in string column +impl ToSql for ReplicaOp { + fn to_sql(&self) -> rusqlite::Result> { + let s = serde_json::to_string(&self) + .map_err(|e| rusqlite::Error::ToSqlConversionFailure(Box::new(e)))?; + Ok(s.into()) + } +} + +/// SqliteStorage is an on-disk storage backed by SQLite3. +pub struct SqliteStorage { + con: Connection, +} + +impl SqliteStorage { + pub fn new>(directory: P) -> anyhow::Result { + // Ensure parent folder exists + std::fs::create_dir_all(&directory)?; + + // Open (or create) database + let db_file = directory.as_ref().join("taskchampion.sqlite3"); + let con = Connection::open(db_file)?; + + // Initialize database + let queries = vec![ + "CREATE TABLE IF NOT EXISTS operations (id INTEGER PRIMARY KEY AUTOINCREMENT, data STRING);", + "CREATE TABLE IF NOT EXISTS sync_meta (key STRING PRIMARY KEY, value STRING);", + "CREATE TABLE IF NOT EXISTS tasks (uuid STRING PRIMARY KEY, data STRING);", + "CREATE TABLE IF NOT EXISTS working_set (id INTEGER PRIMARY KEY, uuid STRING);", + ]; + for q in queries { + con.execute(q, []).context("Creating table")?; + } + + Ok(SqliteStorage { con }) + } +} + +struct Txn<'t> { + txn: Option>, +} + +impl<'t> Txn<'t> { + fn get_txn(&self) -> Result<&rusqlite::Transaction<'t>, SqliteError> { + self.txn + .as_ref() + .ok_or(SqliteError::TransactionAlreadyCommitted) + } + + fn get_next_working_set_number(&self) -> anyhow::Result { + let t = self.get_txn()?; + let next_id: Option = t + .query_row( + "SELECT COALESCE(MAX(id), 0) + 1 FROM working_set", + [], + |r| r.get(0), + ) + .optional() + .context("Getting highest working set ID")?; + + Ok(next_id.unwrap_or(0)) + } +} + +impl Storage for SqliteStorage { + fn txn<'a>(&'a mut self) -> anyhow::Result> { + let txn = self.con.transaction()?; + Ok(Box::new(Txn { txn: Some(txn) })) + } +} + +impl<'t> StorageTxn for Txn<'t> { + fn get_task(&mut self, uuid: Uuid) -> anyhow::Result> { + let t = self.get_txn()?; + let result: Option = t + .query_row( + "SELECT data FROM tasks WHERE uuid = ? LIMIT 1", + [&StoredUuid(uuid)], + |r| r.get("data"), + ) + .optional()?; + + // Get task from "stored" wrapper + Ok(result.map(|t| t.0)) + } + + fn create_task(&mut self, uuid: Uuid) -> anyhow::Result { + let t = self.get_txn()?; + let count: usize = t.query_row( + "SELECT count(uuid) FROM tasks WHERE uuid = ?", + [&StoredUuid(uuid)], + |x| x.get(0), + )?; + if count > 0 { + return Ok(false); + } + + let data = TaskMap::default(); + t.execute( + "INSERT INTO tasks (uuid, data) VALUES (?, ?)", + params![&StoredUuid(uuid), &StoredTaskMap(data)], + ) + .context("Create task query")?; + Ok(true) + } + + fn set_task(&mut self, uuid: Uuid, task: TaskMap) -> anyhow::Result<()> { + let t = self.get_txn()?; + t.execute( + "INSERT OR REPLACE INTO tasks (uuid, data) VALUES (?, ?)", + params![&StoredUuid(uuid), &StoredTaskMap(task)], + ) + .context("Update task query")?; + Ok(()) + } + + fn delete_task(&mut self, uuid: Uuid) -> anyhow::Result { + let t = self.get_txn()?; + let changed = t + .execute("DELETE FROM tasks WHERE uuid = ?", [&StoredUuid(uuid)]) + .context("Delete task query")?; + Ok(changed > 0) + } + + fn all_tasks(&mut self) -> anyhow::Result> { + let t = self.get_txn()?; + + let mut q = t.prepare("SELECT uuid, data FROM tasks")?; + let rows = q.query_map([], |r| { + let uuid: StoredUuid = r.get("uuid")?; + let data: StoredTaskMap = r.get("data")?; + Ok((uuid.0, data.0)) + })?; + + let mut ret = vec![]; + for r in rows { + ret.push(r?); + } + Ok(ret) + } + + fn all_task_uuids(&mut self) -> anyhow::Result> { + let t = self.get_txn()?; + + let mut q = t.prepare("SELECT uuid FROM tasks")?; + let rows = q.query_map([], |r| { + let uuid: StoredUuid = r.get("uuid")?; + Ok(uuid.0) + })?; + + let mut ret = vec![]; + for r in rows { + ret.push(r?); + } + Ok(ret) + } + + fn base_version(&mut self) -> anyhow::Result { + let t = self.get_txn()?; + + let version: Option = t + .query_row( + "SELECT value FROM sync_meta WHERE key = 'base_version'", + [], + |r| r.get("value"), + ) + .optional()?; + Ok(version.map(|u| u.0).unwrap_or(DEFAULT_BASE_VERSION)) + } + + fn set_base_version(&mut self, version: VersionId) -> anyhow::Result<()> { + let t = self.get_txn()?; + t.execute( + "INSERT OR REPLACE INTO sync_meta (key, value) VALUES (?, ?)", + params!["base_version", &StoredUuid(version)], + ) + .context("Set base version")?; + Ok(()) + } + + fn operations(&mut self) -> anyhow::Result> { + let t = self.get_txn()?; + + let mut q = t.prepare("SELECT data FROM operations ORDER BY id ASC")?; + let rows = q.query_map([], |r| { + let data: ReplicaOp = r.get("data")?; + Ok(data) + })?; + + let mut ret = vec![]; + for r in rows { + ret.push(r?); + } + Ok(ret) + } + + fn num_operations(&mut self) -> anyhow::Result { + let t = self.get_txn()?; + let count: usize = t.query_row("SELECT count(*) FROM operations", [], |x| x.get(0))?; + Ok(count) + } + + fn add_operation(&mut self, op: ReplicaOp) -> anyhow::Result<()> { + let t = self.get_txn()?; + + t.execute("INSERT INTO operations (data) VALUES (?)", params![&op]) + .context("Add operation query")?; + Ok(()) + } + + fn set_operations(&mut self, ops: Vec) -> anyhow::Result<()> { + let t = self.get_txn()?; + t.execute("DELETE FROM operations", []) + .context("Clear all existing operations")?; + t.execute("DELETE FROM sqlite_sequence WHERE name = 'operations'", []) + .context("Clear all existing operations")?; + + for o in ops { + self.add_operation(o)?; + } + Ok(()) + } + + fn get_working_set(&mut self) -> anyhow::Result>> { + let t = self.get_txn()?; + + let mut q = t.prepare("SELECT id, uuid FROM working_set ORDER BY id ASC")?; + let rows = q + .query_map([], |r| { + let id: usize = r.get("id")?; + let uuid: StoredUuid = r.get("uuid")?; + Ok((id, uuid.0)) + }) + .context("Get working set query")?; + + let rows: Vec> = rows.collect(); + let mut res = Vec::with_capacity(rows.len()); + for _ in 0..self + .get_next_working_set_number() + .context("Getting working set number")? + { + res.push(None); + } + for r in rows { + let (id, uuid) = r?; + res[id as usize] = Some(uuid); + } + + Ok(res) + } + + fn add_to_working_set(&mut self, uuid: Uuid) -> anyhow::Result { + let t = self.get_txn()?; + + let next_working_id = self.get_next_working_set_number()?; + + t.execute( + "INSERT INTO working_set (id, uuid) VALUES (?, ?)", + params![next_working_id, &StoredUuid(uuid)], + ) + .context("Create task query")?; + + Ok(next_working_id) + } + + fn set_working_set_item(&mut self, index: usize, uuid: Option) -> anyhow::Result<()> { + let t = self.get_txn()?; + match uuid { + // Add or override item + Some(uuid) => t.execute( + "INSERT OR REPLACE INTO working_set (id, uuid) VALUES (?, ?)", + params![index, &StoredUuid(uuid)], + ), + // Setting to None removes the row from database + None => t.execute("DELETE FROM working_set WHERE id = ?", [index]), + } + .context("Set working set item query")?; + Ok(()) + } + + fn clear_working_set(&mut self) -> anyhow::Result<()> { + let t = self.get_txn()?; + t.execute("DELETE FROM working_set", []) + .context("Clear working set query")?; + Ok(()) + } + + fn commit(&mut self) -> anyhow::Result<()> { + let t = self + .txn + .take() + .ok_or(SqliteError::TransactionAlreadyCommitted)?; + t.commit().context("Committing transaction")?; + Ok(()) + } +} + +#[cfg(test)] +mod test { + use super::*; + use crate::storage::taskmap_with; + use pretty_assertions::assert_eq; + use tempfile::TempDir; + + #[test] + fn test_empty_dir() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let non_existant = tmp_dir.path().join("subdir"); + let mut storage = SqliteStorage::new(&non_existant)?; + let uuid = Uuid::new_v4(); + { + let mut txn = storage.txn()?; + assert!(txn.create_task(uuid)?); + txn.commit()?; + } + { + let mut txn = storage.txn()?; + let task = txn.get_task(uuid)?; + assert_eq!(task, Some(taskmap_with(vec![]))); + } + Ok(()) + } + + #[test] + fn drop_transaction() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid1 = Uuid::new_v4(); + let uuid2 = Uuid::new_v4(); + + { + let mut txn = storage.txn()?; + assert!(txn.create_task(uuid1)?); + txn.commit()?; + } + + { + let mut txn = storage.txn()?; + assert!(txn.create_task(uuid2)?); + std::mem::drop(txn); // Unnecessary explicit drop of transaction + } + + { + let mut txn = storage.txn()?; + let uuids = txn.all_task_uuids()?; + + assert_eq!(uuids, [uuid1]); + } + + Ok(()) + } + + #[test] + fn test_create() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid = Uuid::new_v4(); + { + let mut txn = storage.txn()?; + assert!(txn.create_task(uuid)?); + txn.commit()?; + } + { + let mut txn = storage.txn()?; + let task = txn.get_task(uuid)?; + assert_eq!(task, Some(taskmap_with(vec![]))); + } + Ok(()) + } + + #[test] + fn test_create_exists() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid = Uuid::new_v4(); + { + let mut txn = storage.txn()?; + assert!(txn.create_task(uuid)?); + txn.commit()?; + } + { + let mut txn = storage.txn()?; + assert!(!txn.create_task(uuid)?); + txn.commit()?; + } + Ok(()) + } + + #[test] + fn test_get_missing() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid = Uuid::new_v4(); + { + let mut txn = storage.txn()?; + let task = txn.get_task(uuid)?; + assert_eq!(task, None); + } + Ok(()) + } + + #[test] + fn test_set_task() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid = Uuid::new_v4(); + { + let mut txn = storage.txn()?; + txn.set_task(uuid, taskmap_with(vec![("k".to_string(), "v".to_string())]))?; + txn.commit()?; + } + { + let mut txn = storage.txn()?; + let task = txn.get_task(uuid)?; + assert_eq!( + task, + Some(taskmap_with(vec![("k".to_string(), "v".to_string())])) + ); + } + Ok(()) + } + + #[test] + fn test_delete_task_missing() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid = Uuid::new_v4(); + { + let mut txn = storage.txn()?; + assert!(!txn.delete_task(uuid)?); + } + Ok(()) + } + + #[test] + fn test_delete_task_exists() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid = Uuid::new_v4(); + { + let mut txn = storage.txn()?; + assert!(txn.create_task(uuid)?); + txn.commit()?; + } + { + let mut txn = storage.txn()?; + assert!(txn.delete_task(uuid)?); + } + Ok(()) + } + + #[test] + fn test_all_tasks_empty() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + { + let mut txn = storage.txn()?; + let tasks = txn.all_tasks()?; + assert_eq!(tasks, vec![]); + } + Ok(()) + } + + #[test] + fn test_all_tasks_and_uuids() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid1 = Uuid::new_v4(); + let uuid2 = Uuid::new_v4(); + { + let mut txn = storage.txn()?; + assert!(txn.create_task(uuid1.clone())?); + txn.set_task( + uuid1.clone(), + taskmap_with(vec![("num".to_string(), "1".to_string())]), + )?; + assert!(txn.create_task(uuid2.clone())?); + txn.set_task( + uuid2.clone(), + taskmap_with(vec![("num".to_string(), "2".to_string())]), + )?; + txn.commit()?; + } + { + let mut txn = storage.txn()?; + let mut tasks = txn.all_tasks()?; + + // order is nondeterministic, so sort by uuid + tasks.sort_by(|a, b| a.0.cmp(&b.0)); + + let mut exp = vec![ + ( + uuid1.clone(), + taskmap_with(vec![("num".to_string(), "1".to_string())]), + ), + ( + uuid2.clone(), + taskmap_with(vec![("num".to_string(), "2".to_string())]), + ), + ]; + exp.sort_by(|a, b| a.0.cmp(&b.0)); + + assert_eq!(tasks, exp); + } + { + let mut txn = storage.txn()?; + let mut uuids = txn.all_task_uuids()?; + uuids.sort(); + + let mut exp = vec![uuid1.clone(), uuid2.clone()]; + exp.sort(); + + assert_eq!(uuids, exp); + } + Ok(()) + } + + #[test] + fn test_base_version_default() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + { + let mut txn = storage.txn()?; + assert_eq!(txn.base_version()?, DEFAULT_BASE_VERSION); + } + Ok(()) + } + + #[test] + fn test_base_version_setting() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let u = Uuid::new_v4(); + { + let mut txn = storage.txn()?; + txn.set_base_version(u)?; + txn.commit()?; + } + { + let mut txn = storage.txn()?; + assert_eq!(txn.base_version()?, u); + } + Ok(()) + } + + #[test] + fn test_operations() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid1 = Uuid::new_v4(); + let uuid2 = Uuid::new_v4(); + let uuid3 = Uuid::new_v4(); + + // create some operations + { + let mut txn = storage.txn()?; + txn.add_operation(ReplicaOp::Create { uuid: uuid1 })?; + txn.add_operation(ReplicaOp::Create { uuid: uuid2 })?; + txn.commit()?; + } + + // read them back + { + let mut txn = storage.txn()?; + let ops = txn.operations()?; + assert_eq!( + ops, + vec![ + ReplicaOp::Create { uuid: uuid1 }, + ReplicaOp::Create { uuid: uuid2 }, + ] + ); + + assert_eq!(txn.num_operations()?, 2); + } + + // set them to a different bunch + { + let mut txn = storage.txn()?; + txn.set_operations(vec![ + ReplicaOp::Delete { + uuid: uuid2, + old_task: TaskMap::new(), + }, + ReplicaOp::Delete { + uuid: uuid1, + old_task: TaskMap::new(), + }, + ])?; + txn.commit()?; + } + + // create some more operations (to test adding operations after clearing) + { + let mut txn = storage.txn()?; + txn.add_operation(ReplicaOp::Create { uuid: uuid3 })?; + txn.add_operation(ReplicaOp::Delete { + uuid: uuid3, + old_task: TaskMap::new(), + })?; + txn.commit()?; + } + + // read them back + { + let mut txn = storage.txn()?; + let ops = txn.operations()?; + assert_eq!( + ops, + vec![ + ReplicaOp::Delete { + uuid: uuid2, + old_task: TaskMap::new() + }, + ReplicaOp::Delete { + uuid: uuid1, + old_task: TaskMap::new() + }, + ReplicaOp::Create { uuid: uuid3 }, + ReplicaOp::Delete { + uuid: uuid3, + old_task: TaskMap::new() + }, + ] + ); + assert_eq!(txn.num_operations()?, 4); + } + Ok(()) + } + + #[test] + fn get_working_set_empty() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + + { + let mut txn = storage.txn()?; + let ws = txn.get_working_set()?; + assert_eq!(ws, vec![None]); + } + + Ok(()) + } + + #[test] + fn add_to_working_set() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid1 = Uuid::new_v4(); + let uuid2 = Uuid::new_v4(); + + { + let mut txn = storage.txn()?; + txn.add_to_working_set(uuid1)?; + txn.add_to_working_set(uuid2)?; + txn.commit()?; + } + + { + let mut txn = storage.txn()?; + let ws = txn.get_working_set()?; + assert_eq!(ws, vec![None, Some(uuid1), Some(uuid2)]); + } + + Ok(()) + } + + #[test] + fn clear_working_set() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid1 = Uuid::new_v4(); + let uuid2 = Uuid::new_v4(); + + { + let mut txn = storage.txn()?; + txn.add_to_working_set(uuid1)?; + txn.add_to_working_set(uuid2)?; + txn.commit()?; + } + + { + let mut txn = storage.txn()?; + txn.clear_working_set()?; + txn.add_to_working_set(uuid2)?; + txn.add_to_working_set(uuid1)?; + txn.commit()?; + } + + { + let mut txn = storage.txn()?; + let ws = txn.get_working_set()?; + assert_eq!(ws, vec![None, Some(uuid2), Some(uuid1)]); + } + + Ok(()) + } + + #[test] + fn set_working_set_item() -> anyhow::Result<()> { + let tmp_dir = TempDir::new()?; + let mut storage = SqliteStorage::new(&tmp_dir.path())?; + let uuid1 = Uuid::new_v4(); + let uuid2 = Uuid::new_v4(); + + { + let mut txn = storage.txn()?; + txn.add_to_working_set(uuid1)?; + txn.add_to_working_set(uuid2)?; + txn.commit()?; + } + + { + let mut txn = storage.txn()?; + let ws = txn.get_working_set()?; + assert_eq!(ws, vec![None, Some(uuid1), Some(uuid2)]); + } + + // Clear one item + { + let mut txn = storage.txn()?; + txn.set_working_set_item(1, None)?; + txn.commit()?; + } + + { + let mut txn = storage.txn()?; + let ws = txn.get_working_set()?; + assert_eq!(ws, vec![None, None, Some(uuid2)]); + } + + // Override item + { + let mut txn = storage.txn()?; + txn.set_working_set_item(2, Some(uuid1))?; + txn.commit()?; + } + + { + let mut txn = storage.txn()?; + let ws = txn.get_working_set()?; + assert_eq!(ws, vec![None, None, Some(uuid1)]); + } + + Ok(()) + } +} diff --git a/rust/taskchampion/src/task/annotation.rs b/rust/taskchampion/src/task/annotation.rs new file mode 100644 index 000000000..951dc3f11 --- /dev/null +++ b/rust/taskchampion/src/task/annotation.rs @@ -0,0 +1,10 @@ +use super::Timestamp; + +/// An annotation for a task +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Annotation { + /// Time the annotation was made + pub entry: Timestamp, + /// Content of the annotation + pub description: String, +} diff --git a/rust/taskchampion/src/task/mod.rs b/rust/taskchampion/src/task/mod.rs new file mode 100644 index 000000000..2e95b2a09 --- /dev/null +++ b/rust/taskchampion/src/task/mod.rs @@ -0,0 +1,14 @@ +#![allow(clippy::module_inception)] +use chrono::prelude::*; + +mod annotation; +mod status; +mod tag; +mod task; + +pub use annotation::Annotation; +pub use status::Status; +pub use tag::Tag; +pub use task::{Task, TaskMut}; + +pub type Timestamp = DateTime; diff --git a/rust/taskchampion/src/task/status.rs b/rust/taskchampion/src/task/status.rs new file mode 100644 index 000000000..31fee9cf7 --- /dev/null +++ b/rust/taskchampion/src/task/status.rs @@ -0,0 +1,69 @@ +/// The status of a task, as defined by the task data model. +#[derive(Debug, PartialEq, Clone, strum_macros::Display)] +#[repr(C)] +pub enum Status { + Pending, + Completed, + Deleted, + /// Unknown signifies a status in the task DB that was not + /// recognized. This supports forward-compatibility if a + /// new status is added. Tasks with unknown status should + /// be ignored (but not deleted). + Unknown(String), +} + +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 { + "pending" => Status::Pending, + "completed" => Status::Completed, + "deleted" => Status::Deleted, + v => Status::Unknown(v.to_string()), + } + } + + /// Get the 1-character value for this status to use in the TaskMap. + pub(crate) fn to_taskmap(&self) -> &str { + match self { + Status::Pending => "pending", + Status::Completed => "completed", + Status::Deleted => "deleted", + Status::Unknown(v) => v.as_ref(), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn to_taskmap() { + assert_eq!(Status::Pending.to_taskmap(), "pending"); + assert_eq!(Status::Completed.to_taskmap(), "completed"); + assert_eq!(Status::Deleted.to_taskmap(), "deleted"); + assert_eq!(Status::Unknown("wishful".into()).to_taskmap(), "wishful"); + } + + #[test] + fn from_taskmap() { + assert_eq!(Status::from_taskmap("pending"), Status::Pending); + assert_eq!(Status::from_taskmap("completed"), Status::Completed); + assert_eq!(Status::from_taskmap("deleted"), Status::Deleted); + assert_eq!( + Status::from_taskmap("something-else"), + Status::Unknown("something-else".into()) + ); + } + + #[test] + fn display() { + assert_eq!(format!("{}", Status::Pending), "Pending"); + assert_eq!(format!("{}", Status::Completed), "Completed"); + assert_eq!(format!("{}", Status::Deleted), "Deleted"); + assert_eq!(format!("{}", Status::Unknown("wishful".into())), "Unknown"); + } +} diff --git a/rust/taskchampion/src/task/tag.rs b/rust/taskchampion/src/task/tag.rs new file mode 100644 index 000000000..a4f1e677c --- /dev/null +++ b/rust/taskchampion/src/task/tag.rs @@ -0,0 +1,174 @@ +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 `+-*/(<>^! %=~`. +/// 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), +} + +// see doc comment for Tag, above +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, + Blocked, + Unblocked, + Blocking, +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + 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/rust/taskchampion/src/task/task.rs b/rust/taskchampion/src/task/task.rs new file mode 100644 index 000000000..a3f4384ac --- /dev/null +++ b/rust/taskchampion/src/task/task.rs @@ -0,0 +1,1161 @@ +use super::tag::{SyntheticTag, TagInner}; +use super::{Annotation, Status, Tag, Timestamp}; +use crate::depmap::DependencyMap; +use crate::replica::Replica; +use crate::storage::TaskMap; +use chrono::prelude::*; +use log::trace; +use std::convert::AsRef; +use std::convert::TryInto; +use std::rc::Rc; +use std::str::FromStr; +use uuid::Uuid; + +/* The Task and TaskMut classes wrap the underlying [`TaskMap`], which is a simple key/value map. + * They provide semantic meaning to that TaskMap according to the TaskChampion data model. For + * example, [`get_status`](Task::get_status) and [`set_status`](TaskMut::set_status) translate from + * strings in the TaskMap to [`Status`]. + * + * The same approach applies for more complex data such as dependencies or annotations. Users of + * this API should only need the [`get_taskmap`](Task::get_taskmap) method for debugging purposes, + * and should never need to make changes to the TaskMap directly. + */ + +/// 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 +/// protected by the atomicity of the backend storage. Concurrent modifications are safe, +/// but a Task that is cached for more than a few seconds may cause the user to see stale +/// data. Fetch, use, and drop Tasks quickly. +/// +/// This struct contains only getters for various values on the task. The +/// [`into_mut`](Task::into_mut) method +/// returns a TaskMut which can be used to modify the task. +#[derive(Debug, Clone)] +pub struct Task { + uuid: Uuid, + taskmap: TaskMap, + depmap: Rc, +} + +impl PartialEq for Task { + fn eq(&self, other: &Task) -> bool { + // compare only the taskmap and uuid; depmap is just present for reference + self.uuid == other.uuid && self.taskmap == other.taskmap + } +} + +/// A mutable task, with setter methods. +/// +/// Most methods are simple setters and not further described. Calling a setter will update the +/// referenced Replica, as well as the included Task, immediately. +/// +/// The [`Task`] methods are available on [`TaskMut`] via [`Deref`](std::ops::Deref). +pub struct TaskMut<'r> { + task: Task, + replica: &'r mut Replica, + updated_modified: bool, +} + +/// An enum containing all of the key names defined in the data model, with the exception +/// of the properties containing data (`tag_..`, etc.) +#[derive(strum_macros::AsRefStr, strum_macros::EnumString)] +#[strum(serialize_all = "kebab-case")] +enum Prop { + Description, + Modified, + Start, + Status, + Priority, + Wait, + End, + Entry, +} + +#[allow(clippy::ptr_arg)] +fn uda_string_to_tuple(key: &str) -> (&str, &str) { + let mut iter = key.splitn(2, '.'); + let first = iter.next().unwrap(); + let second = iter.next(); + if let Some(second) = second { + (first, second) + } else { + ("", first) + } +} + +fn uda_tuple_to_string(namespace: impl AsRef, key: impl AsRef) -> String { + let namespace = namespace.as_ref(); + let key = key.as_ref(); + if namespace.is_empty() { + key.into() + } else { + format!("{}.{}", namespace, key) + } +} + +impl Task { + pub(crate) fn new(uuid: Uuid, taskmap: TaskMap, depmap: Rc) -> Task { + Task { + uuid, + taskmap, + depmap, + } + } + + pub fn get_uuid(&self) -> Uuid { + self.uuid + } + + pub fn get_taskmap(&self) -> &TaskMap { + &self.taskmap + } + + /// Prepare to mutate this task, requiring a mutable Replica + /// in order to update the data it contains. + pub fn into_mut(self, replica: &mut Replica) -> TaskMut { + TaskMut { + task: self, + replica, + updated_modified: false, + } + } + + pub fn get_status(&self) -> Status { + self.taskmap + .get(Prop::Status.as_ref()) + .map(|s| Status::from_taskmap(s)) + .unwrap_or(Status::Pending) + } + + pub fn get_description(&self) -> &str { + self.taskmap + .get(Prop::Description.as_ref()) + .map(|s| s.as_ref()) + .unwrap_or("") + } + + pub fn get_entry(&self) -> Option> { + self.get_timestamp(Prop::Entry.as_ref()) + } + + pub fn get_priority(&self) -> &str { + self.taskmap + .get(Prop::Priority.as_ref()) + .map(|s| s.as_ref()) + .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(Prop::Wait.as_ref()) + } + + /// 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 { + self.taskmap.contains_key(Prop::Start.as_ref()) + } + + /// Determine whether this task is blocked -- that is, has at least one unresolved dependency. + pub fn is_blocked(&self) -> bool { + self.depmap.dependencies(self.uuid).next().is_some() + } + + /// Determine whether this task is blocking -- that is, has at least one unresolved dependent. + pub fn is_blocking(&self) -> bool { + self.depmap.dependents(self.uuid).next().is_some() + } + + /// 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, + SyntheticTag::Blocked => self.is_blocked(), + SyntheticTag::Unblocked => !self.is_blocked(), + SyntheticTag::Blocking => self.is_blocking(), + } + } + + /// Check if this task has the given tag + pub fn has_tag(&self, tag: &Tag) -> bool { + 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 + '_ { + 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 + } + None + }) + .chain( + SyntheticTag::iter() + .filter(move |st| self.has_synthetic_tag(st)) + .map(|st| Tag::from_inner(TagInner::Synthetic(st))), + ) + } + + /// Iterate over the task's annotations, in arbitrary order. + pub fn get_annotations(&self) -> impl Iterator + '_ { + self.taskmap.iter().filter_map(|(k, v)| { + if let Some(ts) = k.strip_prefix("annotation_") { + if let Ok(ts) = ts.parse::() { + return Some(Annotation { + entry: Utc.timestamp(ts, 0), + description: v.to_owned(), + }); + } + // note that invalid "annotation_*" are ignored + } + None + }) + } + + /// Get the named user defined attributes (UDA). This will return None + /// for any key defined in the Task data model, regardless of whether + /// it is set or not. + pub fn get_uda(&self, namespace: &str, key: &str) -> Option<&str> { + self.get_legacy_uda(uda_tuple_to_string(namespace, key).as_ref()) + } + + /// Get the user defined attributes (UDAs) of this task, in arbitrary order. Each key is split + /// on the first `.` character. Legacy keys that do not contain `.` are represented as `("", + /// key)`. + pub fn get_udas(&self) -> impl Iterator + '_ { + self.taskmap + .iter() + .filter(|(k, _)| !Task::is_known_key(k)) + .map(|(k, v)| (uda_string_to_tuple(k), v.as_ref())) + } + + /// Get the named user defined attribute (UDA) in a legacy format. This will return None for + /// any key defined in the Task data model, regardless of whether it is set or not. + pub fn get_legacy_uda(&self, key: &str) -> Option<&str> { + if Task::is_known_key(key) { + return None; + } + self.taskmap.get(key).map(|s| s.as_ref()) + } + + /// Like `get_udas`, but returning each UDA key as a single string. + pub fn get_legacy_udas(&self) -> impl Iterator + '_ { + self.taskmap + .iter() + .filter(|(p, _)| !Task::is_known_key(p)) + .map(|(p, v)| (p.as_ref(), v.as_ref())) + } + + /// Get the modification time for this task. + pub fn get_modified(&self) -> Option> { + self.get_timestamp(Prop::Modified.as_ref()) + } + + /// Get the UUIDs of tasks on which this task depends. + /// + /// This includes all dependencies, regardless of their status. In fact, it may include + /// dependencies that do not exist. + pub fn get_dependencies(&self) -> impl Iterator + '_ { + self.taskmap.iter().filter_map(|(p, _)| { + if let Some(dep_str) = p.strip_prefix("dep_") { + if let Ok(u) = Uuid::parse_str(dep_str) { + return Some(u); + } + // (un-parseable dep_.. properties are ignored) + } + None + }) + } + + // -- utility functions + + fn is_known_key(key: &str) -> bool { + Prop::from_str(key).is_ok() + || key.starts_with("tag_") + || key.starts_with("annotation_") + || key.starts_with("dep_") + } + + fn get_timestamp(&self, property: &str) -> Option> { + if let Some(ts) = self.taskmap.get(property) { + if let Ok(ts) = ts.parse() { + return Some(Utc.timestamp(ts, 0)); + } + // if the value does not parse as an integer, default to None + } + None + } +} + +impl<'r> TaskMut<'r> { + /// Get the immutable version of this object, ending the exclusive reference to the Replica. + pub fn into_immut(self) -> Task { + self.task + } + + /// Set the task's status. This also adds the task to the working set if the + /// new status puts it in that set. + pub fn set_status(&mut self, status: Status) -> anyhow::Result<()> { + match status { + Status::Pending => { + // clear "end" when a task becomes "pending" + if self.taskmap.contains_key(Prop::End.as_ref()) { + self.set_timestamp(Prop::End.as_ref(), None)?; + } + let uuid = self.uuid; + self.replica.add_to_working_set(uuid)?; + } + Status::Completed | Status::Deleted => { + // set "end" when a task is deleted or completed + if !self.taskmap.contains_key(Prop::End.as_ref()) { + self.set_timestamp(Prop::End.as_ref(), Some(Utc::now()))?; + } + } + _ => {} + } + self.set_string( + Prop::Status.as_ref(), + Some(String::from(status.to_taskmap())), + ) + } + + pub fn set_description(&mut self, description: String) -> anyhow::Result<()> { + self.set_string(Prop::Description.as_ref(), Some(description)) + } + + pub fn set_priority(&mut self, priority: String) -> anyhow::Result<()> { + self.set_string(Prop::Priority.as_ref(), Some(priority)) + } + + pub fn set_entry(&mut self, entry: Option>) -> anyhow::Result<()> { + self.set_timestamp(Prop::Entry.as_ref(), entry) + } + + pub fn set_wait(&mut self, wait: Option>) -> anyhow::Result<()> { + self.set_timestamp(Prop::Wait.as_ref(), wait) + } + + pub fn set_modified(&mut self, modified: DateTime) -> anyhow::Result<()> { + self.set_timestamp(Prop::Modified.as_ref(), Some(modified)) + } + + /// Start the task by creating "start": "", if the task is not already + /// active. + pub fn start(&mut self) -> anyhow::Result<()> { + if self.is_active() { + return Ok(()); + } + self.set_timestamp(Prop::Start.as_ref(), Some(Utc::now())) + } + + /// Stop the task by removing the `start` key + pub fn stop(&mut self) -> anyhow::Result<()> { + self.set_timestamp(Prop::Start.as_ref(), None) + } + + /// Mark this task as complete + pub fn done(&mut self) -> anyhow::Result<()> { + self.set_status(Status::Completed) + } + + /// Mark this task as deleted. + /// + /// Note that this does not delete the task. It merely marks the task as + /// deleted. + pub fn delete(&mut self) -> anyhow::Result<()> { + self.set_status(Status::Deleted) + } + + /// 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) + } + + /// Add a new annotation. Note that annotations with the same entry time + /// will overwrite one another. + pub fn add_annotation(&mut self, ann: Annotation) -> anyhow::Result<()> { + self.set_string( + format!("annotation_{}", ann.entry.timestamp()), + Some(ann.description), + ) + } + + /// Remove an annotation, based on its entry time. + pub fn remove_annotation(&mut self, entry: Timestamp) -> anyhow::Result<()> { + self.set_string(format!("annotation_{}", entry.timestamp()), None) + } + + /// Set a user-defined attribute (UDA). This will fail if the key is defined by the data + /// model. + pub fn set_uda( + &mut self, + namespace: impl AsRef, + key: impl AsRef, + value: impl Into, + ) -> anyhow::Result<()> { + let key = uda_tuple_to_string(namespace, key); + self.set_legacy_uda(key, value) + } + + /// Remove a user-defined attribute (UDA). This will fail if the key is defined by the data + /// model. + pub fn remove_uda( + &mut self, + namespace: impl AsRef, + key: impl AsRef, + ) -> anyhow::Result<()> { + let key = uda_tuple_to_string(namespace, key); + self.remove_legacy_uda(key) + } + + /// Set a user-defined attribute (UDA), where the key is a legacy key. + pub fn set_legacy_uda( + &mut self, + key: impl Into, + value: impl Into, + ) -> anyhow::Result<()> { + let key = key.into(); + if Task::is_known_key(&key) { + anyhow::bail!( + "Property name {} as special meaning in a task and cannot be used as a UDA", + key + ); + } + self.set_string(key, Some(value.into())) + } + + /// Remove a user-defined attribute (UDA), where the key is a legacy key. + pub fn remove_legacy_uda(&mut self, key: impl Into) -> anyhow::Result<()> { + let key = key.into(); + if Task::is_known_key(&key) { + anyhow::bail!( + "Property name {} as special meaning in a task and cannot be used as a UDA", + key + ); + } + self.set_string(key, None) + } + + /// Add a dependency. + pub fn add_dependency(&mut self, dep: Uuid) -> anyhow::Result<()> { + let key = format!("dep_{}", dep); + self.set_string(key, Some("".to_string())) + } + + /// Remove a dependency. + pub fn remove_dependency(&mut self, dep: Uuid) -> anyhow::Result<()> { + let key = format!("dep_{}", dep); + self.set_string(key, None) + } + + // -- utility functions + + fn update_modified(&mut self) -> anyhow::Result<()> { + if !self.updated_modified { + let now = format!("{}", Utc::now().timestamp()); + trace!("task {}: set property modified={:?}", self.task.uuid, now); + self.task.taskmap = + self.replica + .update_task(self.task.uuid, Prop::Modified.as_ref(), Some(now))?; + self.updated_modified = true; + } + Ok(()) + } + + fn set_string>( + &mut self, + property: S, + value: Option, + ) -> anyhow::Result<()> { + let property = property.into(); + // updated the modified timestamp unless we are setting it explicitly + if &property != "modified" { + self.update_modified()?; + } + + if let Some(ref v) = value { + trace!("task {}: set property {}={:?}", self.task.uuid, property, v); + } else { + trace!("task {}: remove property {}", self.task.uuid, property); + } + + self.task.taskmap = self + .replica + .update_task(self.task.uuid, &property, value.as_ref())?; + + Ok(()) + } + + fn set_timestamp( + &mut self, + property: &str, + value: Option>, + ) -> anyhow::Result<()> { + self.set_string(property, value.map(|v| v.timestamp().to_string())) + } + + /// Used by tests to ensure that updates are properly written + #[cfg(test)] + fn reload(&mut self) -> anyhow::Result<()> { + let uuid = self.uuid; + let task = self.replica.get_task(uuid)?.unwrap(); + self.task.taskmap = task.taskmap; + Ok(()) + } +} + +impl<'r> std::ops::Deref for TaskMut<'r> { + type Target = Task; + + fn deref(&self) -> &Self::Target { + &self.task + } +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + use std::collections::HashSet; + + fn dm() -> Rc { + Rc::new(DependencyMap::new()) + } + + fn with_mut_task(f: F) { + let mut replica = Replica::new_inmemory(); + let task = replica.new_task(Status::Pending, "test".into()).unwrap(); + let task = task.into_mut(&mut replica); + f(task) + } + + /// Create a user tag, without checking its validity + fn utag(name: &'static str) -> Tag { + Tag::from_inner(TagInner::User(name.into())) + } + + /// Create a synthetic tag + fn stag(synth: SyntheticTag) -> Tag { + Tag::from_inner(TagInner::Synthetic(synth)) + } + + #[test] + fn test_is_active_never_started() { + let task = Task::new(Uuid::new_v4(), TaskMap::new(), dm()); + assert!(!task.is_active()); + } + + #[test] + fn test_is_active_active() { + let task = Task::new( + Uuid::new_v4(), + vec![(String::from("start"), String::from("1234"))] + .drain(..) + .collect(), + dm(), + ); + + assert!(task.is_active()); + } + + #[test] + fn test_is_active_inactive() { + let task = Task::new(Uuid::new_v4(), Default::default(), dm()); + assert!(!task.is_active()); + } + + #[test] + fn test_entry_not_set() { + let task = Task::new(Uuid::new_v4(), TaskMap::new(), dm()); + assert_eq!(task.get_entry(), None); + } + + #[test] + fn test_entry_set() { + let ts = Utc.ymd(1980, 1, 1).and_hms(0, 0, 0); + let task = Task::new( + Uuid::new_v4(), + vec![(String::from("entry"), format!("{}", ts.timestamp()))] + .drain(..) + .collect(), + dm(), + ); + assert_eq!(task.get_entry(), Some(ts)); + } + + #[test] + fn test_wait_not_set() { + let task = Task::new(Uuid::new_v4(), TaskMap::new(), dm()); + + 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("wait"), format!("{}", ts.timestamp()))] + .drain(..) + .collect(), + dm(), + ); + + 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(), + dm(), + ); + + 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"), String::from("1234")), + ] + .drain(..) + .collect(), + dm(), + ); + + 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] + fn test_get_tags() { + let task = Task::new( + Uuid::new_v4(), + 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(), + dm(), + ); + + let tags: HashSet<_> = task.get_tags().collect(); + let exp = set![ + utag("abc"), + utag("def"), + stag(SyntheticTag::Pending), + stag(SyntheticTag::Waiting), + stag(SyntheticTag::Unblocked), + ]; + assert_eq!(tags, exp); + } + + #[test] + fn test_get_tags_invalid_tags() { + let task = Task::new( + Uuid::new_v4(), + vec![ + (String::from("tag_ok"), String::from("")), + (String::from("tag_"), String::from("")), + (String::from("tag_123"), String::from("")), + (String::from("tag_a!!"), String::from("")), + ] + .drain(..) + .collect(), + dm(), + ); + + // only "ok" is OK + let tags: HashSet<_> = task.get_tags().collect(); + assert_eq!( + tags, + set![ + utag("ok"), + stag(SyntheticTag::Pending), + stag(SyntheticTag::Unblocked) + ] + ); + } + + #[test] + fn test_get_priority_default() { + let task = Task::new(Uuid::new_v4(), TaskMap::new(), dm()); + assert_eq!(task.get_priority(), ""); + } + + #[test] + fn test_get_annotations() { + let task = Task::new( + Uuid::new_v4(), + vec![ + ( + String::from("annotation_1635301873"), + String::from("left message"), + ), + ( + String::from("annotation_1635301883"), + String::from("left another message"), + ), + (String::from("annotation_"), String::from("invalid")), + (String::from("annotation_abcde"), String::from("invalid")), + ] + .drain(..) + .collect(), + dm(), + ); + + let mut anns: Vec<_> = task.get_annotations().collect(); + anns.sort(); + assert_eq!( + anns, + vec![ + Annotation { + entry: Utc.timestamp(1635301873, 0), + description: "left message".into() + }, + Annotation { + entry: Utc.timestamp(1635301883, 0), + description: "left another message".into() + } + ] + ); + } + + #[test] + fn test_add_annotation() { + with_mut_task(|mut task| { + task.add_annotation(Annotation { + entry: Utc.timestamp(1635301900, 0), + description: "right message".into(), + }) + .unwrap(); + let k = "annotation_1635301900"; + assert_eq!(task.taskmap[k], "right message".to_owned()); + task.reload().unwrap(); + assert_eq!(task.taskmap[k], "right message".to_owned()); + // adding with same time overwrites.. + task.add_annotation(Annotation { + entry: Utc.timestamp(1635301900, 0), + description: "right message 2".into(), + }) + .unwrap(); + assert_eq!(task.taskmap[k], "right message 2".to_owned()); + }); + } + + #[test] + fn test_remove_annotation() { + with_mut_task(|mut task| { + task.set_string("annotation_1635301873", Some("left message".into())) + .unwrap(); + task.set_string("annotation_1635301883", Some("left another message".into())) + .unwrap(); + + task.remove_annotation(Utc.timestamp(1635301873, 0)) + .unwrap(); + + task.reload().unwrap(); + + let mut anns: Vec<_> = task.get_annotations().collect(); + anns.sort(); + assert_eq!( + anns, + vec![Annotation { + entry: Utc.timestamp(1635301883, 0), + description: "left another message".into() + }] + ); + }); + } + + #[test] + fn test_set_get_priority() { + with_mut_task(|mut task| { + assert_eq!(task.get_priority(), ""); + task.set_priority("H".into()).unwrap(); + assert_eq!(task.get_priority(), "H"); + }); + } + + #[test] + fn test_set_status_pending() { + with_mut_task(|mut task| { + task.done().unwrap(); + + task.set_status(Status::Pending).unwrap(); + assert_eq!(task.get_status(), Status::Pending); + assert!(!task.taskmap.contains_key("end")); + assert!(task.has_tag(&stag(SyntheticTag::Pending))); + assert!(!task.has_tag(&stag(SyntheticTag::Completed))); + }); + } + + #[test] + fn test_set_status_completed() { + with_mut_task(|mut task| { + task.set_status(Status::Completed).unwrap(); + assert_eq!(task.get_status(), Status::Completed); + assert!(task.taskmap.contains_key("end")); + assert!(!task.has_tag(&stag(SyntheticTag::Pending))); + assert!(task.has_tag(&stag(SyntheticTag::Completed))); + }); + } + + #[test] + fn test_set_status_deleted() { + with_mut_task(|mut task| { + task.set_status(Status::Deleted).unwrap(); + assert_eq!(task.get_status(), Status::Deleted); + assert!(task.taskmap.contains_key("end")); + assert!(!task.has_tag(&stag(SyntheticTag::Pending))); + assert!(!task.has_tag(&stag(SyntheticTag::Completed))); + }); + } + + #[test] + fn test_start() { + with_mut_task(|mut task| { + task.start().unwrap(); + assert!(task.taskmap.contains_key("start")); + + task.reload().unwrap(); + assert!(task.taskmap.contains_key("start")); + + // second start doesn't change anything.. + task.start().unwrap(); + assert!(task.taskmap.contains_key("start")); + + task.reload().unwrap(); + assert!(task.taskmap.contains_key("start")); + }); + } + + #[test] + fn test_stop() { + with_mut_task(|mut task| { + task.start().unwrap(); + task.stop().unwrap(); + assert!(!task.taskmap.contains_key("start")); + + task.reload().unwrap(); + assert!(!task.taskmap.contains_key("start")); + + // redundant call does nothing.. + task.stop().unwrap(); + assert!(!task.taskmap.contains_key("start")); + + task.reload().unwrap(); + assert!(!task.taskmap.contains_key("start")); + }); + } + + #[test] + fn test_done() { + with_mut_task(|mut task| { + task.done().unwrap(); + assert_eq!(task.get_status(), Status::Completed); + assert!(task.taskmap.contains_key("end")); + 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_delete() { + with_mut_task(|mut task| { + task.delete().unwrap(); + assert_eq!(task.get_status(), Status::Deleted); + assert!(task.taskmap.contains_key("end")); + assert!(!task.has_tag(&stag(SyntheticTag::Completed))); + + // redundant call does nothing.. + task.delete().unwrap(); + assert_eq!(task.get_status(), Status::Deleted); + assert!(!task.has_tag(&stag(SyntheticTag::Completed))); + }); + } + + #[test] + fn test_add_tags() { + with_mut_task(|mut task| { + 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(&utag("abc")).unwrap(); + assert!(task.taskmap.contains_key("tag_abc")); + }); + } + + #[test] + fn test_remove_tags() { + with_mut_task(|mut task| { + task.add_tag(&utag("abc")).unwrap(); + task.reload().unwrap(); + assert!(task.taskmap.contains_key("tag_abc")); + + task.remove_tag(&utag("abc")).unwrap(); + assert!(!task.taskmap.contains_key("tag_abc")); + // redundant remove has no effect.. + task.remove_tag(&utag("abc")).unwrap(); + assert!(!task.taskmap.contains_key("tag_abc")); + }); + } + + #[test] + fn test_get_udas() { + let task = Task::new( + Uuid::new_v4(), + vec![ + ("description".into(), "not a uda".into()), + ("modified".into(), "not a uda".into()), + ("start".into(), "not a uda".into()), + ("status".into(), "not a uda".into()), + ("wait".into(), "not a uda".into()), + ("start".into(), "not a uda".into()), + ("tag_abc".into(), "not a uda".into()), + ("dep_1234".into(), "not a uda".into()), + ("annotation_1234".into(), "not a uda".into()), + ("githubid".into(), "123".into()), + ("jira.url".into(), "h://x".into()), + ] + .drain(..) + .collect(), + dm(), + ); + + let mut udas: Vec<_> = task.get_udas().collect(); + udas.sort_unstable(); + assert_eq!( + udas, + vec![(("", "githubid"), "123"), (("jira", "url"), "h://x")] + ); + } + + #[test] + fn test_get_uda() { + let task = Task::new( + Uuid::new_v4(), + vec![ + ("description".into(), "not a uda".into()), + ("githubid".into(), "123".into()), + ("jira.url".into(), "h://x".into()), + ] + .drain(..) + .collect(), + dm(), + ); + + assert_eq!(task.get_uda("", "description"), None); // invalid UDA + assert_eq!(task.get_uda("", "githubid"), Some("123")); + assert_eq!(task.get_uda("jira", "url"), Some("h://x")); + assert_eq!(task.get_uda("bugzilla", "url"), None); + } + + #[test] + fn test_get_legacy_uda() { + let task = Task::new( + Uuid::new_v4(), + vec![ + ("description".into(), "not a uda".into()), + ("dep_1234".into(), "not a uda".into()), + ("githubid".into(), "123".into()), + ("jira.url".into(), "h://x".into()), + ] + .drain(..) + .collect(), + dm(), + ); + + assert_eq!(task.get_legacy_uda("description"), None); // invalid UDA + assert_eq!(task.get_legacy_uda("dep_1234"), None); // invalid UDA + assert_eq!(task.get_legacy_uda("githubid"), Some("123")); + assert_eq!(task.get_legacy_uda("jira.url"), Some("h://x")); + assert_eq!(task.get_legacy_uda("bugzilla.url"), None); + } + + #[test] + fn test_set_uda() { + with_mut_task(|mut task| { + task.set_uda("jira", "url", "h://y").unwrap(); + let udas: Vec<_> = task.get_udas().collect(); + assert_eq!(udas, vec![(("jira", "url"), "h://y")]); + + task.set_uda("", "jiraid", "TW-1234").unwrap(); + + let mut udas: Vec<_> = task.get_udas().collect(); + udas.sort_unstable(); + assert_eq!( + udas, + vec![(("", "jiraid"), "TW-1234"), (("jira", "url"), "h://y")] + ); + }) + } + + #[test] + fn test_set_legacy_uda() { + with_mut_task(|mut task| { + task.set_legacy_uda("jira.url", "h://y").unwrap(); + let udas: Vec<_> = task.get_udas().collect(); + assert_eq!(udas, vec![(("jira", "url"), "h://y")]); + + task.set_legacy_uda("jiraid", "TW-1234").unwrap(); + + let mut udas: Vec<_> = task.get_udas().collect(); + udas.sort_unstable(); + assert_eq!( + udas, + vec![(("", "jiraid"), "TW-1234"), (("jira", "url"), "h://y")] + ); + }) + } + + #[test] + fn test_set_uda_invalid() { + with_mut_task(|mut task| { + assert!(task.set_uda("", "modified", "123").is_err()); + assert!(task.set_uda("", "tag_abc", "123").is_err()); + assert!(task.set_legacy_uda("modified", "123").is_err()); + assert!(task.set_legacy_uda("tag_abc", "123").is_err()); + }) + } + + #[test] + fn test_remove_uda() { + with_mut_task(|mut task| { + task.set_string("github.id", Some("123".into())).unwrap(); + task.remove_uda("github", "id").unwrap(); + + let udas: Vec<_> = task.get_udas().collect(); + assert_eq!(udas, vec![]); + }) + } + + #[test] + fn test_remove_legacy_uda() { + with_mut_task(|mut task| { + task.set_string("githubid", Some("123".into())).unwrap(); + task.remove_legacy_uda("githubid").unwrap(); + + let udas: Vec<_> = task.get_udas().collect(); + assert_eq!(udas, vec![]); + }) + } + + #[test] + fn test_remove_uda_invalid() { + with_mut_task(|mut task| { + assert!(task.remove_uda("", "modified").is_err()); + assert!(task.remove_uda("", "tag_abc").is_err()); + assert!(task.remove_legacy_uda("modified").is_err()); + assert!(task.remove_legacy_uda("tag_abc").is_err()); + }) + } + + #[test] + fn test_dependencies() { + with_mut_task(|mut task| { + assert_eq!(task.get_dependencies().collect::>(), vec![]); + let dep1 = Uuid::new_v4(); + let dep2 = Uuid::new_v4(); + + task.add_dependency(dep1).unwrap(); + assert_eq!(task.get_dependencies().collect::>(), vec![dep1]); + + task.add_dependency(dep1).unwrap(); // add twice is ok + task.add_dependency(dep2).unwrap(); + let deps = task.get_dependencies().collect::>(); + assert!(deps.contains(&dep1)); + assert!(deps.contains(&dep2)); + + task.remove_dependency(dep1).unwrap(); + assert_eq!(task.get_dependencies().collect::>(), vec![dep2]); + }) + } + + #[test] + fn dependencies_tags() { + let mut rep = Replica::new_inmemory(); + let uuid1; + let uuid2; + { + let t1 = rep.new_task(Status::Pending, "1".into()).unwrap(); + uuid1 = t1.get_uuid(); + let t2 = rep.new_task(Status::Pending, "2".into()).unwrap(); + uuid2 = t2.get_uuid(); + + let mut t1 = t1.into_mut(&mut rep); + t1.add_dependency(t2.get_uuid()).unwrap(); + } + + // force-refresh depmap + rep.dependency_map(true).unwrap(); + + let t1 = rep.get_task(uuid1).unwrap().unwrap(); + let t2 = rep.get_task(uuid2).unwrap().unwrap(); + assert!(t1.has_tag(&stag(SyntheticTag::Blocked))); + assert!(!t1.has_tag(&stag(SyntheticTag::Unblocked))); + assert!(!t1.has_tag(&stag(SyntheticTag::Blocking))); + assert!(!t2.has_tag(&stag(SyntheticTag::Blocked))); + assert!(t2.has_tag(&stag(SyntheticTag::Unblocked))); + assert!(t2.has_tag(&stag(SyntheticTag::Blocking))); + } +} diff --git a/rust/taskchampion/src/taskdb/apply.rs b/rust/taskchampion/src/taskdb/apply.rs new file mode 100644 index 000000000..1e3a3fa83 --- /dev/null +++ b/rust/taskchampion/src/taskdb/apply.rs @@ -0,0 +1,406 @@ +use crate::errors::Error; +use crate::server::SyncOp; +use crate::storage::{ReplicaOp, StorageTxn, TaskMap}; + +/// Apply the given SyncOp to the replica, updating both the task data and adding a +/// ReplicaOp to the list of operations. Returns the TaskMap of the task after the +/// operation has been applied (or an empty TaskMap for Delete). It is not an error +/// to create an existing task, nor to delete a nonexistent task. +pub(super) fn apply_and_record(txn: &mut dyn StorageTxn, op: SyncOp) -> anyhow::Result { + match op { + SyncOp::Create { uuid } => { + let created = txn.create_task(uuid)?; + if created { + txn.add_operation(ReplicaOp::Create { uuid })?; + txn.commit()?; + Ok(TaskMap::new()) + } else { + Ok(txn + .get_task(uuid)? + .expect("create_task failed but task does not exist")) + } + } + SyncOp::Delete { uuid } => { + let task = txn.get_task(uuid)?; + if let Some(task) = task { + txn.delete_task(uuid)?; + txn.add_operation(ReplicaOp::Delete { + uuid, + old_task: task, + })?; + txn.commit()?; + Ok(TaskMap::new()) + } else { + Ok(TaskMap::new()) + } + } + SyncOp::Update { + uuid, + property, + value, + timestamp, + } => { + let task = txn.get_task(uuid)?; + if let Some(mut task) = task { + let old_value = task.get(&property).cloned(); + if let Some(ref v) = value { + task.insert(property.clone(), v.clone()); + } else { + task.remove(&property); + } + txn.set_task(uuid, task.clone())?; + txn.add_operation(ReplicaOp::Update { + uuid, + property, + old_value, + value, + timestamp, + })?; + txn.commit()?; + Ok(task) + } else { + Err(Error::Database(format!("Task {} does not exist", uuid)).into()) + } + } + } +} + +/// Apply an op to the TaskDb's set of tasks (without recording it in the list of operations) +pub(super) fn apply_op(txn: &mut dyn StorageTxn, op: &SyncOp) -> anyhow::Result<()> { + // TODO: test + // TODO: it'd be nice if this was integrated into apply() somehow, but that clones TaskMaps + // unnecessariliy + match op { + SyncOp::Create { uuid } => { + // insert if the task does not already exist + if !txn.create_task(*uuid)? { + return Err(Error::Database(format!("Task {} already exists", uuid)).into()); + } + } + SyncOp::Delete { ref uuid } => { + if !txn.delete_task(*uuid)? { + return Err(Error::Database(format!("Task {} does not exist", uuid)).into()); + } + } + SyncOp::Update { + ref uuid, + ref property, + ref value, + timestamp: _, + } => { + // update if this task exists, otherwise ignore + if let Some(mut task) = txn.get_task(*uuid)? { + match value { + Some(ref val) => task.insert(property.to_string(), val.clone()), + None => task.remove(property), + }; + txn.set_task(*uuid, task)?; + } else { + return Err(Error::Database(format!("Task {} does not exist", uuid)).into()); + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::storage::TaskMap; + use crate::taskdb::TaskDb; + use chrono::Utc; + use pretty_assertions::assert_eq; + use std::collections::HashMap; + use uuid::Uuid; + + #[test] + fn test_apply_create() -> anyhow::Result<()> { + let mut db = TaskDb::new_inmemory(); + let uuid = Uuid::new_v4(); + let op = SyncOp::Create { uuid }; + + { + let mut txn = db.storage.txn()?; + let taskmap = apply_and_record(txn.as_mut(), op)?; + assert_eq!(taskmap.len(), 0); + txn.commit()?; + } + + assert_eq!(db.sorted_tasks(), vec![(uuid, vec![]),]); + assert_eq!(db.operations(), vec![ReplicaOp::Create { uuid }]); + Ok(()) + } + + #[test] + fn test_apply_create_exists() -> anyhow::Result<()> { + let mut db = TaskDb::new_inmemory(); + let uuid = Uuid::new_v4(); + { + let mut txn = db.storage.txn()?; + txn.create_task(uuid)?; + let mut taskmap = TaskMap::new(); + taskmap.insert("foo".into(), "bar".into()); + txn.set_task(uuid, taskmap)?; + txn.commit()?; + } + + let op = SyncOp::Create { uuid }; + { + let mut txn = db.storage.txn()?; + let taskmap = apply_and_record(txn.as_mut(), op.clone())?; + + assert_eq!(taskmap.len(), 1); + assert_eq!(taskmap.get("foo").unwrap(), "bar"); + + txn.commit()?; + } + + // create did not delete the old task.. + assert_eq!( + db.sorted_tasks(), + vec![(uuid, vec![("foo".into(), "bar".into())])] + ); + // create was done "manually" above, and no new op was added + assert_eq!(db.operations(), vec![]); + Ok(()) + } + + #[test] + fn test_apply_create_update() -> anyhow::Result<()> { + let mut db = TaskDb::new_inmemory(); + let uuid = Uuid::new_v4(); + let now = Utc::now(); + let op1 = SyncOp::Create { uuid }; + + { + let mut txn = db.storage.txn()?; + let taskmap = apply_and_record(txn.as_mut(), op1)?; + assert_eq!(taskmap.len(), 0); + txn.commit()?; + } + + let op2 = SyncOp::Update { + uuid, + property: String::from("title"), + value: Some("my task".into()), + timestamp: now, + }; + { + let mut txn = db.storage.txn()?; + let mut taskmap = apply_and_record(txn.as_mut(), op2)?; + assert_eq!( + taskmap.drain().collect::>(), + vec![("title".into(), "my task".into())] + ); + txn.commit()?; + } + + assert_eq!( + db.sorted_tasks(), + vec![(uuid, vec![("title".into(), "my task".into())])] + ); + assert_eq!( + db.operations(), + vec![ + ReplicaOp::Create { uuid }, + ReplicaOp::Update { + uuid, + property: "title".into(), + old_value: None, + value: Some("my task".into()), + timestamp: now + } + ] + ); + + Ok(()) + } + + #[test] + fn test_apply_create_update_delete_prop() -> anyhow::Result<()> { + let mut db = TaskDb::new_inmemory(); + let uuid = Uuid::new_v4(); + let now = Utc::now(); + let op1 = SyncOp::Create { uuid }; + { + let mut txn = db.storage.txn()?; + let taskmap = apply_and_record(txn.as_mut(), op1)?; + assert_eq!(taskmap.len(), 0); + txn.commit()?; + } + + let op2 = SyncOp::Update { + uuid, + property: String::from("title"), + value: Some("my task".into()), + timestamp: now, + }; + { + let mut txn = db.storage.txn()?; + let taskmap = apply_and_record(txn.as_mut(), op2)?; + assert_eq!(taskmap.get("title"), Some(&"my task".to_owned())); + txn.commit()?; + } + + let op3 = SyncOp::Update { + uuid, + property: String::from("priority"), + value: Some("H".into()), + timestamp: now, + }; + { + let mut txn = db.storage.txn()?; + let taskmap = apply_and_record(txn.as_mut(), op3)?; + assert_eq!(taskmap.get("priority"), Some(&"H".to_owned())); + txn.commit()?; + } + + let op4 = SyncOp::Update { + uuid, + property: String::from("title"), + value: None, + timestamp: now, + }; + { + let mut txn = db.storage.txn()?; + let taskmap = apply_and_record(txn.as_mut(), op4)?; + assert_eq!(taskmap.get("title"), None); + assert_eq!(taskmap.get("priority"), Some(&"H".to_owned())); + txn.commit()?; + } + + let mut exp = HashMap::new(); + let mut task = HashMap::new(); + task.insert(String::from("priority"), String::from("H")); + exp.insert(uuid, task); + assert_eq!( + db.sorted_tasks(), + vec![(uuid, vec![("priority".into(), "H".into())])] + ); + assert_eq!( + db.operations(), + vec![ + ReplicaOp::Create { uuid }, + ReplicaOp::Update { + uuid, + property: "title".into(), + old_value: None, + value: Some("my task".into()), + timestamp: now, + }, + ReplicaOp::Update { + uuid, + property: "priority".into(), + old_value: None, + value: Some("H".into()), + timestamp: now, + }, + ReplicaOp::Update { + uuid, + property: "title".into(), + old_value: Some("my task".into()), + value: None, + timestamp: now, + } + ] + ); + + Ok(()) + } + + #[test] + fn test_apply_update_does_not_exist() -> anyhow::Result<()> { + let mut db = TaskDb::new_inmemory(); + let uuid = Uuid::new_v4(); + let op = SyncOp::Update { + uuid, + property: String::from("title"), + value: Some("my task".into()), + timestamp: Utc::now(), + }; + { + let mut txn = db.storage.txn()?; + assert_eq!( + apply_and_record(txn.as_mut(), op) + .err() + .unwrap() + .to_string(), + format!("Task Database Error: Task {} does not exist", uuid) + ); + txn.commit()?; + } + + Ok(()) + } + + #[test] + fn test_apply_create_delete() -> anyhow::Result<()> { + let mut db = TaskDb::new_inmemory(); + let uuid = Uuid::new_v4(); + let now = Utc::now(); + + let op1 = SyncOp::Create { uuid }; + { + let mut txn = db.storage.txn()?; + let taskmap = apply_and_record(txn.as_mut(), op1)?; + assert_eq!(taskmap.len(), 0); + } + + let op2 = SyncOp::Update { + uuid, + property: String::from("priority"), + value: Some("H".into()), + timestamp: now, + }; + { + let mut txn = db.storage.txn()?; + let taskmap = apply_and_record(txn.as_mut(), op2)?; + assert_eq!(taskmap.get("priority"), Some(&"H".to_owned())); + txn.commit()?; + } + + let op3 = SyncOp::Delete { uuid }; + { + let mut txn = db.storage.txn()?; + let taskmap = apply_and_record(txn.as_mut(), op3)?; + assert_eq!(taskmap.len(), 0); + txn.commit()?; + } + + assert_eq!(db.sorted_tasks(), vec![]); + let mut old_task = TaskMap::new(); + old_task.insert("priority".into(), "H".into()); + assert_eq!( + db.operations(), + vec![ + ReplicaOp::Create { uuid }, + ReplicaOp::Update { + uuid, + property: "priority".into(), + old_value: None, + value: Some("H".into()), + timestamp: now, + }, + ReplicaOp::Delete { uuid, old_task }, + ] + ); + + Ok(()) + } + + #[test] + fn test_apply_delete_not_present() -> anyhow::Result<()> { + let mut db = TaskDb::new_inmemory(); + let uuid = Uuid::new_v4(); + let op = SyncOp::Delete { uuid }; + { + let mut txn = db.storage.txn()?; + let taskmap = apply_and_record(txn.as_mut(), op)?; + assert_eq!(taskmap.len(), 0); + txn.commit()?; + } + + Ok(()) + } +} diff --git a/rust/taskchampion/src/taskdb/mod.rs b/rust/taskchampion/src/taskdb/mod.rs new file mode 100644 index 000000000..71404d968 --- /dev/null +++ b/rust/taskchampion/src/taskdb/mod.rs @@ -0,0 +1,263 @@ +use crate::server::{Server, SyncOp}; +use crate::storage::{ReplicaOp, Storage, TaskMap}; +use uuid::Uuid; + +mod apply; +mod snapshot; +mod sync; +mod undo; +mod working_set; + +/// A TaskDb is the backend for a replica. It manages the storage, operations, synchronization, +/// and so on, and all the invariants that come with it. It leaves the meaning of particular task +/// properties to the replica and task implementations. +pub struct TaskDb { + storage: Box, +} + +impl TaskDb { + /// Create a new TaskDb with the given backend storage + pub fn new(storage: Box) -> TaskDb { + TaskDb { storage } + } + + #[cfg(test)] + pub fn new_inmemory() -> TaskDb { + #[cfg(test)] + use crate::storage::InMemoryStorage; + + TaskDb::new(Box::new(InMemoryStorage::new())) + } + + /// Apply an operation to the TaskDb. This will update the set of tasks and add a ReplicaOp to + /// the set of operations in the TaskDb, and return the TaskMap containing the resulting task's + /// properties (or an empty TaskMap for deletion). + /// + /// Aside from synchronization operations, this is the only way to modify the TaskDb. In cases + /// where an operation does not make sense, this function will do nothing and return an error + /// (but leave the TaskDb in a consistent state). + pub fn apply(&mut self, op: SyncOp) -> anyhow::Result { + let mut txn = self.storage.txn()?; + apply::apply_and_record(txn.as_mut(), op) + } + + /// Add an UndoPoint operation to the list of replica operations. + pub fn add_undo_point(&mut self) -> anyhow::Result<()> { + let mut txn = self.storage.txn()?; + txn.add_operation(ReplicaOp::UndoPoint)?; + txn.commit() + } + + /// Get all tasks. + pub fn all_tasks(&mut self) -> anyhow::Result> { + let mut txn = self.storage.txn()?; + txn.all_tasks() + } + + /// Get the UUIDs of all tasks + pub fn all_task_uuids(&mut self) -> anyhow::Result> { + let mut txn = self.storage.txn()?; + txn.all_task_uuids() + } + + /// Get the working set + pub fn working_set(&mut self) -> anyhow::Result>> { + let mut txn = self.storage.txn()?; + txn.get_working_set() + } + + /// Get a single task, by uuid. + pub fn get_task(&mut self, uuid: Uuid) -> anyhow::Result> { + let mut txn = self.storage.txn()?; + txn.get_task(uuid) + } + + /// Rebuild the working set using a function to identify tasks that should be in the set. This + /// renumbers the existing working-set tasks to eliminate gaps, and also adds any tasks that + /// are not already in the working set but should be. The rebuild occurs in a single + /// trasnsaction against the storage backend. + pub fn rebuild_working_set( + &mut self, + in_working_set: F, + renumber: bool, + ) -> anyhow::Result<()> + where + F: Fn(&TaskMap) -> bool, + { + working_set::rebuild(self.storage.txn()?.as_mut(), in_working_set, renumber) + } + + /// Add the given uuid to the working set and return its index; if it is already in the working + /// set, its index is returned. This does *not* renumber any existing tasks. + pub fn add_to_working_set(&mut self, uuid: Uuid) -> anyhow::Result { + let mut txn = self.storage.txn()?; + // search for an existing entry for this task.. + for (i, elt) in txn.get_working_set()?.iter().enumerate() { + if *elt == Some(uuid) { + // (note that this drops the transaction with no changes made) + return Ok(i); + } + } + // and if not found, add one + let i = txn.add_to_working_set(uuid)?; + txn.commit()?; + Ok(i) + } + + /// Sync to the given server, pulling remote changes and pushing local changes. + /// + /// If `avoid_snapshots` is true, the sync operations produces a snapshot only when the server + /// indicate it is urgent (snapshot urgency "high"). This allows time for other replicas to + /// create a snapshot before this one does. + /// + /// Set this to true on systems more constrained in CPU, memory, or bandwidth than a typical desktop + /// system + pub fn sync( + &mut self, + server: &mut Box, + avoid_snapshots: bool, + ) -> anyhow::Result<()> { + let mut txn = self.storage.txn()?; + sync::sync(server, txn.as_mut(), avoid_snapshots) + } + + /// Undo local operations until the most recent UndoPoint, returning false if there are no + /// local operations to undo. + pub fn undo(&mut self) -> anyhow::Result { + let mut txn = self.storage.txn()?; + undo::undo(txn.as_mut()) + } + + /// Get the number of un-synchronized operations in storage. + pub fn num_operations(&mut self) -> anyhow::Result { + let mut txn = self.storage.txn().unwrap(); + txn.num_operations() + } + + // functions for supporting tests + + #[cfg(test)] + pub(crate) fn sorted_tasks(&mut self) -> Vec<(Uuid, Vec<(String, String)>)> { + let mut res: Vec<(Uuid, Vec<(String, String)>)> = self + .all_tasks() + .unwrap() + .iter() + .map(|(u, t)| { + let mut t = t + .iter() + .map(|(p, v)| (p.clone(), v.clone())) + .collect::>(); + t.sort(); + (u.clone(), t) + }) + .collect(); + res.sort(); + res + } + + #[cfg(test)] + pub(crate) fn operations(&mut self) -> Vec { + let mut txn = self.storage.txn().unwrap(); + txn.operations() + .unwrap() + .iter() + .map(|o| o.clone()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::test::TestServer; + use crate::storage::{InMemoryStorage, ReplicaOp}; + use chrono::Utc; + use pretty_assertions::assert_eq; + use proptest::prelude::*; + use uuid::Uuid; + + #[test] + fn test_apply() { + // this verifies that the operation is both applied and included in the list of + // operations; more detailed tests are in the `apply` module. + let mut db = TaskDb::new_inmemory(); + let uuid = Uuid::new_v4(); + let op = SyncOp::Create { uuid }; + db.apply(op.clone()).unwrap(); + + assert_eq!(db.sorted_tasks(), vec![(uuid, vec![]),]); + assert_eq!(db.operations(), vec![ReplicaOp::Create { uuid }]); + } + + #[test] + fn test_add_undo_point() { + let mut db = TaskDb::new_inmemory(); + db.add_undo_point().unwrap(); + assert_eq!(db.operations(), vec![ReplicaOp::UndoPoint]); + } + + fn newdb() -> TaskDb { + TaskDb::new(Box::new(InMemoryStorage::new())) + } + + #[derive(Debug)] + enum Action { + Op(SyncOp), + Sync, + } + + fn action_sequence_strategy() -> impl Strategy> { + // Create, Update, Delete, or Sync on client 1, 2, .., followed by a round of syncs + "([CUDS][123])*S1S2S3S1S2".prop_map(|seq| { + let uuid = Uuid::parse_str("83a2f9ef-f455-4195-b92e-a54c161eebfc").unwrap(); + seq.as_bytes() + .chunks(2) + .map(|action_on| { + let action = match action_on[0] { + b'C' => Action::Op(SyncOp::Create { uuid }), + b'U' => Action::Op(SyncOp::Update { + uuid, + property: "title".into(), + value: Some("foo".into()), + timestamp: Utc::now(), + }), + b'D' => Action::Op(SyncOp::Delete { uuid }), + b'S' => Action::Sync, + _ => unreachable!(), + }; + let acton = action_on[1] - b'1'; + (action, acton) + }) + .collect::>() + }) + } + + proptest! { + #[test] + // check that various sequences of operations on mulitple db's do not get the db's into an + // incompatible state. The main concern here is that there might be a sequence of create + // and delete operations that results in a task existing in one TaskDb but not existing in + // another. So, the generated sequences focus on a single task UUID. + fn transform_sequences_of_operations(action_sequence in action_sequence_strategy()) { + let mut server: Box = Box::new(TestServer::new()); + let mut dbs = [newdb(), newdb(), newdb()]; + + for (action, db) in action_sequence { + println!("{:?} on db {}", action, db); + + let db = &mut dbs[db as usize]; + match action { + Action::Op(op) => { + if let Err(e) = db.apply(op) { + println!(" {:?} (ignored)", e); + } + }, + Action::Sync => db.sync(&mut server, false).unwrap(), + } + } + + assert_eq!(dbs[0].sorted_tasks(), dbs[0].sorted_tasks()); + assert_eq!(dbs[1].sorted_tasks(), dbs[2].sorted_tasks()); + } + } +} diff --git a/rust/taskchampion/src/taskdb/snapshot.rs b/rust/taskchampion/src/taskdb/snapshot.rs new file mode 100644 index 000000000..33ab7e8df --- /dev/null +++ b/rust/taskchampion/src/taskdb/snapshot.rs @@ -0,0 +1,178 @@ +use crate::storage::{StorageTxn, TaskMap, VersionId}; +use flate2::{read::ZlibDecoder, write::ZlibEncoder, Compression}; +use serde::de::{Deserialize, Deserializer, MapAccess, Visitor}; +use serde::ser::{Serialize, SerializeMap, Serializer}; +use std::fmt; +use uuid::Uuid; + +/// A newtype to wrap the result of [`crate::storage::StorageTxn::all_tasks`] +pub(super) struct SnapshotTasks(Vec<(Uuid, TaskMap)>); + +impl Serialize for SnapshotTasks { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + let mut map = serializer.serialize_map(Some(self.0.len()))?; + for (k, v) in &self.0 { + map.serialize_entry(k, v)?; + } + map.end() + } +} + +struct TaskDbVisitor; + +impl<'de> Visitor<'de> for TaskDbVisitor { + type Value = SnapshotTasks; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a map representing a task snapshot") + } + + fn visit_map(self, mut access: M) -> Result + where + M: MapAccess<'de>, + { + let mut map = SnapshotTasks(Vec::with_capacity(access.size_hint().unwrap_or(0))); + + while let Some((key, value)) = access.next_entry()? { + map.0.push((key, value)); + } + + Ok(map) + } +} + +impl<'de> Deserialize<'de> for SnapshotTasks { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_map(TaskDbVisitor) + } +} + +impl SnapshotTasks { + pub(super) fn encode(&self) -> anyhow::Result> { + let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default()); + serde_json::to_writer(&mut encoder, &self)?; + Ok(encoder.finish()?) + } + + pub(super) fn decode(snapshot: &[u8]) -> anyhow::Result { + let decoder = ZlibDecoder::new(snapshot); + Ok(serde_json::from_reader(decoder)?) + } + + pub(super) fn into_inner(self) -> Vec<(Uuid, TaskMap)> { + self.0 + } +} + +/// Generate a snapshot (compressed, unencrypted) for the current state of the taskdb in the given +/// storage. +pub(super) fn make_snapshot(txn: &mut dyn StorageTxn) -> anyhow::Result> { + let all_tasks = SnapshotTasks(txn.all_tasks()?); + all_tasks.encode() +} + +/// Apply the given snapshot (compressed, unencrypted) to the taskdb's storage. +pub(super) fn apply_snapshot( + txn: &mut dyn StorageTxn, + version: VersionId, + snapshot: &[u8], +) -> anyhow::Result<()> { + let all_tasks = SnapshotTasks::decode(snapshot)?; + + // double-check emptiness + if !txn.is_empty()? { + anyhow::bail!("Cannot apply snapshot to a non-empty task database"); + } + + for (uuid, task) in all_tasks.into_inner().drain(..) { + txn.set_task(uuid, task)?; + } + txn.set_base_version(version)?; + + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::storage::{InMemoryStorage, Storage, TaskMap}; + use pretty_assertions::assert_eq; + + #[test] + fn test_serialize_empty() -> anyhow::Result<()> { + let empty = SnapshotTasks(vec![]); + assert_eq!(serde_json::to_vec(&empty)?, b"{}".to_owned()); + Ok(()) + } + + #[test] + fn test_serialize_tasks() -> anyhow::Result<()> { + let u = Uuid::new_v4(); + let m: TaskMap = vec![("description".to_owned(), "my task".to_owned())] + .drain(..) + .collect(); + let all_tasks = SnapshotTasks(vec![(u, m)]); + assert_eq!( + serde_json::to_vec(&all_tasks)?, + format!("{{\"{}\":{{\"description\":\"my task\"}}}}", u).into_bytes(), + ); + Ok(()) + } + + #[test] + fn test_round_trip() -> anyhow::Result<()> { + let mut storage = InMemoryStorage::new(); + let version = Uuid::new_v4(); + + let task1 = ( + Uuid::new_v4(), + vec![("description".to_owned(), "one".to_owned())] + .drain(..) + .collect::(), + ); + let task2 = ( + Uuid::new_v4(), + vec![("description".to_owned(), "two".to_owned())] + .drain(..) + .collect::(), + ); + + { + let mut txn = storage.txn()?; + txn.set_task(task1.0, task1.1.clone())?; + txn.set_task(task2.0, task2.1.clone())?; + txn.commit()?; + } + + let snap = { + let mut txn = storage.txn()?; + make_snapshot(txn.as_mut())? + }; + + // apply that snapshot to a fresh bit of fake + let mut storage = InMemoryStorage::new(); + { + let mut txn = storage.txn()?; + apply_snapshot(txn.as_mut(), version, &snap)?; + txn.commit()? + } + + { + let mut txn = storage.txn()?; + assert_eq!(txn.get_task(task1.0)?, Some(task1.1)); + assert_eq!(txn.get_task(task2.0)?, Some(task2.1)); + assert_eq!(txn.all_tasks()?.len(), 2); + assert_eq!(txn.base_version()?, version); + assert_eq!(txn.operations()?.len(), 0); + assert_eq!(txn.get_working_set()?.len(), 1); + } + + Ok(()) + } +} diff --git a/rust/taskchampion/src/taskdb/sync.rs b/rust/taskchampion/src/taskdb/sync.rs new file mode 100644 index 000000000..d479c6c03 --- /dev/null +++ b/rust/taskchampion/src/taskdb/sync.rs @@ -0,0 +1,385 @@ +use super::{apply, snapshot}; +use crate::server::{AddVersionResult, GetVersionResult, Server, SnapshotUrgency, SyncOp}; +use crate::storage::StorageTxn; +use crate::Error; +use log::{info, trace, warn}; +use serde::{Deserialize, Serialize}; +use std::str; + +#[derive(Serialize, Deserialize, Debug)] +struct Version { + operations: Vec, +} + +/// Sync to the given server, pulling remote changes and pushing local changes. +pub(super) fn sync( + server: &mut Box, + txn: &mut dyn StorageTxn, + avoid_snapshots: bool, +) -> anyhow::Result<()> { + // if this taskdb is entirely empty, then start by getting and applying a snapshot + if txn.is_empty()? { + trace!("storage is empty; attempting to apply a snapshot"); + if let Some((version, snap)) = server.get_snapshot()? { + snapshot::apply_snapshot(txn, version, snap.as_ref())?; + trace!("applied snapshot for version {}", version); + } + } + + // retry synchronizing until the server accepts our version (this allows for races between + // replicas trying to sync to the same server). If the server insists on the same base + // version twice, then we have diverged. + let mut requested_parent_version_id = None; + loop { + trace!("beginning sync outer loop"); + let mut base_version_id = txn.base_version()?; + + let mut local_ops: Vec = txn + .operations()? + .drain(..) + .filter_map(|op| op.into_sync()) + .collect(); + + // first pull changes and "rebase" on top of them + loop { + trace!("beginning sync inner loop"); + if let GetVersionResult::Version { + version_id, + history_segment, + .. + } = server.get_child_version(base_version_id)? + { + let version_str = str::from_utf8(&history_segment).unwrap(); + let version: Version = serde_json::from_str(version_str).unwrap(); + + // apply this verison and update base_version in storage + info!("applying version {:?} from server", version_id); + apply_version(txn, &mut local_ops, version)?; + txn.set_base_version(version_id)?; + base_version_id = version_id; + } else { + info!("no child versions of {:?}", base_version_id); + // at the moment, no more child versions, so we can try adding our own + break; + } + } + + if local_ops.is_empty() { + info!("no changes to push to server"); + // nothing to sync back to the server.. + break; + } + + trace!("sending {} operations to the server", local_ops.len()); + + // now make a version of our local changes and push those + let new_version = Version { + operations: local_ops, + }; + let history_segment = serde_json::to_string(&new_version).unwrap().into(); + info!("sending new version to server"); + let (res, snapshot_urgency) = server.add_version(base_version_id, history_segment)?; + match res { + AddVersionResult::Ok(new_version_id) => { + info!("version {:?} received by server", new_version_id); + txn.set_base_version(new_version_id)?; + + // make a snapshot if the server indicates it is urgent enough + let base_urgency = if avoid_snapshots { + SnapshotUrgency::High + } else { + SnapshotUrgency::Low + }; + if snapshot_urgency >= base_urgency { + let snapshot = snapshot::make_snapshot(txn)?; + server.add_snapshot(new_version_id, snapshot)?; + } + + break; + } + AddVersionResult::ExpectedParentVersion(parent_version_id) => { + info!( + "new version rejected; must be based on {:?}", + parent_version_id + ); + if let Some(requested) = requested_parent_version_id { + if parent_version_id == requested { + return Err(Error::OutOfSync.into()); + } + } + requested_parent_version_id = Some(parent_version_id); + } + } + } + + txn.set_operations(vec![])?; + txn.commit()?; + Ok(()) +} + +fn apply_version( + txn: &mut dyn StorageTxn, + local_ops: &mut Vec, + mut version: Version, +) -> anyhow::Result<()> { + // The situation here is that the server has already applied all server operations, and we + // have already applied all local operations, so states have diverged by several + // operations. We need to figure out what operations to apply locally and on the server in + // order to return to the same state. + // + // Operational transforms provide this on an operation-by-operation basis. To break this + // down, we treat each server operation individually, in order. For each such operation, + // we start in this state: + // + // + // base state-* + // / \-server op + // * * + // local / \ / + // ops * * + // / \ / new + // * * local + // local / \ / ops + // state-* * + // new-\ / + // server op *-new local state + // + // This is slightly complicated by the fact that the transform function can return None, + // indicating no operation is required. If this happens for a local op, we can just omit + // it. If it happens for server op, then we must copy the remaining local ops. + for server_op in version.operations.drain(..) { + trace!( + "rebasing local operations onto server operation {:?}", + server_op + ); + let mut new_local_ops = Vec::with_capacity(local_ops.len()); + let mut svr_op = Some(server_op); + for local_op in local_ops.drain(..) { + if let Some(o) = svr_op { + let (new_server_op, new_local_op) = SyncOp::transform(o, local_op.clone()); + trace!("local operation {:?} -> {:?}", local_op, new_local_op); + svr_op = new_server_op; + if let Some(o) = new_local_op { + new_local_ops.push(o); + } + } else { + trace!( + "local operation {:?} unchanged (server operation consumed)", + local_op + ); + new_local_ops.push(local_op); + } + } + if let Some(o) = svr_op { + if let Err(e) = apply::apply_op(txn, &o) { + warn!("Invalid operation when syncing: {} (ignored)", e); + } + } + *local_ops = new_local_ops; + } + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::server::{test::TestServer, SyncOp}; + use crate::storage::InMemoryStorage; + use crate::taskdb::{snapshot::SnapshotTasks, TaskDb}; + use chrono::Utc; + use pretty_assertions::assert_eq; + use uuid::Uuid; + + fn newdb() -> TaskDb { + TaskDb::new(Box::new(InMemoryStorage::new())) + } + + #[test] + fn test_sync() -> anyhow::Result<()> { + let mut server: Box = TestServer::new().server(); + + let mut db1 = newdb(); + sync(&mut server, db1.storage.txn()?.as_mut(), false).unwrap(); + + let mut db2 = newdb(); + sync(&mut server, db2.storage.txn()?.as_mut(), false).unwrap(); + + // make some changes in parallel to db1 and db2.. + let uuid1 = Uuid::new_v4(); + db1.apply(SyncOp::Create { uuid: uuid1 }).unwrap(); + db1.apply(SyncOp::Update { + uuid: uuid1, + property: "title".into(), + value: Some("my first task".into()), + timestamp: Utc::now(), + }) + .unwrap(); + + let uuid2 = Uuid::new_v4(); + db2.apply(SyncOp::Create { uuid: uuid2 }).unwrap(); + db2.apply(SyncOp::Update { + uuid: uuid2, + property: "title".into(), + value: Some("my second task".into()), + timestamp: Utc::now(), + }) + .unwrap(); + + // and synchronize those around + sync(&mut server, db1.storage.txn()?.as_mut(), false).unwrap(); + sync(&mut server, db2.storage.txn()?.as_mut(), false).unwrap(); + sync(&mut server, db1.storage.txn()?.as_mut(), false).unwrap(); + assert_eq!(db1.sorted_tasks(), db2.sorted_tasks()); + + // now make updates to the same task on both sides + db1.apply(SyncOp::Update { + uuid: uuid2, + property: "priority".into(), + value: Some("H".into()), + timestamp: Utc::now(), + }) + .unwrap(); + db2.apply(SyncOp::Update { + uuid: uuid2, + property: "project".into(), + value: Some("personal".into()), + timestamp: Utc::now(), + }) + .unwrap(); + + // and synchronize those around + sync(&mut server, db1.storage.txn()?.as_mut(), false).unwrap(); + sync(&mut server, db2.storage.txn()?.as_mut(), false).unwrap(); + sync(&mut server, db1.storage.txn()?.as_mut(), false).unwrap(); + assert_eq!(db1.sorted_tasks(), db2.sorted_tasks()); + + Ok(()) + } + + #[test] + fn test_sync_create_delete() -> anyhow::Result<()> { + let mut server: Box = TestServer::new().server(); + + let mut db1 = newdb(); + sync(&mut server, db1.storage.txn()?.as_mut(), false).unwrap(); + + let mut db2 = newdb(); + sync(&mut server, db2.storage.txn()?.as_mut(), false).unwrap(); + + // create and update a task.. + let uuid = Uuid::new_v4(); + db1.apply(SyncOp::Create { uuid }).unwrap(); + db1.apply(SyncOp::Update { + uuid, + property: "title".into(), + value: Some("my first task".into()), + timestamp: Utc::now(), + }) + .unwrap(); + + // and synchronize those around + sync(&mut server, db1.storage.txn()?.as_mut(), false).unwrap(); + sync(&mut server, db2.storage.txn()?.as_mut(), false).unwrap(); + sync(&mut server, db1.storage.txn()?.as_mut(), false).unwrap(); + assert_eq!(db1.sorted_tasks(), db2.sorted_tasks()); + + // delete and re-create the task on db1 + db1.apply(SyncOp::Delete { uuid }).unwrap(); + db1.apply(SyncOp::Create { uuid }).unwrap(); + db1.apply(SyncOp::Update { + uuid, + property: "title".into(), + value: Some("my second task".into()), + timestamp: Utc::now(), + }) + .unwrap(); + + // and on db2, update a property of the task + db2.apply(SyncOp::Update { + uuid, + property: "project".into(), + value: Some("personal".into()), + timestamp: Utc::now(), + }) + .unwrap(); + + sync(&mut server, db1.storage.txn()?.as_mut(), false).unwrap(); + sync(&mut server, db2.storage.txn()?.as_mut(), false).unwrap(); + sync(&mut server, db1.storage.txn()?.as_mut(), false).unwrap(); + assert_eq!(db1.sorted_tasks(), db2.sorted_tasks()); + + Ok(()) + } + + #[test] + fn test_sync_add_snapshot_start_with_snapshot() -> anyhow::Result<()> { + let mut test_server = TestServer::new(); + + let mut server: Box = test_server.server(); + let mut db1 = newdb(); + + let uuid = Uuid::new_v4(); + db1.apply(SyncOp::Create { uuid })?; + db1.apply(SyncOp::Update { + uuid, + property: "title".into(), + value: Some("my first task".into()), + timestamp: Utc::now(), + })?; + + test_server.set_snapshot_urgency(SnapshotUrgency::High); + sync(&mut server, db1.storage.txn()?.as_mut(), false)?; + + // assert that a snapshot was added + let base_version = db1.storage.txn()?.base_version()?; + let (v, s) = test_server + .snapshot() + .ok_or_else(|| anyhow::anyhow!("no snapshot"))?; + assert_eq!(v, base_version); + + let tasks = SnapshotTasks::decode(&s)?.into_inner(); + assert_eq!(tasks[0].0, uuid); + + // update the taskdb and sync again + db1.apply(SyncOp::Update { + uuid, + property: "title".into(), + value: Some("my first task, updated".into()), + timestamp: Utc::now(), + })?; + sync(&mut server, db1.storage.txn()?.as_mut(), false)?; + + // delete the first version, so that db2 *must* initialize from + // the snapshot + test_server.delete_version(Uuid::nil()); + + // sync to a new DB and check that we got the expected results + let mut db2 = newdb(); + sync(&mut server, db2.storage.txn()?.as_mut(), false)?; + + let task = db2.get_task(uuid)?.unwrap(); + assert_eq!(task.get("title").unwrap(), "my first task, updated"); + + Ok(()) + } + + #[test] + fn test_sync_avoids_snapshot() -> anyhow::Result<()> { + let test_server = TestServer::new(); + + let mut server: Box = test_server.server(); + let mut db1 = newdb(); + + let uuid = Uuid::new_v4(); + db1.apply(SyncOp::Create { uuid }).unwrap(); + + test_server.set_snapshot_urgency(SnapshotUrgency::Low); + sync(&mut server, db1.storage.txn()?.as_mut(), true).unwrap(); + + // assert that a snapshot was not added, because we indicated + // we wanted to avoid snapshots and it was only low urgency + assert_eq!(test_server.snapshot(), None); + + Ok(()) + } +} diff --git a/rust/taskchampion/src/taskdb/undo.rs b/rust/taskchampion/src/taskdb/undo.rs new file mode 100644 index 000000000..57bba0fa2 --- /dev/null +++ b/rust/taskchampion/src/taskdb/undo.rs @@ -0,0 +1,117 @@ +use super::apply; +use crate::storage::{ReplicaOp, StorageTxn}; +use log::{debug, trace}; + +/// Undo local operations until an UndoPoint. +pub(super) fn undo(txn: &mut dyn StorageTxn) -> anyhow::Result { + let mut applied = false; + let mut popped = false; + let mut local_ops = txn.operations()?; + + while let Some(op) = local_ops.pop() { + popped = true; + if op == ReplicaOp::UndoPoint { + break; + } + debug!("Reversing operation {:?}", op); + let rev_ops = op.reverse_ops(); + for op in rev_ops { + trace!("Applying reversed operation {:?}", op); + apply::apply_op(txn, &op)?; + applied = true; + } + } + + if popped { + txn.set_operations(local_ops)?; + txn.commit()?; + } + + Ok(applied) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::SyncOp; + use crate::taskdb::TaskDb; + use chrono::Utc; + use pretty_assertions::assert_eq; + use uuid::Uuid; + + #[test] + fn test_apply_create() -> anyhow::Result<()> { + let mut db = TaskDb::new_inmemory(); + let uuid1 = Uuid::new_v4(); + let uuid2 = Uuid::new_v4(); + let timestamp = Utc::now(); + + // apply a few ops, capture the DB state, make an undo point, and then apply a few more + // ops. + db.apply(SyncOp::Create { uuid: uuid1 })?; + db.apply(SyncOp::Update { + uuid: uuid1, + property: "prop".into(), + value: Some("v1".into()), + timestamp, + })?; + db.apply(SyncOp::Create { uuid: uuid2 })?; + db.apply(SyncOp::Update { + uuid: uuid2, + property: "prop".into(), + value: Some("v2".into()), + timestamp, + })?; + db.apply(SyncOp::Update { + uuid: uuid2, + property: "prop2".into(), + value: Some("v3".into()), + timestamp, + })?; + + let db_state = db.sorted_tasks(); + + db.add_undo_point()?; + db.apply(SyncOp::Delete { uuid: uuid1 })?; + db.apply(SyncOp::Update { + uuid: uuid2, + property: "prop".into(), + value: None, + timestamp, + })?; + db.apply(SyncOp::Update { + uuid: uuid2, + property: "prop2".into(), + value: Some("new-value".into()), + timestamp, + })?; + + assert_eq!(db.operations().len(), 9); + + { + let mut txn = db.storage.txn()?; + assert!(undo(txn.as_mut())?); + } + + // undo took db back to the snapshot + assert_eq!(db.operations().len(), 5); + assert_eq!(db.sorted_tasks(), db_state); + + { + let mut txn = db.storage.txn()?; + assert!(undo(txn.as_mut())?); + } + + // empty db + assert_eq!(db.operations().len(), 0); + assert_eq!(db.sorted_tasks(), vec![]); + + { + let mut txn = db.storage.txn()?; + // nothing left to undo, so undo() returns false + assert!(!undo(txn.as_mut())?); + } + + Ok(()) + } +} diff --git a/rust/taskchampion/src/taskdb/working_set.rs b/rust/taskchampion/src/taskdb/working_set.rs new file mode 100644 index 000000000..dd9e57f97 --- /dev/null +++ b/rust/taskchampion/src/taskdb/working_set.rs @@ -0,0 +1,167 @@ +use crate::storage::{StorageTxn, TaskMap}; +use std::collections::HashSet; + +/// Rebuild the working set using a function to identify tasks that should be in the set. This +/// renumbers the existing working-set tasks to eliminate gaps, and also adds any tasks that +/// are not already in the working set but should be. The rebuild occurs in a single +/// trasnsaction against the storage backend. +pub fn rebuild(txn: &mut dyn StorageTxn, in_working_set: F, renumber: bool) -> anyhow::Result<()> +where + F: Fn(&TaskMap) -> bool, +{ + let mut new_ws = vec![None]; // index 0 is always None + let mut seen = HashSet::new(); + + // The goal here is for existing working-set items to be "compressed' down to index 1, so + // we begin by scanning the current working set and inserting any tasks that should still + // be in the set into new_ws, implicitly dropping any tasks that are no longer in the + // working set. + for elt in txn.get_working_set()?.drain(1..) { + if let Some(uuid) = elt { + if let Some(task) = txn.get_task(uuid)? { + if in_working_set(&task) { + new_ws.push(Some(uuid)); + seen.insert(uuid); + continue; + } + } + } + + // if we are not renumbering, then insert a blank working-set entry here + if !renumber { + new_ws.push(None); + } + } + + // if renumbering, clear the working set and re-add + if renumber { + txn.clear_working_set()?; + for elt in new_ws.drain(1..new_ws.len()).flatten() { + txn.add_to_working_set(elt)?; + } + } else { + // ..otherwise, just clear the None items determined above from the working set + for (i, elt) in new_ws.iter().enumerate().skip(1) { + if elt.is_none() { + txn.set_working_set_item(i, None)?; + } + } + } + + // Now go hunting for tasks that should be in this list but are not, adding them at the + // end of the list, whether renumbering or not + for (uuid, task) in txn.all_tasks()? { + if !seen.contains(&uuid) && in_working_set(&task) { + txn.add_to_working_set(uuid)?; + } + } + + txn.commit()?; + Ok(()) +} + +#[cfg(test)] +mod test { + use super::*; + use crate::server::SyncOp; + use crate::taskdb::TaskDb; + use chrono::Utc; + use uuid::Uuid; + + #[test] + fn rebuild_working_set_renumber() -> anyhow::Result<()> { + rebuild_working_set(true) + } + + #[test] + fn rebuild_working_set_no_renumber() -> anyhow::Result<()> { + rebuild_working_set(false) + } + + fn rebuild_working_set(renumber: bool) -> anyhow::Result<()> { + let mut db = TaskDb::new_inmemory(); + let mut uuids = vec![]; + uuids.push(Uuid::new_v4()); + println!("uuids[0]: {:?} - pending, not in working set", uuids[0]); + uuids.push(Uuid::new_v4()); + println!("uuids[1]: {:?} - pending, in working set", uuids[1]); + uuids.push(Uuid::new_v4()); + println!("uuids[2]: {:?} - not pending, not in working set", uuids[2]); + uuids.push(Uuid::new_v4()); + println!("uuids[3]: {:?} - not pending, in working set", uuids[3]); + uuids.push(Uuid::new_v4()); + println!("uuids[4]: {:?} - pending, in working set", uuids[4]); + + // add everything to the TaskDb + for uuid in &uuids { + db.apply(SyncOp::Create { uuid: *uuid })?; + } + for i in &[0usize, 1, 4] { + db.apply(SyncOp::Update { + uuid: uuids[*i].clone(), + property: String::from("status"), + value: Some("pending".into()), + timestamp: Utc::now(), + })?; + } + + // set the existing working_set as we want it + { + let mut txn = db.storage.txn()?; + txn.clear_working_set()?; + + for i in &[1usize, 3, 4] { + txn.add_to_working_set(uuids[*i])?; + } + + txn.commit()?; + } + + assert_eq!( + db.working_set()?, + vec![ + None, + Some(uuids[1].clone()), + Some(uuids[3].clone()), + Some(uuids[4].clone()) + ] + ); + + rebuild( + db.storage.txn()?.as_mut(), + |t| { + if let Some(status) = t.get("status") { + status == "pending" + } else { + false + } + }, + renumber, + )?; + + let exp = if renumber { + // uuids[1] and uuids[4] are already in the working set, so are compressed + // to the top, and then uuids[0] is added. + vec![ + None, + Some(uuids[1].clone()), + Some(uuids[4].clone()), + Some(uuids[0].clone()), + ] + } else { + // uuids[1] and uuids[4] are already in the working set, at indexes 1 and 3, + // and then uuids[0] is added. + vec![ + None, + Some(uuids[1].clone()), + None, + Some(uuids[4].clone()), + Some(uuids[0].clone()), + ] + }; + + assert_eq!(db.working_set()?, exp); + + Ok(()) + } +} diff --git a/rust/taskchampion/src/utils.rs b/rust/taskchampion/src/utils.rs new file mode 100644 index 000000000..7eb0885dc --- /dev/null +++ b/rust/taskchampion/src/utils.rs @@ -0,0 +1,61 @@ +use std::convert::TryInto; +use uuid::Uuid; + +/// A representation of a UUID as a key. This is just a newtype wrapping the 128-bit packed form +/// of a UUID. +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) struct Key(uuid::Bytes); + +impl From<&[u8]> for Key { + fn from(bytes: &[u8]) -> Key { + Key(bytes.try_into().expect("expected 16 bytes")) + } +} + +impl From<&Uuid> for Key { + fn from(uuid: &Uuid) -> Key { + let key = Key(*uuid.as_bytes()); + key + } +} + +impl From for Key { + fn from(uuid: Uuid) -> Key { + let key = Key(*uuid.as_bytes()); + key + } +} + +impl From for Uuid { + fn from(key: Key) -> Uuid { + Uuid::from_bytes(key.0) + } +} + +impl AsRef<[u8]> for Key { + fn as_ref(&self) -> &[u8] { + &self.0[..] + } +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn test_from_bytes() { + let k: Key = (&[1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16][..]).into(); + let u: Uuid = k.into(); + assert_eq!( + u, + Uuid::parse_str("01020304-0506-0708-090a-0b0c0d0e0f10").unwrap() + ); + } + + #[test] + #[should_panic] + fn test_from_bytes_bad_len() { + let _: Key = (&[1u8, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11][..]).into(); + } +} diff --git a/rust/taskchampion/src/workingset.rs b/rust/taskchampion/src/workingset.rs new file mode 100644 index 000000000..15a509753 --- /dev/null +++ b/rust/taskchampion/src/workingset.rs @@ -0,0 +1,154 @@ +use std::collections::HashMap; +use uuid::Uuid; + +/// A WorkingSet represents a snapshot of the working set from a replica. +/// +/// A replica's working set is a mapping from small integers to task uuids for all pending tasks. +/// The small integers are meant to be stable, easily-typed identifiers for users to interact with +/// important tasks. +/// +/// IMPORTANT: the content of the working set may change at any time that a DB transaction is not +/// in progress, and the data in this type will not be updated automatically. It is up to the +/// caller to decide how long to keep this value, and how much to trust the accuracy of its +/// contents. In practice, the answers are usually "a few milliseconds" and treating unexpected +/// results as non-fatal. +pub struct WorkingSet { + by_index: Vec>, + by_uuid: HashMap, +} + +impl WorkingSet { + /// Create a new WorkingSet. Typically this is acquired via `replica.working_set()` + pub(crate) fn new(by_index: Vec>) -> Self { + let mut by_uuid = HashMap::new(); + + // working sets are 1-indexed, so element 0 should always be None + assert!(by_index.is_empty() || by_index[0].is_none()); + + for (index, uuid) in by_index.iter().enumerate() { + if let Some(uuid) = uuid { + by_uuid.insert(*uuid, index); + } + } + Self { by_index, by_uuid } + } + + /// Get the "length" of the working set: the total number of uuids in the set. + pub fn len(&self) -> usize { + self.by_index.iter().filter(|e| e.is_some()).count() + } + + /// Get the largest index in the working set, or zero if the set is empty. + pub fn largest_index(&self) -> usize { + self.by_index.len().saturating_sub(1) + } + + /// True if the length is zero + pub fn is_empty(&self) -> bool { + self.by_index.iter().all(|e| e.is_none()) + } + + /// Get the uuid with the given index, if any exists. + pub fn by_index(&self, index: usize) -> Option { + if let Some(Some(uuid)) = self.by_index.get(index) { + Some(*uuid) + } else { + None + } + } + + /// Get the index for the given uuid, if any + pub fn by_uuid(&self, uuid: Uuid) -> Option { + self.by_uuid.get(&uuid).copied() + } + + /// Iterate over pairs (index, uuid), in order by index. + pub fn iter(&self) -> impl Iterator + '_ { + self.by_index + .iter() + .enumerate() + .filter_map(|(index, uuid)| uuid.as_ref().map(|uuid| (index, *uuid))) + } +} + +#[cfg(test)] +mod test { + use super::*; + use pretty_assertions::assert_eq; + + fn make() -> (Uuid, Uuid, WorkingSet) { + let uuid1 = Uuid::new_v4(); + let uuid2 = Uuid::new_v4(); + ( + uuid1, + uuid2, + WorkingSet::new(vec![None, Some(uuid1), None, Some(uuid2), None]), + ) + } + + #[test] + fn test_new() { + let (_, uuid2, ws) = make(); + assert_eq!(ws.by_index[3], Some(uuid2)); + assert_eq!(ws.by_uuid.get(&uuid2), Some(&3)); + } + + #[test] + fn test_len_and_is_empty() { + let (_, _, ws) = make(); + assert_eq!(ws.len(), 2); + assert_eq!(ws.is_empty(), false); + + let ws = WorkingSet::new(vec![]); + assert_eq!(ws.len(), 0); + assert_eq!(ws.is_empty(), true); + + let ws = WorkingSet::new(vec![None, None, None]); + assert_eq!(ws.len(), 0); + assert_eq!(ws.is_empty(), true); + } + + #[test] + fn test_largest_index() { + let uuid1 = Uuid::new_v4(); + let uuid2 = Uuid::new_v4(); + + let ws = WorkingSet::new(vec![]); + assert_eq!(ws.largest_index(), 0); + + let ws = WorkingSet::new(vec![None, Some(uuid1)]); + assert_eq!(ws.largest_index(), 1); + + let ws = WorkingSet::new(vec![None, Some(uuid1), None, Some(uuid2)]); + assert_eq!(ws.largest_index(), 3); + + let ws = WorkingSet::new(vec![None, Some(uuid1), None, Some(uuid2), None]); + assert_eq!(ws.largest_index(), 4); + } + + #[test] + fn test_by_index() { + let (uuid1, uuid2, ws) = make(); + assert_eq!(ws.by_index(0), None); + assert_eq!(ws.by_index(1), Some(uuid1)); + assert_eq!(ws.by_index(2), None); + assert_eq!(ws.by_index(3), Some(uuid2)); + assert_eq!(ws.by_index(4), None); + assert_eq!(ws.by_index(100), None); // past the end of the vector + } + + #[test] + fn test_by_uuid() { + let (uuid1, uuid2, ws) = make(); + let nosuch = Uuid::new_v4(); + assert_eq!(ws.by_uuid(uuid1), Some(1)); + assert_eq!(ws.by_uuid(uuid2), Some(3)); + assert_eq!(ws.by_uuid(nosuch), None); + } + + #[test] + fn test_iter() { + let (uuid1, uuid2, ws) = make(); + assert_eq!(ws.iter().collect::>(), vec![(1, uuid1), (3, uuid2),]); + } +} diff --git a/rust/xtask/Cargo.toml b/rust/xtask/Cargo.toml new file mode 100644 index 000000000..6deb10d58 --- /dev/null +++ b/rust/xtask/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "xtask" +version = "0.4.1" +edition = "2018" + +[dependencies] +anyhow = "1.0" +cbindgen = "0.20.0" diff --git a/rust/xtask/src/main.rs b/rust/xtask/src/main.rs new file mode 100644 index 000000000..73ea71119 --- /dev/null +++ b/rust/xtask/src/main.rs @@ -0,0 +1,49 @@ +//! This executable defines the `cargo xtask` subcommands. +//! +//! At the moment it is very simple, but if this grows more subcommands then +//! it will be sensible to use `clap` or another similar library. + +use cbindgen::*; +use std::env; +use std::path::PathBuf; + +pub fn main() -> anyhow::Result<()> { + let arg = env::args().nth(1); + match arg.as_deref() { + Some("codegen") => codegen(), + Some(arg) => anyhow::bail!("unknown xtask {}", arg), + _ => anyhow::bail!("unknown xtask"), + } +} + +/// `cargo xtask codegen` +/// +/// This uses cbindgen to generate `lib/taskchampion.h`. +fn codegen() -> anyhow::Result<()> { + let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); + let workspace_dir = manifest_dir.parent().unwrap(); + let lib_crate_dir = workspace_dir.join("lib"); + + Builder::new() + .with_crate(&lib_crate_dir) + .with_config(Config { + header: Some(include_str!("../../lib/header-intro.h").into()), + language: Language::C, + include_guard: Some("TASKCHAMPION_H".into()), + cpp_compat: true, + sys_includes: vec!["stdbool.h".into(), "stdint.h".into(), "time.h".into()], + usize_is_size_t: true, + no_includes: true, + enumeration: EnumConfig { + // this appears to still default to true for C + enum_class: false, + ..Default::default() + }, + ..Default::default() + }) + .generate() + .expect("Unable to generate bindings") + .write_to_file(lib_crate_dir.join("taskchampion.h")); + + Ok(()) +}