ajhahn.de
← eeco
Go 288 lines
package ask

import (
	"encoding/json"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/ajhahnde/eeco/internal/config"
	"github.com/ajhahnde/eeco/internal/memory"
)

// TestMain pins the user-global config dir to an empty temp dir so the
// global config layer is a hermetic no-op and these tests never read the
// dev box's ~/.config/eeco.
func TestMain(m *testing.M) {
	gdir, err := os.MkdirTemp("", "eeco-global-")
	if err != nil {
		panic(err)
	}
	os.Setenv(config.GlobalConfigEnv, gdir)
	code := m.Run()
	os.RemoveAll(gdir)
	os.Exit(code)
}

// fixture builds a throwaway repo (a bare .git so the directory-walk
// fallback engages) with a few source files carrying known terms, and
// returns a loaded config rooted at it.
func fixture(t *testing.T) *config.Config {
	t.Helper()
	root := t.TempDir()
	mustWrite(t, root, ".git/HEAD", "ref: refs/heads/main\n")
	mustWrite(t, root, "go.mod", "module sample\n\ngo 1.24\n")
	mustWrite(t, root, "boot.go", "// boot path setup\nfunc boot() {}\n")
	mustWrite(t, root, "internal/render/render.go", "// render the project brief\nfunc Render() {}\n")
	mustWrite(t, root, "README.md", "A sample project.\n")
	// A large file and a binary file must both be skipped by the scan.
	mustWrite(t, root, "big.txt", "boot\n"+strings.Repeat("x\n", maxFileBytes))
	mustWrite(t, root, "blob.bin", "boot\x00path\n")

	cfg, err := config.Load(root, "")
	if err != nil {
		t.Fatalf("config.Load: %v", err)
	}
	return cfg
}

func mustWrite(t *testing.T, dir, rel, content string) {
	t.Helper()
	full := filepath.Join(dir, filepath.FromSlash(rel))
	if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
		t.Fatal(err)
	}
}

func TestSearch_CodeMatchesAndRanks(t *testing.T) {
	cfg := fixture(t)
	res, err := Search(cfg, "render project brief", 10)
	if err != nil {
		t.Fatalf("Search: %v", err)
	}
	if len(res.Code) == 0 {
		t.Fatal("expected code matches, got none")
	}
	// The render.go line carries three of the query terms; it must rank
	// first regardless of path order.
	top := res.Code[0]
	if !strings.Contains(top.Path, "render.go") {
		t.Errorf("top hit should be render.go, got %s:%d (%s)", top.Path, top.Line, top.Text)
	}
	if top.Score < 2 {
		t.Errorf("top hit score = %d, want >= 2", top.Score)
	}
}

func TestSearch_SkipsBinaryAndOversized(t *testing.T) {
	cfg := fixture(t)
	res, err := Search(cfg, "boot path", 50)
	if err != nil {
		t.Fatalf("Search: %v", err)
	}
	for _, c := range res.Code {
		if c.Path == "blob.bin" {
			t.Error("binary file blob.bin should be skipped")
		}
		if c.Path == "big.txt" {
			t.Error("oversized file big.txt should be skipped")
		}
	}
	if len(res.Code) == 0 {
		t.Error("expected boot.go to match 'boot path'")
	}
}

func TestSearch_Deterministic(t *testing.T) {
	cfg := fixture(t)
	first, err := Search(cfg, "boot render project", 10)
	if err != nil {
		t.Fatal(err)
	}
	second, err := Search(cfg, "boot render project", 10)
	if err != nil {
		t.Fatal(err)
	}
	if Render(first) != Render(second) {
		t.Error("Search is not deterministic across runs")
	}
}

func TestSearch_LimitCapsCode(t *testing.T) {
	cfg := fixture(t)
	res, err := Search(cfg, "boot render project sample func", 1)
	if err != nil {
		t.Fatal(err)
	}
	if len(res.Code) > 1 {
		t.Errorf("--limit 1 returned %d code hits", len(res.Code))
	}
}

func TestSearch_EmptyQuestion(t *testing.T) {
	cfg := fixture(t)
	res, err := Search(cfg, "a !! ?", 10) // only short / non-word tokens
	if err != nil {
		t.Fatal(err)
	}
	if len(res.Code) != 0 || len(res.Memory) != 0 {
		t.Error("a question with no usable terms should match nothing")
	}
	// Non-nil slices so JSON renders [] not null.
	if res.Memory == nil || res.Code == nil {
		t.Error("slices must be non-nil")
	}
}

