diff --git a/.github/workflows/node.tests.yml b/.github/workflows/node.tests.yml deleted file mode 100644 index c644762..0000000 --- a/.github/workflows/node.tests.yml +++ /dev/null @@ -1,25 +0,0 @@ -name: Node Tests - -on: - push: - branches: [ "main", "develop" ] - pull_request: - branches: [ "main", "develop" ] - -jobs: - build: - runs-on: ubuntu-latest - - strategy: - matrix: - node-version: [18.x, 20.x, 22.x, 24.x] - - steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 - with: - node-version: ${{ matrix.node-version }} - - run: npm install - - run: npm run build - - run: npm run test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 8d7e9a9..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: GitHub Package - -on: - workflow_dispatch: - release: - types: [created] - -jobs: - build: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: latest - - run: npm install - - run: npm run build - - run: npm run test - - publish-gpr: - needs: build - runs-on: ubuntu-latest - permissions: - packages: write - contents: read - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - registry-url: https://npm.pkg.github.com/ - - run: npm install - - run: npm run build - - run: npm publish --access public - env: - NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/test.rust.yml b/.github/workflows/test.rust.yml new file mode 100644 index 0000000..208d796 --- /dev/null +++ b/.github/workflows/test.rust.yml @@ -0,0 +1,36 @@ +name: Test Rust + +on: + push: + branches: [ "main", "develop" ] + pull_request: + branches: [ "main", "develop" ] + +jobs: + test: + name: Test and Coverage + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v6 + + - name: Set up Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Lint + run: cargo clippy --all-targets --all-features -- -D warnings + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@cargo-llvm-cov + + - name: Generate coverage + run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: lcov.info + token: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: true diff --git a/.gitignore b/.gitignore index 640da1f..e8cc3ff 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ node_modules dotenv-*.tgz package-lock.json +target diff --git a/.npmrc b/.npmrc index b6f27f1..d26c2a7 100644 --- a/.npmrc +++ b/.npmrc @@ -1 +1,6 @@ -engine-strict=true +registry=https://registry.npmjs.org/ + +@mikegarde:registry=https://npm.pkg.github.com/ + +//registry.npmjs.org/:_authToken=${NPM_TOKEN} +//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN} diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..3173948 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,808 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +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 = "assert_cmd" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39bae1d3fa576f7c6519514180a72559268dd7d1fe104070956cb687bc6673bd" +dependencies = [ + "anstyle", + "bstr", + "libc", + "predicates", + "predicates-core", + "predicates-tree", + "wait-timeout", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + +[[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 = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + +[[package]] +name = "dotenv-cli" +version = "0.6.1" +dependencies = [ + "assert_cmd", + "clap", + "indexmap", + "is-terminal", + "predicates", + "serde_json", + "sha2", + "tempfile", +] + +[[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.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[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 = "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", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[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.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hybrid-array" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +dependencies = [ + "typenum", +] + +[[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.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys", +] + +[[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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[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.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[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" +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 = "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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +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 = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[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.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[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 = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[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 = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "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 = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[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 = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[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 0.51.0", +] + +[[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-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" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[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 new file mode 100644 index 0000000..076a6ec --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "dotenv-cli" +version = "0.6.1" +edition = "2021" +description = "Read and update dotenv files from the cli" +license = "GPL-3.0-or-later" + +[lib] +name = "dotenv_cli" +path = "src/lib.rs" + +[[bin]] +name = "dotenv" +path = "src/main.rs" + +[dependencies] +clap = { version = "4", features = ["derive"] } +serde_json = { version = "1", features = ["preserve_order"] } +indexmap = "2" +is-terminal = "0.4" + +[dev-dependencies] +assert_cmd = "2" +predicates = "3" +tempfile = "3" +sha2 = "0.11.0" diff --git a/README.md b/README.md index a868f25..5bb8084 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,32 @@ # dotenv-cli -[![NPM Version](https://img.shields.io/npm/v/%40mikegarde%2Fdotenv-cli)](https://www.npmjs.com/package/@mikegarde/dotenv-cli) -[![NPM Unpacked Size](https://img.shields.io/npm/unpacked-size/%40mikegarde%2Fdotenv-cli)](https://www.npmjs.com/package/@mikegarde/dotenv-cli) -[![NPM Downloads](https://img.shields.io/npm/dy/%40mikegarde%2Fdotenv-cli)](https://www.npmjs.com/package/@mikegarde/dotenv-cli) +[![Version](https://img.shields.io/github/v/release/mikegarde/dotenv-cli)](https://github.com/MikeGarde/dotenv-cli) [![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/mikegarde/dotenv-cli/node.tests.yml)](https://github.com/MikeGarde/dotenv-cli/actions) +[![Codecov](https://img.shields.io/codecov/c/github/mikegarde/dotenv-cli)](https://app.codecov.io/gh/MikeGarde/dotenv-cli) +[![NPM Downloads](https://img.shields.io/npm/dy/%40mikegarde%2Fdotenv-cli?logo=npm&color=blue)](https://www.npmjs.com/package/@mikegarde/dotenv-cli) +[![Crates.io Downloads](https://img.shields.io/crates/d/dotenv-cli?logo=crates&color=blue)](https://crates.io/crates/dotenv-cli) +[![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/mikegarde/dotenv-cli/total?logo=github&color=blue)](https://github.com/MikeGarde/dotenv-cli/releases) A simple way to retrieve, update, or delete .env variables directly from the command line. ## Install Find it on -[npm](https://www.npmjs.com/package/@mikegarde/dotenv-cli) or -[GitHub](https://github.com/MikeGarde/dotenv-cli) +[GitHub](https://github.com/MikeGarde/dotenv-cli), +[crates.io](https://crates.io/crates/dotenv-cli), or +[npm](https://www.npmjs.com/package/@mikegarde/dotenv-cli), -```shell +```bash +# Using Homebrew (macOS/Linux) +brew install mikegarde/tap/dotenv-cli + +# Using npm (Node.js) npm i -g @mikegarde/dotenv-cli + +# Using Cargo (Rust) +cargo install dotenv-cli ``` -## CLI Usage +## Usage Get a value from a .env file: @@ -30,38 +40,6 @@ Get a value from a .env.example file: dotenv --file .env.example ``` -### JSON - -By default multiple keys are returned as a JSON object. To return a single key as a JSON object, use the `--json` flag. -To not return a JSON object, use the `--no-json` flag. - -Return a .env file as JSON: - -```shell -dotenv -``` - -Wildcard search: - -```shell -dotenv "DB_*" -``` - -### Multiline Values - -The default behavior is to output a single line value. If you want to output a multiline value, -you can use the `--multiline` flag: - -```shell -$ dotenv RSA_KEY ------BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAKj34GkxFhD90vcNLYLInFEX6Ppy1tPf... - -$ dotenv RSA_KEY --multiline ------BEGIN RSA PRIVATE KEY----- -MIIBOgIBAAJBAKj34GkxFhD90vcNLYLInFEX6Ppy1tPf9Cnzj4p4WGeKLs1Pt8Qu -KUpRKfFLfRYC9AIKjbJTWit+CqvjWYzvQwECAwEAAQJAIJLixBy2qpFoS4DSmoEm -``` - ### Setting a Value Set a value in a .env file: @@ -84,19 +62,6 @@ Delete a value from a .env file: dotenv --delete ``` -### Using DOTENV_FILE Environment Variable - -You can define the `DOTENV_FILE` environment variable in your shell or script to specify the `.env` file to use, instead -of passing the `--file` option every time. - -```shell -export DOTENV_FILE=.env.example -dotenv -``` - -This will use the `.env.example` file automatically. If the `--file` option is provided, it will override the -`DOTENV_FILE` environment variable. - ## Examples ### RSA Key Pair @@ -139,3 +104,50 @@ $ dotenv | jq 'to_entries | map(select(.key | startswith("DB_")))[] | "\(.key)=\ "DB_USER=root" "DB_PASS=password" ``` + +## Other Stuff + +### JSON + +By default multiple keys are returned as a JSON object. To return a single key as a JSON object, use the `--json` flag. +To not return a JSON object, use the `--no-json` flag. + +Return a .env file as JSON: + +```shell +dotenv +``` + +Wildcard search: + +```shell +dotenv "DB_*" +``` + +### Multiline Values + +The default behavior is to output a single line value. If you want to output a multiline value, +you can use the `--multiline` flag: + +```shell +$ dotenv RSA_KEY +-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAKj34GkxFhD90vcNLYLInFEX6Ppy1tPf... + +$ dotenv RSA_KEY --multiline +-----BEGIN RSA PRIVATE KEY----- +MIIBOgIBAAJBAKj34GkxFhD90vcNLYLInFEX6Ppy1tPf9Cnzj4p4WGeKLs1Pt8Qu +KUpRKfFLfRYC9AIKjbJTWit+CqvjWYzvQwECAwEAAQJAIJLixBy2qpFoS4DSmoEm +``` + +### Using DOTENV_FILE Environment Variable + +You can define the `DOTENV_FILE` environment variable in your shell or script to specify the `.env` file to use, instead +of passing the `--file` option every time. + +```shell +export DOTENV_FILE=.env.example +dotenv +``` + +This will use the `.env.example` file automatically. If the `--file` option is provided, it will override the +`DOTENV_FILE` environment variable. diff --git a/Taskfile.yaml b/Taskfile.yaml index cd61260..5966cf1 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -6,42 +6,77 @@ tasks: cmds: - task --list --sort alphanumeric + setup: + desc: "Setup development environment" + silent: true + status: + - rustup target list --installed | grep -q x86_64-apple-darwin + - rustup target list --installed | grep -q aarch64-apple-darwin + - rustup target list --installed | grep -q x86_64-unknown-linux-gnu + - rustup target list --installed | grep -q aarch64-unknown-linux-gnu + - rustup target list --installed | grep -q x86_64-unknown-linux-musl + - rustup target list --installed | grep -q aarch64-unknown-linux-musl + - rustup target list --installed | grep -q x86_64-pc-windows-msvc + - rustup target list --installed | grep -q aarch64-pc-windows-msvc + cmds: + - rustup target add x86_64-apple-darwin + - rustup target add aarch64-apple-darwin + - rustup target add x86_64-unknown-linux-gnu + - rustup target add aarch64-unknown-linux-gnu + - rustup target add x86_64-unknown-linux-musl + - rustup target add aarch64-unknown-linux-musl + - rustup target add x86_64-pc-windows-msvc + - rustup target add aarch64-pc-windows-msvc + test: - desc: Run tests - deps: - - build + desc: "Run all tests" cmds: - - npm run test + - cargo clippy --all-targets --all-features -- -D warnings + - cargo test --all-features build: - desc: Compile TypeScript to JavaScript - cmds: - - npx tsc - sources: - - src/**/*.ts - generates: - - build/**/*.js - build:watch: - desc: Compile TypeScript to JavaScript and watch for changes - cmds: - - npx tsc -w - sources: - - src/**/*.ts - generates: - - build/**/*.js - build:clean: - desc: Remove build directory - cmds: - - rm -rf build - - git checkout -- build + desc: Build the project in release mode + cmds: + - task: build:release - dev-install: + build:*: + desc: Build the Rust binary with the requested Cargo profile + vars: + TARGET: '{{index .MATCH 0}}' + cmds: + - cargo build --profile {{.TARGET}} + + install: + desc: "Install binary locally via cargo install --path ." + cmds: + - task: uninstall + - cargo install --path . + install:brew: + desc: Install via Homebrew tap + cmds: + - task: uninstall + - brew install mikegarde/tap/dotenv-cli + install:npm: desc: Local development setup cmds: - - npm uninstall -g @mikegarde/dotenv-cli || true + - task: uninstall - npm install - - task build - - sudo npm install -g + - task: build:release + - npm install -g + install:npm:repo: + desc: Local development setup + cmds: + - task: uninstall + - npm install -g @mikegarde/dotenv-cli + uninstall: + desc: "Uninstall all local installations of dotenv-cli (cargo, npm, brew)" + internal: true + cmds: + - cargo uninstall dotenv-cli || true + - cargo uninstall --path . || true + - brew uninstall mikegarde/tap/dotenv-cli || true + - npm uninstall -g @mikegarde/dotenv-cli || true + - npm uninstall -g || true is-branch-safe: desc: Check if the current branch is main @@ -63,54 +98,37 @@ tasks: msg: "Not on main or develop branch" vars: STEP: '{{index .MATCH 0}}' - BRANCH: - sh: git rev-parse --abbrev-ref HEAD - PRERELEASE: - sh: | - if [ "{{.BRANCH}}" = "develop" ]; then - echo "--prerelease" - fi cmds: - - | - task build:clean - task test - - VERSION=$(gh release list --json tagName | jq -r '.[] | .tagName' | sort -V | tail -n 1) - MAJOR=$(echo $VERSION | cut -d. -f1) - MINOR=$(echo $VERSION | cut -d. -f2) - PATCH=$(echo $VERSION | cut -d. -f3) + - devops/release/1-bump-version.sh {{.STEP}} + - task test + - devops/release/2-tag-version.sh + - task release:assets + - devops/release/4-generate-release-notes.sh + - devops/release/5-create-github-release.sh + publish: + desc: Publish a release to npmjs and GitHub Packages + preconditions: + - task is-branch-safe + cmds: + - task: publish:npm + - task: publish:github - echo "Current version: $VERSION" - if [ "{{.STEP}}" = "major" ]; then - MAJOR=$((MAJOR+1)) - MINOR=0 - PATCH=0 - elif [ "{{.STEP}}" = "minor" ]; then - MINOR=$((MINOR+1)) - PATCH=0 - elif [ "{{.STEP}}" = "patch" ]; then - PATCH=$((PATCH+1)) - else - echo "Invalid step: $STEP" - exit 1 - fi - VERSION="$MAJOR.$MINOR.$PATCH" - echo "New version: $VERSION" + publish:npm: + desc: Publish a release to npmjs + preconditions: + - task is-branch-safe + cmds: + - npm run publish:npm - npm version $VERSION - git push - task build - zip -r build.zip build - gh release create "$VERSION" --generate-notes --target {{.BRANCH}} {{.PRERELEASE}} build.zip - rm build.zip - publish: - desc: publish a release to npm + publish:github: + desc: Publish a release to GitHub Packages preconditions: - task is-branch-safe cmds: - - npm publish --access public --registry https://registry.npmjs.org + - npm run publish:github + release:publish:*: - desc: Create a new release and publish it to npm, release-[patch|minor|major]-pub + desc: Create a new release and publish it to npmjs and GitHub Packages vars: STEP: '{{index .MATCH 0}}' cmds: diff --git a/bin/dotenv b/bin/dotenv new file mode 100755 index 0000000..5424a4e --- /dev/null +++ b/bin/dotenv @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const packageRoot = join(__dirname, '..'); + +const binaryName = process.platform === 'win32' ? 'dotenv.exe' : 'dotenv'; +const binaryPath = join(packageRoot, 'vendor', binaryName); + +if (!existsSync(binaryPath)) { + console.error( + 'dotenv binary was not found. Try reinstalling @mikegarde/dotenv-cli.' + ); + process.exit(1); +} + +const result = spawnSync(binaryPath, process.argv.slice(2), { + stdio: 'inherit', + env: process.env, +}); + +if (result.error) { + console.error(result.error.message); + process.exit(1); +} + +process.exit(result.status ?? 1); diff --git a/jest.config.js b/jest.config.js deleted file mode 100644 index e7aeae2..0000000 --- a/jest.config.js +++ /dev/null @@ -1,14 +0,0 @@ -/** @type {import('ts-jest').JestConfigWithTsJest} */ -export default { - preset: 'ts-jest', - testEnvironment: 'node', - extensionsToTreatAsEsm: ['.ts'], - transform: { - '^.+\\.ts$': 'ts-jest', - }, - moduleNameMapper: { - '^(\\.{1,2}/.*)\\.js$': '$1', - }, - testMatch: ['/tests/**/*.tests.ts'], - verbose: true, -}; diff --git a/package.json b/package.json index 696ea11..7b66a6a 100644 --- a/package.json +++ b/package.json @@ -2,20 +2,22 @@ "name": "@mikegarde/dotenv-cli", "version": "0.6.1", "description": "Read and update dotenv files from the cli", - "main": "build/app.js", - "types": "build/app.d.ts", "bin": { - "dotenv": "./build/app.js" + "dotenv": "./bin/dotenv" }, "type": "module", "files": [ - "build/", - "package.json", - "README.md" + "bin/", + "postinstall.js", + "vendor/" ], "scripts": { - "test": "npx jest", - "build": "npx tsc" + "build": "cargo build --release", + "test": "cargo clippy --all-targets --all-features -- -D warnings && cargo test --all-features", + "pack:dry-run": "npm pack --dry-run", + "publish:npm": "npm publish --access public --registry https://registry.npmjs.org", + "publish:github": "npm publish --registry https://npm.pkg.github.com", + "postinstall": "node ./postinstall.js" }, "engines": { "node": ">=18" @@ -29,7 +31,7 @@ "cli", "env", ".env", - "environment" + "DevOps" ], "author": "Mike Garde", "license": "GPL-3.0-or-later", @@ -38,19 +40,6 @@ }, "homepage": "https://github.com/MikeGarde/dotenv#readme", "dependencies": { - "commander": ">=12.1.0", - "loglevel": "^1.9.1" - }, - "devDependencies": { - "@types/jest": "^29.5.12", - "@types/node": ">=20.12.7", - "globals": ">=15.6.0", - "jest": "^29.7.0", - "postject": "^1.0.0-alpha.6", - "ts-jest": "29.1.5", - "typescript": "^5.5.4" - }, - "publishConfig": { - "@MikeGarde:dotenv-cli": "https://npm.pkg.github.com" + "tar": "^7.5.13" } } diff --git a/postinstall.js b/postinstall.js new file mode 100644 index 0000000..8694eaf --- /dev/null +++ b/postinstall.js @@ -0,0 +1,100 @@ +#!/usr/bin/env node + +import https from 'https'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const version = require('./package.json').version; +const repo = 'MikeGarde/dotenv-cli'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// For tar, use dynamic import: + + + +function getPlatform() { + switch (process.platform) { + case 'darwin': return 'apple-darwin'; + case 'linux': return 'unknown-linux-gnu'; + case 'win32': return 'pc-windows-msvc'; + default: throw new Error(`Unsupported platform: ${process.platform}`); + } +} + +function getArch() { + switch (process.arch) { + case 'x64': return 'x86_64'; + case 'arm64': return 'aarch64'; + default: throw new Error(`Unsupported arch: ${process.arch}`); + } +} + +function getAssetName() { + return `dotenv-cli-${version}-${getPlatform()}-${getArch()}.tar.gz`; +} + +function getDownloadUrl() { + return `https://github.com/${repo}/releases/download/v${version}/${getAssetName()}`; +} + +function getVendorDir() { + return path.join(__dirname, 'vendor'); +} + +function getBinName() { + return 'dotenv' + (process.platform === 'win32' ? '.exe' : ''); +} + +function getBinPath() { + return path.join(getVendorDir(), getBinName()); +} + +async function downloadAndExtract(url, destDir, binName) { + const tar = await import('tar'); + if (!fs.existsSync(destDir)) fs.mkdirSync(destDir, { recursive: true }); + const tmpFile = path.join(os.tmpdir(), `dotenv-cli-${Date.now()}.tar.gz`); + await download(url, tmpFile); + await tar.x({ file: tmpFile, cwd: destDir }); + fs.chmodSync(path.join(destDir, binName), 0o755); + fs.unlinkSync(tmpFile); + console.log(`Installed dotenv binary to ${path.join(destDir, binName)}`); +} + +function download(url, tmpFile) { + return new Promise((resolve, reject) => { + const file = fs.createWriteStream(tmpFile); + + https.get(url, (response) => { + if (response.statusCode !== 200) { + reject(new Error(`Failed to download: ${url}`)); + return; + } + + response.pipe(file); + file.on('finish', () => file.close(resolve)); + }).on('error', reject); + }); +} + +async function main() { + + if (fs.existsSync(getBinPath())) { + console.log('dotenv binary already exists, skipping download'); + return; + } + + const binDir = getVendorDir(); + const url = getDownloadUrl(); + await downloadAndExtract(url, binDir, getBinName()); +} + +main().catch((err) => { + console.error(err.message); + process.exit(1); +}); diff --git a/src/app.ts b/src/app.ts deleted file mode 100644 index a89a972..0000000 --- a/src/app.ts +++ /dev/null @@ -1,141 +0,0 @@ -#!/usr/bin/env node - -import {program} from 'commander'; -import fs from 'node:fs'; -import path from 'node:path'; -import * as url from 'node:url'; -import parseEnvFile from './envParser.js'; -import handlers from "./services/handlers.js"; -import readPipe from "./readPipe.js"; -import EnvObject from './envObject.js'; - -import log, {setLogDebug} from './log.js'; -import {Options, qualifyingRules} from './components/qualifyingRules.js'; -import RuleViolationError from './errors/RuleViolationError.js'; -import escapeAndQuote from "./escapeAndQuote.js"; - -async function app() { - const installDir: string = path.dirname(url.fileURLToPath(import.meta.url)); - const packagePath: string = path.join(installDir, '../package.json'); - const packageJson = JSON.parse(fs.readFileSync(packagePath, 'utf8')); - - // Parse command line options - program - .version(packageJson.version, '-v, --version', 'Output the version number') - .helpOption('-h, --help', 'Show the help') - .description('Read and update environment variables from a .env file') - .argument('[key...]', 'Environment variable key') - .option('-f, --file ', 'Specify the .env file (default: .env)') - .option('-j, --json', 'Output as JSON') - .option('--no-json', 'Output as plain text') - .option('-m, --multiline', 'Allow multiline values') - .option('-s, --set ', 'Update the environment variable in the .env file') - .option('-q, --quote', 'Quote the value when --set regardless of need') - .option('-D, --delete', 'Delete the environment variable from the .env file') - .option('-d, --debug', 'Output extra debugging') - .showSuggestionAfterError(true) - .parse(process.argv); - - const cliOptions = program.opts(); - - setLogDebug(cliOptions.debug); - - const stdin: string | void = await readPipe().catch((err) => { - throw new RuleViolationError(`Error reading from stdin: ${err}`); - }); - - const envFilePath: string = cliOptions.file || process.env.DOTENV_FILE || '.env'; - const fullEnvPath: string = path.resolve(envFilePath); - const keys: string[] = program.args; - const set: string = cliOptions.set; - - // Determine if we are setting a value, and if so, what's the value - let setValue: string = ''; - if (stdin && set) { - // - cannot have both --set [value] and stdin - throw new RuleViolationError('Cannot use --set and stdin together'); - } else if (stdin) { - setValue = stdin; - } else if (set) { - setValue = set; - } - - log.debug('Keys:', keys); - log.debug('File:', fullEnvPath); - - // Must have a .env file - if (!fs.existsSync(fullEnvPath)) { - throw new RuleViolationError(`File not found: ${fullEnvPath}`); - } - - let options: Options = { - fullEnvPath: fullEnvPath, - envObject: new EnvObject(), - json: (process.argv.includes('--json')), // commander.js sets json instead of no-json - noJson: (process.argv.includes('--no-json')), // commander.js fails to parse this - multiline: (cliOptions.multiline !== undefined), - quote: (cliOptions.quote !== undefined), - action: { - set: (setValue !== ''), - delete: (cliOptions.delete !== undefined), - }, - singleKey: (keys.length === 1), - returnAllKeys: (keys.length === 0), - targetKeys: keys, - setValue: escapeAndQuote(setValue, (cliOptions.quote !== undefined)), - }; - - // Multiple keys or no keys assume --json - if ((keys.length > 1 || !keys.length) && !options['noJson']) { - log.debug('Key count (0 or >1) defaulting to JSON'); - options.json = true; - } - log.debug('Options:', options); - - qualifyingRules(options); - - options.envObject = parseEnvFile(envFilePath); - - if (keys.length === 1 && !options['noJson']) { - log.debug('Single key, and not --no-json, checking for wildcard'); - - if (options.targetKeys[0].includes('*')) { - log.debug('Wildcard found') - const target: string = options.targetKeys[0].replace('*', '.*'); - const regex: RegExp = new RegExp('^' + target + '$'); - let i: number = 0; - for (const key in options.envObject) { - if (key.match(regex)) { - log.debug(`Adding "${key}" to targetKeys`); - options.targetKeys[i] = key; - i++; - } - } - options.json = true; - } - } - - if (options.json && options.returnAllKeys) { - log.debug('Outputting entire .env file as JSON'); - log.info(options.envObject.toJsonString(options.multiline)); - } else if (options.action.delete) { - handlers.deleteKey(options); - } else if (options.action.set) { - handlers.setValue(options); - } else { - handlers.getValue(options); - } -} - -app().then(() => { - log.debug('done'); -}).catch((error) => { - if (error instanceof RuleViolationError) { - log.error(error.message); - } else { - log.error('An unexpected error occurred:', error); - } - process.exitCode = 1; -}); - -export default app; diff --git a/src/cli.rs b/src/cli.rs new file mode 100644 index 0000000..9cb6b35 --- /dev/null +++ b/src/cli.rs @@ -0,0 +1,36 @@ +use clap::{ArgAction, Parser}; + +#[derive(Debug, Parser)] +#[command( + name = "dotenv", + about = "Read and update environment variables from a .env file", + disable_version_flag = true +)] +pub struct Cli { + #[arg(short = 'v', long = "version", action = ArgAction::SetTrue, help = "Output the version number")] + pub version: bool, + + #[arg(value_name = "key")] + pub key: Vec, + + #[arg(short, long, help = "Specify the .env file (default: .env)")] + pub file: Option, + + #[arg(short, long, action = ArgAction::SetTrue, help = "Output as JSON")] + pub json: bool, + + #[arg(long, action = ArgAction::SetTrue, help = "Output as plain text")] + pub no_json: bool, + + #[arg(short, long, action = ArgAction::SetTrue, help = "Allow multiline values")] + pub multiline: bool, + + #[arg(short, long, help = "Update the environment variable in the .env file")] + pub set: Option, + + #[arg(short = 'D', long, action = ArgAction::SetTrue, help = "Delete the environment variable from the .env file")] + pub delete: bool, + + #[arg(short, long, action = ArgAction::SetTrue, help = "Output extra debugging")] + pub debug: bool, +} diff --git a/src/components/qualifyingRules.ts b/src/components/qualifyingRules.ts deleted file mode 100644 index 7d58b1b..0000000 --- a/src/components/qualifyingRules.ts +++ /dev/null @@ -1,39 +0,0 @@ -import fs from "node:fs"; -import RuleViolationError from "../errors/RuleViolationError.js"; -import EnvObject from "../envObject.js"; - -export interface Options { - fullEnvPath: string; - envObject: EnvObject; - json: boolean; - noJson: boolean; - multiline: boolean; - quote: boolean; - action: { - set: boolean; - delete: boolean; - } - singleKey: boolean; - returnAllKeys: boolean; - targetKeys: string[]; - setValue: string | null; -} - -export function qualifyingRules(settings: Options) { - // - cannot have both --json and --set - if (settings.json && settings.action.set) { - throw new RuleViolationError('Cannot use --json and --set together'); - } - // - must have a key if using --set - if (settings.action.set && !settings.singleKey) { - throw new RuleViolationError('Must specify a single key when using --set'); - } - // - cannot use --delete with any other options - if (settings.action.delete && (settings.action.set || settings.json || settings.multiline)) { - throw new RuleViolationError('Cannot use --delete with any other options'); - } - // - must have a key if using --delete - if (settings.action.delete && !settings.singleKey) { - throw new RuleViolationError('Must specify a single key when using --delete'); - } -} diff --git a/src/envObject.ts b/src/envObject.ts deleted file mode 100644 index 57b6476..0000000 --- a/src/envObject.ts +++ /dev/null @@ -1,111 +0,0 @@ -import log from './log.js'; - -export class EnvValue { - value: string; - lineStart: number; - lineEnd: number; - - constructor(value: string, lineStart: number = -1, lineEnd: number = -1) { - this.value = value; - this.lineStart = lineStart; - this.lineEnd = lineEnd; - } -} - -class EnvObject { - [key: string]: EnvValue | any; - - constructor() { - return new Proxy(this, { - set(target, key: PropertyKey, value, receiver) { - if (typeof key !== 'string') { - key = 'value'; - } - if (value instanceof EnvValue) { - target[key] = value; - return true; - } - if (typeof value === 'object' && value !== null) { - target[key] = new EnvValue(value.value, value.lineStart, value.lineEnd); - return true; - } - if (typeof value === 'string') { - if (target[key] instanceof EnvValue) { - target[key] = value; - } else { - target[key] = new EnvValue(value); - } - - } - if (typeof target[key] === 'object' && target[key] !== null) { - target[key].value = value; - } else { - // TODO: let's not allow this - target[key as string] = { - value: value, - lineStart: -1, // You might want to set these values appropriately - lineEnd: -1 - }; - } - return true; - } - }); - } - - resolveNestedVariables(): void { - const variablePattern = /\$\{([a-zA-Z0-9_]+)\}/g; - - for (const key in this) { - const envValue = this[key] as EnvValue; - if (envValue instanceof EnvValue) { - let value = envValue.value; - - if (!value.includes('${')) { - continue; - } - - let previousValue; - do { - previousValue = value; - value = value.replace(variablePattern, (match: string, varName: string): string => { - const nestedEnvValue = this[varName] as EnvValue; - if (nestedEnvValue instanceof EnvValue) { - log.debug(`Replacing variable ${varName} with value ${nestedEnvValue.value}`); - return nestedEnvValue.value; - } - log.debug(`Variable ${varName} not found, leaving as is`); - return match; - }); - } while (value !== previousValue); - - if (value !== envValue.value) { - envValue.value = value; - } - } - } - } - - toObj(): { [key: string]: string } { - let obj: { [key: string]: string } = {}; - - for (const key in this) { - const keyName: string = key as string; - const value = this[keyName].value; - - if (typeof value === 'string') { - obj[keyName] = value; - } - } - return obj; - } - - toJsonString(pretty: boolean = false): string { - if (pretty) { - return JSON.stringify(this.toObj(), null, 2); - } else { - return JSON.stringify(this.toObj()); - } - } -} - -export default EnvObject; diff --git a/src/envParser.ts b/src/envParser.ts deleted file mode 100644 index ed65756..0000000 --- a/src/envParser.ts +++ /dev/null @@ -1,95 +0,0 @@ -import fs from 'node:fs'; -import log from './log.js'; - -import EnvObject, {EnvValue} from "./envObject.js"; -import EnvParseError from "./errors/EnvParseError.js"; - -function getEndLine(envLines: string[], startLine: number, quoteType: string): number { - let endLine = startLine; - while (!envLines[endLine].trim().endsWith(quoteType)) { - endLine++; - } - return endLine; -} - -function extractLines(envLines: string[], startLine: number, endLine: number, quoted: boolean): EnvValue { - let allLines: string[] = envLines.slice(startLine, endLine + 1); - allLines[0] = allLines[0].split('=')[1]; - let blob: string = allLines.join('\n').trim(); - - if (quoted) { - blob = blob.slice(1, -1); - } - - if (blob.startsWith('[') && blob.endsWith(']')) { - let arr: string[] = []; - try { - arr = JSON.parse(blob); - } catch (e) { - throw new EnvParseError(startLine + 1, `Invalid list: ${blob}`); - } - arr = arr.map((item: string) => `"${item}"`); - blob = '[' + arr.join(', ') + ']'; - } - - return { - value: blob, - lineStart: startLine, - lineEnd: endLine - }; -} - -function parseEnvFile(filePath: string): EnvObject { - const envContent: string = fs.readFileSync(filePath, 'utf8'); - const envLines: string[] = envContent.split('\n'); - const envObject: EnvObject = new EnvObject(); - - for (let lineCurrent = 0; lineCurrent < envLines.length; lineCurrent++) { - const trimmedLine: string = envLines[lineCurrent].trim(); - const lineStart: number = lineCurrent; - - if (!trimmedLine) { - log.debug(`${lineCurrent + 1} | Ignoring empty line`); - } else if (trimmedLine.startsWith('#')) { - log.debug(`${lineCurrent + 1} | Ignoring comment`); - } else { - const [key, ...valueParts] = trimmedLine.split('='); - const value: string = valueParts.join('=').trim(); - - if (key === trimmedLine) { - log.debug(`${lineCurrent + 1} | Ignoring line without key=value: ${envLines[lineCurrent]}`); - continue; - } - - if (value.startsWith('"')) { - log.debug(`${lineCurrent + 1} | key: ${key}, double quoted, ${value.endsWith('"') ? 'single line' : 'multiline'}`); - lineCurrent = getEndLine(envLines, lineStart, '"'); - envObject[key] = extractLines(envLines, lineStart, lineCurrent, true); - } else if (value.startsWith("'")) { - log.debug(`${lineCurrent + 1} | key: ${key}, single quoted, ${value.endsWith("'") ? 'single line' : 'multiline'}`); - lineCurrent = getEndLine(envLines, lineStart, ""); - envObject[key] = extractLines(envLines, lineStart, lineCurrent, true); - } else if (value.startsWith('[')) { - log.debug(`${lineCurrent + 1} | key: ${key}, list, ${value.endsWith(']') ? 'single line' : 'multiline'}`); - lineCurrent = getEndLine(envLines, lineStart, ']'); - envObject[key] = extractLines(envLines, lineStart, lineCurrent, false); - } else { - log.debug(`${lineCurrent + 1} | key: ${key}, un-quoted, single line`) - - const hasQuotes: boolean = value.includes('"') || value.includes("'"); - if (hasQuotes) { - throw new EnvParseError(lineCurrent + 1, `Invalid value: ${envLines[lineCurrent]}`); - } - envObject[key] = extractLines(envLines, lineStart, lineCurrent, false); - } - } - } - - envObject.resolveNestedVariables(); - - return envObject; -} - -export default parseEnvFile; - -export {EnvObject}; diff --git a/src/env_object.rs b/src/env_object.rs new file mode 100644 index 0000000..5bff15e --- /dev/null +++ b/src/env_object.rs @@ -0,0 +1,126 @@ +use indexmap::IndexMap; + +/// Mirrors EnvValue in envObject.ts. +/// lineStart/lineEnd are 0-based line indices into the raw .env file. +/// -1 means the value was not parsed from a file (e.g. appended). +#[derive(Debug, Clone)] +pub struct EnvValue { + pub value: String, + pub line_start: i64, + pub line_end: i64, +} + +impl EnvValue { + #[allow(dead_code)] + pub fn new(value: String) -> Self { + EnvValue { + value, + line_start: -1, + line_end: -1, + } + } + + pub fn with_lines(value: String, line_start: i64, line_end: i64) -> Self { + EnvValue { + value, + line_start, + line_end, + } + } +} + +/// Ordered map of env keys → EnvValue. Insertion order is preserved (IndexMap). +/// Iteration with .keys() / .iter() yields only env keys, not struct methods. +pub struct EnvObject { + pub entries: IndexMap, +} + +impl Default for EnvObject { + fn default() -> Self { + Self::new() + } +} + +impl EnvObject { + pub fn new() -> Self { + EnvObject { + entries: IndexMap::new(), + } + } + + pub fn get(&self, key: &str) -> Option<&EnvValue> { + self.entries.get(key) + } + + pub fn set(&mut self, key: String, value: EnvValue) { + self.entries.insert(key, value); + } + + pub fn keys(&self) -> impl Iterator { + self.entries.keys() + } + + /// Resolve ${VAR} references in-place. Iterates until stable (handles chains). + pub fn resolve_nested_variables(&mut self) { + let keys: Vec = self.entries.keys().cloned().collect(); + for key in &keys { + let value = self.entries[key.as_str()].value.clone(); + if !value.contains("${") { + continue; + } + let resolved = self.resolve_value(&value); + if resolved != value { + self.entries[key.as_str()].value = resolved; + } + } + } + + fn resolve_value(&self, input: &str) -> String { + let mut result = input.to_string(); + loop { + let prev = result.clone(); + let mut new_result = String::new(); + let mut chars = result.chars().peekable(); + while let Some(c) = chars.next() { + if c == '$' && chars.peek() == Some(&'{') { + chars.next(); // consume '{' + let mut var_name = String::new(); + loop { + match chars.next() { + Some('}') | None => break, + Some(ch) => var_name.push(ch), + } + } + if let Some(env_val) = self.entries.get(&var_name) { + new_result.push_str(&env_val.value); + } else { + new_result.push_str(&format!("${{{}}}", var_name)); + } + } else { + new_result.push(c); + } + } + result = new_result; + if result == prev { + break; + } + } + result + } + + /// Serialise all keys to JSON. multiline=true → pretty-print (2-space indent). + /// Values are stored raw; no newline conversion is performed here. + pub fn to_json_string(&self, multiline: bool) -> String { + let map: serde_json::Map = self + .entries + .iter() + .map(|(k, v)| (k.clone(), serde_json::Value::String(v.value.clone()))) + .collect(); + let val = serde_json::Value::Object(map); + if multiline { + serde_json::to_string_pretty(&val).unwrap() + } else { + serde_json::to_string(&val).unwrap() + } + } +} diff --git a/src/env_parser.rs b/src/env_parser.rs new file mode 100644 index 0000000..93c44a8 --- /dev/null +++ b/src/env_parser.rs @@ -0,0 +1,165 @@ +use std::fs; + +use crate::env_object::{EnvObject, EnvValue}; +use crate::errors::EnvParseError; + +/// Walk forward from `start` until a line whose trimmed form ends with `end_str`. +/// Returns the index of that line, capped at the last line index. +fn get_end_line(lines: &[&str], start: usize, end_str: &str) -> usize { + let mut end = start; + while end < lines.len() && !lines[end].trim().ends_with(end_str) { + if end + 1 >= lines.len() { + break; + } + end += 1; + } + end +} + +/// Extract and normalise the value spanning lines[start..=end]. +/// +/// - Takes everything after the first '=' on lines[start] (preserving '=' in values). +/// - Joins continuation lines with '\n'. +/// - If `quoted`, strips one layer of outer quote characters. +/// - If the result looks like a JSON array, re-formats it as `["a", "b", ...]` +/// and returns an error if the JSON is invalid. +fn extract_value( + lines: &[&str], + start: usize, + end: usize, + quoted: bool, +) -> Result { + let first_line = lines[start]; + let after_eq = first_line + .find('=') + .map(|i| &first_line[i + 1..]) + .unwrap_or(""); + + let mut parts: Vec<&str> = vec![after_eq]; + for line in lines.iter().take(end + 1).skip(start + 1) { + parts.push(line); + } + + let mut blob = parts.join("\n").trim().to_string(); + + if quoted && blob.len() >= 2 { + // Strip outer quote character (first and last byte – safe for ASCII " and ') + blob = blob[1..blob.len() - 1].to_string(); + } + + // Handle JSON array values: reformat as ["a", "b", ...] + if blob.starts_with('[') && blob.ends_with(']') { + match serde_json::from_str::>(&blob) { + Ok(arr) => { + let items: Vec = arr + .iter() + .map(|item| match item { + serde_json::Value::String(s) => format!("\"{}\"", s), + other => other.to_string(), + }) + .collect(); + blob = format!("[{}]", items.join(", ")); + } + Err(_) => { + return Err(EnvParseError { + line: start + 1, + message: format!("Invalid list: {}", blob), + }); + } + } + } + + Ok(blob) +} + +/// Parse a .env file and return an EnvObject with resolved nested variables. +/// +/// Handles: +/// - `KEY=unquoted` +/// - `KEY="double quoted"` (single or multiline) +/// - `KEY='single quoted'` (single or multiline) +/// - `KEY=["json", "array"]` (single or multiline) +/// - Comments (`# …`) and blank lines are ignored. +/// - Lines without `=` are ignored (e.g. `// invalid comment`). +pub fn parse_env_file(file_path: &str) -> Result { + let content = fs::read_to_string(file_path).map_err(|e| EnvParseError { + line: 0, + message: e.to_string(), + })?; + + // Keep raw lines for line-number-based splice operations. + let raw_lines: Vec<&str> = content.split('\n').collect(); + let mut env_object = EnvObject::new(); + let mut i = 0; + + while i < raw_lines.len() { + let trimmed = raw_lines[i].trim(); + let line_start = i; + + // Skip blank lines and comments + if trimmed.is_empty() || trimmed.starts_with('#') { + i += 1; + continue; + } + + // Skip lines without '=' (e.g. `// invalid comment`) + let eq_pos = match trimmed.find('=') { + Some(p) => p, + None => { + i += 1; + continue; + } + }; + + let key = trimmed[..eq_pos].to_string(); + let value_part = trimmed[eq_pos + 1..].trim(); + + if key.is_empty() { + i += 1; + continue; + } + + if value_part.starts_with('"') { + let end = get_end_line(&raw_lines, line_start, "\""); + let value = extract_value(&raw_lines, line_start, end, true)?; + env_object.set( + key, + EnvValue::with_lines(value, line_start as i64, end as i64), + ); + i = end + 1; + } else if value_part.starts_with('\'') { + let end = get_end_line(&raw_lines, line_start, "'"); + let value = extract_value(&raw_lines, line_start, end, true)?; + env_object.set( + key, + EnvValue::with_lines(value, line_start as i64, end as i64), + ); + i = end + 1; + } else if value_part.starts_with('[') { + let end = get_end_line(&raw_lines, line_start, "]"); + let value = extract_value(&raw_lines, line_start, end, false)?; + env_object.set( + key, + EnvValue::with_lines(value, line_start as i64, end as i64), + ); + i = end + 1; + } else { + // Unquoted value — must not contain bare quotes (would indicate a parse issue) + if value_part.contains('"') || value_part.contains('\'') { + return Err(EnvParseError { + line: i + 1, + message: format!("Invalid value: {}", raw_lines[i]), + }); + } + let value = extract_value(&raw_lines, line_start, line_start, false)?; + env_object.set( + key, + EnvValue::with_lines(value, line_start as i64, line_start as i64), + ); + i += 1; + } + } + + env_object.resolve_nested_variables(); + Ok(env_object) +} diff --git a/src/errors.rs b/src/errors.rs new file mode 100644 index 0000000..080f57a --- /dev/null +++ b/src/errors.rs @@ -0,0 +1,31 @@ +use std::fmt; + +/// User/input errors — caught in main, printed without stack trace, exits 1. +#[derive(Debug)] +pub struct RuleViolationError(pub String); + +/// .env file parse errors — includes line number, exits 1. +#[derive(Debug)] +pub struct EnvParseError { + pub line: usize, + pub message: String, +} + +impl fmt::Display for RuleViolationError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl fmt::Display for EnvParseError { + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!( + f, + "Error parsing .env file at line {}: {}", + self.line, self.message + ) + } +} + +impl std::error::Error for RuleViolationError {} +impl std::error::Error for EnvParseError {} diff --git a/src/errors/EnvParseError.ts b/src/errors/EnvParseError.ts deleted file mode 100644 index e6027fc..0000000 --- a/src/errors/EnvParseError.ts +++ /dev/null @@ -1,5 +0,0 @@ -export default class EnvParseError extends Error { - constructor(line: number, message: string) { - super(`Error parsing .env file at line ${line}: ${message}`); - } -} diff --git a/src/errors/RuleViolationError.ts b/src/errors/RuleViolationError.ts deleted file mode 100644 index 5d999a4..0000000 --- a/src/errors/RuleViolationError.ts +++ /dev/null @@ -1,6 +0,0 @@ -export default class RuleViolationError extends Error { - constructor(message: string) { - super(message); - Object.setPrototypeOf(this, RuleViolationError.prototype); - } -} diff --git a/src/escapeAndQuote.ts b/src/escapeAndQuote.ts deleted file mode 100644 index 05833e6..0000000 --- a/src/escapeAndQuote.ts +++ /dev/null @@ -1,24 +0,0 @@ -import log from './log.js'; - -function escapeAndQuote(str: string, quote: boolean): string { - if (str.startsWith('"') && str.endsWith('"')) { - // Remove the quotes - str = str.slice(1, -1); - } - // If string is a list, return as is unless a user has requested quotes - if (!quote && (str.startsWith('[') && str.endsWith(']'))) { - log.debug('List found, returning as is'); - return str; - } - - const needsQuotes: boolean = quote || /\s|"/.test(str); - - // Only escape double quotes if necessary - if (needsQuotes || /(? String { + if multiline { + value.replace("\\n", "\n") + } else { + value.replace('\n', "\\n") + } +} diff --git a/src/handlers/delete_key.rs b/src/handlers/delete_key.rs new file mode 100644 index 0000000..6b75d07 --- /dev/null +++ b/src/handlers/delete_key.rs @@ -0,0 +1,26 @@ +use std::fs; + +use crate::qualifying_rules::Options; + +/// Remove a key and its value lines from the .env file. +/// +/// Returns `true` if the key was found and deleted, `false` if not found +/// (caller should set exit code 1). +pub fn delete_key(options: &Options) -> bool { + let key = &options.target_keys[0]; + let env_object = options.env_object.as_ref().unwrap(); + + if let Some(env_val) = env_object.get(key) { + let content = fs::read_to_string(&options.full_env_path).expect("Failed to read .env file"); + let mut lines: Vec = content.split('\n').map(str::to_string).collect(); + + let start = env_val.line_start as usize; + let end = env_val.line_end as usize; + lines.drain(start..=end); + + fs::write(&options.full_env_path, lines.join("\n")).expect("Failed to write .env file"); + true + } else { + false + } +} diff --git a/src/handlers/get_value.rs b/src/handlers/get_value.rs new file mode 100644 index 0000000..3597ce4 --- /dev/null +++ b/src/handlers/get_value.rs @@ -0,0 +1,52 @@ +use crate::format_value::format_value; +use crate::qualifying_rules::Options; + +/// Read and print one or more values from the parsed EnvObject. +/// +/// Returns `true` if every requested key was found, `false` otherwise +/// (caller should set exit code 1). +/// +/// JSON mode builds a proper JSON object via serde_json for correct escaping. +/// Plain mode prints one value per line. +pub fn get_value(options: &Options) -> bool { + let env_object = options.env_object.as_ref().unwrap(); + let mut all_found = true; + + let keys: Vec = if options.target_keys.is_empty() { + env_object.keys().cloned().collect() + } else { + options.target_keys.clone() + }; + + if options.json { + let mut map = serde_json::Map::new(); + for key in &keys { + if let Some(env_val) = env_object.get(key) { + let value = format_value(&env_val.value, options.multiline); + map.insert(key.clone(), serde_json::Value::String(value)); + } else { + all_found = false; + map.insert(key.clone(), serde_json::Value::Null); + } + } + println!( + "{}", + serde_json::to_string(&serde_json::Value::Object(map)).unwrap() + ); + } else { + let mut lines: Vec = Vec::new(); + for key in &keys { + if let Some(env_val) = env_object.get(key) { + lines.push(format_value(&env_val.value, options.multiline)); + } else { + all_found = false; + lines.push(String::new()); // empty line for missing key, mirrors TS + } + } + // Join and print — mirrors TS result.slice(0, -1) which trims trailing newline + print!("{}", lines.join("\n")); + println!(); // final newline + } + + all_found +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..cc156df --- /dev/null +++ b/src/handlers/mod.rs @@ -0,0 +1,3 @@ +pub mod delete_key; +pub mod get_value; +pub mod set_value; diff --git a/src/handlers/set_value.rs b/src/handlers/set_value.rs new file mode 100644 index 0000000..4b48bde --- /dev/null +++ b/src/handlers/set_value.rs @@ -0,0 +1,39 @@ +use std::fs; +use std::io::Write; + +use crate::qualifying_rules::Options; + +/// Write (or append) a key=value pair to the .env file. +/// +/// If the key already exists, the lines from lineStart to lineEnd (inclusive) +/// are spliced out and replaced — preserving all comments and other keys. +/// If the key is new, `KEY=value\n` is appended to the end of the file. +pub fn set_value(options: &Options) { + let key = &options.target_keys[0]; + let set_val = options.set_value.as_deref().unwrap_or(""); + let new_line = format!("{}={}", key, set_val); + + let env_object = options.env_object.as_ref().unwrap(); + + if let Some(env_val) = env_object.get(key) { + // Read file fresh (in case concurrent changes occurred) + let content = fs::read_to_string(&options.full_env_path).expect("Failed to read .env file"); + let mut lines: Vec = content.split('\n').map(str::to_string).collect(); + + let start = env_val.line_start as usize; + let end = env_val.line_end as usize; + let new_lines: Vec = new_line.split('\n').map(str::to_string).collect(); + + // splice: replace [start, end] with new_lines + lines.splice(start..=end, new_lines); + + fs::write(&options.full_env_path, lines.join("\n")).expect("Failed to write .env file"); + } else { + // Append new key to end of file + let mut file = fs::OpenOptions::new() + .append(true) + .open(&options.full_env_path) + .expect("Failed to open .env file for appending"); + writeln!(file, "{}", new_line).expect("Failed to append to .env file"); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..d10f610 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,8 @@ +pub mod cli; +pub mod env_object; +pub mod env_parser; +pub mod errors; +pub mod format_value; +pub mod handlers; +pub mod qualifying_rules; +pub mod read_pipe; diff --git a/src/log.ts b/src/log.ts deleted file mode 100644 index fdeb9b9..0000000 --- a/src/log.ts +++ /dev/null @@ -1,16 +0,0 @@ -import loglevel from 'loglevel'; - -const log = loglevel.getLogger('dotenv'); - -// trace debug info warn error -log.setLevel(loglevel.levels.INFO); - -export function setLogDebug(debug: boolean) { - if (debug) { - log.setLevel(loglevel.levels.DEBUG); - } else { - log.setLevel(loglevel.levels.INFO); - } -} - -export default log; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..21ace6c --- /dev/null +++ b/src/main.rs @@ -0,0 +1,200 @@ +#!/usr/bin/env -S cargo +nightly script +mod cli; +mod env_object; +mod env_parser; +mod errors; +mod format_value; +mod handlers; +mod qualifying_rules; +mod read_pipe; + +use std::path::{Path, PathBuf}; + +use clap::Parser; + +use cli::Cli; +use env_parser::parse_env_file; +use errors::{EnvParseError, RuleViolationError}; +use qualifying_rules::{qualifying_rules, Options}; + +fn main() { + let exit_code = match run() { + Ok(code) => code, + Err(e) => { + if let Some(err) = e.downcast_ref::() { + eprintln!("{}", err); + } else if let Some(err) = e.downcast_ref::() { + // Include "EnvParseError" in stderr so tests can detect it + eprintln!("An unexpected error occurred: EnvParseError: {}", err); + } else { + eprintln!("An unexpected error occurred: {}", e); + } + 1 + } + }; + std::process::exit(exit_code); +} + +fn run() -> Result> { + let cli = Cli::parse(); + + if cli.version { + println!("{}", env!("CARGO_PKG_VERSION")); + return Ok(0); + } + + let debug = cli.debug; + let multiline = cli.multiline; + let delete = cli.delete; + let keys = cli.key.clone(); + + // Read stdin before any other I/O + let stdin_value = read_pipe::read_pipe(); + let set_opt = cli.set.clone(); + + if stdin_value.is_some() && set_opt.is_some() { + return Err(Box::new(RuleViolationError( + "Cannot use --set and stdin together".to_string(), + ))); + } + + let set_value_raw = stdin_value.or(set_opt); + let is_set = set_value_raw.is_some(); + + let env_file = cli + .file + .or_else(|| std::env::var("DOTENV_FILE").ok()) + .unwrap_or_else(|| ".env".to_string()); + + // Resolve to absolute path (mirrors Node's path.resolve) + let full_env_path: PathBuf = if Path::new(&env_file).is_absolute() { + PathBuf::from(&env_file) + } else { + std::env::current_dir() + .map_err(|e| Box::new(RuleViolationError(e.to_string())) as Box)? + .join(&env_file) + }; + let full_env_path_str = full_env_path.to_string_lossy().to_string(); + + if debug { + eprintln!("Keys: {:?}", keys); + eprintln!("File: {}", full_env_path_str); + } + + if !full_env_path.exists() { + return Err(Box::new(RuleViolationError(format!( + "File not found: {}", + full_env_path_str + )))); + } + + let mut options = Options { + full_env_path: full_env_path_str, + env_object: None, + json: cli.json, + no_json: cli.no_json, + multiline, + action_set: is_set, + action_delete: delete, + single_key: keys.len() == 1, + return_all_keys: keys.is_empty(), + target_keys: keys.clone(), + set_value: set_value_raw, + debug, + }; + + // Multiple keys or no keys default to JSON output (unless overridden with --no-json) + if (keys.len() > 1 || keys.is_empty()) && !options.no_json { + if debug { + eprintln!("Key count (0 or >1) defaulting to JSON"); + } + options.json = true; + } + + if debug { + eprintln!("Options assembled"); + } + + qualifying_rules(&options).map_err(|e| Box::new(e) as Box)?; + + let env_object = parse_env_file(&options.full_env_path) + .map_err(|e| Box::new(e) as Box)?; + + // Wildcard expansion: `DB_*` → matched keys, automatically enables JSON output + if keys.len() == 1 && !options.no_json && options.target_keys[0].contains('*') { + if debug { + eprintln!("Wildcard found"); + } + let pattern = options.target_keys[0].clone(); + let matched: Vec = env_object + .keys() + .filter(|k| wildcard_match(&pattern, k)) + .cloned() + .collect(); + options.target_keys = matched; + options.json = true; + } + + options.env_object = Some(env_object); + + // Route to the appropriate handler + let exit_code = if options.json && options.return_all_keys { + println!( + "{}", + options + .env_object + .as_ref() + .unwrap() + .to_json_string(options.multiline) + ); + 0 + } else if options.action_delete { + let found = handlers::delete_key::delete_key(&options); + if !found { + 1 + } else { + 0 + } + } else if options.action_set { + handlers::set_value::set_value(&options); + 0 + } else { + let all_found = handlers::get_value::get_value(&options); + if all_found { + 0 + } else { + 1 + } + }; + + Ok(exit_code) +} + +/// Simple glob wildcard matching: `*` matches any sequence of characters. +/// `DB_*` matches `DB_HOST`, `DB_USER`, etc. +fn wildcard_match(pattern: &str, text: &str) -> bool { + let parts: Vec<&str> = pattern.split('*').collect(); + if parts.len() == 1 { + return pattern == text; + } + + let mut remaining = text; + for (i, part) in parts.iter().enumerate() { + if i == 0 { + if !remaining.starts_with(part) { + return false; + } + remaining = &remaining[part.len()..]; + } else if i == parts.len() - 1 { + if !remaining.ends_with(part) { + return false; + } + } else { + match remaining.find(part) { + Some(pos) => remaining = &remaining[pos + part.len()..], + None => return false, + } + } + } + true +} diff --git a/src/qualifying_rules.rs b/src/qualifying_rules.rs new file mode 100644 index 0000000..81f824b --- /dev/null +++ b/src/qualifying_rules.rs @@ -0,0 +1,47 @@ +use crate::env_object::EnvObject; +use crate::errors::RuleViolationError; + +/// All resolved CLI options passed to the three action handlers. +#[allow(dead_code)] +pub struct Options { + pub full_env_path: String, + pub env_object: Option, + /// --json was passed explicitly + pub json: bool, + /// --no-json was passed explicitly + pub no_json: bool, + pub multiline: bool, + pub action_set: bool, + pub action_delete: bool, + pub single_key: bool, + pub return_all_keys: bool, + pub target_keys: Vec, + /// Literal value ready to write into the file + pub set_value: Option, + pub debug: bool, +} + +/// Validate option combinations (mirrors qualifyingRules.ts). +pub fn qualifying_rules(opts: &Options) -> Result<(), RuleViolationError> { + if opts.json && opts.action_set { + return Err(RuleViolationError( + "Cannot use --json and --set together".to_string(), + )); + } + if opts.action_set && !opts.single_key { + return Err(RuleViolationError( + "Must specify a single key when using --set".to_string(), + )); + } + if opts.action_delete && (opts.action_set || opts.json || opts.multiline) { + return Err(RuleViolationError( + "Cannot use --delete with any other options".to_string(), + )); + } + if opts.action_delete && !opts.single_key { + return Err(RuleViolationError( + "Must specify a single key when using --delete".to_string(), + )); + } + Ok(()) +} diff --git a/src/readPipe.ts b/src/readPipe.ts deleted file mode 100644 index 9c94e94..0000000 --- a/src/readPipe.ts +++ /dev/null @@ -1,31 +0,0 @@ -import {Interface, createInterface} from 'node:readline'; -import {stdin as input, stdout as output} from 'node:process'; - -const readPipe = (): Promise => { - return new Promise((resolve, reject) => { - // If the stdin is a TTY device aka no pipe, resolve the promise with an empty string - if (input.isTTY) { - resolve(''); - return; - } - - const rl: Interface = createInterface({input, output}); - - let inputData: string = ''; - - rl.on('line', (input) => { - inputData += input + '\n'; - }); - - rl.on('close', () => { - inputData = inputData.trim(); - resolve(inputData); - }); - - rl.on('error', (err) => { - reject(err); - }); - }); -} - -export default readPipe; diff --git a/src/read_pipe.rs b/src/read_pipe.rs new file mode 100644 index 0000000..00fef91 --- /dev/null +++ b/src/read_pipe.rs @@ -0,0 +1,18 @@ +use is_terminal::IsTerminal; +use std::io::{self, Read}; + +/// Read all piped stdin and return it trimmed, or `None` when stdin is a TTY +/// (i.e. no pipe is present). +pub fn read_pipe() -> Option { + if io::stdin().is_terminal() { + return None; + } + let mut input = String::new(); + io::stdin().read_to_string(&mut input).ok()?; + let trimmed = input.trim().to_string(); + if trimmed.is_empty() { + None + } else { + Some(trimmed) + } +} diff --git a/src/services/handlers.ts b/src/services/handlers.ts deleted file mode 100644 index 9aa2261..0000000 --- a/src/services/handlers.ts +++ /dev/null @@ -1,7 +0,0 @@ -import deleteKey from "./handlers/deleteKey.js"; -import getValue from "./handlers/getValue.js"; -import setValue from "./handlers/setValue.js"; - -const handlers = {deleteKey, getValue, setValue}; - -export default handlers; diff --git a/src/services/handlers/deleteKey.ts b/src/services/handlers/deleteKey.ts deleted file mode 100644 index 68e49bb..0000000 --- a/src/services/handlers/deleteKey.ts +++ /dev/null @@ -1,26 +0,0 @@ -import fs from "node:fs"; -import log from "../../log.js"; -import {Options} from "../../components/qualifyingRules.js"; - -export default function deleteKey(options: Options) { - const key: string = options.targetKeys[0]; - log.debug(`Deleting "${key}"`); - - if (options.envObject[key]) { - const lineStart = options.envObject[key].lineStart; - const lineEnd = options.envObject[key].lineEnd; - log.debug(`Deleting lines ${lineStart}-${lineEnd}`); - - // Read the file and split it into an array of lines - let lines: string[] = fs.readFileSync(options.fullEnvPath, 'utf8').split('\n'); - - // Remove the lines between lineStart and lineEnd - lines.splice(lineStart, lineEnd - lineStart + 1); - - // Join the lines back together and write the result back to the file - fs.writeFileSync(options.fullEnvPath, lines.join('\n')); - } else { - log.debug(`Environment variable "${key}" not found`); - process.exitCode = 1; - } -}; diff --git a/src/services/handlers/getValue.ts b/src/services/handlers/getValue.ts deleted file mode 100644 index 9a0d8b3..0000000 --- a/src/services/handlers/getValue.ts +++ /dev/null @@ -1,34 +0,0 @@ -import formatValue from "../../formatValue.js"; -import log from "../../log.js"; -import {Options} from "../../components/qualifyingRules.js"; - -export default function getValue(options: Options) { - let result: string = ''; - - if (options.targetKeys.length === 0) { - options.targetKeys = Object.keys(options.envObject); - } - - for (const key of options.targetKeys) { - log.debug(`Getting "${key}"`); - - let value = ''; - - if (!options.envObject[key]) { - log.debug(`Environment variable "${key}" not found`); - process.exitCode = 1; - } else { - value = formatValue(options.envObject[key].value, options.multiline); - } - - value = options.json ? (value ? `"${value}"` : 'null') : value; - result += options.json ? `"${key}": ${value},` : `${value}\n`; - } - - // Removes trailing newline or comma - result = result.slice(0, -1); - if (options.json) { - result = `{${result}}`; - } - log.info(result); -}; diff --git a/src/services/handlers/setValue.ts b/src/services/handlers/setValue.ts deleted file mode 100644 index 16091ff..0000000 --- a/src/services/handlers/setValue.ts +++ /dev/null @@ -1,34 +0,0 @@ -import fs from "node:fs"; -import log from "../../log.js"; -import {Options} from "../../components/qualifyingRules.js"; - -export default function setValue(options: Options) { - const key: string = options.targetKeys[0]; - const newLines: string = `${key}=${options.setValue}`; - - log.debug(`Updating or adding "${key}"`); - - // Do we want to update or append the .env file? - if (options.envObject[key]) { - log.debug('Updating existing key', options.envObject[key]); - const lineStart = options.envObject[key].lineStart; - const lineEnd = options.envObject[key].lineEnd; - log.debug(`Replacing lines ${lineStart}-${lineEnd}`); - - // Split the new lines into an array - let newLinesArray: string[] = newLines.split('\n'); - - // Read the file and split it into an array of lines - let lines: string[] = fs.readFileSync(options.fullEnvPath, 'utf8').split('\n'); - - // Replace the lines between lineStart and lineEnd - lines.splice(lineStart, lineEnd - lineStart + 1, ...newLinesArray); - - // Join the lines back together and write the result back to the file - fs.writeFileSync(options.fullEnvPath, lines.join('\n')); - } else { - log.debug(`Appending "${key}" to "${options.fullEnvPath}"`); - - fs.writeFileSync(options.fullEnvPath, `${newLines}\n`, {flag: 'a'}); - } -}; diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..e69de29 diff --git a/tests/app.rs b/tests/app.rs new file mode 100644 index 0000000..767bf95 --- /dev/null +++ b/tests/app.rs @@ -0,0 +1,118 @@ +use assert_cmd::Command; +use predicates::prelude::*; +use std::path::Path; + +fn bin() -> Command { + Command::cargo_bin("dotenv").unwrap() +} + +fn env_path() -> String { + let here = Path::new(env!("CARGO_MANIFEST_DIR")); + here.join("tests/.env.test").to_string_lossy().to_string() +} + +fn bad_list_path() -> String { + let here = Path::new(env!("CARGO_MANIFEST_DIR")); + here.join("tests/envFiles/badList.env") + .to_string_lossy() + .to_string() +} + +#[test] +fn missing_env_file() { + bin() + .arg("void") + .arg("--file") + .arg("non-existent.env") + .assert() + .failure() + .stderr(predicate::str::contains("File not found")); +} + +#[test] +fn uses_dotenv_file_env_var() { + let env_path = env_path(); + bin() + .arg("NAME") + .env("DOTENV_FILE", &env_path) + .assert() + .success() + .stdout("dotenv-cli\n"); +} + +#[test] +fn read_simple_value() { + bin() + .arg("NAME") + .arg("--file") + .arg(env_path()) + .assert() + .success() + .stdout("dotenv-cli\n"); +} + +#[test] +fn read_double_quoted_value() { + bin() + .arg("DOUBLE") + .arg("--file") + .arg(env_path()) + .assert() + .success() + .stdout("Double quotes\n"); +} + +#[test] +fn read_single_quoted_value() { + bin() + .arg("SINGLE") + .arg("--file") + .arg(env_path()) + .assert() + .success() + .stdout("Single quotes\n"); +} + +#[test] +fn missing_key() { + bin() + .arg("MISSING") + .arg("--file") + .arg(env_path()) + .assert() + .failure() + .stdout("\n"); +} + +#[test] +fn valid_single_line_list() { + bin() + .arg("LIST_SINGLE_LINE") + .arg("--file") + .arg(env_path()) + .assert() + .success() + .stdout("[\"one\", \"two\", \"three\"]\n"); +} + +#[test] +fn valid_multi_line_list() { + bin() + .arg("LIST_MULTI_LINE") + .arg("--file") + .arg(env_path()) + .assert() + .success() + .stdout("[\"one\", \"two\", \"three\"]\n"); +} + +#[test] +fn invalid_list_throws_error() { + bin() + .arg("BAD_LIST") + .arg("--file") + .arg(bad_list_path()) + .assert() + .failure() + .stderr(predicate::str::contains("EnvParseError")); +} diff --git a/tests/app.tests.ts b/tests/app.tests.ts deleted file mode 100644 index 42aef96..0000000 --- a/tests/app.tests.ts +++ /dev/null @@ -1,78 +0,0 @@ -import {execSync} from 'child_process'; -import path from 'path'; - -describe('app.ts', () => { - const appPath = path.resolve(__dirname, '../build/app.js'); - const envPath = path.resolve(__dirname, '.env.test'); - - const badListPath = path.resolve(__dirname, 'envFiles/badList.env'); - - test('missing .env file', async () => { - try { - const nonExistent = execSync(`node ${appPath} void --file non-existent.env`); - // This shouldn't happen - expect(true).toBeFalsy(); - } catch (error) { - const errorJson: string = JSON.stringify(error); - const parsedError: any = JSON.parse(errorJson); - const buffer: Buffer = Buffer.from(parsedError.stderr.data); - const errorMsg: string = buffer.toString('utf8'); - - expect(parsedError.status).toEqual(1); - expect(errorMsg).toContain('File not found'); - } - }); - - test('uses DOTENV_FILE environment variable', () => { - const result = execSync(`export DOTENV_FILE=${envPath} && node ${appPath} NAME`); - expect(result.toString().trim()).toBe('dotenv-cli'); - delete process.env.DOTENV_FILE; - }); - - test('read simple value', () => { - const result = execSync(`node ${appPath} NAME --file ${envPath}`); - expect(result.toString().trim()).toBe('dotenv-cli'); - }); - - test('read double quoted value', () => { - const result = execSync(`node ${appPath} DOUBLE --file ${envPath}`); - expect(result.toString().trim()).toBe('Double quotes'); - }); - - test('read single quotes from value', () => { - const result = execSync(`node ${appPath} SINGLE --file ${envPath}`); - expect(result.toString().trim()).toBe('Single quotes'); - }); - - test('missing key', async () => { - try { - const result = execSync(`node ${appPath} MISSING --file ${envPath}`); - // This shouldn't happen - expect(true).toBeFalsy(); - } catch (error) { - const errorJson: string = JSON.stringify(error); - const parsedError: any = JSON.parse(errorJson); - const buffer: Buffer = Buffer.from(parsedError.stderr.data); - const errorMsg: string = buffer.toString('utf8'); - - expect(parsedError.status).toEqual(1); - expect(errorMsg).toBe(''); - } - }); - - test('valid single line list', () => { - const result = execSync(`node ${appPath} LIST_SINGLE_LINE --file ${envPath}`); - expect(result.toString().trim()).toBe('["one", "two", "three"]'); - }); - - test('valid multi-line list', () => { - const result = execSync(`node ${appPath} LIST_MULTI_LINE --file ${envPath}`); - expect(result.toString().trim()).toBe('["one", "two", "three"]'); - }); - - test('invalid list throws error', () => { - expect(() => { - execSync(`node ${appPath} BAD_LIST --file ${badListPath}`); - }).toThrow('EnvParseError'); - }); -}); diff --git a/tests/cmd.rs b/tests/cmd.rs new file mode 100644 index 0000000..ad7f955 --- /dev/null +++ b/tests/cmd.rs @@ -0,0 +1,24 @@ +use assert_cmd::Command; +use predicates::prelude::*; + +fn bin() -> Command { + Command::cargo_bin("dotenv").unwrap() +} + +#[test] +fn help() { + bin() + .arg("--help") + .assert() + .success() + .stdout(predicate::str::contains("Usage: dotenv [OPTIONS] [key]...")); +} + +#[test] +fn version() { + bin() + .arg("--version") + .assert() + .success() + .stdout(predicate::str::is_match(r"^\d+\.\d+\.\d+\n$").unwrap()); +} diff --git a/tests/cmd.tests.ts b/tests/cmd.tests.ts deleted file mode 100644 index f7e7366..0000000 --- a/tests/cmd.tests.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {execSync} from 'child_process'; -import path from 'path'; - -describe('cmd.ts', () => { - const appPath = path.resolve(__dirname, '../build/app.js'); - - test('help', () => { - const result: Buffer = execSync(`node ${appPath} --help`); - const resultLines: string[] = result.toString().split('\n'); - expect(resultLines[0]).toContain('Usage: app [options] [key...]'); - }); - - test('version', () => { - const result: Buffer = execSync(`node ${appPath} --version`); - const resultLine: string = result.toString().trim(); - expect(resultLine).toMatch(/^[0-9]+\.[0-9]+\.[0-9]+$/); - }); -}); diff --git a/tests/envParser.tests.ts b/tests/envParser.tests.ts deleted file mode 100644 index 3b345a7..0000000 --- a/tests/envParser.tests.ts +++ /dev/null @@ -1,12 +0,0 @@ -import parseEnvFile, { EnvObject } from '../src/envParser.js'; - -describe('envParse', () => { - test('parse file', () => { - const envObject: EnvObject = parseEnvFile('tests/.env.test'); - const envCount: number = Object.keys(envObject).length; - - // TODO: Race conditions by the setAndDelete tests may cause this number to vary upward - // TODO: expect(envCount).toBe(7); - expect(envCount).toBeGreaterThanOrEqual(9); - }); -}); diff --git a/tests/env_object.rs b/tests/env_object.rs new file mode 100644 index 0000000..2ab24df --- /dev/null +++ b/tests/env_object.rs @@ -0,0 +1,54 @@ +use dotenv_cli::env_object::{EnvObject, EnvValue}; + +#[test] +fn resolve_single_variable() { + let mut env = EnvObject::new(); + env.set( + "VAR1".to_string(), + EnvValue::with_lines("Hello".to_string(), 0, 0), + ); + env.set( + "VAR2".to_string(), + EnvValue::with_lines("${VAR1} World".to_string(), 1, 1), + ); + env.resolve_nested_variables(); + assert_eq!(env.get("VAR2").unwrap().value, "Hello World"); +} + +#[test] +fn resolve_two_variables() { + let mut env = EnvObject::new(); + env.set( + "VAR1".to_string(), + EnvValue::with_lines("Hello".to_string(), 0, 0), + ); + env.set( + "VAR2".to_string(), + EnvValue::with_lines("World".to_string(), 1, 1), + ); + env.set( + "VAR3".to_string(), + EnvValue::with_lines("${VAR1} ${VAR2}".to_string(), 2, 2), + ); + env.resolve_nested_variables(); + assert_eq!(env.get("VAR3").unwrap().value, "Hello World"); +} + +#[test] +fn resolve_merged_value() { + let mut env = EnvObject::new(); + env.set( + "VAR1".to_string(), + EnvValue::with_lines("Hello".to_string(), 0, 0), + ); + env.set( + "VAR2".to_string(), + EnvValue::with_lines("${VAR1} World".to_string(), 1, 1), + ); + env.set( + "VAR3".to_string(), + EnvValue::with_lines("${VAR2} & Universe".to_string(), 2, 2), + ); + env.resolve_nested_variables(); + assert_eq!(env.get("VAR3").unwrap().value, "Hello World & Universe"); +} diff --git a/tests/env_parser.rs b/tests/env_parser.rs new file mode 100644 index 0000000..93d5287 --- /dev/null +++ b/tests/env_parser.rs @@ -0,0 +1,25 @@ +use assert_cmd::Command; +use std::path::Path; + +fn bin() -> Command { + Command::cargo_bin("dotenv").unwrap() +} + +fn env_path() -> String { + let here = Path::new(env!("CARGO_MANIFEST_DIR")); + here.join("tests/.env.test").to_string_lossy().to_string() +} + +#[test] +fn parse_file_count_keys() { + // The Node test expects >=9 keys due to race conditions, so we just check >=9 + let output = bin() + .arg("--file") + .arg(env_path()) + .output() + .expect("failed to run dotenv"); + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + let count = json.as_object().unwrap().len(); + assert!(count >= 9, "expected at least 9 keys, got {}", count); +} diff --git a/tests/escape.tests.ts b/tests/escape.tests.ts deleted file mode 100644 index 4199e93..0000000 --- a/tests/escape.tests.ts +++ /dev/null @@ -1,49 +0,0 @@ -import escapeAndQuote from "../src/escapeAndQuote"; - -describe('Escape and Quote', () => { - - test('with spaces', () => { - const result: string = escapeAndQuote('with spaces', false); - expect(result).toBe('"with spaces"'); - }); - - test('without spaces', () => { - const result: string = escapeAndQuote('withoutspaces', false); - expect(result).toBe('withoutspaces'); - }); - - test('double quote with true', () => { - const result: string = escapeAndQuote('with"quote', true); - expect(result).toBe('"with\\"quote"'); - }); - - test('double quote with false', () => { - const result: string = escapeAndQuote('with"quote', false); - expect(result).toBe('"with\\"quote"'); - }); - - test('already escaped', () => { - const result: string = escapeAndQuote('"with\\"quote"', false); - expect(result).toBe('"with\\"quote"'); - }); - - test('single quote', () => { - const result: string = escapeAndQuote('with\'quote', true); - expect(result).toBe('"with\'quote"'); - }); - - test('list', () => { - const result: string = escapeAndQuote('["one", "two", "three"]', false); - expect(result).toBe('["one", "two", "three"]'); - }); - - test('quoted list', () => { - const result: string = escapeAndQuote('"["one", "two", "three"]"', false); - expect(result).toBe('["one", "two", "three"]'); - }); - - test('escaped list', () => { - const result: string = escapeAndQuote('[\"one\", \"two\", \"three\"]', false); - expect(result).toBe('["one", "two", "three"]'); - }); -}); diff --git a/tests/format_value.rs b/tests/format_value.rs new file mode 100644 index 0000000..73b928e --- /dev/null +++ b/tests/format_value.rs @@ -0,0 +1,15 @@ +use dotenv_cli::format_value::format_value; + +#[test] +fn single_line_to_literal() { + let value = "line1\nline2"; + let formatted = format_value(value, false); + assert_eq!(formatted, "line1\\nline2"); +} + +#[test] +fn literal_to_multiline() { + let value = "line1\\nline2"; + let formatted = format_value(value, true); + assert_eq!(formatted, "line1\nline2"); +} diff --git a/tests/json.rs b/tests/json.rs new file mode 100644 index 0000000..4346606 --- /dev/null +++ b/tests/json.rs @@ -0,0 +1,59 @@ +use assert_cmd::Command; +use std::path::Path; + +fn bin() -> Command { + Command::cargo_bin("dotenv").unwrap() +} + +fn env_path() -> String { + let here = Path::new(env!("CARGO_MANIFEST_DIR")); + here.join("tests/.env.test").to_string_lossy().to_string() +} + +#[test] +fn output_entire_env_as_json() { + let output = bin() + .arg("--json") + .arg("--file") + .arg(env_path()) + .output() + .expect("failed to run dotenv"); + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + let length = json.as_object().unwrap().len(); + assert_eq!(json["NAME"], "dotenv-cli"); + assert!(length > 1); +} + +#[test] +fn output_single_key_as_json() { + let output = bin() + .arg("NAME") + .arg("--json") + .arg("--file") + .arg(env_path()) + .output() + .expect("failed to run dotenv"); + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + let length = json.as_object().unwrap().len(); + assert_eq!(json["NAME"], "dotenv-cli"); + assert_eq!(length, 1); +} + +#[test] +fn multiple_keys_as_json() { + let output = bin() + .arg("NAME") + .arg("DOUBLE") + .arg("--file") + .arg(env_path()) + .output() + .expect("failed to run dotenv"); + let stdout = String::from_utf8_lossy(&output.stdout); + let json: serde_json::Value = serde_json::from_str(stdout.trim()).unwrap(); + let length = json.as_object().unwrap().len(); + assert_eq!(json["NAME"], "dotenv-cli"); + assert_eq!(json["DOUBLE"], "Double quotes"); + assert_eq!(length, 2); +} diff --git a/tests/json.tests.ts b/tests/json.tests.ts deleted file mode 100644 index 9317fd5..0000000 --- a/tests/json.tests.ts +++ /dev/null @@ -1,35 +0,0 @@ -import {execSync} from 'child_process'; -import path from 'path'; - -describe('app.ts', () => { - const appPath = path.resolve(__dirname, '../build/app.js'); - const envPath = path.resolve(__dirname, '.env.test'); - - test('output entire .env as valid JSON', () => { - const result: Buffer = execSync(`node ${appPath} --json --file ${envPath}`); - const json = JSON.parse(result.toString().trim()); - const length: number = Object.keys(json).length; - - expect(json.NAME).toBe('dotenv-cli'); - expect(length).toBeGreaterThan(1); - }); - - test('output valid JSON with a single key and value', () => { - const result: Buffer = execSync(`node ${appPath} NAME --json --file ${envPath}`); - const json = JSON.parse(result.toString().trim()); - const length: number = Object.keys(json).length; - - expect(json.NAME).toBe('dotenv-cli'); - expect(length).toBe(1); - }); - - test('multiple keys specified, outputting as JSON', () => { - const result: Buffer = execSync(`node ${appPath} NAME DOUBLE --file ${envPath}`); - const resultJson = JSON.parse(result.toString().trim()); - const length: number = Object.keys(resultJson).length; - - expect(resultJson.NAME).toBe('dotenv-cli'); - expect(resultJson.DOUBLE).toBe('Double quotes'); - expect(length).toBe(2); - }); -}); diff --git a/tests/qualifying_rules.rs b/tests/qualifying_rules.rs new file mode 100644 index 0000000..a423ff0 --- /dev/null +++ b/tests/qualifying_rules.rs @@ -0,0 +1,122 @@ +use dotenv_cli::qualifying_rules::{qualifying_rules, Options}; + +fn options() -> Options { + Options { + full_env_path: ".env".to_string(), + env_object: None, + json: false, + no_json: false, + multiline: false, + action_set: false, + action_delete: false, + single_key: false, + return_all_keys: false, + target_keys: Vec::new(), + set_value: None, + debug: false, + } +} + +fn error_message(opts: Options) -> String { + qualifying_rules(&opts).unwrap_err().to_string() +} + +#[test] +fn allows_read_flags_without_set_or_delete() { + let mut opts = options(); + opts.json = true; + opts.no_json = true; + opts.multiline = true; + opts.return_all_keys = true; + + assert!(qualifying_rules(&opts).is_ok()); +} + +#[test] +fn allows_set_with_a_single_key() { + let mut opts = options(); + opts.action_set = true; + opts.single_key = true; + + assert!(qualifying_rules(&opts).is_ok()); +} + +#[test] +fn rejects_json_with_set() { + let mut opts = options(); + opts.json = true; + opts.action_set = true; + opts.single_key = true; + + assert_eq!(error_message(opts), "Cannot use --json and --set together"); +} + +#[test] +fn rejects_set_without_a_single_key() { + let mut opts = options(); + opts.action_set = true; + + assert_eq!( + error_message(opts), + "Must specify a single key when using --set" + ); +} + +#[test] +fn allows_delete_with_a_single_key() { + let mut opts = options(); + opts.action_delete = true; + opts.single_key = true; + + assert!(qualifying_rules(&opts).is_ok()); +} + +#[test] +fn rejects_delete_with_set() { + let mut opts = options(); + opts.action_delete = true; + opts.action_set = true; + opts.single_key = true; + + assert_eq!( + error_message(opts), + "Cannot use --delete with any other options" + ); +} + +#[test] +fn rejects_delete_with_json() { + let mut opts = options(); + opts.action_delete = true; + opts.json = true; + opts.single_key = true; + + assert_eq!( + error_message(opts), + "Cannot use --delete with any other options" + ); +} + +#[test] +fn rejects_delete_with_multiline() { + let mut opts = options(); + opts.action_delete = true; + opts.multiline = true; + opts.single_key = true; + + assert_eq!( + error_message(opts), + "Cannot use --delete with any other options" + ); +} + +#[test] +fn rejects_delete_without_a_single_key() { + let mut opts = options(); + opts.action_delete = true; + + assert_eq!( + error_message(opts), + "Must specify a single key when using --delete" + ); +} diff --git a/tests/resolveNestedVariables.tests.ts b/tests/resolveNestedVariables.tests.ts deleted file mode 100644 index f5f0059..0000000 --- a/tests/resolveNestedVariables.tests.ts +++ /dev/null @@ -1,35 +0,0 @@ -import EnvObject, { EnvValue } from '../src/envObject'; - -describe('EnvObject - resolveNestedVariables', () => { - test('should resolve a single variable in the string', () => { - const envObject = new EnvObject(); - envObject['VAR1'] = new EnvValue('Hello'); - envObject['VAR2'] = new EnvValue('${VAR1} World'); - - envObject.resolveNestedVariables(); - - expect(envObject['VAR2'].value).toBe('Hello World'); - }); - - test('should resolve two variables in the string', () => { - const envObject = new EnvObject(); - envObject['VAR1'] = new EnvValue('Hello'); - envObject['VAR2'] = new EnvValue('World'); - envObject['VAR3'] = new EnvValue('${VAR1} ${VAR2}'); - - envObject.resolveNestedVariables(); - - expect(envObject['VAR3'].value).toBe('Hello World'); - }); - - test('should resolve a previously merged value with another variable', () => { - const envObject = new EnvObject(); - envObject['VAR1'] = new EnvValue('Hello'); - envObject['VAR2'] = new EnvValue('${VAR1} World'); - envObject['VAR3'] = new EnvValue('${VAR2} & Universe'); - - envObject.resolveNestedVariables(); - - expect(envObject['VAR3'].value).toBe('Hello World & Universe'); - }); -}); diff --git a/tests/resolve_nested_variables.rs b/tests/resolve_nested_variables.rs new file mode 100644 index 0000000..2ab24df --- /dev/null +++ b/tests/resolve_nested_variables.rs @@ -0,0 +1,54 @@ +use dotenv_cli::env_object::{EnvObject, EnvValue}; + +#[test] +fn resolve_single_variable() { + let mut env = EnvObject::new(); + env.set( + "VAR1".to_string(), + EnvValue::with_lines("Hello".to_string(), 0, 0), + ); + env.set( + "VAR2".to_string(), + EnvValue::with_lines("${VAR1} World".to_string(), 1, 1), + ); + env.resolve_nested_variables(); + assert_eq!(env.get("VAR2").unwrap().value, "Hello World"); +} + +#[test] +fn resolve_two_variables() { + let mut env = EnvObject::new(); + env.set( + "VAR1".to_string(), + EnvValue::with_lines("Hello".to_string(), 0, 0), + ); + env.set( + "VAR2".to_string(), + EnvValue::with_lines("World".to_string(), 1, 1), + ); + env.set( + "VAR3".to_string(), + EnvValue::with_lines("${VAR1} ${VAR2}".to_string(), 2, 2), + ); + env.resolve_nested_variables(); + assert_eq!(env.get("VAR3").unwrap().value, "Hello World"); +} + +#[test] +fn resolve_merged_value() { + let mut env = EnvObject::new(); + env.set( + "VAR1".to_string(), + EnvValue::with_lines("Hello".to_string(), 0, 0), + ); + env.set( + "VAR2".to_string(), + EnvValue::with_lines("${VAR1} World".to_string(), 1, 1), + ); + env.set( + "VAR3".to_string(), + EnvValue::with_lines("${VAR2} & Universe".to_string(), 2, 2), + ); + env.resolve_nested_variables(); + assert_eq!(env.get("VAR3").unwrap().value, "Hello World & Universe"); +} diff --git a/tests/setAndDelete.tests.ts b/tests/setAndDelete.tests.ts deleted file mode 100644 index c3515b4..0000000 --- a/tests/setAndDelete.tests.ts +++ /dev/null @@ -1,104 +0,0 @@ -import {execSync} from 'child_process'; -import path from 'path'; - -describe('app.ts', () => { - const appPath: string = path.resolve(__dirname, '../build/app.js'); - const envPath: string = path.resolve(__dirname, '.env.test'); - const orgHash: string = execSync(`shasum -a 256 ${envPath}`).toString().split(' ')[0]; - - test('add a key', () => { - const setCommand: Buffer = execSync(`node ${appPath} NEW_KEY --set VERY_NEW --file ${envPath}`); - const getCommand: Buffer = execSync(`node ${appPath} NEW_KEY --file ${envPath}`); - - expect(setCommand.toString().trim()).toBe(''); - expect(getCommand.toString().trim()).toBe('VERY_NEW'); - }); - - test('delete an existing key', () => { - const delCommand: Buffer = execSync(`node ${appPath} NEW_KEY --delete --file ${envPath}`); - const getCommand: Buffer = execSync(`node ${appPath} --file ${envPath}`); - const allJson: any = JSON.parse(getCommand.toString().trim()); - const keys: string[] = Object.keys(allJson); - - expect(delCommand.toString().trim()).toBe(''); - expect(keys).not.toContain('NEW_KEY'); - }); - - test('add a new key with a multiline value as a single line', () => { - const setCommand: Buffer = execSync(`node ${appPath} NEW_ONE --set "This is a\nmultiline value" --file ${envPath}`); - const getCommand: Buffer = execSync(`node ${appPath} NEW_ONE --file ${envPath}`); - - expect(setCommand.toString().trim()).toBe(''); - // Note: We're escaping the newline character in the string - expect(getCommand.toString().trim()).toBe('This is a\\nmultiline value'); - }); - - test('add a new key with a multiline value', () => { - const setCommand: Buffer = execSync(`node ${appPath} NEW_TWO --set "This is a\nmultiline value" --file ${envPath}`); - const getCommand: Buffer = execSync(`node ${appPath} NEW_TWO --file ${envPath}`); - - expect(setCommand.toString().trim()).toBe(''); - expect(getCommand.toString().trim()).toBe('This is a\\nmultiline value'); - }); - - test('update an existing key without disturbing key/values below it', () => { - // This also tests that new keys are added to the end of the .env file - const setCommand: Buffer = execSync(`node ${appPath} NEW_ONE --set "Single line value" --file ${envPath}`); - const getCommand: Buffer = execSync(`node ${appPath} --file ${envPath}`); - const allJson: any = JSON.parse(getCommand.toString().trim()); - const keys: string[] = Object.keys(allJson); - const lastKey: string = keys[keys.length - 1]; - const lastValue: string = allJson[lastKey]; - - expect(setCommand.toString().trim()).toBe(''); - expect(allJson['NEW_ONE']).toBe('Single line value'); - expect(lastKey).toBe('NEW_TWO'); - expect(lastValue).toBe('This is a\nmultiline value'); - }); - - test('update an existing key with a stdin value', () => { - const setCommand: Buffer = execSync(`echo "New stdin value" | node ${appPath} NEW_TWO --file ${envPath}`); - const getCommand: Buffer = execSync(`node ${appPath} NEW_TWO --file ${envPath}`); - - expect(setCommand.toString().trim()).toBe(''); - expect(getCommand.toString().trim()).toBe('New stdin value'); - }); - - test('add list', () => { - const setCommand: Buffer = execSync(`node ${appPath} LIST --set '["one", "two", "three"]' --file ${envPath}`); - const getCommand: Buffer = execSync(`node ${appPath} LIST --file ${envPath}`); - - expect(setCommand.toString().trim()).toBe(''); - expect(getCommand.toString().trim()).toBe('["one", "two", "three"]'); - }); - - test('update list', () => { - const setCommand: Buffer = execSync(`node ${appPath} LIST --set "[\\"four\\", \\"five\\", \\"six\\"]" --file ${envPath}`); - const getCommand: Buffer = execSync(`node ${appPath} LIST --file ${envPath}`); - - expect(setCommand.toString().trim()).toBe(''); - expect(getCommand.toString().trim()).toBe('["four", "five", "six"]'); - }); - - test('remove all new test keys', () => { - const delOne: Buffer = execSync(`node ${appPath} NEW_ONE --delete --file ${envPath}`); - const delTwo: Buffer = execSync(`node ${appPath} NEW_TWO --delete --file ${envPath}`); - const delList: Buffer = execSync(`node ${appPath} LIST --delete --file ${envPath}`); - const getCommand: Buffer = execSync(`node ${appPath} --file ${envPath}`); - const allJson: any = JSON.parse(getCommand.toString().trim()); - const keys: string[] = Object.keys(allJson); - - expect(delOne.toString().trim()).toBe(''); - expect(delTwo.toString().trim()).toBe(''); - expect(delList.toString().trim()).toBe(''); - expect(keys).not.toContain('NEW_ONE'); - expect(keys).not.toContain('NEW_TWO'); - expect(keys).not.toContain('LIST'); - }); - - test('after above .env file is unchanged', () => { - const hash: string = execSync(`shasum -a 256 ${envPath}`).toString().split(' ')[0]; - expect(hash).toBe(orgHash); - }); - -}); diff --git a/tests/set_and_delete.rs b/tests/set_and_delete.rs new file mode 100644 index 0000000..2dbe619 --- /dev/null +++ b/tests/set_and_delete.rs @@ -0,0 +1,388 @@ +use assert_cmd::Command; +use std::fs; +use std::path::Path; +use tempfile::NamedTempFile; + +fn bin() -> Command { + Command::cargo_bin("dotenv").unwrap() +} + +fn env_path() -> String { + let here = Path::new(env!("CARGO_MANIFEST_DIR")); + here.join("tests/.env.test").to_string_lossy().to_string() +} + +fn copy_env() -> NamedTempFile { + let orig = env_path(); + let mut tmp = NamedTempFile::new().unwrap(); + let content = fs::read_to_string(&orig).unwrap(); + use std::io::Write; + tmp.write_all(content.as_bytes()).unwrap(); + tmp +} + +fn env_json(env_file: &NamedTempFile) -> serde_json::Value { + let output = bin().arg("--file").arg(env_file.path()).output().unwrap(); + serde_json::from_slice(&output.stdout).unwrap() +} + +#[test] +fn add_a_key() { + let tmp = copy_env(); + bin() + .arg("NEW_KEY") + .arg("--set") + .arg("VERY_NEW") + .arg("--file") + .arg(tmp.path()) + .assert() + .success(); + bin() + .arg("NEW_KEY") + .arg("--file") + .arg(tmp.path()) + .assert() + .success() + .stdout("VERY_NEW\n"); +} + +#[test] +fn delete_existing_key() { + let tmp = copy_env(); + bin() + .arg("NEW_KEY") + .arg("--set") + .arg("VERY_NEW") + .arg("--file") + .arg(tmp.path()) + .assert() + .success(); + bin() + .arg("NEW_KEY") + .arg("--delete") + .arg("--file") + .arg(tmp.path()) + .assert() + .success(); + let json = env_json(&tmp); + assert!( + !json.as_object().unwrap().contains_key("NEW_KEY"), + "Key 'NEW_KEY' should have been deleted, but is still present in the env file" + ); +} + +#[test] +fn add_multiline_value_single_line() { + let tmp = copy_env(); + bin() + .arg("NEW_ONE") + .arg("--set") + .arg("This is a\\nmultiline value") + .arg("--file") + .arg(tmp.path()) + .assert() + .success(); + bin() + .arg("NEW_ONE") + .arg("--file") + .arg(tmp.path()) + .assert() + .success() + .stdout("This is a\\nmultiline value\n"); +} + +#[test] +fn update_existing_key() { + let tmp = copy_env(); + bin() + .arg("NEW_ONE") + .arg("--set") + .arg("Single line value") + .arg("--file") + .arg(tmp.path()) + .assert() + .success(); + let json = env_json(&tmp); + assert_eq!( + json["NEW_ONE"], "Single line value", + "Key 'NEW_ONE' should have value 'Single line value', got {:?}", + json["NEW_ONE"] + ); +} + +#[test] +fn update_existing_key_with_stdin() { + let tmp = copy_env(); + // Use assert_cmd::Command to test stdin input + bin() + .arg("NEW_TWO") + .arg("--file") + .arg(tmp.path()) + .write_stdin("New stdin value") + .assert() + .success(); + bin() + .arg("NEW_TWO") + .arg("--file") + .arg(tmp.path()) + .assert() + .success() + .stdout("New stdin value\n"); +} + +#[test] +fn add_list() { + let tmp = copy_env(); + bin() + .arg("LIST") + .arg("--set") + .arg("[\"one\", \"two\", \"three\"]") + .arg("--file") + .arg(tmp.path()) + .assert() + .success(); + bin() + .arg("LIST") + .arg("--file") + .arg(tmp.path()) + .assert() + .success() + .stdout("[\"one\", \"two\", \"three\"]\n"); +} + +#[test] +fn update_list() { + let tmp = copy_env(); + bin() + .arg("LIST") + .arg("--set") + .arg("[\"four\", \"five\", \"six\"]") + .arg("--file") + .arg(tmp.path()) + .assert() + .success(); + bin() + .arg("LIST") + .arg("--file") + .arg(tmp.path()) + .assert() + .success() + .stdout("[\"four\", \"five\", \"six\"]\n"); +} + +#[test] +fn remove_all_new_test_keys() { + let tmp = copy_env(); + for (key, value) in [ + ("NEW_ONE", "one"), + ("NEW_TWO", "two"), + ("LIST", "[\"one\", \"two\", \"three\"]"), + ] { + bin() + .arg(key) + .arg("--set") + .arg(value) + .arg("--file") + .arg(tmp.path()) + .assert() + .success(); + } + + for key in &["NEW_ONE", "NEW_TWO", "LIST"] { + bin() + .arg(key) + .arg("--delete") + .arg("--file") + .arg(tmp.path()) + .assert() + .success(); + } + let json = env_json(&tmp); + let keys = json.as_object().unwrap(); + assert!( + !keys.contains_key("NEW_ONE"), + "Key 'NEW_ONE' should have been deleted, but is still present" + ); + assert!( + !keys.contains_key("NEW_TWO"), + "Key 'NEW_TWO' should have been deleted, but is still present" + ); + assert!( + !keys.contains_key("LIST"), + "Key 'LIST' should have been deleted, but is still present" + ); +} + +#[test] +fn chained_set_update_delete_workflow_preserves_file() { + use sha2::{Digest, Sha256}; + let orig = fs::read(env_path()).unwrap(); + let tmp = copy_env(); + let hash1 = Sha256::digest(&orig); + + bin() + .arg("NEW_KEY") + .arg("--set") + .arg("VERY_NEW") + .arg("--file") + .arg(tmp.path()) + .assert() + .success(); + bin() + .arg("NEW_KEY") + .arg("--file") + .arg(tmp.path()) + .assert() + .success() + .stdout("VERY_NEW\n"); + bin() + .arg("NEW_KEY") + .arg("--delete") + .arg("--file") + .arg(tmp.path()) + .assert() + .success(); + assert!( + !env_json(&tmp).as_object().unwrap().contains_key("NEW_KEY"), + "NEW_KEY should be absent after deletion" + ); + + bin() + .arg("NEW_ONE") + .arg("--set") + .arg("This is a\\nmultiline value") + .arg("--file") + .arg(tmp.path()) + .assert() + .success(); + bin() + .arg("NEW_ONE") + .arg("--file") + .arg(tmp.path()) + .assert() + .success() + .stdout("This is a\\nmultiline value\n"); + + bin() + .arg("NEW_TWO") + .arg("--set") + .arg("This is a\\nmultiline value") + .arg("--file") + .arg(tmp.path()) + .assert() + .success(); + + bin() + .arg("NEW_ONE") + .arg("--set") + .arg("Single line value") + .arg("--file") + .arg(tmp.path()) + .assert() + .success(); + let json = env_json(&tmp); + let keys: Vec<_> = json.as_object().unwrap().keys().cloned().collect(); + let last_key = keys.last().unwrap(); + assert_eq!(json["NEW_ONE"], "Single line value"); + assert_eq!(last_key, "NEW_TWO"); + assert_eq!(json["NEW_TWO"], "This is a\\nmultiline value"); + + bin() + .arg("NEW_TWO") + .arg("--file") + .arg(tmp.path()) + .write_stdin("New stdin value") + .assert() + .success(); + bin() + .arg("NEW_TWO") + .arg("--file") + .arg(tmp.path()) + .assert() + .success() + .stdout("New stdin value\n"); + + bin() + .arg("LIST") + .arg("--set") + .arg("[\"one\", \"two\", \"three\"]") + .arg("--file") + .arg(tmp.path()) + .assert() + .success(); + bin() + .arg("LIST") + .arg("--set") + .arg("[\"four\", \"five\", \"six\"]") + .arg("--file") + .arg(tmp.path()) + .assert() + .success(); + bin() + .arg("LIST") + .arg("--file") + .arg(tmp.path()) + .assert() + .success() + .stdout("[\"four\", \"five\", \"six\"]\n"); + + for key in &["NEW_ONE", "NEW_TWO", "LIST"] { + bin() + .arg(key) + .arg("--delete") + .arg("--file") + .arg(tmp.path()) + .assert() + .success(); + } + + let json = env_json(&tmp); + let keys = json.as_object().unwrap(); + assert!(!keys.contains_key("NEW_ONE")); + assert!(!keys.contains_key("NEW_TWO")); + assert!(!keys.contains_key("LIST")); + + let new = fs::read(tmp.path()).unwrap(); + let hash2 = Sha256::digest(&new); + assert_eq!( + hash1[..], + hash2[..], + ".env file changed after the full set/update/delete workflow: original hash = {:x?}, new hash = {:x?}", + hash1, + hash2 + ); +} + +#[test] +fn add_and_delete_single_key_preserves_file() { + use sha2::{Digest, Sha256}; + let orig = fs::read(env_path()).unwrap(); + let tmp = copy_env(); + let hash1 = Sha256::digest(&orig); + + bin() + .arg("HASH_TEST") + .arg("--set") + .arg("value") + .arg("--file") + .arg(tmp.path()) + .assert() + .success(); + bin() + .arg("HASH_TEST") + .arg("--delete") + .arg("--file") + .arg(tmp.path()) + .assert() + .success(); + + let new = fs::read(tmp.path()).unwrap(); + let hash2 = Sha256::digest(&new); + assert_eq!( + hash1[..], + hash2[..], + ".env file changed after add+delete: original hash = {:x?}, new hash = {:x?}", + hash1, + hash2 + ); +}