# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project

Mangalord is a manga / comics reader. Users browse, search, read, bookmark, and upload manga. The same HTTP API is consumed by both the SvelteKit frontend and external bots/scripts — there is no separate "bot API" or "internal API." Deployment is a single server via Docker Compose.

## Stack

- **Backend**: Rust, axum 0.7, sqlx 0.8 (Postgres), tokio
- **Database**: Postgres 16
- **Frontend**: SvelteKit 2 with Svelte 5 runes, TypeScript, Vite
- **Storage**: `Storage` trait + `LocalStorage` impl. S3 and friends are planned implementations, not refactors.
- **Tests**: `cargo test` (unit + integration via `#[sqlx::test]`), Vitest (frontend unit), Playwright (E2E)

## Development workflow rules

These rules apply to every change. They are not negotiable per-task — if a change can't be made under them, raise the conflict before writing code.

### TDD

Tests describing the expected behaviour are written **before** the implementation, in the same commit or one immediately preceding. A commit that adds production code without a corresponding test (or that updates a test only after the fact to match what was built) does not meet this bar — split it.

See *TDD workflow* below for picking the right test level.

### Git

- One branch per logical change, prefixed by intent: `feat/<slug>`, `bugfix/<slug>`, `chore/<slug>`, `docs/<slug>`, `refactor/<slug>`, `test/<slug>`. Never commit directly to `main`.
- Commit messages follow Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`). One concern per commit.
- Each branch lands via a reviewed PR (self-review counts when working solo; the diff must still be read end-to-end before merge). Squash-merge into `main` to keep history linear.
- Do not push `main` directly, do not force-push shared branches, and do not bypass hooks.

### Semantic versioning

The project version is tracked in [backend/Cargo.toml](backend/Cargo.toml) and [frontend/package.json](frontend/package.json) and bumped on every merge to `main`:

- `feat:` → bump **minor**
- `fix:` / `bugfix:` → bump **patch**
- breaking change (only valid before 1.0, otherwise needs a major bump after 1.0) → bump **minor** while under 1.0
- `chore:` / `docs:` / `test:` / `refactor:` with no behaviour change → no bump

While the project is pre-release the version stays **below `1.0.0`**. The `1.0.0` release happens **only at the user's explicit instruction** — do not bump to or past it autonomously.

Both manifests must stay in lockstep (same version string).

## TDD workflow

The project is developed test-first. For each change:

1. Write a failing test at the right level.
2. Implement the minimum to make it pass.
3. Refactor with tests green.

Pick the level deliberately:

- **Pure logic / mappers / validation** → unit test next to the code. In Rust: `#[cfg(test)] mod tests` in the same file. In the frontend: a sibling `*.test.ts`.
- **Anything that touches the DB** → integration test in [backend/tests/](backend/tests/), using `#[sqlx::test(migrations = "./migrations")]`. Each test gets a fresh, migrated database.
- **Cross-component user journeys** → Playwright in [frontend/e2e/](frontend/e2e/). Mock the network at the Playwright route level when the journey doesn't require a real backend.

Run these before claiming a change works:

```bash
(cd backend && cargo test)
(cd frontend && npm test)
(cd frontend && npm run test:e2e)   # needs dev server, see playwright.config.ts
```

Backend integration tests require `DATABASE_URL` to point at a Postgres where the test user can `CREATEDB` (the `#[sqlx::test]` macro provisions a fresh database per test).

## Backend layout

Hexagonal-ish layering. Handlers depend on `repo` and `storage`, never the other way around. This is the seam new extensions plug into.

- [backend/src/domain/](backend/src/domain/) — pure data types, no I/O. Add new types here first.
- [backend/src/repo/](backend/src/repo/) — DB access functions taking `&PgPool`. Plain async fns rather than a repository struct; tests target them directly.
- [backend/src/storage/](backend/src/storage/) — `Storage` trait + `LocalStorage`. **Add `S3Storage` here when needed**, do not branch on backend type in handlers.
- [backend/src/api/](backend/src/api/) — axum handlers and route wiring. One module per resource.
- [backend/src/app.rs](backend/src/app.rs) — `AppState` and router assembly. Integration tests use the same `router(state)` function with a test `AppState`.
- [backend/src/error.rs](backend/src/error.rs) — `AppError` is the single error type returned by handlers. New variants map to status codes in `IntoResponse`.

When adding a new resource:

1. Add a migration `backend/migrations/NNNN_<name>.sql`.
2. Add the domain type in [backend/src/domain/](backend/src/domain/).
3. Add repo functions in [backend/src/repo/](backend/src/repo/).
4. Add handlers in `backend/src/api/<resource>.rs` and merge in [backend/src/api/mod.rs](backend/src/api/mod.rs).
5. Add integration tests in [backend/tests/](backend/tests/), reusing [backend/tests/common/mod.rs](backend/tests/common/mod.rs).

### Database access

Queries use `sqlx::query_as::<_, T>(...)` (runtime-checked) with `#[derive(FromRow)]` on the domain types, so the project builds without a live DB. If you want compile-time SQL checking later, switch to `sqlx::query!`/`query_as!` macros and commit a `.sqlx` directory via `cargo sqlx prepare`.

Migrations live in [backend/migrations/](backend/migrations/) and run automatically at startup via `sqlx::migrate!`.

### Storage trait

Do not call `tokio::fs` or any filesystem API from handlers. Go through `state.storage` (the `Storage` trait). Keys are `/`-separated paths; the local backend rejects `..` and empty segments. Suggested key layout:

