ajhahn.de
← eeco
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)
	}
}