Go 252 lines
package memory
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/ajhahnde/eeco/internal/config"
)
// newStore returns an opened Store rooted at a fresh temp workspace,
// with a fake repo root (no .git required). The Now clock is fixed.
func newStore(t *testing.T) *Store {
t.Helper()
root := t.TempDir()
ws := filepath.Join(root, ".eeco")
if err := os.MkdirAll(filepath.Join(ws, "state"), 0o755); err != nil {
t.Fatal(err)
}
cfg := &config.Config{
RepoRoot: root,
WorkspaceName: ".eeco",
Workspace: ws,
Profile: config.ProfileGeneric,
StaleDays: config.DefaultStaleDays,
}
s, err := Open(cfg)
if err != nil {
t.Fatal(err)
}
s.Now = func() time.Time { return time.Date(2026, 5, 19, 12, 0, 0, 0, time.UTC) }
return s
}
func makeFact(name, desc string, typ FactType, opts ...func(*Fact)) *Fact {
f := &Fact{
Name: name,
Description: desc,
Type: typ,
Created: time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC),
LastUsed: time.Date(2026, 5, 19, 0, 0, 0, 0, time.UTC),
}
for _, o := range opts {
o(f)
}
return f
}
func TestStoreSaveLoad_RoundTrip(t *testing.T) {
s := newStore(t)
f := makeFact("alpha", "alpha fact", TypeProject)
if err := s.Save(f); err != nil {
t.Fatal(err)
}
facts, err := s.LoadAll()
if err != nil {
t.Fatal(err)
}
if len(facts) != 1 {
t.Fatalf("LoadAll = %d, want 1", len(facts))
}
if facts[0].Name != "alpha" {
t.Errorf("name = %q", facts[0].Name)
}
}
func TestStoreLoadAll_SkipsAtticAndIndex(t *testing.T) {
s := newStore(t)
f := makeFact("keep", "keep me", TypeProject)
if err := s.Save(f); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(s.AtticDir, 0o755); err != nil {
t.Fatal(err)
}
// Drop an "archived" file in attic; LoadAll must ignore it.
if err := os.WriteFile(filepath.Join(s.AtticDir, "garbage.md"), []byte("---\nname: garbage\n---\n"), 0o644); err != nil {
t.Fatal(err)
}
// Drop a MEMORY.md sibling; LoadAll must ignore it.
if err := os.WriteFile(filepath.Join(s.MemoryDir, IndexFilename), []byte("# index"), 0o644); err != nil {
t.Fatal(err)
}
// Drop a non-md sibling; LoadAll must ignore it.
if err := os.WriteFile(filepath.Join(s.MemoryDir, "notes.txt"), []byte("hi"), 0o644); err != nil {
t.Fatal(err)
}
facts, err := s.LoadAll()
if err != nil {
t.Fatal(err)
}
if len(facts) != 1 || facts[0].Name != "keep" {
t.Errorf("unexpected facts: %+v", facts)
}
}
func TestStoreLoadAll_SkipsDotPrefixed(t *testing.T) {
s := newStore(t)
f := makeFact("keep", "keep me", TypeProject)
if err := s.Save(f); err != nil {
t.Fatal(err)
}
// Dot-prefixed file with body that would otherwise fail ParseFact;
// proves the skip happens before parsing.
if err := os.WriteFile(filepath.Join(s.MemoryDir, ".gc-policy.md"), []byte("# policy\n"), 0o644); err != nil {
t.Fatal(err)
}
// Dot-prefixed file with valid-looking frontmatter; proves the
// skip is filename-based, not parse-outcome-based.
hidden := makeFact("hidden", "hidden fact", TypeUser)
data, err := Serialize(hidden)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(s.MemoryDir, ".hidden.md"), data, 0o644); err != nil {
t.Fatal(err)
}
facts, err := s.LoadAll()
if err != nil {
t.Fatalf("LoadAll errored: %v", err)
}
if len(facts) != 1 || facts[0].Name != "keep" {
t.Errorf("unexpected facts: %+v", facts)
}
}
func TestStoreLoadAll_FilenameMustMatchName(t *testing.T) {
s := newStore(t)
f := makeFact("good-name", "x", TypeUser)
data, err := Serialize(f)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(s.MemoryDir, "wrong-name.md"), data, 0o644); err != nil {
t.Fatal(err)
}
if _, err := s.LoadAll(); err == nil {
t.Fatal("expected mismatched filename to error")
}
}
func TestStoreLoadAll_RejectsParseError(t *testing.T) {
s := newStore(t)
if err := os.WriteFile(filepath.Join(s.MemoryDir, "broken.md"), []byte("not a fact"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := s.LoadAll(); err == nil {
t.Fatal("expected parse error to bubble up")
}
}
func TestStoreLoadAll_EmptyDirOK(t *testing.T) {
s := newStore(t)
facts, err := s.LoadAll()
if err != nil {
t.Fatal(err)
}
if len(facts) != 0 {
t.Errorf("expected empty, got %d facts", len(facts))
}
}
// --- Save atomic-write faults (target c: fail loudly and cleanly) ---
func TestSave_ValidateFail(t *testing.T) {
s := newStore(t)
if err := s.Save(&Fact{}); err == nil {
t.Fatal("expected Save of an invalid fact to error")
} else if !strings.Contains(err.Error(), "memory.Save:") {
t.Errorf("err = %v, want wrap memory.Save:", err)
}
}
func TestSave_CreateTempFail_ParentNotDir(t *testing.T) {
s := newStore(t)
// Point MemoryDir at a regular file: os.CreateTemp tries to create a
// temp file *inside* it and fails with ENOTDIR. Validate and Serialize
// pass first, so this exercises the CreateTemp error branch.
filePath := filepath.Join(s.RepoRoot, "not-a-dir")
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
s.MemoryDir = filePath
err := s.Save(makeFact("foo", "x", TypeUser))
if err == nil {
t.Fatal("expected CreateTemp to fail when MemoryDir is a file")
}
if !strings.Contains(err.Error(), "memory.Save:") {
t.Errorf("err = %v, want wrap memory.Save:", err)
}
}
func TestSave_RenameFail_TargetIsDir(t *testing.T) {
s := newStore(t)
// Target foo.md is a directory: CreateTemp/Write/Close succeed, but the
// final Rename onto a directory fails. The cleanup() closure must then
// remove the temp file so no .foo.*.tmp leaks.
if err := os.Mkdir(s.pathFor("foo"), 0o755); err != nil {
t.Fatal(err)
}
err := s.Save(makeFact("foo", "x", TypeUser))
if err == nil {
t.Fatal("expected Rename onto a directory to fail")
}
if !strings.Contains(err.Error(), "memory.Save:") {
t.Errorf("err = %v, want wrap memory.Save:", err)
}
leftover, _ := filepath.Glob(filepath.Join(s.MemoryDir, ".foo.*.tmp"))
if len(leftover) != 0 {
t.Errorf("temp file not cleaned up: %v", leftover)
}
}
func TestOpen_NilConfig(t *testing.T) {
_, err := Open(nil)
if err == nil {
t.Fatal("expected nil config to error")
}
if !strings.Contains(err.Error(), "memory.Open: nil config") {
t.Errorf("err = %v, want memory.Open: nil config", err)
}
}
func TestOpen_MkdirAllFail_WorkspaceIsFile(t *testing.T) {
root := t.TempDir()
filePath := filepath.Join(root, "file")
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
// Workspace sits *under* a regular file, so Join(Workspace, "memory")
// can't be created: MkdirAll returns ENOTDIR. (newStore is not reused
// here — it must succeed; this case needs Open itself to fail.)
cfg := &config.Config{
RepoRoot: root,
WorkspaceName: ".eeco",
Workspace: filepath.Join(filePath, "sub"),
Profile: config.ProfileGeneric,
StaleDays: config.DefaultStaleDays,
}
_, err := Open(cfg)
if err == nil {
t.Fatal("expected MkdirAll to fail when workspace is under a file")
}
if !strings.Contains(err.Error(), "memory: create memory dir:") {
t.Errorf("err = %v, want memory: create memory dir:", err)
}
}