~bigbes/huntsman

783841b91eafd678cb3895cfcc8dfd89f290ece7 — Eugene Blikh 6 days ago
Initial commit: multi-provider search router
A  => .gitignore +9 -0
@@ 1,9 @@
# Build output
/huntsman

# Local config
/config.yaml

# Editor / OS
*.swp
.DS_Store

A  => .golangci.yml +45 -0
@@ 1,45 @@
version: "2"

run:
  timeout: 5m

linters:
  enable:
    - misspell
    - whitespace
    - errcheck
    - govet
    - staticcheck
    - ineffassign
    - unused
    - gosec
    - gocritic
    - revive
    - unconvert
    - unparam
    - prealloc
    - noctx
    - bodyclose
  settings:
    gocritic:
      enabled-tags:
        - diagnostic
        - style
        - performance
      disabled-checks:
        # Provider/OSD structs are tiny config payloads that we copy by value
        # for clarity. Pointer receivers would force nil checks for no gain.
        - hugeParam
    revive:
      rules:
        - name: exported
          disabled: true

formatters:
  enable:
    - gofmt
    - goimports

issues:
  max-issues-per-linter: 0
  max-same-issues: 0

A  => Dockerfile +18 -0
@@ 1,18 @@
FROM golang:1.26-alpine AS builder

WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download

COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o huntsman ./cmd/server

FROM alpine:3.20
RUN apk --no-cache add ca-certificates tzdata

WORKDIR /app
COPY --from=builder /app/huntsman .
COPY config.example.yaml /app/config.yaml

EXPOSE 8080
ENTRYPOINT ["/app/huntsman", "-config", "/app/config.yaml"]

A  => Justfile +40 -0
@@ 1,40 @@
binary := "huntsman"
version := `git describe --tags --always 2>/dev/null || echo dev`
commit := `git rev-parse --short HEAD 2>/dev/null || echo none`
date := `date -u +%Y-%m-%dT%H:%M:%SZ`

ldflags := "-X main.version=" + version + " -X main.commit=" + commit + " -X main.date=" + date

default:
    @just --list

build:
    go build -ldflags "{{ldflags}}" -o {{binary}} ./cmd/server

run: build
    ./{{binary}} -config config.yaml

dev:
    go run ./cmd/server -config config.yaml

test:
    go test -race ./...

lint:
    golangci-lint run ./...

fmt:
    gofmt -s -w .
    goimports -w .

tidy:
    go mod tidy

docker-build:
    docker build -t {{binary}}:{{version}} .

docker-run:
    docker compose up -d --build

docker-stop:
    docker compose down

A  => README.md +73 -0
@@ 1,73 @@
# huntsman

A small self-hosted HTTP service that acts as a multi-provider search router.

It exposes a single OpenSearch description that you can register in your
browser's address bar. When you type a query, a short prefix decides which
upstream search engine handles it.

## Why

Browsers let you add at most one default search engine. If you regularly
search Urban Dictionary, GitHub, and Steam, you end up adding three
engines and learning three keywords per browser. This service collapses
that into a single `<link rel="search">` and a unified prefix grammar
that works in any browser, on any device.

## Providers

| Prefix  | Provider          | Upstream                              |
| ------- | ----------------- | ------------------------------------- |
| `ud`    | Urban Dictionary  | `urbandictionary.com/define.php`      |
| `gh`    | GitHub            | `github.com/search`                   |
| `steam` | Steam Store       | `store.steampowered.com/search`       |

Both `ud foo bar` and `ud:foo bar` work. With no recognized prefix the
query falls through to the configured default provider.

## Endpoints

| Method | Path                            | Purpose                                       |
| ------ | ------------------------------- | --------------------------------------------- |
| GET    | `/`                             | Landing page advertising the OSD via `<link>` |
| GET    | `/search?q=...`                 | 302 redirect to the chosen provider           |
| GET    | `/providers`                    | JSON list of registered providers             |
| GET    | `/opensearch.xml`               | Unified OSD pointing at `/search`             |
| GET    | `/opensearch/{provider}.xml`    | Per-provider OSD pointing directly upstream   |
| GET    | `/healthz`, `/readyz`           | Liveness & readiness probes                   |

## Quick start

```sh
cp config.example.yaml config.yaml
# edit server.public_url to match your deployment
just dev
# then open http://localhost:8080/ in a browser and add the search engine
```

## Configuration

Config is YAML. Every field can be overridden by an environment variable
prefixed `APP_` with `.` replaced by `_`, e.g. `APP_SERVER_PORT=9000`.

```yaml
server:
  host: "0.0.0.0"
  port: 8080
  public_url: "https://search.example.com"  # used in OSD XML
search:
  default_provider: gh                       # ud | gh | steam
log:
  level: info
  format: human                              # human | json
```

## Build / run

```sh
just build           # produces ./huntsman
just test            # unit tests
just lint            # golangci-lint
just docker-build    # container image
just docker-run      # docker compose up -d --build
```

A  => cmd/server/main.go +52 -0
@@ 1,52 @@
// Command server runs the multi-provider search helper.
package main

import (
	"context"
	"flag"
	"fmt"
	"log/slog"
	"os"
	"os/signal"
	"syscall"

	"go.bigb.es/auxilia/scribe"

	"sourcecraft.dev/bigbes/huntsman/internal/app"
)

// Set by goreleaser / -ldflags.
var (
	version = "dev"
	commit  = "none"
	date    = "unknown"
)

func main() {
	os.Exit(run())
}

func run() int {
	configPath := flag.String("config", "config.yaml", "path to config file")
	showVersion := flag.Bool("version", false, "print version and exit")
	flag.Parse()

	if *showVersion {
		fmt.Printf("huntsman %s (commit %s, built %s)\n", version, commit, date)
		return 0
	}

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	a, err := app.New(ctx, *configPath, app.BuildInfo{Version: version, Commit: commit, Date: date})
	if err != nil {
		slog.Error("startup failed", scribe.Err(err))
		return 1
	}
	if err := a.Run(ctx); err != nil {
		slog.Error("run failed", scribe.Err(err))
		return 1
	}
	return 0
}

A  => config.example.yaml +17 -0
@@ 1,17 @@
server:
  host: "0.0.0.0"
  port: 8080
  read_timeout: 15s
  write_timeout: 15s
  # Externally-visible URL of this service. Used in OpenSearch description
  # XML so browsers know where to fetch the search template from.
  public_url: "http://localhost:8080"

log:
  level: info   # debug, info, warn, error
  format: human # human, json

search:
  # Provider used when the query has no recognized prefix.
  # One of: ud, gh, steam.
  default_provider: gh

A  => docker-compose.yml +10 -0
@@ 1,10 @@
services:
  app:
    build: .
    ports:
      - "8080:8080"
    environment:
      APP_LOG_FORMAT: json
      APP_LOG_LEVEL: info
      APP_SERVER_PUBLIC_URL: "http://localhost:8080"
    restart: unless-stopped

A  => go.mod +27 -0
@@ 1,27 @@
module sourcecraft.dev/bigbes/huntsman

go 1.26.1

require (
	github.com/go-chi/chi/v5 v5.2.5
	github.com/google/uuid v1.6.0
	github.com/spf13/viper v1.21.0
	go.bigb.es/auxilia v0.2.0
)

require (
	github.com/dominikbraun/graph v0.23.0 // indirect
	github.com/fsnotify/fsnotify v1.9.0 // indirect
	github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
	github.com/sagikazarmark/locafero v0.11.0 // indirect
	github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
	github.com/soverenio/vanilla v0.0.0-20240904095435-e5a9e99f83ca // indirect
	github.com/spf13/afero v1.15.0 // indirect
	github.com/spf13/cast v1.10.0 // indirect
	github.com/spf13/pflag v1.0.10 // indirect
	github.com/subosito/gotenv v1.6.0 // indirect
	go.yaml.in/yaml/v3 v3.0.4 // indirect
	golang.org/x/sys v0.42.0 // indirect
	golang.org/x/text v0.35.0 // indirect
)

A  => go.sum +57 -0
@@ 1,57 @@
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dominikbraun/graph v0.23.0 h1:TdZB4pPqCLFxYhdyMFb1TBdFxp8XLcJfTTBQucVPgCo=
github.com/dominikbraun/graph v0.23.0/go.mod h1:yOjYyogZLY1LSG9E33JWZJiq5k83Qy2C6POAuiViluc=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
github.com/soverenio/vanilla v0.0.0-20240904095435-e5a9e99f83ca h1:rB4eZDa14pktnbTiEn9GpTt3b78e1l+kK35/qhoc/PI=
github.com/soverenio/vanilla v0.0.0-20240904095435-e5a9e99f83ca/go.mod h1:EiaNftWfwv+c5RT1C2PXI9ZJjBJwtOymKQl56sC+ORE=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.bigb.es/auxilia v0.2.0 h1:qAkyDiNCihdUMqxiHOavAuqgtGNZ8ZkIwCFV6UcaM80=
go.bigb.es/auxilia v0.2.0/go.mod h1:7TFf9RZZo0YbXQ3mY9GnirwxgVTa9HXaJBHr+1rU0LY=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

A  => internal/app/app.go +80 -0
@@ 1,80 @@
// Package app wires the application together using auxilia/steward for
// lifecycle management. New constructs the steward Manager; Run drives
// it through Inject -> Init -> Start, blocks on context cancellation,
// then runs Stop and Destroy.
package app

import (
	"context"
	"time"

	"go.bigb.es/auxilia/culpa"
	"go.bigb.es/auxilia/steward"

	"sourcecraft.dev/bigbes/huntsman/internal/config"
)

// BuildInfo carries linker-injected build metadata for /providers and logs.
type BuildInfo struct {
	Version string
	Commit  string
	Date    string
}

// stopTimeout caps how long graceful shutdown is allowed to take after
// the run context cancels. Slightly longer than the per-server shutdown
// budget so steward can finish ordering Stop calls.
const stopTimeout = 35 * time.Second

// App holds the steward Manager and the config used to build it.
type App struct {
	mgr *steward.Manager
}

