Przejdź do głównej zawartości

Wzorce programowania w Rust

Wzorce Rust dla agentów AI to reguły, prompty i bramki, dzięki którym Claude Code, Codex i Cursor tworzą usługi na Axum, SQLx i Tokio akceptowane przez kompilator bez zalewu .clone(): jawny ownership, jeden typ błędu thiserror mapowany na HTTP, query_as! sprawdzane na prawdziwym schemacie i czyste zamykanie. Kod agenta przyjmuje się na podstawie wyników clippy, testów, SQLx i testów mutacyjnych.

Prosisz agenta o endpoint użytkowników. Dostajesz kod, który klonuje każdy String, żeby uciszyć borrow checker, rejestruje trasę /users/:id (na axum 0.8 panikuje przy starcie) i pisze query_as!, którego lista SELECT nie zgadza się ze strukturą. Potem pada CI, bo nie ma tam DATABASE_URL. Każda poprawka to kolejna runda, a i tak kończysz, czytając każdą linijkę.

Ta receptura jest dla programistów, którzy budują backendy w Rust z agentem. Ograniczenia, na których zależy kompilatorowi, trafiają od razu do promptów, a wynik udowadnia toolchain, a nie Twoje oczy.

  • Jeden blok reguł Rust w AGENTS.md, którego przestrzegają Claude Code, Codex i Cursor, oraz bramkę make check, którą agent musi przejść, zanim zgłosi „gotowe”
  • Sześć promptów do wklejenia: refaktoryzacja ownershipu, kanoniczny model domeny, repozytorium SQLx świadome schematu, typ błędu mapowany na HTTP, graceful shutdown oraz testy bazodanowe i property-based
  • Konfigurację serwera Postgres MCP tylko do odczytu, żeby agent czytał Twój schemat, zanim napisze zapytanie
  • Tabelę weryfikacji, która mówi, która bramka łapie który błąd, więc czytasz wynik bramki zamiast diffu
  • Sposoby wyjścia z siedmiu typowych awarii Rust pisanego przez agenta

Przygotuj konwencje Rust i bramkę, którą agent musi przejść

Dział zatytułowany „Przygotuj konwencje Rust i bramkę, którą agent musi przejść”

