856 lines
28 KiB
Go
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)
|
|
}
|
|
}
|