// New loads config and assembles the dependency graph.
func New(ctx context.Context, configPath string, info BuildInfo) (*App, error) {
	cfg, err := config.Load(configPath)
	if err != nil {
		return nil, culpa.Wrap(err, "load config")
	}

	mgr := steward.NewManager()
	mgr.AddComponent(ctx,
		steward.MustConfigurationAsset(*cfg),
		steward.MustServiceAsset(&loggerWire{}),
		steward.MustServiceAsset(&searchWire{}),
		steward.MustServiceAsset(&healthWire{}),
		steward.MustServiceAsset(&httpServer{Info: info}, steward.Root()),
	)

	if err := mgr.Inject(ctx); err != nil {
		return nil, culpa.Wrap(err, "inject")
	}
	if err := mgr.Init(ctx); err != nil {
		return nil, culpa.Wrap(err, "init")
	}
	return &App{mgr: mgr}, nil
}

// Run starts every service, blocks until ctx is cancelled, then runs
// graceful shutdown via steward.Stop and Destroy.
func (a *App) Run(ctx context.Context) error {
	if err := a.mgr.Start(ctx); err != nil {
		stopCtx, cancel := context.WithTimeout(context.Background(), stopTimeout)
		defer cancel()
		_ = a.mgr.Stop(stopCtx)
		return culpa.Wrap(err, "start")
	}

	<-ctx.Done()

	stopCtx, cancel := context.WithTimeout(context.Background(), stopTimeout)
	defer cancel()
	if err := a.mgr.Stop(stopCtx); err != nil {
		return culpa.Wrap(err, "stop")
	}
	if err := a.mgr.Destroy(stopCtx); err != nil {
		return culpa.Wrap(err, "destroy")
	}
	return nil
}

A  => internal/app/wires.go +121 -0
@@ 1,121 @@
package app

import (
	"context"
	"errors"
	"fmt"
	"log/slog"
	"net/http"
	"time"

	"go.bigb.es/auxilia/culpa"
	"go.bigb.es/auxilia/scribe"

	"sourcecraft.dev/bigbes/huntsman/internal/config"
	"sourcecraft.dev/bigbes/huntsman/internal/domain/search"
	"sourcecraft.dev/bigbes/huntsman/internal/platform/health"
	"sourcecraft.dev/bigbes/huntsman/internal/platform/observability"
	"sourcecraft.dev/bigbes/huntsman/internal/server"
)

// loggerWire builds the application's structured logger from config and
// installs it as the slog default so package-level callers pick it up.
type loggerWire struct {
	Config config.Config `config:""`

	Log *slog.Logger
}

func (w *loggerWire) Init(_ context.Context) error {
	w.Log = observability.NewLogger(w.Config.Log.Level, w.Config.Log.Format)
	slog.SetDefault(w.Log)
	return nil
}

// searchWire constructs the search service and HTTP handler. Both are
// kept on the wire so HTTP can inject the handler and other services
// can call into the service if needed later.
type searchWire struct {
	Config config.Config `config:""`

	Service *search.Service
	Handler *search.Handler
}

func (w *searchWire) Init(_ context.Context) error {
	svc, err := search.NewService(w.Config.Search.DefaultProvider, w.Config.Server.PublicURL)
	if err != nil {
		return culpa.Wrap(err, "build search service")
	}
	w.Service = svc
	w.Handler = search.NewHandler(svc)
	return nil
}

// healthWire wraps the health registry so steward owns its lifetime.
// Add upstream probes by calling Health.Register from another service's
// Init via inject:"".
type healthWire struct {
	Health *health.Health
}

func (w *healthWire) Init(_ context.Context) error {
	w.Health = health.New()
	return nil
}

// shutdownTimeout is how long http.Server.Shutdown is allowed to drain
// in-flight requests before returning.
const shutdownTimeout = 30 * time.Second

// httpServer is the root steward service. It depends on logger, search,
// and health wires (resolved by type). Init builds the chi router; Start
// runs the listener in a goroutine; Stop drains via Shutdown.
type httpServer struct {
	Config config.Config `config:""`
	Logger *loggerWire   `inject:""`
	Search *searchWire   `inject:""`
	Health *healthWire   `inject:""`

	Info BuildInfo

	srv *http.Server
}

func (s *httpServer) Init(_ context.Context) error {
	router := server.Routes(s.Logger.Log, s.Health.Health, s.Search.Handler)
	addr := fmt.Sprintf("%s:%d", s.Config.Server.Host, s.Config.Server.Port)
	s.srv = &http.Server{
		Addr:         addr,
		Handler:      router,
		ReadTimeout:  s.Config.Server.ReadTimeout,
		WriteTimeout: s.Config.Server.WriteTimeout,
		IdleTimeout:  60 * time.Second,
	}
	return nil
}

func (s *httpServer) Start(_ context.Context) error {
	go func() {
		s.Logger.Log.Info("server starting",
			"addr", s.srv.Addr,
			"version", s.Info.Version,
		)
		if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
			s.Logger.Log.Error("server stopped unexpectedly",
				scribe.Err(culpa.Wrap(err, "ListenAndServe")),
			)
		}
	}()
	return nil
}

func (s *httpServer) Stop(_ context.Context) error {
	s.Logger.Log.Info("shutting down gracefully")
	shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
	defer cancel()
	if err := s.srv.Shutdown(shutdownCtx); err != nil {
		return culpa.Wrap(err, "http shutdown")
	}
	return nil
}

A  => internal/config/config.go +89 -0
@@ 1,89 @@
// Package config defines the application configuration shape and validation.
package config

import (
	"net/url"
	"time"

	"go.bigb.es/auxilia/culpa"

	"sourcecraft.dev/bigbes/huntsman/internal/domain/search"
)

// Config is the top-level configuration for huntsman.
type Config struct {
	Server ServerConfig `mapstructure:"server"`
	Log    LogConfig    `mapstructure:"log"`
	Search SearchConfig `mapstructure:"search"`
}

// ServerConfig configures the HTTP server.
type ServerConfig struct {
	Host         string        `mapstructure:"host"`
	Port         int           `mapstructure:"port"`
	ReadTimeout  time.Duration `mapstructure:"read_timeout"`
	WriteTimeout time.Duration `mapstructure:"write_timeout"`
	// PublicURL is the externally-visible base URL of this service.
	// It's embedded in OpenSearch description XML so browsers know where to
	// fetch the search template. Example: "https://search.example.com".
	PublicURL string `mapstructure:"public_url"`
}

// LogConfig configures structured logging.
type LogConfig struct {
	Level  string `mapstructure:"level"`
	Format string `mapstructure:"format"`
}

// SearchConfig controls search-routing behavior.
type SearchConfig struct {
	// DefaultProvider is the provider used when no prefix matches.
	// Must be one of the registered provider IDs (see search.AllProviders).
	DefaultProvider string `mapstructure:"default_provider"`
}

// Validate runs hand-written checks against the loaded config. Failures
// carry an "INVALID_CONFIG" code so call sites can distinguish them.
func (c *Config) Validate() error {
	if c.Server.Host == "" {
		return invalid("server.host is required")
	}
	if c.Server.Port < 1 || c.Server.Port > 65535 {
		return invalid("server.port must be between 1 and 65535")
	}
	if c.Server.PublicURL == "" {
		return invalid("server.public_url is required")
	}
	if u, err := url.Parse(c.Server.PublicURL); err != nil || u.Scheme == "" || u.Host == "" {
		return invalid("server.public_url must be an absolute URL")
	}

	switch c.Log.Level {
	case "debug", "info", "warn", "error":
	default:
		return invalid("log.level must be one of debug|info|warn|error")
	}
	switch c.Log.Format {
	case "human", "json":
	default:
		return invalid("log.format must be one of human|json")
	}

	if !knownProvider(c.Search.DefaultProvider) {
		return invalid("search.default_provider must be a known provider id")
	}
	return nil
}

func invalid(msg string) error {
	return culpa.WithCode(culpa.New(msg), "INVALID_CONFIG")
}

func knownProvider(id string) bool {
	for _, p := range search.AllProviders() {
		if p.ID == id {
			return true
		}
	}
	return false
}

A  => internal/config/loader.go +47 -0
@@ 1,47 @@
package config

import (
	"strings"
	"time"

	"github.com/spf13/viper"
	"go.bigb.es/auxilia/culpa"
)

// Load reads YAML config from path, overlays APP_* env vars, and validates.
//
// Strict mode is enabled — unknown keys cause a load failure rather than
// being silently ignored, which catches typos in production config.
func Load(path string) (*Config, error) {
	v := viper.New()
	v.SetConfigFile(path)
	v.SetConfigType("yaml")

	v.SetEnvPrefix("APP")
	v.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
	v.AutomaticEnv()

	// Defaults — keep these in sync with config.example.yaml.
	v.SetDefault("server.host", "0.0.0.0")
	v.SetDefault("server.port", 8080)
	v.SetDefault("server.read_timeout", 15*time.Second)
	v.SetDefault("server.write_timeout", 15*time.Second)
	v.SetDefault("log.level", "info")
	v.SetDefault("log.format", "human")
	v.SetDefault("search.default_provider", "gh")

	if err := v.ReadInConfig(); err != nil {
		return nil, culpa.Wrap(err, "read config")
	}

	var cfg Config
	if err := v.UnmarshalExact(&cfg); err != nil {
		return nil, culpa.Wrap(err, "unmarshal config")
	}

	if err := cfg.Validate(); err != nil {
		return nil, culpa.Wrap(err, "validate config")
	}

	return &cfg, nil
}

A  => internal/config/loader_test.go +226 -0
@@ 1,226 @@
package config

import (
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"
)

// writeConfig drops the given YAML body into a temp file and returns its path.
func writeConfig(t *testing.T, body string) string {
	t.Helper()
	dir := t.TempDir()
	path := filepath.Join(dir, "config.yaml")
	if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
		t.Fatalf("write config: %v", err)
	}
	return path
}

func TestLoadFullConfig(t *testing.T) {
	path := writeConfig(t, `
server:
  host: "127.0.0.1"
  port: 9001
  read_timeout: 7s
  write_timeout: 8s
  public_url: "https://search.example.com"
log:
  level: debug
  format: json
search:
  default_provider: steam
`)

	cfg, err := Load(path)
	if err != nil {
		t.Fatalf("Load: %v", err)
	}
	if cfg.Server.Host != "127.0.0.1" {
		t.Errorf("host = %q", cfg.Server.Host)
	}
	if cfg.Server.Port != 9001 {
		t.Errorf("port = %d", cfg.Server.Port)
	}
	if cfg.Server.ReadTimeout != 7*time.Second {
		t.Errorf("read_timeout = %v", cfg.Server.ReadTimeout)
	}
	if cfg.Server.WriteTimeout != 8*time.Second {
		t.Errorf("write_timeout = %v", cfg.Server.WriteTimeout)
	}
	if cfg.Server.PublicURL != "https://search.example.com" {
		t.Errorf("public_url = %q", cfg.Server.PublicURL)
	}
	if cfg.Log.Level != "debug" || cfg.Log.Format != "json" {
		t.Errorf("log = %+v", cfg.Log)
	}
	if cfg.Search.DefaultProvider != "steam" {
		t.Errorf("default_provider = %q", cfg.Search.DefaultProvider)
	}
}