Zanim poprosisz o jakikolwiek kod, napisz raz na repozytorium trzy pliki: przypięcie toolchaina, manifest workspace’u z aktualnymi wersjami crate’ów i plik reguł z jedną definicją „gotowe”.

  1. Przypnij toolchain w rust-toolchain.toml. 26 września 2026 aktualne wydanie stabilne to Rust 1.98.1 (według manifestu kanału stable na static.rust-lang.org). Przypięcie powstrzymuje agenta przed zakładaniem starszego kompilatora, a przy SQLx ma to znaczenie: sqlx 0.9.0 deklaruje na crates.io rust-version = 1.94.

    [toolchain]
    channel = "1.98.1"
    components = ["rustfmt", "clippy", "rust-analyzer"]
  2. Zadeklaruj stos raz, w Cargo.toml workspace’u. Wersje to najnowsze na crates.io z 26 września 2026. Wymagania z daszkiem pozwalają cargo update podnosić wydania poprawkowe; dla crate’a 0.x, takiego jak "0.9", daszek nie wychodzi poza 0.9.*.

    [workspace]
    members = ["api", "db"]
    resolver = "3"
    [workspace.package]
    edition = "2024"
    [workspace.dependencies]
    axum = "0.8"
    tokio = { version = "1", features = ["full"] }
    tokio-util = { version = "0.7", features = ["rt"] } # "rt" enables TaskTracker
    serde = { version = "1", features = ["derive"] }
    serde_json = "1"
    sqlx = { version = "0.9", default-features = false, features = [
    "runtime-tokio", "tls-rustls", "postgres", "macros", "migrate", "chrono",
    ] }
    chrono = { version = "0.4", features = ["serde"] }
    thiserror = "2"
    anyhow = "1"
    tracing = "0.1"
    argon2 = "0.6"
    proptest = "1"

    Łatwo przeoczyć dwie flagi. Bez chrono w sqlx pole DateTime<Utc> nie skompiluje się w query_as!. Bez rt w tokio-util nie istnieje TaskTracker.

  3. Dodaj bramkę jako cel make check. Potrzebuje DATABASE_URL wskazującego na lokalny, deweloperski Postgres, bo zarówno cargo sqlx prepare --check, jak i #[sqlx::test] łączą się z serwerem.

    .PHONY: check
    check:
    cargo fmt --all -- --check
    cargo clippy --workspace --all-targets -- -D warnings
    cargo sqlx prepare --workspace --check
    cargo nextest run --workspace
    cargo test --workspace --doc
    cargo deny check

    Zainstaluj narzędzia raz, w terminalu, a potem wygeneruj startowy deny.toml:

    Okno terminala
    cargo install --locked cargo-nextest cargo-deny sqlx-cli cargo-mutants
    cargo deny init

    cargo nextest nie uruchamia doctestów, dlatego bramka wywołuje osobno cargo test --doc. cargo deny check jednym poleceniem sprawdza zgłoszenia RustSec, licencje oraz zakazane i zdublowane crate’y.

  4. Umieść reguły Rust w AGENTS.md w katalogu głównym repozytorium. Codex czyta ten plik natywnie; zakładki poniżej pokazują, jak ten sam tekst trafia do Claude Code i Cursora.

    ## Rust conventions
    - Edition 2024, toolchain from rust-toolchain.toml. Stack: axum 0.8 + sqlx 0.9
    (Postgres) + tokio. Do not add actix-web, diesel, rocket, or sea-orm.
    - axum 0.8 routes use `/{id}`, never `/:id`. Extractors implement
    FromRequestParts without #[async_trait].
    - Borrow by default (&str, &[T]). Take ownership only when the callee stores the
    value. Never add .clone(), Arc, or Rc just to satisfy the borrow checker;
    change the signature and explain why instead.
    - Every fallible function returns Result<T, AppError>. No .unwrap() or .expect()
    outside #[cfg(test)] and main().
    - SQL uses sqlx::query_as! / query! macros only. After any query or migration
    change, run `cargo sqlx prepare --workspace` and commit .sqlx/.
    - CPU-heavy or blocking work (password hashing, std::fs) runs in
    tokio::task::spawn_blocking.
    - Never delete or loosen an existing test, and never add #[ignore], to reach
    green. If a test looks wrong, stop and say why.
    - Done means `make check` passes. Paste its last 20 lines in your final message.

Najwięcej ważą dwie ostatnie reguły. Pierwsza nie pozwala agentowi osłabiać testów, żeby dojść do zielonego wyniku; w artykule chroń wyrocznię wyjaśniamy, dlaczego to najczęstszy sposób, w jaki praca agenta udaje sukces. Druga zamienia „kompiluje się” w dowód, który przeczytasz w dziesięć sekund.

Plik reguł i prompty są takie same we wszystkich narzędziach. Różni się to, jak każde z nich ładuje reguły, formatuje kod i recenzuje wynik.

Utwórz CLAUDE.md, który importuje wspólny plik. Import @path wstawia jego treść do kontekstu przy starcie:

@AGENTS.md

Od wersji 2.1.277 (kanał latest 26 września 2026) Claude Code sam czyta AGENTS.md, gdy projekt nie ma CLAUDE.md. Import działa na każdym kanale.

Formatuj każdy plik Rust edytowany przez agenta hookiem PostToolUse w .claude/settings.json. Przekaż --edition 2024, bo rustfmt uruchomiony na pojedynczym pliku nie odczytuje edycji z Cargo.toml:

{
"hooks": {
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "jq -r '.tool_input.file_path' | grep '\\.rs$' | xargs -r rustfmt --edition 2024"
}
]
}
]
}
}

