# AGENTS.md Kobo Shelf is a Kobo eReader sync server written in Rust (axum). It reads from a Calibre library's `metadata.db` (read-only) and maintains its own SQLite database for sync state, reading progress, shelves, and auth tokens. ## Build & Run ```bash cargo build # dev build cargo build --release # release build cargo run -- -l /path/to/calibre-library # run server (dir containing metadata.db) ``` ### Lint ```bash cargo clippy --all-targets -- --deny warnings # must pass with zero warnings ``` ### Format ```bash cargo fmt # format code (default rustfmt, no custom config) cargo fmt -- --check # verify formatting ``` ### Test ```bash cargo nextest run # run all tests (preferred runner) cargo nextest run test_name # run a single test by name cargo nextest run -E 'test(pattern)' # filter tests by pattern cargo test # fallback: built-in runner cargo test test_name # single test via built-in runner ``` Unit tests live inline (`#[cfg(test)]` modules) in `upload/epub.rs`, `upload/cover_search.rs`, `upload/metadata_search.rs`, `kobo/sync_token.rs`, and `kobo/models.rs`. Run them with `cargo nextest run`. The CI infrastructure (`cargo-nextest`, `cargo-llvm-cov`) is configured in `flake.nix`. ### CI Gitea Actions (`.gitea/workflows/build.yaml`) runs on the `nix` runner: a static matrix of the flake checks below plus a `package` job building `.#kobo-shelf` and `.#kobo-shelf-web-ui`. The matrix is static because Gitea can't expand a dynamic one from a prior job's outputs. Regenerate the attr list with `nix eval --json '.#githubActions.matrix' --apply 'm: map (x: x.attr) m.include'`. ### Nix (CI) ```bash nix flake check # runs all CI checks (clippy, fmt, nextest, audit, deny, doc) nix build # build the package nix develop # enter dev shell (rust-analyzer, cargo-nextest, sqlite, etc.) ``` ## Project Structure ``` src/ main.rs # Entry point: config parsing, DB setup, server start lib.rs # Module declarations only (no logic) config.rs # AppConfig (clap derive with env fallbacks) errors.rs # Error enum + Result type alias web.rs # Web API + static SPA serving (/api/*, /covers/{id}, login, OIDC, SPA fallback) oidc.rs # OIDC Relying Party: discovery, auth URL, token exchange, provisioning db/ mod.rs # Re-exports only book_store.rs # BookStore: the unified `books` catalog (single source of truth) calibre_db.rs # Read-only Calibre metadata.db reader (used only by ingest) app_db.rs # Read-write app database (books, sync state, shelves, etc.) ingest/ calibre.rs # CalibreIngest: metadata.db -> `books` table (source='calibre') upload/ epub.rs # EPUB OPF metadata + cover extraction (zip + quick-xml) enrich.rs # Optional online metadata enrichment (OpenLibrary/Google Books) cover.rs # Cover transcode to JPEG (image crate) cover_search.rs # Online cover-candidate search for the cover picker (SSRF-guarded) metadata_search.rs # Online metadata-candidate search for the book editor convert.rs # Optional kepubify EPUB->KEPUB conversion (soft dependency) handler.rs # /api/upload (+ /check), /api/books/{id} cover & metadata edit kobo/ mod.rs # Re-exports only auth.rs # AppState, KoboAuth extractor handlers.rs # All Kobo API handlers models.rs # Kobo JSON types (serde models) resources.rs # Kobo store resource dictionary (131 entries; 6 overridden to point at us) router.rs # Router construction sync.rs # SyncEngine (incremental sync algorithm) sync_point.rs # Sync point tracking sync_token.rs # Sync token encode/decode web-ui/ # Leptos CSR frontend (own crate + workspace, wasm32 only) index.html # Trunk entrypoint Trunk.toml # pre_build hook: tailwindcss -i tailwind.css -o styles.css tailwind.css # Tailwind v4 entrypoint: @theme design tokens + base layer styles.css # Generated by the hook from tailwind.css (gitignored) setup_glue.js # JS glue for the setup page (File System Access + sql.js) src/ main.rs # mount_to_body(App) api.rs # Typed /api client (gloo-net) style.rs # Shared style constants (BTN*, INPUT, ALERT_*, ...) components/ # app (router), library, book_editor, kobo_setup, login, # register, settings, setup ``` The browser UI is a Leptos client-side-rendered SPA in `web-ui/`, built with Trunk into `web-ui/dist` and embedded into the server binary via `rust-embed` (see `web.rs` `WebAssets`). It talks to the same `/api/...` JSON routes; the server serves the SPA shell for all non-API paths (SPA fallback) and enforces auth per-`/api` handler (returning `401`, not a redirect). Build it with `trunk build` in `web-ui/` (dev) or `nix build .#kobo-shelf-web-ui`; the full `nix build` builds the bundle and embeds it. None of this touches the Kobo wire contract. Styling is Tailwind v4, compiled by trunk's `pre_build` hook (`tailwindcss` comes from the devShell / the package's `nativeBuildInputs`, so no tool is downloaded). `tailwind.css` holds the `@theme` design tokens — the palette is Catppuccin Mocha, mapped semantically (`--color-bg` = mantle, `--color-elev` = base, `--color-elev2` = surface0, borders = surface1/2, accent = mauve). Mocha's accents are light, so anything filled with one takes `--color-on-accent` (crust) as its foreground. Recurring class strings live in `src/style.rs`; edit those rather than repeating utility lists per component. ## Code Style ### General Principles - Prefer structs with methods over free functions - Prefer iterators and method chaining over imperative loops - Use imperative loops only when side effects (DB writes) are involved - No `unwrap()` or `expect()` — use proper error handling everywhere ### Imports Three groups separated by blank lines, in this order: ```rust use std::collections::HashSet; // 1. std use chrono::{DateTime, Utc}; // 2. External crates (alphabetical) use tracing::info; use crate::db::calibre_db::CalibreDb; // 3. Crate-internal (crate:: prefix always) use crate::errors::Result; ``` - Multi-item imports use `{}` block syntax - Always use `crate::` prefix (never `super::` or relative paths) - Use `ResultExt as _` when only trait methods are needed - Glob imports (`*`) only for `crate::kobo::models::*` in heavy-use files ### Error Handling Uses `error-stack` 0.6 with `thiserror` 2.0. Error variants are **unit-only** (no fields) — context is attached via error-stack methods. ```rust // errors.rs pattern #[derive(Debug, thiserror::Error)] pub enum Error { #[error("Database error")] Database, // ... } pub type Result = std::result::Result>; ``` **Attaching context to Results:** ```rust // .change_context() + .attach() for static messages sqlx::query("...") .fetch_all(&self.pool) .await .change_context(Error::Database) .attach("Failed to fetch books")?; // .attach_with() for dynamic messages (avoids allocation on success) pool.connect(&url) .await .change_context(Error::Database) .attach_with(|| format!("path: {}", path.display()))?; ``` **Constructing errors from scratch:** ```rust Err(Report::new(Error::SyncToken).attach(format!("Invalid version: {v}"))) ``` **In handlers** — convert to `(StatusCode, String)`: ```rust async fn handler(State(s): State) -> Result { let data = s.calibre_db.fetch() .await .map_err(|e| { error!("Failed: {e:?}"); (StatusCode::INTERNAL_SERVER_ERROR, format!("{e}")) })?; Ok(Json(data)) } ``` ### Types & Derives - Data structs: `#[derive(Debug, Clone)]` - Serde models: `#[derive(Debug, Clone, Serialize, Deserialize)]` - Kobo API types: add `#[serde(rename_all = "PascalCase")]` - Optional fields: `#[serde(skip_serializing_if = "Option::is_none")]` - Config: `#[derive(Debug, Clone, Parser)]` ### Naming | Element | Convention | Example | |-------------------|--------------------|----------------------------------| | Modules | `snake_case` | `calibre_db`, `sync_token` | | Structs/Enums | `PascalCase` | `CalibreBook`, `SyncEngine` | | Functions | `snake_case` | `fetch_all_books`, `handle_sync` | | Constants | `SCREAMING_SNAKE` | `SYNC_TOKEN_HEADER`, `VERSION` | | Handler methods | `handle_*` | `handle_init`, `handle_metadata` | | DB reads (Calibre)| `fetch_*` | `fetch_book_by_id` | | DB writes (App) | `get_*`/`upsert_*` | `upsert_reading_state` | | Row converters | `row_to_*` | `row_to_calibre_book` | ### Namespace Structs Handler collections and router builders are unit structs with only associated functions (no fields): ```rust pub struct KoboHandlers; impl KoboHandlers { pub async fn handle_init(/* ... */) -> impl IntoResponse { /* ... */ } } ``` ### SQL Queries - Always raw `sqlx::query()` with `.bind()` — never `query_as!` or macros - Multi-line SQL in `r#"..."#` raw strings - Row extraction via `row.get::("column")` or `row.get("column")` - Standalone `fn row_to_*` functions for row-to-struct conversion - Migrations: inline SQL array in `run_migrations()`, no migration framework - Upserts: `INSERT ... ON CONFLICT(...) DO UPDATE SET` ### Async Patterns - `tokio::try_join!()` for parallel independent DB queries - `futures::future::try_join_all()` for dynamic-length async collections - `tokio::fs::read()` for file I/O (never blocking reads) ### axum Patterns - Extractors in order: `State`, `Path`, `HeaderMap`/`Json` - Return `Result` for fallible handlers - `AppState` wraps `CalibreDb`, `AppDb`, `AppConfig` in `Arc` - Kobo routes nested under `/kobo/{auth_token}`, web routes at root - Middleware: `TraceLayer` only ### Tracing ```rust info!(field = %display_val, field2 = ?debug_val, "message last"); error!("Failure: {e:?}"); // use Debug format for error-stack reports ``` ### Documentation - `///` doc comments on all public types and methods - `// ---- Section ----` dividers for grouping related functions - Handler docs include HTTP method and path: `/// GET /v1/library/sync` ### Important YOU MUST NEVER MODIFY THE API CONTRACT FOR THE KOBO API. This includes JSON shapes, header names (`x-kobo-synctoken`, `x-kobo-apitoken`, `x-kobo-sync`), route paths, and PascalCase field casing — all dictated by the Kobo device. Internal refactors are fine; wire-visible changes are not. ### Architecture notes - **Two routers mounted:** Kobo API under `/kobo/{auth_token}`, web UI at root (see `web.rs`; the Leptos SPA bundle `web-ui/dist` is embedded via `rust-embed` and served with an `index.html` SPA fallback). - **Auth:** the device sends the opaque token as an `Authorization: Bearer` JWT (the token rides in the `kobo_shelf_token` claim of its `KoboAccessToken`). `token_header::inject_bearer_token` middleware runs before routing, decodes the claim, and rewrites token-free `/kobo/v1/...` requests to the internal `/kobo/{auth_token}/...` form, so handlers and the `KoboAuth` extractor still see a path token. Image/download URLs keep the token in the path (they are self-authorizing links the device fetches without a header). - **Web login is separate from device auth.** The browser UI uses `axum-login` sessions (`auth.rs`, SQLite session store). Two methods, each independently toggleable: local username/password (`local_login`, default on, argon2 via `password-auth`) and **OIDC SSO** (`oidc.rs`, enabled when issuer + client id + client secret are all set). OIDC is Authorization Code + PKCE: `/auth/oidc/login` stashes CSRF/nonce/PKCE in the session and redirects to the IdP; `/auth/oidc/callback` verifies state, exchanges the code, validates the ID token, and provisions/links a user (`find_user_by_oidc_sub` → link an existing same-username local account → else `create_oidc_user`). Provider metadata is discovered per-login (rotation-safe). The client secret is resolved from `KOBO_SHELF_OIDC_CLIENT_SECRET`/`_FILE` in `main.rs`, never stored in `AppConfig` (which is logged). The server refuses to start if `local_login` is off and OIDC is unconfigured. `/api/auth_config` tells the login page which methods to show. None of this touches the Kobo wire contract — it is all root web routes. - **Token-minting endpoints have no Bearer.** The device calls `/v1/auth/device`, `/v1/auth/refresh`, and `/v1/user/add-device` token-free and *without* an `Authorization` header, then adopts `device_auth`'s returned `AccessToken` and presents it to `/v1/initialization`. So `device_auth` must return a JWT carrying the real token, but its request only has a `SerialNumber`. It resolves the serial → token via `device_registrations`, bound on first sync by claiming a `pending_registrations` row that setup inserts (web UI `POST /api/register-device`, or a manual insert). These three endpoints have explicit token-free routes in `router.rs`. Tokens are provisioned via the web UI or by inserting a row directly. - **The app DB `books` table is the single source of truth.** Handlers and the sync engine query only `BookStore` (`db/book_store.rs`), never Calibre live. Books enter the catalog two ways: the Calibre ingest (`ingest/calibre.rs`) reads `metadata.db` and rewrites the `source='calibre'` rows on startup / `POST /api/ingest/calibre`, preserving each Calibre `books.id`; the upload portal (`upload/`) inserts `source='upload'` rows with ids from `UPLOAD_ID_BASE` (1e9). `BookStore.book_file_path`/`cover_path` branch on `books.source` to locate files (Calibre library FS vs `upload_dir/{uuid}/`). - **Upload dedup is by stable EPUB UUID, not content hash.** Re-embedding metadata (the Calibre plugin does this on every send) changes the bytes, so a content hash would miss the re-upload. The plugin declares its Calibre library UUID as a `book_uuid` multipart field on `/api/upload`; the server stores it as `books.source_uuid` (declared wins over the OPF-parsed UUID) and self-heals legacy rows via `set_source_uuid` on any duplicate detection. `POST /api/upload/check` lets the plugin pre-filter a batch of UUIDs against `known_source_uuids` so already-synced books are skipped without transferring the file. Calibre-ingested rows carry `source_uuid = NULL`. - **Cover overrides live in a side table.** The web UI can replace any book's cover (search online candidates or upload a file). Overrides are stored as `book_covers(book_id, version)` (a separate table, so the startup `replace_calibre_books` delete+reinsert never wipes them) plus a JPEG at `{upload_dir}/overrides/{book_id}.jpg`. `cover_path` checks the override first. `version` cache-busts both the browser (`?v=`) and the device (the `CoverImageId` gains a `_v{n}` suffix, stripped by the cover handler), so a changed cover reaches the Kobo on its next sync. Endpoints live in `upload/handler.rs`; candidate search is `upload/cover_search.rs`. - **Calibre `metadata.db` is read-only.** It is only ever *read*, by the ingest. All writes go to the app DB (`kobo-shelf.db` by default). `db/calibre_db.rs` (RO reader) vs `db/app_db.rs` / `db/book_store.rs` (RW) — that separation is load-bearing. Calibre is optional: with no library configured, the catalog is served entirely from uploads. - **No migration framework** — schema lives as an inline SQL array in `run_migrations()` in `db/app_db.rs`. Append new migrations; do not reorder. - **Runtime settings + per-user book sync.** Two things are user-toggleable at runtime from the web Settings page (`AppConfig` stays startup-only): a key/value `settings` table holds `proxy_unimplemented` (whether `handle_unimplemented` 307-redirects unknown store paths to `storeapi.kobo.com` or returns 404) and `sync_new_uploads` (default sync flag stamped on new uploads). Per-book sync is per-user: `books.default_sync` is the book's default and `book_sync_prefs(user_id, book_id, enabled)` overrides it; a user's syncable set is `COALESCE(pref, default_sync)=1`, filtered in `BookStore::fetch_syncable_books_with_format(user_id)` so a toggled-off book drops out of that user's next sync-point snapshot (emitted as removed). - **Resources dictionary** in `kobo/resources.rs` has 131 entries; only six are rewritten (`image_host`, `image_url_template`, `image_url_quality_template`, `library_sync`, `device_auth`, `device_refresh`). Leave the rest alone. ### Conventions Prefer Iterators over loops Prefer a functional style with method chaining over imperative style Prefer pure functions with no side effects Prefer methods over free functions.