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