Wzorce rozwoju Rust
Prosisz AI o „zrefaktoryzowanie tej funkcji”, a w odpowiedzi dostajesz kod, który rozsiewa .clone() w każdej linijce, wymyśla API tokio, które nie istnieje, albo pisze makro query_as! przeciwko strukturze, której pola nie pasują do kolumn z SELECT. Borrow checker to odrzuca, kompilator to odrzuca, a Ty zamiast dowozić, odtwarzasz teraz logikę z wygenerowanego przez AI kodu metodą inżynierii wstecznej. Rust karze za niejasne prompty mocniej niż jakikolwiek inny język: istnieje dokładnie jedna historia ownershipu, którą kompilator zaakceptuje, a „spraw, żeby działało” nią nie jest.
Rozwiązaniem jest promptowanie ograniczeniem, a nie życzeniem. Powiedz agentowi, kto zachowuje ownership, który wariant błędu mapuje się na który status HTTP i której tabeli dotyczy zapytanie — i pozwól mu zweryfikować to przeciwko prawdziwemu kompilatorowi i działającemu schematowi.
Co z tego wyniesiesz
Dział zatytułowany „Co z tego wyniesiesz”- Wzorzec promptowania, który skłania agenta do naprawiania błędów borrow checkera przez zmianę sygnatur, a nie przez klonowanie
- Spójny model
User/CreateUser/AppError, który możesz wrzucić do usługi opartej na Axum + SQLx - Utrzymywany serwer Postgres MCP podpięty tak, by agent czytał Twój prawdziwy schemat, zanim napisze sprawdzane przy kompilacji
query_as! - Trzy lub więcej promptów gotowych do skopiowania — dla ownershipu, propagacji błędów, asynchronicznego wyłączania i parsowania zero-copy
- Listę kontrolną „Gdy to się psuje” obejmującą tryby awarii charakterystyczne dla generowanego przez AI kodu Rust
Przygotowanie: jeden stack, ustalony raz
Dział zatytułowany „Przygotowanie: jeden stack, ustalony raz”Wybierz jeden stack i wpisz go do pliku z regułami, żeby agent przestał mieszać Actix, Diesel i Rocket w jednym projekcie. Ten przepis trzyma się wszędzie Axum + SQLx + Tokio. SQLx (które nie jest ORM-em) jest tu właściwym wyborem, bo jego makro query_as! sprawdza każde zapytanie przeciwko Twojej bazie danych w czasie kompilacji — a to jest dokładnie ten rodzaj zabezpieczenia, który wyłapuje halucynacje AI, zanim trafią na produkcję.
Dodaj plik .cursor/rules/rust.mdc, żeby każda tura agenta dziedziczyła ograniczenia:
---description: Rust backend conventionsalwaysApply: true---- Rust 2021 edition, stable toolchain- Stack: Axum + SQLx (Postgres) + Tokio. Do NOT introduce Actix, Diesel, or Rocket.- Return Result<T, AppError>; never .unwrap()/.expect() outside tests- Use thiserror for the error enum, the `?` operator to propagate- Prefer borrowing (&str, &[T]) over taking ownership unless the callee must store the value- Every SQLx call uses query_as!/query! macros (compile-time checked), never runtime query()Umieść te same ograniczenia w pliku CLAUDE.md w katalogu głównym repozytorium, żeby ładowały się do każdej sesji:
## Rust conventions- Rust 2021, stable. Stack: Axum + SQLx (Postgres) + Tokio only.- Result<T, AppError> everywhere; no .unwrap() outside #[cfg(test)].- thiserror enum + `?` propagation. Borrow by default; take ownership only to store.- SQLx: query_as!/query! macros (compile-checked). Run `cargo sqlx prepare` after schema changes.Następnie pozwól Claude Code zbudować szkielet workspace w terminalu:
claude "Scaffold a Cargo workspace: an `api` crate (Axum) and a `db` crate (SQLx repositories). Add tokio, serde, thiserror, anyhow, tracing, sqlx with the postgres+macros+runtime-tokio features. Use caret version pins, not frozen minors."Codex czyta AGENTS.md. Wrzuć tam te same reguły stacku, a potem steruj nim z poziomu CLI:
codex "Scaffold a Cargo workspace with an Axum `api` crate and a SQLx `db` crate. Add tokio, serde, thiserror, anyhow, tracing, and sqlx (postgres, macros, runtime-tokio). Use caret pins."Codex domyślnie działa w sandboxie workspace-write, więc między edycjami może sam uruchamiać cargo check. Dodaj --ask-for-approval on-failure, jeśli chcesz, żeby pauzował tylko wtedy, gdy jakieś polecenie zwróci błąd.
Powstały Cargo.toml powinien używać przypięć z daszkiem (caret), żeby nie zgnił za pół roku:
[workspace]members = ["api", "db"]
[workspace.dependencies]tokio = { version = "1", features = ["full"] }serde = { version = "1", features = ["derive"] }sqlx = { version = "0.9", features = ["postgres", "macros", "runtime-tokio"] }thiserror = "2" # 2.x keeps the #[error(...)] derive syntax; major bump from 1.xanyhow = "1"tracing = "0.1"Model domeny: zdefiniuj go raz
Dział zatytułowany „Model domeny: zdefiniuj go raz”Większość generowanego przez AI kodu Rust rozsypuje się, bo model się rozjeżdża — CreateUser ma {email, name} w jednym bloku i {email, name, password} trzy bloki dalej, a makro query_as! nie kompiluje się przeciwko strukturze User, której brakuje password_hash. Najpierw zablokuj typy i powiedz agentowi, że to jedyne dozwolone definicje.
use chrono::{DateTime, Utc};
// The full row, matching the `users` table exactly.#[derive(Debug, Clone, sqlx::FromRow, serde::Serialize)]pub struct User { pub id: i64, pub email: String, pub name: String, #[serde(skip_serializing)] pub password_hash: String, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>,}
// Input 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,}Wygrywanie walki z borrow checkerem
Dział zatytułowany „Wygrywanie walki z borrow checkerem”Najbardziej przydatny prompt w Rust to taki, który zakazuje leniwej furtki. Gdy agent natrafia na błąd ownershipu, jego domyślą jest „odklonowanie” problemu przez .clone() albo opakowanie wszystkiego w Arc. Zamiast tego powiedz mu, jakiej historii ownershipu oczekujesz.
Zaznacz problematyczną funkcję, otwórz edycję inline (Cmd/Ctrl+K) i wklej prompt zaczynający się od ograniczenia, a nie „napraw błąd”:
// Before: takes ownership it doesn't need, so the caller loses `data`.fn process_data(data: Vec<String>) -> Vec<String> { data.into_iter().filter(|s| s.len() > 5).collect()}
// After the prompt below: borrows, so the caller keeps ownership.fn process_data(data: &[String]) -> Vec<String> { data.iter().filter(|s| s.len() > 5).cloned().collect()}Skieruj Claude Code na plik i sformułuj wprost niezmiennik ownershipu:
claude "In src/handlers.rs, process_data takes Vec<String> but the caller needs the original afterward. Change it to take &[String] so ownership stays with the caller. Update every call site and explain the one borrow each now needs."Codex może sprawdzić zmianę kompilacją w swoim sandboxie, zanim ją zwróci:
codex "process_data in src/handlers.rs should borrow &[String] instead of consuming Vec<String>, so the caller keeps the value. Update call sites and run `cargo check` to confirm it builds."Obsługa błędów, która mapuje się na HTTP
Dział zatytułowany „Obsługa błędów, która mapuje się na HTTP”Zdefiniuj enum błędów raz, za pomocą thiserror, a potem zaimplementuj IntoResponse, żeby Axum zamieniał każdy wariant na właściwy kod statusu. Pułapka, której trzeba unikać: agent, który łapie sqlx::Error::RowNotFound i zwraca 500 zamiast 404.
use thiserror::Error;use axum::{response::{IntoResponse, Response}, http::StatusCode, Json};
#[derive(Error, Debug)]pub enum AppError { #[error("not found: {0}")] NotFound(String), #[error("validation failed: {0}")] Validation(String), #[error("unauthorized")] Unauthorized, #[error(transparent)] Database(#[from] sqlx::Error),}
pub type Result<T> = std::result::Result<T, AppError>;
impl IntoResponse for AppError { fn into_response(self) -> Response { let status = match self { // RowNotFound is a 404, not a 500 — the bug AI loves to write. AppError::Database(sqlx::Error::RowNotFound) | AppError::NotFound(_) => { StatusCode::NOT_FOUND } AppError::Validation(_) => StatusCode::BAD_REQUEST, AppError::Unauthorized => StatusCode::UNAUTHORIZED, AppError::Database(_) => StatusCode::INTERNAL_SERVER_ERROR, }; (status, Json(serde_json::json!({ "error": self.to_string() }))).into_response() }}Repozytorium SQLx wsparte serwerem Postgres MCP
Dział zatytułowany „Repozytorium SQLx wsparte serwerem Postgres MCP”Oto zmiana w przepływie pracy, która ma największe znaczenie. query_as! jest sprawdzane przy kompilacji przeciwko Twojej działającej bazie danych — więc jeśli agent zgadnie nazwę kolumny, kompilacja padnie. Ale agent zgaduje właśnie dlatego, że nie widzi Twojego schematu. Serwer Postgres MCP zamyka tę lukę: pozwala agentowi przejrzeć prawdziwe tabele zanim napisze choćby jedno makro, dzięki czemu kolumny w SELECT pasują do struktury już za pierwszym razem.
Używaj utrzymywanego serwera. Stary referencyjny serwer Anthropic @modelcontextprotocol/server-postgres został wycofany i zarchiwizowany w lipcu 2025 po ujawnieniu podatności na SQL injection — pomiń go. Ten przepis używa Postgres MCP Pro (crystaldba/postgres-mcp), który jest aktywnie utrzymywany i ma prawdziwy, tylko do odczytu tryb restricted. Działa przez uvx lub Docker (wymagany Python 3.12+ albo Docker) i pobiera connection string ze zmiennej środowiskowej DATABASE_URI.
Wszystkie trzy narzędzia mówią w MCP. Różni się tylko lokalizacja pliku konfiguracyjnego (Cursor: .cursor/mcp.json; Claude Code: .mcp.json; Codex: ~/.codex/config.toml).
.cursor/mcp.json:
{ "mcpServers": { "postgres": { "command": "uvx", "args": ["postgres-mcp", "--access-mode=restricted"], "env": { "DATABASE_URI": "postgresql://localhost/myapp" } } }}claude mcp add --transport stdio --env DATABASE_URI=postgresql://localhost/myapp postgres \ -- uvx postgres-mcp --access-mode=restrictedTo zapisuje serwer w .mcp.json. Potwierdź poleceniem claude mcp list.
~/.codex/config.toml:
[mcp_servers.postgres]command = "uvx"args = ["postgres-mcp", "--access-mode=restricted"]env = { DATABASE_URI = "postgresql://localhost/myapp" }Po podłączeniu serwera repozytorium, które tworzy agent, pasuje do struktury User pole w pole:
use sqlx::PgPool;
pub struct UserRepository { pool: PgPool,}
impl UserRepository { pub fn new(pool: PgPool) -> Self { Self { pool } }
pub async fn find_by_email(&self, email: &str) -> Result<Option<User>> { let user = sqlx::query_as!( User, "SELECT id, email, name, password_hash, created_at, updated_at \ FROM users WHERE email = $1", email ) .fetch_optional(&self.pool) .await?; Ok(user) }
pub async fn create(&self, input: &CreateUser) -> Result<User> { let password_hash = hash_password(&input.password)?; let user = sqlx::query_as!( User, "INSERT INTO users (email, name, password_hash) \ VALUES ($1, $2, $3) \ RETURNING id, email, name, password_hash, created_at, updated_at", input.email, input.name, password_hash ) .fetch_one(&self.pool) .await?; Ok(user) }}Usługi async, które wyłączają się czysto
Dział zatytułowany „Usługi async, które wyłączają się czysto”Usługa Tokio, która nigdy nie kończy się czysto, gubi zadania i zawiesza Twoje testy integracyjne. Wzorzec, który działa: CancellationToken z tokio-util plus TaskTracker, żeby wyłączanie czekało na pracę będącą w toku.
use tokio_util::{sync::CancellationToken, task::TaskTracker};
pub async fn run(token: CancellationToken) { let tracker = TaskTracker::new(); loop { tokio::select! { // Cancelled — stop accepting work and drop out of the loop. _ = token.cancelled() => break, // Otherwise pull the next job and track it so shutdown can await it. job = next_job() => { let child = token.clone(); tracker.spawn(async move { tokio::select! { _ = handle(job) => {} _ = child.cancelled() => { /* abort mid-flight */ } } }); } } } tracker.close(); tracker.wait().await; // block until every spawned task has finished}Naprawdę zero-copy parsowanie
Dział zatytułowany „Naprawdę zero-copy parsowanie”„Zoptymalizuj pod zero-copy” to prompt, który często daje efekt odwrotny: owned Vec<u8>, który jest na nowo indeksowany przy każdym odczycie, opisany jako zero-copy. Prawdziwe zero-copy w Rust oznacza Bytes/BytesMut z split_to, które wydaje zliczane referencyjnie wycinki współdzielące tę samą alokację.
use bytes::BytesMut;use tokio::io::{AsyncRead, AsyncReadExt};
pub async fn frames<R: AsyncRead + Unpin>(mut reader: R) -> Result<usize> { let mut buf = BytesMut::with_capacity(8 * 1024); let mut total = 0; loop { // read_buf appends at the buffer's current length and grows as needed. if reader.read_buf(&mut buf).await? == 0 { break; } // split_to hands out an owned-but-shared slice — no byte copy. while buf.len() >= 4 { let frame = buf.split_to(4); total += frame.len(); // ... handle `frame` (a `BytesMut` view into the same allocation) } } Ok(total)}Gdy to się psuje
Dział zatytułowany „Gdy to się psuje”- Agent klonuje wszystko. Jeśli wynik jest usiany
.clone()iArc::new, znaczy to, że poddał się borrow checkerowi. Sformułuj prompt ponownie z niezmiennikiem ownershipu („wołający musi zachować tę wartość”) i wprost zakaż klonowania. - Zmyślone wersje crate’ów lub API. Dane treningowe AI są opóźnione względem crates.io, więc przypina stare wersje minor (
tokio = "1.35") albo wywołuje metody, które zostały przemianowane. Używaj przypięć z daszkiem ("1","2") i uruchomcargo check— albo pozwól to zrobić Codexowi/Claude Code — zanim zaufasz jakiejkolwiek nowej zależności. Sprawdź prawdziwą najnowszą wersję crate’a poleceniemcargo search <crate>. query_as!nie chce się skompilować. Pola struktury i listaSELECTsię nie zgadzają alboDATABASE_URLnie jest ustawione, więc SQLx nie sięga do schematu. Podłącz serwer Postgres MCP, uruchomcargo sqlx prepare, żeby zbuforować metadane zapytań, i upewnij się, że pola struktury dokładnie odpowiadają zwracanym kolumnom.- Błędy derive
thiserrorpo aktualizacji do 2.x. Składnia#[error("...")]jest niezmieniona, ale kilka skrajnych przypadków atrybutów się przesunęło. Pozwól agentowi przeczytać changelog thiserror 2.0, zamiast zgadywać. - Mockowanie traitów async zawodzi.
#[mockall::automock]na traicie zasync fnwymaga#[async_trait]albo obsługi async w mockall. Kiedy to stawia opór, ręcznie napisana fałszywa struktura jest prostsza niż mocowanie się z makrem — powiedz agentowi, żeby zamiast tego napisał fake’a.