diff --git a/Cargo.lock b/Cargo.lock index d0ce1c0..ed0d730 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -106,6 +106,21 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23b62fc65de8e4e7f52534fb52b0f3ed04746ae267519eef2a83941e8085068b" +[[package]] +name = "assert_cmd" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a686bbee5efb88a82df0621b236e74d925f470e5445d3220a5648b892ec99c9" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -120,28 +135,32 @@ checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "boat-cli" -version = "0.4.0" +version = "0.5.0" dependencies = [ "anyhow", + "assert_cmd", "boat-lib", "chrono", "clap", + "clap-verbosity-flag", "directories", "env_logger", "log", + "predicates", "rusqlite", "serde", "serde_json", "tabular", + "tempfile", "toml", "yansi", ] [[package]] name = "boat-lib" -version = "0.3.2" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbae42e69f927cab170eeb31a2add0ba0aaf69daedecc11a5bc2a0f5c71ee5a5" +checksum = "54a959a8c5ccc91cf1ad19110d16a1e55b14ad97fe1b08416800efbf4b2bf74b" dependencies = [ "anyhow", "chrono", @@ -152,6 +171,17 @@ dependencies = [ "serde_json", ] +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -198,6 +228,16 @@ dependencies = [ "clap_derive", ] +[[package]] +name = "clap-verbosity-flag" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d92b1fab272fe943881b77cc6e920d6543e5b1bfadbd5ed81c7c5a755742394" +dependencies = [ + "clap", + "log", +] + [[package]] name = "clap_builder" version = "4.6.0" @@ -240,6 +280,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "directories" version = "6.0.0" @@ -290,6 +336,16 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -302,12 +358,33 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "find-msvc-tools" version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foldhash" version = "0.2.0" @@ -325,13 +402,35 @@ dependencies = [ "wasi", ] +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + [[package]] name = "hashbrown" version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "foldhash", + "foldhash 0.2.0", ] [[package]] @@ -340,7 +439,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" dependencies = [ - "hashbrown", + "hashbrown 0.16.1", ] [[package]] @@ -373,6 +472,12 @@ dependencies = [ "cc", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "indexmap" version = "2.13.0" @@ -380,7 +485,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.16.1", + "serde", + "serde_core", ] [[package]] @@ -429,6 +536,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.183" @@ -455,6 +568,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "log" version = "0.4.29" @@ -467,6 +586,12 @@ version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "num-traits" version = "0.2.19" @@ -515,6 +640,46 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "predicates" +version = "3.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" +dependencies = [ + "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", + "predicates-core", + "regex", +] + +[[package]] +name = "predicates-core" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" + +[[package]] +name = "predicates-tree" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" +dependencies = [ + "predicates-core", + "termtree", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -533,13 +698,19 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "redox_users" version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ - "getrandom", + "getrandom 0.2.17", "libredox", "thiserror", ] @@ -579,7 +750,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8a1f2315036ef6b1fbacd1972e8ee7688030b0a2121edfc2a6550febd41574d" dependencies = [ - "hashbrown", + "hashbrown 0.16.1", "thiserror", ] @@ -598,12 +769,31 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + [[package]] name = "serde" version = "1.0.228" @@ -716,6 +906,25 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "termtree" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" + [[package]] name = "thiserror" version = "2.0.18" @@ -787,6 +996,12 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "utf8parse" version = "0.2.2" @@ -820,12 +1035,39 @@ dependencies = [ "quote", ] +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "wasm-bindgen" version = "0.2.114" @@ -871,6 +1113,40 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -945,6 +1221,94 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index b71c8f6..bb818e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "boat-cli" -version = "0.4.0" +version = "0.5.0" edition = "2024" description = "Basic Opinionated Activity Tracker, a command line interface inspired by bartib" repository = "https://github.com/coko7/boat" @@ -18,7 +18,7 @@ path = "src/main.rs" clap = { version = "4.6.0", features = ["derive"] } env_logger = "0.11.9" log = "0.4.29" -# clap-verbosity-flag = "3.0.4" +clap-verbosity-flag = "3.0.4" anyhow = "1.0.102" directories = "6.0.0" serde = { version = "1.0.228", features = ["derive"] } @@ -26,10 +26,15 @@ toml = "1.0.7" rusqlite = "0.39.0" chrono = { version = "0.4.44", features = ["serde"] } serde_json = "1.0.149" -boat-lib = "0.3.2" +boat-lib = "0.4.0" tabular = { version = "0.2.0", features = ["ansi-cell"] } yansi = "1.0.1" +[dev-dependencies] +assert_cmd = "2.0" +predicates = "3.0" +tempfile = "3.8" + [features] default = [] bundled-sqlite = ["boat-lib/bundled-sqlite"] diff --git a/README.md b/README.md index 64fa116..a5bf1c6 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,15 @@ `boat` - A **B**asic **O**pinionated **A**ctivity **T**racker, inspired by [bartib](https://github.com/nikolassv/bartib). -This is only the code for the command line application. It relies on [`boat-lib`](https://github.com/coko7/boat-lib) for core functions. +Like its name implies, `boat` allows you to track the time you spend on everyday tasks. + +It has mainly been designed to be easy to embed in custom bash scripts so that you can augment it with fuzzy-finding. +That said, if you plan to use the CLI directly (without external scripts), it also benefits from a [variety of handy aliases](#-usage). + +`boat` stores its data in a SQLite database file which is kept in the config directory by default (`.config/boat/boat.db`). + +This repository contains only the code for the command line application. +It relies on [`boat-lib`](https://github.com/coko7/boat-lib) for core functions. [![Crates info](https://img.shields.io/crates/v/boat-cli.svg)](https://crates.io/crates/boat-cli) [![License: GPL-3.0](https://img.shields.io/github/license/coko7/boat-cli?color=blue)](LICENSE) @@ -15,11 +23,19 @@ This is only the code for the command line application. It relies on [`boat-lib` > This cli is actively being developed. Since it's in its early stages, things will likely break often. > Don't use it for now. +## Contents + +- [🤔 Why was this tool created?](#🤔-why-was-this-tool-created) +- [🛠️ Installation](#🛠️-installation) + - [Install with a bundled version of SQLite](#install-with-a-bundled-version-of-sqlite) +- [⚙️ Configuration](#⚙️-configuration) +- [✨ Usage](#✨-usage) + ## 🤔 Why was this tool created? The [`bartib`](https://github.com/nikolassv/bartib) cli is what inspired me to create `boat`. It's a feature-full tool that I used for a while, but I found it quite limiting for my usage due to its [lack of support for machine-readable output](https://github.com/nikolassv/bartib/pull/26). -That's it, I wanted an activity tracker that I could combine easily with [`jq`](https://github.com/jqlang/jq) and so I decided to make my own tool. +And that's it. All I wanted was an activity tracker that I could combine easily with [`jq`](https://github.com/jqlang/jq) and so I decided to make my own tool. ## 🛠️ Installation @@ -58,7 +74,7 @@ By default, `boat` will create a configuration file in one of the following dirs - 🪟 **Windows:** `C:\Users\\AppData\Roaming\boat\config.toml` - 🍎 **macOS:** `/Users//Library/Application Support/boat/config.toml` -It will also keep the SQLite database file `boat.db` in the same directory (unless specified otherwise in config): +It will also store the SQLite database file `boat.db` in the same directory (unless specified otherwise in config): ```toml database_path = "/home//.config/boat/boat.db" ``` @@ -66,9 +82,10 @@ You can override the default configuration file path by setting the `BOAT_CONFIG ## ✨ Usage -To get a feel of how `boat` can be used, you can try `boat help` to get the list of commands: +If you have ever used [`bartib`](https://github.com/nikolassv/bartib), then `boat` is going to feel very familiar. +Try `boat help` for a quick list of commands: ```help -boat 0.2.1 +boat 0.5.0 Basic Opinionated Activity Tracker @@ -78,11 +95,13 @@ boat Commands: new Create a new activity start Start/resume an activity + cancel Cancel the current activity pause Pause/stop the current activity modify Modify an activity delete Delete an activity get Get the current activity - list List boat objects + list List activities + query Query boat objects help Print this message or the help of the given subcommand(s) Options: @@ -92,21 +111,25 @@ Options: Made by @coko7 ``` -If you want to invoke `boat` from your command-line directly, you can make use of a variety of shorter aliases: -```help -Commands: - new n - start s, st, sail - config c, cfg, conf - pause p - modify m, mod - delete d, del - get g - list l, ls - help h, -h, --help -``` +> [!TIP] +> `boat` comes bundled with many command aliases: +> - new: `n`, `new`, `create` +> - start: `s`, `st`, `start`, `sail`, `continue`, `resume` +> - cancel: `c`, `can`, `cancel` +> - pause: `p`, `pause`, `stop` +> - modify: `m`, `mod`, `modify` +> - delete: `d`, `del`, `delete`, `rm`, `rem`, `remove` +> - get: `g`, `get` +> - list: `l`, `ls`, `list` +> - query: `q`, `query` +> - help: `h`, `help`, `-h`, `--help` +> +> Prefer using the full length command names in scripts as they are more explicit and unlikely to be changed (unlike shorter aliases). + I really wanted to have each command start with a different character so that I could assign a single-char alias to all of them. That explains why some of the commands do not use a more fitting keyword. Like `stop` would have been a better command than `pause` but since it shares the same starting charcter as the `start` command, I could not use it. Maybe I will drop this in the future, let's see. + +*I have included some fallback in case you type `stop`/`remove` instead of `pause`/`delete` 👀* diff --git a/src/cli.rs b/src/cli.rs index 1b20507..5128021 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -18,26 +18,32 @@ use crate::utils; pub struct Cli { #[command(subcommand)] pub command: Commands, - // - // #[command(flatten)] - // pub verbose: clap_verbosity_flag::Verbosity, + + #[command(flatten)] + pub verbose: clap_verbosity_flag::Verbosity, } #[derive(Subcommand)] #[command(rename_all = "kebab-case")] pub enum Commands { /// Create a new activity - #[command(alias = "n")] + #[command(alias = "n", alias = "create")] New(CreateActivityArgs), - // create a backup command /// Start/resume an activity - #[command(alias = "s", alias = "st", alias = "sail")] + #[command( + alias = "s", + alias = "st", + alias = "sail", + alias = "continue", + alias = "resume" + )] Start(SelectActivityArgs), - // /// Manage configuration - // #[command(alias = "c", alias = "cfg", alias = "conf")] - // Config {}, + /// Cancel the current activity + #[command(alias = "c", alias = "can")] + Cancel, + /// Pause/stop the current activity #[command(alias = "p", alias = "stop")] Pause, @@ -47,18 +53,28 @@ pub enum Commands { Modify(ModifyActivityArgs), /// Delete an activity - #[command(alias = "d", alias = "del")] + #[command( + alias = "d", + alias = "del", + alias = "rm", + alias = "rem", + alias = "remove" + )] Delete(SelectActivityArgs), /// Get the current activity #[command(alias = "g")] Get(PrintActivityArgs), - /// List boat objects + /// List activities #[command(alias = "l", alias = "ls")] - List { + List(ListActivityArgs), + + /// Query boat objects + #[command(alias = "q")] + Query { #[command(subcommand)] - command: ListSubcommand, + command: QuerySubcommand, }, // This is ONLY way I could find to use the 'h' short alias for help. @@ -84,12 +100,12 @@ pub enum Commands { #[derive(Subcommand)] #[command(rename_all = "kebab-case")] -pub enum ListSubcommand { - /// List activity logs +pub enum QuerySubcommand { + /// Manage logs #[command(name = "logs", alias = "l", alias = "log")] Logs(ListActivityArgs), - /// List activities + /// Manage activities #[command( name = "acts", alias = "act", @@ -99,7 +115,7 @@ pub enum ListSubcommand { )] Activities(ListArgs), - /// List tags + /// Manage tags #[command(name = "tags", alias = "t", alias = "tag")] Tags(ListArgs), } @@ -107,7 +123,7 @@ pub enum ListSubcommand { #[derive(Args, Debug)] pub struct ListActivityArgs { /// Restrict to entries starting in the given - #[arg(short = 'p', long = "period", value_name = "PERIOD", default_value_t = Period::Today, value_enum, conflicts_with_all = ["from", "to", "date"])] + #[arg(short = 'p', long = "period", value_name = "PERIOD", default_value_t = Period::ThisWeek, value_enum, conflicts_with_all = ["from", "to", "date"])] pub period: Period, /// Restrict to entries starting after (YYYY-MM-DD format) @@ -122,6 +138,14 @@ pub struct ListActivityArgs { #[arg(short = 'd', long = "date", value_name = "DATE", value_parser = utils::date::parse_date, conflicts_with_all = ["period", "from", "to"])] pub date: Option, + /// Only show activities, do not include their respective logs + #[arg(short = 'a', long = "activities-only", conflicts_with = "no_grouping")] + pub activities_only: bool, + + /// Do not group activities by date + #[arg(short = 'n', long = "no-grouping", conflicts_with = "activities_only")] + pub no_grouping: bool, + /// Output in JSON #[arg(short = 'j', long = "json")] pub use_json_format: bool, @@ -136,11 +160,11 @@ pub struct ListArgs { #[derive(ValueEnum, Clone, Debug)] pub enum Period { - #[value(name = "today", alias = "td")] + #[value(name = "today", alias = "td", alias = "tod")] Today, #[value(name = "yesterday", alias = "yd", alias = "ytd")] Yesterday, - #[value(name = "this-week", alias = "tw", alias = "twk")] + #[value(name = "this-week", alias = "tw", alias = "twk", alias = "wk")] ThisWeek, #[value( name = "last-week", @@ -151,7 +175,7 @@ pub enum Period { alias = "ywk" )] LastWeek, - #[value(name = "this-month", alias = "tm", alias = "tmo")] + #[value(name = "this-month", alias = "tm", alias = "tmo", alias = "mo")] ThisMonth, #[value( name = "last-month", @@ -189,6 +213,10 @@ impl Default for PrintActivityArgs { pub struct SelectActivityArgs { /// ID of the activity pub activity_id: Id, + + /// Output in JSON + #[arg(short = 'j', long = "json")] + pub use_json_format: bool, } #[derive(Args, Debug)] @@ -207,6 +235,10 @@ pub struct CreateActivityArgs { /// Start the new activity automatically #[arg(short = 's', long = "start")] pub auto_start: bool, + + /// Output in JSON + #[arg(short = 'j', long = "json")] + pub use_json_format: bool, } #[derive(Args, Debug)] diff --git a/src/commands/activity.rs b/src/commands/activity.rs index 0255395..119a16a 100644 --- a/src/commands/activity.rs +++ b/src/commands/activity.rs @@ -4,7 +4,7 @@ use rusqlite::Connection; use crate::{ cli, - models::{TablePrintable, activity_log::PrintableActivityLog}, + models::{TablePrintable, activity::PrintableActivity, activity_log::PrintableActivityLog}, }; pub fn create(conn: &mut Connection, args: &cli::CreateActivityArgs) -> Result<()> { @@ -19,6 +19,13 @@ pub fn create(conn: &mut Connection, args: &cli::CreateActivityArgs) -> Result<( activities::start(conn, created.id)?; } + let act = PrintableActivity::from_activity(&created); + if args.use_json_format { + let json = serde_json::to_string(&act)?; + println!("{json}"); + return Ok(()); + } + println!("{}", created.id); Ok(()) } @@ -90,3 +97,16 @@ pub fn get_current(conn: &mut Connection, args: &cli::PrintActivityArgs) -> Resu } Ok(()) } + +pub fn cancel_current(conn: &mut Connection) -> Result<()> { + match activities::get_current_ongoing(conn)? { + Some(act) => { + activities::cancel_current(conn)?; + println!("cancelled activity: {act:?}"); + } + None => { + println!("no current activity"); + } + } + Ok(()) +} diff --git a/src/commands/list.rs b/src/commands/list.rs index 0fd7084..0f3b0b2 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -1,74 +1,81 @@ use anyhow::Result; -use boat_lib::repository::{activities_repository as activities, tags_repository as tags}; +use boat_lib::repository::activities_repository as activities; +use chrono::Local; use rusqlite::Connection; -use serde::Serialize; -use std::cmp::Reverse; +use std::{cmp::Reverse, collections::BTreeMap}; use crate::{ - cli, - models::{ - RowPrintable, TablePrintable, activity::PrintableActivity, - activity_log::PrintableActivityLog, tag::PrintableTag, - }, - utils, + cli::{self, Period}, + models::{activity::PrintableActivity, activity_log::PrintableActivityLog}, + utils::{self, date::DateTimeRenderMode}, }; -pub fn list(conn: &mut Connection, command: &cli::ListSubcommand) -> Result<()> { - match command { - cli::ListSubcommand::Logs(args) => list_activity_logs(conn, args), - cli::ListSubcommand::Activities(args) => list_activities(conn, args), - cli::ListSubcommand::Tags(args) => list_tags(conn, args), +pub fn list_activities(conn: &mut Connection, args: &cli::ListActivityArgs) -> Result<()> { + if args.activities_only { + return list_activities_only(conn, args.use_json_format); } -} - -pub fn list_printable_items( - items: Vec, - show_as_json: bool, -) -> Result<()> { - if show_as_json { - let json = serde_json::to_string(&items)?; - println!("{json}"); - return Ok(()); - } - - let table = items.to_printable_table(); - println!("{table}"); - Ok(()) -} - -fn list_tags(conn: &mut Connection, args: &cli::ListArgs) -> Result<()> { - let mut all_tags: Vec<_> = tags::get_all(conn)? - .iter() - .map(PrintableTag::from_tag) - .collect(); - all_tags.sort_by_key(|t| Reverse(t.id)); - list_printable_items(all_tags, args.use_json_format) + list_activity_logs(conn, args) } -fn list_activities(conn: &mut Connection, args: &cli::ListArgs) -> Result<()> { +fn list_activities_only(conn: &mut Connection, use_json: bool) -> Result<()> { let mut all_acts: Vec<_> = activities::get_all(conn)? .iter() .map(PrintableActivity::from_activity) .collect(); all_acts.sort_by_key(|a| Reverse(a.id)); - list_printable_items(all_acts, args.use_json_format) + utils::common::list_printable_items(all_acts, use_json) +} + +fn matches_period(al: &PrintableActivityLog, period: &Period) -> bool { + match period { + cli::Period::Today => utils::date::is_today(al.log.starts_at), + cli::Period::Yesterday => utils::date::is_yesterday(al.log.starts_at), + cli::Period::ThisWeek => utils::date::is_this_week(al.log.starts_at), + cli::Period::LastWeek => utils::date::is_last_week(al.log.starts_at), + cli::Period::ThisMonth => utils::date::is_this_month(al.log.starts_at), + cli::Period::LastMonth => utils::date::is_last_month(al.log.starts_at), + } } fn list_activity_logs(conn: &mut Connection, args: &cli::ListActivityArgs) -> Result<()> { - let all: Vec<_> = activities::get_all(conn)? + let mut act_logs: Vec<_> = activities::get_all(conn)? .iter() .flat_map(PrintableActivityLog::from_activity) - .filter(|al| match args.period { - cli::Period::Today => utils::date::is_today(al.log.starts_at), - cli::Period::Yesterday => utils::date::is_yesterday(al.log.starts_at), - cli::Period::ThisWeek => utils::date::is_this_week(al.log.starts_at), - cli::Period::LastWeek => utils::date::is_last_week(al.log.starts_at), - cli::Period::ThisMonth => utils::date::is_this_month(al.log.starts_at), - cli::Period::LastMonth => utils::date::is_last_month(al.log.starts_at), - }) + .filter(|al| matches_period(al, &args.period)) .collect(); + act_logs.sort_by_key(|al| al.log.starts_at); + + if args.no_grouping { + return utils::common::list_printable_items(act_logs.to_vec(), args.use_json_format); + } + + let act_logs_by_date = group_by_date(&act_logs); + + if args.use_json_format { + let json = serde_json::to_string(&act_logs_by_date)?; + println!("{json}"); + return Ok(()); + } + + for (date, act_logs) in act_logs_by_date.iter() { + println!("{date}"); + utils::common::list_printable_items(act_logs.to_vec(), false)?; + } + Ok(()) +} + +fn group_by_date( + activity_logs: &[PrintableActivityLog], +) -> BTreeMap> { + let mut groups: BTreeMap<_, Vec<_>> = BTreeMap::new(); + + for act_log in activity_logs { + let latest_dt = act_log.log.ends_at.unwrap_or(Local::now()); + let key = DateTimeRenderMode::DateOnly.render_date_time(latest_dt); + groups.entry(key).or_default().push(act_log.clone()); + } - list_printable_items(all, args.use_json_format) + groups } diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 2781d77..c18f09e 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -1,2 +1,3 @@ pub mod activity; pub mod list; +pub mod query; diff --git a/src/commands/query.rs b/src/commands/query.rs new file mode 100644 index 0000000..abe8b92 --- /dev/null +++ b/src/commands/query.rs @@ -0,0 +1,21 @@ +use std::cmp::Reverse; + +use anyhow::Result; +use boat_lib::repository::tags_repository as tags; +use rusqlite::Connection; + +use crate::{cli, models::tag::PrintableTag, utils}; + +pub fn query_subcommand(conn: &mut Connection, command: &cli::QuerySubcommand) -> Result<()> { + todo!() +} + +fn list_tags(conn: &mut Connection, args: &cli::ListArgs) -> Result<()> { + let mut all_tags: Vec<_> = tags::get_all(conn)? + .iter() + .map(PrintableTag::from_tag) + .collect(); + all_tags.sort_by_key(|t| Reverse(t.id)); + + utils::common::list_printable_items(all_tags, args.use_json_format) +} diff --git a/src/main.rs b/src/main.rs index d9b96a0..51a93aa 100644 --- a/src/main.rs +++ b/src/main.rs @@ -16,7 +16,7 @@ fn main() -> ExitCode { env_logger::Builder::new() .filter_module("boat-cli", LevelFilter::Warn) .filter_module("boat-lib", LevelFilter::Debug) - // .filter_level(args.verbose.log_level_filter()) + .filter_level(args.verbose.log_level_filter()) .init(); info!("process cli args"); @@ -48,9 +48,10 @@ fn process_args(args: Cli) -> Result<()> { cli::Commands::Modify(args) => commands::activity::modify(&mut conn, args), cli::Commands::Delete(args) => commands::activity::delete(&mut conn, args), cli::Commands::Get(args) => commands::activity::get_current(&mut conn, args), - // cli::Commands::Config {} => todo!(), cli::Commands::HelpExtension => print_help(), - cli::Commands::List { command } => commands::list::list(&mut conn, command), + cli::Commands::Query { command } => commands::query::query_subcommand(&mut conn, command), + cli::Commands::Cancel => commands::activity::cancel_current(&mut conn), + cli::Commands::List(args) => commands::list::list_activities(&mut conn, args), } } diff --git a/src/models/activity.rs b/src/models/activity.rs index 1b9ef09..96827f4 100644 --- a/src/models/activity.rs +++ b/src/models/activity.rs @@ -34,6 +34,31 @@ impl PrintableActivity { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn tags_str_renders_comma_separated() { + let mut tags = HashSet::new(); + tags.insert("foo".to_owned()); + tags.insert("bar".to_owned()); + + let act = PrintableActivity { + id: 42, + name: "n".to_owned(), + description: None, + ongoing: false, + tags, + }; + let tags_str = act.tags_str(); + + assert!(tags_str.contains("foo")); + assert!(tags_str.contains("bar")); + assert!(tags_str.find(',').is_some()); + } +} + impl RowPrintable for PrintableActivity { fn row_spec() -> String { "{:>} {:<} {:<} {:<} {:^}".to_string() diff --git a/src/models/activity_log.rs b/src/models/activity_log.rs index 74583ee..4bb8a9d 100644 --- a/src/models/activity_log.rs +++ b/src/models/activity_log.rs @@ -1,9 +1,10 @@ use boat_lib::models::activity::Activity as DatabaseActivity; use serde::{Deserialize, Serialize}; +use yansi::Paint; use crate::{ models::{RowPrintable, activity::PrintableActivity, log::PrintableLog}, - utils, + utils::{self, date::DateTimeRenderMode}, }; #[derive(Debug, Serialize, Deserialize, Clone)] @@ -46,6 +47,7 @@ impl RowPrintable for PrintableActivityLog { } fn row_values(&self) -> Vec { + let dt_render = DateTimeRenderMode::TimeOnly; let duration = self.log.duration_sec(); vec![ @@ -53,12 +55,20 @@ impl RowPrintable for PrintableActivityLog { self.activity.name.clone(), self.activity.description.clone().unwrap_or_default(), self.activity.tags_str(), - self.log.starts_at.format("%H:%M").to_string(), + dt_render.render_date_time(self.log.starts_at), self.log .ends_at - .map(|t| t.format("%H:%M").to_string()) + .map(|t| dt_render.render_date_time(t)) .unwrap_or("-".to_string()), utils::date::pretty_format_duration(duration), ] } + + fn style_cell(&self, value: String) -> String { + if self.log.ends_at.is_none() { + Paint::green(&value).to_string() + } else { + value + } + } } diff --git a/src/models/log.rs b/src/models/log.rs index ac21fd5..fd52499 100644 --- a/src/models/log.rs +++ b/src/models/log.rs @@ -15,9 +15,29 @@ impl PrintableLog { ends_at: log.ends_at.map(|t| t.with_timezone(&Local)), } } - pub fn duration_sec(&self) -> i64 { let end = self.ends_at.unwrap_or(Local::now()); (end - self.starts_at).num_seconds() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_duration_sec() { + let now = Local::now(); + let log = PrintableLog { + starts_at: now, + ends_at: Some(now + chrono::Duration::seconds(60)), + }; + assert_eq!(log.duration_sec(), 60); + + let log = PrintableLog { + starts_at: now, + ends_at: None, + }; + assert!(log.duration_sec() >= 0); // Should not panic + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 68a9787..34dc0bb 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -14,6 +14,9 @@ pub trait RowPrintable { fn row_spec() -> String; fn header_names() -> Vec; fn row_values(&self) -> Vec; + fn style_cell(&self, value: String) -> String { + value + } } impl TablePrintable for Vec @@ -32,7 +35,8 @@ where for item in self.iter() { let mut row = Row::new(); for value in item.row_values() { - row.add_ansi_cell(value.to_string()); + let styled = item.style_cell(value.to_string()); + row.add_ansi_cell(styled); } table.add_row(row); } diff --git a/src/models/tag.rs b/src/models/tag.rs index aaffadf..435fa15 100644 --- a/src/models/tag.rs +++ b/src/models/tag.rs @@ -19,6 +19,21 @@ impl PrintableTag { } } +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn parse_from_tag_works() { + let db_tag = DatabaseTag { + id: 1, + name: "foo".to_string(), + }; + let tag = PrintableTag::from_tag(&db_tag); + assert_eq!(tag.id, 1); + assert_eq!(tag.name, "foo"); + } +} + impl RowPrintable for PrintableTag { fn row_spec() -> String { "{:>} {:<}".to_string() diff --git a/src/utils/common.rs b/src/utils/common.rs index 8b13789..13be5f7 100644 --- a/src/utils/common.rs +++ b/src/utils/common.rs @@ -1 +1,19 @@ +use anyhow::Result; +use serde::Serialize; +use crate::models::{RowPrintable, TablePrintable}; + +pub fn list_printable_items( + items: Vec, + show_as_json: bool, +) -> Result<()> { + if show_as_json { + let json = serde_json::to_string(&items)?; + println!("{json}"); + return Ok(()); + } + + let table = items.to_printable_table(); + println!("{table}"); + Ok(()) +} diff --git a/src/utils/date.rs b/src/utils/date.rs index 2154232..cb77bbb 100644 --- a/src/utils/date.rs +++ b/src/utils/date.rs @@ -13,17 +13,55 @@ pub fn pretty_format_duration(mut seconds: i64) -> String { parts.push(format!("{hours}h")); } - if minutes > 0 || hours > 0 { + if minutes > 0 { parts.push(format!("{minutes}m")); } - if parts.len() < 2 && (seconds > 0 || parts.is_empty()) { + if hours == 0 && minutes == 0 { parts.push(format!("{seconds}s")); } parts.join(" ") } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pretty_format_duration() { + assert_eq!(&pretty_format_duration(0), "0s"); + assert_eq!(&pretty_format_duration(42), "42s"); + assert_eq!(&pretty_format_duration(60), "1m"); + assert_eq!(&pretty_format_duration(61), "1m"); + assert_eq!(&pretty_format_duration(3600), "1h"); + assert_eq!(&pretty_format_duration(3601), "1h"); + assert_eq!(&pretty_format_duration(3661), "1h 1m"); + } +} + +pub enum DateTimeRenderMode { + TimeOnly, + DateOnly, + DateAndTime, +} + +impl DateTimeRenderMode { + pub fn render_date_time(&self, dt: chrono::DateTime) -> String + where + Tz: chrono::TimeZone, + Tz::Offset: std::fmt::Display, + { + let fmt = match self { + DateTimeRenderMode::TimeOnly => "%H:%M", + DateTimeRenderMode::DateOnly => "%Y-%m-%d", + DateTimeRenderMode::DateAndTime => "%Y-%m-%d %H:%M", + }; + + dt.format(fmt).to_string() + } +} + pub fn is_today(dt: DateTime) -> bool where Tz: chrono::TimeZone, @@ -129,3 +167,21 @@ pub fn parse_date(s: &str) -> Result { NaiveDate::parse_from_str(s, "%Y-%m-%d") .map_err(|_| format!("invalid date '{s}', expected format YYYY-MM-DD")) } + +#[cfg(test)] +mod parse_date_tests { + use super::*; + + #[test] + fn parse_date_valid_should_succeed() { + assert_eq!( + parse_date("2023-08-14").unwrap(), + NaiveDate::from_ymd_opt(2023, 8, 14).unwrap() + ); + } + #[test] + fn parse_date_fails_invalid_should_fail() { + let e = parse_date("nope").unwrap_err(); + assert!(e.contains("invalid date")); + } +} diff --git a/tests/cli_activity_flow.rs b/tests/cli_activity_flow.rs new file mode 100644 index 0000000..28a0be5 --- /dev/null +++ b/tests/cli_activity_flow.rs @@ -0,0 +1,51 @@ +//! Test basic activity CRUD and flow in the CLI +use assert_cmd::Command; +use predicates::prelude::*; +use std::fs; +use tempfile::TempDir; + +/// Helper to spin up a temp config + db directory and return required CLI args +fn cli_args_for_temp() -> (TempDir, String) { + let tmp = TempDir::new().unwrap(); + let db_path = tmp.path().join("boat.db"); + let config_path = tmp.path().join("boat_config.toml"); + fs::write( + &config_path, + format!("database_path = {:?}", db_path.display()), + ) + .unwrap(); + (tmp, config_path.display().to_string()) +} + +#[test] +fn can_create_start_pause_list_activity() { + let (_tmp, config_path) = cli_args_for_temp(); + + // boat new + let mut cmd = Command::cargo_bin("boat").unwrap(); + cmd.env("BOAT_CONFIG", &config_path) + .arg("new") + .arg("TestTask"); + cmd.assert().success(); + + // boat start + let mut cmd = Command::cargo_bin("boat").unwrap(); + cmd.env("BOAT_CONFIG", &config_path).arg("start").arg("1"); + cmd.assert().success(); + + // boat pause + let mut cmd = Command::cargo_bin("boat").unwrap(); + cmd.env("BOAT_CONFIG", &config_path).arg("pause"); + cmd.assert().success().stdout( + predicates::str::contains("stopped").or(predicates::str::contains("stopped activity")), + ); + + // boat list --json, just check output contains the activity name 'TestTask' + let mut cmd = Command::cargo_bin("boat").unwrap(); + cmd.env("BOAT_CONFIG", &config_path) + .arg("list") + .arg("--json"); + cmd.assert() + .success() + .stdout(predicates::str::contains("TestTask")); +} diff --git a/tests/cli_errors.rs b/tests/cli_errors.rs new file mode 100644 index 0000000..6e7cc31 --- /dev/null +++ b/tests/cli_errors.rs @@ -0,0 +1,59 @@ +//! Tests for error/failure scenarios in the CLI +use assert_cmd::Command; +use predicates::prelude::*; +use std::fs; +use tempfile::TempDir; + +fn cli_args_for_temp() -> (TempDir, String) { + let tmp = TempDir::new().unwrap(); + let db_path = tmp.path().join("boat.db"); + let config_path = tmp.path().join("boat_config.toml"); + fs::write( + &config_path, + format!("database_path = {:?}", db_path.display()), + ) + .unwrap(); + (tmp, config_path.display().to_string()) +} + +#[test] +fn new_without_name_should_fail() { + let (_tmp, config_path) = cli_args_for_temp(); + let mut cmd = Command::cargo_bin("boat").unwrap(); + cmd.env("BOAT_CONFIG", &config_path).arg("new"); + + // purposely omit activity name + cmd.assert().failure().stderr( + predicates::str::contains("error").or(predicates::str::contains("required arguments")), + ); +} + +#[test] +fn list_mutually_exclusive_args_should_fail() { + let (_tmp, config_path) = cli_args_for_temp(); + let mut cmd = Command::cargo_bin("boat").unwrap(); + + cmd.env("BOAT_CONFIG", &config_path) + .arg("list") + .arg("--period") + .arg("today") + .arg("--date") + .arg("2024-05-01"); + cmd.assert() + .failure() + .stderr(predicates::str::contains("cannot be used with")); +} + +#[test] +fn list_with_invalid_date_input_should_fail() { + let (_tmp, config_path) = cli_args_for_temp(); + let mut cmd = Command::cargo_bin("boat").unwrap(); + + cmd.env("BOAT_CONFIG", &config_path) + .arg("list") + .arg("--date") + .arg("not-a-date"); + cmd.assert() + .failure() + .stderr(predicates::str::contains("invalid date")); +} diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs new file mode 100644 index 0000000..d129090 --- /dev/null +++ b/tests/cli_smoke.rs @@ -0,0 +1,39 @@ +//! Basic smoke tests: help, version, invalid command +use assert_cmd::Command; +use predicates::prelude::PredicateBooleanExt; + +#[test] +fn test_help_arg() { + let mut cmd = Command::cargo_bin("boat").unwrap(); + cmd.arg("--help"); + cmd.assert() + .success() + .stdout(predicates::str::contains("Usage").or(predicates::str::contains("USAGE"))); +} + +#[test] +fn test_help_subcommand_short_alias() { + let mut cmd = Command::cargo_bin("boat").unwrap(); + cmd.arg("h"); + cmd.assert() + .success() + .stdout(predicates::str::contains("Usage").or(predicates::str::contains("USAGE"))); +} + +#[test] +fn test_version_arg() { + let mut cmd = Command::cargo_bin("boat").unwrap(); + cmd.arg("--version"); + cmd.assert() + .success() + .stdout(predicates::str::contains("boat")); +} + +#[test] +fn test_unknown_subcommand_fails() { + let mut cmd = Command::cargo_bin("boat").unwrap(); + cmd.arg("definitely-not-a-command"); + cmd.assert().failure().stderr( + predicates::str::contains("error").or(predicates::str::contains("not a valid subcommand")), + ); +}