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.optional && 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        }
262    }
263
264    fn make_assertion_scaffold(title: &str) -> Entity {
265        let schema = type_by_name(builtin_names::ASSERTION).unwrap();
266        let mut metadata = IndexMap::new();
267        metadata.insert(
268            "created_date".to_string(),
269            MetadataValue::String("2026-01-15".to_string()),
270        );
271        metadata.insert(
272            "last_modified".to_string(),
273            MetadataValue::String("2026-04-12".to_string()),
274        );
275        metadata.insert(
276            "type".to_string(),
277            MetadataValue::String("assertion".to_string()),
278        );
279
280        let mut sections = IndexMap::new();
281        for s in &schema.sections {
282            sections.insert(s.key.clone(), String::new());
283        }
284
285        let slug = crate::entity::id::title_to_slug(title).unwrap();
286        Entity {
287            id: EntityId::new("assertions", &slug),
288            title: title.to_string(),
289            entity_type: "assertion".to_string(),
290            mem: "assertions".to_string(),
291            file_path: format!("{slug}.md"),
292            metadata,
293            sections,
294            relationships: Vec::new(),
295            content_hash: String::new(),
296            stub: false,
297            stub_kind: None,
298            heading_spans: std::collections::HashMap::new(),
299        }
300    }
301
302    #[test]
303    fn generate_assertion_scaffold_has_schema_sections_and_defaults() {
304        let schema = type_by_name(builtin_names::ASSERTION).unwrap();
305        let entity = make_assertion_scaffold("Sled Outperforms Rocksdb");
306        let md = generate_markdown(&entity, &schema);
307
308        // Schema-declared headings, not spec ones
309        assert!(md.contains("## Claim"));
310        assert!(md.contains("## Evidence"));
311        assert!(md.contains("## Conditions"));
312        assert!(md.contains("## Counterevidence"));
313        assert!(!md.contains("## Identity"));
314        assert!(!md.contains("## Purpose"));
315
316        // Required sections before optional ones (schema order)
317        let claim_pos = md.find("## Claim").unwrap();
318        let evid_pos = md.find("## Evidence").unwrap();
319        let cond_pos = md.find("## Conditions").unwrap();
320        let counter_pos = md.find("## Counterevidence").unwrap();
321        assert!(claim_pos < evid_pos);
322        assert!(evid_pos < cond_pos);
323        assert!(cond_pos < counter_pos);
324
325        // Metadata defaults from schemas.rs
326        assert!(md.contains("type: assertion"));
327        assert!(md.contains("confidence: medium"));
328        assert!(md.contains("verification_status: unverified"));
329
330        // Optional fields with no value are omitted
331        assert!(!md.contains("source_quality:"));
332        assert!(!md.contains("last_verified:"));
333    }
334
335    #[test]
336    fn generate_basic_entity() {
337        let schema = type_by_name(builtin_names::SPEC).unwrap();
338        let entity = make_entity("Test Entity", "specs");
339        let md = generate_markdown(&entity, &schema);
340
341        assert!(md.starts_with("---\n"));
342        assert!(md.contains("# Test Entity"));
343        assert!(md.contains("## Identity\nTest identity."));
344        assert!(md.contains("## Purpose\nTest purpose."));
345        assert!(md.contains("type: spec"));
346        assert!(md.contains("level: M0"));
347    }
348
349    #[test]
350    fn generate_with_relationships() {
351        let schema = type_by_name(builtin_names::SPEC).unwrap();
352        let mut entity = make_entity("Parent", "specs");
353        entity.relationships.push(Relationship {
354            rel_type: "USES".to_string(),
355            target: EntityId::new("specs", "child-entity"),
356            description: None,
357        });
358        let md = generate_markdown(&entity, &schema);
359
360        assert!(md.contains("## Relationships\n- **USES**: [[child-entity]]"));
361    }
362
363    #[test]
364    fn generate_relationship_with_description_uses_em_dash_delimiter() {
365        let schema = type_by_name(builtin_names::SPEC).unwrap();
366        let mut entity = make_entity("Parent", "specs");
367        entity.relationships.push(Relationship {
368            rel_type: "OTHER".to_string(),
369            target: EntityId::new("specs", "child-entity"),
370            description: Some("replaced by checkout-flow".to_string()),
371        });
372        let md = generate_markdown(&entity, &schema);
373
374        assert!(
375            md.contains(
376                "## Relationships\n- **OTHER**: [[child-entity]] \u{2014} replaced by checkout-flow"
377            ),
378            "expected canonical em-dash delimiter; got:\n{md}"
379        );
380    }
381
382    #[test]
383    fn generate_relationship_without_description_omits_em_dash() {
384        let schema = type_by_name(builtin_names::SPEC).unwrap();
385        let mut entity = make_entity("Parent", "specs");
386        entity.relationships.push(Relationship {
387            rel_type: "USES".to_string(),
388            target: EntityId::new("specs", "child-entity"),
389            description: None,
390        });
391        let md = generate_markdown(&entity, &schema);
392        // Trailing whitespace, em-dash, or stray content must not
393        // appear after the closing `]]` when description is None.
394        assert!(md.contains("- **USES**: [[child-entity]]\n"));
395        assert!(
396            !md.contains("\u{2014}"),
397            "no em-dash should be emitted when description is None"
398        );
399    }
400
401    #[test]
402    fn generate_metadata_order_follows_schema() {
403        let schema = type_by_name(builtin_names::SPEC).unwrap();
404        let entity = make_entity("Order Test", "specs");
405        let md = generate_markdown(&entity, &schema);
406
407        // Canonical base-metadata order: type, created_date, last_modified,
408        // <type-specific>, tags. Type-specific level sits between timestamps
409        // and tags.
410        let type_pos = md.find("type:").unwrap();
411        let created_pos = md.find("created_date:").unwrap();
412        let modified_pos = md.find("last_modified:").unwrap();
413        let level_pos = md.find("level:").unwrap();
414        let tags_pos = md.find("tags:").unwrap();
415
416        assert!(type_pos < created_pos);
417        assert!(created_pos < modified_pos);
418        assert!(modified_pos < level_pos);
419        assert!(level_pos < tags_pos);
420    }
421
422    #[test]
423    fn generate_omits_optional_absent() {
424        let schema = type_by_name(builtin_names::NARRATIVE).unwrap();
425        let mut metadata = IndexMap::new();
426        metadata.insert(
427            "reliability".to_string(),
428            MetadataValue::String("firsthand".to_string()),
429        );
430        metadata.insert(
431            "created_date".to_string(),
432            MetadataValue::String("2026-01-15".to_string()),
433        );
434        metadata.insert(
435            "last_modified".to_string(),
436            MetadataValue::String("2026-04-12".to_string()),
437        );
438        metadata.insert(
439            "type".to_string(),
440            MetadataValue::String("narrative".to_string()),
441        );
442
443        let mut sections = IndexMap::new();
444        for s in &schema.sections {
445            sections.insert(s.key.clone(), "placeholder.".to_string());
446        }
447
448        let entity = Entity {
449            id: EntityId::new("narratives", "no-optional"),
450            title: "No Optional".to_string(),
451            entity_type: "narrative".to_string(),
452            mem: "narratives".to_string(),
453            file_path: "no-optional.md".to_string(),
454            metadata,
455            sections,
456            relationships: Vec::new(),
457            content_hash: String::new(),
458            stub: false,
459            stub_kind: None,
460            heading_spans: std::collections::HashMap::new(),
461        };
462
463        let md = generate_markdown(&entity, &schema);
464        // temporal_range is optional on narrative with no value — must be omitted.
465        assert!(!md.contains("temporal_range:"));
466    }
467
468    #[test]
469    fn generate_ends_with_newline() {
470        let schema = type_by_name(builtin_names::SPEC).unwrap();
471        let entity = make_entity("Newline Test", "specs");
472        let md = generate_markdown(&entity, &schema);
473        assert!(md.ends_with('\n'));
474    }
475
476    #[test]
477    fn roundtrip_parse_generate_parse() {
478        let schema = type_by_name(builtin_names::SPEC).unwrap();
479        let md = "\
480---
481type: spec
482created_date: 2026-01-15
483last_modified: 2026-04-12
484level: M0
485tags: backend, api
486---
487# Roundtrip Test
488
489## Identity
490
491This is a roundtrip test entity.
492
493## Purpose
494
495Testing parse→generate→parse stability.
496
497## Relationships
498
499- **USES**: [[other-entity]]
500
501## Specifies
502
503Some content with [[inline-link]].
504
505## Constraints
506
507
508
509## Rationale
510
511";
512
513        // First parse
514        let result1 =
515            crate::entity::parser::parse_markdown(md, "roundtrip-test.md", &schema, "specs")
516                .unwrap();
517
518        // Generate
519        let generated = generate_markdown(&result1.entity, &schema);
520
521        // Second parse
522        let result2 = crate::entity::parser::parse_markdown(
523            &generated,
524            "roundtrip-test.md",
525            &schema,
526            "specs",
527        )
528        .unwrap();
529
530        // Compare: parse(generate(parse(md))) == parse(md)
531        assert_eq!(result1.entity.title, result2.entity.title);
532        assert_eq!(result1.entity.id, result2.entity.id);
533        assert_eq!(
534            result1.entity.relationships.len(),
535            result2.entity.relationships.len()
536        );
537        for (r1, r2) in result1
538            .entity
539            .relationships
540            .iter()
541            .zip(&result2.entity.relationships)
542        {
543            assert_eq!(r1.rel_type, r2.rel_type);
544            assert_eq!(r1.target, r2.target);
545        }
546        // Compare sections
547        for key in result1.entity.sections.keys() {
548            assert_eq!(
549                result1.entity.sections.get(key).map(|s| s.trim()),
550                result2.entity.sections.get(key).map(|s| s.trim()),
551                "Section '{key}' differs after roundtrip"
552            );
553        }
554        // Compare metadata
555        for (key, val) in &result1.entity.metadata {
556            assert_eq!(
557                Some(val),
558                result2.entity.metadata.get(key),
559                "Metadata '{key}' differs after roundtrip"
560            );
561        }
562
563        // Verify second roundtrip is byte-stable
564        let generated2 = generate_markdown(&result2.entity, &schema);
565        assert_eq!(
566            generated, generated2,
567            "Second roundtrip changed the markdown"
568        );
569    }
570
571    /// A `FieldType::String` value whose text happens to look like a
572    /// number/bool must round-trip as String — the generator has to
573    /// YAML-quote it, otherwise the tolerant parser re-types it on the
574    /// next read and strict ingress rejects the canonical bytes.
575    ///
576    /// Regression lock for the narrative-schema `temporal_range` bug
577    /// surfaced by V3's project-mems round-trip test.
578    #[test]
579    fn generate_quotes_number_shaped_string_value() {
580        let schema = type_by_name(builtin_names::NARRATIVE).unwrap();
581        let mut metadata = IndexMap::new();
582        metadata.insert(
583            "temporal_range".to_string(),
584            MetadataValue::String("1968".to_string()),
585        );
586        metadata.insert(
587            "reliability".to_string(),
588            MetadataValue::String("firsthand".to_string()),
589        );
590        metadata.insert(
591            "created_date".to_string(),
592            MetadataValue::String("2026-04-12".to_string()),
593        );
594        metadata.insert(
595            "last_modified".to_string(),
596            MetadataValue::String("2026-04-12".to_string()),
597        );
598        metadata.insert(
599            "tags".to_string(),
600            MetadataValue::String("history".to_string()),
601        );
602        metadata.insert(
603            "type".to_string(),
604            MetadataValue::String("narrative".to_string()),
605        );
606
607        let mut sections = IndexMap::new();
608        for s in &schema.sections {
609            sections.insert(s.key.clone(), "placeholder.".to_string());
610        }
611
612        let entity = Entity {
613            id: EntityId::new("specs", "ambiguous"),
614            title: "Ambiguous".to_string(),
615            entity_type: "narrative".to_string(),
616            mem: "specs".to_string(),
617            file_path: "ambiguous.md".to_string(),
618            metadata,
619            sections,
620            relationships: Vec::new(),
621            content_hash: String::new(),
622            stub: false,
623            stub_kind: None,
624            heading_spans: std::collections::HashMap::new(),
625        };
626
627        let md = generate_markdown(&entity, &schema);
628        assert!(
629            md.contains("temporal_range: \"1968\""),
630            "number-shaped string value must be emitted quoted, got:\n{md}"
631        );
632
633        // Round-trip must preserve the String typing — unquoted would
634        // coerce back to Integer and break strict ingress.
635        let parsed =
636            crate::entity::parser::parse_markdown(&md, "ambiguous.md", &schema, "specs").unwrap();
637        assert_eq!(
638            parsed.entity.metadata.get("temporal_range"),
639            Some(&MetadataValue::String("1968".to_string())),
640            "quoted value must round-trip as String"
641        );
642    }
643}