1use memstead_schema::{FieldType, Serialization, TypeDefinition};
11
12use super::Entity;
13
14pub const MISSING_DATE_SENTINEL: &str = "1970-01-01";
20
21pub fn generate_markdown(entity: &Entity, schema: &TypeDefinition) -> String {
26 let mut parts = Vec::new();
27
28 let metadata_str = build_metadata(entity, schema);
30 parts.push(format!("---\n{metadata_str}\n---"));
31
32 parts.push(close_open_fence(format!("# {}", entity.title)));
38
39 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 if !entity.relationships.is_empty() {
58 let rel_lines: Vec<String> = entity
59 .relationships
60 .iter()
61 .map(|r| {
62 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 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 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 parts.push(close_open_fence(format!("## {}\n\n{content}", s.heading)));
118 }
119
120 parts.join("\n\n") + "\n"
121}
122
123fn 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
147fn 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 if !field_def.is_required() && value.is_none() {
157 continue;
158 }
159
160 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 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 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
233fn 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 #[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 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 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 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 assert!(md.contains("type: assertion"));
413 assert!(md.contains("confidence: medium"));
414 assert!(md.contains("verification_status: unverified"));
415
416 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 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 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 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 #[test]
569 fn planning_goal_scope_sections_roundtrip() {
570 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 let result1 =
681 crate::entity::parser::parse_markdown(md, "roundtrip-test.md", &schema, "specs")
682 .unwrap();
683
684 let generated = generate_markdown(&result1.entity, &schema);
686
687 let result2 = crate::entity::parser::parse_markdown(
689 &generated,
690 "roundtrip-test.md",
691 &schema,
692 "specs",
693 )
694 .unwrap();
695
696 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 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 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 let generated2 = generate_markdown(&result2.entity, &schema);
731 assert_eq!(
732 generated, generated2,
733 "Second roundtrip changed the markdown"
734 );
735 }
736
737 #[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 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}