| 1 | package memory |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | "sync" |
| 8 | "testing" |
| 9 | ) |
| 10 | |
| 11 | func TestStoreV2StableIDSurvivesRenameAndRevisionIncrements(t *testing.T) { |
| 12 | store := Store{Dir: t.TempDir()} |
| 13 | first, err := store.SaveWithOptions(Memory{Name: "alpha", Description: "first", Body: "v1"}, SaveOptions{}) |
| 14 | if err != nil { |
| 15 | t.Fatal(err) |
| 16 | } |
| 17 | if first.Memory.ID == "" || first.Memory.Revision != 1 { |
| 18 | t.Fatalf("first save = %+v", first.Memory) |
| 19 | } |
| 20 | |
| 21 | second, err := store.SaveWithOptions(Memory{ |
| 22 | ID: first.Memory.ID, Name: "beta", Description: "renamed", Body: "v2", |
| 23 | }, SaveOptions{ExpectedRevision: 1, RequireExpectedRevision: true}) |
| 24 | if err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | if second.Memory.ID != first.Memory.ID || second.Memory.Revision != 2 || second.Memory.Name != "beta" { |
| 28 | t.Fatalf("renamed save = %+v, first = %+v", second.Memory, first.Memory) |
| 29 | } |
| 30 | if _, err := os.Stat(filepath.Join(store.Dir, "alpha.md")); !os.IsNotExist(err) { |
| 31 | t.Fatalf("old slug path still active: %v", err) |
| 32 | } |
| 33 | if _, err := os.Stat(filepath.Join(store.Dir, "beta.md")); err != nil { |
| 34 | t.Fatalf("renamed path missing: %v", err) |
| 35 | } |
| 36 | } |
| 37 | |
| 38 | func TestStoreV2IndexIsDerivedFromFacts(t *testing.T) { |
| 39 | store := Store{Dir: t.TempDir()} |
| 40 | if _, err := store.Save(Memory{Name: "source-of-truth", Title: "Source of truth", Description: "active fact", Body: "body"}); err != nil { |
| 41 | t.Fatal(err) |
| 42 | } |
| 43 | if err := os.WriteFile(filepath.Join(store.Dir, indexFile), []byte("# stale\n\n- [Ghost](ghost.md) — stale\n"), 0o644); err != nil { |
| 44 | t.Fatal(err) |
| 45 | } |
| 46 | |
| 47 | index := store.Index() |
| 48 | if !strings.Contains(index, "[Source of truth](source-of-truth.md)") || strings.Contains(index, "Ghost") { |
| 49 | t.Fatalf("derived index used stale MEMORY.md:\n%s", index) |
| 50 | } |
| 51 | } |
| 52 | |
| 53 | func TestStoreV2ExpectedRevisionRejectsLostUpdate(t *testing.T) { |
| 54 | store := Store{Dir: t.TempDir()} |
| 55 | first, err := store.SaveWithOptions(Memory{Name: "fact", Description: "one", Body: "v1"}, SaveOptions{}) |
| 56 | if err != nil { |
| 57 | t.Fatal(err) |
| 58 | } |
| 59 | if _, err := store.SaveWithOptions(Memory{ID: first.Memory.ID, Name: "fact", Description: "two", Body: "v2"}, SaveOptions{}); err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | _, err = store.SaveWithOptions(Memory{ID: first.Memory.ID, Name: "fact", Description: "stale", Body: "lost"}, SaveOptions{ |
| 63 | ExpectedRevision: 1, RequireExpectedRevision: true, |
| 64 | }) |
| 65 | if err == nil || !strings.Contains(err.Error(), "revision conflict") { |
| 66 | t.Fatalf("stale save error = %v, want revision conflict", err) |
| 67 | } |
| 68 | got, ok := store.Read(first.Memory.ID) |
| 69 | if !ok || got.Body != "v2" || got.Revision != 2 { |
| 70 | t.Fatalf("stale update changed active memory: %+v, ok=%v", got, ok) |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | func TestStoreV2RestoreRevisionCreatesAuditedRevision(t *testing.T) { |
| 75 | store := Store{Dir: t.TempDir()} |
| 76 | first, err := store.SaveWithOptions(Memory{Name: "fact", Description: "one", Body: "v1"}, SaveOptions{}) |
| 77 | if err != nil { |
| 78 | t.Fatal(err) |
| 79 | } |
| 80 | if _, err := store.SaveWithOptions(Memory{ID: first.Memory.ID, Name: "fact", Description: "two", Body: "v2"}, SaveOptions{}); err != nil { |
| 81 | t.Fatal(err) |
| 82 | } |
| 83 | restored, err := store.Restore(first.Memory.ID, 1) |
| 84 | if err != nil { |
| 85 | t.Fatal(err) |
| 86 | } |
| 87 | if restored.Memory.ID != first.Memory.ID || restored.Memory.Revision != 3 || restored.Memory.Body != "v1" { |
| 88 | t.Fatalf("restored memory = %+v", restored.Memory) |
| 89 | } |
| 90 | if revisions := store.Revisions(first.Memory.ID); len(revisions) < 2 || revisions[0].Revision != 2 || revisions[1].Revision != 1 { |
| 91 | t.Fatalf("revision history = %+v, want revisions 2 and 1 newest first", revisions) |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | func TestStoreV2LegacyMemoryGetsDeterministicIdentity(t *testing.T) { |
| 96 | dir := t.TempDir() |
| 97 | legacy := "---\nname: legacy-fact\ndescription: old file\nmetadata:\n type: project\n scope: project\n---\n\nlegacy body\n" |
| 98 | if err := os.WriteFile(filepath.Join(dir, "legacy-fact.md"), []byte(legacy), 0o644); err != nil { |
| 99 | t.Fatal(err) |
| 100 | } |
| 101 | store := Store{Dir: dir} |
| 102 | first := store.List() |
| 103 | second := store.List() |
| 104 | if len(first) != 1 || len(second) != 1 || first[0].ID == "" || first[0].ID != second[0].ID || first[0].Revision != 1 { |
| 105 | t.Fatalf("legacy identities = first %+v second %+v", first, second) |
| 106 | } |
| 107 | if !strings.HasPrefix(first[0].ID, "legacy-") { |
| 108 | t.Fatalf("legacy id = %q, want recognizable deterministic id", first[0].ID) |
| 109 | } |
| 110 | } |
| 111 | |
| 112 | func TestStoreV2MigrationPersistsLegacyIdentity(t *testing.T) { |
| 113 | dir := t.TempDir() |
| 114 | path := filepath.Join(dir, "legacy-fact.md") |
| 115 | legacy := "---\nname: legacy-fact\ndescription: old file\nmetadata:\n type: project\n scope: project\n---\n\nlegacy body\n" |
| 116 | if err := os.WriteFile(path, []byte(legacy), 0o644); err != nil { |
| 117 | t.Fatal(err) |
| 118 | } |
| 119 | store := Store{Dir: dir} |
| 120 | report, err := store.MigrateV2() |
| 121 | if err != nil { |
| 122 | t.Fatal(err) |
| 123 | } |
| 124 | if report.Migrated != 1 { |
| 125 | t.Fatalf("migration report = %+v", report) |
| 126 | } |
| 127 | frontmatter, _ := splitFrontmatter(mustReadString(t, path)) |
| 128 | if !strings.HasPrefix(frontmatter["id"], "legacy-") || frontmatter["revision"] != "1" || frontmatter["created_at"] == "" || frontmatter["updated_at"] == "" { |
| 129 | t.Fatalf("migrated frontmatter = %+v", frontmatter) |
| 130 | } |
| 131 | again, err := store.MigrateV2() |
| 132 | if err != nil || again.Migrated != 0 { |
| 133 | t.Fatalf("idempotent migration = %+v, %v", again, err) |
| 134 | } |
| 135 | } |
| 136 | |
| 137 | func TestStoreV2LegacyIdentityIncludesScope(t *testing.T) { |
| 138 | root := t.TempDir() |
| 139 | store := Store{Dir: filepath.Join(root, "project"), GlobalDir: filepath.Join(root, "global")} |
| 140 | for _, fixture := range []struct { |
| 141 | dir string |
| 142 | scope FactScope |
| 143 | }{ |
| 144 | {dir: store.Dir, scope: FactScopeProject}, |
| 145 | {dir: store.GlobalDir, scope: FactScopeGlobal}, |
| 146 | } { |
| 147 | if err := os.MkdirAll(fixture.dir, 0o755); err != nil { |
| 148 | t.Fatal(err) |
| 149 | } |
| 150 | legacy := "---\nname: shared-name\ndescription: old file\nmetadata:\n type: project\n scope: " + string(fixture.scope) + "\n---\n\nlegacy body\n" |
| 151 | if err := os.WriteFile(filepath.Join(fixture.dir, "shared-name.md"), []byte(legacy), 0o644); err != nil { |
| 152 | t.Fatal(err) |
| 153 | } |
| 154 | } |
| 155 | |
| 156 | project, _, ok := store.findActiveInDir(store.Dir, "shared-name") |
| 157 | if !ok { |
| 158 | t.Fatal("project legacy memory not found") |
| 159 | } |
| 160 | global, _, ok := store.findActiveInDir(store.GlobalDir, "shared-name") |
| 161 | if !ok { |
| 162 | t.Fatal("global legacy memory not found") |
| 163 | } |
| 164 | if project.ID == global.ID { |
| 165 | t.Fatalf("legacy IDs collided across scopes: %q", project.ID) |
| 166 | } |
| 167 | } |
| 168 | |
| 169 | func TestStoreV2ArchiveByIDOnlyArchivesMatchingIdentity(t *testing.T) { |
| 170 | root := t.TempDir() |
| 171 | store := Store{Dir: filepath.Join(root, "project"), GlobalDir: filepath.Join(root, "global")} |
| 172 | for _, fixture := range []struct { |
| 173 | dir string |
| 174 | id string |
| 175 | scope FactScope |
| 176 | }{ |
| 177 | {dir: store.Dir, id: "mem-project", scope: FactScopeProject}, |
| 178 | {dir: store.GlobalDir, id: "mem-global", scope: FactScopeGlobal}, |
| 179 | } { |
| 180 | if err := os.MkdirAll(fixture.dir, 0o755); err != nil { |
| 181 | t.Fatal(err) |
| 182 | } |
| 183 | memory := Memory{ID: fixture.id, Revision: 1, Name: "shared-name", Description: string(fixture.scope), Scope: fixture.scope, Body: "body"} |
| 184 | if err := os.WriteFile(filepath.Join(fixture.dir, "shared-name.md"), []byte(render(memory, memory.Name)), 0o644); err != nil { |
| 185 | t.Fatal(err) |
| 186 | } |
| 187 | } |
| 188 | |
| 189 | if _, err := store.Archive("mem-project"); err != nil { |
| 190 | t.Fatal(err) |
| 191 | } |
| 192 | if _, err := os.Stat(filepath.Join(store.Dir, "shared-name.md")); !os.IsNotExist(err) { |
| 193 | t.Fatalf("project memory remained active: %v", err) |
| 194 | } |
| 195 | if _, err := os.Stat(filepath.Join(store.GlobalDir, "shared-name.md")); err != nil { |
| 196 | t.Fatalf("global memory with a different ID was archived: %v", err) |
| 197 | } |
| 198 | } |
| 199 | |
| 200 | func TestStoreV2ScopeQualifiedReferencesRoundTrip(t *testing.T) { |
| 201 | root := t.TempDir() |
| 202 | store := Store{Dir: filepath.Join(root, "project"), GlobalDir: filepath.Join(root, "global")} |
| 203 | global, err := store.SaveWithOptions(Memory{ |
| 204 | Name: "global/shared.md", Description: "global", Body: "global body", |
| 205 | }, SaveOptions{}) |
| 206 | if err != nil { |
| 207 | t.Fatal(err) |
| 208 | } |
| 209 | project, err := store.SaveWithOptions(Memory{ |
| 210 | Name: "project/shared.md", Description: "project", Body: "project body", |
| 211 | }, SaveOptions{}) |
| 212 | if err != nil { |
| 213 | t.Fatal(err) |
| 214 | } |
| 215 | if global.Memory.Name != "shared" || global.Memory.Scope != FactScopeGlobal { |
| 216 | t.Fatalf("global memory = %+v", global.Memory) |
| 217 | } |
| 218 | if project.Memory.Name != "shared" || project.Memory.Scope != FactScopeProject { |
| 219 | t.Fatalf("project memory = %+v", project.Memory) |
| 220 | } |
| 221 | if got, ok := store.Read("global/shared.md"); !ok || got.ID != global.Memory.ID || got.Body != "global body" { |
| 222 | t.Fatalf("global qualified read = %+v, ok=%v", got, ok) |
| 223 | } |
| 224 | if got, ok := store.Read("project/shared.md"); !ok || got.ID != project.Memory.ID || got.Body != "project body" { |
| 225 | t.Fatalf("project qualified read = %+v, ok=%v", got, ok) |
| 226 | } |
| 227 | if got, ok := store.Read("shared.md"); !ok || got.ID != project.Memory.ID { |
| 228 | t.Fatalf("legacy unqualified read = %+v, ok=%v; want project-preferred", got, ok) |
| 229 | } |
| 230 | |
| 231 | updated, err := store.SaveWithOptions(Memory{ |
| 232 | Name: "global/shared.md", Description: "updated global", Body: "global v2", |
| 233 | }, SaveOptions{ExpectedRevision: 1, RequireExpectedRevision: true}) |
| 234 | if err != nil { |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | if updated.Memory.Name != "shared" || updated.Memory.Scope != FactScopeGlobal || updated.Memory.Revision != 2 { |
| 238 | t.Fatalf("qualified update = %+v", updated.Memory) |
| 239 | } |
| 240 | if _, err := os.Stat(filepath.Join(store.GlobalDir, "shared.md")); err != nil { |
| 241 | t.Fatalf("qualified update renamed or moved global fact: %v", err) |
| 242 | } |
| 243 | if got, ok := store.Read("project/shared.md"); !ok || got.ID != project.Memory.ID { |
| 244 | t.Fatalf("qualified update disturbed project fact: %+v, ok=%v", got, ok) |
| 245 | } |
| 246 | if revisions := store.Revisions("global/shared.md"); len(revisions) != 1 || revisions[0].Revision != 1 { |
| 247 | t.Fatalf("qualified revisions = %+v", revisions) |
| 248 | } |
| 249 | |
| 250 | if _, err := store.Archive("project/shared.md"); err != nil { |
| 251 | t.Fatal(err) |
| 252 | } |
| 253 | if _, ok := store.Read("project/shared.md"); ok { |
| 254 | t.Fatal("qualified archive left project fact active") |
| 255 | } |
| 256 | if got, ok := store.Read("global/shared.md"); !ok || got.ID != global.Memory.ID || got.Revision != 2 { |
| 257 | t.Fatalf("qualified archive disturbed global fact: %+v, ok=%v", got, ok) |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | func TestStoreV2RejectsConflictingQualifiedReferenceScope(t *testing.T) { |
| 262 | root := t.TempDir() |
| 263 | store := Store{Dir: filepath.Join(root, "project"), GlobalDir: filepath.Join(root, "global")} |
| 264 | _, err := store.SaveWithOptions(Memory{ |
| 265 | Name: "global/fact.md", Scope: FactScopeProject, Description: "conflict", Body: "body", |
| 266 | }, SaveOptions{}) |
| 267 | if err == nil || !strings.Contains(err.Error(), "conflicts") { |
| 268 | t.Fatalf("conflicting qualified reference error = %v", err) |
| 269 | } |
| 270 | if len(store.ListAll()) != 0 { |
| 271 | t.Fatalf("conflicting reference wrote a fact: %+v", store.ListAll()) |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | func TestStoreV2RestoreArchivedPreservesIdentityAndCreatesRevision(t *testing.T) { |
| 276 | store := Store{Dir: t.TempDir()} |
| 277 | first, err := store.SaveWithOptions(Memory{Name: "fact", Description: "first", Body: "v1"}, SaveOptions{}) |
| 278 | if err != nil { |
| 279 | t.Fatal(err) |
| 280 | } |
| 281 | archivePath, err := store.Archive(first.Memory.ID) |
| 282 | if err != nil { |
| 283 | t.Fatal(err) |
| 284 | } |
| 285 | |
| 286 | restored, err := store.RestoreArchived(archivePath) |
| 287 | if err != nil { |
| 288 | t.Fatal(err) |
| 289 | } |
| 290 | if restored.Memory.ID != first.Memory.ID || restored.Memory.Revision != 2 || restored.Memory.Body != "v1" { |
| 291 | t.Fatalf("restored memory = %+v, first = %+v", restored.Memory, first.Memory) |
| 292 | } |
| 293 | if _, err := os.Stat(archivePath); !os.IsNotExist(err) { |
| 294 | t.Fatalf("restored archive should leave the archive list: %v", err) |
| 295 | } |
| 296 | revisions := store.Revisions(first.Memory.ID) |
| 297 | if len(revisions) != 1 || revisions[0].Revision != 1 || revisions[0].Body != "v1" { |
| 298 | t.Fatalf("revision history = %+v, want archived revision 1", revisions) |
| 299 | } |
| 300 | } |
| 301 | |
| 302 | func TestStoreV2RestoreArchivedRejectsActiveCollisions(t *testing.T) { |
| 303 | store := Store{Dir: t.TempDir()} |
| 304 | first, err := store.SaveWithOptions(Memory{Name: "fact", Description: "first", Body: "archived"}, SaveOptions{}) |
| 305 | if err != nil { |
| 306 | t.Fatal(err) |
| 307 | } |
| 308 | archivePath, err := store.Archive(first.Memory.ID) |
| 309 | if err != nil { |
| 310 | t.Fatal(err) |
| 311 | } |
| 312 | if _, err := store.SaveWithOptions(Memory{Name: "fact", Description: "replacement", Body: "active"}, SaveOptions{}); err != nil { |
| 313 | t.Fatal(err) |
| 314 | } |
| 315 | |
| 316 | if _, err := store.RestoreArchived(archivePath); err == nil || !strings.Contains(err.Error(), "already active") { |
| 317 | t.Fatalf("restore collision error = %v, want already active", err) |
| 318 | } |
| 319 | active, ok := store.Read("fact") |
| 320 | if !ok || active.Body != "active" { |
| 321 | t.Fatalf("restore collision overwrote active fact: %+v, ok=%v", active, ok) |
| 322 | } |
| 323 | if _, err := os.Stat(archivePath); err != nil { |
| 324 | t.Fatalf("failed restore should preserve archive: %v", err) |
| 325 | } |
| 326 | } |
| 327 | |
| 328 | func TestStoreV2RestoreArchivedIsConcurrencySafe(t *testing.T) { |
| 329 | store := Store{Dir: t.TempDir()} |
| 330 | first, err := store.SaveWithOptions(Memory{Name: "fact", Description: "first", Body: "v1"}, SaveOptions{}) |
| 331 | if err != nil { |
| 332 | t.Fatal(err) |
| 333 | } |
| 334 | archivePath, err := store.Archive(first.Memory.ID) |
| 335 | if err != nil { |
| 336 | t.Fatal(err) |
| 337 | } |
| 338 | |
| 339 | var wg sync.WaitGroup |
| 340 | errs := make(chan error, 2) |
| 341 | for range 2 { |
| 342 | wg.Add(1) |
| 343 | go func() { |
| 344 | defer wg.Done() |
| 345 | _, restoreErr := store.RestoreArchived(archivePath) |
| 346 | errs <- restoreErr |
| 347 | }() |
| 348 | } |
| 349 | wg.Wait() |
| 350 | close(errs) |
| 351 | |
| 352 | var successes int |
| 353 | for restoreErr := range errs { |
| 354 | if restoreErr == nil { |
| 355 | successes++ |
| 356 | } |
| 357 | } |
| 358 | if successes != 1 { |
| 359 | t.Fatalf("successful restores = %d, want exactly one", successes) |
| 360 | } |
| 361 | active, ok := store.Read(first.Memory.ID) |
| 362 | if !ok || active.Revision != 2 { |
| 363 | t.Fatalf("active memory = %+v, ok=%v", active, ok) |
| 364 | } |
| 365 | } |
| 366 | |
| 367 | func TestStoreV2RestoreArchivedRejectsPathsOutsideStore(t *testing.T) { |
| 368 | store := Store{Dir: t.TempDir()} |
| 369 | outside := filepath.Join(t.TempDir(), "fact.md") |
| 370 | if err := os.WriteFile(outside, []byte("not an archive"), 0o644); err != nil { |
| 371 | t.Fatal(err) |
| 372 | } |
| 373 | if _, err := store.RestoreArchived(outside); err == nil || !strings.Contains(err.Error(), "not found") { |
| 374 | t.Fatalf("outside restore error = %v, want archive not found", err) |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | func TestStoreV2RestoreArchivedRejectsSymlinkEntry(t *testing.T) { |
| 379 | store := Store{Dir: t.TempDir()} |
| 380 | archiveDir := filepath.Join(store.Dir, ".archive") |
| 381 | if err := os.MkdirAll(archiveDir, 0o755); err != nil { |
| 382 | t.Fatal(err) |
| 383 | } |
| 384 | outside := filepath.Join(t.TempDir(), "fact.md") |
| 385 | if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil { |
| 386 | t.Fatal(err) |
| 387 | } |
| 388 | archivePath := filepath.Join(archiveDir, "fact.md") |
| 389 | if err := os.Symlink(outside, archivePath); err != nil { |
| 390 | t.Skipf("symlink unavailable: %v", err) |
| 391 | } |
| 392 | if _, err := store.RestoreArchived(archivePath); err == nil || !strings.Contains(err.Error(), "not found") { |
| 393 | t.Fatalf("symlink restore error = %v, want archive not found", err) |
| 394 | } |
| 395 | if _, err := os.Stat(outside); err != nil { |
| 396 | t.Fatalf("outside target changed: %v", err) |
| 397 | } |
| 398 | } |
| 399 | |
| 400 | func TestStoreV2RestoreArchivedRejectsSymlinkDirectory(t *testing.T) { |
| 401 | store := Store{Dir: t.TempDir()} |
| 402 | outsideDir := t.TempDir() |
| 403 | outside := filepath.Join(outsideDir, "fact.md") |
| 404 | if err := os.WriteFile(outside, []byte("outside"), 0o644); err != nil { |
| 405 | t.Fatal(err) |
| 406 | } |
| 407 | archiveDir := filepath.Join(store.Dir, ".archive") |
| 408 | if err := os.Symlink(outsideDir, archiveDir); err != nil { |
| 409 | t.Skipf("symlink unavailable: %v", err) |
| 410 | } |
| 411 | archivePath := filepath.Join(archiveDir, "fact.md") |
| 412 | if _, err := store.RestoreArchived(archivePath); err == nil || !strings.Contains(err.Error(), "not found") { |
| 413 | t.Fatalf("symlinked directory restore error = %v, want archive not found", err) |
| 414 | } |
| 415 | if _, err := os.Stat(outside); err != nil { |
| 416 | t.Fatalf("outside target changed: %v", err) |
| 417 | } |
| 418 | } |
| 419 |