Przejdź do głównej zawartości

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.

  • 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

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 conventions
alwaysApply: 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()

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.x
anyhow = "1"
tracing = "0.1"

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,
}

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()
}

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()
}
}

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" }
}
}
}

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ł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
}

„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)
}
  • Agent klonuje wszystko. Jeśli wynik jest usiany .clone() i Arc::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 uruchom cargo check — albo pozwól to zrobić Codexowi/Claude Code — zanim zaufasz jakiejkolwiek nowej zależności. Sprawdź prawdziwą najnowszą wersję crate’a poleceniem cargo search <crate>.
  • query_as! nie chce się skompilować. Pola struktury i lista SELECT się nie zgadzają albo DATABASE_URL nie jest ustawione, więc SQLx nie sięga do schematu. Podłącz serwer Postgres MCP, uruchom cargo sqlx prepare, żeby zbuforować metadane zapytań, i upewnij się, że pola struktury dokładnie odpowiadają zwracanym kolumnom.
  • Błędy derive thiserror po 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 z async fn wymaga #[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.