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