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