Skip to main content

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}