Rust

Workspace Automation

Shell scripts and Just recipes for managing multi-crate Rust workspaces

Workspace Automation

When you maintain multiple Rust crates that depend on each other, routine tasks multiply. Checking status across repos, validating dependency versions, running tests everywhere, publishing in the right order — doing these by hand doesn't scale.

This guide covers practical shell scripts and Just recipes for automating a multi-crate workspace. The patterns work for any collection of related Rust repos, whether they live in a Cargo workspace or as separate repositories.

The Problem

Consider maintaining four crates where gainlineup depends on rfconversions and touchstone, and linkbudget depends on rfconversions. You need to:

  • Know which repos have uncommitted changes or unpushed commits
  • Detect when local crate versions differ from what's on crates.io
  • Find dependency version mismatches across repos
  • Run tests across everything quickly
  • Publish crates in the correct order

Each of these is simple alone. Together, they're a system.

Repo Status Dashboard

The most frequently used script shows the health of every Git repo at a glance:

#!/usr/bin/env bash
# repo-status.sh — Quick health check across all repos
set -uo pipefail

DEV_DIR="$HOME/Development"

printf "%-18s %-20s %-12s %-12s %-10s %s\n" \
  "REPO" "BRANCH" "DIRTY" "UNPUSHED" "OPEN PRs" "STALE BRANCHES"
printf '%.0s─' {1..90}; echo

for repo_dir in "$DEV_DIR"/*/; do
  [ -d "$repo_dir/.git" ] || continue
  repo=$(basename "$repo_dir")
  cd "$repo_dir"

  branch=$(git branch --show-current 2>/dev/null || echo "detached")

  if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
    dirty="yes"
  else
    dirty=""
  fi

  unpushed=$(git log --oneline "@{u}..HEAD" 2>/dev/null | wc -l | tr -d ' ')
  [ "$unpushed" = "0" ] && unpushed=""

  open_prs=$(gh pr list --repo "iancleary/$repo" --json number \
    --jq 'length' 2>/dev/null || echo "?")
  [ "$open_prs" = "0" ] && open_prs=""

  stale=$(git branch --merged main 2>/dev/null \
    | grep -v '^\*\|main\|master' | wc -l | tr -d ' ')
  [ "$stale" = "0" ] && stale=""

  printf "%-18s %-20s %-12s %-12s %-10s %s\n" \
    "$repo" "$branch" "$dirty" "$unpushed" "$open_prs" "$stale"
done

Run it and you get a table like:

REPO               BRANCH               DIRTY        UNPUSHED     OPEN PRs   STALE BRANCHES
──────────────────────────────────────────────────────────────────────────────────────────────
career             main                 —            —            —          —
docs               main                 —            —            —          2
gainlineup         main                 —            —            —          —
linkbudget         main                 —            —            1          —
rfconversions      main                 yes          —            —          —

At a glance: rfconversions has uncommitted changes, linkbudget has an open PR, and docs has two stale branches to clean up.

Crate Version Comparison

Detect when your local version differs from what's published on crates.io:

#!/usr/bin/env bash
# crate-versions.sh — Compare local vs published crate versions
set -uo pipefail

CRATES=("rfconversions" "touchstone" "gainlineup" "linkbudget")
DEV_DIR="$HOME/Development"

printf "%-20s %-12s %-12s %s\n" "CRATE" "LOCAL" "CRATES.IO" "STATUS"
printf '%.0s─' {1..60}; echo

for crate in "${CRATES[@]}"; do
  dir="$DEV_DIR/$crate"
  [ -f "$dir/Cargo.toml" ] || continue

  local_ver=$(grep '^version' "$dir/Cargo.toml" \
    | head -1 | sed 's/.*"\(.*\)".*/\1/')

  published=$(cargo search "$crate" --limit 1 2>/dev/null \
    | grep "^$crate " | sed 's/.*= "\(.*\)".*/\1/')

  if [ "$local_ver" = "$published" ]; then
    status="✓ up to date"
  else
    status="⬆ needs publish"
  fi

  printf "%-20s %-12s %-12s %s\n" \
    "$crate" "$local_ver" "${published:-?}" "$status"
done

Output:

