# Claude Code Guidelines for Brewlog ## Project Overview Brewlog is a self-hosted coffee logging platform built in Rust. It provides: - HTTP server with web UI (Axum + Askama templates + Datastar) - REST API for programmatic access - CLI client for command-line operations - SQLite/PostgreSQL database support (feature-flagged) ## Build & Test Commands ```bash cargo build # Build the project cargo test # Run all tests cargo clippy --allow-dirty --fix # Lint and auto-fix cargo fmt # Format code ``` ### Database Migrations Create new migrations using sqlx: ```bash sqlx migrate add # Creates migrations/NNNN_.sql ``` Migration files are plain SQL in the `migrations/` directory, numbered sequentially (e.g., `0008_remove_gear_notes.sql`). ## Workflow Requirements **Before finishing any task**, always: 1. Run `cargo clippy --allow-dirty --fix && cargo fmt` to lint and format 2. Run `cargo build` to verify compilation 3. Run `cargo test` if changes affect testable code 4. Update `README.md` if the change adds/removes/renames CLI commands, environment variables, or user-facing features 5. Update `scripts/bootstrap-db.sh` if the change adds/removes/renames CLI commands, flags, or entity fields used by the bootstrap script 6. Provide a **draft commit message** using Conventional Commits format Example commit message: ``` feat(gear): add category filtering to gear list - Add GearFilter with optional category field - Update repository to apply filter in SQL WHERE clause - Add --category flag to CLI list-gear command ``` ## Architecture The codebase follows **Clean Architecture / Domain-Driven Design** with four layers: ``` src/ ├── domain/ # Pure business logic, no external dependencies │ ├── errors.rs # RepositoryError enum │ ├── ids.rs # Typed ID wrappers (RoasterId, RoastId, BagId, BrewId, GearId, etc.) │ ├── listing.rs # Pagination & sorting (SortKey, ListRequest, Page, PageSize) │ ├── repositories.rs # Repository traits │ └── {entity}.rs # Entity definitions (roasters, roasts, bags, brews, gear, etc.) │ ├── infrastructure/ # External integrations (database, HTTP clients, third-party APIs) │ ├── repositories/ # SQL implementations of repository traits │ ├── client/ # HTTP client for CLI │ ├── ai/ # OpenRouter LLM integration for AI extraction │ ├── foursquare.rs # Foursquare Places API for nearby cafe search │ └── database.rs # Database pool abstraction │ ├── application/ # HTTP server, routes, middleware, services │ ├── routes/ # Axum route handlers │ ├── services/ # Entity services (create + timeline orchestration) │ └── errors.rs # HTTP error mapping │ └── presentation/ # User interfaces ├── cli/ # CLI commands and argument parsing └── web/ # View models for templates ``` **Dependency flow**: `presentation → application → domain ← infrastructure` ## Gotchas These are non-obvious footguns that will cause bugs if missed. **1. `datastar-fetch` event bubbles through the DOM.** When a page has multiple forms with `data-on:datastar-fetch` handlers, each handler fires for events from *any* `@post`/`@get` in the same DOM tree. **Every handler must guard with its own in-progress signal**: ```html
``` Only reset state on `finished` or `error`, never unconditionally. **2. No `data-model` in Datastar v1** — it is silently ignored. Use `data-bind:_signal-name` for two-way binding. **3. Signal patching requires JSON, not HTML.** Datastar only processes signal updates from `application/json` responses (via `render_signals_json()`), not from `data-signals` attributes in DOM-patched HTML fragments. **4. List partial must be OUTSIDE the form section.** In page templates, the `{% include %}` for the list partial must be a **sibling** of the form `
`, not nested inside it. Placing it inside removes the flex gap between form and list. **5. Table wrapper must be `
`, not `
`.** List partials wrap the table in `
`. **6. Infinite scroll sentinel needs `md:hidden`.** The sentinel `
` must include `md:hidden` to avoid unwanted height on desktop. Same applies when creating sentinels dynamically in JS. **7. Use token-based text classes, never hardcoded `text-stone-*`.** Always use `text-text`, `text-text-secondary`, `text-text-muted` which adapt between light and dark themes. **8. Static assets need explicit routes.** All assets are embedded at compile time via `include_str!()`/`include_bytes!()` with explicit routes in `application/routes/mod.rs`. There is no `tower-http` static file serving. ## Backend Patterns ### Repository Pattern All data access goes through trait-based repositories defined in `domain/repositories.rs`. SQL implementations live in `infrastructure/repositories/`, each using a private `Record` struct with a `to_domain()` method to convert database rows to domain entities. Use typed ID wrappers from `domain/ids.rs` (e.g., `RoastId`, `BagId`) — never raw `i64`. ### Service Layer Services in `application/services/` encapsulate "create entity + record timeline event" as a single operation. **When to use services vs repos:** - **Services** — for `create()` (and `finish()` for bags). These record a timeline event after the insert. - **Repos** — for `get()`, `list()`, `update()`, `delete()`. No side effects needed. `AppState` holds both repos and services. Route handlers call `state.xxx_service.create()` for creation and `state.xxx_repo.get()` / `.list()` / etc. for reads and updates. The `define_simple_service!` macro in `services/mod.rs` generates services for entities whose `to_timeline_event()` needs only `&self`. This covers `RoasterService`, `CafeService`, `GearService`. Entities needing enrichment are hand-written: | Service | Extra repos | Why | |---------|-------------|-----| | `RoastService` | `roaster_repo` | Needs roaster name/slug for timeline | | `BagService` | `roast_repo`, `roaster_repo` | `create()` + `finish()`, needs roast+roaster for timeline | | `BrewService` | — | `create()` enriches via `get_with_details()` for timeline + response | | `CupService` | — | `create()` enriches via `get_with_details()` for timeline | Timeline events are display-only (not data integrity), so they use fire-and-forget error handling: ```rust if let Err(err) = self.timeline_repo.insert(entity.to_timeline_event()).await { warn!(error = %err, id = %entity.id, "failed to record timeline event"); } ``` ### Route Module Structure Each list-bearing route module (roasters, roasts, bags, gear, brews) follows the same structure: 1. **Path constants** — `ENTITY_PAGE_PATH` (full page URL) and `ENTITY_FRAGMENT_PATH` (with `#entity-list` anchor) 2. **`load_entity_page()`** — calls `repo.list()` and builds view models via `build_page_view()` from `support.rs` 3. **`entity_page()`** — checks `is_datastar_request()`: returns fragment for Datastar, full page otherwise 4. **`render_entity_list_fragment()`** — returns just the list partial for Datastar replacement Create handlers follow a three-way response pattern: ```rust if is_datastar_request(&headers) { render_fragment(...) // Datastar → updated list fragment } else if matches!(source, PayloadSource::Form) { Redirect::to(...) // Browser form → redirect } else { Json(entity) // API → JSON } ``` ### Macros Reference All macros have doc comments with usage examples. Check the source files for full documentation. | Macro | Location | Purpose | |-------|----------|---------| | `define_simple_service!` | `application/services/mod.rs` | Generate service struct with `create()` + timeline | | `define_get_handler!` | `application/routes/api/macros.rs` | GET `/api/v1/:entity/:id` → JSON | | `define_enriched_get_handler!` | `application/routes/api/macros.rs` | GET with joined related entities → JSON | | `define_delete_handler!` | `application/routes/api/macros.rs` | DELETE → fragment for Datastar or 204 for API | | `define_list_fragment_renderer!` | `application/routes/api/macros.rs` | Generate fragment renderer for a list page | | `define_get_command!` | `presentation/cli/macros.rs` | CLI get-entity command | | `define_delete_command!` | `presentation/cli/macros.rs` | CLI delete-entity command | | `push_update_field!` | `infrastructure/repositories/macros.rs` | Build dynamic UPDATE queries with `QueryBuilder` | ### SQL & Queries Use `QueryBuilder` for dynamic queries. For UPDATE, use `push_update_field!` (see macro docs). Each repository has an `order_clause()` method for sort query generation — use `order_clause` as the method name, not `sort_clause`. ### Error Handling & Logging **Error types**: `RepositoryError` (domain), `AppError` (HTTP with status code mapping), `anyhow::Result` (CLI). **Logging**: `tracing` + `tracing-subscriber` with `tower-http` `TraceLayer`. Configure via `RUST_LOG` (default `info`) and `RUST_LOG_FORMAT=json` for structured output. **Error logging rules** — never silently discard errors: 1. **`map_err(|_| StatusCode::*)` patterns** — log the original error before mapping. Use `warn!` for client-caused failures, `error!` for server-side. 2. **`.ok()` / `.ok()?` patterns** — replace with explicit match that logs before returning `None`. 3. **Fire-and-forget (`let _ = ...`)** — use `if let Err(err) = ...` and log. 4. **Background tasks (`tokio::spawn`)** — log inside the spawned future. **CRUD logging**: Every successful create/update/delete logs at `info!` with entity ID and key fields. **Security logging**: Auth events (login, logout, token create/revoke, passkey delete) log at `info!` with user ID. ### Foursquare Integration Nearby cafe search uses the Foursquare Places API. Set `BREWLOG_FOURSQUARE_API_KEY`. See `infrastructure/foursquare.rs` for the implementation and `tests/server/nearby_api.rs` for the `wiremock`-based test pattern. ## Datastar & Frontend ### Core Concepts The web UI uses [Datastar](https://data-star.dev/) for reactive updates without full page reloads. Key attributes: | Attribute | Purpose | Example | |-----------|---------|---------| | `data-signals:_name="value"` | Declare local signal (underscore = not sent to server) | `data-signals:_show-form="false"` | | `data-show="$_signal"` | Conditional visibility | `data-show="$_showForm"` | | `data-bind:_signal-name` | Two-way binding to input value | `data-bind:_roaster-name` | | `data-on:event="expr"` | Event handler | `data-on:submit="$_submitting = true; @post(...)"` | | `data-ref="_name"` | DOM element reference | `data-ref="_form"` | | `data-text="$_signal"` | Set text content from signal | `data-text="$_cafeName"` | | `data-attr:attr="$_signal"` | Set attribute from signal | `data-attr:value="$_roastId"` | | `@get/@post/@put/@delete` | HTTP actions with Datastar headers | `@post('/api/v1/roasters', {contentType: 'form'})` | ### Signal Naming Signal names use **kebab-case** in HTML attributes and auto-convert to **camelCase** in JS expressions and JSON: - HTML: `data-signals:_roaster-name="''"` or `data-bind:_roaster-name` - JS expression: `$_roasterName` - JSON key: `_roasterName` **Naming conventions for common signals:** | Signal | Purpose | |--------|---------| | `_extracting` | AI extraction in progress | | `_submitting` | Form save/create in progress | | `_extract-error` / `_error` | Error message | | `_show-{thing}` | Boolean visibility toggle | ### Response Types Two response formats exist for Datastar: **HTML fragments** — for replacing DOM sections. Use `render_fragment(template, selector)` from `support.rs`, which sets `datastar-selector` and `datastar-mode: replace` headers. **JSON signal patches** — for updating signal values (e.g., AI extraction filling form fields). Use `render_signals_json(&[("_signal-name", value)])` from `support.rs`. Signal keys are passed in kebab-case; the function converts to camelCase for the JSON response. ### Datastar vs JavaScript **Use Datastar for:** - Visibility toggling (`data-show` + signals) - List CRUD — delete with `confirm() && @delete()`, create with `@post()` + fragment re-render - Debounced search — `data-on:input__debounce.300ms` + `@get()` with `responseOverrides` - AI extraction signal patching - Multi-step wizards — step signals (`$_step`) with `data-show="$_step === N"` - Searchable selection lists — `` with `data-on:change` **Use JavaScript for:** - Browser APIs: WebAuthn, clipboard, geolocation, FileReader - Infinite scroll (`IntersectionObserver` in `base.html`) - Theme toggle — must run in `` before DOM renders - Any flow requiring `window.location.reload()` after completion ### AI Extraction Pattern Pages with AI-powered form filling use a Datastar-native pattern. Extraction endpoints return JSON signal patches that Datastar merges into the signal store, and `data-bind` pushes values into form fields automatically. Template structure: ```html
Take Photo
``` Server-side handler: ```rust let signals = vec![ ("_roaster-name", Value::String(result.name)), ("_roaster-country", Value::String(result.country)), ]; render_signals_json(&signals) ``` ### Web Components **``** (`static/js/components/photo-capture.js`): | Attribute | Purpose | |-----------|---------| | `target-input` | ID of hidden `` that receives the data URL | | `target-form` | ID of `
` to submit after reading the photo | Clicking the element opens the camera/file picker, reads the file as a data URL, sets the target input, and submits the form. **``** (`static/js/components/searchable-select.js`): | Attribute | Purpose | |-----------|---------| | `name` | Name for hidden `` in form submission | | `placeholder` | Search input placeholder (default: "Type to search...") | | Event | Detail | |-------|--------| | `change` | `{ value, display, data }` — fires on selection | | `clear` | Fires when selection is cleared | Place `