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