Skip to content

PACE with Rust

This tutorial shows how to configure PACE for a Rust project. FORGE will write idiomatic Rust code using Result types, trait-based dependency injection, and #[cfg(test)] unit test blocks. GATE will run cargo test, and SENTINEL will audit for unsafe code, integer overflow risks, and secrets in source.

Prerequisites

  • Rust 1.76 or later (2021 edition)
  • cargo (included with rustup)
  • cargo-tarpaulin for coverage reporting (optional — install separately)
  • An API key for your LLM provider

Project layout assumed by this tutorial

my-service/
├── pace/
│ └── pace.config.yaml
├── src/
│ ├── lib.rs
│ └── domain/
├── tests/ ← integration tests
├── Cargo.toml
└── .pace/

1 — Install PACE

Terminal window
# From your repo root
git clone https://github.com/pace-framework-org/pace-framework-starter pace
cd pace
python -m venv .venv && source .venv/bin/activate
pip install PyYAML jsonschema anthropic

2 — Configure for Rust

Edit pace/pace.config.yaml:

framework_version: "1.0"
product:
name: "My Service"
description: >
A Rust service (2021 edition). Domain types in src/domain/. Repository traits
in src/ports/. Implementations in src/adapters/. Use Result<T, E> for all
fallible operations — never unwrap() in production code. Error types derive
thiserror::Error. Use anyhow::Error in the application layer. Dependency
injection via Arc<dyn Trait>. No global mutable state.
Code must pass cargo clippy -- -D warnings. Never use unwrap() or expect()
outside of tests.
github_org: "my-org"
sprint:
duration_days: 14
source:
dirs:
- name: "src"
path: "src/"
language: "Rust"
description: "Domain types, repository traits (ports), and service implementations"
- name: "tests"
path: "tests/"
language: "Rust"
description: "Integration tests that link against the crate root"
tech:
primary_language: "Rust 1.76 (2021 edition)"
test_command: "cargo test -- --test-threads=1"
build_command: "cargo clippy -- -D warnings && cargo fmt --check"
platform:
ci: local
llm:
provider: anthropic
model: claude-sonnet-4-6

Test command variants

Scenariotest_command
All tests (serialised)cargo test -- --test-threads=1
Specific modulecargo test domain::product -- --test-threads=1
Integration tests onlycargo test --test '*'
Show println! outputcargo test -- --nocapture
Doc tests onlycargo test --doc

3 — FORGE hints for Rust

The product.description field steers FORGE toward idiomatic Rust. Include the following guidance:

product:
description: >
A Rust service using the 2021 edition. Follow these conventions:
Use Result<T, E> for all fallible operations. Never unwrap() in production code.
Domain types in src/domain/. Repository traits in src/ports/. Implementations
in src/adapters/.
All error types derive thiserror::Error. Use anyhow::Error in the application layer.
Tests in the same file under #[cfg(test)] mod tests {}. Integration tests in tests/.
Use Arc<dyn Trait> for dependency injection. No global state.

Key hints and why they matter:

  • “Use Result<T, E> for all fallible operations. Never unwrap() in production code” — prevents FORGE from writing code that panics on unexpected input, which SENTINEL will flag.
  • “Domain types in src/domain/. Repository traits in src/ports/. Implementations in src/adapters/ — establishes the hexagonal architecture layout so FORGE puts files in the right modules.
  • “All error types derive thiserror::Error. Use anyhow::Error in the application layer” — keeps error handling consistent across the codebase.
  • “Tests in the same file under #[cfg(test)] mod tests {} — separates unit tests (co-located with code) from integration tests (tests/ directory).
  • “Use Arc<dyn Trait> for dependency injection. No global state” — makes services testable with mock implementations.

4 — Set credentials

Terminal window
export ANTHROPIC_API_KEY="sk-ant-..."

5 — Write a Rust sprint plan

Create plan.yaml at your repo root:

release: v1.0
stories:
- id: story-1
title: "Product domain type, repository trait, and in-memory implementation"
status: pending
acceptance_criteria:
- "Product struct has id (Uuid), name (String), price (f64), and stock (u32) fields"
- "ProductRepository trait defines create(), find_by_id(), list(), and update() methods"
- "InMemoryProductRepository implements ProductRepository using HashMap protected by RwLock"
- "Tests cover concurrent access using Arc<InMemoryProductRepository> with multiple threads"
- "cargo test exits 0"
out_of_scope:
- "HTTP handlers"
- "Database persistence"
- id: story-2
title: "CatalogService with validation and custom error types"
status: pending
acceptance_criteria:
- "create_product() returns Err(CatalogError::InvalidPrice) for price <= 0.0"
- "restock() returns Err(CatalogError::ProductNotFound) for an unknown id"
- "CatalogError enum derives thiserror::Error with human-readable Display messages"
- "Table-style tests cover all success and error paths"
- "cargo test exits 0"
- id: story-3
title: "Axum HTTP handlers for GET /products and POST /products"
status: pending
acceptance_criteria:
- "GET /products returns 200 with a JSON array of products"
- "POST /products returns 201 with a Location header pointing to the new resource"
- "POST /products returns 422 for a request body with invalid price"
- "Integration tests use axum::test_helpers or reqwest with a live test server"
- "cargo test exits 0"

6 — Run Day 1

Terminal window
cd pace
python pace/orchestrator.py --day 1

What FORGE does with Rust

FORGE’s tool-calling loop will:

  1. Read Cargo.toml to understand crate dependencies, features, and workspace structure
  2. Read src/lib.rs to understand module declarations and public surface
  3. Write .rs files using idiomatic Rust: derive macros, trait implementations, Result error handling
  4. Run cargo clippy -- -D warnings to catch common mistakes and style violations
  5. Run cargo test and read FAILED output to self-correct, paying attention to lifetime and borrow-checker errors

A typical FORGE run for Day 1:

[FORGE] Reading Cargo.toml ...
[FORGE] Reading src/lib.rs ...
[FORGE] Writing src/domain/product.rs ...
[FORGE] Writing src/ports/product_repository.rs ...
[FORGE] Writing src/adapters/memory.rs ...
[FORGE] Running build: cargo clippy -- -D warnings && cargo fmt --check ...
[FORGE] Running tests: cargo test -- --test-threads=1 ...
[FORGE] All tests pass. Calling complete_handoff.

What SENTINEL checks for Rust

SENTINEL applies Rust-specific checks:

  • Unsafe blocks: unchecked pointer arithmetic, misuse of std::mem::transmute, or unsafe blocks without a // SAFETY: comment
  • Integer overflow: arithmetic operations without overflow checks in release mode (debug mode panics; release mode wraps silently)
  • Secrets: hardcoded tokens, API keys, or private key material in source files
  • Dependency audit: known-vulnerable crate versions flagged by pattern matching against published advisories
  • unwrap()/expect() on user-controlled input: panics in request handlers or parsers that process external data
  • Path traversal: std::fs::read or std::fs::File::open called with user-provided paths without sanitisation
  • HTTP: outbound HTTP clients (reqwest, hyper) configured without TLS

7 — Test coverage

cargo-tarpaulin

Install tarpaulin:

Terminal window
cargo install cargo-tarpaulin

Update test_command in pace.config.yaml to enforce a minimum coverage threshold:

tech:
test_command: "cargo tarpaulin --out Stdout --fail-under 80"

cargo-llvm-cov (faster, cross-platform)

For macOS and Windows CI where tarpaulin is unreliable, use cargo-llvm-cov:

Terminal window
cargo install cargo-llvm-cov
tech:
test_command: "cargo llvm-cov --summary-only --fail-under-lines 80"

8 — Linter and formatter

Run both clippy and cargo fmt as the build_command:

tech:
build_command: "cargo clippy -- -D warnings && cargo fmt --check"

Configure clippy lints in Cargo.toml to warn on unwrap() and expect() usage:

[lints.clippy]
unwrap_used = "warn"
expect_used = "warn"

Add the clippy constraint to product.description so FORGE writes conformant code from the first attempt:

product:
description: >
... Code must pass cargo clippy -- -D warnings. Never use unwrap() or
expect() outside of tests. ...

If FORGE-generated code triggers a clippy lint that is a known false positive for your project, add it to the allow list in Cargo.toml and document the exception in product.description:

[lints.clippy]
too_many_arguments = "allow" # domain constructors have many fields by design

9 — Integration test patterns

Rust integration tests live in the tests/ directory and link against the crate as an external consumer. They can only call public API surface, which makes them a good proxy for how real callers will use the library.

tests/catalog_integration.rs
use my_service::{CatalogService, InMemoryProductRepository};
use std::sync::Arc;
#[test]
fn create_and_retrieve_product() {
let repo = Arc::new(InMemoryProductRepository::new());
let service = CatalogService::new(repo.clone());
let product = service.create_product("Widget", 9.99, 100).unwrap();
assert_eq!(service.find_by_id(product.id).unwrap().name, "Widget");
}