- `mangas/{manga_id}/cover.{ext}`
- `mangas/{manga_id}/chapters/{chapter_id}/pages/{nnnn}.{ext}`

## Frontend layout

- [frontend/src/lib/api/](frontend/src/lib/api/) — typed API client. **All backend calls go through here**, not raw `fetch` in components. The base URL is `import.meta.env.VITE_API_BASE`, defaulting to `/api`. Two layers route `/api/*` to axum depending on environment:
  - **`npm run dev`** uses the Vite proxy in [vite.config.ts](frontend/vite.config.ts) to forward `/api` to `http://localhost:8080`.
  - **Production / adapter-node** uses [frontend/src/hooks.server.ts](frontend/src/hooks.server.ts) to reverse-proxy `/api/*` to `BACKEND_URL` (compose wires `http://backend:8080`). The browser only ever talks to the SvelteKit container on `:3000`, so cookies stay same-origin and `CORS_ALLOWED_ORIGINS` can stay empty.
- [frontend/src/routes/](frontend/src/routes/) — SvelteKit routes.
- Use Svelte 5 runes (`$state`, `$derived`, `$effect`, `$props`). Do not use the legacy `let`-reactive syntax or `on:event=` directive form — prefer `onevent={...}`.
- The Node adapter is configured for production; `npm run build && node build` is what the Dockerfile runs.

## Common commands

```bash
# Full stack (compose), production-like
docker compose up --build

# Local dev: Postgres in compose, backend + frontend native
docker compose -f docker-compose.dev.yml up -d
(cd backend && cargo run)
(cd frontend && npm run dev)

# Backend tests
(cd backend && cargo test)
(cd backend && cargo test --test api_mangas)         # single integration test file
(cd backend && cargo test list_is_empty_initially)   # single test by name

# Frontend tests
(cd frontend && npm test)                            # vitest, all
(cd frontend && npx vitest run src/lib/api/mangas.test.ts)  # single file
(cd frontend && npm run test:e2e)                    # playwright (auto-starts vite)
```

## Extension points

These are first-class slots in the architecture. When adding any of them, plug into the existing seam rather than building parallel infrastructure.

- **Tags / lists**: new tables joined to `mangas`. New `domain`, `repo`, and `api` modules; the existing manga endpoints do not need to change.
- **Per-page collections / tags**: `collections` is heterogeneous — `collection_mangas` holds whole mangas, `collection_pages` holds individual pages (FK to `pages.id`). Per-user page tags live in `page_tags`, which references the **shared** `tags` table by `tag_id` (migration 0024) — the same lookup table `manga_tags` uses, so manga tags and page tags share one global vocabulary. The HTTP contract still speaks tag *names*; `repo::page_tag` resolves name↔id via `repo::tag::upsert_by_name` and applies the stricter page-tag `normalize_tag` (lowercase, collapse whitespace, reject wildcards/control/invisible chars) at the API layer. Both `collection_pages` and `page_tags` cascade-delete with `pages` and `chapters`, so a saved-page reference is dropped only when its `pages` row is genuinely deleted — i.e. when the chapter is deleted (cascade), or when a user deletes and re-creates a chapter (the user upload path in `api::chapters::finalize_chapter` inserts a *new* chapter row with fresh page ids). A crawler **re-fetch** does **not** drop saves: `content::persist_pages` upserts pages by `(chapter_id, page_number)` (`ON CONFLICT (…) DO UPDATE … RETURNING id`), preserving each `pages.id`, so collections and page tags keyed on that id survive the re-fetch. (Migration 0023's header comment predates this and describes the cascade as an unconditional "re-upload drops saves"; the checked-in migration text is intentionally left as-is because sqlx checksums applied migrations.)
- **Tag-based content search (`/search`)**: the user-facing search surface lives at [frontend/src/routes/search/+page.svelte](frontend/src/routes/search/+page.svelte). Three result views (Pages / Chapters / Mangas) consume the matching `/v1/me/page-tags`, `/v1/me/page-tags/chapters`, and `/v1/me/page-tags/mangas` endpoints. Note the two distinct query-param spaces: `?q=` on `/v1/me/page-tags` is a tag-name prefix (for autocomplete in the "Add tag" sheet); `?text=` on the aggregation endpoints performs **OCR full-text search** — the active OCR backend writes `page_ocr_text` rows and a weighted `search_doc` tsvector, and the aggregation queries JOIN on a `plainto_tsquery` filter ranked by `ts_rank` (see [backend/src/repo/page_analysis.rs](backend/src/repo/page_analysis.rs)). (`text=` was previously reserved and returned 501 `text_search_not_yet_supported`; that placeholder is gone now that the OCR backend is active. The generic `AppError::NotImplemented` 501 mechanism remains for future feature reservations.)
- **Full-text / fuzzy search**: enable `pg_trgm` in a migration and add a GIN index on `mangas.title`; swap the `WHERE` in `repo::manga::list` to use `%` operator or `tsvector`. The API shape (`?search=...`) does not change.
- **OCR / autotagging**: a background worker (a separate binary or a tokio task spawned in `app::build`) that reads pages from `storage::Storage` and writes tag rows. Do not couple OCR to upload handlers — it runs asynchronously.
- **S3 storage**: add `storage::S3Storage` implementing `Storage`. Branch in `app::build` based on a config field (e.g., `STORAGE_BACKEND=s3`). Handlers do not change.
- **Auth**: an axum middleware producing a `CurrentUser` extractor. Bots use an API token header, browser users a session cookie — both should populate the same extractor so handlers stay backend-agnostic. Until this lands, treat all endpoints as unauthenticated (this is acknowledged technical debt, not a final design).