func TestLoadAppliesDefaults(t *testing.T) {
	// Only public_url is required and has no default.
	path := writeConfig(t, `
server:
  public_url: "http://localhost:8080"
`)
	cfg, err := Load(path)
	if err != nil {
		t.Fatalf("Load: %v", err)
	}
	if cfg.Server.Host != "0.0.0.0" {
		t.Errorf("default host = %q", cfg.Server.Host)
	}
	if cfg.Server.Port != 8080 {
		t.Errorf("default port = %d", cfg.Server.Port)
	}
	if cfg.Server.ReadTimeout != 15*time.Second {
		t.Errorf("default read_timeout = %v", cfg.Server.ReadTimeout)
	}
	if cfg.Log.Level != "info" || cfg.Log.Format != "human" {
		t.Errorf("default log = %+v", cfg.Log)
	}
	if cfg.Search.DefaultProvider != "gh" {
		t.Errorf("default provider = %q", cfg.Search.DefaultProvider)
	}
}

func TestLoadEnvOverride(t *testing.T) {
	path := writeConfig(t, `
server:
  public_url: "http://localhost:8080"
`)
	t.Setenv("APP_SERVER_PORT", "9090")
	t.Setenv("APP_LOG_LEVEL", "warn")
	t.Setenv("APP_SEARCH_DEFAULT_PROVIDER", "ud")

	cfg, err := Load(path)
	if err != nil {
		t.Fatalf("Load: %v", err)
	}
	if cfg.Server.Port != 9090 {
		t.Errorf("env override port = %d", cfg.Server.Port)
	}
	if cfg.Log.Level != "warn" {
		t.Errorf("env override log.level = %q", cfg.Log.Level)
	}
	if cfg.Search.DefaultProvider != "ud" {
		t.Errorf("env override default_provider = %q", cfg.Search.DefaultProvider)
	}
}

func TestLoadMissingFile(t *testing.T) {
	_, err := Load(filepath.Join(t.TempDir(), "does-not-exist.yaml"))
	if err == nil {
		t.Fatal("expected error for missing file")
	}
}

func TestLoadInvalidYAML(t *testing.T) {
	path := writeConfig(t, "this: is: not: valid: yaml:\n  - [")
	if _, err := Load(path); err == nil {
		t.Fatal("expected parse error")
	}
}

func TestLoadUnknownKeyRejected(t *testing.T) {
	// UnmarshalExact rejects unknown keys, which catches typos.
	path := writeConfig(t, `
server:
  public_url: "http://localhost:8080"
  bogus_field: 42
`)
	_, err := Load(path)
	if err == nil {
		t.Fatal("expected error for unknown key")
	}
	if !strings.Contains(err.Error(), "bogus_field") && !strings.Contains(err.Error(), "unmarshal") {
		t.Errorf("error should mention unknown key or unmarshal, got: %v", err)
	}
}

func TestLoadValidationErrors(t *testing.T) {
	cases := []struct {
		name string
		body string
	}{
		{
			name: "missing public_url",
			body: `
server:
  host: "0.0.0.0"
  port: 8080
`,
		},
		{
			name: "invalid public_url",
			body: `
server:
  public_url: "not a url"
`,
		},
		{
			name: "port out of range",
			body: `
server:
  public_url: "http://localhost:8080"
  port: 70000
`,
		},
		{
			name: "unknown log level",
			body: `
server:
  public_url: "http://localhost:8080"
log:
  level: shout
`,
		},
		{
			name: "unknown log format",
			body: `
server:
  public_url: "http://localhost:8080"
log:
  format: yaml
`,
		},
		{
			name: "unknown default provider",
			body: `
server:
  public_url: "http://localhost:8080"
search:
  default_provider: yahoo
`,
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			path := writeConfig(t, tc.body)
			if _, err := Load(path); err == nil {
				t.Fatal("expected validation error")
			}
		})
	}
}

func TestValidateDirectly(t *testing.T) {
	cfg := &Config{
		Server: ServerConfig{Host: "0.0.0.0", Port: 8080, PublicURL: "http://localhost:8080"},
		Log:    LogConfig{Level: "info", Format: "human"},
		Search: SearchConfig{DefaultProvider: "gh"},
	}
	if err := cfg.Validate(); err != nil {
		t.Fatalf("Validate on good config: %v", err)
	}

	cfg.Search.DefaultProvider = ""
	if err := cfg.Validate(); err == nil {
		t.Fatal("expected validation error for empty default_provider")
	}
}

A  => internal/domain/search/handler.go +116 -0
@@ 1,116 @@
package search

import (
	"net/http"

	"github.com/go-chi/chi/v5"

	"sourcecraft.dev/bigbes/huntsman/internal/pkg/apierror"
	"sourcecraft.dev/bigbes/huntsman/internal/pkg/httputil"
)

// Handler exposes the search service over HTTP.
type Handler struct {
	svc *Service
}

// NewHandler wires a Service into an HTTP handler.
func NewHandler(svc *Service) *Handler {
	return &Handler{svc: svc}
}

// RegisterRoutes attaches the search-related routes to a chi router.
//
// Routes:
//
//	GET /search?q=...                  → 302 to the chosen provider
//	GET /providers                     → JSON list of providers
//	GET /opensearch.xml                → unified OSD pointing at /search
//	GET /opensearch/{provider}.xml     → OSD pointing directly at provider
func (h *Handler) RegisterRoutes(r chi.Router) {
	r.Get("/search", h.Search)
	r.Get("/providers", h.ListProviders)
	r.Get("/opensearch.xml", h.OpenSearchRouter)
	r.Get("/opensearch/{provider}.xml", h.OpenSearchProvider)
}

// Search routes the query string parameter "q" to the matching provider
// and returns a 302 redirect to the upstream search URL.
func (h *Handler) Search(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query().Get("q")

	provider, cleaned, err := h.svc.Route(q)
	if err != nil {
		httputil.Error(w, r, err)
		return
	}

	target := provider.SearchURLFor(cleaned)
	http.Redirect(w, r, target, http.StatusFound)
}

// providerView is the JSON shape returned by /providers.
type providerView struct {
	ID            string `json:"id"`
	Name          string `json:"name"`
	Description   string `json:"description"`
	HomeURL       string `json:"home_url"`
	OpenSearchURL string `json:"opensearch_url"`
}

// ListProviders returns the registered providers as JSON.
func (h *Handler) ListProviders(w http.ResponseWriter, _ *http.Request) {
	providers := h.svc.Providers()
	out := make([]providerView, 0, len(providers))
	for _, p := range providers {
		out = append(out, providerView{
			ID:            p.ID,
			Name:          p.Name,
			Description:   p.Description,
			HomeURL:       p.HomeURL,
			OpenSearchURL: h.svc.PublicURL() + "/opensearch/" + p.ID + ".xml",
		})
	}
	httputil.OK(w, map[string]any{
		"default":   h.svc.defaultProvider,
		"providers": out,
	})
}

// OpenSearchRouter serves the unified OSD XML document, which makes the
// browser send searches to /search where prefix routing kicks in.
func (h *Handler) OpenSearchRouter(w http.ResponseWriter, r *http.Request) {
	osd := DescriptionForRouter(h.svc.PublicURL())
	body, err := Marshal(osd)
	if err != nil {
		httputil.Error(w, r, err)
		return
	}
	writeOSD(w, body)
}

// OpenSearchProvider serves an OSD document for a single upstream provider,
// useful when the user wants to add e.g. urbandictionary directly.
func (h *Handler) OpenSearchProvider(w http.ResponseWriter, r *http.Request) {
	id := chi.URLParam(r, "provider")
	provider, ok := h.svc.Lookup(id)
	if !ok {
		httputil.Error(w, r, apierror.NotFound("provider "+id))
		return
	}
	body, err := Marshal(DescriptionForProvider(provider))
	if err != nil {
		httputil.Error(w, r, err)
		return
	}
	writeOSD(w, body)
}

// writeOSD writes a marshaled OSD document with the correct MIME type.
//
// The opensearchdescription+xml MIME type is what browsers look for when
// auto-detecting search engines via <link rel="search">.
func writeOSD(w http.ResponseWriter, body []byte) {
	w.Header().Set("Content-Type", "application/opensearchdescription+xml")
	_, _ = w.Write(body)
}

A  => internal/domain/search/handler_test.go +171 -0
@@ 1,171 @@
package search

import (
	"encoding/json"
	"encoding/xml"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/go-chi/chi/v5"
)

// newTestHandler builds a router with the search handler mounted at /.
func newTestHandler(t *testing.T) http.Handler {
	t.Helper()
	svc, err := NewService("gh", "https://example.com")
	if err != nil {
		t.Fatalf("NewService: %v", err)
	}
	r := chi.NewRouter()
	NewHandler(svc).RegisterRoutes(r)
	return r
}

func TestSearchRedirects(t *testing.T) {
	h := newTestHandler(t)

	cases := []struct {
		name    string
		query   string
		wantLoc string
	}{
		{"unknown prefix uses default gh", "foo bar", "https://github.com/search?q=foo+bar"},
		{"ud prefix routes to urban dictionary", "ud meme", "https://www.urbandictionary.com/define.php?term=meme"},
		{"steam prefix routes to steam", "steam half life", "https://store.steampowered.com/search/?term=half+life"},
		{"colon prefix", "ud:lit", "https://www.urbandictionary.com/define.php?term=lit"},
		{"empty query goes to default home", "", "https://github.com/"},
		{"bare prefix goes to provider home", "ud", "https://www.urbandictionary.com/"},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			req := httptest.NewRequestWithContext(t.Context(), "GET", "/search?q="+encodeQ(tc.query), http.NoBody)
			rr := httptest.NewRecorder()
			h.ServeHTTP(rr, req)

			if rr.Code != http.StatusFound {
				t.Fatalf("status = %d, want 302", rr.Code)
			}
			if got := rr.Header().Get("Location"); got != tc.wantLoc {
				t.Errorf("Location = %q\n  want %q", got, tc.wantLoc)
			}
		})
	}
}

