samsa/docs/CONTRIBUTING.md
Franz Kafka ba06582218
Some checks failed
Build and Push Docker Image / build-and-push (push) Failing after 7s
Mirror to GitHub / mirror (push) Failing after 5s
Tests / test (push) Successful in 22s
docs: add CONTRIBUTING guide for adding new engines
Covers the full lifecycle: interface, SearchRequest/SearchResponse,
result building, graceful degradation, factory wiring, planner
registration, testing, and RSS parsing example.
2026-03-23 08:19:28 +00:00

5.6 KiB

Contributing — Adding a New Engine

This guide walks through adding a new search engine to samsa. The minimal engine needs only an HTTP client, a query, and a result parser.


1. Create the engine file

Place it in internal/engines/:

internal/engines/
  myengine.go        ← your engine
  myengine_test.go   ← tests (required)

Name the struct after the engine, e.g. WolframEngine for "wolfram". The Name() method returns the engine key used throughout samsa.

2. Implement the Engine interface

package engines

import (
    "context"
    "github.com/metamorphosis-dev/samsa/internal/contracts"
)

type MyEngine struct {
    client *http.Client
}

func (e *MyEngine) Name() string { return "myengine" }

func (e *MyEngine) Search(ctx context.Context, req contracts.SearchRequest) (contracts.SearchResponse, error) {
    // ...
}

The SearchRequest fields you'll use most:

Field Type Description
Query string The search query
Pageno int Current page number (1-based)
Safesearch int 0=off, 1=moderate, 2=strict
Language string ISO language code (e.g. "en")

The SearchResponse to return:

contracts.SearchResponse{
    Query:               req.Query,
    NumberOfResults:     len(results),
    Results:             results,       // []MainResult
    Answers:             []map[string]any{},
    Corrections:         []string{},
    Infoboxes:           []map[string]any{},
    Suggestions:         []string{},
    UnresponsiveEngines: [][2]string{},
}

Empty query — return early:

if strings.TrimSpace(req.Query) == "" {
    return contracts.SearchResponse{Query: req.Query}, nil
}

Engine unavailable / error — graceful degradation:

// Rate limited or blocked
return contracts.SearchResponse{
    Query:               req.Query,
    UnresponsiveEngines: [][2]string{{"myengine", "reason"}},
    Results:             []contracts.MainResult{},
    // ... empty other fields
}, nil

// Hard error — return it
return contracts.SearchResponse{}, fmt.Errorf("myengine upstream error: status %d", resp.StatusCode)

3. Build the result

urlPtr := "https://example.com/result"
result := contracts.MainResult{
    Title:    "Result Title",
    Content:  "Snippet or description text",
    URL:      &urlPtr,       // pointer to string, required
    Engine:   "myengine",
    Category: "general",     // or "it", "science", "videos", "images", "social media"
    Score:    0,             // used for relevance ranking during merge
    Engines:  []string{"myengine"},
}

Template field

The template system checks for "videos" and "images". Everything else renders via result_item.html. Set Template only if you have a custom template; omit it for the default result card.

Category field

Controls which category tab the result appears under and which engines are triggered:

Category Engines used
general google, bing, ddg, brave, braveapi, qwant, wikipedia
it github, stackoverflow
science arxiv, crossref
videos youtube
images bing_images, ddg_images, qwant_images
social media reddit

4. Wire it into the factory

In internal/engines/factory.go, add your engine to the map returned by NewDefaultPortedEngines:

"myengine": &MyEngine{client: client},

If your engine needs an API key, read it from config or the environment (see braveapi or youtube in factory.go for the pattern).

5. Register defaults

In internal/engines/planner.go:

Add to defaultPortedEngines:

var defaultPortedEngines = []string{
    // ... existing ...
    "myengine",
}

Add to category mapping in inferFromCategories (if applicable):

case "general":
    set["myengine"] = true

Update the sort order map so results maintain consistent ordering:

order := map[string]int{
    // ... existing ...
    "myengine": N,  // pick a slot
}

6. Add tests

At minimum, test:

  • Name() returns the correct string
  • Nil engine returns an error
  • Empty query returns zero results
  • Successful API response parses correctly
  • Rate limit / error cases return UnresponsiveEngines with a reason

Use httptest.NewServer to mock the upstream API. See arxiv_test.go or reddit_test.go for examples.

7. Build and test

go build ./...
go test ./internal/engines/ -run MyEngine -v
go test ./...

Example: Adding an RSS-based engine

If the engine provides an RSS feed, the parsing is straightforward:

type rssItem struct {
    Title       string `xml:"title"`
    Link        string `xml:"link"`
    Description string `xml:"description"`
}

type rssFeed struct {
    Channel struct {
        Items []rssItem `xml:"item"`
    } `xml:"channel"`
}

dec := xml.NewDecoder(resp.Body)
var feed rssFeed
dec.Decode(&feed)

for _, item := range feed.Channel.Items {
    urlPtr := item.Link
    results = append(results, contracts.MainResult{
        Title:   item.Title,
        Content: stripHTML(item.Description),
        URL:     &urlPtr,
        Engine:  "myengine",
        // ...
    })
}

Checklist

  • Engine file created in internal/engines/
  • Engine interface implemented (Name() + Search())
  • Empty query handled (return early, no error)
  • Graceful degradation for errors and rate limits
  • Results use Category to group with related engines
  • Factory updated with new engine
  • Planner updated (defaults + category mapping + sort order)
  • Tests written covering main paths
  • go build ./... succeeds
  • go test ./... passes