Skip to main content

max / makenotwork

Add CONTRIBUTING.md for server and multithreaded Extract coding patterns, conventions, and architecture rules from CLAUDE.md into human-readable contributor guides. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-15 22:44 UTC
Commit: e604a277b33380b12532a9fc3f35ef58de9bf639
Parent: 4745e79
2 files changed, +577 insertions, -0 deletions
@@ -0,0 +1,223 @@
1 + # Contributing to Multithreaded
2 +
3 + Patterns, conventions, and rules for working on the Multithreaded forum codebase.
4 +
5 + ## Project Structure
6 +
7 + ```
8 + multithreaded/ (workspace root)
9 + Cargo.toml # Workspace + root crate deps
10 + src/
11 + lib.rs # AppState, module declarations
12 + main.rs # Entry point, migrations, server bind
13 + auth.rs # MNW OAuth (PKCE), session extractors
14 + csrf.rs # CSRF middleware (synchronizer token)
15 + config.rs # Config from environment
16 + internal_auth.rs # HMAC-SHA256 auth for MNW→MT internal API
17 + link_preview.rs # OG metadata extraction
18 + storage.rs # S3 wrapper around s3-storage crate
19 + seed.rs # Seed data for development
20 + routes/
21 + mod.rs # Route tree, rate limiting
22 + helpers.rs # Reusable route helpers (get_community, render_markdown)
23 + forum/ # Forum views, thread, posts, actions
24 + moderation.rs # Mod tools
25 + flagging.rs # Content flagging
26 + settings.rs # Community settings
27 + admin.rs # Platform admin
28 + search.rs # Full-text search
29 + tracking.rs # Read state tracking
30 + uploads.rs # Image uploads (S3)
31 + templates/
32 + mod.rs # Template structs + IntoResponse macro
33 + public.rs # Public page templates
34 + admin.rs # Admin templates
35 + crates/
36 + mt-core/ # Domain types (enums, time formatting)
37 + mt-db/ # Database queries and mutations (sqlx)
38 + templates/ # Askama HTML templates
39 + static/ # CSS, JS, fonts
40 + migrations/ # PostgreSQL migrations (auto-applied on boot)
41 + tests/ # Integration tests
42 + deploy/ # Deploy scripts, systemd unit
43 + ```
44 +
45 + ### Crate Boundaries
46 +
47 + | Crate | Role | May depend on |
48 + |-------|------|---------------|
49 + | `mt-core` | Domain enums, time formatting | Nothing internal |
50 + | `mt-db` | SQL queries and mutations | `mt-core` |
51 + | root crate | Routes, templates, auth, config | `mt-core`, `mt-db`, shared libs |
52 +
53 + Library crates (`mt-core`, `mt-db`) contain no web framework types. Routes and templates live in the root crate only.
54 +
55 + ## MNW OAuth Integration
56 +
57 + Multithreaded delegates all authentication to MNW via OAuth 2.0 with PKCE. There are no local passwords or signup forms.
58 +
59 + **Flow:**
60 + 1. `/auth/login` generates PKCE verifier + challenge, stores verifier in session, redirects to MNW `/oauth/authorize`
61 + 2. MNW authenticates the user, redirects back to `/auth/callback` with an authorization code
62 + 3. Callback exchanges code for token (with PKCE verifier), fetches userinfo, upserts local user via `ON CONFLICT` upsert
63 + 4. Session is created with `user_id`, `username`, `display_name`; session ID is cycled
64 +
65 + **Extractors:**
66 + - `MaybeUser(Option<SessionUser>)` — optional auth, infallible (never rejects)
67 + - `PlatformAdmin(SessionUser)` — admin-only, returns 404 to hide admin routes from non-admins
68 +
69 + ## Route Handlers
70 +
71 + ### Signature Pattern
72 +
73 + ```rust
74 + #[tracing::instrument(skip_all)]
75 + pub(in crate::routes) async fn handler_name(
76 + axum::extract::State(state): axum::extract::State<AppState>,
77 + session: Session,
78 + MaybeUser(session_user): MaybeUser,
79 + Path((slug, category)): Path<(String, String)>,
80 + Query(page_query): Query<PageQuery>,
81 + ) -> Result<impl IntoResponse, Response> {
82 + let csrf_token = Some(csrf::get_or_create_token(&session).await);
83 + let community = get_community(&state.db, &slug).await?;
84 + // ... build template struct ...
85 + Ok(MyTemplate { csrf_token, session_user, /* ... */ })
86 + }
87 + ```
88 +
89 + ### Error Handling
90 +
91 + Multithreaded uses `Result<impl IntoResponse, Response>` directly — no centralized `AppError` type. Errors are converted to `Response` inline via helper functions in `routes/helpers.rs`:
92 +
93 + ```rust
94 + pub(crate) async fn get_community(db: &PgPool, slug: &str) -> Result<CommunityRow, Response> {
95 + mt_db::queries::get_community_by_slug(db, slug)
96 + .await
97 + .map_err(|e| {
98 + tracing::error!(error = ?e, "db error fetching community");
99 + StatusCode::INTERNAL_SERVER_ERROR.into_response()
100 + })?
101 + .ok_or_else(|| StatusCode::NOT_FOUND.into_response())
102 + }
103 + ```
104 +
105 + Use these helpers for consistent error responses. All DB errors log with `tracing::error!` and return 500. Missing resources return 404.
106 +
107 + ### Rate Limiting
108 +
109 + Write routes (POST) use `tower_governor` rate limiting. Read routes (GET) have no rate limit. The rate limiter is applied via `.route_layer()` on the write routes group.
110 +
111 + ## Templates
112 +
113 + Askama templates with Jinja2-like syntax. Template structs live in `src/templates/`, HTML files in `templates/`.
114 +
115 + ### IntoResponse Macro
116 +
117 + Template structs get `IntoResponse` via a bulk macro:
118 +
119 + ```rust
120 + impl_into_response!(
121 + ForumDirectoryTemplate,
122 + CommunityTemplate,
123 + ThreadTemplate,
124 + // ...
125 + );
126 + ```
127 +
128 + This handles rendering and returns 500 if template rendering fails.
129 +
130 + ### Layout Inheritance
131 +
132 + All full pages extend `templates/base.html`:
133 + ```html
134 + {% extends "base.html" %}
135 + {% block title %}Page Title{% endblock %}
136 + {% block header %}{% include "partials/site_header.html" %}{% endblock %}
137 + {% block content %}
138 + <!-- page content -->
139 + {% endblock %}
140 + ```
141 +
142 + Every full-page template struct needs:
143 + - `csrf_token: Option<String>` — for the CSRF meta tag
144 + - `session_user: Option<TemplateSessionUser>` — for header login state
145 + - `mnw_base_url: Arc<str>` — for links back to MNW
146 +
147 + ## Database Layer
148 +
149 + Queries and mutations live in `crates/mt-db/`. Same sqlx patterns as MNW server:
150 +
151 + ```rust
152 + pub async fn list_communities(pool: &PgPool, limit: i64, offset: i64) -> Result<Vec<CommunityListRow>, sqlx::Error> {
153 + sqlx::query_as::<_, CommunityListRow>(
154 + "SELECT co.name, co.slug, co.description, ... FROM communities co ...
155 + LIMIT $1 OFFSET $2",
156 + )
157 + .bind(limit).bind(offset)
158 + .fetch_all(pool).await
159 + }
160 + ```
161 +
162 + **Rules:**
163 + - Always use positional parameters (`$1`, `$2`). Never interpolate.
164 + - Projection structs derive `sqlx::FromRow` and are shaped for templates, not domain models.
165 + - Queries in `queries.rs`, mutations in `mutations.rs`.
166 + - Use `ON CONFLICT ... DO UPDATE` for upserts (user sync from MNW).
167 +
168 + Migrations are in `migrations/` and auto-apply on boot via `sqlx::migrate!()`.
169 +
170 + ## Shared Dependencies
171 +
172 + MT uses three shared crates from `MNW/shared/`:
173 +
174 + | Crate | Usage |
175 + |-------|-------|
176 + | `docengine` | Markdown rendering with `render_strict()`, @mention resolution, quote post-processing |
177 + | `tagtree` | Tag name validation (`validate_with(&tag, &MT_TAG_CONFIG)`) |
178 + | `s3-storage` | Image uploads via `S3Storage` wrapper |
179 +
180 + **Deploy note:** `deploy.sh` syncs shared deps to `~/src/shared/` on Astra because Cargo.toml references `../shared/X`. If you add a new shared dep, update the rsync loop in `deploy.sh`.
181 +
182 + ## CSRF Protection
183 +
184 + Synchronizer token pattern, same as MNW server. CSRF middleware validates `X-CSRF-Token` header on POST/PUT/PATCH/DELETE. Exempt paths: `/auth/`, `/api/health`, `/_test/`.
185 +
186 + Client-side: HTMX sends the token automatically via a `htmx:configRequest` listener that reads from `<meta name="csrf-token">`.
187 +
188 + ## Internal API (MNW → MT)
189 +
190 + MNW server can call MT's internal API (e.g., to auto-create communities for new projects). These requests use HMAC-SHA256 authentication:
191 +
192 + - `X-Internal-Timestamp` — Unix timestamp (must be within 60 seconds of server time)
193 + - `X-Internal-Signature` — HMAC-SHA256 of `"timestamp\nbody"` using shared secret
194 +
195 + The `InternalAuth` extractor validates both before allowing access.
196 +
197 + ## Testing
198 +
199 + Integration tests use the same pattern as MNW: each test creates and drops its own PostgreSQL database.
200 +
201 + ```rust
202 + let harness = TestHarness::new().await; // Creates mnw_test_<uuid> DB
203 + let user_id = harness.login_as("alice").await; // Inserts user + sets session
204 + // ... test routes via harness.client ...
205 + // Database dropped on harness drop
206 + ```
207 +
208 + A `/_test/login` route (only available in tests) bypasses OAuth for session setup. Tests are organized by domain in `tests/workflows/`.
209 +
210 + ## Deployment
211 +
212 + MT builds natively on Astra (aarch64). Deploy from `multithreaded/`:
213 +
214 + ```bash
215 + ./deploy/deploy.sh # rsync source + shared deps, build on Astra, deploy binary + assets
216 + ```
217 +
218 + The deploy script:
219 + 1. Rsyncs source to `~/src/multithreaded/` on Astra
220 + 2. Rsyncs shared deps (docengine, tagtree, s3-storage) to `~/src/shared/`
221 + 3. Builds release binary on Astra
222 + 4. Copies binary + static + migrations to `/opt/multithreaded/`
223 + 5. Restarts the systemd service
@@ -0,0 +1,354 @@
1 + # Contributing to MNW Server
2 +
3 + Patterns, conventions, and rules for working on the MNW server codebase. Read this before making changes.
4 +
5 + ## Project Structure
6 +
7 + ```
8 + server/
9 + src/
10 + main.rs # Entry point
11 + lib.rs # Library root, AppState definition
12 + config.rs # Configuration from environment
13 + error.rs # AppError enum, HTTP error responses
14 + auth.rs # Session auth extractors (AuthUser, MaybeUser, AdminUser)
15 + csrf.rs # CSRF middleware + token management
16 + helpers.rs # Shared utilities (format_price, get_initials, etc.)
17 + db/ # Database queries, one file per domain
18 + types/ # View types + Db→View conversions
19 + routes/ # HTTP handlers, grouped by domain
20 + templates/ # Askama template structs (Rust side)
21 + payments/ # Stripe integration
22 + email/ # Postmark transactional email
23 + scanning/ # File scanning (ClamAV, YARA, hash lookup)
24 + storage.rs # S3 storage abstraction
25 + synckit_auth.rs # SyncKit JWT auth
26 + migrations/ # PostgreSQL migrations (numbered, auto-applied on boot)
27 + templates/ # Askama HTML templates (Jinja2-like)
28 + static/ # CSS, JS, fonts, images
29 + tests/ # Integration tests
30 + deploy/ # Deployment scripts and configs
31 + ```
32 +
33 + ## Error Handling
34 +
35 + All handlers return `Result<T, AppError>`. The `AppError` enum (`src/error.rs`) maps each variant to an HTTP status code, a Sentry tag, and a user-facing message. Internal details (DB errors, storage errors) are logged but never exposed to users.
36 +
37 + ```rust
38 + #[derive(Debug, thiserror::Error)]
39 + pub enum AppError {
40 + #[error("Not found")]
41 + NotFound, // 404
42 + #[error("Bad request: {0}")]
43 + BadRequest(String), // 400 — message shown to user
44 + #[error("Database error: {0}")]
45 + Database(#[from] sqlx::Error), // 500 — generic message to user
46 + #[error("Internal server error")]
47 + Internal(#[from] anyhow::Error), // 500 — generic message to user
48 + // ... see error.rs for full list
49 + }
50 + ```
51 +
52 + **Rules:**
53 + - Use `?` for error propagation. Never `.unwrap()` in production code.
54 + - Use `.ok_or(AppError::NotFound)?` when an optional DB result must exist.
55 + - Use `AppError::BadRequest("message")` for user-caused errors — the string is shown directly.
56 + - Use `AppError::Validation("message")` for form validation failures (returns 422).
57 + - Never expose internal error details to users. `Database` and `Internal` variants always show "Something went wrong."
58 + - Convert external errors with `From` impls, not string formatting. Add `#[from]` to AppError variants for automatic conversion.
59 +
60 + On API routes (`/api/*`), a middleware layer (`json_error_layer`) automatically converts HTML error responses to `{"error": "message"}` JSON. Handlers don't need to handle this — it's transparent.
61 +
62 + ## Route Handlers
63 +
64 + ### Signature Pattern
65 +
66 + Every handler follows this structure:
67 +
68 + ```rust
69 + #[tracing::instrument(skip_all, name = "module::handler_name")]
70 + async fn handler_name(
71 + State(state): State<AppState>, // App state (DB pool, config, etc.)
72 + session: Session, // Session store
73 + AuthUser(user): AuthUser, // Or MaybeUser(maybe_user), or AdminUser
74 + Path(slug): Path<String>, // URL parameters
75 + Query(params): Query<FilterQuery>, // Query string
76 + Form(form): Form<CreateForm>, // POST body (form-encoded)
77 + ) -> Result<impl IntoResponse> {
78 + let csrf_token = get_csrf_token(&session).await;
79 + // ... business logic ...
80 + Ok(MyTemplate { csrf_token, session_user: Some(user.into()), /* ... */ })
81 + }
82 + ```
83 +
84 + **Rules:**
85 + - Every handler gets `#[tracing::instrument(skip_all, name = "...")]` for structured logging.
86 + - Return type is always `Result<impl IntoResponse>`.
87 + - Extract auth requirements via the type system: `AuthUser` (login required), `MaybeUser` (optional), `AdminUser` (admin only, returns 404 to hide admin routes from non-admins).
88 + - Askama templates implement `IntoResponse` — return the struct directly.
89 + - CSRF token goes into every template that renders forms.
90 +
91 + ### HTMX Responses
92 +
93 + Full-page handlers extend `base.html` and include `session_user`, `csrf_token`, navigation, etc. HTMX handlers return partial templates — HTML fragments without the base layout.
94 +
95 + ```rust
96 + // Full page — extends base.html
97 + Ok(FullPageTemplate { csrf_token, session_user: maybe_user, /* ... */ })
98 +
99 + // HTMX fragment — standalone partial, no base layout
100 + Ok(FilteredEntriesTemplate { items, current_page, total_pages })
101 + ```
102 +
103 + In templates, HTMX attributes trigger fragment requests:
104 + ```html
105 + <button hx-get="/dashboard/items?page=2"
106 + hx-target="#item-list"
107 + hx-swap="innerHTML">Next</button>
108 + ```
109 +
110 + The CSRF token is included in HTMX requests via the `X-CSRF-Token` header, set globally from the `<meta name="csrf-token">` tag.
111 +
112 + ### Route Module Organization
113 +
114 + Routes are grouped by domain under `src/routes/`. Each domain is either a single file (for small domains) or a directory module (when it exceeds ~500 lines).
115 +
116 + ```
117 + routes/
118 + mod.rs # Declares modules + re-exports *_routes() functions
119 + auth.rs # Login, signup, logout
120 + admin.rs # Admin panel (or admin/ directory)
121 + pages/ # All public HTML pages (directory module)
122 + mod.rs # Composes sub-routers
123 + public/ # Public-facing pages
124 + dashboard/ # Creator dashboard + HTMX tabs
125 + api/ # JSON API endpoints
126 + stripe/ # Stripe webhooks + checkout
127 + synckit/ # SyncKit API
128 + ```
129 +
130 + Each module exposes a `*_routes()` function that returns an `axum::Router`:
131 + ```rust
132 + pub fn page_routes() -> Router<AppState> {
133 + Router::new()
134 + .route("/", get(home))
135 + .route("/:username", get(profile))
136 + // ...
137 + }
138 + ```
139 +
140 + These are composed in `main.rs` via `.merge()` or `.nest()`.
141 +
142 + ## Database Layer
143 +
144 + ### Query Pattern
145 +
146 + All queries use sqlx with compile-time checking. Queries live in `src/db/`, one file per domain (e.g., `db/users.rs`, `db/items.rs`, `db/synckit.rs`).
147 +
148 + ```rust
149 + pub async fn get_user_by_id(pool: &PgPool, id: UserId) -> Result<Option<DbUser>> {
150 + let user = sqlx::query_as::<_, DbUser>(
151 + "SELECT * FROM users WHERE id = $1"
152 + )
153 + .bind(id)
154 + .fetch_optional(pool)
155 + .await?;
156 + Ok(user)
157 + }
158 + ```
159 +
160 + **Rules:**
161 + - Always use positional parameters (`$1`, `$2`, ...). Never interpolate values into SQL strings.
162 + - Use `sqlx::query_as::<_, DbRow>` for typed results. Use `sqlx::query!` only when the macro's compile-time checking is needed.
163 + - `.fetch_one()` when exactly one row expected (errors on zero), `.fetch_optional()` when zero or one, `.fetch_all()` for lists.
164 + - Newtype ID wrappers (`UserId`, `ProjectId`, etc.) work directly with `.bind()` — they implement sqlx's `Encode`/`Decode`.
165 + - Multi-line SQL uses `r#"..."#` raw strings.
166 +
167 + ### DB Row Types vs View Types
168 +
169 + Database rows are `Db*` structs (e.g., `DbUser`, `DbProject`) in `src/db/`. View types for templates are in `src/types/` (e.g., `User`, `Project`). Conversions between them use `From` trait impls in `src/types/conversions.rs`.
170 +
171 + ```rust
172 + // In src/types/conversions.rs
173 + impl From<&db::DbUser> for User {
174 + fn from(u: &db::DbUser) -> Self {
175 + User {
176 + username: u.username.to_string(),
177 + avatar_initials: get_initials(u.display_name.as_deref().unwrap_or(&u.username)),
178 + stripe_connected: u.stripe_account_id.is_some(),
179 + // ... computed fields
180 + }
181 + }
182 + }
183 + ```
184 +
185 + This separation means:
186 + - `Db*` types mirror the database schema exactly (derive `sqlx::FromRow`).
187 + - View types have display-ready fields: formatted dates, computed booleans, pre-rendered HTML.
188 + - Handlers call `let user: User = (&db_user).into();` to convert.
189 +
190 + ## Type Safety
191 +
192 + ### ID Newtypes
193 +
194 + All database IDs are newtype wrappers defined via the `define_pg_uuid_id!` macro in `src/db/id_types.rs`:
195 +
196 + ```rust
197 + define_pg_uuid_id!(UserId, ProjectId, ItemId, VersionId, /* ... */);
198 + ```
199 +
200 + This generates `UserId(Uuid)` with `Display`, `FromStr`, `sqlx::Type`, `Encode`, `Decode`, `Serialize`, `Deserialize`, and `Default` (generates new v4 UUID). Use these everywhere — never pass raw `Uuid` or `String` for IDs.
201 +
202 + ### String Enums
203 +
204 + Domain enums that map to database TEXT columns use the `impl_str_enum!` macro in `src/db/enums.rs`:
205 +
206 + ```rust
207 + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
208 + pub enum ItemType { Audio, Video, Text, Software, /* ... */ }
209 +
210 + impl_str_enum!(ItemType {
211 + Audio => "audio",
212 + Video => "video",
213 + Text => "text",
214 + Software => "software",
215 + });
216 + ```
217 +
218 + This generates `Display`, `FromStr`, and sqlx `Type`/`Encode`/`Decode`. Add `#[serde(rename_all = "lowercase")]` if the JSON representation should match the database strings.
219 +
220 + ## Migrations
221 +
222 + Migrations live in `server/migrations/` and are numbered sequentially (e.g., `001_initial_schema.sql`, `053_add_video_support.sql`). They run automatically on application boot via sqlx.
223 +
224 + **Rules:**
225 + - Use `IF NOT EXISTS` guards wherever possible (tables, indexes, extensions).
226 + - Use `TIMESTAMPTZ` for all timestamps (UTC-aware).
227 + - Use `gen_random_uuid()` for UUID primary keys.
228 + - Always add `DEFAULT` values for `NOT NULL` columns.
229 + - Create indexes after the table definition, in the same migration.
230 + - Prefer additive migrations (add columns, add tables). Destructive changes (drop columns, rename tables) need careful planning.
231 + - Name migrations descriptively: `NNN_what_it_does.sql`.
232 +
233 + ```sql
234 + -- Example: 004_file_scan_status.sql
235 + ALTER TABLE items ADD COLUMN scan_status TEXT NOT NULL DEFAULT 'pending';
236 +
237 + CREATE TABLE file_scan_results (
238 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
239 + s3_key TEXT NOT NULL,
240 + scan_status TEXT NOT NULL,
241 + scanned_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
242 + );
243 +
244 + CREATE INDEX idx_file_scan_results_s3_key ON file_scan_results(s3_key);
245 + ```
246 +
247 + ## Templates
248 +
249 + Askama templates (Jinja2-like syntax) live in `server/templates/`. Template structs (Rust side) live in `server/src/templates/`.
250 +
251 + ### Layout Inheritance
252 +
253 + All full pages extend `base.html`:
254 + ```html
255 + {% extends "base.html" %}
256 + {% block title %}Page Title{% endblock %}
257 + {% block content %}
258 + {% include "partials/site_header.html" %}
259 + <!-- page content -->
260 + {% endblock %}
261 + ```
262 +
263 + Blocks available in `base.html`: `title`, `meta_description`, `head` (extra CSS/meta), `body_attrs`, `content`, `scripts`.
264 +
265 + ### HTMX Partials
266 +
267 + HTMX fragment templates do NOT extend `base.html`. They render standalone HTML fragments:
268 + ```html
269 + {# No extends — this is a partial #}
270 + {% for item in items %}
271 + <div class="item-row">{{ item.title }}</div>
272 + {% endfor %}
273 + ```
274 +
275 + ### Template Variables
276 +
277 + Every full-page template needs at minimum:
278 + - `csrf_token: Option<String>` — for the CSRF meta tag
279 + - `session_user: Option<SessionUser>` — for the header (login state, avatar)
280 +
281 + ## CSRF Protection
282 +
283 + The CSRF middleware (`src/csrf.rs`) validates all state-changing requests (POST, PUT, PATCH, DELETE) except exempted paths (webhooks, auth endpoints, OAuth).
284 +
285 + **For HTMX requests:** The CSRF token is sent via the `X-CSRF-Token` header, read from the `<meta name="csrf-token">` tag by client-side JS.
286 +
287 + **For vanilla forms:** Include a hidden `_csrf` field:
288 + ```html
289 + <input type="hidden" name="_csrf" value="{{ csrf_token.as_deref().unwrap_or_default() }}">
290 + ```
291 +
292 + Token comparison uses constant-time comparison to prevent timing attacks.
293 +
294 + ## Tracing
295 +
296 + Every handler and significant function gets `#[tracing::instrument(skip_all, name = "...")]`. The `name` parameter uses the pattern `module::function_name` (e.g., `"pages::blog_post_page"`, `"admin::approve_user"`).
297 +
298 + `skip_all` prevents large structs (State, Session, request bodies) from being serialized into spans. Add specific fields if needed:
299 + ```rust
300 + #[tracing::instrument(skip_all, fields(user_id = %user.id))]
301 + ```
302 +
303 + Internal errors are logged at `error!` level. Security events (failed CSRF, malware detection) at `warn!`.
304 +
305 + ## Testing
306 +
307 + ### Unit Tests
308 +
309 + In-file `#[cfg(test)]` modules. No database needed.
310 +
311 + ```rust
312 + #[cfg(test)]
313 + mod tests {
314 + use super::*;
315 +
316 + #[test]
317 + fn status_code_not_found() {
318 + assert_eq!(AppError::NotFound.status_code(), StatusCode::NOT_FOUND);
319 + }
320 + }
321 + ```
322 +
323 + ### Integration Tests
324 +
325 + Each integration test creates and drops its own PostgreSQL database. Requires `TEST_DATABASE_URL` pointing to a PostgreSQL instance with `CREATE DATABASE` permission.
326 +
327 + ```bash
328 + TEST_DATABASE_URL="postgres:///postgres" cargo test --test integration
329 + ```
330 +
331 + On Astra, use `--test-threads=8` (or the `RUST_TEST_THREADS=8` env var) to avoid overwhelming PostgreSQL with concurrent database creation.
332 +
333 + ## Rust Edition and Style
334 +
335 + - **Rust 2024 edition** (Rust 1.85+). Uses `gen` keyword restrictions and other 2024 features.
336 + - No `.unwrap()` in production code. Use `?`, `.ok_or()`, or `unwrap_or_default()`.
337 + - Prefer `Option::and_then`/`map` over `if let Some`/`match` for simple transforms.
338 + - Keep route files under 500 lines. Split into directory modules when they grow beyond that.
339 + - Files with 500+ lines of branching logic should be split. Flat lists (SQL queries, type conversions, static data) are exempt.
340 +
341 + ## Dependencies
342 +
343 + Always use the latest stable release of every dependency. When upgrading introduces breaking API changes, update the code — never pin old versions to avoid migration work.
344 +
345 + ## Deployment
346 +
347 + Deploy from the `server/` directory:
348 + ```bash
349 + ./deploy/deploy.sh # Full: cross-compile + config + binary + restart
350 + ./deploy/deploy.sh --quick # Binary only + restart
351 + ./deploy/deploy.sh --config # Config files only
352 + ```
353 +
354 + Cross-compiles to x86_64-unknown-linux-gnu via `cargo zigbuild`. Version is read from `Cargo.toml` and compiled into the binary via `env!("CARGO_PKG_VERSION")` for Sentry release strings. Bump the version in `Cargo.toml` before every production deploy.