// encodeQ percent-encodes spaces only — enough for these tests.
func encodeQ(s string) string {
	return strings.ReplaceAll(s, " ", "+")
}

func TestListProvidersJSON(t *testing.T) {
	h := newTestHandler(t)
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/providers", http.NoBody))

	if rr.Code != http.StatusOK {
		t.Fatalf("status = %d", rr.Code)
	}
	if got := rr.Header().Get("Content-Type"); got != "application/json" {
		t.Errorf("content-type = %q", got)
	}

	var body struct {
		Default   string `json:"default"`
		Providers []struct {
			ID            string `json:"id"`
			Name          string `json:"name"`
			Description   string `json:"description"`
			HomeURL       string `json:"home_url"`
			OpenSearchURL string `json:"opensearch_url"`
		} `json:"providers"`
	}
	if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
		t.Fatalf("decode: %v", err)
	}

	if body.Default != "gh" {
		t.Errorf("default = %q", body.Default)
	}
	if len(body.Providers) != 3 {
		t.Fatalf("expected 3 providers, got %d", len(body.Providers))
	}
	// Order should match AllProviders().
	wantIDs := []string{"ud", "gh", "steam"}
	for i, want := range wantIDs {
		if body.Providers[i].ID != want {
			t.Errorf("providers[%d].ID = %q, want %q", i, body.Providers[i].ID, want)
		}
	}
	// Per-provider OSD URL is built from the public URL.
	if body.Providers[0].OpenSearchURL != "https://example.com/opensearch/ud.xml" {
		t.Errorf("opensearch_url = %q", body.Providers[0].OpenSearchURL)
	}
}

func TestOpenSearchRouterXML(t *testing.T) {
	h := newTestHandler(t)
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/opensearch.xml", http.NoBody))

	if rr.Code != http.StatusOK {
		t.Fatalf("status = %d", rr.Code)
	}
	if got := rr.Header().Get("Content-Type"); got != "application/opensearchdescription+xml" {
		t.Errorf("content-type = %q", got)
	}
	body := rr.Body.String()
	if !strings.Contains(body, "<?xml") {
		t.Errorf("body missing XML prolog: %q", body)
	}
	if !strings.Contains(body, "https://example.com/search?q={searchTerms}") {
		t.Errorf("body missing router template: %q", body)
	}

	var osd OpenSearchDescription
	if err := xml.Unmarshal(rr.Body.Bytes(), &osd); err != nil {
		t.Fatalf("unmarshal OSD: %v", err)
	}
	if osd.ShortName != "huntsman" {
		t.Errorf("ShortName = %q", osd.ShortName)
	}
}

func TestOpenSearchProviderXML(t *testing.T) {
	h := newTestHandler(t)
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/opensearch/ud.xml", http.NoBody))

	if rr.Code != http.StatusOK {
		t.Fatalf("status = %d", rr.Code)
	}

	var osd OpenSearchDescription
	if err := xml.Unmarshal(rr.Body.Bytes(), &osd); err != nil {
		t.Fatalf("unmarshal: %v", err)
	}
	if osd.ShortName != "Urban Dictionary" {
		t.Errorf("ShortName = %q", osd.ShortName)
	}
	if len(osd.URLs) != 1 || !strings.Contains(osd.URLs[0].Template, "{searchTerms}") {
		t.Errorf("template missing {searchTerms}: %+v", osd.URLs)
	}
	if osd.Image == nil || osd.Image.Value == "" {
		t.Errorf("expected Image with icon URL")
	}
}

func TestOpenSearchProviderUnknown(t *testing.T) {
	h := newTestHandler(t)
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/opensearch/yahoo.xml", http.NoBody))

	if rr.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want 404", rr.Code)
	}
	if got := rr.Header().Get("Content-Type"); got != "application/problem+json" {
		t.Errorf("content-type = %q", got)
	}
}

A  => internal/domain/search/opensearch.go +96 -0
@@ 1,96 @@
package search

import (
	"encoding/xml"
	"fmt"
	"strings"
)

// OpenSearchDescription is the root <OpenSearchDescription> element of an
// OSD 1.1 document. Browsers (Firefox, Chromium) parse this to register
// a search engine; the {searchTerms} placeholder in URL templates is
// substituted by the browser at search time.
//
// Spec: https://github.com/dewitt/opensearch/blob/master/opensearch-1-1-draft-6.md
type OpenSearchDescription struct {
	XMLName        xml.Name `xml:"OpenSearchDescription"`
	XMLNS          string   `xml:"xmlns,attr"`
	ShortName      string   `xml:"ShortName"`
	Description    string   `xml:"Description"`
	InputEncoding  string   `xml:"InputEncoding"`
	Image          *Image   `xml:"Image,omitempty"`
	URLs           []URL    `xml:"Url"`
	SearchForm     string   `xml:"SearchForm,omitempty"`
	Developer      string   `xml:"Developer,omitempty"`
	Attribution    string   `xml:"Attribution,omitempty"`
	SyndicationRgt string   `xml:"SyndicationRight,omitempty"`
	AdultContent   string   `xml:"AdultContent,omitempty"`
}

// URL is a single <Url> element pointing at a search template.
type URL struct {
	Type     string `xml:"type,attr"`
	Template string `xml:"template,attr"`
	Method   string `xml:"method,attr,omitempty"`
}

// Image is a <Image> element for the search engine icon.
type Image struct {
	Height int    `xml:"height,attr"`
	Width  int    `xml:"width,attr"`
	Type   string `xml:"type,attr"`
	Value  string `xml:",chardata"`
}

const xmlNS = "http://a9.com/-/spec/opensearch/1.1/"

// DescriptionForProvider builds an OSD document that points the browser
// directly at the upstream provider — useful when the user wants to add
// e.g. urbandictionary as a standalone search engine.
func DescriptionForProvider(p Provider) OpenSearchDescription {
	osd := OpenSearchDescription{
		XMLNS:         xmlNS,
		ShortName:     p.Name,
		Description:   p.Description,
		InputEncoding: "UTF-8",
		URLs: []URL{{
			Type: "text/html",
			// OpenSearch uses {searchTerms}; convert from our internal {q}.
			Template: strings.ReplaceAll(p.SearchURL, "{q}", "{searchTerms}"),
		}},
		SearchForm: p.HomeURL,
	}
	if p.IconURL != "" {
		osd.Image = &Image{Height: 16, Width: 16, Type: "image/x-icon", Value: p.IconURL}
	}
	return osd
}

// DescriptionForRouter builds an OSD document that points the browser at
// this service's own /search endpoint, so prefix routing ("ud foo") works
// from the browser's address bar.
//
// publicURL must be the externally-visible base URL (no trailing slash).
func DescriptionForRouter(publicURL string) OpenSearchDescription {
	publicURL = strings.TrimRight(publicURL, "/")
	return OpenSearchDescription{
		XMLNS:         xmlNS,
		ShortName:     "huntsman",
		Description:   "Multi-provider search router (ud, gh, steam)",
		InputEncoding: "UTF-8",
		URLs: []URL{{
			Type:     "text/html",
			Template: fmt.Sprintf("%s/search?q={searchTerms}", publicURL),
		}},
		SearchForm: publicURL + "/",
	}
}

// Marshal serializes an OSD document with the standard XML prolog.
func Marshal(osd OpenSearchDescription) ([]byte, error) {
	body, err := xml.MarshalIndent(osd, "", "  ")
	if err != nil {
		return nil, err
	}
	return append([]byte(xml.Header), body...), nil
}

A  => internal/domain/search/opensearch_test.go +72 -0
@@ 1,72 @@
package search

import (
	"encoding/xml"
	"strings"
	"testing"
)

func TestMarshalEmitsXMLProlog(t *testing.T) {
	osd := DescriptionForRouter("https://example.com")
	body, err := Marshal(osd)
	if err != nil {
		t.Fatalf("Marshal: %v", err)
	}
	if !strings.HasPrefix(string(body), "<?xml") {
		t.Errorf("missing XML prolog: %q", body[:min(40, len(body))])
	}
	// Round-trip should succeed.
	var got OpenSearchDescription
	if err := xml.Unmarshal(body, &got); err != nil {
		t.Fatalf("unmarshal round-trip: %v", err)
	}
	if got.ShortName != osd.ShortName {
		t.Errorf("ShortName lost in round-trip: %q vs %q", got.ShortName, osd.ShortName)
	}
}

func TestDescriptionForRouterStripsTrailingSlash(t *testing.T) {
	osd := DescriptionForRouter("https://example.com///")
	if osd.URLs[0].Template != "https://example.com/search?q={searchTerms}" {
		t.Errorf("template = %q", osd.URLs[0].Template)
	}
	if osd.SearchForm != "https://example.com/" {
		t.Errorf("SearchForm = %q", osd.SearchForm)
	}
}

func TestDescriptionForProviderConvertsPlaceholder(t *testing.T) {
	p := Provider{
		ID:        "x",
		Name:      "X",
		SearchURL: "https://x.example/?q={q}",
		HomeURL:   "https://x.example/",
	}
	osd := DescriptionForProvider(p)
	if osd.URLs[0].Template != "https://x.example/?q={searchTerms}" {
		t.Errorf("template = %q", osd.URLs[0].Template)
	}
	if osd.Image != nil {
		t.Errorf("Image should be nil when IconURL empty, got %+v", osd.Image)
	}
}

func TestDescriptionForProviderIncludesIcon(t *testing.T) {
	p := Provider{
		ID:        "x",
		Name:      "X",
		SearchURL: "https://x.example/?q={q}",
		HomeURL:   "https://x.example/",
		IconURL:   "https://x.example/favicon.ico",
	}
	osd := DescriptionForProvider(p)
	if osd.Image == nil {
		t.Fatal("Image should not be nil")
	}
	if osd.Image.Value != p.IconURL {
		t.Errorf("Image.Value = %q", osd.Image.Value)
	}
	if osd.Image.Width != 16 || osd.Image.Height != 16 {
		t.Errorf("Image dimensions = %dx%d", osd.Image.Width, osd.Image.Height)
	}
}

