AtlatestRepositoryfolio

folio / tree / testtest-append-clobber.sgl

1;; Regression tests for the folio note-edit APPEND CLOBBER data-loss bug.
2;;
3;; Symptom: appending to a note replaced its entire body with only the newly
4;; appended block, leaving just the frontmatter + new section.
5;;
6;; Three compounding defects were involved:
7;; 1. read-frontmatter mis-handled the frontmatter boundary on notes with a
8;; nested `---...---` block in the body whose top fence was malformed,
9;; absorbing real body content into "frontmatter" and truncating the body.
10;; 2. note-create double-wrapped frontmatter when the supplied content itself
11;; began with a `---...---` block, producing the malformed on-disk state.
12;; 3. note-append round-tripped through read/write-frontmatter, persisting any
13;; misparse (and re-encoding frontmatter) -- so a parse glitch became
14;; permanent data loss.
15;;
16;; The fixes: harden read-frontmatter, merge leading frontmatter in note-create,
17;; and make note-append a raw, body-preserving append.
19(import (sigil test)
20 (sigil string)
21 (sigil dict)
22 (sigil fs)
23 (sigil io)
24 (sigil path)
25 (folio store)
26 (folio note)
27 (folio frontmatter))
29;; A note whose BODY legitimately contains a nested `---...---` frontmatter-style
30;; block (this is what folio produces when note-create wraps content that itself
31;; had frontmatter -- daily notes and task briefs both look like this).
32(define (well-formed-nested)
33 (string-append
34 "---\n"
35 "tags: []\n"
36 "created: 2026-06-25\n"
37 "last-updated: 2026-06-25\n"
38 "---\n"
39 "\n"
40 "# 2026-06-25\n"
41 "\n"
42 "---\n"
43 "last-updated: 2026-06-25\n"
44 "---\n"
45 "\n"
46 "## PRIMER\n"
47 "Keep this primer text.\n"
48 "\n"
49 "## Briefing\n"
50 "Keep this briefing text.\n"))
52;; ============================================================
53;; Fix 1: read-frontmatter boundary hardening
54;; ============================================================
56(test-group "read-frontmatter boundary hardening"
57 ;; Well-formed note with a nested block: body fully preserved, frontmatter ok.
58 (test "well-formed nested block keeps full body"
59 (let-values (((fm body) (read-frontmatter (well-formed-nested))))
60 (assert-true (string-contains? body "# 2026-06-25"))
61 (assert-true (string-contains? body "## PRIMER"))
62 (assert-true (string-contains? body "## Briefing"))
63 (assert-equal "2026-06-25" (dict-ref fm created: #f))))
65 ;; Malformed: the TOP closing fence is missing, so the first `---` the old
66 ;; parser would find is the nested block's opener. Before the fix it absorbed
67 ;; the `# 2026-06-25` heading + primer into "frontmatter" and truncated the
68 ;; body. After the fix it bails on the heading and keeps everything as body.
69 (test "missing top fence does not absorb body (no data loss)"
70 (let ((malformed
71 (string-append
72 "---\n"
73 "tags: []\n"
74 "created: 2026-06-25\n"
75 ;; <- no closing --- here
76 "\n"
77 "# 2026-06-25\n"
78 "\n"
79 "---\n"
80 "last-updated: 2026-06-25\n"
81 "---\n"
82 "\n"
83 "## PRIMER\n"
84 "Keep this primer text.\n")))
85 (let-values (((fm body) (read-frontmatter malformed)))
86 ;; All real content must survive in the body, nothing destroyed.
87 (assert-true (string-contains? body "# 2026-06-25"))
88 (assert-true (string-contains? body "## PRIMER"))
89 (assert-true (string-contains? body "Keep this primer text."))))))
91;; ============================================================
92;; Fix 3: note-append is raw and body-preserving
93;; ============================================================
95(test-group "note-append! preserves body"
96 (test "append to nested-block note keeps all prior content"
97 (call-with-temp-directory
98 (lambda (dir)
99 (let ((file (path-join dir "daily.md")))
100 (write-file-string file (well-formed-nested))
101 (note-append! file "\n## New Section\nFresh appended content.\n")
102 (let ((after (read-file-string file)))
103 (assert-true (string-contains? after "## PRIMER"))
104 (assert-true (string-contains? after "## Briefing"))
105 (assert-true (string-contains? after "Keep this primer text."))
106 (assert-true (string-contains? after "Fresh appended content.")))))))
108 (test "three successive appends never clobber"
109 (call-with-temp-directory
110 (lambda (dir)
111 (let ((file (path-join dir "daily.md")))
112 (write-file-string file (well-formed-nested))
113 (note-append! file "\n## A\nAlpha.\n")
114 (note-append! file "\n## B\nBeta.\n")
115 (note-append! file "\n## C\nGamma.\n")
116 (let ((after (read-file-string file)))
117 (assert-true (string-contains? after "Keep this primer text."))
118 (assert-true (string-contains? after "Alpha."))
119 (assert-true (string-contains? after "Beta."))
120 (assert-true (string-contains? after "Gamma.")))))))
122 ;; Even against a malformed on-disk note (missing top fence), append must not
123 ;; destroy existing content -- the raw append preserves every byte.
124 (test "append to malformed note does not shrink it"
125 (call-with-temp-directory
126 (lambda (dir)
127 (let* ((file (path-join dir "n.md"))
128 (malformed (string-append
129 "---\n"
130 "tags: []\n"
131 "created: 2026-06-25\n"
132 "\n"
133 "# Heading\n"
134 "\n"
135 "---\n"
136 "k: v\n"
137 "---\n"
138 "\n"
139 "## Section\n"
140 "Important body that must survive.\n")))
141 (write-file-string file malformed)
142 (note-append! file "\n## Appended\nNew line.\n")
143 (let ((after (read-file-string file)))
144 ;; Every original line preserved verbatim, plus the new text.
145 (assert-true (string-contains? after "# Heading"))
146 (assert-true (string-contains? after "## Section"))
147 (assert-true (string-contains? after "Important body that must survive."))
148 (assert-true (string-contains? after "New line."))
149 ;; The append never makes the file smaller.
150 (assert-true (> (string-length after) (string-length malformed))))))))
152 (test "append bumps last-updated in place"
153 (call-with-temp-directory
154 (lambda (dir)
155 (let ((file (path-join dir "n.md")))
156 (write-file-string file
157 "---\ntags: []\ncreated: 2026-01-01\nlast-updated: 2026-01-01\n---\n\n# Note\n\nBody.\n")
158 (note-append! file "\nMore.\n")
159 (let ((note (read-note file)))
160 ;; created untouched, last-updated bumped to today (not 2026-01-01),
161 ;; body fully preserved.
162 (assert-equal "2026-01-01" (dict-ref (folio-note-frontmatter note) created: #f))
163 (assert-false (equal? "2026-01-01" (note-last-updated note)))
164 (assert-true (string-contains? (folio-note-body note) "Body."))
165 (assert-true (string-contains? (folio-note-body note) "More.")))))))
167 (test "append adds last-updated when frontmatter lacks it"
168 (call-with-temp-directory
169 (lambda (dir)
170 (let ((file (path-join dir "n.md")))
171 (write-file-string file "---\ntags: []\ncreated: 2026-01-01\n---\n\n# Note\n\nBody.\n")
172 (note-append! file "\nMore.\n")
173 (let ((note (read-note file)))
174 (assert-true (string? (note-last-updated note)))
175 (assert-true (string-contains? (folio-note-body note) "Body."))
176 (assert-true (string-contains? (folio-note-body note) "More."))))))))
178;; ============================================================
179;; Fix 2: note-create merges leading frontmatter (no double-wrap)
180;; ============================================================
182(test-group "note-create! merges leading frontmatter"
183 (test "content with its own frontmatter does not double-wrap"
184 (call-with-temp-directory
185 (lambda (dir)
186 (let* ((store (make-folio-store dir))
187 (content (string-append
188 "---\n"
189 "tags:\n - task-brief\n - folio\n"
190 "status: assigned\n"
191 "---\n"
192 "\n"
193 "# Real Title\n"
194 "\n"
195 "Real body content.\n"))
196 (file (note-create! store "tasks/example" '() content)))
197 (let ((raw (read-file-string file)))
198 ;; Exactly ONE frontmatter block: the file has a single `---\n...\n---`
199 ;; opener/closer pair at the top, never two adjacent or nested-as-fm.
200 (assert-false (string-contains? raw "---\n---\n"))
201 ;; The note must parse cleanly: frontmatter fields lifted up.
202 (let ((note (read-note file)))
203 (assert-true (member "task-brief" (note-tags note)))
204 (assert-true (member "folio" (note-tags note)))
205 (assert-equal "assigned"
206 (dict-ref (folio-note-frontmatter note) status: #f))
207 (assert-true (string? (note-created note)))
208 ;; Body keeps the real content; the inner `---` block is gone
209 ;; (merged), so the body has no leftover frontmatter fences.
210 (assert-true (string-contains? (folio-note-body note) "Real body content."))))))))
212 (test "create-then-append on frontmatter content never clobbers"
213 (call-with-temp-directory
214 (lambda (dir)
215 (let* ((store (make-folio-store dir))
216 (content "---\ntags:\n - brief\n---\n\n# Title\n\nFirst body.\n")
217 (file (note-create! store "tasks/cascade" '() content)))
218 (note-append! file "\n## Update One\nSecond body.\n")
219 (note-append! file "\n## Update Two\nThird body.\n")
220 (let ((after (read-file-string file)))
221 (assert-true (string-contains? after "First body."))
222 (assert-true (string-contains? after "Second body."))
223 (assert-true (string-contains? after "Third body.")))))))
225 (test "plain content (no leading frontmatter) is unchanged behavior"
226 (call-with-temp-directory
227 (lambda (dir)
228 (let* ((store (make-folio-store dir))
229 (file (note-create! store "Plain Note" '("x") "Just text.")))
230 (let ((note (read-note file)))
231 (assert-true (string-contains? (folio-note-body note) "Just text."))
232 (assert-true (member "x" (note-tags note)))))))))
234;; ============================================================
235;; Tags arrive from the MCP/JSON layer as an *array*, not a list.
236;; An empty array is not `null?`, and YAML-encoding it produced a malformed
237;; `tags:` block; it also wrongly suppressed the content's own tags.
238;; ============================================================
240(test-group "note-create! normalizes array tags"
241 (test "empty array tags falls back to content frontmatter tags"
242 (call-with-temp-directory
243 (lambda (dir)
244 (let* ((store (make-folio-store dir))
245 (content "---\ntags:\n - alpha\n - beta\n---\n\n# Title\n\nBody.\n")
246 (file (note-create! store "tasks/arr" (list->array '()) content))
247 (note (read-note file))
248 (raw (read-file-string file)))
249 (assert-true (member "alpha" (note-tags note)))
250 (assert-true (member "beta" (note-tags note)))
251 ;; And no malformed empty-list frontmatter line on disk.
252 (assert-false (string-contains? raw "\n[]\n"))))))
254 (test "non-empty array tags are used"
255 (call-with-temp-directory
256 (lambda (dir)
257 (let* ((store (make-folio-store dir))
258 (file (note-create! store "tasks/arr2" (list->array '("one" "two"))
259 "Plain body."))
260 (note (read-note file)))
261 (assert-true (member "one" (note-tags note)))
262 (assert-true (member "two" (note-tags note))))))))
264;; ============================================================
265;; prepend/replace/patch sanity (must remain unaffected)
266;; ============================================================
268(test-group "other mutators unaffected"
269 (test "note-edit! (replace) still replaces body, keeps frontmatter"
270 (call-with-temp-directory
271 (lambda (dir)
272 (let* ((store (make-folio-store dir))
273 (file (note-create! store "R" '("t") "Original.")))
274 (note-edit! file "\n# R\n\nReplaced body.\n")
275 (let ((note (read-note file)))
276 (assert-true (string-contains? (folio-note-body note) "Replaced body."))
277 (assert-false (string-contains? (folio-note-body note) "Original."))
278 (assert-true (member "t" (note-tags note))))))))
280 (test "note-patch! still preserves frontmatter and body"
281 (call-with-temp-directory
282 (lambda (dir)
283 (let* ((store (make-folio-store dir))
284 (file (note-create! store "P" '("a" "b") "Hello world.")))
285 (note-patch! file (list (cons "Hello" "Hi")))
286 (let ((note (read-note file)))
287 (assert-true (string-contains? (folio-note-body note) "Hi world."))
288 (assert-equal 2 (length (note-tags note)))))))))