From caa12f5366e3a58611c4d3d0f3c8c1504a0b3e86 Mon Sep 17 00:00:00 2001 From: Anders Olsson Date: Tue, 2 Jun 2026 00:29:25 +0200 Subject: [PATCH] docs: add Plan 0 (Foundation) implementation plan Bite-sized TDD plan: role-named workspace, clap-derive config, sqlx Db handle, axum health probes + config-driven OpenAPI, server wiring, local dev tooling. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/plans/2026-06-02-foundation.md | 1032 +++++++++++++++++++++++++++ 1 file changed, 1032 insertions(+) create mode 100644 docs/plans/2026-06-02-foundation.md diff --git a/docs/plans/2026-06-02-foundation.md b/docs/plans/2026-06-02-foundation.md new file mode 100644 index 0000000..0f06422 --- /dev/null +++ b/docs/plans/2026-06-02-foundation.md @@ -0,0 +1,1032 @@ +# Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the placeholder single-package scaffold with a role-named Cargo workspace running a tested, single-tenant axum server that exposes health probes and a config-driven OpenAPI document. + +**Architecture:** A Cargo virtual workspace with role-named crates (`domain`, `db`, `api`, `server`). `domain` holds pure types (no I/O); `db` owns all SQL behind a `Db` handle; `api` builds the axum router, handlers, and OpenAPI doc; `server` is the binary that loads config (clap derive, env+args), connects the database, and serves. The product name is never hardcoded — it comes from config and flows into the OpenAPI title. + +**Tech Stack:** Rust 2024, tokio, axum 0.8, sqlx 0.8 (PostgreSQL), clap 4 (derive), utoipa 5 (code-first OpenAPI), tracing. Tests use `#[sqlx::test]` + `tower::ServiceExt::oneshot` and a TCP smoke test with reqwest. + +--- + +## Prerequisites (read once before starting) + +- **Rust** stable ≥ 1.85 (edition 2024) and the **nightly** toolchain for formatting (`cargo +nightly fmt`). +- **Docker** (for a local PostgreSQL during tests) — or any reachable PostgreSQL where the connecting role may `CREATE DATABASE` (required by `#[sqlx::test]`, which provisions a temp DB per test). +- **`just`** (optional, for the task runner added in Task 7). +- **Verify crate versions before pinning.** Versions below are expected-current for 2026; confirm the latest compatible release with the `cratesio-mcp` tools and adjust if needed. Add/update dependencies with `cargo-mcp` rather than hand-editing where practical. +- Set `DATABASE_URL` before running DB-backed tests, e.g. `export DATABASE_URL=postgres://postgres:postgres@localhost:5432/cms_dev` (Task 7 ships a `docker-compose.yml` and `.env.example`). + +## File Structure + +``` +Cargo.toml virtual workspace (no [package]) +crates/ + domain/ + Cargo.toml + src/lib.rs re-exports + src/id.rs newtype IDs (OrgId) + db/ + Cargo.toml + src/lib.rs Db handle: connect/from_pool/pool/ping + tests/ping.rs #[sqlx::test] ping + api/ + Cargo.toml + src/lib.rs AppState, build_app + src/health.rs /health/live, /health/ready + src/openapi.rs ApiDoc + /api-docs/openapi.json + tests/health.rs oneshot tests + server/ + Cargo.toml + src/lib.rs Config re-export, run(), serve() + src/config.rs clap-derive Config + src/main.rs binary entrypoint + tests/config.rs arg/env parsing + tests/serve.rs TCP smoke test +docker-compose.yml local Postgres +.env.example +justfile +``` + +Removed: the root `[package]` and `src/main.rs` (the `biggus-dickus` hello-world scaffold). + +--- + +## Task 1: Workspace skeleton (dissolve the placeholder package) + +**Files:** +- Modify: `Cargo.toml` (replace entire contents) +- Delete: `src/main.rs` (and the now-empty `src/`) +- Create: `crates/domain/Cargo.toml`, `crates/domain/src/lib.rs` +- Create: `crates/db/Cargo.toml`, `crates/db/src/lib.rs` +- Create: `crates/api/Cargo.toml`, `crates/api/src/lib.rs` +- Create: `crates/server/Cargo.toml`, `crates/server/src/main.rs` + +- [ ] **Step 1: Replace the root `Cargo.toml` with a virtual workspace** + +Overwrite `Cargo.toml` with exactly: + +```toml +[workspace] +resolver = "3" +members = ["crates/domain", "crates/db", "crates/api", "crates/server"] + +[workspace.package] +edition = "2024" +rust-version = "1.85" + +[workspace.dependencies] +tokio = { version = "1", features = ["full"] } +axum = "0.8" +sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "uuid", "macros"] } +uuid = { version = "1", features = ["v4", "serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +clap = { version = "4", features = ["derive", "env"] } +utoipa = { version = "5", features = ["uuid"] } +anyhow = "1" +thiserror = "2" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tower = { version = "0.5", features = ["util"] } +http-body-util = "0.1" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +``` + +- [ ] **Step 2: Remove the placeholder binary** + +Run: `git rm src/main.rs` +Expected: `rm 'src/main.rs'`. The `src/` directory is now empty (removed). + +- [ ] **Step 3: Create the `domain` crate (empty for now)** + +`crates/domain/Cargo.toml`: + +```toml +[package] +name = "domain" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true + +[dependencies] +uuid.workspace = true +serde.workspace = true +``` + +`crates/domain/src/lib.rs`: + +```rust +//! Core domain types and invariants. No I/O dependencies. +``` + +- [ ] **Step 4: Create the `db` crate (empty for now)** + +`crates/db/Cargo.toml`: + +```toml +[package] +name = "db" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true + +[dependencies] +sqlx.workspace = true +thiserror.workspace = true + +[dev-dependencies] +tokio.workspace = true +``` + +`crates/db/src/lib.rs`: + +```rust +//! Database access. All SQL lives in this crate. +``` + +- [ ] **Step 5: Create the `api` crate (empty for now)** + +`crates/api/Cargo.toml`: + +```toml +[package] +name = "api" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true + +[dependencies] +axum.workspace = true +serde.workspace = true +utoipa.workspace = true +db = { path = "../db" } + +[dev-dependencies] +tokio.workspace = true +tower.workspace = true +http-body-util.workspace = true +serde_json.workspace = true +sqlx.workspace = true +``` + +`crates/api/src/lib.rs`: + +```rust +//! HTTP API: router, handlers, and OpenAPI document. +``` + +- [ ] **Step 6: Create the `server` crate with a placeholder main** + +`crates/server/Cargo.toml`: + +```toml +[package] +name = "server" +version = "0.0.0" +edition.workspace = true +rust-version.workspace = true + +[lib] +path = "src/lib.rs" + +[[bin]] +name = "server" +path = "src/main.rs" + +[dependencies] +tokio.workspace = true +axum.workspace = true +clap.workspace = true +anyhow.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true +api = { path = "../api" } +db = { path = "../db" } + +[dev-dependencies] +reqwest.workspace = true +serde_json.workspace = true +api = { path = "../api" } +db = { path = "../db" } +sqlx.workspace = true +``` + +`crates/server/src/lib.rs`: + +```rust +//! Server wiring: configuration and startup. +``` + +`crates/server/src/main.rs`: + +```rust +fn main() { + println!("placeholder"); +} +``` + +- [ ] **Step 7: Verify the workspace builds** + +Run: `cargo build --workspace` +Expected: compiles successfully; four crates (`domain`, `db`, `api`, `server`) build with warnings only about unused placeholders being acceptable. + +- [ ] **Step 8: Commit** + +```bash +git add -A +git commit -m "chore: replace placeholder package with role-named workspace" +``` + +--- + +## Task 2: `domain` — newtype IDs + +**Files:** +- Create: `crates/domain/src/id.rs` +- Modify: `crates/domain/src/lib.rs` + +- [ ] **Step 1: Write the failing test** + +Create `crates/domain/src/id.rs`: + +```rust +use std::fmt; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// Identifier for an organization (tenant). +/// +/// A newtype over [`Uuid`] so it can never be confused with another entity's id. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct OrgId(Uuid); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_and_displays_round_trip() { + let text = "550e8400-e29b-41d4-a716-446655440000"; + let id: OrgId = text.parse().expect("valid uuid should parse"); + assert_eq!(id.to_string(), text); + } + + #[test] + fn rejects_invalid_uuid() { + assert!("not-a-uuid".parse::().is_err()); + } +} +``` + +Wire it into `crates/domain/src/lib.rs`: + +```rust +//! Core domain types and invariants. No I/O dependencies. + +mod id; + +pub use id::OrgId; +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p domain` +Expected: FAIL — compile error, `FromStr`/`Display` not implemented for `OrgId` (and `parse` unavailable). + +- [ ] **Step 3: Implement the newtype API** + +Append to `crates/domain/src/id.rs`, immediately after the `pub struct OrgId(Uuid);` line (before the `#[cfg(test)]` module): + +```rust +impl OrgId { + /// Generate a fresh random id. + pub fn new() -> Self { + Self(Uuid::new_v4()) + } + + /// Wrap an existing [`Uuid`]. + pub fn from_uuid(uuid: Uuid) -> Self { + Self(uuid) + } + + /// The underlying [`Uuid`]. + pub fn as_uuid(&self) -> Uuid { + self.0 + } +} + +impl Default for OrgId { + fn default() -> Self { + Self::new() + } +} + +impl fmt::Display for OrgId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl FromStr for OrgId { + type Err = uuid::Error; + + fn from_str(s: &str) -> Result { + Ok(Self(Uuid::parse_str(s)?)) + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test -p domain` +Expected: PASS — 2 tests (`parses_and_displays_round_trip`, `rejects_invalid_uuid`). + +- [ ] **Step 5: Commit** + +```bash +git add crates/domain +git commit -m "feat(domain): add OrgId newtype" +``` + +--- + +## Task 3: `server` — configuration via clap derive + +**Files:** +- Create: `crates/server/src/config.rs` +- Modify: `crates/server/src/lib.rs` +- Test: `crates/server/tests/config.rs` + +- [ ] **Step 1: Write the failing test** + +Create `crates/server/tests/config.rs`: + +```rust +use clap::Parser; +use server::Config; + +#[test] +fn parses_from_args_with_defaults() { + let cfg = Config::try_parse_from([ + "server", + "--database-url", + "postgres://localhost/test", + ]) + .expect("should parse"); + + assert_eq!(cfg.database_url, "postgres://localhost/test"); + assert_eq!(cfg.bind_addr, "0.0.0.0:8080"); + assert_eq!(cfg.app_name, "Collection Management System"); +} + +#[test] +fn database_url_is_required() { + assert!(Config::try_parse_from(["server"]).is_err()); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p server --test config` +Expected: FAIL — `server::Config` does not exist. + +- [ ] **Step 3: Implement the config and export it** + +Create `crates/server/src/config.rs`: + +```rust +use clap::Parser; + +/// Runtime configuration, sourced from CLI arguments and environment variables. +#[derive(Debug, Clone, Parser)] +#[command(version, about = "Collection management system server")] +pub struct Config { + /// PostgreSQL connection string. + #[arg(long, env = "DATABASE_URL")] + pub database_url: String, + + /// Address to bind the HTTP server to. + #[arg(long, env = "BIND_ADDR", default_value = "0.0.0.0:8080")] + pub bind_addr: String, + + /// User-facing application name (OpenAPI title, page title, …). + /// + /// Defaults to a neutral name; set this to the real product name at deploy + /// time. The product name must never be hardcoded in source. + #[arg(long, env = "APP_NAME", default_value = "Collection Management System")] + pub app_name: String, +} +``` + +Replace `crates/server/src/lib.rs` with: + +```rust +//! Server wiring: configuration and startup. + +mod config; + +pub use config::Config; +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `cargo test -p server --test config` +Expected: PASS — 2 tests. + +- [ ] **Step 5: Commit** + +```bash +git add crates/server +git commit -m "feat(server): add clap-derive Config (args + env)" +``` + +--- + +## Task 4: `db` — connection pool and readiness ping + +**Files:** +- Modify: `crates/db/src/lib.rs` +- Test: `crates/db/tests/ping.rs` + +- [ ] **Step 1: Write the failing test** + +Create `crates/db/tests/ping.rs`: + +```rust +use db::Db; +use sqlx::PgPool; + +#[sqlx::test] +async fn ping_succeeds(pool: PgPool) { + let db = Db::from_pool(pool); + db.ping() + .await + .expect("ping should succeed against a live database"); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p db` +Expected: FAIL — `Db`, `Db::from_pool`, and `Db::ping` do not exist (compile error). + +- [ ] **Step 3: Implement the `Db` handle** + +Replace `crates/db/src/lib.rs` with: + +```rust +//! Database access. All SQL lives in this crate. + +use sqlx::postgres::{PgPool, PgPoolOptions}; + +/// A handle to the organization's PostgreSQL database. +#[derive(Clone)] +pub struct Db { + pool: PgPool, +} + +impl Db { + /// Connect to the database at `database_url`, opening a connection pool. + pub async fn connect(database_url: &str) -> Result { + let pool = PgPoolOptions::new() + .max_connections(5) + .connect(database_url) + .await?; + Ok(Self { pool }) + } + + /// Build a handle from an existing pool (used in tests). + pub fn from_pool(pool: PgPool) -> Self { + Self { pool } + } + + /// Borrow the underlying pool for repository modules. + pub fn pool(&self) -> &PgPool { + &self.pool + } + + /// Readiness check: run a trivial query to confirm the database answers. + pub async fn ping(&self) -> Result<(), sqlx::Error> { + sqlx::query_scalar::<_, i32>("SELECT 1") + .fetch_one(&self.pool) + .await?; + Ok(()) + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Ensure PostgreSQL is running and `DATABASE_URL` is set (see Prerequisites / Task 7). +Run: `cargo test -p db` +Expected: PASS — 1 test (`ping_succeeds`). `#[sqlx::test]` provisions and drops a temporary database automatically. + +- [ ] **Step 5: Commit** + +```bash +git add crates/db +git commit -m "feat(db): add Db handle with pool connect and readiness ping" +``` + +--- + +## Task 5: `api` — health probes, OpenAPI doc, and router + +**Files:** +- Create: `crates/api/src/health.rs` +- Create: `crates/api/src/openapi.rs` +- Modify: `crates/api/src/lib.rs` +- Test: `crates/api/tests/health.rs` + +- [ ] **Step 1: Write the failing test** + +Create `crates/api/tests/health.rs`: + +```rust +use api::{AppState, build_app}; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use sqlx::PgPool; +use tower::ServiceExt; // for `oneshot` + +fn state(pool: PgPool, app_name: &str) -> AppState { + AppState { + db: db::Db::from_pool(pool), + app_name: app_name.to_string(), + } +} + +#[sqlx::test] +async fn live_returns_ok(pool: PgPool) { + let app = build_app(state(pool, "Test")); + let resp = app + .oneshot( + Request::builder() + .uri("/health/live") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(json["status"], "ok"); +} + +#[sqlx::test] +async fn ready_reports_database_true(pool: PgPool) { + let app = build_app(state(pool, "Test")); + let resp = app + .oneshot( + Request::builder() + .uri("/health/ready") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(json["database"], true); +} + +#[sqlx::test] +async fn openapi_doc_uses_configured_title(pool: PgPool) { + let app = build_app(state(pool, "My Museum CMS")); + let resp = app + .oneshot( + Request::builder() + .uri("/api-docs/openapi.json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let json: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(json["info"]["title"], "My Museum CMS"); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p api --test health` +Expected: FAIL — `AppState`, `build_app`, and the routes do not exist (compile error). + +- [ ] **Step 3: Implement health handlers** + +Create `crates/api/src/health.rs`: + +```rust +use axum::{ + Json, Router, extract::State, http::StatusCode, response::IntoResponse, routing::get, +}; +use serde::Serialize; +use utoipa::ToSchema; + +use crate::AppState; + +/// Liveness payload: the process is running. +#[derive(Serialize, ToSchema)] +pub(crate) struct Live { + /// Always `"ok"` when the process serves requests. + pub status: &'static str, +} + +/// Readiness payload: dependencies were checked. +#[derive(Serialize, ToSchema)] +pub(crate) struct Ready { + /// `"ok"` when ready, `"degraded"` otherwise. + pub status: &'static str, + /// Whether the database responded to a ping. + pub database: bool, +} + +/// Liveness probe — no dependencies checked. +#[utoipa::path(get, path = "/health/live", responses((status = 200, body = Live)))] +pub(crate) async fn live() -> Json { + Json(Live { status: "ok" }) +} + +/// Readiness probe — confirms the database answers. +#[utoipa::path( + get, + path = "/health/ready", + responses( + (status = 200, body = Ready, description = "Ready"), + (status = 503, body = Ready, description = "A dependency is unavailable") + ) +)] +pub(crate) async fn ready(State(state): State) -> impl IntoResponse { + match state.db.ping().await { + Ok(()) => ( + StatusCode::OK, + Json(Ready { + status: "ok", + database: true, + }), + ), + Err(_) => ( + StatusCode::SERVICE_UNAVAILABLE, + Json(Ready { + status: "degraded", + database: false, + }), + ), + } +} + +/// Health routes, parameterized over [`AppState`]. +pub(crate) fn routes() -> Router { + Router::new() + .route("/health/live", get(live)) + .route("/health/ready", get(ready)) +} +``` + +- [ ] **Step 4: Implement the OpenAPI document and route** + +Create `crates/api/src/openapi.rs`: + +```rust +use axum::{Json, Router, extract::State, routing::get}; +use utoipa::OpenApi; + +use crate::{AppState, health}; + +#[derive(OpenApi)] +#[openapi( + paths(health::live, health::ready), + components(schemas(health::Live, health::Ready)), + info(title = "Collection Management System", version = "0.0.0") +)] +struct ApiDoc; + +/// Serve the OpenAPI document, overriding the title from runtime config so the +/// product name is never hardcoded. +async fn openapi_json(State(state): State) -> Json { + let mut doc = ApiDoc::openapi(); + doc.info.title = state.app_name.clone(); + Json(doc) +} + +/// OpenAPI routes, parameterized over [`AppState`]. +pub(crate) fn routes() -> Router { + Router::new().route("/api-docs/openapi.json", get(openapi_json)) +} +``` + +- [ ] **Step 5: Implement `AppState` and `build_app`** + +Replace `crates/api/src/lib.rs` with: + +```rust +//! HTTP API: router, handlers, and OpenAPI document. + +mod health; +mod openapi; + +use axum::Router; +use db::Db; + +/// Shared application state passed to handlers. +#[derive(Clone)] +pub struct AppState { + /// Database handle for this organization. + pub db: Db, + /// User-facing product name (from config). Never hardcoded. + pub app_name: String, +} + +/// Build the application router from shared state. +pub fn build_app(state: AppState) -> Router { + Router::new() + .merge(health::routes()) + .merge(openapi::routes()) + .with_state(state) +} +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `cargo test -p api --test health` +Expected: PASS — 3 tests (`live_returns_ok`, `ready_reports_database_true`, `openapi_doc_uses_configured_title`). + +- [ ] **Step 7: Commit** + +```bash +git add crates/api +git commit -m "feat(api): add health probes, OpenAPI doc, and router" +``` + +--- + +## Task 6: `server` — wire dependencies and serve + +**Files:** +- Modify: `crates/server/src/lib.rs` +- Modify: `crates/server/src/main.rs` +- Test: `crates/server/tests/serve.rs` + +- [ ] **Step 1: Write the failing test** + +Create `crates/server/tests/serve.rs`: + +```rust +use std::net::SocketAddr; + +use api::AppState; +use db::Db; +use server::serve; +use tokio::net::TcpListener; + +#[tokio::test] +async fn serves_health_live_over_tcp() { + let database_url = + std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for this test"); + let db = Db::connect(&database_url).await.expect("connect to database"); + let state = AppState { + db, + app_name: "Test".to_string(), + }; + + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr: SocketAddr = listener.local_addr().unwrap(); + + let handle = tokio::spawn(async move { + serve(listener, state).await.unwrap(); + }); + + let url = format!("http://{addr}/health/live"); + let body: serde_json::Value = reqwest::get(&url) + .await + .expect("request succeeds") + .json() + .await + .expect("json body"); + assert_eq!(body["status"], "ok"); + + handle.abort(); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `cargo test -p server --test serve` +Expected: FAIL — `server::serve` does not exist (compile error). + +- [ ] **Step 3: Implement `run` and `serve`** + +Replace `crates/server/src/lib.rs` with: + +```rust +//! Server wiring: configuration and startup. + +mod config; + +pub use config::Config; + +use anyhow::Context; +use api::{AppState, build_app}; +use db::Db; +use tokio::net::TcpListener; + +/// Connect dependencies from `config` and serve until shutdown. +pub async fn run(config: Config) -> anyhow::Result<()> { + let db = Db::connect(&config.database_url) + .await + .context("connecting to the database")?; + let state = AppState { + db, + app_name: config.app_name.clone(), + }; + + let listener = TcpListener::bind(&config.bind_addr) + .await + .with_context(|| format!("binding to {}", config.bind_addr))?; + tracing::info!(addr = %config.bind_addr, "server listening"); + + serve(listener, state).await +} + +/// Serve the API on an already-bound listener (used by `run` and tests). +pub async fn serve(listener: TcpListener, state: AppState) -> anyhow::Result<()> { + let app = build_app(state); + axum::serve(listener, app) + .await + .context("running the HTTP server")?; + Ok(()) +} +``` + +- [ ] **Step 4: Implement the binary entrypoint** + +Replace `crates/server/src/main.rs` with: + +```rust +use clap::Parser; +use server::{Config, run}; + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + + let config = Config::parse(); + run(config).await +} +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Ensure PostgreSQL is running and `DATABASE_URL` is set. +Run: `cargo test -p server --test serve` +Expected: PASS — 1 test (`serves_health_live_over_tcp`). + +- [ ] **Step 6: Run the whole workspace test suite** + +Run: `cargo test --workspace` +Expected: PASS — all tests across `domain`, `db`, `api`, `server`. + +- [ ] **Step 7: Commit** + +```bash +git add crates/server +git commit -m "feat(server): wire config, database, and HTTP serving" +``` + +--- + +## Task 7: Developer ergonomics (local Postgres, task runner, env) + +**Files:** +- Create: `docker-compose.yml` +- Create: `.env.example` +- Create: `justfile` +- Modify: `.gitignore` + +- [ ] **Step 1: Add a local PostgreSQL service** + +Create `docker-compose.yml`: + +```yaml +services: + postgres: + image: postgres:17 + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: cms_dev + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + +volumes: + pgdata: +``` + +- [ ] **Step 2: Add an example environment file** + +Create `.env.example`: + +```dotenv +# Connection string for local development and tests. +# The role must be allowed to CREATE DATABASE (sqlx::test provisions temp DBs). +DATABASE_URL=postgres://postgres:postgres@localhost:5432/cms_dev +BIND_ADDR=0.0.0.0:8080 +APP_NAME=Collection Management System +``` + +- [ ] **Step 3: Add a task runner** + +Create `justfile`: + +```just +set dotenv-load + +# Run the server (reads .env) +run: + cargo run -p server + +# Run the full test suite +test: + cargo test --workspace + +# Format with the nightly toolchain +fmt: + cargo +nightly fmt + +# Lint, treating warnings as errors +lint: + cargo clippy --workspace --all-targets -- -D warnings + +# Format, lint, and test +check: fmt lint test +``` + +- [ ] **Step 4: Ignore the local env file** + +Append to `.gitignore` (a newline then): + +```gitignore +.env +``` + +- [ ] **Step 5: Verify the local stack end to end** + +Run: +```bash +docker compose up -d +cp .env.example .env +cargo +nightly fmt +cargo clippy --workspace --all-targets -- -D warnings +cargo test --workspace +``` +Expected: containers start; formatting clean; clippy reports no warnings; all tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add docker-compose.yml .env.example justfile .gitignore +git commit -m "chore: add local Postgres, justfile, and env example" +``` + +--- + +## Self-Review (completed) + +**Spec coverage (foundation slice of `docs/specs/2026-06-02-mvp-architecture.md`):** +- §5 role-named workspace, dependency direction inward → Tasks 1–6 (domain has no I/O deps; api depends on db; server depends on api+db). ✓ +- §13 product name never hardcoded; from config → Task 3 (`Config::app_name`) + Task 5 (OpenAPI title override). ✓ +- §7 OpenAPI code-first via utoipa → Task 5. ✓ +- §8 sqlx + SQL confined to `db` crate → Task 4 (only crate with SQL). ✓ +- §12 self-host single binary, sensible defaults → Tasks 3, 6 (defaults for bind/app_name; one binary). ✓ +- §19 testing: repositories integration-tested against Postgres; deterministic handler tests → Tasks 4–6. ✓ +- §21 first scaffolding task = dissolve `biggus-dickus` package → Task 1. ✓ +- Out of scope here (later plans): isolation/credentials (deployment), auth, the data model, search, audit, export. Not foundation gaps. + +**Placeholder scan:** no `TODO`/`TBD`/"add error handling"/"similar to" — every step has concrete code and commands. ✓ + +**Type consistency:** `AppState { db: Db, app_name: String }` is constructed identically in Task 5 tests, Task 6 lib, and Task 6 test; `build_app`/`serve`/`Db::from_pool`/`Db::connect`/`Db::ping` signatures match across tasks; `Config` field names (`database_url`, `bind_addr`, `app_name`) are consistent between Task 3 and Task 6. ✓ + +--- + +## Notes for follow-on plans + +- Add `storage`, `search`, `auth` crates to `members` when their plans begin. +- CI (gitea actions running `just check`) is intentionally deferred — add once a second plan lands. +- Licensing for the workspace is undecided; `[workspace.package]` deliberately omits `license` until chosen.