Guide for AI agents (Claude Code, Cursor, etc.) working in this repo. Complements
CLAUDE.md(general conventions). This file focuses on code quality patterns.
Owner/admin check — helper already exists, DO NOT write manually:
// ✅ DO THIS
ApiError::check_owner_or_admin(&user, &resource.created_by, "You can only modify your own posts")?;
// ❌ NOT THIS (was duplicated 3x before refactor)
let is_author = resource.created_by == user.id;
let is_admin = user.role == UserRole::Admin;
if !is_author && !is_admin {
return Err(ApiError::forbidden("..."));
}Admin-only check:
// ✅ DO THIS
ApiError::require_admin(&user)?;
// ❌ NOT THIS
if user.role != UserRole::Admin {
return Err(ApiError::forbidden("Admin access required"));
}Helper location: crates/rungu-api/src/error.rs
All handlers use the same format:
// Success
Ok(Json(serde_json::json!({ "data": value })))
// Created
Ok((StatusCode::CREATED, Json(serde_json::json!({ "data": value }))))
// Error (via ApiError)
Err(ApiError::not_found("Post not found"))
// → auto-renders as { "error": "Post not found" } with correct status codeDo not create new response formats. If you need pagination:
Json(serde_json::json!({
"data": items,
"pagination": { "page": page, "per_page": per_page, "total": total }
}))Each resource route must expose pub fn router() -> Router<AppState>:
crates/rungu-api/src/
├── lib.rs # api_routes() — merge point
├── error.rs # ApiError + auth helpers
├── post_routes.rs # pub fn router() -> Router<AppState>
├── vote_routes.rs # pub fn router() -> Router<AppState>
├── comment_routes.rs # pub fn router() -> Router<AppState>
└── project_routes.rs # pub fn router() -> Router<AppState>
Adding a new resource (e.g. tag_routes):
- Create
crates/rungu-api/src/tag_routes.rswithpub fn router() - Register in
lib.rs:pub mod tag_routes;+.merge(tag_routes::router()) - Use
crate::error::ApiErrorfor all errors
Do not place .route() calls directly in server.rs or api_routes().
// ✅ DO THIS — parse_now for self-generated timestamps
let now = Utc::now().to_rfc3339();
created_at: parse_now(&now),
// ✅ DO THIS — parse_ts for DB-read timestamps (has fallback)
let created_at = parse_ts(row.get("created_at"));
// ❌ NOT THIS — unwrap() on parse
created_at: now.parse().unwrap(), // BANNED in production codeHelper location: crates/rungu-core/src/store.rs
// ✅ DO THIS — positional ? binding
conditions.push("(title LIKE ? OR description LIKE ?)");
let pattern = format!("%{q}%");
query.bind(pattern.clone()).bind(pattern);
// ❌ NOT THIS — SQL INJECTION
format!("(title LIKE '%{q}%')"); // NEVER DO THISSort/order is the only exception (hardcoded match, not user input):
// ✅ OK — hardcoded match, not interpolation
let order = match params.sort {
PostSort::Newest => "created_at DESC",
// ...
};All API errors go through ApiError:
// ✅ Handler signature
async fn handler() -> Result<impl IntoResponse, ApiError> { ... }
// ✅ Error construction helpers
ApiError::bad_request("Title is required")
ApiError::not_found("Post not found")
ApiError::forbidden("Admin access required")
ApiError::internal("Unexpected error")
// ✅ From<anyhow::Error> auto-converts store errors to 500
state.store.create_post(...).await?; // ? → ApiError::internalDo not use StatusCode directly in handlers. Do not leak anyhow::Error to responses.
| Layer | Test type | Location | What to cover |
|---|---|---|---|
| Store | Integration (in-memory SQLite) | crates/rungu-core/tests/store_test.rs |
CRUD, filters, cascade, edge cases |
| API | Integration (tower::oneshot) | crates/rungu-api/tests/api_test.rs |
Auth guards (401/403), CRUD, validation |
| Unit | #[cfg(test)] in source |
alongside handler | Parsing, helpers, pure logic |
Test setup pattern:
// Store test
async fn setup() -> Store {
let pool = open_pool(":memory:").await.unwrap();
run_migrations(&pool).await.unwrap();
Store::new(pool)
}
// API test
let (app, store) = setup_app().await;
let response = app.oneshot(Request::builder().uri("/projects").body(Body::empty()).unwrap()).await.unwrap();Minimum coverage before merge:
- Every public endpoint: test happy path + 404
- Every auth-required endpoint: test 401 without token
- Every admin-only endpoint: test 403 for non-admin
- Store methods: test CRUD lifecycle + edge cases
All handlers must have #[utoipa::path(...)] attribute:
#[utoipa::path(
get,
path = "/api/projects/{slug}/posts",
params(("slug" = String, Path, description = "Project slug")),
responses(
(status = 200, description = "List of posts", body = serde_json::Value),
(status = 404, description = "Project not found"),
),
tag = "posts",
)]
async fn list_posts(...) -> Result<impl IntoResponse, ApiError> { ... }Wire types in proto must derive ToSchema:
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct Post { ... }Swagger UI: http://localhost:3000/swagger-ui
- Feature:
feat/xxx-api - Bugfix:
fix/xxx-description - Refactor:
refactor/xxx - Always branch from up-to-date
develop
Conventional commits:
feat:new featurefix:bug fixrefactor:code cleanup (no behavior change)test:test additionsdocs:documentationchore:tooling, deps
Before pushing, ENSURE:
cargo fmt --all -- --check # formatting
cargo clippy --workspace --all-targets -- -D warnings # lint
cargo test --workspace # tests- Check, Format, Clippy (-D warnings), Test, Build (release)
- Cargo Audit, Trivy, npm Audit
- Cora Review (known bug: exit code 2 flaky — bypass via admin enforcement if needed)
- Each subagent works in an isolated git worktree
- Shared files that frequently conflict:
lib.rs(module registration) +server.rs(route merge) - After merging one PR, rebase other branches before merging