func TestSearch_MemoryHits(t *testing.T) {
	cfg := fixture(t)
	// IsInitialized requires the scaffolded subdirs to exist.
	for _, sub := range []string{"engine", "memory", "workflows", "state", "docs"} {
		if err := os.MkdirAll(filepath.Join(cfg.Workspace, sub), 0o755); err != nil {
			t.Fatal(err)
		}
	}
	store, err := memory.Open(cfg)
	if err != nil {
		t.Fatal(err)
	}
	now := time.Now()
	fact := &memory.Fact{
		Name:        "boot-path",
		Description: "where the boot path is configured",
		Type:        memory.TypeProject,
		Created:     now,
		LastUsed:    now,
		Ref:         "boot.go",
		Body:        "the boot sequence starts here",
	}
	if err := store.Save(fact); err != nil {
		t.Fatal(err)
	}

	// Snapshot last_used as persisted, before the search.
	before, err := store.LoadAll()
	if err != nil {
		t.Fatal(err)
	}
	wantLastUsed := before[0].LastUsed

	res, err := Search(cfg, "boot path", 10)
	if err != nil {
		t.Fatal(err)
	}
	if len(res.Memory) == 0 {
		t.Fatal("expected a memory hit for 'boot path'")
	}
	if res.Memory[0].Ref != "boot.go" {
		t.Errorf("memory hit ref = %q, want boot.go", res.Memory[0].Ref)
	}

	// Search must not mutate the store (unlike memory.Select, which bumps
	// last_used): the persisted last_used is unchanged after the search.
	after, err := store.LoadAll()
	if err != nil {
		t.Fatal(err)
	}
	if !after[0].LastUsed.Equal(wantLastUsed) {
		t.Errorf("ask.Search mutated last_used: was %v, now %v", wantLastUsed, after[0].LastUsed)
	}
}

func TestSearch_DisabledFactsHidden(t *testing.T) {
	cfg := fixture(t)
	for _, sub := range []string{"engine", "memory", "workflows", "state", "docs"} {
		if err := os.MkdirAll(filepath.Join(cfg.Workspace, sub), 0o755); err != nil {
			t.Fatal(err)
		}
	}
	store, err := memory.Open(cfg)
	if err != nil {
		t.Fatal(err)
	}
	now := time.Now()
	enabled := &memory.Fact{
		Name: "enabled-boot", Description: "boot path enabled",
		Type: memory.TypeProject, Created: now, LastUsed: now, Body: "boot path lives here",
	}
	disabled := &memory.Fact{
		Name: "disabled-boot", Description: "boot path disabled",
		Type: memory.TypeProject, Created: now, LastUsed: now, Body: "boot path lives here too",
		Disabled: true,
	}
	for _, f := range []*memory.Fact{enabled, disabled} {
		if err := store.Save(f); err != nil {
			t.Fatal(err)
		}
	}
	res, err := Search(cfg, "boot path", 10)
	if err != nil {
		t.Fatal(err)
	}
	for _, h := range res.Memory {
		if h.Name == "disabled-boot" {
			t.Errorf("disabled fact leaked into ask Memory results: %+v", h)
		}
	}
	var foundEnabled bool
	for _, h := range res.Memory {
		if h.Name == "enabled-boot" {
			foundEnabled = true
		}
	}
	if !foundEnabled {
		t.Error("enabled fact missing from ask Memory results")
	}
}

func TestSearch_NotInitializedEmptyMemory(t *testing.T) {
	cfg := fixture(t) // no config.local written → not initialised
	res, err := Search(cfg, "boot path", 10)
	if err != nil {
		t.Fatal(err)
	}
	if len(res.Memory) != 0 {
		t.Error("uninitialised workspace should yield no memory hits")
	}
	if len(res.Code) == 0 {
		t.Error("code search should still run when uninitialised")
	}
}

func TestRender_EmptyState(t *testing.T) {
	out := Render(Result{Question: "nothing here", Memory: []MemoryHit{}, Code: []CodeHit{}})
	if !strings.Contains(out, "No matches") {
		t.Errorf("empty answer should render the no-matches guidance:\n%s", out)
	}
	if strings.Contains(out, "## Code") {
		t.Errorf("empty answer should not render section headers:\n%s", out)
	}
}

func TestRenderJSON_KeysAndNonNull(t *testing.T) {
	out, err := RenderJSON(Result{Question: "q", Memory: []MemoryHit{}, Code: []CodeHit{}})
	if err != nil {
		t.Fatal(err)
	}
	if !json.Valid([]byte(out)) {
		t.Fatalf("RenderJSON produced invalid JSON:\n%s", out)
	}
	var raw map[string]json.RawMessage
	if err := json.Unmarshal([]byte(out), &raw); err != nil {
		t.Fatal(err)
	}
	for _, k := range []string{"question", "memory", "code"} {
		if _, ok := raw[k]; !ok {
			t.Errorf("JSON missing frozen top-level key %q", k)
		}
	}
	if string(raw["memory"]) == "null" || string(raw["code"]) == "null" {
		t.Error("arrays must serialise as [] not null")
	}
}