Files
biggus-dickus/docs/plans/2026-06-02-foundation.md
logaritmisk caa12f5366 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) <noreply@anthropic.com>
2026-06-02 00:29:25 +02:00

1033 lines
27 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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::<OrgId>().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<Self, Self::Err> {
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<Self, sqlx::Error> {
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<Live> {
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<AppState>) -> 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<AppState> {
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<AppState>) -> Json<utoipa::openapi::OpenApi> {
let mut doc = ApiDoc::openapi();
doc.info.title = state.app_name.clone();
Json(doc)
}
/// OpenAPI routes, parameterized over [`AppState`].
pub(crate) fn routes() -> Router<AppState> {
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 16 (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 46. ✓
- §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.