Files
2026-07-13 13:00:08 +08:00

856 lines
28 KiB
Go

package memory
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
fileencoding "reasonix/internal/fileutil/encoding"
)
// TestRenderEscapesYAMLMetacharacters pins the frontmatter-corruption fix: a
// title or description with YAML metacharacters must survive a save→load
// round-trip. The previous hand-concatenated renderer produced unparseable
// YAML for "Plan: step one"-style titles; frontmatter.Split then returned an
// empty map and the reloaded memory silently lost its name, title, and type.
func TestRenderEscapesYAMLMetacharacters(t *testing.T) {
cases := []struct {
label, title, desc string
}{
{"colon", "My plan: step one", "Covers: the rollout"},
{"hash", "Ship #42", "Tracks #release notes"},
{"double-quote", `The "golden" path`, `Says "hello"`},
{"single-quote", "Don't drop this", "User's preference"},
{"leading-special", "- looks like a list", "* also a list"},
{"yaml-lookalike", "type: reference", "metadata: nested"},
}
for _, tc := range cases {
t.Run(tc.label, func(t *testing.T) {
dir := t.TempDir()
s := Store{Dir: filepath.Join(dir, "memory")}
if _, err := s.Save(Memory{
Name: "esc-" + tc.label,
Title: tc.title,
Description: tc.desc,
Type: TypeProject,
Body: "the body",
}); err != nil {
t.Fatal(err)
}
list := s.List()
if len(list) != 1 {
t.Fatalf("want 1 memory, got %d", len(list))
}
got := list[0]
if got.Title != tc.title {
t.Errorf("Title = %q, want %q", got.Title, tc.title)
}
if got.Description != tc.desc {
t.Errorf("Description = %q, want %q", got.Description, tc.desc)
}
if got.Type != TypeProject {
t.Errorf("Type = %q, want project (metadata lost)", got.Type)
}
if got.Body != "the body" {
t.Errorf("Body = %q", got.Body)
}
})
}
}
// TestRenderPlainValuesKeepLegacyBytes pins byte-compatibility for the common
// case: memories whose title/description need no escaping must render exactly
// as the previous hand-built format did, so existing files rewritten on save
// do not churn.
func TestRenderPlainValuesKeepLegacyBytes(t *testing.T) {
got := render(Memory{
Title: "Prefers tabs",
Description: "User prefers tabs over spaces",
Type: TypeUser,
Body: "Always indent with tabs.",
}, "prefers-tabs")
want := "---\n" +
"name: prefers-tabs\n" +
"title: Prefers tabs\n" +
"description: User prefers tabs over spaces\n" +
"metadata:\n" +
" type: user\n" +
"---\n\n" +
"Always indent with tabs.\n"
if got != want {
t.Fatalf("plain-value render changed bytes:\ngot:\n%s\nwant:\n%s", got, want)
}
}
// TestStoreSaveAndIndex covers the round-trip: Save writes a frontmatter file,
// reindex adds exactly one index line, and List parses it back.
func TestStoreSaveAndIndex(t *testing.T) {
dir := t.TempDir()
s := Store{Dir: filepath.Join(dir, "memory")}
path, err := s.Save(Memory{
Name: "Prefers Tabs",
Description: "User prefers tabs over spaces",
Type: TypeUser,
Body: "Always indent with tabs in this project.",
})
if err != nil {
t.Fatal(err)
}
if filepath.Base(path) != "prefers-tabs.md" {
t.Fatalf("name not slugified into filename: %s", path)
}
idx := s.Index()
if !strings.Contains(idx, "prefers-tabs.md") || !strings.Contains(idx, "User prefers tabs") {
t.Fatalf("index missing entry:\n%s", idx)
}
list := s.List()
if len(list) != 1 {
t.Fatalf("want 1 memory, got %d", len(list))
}
m := list[0]
if m.Name != "prefers-tabs" || m.Type != TypeUser {
t.Fatalf("round-trip mismatch: %+v", m)
}
if !strings.Contains(m.Body, "indent with tabs") {
t.Fatalf("body not preserved: %q", m.Body)
}
}
func TestStoreListDecodesGB18030MemoryFile(t *testing.T) {
s := Store{Dir: t.TempDir()}
if err := os.MkdirAll(s.Dir, 0o755); err != nil {
t.Fatal(err)
}
body := `---
title: 中文偏好
description: 使用中文回答
type: user
---
用户希望默认使用中文。`
if err := os.WriteFile(filepath.Join(s.Dir, "cn-pref.md"), fileencoding.Encode(body, fileencoding.GB18030), 0o644); err != nil {
t.Fatal(err)
}
memories := s.List()
if len(memories) != 1 {
t.Fatalf("List() = %+v, want one decoded memory", memories)
}
m := memories[0]
if m.Title != "中文偏好" || m.Description != "使用中文回答" || !strings.Contains(m.Body, "默认使用中文") {
t.Fatalf("decoded memory = %+v", m)
}
}
// TestStoreOverwriteDoesNotDuplicateIndex verifies re-saving the same name
// replaces its index line rather than appending a second.
func TestStoreOverwriteDoesNotDuplicateIndex(t *testing.T) {
s := Store{Dir: t.TempDir()}
for _, desc := range []string{"first version", "second version"} {
if _, err := s.Save(Memory{Name: "note", Description: desc, Type: TypeProject, Body: "b"}); err != nil {
t.Fatal(err)
}
}
idx := s.Index()
if n := strings.Count(idx, "note.md"); n != 1 {
t.Fatalf("want exactly 1 index line for note, got %d:\n%s", n, idx)
}
if !strings.Contains(idx, "second version") || strings.Contains(idx, "first version") {
t.Fatalf("index not updated to latest description:\n%s", idx)
}
}
// TestStoreIndexPreservesHandEdits verifies reindex keeps unrelated lines, so a
// user hand-editing MEMORY.md isn't clobbered when the model saves a new fact.
func TestStoreIndexPreservesHandEdits(t *testing.T) {
s := Store{Dir: t.TempDir()}
if _, err := s.Save(Memory{Name: "alpha", Description: "first", Type: TypeProject, Body: "x"}); err != nil {
t.Fatal(err)
}
indexPath := filepath.Join(s.Dir, indexFile)
handEdited := "# Memory\n\nUser note before managed lines.\n\n" + mustReadString(t, indexPath) + "\nSee [design](design.md) for context.\n"
if err := os.WriteFile(indexPath, []byte(handEdited), 0o644); err != nil {
t.Fatal(err)
}
if _, err := s.Save(Memory{Name: "beta", Description: "second", Type: TypeProject, Body: "y"}); err != nil {
t.Fatal(err)
}
raw := mustReadString(t, indexPath)
for _, want := range []string{"User note before managed lines.", "See [design](design.md) for context.", "alpha.md", "beta.md"} {
if !strings.Contains(raw, want) {
t.Fatalf("MEMORY.md lost %q:\n%s", want, raw)
}
}
if strings.Count(raw, "alpha.md") != 1 || strings.Count(raw, "beta.md") != 1 {
t.Fatalf("managed lines were duplicated:\n%s", raw)
}
if err := s.Delete("alpha"); err != nil {
t.Fatal(err)
}
raw = mustReadString(t, indexPath)
if strings.Contains(raw, "alpha.md") {
t.Fatalf("deleted managed line remained:\n%s", raw)
}
if !strings.Contains(raw, "See [design](design.md) for context.") {
t.Fatalf("ordinary markdown link was treated as managed:\n%s", raw)
}
}
// TestStoreSaveTitleInIndexAndFrontmatter verifies an explicit title becomes the
// index link label and round-trips through the file's frontmatter.
func TestStoreSaveTitleInIndexAndFrontmatter(t *testing.T) {
s := Store{Dir: t.TempDir()}
if _, err := s.Save(Memory{
Name: "tabs-rule",
Title: "Prefers tabs",
Description: "indent with tabs",
Type: TypeUser,
Body: "b",
}); err != nil {
t.Fatal(err)
}
if idx := s.Index(); !strings.Contains(idx, "[Prefers tabs](tabs-rule.md)") {
t.Fatalf("index link should use the title label:\n%s", idx)
}
if got := s.List()[0].Title; got != "Prefers tabs" {
t.Fatalf("title not round-tripped: %q", got)
}
}
// TestStoreIndexLabelFallsBackToDeKebabbedName checks a title-less memory still
// gets a readable label instead of a bare slug.
func TestStoreIndexLabelFallsBackToDeKebabbedName(t *testing.T) {
s := Store{Dir: t.TempDir()}
if _, err := s.Save(Memory{Name: "likes-go", Description: "d", Type: TypeUser, Body: "b"}); err != nil {
t.Fatal(err)
}
if idx := s.Index(); !strings.Contains(idx, "[likes go](likes-go.md)") {
t.Fatalf("missing-title label should de-kebab the name:\n%s", idx)
}
}
// TestStoreDelete archives a fact's file and removes its index line while
// leaving others.
func TestStoreDelete(t *testing.T) {
s := Store{Dir: t.TempDir()}
for _, n := range []string{"alpha", "beta"} {
if _, err := s.Save(Memory{Name: n, Description: "d", Type: TypeProject, Body: "b"}); err != nil {
t.Fatal(err)
}
}
if err := s.Delete("alpha"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(s.Dir, "alpha.md")); !os.IsNotExist(err) {
t.Fatalf("alpha.md should be gone, stat err = %v", err)
}
archived := archivedFiles(t, s.Dir)
if len(archived) != 1 || !strings.HasSuffix(archived[0], "-alpha.md") {
t.Fatalf("archive files = %v, want one alpha archive", archived)
}
idx := s.Index()
if strings.Contains(idx, "alpha.md") {
t.Fatalf("deleted entry still in index:\n%s", idx)
}
if !strings.Contains(idx, "beta.md") {
t.Fatalf("unrelated entry lost on delete:\n%s", idx)
}
if names := s.List(); len(names) != 1 || names[0].Name != "beta" {
t.Fatalf("List after delete = %+v, want only beta", names)
}
}
// TestStoreDeleteMissingIsNoError treats deleting an absent memory as success —
// the goal state (gone) already holds.
func TestStoreDeleteMissingIsNoError(t *testing.T) {
s := Store{Dir: t.TempDir()}
if err := s.Delete("never-saved"); err != nil {
t.Fatalf("deleting a missing memory should not error: %v", err)
}
}
func TestSafeJoinRejectsStoreEscape(t *testing.T) {
dir := t.TempDir()
if _, err := safeJoin(dir, filepath.Join("..", "outside.md")); err == nil {
t.Fatal("safeJoin should reject paths outside the store")
}
if _, err := safeJoin(dir, filepath.Join(t.TempDir(), "outside.md")); err == nil {
t.Fatal("safeJoin should reject absolute paths outside the store")
}
}
func TestStoreArchiveSanitizesNameBeforePathUse(t *testing.T) {
root := t.TempDir()
s := Store{Dir: filepath.Join(root, "memory")}
if err := os.MkdirAll(s.Dir, 0o755); err != nil {
t.Fatal(err)
}
outside := filepath.Join(root, "outside.md")
if err := os.WriteFile(outside, []byte("do not move"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := s.Archive("../outside"); err != nil {
t.Fatalf("Archive with path-like name should be treated as a slug, not a path: %v", err)
}
if _, err := os.Stat(outside); err != nil {
t.Fatalf("outside file should remain untouched: %v", err)
}
}
func TestStoreDeleteRepairsReadOnlyMemoryFile(t *testing.T) {
s := Store{Dir: t.TempDir()}
if _, err := s.Save(Memory{Name: "locked", Description: "d", Type: TypeProject, Body: "b"}); err != nil {
t.Fatal(err)
}
path := filepath.Join(s.Dir, "locked.md")
if err := os.Chmod(path, 0o400); err != nil {
t.Fatal(err)
}
if err := s.Delete("locked"); err != nil {
t.Fatalf("delete read-only memory: %v", err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("locked.md should be gone, stat err = %v", err)
}
archived := archivedFiles(t, s.Dir)
if len(archived) != 1 || !strings.HasSuffix(archived[0], "-locked.md") {
t.Fatalf("archive files = %v, want one locked archive", archived)
}
if strings.Contains(s.Index(), "locked.md") {
t.Fatalf("deleted read-only entry still in index:\n%s", s.Index())
}
}
func TestStoreArchiveReturnsArchivePath(t *testing.T) {
s := Store{Dir: t.TempDir()}
if _, err := s.Save(Memory{Name: "old-fact", Description: "d", Type: TypeProject, Body: "body"}); err != nil {
t.Fatal(err)
}
archive, err := s.Archive("old-fact")
if err != nil {
t.Fatalf("Archive: %v", err)
}
if archive == "" {
t.Fatal("Archive returned empty path for existing memory")
}
body, err := os.ReadFile(archive)
if err != nil {
t.Fatalf("read archive: %v", err)
}
if !strings.Contains(string(body), "body") {
t.Fatalf("archive missing memory body:\n%s", body)
}
if strings.Contains(s.Index(), "old-fact.md") {
t.Fatalf("archived memory still in index:\n%s", s.Index())
}
archived := s.ListArchived()
if len(archived) != 1 {
t.Fatalf("ListArchived = %+v, want one entry", archived)
}
if archived[0].Name != "old-fact" || archived[0].Path != archive {
t.Fatalf("archived entry mismatch: %+v, path %q", archived[0], archive)
}
if archived[0].ArchivedAt.IsZero() {
t.Fatalf("archived entry missing timestamp: %+v", archived[0])
}
if len(s.List()) != 0 {
t.Fatalf("active List should exclude archived memories: %+v", s.List())
}
}
func TestStoreArchiveFlushesStaleIndexWithoutFile(t *testing.T) {
s := Store{Dir: t.TempDir()}
if _, err := s.Save(Memory{Name: "beta", Description: "keep", Type: TypeProject, Body: "body"}); err != nil {
t.Fatal(err)
}
if err := flushIndexIn(s.Dir, map[string]string{
"alpha": "- [alpha](alpha.md) — stale",
"beta": "- [beta](beta.md) — keep",
}); err != nil {
t.Fatal(err)
}
archive, err := s.Archive("alpha")
if err != nil {
t.Fatalf("Archive stale index: %v", err)
}
if archive != "" {
t.Fatalf("Archive should return no path for missing file, got %q", archive)
}
idx := s.Index()
if strings.Contains(idx, "alpha.md") {
t.Fatalf("stale index line should be removed:\n%s", idx)
}
if !strings.Contains(idx, "beta.md") {
t.Fatalf("unrelated index line should remain:\n%s", idx)
}
}
func mustReadString(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func TestStoreListArchivedNewestFirst(t *testing.T) {
s := Store{Dir: t.TempDir()}
dir := filepath.Join(s.Dir, ".archive")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
files := []struct {
name string
body string
}{
{"20260101-010000.000-old.md", render(Memory{Name: "old", Description: "old d", Type: TypeProject, Body: "old body"}, "old")},
{"20260102-010000.000-new.md", render(Memory{Name: "new", Description: "new d", Type: TypeFeedback, Body: "new body"}, "new")},
}
for _, f := range files {
if err := os.WriteFile(filepath.Join(dir, f.name), []byte(f.body), 0o644); err != nil {
t.Fatal(err)
}
}
archived := s.ListArchived()
if len(archived) != 2 {
t.Fatalf("ListArchived len = %d, want 2: %+v", len(archived), archived)
}
if archived[0].Name != "new" || archived[1].Name != "old" {
t.Fatalf("ListArchived order = %+v, want newest first", archived)
}
if archived[0].Type != TypeFeedback || !strings.Contains(archived[1].Body, "old body") {
t.Fatalf("archived memory did not round-trip metadata/body: %+v", archived)
}
}
func archivedFiles(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(filepath.Join(dir, ".archive"))
if err != nil {
t.Fatalf("read archive dir: %v", err)
}
var out []string
for _, entry := range entries {
out = append(out, entry.Name())
}
return out
}
// TestNormalizeType maps unknown types to project and keeps known ones.
func TestNormalizeType(t *testing.T) {
if got := NormalizeType("feedback"); got != TypeFeedback {
t.Errorf("feedback: got %q", got)
}
if got := NormalizeType("garbage"); got != TypeProject {
t.Errorf("unknown should default to project, got %q", got)
}
}
// TestStoreForSlug ensures the project path becomes one filesystem-safe segment.
func TestStoreForSlug(t *testing.T) {
s := StoreFor("/home/me/.reasonix", "/Users/me/proj")
if strings.Count(filepath.Base(filepath.Dir(s.Dir)), "/") != 0 {
t.Fatalf("slug should have no separators: %s", s.Dir)
}
// config.WorkspaceSlug folds case on Windows (equivalent spellings of one
// folder must share a slug); unix slugs keep the original case.
want := "-Users-me-proj"
if runtime.GOOS == "windows" {
want = "-users-me-proj"
}
if !strings.Contains(s.Dir, want) {
t.Fatalf("unexpected slug: %s", s.Dir)
}
}
// TestDisabledStoreIsNoOp ensures a zero Store (no user config dir) never panics
// and errors cleanly on Save.
func TestDisabledStoreIsNoOp(t *testing.T) {
var s Store
if s.Index() != "" || s.List() != nil {
t.Fatal("disabled store should read empty")
}
if _, err := s.Save(Memory{Name: "x", Description: "d", Body: "b"}); err == nil {
t.Fatal("disabled store Save should error, not silently drop")
}
}
// TestStoreGlobalAndProject verifies that TypeUser/TypeFeedback memories are
// routed to GlobalDir, TypeProject/TypeReference stay in Dir, List() merges
// both, and Delete() removes from the correct directory.
func TestStoreGlobalAndProject(t *testing.T) {
dir := t.TempDir()
s := Store{
Dir: filepath.Join(dir, "project", "memory"),
GlobalDir: filepath.Join(dir, "global"),
}
// TypeUser → GlobalDir
pUser, err := s.Save(Memory{Name: "prefers-tabs", Description: "user pref", Type: TypeUser, Body: "use tabs"})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(pUser, s.GlobalDir) {
t.Fatalf("TypeUser should go to GlobalDir, got %s", pUser)
}
// TypeProject → Dir
pProj, err := s.Save(Memory{Name: "build-target", Description: "build target", Type: TypeProject, Body: "go build"})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(pProj, s.Dir) {
t.Fatalf("TypeProject should go to Dir, got %s", pProj)
}
// TypeFeedback → GlobalDir
pFb, err := s.Save(Memory{Name: "no-emoji", Description: "no emoji", Type: TypeFeedback, Body: "skip emoji"})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(pFb, s.GlobalDir) {
t.Fatalf("TypeFeedback should go to GlobalDir, got %s", pFb)
}
// TypeReference → Dir
pRef, err := s.Save(Memory{Name: "api-docs", Description: "api docs", Type: TypeReference, Body: "see docs"})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(pRef, s.Dir) {
t.Fatalf("TypeReference should go to Dir, got %s", pRef)
}
// List merges both directories
list := s.List()
if len(list) != 4 {
t.Fatalf("want 4 memories, got %d", len(list))
}
// Index merges both directories
idx := s.Index()
if !strings.Contains(idx, "prefers-tabs") || !strings.Contains(idx, "build-target") {
t.Fatalf("index should contain both global and project memories:\n%s", idx)
}
// Delete removes from the correct directory
if err := s.Delete("prefers-tabs"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(pUser); !os.IsNotExist(err) {
t.Fatal("global memory file should be gone after delete")
}
// List after delete
list2 := s.List()
if len(list2) != 3 {
t.Fatalf("want 3 memories after delete, got %d", len(list2))
}
// Index should not duplicate # Memory headers (Block() adds its own).
idx2 := s.Index()
if strings.Count(idx2, "# Memory") != 0 {
t.Fatalf("Index should have 0 # Memory headers (Block() adds one), got %d:\n%s", strings.Count(idx2, "# Memory"), idx2)
}
}
func TestStoreSaveRemovesStaleCopyWhenScopeChanges(t *testing.T) {
dir := t.TempDir()
s := Store{
Dir: filepath.Join(dir, "project", "memory"),
GlobalDir: filepath.Join(dir, "global"),
}
globalPath, err := s.Save(Memory{Name: "same-name", Description: "old global", Type: TypeUser, Body: "old body"})
if err != nil {
t.Fatal(err)
}
projectPath, err := s.Save(Memory{Name: "same-name", Description: "new project", Type: TypeProject, Body: "new body"})
if err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(projectPath, s.Dir) {
t.Fatalf("project-scoped rewrite should land in project dir, got %s", projectPath)
}
if _, err := os.Stat(globalPath); !os.IsNotExist(err) {
t.Fatalf("old global active file should be archived away, stat err = %v", err)
}
globalIndex, err := os.ReadFile(filepath.Join(s.GlobalDir, indexFile))
if err != nil {
t.Fatalf("read global index: %v", err)
}
if strings.Contains(string(globalIndex), "same-name.md") {
t.Fatalf("old global index line should be removed:\n%s", globalIndex)
}
list := s.List()
if len(list) != 1 {
t.Fatalf("List() = %+v, want one active copy", list)
}
if list[0].Type != TypeProject || !strings.Contains(list[0].Body, "new body") {
t.Fatalf("active copy = %+v, want new project body", list[0])
}
idx := s.Index()
if strings.Contains(idx, "old global") || !strings.Contains(idx, "new project") {
t.Fatalf("merged index should reflect new scope only:\n%s", idx)
}
}
// TestStoreForInitializesGlobalDir ensures StoreFor sets GlobalDir alongside Dir.
func TestStoreForInitializesGlobalDir(t *testing.T) {
s := StoreFor("/home/me/.reasonix", "/Users/me/proj")
if s.GlobalDir == "" {
t.Fatal("StoreFor should set GlobalDir")
}
if !strings.Contains(s.GlobalDir, "memory") || !strings.Contains(s.GlobalDir, "global") {
t.Fatalf("unexpected GlobalDir: %s", s.GlobalDir)
}
if s.GlobalDir == s.Dir {
t.Fatal("GlobalDir and Dir should be different paths")
}
}
// TestDirForRoutesCorrectly verifies DirFor routes user/feedback to GlobalDir
// and everything else to Dir.
func TestDirForRoutesCorrectly(t *testing.T) {
dir := t.TempDir()
s := Store{
Dir: filepath.Join(dir, "project", "memory"),
GlobalDir: filepath.Join(dir, "global"),
}
if got := s.DirFor(TypeUser); got != s.GlobalDir {
t.Errorf("TypeUser: got %q, want %q", got, s.GlobalDir)
}
if got := s.DirFor(TypeFeedback); got != s.GlobalDir {
t.Errorf("TypeFeedback: got %q, want %q", got, s.GlobalDir)
}
if got := s.DirFor(TypeProject); got != s.Dir {
t.Errorf("TypeProject: got %q, want %q", got, s.Dir)
}
if got := s.DirFor(TypeReference); got != s.Dir {
t.Errorf("TypeReference: got %q, want %q", got, s.Dir)
}
}
// TestDirForFallsBackWhenNoGlobalDir ensures DirFor falls back to Dir when
// GlobalDir is empty.
func TestDirForFallsBackWhenNoGlobalDir(t *testing.T) {
dir := t.TempDir()
s := Store{Dir: filepath.Join(dir, "memory")}
if got := s.DirFor(TypeUser); got != s.Dir {
t.Errorf("TypeUser without GlobalDir should fall back to Dir, got %q", got)
}
}
// TestStoreDeleteRemovesFromAllDirs verifies that after a type-routing migration
// (same name in both GlobalDir and Dir), Delete removes both copies so the
// memory truly disappears.
func TestStoreDeleteRemovesFromAllDirs(t *testing.T) {
dir := t.TempDir()
s := Store{
Dir: filepath.Join(dir, "project", "memory"),
GlobalDir: filepath.Join(dir, "global"),
}
// Simulate migration: write a TypeUser memory directly into both dirs.
name := "prefers-tabs"
for _, d := range []string{s.Dir, s.GlobalDir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
m := Memory{Name: name, Description: "user pref", Type: TypeUser, Body: "use tabs"}
if err := os.WriteFile(filepath.Join(d, name+".md"), []byte(render(m, name)), 0o644); err != nil {
t.Fatal(err)
}
if err := reindexIn(d, name, m); err != nil {
t.Fatal(err)
}
}
// Both copies should appear, but deduplicated.
list := s.List()
if len(list) != 1 {
t.Fatalf("want 1 deduplicated memory, got %d", len(list))
}
// Delete should remove from BOTH directories.
if err := s.Delete(name); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(s.GlobalDir, name+".md")); !os.IsNotExist(err) {
t.Fatal("global copy should be gone after delete")
}
if _, err := os.Stat(filepath.Join(s.Dir, name+".md")); !os.IsNotExist(err) {
t.Fatal("project copy should be gone after delete")
}
list2 := s.List()
if len(list2) != 0 {
t.Fatalf("want 0 memories after delete, got %d", len(list2))
}
if idx := s.Index(); idx != "" {
t.Fatalf("Index() should be empty after deleting all entries, got:\n%s", idx)
}
}
// TestStoreIndexDeduplicatesAcrossDirs verifies Index() does not emit duplicate
// lines when the same memory name exists in both GlobalDir and Dir.
func TestStoreIndexDeduplicatesAcrossDirs(t *testing.T) {
dir := t.TempDir()
s := Store{
Dir: filepath.Join(dir, "project", "memory"),
GlobalDir: filepath.Join(dir, "global"),
}
// Write the same memory into both dirs (migration scenario).
name := "prefers-tabs"
for _, d := range []string{s.GlobalDir, s.Dir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
m := Memory{Name: name, Description: "user pref", Type: TypeUser, Body: "use tabs"}
if err := os.WriteFile(filepath.Join(d, name+".md"), []byte(render(m, name)), 0o644); err != nil {
t.Fatal(err)
}
if err := reindexIn(d, name, m); err != nil {
t.Fatal(err)
}
}
idx := s.Index()
count := strings.Count(idx, name+".md")
if count != 1 {
t.Fatalf("want exactly 1 index line for %s, got %d:\n%s", name, count, idx)
}
if strings.Count(idx, "# Memory") != 0 {
t.Fatalf("merged index should have 0 # Memory headers (Block() adds one), got %d:\n%s", strings.Count(idx, "# Memory"), idx)
}
}
// TestStoreSaveVerifiesIndexDir verifies that Save writes the MEMORY.md
// index to the correct directory for the memory type.
func TestStoreSaveVerifiesIndexDir(t *testing.T) {
dir := t.TempDir()
s := Store{
Dir: filepath.Join(dir, "project", "memory"),
GlobalDir: filepath.Join(dir, "global"),
}
// TypeUser → GlobalDir
if _, err := s.Save(Memory{Name: "user-pref", Description: "d", Type: TypeUser, Body: "b"}); err != nil {
t.Fatal(err)
}
gb, _ := os.ReadFile(filepath.Join(s.GlobalDir, indexFile))
pb, _ := os.ReadFile(filepath.Join(s.Dir, indexFile))
if !strings.Contains(string(gb), "user-pref") {
t.Fatal("GlobalDir MEMORY.md should contain user-pref")
}
if strings.Contains(string(pb), "user-pref") {
t.Fatal("Dir MEMORY.md should NOT contain user-pref (it went to GlobalDir)")
}
// TypeProject → Dir
if _, err := s.Save(Memory{Name: "build-cmd", Description: "d", Type: TypeProject, Body: "b"}); err != nil {
t.Fatal(err)
}
pb2, _ := os.ReadFile(filepath.Join(s.Dir, indexFile))
if !strings.Contains(string(pb2), "build-cmd") {
t.Fatal("Dir MEMORY.md should contain build-cmd")
}
gb2, _ := os.ReadFile(filepath.Join(s.GlobalDir, indexFile))
if strings.Contains(string(gb2), "build-cmd") {
t.Fatal("GlobalDir MEMORY.md should NOT contain build-cmd (it went to Dir)")
}
}
// TestStoreDeleteFlushesIndexPerDir verifies that Delete calls flushIndexIn
// for each directory where the memory file existed.
func TestStoreDeleteFlushesIndexPerDir(t *testing.T) {
dir := t.TempDir()
s := Store{
Dir: filepath.Join(dir, "project", "memory"),
GlobalDir: filepath.Join(dir, "global"),
}
// Write to both dirs manually (migration scenario).
name := "prefers-tabs"
for _, d := range []string{s.GlobalDir, s.Dir} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
m := Memory{Name: name, Description: "d", Type: TypeUser, Body: "b"}
if err := os.WriteFile(filepath.Join(d, name+".md"), []byte(render(m, name)), 0o644); err != nil {
t.Fatal(err)
}
if err := reindexIn(d, name, m); err != nil {
t.Fatal(err)
}
}
if err := s.Delete(name); err != nil {
t.Fatal(err)
}
// Verify both MEMORY.md files have the entry removed.
gb, _ := os.ReadFile(filepath.Join(s.GlobalDir, indexFile))
pb, _ := os.ReadFile(filepath.Join(s.Dir, indexFile))
if strings.Contains(string(gb), name+".md") {
t.Fatalf("GlobalDir MEMORY.md should not reference %s after delete:\n%s", name, gb)
}
if strings.Contains(string(pb), name+".md") {
t.Fatalf("Dir MEMORY.md should not reference %s after delete:\n%s", name, pb)
}
// Index() should return "" (no entries, no orphaned header).
idx := s.Index()
if idx != "" {
t.Fatalf("Index() should return empty after deleting all entries, got:\n%s", idx)
}
}
// TestStorePathWithGlobalDir verifies Path() checks GlobalDir first and
// falls back to Dir for new files.
func TestStorePathWithGlobalDir(t *testing.T) {
dir := t.TempDir()
s := Store{
Dir: filepath.Join(dir, "project", "memory"),
GlobalDir: filepath.Join(dir, "global"),
}
// No files yet → defaults to Dir.
p := s.Path("new-fact")
if !strings.HasPrefix(p, s.Dir) {
t.Fatalf("Path for new file should default to Dir, got %s", p)
}
// Write a file to GlobalDir.
if err := os.MkdirAll(s.GlobalDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(s.GlobalDir, "existing.md"), []byte("body"), 0o644); err != nil {
t.Fatal(err)
}
p2 := s.Path("existing")
if !strings.HasPrefix(p2, s.GlobalDir) {
t.Fatalf("Path for file in GlobalDir should return GlobalDir path, got %s", p2)
}
// Write a file to Dir (not GlobalDir).
if err := os.MkdirAll(s.Dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(s.Dir, "proj-fact.md"), []byte("body"), 0o644); err != nil {
t.Fatal(err)
}
p3 := s.Path("proj-fact")
if !strings.HasPrefix(p3, s.Dir) {
t.Fatalf("Path for file only in Dir should return Dir path, got %s", p3)
}
}