A tool that analyzes Pull Requests in Rust projects and enforces PR size limits by distinguishing production code from test code. It uses Rust AST (Abstract Syntax Tree) analysis to count only meaningful production code changes, ignoring tests, benchmarks, and examples.
Why limit PR size? Large PRs are harder to review, more likely to contain bugs, and slow down the development process. This tool helps teams enforce reasonable PR sizes automatically.
- Semantic Analysis: Parses Rust code to identify code units (functions, structs, enums, traits, impl blocks) rather than just counting lines
- Qualified Names: Shows fully qualified unit names (e.g.,
Parser::new,Display for Item::display) - Line Ranges: Displays exact line numbers where changes occurred (e.g.,
src/lib.rs:24-38) - Per-Unit Stats: Shows lines added/removed for each individual unit (
+5 -3) - Smart Classification: Automatically distinguishes between production, test, benchmark, and example code
- Analysis Scope: Reports analyzed files, excluded patterns, and skipped files
- Weighted Scoring: Assigns different weights to different code types (public functions are worth more than private ones)
- Flexible Limits: Set global limits, per-type limits (e.g., max 5 functions), and line-based limits
- PR Comments: Automatically posts formatted analysis results as comments on pull requests
- Multiple Output Formats: GitHub Actions outputs, JSON for integration, human-readable text, or markdown comments
cargo install rust-diff-analyzergit clone https://github.com/RAprogramm/rust-prod-diff-checker
cd rust-prod-diff-checker
cargo build --releaseThe binary will be available at target/release/rust-diff-analyzer.
The tool reads a diff (output from git diff) and analyzes the Rust code changes.
# Analyze diff from a file
rust-diff-analyzer --diff-file changes.diff
# Pipe diff from git directly
git diff HEAD~1 | rust-diff-analyzer
# Compare with a specific branch
git diff main | rust-diff-analyzer
# Set custom limits
rust-diff-analyzer --diff-file changes.diff --max-units 50 --max-score 200 --max-lines 300
# Don't exit with code 1 when limits exceeded (useful in scripts)
rust-diff-analyzer --diff-file changes.diff --no-fail
# Different output formats
rust-diff-analyzer --diff-file changes.diff --format json # Machine-readable JSON
rust-diff-analyzer --diff-file changes.diff --format human # Human-readable text
rust-diff-analyzer --diff-file changes.diff --format comment # Markdown for PR comments
rust-diff-analyzer --diff-file changes.diff --format github # GitHub Actions outputs (default)The tool will exit with code 1 if limits are exceeded (useful for CI). Use --no-fail to always exit with code 0.
Add this workflow to your repository at .github/workflows/pr-analysis.yml:
name: PR Analysis
on:
pull_request:
jobs:
analyze:
name: Analyze PR Size
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # Required for posting comments
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Required to get full git history for diff
- name: Analyze PR
uses: RAprogramm/rust-prod-diff-checker@v1
with:
max_prod_units: 30 # Max production code units
max_weighted_score: 100 # Max weighted score
fail_on_exceed: true # Fail CI if limits exceeded
post_comment: true # Post results as PR comment| Input | Description | Default |
|---|---|---|
max_prod_units |
Maximum number of production code units (functions, structs, etc.) allowed in a single PR | 30 |
max_weighted_score |
Maximum weighted score allowed. Public items have higher weights than private ones | 100 |
max_prod_lines |
Maximum number of production code lines added. Leave empty for no limit | - |
fail_on_exceed |
Whether to fail the CI job if any limit is exceeded | true |
post_comment |
Post analysis results as a comment on the PR | false |
update_comment |
Update existing comment instead of creating a new one on each push | true |
output_format |
Output format: github (Actions outputs), json, or human |
github |
config_file |
Path to custom configuration file | - |
The action provides these outputs that can be used in subsequent steps:
prod_functions_changed- Number of production functions changedprod_structs_changed- Number of production structs changedprod_other_changed- Number of other production units changedtest_units_changed- Number of test units changedprod_lines_added- Lines added in production codeprod_lines_removed- Lines removed from production codetest_lines_added- Lines added in test codetest_lines_removed- Lines removed from test codeweighted_score- Calculated weighted scoreexceeds_limit- Whether any limit was exceeded (true/false)
When post_comment: true is set, the action posts a formatted comment on the PR:
- name: Analyze PR
uses: RAprogramm/rust-prod-diff-checker@v1
with:
post_comment: true
update_comment: true # Updates same comment instead of creating new onesThe comment includes:
- Summary table: Production vs test metrics at a glance
- Weighted score: Total score with pass/fail indicator
- Changed units table: Detailed breakdown with:
- File path with line range (e.g.,
src/lib.rs:24-38) - Qualified unit name (e.g.,
Parser::new) - Unit type (function, struct, etc.)
- Lines changed (
+5 -3)
- File path with line range (e.g.,
- Analysis scope: Collapsible section showing analyzed files, excluded patterns, and skipped files
Example PR comment output:
## Rust Diff Analysis
| Metric | Production | Test |
|--------|------------|------|
| Functions | 3 | - |
| Structs/Enums | 1 | - |
| Lines added | 45 | 20 |
### Score
**15** / 100 ✅
### Changed Units
#### Production (4)
| File | Unit | Type | Changes |
|------|------|------|---------|
| `src/parser.rs:24-38` | `Parser::new` | function | +12 -3 |
| `src/parser.rs:45-67` | `Parser::parse` | function | +18 -5 |
<details>
<summary>Analysis Scope</summary>
**Analyzed:** 3 Rust files
**Skipped files:**
- 5 non-Rust files
</details>This provides reviewers with immediate context about the PR's scope.
For fine-grained control, create .rust-diff-analyzer.toml in your project root:
# Classification settings - what counts as test code
[classification]
# Cargo features that indicate test code
test_features = ["test-utils", "testing", "mock"]
# Directories containing test code (trailing slash important)
test_paths = ["tests/", "benches/", "examples/"]
# Paths to completely ignore in analysis
ignore_paths = ["generated/", "vendor/"]
# Weight settings - how much each item type contributes to the score
[weights]
public_function = 3 # Public functions are significant API surface
private_function = 1 # Private functions are implementation details
public_struct = 3 # Public structs define the API
private_struct = 1
impl_block = 2 # Impl blocks add methods
trait_definition = 4 # Traits are major abstractions
const_static = 1 # Constants are minor
# Limit settings
[limits]
max_prod_units = 30 # Maximum production units per PR
max_weighted_score = 100 # Maximum weighted score per PR
max_prod_lines = 200 # Maximum production lines added
fail_on_exceed = true # Fail CI when exceeded
# Per-type limits for fine-grained control (all optional)
[limits.per_type]
functions = 5 # Max 5 functions per PR
structs = 3 # Max 3 structs per PR
enums = 3
traits = 2 # Traits are complex, limit to 2
impl_blocks = 10
consts = 5
statics = 2
type_aliases = 3
macros = 2
modules = 3
# Output settings
[output]
format = "github" # Default output format
include_details = true # Include list of changed units in outputThis format is designed for GitHub Actions. Each line sets an output variable:
prod_functions_changed=5
prod_structs_changed=2
prod_other_changed=1
test_units_changed=10
prod_lines_added=150
prod_lines_removed=30
test_lines_added=200
test_lines_removed=50
weighted_score=23
exceeds_limit=false
Machine-readable format for integration with other tools:
{
"summary": {
"prod_functions": 5,
"prod_structs": 2,
"prod_other": 1,
"test_units": 10,
"prod_lines_added": 150,
"prod_lines_removed": 30,
"test_lines_added": 200,
"test_lines_removed": 50,
"weighted_score": 23,
"exceeds_limit": false
},
"changes": [
{
"file_path": "src/lib.rs",
"unit": {
"name": "parse_config",
"kind": "Function",
"visibility": "Public"
},
"classification": "Production",
"lines_added": 20,
"lines_removed": 5
}
]
}The analyzer automatically classifies code into categories:
- File path: Code in
tests/,benches/, orexamples/directories is not production - Attributes: Functions with
#[test],#[bench], or#[cfg(test)]are tests - Module context: Code inside
mod tests { }blocks is test code
| Classification | Description | Counts toward limits? |
|---|---|---|
| Production | Regular production code | Yes |
| Test | Test functions and utilities | No |
| Benchmark | Performance benchmarks | No |
| Example | Example code in examples/ |
No |
| BuildScript | build.rs files |
No |
This means you can add as many tests as you want without affecting your PR size limits!
Not all code changes are equal. The weighted scoring system assigns different values to different types of changes:
| Unit Type | Public | Private | Rationale |
|---|---|---|---|
| Function | 3 | 1 | Public functions are API surface |
| Struct | 3 | 1 | Public structs define data contracts |
| Enum | 3 | 1 | Public enums are often API types |
| Trait | 4 | 4 | Traits are major abstractions |
| Impl Block | 2 | 2 | Adds behavior to types |
| Const/Static | 1 | 1 | Minor configuration |
Example: A PR that adds:
- 2 public functions (2 × 3 = 6)
- 1 private function (1 × 1 = 1)
- 1 public struct (1 × 3 = 3)
Total weighted score: 10
You can use this tool as a library in your own Rust projects:
use rust_diff_analyzer::{
analysis::map_changes,
config::Config,
git::parse_diff,
};
fn main() -> Result<(), rust_diff_analyzer::AppError> {
// Read the diff
let diff = std::fs::read_to_string("changes.diff")?;
// Use default configuration or load from file
let config = Config::default();
// Parse the diff into structured data
let file_diffs = parse_diff(&diff)?;
// Map changes to semantic units
// The closure provides file content for AST parsing
let result = map_changes(&file_diffs, &config, |path| {
std::fs::read_to_string(path)
})?;
// Access changes and scope information
for change in &result.changes {
println!("{}:{}-{}: {} ({})",
change.file_path.display(),
change.unit.span.start,
change.unit.span.end,
change.unit.qualified_name(), // e.g., "Parser::new"
change.classification.as_str()
);
println!(" Lines: +{} -{}", change.lines_added, change.lines_removed);
}
// Check analysis scope
println!("Analyzed {} files", result.scope.analyzed_files.len());
println!("Skipped {} non-Rust files", result.scope.non_rust_count());
Ok(())
}Add to your Cargo.toml:
[dependencies]
rust-diff-analyzer = "1.1"Coverage Graphs
The inner-most circle is the entire project, moving away from the center are folders then, finally, a single file. The size and color of each slice is representing the number of statements and the coverage, respectively.
Each block represents a single file in the project. The size and color of each block is represented by the number of statements and the coverage, respectively.
The top section represents the entire project. Proceeding with folders and finally individual files. The size and color of each slice is representing the number of statements and the coverage, respectively.
MIT License - see LICENSE for details.