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 withrustup)cargo-tarpaulinfor 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
# From your repo rootgit clone https://github.com/pace-framework-org/pace-framework-starter pacecd pacepython -m venv .venv && source .venv/bin/activatepip install PyYAML jsonschema anthropic2 — 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-6Test command variants
| Scenario | test_command |
|---|---|
| All tests (serialised) | cargo test -- --test-threads=1 |
| Specific module | cargo test domain::product -- --test-threads=1 |
| Integration tests only | cargo test --test '*' |
| Show println! output | cargo test -- --nocapture |
| Doc tests only | cargo 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. Neverunwrap()in production code” — prevents FORGE from writing code that panics on unexpected input, which SENTINEL will flag. - “Domain types in
src/domain/. Repository traits insrc/ports/. Implementations insrc/adapters/” — establishes the hexagonal architecture layout so FORGE puts files in the right modules. - “All error types derive
thiserror::Error. Useanyhow::Errorin 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
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
cd pacepython pace/orchestrator.py --day 1What FORGE does with Rust
FORGE’s tool-calling loop will:
- Read
Cargo.tomlto understand crate dependencies, features, and workspace structure - Read
src/lib.rsto understand module declarations and public surface - Write
.rsfiles using idiomatic Rust:derivemacros, trait implementations,Resulterror handling - Run
cargo clippy -- -D warningsto catch common mistakes and style violations - Run
cargo testand readFAILEDoutput 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, orunsafeblocks 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::readorstd::fs::File::opencalled with user-provided paths without sanitisation - HTTP: outbound HTTP clients (
reqwest,hyper) configured without TLS
7 — Test coverage
cargo-tarpaulin
Install tarpaulin:
cargo install cargo-tarpaulinUpdate 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:
cargo install cargo-llvm-covtech: 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 design9 — 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.
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 onNoneorErrin production paths. Remediation is to propagate with?or convert to a typed error.unsafeblocks without safety comments: SENTINEL flags anyunsafeblock that lacks a// SAFETY:comment explaining the invariant being upheld. Remediation is to add the comment or remove theunsafeblock.
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.