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