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