oxios_markdown/frontformat.rs
1//! Vault write-side adapter for `oxi-frontmatter`.
2//!
3//! Once the `knowledge.rs` migration lands (Tasks 12/13/19), this
4//! module will be the **sole** producer of `oxios:` frontmatter in
5//! `oxios-markdown`; until then `knowledge.rs::note_write_with_meta`
6//! still emits its own block and the two paths may overlap. The
7//! frontformat module exists to consolidate the two responsibilities
8//! §6 of the vault-unification design separates:
9//!
10//! 1. **System-path exclusion** — `Chat.md`, `Later.md`, `Done.md`,
11//! `Shop.md`, `Watch.md`, `Read.md`, `journal/`, `habits/`,
12//! `insights/`, `archive/`, `media/`, `img/`, and any non-`.md`
13//! file must NEVER carry frontmatter. Even if the caller passed
14//! body bytes that look like a memo, we treat them as raw bytes
15//! and write them through `atomic_write`.
16//!
17//! 2. **Memo-path merge-write** — for first-class documents
18//! (e.g. `brain/Rust.md`), `write_note` round-trips through
19//! `oxi-frontmatter::write_document` so the file's `id`,
20//! `created`, and `updated` invariants are kept consistent, and
21//! editor-supplied keys (Obsidian tags, aliases, etc.) are
22//! preserved.
23//!
24//! # Layering
25//!
26//! This module is a thin policy layer over `oxi-frontmatter`. It
27//! does **not** re-implement merge logic — it routes every memo
28//! write through `oxi-frontmatter::write_document`, which is the
29//! canonical writer per the spec.
30//!
31//! Backed by `oxi-frontmatter` v0.1 (`grammar v2`). See
32//! `oximemo-vault-unification/crates/oxi-frontmatter/SPEC.md` for
33//! the underlying grammar.
34
35use std::path::Path;
36
37use oxi_frontmatter::{
38 FrontmatterError, NoteFormat, Parsed, Synthesize, Table, Value, WriteOutcome, atomic_write,
39 emit, parse, write_document,
40};
41use time::OffsetDateTime;
42
43use crate::types::{
44 CHAT_FILENAME, DIR_ARCHIVE, DIR_HABITS, DIR_INSIGHTS, DIR_JOURNAL, DIR_MEDIA, DONE_FILENAME,
45 LATER_FILENAME, MD_EXT, NoteMeta, READ_FILENAME, SHOP_FILENAME, WATCH_FILENAME,
46};
47
48// ---------------------------------------------------------------------------
49// Path hardening
50// ---------------------------------------------------------------------------
51
52/// Reject `rel` paths that could escape `root` when joined.
53///
54/// Mirrors `VirtualFs::safe_path` (fs.rs:95-117): rejects `..`
55/// segments, leading `/` or `\` (absolute), and embedded null bytes.
56/// Returns `Err(FsError::UnsafePath)` for any traversal attempt so the
57/// caller cannot bypass the §6 system-path exclusion by passing
58/// `../Chat.md` or `/etc/passwd` as a relative path.
59fn assert_safe_rel(rel_path: &str) -> Result<(), FrontmatterError> {
60 if rel_path.is_empty()
61 || rel_path.starts_with('/')
62 || rel_path.starts_with('\\')
63 || rel_path.starts_with("..")
64 || rel_path.contains("/../")
65 || rel_path.contains("\\..\\")
66 || rel_path.contains('\0')
67 {
68 return Err(FrontmatterError::Io(std::io::Error::new(
69 std::io::ErrorKind::InvalidInput,
70 format!("unsafe path: {rel_path:?}"),
71 )));
72 }
73 Ok(())
74}
75
76// ---------------------------------------------------------------------------
77// System-path exclusion set (§6 of the vault-unification design)
78// ---------------------------------------------------------------------------
79
80/// First path component that disqualifies a path from receiving
81/// frontmatter, alongside the file-level constants in `types.rs`.
82const SYSTEM_DIRS: &[&str] = &[
83 DIR_ARCHIVE,
84 DIR_JOURNAL,
85 DIR_HABITS,
86 DIR_INSIGHTS,
87 DIR_MEDIA,
88 "img",
89];
90
91/// Filenames that — **anchored to the vault root only** — must never
92/// carry frontmatter. Review F5 of the design notes that filename
93/// equality must be root-anchored so a user memo named `Chat.md`
94/// inside a folder is not silently treated as a system file.
95const SYSTEM_FILES_ROOT: &[&str] = &[
96 CHAT_FILENAME,
97 LATER_FILENAME,
98 DONE_FILENAME,
99 SHOP_FILENAME,
100 WATCH_FILENAME,
101 READ_FILENAME,
102];
103
104/// Returns `true` if `rel_path` must never carry frontmatter.
105///
106/// "System path" means ANY of:
107///
108/// 1. `rel_path` is not a `.md` file (raw bytes, images).
109/// 2. The first path component is one of the SF-D-2 reserved
110/// directories (`archive/`, `journal/`, `habits/`, `insights/`,
111/// `media/`, `img/`).
112/// 3. `rel_path` is exactly one of the inboxes / lists that the app
113/// treats as "infrastructure" (`Chat.md`, `Later.md`, `Done.md`,
114/// `Shop.md`, `Watch.md`, `Read.md`). The match is on the
115/// full path so a user memo named `Chat.md` inside a folder is
116/// NOT classified as system — see review F5.
117///
118/// A user note called `brain/Rust.md` returns `false` — first-class
119/// documents always carry frontmatter.
120pub fn is_system_path(rel_path: &str) -> bool {
121 // Non-markdown files are always raw: the parser is markdown-only,
122 // and images / non-md payloads have no frontmatter concept.
123 if !rel_path.ends_with(MD_EXT) {
124 return true;
125 }
126
127 let first = rel_path.split('/').next().unwrap_or(rel_path);
128
129 // Root-anchored exact-match: the entire rel_path equals one of
130 // the system filenames (no leading directory component).
131 if SYSTEM_FILES_ROOT.contains(&rel_path) {
132 return true;
133 }
134
135 // Directory match considers the first component only.
136 if SYSTEM_DIRS.contains(&first) {
137 return true;
138 }
139
140 false
141}
142
143/// Read RFC-022 [`NoteMeta`] from the `oxios:` table of a note.
144///
145/// Returns `None` when the file:
146///
147/// - has no frontmatter block at all (`BodyOnly` notes),
148/// - has a frontmatter block but no `oxios:` key (user-authored
149/// frontmatter, e.g. Obsidian tags — we never touch these).
150///
151/// **Malformed frontmatter** is a hard error propagated via
152/// `Err(FrontmatterError::Parse)` — the oxi-frontmatter spec says
153/// we don't silently repair, and the contract is the same here.
154///
155/// **Inside a valid `oxios:` map, individual fields that fail to
156/// decode** (unknown enum variant, non-string scalar where a string
157/// is required) fall back to the documented defaults: missing
158/// `author` → `""`, missing `quality` → `Raw`, missing `source` →
159/// `Hook`, missing `needs_review` → `false`. A malformed scalar
160/// never prevents the surrounding note from being recognized.
161pub fn read_note_meta(content: &str) -> Result<Option<NoteMeta>, FrontmatterError> {
162 let parsed = parse(content, NoteFormat::Markdown)?;
163 Ok(match parsed {
164 Parsed::Memo { table, .. } => table_to_note_meta(&table),
165 Parsed::BodyOnly { .. } => None,
166 })
167}
168
169/// Read a note's body with any frontmatter block stripped (v4 grammar).
170///
171/// The body-only counterpart of [`read_note_meta`] for consumers that
172/// need the markdown body — e.g. the curation scan feeding the LLM:
173/// `Parsed::Memo` yields the verbatim body after the closing fence,
174/// `BodyOnly` content is returned as-is. Malformed frontmatter is a
175/// hard [`FrontmatterError::Parse`] — unlike the legacy bespoke
176/// parser in `knowledge.rs`, which silently returned the full file
177/// (frontmatter included) as the body on any parse miss.
178pub fn read_note_body(content: &str) -> Result<String, FrontmatterError> {
179 Ok(match parse(content, NoteFormat::Markdown)? {
180 Parsed::Memo { body, .. } => body,
181 Parsed::BodyOnly { body } => body,
182 })
183}
184
185/// Serialize full file content with an `oxios:` table merged in.
186///
187/// `content` is whatever the caller wants the file to look like —
188/// existing frontmatter, a plain body, or a complete note. The
189/// returned `String` is the canonical form (starts with `---\n`,
190/// contains an `oxios:` table, then the body).
191///
192/// Errors:
193/// - [`FrontmatterError::Unemittable`] — the merged table contains
194/// a shape the parser cannot re-read (e.g. an empty `Array`).
195/// - [`FrontmatterError::Parse`] — the caller's `content` is
196/// malformed frontmatter that we cannot safely merge.
197pub fn with_oxios_table(content: &str, meta: &NoteMeta) -> Result<String, FrontmatterError> {
198 let (incoming_table, body) = match parse(content, NoteFormat::Markdown)? {
199 Parsed::Memo { table, body } => (table, body),
200 Parsed::BodyOnly { body } => (Table::new(), body),
201 };
202
203 let mut merged = incoming_table;
204 merge_note_meta(&mut merged, meta);
205
206 Ok(emit(&merged, &body, NoteFormat::Markdown))
207}
208
209/// Write a note to `root / rel` using the §6 exclusion rule.
210///
211/// Path hardening: `rel` must be a vault-relative POSIX path
212/// (no `..`, no leading `/`/`\`, no null). Otherwise we return
213/// `Err(FrontmatterError::Io(InvalidInput))` without touching the
214/// filesystem (mirrors `VirtualFs::safe_path`).
215///
216/// - **System path** (`Chat.md`, anything non-`.md`, anything inside
217/// a SYSTEM_DIR) → raw `atomic_write`. We never synthesize
218/// frontmatter. If the bytes match the existing file, we return
219/// `WriteOutcome::NoOp` without touching the file.
220///
221/// - **Memo path** → routed through `oxi-frontmatter::write_document`
222/// with the body's frontmatter pre-parsed:
223///
224/// * If `content` parses as `Memo{table, body}` (caller supplied a
225/// frontmatter block), we write `body` to the file with the
226/// **incoming table as base** — `write_document` carries its
227/// `id`/`created`/unknown keys forward, and our `oxios:` map is
228/// merged in alongside them. This is how editor-supplied tags
229/// or aliases survive a knowledge-write pass.
230///
231/// * If `content` parses as `BodyOnly`, the whole `content` is
232/// the body and the file's **existing table** becomes the base
233/// (write_document reads it). This is the "missing
234/// frontmatter ⇒ synthesize" path.
235///
236/// In both cases `write_document` synthesizes `id`/`created` if
237/// the resulting table lacks them, bumps `updated` only on a real
238/// write, and returns `WriteOutcome::NoOp` when the parsed form
239/// is identical to what was on disk.
240///
241/// `now` is injected for testability.
242pub fn write_note(
243 root: &Path,
244 rel: &str,
245 content: &str,
246 now: OffsetDateTime,
247) -> Result<WriteOutcome, FrontmatterError> {
248 assert_safe_rel(rel)?;
249
250 let path = root.join(rel);
251 if is_system_path(rel) {
252 // Raw atomic write. NoOp on byte-identical content.
253 let existing = std::fs::read(&path).ok();
254 if existing.as_deref() == Some(content.as_bytes()) {
255 return Ok(WriteOutcome::NoOp);
256 }
257 atomic_write(&path, content.as_bytes())?;
258 return Ok(WriteOutcome::Written);
259 }
260
261 // Memo path. The incoming content's frontmatter block is the
262 // *primary* source of truth for the merge base (the brief's
263 // review-mandated behavior — see round-1 finding #1), but we
264 // also carry forward unknown keys from the existing file so an
265 // editor-supplied `custom_key: hello` survives a write that
266 // happens after a previous edit added `legacy_key: kept`.
267 match parse(content, NoteFormat::Markdown)? {
268 Parsed::Memo {
269 table: incoming_table,
270 body,
271 } => write_memo_with_incoming_table(&path, incoming_table, &body, now),
272 Parsed::BodyOnly { body } => {
273 // No frontmatter block at all in the caller's content:
274 // the file's existing table is the merge base, and
275 // write_document will synthesize id/created/updated if
276 // the file is missing or BodyOnly.
277 write_document(
278 &path,
279 &body,
280 NoteFormat::Markdown,
281 oxi_frontmatter::Mutation::default(),
282 Synthesize::Yes,
283 now,
284 )
285 }
286 }
287}
288
289/// Merge-write a memo file where the caller supplied a frontmatter
290/// block. The incoming table becomes the merge base; unknown keys
291/// from the existing file are carried forward; `id` and `created`
292/// are synthesized if either side lacks them; `updated` is set to
293/// `now` only on a real write.
294fn write_memo_with_incoming_table(
295 path: &Path,
296 incoming_table: Table,
297 body: &str,
298 now: OffsetDateTime,
299) -> Result<WriteOutcome, FrontmatterError> {
300 // Read the existing file once and reuse for both the merge
301 // base and the NoOp probe. Missing file => empty table.
302 let existing_parsed: Option<Parsed> = match std::fs::read(path) {
303 Ok(b) => match std::str::from_utf8(&b) {
304 Ok(s) => Some(parse(s, NoteFormat::Markdown)?),
305 Err(_) => {
306 return Err(FrontmatterError::Io(std::io::Error::new(
307 std::io::ErrorKind::InvalidData,
308 format!("file at {} is not valid UTF-8", path.display()),
309 )));
310 }
311 },
312 Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
313 Err(e) => return Err(FrontmatterError::Io(e)),
314 };
315
316 let mut next_table: Table = match &existing_parsed {
317 Some(Parsed::Memo { table, .. }) => table.clone(),
318 _ => Table::new(),
319 };
320 for (k, v) in incoming_table {
321 next_table.insert(k, v);
322 }
323 if !next_table.contains_key("id") {
324 next_table.insert(
325 "id".to_string(),
326 Value::Str(uuid::Uuid::now_v7().to_string()),
327 );
328 }
329 if !next_table.contains_key("created") {
330 next_table.insert("created".to_string(), Value::Str(format_offset(now)));
331 }
332 // `updated` is deliberately NOT set here: the candidate table
333 // keeps whatever `updated` the merged base carries (the on-disk
334 // value overlaid by the incoming table), so the semantic-NoOp
335 // probe below compares apples to apples. Mirrors
336 // `oxi_frontmatter::write_document` step 3. The old code inserted
337 // `updated = now` BEFORE the probe, so with a per-call clock
338 // (production injects `now_utc()` per invocation) the probe
339 // always differed and NoOp was unreachable for frontmatter-bearing
340 // content — every unchanged editor re-save rewrote the file,
341 // bumped `updated`, re-canonicalized foreign formatting, and
342 // fired git auto-commit + brain episodes (whole-branch P1).
343
344 // Semantic NoOp (round-2 fix): the probe must compare the
345 // *incoming* body against the on-disk body, NOT reuse the
346 // existing body in the emission. Otherwise a body edit can
347 // return NoOp whenever the merged table happens to match the
348 // on-disk table (e.g. when `now` equals the previous
349 // `updated`) and the edit is silently dropped. NoOp only when
350 // BOTH the table AND the incoming body are semantically
351 // unchanged. The probe MUST use the *incoming* `body`, not the
352 // existing body, otherwise a body change can return NoOp whenever
353 // the merged table happens to equal the on-disk table.
354 let same = match &existing_parsed {
355 Some(Parsed::Memo { table: t, body: b }) => {
356 let probe = emit(&next_table, body, NoteFormat::Markdown);
357 match parse(&probe, NoteFormat::Markdown) {
358 Ok(Parsed::Memo {
359 table: t2,
360 body: b2,
361 }) => t == &t2 && b == &b2,
362 _ => false,
363 }
364 }
365 _ => false,
366 };
367 if same {
368 return Ok(WriteOutcome::NoOp);
369 }
370
371 // Real write — bump `updated` to `now` ONLY now (after the probe
372 // passed), mirroring `write_document` step 6: a true NoOp never
373 // bumps it.
374 next_table.insert("updated".to_string(), Value::Str(format_offset(now)));
375
376 let new_bytes = emit(&next_table, body, NoteFormat::Markdown).into_bytes();
377 atomic_write(path, &new_bytes)?;
378 Ok(WriteOutcome::Written)
379}
380
381/// Format an `OffsetDateTime` as an RFC3339 string. Cannot fail for
382/// well-formed inputs — panicking is the loud-but-safe choice.
383fn format_offset(t: OffsetDateTime) -> String {
384 t.format(&time::format_description::well_known::Rfc3339)
385 .expect("RFC3339 formatting of OffsetDateTime cannot fail")
386}
387
388// ---------------------------------------------------------------------------
389// Internal helpers
390// ---------------------------------------------------------------------------
391
392/// Convert an `oxios:` table to [`NoteMeta`].
393///
394/// Returns `None` if the table has no `oxios` key — caller treats
395/// that as "user-authored, no agent provenance". Unparseable scalar
396/// fields default per the documented fallback rules on [`read_note_meta`].
397fn table_to_note_meta(table: &Table) -> Option<NoteMeta> {
398 let oxios = table.get("oxios")?;
399 let Value::Map(map) = oxios else {
400 return None;
401 };
402
403 let author = get_str(map, "author").unwrap_or_default();
404 let quality = get_str(map, "quality")
405 .and_then(|s| parse_quality(&s))
406 .unwrap_or(crate::types::NoteQuality::Raw);
407 let source = get_str(map, "source")
408 .and_then(|s| parse_source(&s))
409 .unwrap_or(crate::types::NoteSource::Hook);
410 let needs_review = get_bool(map, "needs_review").unwrap_or(false);
411 let session_id = get_str(map, "session_id");
412 let message_index = get_usize(map, "message_index");
413 let saved_at = get_str(map, "saved_at");
414
415 Some(NoteMeta {
416 author,
417 source,
418 quality,
419 needs_review,
420 session_id,
421 message_index,
422 saved_at,
423 })
424}
425
426/// Embed `meta` as an `oxios:` row in `table`. Existing non-`oxios`
427/// keys are preserved (id, created, updated, tags, etc.).
428fn merge_note_meta(table: &mut Table, meta: &NoteMeta) {
429 let mut inner = Table::new();
430 inner.insert("author".to_string(), Value::Str(meta.author.clone()));
431 inner.insert(
432 "source".to_string(),
433 Value::Str(source_str(&meta.source).to_string()),
434 );
435 inner.insert(
436 "quality".to_string(),
437 Value::Str(quality_str(&meta.quality).to_string()),
438 );
439 inner.insert("needs_review".to_string(), Value::Bool(meta.needs_review));
440 if let Some(sid) = &meta.session_id {
441 inner.insert("session_id".to_string(), Value::Str(sid.clone()));
442 }
443 if let Some(idx) = meta.message_index {
444 inner.insert("message_index".to_string(), Value::Str(idx.to_string()));
445 }
446 if let Some(ts) = &meta.saved_at {
447 inner.insert("saved_at".to_string(), Value::Str(ts.clone()));
448 }
449 table.insert("oxios".to_string(), Value::Map(inner));
450}
451
452fn get_str(map: &Table, key: &str) -> Option<String> {
453 match map.get(key)? {
454 Value::Str(s) => Some(s.clone()),
455 _ => None,
456 }
457}
458
459fn get_bool(map: &Table, key: &str) -> Option<bool> {
460 match map.get(key)? {
461 Value::Bool(b) => Some(*b),
462 _ => None,
463 }
464}
465
466fn get_usize(map: &Table, key: &str) -> Option<usize> {
467 match map.get(key)? {
468 Value::Str(s) => s.parse().ok(),
469 _ => None,
470 }
471}
472
473fn parse_quality(s: &str) -> Option<crate::types::NoteQuality> {
474 match s {
475 "raw" => Some(crate::types::NoteQuality::Raw),
476 "curated" => Some(crate::types::NoteQuality::Curated),
477 "refined" => Some(crate::types::NoteQuality::Refined),
478 _ => None,
479 }
480}
481
482fn parse_source(s: &str) -> Option<crate::types::NoteSource> {
483 match s {
484 "hook" => Some(crate::types::NoteSource::Hook),
485 "tool" => Some(crate::types::NoteSource::Tool),
486 "ui" => Some(crate::types::NoteSource::Ui),
487 "dream" => Some(crate::types::NoteSource::Dream),
488 _ => None,
489 }
490}
491
492fn source_str(s: &crate::types::NoteSource) -> &'static str {
493 match s {
494 crate::types::NoteSource::Hook => "hook",
495 crate::types::NoteSource::Tool => "tool",
496 crate::types::NoteSource::Ui => "ui",
497 crate::types::NoteSource::Dream => "dream",
498 }
499}
500
501fn quality_str(q: &crate::types::NoteQuality) -> &'static str {
502 match q {
503 crate::types::NoteQuality::Raw => "raw",
504 crate::types::NoteQuality::Curated => "curated",
505 crate::types::NoteQuality::Refined => "refined",
506 }
507}
508
509// ---------------------------------------------------------------------------
510// Tests
511// ---------------------------------------------------------------------------
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use crate::types::NoteQuality;
517 use oxi_frontmatter::parse;
518 use time::macros::datetime;
519
520 #[test]
521 fn system_paths_are_excluded() {
522 for p in [
523 "Chat.md",
524 "Later.md",
525 "Done.md",
526 "Shop.md",
527 "journal/2026.08 August.md",
528 "habits/Mood.md",
529 "insights/2026 Habits.md",
530 "archive/Done.md",
531 "config.json",
532 "img/x.png",
533 ] {
534 assert!(is_system_path(p), "{p} should be a system path");
535 }
536 assert!(
537 !is_system_path("brain/Rust.md"),
538 "first-class memo must NOT be a system path"
539 );
540 // Review F5: a user memo named "Chat.md" inside a folder is
541 // NOT a system path (root-anchored filename match).
542 assert!(
543 !is_system_path("personal/Chat.md"),
544 "filename equality is root-anchored; personal/Chat.md is a memo"
545 );
546 }
547
548 #[test]
549 fn unsafe_rel_is_rejected() {
550 let tmp = tempfile::tempdir().unwrap();
551 let now = datetime!(2026-08-21 00:00 UTC);
552 for bad in [
553 "../Chat.md",
554 "/etc/passwd",
555 "\\Windows\\System32",
556 "ok/\x00/bad",
557 ] {
558 let err = write_note(tmp.path(), bad, "body", now).unwrap_err();
559 let msg = format!("{err}");
560 assert!(
561 msg.contains("unsafe path"),
562 "{bad} should be rejected; got {msg}"
563 );
564 }
565 }
566
567 #[test]
568 fn legacy_rfc022_is_native_and_meta_roundtrips() {
569 let legacy = "---\noxios:\n author: agent\n quality: raw\n---\nbody";
570 let meta = read_note_meta(legacy)
571 .expect("legacy RFC-022 must parse")
572 .expect("oxios: present");
573 assert_eq!(meta.author, "agent");
574 assert_eq!(meta.quality, NoteQuality::Raw);
575
576 let out = with_oxios_table(legacy, &meta).expect("emit must succeed");
577 assert!(
578 out.starts_with("---\n") && out.contains("oxios:"),
579 "canonical form must carry ---\\noxios:; got: {out:?}"
580 );
581 // Round-trip: reparse the canonical form and recover the meta.
582 let reparsed = read_note_meta(&out)
583 .expect("canonical form must parse")
584 .expect("oxios: present");
585 assert_eq!(reparsed.author, "agent");
586 assert_eq!(reparsed.quality, NoteQuality::Raw);
587 }
588
589 #[test]
590 fn user_authored_means_no_oxios_table() {
591 assert!(
592 read_note_meta(
593 "---\nid: a\ncreated: 2026-01-01T00:00:00Z\nupdated: 2026-01-01T00:00:00Z\n---\nbody"
594 )
595 .unwrap()
596 .is_none(),
597 "frontmatter without `oxios:` key is user-authored"
598 );
599 assert!(
600 read_note_meta("plain body, no frontmatter")
601 .unwrap()
602 .is_none(),
603 "no-fence content returns None"
604 );
605 }
606
607 #[test]
608 fn read_note_body_strips_frontmatter_and_hard_fails_on_malformed() {
609 // Memo with an oxios: table ⇒ body only, fence gone.
610 let body = read_note_body(
611 "---\nid: a\ncreated: 2026-01-01T00:00:00Z\nupdated: 2026-01-01T00:00:00Z\noxios:\n author: agent\n needs_review: true\n---\n# Curate me\n",
612 )
613 .expect("memo must parse");
614 assert_eq!(body, "# Curate me\n");
615 assert!(!body.contains("---"), "frontmatter must be stripped");
616
617 // BodyOnly content passes through verbatim.
618 assert_eq!(
619 read_note_body("plain body, no frontmatter").unwrap(),
620 "plain body, no frontmatter"
621 );
622
623 // Malformed frontmatter is a hard error — never silently
624 // returned as the body (the legacy bespoke parser did that,
625 // feeding frontmatter to the curation LLM).
626 assert!(
627 read_note_body("---\nfoo: [unclosed\n---\nbody").is_err(),
628 "malformed frontmatter must be a hard parse error"
629 );
630 }
631
632 #[test]
633 fn write_note_synthesizes_and_preserves() {
634 let tmp = tempfile::tempdir().unwrap();
635 let now = datetime!(2026-08-21 00:00 UTC);
636
637 // New doc: write_note must rewrite the file with a canonical
638 // frontmatter block (id/created/updated synthesized).
639 let rel = "brain/Rust.md";
640 let outcome =
641 write_note(tmp.path(), rel, "# Rust\n\nOwnership rules.", now).expect("write_note");
642 assert_eq!(outcome, WriteOutcome::Written);
643 let bytes = std::fs::read(tmp.path().join(rel)).unwrap();
644 let text = std::str::from_utf8(&bytes).unwrap();
645 assert!(
646 text.starts_with("---\n"),
647 "must have frontmatter; got: {text}"
648 );
649 assert!(text.contains("id:"), "must synthesize id; got: {text}");
650 assert!(
651 text.contains("created:"),
652 "must synthesize created; got: {text}"
653 );
654 assert!(
655 text.contains("updated:"),
656 "must synthesize updated; got: {text}"
657 );
658 assert!(
659 text.contains("Ownership rules."),
660 "body preserved; got: {text}"
661 );
662
663 // Rewrite with identical body content → NoOp.
664 let outcome2 = write_note(tmp.path(), rel, "# Rust\n\nOwnership rules.", now).unwrap();
665 assert_eq!(outcome2, WriteOutcome::NoOp);
666
667 // Pre-seed the file with an editor block carrying an unknown
668 // key, then write new content with a different unknown key:
669 // both must survive and the body must not start with a fence.
670 let _ = std::fs::write(
671 tmp.path().join(rel),
672 "---\nid: pre-existing-id\nlegacy_key: kept\n---\n# Rust\n\nOwnership rules.\n",
673 );
674 let editor_input =
675 "---\ntags: [rust, design]\ncustom_key: hello\n---\n# Rust\n\nOwnership rules.\n";
676 let outcome3 = write_note(tmp.path(), rel, editor_input, now).unwrap();
677 assert_eq!(outcome3, WriteOutcome::Written);
678 let text2 = std::fs::read_to_string(tmp.path().join(rel)).unwrap();
679
680 // Parse the written file and assert the table is exactly what
681 // we expect — strong check (no string-contains footgun).
682 let parsed = parse(&text2, NoteFormat::Markdown).expect("written file must parse");
683 let Parsed::Memo { table, body } = parsed else {
684 panic!("written file must have frontmatter; got: {text2}")
685 };
686 // Existing-file keys carry forward when absent from incoming.
687 assert!(
688 table.contains_key("id"),
689 "pre-existing id must remain; got table keys: {:?}",
690 table.keys().collect::<Vec<_>>()
691 );
692 assert!(
693 table.contains_key("legacy_key"),
694 "pre-existing foreign key must carry forward; got table keys: {:?}",
695 table.keys().collect::<Vec<_>>()
696 );
697 // Incoming keys overwrite/land alongside existing.
698 assert!(
699 table.contains_key("tags"),
700 "editor-supplied tags key must survive; got table keys: {:?}",
701 table.keys().collect::<Vec<_>>()
702 );
703 assert!(
704 table.contains_key("custom_key"),
705 "editor-supplied custom_key must survive; got table keys: {:?}",
706 table.keys().collect::<Vec<_>>()
707 );
708 // No oxios: key — write_note does NOT add it (that's
709 // with_oxios_table's job, which uses write_note under the
710 // hood but adds the oxios: row first).
711 assert!(
712 !table.contains_key("oxios"),
713 "write_note must NOT add an oxios: row; got table keys: {:?}",
714 table.keys().collect::<Vec<_>>()
715 );
716 // Body must NOT start with a fence line (review #2:
717 // contains() alone would pass if the key leaked into the body).
718 assert!(
719 !body.starts_with("---"),
720 "body must not start with a fence; got body: {body:?}"
721 );
722 assert!(
723 body.contains("Ownership rules."),
724 "body must contain the user content; got: {body}"
725 );
726 }
727
728 #[test]
729 fn write_note_system_path_is_raw_atomic() {
730 let tmp = tempfile::tempdir().unwrap();
731 let now = datetime!(2026-08-21 00:00 UTC);
732
733 let rel = "Chat.md";
734 let content = "free-form chat log, no frontmatter expected\n";
735 let outcome = write_note(tmp.path(), rel, content, now).unwrap();
736 assert_eq!(outcome, WriteOutcome::Written);
737 let bytes = std::fs::read(tmp.path().join(rel)).unwrap();
738 let text = std::str::from_utf8(&bytes).unwrap();
739 assert_eq!(text, content, "system path gets raw bytes");
740 assert!(!text.starts_with("---\n"), "no frontmatter synthesized");
741
742 // Identical rewrite → NoOp.
743 let outcome2 = write_note(tmp.path(), rel, content, now).unwrap();
744 assert_eq!(outcome2, WriteOutcome::NoOp);
745
746 // config.json is also a system path (non-.md short-circuit).
747 let cfg = "{\"k\":1}";
748 let outcome3 = write_note(tmp.path(), "config.json", cfg, now).unwrap();
749 assert_eq!(outcome3, WriteOutcome::Written);
750 let cfg_bytes = std::fs::read(tmp.path().join("config.json")).unwrap();
751 assert_eq!(cfg_bytes, cfg.as_bytes());
752 }
753
754 /// Round-2 covering test. When the merged table is semantically
755 /// equal to the on-disk table (e.g. the editor rewrites the
756 /// frontmatter but the merge produces the same key set with the
757 /// same `updated`), the `NoOp` decision MUST depend on the body
758 /// too: a different incoming body has to result in
759 /// `WriteOutcome::Written`, never a silent drop. The early
760 /// round-2 prototype reused the existing body in the probe,
761 /// which let any table-equivalent rewrite return NoOp even when
762 /// the body differed.
763 #[test]
764 fn write_note_body_change_is_not_a_noop() {
765 let tmp = tempfile::tempdir().unwrap();
766 let now = datetime!(2026-08-21 00:00 UTC);
767
768 let rel = "brain/Rust.md";
769
770 // Seed the file with a known table T and body "old". Use a
771 // frontmatter block so write_memo_with_incoming_table sees a
772 // Memo parsed (not a fresh synthesize). The `brain/` parent
773 // directory needs to exist because write_note does not
774 // create intermediate dirs (path hardening responsibility
775 // lives in the caller).
776 std::fs::create_dir_all(tmp.path().join("brain")).unwrap();
777 let seed = "---\nid: pre-existing-id\ntags: [keep]\n---\nold body\n";
778 std::fs::write(tmp.path().join(rel), seed).unwrap();
779
780 // The incoming content has the SAME frontmatter keys plus
781 // the same `tags` value (so the merged table equals T
782 // semantically) but a DIFFERENT body. The probe must
783 // compare the incoming body "new" against the file body
784 // "old"; they differ, so a write must occur.
785 let incoming = "---\nid: pre-existing-id\ntags: [keep]\n---\nnew body\n";
786 let outcome1 = write_note(tmp.path(), rel, incoming, now).expect("first write");
787 assert_eq!(
788 outcome1,
789 WriteOutcome::Written,
790 "body change must produce Written, never a silent NoOp"
791 );
792
793 // The file body must be the incoming body, not the old one.
794 let after_first = std::fs::read_to_string(tmp.path().join(rel)).unwrap();
795 let parsed1 = parse(&after_first, NoteFormat::Markdown).expect("file parses");
796 let Parsed::Memo {
797 table: t1,
798 body: b1,
799 } = parsed1
800 else {
801 panic!("expected Memo after first write; got BodyOnly; file: {after_first}")
802 };
803 assert_eq!(b1, "new body\n", "body must reflect the incoming content");
804
805 // `updated` must be bumped to `now` (otherwise the table
806 // would already equal a prior state and the next call would
807 // be a NoOp by accident).
808 assert!(
809 t1.contains_key("updated"),
810 "updated must be present on a real write; got keys: {:?}",
811 t1.keys().collect::<Vec<_>>()
812 );
813
814 // A SECOND identical write must now be NoOp: same body,
815 // same merged table, same updated timestamp.
816 let outcome2 = write_note(tmp.path(), rel, incoming, now).expect("second write");
817 assert_eq!(
818 outcome2,
819 WriteOutcome::NoOp,
820 "second identical write must be NoOp"
821 );
822
823 // The file body must STILL be the incoming content (the
824 // NoOp did not silently strip it).
825 let after_second = std::fs::read_to_string(tmp.path().join(rel)).unwrap();
826 assert_eq!(after_second, after_first, "NoOp must not modify the file");
827 }
828
829 /// Whole-branch review fix (P1 — NoOp ordering): production calls
830 /// inject a fresh `now_utc()` on every invocation, so the covering
831 /// tests here deliberately use DIFFERENT `now` values across calls —
832 /// the earlier tests injected one identical `now`, which masked the
833 /// ordering bug (the old code inserted `updated = now` into the
834 /// merge base BEFORE the semantic-NoOp probe, so any clock advance
835 /// made the probe differ and every unchanged editor re-save rewrote
836 /// the file, bumping `updated`, re-canonicalizing foreign
837 /// formatting, and firing git auto-commit + brain episodes).
838 #[test]
839 fn write_note_noop_survives_advancing_clock() {
840 let tmp = tempfile::tempdir().unwrap();
841 std::fs::create_dir_all(tmp.path().join("brain")).unwrap();
842 let rel = "brain/Rust.md";
843 let now1 = datetime!(2026-08-21 00:00 UTC);
844 let now2 = datetime!(2026-08-21 09:30 UTC);
845 let now3 = datetime!(2026-08-22 14:10 UTC);
846
847 let incoming =
848 "---\nid: fixed-id\ncreated: 2026-08-20T00:00:00Z\ntags: [keep]\n---\nstable body\n";
849 assert_eq!(
850 write_note(tmp.path(), rel, incoming, now1).unwrap(),
851 WriteOutcome::Written
852 );
853 let after_first = std::fs::read_to_string(tmp.path().join(rel)).unwrap();
854
855 // Unchanged editor re-save: the incoming content is exactly what
856 // is on disk, but the wall clock moved on (per-call now_utc()).
857 // Must be NoOp and leave the file byte-identical.
858 assert_eq!(
859 write_note(tmp.path(), rel, &after_first, now2).unwrap(),
860 WriteOutcome::NoOp,
861 "unchanged re-save with an advanced clock must be NoOp"
862 );
863 let after_resave = String::from_utf8(std::fs::read(tmp.path().join(rel)).unwrap()).unwrap();
864 assert_eq!(
865 after_resave, after_first,
866 "NoOp must leave the file byte-identical (no updated bump, no re-canonicalization)"
867 );
868
869 // Changed body with a further-advanced clock ⇒ Written with
870 // `updated` bumped to the new `now`, id/created preserved.
871 let edited = after_first.replace("stable body", "edited body");
872 assert_eq!(
873 write_note(tmp.path(), rel, &edited, now3).unwrap(),
874 WriteOutcome::Written
875 );
876 let after_edit = std::fs::read_to_string(tmp.path().join(rel)).unwrap();
877 let parsed = parse(&after_edit, NoteFormat::Markdown).expect("file must parse");
878 let Parsed::Memo { table, body } = parsed else {
879 panic!("edited file must have frontmatter; got: {after_edit}")
880 };
881 assert_eq!(body, "edited body\n", "body must reflect the edit");
882 assert_eq!(
883 table.get("updated"),
884 Some(&Value::Str(format_offset(now3))),
885 "real write must bump updated to the injected now"
886 );
887 assert_eq!(
888 table.get("id"),
889 Some(&Value::Str("fixed-id".to_string())),
890 "id must carry forward"
891 );
892 assert_eq!(
893 table.get("created"),
894 Some(&Value::Str("2026-08-20T00:00:00Z".to_string())),
895 "created must carry forward"
896 );
897 }
898}