Daj agentowi nawigację świadomą typów i diagnostykę kompilatora przez plugin rust-analyzer-lsp z oficjalnego marketplace’u Anthropic. Plugin tylko rejestruje serwer; binarkę dostarcza komponent rust-analyzer z kroku 1:

Okno terminala
claude plugin install rust-analyzer-lsp@claude-plugins-official

Zanim otworzysz pull request, uruchom w sesji /code-review, żeby dostać drugą opinię o diffie.

Zacznij w każdym narzędziu od jego domyślnego modelu i podnieś poziom effort, zanim zmienisz model; aktualne domyślne modele znajdziesz w przeglądzie modeli.

Zbuduj usługę na Axum i SQLx z agentem krok po kroku

Dział zatytułowany „Zbuduj usługę na Axum i SQLx z agentem krok po kroku”

Każdy krok to jeden prompt, fragment dobrej odpowiedzi i bramka, która ją udowadnia. Wykonuj je po kolei: późniejsze prompty zakładają, że model i repozytorium już istnieją.

Jak powstrzymać agenta przed obchodzeniem borrow checkera klonowaniem?

Dział zatytułowany „Jak powstrzymać agenta przed obchodzeniem borrow checkera klonowaniem?”

Gdy agent trafia na błąd ownershipu, domyślnie naprawia go przez .clone() albo Arc::new. Oba sposoby się kompilują i oba ukrywają pytanie projektowe, które zadawał błąd. Nazwij historię ownershipu, jakiej chcesz, i zabroń drogi na skróty.

Dobra odpowiedź zmienia sygnaturę, a nie dane w miejscach wywołania:

// Before: consumes the vector, so callers clone it first.
pub fn active_emails(users: Vec<User>) -> Vec<String> {
users.into_iter().filter(|u| u.active).map(|u| u.email).collect()
}
// After: borrows; the returned &str values live as long as `users`.
pub fn active_emails(users: &[User]) -> Vec<&str> {
users.iter().filter(|u| u.active).map(|u| u.email.as_str()).collect()
}

Nie musisz sprawdzać lifetime’ów wzrokiem: jeśli pożyczka jest błędna, build się nie uda. Kompilator nie powie Ci natomiast, czy agent przemycił klonowanie gdzie indziej, więc policz klony w diffie (zobacz sekcję o weryfikacji).

Jak przypiąć model domeny, żeby query_as! się kompilowało?

Dział zatytułowany „Jak przypiąć model domeny, żeby query_as! się kompilowało?”

Rust pisany przez agenta często się rozjeżdża: CreateUser ma trzy pola w jednym pliku i dwa w innym, a query_as! nie przechodzi, bo lista SELECT pomija kolumnę zadeklarowaną w strukturze. Zdefiniuj typy raz i powiedz agentowi, że to jedyne dozwolone definicje.

