Skip to main content

memstead_base/entity/
generator.rs

1//! Entity → Markdown generator. Deterministic output for roundtrip stability.
2//!
3//! Output order:
4//! 1. YAML frontmatter (metadata keys in schema metadata_fields order)
5//! 2. `# {title}`
6//! 3. Required sections (schema order)
7//! 4. Relationships section (if any explicit relations)
8//! 5. Optional sections (schema order)
9
10use memstead_schema::{FieldType, Serialization, TypeDefinition};
11
12use super::Entity;
13
14/// Sentinel value emitted into the frontmatter for required date
15/// fields the caller did not supply and the schema does not
16/// auto-fill. Reads cleanly as "obviously a placeholder" to humans
17/// and agents (epoch zero in ISO 8601) and never gets mistaken for a
18/// real claim.
19pub const MISSING_DATE_SENTINEL: &str = "1970-01-01";
20
21/// Generate markdown from a structured entity.
22///
23/// Invariant: `parse(generate(parse(md))) == parse(md)` — field order, spacing,
24/// and metadata serialization are deterministic.
25pub fn generate_markdown(entity: &Entity, schema: &TypeDefinition) -> String {
26    let mut parts = Vec::new();
27
28    // YAML frontmatter
29    let metadata_str = build_metadata(entity, schema);
30    parts.push(format!("---\n{metadata_str}\n---"));
31
32    // Title. Fence-checked like every body part: a title can embed a
33    // live fence opener (CR characters inside it are CommonMark line
34    // endings of their own, so its tail after a CR is a line that can
35    // open a fence), which masked every following section on the next
36    // parse (fuzz finding, corpus member `crash-711dc30a…`).
37    parts.push(close_open_context(format!("# {}", entity.title)));
38
39    // Required sections (schema order)
40    for s in schema.sections.iter().filter(|s| s.required) {
41        let content = entity
42            .sections
43            .get(s.key.as_str())
44            .map(|v| v.as_str())
45            .unwrap_or("");
46        parts.push(close_open_context(format!("## {}\n{content}", s.heading)));
47    }
48
49    // Relationships section (between required and optional).
50    // Cross-mem relations render as `[[<mem>:<slug>]]` so the
51    // wiki-link round-trips through the parser (which interprets
52    // bare-slug `[[<slug>]]` as same-mem). Pre-fix the renderer
53    // always emitted `[[<slug>]]`, breaking the round-trip for
54    // cross-mem relations — the parser would land them in the
55    // entity's own mem and the in-memory edge would drift from
56    // the on-disk intent.
57    if !entity.relationships.is_empty() {
58        let rel_lines: Vec<String> = entity
59            .relationships
60            .iter()
61            .map(|r| {
62                // Same-mem targets render bare — unless the bare path
63                // would re-decode as a DIFFERENT id: a path that itself
64                // matches the cross-mem dash form (`nttype--ospity`) is
65                // read back by the decoder's tier 0 as `nttype:ospity`,
66                // so the round-trip drifts (fuzz finding, long tier,
67                // 2026-08-24, corpus member `crash-219d83e3…`). The
68                // decoder itself is the oracle — no second model of the
69                // tier-0 rules; ambiguous paths self-qualify with the
70                // colon form.
71                let link = if r.target.mem() == entity.mem
72                    && crate::entity::id::wiki_link_to_id_lenient(r.target.path(), &entity.mem)
73                        == r.target
74                {
75                    r.target.path().to_string()
76                } else {
77                    format!("{}:{}", r.target.mem(), r.target.path())
78                };
79                // Em-dash form when the relation carries a per-edge
80                // description; canonical delimiter is the three-byte
81                // ` — ` (space + U+2014 + space). Empty / whitespace-only
82                // descriptions are normalised to `None` at every
83                // mutation entry, so `Some("")` should never reach the
84                // renderer — if it does we still avoid emitting a bare
85                // em-dash by treating empty-after-trim as the simple form.
86                match r
87                    .description
88                    .as_deref()
89                    .map(str::trim)
90                    .filter(|s| !s.is_empty())
91                {
92                    Some(text) => format!("- **{}**: [[{link}]] \u{2014} {text}", r.rel_type),
93                    None => format!("- **{}**: [[{link}]]", r.rel_type),
94                }
95            })
96            .collect();
97        parts.push(close_open_context(format!(
98            "## Relationships\n{}",
99            rel_lines.join("\n")
100        )));
101    }
102
103    // Optional sections (schema order). A key ABSENT from the entity's
104    // map is not rendered: emitting `unwrap_or("")` here is what
105    // materialised an empty heading for every declared-but-unwritten
106    // optional section on the first round-trip — scaffold headings no
107    // content ever reached (one campaign counted 82 across a mem) — and
108    // what made `sections_unset` impossible: the removed key came back
109    // as an empty heading on the next parse. A key PRESENT with empty
110    // content still renders (empty-but-kept is a valid authored state);
111    // required sections render unconditionally above, as before.
112    for s in schema.sections.iter().filter(|s| !s.required) {
113        let Some(content) = entity.sections.get(s.key.as_str()) else {
114            continue;
115        };
116        // All sections get the same spacing for roundtrip stability
117        parts.push(close_open_context(format!("## {}\n\n{content}", s.heading)));
118    }
119
120    parts.join("\n\n") + "\n"
121}
122
123/// Terminate an emitted part that ends inside an open block context: a
124/// code fence, or an HTML block of the kinds no blank line ends.
125/// Without this, everything the generator writes after the part
126/// — the following section headings included — is inside that fence on
127/// the next parse: sections are absorbed, and the document grows on
128/// every parse→generate round; an open HTML block hides the next
129/// part's fences instead, so masked lines surface as structure. The
130/// fix is at generation, not by mutating the stored content: one round
131/// normalises (the reparsed section content then carries the closer),
132/// after which parse→generate is a fixpoint. Balanced parts are
133/// untouched, so canonical bytes of well-formed entities do not change.
134///
135/// The check runs over exactly the bytes emitted — heading and title
136/// lines included, not just section content — because a heading or
137/// title can itself carry a live fence opener: CR characters inside it
138/// are CommonMark line endings of their own, so its tail after a CR is
139/// a line that can open a fence (fuzz findings, corpus members
140/// `crash-eca0fc99…` and `crash-711dc30a…`).
141fn close_open_context(mut part: String) -> String {
142    if let Some(closer) = crate::markdown::closing_context_if_unterminated(&part) {
143        part.push('\n');
144        part.push_str(&closer);
145    }
146    part
147}
148
149/// Build YAML frontmatter metadata string.
150/// Keys are emitted in the order defined by schema.metadata_fields.
151fn build_metadata(entity: &Entity, schema: &TypeDefinition) -> String {
152    let mut lines = Vec::new();
153
154    for field_def in &schema.metadata_fields {
155        let value = entity.metadata.get(field_def.key.as_str());
156
157        // Optional fields: skip if absent
158        if !field_def.is_required() && value.is_none() {
159            continue;
160        }
161
162        // omit-when-falsy: only emit when truthy
163        if field_def.serialization == Serialization::OmitWhenFalsy {
164            match value {
165                Some(v) if !v.is_falsy() => {}
166                _ => continue,
167            }
168        }
169
170        let formatted = match field_def.field_type {
171            FieldType::Date => {
172                // Schema-managed timestamps (`init_timestamp` /
173                // `auto_timestamp`) are written into metadata by the
174                // create / update flow before the generator runs, so
175                // `value.is_some()` for those. When the value is absent
176                // here, the field is required, the caller did not
177                // supply it, and the schema does not auto-fill it —
178                // `MISSING_REQUIRED_FIELD` already warned. A fallback of
179                // today's date would be indistinguishable from a
180                // real "set today" claim, so use a clearly-unreal
181                // sentinel (`1970-01-01`) so an agent or reviewer
182                // reading the frontmatter sees the placeholder
183                // immediately. Schema-declared `default_value` (when
184                // set) wins over the sentinel — the schema's choice
185                // is authoritative.
186                let val = value.map(|v| v.to_frontmatter_string()).unwrap_or_else(|| {
187                    field_def
188                        .default_value
189                        .as_deref()
190                        .unwrap_or(MISSING_DATE_SENTINEL)
191                        .to_string()
192                });
193                format!("{}: {val}", field_def.key)
194            }
195            FieldType::Boolean => {
196                let val = value.map(|v| v.to_frontmatter_string()).unwrap_or_else(|| {
197                    field_def
198                        .default_value
199                        .as_deref()
200                        .unwrap_or("false")
201                        .to_string()
202                });
203                format!("{}: {val}", field_def.key)
204            }
205            FieldType::Number => {
206                let val = value.map(|v| v.to_frontmatter_string()).unwrap_or_else(|| {
207                    field_def
208                        .default_value
209                        .as_deref()
210                        .unwrap_or("0")
211                        .to_string()
212                });
213                format!("{}: {val}", field_def.key)
214            }
215            FieldType::String => {
216                if field_def.serialization == Serialization::CsvArray {
217                    // csv-array: emit as comma-separated
218                    let val = value.map(|v| v.to_frontmatter_string()).unwrap_or_default();
219                    format!("{}: {}", field_def.key, quote_if_ambiguous(&val))
220                } else {
221                    let val = value.map(|v| v.to_frontmatter_string()).unwrap_or_else(|| {
222                        field_def.default_value.as_deref().unwrap_or("").to_string()
223                    });
224                    format!("{}: {}", field_def.key, quote_if_ambiguous(&val))
225                }
226            }
227        };
228
229        lines.push(formatted);
230    }
231
232    lines.join("\n")
233}
234
235/// YAML-quote a string value when emitting it unquoted would make the
236/// tolerant parser re-type it (as bool/int/float) on the next read.
237///
238/// Round-trip protection for `FieldType::String` metadata whose stored
239/// value happens to look like another type — e.g. a `temporal_range:
240/// "1968"` in source would otherwise be re-emitted as `temporal_range:
241/// 1968`, re-parsed as `MetadataValue::Integer(1968)`, and rejected by
242/// strict ingress as a String-vs-Integer type mismatch. Quoting forces
243/// the tolerant parser's `strip_quotes` branch, which keeps the value
244/// in `MetadataValue::String`.
245///
246/// Prefer double quotes; fall back to single quotes if the value
247/// contains `"`. If the value contains both quote styles the tolerant
248/// parser's `strip_quotes` cannot escape either, so we leave it
249/// unquoted — accepted as a rare lossy corner case, not defended here.
250fn quote_if_ambiguous(s: &str) -> String {
251    if !crate::entity::parser::would_coerce_from_string(s) {
252        return s.to_string();
253    }
254    if !s.contains('"') {
255        format!("\"{s}\"")
256    } else if !s.contains('\'') {
257        format!("'{s}'")
258    } else {
259        s.to_string()
260    }
261}
262
263#[cfg(test)]
264mod tests {
265    use super::*;
266    use crate::entity::{EntityId, MetadataValue, Relationship};
267    use indexmap::IndexMap;
268    use memstead_schema::{builtin_names, type_by_name};
269
270    fn make_entity(title: &str, mem: &str) -> Entity {
271        let schema = type_by_name(builtin_names::SPEC).unwrap();
272        let mut metadata = IndexMap::new();
273        metadata.insert("level".to_string(), MetadataValue::String("M0".to_string()));
274        metadata.insert(
275            "created_date".to_string(),
276            MetadataValue::String("2026-01-15".to_string()),
277        );
278        metadata.insert(
279            "last_modified".to_string(),
280            MetadataValue::String("2026-04-12".to_string()),
281        );
282        metadata.insert(
283            "tags".to_string(),
284            MetadataValue::String("backend, api".to_string()),
285        );
286        metadata.insert(
287            "type".to_string(),
288            MetadataValue::String("spec".to_string()),
289        );
290
291        let mut sections = IndexMap::new();
292        for s in &schema.sections {
293            sections.insert(s.key.clone(), String::new());
294        }
295        sections.insert("identity".to_string(), "Test identity.".to_string());
296        sections.insert("purpose".to_string(), "Test purpose.".to_string());
297
298        let slug = crate::entity::id::title_to_slug(title).unwrap();
299        Entity {
300            id: EntityId::new(mem, &slug),
301            title: title.to_string(),
302            entity_type: "spec".to_string(),
303            mem: mem.to_string(),
304            file_path: format!("{slug}.md"),
305            metadata,
306            sections,
307            relationships: Vec::new(),
308            content_hash: String::new(),
309            stub: false,
310            stub_kind: None,
311            heading_spans: std::collections::HashMap::new(),
312            raw_section_headings: Vec::new(),
313        }
314    }
315
316    fn make_assertion_scaffold(title: &str) -> Entity {
317        let schema = type_by_name(builtin_names::ASSERTION).unwrap();
318        let mut metadata = IndexMap::new();
319        metadata.insert(
320            "created_date".to_string(),
321            MetadataValue::String("2026-01-15".to_string()),
322        );
323        metadata.insert(
324            "last_modified".to_string(),
325            MetadataValue::String("2026-04-12".to_string()),
326        );
327        metadata.insert(
328            "type".to_string(),
329            MetadataValue::String("assertion".to_string()),
330        );
331
332        let mut sections = IndexMap::new();
333        for s in &schema.sections {
334            sections.insert(s.key.clone(), String::new());
335        }
336
337        let slug = crate::entity::id::title_to_slug(title).unwrap();
338        Entity {
339            id: EntityId::new("assertions", &slug),
340            title: title.to_string(),
341            entity_type: "assertion".to_string(),
342            mem: "assertions".to_string(),
343            file_path: format!("{slug}.md"),
344            metadata,
345            sections,
346            relationships: Vec::new(),
347            content_hash: String::new(),
348            stub: false,
349            stub_kind: None,
350            heading_spans: std::collections::HashMap::new(),
351            raw_section_headings: Vec::new(),
352        }
353    }
354
355    // Fixture pinned by the coverage-guided long tier (2026-08-24,
356    // corpus member `crash-219d83e3…`): a same-mem target whose PATH
357    // itself matches the cross-mem dash form (`nttype--ospity`)
358    // rendered bare, and the decoder's tier 0 read the bare form back
359    // as cross-mem `nttype:ospity` — a different id, so parse→generate
360    // was not a fixpoint. Such targets must self-qualify with the
361    // colon form; unambiguous same-mem targets stay bare.
362    #[test]
363    fn same_mem_target_matching_the_dash_form_self_qualifies() {
364        let schema = type_by_name(builtin_names::SPEC).unwrap();
365        let mut entity = make_entity("Dash Path", "specs");
366        entity.relationships.push(Relationship {
367            rel_type: "USES".to_string(),
368            target: EntityId::new("specs", "nttype--ospity"),
369            description: None,
370        });
371        entity.relationships.push(Relationship {
372            rel_type: "PART_OF".to_string(),
373            target: EntityId::new("specs", "plain-target"),
374            description: None,
375        });
376        let md = generate_markdown(&entity, &schema);
377        assert!(
378            md.contains("[[specs:nttype--ospity]]"),
379            "ambiguous same-mem path self-qualifies: {md}"
380        );
381        assert!(
382            md.contains("[[plain-target]]"),
383            "unambiguous same-mem path stays bare: {md}"
384        );
385        // The rendered row round-trips to the same id.
386        let decoded = crate::entity::id::wiki_link_to_id_lenient("specs:nttype--ospity", "specs");
387        assert_eq!(decoded, EntityId::new("specs", "nttype--ospity"));
388    }
389
390    #[test]
391    fn generate_assertion_scaffold_has_schema_sections_and_defaults() {
392        let schema = type_by_name(builtin_names::ASSERTION).unwrap();
393        let entity = make_assertion_scaffold("Sled Outperforms Rocksdb");
394        let md = generate_markdown(&entity, &schema);
395
396        // Schema-declared headings, not spec ones
397        assert!(md.contains("## Claim"));
398        assert!(md.contains("## Evidence"));
399        assert!(md.contains("## Conditions"));
400        assert!(md.contains("## Counterevidence"));
401        assert!(!md.contains("## Identity"));
402        assert!(!md.contains("## Purpose"));
403
404        // Required sections before optional ones (schema order)
405        let claim_pos = md.find("## Claim").unwrap();
406        let evid_pos = md.find("## Evidence").unwrap();
407        let cond_pos = md.find("## Conditions").unwrap();
408        let counter_pos = md.find("## Counterevidence").unwrap();
409        assert!(claim_pos < evid_pos);
410        assert!(evid_pos < cond_pos);
411        assert!(cond_pos < counter_pos);
412
413        // Metadata defaults from schemas.rs
414        assert!(md.contains("type: assertion"));
415        assert!(md.contains("confidence: medium"));
416        assert!(md.contains("verification_status: unverified"));
417
418        // Optional fields with no value are omitted
419        assert!(!md.contains("source_quality:"));
420        assert!(!md.contains("last_verified:"));
421    }
422
423    #[test]
424    fn generate_basic_entity() {
425        let schema = type_by_name(builtin_names::SPEC).unwrap();
426        let entity = make_entity("Test Entity", "specs");
427        let md = generate_markdown(&entity, &schema);
428
429        assert!(md.starts_with("---\n"));
430        assert!(md.contains("# Test Entity"));
431        assert!(md.contains("## Identity\nTest identity."));
432        assert!(md.contains("## Purpose\nTest purpose."));
433        assert!(md.contains("type: spec"));
434        assert!(md.contains("level: M0"));
435    }
436
437    #[test]
438    fn generate_with_relationships() {
439        let schema = type_by_name(builtin_names::SPEC).unwrap();
440        let mut entity = make_entity("Parent", "specs");
441        entity.relationships.push(Relationship {
442            rel_type: "USES".to_string(),
443            target: EntityId::new("specs", "child-entity"),
444            description: None,
445        });
446        let md = generate_markdown(&entity, &schema);
447
448        assert!(md.contains("## Relationships\n- **USES**: [[child-entity]]"));
449    }
450
451    #[test]
452    fn generate_relationship_with_description_uses_em_dash_delimiter() {
453        let schema = type_by_name(builtin_names::SPEC).unwrap();
454        let mut entity = make_entity("Parent", "specs");
455        entity.relationships.push(Relationship {
456            rel_type: "OTHER".to_string(),
457            target: EntityId::new("specs", "child-entity"),
458            description: Some("replaced by checkout-flow".to_string()),
459        });
460        let md = generate_markdown(&entity, &schema);
461
462        assert!(
463            md.contains(
464                "## Relationships\n- **OTHER**: [[child-entity]] \u{2014} replaced by checkout-flow"
465            ),
466            "expected canonical em-dash delimiter; got:\n{md}"
467        );
468    }
469
470    #[test]
471    fn generate_relationship_without_description_omits_em_dash() {
472        let schema = type_by_name(builtin_names::SPEC).unwrap();
473        let mut entity = make_entity("Parent", "specs");
474        entity.relationships.push(Relationship {
475            rel_type: "USES".to_string(),
476            target: EntityId::new("specs", "child-entity"),
477            description: None,
478        });
479        let md = generate_markdown(&entity, &schema);
480        // Trailing whitespace, em-dash, or stray content must not
481        // appear after the closing `]]` when description is None.
482        assert!(md.contains("- **USES**: [[child-entity]]\n"));
483        assert!(
484            !md.contains("\u{2014}"),
485            "no em-dash should be emitted when description is None"
486        );
487    }
488
489    #[test]
490    fn generate_metadata_order_follows_schema() {
491        let schema = type_by_name(builtin_names::SPEC).unwrap();
492        let entity = make_entity("Order Test", "specs");
493        let md = generate_markdown(&entity, &schema);
494
495        // Canonical base-metadata order: type, created_date, last_modified,
496        // <type-specific>, tags. Type-specific level sits between timestamps
497        // and tags.
498        let type_pos = md.find("type:").unwrap();
499        let created_pos = md.find("created_date:").unwrap();
500        let modified_pos = md.find("last_modified:").unwrap();
501        let level_pos = md.find("level:").unwrap();
502        let tags_pos = md.find("tags:").unwrap();
503
504        assert!(type_pos < created_pos);
505        assert!(created_pos < modified_pos);
506        assert!(modified_pos < level_pos);
507        assert!(level_pos < tags_pos);
508    }
509
510    #[test]
511    fn generate_omits_optional_absent() {
512        let schema = type_by_name(builtin_names::NARRATIVE).unwrap();
513        let mut metadata = IndexMap::new();
514        metadata.insert(
515            "reliability".to_string(),
516            MetadataValue::String("firsthand".to_string()),
517        );
518        metadata.insert(
519            "created_date".to_string(),
520            MetadataValue::String("2026-01-15".to_string()),
521        );
522        metadata.insert(
523            "last_modified".to_string(),
524            MetadataValue::String("2026-04-12".to_string()),
525        );
526        metadata.insert(
527            "type".to_string(),
528            MetadataValue::String("narrative".to_string()),
529        );
530
531        let mut sections = IndexMap::new();
532        for s in &schema.sections {
533            sections.insert(s.key.clone(), "placeholder.".to_string());
534        }
535
536        let entity = Entity {
537            id: EntityId::new("narratives", "no-optional"),
538            title: "No Optional".to_string(),
539            entity_type: "narrative".to_string(),
540            mem: "narratives".to_string(),
541            file_path: "no-optional.md".to_string(),
542            metadata,
543            sections,
544            relationships: Vec::new(),
545            content_hash: String::new(),
546            stub: false,
547            stub_kind: None,
548            heading_spans: std::collections::HashMap::new(),
549            raw_section_headings: Vec::new(),
550        };
551
552        let md = generate_markdown(&entity, &schema);
553        // temporal_range is optional on narrative with no value — must be omitted.
554        assert!(!md.contains("temporal_range:"));
555    }
556
557    #[test]
558    fn generate_ends_with_newline() {
559        let schema = type_by_name(builtin_names::SPEC).unwrap();
560        let entity = make_entity("Newline Test", "specs");
561        let md = generate_markdown(&entity, &schema);
562        assert!(md.ends_with('\n'));
563    }
564
565    /// The planning `goal` type's scope sections round-trip after the
566    /// scope_in→in_scope / scope_out→out_of_scope key fix: content
567    /// written under `## In Scope` / `## Out of Scope` parses back
568    /// under the declared keys instead of falling through to the
569    /// catch-all (which pre-fix silently absorbed and re-headed it).
570    #[test]
571    fn planning_goal_scope_sections_roundtrip() {
572        // Pinned: the builtin ships two planning versions since the
573        // 0.2.0 section-format bump, so a bare-name lookup is
574        // ambiguous by design.
575        let reg = memstead_schema::SchemaRegistry::builtin();
576        let planning = reg
577            .get("planning", &semver::Version::new(0, 1, 0))
578            .expect("planning@0.1.0 is a built-in");
579        let goal = planning.get_type("goal").expect("goal type exists");
580
581        let mut metadata = IndexMap::new();
582        metadata.insert(
583            "type".to_string(),
584            MetadataValue::String("goal".to_string()),
585        );
586        let mut sections = IndexMap::new();
587        for s in &goal.sections {
588            sections.insert(s.key.clone(), String::new());
589        }
590        sections.insert("statement".to_string(), "Ship the gate.".to_string());
591        sections.insert("in_scope".to_string(), "- the engine".to_string());
592        sections.insert("out_of_scope".to_string(), "- the moon".to_string());
593
594        let entity = Entity {
595            id: EntityId::new("plan", "ship-the-gate"),
596            title: "Ship The Gate".to_string(),
597            entity_type: "goal".to_string(),
598            mem: "plan".to_string(),
599            file_path: "ship-the-gate.md".to_string(),
600            metadata,
601            sections,
602            relationships: Vec::new(),
603            content_hash: String::new(),
604            stub: false,
605            stub_kind: None,
606            heading_spans: std::collections::HashMap::new(),
607            raw_section_headings: Vec::new(),
608        };
609
610        let md = generate_markdown(&entity, &goal);
611        assert!(
612            md.contains("## In Scope"),
613            "declared heading emitted:\n{md}"
614        );
615        assert!(
616            md.contains("## Out of Scope"),
617            "declared heading emitted:\n{md}"
618        );
619
620        let parsed = crate::entity::parser::parse_markdown(&md, "ship-the-gate.md", &goal, "plan")
621            .expect("round-trip parse");
622        assert_eq!(
623            parsed.entity.sections.get("in_scope").map(|s| s.trim()),
624            Some("- the engine"),
625            "In Scope content lands under its declared key"
626        );
627        assert_eq!(
628            parsed.entity.sections.get("out_of_scope").map(|s| s.trim()),
629            Some("- the moon"),
630            "Out of Scope content lands under its declared key"
631        );
632        let notes = parsed
633            .entity
634            .sections
635            .get("notes")
636            .map(|s| s.as_str())
637            .unwrap_or("");
638        assert!(
639            !notes.contains("the engine") && !notes.contains("the moon"),
640            "scope content must not be absorbed into the catch-all: {notes:?}"
641        );
642    }
643
644    #[test]
645    fn roundtrip_parse_generate_parse() {
646        let schema = type_by_name(builtin_names::SPEC).unwrap();
647        let md = "\
648---
649type: spec
650created_date: 2026-01-15
651last_modified: 2026-04-12
652level: M0
653tags: backend, api
654---
655# Roundtrip Test
656
657## Identity
658
659This is a roundtrip test entity.
660
661## Purpose
662
663Testing parse→generate→parse stability.
664
665## Relationships
666
667- **USES**: [[other-entity]]
668
669## Specifies
670
671Some content with [[inline-link]].
672
673## Constraints
674
675
676
677## Rationale
678
679";
680
681        // First parse
682        let result1 =
683            crate::entity::parser::parse_markdown(md, "roundtrip-test.md", &schema, "specs")
684                .unwrap();
685
686        // Generate
687        let generated = generate_markdown(&result1.entity, &schema);
688
689        // Second parse
690        let result2 = crate::entity::parser::parse_markdown(
691            &generated,
692            "roundtrip-test.md",
693            &schema,
694            "specs",
695        )
696        .unwrap();
697
698        // Compare: parse(generate(parse(md))) == parse(md)
699        assert_eq!(result1.entity.title, result2.entity.title);
700        assert_eq!(result1.entity.id, result2.entity.id);
701        assert_eq!(
702            result1.entity.relationships.len(),
703            result2.entity.relationships.len()
704        );
705        for (r1, r2) in result1
706            .entity
707            .relationships
708            .iter()
709            .zip(&result2.entity.relationships)
710        {
711            assert_eq!(r1.rel_type, r2.rel_type);
712            assert_eq!(r1.target, r2.target);
713        }
714        // Compare sections
715        for key in result1.entity.sections.keys() {
716            assert_eq!(
717                result1.entity.sections.get(key).map(|s| s.trim()),
718                result2.entity.sections.get(key).map(|s| s.trim()),
719                "Section '{key}' differs after roundtrip"
720            );
721        }
722        // Compare metadata
723        for (key, val) in &result1.entity.metadata {
724            assert_eq!(
725                Some(val),
726                result2.entity.metadata.get(key),
727                "Metadata '{key}' differs after roundtrip"
728            );
729        }
730
731        // Verify second roundtrip is byte-stable
732        let generated2 = generate_markdown(&result2.entity, &schema);
733        assert_eq!(
734            generated, generated2,
735            "Second roundtrip changed the markdown"
736        );
737    }
738
739    /// A `FieldType::String` value whose text happens to look like a
740    /// number/bool must round-trip as String — the generator has to
741    /// YAML-quote it, otherwise the tolerant parser re-types it on the
742    /// next read and strict ingress rejects the canonical bytes.
743    ///
744    /// Regression lock for the narrative-schema `temporal_range` bug
745    /// surfaced by V3's project-mems round-trip test.
746    #[test]
747    fn generate_quotes_number_shaped_string_value() {
748        let schema = type_by_name(builtin_names::NARRATIVE).unwrap();
749        let mut metadata = IndexMap::new();
750        metadata.insert(
751            "temporal_range".to_string(),
752            MetadataValue::String("1968".to_string()),
753        );
754        metadata.insert(
755            "reliability".to_string(),
756            MetadataValue::String("firsthand".to_string()),
757        );
758        metadata.insert(
759            "created_date".to_string(),
760            MetadataValue::String("2026-04-12".to_string()),
761        );
762        metadata.insert(
763            "last_modified".to_string(),
764            MetadataValue::String("2026-04-12".to_string()),
765        );
766        metadata.insert(
767            "tags".to_string(),
768            MetadataValue::String("history".to_string()),
769        );
770        metadata.insert(
771            "type".to_string(),
772            MetadataValue::String("narrative".to_string()),
773        );
774
775        let mut sections = IndexMap::new();
776        for s in &schema.sections {
777            sections.insert(s.key.clone(), "placeholder.".to_string());
778        }
779
780        let entity = Entity {
781            id: EntityId::new("specs", "ambiguous"),
782            title: "Ambiguous".to_string(),
783            entity_type: "narrative".to_string(),
784            mem: "specs".to_string(),
785            file_path: "ambiguous.md".to_string(),
786            metadata,
787            sections,
788            relationships: Vec::new(),
789            content_hash: String::new(),
790            stub: false,
791            stub_kind: None,
792            heading_spans: std::collections::HashMap::new(),
793            raw_section_headings: Vec::new(),
794        };
795
796        let md = generate_markdown(&entity, &schema);
797        assert!(
798            md.contains("temporal_range: \"1968\""),
799            "number-shaped string value must be emitted quoted, got:\n{md}"
800        );
801
802        // Round-trip must preserve the String typing — unquoted would
803        // coerce back to Integer and break strict ingress.
804        let parsed =
805            crate::entity::parser::parse_markdown(&md, "ambiguous.md", &schema, "specs").unwrap();
806        assert_eq!(
807            parsed.entity.metadata.get("temporal_range"),
808            Some(&MetadataValue::String("1968".to_string())),
809            "quoted value must round-trip as String"
810        );
811    }
812}