A  => internal/domain/search/providers.go +135 -0
@@ 1,135 @@
// Package search implements the multi-provider search router.
//
// Three search providers are supported out of the box:
//
//   - ud    → Urban Dictionary
//   - gh    → GitHub
//   - steam → Steam Store
//
// Each provider has a stable ID, a display name, and a URL template
// where {q} is substituted with the URL-encoded query.
package search

import (
	"net/url"
	"strings"

	"go.bigb.es/auxilia/culpa"
)

// Provider describes a single upstream search engine.
type Provider struct {
	ID          string // short prefix, e.g. "ud"
	Name        string // human-readable name, e.g. "Urban Dictionary"
	Description string // shown in OpenSearch description XML
	SearchURL   string // template containing the literal "{q}" placeholder
	HomeURL     string // upstream homepage, used as fallback
	IconURL     string // 16x16 favicon, optional
}

// SearchURLFor returns the upstream search URL for the given raw query.
//
// The query is trimmed and percent-encoded; an empty query returns the
// provider's HomeURL so a stray /search?q= still lands somewhere useful.
func (p Provider) SearchURLFor(q string) string {
	q = strings.TrimSpace(q)
	if q == "" {
		return p.HomeURL
	}
	return strings.ReplaceAll(p.SearchURL, "{q}", url.QueryEscape(q))
}

// AllProviders returns the registered providers in stable order.
//
// The order is also the order they appear in /providers and OpenSearch
// listings, so it's intentional rather than a map iteration.
func AllProviders() []Provider {
	return []Provider{
		{
			ID:          "ud",
			Name:        "Urban Dictionary",
			Description: "Search slang definitions on Urban Dictionary",
			SearchURL:   "https://www.urbandictionary.com/define.php?term={q}",
			HomeURL:     "https://www.urbandictionary.com/",
			IconURL:     "https://www.urbandictionary.com/favicon.ico",
		},
		{
			ID:          "gh",
			Name:        "GitHub",
			Description: "Search repositories, code, and issues on GitHub",
			SearchURL:   "https://github.com/search?q={q}",
			HomeURL:     "https://github.com/",
			IconURL:     "https://github.com/favicon.ico",
		},
		{
			ID:          "steam",
			Name:        "Steam Store",
			Description: "Search games on the Steam store",
			SearchURL:   "https://store.steampowered.com/search/?term={q}",
			HomeURL:     "https://store.steampowered.com/",
			IconURL:     "https://store.steampowered.com/favicon.ico",
		},
	}
}

// providerByID indexes AllProviders by ID for O(1) lookup.
func providerByID() map[string]Provider {
	all := AllProviders()
	m := make(map[string]Provider, len(all))
	for _, p := range all {
		m[p.ID] = p
	}
	return m
}

// Lookup returns the provider with the given ID, or false if none matches.
func Lookup(id string) (Provider, bool) {
	p, ok := providerByID()[id]
	return p, ok
}

// Route inspects a raw user query and decides which provider should handle it.
//
// The first whitespace-delimited token is treated as a possible prefix.
// Both "ud foo" and "ud:foo" route to the urbandictionary provider with
// the query "foo". If no token matches a provider ID, the default provider
// receives the original query unchanged.
//
// Returns the matched provider and the cleaned query to forward.
func Route(raw string, providers map[string]Provider, defaultID string) (Provider, string, error) {
	def, ok := providers[defaultID]
	if !ok {
		return Provider{}, "", culpa.WithCode(
			culpa.Errorf("default provider %q is not registered", defaultID),
			"UNKNOWN_PROVIDER",
		)
	}

	trimmed := strings.TrimSpace(raw)
	if trimmed == "" {
		return def, "", nil
	}

	// "ud:foo bar" → prefix "ud", rest "foo bar"
	if i := strings.IndexByte(trimmed, ':'); i > 0 {
		prefix := strings.ToLower(trimmed[:i])
		if p, ok := providers[prefix]; ok {
			return p, strings.TrimSpace(trimmed[i+1:]), nil
		}
	}

	// "ud foo bar" → prefix "ud", rest "foo bar"
	if i := strings.IndexByte(trimmed, ' '); i > 0 {
		prefix := strings.ToLower(trimmed[:i])
		if p, ok := providers[prefix]; ok {
			return p, strings.TrimSpace(trimmed[i+1:]), nil
		}
	}

	// Bare prefix like "ud" with no query — go to that provider's homepage.
	if p, ok := providers[strings.ToLower(trimmed)]; ok {
		return p, "", nil
	}

	return def, trimmed, nil
}

A  => internal/domain/search/providers_test.go +87 -0
@@ 1,87 @@
package search

import (
	"strings"
	"testing"
)

func TestRoute(t *testing.T) {
	providers := providerByID()

	cases := []struct {
		name      string
		input     string
		wantID    string
		wantQuery string
	}{
		{"empty falls back to default", "", "gh", ""},
		{"unknown prefix becomes part of query", "foo bar", "gh", "foo bar"},
		{"space prefix routes to ud", "ud meme", "ud", "meme"},
		{"colon prefix routes to gh", "gh:repo language:go", "gh", "repo language:go"},
		{"steam prefix with multi-word query", "steam half life", "steam", "half life"},
		{"bare prefix returns provider with empty query", "ud", "ud", ""},
		{"prefix is case-insensitive", "UD bar", "ud", "bar"},
		{"leading whitespace is stripped", "   gh    foo", "gh", "foo"},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			got, q, err := Route(tc.input, providers, "gh")
			if err != nil {
				t.Fatalf("unexpected error: %v", err)
			}
			if got.ID != tc.wantID {
				t.Errorf("provider ID = %q, want %q", got.ID, tc.wantID)
			}
			if q != tc.wantQuery {
				t.Errorf("query = %q, want %q", q, tc.wantQuery)
			}
		})
	}
}

func TestRouteUnknownDefault(t *testing.T) {
	if _, _, err := Route("anything", providerByID(), "nope"); err == nil {
		t.Fatal("expected error for unknown default provider")
	}
}

func TestSearchURLFor(t *testing.T) {
	p, ok := Lookup("ud")
	if !ok {
		t.Fatal("ud provider missing")
	}
	url := p.SearchURLFor("hello world")
	if !strings.Contains(url, "term=hello+world") {
		t.Errorf("expected URL to contain encoded query, got %q", url)
	}

	if got := p.SearchURLFor(""); got != p.HomeURL {
		t.Errorf("empty query should return HomeURL, got %q", got)
	}
}

func TestDescriptionForRouter(t *testing.T) {
	osd := DescriptionForRouter("https://example.com/")
	if len(osd.URLs) != 1 {
		t.Fatalf("expected 1 URL element, got %d", len(osd.URLs))
	}
	want := "https://example.com/search?q={searchTerms}"
	if osd.URLs[0].Template != want {
		t.Errorf("template = %q, want %q", osd.URLs[0].Template, want)
	}
}

func TestDescriptionForProvider(t *testing.T) {
	p, _ := Lookup("gh")
	osd := DescriptionForProvider(p)
	if len(osd.URLs) != 1 {
		t.Fatalf("expected 1 URL element, got %d", len(osd.URLs))
	}
	if !strings.Contains(osd.URLs[0].Template, "{searchTerms}") {
		t.Errorf("template should contain {searchTerms}, got %q", osd.URLs[0].Template)
	}
	if strings.Contains(osd.URLs[0].Template, "{q}") {
		t.Errorf("template should not contain internal {q} placeholder")
	}
}

A  => internal/domain/search/service.go +59 -0
@@ 1,59 @@
package search

// Service holds the registered providers and routing defaults.
//
// It's stateless apart from configuration, so a single instance is shared
// across all HTTP handlers.
type Service struct {
	providers       map[string]Provider
	defaultProvider string
	publicURL       string
}

// NewService constructs a search service with the built-in provider set.
//
// defaultProvider must be the ID of one of the built-in providers; the
// loader-time validator guarantees this, but we re-check at startup so a
// programmer mistake fails loudly rather than silently picking something.
func NewService(defaultProvider, publicURL string) (*Service, error) {
	providers := providerByID()
	if _, ok := providers[defaultProvider]; !ok {
		return nil, ErrUnknownProvider{ID: defaultProvider}
	}
	return &Service{
		providers:       providers,
		defaultProvider: defaultProvider,
		publicURL:       publicURL,
	}, nil
}

// Providers returns all registered providers in the canonical order.
func (s *Service) Providers() []Provider {
	return AllProviders()
}

// Lookup returns a provider by ID, or false if not registered.
func (s *Service) Lookup(id string) (Provider, bool) {
	p, ok := s.providers[id]
	return p, ok
}

// Route resolves a raw query into a (provider, cleaned-query) pair.
func (s *Service) Route(raw string) (Provider, string, error) {
	return Route(raw, s.providers, s.defaultProvider)
}

// PublicURL returns the externally-visible URL of this service, used to
// build self-referential OpenSearch description documents.
func (s *Service) PublicURL() string {
	return s.publicURL
}

// ErrUnknownProvider is returned when a provider ID is referenced but not
// registered. It's a typed error so callers can distinguish it from
// validation failures higher up the stack.
type ErrUnknownProvider struct{ ID string }

func (e ErrUnknownProvider) Error() string {
	return "unknown provider: " + e.ID
}

A  => internal/domain/search/service_test.go +56 -0
@@ 1,56 @@
package search

import (
	"errors"
	"testing"
)

func TestNewServiceUnknownDefault(t *testing.T) {
	_, err := NewService("yahoo", "https://example.com")
	if err == nil {
		t.Fatal("expected error for unknown default provider")
	}
	var unk ErrUnknownProvider
	if !errors.As(err, &unk) {
		t.Fatalf("expected ErrUnknownProvider, got %T: %v", err, err)
	}
	if unk.ID != "yahoo" {
		t.Errorf("ID = %q", unk.ID)
	}
	if unk.Error() != "unknown provider: yahoo" {
		t.Errorf("Error() = %q", unk.Error())
	}
}

func TestServiceLookupAndProviders(t *testing.T) {
	svc, err := NewService("gh", "https://example.com")
	if err != nil {
		t.Fatalf("NewService: %v", err)
	}
	if got := svc.PublicURL(); got != "https://example.com" {
		t.Errorf("PublicURL = %q", got)
	}
	if len(svc.Providers()) != 3 {
		t.Errorf("expected 3 providers")
	}
	if _, ok := svc.Lookup("ud"); !ok {
		t.Errorf("Lookup ud should succeed")
	}
	if _, ok := svc.Lookup("yahoo"); ok {
		t.Errorf("Lookup yahoo should fail")
	}
}

