From e980647c3de02964c6bd222a5fb0557a0fcc5390 Mon Sep 17 00:00:00 2001 From: Harkaran Brar Date: Sat, 6 Dec 2025 17:22:03 -0800 Subject: [PATCH 1/2] feat: implement automated release workflow with multi-target builds - Add GitHub Actions workflow to trigger releases on pull request merges - Extract version from Cargo.toml and create tagged release using GitHub CLI - Build and upload binaries for Linux, Windows, and macOS (x86_64 and ARM64) - Integrate diff parser for classifying added lines into code, comments, docstrings, and blanks for purity metrics - Ensure no duplicate releases and fail on existing tags for version safety --- .github/workflows/auto-release-on-merge.yml | 115 ++++++ .github/workflows/ci.yml | 49 +++ .gitignore | 21 ++ .pre-commit-hooks.yaml | 7 + CONTRIBUTING.md | 61 ++++ Cargo.lock | 269 ++++++++++++++ Cargo.toml | 8 + LICENSE | 21 ++ README.md | 146 +++++++- install.ps1 | 79 +++++ install.sh | 97 +++++ src/classifier.rs | 370 ++++++++++++++++++++ src/diff.rs | 29 ++ src/lib.rs | 38 ++ src/main.rs | 138 ++++++++ src/parser.rs | 154 ++++++++ src/report.rs | 234 +++++++++++++ src/stats.rs | 38 ++ 18 files changed, 1873 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/auto-release-on-merge.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .pre-commit-hooks.yaml create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE create mode 100644 install.ps1 create mode 100644 install.sh create mode 100644 src/classifier.rs create mode 100644 src/diff.rs create mode 100644 src/lib.rs create mode 100644 src/main.rs create mode 100644 src/parser.rs create mode 100644 src/report.rs create mode 100644 src/stats.rs diff --git a/.github/workflows/auto-release-on-merge.yml b/.github/workflows/auto-release-on-merge.yml new file mode 100644 index 0000000..2a04ec7 --- /dev/null +++ b/.github/workflows/auto-release-on-merge.yml @@ -0,0 +1,115 @@ +name: Auto Release on Merge + +on: + pull_request: + types: [closed] + +permissions: + contents: write + +jobs: + prepare_release: + if: github.event.pull_request.merged == true + name: Prepare Release + runs-on: ubuntu-latest + outputs: + release_tag: ${{ steps.get_version.outputs.version_tag }} + steps: + - name: Checkout Code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + + - name: Get Version from Cargo.toml + id: get_version + run: | + VERSION=$(grep -m1 'version =' Cargo.toml | cut -d '"' -f 2) + echo "Found version: $VERSION" + echo "version_tag=v$VERSION" >> "$GITHUB_OUTPUT" + + - name: Check and Create Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ steps.get_version.outputs.version_tag }} + SHA: ${{ github.event.pull_request.merge_commit_sha }} + run: | + if gh release view "$TAG" > /dev/null 2>&1; then + echo "Error: Release $TAG already exists." + exit 1 + else + echo "Creating release $TAG for commit $SHA" + gh release create "$TAG" --target "$SHA" --generate-notes + fi + + build_and_upload: + needs: prepare_release + name: Build and Upload Assets + runs-on: ${{ matrix.os }} + defaults: + run: + shell: bash + strategy: + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + artifact_name: purecode + asset_name: purecode-x86_64-unknown-linux-gnu.tar.gz + - os: ubuntu-latest + target: x86_64-unknown-linux-musl + artifact_name: purecode + asset_name: purecode-x86_64-unknown-linux-musl.tar.gz + - os: windows-latest + target: x86_64-pc-windows-msvc + artifact_name: purecode.exe + asset_name: purecode-x86_64-pc-windows-msvc.zip + - os: macos-13 + target: x86_64-apple-darwin + artifact_name: purecode + asset_name: purecode-x86_64-apple-darwin.tar.gz + - os: macos-14 + target: aarch64-apple-darwin + artifact_name: purecode + asset_name: purecode-aarch64-apple-darwin.tar.gz + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.merge_commit_sha }} + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + targets: ${{ matrix.target }} + + - name: Install musl-tools (Linux musl) + if: contains(matrix.target, 'musl') + run: | + sudo apt-get update + sudo apt-get install -y musl-tools + + - name: Build Release + run: cargo build --release --target ${{ matrix.target }} + + - name: Package (Unix) + if: runner.os != 'Windows' + run: | + cd target/${{ matrix.target }}/release + tar -czf ../../../${{ matrix.asset_name }} ${{ matrix.artifact_name }} + cd - + + - name: Package (Windows) + if: runner.os == 'Windows' + run: | + cd target/${{ matrix.target }}/release + 7z a ../../../${{ matrix.asset_name }} ${{ matrix.artifact_name }} + cd - + + - name: Upload Release Asset + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.prepare_release.outputs.release_tag }} + run: | + gh release upload "$TAG" "${{ matrix.asset_name }}" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..425eb6d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,49 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +env: + CARGO_TERM_COLOR: always + +jobs: + build-and-test: + name: Build and Test + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + include: + - os: ubuntu-latest + artifact_name: purecode-linux-amd64 + asset_name: purecode + - os: macos-latest + artifact_name: purecode-macos-amd64 + asset_name: purecode + - os: windows-latest + artifact_name: purecode-windows-amd64.exe + asset_name: purecode.exe + + steps: + - uses: actions/checkout@v3 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + toolchain: stable + + - name: Build Release + run: cargo build --release + + - name: Run Tests + run: cargo test --release + + - name: Upload Artifact + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact_name }} + path: target/release/${{ matrix.asset_name }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad67955 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Generated by Cargo +# will have compiled files and executables +debug +target + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Generated by cargo mutants +# Contains mutation testing data +**/mutants.out*/ + +# RustRover +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 0000000..065cf90 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,7 @@ +- id: purecode + name: purecode + entry: purecode + language: system + pass_filenames: false + stages: [commit-msg, pre-commit] + args: ["--stdin"] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..8ef38e6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# Contributing to PureCode + +Thank you for your interest in contributing to `purecode`! We want to make this tool the standard for semantic diff analysis. + +## Prerequisites + +- **Rust**: You will need a stable Rust toolchain installed. We recommend using [rustup](https://rustup.rs/). +- **Git**: For version control. + +## Local Development + +1. **Clone the repository**: + ```bash + git clone https://github.com/yourusername/purecode.git + cd purecode + ``` + +2. **Build the project**: + ```bash + cargo build + ``` + +3. **Run tests**: + ```bash + cargo test + ``` + +4. **Run the tool locally**: + You can run the CLI against the repo itself to test changes: + ```bash + cargo run -- --base HEAD~1 --head HEAD + ``` + +## Coding Guidelines + +- **Idiomatic Rust**: We strive for clean, idiomatic Rust code (Edition 2021). +- **Formatting**: Please run `cargo fmt` before submitting. +- **Linting**: We use `clippy` to catch common mistakes. Please run `cargo clippy` and address warnings. + ```bash + cargo clippy -- -D warnings + ``` +- **Documentation**: Public structs and functions should have documentation comments (`///`). + +## Branching and Pull Requests + +1. Create a new branch for your feature or fix. We recommend naming it `feature/your-feature-name` or `fix/issue-description`. +2. Make your changes. +3. Add tests for any new logic (especially new classifiers). +4. Ensure all tests pass. +5. Push your branch and open a Pull Request against `main`. + +## CI/CD + +Our GitHub Actions workflow automatically: +- Builds the project on Linux, macOS, and Windows. +- Runs the test suite. +- (On PRs) Generates binary artifacts that you can download to verify behavior on different OSes. + +## Reporting Issues + +If you find a bug or have a feature request, please open an issue in the GitHub repository describing the problem and how to reproduce it. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..084c167 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,269 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "0.6.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" + +[[package]] +name = "anstyle-parse" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "clap" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" + +[[package]] +name = "colorchoice" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" + +[[package]] +name = "colored" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fde0e0ec90c9dfb3b4b1a0891a7dcd0e2bffde2f7efed5fe7c9bb00e5bfb915e" +dependencies = [ + "windows-sys 0.59.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "purecode" +version = "0.1.0" +dependencies = [ + "clap", + "colored", +] + +[[package]] +name = "quote" +version = "1.0.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "390cc9a294ab71bdb1aa2e99d13be9c753cd2d7bd6560c77118597410c4d2e87" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..73d9999 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "purecode" +version = "0.1.0" +edition = "2021" + +[dependencies] +clap = { version = "4.4", features = ["derive"] } +colored = "3.0.0" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..492a0aa --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 isupervillain + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b4df3b0..4d1631d 100644 --- a/README.md +++ b/README.md @@ -1 +1,145 @@ -# purecode \ No newline at end of file +# PureCode + +`purecode` is a production-grade Rust CLI tool that analyzes **git diffs** to provide meaningful insights into your code changes. Unlike standard line counters, it distinguishes between "pure code" and "noise" (comments, docstrings, and blank lines), helping you understand the real impact of a pull request or commit. + +## Features + +- **Language-Aware Analysis**: Automatically detects languages and applies specific rules for identifying comments and code. +- **Pure Code Metrics**: Calculates `TOTAL` lines changed and `PURE` lines changed (excluding noise). +- **Rich Stats**: Tracks added/removed words, estimated tokens, and breaks down comments vs docstrings. +- **Flexible Input**: + - Run directly on git repositories (comparing branches/commits). + - Pipe unified diffs via stdin (ideal for pre-commit hooks and CI). +- **Thresholds**: Gate CI pipelines by enforcing maximum noise ratios or minimum pure code requirements. +- **Fast**: Built in Rust for high performance. +- **Multiple Formats**: Output Human-readable (colorful), Plain text, or JSON. + +## Supported Languages + +`purecode` supports detecting and classifying code for: +- Python (distinguishes Docstrings vs Comments) +- C-style languages (C, C++, Java, C#, JS, TS, Go, PHP, Swift, Kotlin, Scala) +- Shell / PowerShell +- Ruby +- HTML, CSS, Vue (basic support) + +## Quickstart + +Install with a single command (macOS / Linux): + +```bash +curl -LsSf https://raw.githubusercontent.com/isupervillain/purecode/main/install.sh | sh +``` + +For Windows (PowerShell): + +```powershell +powershell -ExecutionPolicy ByPass -c "irm https://raw.githubusercontent.com/isupervillain/purecode/main/install.ps1 | iex" +``` + +Run in a git repository: + +```bash +# Compare local changes against main +purecode + +# OR pipe a diff manually +git diff --cached --unified=0 --no-color | purecode --stdin +``` + +## How It Works + +1. **Diff Parsing**: `purecode` takes a unified diff (standard git output). It ignores metadata and focuses on lines starting with `+` or `-`. +2. **Language Detection**: It identifies the file type for each hunk (e.g., `.py`, `.rs`, `.js`). +3. **Classification**: + * **Pure Code**: Logic, variable definitions, function calls. + * **Noise**: Comments (`//`, `#`), Docstrings (`"""`, `/**`), and Blank lines. +4. **Aggregation**: It sums up these metrics to show you the "Net Pure Code" added or removed. + +*Note*: For accurate results, always use `--unified=0` when piping diffs. This ensures context lines aren't counted as "noise" or "code". + +## Usage + +### Basic Usage + +Compare `origin/main` (default base) with `HEAD` (default head): + +```bash +purecode +``` + +### Specific Commits/Branches + +```bash +purecode --base v1.0 --head v2.0 +``` + +### Output Formats + +```bash +# Default (Colorful with emojis) +purecode --format human + +# Plain (CI friendly, no colors) +purecode --format plain + +# JSON (Machine readable) +purecode --format json +``` + +### Thresholds & CI Gates + +Fail the command if the PR is "too noisy" or decreases code volume: + +```bash +# Fail if more than 50% of changes are comments/blanks +purecode --max-noise-ratio 0.5 + +# Fail if net pure lines < 10 (ensure significant contribution) +purecode --min-pure-lines 10 + +# Fail if net pure code is negative +purecode --fail-on-decrease +``` + +## Integration + +### Pre-commit Hook + +Add to `.pre-commit-config.yaml`: + +```yaml +repos: + - repo: https://github.com/isupervillain/purecode + rev: v0.1.0 + hooks: + - id: purecode + args: ["--stdin", "--format", "human"] +``` + +### GitHub Actions + +Run `purecode` to check PR quality: + +```yaml +steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Need history for diff + + - name: Install PureCode + run: curl -LsSf https://raw.githubusercontent.com/isupervillain/purecode/main/install.sh | sh + + - name: Run Analysis + run: | + # Check against the PR base + purecode --base origin/${{ github.base_ref }} --head HEAD --format human --max-noise-ratio 0.6 +``` + +## Contributing + +We welcome contributions! Please see [CONTRIBUTING.md](CONTRIBUTING.md) for details. + +## License + +This project is open source. diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..1bc1534 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,79 @@ +# PowerShell Installer for purecode +$ErrorActionPreference = "Stop" + +$Repo = "isupervillain/purecode" +$GitHubUrl = "https://github.com/$Repo/releases/download" +$InstallDir = "$env:USERPROFILE\.purecode\bin" + +# --- Architecture Detection --- +$Arch = $env:PROCESSOR_ARCHITECTURE +if ($Arch -eq "AMD64") { + $Target = "x86_64-pc-windows-msvc" +} else { + Write-Error "Unsupported architecture: $Arch. Only x86_64 is currently supported." + exit 1 +} + +# --- Version Detection --- +Write-Host "Detecting latest version..." +try { + # Fetch latest release info from GitHub API + # Note: Using unauthenticated API might hit rate limits, but usually fine for installers. + # Alternative: Parse the HTML redirect like shell script. + # Let's try the redirect method as it's often more robust against rate limits for anonymous IPs. + $LatestUrl = "https://github.com/$Repo/releases/latest" + $Request = [System.Net.WebRequest]::Create($LatestUrl) + $Request.Method = "HEAD" + $Request.AllowAutoRedirect = $false + try { + $Response = $Request.GetResponse() + # Should be a 302 Found + $FinalUrl = $Response.Headers["Location"] + } catch [System.Net.WebException] { + # If it throws on 302 (Powershell behavior varies), catch it. + if ($_.Exception.Response.StatusCode -eq [System.Net.HttpStatusCode]::Found) { + $FinalUrl = $_.Exception.Response.Headers["Location"] + } else { + throw $_ + } + } + + # URL format: .../releases/tag/vX.Y.Z + $VersionTag = $FinalUrl.Split('/')[-1] +} catch { + Write-Warning "Could not detect version via redirect. Defaulting to 'latest' logic might fail if assets aren't predictable." + Write-Error "Failed to check latest version: $_" + exit 1 +} + +Write-Host "Latest version: $VersionTag" + +$AssetName = "purecode-${Target}.zip" +$DownloadUrl = "$GitHubUrl/$VersionTag/$AssetName" + +# --- Install --- +if (!(Test-Path $InstallDir)) { + New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null +} + +$ZipPath = "$env:TEMP\$AssetName" +Write-Host "Downloading $DownloadUrl ..." +Invoke-WebRequest -Uri $DownloadUrl -OutFile $ZipPath + +Write-Host "Extracting to $InstallDir ..." +Expand-Archive -Path $ZipPath -DestinationPath $InstallDir -Force + +# Cleanup +Remove-Item -Path $ZipPath -Force + +# --- Path --- +$UserPath = [Environment]::GetEnvironmentVariable("Path", "User") +if ($UserPath -notlike "*$InstallDir*") { + Write-Host "Adding $InstallDir to User PATH..." + [Environment]::SetEnvironmentVariable("Path", "$UserPath;$InstallDir", "User") + Write-Host "Added to PATH. Please restart your terminal." +} else { + Write-Host "Install directory is already in PATH." +} + +Write-Host "Successfully installed purecode!" diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..be7df71 --- /dev/null +++ b/install.sh @@ -0,0 +1,97 @@ +#!/bin/sh +set -e + +# --- Configuration --- +REPO="isupervillain/purecode" +# Base URL for assets. We'll use the GitHub releases API structure. +# https://github.com/isupervillain/purecode/releases/download/vX.Y.Z/purecode-. +GITHUB_URL="https://github.com/$REPO/releases/download" + +# Allow overriding the install directory with PURECODE_UNMANAGED_INSTALL +INSTALL_DIR="${PURECODE_UNMANAGED_INSTALL:-$HOME/.local/bin}" +BIN_NAME="purecode" + +# --- Detection --- +OS="$(uname -s)" +ARCH="$(uname -m)" + +case "$OS" in + Linux) + # Prefer musl if available, otherwise gnu? + # Actually, let's stick to musl for broad compatibility as requested. + # But we must ensure the user machine is x86_64 for musl artifact. + if [ "$ARCH" = "x86_64" ]; then + TARGET="x86_64-unknown-linux-musl" + else + # Fallback or error? For now, we only build musl for x86_64 in CI. + # If aarch64 linux, we might not have a build yet. + echo "Error: Unsupported Linux architecture: $ARCH. Only x86_64 is currently supported via this script." + exit 1 + fi + EXT="tar.gz" + ;; + Darwin) + if [ "$ARCH" = "x86_64" ]; then + TARGET="x86_64-apple-darwin" + elif [ "$ARCH" = "arm64" ]; then + TARGET="aarch64-apple-darwin" + else + echo "Error: Unsupported macOS architecture: $ARCH" + exit 1 + fi + EXT="tar.gz" + ;; + *) + echo "Error: Unsupported OS: $OS" + exit 1 + ;; +esac + +# --- Fetch Latest Version --- +# We need to know the version to construct the URL. +# Strategy: fetch the latest release tag from GitHub API (public). +# Or just ask the user/hardcode? No, dynamic is better. +# Using a simple curl to get the latest release redirect URL is a common trick. +echo "Detecting latest version..." +LATEST_URL="https://github.com/$REPO/releases/latest" +# -I headers only, -o /dev/null discard body, -w redirect_url print final url +RELEASE_URL=$(curl -Ls -o /dev/null -w %{url_effective} "$LATEST_URL") +# Extract tag from URL (e.g. .../releases/tag/v0.3.0) +VERSION_TAG=$(basename "$RELEASE_URL") + +if [ -z "$VERSION_TAG" ] || [ "$VERSION_TAG" = "latest" ]; then + echo "Error: Could not detect latest version tag." + exit 1 +fi + +echo "Latest version: $VERSION_TAG" + +ASSET_NAME="purecode-${TARGET}.${EXT}" +DOWNLOAD_URL="${GITHUB_URL}/${VERSION_TAG}/${ASSET_NAME}" + +# --- Download & Install --- +echo "Downloading $DOWNLOAD_URL ..." +TMP_DIR=$(mktemp -d) +trap 'rm -rf "$TMP_DIR"' EXIT + +curl -LsSf "$DOWNLOAD_URL" -o "$TMP_DIR/$ASSET_NAME" + +echo "Installing to $INSTALL_DIR ..." +mkdir -p "$INSTALL_DIR" + +tar -xzf "$TMP_DIR/$ASSET_NAME" -C "$TMP_DIR" +# The tarball contains the binary directly or in a folder? +# CI command: `tar -czf ... purecode` inside the release dir. +# So it should unpack as just `purecode`. +mv "$TMP_DIR/$BIN_NAME" "$INSTALL_DIR/$BIN_NAME" +chmod +x "$INSTALL_DIR/$BIN_NAME" + +echo "Successfully installed purecode to $INSTALL_DIR/$BIN_NAME" + +# --- Path Check --- +case ":$PATH:" in + *":$INSTALL_DIR:"*) ;; + *) echo "Warning: $INSTALL_DIR is not in your PATH. You may need to add it:" + echo " export PATH=\"\$PATH:$INSTALL_DIR\"" + ;; +esac diff --git a/src/classifier.rs b/src/classifier.rs new file mode 100644 index 0000000..61ea3e2 --- /dev/null +++ b/src/classifier.rs @@ -0,0 +1,370 @@ +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +pub enum LineType { + Pure, + Comment, + Docstring, + Blank, +} + +pub trait Classifier { + /// Classifies the line as Pure, Comment, Docstring, or Blank. + /// This method is stateful for multi-line comments. + fn classify(&mut self, line: &str) -> LineType; +} + +pub struct DefaultClassifier; + +impl Classifier for DefaultClassifier { + fn classify(&mut self, line: &str) -> LineType { + if line.trim().is_empty() { + LineType::Blank + } else { + LineType::Pure + } + } +} + +pub struct PythonClassifier { + in_triple_double: bool, + in_triple_single: bool, +} + +impl PythonClassifier { + pub fn new() -> Self { + Self { + in_triple_double: false, + in_triple_single: false, + } + } +} + +impl Default for PythonClassifier { + fn default() -> Self { + Self::new() + } +} + +impl Classifier for PythonClassifier { + fn classify(&mut self, line: &str) -> LineType { + let trimmed = line.trim(); + if trimmed.is_empty() { + return LineType::Blank; + } + + // If we are currently inside a triple-quote block + if self.in_triple_double { + if trimmed.contains("\"\"\"") { + self.in_triple_double = false; + } + return LineType::Docstring; + } + if self.in_triple_single { + if trimmed.contains("'''") { + self.in_triple_single = false; + } + return LineType::Docstring; + } + + // Check for comments + if trimmed.starts_with('#') { + return LineType::Comment; + } + + // Check for start of docstrings + if trimmed.starts_with("\"\"\"") { + let count = line.matches("\"\"\"").count(); + if count >= 2 { + // Open and close on same line -> one-line docstring + return LineType::Docstring; + } else { + // Open, but not close -> enter state + self.in_triple_double = true; + return LineType::Docstring; + } + } + + if trimmed.starts_with("'''") { + let count = line.matches("'''").count(); + if count >= 2 { + return LineType::Docstring; + } else { + self.in_triple_single = true; + return LineType::Docstring; + } + } + + LineType::Pure + } +} + +pub struct CStyleClassifier { + in_block: bool, +} + +impl CStyleClassifier { + pub fn new() -> Self { + Self { in_block: false } + } +} + +impl Default for CStyleClassifier { + fn default() -> Self { + Self::new() + } +} + +impl Classifier for CStyleClassifier { + fn classify(&mut self, line: &str) -> LineType { + let trimmed = line.trim(); + if trimmed.is_empty() { + return LineType::Blank; + } + + if self.in_block { + if let Some(end_idx) = trimmed.find("*/") { + self.in_block = false; + // If there is code after the block ends, treat as pure. + if trimmed[end_idx + 2..].trim().is_empty() { + return LineType::Comment; + } else { + return LineType::Pure; + } + } + return LineType::Comment; + } + + if trimmed.starts_with("//") { + return LineType::Comment; + } + + // Check for Javadoc style continuations + if trimmed.starts_with('*') { + return LineType::Comment; + } + + if let Some(start_idx) = trimmed.find("/*") { + if let Some(end_idx) = trimmed.find("*/") { + // Inline block comment. If there's code outside the comment, count as pure. + let before = trimmed[..start_idx].trim(); + let after = trimmed[end_idx + 2..].trim(); + if before.is_empty() && after.is_empty() { + return LineType::Comment; + } + return LineType::Pure; + } + + // Block starts, doesn't end on this line. + self.in_block = true; + // If there is code before the block starts, treat as pure; otherwise comment. + if trimmed[..start_idx].trim().is_empty() { + return LineType::Comment; + } else { + return LineType::Pure; + } + } + + LineType::Pure + } +} + +pub struct HtmlClassifier { + in_comment: bool, +} + +impl HtmlClassifier { + pub fn new() -> Self { + Self { in_comment: false } + } +} + +impl Default for HtmlClassifier { + fn default() -> Self { + Self::new() + } +} + +impl Classifier for HtmlClassifier { + fn classify(&mut self, line: &str) -> LineType { + let trimmed = line.trim(); + if trimmed.is_empty() { + return LineType::Blank; + } + + if self.in_comment { + if let Some(end_idx) = trimmed.find("-->") { + self.in_comment = false; + if trimmed[end_idx + 3..].trim().is_empty() { + return LineType::Comment; + } else { + return LineType::Pure; + } + } + return LineType::Comment; + } + + if let Some(start_idx) = trimmed.find("") { + let end_idx = start_idx + 4 + end_rel; + let before = trimmed[..start_idx].trim(); + let after = trimmed[end_idx + 3..].trim(); + if before.is_empty() && after.is_empty() { + return LineType::Comment; + } + return LineType::Pure; + } else { + self.in_comment = true; + if trimmed[..start_idx].trim().is_empty() { + return LineType::Comment; + } else { + return LineType::Pure; + } + } + } + + LineType::Pure + } +} + +pub struct ShellClassifier; + +impl Classifier for ShellClassifier { + fn classify(&mut self, line: &str) -> LineType { + let trimmed = line.trim(); + if trimmed.is_empty() { + LineType::Blank + } else if trimmed.starts_with('#') { + LineType::Comment + } else { + LineType::Pure + } + } +} + +pub struct RubyClassifier { + in_block: bool, +} + +impl RubyClassifier { + pub fn new() -> Self { + Self { in_block: false } + } +} + +impl Default for RubyClassifier { + fn default() -> Self { + Self::new() + } +} + +impl Classifier for RubyClassifier { + fn classify(&mut self, line: &str) -> LineType { + let trimmed = line.trim(); + if trimmed.is_empty() { + return LineType::Blank; + } + + if self.in_block { + if trimmed.starts_with("=end") { + self.in_block = false; + } + return LineType::Comment; + } + + if trimmed.starts_with('#') { + return LineType::Comment; + } + + if trimmed.starts_with("=begin") { + self.in_block = true; + return LineType::Comment; + } + + LineType::Pure + } +} + +pub fn get_classifier(lang: &str) -> Box { + match lang { + "Python" => Box::new(PythonClassifier::new()), + "TypeScript" | "JavaScript" | "C" | "C++" | "C#" | "Java" | "Go" | "PHP" | "Swift" + | "Kotlin" | "Scala" | "CSS" => Box::new(CStyleClassifier::new()), + "HTML" | "Vue" => Box::new(HtmlClassifier::new()), + "Shell" | "PowerShell" => Box::new(ShellClassifier), // PowerShell uses # for comments too + "Ruby" => Box::new(RubyClassifier::new()), + _ => Box::new(DefaultClassifier), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_python_classifier() { + let mut c = PythonClassifier::new(); + assert_eq!(c.classify("x = 1"), LineType::Pure); + assert_eq!(c.classify("# comment"), LineType::Comment); + assert_eq!(c.classify(" "), LineType::Blank); + + // Multiline docstring + assert_eq!(c.classify("\"\"\""), LineType::Docstring); // Start block + assert_eq!(c.classify("docs"), LineType::Docstring); // Inside + assert_eq!(c.classify("\"\"\""), LineType::Docstring); // End block + assert_eq!(c.classify("x = 2"), LineType::Pure); + + // One-liner + assert_eq!(c.classify("\"\"\" one line docs \"\"\""), LineType::Docstring); + assert_eq!(c.classify("y = 2"), LineType::Pure); + } + + #[test] + fn test_cstyle_classifier() { + let mut c = CStyleClassifier::new(); + assert_eq!(c.classify("int x = 1;"), LineType::Pure); + assert_eq!(c.classify("// comment"), LineType::Comment); + assert_eq!(c.classify(" "), LineType::Blank); + + // Multiline + assert_eq!(c.classify("/*"), LineType::Comment); + assert_eq!(c.classify(" * inside"), LineType::Comment); + assert_eq!(c.classify("*/"), LineType::Comment); + assert_eq!(c.classify("x = 2;"), LineType::Pure); + + // One-liner + assert_eq!(c.classify("/* comment */"), LineType::Comment); + assert_eq!(c.classify("code(); /* comment */"), LineType::Pure); + assert_eq!(c.classify("code(); /* comment */ more();"), LineType::Pure); + + // Inline block start that continues on next line counts as pure for the code part. + assert_eq!(c.classify("do_work(); /* start"), LineType::Pure); + assert_eq!(c.classify(" * inside block"), LineType::Comment); + assert_eq!(c.classify(" end */"), LineType::Comment); + assert_eq!(c.classify("done();"), LineType::Pure); + } + + #[test] + fn test_ruby_classifier() { + let mut c = RubyClassifier::new(); + assert_eq!(c.classify("x = 1"), LineType::Pure); + assert_eq!(c.classify("# comment"), LineType::Comment); + assert_eq!(c.classify(""), LineType::Blank); + + assert_eq!(c.classify("=begin"), LineType::Comment); + assert_eq!(c.classify("docs"), LineType::Comment); + assert_eq!(c.classify("=end"), LineType::Comment); + assert_eq!(c.classify("y = 2"), LineType::Pure); + } + + #[test] + fn test_html_classifier() { + let mut c = HtmlClassifier::new(); + assert_eq!(c.classify("
hi
"), LineType::Pure); + assert_eq!(c.classify(""), LineType::Comment); + assert_eq!(c.classify("text more"), LineType::Pure); + + // Multiline HTML comments + assert_eq!(c.classify(" trailing"), LineType::Pure); + } +} diff --git a/src/diff.rs b/src/diff.rs new file mode 100644 index 0000000..fd726ea --- /dev/null +++ b/src/diff.rs @@ -0,0 +1,29 @@ +use std::io::{self, BufReader}; +use std::process::{Command, Stdio}; + +pub fn get_git_diff(base: &str, head: &str) -> io::Result> { + let output = Command::new("git") + .args([ + "diff", + &format!("{}...{}", base, head), + "--unified=0", + "--no-color", + ]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn()?; + + let output = output.wait_with_output()?; + + if !output.status.success() { + let err_msg = String::from_utf8_lossy(&output.stderr); + return Err(io::Error::other(format!("git diff failed: {}", err_msg))); + } + + let cursor = std::io::Cursor::new(output.stdout); + Ok(Box::new(BufReader::new(cursor))) +} + +pub fn get_stdin_diff() -> Box { + Box::new(BufReader::new(io::stdin())) +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..4397d09 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,38 @@ +pub mod classifier; +pub mod diff; +pub mod parser; +pub mod report; +pub mod stats; + +/// Detects the programming language based on the file extension. +/// +/// Returns "Other" if the extension is unknown. +pub fn detect_language(path: &str) -> String { + let extension = std::path::Path::new(path) + .extension() + .and_then(|ext| ext.to_str()) + .unwrap_or(""); + + match extension { + "py" => "Python", + "ts" | "tsx" => "TypeScript", + "js" | "jsx" => "JavaScript", + "c" | "h" => "C", + "cpp" | "cc" | "hpp" | "hh" => "C++", + "cs" => "C#", + "java" => "Java", + "go" => "Go", + "swift" => "Swift", + "kt" | "kts" => "Kotlin", + "php" => "PHP", + "scala" => "Scala", + "rb" => "Ruby", + "sh" | "bash" | "zsh" => "Shell", + "ps1" => "PowerShell", + "css" | "scss" => "CSS", + "html" | "htm" => "HTML", + "vue" => "Vue", + _ => "Other", + } + .to_string() +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..b6027bd --- /dev/null +++ b/src/main.rs @@ -0,0 +1,138 @@ +use clap::{Parser, ValueEnum}; +use purecode::{diff, parser, report, stats::LangStats}; +use std::collections::HashMap; +use std::process::exit; + +#[derive(Parser, Debug)] +#[command(name = "purecode")] +#[command(author = "PureCode Author")] +#[command(version = "0.1.0")] +#[command(about = "Analyzes git diffs to count pure code vs noise", long_about = None)] +struct Cli { + /// Base ref for git diff + #[arg(long, default_value = "origin/main")] + base: String, + + /// Head ref for git diff + #[arg(long, default_value = "HEAD")] + head: String, + + /// Read unified diff from stdin instead of running git + #[arg(long)] + stdin: bool, + + /// Output format + #[arg(long, value_enum, default_value_t = Format::Human)] + format: Format, + + /// Fail if noise ratio (comments/blanks) is greater than this value (0.0 - 1.0) + #[arg(long)] + max_noise_ratio: Option, + + /// Fail if the number of net pure lines is less than this value + #[arg(long)] + min_pure_lines: Option, + + /// Fail if the net pure code change is negative + #[arg(long)] + fail_on_decrease: bool, +} + +#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, ValueEnum, Debug)] +enum Format { + Human, + Plain, + Json, +} + +impl From for report::OutputFormat { + fn from(f: Format) -> Self { + match f { + Format::Human => report::OutputFormat::Human, + Format::Plain => report::OutputFormat::Plain, + Format::Json => report::OutputFormat::Json, + } + } +} + +fn main() { + let args = Cli::parse(); + + let reader: Box = if args.stdin { + diff::get_stdin_diff() + } else { + match diff::get_git_diff(&args.base, &args.head) { + Ok(r) => r, + Err(e) => { + eprintln!("Error running git diff: {}", e); + exit(1); + } + } + }; + + let mut stats = HashMap::new(); + if let Err(e) = parser::parse_diff(reader, &mut stats) { + eprintln!("Error parsing diff: {}", e); + exit(1); + } + + report::print_report(&stats, args.format.into()); + + if let Err(e) = check_thresholds(&stats, &args) { + eprintln!("{}", e); + exit(1); + } +} + +fn check_thresholds(stats: &HashMap, args: &Cli) -> Result<(), String> { + // Aggregate overall stats + let mut overall = LangStats::default(); + for s in stats.values() { + overall.total_added += s.total_added; + overall.total_removed += s.total_removed; + overall.pure_added += s.pure_added; + overall.pure_removed += s.pure_removed; + } + + // Check Max Noise Ratio + if let Some(max_ratio) = args.max_noise_ratio { + let total_changes = overall.total_added + overall.total_removed; + if total_changes > 0 { + let pure_changes = overall.pure_added + overall.pure_removed; + let pure_ratio = pure_changes as f64 / total_changes as f64; + let noise_ratio = 1.0 - pure_ratio; + + if noise_ratio > max_ratio { + return Err(format!( + "Failure: Noise ratio {:.2} exceeds limit {:.2}", + noise_ratio, max_ratio + )); + } + } + } + + // Check Min Pure Lines (Total touched) + // Treat "pure lines" as the amount of pure code touched (added or removed), + // so cleanup PRs that delete code still count toward the threshold. + if let Some(min_lines) = args.min_pure_lines { + let pure_touched = overall.pure_added + overall.pure_removed; + if pure_touched < min_lines { + return Err(format!( + "Failure: Pure lines touched {} is less than minimum {}", + pure_touched, min_lines + )); + } + } + + // Check Fail on Decrease + if args.fail_on_decrease { + if overall.net_pure() < 0 { + return Err(format!( + "Failure: Net pure code decreased ({})", + overall.net_pure() + )); + } + } + + Ok(()) +} diff --git a/src/parser.rs b/src/parser.rs new file mode 100644 index 0000000..72f7443 --- /dev/null +++ b/src/parser.rs @@ -0,0 +1,154 @@ +use crate::classifier::{get_classifier, LineType}; +use crate::detect_language; +use crate::stats::LangStats; +use std::collections::HashMap; + +/// Parses a unified diff from the reader and updates statistics. +/// +/// This function reads the diff line by line, detects file changes and languages, +/// uses classifiers to determine if lines are pure code or noise, and aggregates +/// the results into the provided `stats` map. +pub fn parse_diff( + reader: R, + stats: &mut HashMap, +) -> Result<(), std::io::Error> { + let mut current_lang = String::new(); + let mut classifier = get_classifier("Other"); + + for line_result in reader.lines() { + let line = line_result?; + + if line.starts_with("+++ ") { + let path_part = line.trim_start_matches("+++ ").trim(); + + if path_part == "/dev/null" || path_part.ends_with("/dev/null") { + continue; + } + + // Strip prefix a/ or b/ + let clean_path = if let Some(stripped) = path_part.strip_prefix("b/") { + stripped + } else if let Some(stripped) = path_part.strip_prefix("a/") { + stripped + } else { + path_part + }; + + current_lang = detect_language(clean_path); + classifier = get_classifier(¤t_lang); + continue; + } + + if line.starts_with("--- ") { + let path_part = line.trim_start_matches("--- ").trim(); + if path_part != "/dev/null" && !path_part.ends_with("/dev/null") { + let clean_path = if let Some(stripped) = path_part.strip_prefix("a/") { + stripped + } else if let Some(stripped) = path_part.strip_prefix("b/") { + stripped + } else { + path_part + }; + current_lang = detect_language(clean_path); + classifier = get_classifier(¤t_lang); + } + continue; + } + + // Ignore metadata + if line.starts_with("diff --git") + || line.starts_with("index ") + || line.starts_with("new file mode") + || line.starts_with("deleted file mode") + || line.starts_with("@@") + { + continue; + } + + if current_lang.is_empty() { + // Probably haven't seen file header yet or parsing error, skip + continue; + } + + if line.starts_with('+') && !line.starts_with("+++") { + let content = &line[1..]; + let stat = stats.entry(current_lang.clone()).or_default(); + stat.total_added += 1; + + match classifier.classify(content) { + LineType::Pure => { + stat.pure_added += 1; + stat.code_words_added += count_words(content) as i64; + }, + LineType::Comment => stat.comment_lines_added += 1, + LineType::Docstring => stat.docstring_lines_added += 1, + LineType::Blank => stat.blank_lines_added += 1, + } + } else if line.starts_with('-') && !line.starts_with("---") { + let content = &line[1..]; + let stat = stats.entry(current_lang.clone()).or_default(); + stat.total_removed += 1; + + match classifier.classify(content) { + LineType::Pure => { + stat.pure_removed += 1; + stat.code_words_removed += count_words(content) as i64; + }, + LineType::Comment => stat.comment_lines_removed += 1, + LineType::Docstring => stat.docstring_lines_removed += 1, + LineType::Blank => stat.blank_lines_removed += 1, + } + } + } + Ok(()) +} + +fn count_words(line: &str) -> usize { + line.split_whitespace().count() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + #[test] + fn test_parse_diff_synthetic() { + let diff_input = "\ +diff --git a/test.py b/test.py +index 123..456 100644 +--- a/test.py ++++ b/test.py +@@ -1,3 +1,3 @@ +-def foo(): +-# comment ++def bar(): ++ pass +"; + let mut stats = HashMap::new(); + let reader = Cursor::new(diff_input); + parse_diff(reader, &mut stats).unwrap(); + + let py_stats = stats.get("Python").unwrap(); + // Removed: "def foo():", "# comment" + // Added: "def bar():", " pass" + assert_eq!(py_stats.total_removed, 2); + assert_eq!(py_stats.total_added, 2); + + // Pure Removed: "def foo():" (1) + // Pure Added: "def bar():", " pass" (2) + assert_eq!(py_stats.pure_removed, 1); + assert_eq!(py_stats.pure_added, 2); + + // Words + // "def foo():" -> 2 words + // "def bar():" -> 2 words + // " pass" -> 1 word + assert_eq!(py_stats.code_words_removed, 2); + assert_eq!(py_stats.code_words_added, 3); + + // Comments + assert_eq!(py_stats.comment_lines_removed, 1); + assert_eq!(py_stats.comment_lines_added, 0); + } +} diff --git a/src/report.rs b/src/report.rs new file mode 100644 index 0000000..45dc154 --- /dev/null +++ b/src/report.rs @@ -0,0 +1,234 @@ +use crate::stats::LangStats; +use colored::Colorize; +use std::collections::HashMap; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputFormat { + Human, + Plain, + Json, +} + +pub fn print_report(stats: &HashMap, format: OutputFormat) { + match format { + OutputFormat::Human => print_human(stats, true, true), + OutputFormat::Plain => print_human(stats, false, false), + OutputFormat::Json => print_json(stats), + } +} + +fn print_human(stats: &HashMap, use_emojis: bool, use_color: bool) { + // Aggregate overall stats + let mut overall = LangStats::default(); + + let mut languages: Vec<_> = stats.keys().collect(); + languages.sort(); + + for lang in &languages { + if let Some(s) = stats.get(*lang) { + overall.total_added += s.total_added; + overall.total_removed += s.total_removed; + overall.pure_added += s.pure_added; + overall.pure_removed += s.pure_removed; + overall.comment_lines_added += s.comment_lines_added; + overall.comment_lines_removed += s.comment_lines_removed; + overall.docstring_lines_added += s.docstring_lines_added; + overall.docstring_lines_removed += s.docstring_lines_removed; + overall.blank_lines_added += s.blank_lines_added; + overall.blank_lines_removed += s.blank_lines_removed; + overall.code_words_added += s.code_words_added; + overall.code_words_removed += s.code_words_removed; + } + } + + let net_total = overall.net_total(); + let net_pure = overall.net_pure(); + + // Ratios + let total_changes = overall.total_added + overall.total_removed; + let pure_changes = overall.pure_added + overall.pure_removed; + + let pure_ratio = if total_changes > 0 { + (pure_changes as f64 / total_changes as f64) * 100.0 + } else { + 0.0 + }; + let noise_ratio = 100.0 - pure_ratio; + + let e_pure = if use_emojis { "โœ… " } else { "" }; + let e_noise = if use_emojis { "๐Ÿงน " } else { "" }; + let e_summary = if use_emojis { "๐Ÿ“Š " } else { "" }; + let e_sparkles = if use_emojis { "โœจ " } else { "" }; + let e_comments = if use_emojis { "๐Ÿ“ " } else { "" }; + let e_docs = if use_emojis { "๐Ÿ“š " } else { "" }; + + if use_color { + println!("{}", format!("{}=== PureCode Summary ===", e_summary).bold()); + } else { + println!("{}=== PureCode Summary ===", e_summary); + } + + println!( + "TOTAL lines : +{:<4} -{:<4} (net {})", + overall.total_added, overall.total_removed, net_total + ); + println!( + "PURE lines : +{:<4} -{:<4} (net {})", + overall.pure_added, overall.pure_removed, net_pure + ); + println!( + "NOISE lines : +{:<4} -{:<4} (comments/docstrings/blanks)", + overall.total_added - overall.pure_added, + overall.total_removed - overall.pure_removed + ); + println!(); + + if use_color { + println!( + "{}{}", + e_sparkles, + format!("Pure ratio : {:.0}% of changes are pure code", pure_ratio).green() + ); + let noise_msg = format!("Noise : {:.0}% comments & formatting", noise_ratio); + if noise_ratio > 50.0 { + println!("{}{}", e_noise, noise_msg.yellow()); + } else { + println!("{}{}", e_noise, noise_msg.dimmed()); + } + } else { + println!( + "{}Pure ratio : {:.0}% of changes are pure code", + e_sparkles, pure_ratio + ); + println!( + "{}Noise : {:.0}% comments & formatting", + e_noise, noise_ratio + ); + } + println!(); + + if use_color { + println!("{}", "=== Per language ===".bold()); + } else { + println!("=== Per language ==="); + } + + for lang in languages { + if let Some(s) = stats.get(lang) { + if s.total_added == 0 && s.total_removed == 0 { + continue; + } + + if use_color { + println!("{}", lang.cyan().bold()); + } else { + println!("{}", lang); + } + + println!( + " TOTAL : +{:<4} -{:<4} (net {})", + s.total_added, + s.total_removed, + s.net_total() + ); + println!( + " PURE : +{:<4} -{:<4} (net {}) {}", + s.pure_added, + s.pure_removed, + s.net_pure(), + e_pure + ); + + if s.comment_lines_added > 0 || s.comment_lines_removed > 0 { + println!( + " Comments : +{:<4} -{:<4} {}", + s.comment_lines_added, s.comment_lines_removed, e_comments + ); + } + if s.docstring_lines_added > 0 || s.docstring_lines_removed > 0 { + println!( + " Docstrings : +{:<4} -{:<4} {}", + s.docstring_lines_added, s.docstring_lines_removed, e_docs + ); + } + if s.blank_lines_added > 0 || s.blank_lines_removed > 0 { + println!( + " Blanks : +{:<4} -{:<4}", + s.blank_lines_added, s.blank_lines_removed + ); + } + + let net_words = s.code_words_added - s.code_words_removed; + let est_tokens_net = s.estimated_tokens_added() - s.estimated_tokens_removed(); + + println!( + " Words : +{:<4} -{:<4} (net {} words, est. ~{} tokens)", + s.code_words_added, s.code_words_removed, net_words, est_tokens_net + ); + println!(); + } + } +} + +fn print_json(stats: &HashMap) { + // Simple manual JSON construction to avoid adding serde/serde_json dependencies for now. + // If complex, we should add serde. + + // Overall aggregation + let mut overall = LangStats::default(); + for s in stats.values() { + overall.total_added += s.total_added; + overall.total_removed += s.total_removed; + overall.pure_added += s.pure_added; + overall.pure_removed += s.pure_removed; + overall.comment_lines_added += s.comment_lines_added; + overall.comment_lines_removed += s.comment_lines_removed; + overall.docstring_lines_added += s.docstring_lines_added; + overall.docstring_lines_removed += s.docstring_lines_removed; + overall.blank_lines_added += s.blank_lines_added; + overall.blank_lines_removed += s.blank_lines_removed; + overall.code_words_added += s.code_words_added; + overall.code_words_removed += s.code_words_removed; + } + + println!("{{"); + println!(" \"overall\": {{"); + print_json_stats_fields(&overall, 4); + println!(" }},"); + println!(" \"languages\": {{"); + + let mut languages: Vec<_> = stats.keys().collect(); + languages.sort(); + + for (i, lang) in languages.iter().enumerate() { + let s = stats.get(*lang).unwrap(); + println!(" \"{}\": {{", lang.to_lowercase()); + print_json_stats_fields(s, 6); + if i < languages.len() - 1 { + println!(" }},"); + } else { + println!(" }}"); + } + } + + println!(" }}"); + println!("}}"); +} + +fn print_json_stats_fields(s: &LangStats, indent: usize) { + let pad = " ".repeat(indent); + println!("{}\"total_added\": {},", pad, s.total_added); + println!("{}\"total_removed\": {},", pad, s.total_removed); + println!("{}\"pure_added\": {},", pad, s.pure_added); + println!("{}\"pure_removed\": {},", pad, s.pure_removed); + println!("{}\"comment_added\": {},", pad, s.comment_lines_added); + println!("{}\"comment_removed\": {},", pad, s.comment_lines_removed); + println!("{}\"docstring_added\": {},", pad, s.docstring_lines_added); + println!("{}\"docstring_removed\": {},", pad, s.docstring_lines_removed); + println!("{}\"blank_added\": {},", pad, s.blank_lines_added); + println!("{}\"blank_removed\": {},", pad, s.blank_lines_removed); + println!("{}\"words_added\": {},", pad, s.code_words_added); + println!("{}\"words_removed\": {},", pad, s.code_words_removed); + println!("{}\"tokens_added_est\": {},", pad, s.estimated_tokens_added()); + println!("{}\"tokens_removed_est\": {}", pad, s.estimated_tokens_removed()); +} diff --git a/src/stats.rs b/src/stats.rs new file mode 100644 index 0000000..c226287 --- /dev/null +++ b/src/stats.rs @@ -0,0 +1,38 @@ +#[derive(Debug, Default, Clone)] +pub struct LangStats { + pub total_added: i64, + pub total_removed: i64, + + pub pure_added: i64, + pub pure_removed: i64, + + pub comment_lines_added: i64, + pub comment_lines_removed: i64, + + pub docstring_lines_added: i64, + pub docstring_lines_removed: i64, + + pub blank_lines_added: i64, + pub blank_lines_removed: i64, + + pub code_words_added: i64, + pub code_words_removed: i64, +} + +impl LangStats { + pub fn net_total(&self) -> i64 { + self.total_added - self.total_removed + } + + pub fn net_pure(&self) -> i64 { + self.pure_added - self.pure_removed + } + + pub fn estimated_tokens_added(&self) -> i64 { + (self.code_words_added as f64 * 1.3).round() as i64 + } + + pub fn estimated_tokens_removed(&self) -> i64 { + (self.code_words_removed as f64 * 1.3).round() as i64 + } +} From a317ce021ead119bc0e765c4debbc5cc632b8f7d Mon Sep 17 00:00:00 2001 From: Harkaran Brar Date: Sat, 6 Dec 2025 17:57:22 -0800 Subject: [PATCH 2/2] ci: customize release notes in auto-release workflow - Added a "Generate Release Notes" step that creates a structured release_notes.md template with sections for highlights, fixes, compatibility, and installation instructions - Updated the release creation command to use the custom notes file (--notes-file release_notes.md) instead of auto-generated notes (--generate-notes) - This provides more consistent and informative release notes for PureCode versions, tailored to the project's needs --- .github/workflows/auto-release-on-merge.yml | 29 ++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/.github/workflows/auto-release-on-merge.yml b/.github/workflows/auto-release-on-merge.yml index 2a04ec7..4a62fdb 100644 --- a/.github/workflows/auto-release-on-merge.yml +++ b/.github/workflows/auto-release-on-merge.yml @@ -27,6 +27,33 @@ jobs: echo "Found version: $VERSION" echo "version_tag=v$VERSION" >> "$GITHUB_OUTPUT" + - name: Generate Release Notes + env: + TAG: ${{ steps.get_version.outputs.version_tag }} + run: | + cat > release_notes.md <<'EOF' + # PureCode $TAG + + ## Highlights + - Add a few concise bullets about the most impactful changes. + + ## Fixes + - Note bug fixes or quality improvements worth calling out. + + ## Compatibility + - Breaking changes: none. + - Minimum supported Rust: stable. + + ## Install + ```bash + curl -LsSf https://raw.githubusercontent.com/isupervillain/purecode/main/install.sh | sh + ``` + + ```powershell + powershell -ExecutionPolicy ByPass -c "irm https://raw.githubusercontent.com/isupervillain/purecode/main/install.ps1 | iex" + ``` + EOF + - name: Check and Create Release env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -38,7 +65,7 @@ jobs: exit 1 else echo "Creating release $TAG for commit $SHA" - gh release create "$TAG" --target "$SHA" --generate-notes + gh release create "$TAG" --target "$SHA" --notes-file release_notes.md fi build_and_upload: