// 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
}