diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a5588fe --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,250 @@ +# AGENTS.md + +Guidance for AI agents (and humans) working in this repository. + +## Project Overview + +**kurious** (`git.loyso.art/frx/kurious`) is a course/education platform that +aggregates and serves educational course listings. It scrapes/syncs course data +from an external source (sravni.ru) via a rate-limited HTTP client, stores it +locally, and exposes it through a server-rendered web UI with filtering, +pagination, and statistics. + +The project is written in Go and follows a hexagonal (ports & adapters) +architecture with a CQRS-flavored application layer. + +## Tech Stack + +- **Language:** Go 1.26 (toolchain `go1.26.4`; see `go.mod`) +- **HTTP routing:** `github.com/gorilla/mux` +- **HTTP client:** `github.com/go-resty/resty/v2` (sravni.ru scraper) +- **Database:** SQLite via `modernc.org/sqlite` (pure-Go, CGO disabled). + YDB was historically supported but is **no longer supported** + (`service.NewApplication` returns an error for the YDB engine). +- **DB access:** `github.com/jmoiron/sqlx` (named queries) +- **Templating:** `github.com/a-h/templ` (`.templ` files compile to Go) +- **Observability:** OpenTelemetry (`go.opentelemetry.io/otel`) — traces and + metrics with stdout / OTLP (HTTP & gRPC) exporters +- **Background jobs:** `github.com/robfig/cron/v3` +- **Logging:** standard `log/slog` (text or JSON, configurable) +- **Rate limiting:** `golang.org/x/time/rate` +- **Testing:** `github.com/stretchr/testify` (assert, require, mock) +- **Mocking:** `github.com/vektra/mockery/v2` (config in `.mockery.yaml`) +- **Linting:** `golangci-lint` v1.55.2 +- **Build/task runner:** [Taskfile](https://taskfile.dev) (`Taskfile.yml`) +- **Build flags:** ldflags inject `version`, `commit`, `buildTime` + (see `kurious.go`) + +## Architecture + +The codebase implements a **hexagonal architecture** (ports & adapters) with a +CQRS-style separation between commands (writes) and queries (reads). + +``` + ┌─────────────────────────────────────────────┐ + delivery │ ports/ (HTTP server, cron jobs) │ + mechanisms └──────────────────────┬──────────────────────┘ + │ depends on + ┌──────────────────────▼──────────────────────┐ + application │ app/ (command/, query/) │ + layer │ app.Application { Commands, Queries } │ + │ decorator/ (logging decorators) │ + └──────────────────────┬──────────────────────┘ + │ depends on + ┌──────────────────────▼──────────────────────┐ + domain │ domain/ (entities, repository ports) │ + └──────────────────────┬──────────────────────┘ + │ implemented by + ┌──────────────────────▼──────────────────────┐ + adapters │ adapters/ (sqlite_*, memory_mapper, │ + │ ydb_* legacy stub) │ + └─────────────────────────────────────────────┘ +``` + +- **`domain/`** — pure business entities (`Course`, `Organization`, + `LearningCategory`) and the **repository interfaces** (ports) that the + application depends on: `CourseRepository`, `OrganizationRepository`, + `LearningCategoryRepository`. No I/O or framework code lives here. +- **`app/`** — the application layer. `app.Application` aggregates a + `Commands` struct and a `Queries` struct. Handlers in `app/command/` perform + writes; handlers in `app/query/` perform reads. Each handler implements a + generic `decorator.CommandHandler[T]` or `decorator.QueryHandler[Q, U]` + interface and is wrapped with logging decorators at construction time. +- **`adapters/`** — concrete implementations of the domain repository ports. + `sqlite_course_repository.go`, `sqlite_organization_repository.go`, etc. + translate between domain types and SQL rows. `memory_mapper.go` is an + in-memory mapper translating external dictionary IDs to human-readable names. +- **`ports/`** — delivery mechanisms. `ports/http/` is the HTTP server (gorilla/mux + + templ templates); `ports/background/` runs scheduled cron jobs (e.g. + `SyncSravniHandler`). +- **`service/`** — the **composition root**. `service.NewApplication` selects + the DB engine, constructs the SQLite connection, builds the adapters, and + wires everything into an `app.Application`. This is what binaries call. + +Data flows: an HTTP request enters `ports/http`, calls a handler on +`service.Application` (which delegates to `app/command` or `app/query`), which +calls a `domain` repository interface implemented by an `adapters/*` repository. + +## Directory Structure + +``` +. +├── kurious.go # Root package: version/commit/buildTime getters +├── Taskfile.yml # Build, test, lint, generate, run task definitions +├── .mockery.yaml # mockery config (with-expecter, keeptree) +├── go.mod / go.sum +├── cmd/ # Entry points (one package per binary) +│ ├── kuriweb/ # Main HTTP web server (config.go, main.go, http.go, trace.go) +│ ├── background/ # Background sync process (cron-driven sravni sync) +│ └── dev/sravnicli/ # Developer CLI for inspecting the sravni source +├── internal/ +│ ├── kurious/ # Application core (the hexagon) +│ │ ├── domain/ # Entities + repository interface ports +│ │ ├── app/ +│ │ │ ├── app.go # Application{Commands, Queries} aggregate +│ │ │ ├── command/ # Write-side handlers (Create, Delete, Update...) +│ │ │ └── query/ # Read-side handlers (List, Get, Stats...) +│ │ ├── adapters/ # Repository implementations + mocks/ +│ │ ├── ports/ +│ │ │ ├── http/ # HTTP server, course routes +│ │ │ │ └── bootstrap/ # templ templates (.templ) + generated _templ.go +│ │ │ ├── background/ # Cron job handlers +│ │ │ ├── background.go # BackgroundProcess (cron scheduler) +│ │ │ └── services.go # Services{HTTP, Background} aggregate +│ │ └── service/ # Composition root: NewApplication wiring +│ └── common/ # Shared, reusable utilities +│ ├── client/sravni/ # sravni.ru HTTP client + mocks/ +│ ├── config/ # Config structs (HTTP, Log, Sqlite, Trace, YDB, Duration) +│ ├── decorator/ # Command/Query handler interfaces + logging decorators +│ ├── errors/ # SimpleError, ValidationError, sentinel errors +│ ├── generator/ # ID generators +│ ├── nullable/ # Generic nullable Value[T] +│ ├── xcontext/ # context helpers (request id, log fields) +│ ├── xlog/ # slog + cron logger adapters +│ └── xslices/ # slice helpers (Map, Filter, ForEach, LRU) +├── pkg/ +│ └── xdefault/ # WithFallback helper (public-ish pkg) +├── migrations/ +│ └── sqlite/ # SQL migrations (embed.FS) + migrator.go +├── assets/kurious/static/ # Static web assets (embedded via go:embed) +└── htmlexamples/ # Standalone HTML/templ prototyping examples +``` + +## Build, Test, and Lint Commands + +All operations are driven by **Taskfile** (`task `). The toolchain is +installed into a local `bin/` (`GOBIN={{.USER_WORKING_DIR}}/bin`) and +`CGO_ENABLED=0` is enforced. + +| Command | What it does | +|--------------------------|--------------------------------------------------------------------| +| `task install_tools` | Install `golangci-lint`, `templ`, `mockery` into `bin/` | +| `task generate` | Run `templ generate` (compiles `.templ` → `_templ.go`) | +| `task mocks` | Run `go generate ./internal/...` (regenerate mockery mocks) | +| `task check` | Run `golangci-lint run ./...` (depends on `generate`) | +| `task test` | Run `go test ./internal/...` (depends on `generate`) | +| `task build_web` | Build `bin/kuriousweb` (depends on `check` + `test`) | +| `task build_background` | Build `bin/kuriousbg` (depends on `check` + `test`) | +| `task build_dev_cli` | Build `bin/sravnicli` (depends on `check` + `test`) | +| `task build` | Build all three binaries | +| `task run` | Build then run `bin/kuriousweb` | + +Typical workflow before committing: `task check && task test`. + +### Running the binaries + +Binaries read a JSON config file path as their first argument, defaulting to +`config.json` (note: `*.json` is gitignored, so configs are local). Example +fields: `log`, `sqlite`, `http.listen_addr`, `db_engine`, `tracing`. + +```bash +./bin/kuriousweb path/to/config.json +``` + +## Code Conventions + +### Decorators / handler pattern + +Command and query handlers implement generic interfaces and are wrapped with +logging decorators at construction time. When adding a new use case: + +1. Define the command/query struct and a `*Handler` type alias of + `decorator.CommandHandler[T]` / `decorator.QueryHandler[Q, U]` in + `app/command/` or `app/query/`. +2. Implement a private `*Handler` struct holding its repository dependencies. +3. Expose a `New*Handler(deps..., log *slog.Logger)` constructor that returns + the decorated handler (`decorator.ApplyCommandDecorators` / + `decorator.AddQueryDecorators`). +4. Register the new handler on `app.Commands` / `app.Queries` in + `app/app.go` and wire it in `service/service.go`. + +### Mock generation (mockery) + +- Interfaces marked with `//go:generate mockery ...` directives (see + `domain/repository.go`, `common/client/sravni/client.go`) are mocked into + sibling `mocks/` packages (`keeptree: True`, `with-expecter: true`). +- Regenerate with `task mocks` (runs `go generate ./internal/...`). +- Mocks are committed to the repo. + +### Templating (templ) + +- HTML is authored as `.templ` files under + `internal/kurious/ports/http/bootstrap/`. +- `task generate` compiles them to `*_templ.go` (committed). Run it whenever a + `.templ` file changes; `task check` and `task test` both depend on it. + +### Database / migrations + +- SQLite migrations live in `migrations/sqlite/*.sql` and are embedded via + `go:embed`. The migrator (`migrations/sqlite/migrator.go`) applies them in + order inside a transaction. +- Repository adapters use `sqlx` named queries; domain ↔ row translation is + centralized via `AsDomain()` methods. + +### Logging & observability + +- Use `log/slog` with structured attributes. Request-scoped fields (e.g. + `request_id`) are propagated through `context` via `internal/common/xcontext`. +- HTTP handlers wrap requests in trace spans and record metrics + (request duration histogram). Database adapters emit spans tagged with + `db.*` attributes. + +### Error handling + +- Domain/repository errors use sentinels from `internal/common/errors` + (`ErrNotFound`, `ErrNotImplemented`) and `*ValidationError` (mapped to HTTP + 400/404 in `ports/http/server.go`). +- Wrap errors with `fmt.Errorf("doing X: %w", err)` to add context while + preserving the underlying error for `errors.Is` / `errors.As`. + +## Testing Patterns + +- **Scope:** unit tests live next to the code they test + (`*_test.go`). `task test` runs `go test ./internal/...`. +- **Framework:** `github.com/stretchr/testify` — `require` for fatal + assertions, `assert` for non-fatal, `mock` for mock interactions. +- **Mocks:** generated mockery mocks with the **expecter** API: + ```go + repo.EXPECT().Create(mock.Anything, expectedParams). + Return(domain.Course{ID: "c1"}, nil).Once() + ``` + Note: when the generated mock does not yet implement every interface method, + tests embed the mock and add the missing methods (see the `fullRepo` pattern + in `internal/kurious/app/command/command_test.go`). +- **Logger:** tests use a `quietLogger()` helper that discards output + (`slog.New(slog.NewTextHandler(io.Discard, nil))`). +- **Adapter tests:** `internal/kurious/adapters/sqlite_*_test.go` exercise the + real SQLite repositories against an in-memory/temp database. +- **Conventions:** table-driven where appropriate; use `context.Background()` + in tests; assert on wrapped errors with `assert.ErrorIs` and message + fragments with `assert.Contains`. + +## Notes for Agents + +- Run `task generate` before linting/testing if you touched any `.templ` file. +- Do not re-introduce YDB as a working engine without updating + `service.NewApplication` and the migration story (currently SQLite-only). +- Generated files (`*_templ.go`, `mocks/*.go`) are committed — regenerate and + commit them alongside source changes. +- `*.json`, `*.sqlite`, `bin/`, and `*.log` are gitignored; do not commit + local configs or databases.