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")
}
}