AtlatestRepositoryfolio
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 newly4
;; 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 a8
;; 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 itself11
;; began with a `---...---` block, producing the malformed on-disk state.12
;; 3. note-append round-tripped through read/write-frontmatter, persisting any13
;; misparse (and re-encoding frontmatter) -- so a parse glitch became14
;; 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-style30
;; block (this is what folio produces when note-create wraps content that itself31
;; had frontmatter -- daily notes and task briefs both look like this).32
(define (well-formed-nested)33
(string-append34
"---\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 hardening54
;; ============================================================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 old66
;; parser would find is the nested block's opener. Before the fix it absorbed67
;; the `# 2026-06-25` heading + primer into "frontmatter" and truncated the68
;; 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 ((malformed71
(string-append72
"---\n"73
"tags: []\n"74
"created: 2026-06-25\n"75
;; <- no closing --- here76
"\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-preserving93
;; ============================================================95
(test-group "note-append! preserves body"96
(test "append to nested-block note keeps all prior content"97
(call-with-temp-directory98
(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-directory110
(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 not123
;; destroy existing content -- the raw append preserves every byte.124
(test "append to malformed note does not shrink it"125
(call-with-temp-directory126
(lambda (dir)127
(let* ((file (path-join dir "n.md"))128
(malformed (string-append129
"---\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-directory154
(lambda (dir)155
(let ((file (path-join dir "n.md")))156
(write-file-string file157
"---\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-directory169
(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-directory185
(lambda (dir)186
(let* ((store (make-folio-store dir))187
(content (string-append188
"---\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 gone209
;; (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-directory214
(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-directory227
(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 malformed237
;; `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-directory243
(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-directory256
(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-directory271
(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-directory282
(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)))))))))