返回 DeepSeek-Reasonix
store_test.go
根目录 / internal / memory / store_test.go
1 package memory
2
3 import (
4 "os"
5 "path/filepath"
6 "runtime"
7 "strings"
8 "testing"
9
10 fileencoding "reasonix/internal/fileutil/encoding"
11 )
12
13 // TestRenderEscapesYAMLMetacharacters pins the frontmatter-corruption fix: a
14 // title or description with YAML metacharacters must survive a save→load
15 // round-trip. The previous hand-concatenated renderer produced unparseable
16 // YAML for "Plan: step one"-style titles; frontmatter.Split then returned an
17 // empty map and the reloaded memory silently lost its name, title, and type.
18 func TestRenderEscapesYAMLMetacharacters(t *testing.T) {
19 cases := []struct {
20 label, title, desc string
21 }{
22 {"colon", "My plan: step one", "Covers: the rollout"},
23 {"hash", "Ship #42", "Tracks #release notes"},
24 {"double-quote", `The "golden" path`, `Says "hello"`},
25 {"single-quote", "Don't drop this", "User's preference"},
26 {"leading-special", "- looks like a list", "* also a list"},
27 {"yaml-lookalike", "type: reference", "metadata: nested"},
28 }
29 for _, tc := range cases {
30 t.Run(tc.label, func(t *testing.T) {
31 dir := t.TempDir()
32 s := Store{Dir: filepath.Join(dir, "memory")}
33 if _, err := s.Save(Memory{
34 Name: "esc-" + tc.label,
35 Title: tc.title,
36 Description: tc.desc,
37 Type: TypeProject,
38 Body: "the body",
39 }); err != nil {
40 t.Fatal(err)
41 }
42 list := s.List()
43 if len(list) != 1 {
44 t.Fatalf("want 1 memory, got %d", len(list))
45 }
46 got := list[0]
47 if got.Title != tc.title {
48 t.Errorf("Title = %q, want %q", got.Title, tc.title)
49 }
50 if got.Description != tc.desc {
51 t.Errorf("Description = %q, want %q", got.Description, tc.desc)
52 }
53 if got.Type != TypeProject {
54 t.Errorf("Type = %q, want project (metadata lost)", got.Type)
55 }
56 if got.Body != "the body" {
57 t.Errorf("Body = %q", got.Body)
58 }
59 })
60 }
61 }
62
63 // TestRenderPlainValuesUsesPreviousReleaseRoutingType keeps project scope safe
64 // when an older binary reads a project-scoped user preference.
65 func TestRenderPlainValuesUsesPreviousReleaseRoutingType(t *testing.T) {
66 got := render(Memory{
67 Title: "Prefers tabs",
68 Description: "User prefers tabs over spaces",
69 Type: TypeUser,
70 Body: "Always indent with tabs.",
71 }, "prefers-tabs")
72 want := "---\n" +
73 "name: prefers-tabs\n" +
74 "title: Prefers tabs\n" +
75 "description: User prefers tabs over spaces\n" +
76 "metadata:\n" +
77 " type: project\n" +
78 " fact_type: user\n" +
79 " scope: project\n" +
80 "---\n\n" +
81 "Always indent with tabs.\n"
82 if got != want {
83 t.Fatalf("plain-value render changed bytes:\ngot:\n%s\nwant:\n%s", got, want)
84 }
85 }
86
87 func TestRenderKeepsPreviousReleaseRoutingScopeSafe(t *testing.T) {
88 for _, scope := range []FactScope{FactScopeProject, FactScopeGlobal} {
89 for _, typ := range []Type{TypeUser, TypeFeedback, TypeProject, TypeReference} {
90 label := string(scope) + "-" + string(typ)
91 t.Run(label, func(t *testing.T) {
92 fm, _ := splitFrontmatter(render(Memory{Description: "d", Type: typ, Scope: scope, Body: "b"}, label))
93 if got := persistedFactType(fm); got != typ {
94 t.Fatalf("new reader type = %q, want %q; frontmatter=%v", got, typ, fm)
95 }
96 legacyType := NormalizeType(fm["type"])
97 legacyRoutesGlobal := legacyType == TypeUser || legacyType == TypeFeedback
98 if wantGlobal := scope == FactScopeGlobal; legacyRoutesGlobal != wantGlobal {
99 t.Fatalf("previous release type %q routes global=%v, want %v; frontmatter=%v", legacyType, legacyRoutesGlobal, wantGlobal, fm)
100 }
101 if got := factScopeFromFrontmatter(fm["scope"]); got != scope {
102 t.Fatalf("new reader scope = %q, want %q", got, scope)
103 }
104 })
105 }
106 }
107 }
108
109 // TestStoreSaveAndIndex covers the round-trip: Save writes a frontmatter file,
110 // reindex adds exactly one index line, and List parses it back.
111 func TestStoreSaveAndIndex(t *testing.T) {
112 dir := t.TempDir()
113 s := Store{Dir: filepath.Join(dir, "memory")}
114
115 path, err := s.Save(Memory{
116 Name: "Prefers Tabs",
117 Description: "User prefers tabs over spaces",
118 Type: TypeUser,
119 Body: "Always indent with tabs in this project.",
120 })
121 if err != nil {
122 t.Fatal(err)
123 }
124 if filepath.Base(path) != "prefers-tabs.md" {
125 t.Fatalf("name not slugified into filename: %s", path)
126 }
127
128 idx := s.Index()
129 if !strings.Contains(idx, "prefers-tabs.md") || !strings.Contains(idx, "User prefers tabs") {
130 t.Fatalf("index missing entry:\n%s", idx)
131 }
132
133 list := s.List()
134 if len(list) != 1 {
135 t.Fatalf("want 1 memory, got %d", len(list))
136 }
137 m := list[0]
138 if m.Name != "prefers-tabs" || m.Type != TypeUser {
139 t.Fatalf("round-trip mismatch: %+v", m)
140 }
141 if !strings.Contains(m.Body, "indent with tabs") {
142 t.Fatalf("body not preserved: %q", m.Body)
143 }
144 }
145
146 func TestStoreListDecodesGB18030MemoryFile(t *testing.T) {
147 s := Store{Dir: t.TempDir()}
148 if err := os.MkdirAll(s.Dir, 0o755); err != nil {
149 t.Fatal(err)
150 }
151 body := `---
152 title: 中文偏好
153 description: 使用中文回答
154 type: user
155 ---
156 用户希望默认使用中文。`
157 if err := os.WriteFile(filepath.Join(s.Dir, "cn-pref.md"), fileencoding.Encode(body, fileencoding.GB18030), 0o644); err != nil {
158 t.Fatal(err)
159 }
160
161 memories := s.List()
162 if len(memories) != 1 {
163 t.Fatalf("List() = %+v, want one decoded memory", memories)
164 }
165 m := memories[0]
166 if m.Title != "中文偏好" || m.Description != "使用中文回答" || !strings.Contains(m.Body, "默认使用中文") {
167 t.Fatalf("decoded memory = %+v", m)
168 }
169 }
170
171 // TestStoreOverwriteDoesNotDuplicateIndex verifies re-saving the same name
172 // replaces its index line rather than appending a second.
173 func TestStoreOverwriteDoesNotDuplicateIndex(t *testing.T) {
174 s := Store{Dir: t.TempDir()}
175 for _, desc := range []string{"first version", "second version"} {
176 if _, err := s.Save(Memory{Name: "note", Description: desc, Type: TypeProject, Body: "b"}); err != nil {
177 t.Fatal(err)
178 }
179 }
180 idx := s.Index()
181 if n := strings.Count(idx, "note.md"); n != 1 {
182 t.Fatalf("want exactly 1 index line for note, got %d:\n%s", n, idx)
183 }
184 if !strings.Contains(idx, "second version") || strings.Contains(idx, "first version") {
185 t.Fatalf("index not updated to latest description:\n%s", idx)
186 }
187 }
188
189 // TestStoreIndexPreservesHandEdits verifies reindex keeps unrelated lines, so a
190 // user hand-editing MEMORY.md isn't clobbered when the model saves a new fact.
191 func TestStoreIndexPreservesHandEdits(t *testing.T) {
192 s := Store{Dir: t.TempDir()}
193 if _, err := s.Save(Memory{Name: "alpha", Description: "first", Type: TypeProject, Body: "x"}); err != nil {
194 t.Fatal(err)
195 }
196 indexPath := filepath.Join(s.Dir, indexFile)
197 handEdited := "# Memory\n\nUser note before managed lines.\n\n" + mustReadString(t, indexPath) + "\nSee [design](design.md) for context.\n"
198 if err := os.WriteFile(indexPath, []byte(handEdited), 0o644); err != nil {
199 t.Fatal(err)
200 }
201 if _, err := s.Save(Memory{Name: "beta", Description: "second", Type: TypeProject, Body: "y"}); err != nil {
202 t.Fatal(err)
203 }
204 raw := mustReadString(t, indexPath)
205 for _, want := range []string{"User note before managed lines.", "See [design](design.md) for context.", "alpha.md", "beta.md"} {
206 if !strings.Contains(raw, want) {
207 t.Fatalf("MEMORY.md lost %q:\n%s", want, raw)
208 }
209 }
210 if strings.Count(raw, "alpha.md") != 1 || strings.Count(raw, "beta.md") != 1 {
211 t.Fatalf("managed lines were duplicated:\n%s", raw)
212 }
213 if err := s.Delete("alpha"); err != nil {
214 t.Fatal(err)
215 }
216 raw = mustReadString(t, indexPath)
217 if strings.Contains(raw, "alpha.md") {
218 t.Fatalf("deleted managed line remained:\n%s", raw)
219 }
220 if !strings.Contains(raw, "See [design](design.md) for context.") {
221 t.Fatalf("ordinary markdown link was treated as managed:\n%s", raw)
222 }
223 }
224
225 // TestStoreSaveTitleInIndexAndFrontmatter verifies an explicit title becomes the
226 // index link label and round-trips through the file's frontmatter.
227 func TestStoreSaveTitleInIndexAndFrontmatter(t *testing.T) {
228 s := Store{Dir: t.TempDir()}
229 if _, err := s.Save(Memory{
230 Name: "tabs-rule",
231 Title: "Prefers tabs",
232 Description: "indent with tabs",
233 Type: TypeUser,
234 Body: "b",
235 }); err != nil {
236 t.Fatal(err)
237 }
238 if idx := s.Index(); !strings.Contains(idx, "[Prefers tabs](tabs-rule.md)") {
239 t.Fatalf("index link should use the title label:\n%s", idx)
240 }
241 if got := s.List()[0].Title; got != "Prefers tabs" {
242 t.Fatalf("title not round-tripped: %q", got)
243 }
244 }
245
246 // TestStoreIndexLabelFallsBackToDeKebabbedName checks a title-less memory still
247 // gets a readable label instead of a bare slug.
248 func TestStoreIndexLabelFallsBackToDeKebabbedName(t *testing.T) {
249 s := Store{Dir: t.TempDir()}
250 if _, err := s.Save(Memory{Name: "likes-go", Description: "d", Type: TypeUser, Body: "b"}); err != nil {
251 t.Fatal(err)
252 }
253 if idx := s.Index(); !strings.Contains(idx, "[likes go](likes-go.md)") {
254 t.Fatalf("missing-title label should de-kebab the name:\n%s", idx)
255 }
256 }
257
258 // TestStoreDelete archives a fact's file and removes its index line while
259 // leaving others.
260 func TestStoreDelete(t *testing.T) {
261 s := Store{Dir: t.TempDir()}
262 for _, n := range []string{"alpha", "beta"} {
263 if _, err := s.Save(Memory{Name: n, Description: "d", Type: TypeProject, Body: "b"}); err != nil {
264 t.Fatal(err)
265 }
266 }
267 if err := s.Delete("alpha"); err != nil {
268 t.Fatal(err)
269 }
270 if _, err := os.Stat(filepath.Join(s.Dir, "alpha.md")); !os.IsNotExist(err) {
271 t.Fatalf("alpha.md should be gone, stat err = %v", err)
272 }
273 archived := archivedFiles(t, s.Dir)
274 if len(archived) != 1 || !strings.HasSuffix(archived[0], "-alpha.md") {
275 t.Fatalf("archive files = %v, want one alpha archive", archived)
276 }
277 idx := s.Index()
278 if strings.Contains(idx, "alpha.md") {
279 t.Fatalf("deleted entry still in index:\n%s", idx)
280 }
281 if !strings.Contains(idx, "beta.md") {
282 t.Fatalf("unrelated entry lost on delete:\n%s", idx)
283 }
284 if names := s.List(); len(names) != 1 || names[0].Name != "beta" {
285 t.Fatalf("List after delete = %+v, want only beta", names)
286 }
287 }
288
289 // TestStoreDeleteMissingIsNoError treats deleting an absent memory as success —
290 // the goal state (gone) already holds.
291 func TestStoreDeleteMissingIsNoError(t *testing.T) {
292 s := Store{Dir: t.TempDir()}
293 if err := s.Delete("never-saved"); err != nil {
294 t.Fatalf("deleting a missing memory should not error: %v", err)
295 }
296 }
297
298 func TestSafeJoinRejectsStoreEscape(t *testing.T) {
299 dir := t.TempDir()
300 if _, err := safeJoin(dir, filepath.Join("..", "outside.md")); err == nil {
301 t.Fatal("safeJoin should reject paths outside the store")
302 }
303 if _, err := safeJoin(dir, filepath.Join(t.TempDir(), "outside.md")); err == nil {
304 t.Fatal("safeJoin should reject absolute paths outside the store")
305 }
306 }
307
308 func TestStoreArchiveSanitizesNameBeforePathUse(t *testing.T) {
309 root := t.TempDir()
310 s := Store{Dir: filepath.Join(root, "memory")}
311 if err := os.MkdirAll(s.Dir, 0o755); err != nil {
312 t.Fatal(err)
313 }
314 outside := filepath.Join(root, "outside.md")
315 if err := os.WriteFile(outside, []byte("do not move"), 0o644); err != nil {
316 t.Fatal(err)
317 }
318 if _, err := s.Archive("../outside"); err != nil {
319 t.Fatalf("Archive with path-like name should be treated as a slug, not a path: %v", err)
320 }
321 if _, err := os.Stat(outside); err != nil {
322 t.Fatalf("outside file should remain untouched: %v", err)
323 }
324 }
325
326 func TestStoreDeleteRepairsReadOnlyMemoryFile(t *testing.T) {
327 s := Store{Dir: t.TempDir()}
328 if _, err := s.Save(Memory{Name: "locked", Description: "d", Type: TypeProject, Body: "b"}); err != nil {
329 t.Fatal(err)
330 }
331 path := filepath.Join(s.Dir, "locked.md")
332 if err := os.Chmod(path, 0o400); err != nil {
333 t.Fatal(err)
334 }
335 if err := s.Delete("locked"); err != nil {
336 t.Fatalf("delete read-only memory: %v", err)
337 }
338 if _, err := os.Stat(path); !os.IsNotExist(err) {
339 t.Fatalf("locked.md should be gone, stat err = %v", err)
340 }
341 archived := archivedFiles(t, s.Dir)
342 if len(archived) != 1 || !strings.HasSuffix(archived[0], "-locked.md") {
343 t.Fatalf("archive files = %v, want one locked archive", archived)
344 }
345 if strings.Contains(s.Index(), "locked.md") {
346 t.Fatalf("deleted read-only entry still in index:\n%s", s.Index())
347 }
348 }
349
350 func TestStoreArchiveReturnsArchivePath(t *testing.T) {
351 s := Store{Dir: t.TempDir()}
352 if _, err := s.Save(Memory{Name: "old-fact", Description: "d", Type: TypeProject, Body: "body"}); err != nil {
353 t.Fatal(err)
354 }
355 archive, err := s.Archive("old-fact")
356 if err != nil {
357 t.Fatalf("Archive: %v", err)
358 }
359 if archive == "" {
360 t.Fatal("Archive returned empty path for existing memory")
361 }
362 body, err := os.ReadFile(archive)
363 if err != nil {
364 t.Fatalf("read archive: %v", err)
365 }
366 if !strings.Contains(string(body), "body") {
367 t.Fatalf("archive missing memory body:\n%s", body)
368 }
369 if strings.Contains(s.Index(), "old-fact.md") {
370 t.Fatalf("archived memory still in index:\n%s", s.Index())
371 }
372 archived := s.ListArchived()
373 if len(archived) != 1 {
374 t.Fatalf("ListArchived = %+v, want one entry", archived)
375 }
376 if archived[0].Name != "old-fact" || archived[0].Path != archive {
377 t.Fatalf("archived entry mismatch: %+v, path %q", archived[0], archive)
378 }
379 if archived[0].ArchivedAt.IsZero() {
380 t.Fatalf("archived entry missing timestamp: %+v", archived[0])
381 }
382 if len(s.List()) != 0 {
383 t.Fatalf("active List should exclude archived memories: %+v", s.List())
384 }
385 }
386
387 func TestStoreArchiveFlushesStaleIndexWithoutFile(t *testing.T) {
388 s := Store{Dir: t.TempDir()}
389 if _, err := s.Save(Memory{Name: "beta", Description: "keep", Type: TypeProject, Body: "body"}); err != nil {
390 t.Fatal(err)
391 }
392 if err := flushIndexIn(s.Dir, map[string]string{
393 "alpha": "- [alpha](alpha.md) — stale",
394 "beta": "- [beta](beta.md) — keep",
395 }); err != nil {
396 t.Fatal(err)
397 }
398
399 archive, err := s.Archive("alpha")
400 if err != nil {
401 t.Fatalf("Archive stale index: %v", err)
402 }
403 if archive != "" {
404 t.Fatalf("Archive should return no path for missing file, got %q", archive)
405 }
406 idx := s.Index()
407 if strings.Contains(idx, "alpha.md") {
408 t.Fatalf("stale index line should be removed:\n%s", idx)
409 }
410 if !strings.Contains(idx, "beta.md") {
411 t.Fatalf("unrelated index line should remain:\n%s", idx)
412 }
413 }
414
415 func mustReadString(t *testing.T, path string) string {
416 t.Helper()
417 b, err := os.ReadFile(path)
418 if err != nil {
419 t.Fatal(err)
420 }
421 return string(b)
422 }
423
424 func TestStoreListArchivedNewestFirst(t *testing.T) {
425 s := Store{Dir: t.TempDir()}
426 dir := filepath.Join(s.Dir, ".archive")
427 if err := os.MkdirAll(dir, 0o755); err != nil {
428 t.Fatal(err)
429 }
430 files := []struct {
431 name string
432 body string
433 }{
434 {"20260101-010000.000-old.md", render(Memory{Name: "old", Description: "old d", Type: TypeProject, Body: "old body"}, "old")},
435 {"20260102-010000.000-new.md", render(Memory{Name: "new", Description: "new d", Type: TypeFeedback, Body: "new body"}, "new")},
436 }
437 for _, f := range files {
438 if err := os.WriteFile(filepath.Join(dir, f.name), []byte(f.body), 0o644); err != nil {
439 t.Fatal(err)
440 }
441 }
442 archived := s.ListArchived()
443 if len(archived) != 2 {
444 t.Fatalf("ListArchived len = %d, want 2: %+v", len(archived), archived)
445 }
446 if archived[0].Name != "new" || archived[1].Name != "old" {
447 t.Fatalf("ListArchived order = %+v, want newest first", archived)
448 }
449 if archived[0].Type != TypeFeedback || !strings.Contains(archived[1].Body, "old body") {
450 t.Fatalf("archived memory did not round-trip metadata/body: %+v", archived)
451 }
452 }
453
454 func archivedFiles(t *testing.T, dir string) []string {
455 t.Helper()
456 entries, err := os.ReadDir(filepath.Join(dir, ".archive"))
457 if err != nil {
458 t.Fatalf("read archive dir: %v", err)
459 }
460 var out []string
461 for _, entry := range entries {
462 out = append(out, entry.Name())
463 }
464 return out
465 }
466
467 // TestNormalizeType maps unknown types to project and keeps known ones.
468 func TestNormalizeType(t *testing.T) {
469 if got := NormalizeType("feedback"); got != TypeFeedback {
470 t.Errorf("feedback: got %q", got)
471 }
472 if got := NormalizeType("garbage"); got != TypeProject {
473 t.Errorf("unknown should default to project, got %q", got)
474 }
475 }
476
477 // TestStoreForSlug ensures the project path becomes one filesystem-safe segment.
478 func TestStoreForSlug(t *testing.T) {
479 s := StoreFor("/home/me/.reasonix", "/Users/me/proj")
480 if strings.Count(filepath.Base(filepath.Dir(s.Dir)), "/") != 0 {
481 t.Fatalf("slug should have no separators: %s", s.Dir)
482 }
483 // config.WorkspaceSlug folds case on Windows (equivalent spellings of one
484 // folder must share a slug); unix slugs keep the original case.
485 want := "-Users-me-proj"
486 if runtime.GOOS == "windows" {
487 want = "-users-me-proj"
488 }
489 if !strings.Contains(s.Dir, want) {
490 t.Fatalf("unexpected slug: %s", s.Dir)
491 }
492 }
493
494 // TestDisabledStoreIsNoOp ensures a zero Store (no user config dir) never panics
495 // and errors cleanly on Save.
496 func TestDisabledStoreIsNoOp(t *testing.T) {
497 var s Store
498 if s.Index() != "" || s.List() != nil {
499 t.Fatal("disabled store should read empty")
500 }
501 if _, err := s.Save(Memory{Name: "x", Description: "d", Body: "b"}); err == nil {
502 t.Fatal("disabled store Save should error, not silently drop")
503 }
504 }
505
506 // TestStoreGlobalAndProject verifies explicit scope routing independently of
507 // memory type, merged reads, and deletion from the correct directory.
508 func TestStoreGlobalAndProject(t *testing.T) {
509 dir := t.TempDir()
510 s := Store{
511 Dir: filepath.Join(dir, "project", "memory"),
512 GlobalDir: filepath.Join(dir, "global"),
513 }
514
515 // A user preference can be explicitly global.
516 pUser, err := s.Save(Memory{Name: "prefers-tabs", Description: "user pref", Type: TypeUser, Scope: FactScopeGlobal, Body: "use tabs"})
517 if err != nil {
518 t.Fatal(err)
519 }
520 if !strings.HasPrefix(pUser, s.GlobalDir) {
521 t.Fatalf("global-scoped user memory should go to GlobalDir, got %s", pUser)
522 }
523
524 // Project scope is the default, independently of type.
525 pProj, err := s.Save(Memory{Name: "build-target", Description: "build target", Type: TypeProject, Body: "go build"})
526 if err != nil {
527 t.Fatal(err)
528 }
529 if !strings.HasPrefix(pProj, s.Dir) {
530 t.Fatalf("project-scoped memory should go to Dir, got %s", pProj)
531 }
532
533 // Feedback can also be explicitly global.
534 pFb, err := s.Save(Memory{Name: "no-emoji", Description: "no emoji", Type: TypeFeedback, Scope: FactScopeGlobal, Body: "skip emoji"})
535 if err != nil {
536 t.Fatal(err)
537 }
538 if !strings.HasPrefix(pFb, s.GlobalDir) {
539 t.Fatalf("global-scoped feedback should go to GlobalDir, got %s", pFb)
540 }
541
542 // A reference also defaults to project scope.
543 pRef, err := s.Save(Memory{Name: "api-docs", Description: "api docs", Type: TypeReference, Body: "see docs"})
544 if err != nil {
545 t.Fatal(err)
546 }
547 if !strings.HasPrefix(pRef, s.Dir) {
548 t.Fatalf("project-scoped reference should go to Dir, got %s", pRef)
549 }
550
551 // List merges both directories
552 list := s.List()
553 if len(list) != 4 {
554 t.Fatalf("want 4 memories, got %d", len(list))
555 }
556
557 // Index merges both directories
558 idx := s.Index()
559 if !strings.Contains(idx, "prefers-tabs") || !strings.Contains(idx, "build-target") {
560 t.Fatalf("index should contain both global and project memories:\n%s", idx)
561 }
562
563 // Delete removes from the correct directory
564 if err := s.Delete("prefers-tabs"); err != nil {
565 t.Fatal(err)
566 }
567 if _, err := os.Stat(pUser); !os.IsNotExist(err) {
568 t.Fatal("global memory file should be gone after delete")
569 }
570
571 // List after delete
572 list2 := s.List()
573 if len(list2) != 3 {
574 t.Fatalf("want 3 memories after delete, got %d", len(list2))
575 }
576
577 // Index should not duplicate # Memory headers (Block() adds its own).
578 idx2 := s.Index()
579 if strings.Count(idx2, "# Memory") != 0 {
580 t.Fatalf("Index should have 0 # Memory headers (Block() adds one), got %d:\n%s", strings.Count(idx2, "# Memory"), idx2)
581 }
582 }
583
584 func TestStoreSaveRemovesStaleCopyWhenScopeChanges(t *testing.T) {
585 dir := t.TempDir()
586 s := Store{
587 Dir: filepath.Join(dir, "project", "memory"),
588 GlobalDir: filepath.Join(dir, "global"),
589 }
590
591 globalPath, err := s.Save(Memory{Name: "same-name", Description: "old global", Type: TypeFeedback, Scope: FactScopeGlobal, Body: "old body"})
592 if err != nil {
593 t.Fatal(err)
594 }
595 projectPath, err := s.Save(Memory{Name: "same-name", Description: "new project", Type: TypeFeedback, Scope: FactScopeProject, Body: "new body"})
596 if err != nil {
597 t.Fatal(err)
598 }
599 if !strings.HasPrefix(projectPath, s.Dir) {
600 t.Fatalf("project-scoped rewrite should land in project dir, got %s", projectPath)
601 }
602 if _, err := os.Stat(globalPath); !os.IsNotExist(err) {
603 t.Fatalf("old global active file should be archived away, stat err = %v", err)
604 }
605 globalIndex, err := os.ReadFile(filepath.Join(s.GlobalDir, indexFile))
606 if err != nil {
607 t.Fatalf("read global index: %v", err)
608 }
609 if strings.Contains(string(globalIndex), "same-name.md") {
610 t.Fatalf("old global index line should be removed:\n%s", globalIndex)
611 }
612
613 list := s.List()
614 if len(list) != 1 {
615 t.Fatalf("List() = %+v, want one active copy", list)
616 }
617 if list[0].Type != TypeFeedback || list[0].Scope != FactScopeProject || !strings.Contains(list[0].Body, "new body") {
618 t.Fatalf("active copy = %+v, want new project body", list[0])
619 }
620 idx := s.Index()
621 if strings.Contains(idx, "old global") || !strings.Contains(idx, "new project") {
622 t.Fatalf("merged index should reflect new scope only:\n%s", idx)
623 }
624 }
625
626 // TestStoreForInitializesGlobalDir ensures StoreFor sets GlobalDir alongside Dir.
627 func TestStoreForInitializesGlobalDir(t *testing.T) {
628 s := StoreFor("/home/me/.reasonix", "/Users/me/proj")
629 if s.GlobalDir == "" {
630 t.Fatal("StoreFor should set GlobalDir")
631 }
632 if !strings.Contains(s.GlobalDir, "memory") || !strings.Contains(s.GlobalDir, "global") {
633 t.Fatalf("unexpected GlobalDir: %s", s.GlobalDir)
634 }
635 if s.GlobalDir == s.Dir {
636 t.Fatal("GlobalDir and Dir should be different paths")
637 }
638 }
639
640 // TestDirForRoutesCorrectly verifies scope, rather than type, owns routing.
641 func TestDirForRoutesCorrectly(t *testing.T) {
642 dir := t.TempDir()
643 s := Store{
644 Dir: filepath.Join(dir, "project", "memory"),
645 GlobalDir: filepath.Join(dir, "global"),
646 }
647 if got := s.DirFor(FactScopeGlobal); got != s.GlobalDir {
648 t.Errorf("global: got %q, want %q", got, s.GlobalDir)
649 }
650 if got := s.DirFor(FactScopeProject); got != s.Dir {
651 t.Errorf("project: got %q, want %q", got, s.Dir)
652 }
653 if got := s.DirFor(""); got != s.Dir {
654 t.Errorf("default: got %q, want %q", got, s.Dir)
655 }
656 }
657
658 // TestDirForFallsBackWhenNoGlobalDir ensures DirFor falls back to Dir when
659 // GlobalDir is empty.
660 func TestDirForFallsBackWhenNoGlobalDir(t *testing.T) {
661 dir := t.TempDir()
662 s := Store{Dir: filepath.Join(dir, "memory")}
663 if got := s.DirFor(FactScopeGlobal); got != s.Dir {
664 t.Errorf("global scope without GlobalDir should fall back to Dir, got %q", got)
665 }
666 }
667
668 func TestStoreDefaultsNewMemoriesToProjectScope(t *testing.T) {
669 dir := t.TempDir()
670 s := Store{Dir: filepath.Join(dir, "project"), GlobalDir: filepath.Join(dir, "global")}
671 path, err := s.Save(Memory{Name: "project-feedback", Description: "project-only feedback", Type: TypeFeedback, Body: "keep this local"})
672 if err != nil {
673 t.Fatal(err)
674 }
675 if !strings.HasPrefix(path, s.Dir) {
676 t.Fatalf("default save path = %q, want project dir %q", path, s.Dir)
677 }
678 list := s.List()
679 if len(list) != 1 || list[0].Scope != FactScopeProject {
680 t.Fatalf("default memory = %+v, want project scope", list)
681 }
682 }
683
684 func TestStoreSaveWithoutScopePreservesExistingGlobalScope(t *testing.T) {
685 dir := t.TempDir()
686 s := Store{Dir: filepath.Join(dir, "project"), GlobalDir: filepath.Join(dir, "global")}
687 globalPath, err := s.Save(Memory{Name: "same-name", Description: "old", Type: TypeUser, Scope: FactScopeGlobal, Body: "old body"})
688 if err != nil {
689 t.Fatal(err)
690 }
691 written, err := s.Save(Memory{Name: "same-name", Description: "new", Type: TypeUser, Body: "new body"})
692 if err != nil {
693 t.Fatal(err)
694 }
695 if written != globalPath {
696 t.Fatalf("omitted-scope update path = %q, want existing global path %q", written, globalPath)
697 }
698 if _, err := os.Stat(filepath.Join(s.Dir, "same-name.md")); !os.IsNotExist(err) {
699 t.Fatalf("unexpected project copy after inherited update, stat err=%v", err)
700 }
701 }
702
703 func TestStoreInfersLegacyScopeFromContainingDirectory(t *testing.T) {
704 dir := t.TempDir()
705 s := Store{Dir: filepath.Join(dir, "project"), GlobalDir: filepath.Join(dir, "global")}
706 for _, tc := range []struct {
707 dir string
708 name string
709 scope FactScope
710 }{
711 {s.Dir, "legacy-project", FactScopeProject},
712 {s.GlobalDir, "legacy-global", FactScopeGlobal},
713 } {
714 if err := os.MkdirAll(tc.dir, 0o755); err != nil {
715 t.Fatal(err)
716 }
717 legacy := "---\nname: " + tc.name + "\ndescription: legacy\nmetadata:\n type: feedback\n---\n\nbody\n"
718 if err := os.WriteFile(filepath.Join(tc.dir, tc.name+".md"), []byte(legacy), 0o644); err != nil {
719 t.Fatal(err)
720 }
721 if err := reindexIn(tc.dir, tc.name, Memory{Name: tc.name, Description: "legacy", Type: TypeFeedback, Scope: tc.scope}); err != nil {
722 t.Fatal(err)
723 }
724 }
725 got := map[string]FactScope{}
726 for _, m := range s.List() {
727 got[m.Name] = m.Scope
728 }
729 if got["legacy-project"] != FactScopeProject || got["legacy-global"] != FactScopeGlobal {
730 t.Fatalf("legacy scopes = %+v", got)
731 }
732 }
733
734 // TestStoreDeleteRemovesFromAllDirs verifies that after a scope migration (same
735 // name in both GlobalDir and Dir), Delete removes both copies so the memory
736 // truly disappears.
737 func TestStoreDeleteRemovesFromAllDirs(t *testing.T) {
738 dir := t.TempDir()
739 s := Store{
740 Dir: filepath.Join(dir, "project", "memory"),
741 GlobalDir: filepath.Join(dir, "global"),
742 }
743
744 // Simulate migration: write the same memory directly into both dirs.
745 name := "prefers-tabs"
746 for _, d := range []string{s.Dir, s.GlobalDir} {
747 if err := os.MkdirAll(d, 0o755); err != nil {
748 t.Fatal(err)
749 }
750 m := Memory{Name: name, Description: "user pref", Type: TypeUser, Body: "use tabs"}
751 if err := os.WriteFile(filepath.Join(d, name+".md"), []byte(render(m, name)), 0o644); err != nil {
752 t.Fatal(err)
753 }
754 if err := reindexIn(d, name, m); err != nil {
755 t.Fatal(err)
756 }
757 }
758
759 // Both copies should appear, but deduplicated.
760 list := s.List()
761 if len(list) != 1 {
762 t.Fatalf("want 1 deduplicated memory, got %d", len(list))
763 }
764
765 // Delete should remove from BOTH directories.
766 if err := s.Delete(name); err != nil {
767 t.Fatal(err)
768 }
769 if _, err := os.Stat(filepath.Join(s.GlobalDir, name+".md")); !os.IsNotExist(err) {
770 t.Fatal("global copy should be gone after delete")
771 }
772 if _, err := os.Stat(filepath.Join(s.Dir, name+".md")); !os.IsNotExist(err) {
773 t.Fatal("project copy should be gone after delete")
774 }
775
776 list2 := s.List()
777 if len(list2) != 0 {
778 t.Fatalf("want 0 memories after delete, got %d", len(list2))
779 }
780 if idx := s.Index(); idx != "" {
781 t.Fatalf("Index() should be empty after deleting all entries, got:\n%s", idx)
782 }
783 }
784
785 // TestStoreIndexDeduplicatesAcrossDirs verifies Index() does not emit duplicate
786 // lines when the same memory name exists in both GlobalDir and Dir.
787 func TestStoreIndexDeduplicatesAcrossDirs(t *testing.T) {
788 dir := t.TempDir()
789 s := Store{
790 Dir: filepath.Join(dir, "project", "memory"),
791 GlobalDir: filepath.Join(dir, "global"),
792 }
793
794 // Write the same memory into both dirs (migration scenario).
795 name := "prefers-tabs"
796 for _, d := range []string{s.GlobalDir, s.Dir} {
797 if err := os.MkdirAll(d, 0o755); err != nil {
798 t.Fatal(err)
799 }
800 m := Memory{Name: name, Description: "user pref", Type: TypeUser, Body: "use tabs"}
801 if err := os.WriteFile(filepath.Join(d, name+".md"), []byte(render(m, name)), 0o644); err != nil {
802 t.Fatal(err)
803 }
804 if err := reindexIn(d, name, m); err != nil {
805 t.Fatal(err)
806 }
807 }
808
809 idx := s.Index()
810 count := strings.Count(idx, name+".md")
811 if count != 1 {
812 t.Fatalf("want exactly 1 index line for %s, got %d:\n%s", name, count, idx)
813 }
814 if strings.Count(idx, "# Memory") != 0 {
815 t.Fatalf("merged index should have 0 # Memory headers (Block() adds one), got %d:\n%s", strings.Count(idx, "# Memory"), idx)
816 }
817 }
818
819 // TestStoreSaveVerifiesIndexDir verifies that Save writes MEMORY.md to the
820 // directory selected by explicit scope, independently of type.
821 func TestStoreSaveVerifiesIndexDir(t *testing.T) {
822 dir := t.TempDir()
823 s := Store{
824 Dir: filepath.Join(dir, "project", "memory"),
825 GlobalDir: filepath.Join(dir, "global"),
826 }
827
828 // Explicit global user preference → GlobalDir.
829 if _, err := s.Save(Memory{Name: "user-pref", Description: "d", Type: TypeUser, Scope: FactScopeGlobal, Body: "b"}); err != nil {
830 t.Fatal(err)
831 }
832 gb, _ := os.ReadFile(filepath.Join(s.GlobalDir, indexFile))
833 pb, _ := os.ReadFile(filepath.Join(s.Dir, indexFile))
834 if !strings.Contains(string(gb), "user-pref") {
835 t.Fatal("GlobalDir MEMORY.md should contain user-pref")
836 }
837 if strings.Contains(string(pb), "user-pref") {
838 t.Fatal("Dir MEMORY.md should NOT contain user-pref (it went to GlobalDir)")
839 }
840
841 // TypeProject → Dir
842 if _, err := s.Save(Memory{Name: "build-cmd", Description: "d", Type: TypeProject, Body: "b"}); err != nil {
843 t.Fatal(err)
844 }
845 pb2, _ := os.ReadFile(filepath.Join(s.Dir, indexFile))
846 if !strings.Contains(string(pb2), "build-cmd") {
847 t.Fatal("Dir MEMORY.md should contain build-cmd")
848 }
849 gb2, _ := os.ReadFile(filepath.Join(s.GlobalDir, indexFile))
850 if strings.Contains(string(gb2), "build-cmd") {
851 t.Fatal("GlobalDir MEMORY.md should NOT contain build-cmd (it went to Dir)")
852 }
853 }
854
855 // TestStoreDeleteFlushesIndexPerDir verifies that Delete calls flushIndexIn
856 // for each directory where the memory file existed.
857 func TestStoreDeleteFlushesIndexPerDir(t *testing.T) {
858 dir := t.TempDir()
859 s := Store{
860 Dir: filepath.Join(dir, "project", "memory"),
861 GlobalDir: filepath.Join(dir, "global"),
862 }
863
864 // Write to both dirs manually (migration scenario).
865 name := "prefers-tabs"
866 for _, d := range []string{s.GlobalDir, s.Dir} {
867 if err := os.MkdirAll(d, 0o755); err != nil {
868 t.Fatal(err)
869 }
870 m := Memory{Name: name, Description: "d", Type: TypeUser, Body: "b"}
871 if err := os.WriteFile(filepath.Join(d, name+".md"), []byte(render(m, name)), 0o644); err != nil {
872 t.Fatal(err)
873 }
874 if err := reindexIn(d, name, m); err != nil {
875 t.Fatal(err)
876 }
877 }
878
879 if err := s.Delete(name); err != nil {
880 t.Fatal(err)
881 }
882
883 // Verify both MEMORY.md files have the entry removed.
884 gb, _ := os.ReadFile(filepath.Join(s.GlobalDir, indexFile))
885 pb, _ := os.ReadFile(filepath.Join(s.Dir, indexFile))
886 if strings.Contains(string(gb), name+".md") {
887 t.Fatalf("GlobalDir MEMORY.md should not reference %s after delete:\n%s", name, gb)
888 }
889 if strings.Contains(string(pb), name+".md") {
890 t.Fatalf("Dir MEMORY.md should not reference %s after delete:\n%s", name, pb)
891 }
892
893 // Index() should return "" (no entries, no orphaned header).
894 idx := s.Index()
895 if idx != "" {
896 t.Fatalf("Index() should return empty after deleting all entries, got:\n%s", idx)
897 }
898 }
899
900 // TestStorePathWithGlobalDir verifies Path() checks GlobalDir first and
901 // falls back to Dir for new files.
902 func TestStorePathWithGlobalDir(t *testing.T) {
903 dir := t.TempDir()
904 s := Store{
905 Dir: filepath.Join(dir, "project", "memory"),
906 GlobalDir: filepath.Join(dir, "global"),
907 }
908
909 // No files yet → defaults to Dir.
910 p := s.Path("new-fact")
911 if !strings.HasPrefix(p, s.Dir) {
912 t.Fatalf("Path for new file should default to Dir, got %s", p)
913 }
914
915 // Write a file to GlobalDir.
916 if err := os.MkdirAll(s.GlobalDir, 0o755); err != nil {
917 t.Fatal(err)
918 }
919 if err := os.WriteFile(filepath.Join(s.GlobalDir, "existing.md"), []byte("body"), 0o644); err != nil {
920 t.Fatal(err)
921 }
922 p2 := s.Path("existing")
923 if !strings.HasPrefix(p2, s.GlobalDir) {
924 t.Fatalf("Path for file in GlobalDir should return GlobalDir path, got %s", p2)
925 }
926
927 // Write a file to Dir (not GlobalDir).
928 if err := os.MkdirAll(s.Dir, 0o755); err != nil {
929 t.Fatal(err)
930 }
931 if err := os.WriteFile(filepath.Join(s.Dir, "proj-fact.md"), []byte("body"), 0o644); err != nil {
932 t.Fatal(err)
933 }
934 p3 := s.Path("proj-fact")
935 if !strings.HasPrefix(p3, s.Dir) {
936 t.Fatalf("Path for file only in Dir should return Dir path, got %s", p3)
937 }
938 }
939
939 lines GO