CRATE                LOCAL        CRATES.IO    STATUS
────────────────────────────────────────────────────────────────
rfconversions        0.7.2        0.7.0        ⬆ needs publish
touchstone           0.11.4       0.11.4       ✓ up to date
gainlineup           0.21.0       0.21.0       ✓ up to date
linkbudget           0.5.3        0.5.3        ✓ up to date

Dependency Matrix

Cross-crate dependency mismatches cause subtle bugs. This script checks what version each crate declares for its dependencies:

#!/usr/bin/env bash
# dep-check.sh — Cross-crate dependency matrix
set -uo pipefail

CRATES=("rfconversions" "touchstone" "gainlineup" "linkbudget")
DEV_DIR="$HOME/Development"

echo "=== Dependency Matrix ==="
for crate in "${CRATES[@]}"; do
  toml="$DEV_DIR/$crate/Cargo.toml"
  [ -f "$toml" ] || continue
  echo ""
  echo "$crate dependencies:"

  for dep in "${CRATES[@]}"; do
    [ "$dep" = "$crate" ] && continue
    ver=$(grep "^$dep" "$toml" 2>/dev/null \
      | sed 's/.*version *= *"\([^"]*\)".*/\1/' | head -1)
    [ -n "$ver" ] && echo "  $dep = $ver"
  done
done

echo ""
echo "=== Cargo.lock Freshness ==="
for crate in "${CRATES[@]}"; do
  dir="$DEV_DIR/$crate"
  [ -f "$dir/Cargo.lock" ] || continue
  lock_age=$(( ($(date +%s) - $(stat -f %m "$dir/Cargo.lock")) / 86400 ))
  echo "  $crate: Cargo.lock is ${lock_age}d old"
done

This catches the scenario where gainlineup pins rfconversions = "0.6.0" but you've already published 0.7.0. Without this check, you'd discover the mismatch at publish time.

Test Summary

Running cargo test across five crates and counting results manually is tedious:

#!/usr/bin/env bash
# test-summary.sh — Run tests across all crates, report counts
set -uo pipefail

CRATES=("rfconversions" "touchstone" "gainlineup" "linkbudget" "regulatory-rf")
DEV_DIR="$HOME/Development"
total=0

printf "%-20s %-10s %-8s\n" "CRATE" "VERSION" "TESTS"
printf '%.0s─' {1..42}; echo

for crate in "${CRATES[@]}"; do
  dir="$DEV_DIR/$crate"
  [ -f "$dir/Cargo.toml" ] || continue

  ver=$(grep '^version' "$dir/Cargo.toml" \
    | head -1 | sed 's/.*"\(.*\)".*/\1/')

  count=$(cd "$dir" && cargo test 2>&1 \
    | grep "test result:" \
    | awk '{sum += $4} END {print sum}')

  total=$((total + count))
  printf "%-20s %-10s %-8s\n" "$crate" "$ver" "$count"
done

printf '%.0s─' {1..42}; echo
printf "%-20s %-10s %-8s\n" "TOTAL" "" "$total"

A quick mode counts #[test] annotations without compiling — useful for fast estimates:

# Quick mode: count test annotations without compiling
for crate in "${CRATES[@]}"; do
  dir="$DEV_DIR/$crate"
  unit=$(grep -r '#\[test\]' "$dir/src/" 2>/dev/null | wc -l | tr -d ' ')
  doc=$(grep -r '```' "$dir/src/" 2>/dev/null \
    | grep -v 'no_run\|ignore\|compile_fail' | wc -l | tr -d ' ')
  doc=$((doc / 2))  # opening + closing fence pairs
  echo "$crate: ~$unit unit + ~$doc doc = ~$((unit + doc))"
done

Publish Cascade

Crates with inter-dependencies must be published in topological order. Publishing gainlineup before its dependency rfconversions fails:

#!/usr/bin/env bash
# publish-cascade.sh — Ordered publish plan
set -uo pipefail

# Define publish order (leaves first)
PUBLISH_ORDER=(
  "rfconversions"    # no internal deps
  "touchstone"       # depends on rfconversions
  "gainlineup"       # depends on rfconversions + touchstone
  "linkbudget"       # depends on rfconversions
)