func TestServiceRouteDelegates(t *testing.T) {
	svc, err := NewService("gh", "https://example.com")
	if err != nil {
		t.Fatalf("NewService: %v", err)
	}
	p, q, err := svc.Route("ud foo")
	if err != nil {
		t.Fatalf("Route: %v", err)
	}
	if p.ID != "ud" || q != "foo" {
		t.Errorf("got (%q, %q), want (ud, foo)", p.ID, q)
	}
}

A  => internal/pkg/apierror/error.go +97 -0
@@ 1,97 @@
// Package apierror models RFC 7807 Problem Details responses.
//
// Errors returned from handlers flow through FromError, which inspects
// typed sentinel values and culpa details to build a Problem with the
// right HTTP status. Anything unrecognized becomes a 500 with the
// original message stripped from the response body.
package apierror

import (
	"encoding/json"
	"errors"
	"net/http"

	"go.bigb.es/auxilia/culpa"
)

// Problem implements RFC 7807 Problem Details for HTTP APIs.
type Problem struct {
	Type   string `json:"type"`
	Title  string `json:"title"`
	Status int    `json:"status"`
	Detail string `json:"detail,omitempty"`
	Code   string `json:"code,omitempty"`
}

// Error makes Problem usable as an error itself for chained handling.
func (p Problem) Error() string { return p.Title }

// Write encodes the Problem as application/problem+json and writes it.
func (p Problem) Write(w http.ResponseWriter) {
	w.Header().Set("Content-Type", "application/problem+json")
	w.WriteHeader(p.Status)
	_ = json.NewEncoder(w).Encode(p)
}

// FromError maps any error onto a Problem. Internal errors (anything not
// matched by a typed predicate below) collapse to a generic 500 so we
// don't leak implementation details to the client.
//
// If the error chain carries culpa CodeDetail / PublicDetail, those
// override the defaults so call sites can attach machine codes and
// user-safe messages without defining new typed errors.
func FromError(err error) Problem {
	var p Problem
	if errors.As(err, &p) {
		applyCulpaOverrides(err, &p)
		return p
	}

	var nf NotFoundError
	if errors.As(err, &nf) {
		p = Problem{Type: "about:blank", Title: "Not Found", Status: 404, Detail: nf.Error(), Code: "NOT_FOUND"}
		applyCulpaOverrides(err, &p)
		return p
	}

	var br BadRequestError
	if errors.As(err, &br) {
		p = Problem{Type: "about:blank", Title: "Bad Request", Status: 400, Detail: br.Error(), Code: "BAD_REQUEST"}
		applyCulpaOverrides(err, &p)
		return p
	}

	return Problem{Type: "about:blank", Title: "Internal Server Error", Status: 500}
}

// applyCulpaOverrides mutates p with any CodeDetail/PublicDetail attached
// to the error chain. Public messages are intended for end users, so when
// present they replace whatever the typed error produced.
func applyCulpaOverrides(err error, p *Problem) {
	var code culpa.CodeDetail
	if culpa.FindDetail(err, &code) {
		if s, ok := code.Code.(string); ok && s != "" {
			p.Code = s
		}
	}
	var pub culpa.PublicDetail
	if culpa.FindDetail(err, &pub) && pub.Message != "" {
		p.Detail = pub.Message
	}
}

// NotFoundError signals a missing resource and maps to HTTP 404.
type NotFoundError struct{ Resource string }

func (e NotFoundError) Error() string { return e.Resource + " not found" }

// NotFound is a convenience constructor for NotFoundError.
func NotFound(resource string) error { return NotFoundError{Resource: resource} }

// BadRequestError signals invalid input and maps to HTTP 400.
type BadRequestError struct{ Detail string }

func (e BadRequestError) Error() string { return e.Detail }

// BadRequest is a convenience constructor for BadRequestError.
func BadRequest(detail string) error { return BadRequestError{Detail: detail} }

A  => internal/pkg/apierror/error_test.go +105 -0
@@ 1,105 @@
package apierror

import (
	"encoding/json"
	"errors"
	"fmt"
	"net/http/httptest"
	"testing"
)

func TestFromErrorPassesThroughProblem(t *testing.T) {
	original := Problem{Title: "Teapot", Status: 418, Code: "TEAPOT"}
	got := FromError(original)
	if got != original {
		t.Errorf("FromError(Problem) lost data: got %+v want %+v", got, original)
	}
}

func TestFromErrorWrappedProblem(t *testing.T) {
	original := Problem{Title: "Teapot", Status: 418}
	wrapped := fmt.Errorf("outer: %w", original)
	got := FromError(wrapped)
	if got.Status != 418 {
		t.Errorf("status = %d, want 418", got.Status)
	}
}

func TestFromErrorNotFound(t *testing.T) {
	p := FromError(NotFound("widget"))
	if p.Status != 404 {
		t.Errorf("status = %d, want 404", p.Status)
	}
	if p.Code != "NOT_FOUND" {
		t.Errorf("code = %q", p.Code)
	}
	if p.Detail != "widget not found" {
		t.Errorf("detail = %q", p.Detail)
	}
}

func TestFromErrorBadRequest(t *testing.T) {
	p := FromError(BadRequest("missing q"))
	if p.Status != 400 {
		t.Errorf("status = %d, want 400", p.Status)
	}
	if p.Code != "BAD_REQUEST" {
		t.Errorf("code = %q", p.Code)
	}
	if p.Detail != "missing q" {
		t.Errorf("detail = %q", p.Detail)
	}
}

func TestFromErrorUnknownCollapsesTo500(t *testing.T) {
	p := FromError(errors.New("secret database password leaked"))
	if p.Status != 500 {
		t.Errorf("status = %d, want 500", p.Status)
	}
	if p.Detail != "" {
		t.Errorf("detail should be empty to avoid leaks, got %q", p.Detail)
	}
	if p.Title != "Internal Server Error" {
		t.Errorf("title = %q", p.Title)
	}
}

func TestProblemError(t *testing.T) {
	p := Problem{Title: "Bad"}
	if p.Error() != "Bad" {
		t.Errorf("Error() = %q", p.Error())
	}
}

func TestProblemWrite(t *testing.T) {
	rr := httptest.NewRecorder()
	p := Problem{Title: "Not Found", Status: 404, Detail: "thing missing", Code: "NOT_FOUND"}
	p.Write(rr)

	if rr.Code != 404 {
		t.Errorf("status = %d", rr.Code)
	}
	if got := rr.Header().Get("Content-Type"); got != "application/problem+json" {
		t.Errorf("content-type = %q", got)
	}

	var decoded Problem
	if err := json.Unmarshal(rr.Body.Bytes(), &decoded); err != nil {
		t.Fatalf("decode body: %v", err)
	}
	if decoded != p {
		t.Errorf("decoded = %+v, want %+v", decoded, p)
	}
}

func TestNotFoundErrorMessage(t *testing.T) {
	if got := (NotFoundError{Resource: "thing"}).Error(); got != "thing not found" {
		t.Errorf("Error() = %q", got)
	}
}

func TestBadRequestErrorMessage(t *testing.T) {
	if got := (BadRequestError{Detail: "nope"}).Error(); got != "nope" {
		t.Errorf("Error() = %q", got)
	}
}

A  => internal/pkg/httputil/response.go +36 -0
@@ 1,36 @@
// Package httputil contains thin JSON-response and error-routing helpers.
package httputil

import (
	"encoding/json"
	"log/slog"
	"net/http"

	"go.bigb.es/auxilia/scribe"

	"sourcecraft.dev/bigbes/huntsman/internal/pkg/apierror"
)

// JSON writes a JSON response with the given status code.
func JSON(w http.ResponseWriter, status int, data any) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(status)
	if data != nil {
		_ = json.NewEncoder(w).Encode(data)
	}
}

// OK is the common 200 path.
func OK(w http.ResponseWriter, data any) { JSON(w, http.StatusOK, data) }

// Error converts err into a Problem response. Internal (5xx) errors are
// logged with the original error message; client errors are not, since
// they're not actionable for the operator.
func Error(w http.ResponseWriter, r *http.Request, err error) {
	problem := apierror.FromError(err)
	if problem.Status >= 500 {
		//nolint:gosec // G706: slog handlers escape attribute values, so r.URL.Path cannot inject newlines into log output.
		slog.Error("internal error", "path", r.URL.Path, scribe.Err(err))
	}
	problem.Write(w)
}

A  => internal/pkg/httputil/response_test.go +75 -0
@@ 1,75 @@
package httputil

import (
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"sourcecraft.dev/bigbes/huntsman/internal/pkg/apierror"
)

func TestJSONStatusAndBody(t *testing.T) {
	rr := httptest.NewRecorder()
	JSON(rr, 201, map[string]int{"n": 7})

	if rr.Code != 201 {
		t.Errorf("status = %d", rr.Code)
	}
	if got := rr.Header().Get("Content-Type"); got != "application/json" {
		t.Errorf("content-type = %q", got)
	}
	var body map[string]int
	if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if body["n"] != 7 {
		t.Errorf("body = %+v", body)
	}
}

func TestJSONNilDataWritesNoBody(t *testing.T) {
	rr := httptest.NewRecorder()
	JSON(rr, 204, nil)
	if rr.Code != 204 {
		t.Errorf("status = %d", rr.Code)
	}
	if rr.Body.Len() != 0 {
		t.Errorf("body should be empty, got %q", rr.Body.String())
	}
}

func TestOK(t *testing.T) {
	rr := httptest.NewRecorder()
	OK(rr, map[string]string{"hello": "world"})
	if rr.Code != 200 {
		t.Errorf("status = %d", rr.Code)
	}
}

func TestErrorMapsClientError(t *testing.T) {
	rr := httptest.NewRecorder()
	req := httptest.NewRequestWithContext(t.Context(), "GET", "/foo", http.NoBody)
	Error(rr, req, apierror.NotFound("widget"))
	if rr.Code != 404 {
		t.Errorf("status = %d", rr.Code)
	}
	if got := rr.Header().Get("Content-Type"); got != "application/problem+json" {
		t.Errorf("content-type = %q", got)
	}
}

