AtlatestRepositoryfolio
1
(import (sigil test)2
(sigil struct)3
(sigil string)4
(sigil dict)5
(sigil fs)6
(sigil path)7
(sigil time)8
(folio store)9
(folio note)10
(folio links)11
(folio frontmatter)12
(folio tools))14
;; ============================================================15
;; extract-crosslinks16
;; ============================================================18
(test-group "extract-crosslinks"19
(test "single link"20
(assert-equal '("foo")21
(extract-crosslinks "See [[foo]] for details.")))23
(test "multiple links"24
(assert-equal '("foo" "bar-baz")25
(extract-crosslinks "See [[foo]] and [[bar-baz]].")))27
(test "no links"28
(assert-equal '()29
(extract-crosslinks "No links here.")))31
(test "empty brackets"32
(assert-equal '()33
(extract-crosslinks "Empty [[]] link.")))35
(test "adjacent links"36
(assert-equal '("a" "b")37
(extract-crosslinks "[[a]][[b]]")))39
(test "link at start"40
(assert-equal '("start")41
(extract-crosslinks "[[start]] of text")))43
(test "link at end"44
(assert-equal '("end")45
(extract-crosslinks "text at [[end]]"))))47
;; ============================================================48
;; note-create! and read-note49
;; ============================================================51
(test-group "note-create!"52
(test "creates note with frontmatter and content"53
(call-with-temp-directory54
(lambda (dir)55
(let* ((store (make-folio-store dir))56
(file (note-create! store "JMAP Quirks" '("jmap" "email") "Some content here.")))57
(assert-true (file-exists? file))58
(let ((note (read-note file)))59
(assert-equal "jmap-quirks" (folio-note-name note))60
(assert-true (string-contains? (folio-note-body note) "Some content here."))61
(assert-equal 2 (length (note-tags note)))))))))63
;; ============================================================64
;; store-note-files skips editor lock/dotfiles65
;; ============================================================67
(test-group "store-note-files dotfile filtering"68
(test "excludes .# Emacs lock files, keeps real notes"69
(call-with-temp-directory70
(lambda (dir)71
(let ((store (make-folio-store dir)))72
(note-create! store "Real Note" '("x") "Body.")73
;; Simulate an Emacs lock next to the note. It ends in .md, so the74
;; pre-fix walk included it, then index-note! crashed reading the75
;; (normally dangling) symlink. The walk must filter it by name.76
(write-file-string (path-join (store-notes-dir store) ".#real-note.md")77
"user@host.12345")78
(let ((files (store-note-files store)))79
(assert-equal 1 (length files))80
(assert-true (string-ends-with? (car files) "real-note.md"))))))))82
;; ============================================================83
;; note-edit!84
;; ============================================================86
(test-group "note-edit!"87
(test "replaces body and bumps last-updated"88
(call-with-temp-directory89
(lambda (dir)90
(let* ((store (make-folio-store dir))91
(file (note-create! store "Test Note" '("test") "Original.")))92
(note-edit! file "\n# Test Note\n\nUpdated content.\n")93
(let ((note (read-note file)))94
(assert-true (string-contains? (folio-note-body note) "Updated content."))95
(assert-false (string-contains? (folio-note-body note) "Original.")))))))97
(test "keeps the title when the new content has no heading"98
(call-with-temp-directory99
(lambda (dir)100
(let* ((store (make-folio-store dir))101
(file (note-create! store "topics/Kept Title" '() "Original.")))102
(note-edit! file "Replacement body.")103
(let ((body (folio-note-body (read-note file))))104
(assert-true (string-starts-with? (string-trim body) "# Kept Title"))105
(assert-true (string-contains? body "Replacement body."))106
(assert-false (string-contains? body "Original.")))))))108
(test "uses the new content's own heading instead of adding a second one"109
(call-with-temp-directory110
(lambda (dir)111
(let* ((store (make-folio-store dir))112
(file (note-create! store "Old Title" '() "Original.")))113
(note-edit! file "# New Title\n\nReplacement body.")114
(let ((body (folio-note-body (read-note file))))115
(assert-true (string-contains? body "# New Title"))116
(assert-false (string-contains? body "# Old Title"))))))))118
;; ============================================================119
;; crosslink traversal120
;; ============================================================122
(test-group "crosslinks"123
(test "outgoing links extracted from body"124
(call-with-temp-directory125
(lambda (dir)126
(let* ((store (make-folio-store dir))127
(file (note-create! store "Note A" '() "Links to [[note-b]] and [[note-c]].")))128
(let ((note (read-note file)))129
(assert-equal '("note-b" "note-c") (note-outgoing-links note)))))))131
(test "find backlinks"132
(call-with-temp-directory133
(lambda (dir)134
(let ((store (make-folio-store dir)))135
(note-create! store "Alpha" '() "See [[gamma]].")136
(note-create! store "Beta" '() "Also [[gamma]] and [[alpha]].")137
(note-create! store "Gamma" '() "Links to [[alpha]].")138
(let* ((notes (all-notes store))139
(backlinks (find-backlinks "gamma" notes)))140
(assert-equal 2 (length backlinks))))))))142
;; ============================================================143
;; search-notes144
;; ============================================================146
(test-group "search-notes"147
(test "search by text"148
(call-with-temp-directory149
(lambda (dir)150
(let ((store (make-folio-store dir)))151
(note-create! store "JMAP Quirks" '("jmap") "Fastmail deviates from spec.")152
(note-create! store "CalDAV Notes" '("caldav") "Discovery is tricky.")153
(let ((results (search-notes store "Fastmail" #f)))154
(assert-equal 1 (length results))155
(assert-equal "jmap-quirks" (folio-note-name (car results))))))))157
(test "search by tag"158
(call-with-temp-directory159
(lambda (dir)160
(let ((store (make-folio-store dir)))161
(note-create! store "Note A" '("jmap" "email") "Content A.")162
(note-create! store "Note B" '("caldav") "Content B.")163
(let ((results (search-notes store #f "jmap")))164
(assert-equal 1 (length results)))))))166
(test "search by since date returns matching notes"167
(call-with-temp-directory168
(lambda (dir)169
(let ((store (make-folio-store dir)))170
(note-create! store "Today Note" '("test") "Created today.")171
;; All notes created today should match today's date172
(let ((results (search-notes store #f #f since: (format-date (current-second)))))173
(assert-equal 1 (length results))174
(assert-equal "today-note" (folio-note-name (car results))))))))176
(test "search by since date excludes old notes"177
(call-with-temp-directory178
(lambda (dir)179
(let ((store (make-folio-store dir)))180
(note-create! store "Some Note" '("test") "Content.")181
;; A future date should exclude everything182
(let ((results (search-notes store #f #f since: "2099-01-01")))183
(assert-equal 0 (length results)))))))185
(test "search by since date with query and tag"186
(call-with-temp-directory187
(lambda (dir)188
(let ((store (make-folio-store dir)))189
(note-create! store "Alpha" '("jmap") "JMAP content.")190
(note-create! store "Beta" '("caldav") "CalDAV content.")191
(let ((results (search-notes store "JMAP" "jmap" since: (format-date (current-second)))))192
(assert-equal 1 (length results))193
(assert-equal "alpha" (folio-note-name (car results)))))))))195
;; ============================================================196
;; note-rename!197
;; ============================================================199
(test-group "note-rename!"200
(test "renames file and updates crosslinks"201
(call-with-temp-directory202
(lambda (dir)203
(let ((store (make-folio-store dir)))204
(note-create! store "Alpha" '() "Links to [[beta]].")205
(note-create! store "Beta" '() "Links to [[alpha]].")206
;; Rename alpha to gamma207
(assert-true (note-rename! store "alpha" "gamma"))208
;; Old file gone, new file exists209
(assert-false (find-note-file store "alpha"))210
(assert-true (string? (find-note-file store "gamma")))211
;; Crosslink in beta updated212
(let ((beta (read-note (find-note-file store "beta"))))213
(assert-true (string-contains? (folio-note-body beta) "[[gamma]]"))214
(assert-false (string-contains? (folio-note-body beta) "[[alpha]]")))))))216
(test "returns #f for nonexistent note"217
(call-with-temp-directory218
(lambda (dir)219
(let ((store (make-folio-store dir)))220
(assert-false (note-rename! store "nonexistent" "new-name")))))))222
;; ============================================================223
;; subdirectory notes224
;; ============================================================226
(test-group "subdirectory notes"227
(test "create note in subdirectory"228
(call-with-temp-directory229
(lambda (dir)230
(let* ((store (make-folio-store dir))231
(file (note-create! store "procedures/Email Triage" '("procedure") "Triage steps.")))232
(assert-true (file-exists? file))233
(assert-true (string-contains? file "procedures/"))))))235
(test "find note in subdirectory"236
(call-with-temp-directory237
(lambda (dir)238
(let ((store (make-folio-store dir)))239
(note-create! store "procedures/Email Triage" '() "Content.")240
(assert-true (string? (find-note-file store "procedures/email-triage")))))))242
(test "all-notes includes subdirectory notes"243
(call-with-temp-directory244
(lambda (dir)245
(let ((store (make-folio-store dir)))246
(note-create! store "Top Level" '() "A.")247
(note-create! store "procedures/Deep" '() "B.")248
(let ((notes (all-notes store)))249
(assert-equal 2 (length notes)))))))251
(test "subdirectory note has relative name"252
(call-with-temp-directory253
(lambda (dir)254
(let ((store (make-folio-store dir)))255
(note-create! store "contacts/Alice" '() "Alice info.")256
(let ((notes (all-notes store)))257
(assert-equal "contacts/alice"258
(folio-note-name (car notes)))))))))260
;; ============================================================261
;; orphaned notes262
;; ============================================================264
(test-group "orphaned notes"265
(test "detects notes with no links"266
(call-with-temp-directory267
(lambda (dir)268
(let ((store (make-folio-store dir)))269
(note-create! store "Connected A" '() "See [[connected-b]].")270
(note-create! store "Connected B" '() "See [[connected-a]].")271
(note-create! store "Orphan" '() "All alone.")272
(let ((orphans (find-orphaned-notes store)))273
(assert-equal 1 (length orphans))274
(assert-equal "orphan" (folio-note-name (car orphans))))))))276
(test "no orphans when all linked"277
(call-with-temp-directory278
(lambda (dir)279
(let ((store (make-folio-store dir)))280
(note-create! store "A" '() "See [[b]].")281
(note-create! store "B" '() "See [[a]].")282
(assert-equal 0 (length (find-orphaned-notes store))))))))284
;; ============================================================285
;; unified link resolution286
;; ============================================================288
(test-group "link resolution"289
(test "resolves note links"290
(call-with-temp-directory291
(lambda (dir)292
(let ((store (make-folio-store dir)))293
(note-create! store "Target" '() "Content.")294
(assert-equal 'note (link-target-type store "target"))))))296
(test "resolves broken links"297
(call-with-temp-directory298
(lambda (dir)299
(let ((store (make-folio-store dir)))300
(assert-equal 'broken (link-target-type store "nonexistent")))))))302
;; ============================================================303
;; note-patch!304
;; ============================================================306
(test-group "note-patch!"307
(test "single patch replaces old with new"308
(call-with-temp-directory309
(lambda (dir)310
(let* ((store (make-folio-store dir))311
(file (note-create! store "Patch Test" '("test") "Hello world. Goodbye world.")))312
(let-values (((old-body new-body)313
(note-patch! file (list (cons "Hello" "Hi")))))314
(assert-true (string-contains? old-body "Hello world"))315
(assert-true (string-contains? new-body "Hi world"))316
(assert-false (string-contains? new-body "Hello world")))317
;; Verify file was actually written318
(let ((note (read-note file)))319
(assert-true (string-contains? (folio-note-body note) "Hi world")))))))321
(test "multiple patches applied in order"322
(call-with-temp-directory323
(lambda (dir)324
(let* ((store (make-folio-store dir))325
(file (note-create! store "Multi Patch" '("test") "AAA BBB CCC.")))326
(let-values (((old-body new-body)327
(note-patch! file (list (cons "AAA" "XXX")328
(cons "BBB" "YYY")))))329
(assert-true (string-contains? new-body "XXX"))330
(assert-true (string-contains? new-body "YYY"))331
(assert-true (string-contains? new-body "CCC")))))))333
(test "error when old string not found"334
(call-with-temp-directory335
(lambda (dir)336
(let* ((store (make-folio-store dir))337
(file (note-create! store "Missing Patch" '("test") "Some content.")))338
(assert-error339
(note-patch! file (list (cons "nonexistent text" "replacement"))))))))341
(test "error when old string matches multiple times"342
(call-with-temp-directory343
(lambda (dir)344
(let* ((store (make-folio-store dir))345
(file (note-create! store "Ambiguous Patch" '("test") "foo bar foo baz.")))346
(assert-error347
(note-patch! file (list (cons "foo" "qux"))))))))349
(test "patches update last-updated"350
(call-with-temp-directory351
(lambda (dir)352
(let* ((store (make-folio-store dir))353
(file (note-create! store "Date Patch" '("test") "Original text.")))354
(note-patch! file (list (cons "Original" "Updated")))355
(let ((note (read-note file)))356
(assert-true (dict-ref (folio-note-frontmatter note) last-updated: #f)))))))358
(test "patch does not modify frontmatter content"359
(call-with-temp-directory360
(lambda (dir)361
(let* ((store (make-folio-store dir))362
(file (note-create! store "FM Safe" '("important" "test") "Body text here.")))363
(note-patch! file (list (cons "Body text" "New body text")))364
(let ((note (read-note file)))365
(assert-equal 2 (length (note-tags note)))366
(assert-true (string-contains? (folio-note-body note) "New body text"))))))))368
;; ============================================================369
;; parse-patch-entry370
;; ============================================================372
(test-group "parse-patch-entry"373
(test "old/new format returns (old . new) pair"374
(let ((result (parse-patch-entry (dict old: "hello" new: "world"))))375
(assert-equal "hello" (car result))376
(assert-equal "world" (cdr result))))378
(test "match/content format returns (match . content) pair"379
(let ((result (parse-patch-entry (dict match: "hello" content: "world"))))380
(assert-equal "hello" (car result))381
(assert-equal "world" (cdr result))))383
(test "old/new takes precedence over match/content"384
(let ((result (parse-patch-entry (dict old: "aaa" new: "bbb"385
match: "ccc" content: "ddd"))))386
(assert-equal "aaa" (car result))387
(assert-equal "bbb" (cdr result))))389
(test "error when no old or match field"390
(assert-error (parse-patch-entry (dict content: "world"))))392
(test "error when no new or content field"393
(assert-error (parse-patch-entry (dict old: "hello")))))395
;; ============================================================396
;; unescape-content397
;; ============================================================399
(test-group "unescape-content"400
(test "converts literal backslash-n to newline"401
(let ((result (unescape-content "Line one.\\nLine two.")))402
(assert-true (string-contains? result "\n"))403
(assert-true (string-contains? result "Line one."))404
(assert-true (string-contains? result "Line two."))))406
(test "converts literal backslash-t to tab"407
(let ((result (unescape-content "col1\\tcol2")))408
(assert-true (string-contains? result "\t"))))410
(test "converts double backslash to single"411
(let ((result (unescape-content "path\\\\to\\\\file")))412
(assert-equal "path\\to\\file" result)))414
(test "leaves normal text unchanged"415
(assert-equal "hello world" (unescape-content "hello world")))417
(test "handles empty string"418
(assert-equal "" (unescape-content "")))420
(test "preserves actual newlines"421
(assert-equal "line1\nline2" (unescape-content "line1\nline2")))423
(test "multi-line content roundtrip through note-create"424
(call-with-temp-directory425
(lambda (dir)426
(let* ((store (make-folio-store dir))427
;; Simulate MCP content with literal \n sequences428
(escaped-content "# Title\\n\\nParagraph one.\\n\\n## Section\\n\\nParagraph two.")429
(content (unescape-content escaped-content))430
(file (note-create! store "Roundtrip Test" '("test") content)))431
(let ((note (read-note file)))432
;; Body should have actual newlines, not literal \n433
(assert-true (string-contains? (folio-note-body note) "Paragraph one."))434
(assert-true (string-contains? (folio-note-body note) "## Section"))435
(assert-false (string-contains? (folio-note-body note) "\\n"))))))))437
(test-group "note names stay inside the notes directory"438
(test "absolute and parent traversal names are refused for lookup, create and rename"439
(call-with-temp-directory440
(lambda (dir)441
(let ((store (make-folio-store dir)))442
(note-create! store "safe" '() "Original body")443
(for-each444
(lambda (name)445
(assert-error (find-note-file store name))446
(assert-error (note-create! store name '() "Outside body"))447
(assert-error (note-rename! store "safe" name)))448
'("../outside" "nested/../../outside" "/tmp/outside"))449
(assert-true (file-exists? (find-note-file store "safe")))450
(note-create! store "nested/valid" '() "Inside body")451
(assert-true (file-exists? (find-note-file store "nested/valid"))))))))453
(test-group "invalid crosslinks stay unresolved"454
(test "parent, absolute and invalid targets do not abort link resolution"455
(call-with-temp-directory456
(lambda (root)457
(let ((store (make-folio-store root)))458
(note-create! store "Target" '() "Content.")459
(for-each460
(lambda (name)461
(assert-false (resolve-link store name))462
(assert-equal 'broken (link-target-type store name)))463
(list "../x" "nested/../../x" "/tmp/x" ""464
(string-append "x" (string (integer->char 0)))))465
(assert-equal '(("../x" broken) ("target" note))466
(all-links store "See [[../x]] and [[target]].")))))))