for crate in "${PUBLISH_ORDER[@]}"; do
  local_ver=$(grep '^version' "$HOME/Development/$crate/Cargo.toml" \
    | head -1 | sed 's/.*"\(.*\)".*/\1/')
  published=$(cargo search "$crate" --limit 1 2>/dev/null \
    | grep "^$crate " | sed 's/.*= "\(.*\)".*/\1/')

  if [ "$local_ver" != "$published" ]; then
    echo "PUBLISH: $crate $published$local_ver"
    echo "  cd ~/Development/$crate && cargo publish"
  else
    echo "SKIP:    $crate ($local_ver = published)"
  fi
done

Pre-Publish Checklist

Before running cargo publish, validate everything:

#!/usr/bin/env bash
# pre-publish.sh — Comprehensive pre-publish checks
set -euo pipefail

CRATE="${1:?Usage: pre-publish.sh <crate-name>}"
DIR="$HOME/Development/$CRATE"
cd "$DIR"

echo "=== Pre-publish: $CRATE ==="
PASS=0; FAIL=0

check() {
  if eval "$2" >/dev/null 2>&1; then
    echo "$1"; ((PASS++))
  else
    echo "$1"; ((FAIL++))
  fi
}

check "On main branch"       '[ "$(git branch --show-current)" = "main" ]'
check "Clean working tree"   '[ -z "$(git status --porcelain)" ]'
check "No unpushed commits"  '[ -z "$(git log @{u}..HEAD --oneline)" ]'
check "Tests pass"           'cargo test --quiet'
check "Clippy clean"         'cargo clippy -- -D warnings'
check "Docs build"           'cargo doc --no-deps --quiet'
check "Dry run succeeds"     'cargo publish --dry-run --quiet'
check "Semver OK"            'cargo semver-checks'

echo ""
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] && echo "Ready to publish! 🚀" || echo "Fix issues first."

Tying It Together with Just

A justfile at the workspace root makes these scripts easy to invoke:

# Workspace-level justfile

# Show repo health across all projects
status:
    ./scripts/repo-status.sh

# Compare local vs published crate versions
crate-versions:
    ./scripts/crate-versions.sh

# Cross-crate dependency check
dep-check:
    ./scripts/dep-check.sh

# Run all tests and show summary
test-summary:
    ./scripts/test-summary.sh

# Quick test count (no compile)
test-summary-quick:
    ./scripts/test-summary.sh --quick

# Full dashboard
dashboard:
    ./scripts/crate-versions.sh
    @echo ""
    ./scripts/repo-status.sh

# Pre-publish checks for a specific crate
pre-publish crate:
    ./scripts/pre-publish.sh {{crate}}

# Show publish order and what needs publishing
publish-cascade:
    ./scripts/publish-cascade.sh

# Delete merged branches (dry run)
clean-branches:
    ./scripts/clean-stale-branches.sh

# Delete merged branches (for real)
clean-branches-execute:
    ./scripts/clean-stale-branches.sh --execute

Now just dashboard gives a full workspace overview, and just pre-publish rfconversions runs all checks before you publish.

Key Patterns

Fail fast, fix early. Run just dashboard before starting work. Catch stale branches, unpushed commits, and version mismatches before they compound.

Topological publishing. Always publish leaf crates first. The publish-cascade script encodes this order so you don't have to remember it.

Quick vs. full modes. Test counting without compilation gives a fast estimate. Save full test runs for pre-publish validation.

Color in terminals, clean in logs. Use ANSI colors in interactive scripts but strip them when piping to files or CI. The tput command or [ -t 1 ] check helps:

if [ -t 1 ]; then
  RED='\033[0;31m'; RESET='\033[0m'
else
  RED=''; RESET=''
fi

Idempotent operations. Every script can run multiple times safely. Status checks don't modify anything. The branch cleaner defaults to dry-run mode.

Building Your Own

Start with the problem that annoys you most. If you're constantly running git status across repos, build the status dashboard first. If you keep publishing crates in the wrong order, build the cascade script first.

Each script here is under 100 lines. They compose through the justfile. The whole system grew organically — one script at a time, each solving a real friction point.