func TestErrorMapsInternalError(t *testing.T) {
	rr := httptest.NewRecorder()
	req := httptest.NewRequestWithContext(t.Context(), "GET", "/foo", http.NoBody)
	Error(rr, req, errors.New("boom"))
	if rr.Code != 500 {
		t.Errorf("status = %d", rr.Code)
	}
	// Internal detail should not leak in the body.
	if got := rr.Body.String(); strings.Contains(got, "boom") {
		t.Errorf("response body leaks internal error: %q", got)
	}
}

A  => internal/platform/health/health.go +78 -0
@@ 1,78 @@
// Package health implements simple liveness and readiness probes.
//
// huntsman has no real backing services, so readiness == liveness
// today, but the Health struct keeps the door open for plugging in real
// checkers later (e.g. an upstream API ping) without touching the router.
package health

import (
	"context"
	"encoding/json"
	"net/http"
	"sync"
	"time"
)

// Checker reports the current health of one subsystem.
type Checker interface {
	Check(ctx context.Context) error
}

// CheckerFunc adapts an ordinary function to the Checker interface.
type CheckerFunc func(ctx context.Context) error

// Check satisfies the Checker interface.
func (f CheckerFunc) Check(ctx context.Context) error { return f(ctx) }

// Health aggregates named checkers and exposes them as HTTP handlers.
type Health struct {
	mu       sync.RWMutex
	checkers map[string]Checker
}

// New constructs an empty Health registry.
func New() *Health {
	return &Health{checkers: make(map[string]Checker)}
}

// Register adds (or replaces) a named checker.
func (h *Health) Register(name string, c Checker) {
	h.mu.Lock()
	defer h.mu.Unlock()
	h.checkers[name] = c
}

// Healthz is a liveness probe — does the process exist and respond?
func (h *Health) Healthz(w http.ResponseWriter, _ *http.Request) {
	w.WriteHeader(http.StatusOK)
	_, _ = w.Write([]byte("ok"))
}

// Readyz runs all registered checkers with a 5s timeout. Any failure
// flips the response to 503 and lists per-checker status in the body.
func (h *Health) Readyz(w http.ResponseWriter, r *http.Request) {
	ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
	defer cancel()

	h.mu.RLock()
	defer h.mu.RUnlock()

	results := make(map[string]string, len(h.checkers))
	allOK := true
	for name, checker := range h.checkers {
		if err := checker.Check(ctx); err != nil {
			results[name] = err.Error()
			allOK = false
		} else {
			results[name] = "ok"
		}
	}

	w.Header().Set("Content-Type", "application/json")
	if allOK {
		w.WriteHeader(http.StatusOK)
	} else {
		w.WriteHeader(http.StatusServiceUnavailable)
	}
	_ = json.NewEncoder(w).Encode(results)
}

A  => internal/platform/health/health_test.go +96 -0
@@ 1,96 @@
package health

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestHealthzAlwaysOK(t *testing.T) {
	h := New()
	rr := httptest.NewRecorder()
	h.Healthz(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/healthz", http.NoBody))

	if rr.Code != 200 {
		t.Errorf("status = %d", rr.Code)
	}
	if rr.Body.String() != "ok" {
		t.Errorf("body = %q", rr.Body.String())
	}
}

func TestReadyzNoCheckersIsOK(t *testing.T) {
	h := New()
	rr := httptest.NewRecorder()
	h.Readyz(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/readyz", http.NoBody))

	if rr.Code != 200 {
		t.Errorf("status = %d", rr.Code)
	}

	var body map[string]string
	if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if len(body) != 0 {
		t.Errorf("expected empty results, got %v", body)
	}
}

func TestReadyzAllPass(t *testing.T) {
	h := New()
	h.Register("db", CheckerFunc(func(_ context.Context) error { return nil }))
	h.Register("cache", CheckerFunc(func(_ context.Context) error { return nil }))

	rr := httptest.NewRecorder()
	h.Readyz(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/readyz", http.NoBody))

	if rr.Code != 200 {
		t.Errorf("status = %d", rr.Code)
	}
	var body map[string]string
	if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if body["db"] != "ok" || body["cache"] != "ok" {
		t.Errorf("body = %v", body)
	}
}

func TestReadyzOneFails(t *testing.T) {
	h := New()
	h.Register("db", CheckerFunc(func(_ context.Context) error { return nil }))
	h.Register("upstream", CheckerFunc(func(_ context.Context) error { return errors.New("dns fail") }))

	rr := httptest.NewRecorder()
	h.Readyz(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/readyz", http.NoBody))

	if rr.Code != 503 {
		t.Errorf("status = %d, want 503", rr.Code)
	}
	var body map[string]string
	if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
		t.Fatalf("decode: %v", err)
	}
	if body["db"] != "ok" {
		t.Errorf("db = %q", body["db"])
	}
	if body["upstream"] != "dns fail" {
		t.Errorf("upstream = %q", body["upstream"])
	}
}

func TestRegisterReplaces(t *testing.T) {
	h := New()
	h.Register("svc", CheckerFunc(func(_ context.Context) error { return errors.New("first") }))
	h.Register("svc", CheckerFunc(func(_ context.Context) error { return nil }))

	rr := httptest.NewRecorder()
	h.Readyz(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/readyz", http.NoBody))
	if rr.Code != 200 {
		t.Errorf("status = %d, want 200 after replace", rr.Code)
	}
}

A  => internal/platform/observability/logging.go +44 -0
@@ 1,44 @@
// Package observability wires up logging.
package observability

import (
	"log/slog"
	"os"
	"strings"

	"go.bigb.es/auxilia/scribe"
)

// NewLogger builds a slog.Logger backed by an auxilia/scribe handler.
//
// "human" produces a colorized TintHandler suitable for terminals; "json"
// produces a structured handler suitable for log aggregators. Unknown
// formats fall back to "human" so a typo doesn't break logging entirely.
func NewLogger(level, format string) *slog.Logger {
	lvl := parseLevel(level)
	opts := []scribe.Option{
		scribe.WithWriter(os.Stdout),
		scribe.WithLevel(lvl),
	}

	var handler slog.Handler
	if strings.EqualFold(format, "json") {
		handler = scribe.NewJSONHandler(opts...)
	} else {
		handler = scribe.NewTintHandler(opts...)
	}
	return slog.New(handler)
}

func parseLevel(level string) slog.Level {
	switch strings.ToLower(level) {
	case "debug":
		return slog.LevelDebug
	case "warn":
		return slog.LevelWarn
	case "error":
		return slog.LevelError
	default:
		return slog.LevelInfo
	}
}

A  => internal/platform/observability/logging_test.go +57 -0
@@ 1,57 @@
package observability

import (
	"log/slog"
	"testing"
)

func TestNewLoggerLevels(t *testing.T) {
	cases := []struct {
		level string
		want  slog.Level
	}{
		{"debug", slog.LevelDebug},
		{"DEBUG", slog.LevelDebug},
		{"info", slog.LevelInfo},
		{"warn", slog.LevelWarn},
		{"error", slog.LevelError},
		{"unknown", slog.LevelInfo}, // fallback
		{"", slog.LevelInfo},        // fallback
	}

	for _, tc := range cases {
		t.Run(tc.level, func(t *testing.T) {
			lg := NewLogger(tc.level, "human")
			if lg == nil {
				t.Fatal("logger is nil")
			}
			// Probe whether the chosen level is enabled.
			if !lg.Enabled(t.Context(), tc.want) {
				t.Errorf("level %v should be enabled", tc.want)
			}
			// And one level below should be filtered out.
			if tc.want > slog.LevelDebug {
				if lg.Enabled(t.Context(), tc.want-4) {
					t.Errorf("level below %v should be filtered", tc.want)
				}
			}
		})
	}
}

func TestNewLoggerFormatFallback(t *testing.T) {
	// Any unrecognized format should not panic and should still return a logger.
	lg := NewLogger("info", "yaml")
	if lg == nil {
		t.Fatal("logger is nil")
	}
}

func TestNewLoggerJSONFormat(t *testing.T) {
	// Just exercise the JSON path; we can't easily inspect the handler type
	// without adapter, but we ensure it constructs without panic.
	lg := NewLogger("info", "json")
	if lg == nil {
		t.Fatal("logger is nil")
	}
}

A  => internal/server/middleware/logging.go +46 -0
@@ 1,46 @@
package middleware

import (
	"log/slog"
	"net/http"
	"time"
)

type responseWriter struct {
	http.ResponseWriter
	status int
	size   int
}

func (rw *responseWriter) WriteHeader(status int) {
	rw.status = status
	rw.ResponseWriter.WriteHeader(status)
}

func (rw *responseWriter) Write(b []byte) (int, error) {
	n, err := rw.ResponseWriter.Write(b)
	rw.size += n
	return n, err
}

// Logging emits a single structured log line per request once the handler
// returns. The status code defaults to 200 if the handler never explicitly
// calls WriteHeader.
func Logging(logger *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			start := time.Now()
			rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
			next.ServeHTTP(rw, r)

			logger.Info("http request",
				"method", r.Method,
				"path", r.URL.Path,
				"status", rw.status,
				"size", rw.size,
				"duration", time.Since(start),
				"request_id", GetRequestID(r.Context()),
			)
		})
	}
}

A  => internal/server/middleware/logging_test.go +62 -0
@@ 1,62 @@
package middleware

import (
	"bytes"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func TestLoggingEmitsLineWithStatusAndPath(t *testing.T) {
	var buf bytes.Buffer
	logger := slog.New(slog.NewTextHandler(&buf, nil))
	h := Logging(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(201)
		_, _ = w.Write([]byte("hello"))
	}))

	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "POST", "/widgets", http.NoBody))

	logged := buf.String()
	for _, want := range []string{"http request", "method=POST", "path=/widgets", "status=201", "size=5"} {
		if !strings.Contains(logged, want) {
			t.Errorf("log line missing %q\nfull line: %s", want, logged)
		}
	}
}

func TestLoggingDefaultsStatusTo200(t *testing.T) {
	var buf bytes.Buffer
	logger := slog.New(slog.NewTextHandler(&buf, nil))
	h := Logging(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		// No explicit WriteHeader: should default to 200 in our log line.
		_, _ = w.Write([]byte("ok"))
	}))

	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/", http.NoBody))

	if !strings.Contains(buf.String(), "status=200") {
		t.Errorf("expected status=200 in log, got %q", buf.String())
	}
}