10 — Mock patterns

Use the mockall crate to generate mock implementations of repository traits for unit testing:

[dev-dependencies]
mockall = "0.12"
use mockall::mock;
mock! {
ProductRepo {}
impl ProductRepository for ProductRepo {
fn create(&self, product: Product) -> Result<Product, RepositoryError>;
fn find_by_id(&self, id: Uuid) -> Result<Option<Product>, RepositoryError>;
}
}
#[test]
fn service_returns_error_for_unknown_product() {
let mut mock = MockProductRepo::new();
mock.expect_find_by_id().returning(|_| Ok(None));
let service = CatalogService::new(Arc::new(mock));
assert!(service.find_by_id(Uuid::new_v4()).is_err());
}

11 — Multi-day sprint

PACE carries context between days through .pace/context/engineering.md. After Day 1 ships, FORGE reads this file to understand what domain types, traits, and implementations are already in place before writing Day 2 code.

A typical Day 2 start:

[FORGE] Reading .pace/context/engineering.md ...
[FORGE] Found: src/domain/product.rs (Product struct, ProductRepository trait)
[FORGE] Found: src/adapters/memory.rs (InMemoryProductRepository)
[FORGE] Writing src/services/catalog.rs ...
[FORGE] Writing tests/catalog_service_tests.rs ...

FORGE builds CatalogService against the ProductRepository trait it already knows, injects the concrete implementation via Arc<dyn ProductRepository>, and writes tests that mock the trait — without touching the Day 1 code that GATE already verified.

12 — Advisory clearance story

Once SENTINEL has flagged advisories across earlier stories, add a dedicated clearance story to the sprint plan:

- id: story-5
title: "Security and DevOps advisory clearance"
status: pending
acceptance_criteria:
- "All open SENTINEL advisories resolved or escalated with written justification"
- "All CONDUIT advisories resolved or escalated"
- "cargo test exits 0 after any remediation"

Common Rust SENTINEL advisories that end up in clearance stories:

  • unwrap() in non-test code: panics on None or Err in production paths. Remediation is to propagate with ? or convert to a typed error.
  • unsafe blocks without safety comments: SENTINEL flags any unsafe block that lacks a // SAFETY: comment explaining the invariant being upheld. Remediation is to add the comment or remove the unsafe block.

13 — CI build caching

Cache Cargo’s registry index and compiled artifacts to avoid recompiling all dependencies on every run:

- uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-

14 — Workspace setups (multi-crate)

For Cargo workspaces where each bounded context is a separate crate, scope PACE to the crate being sprinted:

source:
dirs:
- name: "catalog"
path: "crates/catalog/src/"
language: "Rust"
description: "Catalog bounded context — product domain, repository traits, service"
tech:
test_command: "cargo test -p catalog -- --test-threads=1"
build_command: "cargo clippy -p catalog -- -D warnings"

The -p catalog flag runs only the workspace member named catalog, keeping FORGE’s feedback loop fast even in large monorepos with many crates.

15 — Windows notes

On Windows, cargo and rustup work natively without WSL. The --test-threads=1 flag in test_command prevents file system conflicts from parallel tests on case-insensitive Windows paths. If the single-dash form of cargo fmt flags causes parsing errors in your shell, use the double-dash form: cargo fmt -- --check.

Common issues

cargo: command not found Ensure ~/.cargo/bin is on PATH. If you installed Rust via rustup, run source ~/.cargo/env or add the export to your shell profile. On GitHub Actions, the dtolnay/rust-toolchain action handles this automatically.

FORGE generates code that does not compile due to lifetime errors Add to product.description: “All lifetimes must be explicit when the compiler cannot infer them. Prefer owned types (String, Vec<T>) over references in struct fields.” Owned types eliminate most lifetime inference failures in generated code.

Tests fail due to shared state under --test-threads=1 Even with serialised execution, tests that modify global state (environment variables, static mutables) can interfere. Use per-test state only. Add to product.description: “No global mutable state. All test fixtures are created fresh in each test function.”

cargo clippy fails on FORGE-generated code Add the specific lint to the allow list in Cargo.toml’s [lints.clippy] section and note the exception in product.description so FORGE includes the allow attribute in future files. Avoid suppressing entire lint categories — suppress only the specific named lint.

tarpaulin fails in CI cargo-tarpaulin requires Linux (it uses ptrace). For macOS or Windows CI runners, switch to cargo-llvm-cov as described in the Test coverage section above.