# v0 task 01: module, gate and the config package **Branch:** `v0` (create it: `git switch -c v0`; `git status --short` must be empty first, otherwise stop) **Commit subject:** `Add the Go module, the gate and the config package` ## Goal Create the Go module and the gate every later task must pass, and write `internal/config`: the TOML file crossbar starts from, with defaults and validation. At the end `make gate` prints `gate: ok`. ## Context crossbar is an HTTP reverse proxy in front of several `llama-server` routers ("hosts"). A client's identity is the first segment of its request path (its "route"); each route has an ordered list of hosts to try. The config file names the hosts, which models each serves, and the routes. It must be strict: a misspelt key or a route naming a host that does not exist is an error at start, not a surprise at 3 a.m. The one dependency is `github.com/BurntSushi/toml` v1.6.0; `go.sum` for it is given. ## Files - Copy (never edit afterwards): `Makefile`, `scripts/check-lines.sh`, `go.sum`, `internal/config/config_test.go`, `internal/config/testdata/` (5 files) - Create: `go.mod`, `internal/config/config.go` - Modify: `docs/implementer-log.md` ## Interfaces Produces, in `internal/config/config.go`, package `config`: ```go // Duration is a time.Duration that TOML reads from a string such as "60s" or "30m". type Duration struct{ time.Duration } func (d *Duration) UnmarshalText(text []byte) error // time.ParseDuration type Model struct { Parallel int `toml:"parallel"` } type Host struct { BaseURL string `toml:"base_url"` Weight float64 `toml:"weight"` Models map[string]Model `toml:"models"` } type Route struct { Hosts []string `toml:"hosts"` DefaultModel string `toml:"default_model"` } type Config struct { Listen string `toml:"listen"` PollInterval Duration `toml:"poll_interval"` QueueMax int `toml:"queue_max"` Hosts map[string]Host `toml:"hosts"` Routes map[string]Route `toml:"routes"` } // Error is a validation error naming the field it is about. type Error struct { Field, Msg string } func (e *Error) Error() string // "config: " + Field + ": " + Msg const ( DefaultPollInterval = 60 * time.Second DefaultQueueMax = 8 MinPollInterval = time.Second ) func Load(path string) (*Config, error) // open, then Parse func Parse(r io.Reader) (*Config, error) // decode, defaults, validate func (c *Config) Serves(host, model string) bool // host exists and lists model func IsError(err error) (*Error, bool) // errors.As on *Error ``` Rules the tests check: 1. **Decoding.** `toml.NewDecoder(r).Decode(&c)`. A TOML syntax error is returned as `fmt.Errorf("config: %w", err)` — it is *not* an `*Error`. If `md.Undecoded()` is non-empty, the error is `&Error{Field: , Msg: "unknown key"}`. `Load` wraps an open failure the same way (`config: …`). 2. **Validation, in this order, first problem wins.** Every problem is an `*Error` with exactly this `Field`: - `listen`: required; must be `host:port` (`net.SplitHostPort`); the host part must not be empty and must not be an unspecified address (`0.0.0.0`, `::`). - `poll_interval`: `0` (absent) becomes `DefaultPollInterval`; less than `MinPollInterval` is an error. - `queue_max`: `0` becomes `DefaultQueueMax`; negative is an error. - `hosts`: at least one. Then for each host, **in sorted name order**: - `hosts..base_url`: `url.Parse` must succeed, scheme `http` or `https`, non-empty host, no query, no fragment. A trailing `/` is trimmed (`strings.TrimRight(url, "/")`) and the trimmed value stored. - `hosts..weight`: `0` becomes `1`; negative is an error. - `hosts..models`: at least one. For each model in sorted order, `hosts..models..parallel`: `0` becomes `1`; negative is an error. - `routes`: at least one. For each route in sorted name order: - `routes.`: the name must match `^[a-z0-9][a-z0-9-]*$`. - `routes..hosts`: at least one; every entry must be a configured host; no host twice. - `routes..default_model`: if set, at least one of the route's hosts must list it. 3. Defaults are written back into the returned `Config` (the tests read `Weight == 1`, `Parallel == 1`, `PollInterval == DefaultPollInterval` after parsing files that omit them). 4. Nothing here panics on a bad file: every map lookup and slice access is on data you checked. ## Steps - [ ] **1. Branch and copy.** ```sh git switch -c v0 mkdir -p scripts internal/config/testdata cp docs/plans/v0/_files/Makefile docs/plans/v0/_files/go.sum . cp docs/plans/v0/_files/scripts/check-lines.sh scripts/ cp docs/plans/v0/_files/internal/config/config_test.go internal/config/ cp docs/plans/v0/_files/internal/config/testdata/*.toml internal/config/testdata/ ``` Read `Makefile` and `internal/config/config_test.go`. The test names every rule above. - [ ] **2. Write `go.mod`** with exactly this content: ``` module git.wntrmute.dev/kyle/crossbar go 1.26 require github.com/BurntSushi/toml v1.6.0 ``` Then `go mod download github.com/BurntSushi/toml` (this needs the network once) and `go mod verify`. Expected: `all modules verified`. - [ ] **3. See the test fail.** `go test ./internal/config/`. Expected: it does not compile (`no Go files` or undefined names). - [ ] **4. Write `internal/config/config.go`.** Run `gofmt -w internal/config/`. - [ ] **5. See the test pass.** `go test ./internal/config/`. Expected: `ok`. - [ ] **6. Run the gate.** `make gate`. Expected last line: `gate: ok`. - [ ] **7. Log and commit.** Add your row to `docs/implementer-log.md` (Task `v0/01-module-gate-config`). ```sh git add go.mod go.sum Makefile scripts/check-lines.sh internal/config docs/implementer-log.md git commit ``` ## Done when - `go test ./internal/config/` is `ok`; `make gate` prints `gate: ok`. - `cmp internal/config/config_test.go docs/plans/v0/_files/internal/config/config_test.go` prints nothing. ## Stop and report if - `go mod download` cannot fetch the module (no network): stop, log `stopped`. - A test expects a `Field` you cannot produce under the rules above: stop; do not edit the test.