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