diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..29f636b --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,28 @@ +name: Build + +on: + push: + branches: ["main"] + pull_request: + branches: ["main"] + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: Swatinem/rust-cache@v2 + + - name: Build + run: cargo build --verbose + - name: Run tests + run: cargo test --verbose + - name: Run clippy + run: cargo clippy --all-targets --all-features -- -D warnings + - name: Check formatting + run: cargo fmt --check diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7e84e26 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,61 @@ +name: Release + +on: + push: + tags: + - "[0-9]+.[0-9]+.[0-9]+" + +permissions: + contents: write + +jobs: + build-and-upload: + name: Build and upload + runs-on: ${{ matrix.os }} + + strategy: + matrix: + include: + - build: linux + os: ubuntu-latest + target: x86_64-unknown-linux-gnu + - build: macos-intel + os: macos-latest + target: x86_64-apple-darwin + - build: macos-arm + os: macos-latest + target: aarch64-apple-darwin + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - uses: Swatinem/rust-cache@v2 + + - name: Get the release version from the tag + shell: bash + run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Build + run: cargo build --verbose --release --target ${{ matrix.target }} + + - name: Build archive + shell: bash + run: | + binary_name="tabbs" + dirname="$binary_name-${{ env.VERSION }}-${{ matrix.target }}" + mkdir "$dirname" + mv "target/${{ matrix.target }}/release/$binary_name" "$dirname" + tar -czf "$dirname.tar.gz" "$dirname" + echo "ASSET=$dirname.tar.gz" >> $GITHUB_ENV + + - name: Release + uses: softprops/action-gh-release@v2 + with: + files: | + ${{ env.ASSET }} diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml deleted file mode 100644 index 31000a2..0000000 --- a/.github/workflows/rust.yml +++ /dev/null @@ -1,22 +0,0 @@ -name: Rust - -on: - push: - branches: [ "main" ] - pull_request: - branches: [ "main" ] - -env: - CARGO_TERM_COLOR: always - -jobs: - build: - - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v3 - - name: Build - run: cargo build --verbose - - name: Run tests - run: cargo test --verbose diff --git a/Cargo.lock b/Cargo.lock index 94163c6..4446304 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,6 +1,62 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" [[package]] name = "atty" @@ -13,6 +69,64 @@ dependencies = [ "winapi", ] +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + [[package]] name = "colored" version = "2.0.0" @@ -24,6 +138,68 @@ dependencies = [ "winapi", ] +[[package]] +name = "equivalent" +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 = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" +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", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.1.19" @@ -33,23 +209,286 @@ dependencies = [ "libc", ] +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", + "serde", + "serde_core", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + [[package]] name = "lazy_static" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646" +[[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.142" +version = "0.2.182" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a987beff54b60ffa6d51982e1aa1146bc42f19bd26be28b0586f252fccf5317" +checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" [[package]] -name = "padds" -version = "0.1.0" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[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 = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tabbs" +version = "0.2.0" dependencies = [ + "clap", "colored", + "tempfile", +] + +[[package]] +name = "tempfile" +version = "3.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[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-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]] @@ -73,3 +512,112 @@ 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 = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[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 = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index 706e68f..4bf98e8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,17 @@ [package] -name = "padds" -version = "0.1.0" +name = "tabbs" +version = "0.2.0" edition = "2021" +description = "A command line tool for displaying delimiter-separated data as a table in the terminal" +license = "MIT" +repository = "https://github.com/ztroop/tabbs" +homepage = "https://github.com/ztroop/tabbs" +keywords = ["cli", "table", "csv", "terminal", "formatting"] +categories = ["command-line-utilities", "development-tools"] [dependencies] -colored = "2.0" \ No newline at end of file +clap = { version = "4.4", features = ["derive"] } +colored = "2.0" + +[dev-dependencies] +tempfile = "3" \ No newline at end of file diff --git a/README.md b/README.md index eaab200..e8091e8 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,43 @@ -[![Rust](https://github.com/ztroop/tabbs/actions/workflows/rust.yml/badge.svg)](https://github.com/ztroop/tabbs/actions/workflows/rust.yml) +[![Build](https://github.com/ztroop/tabbs/actions/workflows/build.yml/badge.svg)](https://github.com/ztroop/tabbs/actions/workflows/build.yml) # tabbs -A command line tool for displaying comma-separated data as a table in the terminal. +A command line tool for displaying delimiter-separated data as a table in the terminal. ## Features -- Takes input from stdin -- Supports specifying column names +- Takes input from stdin or a file +- Supports specifying column names or using first row as header +- Customizable delimiter (comma, tab, pipe, etc.) +- Right-align numeric columns - Configurable header and cell colors - Easy to use ## Installation -To install tabb, you will need Rust and Cargo installed on your system. You can find installation instructions at [https://www.rust-lang.org/tools/install](https://www.rust-lang.org/tools/install). +To install tabbs, you will need [Rust and Cargo](https://www.rust-lang.org/tools/install) installed on your system. -Once Rust and Cargo are installed, you can build and install tabb using the following command: +```sh +cargo install --path . +``` + +Or install from crates.io (when published): ```sh -$ cargo install --path . +cargo install tabbs ``` ## Usage -To use tabb, you need to pipe input data to the tool, specifying the column names with the `-c` flag: +Pipe delimiter-separated data to tabbs and specify column names with the `-c` flag: ```sh -$ echo "jack,35,neat\n\ - jane,50,cool\n\ - erin,20,nice" | tabb -c "name,age,text" +printf "jack,35,neat\njane,50,cool\nerin,20,nice" | tabbs -c "name,age,text" ``` -This will produce the following output: +Output: -```sh +``` +------+-----+------+ | name | age | text | +------+-----+------+ @@ -43,22 +47,76 @@ This will produce the following output: +------+-----+------+ ``` -## Specifying Colors +### Command line options + +| Option | Short | Description | +|--------|-------|-------------| +| `--columns` | `-c` | Column names, separated by the delimiter (required unless `--header-row`) | +| `--file` | `-f` | Read input from file instead of stdin | +| `--delimiter` | `-d` | Field delimiter (default: `,`) | +| `--header-row` | | Use the first line of input as column names | +| `--no-header` | | Do not print the header row | +| `--align-numeric` | | Right-align columns that contain only numbers | +| `--header-color` | | Color for the header row | +| `--cell-color` | | Color for cell text | +| `--help` | `-h` | Print help | + +Supported colors: `black`, `red`, `green`, `yellow`, `blue`, `magenta`, `cyan`, `white`, and their `bright_` variants. + +### File input -You can also specify the header and cell colors using the `--header-color` and `--cell-color` flags: +Read from a file with `-f`: ```sh -$ echo "jack,35,neat\n\ - jane,50,cool\n\ - erin,20,ah" | tabb -c "name,age,text" --header-color blue --cell-color green +tabbs -f data.csv -c "name,age,city" ``` -This will produce a table with blue headers and green cell text. +### Header from first row + +When your data includes a header line, use `--header-row` instead of `-c`: + +```sh +printf "name,age\njack,35\njane,50" | tabbs --header-row +``` + +### Right-align numbers + +Use `--align-numeric` to right-align columns where all values are numbers: + +```sh +printf "name,age,score\nalice,30,95.5\nbob,25,87" | tabbs --header-row --align-numeric +``` + +### Custom delimiter + +Use `-d` or `--delimiter` for tab-separated, pipe-separated, or other formats: + +```sh +# Tab-separated (use $'\t' in bash; column names must use the same delimiter) +printf "alice\t30\nbob\t25" | tabbs -c $'name\tage' -d $'\t' + +# Pipe-separated +printf "alice|30\nbob|25" | tabbs -c "name|age" -d "|" +``` + +### Colors + +```sh +printf "jack,35,neat\njane,50,cool\nerin,20,nice" | tabbs -c "name,age,text" --header-color blue --cell-color green +``` + +## Limitations + +- Simple delimiter-separated parsing only; quoted fields (e.g. `"Smith, John",42`) are not supported +- Rows with fewer columns than the header are padded with empty cells +- Rows with more columns than the header are truncated +- Empty lines in input are skipped +- Input is read fully into memory (very large files may use significant memory) ## Contributing -If you would like to contribute to the project, feel free to submit a pull request on GitHub. +Contributions are welcome. Please open an issue or pull request on [GitHub](https://github.com/ztroop/tabbs). ## License -This project is released under the MIT License. See the LICENSE file for details. +MIT License. See [LICENSE](LICENSE) for details. diff --git a/src/main.rs b/src/main.rs index 659f798..89da72e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,11 +1,9 @@ -//! tabbs is a command line tool to print comma-separated data as a table. +//! tabbs is a command line tool to print delimiter-separated data as a table. //! //! Example usage: //! //! ```sh -//! echo "jack,35,neat\n\ -//! jane,50,cool\n\ -//! erin,20,ah" | tabb -c "name,age,text" +//! printf "jack,35,neat\njane,50,cool\nerin,20,nice" | tabbs -c "name,age,text" //! ``` //! //! This will produce the following output: @@ -20,55 +18,184 @@ //! +------+-----+------+ //! ``` -use colored::*; -use std::env; +use clap::Parser; +use colored::Colorize; +use std::fs; use std::io::{self, Read, Write}; -use std::process; +use std::path::PathBuf; + +/// A command line tool for displaying delimiter-separated data as a table in the terminal. +#[derive(Parser, Debug)] +#[command(name = "tabbs")] +#[command( + about = "Display delimiter-separated data as a table", + long_about = "Reads delimiter-separated values from stdin or a file and prints them as a formatted table. \ + Supports custom delimiters (comma, tab, pipe, etc.) and optional header/cell colors." +)] +struct Args { + /// Column names, separated by the delimiter (e.g., "name,age,text"). Not needed with --header-row. + #[arg(short = 'c', long)] + columns: Option, + + /// Read input from file instead of stdin + #[arg(short = 'f', long)] + file: Option, + + /// Field delimiter (default: comma) + #[arg(short = 'd', long, default_value = ",")] + delimiter: String, + + /// Use the first line of input as column names + #[arg(long)] + header_row: bool, + + /// Do not print the header row + #[arg(long)] + no_header: bool, + + /// Right-align columns that contain only numbers + #[arg(long)] + align_numeric: bool, + + /// Color for the header row (e.g., blue, red, green) + #[arg(long)] + header_color: Option, + + /// Color for the cell text (e.g., blue, red, green) + #[arg(long)] + cell_color: Option, +} -/// The main function reads the command line arguments and standard input, -/// then calls the `print_table_to_writer` function to print the table to stdout. -/// fn main() { - let args: Vec = env::args().collect(); - if args.len() < 3 || args[1] != "-c" { - eprintln!( - "Usage: tabb -c \"column1,column2,...\" [--header-color COLOR] [--cell-color COLOR]" - ); - process::exit(1); - } + let args = Args::parse(); - let column_names: Vec<&str> = args[2].split(',').collect(); + let delimiter: &str = args.delimiter.as_str(); + if delimiter.is_empty() { + eprintln!("Error: delimiter cannot be empty"); + std::process::exit(1); + } - let header_color = args - .iter() - .position(|arg| arg == "--header-color") - .and_then(|pos| args.get(pos + 1).map(|s| s.to_owned())); - let cell_color = args - .iter() - .position(|arg| arg == "--cell-color") - .and_then(|pos| args.get(pos + 1).map(|s| s.to_owned())); + if args.columns.is_none() && !args.header_row { + eprintln!("Error: either --columns (-c) or --header-row is required"); + std::process::exit(1); + } - let stdin = io::stdin(); let mut input = String::new(); - stdin - .lock() - .read_to_string(&mut input) - .expect("Failed to read input"); - - let rows: Vec> = input - .split_whitespace() - .map(|line| line.split(',').map(|s| s.trim().to_string()).collect()) - .collect(); - - let stdout = io::stdout(); - let mut handle = stdout.lock(); - print_table_to_writer( - &column_names, - &rows, - header_color.as_deref(), - cell_color.as_deref(), - &mut handle, - ); + if let Some(path) = &args.file { + input = fs::read_to_string(path).unwrap_or_else(|e| { + eprintln!("Error: failed to read file {}: {e}", path.display()); + std::process::exit(1); + }); + } else if let Err(e) = io::stdin().lock().read_to_string(&mut input) { + eprintln!("Error: failed to read input: {e}"); + std::process::exit(1); + } + + let (column_names, padded_rows) = if args.header_row { + let all_rows = parse_input(&input, delimiter); + if all_rows.is_empty() { + eprintln!("Error: no data to parse (empty input or no valid lines)"); + std::process::exit(1); + } + let mut rows = all_rows; + let header = rows.remove(0); + let num_columns = header.len(); + let column_names: Vec = header; + let padded_rows: Vec> = rows + .into_iter() + .map(|row| { + let mut padded = row; + padded.resize(num_columns, String::new()); + padded + }) + .collect(); + (column_names, padded_rows) + } else { + let column_names: Vec = args + .columns + .as_deref() + .unwrap() + .split(delimiter) + .map(|s| s.trim().to_string()) + .collect(); + + if column_names.is_empty() || column_names.iter().all(|s| s.is_empty()) { + eprintln!("Error: at least one non-empty column name is required"); + std::process::exit(1); + } + + let rows = parse_input(&input, delimiter); + let num_columns = column_names.len(); + let padded_rows: Vec> = rows + .into_iter() + .map(|row| { + let mut padded = row; + padded.resize(num_columns, String::new()); + padded + }) + .collect(); + (column_names, padded_rows) + }; + + let column_names_ref: Vec<&str> = column_names.iter().map(|s| s.as_str()).collect(); + let mut stdout = io::stdout().lock(); + if let Err(e) = print_table_to_writer( + &column_names_ref, + &padded_rows, + args.header_color.as_deref(), + args.cell_color.as_deref(), + args.no_header, + args.align_numeric, + &mut stdout, + ) { + eprintln!("Error: failed to write output: {e}"); + std::process::exit(1); + } +} + +/// Parse delimiter-separated input into rows of cells. +/// +/// Skips empty lines and trims whitespace from each cell. +/// +/// # Arguments +/// +/// * `input` - The raw input string (typically from stdin). +/// * `delimiter` - The field delimiter (e.g. `,`, `\t`, `|`). +/// +/// # Returns +/// +/// A vector of rows, where each row is a vector of cell strings. +fn parse_input(input: &str, delimiter: &str) -> Vec> { + input + .lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + line.split(delimiter) + .map(|s| s.trim().to_string()) + .collect() + }) + .collect() +} + +fn is_numeric(s: &str) -> bool { + let s = s.trim(); + if s.is_empty() { + return false; + } + s.parse::().is_ok() +} + +/// Determine which columns should be right-aligned (all data cells are numeric). +fn numeric_columns(column_names: &[&str], rows: &[Vec]) -> Vec { + let mut numeric = vec![true; column_names.len()]; + for row in rows { + for (i, cell) in row.iter().enumerate() { + if i < numeric.len() && numeric[i] && !is_numeric(cell) { + numeric[i] = false; + } + } + } + numeric } /// Print a table with the given column names, row data, optional header and cell colors to the provided writer. @@ -76,18 +203,25 @@ fn main() { /// # Arguments /// /// * `column_names` - A slice of strings representing the column names. -/// * `rows` - A slice of Vec representing the rows of data. +/// * `rows` - A slice of Vec representing the rows of data (each row should have the same length as column_names). /// * `header_color` - An optional string specifying the color of the header text. /// * `cell_color` - An optional string specifying the color of the cell text. +/// * `no_header` - If true, do not print the header row. +/// * `align_numeric` - If true, right-align columns that contain only numbers. /// * `writer` - A mutable reference to a writer implementing the `Write` trait. /// +/// # Returns +/// +/// `Ok(())` on success, or an `Err` if writing fails. fn print_table_to_writer( column_names: &[&str], rows: &[Vec], header_color: Option<&str>, cell_color: Option<&str>, + no_header: bool, + align_numeric: bool, writer: &mut impl Write, -) { +) -> io::Result<()> { let mut column_widths: Vec = column_names.iter().map(|s| s.len()).collect(); for row in rows { @@ -98,48 +232,59 @@ fn print_table_to_writer( } } + let right_align = if align_numeric { + numeric_columns(column_names, rows) + } else { + vec![false; column_names.len()] + }; + let separator: String = column_widths .iter() .map(|width| "-".repeat(width + 2)) .collect::>() .join("+"); - writeln!(writer, "+{}+", separator).unwrap(); - write!(writer, "|").unwrap(); - for (i, column_name) in column_names.iter().enumerate() { - let colored_column_name = header_color.map_or(column_name.to_string(), |color| { - column_name.color(color).to_string() - }); - write!( - writer, - " {:width$}", column_name, width = column_widths[i]) + } else { + format!("{: padded.color(color).to_string(), + None => padded, + }; + write!(writer, " {} |", display)?; + } + writeln!(writer)?; + writeln!(writer, "+{}+", separator)?; } - writeln!(writer).unwrap(); - writeln!(writer, "+{}+", separator).unwrap(); for row in rows { - write!(writer, "|").unwrap(); + write!(writer, "|")?; for (i, cell) in row.iter().enumerate() { if i < column_widths.len() { - let colored_cell = - cell_color.map_or(cell.to_string(), |color| cell.color(color).to_string()); - write!( - writer, - " {:width$}", cell.as_str(), width = column_widths[i]) + } else { + format!("{: padded.color(color).to_string(), + None => padded, + }; + write!(writer, " {} |", display)?; } } - writeln!(writer).unwrap(); + writeln!(writer)?; } - writeln!(writer, "+{}+", separator).unwrap(); + writeln!(writer, "+{}+", separator)?; + Ok(()) } #[cfg(test)] @@ -166,8 +311,276 @@ mod tests { let mut output = Vec::new(); { - let mut output_writer = std::io::BufWriter::new(output.by_ref()); - print_table_to_writer(&column_names, &rows, None, None, &mut output_writer); + let mut output_writer = std::io::BufWriter::new(&mut output); + print_table_to_writer( + &column_names, + &rows, + None, + None, + false, + false, + &mut output_writer, + ) + .unwrap(); + } + let output_str = String::from_utf8(output).unwrap(); + assert_eq!(output_str.trim(), expected_output); + } + + #[test] + fn test_print_table_with_varying_column_counts() { + let column_names = ["x", "y", "z"]; + let rows = [ + vec!["a".to_string(), "b".to_string()], + vec!["c".to_string(), "d".to_string(), "e".to_string()], + vec!["f".to_string()], + ]; + + let expected_output = "\ ++---+---+---+ +| x | y | z | ++---+---+---+ +| a | b | | +| c | d | e | +| f | | | ++---+---+---+"; + let mut output = Vec::new(); + + let padded_rows: Vec> = rows + .iter() + .map(|row| { + let mut padded = row.clone(); + padded.resize(column_names.len(), String::new()); + padded + }) + .collect(); + + { + let mut output_writer = std::io::BufWriter::new(&mut output); + print_table_to_writer( + &column_names, + &padded_rows, + None, + None, + false, + false, + &mut output_writer, + ) + .unwrap(); + } + let output_str = String::from_utf8(output).unwrap(); + assert_eq!(output_str.trim(), expected_output); + } + + #[test] + fn test_print_table_empty_input() { + let column_names = ["a", "b"]; + let rows: Vec> = vec![]; + + let expected_output = "\ ++---+---+ +| a | b | ++---+---+ ++---+---+"; + let mut output = Vec::new(); + + { + let mut output_writer = std::io::BufWriter::new(&mut output); + print_table_to_writer( + &column_names, + &rows, + None, + None, + false, + false, + &mut output_writer, + ) + .unwrap(); + } + let output_str = String::from_utf8(output).unwrap(); + assert_eq!(output_str.trim(), expected_output); + } + + #[test] + fn test_print_table_with_colors() { + let column_names = ["name", "age"]; + let rows = [ + vec!["alice".to_string(), "30".to_string()], + vec!["bob".to_string(), "25".to_string()], + ]; + + let mut output = Vec::new(); + { + let mut output_writer = std::io::BufWriter::new(&mut output); + print_table_to_writer( + &column_names, + &rows, + Some("blue"), + Some("green"), + false, + false, + &mut output_writer, + ) + .unwrap(); + } + let output_str = String::from_utf8(output).unwrap(); + let lines: Vec<&str> = output_str.trim().lines().collect(); + assert_eq!(lines.len(), 6); + assert_eq!(lines[0], lines[2]); + assert_eq!(lines[0], lines[5]); + assert!(lines[1].contains("name")); + assert!(lines[1].contains("age")); + } + + #[test] + fn test_parse_input_empty() { + let rows = parse_input("", ","); + assert!(rows.is_empty()); + } + + #[test] + fn test_parse_input() { + let input = "a,b,c\nd,e,f"; + let rows = parse_input(input, ","); + assert_eq!( + rows, + [ + vec!["a".to_string(), "b".to_string(), "c".to_string()], + vec!["d".to_string(), "e".to_string(), "f".to_string()], + ] + ); + } + + #[test] + fn test_parse_input_filters_empty_lines() { + let input = "a,b\n\n\nc,d\n"; + let rows = parse_input(input, ","); + assert_eq!( + rows, + [ + vec!["a".to_string(), "b".to_string()], + vec!["c".to_string(), "d".to_string()], + ] + ); + } + + #[test] + fn test_parse_input_trims_cells() { + let input = " a , b \n c , d "; + let rows = parse_input(input, ","); + assert_eq!( + rows, + [ + vec!["a".to_string(), "b".to_string()], + vec!["c".to_string(), "d".to_string()], + ] + ); + } + + #[test] + fn test_parse_input_custom_delimiter() { + let input = "a|b|c\nd|e|f"; + let rows = parse_input(input, "|"); + assert_eq!( + rows, + [ + vec!["a".to_string(), "b".to_string(), "c".to_string()], + vec!["d".to_string(), "e".to_string(), "f".to_string()], + ] + ); + } + + #[test] + fn test_print_table_no_header() { + let column_names = ["name", "age"]; + let rows = [ + vec!["alice".to_string(), "30".to_string()], + vec!["bob".to_string(), "25".to_string()], + ]; + + let expected_output = "\ ++-------+-----+ +| alice | 30 | +| bob | 25 | ++-------+-----+"; + let mut output = Vec::new(); + + { + let mut output_writer = std::io::BufWriter::new(&mut output); + print_table_to_writer( + &column_names, + &rows, + None, + None, + true, + false, + &mut output_writer, + ) + .unwrap(); + } + let output_str = String::from_utf8(output).unwrap(); + assert_eq!(output_str.trim(), expected_output); + } + + #[test] + fn test_print_table_align_numeric() { + let column_names = ["name", "age", "score"]; + let rows = [ + vec!["alice".to_string(), "30".to_string(), "95.5".to_string()], + vec!["bob".to_string(), "25".to_string(), "87".to_string()], + ]; + + let expected_output = "\ ++-------+-----+-------+ +| name | age | score | ++-------+-----+-------+ +| alice | 30 | 95.5 | +| bob | 25 | 87 | ++-------+-----+-------+"; + let mut output = Vec::new(); + + { + let mut output_writer = std::io::BufWriter::new(&mut output); + print_table_to_writer( + &column_names, + &rows, + None, + None, + false, + true, + &mut output_writer, + ) + .unwrap(); + } + let output_str = String::from_utf8(output).unwrap(); + assert_eq!(output_str.trim(), expected_output); + } + + #[test] + fn test_print_table_empty_column_names() { + let column_names = ["", ""]; + let rows = [vec!["x".to_string(), "y".to_string()]]; + + let expected_output = "\ ++---+---+ +| | | ++---+---+ +| x | y | ++---+---+"; + let mut output = Vec::new(); + + { + let mut output_writer = std::io::BufWriter::new(&mut output); + print_table_to_writer( + &column_names, + &rows, + None, + None, + false, + false, + &mut output_writer, + ) + .unwrap(); } let output_str = String::from_utf8(output).unwrap(); assert_eq!(output_str.trim(), expected_output); diff --git a/tests/integration_test.rs b/tests/integration_test.rs new file mode 100644 index 0000000..719427a --- /dev/null +++ b/tests/integration_test.rs @@ -0,0 +1,101 @@ +use std::io::Write; +use std::process::{Command, Stdio}; +use std::str; + +#[test] +fn test_binary_with_pipe_delimiter() { + let mut child = Command::new(env!("CARGO_BIN_EXE_tabbs")) + .args(["-c", "name|age", "-d", "|"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("failed to spawn tabbs"); + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(b"alice|30\nbob|25") + .expect("failed to write to stdin"); + } + + let output = child.wait_with_output().expect("failed to get output"); + let stdout = str::from_utf8(&output.stdout).unwrap(); + let stderr = str::from_utf8(&output.stderr).unwrap(); + assert!( + output.status.success(), + "tabbs failed: {}\nstdout: {}\nstderr: {}", + output.status, + stdout, + stderr + ); + assert!(stdout.contains("| name | age |")); + assert!(stdout.contains("| alice | 30 |")); + assert!(stdout.contains("| bob | 25 |")); +} + +#[test] +fn test_binary_empty_delimiter_errors() { + let output = Command::new(env!("CARGO_BIN_EXE_tabbs")) + .args(["-c", "a,b", "-d", ""]) + .stdin(Stdio::null()) + .output() + .expect("failed to run tabbs"); + + let stderr = str::from_utf8(&output.stderr).unwrap(); + let stdout = str::from_utf8(&output.stdout).unwrap(); + assert!( + !output.status.success(), + "expected tabbs to fail with empty delimiter\nstdout: {}\nstderr: {}", + stdout, + stderr + ); + assert!(stderr.contains("delimiter cannot be empty")); +} + +#[test] +fn test_binary_header_row() { + let mut child = Command::new(env!("CARGO_BIN_EXE_tabbs")) + .args(["--header-row", "-d", ","]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .spawn() + .expect("failed to spawn tabbs"); + + if let Some(mut stdin) = child.stdin.take() { + stdin + .write_all(b"name,age\nalice,30\nbob,25") + .expect("failed to write to stdin"); + } + + let output = child.wait_with_output().expect("failed to get output"); + let stdout = str::from_utf8(&output.stdout).unwrap(); + let stderr = str::from_utf8(&output.stderr).unwrap(); + assert!( + output.status.success(), + "tabbs failed: {}\nstdout: {}\nstderr: {}", + output.status, + stdout, + stderr + ); + assert!(stdout.contains("| name | age |")); + assert!(stdout.contains("| alice | 30 |")); +} + +#[test] +fn test_binary_file_input() { + use std::io::Write; + + let mut file = tempfile::NamedTempFile::new().expect("create temp file"); + file.write_all(b"a,b,c\n1,2,3\n4,5,6") + .expect("write temp file"); + let path = file.path(); + + let output = Command::new(env!("CARGO_BIN_EXE_tabbs")) + .args(["-c", "a,b,c", "-f", path.to_str().unwrap()]) + .output() + .expect("failed to run tabbs"); + + let stdout = str::from_utf8(&output.stdout).unwrap(); + assert!(output.status.success()); + assert!(stdout.contains("| a | b | c |")); + assert!(stdout.contains("| 1 | 2 | 3 |")); +}