# 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 │ ├── routes/ # Axum route handlers │ └── 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` ## Code Patterns ### Repository Pattern All data access goes through trait-based repositories defined in `domain/repositories.rs`: ```rust #[async_trait] pub trait RoasterRepository { async fn insert(&self, roaster: NewRoaster) -> Result; async fn get(&self, id: RoasterId) -> Result; // ... } ``` SQL implementations live in `infrastructure/repositories/`. Each uses a private `Record` struct (e.g., `BagRecord`) with a `to_domain()` method to convert from database row to domain entity: ```rust impl BagRecord { fn to_domain(self) -> Bag { ... } } ``` ### Typed IDs Use the typed ID wrappers from `domain/ids.rs` to prevent mixing up IDs: ```rust // Good fn get_roast(&self, id: RoastId) -> Result // Bad - raw i64 could be any ID type fn get_roast(&self, id: i64) -> Result ``` ### SQL Query Construction Use `QueryBuilder` for dynamic queries. For UPDATE queries, use the `push_update_field!` macro: ```rust use super::macros::push_update_field; let mut builder = QueryBuilder::new("UPDATE roasters SET "); let mut sep = false; push_update_field!(builder, sep, "name", changes.name); push_update_field!(builder, sep, "country", changes.country); // ... more fields if !sep { return Err(RepositoryError::unexpected("No fields provided for update")); } builder.push(" WHERE id = "); builder.push_bind(i64::from(id)); ``` ### Sorting/Ordering Each repository has an `order_clause()` method for consistent sort query generation: ```rust fn order_clause(request: &ListRequest) -> String { let dir_sql = match request.sort_direction() { SortDirection::Asc => "ASC", SortDirection::Desc => "DESC", }; match request.sort_key() { RoasterSortKey::Name => format!("LOWER(name) {dir_sql}, created_at DESC"), // ... } } ``` ### CLI Commands For simple get/delete commands, use the macros in `presentation/cli/macros.rs`: ```rust use super::macros::{define_get_command, define_delete_command}; define_get_command!(GetRoasterCommand, get_roaster, RoasterId, roasters); define_delete_command!(DeleteRoasterCommand, delete_roaster, RoasterId, roasters, "roaster"); ``` ### Datastar Integration The web UI uses [Datastar](https://data-star.dev/) for reactive updates without full page reloads. This provides HTMX-style interactions with a declarative API. #### Request Detection Datastar requests are identified by the `datastar-request: true` header: ```rust // application/routes/support.rs pub fn is_datastar_request(headers: &HeaderMap) -> bool { headers .get("datastar-request") .and_then(|value| value.to_str().ok()) .map(|value| value.eq_ignore_ascii_case("true")) .unwrap_or(false) } ``` #### Fragment Rendering When a Datastar request is detected, return a fragment instead of a full page: ```rust pub(crate) async fn roasters_page(...) -> Result { let (request, search) = query.into_request_and_search::(); if is_datastar_request(&headers) { // Datastar request → return fragment only return render_roaster_list_fragment(state, request, search, is_authenticated).await; } // Traditional request → return full page with layout let template = RoastersTemplate { ... }; render_html(template).map(IntoResponse::into_response) } ``` Fragments are rendered with special headers that tell Datastar where to patch the DOM: ```rust // application/routes/support.rs pub fn render_fragment(template: T, selector: &'static str) -> Result { let html = render_template(template)?; let mut response = Html(html).into_response(); response.headers_mut().insert("datastar-selector", HeaderValue::from_static(selector)); response.headers_mut().insert("datastar-mode", HeaderValue::from_static("replace")); Ok(response) } ``` #### Frontend Attributes Templates use Datastar attributes for interactivity: ```html
``` Key attributes: - `data-signals:_name="value"` — Local signals (underscore prefix excludes from backend requests) - `data-show="$_signal"` — Conditional visibility - `data-bind:_signal-name` — Two-way binding between signal and input value - `data-on:event="expression"` — Event handlers - `data-ref="_name"` — DOM element references (underscore prefix for local refs) - `data-text="$_signal"` — Set element text content from signal - `data-attr:value="$_signal"` — Set element attribute from signal (used for hidden inputs) - `@get/@post/@put/@delete(url, options)` — HTTP actions with automatic Datastar headers #### Signal Naming Signal names use kebab-case in HTML attributes and auto-convert to camelCase in JS expressions: - HTML attribute: `data-signals:_roaster-name="''"` or `data-bind:_roaster-name` - JS expression: `$_roasterName` - JSON response key: `_roasterName` (camelCase) #### Two-Way Binding Use `data-bind:_signal-name` for two-way binding between signals and form inputs. **Do not use `data-model`** — it does not exist in Datastar v1 and is silently ignored. #### URL Generation `ListNavigator` generates URLs for pagination and sorting: ```rust // presentation/web/views.rs navigator.page_href(2) // "/roasters?page=2&..." (full page) navigator.fragment_page_href(2) // "/roasters?page=2&...#roaster-list" (fragment) navigator.sort_href(key) // "/roasters?sort=name&dir=..." ``` #### Flexible Payload Handling Handlers accept both JSON and form data via `FlexiblePayload`. When form fields don't map directly to the domain `New*` struct (e.g., the form sends a roaster name that needs to be resolved to an ID), use a `*Submission` newtype that handles the conversion: ```rust pub(crate) async fn create_roaster( payload: FlexiblePayload, // simple — form maps 1:1 to domain // or: FlexiblePayload // submission type — needs conversion ) -> Result { let (new_roaster, source) = payload.into_parts(); if is_datastar_request(&headers) { render_fragment(state, request, true).await // Return updated fragment } else if matches!(source, PayloadSource::Form) { Ok(Redirect::to(&target).into_response()) // Traditional form redirect } else { Ok((StatusCode::CREATED, Json(roaster)).into_response()) // JSON API } } ``` ### Route Handler Macros For simple get/delete API handlers, use the macros in `application/routes/macros.rs`: ```rust use super::macros::{define_get_handler, define_enriched_get_handler, define_delete_handler}; // GET /api/v1/roasters/:id → returns JSON define_get_handler!(get_roaster, RoasterId, Roaster, roaster_repo); // GET with enriched data (joins related entities) → returns JSON define_enriched_get_handler!(get_roast, RoastId, RoastWithRoaster, roast_repo, get_with_roaster); // DELETE /api/v1/roasters/:id → returns fragment for Datastar or 204 for API define_delete_handler!( delete_roaster, RoasterId, RoasterSortKey, roaster_repo, render_roaster_list_fragment ); ``` ### Route Module Structure Each list-bearing route module (roasters, roasts, bags, gear, brews) follows the same internal structure: ```rust // Path constants for full-page and fragment URLs const ROASTER_PAGE_PATH: &str = "/roasters"; const ROASTER_FRAGMENT_PATH: &str = "/roasters#roaster-list"; // Data loader — calls repo.list() and builds view models via build_page_view() async fn load_roaster_page(state, request, search) -> Result<(Paginated, ListNavigator), AppError> // Page handler — checks is_datastar_request(), returns full page or fragment pub(crate) async fn roasters_page(...) -> Result // Fragment renderer — returns just the list partial for Datastar replacement async fn render_roaster_list_fragment(state, request, search, is_authenticated) -> Result ``` The `build_page_view()` helper in `application/routes/support.rs` standardises the conversion from a repo `Page` to `(Paginated, ListNavigator)`: ```rust let (items, navigator) = build_page_view(page, request, RoasterView::from, ROASTER_PAGE_PATH, ROASTER_FRAGMENT_PATH, search); ``` ### When to Use Datastar vs JavaScript **Use Datastar for:** - Visibility toggling (`data-show` + signals) — replaces `classList.add/remove("hidden")` - 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 — server returns `application/json` signal patches via `render_signals_json()` - Multi-step wizards — step signals (`$_step`) with `data-show="$_step === N"` - Searchable selection lists — use `` component with `data-on:change` **Use JavaScript for:** - Browser APIs: WebAuthn (`navigator.credentials`), clipboard (`navigator.clipboard`), geolocation (`navigator.geolocation`), FileReader - Infinite scroll (`IntersectionObserver` in `base.html`) — no native Datastar equivalent - Theme toggle — must run in `` before DOM renders, manipulates `` data-theme attribute + `localStorage` - Any flow that requires `window.location.reload()` after completion (delete passkey, revoke token) **Signal naming conventions for in-progress states:** - `_extracting` — AI extraction in progress (consistent across home, add, check-in pages) - `_submitting` — form save/create in progress - `_extract-error` / `_error` — error message signals - `_show-{thing}` — boolean visibility toggles (e.g. `_show-passkey-form`) ### Static Assets Static files live in `static/` and are compiled into the binary via `include_str!()`/`include_bytes!()`. Each file needs an explicit route in `application/routes/mod.rs`: ```rust .route("/styles.css", get(styles)) .route("/favicon.ico", get(favicon)) async fn styles() -> impl IntoResponse { ( [("content-type", "text/css; charset=utf-8")], include_str!("../../../static/css/styles.css"), ) } ``` There is no `tower-http` static file serving — all assets are embedded at compile time. Web component JS files live in `static/js/components/` and are served via explicit routes (e.g., `/components/photo-capture.js`, `/components/searchable-select.js`). Most interactivity is handled via Datastar attributes and minimal inline JS. ### CSS Architecture The UI uses **Tailwind CSS v4** built via the standalone CLI (no Node.js required). The Nix flake provides `tailwindcss_4` in both the devShell and the package `preBuild`. **Source file**: `static/css/input.css` — the single source of truth for all styles. **Generated file**: `static/css/styles.css` — gitignored, built automatically by `build.rs`. #### Build integration `build.rs` runs `tailwindcss` automatically during `cargo build`. It watches all files under `templates/` and `static/` and re-runs when any change. Release builds pass `--minify`. If `tailwindcss` is not found on `PATH`, the build prints a warning but continues (useful for CI without the CLI installed). There is **no need to run `tailwindcss` manually** — `cargo build` / `cargo run` handles it. You can still run it directly for validation or to check output without a full Rust compile: ```bash tailwindcss -i static/css/input.css -o static/css/styles.css ``` For continuous CSS-only iteration, the `--watch` flag is useful alongside `cargo watch`: ```bash tailwindcss -i static/css/input.css -o static/css/styles.css --watch # terminal 1 cargo watch -x run # terminal 2 ``` #### Design Tokens Colors are defined as raw CSS custom properties in `:root` (light) and `[data-theme="dark"]` (dark), then mapped to Tailwind utilities via `@theme`: | Token | Light | Dark | Tailwind class | |-------|-------|------|---------------| | `--page` | `#fafaf9` (stone-50) | `#1c1917` | `bg-page` | | `--surface` | `#ffffff` | `#292524` | `bg-surface` | | `--surface-alt` | `#f5f5f4` (stone-100) | `#44403c` | `bg-surface-alt` | | `--border` | `#e7e5e4` (stone-200) | `#57534e` | default `border` | | `--accent` | `#c2410c` (orange-700) | `#ea580c` | `bg-accent`, `text-accent` | | `--accent-hover` | `#ea580c` | `#f97316` | `bg-accent-hover`, `hover:bg-accent-hover` | | `--accent-subtle` | `#fff7ed` (orange-50) | `rgba(234,88,12,0.1)` | `bg-accent-subtle` | | `--accent-text` | `#ffffff` | `#ffffff` | `text-accent-text` | | `--text` | `#1c1917` (stone-900) | `#e7e5e4` (stone-200) | `text-text` | | `--text-secondary` | `#57534e` (stone-600) | `#d6d3d1` (stone-300) | `text-text-secondary` | | `--text-muted` | `#78716c` (stone-500) | `#a8a29e` (stone-400) | `text-text-muted` | A base layer rule sets the default border color so bare `border` / `divide-y` classes use the theme: ```css @layer base { *, ::after, ::before { border-color: var(--border); } } ``` Neutral text colors use the tokenised utilities (`text-text`, `text-text-secondary`, `text-text-muted`) which adapt automatically between light and dark themes. Do not use hardcoded `text-stone-*` classes for neutral text — always use the token-based classes. #### Dark Mode - `[data-theme="dark"]` attribute on `` — set via a `