Created
November 15, 2025 18:48
-
-
Save RPDevJesco/3b28bc25fab9fbaa87c61138d5a5dea4 to your computer and use it in GitHub Desktop.
Detailed summaries of your crates usage in your Rust project
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env pwsh | |
| #Requires -Version 5.1 | |
| <# | |
| .SYNOPSIS | |
| Analyzes Rust crate dependencies and usage patterns | |
| .DESCRIPTION | |
| Provides comprehensive analysis of crate dependencies including: | |
| - Tree-based counts | |
| - Metadata-based dependency analysis | |
| - Direct dependency usage in source code | |
| - Transitive dependency usage detection | |
| .PARAMETER SkipBuild | |
| Skip the cargo build step | |
| .EXAMPLE | |
| .\crate-summary.ps1 | |
| .\crate-summary.ps1 -SkipBuild | |
| #> | |
| param( | |
| [switch]$SkipBuild | |
| ) | |
| $ErrorActionPreference = "Stop" | |
| # --------------------------------------------------------- | |
| # Helper Functions | |
| # --------------------------------------------------------- | |
| function Test-Command { | |
| param([string]$Command) | |
| $null -ne (Get-Command $Command -ErrorAction SilentlyContinue) | |
| } | |
| # --------------------------------------------------------- | |
| # Prerequisite Checks | |
| # --------------------------------------------------------- | |
| if (-not (Test-Command cargo)) { | |
| Write-Error "Error: cargo not found in PATH" | |
| exit 1 | |
| } | |
| if (-not (Test-Command jq)) { | |
| Write-Error "Error: jq is required for this script (cargo metadata parsing).`nInstall jq (choco install jq), (scoop install jq), 0r (winget install jqlang.jq) and run again." | |
| exit 1 | |
| } | |
| Write-Host "== Crate summary for current project ==" -ForegroundColor Cyan | |
| # --------------------------------------------------------- | |
| # Optional Build | |
| # --------------------------------------------------------- | |
| if (-not $SkipBuild) { | |
| Write-Host "" | |
| Write-Host "Building project (debug)..." -ForegroundColor Yellow | |
| cargo build | |
| if ($LASTEXITCODE -ne 0) { | |
| Write-Error "Build failed" | |
| exit 1 | |
| } | |
| } | |
| # --------------------------------------------------------- | |
| # Tree-based counts (cargo tree) | |
| # --------------------------------------------------------- | |
| Write-Host "" | |
| Write-Host "=== Tree-based counts (cargo tree) ===" -ForegroundColor Green | |
| $treeOutput = cargo tree --edges normal --prefix none 2>&1 | Where-Object { $_.Trim() -ne "" } | |
| $totalTree = ($treeOutput | Measure-Object).Count | |
| $uniqueTree = ($treeOutput | ForEach-Object { $_.Trim() } | Sort-Object -Unique | Measure-Object).Count | |
| Write-Host "Total entries in tree : $totalTree" | |
| Write-Host "Unique lines in tree : $uniqueTree" | |
| # --------------------------------------------------------- | |
| # Metadata-based counts (cargo metadata + jq) | |
| # --------------------------------------------------------- | |
| Write-Host "" | |
| Write-Host "=== Metadata-based counts (cargo metadata) ===" -ForegroundColor Green | |
| $metadataJson = cargo metadata --format-version 1 2>&1 | ConvertFrom-Json | |
| $rootId = $metadataJson.resolve.root | |
| $totalCrates = $metadataJson.resolve.nodes.Count | |
| $rootPackage = $metadataJson.packages | Where-Object { $_.id -eq $rootId } | |
| $rootName = $rootPackage.name | |
| $rootNode = $metadataJson.resolve.nodes | Where-Object { $_.id -eq $rootId } | |
| $directDeps = $rootNode.dependencies.Count | |
| $transitiveDeps = $totalCrates - 1 - $directDeps | |
| Write-Host "Root crate : $rootName" | |
| Write-Host "Total crates in graph : $totalCrates (including root)" | |
| Write-Host "Direct dependencies : $directDeps" | |
| Write-Host "Transitive deps : $transitiveDeps" | |
| Write-Host "" | |
| Write-Host "Summary:" | |
| Write-Host " Tree entries (dup) : $totalTree" | |
| Write-Host " Tree unique lines : $uniqueTree" | |
| Write-Host " Unique crates total : $totalCrates" | |
| Write-Host " Direct / transitive : $directDeps / $transitiveDeps" | |
| # --------------------------------------------------------- | |
| # Crate usage distribution in src/ | |
| # --------------------------------------------------------- | |
| Write-Host "" | |
| Write-Host "=== Crate usage distribution in src/ ===" -ForegroundColor Green | |
| if (-not (Test-Path "src" -PathType Container)) { | |
| Write-Host "No src/ directory found, skipping usage analysis." | |
| exit 0 | |
| } | |
| $rsFiles = Get-ChildItem -Path "src" -Filter "*.rs" -Recurse -File | |
| if ($rsFiles.Count -eq 0) { | |
| Write-Host "No .rs files under src/, skipping usage analysis." | |
| exit 0 | |
| } | |
| # Get direct dependency names | |
| $directDepNames = $rootPackage.dependencies | ForEach-Object { $_.name } | Sort-Object -Unique | |
| if ($directDepNames.Count -eq 0) { | |
| Write-Host "No direct dependencies found in metadata; skipping usage analysis." | |
| exit 0 | |
| } | |
| # Count occurrences of "crate_name::" in source files | |
| $counts = @{} | |
| foreach ($dep in $directDepNames) { | |
| # PowerShell regex pattern - look for non-identifier char followed by dep:: | |
| $pattern = "[^A-Za-z0-9_]$([regex]::Escape($dep))::" | |
| $count = 0 | |
| foreach ($file in $rsFiles) { | |
| $content = Get-Content $file.FullName -Raw | |
| $matches = [regex]::Matches($content, $pattern) | |
| $count += $matches.Count | |
| } | |
| $counts[$dep] = $count | |
| } | |
| # Compute total usage | |
| $totalUsage = ($counts.Values | Measure-Object -Sum).Sum | |
| if ($totalUsage -eq 0) { | |
| Write-Host "No direct crate usages of the form 'crate_name::' found in src/." | |
| Write-Host "This can happen if crates are mostly used via derives/macros or re-exports." | |
| exit 0 | |
| } | |
| # Sort by count descending and display | |
| Write-Host "" | |
| Write-Host ("{0,-20} {1,10} {2,10} {3,12}" -f "Crate", "Calls", "Percent", "Category") | |
| Write-Host ("{0,-20} {1,10} {2,10} {3,12}" -f "-----", "-----", "-------", "--------") | |
| $sortedCounts = $counts.GetEnumerator() | Sort-Object -Property Value -Descending | |
| foreach ($entry in $sortedCounts) { | |
| $crate = $entry.Key | |
| $calls = $entry.Value | |
| $pct = ($calls * 100.0) / $totalUsage | |
| # Determine category | |
| $category = if ($pct -ge 20) { "Heavy" } elseif ($pct -ge 5) { "Medium" } else { "Light" } | |
| Write-Host ("{0,-20} {1,10} {2,9:F2}% {3,12}" -f $crate, $calls, $pct, $category) | |
| } | |
| # --------------------------------------------------------- | |
| # Transitive crate usage in src/ | |
| # --------------------------------------------------------- | |
| Write-Host "" | |
| Write-Host "=== Transitive crate usage in src/ ===" -ForegroundColor Green | |
| # Get all package IDs except root | |
| $allDepIds = $metadataJson.resolve.nodes | Where-Object { $_.id -ne $rootId } | ForEach-Object { $_.id } | |
| # Get direct dependency IDs | |
| $directDepIds = $rootNode.dependencies | |
| # Get transitive IDs (all - direct) | |
| $transitiveDepIds = $allDepIds | Where-Object { $_ -notin $directDepIds } | |
| # Map IDs to names | |
| $transitiveDepNames = $metadataJson.packages | | |
| Where-Object { $_.id -in $transitiveDepIds } | | |
| ForEach-Object { $_.name } | | |
| Sort-Object -Unique | |
| if ($transitiveDepNames.Count -eq 0) { | |
| Write-Host "No transitive dependencies found." | |
| exit 0 | |
| } | |
| Write-Host "Scanning for usage of $($transitiveDepNames.Count) transitive dependencies..." | |
| # Count usages of transitive deps | |
| $transCounts = @{} | |
| foreach ($dep in $transitiveDepNames) { | |
| $pattern = "[^A-Za-z0-9_]$([regex]::Escape($dep))::" | |
| $count = 0 | |
| foreach ($file in $rsFiles) { | |
| $content = Get-Content $file.FullName -Raw | |
| $matches = [regex]::Matches($content, $pattern) | |
| $count += $matches.Count | |
| } | |
| if ($count -gt 0) { | |
| $transCounts[$dep] = $count | |
| } | |
| } | |
| # Check if any transitive deps are used | |
| if ($transCounts.Count -eq 0) { | |
| Write-Host "No transitive crate usages of the form 'crate_name::' found in src/." | |
| Write-Host "All crate usage is through direct dependencies or re-exports." | |
| exit 0 | |
| } | |
| $totalTransUsage = ($transCounts.Values | Measure-Object -Sum).Sum | |
| Write-Host "" | |
| Write-Host "Found $totalTransUsage usages of transitive dependencies!" | |
| Write-Host "Consider adding these as direct dependencies if heavily used:" | |
| Write-Host "" | |
| Write-Host ("{0,-20} {1,10} {2,10} {3,12}" -f "Crate", "Calls", "Percent", "Category") | |
| Write-Host ("{0,-20} {1,10} {2,10} {3,12}" -f "-----", "-----", "-------", "--------") | |
| $sortedTransCounts = $transCounts.GetEnumerator() | Sort-Object -Property Value -Descending | |
| foreach ($entry in $sortedTransCounts) { | |
| $crate = $entry.Key | |
| $calls = $entry.Value | |
| $pct = ($calls * 100.0) / $totalTransUsage | |
| # Determine category | |
| $category = if ($pct -ge 20) { "Heavy" } elseif ($pct -ge 5) { "Medium" } else { "Light" } | |
| Write-Host ("{0,-20} {1,10} {2,9:F2}% {3,12}" -f $crate, $calls, $pct, $category) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| # --------------------------------------------------------- | |
| # Config | |
| # --------------------------------------------------------- | |
| SKIP_BUILD=0 | |
| if [[ "${1-}" == "--skip-build" ]]; then | |
| SKIP_BUILD=1 | |
| fi | |
| # --------------------------------------------------------- | |
| # Helpers | |
| # --------------------------------------------------------- | |
| have_cmd() { | |
| command -v "$1" >/dev/null 2>&1 | |
| } | |
| if ! have_cmd cargo; then | |
| echo "Error: cargo not found in PATH" >&2 | |
| exit 1 | |
| fi | |
| if ! have_cmd jq; then | |
| echo "Error: jq is required for this script (cargo metadata parsing)." >&2 | |
| echo "Install jq and run again." >&2 | |
| exit 1 | |
| fi | |
| echo "== Crate summary for current project ==" | |
| # --------------------------------------------------------- | |
| # Optional build | |
| # --------------------------------------------------------- | |
| if [[ "$SKIP_BUILD" -eq 0 ]]; then | |
| echo | |
| echo "Building project (debug)..." | |
| cargo build | |
| fi | |
| # --------------------------------------------------------- | |
| # Tree-based counts (cargo tree) | |
| # --------------------------------------------------------- | |
| echo | |
| echo "=== Tree-based counts (cargo tree) ===" | |
| # Total entries in the tree (with duplicates) | |
| total_tree=$( | |
| cargo tree --edges normal --prefix none \ | |
| | sed '/^[[:space:]]*$/d' \ | |
| | wc -l \ | |
| | awk '{print $1}' | |
| ) | |
| # Unique lines in the tree (deduped) | |
| unique_tree=$( | |
| cargo tree --edges normal --prefix none \ | |
| | sed 's/^[[:space:]]*//' \ | |
| | sed '/^$/d' \ | |
| | sort -u \ | |
| | wc -l \ | |
| | awk '{print $1}' | |
| ) | |
| echo "Total entries in tree : $total_tree" | |
| echo "Unique lines in tree : $unique_tree" | |
| # --------------------------------------------------------- | |
| # Metadata-based counts (cargo metadata + jq) | |
| # --------------------------------------------------------- | |
| echo | |
| echo "=== Metadata-based counts (cargo metadata) ===" | |
| metadata_json="$(cargo metadata --format-version 1 2>/dev/null)" | |
| root_id="$(printf '%s\n' "$metadata_json" | jq -r '.resolve.root')" | |
| total_crates="$(printf '%s\n' "$metadata_json" | jq '.resolve.nodes | length')" | |
| root_name="$( | |
| printf '%s\n' "$metadata_json" \ | |
| | jq -r --arg root "$root_id" '.packages[] | select(.id == $root) | .name' | |
| )" | |
| direct_deps="$( | |
| printf '%s\n' "$metadata_json" \ | |
| | jq --arg root "$root_id" '.resolve.nodes[] | select(.id == $root) | .dependencies | length' | |
| )" | |
| transitive_deps=$(( total_crates - 1 - direct_deps )) | |
| echo "Root crate : $root_name" | |
| echo "Total crates in graph : $total_crates (including root)" | |
| echo "Direct dependencies : $direct_deps" | |
| echo "Transitive deps : $transitive_deps" | |
| echo | |
| echo "Summary:" | |
| echo " Tree entries (dup) : $total_tree" | |
| echo " Tree unique lines : $unique_tree" | |
| echo " Unique crates total : $total_crates" | |
| echo " Direct / transitive : $direct_deps / $transitive_deps" | |
| # --------------------------------------------------------- | |
| # Crate usage distribution in src/ | |
| # --------------------------------------------------------- | |
| echo | |
| echo "=== Crate usage distribution in src/ ===" | |
| if [[ ! -d src ]]; then | |
| echo "No src/ directory found, skipping usage analysis." | |
| exit 0 | |
| fi | |
| # Any .rs files? | |
| if ! find src -type f -name '*.rs' | read -r _; then | |
| echo "No .rs files under src/, skipping usage analysis." | |
| exit 0 | |
| fi | |
| # Direct dependency names for the root package | |
| # Using portable array building instead of mapfile | |
| direct_dep_names=() | |
| while IFS= read -r dep_name; do | |
| [[ -n "$dep_name" ]] && direct_dep_names+=("$dep_name") | |
| done < <( | |
| printf '%s\n' "$metadata_json" \ | |
| | jq -r --arg root "$root_id" ' | |
| .packages[] | |
| | select(.id == $root) | |
| | .dependencies[].name | |
| ' \ | |
| | sort -u | |
| ) | |
| if [[ ${#direct_dep_names[@]} -eq 0 ]]; then | |
| echo "No direct dependencies found in metadata; skipping usage analysis." | |
| exit 0 | |
| fi | |
| # For each direct dependency, count occurrences of "crate_name::" in src/**/*.rs | |
| # Store counts in a temp file (key-value pairs) for portability | |
| counts_file="$(mktemp)" | |
| for dep in "${direct_dep_names[@]}"; do | |
| # NOTE: This is heuristic; it looks for something like `<non-ident>dep::` | |
| # to approximate "dep::<symbol>" usages. | |
| # We escape '-' literally and let grep treat it as normal char. | |
| pattern="[^A-Za-z0-9_]${dep}::" | |
| count=$( | |
| grep -REo "$pattern" src 2>/dev/null \ | |
| | wc -l \ | |
| | awk '{print $1}' | |
| ) | |
| echo "$dep $count" >> "$counts_file" | |
| done | |
| # Compute total usage across all direct deps | |
| total_usage=$(awk '{sum += $2} END {print sum}' "$counts_file") | |
| if [[ "$total_usage" -eq 0 ]]; then | |
| echo "No direct crate usages of the form 'crate_name::' found in src/." | |
| echo "This can happen if crates are mostly used via derives/macros or re-exports." | |
| rm -f "$counts_file" | |
| exit 0 | |
| fi | |
| # Build a sorted list by count (desc) | |
| # We'll print a table: crate, calls, percent, category | |
| printf "\n%-20s %10s %10s %12s\n" "Crate" "Calls" "Percent" "Category" | |
| printf "%-20s %10s %10s %12s\n" "-----" "-----" "-------" "--------" | |
| # Sort by count (second field) descending | |
| sort -k2 -nr "$counts_file" > "${counts_file}.sorted" | |
| while read -r crate calls; do | |
| # Avoid division by zero (already handled earlier, but just in case) | |
| if [[ "$total_usage" -gt 0 ]]; then | |
| pct=$(awk -v c="$calls" -v t="$total_usage" 'BEGIN { printf "%.2f", (c * 100.0) / t }') | |
| else | |
| pct="0.00" | |
| fi | |
| # Category thresholds (tweak if you like) | |
| # Heavy: >= 20% | |
| # Medium: >= 5% | |
| # Light: otherwise | |
| pct_num=$(printf '%s\n' "$pct" | sed 's/,/./') | |
| category="Light" | |
| awk -v p="$pct_num" 'BEGIN { exit !(p >= 20.0) }' && category="Heavy" || true | |
| if [[ "$category" == "Light" ]]; then | |
| awk -v p="$pct_num" 'BEGIN { exit !(p >= 5.0) }' && category="Medium" || true | |
| fi | |
| printf "%-20s %10d %9s%% %12s\n" "$crate" "$calls" "$pct" "$category" | |
| done < "${counts_file}.sorted" | |
| rm -f "$counts_file" "${counts_file}.sorted" | |
| # --------------------------------------------------------- | |
| # Transitive crate usage in src/ | |
| # --------------------------------------------------------- | |
| echo | |
| echo "=== Transitive crate usage in src/ ===" | |
| # Get all transitive dependency names (exclude root and direct deps) | |
| transitive_dep_names=() | |
| while IFS= read -r dep_name; do | |
| [[ -n "$dep_name" ]] && transitive_dep_names+=("$dep_name") | |
| done < <( | |
| printf '%s\n' "$metadata_json" \ | |
| | jq -r --arg root "$root_id" ' | |
| # Get all package IDs in the dependency graph | |
| [.resolve.nodes[] | select(.id != $root) | .id] as $all_deps | | |
| # Get direct dependency IDs | |
| [.resolve.nodes[] | select(.id == $root) | .dependencies[]] as $direct_deps | | |
| # Get transitive = all - direct | |
| ($all_deps - $direct_deps) as $transitive_ids | | |
| # Map IDs to names | |
| .packages[] | select([.id] | inside($transitive_ids)) | .name | |
| ' \ | |
| | sort -u | |
| ) | |
| if [[ ${#transitive_dep_names[@]} -eq 0 ]]; then | |
| echo "No transitive dependencies found." | |
| exit 0 | |
| fi | |
| echo "Scanning for usage of ${#transitive_dep_names[@]} transitive dependencies..." | |
| # Count usages of transitive deps | |
| trans_counts_file="$(mktemp)" | |
| for dep in "${transitive_dep_names[@]}"; do | |
| pattern="[^A-Za-z0-9_]${dep}::" | |
| count=$( | |
| grep -REo "$pattern" src 2>/dev/null \ | |
| | wc -l \ | |
| | awk '{print $1}' | |
| ) | |
| if [[ "$count" -gt 0 ]]; then | |
| echo "$dep $count" >> "$trans_counts_file" | |
| fi | |
| done | |
| # Check if any transitive deps are used | |
| if [[ ! -s "$trans_counts_file" ]]; then | |
| echo "No transitive crate usages of the form 'crate_name::' found in src/." | |
| echo "All crate usage is through direct dependencies or re-exports." | |
| rm -f "$trans_counts_file" | |
| exit 0 | |
| fi | |
| total_trans_usage=$(awk '{sum += $2} END {print sum}' "$trans_counts_file") | |
| echo | |
| echo "Found $total_trans_usage usages of transitive dependencies!" | |
| echo "Consider adding these as direct dependencies if heavily used:" | |
| echo | |
| printf "%-20s %10s %10s %12s\n" "Crate" "Calls" "Percent" "Category" | |
| printf "%-20s %10s %10s %12s\n" "-----" "-----" "-------" "--------" | |
| # Sort by count descending | |
| sort -k2 -nr "$trans_counts_file" > "${trans_counts_file}.sorted" | |
| while read -r crate calls; do | |
| if [[ "$total_trans_usage" -gt 0 ]]; then | |
| pct=$(awk -v c="$calls" -v t="$total_trans_usage" 'BEGIN { printf "%.2f", (c * 100.0) / t }') | |
| else | |
| pct="0.00" | |
| fi | |
| pct_num=$(printf '%s\n' "$pct" | sed 's/,/./') | |
| category="Light" | |
| awk -v p="$pct_num" 'BEGIN { exit !(p >= 20.0) }' && category="Heavy" || true | |
| if [[ "$category" == "Light" ]]; then | |
| awk -v p="$pct_num" 'BEGIN { exit !(p >= 5.0) }' && category="Medium" || true | |
| fi | |
| printf "%-20s %10d %9s%% %12s\n" "$crate" "$calls" "$pct" "$category" | |
| done < "${trans_counts_file}.sorted" | |
| rm -f "$trans_counts_file" "${trans_counts_file}.sorted" |
On my MacOS Sequoia the Crate Usage Distribution calc hanged when running the script.
I ran it through Claude and it found the change that was needed. Here's Claude's explanation:
"The "Crate usage distribution in src/" section was hanging due to bash subshell nesting issues. The problem was
caused by using command substitution $(grep ... | wc ...) inside nested for loops that came after process substitution
for populating arrays. This created file descriptor conflicts that caused grep to hang."
Thanks again for this awesome script - super helpful!
=== Crate usage distribution in src/ ===
| Crate | Calls | Percent | Category |
|---|---|---|---|
| serde_json | 13 | 52.00% | Heavy |
| serde | 11 | 44.00% | Heavy |
| regex | 1 | 4.00% | Light |
| pgf2json | 0 | 0.00% | Light |
| clap | 0 | 0.00% | Light |
| bytes | 0 | 0.00% | Light |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This is awesome! Thanks for sharing it!