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_context(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_context(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_context(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_context(format!("## {}\n\n{content}", s.heading)));
118 }
119
120 parts.join("\n\n") + "\n"
121}
122
123fn close_open_context(mut part: String) -> String {
142 if let Some(closer) = crate::markdown::closing_context_if_unterminated(&part) {
143 part.push('\n');
144 part.push_str(&closer);
145 }
146 part
147}
148
149fn build_metadata(entity: &Entity, schema: &TypeDefinition) -> String {
152 let mut lines = Vec::new();
153
154 for field_def in &schema.metadata_fields {
155 let value = entity.metadata.get(field_def.key.as_str());
156
157 if !field_def.is_required() && value.is_none() {
159 continue;
160 }
161
162 if field_def.serialization == Serialization::OmitWhenFalsy {
164 match value {
165 Some(v) if !v.is_falsy() => {}
166 _ => continue,
167 }
168 }
169
170 let formatted = match field_def.field_type {
171 FieldType::Date => {
172 let val = value.map(|v| v.to_frontmatter_string()).unwrap_or_else(|| {
187 field_def
188 .default_value
189 .as_deref()
190 .unwrap_or(MISSING_DATE_SENTINEL)
191 .to_string()
192 });
193 format!("{}: {val}", field_def.key)
194 }
195 FieldType::Boolean => {
196 let val = value.map(|v| v.to_frontmatter_string()).unwrap_or_else(|| {
197 field_def
198 .default_value
199 .as_deref()
200 .unwrap_or("false")
201 .to_string()
202 });
203 format!("{}: {val}", field_def.key)
204 }
205 FieldType::Number => {
206 let val = value.map(|v| v.to_frontmatter_string()).unwrap_or_else(|| {
207 field_def
208 .default_value
209 .as_deref()
210 .unwrap_or("0")
211 .to_string()
212 });
213 format!("{}: {val}", field_def.key)
214 }
215 FieldType::String => {
216 if field_def.serialization == Serialization::CsvArray {
217 let val = value.map(|v| v.to_frontmatter_string()).unwrap_or_default();
219 format!("{}: {}", field_def.key, quote_if_ambiguous(&val))
220 } else {
221 let val = value.map(|v| v.to_frontmatter_string()).unwrap_or_else(|| {
222 field_def.default_value.as_deref().unwrap_or("").to_string()
223 });
224 format!("{}: {}", field_def.key, quote_if_ambiguous(&val))
225 }
226 }
227 };
228
229 lines.push(formatted);
230 }
231
232 lines.join("\n")
233}
234
235fn quote_if_ambiguous(s: &str) -> String {
251 if !crate::entity::parser::would_coerce_from_string(s) {
252 return s.to_string();
253 }
254 if !s.contains('"') {
255 format!("\"{s}\"")
256 } else if !s.contains('\'') {
257 format!("'{s}'")
258 } else {
259 s.to_string()
260 }
261}
262
263#[cfg(test)]
264mod tests {
265 use super::*;
266 use crate::entity::{EntityId, MetadataValue, Relationship};
267 use indexmap::IndexMap;
268 use memstead_schema::{builtin_names, type_by_name};
269
270 fn make_entity(title: &str, mem: &str) -> Entity {
271 let schema = type_by_name(builtin_names::SPEC).unwrap();
272 let mut metadata = IndexMap::new();
273 metadata.insert("level".to_string(), MetadataValue::String("M0".to_string()));
274 metadata.insert(
275 "created_date".to_string(),
276 MetadataValue::String("2026-01-15".to_string()),
277 );
278 metadata.insert(
279 "last_modified".to_string(),
280 MetadataValue::String("2026-04-12".to_string()),
281 );
282 metadata.insert(
283 "tags".to_string(),
284 MetadataValue::String("backend, api".to_string()),
285 );
286 metadata.insert(
287 "type".to_string(),
288 MetadataValue::String("spec".to_string()),
289 );
290
291 let mut sections = IndexMap::new();
292 for s in &schema.sections {
293 sections.insert(s.key.clone(), String::new());
294 }
295 sections.insert("identity".to_string(), "Test identity.".to_string());
296 sections.insert("purpose".to_string(), "Test purpose.".to_string());
297
298 let slug = crate::entity::id::title_to_slug(title).unwrap();
299 Entity {
300 id: EntityId::new(mem, &slug),
301 title: title.to_string(),
302 entity_type: "spec".to_string(),
303 mem: mem.to_string(),
304 file_path: format!("{slug}.md"),
305 metadata,
306 sections,
307 relationships: Vec::new(),
308 content_hash: String::new(),
309 stub: false,
310 stub_kind: None,
311 heading_spans: std::collections::HashMap::new(),
312 raw_section_headings: Vec::new(),
313 }
314 }
315
316 fn make_assertion_scaffold(title: &str) -> Entity {
317 let schema = type_by_name(builtin_names::ASSERTION).unwrap();
318 let mut metadata = IndexMap::new();
319 metadata.insert(
320 "created_date".to_string(),
321 MetadataValue::String("2026-01-15".to_string()),
322 );
323 metadata.insert(
324 "last_modified".to_string(),
325 MetadataValue::String("2026-04-12".to_string()),
326 );
327 metadata.insert(
328 "type".to_string(),
329 MetadataValue::String("assertion".to_string()),
330 );
331
332 let mut sections = IndexMap::new();
333 for s in &schema.sections {
334 sections.insert(s.key.clone(), String::new());
335 }
336
337 let slug = crate::entity::id::title_to_slug(title).unwrap();
338 Entity {
339 id: EntityId::new("assertions", &slug),
340 title: title.to_string(),
341 entity_type: "assertion".to_string(),
342 mem: "assertions".to_string(),
343 file_path: format!("{slug}.md"),
344 metadata,
345 sections,
346 relationships: Vec::new(),
347 content_hash: String::new(),
348 stub: false,
349 stub_kind: None,
350 heading_spans: std::collections::HashMap::new(),
351 raw_section_headings: Vec::new(),
352 }
353 }
354
355 #[test]
363 fn same_mem_target_matching_the_dash_form_self_qualifies() {
364 let schema = type_by_name(builtin_names::SPEC).unwrap();
365 let mut entity = make_entity("Dash Path", "specs");
366 entity.relationships.push(Relationship {
367 rel_type: "USES".to_string(),
368 target: EntityId::new("specs", "nttype--ospity"),
369 description: None,
370 });
371 entity.relationships.push(Relationship {
372 rel_type: "PART_OF".to_string(),
373 target: EntityId::new("specs", "plain-target"),
374 description: None,
375 });
376 let md = generate_markdown(&entity, &schema);
377 assert!(
378 md.contains("[[specs:nttype--ospity]]"),
379 "ambiguous same-mem path self-qualifies: {md}"
380 );
381 assert!(
382 md.contains("[[plain-target]]"),
383 "unambiguous same-mem path stays bare: {md}"
384 );
385 let decoded = crate::entity::id::wiki_link_to_id_lenient("specs:nttype--ospity", "specs");
387 assert_eq!(decoded, EntityId::new("specs", "nttype--ospity"));
388 }
389
390 #[test]
391 fn generate_assertion_scaffold_has_schema_sections_and_defaults() {
392 let schema = type_by_name(builtin_names::ASSERTION).unwrap();
393 let entity = make_assertion_scaffold("Sled Outperforms Rocksdb");
394 let md = generate_markdown(&entity, &schema);
395
396 assert!(md.contains("## Claim"));
398 assert!(md.contains("## Evidence"));
399 assert!(md.contains("## Conditions"));
400 assert!(md.contains("## Counterevidence"));
401 assert!(!md.contains("## Identity"));
402 assert!(!md.contains("## Purpose"));
403
404 let claim_pos = md.find("## Claim").unwrap();
406 let evid_pos = md.find("## Evidence").unwrap();
407 let cond_pos = md.find("## Conditions").unwrap();
408 let counter_pos = md.find("## Counterevidence").unwrap();
409 assert!(claim_pos < evid_pos);
410 assert!(evid_pos < cond_pos);
411 assert!(cond_pos < counter_pos);
412
413 assert!(md.contains("type: assertion"));
415 assert!(md.contains("confidence: medium"));
416 assert!(md.contains("verification_status: unverified"));
417
418 assert!(!md.contains("source_quality:"));
420 assert!(!md.contains("last_verified:"));
421 }
422
423 #[test]
424 fn generate_basic_entity() {
425 let schema = type_by_name(builtin_names::SPEC).unwrap();
426 let entity = make_entity("Test Entity", "specs");
427 let md = generate_markdown(&entity, &schema);
428
429 assert!(md.starts_with("---\n"));
430 assert!(md.contains("# Test Entity"));
431 assert!(md.contains("## Identity\nTest identity."));
432 assert!(md.contains("## Purpose\nTest purpose."));
433 assert!(md.contains("type: spec"));
434 assert!(md.contains("level: M0"));
435 }
436
437 #[test]
438 fn generate_with_relationships() {
439 let schema = type_by_name(builtin_names::SPEC).unwrap();
440 let mut entity = make_entity("Parent", "specs");
441 entity.relationships.push(Relationship {
442 rel_type: "USES".to_string(),
443 target: EntityId::new("specs", "child-entity"),
444 description: None,
445 });
446 let md = generate_markdown(&entity, &schema);
447
448 assert!(md.contains("## Relationships\n- **USES**: [[child-entity]]"));
449 }
450
451 #[test]
452 fn generate_relationship_with_description_uses_em_dash_delimiter() {
453 let schema = type_by_name(builtin_names::SPEC).unwrap();
454 let mut entity = make_entity("Parent", "specs");
455 entity.relationships.push(Relationship {
456 rel_type: "OTHER".to_string(),
457 target: EntityId::new("specs", "child-entity"),
458 description: Some("replaced by checkout-flow".to_string()),
459 });
460 let md = generate_markdown(&entity, &schema);
461
462 assert!(
463 md.contains(
464 "## Relationships\n- **OTHER**: [[child-entity]] \u{2014} replaced by checkout-flow"
465 ),
466 "expected canonical em-dash delimiter; got:\n{md}"
467 );
468 }
469
470 #[test]
471 fn generate_relationship_without_description_omits_em_dash() {
472 let schema = type_by_name(builtin_names::SPEC).unwrap();
473 let mut entity = make_entity("Parent", "specs");
474 entity.relationships.push(Relationship {
475 rel_type: "USES".to_string(),
476 target: EntityId::new("specs", "child-entity"),
477 description: None,
478 });
479 let md = generate_markdown(&entity, &schema);
480 assert!(md.contains("- **USES**: [[child-entity]]\n"));
483 assert!(
484 !md.contains("\u{2014}"),
485 "no em-dash should be emitted when description is None"
486 );
487 }
488
489 #[test]
490 fn generate_metadata_order_follows_schema() {
491 let schema = type_by_name(builtin_names::SPEC).unwrap();
492 let entity = make_entity("Order Test", "specs");
493 let md = generate_markdown(&entity, &schema);
494
495 let type_pos = md.find("type:").unwrap();
499 let created_pos = md.find("created_date:").unwrap();
500 let modified_pos = md.find("last_modified:").unwrap();
501 let level_pos = md.find("level:").unwrap();
502 let tags_pos = md.find("tags:").unwrap();
503
504 assert!(type_pos < created_pos);
505 assert!(created_pos < modified_pos);
506 assert!(modified_pos < level_pos);
507 assert!(level_pos < tags_pos);
508 }
509
510 #[test]
511 fn generate_omits_optional_absent() {
512 let schema = type_by_name(builtin_names::NARRATIVE).unwrap();
513 let mut metadata = IndexMap::new();
514 metadata.insert(
515 "reliability".to_string(),
516 MetadataValue::String("firsthand".to_string()),
517 );
518 metadata.insert(
519 "created_date".to_string(),
520 MetadataValue::String("2026-01-15".to_string()),
521 );
522 metadata.insert(
523 "last_modified".to_string(),
524 MetadataValue::String("2026-04-12".to_string()),
525 );
526 metadata.insert(
527 "type".to_string(),
528 MetadataValue::String("narrative".to_string()),
529 );
530
531 let mut sections = IndexMap::new();
532 for s in &schema.sections {
533 sections.insert(s.key.clone(), "placeholder.".to_string());
534 }
535
536 let entity = Entity {
537 id: EntityId::new("narratives", "no-optional"),
538 title: "No Optional".to_string(),
539 entity_type: "narrative".to_string(),
540 mem: "narratives".to_string(),
541 file_path: "no-optional.md".to_string(),
542 metadata,
543 sections,
544 relationships: Vec::new(),
545 content_hash: String::new(),
546 stub: false,
547 stub_kind: None,
548 heading_spans: std::collections::HashMap::new(),
549 raw_section_headings: Vec::new(),
550 };
551
552 let md = generate_markdown(&entity, &schema);
553 assert!(!md.contains("temporal_range:"));
555 }
556
557 #[test]
558 fn generate_ends_with_newline() {
559 let schema = type_by_name(builtin_names::SPEC).unwrap();
560 let entity = make_entity("Newline Test", "specs");
561 let md = generate_markdown(&entity, &schema);
562 assert!(md.ends_with('\n'));
563 }
564
565 #[test]
571 fn planning_goal_scope_sections_roundtrip() {
572 let reg = memstead_schema::SchemaRegistry::builtin();
576 let planning = reg
577 .get("planning", &semver::Version::new(0, 1, 0))
578 .expect("planning@0.1.0 is a built-in");
579 let goal = planning.get_type("goal").expect("goal type exists");
580
581 let mut metadata = IndexMap::new();
582 metadata.insert(
583 "type".to_string(),
584 MetadataValue::String("goal".to_string()),
585 );
586 let mut sections = IndexMap::new();
587 for s in &goal.sections {
588 sections.insert(s.key.clone(), String::new());
589 }
590 sections.insert("statement".to_string(), "Ship the gate.".to_string());
591 sections.insert("in_scope".to_string(), "- the engine".to_string());
592 sections.insert("out_of_scope".to_string(), "- the moon".to_string());
593
594 let entity = Entity {
595 id: EntityId::new("plan", "ship-the-gate"),
596 title: "Ship The Gate".to_string(),
597 entity_type: "goal".to_string(),
598 mem: "plan".to_string(),
599 file_path: "ship-the-gate.md".to_string(),
600 metadata,
601 sections,
602 relationships: Vec::new(),
603 content_hash: String::new(),
604 stub: false,
605 stub_kind: None,
606 heading_spans: std::collections::HashMap::new(),
607 raw_section_headings: Vec::new(),
608 };
609
610 let md = generate_markdown(&entity, &goal);
611 assert!(
612 md.contains("## In Scope"),
613 "declared heading emitted:\n{md}"
614 );
615 assert!(
616 md.contains("## Out of Scope"),
617 "declared heading emitted:\n{md}"
618 );
619
620 let parsed = crate::entity::parser::parse_markdown(&md, "ship-the-gate.md", &goal, "plan")
621 .expect("round-trip parse");
622 assert_eq!(
623 parsed.entity.sections.get("in_scope").map(|s| s.trim()),
624 Some("- the engine"),
625 "In Scope content lands under its declared key"
626 );
627 assert_eq!(
628 parsed.entity.sections.get("out_of_scope").map(|s| s.trim()),
629 Some("- the moon"),
630 "Out of Scope content lands under its declared key"
631 );
632 let notes = parsed
633 .entity
634 .sections
635 .get("notes")
636 .map(|s| s.as_str())
637 .unwrap_or("");
638 assert!(
639 !notes.contains("the engine") && !notes.contains("the moon"),
640 "scope content must not be absorbed into the catch-all: {notes:?}"
641 );
642 }
643
644 #[test]
645 fn roundtrip_parse_generate_parse() {
646 let schema = type_by_name(builtin_names::SPEC).unwrap();
647 let md = "\
648---
649type: spec
650created_date: 2026-01-15
651last_modified: 2026-04-12
652level: M0
653tags: backend, api
654---
655# Roundtrip Test
656
657## Identity
658
659This is a roundtrip test entity.
660
661## Purpose
662
663Testing parse→generate→parse stability.
664
665## Relationships
666
667- **USES**: [[other-entity]]
668
669## Specifies
670
671Some content with [[inline-link]].
672
673## Constraints
674
675
676
677## Rationale
678
679";
680
681 let result1 =
683 crate::entity::parser::parse_markdown(md, "roundtrip-test.md", &schema, "specs")
684 .unwrap();
685
686 let generated = generate_markdown(&result1.entity, &schema);
688
689 let result2 = crate::entity::parser::parse_markdown(
691 &generated,
692 "roundtrip-test.md",
693 &schema,
694 "specs",
695 )
696 .unwrap();
697
698 assert_eq!(result1.entity.title, result2.entity.title);
700 assert_eq!(result1.entity.id, result2.entity.id);
701 assert_eq!(
702 result1.entity.relationships.len(),
703 result2.entity.relationships.len()
704 );
705 for (r1, r2) in result1
706 .entity
707 .relationships
708 .iter()
709 .zip(&result2.entity.relationships)
710 {
711 assert_eq!(r1.rel_type, r2.rel_type);
712 assert_eq!(r1.target, r2.target);
713 }
714 for key in result1.entity.sections.keys() {
716 assert_eq!(
717 result1.entity.sections.get(key).map(|s| s.trim()),
718 result2.entity.sections.get(key).map(|s| s.trim()),
719 "Section '{key}' differs after roundtrip"
720 );
721 }
722 for (key, val) in &result1.entity.metadata {
724 assert_eq!(
725 Some(val),
726 result2.entity.metadata.get(key),
727 "Metadata '{key}' differs after roundtrip"
728 );
729 }
730
731 let generated2 = generate_markdown(&result2.entity, &schema);
733 assert_eq!(
734 generated, generated2,
735 "Second roundtrip changed the markdown"
736 );
737 }
738
739 #[test]
747 fn generate_quotes_number_shaped_string_value() {
748 let schema = type_by_name(builtin_names::NARRATIVE).unwrap();
749 let mut metadata = IndexMap::new();
750 metadata.insert(
751 "temporal_range".to_string(),
752 MetadataValue::String("1968".to_string()),
753 );
754 metadata.insert(
755 "reliability".to_string(),
756 MetadataValue::String("firsthand".to_string()),
757 );
758 metadata.insert(
759 "created_date".to_string(),
760 MetadataValue::String("2026-04-12".to_string()),
761 );
762 metadata.insert(
763 "last_modified".to_string(),
764 MetadataValue::String("2026-04-12".to_string()),
765 );
766 metadata.insert(
767 "tags".to_string(),
768 MetadataValue::String("history".to_string()),
769 );
770 metadata.insert(
771 "type".to_string(),
772 MetadataValue::String("narrative".to_string()),
773 );
774
775 let mut sections = IndexMap::new();
776 for s in &schema.sections {
777 sections.insert(s.key.clone(), "placeholder.".to_string());
778 }
779
780 let entity = Entity {
781 id: EntityId::new("specs", "ambiguous"),
782 title: "Ambiguous".to_string(),
783 entity_type: "narrative".to_string(),
784 mem: "specs".to_string(),
785 file_path: "ambiguous.md".to_string(),
786 metadata,
787 sections,
788 relationships: Vec::new(),
789 content_hash: String::new(),
790 stub: false,
791 stub_kind: None,
792 heading_spans: std::collections::HashMap::new(),
793 raw_section_headings: Vec::new(),
794 };
795
796 let md = generate_markdown(&entity, &schema);
797 assert!(
798 md.contains("temporal_range: \"1968\""),
799 "number-shaped string value must be emitted quoted, got:\n{md}"
800 );
801
802 let parsed =
805 crate::entity::parser::parse_markdown(&md, "ambiguous.md", &schema, "specs").unwrap();
806 assert_eq!(
807 parsed.entity.metadata.get("temporal_range"),
808 Some(&MetadataValue::String("1968".to_string())),
809 "quoted value must round-trip as String"
810 );
811 }
812}