├── .cargo └── config.toml ├── .git-metrics.toml ├── .github ├── FUNDING.yml ├── actions │ ├── download-artifacts │ │ └── action.yml │ ├── git-user │ │ └── action.yml │ └── persist-metrics │ │ └── action.yml ├── dependabot.yml └── workflows │ ├── build.yml │ ├── code-testing.yml │ ├── commit-lint.yml │ ├── pull-request-build.yml │ ├── release-plz.yml │ ├── release.yml │ └── security-audit.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── Dockerfile ├── LICENSE ├── asset ├── lcov.info └── report-comment.png ├── cross.Dockerfile ├── readme.md ├── rust-toolchain.toml ├── rustfmt.toml └── src ├── backend ├── command.rs ├── git2.rs ├── mock.rs └── mod.rs ├── cmd ├── add.rs ├── check │ ├── format │ │ ├── format_md_by_default.md │ │ ├── format_md_with_success_showed.md │ │ ├── format_text_by_default.txt │ │ ├── format_text_with_success_showed.txt │ │ ├── html.rs │ │ ├── markdown.rs │ │ ├── mod.rs │ │ └── text.rs │ └── mod.rs ├── diff │ ├── format │ │ ├── markdown.rs │ │ ├── mod.rs │ │ └── text.rs │ └── mod.rs ├── export │ ├── json.rs │ ├── markdown.rs │ └── mod.rs ├── format │ ├── mod.rs │ └── text.rs ├── import │ ├── lcov.rs │ └── mod.rs ├── init.rs ├── log │ ├── format.rs │ └── mod.rs ├── mod.rs ├── prelude.rs ├── pull.rs ├── push.rs ├── remove.rs └── show.rs ├── entity ├── check.rs ├── config.rs ├── difference.rs ├── git.rs ├── log.rs ├── metric.rs └── mod.rs ├── error.rs ├── exporter ├── json.rs ├── markdown.rs └── mod.rs ├── formatter ├── difference.rs ├── metric.rs ├── mod.rs ├── percent.rs └── rule.rs ├── importer ├── lcov.rs └── mod.rs ├── main.rs ├── service ├── add.rs ├── check.rs ├── diff.rs ├── log.rs ├── mod.rs ├── pull.rs ├── push.rs ├── remove.rs └── show.rs └── tests ├── check_budget.rs ├── conflict_different.rs ├── display_diff.rs ├── mod.rs └── simple_use_case.rs /.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [target.aarch64-unknown-linux-gnu] 2 | linker = "aarch64-linux-gnu-gcc" 3 | -------------------------------------------------------------------------------- /.git-metrics.toml: -------------------------------------------------------------------------------- 1 | [metrics.binary-size.unit] 2 | scale = "binary" 3 | suffix = "B" 4 | 5 | # max increase size of 10% 6 | [[metrics.binary-size.rules]] 7 | type = "max-increase" 8 | ratio = 0.1 9 | 10 | # max size of 10MB 11 | [[metrics.binary-size.rules]] 12 | type = "max" 13 | value = 10485760.0 14 | 15 | # max decrease of 5% 16 | [[metrics."coverage.functions.percentage".rules]] 17 | type = "max-decrease" 18 | ratio = 0.05 19 | 20 | # max decrease of 5% 21 | [[metrics."coverage.lines.percentage".rules]] 22 | type = "max-decrease" 23 | ratio = 0.05 24 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ["jdrouet"] 2 | -------------------------------------------------------------------------------- /.github/actions/download-artifacts/action.yml: -------------------------------------------------------------------------------- 1 | name: download-artifacts 2 | description: download the artifacts that were previously built or generated 3 | runs: 4 | using: "composite" 5 | steps: 6 | - uses: actions/download-artifact@v4 7 | with: 8 | name: binary-windows 9 | - uses: actions/download-artifact@v4 10 | with: 11 | name: binary-darwin 12 | - uses: actions/download-artifact@v4 13 | with: 14 | name: binary-linux 15 | - uses: actions/download-artifact@v4 16 | with: 17 | name: code-coverage 18 | -------------------------------------------------------------------------------- /.github/actions/git-user/action.yml: -------------------------------------------------------------------------------- 1 | name: git-user 2 | description: set global config for git user 3 | runs: 4 | using: "composite" 5 | steps: 6 | - name: set user name 7 | shell: bash 8 | run: git config --global user.name 'github-actions[bot]' 9 | - name: set user email 10 | shell: bash 11 | run: git config --global user.email "$GITHUB_ACTOR_ID+$GITHUB_ACTOR@users.noreply.github.com" 12 | -------------------------------------------------------------------------------- /.github/actions/persist-metrics/action.yml: -------------------------------------------------------------------------------- 1 | name: persist-metrics 2 | description: save binary sizes with git-metrics 3 | inputs: 4 | push: 5 | description: "Should we push metrics" 6 | required: true 7 | default: "false" 8 | runs: 9 | using: "composite" 10 | steps: 11 | - name: set git user 12 | uses: ./.github/actions/git-user 13 | - name: make git-metrics executable 14 | shell: bash 15 | run: chmod +x ./git-metrics_linux-x86_64 16 | 17 | - name: pull metrics 18 | shell: bash 19 | run: ./git-metrics_linux-x86_64 --backend command pull 20 | - name: set metrics 21 | shell: bash 22 | run: | 23 | ./git-metrics_linux-x86_64 add binary-size --tag "build.target: x86_64-pc-windows-msvc" --tag "platform.os: windows" --tag "platform.arch: x86_64" --tag "unit: byte" $(stat --printf="%s" ./git-metrics_win-x86_64.exe) 24 | ./git-metrics_linux-x86_64 add binary-size --tag "build.target: aarch64-pc-windows-msvc" --tag "platform.os: windows" --tag "platform.arch: aarch64" --tag "unit: byte" $(stat --printf="%s" ./git-metrics_win-aarch64.exe) 25 | ./git-metrics_linux-x86_64 add binary-size --tag "build.target: x86_64-apple-darwin" --tag "platform.os: macos" --tag "platform.arch: x86_64" --tag "unit: byte" $(stat --printf="%s" ./git-metrics_darwin-x86_64) 26 | ./git-metrics_linux-x86_64 add binary-size --tag "build.target: aarch64-apple-darwin" --tag "platform.os: macos" --tag "platform.arch: aarch64" --tag "unit: byte" $(stat --printf="%s" ./git-metrics_darwin-aarch64) 27 | ./git-metrics_linux-x86_64 add binary-size --tag "build.target: x86_64-unknown-linux-gnu" --tag "platform.os: linux" --tag "platform.arch: x86_64" --tag "unit: byte" $(stat --printf="%s" ./git-metrics_linux-x86_64) 28 | ./git-metrics_linux-x86_64 add binary-size --tag "build.target: aarch64-unknown-linux-gnu" --tag "platform.os: linux" --tag "platform.arch: aarch64" --tag "unit: byte" $(stat --printf="%s" ./git-metrics_linux-aarch64) 29 | ./git-metrics_linux-x86_64 import lcov --disable-branches ./lcov.info 30 | - name: push metrics 31 | if: ${{ inputs.push == 'true' }} 32 | shell: bash 33 | run: ./git-metrics_linux-x86_64 --backend command push 34 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | # Check for updates to GitHub Actions every week 7 | interval: "weekly" 8 | 9 | - package-ecosystem: "cargo" 10 | directory: "/" 11 | schedule: 12 | interval: "daily" 13 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | workflow_call: 5 | workflow_dispatch: 6 | 7 | jobs: 8 | build-windows-binaries: 9 | name: build windows binaries 10 | runs-on: windows-latest 11 | steps: 12 | - uses: actions/checkout@v5 13 | - uses: actions-rust-lang/setup-rust-toolchain@v1 14 | with: 15 | target: aarch64-pc-windows-msvc 16 | - name: build binary for amd64 17 | run: cargo build --release --target x86_64-pc-windows-msvc 18 | - name: rename binary for amd64 19 | run: mv target/x86_64-pc-windows-msvc/release/git-metrics.exe ./git-metrics_win-x86_64.exe 20 | - name: build binary for arm64 21 | run: cargo build --release --target aarch64-pc-windows-msvc 22 | - name: rename binary for arm64 23 | run: mv target/aarch64-pc-windows-msvc/release/git-metrics.exe ./git-metrics_win-aarch64.exe 24 | - uses: actions/upload-artifact@v4 25 | with: 26 | name: binary-windows 27 | path: git-metrics_win-* 28 | if-no-files-found: error 29 | 30 | build-macos-binaries: 31 | name: build macos binaries 32 | runs-on: macos-latest 33 | steps: 34 | - uses: actions/checkout@v5 35 | - uses: actions-rust-lang/setup-rust-toolchain@v1 36 | with: 37 | target: aarch64-apple-darwin 38 | - name: build binary for amd64 39 | run: | 40 | cargo build --release 41 | strip target/release/git-metrics 42 | - run: mv ./target/release/git-metrics ./git-metrics_darwin-x86_64 43 | - name: build binary for arm64 44 | run: | 45 | cargo build --release --target aarch64-apple-darwin 46 | strip target/aarch64-apple-darwin/release/git-metrics 47 | - run: mv ./target/aarch64-apple-darwin/release/git-metrics ./git-metrics_darwin-aarch64 48 | - uses: actions/upload-artifact@v4 49 | with: 50 | name: binary-darwin 51 | path: git-metrics_darwin-* 52 | if-no-files-found: error 53 | 54 | build-linux-binaries: 55 | name: build linux binaries 56 | runs-on: ubuntu-latest 57 | steps: 58 | - name: checkout 59 | uses: actions/checkout@v5 60 | - name: set up qemu 61 | uses: docker/setup-qemu-action@v3 62 | - name: set up docker buildx 63 | uses: docker/setup-buildx-action@v3 64 | - name: build binaries using buildx 65 | uses: docker/build-push-action@v6 66 | with: 67 | cache-from: type=gha 68 | cache-to: type=gha,mode=max 69 | file: ./cross.Dockerfile 70 | outputs: type=local,dest=${{ github.workspace }} 71 | push: false 72 | target: binary 73 | - uses: actions/upload-artifact@v4 74 | with: 75 | name: binary-linux 76 | path: ./git-metrics_* 77 | if-no-files-found: error 78 | -------------------------------------------------------------------------------- /.github/workflows/code-testing.yml: -------------------------------------------------------------------------------- 1 | name: code testing 2 | 3 | on: 4 | workflow_call: 5 | workflow_dispatch: 6 | 7 | env: 8 | RUSTFLAGS: "-Dwarnings" 9 | 10 | jobs: 11 | code-checking: 12 | runs-on: ubuntu-latest 13 | concurrency: 14 | group: ${{ github.ref }}-code-checking 15 | cancel-in-progress: true 16 | steps: 17 | - uses: actions/checkout@v5 18 | - uses: actions-rs/toolchain@v1 19 | with: 20 | toolchain: stable 21 | profile: minimal 22 | components: rustfmt,clippy 23 | 24 | - uses: actions/cache@v4 25 | with: 26 | path: | 27 | ~/.cargo/bin/ 28 | ~/.cargo/registry/index/ 29 | ~/.cargo/registry/cache/ 30 | ~/.cargo/git/db/ 31 | target/ 32 | key: ${{ runner.os }}-code-checking-${{ hashFiles('**/Cargo.lock') }} 33 | 34 | - name: run lint 35 | run: cargo fmt --all --check 36 | 37 | - name: run check 38 | run: cargo check --all-features --tests --workspace 39 | 40 | - name: run clippy 41 | run: cargo clippy --all-targets --all-features --tests --workspace 42 | 43 | testing: 44 | runs-on: ubuntu-latest 45 | concurrency: 46 | group: ${{ github.ref }}-testing 47 | cancel-in-progress: true 48 | 49 | steps: 50 | - uses: actions/checkout@v5 51 | - uses: actions-rs/toolchain@v1 52 | with: 53 | toolchain: stable 54 | profile: minimal 55 | 56 | # install cargo-llvm-cov 57 | - uses: taiki-e/install-action@cargo-llvm-cov 58 | 59 | - uses: actions/cache@v4 60 | with: 61 | path: | 62 | ~/.cargo/bin/ 63 | ~/.cargo/registry/index/ 64 | ~/.cargo/registry/cache/ 65 | ~/.cargo/git/db/ 66 | target/ 67 | key: ${{ runner.os }}-testing-${{ hashFiles('**/Cargo.lock') }} 68 | 69 | - name: prepare git global config 70 | run: | 71 | git config --global user.email "you@example.com" 72 | git config --global user.name "git-metrics tester" 73 | git config --global init.defaultBranch main 74 | 75 | - name: run tests and build lcov file 76 | run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info 77 | 78 | - uses: actions/upload-artifact@v4 79 | with: 80 | name: code-coverage 81 | path: lcov.info 82 | if-no-files-found: error 83 | 84 | dependencies: 85 | runs-on: ubuntu-latest 86 | concurrency: 87 | group: ${{ github.ref }}-dependencies 88 | cancel-in-progress: true 89 | 90 | steps: 91 | - uses: actions/checkout@v5 92 | - uses: bnjbvr/cargo-machete@main 93 | -------------------------------------------------------------------------------- /.github/workflows/commit-lint.yml: -------------------------------------------------------------------------------- 1 | name: commit lint 2 | 3 | on: 4 | pull_request: 5 | 6 | permissions: 7 | pull-requests: read 8 | 9 | jobs: 10 | main: 11 | name: Validate PR title 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: amannn/action-semantic-pull-request@v6 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | with: 18 | # Configure which types are allowed (newline-delimited). 19 | # Default: https://github.com/commitizen/conventional-commit-types 20 | types: | 21 | ci 22 | chore 23 | build 24 | doc 25 | example 26 | feat 27 | fix 28 | perf 29 | refactor 30 | revert 31 | style 32 | test 33 | # Configure which scopes are allowed (newline-delimited). 34 | # These are regex patterns auto-wrapped in `^ $`. 35 | scopes: | 36 | deps 37 | deps-dev 38 | # Configure that a scope must always be provided. 39 | requireScope: false 40 | # Configure additional validation for the subject based on a regex. 41 | # This example ensures the subject doesn't start with an uppercase character. 42 | subjectPattern: ^(?![A-Z]).+$ 43 | # If `subjectPattern` is configured, you can use this property to override 44 | # the default error message that is shown when the pattern doesn't match. 45 | # The variables `subject` and `title` can be used within the message. 46 | subjectPatternError: | 47 | The subject "{subject}" found in the pull request title "{title}" 48 | didn't match the configured pattern. Please ensure that the subject 49 | doesn't start with an uppercase character. 50 | -------------------------------------------------------------------------------- /.github/workflows/pull-request-build.yml: -------------------------------------------------------------------------------- 1 | name: pr build 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - ".github/workflows/pull-request-build.yml" 7 | - ".github/workflows/build.yml" 8 | - "rust-toolchain.toml" 9 | - "**/Cargo.toml" 10 | - "**/Cargo.lock" 11 | - "**.rs" 12 | 13 | permissions: 14 | pull-requests: write 15 | 16 | jobs: 17 | execute-build: 18 | uses: ./.github/workflows/build.yml 19 | 20 | code-testing: 21 | uses: ./.github/workflows/code-testing.yml 22 | 23 | persist-metrics: 24 | name: persist metrics with git-metrics 25 | runs-on: ubuntu-latest 26 | needs: 27 | - execute-build 28 | - code-testing 29 | steps: 30 | - uses: actions/checkout@v5 31 | with: 32 | fetch-depth: 0 33 | 34 | - name: download binaries 35 | uses: ./.github/actions/download-artifacts 36 | - name: persist metrics 37 | uses: ./.github/actions/persist-metrics 38 | with: 39 | push: "false" 40 | 41 | - name: execute git check 42 | uses: jdrouet/action-git-metrics@check 43 | with: 44 | binary_path: "./git-metrics_linux-x86_64" 45 | format: markdown 46 | -------------------------------------------------------------------------------- /.github/workflows/release-plz.yml: -------------------------------------------------------------------------------- 1 | name: release-plz 2 | 3 | permissions: 4 | pull-requests: write 5 | contents: write 6 | 7 | on: 8 | push: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | release-plz: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: checkout repository 17 | uses: actions/checkout@v5 18 | with: 19 | fetch-depth: 0 20 | - name: install Rust toolchain 21 | uses: dtolnay/rust-toolchain@stable 22 | - name: run release-plz 23 | id: release-plz 24 | uses: MarcoIeni/release-plz-action@v0.5 25 | env: 26 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 27 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 28 | outputs: 29 | releases: ${{ steps.release-plz.outputs.releases }} 30 | 31 | trigger-release: 32 | needs: 33 | - release-plz 34 | if: ${{ needs.release-plz.outputs.releases != '[]' }} 35 | uses: ./.github/workflows/release.yml 36 | with: 37 | release_tag: ${{ fromJSON(needs.release-plz.outputs.releases)[0].tag }} 38 | 39 | notify: 40 | runs-on: ubuntu-latest 41 | needs: 42 | - release-plz 43 | - trigger-release 44 | 45 | steps: 46 | - name: notify mastodon 47 | uses: cbrgm/mastodon-github-action@v2 48 | with: 49 | access-token: ${{ secrets.MASTODON_ACCESS_TOKEN }} 50 | url: ${{ secrets.MASTODON_URL }} 51 | language: "en" 52 | message: | 53 | 👋 Hey! I just released a new version of git-metrics! 54 | 55 | 🔥 If you want to monitor some metrics about the app you're building, 56 | without depending on an external service, it's made for you. 📈 57 | 58 | https://github.com/jdrouet/git-metrics/releases/tag/${{ fromJSON(needs.release-plz.outputs.releases)[0].tag }} 59 | 60 | #rustlang #opensource 61 | visibility: "public" 62 | continue-on-error: true 63 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | workflow_call: 9 | inputs: 10 | release_tag: 11 | description: "version of the release" 12 | required: true 13 | type: string 14 | 15 | workflow_dispatch: 16 | inputs: 17 | release_tag: 18 | description: "version of the release" 19 | required: true 20 | type: string 21 | 22 | jobs: 23 | execute-build: 24 | uses: ./.github/workflows/build.yml 25 | 26 | code-testing: 27 | uses: ./.github/workflows/code-testing.yml 28 | 29 | persist-metrics: 30 | name: persist metrics with git-metrics 31 | runs-on: ubuntu-latest 32 | needs: 33 | - execute-build 34 | - code-testing 35 | steps: 36 | - uses: actions/checkout@v5 37 | with: 38 | fetch-depth: 0 39 | 40 | - name: download artifacts 41 | uses: ./.github/actions/download-artifacts 42 | - name: persist metrics 43 | uses: ./.github/actions/persist-metrics 44 | with: 45 | push: "true" 46 | 47 | - name: execute git check 48 | uses: jdrouet/action-git-metrics@check 49 | with: 50 | binary_path: "./git-metrics_linux-x86_64" 51 | format: markdown 52 | # remove this when it's sure this works on push 53 | continue-on-error: true 54 | 55 | publish: 56 | name: publish 57 | runs-on: ubuntu-latest 58 | if: ${{ github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call' }} 59 | needs: 60 | - execute-build 61 | - code-testing 62 | steps: 63 | - uses: actions/checkout@v5 64 | - name: download artifacts 65 | uses: ./.github/actions/download-artifacts 66 | 67 | - name: get release id from tag 68 | id: release_id 69 | run: | 70 | release_id=$(curl -L -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" $GITHUB_API_URL/repos/$GITHUB_REPOSITORY/releases/tags/${{ inputs.release_tag }} | jq .id) 71 | echo "release_id=$release_id" >> $GITHUB_OUTPUT 72 | - name: upload the artifacts 73 | uses: skx/github-action-publish-binaries@release-2.0 74 | env: 75 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 76 | with: 77 | releaseId: ${{ steps.release_id.outputs.release_id }} 78 | args: "git-metrics_*" 79 | -------------------------------------------------------------------------------- /.github/workflows/security-audit.yml: -------------------------------------------------------------------------------- 1 | name: security audit 2 | 3 | on: 4 | push: 5 | paths: 6 | - "**/Cargo.toml" 7 | - "**/Cargo.lock" 8 | 9 | jobs: 10 | security_audit: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v5 14 | - uses: rustsec/audit-check@v2.0.0 15 | with: 16 | token: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "git-metrics" 3 | description = "A git extension to store metrics directly in git, using the notes." 4 | authors = ["Jeremie Drouet "] 5 | license = "MIT" 6 | version = "0.2.6" 7 | edition = "2021" 8 | repository = "https://github.com/jdrouet/git-metrics" 9 | 10 | [package.metadata.deb] 11 | license-file = ["LICENSE", "4"] 12 | section = "utility" 13 | 14 | [features] 15 | default = [ 16 | "exporter-json", 17 | "exporter-markdown", 18 | "importer-lcov", 19 | "impl-command", 20 | "impl-git2", 21 | ] 22 | exporter = [] 23 | exporter-json = ["exporter", "dep:serde_json"] 24 | exporter-markdown = ["exporter"] 25 | importer = [] 26 | importer-noop = ["importer"] 27 | importer-lcov = ["importer", "dep:lcov"] 28 | impl-command = [] 29 | impl-git2 = ["dep:git2", "dep:auth-git2"] 30 | 31 | [dependencies] 32 | another-html-builder = "0.2" 33 | auth-git2 = { version = "0.5", optional = true, features = ["log"] } 34 | clap = { version = "4.5", features = ["derive", "env"] } 35 | git2 = { version = "0.20", optional = true } 36 | human-number = { version = "0.1" } 37 | indexmap = { version = "2.11", features = ["serde"] } 38 | lcov = { version = "0.8", optional = true } 39 | nu-ansi-term = { version = "0.50" } 40 | serde = { version = "1.0", features = ["derive"] } 41 | serde_json = { version = "1.0", features = ["preserve_order"], optional = true } 42 | thiserror = { version = "2.0" } 43 | toml = { version = "0.9", features = ["preserve_order"] } 44 | tracing = { version = "0.1" } 45 | tracing-subscriber = { version = "0.3" } 46 | 47 | [dev-dependencies] 48 | mockall = "0.13" 49 | similar-asserts = "1.7" 50 | tempfile = "3.23" 51 | test-case = "3.3" 52 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # docker buildx build --platform linux/arm64 --target binary --output type=local,dest=$(pwd)/target/docker . 2 | 3 | FROM --platform=$BUILDPLATFORM rust:1-bookworm AS vendor 4 | 5 | ENV USER=root 6 | 7 | WORKDIR /code 8 | RUN cargo init --bin --name git-metrics /code 9 | COPY Cargo.lock Cargo.toml /code/ 10 | 11 | # https://docs.docker.com/engine/reference/builder/#run---mounttypecache 12 | RUN --mount=type=cache,target=$CARGO_HOME/git,sharing=locked \ 13 | --mount=type=cache,target=$CARGO_HOME/registry,sharing=locked \ 14 | mkdir -p /code/.cargo \ 15 | && cargo vendor >> /code/.cargo/config.toml 16 | 17 | FROM rust:1-bookworm AS base-builder 18 | 19 | RUN apt-get update \ 20 | && apt-get install -y git \ 21 | && rm -rf /var/lib/apt/lists 22 | 23 | RUN cargo install cargo-deb 24 | 25 | FROM base-builder AS builder 26 | 27 | ENV USER=root 28 | 29 | WORKDIR /code 30 | 31 | COPY Cargo.toml /code/Cargo.toml 32 | COPY Cargo.lock /code/Cargo.lock 33 | COPY src /code/src 34 | COPY LICENSE /code/LICENSE 35 | COPY --from=vendor /code/.cargo /code/.cargo 36 | COPY --from=vendor /code/vendor /code/vendor 37 | 38 | RUN --mount=type=cache,target=/code/target/release/deps,sharing=locked \ 39 | --mount=type=cache,target=/code/target/release/build,sharing=locked \ 40 | --mount=type=cache,target=/code/target/release/incremental,sharing=locked \ 41 | cargo build --release --offline 42 | 43 | RUN strip /code/target/release/git-metrics 44 | RUN cargo deb --no-build 45 | 46 | FROM scratch AS binary 47 | 48 | COPY --from=builder /code/target/release/git-metrics /git-metrics 49 | COPY --from=builder /code/target/debian/*.deb / 50 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2024 Jeremie Drouet 4 | 5 | Permission is hereby granted, free of charge, to any 6 | person obtaining a copy of this software and associated 7 | documentation files (the "Software"), to deal in the 8 | Software without restriction, including without 9 | limitation the rights to use, copy, modify, merge, 10 | publish, distribute, sublicense, and/or sell copies of 11 | the Software, and to permit persons to whom the Software 12 | is furnished to do so, subject to the following 13 | conditions: 14 | 15 | The above copyright notice and this permission notice 16 | shall be included in all copies or substantial portions 17 | of the Software. 18 | 19 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 20 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 21 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 22 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 23 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 24 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 25 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 26 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 27 | DEALINGS IN THE SOFTWARE. 28 | -------------------------------------------------------------------------------- /asset/report-comment.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jdrouet/git-metrics/5e250d944456d84dc11e39f6f32ebca34b3ddc67/asset/report-comment.png -------------------------------------------------------------------------------- /cross.Dockerfile: -------------------------------------------------------------------------------- 1 | # docker buildx build --platform linux/arm64 --target binary --output type=local,dest=$(pwd)/target/docker . 2 | 3 | FROM --platform=$BUILDPLATFORM rust:1-bookworm AS vendor 4 | 5 | ENV USER=root 6 | 7 | WORKDIR /code 8 | RUN cargo init --bin --name git-metrics /code 9 | COPY Cargo.lock Cargo.toml /code/ 10 | COPY .cargo /code/.cargo 11 | 12 | # https://docs.docker.com/engine/reference/builder/#run---mounttypecache 13 | RUN --mount=type=cache,target=$CARGO_HOME/git,sharing=locked \ 14 | --mount=type=cache,target=$CARGO_HOME/registry,sharing=locked \ 15 | mkdir -p /code/.cargo \ 16 | && cargo vendor >> /code/.cargo/config.toml 17 | 18 | FROM --platform=linux/amd64 rust:1-bookworm AS base-builder 19 | 20 | RUN cargo install cargo-deb 21 | 22 | FROM --platform=linux/amd64 base-builder AS amd64-builder 23 | 24 | RUN apt-get update \ 25 | && apt-get install -y git \ 26 | && rm -rf /var/lib/apt/lists 27 | 28 | ENV USER=root 29 | 30 | WORKDIR /code 31 | 32 | COPY Cargo.toml /code/Cargo.toml 33 | COPY Cargo.lock /code/Cargo.lock 34 | COPY src /code/src 35 | COPY LICENSE /code/LICENSE 36 | COPY --from=vendor /code/.cargo /code/.cargo 37 | COPY --from=vendor /code/vendor /code/vendor 38 | 39 | RUN --mount=type=cache,target=/code/target/x86_64-unknown-linux-gnu/release/deps,sharing=locked \ 40 | --mount=type=cache,target=/code/target/x86_64-unknown-linux-gnu/release/build,sharing=locked \ 41 | --mount=type=cache,target=/code/target/x86_64-unknown-linux-gnu/release/incremental,sharing=locked \ 42 | cargo build --release --offline --target x86_64-unknown-linux-gnu 43 | 44 | RUN strip /code/target/x86_64-unknown-linux-gnu/release/git-metrics 45 | RUN cargo deb --no-build --target x86_64-unknown-linux-gnu 46 | 47 | FROM --platform=linux/amd64 base-builder AS arm64-builder 48 | 49 | RUN rustup target add aarch64-unknown-linux-gnu 50 | 51 | RUN dpkg --add-architecture arm64 52 | RUN apt-get update \ 53 | && apt-get install -y libssl-dev:arm64 gcc-aarch64-linux-gnu g++-aarch64-linux-gnu binutils-aarch64-linux-gnu \ 54 | && rm -rf /var/lib/apt/lists 55 | 56 | ENV USER=root 57 | 58 | WORKDIR /code 59 | 60 | COPY Cargo.toml /code/Cargo.toml 61 | COPY Cargo.lock /code/Cargo.lock 62 | COPY src /code/src 63 | COPY LICENSE /code/LICENSE 64 | COPY --from=vendor /code/.cargo /code/.cargo 65 | COPY --from=vendor /code/vendor /code/vendor 66 | 67 | ENV OPENSSL_INCLUDE_DIR=/usr/include/aarch64-linux-gnu/openssl 68 | ENV OPENSSL_LIB_DIR=/usr/lib/aarch64-linux-gnu 69 | ENV PKG_CONFIG_PATH=/usr/lib/aarch64-linux-gnu/pkgconfig 70 | 71 | RUN --mount=type=cache,target=/code/target/aarch64-unknown-linux-gnu/release/deps,sharing=locked \ 72 | --mount=type=cache,target=/code/target/aarch64-unknown-linux-gnu/release/build,sharing=locked \ 73 | --mount=type=cache,target=/code/target/aarch64-unknown-linux-gnu/release/incremental,sharing=locked \ 74 | cargo build --release --offline --target aarch64-unknown-linux-gnu 75 | 76 | RUN /usr/aarch64-linux-gnu/bin/strip /code/target/aarch64-unknown-linux-gnu/release/git-metrics 77 | RUN cargo deb --no-build --target aarch64-unknown-linux-gnu 78 | 79 | # making sure it works for linux/arm64 80 | FROM --platform=linux/arm64 debian:bookworm AS testing-arm64 81 | 82 | RUN apt-get update \ 83 | && apt-get install -y git \ 84 | && rm -rf /var/lib/apt/lists 85 | 86 | COPY --from=arm64-builder /code/target/aarch64-unknown-linux-gnu/release/git-metrics /git-metrics 87 | RUN chmod +x /git-metrics 88 | RUN /git-metrics --help 89 | 90 | # making sure it works for linux/amd64 91 | FROM --platform=linux/amd64 debian:bookworm AS testing-amd64 92 | 93 | RUN apt-get update \ 94 | && apt-get install -y git \ 95 | && rm -rf /var/lib/apt/lists 96 | 97 | COPY --from=amd64-builder /code/target/x86_64-unknown-linux-gnu/release/git-metrics /git-metrics 98 | RUN chmod +x /git-metrics 99 | RUN /git-metrics --help 100 | 101 | FROM scratch AS binary 102 | 103 | COPY --from=testing-amd64 /git-metrics /git-metrics_linux-x86_64 104 | COPY --from=amd64-builder /code/target/x86_64-unknown-linux-gnu/debian/*.deb / 105 | COPY --from=testing-arm64 /git-metrics /git-metrics_linux-aarch64 106 | COPY --from=arm64-builder /code/target/aarch64-unknown-linux-gnu/debian/*.deb / 107 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Git Metrics 2 | 3 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fjdrouet%2Fgit-metrics.svg?type=shield)](https://app.fossa.com/projects/git%2Bgithub.com%2Fjdrouet%2Fgit-metrics?ref=badge_shield) 4 | 5 | Right now, if you want to track the evolution of some metrics for your project 6 | over time, you need an external tool to store those metrics. But these metrics 7 | could be stored withing the git repository. Git provides a mechanism of notes 8 | that `git-metrics` simplifies. 9 | 10 | ## How to install 11 | 12 | ### From sources 13 | 14 | ```bash 15 | cargo install --git https://github.com/jdrouet/git-metrics 16 | ``` 17 | 18 | ## How to use it 19 | 20 | ### Locally 21 | 22 | ```bash 23 | # fetch the remote metrics 24 | $ git metrics pull 25 | # add a new metric 26 | $ git metrics add binary-size \ 27 | --tag "platform.os: linux" \ 28 | --tag "platform.arch: amd64" \ 29 | 1024.0 30 | # push the metrics to remote 31 | $ git metrics push 32 | # log all the metrics for the past commits 33 | $ git metrics log --filter-empty 34 | # display the metrics on current commit 35 | $ git metrics show 36 | binary-size{platform.os="linux", platform.arch="amd64"} 1024.0 37 | # display the metrics difference between commits 38 | $ git metrics diff HEAD~2..HEAD 39 | - binary-size{platform.os="linux", platform.arch="amd64"} 512.0 40 | + binary-size{platform.os="linux", platform.arch="amd64"} 1024.0 (+200.00 %) 41 | # check the metrics against the defined rules 42 | $ git metrics check --show-success-rules --show-skipped-rules HEAD~2..HEAD 43 | [SUCCESS] binary-size{platform.os="linux", platform.arch="amd64"} 3.44 MiB => 3.53 MiB Δ +96.01 kiB (+2.72 %) 44 | increase should be less than 10.00 % ... check 45 | should be lower than 10.00 MiB ... check 46 | [SUCCESS] binary-size{platform.os="linux", platform.arch="aarch64"} 3.14 MiB => 3.14 MiB 47 | increase should be less than 10.00 % ... check 48 | should be lower than 10.00 MiB ... check 49 | ``` 50 | 51 | ### With a github action 52 | 53 | With `git-metrics`, using [the GitHub actions](https://github.com/jdrouet/action-git-metrics), you can even add a check to every pull request that opens on your project. 54 | 55 | ![check report](asset/report-comment.png) 56 | 57 | ```yaml 58 | name: monitoring metrics 59 | 60 | on: 61 | pull_request: 62 | branches: 63 | - main 64 | push: 65 | branches: 66 | - main 67 | 68 | # this is required to be able to post the result of the check command 69 | # in a comment of the pull request 70 | permissions: 71 | pull-requests: write 72 | 73 | jobs: 74 | building: 75 | runs-on: ubuntu-latest 76 | steps: 77 | - uses: actions/checkout@v3 78 | with: 79 | # this is needed for reporting metrics 80 | fetch-depth: 0 81 | # set the git identity to be able to save and push the metrics 82 | - uses: jdrouet/action-git-identity@main 83 | - uses: jdrouet/action-git-metrics@install 84 | - uses: jdrouet/action-git-metrics@execute 85 | with: 86 | pull: 'true' 87 | # set that to true when not a pull request 88 | push: ${{ github.event_name != 'pull_request' }} 89 | script: | 90 | add binary-size --tag "platform: linux" 1024 91 | # add a comment message to your pull request reporting the evolution 92 | - uses: jdrouet/action-git-metrics@check 93 | if: ${{ github.event_name == 'pull_request' }} 94 | ``` 95 | 96 | ## Related projects 97 | 98 | - GitHub action to install `git-metrics`: https://github.com/jdrouet/action-git-metrics/tree/install 99 | - GitHub action to execute `git-metrics`: https://github.com/jdrouet/action-git-metrics/tree/execute 100 | - GitHub action to report `git-metrics` checks: https://github.com/jdrouet/action-git-metrics/tree/check 101 | - GitHub action to report `git-metrics` diff: https://github.com/jdrouet/action-git-metrics/tree/diff 102 | 103 | ## Project goals 104 | 105 | - [x] `git-metrics show` displays the metrics to the current commit 106 | - [x] `git-metrics add` adds a metric to the current commit 107 | - [x] `git-metrics remove` removes a metric from the current commit 108 | - [x] `git-metrics fetch` fetches the metrics 109 | - [x] `git-metrics push` pushes the metrics 110 | - [x] `git-metrics log` displays the metrics for the last commits 111 | - [x] `git-metrics diff` computes the diff of the metrics between 2 commits 112 | - [x] `git-metrics check` compares the metrics against the defined budget 113 | - [ ] `git-metrics page` generates a web page with charts for every metrics 114 | - [ ] `git-metrics import` to add metrics based on some apps output 115 | - [x] from lcov file 116 | 117 | ## License 118 | 119 | [![FOSSA Status](https://app.fossa.com/api/projects/git%2Bgithub.com%2Fjdrouet%2Fgit-metrics.svg?type=large)](https://app.fossa.com/projects/git%2Bgithub.com%2Fjdrouet%2Fgit-metrics?ref=badge_large) 120 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | channel = "stable" 3 | components = ["rustfmt"] 4 | profile = "minimal" 5 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | group_imports = "StdExternalCrate" 2 | imports_granularity = "Module" 3 | -------------------------------------------------------------------------------- /src/backend/command.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use super::NoteRef; 4 | use crate::backend::REMOTE_METRICS_REF; 5 | use crate::entity::git::Commit; 6 | 7 | #[derive(Debug, thiserror::Error)] 8 | pub(crate) enum Error { 9 | #[error("unable to execute")] 10 | UnableToExecute { 11 | #[from] 12 | #[source] 13 | source: std::io::Error, 14 | }, 15 | #[error("execution failed")] 16 | Failed(String), 17 | #[error("invalid git range")] 18 | InvalidRange(String), 19 | #[error("unable to deserialize metrics")] 20 | Deserialize { 21 | #[from] 22 | #[source] 23 | source: toml::de::Error, 24 | }, 25 | #[error("unable to serialize metrics")] 26 | Serialize { 27 | #[from] 28 | #[source] 29 | source: toml::ser::Error, 30 | }, 31 | #[error("unable to push metrics")] 32 | UnableToPush(String), 33 | } 34 | 35 | impl crate::error::DetailedError for Error { 36 | fn details(&self) -> Option { 37 | match self { 38 | Self::Deserialize { source } => Some(source.to_string()), 39 | Self::Failed(inner) | Self::InvalidRange(inner) | Self::UnableToPush(inner) => { 40 | Some(inner.clone()) 41 | } 42 | Self::Serialize { source } => Some(source.to_string()), 43 | Self::UnableToExecute { source } => Some(source.to_string()), 44 | } 45 | } 46 | } 47 | 48 | #[derive(Debug)] 49 | pub(crate) struct CommandBackend { 50 | path: Option, 51 | } 52 | 53 | impl CommandBackend { 54 | pub fn new(path: Option) -> Self { 55 | Self { path } 56 | } 57 | } 58 | 59 | impl CommandBackend { 60 | fn cmd(&self) -> std::process::Command { 61 | let mut cmd = std::process::Command::new("git"); 62 | if let Some(ref path) = self.path { 63 | cmd.current_dir(path); 64 | } 65 | cmd 66 | } 67 | } 68 | 69 | impl super::Backend for CommandBackend { 70 | type Err = Error; 71 | 72 | fn rev_list(&self, range: &str) -> Result, Self::Err> { 73 | tracing::trace!("listing revisions in range {range:?}"); 74 | let output = self.cmd().arg("rev-list").arg(range).output()?; 75 | if output.status.success() { 76 | let stdout = String::from_utf8_lossy(&output.stdout); 77 | tracing::trace!("stdout {stdout:?}"); 78 | Ok(stdout 79 | .split('\n') 80 | .filter(|v| !v.is_empty()) 81 | .map(String::from) 82 | .collect()) 83 | } else { 84 | let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); 85 | tracing::trace!("stderr {stderr:?}"); 86 | Err(Error::Failed(stderr)) 87 | } 88 | } 89 | 90 | fn rev_parse(&self, range: &str) -> Result { 91 | tracing::trace!("parse revision range {range:?}"); 92 | let output = self.cmd().arg("rev-parse").arg(range).output()?; 93 | if output.status.success() { 94 | let stdout = String::from_utf8_lossy(&output.stdout); 95 | tracing::trace!("stdout {stdout:?}"); 96 | let mut iter = stdout.split('\n').filter(|v| !v.is_empty()); 97 | if let Some(first) = iter.next() { 98 | if let Some(second) = iter.next().and_then(|v| v.strip_prefix('^')) { 99 | Ok(super::RevParse::Range( 100 | second.to_string(), 101 | first.to_string(), 102 | )) 103 | } else { 104 | Ok(super::RevParse::Single(first.to_string())) 105 | } 106 | } else { 107 | Err(Error::InvalidRange(stdout.into())) 108 | } 109 | } else { 110 | let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); 111 | tracing::trace!("stderr {stderr:?}"); 112 | Err(Error::Failed(stderr)) 113 | } 114 | } 115 | 116 | fn list_notes(&self, note_ref: &NoteRef) -> Result, Self::Err> { 117 | tracing::trace!("listing notes for ref {note_ref:?}"); 118 | let output = self 119 | .cmd() 120 | .arg("notes") 121 | .arg("--ref") 122 | .arg(note_ref.to_string()) 123 | .output()?; 124 | if output.status.success() { 125 | let stdout = String::from_utf8_lossy(&output.stdout); 126 | tracing::trace!("stdout {stdout:?}"); 127 | Ok(stdout 128 | .split('\n') 129 | .filter_map(|line| { 130 | line.split_once(' ') 131 | .map(|(note_id, commit_id)| super::Note { 132 | note_id: note_id.to_string(), 133 | commit_id: commit_id.to_string(), 134 | }) 135 | }) 136 | .collect()) 137 | } else { 138 | let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); 139 | tracing::trace!("stderr {stderr:?}"); 140 | Err(Error::Failed(stderr)) 141 | } 142 | } 143 | 144 | fn remove_note(&self, target: &str, note_ref: &NoteRef) -> Result<(), Self::Err> { 145 | tracing::trace!("removing note for target {target:?} and {note_ref:?}"); 146 | let output = self 147 | .cmd() 148 | .arg("notes") 149 | .arg("--ref") 150 | .arg(note_ref.to_string()) 151 | .arg("remove") 152 | .arg(target) 153 | .output()?; 154 | if output.status.success() { 155 | Ok(()) 156 | } else { 157 | let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); 158 | tracing::trace!("stderr {stderr:?}"); 159 | Err(Error::Failed(stderr)) 160 | } 161 | } 162 | 163 | fn read_note( 164 | &self, 165 | target: &str, 166 | note_ref: &NoteRef, 167 | ) -> Result, Self::Err> { 168 | tracing::trace!("getting note for target {target:?} and note {note_ref:?}"); 169 | let output = self 170 | .cmd() 171 | .arg("notes") 172 | .arg("--ref") 173 | .arg(note_ref.to_string()) 174 | .arg("show") 175 | .arg(target) 176 | .output()?; 177 | if output.status.success() { 178 | let stdout = String::from_utf8_lossy(&output.stdout); 179 | tracing::trace!("stdout {stdout:?}"); 180 | let note: T = toml::from_str(&stdout)?; 181 | Ok(Some(note)) 182 | } else { 183 | let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); 184 | tracing::trace!("stderr {stderr:?}"); 185 | if stderr.starts_with("error: no note found for object") { 186 | return Ok(None); 187 | } 188 | Err(Error::Failed(stderr)) 189 | } 190 | } 191 | 192 | fn write_note( 193 | &self, 194 | target: &str, 195 | note_ref: &NoteRef, 196 | value: &T, 197 | ) -> Result<(), Self::Err> { 198 | tracing::trace!("setting note for target {target:?} and note {note_ref:?}",); 199 | let message = toml::to_string(value)?; 200 | let output = self 201 | .cmd() 202 | .arg("notes") 203 | .arg("--ref") 204 | .arg(note_ref.to_string()) 205 | .arg("add") 206 | .arg("-f") 207 | .arg("-m") 208 | .arg(message.as_str()) 209 | .arg(target) 210 | .output()?; 211 | 212 | if output.status.success() { 213 | Ok(()) 214 | } else { 215 | let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); 216 | tracing::trace!("stderr {stderr:?}"); 217 | Err(Error::Failed(stderr)) 218 | } 219 | } 220 | 221 | fn pull(&self, remote: &str, local_ref: &NoteRef) -> Result<(), Self::Err> { 222 | tracing::trace!("pulling metrics"); 223 | let output = self 224 | .cmd() 225 | .arg("fetch") 226 | .arg(remote) 227 | .arg(format!("+{REMOTE_METRICS_REF}:{local_ref}",)) 228 | .output()?; 229 | if output.status.success() { 230 | Ok(()) 231 | } else { 232 | let stderr = String::from_utf8_lossy(&output.stderr); 233 | tracing::trace!("stderr {stderr:?}"); 234 | 235 | if stderr.starts_with("fatal: couldn't find remote ref") { 236 | Ok(()) 237 | } else { 238 | tracing::error!("something went wrong when fetching metrics"); 239 | tracing::trace!("{stderr}"); 240 | Err(Error::Failed(stderr.into())) 241 | } 242 | } 243 | } 244 | 245 | fn push(&self, remote: &str, local_ref: &NoteRef) -> Result<(), Self::Err> { 246 | tracing::trace!("pushing metrics"); 247 | 248 | let output = self 249 | .cmd() 250 | .arg("push") 251 | .arg(remote) 252 | .arg(format!("{local_ref}:{REMOTE_METRICS_REF}",)) 253 | .output()?; 254 | 255 | if !output.status.success() { 256 | let stderr = String::from_utf8_lossy(&output.stderr); 257 | tracing::error!("unable to push metrics"); 258 | tracing::trace!("stderr {stderr:?}"); 259 | Err(Error::UnableToPush(stderr.into())) 260 | } else { 261 | Ok(()) 262 | } 263 | } 264 | 265 | fn get_commits(&self, range: &str) -> Result, Self::Err> { 266 | let output = self 267 | .cmd() 268 | .arg("log") 269 | .arg("--format=format:%H:%s") 270 | .arg(range) 271 | .output()?; 272 | 273 | if !output.status.success() { 274 | let stderr = String::from_utf8_lossy(&output.stderr); 275 | tracing::error!("something went wrong when getting commits"); 276 | tracing::trace!("stderr {stderr:?}"); 277 | Err(Error::Failed(stderr.into())) 278 | } else { 279 | let stdout = String::from_utf8_lossy(&output.stdout); 280 | tracing::trace!("stdout {stdout:?}"); 281 | Ok(stdout 282 | .split('\n') 283 | .map(|item| item.trim()) 284 | .filter(|item| !item.is_empty()) 285 | .filter_map(|line| { 286 | line.split_once(':').map(|(sha, summary)| Commit { 287 | sha: sha.to_string(), 288 | summary: summary.to_string(), 289 | }) 290 | }) 291 | .collect()) 292 | } 293 | } 294 | 295 | fn root_path(&self) -> Result { 296 | let output = self 297 | .cmd() 298 | .arg("rev-parse") 299 | .arg("--show-toplevel") 300 | .output()?; 301 | 302 | if !output.status.success() { 303 | let stderr = String::from_utf8_lossy(&output.stderr); 304 | tracing::error!("something went wrong when getting commits"); 305 | tracing::trace!("stderr {stderr:?}"); 306 | Err(Error::Failed(stderr.into())) 307 | } else { 308 | let stdout = String::from_utf8_lossy(&output.stdout); 309 | tracing::trace!("stdout {stdout:?}"); 310 | Ok(PathBuf::from(stdout.trim())) 311 | } 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /src/backend/mock.rs: -------------------------------------------------------------------------------- 1 | use std::cell::RefCell; 2 | use std::collections::HashMap; 3 | use std::fmt::Display; 4 | use std::rc::Rc; 5 | 6 | use super::{NoteRef, RevParse}; 7 | use crate::entity::config::Config; 8 | use crate::entity::git::Commit; 9 | 10 | #[derive(Debug)] 11 | pub(crate) struct Error { 12 | message: &'static str, 13 | } 14 | 15 | impl Error { 16 | fn new(message: &'static str) -> Self { 17 | Self { message } 18 | } 19 | } 20 | 21 | impl Display for Error { 22 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 23 | self.message.fmt(f) 24 | } 25 | } 26 | 27 | impl std::error::Error for Error {} 28 | 29 | impl crate::error::DetailedError for Error { 30 | fn details(&self) -> Option { 31 | None 32 | } 33 | } 34 | 35 | #[derive(Clone, Debug, Default)] 36 | pub(crate) struct MockBackend(Rc); 37 | 38 | #[derive(Debug)] 39 | pub(crate) struct MockBackendInner { 40 | temp_dir: tempfile::TempDir, 41 | commits: Vec, 42 | notes: RefCell>, 43 | rev_parses: RefCell>, 44 | rev_lists: RefCell>>, 45 | } 46 | 47 | impl Default for MockBackendInner { 48 | fn default() -> Self { 49 | Self { 50 | temp_dir: tempfile::tempdir().unwrap(), 51 | commits: Default::default(), 52 | notes: Default::default(), 53 | rev_parses: Default::default(), 54 | rev_lists: Default::default(), 55 | } 56 | } 57 | } 58 | 59 | impl MockBackend { 60 | pub(crate) fn get_note(&self, target: &str, note_ref: NoteRef) -> Option { 61 | let key = format!("{target}/{note_ref}"); 62 | self.0.notes.borrow().get(&key).map(String::from) 63 | } 64 | 65 | pub(crate) fn set_note(&self, target: &str, note_ref: NoteRef, value: impl Into) { 66 | let key = format!("{target}/{note_ref}"); 67 | self.0.notes.borrow_mut().insert(key, value.into()); 68 | } 69 | 70 | pub(crate) fn set_rev_list>( 71 | &self, 72 | target: impl Into, 73 | items: impl IntoIterator, 74 | ) { 75 | self.0.rev_lists.borrow_mut().insert( 76 | target.into(), 77 | items.into_iter().map(Into::into).collect::>(), 78 | ); 79 | } 80 | 81 | pub(crate) fn set_rev_parse(&self, target: impl Into, item: RevParse) { 82 | self.0.rev_parses.borrow_mut().insert(target.into(), item); 83 | } 84 | 85 | pub(crate) fn set_config(&self, input: &str) { 86 | let file = self.0.temp_dir.path().join(".git-metrics.toml"); 87 | std::fs::write(file, input).unwrap(); 88 | } 89 | 90 | pub(crate) fn get_config(&self) -> Config { 91 | let file = self.0.temp_dir.path().join(".git-metrics.toml"); 92 | Config::from_path(&file).unwrap() 93 | } 94 | } 95 | 96 | impl super::Backend for MockBackend { 97 | type Err = Error; 98 | 99 | fn rev_list(&self, range: &str) -> Result, Self::Err> { 100 | Ok(self 101 | .0 102 | .rev_lists 103 | .borrow() 104 | .get(range) 105 | .cloned() 106 | .unwrap_or_default()) 107 | } 108 | 109 | fn rev_parse(&self, range: &str) -> Result { 110 | self.0 111 | .rev_parses 112 | .borrow() 113 | .get(range) 114 | .cloned() 115 | .ok_or_else(|| Error::new("invalid range for rev_parse")) 116 | } 117 | 118 | fn list_notes(&self, _note_ref: &NoteRef) -> Result, Self::Err> { 119 | todo!() 120 | } 121 | 122 | fn remove_note(&self, target: &str, note_ref: &NoteRef) -> Result<(), Self::Err> { 123 | let key = format!("{target}/{note_ref}"); 124 | self.0.notes.borrow_mut().remove(&key); 125 | Ok(()) 126 | } 127 | 128 | fn pull(&self, _remote: &str, _local_ref: &NoteRef) -> Result<(), Self::Err> { 129 | todo!() 130 | } 131 | 132 | fn push(&self, _remote: &str, _local_ref: &NoteRef) -> Result<(), Self::Err> { 133 | todo!() 134 | } 135 | 136 | fn read_note( 137 | &self, 138 | target: &str, 139 | note_ref: &NoteRef, 140 | ) -> Result, Self::Err> { 141 | let key = format!("{target}/{note_ref}"); 142 | if let Some(value) = self.0.notes.borrow().get(&key) { 143 | let value: T = 144 | toml::from_str(value).map_err(|_| Error::new("unable to deserialize"))?; 145 | Ok(Some(value)) 146 | } else { 147 | Ok(None) 148 | } 149 | } 150 | 151 | fn write_note( 152 | &self, 153 | target: &str, 154 | note_ref: &NoteRef, 155 | value: &T, 156 | ) -> Result<(), Self::Err> { 157 | let key = format!("{target}/{note_ref}"); 158 | let value = 159 | toml::to_string_pretty(&value).map_err(|_| Error::new("unable to serialize"))?; 160 | self.0.notes.borrow_mut().insert(key, value); 161 | Ok(()) 162 | } 163 | 164 | fn get_commits(&self, _range: &str) -> Result, Self::Err> { 165 | Ok(self.0.commits.clone()) 166 | } 167 | 168 | fn root_path(&self) -> Result { 169 | Ok(self.0.temp_dir.path().to_path_buf()) 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /src/backend/mod.rs: -------------------------------------------------------------------------------- 1 | use std::fmt::Display; 2 | use std::path::PathBuf; 3 | 4 | #[cfg(feature = "impl-command")] 5 | mod command; 6 | #[cfg(feature = "impl-git2")] 7 | mod git2; 8 | #[cfg(test)] 9 | pub(crate) mod mock; 10 | 11 | #[cfg(feature = "impl-command")] 12 | pub(crate) use command::CommandBackend; 13 | #[cfg(feature = "impl-git2")] 14 | pub(crate) use git2::Git2Backend; 15 | 16 | use crate::entity::git::Commit; 17 | 18 | const REMOTE_METRICS_REF: &str = "refs/notes/metrics"; 19 | 20 | #[derive(Debug, thiserror::Error)] 21 | pub(crate) enum Error { 22 | #[cfg(feature = "impl-command")] 23 | #[error(transparent)] 24 | Command(#[from] crate::backend::command::Error), 25 | #[cfg(feature = "impl-git2")] 26 | #[error(transparent)] 27 | Git2(#[from] crate::backend::git2::Error), 28 | #[cfg(test)] 29 | #[error(transparent)] 30 | Mock(#[from] crate::backend::mock::Error), 31 | } 32 | 33 | impl crate::error::DetailedError for Error { 34 | fn details(&self) -> Option { 35 | match self { 36 | #[cfg(feature = "impl-command")] 37 | Self::Command(inner) => inner.details(), 38 | #[cfg(feature = "impl-git2")] 39 | Self::Git2(inner) => inner.details(), 40 | #[cfg(test)] 41 | Self::Mock(inner) => inner.details(), 42 | } 43 | } 44 | } 45 | 46 | #[derive(Clone, Debug)] 47 | pub(crate) enum NoteRef { 48 | Changes, 49 | RemoteMetrics { name: String }, 50 | } 51 | 52 | impl NoteRef { 53 | pub(crate) fn remote_metrics(name: impl Into) -> Self { 54 | Self::RemoteMetrics { name: name.into() } 55 | } 56 | } 57 | 58 | impl std::fmt::Display for NoteRef { 59 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 60 | match self { 61 | Self::Changes => write!(f, "refs/notes/metrics-changes"), 62 | Self::RemoteMetrics { name } => write!(f, "refs/notes/metrics-remote-{name}"), 63 | } 64 | } 65 | } 66 | 67 | #[derive(Debug)] 68 | pub(crate) struct Note { 69 | #[allow(dead_code)] 70 | pub note_id: String, 71 | pub commit_id: String, 72 | } 73 | 74 | #[derive(Clone, Debug)] 75 | pub(crate) enum RevParse { 76 | Single(String), 77 | Range(String, String), 78 | } 79 | 80 | impl Display for RevParse { 81 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 82 | match self { 83 | Self::Single(inner) => f.write_str(inner.as_str()), 84 | Self::Range(first, second) => write!(f, "{first}..{second}"), 85 | } 86 | } 87 | } 88 | 89 | pub(crate) trait Backend { 90 | type Err: Into; 91 | 92 | fn rev_parse(&self, range: &str) -> Result; 93 | fn rev_list(&self, range: &str) -> Result, Self::Err>; 94 | fn pull(&self, remote: &str, local_ref: &NoteRef) -> Result<(), Self::Err>; 95 | fn push(&self, remote: &str, local_ref: &NoteRef) -> Result<(), Self::Err>; 96 | fn read_note( 97 | &self, 98 | target: &str, 99 | note_ref: &NoteRef, 100 | ) -> Result, Self::Err>; 101 | fn write_note( 102 | &self, 103 | target: &str, 104 | note_ref: &NoteRef, 105 | value: &T, 106 | ) -> Result<(), Self::Err>; 107 | fn remove_note(&self, target: &str, note_ref: &NoteRef) -> Result<(), Self::Err>; 108 | fn list_notes(&self, note_ref: &NoteRef) -> Result, Self::Err>; 109 | fn get_commits(&self, range: &str) -> Result, Self::Err>; 110 | fn root_path(&self) -> Result; 111 | } 112 | -------------------------------------------------------------------------------- /src/cmd/add.rs: -------------------------------------------------------------------------------- 1 | use super::prelude::{PrettyWriter, Tag}; 2 | use crate::backend::Backend; 3 | use crate::service::Service; 4 | use crate::ExitCode; 5 | 6 | /// Add a metric related to the target 7 | #[derive(clap::Parser, Debug, Default)] 8 | pub struct CommandAdd { 9 | /// Commit target, default to HEAD 10 | #[clap(long, short, default_value = "HEAD")] 11 | target: String, 12 | /// Name of the metric 13 | name: String, 14 | /// Tag given to the metric 15 | #[clap(long)] 16 | tag: Vec, 17 | /// Value of the metric 18 | value: f64, 19 | } 20 | 21 | impl super::Executor for CommandAdd { 22 | #[tracing::instrument(name = "add", skip_all, fields(target = self.target.as_str(), name = self.name.as_str()))] 23 | fn execute( 24 | self, 25 | backend: B, 26 | _stdout: Out, 27 | ) -> Result { 28 | let metric = crate::entity::metric::Metric { 29 | header: crate::entity::metric::MetricHeader { 30 | name: self.name, 31 | tags: self 32 | .tag 33 | .into_iter() 34 | .map(|tag| (tag.name, tag.value)) 35 | .collect(), 36 | }, 37 | value: self.value, 38 | }; 39 | let opts = crate::service::add::Options { 40 | target: self.target, 41 | }; 42 | 43 | Service::new(backend).add(metric, &opts)?; 44 | Ok(ExitCode::Success) 45 | } 46 | } 47 | 48 | #[cfg(test)] 49 | mod tests { 50 | use clap::Parser; 51 | 52 | use crate::backend::mock::MockBackend; 53 | 54 | #[test] 55 | fn should_add_metric_with_one_attribute() { 56 | let mut stdout = Vec::new(); 57 | let mut stderr = Vec::new(); 58 | 59 | let repo = MockBackend::default(); 60 | 61 | let code = crate::Args::parse_from(["_", "add", "my-metric", "--tag", "foo: bar", "12.34"]) 62 | .command 63 | .execute(repo, false, &mut stdout, &mut stderr); 64 | 65 | assert!(code.is_success()); 66 | assert!(stdout.is_empty()); 67 | assert!(stderr.is_empty()); 68 | } 69 | 70 | #[test] 71 | fn should_add_metric_with_multiple_attributes() { 72 | let mut stdout = Vec::new(); 73 | let mut stderr = Vec::new(); 74 | 75 | let repo = MockBackend::default(); 76 | 77 | let code = crate::Args::parse_from([ 78 | "_", 79 | "add", 80 | "my-metric", 81 | "--tag", 82 | "foo: bar", 83 | "--tag", 84 | "yolo: pouwet", 85 | "12.34", 86 | ]) 87 | .command 88 | .execute(repo.clone(), false, &mut stdout, &mut stderr); 89 | 90 | assert!(code.is_success()); 91 | assert!(stdout.is_empty()); 92 | assert!(stderr.is_empty()); 93 | 94 | assert_eq!( 95 | repo.get_note("HEAD", crate::backend::NoteRef::Changes), 96 | Some(String::from( 97 | r#"[[changes]] 98 | action = "add" 99 | name = "my-metric" 100 | value = 12.34 101 | 102 | [changes.tags] 103 | foo = "bar" 104 | yolo = "pouwet" 105 | "# 106 | )) 107 | ); 108 | } 109 | 110 | #[test] 111 | fn should_add_metric_to_different_target() { 112 | let mut stdout = Vec::new(); 113 | let mut stderr = Vec::new(); 114 | 115 | let repo = MockBackend::default(); 116 | 117 | let code = crate::Args::parse_from(["_", "add", "--target", "other", "my-metric", "12.34"]) 118 | .command 119 | .execute(repo.clone(), false, &mut stdout, &mut stderr); 120 | 121 | assert!(code.is_success()); 122 | assert!(stdout.is_empty()); 123 | assert!(stderr.is_empty()); 124 | 125 | assert_eq!( 126 | repo.get_note("other", crate::backend::NoteRef::Changes), 127 | Some(String::from( 128 | r#"[[changes]] 129 | action = "add" 130 | name = "my-metric" 131 | value = 12.34 132 | 133 | [changes.tags] 134 | "# 135 | )) 136 | ); 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/cmd/check/format/format_md_by_default.md: -------------------------------------------------------------------------------- 1 |
StatusMetricPrevious valueCurrent valueChange
⛔️first{platform.os="linux", platform.arch="amd64", unit="byte"}10.0020.0010.00
(+100.00 %)
show_not_increase_too_much
⛔️ increase should be less than 20.00 %
first{platform.os="linux", platform.arch="arm64", unit="byte"}10.0011.001.00
(+10.00 %)
⏭️unknown42.0028.00-14.00
(-33.33 %)
⏭️noglobal42.0028.00-14.00
(-33.33 %)
nochange10.0010.000.00
(+0.00 %)
with-unit20.00 MiB25.00 MiB5.00 MiB
(+25.00 %)
⛔️with-change20971520.0026214400.005242880.00
(+25.00 %)
⛔️ increase should be less than 2097152.00
-------------------------------------------------------------------------------- /src/cmd/check/format/format_md_with_success_showed.md: -------------------------------------------------------------------------------- 1 |
StatusMetricPrevious valueCurrent valueChange
⛔️first{platform.os="linux", platform.arch="amd64", unit="byte"}10.0020.0010.00
(+100.00 %)
✅ should be lower than 30.00
show_not_increase_too_much
⛔️ increase should be less than 20.00 %
first{platform.os="linux", platform.arch="arm64", unit="byte"}10.0011.001.00
(+10.00 %)
✅ should be lower than 30.00
show_not_increase_too_much
✅ increase should be less than 20.00 %
⏭️unknown42.0028.00-14.00
(-33.33 %)
⏭️noglobal42.0028.00-14.00
(-33.33 %)
show_pass
⏭️ increase should be less than 20.00 %
nochange10.0010.000.00
(+0.00 %)
✅ should be lower than 30.00
with-unit20.00 MiB25.00 MiB5.00 MiB
(+25.00 %)
✅ should be lower than 30.00 MiB
⛔️with-change20971520.0026214400.005242880.00
(+25.00 %)
✅ increase should be less than 10485760.00
⛔️ increase should be less than 2097152.00
-------------------------------------------------------------------------------- /src/cmd/check/format/format_text_by_default.txt: -------------------------------------------------------------------------------- 1 | [FAILURE] first{platform.os="linux", platform.arch="amd64", unit="byte"} 10.00 => 20.00 Δ +10.00 (+100.00 %) 2 | # "show_not_increase_too_much" matching tags {platform.os="linux"} 3 | increase should be less than 20.00 % ... failed 4 | [SUCCESS] first{platform.os="linux", platform.arch="arm64", unit="byte"} 10.00 => 11.00 Δ +1.00 (+10.00 %) 5 | [SKIP] unknown 42.00 => 28.00 Δ -14.00 (-33.33 %) 6 | [SKIP] noglobal 42.00 => 28.00 Δ -14.00 (-33.33 %) 7 | [SUCCESS] nochange 10.00 => 10.00 8 | [SUCCESS] with-unit 20.00 MiB => 25.00 MiB Δ +5.00 MiB (+25.00 %) 9 | [FAILURE] with-change 20971520.00 => 26214400.00 Δ +5242880.00 (+25.00 %) 10 | increase should be less than 2097152.00 ... failed 11 | -------------------------------------------------------------------------------- /src/cmd/check/format/format_text_with_success_showed.txt: -------------------------------------------------------------------------------- 1 | [FAILURE] first{platform.os="linux", platform.arch="amd64", unit="byte"} 10.00 => 20.00 Δ +10.00 (+100.00 %) 2 | should be lower than 30.00 ... check 3 | # "show_not_increase_too_much" matching tags {platform.os="linux"} 4 | increase should be less than 20.00 % ... failed 5 | [SUCCESS] first{platform.os="linux", platform.arch="arm64", unit="byte"} 10.00 => 11.00 Δ +1.00 (+10.00 %) 6 | should be lower than 30.00 ... check 7 | # "show_not_increase_too_much" matching tags {platform.os="linux"} 8 | increase should be less than 20.00 % ... check 9 | [SKIP] unknown 42.00 => 28.00 Δ -14.00 (-33.33 %) 10 | [SKIP] noglobal 42.00 => 28.00 Δ -14.00 (-33.33 %) 11 | # "show_pass" matching tags {foo="bar"} 12 | increase should be less than 20.00 % ... skip 13 | [SUCCESS] nochange 10.00 => 10.00 14 | should be lower than 30.00 ... check 15 | [SUCCESS] with-unit 20.00 MiB => 25.00 MiB Δ +5.00 MiB (+25.00 %) 16 | should be lower than 30.00 MiB ... check 17 | [FAILURE] with-change 20971520.00 => 26214400.00 Δ +5242880.00 (+25.00 %) 18 | increase should be less than 10485760.00 ... check 19 | increase should be less than 2097152.00 ... failed 20 | -------------------------------------------------------------------------------- /src/cmd/check/format/html.rs: -------------------------------------------------------------------------------- 1 | use another_html_builder::prelude::WriterExt; 2 | use another_html_builder::{Body, Buffer}; 3 | 4 | use crate::entity::check::{MetricCheck, RuleCheck, StatusCount}; 5 | use crate::entity::config::Config; 6 | use crate::formatter::metric::TextMetricHeader; 7 | use crate::formatter::percent::TextPercent; 8 | use crate::formatter::rule::TextRule; 9 | 10 | fn empty(buf: Buffer>) -> Buffer> { 11 | buf 12 | } 13 | 14 | fn text( 15 | value: &'static str, 16 | ) -> impl FnOnce(Buffer>) -> Buffer> { 17 | |buf: Buffer>| buf.text(value) 18 | } 19 | 20 | fn write_thead(buf: Buffer>) -> Buffer> { 21 | buf.node("thead").content(|buf| { 22 | buf.node("tr").content(|buf| { 23 | buf.node("th") 24 | .attr(("align", "center")) 25 | .content(text("Status")) 26 | .node("th") 27 | .attr(("align", "left")) 28 | .content(text("Metric")) 29 | .node("th") 30 | .attr(("align", "right")) 31 | .content(text("Previous value")) 32 | .node("th") 33 | .attr(("align", "right")) 34 | .content(text("Current value")) 35 | .node("th") 36 | .attr(("align", "right")) 37 | .content(text("Change")) 38 | }) 39 | }) 40 | } 41 | 42 | fn should_display_detailed(params: &super::Params, status: &StatusCount) -> bool { 43 | status.failed > 0 44 | || (status.neutral > 0 && params.show_skipped_rules) 45 | || (status.success > 0 && params.show_success_rules) 46 | } 47 | 48 | pub(super) struct MetricCheckTable<'a> { 49 | params: &'a super::Params, 50 | config: &'a Config, 51 | values: &'a [MetricCheck], 52 | } 53 | 54 | impl<'e> MetricCheckTable<'e> { 55 | pub fn new(params: &'e super::Params, config: &'e Config, values: &'e [MetricCheck]) -> Self { 56 | Self { 57 | params, 58 | config, 59 | values, 60 | } 61 | } 62 | 63 | fn write_rule_check<'a, W: WriterExt>( 64 | &self, 65 | buf: Buffer>, 66 | check: &RuleCheck, 67 | formatter: &human_number::Formatter<'_>, 68 | ) -> Buffer> { 69 | buf.cond( 70 | check.status.is_failed() 71 | || (self.params.show_skipped_rules && check.status.is_skip()) 72 | || (self.params.show_success_rules && check.status.is_success()), 73 | |buf| { 74 | buf.raw(check.status.emoji()) 75 | .raw(" ") 76 | .raw(TextRule::new(formatter, &check.rule)) 77 | .node("br") 78 | .close() 79 | }, 80 | ) 81 | } 82 | 83 | fn write_metric_check<'a, W: WriterExt>( 84 | &self, 85 | buf: Buffer>, 86 | check: &MetricCheck, 87 | ) -> Buffer> { 88 | let formatter = self.config.formatter(&check.diff.header.name); 89 | 90 | let buf = buf.node("tr").content(|buf| { 91 | buf.node("td") 92 | .attr(("align", "center")) 93 | .content(|buf| buf.raw(check.status.status().emoji())) 94 | .node("td") 95 | .attr(("align", "left")) 96 | .content(|buf| buf.raw(TextMetricHeader::new(&check.diff.header))) 97 | .node("td") 98 | .attr(("align", "right")) 99 | .content(|buf| { 100 | buf.optional(check.diff.comparison.previous(), |buf, value| { 101 | buf.raw(formatter.format(value)) 102 | }) 103 | }) 104 | .node("td") 105 | .attr(("align", "right")) 106 | .content(|buf| { 107 | buf.optional(check.diff.comparison.current(), |buf, value| { 108 | buf.raw(formatter.format(value)) 109 | }) 110 | }) 111 | .node("td") 112 | .attr(("align", "right")) 113 | .content(|buf| { 114 | buf.optional(check.diff.comparison.delta(), |buf, delta| { 115 | let buf = buf.raw(formatter.format(delta.absolute)); 116 | buf.optional(delta.relative, |buf, rel| { 117 | buf.node("br") 118 | .close() 119 | .raw("(") 120 | .raw(TextPercent::new(rel).with_sign(true)) 121 | .raw(")") 122 | }) 123 | }) 124 | }) 125 | }); 126 | 127 | buf.cond(should_display_detailed(self.params, &check.status), |buf| { 128 | buf.node("tr").content(|buf| { 129 | buf.node("td") 130 | .content(empty) 131 | .node("td") 132 | .attr(("colspan", "4")) 133 | .content(|buf| { 134 | let buf = check.checks.iter().fold(buf, |buf, rule_check| { 135 | self.write_rule_check(buf, rule_check, &formatter) 136 | }); 137 | check.subsets.iter().fold(buf, |buf, (title, subset)| { 138 | buf.cond( 139 | should_display_detailed(self.params, &subset.status), 140 | |buf| { 141 | let buf = buf 142 | .node("i") 143 | .content(|buf| buf.text(title)) 144 | .node("br") 145 | .close(); 146 | 147 | subset.checks.iter().fold(buf, |buf, rule_check| { 148 | self.write_rule_check(buf, rule_check, &formatter) 149 | }) 150 | }, 151 | ) 152 | }) 153 | }) 154 | }) 155 | }) 156 | } 157 | 158 | pub fn write<'a, W: WriterExt>(&self, buf: Buffer>) -> Buffer> { 159 | buf.node("table").content(|buf| { 160 | let buf = write_thead(buf); 161 | buf.node("tbody").content(|buf| { 162 | self.values 163 | .iter() 164 | .fold(buf, |buf, check| self.write_metric_check(buf, check)) 165 | }) 166 | }) 167 | } 168 | 169 | pub fn render(&self, writer: W) -> W { 170 | let buf = Buffer::from(writer); 171 | let buf = self.write(buf); 172 | buf.into_inner() 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/cmd/check/format/markdown.rs: -------------------------------------------------------------------------------- 1 | use crate::entity::check::CheckList; 2 | use crate::entity::config::Config; 3 | 4 | pub struct MarkdownFormatter<'a> { 5 | params: &'a super::Params, 6 | } 7 | 8 | impl<'a> MarkdownFormatter<'a> { 9 | pub fn new(params: &'a super::Params) -> Self { 10 | Self { params } 11 | } 12 | 13 | pub fn format( 14 | &self, 15 | res: &CheckList, 16 | config: &Config, 17 | stdout: W, 18 | ) -> std::io::Result { 19 | Ok(super::html::MetricCheckTable::new(self.params, config, &res.list).render(stdout)) 20 | } 21 | } 22 | 23 | #[cfg(test)] 24 | mod tests { 25 | use super::*; 26 | use crate::cmd::check::format::Params; 27 | use crate::cmd::prelude::BasicWriter; 28 | use crate::entity::check::{CheckList, MetricCheck, Status, SubsetCheck}; 29 | use crate::entity::config::{MetricConfig, Rule, Unit}; 30 | use crate::entity::difference::{Comparison, MetricDiff}; 31 | use crate::entity::metric::MetricHeader; 32 | 33 | fn complete_checklist() -> CheckList { 34 | CheckList::default() 35 | .with_check( 36 | MetricCheck::new(MetricDiff::new( 37 | MetricHeader::new("first") 38 | .with_tag("platform.os", "linux") 39 | .with_tag("platform.arch", "amd64") 40 | .with_tag("unit", "byte"), 41 | Comparison::matching(10.0, 20.0), 42 | )) 43 | .with_check(Rule::max(30.0), Status::Success) 44 | .with_subset( 45 | "show_not_increase_too_much", 46 | SubsetCheck::default() 47 | .with_matching("platform.os", "linux") 48 | .with_check(Rule::max_relative_increase(0.2), Status::Failed), 49 | ), 50 | ) 51 | .with_check( 52 | MetricCheck::new(MetricDiff::new( 53 | MetricHeader::new("first") 54 | .with_tag("platform.os", "linux") 55 | .with_tag("platform.arch", "arm64") 56 | .with_tag("unit", "byte"), 57 | Comparison::matching(10.0, 11.0), 58 | )) 59 | .with_check(Rule::max(30.0), Status::Success) 60 | .with_subset( 61 | "show_not_increase_too_much", 62 | SubsetCheck::default() 63 | .with_matching("platform.os", "linux") 64 | .with_check(Rule::max_relative_increase(0.2), Status::Success), 65 | ), 66 | ) 67 | // metric not known in config 68 | .with_check(MetricCheck::new(MetricDiff::new( 69 | MetricHeader::new("unknown"), 70 | Comparison::matching(42.0, 28.0), 71 | ))) 72 | // metric without general rule 73 | .with_check( 74 | MetricCheck::new(MetricDiff::new( 75 | MetricHeader::new("noglobal"), 76 | Comparison::matching(42.0, 28.0), 77 | )) 78 | .with_subset( 79 | "show_pass", 80 | SubsetCheck::default() 81 | .with_matching("foo", "bar") 82 | .with_check(Rule::max_relative_increase(0.2), Status::Skip), 83 | ), 84 | ) 85 | // metric that doesn't change 86 | .with_check( 87 | MetricCheck::new(MetricDiff::new( 88 | MetricHeader::new("nochange"), 89 | Comparison::matching(10.0, 10.0), 90 | )) 91 | .with_check(Rule::max(30.0), Status::Success), 92 | ) 93 | // metric that doesn't change 94 | .with_check( 95 | MetricCheck::new(MetricDiff::new( 96 | MetricHeader::new("with-unit"), 97 | Comparison::matching(1024.0 * 1024.0 * 20.0, 1024.0 * 1024.0 * 25.0), 98 | )) 99 | .with_check(Rule::max(1024.0 * 1024.0 * 30.0), Status::Success), 100 | ) 101 | // with absolute change 102 | .with_check( 103 | MetricCheck::new(MetricDiff::new( 104 | MetricHeader::new("with-change"), 105 | Comparison::matching(1024.0 * 1024.0 * 20.0, 1024.0 * 1024.0 * 25.0), 106 | )) 107 | .with_check( 108 | Rule::max_absolute_increase(1024.0 * 1024.0 * 10.0), 109 | Status::Success, 110 | ) 111 | .with_check( 112 | Rule::max_absolute_increase(1024.0 * 1024.0 * 2.0), 113 | Status::Failed, 114 | ), 115 | ) 116 | } 117 | 118 | #[test] 119 | fn should_format_to_text_by_default() { 120 | let config = Config::default().with_metric( 121 | "with-unit", 122 | MetricConfig::default().with_unit(Unit::binary().with_suffix("B")), 123 | ); 124 | let markdown_formatter = MarkdownFormatter::new(&Params { 125 | show_success_rules: false, 126 | show_skipped_rules: false, 127 | }); 128 | let list = complete_checklist(); 129 | let mut writter = BasicWriter::from(Vec::::new()); 130 | markdown_formatter 131 | .format(&list, &config, &mut writter) 132 | .unwrap(); 133 | let stdout = writter.into_string(); 134 | similar_asserts::assert_eq!(stdout, include_str!("./format_md_by_default.md")); 135 | } 136 | 137 | #[test] 138 | fn should_format_to_text_with_success_showed() { 139 | let config = Config::default().with_metric( 140 | "with-unit", 141 | MetricConfig::default().with_unit(Unit::binary().with_suffix("B")), 142 | ); 143 | let formatter = MarkdownFormatter::new(&Params { 144 | show_success_rules: true, 145 | show_skipped_rules: true, 146 | }); 147 | let list = complete_checklist(); 148 | let mut writter = BasicWriter::from(Vec::::new()); 149 | formatter.format(&list, &config, &mut writter).unwrap(); 150 | let stdout = writter.into_string(); 151 | similar_asserts::assert_eq!(stdout, include_str!("./format_md_with_success_showed.md")); 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/cmd/check/format/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::entity::check::Status; 2 | 3 | mod html; 4 | pub mod markdown; 5 | pub mod text; 6 | 7 | #[derive(Default)] 8 | pub(crate) struct Params { 9 | pub show_success_rules: bool, 10 | pub show_skipped_rules: bool, 11 | } 12 | 13 | impl Status { 14 | const fn big_label(&self) -> &'static str { 15 | match self { 16 | Status::Failed => "[FAILURE]", 17 | Status::Skip => "[SKIP]", 18 | Status::Success => "[SUCCESS]", 19 | } 20 | } 21 | 22 | fn style(&self) -> nu_ansi_term::Style { 23 | match self { 24 | Status::Failed => nu_ansi_term::Style::new() 25 | .bold() 26 | .fg(nu_ansi_term::Color::Red), 27 | Status::Skip => nu_ansi_term::Style::new() 28 | .italic() 29 | .fg(nu_ansi_term::Color::LightGray), 30 | Status::Success => nu_ansi_term::Style::new() 31 | .bold() 32 | .fg(nu_ansi_term::Color::Green), 33 | } 34 | } 35 | 36 | const fn small_label(&self) -> &'static str { 37 | match self { 38 | Status::Failed => "failed", 39 | Status::Skip => "skip", 40 | Status::Success => "check", 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/cmd/check/format/text.rs: -------------------------------------------------------------------------------- 1 | use human_number::Formatter; 2 | 3 | use crate::cmd::format::text::{PrettyTextMetricHeader, TAB}; 4 | use crate::cmd::prelude::{PrettyDisplay, PrettyWriter}; 5 | use crate::entity::check::{CheckList, MetricCheck, RuleCheck, Status}; 6 | use crate::entity::config::Config; 7 | use crate::formatter::difference::ShortTextComparison; 8 | use crate::formatter::metric::TextMetricTags; 9 | use crate::formatter::rule::TextRule; 10 | 11 | struct TextStatus { 12 | value: Status, 13 | } 14 | 15 | impl TextStatus { 16 | #[inline] 17 | pub const fn new(value: Status) -> Self { 18 | Self { value } 19 | } 20 | } 21 | 22 | impl PrettyDisplay for TextStatus { 23 | fn print(&self, writer: &mut W) -> std::io::Result<()> { 24 | let style = self.value.style(); 25 | writer.set_style(style.prefix())?; 26 | writer.write_str(self.value.big_label())?; 27 | writer.set_style(style.suffix())?; 28 | Ok(()) 29 | } 30 | } 31 | 32 | struct SmallTextStatus { 33 | value: Status, 34 | } 35 | 36 | impl SmallTextStatus { 37 | #[inline] 38 | pub const fn new(value: Status) -> Self { 39 | Self { value } 40 | } 41 | } 42 | 43 | impl PrettyDisplay for SmallTextStatus { 44 | fn print(&self, writer: &mut W) -> std::io::Result<()> { 45 | let style = self.value.style(); 46 | writer.set_style(style.prefix())?; 47 | writer.write_str(self.value.small_label())?; 48 | writer.set_style(style.suffix())?; 49 | Ok(()) 50 | } 51 | } 52 | 53 | pub struct TextFormatter<'a> { 54 | params: &'a super::Params, 55 | } 56 | 57 | impl<'a> TextFormatter<'a> { 58 | pub fn new(params: &'a super::Params) -> Self { 59 | Self { params } 60 | } 61 | 62 | fn format_check( 63 | &self, 64 | check: &RuleCheck, 65 | numeric_formatter: &Formatter<'_>, 66 | stdout: &mut W, 67 | ) -> std::io::Result<()> { 68 | match check.status { 69 | Status::Success if !self.params.show_success_rules => Ok(()), 70 | Status::Skip if !self.params.show_skipped_rules => Ok(()), 71 | _ => { 72 | stdout.write_str(TAB)?; 73 | stdout.write_element(TextRule::new(numeric_formatter, &check.rule))?; 74 | stdout.write_str(" ... ")?; 75 | stdout.write_element(SmallTextStatus::new(check.status))?; 76 | writeln!(stdout) 77 | } 78 | } 79 | } 80 | 81 | fn format_metric( 82 | &self, 83 | item: &MetricCheck, 84 | numeric_formatter: Formatter<'_>, 85 | stdout: &mut W, 86 | ) -> std::io::Result<()> { 87 | stdout.write_element(TextStatus::new(item.status.status()))?; 88 | stdout.write_str(" ")?; 89 | stdout.write_element(PrettyTextMetricHeader::new(&item.diff.header))?; 90 | stdout.write_str(" ")?; 91 | stdout.write_element(ShortTextComparison::new( 92 | &numeric_formatter, 93 | &item.diff.comparison, 94 | ))?; 95 | stdout.write_str("\n")?; 96 | for check in item.checks.iter() { 97 | self.format_check(check, &numeric_formatter, stdout)?; 98 | } 99 | let subset_style = nu_ansi_term::Style::new().fg(nu_ansi_term::Color::LightGray); 100 | for (name, subset) in item.subsets.iter() { 101 | if subset.status.is_failed() 102 | || (self.params.show_skipped_rules && subset.status.neutral > 0) 103 | || (self.params.show_success_rules && subset.status.success > 0) 104 | { 105 | stdout.set_style(subset_style.prefix())?; 106 | writeln!( 107 | stdout, 108 | "{TAB}# {name:?} matching tags {}", 109 | TextMetricTags::new(&subset.matching) 110 | )?; 111 | stdout.set_style(subset_style.suffix())?; 112 | for check in subset.checks.iter() { 113 | self.format_check(check, &numeric_formatter, stdout)?; 114 | } 115 | } 116 | } 117 | Ok(()) 118 | } 119 | 120 | pub fn format( 121 | &self, 122 | res: &CheckList, 123 | config: &Config, 124 | mut stdout: W, 125 | ) -> std::io::Result { 126 | for entry in res.list.iter() { 127 | let formatter: Formatter = config.formatter(entry.diff.header.name.as_str()); 128 | self.format_metric(entry, formatter, &mut stdout)?; 129 | } 130 | Ok(stdout) 131 | } 132 | } 133 | 134 | #[cfg(test)] 135 | mod tests { 136 | use super::*; 137 | use crate::cmd::check::format::Params; 138 | use crate::cmd::prelude::BasicWriter; 139 | use crate::entity::check::SubsetCheck; 140 | use crate::entity::config::{MetricConfig, Rule, Unit}; 141 | use crate::entity::difference::{Comparison, MetricDiff}; 142 | use crate::entity::metric::MetricHeader; 143 | 144 | fn complete_checklist() -> CheckList { 145 | CheckList::default() 146 | .with_check( 147 | MetricCheck::new(MetricDiff::new( 148 | MetricHeader::new("first") 149 | .with_tag("platform.os", "linux") 150 | .with_tag("platform.arch", "amd64") 151 | .with_tag("unit", "byte"), 152 | Comparison::matching(10.0, 20.0), 153 | )) 154 | .with_check(Rule::max(30.0), Status::Success) 155 | .with_subset( 156 | "show_not_increase_too_much", 157 | SubsetCheck::default() 158 | .with_matching("platform.os", "linux") 159 | .with_check(Rule::max_relative_increase(0.2), Status::Failed), 160 | ), 161 | ) 162 | .with_check( 163 | MetricCheck::new(MetricDiff::new( 164 | MetricHeader::new("first") 165 | .with_tag("platform.os", "linux") 166 | .with_tag("platform.arch", "arm64") 167 | .with_tag("unit", "byte"), 168 | Comparison::matching(10.0, 11.0), 169 | )) 170 | .with_check(Rule::max(30.0), Status::Success) 171 | .with_subset( 172 | "show_not_increase_too_much", 173 | SubsetCheck::default() 174 | .with_matching("platform.os", "linux") 175 | .with_check(Rule::max_relative_increase(0.2), Status::Success), 176 | ), 177 | ) 178 | // metric not known in config 179 | .with_check(MetricCheck::new(MetricDiff::new( 180 | MetricHeader::new("unknown"), 181 | Comparison::matching(42.0, 28.0), 182 | ))) 183 | // metric without general rule 184 | .with_check( 185 | MetricCheck::new(MetricDiff::new( 186 | MetricHeader::new("noglobal"), 187 | Comparison::matching(42.0, 28.0), 188 | )) 189 | .with_subset( 190 | "show_pass", 191 | SubsetCheck::default() 192 | .with_matching("foo", "bar") 193 | .with_check(Rule::max_relative_increase(0.2), Status::Skip), 194 | ), 195 | ) 196 | // metric that doesn't change 197 | .with_check( 198 | MetricCheck::new(MetricDiff::new( 199 | MetricHeader::new("nochange"), 200 | Comparison::matching(10.0, 10.0), 201 | )) 202 | .with_check(Rule::max(30.0), Status::Success), 203 | ) 204 | // metric that doesn't change 205 | .with_check( 206 | MetricCheck::new(MetricDiff::new( 207 | MetricHeader::new("with-unit"), 208 | Comparison::matching(1024.0 * 1024.0 * 20.0, 1024.0 * 1024.0 * 25.0), 209 | )) 210 | .with_check(Rule::max(1024.0 * 1024.0 * 30.0), Status::Success), 211 | ) 212 | // with absolute change 213 | .with_check( 214 | MetricCheck::new(MetricDiff::new( 215 | MetricHeader::new("with-change"), 216 | Comparison::matching(1024.0 * 1024.0 * 20.0, 1024.0 * 1024.0 * 25.0), 217 | )) 218 | .with_check( 219 | Rule::max_absolute_increase(1024.0 * 1024.0 * 10.0), 220 | Status::Success, 221 | ) 222 | .with_check( 223 | Rule::max_absolute_increase(1024.0 * 1024.0 * 2.0), 224 | Status::Failed, 225 | ), 226 | ) 227 | } 228 | 229 | #[test] 230 | fn should_format_to_text_by_default() { 231 | let config = Config::default().with_metric( 232 | "with-unit", 233 | MetricConfig::default().with_unit(Unit::binary().with_suffix("B")), 234 | ); 235 | let text_formatter = TextFormatter::new(&Params { 236 | show_skipped_rules: false, 237 | show_success_rules: false, 238 | }); 239 | let list = complete_checklist(); 240 | let writter = BasicWriter::from(Vec::::new()); 241 | let writter = text_formatter.format(&list, &config, writter).unwrap(); 242 | let stdout = writter.into_string(); 243 | similar_asserts::assert_eq!(stdout, include_str!("./format_text_by_default.txt")); 244 | } 245 | 246 | #[test] 247 | fn should_format_to_text_with_success_showed() { 248 | let config = Config::default().with_metric( 249 | "with-unit", 250 | MetricConfig::default().with_unit(Unit::binary().with_suffix("B")), 251 | ); 252 | let formatter = TextFormatter::new(&Params { 253 | show_success_rules: true, 254 | show_skipped_rules: true, 255 | }); 256 | let list = complete_checklist(); 257 | let writter = BasicWriter::from(Vec::::new()); 258 | let writter = formatter.format(&list, &config, writter).unwrap(); 259 | let stdout = writter.into_string(); 260 | similar_asserts::assert_eq!( 261 | stdout, 262 | include_str!("./format_text_with_success_showed.txt") 263 | ); 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /src/cmd/check/mod.rs: -------------------------------------------------------------------------------- 1 | use super::prelude::PrettyWriter; 2 | use crate::backend::Backend; 3 | use crate::service::Service; 4 | use crate::ExitCode; 5 | 6 | mod format; 7 | 8 | /// Show metrics changes 9 | #[derive(clap::Parser, Debug, Default)] 10 | pub struct CommandCheck { 11 | /// Remote name, default to origin 12 | #[clap(long, default_value = "origin")] 13 | remote: String, 14 | /// Output format 15 | #[clap(long, default_value = "text")] 16 | format: super::format::Format, 17 | /// Show the successful rules 18 | #[clap(long)] 19 | show_success_rules: bool, 20 | /// Show the skipped rules 21 | #[clap(long)] 22 | show_skipped_rules: bool, 23 | /// Commit range, default to HEAD 24 | /// 25 | /// Can use ranges like HEAD~2..HEAD 26 | #[clap(default_value = "HEAD")] 27 | target: String, 28 | } 29 | 30 | impl super::Executor for CommandCheck { 31 | #[tracing::instrument(name = "check", skip_all, fields(target = self.target.as_str()))] 32 | fn execute( 33 | self, 34 | backend: B, 35 | stdout: Out, 36 | ) -> Result { 37 | let svc = Service::new(backend); 38 | let config = svc.open_config()?; 39 | let checklist = svc.check( 40 | &config, 41 | &crate::service::check::Options { 42 | remote: self.remote.as_str(), 43 | target: self.target.as_str(), 44 | }, 45 | )?; 46 | 47 | let format_params = format::Params { 48 | show_success_rules: self.show_success_rules, 49 | show_skipped_rules: self.show_skipped_rules, 50 | }; 51 | 52 | match self.format { 53 | super::format::Format::Text => { 54 | format::text::TextFormatter::new(&format_params) 55 | .format(&checklist, &config, stdout)?; 56 | } 57 | super::format::Format::Markdown => { 58 | format::markdown::MarkdownFormatter::new(&format_params) 59 | .format(&checklist, &config, stdout)?; 60 | } 61 | }; 62 | 63 | if checklist.status.is_failed() { 64 | Ok(ExitCode::Failure) 65 | } else { 66 | Ok(ExitCode::Success) 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/cmd/diff/format/markdown.rs: -------------------------------------------------------------------------------- 1 | use human_number::Formatter; 2 | 3 | use crate::entity::config::Config; 4 | use crate::entity::difference::{Comparison, MetricDiff, MetricDiffList}; 5 | use crate::formatter::difference::TextDelta; 6 | use crate::formatter::metric::TextMetricHeader; 7 | 8 | pub struct MarkdownFormatter<'a>(pub &'a super::Params); 9 | 10 | impl MarkdownFormatter<'_> { 11 | fn format_entry( 12 | &self, 13 | entry: &MetricDiff, 14 | formatter: &Formatter<'_>, 15 | stdout: &mut W, 16 | ) -> std::io::Result<()> { 17 | match &entry.comparison { 18 | Comparison::Created { current } => { 19 | writeln!( 20 | stdout, 21 | "| 🆕 | {} | | {} | |", 22 | TextMetricHeader::new(&entry.header), 23 | formatter.format(*current) 24 | ) 25 | } 26 | Comparison::Missing { previous } if self.0.show_previous => { 27 | writeln!( 28 | stdout, 29 | "| | {} | {} | | |", 30 | TextMetricHeader::new(&entry.header), 31 | formatter.format(*previous) 32 | ) 33 | } 34 | Comparison::Matching { 35 | previous, 36 | current, 37 | delta: _, 38 | } if previous == current => { 39 | writeln!( 40 | stdout, 41 | "| ➡️ | {} | {} | {} | |", 42 | TextMetricHeader::new(&entry.header), 43 | formatter.format(*previous), 44 | formatter.format(*current), 45 | ) 46 | } 47 | Comparison::Matching { 48 | previous, 49 | current, 50 | delta, 51 | } => { 52 | let icon = if delta.absolute > 0.0 { 53 | "⬆️" 54 | } else { 55 | "⬇️" 56 | }; 57 | writeln!( 58 | stdout, 59 | "| {icon} | {} | {} | {} | {} |", 60 | TextMetricHeader::new(&entry.header), 61 | formatter.format(*previous), 62 | formatter.format(*current), 63 | TextDelta::new(formatter, delta) 64 | ) 65 | } 66 | _ => Ok(()), 67 | } 68 | } 69 | 70 | pub fn format( 71 | &self, 72 | list: &MetricDiffList, 73 | config: &Config, 74 | mut stdout: W, 75 | ) -> std::io::Result { 76 | writeln!( 77 | &mut stdout, 78 | "| | Metric | Previous value | Current value | Change |" 79 | )?; 80 | writeln!( 81 | &mut stdout, 82 | "|:---:|:-------|---------------:|--------------:|-------:|" 83 | )?; 84 | for entry in list.inner().iter() { 85 | let formatter: Formatter = config.formatter(entry.header.name.as_str()); 86 | self.format_entry(entry, &formatter, &mut stdout)?; 87 | } 88 | Ok(stdout) 89 | } 90 | } 91 | 92 | #[cfg(test)] 93 | mod tests { 94 | use crate::cmd::diff::format::Params; 95 | use crate::cmd::prelude::BasicWriter; 96 | use crate::entity::config::Config; 97 | use crate::entity::difference::{Comparison, MetricDiff, MetricDiffList}; 98 | use crate::entity::metric::MetricHeader; 99 | 100 | #[test] 101 | fn should_format_text() { 102 | let list = MetricDiffList(vec![ 103 | MetricDiff::new(MetricHeader::new("first"), Comparison::created(10.0)), 104 | MetricDiff::new( 105 | MetricHeader::new("second"), 106 | Comparison::new(10.0, Some(12.0)), 107 | ), 108 | MetricDiff::new(MetricHeader::new("third"), Comparison::new(10.0, None)), 109 | ]); 110 | let mut writer = BasicWriter::from(Vec::::new()); 111 | let config = Config::default(); 112 | super::MarkdownFormatter(&Params { 113 | show_previous: true, 114 | }) 115 | .format(&list, &config, &mut writer) 116 | .unwrap(); 117 | let stdout = writer.into_string(); 118 | similar_asserts::assert_eq!( 119 | stdout, 120 | r#"| | Metric | Previous value | Current value | Change | 121 | |:---:|:-------|---------------:|--------------:|-------:| 122 | | 🆕 | first | | 10.00 | | 123 | | ⬆️ | second | 10.00 | 12.00 | 2.00 (+20.00 %) | 124 | | | third | 10.00 | | | 125 | "# 126 | ); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/cmd/diff/format/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod markdown; 2 | pub mod text; 3 | 4 | #[derive(Default)] 5 | pub struct Params { 6 | pub show_previous: bool, 7 | } 8 | -------------------------------------------------------------------------------- /src/cmd/diff/format/text.rs: -------------------------------------------------------------------------------- 1 | use human_number::Formatter; 2 | 3 | use crate::cmd::format::text::PrettyTextMetricHeader; 4 | use crate::cmd::prelude::PrettyWriter; 5 | use crate::entity::config::Config; 6 | use crate::entity::difference::{Comparison, MetricDiff, MetricDiffList}; 7 | use crate::formatter::percent::TextPercent; 8 | 9 | pub struct TextFormatter<'a>(pub &'a super::Params); 10 | 11 | impl TextFormatter<'_> { 12 | fn format_entry( 13 | &self, 14 | entry: &MetricDiff, 15 | formatter: Formatter, 16 | stdout: &mut W, 17 | ) -> std::io::Result<()> { 18 | match &entry.comparison { 19 | Comparison::Created { current } => { 20 | stdout.write_str("+ ")?; 21 | stdout.write_element(PrettyTextMetricHeader::new(&entry.header))?; 22 | writeln!(stdout, " {}", formatter.format(*current)) 23 | } 24 | Comparison::Missing { previous } if self.0.show_previous => { 25 | stdout.write_str(" ")?; 26 | stdout.write_element(PrettyTextMetricHeader::new(&entry.header))?; 27 | writeln!(stdout, " {}", formatter.format(*previous)) 28 | } 29 | Comparison::Matching { 30 | previous, 31 | current, 32 | delta: _, 33 | } if previous == current => { 34 | stdout.write_str("= ")?; 35 | stdout.write_element(PrettyTextMetricHeader::new(&entry.header))?; 36 | writeln!(stdout, " {}", formatter.format(*current)) 37 | } 38 | Comparison::Matching { 39 | previous, 40 | current, 41 | delta, 42 | } => { 43 | stdout.write_str("- ")?; 44 | stdout.write_element(PrettyTextMetricHeader::new(&entry.header))?; 45 | writeln!(stdout, " {}", formatter.format(*previous))?; 46 | stdout.write_str("+ ")?; 47 | stdout.write_element(PrettyTextMetricHeader::new(&entry.header))?; 48 | write!(stdout, " {}", formatter.format(*current))?; 49 | if let Some(relative) = delta.relative { 50 | stdout.write_str(" (")?; 51 | stdout.write_element(TextPercent::new(relative).with_sign(true))?; 52 | stdout.write_str(")")?; 53 | } 54 | writeln!(stdout) 55 | } 56 | _ => Ok(()), 57 | } 58 | } 59 | 60 | pub fn format( 61 | &self, 62 | list: &MetricDiffList, 63 | config: &Config, 64 | mut stdout: W, 65 | ) -> std::io::Result { 66 | for entry in list.inner().iter() { 67 | let formatter: Formatter = config.formatter(entry.header.name.as_str()); 68 | self.format_entry(entry, formatter, &mut stdout)?; 69 | } 70 | Ok(stdout) 71 | } 72 | } 73 | 74 | #[cfg(test)] 75 | mod tests { 76 | use crate::cmd::diff::format::Params; 77 | use crate::cmd::prelude::BasicWriter; 78 | use crate::entity::config::Config; 79 | use crate::entity::difference::{Comparison, MetricDiff, MetricDiffList}; 80 | use crate::entity::metric::MetricHeader; 81 | 82 | #[test] 83 | fn should_format_text() { 84 | let list = MetricDiffList(vec![ 85 | MetricDiff::new(MetricHeader::new("first"), Comparison::created(10.0)), 86 | MetricDiff::new( 87 | MetricHeader::new("second"), 88 | Comparison::new(10.0, Some(12.0)), 89 | ), 90 | MetricDiff::new(MetricHeader::new("third"), Comparison::new(10.0, None)), 91 | ]); 92 | let writer = BasicWriter::from(Vec::::new()); 93 | let config = Config::default(); 94 | let writer = super::TextFormatter(&Params { 95 | show_previous: true, 96 | }) 97 | .format(&list, &config, writer) 98 | .unwrap(); 99 | let stdout = writer.into_string(); 100 | similar_asserts::assert_eq!( 101 | stdout, 102 | r#"+ first 10.00 103 | - second 10.00 104 | + second 12.00 (+20.00 %) 105 | third 10.00 106 | "# 107 | ); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/cmd/diff/mod.rs: -------------------------------------------------------------------------------- 1 | use super::prelude::PrettyWriter; 2 | use crate::backend::Backend; 3 | use crate::service::Service; 4 | use crate::ExitCode; 5 | 6 | mod format; 7 | 8 | /// Show metrics changes 9 | #[derive(clap::Parser, Debug, Default)] 10 | pub struct CommandDiff { 11 | /// Remote name, default to origin 12 | #[clap(long, default_value = "origin")] 13 | remote: String, 14 | /// When enabled, the metrics prior the provided range will be displayed 15 | #[clap(long)] 16 | show_previous: bool, 17 | 18 | /// Output format 19 | #[clap(long, default_value = "text")] 20 | format: super::format::Format, 21 | 22 | /// Commit range, default to HEAD 23 | /// 24 | /// Can use ranges like HEAD~2..HEAD 25 | #[clap(default_value = "HEAD")] 26 | target: String, 27 | } 28 | 29 | impl super::Executor for CommandDiff { 30 | #[tracing::instrument(name = "diff", skip_all, fields(target = self.target.as_str()))] 31 | fn execute( 32 | self, 33 | backend: B, 34 | stdout: Out, 35 | ) -> Result { 36 | let svc = Service::new(backend); 37 | let config = svc.open_config()?; 38 | let opts = crate::service::diff::Options { 39 | remote: self.remote.as_str(), 40 | target: self.target.as_str(), 41 | }; 42 | let diff = svc.diff(&opts)?; 43 | let diff = if self.show_previous { 44 | diff 45 | } else { 46 | diff.remove_missing() 47 | }; 48 | let params = format::Params { 49 | show_previous: self.show_previous, 50 | }; 51 | match self.format { 52 | super::format::Format::Text => { 53 | format::text::TextFormatter(¶ms).format(&diff, &config, stdout) 54 | } 55 | super::format::Format::Markdown => { 56 | format::markdown::MarkdownFormatter(¶ms).format(&diff, &config, stdout) 57 | } 58 | }?; 59 | Ok(ExitCode::Success) 60 | } 61 | } 62 | 63 | #[cfg(test)] 64 | mod tests { 65 | use clap::Parser; 66 | 67 | use super::CommandDiff; 68 | 69 | #[test] 70 | fn should_parse_range() { 71 | let cmd = CommandDiff::parse_from(["_", "HEAD~4..HEAD"]); 72 | assert_eq!(cmd.target, "HEAD~4..HEAD"); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/cmd/export/json.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::ExitCode; 4 | 5 | /// Report check result and diff 6 | #[derive(clap::Parser, Debug)] 7 | pub struct CommandExportJson { 8 | /// Path to write the json output 9 | #[clap()] 10 | output: Option, 11 | } 12 | 13 | impl CommandExportJson { 14 | pub(super) fn execute( 15 | self, 16 | stdout: W, 17 | payload: &crate::exporter::Payload, 18 | ) -> Result { 19 | if let Some(path) = self.output { 20 | crate::exporter::json::to_file(&path, payload)?; 21 | } else { 22 | crate::exporter::json::to_writer(stdout, payload)?; 23 | } 24 | Ok(ExitCode::Success) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/cmd/export/markdown.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use crate::entity::config::Config; 4 | use crate::ExitCode; 5 | 6 | /// Report check result and diff 7 | #[derive(clap::Parser, Debug)] 8 | pub struct CommandExportMarkdown { 9 | /// Path to write the json output 10 | #[clap()] 11 | output: Option, 12 | } 13 | 14 | impl CommandExportMarkdown { 15 | pub(super) fn execute( 16 | self, 17 | stdout: W, 18 | config: Config, 19 | payload: &crate::exporter::Payload, 20 | ) -> Result { 21 | if let Some(path) = self.output { 22 | crate::exporter::markdown::to_file(&path, &config, payload)?; 23 | } else { 24 | crate::exporter::markdown::to_writer(stdout, &config, payload)?; 25 | } 26 | Ok(ExitCode::Success) 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/cmd/export/mod.rs: -------------------------------------------------------------------------------- 1 | use super::prelude::PrettyWriter; 2 | use crate::backend::Backend; 3 | use crate::entity::config::Config; 4 | use crate::entity::log::LogEntry; 5 | use crate::service::Service; 6 | use crate::ExitCode; 7 | 8 | #[cfg(feature = "exporter-json")] 9 | mod json; 10 | #[cfg(feature = "exporter-markdown")] 11 | mod markdown; 12 | 13 | #[derive(Debug, clap::Subcommand)] 14 | enum ExportFormat { 15 | Json(json::CommandExportJson), 16 | Markdown(markdown::CommandExportMarkdown), 17 | } 18 | 19 | impl ExportFormat { 20 | fn execute( 21 | self, 22 | output: W, 23 | config: Config, 24 | payload: &crate::exporter::Payload, 25 | ) -> Result { 26 | match self { 27 | Self::Json(inner) => inner.execute(output, payload), 28 | Self::Markdown(inner) => inner.execute(output, config, payload), 29 | } 30 | } 31 | } 32 | 33 | /// Report check result and diff 34 | #[derive(clap::Parser, Debug)] 35 | pub struct CommandExport { 36 | /// Remote name, default to origin 37 | #[clap(default_value = "origin")] 38 | remote: String, 39 | /// Commit range, default to HEAD 40 | /// 41 | /// Can use ranges like HEAD~2..HEAD 42 | #[clap(default_value = "HEAD")] 43 | target: String, 44 | /// Output format 45 | #[command(subcommand)] 46 | format: ExportFormat, 47 | } 48 | 49 | impl super::Executor for CommandExport { 50 | #[tracing::instrument(name = "export", skip_all, fields( 51 | remote = self.remote.as_str(), 52 | target = self.target.as_str(), 53 | ))] 54 | fn execute( 55 | self, 56 | backend: B, 57 | stdout: Out, 58 | ) -> Result { 59 | let svc = Service::new(backend); 60 | let config = svc.open_config()?; 61 | 62 | let checks = svc.check( 63 | &config, 64 | &crate::service::check::Options { 65 | remote: self.remote.as_str(), 66 | target: self.target.as_str(), 67 | }, 68 | )?; 69 | 70 | let logs = svc.log(&crate::service::log::Options { 71 | remote: self.remote.as_str(), 72 | target: self.target.as_str(), 73 | })?; 74 | 75 | let payload = crate::exporter::Payload::new( 76 | self.target, 77 | checks, 78 | logs.into_iter().map(LogEntry::from).collect(), 79 | ); 80 | self.format.execute(stdout, config, &payload) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/cmd/format/mod.rs: -------------------------------------------------------------------------------- 1 | #[derive(clap::ValueEnum, Clone, Copy, Debug, Default)] 2 | pub enum Format { 3 | #[default] 4 | Text, 5 | Markdown, 6 | } 7 | 8 | pub mod text; 9 | -------------------------------------------------------------------------------- /src/cmd/format/text.rs: -------------------------------------------------------------------------------- 1 | use human_number::Formatter; 2 | 3 | use crate::cmd::prelude::{PrettyDisplay, PrettyWriter}; 4 | use crate::entity::metric::{Metric, MetricHeader}; 5 | use crate::formatter::metric::TextMetricTags; 6 | 7 | pub const TAB: &str = " "; 8 | 9 | pub struct PrettyTextMetricHeader<'a> { 10 | value: &'a MetricHeader, 11 | } 12 | 13 | impl<'a> PrettyTextMetricHeader<'a> { 14 | #[inline] 15 | pub const fn new(value: &'a MetricHeader) -> Self { 16 | Self { value } 17 | } 18 | } 19 | 20 | impl PrettyDisplay for PrettyTextMetricHeader<'_> { 21 | fn print(&self, writer: &mut W) -> std::io::Result<()> { 22 | let style = nu_ansi_term::Style::new().bold(); 23 | writer.set_style(style.prefix())?; 24 | writer.write_str(self.value.name.as_str())?; 25 | writer.set_style(style.suffix())?; 26 | TextMetricTags::new(&self.value.tags).print(writer) 27 | } 28 | } 29 | 30 | pub struct PrettyTextMetric<'a> { 31 | value: &'a Metric, 32 | formatter: &'a Formatter<'a>, 33 | } 34 | 35 | impl<'a> PrettyTextMetric<'a> { 36 | #[inline] 37 | pub const fn new(formatter: &'a Formatter<'a>, value: &'a Metric) -> Self { 38 | Self { value, formatter } 39 | } 40 | } 41 | 42 | impl PrettyDisplay for PrettyTextMetric<'_> { 43 | fn print(&self, writer: &mut W) -> std::io::Result<()> { 44 | PrettyTextMetricHeader::new(&self.value.header).print(writer)?; 45 | write!(writer, " {}", self.formatter.format(self.value.value)) 46 | } 47 | } 48 | 49 | #[cfg(test)] 50 | mod tests { 51 | use human_number::Formatter; 52 | 53 | use crate::cmd::prelude::PrettyDisplay; 54 | 55 | #[test] 56 | fn should_display_metric_with_single_tag() { 57 | let item = super::Metric::new("name", 12.34).with_tag("foo", "bar"); 58 | let formatter = Formatter::si(); 59 | assert_eq!( 60 | super::PrettyTextMetric::new(&formatter, &item) 61 | .to_basic_string() 62 | .unwrap(), 63 | "name{foo=\"bar\"} 12.34" 64 | ); 65 | } 66 | 67 | #[test] 68 | fn should_display_metric_with_multiple_tags() { 69 | let formatter = Formatter::si(); 70 | let item = super::Metric::new("name", 12.34) 71 | .with_tag("foo", "bar") 72 | .with_tag("ab", "cd"); 73 | assert_eq!( 74 | super::PrettyTextMetric::new(&formatter, &item) 75 | .to_basic_string() 76 | .unwrap(), 77 | "name{foo=\"bar\", ab=\"cd\"} 12.34" 78 | ); 79 | } 80 | 81 | #[test] 82 | fn should_display_metric_with_empty_tags() { 83 | let formatter = Formatter::si(); 84 | let item = super::Metric::new("name", 12.34); 85 | assert_eq!( 86 | super::PrettyTextMetric::new(&formatter, &item) 87 | .to_basic_string() 88 | .unwrap(), 89 | "name 12.34" 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/cmd/import/lcov.rs: -------------------------------------------------------------------------------- 1 | /// Imports metrics from a lcov.info file 2 | /// 3 | /// This can be obtained with the following commands 4 | /// 5 | /// For Rust, use with the following command. 6 | /// 7 | /// cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info 8 | /// 9 | /// For other languages, feel free to open a PR or an issue with the command. 10 | #[derive(clap::Parser, Debug)] 11 | pub(super) struct LcovImporter { 12 | /// Path to the lcov.info file 13 | path: std::path::PathBuf, 14 | /// Skip importing branch coverage 15 | #[clap(long, default_value = "false")] 16 | disable_branches: bool, 17 | /// Skip importing function coverage 18 | #[clap(long, default_value = "false")] 19 | disable_functions: bool, 20 | /// Skip importing line coverage 21 | #[clap(long, default_value = "false")] 22 | disable_lines: bool, 23 | } 24 | 25 | impl LcovImporter { 26 | #[inline(always)] 27 | fn options(&self) -> crate::importer::lcov::LcovImporterOptions { 28 | crate::importer::lcov::LcovImporterOptions { 29 | branches: !self.disable_branches, 30 | functions: !self.disable_functions, 31 | lines: !self.disable_lines, 32 | } 33 | } 34 | } 35 | 36 | impl crate::importer::Importer for LcovImporter { 37 | fn import(self) -> Result, crate::importer::Error> { 38 | let opts = self.options(); 39 | crate::importer::lcov::LcovImporter::new(self.path, opts).import() 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/cmd/import/mod.rs: -------------------------------------------------------------------------------- 1 | use super::prelude::PrettyWriter; 2 | use crate::entity::metric::Metric; 3 | use crate::importer::Importer; 4 | use crate::ExitCode; 5 | 6 | #[cfg(feature = "importer-lcov")] 7 | mod lcov; 8 | 9 | #[derive(Debug, clap::Subcommand)] 10 | enum CommandImporter { 11 | /// Just for testing, will import nothing. 12 | #[cfg(feature = "importer-noop")] 13 | Noop, 14 | #[cfg(feature = "importer-lcov")] 15 | Lcov(lcov::LcovImporter), 16 | } 17 | 18 | impl crate::importer::Importer for CommandImporter { 19 | fn import(self) -> Result, crate::importer::Error> { 20 | match self { 21 | #[cfg(feature = "importer-noop")] 22 | Self::Noop => Ok(Vec::new()), 23 | #[cfg(feature = "importer-lcov")] 24 | Self::Lcov(inner) => inner.import(), 25 | } 26 | } 27 | } 28 | 29 | /// Import metrics in batch from source files. 30 | #[derive(clap::Parser, Debug)] 31 | pub struct CommandImport { 32 | /// Commit target, default to HEAD 33 | #[clap(long, short, default_value = "HEAD")] 34 | target: String, 35 | 36 | /// Load metrics without adding them to the repository 37 | #[clap(long, default_value = "false")] 38 | dry_run: bool, 39 | 40 | #[command(subcommand)] 41 | importer: CommandImporter, 42 | } 43 | 44 | impl crate::cmd::Executor for CommandImport { 45 | fn execute( 46 | self, 47 | backend: B, 48 | _stdout: Out, 49 | ) -> Result { 50 | let metrics = self.importer.import()?; 51 | if metrics.is_empty() { 52 | tracing::debug!("no metrics found"); 53 | return Ok(ExitCode::Success); 54 | } 55 | 56 | tracing::debug!("{} metrics found", metrics.len()); 57 | 58 | if self.dry_run { 59 | for metric in metrics { 60 | tracing::info!("{metric:?}"); 61 | } 62 | tracing::debug!("dry run aborting early"); 63 | return Ok(ExitCode::Success); 64 | } 65 | 66 | let svc = crate::service::Service::new(backend); 67 | let opts = crate::service::add::Options { 68 | target: self.target, 69 | }; 70 | 71 | for metric in metrics { 72 | tracing::trace!("importing {metric:?}"); 73 | svc.add(metric, &opts)?; 74 | } 75 | tracing::debug!("import done"); 76 | Ok(ExitCode::Success) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/cmd/init.rs: -------------------------------------------------------------------------------- 1 | use super::prelude::PrettyWriter; 2 | use crate::entity::config::Config; 3 | use crate::ExitCode; 4 | 5 | /// Initialize the git-metrics configuration 6 | #[derive(clap::Parser, Debug, Default)] 7 | pub struct CommandInit; 8 | 9 | impl crate::cmd::Executor for CommandInit { 10 | fn execute( 11 | self, 12 | backend: B, 13 | _stdout: Out, 14 | ) -> Result { 15 | let root = backend.root_path()?; 16 | Config::write_sample(&root)?; 17 | Ok(ExitCode::Success) 18 | } 19 | } 20 | 21 | #[cfg(test)] 22 | mod tests { 23 | use clap::Parser; 24 | 25 | use super::CommandInit; 26 | use crate::cmd::prelude::BasicWriter; 27 | use crate::cmd::Executor; 28 | 29 | #[test] 30 | fn should_do_nothing_for_now() { 31 | let backend = crate::backend::mock::MockBackend::default(); 32 | let stdout = BasicWriter::from(Vec::::new()); 33 | let cmd = CommandInit::parse_from(["_"]).execute(backend, stdout); 34 | assert!(cmd.is_ok()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/cmd/log/format.rs: -------------------------------------------------------------------------------- 1 | use human_number::Formatter; 2 | 3 | /// The output format should be something like 4 | /// ``` 5 | /// * aaaaaa commit_message 6 | /// metric_name{key="value"} 12.34 7 | /// metric_name{key="other"} 23.45 8 | /// ``` 9 | use crate::cmd::format::text::PrettyTextMetric; 10 | use crate::cmd::prelude::{PrettyDisplay, PrettyWriter}; 11 | use crate::entity::config::Config; 12 | use crate::entity::git::Commit; 13 | use crate::entity::metric::{Metric, MetricStack}; 14 | 15 | const TAB: &str = " "; 16 | 17 | struct TextCommit<'a> { 18 | value: &'a Commit, 19 | } 20 | 21 | impl<'a> TextCommit<'a> { 22 | #[inline] 23 | pub const fn new(value: &'a Commit) -> Self { 24 | Self { value } 25 | } 26 | } 27 | 28 | impl PrettyDisplay for TextCommit<'_> { 29 | fn print(&self, writer: &mut W) -> std::io::Result<()> { 30 | let style = nu_ansi_term::Style::new().fg(nu_ansi_term::Color::Yellow); 31 | writer.write_str("* ")?; 32 | writer.set_style(style.prefix())?; 33 | writer.write_str(self.value.short_sha())?; 34 | writer.set_style(style.suffix())?; 35 | writer.write_str(" ")?; 36 | writer.write_str(self.value.summary.as_str())?; 37 | Ok(()) 38 | } 39 | } 40 | 41 | #[derive(Default)] 42 | pub struct TextFormatter { 43 | pub filter_empty: bool, 44 | } 45 | 46 | impl TextFormatter { 47 | fn format_metric( 48 | &self, 49 | item: &Metric, 50 | formatter: &Formatter, 51 | stdout: &mut W, 52 | ) -> std::io::Result<()> { 53 | stdout.write_str(TAB)?; 54 | stdout.write_element(PrettyTextMetric::new(formatter, item))?; 55 | stdout.write_str("\n")?; 56 | Ok(()) 57 | } 58 | 59 | fn format_commit(&self, item: &Commit, writer: &mut W) -> std::io::Result<()> { 60 | TextCommit::new(item).print(writer)?; 61 | writeln!(writer) 62 | } 63 | 64 | pub(crate) fn format( 65 | &self, 66 | list: Vec<(Commit, MetricStack)>, 67 | config: &Config, 68 | mut stdout: W, 69 | ) -> std::io::Result<()> { 70 | for (commit, metrics) in list { 71 | if metrics.is_empty() && self.filter_empty { 72 | continue; 73 | } 74 | 75 | self.format_commit(&commit, &mut stdout)?; 76 | for metric in metrics.into_metric_iter() { 77 | let formatter = config.formatter(metric.header.name.as_str()); 78 | self.format_metric(&metric, &formatter, &mut stdout)?; 79 | } 80 | } 81 | Ok(()) 82 | } 83 | } 84 | 85 | #[cfg(test)] 86 | mod tests {} 87 | -------------------------------------------------------------------------------- /src/cmd/log/mod.rs: -------------------------------------------------------------------------------- 1 | use super::prelude::PrettyWriter; 2 | use crate::backend::Backend; 3 | use crate::service::Service; 4 | use crate::ExitCode; 5 | 6 | mod format; 7 | 8 | /// Add a metric related to the target 9 | #[derive(clap::Parser, Debug, Default)] 10 | pub struct CommandLog { 11 | /// Remote name, default to origin 12 | #[clap(long, default_value = "origin")] 13 | remote: String, 14 | /// Commit range, default to HEAD 15 | /// 16 | /// Can use ranges like HEAD~2..HEAD 17 | #[clap(default_value = "HEAD")] 18 | target: String, 19 | 20 | /// If enabled, the empty commits will not be displayed 21 | #[clap(long)] 22 | filter_empty: bool, 23 | } 24 | 25 | impl super::Executor for CommandLog { 26 | fn execute( 27 | self, 28 | backend: B, 29 | stdout: Out, 30 | ) -> Result { 31 | let svc = Service::new(backend); 32 | let config = svc.open_config()?; 33 | let result = svc.log(&crate::service::log::Options { 34 | remote: self.remote.as_str(), 35 | target: self.target.as_str(), 36 | })?; 37 | format::TextFormatter { 38 | filter_empty: self.filter_empty, 39 | } 40 | .format(result, &config, stdout)?; 41 | Ok(ExitCode::Success) 42 | } 43 | } 44 | 45 | #[cfg(test)] 46 | mod tests { 47 | use clap::Parser; 48 | 49 | use super::CommandLog; 50 | 51 | #[test] 52 | fn should_parse_range() { 53 | let cmd = CommandLog::parse_from(["_", "HEAD~4..HEAD"]); 54 | assert_eq!(cmd.target, "HEAD~4..HEAD"); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/cmd/mod.rs: -------------------------------------------------------------------------------- 1 | use std::io::Write; 2 | 3 | use prelude::{BasicWriter, ColoredWriter, PrettyWriter}; 4 | 5 | use crate::backend::Backend; 6 | use crate::error::DetailedError; 7 | use crate::ExitCode; 8 | 9 | mod add; 10 | mod check; 11 | mod diff; 12 | #[cfg(feature = "exporter")] 13 | mod export; 14 | #[cfg(feature = "importer")] 15 | mod import; 16 | mod init; 17 | mod log; 18 | mod pull; 19 | mod push; 20 | mod remove; 21 | mod show; 22 | 23 | mod format; 24 | mod prelude; 25 | 26 | trait Executor { 27 | fn execute( 28 | self, 29 | backend: B, 30 | stdout: Out, 31 | ) -> Result; 32 | } 33 | 34 | #[derive(Debug, clap::Subcommand)] 35 | pub(crate) enum Command { 36 | Add(add::CommandAdd), 37 | Check(check::CommandCheck), 38 | Diff(diff::CommandDiff), 39 | Export(export::CommandExport), 40 | Init(init::CommandInit), 41 | #[cfg(feature = "importer")] 42 | Import(import::CommandImport), 43 | Log(log::CommandLog), 44 | Pull(pull::CommandPull), 45 | Push(push::CommandPush), 46 | Remove(remove::CommandRemove), 47 | Show(show::CommandShow), 48 | } 49 | 50 | impl Default for Command { 51 | fn default() -> Self { 52 | Self::Show(show::CommandShow::default()) 53 | } 54 | } 55 | 56 | impl Command { 57 | fn execute_with( 58 | self, 59 | repo: Repo, 60 | stdout: Out, 61 | ) -> Result { 62 | match self { 63 | Self::Add(inner) => inner.execute(repo, stdout), 64 | Self::Check(inner) => inner.execute(repo, stdout), 65 | Self::Diff(inner) => inner.execute(repo, stdout), 66 | Self::Export(inner) => inner.execute(repo, stdout), 67 | Self::Init(inner) => inner.execute(repo, stdout), 68 | #[cfg(feature = "importer")] 69 | Self::Import(inner) => inner.execute(repo, stdout), 70 | Self::Log(inner) => inner.execute(repo, stdout), 71 | Self::Pull(inner) => inner.execute(repo, stdout), 72 | Self::Push(inner) => inner.execute(repo, stdout), 73 | Self::Remove(inner) => inner.execute(repo, stdout), 74 | Self::Show(inner) => inner.execute(repo, stdout), 75 | } 76 | } 77 | 78 | pub(crate) fn execute( 79 | self, 80 | repo: Repo, 81 | color_enabled: bool, 82 | stdout: Out, 83 | stderr: Err, 84 | ) -> ExitCode { 85 | let result = if color_enabled { 86 | self.execute_with(repo, ColoredWriter::from(stdout)) 87 | } else { 88 | self.execute_with(repo, BasicWriter::from(stdout)) 89 | }; 90 | 91 | match result { 92 | Ok(res) => res, 93 | Err(error) => { 94 | error.write(stderr).expect("couldn't log error"); 95 | ExitCode::Failure 96 | } 97 | } 98 | } 99 | } 100 | 101 | #[derive(Debug, Default, clap::Parser)] 102 | pub(crate) struct GitCredentials { 103 | /// Username for git authentication 104 | #[clap(long, env = "GIT_USERNAME")] 105 | pub(crate) username: Option, 106 | /// Password for git authentication 107 | #[clap(long, env = "GIT_PASSWORD")] 108 | pub(crate) password: Option, 109 | } 110 | -------------------------------------------------------------------------------- /src/cmd/prelude.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | #[derive(Clone, Debug)] 4 | pub struct Tag { 5 | pub name: String, 6 | pub value: String, 7 | } 8 | 9 | impl FromStr for Tag { 10 | type Err = &'static str; 11 | 12 | fn from_str(s: &str) -> Result { 13 | s.split_once(':') 14 | .map(|(name, value)| Tag { 15 | name: name.trim().to_string(), 16 | value: value.trim().to_string(), 17 | }) 18 | .ok_or("unable to decode tag name and value") 19 | } 20 | } 21 | 22 | pub trait PrettyWriter: std::io::Write + Sized { 23 | fn set_style(&mut self, style: S) -> std::io::Result<()>; 24 | 25 | #[inline] 26 | fn write_str(&mut self, value: &str) -> std::io::Result { 27 | self.write(value.as_bytes()) 28 | } 29 | 30 | #[inline] 31 | fn write_element(&mut self, element: E) -> std::io::Result<()> { 32 | element.print(self) 33 | } 34 | } 35 | 36 | pub struct ColoredWriter(W); 37 | 38 | impl From for ColoredWriter { 39 | #[inline] 40 | fn from(value: W) -> Self { 41 | Self(value) 42 | } 43 | } 44 | 45 | impl std::io::Write for ColoredWriter { 46 | #[inline] 47 | fn write_vectored(&mut self, bufs: &[std::io::IoSlice<'_>]) -> std::io::Result { 48 | self.0.write_vectored(bufs) 49 | } 50 | #[inline] 51 | fn write_fmt(&mut self, fmt: std::fmt::Arguments<'_>) -> std::io::Result<()> { 52 | self.0.write_fmt(fmt) 53 | } 54 | #[inline] 55 | fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> { 56 | self.0.write_all(buf) 57 | } 58 | #[inline] 59 | fn write(&mut self, buf: &[u8]) -> std::io::Result { 60 | self.0.write(buf) 61 | } 62 | #[inline] 63 | fn flush(&mut self) -> std::io::Result<()> { 64 | self.0.flush() 65 | } 66 | } 67 | 68 | impl PrettyWriter for ColoredWriter { 69 | #[inline] 70 | fn set_style(&mut self, style: S) -> std::io::Result<()> { 71 | write!(self.0, "{style}")?; 72 | Ok(()) 73 | } 74 | } 75 | 76 | pub struct BasicWriter(W); 77 | 78 | impl From for BasicWriter { 79 | #[inline] 80 | fn from(value: W) -> Self { 81 | Self(value) 82 | } 83 | } 84 | 85 | #[cfg(test)] 86 | impl BasicWriter> { 87 | pub fn into_string(self) -> String { 88 | String::from_utf8(self.0).unwrap() 89 | } 90 | } 91 | 92 | impl std::io::Write for BasicWriter { 93 | #[inline] 94 | fn write_vectored(&mut self, bufs: &[std::io::IoSlice<'_>]) -> std::io::Result { 95 | self.0.write_vectored(bufs) 96 | } 97 | #[inline] 98 | fn write_fmt(&mut self, fmt: std::fmt::Arguments<'_>) -> std::io::Result<()> { 99 | self.0.write_fmt(fmt) 100 | } 101 | #[inline] 102 | fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> { 103 | self.0.write_all(buf) 104 | } 105 | #[inline] 106 | fn write(&mut self, buf: &[u8]) -> std::io::Result { 107 | self.0.write(buf) 108 | } 109 | #[inline] 110 | fn flush(&mut self) -> std::io::Result<()> { 111 | self.0.flush() 112 | } 113 | } 114 | 115 | impl PrettyWriter for BasicWriter { 116 | fn set_style(&mut self, _style: S) -> std::io::Result<()> { 117 | Ok(()) 118 | } 119 | } 120 | 121 | pub trait PrettyDisplay { 122 | fn print(&self, writer: &mut W) -> std::io::Result<()>; 123 | 124 | #[cfg(test)] 125 | fn to_basic_string(&self) -> std::io::Result { 126 | let mut writer = BasicWriter::from(Vec::::new()); 127 | self.print(&mut writer).unwrap(); 128 | String::from_utf8(writer.0) 129 | .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err)) 130 | } 131 | } 132 | 133 | impl PrettyDisplay for E { 134 | #[inline] 135 | fn print(&self, writer: &mut W) -> std::io::Result<()> { 136 | write!(writer, "{self}") 137 | } 138 | } 139 | -------------------------------------------------------------------------------- /src/cmd/pull.rs: -------------------------------------------------------------------------------- 1 | use super::prelude::PrettyWriter; 2 | use crate::backend::Backend; 3 | use crate::service::Service; 4 | use crate::ExitCode; 5 | 6 | /// Pulls the metrics 7 | #[derive(clap::Parser, Debug, Default)] 8 | pub struct CommandPull { 9 | /// Remote name, default to origin 10 | #[clap(default_value = "origin")] 11 | remote: String, 12 | } 13 | 14 | impl super::Executor for CommandPull { 15 | #[tracing::instrument(name = "pull", skip_all, fields(remote = self.remote.as_str()))] 16 | fn execute( 17 | self, 18 | backend: B, 19 | _stdout: Out, 20 | ) -> Result { 21 | Service::new(backend).pull(&crate::service::pull::Options { 22 | remote: self.remote.as_str(), 23 | })?; 24 | Ok(ExitCode::Success) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/cmd/push.rs: -------------------------------------------------------------------------------- 1 | use super::prelude::PrettyWriter; 2 | use crate::backend::Backend; 3 | use crate::service::Service; 4 | use crate::ExitCode; 5 | 6 | /// Pushes the metrics 7 | #[derive(clap::Parser, Debug, Default)] 8 | pub struct CommandPush { 9 | /// Remote name, default to origin 10 | #[clap(default_value = "origin")] 11 | remote: String, 12 | } 13 | 14 | impl super::Executor for CommandPush { 15 | #[tracing::instrument(name = "push", skip_all, fields(remote = self.remote.as_str()))] 16 | fn execute( 17 | self, 18 | backend: B, 19 | _stdout: Out, 20 | ) -> Result { 21 | Service::new(backend).push(&crate::service::push::Options { 22 | remote: self.remote.as_str(), 23 | })?; 24 | Ok(ExitCode::Success) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/cmd/remove.rs: -------------------------------------------------------------------------------- 1 | use super::prelude::PrettyWriter; 2 | use crate::backend::Backend; 3 | use crate::service::Service; 4 | use crate::ExitCode; 5 | 6 | /// Remove a metric related to the target 7 | #[derive(clap::Parser, Debug, Default)] 8 | pub struct CommandRemove { 9 | /// Remote name, default to origin 10 | #[clap(long, default_value = "origin")] 11 | remote: String, 12 | /// Commit target, default to HEAD 13 | #[clap(long, short, default_value = "HEAD")] 14 | target: String, 15 | /// Index of the metric to remove 16 | index: usize, 17 | } 18 | 19 | impl super::Executor for CommandRemove { 20 | #[tracing::instrument(name = "remove", skip_all, fields(target = self.target.as_str(), index = self.index))] 21 | fn execute( 22 | self, 23 | backend: B, 24 | _stdout: Out, 25 | ) -> Result { 26 | Service::new(backend).remove( 27 | self.index, 28 | &crate::service::remove::Options { 29 | remote: self.remote.as_str(), 30 | target: self.target.as_str(), 31 | }, 32 | )?; 33 | Ok(ExitCode::Success) 34 | } 35 | } 36 | 37 | #[cfg(test)] 38 | mod tests { 39 | use clap::Parser; 40 | 41 | use crate::backend::mock::MockBackend; 42 | 43 | #[test] 44 | fn should_remove_metric() { 45 | let mut stdout = Vec::new(); 46 | let mut stderr = Vec::new(); 47 | 48 | let backend = MockBackend::default(); 49 | 50 | let code = crate::Args::parse_from(["_", "remove", "0"]) 51 | .command 52 | .execute(backend, false, &mut stdout, &mut stderr); 53 | 54 | assert!(code.is_success()); 55 | assert!(stdout.is_empty()); 56 | assert!(stderr.is_empty()); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/cmd/show.rs: -------------------------------------------------------------------------------- 1 | use super::format::text::PrettyTextMetric; 2 | use super::prelude::PrettyWriter; 3 | use crate::backend::Backend; 4 | use crate::service::Service; 5 | use crate::ExitCode; 6 | 7 | /// Display the metrics related to the target 8 | #[derive(clap::Parser, Debug, Default)] 9 | pub struct CommandShow { 10 | /// Remote name, default to origin 11 | #[clap(long, default_value = "origin")] 12 | remote: String, 13 | /// Commit target, default to HEAD 14 | #[clap(long, short, default_value = "HEAD")] 15 | target: String, 16 | } 17 | 18 | impl super::Executor for CommandShow { 19 | #[tracing::instrument(name = "show", skip_all, fields(target = self.target.as_str()))] 20 | fn execute( 21 | self, 22 | backend: B, 23 | mut stdout: Out, 24 | ) -> Result { 25 | let svc = Service::new(backend); 26 | let config = svc.open_config()?; 27 | let metrics = svc.show(&crate::service::show::Options { 28 | remote: self.remote.as_str(), 29 | target: self.target.as_str(), 30 | })?; 31 | for metric in metrics.into_metric_iter() { 32 | let formatter = config.formatter(metric.header.name.as_str()); 33 | stdout.write_element(PrettyTextMetric::new(&formatter, &metric))?; 34 | stdout.write_str("\n")?; 35 | } 36 | Ok(ExitCode::Success) 37 | } 38 | } 39 | 40 | #[cfg(test)] 41 | mod tests { 42 | use clap::Parser; 43 | 44 | use crate::backend::NoteRef; 45 | 46 | #[test] 47 | fn should_read_head_and_return_nothing() { 48 | let mut stdout = Vec::new(); 49 | let mut stderr = Vec::new(); 50 | 51 | let repo = crate::backend::mock::MockBackend::default(); 52 | repo.set_note("HEAD", NoteRef::remote_metrics("origin"), String::new()); 53 | 54 | let code = crate::Args::parse_from(["_", "show"]).command.execute( 55 | repo, 56 | false, 57 | &mut stdout, 58 | &mut stderr, 59 | ); 60 | 61 | assert!(code.is_success()); 62 | assert!(stdout.is_empty()); 63 | assert!(stderr.is_empty()); 64 | } 65 | 66 | #[test] 67 | fn should_read_hash_and_return_a_list() { 68 | let mut stdout = Vec::new(); 69 | let mut stderr = Vec::new(); 70 | 71 | let sha = "aaaaaaa"; 72 | 73 | let repo = crate::backend::mock::MockBackend::default(); 74 | repo.set_note( 75 | sha, 76 | NoteRef::remote_metrics("origin"), 77 | String::from( 78 | r#"[[metrics]] 79 | name = "foo" 80 | value = 1.0 81 | "#, 82 | ), 83 | ); 84 | repo.set_note( 85 | sha, 86 | crate::backend::NoteRef::Changes, 87 | String::from( 88 | r#"[[changes]] 89 | action = "add" 90 | name = "foo" 91 | tags = { bar = "baz" } 92 | value = 1.0 93 | "#, 94 | ), 95 | ); 96 | 97 | let code = crate::Args::parse_from(["_", "show", "--target", sha]) 98 | .command 99 | .execute(repo, false, &mut stdout, &mut stderr); 100 | 101 | assert!(code.is_success(), "{:?}", String::from_utf8_lossy(&stderr)); 102 | assert!(!stdout.is_empty()); 103 | assert!(stderr.is_empty()); 104 | 105 | let stdout = String::from_utf8_lossy(&stdout); 106 | assert_eq!(stdout, "foo 1.00\nfoo{bar=\"baz\"} 1.00\n"); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/entity/config.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | use std::str::FromStr; 3 | 4 | use human_number::Formatter; 5 | use indexmap::IndexMap; 6 | 7 | use super::metric::MetricHeader; 8 | 9 | const NO_SCALE: human_number::Scales<'static> = human_number::Scales::new(&[], &[]); 10 | 11 | #[inline] 12 | fn undefined_unit_formatter() -> human_number::Formatter<'static> { 13 | human_number::Formatter::new(NO_SCALE, human_number::Options::default()) 14 | } 15 | 16 | #[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize, serde::Serialize)] 17 | pub(crate) struct RuleAbsolute { 18 | pub value: f64, 19 | } 20 | 21 | #[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize, serde::Serialize)] 22 | pub(crate) struct RuleRelative { 23 | pub ratio: f64, 24 | } 25 | 26 | #[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize, serde::Serialize)] 27 | #[serde(untagged)] 28 | pub(crate) enum RuleChange { 29 | Absolute(RuleAbsolute), 30 | Relative(RuleRelative), 31 | } 32 | 33 | #[derive(Clone, Copy, Debug, PartialEq, serde::Deserialize, serde::Serialize)] 34 | #[serde(tag = "type", rename_all = "kebab-case")] 35 | pub(crate) enum Rule { 36 | Max(RuleAbsolute), 37 | Min(RuleAbsolute), 38 | MaxIncrease(RuleChange), 39 | MaxDecrease(RuleChange), 40 | } 41 | 42 | #[cfg(test)] 43 | impl Rule { 44 | pub fn max(value: f64) -> Self { 45 | Self::Max(RuleAbsolute { value }) 46 | } 47 | 48 | pub fn max_absolute_increase(value: f64) -> Self { 49 | Self::MaxIncrease(RuleChange::Absolute(RuleAbsolute { value })) 50 | } 51 | 52 | pub fn max_relative_increase(ratio: f64) -> Self { 53 | Self::MaxIncrease(RuleChange::Relative(RuleRelative { ratio })) 54 | } 55 | 56 | pub fn min(value: f64) -> Self { 57 | Self::Min(RuleAbsolute { value }) 58 | } 59 | 60 | pub fn max_absolute_decrease(value: f64) -> Self { 61 | Self::MaxDecrease(RuleChange::Absolute(RuleAbsolute { value })) 62 | } 63 | 64 | pub fn max_relative_decrease(ratio: f64) -> Self { 65 | Self::MaxDecrease(RuleChange::Relative(RuleRelative { ratio })) 66 | } 67 | } 68 | 69 | #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] 70 | pub(crate) struct SubsetConfig { 71 | #[serde(default)] 72 | pub(crate) matching: IndexMap, 73 | #[serde(default)] 74 | pub rules: Vec, 75 | } 76 | 77 | impl SubsetConfig { 78 | pub fn matches(&self, header: &MetricHeader) -> bool { 79 | self.matching 80 | .iter() 81 | .all(|(key, value)| header.tags.get(key).map(|v| v == value).unwrap_or(false)) 82 | } 83 | } 84 | 85 | #[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)] 86 | #[serde(rename_all = "lowercase")] 87 | pub enum UnitScale { 88 | #[default] 89 | SI, 90 | Binary, 91 | } 92 | 93 | #[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)] 94 | pub(crate) struct Unit { 95 | #[serde(default)] 96 | pub scale: Option, 97 | #[serde(default)] 98 | pub suffix: Option, 99 | #[serde(default)] 100 | pub decimals: Option, 101 | } 102 | 103 | #[cfg(test)] 104 | impl Unit { 105 | pub fn new>(scale: UnitScale, suffix: Option) -> Self { 106 | Unit { 107 | scale: Some(scale), 108 | suffix: suffix.map(|v| v.into()), 109 | decimals: None, 110 | } 111 | } 112 | 113 | pub fn binary() -> Self { 114 | Unit::new(UnitScale::Binary, None::) 115 | } 116 | 117 | pub fn with_suffix>(mut self, value: S) -> Self { 118 | self.suffix = Some(value.into()); 119 | self 120 | } 121 | } 122 | 123 | impl Unit { 124 | pub fn formater(&self) -> human_number::Formatter<'_> { 125 | let mut formatter = match self.scale { 126 | Some(UnitScale::SI) => human_number::Formatter::si(), 127 | Some(UnitScale::Binary) => human_number::Formatter::binary(), 128 | None => undefined_unit_formatter(), 129 | }; 130 | if let Some(ref unit) = self.suffix { 131 | formatter.set_unit(unit.as_str()); 132 | } 133 | if let Some(decimals) = self.decimals { 134 | formatter.set_decimals(decimals); 135 | } 136 | formatter 137 | } 138 | } 139 | 140 | #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] 141 | #[cfg_attr(test, derive(Default))] 142 | pub(crate) struct MetricConfig { 143 | #[serde(default)] 144 | pub rules: Vec, 145 | #[serde(default)] 146 | pub subsets: IndexMap, 147 | #[serde(default)] 148 | pub unit: Unit, 149 | } 150 | 151 | #[cfg(test)] 152 | impl MetricConfig { 153 | pub fn with_unit(mut self, unit: Unit) -> Self { 154 | self.unit = unit; 155 | self 156 | } 157 | } 158 | 159 | #[derive(Clone, Debug, Default, serde::Deserialize, serde::Serialize)] 160 | pub(crate) struct Config { 161 | #[serde(default)] 162 | pub metrics: IndexMap, 163 | } 164 | 165 | #[cfg(test)] 166 | impl Config { 167 | pub fn with_metric>(mut self, name: N, value: MetricConfig) -> Self { 168 | self.metrics.insert(name.into(), value); 169 | self 170 | } 171 | } 172 | 173 | const fn sample() -> &'static str { 174 | r#"# # For every metric you want to monitor, you need to add an entry 175 | # [metrics.metric_name.unit] 176 | # # This scale can be "si" for International System of Units or "binary" (optional) 177 | # scale = "si" 178 | # # Any string that will be added as a suffix (optional) 179 | # suffix = "B" 180 | # # Number of decimals for every number (optional) 181 | # decimals = 3 182 | # 183 | # # Set of rules for the unit budget. 184 | # [[metrics.metric_name.rules]] 185 | # type = "max" 186 | # value = 12.34 187 | # 188 | # [[metrics.metric_name.rules]] 189 | # type = "min" 190 | # value = 1.234 191 | # 192 | # [[metrics.metric_name.rules]] 193 | # type = "max-decrease" 194 | # # the metric cannot decrease of more than 5% 195 | # ratio = 0.05 196 | # 197 | # [[metrics.metric_name.rules]] 198 | # type = "max-decrease" 199 | # # the metric cannot decrease of more than 1.234 200 | # value = 1.234 201 | # 202 | # [[metrics.metric_name.rules]] 203 | # type = "max-increase" 204 | # # the metric cannot increase of more than 5% 205 | # ratio = 0.05 206 | # 207 | # [[metrics.metric_name.rules]] 208 | # type = "max-increase" 209 | # # the metric cannot increase of more than 1.234 210 | # value = 1.234 211 | "# 212 | } 213 | 214 | impl Config { 215 | pub(crate) fn from_path(path: &Path) -> std::io::Result { 216 | let content = std::fs::read_to_string(path)?; 217 | 218 | Config::from_str(content.as_str()) 219 | } 220 | 221 | fn config_path(root: &Path) -> PathBuf { 222 | root.join(".git-metrics.toml") 223 | } 224 | 225 | pub(crate) fn from_root_path(root: &Path) -> std::io::Result { 226 | let config_path = Self::config_path(root); 227 | if config_path.is_file() { 228 | Config::from_path(&config_path) 229 | } else { 230 | Ok(Default::default()) 231 | } 232 | } 233 | 234 | pub(crate) fn formatter(&self, metric_name: &str) -> Formatter<'_> { 235 | if let Some(config) = self.metrics.get(metric_name) { 236 | config.unit.formater() 237 | } else { 238 | undefined_unit_formatter() 239 | } 240 | } 241 | 242 | pub(crate) fn write_sample(root: &Path) -> std::io::Result<()> { 243 | let config_path = Self::config_path(root); 244 | std::fs::write(&config_path, sample()) 245 | } 246 | } 247 | 248 | impl FromStr for Config { 249 | type Err = std::io::Error; 250 | 251 | fn from_str(source: &str) -> Result { 252 | use std::io::{Error, ErrorKind}; 253 | 254 | toml::de::from_str(source).map_err(|err| Error::new(ErrorKind::InvalidData, err)) 255 | } 256 | } 257 | 258 | #[cfg(test)] 259 | mod tests { 260 | use std::str::FromStr; 261 | 262 | #[test] 263 | fn should_parse_config() { 264 | let without_comment = super::sample() 265 | .split("\n") 266 | .filter_map(|line| line.strip_prefix("# ")) 267 | .collect::>() 268 | .join("\n"); 269 | super::Config::from_str(&without_comment).unwrap(); 270 | } 271 | 272 | fn should_deserialize(payload: &str, names: &[&str]) { 273 | let config = super::Config::from_str(payload).unwrap(); 274 | for name in names { 275 | assert!( 276 | config.metrics.contains_key(*name), 277 | "should contain key {name}" 278 | ); 279 | } 280 | } 281 | 282 | #[test] 283 | fn should_deserialize_with_simple_name() { 284 | should_deserialize( 285 | r#"[metrics.binary_size] 286 | rules = [{ type = "max-increase", ratio = 0.1 }] 287 | "#, 288 | &["binary_size"], 289 | ); 290 | } 291 | 292 | #[test] 293 | fn should_deserialize_with_name_containing_dot() { 294 | should_deserialize( 295 | r#"[metrics.binary_size] 296 | rules = [{ type = "max-increase", ratio = 0.1 }] 297 | 298 | [metrics."binary.size"] 299 | rules = [{ type = "max-increase", ratio = 0.1 }] 300 | "#, 301 | &["binary_size", "binary.size"], 302 | ); 303 | } 304 | 305 | #[test] 306 | fn should_deserialize_with_relative_and_absolute() { 307 | should_deserialize( 308 | r#"[metrics.binary_size] 309 | rules = [{ type = "max-increase", ratio = 0.1 }, { type = "max-increase", value = 1.0 }, { type = "max-decrease", ratio = 0.1 }, { type = "max-decrease", value = 1.0 }] 310 | "#, 311 | &["binary_size"], 312 | ); 313 | } 314 | } 315 | -------------------------------------------------------------------------------- /src/entity/difference.rs: -------------------------------------------------------------------------------- 1 | use super::metric::{MetricHeader, MetricStack}; 2 | 3 | #[derive(Debug, serde::Serialize)] 4 | #[cfg_attr(test, derive(PartialEq))] 5 | pub(crate) struct Delta { 6 | #[allow(dead_code)] 7 | pub(crate) absolute: f64, 8 | pub(crate) relative: Option, 9 | } 10 | 11 | impl Delta { 12 | pub fn new(previous: f64, current: f64) -> Self { 13 | let absolute = current - previous; 14 | let relative = if previous == 0.0 { 15 | None 16 | } else { 17 | Some(absolute / previous) 18 | }; 19 | 20 | Self { absolute, relative } 21 | } 22 | } 23 | 24 | #[derive(Debug, serde::Serialize)] 25 | #[cfg_attr(test, derive(PartialEq))] 26 | #[serde(tag = "type", rename_all = "kebab-case")] 27 | pub(crate) enum Comparison { 28 | Created { 29 | current: f64, 30 | }, 31 | Missing { 32 | previous: f64, 33 | }, 34 | Matching { 35 | #[allow(dead_code)] 36 | previous: f64, 37 | current: f64, 38 | delta: Delta, 39 | }, 40 | } 41 | 42 | #[cfg(test)] 43 | impl Comparison { 44 | #[inline] 45 | pub fn matching(previous: f64, current: f64) -> Self { 46 | Self::Matching { 47 | previous, 48 | current, 49 | delta: Delta::new(previous, current), 50 | } 51 | } 52 | } 53 | 54 | impl Comparison { 55 | pub fn has_current(&self) -> bool { 56 | matches!(self, Self::Created { .. } | Self::Matching { .. }) 57 | } 58 | 59 | pub fn created(current: f64) -> Self { 60 | Self::Created { current } 61 | } 62 | 63 | pub fn new(previous: f64, current: Option) -> Self { 64 | if let Some(current) = current { 65 | Self::Matching { 66 | previous, 67 | current, 68 | delta: Delta::new(previous, current), 69 | } 70 | } else { 71 | Self::Missing { previous } 72 | } 73 | } 74 | 75 | pub fn previous(&self) -> Option { 76 | match self { 77 | Self::Matching { previous, .. } | Self::Missing { previous } => Some(*previous), 78 | _ => None, 79 | } 80 | } 81 | 82 | pub fn current(&self) -> Option { 83 | match self { 84 | Self::Created { current } => Some(*current), 85 | Self::Matching { current, .. } => Some(*current), 86 | Self::Missing { .. } => None, 87 | } 88 | } 89 | 90 | pub fn delta(&self) -> Option<&Delta> { 91 | match self { 92 | Self::Matching { delta, .. } => Some(delta), 93 | _ => None, 94 | } 95 | } 96 | } 97 | 98 | #[derive(Debug, serde::Serialize)] 99 | #[cfg_attr(test, derive(PartialEq))] 100 | pub(crate) struct MetricDiff { 101 | pub header: MetricHeader, 102 | pub comparison: Comparison, 103 | } 104 | 105 | #[cfg(test)] 106 | impl MetricDiff { 107 | pub fn new(header: MetricHeader, comparison: Comparison) -> Self { 108 | Self { header, comparison } 109 | } 110 | } 111 | 112 | pub(crate) struct MetricDiffList(pub(crate) Vec); 113 | 114 | impl MetricDiffList { 115 | pub fn new(previous: MetricStack, mut current: MetricStack) -> Self { 116 | let mut result = Vec::new(); 117 | for (header, previous_value) in previous.into_inner().into_iter() { 118 | let current_value = current.remove_entry(&header).map(|(_, value)| value); 119 | result.push(MetricDiff { 120 | header, 121 | comparison: Comparison::new(previous_value, current_value), 122 | }); 123 | } 124 | for (header, value) in current.into_inner().into_iter() { 125 | result.push(MetricDiff { 126 | header, 127 | comparison: Comparison::created(value), 128 | }); 129 | } 130 | Self(result) 131 | } 132 | 133 | pub fn remove_missing(self) -> Self { 134 | Self( 135 | self.0 136 | .into_iter() 137 | .filter(|m| m.comparison.has_current()) 138 | .collect(), 139 | ) 140 | } 141 | 142 | pub fn inner(&self) -> &[MetricDiff] { 143 | &self.0 144 | } 145 | 146 | pub fn into_inner(self) -> Vec { 147 | self.0 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /src/entity/git.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, serde::Serialize)] 2 | #[cfg_attr(test, derive(Clone))] 3 | pub(crate) struct Commit { 4 | pub sha: String, 5 | pub summary: String, 6 | } 7 | 8 | impl Commit { 9 | pub fn short_sha(&self) -> &str { 10 | &self.sha[..7] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/entity/log.rs: -------------------------------------------------------------------------------- 1 | use super::git::Commit; 2 | use super::metric::{Metric, MetricStack}; 3 | 4 | #[derive(Debug, serde::Serialize)] 5 | pub struct LogEntry { 6 | pub commit: Commit, 7 | pub metrics: Vec, 8 | } 9 | 10 | impl From<(Commit, MetricStack)> for LogEntry { 11 | fn from((commit, metrics): (Commit, MetricStack)) -> Self { 12 | Self { 13 | commit, 14 | metrics: metrics.into_vec(), 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/entity/metric.rs: -------------------------------------------------------------------------------- 1 | use std::hash::{Hash, Hasher}; 2 | 3 | use indexmap::IndexMap; 4 | 5 | pub(crate) struct MetricStackIterator { 6 | inner: indexmap::map::IntoIter, 7 | } 8 | 9 | impl Iterator for MetricStackIterator { 10 | type Item = Metric; 11 | 12 | fn next(&mut self) -> Option { 13 | self.inner 14 | .next() 15 | .map(|(header, value)| Metric { header, value }) 16 | } 17 | } 18 | 19 | #[derive(Debug, Default, serde::Serialize)] 20 | pub(crate) struct MetricStack { 21 | #[serde(flatten)] 22 | inner: IndexMap, 23 | } 24 | 25 | impl MetricStack { 26 | #[inline] 27 | pub(crate) fn from_iter(iter: impl Iterator) -> Self { 28 | Self { 29 | inner: IndexMap::from_iter(iter.map(|Metric { header, value }| (header, value))), 30 | } 31 | } 32 | 33 | pub(crate) fn extend(&mut self, other: Self) { 34 | self.inner.extend(other.inner); 35 | } 36 | 37 | pub(crate) fn remove_entry(&mut self, header: &MetricHeader) -> Option<(MetricHeader, f64)> { 38 | self.inner.shift_remove_entry(header) 39 | } 40 | 41 | pub(crate) fn with_change(mut self, change: MetricChange) -> Self { 42 | match change { 43 | MetricChange::Add(Metric { header, value }) => { 44 | self.inner.insert(header, value); 45 | } 46 | MetricChange::Remove(Metric { header, value }) => match self.inner.get(&header) { 47 | Some(existing) if *existing == value => { 48 | self.inner.swap_remove(&header); 49 | } 50 | _ => {} 51 | }, 52 | }; 53 | self 54 | } 55 | 56 | pub(crate) fn with_changes(self, iter: impl Iterator) -> Self { 57 | iter.fold(self, |this, change| this.with_change(change)) 58 | } 59 | 60 | pub(crate) fn into_metric_iter(self) -> MetricStackIterator { 61 | MetricStackIterator { 62 | inner: self.inner.into_iter(), 63 | } 64 | } 65 | 66 | pub(crate) fn into_inner(self) -> IndexMap { 67 | self.inner 68 | } 69 | 70 | pub(crate) fn into_vec(self) -> Vec { 71 | self.into_metric_iter().collect() 72 | } 73 | 74 | pub(crate) fn at(&self, index: usize) -> Option<(&MetricHeader, f64)> { 75 | self.inner 76 | .get_index(index) 77 | .map(|(header, value)| (header, *value)) 78 | } 79 | 80 | pub(crate) fn is_empty(&self) -> bool { 81 | self.inner.is_empty() 82 | } 83 | } 84 | 85 | #[derive(Debug, serde::Deserialize, serde::Serialize)] 86 | #[serde(tag = "action", rename_all = "lowercase")] 87 | pub(crate) enum MetricChange { 88 | Add(Metric), 89 | Remove(Metric), 90 | } 91 | 92 | #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize, serde::Deserialize)] 93 | pub(crate) struct MetricHeader { 94 | pub name: String, 95 | #[serde(default)] 96 | pub tags: IndexMap, 97 | } 98 | 99 | #[cfg(test)] 100 | impl MetricHeader { 101 | pub fn new>(name: N) -> Self { 102 | Self { 103 | name: name.into(), 104 | tags: Default::default(), 105 | } 106 | } 107 | 108 | pub fn with_tag, V: Into>(mut self, name: N, value: V) -> Self { 109 | self.tags.insert(name.into(), value.into()); 110 | self 111 | } 112 | } 113 | 114 | impl Hash for MetricHeader { 115 | fn hash(&self, state: &mut H) { 116 | self.name.hash(state); 117 | for (key, value) in self.tags.iter() { 118 | key.hash(state); 119 | value.hash(state); 120 | } 121 | } 122 | } 123 | 124 | #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)] 125 | #[cfg_attr(test, derive(Clone))] 126 | pub struct Metric { 127 | #[serde(flatten)] 128 | pub header: MetricHeader, 129 | pub value: f64, 130 | } 131 | 132 | impl Metric { 133 | #[cfg(any(test, feature = "importer-lcov"))] 134 | pub(crate) fn new(name: N, value: f64) -> Self 135 | where 136 | N: Into, 137 | { 138 | Self { 139 | header: MetricHeader { 140 | name: name.into(), 141 | tags: Default::default(), 142 | }, 143 | value, 144 | } 145 | } 146 | 147 | #[cfg(test)] 148 | pub(crate) fn with_tag, V: Into>(mut self, key: K, value: V) -> Self { 149 | self.header.tags.insert(key.into(), value.into()); 150 | self 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/entity/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod check; 2 | pub(crate) mod config; 3 | pub(crate) mod difference; 4 | pub(crate) mod git; 5 | pub(crate) mod log; 6 | pub(crate) mod metric; 7 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | pub(crate) trait DetailedError: std::error::Error { 2 | fn details(&self) -> Option; 3 | 4 | fn write(&self, mut w: W) -> std::io::Result<()> { 5 | writeln!(w, "{self}")?; 6 | if let Some(details) = self.details() { 7 | write!(w, "\n\n")?; 8 | for line in details.split('\n').filter(|line| !line.is_empty()) { 9 | writeln!(w, "\t{line}")?; 10 | } 11 | } 12 | Ok(()) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/exporter/json.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | pub(crate) fn to_file(path: &Path, payload: &super::Payload) -> Result<(), super::Error> { 4 | let mut file = super::with_file(path)?; 5 | to_writer(&mut file, payload)?; 6 | Ok(()) 7 | } 8 | 9 | pub(crate) fn to_writer( 10 | output: W, 11 | payload: &super::Payload, 12 | ) -> Result<(), super::Error> { 13 | serde_json::to_writer(output, payload)?; 14 | Ok(()) 15 | } 16 | -------------------------------------------------------------------------------- /src/exporter/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | use crate::entity::check::CheckList; 4 | use crate::entity::log::LogEntry; 5 | 6 | #[cfg(feature = "exporter-json")] 7 | pub(crate) mod json; 8 | #[cfg(feature = "exporter-markdown")] 9 | pub(crate) mod markdown; 10 | 11 | #[derive(Debug, thiserror::Error)] 12 | pub(crate) enum Error { 13 | #[cfg(feature = "exporter-json")] 14 | #[error("unable to write to json file")] 15 | Json( 16 | #[from] 17 | #[source] 18 | serde_json::Error, 19 | ), 20 | #[cfg(feature = "exporter")] 21 | #[error("unable to open file")] 22 | Io( 23 | #[from] 24 | #[source] 25 | std::io::Error, 26 | ), 27 | } 28 | 29 | #[derive(Debug, serde::Serialize)] 30 | pub(crate) struct Payload { 31 | target: String, 32 | checks: CheckList, 33 | logs: Vec, 34 | } 35 | 36 | impl Payload { 37 | pub(crate) fn new(target: String, checks: CheckList, logs: Vec) -> Self { 38 | Self { 39 | target, 40 | checks, 41 | logs, 42 | } 43 | } 44 | } 45 | 46 | fn with_file(path: &Path) -> std::io::Result { 47 | std::fs::OpenOptions::new() 48 | .write(true) 49 | .create(true) 50 | .truncate(true) 51 | .open(path) 52 | } 53 | -------------------------------------------------------------------------------- /src/formatter/difference.rs: -------------------------------------------------------------------------------- 1 | use human_number::Formatter; 2 | 3 | use super::percent::TextPercent; 4 | use crate::entity::difference::{Comparison, Delta}; 5 | 6 | pub(crate) struct TextDelta<'a> { 7 | formatter: &'a Formatter<'a>, 8 | value: &'a Delta, 9 | } 10 | 11 | impl<'a> TextDelta<'a> { 12 | pub fn new(formatter: &'a Formatter<'a>, value: &'a Delta) -> Self { 13 | Self { formatter, value } 14 | } 15 | } 16 | 17 | impl std::fmt::Display for TextDelta<'_> { 18 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 19 | match self.value.relative { 20 | Some(relative) => write!( 21 | f, 22 | "{} ({})", 23 | self.formatter.format(self.value.absolute), 24 | TextPercent::new(relative).with_sign(true) 25 | ), 26 | None => self.formatter.format(self.value.absolute).fmt(f), 27 | } 28 | } 29 | } 30 | 31 | pub(crate) struct ShortTextComparison<'a> { 32 | formatter: &'a Formatter<'a>, 33 | value: &'a Comparison, 34 | } 35 | 36 | impl<'a> ShortTextComparison<'a> { 37 | #[inline] 38 | pub const fn new(formatter: &'a Formatter<'a>, value: &'a Comparison) -> Self { 39 | Self { formatter, value } 40 | } 41 | } 42 | 43 | impl std::fmt::Display for ShortTextComparison<'_> { 44 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 45 | let diff_formatter = self.formatter.clone().with_force_sign(true); 46 | match self.value { 47 | Comparison::Created { current } => { 48 | write!(f, "{} (new)", self.formatter.format(*current)) 49 | } 50 | Comparison::Missing { previous } => { 51 | write!(f, "{} (old)", self.formatter.format(*previous)) 52 | } 53 | Comparison::Matching { 54 | previous, 55 | current, 56 | delta: 57 | Delta { 58 | absolute, 59 | relative: _, 60 | }, 61 | } if *absolute == 0.0 => { 62 | write!( 63 | f, 64 | "{} => {}", 65 | self.formatter.format(*previous), 66 | self.formatter.format(*current) 67 | ) 68 | } 69 | Comparison::Matching { 70 | previous, 71 | current, 72 | delta, 73 | } => { 74 | write!( 75 | f, 76 | "{} => {} Δ {}", 77 | self.formatter.format(*previous), 78 | self.formatter.format(*current), 79 | TextDelta::new(&diff_formatter, delta), 80 | ) 81 | } 82 | } 83 | } 84 | } 85 | 86 | pub(crate) struct LongTextComparison<'a> { 87 | formatter: &'a Formatter<'a>, 88 | value: &'a Comparison, 89 | } 90 | 91 | impl<'a> LongTextComparison<'a> { 92 | #[inline] 93 | pub const fn new(formatter: &'a Formatter<'a>, value: &'a Comparison) -> Self { 94 | Self { formatter, value } 95 | } 96 | } 97 | 98 | impl std::fmt::Display for LongTextComparison<'_> { 99 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 100 | let diff_formatter = self.formatter.clone().with_force_sign(true); 101 | match self.value { 102 | Comparison::Created { current } => { 103 | write!( 104 | f, 105 | "This metric didn't exist before and was created with the value {}.", 106 | self.formatter.format(*current) 107 | ) 108 | } 109 | Comparison::Missing { previous } => { 110 | write!( 111 | f, 112 | "This metric doesn't exist for the current target. The previous value was {}.", 113 | self.formatter.format(*previous) 114 | ) 115 | } 116 | Comparison::Matching { 117 | previous: _, 118 | current, 119 | delta: 120 | Delta { 121 | absolute, 122 | relative: _, 123 | }, 124 | } if *absolute == 0.0 => { 125 | write!( 126 | f, 127 | "This metric didn't change and kept the value of {}.", 128 | self.formatter.format(*current) 129 | ) 130 | } 131 | Comparison::Matching { 132 | previous, 133 | current, 134 | delta, 135 | } => { 136 | write!( 137 | f, 138 | "This metric changed from {} to {}, with a difference of {}.", 139 | self.formatter.format(*previous), 140 | self.formatter.format(*current), 141 | TextDelta::new(&diff_formatter, delta), 142 | ) 143 | } 144 | } 145 | } 146 | } 147 | 148 | #[cfg(test)] 149 | mod tests { 150 | use human_number::Formatter; 151 | 152 | use super::TextDelta; 153 | use crate::entity::difference::Delta; 154 | 155 | #[test_case::test_case(10.0, 20.0, "+10.00 B (+100.00 %)"; "with increase")] 156 | #[test_case::test_case(20.0, 10.0, "-10.00 B (-50.00 %)"; "with decrease")] 157 | #[test_case::test_case(10.0, 10.0, "+0.00 B (+0.00 %)"; "stable")] 158 | #[test_case::test_case(100_000_000.0, 100_000_001.0, "+1.00 B (+0.00 %)"; "tiny increase")] 159 | #[test_case::test_case(0.0, 10.0, "+10.00 B"; "increase from 0")] 160 | #[test_case::test_case(0.0, -10.0, "-10.00 B"; "decrease from 0")] 161 | fn format_delta(previous: f64, current: f64, expected: &str) { 162 | let fmt = Formatter::binary().with_unit("B").with_force_sign(true); 163 | let delta = Delta::new(previous, current); 164 | assert_eq!(expected, TextDelta::new(&fmt, &delta).to_string()); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/formatter/metric.rs: -------------------------------------------------------------------------------- 1 | use indexmap::IndexMap; 2 | 3 | use crate::entity::metric::MetricHeader; 4 | 5 | pub struct TextMetricTags<'a> { 6 | value: &'a IndexMap, 7 | } 8 | 9 | impl<'a> TextMetricTags<'a> { 10 | #[inline] 11 | pub const fn new(value: &'a IndexMap) -> Self { 12 | Self { value } 13 | } 14 | } 15 | 16 | impl std::fmt::Display for TextMetricTags<'_> { 17 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 18 | if !self.value.is_empty() { 19 | f.write_str("{")?; 20 | for (index, (key, value)) in self.value.iter().enumerate() { 21 | if index > 0 { 22 | f.write_str(", ")?; 23 | } 24 | write!(f, "{key}={value:?}")?; 25 | } 26 | f.write_str("}")?; 27 | } 28 | Ok(()) 29 | } 30 | } 31 | 32 | pub struct TextMetricHeader<'a> { 33 | value: &'a MetricHeader, 34 | } 35 | 36 | impl<'a> TextMetricHeader<'a> { 37 | #[inline] 38 | pub const fn new(value: &'a MetricHeader) -> Self { 39 | Self { value } 40 | } 41 | } 42 | 43 | impl std::fmt::Display for TextMetricHeader<'_> { 44 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 45 | self.value.name.fmt(f)?; 46 | TextMetricTags::new(&self.value.tags).fmt(f) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/formatter/mod.rs: -------------------------------------------------------------------------------- 1 | pub(crate) mod difference; 2 | pub(crate) mod metric; 3 | pub(crate) mod percent; 4 | pub(crate) mod rule; 5 | -------------------------------------------------------------------------------- /src/formatter/percent.rs: -------------------------------------------------------------------------------- 1 | pub(crate) struct TextPercent { 2 | value: f64, 3 | sign: bool, 4 | } 5 | 6 | impl TextPercent { 7 | #[inline] 8 | pub(crate) const fn new(value: f64) -> Self { 9 | Self { value, sign: false } 10 | } 11 | 12 | #[inline] 13 | pub(crate) const fn with_sign(mut self, sign: bool) -> Self { 14 | self.sign = sign; 15 | self 16 | } 17 | } 18 | 19 | impl std::fmt::Display for TextPercent { 20 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 21 | if self.sign { 22 | write!(f, "{:+.2} %", self.value * 100.0) 23 | } else { 24 | write!(f, "{:.2} %", self.value * 100.0) 25 | } 26 | } 27 | } 28 | 29 | #[cfg(test)] 30 | mod tests { 31 | use super::*; 32 | 33 | #[test_case::test_case(0.1234, false, "12.34 %"; "positive without sign")] 34 | #[test_case::test_case(0.1234, true, "+12.34 %"; "positive with sign")] 35 | #[test_case::test_case(-0.1234, false, "-12.34 %"; "negative without forcing sign")] 36 | #[test_case::test_case(-0.1234, true, "-12.34 %"; "negative with sign")] 37 | fn format(value: f64, sign: bool, expected: &str) { 38 | assert_eq!( 39 | expected, 40 | TextPercent::new(value).with_sign(sign).to_string() 41 | ); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/formatter/rule.rs: -------------------------------------------------------------------------------- 1 | use human_number::Formatter; 2 | 3 | use crate::entity::config::{Rule, RuleAbsolute, RuleChange, RuleRelative}; 4 | use crate::formatter::percent::TextPercent; 5 | 6 | pub(crate) struct TextRule<'a> { 7 | formatter: &'a Formatter<'a>, 8 | value: &'a Rule, 9 | } 10 | 11 | impl<'a> TextRule<'a> { 12 | #[inline] 13 | pub const fn new(formatter: &'a Formatter<'a>, value: &'a Rule) -> Self { 14 | Self { formatter, value } 15 | } 16 | } 17 | 18 | impl std::fmt::Display for TextRule<'_> { 19 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 20 | match self.value { 21 | Rule::Max(RuleAbsolute { value }) => { 22 | write!(f, "should be lower than {}", self.formatter.format(*value)) 23 | } 24 | Rule::Min(RuleAbsolute { value }) => write!( 25 | f, 26 | "should be greater than {}", 27 | self.formatter.format(*value) 28 | ), 29 | Rule::MaxIncrease(RuleChange::Relative(RuleRelative { ratio })) => { 30 | write!( 31 | f, 32 | "increase should be less than {}", 33 | TextPercent::new(*ratio) 34 | ) 35 | } 36 | Rule::MaxIncrease(RuleChange::Absolute(RuleAbsolute { value })) => { 37 | write!( 38 | f, 39 | "increase should be less than {}", 40 | self.formatter.format(*value) 41 | ) 42 | } 43 | Rule::MaxDecrease(RuleChange::Relative(RuleRelative { ratio })) => { 44 | write!( 45 | f, 46 | "decrease should be less than {}", 47 | TextPercent::new(*ratio) 48 | ) 49 | } 50 | Rule::MaxDecrease(RuleChange::Absolute(RuleAbsolute { value })) => { 51 | write!( 52 | f, 53 | "decrease should be less than {}", 54 | self.formatter.format(*value) 55 | ) 56 | } 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/importer/lcov.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use lcov::{Reader, Record}; 4 | 5 | use crate::entity::metric::Metric; 6 | 7 | #[derive(Debug)] 8 | struct LcovFile { 9 | options: LcovImporterOptions, 10 | branches_count: usize, 11 | branches_hit: usize, 12 | functions_count: usize, 13 | functions_hit: usize, 14 | lines_count: usize, 15 | lines_hit: usize, 16 | } 17 | 18 | impl LcovFile { 19 | fn handle(&mut self, record: lcov::Record) { 20 | match record { 21 | Record::BranchesFound { found } => { 22 | self.branches_count += found as usize; 23 | } 24 | Record::BranchesHit { hit } => { 25 | self.branches_hit += hit as usize; 26 | } 27 | Record::FunctionsFound { found } => { 28 | self.functions_count += found as usize; 29 | } 30 | Record::FunctionsHit { hit } => { 31 | self.functions_hit += hit as usize; 32 | } 33 | Record::LinesFound { found } => { 34 | self.lines_count += found as usize; 35 | } 36 | Record::LinesHit { hit } => { 37 | self.lines_hit += hit as usize; 38 | } 39 | _ => {} 40 | } 41 | } 42 | 43 | fn new(path: PathBuf, options: LcovImporterOptions) -> Result { 44 | let mut res = Self { 45 | options, 46 | branches_count: 0, 47 | branches_hit: 0, 48 | functions_count: 0, 49 | functions_hit: 0, 50 | lines_count: 0, 51 | lines_hit: 0, 52 | }; 53 | for item in Reader::open_file(path)? { 54 | match item { 55 | Ok(record) => res.handle(record), 56 | Err(error) => { 57 | return Err(super::Error::InvalidFormat { 58 | source: Box::new(error), 59 | }) 60 | } 61 | } 62 | } 63 | Ok(res) 64 | } 65 | 66 | fn build(self) -> Vec { 67 | let mut res = Vec::with_capacity(self.options.expected_count()); 68 | // branches 69 | if self.options.branches { 70 | let branches_count = self.branches_count as f64; 71 | res.push(Metric::new("coverage.branches.count", branches_count)); 72 | let branches_hit = self.branches_hit as f64; 73 | res.push(Metric::new("coverage.branches.hit", branches_hit)); 74 | if self.branches_count > 0 { 75 | let branches_percentage = branches_hit / branches_count; 76 | res.push(Metric::new( 77 | "coverage.branches.percentage", 78 | branches_percentage, 79 | )); 80 | } 81 | } else { 82 | tracing::debug!("skipping collecting branches"); 83 | } 84 | // functions 85 | if self.options.functions { 86 | let functions_count = self.functions_count as f64; 87 | res.push(Metric::new("coverage.functions.count", functions_count)); 88 | let functions_hit = self.functions_hit as f64; 89 | res.push(Metric::new("coverage.functions.hit", functions_hit)); 90 | if self.functions_count > 0 { 91 | let functions_percentage = functions_hit / functions_count; 92 | res.push(Metric::new( 93 | "coverage.functions.percentage", 94 | functions_percentage, 95 | )); 96 | } 97 | } else { 98 | tracing::debug!("skipping collecting functions"); 99 | } 100 | // lines 101 | if self.options.lines { 102 | let lines_count = self.lines_count as f64; 103 | res.push(Metric::new("coverage.lines.count", lines_count)); 104 | let lines_hit = self.lines_hit as f64; 105 | res.push(Metric::new("coverage.lines.hit", lines_hit)); 106 | if self.lines_count > 0 { 107 | let lines_percentage = lines_hit / lines_count; 108 | res.push(Metric::new("coverage.lines.percentage", lines_percentage)); 109 | } 110 | } else { 111 | tracing::debug!("skipping collecting lines"); 112 | } 113 | // 114 | res 115 | } 116 | } 117 | 118 | #[derive(Debug)] 119 | pub(crate) struct LcovImporterOptions { 120 | pub branches: bool, 121 | pub functions: bool, 122 | pub lines: bool, 123 | } 124 | 125 | impl LcovImporterOptions { 126 | fn expected_count(&self) -> usize { 127 | let mut count = 0; 128 | if self.branches { 129 | count += 3; 130 | } 131 | if self.functions { 132 | count += 3; 133 | } 134 | if self.lines { 135 | count += 3; 136 | } 137 | count 138 | } 139 | } 140 | 141 | #[derive(Debug)] 142 | pub(crate) struct LcovImporter { 143 | pub path: PathBuf, 144 | pub options: LcovImporterOptions, 145 | } 146 | 147 | impl LcovImporter { 148 | #[inline(always)] 149 | pub(crate) fn new(path: PathBuf, options: LcovImporterOptions) -> Self { 150 | Self { path, options } 151 | } 152 | } 153 | 154 | impl super::Importer for LcovImporter { 155 | fn import(self) -> Result, super::Error> { 156 | LcovFile::new(self.path, self.options).map(|file| file.build()) 157 | } 158 | } 159 | 160 | #[cfg(test)] 161 | mod tests { 162 | use std::path::PathBuf; 163 | 164 | use crate::importer::lcov::{LcovImporter, LcovImporterOptions}; 165 | use crate::importer::Importer; 166 | 167 | #[test] 168 | fn should_load_metrics_complete() { 169 | let file = PathBuf::from(env!("CARGO_MANIFEST_DIR")) 170 | .join("asset") 171 | .join("lcov.info"); 172 | let importer = LcovImporter::new( 173 | file, 174 | LcovImporterOptions { 175 | branches: true, 176 | functions: true, 177 | lines: true, 178 | }, 179 | ); 180 | let metrics = importer.import().unwrap(); 181 | assert_eq!(metrics.len(), 8); 182 | assert_eq!(metrics[0].header.name, "coverage.branches.count"); 183 | assert_eq!(metrics[0].value, 0.0); 184 | assert_eq!(metrics[1].header.name, "coverage.branches.hit"); 185 | assert_eq!(metrics[1].value, 0.0); 186 | assert_eq!(metrics[2].header.name, "coverage.functions.count"); 187 | assert_eq!(metrics[2].value, 338.0); 188 | assert_eq!(metrics[3].header.name, "coverage.functions.hit"); 189 | assert_eq!(metrics[3].value, 255.0); 190 | assert_eq!(metrics[4].header.name, "coverage.functions.percentage"); 191 | assert_eq!(metrics[4].value, 255.0 / 338.0); 192 | assert_eq!(metrics[5].header.name, "coverage.lines.count"); 193 | assert_eq!(metrics[5].value, 2721.0); 194 | assert_eq!(metrics[6].header.name, "coverage.lines.hit"); 195 | assert_eq!(metrics[6].value, 2298.0); 196 | assert_eq!(metrics[7].header.name, "coverage.lines.percentage"); 197 | assert_eq!(metrics[7].value, 2298.0 / 2721.0); 198 | } 199 | 200 | #[test] 201 | fn should_load_metrics_without_branches() { 202 | let file = PathBuf::from(env!("CARGO_MANIFEST_DIR")) 203 | .join("asset") 204 | .join("lcov.info"); 205 | let importer = LcovImporter::new( 206 | file, 207 | LcovImporterOptions { 208 | branches: false, 209 | functions: true, 210 | lines: true, 211 | }, 212 | ); 213 | let metrics = importer.import().unwrap(); 214 | assert_eq!(metrics.len(), 6); 215 | assert_eq!(metrics[0].header.name, "coverage.functions.count"); 216 | assert_eq!(metrics[1].header.name, "coverage.functions.hit"); 217 | assert_eq!(metrics[2].header.name, "coverage.functions.percentage"); 218 | assert_eq!(metrics[3].header.name, "coverage.lines.count"); 219 | assert_eq!(metrics[4].header.name, "coverage.lines.hit"); 220 | assert_eq!(metrics[5].header.name, "coverage.lines.percentage"); 221 | } 222 | 223 | #[test] 224 | fn should_load_metrics_without_functions() { 225 | let file = PathBuf::from(env!("CARGO_MANIFEST_DIR")) 226 | .join("asset") 227 | .join("lcov.info"); 228 | let importer = LcovImporter::new( 229 | file, 230 | LcovImporterOptions { 231 | branches: true, 232 | functions: false, 233 | lines: true, 234 | }, 235 | ); 236 | let metrics = importer.import().unwrap(); 237 | assert_eq!(metrics.len(), 5); 238 | assert_eq!(metrics[0].header.name, "coverage.branches.count"); 239 | assert_eq!(metrics[1].header.name, "coverage.branches.hit"); 240 | assert_eq!(metrics[2].header.name, "coverage.lines.count"); 241 | assert_eq!(metrics[3].header.name, "coverage.lines.hit"); 242 | assert_eq!(metrics[4].header.name, "coverage.lines.percentage"); 243 | } 244 | 245 | #[test] 246 | fn should_load_metrics_without_lines() { 247 | let file = PathBuf::from(env!("CARGO_MANIFEST_DIR")) 248 | .join("asset") 249 | .join("lcov.info"); 250 | let importer = LcovImporter::new( 251 | file, 252 | LcovImporterOptions { 253 | branches: true, 254 | functions: true, 255 | lines: false, 256 | }, 257 | ); 258 | let metrics = importer.import().unwrap(); 259 | assert_eq!(metrics.len(), 5); 260 | assert_eq!(metrics[0].header.name, "coverage.branches.count"); 261 | assert_eq!(metrics[1].header.name, "coverage.branches.hit"); 262 | assert_eq!(metrics[2].header.name, "coverage.functions.count"); 263 | assert_eq!(metrics[3].header.name, "coverage.functions.hit"); 264 | assert_eq!(metrics[4].header.name, "coverage.functions.percentage"); 265 | } 266 | 267 | #[test] 268 | #[should_panic = "Io { source: Os { code: 2, kind: NotFound, message: \"No such file or directory\" } }"] 269 | fn should_fail_opening() { 270 | let file = PathBuf::default().join("nowhere").join("lcov.info"); 271 | let importer = LcovImporter::new( 272 | file, 273 | LcovImporterOptions { 274 | branches: true, 275 | functions: true, 276 | lines: true, 277 | }, 278 | ); 279 | let _ = importer.import().unwrap(); 280 | } 281 | } 282 | -------------------------------------------------------------------------------- /src/importer/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::entity::metric::Metric; 2 | 3 | #[cfg(feature = "importer-lcov")] 4 | pub(crate) mod lcov; 5 | 6 | #[derive(Debug, thiserror::Error)] 7 | pub enum Error { 8 | #[error(transparent)] 9 | Io { 10 | #[from] 11 | source: std::io::Error, 12 | }, 13 | #[allow(dead_code)] 14 | #[error("invalid source file format")] 15 | InvalidFormat { 16 | #[source] 17 | source: Box, 18 | }, 19 | } 20 | 21 | pub trait Importer { 22 | fn import(self) -> Result, Error>; 23 | } 24 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | #[cfg(test)] 2 | pub(crate) mod tests; 3 | 4 | mod backend; 5 | mod cmd; 6 | mod entity; 7 | mod error; 8 | #[cfg(feature = "exporter")] 9 | mod exporter; 10 | mod formatter; 11 | #[cfg(feature = "importer")] 12 | mod importer; 13 | mod service; 14 | 15 | use std::path::PathBuf; 16 | 17 | use clap::Parser; 18 | 19 | enum ExitCode { 20 | Success, 21 | Failure, 22 | } 23 | 24 | impl ExitCode { 25 | #[cfg(test)] 26 | fn is_success(&self) -> bool { 27 | matches!(self, Self::Success) 28 | } 29 | 30 | fn exit(self) { 31 | std::process::exit(match self { 32 | Self::Success => 0, 33 | Self::Failure => 1, 34 | }) 35 | } 36 | } 37 | 38 | #[cfg(not(any(feature = "impl-command", feature = "impl-git2")))] 39 | compile_error!("you need to pick at least one implementation"); 40 | 41 | #[derive(Clone, Copy, Debug, clap::ValueEnum)] 42 | enum Backend { 43 | #[cfg(feature = "impl-command")] 44 | Command, 45 | #[cfg(feature = "impl-git2")] 46 | Git2, 47 | } 48 | 49 | /// Git extension in order to attach metrics to commits 50 | #[derive(Parser, Debug)] 51 | #[command(version, about, long_about = None)] 52 | struct Args { 53 | /// Wether it's running on a CI 54 | /// 55 | /// Enabling this will disable the colors 56 | #[clap(long, env = "CI")] 57 | ci: bool, 58 | 59 | /// Disable the colors in the output text 60 | /// 61 | /// The color will only be enabled if we detect that your environment is compatible. 62 | /// If NO_COLOR is set or TERM=dumb, it will be disabled by default. 63 | #[clap(global = true, long, env = "DISABLE_COLOR")] 64 | disable_color: bool, 65 | 66 | /// Root directory of the git repository 67 | #[clap(long)] 68 | root_dir: Option, 69 | 70 | #[clap(flatten)] 71 | auth: cmd::GitCredentials, 72 | 73 | /// Select the backend to use to interact with git. 74 | /// 75 | /// If running on the CI, you should use command to avoid authentication failures. 76 | #[cfg_attr( 77 | not(feature = "impl-git2"), 78 | clap( 79 | short, 80 | long, 81 | default_value = "command", 82 | value_enum, 83 | env = "GIT_BACKEND" 84 | ) 85 | )] 86 | #[cfg_attr( 87 | feature = "impl-git2", 88 | clap(short, long, default_value = "git2", value_enum, env = "GIT_BACKEND") 89 | )] 90 | backend: Backend, 91 | 92 | /// Enables verbosity 93 | #[clap(short, long, action = clap::ArgAction::Count, env = "VERBOSITY")] 94 | verbose: u8, 95 | 96 | #[command(subcommand)] 97 | command: cmd::Command, 98 | } 99 | 100 | // This is a duplicate from `termcolor` 101 | fn can_color() -> bool { 102 | match std::env::var_os("TERM") { 103 | // If TERM isn't set, then we are in a weird environment that 104 | // probably doesn't support colors. 105 | None => return false, 106 | Some(k) => { 107 | if k == "dumb" { 108 | return false; 109 | } 110 | } 111 | } 112 | // If TERM != dumb, then the only way we don't allow colors at this 113 | // point is if NO_COLOR is set. 114 | if std::env::var_os("NO_COLOR").is_some() { 115 | return false; 116 | } 117 | true 118 | } 119 | 120 | impl Args { 121 | fn color_enabled(&self) -> bool { 122 | !self.ci && !self.disable_color && can_color() 123 | } 124 | 125 | fn log_level(&self) -> Option { 126 | match self.verbose { 127 | 0 => None, 128 | 1 => Some(tracing::Level::ERROR), 129 | 2 => Some(tracing::Level::WARN), 130 | 3 => Some(tracing::Level::INFO), 131 | 4 => Some(tracing::Level::DEBUG), 132 | _ => Some(tracing::Level::TRACE), 133 | } 134 | } 135 | 136 | fn execute( 137 | self, 138 | stdout: &mut Out, 139 | stderr: &mut Err, 140 | ) -> ExitCode { 141 | let color = self.color_enabled(); 142 | match self.backend { 143 | #[cfg(feature = "impl-command")] 144 | Backend::Command => self.command.execute( 145 | crate::backend::CommandBackend::new(self.root_dir), 146 | color, 147 | stdout, 148 | stderr, 149 | ), 150 | #[cfg(feature = "impl-git2")] 151 | Backend::Git2 => self.command.execute( 152 | crate::backend::Git2Backend::new(self.root_dir) 153 | .unwrap() 154 | .with_credentials(self.auth), 155 | color, 156 | stdout, 157 | stderr, 158 | ), 159 | } 160 | } 161 | } 162 | 163 | fn main() { 164 | let args = Args::parse(); 165 | 166 | if let Some(level) = args.log_level() { 167 | tracing_subscriber::fmt() 168 | .with_max_level(level) 169 | .with_ansi(args.color_enabled()) 170 | .init(); 171 | } 172 | 173 | let mut stdout = std::io::stdout(); 174 | let mut stderr = std::io::stderr(); 175 | 176 | args.execute(&mut stdout, &mut stderr).exit(); 177 | } 178 | -------------------------------------------------------------------------------- /src/service/add.rs: -------------------------------------------------------------------------------- 1 | use crate::backend::Backend; 2 | use crate::entity::metric::{Metric, MetricChange}; 3 | 4 | #[derive(Debug)] 5 | pub(crate) struct Options { 6 | pub target: String, 7 | } 8 | 9 | impl super::Service { 10 | pub(crate) fn add(&self, metric: Metric, opts: &Options) -> Result<(), super::Error> { 11 | let mut changes = self.get_metric_changes(&opts.target)?; 12 | changes.push(MetricChange::Add(metric)); 13 | self.set_metric_changes(&opts.target, changes)?; 14 | 15 | Ok(()) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/service/check.rs: -------------------------------------------------------------------------------- 1 | use crate::backend::Backend; 2 | use crate::entity::check::CheckList; 3 | use crate::entity::config::Config; 4 | 5 | #[derive(Debug)] 6 | pub(crate) struct Options<'a> { 7 | pub remote: &'a str, 8 | pub target: &'a str, 9 | } 10 | 11 | impl super::Service { 12 | pub(crate) fn check(&self, config: &Config, opts: &Options) -> Result { 13 | let diff = self 14 | .diff(&super::diff::Options { 15 | remote: opts.remote, 16 | target: opts.target, 17 | })? 18 | .remove_missing() 19 | .into_inner(); 20 | 21 | Ok(CheckList::evaluate(config, diff)) 22 | } 23 | } 24 | 25 | #[cfg(test)] 26 | mod tests { 27 | use super::*; 28 | use crate::backend::mock::MockBackend; 29 | use crate::backend::{NoteRef, RevParse}; 30 | use crate::entity::check::{MetricCheck, Status, SubsetCheck}; 31 | use crate::entity::config::Rule; 32 | use crate::entity::difference::{Comparison, MetricDiff}; 33 | use crate::entity::metric::MetricHeader; 34 | use crate::service::Service; 35 | 36 | #[test] 37 | fn should_success() { 38 | let backend = MockBackend::default(); 39 | backend.set_config( 40 | r#"[[metrics.first.rules]] 41 | type = "max" 42 | value = 100.0 43 | 44 | [[metrics.first.rules]] 45 | type = "max-increase" 46 | ratio = 0.1 47 | "#, 48 | ); 49 | backend.set_rev_parse( 50 | "main..HEAD", 51 | RevParse::Range("aaaaaab".into(), "aaaaaaa".into()), 52 | ); 53 | backend.set_rev_list("aaaaaab", ["aaaaaac", "aaaaaad"]); 54 | backend.set_rev_list("aaaaaab..aaaaaaa", ["aaaaaaa"]); 55 | backend.set_note( 56 | "aaaaaaa", 57 | NoteRef::remote_metrics("origin"), 58 | r#"[[metrics]] 59 | name = "first" 60 | tags = {} 61 | value = 120.0 62 | "#, 63 | ); 64 | backend.set_note( 65 | "aaaaaac", 66 | NoteRef::remote_metrics("origin"), 67 | r#"[[metrics]] 68 | name = "first" 69 | tags = {} 70 | value = 80.0 71 | "#, 72 | ); 73 | let config = backend.get_config(); 74 | let res = Service::new(backend) 75 | .check( 76 | &config, 77 | &super::Options { 78 | remote: "origin", 79 | target: "main..HEAD", 80 | }, 81 | ) 82 | .unwrap(); 83 | similar_asserts::assert_eq!( 84 | res, 85 | CheckList::default().with_check( 86 | MetricCheck::new(MetricDiff::new( 87 | MetricHeader::new("first"), 88 | Comparison::matching(80.0, 120.0) 89 | )) 90 | .with_check(Rule::max(100.0), Status::Failed) 91 | .with_check(Rule::max_relative_increase(0.1), Status::Failed) 92 | ) 93 | ); 94 | } 95 | 96 | #[test] 97 | fn should_success_with_subsets() { 98 | let backend = MockBackend::default(); 99 | backend.set_config( 100 | r#"[[metrics.first.rules]] 101 | type = "max" 102 | value = 100.0 103 | 104 | [metrics.first.subsets.foo] 105 | matching = { foo = "bar" } 106 | 107 | [[metrics.first.subsets.foo.rules]] 108 | type = "max-increase" 109 | ratio = 0.1 110 | "#, 111 | ); 112 | backend.set_rev_parse( 113 | "main..HEAD", 114 | RevParse::Range("aaaaaab".into(), "aaaaaaa".into()), 115 | ); 116 | backend.set_rev_list("aaaaaab", ["aaaaaac", "aaaaaad"]); 117 | backend.set_rev_list("aaaaaab..aaaaaaa", ["aaaaaaa"]); 118 | backend.set_note( 119 | "aaaaaaa", 120 | NoteRef::remote_metrics("origin"), 121 | r#"[[metrics]] 122 | name = "first" 123 | tags = {} 124 | value = 90.0 125 | 126 | [[metrics]] 127 | name = "first" 128 | tags = { foo = "bar" } 129 | value = 90.0 130 | "#, 131 | ); 132 | backend.set_note( 133 | "aaaaaac", 134 | NoteRef::remote_metrics("origin"), 135 | r#"[[metrics]] 136 | name = "first" 137 | tags = {} 138 | value = 50.0 139 | 140 | [[metrics]] 141 | name = "first" 142 | tags = { foo = "bar" } 143 | value = 50.0 144 | "#, 145 | ); 146 | let config = backend.get_config(); 147 | let res = Service::new(backend) 148 | .check( 149 | &config, 150 | &super::Options { 151 | remote: "origin", 152 | target: "main..HEAD", 153 | }, 154 | ) 155 | .unwrap(); 156 | similar_asserts::assert_eq!( 157 | res, 158 | CheckList::default() 159 | .with_check( 160 | MetricCheck::new(MetricDiff::new( 161 | MetricHeader::new("first"), 162 | Comparison::matching(50.0, 90.0) 163 | )) 164 | .with_check(Rule::max(100.0), Status::Success) 165 | .with_subset("foo", SubsetCheck::default().with_matching("foo", "bar")) 166 | ) 167 | .with_check( 168 | MetricCheck::new(MetricDiff::new( 169 | MetricHeader::new("first").with_tag("foo", "bar"), 170 | Comparison::matching(50.0, 90.0) 171 | )) 172 | .with_check(Rule::max(100.0), Status::Success) 173 | .with_subset( 174 | "foo", 175 | SubsetCheck::default() 176 | .with_matching("foo", "bar") 177 | .with_check(Rule::max_relative_increase(0.1), Status::Failed) 178 | ) 179 | ) 180 | ); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/service/diff.rs: -------------------------------------------------------------------------------- 1 | use crate::backend::{Backend, RevParse}; 2 | use crate::entity::difference::MetricDiffList; 3 | use crate::entity::metric::MetricStack; 4 | 5 | #[derive(Debug)] 6 | pub(crate) struct Options<'a> { 7 | pub remote: &'a str, 8 | pub target: &'a str, 9 | } 10 | 11 | impl super::Service { 12 | pub(super) fn stack_metrics( 13 | &self, 14 | remote_name: &str, 15 | range: &str, 16 | ) -> Result { 17 | let mut stack = MetricStack::default(); 18 | let mut commits = self.backend.rev_list(range)?; 19 | commits.reverse(); 20 | for commit_sha in commits { 21 | let metrics = self.get_metrics(commit_sha.as_str(), remote_name)?; 22 | stack.extend(metrics); 23 | } 24 | Ok(stack) 25 | } 26 | 27 | pub(crate) fn diff(&self, opts: &Options<'_>) -> Result { 28 | let rev_parse = self.backend.rev_parse(opts.target)?; 29 | let (before, after) = match rev_parse { 30 | RevParse::Range(ref first, _) => { 31 | let before = self.stack_metrics(opts.remote, first.as_str())?; 32 | let after = self.stack_metrics(opts.remote, &rev_parse.to_string())?; 33 | (before, after) 34 | } 35 | RevParse::Single(single) => { 36 | let before = self.stack_metrics(opts.remote, &format!("{single}~1"))?; 37 | let after = self.get_metrics(single.as_str(), opts.remote)?; 38 | (before, after) 39 | } 40 | }; 41 | 42 | Ok(MetricDiffList::new(before, after)) 43 | } 44 | } 45 | 46 | #[cfg(test)] 47 | mod tests { 48 | use crate::backend::mock::MockBackend; 49 | use crate::backend::{NoteRef, RevParse}; 50 | use crate::entity::difference::{Comparison, Delta}; 51 | use crate::service::Service; 52 | 53 | #[test] 54 | fn should_render_diff_with_single_target_keeping_previous() { 55 | let backend = MockBackend::default(); 56 | backend.set_rev_parse("HEAD", RevParse::Single("aaaaaaa".into())); 57 | backend.set_rev_list("aaaaaaa~1", ["aaaaaab", "aaaaaac", "aaaaaad", "aaaaaae"]); 58 | backend.set_note( 59 | "aaaaaaa", 60 | NoteRef::remote_metrics("origin"), 61 | r#"[[metrics]] 62 | name = "first" 63 | tags = {} 64 | value = 2.0 65 | "#, 66 | ); 67 | backend.set_note( 68 | "aaaaaac", 69 | NoteRef::remote_metrics("origin"), 70 | r#"[[metrics]] 71 | name = "first" 72 | tags = {} 73 | value = 1.0 74 | 75 | [[metrics]] 76 | name = "second" 77 | tags = {} 78 | value = 1.0 79 | "#, 80 | ); 81 | let list = Service::new(backend) 82 | .diff(&super::Options { 83 | remote: "origin", 84 | target: "HEAD", 85 | }) 86 | .unwrap(); 87 | assert_eq!(list.0.len(), 2); 88 | assert_eq!(list.0[0].header.name, "first"); 89 | assert_eq!( 90 | list.0[0].comparison, 91 | Comparison::Matching { 92 | previous: 1.0, 93 | current: 2.0, 94 | delta: Delta { 95 | absolute: 1.0, 96 | relative: Some(1.0), 97 | }, 98 | } 99 | ); 100 | assert_eq!(list.0[1].header.name, "second"); 101 | assert_eq!(list.0[1].comparison, Comparison::Missing { previous: 1.0 }); 102 | let list = list.remove_missing(); 103 | assert_eq!(list.inner().len(), 1); 104 | } 105 | 106 | #[test] 107 | fn should_render_diff_with_range_target() { 108 | let backend = MockBackend::default(); 109 | backend.set_rev_parse( 110 | "HEAD~3..HEAD", 111 | RevParse::Range("aaaaaad".into(), "aaaaaaa".into()), 112 | ); 113 | backend.set_rev_list("aaaaaad", ["aaaaaad", "aaaaaae", "aaaaaaf"]); 114 | backend.set_rev_list("aaaaaad..aaaaaaa", ["aaaaaaa", "aaaaaab", "aaaaaac"]); 115 | backend.set_note( 116 | "aaaaaaa", 117 | NoteRef::remote_metrics("origin"), 118 | r#"[[metrics]] 119 | name = "first" 120 | tags = {} 121 | value = 2.0 122 | "#, 123 | ); 124 | backend.set_note( 125 | "aaaaaac", 126 | NoteRef::remote_metrics("origin"), 127 | r#"[[metrics]] 128 | name = "first" 129 | tags = {} 130 | value = 1.0 131 | 132 | [[metrics]] 133 | name = "second" 134 | tags = {} 135 | value = 1.0 136 | "#, 137 | ); 138 | backend.set_note( 139 | "aaaaaae", 140 | NoteRef::remote_metrics("origin"), 141 | r#"[[metrics]] 142 | name = "first" 143 | tags = {} 144 | value = 0.5 145 | 146 | [[metrics]] 147 | name = "second" 148 | tags = {} 149 | value = 1.0 150 | 151 | [[metrics]] 152 | name = "third" 153 | tags = {} 154 | value = 0.1 155 | "#, 156 | ); 157 | let list = Service::new(backend) 158 | .diff(&super::Options { 159 | remote: "origin", 160 | target: "HEAD~3..HEAD", 161 | }) 162 | .unwrap(); 163 | assert_eq!(list.0.len(), 3); 164 | assert_eq!(list.0[0].header.name, "first"); 165 | assert_eq!( 166 | list.0[0].comparison, 167 | Comparison::Matching { 168 | previous: 0.5, 169 | current: 2.0, 170 | delta: Delta { 171 | absolute: 1.5, 172 | relative: Some(3.0), 173 | }, 174 | } 175 | ); 176 | assert_eq!(list.0[1].header.name, "second"); 177 | assert_eq!( 178 | list.0[1].comparison, 179 | Comparison::Matching { 180 | previous: 1.0, 181 | current: 1.0, 182 | delta: Delta { 183 | absolute: 0.0, 184 | relative: Some(0.0), 185 | }, 186 | } 187 | ); 188 | assert_eq!(list.0[2].header.name, "third"); 189 | assert_eq!( 190 | list.0[2].comparison, 191 | Comparison::Missing { previous: 0.1 }, 192 | "{:?}", 193 | list.0[2].comparison 194 | ); 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /src/service/log.rs: -------------------------------------------------------------------------------- 1 | use crate::backend::Backend; 2 | use crate::entity::git::Commit; 3 | use crate::entity::metric::MetricStack; 4 | 5 | #[derive(Debug)] 6 | pub(crate) struct Options<'a> { 7 | pub remote: &'a str, 8 | pub target: &'a str, 9 | } 10 | 11 | impl super::Service { 12 | pub(crate) fn log(&self, opts: &Options) -> Result, super::Error> { 13 | let commits = self.backend.get_commits(opts.target)?; 14 | let mut result = Vec::with_capacity(commits.len()); 15 | for commit in commits { 16 | let metrics = self.get_metrics(&commit.sha, opts.remote)?; 17 | result.push((commit, metrics)); 18 | } 19 | Ok(result) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/service/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::backend::{Backend, NoteRef}; 2 | use crate::entity::config::Config; 3 | use crate::entity::metric::{Metric, MetricChange, MetricStack}; 4 | 5 | pub(crate) mod add; 6 | pub(crate) mod check; 7 | pub(crate) mod diff; 8 | pub(crate) mod log; 9 | pub(crate) mod pull; 10 | pub(crate) mod push; 11 | pub(crate) mod remove; 12 | pub(crate) mod show; 13 | 14 | #[derive(Debug, thiserror::Error)] 15 | pub(crate) enum Error { 16 | #[error("unable to write output")] 17 | Io(#[from] std::io::Error), 18 | #[error(transparent)] 19 | Backend(crate::backend::Error), 20 | #[cfg(feature = "importer")] 21 | #[error(transparent)] 22 | Importer(#[from] crate::importer::Error), 23 | #[cfg(feature = "exporter")] 24 | #[error(transparent)] 25 | Exporter(#[from] crate::exporter::Error), 26 | } 27 | 28 | impl> From for Error { 29 | fn from(value: E) -> Self { 30 | Self::Backend(value.into()) 31 | } 32 | } 33 | 34 | impl crate::error::DetailedError for Error { 35 | fn details(&self) -> Option { 36 | match self { 37 | Self::Io(inner) => Some(inner.to_string()), 38 | Self::Backend(inner) => inner.details(), 39 | #[cfg(feature = "importer")] 40 | Self::Importer(inner) => Some(inner.to_string()), 41 | #[cfg(feature = "exporter")] 42 | Self::Exporter(inner) => Some(inner.to_string()), 43 | } 44 | } 45 | } 46 | 47 | #[derive(Debug, serde::Deserialize, serde::Serialize)] 48 | struct MetricList { 49 | #[serde(default)] 50 | metrics: Vec, 51 | } 52 | 53 | impl From> for MetricList { 54 | fn from(value: Vec) -> Self { 55 | Self { metrics: value } 56 | } 57 | } 58 | 59 | #[derive(Debug, serde::Deserialize, serde::Serialize)] 60 | struct ChangeList { 61 | #[serde(default)] 62 | changes: Vec, 63 | } 64 | 65 | pub(crate) struct Service { 66 | backend: B, 67 | } 68 | 69 | impl Service { 70 | pub(crate) fn new(backend: B) -> Self { 71 | Self { backend } 72 | } 73 | 74 | pub(crate) fn open_config(&self) -> Result { 75 | let root = self.backend.root_path()?; 76 | Config::from_root_path(&root).map_err(Error::from) 77 | } 78 | 79 | pub(crate) fn set_metric_changes( 80 | &self, 81 | commit_sha: &str, 82 | changes: Vec, 83 | ) -> Result<(), Error> { 84 | let payload = ChangeList { changes }; 85 | self.backend 86 | .write_note(commit_sha, &NoteRef::Changes, &payload)?; 87 | Ok(()) 88 | } 89 | 90 | pub(crate) fn get_metric_changes(&self, commit_sha: &str) -> Result, Error> { 91 | Ok(self 92 | .backend 93 | .read_note::(commit_sha, &NoteRef::Changes)? 94 | .map(|list| list.changes) 95 | .unwrap_or_default()) 96 | } 97 | 98 | pub(crate) fn get_metrics( 99 | &self, 100 | commit_sha: &str, 101 | remote_name: &str, 102 | ) -> Result { 103 | let remote_metrics = self 104 | .backend 105 | .read_note::(commit_sha, &NoteRef::remote_metrics(remote_name))? 106 | .map(|list| list.metrics) 107 | .unwrap_or_default(); 108 | 109 | let diff_metrics = self.get_metric_changes(commit_sha)?; 110 | 111 | Ok(MetricStack::from_iter(remote_metrics.into_iter()) 112 | .with_changes(diff_metrics.into_iter())) 113 | } 114 | 115 | pub(crate) fn set_metrics_for_ref( 116 | &self, 117 | commit_sha: &str, 118 | note_ref: &NoteRef, 119 | metrics: Vec, 120 | ) -> Result<(), Error> { 121 | let payload = MetricList { metrics }; 122 | self.backend.write_note(commit_sha, note_ref, &payload)?; 123 | Ok(()) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/service/pull.rs: -------------------------------------------------------------------------------- 1 | use crate::backend::{Backend, NoteRef}; 2 | 3 | #[derive(Debug)] 4 | pub(crate) struct Options<'a> { 5 | pub remote: &'a str, 6 | } 7 | 8 | impl super::Service { 9 | pub(crate) fn pull(&self, opts: &Options) -> Result<(), super::Error> { 10 | let note_ref = NoteRef::remote_metrics(opts.remote); 11 | self.backend.pull(opts.remote, ¬e_ref)?; 12 | Ok(()) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/service/push.rs: -------------------------------------------------------------------------------- 1 | use super::MetricList; 2 | use crate::backend::{Backend, NoteRef}; 3 | use crate::entity::metric::MetricStack; 4 | 5 | impl super::Service { 6 | #[inline] 7 | fn prune_notes_in_ref(&self, note_ref: &NoteRef) -> Result<(), super::Error> { 8 | let notes = self.backend.list_notes(note_ref)?; 9 | for note in notes { 10 | self.backend.remove_note(¬e.commit_id, note_ref)?; 11 | } 12 | Ok(()) 13 | } 14 | } 15 | 16 | #[derive(Debug)] 17 | pub(crate) struct Options<'a> { 18 | pub remote: &'a str, 19 | } 20 | 21 | impl super::Service { 22 | pub(crate) fn push(&self, opts: &Options) -> Result<(), super::Error> { 23 | let remote_ref = NoteRef::remote_metrics(opts.remote); 24 | let local_notes = self.backend.list_notes(&NoteRef::Changes)?; 25 | 26 | for commit_sha in local_notes.into_iter().map(|item| item.commit_id) { 27 | let remote_metrics = self 28 | .backend 29 | .read_note::(commit_sha.as_str(), &remote_ref)? 30 | .map(|list| list.metrics) 31 | .unwrap_or_default(); 32 | 33 | let diff_metrics = self.get_metric_changes(commit_sha.as_str())?; 34 | 35 | if !diff_metrics.is_empty() { 36 | let new_metrics = MetricStack::from_iter(remote_metrics.into_iter()) 37 | .with_changes(diff_metrics.into_iter()) 38 | .into_vec(); 39 | self.set_metrics_for_ref(commit_sha.as_str(), &remote_ref, new_metrics)?; 40 | } 41 | } 42 | 43 | self.backend.push(opts.remote, &remote_ref)?; 44 | self.prune_notes_in_ref(&NoteRef::Changes)?; 45 | 46 | Ok(()) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/service/remove.rs: -------------------------------------------------------------------------------- 1 | use crate::backend::Backend; 2 | use crate::entity::metric::{Metric, MetricChange}; 3 | 4 | #[derive(Debug)] 5 | pub(crate) struct Options<'a> { 6 | pub remote: &'a str, 7 | pub target: &'a str, 8 | } 9 | 10 | impl super::Service { 11 | pub(crate) fn remove(&self, index: usize, opts: &Options) -> Result<(), super::Error> { 12 | let metrics = self.get_metrics(opts.target, opts.remote)?; 13 | if let Some((header, value)) = metrics.at(index) { 14 | let mut changes = self.get_metric_changes(opts.target)?; 15 | changes.push(MetricChange::Remove(Metric { 16 | header: header.clone(), 17 | value, 18 | })); 19 | self.set_metric_changes(opts.target, changes)?; 20 | } 21 | 22 | Ok(()) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/service/show.rs: -------------------------------------------------------------------------------- 1 | use crate::backend::Backend; 2 | use crate::entity::metric::MetricStack; 3 | 4 | #[derive(Debug)] 5 | pub(crate) struct Options<'a> { 6 | pub remote: &'a str, 7 | pub target: &'a str, 8 | } 9 | 10 | impl super::Service { 11 | pub(crate) fn show(&self, opts: &Options) -> Result { 12 | self.get_metrics(opts.target, opts.remote) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/tests/check_budget.rs: -------------------------------------------------------------------------------- 1 | use crate::assert_success; 2 | use crate::tests::GitRepo; 3 | 4 | #[test_case::test_case("git2"; "with git2 backend")] 5 | #[test_case::test_case("command"; "with command backend")] 6 | fn execute(backend: &'static str) { 7 | super::init_logs(); 8 | 9 | let root = tempfile::tempdir().unwrap(); 10 | let server = GitRepo::create(backend, root.path().join("server")); 11 | let client = GitRepo::clone(&server, root.path().join("client")); 12 | // set configuration 13 | let cfg_path = client.path.join(".git-metrics.toml"); 14 | std::fs::write( 15 | cfg_path, 16 | r#"[metrics.binary-size] 17 | description = "Binary size" 18 | 19 | # max increase of 20% 20 | [[metrics.binary-size.rules]] 21 | type = "max-increase" 22 | ratio = 0.2 23 | 24 | [[metrics.binary-size.rules]] 25 | type = "max" 26 | value = 200.0 27 | 28 | [metrics.binary-size.subsets.for-darwin] 29 | description = "Binary size for darwin" 30 | matching = { "platform.os" = "darwin" } 31 | 32 | [[metrics.binary-size.subsets.for-darwin.rules]] 33 | type = "max" 34 | value = 120.0 35 | 36 | [metrics.binary-size.subsets.for-linux] 37 | description = "Binary size for linux" 38 | matching = { "platform.os" = "linux" } 39 | 40 | [[metrics.binary-size.subsets.for-linux.rules]] 41 | type = "max" 42 | value = 140.0 43 | "#, 44 | ) 45 | .unwrap(); 46 | // 47 | client.commit("First commit"); 48 | client.push(); 49 | // 50 | client.metrics(["pull"], assert_success!()); 51 | client.metrics( 52 | ["add", "binary-size", "--tag", "platform.os: linux", "100.0"], 53 | assert_success!(), 54 | ); 55 | client.metrics( 56 | [ 57 | "add", 58 | "binary-size", 59 | "--tag", 60 | "platform.os: darwin", 61 | "100.0", 62 | ], 63 | assert_success!(), 64 | ); 65 | client.metrics( 66 | ["add", "binary-size", "--tag", "platform.os: win", "100.0"], 67 | assert_success!(), 68 | ); 69 | // 70 | client.commit("Second commit"); 71 | client.metrics( 72 | ["add", "binary-size", "--tag", "platform.os: linux", "100.0"], 73 | assert_success!(), 74 | ); 75 | client.metrics( 76 | [ 77 | "add", 78 | "binary-size", 79 | "--tag", 80 | "platform.os: darwin", 81 | "100.0", 82 | ], 83 | assert_success!(), 84 | ); 85 | client.metrics( 86 | ["add", "binary-size", "--tag", "platform.os: win", "100.0"], 87 | assert_success!(), 88 | ); 89 | client.metrics(["check", "HEAD"], |stdout, stderr, exit| { 90 | similar_asserts::assert_eq!( 91 | stdout, 92 | r#"[SUCCESS] binary-size{platform.os="linux"} 100.00 => 100.00 93 | [SUCCESS] binary-size{platform.os="darwin"} 100.00 => 100.00 94 | [SUCCESS] binary-size{platform.os="win"} 100.00 => 100.00 95 | "# 96 | ); 97 | similar_asserts::assert_eq!(stderr, ""); 98 | assert!(exit.is_success()); 99 | }); 100 | // 101 | client.commit("Third commit"); 102 | client.metrics( 103 | ["add", "binary-size", "--tag", "platform.os: linux", "100.0"], 104 | assert_success!(), 105 | ); 106 | client.metrics( 107 | [ 108 | "add", 109 | "binary-size", 110 | "--tag", 111 | "platform.os: darwin", 112 | "150.0", 113 | ], 114 | assert_success!(), 115 | ); 116 | client.metrics( 117 | ["add", "binary-size", "--tag", "platform.os: win", "130.0"], 118 | assert_success!(), 119 | ); 120 | client.metrics(["check", "HEAD"], |stdout, _stderr, exit| { 121 | similar_asserts::assert_eq!( 122 | stdout, 123 | r#"[SUCCESS] binary-size{platform.os="linux"} 100.00 => 100.00 124 | [FAILURE] binary-size{platform.os="darwin"} 100.00 => 150.00 Δ +50.00 (+50.00 %) 125 | increase should be less than 20.00 % ... failed 126 | # "for-darwin" matching tags {platform.os="darwin"} 127 | should be lower than 120.00 ... failed 128 | [FAILURE] binary-size{platform.os="win"} 100.00 => 130.00 Δ +30.00 (+30.00 %) 129 | increase should be less than 20.00 % ... failed 130 | "# 131 | ); 132 | assert!(!exit.is_success()); 133 | }); 134 | } 135 | -------------------------------------------------------------------------------- /src/tests/conflict_different.rs: -------------------------------------------------------------------------------- 1 | use crate::assert_success; 2 | use crate::tests::GitRepo; 3 | 4 | #[test_case::test_case("git2"; "with git2 backend")] 5 | #[test_case::test_case("command"; "with command backend")] 6 | fn execute(backend: &'static str) { 7 | super::init_logs(); 8 | 9 | let root = tempfile::tempdir().unwrap(); 10 | let server = GitRepo::create(backend, root.path().join("server")); 11 | let first = GitRepo::clone(&server, root.path().join("first")); 12 | first.commit("Hello World"); 13 | first.push(); 14 | // 15 | let second = GitRepo::clone(&server, root.path().join("second")); 16 | // 17 | second.metrics(["pull"], assert_success!()); 18 | // 19 | first.metrics(["pull"], assert_success!()); 20 | first.metrics(["add", "my-metric", "1.0"], assert_success!()); 21 | first.metrics(["show"], assert_success!("my-metric 1.00\n")); 22 | first.metrics(["push"], assert_success!()); 23 | // 24 | second.metrics(["add", "other-metric", "1.0"], assert_success!()); 25 | second.metrics(["push"], |stdout, stderr, code| { 26 | assert_eq!(stdout, "", "unexpected stdout"); 27 | assert!(stderr.starts_with("unable to push metrics"), "{stderr}"); 28 | assert!(!code.is_success()); 29 | }); 30 | // 31 | second.metrics(["show"], assert_success!("other-metric 1.00\n")); 32 | second.metrics(["pull"], assert_success!()); 33 | second.metrics( 34 | ["show"], 35 | assert_success!("my-metric 1.00\nother-metric 1.00\n"), 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /src/tests/display_diff.rs: -------------------------------------------------------------------------------- 1 | use crate::assert_success; 2 | use crate::tests::GitRepo; 3 | 4 | #[test] 5 | fn execute() { 6 | super::init_logs(); 7 | 8 | let root = tempfile::tempdir().unwrap(); 9 | let server = GitRepo::create("git2", root.path().join("server")); 10 | let cli = GitRepo::clone(&server, root.path().join("first")); 11 | // HEAD~4 12 | cli.commit("0001"); 13 | cli.push(); 14 | cli.metrics( 15 | ["add", "binary_size", "--tag", "build.os: linux", "1000"], 16 | assert_success!(), 17 | ); 18 | cli.metrics( 19 | ["add", "binary_size", "--tag", "build.os: windows", "2000"], 20 | assert_success!(), 21 | ); 22 | cli.metrics(["push"], assert_success!()); 23 | // HEAD~3 24 | cli.commit("0002"); 25 | cli.push(); 26 | cli.metrics( 27 | ["add", "binary_size", "--tag", "build.os: linux", "1000"], 28 | assert_success!(), 29 | ); 30 | cli.metrics( 31 | ["add", "binary_size", "--tag", "build.os: windows", "2000"], 32 | assert_success!(), 33 | ); 34 | cli.metrics( 35 | ["add", "binary_size", "--tag", "build.os: macos", "3000"], 36 | assert_success!(), 37 | ); 38 | cli.metrics(["push"], assert_success!()); 39 | // HEAD~2 40 | cli.commit("0003"); 41 | cli.push(); 42 | cli.metrics( 43 | ["add", "binary_size", "--tag", "build.os: linux", "1500"], 44 | assert_success!(), 45 | ); 46 | cli.metrics( 47 | ["add", "binary_size", "--tag", "build.os: windows", "2500"], 48 | assert_success!(), 49 | ); 50 | cli.metrics( 51 | ["add", "binary_size", "--tag", "build.os: macos", "3500"], 52 | assert_success!(), 53 | ); 54 | // HEAD~1 55 | cli.commit("0004"); 56 | cli.push(); 57 | cli.metrics( 58 | ["add", "binary_size", "--tag", "build.os: linux", "1500"], 59 | assert_success!(), 60 | ); 61 | cli.metrics( 62 | ["add", "binary_size", "--tag", "build.os: windows", "2500"], 63 | assert_success!(), 64 | ); 65 | cli.metrics( 66 | ["add", "binary_size", "--tag", "build.os: macos", "4000"], 67 | assert_success!(), 68 | ); 69 | // HEAD 70 | cli.commit("0005"); 71 | cli.push(); 72 | cli.metrics( 73 | ["add", "binary_size", "--tag", "build.os: linux", "1000"], 74 | assert_success!(), 75 | ); 76 | cli.metrics( 77 | ["add", "binary_size", "--tag", "build.os: windows", "2000"], 78 | assert_success!(), 79 | ); 80 | cli.metrics( 81 | ["add", "binary_size", "--tag", "build.os: macos", "3000"], 82 | assert_success!(), 83 | ); 84 | // 85 | assert_eq!( 86 | cli.metrics_exec("command", ["diff"]), 87 | cli.metrics_exec("git2", ["diff"]), 88 | ); 89 | assert_eq!( 90 | cli.metrics_exec("command", ["diff", "HEAD~2"]), 91 | cli.metrics_exec("git2", ["diff", "HEAD~2"]), 92 | ); 93 | assert_eq!( 94 | cli.metrics_exec("command", ["diff", "HEAD~3..HEAD~1"]), 95 | cli.metrics_exec("git2", ["diff", "HEAD~3..HEAD~1"]), 96 | ); 97 | // 98 | cli.metrics(["diff"], |stdout, stderr, code| { 99 | similar_asserts::assert_eq!( 100 | stdout, 101 | r#"- binary_size{build.os="linux"} 1500.00 102 | + binary_size{build.os="linux"} 1000.00 (-33.33 %) 103 | - binary_size{build.os="windows"} 2500.00 104 | + binary_size{build.os="windows"} 2000.00 (-20.00 %) 105 | - binary_size{build.os="macos"} 4000.00 106 | + binary_size{build.os="macos"} 3000.00 (-25.00 %) 107 | "# 108 | ); 109 | assert_eq!(stderr, ""); 110 | assert!(code.is_success()); 111 | }); 112 | cli.metrics(["diff", "HEAD~3..HEAD~1"], |stdout, stderr, code| { 113 | similar_asserts::assert_eq!( 114 | stdout, 115 | r#"- binary_size{build.os="linux"} 1000.00 116 | + binary_size{build.os="linux"} 1500.00 (+50.00 %) 117 | - binary_size{build.os="windows"} 2000.00 118 | + binary_size{build.os="windows"} 2500.00 (+25.00 %) 119 | - binary_size{build.os="macos"} 3000.00 120 | + binary_size{build.os="macos"} 4000.00 (+33.33 %) 121 | "# 122 | ); 123 | assert_eq!(stderr, ""); 124 | assert!(code.is_success()); 125 | }); 126 | } 127 | -------------------------------------------------------------------------------- /src/tests/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | use std::process::Command; 3 | 4 | mod check_budget; 5 | mod conflict_different; 6 | mod display_diff; 7 | mod simple_use_case; 8 | 9 | fn init_logs() { 10 | let _ = tracing_subscriber::fmt() 11 | .with_max_level(tracing::Level::TRACE) 12 | .try_init(); 13 | } 14 | 15 | struct GitRepo { 16 | backend: &'static str, 17 | path: PathBuf, 18 | path_str: String, 19 | } 20 | 21 | impl GitRepo { 22 | fn create(backend: &'static str, path: PathBuf) -> Self { 23 | let path_str = path.to_string_lossy().to_string(); 24 | let output = Command::new("git") 25 | .arg("init") 26 | .arg("--bare") 27 | .arg(path.as_path()) 28 | .output() 29 | .unwrap(); 30 | assert!( 31 | output.status.success(), 32 | "stderr: {:?}", 33 | String::from_utf8_lossy(&output.stderr) 34 | ); 35 | Self { 36 | backend, 37 | path, 38 | path_str, 39 | } 40 | } 41 | 42 | fn clone(server: &GitRepo, path: PathBuf) -> Self { 43 | let path_str = path.to_string_lossy().to_string(); 44 | let output = Command::new("git") 45 | .arg("clone") 46 | .arg(server.path.as_path()) 47 | .arg(path.as_path()) 48 | .output() 49 | .unwrap(); 50 | assert!( 51 | output.status.success(), 52 | "stderr: {:?}", 53 | String::from_utf8_lossy(&output.stderr) 54 | ); 55 | Self { 56 | backend: server.backend, 57 | path, 58 | path_str, 59 | } 60 | } 61 | 62 | fn path_str(&self) -> &str { 63 | self.path_str.as_str() 64 | } 65 | 66 | fn commit(&self, message: &str) { 67 | let output = Command::new("git") 68 | .current_dir(self.path.as_path()) 69 | .arg("commit") 70 | .arg("--allow-empty") 71 | .arg("-m") 72 | .arg(message) 73 | .output() 74 | .unwrap(); 75 | assert!( 76 | output.status.success(), 77 | "stderr: {:?}", 78 | String::from_utf8_lossy(&output.stderr) 79 | ); 80 | } 81 | 82 | fn pull(&self) { 83 | let output = Command::new("git") 84 | .current_dir(self.path.as_path()) 85 | .arg("pull") 86 | .output() 87 | .unwrap(); 88 | assert!(output.status.success()); 89 | } 90 | 91 | fn push(&self) { 92 | let output = Command::new("git") 93 | .current_dir(self.path.as_path()) 94 | .arg("push") 95 | .output() 96 | .unwrap(); 97 | assert!( 98 | String::from_utf8_lossy(&output.stderr).contains("main -> main"), 99 | "stderr: {:?}", 100 | String::from_utf8_lossy(&output.stderr) 101 | ); 102 | assert!( 103 | output.status.success(), 104 | "stderr: {:?}", 105 | String::from_utf8_lossy(&output.stderr) 106 | ); 107 | } 108 | 109 | fn metrics_exec<'a, I>(&'a self, backend: &'a str, iter: I) -> Result 110 | where 111 | I: IntoIterator, 112 | { 113 | use clap::Parser; 114 | 115 | let mut args = vec![ 116 | "git-metrics", 117 | "--root-dir", 118 | self.path_str(), 119 | "--backend", 120 | backend, 121 | ]; 122 | args.extend(iter); 123 | 124 | let mut stdout = Vec::::new(); 125 | let mut stderr = Vec::::new(); 126 | let result = crate::Args::parse_from(args).execute(&mut stdout, &mut stderr); 127 | 128 | let stdout = String::from_utf8_lossy(&stdout).to_string(); 129 | let stderr = String::from_utf8_lossy(&stderr).to_string(); 130 | 131 | if result.is_success() { 132 | Ok(stdout) 133 | } else { 134 | Err(stderr) 135 | } 136 | } 137 | 138 | fn metrics<'a, I, F>(&'a self, iter: I, callback: F) 139 | where 140 | I: IntoIterator, 141 | F: FnOnce(String, String, crate::ExitCode), 142 | { 143 | use clap::Parser; 144 | 145 | let mut args = vec![ 146 | "git-metrics", 147 | "--root-dir", 148 | self.path_str(), 149 | "--disable-color", 150 | "--backend", 151 | self.backend, 152 | ]; 153 | args.extend(iter); 154 | 155 | let mut stdout = Vec::::new(); 156 | let mut stderr = Vec::::new(); 157 | let result = crate::Args::parse_from(args).execute(&mut stdout, &mut stderr); 158 | 159 | let stdout = String::from_utf8_lossy(&stdout).to_string(); 160 | let stderr = String::from_utf8_lossy(&stderr).to_string(); 161 | 162 | callback(stdout, stderr, result); 163 | } 164 | } 165 | 166 | #[macro_export] 167 | macro_rules! assert_success { 168 | () => { 169 | |stdout, stderr, code| { 170 | similar_asserts::assert_eq!(stdout, "", "unexpected stdout"); 171 | similar_asserts::assert_eq!(stderr, "", "unexpected stderr"); 172 | assert!(code.is_success()); 173 | } 174 | }; 175 | ($output:expr) => { 176 | |stdout, stderr, code| { 177 | similar_asserts::assert_eq!(stdout, $output, "unexpected stdout"); 178 | similar_asserts::assert_eq!(stderr, "", "unexpected stderr"); 179 | assert!(code.is_success()); 180 | } 181 | }; 182 | } 183 | #[macro_export] 184 | macro_rules! assert_failure { 185 | () => { 186 | |stdout, stderr, code| { 187 | similar_asserts::assert_eq!(stdout, "", "unexpected stdout"); 188 | similar_asserts::assert_eq!(stderr, "", "unexpected stderr"); 189 | assert!(!code.is_success()); 190 | } 191 | }; 192 | ($output:expr) => { 193 | |stdout, stderr, code| { 194 | similar_asserts::assert_eq!(stdout, "", "unexpected stdout"); 195 | similar_asserts::assert_eq!(stderr, $output, "unexpected stderr"); 196 | assert!(!code.is_success()); 197 | } 198 | }; 199 | } 200 | -------------------------------------------------------------------------------- /src/tests/simple_use_case.rs: -------------------------------------------------------------------------------- 1 | use crate::assert_success; 2 | use crate::tests::GitRepo; 3 | 4 | #[test_case::test_case("git2"; "with git2 backend")] 5 | #[test_case::test_case("command"; "with command backend")] 6 | fn execute(backend: &'static str) { 7 | super::init_logs(); 8 | 9 | let root = tempfile::tempdir().unwrap(); 10 | let server = GitRepo::create(backend, root.path().join("server")); 11 | let first = GitRepo::clone(&server, root.path().join("first")); 12 | first.commit("Hello World"); 13 | first.push(); 14 | // 15 | first.metrics(["add", "my-metric", "1.0"], assert_success!()); 16 | // 17 | first.metrics(["show"], assert_success!("my-metric 1.00\n")); 18 | // 19 | first.metrics(["push"], assert_success!()); 20 | // 21 | let second = GitRepo::clone(&server, root.path().join("second")); 22 | second.metrics(["pull"], assert_success!()); 23 | // 24 | second.metrics(["show"], assert_success!("my-metric 1.00\n")); 25 | // 26 | first.commit("second commit"); 27 | first.push(); 28 | first.metrics(["add", "my-metric", "2.0"], assert_success!()); 29 | first.metrics(["add", "other-metric", "42.0"], assert_success!()); 30 | first.metrics(["push"], assert_success!()); 31 | // 32 | second.pull(); 33 | second.metrics(["pull"], assert_success!()); 34 | second.metrics(["log"], |stdout, stderr, code| { 35 | let lines: Vec<_> = stdout.trim().split('\n').collect(); 36 | assert_eq!(lines.len(), 5); 37 | assert!(!lines[0].starts_with(' ')); 38 | similar_asserts::assert_eq!(lines[1], " my-metric 2.00"); 39 | similar_asserts::assert_eq!(lines[2], " other-metric 42.00"); 40 | assert!(!lines[3].starts_with(' ')); 41 | similar_asserts::assert_eq!(lines[4], " my-metric 1.00"); 42 | assert_eq!(stderr, ""); 43 | assert!(code.is_success()); 44 | }); 45 | } 46 | --------------------------------------------------------------------------------