use chrono::{DateTime, Utc};
/// One row of the `users` table, field for field.
#[derive(Debug, Clone, serde::Serialize)]
pub struct User {
pub id: i64,
pub email: String,
pub name: String,
pub active: bool,
#[serde(skip_serializing)]
pub password_hash: String,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
/// Request body for creation. Carries the plaintext password; the repository hashes it.
#[derive(Debug, serde::Deserialize)]
pub struct CreateUser {
pub email: String,
pub name: String,
pub password: String,
}

#[serde(skip_serializing)] przy password_hash to linijka, która trzyma hash z dala od każdej odpowiedzi JSON. Umieść ją w typie kanonicznym, żeby żaden handler nie mógł o niej zapomnieć.

Jak dać agentowi prawdziwy schemat, zanim napisze zapytania SQLx?

Dział zatytułowany „Jak dać agentowi prawdziwy schemat, zanim napisze zapytania SQLx?”

query_as! sprawdza każde zapytanie na żywej bazie w czasie kompilacji, więc zgadnięta nazwa kolumny przerywa kompilację. Agent zgaduje, bo nie widzi Twojego schematu. Serwer Postgres MCP tylko do odczytu pozwala mu najpierw obejrzeć prawdziwe tabele, więc zapytanie pasuje za pierwszym podejściem, a nie za czwartym.

Ta receptura używa Postgres MCP Pro, pakietu Pythona (postgres-mcp 0.3.0 na PyPI, Python 3.12 lub nowszy) uruchamianego przez uvx. Flaga --access-mode=restricted ogranicza go do transakcji tylko do odczytu. Narzędzia różnią się wyłącznie miejscem, w którym trzymają konfigurację.

Okno terminala
claude mcp add postgres -e DATABASE_URI=postgresql://localhost/myapp_dev \
-- uvx postgres-mcp --access-mode=restricted

Domyślny zakres to local: serwer jest zapisany dla tego projektu w Twojej konfiguracji użytkownika, a nie w repozytorium. Dodaj -s project tylko wtedy, gdy chcesz go mieć we wspólnym .mcp.json, i nigdy nie commituj connection stringa z hasłem. Połączenie sprawdzisz przez claude mcp list.

Rdzeń dobrej odpowiedzi:

pub async fn find_by_email(&self, email: &str) -> Result<Option<User>> {
let user = sqlx::query_as!(
User,
"SELECT id, email, name, active, password_hash, created_at, updated_at \
FROM users WHERE email = $1",
email
)
.fetch_optional(&self.pool)
.await?;
Ok(user)
}

fetch_optional ma znaczenie: fetch_one dla brakującego użytkownika zwraca sqlx::Error::RowNotFound, który następna sekcja musi zamienić na 404 zamiast 500. Krok cargo sqlx prepare --workspace --check w bramce kończy się błędem, gdy zacommitowane metadane w .sqlx/ przestają pasować do zapytań. To właśnie utrzymuje CI zdolne do budowania z SQLX_OFFLINE=true.

Jak mapować błędy na kody HTTP, nie ujawniając szczegółów wewnętrznych?

Dział zatytułowany „Jak mapować błędy na kody HTTP, nie ujawniając szczegółów wewnętrznych?”

Zdefiniuj enum błędów raz, przez thiserror, a potem zaimplementuj IntoResponse, żeby axum zamieniał każdy wariant na kod statusu. W kodzie agenta powtarzają się dwa błędy: RowNotFound zwracany jako 500 i surowy tekst błędu bazy wysyłany klientowi.

use axum::{http::StatusCode, response::{IntoResponse, Response}, Json};
#[derive(Debug, thiserror::Error)]
pub enum AppError {
#[error("{0} not found")]
NotFound(&'static str),
#[error("validation failed: {0}")]
Validation(String),
#[error("{0} already exists")]
Conflict(&'static str),
#[error("unauthorized")]
Unauthorized,
#[error(transparent)]
Database(#[from] sqlx::Error),
#[error(transparent)]
Internal(#[from] anyhow::Error),
}
pub type Result<T> = std::result::Result<T, AppError>;
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let status = match &self {
AppError::NotFound(_) | AppError::Database(sqlx::Error::RowNotFound) => StatusCode::NOT_FOUND,
AppError::Validation(_) => StatusCode::BAD_REQUEST,
AppError::Conflict(_) => StatusCode::CONFLICT,
AppError::Unauthorized => StatusCode::UNAUTHORIZED,
AppError::Database(_) | AppError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
};
// Log the detail; send the client a generic message for every 5xx.
let message = if status.is_server_error() {
tracing::error!(error = ?self, "request failed");
"internal error".to_string()
} else {
self.to_string()
};
(status, Json(serde_json::json!({ "error": message }))).into_response()
}
}

Dowodem są testy handlerów. Nie przejdą, jeśli wariant trafi na zły status albo jeśli tekst z bazy dotrze do klienta, a Ty nie musisz czytać into_response, żeby się o tym dowiedzieć.

Poproś o „graceful shutdown”, a agent często zbuduje własny kanał oneshot: serwer przestaje przyjmować połączenia, ale uruchomione zadania w tle działają dalej i nic na nie nie czeka. Do żądań użyj with_graceful_shutdown z axum, a do całej reszty CancellationToken i TaskTracker z tokio-util.

axum::serve(listener, app)
.with_graceful_shutdown(shutdown_signal())
.await?;
token.cancel(); // tell background workers to stop taking new work
tracker.close(); // no new tasks may be spawned
if tokio::time::timeout(Duration::from_secs(10), tracker.wait()).await.is_err() {
tracing::warn!("background tasks did not finish within 10s");
}

Dowodem jest test integracyjny: nie przejdzie, jeśli nikt nie czeka na tracker, bo zamykanie kończy się przed zakończeniem zadania, a asercja ukończenia widzi niedokończone zadanie.

Jak generować testy Rust, które łapią prawdziwe błędy?

Dział zatytułowany „Jak generować testy Rust, które łapią prawdziwe błędy?”

Nazwij przypadki wprost, inaczej dostaniesz same testy happy path. Potem dodaj jeden test na prawdziwym Postgresie, bo zamockowane repozytorium nie wyłapie złej kolumny ani brakującego indeksu unikalnego. #[sqlx::test] tworzy dla każdego testu izolowaną bazę i automatycznie stosuje ./migrations, gdy włączona jest flaga migrate.

Prośba o listę przypadków przed kodem daje Ci jedyną rzecz wartą przeczytania: listę testów. Więcej o projektowaniu testów z agentem znajdziesz w artykułach o testach integracyjnych i testach property-based.

Jak zweryfikować Rust napisany przez agenta bez czytania każdej linijki?

Dział zatytułowany „Jak zweryfikować Rust napisany przez agenta bez czytania każdej linijki?”

Czytaj wynik bramki i listę testów, nie diff. Kompilator już wyklucza błędy bezpieczeństwa pamięci i wyścigi danych w bezpiecznym kodzie, więc uwaga człowieka idzie tam, czego on nie widzi: do logiki, mapowania błędów i tego, czy testy zauważą regresję.

BramkaPolecenieCo łapie
Formatowaniecargo fmt --all -- --checkNiesformatowane pliki, które ominął hook
Lintcargo clippy --workspace --all-targets -- -D warningsZbędne pożyczki i konwersje, typowe linty poprawności i wydajności; .unwrap() w kodzie produkcyjnym po włączeniu clippy::unwrap_used
Metadane zapytańcargo sqlx prepare --workspace --checkNieaktualne .sqlx/, które psuje buildy z SQLX_OFFLINE w CI
Testycargo nextest run --workspace i cargo test --docZłe zachowanie, złe kody statusu, brakujące ograniczenia
Łańcuch dostawcargo deny checkOstrzeżenia bezpieczeństwa RustSec, niedozwolone licencje, zdublowane lub zakazane crate’y
Siła testówcargo mutants --in-diff pr.diffZmieniony kod, którego testy przechodzą, nawet gdy kod jest zepsuty
Liczba klonówgit diff main -- '*.rs' | grep -c '^+.*\.clone()'Klony dodane przez agenta, żeby obejść borrow checker
Agent recenzujący/code-review (Claude Code), codex review --base main (Codex), Bugbot (Cursor)Błędy logiki, których bramki nie potrafią wyrazić

Plik pr.diff wygenerujesz przez git diff main > pr.diff. Testy mutacyjne ważą tu najwięcej: zielony zestaw dowodzi tylko tego, że testy przechodzą, a cargo mutants pokazuje, czy nie przejdą, gdy zmieniony kod zostanie zepsuty. Pierwsze pięć bramek uruchamiaj w CI przy każdym pull requeście (plik workflow znajdziesz we wzorcach pipeline’ów CI/CD), a testy mutacyjne na diffie.

Inżynier odpowiedzialny za usługę akceptuje cztery rzeczy: listę przypadków testowych, zieloną bramkę, raport mutacyjny bez przeoczonych mutantów w zmienionym kodzie i liczbę klonów, którą potrafi uzasadnić. Ludzkie czytanie trafia do fragmentów oznaczonych przez agenta recenzującego oraz do każdego kodu z unsafe, spawn_blocking albo transakcjami. Jak podzielić tę pracę w zespole, opisuje recenzja PR z agentem.

Każda z poniższych awarii przetrwa pobieżny rzut oka na diff. Każda ma objaw, który wykryjesz, i sposób wyjścia, który wkleisz.

  • Zalew klonów i Arc. Objaw: liczba klonów w diffie skacze albo Arc<Mutex<_>> pojawia się wokół danych, których dotyka tylko jedno zadanie. Wyjście: prompt „Remove every .clone() and Arc you added in this branch. For each one, either change the signature to borrow or explain in one line why ownership must move. Done means make check passes.”
  • Przestarzałe idiomy z axum 0.7. Objaw: serwer panikuje przy starcie na trasie typu /users/:id albo ekstraktor ma #[async_trait]. Przyczyna: axum 0.8.0 zmienił składnię ścieżek na /{id} (stara celowo wywołuje panikę), a axum-core zastąpił #[async_trait] przez impl Trait w pozycji zwracanej w traitach. Wyjście: trzymaj regułę o axum w AGENTS.md i użyj promptu „Migrate this crate to axum 0.8: /{param} route syntax, no #[async_trait] on extractors. Change no behaviour.”
  • query_as! pada w CI, ale nie lokalnie. Objaw: set DATABASE_URL to use query macros online, or run cargo sqlx prepare. Przyczyna: agent zmienił zapytanie bez odświeżenia .sqlx/. Wyjście: uruchom cargo sqlx prepare --workspace na bazie deweloperskiej, zacommituj .sqlx/ i ustaw SQLX_OFFLINE=true w CI. Krok --check w bramce nie pozwoli, żeby to się powtórzyło.
  • Runtime async staje pod obciążeniem. Objaw: skoki opóźnień na wszystkich endpointach, gdy jeden z nich jest zajęty. Przyczyna: hashowanie argon2, std::fs albo blokujący klient HTTP wywołany wprost w async fn. Wyjście: prompt „Find every CPU-heavy or blocking call inside an async fn in this workspace and move it into tokio::task::spawn_blocking. List each call you moved.”
  • Błędy wewnętrzne wyciekają do klientów. Objaw: treść odpowiedzi 500 zawiera SQL, nazwę tabeli albo ścieżkę pliku. Przyczyna: self.to_string() używane dla każdego statusu. Wyjście: ogólny komunikat dla 5xx w into_response plus test handlera, który sprawdza, że w treści nie ma tekstu SQL.
  • „Zero-copy”, które kopiuje. Objaw: agent nazywa parser zero-copy, ale ten wywołuje .to_vec() albo zbiera każdą ramkę do Vec<u8>. Wyjście: poproś o bytes::BytesMut::split_to(n).freeze(), które zwraca zliczany referencjami widok Bytes na tę samą alokację, albo o tokio_util::codec::LengthDelimitedCodec zamiast ręcznego ramkowania. Zysk udowodnij benchmarkiem, nie etykietą.
  • Agent osłabia test, żeby dojść do zielonego wyniku. Objaw: poluzowana asercja, usunięty przypadek albo #[ignore] dodane w tym samym diffie co poprawka. Wyjście: przywróć plik testu, powtórz regułę z AGENTS.md i skonfiguruj CI tak, żeby odrzucało pull request, który zmienia, przenosi albo usuwa istniejący test bez etykiety test-change: git diff --name-status --diff-filter=MDR origin/main... -- '*tests/*' | awk -F'\t' '{print $2}' wypisuje te pliki pod ścieżką sprzed zmiany, a pełną kontrolę opisuje artykuł chroń wyrocznię.