| 1 | package memory |
| 2 | |
| 3 | import ( |
| 4 | "os" |
| 5 | "path/filepath" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | // --- splitFrontmatter --- |
| 11 | |
| 12 | func TestSplitFrontmatterNoFence(t *testing.T) { |
| 13 | fm, body := splitFrontmatter("just plain text\nno frontmatter") |
| 14 | if len(fm) != 0 { |
| 15 | t.Errorf("expected empty fm, got %v", fm) |
| 16 | } |
| 17 | if !strings.Contains(body, "just plain text") { |
| 18 | t.Errorf("body should contain original text: %q", body) |
| 19 | } |
| 20 | } |
| 21 | |
| 22 | func TestSplitFrontmatterUnclosedFence(t *testing.T) { |
| 23 | input := "---\nname: test\ndescription: desc\n\nsome body without closing fence" |
| 24 | fm, body := splitFrontmatter(input) |
| 25 | // Unclosed fence: treat all as body. |
| 26 | if len(fm) != 0 { |
| 27 | t.Errorf("unclosed fence should return empty fm, got %v", fm) |
| 28 | } |
| 29 | if !strings.Contains(body, "---") { |
| 30 | t.Errorf("body should contain the original content: %q", body) |
| 31 | } |
| 32 | } |
| 33 | |
| 34 | func TestSplitFrontmatterEmptyBody(t *testing.T) { |
| 35 | input := "---\nname: test\n---\n" |
| 36 | fm, body := splitFrontmatter(input) |
| 37 | if fm["name"] != "test" { |
| 38 | t.Errorf("name = %q", fm["name"]) |
| 39 | } |
| 40 | if strings.TrimSpace(body) != "" { |
| 41 | t.Errorf("expected empty body, got %q", body) |
| 42 | } |
| 43 | } |
| 44 | |
| 45 | func TestSplitFrontmatterNestedMetadata(t *testing.T) { |
| 46 | input := "---\nname: my-fact\ndescription: a desc\nmetadata:\n type: user\n---\n\nbody here" |
| 47 | fm, body := splitFrontmatter(input) |
| 48 | if fm["name"] != "my-fact" { |
| 49 | t.Errorf("name = %q", fm["name"]) |
| 50 | } |
| 51 | if fm["description"] != "a desc" { |
| 52 | t.Errorf("description = %q", fm["description"]) |
| 53 | } |
| 54 | // The nested " type: user" should flatten to fm["type"]. |
| 55 | if fm["type"] != "user" { |
| 56 | t.Errorf("type = %q, expected flattened from metadata", fm["type"]) |
| 57 | } |
| 58 | if !strings.Contains(body, "body here") { |
| 59 | t.Errorf("body = %q", body) |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | func TestSplitFrontmatterCRLF(t *testing.T) { |
| 64 | input := "---\r\nname: test\r\n---\r\nbody\r\n" |
| 65 | fm, body := splitFrontmatter(input) |
| 66 | if fm["name"] != "test" { |
| 67 | t.Errorf("name = %q", fm["name"]) |
| 68 | } |
| 69 | if !strings.Contains(body, "body") { |
| 70 | t.Errorf("body = %q", body) |
| 71 | } |
| 72 | } |
| 73 | |
| 74 | func TestSplitFrontmatterQuotedValues(t *testing.T) { |
| 75 | input := "---\nname: test\ndescription: \"quoted desc\"\n---\n" |
| 76 | fm, _ := splitFrontmatter(input) |
| 77 | if fm["description"] != "quoted desc" { |
| 78 | t.Errorf("description should be unquoted: %q", fm["description"]) |
| 79 | } |
| 80 | } |
| 81 | |
| 82 | // --- slug --- |
| 83 | |
| 84 | func TestSlug(t *testing.T) { |
| 85 | cases := []struct { |
| 86 | input string |
| 87 | want string |
| 88 | }{ |
| 89 | {"Prefers Tabs", "prefers-tabs"}, |
| 90 | {" spaces ", "spaces"}, |
| 91 | {"CamelCase", "camelcase"}, |
| 92 | {"with/slash", "with-slash"}, |
| 93 | {"", ""}, |
| 94 | {"---", ""}, |
| 95 | {"hello_world", "hello-world"}, |
| 96 | {"中文标题", "中文标题"}, |
| 97 | {"HÉLLO", "héllo"}, |
| 98 | {"日本語123", "日本語123"}, |
| 99 | } |
| 100 | for _, c := range cases { |
| 101 | got := slug(c.input) |
| 102 | if got != c.want { |
| 103 | t.Errorf("slug(%q) = %q, want %q", c.input, got, c.want) |
| 104 | } |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | func TestStoreSaveUnicodeAndRejectsEmptySlug(t *testing.T) { |
| 109 | s := Store{Dir: t.TempDir()} |
| 110 | path, err := s.Save(Memory{Name: "中文标题", Description: "d", Type: TypeProject, Body: "body"}) |
| 111 | if err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | if filepath.Base(path) != "中文标题.md" { |
| 115 | t.Fatalf("unicode name path = %q", path) |
| 116 | } |
| 117 | if _, err := s.Save(Memory{Name: "---", Description: "d", Type: TypeProject, Body: "body"}); err == nil { |
| 118 | t.Fatal("punctuation-only name should be rejected") |
| 119 | } |
| 120 | } |
| 121 | |
| 122 | // --- oneLine --- |
| 123 | |
| 124 | func TestOneLine(t *testing.T) { |
| 125 | cases := []struct { |
| 126 | input string |
| 127 | want string |
| 128 | }{ |
| 129 | {"hello world", "hello world"}, |
| 130 | {" multiple spaces ", "multiple spaces"}, |
| 131 | {"tabs\there", "tabs here"}, |
| 132 | {"\n\nnewlines\n\n", "newlines"}, |
| 133 | {"", ""}, |
| 134 | } |
| 135 | for _, c := range cases { |
| 136 | got := oneLine(c.input) |
| 137 | if got != c.want { |
| 138 | t.Errorf("oneLine(%q) = %q, want %q", c.input, got, c.want) |
| 139 | } |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | // --- render --- |
| 144 | |
| 145 | func TestRenderRoundTrip(t *testing.T) { |
| 146 | m := Memory{ |
| 147 | Name: "test-fact", |
| 148 | Description: "A test fact", |
| 149 | Type: TypeUser, |
| 150 | Body: "The body of the fact.", |
| 151 | } |
| 152 | rendered := render(m, "test-fact") |
| 153 | fm, body := splitFrontmatter(rendered) |
| 154 | if fm["name"] != "test-fact" { |
| 155 | t.Errorf("name = %q", fm["name"]) |
| 156 | } |
| 157 | if fm["description"] != "A test fact" { |
| 158 | t.Errorf("description = %q", fm["description"]) |
| 159 | } |
| 160 | if got := persistedFactType(fm); got != TypeUser { |
| 161 | t.Errorf("type = %q", got) |
| 162 | } |
| 163 | if !strings.Contains(body, "The body of the fact.") { |
| 164 | t.Errorf("body = %q", body) |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | func TestRenderNormalizesType(t *testing.T) { |
| 169 | m := Memory{Name: "x", Description: "d", Type: Type("unknown"), Body: "b"} |
| 170 | rendered := render(m, "x") |
| 171 | fm, _ := splitFrontmatter(rendered) |
| 172 | if fm["type"] != "project" { |
| 173 | t.Errorf("unknown type should normalize to project, got %q", fm["type"]) |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | // --- loadMemory --- |
| 178 | |
| 179 | func TestLoadMemoryNoFrontmatter(t *testing.T) { |
| 180 | dir := t.TempDir() |
| 181 | f := filepath.Join(dir, "no-fm.md") |
| 182 | os.WriteFile(f, []byte("just a body\nno frontmatter"), 0o644) |
| 183 | m, ok := loadMemory(f) |
| 184 | if !ok { |
| 185 | t.Fatal("loadMemory should succeed for files without frontmatter") |
| 186 | } |
| 187 | // Name should be derived from filename. |
| 188 | if m.Name != "no-fm" { |
| 189 | t.Errorf("name = %q, want no-fm", m.Name) |
| 190 | } |
| 191 | if !strings.Contains(m.Body, "just a body") { |
| 192 | t.Errorf("body = %q", m.Body) |
| 193 | } |
| 194 | } |
| 195 | |
| 196 | func TestLoadMemoryMissingFile(t *testing.T) { |
| 197 | _, ok := loadMemory("/nonexistent/path.md") |
| 198 | if ok { |
| 199 | t.Error("loadMemory should return false for missing file") |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | func TestLoadMemoryEmptyFile(t *testing.T) { |
| 204 | dir := t.TempDir() |
| 205 | f := filepath.Join(dir, "empty.md") |
| 206 | os.WriteFile(f, nil, 0o644) |
| 207 | m, ok := loadMemory(f) |
| 208 | if !ok { |
| 209 | t.Fatal("loadMemory should succeed for empty files") |
| 210 | } |
| 211 | if m.Name != "empty" { |
| 212 | t.Errorf("name = %q", m.Name) |
| 213 | } |
| 214 | } |
| 215 | |
| 216 | // --- Store.List edge cases --- |
| 217 | |
| 218 | func TestListSkipsNonMdFiles(t *testing.T) { |
| 219 | dir := t.TempDir() |
| 220 | os.WriteFile(filepath.Join(dir, "fact.md"), []byte("---\nname: fact\n---\nbody"), 0o644) |
| 221 | os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("not a memory"), 0o644) |
| 222 | os.WriteFile(filepath.Join(dir, "MEMORY.md"), []byte("# Memory\n"), 0o644) |
| 223 | s := Store{Dir: dir} |
| 224 | list := s.List() |
| 225 | if len(list) != 1 { |
| 226 | t.Fatalf("want 1 memory, got %d", len(list)) |
| 227 | } |
| 228 | if list[0].Name != "fact" { |
| 229 | t.Errorf("name = %q", list[0].Name) |
| 230 | } |
| 231 | } |
| 232 | |
| 233 | func TestListEmptyDir(t *testing.T) { |
| 234 | s := Store{Dir: t.TempDir()} |
| 235 | if list := s.List(); len(list) != 0 { |
| 236 | t.Errorf("empty dir should return empty list, got %d", len(list)) |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | func TestListSortedByName(t *testing.T) { |
| 241 | dir := t.TempDir() |
| 242 | for _, name := range []string{"zebra", "alpha", "middle"} { |
| 243 | os.WriteFile(filepath.Join(dir, name+".md"), []byte("---\nname: "+name+"\n---\nbody"), 0o644) |
| 244 | } |
| 245 | s := Store{Dir: dir} |
| 246 | list := s.List() |
| 247 | if len(list) != 3 { |
| 248 | t.Fatalf("want 3, got %d", len(list)) |
| 249 | } |
| 250 | if list[0].Name != "alpha" || list[1].Name != "middle" || list[2].Name != "zebra" { |
| 251 | t.Errorf("not sorted: %v %v %v", list[0].Name, list[1].Name, list[2].Name) |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | // --- Store.Save edge cases --- |
| 256 | |
| 257 | func TestSaveEmptyName(t *testing.T) { |
| 258 | s := Store{Dir: t.TempDir()} |
| 259 | _, err := s.Save(Memory{Name: "", Description: "d", Body: "b"}) |
| 260 | if err == nil { |
| 261 | t.Fatal("expected error for empty name") |
| 262 | } |
| 263 | } |
| 264 | |
| 265 | func TestSaveCreatesDir(t *testing.T) { |
| 266 | dir := t.TempDir() |
| 267 | s := Store{Dir: filepath.Join(dir, "deep", "nested", "memory")} |
| 268 | _, err := s.Save(Memory{Name: "test", Description: "d", Body: "b"}) |
| 269 | if err != nil { |
| 270 | t.Fatalf("Save should create dirs: %v", err) |
| 271 | } |
| 272 | if _, err := os.Stat(filepath.Join(s.Dir, "test.md")); err != nil { |
| 273 | t.Fatal("memory file should exist") |
| 274 | } |
| 275 | } |
| 276 | |
| 277 | // --- Store.Path --- |
| 278 | |
| 279 | func TestStorePath(t *testing.T) { |
| 280 | dir := filepath.Join(t.TempDir(), "memory") |
| 281 | s := Store{Dir: dir} |
| 282 | got := s.Path("My Fact") |
| 283 | if want := filepath.Join(dir, "my-fact.md"); got != want { |
| 284 | t.Errorf("Path = %q, want %q", got, want) |
| 285 | } |
| 286 | } |
| 287 |