diff --git a/.github/workflows/book.yml b/.github/workflows/book.yml new file mode 100644 index 0000000..8a1a892 --- /dev/null +++ b/.github/workflows/book.yml @@ -0,0 +1,39 @@ +# From https://github.com/rust-lang/mdBook/wiki/Automated-Deployment%3A-GitHub-Actions#GitHub-Pages-Deploy +name: Deploy GH Pages +on: + push: + branches: + - main + +jobs: + deploy: + runs-on: ubuntu-latest + permissions: + contents: write # To push a branch + pages: write # To push to a GitHub Pages site + id-token: write # To update the deployment status + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Install latest mdbook + run: | + tag=$(curl 'https://api.github.com/repos/rust-lang/mdbook/releases/latest' | jq -r '.tag_name') + url="https://github.com/rust-lang/mdbook/releases/download/${tag}/mdbook-${tag}-x86_64-unknown-linux-gnu.tar.gz" + mkdir mdbook + curl -sSL $url | tar -xz --directory=./mdbook + echo `pwd`/mdbook >> $GITHUB_PATH + - name: Build Book + run: | + cd book + mdbook build + - name: Setup Pages + uses: actions/configure-pages@v4 + - name: Upload artifact + uses: actions/upload-pages-artifact@v3 + with: + # Upload entire repository + path: 'book' + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 \ No newline at end of file diff --git a/.github/workflows/publish-crate.yml b/.github/workflows/publish-crate.yml new file mode 100644 index 0000000..3bcc0a6 --- /dev/null +++ b/.github/workflows/publish-crate.yml @@ -0,0 +1,33 @@ +name: Publish to crates.io + +on: + workflow_dispatch: + inputs: + crate: + description: 'Which crate to publish' + required: true + default: 'crane' + type: choice + options: + - crane + - crane_bricks + +env: + CARGO_INCREMENTAL: 0 + +permissions: + contents: write + +jobs: + publish-crates: + name: Publish to crates.io + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: Publish to crates.io + run: cargo publish -p ${{ inputs.crate }} --token ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5dd43c5 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,86 @@ +name: Release + +on: + workflow_dispatch: + +env: + CARGO_INCREMENTAL: 0 + +permissions: + contents: write + +jobs: + release: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-apple-darwin + os: macos-latest + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Get version + id: get_version + uses: SebRollen/toml-action@v1.2.0 + with: + file: Cargo.toml + field: package.version + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true + target: ${{ matrix.target }} + - name: Setup cache + uses: Swatinem/rust-cache@v2.8.1 + with: + key: ${{ matrix.target }} + - name: Install cross + if: ${{ runner.os == 'Linux' }} + uses: actions-rs/cargo@v1 + with: + command: install + args: --color=always --git=https://github.com/cross-rs/cross.git --locked --rev=e281947ca900da425e4ecea7483cfde646c8a1ea --verbose cross + - name: Build binary + uses: actions-rs/cargo@v1 + with: + command: build + args: --release --locked --target=${{ matrix.target }} --color=always --verbose + use-cross: ${{ runner.os == 'Linux' }} + - name: Package (*nix) + env: + BINARY_NAME: "crane" + run: | + tar -c -C target/${{ matrix.target }}/release/ $BINARY_NAME | + gzip --best > \ + $BINARY_NAME-${{ steps.get_version.outputs.value }}-${{ matrix.target }}.tar.gz + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.target }} + path: | + *.tar.gz + *.zip + Cargo.lock + - name: Create release + uses: softprops/action-gh-release@v2 + with: + draft: true + files: | + *.tar.gz + *.zip + Cargo.lock + name: v${{ steps.get_version.outputs.value }} + tag_name: ${{ github.ref_name }} diff --git a/.github/workflows/rust-ci.yml b/.github/workflows/rust-ci.yml new file mode 100644 index 0000000..5713c5e --- /dev/null +++ b/.github/workflows/rust-ci.yml @@ -0,0 +1,62 @@ +name: Rust Lint & Test + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + + # make sure all code has been formatted with rustfmt + - run: rustup component add rustfmt + - name: check rustfmt + run: cargo fmt -- --check --color always + + # run clippy to verify we have no warnings + - run: rustup component add clippy + - run: cargo fetch + - name: cargo clippy + run: cargo clippy --all-targets -- -D warnings + + test: + name: Test + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-apple-darwin + os: macos-latest + - target: aarch64-apple-darwin + os: macos-latest + - target: x86_64-unknown-linux-gnu + os: ubuntu-latest + - target: aarch64-unknown-linux-gnu + os: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo fetch + - name: cargo test build + run: cargo build --tests --release + - name: cargo test + shell: bash + run: cargo test --release + - name: detects powershell + if: ${{ matrix.os != 'macos-14' }} + shell: pwsh + run: cargo test --release -- --ignored is_powershell_true + - name: doesn't detect powershell + if: ${{ matrix.os != 'macos-14' }} + shell: bash + run: cargo test --release -- --ignored is_powershell_false \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index eb60a8e..38d711c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,7 +47,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e231f6134f61b71076a3eab506c379d4f36122f2af15a9ff04415ea4c3339e2" dependencies = [ - "windows-sys", + "windows-sys 0.60.2", ] [[package]] @@ -58,7 +58,7 @@ checksum = "3e0633414522a32ffaac8ac6cc8f748e090c5717661fddeea04219e2344f5f2a" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.60.2", ] [[package]] @@ -67,6 +67,12 @@ version = "1.0.100" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61" +[[package]] +name = "bitflags" +version = "2.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2261d10cca569e4643e526d8dc2e62e433cc8aba21ab764233731f8d369bf394" + [[package]] name = "cfg-if" version = "1.0.3" @@ -75,9 +81,9 @@ checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9" [[package]] name = "clap" -version = "4.5.47" +version = "4.5.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eac00902d9d136acd712710d71823fb8ac8004ca445a89e73a41d45aa712931" +checksum = "e2134bb3ea021b78629caa971416385309e0131b351b25e01dc16fb54e1b5fae" dependencies = [ "clap_builder", "clap_derive", @@ -96,9 +102,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.47" +version = "4.5.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ad9bbf750e73b5884fb8a211a9424a1906c1e156724260fdae972f31d70e1d6" +checksum = "c2ba64afa3c0a6df7fa517765e31314e983f51dda798ffba27b988194fb65dc9" dependencies = [ "anstream", "anstyle", @@ -124,19 +130,42 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b94f61472cee1439c0b966b47e3aca9ae07e45d070759512cd390ea2bebc6675" +[[package]] +name = "colog" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df62599ba6adc9c6c04a54278c8209125343dc4775f57b9d76c9a4287e58f2bd" +dependencies = [ + "colored", + "env_logger", + "log", +] + [[package]] name = "colorchoice" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "colored" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" +dependencies = [ + "windows-sys 0.59.0", +] + [[package]] name = "crane" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "clap", "clap-verbosity", + "colog", + "colored", + "crane_bricks", "env_logger", "fuzzy-matcher", "log", @@ -144,6 +173,40 @@ dependencies = [ "toml", ] +[[package]] +name = "crane_bricks" +version = "0.1.0" +dependencies = [ + "anyhow", + "env_logger", + "log", + "serde", + "shellexpand", + "tempfile", + "toml", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.60.2", +] + [[package]] name = "env_filter" version = "0.1.3" @@ -173,6 +236,22 @@ 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 0.60.2", +] + +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fuzzy-matcher" version = "0.3.7" @@ -182,11 +261,34 @@ dependencies = [ "thread_local", ] +[[package]] +name = "getrandom" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "335ff9f135e4384c8150d6f27c6daed433577f86b4750418338c01a1a2528592" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "hashbrown" -version = "0.15.5" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" [[package]] name = "heck" @@ -196,9 +298,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "indexmap" -version = "2.11.3" +version = "2.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92119844f513ffa41556430369ab02c295a3578af21cf945caa3e9e0c2481ac3" +checksum = "4b0f83760fb341a774ed326568e19f5a863af4a952def8c39f9ab92fd95b88e5" dependencies = [ "equivalent", "hashbrown", @@ -234,6 +336,28 @@ dependencies = [ "syn", ] +[[package]] +name = "libc" +version = "0.2.175" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a82ae493e598baaea5209805c49bbf2ea7de956d50d7da0da1164f9c6d28543" + +[[package]] +name = "libredox" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "416f7e718bdb06000964960ffa43b4335ad4012ae8b99060261aa4a8088d5ccb" +dependencies = [ + "bitflags", + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "log" version = "0.4.28" @@ -246,12 +370,24 @@ version = "2.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + [[package]] name = "once_cell_polyfill" version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "portable-atomic" version = "1.11.1" @@ -285,6 +421,23 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.16", + "libredox", + "thiserror", +] + [[package]] name = "regex" version = "1.11.2" @@ -314,11 +467,24 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001" +[[package]] +name = "rustix" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.60.2", +] + [[package]] name = "serde" -version = "1.0.225" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd6c24dee235d0da097043389623fb913daddf92c76e9f5a1db88607a0bcbd1d" +checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" dependencies = [ "serde_core", "serde_derive", @@ -326,18 +492,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.225" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "659356f9a0cb1e529b24c01e43ad2bdf520ec4ceaf83047b83ddcc2251f96383" +checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.225" +version = "1.0.226" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ea936adf78b1f766949a4977b91d2f5595825bd6ec079aa9543ad2685fc4516" +checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" dependencies = [ "proc-macro2", "quote", @@ -346,13 +512,22 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2789234a13a53fc4be1b51ea1bab45a3c338bdb884862a257d10e5a74ae009e6" +checksum = "5417783452c2be558477e104686f7de5dae53dba813c28435e0e70f82d9b04ee" dependencies = [ "serde_core", ] +[[package]] +name = "shellexpand" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" +dependencies = [ + "dirs", +] + [[package]] name = "strsim" version = "0.11.1" @@ -370,6 +545,39 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84fa4d11fadde498443cca10fd3ac23c951f0dc59e080e9f4b93d4df4e4eea53" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.60.2", +] + +[[package]] +name = "thiserror" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -381,9 +589,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.6" +version = "0.9.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae2a4cf385da23d1d53bc15cdfa5c2109e93d8d362393c801e87da2f72f0e201" +checksum = "00e5e5d9bf2475ac9d4f0d9edab68cc573dc2fd644b0dba36b0c30a92dd9eaa0" dependencies = [ "indexmap", "serde_core", @@ -396,27 +604,27 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.1" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a197c0ec7d131bfc6f7e82c8442ba1595aeab35da7adbf05b6b73cd06a16b6be" +checksum = "32f1085dec27c2b6632b04c80b3bb1b4300d6495d1e129693bdda7d91e72eec1" dependencies = [ "serde_core", ] [[package]] name = "toml_parser" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" +checksum = "4cf893c33be71572e0e9aa6dd15e6677937abd686b066eac3f8cd3531688a627" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" +checksum = "d163a63c116ce562a22cda521fcc4d79152e7aba014456fb5eb442f6d6a10109" [[package]] name = "unicode-ident" @@ -430,19 +638,68 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.1+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7" +dependencies = [ + "wit-bindgen", +] + [[package]] name = "windows-link" version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.3", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", ] [[package]] @@ -452,58 +709,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5fe6031c4041849d7c496a8ded650796e7b6ecc19df1a431c1a363342e5dc91" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.0", + "windows_aarch64_msvc 0.53.0", + "windows_i686_gnu 0.53.0", + "windows_i686_gnullvm 0.53.0", + "windows_i686_msvc 0.53.0", + "windows_x86_64_gnu 0.53.0", + "windows_x86_64_gnullvm 0.53.0", + "windows_x86_64_msvc 0.53.0", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "86b8d5f90ddd19cb4a147a5fa63ca848db3df085e25fee3cc10b39b6eebae764" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7651a1f62a11b8cbd5e0d42526e55f2c99886c77e007179efff86c2b137e66c" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1dc67659d35f387f5f6c479dc4e28f1d4bb90ddd1a5d3da2e5d97b42d6272c3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ce6ccbdedbf6d6354471319e781c0dfef054c81fbc7cf83f338a4296c0cae11" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "581fee95406bb13382d2f65cd4a908ca7b1e4c2f1917f143ba16efe98a589b5d" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e55b5ac9ea33f2fc1716d1742db15574fd6fc8dadc51caab1c16a3d3b4190ba" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0a6e035dd0599267ce1ee132e51c27dd29437f63325753051e71dd9e42406c57" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.0" @@ -515,3 +820,9 @@ name = "winnow" version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" + +[[package]] +name = "wit-bindgen" +version = "0.46.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" diff --git a/Cargo.toml b/Cargo.toml index 1e1bd2f..7c701cf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,14 +1,6 @@ -[package] -name = "crane" -version = "0.1.0" -edition = "2024" - -[dependencies] -serde = { version = "1.0", features = ["derive"] } -clap = { version = "4.5.47", features = ["derive"] } -fuzzy-matcher = "0.3.7" -anyhow = "1.0.99" -toml = "0.9.6" -clap-verbosity = "2.1.0" -env_logger = "0.11.8" -log = "0.4.28" +[workspace] +resolver = "3" +members = [ + "crane_bricks", + "crane" +] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..145534d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 timothebot + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 45271d2..a0a0d9e 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,17 @@ # crane πŸ—οΈ Easily add bricks (files or snippets) to your projects. + +🧱 A brick is an instruction. It can be a file that gets added, a command that +executes or lines getting replaced in a target file. + +> 🚧 crane is currently being worked on :D +> Some features that are already documented don't work yet +> and everything could change until a stable release! + +## ToDo + +- [ ] Regex support +- [ ] Path support +- [ ] Improve readme +- [ ] Variables support diff --git a/book/.gitignore b/book/.gitignore new file mode 100644 index 0000000..7585238 --- /dev/null +++ b/book/.gitignore @@ -0,0 +1 @@ +book diff --git a/book/book.toml b/book/book.toml new file mode 100644 index 0000000..dfc511b --- /dev/null +++ b/book/book.toml @@ -0,0 +1,5 @@ +[book] +authors = ["timothebot"] +language = "en" +src = "src" +title = "Crane Documentation πŸ—οΈ" diff --git a/book/src/SUMMARY.md b/book/src/SUMMARY.md new file mode 100644 index 0000000..78f0619 --- /dev/null +++ b/book/src/SUMMARY.md @@ -0,0 +1,9 @@ +# Summary + +- [Introduction](./intro.md) + +# User Guide + +- [Configuration](./configuration/README.md) + - [Crane Config](./configuration/crane_config.md) + - [Brick Config](./configuration/brick_config.md) diff --git a/book/src/configuration/README.md b/book/src/configuration/README.md new file mode 100644 index 0000000..a025a48 --- /dev/null +++ b/book/src/configuration/README.md @@ -0,0 +1 @@ +# Configuration diff --git a/book/src/configuration/brick_config.md b/book/src/configuration/brick_config.md new file mode 100644 index 0000000..e4c8d1d --- /dev/null +++ b/book/src/configuration/brick_config.md @@ -0,0 +1,91 @@ +# Brick Config + +The brick config defines what the brick should do. The file must be called `brick.toml` +and must be located at the root of the brick directory. + +To get started, create a new directory in a [defined brick directory](./crane_config.md#brick-directories). +Inside, create a file called `brick.toml` and add a name for your brick. + +```toml +name = "my_brick_name" +``` + +## No Config + +If you have a brick directory without a `brick.toml` file, it will still work. By default, this will take the directory name as brick name and add the [insert file](#insert-file) action. + +## Shared options + +```toml +# Name must always be set if a brick.toml exists +name = "" +``` + +## Actions + +You can define as many actions as you want. For all actions, you may specify a specific `working_dir`, +which is a relative path from where the action would execute to where it should actually execute. + +This is useful if you want to add files from a project root that are located in subfolders. + +```toml +working_dir = "./src/" +``` + +### Insert File + +```toml +[[actions]] +action = "insert_file" + +if_file_exists = "append" # or "replace" or "pass" + +# Which files should be inserted +sources = [ + "file.txt" +] +``` + +If no `sources` are defined, it will use all files in the brick directory (except the config file). + +### Modify File + +Allows you to modify a specific part of a file. +You can do different operations, like *replace*, *append* or *prepend*. + +```toml +[[actions]] +action = "modify_file" + +# The content that will be inserted into the file. +content = "text" # or file (prefix path with file:) + +# Where the modification should happen +selector = "[dependencies]" # either text or regex (prefix with re:) + +# Modify can be type "append" (default), "prepend" or "replace": +# append text inside file +type = "append" + + +# Specify which files this action applies to +sources = [ + "file.txt" +] +``` + +If no `sources` are defined, it will use all files in the brick directory (except the config file). + +### Run Script + +Allows you to run a command or a script file. + +```toml +[[actions]] +action = "run_command" + +command = "echo 'hi'" # command or a script file (prefix with file:) +``` + +This is by far the most simple yet powerful action. +If you need more complex behaviour, you can add a custom script that does what you need. diff --git a/book/src/configuration/crane_config.md b/book/src/configuration/crane_config.md new file mode 100644 index 0000000..ae9a437 --- /dev/null +++ b/book/src/configuration/crane_config.md @@ -0,0 +1,23 @@ +# Crane Config File + +The crane config file is located by default at `~/.config/crane/config.toml`, but the config directory can be changed by setting the `CRANE_CONFIG_DIR` env variable. + +## Brick Directories + +You can define where crane should look for bricks. If no paths are set, crane will look for a `bricks` folder in the same directory as the config is placed. + +```toml +brick_dirs = [ + "./bricks" +] +``` + +## Aliases + +You can define aliases for multiple bricks. + +```toml +[[alias]] +name = "rust" +bricks = [ "mit", "rustfmt", "serde" ] +``` diff --git a/book/src/intro.md b/book/src/intro.md new file mode 100644 index 0000000..60d1efa --- /dev/null +++ b/book/src/intro.md @@ -0,0 +1,29 @@ +# Introduction + +Crane is a CLI tool that allows you to create *bricks* that you can later add to any project. + +A brick is an instruction. It can be a file that gets added, a command that executes or lines getting replaced in a target file. + +## Quick Examples + +### License brick + +Instead of having to look up and copy your desired license from the web, you can create a brick out of it and then run `crane add some-license`. + +### Language specific bricks + +You can create multiple bricks and combine them behind an alias. + +This way, you can easily bootstrap new projects! + +```shell +$ cargo new my_project && cd my_project +# ... +$ crane add rust +β†’ Executing 4 bricks + β€’ mit + β€’ serde + β€’ rustfmt + β€’ rustauthor +# ... +``` \ No newline at end of file diff --git a/crane/Cargo.toml b/crane/Cargo.toml new file mode 100644 index 0000000..47822ac --- /dev/null +++ b/crane/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "crane" +description = "Easily add bricks (files or snippets) to your projects!" +version = "0.2.0" +edition = "2024" +license = "MIT" +readme = "../README.md" +repository = "https://github.com/timothebot/crane" +categories = ["command-line-utilities"] +keywords = ["cli", "tool", "utility"] + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +toml = "0.9.6" +clap = { version = "4.5.47", features = ["derive"] } +fuzzy-matcher = "0.3.7" +anyhow = "1.0.99" +clap-verbosity = "2.1.0" +env_logger = "0.11.8" +log = "0.4.28" +crane_bricks = { path = "../crane_bricks/", version = "0.1.0"} +colog = "1.4.0" +colored = "3.0.0" diff --git a/crane/src/cmd/add.rs b/crane/src/cmd/add.rs new file mode 100644 index 0000000..259aee9 --- /dev/null +++ b/crane/src/cmd/add.rs @@ -0,0 +1,122 @@ +use std::{env, path::Path}; + +use colored::Colorize; +use log::debug; + +use crate::{ + cmd::{Add, Run}, + config::CraneConfig, +}; +use crane_bricks::{ + brick::{Brick, bricks_in_dir}, + context::ActionContext, +}; + +impl Run for Add { + fn run(&self) { + let config = CraneConfig::new(); + let brick_dirs = if let Some(brick_dirs) = &self.brick_dirs + && brick_dirs.is_empty() + { + brick_dirs + } else { + &config.brick_dirs().to_vec() + }; + debug!( + "Checking brick dirs:\n* {}", + brick_dirs + .iter() + .map(|path| path.display().to_string()) + .collect::>() + .join("\n* ") + ); + + let target_dir = match &self.target_dir { + Some(dir) => dir, + None => &env::current_dir().unwrap(), + }; + + let bricks: Vec = brick_dirs + .iter() + .flat_map(|dir| bricks_in_dir(dir)) + .collect(); + + debug!( + "Found bricks:\n* {}", + bricks + .iter() + .map(|brick| brick.name().to_string()) + .collect::>() + .join("\n* ") + ); + + let brick_queries: Vec = self + .bricks + .iter() + .flat_map(|query| { + for alias in config.alias() { + if alias.name().to_lowercase() == query.to_lowercase() { + return alias.bricks().to_vec(); + } + } + vec![query.clone()] + }) + .collect(); + + let mut bricks_to_execute: Vec<&Brick> = Vec::new(); + for brick_query in brick_queries { + let mut found = false; + for brick in &bricks { + if brick.name().to_lowercase() == brick_query.to_lowercase() { + bricks_to_execute.push(brick); + found = true; + break; + } + } + if !found { + eprintln!("{} Could not find brick '{}'", "⚠".red(), brick_query); + } + } + /* TODO: render aliases like this: + β†’ Executing 4 bricks + β€’ MIT + β€’ rust (alias) + β—¦ author-rust + β—¦ serde + β€’ rustfmt + */ + let plural = if bricks_to_execute.len() > 1 { "s" } else { "" }; + println!( + "{} Executing {} brick{}", + "β†’".green(), + bricks_to_execute.len().to_string().purple(), + plural + ); + for brick in &bricks_to_execute { + println!(" {} {}", "β€’".dimmed(), brick.name()) + } + + let context = ActionContext::new(self.dry_run); + for brick in bricks_to_execute { + execute_brick(brick, &context, target_dir); + } + } +} + +fn execute_brick(brick: &Brick, context: &ActionContext, cwd: &Path) { + println!( + "\n{} Executing brick '{}'", + "β†’".green(), + brick.name().purple() + ); + match brick.execute(context, cwd) { + Ok(_) => println!( + "{}", + format!("βœ” Successfully executed '{}'! ◝(Β°α—œΒ°)β—œ", brick.name().bold()).green() + ), + Err(_) => eprintln!( + "{}", + format!("✘ Failed to execute '{}'! ヽ(°〇°)οΎ‰", brick.name().bold()).red() + ), + } +} diff --git a/src/cmd/cmd.rs b/crane/src/cmd/commands.rs similarity index 72% rename from src/cmd/cmd.rs rename to crane/src/cmd/commands.rs index ee44ae9..b00174c 100644 --- a/src/cmd/cmd.rs +++ b/crane/src/cmd/commands.rs @@ -1,7 +1,7 @@ use std::path::PathBuf; use clap::{Parser, Subcommand, ValueHint, command}; -use clap_verbosity::Verbosity; +use clap_verbosity::{InfoLevel, Verbosity}; #[derive(Debug, Parser)] #[command(version, about, long_about = None)] @@ -10,7 +10,7 @@ pub struct CraneCli { pub command: CraneCommand, #[command(flatten)] - pub verbose: Verbosity, + pub verbose: Verbosity, } #[derive(Subcommand, Debug)] @@ -25,19 +25,19 @@ pub struct Add { #[clap(num_args = 1.., required = true)] pub bricks: Vec, - #[arg(short, long, value_hint=ValueHint::DirPath, value_terminator=",", default_value="")] - pub brick_dirs: Vec, + #[arg(short, long, value_hint=ValueHint::DirPath, value_terminator=",")] + pub brick_dirs: Option>, #[arg(short, long, value_hint=ValueHint::DirPath)] pub target_dir: Option, - #[arg(short='n', long)] + #[arg(short = 'n', long)] pub dry_run: bool, } /// List all available bricks #[derive(Debug, Parser, Clone)] pub struct List { - #[arg(short, long, value_hint=ValueHint::DirPath)] - pub brick_dirs: Option, + #[arg(short, long, value_hint=ValueHint::DirPath, value_terminator=",")] + pub brick_dirs: Option>, } diff --git a/crane/src/cmd/list.rs b/crane/src/cmd/list.rs new file mode 100644 index 0000000..c2a8704 --- /dev/null +++ b/crane/src/cmd/list.rs @@ -0,0 +1,43 @@ +use std::fs; + +use colored::Colorize; +use log::info; + +use crate::{ + cmd::{List, Run}, + config::{CraneConfig, map_aliases}, +}; +use crane_bricks::brick::bricks_in_dir; + +impl Run for List { + fn run(&self) { + let config = CraneConfig::new(); + let brick_dirs = if let Some(brick_dirs) = &self.brick_dirs + && brick_dirs.is_empty() + { + brick_dirs + } else { + &config.brick_dirs().to_vec() + }; + + let alias_mapped = map_aliases(config.alias()); + + for brick_dir in brick_dirs { + println!( + "{} Found brick directory at {}", + "β†’".green(), + fs::canonicalize(brick_dir) + .unwrap_or(brick_dir.to_path_buf()) + .display() + ); + for brick in bricks_in_dir(brick_dir) { + let mut affix = String::new(); + if let Some(aliases) = alias_mapped.get(brick.name()) { + affix = format!(" (aliased in '{}')", aliases.join("', '")); + } + info!("{}{}", brick.name(), affix.dimmed()); + } + println!() + } + } +} diff --git a/src/cmd/mod.rs b/crane/src/cmd/mod.rs similarity index 67% rename from src/cmd/mod.rs rename to crane/src/cmd/mod.rs index 9699b61..b87c666 100644 --- a/src/cmd/mod.rs +++ b/crane/src/cmd/mod.rs @@ -1,8 +1,8 @@ mod add; -mod cmd; +mod commands; mod list; -pub use crate::cmd::cmd::*; +pub use crate::cmd::commands::*; pub trait Run { fn run(&self); @@ -12,7 +12,7 @@ impl Run for CraneCli { fn run(&self) { match &self.command { CraneCommand::Add(cmd) => cmd.run(), - CraneCommand::List(cmd) => cmd.run() + CraneCommand::List(cmd) => cmd.run(), } } } diff --git a/crane/src/config.rs b/crane/src/config.rs new file mode 100644 index 0000000..ef4b14d --- /dev/null +++ b/crane/src/config.rs @@ -0,0 +1,150 @@ +use std::{collections::HashMap, env, fs, path::PathBuf}; + +use serde::{Deserialize, Serialize}; + +const ENV_KEY_CONFIG_DIR: &str = "CRANE_CONFIG_DIR"; + +fn config_path_from_env() -> anyhow::Result { + Ok(PathBuf::from(env::var(ENV_KEY_CONFIG_DIR)?)) +} + +pub fn config_dir() -> PathBuf { + match config_path_from_env() { + Ok(path) => path, + Err(_) => PathBuf::from("~/.config/crane"), + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Alias { + name: String, + bricks: Vec, +} + +impl Alias { + #[allow(dead_code)] + pub fn new(name: String, bricks: Vec) -> Self { + Self { name, bricks } + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn bricks(&self) -> &[String] { + &self.bricks + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, Default)] +pub struct CraneConfig { + #[serde(default)] + brick_dirs: Vec, + + #[serde(default)] + alias: Vec, +} + +impl CraneConfig { + pub fn new() -> Self { + let cnf_dir = config_dir(); + let config_file = cnf_dir.join("config.toml"); + let mut config = { + if config_file.exists() + && let Ok(parsed_config) = toml::from_str::( + fs::read_to_string(config_file).unwrap_or_default().as_str(), + ) + { + parsed_config + } else { + CraneConfig::default() + } + }; + + if config.brick_dirs.is_empty() { + config.brick_dirs.push(PathBuf::from("bricks")); + } + + config.brick_dirs = config + .brick_dirs() + .iter() + .map(|brick_dir| { + if brick_dir.is_absolute() { + brick_dir.clone() + } else { + cnf_dir.join(brick_dir) + } + }) + .collect(); + config + } + + pub fn brick_dirs(&self) -> &[PathBuf] { + &self.brick_dirs + } + + pub fn alias(&self) -> &[Alias] { + &self.alias + } +} + +/// Converts a list of aliases to a map where the brick +/// name is the key and value all aliases +pub fn map_aliases(aliases: &[Alias]) -> HashMap> { + let mut brick_map: HashMap> = HashMap::new(); + for alias in aliases { + for brick in alias.bricks() { + if brick_map.contains_key(brick) { + brick_map + .get_mut(brick) + .unwrap() + .push(alias.name().to_string()); + } else { + brick_map.insert(brick.to_string(), vec![alias.name().to_string()]); + } + } + } + brick_map +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use super::*; + + #[test] + fn test_config_dir_from_env() { + // should be safe as long as it is only one test (of this kind) + unsafe { + // THIS IS NOT THE DEFAULT PATH! + env::set_var(ENV_KEY_CONFIG_DIR, "~/.crane"); + }; + assert_eq!( + format!("{}", config_dir().display()), + String::from("~/.crane") + ) + } + + #[test] + fn test_alias_map() { + let aliases = vec![ + Alias::new( + String::from("hello"), + vec![String::from("brick_a"), String::from("brick_b")], + ), + Alias::new( + String::from("world"), + vec![String::from("brick_c"), String::from("brick_b")], + ), + ]; + let mut brick_map: HashMap> = HashMap::new(); + brick_map.insert(String::from("brick_a"), vec![String::from("hello")]); + brick_map.insert( + String::from("brick_b"), + vec![String::from("hello"), String::from("world")], + ); + brick_map.insert(String::from("brick_c"), vec![String::from("world")]); + assert_eq!(map_aliases(&aliases), brick_map); + } +} diff --git a/crane/src/logging.rs b/crane/src/logging.rs new file mode 100644 index 0000000..6b2a5a5 --- /dev/null +++ b/crane/src/logging.rs @@ -0,0 +1,32 @@ +use clap_verbosity::{InfoLevel, Verbosity}; +use colog::format::CologStyle; +use colored::Colorize; +use env_logger::Builder; +use log::Level; + +struct CustomLogStyle {} + +impl CologStyle for CustomLogStyle { + fn prefix_token(&self, level: &Level) -> String { + let prefix = match level { + Level::Error => "⚠".red(), + Level::Warn => "⚠".yellow(), + Level::Info => ">".dimmed(), + Level::Debug => { + return format!("{}", "[DEBUG]".red()); + } + Level::Trace => { + return format!("{}", "[TRACE]".red()); + } + }; + + format!(" {}", prefix) + } +} + +pub fn setup(verbose: &Verbosity) { + let mut builder = Builder::new(); + builder.filter_level(verbose.log_level_filter()); + builder.format(colog::formatter(CustomLogStyle {})); + builder.init(); +} diff --git a/crane/src/main.rs b/crane/src/main.rs new file mode 100644 index 0000000..5666ab9 --- /dev/null +++ b/crane/src/main.rs @@ -0,0 +1,13 @@ +use clap::Parser; + +use crate::cmd::{CraneCli, Run}; + +mod cmd; +mod config; +mod logging; + +fn main() { + let cli = CraneCli::parse(); + logging::setup(&cli.verbose); + cli.run(); +} diff --git a/crane_bricks/Cargo.toml b/crane_bricks/Cargo.toml new file mode 100644 index 0000000..468441d --- /dev/null +++ b/crane_bricks/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "crane_bricks" +description = "Execute bricks" +version = "0.1.0" +edition = "2024" +license = "MIT" +repository = "https://github.com/timothebot/crane" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +toml = "0.9.6" +anyhow = "1.0.99" +log = "0.4.28" +shellexpand = "3.1.1" + +[dev-dependencies] +tempfile = "3" +env_logger = "0.11.8" diff --git a/crane_bricks/README.md b/crane_bricks/README.md new file mode 100644 index 0000000..3a2dc07 --- /dev/null +++ b/crane_bricks/README.md @@ -0,0 +1,3 @@ +# crane bricks 🧱 + +Library for https://github.com/timothebot/crane. diff --git a/crane_bricks/src/actions/common.rs b/crane_bricks/src/actions/common.rs new file mode 100644 index 0000000..a0e0390 --- /dev/null +++ b/crane_bricks/src/actions/common.rs @@ -0,0 +1,26 @@ +use serde::Deserialize; + +#[derive(Debug, Deserialize, Default, Clone, PartialEq, Eq)] +pub struct Common { + /// Relative path from where you run crane to where the files should go + /// + /// ```toml + /// [[actions]] + /// working_dir = "./src/" + /// ``` + #[serde(default)] + pub working_dir: Option, + + /// List of paths including for which files the action should run + /// Empty means all files (except config file) will be included + /// + /// ```toml + /// [[actions]] + /// sources = [ "README.md", "LICENSE" ] + /// + /// # Or regex + /// sources = [ "re:.+\.md", "LICENSE"] + /// ``` + #[serde(default)] + pub sources: Vec, +} diff --git a/crane_bricks/src/actions/insert_file.rs b/crane_bricks/src/actions/insert_file.rs new file mode 100644 index 0000000..a676312 --- /dev/null +++ b/crane_bricks/src/actions/insert_file.rs @@ -0,0 +1,100 @@ +use std::path::Path; + +use serde::Deserialize; + +use crate::{ + actions::{ExecuteAction, common::Common}, + brick::Brick, + context::ActionContext, + file_utils::{file_append_content, file_create_new, file_replace_content}, +}; + +/// Creates a new file. +/// +/// ## Example +/// +/// ### Config +/// +/// ```toml +/// [[actions]] +/// sources = [ +/// "LICENSE" +/// ] +/// if_file_exists = "replace" +/// ``` +/// +/// ### Result +/// +/// Will create the LICENSE file. If it already exists, it replaces it. +#[derive(Debug, Deserialize, Default, Clone, PartialEq, Eq)] +pub struct InsertFileAction { + #[serde(flatten)] + pub common: Common, + + /// Define what happens if the file already exists + #[serde(default)] + pub if_file_exists: FileExistsAction, +} + +#[derive(Debug, Deserialize, Clone, Default, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum FileExistsAction { + #[default] + Append, + Replace, + Pass, +} + +impl ExecuteAction for InsertFileAction { + fn execute( + &self, + context: &ActionContext, + brick: &Brick, + cwd: &Path, + ) -> anyhow::Result<()> { + let mut files = brick.files(); + if !&self.common.sources.is_empty() { + files.retain(|file| self.common.sources.contains(&file.name().to_string())); + } + debug!("{} executing for {} files", brick.name(), files.len()); + if files.len() > 1 { + info!( + "Inserting files: '{}'", + files + .iter() + .map(|file| file.name().to_string()) + .collect::>() + .join("', '") + ) + } else if files.len() == 1 { + info!("Inserting file '{}'", files.first().unwrap().name()); + } else { + warn!("No files found to insert!"); + } + for file in files { + let target_path = cwd.join(file.name()); + let content = file.content().to_string(); + if !target_path.exists() { + info!("Created file '{}'", file.name()); + file_create_new(context, &target_path, Some(content))?; + continue; + } + warn!("File '{}' already exists", file.name()); + match &self.if_file_exists { + FileExistsAction::Append => { + info!("Appending content to file"); + file_append_content(context, &target_path, &content)? + } + FileExistsAction::Replace => { + info!("Replacing all content of file"); + file_replace_content(context, &target_path, &content)? + } + FileExistsAction::Pass => { + info!("Continuing"); + continue; + } + } + } + Ok(()) + } +} diff --git a/crane_bricks/src/actions/mod.rs b/crane_bricks/src/actions/mod.rs new file mode 100644 index 0000000..4cf536b --- /dev/null +++ b/crane_bricks/src/actions/mod.rs @@ -0,0 +1,50 @@ +pub mod common; +pub mod insert_file; +pub mod modify_file; +pub mod run_command; + +use std::path::Path; + +use serde::Deserialize; + +use crate::{ + actions::{ + insert_file::InsertFileAction, modify_file::ModifyFileAction, + run_command::RunCommandAction, + }, + brick::Brick, + context::ActionContext, +}; + +pub trait ExecuteAction { + fn execute( + &self, + context: &ActionContext, + brick: &Brick, + cwd: &Path, + ) -> anyhow::Result<()>; +} + +#[derive(Debug, Deserialize, Clone, PartialEq, Eq)] +#[serde(tag = "action", rename_all = "snake_case")] +pub enum Action { + InsertFile(InsertFileAction), + ModifyFile(ModifyFileAction), + RunCommand(RunCommandAction), +} + +impl ExecuteAction for Action { + fn execute( + &self, + context: &ActionContext, + brick: &Brick, + cwd: &Path, + ) -> anyhow::Result<()> { + debug!("Executing '{}' brick action '{:#?}'", brick.name(), &self); + match &self { + Action::InsertFile(action) => action.execute(context, brick, cwd), + Action::ModifyFile(action) => action.execute(context, brick, cwd), + Action::RunCommand(action) => action.execute(context, brick, cwd), + } + } +} diff --git a/crane_bricks/src/actions/modify_file.rs b/crane_bricks/src/actions/modify_file.rs new file mode 100644 index 0000000..36eaedb --- /dev/null +++ b/crane_bricks/src/actions/modify_file.rs @@ -0,0 +1,162 @@ +use anyhow::{Ok, anyhow}; +use serde::Deserialize; + +use crate::{ + actions::{ExecuteAction, common::Common}, + file_utils::{file_read_content, file_replace_content}, +}; + +/// Modify a file by inserting content at a specific location. +/// +/// ## Example +/// +/// ### Config +/// +/// ```toml +/// [[actions]] +/// # For this action, the name of the files that the modification +/// # will apply +/// sources = [ +/// "Cargo.toml" +/// ] +/// type = "append" +/// content = "\nserde = \"1\"" +/// selector = "[dependencies]" +/// ``` +/// +/// ### Result +/// +/// ```toml +/// # Before +/// [dependencies] +/// crane = "9.9.9" +/// +/// # After +/// [dependencies] +/// serde = "1" +/// crane = "9.9.9" +/// ``` +#[derive(Debug, Deserialize, Default, Clone, PartialEq, Eq)] +pub struct ModifyFileAction { + #[serde(flatten)] + pub common: Common, + + /// If the modification should append or prepend text next to the + /// selector or if it should replace it. + pub(self) r#type: ModifyType, + + pub content: Option, + + /// The content selector for the modification, must be unique. + /// Can be regex if prefix with "re:". + pub selector: String, +} + +#[derive(Debug, Deserialize, Default, Clone, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum ModifyType { + #[default] + Append, + Prepend, + Replace, +} + +impl ModifyFileAction { + pub fn content(&self) -> String { + // TODO: Get content from somewhere else if not set + self.content.clone().unwrap_or_default() + } + + pub fn modify_content(&self, source_text: String) -> anyhow::Result { + // TODO: Handle regex + // TODO: insert for all or just one? + + let locations: Vec<(usize, &str)> = + source_text.match_indices(&self.selector).collect(); + + if locations.is_empty() { + return Err(anyhow!("No selector matches in target file!")); + } + if locations.len() > 1 { + info!("Found {} matches", locations.len()); + } else { + info!("Found {} match", locations.len()); + } + + let mut output = source_text.clone(); + let start_length = source_text.len(); + + for (index, selected) in locations { + // This is to account for new inserted text, which + // means the index has shifted. + let modified_index = index + output.len().abs_diff(start_length); + match &self.r#type { + ModifyType::Append => { + output.insert_str( + (modified_index + selected.len()).max(0), + &self.content(), + ); + } + ModifyType::Prepend => { + output.insert_str(modified_index, &self.content()); + } + ModifyType::Replace => { + // TODO: Something isnt right here but im so tired rn pls + // future tiimo fix this + debug!( + "replacing from {} to {} (total chars {})", + modified_index, + (modified_index + selected.len()).max(0), + output.len() + ); + if modified_index > output.len() { + output.insert_str(output.len(), &self.content()); + } else { + output.replace_range( + modified_index..(modified_index + selected.len()), + &self.content(), + ); + } + } + } + } + match &self.r#type { + ModifyType::Append => { + info!("Appended to all matches"); + } + ModifyType::Prepend => { + info!("Prepended to all matches"); + } + ModifyType::Replace => { + info!("Replaced all matches"); + } + } + Ok(output) + } +} + +impl ExecuteAction for ModifyFileAction { + fn execute( + &self, + context: &crate::context::ActionContext, + brick: &crate::brick::Brick, + cwd: &std::path::Path, + ) -> anyhow::Result<()> { + let mut files: Vec = brick + .files() + .iter() + .map(|brick_file| brick_file.name().to_string()) + .collect(); + files.extend(self.common.sources.clone()); + for file in files { + let target_path = cwd.join(file); + if !target_path.exists() { + return Err(anyhow!("Target file does not exist!")); + } + info!("Modifying file '{}'", target_path.display()); + let content = file_read_content(context, &target_path)?; + file_replace_content(context, &target_path, &self.modify_content(content)?)?; + } + Ok(()) + } +} diff --git a/crane_bricks/src/actions/run_command.rs b/crane_bricks/src/actions/run_command.rs new file mode 100644 index 0000000..72aaea9 --- /dev/null +++ b/crane_bricks/src/actions/run_command.rs @@ -0,0 +1,52 @@ +use std::{path::Path, process::Command}; + +use serde::Deserialize; + +use crate::{ + actions::{ExecuteAction, common::Common}, + brick::Brick, + context::ActionContext, +}; + +/// Run a command +/// +/// ## Example +/// +/// ### Config +/// +/// ```toml +/// [[actions]] +/// command = "echo hi > test.txt" +/// ``` +/// +/// ### Result +/// +/// Will run echo hi and write the stdout into test.txt +#[derive(Debug, Deserialize, Default, Clone, PartialEq, Eq)] +pub struct RunCommandAction { + #[serde(flatten)] + pub common: Common, + + pub command: String, +} + +impl ExecuteAction for RunCommandAction { + fn execute( + &self, + context: &ActionContext, + _brick: &Brick, + cwd: &Path, + ) -> anyhow::Result<()> { + info!("Running command"); + if context.dry_run { + return Ok(()); + } + Command::new("sh") + .arg("-c") + .arg(&self.command) + .current_dir(cwd) + .output()?; + + Ok(()) + } +} diff --git a/crane_bricks/src/brick.rs b/crane_bricks/src/brick.rs new file mode 100644 index 0000000..15cbad3 --- /dev/null +++ b/crane_bricks/src/brick.rs @@ -0,0 +1,165 @@ +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use anyhow::anyhow; +use serde::Deserialize; + +use crate::{ + actions::{Action, ExecuteAction, insert_file::InsertFileAction}, + context::ActionContext, + file_utils::{sub_dirs, sub_paths}, +}; + +const BRICK_CONFIG_FILE: &str = "brick.toml"; + +#[derive(Deserialize, Debug, Clone, Default, PartialEq, Eq)] +pub struct BrickConfig { + name: String, + + #[serde(default)] + actions: Vec, +} + +impl BrickConfig { + pub fn new(name: String, actions: Vec) -> Self { + Self { name, actions } + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn actions(&self) -> &[Action] { + &self.actions + } +} + +#[derive(Debug, Clone)] +pub struct BrickFile { + name: String, + content: String, +} + +impl BrickFile { + pub fn new(name: String, content: String) -> Self { + Self { name, content } + } + + pub fn name(&self) -> &str { + &self.name + } + + pub fn content(&self) -> &str { + &self.content + } +} + +#[derive(Debug, Clone)] +pub struct Brick { + config: BrickConfig, + source_path: PathBuf, +} + +impl Brick { + pub fn new(name: String, source_path: PathBuf) -> Self { + Brick { + config: BrickConfig { + name, + // If no action is configured, InsertFileAction is default + actions: vec![Action::InsertFile(InsertFileAction::default())], + }, + source_path, + } + } + + pub fn new_with_config(config: BrickConfig, source_path: PathBuf) -> Self { + Brick { + config, + source_path, + } + } + + pub fn name(&self) -> &str { + &self.config.name + } + + pub fn path(&self) -> &PathBuf { + &self.source_path + } + + pub fn config(&self) -> &BrickConfig { + &self.config + } + + pub fn execute(&self, context: &ActionContext, cwd: &Path) -> anyhow::Result<()> { + for action in &self.config.actions { + action.execute(context, self, cwd)?; + } + Ok(()) + } + + /// Returns a list of all files that + pub fn files(&self) -> Vec { + let Ok(paths) = sub_paths(self.path()) else { + return vec![]; + }; + paths + .iter() + .filter_map(|path| { + let name = path.file_name()?.display().to_string(); + if !path.is_file() || name == BRICK_CONFIG_FILE { + return None; + } + let content = fs::read_to_string(path).unwrap_or_default(); + + Some(BrickFile::new(name, content)) + }) + .collect() + } +} + +impl TryFrom for Brick { + type Error = anyhow::Error; + + fn try_from(value: PathBuf) -> Result { + let config_file = value.join(BRICK_CONFIG_FILE); + if !config_file.exists() { + debug!( + "Brick config file not found at '{:?}'", + config_file.display() + ); + let name = value + .as_path() + .file_name() + .ok_or_else(|| anyhow!("Could not read brick dir name!"))?; + return Ok(Brick::new(name.display().to_string(), value)); + } + debug!("Creating Brick from config file"); + let config: BrickConfig = + toml::from_str(fs::read_to_string(config_file)?.as_str())?; + Ok(Brick::new_with_config(config, value)) + } +} + +/// Get all bricks in a directory +pub fn bricks_in_dir(dir: &Path) -> Vec { + debug!("{:#?}", sub_dirs(dir)); + let Ok(dirs) = sub_dirs(dir) else { + return vec![]; + }; + dirs.iter() + .filter_map(|dir| match Brick::try_from(dir.clone()) { + Ok(brick) => Some(brick), + Err(error) => { + warn!( + "Failed to create brick at '{}'. Error: {}", + dir.display(), + error + ); + None + } + }) + .collect::>() +} diff --git a/crane_bricks/src/context.rs b/crane_bricks/src/context.rs new file mode 100644 index 0000000..b0770a0 --- /dev/null +++ b/crane_bricks/src/context.rs @@ -0,0 +1,9 @@ +pub struct ActionContext { + pub dry_run: bool, +} + +impl ActionContext { + pub fn new(dry_run: bool) -> Self { + Self { dry_run } + } +} diff --git a/crane_bricks/src/file_utils.rs b/crane_bricks/src/file_utils.rs new file mode 100644 index 0000000..afdaf90 --- /dev/null +++ b/crane_bricks/src/file_utils.rs @@ -0,0 +1,86 @@ +use std::{ + fs::{self, File}, + io::{self, Write}, + path::{Path, PathBuf}, +}; + +use anyhow::anyhow; + +use crate::context::ActionContext; + +pub fn sub_dirs(dir: &Path) -> anyhow::Result> { + Ok(sub_paths(dir)? + .into_iter() + .filter(|path| path.is_dir()) + .collect::>()) +} + +/// Get a vec of all files and folders in the given dir if valid +pub fn sub_paths(dir: &Path) -> anyhow::Result> { + let dir = PathBuf::from(shellexpand::tilde(&dir.display().to_string()).to_string()); + if !dir.exists() || !dir.is_dir() { + return Err(anyhow!("Target does not exist or not a directory")); + } + let dirs = dir.read_dir()?; + Ok(dirs + .filter_map(|entry_res| Some(entry_res.ok()?.path())) + .collect()) +} + +pub fn file_create_new( + ctx: &ActionContext, + path: &Path, + content: Option, +) -> anyhow::Result<()> { + if !ctx.dry_run { + debug!("Creating new file '{:?}'", path); + let mut file = File::create_new(path)?; + file.write_all(content.unwrap_or_default().as_bytes())?; + } + Ok(()) +} + +pub fn file_read_content(ctx: &ActionContext, path: &Path) -> anyhow::Result { + if ctx.dry_run && !path.exists() { + return Ok(String::new()); + } + if !path.exists() { + return Err(anyhow::Error::new(io::Error::new( + io::ErrorKind::NotFound, + "Target file not found", + ))); + } + debug!("Reading content of file"); + Ok(fs::read_to_string(path).unwrap_or_default()) +} + +pub fn file_replace_content( + ctx: &ActionContext, + path: &Path, + content: &String, +) -> anyhow::Result<()> { + debug!("Replacing contents of '{:?}'", path.display()); + if ctx.dry_run { + return Ok(()); + } + let mut file = File::options() + .write(true) + .create(true) + .truncate(true) + .open(path)?; + file.write_all(content.as_bytes())?; + Ok(()) +} + +pub fn file_append_content( + ctx: &ActionContext, + path: &Path, + content: &String, +) -> anyhow::Result<()> { + if ctx.dry_run { + return Ok(()); + } + let mut file = File::options().append(true).create(true).open(path)?; + file.write_all(content.as_bytes())?; + Ok(()) +} diff --git a/crane_bricks/src/lib.rs b/crane_bricks/src/lib.rs new file mode 100644 index 0000000..df5eede --- /dev/null +++ b/crane_bricks/src/lib.rs @@ -0,0 +1,7 @@ +#[macro_use] +extern crate log; + +pub mod actions; +pub mod brick; +pub mod context; +pub mod file_utils; diff --git a/crane_bricks/tests/bricks/insert_no_config/TEST_B b/crane_bricks/tests/bricks/insert_no_config/TEST_B new file mode 100644 index 0000000..5e1c309 --- /dev/null +++ b/crane_bricks/tests/bricks/insert_no_config/TEST_B @@ -0,0 +1 @@ +Hello World \ No newline at end of file diff --git a/crane_bricks/tests/bricks/insert_with_config/TEST_A b/crane_bricks/tests/bricks/insert_with_config/TEST_A new file mode 100644 index 0000000..3b12464 --- /dev/null +++ b/crane_bricks/tests/bricks/insert_with_config/TEST_A @@ -0,0 +1 @@ +TEST \ No newline at end of file diff --git a/crane_bricks/tests/bricks/insert_with_config/brick.toml b/crane_bricks/tests/bricks/insert_with_config/brick.toml new file mode 100644 index 0000000..7aee887 --- /dev/null +++ b/crane_bricks/tests/bricks/insert_with_config/brick.toml @@ -0,0 +1,5 @@ +name = "test" + +[[actions]] +action = "insert_file" +if_file_exists = "replace" diff --git a/crane_bricks/tests/bricks/modify_append/brick.toml b/crane_bricks/tests/bricks/modify_append/brick.toml new file mode 100644 index 0000000..774975c --- /dev/null +++ b/crane_bricks/tests/bricks/modify_append/brick.toml @@ -0,0 +1,8 @@ +name = "test" + +[[actions]] +sources = ["Test.toml"] +action = "modify_file" +type = "append" +content = "\nserde = \"1\"" +selector = "[dependencies]" diff --git a/crane_bricks/tests/bricks/modify_prepend/brick.toml b/crane_bricks/tests/bricks/modify_prepend/brick.toml new file mode 100644 index 0000000..ca71d52 --- /dev/null +++ b/crane_bricks/tests/bricks/modify_prepend/brick.toml @@ -0,0 +1,8 @@ +name = "test" + +[[actions]] +sources = ["Test.toml"] +action = "modify_file" +type = "prepend" +content = "\nserde = \"1\"\n" +selector = "[dependencies]" diff --git a/crane_bricks/tests/bricks/modify_replace/brick.toml b/crane_bricks/tests/bricks/modify_replace/brick.toml new file mode 100644 index 0000000..9543bce --- /dev/null +++ b/crane_bricks/tests/bricks/modify_replace/brick.toml @@ -0,0 +1,8 @@ +name = "test" + +[[actions]] +sources = ["Test.toml"] +action = "modify_file" +type = "replace" +content = "[dev-dependencies]" +selector = "[dependencies]" diff --git a/crane_bricks/tests/bricks/run_command/brick.toml b/crane_bricks/tests/bricks/run_command/brick.toml new file mode 100644 index 0000000..bc5ba76 --- /dev/null +++ b/crane_bricks/tests/bricks/run_command/brick.toml @@ -0,0 +1,5 @@ +name = "test" + +[[actions]] +action = "run_command" +command = "echo hi > test.txt" diff --git a/crane_bricks/tests/common/mod.rs b/crane_bricks/tests/common/mod.rs new file mode 100644 index 0000000..79eb07f --- /dev/null +++ b/crane_bricks/tests/common/mod.rs @@ -0,0 +1,43 @@ +use std::{ + fs::File, + io::{Read, Write}, + path::{Path, PathBuf}, +}; + +pub fn init_logger() { + let _ = env_logger::builder() + // Include all events in tests + .filter_level(log::LevelFilter::max()) + // Ensure events are captured by `cargo test` + .is_test(true) + // Ignore errors initializing the logger if tests race to configure it + .try_init(); +} + +pub fn test_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests") +} + +pub fn brick_dir(brick: &str) -> PathBuf { + test_dir().join("bricks/").join(brick) +} + +/// Add a file from the tests/data dir to the temp dir +pub fn add_test_data(temp: &Path, file: &str) { + let mut data_file = File::options() + .read(true) + .open(test_dir().join("data/").join(file)) + .unwrap(); + let mut content = String::new(); + data_file.read_to_string(&mut content).unwrap(); + + let mut target_file = File::create(temp.join(file)).unwrap(); + target_file.write_all(content.as_bytes()).unwrap(); +} + +pub fn file_content(path: &Path) -> String { + let mut file = File::options().read(true).open(path).unwrap(); + let mut output = String::new(); + file.read_to_string(&mut output).unwrap(); + output +} diff --git a/crane_bricks/tests/data/Test.toml b/crane_bricks/tests/data/Test.toml new file mode 100644 index 0000000..e9b98cc --- /dev/null +++ b/crane_bricks/tests/data/Test.toml @@ -0,0 +1,7 @@ +[package] +name = "test" +version = "0.1.0" +edition = "2024" + +[dependencies] +toml = "0" diff --git a/crane_bricks/tests/integration_test.rs b/crane_bricks/tests/integration_test.rs new file mode 100644 index 0000000..053466f --- /dev/null +++ b/crane_bricks/tests/integration_test.rs @@ -0,0 +1,138 @@ +use std::vec; + +use crane_bricks::{ + actions::{ + Action, + common::Common, + insert_file::{FileExistsAction, InsertFileAction}, + }, + brick::{Brick, BrickConfig}, + context::ActionContext, +}; +use log::debug; + +use common::*; + +mod common; + +#[test] +fn test_test_functions() { + let tmpdir = tempfile::tempdir().unwrap(); + add_test_data(tmpdir.path(), "Test.toml"); + + assert!(tmpdir.path().join("Test.toml").exists()); +} + +#[test] +fn test_actions_parse() { + init_logger(); + + let config = r#" +name = "hi" + +[[actions]] +action = "insert_file" + +"#; + let config_parsed: BrickConfig = toml::from_str(config).unwrap(); + let config: BrickConfig = BrickConfig::new( + String::from("hi"), + vec![Action::InsertFile(InsertFileAction { + common: Common::default(), + if_file_exists: FileExistsAction::Append, + })], + ); + assert_eq!(config_parsed, config); +} + +#[test] +fn test_insert_file() { + init_logger(); + + let brick = Brick::try_from(brick_dir("insert_with_config")).unwrap(); + debug!("{:?}", brick); + + let ctx = ActionContext { dry_run: false }; + let tmpdir = tempfile::tempdir().unwrap(); + brick.execute(&ctx, tmpdir.path()).unwrap(); + assert!(tmpdir.path().join("TEST_A").exists()); + assert!(!tmpdir.path().join("TEST_B").exists()); + assert!(!tmpdir.path().join("brick.toml").exists()); +} + +#[test] +fn test_without_config() { + init_logger(); + + let brick = Brick::try_from(brick_dir("insert_no_config")).unwrap(); + debug!("{:?}", brick); + + assert_eq!(1, brick.config().actions().len()); + + let ctx = ActionContext { dry_run: false }; + let tmpdir = tempfile::tempdir().unwrap(); + brick.execute(&ctx, tmpdir.path()).unwrap(); + assert!(tmpdir.path().join("TEST_B").exists()); +} + +#[test] +fn test_modify_append() { + init_logger(); + + let brick = Brick::try_from(brick_dir("modify_append")).unwrap(); + + let tmpdir = tempfile::tempdir().unwrap(); + add_test_data(tmpdir.path(), "Test.toml"); + let ctx = ActionContext { dry_run: false }; + + brick.execute(&ctx, tmpdir.path()).unwrap(); + let res_content = file_content(&tmpdir.path().join("Test.toml")); + debug!("{}", res_content); + assert!(res_content.contains("[dependencies]\nserde = \"1\"\n")) +} + +#[test] +fn test_modify_prepend() { + init_logger(); + + let brick = Brick::try_from(brick_dir("modify_prepend")).unwrap(); + + let tmpdir = tempfile::tempdir().unwrap(); + add_test_data(tmpdir.path(), "Test.toml"); + let ctx = ActionContext { dry_run: false }; + + brick.execute(&ctx, tmpdir.path()).unwrap(); + let res_content = file_content(&tmpdir.path().join("Test.toml")); + debug!("{}", res_content); + assert!(res_content.contains("serde = \"1\"\n[dependencies]")) +} + +#[test] +fn test_modify_replace() { + init_logger(); + + let brick = Brick::try_from(brick_dir("modify_replace")).unwrap(); + + let tmpdir = tempfile::tempdir().unwrap(); + add_test_data(tmpdir.path(), "Test.toml"); + let ctx = ActionContext { dry_run: false }; + + brick.execute(&ctx, tmpdir.path()).unwrap(); + let res_content = file_content(&tmpdir.path().join("Test.toml")); + debug!("{}", res_content); + assert!(!res_content.contains("[dependencies]")); + assert!(res_content.contains("[dev-dependencies]")); +} + +#[test] +fn test_command() { + init_logger(); + + let brick = Brick::try_from(brick_dir("run_command")).unwrap(); + + let tmpdir = tempfile::tempdir().unwrap(); + let ctx = ActionContext { dry_run: false }; + + brick.execute(&ctx, tmpdir.path()).unwrap(); + assert!(tmpdir.path().join("test.txt").exists()); +} diff --git a/example/bricks/serde/brick.toml b/example/bricks/serde/brick.toml new file mode 100644 index 0000000..768563e --- /dev/null +++ b/example/bricks/serde/brick.toml @@ -0,0 +1,20 @@ +name = "serde" + +[[actions]] +action = "modify_file" + +# The content that will be inserted into the file. +content = "a" # or file (prefix path with file:) + +# Where the modification should happen +selector = "MIT license" # either text or regex (prefix with re:) + +# Modify can be type "append" (default), "prepend" or "replace": +# append text inside file +type = "replace" + + +# Specify which files this action applies to +sources = [ + "LICENSE" +] \ No newline at end of file diff --git a/example/config.toml b/example/config.toml index e69de29..d8cef00 100644 --- a/example/config.toml +++ b/example/config.toml @@ -0,0 +1,6 @@ + +brick_dirs = ["./foo/other_bricks/", "./bricks"] + +[[alias]] +name = "rust" +bricks = ["mit", "serde"] diff --git a/example/foo/other_bricks/smile/smile.txt b/example/foo/other_bricks/smile/smile.txt new file mode 100644 index 0000000..93c710d --- /dev/null +++ b/example/foo/other_bricks/smile/smile.txt @@ -0,0 +1 @@ +:D \ No newline at end of file diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..8518ab0 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +max_width = 90 \ No newline at end of file diff --git a/src/bricks.rs b/src/bricks.rs deleted file mode 100644 index 2d87656..0000000 --- a/src/bricks.rs +++ /dev/null @@ -1,105 +0,0 @@ -use std::{fs, path::PathBuf}; - -use anyhow::anyhow; -use serde::{Deserialize, Serialize}; - -use crate::{ - files::BrickFile, - utils::{sub_dirs, sub_paths}, -}; - -const BRICK_CONFIG_FILE: &'static str = "brick.toml"; - -#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default)] -pub enum FileAction { - #[default] - Replace, - Append, - // Regex { - // regex: String, - // position: After | Replace | Before, - // } -} - -#[derive(Serialize, Deserialize, Debug, Clone, Default)] -pub struct BrickConfig { - pub name: String, - #[serde(default)] - pub action: FileAction, - // pub requires: Vec -} - -#[derive(Debug, Clone)] -pub struct Brick { - config: BrickConfig, - path: PathBuf, -} - -impl Brick { - pub fn new(name: String, path: PathBuf) -> Self { - Brick { - config: BrickConfig { - name, - ..BrickConfig::default() - }, - path, - } - } - - pub fn name(&self) -> &str { - &self.config.name - } - - pub fn path(&self) -> &PathBuf { - &self.path - } - - pub fn action(&self) -> &FileAction { - &self.config.action - } - - /// Returns a list of all files that - pub fn files(&self) -> Vec { - let Ok(paths) = sub_paths(&self.path()) else { - return vec![]; - }; - paths - .iter() - .filter_map(|path| { - let name = path.file_name()?.display().to_string(); - if !path.is_file() || name == BRICK_CONFIG_FILE { - return None; - } - let content = fs::read_to_string(path).unwrap_or_default(); - - Some(BrickFile::new(name, content, self.action().clone())) - }) - .collect() - } -} - -impl TryFrom for Brick { - type Error = anyhow::Error; - - fn try_from(value: PathBuf) -> Result { - let config_file = value.join(BRICK_CONFIG_FILE); - if !config_file.exists() { - let name = value - .as_path() - .file_name() - .ok_or_else(|| anyhow!("Could not read brick dir name!"))?; - return Ok(Brick::new(name.display().to_string(), value)); - } - let config: BrickConfig = toml::from_str(fs::read_to_string(config_file)?.as_str())?; - Ok(Brick::new(config.name, value)) - } -} - -pub fn bricks(dir: &PathBuf) -> Vec { - let Ok(dirs) = sub_dirs(dir) else { - return vec![]; - }; - dirs.iter() - .filter_map(|dir| Brick::try_from(dir.clone()).ok()) - .collect::>() -} diff --git a/src/cmd/add.rs b/src/cmd/add.rs deleted file mode 100644 index 16145b9..0000000 --- a/src/cmd/add.rs +++ /dev/null @@ -1,97 +0,0 @@ -use std::{env, path::PathBuf}; - -use fuzzy_matcher::{FuzzyMatcher, skim::SkimMatcherV2}; -use log::{debug, error, info, warn}; - -use crate::{ - bricks::{Brick, bricks}, - cmd::{Add, Run}, - config::CraneConfig, -}; - -impl Run for Add { - fn run(&self) { - let config = CraneConfig::new(); - let brick_dirs = if self.brick_dirs.len() > 0 { - &self.brick_dirs - } else { - &config.brick_dirs().to_vec() - }; - debug!( - "Checking brick dirs:\n* {}", - brick_dirs - .iter() - .map(|path| path.display().to_string()) - .collect::>() - .join("\n* ") - ); - - let target_dir = match &self.target_dir { - Some(dir) => dir, - None => &env::current_dir().unwrap(), - }; - - let bricks: Vec = brick_dirs.iter().map(|dir| bricks(dir)).flatten().collect(); - - debug!( - "Found bricks:\n* {}", - bricks - .iter() - .map(|brick| brick.name().to_string()) - .collect::>() - .join("\n* ") - ); - - let matcher = SkimMatcherV2::default(); - - for brick_query in &self.bricks { - let mut matches: Vec<(Brick, i64)> = Vec::new(); - let mut highest_score: i64 = 0; - for brick in &bricks { - if let Some(score) = matcher.fuzzy_match(brick.name(), brick_query.as_str()) { - if score >= highest_score { - matches.push((brick.clone(), score)); - highest_score = score; - } - } - } - if matches.len() == 1 { - add_brick( - matches.first().unwrap().0.clone(), - &target_dir, - self.dry_run, - ); - } else if matches.len() > 1 { - multiple_matches_found(brick_query.to_string(), matches); - } else { - no_matches_found(brick_query.to_string()); - } - } - } -} - -fn add_brick(brick: Brick, target_dir: &PathBuf, dry_run: bool) { - info!("Adding brick '{}'", brick.name()); - for file in brick.files() { - match file.create(target_dir.clone(), dry_run) { - Ok(_) => {} - Err(err) => error!("{}", err), - } - } -} - -fn no_matches_found(query: String) { - error!("No possible bricks found for '{}'", query); -} - -fn multiple_matches_found(query: String, matches: Vec<(Brick, i64)>) { - warn!( - "Multiple possible bricks found for '{}'\n* {}", - query, - matches - .iter() - .map(|(brick, score)| format!("{} ({})", brick.name(), score)) - .collect::>() - .join("\n* ") - ); -} diff --git a/src/cmd/list.rs b/src/cmd/list.rs deleted file mode 100644 index 7971334..0000000 --- a/src/cmd/list.rs +++ /dev/null @@ -1,18 +0,0 @@ -use log::info; - -use crate::{ - bricks::bricks, - cmd::{List, Run}, -}; - -impl Run for List { - fn run(&self) { - let Some(path) = self.brick_dirs.clone() else { - return; - }; - for brick in bricks(&path) { - info!("{:?}", brick); - info!("{:#?}", brick.files()); - } - } -} diff --git a/src/config.rs b/src/config.rs deleted file mode 100644 index d498b25..0000000 --- a/src/config.rs +++ /dev/null @@ -1,58 +0,0 @@ -use std::{env, fs, path::PathBuf}; - -use serde::{Deserialize, Serialize}; - -const ENV_KEY_CONFIG_DIR: &'static str = "CRANE_CONFIG_DIR"; - -fn config_path_from_env() -> anyhow::Result { - Ok(PathBuf::try_from(env::var(ENV_KEY_CONFIG_DIR)?)?) -} - -pub fn config_dir() -> PathBuf { - match config_path_from_env() { - Ok(path) => path, - Err(_) => PathBuf::from("~/.config/crane"), - } -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -pub struct CraneConfig { - brick_dirs: Vec, -} - -impl CraneConfig { - pub fn new() -> Self { - let config_file = config_dir().join("config.toml"); - if config_file.exists() { - if let Ok(parsed_config) = toml::from_str::( - fs::read_to_string(config_file).unwrap_or_default().as_str(), - ) { - return parsed_config; - } - } - Self { - brick_dirs: vec![config_dir().join("bricks")], - } - } - - pub fn brick_dirs(&self) -> &[PathBuf] { - &self.brick_dirs - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_config_dir_from_env() { - // should be safe as long as it is only one test (of this kind) - unsafe { - env::set_var(ENV_KEY_CONFIG_DIR, "~/.crane"); - }; - assert_eq!( - format!("{}", config_dir().display()), - String::from("~/.crane") - ) - } -} diff --git a/src/files.rs b/src/files.rs deleted file mode 100644 index bcfbced..0000000 --- a/src/files.rs +++ /dev/null @@ -1,119 +0,0 @@ -use std::{ - fs::{self, File}, - io::{self, Read, Write}, - path::{Path, PathBuf}, -}; - -use anyhow::Ok; -use log::{debug, info}; - -use crate::bricks::FileAction; - -/// The object that contains all information except the target location -/// to execute a brick -#[derive(Debug, Clone)] -pub struct BrickFile { - name: String, - content: String, - action: FileAction, -} - -struct FileUtility { - dry_run: bool, -} - -impl FileUtility { - pub fn new(dry_run: bool) -> Self { - Self { dry_run } - } - - pub fn create_new(&self, path: &Path) -> anyhow::Result<()> { - if !self.dry_run { - File::create_new(path)?; - } - Ok(()) - } - - pub fn read_content(&self, path: &Path) -> anyhow::Result { - if self.dry_run && !path.exists() { - return Ok(String::new()); - } - if !path.exists() { - return Err(anyhow::Error::new(io::Error::new( - io::ErrorKind::NotFound, - "Target file not found", - ))); - } - debug!("Reading content of file"); - Ok(fs::read_to_string(path).unwrap_or_default()) - } - - pub fn replace_content(&self, path: &Path, content: &String) -> anyhow::Result<()> { - debug!("Replacing contents of '{:?}'", path.display()); - if self.dry_run { - return Ok(()); - } - let mut file = File::options().write(true).create(true).open(&path)?; - file.write(content.as_bytes())?; - Ok(()) - } - pub fn append_content(&self, path: &Path, content: &String) -> anyhow::Result<()> { - if self.dry_run { - return Ok(()); - } - let mut file = File::options().append(true).create(true).open(&path)?; - file.write(content.as_bytes())?; - Ok(()) - } -} - -impl BrickFile { - pub fn new(name: String, content: String, action: FileAction) -> Self { - Self { - name, - content, - action, - } - } - - /// Check if this file can be executed without the target - /// file already existing - fn needs_existing_file(&self) -> bool { - match &self.action { - FileAction::Replace | FileAction::Append => false, - } - } - - fn parse_content(&self) -> &String { - &self.content - } - - // TODO: Create with some kind of context? - pub fn create(&self, path: PathBuf, dry_run: bool) -> anyhow::Result<()> { - let file_util = FileUtility::new(dry_run); - let target_file = path.join(&self.name); - if !target_file.exists() { - if self.needs_existing_file() { - return Err(anyhow::Error::msg("File does not exist")); - } - info!( - "Target file does not exist, creating it at '{:?}'", - target_file - ); - file_util.create_new(&target_file).unwrap(); - } - - // let content = file_util.read_content(&path).unwrap_or_default(); - - match self.action { - FileAction::Replace => { - file_util.replace_content(&target_file, &self.parse_content())?; - } - FileAction::Append => { - file_util.append_content(&target_file, &self.parse_content())?; - } - }; - - Ok(()) - } -} diff --git a/src/main.rs b/src/main.rs deleted file mode 100644 index 1080d50..0000000 --- a/src/main.rs +++ /dev/null @@ -1,19 +0,0 @@ -use clap::Parser; - -use crate::cmd::{CraneCli, Run}; - -mod bricks; -mod cmd; -mod config; -mod files; -mod utils; - -fn main() { - let cli = CraneCli::parse(); - - env_logger::Builder::new() - .filter_level(cli.verbose.log_level_filter()) - .init(); - - cli.run(); -} diff --git a/src/utils.rs b/src/utils.rs deleted file mode 100644 index 7f5bb0c..0000000 --- a/src/utils.rs +++ /dev/null @@ -1,24 +0,0 @@ -use std::path::{Path, PathBuf}; - -use anyhow::anyhow; - -pub fn sub_dirs(dir: &Path) -> anyhow::Result> { - Ok( - sub_paths(dir)? - .into_iter() - .filter(|path| path.is_dir()) - .collect::>() - ) -} - -/// Get a vec of all files and folders in the given dir if valid -pub fn sub_paths(dir: &Path) -> anyhow::Result> { - if !dir.exists() || !dir.is_dir() { - return Err(anyhow!("Target does not exist or not a directory")); - } - let dirs = dir.read_dir()?; - Ok( - dirs.filter_map(|entry_res| Some(entry_res.ok()?.path())) - .collect(), - ) -}