diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 9d1bc06..4c4bd85 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,7 +1,3 @@ -## Description - - - ## Type of Change @@ -13,73 +9,4 @@ - [ ] Performance improvement - [ ] Code refactoring (no functional changes) -## Related Issues - - - -Fixes # - -## Changes Made - - - -- -- -- - -## Testing - - - -- [ ] All existing tests pass -- [ ] New tests added for new functionality -- [ ] Manual testing performed -- [ ] Tested on multiple platforms (Linux/macOS/Windows) - -### Test Commands - -```bash -# Commands used to test the changes -cargo test --all-features -cargo fmt --all -- --check -cargo clippy --all-targets --all-features -- -D warnings -``` - -## Performance Impact - - - -- [ ] No performance impact -- [ ] Performance improved -- [ ] Performance impact acceptable for the feature -- [ ] Performance benchmarks included - -## Breaking Changes - - - -## Documentation - -- [ ] Documentation updated (if applicable) -- [ ] README updated (if applicable) -- [ ] Help text updated (if applicable) -- [ ] Examples updated (if applicable) - -## Checklist - -- [ ] Code follows the project's style guidelines -- [ ] Self-review of the code completed -- [ ] Code is commented, particularly in hard-to-understand areas -- [ ] Corresponding changes to documentation made -- [ ] Changes generate no new warnings -- [ ] New tests added that prove the fix is effective or the feature works -- [ ] All new and existing tests pass locally -- [ ] Any dependent changes have been merged - -## Screenshots - - - -## Additional Notes - - \ No newline at end of file +## Description diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69c745e..93ef0df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [ main ] + branches: [main] pull_request: - branches: [ main ] + branches: [main] env: CARGO_TERM_COLOR: always @@ -23,44 +23,44 @@ jobs: experimental: true steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@master - with: - toolchain: ${{ matrix.rust }} - components: rustfmt, clippy - - - name: Cache dependencies - uses: actions/cache@v3 - with: - path: | - ~/.cargo/registry - ~/.cargo/git - target - key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} - - - name: Run clippy - run: cargo clippy --all-features - - - name: Run tests - run: cargo test --all-features + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@master + with: + toolchain: ${{ matrix.rust }} + components: rustfmt, clippy + + - name: Cache dependencies + uses: actions/cache@v3 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Run clippy + run: cargo clippy --all-features + + - name: Run tests + run: cargo test --all-features security: name: Security Audit runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + - name: Checkout code + uses: actions/checkout@v4 - - name: Install cargo-audit - run: cargo install cargo-audit + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable - - name: Run security audit - run: cargo audit + - name: Install cargo-audit + run: cargo install cargo-audit + + - name: Run security audit + run: cargo audit build: name: Build Release @@ -76,21 +76,21 @@ jobs: target: x86_64-apple-darwin steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} - - - name: Build release - run: cargo build --release --target ${{ matrix.target }} - - - name: Upload binary - uses: actions/upload-artifact@v4 - with: - name: bradar-${{ matrix.target }} - path: | - target/${{ matrix.target }}/release/bradar* - !target/${{ matrix.target }}/release/bradar.d \ No newline at end of file + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + + - name: Build release + run: cargo build --release --target ${{ matrix.target }} + + - name: Upload binary + uses: actions/upload-artifact@v4 + with: + name: bradar-${{ matrix.target }} + path: | + target/${{ matrix.target }}/release/bradar* + !target/${{ matrix.target }}/release/bradar.d diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c64775c..fb9aed7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,7 +3,7 @@ name: Release on: push: tags: - - 'v*' + - "v*" env: CARGO_TERM_COLOR: always @@ -40,50 +40,50 @@ jobs: binary_name: bradar steps: - - name: Checkout code - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@v4 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - targets: ${{ matrix.target }} + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} - - name: Install cross (for Linux ARM64) - if: matrix.target == 'aarch64-unknown-linux-gnu' - run: cargo install cross + - name: Install cross (for Linux ARM64) + if: matrix.target == 'aarch64-unknown-linux-gnu' + run: cargo install cross - - name: Build release - run: | - if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then - cross build --release --target ${{ matrix.target }} - else - cargo build --release --target ${{ matrix.target }} - fi - shell: bash + - name: Build release + run: | + if [ "${{ matrix.target }}" = "aarch64-unknown-linux-gnu" ]; then + cross build --release --target ${{ matrix.target }} + else + cargo build --release --target ${{ matrix.target }} + fi + shell: bash - - name: Rename binary for release - run: | - mkdir -p release-assets - cp target/${{ matrix.target }}/release/${{ matrix.binary_name }} release-assets/${{ matrix.asset_name }} - shell: bash + - name: Rename binary for release + run: | + mkdir -p release-assets + cp target/${{ matrix.target }}/release/${{ matrix.binary_name }} release-assets/${{ matrix.asset_name }} + shell: bash - - name: Upload Release Asset - uses: softprops/action-gh-release@v2 - with: - files: release-assets/${{ matrix.asset_name }} + - name: Upload Release Asset + uses: softprops/action-gh-release@v2 + with: + files: release-assets/${{ matrix.asset_name }} publish-crate: name: Publish to crates.io needs: build-release runs-on: ubuntu-latest steps: - - name: Checkout code - uses: actions/checkout@v4 + - name: Checkout code + uses: actions/checkout@v4 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable - - name: Publish to crates.io - run: cargo publish - env: - CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} \ No newline at end of file + - name: Publish to crates.io + run: cargo publish + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} diff --git a/.gitignore b/.gitignore index 09e7d70..d2c2404 100644 --- a/.gitignore +++ b/.gitignore @@ -10,5 +10,5 @@ pkg/ node_modules/ wasm/node_modules/ -server/.wrangler/ -server/node_modules/ +worker/.wrangler/ +worker/node_modules/ diff --git a/Cargo.lock b/Cargo.lock index 73fe0e8..46c9e33 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -82,6 +82,17 @@ version = "1.0.98" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e16d2d3311acee920a9eb8d33b8cbc1787ce4a264e85f964c2404b969bdcd487" +[[package]] +name = "async-trait" +version = "0.1.88" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "backtrace" version = "0.3.75" @@ -123,9 +134,10 @@ checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" [[package]] name = "bytes-radar" -version = "0.6.0" +version = "1.0.0" dependencies = [ "anyhow", + "async-trait", "bytes", "clap", "colored", @@ -141,9 +153,11 @@ dependencies = [ "serde-wasm-bindgen", "serde-xml-rs", "serde_json", + "serde_yaml", "tar", "thiserror 2.0.12", "tokio", + "toml", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -239,6 +253,22 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "crc32fast" version = "1.4.2" @@ -288,6 +318,12 @@ dependencies = [ "log", ] +[[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.13" @@ -298,6 +334,12 @@ dependencies = [ "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 = "filetime" version = "0.2.25" @@ -326,6 +368,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -429,6 +486,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "hashbrown" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" + [[package]] name = "heck" version = "0.5.0" @@ -511,6 +574,22 @@ dependencies = [ "webpki-roots", ] +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.14" @@ -642,6 +721,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "indicatif" version = "0.17.11" @@ -801,6 +890,23 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "native-tls" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "number_prefix" version = "0.4.0" @@ -828,6 +934,50 @@ version = "1.70.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4895175b425cb1f87721b59f0f286c2092bd4af812243672510e1ac53e2e0ad" +[[package]] +name = "openssl" +version = "0.10.73" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" + +[[package]] +name = "openssl-sys" +version = "0.9.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90096e2e47630d78b7d1c20952dc621f957103f8bc2c8359ec81290d75238571" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "percent-encoding" version = "2.3.1" @@ -846,6 +996,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + [[package]] name = "portable-atomic" version = "1.11.1" @@ -1040,9 +1196,11 @@ dependencies = [ "http-body-util", "hyper", "hyper-rustls", + "hyper-tls", "hyper-util", "js-sys", "log", + "native-tls", "percent-encoding", "pin-project-lite", "quinn", @@ -1053,6 +1211,7 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", + "tokio-native-tls", "tokio-rustls", "tokio-util", "tower", @@ -1152,6 +1311,38 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" +[[package]] +name = "schannel" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f29ebaa345f945cec9fbbc532eb307f0fdad8161f281b6369539c8d84876b3d" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49db231d56a190491cb4aeda9527f1ad45345af50b0851622a7adb8c03b01c32" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" version = "1.0.219" @@ -1207,6 +1398,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -1219,6 +1419,19 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "shlex" version = "1.3.0" @@ -1316,6 +1529,19 @@ dependencies = [ "xattr", ] +[[package]] +name = "tempfile" +version = "3.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8a64e3985349f2441a1a9ef0b853f869006c3855f2cda6862a94d26ebb9d6a1" +dependencies = [ + "fastrand", + "getrandom 0.3.3", + "once_cell", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -1411,6 +1637,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.2" @@ -1434,6 +1670,47 @@ dependencies = [ "tokio", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tower" version = "0.5.2" @@ -1516,6 +1793,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "untrusted" version = "0.9.0" @@ -1545,6 +1828,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "want" version = "0.3.1" @@ -1837,6 +2126,15 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "winnow" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74c7b26e3480b707944fc872477815d29a8e429d2f93a1ce000f5fa84a15cbcd" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" diff --git a/Cargo.toml b/Cargo.toml index a450091..0e3edce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "bytes-radar" -version = "0.6.0" +version = "1.0.0" edition = "2021" authors = ["ProgramZmh "] description = "A tool for analyzing code statistics from remote repositories with hyper-fast performance" @@ -23,7 +23,7 @@ exclude = [ [features] default = ["cli"] -cli = ["colored", "env_logger", "clap", "indicatif"] +cli = ["colored", "env_logger", "clap", "indicatif", "serde_yaml", "toml"] worker = ["wasm-bindgen", "web-sys", "js-sys", "wasm-bindgen-futures", "serde-wasm-bindgen"] [lib] @@ -41,12 +41,15 @@ tar = "0.4" flate2 = "1.1" futures-util = "0.3" bytes = "1.5" +async-trait = "0.1" # CLI dependencies colored = { version = "2.2", optional = true } env_logger = { version = "0.11", optional = true } clap = { version = "4.5", features = ["derive"], optional = true } indicatif = { version = "0.17", optional = true } +serde_yaml = { version = "0.9", optional = true } +toml = { version = "0.8", optional = true } # WASM dependencies wasm-bindgen = { version = "0.2", optional = true } @@ -62,7 +65,7 @@ reqwest = { version = "0.12", features = ["stream", "rustls-tls", "json"], defau [target.'cfg(target_arch = "wasm32")'.dependencies] tokio = { version = "1.46", default-features = false, features = ["macros", "rt", "sync"] } -reqwest = { version = "0.12", features = ["stream", "json"], default-features = false } +reqwest = { version = "0.12", features = ["stream", "json", "default-tls"], default-features = false } [profile.release] opt-level = 3 diff --git a/README.md b/README.md index f627bd2..4e15111 100644 --- a/README.md +++ b/README.md @@ -7,57 +7,48 @@ [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button.svg)](https://deploy.workers.cloudflare.com/?url=https://github.com/zmh-program/bytes-radar) -Hyper-fast **CLOC** *(\*count lines of code)* tool for remote repositories. +Hyper-fast **CLOC** _(\*count lines of code)_ tool for remote repositories. ![Banner](docs/banner.jpg) ## Features -- **Asynchronous Repository Processing**: Non-blocking HTTP client with async streaming request processing for efficient remote repository fetching and decompression, optimized for **low memory usage** and **serverless environments** (always `<32MiB` runtime memory usage for large files) -- **Multi-Platform URL Resolution**: Features intelligent URL parsing engine that normalizes different Git hosting platform APIs (GitHub, GitLab, Bitbucket, Codeberg) into unified archive endpoints with branch/commit resolution -- **Streaming Archive Analysis**: Processes tar.gz archives directly in memory using streaming decompression without temporary file extraction, reducing I/O overhead and memory footprint -- **Language Detection Engine**: Implements rule-based file extension and content analysis system supporting 150+ programming languages with configurable pattern matching and statistical computation (use tokei [languages map](https://github.com/XAMPPRocky/tokei/blob/master/languages.json)) -- **Real-time Progress Monitoring**: Features bandwidth-aware progress tracking with download speed calculation, ETA estimation, and adaptive UI rendering for terminal environments -- **Structured Data Serialization**: Provides multiple output format engines (Table, JSON, CSV, XML) with schema validation and type-safe serialization for integration with external tools -- **Authentication Layer**: Implements OAuth token management with secure credential handling for accessing private repositories across different hosting platforms -- **Cross-Platform Binary Distribution**: Supports native compilation targets for Linux, macOS, and Windows with platform-specific optimizations and dependency management +- Efficient remote repository analysis with async streaming and in-memory decompression, optimized for low memory usage (always <12MB runtime mem) +- Unified URL parsing for GitHub, GitLab, Bitbucket, Codeberg, SourceForge, Gitea and Azure DevOps +- Rule-based language detection supporting 150+ programming languages with [Tokei's Language Rules](https://github.com/XAMPPRocky/tokei/blob/master/languages.json) +- Real-time progress tracking with download speed, ETA and adaptive terminal UI +- Multiple output formats (Table, JSON, CSV, XML, YAML, TOML) with schema validation +- OAuth token management for private repository access +- Native binaries for Linux, macOS and Windows +- Experimental parallel processing and streaming analysis -## Installation - -### From Cargo (Recommended) - -```bash -cargo install bytes-radar -``` +## Supported Platforms -### From Releases +| Platform | URL Format | Example | +| ------------------ | ----------------------------------------- | ------------------------------------------- | +| **GitHub** | `user/repo`, `user/repo@branch`, full URL | `microsoft/vscode`, `torvalds/linux@master` | +| **GitLab** | Full URL | `https://gitlab.com/user/repo` | +| **Bitbucket** | Full URL | `https://bitbucket.org/user/repo` | +| **Codeberg** | Full URL | `https://codeberg.org/user/repo` | +| **SourceForge** | Full URL | `https://sourceforge.net/user/repo` | +| **Gitea** | Full URL | `https://gitea.example.com/user/repo` | +| **Azure DevOps** | Full URL | `https://dev.azure.com/org/project` | +| **Direct Archive** | tar.gz, tgz, zip URL | `https://example.com/archive.tar.gz` | -Download the latest binary from [GitHub Releases](https://github.com/zmh-program/bytes-radar/releases) +## Installation -### From Source +Download the latest binary from **[GitHub Releases](https://github.com/zmh-program/bytes-radar/releases)** or install via Cargo: ```bash -git clone https://github.com/zmh-program/bytes-radar.git -cd bytes-radar -cargo build --release +cargo install bytes-radar ``` ## Usage -```bash -bradar [OPTIONS] -``` - -### Examples - -#### Basic Repository Analysis - -Analyze GitHub repositories using shorthand notation: +#### Basic Repo Analysis ```bash -bradar torvalds/linux -bradar microsoft/typescript -bradar rust-lang/cargo +bradar torvalds/linux # or https://github.com/torvalds/linux ``` #### Branch and Commit Targeting @@ -65,9 +56,8 @@ bradar rust-lang/cargo Specify particular branches or commit hashes for analysis: ```bash -bradar microsoft/vscode@main -bradar kubernetes/kubernetes@release-1.28 -bradar rust-lang/rust@abc1234567 +bradar microsoft/vscode@main # or https://github.com/microsoft/vscode/tree/main +bradar kubernetes/kubernetes@release-1.28 # or https://github.com/kubernetes/kubernetes/tree/release-1.28 ``` #### Multi-Platform Repository Support @@ -86,8 +76,6 @@ Generate analysis results in structured data formats: ```bash bradar -f json torvalds/linux -bradar -f csv microsoft/typescript -bradar -f xml rust-lang/cargo ``` #### Private Repository Access @@ -108,130 +96,71 @@ bradar --quiet --no-progress user/repo bradar --timeout 600 --detailed large-org/massive-repo ``` -## Usage Environments - -### CLI - -See the CLI Options section below for command-line usage. - -## Output Formats - -### Table (Default) -```shell -$ bradar torvalds/linux -Analyzing: https://github.com/torvalds/linux -Analysis completed in 123.76s - -================================================================================ - Project linux@main - Total Files 89,639 - Total Lines 40,876,027 - Code Lines 32,848,710 - Comment Lines 2,877,885 - Blank Lines 5,149,432 - Languages 51 - Primary Language C - Code Ratio 80.4% - Documentation 8.8% -================================================================================ - Language Files Lines Code Comments Blanks Share% -================================================================================ - C 35,586 25,268,107 18,782,347 2,836,806 3,648,954 61.8% - C Header 25,845 10,247,647 9,481,722 0 765,925 25.1% - Device Tree 5,789 1,831,396 1,589,630 0 241,766 4.5% - ReStructuredText 3,785 782,387 593,628 0 188,759 1.9% - JSON 961 572,657 572,655 0 2 1.4% - Text 5,100 566,733 499,590 0 67,143 1.4% - YAML 4,862 548,408 458,948 0 89,460 1.3% - GNU Style Assembly 1,343 373,956 326,745 0 47,211 0.9% - Shell 960 189,965 155,974 0 33,991 0.5% - Plain Text 1,298 128,205 105,235 0 22,970 0.3% - Python 293 89,285 69,449 5,770 14,066 0.2% - Makefile 3,115 82,692 57,091 13,109 12,492 0.2% - SVG 82 53,409 53,316 0 93 0.1% - Perl 58 43,986 33,264 4,406 6,316 0.1% - Rust 158 39,561 19,032 16,697 3,832 0.1% - XML 24 22,193 20,971 0 1,222 0.1% - PO File 7 6,711 5,605 0 1,106 0.0% - Happy 10 6,078 5,352 0 726 0.0% - Assembly 11 5,361 4,427 0 934 0.0% - Lex 10 2,996 2,277 347 372 0.0% - AWK 12 2,611 1,777 487 347 0.0% - C++ 7 2,267 1,932 0 335 0.0% - ... -================================================================================ - Total 89,639 40,876,027 32,848,710 2,877,885 5,149,432 100.0% -``` - -### JSON Output -```json -{ - "project_name": "linux@master", - "summary": { - "total_files": 75823, - "total_lines": 28691744, - "code_lines": 22453891, - "comment_lines": 3891234, - "blank_lines": 2346619 - }, - "language_statistics": [...] -} -``` - -## Supported Platforms - -| Platform | URL Format | Example | -|----------|------------|---------| -| **GitHub** | `user/repo` or full URL | `torvalds/linux` | -| **GitLab** | Full URL | `https://gitlab.com/user/repo` | -| **Bitbucket** | Full URL | `https://bitbucket.org/user/repo` | -| **Codeberg** | Full URL | `https://codeberg.org/user/repo` | -| **Direct** | tar.gz URL | `https://example.com/file.tar.gz` | - ## CLI Options ```bash bradar [OPTIONS] ARGUMENTS: - URL to analyze: user/repo, user/repo@branch, or full URL + Repository URL to analyze (user/repo, user/repo@branch, or full URL) OPTIONS: - -f, --format Output format [table|json|csv|xml] - --detailed Show detailed file-by-file statistics - -d, --debug Enable debug output - --token GitHub token for private repositories - --timeout Request timeout in seconds [default: 300] - --allow-insecure Allow insecure HTTP connections - --no-progress Disable progress bar - --quiet Quiet mode - minimal output - -h, --help Print help - -V, --version Print version + # Output Options + -f, --format Output format [table|json|csv|xml|yaml|toml] + --detailed Show detailed file-by-file statistics + -q, --quiet Quiet mode - suppress progress and minimize output + --no-progress Disable progress bar + --no-color Disable colored output + + # Authentication + --token Authentication token for private repositories + + # Network Options + --timeout Request timeout in seconds [default: 300] + --allow-insecure Allow insecure HTTPS connections + --user-agent Custom User-Agent string + --retry-count Number of retry attempts for failed requests [default: 3] + + # Filtering Options + --aggressive-filter Enable aggressive filtering for maximum performance + --max-file-size Maximum file size to process in KB [default: 1024] + --min-file-size Minimum file size to process in bytes [default: 1] + --include-tests Include test directories in analysis + --include-docs Include documentation directories in analysis + --include-hidden Include hidden files and directories + --exclude-pattern Exclude files matching this pattern (glob) + --include-pattern Only include files matching this pattern (glob) + + # Language Options + --language Only analyze files of specific language + --exclude-language Exclude specific language from analysis + + # Analysis Options + --ignore-whitespace Ignore whitespace-only lines in code analysis + --count-generated Include generated files in analysis + --max-line-length Maximum line length to consider (0 = unlimited) [default: 0] + + # Debug and Logging + -d, --debug Enable debug output + --trace Enable trace-level logging + --log-file Write logs to file + + # Advanced Options + --threads Number of worker threads (0 = auto) [default: 0] + --memory-limit Memory limit in MB (0 = unlimited) [default: 0] + --cache-dir Directory for caching downloaded files + --no-cache Disable caching of downloaded files + + # Experimental Features + --experimental-parallel Enable experimental parallel processing + --experimental-streaming Enable experimental streaming analysis + + # General + -v, --version Print version + -h, --help Print help ``` -## Contributing - -We welcome contributions! Please see [CONTRIBUTING.md](docs/CONTRIBUTING.md) for guidelines. - -### Development Setup - -```bash -# Clone the repository -git clone https://github.com/zmh-program/bytes-radar.git -cd bytes-radar - -# Install dependencies -cargo build - -# Run tests -cargo test --all-features - -# Format code -cargo fmt - -# Lint code -cargo clippy --all-targets --all-features -``` +See [CLI USAGE GUIDE](docs/CLI_USAGE.md) for more detailed usage examples and advanced configurations. ## Deployment @@ -240,12 +169,10 @@ cargo clippy --all-targets --all-features [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button.svg)](https://deploy.workers.cloudflare.com/?url=https://github.com/zmh-program/bytes-radar) > [!TIP] -> The Free Tier of Cloudflare Workers has a **20s request timeout limit** (wall time). Analysis of large repositories may fail due to this limitation. Consider upgrading to Cloudflare Workers Pro or using alternative deployment methods for processing large repositories. +> The Free Tier of Cloudflare Workers has a **20s request timeout limit** (wall time). Analysis of large repositories may fail due to this limitation. Consider upgrading to Cloudflare Workers Pro or using alternative methods for processing large repositories. -For detailed deployment instructions and API documentation, see [DEPLOYMENT.md](docs/DEPLOYMENT.md). +For detailed deployment instructions and API documentation, see [DEPLOYMENT GUIDE](docs/DEPLOYMENT.md). -## Usage Environments - -### CLI +## Contributing -See the CLI Options section below for command-line usage. +We welcome contributions! Please see [CONTRIBUTING GUIDE](docs/CONTRIBUTING.md) for guidelines. diff --git a/docs/CLI_USAGE.md b/docs/CLI_USAGE.md new file mode 100644 index 0000000..3967a6b --- /dev/null +++ b/docs/CLI_USAGE.md @@ -0,0 +1,379 @@ +# Bytes Radar CLI Usage Guide + +`bradar` is a professional command-line tool for analyzing code statistics from remote repositories with hyper-fast performance. + +## Table of Contents + +- [Installation](#installation) +- [Basic Usage](#basic-usage) +- [Supported Platforms](#supported-platforms) +- [URL Formats](#url-formats) +- [Command-Line Options](#command-line-options) +- [Output Formats](#output-formats) +- [Advanced Usage Examples](#advanced-usage-examples) +- [Environment Variables](#environment-variables) +- [Performance Tuning](#performance-tuning) +- [Troubleshooting](#troubleshooting) + +## Installation + +### From Source + +```bash +git clone https://github.com/zmh-program/bytes-radar.git +cd bytes-radar +cargo build --release +``` + +### Using Cargo + +```bash +cargo install bytes-radar +``` + +## Basic Usage + +The simplest way to analyze a repository: + +```bash +bradar user/repo +``` + +This analyzes the default branch of `user/repo` on GitHub and displays results in a human-readable table format. + +## Supported Platforms + +- **GitHub** (github.com, GitHub Enterprise) +- **GitLab** (gitlab.com, self-hosted instances) +- **Bitbucket** (bitbucket.org) +- **Codeberg** (codeberg.org) +- **SourceForge** (sourceforge.net) +- **Gitea** instances +- **Azure DevOps** +- **Direct archive URLs** (tar.gz, tgz, zip) + +## URL Formats + +| Format | Description | Example | +| ------------------ | ---------------------------------- | ------------------------------------ | +| `user/repo` | GitHub repository (default branch) | `microsoft/vscode` | +| `user/repo@branch` | Specific branch | `torvalds/linux@master` | +| `user/repo@commit` | Specific commit hash | `rust-lang/rust@abc123` | +| Full URL | Complete repository URL | `https://github.com/user/repo` | +| Archive URL | Direct archive link | `https://example.com/project.tar.gz` | + +## Command-Line Options + +### Basic Information + +```bash +bradar --help # Show help information +bradar -v # Show version information +``` + +### Repository Analysis + +```bash +bradar [OPTIONS] +``` + +### Output Options + +| Option | Short | Description | Default | +| --------------- | ----- | -------------------------------------------------- | ------- | +| `--format` | `-f` | Output format (table, json, csv, xml, yaml, toml) | `table` | +| `--detailed` | | Show detailed file-by-file statistics | `false` | +| `--quiet` | `-q` | Quiet mode - suppress progress and minimize output | `false` | +| `--no-progress` | | Disable progress bar | `false` | +| `--no-color` | | Disable colored output | `false` | + +### Authentication + +| Option | Description | +| --------- | --------------------------------------------- | +| `--token` | Authentication token for private repositories | + +### Network Options + +| Option | Description | Default | +| ------------------ | -------------------------------------------- | ------- | +| `--timeout` | Request timeout in seconds | `300` | +| `--allow-insecure` | Allow insecure HTTPS connections | `false` | +| `--user-agent` | Custom User-Agent string | | +| `--retry-count` | Number of retry attempts for failed requests | `3` | + +### Filtering Options + +| Option | Description | Default | +| --------------------- | --------------------------------------------------- | ------- | +| `--aggressive-filter` | Enable aggressive filtering for maximum performance | `false` | +| `--max-file-size` | Maximum file size to process in KB | `1024` | +| `--min-file-size` | Minimum file size to process in bytes | `1` | +| `--include-tests` | Include test directories in analysis | `false` | +| `--include-docs` | Include documentation directories in analysis | `false` | +| `--include-hidden` | Include hidden files and directories | `false` | +| `--exclude-pattern` | Exclude files matching this pattern (glob) | | +| `--include-pattern` | Only include files matching this pattern (glob) | | + +### Language Options + +| Option | Description | +| -------------------- | --------------------------------------- | +| `--language` | Only analyze files of specific language | +| `--exclude-language` | Exclude specific language from analysis | + +### Analysis Options + +| Option | Description | Default | +| --------------------- | ----------------------------------------------- | ------- | +| `--ignore-whitespace` | Ignore whitespace-only lines in code analysis | `false` | +| `--count-generated` | Include generated files in analysis | `false` | +| `--max-line-length` | Maximum line length to consider (0 = unlimited) | `0` | + +### Debug and Logging + +| Option | Short | Description | +| ------------ | ----- | -------------------------- | +| `--debug` | `-d` | Enable debug output | +| `--trace` | | Enable trace-level logging | +| `--log-file` | | Write logs to file | + +### Advanced Options + +| Option | Description | Default | +| ---------------- | -------------------------------------- | ------- | +| `--threads` | Number of worker threads (0 = auto) | `0` | +| `--memory-limit` | Memory limit in MB (0 = unlimited) | `0` | +| `--cache-dir` | Directory for caching downloaded files | | +| `--no-cache` | Disable caching of downloaded files | `false` | + +### Experimental Features + +| Option | Description | +| -------------------------- | --------------------------------------- | +| `--experimental-parallel` | Enable experimental parallel processing | +| `--experimental-streaming` | Enable experimental streaming analysis | + +## Output Formats + +### Table (Default) + +Human-readable table format with colored output and progress indicators. + +```bash +bradar microsoft/vscode +``` + +### JSON + +Machine-readable JSON format for integration with other tools. + +```bash +bradar --format json microsoft/vscode +``` + +### CSV + +Comma-separated values format for spreadsheet analysis. + +```bash +bradar --format csv microsoft/vscode +``` + +### XML + +XML format for structured data processing. + +```bash +bradar --format xml microsoft/vscode +``` + +### YAML + +YAML format for configuration files and human-readable structured data. + +```bash +bradar --format yaml microsoft/vscode +``` + +### TOML + +TOML format for configuration management. + +```bash +bradar --format toml microsoft/vscode +``` + +## Advanced Usage Examples + +### Analyzing Private Repositories + +```bash +# Using token parameter +bradar --token ghp_xxxxxxxxxxxxxxxxxxxx private-org/private-repo + +# Using environment variable +export BRADAR_TOKEN=ghp_xxxxxxxxxxxxxxxxxxxx +bradar private-org/private-repo +``` + +### Performance Optimization + +```bash +# For large repositories - aggressive filtering +bradar --aggressive-filter --max-file-size 2048 torvalds/linux + +# Custom thread count and memory limit +bradar --threads 8 --memory-limit 4096 large-org/huge-repo + +# Enable experimental features for better performance +bradar --experimental-parallel --experimental-streaming big-repo +``` + +### Detailed Analysis with Filtering + +```bash +# Include tests and documentation +bradar --include-tests --include-docs --detailed microsoft/typescript + +# Analyze only specific language +bradar --language rust rust-lang/rust + +# Exclude multiple languages +bradar --exclude-language javascript --exclude-language css web-project + +# Custom file patterns +bradar --include-pattern "*.rs" --exclude-pattern "*test*" rust-project +``` + +### Output Customization + +```bash +# Quiet JSON output for scripting +bradar --quiet --format json user/repo | jq '.global_metrics' + +# Detailed CSV for analysis +bradar --format csv --detailed user/repo > analysis.csv + +# Debug mode with log file +bradar --debug --log-file analysis.log --trace user/repo +``` + +### Network Configuration + +```bash +# Custom timeout and retry settings +bradar --timeout 600 --retry-count 5 slow-server/repo + +# Custom User-Agent +bradar --user-agent "MyAnalyzer/1.0" user/repo + +# Allow insecure connections +bradar --allow-insecure https://insecure-server.com/repo.tar.gz +``` + +## Environment Variables + +| Variable | Description | Example | +| -------------- | ---------------------------- | -------------------------- | +| `BRADAR_TOKEN` | Default authentication token | `ghp_xxxxxxxxxxxxxxxxxxxx` | + +## Performance Tuning + +### For Large Repositories + +1. **Use aggressive filtering**: `--aggressive-filter` +2. **Reduce max file size**: `--max-file-size 512` +3. **Increase timeout**: `--timeout 600` +4. **Use experimental features**: `--experimental-parallel` + +### For Many Small Files + +1. **Increase thread count**: `--threads 16` +2. **Use streaming**: `--experimental-streaming` +3. **Set memory limit**: `--memory-limit 8192` + +### For Network-Limited Environments + +1. **Enable caching**: `--cache-dir ~/.bradar-cache` +2. **Increase retry count**: `--retry-count 10` +3. **Extend timeout**: `--timeout 900` + +## Troubleshooting + +### Common Issues + +#### Authentication Errors + +```bash +# Make sure token has correct permissions +bradar --token ghp_xxxxxxxxxxxxxxxxxxxx --debug private-repo +``` + +#### Network Timeouts + +```bash +# Increase timeout and retry count +bradar --timeout 600 --retry-count 5 slow-repo +``` + +#### Memory Issues + +```bash +# Set memory limit and use aggressive filtering +bradar --memory-limit 2048 --aggressive-filter large-repo +``` + +#### Slow Analysis + +```bash +# Use performance optimizations +bradar --aggressive-filter --experimental-parallel --threads 8 repo +``` + +### Debug Mode + +Enable debug mode for detailed information: + +```bash +bradar --debug --trace --log-file debug.log problematic-repo +``` + +### Getting Help + +```bash +bradar --help # General help +bradar --version # Version information +``` + +## Integration Examples + +### Shell Scripts + +```bash +#!/bin/bash +# Analyze multiple repositories and save to JSON +repos=("user/repo1" "user/repo2" "user/repo3") +for repo in "${repos[@]}"; do + bradar --format json --quiet "$repo" > "${repo//\//_}.json" +done +``` + +### CI/CD Integration + +```yaml +# GitHub Actions example +- name: Analyze Code Statistics + run: | + bradar --format json --quiet ${{ github.repository }} > stats.json + cat stats.json | jq '.global_metrics.total_lines' +``` + +### Monitoring Scripts + +```bash +#!/bin/bash +# Monitor repository growth +bradar --format json --quiet user/repo | \ +jq -r '.global_metrics | "\(.total_files) files, \(.total_lines) lines"' +``` diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 870aa9f..38c11f4 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -14,12 +14,14 @@ Thank you for your interest in contributing to bytes-radar! This document provid 1. **Fork the repository** 2. **Clone your fork**: + ```bash git clone https://github.com/YOUR_USERNAME/bytes-radar.git cd bytes-radar ``` 3. **Set up the development environment**: + ```bash cargo build cargo test --all-features @@ -57,6 +59,7 @@ Follow conventional commit format: - `chore:` - Maintenance tasks Examples: + ``` feat: add support for SourceForge repositories fix: handle network timeouts gracefully @@ -172,36 +175,26 @@ Releases are handled by maintainers: 3. Create git tag 4. GitHub Actions builds and publishes -## Project Structure - -``` -bytes-radar/ -├── .github/ # GitHub workflows and templates -├── src/ -│ ├── cli/ # Command-line interface -│ ├── core/ # Core analysis logic -│ └── lib.rs # Library entry point -├── tests/ # Integration tests -└── examples/ # Usage examples -``` - ## Areas for Contribution We welcome contributions in these areas: ### High Priority + - New git platform support - Performance optimizations - Better error messages - More output formats ### Medium Priority + - CLI improvements - Documentation improvements - More language detection - Cross-platform testing ### Low Priority + - Code cleanup - Minor feature additions - Example improvements @@ -253,4 +246,4 @@ By contributing to bytes-radar, you agree that your contributions will be licens --- -Thank you for contributing to bytes-radar! \ No newline at end of file +Thank you for contributing to bytes-radar! diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 5ac8099..bcd492c 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -5,11 +5,9 @@ [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button.svg)](https://deploy.workers.cloudflare.com/?url=https://github.com/zmh-program/bytes-radar) > [!TIP] -> The Free Tier of Cloudflare Workers has a **20s request timeout limit**. Analysis of large repositories may fail due to this limitation. Consider upgrading to Cloudflare Workers Pro or using alternative deployment methods for processing large repositories. +> The Free Tier of Cloudflare Workers has a **20s request timeout limit**. Analysis of large repositories may fail due to this limitation. Consider upgrading to Cloudflare Workers Pro or using alternative methods for processing large repositories. -The server component is automatically built and pushed to the `cf-worker` branch whenever changes are made to the server code. You can deploy to Cloudflare Workers with one click using the button above. This will: - -1. Fork the repository to your GitHub account (using the pre-built worker from cf-worker branch) +1. Fork the repository to your GitHub account 2. Connect it to your Cloudflare Workers account 3. Deploy the worker to your chosen environment @@ -17,28 +15,33 @@ The server component is automatically built and pushed to the `cf-worker` branch If you prefer to deploy manually: -1. Clone the cf-worker branch which contains the pre-built worker: +1. Clone the repository + ```bash -git clone -b cf-worker https://github.com/zmh-program/bytes-radar.git -cd bytes-radar/server +git clone https://github.com/zmh-program/bytes-radar.git +cd bytes-radar/worker ``` 2. Install Wrangler CLI: + ```bash pnpm install -g wrangler ``` 3. Authenticate with Cloudflare: + ```bash wrangler login ``` 4. Deploy to staging environment: + ```bash wrangler deploy --env staging ``` 5. Deploy to production: + ```bash wrangler deploy --env production ``` @@ -46,16 +49,18 @@ wrangler deploy --env production ### Environment Configuration The worker supports two environments: + - `staging`: For testing and development (bytes-radar-staging.workers.dev) - `production`: For production use (bytes-radar-prod.workers.dev) -See `server/wrangler.toml` for environment-specific configurations. +See `worker/wrangler.toml` for environment-specific configurations. ## API Documentation The Bytes Radar API provides code analysis capabilities through a simple HTTP interface. ### Base URL + ``` https://bradar.zmh.me ``` @@ -63,6 +68,7 @@ https://bradar.zmh.me ### Endpoints #### Analyze Repository + ```http GET /{repository_path} ``` @@ -70,22 +76,26 @@ GET /{repository_path} Analyzes a repository and returns detailed statistics about its codebase. ##### Repository Path Formats + - GitHub repository: `owner/repo` or `owner/repo@branch` - Full GitHub URL: `https://github.com/owner/repo` - GitLab URL: `https://gitlab.com/owner/repo` - Direct archive URL: `https://example.com/archive.tar.gz` ##### Query Parameters + - `ignore_hidden` (boolean, default: true) - Whether to ignore hidden files/directories - `ignore_gitignore` (boolean, default: true) - Whether to respect .gitignore rules - `max_file_size` (number, default: -1) - Maximum file size to analyze in bytes (-1 for no limit) ##### Example Request + ```http GET /zmh-program/bytes-radar ``` ##### Example Response + ```json { "project_name": "bytes-radar@main", @@ -135,6 +145,7 @@ GET /zmh-program/bytes-radar ``` ##### Error Response + ```json { "error": "Error message", @@ -146,13 +157,3 @@ GET /zmh-program/bytes-radar } } ``` - -### Rate Limits and Timeouts -- Request timeout: 20~30 seconds (Free tier) -- Rate limits: Based on Cloudflare Workers limits - -### Notes -- Large repositories may hit the 20-second timeout limit on the free tier -- For analyzing large repositories, consider using the CLI tool or upgrading to Cloudflare Workers Pro -- The service automatically tries common branch names (main, master, develop, dev) if not specified - diff --git a/examples/README.md b/examples/README.md deleted file mode 100644 index 10a8c48..0000000 --- a/examples/README.md +++ /dev/null @@ -1,89 +0,0 @@ -# Examples - -This directory contains examples demonstrating how to use the bytes-radar library. - -## Available Examples - -### 1. Basic Analysis (`basic_analysis.rs`) - -Demonstrates basic usage of the RemoteAnalyzer to analyze a public repository. - -```bash -cargo run --example basic_analysis -``` - -This example analyzes the bytes-radar repository itself and displays: -- Project summary statistics -- Language breakdown -- Total lines, files, and size information - -### 2. GitHub Token Analysis (`github_token_analysis.rs`) - -Shows how to use GitHub authentication for private repositories. - -```bash -export GITHUB_TOKEN=your_github_token_here -cargo run --example github_token_analysis -``` - -Make sure to: -1. Set the `GITHUB_TOKEN` environment variable -2. Update the repository URL in the example to point to your private repo -3. Ensure your token has access to the repository - -### 3. Compare Repositories (`compare_repositories.rs`) - -Compares multiple repositories and ranks them by various metrics. - -```bash -cargo run --example compare_repositories -``` - -This example: -- Analyzes multiple popular repositories -- Compares them side by side -- Ranks by complexity and documentation ratios -- Demonstrates batch processing - -### 4. Custom Analysis (`custom_analysis.rs`) - -Demonstrates manual construction of project analysis without remote fetching. - -```bash -cargo run --example custom_analysis -``` - -This example shows how to: -- Create FileMetrics manually -- Build a ProjectAnalysis step by step -- Use different file categories -- Display detailed file-by-file information - -## Running All Examples - -To run all examples in sequence: - -```bash -cargo run --example basic_analysis -cargo run --example custom_analysis -cargo run --example compare_repositories - -# For github_token_analysis, set your token first: -export GITHUB_TOKEN=your_token -cargo run --example github_token_analysis -``` - -## Notes - -- Examples that analyze remote repositories require an internet connection -- GitHub token examples require valid authentication -- Some examples may take time to complete due to network requests -- Adjust timeout values if you experience connection issues - -## Customizing Examples - -Feel free to modify these examples: -- Change repository URLs to analyze your own projects -- Adjust timeout values for slower connections -- Add error handling for production use -- Experiment with different output formats in the CLI \ No newline at end of file diff --git a/examples/compare_repositories.rs b/examples/compare_repositories.rs index bc139f5..96d0d17 100644 --- a/examples/compare_repositories.rs +++ b/examples/compare_repositories.rs @@ -47,7 +47,7 @@ async fn main() -> Result<()> { println!("{}", "-".repeat(75)); for (repo, summary) in &results { - let repo_name = repo.split('/').last().unwrap_or(repo); + let repo_name = repo.split('/').next_back().unwrap_or(repo); let primary_lang = summary.primary_language.as_deref().unwrap_or("Unknown"); println!( @@ -70,7 +70,7 @@ async fn main() -> Result<()> { }); for (i, (repo, summary)) in complexity_ranking.iter().enumerate() { - let repo_name = repo.split('/').last().unwrap_or(repo); + let repo_name = repo.split('/').next_back().unwrap_or(repo); println!( "{}. {}: {:.1}% code ratio", i + 1, @@ -89,7 +89,7 @@ async fn main() -> Result<()> { }); for (i, (repo, summary)) in doc_ranking.iter().enumerate() { - let repo_name = repo.split('/').last().unwrap_or(repo); + let repo_name = repo.split('/').next_back().unwrap_or(repo); println!( "{}. {}: {:.1}% documentation ratio", i + 1, diff --git a/examples/github_token_analysis.rs b/examples/github_token_analysis.rs index 908fa93..12d069d 100644 --- a/examples/github_token_analysis.rs +++ b/examples/github_token_analysis.rs @@ -1,4 +1,5 @@ use bytes_radar::{RemoteAnalyzer, Result}; +use std::collections::HashMap; use std::env; #[tokio::main] @@ -7,7 +8,11 @@ async fn main() -> Result<()> { env::var("GITHUB_TOKEN").expect("Please set GITHUB_TOKEN environment variable"); let mut analyzer = RemoteAnalyzer::new(); - analyzer.set_github_token(&github_token); + + let mut credentials = HashMap::new(); + credentials.insert("token".to_string(), github_token); + analyzer.set_provider_credentials("github", credentials); + analyzer.set_timeout(120); let url = "https://github.com/your-username/your-repo"; diff --git a/package.json b/package.json index ca2df87..2236d62 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,14 @@ "build": "node build.js", "dev": "wrangler dev", "deploy": "wrangler deploy", - "deploy:prod": "node build.js && wrangler deploy" + "deploy:prod": "node worker/build.js && wrangler deploy", + "prettier": "prettier --write . && cargo fmt" }, "dependencies": { "@cloudflare/workers-types": "^4.20240208.0" }, "devDependencies": { + "prettier": "^3.6.2", "typescript": "^5.3.3", "wrangler": "^3.28.1" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..befe928 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1292 @@ +lockfileVersion: "9.0" + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + .: + dependencies: + "@cloudflare/workers-types": + specifier: ^4.20240208.0 + version: 4.20250710.0 + devDependencies: + prettier: + specifier: ^3.6.2 + version: 3.6.2 + typescript: + specifier: ^5.3.3 + version: 5.8.3 + wrangler: + specifier: ^3.28.1 + version: 3.114.11(@cloudflare/workers-types@4.20250710.0) + +packages: + "@cloudflare/kv-asset-handler@0.3.4": + resolution: + { + integrity: sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==, + } + engines: { node: ">=16.13" } + + "@cloudflare/unenv-preset@2.0.2": + resolution: + { + integrity: sha512-nyzYnlZjjV5xT3LizahG1Iu6mnrCaxglJ04rZLpDwlDVDZ7v46lNsfxhV3A/xtfgQuSHmLnc6SVI+KwBpc3Lwg==, + } + peerDependencies: + unenv: 2.0.0-rc.14 + workerd: ^1.20250124.0 + peerDependenciesMeta: + workerd: + optional: true + + "@cloudflare/workerd-darwin-64@1.20250408.0": + resolution: + { + integrity: sha512-bxhIwBWxaNItZLXDNOKY2dCv0FHjDiDkfJFpwv4HvtvU5MKcrivZHVmmfDzLW85rqzfcDOmKbZeMPVfiKxdBZw==, + } + engines: { node: ">=16" } + cpu: [x64] + os: [darwin] + + "@cloudflare/workerd-darwin-arm64@1.20250408.0": + resolution: + { + integrity: sha512-5XZ2Oykr8bSo7zBmERtHh18h5BZYC/6H1YFWVxEj3PtalF3+6SHsO4KZsbGvDml9Pu7sHV277jiZE5eny8Hlyw==, + } + engines: { node: ">=16" } + cpu: [arm64] + os: [darwin] + + "@cloudflare/workerd-linux-64@1.20250408.0": + resolution: + { + integrity: sha512-WbgItXWln6G5d7GvYLWcuOzAVwafysZaWunH3UEfsm95wPuRofpYnlDD861gdWJX10IHSVgMStGESUcs7FLerQ==, + } + engines: { node: ">=16" } + cpu: [x64] + os: [linux] + + "@cloudflare/workerd-linux-arm64@1.20250408.0": + resolution: + { + integrity: sha512-pAhEywPPvr92SLylnQfZEPgXz+9pOG9G9haAPLpEatncZwYiYd9yiR6HYWhKp2erzCoNrOqKg9IlQwU3z1IDiw==, + } + engines: { node: ">=16" } + cpu: [arm64] + os: [linux] + + "@cloudflare/workerd-windows-64@1.20250408.0": + resolution: + { + integrity: sha512-nJ3RjMKGae2aF2rZ/CNeBvQPM+W5V1SUK0FYWG/uomyr7uQ2l4IayHna1ODg/OHHTEgIjwom0Mbn58iXb0WOcQ==, + } + engines: { node: ">=16" } + cpu: [x64] + os: [win32] + + "@cloudflare/workers-types@4.20250710.0": + resolution: + { + integrity: sha512-o055XFgW/ZinLnxDDlm4Q4b2yEmP2x5kekOka/E86blax/RGXj51mBQV/Co3VNog1CePzOA9S/CEOthoIirWRA==, + } + + "@cspotcode/source-map-support@0.8.1": + resolution: + { + integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==, + } + engines: { node: ">=12" } + + "@emnapi/runtime@1.4.4": + resolution: + { + integrity: sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==, + } + + "@esbuild-plugins/node-globals-polyfill@0.2.3": + resolution: + { + integrity: sha512-r3MIryXDeXDOZh7ih1l/yE9ZLORCd5e8vWg02azWRGj5SPTuoh69A2AIyn0Z31V/kHBfZ4HgWJ+OK3GTTwLmnw==, + } + peerDependencies: + esbuild: "*" + + "@esbuild-plugins/node-modules-polyfill@0.2.2": + resolution: + { + integrity: sha512-LXV7QsWJxRuMYvKbiznh+U1ilIop3g2TeKRzUxOG5X3YITc8JyyTa90BmLwqqv0YnX4v32CSlG+vsziZp9dMvA==, + } + peerDependencies: + esbuild: "*" + + "@esbuild/android-arm64@0.17.19": + resolution: + { + integrity: sha512-KBMWvEZooR7+kzY0BtbTQn0OAYY7CsiydT63pVEaPtVYF0hXbUaOyZog37DKxK7NF3XacBJOpYT4adIJh+avxA==, + } + engines: { node: ">=12" } + cpu: [arm64] + os: [android] + + "@esbuild/android-arm@0.17.19": + resolution: + { + integrity: sha512-rIKddzqhmav7MSmoFCmDIb6e2W57geRsM94gV2l38fzhXMwq7hZoClug9USI2pFRGL06f4IOPHHpFNOkWieR8A==, + } + engines: { node: ">=12" } + cpu: [arm] + os: [android] + + "@esbuild/android-x64@0.17.19": + resolution: + { + integrity: sha512-uUTTc4xGNDT7YSArp/zbtmbhO0uEEK9/ETW29Wk1thYUJBz3IVnvgEiEwEa9IeLyvnpKrWK64Utw2bgUmDveww==, + } + engines: { node: ">=12" } + cpu: [x64] + os: [android] + + "@esbuild/darwin-arm64@0.17.19": + resolution: + { + integrity: sha512-80wEoCfF/hFKM6WE1FyBHc9SfUblloAWx6FJkFWTWiCoht9Mc0ARGEM47e67W9rI09YoUxJL68WHfDRYEAvOhg==, + } + engines: { node: ">=12" } + cpu: [arm64] + os: [darwin] + + "@esbuild/darwin-x64@0.17.19": + resolution: + { + integrity: sha512-IJM4JJsLhRYr9xdtLytPLSH9k/oxR3boaUIYiHkAawtwNOXKE8KoU8tMvryogdcT8AU+Bflmh81Xn6Q0vTZbQw==, + } + engines: { node: ">=12" } + cpu: [x64] + os: [darwin] + + "@esbuild/freebsd-arm64@0.17.19": + resolution: + { + integrity: sha512-pBwbc7DufluUeGdjSU5Si+P3SoMF5DQ/F/UmTSb8HXO80ZEAJmrykPyzo1IfNbAoaqw48YRpv8shwd1NoI0jcQ==, + } + engines: { node: ">=12" } + cpu: [arm64] + os: [freebsd] + + "@esbuild/freebsd-x64@0.17.19": + resolution: + { + integrity: sha512-4lu+n8Wk0XlajEhbEffdy2xy53dpR06SlzvhGByyg36qJw6Kpfk7cp45DR/62aPH9mtJRmIyrXAS5UWBrJT6TQ==, + } + engines: { node: ">=12" } + cpu: [x64] + os: [freebsd] + + "@esbuild/linux-arm64@0.17.19": + resolution: + { + integrity: sha512-ct1Tg3WGwd3P+oZYqic+YZF4snNl2bsnMKRkb3ozHmnM0dGWuxcPTTntAF6bOP0Sp4x0PjSF+4uHQ1xvxfRKqg==, + } + engines: { node: ">=12" } + cpu: [arm64] + os: [linux] + + "@esbuild/linux-arm@0.17.19": + resolution: + { + integrity: sha512-cdmT3KxjlOQ/gZ2cjfrQOtmhG4HJs6hhvm3mWSRDPtZ/lP5oe8FWceS10JaSJC13GBd4eH/haHnqf7hhGNLerA==, + } + engines: { node: ">=12" } + cpu: [arm] + os: [linux] + + "@esbuild/linux-ia32@0.17.19": + resolution: + { + integrity: sha512-w4IRhSy1VbsNxHRQpeGCHEmibqdTUx61Vc38APcsRbuVgK0OPEnQ0YD39Brymn96mOx48Y2laBQGqgZ0j9w6SQ==, + } + engines: { node: ">=12" } + cpu: [ia32] + os: [linux] + + "@esbuild/linux-loong64@0.17.19": + resolution: + { + integrity: sha512-2iAngUbBPMq439a+z//gE+9WBldoMp1s5GWsUSgqHLzLJ9WoZLZhpwWuym0u0u/4XmZ3gpHmzV84PonE+9IIdQ==, + } + engines: { node: ">=12" } + cpu: [loong64] + os: [linux] + + "@esbuild/linux-mips64el@0.17.19": + resolution: + { + integrity: sha512-LKJltc4LVdMKHsrFe4MGNPp0hqDFA1Wpt3jE1gEyM3nKUvOiO//9PheZZHfYRfYl6AwdTH4aTcXSqBerX0ml4A==, + } + engines: { node: ">=12" } + cpu: [mips64el] + os: [linux] + + "@esbuild/linux-ppc64@0.17.19": + resolution: + { + integrity: sha512-/c/DGybs95WXNS8y3Ti/ytqETiW7EU44MEKuCAcpPto3YjQbyK3IQVKfF6nbghD7EcLUGl0NbiL5Rt5DMhn5tg==, + } + engines: { node: ">=12" } + cpu: [ppc64] + os: [linux] + + "@esbuild/linux-riscv64@0.17.19": + resolution: + { + integrity: sha512-FC3nUAWhvFoutlhAkgHf8f5HwFWUL6bYdvLc/TTuxKlvLi3+pPzdZiFKSWz/PF30TB1K19SuCxDTI5KcqASJqA==, + } + engines: { node: ">=12" } + cpu: [riscv64] + os: [linux] + + "@esbuild/linux-s390x@0.17.19": + resolution: + { + integrity: sha512-IbFsFbxMWLuKEbH+7sTkKzL6NJmG2vRyy6K7JJo55w+8xDk7RElYn6xvXtDW8HCfoKBFK69f3pgBJSUSQPr+4Q==, + } + engines: { node: ">=12" } + cpu: [s390x] + os: [linux] + + "@esbuild/linux-x64@0.17.19": + resolution: + { + integrity: sha512-68ngA9lg2H6zkZcyp22tsVt38mlhWde8l3eJLWkyLrp4HwMUr3c1s/M2t7+kHIhvMjglIBrFpncX1SzMckomGw==, + } + engines: { node: ">=12" } + cpu: [x64] + os: [linux] + + "@esbuild/netbsd-x64@0.17.19": + resolution: + { + integrity: sha512-CwFq42rXCR8TYIjIfpXCbRX0rp1jo6cPIUPSaWwzbVI4aOfX96OXY8M6KNmtPcg7QjYeDmN+DD0Wp3LaBOLf4Q==, + } + engines: { node: ">=12" } + cpu: [x64] + os: [netbsd] + + "@esbuild/openbsd-x64@0.17.19": + resolution: + { + integrity: sha512-cnq5brJYrSZ2CF6c35eCmviIN3k3RczmHz8eYaVlNasVqsNY+JKohZU5MKmaOI+KkllCdzOKKdPs762VCPC20g==, + } + engines: { node: ">=12" } + cpu: [x64] + os: [openbsd] + + "@esbuild/sunos-x64@0.17.19": + resolution: + { + integrity: sha512-vCRT7yP3zX+bKWFeP/zdS6SqdWB8OIpaRq/mbXQxTGHnIxspRtigpkUcDMlSCOejlHowLqII7K2JKevwyRP2rg==, + } + engines: { node: ">=12" } + cpu: [x64] + os: [sunos] + + "@esbuild/win32-arm64@0.17.19": + resolution: + { + integrity: sha512-yYx+8jwowUstVdorcMdNlzklLYhPxjniHWFKgRqH7IFlUEa0Umu3KuYplf1HUZZ422e3NU9F4LGb+4O0Kdcaag==, + } + engines: { node: ">=12" } + cpu: [arm64] + os: [win32] + + "@esbuild/win32-ia32@0.17.19": + resolution: + { + integrity: sha512-eggDKanJszUtCdlVs0RB+h35wNlb5v4TWEkq4vZcmVt5u/HiDZrTXe2bWFQUez3RgNHwx/x4sk5++4NSSicKkw==, + } + engines: { node: ">=12" } + cpu: [ia32] + os: [win32] + + "@esbuild/win32-x64@0.17.19": + resolution: + { + integrity: sha512-lAhycmKnVOuRYNtRtatQR1LPQf2oYCkRGkSFnseDAKPl8lu5SOsK/e1sXe5a0Pc5kHIHe6P2I/ilntNv2xf3cA==, + } + engines: { node: ">=12" } + cpu: [x64] + os: [win32] + + "@fastify/busboy@2.1.1": + resolution: + { + integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==, + } + engines: { node: ">=14" } + + "@img/sharp-darwin-arm64@0.33.5": + resolution: + { + integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [arm64] + os: [darwin] + + "@img/sharp-darwin-x64@0.33.5": + resolution: + { + integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [x64] + os: [darwin] + + "@img/sharp-libvips-darwin-arm64@1.0.4": + resolution: + { + integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==, + } + cpu: [arm64] + os: [darwin] + + "@img/sharp-libvips-darwin-x64@1.0.4": + resolution: + { + integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==, + } + cpu: [x64] + os: [darwin] + + "@img/sharp-libvips-linux-arm64@1.0.4": + resolution: + { + integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==, + } + cpu: [arm64] + os: [linux] + + "@img/sharp-libvips-linux-arm@1.0.5": + resolution: + { + integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==, + } + cpu: [arm] + os: [linux] + + "@img/sharp-libvips-linux-s390x@1.0.4": + resolution: + { + integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==, + } + cpu: [s390x] + os: [linux] + + "@img/sharp-libvips-linux-x64@1.0.4": + resolution: + { + integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==, + } + cpu: [x64] + os: [linux] + + "@img/sharp-libvips-linuxmusl-arm64@1.0.4": + resolution: + { + integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==, + } + cpu: [arm64] + os: [linux] + + "@img/sharp-libvips-linuxmusl-x64@1.0.4": + resolution: + { + integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==, + } + cpu: [x64] + os: [linux] + + "@img/sharp-linux-arm64@0.33.5": + resolution: + { + integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [arm64] + os: [linux] + + "@img/sharp-linux-arm@0.33.5": + resolution: + { + integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [arm] + os: [linux] + + "@img/sharp-linux-s390x@0.33.5": + resolution: + { + integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [s390x] + os: [linux] + + "@img/sharp-linux-x64@0.33.5": + resolution: + { + integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [x64] + os: [linux] + + "@img/sharp-linuxmusl-arm64@0.33.5": + resolution: + { + integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [arm64] + os: [linux] + + "@img/sharp-linuxmusl-x64@0.33.5": + resolution: + { + integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [x64] + os: [linux] + + "@img/sharp-wasm32@0.33.5": + resolution: + { + integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [wasm32] + + "@img/sharp-win32-ia32@0.33.5": + resolution: + { + integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [ia32] + os: [win32] + + "@img/sharp-win32-x64@0.33.5": + resolution: + { + integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + cpu: [x64] + os: [win32] + + "@jridgewell/resolve-uri@3.1.2": + resolution: + { + integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==, + } + engines: { node: ">=6.0.0" } + + "@jridgewell/sourcemap-codec@1.5.4": + resolution: + { + integrity: sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==, + } + + "@jridgewell/trace-mapping@0.3.9": + resolution: + { + integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==, + } + + acorn-walk@8.3.2: + resolution: + { + integrity: sha512-cjkyv4OtNCIeqhHrfS81QWXoCBPExR/J62oyEqepVw8WaQeSqpW2uhuLPh1m9eWhDuOo/jUXVTlifvesOWp/4A==, + } + engines: { node: ">=0.4.0" } + + acorn@8.14.0: + resolution: + { + integrity: sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==, + } + engines: { node: ">=0.4.0" } + hasBin: true + + as-table@1.0.55: + resolution: + { + integrity: sha512-xvsWESUJn0JN421Xb9MQw6AsMHRCUknCe0Wjlxvjud80mU4E6hQf1A6NzQKcYNmYw62MfzEtXc+badstZP3JpQ==, + } + + blake3-wasm@2.1.5: + resolution: + { + integrity: sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g==, + } + + color-convert@2.0.1: + resolution: + { + integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, + } + engines: { node: ">=7.0.0" } + + color-name@1.1.4: + resolution: + { + integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==, + } + + color-string@1.9.1: + resolution: + { + integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==, + } + + color@4.2.3: + resolution: + { + integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==, + } + engines: { node: ">=12.5.0" } + + cookie@0.7.2: + resolution: + { + integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==, + } + engines: { node: ">= 0.6" } + + data-uri-to-buffer@2.0.2: + resolution: + { + integrity: sha512-ND9qDTLc6diwj+Xe5cdAgVTbLVdXbtxTJRXRhli8Mowuaan+0EJOtdqJ0QCHNSSPyoXGx9HX2/VMnKeC34AChA==, + } + + defu@6.1.4: + resolution: + { + integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==, + } + + detect-libc@2.0.4: + resolution: + { + integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==, + } + engines: { node: ">=8" } + + esbuild@0.17.19: + resolution: + { + integrity: sha512-XQ0jAPFkK/u3LcVRcvVHQcTIqD6E2H1fvZMA5dQPSOWb3suUbWbfbRf94pjc0bNzRYLfIrDRQXr7X+LHIm5oHw==, + } + engines: { node: ">=12" } + hasBin: true + + escape-string-regexp@4.0.0: + resolution: + { + integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==, + } + engines: { node: ">=10" } + + estree-walker@0.6.1: + resolution: + { + integrity: sha512-SqmZANLWS0mnatqbSfRP5g8OXZC12Fgg1IwNtLsyHDzJizORW4khDfjPqJZsemPWBB2uqykUah5YpQ6epsqC/w==, + } + + exit-hook@2.2.1: + resolution: + { + integrity: sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw==, + } + engines: { node: ">=6" } + + exsolve@1.0.7: + resolution: + { + integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==, + } + + fsevents@2.3.3: + resolution: + { + integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, + } + engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } + os: [darwin] + + get-source@2.0.12: + resolution: + { + integrity: sha512-X5+4+iD+HoSeEED+uwrQ07BOQr0kEDFMVqqpBuI+RaZBpBpHCuXxo70bjar6f0b0u/DQJsJ7ssurpP0V60Az+w==, + } + + glob-to-regexp@0.4.1: + resolution: + { + integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==, + } + + is-arrayish@0.3.2: + resolution: + { + integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==, + } + + magic-string@0.25.9: + resolution: + { + integrity: sha512-RmF0AsMzgt25qzqqLc1+MbHmhdx0ojF2Fvs4XnOqz2ZOBXzzkEwc/dJQZCYHAn7v1jbVOjAZfK8msRn4BxO4VQ==, + } + + mime@3.0.0: + resolution: + { + integrity: sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==, + } + engines: { node: ">=10.0.0" } + hasBin: true + + miniflare@3.20250408.2: + resolution: + { + integrity: sha512-uTs7cGWFErgJTKtBdmtctwhuoxniuCQqDT8+xaEiJdEC8d+HsaZVYfZwIX2NuSmdAiHMe7NtbdZYjFMbIXtJsQ==, + } + engines: { node: ">=16.13" } + hasBin: true + + mustache@4.2.0: + resolution: + { + integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==, + } + hasBin: true + + ohash@2.0.11: + resolution: + { + integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==, + } + + path-to-regexp@6.3.0: + resolution: + { + integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==, + } + + pathe@2.0.3: + resolution: + { + integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, + } + + prettier@3.6.2: + resolution: + { + integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==, + } + engines: { node: ">=14" } + hasBin: true + + printable-characters@1.0.42: + resolution: + { + integrity: sha512-dKp+C4iXWK4vVYZmYSd0KBH5F/h1HoZRsbJ82AVKRO3PEo8L4lBS/vLwhVtpwwuYcoIsVY+1JYKR268yn480uQ==, + } + + rollup-plugin-inject@3.0.2: + resolution: + { + integrity: sha512-ptg9PQwzs3orn4jkgXJ74bfs5vYz1NCZlSQMBUA0wKcGp5i5pA1AO3fOUEte8enhGUC+iapTCzEWw2jEFFUO/w==, + } + deprecated: This package has been deprecated and is no longer maintained. Please use @rollup/plugin-inject. + + rollup-plugin-node-polyfills@0.2.1: + resolution: + { + integrity: sha512-4kCrKPTJ6sK4/gLL/U5QzVT8cxJcofO0OU74tnB19F40cmuAKSzH5/siithxlofFEjwvw1YAhPmbvGNA6jEroA==, + } + + rollup-pluginutils@2.8.2: + resolution: + { + integrity: sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ==, + } + + semver@7.7.2: + resolution: + { + integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==, + } + engines: { node: ">=10" } + hasBin: true + + sharp@0.33.5: + resolution: + { + integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==, + } + engines: { node: ^18.17.0 || ^20.3.0 || >=21.0.0 } + + simple-swizzle@0.2.2: + resolution: + { + integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==, + } + + source-map@0.6.1: + resolution: + { + integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==, + } + engines: { node: ">=0.10.0" } + + sourcemap-codec@1.4.8: + resolution: + { + integrity: sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA==, + } + deprecated: Please use @jridgewell/sourcemap-codec instead + + stacktracey@2.1.8: + resolution: + { + integrity: sha512-Kpij9riA+UNg7TnphqjH7/CzctQ/owJGNbFkfEeve4Z4uxT5+JapVLFXcsurIfN34gnTWZNJ/f7NMG0E8JDzTw==, + } + + stoppable@1.1.0: + resolution: + { + integrity: sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw==, + } + engines: { node: ">=4", npm: ">=6" } + + tslib@2.8.1: + resolution: + { + integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, + } + + typescript@5.8.3: + resolution: + { + integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==, + } + engines: { node: ">=14.17" } + hasBin: true + + ufo@1.6.1: + resolution: + { + integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==, + } + + undici@5.29.0: + resolution: + { + integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==, + } + engines: { node: ">=14.0" } + + unenv@2.0.0-rc.14: + resolution: + { + integrity: sha512-od496pShMen7nOy5VmVJCnq8rptd45vh6Nx/r2iPbrba6pa6p+tS2ywuIHRZ/OBvSbQZB0kWvpO9XBNVFXHD3Q==, + } + + workerd@1.20250408.0: + resolution: + { + integrity: sha512-bBUX+UsvpzAqiWFNeZrlZmDGddiGZdBBbftZJz2wE6iUg/cIAJeVQYTtS/3ahaicguoLBz4nJiDo8luqM9fx1A==, + } + engines: { node: ">=16" } + hasBin: true + + wrangler@3.114.11: + resolution: + { + integrity: sha512-g0KhNj0AzlDXrW/XNzOrbfjBnDHbmf3a+3+UR67BbKnS8EUC9yTPrKCRymERdFKfiSvlPs34tHGAzOTw62Jb4g==, + } + engines: { node: ">=16.17.0" } + hasBin: true + peerDependencies: + "@cloudflare/workers-types": ^4.20250408.0 + peerDependenciesMeta: + "@cloudflare/workers-types": + optional: true + + ws@8.18.0: + resolution: + { + integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==, + } + engines: { node: ">=10.0.0" } + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ">=5.0.2" + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + + youch@3.3.4: + resolution: + { + integrity: sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==, + } + + zod@3.22.3: + resolution: + { + integrity: sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug==, + } + +snapshots: + "@cloudflare/kv-asset-handler@0.3.4": + dependencies: + mime: 3.0.0 + + "@cloudflare/unenv-preset@2.0.2(unenv@2.0.0-rc.14)(workerd@1.20250408.0)": + dependencies: + unenv: 2.0.0-rc.14 + optionalDependencies: + workerd: 1.20250408.0 + + "@cloudflare/workerd-darwin-64@1.20250408.0": + optional: true + + "@cloudflare/workerd-darwin-arm64@1.20250408.0": + optional: true + + "@cloudflare/workerd-linux-64@1.20250408.0": + optional: true + + "@cloudflare/workerd-linux-arm64@1.20250408.0": + optional: true + + "@cloudflare/workerd-windows-64@1.20250408.0": + optional: true + + "@cloudflare/workers-types@4.20250710.0": {} + + "@cspotcode/source-map-support@0.8.1": + dependencies: + "@jridgewell/trace-mapping": 0.3.9 + + "@emnapi/runtime@1.4.4": + dependencies: + tslib: 2.8.1 + optional: true + + "@esbuild-plugins/node-globals-polyfill@0.2.3(esbuild@0.17.19)": + dependencies: + esbuild: 0.17.19 + + "@esbuild-plugins/node-modules-polyfill@0.2.2(esbuild@0.17.19)": + dependencies: + esbuild: 0.17.19 + escape-string-regexp: 4.0.0 + rollup-plugin-node-polyfills: 0.2.1 + + "@esbuild/android-arm64@0.17.19": + optional: true + + "@esbuild/android-arm@0.17.19": + optional: true + + "@esbuild/android-x64@0.17.19": + optional: true + + "@esbuild/darwin-arm64@0.17.19": + optional: true + + "@esbuild/darwin-x64@0.17.19": + optional: true + + "@esbuild/freebsd-arm64@0.17.19": + optional: true + + "@esbuild/freebsd-x64@0.17.19": + optional: true + + "@esbuild/linux-arm64@0.17.19": + optional: true + + "@esbuild/linux-arm@0.17.19": + optional: true + + "@esbuild/linux-ia32@0.17.19": + optional: true + + "@esbuild/linux-loong64@0.17.19": + optional: true + + "@esbuild/linux-mips64el@0.17.19": + optional: true + + "@esbuild/linux-ppc64@0.17.19": + optional: true + + "@esbuild/linux-riscv64@0.17.19": + optional: true + + "@esbuild/linux-s390x@0.17.19": + optional: true + + "@esbuild/linux-x64@0.17.19": + optional: true + + "@esbuild/netbsd-x64@0.17.19": + optional: true + + "@esbuild/openbsd-x64@0.17.19": + optional: true + + "@esbuild/sunos-x64@0.17.19": + optional: true + + "@esbuild/win32-arm64@0.17.19": + optional: true + + "@esbuild/win32-ia32@0.17.19": + optional: true + + "@esbuild/win32-x64@0.17.19": + optional: true + + "@fastify/busboy@2.1.1": {} + + "@img/sharp-darwin-arm64@0.33.5": + optionalDependencies: + "@img/sharp-libvips-darwin-arm64": 1.0.4 + optional: true + + "@img/sharp-darwin-x64@0.33.5": + optionalDependencies: + "@img/sharp-libvips-darwin-x64": 1.0.4 + optional: true + + "@img/sharp-libvips-darwin-arm64@1.0.4": + optional: true + + "@img/sharp-libvips-darwin-x64@1.0.4": + optional: true + + "@img/sharp-libvips-linux-arm64@1.0.4": + optional: true + + "@img/sharp-libvips-linux-arm@1.0.5": + optional: true + + "@img/sharp-libvips-linux-s390x@1.0.4": + optional: true + + "@img/sharp-libvips-linux-x64@1.0.4": + optional: true + + "@img/sharp-libvips-linuxmusl-arm64@1.0.4": + optional: true + + "@img/sharp-libvips-linuxmusl-x64@1.0.4": + optional: true + + "@img/sharp-linux-arm64@0.33.5": + optionalDependencies: + "@img/sharp-libvips-linux-arm64": 1.0.4 + optional: true + + "@img/sharp-linux-arm@0.33.5": + optionalDependencies: + "@img/sharp-libvips-linux-arm": 1.0.5 + optional: true + + "@img/sharp-linux-s390x@0.33.5": + optionalDependencies: + "@img/sharp-libvips-linux-s390x": 1.0.4 + optional: true + + "@img/sharp-linux-x64@0.33.5": + optionalDependencies: + "@img/sharp-libvips-linux-x64": 1.0.4 + optional: true + + "@img/sharp-linuxmusl-arm64@0.33.5": + optionalDependencies: + "@img/sharp-libvips-linuxmusl-arm64": 1.0.4 + optional: true + + "@img/sharp-linuxmusl-x64@0.33.5": + optionalDependencies: + "@img/sharp-libvips-linuxmusl-x64": 1.0.4 + optional: true + + "@img/sharp-wasm32@0.33.5": + dependencies: + "@emnapi/runtime": 1.4.4 + optional: true + + "@img/sharp-win32-ia32@0.33.5": + optional: true + + "@img/sharp-win32-x64@0.33.5": + optional: true + + "@jridgewell/resolve-uri@3.1.2": {} + + "@jridgewell/sourcemap-codec@1.5.4": {} + + "@jridgewell/trace-mapping@0.3.9": + dependencies: + "@jridgewell/resolve-uri": 3.1.2 + "@jridgewell/sourcemap-codec": 1.5.4 + + acorn-walk@8.3.2: {} + + acorn@8.14.0: {} + + as-table@1.0.55: + dependencies: + printable-characters: 1.0.42 + + blake3-wasm@2.1.5: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + optional: true + + color-name@1.1.4: + optional: true + + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.2 + optional: true + + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + optional: true + + cookie@0.7.2: {} + + data-uri-to-buffer@2.0.2: {} + + defu@6.1.4: {} + + detect-libc@2.0.4: + optional: true + + esbuild@0.17.19: + optionalDependencies: + "@esbuild/android-arm": 0.17.19 + "@esbuild/android-arm64": 0.17.19 + "@esbuild/android-x64": 0.17.19 + "@esbuild/darwin-arm64": 0.17.19 + "@esbuild/darwin-x64": 0.17.19 + "@esbuild/freebsd-arm64": 0.17.19 + "@esbuild/freebsd-x64": 0.17.19 + "@esbuild/linux-arm": 0.17.19 + "@esbuild/linux-arm64": 0.17.19 + "@esbuild/linux-ia32": 0.17.19 + "@esbuild/linux-loong64": 0.17.19 + "@esbuild/linux-mips64el": 0.17.19 + "@esbuild/linux-ppc64": 0.17.19 + "@esbuild/linux-riscv64": 0.17.19 + "@esbuild/linux-s390x": 0.17.19 + "@esbuild/linux-x64": 0.17.19 + "@esbuild/netbsd-x64": 0.17.19 + "@esbuild/openbsd-x64": 0.17.19 + "@esbuild/sunos-x64": 0.17.19 + "@esbuild/win32-arm64": 0.17.19 + "@esbuild/win32-ia32": 0.17.19 + "@esbuild/win32-x64": 0.17.19 + + escape-string-regexp@4.0.0: {} + + estree-walker@0.6.1: {} + + exit-hook@2.2.1: {} + + exsolve@1.0.7: {} + + fsevents@2.3.3: + optional: true + + get-source@2.0.12: + dependencies: + data-uri-to-buffer: 2.0.2 + source-map: 0.6.1 + + glob-to-regexp@0.4.1: {} + + is-arrayish@0.3.2: + optional: true + + magic-string@0.25.9: + dependencies: + sourcemap-codec: 1.4.8 + + mime@3.0.0: {} + + miniflare@3.20250408.2: + dependencies: + "@cspotcode/source-map-support": 0.8.1 + acorn: 8.14.0 + acorn-walk: 8.3.2 + exit-hook: 2.2.1 + glob-to-regexp: 0.4.1 + stoppable: 1.1.0 + undici: 5.29.0 + workerd: 1.20250408.0 + ws: 8.18.0 + youch: 3.3.4 + zod: 3.22.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + mustache@4.2.0: {} + + ohash@2.0.11: {} + + path-to-regexp@6.3.0: {} + + pathe@2.0.3: {} + + prettier@3.6.2: {} + + printable-characters@1.0.42: {} + + rollup-plugin-inject@3.0.2: + dependencies: + estree-walker: 0.6.1 + magic-string: 0.25.9 + rollup-pluginutils: 2.8.2 + + rollup-plugin-node-polyfills@0.2.1: + dependencies: + rollup-plugin-inject: 3.0.2 + + rollup-pluginutils@2.8.2: + dependencies: + estree-walker: 0.6.1 + + semver@7.7.2: + optional: true + + sharp@0.33.5: + dependencies: + color: 4.2.3 + detect-libc: 2.0.4 + semver: 7.7.2 + optionalDependencies: + "@img/sharp-darwin-arm64": 0.33.5 + "@img/sharp-darwin-x64": 0.33.5 + "@img/sharp-libvips-darwin-arm64": 1.0.4 + "@img/sharp-libvips-darwin-x64": 1.0.4 + "@img/sharp-libvips-linux-arm": 1.0.5 + "@img/sharp-libvips-linux-arm64": 1.0.4 + "@img/sharp-libvips-linux-s390x": 1.0.4 + "@img/sharp-libvips-linux-x64": 1.0.4 + "@img/sharp-libvips-linuxmusl-arm64": 1.0.4 + "@img/sharp-libvips-linuxmusl-x64": 1.0.4 + "@img/sharp-linux-arm": 0.33.5 + "@img/sharp-linux-arm64": 0.33.5 + "@img/sharp-linux-s390x": 0.33.5 + "@img/sharp-linux-x64": 0.33.5 + "@img/sharp-linuxmusl-arm64": 0.33.5 + "@img/sharp-linuxmusl-x64": 0.33.5 + "@img/sharp-wasm32": 0.33.5 + "@img/sharp-win32-ia32": 0.33.5 + "@img/sharp-win32-x64": 0.33.5 + optional: true + + simple-swizzle@0.2.2: + dependencies: + is-arrayish: 0.3.2 + optional: true + + source-map@0.6.1: {} + + sourcemap-codec@1.4.8: {} + + stacktracey@2.1.8: + dependencies: + as-table: 1.0.55 + get-source: 2.0.12 + + stoppable@1.1.0: {} + + tslib@2.8.1: + optional: true + + typescript@5.8.3: {} + + ufo@1.6.1: {} + + undici@5.29.0: + dependencies: + "@fastify/busboy": 2.1.1 + + unenv@2.0.0-rc.14: + dependencies: + defu: 6.1.4 + exsolve: 1.0.7 + ohash: 2.0.11 + pathe: 2.0.3 + ufo: 1.6.1 + + workerd@1.20250408.0: + optionalDependencies: + "@cloudflare/workerd-darwin-64": 1.20250408.0 + "@cloudflare/workerd-darwin-arm64": 1.20250408.0 + "@cloudflare/workerd-linux-64": 1.20250408.0 + "@cloudflare/workerd-linux-arm64": 1.20250408.0 + "@cloudflare/workerd-windows-64": 1.20250408.0 + + wrangler@3.114.11(@cloudflare/workers-types@4.20250710.0): + dependencies: + "@cloudflare/kv-asset-handler": 0.3.4 + "@cloudflare/unenv-preset": 2.0.2(unenv@2.0.0-rc.14)(workerd@1.20250408.0) + "@esbuild-plugins/node-globals-polyfill": 0.2.3(esbuild@0.17.19) + "@esbuild-plugins/node-modules-polyfill": 0.2.2(esbuild@0.17.19) + blake3-wasm: 2.1.5 + esbuild: 0.17.19 + miniflare: 3.20250408.2 + path-to-regexp: 6.3.0 + unenv: 2.0.0-rc.14 + workerd: 1.20250408.0 + optionalDependencies: + "@cloudflare/workers-types": 4.20250710.0 + fsevents: 2.3.3 + sharp: 0.33.5 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ws@8.18.0: {} + + youch@3.3.4: + dependencies: + cookie: 0.7.2 + mustache: 4.2.0 + stacktracey: 2.1.8 + + zod@3.22.3: {} diff --git a/server/build.js b/server/build.js deleted file mode 100644 index c1e14c6..0000000 --- a/server/build.js +++ /dev/null @@ -1,54 +0,0 @@ -const { execSync } = require('child_process'); -const fs = require('fs'); -const path = require('path'); - -// Install Rust if not installed -console.log('Installing Rust toolchain...'); -try { - execSync('rustc --version'); - console.log('Rust already installed'); -} catch { - console.log('Installing Rust...'); - execSync('curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y', { - stdio: 'inherit' - }); - // Add cargo to PATH - process.env.PATH = `${process.env.HOME}/.cargo/bin:${process.env.PATH}`; -} - -// Install wasm-pack if not installed -console.log('Installing wasm-pack...'); -try { - execSync('wasm-pack --version'); - console.log('wasm-pack already installed'); -} catch { - console.log('Installing wasm-pack...'); - execSync('curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh', { - stdio: 'inherit' - }); -} - -if (!fs.existsSync('server/pkg')) { - fs.mkdirSync('server/pkg', { recursive: true }); -} - -console.log('Building WebAssembly module...'); -execSync('wasm-pack build --target web --out-dir server/pkg --no-default-features --features worker', { - stdio: 'inherit', - cwd: process.cwd(), -}); - -console.log('Generating TypeScript types...'); -const typesContent = ` -export interface AnalyzeOptions { - ignore_hidden: boolean; - ignore_gitignore: boolean; - max_file_size: number; -} - -export function analyze_url(url: string, options: AnalyzeOptions): Promise; -`; - -fs.writeFileSync(path.join(process.cwd(), 'server/pkg', 'bytes_radar.d.ts'), typesContent); - -console.log('Build complete!'); diff --git a/server/worker.ts b/server/worker.ts deleted file mode 100644 index 11108e8..0000000 --- a/server/worker.ts +++ /dev/null @@ -1,215 +0,0 @@ -import type { AnalyzeOptions } from './pkg/bytes_radar'; -import wasmBinary from './pkg/bytes_radar_bg.wasm'; - -export interface Env { - BYTES_RADAR: DurableObjectNamespace; - LOG_LEVEL?: string; - ENVIRONMENT?: string; -} - -export class BytesRadar { - state: DurableObjectState; - env: Env; - private wasmModule: any = null; - private wasmInitialized = false; - - constructor(state: DurableObjectState, env: Env) { - this.state = state; - this.env = env; - } - - private log(level: 'debug' | 'info' | 'warn' | 'error', message: string, data?: any) { - const logLevel = this.env.LOG_LEVEL || 'info'; - const environment = this.env.ENVIRONMENT || 'development'; - - const levels = { debug: 0, info: 1, warn: 2, error: 3 }; - const currentLevel = levels[logLevel as keyof typeof levels] || 1; - const messageLevel = levels[level]; - - if (messageLevel >= currentLevel) { - const timestamp = new Date().toISOString(); - const logEntry = { - timestamp, - level: level.toUpperCase(), - environment, - message, - ...(data && { data }) - }; - - const fn = { - debug: console.debug, - info: console.info, - warn: console.warn, - error: console.error, - } - - if (environment === 'production') { - fn[level](JSON.stringify(logEntry)); - } else { - fn[level](`[${level.toUpperCase()}] ${message}`, data ? data : ''); - } - } - } - - private async initializeWasm() { - if (!this.wasmInitialized) { - try { - this.wasmModule = await import('./pkg/bytes_radar'); - await this.wasmModule.default(wasmBinary); - this.wasmInitialized = true; - this.log('info', 'WebAssembly module initialized successfully'); - } catch (error) { - this.log('error', 'Failed to initialize WebAssembly module', { - error: error instanceof Error ? error.message : String(error) - }); - throw error; - } - } - } - - async fetch(request: Request) { - const url = new URL(request.url); - if (url.pathname === '/favicon.ico') { - return new Response(null, { status: 404 }); - } - - const startTime = performance.now(); - const debugInfo: any = { - timestamp: new Date().toISOString(), - wasm_initialized: this.wasmInitialized, - }; - - try { - await this.initializeWasm(); - debugInfo.wasm_initialized = true; - - const pathParts = url.pathname.split('/').filter(Boolean); - const targetUrl = pathParts.join('/'); - - if (!targetUrl) { - debugInfo.error = 'Missing repository path'; - debugInfo.duration_ms = performance.now() - startTime; - return new Response(JSON.stringify({ - error: 'Missing repository path', - usage: [ - "/[user/repo", - "/user/repo@master", - "/github.com/user/repo", - "/gitlab.com/user/repo", - "http://example.com/example-asset.tar.gz", - ], - debug_info: debugInfo - }), { - status: 400, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - } - }); - } - - const maxSizeParam = url.searchParams.get('max_size'); - const ignoreHiddenParam = url.searchParams.get('ignore_hidden'); - const ignoreGitignoreParam = url.searchParams.get('ignore_gitignore'); - - const options: AnalyzeOptions = { - ignore_hidden: ignoreHiddenParam === 'false' ? false : true, - ignore_gitignore: ignoreGitignoreParam === 'false' ? false : true, - max_file_size: maxSizeParam === '-1' ? -1 : - maxSizeParam ? parseInt(maxSizeParam) : - -1, - }; - - debugInfo.target_url = targetUrl; - debugInfo.options = options; - - this.log('info', 'Starting analysis', { url: targetUrl, options }); - - const analysisStartTime = performance.now(); - const result = await this.wasmModule.analyze_url(targetUrl, options); - const analysisEndTime = performance.now(); - - debugInfo.analysis_duration_ms = analysisEndTime - analysisStartTime; - debugInfo.total_duration_ms = analysisEndTime - startTime; - - if (result && result.wasm_debug_info) { - Object.assign(debugInfo, result.wasm_debug_info); - delete result.wasm_debug_info; - } - - const response = { - ...result, - debug_info: debugInfo - }; - - this.log('info', 'Analysis completed successfully', debugInfo); - - return new Response(JSON.stringify(response), { - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - }, - }); - } catch (error: unknown) { - let errorMessage = error instanceof Error ? error.message : String(error); - const errorStack = error instanceof Error ? error.stack : undefined; - let errorType = 'UnknownError'; - - if (error && typeof error === 'object' && 'error' in error) { - const wasmError = error as any; - errorMessage = wasmError.error || errorMessage; - errorType = wasmError.error_type || 'WASMError'; - - if (wasmError.wasm_debug_info) { - Object.assign(debugInfo, wasmError.wasm_debug_info); - delete wasmError.wasm_debug_info; - } - } - - debugInfo.error = errorMessage; - debugInfo.error_type = errorType; - debugInfo.error_stack = errorStack; - debugInfo.duration_ms = performance.now() - startTime; - - if (errorMessage.includes('URL parsing error')) { - debugInfo.error_category = 'URL_PARSING'; - debugInfo.suggested_fix = 'Please check the URL format. Use formats like: user/repo, user/repo@branch, or full GitHub URLs'; - } else if (errorMessage.includes('network') || errorMessage.includes('download')) { - debugInfo.error_category = 'NETWORK'; - debugInfo.suggested_fix = 'Check your internet connection and ensure the repository is accessible'; - } else if (errorMessage.includes('branch')) { - debugInfo.error_category = 'BRANCH_ACCESS'; - debugInfo.suggested_fix = 'The repository may not have the expected default branches (main, master, develop, dev)'; - } else { - debugInfo.error_category = 'UNKNOWN'; - debugInfo.suggested_fix = 'Please check the error details and try again'; - } - - this.log('error', 'Error in BytesRadar fetch', debugInfo); - - const errorResponse: any = { - error: errorMessage, - error_type: errorType, - error_category: debugInfo.error_category, - suggested_fix: debugInfo.suggested_fix, - debug_info: debugInfo - }; - - return new Response(JSON.stringify(errorResponse), { - status: 500, - headers: { - 'Content-Type': 'application/json', - 'Access-Control-Allow-Origin': '*', - } - }); - } - } -} - -export default { - async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { - const id = env.BYTES_RADAR.idFromName('default'); - const obj = env.BYTES_RADAR.get(id); - return obj.fetch(request); - }, -}; \ No newline at end of file diff --git a/src/cli/args.rs b/src/cli/args.rs index 4545cd0..8eb6c0f 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -1,86 +1,263 @@ -use clap::{Parser, ValueEnum}; +use clap::{ArgGroup, Parser, ValueEnum}; #[derive(Parser)] #[command(name = "bradar")] -#[command(about = "A tool for analyzing code statistics from remote repositories")] +#[command(about = "A professional tool for analyzing code statistics from remote repositories")] #[command(version)] #[command(long_about = " -A professional code analysis tool for remote repositories. +bradar - Bytes Radar: A hyper-fast code analysis tool for remote repositories. SUPPORTED PLATFORMS: - - GitHub (github.com) - - GitLab (gitlab.com, self-hosted) - - Bitbucket (bitbucket.org) - - Codeberg (codeberg.org) - - SourceForge (sourceforge.net) - - Direct tar.gz/tgz URLs - -USAGE EXAMPLES: - bradar user/repo # GitHub repo (default branch) - bradar user/repo@master # GitHub repo with specific branch - bradar user/repo@abc123 # GitHub repo with specific commit - bradar https://github.com/user/repo # Full GitHub URL - bradar https://gitlab.com/user/repo # GitLab URL - bradar https://bitbucket.org/user/repo # Bitbucket URL - bradar https://example.com/file.tar.gz # Direct tar.gz URL - bradar -f json user/repo # JSON output format - bradar --token ghp_xxx user/repo # With GitHub token for private repos + • GitHub (github.com, GitHub Enterprise) + • GitLab (gitlab.com, self-hosted instances) + • Bitbucket (bitbucket.org) + • Codeberg (codeberg.org) + • SourceForge (sourceforge.net) + • Gitea instances + • Azure DevOps + • Direct archive URLs (tar.gz, tgz, zip) + +URL FORMATS: + user/repo # GitHub repo (default branch) + user/repo@branch # Specific branch + user/repo@commit-hash # Specific commit + https://github.com/user/repo # Full GitHub URL + https://gitlab.com/user/repo # GitLab URL + https://example.com/archive.tar.gz # Direct archive URL + +EXAMPLES: + bradar microsoft/vscode + bradar torvalds/linux@master + bradar https://github.com/rust-lang/rust + bradar --format json --detailed user/repo + bradar --token ghp_xxx --include-tests private/repo + bradar --aggressive-filter --max-file-size 2048 large/repo ")] +#[command(arg_required_else_help = true)] +#[command(disable_version_flag = true)] +#[command(group( + ArgGroup::new("auth") + .args(&["token"]) + .multiple(false) +))] +#[command(group( + ArgGroup::new("output_control") + .args(&["quiet", "debug"]) + .multiple(false) +))] +#[non_exhaustive] pub struct Cli { - #[arg(help = "URL to analyze: user/repo, user/repo@branch, or full URL")] + #[arg(help = "Repository URL to analyze (user/repo, user/repo@branch, or full URL)")] pub url: Option, - #[arg(short, long, help = "Output format", value_enum)] - pub format: Option, + // Version + #[arg(short = 'v', long = "version", action = clap::ArgAction::Version, help = "Current version information")] + version: (), + + // Output Options + #[arg( + short = 'f', + long = "format", + help = "Output format", + value_enum, + default_value = "table" + )] + pub format: OutputFormat, - #[arg(long, help = "Show detailed file-by-file statistics")] + #[arg(long = "detailed", help = "Show detailed file-by-file statistics")] pub detailed: bool, - #[arg(short = 'd', long = "debug", help = "Enable debug output")] - pub debug: bool, + #[arg( + short = 'q', + long = "quiet", + help = "Quiet mode - suppress progress and minimize output" + )] + pub quiet: bool, + + #[arg(long = "no-progress", help = "Disable progress bar")] + pub no_progress: bool, - #[arg(long, help = "GitHub token for private repositories")] + #[arg(long = "no-color", help = "Disable colored output")] + pub no_color: bool, + + // Authentication + #[arg(long = "token", help = "Authentication token for private repositories")] pub token: Option, - #[arg(long, help = "Request timeout in seconds", default_value = "300")] + // Network Options + #[arg( + long = "timeout", + help = "Request timeout in seconds", + default_value = "300", + value_name = "SECONDS" + )] pub timeout: u64, - #[arg(long, help = "Allow insecure HTTP connections")] + #[arg(long = "allow-insecure", help = "Allow insecure HTTPS connections")] pub allow_insecure: bool, - #[arg(long, help = "Disable progress bar")] - pub no_progress: bool, + #[arg( + long = "user-agent", + help = "Custom User-Agent string", + value_name = "STRING" + )] + pub user_agent: Option, - #[arg(long, help = "Quiet mode - minimal output")] - pub quiet: bool, + #[arg( + long = "retry-count", + help = "Number of retry attempts for failed requests", + default_value = "3", + value_name = "COUNT" + )] + pub retry_count: u32, - #[arg(long, help = "Enable aggressive filtering for maximum performance")] + // Filtering Options + #[arg( + long = "aggressive-filter", + help = "Enable aggressive filtering for maximum performance" + )] pub aggressive_filter: bool, #[arg( - long, + long = "max-file-size", help = "Maximum file size to process in KB", - default_value = "1024" + default_value = "1024", + value_name = "KB" )] pub max_file_size: u64, - #[arg(long, help = "Include test directories")] + #[arg(long = "include-tests", help = "Include test directories in analysis")] pub include_tests: bool, - #[arg(long, help = "Include documentation directories")] + #[arg( + long = "include-docs", + help = "Include documentation directories in analysis" + )] pub include_docs: bool, + + #[arg(long = "include-hidden", help = "Include hidden files and directories")] + pub include_hidden: bool, + + #[arg( + long = "exclude-pattern", + help = "Exclude files matching this pattern (glob)", + value_name = "PATTERN" + )] + pub exclude_pattern: Option, + + #[arg( + long = "include-pattern", + help = "Only include files matching this pattern (glob)", + value_name = "PATTERN" + )] + pub include_pattern: Option, + + #[arg( + long = "min-file-size", + help = "Minimum file size to process in bytes", + default_value = "1", + value_name = "BYTES" + )] + pub min_file_size: u64, + + // Language Options + #[arg( + long = "language", + help = "Only analyze files of specific language", + value_name = "LANG" + )] + pub language: Option, + + #[arg( + long = "exclude-language", + help = "Exclude specific language from analysis", + value_name = "LANG" + )] + pub exclude_language: Vec, + + // Analysis Options + #[arg( + long = "ignore-whitespace", + help = "Ignore whitespace-only lines in code analysis" + )] + pub ignore_whitespace: bool, + + #[arg(long = "count-generated", help = "Include generated files in analysis")] + pub count_generated: bool, + + #[arg( + long = "max-line-length", + help = "Maximum line length to consider (0 = unlimited)", + default_value = "0", + value_name = "LENGTH" + )] + pub max_line_length: usize, + + // Debug and Logging + #[arg(short = 'd', long = "debug", help = "Enable debug output")] + pub debug: bool, + + #[arg(long = "trace", help = "Enable trace-level logging")] + pub trace: bool, + + #[arg(long = "log-file", help = "Write logs to file", value_name = "FILE")] + pub log_file: Option, + + // Advanced Options + #[arg( + long = "threads", + help = "Number of worker threads (0 = auto)", + default_value = "0", + value_name = "COUNT" + )] + pub threads: usize, + + #[arg( + long = "memory-limit", + help = "Memory limit in MB (0 = unlimited)", + default_value = "0", + value_name = "MB" + )] + pub memory_limit: usize, + + #[arg( + long = "cache-dir", + help = "Directory for caching downloaded files", + value_name = "DIR" + )] + pub cache_dir: Option, + + #[arg(long = "no-cache", help = "Disable caching of downloaded files")] + pub no_cache: bool, + + // Experimental Features + #[arg( + long = "experimental-parallel", + help = "Enable experimental parallel processing" + )] + pub experimental_parallel: bool, + + #[arg( + long = "experimental-streaming", + help = "Enable experimental streaming analysis" + )] + pub experimental_streaming: bool, } #[derive(Clone, ValueEnum)] pub enum OutputFormat { - #[value(name = "table")] + #[value(name = "table", help = "Human-readable table format")] Table, - #[value(name = "json")] + #[value(name = "json", help = "JSON format")] Json, - #[value(name = "csv")] + #[value(name = "csv", help = "CSV format")] Csv, - #[value(name = "xml")] + #[value(name = "xml", help = "XML format")] Xml, + #[value(name = "yaml", help = "YAML format")] + Yaml, + #[value(name = "toml", help = "TOML format")] + Toml, } impl Default for OutputFormat { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 56dda52..dcc7685 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -10,6 +10,8 @@ mod url_parser; #[cfg(feature = "cli")] use crate::core::*; #[cfg(feature = "cli")] +use crate::net::RemoteAnalyzer; +#[cfg(feature = "cli")] use clap::Parser; #[cfg(feature = "cli")] use std::time::Instant; @@ -21,10 +23,13 @@ pub use args::{Cli, OutputFormat}; pub async fn run() -> Result<()> { let cli = args::Cli::parse(); - if cli.debug { - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("debug")).init(); - } else { - env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); + init_logging(&cli)?; + + let mut cli = cli; + if let Ok(token) = std::env::var("BRADAR_TOKEN") { + if cli.token.is_none() { + cli.token = Some(token); + } } match &cli.url { @@ -36,11 +41,40 @@ pub async fn run() -> Result<()> { } } +#[cfg(feature = "cli")] +fn init_logging(cli: &Cli) -> Result<()> { + let log_level = if cli.trace { + "trace" + } else if cli.debug { + "debug" + } else { + "warn" + }; + + let mut builder = + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or(log_level)); + + if let Some(log_file) = &cli.log_file { + use std::fs::OpenOptions; + + let target = Box::new( + OpenOptions::new() + .create(true) + .append(true) + .open(log_file) + .map_err(|e| crate::core::error::AnalysisError::file_read(log_file, e))?, + ); + builder.target(env_logger::Target::Pipe(target)); + } + + builder.init(); + Ok(()) +} + #[cfg(feature = "cli")] async fn analyze_remote_archive(url: &str, cli: &Cli) -> Result<()> { - let format = cli.format.as_ref().unwrap_or(&OutputFormat::Table); let should_show_progress = - !cli.no_progress && matches!(format, OutputFormat::Table) && !cli.quiet; + !cli.no_progress && matches!(cli.format, OutputFormat::Table) && !cli.quiet; let processed_url = url_parser::expand_url(url); @@ -54,23 +88,20 @@ async fn analyze_remote_archive(url: &str, cli: &Cli) -> Result<()> { let mut analyzer = RemoteAnalyzer::new(); if let Some(token) = &cli.token { - analyzer.set_github_token(token); + let mut credentials = std::collections::HashMap::new(); + credentials.insert("token".to_string(), token.clone()); + analyzer.set_provider_credentials("github", credentials); } analyzer.set_timeout(cli.timeout); analyzer.set_allow_insecure(cli.allow_insecure); - analyzer.set_progress_bar(progress_bar.clone()); - if cli.aggressive_filter { - analyzer.set_aggressive_filtering(true); - } else { - let mut filter = filter::IntelligentFilter::default(); - filter.max_file_size = cli.max_file_size * 1024; - filter.ignore_test_dirs = !cli.include_tests; - filter.ignore_docs_dirs = !cli.include_docs; - analyzer.set_filter(filter); + if let Some(pb) = progress_bar.clone() { + analyzer.set_progress_hook(progress::ProgressBarHook::new(pb)); } + configure_analyzer_filters(&mut analyzer, cli)?; + let project_analysis = analyzer.analyze_url(&processed_url).await?; let elapsed = start_time.elapsed(); @@ -81,13 +112,40 @@ async fn analyze_remote_archive(url: &str, cli: &Cli) -> Result<()> { progress::show_completion_message(elapsed, cli.quiet); - match format { + output_results(&project_analysis, cli)?; + + Ok(()) +} + +#[cfg(feature = "cli")] +fn configure_analyzer_filters(analyzer: &mut RemoteAnalyzer, cli: &Cli) -> Result<()> { + if cli.aggressive_filter { + analyzer.set_aggressive_filtering(true); + } else { + let filter = filter::IntelligentFilter { + max_file_size: cli.max_file_size * 1024, + ignore_test_dirs: !cli.include_tests, + ignore_docs_dirs: !cli.include_docs, + ..filter::IntelligentFilter::default() + }; + + analyzer.set_filter(filter); + } + + Ok(()) +} + +#[cfg(feature = "cli")] +fn output_results(project_analysis: &analysis::ProjectAnalysis, cli: &Cli) -> Result<()> { + match cli.format { OutputFormat::Table => { - output::print_table_format(&project_analysis, cli.detailed, cli.quiet); + output::print_table_format(project_analysis, cli.detailed, cli.quiet); } - OutputFormat::Json => output::print_json_format(&project_analysis)?, - OutputFormat::Csv => output::print_csv_format(&project_analysis)?, - OutputFormat::Xml => output::print_xml_format(&project_analysis)?, + OutputFormat::Json => output::print_json_format(project_analysis)?, + OutputFormat::Csv => output::print_csv_format(project_analysis)?, + OutputFormat::Xml => output::print_xml_format(project_analysis)?, + OutputFormat::Yaml => output::print_yaml_format(project_analysis)?, + OutputFormat::Toml => output::print_toml_format(project_analysis)?, } Ok(()) diff --git a/src/cli/output.rs b/src/cli/output.rs index c1e4c47..b7106b6 100644 --- a/src/cli/output.rs +++ b/src/cli/output.rs @@ -2,6 +2,23 @@ use super::progress::format_number; use crate::core::{analysis::ProjectAnalysis, error::Result}; use colored::Colorize; +fn get_percentage_color(percentage: f64) -> colored::ColoredString { + let percentage_str = format!("{:.1}%", percentage); + if percentage >= 50.0 { + percentage_str.bright_green() + } else if percentage >= 10.0 { + percentage_str.yellow() + } else if percentage >= 1.0 { + percentage_str.white() + } else { + percentage_str.dimmed() + } +} + +fn color_number(num: usize) -> colored::ColoredString { + format_number(num).bright_white() +} + pub fn print_table_format(project_analysis: &ProjectAnalysis, detailed: bool, quiet: bool) { let summary = project_analysis.get_summary(); let language_stats = project_analysis.get_language_statistics(); @@ -14,59 +31,59 @@ pub fn print_table_format(project_analysis: &ProjectAnalysis, detailed: bool, qu println!( " {:<56} {}", "Total Files", - format_number(summary.total_files) + color_number(summary.total_files) ); println!( " {:<56} {}", "Total Lines", - format_number(summary.total_lines) + color_number(summary.total_lines) ); println!( " {:<56} {}", "Code Lines", - format_number(summary.total_code_lines) + color_number(summary.total_code_lines) ); println!( " {:<56} {}", "Comment Lines", - format_number(summary.total_comment_lines) + color_number(summary.total_comment_lines) ); println!( " {:<56} {}", "Blank Lines", - format_number(summary.total_blank_lines) + color_number(summary.total_blank_lines) ); println!( " {:<56} {}", "Languages", - format_number(summary.language_count) + color_number(summary.language_count) ); if let Some(ref primary) = summary.primary_language { println!(" {:<56} {}", "Primary Language", primary); } println!( - " {:<56} {:.1}%", + " {:<56} {}", "Code Ratio", - summary.overall_complexity_ratio * 100.0 + format!("{:.1}%", summary.overall_complexity_ratio * 100.0).bold() ); println!( - " {:<56} {:.1}%", + " {:<56} {}", "Documentation", - summary.overall_documentation_ratio * 100.0 + format!("{:.1}%", summary.overall_documentation_ratio * 100.0).bold() ); if !language_stats.is_empty() && !quiet { println!("{}", "=".repeat(80)); println!( - " {:<20} {:>8} {:>12} {:>8} {:>10} {:>8} {:>8}", + " {:<20} {:>8} {:>12} {:>8} {:>10} {:>8} {:>7}", "Language".bold(), "Files", "Lines", "Code", "Comments", "Blanks", - "Share%" + "%" ); println!("{}", "=".repeat(80)); @@ -78,27 +95,27 @@ pub fn print_table_format(project_analysis: &ProjectAnalysis, detailed: bool, qu }; println!( - " {:<20} {:>8} {:>12} {:>8} {:>10} {:>8} {:>7.1}%", + " {:<20} {:>8} {:>12} {:>8} {:>10} {:>8} {:>7}", stats.language_name, - format_number(stats.file_count), - format_number(stats.total_lines), - format_number(stats.code_lines), - format_number(stats.comment_lines), - format_number(stats.blank_lines), - share_percentage + color_number(stats.file_count), + color_number(stats.total_lines), + color_number(stats.code_lines), + color_number(stats.comment_lines), + color_number(stats.blank_lines), + get_percentage_color(share_percentage) ); } println!("{}", "=".repeat(80)); println!( - " {:<20} {:>8} {:>12} {:>8} {:>10} {:>8} {:>7.1}%", + " {:<20} {:>8} {:>12} {:>8} {:>10} {:>8} {:>7}", "Total".bold(), - format_number(summary.total_files), - format_number(summary.total_lines), - format_number(summary.total_code_lines), - format_number(summary.total_comment_lines), - format_number(summary.total_blank_lines), - 100.0 + color_number(summary.total_files), + color_number(summary.total_lines), + color_number(summary.total_code_lines), + color_number(summary.total_comment_lines), + color_number(summary.total_blank_lines), + "%" ); } @@ -114,9 +131,9 @@ pub fn print_table_format(project_analysis: &ProjectAnalysis, detailed: bool, qu println!( " {:<50} {:>6} lines ({} code, {} comments)", file.file_path, - format_number(file.total_lines), - format_number(file.code_lines), - format_number(file.comment_lines) + color_number(file.total_lines), + color_number(file.code_lines), + color_number(file.comment_lines) ); } } @@ -209,7 +226,7 @@ pub fn print_xml_format(project_analysis: &ProjectAnalysis) -> Result<()> { println!(" "); for stats in language_stats { println!(" "); - println!(" {}", xml_escape(&stats.language_name)); + println!(" {}", xml_escape(&stats.language_name)); println!(" {}", stats.file_count); println!(" {}", stats.total_lines); println!(" {}", stats.code_lines); @@ -230,6 +247,19 @@ pub fn print_xml_format(project_analysis: &ProjectAnalysis) -> Result<()> { Ok(()) } +pub fn print_yaml_format(project_analysis: &ProjectAnalysis) -> Result<()> { + let yaml = serde_yaml::to_string(project_analysis) + .map_err(|e| crate::core::error::AnalysisError::invalid_statistics(e.to_string()))?; + println!("{}", yaml); + Ok(()) +} + +pub fn print_toml_format(project_analysis: &ProjectAnalysis) -> Result<()> { + let toml = toml::to_string_pretty(project_analysis) + .map_err(|e| crate::core::error::AnalysisError::invalid_statistics(e.to_string()))?; + println!("{}", toml); + Ok(()) +} fn xml_escape(text: &str) -> String { text.replace("&", "&") .replace("<", "<") diff --git a/src/cli/progress.rs b/src/cli/progress.rs index 6a074b3..5a476ad 100644 --- a/src/cli/progress.rs +++ b/src/cli/progress.rs @@ -1,3 +1,4 @@ +use crate::net::traits::ProgressHook; use indicatif::{ProgressBar, ProgressStyle}; use std::time::Duration; @@ -9,10 +10,11 @@ pub fn create_progress_bar(show_progress: bool) -> Option { let pb = ProgressBar::new(0); pb.set_style( ProgressStyle::default_spinner() - .template("[{elapsed_precise}] {spinner:.green} {decimal_bytes_per_sec} {msg}") + .template("[{elapsed_precise}] {spinner:.green} {msg} ({decimal_bytes_per_sec})") .unwrap_or_else(|_| ProgressStyle::default_spinner()), ); pb.set_message("Preparing..."); + pb.enable_steady_tick(Duration::from_millis(120)); Some(pb) } @@ -60,3 +62,54 @@ pub fn format_bytes(bytes: u64) -> String { format!("{:.1} {}", size, UNITS[unit_index]) } } + +pub struct ProgressBarHook { + progress_bar: ProgressBar, +} + +impl ProgressBarHook { + pub fn new(progress_bar: ProgressBar) -> Self { + Self { progress_bar } + } +} + +impl ProgressHook for ProgressBarHook { + fn on_download_progress(&self, downloaded: u64, total: Option) { + if let Some(total_size) = total { + self.progress_bar.set_style( + ProgressStyle::default_bar() + .template("[{elapsed_precise}] [{wide_bar:.cyan/blue}] {decimal_bytes_per_sec} {binary_bytes}/{binary_total_bytes} ({eta}) {msg}") + .unwrap_or_else(|_| ProgressStyle::default_bar()) + .progress_chars("#>-"), + ); + self.progress_bar.set_length(total_size); + self.progress_bar.set_position(downloaded); + } else { + self.progress_bar.set_style( + ProgressStyle::default_spinner() + .template( + "[{elapsed_precise}] {spinner:.green} {msg} ({decimal_bytes_per_sec})", + ) + .unwrap_or_else(|_| ProgressStyle::default_spinner()), + ); + self.progress_bar.set_position(downloaded); + let formatted = format_bytes(downloaded); + self.progress_bar + .set_message(format!("Downloaded {}...", formatted)); + } + } + + fn on_processing_start(&self, message: &str) { + self.progress_bar.set_message(message.to_string()); + } + + fn on_processing_progress(&self, current: usize, total: usize) { + if total > 0 { + let percentage = (current * 100) / total; + self.progress_bar.set_message(format!( + "Processing files: {}% ({}/{})", + percentage, current, total + )); + } + } +} diff --git a/src/core/filter.rs b/src/core/filter.rs index 3855aae..62b7ff1 100644 --- a/src/core/filter.rs +++ b/src/core/filter.rs @@ -272,6 +272,12 @@ pub struct FilterStats { pub bytes_saved: u64, } +impl Default for FilterStats { + fn default() -> Self { + Self::new() + } +} + impl FilterStats { pub fn new() -> Self { Self { @@ -317,48 +323,3 @@ impl FilterStats { } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_default_filter() { - let filter = IntelligentFilter::default(); - - assert!(filter.should_process_file("src/main.rs", 1000)); - assert!(!filter.should_process_file(".hidden/file.rs", 1000)); - assert!(!filter.should_process_file("target/debug/main", 1000)); - assert!(!filter.should_process_file("file.exe", 1000)); - assert!(!filter.should_process_file("large_file.rs", 2 * 1024 * 1024)); - } - - #[test] - fn test_aggressive_filter() { - let filter = IntelligentFilter::aggressive(); - - assert!(filter.should_process_file("src/main.rs", 1000)); - assert!(!filter.should_process_file("README.md", 1000)); - assert!(!filter.should_process_file("tests/test.rs", 1000)); - assert!(!filter.should_process_file("docs/guide.rs", 1000)); - } - - #[test] - fn test_build_directory_detection() { - let filter = IntelligentFilter::default(); - - assert!(!filter.should_process_file("target/debug/main.rs", 1000)); - assert!(!filter.should_process_file("build/output/file.rs", 1000)); - assert!(!filter.should_process_file("dist/bundle.js", 1000)); - assert!(filter.should_process_file("src/target_parser.rs", 1000)); - } - - #[test] - fn test_package_directory_detection() { - let filter = IntelligentFilter::default(); - - assert!(!filter.should_process_file("node_modules/package/index.js", 1000)); - assert!(!filter.should_process_file("vendor/package/lib.php", 1000)); - assert!(filter.should_process_file("src/vendor_api.rs", 1000)); - } -} diff --git a/src/core/mod.rs b/src/core/mod.rs index 1dabc79..2525dc1 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,10 +1,8 @@ pub mod analysis; pub mod error; pub mod filter; -pub mod net; pub mod registry; pub use analysis::*; pub use error::*; -pub use net::*; pub use registry::*; diff --git a/src/core/net.rs b/src/core/net.rs deleted file mode 100644 index e3e83a7..0000000 --- a/src/core/net.rs +++ /dev/null @@ -1,1272 +0,0 @@ -use crate::core::{ - analysis::{FileMetrics, ProjectAnalysis}, - error::{AnalysisError, Result}, - filter::{FilterStats, IntelligentFilter}, - registry::LanguageRegistry, -}; -use flate2::read::GzDecoder; -use futures_util::StreamExt; -use reqwest::Client; -use serde::Deserialize; -use std::io::{Cursor, Read}; -use tar::Archive; - -#[cfg(not(target_arch = "wasm32"))] -use tokio::task; - -static USER_AGENT: &str = "bytes-radar/1.0.0"; - -#[derive(Deserialize)] -struct GitHubRepoInfo { - default_branch: String, -} - -#[cfg(feature = "cli")] -use indicatif::ProgressBar; - -pub struct RemoteAnalyzer { - client: Client, - github_token: Option, - timeout: u64, - allow_insecure: bool, - filter: IntelligentFilter, - #[cfg(feature = "cli")] - progress_bar: Option, -} - -impl RemoteAnalyzer { - pub fn new() -> Self { - let mut builder = Client::builder().user_agent(USER_AGENT); - - #[cfg(not(target_arch = "wasm32"))] - { - builder = builder.timeout(std::time::Duration::from_secs(300)); - } - - let client = builder.build().expect("Failed to create HTTP client"); - - Self { - client, - github_token: None, - timeout: 300, - allow_insecure: false, - filter: IntelligentFilter::default(), - #[cfg(feature = "cli")] - progress_bar: None, - } - } - - #[cfg(feature = "cli")] - pub fn set_progress_bar(&mut self, progress_bar: Option) { - self.progress_bar = progress_bar; - } - - pub fn set_github_token(&mut self, token: &str) { - self.github_token = Some(token.to_string()); - self.rebuild_client(); - } - - pub fn set_timeout(&mut self, timeout: u64) { - self.timeout = timeout; - self.rebuild_client(); - } - - pub fn set_allow_insecure(&mut self, allow_insecure: bool) { - self.allow_insecure = allow_insecure; - self.rebuild_client(); - } - - pub fn set_filter(&mut self, filter: IntelligentFilter) { - self.filter = filter; - } - - pub fn set_aggressive_filtering(&mut self, enabled: bool) { - if enabled { - self.filter = IntelligentFilter::aggressive(); - } else { - self.filter = IntelligentFilter::default(); - } - } - - fn rebuild_client(&mut self) { - let mut builder = Client::builder().user_agent(USER_AGENT); - - #[cfg(not(target_arch = "wasm32"))] - { - builder = builder.timeout(std::time::Duration::from_secs(self.timeout)); - } - - #[cfg(not(target_arch = "wasm32"))] - if self.allow_insecure { - builder = builder.danger_accept_invalid_certs(true); - } - - if let Some(token) = &self.github_token { - let mut headers = reqwest::header::HeaderMap::new(); - let auth_value = format!("token {}", token); - headers.insert( - reqwest::header::AUTHORIZATION, - auth_value.parse().expect("Invalid token format"), - ); - builder = builder.default_headers(headers); - } - - self.client = builder.build().expect("Failed to create HTTP client"); - } - - pub async fn analyze_url(&self, url: &str) -> Result { - let download_urls = self.resolve_git_url(url).await?; - - let mut url_errors: Vec = Vec::new(); - let mut total_attempts = 0u32; - for download_url in download_urls { - total_attempts += 1; - match self.analyze_tarball_with_name(&download_url, url).await { - Ok(analysis) => return Ok(analysis), - Err(e) => { - #[cfg(feature = "cli")] - log::debug!("Failed to download from {}: {}", download_url, e); - - let error_info = crate::core::error::DownloadUrlError { - url: download_url.clone(), - error_message: format!("{}", e), - error_type: match e { - AnalysisError::NetworkError { .. } => "NetworkError".to_string(), - AnalysisError::ArchiveError { .. } => "ArchiveError".to_string(), - _ => "UnknownError".to_string(), - }, - http_status_code: self.extract_http_status_code(&e), - retry_count: 1, - }; - - url_errors.push(error_info); - continue; - } - } - } - - Err(AnalysisError::network( - "All download URLs failed".to_string(), - )) - } - - async fn resolve_git_url(&self, url: &str) -> Result> { - if url.ends_with(".tar.gz") || url.ends_with(".tgz") { - return Ok(vec![url.to_string()]); - } - - let expanded_url = self.expand_url(url); - - if expanded_url.starts_with("http://") || expanded_url.starts_with("https://") { - if !expanded_url.contains("github.com") - && !expanded_url.contains("gitlab.com") - && !expanded_url.contains("gitlab.") - && !expanded_url.contains("bitbucket.org") - && !expanded_url.contains("codeberg.org") - { - if expanded_url.ends_with(".tar.gz") || expanded_url.ends_with(".tgz") { - return Ok(vec![expanded_url.to_string()]); - } else { - return Ok(vec![expanded_url.to_string()]); - } - } - } - - let mut download_urls = Vec::new(); - - if let Some(github_url) = self.parse_github_url_with_branch(&expanded_url) { - download_urls.push(github_url); - } - - if let Some(gitlab_url) = self.parse_gitlab_url_with_branch(&expanded_url) { - download_urls.push(gitlab_url); - } - - if let Some(bitbucket_url) = self.parse_bitbucket_url_with_branch(&expanded_url) { - download_urls.push(bitbucket_url); - } - - if let Some(codeberg_url) = self.parse_codeberg_url_with_branch(&expanded_url) { - download_urls.push(codeberg_url); - } - - if download_urls.is_empty() { - let mut branches = vec![ - "main".to_string(), - "master".to_string(), - "develop".to_string(), - "dev".to_string(), - ]; - - #[cfg(not(target_arch = "wasm32"))] - if expanded_url.contains("github.com") { - if let Some(default_branch) = self.get_github_default_branch(&expanded_url).await { - branches.insert(0, default_branch); - branches.dedup(); - } - } - - #[cfg(target_arch = "wasm32")] - if expanded_url.contains("github.com") { - branches = vec![ - "main".to_string(), - "master".to_string(), - "develop".to_string(), - "dev".to_string(), - ]; - } - - for branch in &branches { - if let Some(github_url) = self.parse_github_url(&expanded_url, branch) { - download_urls.push(github_url); - } - - if let Some(gitlab_url) = self.parse_gitlab_url(&expanded_url, branch) { - download_urls.push(gitlab_url); - } - - if let Some(bitbucket_url) = self.parse_bitbucket_url(&expanded_url, branch) { - download_urls.push(bitbucket_url); - } - - if let Some(codeberg_url) = self.parse_codeberg_url(&expanded_url, branch) { - download_urls.push(codeberg_url); - } - } - } - - if download_urls.is_empty() { - return Err(AnalysisError::url_parsing(format!( - "Unsupported URL format or no accessible branch found: {}. Please provide a direct tar.gz URL or a supported repository URL.", - expanded_url - ))); - } - - Ok(download_urls) - } - - fn parse_github_url_with_branch(&self, url: &str) -> Option { - if url.contains("github.com") { - if url.contains("/tree/") { - let parts: Vec<&str> = url.split('/').collect(); - if let Some(tree_pos) = parts.iter().position(|&x| x == "tree") { - if tree_pos + 1 < parts.len() && tree_pos >= 2 { - let owner = parts[tree_pos - 2]; - let repo = parts[tree_pos - 1]; - let branch = parts[tree_pos + 1]; - return Some(format!( - "https://github.com/{}/{}/archive/refs/heads/{}.tar.gz", - owner, repo, branch - )); - } - } - } - - if url.contains("/commit/") { - return self.extract_github_commit_url(url); - } - } - None - } - - fn parse_gitlab_url_with_branch(&self, url: &str) -> Option { - if url.contains("gitlab.com") || url.contains("gitlab.") { - if url.contains("/-/tree/") { - let parts: Vec<&str> = url.split('/').collect(); - if let Some(tree_pos) = parts.iter().position(|&x| x == "tree") { - if tree_pos + 1 < parts.len() && tree_pos >= 3 { - let gitlab_pos = parts.iter().position(|&x| x.contains("gitlab")).unwrap(); - let host = parts[gitlab_pos]; - let owner = parts[gitlab_pos + 1]; - let repo = parts[gitlab_pos + 2]; - let branch = parts[tree_pos + 1]; - return Some(format!( - "https://{}/{}{}/-/archive/{}/{}-{}.tar.gz", - host, - owner, - if parts.len() > gitlab_pos + 3 && parts[gitlab_pos + 3] != "-" { - format!("/{}", parts[gitlab_pos + 3..tree_pos - 1].join("/")) - } else { - String::new() - }, - branch, - repo, - branch - )); - } - } - } - } - None - } - - fn parse_bitbucket_url_with_branch(&self, url: &str) -> Option { - if url.contains("bitbucket.org") { - if url.contains("/commits/") { - let parts: Vec<&str> = url.split('/').collect(); - if let Some(commits_pos) = parts.iter().position(|&x| x == "commits") { - if commits_pos + 1 < parts.len() && commits_pos >= 2 { - let owner = parts[commits_pos - 2]; - let repo = parts[commits_pos - 1]; - let commit = parts[commits_pos + 1]; - return Some(format!( - "https://bitbucket.org/{}/{}/get/{}.tar.gz", - owner, repo, commit - )); - } - } - } - - if url.contains("/branch/") { - let parts: Vec<&str> = url.split('/').collect(); - if let Some(branch_pos) = parts.iter().position(|&x| x == "branch") { - if branch_pos + 1 < parts.len() && branch_pos >= 2 { - let owner = parts[branch_pos - 2]; - let repo = parts[branch_pos - 1]; - let branch = parts[branch_pos + 1]; - return Some(format!( - "https://bitbucket.org/{}/{}/get/{}.tar.gz", - owner, repo, branch - )); - } - } - } - } - None - } - - fn parse_codeberg_url_with_branch(&self, url: &str) -> Option { - if url.contains("codeberg.org") { - if url.contains("/commit/") { - let parts: Vec<&str> = url.split('/').collect(); - if let Some(commit_pos) = parts.iter().position(|&x| x == "commit") { - if commit_pos + 1 < parts.len() && commit_pos >= 2 { - let owner = parts[commit_pos - 2]; - let repo = parts[commit_pos - 1]; - let commit = parts[commit_pos + 1]; - return Some(format!( - "https://codeberg.org/{}/{}/archive/{}.tar.gz", - owner, repo, commit - )); - } - } - } - - if url.contains("/src/branch/") { - let parts: Vec<&str> = url.split('/').collect(); - if let Some(branch_pos) = parts.iter().position(|&x| x == "branch") { - if branch_pos + 1 < parts.len() && branch_pos >= 3 { - let owner = parts[branch_pos - 3]; - let repo = parts[branch_pos - 2]; - let branch = parts[branch_pos + 1]; - return Some(format!( - "https://codeberg.org/{}/{}/archive/{}.tar.gz", - owner, repo, branch - )); - } - } - } - } - None - } - - fn parse_bitbucket_url(&self, url: &str, branch: &str) -> Option { - if url.contains("bitbucket.org") { - let parts: Vec<&str> = url.split('/').collect(); - if let Some(bitbucket_pos) = parts.iter().position(|&x| x == "bitbucket.org") { - if bitbucket_pos + 2 < parts.len() { - let owner = parts[bitbucket_pos + 1]; - let repo = parts[bitbucket_pos + 2]; - return Some(format!( - "https://bitbucket.org/{}/{}/get/{}.tar.gz", - owner, repo, branch - )); - } - } - } - None - } - - fn parse_codeberg_url(&self, url: &str, branch: &str) -> Option { - if url.contains("codeberg.org") { - let parts: Vec<&str> = url.split('/').collect(); - if let Some(codeberg_pos) = parts.iter().position(|&x| x == "codeberg.org") { - if codeberg_pos + 2 < parts.len() { - let owner = parts[codeberg_pos + 1]; - let repo = parts[codeberg_pos + 2]; - return Some(format!( - "https://codeberg.org/{}/{}/archive/{}.tar.gz", - owner, repo, branch - )); - } - } - } - None - } - - async fn check_url_exists(&self, url: &str) -> bool { - if let Ok(response) = self.client.head(url).send().await { - response.status().is_success() - } else { - false - } - } - - fn extract_http_status_code(&self, error: &AnalysisError) -> Option { - match error { - AnalysisError::NetworkError { message } => { - if message.contains("HTTP request failed with status: ") { - if let Some(start) = message.find("HTTP request failed with status: ") { - let status_start = start + "HTTP request failed with status: ".len(); - let status_str = &message[status_start..]; - if let Some(end) = status_str.find(' ') { - status_str[..end].parse().ok() - } else { - status_str.parse().ok() - } - } else { - None - } - } else { - None - } - } - _ => None, - } - } - - fn expand_url(&self, url: &str) -> String { - if url.starts_with("http://") || url.starts_with("https://") { - return url.to_string(); - } - - if url.contains('/') && !url.starts_with("http://") && !url.starts_with("https://") { - let parts: Vec<&str> = url.split('@').collect(); - let repo_part = parts[0]; - let branch_or_commit = parts.get(1); - - let path_parts: Vec<&str> = repo_part.split('/').collect(); - if path_parts.len() == 2 { - if let Some(branch) = branch_or_commit { - if branch.len() >= 7 && branch.chars().all(|c| c.is_ascii_hexdigit()) { - return format!("https://github.com/{}/commit/{}", repo_part, branch); - } else { - return format!("https://github.com/{}/tree/{}", repo_part, branch); - } - } else { - return format!("https://github.com/{}", repo_part); - } - } - } - - url.to_string() - } - - #[cfg(not(target_arch = "wasm32"))] - async fn get_github_default_branch(&self, url: &str) -> Option { - let (owner, repo) = self.extract_github_owner_repo(url)?; - - let api_url = format!("https://api.github.com/repos/{}/{}", owner, repo); - - match self.client.get(&api_url).send().await { - Ok(response) => { - if response.status().is_success() { - match response.json::().await { - Ok(repo_info) => { - #[cfg(feature = "cli")] - log::debug!( - "GitHub API: Found default branch '{}' for {}/{}", - repo_info.default_branch, - owner, - repo - ); - Some(repo_info.default_branch) - } - Err(_) => { - #[cfg(feature = "cli")] - log::debug!( - "GitHub API: Failed to parse response for {}/{}", - owner, - repo - ); - None - } - } - } else { - #[cfg(feature = "cli")] - log::debug!( - "GitHub API: Request failed with status {} for {}/{}", - response.status(), - owner, - repo - ); - None - } - } - Err(_) => { - #[cfg(feature = "cli")] - log::debug!("GitHub API: Network error for {}/{}", owner, repo); - None - } - } - } - - fn extract_github_owner_repo(&self, url: &str) -> Option<(String, String)> { - let url = url.trim_end_matches('/'); - - if let Some(github_url) = url.strip_prefix("https://github.com/") { - let parts: Vec<&str> = github_url.split('/').collect(); - if parts.len() >= 2 { - return Some((parts[0].to_string(), parts[1].to_string())); - } - } - - if url.contains("github.com") { - let parts: Vec<&str> = url.split('/').collect(); - if let Some(github_pos) = parts.iter().position(|&x| x == "github.com") { - if github_pos + 2 < parts.len() { - return Some(( - parts[github_pos + 1].to_string(), - parts[github_pos + 2].to_string(), - )); - } - } - } - - let parts: Vec<&str> = url.split('@').collect(); - let repo_part = parts[0]; - let path_parts: Vec<&str> = repo_part.split('/').collect(); - if path_parts.len() == 2 { - return Some((path_parts[0].to_string(), path_parts[1].to_string())); - } - - None - } - - fn parse_github_url(&self, url: &str, branch: &str) -> Option { - let url = url.trim_end_matches('/'); - - if url.contains("github.com") { - if let Some(commit_url) = self.extract_github_commit_url(url) { - return Some(commit_url); - } - - if let Some(repo_url) = self.extract_github_repo_url(url, branch) { - return Some(repo_url); - } - } - - None - } - - fn extract_github_commit_url(&self, url: &str) -> Option { - if url.contains("/commit/") { - let parts: Vec<&str> = url.split('/').collect(); - if let Some(commit_pos) = parts.iter().position(|&x| x == "commit") { - if commit_pos + 1 < parts.len() { - let owner = parts.get(parts.len() - 4)?; - let repo = parts.get(parts.len() - 3)?; - let commit = parts.get(commit_pos + 1)?; - return Some(format!( - "https://github.com/{}/{}/archive/{}.tar.gz", - owner, repo, commit - )); - } - } - } - None - } - - fn extract_github_repo_url(&self, url: &str, branch: &str) -> Option { - let parts: Vec<&str> = url.split('/').collect(); - if parts.len() >= 2 && parts.contains(&"github.com") { - if let Some(github_pos) = parts.iter().position(|&x| x == "github.com") { - if github_pos + 2 < parts.len() { - let owner = parts[github_pos + 1]; - let repo = parts[github_pos + 2]; - return Some(format!( - "https://github.com/{}/{}/archive/refs/heads/{}.tar.gz", - owner, repo, branch - )); - } - } - } - None - } - - fn parse_gitlab_url(&self, url: &str, branch: &str) -> Option { - let url = url.trim_end_matches('/'); - - if url.contains("gitlab.com") || url.contains("gitlab.") { - let parts: Vec<&str> = url.split('/').collect(); - if let Some(gitlab_pos) = parts.iter().position(|&x| x.contains("gitlab")) { - if gitlab_pos + 2 < parts.len() { - let host = parts[gitlab_pos]; - let owner = parts[gitlab_pos + 1]; - let repo = parts[gitlab_pos + 2]; - return Some(format!( - "https://{}/{}{}/-/archive/{}/{}-{}.tar.gz", - host, - owner, - if parts.len() > gitlab_pos + 3 { - format!("/{}", parts[gitlab_pos + 3..].join("/")) - } else { - String::new() - }, - branch, - repo, - branch - )); - } - } - } - - None - } - - async fn analyze_tarball_with_name( - &self, - download_url: &str, - original_url: &str, - ) -> Result { - let project_name = self.extract_project_name_from_original(original_url); - let mut project_analysis = ProjectAnalysis::new(project_name); - - let response = self - .client - .get(download_url) - .send() - .await - .map_err(|e| AnalysisError::network(format!("Failed to fetch URL: {}", e)))?; - - if !response.status().is_success() { - return Err(AnalysisError::network(format!( - "HTTP request failed with status: {}", - response.status() - ))); - } - - let total_size = response.content_length(); - - #[cfg(feature = "cli")] - if let Some(pb) = &self.progress_bar { - if let Some(size) = total_size { - use indicatif::ProgressStyle; - pb.set_style( - ProgressStyle::default_bar() - .template("[{elapsed_precise}] [{wide_bar:.cyan/blue}] {decimal_bytes_per_sec} {binary_bytes}/{binary_total_bytes} ({eta}) {msg}") - .unwrap_or_else(|_| ProgressStyle::default_bar()) - .progress_chars("#>-"), - ); - pb.set_length(size); - pb.set_message("Downloading and processing..."); - } else { - pb.set_message("Downloading and processing..."); - pb.enable_steady_tick(std::time::Duration::from_millis(120)); - } - } - - #[cfg(target_arch = "wasm32")] - { - let bytes = response - .bytes() - .await - .map_err(|e| AnalysisError::network(format!("Failed to read response: {}", e)))?; - - #[cfg(feature = "cli")] - if let Some(pb) = &self.progress_bar { - pb.set_message("Processing archive..."); - } - - self.process_tarball_bytes(&bytes, &mut project_analysis) - .await?; - } - - #[cfg(not(target_arch = "wasm32"))] - { - let stream = response.bytes_stream(); - let stream_reader = StreamReader::new( - stream, - #[cfg(feature = "cli")] - self.progress_bar.clone(), - total_size, - ); - - #[cfg(feature = "cli")] - if let Some(pb) = &self.progress_bar { - pb.set_message("Processing archive..."); - } - - self.process_tarball_stream(stream_reader, &mut project_analysis) - .await?; - } - - Ok(project_analysis) - } - - async fn process_tarball_stream( - &self, - stream_reader: StreamReader, - project_analysis: &mut ProjectAnalysis, - ) -> Result<()> { - #[cfg(not(target_arch = "wasm32"))] - { - let filter = self.filter.clone(); - let metrics_result = task::spawn_blocking(move || { - let decoder = GzDecoder::new(stream_reader); - let mut archive = Archive::new(decoder); - - let entries = archive.entries().map_err(|e| { - AnalysisError::archive(format!("Failed to read tar entries: {}", e)) - })?; - - let mut collected_metrics = Vec::new(); - let mut stats = FilterStats::new(); - - for entry in entries { - let entry = entry.map_err(|e| { - AnalysisError::archive(format!("Failed to read tar entry: {}", e)) - })?; - - if let Ok(metrics) = Self::process_tar_entry_sync(entry, &filter, &mut stats) { - collected_metrics.push(metrics); - } - } - - #[cfg(feature = "cli")] - log::info!( - "Filter stats: processed {}/{} files ({:.1}% filtered), saved {}", - stats.processed, - stats.total_entries, - stats.filter_ratio() * 100.0, - stats.format_bytes_saved() - ); - - Ok::, AnalysisError>(collected_metrics) - }) - .await - .map_err(|e| AnalysisError::archive(format!("Task join error: {}", e)))??; - - for metrics in metrics_result { - project_analysis.add_file_metrics(metrics)?; - } - } - - #[cfg(target_arch = "wasm32")] - { - let decoder = GzDecoder::new(stream_reader); - let mut archive = Archive::new(decoder); - - let entries = archive.entries().map_err(|e| { - AnalysisError::archive(format!("Failed to read tar entries: {}", e)) - })?; - - let mut stats = FilterStats::new(); - - for entry in entries { - let entry = entry.map_err(|e| { - AnalysisError::archive(format!("Failed to read tar entry: {}", e)) - })?; - - if let Ok(metrics) = Self::process_tar_entry_sync(entry, &self.filter, &mut stats) { - project_analysis.add_file_metrics(metrics)?; - } - } - - web_sys::console::log_1( - &format!( - "Filter stats: processed {}/{} files ({:.1}% filtered), saved {}", - stats.processed, - stats.total_entries, - stats.filter_ratio() * 100.0, - stats.format_bytes_saved() - ) - .into(), - ); - } - - Ok(()) - } - - #[cfg(target_arch = "wasm32")] - async fn process_tarball_bytes( - &self, - bytes: &bytes::Bytes, - project_analysis: &mut ProjectAnalysis, - ) -> Result<()> { - let cursor = Cursor::new(bytes.as_ref()); - let decoder = GzDecoder::new(cursor); - let mut archive = Archive::new(decoder); - - let entries = archive - .entries() - .map_err(|e| AnalysisError::archive(format!("Failed to read tar entries: {}", e)))?; - - let mut stats = FilterStats::new(); - - for entry in entries { - let entry = entry - .map_err(|e| AnalysisError::archive(format!("Failed to read tar entry: {}", e)))?; - - if let Ok(metrics) = Self::process_tar_entry_sync(entry, &self.filter, &mut stats) { - project_analysis.add_file_metrics(metrics)?; - } - } - - web_sys::console::log_1( - &format!( - "Filter stats: processed {}/{} files ({:.1}% filtered), saved {}", - stats.processed, - stats.total_entries, - stats.filter_ratio() * 100.0, - stats.format_bytes_saved() - ) - .into(), - ); - - Ok(()) - } - - fn process_tar_entry_sync( - mut entry: tar::Entry<'_, R>, - filter: &IntelligentFilter, - stats: &mut FilterStats, - ) -> Result { - let header = entry.header(); - let path = header - .path() - .map_err(|e| AnalysisError::archive(format!("Invalid path in tar entry: {}", e)))?; - - let file_path = path.to_string_lossy().to_string(); - - if !header.entry_type().is_file() || header.size().unwrap_or(0) == 0 { - return Err(AnalysisError::archive("Not a file or empty".to_string())); - } - - let file_size = header.size().unwrap_or(0); - - let should_process = filter.should_process_file(&file_path, file_size); - stats.record_entry(file_size, !should_process); - - if !should_process { - return Err(AnalysisError::archive("File filtered out".to_string())); - } - - let language = LanguageRegistry::detect_by_path(&file_path) - .map(|l| l.name.clone()) - .unwrap_or_else(|| "Text".to_string()); - - let mut content = String::new(); - if entry.read_to_string(&mut content).is_err() { - return Err(AnalysisError::archive( - "Failed to read file content".to_string(), - )); - } - - analyze_file_content(&file_path, &content, &language, file_size) - } - - fn extract_project_name_from_original(&self, url: &str) -> String { - if url.starts_with("http://") || url.starts_with("https://") { - let url = url.trim_end_matches('/'); - - if url.contains("/tree/") { - let parts: Vec<&str> = url.split('/').collect(); - if let Some(tree_pos) = parts.iter().position(|&x| x == "tree") { - if tree_pos > 1 { - let repo = parts[tree_pos - 1]; - let branch = parts.get(tree_pos + 1).unwrap_or(&"unknown"); - return format!("{}@{}", repo, branch); - } - } - } - - if url.contains("/commit/") { - let parts: Vec<&str> = url.split('/').collect(); - if let Some(commit_pos) = parts.iter().position(|&x| x == "commit") { - if commit_pos > 1 { - let repo = parts[commit_pos - 1]; - let commit = parts.get(commit_pos + 1).unwrap_or(&"unknown"); - return format!("{}@{}", repo, &commit[..7.min(commit.len())]); - } - } - } - - let parts: Vec<&str> = url.split('/').collect(); - if parts.len() >= 2 { - let repo = parts[parts.len() - 1]; - return format!("{}@main", repo); - } - } else if url.contains('/') && !url.contains('.') { - let parts: Vec<&str> = url.split('@').collect(); - let repo_part = parts[0]; - let branch = parts.get(1).unwrap_or(&"main"); - - if let Some(repo_name) = repo_part.split('/').last() { - return format!("{}@{}", repo_name, branch); - } - } - - "remote-project".to_string() - } - - #[allow(dead_code)] - fn extract_project_name(&self, url: &str) -> String { - let url_path = url.trim_end_matches('/'); - - if let Some(filename) = url_path.split('/').last() { - if filename.ends_with(".tar.gz") { - return filename.trim_end_matches(".tar.gz").to_string(); - } - if filename.ends_with(".tgz") { - return filename.trim_end_matches(".tgz").to_string(); - } - return filename.to_string(); - } - - "remote-project".to_string() - } - - fn format_bytes_simple(bytes: u64) -> String { - const UNITS: &[&str] = &["B", "KiB", "MiB", "GiB", "TiB"]; - const THRESHOLD: f64 = 1024.0; - - if bytes == 0 { - return "0 B".to_string(); - } - - let mut size = bytes as f64; - let mut unit_index = 0; - - while size >= THRESHOLD && unit_index < UNITS.len() - 1 { - size /= THRESHOLD; - unit_index += 1; - } - - if unit_index == 0 { - format!("{} {}", bytes, UNITS[unit_index]) - } else { - format!("{:.1} {}", size, UNITS[unit_index]) - } - } -} - -impl Default for RemoteAnalyzer { - fn default() -> Self { - Self::new() - } -} - -fn analyze_file_content( - file_path: &str, - content: &str, - language: &str, - file_size: u64, -) -> Result { - let lines: Vec<&str> = content.lines().collect(); - let total_lines = lines.len(); - - let mut code_lines = 0; - let mut comment_lines = 0; - let mut blank_lines = 0; - - let lang_def = LanguageRegistry::get_language(language); - let empty_line_comments = vec![]; - let empty_multi_line_comments = vec![]; - let line_comments = lang_def - .map(|l| &l.line_comments) - .unwrap_or(&empty_line_comments); - let multi_line_comments = lang_def - .map(|l| &l.multi_line_comments) - .unwrap_or(&empty_multi_line_comments); - - let mut in_multi_line_comment = false; - - for line in lines { - let trimmed = line.trim(); - - if trimmed.is_empty() { - blank_lines += 1; - continue; - } - - let mut is_comment = false; - - if !in_multi_line_comment { - for comment_start in line_comments { - if trimmed.starts_with(comment_start) { - is_comment = true; - break; - } - } - - for (start, end) in multi_line_comments { - if trimmed.starts_with(start) { - is_comment = true; - if !trimmed.ends_with(end) { - in_multi_line_comment = true; - } - break; - } - } - } else { - is_comment = true; - for (_, end) in multi_line_comments { - if trimmed.ends_with(end) { - in_multi_line_comment = false; - break; - } - } - } - - if is_comment { - comment_lines += 1; - } else { - code_lines += 1; - } - } - - let metrics = FileMetrics::new( - file_path, - language.to_string(), - total_lines, - code_lines, - comment_lines, - blank_lines, - )? - .with_size_bytes(file_size); - - Ok(metrics) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_github_url_parsing() { - let analyzer = RemoteAnalyzer::new(); - - assert_eq!( - analyzer.parse_github_url("https://github.com/user/repo", "main"), - Some("https://github.com/user/repo/archive/refs/heads/main.tar.gz".to_string()) - ); - - assert_eq!( - analyzer.parse_github_url("https://github.com/user/repo/commit/abc123", "main"), - Some("https://github.com/user/repo/archive/abc123.tar.gz".to_string()) - ); - } - - #[test] - fn test_bitbucket_url_parsing() { - let analyzer = RemoteAnalyzer::new(); - - assert_eq!( - analyzer.parse_bitbucket_url("https://bitbucket.org/user/repo", "main"), - Some("https://bitbucket.org/user/repo/get/main.tar.gz".to_string()) - ); - } - - #[test] - fn test_codeberg_url_parsing() { - let analyzer = RemoteAnalyzer::new(); - - assert_eq!( - analyzer.parse_codeberg_url("https://codeberg.org/user/repo", "main"), - Some("https://codeberg.org/user/repo/archive/main.tar.gz".to_string()) - ); - } - - #[test] - fn test_extract_project_name() { - let analyzer = RemoteAnalyzer::new(); - - assert_eq!( - analyzer.extract_project_name("https://example.com/project.tar.gz"), - "project" - ); - - assert_eq!( - analyzer.extract_project_name("https://github.com/user/repo/archive/main.tar.gz"), - "main" - ); - } -} - -use tokio::sync::mpsc; - -struct StreamReader { - receiver: mpsc::Receiver>, - current_chunk: Option>, - finished: bool, -} - -impl StreamReader { - #[cfg(not(target_arch = "wasm32"))] - fn new( - stream: impl futures_util::Stream> + Send + 'static, - #[cfg(feature = "cli")] progress_bar: Option, - total_size: Option, - ) -> Self { - let (tx, rx) = mpsc::channel(32); - - tokio::spawn(async move { - let mut downloaded = 0u64; - let mut stream = Box::pin(stream); - - while let Some(chunk_result) = stream.next().await { - match chunk_result { - Ok(chunk) => { - downloaded += chunk.len() as u64; - - #[cfg(feature = "cli")] - if let Some(pb) = &progress_bar { - if let Some(_total) = total_size { - pb.set_position(downloaded); - } else { - let formatted = RemoteAnalyzer::format_bytes_simple(downloaded); - pb.set_message(format!("Downloaded {}...", formatted)); - } - } - - if tx.send(Ok(chunk)).await.is_err() { - break; - } - } - Err(e) => { - let _ = tx - .send(Err(std::io::Error::new( - std::io::ErrorKind::Other, - format!("Stream error: {}", e), - ))) - .await; - break; - } - } - } - }); - - Self { - receiver: rx, - current_chunk: None, - finished: false, - } - } - - #[cfg(target_arch = "wasm32")] - fn new( - stream: impl futures_util::Stream> + 'static, - #[cfg(feature = "cli")] _progress_bar: Option, - _total_size: Option, - ) -> Self { - let (tx, rx) = mpsc::channel(32); - - wasm_bindgen_futures::spawn_local(async move { - let mut downloaded = 0u64; - let mut stream = Box::pin(stream); - - while let Some(chunk_result) = stream.next().await { - match chunk_result { - Ok(chunk) => { - downloaded += chunk.len() as u64; - - #[cfg(feature = "cli")] - if let Some(pb) = &_progress_bar { - if let Some(_total) = _total_size { - pb.set_position(downloaded); - } else { - let formatted = RemoteAnalyzer::format_bytes_simple(downloaded); - pb.set_message(format!("Downloaded {}...", formatted)); - } - } - - if tx.send(Ok(chunk)).await.is_err() { - break; - } - } - Err(e) => { - let _ = tx - .send(Err(std::io::Error::new( - std::io::ErrorKind::Other, - format!("Stream error: {}", e), - ))) - .await; - break; - } - } - } - }); - - Self { - receiver: rx, - current_chunk: None, - finished: false, - } - } -} - -impl Read for StreamReader { - fn read(&mut self, buf: &mut [u8]) -> std::io::Result { - if let Some(ref mut cursor) = self.current_chunk { - let read = cursor.read(buf)?; - if read > 0 { - return Ok(read); - } - self.current_chunk = None; - } - - if self.finished { - return Ok(0); - } - - match self.receiver.try_recv() { - Ok(Ok(chunk)) => { - self.current_chunk = Some(Cursor::new(chunk)); - if let Some(ref mut cursor) = self.current_chunk { - cursor.read(buf) - } else { - Ok(0) - } - } - Ok(Err(e)) => { - self.finished = true; - Err(e) - } - Err(mpsc::error::TryRecvError::Empty) => { - #[cfg(not(target_arch = "wasm32"))] - { - match self.receiver.blocking_recv() { - Some(Ok(chunk)) => { - self.current_chunk = Some(Cursor::new(chunk)); - if let Some(ref mut cursor) = self.current_chunk { - cursor.read(buf) - } else { - Ok(0) - } - } - Some(Err(e)) => { - self.finished = true; - Err(e) - } - None => { - self.finished = true; - Ok(0) - } - } - } - #[cfg(target_arch = "wasm32")] - { - Err(std::io::Error::new( - std::io::ErrorKind::WouldBlock, - "Would block in WASM", - )) - } - } - Err(mpsc::error::TryRecvError::Disconnected) => { - self.finished = true; - Ok(0) - } - } - } -} diff --git a/src/core/registry.rs b/src/core/registry.rs index a1e255d..cd52042 100644 --- a/src/core/registry.rs +++ b/src/core/registry.rs @@ -4,8 +4,9 @@ use std::collections::HashMap; use std::fmt::{Display, Formatter}; use std::path::Path; -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub enum LanguageType { + #[default] Programming, Markup, Data, @@ -14,12 +15,6 @@ pub enum LanguageType { Other, } -impl Default for LanguageType { - fn default() -> Self { - LanguageType::Programming - } -} - impl Display for LanguageType { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { match self { @@ -33,18 +28,13 @@ impl Display for LanguageType { } } -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] pub enum LineCommentPosition { + #[default] Any, Start, } -impl Default for LineCommentPosition { - fn default() -> Self { - LineCommentPosition::Any - } -} - #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LanguageDefinition { #[serde(default)] diff --git a/src/languages.json b/src/languages.json index 3df3db4..e913624 100644 --- a/src/languages.json +++ b/src/languages.json @@ -59,7 +59,10 @@ "Asn1": { "name": "ASN.1", "line_comment": ["--"], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "multi_line_comments": [["/*", "*/"]], "extensions": ["asn1"] }, @@ -70,7 +73,10 @@ }, "AspNet": { "name": "ASP.NET", - "multi_line_comments": [[""], ["<%--", "-->"]], + "multi_line_comments": [ + [""], + ["<%--", "-->"] + ], "extensions": [ "asax", "ascx", @@ -83,7 +89,10 @@ }, "Assembly": { "line_comment": [";"], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["asm"] }, "AssemblyGAS": { @@ -95,20 +104,21 @@ }, "Astro": { "line_comment": ["//"], - "multi_line_comments": [["/*", "*/"], [""]], + "multi_line_comments": [ + ["/*", "*/"], + [""] + ], "extensions": ["astro"] }, "Ats": { "name": "ATS", "line_comment": ["//"], - "multi_line_comments": [["(*", "*)"], ["/*", "*/"]], + "multi_line_comments": [ + ["(*", "*)"], + ["/*", "*/"] + ], "quotes": [["\\\"", "\\\""]], - "extensions": [ - "dats", - "hats", - "sats", - "atxt" - ] + "extensions": ["dats", "hats", "sats", "atxt"] }, "Autoconf": { "line_comment": ["#", "dnl"], @@ -116,7 +126,10 @@ }, "Autoit": { "line_comment": [";"], - "multi_line_comments": [["#comments-start", "#comments-end"], ["#cs", "#ce"]], + "multi_line_comments": [ + ["#comments-start", "#comments-end"], + ["#cs", "#ce"] + ], "extensions": ["au3"] }, "AutoHotKey": { @@ -145,7 +158,10 @@ "name": "BASH", "shebangs": ["#!/bin/bash"], "line_comment": ["#"], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "env": ["bash"], "extensions": ["bash"] }, @@ -155,8 +171,14 @@ }, "Bazel": { "line_comment": ["#"], - "doc_quotes": [["\\\"\\\"\\\"", "\\\"\\\"\\\""], ["'''", "'''"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "doc_quotes": [ + ["\\\"\\\"\\\"", "\\\"\\\"\\\""], + ["'''", "'''"] + ], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["bzl", "bazel", "bzlmod"], "filenames": ["build", "workspace", "module"] }, @@ -165,23 +187,32 @@ "quotes": [["\\\"", "\\\""]], "extensions": ["bean", "beancount"] }, - "Bicep" : { + "Bicep": { "line_comment": ["//"], "multi_line_comments": [["/*", "*/"]], - "quotes": [["'", "'"], ["'''", "'''"]], + "quotes": [ + ["'", "'"], + ["'''", "'''"] + ], "extensions": ["bicep", "bicepparam"] }, "Bitbake": { "name": "Bitbake", "line_comment": ["#"], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["bb", "bbclass", "bbappend", "inc"] }, "Bqn": { "name": "BQN", "line_comment": ["#"], "extensions": ["bqn"], - "quotes": [["\\\"", "\\\""], ["'", "'"]] + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ] }, "BrightScript": { "quotes": [["\\\"", "\\\""]], @@ -212,27 +243,45 @@ "line_comment": ["//"], "multi_line_comments": [["/*", "*/"]], "nested": true, - "quotes": [["\\\"", "\\\""],["\\\"\\\"\\\"", "\\\"\\\"\\\""]], - "verbatim_quotes": [["#\\\"", "\\\"#"],["##\\\"", "\\\"##"],["###\\\"", "\\\"###"], - ["#'", "'#"],["##'", "'##"],["###'", "'###"]], + "quotes": [ + ["\\\"", "\\\""], + ["\\\"\\\"\\\"", "\\\"\\\"\\\""] + ], + "verbatim_quotes": [ + ["#\\\"", "\\\"#"], + ["##\\\"", "\\\"##"], + ["###\\\"", "\\\"###"], + ["#'", "'#"], + ["##'", "'##"], + ["###'", "'###"] + ], "extensions": ["cj"] }, "Cassius": { "line_comment": ["//"], "multi_line_comments": [["/*", "*/"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["cassius"] }, "Ceylon": { "line_comment": ["//"], "multi_line_comments": [["/*", "*/"]], - "quotes": [["\\\"", "\\\""], ["\\\"\\\"\\\"", "\\\"\\\"\\\""]], + "quotes": [ + ["\\\"", "\\\""], + ["\\\"\\\"\\\"", "\\\"\\\"\\\""] + ], "extensions": ["ceylon"] }, "Chapel": { "line_comment": ["//"], "multi_line_comments": [["/*", "*/"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["chpl"] }, "CHeader": { @@ -288,7 +337,10 @@ "CoffeeScript": { "line_comment": ["#"], "multi_line_comments": [["###", "###"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["coffee", "cjsx"] }, "Cogent": { @@ -297,7 +349,10 @@ }, "ColdFusion": { "multi_line_comments": [[""]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["cfm"] }, "ColdFusionScript": { @@ -330,7 +385,10 @@ "Crystal": { "line_comment": ["#"], "shebangs": ["#!/usr/bin/crystal"], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "env": ["crystal"], "extensions": ["cr"] }, @@ -353,7 +411,10 @@ "name": "CSS", "line_comment": ["//"], "multi_line_comments": [["/*", "*/"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "mime": ["text/css"], "extensions": ["css"] }, @@ -377,15 +438,24 @@ }, "Cython": { "line_comment": ["#"], - "doc_quotes": [["\\\"\\\"\\\"", "\\\"\\\"\\\""], ["'''", "'''"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "doc_quotes": [ + ["\\\"\\\"\\\"", "\\\"\\\"\\\""], + ["'''", "'''"] + ], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "env": ["cython"], "extensions": ["pyx", "pxd", "pxi"] }, "D": { "line_comment": ["//"], "multi_line_comments": [["/*", "*/"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "nested_comments": [["/+", "+/"]], "extensions": ["d"] }, @@ -419,18 +489,24 @@ "quotes": [["\\\"", "\\\""]], "extensions": ["dts", "dtsi"] }, - "Dhall":{ + "Dhall": { "nested": true, "line_comment": ["--"], "multi_line_comments": [["{-", "-}"]], - "quotes": [["\\\"", "\\\""], ["''", "''"]], + "quotes": [ + ["\\\"", "\\\""], + ["''", "''"] + ], "extensions": ["dhall"] }, "Dockerfile": { "line_comment": ["#"], "extensions": ["dockerfile", "dockerignore"], "filenames": ["dockerfile"], - "quotes": [["\\\"", "\\\""], ["'", "'"]] + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ] }, "DotNetResource": { "name": ".NET Resource", @@ -444,7 +520,11 @@ "multi_line_comments": [["/*", "*/"]], "nested": true, "extensions": ["dm", "dme"], - "quotes": [["\\\"", "\\\""], ["{\\\"", "\\\"}"], ["'", "'"]] + "quotes": [ + ["\\\"", "\\\""], + ["{\\\"", "\\\"}"], + ["'", "'"] + ] }, "Dust": { "name": "Dust.js", @@ -453,19 +533,29 @@ }, "Ebuild": { "line_comment": ["#"], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["ebuild", "eclass"] }, "EdgeQL": { "name": "EdgeQL", "line_comment": ["#"], - "quotes": [["'", "'"], ["\\\"", "\\\""], ["$", "$"]], + "quotes": [ + ["'", "'"], + ["\\\"", "\\\""], + ["$", "$"] + ], "extensions": ["edgeql"] }, "ESDL": { "name": "EdgeDB Schema Definition", "line_comment": ["#"], - "quotes": [["'", "'"], ["\\\"", "\\\""]], + "quotes": [ + ["'", "'"], + ["\\\"", "\\\""] + ], "extensions": ["esdl"] }, "Edn": { @@ -503,7 +593,10 @@ }, "Elvish": { "line_comment": ["#"], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "env": ["elvish"], "extensions": ["elv"] }, @@ -514,7 +607,11 @@ }, "Emojicode": { "line_comment": ["💭"], - "multi_line_comments": [["💭🔜", "🔚💭"], ["📗", "📗"], ["📘", "📘"]], + "multi_line_comments": [ + ["💭🔜", "🔚💭"], + ["📗", "📗"], + ["📘", "📘"] + ], "quotes": [["❌🔤", "❌🔤"]], "extensions": ["emojic", "🍇"] }, @@ -523,16 +620,16 @@ "extensions": ["erl", "hrl"] }, "Factor": { - "line_comment": ["!", "#!"], - "multi_line_comments": [["/*", "*/"]], - "extensions": ["factor"] + "line_comment": ["!", "#!"], + "multi_line_comments": [["/*", "*/"]], + "extensions": ["factor"] }, "FEN": { "name": "FEN", "blank": true, "extensions": ["fen"] }, - "Fennel" : { + "Fennel": { "line_comment": [";", ";;"], "quotes": [["\\\"", "\\\""]], "extensions": ["fnl"] @@ -540,7 +637,10 @@ "Fish": { "shebangs": ["#!/bin/fish"], "line_comment": ["#"], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "env": ["fish"], "extensions": ["fish"] }, @@ -576,7 +676,10 @@ "FortranLegacy": { "name": "FORTRAN Legacy", "line_comment": ["c", "C", "!", "*"], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["f", "for", "ftn", "f77", "pfo"] }, "FortranModern": { @@ -637,16 +740,30 @@ "GlimmerJs": { "name": "Glimmer JS", "line_comment": ["//"], - "multi_line_comments": [["/*", "*/"], [""]], - "quotes": [["\\\"", "\\\""], ["'", "'"], ["`", "`"]], + "multi_line_comments": [ + ["/*", "*/"], + [""] + ], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"], + ["`", "`"] + ], "important_syntax": [""]], - "quotes": [["\\\"", "\\\""], ["'", "'"], ["`", "`"]], + "multi_line_comments": [ + ["/*", "*/"], + [""] + ], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"], + ["`", "`"] + ], "important_syntax": [""], ["{{/*", "*/}}"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "multi_line_comments": [ + [""], + ["{{/*", "*/}}"] + ], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["gohtml"] }, "Graphql": { "name": "GraphQL", - "quotes": [["\\\"", "\\\""], ["\\\"\\\"\\\"", "\\\"\\\"\\\""]], + "quotes": [ + ["\\\"", "\\\""], + ["\\\"\\\"\\\"", "\\\"\\\"\\\""] + ], "line_comment": ["#"], "extensions": ["gql", "graphql"] }, @@ -696,20 +838,32 @@ }, "Haml": { "line_comment": ["-#"], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["haml"] }, "Hamlet": { "multi_line_comments": [[""]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["hamlet"] }, "Happy": { "extensions": ["y", "ly"] }, "Handlebars": { - "multi_line_comments": [[""], ["{{!", "}}"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "multi_line_comments": [ + [""], + ["{{!", "}}"] + ], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["hbs", "handlebars"] }, "Haskell": { @@ -721,7 +875,10 @@ "Haxe": { "line_comment": ["//"], "multi_line_comments": [["/*", "*/"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["hx"] }, "Hcl": { @@ -758,12 +915,15 @@ "line_comment": ["//"], "multi_line_comments": [["/*", "*/"]], "quotes": [["\\\"", "\\\""]], - "extensions": ["HC", "hc","ZC","zc"] + "extensions": ["HC", "hc", "ZC", "zc"] }, "Html": { "name": "HTML", "multi_line_comments": [[""]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "kind": "html", "important_syntax": [""], ["{% comment %}", "{% endcomment %}"]] + "multi_line_comments": [ + [""], + ["{% comment %}", "{% endcomment %}"] + ] }, "LinguaFranca": { "name": "Lingua Franca", @@ -1003,12 +1218,18 @@ "LiveScript": { "line_comment": ["#"], "multi_line_comments": [["/*", "*/"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["ls"] }, "LLVM": { "line_comment": [";"], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["ll"] }, "Logtalk": { @@ -1027,13 +1248,19 @@ "Lua": { "line_comment": ["--"], "multi_line_comments": [["--[[", "]]"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["lua", "luau"] }, "Lucius": { "line_comment": ["//"], "multi_line_comments": [["/*", "*/"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["lucius"] }, "M4": { @@ -1077,7 +1304,10 @@ }, "Meson": { "line_comment": ["#"], - "quotes": [["'", "'"], ["'''", "'''"]], + "quotes": [ + ["'", "'"], + ["'''", "'''"] + ], "filenames": ["meson.build", "meson_options.txt"] }, "Metal": { @@ -1109,8 +1339,14 @@ }, "Mojo": { "line_comment": ["#"], - "doc_quotes": [["\\\"\\\"\\\"", "\\\"\\\"\\\""], ["'''", "'''"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "doc_quotes": [ + ["\\\"\\\"\\\"", "\\\"\\\"\\\""], + ["'''", "'''"] + ], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["mojo", "🔥"] }, "MonkeyC": { @@ -1127,18 +1363,27 @@ }, "MoonScript": { "line_comment": ["--"], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["moon"] }, "MsBuild": { "name": "MSBuild", "multi_line_comments": [[""]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["csproj", "vbproj", "fsproj", "props", "targets"] }, "Mustache": { "multi_line_comments": [["{{!", "}}"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["mustache"] }, "Nextflow": { @@ -1149,7 +1394,10 @@ }, "Nim": { "line_comment": ["#"], - "quotes": [["\\\"", "\\\""], ["\\\"\\\"\\\"", "\\\"\\\"\\\""]], + "quotes": [ + ["\\\"", "\\\""], + ["\\\"\\\"\\\"", "\\\"\\\"\\\""] + ], "extensions": ["nim"] }, "Nix": { @@ -1162,13 +1410,19 @@ "name": "Not Quite Perl", "line_comment": ["#"], "multi_line_comments": [["=begin", "=end"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["nqp"] }, "NuGetConfig": { "name": "NuGet Config", "multi_line_comments": [[""]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "filenames": ["nuget.config", "packages.config", "nugetdefaults.config"] }, "Nushell": { @@ -1202,19 +1456,28 @@ "extensions": ["odin"], "line_comment": ["//"], "multi_line_comments": [["/*", "*/"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]] + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ] }, "OpenScad": { "name": "OpenSCAD", "extensions": ["scad"], "line_comment": ["//"], "multi_line_comments": [["/*", "*/"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]] + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ] }, "OpenPolicyAgent": { "name": "Open Policy Agent", "line_comment": ["#"], - "quotes": [["\\\"","\\\""], ["`", "`"]], + "quotes": [ + ["\\\"", "\\\""], + ["`", "`"] + ], "extensions": ["rego"] }, "OpenCL": { @@ -1246,18 +1509,27 @@ "PacmanMakepkg": { "name": "Pacman's makepkg", "line_comment": ["#"], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "filenames": ["pkgbuild"] }, "Pan": { "line_comment": ["#"], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["pan", "tpl"] }, "Pascal": { "nested": true, "line_comment": ["//"], - "multi_line_comments": [["{", "}"], ["(*", "*)"]], + "multi_line_comments": [ + ["{", "}"], + ["(*", "*)"] + ], "quotes": [["'", "'"]], "extensions": ["pas"] }, @@ -1265,27 +1537,45 @@ "shebangs": ["#!/usr/bin/perl"], "line_comment": ["#"], "multi_line_comments": [["=pod", "=cut"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["pl", "pm"] }, "Pest": { "line_comment": ["//"], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["pest"] }, "Phix": { "line_comment": ["--", "//", "#!"], - "multi_line_comments": [["/*", "*/"], ["--/*", "--*/"]], + "multi_line_comments": [ + ["/*", "*/"], + ["--/*", "--*/"] + ], "nested": true, - "quotes": [["\\\"", "\\\""], ["'", "'"]], - "verbatim_quotes": [["\\\"\\\"\\\"", "\\\"\\\"\\\""], ["`", "`"]], - "extensions": ["e","exw"] + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], + "verbatim_quotes": [ + ["\\\"\\\"\\\"", "\\\"\\\"\\\""], + ["`", "`"] + ], + "extensions": ["e", "exw"] }, "Php": { "name": "PHP", "line_comment": ["#", "//"], "multi_line_comments": [["/*", "*/"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["php"] }, "PlantUml": { @@ -1296,9 +1586,9 @@ "extensions": ["puml"] }, "Po": { - "name": "PO File", - "line_comment": ["#"], - "extensions": ["po", "pot"] + "name": "PO File", + "line_comment": ["#"], + "extensions": ["po", "pot"] }, "Poke": { "multi_line_comments": [["/*", "*/"]], @@ -1306,7 +1596,10 @@ }, "Polly": { "multi_line_comments": [[""]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["polly"] }, "Pony": { @@ -1320,7 +1613,10 @@ "name": "PostCSS", "line_comment": ["//"], "multi_line_comments": [["/*", "*/"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["pcss", "sss"] }, "PowerShell": { @@ -1364,7 +1660,7 @@ "line_comment": ["//"], "extensions": ["proto"] }, - "Pug" : { + "Pug": { "line_comment": ["//", "//-"], "quotes": [ ["#{\\\"", "\\\"}"], @@ -1375,7 +1671,10 @@ }, "Puppet": { "line_comment": ["#"], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["pp"] }, "PureScript": { @@ -1387,21 +1686,33 @@ "Pyret": { "line_comment": ["#"], "multi_line_comments": [["#|", "|#"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["arr"], "nested": true }, "Python": { "line_comment": ["#"], - "doc_quotes": [["\\\"\\\"\\\"", "\\\"\\\"\\\""], ["'''", "'''"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "doc_quotes": [ + ["\\\"\\\"\\\"", "\\\"\\\"\\\""], + ["'''", "'''"] + ], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "env": ["python", "python2", "python3"], "mime": ["text/x-python"], "extensions": ["py", "pyw", "pyi"] }, "PRQL": { "line_comment": ["#"], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "mime": ["application/prql"], "extensions": ["prql"] }, @@ -1423,7 +1734,10 @@ "name": "QML", "line_comment": ["//"], "multi_line_comments": [["/*", "*/"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["qml"] }, "R": { @@ -1440,7 +1754,10 @@ "Rakefile": { "line_comment": ["#"], "multi_line_comments": [["=begin", "=end"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "filenames": ["rakefile"], "extensions": ["rake"] }, @@ -1454,7 +1771,10 @@ ["#`「", "」"] ], "nested": true, - "quotes": [["\\\"", "\\\""] , ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "verbatim_quotes": [["「", "」"]], "doc_quotes": [ ["#|{", "}"], @@ -1483,7 +1803,11 @@ }, "Razor": { "line_comment": ["//"], - "multi_line_comments": [[""], ["@*", "*@"], ["/*", "*/"]], + "multi_line_comments": [ + [""], + ["@*", "*@"], + ["/*", "*/"] + ], "quotes": [["\\\"", "\\\""]], "verbatim_quotes": [["@\\\"", "\\\""]], "extensions": ["cshtml", "razor"] @@ -1499,7 +1823,11 @@ "Renpy": { "name": "Ren'Py", "line_comment": ["#"], - "quotes": [["\\\"", "\\\""], ["'", "'"], ["`", "`"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"], + ["`", "`"] + ], "extensions": ["rpy"] }, "ReScript": { @@ -1537,7 +1865,10 @@ "Ruby": { "line_comment": ["#"], "multi_line_comments": [["=begin", "=end"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "env": ["ruby"], "extensions": ["rb"] }, @@ -1545,7 +1876,10 @@ "name": "Ruby HTML", "multi_line_comments": [[""]], "important_syntax": [""], ["${", "}"]], + "quotes": [ + ["\\\"", "\\\""], + ["$[", "]"], + ["$<", ">"], + ["${", "}"] + ], "extensions": ["str"] }, "Stylus": { "line_comment": ["//"], "multi_line_comments": [["/*", "*/"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["styl"] }, "Svelte": { "multi_line_comments": [[""]], "important_syntax": [""]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "mime": ["image/svg+xml"], "extensions": ["svg"] }, @@ -1743,19 +2119,35 @@ "Tcl": { "name": "TCL", "line_comment": ["#"], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["tcl"] }, "Tera": { - "multi_line_comments": [[""], ["{#", "#}"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "multi_line_comments": [ + [""], + ["{#", "#}"] + ], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["tera"] }, "Templ": { "name": "Templ", "line_comment": ["//"], - "multi_line_comments": [[""], ["/*", "*/"]], - "quotes": [["\\\"", "\\\""], ["'", "'"], ["`", "`"]], + "multi_line_comments": [ + [""], + ["/*", "*/"] + ], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"], + ["`", "`"] + ], "important_syntax": ["templ", "script", "css"], "extensions": ["templ", "tmpl"] }, @@ -1773,7 +2165,10 @@ "Thrift": { "line_comment": ["#", "//"], "multi_line_comments": [["/*", "*/"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["thrift"] }, "Toml": { @@ -1791,7 +2186,11 @@ "name": "TSX", "line_comment": ["//"], "multi_line_comments": [["/*", "*/"]], - "quotes": [["\\\"", "\\\""], ["'", "'"], ["`", "`"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"], + ["`", "`"] + ], "extensions": ["tsx"] }, "Ttcn": { @@ -1803,14 +2202,24 @@ }, "Twig": { "name": "Twig", - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["twig"], - "multi_line_comments": [[""], ["{#", "#}"]] + "multi_line_comments": [ + [""], + ["{#", "#}"] + ] }, "TypeScript": { "line_comment": ["//"], "multi_line_comments": [["/*", "*/"]], - "quotes": [["\\\"", "\\\""], ["'", "'"], ["`", "`"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"], + ["`", "`"] + ], "extensions": ["ts", "mts", "cts"] }, "Typst": { @@ -1905,7 +2314,10 @@ "line_comment": ["##"], "multi_line_comments": [["#*", "*#"]], "extensions": ["vm"], - "quotes": [["'", "'"], ["\\\"", "\\\""]] + "quotes": [ + ["'", "'"], + ["\\\"", "\\\""] + ] }, "Verilog": { "line_comment": ["//"], @@ -1938,7 +2350,10 @@ "VisualStudioProject": { "name": "Visual Studio Project", "multi_line_comments": [[""]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["vcproj", "vcxproj"] }, "VisualStudioSolution": { @@ -1949,26 +2364,42 @@ "VimScript": { "name": "Vim Script", "line_comment": ["\\\""], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["vim"] }, "Vue": { "name": "Vue", "line_comment": ["//"], - "multi_line_comments": [[""], ["/*", "*/"]], - "quotes": [["\\\"", "\\\""], ["'", "'"], ["`", "`"]], + "multi_line_comments": [ + [""], + ["/*", "*/"] + ], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"], + ["`", "`"] + ], "important_syntax": [""]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["xaml"] }, "XcodeConfig": { "name": "Xcode Config", "line_comment": ["//"], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["xcconfig"] }, "Xml": { "name": "XML", "multi_line_comments": [[""]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["xml"] }, "XSL": { "name": "XSL", "multi_line_comments": [[""]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["xsl", "xslt"] }, "Xtend": { "line_comment": ["//"], "multi_line_comments": [["/*", "*/"]], - "quotes": [["\\\"", "\\\""], ["'", "'"], ["'''", "'''"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"], + ["'''", "'''"] + ], "extensions": ["xtend"] }, "Yaml": { "name": "YAML", "line_comment": ["#"], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["yaml", "yml"] }, "ZenCode": { "line_comment": ["//", "#"], "multi_line_comments": [["/*", "*/"]], - "quotes": [["\\\"", "\\\""], ["'", "'"]], - "verbatim_quotes": [["@\\\"", "\\\""], ["@'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], + "verbatim_quotes": [ + ["@\\\"", "\\\""], + ["@'", "'"] + ], "extensions": ["zs"] }, "Zig": { @@ -2037,7 +2493,10 @@ "Zsh": { "shebangs": ["#!/bin/zsh"], "line_comment": ["#"], - "quotes": [["\\\"", "\\\""], ["'", "'"]], + "quotes": [ + ["\\\"", "\\\""], + ["'", "'"] + ], "extensions": ["zsh"] }, "GdShader": { @@ -2046,4 +2505,4 @@ "multi_line_comments": [["/*", "*/"]], "extensions": ["gdshader"] } -} \ No newline at end of file +} diff --git a/src/lib.rs b/src/lib.rs index d2a64c6..5c1c0f8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,9 @@ pub mod worker; pub mod core; +pub mod net; pub use core::*; +pub use net::RemoteAnalyzer; #[cfg(feature = "cli")] pub mod cli; diff --git a/src/net/mod.rs b/src/net/mod.rs new file mode 100644 index 0000000..7c40d71 --- /dev/null +++ b/src/net/mod.rs @@ -0,0 +1,528 @@ +pub mod providers; +pub mod stream; +pub mod traits; + +use crate::core::{analysis::ProjectAnalysis, error::Result, filter::IntelligentFilter}; +use providers::*; +use reqwest::Client; +use std::collections::HashMap; +use std::sync::Arc; +use traits::{GitProvider, NoOpProgressHook}; + +pub use traits::{ParsedRepository, ProgressHook, ProviderConfig}; + +/// Remote repository analyzer with comprehensive configuration support +/// +/// The RemoteAnalyzer supports multiple Git hosting providers and allows +/// extensive customization of HTTP requests, authentication, and processing behavior. +/// +/// # Examples +/// +/// ```rust +/// use bytes_radar::net::{RemoteAnalyzer, ProviderConfig}; +/// +/// // Basic usage +/// let mut analyzer = RemoteAnalyzer::new(); +/// +/// // With custom configuration +/// let config = ProviderConfig::new() +/// .with_timeout(120) +/// .with_header("X-Custom-Header", "value") +/// .with_credential("token", "your-token"); +/// +/// analyzer.set_global_config(config); +/// ``` +pub struct RemoteAnalyzer { + providers: Vec>, + global_config: ProviderConfig, + filter: IntelligentFilter, + progress_hook: Arc, + provider_configs: HashMap, +} + +impl RemoteAnalyzer { + /// Create a new analyzer with default configuration + pub fn new() -> Self { + let mut analyzer = Self { + providers: Vec::new(), + global_config: ProviderConfig::default(), + filter: IntelligentFilter::default(), + progress_hook: Arc::new(NoOpProgressHook), + provider_configs: HashMap::new(), + }; + + analyzer.register_default_providers(); + analyzer + } + + /// Register all default Git providers + fn register_default_providers(&mut self) { + self.providers.push(Box::new(GitHubProvider::new())); + self.providers.push(Box::new(GitLabProvider::new())); + self.providers.push(Box::new(BitbucketProvider::new())); + self.providers.push(Box::new(CodebergProvider::new())); + self.providers.push(Box::new(GiteaProvider::new())); + self.providers.push(Box::new(SourceForgeProvider::new())); + self.providers.push(Box::new(AzureDevOpsProvider::new())); + self.providers.push(Box::new(ArchiveProvider::new())); + } + + /// Set a progress hook for monitoring operations + /// + /// # Arguments + /// * `hook` - Progress hook implementation + /// + /// # Examples + /// ```rust + /// use bytes_radar::net::{RemoteAnalyzer, ProgressHook}; + /// + /// struct MyHook; + /// impl ProgressHook for MyHook { + /// fn on_download_progress(&self, downloaded: u64, total: Option) { + /// println!("Downloaded: {} bytes", downloaded); + /// } + /// fn on_processing_start(&self, message: &str) { + /// println!("Processing: {}", message); + /// } + /// fn on_processing_progress(&self, current: usize, total: usize) { + /// println!("Progress: {}/{}", current, total); + /// } + /// } + /// + /// let mut analyzer = RemoteAnalyzer::new(); + /// analyzer.set_progress_hook(MyHook); + /// ``` + pub fn set_progress_hook(&mut self, hook: H) { + self.progress_hook = Arc::new(hook); + } + + /// Set global configuration that applies to all providers + /// + /// # Arguments + /// * `config` - Global configuration + /// + /// # Examples + /// ```rust + /// use bytes_radar::net::{RemoteAnalyzer, ProviderConfig}; + /// + /// let config = ProviderConfig::new() + /// .with_timeout(300) + /// .with_user_agent("my-app/1.0.0") + /// .with_header("X-API-Key", "secret"); + /// + /// let mut analyzer = RemoteAnalyzer::new(); + /// analyzer.set_global_config(config); + /// ``` + pub fn set_global_config(&mut self, config: ProviderConfig) { + self.global_config = config; + self.apply_config_to_providers(); + } + + /// Set configuration for a specific provider + /// + /// # Arguments + /// * `provider_name` - Name of the provider (e.g., "github", "gitlab") + /// * `config` - Provider-specific configuration + /// + /// # Examples + /// ```rust + /// use bytes_radar::net::{RemoteAnalyzer, ProviderConfig}; + /// + /// let github_config = ProviderConfig::new() + /// .with_credential("token", "github-token") + /// .with_header("Accept", "application/vnd.github.v3+json"); + /// + /// let mut analyzer = RemoteAnalyzer::new(); + /// analyzer.set_provider_config("github", github_config); + /// ``` + pub fn set_provider_config(&mut self, provider_name: &str, config: ProviderConfig) { + self.provider_configs + .insert(provider_name.to_string(), config); + self.apply_config_to_providers(); + } + + /// Apply configurations to all providers + fn apply_config_to_providers(&mut self) { + for provider in &mut self.providers { + let provider_name = provider.name(); + + // Start with global config + let mut config = self.global_config.clone(); + + // Override with provider-specific config if exists + if let Some(provider_config) = self.provider_configs.get(provider_name) { + // Merge configurations (provider-specific takes precedence) + config.headers.extend(provider_config.headers.clone()); + config + .credentials + .extend(provider_config.credentials.clone()); + config + .provider_settings + .extend(provider_config.provider_settings.clone()); + + if provider_config.timeout.is_some() { + config.timeout = provider_config.timeout; + } + if provider_config.max_redirects.is_some() { + config.max_redirects = provider_config.max_redirects; + } + if provider_config.user_agent.is_some() { + config.user_agent = provider_config.user_agent.clone(); + } + if provider_config.max_file_size.is_some() { + config.max_file_size = provider_config.max_file_size; + } + if provider_config.proxy.is_some() { + config.proxy = provider_config.proxy.clone(); + } + + config.accept_invalid_certs = provider_config.accept_invalid_certs; + config.use_compression = provider_config.use_compression; + } + + provider.apply_config(&config); + } + } + + /// Set file filtering configuration + /// + /// # Arguments + /// * `filter` - File filter configuration + pub fn set_filter(&mut self, filter: IntelligentFilter) { + self.filter = filter; + } + + /// Enable or disable aggressive file filtering + /// + /// # Arguments + /// * `enabled` - Whether to enable aggressive filtering + pub fn set_aggressive_filtering(&mut self, enabled: bool) { + if enabled { + self.filter = IntelligentFilter::aggressive(); + } else { + self.filter = IntelligentFilter::default(); + } + } + + // Legacy methods for backward compatibility + + /// Set timeout for all providers (legacy method) + /// + /// # Arguments + /// * `timeout` - Timeout in seconds + pub fn set_timeout(&mut self, timeout: u64) { + self.global_config.timeout = Some(timeout); + self.apply_config_to_providers(); + } + + /// Set whether to accept invalid SSL certificates (legacy method) + /// + /// # Arguments + /// * `allow_insecure` - Whether to accept invalid certificates + pub fn set_allow_insecure(&mut self, allow_insecure: bool) { + self.global_config.accept_invalid_certs = allow_insecure; + self.apply_config_to_providers(); + } + + /// Set credentials for a specific provider (legacy method) + /// + /// # Arguments + /// * `provider_name` - Name of the provider + /// * `credentials` - Credentials map + pub fn set_provider_credentials( + &mut self, + provider_name: &str, + credentials: HashMap, + ) { + let config = self + .provider_configs + .entry(provider_name.to_string()) + .or_insert_with(ProviderConfig::default); + + config.credentials.extend(credentials); + self.apply_config_to_providers(); + } + + /// Analyze a repository from its URL + /// + /// # Arguments + /// * `url` - Repository URL or shorthand notation + /// + /// # Examples + /// ```rust,no_run + /// use bytes_radar::net::RemoteAnalyzer; + /// + /// #[tokio::main] + /// async fn main() -> Result<(), Box> { + /// let analyzer = RemoteAnalyzer::new(); + /// + /// // Full URLs + /// let analysis = analyzer.analyze_url("https://github.com/user/repo").await?; + /// + /// // Shorthand notation + /// let analysis = analyzer.analyze_url("user/repo@main").await?; + /// + /// // Direct archive + /// let analysis = analyzer.analyze_url("https://example.com/project.tar.gz").await?; + /// + /// Ok(()) + /// } + /// ``` + pub async fn analyze_url(&self, url: &str) -> Result { + let expanded_url = self.expand_url(url); + + // Try direct archive first for better performance + if expanded_url.ends_with(".tar.gz") || expanded_url.ends_with(".tgz") { + return self.analyze_direct_tarball(&expanded_url).await; + } + + // Try each provider + for provider in &self.providers { + if provider.can_handle(&expanded_url) { + if let Some(parsed) = provider.parse_url(&expanded_url) { + match self.analyze_with_provider(provider.as_ref(), &parsed).await { + Ok(analysis) => return Ok(analysis), + Err(e) => { + #[cfg(feature = "cli")] + log::debug!( + "Provider {} failed for {}: {}", + provider.name(), + expanded_url, + e + ); + continue; + } + } + } + } + } + + Err(crate::core::error::AnalysisError::url_parsing(format!( + "Unsupported URL format: {}. Supported formats include GitHub, GitLab, Bitbucket, Codeberg, Gitea, SourceForge, Azure DevOps, and direct archive URLs.", + expanded_url + ))) + } + + /// Analyze using a specific provider + async fn analyze_with_provider( + &self, + provider: &dyn GitProvider, + parsed: &ParsedRepository, + ) -> Result { + let mut download_urls = provider.build_download_urls(parsed); + + // If no URLs and no specific branch/commit, try common branches + if download_urls.is_empty() && parsed.branch_or_commit.is_none() { + let mut branches = vec![ + "main".to_string(), + "master".to_string(), + "develop".to_string(), + "dev".to_string(), + ]; + + // Try to get default branch from API + #[cfg(not(target_arch = "wasm32"))] + { + let config = self.get_effective_config(provider.name()); + if let Ok(client) = provider.build_client(&config) { + if let Some(default_branch) = provider.get_default_branch(&client, parsed).await + { + branches.insert(0, default_branch); + branches.dedup(); + } + } + } + + // Generate URLs for each branch + for branch in branches { + let mut branch_parsed = parsed.clone(); + branch_parsed.branch_or_commit = Some(branch); + download_urls.extend(provider.build_download_urls(&branch_parsed)); + } + } + + // Try each download URL + for download_url in download_urls { + match self + .analyze_direct_tarball_with_name(&download_url, &parsed.project_name) + .await + { + Ok(analysis) => return Ok(analysis), + Err(e) => { + #[cfg(feature = "cli")] + log::debug!("Failed to download from {}: {}", download_url, e); + continue; + } + } + } + + Err(crate::core::error::AnalysisError::network( + "All download URLs failed".to_string(), + )) + } + + /// Get effective configuration for a provider + fn get_effective_config(&self, provider_name: &str) -> ProviderConfig { + let mut config = self.global_config.clone(); + + if let Some(provider_config) = self.provider_configs.get(provider_name) { + // Merge configurations + config.headers.extend(provider_config.headers.clone()); + config + .credentials + .extend(provider_config.credentials.clone()); + config + .provider_settings + .extend(provider_config.provider_settings.clone()); + + if provider_config.timeout.is_some() { + config.timeout = provider_config.timeout; + } + if provider_config.max_redirects.is_some() { + config.max_redirects = provider_config.max_redirects; + } + if provider_config.user_agent.is_some() { + config.user_agent = provider_config.user_agent.clone(); + } + if provider_config.max_file_size.is_some() { + config.max_file_size = provider_config.max_file_size; + } + if provider_config.proxy.is_some() { + config.proxy = provider_config.proxy.clone(); + } + + config.accept_invalid_certs = provider_config.accept_invalid_certs; + config.use_compression = provider_config.use_compression; + } + + config + } + + /// Analyze a direct archive URL + async fn analyze_direct_tarball(&self, url: &str) -> Result { + let project_name = self.extract_project_name_from_url(url); + self.analyze_direct_tarball_with_name(url, &project_name) + .await + } + + /// Analyze a direct archive URL with custom project name + async fn analyze_direct_tarball_with_name( + &self, + url: &str, + project_name: &str, + ) -> Result { + let mut project_analysis = ProjectAnalysis::new(project_name); + + // Use global config to build client for direct downloads + let client = self.build_global_client()?; + + let response = client.get(url).send().await.map_err(|e| { + crate::core::error::AnalysisError::network(format!("Failed to fetch URL: {}", e)) + })?; + + if !response.status().is_success() { + return Err(crate::core::error::AnalysisError::network(format!( + "HTTP request failed with status: {}", + response.status() + ))); + } + + let total_size = response.content_length(); + self.progress_hook.on_download_progress(0, total_size); + + let stream = response.bytes_stream(); + let progress_hook = Arc::clone(&self.progress_hook); + let stream_reader = stream::StreamReader::new( + stream, + Box::new(move |downloaded, total| { + progress_hook.on_download_progress(downloaded, total); + log::debug!( + "Downloaded: {} bytes of {} total", + downloaded, + total + .map(|t| t.to_string()) + .unwrap_or_else(|| "unknown".to_string()) + ); + }), + total_size, + ); + + self.progress_hook.on_processing_start("Processing..."); + stream::process_tarball_stream( + stream_reader, + &mut project_analysis, + &self.filter, + self.progress_hook.as_ref(), + ) + .await?; + + Ok(project_analysis) + } + + /// Build HTTP client using global configuration + fn build_global_client(&self) -> Result { + // Use archive provider to build client (it has good defaults) + let archive_provider = ArchiveProvider::new(); + archive_provider + .build_client(&self.global_config) + .map_err(|e| { + crate::core::error::AnalysisError::network(format!( + "Failed to build HTTP client: {}", + e + )) + }) + } + + /// Expand shorthand URLs to full URLs + fn expand_url(&self, url: &str) -> String { + if url.starts_with("http://") || url.starts_with("https://") { + return url.to_string(); + } + + // Handle shorthand notation like "user/repo@branch" + if url.contains('/') && !url.starts_with("http://") && !url.starts_with("https://") { + let parts: Vec<&str> = url.split('@').collect(); + let repo_part = parts[0]; + let branch_or_commit = parts.get(1); + + let path_parts: Vec<&str> = repo_part.split('/').collect(); + if path_parts.len() == 2 { + if let Some(branch) = branch_or_commit { + // Check if it looks like a commit hash + if branch.len() >= 7 && branch.chars().all(|c| c.is_ascii_hexdigit()) { + return format!("https://github.com/{}/commit/{}", repo_part, branch); + } else { + return format!("https://github.com/{}/tree/{}", repo_part, branch); + } + } else { + return format!("https://github.com/{}", repo_part); + } + } + } + + url.to_string() + } + + /// Extract project name from a direct URL + fn extract_project_name_from_url(&self, url: &str) -> String { + let url_path = url.trim_end_matches('/'); + + if let Some(filename) = url_path.split('/').next_back() { + if filename.ends_with(".tar.gz") { + return filename.trim_end_matches(".tar.gz").to_string(); + } + if filename.ends_with(".tgz") { + return filename.trim_end_matches(".tgz").to_string(); + } + return filename.to_string(); + } + + "remote-project".to_string() + } +} + +impl Default for RemoteAnalyzer { + fn default() -> Self { + Self::new() + } +} diff --git a/src/net/providers/archive.rs b/src/net/providers/archive.rs new file mode 100644 index 0000000..6088cc1 --- /dev/null +++ b/src/net/providers/archive.rs @@ -0,0 +1,227 @@ +use crate::net::traits::{GitProvider, ParsedRepository, ProviderConfig}; +use async_trait::async_trait; +use reqwest::Client; +use std::collections::HashMap; + +pub struct ArchiveProvider { + credentials: HashMap, +} + +impl ArchiveProvider { + pub fn new() -> Self { + Self { + credentials: HashMap::new(), + } + } +} + +#[async_trait] +impl GitProvider for ArchiveProvider { + fn name(&self) -> &'static str { + "archive" + } + + fn can_handle(&self, url: &str) -> bool { + url.ends_with(".tar.gz") + || url.ends_with(".tgz") + || url.ends_with(".tar.bz2") + || url.ends_with(".tar.xz") + || url.ends_with(".zip") + || url.contains("/archive/") + || url.contains("/tarball/") + || url.contains("/zipball/") + } + + fn parse_url(&self, url: &str) -> Option { + if !self.can_handle(url) { + return None; + } + + let filename = url.split('/').next_back()?; + let name = self.extract_name_from_filename(filename); + + Some( + ParsedRepository::new("archive".to_string(), name.clone()) + .with_host(self.extract_host_from_url(url)), + ) + } + + fn build_download_urls(&self, _parsed: &ParsedRepository) -> Vec { + vec![] + } + + async fn get_default_branch( + &self, + _client: &Client, + _parsed: &ParsedRepository, + ) -> Option { + None + } + + fn apply_config(&mut self, config: &ProviderConfig) { + self.credentials = config.credentials.clone(); + } + + fn get_project_name(&self, url: &str) -> String { + if let Some(filename) = url.split('/').next_back() { + return self.extract_name_from_filename(filename); + } + + "archive-project".to_string() + } +} + +impl ArchiveProvider { + fn extract_name_from_filename(&self, filename: &str) -> String { + let name = if filename.ends_with(".tar.gz") { + filename.trim_end_matches(".tar.gz") + } else if filename.ends_with(".tgz") { + filename.trim_end_matches(".tgz") + } else if filename.ends_with(".tar.bz2") { + filename.trim_end_matches(".tar.bz2") + } else if filename.ends_with(".tar.xz") { + filename.trim_end_matches(".tar.xz") + } else if filename.ends_with(".zip") { + filename.trim_end_matches(".zip") + } else { + filename + }; + + name.to_string() + } + + fn extract_host_from_url(&self, url: &str) -> String { + if let Some(start) = url.find("://") { + let after_protocol = &url[start + 3..]; + if let Some(end) = after_protocol.find('/') { + return after_protocol[..end].to_string(); + } + return after_protocol.to_string(); + } + "unknown".to_string() + } +} + +impl Default for ArchiveProvider { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_can_handle() { + let provider = ArchiveProvider::new(); + assert!(provider.can_handle("https://example.com/project.tar.gz")); + assert!(provider.can_handle("https://example.com/project.tgz")); + assert!(provider.can_handle("https://example.com/project.tar.bz2")); + assert!(provider.can_handle("https://example.com/project.tar.xz")); + assert!(provider.can_handle("https://example.com/project.zip")); + assert!(provider.can_handle("https://example.com/archive/main.tar.gz")); + assert!(provider.can_handle("https://example.com/tarball/main")); + assert!(provider.can_handle("https://example.com/zipball/main")); + assert!(!provider.can_handle("https://github.com/user/repo")); + } + + #[test] + fn test_parse_tar_gz_url() { + let provider = ArchiveProvider::new(); + + let parsed = provider + .parse_url("https://example.com/myproject.tar.gz") + .unwrap(); + assert_eq!(parsed.owner, "archive"); + assert_eq!(parsed.repo, "myproject"); + assert_eq!(parsed.project_name, "myproject@main"); + assert_eq!(parsed.branch_or_commit, None); + assert!(!parsed.is_commit); + assert_eq!(parsed.host.as_ref().unwrap(), "example.com"); + } + + #[test] + fn test_parse_tgz_url() { + let provider = ArchiveProvider::new(); + + let parsed = provider + .parse_url("https://cdn.example.com/releases/v1.0.0.tgz") + .unwrap(); + assert_eq!(parsed.owner, "archive"); + assert_eq!(parsed.repo, "v1.0.0"); + assert_eq!(parsed.project_name, "v1.0.0@main"); + assert_eq!(parsed.host.as_ref().unwrap(), "cdn.example.com"); + } + + #[test] + fn test_parse_zip_url() { + let provider = ArchiveProvider::new(); + + let parsed = provider + .parse_url("https://releases.example.com/project-v2.0.zip") + .unwrap(); + assert_eq!(parsed.owner, "archive"); + assert_eq!(parsed.repo, "project-v2.0"); + assert_eq!(parsed.project_name, "project-v2.0@main"); + assert_eq!(parsed.host.as_ref().unwrap(), "releases.example.com"); + } + + #[test] + fn test_extract_name_from_filename() { + let provider = ArchiveProvider::new(); + + assert_eq!( + provider.extract_name_from_filename("project.tar.gz"), + "project" + ); + assert_eq!( + provider.extract_name_from_filename("mylib-v1.0.0.tgz"), + "mylib-v1.0.0" + ); + assert_eq!( + provider.extract_name_from_filename("source.tar.bz2"), + "source" + ); + assert_eq!( + provider.extract_name_from_filename("archive.tar.xz"), + "archive" + ); + assert_eq!( + provider.extract_name_from_filename("release.zip"), + "release" + ); + } + + #[test] + fn test_extract_host_from_url() { + let provider = ArchiveProvider::new(); + + assert_eq!( + provider.extract_host_from_url("https://example.com/file.tar.gz"), + "example.com" + ); + assert_eq!( + provider.extract_host_from_url("http://cdn.example.org/releases/v1.0.tgz"), + "cdn.example.org" + ); + assert_eq!( + provider.extract_host_from_url("https://api.github.com/repos/user/repo/tarball/main"), + "api.github.com" + ); + } + + #[test] + fn test_get_project_name() { + let provider = ArchiveProvider::new(); + + assert_eq!( + provider.get_project_name("https://example.com/myproject.tar.gz"), + "myproject" + ); + assert_eq!( + provider.get_project_name("https://releases.example.com/v1.2.3.tgz"), + "v1.2.3" + ); + } +} diff --git a/src/net/providers/azure_devops.rs b/src/net/providers/azure_devops.rs new file mode 100644 index 0000000..9126979 --- /dev/null +++ b/src/net/providers/azure_devops.rs @@ -0,0 +1,197 @@ +use crate::net::traits::{GitProvider, ParsedRepository, ProviderConfig}; +use async_trait::async_trait; +use reqwest::Client; +use std::collections::HashMap; + +pub struct AzureDevOpsProvider { + credentials: HashMap, +} + +impl AzureDevOpsProvider { + pub fn new() -> Self { + Self { + credentials: HashMap::new(), + } + } +} + +#[async_trait] +impl GitProvider for AzureDevOpsProvider { + fn name(&self) -> &'static str { + "azure_devops" + } + + fn can_handle(&self, url: &str) -> bool { + url.contains("dev.azure.com") || url.contains("visualstudio.com") || url.contains("_git/") + } + + fn parse_url(&self, url: &str) -> Option { + if !self.can_handle(url) { + return None; + } + + let url = url.trim_end_matches('/'); + + if url.contains("?version=GB") { + return self.parse_branch_url(url); + } + + if url.contains("?version=GC") { + return self.parse_commit_url(url); + } + + self.parse_basic_url(url) + } + + fn build_download_urls(&self, parsed: &ParsedRepository) -> Vec { + let mut urls = Vec::new(); + + if let Some(ref branch_or_commit) = parsed.branch_or_commit { + let host = parsed.host.as_deref().unwrap_or("dev.azure.com"); + + if parsed.is_commit { + urls.push(format!( + "https://{}/{}/{}/_apis/git/repositories/{}/items?path=/&versionDescriptor.version={}&$format=zip", + host, parsed.owner, parsed.repo.split('/').next().unwrap_or(&parsed.repo), parsed.repo, branch_or_commit + )); + } else { + urls.push(format!( + "https://{}/{}/{}/_apis/git/repositories/{}/items?path=/&versionDescriptor.versionType=branch&versionDescriptor.version={}&$format=zip", + host, parsed.owner, parsed.repo.split('/').next().unwrap_or(&parsed.repo), parsed.repo, branch_or_commit + )); + } + } + + urls + } + + async fn get_default_branch( + &self, + _client: &Client, + _parsed: &ParsedRepository, + ) -> Option { + None + } + + fn apply_config(&mut self, config: &ProviderConfig) { + self.credentials = config.credentials.clone(); + } + + fn get_project_name(&self, url: &str) -> String { + if let Some(parsed) = self.parse_url(url) { + return parsed.project_name; + } + + if let Some(filename) = url.split('/').next_back() { + if filename.ends_with(".zip") { + return filename.trim_end_matches(".zip").to_string(); + } + return filename.to_string(); + } + + "azure-devops-project".to_string() + } +} + +impl AzureDevOpsProvider { + fn parse_commit_url(&self, url: &str) -> Option { + if let Some(commit_start) = url.find("?version=GC") { + let base_url = &url[..commit_start]; + let commit = &url[commit_start + "?version=GC".len()..]; + + if let Some(parsed_base) = self.parse_basic_url(base_url) { + return Some(parsed_base.with_commit(commit.to_string())); + } + } + None + } + + fn parse_branch_url(&self, url: &str) -> Option { + if let Some(branch_start) = url.find("?version=GB") { + let base_url = &url[..branch_start]; + let branch = &url[branch_start + "?version=GB".len()..]; + + if let Some(parsed_base) = self.parse_basic_url(base_url) { + return Some(parsed_base.with_branch(branch.to_string())); + } + } + None + } + + fn parse_basic_url(&self, url: &str) -> Option { + if url.contains("dev.azure.com") { + let parts: Vec<&str> = url.split('/').collect(); + if parts.len() >= 7 && parts.contains(&"_git") { + let host = parts[2].to_string(); + let org = parts[3].to_string(); + let project = parts[4].to_string(); + let repo = parts[6].to_string(); + + return Some( + ParsedRepository::new(format!("{}/{}", org, project), repo).with_host(host), + ); + } + } else if url.contains("visualstudio.com") { + let parts: Vec<&str> = url.split('/').collect(); + if parts.len() >= 6 && parts.contains(&"_git") { + let host = parts[2].to_string(); + let org = parts[2].split('.').next().unwrap_or("").to_string(); + let project = parts[4].to_string(); + let repo = parts[6].to_string(); + + return Some( + ParsedRepository::new(format!("{}/{}", org, project), repo).with_host(host), + ); + } + } + None + } +} + +impl Default for AzureDevOpsProvider { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_can_handle() { + let provider = AzureDevOpsProvider::new(); + assert!(provider.can_handle("https://dev.azure.com/org/project/_git/repo")); + assert!(provider.can_handle("https://org.visualstudio.com/project/_git/repo")); + assert!(!provider.can_handle("https://github.com/user/repo")); + } + + #[test] + fn test_parse_basic_url() { + let provider = AzureDevOpsProvider::new(); + + let parsed = provider + .parse_url("https://dev.azure.com/myorg/myproject/_git/myrepo") + .unwrap(); + assert_eq!(parsed.owner, "myorg/myproject"); + assert_eq!(parsed.repo, "myrepo"); + assert_eq!(parsed.project_name, "myrepo@main"); + assert_eq!(parsed.branch_or_commit, None); + assert!(!parsed.is_commit); + assert_eq!(parsed.host.as_ref().unwrap(), "dev.azure.com"); + } + + #[test] + fn test_parse_branch_url() { + let provider = AzureDevOpsProvider::new(); + + let parsed = provider + .parse_url("https://dev.azure.com/myorg/myproject/_git/myrepo?version=GBdevelop") + .unwrap(); + assert_eq!(parsed.owner, "myorg/myproject"); + assert_eq!(parsed.repo, "myrepo"); + assert_eq!(parsed.project_name, "myrepo@develop"); + assert_eq!(parsed.branch_or_commit, Some("develop".to_string())); + assert!(!parsed.is_commit); + } +} diff --git a/src/net/providers/bitbucket.rs b/src/net/providers/bitbucket.rs new file mode 100644 index 0000000..2b980c0 --- /dev/null +++ b/src/net/providers/bitbucket.rs @@ -0,0 +1,214 @@ +use crate::net::traits::{GitProvider, ParsedRepository, ProviderConfig}; +use async_trait::async_trait; +use reqwest::Client; +use std::collections::HashMap; + +pub struct BitbucketProvider { + credentials: HashMap, +} + +impl BitbucketProvider { + pub fn new() -> Self { + Self { + credentials: HashMap::new(), + } + } +} + +#[async_trait] +impl GitProvider for BitbucketProvider { + fn name(&self) -> &'static str { + "bitbucket" + } + + fn can_handle(&self, url: &str) -> bool { + url.contains("bitbucket.org") + } + + fn parse_url(&self, url: &str) -> Option { + if !self.can_handle(url) { + return None; + } + + let url = url.trim_end_matches('/'); + + if url.contains("/commits/") { + return self.parse_commit_url(url); + } + + if url.contains("/branch/") { + return self.parse_branch_url(url); + } + + self.parse_basic_url(url) + } + + fn build_download_urls(&self, parsed: &ParsedRepository) -> Vec { + let mut urls = Vec::new(); + + if let Some(ref branch_or_commit) = parsed.branch_or_commit { + urls.push(format!( + "https://bitbucket.org/{}/{}/get/{}.tar.gz", + parsed.owner, parsed.repo, branch_or_commit + )); + } + + urls + } + + async fn get_default_branch( + &self, + _client: &Client, + _parsed: &ParsedRepository, + ) -> Option { + None + } + + fn apply_config(&mut self, config: &ProviderConfig) { + self.credentials = config.credentials.clone(); + } + + fn get_project_name(&self, url: &str) -> String { + if let Some(parsed) = self.parse_url(url) { + return parsed.project_name; + } + + if let Some(filename) = url.split('/').next_back() { + if filename.ends_with(".tar.gz") { + return filename.trim_end_matches(".tar.gz").to_string(); + } + if filename.ends_with(".tgz") { + return filename.trim_end_matches(".tgz").to_string(); + } + return filename.to_string(); + } + + "bitbucket-project".to_string() + } +} + +impl BitbucketProvider { + fn parse_commit_url(&self, url: &str) -> Option { + let parts: Vec<&str> = url.split('/').collect(); + if let Some(commits_pos) = parts.iter().position(|&x| x == "commits") { + if commits_pos + 1 < parts.len() && commits_pos >= 2 { + let owner = parts[commits_pos - 2].to_string(); + let repo = parts[commits_pos - 1].to_string(); + let commit = parts[commits_pos + 1].to_string(); + + return Some( + ParsedRepository::new(owner, repo) + .with_commit(commit) + .with_host("bitbucket.org".to_string()), + ); + } + } + None + } + + fn parse_branch_url(&self, url: &str) -> Option { + let parts: Vec<&str> = url.split('/').collect(); + if let Some(branch_pos) = parts.iter().position(|&x| x == "branch") { + if branch_pos + 1 < parts.len() && branch_pos >= 2 { + let owner = parts[branch_pos - 2].to_string(); + let repo = parts[branch_pos - 1].to_string(); + let branch = parts[branch_pos + 1].to_string(); + + return Some( + ParsedRepository::new(owner, repo) + .with_branch(branch) + .with_host("bitbucket.org".to_string()), + ); + } + } + None + } + + fn parse_basic_url(&self, url: &str) -> Option { + let parts: Vec<&str> = url.split('/').collect(); + if let Some(bitbucket_pos) = parts.iter().position(|&x| x == "bitbucket.org") { + if bitbucket_pos + 2 < parts.len() { + let owner = parts[bitbucket_pos + 1].to_string(); + let repo = parts[bitbucket_pos + 2].to_string(); + + return Some( + ParsedRepository::new(owner, repo).with_host("bitbucket.org".to_string()), + ); + } + } + None + } +} + +impl Default for BitbucketProvider { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_can_handle() { + let provider = BitbucketProvider::new(); + assert!(provider.can_handle("https://bitbucket.org/user/repo")); + assert!(provider.can_handle("https://bitbucket.org/user/repo/commits/abc123")); + assert!(!provider.can_handle("https://github.com/user/repo")); + } + + #[test] + fn test_parse_basic_url() { + let provider = BitbucketProvider::new(); + + let parsed = provider + .parse_url("https://bitbucket.org/user/repo") + .unwrap(); + assert_eq!(parsed.owner, "user"); + assert_eq!(parsed.repo, "repo"); + assert_eq!(parsed.project_name, "repo@main"); + assert_eq!(parsed.branch_or_commit, None); + assert!(!parsed.is_commit); + assert_eq!(parsed.host.as_ref().unwrap(), "bitbucket.org"); + } + + #[test] + fn test_parse_branch_url() { + let provider = BitbucketProvider::new(); + + let parsed = provider + .parse_url("https://bitbucket.org/user/repo/branch/develop") + .unwrap(); + assert_eq!(parsed.owner, "user"); + assert_eq!(parsed.repo, "repo"); + assert_eq!(parsed.project_name, "repo@develop"); + assert_eq!(parsed.branch_or_commit, Some("develop".to_string())); + assert!(!parsed.is_commit); + } + + #[test] + fn test_parse_commit_url() { + let provider = BitbucketProvider::new(); + + let parsed = provider + .parse_url("https://bitbucket.org/user/repo/commits/abc1234567890") + .unwrap(); + assert_eq!(parsed.owner, "user"); + assert_eq!(parsed.repo, "repo"); + assert_eq!(parsed.project_name, "repo@abc1234"); + assert_eq!(parsed.branch_or_commit, Some("abc1234567890".to_string())); + assert!(parsed.is_commit); + } + + #[test] + fn test_build_download_urls() { + let provider = BitbucketProvider::new(); + + let parsed = ParsedRepository::new("user".to_string(), "repo".to_string()) + .with_branch("main".to_string()); + + let urls = provider.build_download_urls(&parsed); + assert!(urls.contains(&"https://bitbucket.org/user/repo/get/main.tar.gz".to_string())); + } +} diff --git a/src/net/providers/codeberg.rs b/src/net/providers/codeberg.rs new file mode 100644 index 0000000..cc50fc7 --- /dev/null +++ b/src/net/providers/codeberg.rs @@ -0,0 +1,214 @@ +use crate::net::traits::{GitProvider, ParsedRepository, ProviderConfig}; +use async_trait::async_trait; +use reqwest::Client; +use std::collections::HashMap; + +pub struct CodebergProvider { + credentials: HashMap, +} + +impl CodebergProvider { + pub fn new() -> Self { + Self { + credentials: HashMap::new(), + } + } +} + +#[async_trait] +impl GitProvider for CodebergProvider { + fn name(&self) -> &'static str { + "codeberg" + } + + fn can_handle(&self, url: &str) -> bool { + url.contains("codeberg.org") + } + + fn parse_url(&self, url: &str) -> Option { + if !self.can_handle(url) { + return None; + } + + let url = url.trim_end_matches('/'); + + if url.contains("/commit/") { + return self.parse_commit_url(url); + } + + if url.contains("/src/branch/") { + return self.parse_branch_url(url); + } + + self.parse_basic_url(url) + } + + fn build_download_urls(&self, parsed: &ParsedRepository) -> Vec { + let mut urls = Vec::new(); + + if let Some(ref branch_or_commit) = parsed.branch_or_commit { + urls.push(format!( + "https://codeberg.org/{}/{}/archive/{}.tar.gz", + parsed.owner, parsed.repo, branch_or_commit + )); + } + + urls + } + + async fn get_default_branch( + &self, + _client: &Client, + _parsed: &ParsedRepository, + ) -> Option { + None + } + + fn apply_config(&mut self, config: &ProviderConfig) { + self.credentials = config.credentials.clone(); + } + + fn get_project_name(&self, url: &str) -> String { + if let Some(parsed) = self.parse_url(url) { + return parsed.project_name; + } + + if let Some(filename) = url.split('/').next_back() { + if filename.ends_with(".tar.gz") { + return filename.trim_end_matches(".tar.gz").to_string(); + } + if filename.ends_with(".tgz") { + return filename.trim_end_matches(".tgz").to_string(); + } + return filename.to_string(); + } + + "codeberg-project".to_string() + } +} + +impl CodebergProvider { + fn parse_commit_url(&self, url: &str) -> Option { + let parts: Vec<&str> = url.split('/').collect(); + if let Some(commit_pos) = parts.iter().position(|&x| x == "commit") { + if commit_pos + 1 < parts.len() && commit_pos >= 2 { + let owner = parts[commit_pos - 2].to_string(); + let repo = parts[commit_pos - 1].to_string(); + let commit = parts[commit_pos + 1].to_string(); + + return Some( + ParsedRepository::new(owner, repo) + .with_commit(commit) + .with_host("codeberg.org".to_string()), + ); + } + } + None + } + + fn parse_branch_url(&self, url: &str) -> Option { + let parts: Vec<&str> = url.split('/').collect(); + if let Some(branch_pos) = parts.iter().position(|&x| x == "branch") { + if branch_pos + 1 < parts.len() && branch_pos >= 3 { + let owner = parts[branch_pos - 3].to_string(); + let repo = parts[branch_pos - 2].to_string(); + let branch = parts[branch_pos + 1].to_string(); + + return Some( + ParsedRepository::new(owner, repo) + .with_branch(branch) + .with_host("codeberg.org".to_string()), + ); + } + } + None + } + + fn parse_basic_url(&self, url: &str) -> Option { + let parts: Vec<&str> = url.split('/').collect(); + if let Some(codeberg_pos) = parts.iter().position(|&x| x == "codeberg.org") { + if codeberg_pos + 2 < parts.len() { + let owner = parts[codeberg_pos + 1].to_string(); + let repo = parts[codeberg_pos + 2].to_string(); + + return Some( + ParsedRepository::new(owner, repo).with_host("codeberg.org".to_string()), + ); + } + } + None + } +} + +impl Default for CodebergProvider { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_can_handle() { + let provider = CodebergProvider::new(); + assert!(provider.can_handle("https://codeberg.org/user/repo")); + assert!(provider.can_handle("https://codeberg.org/user/repo/commit/abc123")); + assert!(!provider.can_handle("https://github.com/user/repo")); + } + + #[test] + fn test_parse_basic_url() { + let provider = CodebergProvider::new(); + + let parsed = provider + .parse_url("https://codeberg.org/user/repo") + .unwrap(); + assert_eq!(parsed.owner, "user"); + assert_eq!(parsed.repo, "repo"); + assert_eq!(parsed.project_name, "repo@main"); + assert_eq!(parsed.branch_or_commit, None); + assert!(!parsed.is_commit); + assert_eq!(parsed.host.as_ref().unwrap(), "codeberg.org"); + } + + #[test] + fn test_parse_branch_url() { + let provider = CodebergProvider::new(); + + let parsed = provider + .parse_url("https://codeberg.org/user/repo/src/branch/develop") + .unwrap(); + assert_eq!(parsed.owner, "user"); + assert_eq!(parsed.repo, "repo"); + assert_eq!(parsed.project_name, "repo@develop"); + assert_eq!(parsed.branch_or_commit, Some("develop".to_string())); + assert!(!parsed.is_commit); + } + + #[test] + fn test_parse_commit_url() { + let provider = CodebergProvider::new(); + + let parsed = provider + .parse_url("https://codeberg.org/user/repo/commit/abc1234567890") + .unwrap(); + assert_eq!(parsed.owner, "user"); + assert_eq!(parsed.repo, "repo"); + assert_eq!(parsed.project_name, "repo@abc1234"); + assert_eq!(parsed.branch_or_commit, Some("abc1234567890".to_string())); + assert!(parsed.is_commit); + } + + #[test] + fn test_build_download_urls() { + let provider = CodebergProvider::new(); + + let parsed = ParsedRepository::new("user".to_string(), "repo".to_string()) + .with_branch("main".to_string()); + + let urls = provider.build_download_urls(&parsed); + assert!(urls.contains(&"https://codeberg.org/user/repo/archive/main.tar.gz".to_string())); + } +} diff --git a/src/net/providers/gitea.rs b/src/net/providers/gitea.rs new file mode 100644 index 0000000..5f254f4 --- /dev/null +++ b/src/net/providers/gitea.rs @@ -0,0 +1,192 @@ +use crate::net::traits::{GitProvider, ParsedRepository, ProviderConfig}; +use async_trait::async_trait; +use reqwest::Client; +use std::collections::HashMap; + +pub struct GiteaProvider { + credentials: HashMap, +} + +impl GiteaProvider { + pub fn new() -> Self { + Self { + credentials: HashMap::new(), + } + } +} + +#[async_trait] +impl GitProvider for GiteaProvider { + fn name(&self) -> &'static str { + "gitea" + } + + fn can_handle(&self, url: &str) -> bool { + url.contains("gitea.") + || url.contains("/gitea") + || url.contains("git.") + || self.is_likely_gitea(url) + } + + fn parse_url(&self, url: &str) -> Option { + if !self.can_handle(url) { + return None; + } + + let url = url.trim_end_matches('/'); + + if url.contains("/commit/") { + return self.parse_commit_url(url); + } + + if url.contains("/src/branch/") { + return self.parse_branch_url(url); + } + + self.parse_basic_url(url) + } + + fn build_download_urls(&self, parsed: &ParsedRepository) -> Vec { + let mut urls = Vec::new(); + + if let Some(ref branch_or_commit) = parsed.branch_or_commit { + let host = parsed.host.as_deref().unwrap_or("gitea.com"); + + urls.push(format!( + "https://{}/{}/{}/archive/{}.tar.gz", + host, parsed.owner, parsed.repo, branch_or_commit + )); + } + + urls + } + + async fn get_default_branch( + &self, + _client: &Client, + _parsed: &ParsedRepository, + ) -> Option { + None + } + + fn apply_config(&mut self, config: &ProviderConfig) { + self.credentials = config.credentials.clone(); + } + + fn get_project_name(&self, url: &str) -> String { + if let Some(parsed) = self.parse_url(url) { + return parsed.project_name; + } + + if let Some(filename) = url.split('/').next_back() { + if filename.ends_with(".tar.gz") { + return filename.trim_end_matches(".tar.gz").to_string(); + } + if filename.ends_with(".tgz") { + return filename.trim_end_matches(".tgz").to_string(); + } + return filename.to_string(); + } + + "gitea-project".to_string() + } +} + +impl GiteaProvider { + fn is_likely_gitea(&self, _url: &str) -> bool { + false + } + + fn parse_commit_url(&self, url: &str) -> Option { + let parts: Vec<&str> = url.split('/').collect(); + if let Some(commit_pos) = parts.iter().position(|&x| x == "commit") { + if commit_pos + 1 < parts.len() && commit_pos >= 4 { + let host = parts[2].to_string(); + let owner = parts[commit_pos - 2].to_string(); + let repo = parts[commit_pos - 1].to_string(); + let commit = parts[commit_pos + 1].to_string(); + + return Some( + ParsedRepository::new(owner, repo) + .with_commit(commit) + .with_host(host), + ); + } + } + None + } + + fn parse_branch_url(&self, url: &str) -> Option { + let parts: Vec<&str> = url.split('/').collect(); + if let Some(branch_pos) = parts.iter().position(|&x| x == "branch") { + if branch_pos + 1 < parts.len() && branch_pos >= 5 { + let host = parts[2].to_string(); + let owner = parts[branch_pos - 3].to_string(); + let repo = parts[branch_pos - 2].to_string(); + let branch = parts[branch_pos + 1].to_string(); + + return Some( + ParsedRepository::new(owner, repo) + .with_branch(branch) + .with_host(host), + ); + } + } + None + } + + fn parse_basic_url(&self, url: &str) -> Option { + let parts: Vec<&str> = url.split('/').collect(); + if parts.len() >= 5 && parts[0] == "https:" { + let host = parts[2].to_string(); + let owner = parts[3].to_string(); + let repo = parts[4].to_string(); + + return Some(ParsedRepository::new(owner, repo).with_host(host)); + } + None + } +} + +impl Default for GiteaProvider { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_can_handle() { + let provider = GiteaProvider::new(); + assert!(provider.can_handle("https://gitea.com/user/repo")); + assert!(provider.can_handle("https://git.company.com/user/repo")); + } + + #[test] + fn test_parse_basic_url() { + let provider = GiteaProvider::new(); + + let parsed = provider.parse_url("https://gitea.com/user/repo").unwrap(); + assert_eq!(parsed.owner, "user"); + assert_eq!(parsed.repo, "repo"); + assert_eq!(parsed.project_name, "repo@main"); + assert_eq!(parsed.branch_or_commit, None); + assert!(!parsed.is_commit); + assert_eq!(parsed.host.as_ref().unwrap(), "gitea.com"); + } + + #[test] + fn test_build_download_urls() { + let provider = GiteaProvider::new(); + + let parsed = ParsedRepository::new("user".to_string(), "repo".to_string()) + .with_branch("main".to_string()) + .with_host("gitea.com".to_string()); + + let urls = provider.build_download_urls(&parsed); + assert!(urls.contains(&"https://gitea.com/user/repo/archive/main.tar.gz".to_string())); + } +} diff --git a/src/net/providers/github.rs b/src/net/providers/github.rs new file mode 100644 index 0000000..631079a --- /dev/null +++ b/src/net/providers/github.rs @@ -0,0 +1,303 @@ +use crate::net::traits::{GitProvider, ParsedRepository, ProviderConfig}; +use async_trait::async_trait; +use reqwest::Client; +use serde::Deserialize; + +#[derive(Deserialize)] +struct GitHubRepoInfo { + default_branch: String, +} + +pub struct GitHubProvider { + token: Option, +} + +impl GitHubProvider { + pub fn new() -> Self { + Self { token: None } + } +} + +#[async_trait] +impl GitProvider for GitHubProvider { + fn name(&self) -> &'static str { + "github" + } + + fn can_handle(&self, url: &str) -> bool { + url.contains("github.com") + } + + fn parse_url(&self, url: &str) -> Option { + if !self.can_handle(url) { + return None; + } + + let url = url.trim_end_matches('/'); + + if url.contains("/tree/") { + return self.parse_tree_url(url); + } + + if url.contains("/commit/") { + return self.parse_commit_url(url); + } + + self.parse_basic_url(url) + } + + fn build_download_urls(&self, parsed: &ParsedRepository) -> Vec { + let mut urls = Vec::new(); + + if let Some(ref branch_or_commit) = parsed.branch_or_commit { + if parsed.is_commit { + urls.push(format!( + "https://github.com/{}/{}/archive/{}.tar.gz", + parsed.owner, parsed.repo, branch_or_commit + )); + } else { + urls.push(format!( + "https://github.com/{}/{}/archive/refs/heads/{}.tar.gz", + parsed.owner, parsed.repo, branch_or_commit + )); + urls.push(format!( + "https://github.com/{}/{}/archive/refs/tags/{}.tar.gz", + parsed.owner, parsed.repo, branch_or_commit + )); + } + } + + urls + } + + async fn get_default_branch( + &self, + client: &Client, + parsed: &ParsedRepository, + ) -> Option { + #[cfg(not(target_arch = "wasm32"))] + { + let api_url = format!( + "https://api.github.com/repos/{}/{}", + parsed.owner, parsed.repo + ); + + let mut request = client.get(&api_url); + + if let Some(ref token) = self.token { + request = request.header("Authorization", format!("token {}", token)); + } + + match request.send().await { + Ok(response) => { + if response.status().is_success() { + match response.json::().await { + Ok(repo_info) => { + #[cfg(feature = "cli")] + log::debug!( + "GitHub API: Found default branch '{}' for {}/{}", + repo_info.default_branch, + parsed.owner, + parsed.repo + ); + Some(repo_info.default_branch) + } + Err(_) => { + #[cfg(feature = "cli")] + log::debug!( + "GitHub API: Failed to parse response for {}/{}", + parsed.owner, + parsed.repo + ); + None + } + } + } else { + #[cfg(feature = "cli")] + log::debug!( + "GitHub API: Request failed with status {} for {}/{}", + response.status(), + parsed.owner, + parsed.repo + ); + None + } + } + Err(_) => { + #[cfg(feature = "cli")] + log::debug!( + "GitHub API: Network error for {}/{}", + parsed.owner, + parsed.repo + ); + None + } + } + } + + #[cfg(target_arch = "wasm32")] + None + } + + fn apply_config(&mut self, config: &ProviderConfig) { + self.token = config.credentials.get("token").cloned(); + } + + fn get_project_name(&self, url: &str) -> String { + if let Some(parsed) = self.parse_url(url) { + return parsed.project_name; + } + + if let Some(filename) = url.split('/').next_back() { + if filename.ends_with(".tar.gz") { + return filename.trim_end_matches(".tar.gz").to_string(); + } + if filename.ends_with(".tgz") { + return filename.trim_end_matches(".tgz").to_string(); + } + return filename.to_string(); + } + + "github-project".to_string() + } +} + +impl GitHubProvider { + fn parse_tree_url(&self, url: &str) -> Option { + let parts: Vec<&str> = url.split('/').collect(); + if let Some(tree_pos) = parts.iter().position(|&x| x == "tree") { + if tree_pos + 1 < parts.len() && tree_pos >= 2 { + let owner = parts[tree_pos - 2].to_string(); + let repo = parts[tree_pos - 1].to_string(); + let branch = parts[tree_pos + 1].to_string(); + + return Some( + ParsedRepository::new(owner, repo) + .with_branch(branch) + .with_host("github.com".to_string()), + ); + } + } + None + } + + fn parse_commit_url(&self, url: &str) -> Option { + let parts: Vec<&str> = url.split('/').collect(); + if let Some(commit_pos) = parts.iter().position(|&x| x == "commit") { + if commit_pos + 1 < parts.len() && commit_pos >= 2 { + let owner = parts[commit_pos - 2].to_string(); + let repo = parts[commit_pos - 1].to_string(); + let commit = parts[commit_pos + 1].to_string(); + + return Some( + ParsedRepository::new(owner, repo) + .with_commit(commit) + .with_host("github.com".to_string()), + ); + } + } + None + } + + fn parse_basic_url(&self, url: &str) -> Option { + let parts: Vec<&str> = url.split('/').collect(); + if let Some(github_pos) = parts.iter().position(|&x| x == "github.com") { + if github_pos + 2 < parts.len() { + let owner = parts[github_pos + 1].to_string(); + let repo = parts[github_pos + 2].to_string(); + + return Some( + ParsedRepository::new(owner, repo).with_host("github.com".to_string()), + ); + } + } + + if let Some(stripped) = url.strip_prefix("https://github.com/") { + let parts: Vec<&str> = stripped.split('/').collect(); + if parts.len() >= 2 { + let owner = parts[0].to_string(); + let repo = parts[1].to_string(); + + return Some( + ParsedRepository::new(owner, repo).with_host("github.com".to_string()), + ); + } + } + + None + } +} + +impl Default for GitHubProvider { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_can_handle() { + let provider = GitHubProvider::new(); + assert!(provider.can_handle("https://github.com/user/repo")); + assert!(provider.can_handle("https://github.com/user/repo/tree/main")); + assert!(!provider.can_handle("https://gitlab.com/user/repo")); + } + + #[test] + fn test_parse_basic_url() { + let provider = GitHubProvider::new(); + + let parsed = provider.parse_url("https://github.com/user/repo").unwrap(); + assert_eq!(parsed.owner, "user"); + assert_eq!(parsed.repo, "repo"); + assert_eq!(parsed.project_name, "repo@main"); + assert_eq!(parsed.branch_or_commit, None); + assert!(!parsed.is_commit); + assert_eq!(parsed.host.as_ref().unwrap(), "github.com"); + } + + #[test] + fn test_parse_tree_url() { + let provider = GitHubProvider::new(); + + let parsed = provider + .parse_url("https://github.com/user/repo/tree/develop") + .unwrap(); + assert_eq!(parsed.owner, "user"); + assert_eq!(parsed.repo, "repo"); + assert_eq!(parsed.project_name, "repo@develop"); + assert_eq!(parsed.branch_or_commit, Some("develop".to_string())); + assert!(!parsed.is_commit); + } + + #[test] + fn test_parse_commit_url() { + let provider = GitHubProvider::new(); + + let parsed = provider + .parse_url("https://github.com/user/repo/commit/abc1234567890") + .unwrap(); + assert_eq!(parsed.owner, "user"); + assert_eq!(parsed.repo, "repo"); + assert_eq!(parsed.project_name, "repo@abc1234"); + assert_eq!(parsed.branch_or_commit, Some("abc1234567890".to_string())); + assert!(parsed.is_commit); + } + + #[test] + fn test_build_download_urls() { + let provider = GitHubProvider::new(); + + let parsed = ParsedRepository::new("user".to_string(), "repo".to_string()) + .with_branch("main".to_string()); + + let urls = provider.build_download_urls(&parsed); + assert!(urls + .contains(&"https://github.com/user/repo/archive/refs/heads/main.tar.gz".to_string())); + assert!(urls + .contains(&"https://github.com/user/repo/archive/refs/tags/main.tar.gz".to_string())); + } +} diff --git a/src/net/providers/gitlab.rs b/src/net/providers/gitlab.rs new file mode 100644 index 0000000..7806933 --- /dev/null +++ b/src/net/providers/gitlab.rs @@ -0,0 +1,238 @@ +use crate::net::traits::{GitProvider, ParsedRepository, ProviderConfig}; +use async_trait::async_trait; +use reqwest::Client; + +pub struct GitLabProvider { + token: Option, +} + +impl GitLabProvider { + pub fn new() -> Self { + Self { token: None } + } +} + +#[async_trait] +impl GitProvider for GitLabProvider { + fn name(&self) -> &'static str { + "gitlab" + } + + fn can_handle(&self, url: &str) -> bool { + url.contains("gitlab.com") || url.contains("gitlab.") + } + + fn parse_url(&self, url: &str) -> Option { + if !self.can_handle(url) { + return None; + } + + let url = url.trim_end_matches('/'); + + if url.contains("/-/tree/") { + return self.parse_tree_url(url); + } + + if url.contains("/-/commit/") { + return self.parse_commit_url(url); + } + + self.parse_basic_url(url) + } + + fn build_download_urls(&self, parsed: &ParsedRepository) -> Vec { + let mut urls = Vec::new(); + + if let Some(ref branch_or_commit) = parsed.branch_or_commit { + let host = parsed.host.as_deref().unwrap_or("gitlab.com"); + + if parsed.is_commit { + urls.push(format!( + "https://{}/{}/-/archive/{}/{}-{}.tar.gz", + host, + self.build_project_path(&parsed.owner, &parsed.repo), + branch_or_commit, + parsed.repo, + branch_or_commit + )); + } else { + urls.push(format!( + "https://{}/{}/-/archive/{}/{}-{}.tar.gz", + host, + self.build_project_path(&parsed.owner, &parsed.repo), + branch_or_commit, + parsed.repo, + branch_or_commit + )); + } + } + + urls + } + + async fn get_default_branch( + &self, + _client: &Client, + _parsed: &ParsedRepository, + ) -> Option { + None + } + + fn apply_config(&mut self, config: &ProviderConfig) { + self.token = config + .credentials + .get("token") + .cloned() + .or_else(|| config.credentials.get("private_token").cloned()); + } + + fn get_project_name(&self, url: &str) -> String { + if let Some(parsed) = self.parse_url(url) { + return parsed.project_name; + } + + if let Some(filename) = url.split('/').next_back() { + if filename.ends_with(".tar.gz") { + return filename.trim_end_matches(".tar.gz").to_string(); + } + if filename.ends_with(".tgz") { + return filename.trim_end_matches(".tgz").to_string(); + } + return filename.to_string(); + } + + "gitlab-project".to_string() + } +} + +impl GitLabProvider { + fn parse_tree_url(&self, url: &str) -> Option { + let parts: Vec<&str> = url.split('/').collect(); + if let Some(tree_pos) = parts.iter().position(|&x| x == "tree") { + if tree_pos + 1 < parts.len() && tree_pos >= 3 { + let gitlab_pos = parts.iter().position(|&x| x.contains("gitlab"))?; + let host = parts[gitlab_pos].to_string(); + let owner = parts[gitlab_pos + 1].to_string(); + let repo = parts[gitlab_pos + 2].to_string(); + let branch = parts[tree_pos + 1].to_string(); + + return Some( + ParsedRepository::new(owner, repo) + .with_branch(branch) + .with_host(host), + ); + } + } + None + } + + fn parse_commit_url(&self, url: &str) -> Option { + let parts: Vec<&str> = url.split('/').collect(); + if let Some(commit_pos) = parts.iter().position(|&x| x == "commit") { + if commit_pos + 1 < parts.len() && commit_pos >= 3 { + let gitlab_pos = parts.iter().position(|&x| x.contains("gitlab"))?; + let host = parts[gitlab_pos].to_string(); + let owner = parts[gitlab_pos + 1].to_string(); + let repo = parts[gitlab_pos + 2].to_string(); + let commit = parts[commit_pos + 1].to_string(); + + return Some( + ParsedRepository::new(owner, repo) + .with_commit(commit) + .with_host(host), + ); + } + } + None + } + + fn parse_basic_url(&self, url: &str) -> Option { + let parts: Vec<&str> = url.split('/').collect(); + if let Some(gitlab_pos) = parts.iter().position(|&x| x.contains("gitlab")) { + if gitlab_pos + 2 < parts.len() { + let host = parts[gitlab_pos].to_string(); + let owner = parts[gitlab_pos + 1].to_string(); + let repo = parts[gitlab_pos + 2].to_string(); + + return Some(ParsedRepository::new(owner, repo).with_host(host)); + } + } + + None + } + + fn build_project_path(&self, owner: &str, repo: &str) -> String { + format!("{}/{}", owner, repo) + } +} + +impl Default for GitLabProvider { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_can_handle() { + let provider = GitLabProvider::new(); + assert!(provider.can_handle("https://gitlab.com/user/repo")); + assert!(provider.can_handle("https://gitlab.example.com/user/repo")); + assert!(!provider.can_handle("https://github.com/user/repo")); + } + + #[test] + fn test_parse_basic_url() { + let provider = GitLabProvider::new(); + + let parsed = provider.parse_url("https://gitlab.com/user/repo").unwrap(); + assert_eq!(parsed.owner, "user"); + assert_eq!(parsed.repo, "repo"); + assert_eq!(parsed.project_name, "repo@main"); + assert_eq!(parsed.branch_or_commit, None); + assert!(!parsed.is_commit); + assert_eq!(parsed.host.as_ref().unwrap(), "gitlab.com"); + } + + #[test] + fn test_parse_tree_url() { + let provider = GitLabProvider::new(); + + let parsed = provider + .parse_url("https://gitlab.com/user/repo/-/tree/develop") + .unwrap(); + assert_eq!(parsed.owner, "user"); + assert_eq!(parsed.repo, "repo"); + assert_eq!(parsed.project_name, "repo@develop"); + assert_eq!(parsed.branch_or_commit, Some("develop".to_string())); + assert!(!parsed.is_commit); + } + + #[test] + fn test_build_download_urls() { + let provider = GitLabProvider::new(); + + let parsed = ParsedRepository::new("user".to_string(), "repo".to_string()) + .with_branch("main".to_string()) + .with_host("gitlab.com".to_string()); + + let urls = provider.build_download_urls(&parsed); + assert!(urls + .contains(&"https://gitlab.com/user/repo/-/archive/main/repo-main.tar.gz".to_string())); + } + + #[test] + fn test_self_hosted_gitlab() { + let provider = GitLabProvider::new(); + + let parsed = provider + .parse_url("https://gitlab.company.com/team/project") + .unwrap(); + assert_eq!(parsed.owner, "team"); + assert_eq!(parsed.repo, "project"); + assert_eq!(parsed.host.as_ref().unwrap(), "gitlab.company.com"); + } +} diff --git a/src/net/providers/mod.rs b/src/net/providers/mod.rs new file mode 100644 index 0000000..287c08b --- /dev/null +++ b/src/net/providers/mod.rs @@ -0,0 +1,17 @@ +pub mod archive; +pub mod azure_devops; +pub mod bitbucket; +pub mod codeberg; +pub mod gitea; +pub mod github; +pub mod gitlab; +pub mod sourceforge; + +pub use archive::ArchiveProvider; +pub use azure_devops::AzureDevOpsProvider; +pub use bitbucket::BitbucketProvider; +pub use codeberg::CodebergProvider; +pub use gitea::GiteaProvider; +pub use github::GitHubProvider; +pub use gitlab::GitLabProvider; +pub use sourceforge::SourceForgeProvider; diff --git a/src/net/providers/sourceforge.rs b/src/net/providers/sourceforge.rs new file mode 100644 index 0000000..4760214 --- /dev/null +++ b/src/net/providers/sourceforge.rs @@ -0,0 +1,191 @@ +use crate::net::traits::{GitProvider, ParsedRepository, ProviderConfig}; +use async_trait::async_trait; +use reqwest::Client; +use std::collections::HashMap; + +pub struct SourceForgeProvider { + credentials: HashMap, +} + +impl SourceForgeProvider { + pub fn new() -> Self { + Self { + credentials: HashMap::new(), + } + } +} + +#[async_trait] +impl GitProvider for SourceForgeProvider { + fn name(&self) -> &'static str { + "sourceforge" + } + + fn can_handle(&self, url: &str) -> bool { + url.contains("sourceforge.net") || url.contains("sf.net") + } + + fn parse_url(&self, url: &str) -> Option { + if !self.can_handle(url) { + return None; + } + + let url = url.trim_end_matches('/'); + + if url.contains("/ci/") && url.contains("/tree/") { + return self.parse_tree_url(url); + } + + if url.contains("/ci/") { + return self.parse_commit_url(url); + } + + self.parse_basic_url(url) + } + + fn build_download_urls(&self, parsed: &ParsedRepository) -> Vec { + let mut urls = Vec::new(); + + if let Some(ref branch_or_commit) = parsed.branch_or_commit { + urls.push(format!( + "https://sourceforge.net/p/{}/code/ci/{}/tarball", + parsed.repo, branch_or_commit + )); + } + + urls + } + + async fn get_default_branch( + &self, + _client: &Client, + _parsed: &ParsedRepository, + ) -> Option { + None + } + + fn apply_config(&mut self, config: &ProviderConfig) { + self.credentials = config.credentials.clone(); + } + + fn get_project_name(&self, url: &str) -> String { + if let Some(parsed) = self.parse_url(url) { + return parsed.project_name; + } + + if let Some(filename) = url.split('/').next_back() { + if filename.ends_with(".tar.gz") { + return filename.trim_end_matches(".tar.gz").to_string(); + } + if filename.ends_with(".tgz") { + return filename.trim_end_matches(".tgz").to_string(); + } + return filename.to_string(); + } + + "sourceforge-project".to_string() + } +} + +impl SourceForgeProvider { + fn parse_commit_url(&self, url: &str) -> Option { + let parts: Vec<&str> = url.split('/').collect(); + if let Some(ci_pos) = parts.iter().position(|&x| x == "ci") { + if ci_pos + 1 < parts.len() && ci_pos >= 3 { + let project = parts[ci_pos - 1].to_string(); + let commit = parts[ci_pos + 1].to_string(); + + return Some( + ParsedRepository::new("sourceforge".to_string(), project) + .with_commit(commit) + .with_host("sourceforge.net".to_string()), + ); + } + } + None + } + + fn parse_tree_url(&self, url: &str) -> Option { + let parts: Vec<&str> = url.split('/').collect(); + if let Some(tree_pos) = parts.iter().position(|&x| x == "tree") { + if tree_pos + 1 < parts.len() { + if let Some(ci_pos) = parts.iter().position(|&x| x == "ci") { + if ci_pos >= 3 { + let project = parts[ci_pos - 1].to_string(); + let branch = parts[tree_pos + 1].to_string(); + + return Some( + ParsedRepository::new("sourceforge".to_string(), project) + .with_branch(branch) + .with_host("sourceforge.net".to_string()), + ); + } + } + } + } + None + } + + fn parse_basic_url(&self, url: &str) -> Option { + if url.contains("/p/") && url.contains("/code") { + let parts: Vec<&str> = url.split('/').collect(); + if let Some(p_pos) = parts.iter().position(|&x| x == "p") { + if p_pos + 1 < parts.len() { + let project = parts[p_pos + 1].to_string(); + return Some( + ParsedRepository::new("sourceforge".to_string(), project) + .with_host("sourceforge.net".to_string()), + ); + } + } + } + None + } +} + +impl Default for SourceForgeProvider { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_can_handle() { + let provider = SourceForgeProvider::new(); + assert!(provider.can_handle("https://sourceforge.net/p/project/code/")); + assert!(provider.can_handle("https://sf.net/p/project/code/")); + assert!(!provider.can_handle("https://github.com/user/repo")); + } + + #[test] + fn test_parse_basic_url() { + let provider = SourceForgeProvider::new(); + + let parsed = provider + .parse_url("https://sourceforge.net/p/myproject/code/") + .unwrap(); + assert_eq!(parsed.owner, "sourceforge"); + assert_eq!(parsed.repo, "myproject"); + assert_eq!(parsed.project_name, "myproject@main"); + assert_eq!(parsed.branch_or_commit, None); + assert!(!parsed.is_commit); + assert_eq!(parsed.host.as_ref().unwrap(), "sourceforge.net"); + } + + #[test] + fn test_build_download_urls() { + let provider = SourceForgeProvider::new(); + + let parsed = ParsedRepository::new("sourceforge".to_string(), "project".to_string()) + .with_branch("master".to_string()); + + let urls = provider.build_download_urls(&parsed); + assert!( + urls.contains(&"https://sourceforge.net/p/project/code/ci/master/tarball".to_string()) + ); + } +} diff --git a/src/net/stream.rs b/src/net/stream.rs new file mode 100644 index 0000000..c44cb84 --- /dev/null +++ b/src/net/stream.rs @@ -0,0 +1,379 @@ +use super::ProgressHook; +use crate::core::{ + analysis::{FileMetrics, ProjectAnalysis}, + error::{AnalysisError, Result}, + filter::{FilterStats, IntelligentFilter}, + registry::LanguageRegistry, +}; +use flate2::read::GzDecoder; +use futures_util::StreamExt; +use std::io::{Cursor, Read}; +use tar::Archive; +use tokio::sync::mpsc; + +#[cfg(not(target_arch = "wasm32"))] +use tokio::task; + +pub type ProgressCallback = Box) + Send + Sync>; + +pub struct StreamReader { + receiver: mpsc::Receiver>, + current_chunk: Option>, + finished: bool, +} + +impl StreamReader { + #[cfg(not(target_arch = "wasm32"))] + pub fn new( + stream: impl futures_util::Stream> + Send + 'static, + progress_callback: ProgressCallback, + total_size: Option, + ) -> Self { + let (tx, rx) = mpsc::channel(32); + + tokio::spawn(async move { + let mut downloaded = 0u64; + let mut stream = Box::pin(stream); + + while let Some(chunk_result) = stream.next().await { + match chunk_result { + Ok(chunk) => { + downloaded += chunk.len() as u64; + progress_callback(downloaded, total_size); + + if tx.send(Ok(chunk)).await.is_err() { + break; + } + } + Err(e) => { + let _ = tx + .send(Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Stream error: {}", e), + ))) + .await; + break; + } + } + } + }); + + Self { + receiver: rx, + current_chunk: None, + finished: false, + } + } + + #[cfg(target_arch = "wasm32")] + pub fn new( + stream: impl futures_util::Stream> + 'static, + progress_callback: ProgressCallback, + total_size: Option, + ) -> Self { + let (tx, rx) = mpsc::channel(32); + + wasm_bindgen_futures::spawn_local(async move { + let mut downloaded = 0u64; + let mut stream = Box::pin(stream); + + while let Some(chunk_result) = stream.next().await { + match chunk_result { + Ok(chunk) => { + downloaded += chunk.len() as u64; + progress_callback(downloaded, total_size); + + if tx.send(Ok(chunk)).await.is_err() { + break; + } + } + Err(e) => { + let _ = tx + .send(Err(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Stream error: {}", e), + ))) + .await; + break; + } + } + } + }); + + Self { + receiver: rx, + current_chunk: None, + finished: false, + } + } +} + +impl Read for StreamReader { + fn read(&mut self, buf: &mut [u8]) -> std::io::Result { + if let Some(ref mut cursor) = self.current_chunk { + let read = cursor.read(buf)?; + if read > 0 { + return Ok(read); + } + self.current_chunk = None; + } + + if self.finished { + return Ok(0); + } + + match self.receiver.try_recv() { + Ok(Ok(chunk)) => { + self.current_chunk = Some(Cursor::new(chunk)); + if let Some(ref mut cursor) = self.current_chunk { + cursor.read(buf) + } else { + Ok(0) + } + } + Ok(Err(e)) => { + self.finished = true; + Err(e) + } + Err(mpsc::error::TryRecvError::Empty) => { + #[cfg(not(target_arch = "wasm32"))] + { + match self.receiver.blocking_recv() { + Some(Ok(chunk)) => { + self.current_chunk = Some(Cursor::new(chunk)); + if let Some(ref mut cursor) = self.current_chunk { + cursor.read(buf) + } else { + Ok(0) + } + } + Some(Err(e)) => { + self.finished = true; + Err(e) + } + None => { + self.finished = true; + Ok(0) + } + } + } + #[cfg(target_arch = "wasm32")] + { + Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "Would block in WASM", + )) + } + } + Err(mpsc::error::TryRecvError::Disconnected) => { + self.finished = true; + Ok(0) + } + } + } +} + +pub async fn process_tarball_stream( + stream_reader: StreamReader, + project_analysis: &mut ProjectAnalysis, + filter: &IntelligentFilter, + _progress_hook: &dyn ProgressHook, +) -> Result<()> { + #[cfg(not(target_arch = "wasm32"))] + { + let filter = filter.clone(); + let metrics_result = task::spawn_blocking(move || { + let decoder = GzDecoder::new(stream_reader); + let mut archive = Archive::new(decoder); + + let entries = archive.entries().map_err(|e| { + AnalysisError::archive(format!("Failed to read tar entries: {}", e)) + })?; + + let mut collected_metrics = Vec::new(); + let mut stats = FilterStats::new(); + + for entry in entries { + let entry = entry.map_err(|e| { + AnalysisError::archive(format!("Failed to read tar entry: {}", e)) + })?; + + if let Ok(metrics) = process_tar_entry_sync(entry, &filter, &mut stats) { + collected_metrics.push(metrics); + } + } + + #[cfg(feature = "cli")] + log::info!( + "Filter stats: processed {}/{} files ({:.1}% filtered), saved {}", + stats.processed, + stats.total_entries, + stats.filter_ratio() * 100.0, + stats.format_bytes_saved() + ); + + Ok::, AnalysisError>(collected_metrics) + }) + .await + .map_err(|e| AnalysisError::archive(format!("Task join error: {}", e)))??; + + for metrics in metrics_result { + project_analysis.add_file_metrics(metrics)?; + } + } + + #[cfg(target_arch = "wasm32")] + { + let decoder = GzDecoder::new(stream_reader); + let mut archive = Archive::new(decoder); + + let entries = archive + .entries() + .map_err(|e| AnalysisError::archive(format!("Failed to read tar entries: {}", e)))?; + + let mut stats = FilterStats::new(); + + for entry in entries { + let entry = entry + .map_err(|e| AnalysisError::archive(format!("Failed to read tar entry: {}", e)))?; + + if let Ok(metrics) = process_tar_entry_sync(entry, filter, &mut stats) { + project_analysis.add_file_metrics(metrics)?; + } + } + + web_sys::console::log_1( + &format!( + "Filter stats: processed {}/{} files ({:.1}% filtered), saved {}", + stats.processed, + stats.total_entries, + stats.filter_ratio() * 100.0, + stats.format_bytes_saved() + ) + .into(), + ); + } + + Ok(()) +} + +fn process_tar_entry_sync( + mut entry: tar::Entry<'_, R>, + filter: &IntelligentFilter, + stats: &mut FilterStats, +) -> Result { + let header = entry.header(); + let path = header + .path() + .map_err(|e| AnalysisError::archive(format!("Invalid path in tar entry: {}", e)))?; + + let file_path = path.to_string_lossy().to_string(); + + if !header.entry_type().is_file() || header.size().unwrap_or(0) == 0 { + return Err(AnalysisError::archive("Not a file or empty".to_string())); + } + + let file_size = header.size().unwrap_or(0); + + let should_process = filter.should_process_file(&file_path, file_size); + stats.record_entry(file_size, !should_process); + + if !should_process { + return Err(AnalysisError::archive("File filtered out".to_string())); + } + + let language = LanguageRegistry::detect_by_path(&file_path) + .map(|l| l.name.clone()) + .unwrap_or_else(|| "Text".to_string()); + + let mut content = String::new(); + if entry.read_to_string(&mut content).is_err() { + return Err(AnalysisError::archive( + "Failed to read file content".to_string(), + )); + } + + analyze_file_content(&file_path, &content, &language, file_size) +} + +fn analyze_file_content( + file_path: &str, + content: &str, + language: &str, + file_size: u64, +) -> Result { + let lines: Vec<&str> = content.lines().collect(); + let total_lines = lines.len(); + + let mut code_lines = 0; + let mut comment_lines = 0; + let mut blank_lines = 0; + + let lang_def = LanguageRegistry::get_language(language); + let empty_line_comments = vec![]; + let empty_multi_line_comments = vec![]; + let line_comments = lang_def + .map(|l| &l.line_comments) + .unwrap_or(&empty_line_comments); + let multi_line_comments = lang_def + .map(|l| &l.multi_line_comments) + .unwrap_or(&empty_multi_line_comments); + + let mut in_multi_line_comment = false; + + for line in lines { + let trimmed = line.trim(); + + if trimmed.is_empty() { + blank_lines += 1; + continue; + } + + let mut is_comment = false; + + if !in_multi_line_comment { + for comment_start in line_comments { + if trimmed.starts_with(comment_start) { + is_comment = true; + break; + } + } + + for (start, end) in multi_line_comments { + if trimmed.starts_with(start) { + is_comment = true; + if !trimmed.ends_with(end) { + in_multi_line_comment = true; + } + break; + } + } + } else { + is_comment = true; + for (_, end) in multi_line_comments { + if trimmed.ends_with(end) { + in_multi_line_comment = false; + break; + } + } + } + + if is_comment { + comment_lines += 1; + } else { + code_lines += 1; + } + } + + let metrics = FileMetrics::new( + file_path, + language.to_string(), + total_lines, + code_lines, + comment_lines, + blank_lines, + )? + .with_size_bytes(file_size); + + Ok(metrics) +} diff --git a/src/net/traits.rs b/src/net/traits.rs new file mode 100644 index 0000000..a115159 --- /dev/null +++ b/src/net/traits.rs @@ -0,0 +1,403 @@ +use async_trait::async_trait; +use reqwest::Client; +use std::collections::HashMap; +use std::time::Duration; + +/// Progress hook trait for monitoring download and processing progress +pub trait ProgressHook: Send + Sync { + /// Called when download progress is updated + /// + /// # Arguments + /// * `downloaded` - Number of bytes downloaded so far + /// * `total` - Total size in bytes (if known) + fn on_download_progress(&self, downloaded: u64, total: Option); + + /// Called when processing starts with a status message + /// + /// # Arguments + /// * `message` - Status message describing current operation + fn on_processing_start(&self, message: &str); + + /// Called when processing progress is updated + /// + /// # Arguments + /// * `current` - Current item being processed + /// * `total` - Total items to process + fn on_processing_progress(&self, current: usize, total: usize); +} + +/// No-operation progress hook that ignores all progress updates +pub struct NoOpProgressHook; + +impl ProgressHook for NoOpProgressHook { + fn on_download_progress(&self, _downloaded: u64, _total: Option) {} + fn on_processing_start(&self, _message: &str) {} + fn on_processing_progress(&self, _current: usize, _total: usize) {} +} + +/// Universal configuration for all Git providers +#[derive(Debug, Clone)] +pub struct ProviderConfig { + /// Custom HTTP headers to include in requests + pub headers: HashMap, + + /// Request timeout in seconds (None for default) + pub timeout: Option, + + /// Maximum number of redirects to follow + pub max_redirects: Option, + + /// User agent string to use for requests + pub user_agent: Option, + + /// Whether to accept invalid SSL certificates + pub accept_invalid_certs: bool, + + /// Authentication credentials (varies by provider) + pub credentials: HashMap, + + /// Provider-specific settings + pub provider_settings: HashMap, + + /// Maximum file size to download in bytes + pub max_file_size: Option, + + /// Whether to use compression for requests + pub use_compression: bool, + + /// Custom proxy URL + pub proxy: Option, +} + +impl Default for ProviderConfig { + fn default() -> Self { + Self { + headers: HashMap::new(), + timeout: Some(300), // 5 minutes default + max_redirects: Some(10), + user_agent: Some("bytes-radar/1.0.0".to_string()), + accept_invalid_certs: false, + credentials: HashMap::new(), + provider_settings: HashMap::new(), + max_file_size: Some(100 * 1024 * 1024), // 100MB default + use_compression: true, + proxy: None, + } + } +} + +impl ProviderConfig { + /// Create a new configuration with default values + pub fn new() -> Self { + Self::default() + } + + /// Set a custom header + /// + /// # Arguments + /// * `name` - Header name + /// * `value` - Header value + pub fn with_header(mut self, name: impl Into, value: impl Into) -> Self { + self.headers.insert(name.into(), value.into()); + self + } + + /// Set request timeout in seconds + /// + /// # Arguments + /// * `timeout` - Timeout in seconds + pub fn with_timeout(mut self, timeout: u64) -> Self { + self.timeout = Some(timeout); + self + } + + /// Set user agent string + /// + /// # Arguments + /// * `user_agent` - User agent string + pub fn with_user_agent(mut self, user_agent: impl Into) -> Self { + self.user_agent = Some(user_agent.into()); + self + } + + /// Set whether to accept invalid SSL certificates + /// + /// # Arguments + /// * `accept` - Whether to accept invalid certificates + pub fn with_accept_invalid_certs(mut self, accept: bool) -> Self { + self.accept_invalid_certs = accept; + self + } + + /// Set authentication credentials + /// + /// # Arguments + /// * `key` - Credential key (e.g., "token", "username") + /// * `value` - Credential value + pub fn with_credential(mut self, key: impl Into, value: impl Into) -> Self { + self.credentials.insert(key.into(), value.into()); + self + } + + /// Set provider-specific setting + /// + /// # Arguments + /// * `key` - Setting key + /// * `value` - Setting value + pub fn with_provider_setting( + mut self, + key: impl Into, + value: impl Into, + ) -> Self { + self.provider_settings.insert(key.into(), value.into()); + self + } + + /// Set maximum file size in bytes + /// + /// # Arguments + /// * `size` - Maximum file size in bytes + pub fn with_max_file_size(mut self, size: u64) -> Self { + self.max_file_size = Some(size); + self + } + + /// Set proxy URL + /// + /// # Arguments + /// * `proxy` - Proxy URL + pub fn with_proxy(mut self, proxy: impl Into) -> Self { + self.proxy = Some(proxy.into()); + self + } +} + +/// Parsed repository information from a URL +#[derive(Debug, Clone)] +pub struct ParsedRepository { + /// Repository owner/organization + pub owner: String, + + /// Repository name + pub repo: String, + + /// Branch name or commit hash (if specified) + pub branch_or_commit: Option, + + /// Whether branch_or_commit is a commit hash + pub is_commit: bool, + + /// Generated project name for display + pub project_name: String, + + /// Host name (e.g., "github.com") + pub host: Option, +} + +impl ParsedRepository { + /// Create a new parsed repository with default main branch + /// + /// # Arguments + /// * `owner` - Repository owner + /// * `repo` - Repository name + pub fn new(owner: String, repo: String) -> Self { + let project_name = format!("{}@main", repo); + Self { + owner, + repo, + branch_or_commit: None, + is_commit: false, + project_name, + host: None, + } + } + + /// Set the branch and update project name + /// + /// # Arguments + /// * `branch` - Branch name + pub fn with_branch(mut self, branch: String) -> Self { + self.project_name = format!("{}@{}", self.repo, branch); + self.branch_or_commit = Some(branch); + self.is_commit = false; + self + } + + /// Set the commit hash and update project name + /// + /// # Arguments + /// * `commit` - Commit hash + pub fn with_commit(mut self, commit: String) -> Self { + let short_commit = &commit[..7.min(commit.len())]; + self.project_name = format!("{}@{}", self.repo, short_commit); + self.branch_or_commit = Some(commit); + self.is_commit = true; + self + } + + /// Set the host name + /// + /// # Arguments + /// * `host` - Host name + pub fn with_host(mut self, host: String) -> Self { + self.host = Some(host); + self + } +} + +/// Git provider trait for handling different repository hosting services +#[async_trait] +pub trait GitProvider: Send + Sync { + /// Get the provider name (e.g., "github", "gitlab") + fn name(&self) -> &'static str; + + /// Check if this provider can handle the given URL + /// + /// # Arguments + /// * `url` - URL to check + fn can_handle(&self, url: &str) -> bool; + + /// Parse a URL into repository information + /// + /// # Arguments + /// * `url` - URL to parse + fn parse_url(&self, url: &str) -> Option; + + /// Build download URLs for the parsed repository + /// + /// # Arguments + /// * `parsed` - Parsed repository information + fn build_download_urls(&self, parsed: &ParsedRepository) -> Vec; + + /// Get the default branch for a repository (if supported) + /// + /// # Arguments + /// * `client` - HTTP client to use + /// * `parsed` - Parsed repository information + async fn get_default_branch( + &self, + client: &Client, + parsed: &ParsedRepository, + ) -> Option; + + /// Apply configuration to this provider + /// + /// # Arguments + /// * `config` - Configuration to apply + fn apply_config(&mut self, config: &ProviderConfig); + + /// Get project name from URL + /// + /// # Arguments + /// * `url` - URL to extract project name from + fn get_project_name(&self, url: &str) -> String; + + /// Build HTTP client with provider-specific configuration + /// + /// # Arguments + /// * `config` - Configuration to use + fn build_client( + &self, + config: &ProviderConfig, + ) -> Result> { + let mut builder = Client::builder(); + + // Set user agent + if let Some(ref user_agent) = config.user_agent { + builder = builder.user_agent(user_agent); + } + + // Set timeout (works on both wasm and native) + if let Some(timeout) = config.timeout { + #[cfg(not(target_arch = "wasm32"))] + { + builder = builder.timeout(Duration::from_secs(timeout)); + } + } + + // Set redirects + #[cfg(not(target_arch = "wasm32"))] + if let Some(max_redirects) = config.max_redirects { + builder = builder.redirect(reqwest::redirect::Policy::limited(max_redirects as usize)); + } + + // Set SSL verification + #[cfg(not(target_arch = "wasm32"))] + if config.accept_invalid_certs { + builder = builder.danger_accept_invalid_certs(true); + } + + // Set compression + #[cfg(not(target_arch = "wasm32"))] + if !config.use_compression { + builder = builder.no_gzip(); + builder = builder.no_brotli(); + builder = builder.no_deflate(); + } + + // Set proxy (only on native) + #[cfg(not(target_arch = "wasm32"))] + if let Some(ref proxy) = config.proxy { + let proxy = reqwest::Proxy::all(proxy)?; + builder = builder.proxy(proxy); + } + + // Build default headers + let mut headers = reqwest::header::HeaderMap::new(); + + // Add custom headers + for (name, value) in &config.headers { + let header_name = reqwest::header::HeaderName::from_bytes(name.as_bytes())?; + let header_value = reqwest::header::HeaderValue::from_str(value)?; + headers.insert(header_name, header_value); + } + + // Add provider-specific auth headers + self.add_auth_headers(&mut headers, config)?; + + if !headers.is_empty() { + builder = builder.default_headers(headers); + } + + Ok(builder.build()?) + } + + /// Add authentication headers specific to this provider + /// + /// # Arguments + /// * `headers` - Header map to add to + /// * `config` - Configuration containing credentials + fn add_auth_headers( + &self, + _headers: &mut reqwest::header::HeaderMap, + _config: &ProviderConfig, + ) -> Result<(), Box> { + // Default implementation does nothing + // Providers can override this + Ok(()) + } + + /// Validate configuration for this provider + /// + /// # Arguments + /// * `config` - Configuration to validate + fn validate_config(&self, config: &ProviderConfig) -> Result<(), String> { + // Basic validation + if let Some(timeout) = config.timeout { + if timeout == 0 { + return Err("Timeout cannot be zero".to_string()); + } + if timeout > 3600 { + return Err("Timeout cannot exceed 1 hour".to_string()); + } + } + + if let Some(max_file_size) = config.max_file_size { + if max_file_size > 1024 * 1024 * 1024 { + return Err("Max file size cannot exceed 1GB".to_string()); + } + } + + Ok(()) + } +} diff --git a/src/server/mod.rs b/src/server/mod.rs deleted file mode 100644 index c4c6099..0000000 --- a/src/server/mod.rs +++ /dev/null @@ -1,34 +0,0 @@ -use crate::core::RemoteAnalyzer; -use wasm_bindgen::prelude::*; - -#[derive(serde::Serialize, serde::Deserialize)] -pub struct AnalysisOptions { - pub ignore_hidden: bool, - pub ignore_gitignore: bool, - pub max_file_size: i64, -} - -#[cfg(feature = "worker")] -#[wasm_bindgen] -pub async fn analyze_url_server(url: &str, options: JsValue) -> Result { - let _opts: AnalysisOptions = serde_wasm_bindgen::from_value(options) - .map_err(|e| JsValue::from_str(&format!("Failed to parse options: {}", e)))?; - - let mut analyzer = RemoteAnalyzer::new(); - analyzer.set_timeout(300); - - match analyzer.analyze_url(url).await { - Ok(analysis) => { - web_sys::console::log_1(&format!("Server: Successfully analyzed project: {}", analysis.project_name).into()); - serde_wasm_bindgen::to_value(&analysis).map_err(|e| { - JsValue::from_str(&format!("Failed to serialize analysis result: {}", e)) - }) - } - Err(e) => { - web_sys::console::log_1(&format!("Server: Analysis failed: {}", e).into()); - Err(JsValue::from_str(&format!("Analysis failed: {}", e))) - } - } -} - - \ No newline at end of file diff --git a/src/worker.rs b/src/worker.rs index 142e89c..00efe08 100644 --- a/src/worker.rs +++ b/src/worker.rs @@ -1,4 +1,4 @@ -use crate::core::{filter::IntelligentFilter, net::RemoteAnalyzer}; +use crate::{core::filter::IntelligentFilter, net::RemoteAnalyzer}; use wasm_bindgen::prelude::*; #[derive(serde::Serialize, serde::Deserialize)] @@ -23,9 +23,11 @@ pub async fn analyze_url(url: String, options: JsValue) -> Result Result; +`; + +fs.writeFileSync( + path.join(process.cwd(), "worker/pkg", "bytes_radar.d.ts"), + typesContent, +); + +console.log("Build complete!"); diff --git a/server/package.json b/worker/package.json similarity index 99% rename from server/package.json rename to worker/package.json index 75117f5..0232f10 100644 --- a/server/package.json +++ b/worker/package.json @@ -16,4 +16,4 @@ "typescript": "^5.3.3", "wrangler": "^3.28.1" } -} \ No newline at end of file +} diff --git a/server/tsconfig.json b/worker/tsconfig.json similarity index 99% rename from server/tsconfig.json rename to worker/tsconfig.json index 3f6ae45..a8b16ad 100644 --- a/server/tsconfig.json +++ b/worker/tsconfig.json @@ -17,4 +17,4 @@ }, "include": ["./**/*"], "exclude": ["node_modules"] -} \ No newline at end of file +} diff --git a/server/types.d.ts b/worker/types.d.ts similarity index 99% rename from server/types.d.ts rename to worker/types.d.ts index 3c4b72b..f0213b0 100644 --- a/server/types.d.ts +++ b/worker/types.d.ts @@ -11,4 +11,4 @@ declare module "*.wasm?module" { declare module "*.wasm?url" { const content: string; export default content; -} \ No newline at end of file +} diff --git a/worker/worker.ts b/worker/worker.ts new file mode 100644 index 0000000..722a6ab --- /dev/null +++ b/worker/worker.ts @@ -0,0 +1,236 @@ +import type { AnalyzeOptions } from "./pkg/bytes_radar"; +import wasmBinary from "./pkg/bytes_radar_bg.wasm"; + +export interface Env { + BYTES_RADAR: DurableObjectNamespace; + LOG_LEVEL?: string; + ENVIRONMENT?: string; +} + +export class BytesRadar { + state: DurableObjectState; + env: Env; + private wasmModule: any = null; + private wasmInitialized = false; + + constructor(state: DurableObjectState, env: Env) { + this.state = state; + this.env = env; + } + + private log( + level: "debug" | "info" | "warn" | "error", + message: string, + data?: any, + ) { + const logLevel = this.env.LOG_LEVEL || "info"; + const environment = this.env.ENVIRONMENT || "development"; + + const levels = { debug: 0, info: 1, warn: 2, error: 3 }; + const currentLevel = levels[logLevel as keyof typeof levels] || 1; + const messageLevel = levels[level]; + + if (messageLevel >= currentLevel) { + const timestamp = new Date().toISOString(); + const logEntry = { + timestamp, + level: level.toUpperCase(), + environment, + message, + ...(data && { data }), + }; + + const fn = { + debug: console.debug, + info: console.info, + warn: console.warn, + error: console.error, + }; + + if (environment === "production") { + fn[level](JSON.stringify(logEntry)); + } else { + fn[level](`[${level.toUpperCase()}] ${message}`, data ? data : ""); + } + } + } + + private async initializeWasm() { + if (!this.wasmInitialized) { + try { + this.wasmModule = await import("./pkg/bytes_radar"); + await this.wasmModule.default(wasmBinary); + this.wasmInitialized = true; + this.log("info", "WebAssembly module initialized successfully"); + } catch (error) { + this.log("error", "Failed to initialize WebAssembly module", { + error: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + } + + async fetch(request: Request) { + const url = new URL(request.url); + if (url.pathname === "/favicon.ico") { + return new Response(null, { status: 404 }); + } + + const startTime = performance.now(); + const debugInfo: any = { + timestamp: new Date().toISOString(), + wasm_initialized: this.wasmInitialized, + }; + + try { + await this.initializeWasm(); + debugInfo.wasm_initialized = true; + + const pathParts = url.pathname.split("/").filter(Boolean); + const targetUrl = pathParts.join("/"); + + if (!targetUrl) { + debugInfo.error = "Missing repository path"; + debugInfo.duration_ms = performance.now() - startTime; + return new Response( + JSON.stringify({ + error: "Missing repository path", + usage: [ + "/[user/repo", + "/user/repo@master", + "/github.com/user/repo", + "/gitlab.com/user/repo", + "http://example.com/example-asset.tar.gz", + ], + debug_info: debugInfo, + }), + { + status: 400, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }, + }, + ); + } + + const maxSizeParam = url.searchParams.get("max_size"); + const ignoreHiddenParam = url.searchParams.get("ignore_hidden"); + const ignoreGitignoreParam = url.searchParams.get("ignore_gitignore"); + + const options: AnalyzeOptions = { + ignore_hidden: ignoreHiddenParam === "false" ? false : true, + ignore_gitignore: ignoreGitignoreParam === "false" ? false : true, + max_file_size: + maxSizeParam === "-1" + ? -1 + : maxSizeParam + ? parseInt(maxSizeParam) + : -1, + }; + + debugInfo.target_url = targetUrl; + debugInfo.options = options; + + this.log("info", "Starting analysis", { url: targetUrl, options }); + + const analysisStartTime = performance.now(); + const result = await this.wasmModule.analyze_url(targetUrl, options); + const analysisEndTime = performance.now(); + + debugInfo.analysis_duration_ms = analysisEndTime - analysisStartTime; + debugInfo.total_duration_ms = analysisEndTime - startTime; + + if (result && result.wasm_debug_info) { + Object.assign(debugInfo, result.wasm_debug_info); + delete result.wasm_debug_info; + } + + const response = { + ...result, + debug_info: debugInfo, + }; + + this.log("info", "Analysis completed successfully", debugInfo); + + return new Response(JSON.stringify(response), { + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }, + }); + } catch (error: unknown) { + let errorMessage = error instanceof Error ? error.message : String(error); + const errorStack = error instanceof Error ? error.stack : undefined; + let errorType = "UnknownError"; + + if (error && typeof error === "object" && "error" in error) { + const wasmError = error as any; + errorMessage = wasmError.error || errorMessage; + errorType = wasmError.error_type || "WASMError"; + + if (wasmError.wasm_debug_info) { + Object.assign(debugInfo, wasmError.wasm_debug_info); + delete wasmError.wasm_debug_info; + } + } + + debugInfo.error = errorMessage; + debugInfo.error_type = errorType; + debugInfo.error_stack = errorStack; + debugInfo.duration_ms = performance.now() - startTime; + + if (errorMessage.includes("URL parsing error")) { + debugInfo.error_category = "URL_PARSING"; + debugInfo.suggested_fix = + "Please check the URL format. Use formats like: user/repo, user/repo@branch, or full GitHub URLs"; + } else if ( + errorMessage.includes("network") || + errorMessage.includes("download") + ) { + debugInfo.error_category = "NETWORK"; + debugInfo.suggested_fix = + "Check your internet connection and ensure the repository is accessible"; + } else if (errorMessage.includes("branch")) { + debugInfo.error_category = "BRANCH_ACCESS"; + debugInfo.suggested_fix = + "The repository may not have the expected default branches (main, master, develop, dev)"; + } else { + debugInfo.error_category = "UNKNOWN"; + debugInfo.suggested_fix = + "Please check the error details and try again"; + } + + this.log("error", "Error in BytesRadar fetch", debugInfo); + + const errorResponse: any = { + error: errorMessage, + error_type: errorType, + error_category: debugInfo.error_category, + suggested_fix: debugInfo.suggested_fix, + debug_info: debugInfo, + }; + + return new Response(JSON.stringify(errorResponse), { + status: 500, + headers: { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }, + }); + } + } +} + +export default { + async fetch( + request: Request, + env: Env, + ctx: ExecutionContext, + ): Promise { + const id = env.BYTES_RADAR.idFromName("default"); + const obj = env.BYTES_RADAR.get(id); + return obj.fetch(request); + }, +}; diff --git a/wrangler.toml b/wrangler.toml index cbd70c7..1509fc5 100644 --- a/wrangler.toml +++ b/wrangler.toml @@ -1,9 +1,9 @@ name = "bradar" -main = "server/worker.ts" +main = "worker/worker.ts" compatibility_date = "2024-01-01" [build] -command = "node server/build.js" +command = "node worker/build.js" [durable_objects] bindings = [