AtlatestRepositoryfolio

folio / tree / testtest-note.sgl

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-crosslinks
16;; ============================================================
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-note
49;; ============================================================
51(test-group "note-create!"
52 (test "creates note with frontmatter and content"
53 (call-with-temp-directory
54 (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/dotfiles
65;; ============================================================
67(test-group "store-note-files dotfile filtering"
68 (test "excludes .# Emacs lock files, keeps real notes"
69 (call-with-temp-directory
70 (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 the
74 ;; pre-fix walk included it, then index-note! crashed reading the
75 ;; (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-directory
89 (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-directory
99 (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-directory
110 (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 traversal
120;; ============================================================
122(test-group "crosslinks"
123 (test "outgoing links extracted from body"
124 (call-with-temp-directory
125 (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-directory
133 (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-notes
144;; ============================================================
146(test-group "search-notes"
147 (test "search by text"
148 (call-with-temp-directory
149 (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-directory
159 (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-directory
168 (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 date
172 (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-directory
178 (lambda (dir)
179 (let ((store (make-folio-store dir)))
180 (note-create! store "Some Note" '("test") "Content.")
181 ;; A future date should exclude everything
182 (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-directory
187 (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-directory
202 (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 gamma
207 (assert-true (note-rename! store "alpha" "gamma"))
208 ;; Old file gone, new file exists
209 (assert-false (find-note-file store "alpha"))
210 (assert-true (string? (find-note-file store "gamma")))
211 ;; Crosslink in beta updated
212 (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-directory
218 (lambda (dir)
219 (let ((store (make-folio-store dir)))
220 (assert-false (note-rename! store "nonexistent" "new-name")))))))
222;; ============================================================
223;; subdirectory notes
224;; ============================================================
226(test-group "subdirectory notes"
227 (test "create note in subdirectory"
228 (call-with-temp-directory
229 (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-directory
237 (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-directory
244 (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-directory
253 (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 notes
262;; ============================================================
264(test-group "orphaned notes"
265 (test "detects notes with no links"
266 (call-with-temp-directory
267 (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-directory
278 (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 resolution
286;; ============================================================
288(test-group "link resolution"
289 (test "resolves note links"
290 (call-with-temp-directory
291 (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-directory
298 (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-directory
309 (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 written
318 (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-directory
323 (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-directory
335 (lambda (dir)
336 (let* ((store (make-folio-store dir))
337 (file (note-create! store "Missing Patch" '("test") "Some content.")))
338 (assert-error
339 (note-patch! file (list (cons "nonexistent text" "replacement"))))))))
341 (test "error when old string matches multiple times"
342 (call-with-temp-directory
343 (lambda (dir)
344 (let* ((store (make-folio-store dir))
345 (file (note-create! store "Ambiguous Patch" '("test") "foo bar foo baz.")))
346 (assert-error
347 (note-patch! file (list (cons "foo" "qux"))))))))
349 (test "patches update last-updated"
350 (call-with-temp-directory
351 (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-directory
360 (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-entry
370;; ============================================================
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-content
397;; ============================================================
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-directory
425 (lambda (dir)
426 (let* ((store (make-folio-store dir))
427 ;; Simulate MCP content with literal \n sequences
428 (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 \n
433 (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-directory
440 (lambda (dir)
441 (let ((store (make-folio-store dir)))
442 (note-create! store "safe" '() "Original body")
443 (for-each
444 (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-directory
456 (lambda (root)
457 (let ((store (make-folio-store root)))
458 (note-create! store "Target" '() "Content.")
459 (for-each
460 (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]].")))))))