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")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
;; ============================================================98
;; crosslink traversal99
;; ============================================================101
(test-group "crosslinks"102
(test "outgoing links extracted from body"103
(call-with-temp-directory104
(lambda (dir)105
(let* ((store (make-folio-store dir))106
(file (note-create! store "Note A" '() "Links to [[note-b]] and [[note-c]].")))107
(let ((note (read-note file)))108
(assert-equal '("note-b" "note-c") (note-outgoing-links note)))))))110
(test "find backlinks"111
(call-with-temp-directory112
(lambda (dir)113
(let ((store (make-folio-store dir)))114
(note-create! store "Alpha" '() "See [[gamma]].")115
(note-create! store "Beta" '() "Also [[gamma]] and [[alpha]].")116
(note-create! store "Gamma" '() "Links to [[alpha]].")117
(let* ((notes (all-notes store))118
(backlinks (find-backlinks "gamma" notes)))119
(assert-equal 2 (length backlinks))))))))121
;; ============================================================122
;; search-notes123
;; ============================================================125
(test-group "search-notes"126
(test "search by text"127
(call-with-temp-directory128
(lambda (dir)129
(let ((store (make-folio-store dir)))130
(note-create! store "JMAP Quirks" '("jmap") "Fastmail deviates from spec.")131
(note-create! store "CalDAV Notes" '("caldav") "Discovery is tricky.")132
(let ((results (search-notes store "Fastmail" #f)))133
(assert-equal 1 (length results))134
(assert-equal "jmap-quirks" (folio-note-name (car results))))))))136
(test "search by tag"137
(call-with-temp-directory138
(lambda (dir)139
(let ((store (make-folio-store dir)))140
(note-create! store "Note A" '("jmap" "email") "Content A.")141
(note-create! store "Note B" '("caldav") "Content B.")142
(let ((results (search-notes store #f "jmap")))143
(assert-equal 1 (length results)))))))145
(test "search by since date returns matching notes"146
(call-with-temp-directory147
(lambda (dir)148
(let ((store (make-folio-store dir)))149
(note-create! store "Today Note" '("test") "Created today.")150
;; All notes created today should match today's date151
(let ((results (search-notes store #f #f since: (format-date (current-second)))))152
(assert-equal 1 (length results))153
(assert-equal "today-note" (folio-note-name (car results))))))))155
(test "search by since date excludes old notes"156
(call-with-temp-directory157
(lambda (dir)158
(let ((store (make-folio-store dir)))159
(note-create! store "Some Note" '("test") "Content.")160
;; A future date should exclude everything161
(let ((results (search-notes store #f #f since: "2099-01-01")))162
(assert-equal 0 (length results)))))))164
(test "search by since date with query and tag"165
(call-with-temp-directory166
(lambda (dir)167
(let ((store (make-folio-store dir)))168
(note-create! store "Alpha" '("jmap") "JMAP content.")169
(note-create! store "Beta" '("caldav") "CalDAV content.")170
(let ((results (search-notes store "JMAP" "jmap" since: (format-date (current-second)))))171
(assert-equal 1 (length results))172
(assert-equal "alpha" (folio-note-name (car results)))))))))174
;; ============================================================175
;; note-rename!176
;; ============================================================178
(test-group "note-rename!"179
(test "renames file and updates crosslinks"180
(call-with-temp-directory181
(lambda (dir)182
(let ((store (make-folio-store dir)))183
(note-create! store "Alpha" '() "Links to [[beta]].")184
(note-create! store "Beta" '() "Links to [[alpha]].")185
;; Rename alpha to gamma186
(assert-true (note-rename! store "alpha" "gamma"))187
;; Old file gone, new file exists188
(assert-false (find-note-file store "alpha"))189
(assert-true (string? (find-note-file store "gamma")))190
;; Crosslink in beta updated191
(let ((beta (read-note (find-note-file store "beta"))))192
(assert-true (string-contains? (folio-note-body beta) "[[gamma]]"))193
(assert-false (string-contains? (folio-note-body beta) "[[alpha]]")))))))195
(test "returns #f for nonexistent note"196
(call-with-temp-directory197
(lambda (dir)198
(let ((store (make-folio-store dir)))199
(assert-false (note-rename! store "nonexistent" "new-name")))))))201
;; ============================================================202
;; subdirectory notes203
;; ============================================================205
(test-group "subdirectory notes"206
(test "create note in subdirectory"207
(call-with-temp-directory208
(lambda (dir)209
(let* ((store (make-folio-store dir))210
(file (note-create! store "procedures/Email Triage" '("procedure") "Triage steps.")))211
(assert-true (file-exists? file))212
(assert-true (string-contains? file "procedures/"))))))214
(test "find note in subdirectory"215
(call-with-temp-directory216
(lambda (dir)217
(let ((store (make-folio-store dir)))218
(note-create! store "procedures/Email Triage" '() "Content.")219
(assert-true (string? (find-note-file store "procedures/email-triage")))))))221
(test "all-notes includes subdirectory notes"222
(call-with-temp-directory223
(lambda (dir)224
(let ((store (make-folio-store dir)))225
(note-create! store "Top Level" '() "A.")226
(note-create! store "procedures/Deep" '() "B.")227
(let ((notes (all-notes store)))228
(assert-equal 2 (length notes)))))))230
(test "subdirectory note has relative name"231
(call-with-temp-directory232
(lambda (dir)233
(let ((store (make-folio-store dir)))234
(note-create! store "contacts/Alice" '() "Alice info.")235
(let ((notes (all-notes store)))236
(assert-equal "contacts/alice"237
(folio-note-name (car notes)))))))))239
;; ============================================================240
;; orphaned notes241
;; ============================================================243
(test-group "orphaned notes"244
(test "detects notes with no links"245
(call-with-temp-directory246
(lambda (dir)247
(let ((store (make-folio-store dir)))248
(note-create! store "Connected A" '() "See [[connected-b]].")249
(note-create! store "Connected B" '() "See [[connected-a]].")250
(note-create! store "Orphan" '() "All alone.")251
(let ((orphans (find-orphaned-notes store)))252
(assert-equal 1 (length orphans))253
(assert-equal "orphan" (folio-note-name (car orphans))))))))255
(test "no orphans when all linked"256
(call-with-temp-directory257
(lambda (dir)258
(let ((store (make-folio-store dir)))259
(note-create! store "A" '() "See [[b]].")260
(note-create! store "B" '() "See [[a]].")261
(assert-equal 0 (length (find-orphaned-notes store))))))))263
;; ============================================================264
;; unified link resolution265
;; ============================================================267
(test-group "link resolution"268
(test "resolves note links"269
(call-with-temp-directory270
(lambda (dir)271
(let ((store (make-folio-store dir)))272
(note-create! store "Target" '() "Content.")273
(assert-equal 'note (link-target-type store "target"))))))275
(test "resolves broken links"276
(call-with-temp-directory277
(lambda (dir)278
(let ((store (make-folio-store dir)))279
(assert-equal 'broken (link-target-type store "nonexistent")))))))281
;; ============================================================282
;; note-patch!283
;; ============================================================285
(test-group "note-patch!"286
(test "single patch replaces old with new"287
(call-with-temp-directory288
(lambda (dir)289
(let* ((store (make-folio-store dir))290
(file (note-create! store "Patch Test" '("test") "Hello world. Goodbye world.")))291
(let-values (((old-body new-body)292
(note-patch! file (list (cons "Hello" "Hi")))))293
(assert-true (string-contains? old-body "Hello world"))294
(assert-true (string-contains? new-body "Hi world"))295
(assert-false (string-contains? new-body "Hello world")))296
;; Verify file was actually written297
(let ((note (read-note file)))298
(assert-true (string-contains? (folio-note-body note) "Hi world")))))))300
(test "multiple patches applied in order"301
(call-with-temp-directory302
(lambda (dir)303
(let* ((store (make-folio-store dir))304
(file (note-create! store "Multi Patch" '("test") "AAA BBB CCC.")))305
(let-values (((old-body new-body)306
(note-patch! file (list (cons "AAA" "XXX")307
(cons "BBB" "YYY")))))308
(assert-true (string-contains? new-body "XXX"))309
(assert-true (string-contains? new-body "YYY"))310
(assert-true (string-contains? new-body "CCC")))))))312
(test "error when old string not found"313
(call-with-temp-directory314
(lambda (dir)315
(let* ((store (make-folio-store dir))316
(file (note-create! store "Missing Patch" '("test") "Some content.")))317
(assert-error318
(note-patch! file (list (cons "nonexistent text" "replacement"))))))))320
(test "error when old string matches multiple times"321
(call-with-temp-directory322
(lambda (dir)323
(let* ((store (make-folio-store dir))324
(file (note-create! store "Ambiguous Patch" '("test") "foo bar foo baz.")))325
(assert-error326
(note-patch! file (list (cons "foo" "qux"))))))))328
(test "patches update last-updated"329
(call-with-temp-directory330
(lambda (dir)331
(let* ((store (make-folio-store dir))332
(file (note-create! store "Date Patch" '("test") "Original text.")))333
(note-patch! file (list (cons "Original" "Updated")))334
(let ((note (read-note file)))335
(assert-true (dict-ref (folio-note-frontmatter note) last-updated: #f)))))))337
(test "patch does not modify frontmatter content"338
(call-with-temp-directory339
(lambda (dir)340
(let* ((store (make-folio-store dir))341
(file (note-create! store "FM Safe" '("important" "test") "Body text here.")))342
(note-patch! file (list (cons "Body text" "New body text")))343
(let ((note (read-note file)))344
(assert-equal 2 (length (note-tags note)))345
(assert-true (string-contains? (folio-note-body note) "New body text"))))))))347
;; ============================================================348
;; parse-patch-entry349
;; ============================================================351
(test-group "parse-patch-entry"352
(test "old/new format returns (old . new) pair"353
(let ((result (parse-patch-entry (dict old: "hello" new: "world"))))354
(assert-equal "hello" (car result))355
(assert-equal "world" (cdr result))))357
(test "match/content format returns (match . content) pair"358
(let ((result (parse-patch-entry (dict match: "hello" content: "world"))))359
(assert-equal "hello" (car result))360
(assert-equal "world" (cdr result))))362
(test "old/new takes precedence over match/content"363
(let ((result (parse-patch-entry (dict old: "aaa" new: "bbb"364
match: "ccc" content: "ddd"))))365
(assert-equal "aaa" (car result))366
(assert-equal "bbb" (cdr result))))368
(test "error when no old or match field"369
(assert-error (parse-patch-entry (dict content: "world"))))371
(test "error when no new or content field"372
(assert-error (parse-patch-entry (dict old: "hello")))))374
;; ============================================================375
;; unescape-content376
;; ============================================================378
(test-group "unescape-content"379
(test "converts literal backslash-n to newline"380
(let ((result (unescape-content "Line one.\\nLine two.")))381
(assert-true (string-contains? result "\n"))382
(assert-true (string-contains? result "Line one."))383
(assert-true (string-contains? result "Line two."))))385
(test "converts literal backslash-t to tab"386
(let ((result (unescape-content "col1\\tcol2")))387
(assert-true (string-contains? result "\t"))))389
(test "converts double backslash to single"390
(let ((result (unescape-content "path\\\\to\\\\file")))391
(assert-equal "path\\to\\file" result)))393
(test "leaves normal text unchanged"394
(assert-equal "hello world" (unescape-content "hello world")))396
(test "handles empty string"397
(assert-equal "" (unescape-content "")))399
(test "preserves actual newlines"400
(assert-equal "line1\nline2" (unescape-content "line1\nline2")))402
(test "multi-line content roundtrip through note-create"403
(call-with-temp-directory404
(lambda (dir)405
(let* ((store (make-folio-store dir))406
;; Simulate MCP content with literal \n sequences407
(escaped-content "# Title\\n\\nParagraph one.\\n\\n## Section\\n\\nParagraph two.")408
(content (unescape-content escaped-content))409
(file (note-create! store "Roundtrip Test" '("test") content)))410
(let ((note (read-note file)))411
;; Body should have actual newlines, not literal \n412
(assert-true (string-contains? (folio-note-body note) "Paragraph one."))413
(assert-true (string-contains? (folio-note-body note) "## Section"))414
(assert-false (string-contains? (folio-note-body note) "\\n"))))))))