func TestLoggingIncludesRequestID(t *testing.T) {
	var buf bytes.Buffer
	logger := slog.New(slog.NewTextHandler(&buf, nil))
	h := RequestID(Logging(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusOK)
	})))

	req := httptest.NewRequestWithContext(t.Context(), "GET", "/", http.NoBody)
	req.Header.Set("X-Request-ID", "abc-123")
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, req)

	if !strings.Contains(buf.String(), "request_id=abc-123") {
		t.Errorf("expected request_id in log, got %q", buf.String())
	}
}

A  => internal/server/middleware/recovery.go +35 -0
@@ 1,35 @@
package middleware

import (
	"fmt"
	"log/slog"
	"net/http"

	"go.bigb.es/auxilia/culpa"
	"go.bigb.es/auxilia/scribe"
)

// Recovery catches panics from downstream handlers, logs them with a stack
// trace via culpa, and returns a generic 500 so a single bad handler can't
// crash the process.
func Recovery(logger *slog.Logger) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			defer func() {
				if rec := recover(); rec != nil {
					err, ok := rec.(error)
					if !ok {
						err = fmt.Errorf("%v", rec)
					}
					err = culpa.Wrap(err, "panic recovered")
					logger.Error("panic recovered",
						"path", r.URL.Path,
						scribe.Err(err),
					)
					http.Error(w, "internal server error", http.StatusInternalServerError)
				}
			}()
			next.ServeHTTP(w, r)
		})
	}
}

A  => internal/server/middleware/recovery_test.go +57 -0
@@ 1,57 @@
package middleware

import (
	"bytes"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func TestRecoveryCatchesPanic(t *testing.T) {
	var buf bytes.Buffer
	logger := slog.New(slog.NewTextHandler(&buf, nil))
	h := Recovery(logger)(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
		panic("boom")
	}))

	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/explode", http.NoBody))

	if rr.Code != http.StatusInternalServerError {
		t.Errorf("status = %d, want 500", rr.Code)
	}
	if !strings.Contains(rr.Body.String(), "internal server error") {
		t.Errorf("body = %q", rr.Body.String())
	}
	logged := buf.String()
	if !strings.Contains(logged, "panic recovered") {
		t.Errorf("expected log to mention 'panic recovered', got %q", logged)
	}
	if !strings.Contains(logged, "/explode") {
		t.Errorf("expected log to include path, got %q", logged)
	}
}

func TestRecoveryPassesThroughWhenNoPanic(t *testing.T) {
	var buf bytes.Buffer
	logger := slog.New(slog.NewTextHandler(&buf, nil))
	h := Recovery(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusTeapot)
		_, _ = w.Write([]byte("hi"))
	}))

	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/", http.NoBody))

	if rr.Code != http.StatusTeapot {
		t.Errorf("status = %d, want 418", rr.Code)
	}
	if rr.Body.String() != "hi" {
		t.Errorf("body = %q", rr.Body.String())
	}
	if buf.Len() != 0 {
		t.Errorf("nothing should have been logged, got %q", buf.String())
	}
}

A  => internal/server/middleware/requestid.go +36 -0
@@ 1,36 @@
// Package middleware contains chi-compatible HTTP middleware for the server.
package middleware

import (
	"context"
	"net/http"

	"github.com/google/uuid"
)

type ctxKey string

// RequestIDKey is the context key under which the request ID is stored.
const RequestIDKey ctxKey = "request_id"

// RequestID propagates an X-Request-ID header through the request, generating
// a UUID when the client didn't send one. The header is always echoed back.
func RequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := r.Header.Get("X-Request-ID")
		if id == "" {
			id = uuid.NewString()
		}
		ctx := context.WithValue(r.Context(), RequestIDKey, id)
		w.Header().Set("X-Request-ID", id)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

// GetRequestID returns the request ID stored in ctx, or "" if absent.
func GetRequestID(ctx context.Context) string {
	if id, ok := ctx.Value(RequestIDKey).(string); ok {
		return id
	}
	return ""
}

A  => internal/server/middleware/requestid_test.go +64 -0
@@ 1,64 @@
package middleware

import (
	"context"
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestRequestIDGeneratesWhenAbsent(t *testing.T) {
	var seenInHandler string
	h := RequestID(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
		seenInHandler = GetRequestID(r.Context())
	}))

	req := httptest.NewRequestWithContext(t.Context(), "GET", "/", http.NoBody)
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, req)

	got := rr.Header().Get("X-Request-ID")
	if got == "" {
		t.Fatal("X-Request-ID header should be set")
	}
	if got != seenInHandler {
		t.Errorf("response header %q != context value %q", got, seenInHandler)
	}
	// Generated UUID should be 36 chars (8-4-4-4-12).
	if len(got) != 36 {
		t.Errorf("generated id length = %d, want 36", len(got))
	}
}

func TestRequestIDPreservesIncoming(t *testing.T) {
	const incoming = "trace-abc-123"
	var seen string
	h := RequestID(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
		seen = GetRequestID(r.Context())
	}))

	req := httptest.NewRequestWithContext(t.Context(), "GET", "/", http.NoBody)
	req.Header.Set("X-Request-ID", incoming)
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, req)

	if seen != incoming {
		t.Errorf("context id = %q, want %q", seen, incoming)
	}
	if got := rr.Header().Get("X-Request-ID"); got != incoming {
		t.Errorf("response id = %q, want %q", got, incoming)
	}
}

func TestGetRequestIDEmptyContext(t *testing.T) {
	if got := GetRequestID(context.Background()); got != "" {
		t.Errorf("expected empty string, got %q", got)
	}
}

func TestGetRequestIDWrongType(t *testing.T) {
	ctx := context.WithValue(context.Background(), RequestIDKey, 42) // not a string
	if got := GetRequestID(ctx); got != "" {
		t.Errorf("expected empty string, got %q", got)
	}
}

A  => internal/server/router.go +65 -0
@@ 1,65 @@
package server

import (
	"log/slog"
	"net/http"

	"github.com/go-chi/chi/v5"

	"sourcecraft.dev/bigbes/huntsman/internal/domain/search"
	"sourcecraft.dev/bigbes/huntsman/internal/platform/health"
	"sourcecraft.dev/bigbes/huntsman/internal/server/middleware"
)

// Routes wires every HTTP route the service exposes.
//
// Keeping route registration in one file makes it easy to audit the
// public surface area without grepping every handler package.
func Routes(logger *slog.Logger, h *health.Health, searchHandler *search.Handler) http.Handler {
	r := chi.NewRouter()

	r.Use(middleware.RequestID)
	r.Use(middleware.Recovery(logger))
	r.Use(middleware.Logging(logger))

	r.Get("/healthz", h.Healthz)
	r.Get("/readyz", h.Readyz)

	r.Get("/", indexHandler)

	searchHandler.RegisterRoutes(r)

	return r
}

// indexHandler advertises the unified OpenSearch document via a
// <link rel="search"> tag, so browsers offer to add the engine when
// the user visits the root URL.
func indexHandler(w http.ResponseWriter, _ *http.Request) {
	const body = `<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>huntsman</title>
  <link rel="search"
        type="application/opensearchdescription+xml"
        title="huntsman"
        href="/opensearch.xml">
</head>
<body>
  <h1>huntsman</h1>
  <p>Multi-provider search router. Use prefixes:</p>
  <ul>
    <li><code>ud &lt;query&gt;</code> &mdash; Urban Dictionary</li>
    <li><code>gh &lt;query&gt;</code> &mdash; GitHub</li>
    <li><code>steam &lt;query&gt;</code> &mdash; Steam</li>
  </ul>
  <p>Endpoints: <code>/search?q=...</code>, <code>/providers</code>,
     <code>/opensearch.xml</code>,
     <code>/opensearch/{provider}.xml</code>.</p>
</body>
</html>
`
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	_, _ = w.Write([]byte(body))
}

A  => internal/server/router_test.go +87 -0
@@ 1,87 @@
package server

import (
	"io"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"sourcecraft.dev/bigbes/huntsman/internal/domain/search"
	"sourcecraft.dev/bigbes/huntsman/internal/platform/health"
)

func newTestRouter(t *testing.T) http.Handler {
	t.Helper()
	logger := slog.New(slog.NewTextHandler(io.Discard, nil))
	svc, err := search.NewService("gh", "https://example.com")
	if err != nil {
		t.Fatalf("NewService: %v", err)
	}
	return Routes(logger, health.New(), search.NewHandler(svc))
}

func TestIndexHandler(t *testing.T) {
	h := newTestRouter(t)
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/", http.NoBody))

	if rr.Code != http.StatusOK {
		t.Fatalf("status = %d", rr.Code)
	}
	if got := rr.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/html") {
		t.Errorf("content-type = %q", got)
	}
	body := rr.Body.String()
	if !strings.Contains(body, `<link rel="search"`) {
		t.Errorf("body missing OSD link tag: %q", body)
	}
	if !strings.Contains(body, "huntsman") {
		t.Errorf("body missing service name")
	}
}

func TestRoutesMountAllExpectedPaths(t *testing.T) {
	h := newTestRouter(t)

	cases := []struct {
		path       string
		wantStatus int
	}{
		{"/healthz", 200},
		{"/readyz", 200},
		{"/", 200},
		{"/providers", 200},
		{"/opensearch.xml", 200},
		{"/opensearch/gh.xml", 200},
		{"/search?q=foo", 302},
	}
	for _, tc := range cases {
		t.Run(tc.path, func(t *testing.T) {
			rr := httptest.NewRecorder()
			h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", tc.path, http.NoBody))
			if rr.Code != tc.wantStatus {
				t.Errorf("%s: status = %d, want %d", tc.path, rr.Code, tc.wantStatus)
			}
		})
	}
}

func TestRoutesAttachRequestIDMiddleware(t *testing.T) {
	h := newTestRouter(t)
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/", http.NoBody))
	if rr.Header().Get("X-Request-ID") == "" {
		t.Error("expected X-Request-ID header from middleware")
	}
}

func TestRoutesUnknownPath404(t *testing.T) {
	h := newTestRouter(t)
	rr := httptest.NewRecorder()
	h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/no-such-thing", http.NoBody))
	if rr.Code != http.StatusNotFound {
		t.Errorf("status = %d, want 404", rr.Code)
	}
}