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(format!("# {}", entity.title));
34
35 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(close_open_fence(
43 format!("## {}\n{content}", s.heading),
44 content,
45 ));
46 }
47
48 if !entity.relationships.is_empty() {
57 let rel_lines: Vec<String> = entity
58 .relationships
59 .iter()
60 .map(|r| {
61 let link = if r.target.mem() == entity.mem {
62 r.target.path().to_string()
63 } else {
64 format!("{}:{}", r.target.mem(), r.target.path())
65 };
66 match r
74 .description
75 .as_deref()
76 .map(str::trim)
77 .filter(|s| !s.is_empty())
78 {
79 Some(text) => format!("- **{}**: [[{link}]] \u{2014} {text}", r.rel_type),
80 None => format!("- **{}**: [[{link}]]", r.rel_type),
81 }
82 })
83 .collect();
84 parts.push(format!("## Relationships\n{}", rel_lines.join("\n")));
85 }
86
87 for s in schema.sections.iter().filter(|s| !s.required) {
89 let content = entity
90 .sections
91 .get(s.key.as_str())
92 .map(|v| v.as_str())
93 .unwrap_or("");
94 parts.push(close_open_fence(
96 format!("## {}\n\n{content}", s.heading),
97 content,
98 ));
99 }
100
101 parts.join("\n\n") + "\n"
102}
103
104fn close_open_fence(mut part: String, content: &str) -> String {
114 if let Some(closer) = crate::markdown::closing_fence_if_unterminated(content) {
115 part.push('\n');
116 part.push_str(&closer);
117 }
118 part
119}
120
121fn build_metadata(entity: &Entity, schema: &TypeDefinition) -> String {
124 let mut lines = Vec::new();
125
126 for field_def in &schema.metadata_fields {
127 let value = entity.metadata.get(field_def.key.as_str());
128
129 if !field_def.is_required() && value.is_none() {
131 continue;
132 }
133
134 if field_def.serialization == Serialization::OmitWhenFalsy {
136 match value {
137 Some(v) if !v.is_falsy() => {}
138 _ => continue,
139 }
140 }
141
142 let formatted = match field_def.field_type {
143 FieldType::Date => {
144 let val = value.map(|v| v.to_frontmatter_string()).unwrap_or_else(|| {
159 field_def
160 .default_value
161 .as_deref()
162 .unwrap_or(MISSING_DATE_SENTINEL)
163 .to_string()
164 });
165 format!("{}: {val}", field_def.key)
166 }
167 FieldType::Boolean => {
168 let val = value.map(|v| v.to_frontmatter_string()).unwrap_or_else(|| {
169 field_def
170 .default_value
171 .as_deref()
172 .unwrap_or("false")
173 .to_string()
174 });
175 format!("{}: {val}", field_def.key)
176 }
177 FieldType::Number => {
178 let val = value.map(|v| v.to_frontmatter_string()).unwrap_or_else(|| {
179 field_def
180 .default_value
181 .as_deref()
182 .unwrap_or("0")
183 .to_string()
184 });
185 format!("{}: {val}", field_def.key)
186 }
187 FieldType::String => {
188 if field_def.serialization == Serialization::CsvArray {
189 let val = value.map(|v| v.to_frontmatter_string()).unwrap_or_default();
191 format!("{}: {}", field_def.key, quote_if_ambiguous(&val))
192 } else {
193 let val = value.map(|v| v.to_frontmatter_string()).unwrap_or_else(|| {
194 field_def.default_value.as_deref().unwrap_or("").to_string()
195 });
196 format!("{}: {}", field_def.key, quote_if_ambiguous(&val))
197 }
198 }
199 };
200
201 lines.push(formatted);
202 }
203
204 lines.join("\n")
205}
206
207fn quote_if_ambiguous(s: &str) -> String {
223 if !crate::entity::parser::would_coerce_from_string(s) {
224 return s.to_string();
225 }
226 if !s.contains('"') {
227 format!("\"{s}\"")
228 } else if !s.contains('\'') {
229 format!("'{s}'")
230 } else {
231 s.to_string()
232 }
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238 use crate::entity::{EntityId, MetadataValue, Relationship};
239 use indexmap::IndexMap;
240 use memstead_schema::{builtin_names, type_by_name};
241
242 fn make_entity(title: &str, mem: &str) -> Entity {
243 let schema = type_by_name(builtin_names::SPEC).unwrap();
244 let mut metadata = IndexMap::new();
245 metadata.insert("level".to_string(), MetadataValue::String("M0".to_string()));
246 metadata.insert(
247 "created_date".to_string(),
248 MetadataValue::String("2026-01-15".to_string()),
249 );
250 metadata.insert(
251 "last_modified".to_string(),
252 MetadataValue::String("2026-04-12".to_string()),
253 );
254 metadata.insert(
255 "tags".to_string(),
256 MetadataValue::String("backend, api".to_string()),
257 );
258 metadata.insert(
259 "type".to_string(),
260 MetadataValue::String("spec".to_string()),
261 );
262
263 let mut sections = IndexMap::new();
264 for s in &schema.sections {
265 sections.insert(s.key.clone(), String::new());
266 }
267 sections.insert("identity".to_string(), "Test identity.".to_string());
268 sections.insert("purpose".to_string(), "Test purpose.".to_string());
269
270 let slug = crate::entity::id::title_to_slug(title).unwrap();
271 Entity {
272 id: EntityId::new(mem, &slug),
273 title: title.to_string(),
274 entity_type: "spec".to_string(),
275 mem: mem.to_string(),
276 file_path: format!("{slug}.md"),
277 metadata,
278 sections,
279 relationships: Vec::new(),
280 content_hash: String::new(),
281 stub: false,
282 stub_kind: None,
283 heading_spans: std::collections::HashMap::new(),
284 raw_section_headings: Vec::new(),
285 }
286 }
287
288 fn make_assertion_scaffold(title: &str) -> Entity {
289 let schema = type_by_name(builtin_names::ASSERTION).unwrap();
290 let mut metadata = IndexMap::new();
291 metadata.insert(
292 "created_date".to_string(),
293 MetadataValue::String("2026-01-15".to_string()),
294 );
295 metadata.insert(
296 "last_modified".to_string(),
297 MetadataValue::String("2026-04-12".to_string()),
298 );
299 metadata.insert(
300 "type".to_string(),
301 MetadataValue::String("assertion".to_string()),
302 );
303
304 let mut sections = IndexMap::new();
305 for s in &schema.sections {
306 sections.insert(s.key.clone(), String::new());
307 }
308
309 let slug = crate::entity::id::title_to_slug(title).unwrap();
310 Entity {
311 id: EntityId::new("assertions", &slug),
312 title: title.to_string(),
313 entity_type: "assertion".to_string(),
314 mem: "assertions".to_string(),
315 file_path: format!("{slug}.md"),
316 metadata,
317 sections,
318 relationships: Vec::new(),
319 content_hash: String::new(),
320 stub: false,
321 stub_kind: None,
322 heading_spans: std::collections::HashMap::new(),
323 raw_section_headings: Vec::new(),
324 }
325 }
326
327 #[test]
328 fn generate_assertion_scaffold_has_schema_sections_and_defaults() {
329 let schema = type_by_name(builtin_names::ASSERTION).unwrap();
330 let entity = make_assertion_scaffold("Sled Outperforms Rocksdb");
331 let md = generate_markdown(&entity, &schema);
332
333 assert!(md.contains("## Claim"));
335 assert!(md.contains("## Evidence"));
336 assert!(md.contains("## Conditions"));
337 assert!(md.contains("## Counterevidence"));
338 assert!(!md.contains("## Identity"));
339 assert!(!md.contains("## Purpose"));
340
341 let claim_pos = md.find("## Claim").unwrap();
343 let evid_pos = md.find("## Evidence").unwrap();
344 let cond_pos = md.find("## Conditions").unwrap();
345 let counter_pos = md.find("## Counterevidence").unwrap();
346 assert!(claim_pos < evid_pos);
347 assert!(evid_pos < cond_pos);
348 assert!(cond_pos < counter_pos);
349
350 assert!(md.contains("type: assertion"));
352 assert!(md.contains("confidence: medium"));
353 assert!(md.contains("verification_status: unverified"));
354
355 assert!(!md.contains("source_quality:"));
357 assert!(!md.contains("last_verified:"));
358 }
359
360 #[test]
361 fn generate_basic_entity() {
362 let schema = type_by_name(builtin_names::SPEC).unwrap();
363 let entity = make_entity("Test Entity", "specs");
364 let md = generate_markdown(&entity, &schema);
365
366 assert!(md.starts_with("---\n"));
367 assert!(md.contains("# Test Entity"));
368 assert!(md.contains("## Identity\nTest identity."));
369 assert!(md.contains("## Purpose\nTest purpose."));
370 assert!(md.contains("type: spec"));
371 assert!(md.contains("level: M0"));
372 }
373
374 #[test]
375 fn generate_with_relationships() {
376 let schema = type_by_name(builtin_names::SPEC).unwrap();
377 let mut entity = make_entity("Parent", "specs");
378 entity.relationships.push(Relationship {
379 rel_type: "USES".to_string(),
380 target: EntityId::new("specs", "child-entity"),
381 description: None,
382 });
383 let md = generate_markdown(&entity, &schema);
384
385 assert!(md.contains("## Relationships\n- **USES**: [[child-entity]]"));
386 }
387
388 #[test]
389 fn generate_relationship_with_description_uses_em_dash_delimiter() {
390 let schema = type_by_name(builtin_names::SPEC).unwrap();
391 let mut entity = make_entity("Parent", "specs");
392 entity.relationships.push(Relationship {
393 rel_type: "OTHER".to_string(),
394 target: EntityId::new("specs", "child-entity"),
395 description: Some("replaced by checkout-flow".to_string()),
396 });
397 let md = generate_markdown(&entity, &schema);
398
399 assert!(
400 md.contains(
401 "## Relationships\n- **OTHER**: [[child-entity]] \u{2014} replaced by checkout-flow"
402 ),
403 "expected canonical em-dash delimiter; got:\n{md}"
404 );
405 }
406
407 #[test]
408 fn generate_relationship_without_description_omits_em_dash() {
409 let schema = type_by_name(builtin_names::SPEC).unwrap();
410 let mut entity = make_entity("Parent", "specs");
411 entity.relationships.push(Relationship {
412 rel_type: "USES".to_string(),
413 target: EntityId::new("specs", "child-entity"),
414 description: None,
415 });
416 let md = generate_markdown(&entity, &schema);
417 assert!(md.contains("- **USES**: [[child-entity]]\n"));
420 assert!(
421 !md.contains("\u{2014}"),
422 "no em-dash should be emitted when description is None"
423 );
424 }
425
426 #[test]
427 fn generate_metadata_order_follows_schema() {
428 let schema = type_by_name(builtin_names::SPEC).unwrap();
429 let entity = make_entity("Order Test", "specs");
430 let md = generate_markdown(&entity, &schema);
431
432 let type_pos = md.find("type:").unwrap();
436 let created_pos = md.find("created_date:").unwrap();
437 let modified_pos = md.find("last_modified:").unwrap();
438 let level_pos = md.find("level:").unwrap();
439 let tags_pos = md.find("tags:").unwrap();
440
441 assert!(type_pos < created_pos);
442 assert!(created_pos < modified_pos);
443 assert!(modified_pos < level_pos);
444 assert!(level_pos < tags_pos);
445 }
446
447 #[test]
448 fn generate_omits_optional_absent() {
449 let schema = type_by_name(builtin_names::NARRATIVE).unwrap();
450 let mut metadata = IndexMap::new();
451 metadata.insert(
452 "reliability".to_string(),
453 MetadataValue::String("firsthand".to_string()),
454 );
455 metadata.insert(
456 "created_date".to_string(),
457 MetadataValue::String("2026-01-15".to_string()),
458 );
459 metadata.insert(
460 "last_modified".to_string(),
461 MetadataValue::String("2026-04-12".to_string()),
462 );
463 metadata.insert(
464 "type".to_string(),
465 MetadataValue::String("narrative".to_string()),
466 );
467
468 let mut sections = IndexMap::new();
469 for s in &schema.sections {
470 sections.insert(s.key.clone(), "placeholder.".to_string());
471 }
472
473 let entity = Entity {
474 id: EntityId::new("narratives", "no-optional"),
475 title: "No Optional".to_string(),
476 entity_type: "narrative".to_string(),
477 mem: "narratives".to_string(),
478 file_path: "no-optional.md".to_string(),
479 metadata,
480 sections,
481 relationships: Vec::new(),
482 content_hash: String::new(),
483 stub: false,
484 stub_kind: None,
485 heading_spans: std::collections::HashMap::new(),
486 raw_section_headings: Vec::new(),
487 };
488
489 let md = generate_markdown(&entity, &schema);
490 assert!(!md.contains("temporal_range:"));
492 }
493
494 #[test]
495 fn generate_ends_with_newline() {
496 let schema = type_by_name(builtin_names::SPEC).unwrap();
497 let entity = make_entity("Newline Test", "specs");
498 let md = generate_markdown(&entity, &schema);
499 assert!(md.ends_with('\n'));
500 }
501
502 #[test]
508 fn planning_goal_scope_sections_roundtrip() {
509 let reg = memstead_schema::SchemaRegistry::builtin();
513 let planning = reg
514 .get("planning", &semver::Version::new(0, 1, 0))
515 .expect("planning@0.1.0 is a built-in");
516 let goal = planning.get_type("goal").expect("goal type exists");
517
518 let mut metadata = IndexMap::new();
519 metadata.insert(
520 "type".to_string(),
521 MetadataValue::String("goal".to_string()),
522 );
523 let mut sections = IndexMap::new();
524 for s in &goal.sections {
525 sections.insert(s.key.clone(), String::new());
526 }
527 sections.insert("statement".to_string(), "Ship the gate.".to_string());
528 sections.insert("in_scope".to_string(), "- the engine".to_string());
529 sections.insert("out_of_scope".to_string(), "- the moon".to_string());
530
531 let entity = Entity {
532 id: EntityId::new("plan", "ship-the-gate"),
533 title: "Ship The Gate".to_string(),
534 entity_type: "goal".to_string(),
535 mem: "plan".to_string(),
536 file_path: "ship-the-gate.md".to_string(),
537 metadata,
538 sections,
539 relationships: Vec::new(),
540 content_hash: String::new(),
541 stub: false,
542 stub_kind: None,
543 heading_spans: std::collections::HashMap::new(),
544 raw_section_headings: Vec::new(),
545 };
546
547 let md = generate_markdown(&entity, &goal);
548 assert!(
549 md.contains("## In Scope"),
550 "declared heading emitted:\n{md}"
551 );
552 assert!(
553 md.contains("## Out of Scope"),
554 "declared heading emitted:\n{md}"
555 );
556
557 let parsed = crate::entity::parser::parse_markdown(&md, "ship-the-gate.md", &goal, "plan")
558 .expect("round-trip parse");
559 assert_eq!(
560 parsed.entity.sections.get("in_scope").map(|s| s.trim()),
561 Some("- the engine"),
562 "In Scope content lands under its declared key"
563 );
564 assert_eq!(
565 parsed.entity.sections.get("out_of_scope").map(|s| s.trim()),
566 Some("- the moon"),
567 "Out of Scope content lands under its declared key"
568 );
569 let notes = parsed
570 .entity
571 .sections
572 .get("notes")
573 .map(|s| s.as_str())
574 .unwrap_or("");
575 assert!(
576 !notes.contains("the engine") && !notes.contains("the moon"),
577 "scope content must not be absorbed into the catch-all: {notes:?}"
578 );
579 }
580
581 #[test]
582 fn roundtrip_parse_generate_parse() {
583 let schema = type_by_name(builtin_names::SPEC).unwrap();
584 let md = "\
585---
586type: spec
587created_date: 2026-01-15
588last_modified: 2026-04-12
589level: M0
590tags: backend, api
591---
592# Roundtrip Test
593
594## Identity
595
596This is a roundtrip test entity.
597
598## Purpose
599
600Testing parse→generate→parse stability.
601
602## Relationships
603
604- **USES**: [[other-entity]]
605
606## Specifies
607
608Some content with [[inline-link]].
609
610## Constraints
611
612
613
614## Rationale
615
616";
617
618 let result1 =
620 crate::entity::parser::parse_markdown(md, "roundtrip-test.md", &schema, "specs")
621 .unwrap();
622
623 let generated = generate_markdown(&result1.entity, &schema);
625
626 let result2 = crate::entity::parser::parse_markdown(
628 &generated,
629 "roundtrip-test.md",
630 &schema,
631 "specs",
632 )
633 .unwrap();
634
635 assert_eq!(result1.entity.title, result2.entity.title);
637 assert_eq!(result1.entity.id, result2.entity.id);
638 assert_eq!(
639 result1.entity.relationships.len(),
640 result2.entity.relationships.len()
641 );
642 for (r1, r2) in result1
643 .entity
644 .relationships
645 .iter()
646 .zip(&result2.entity.relationships)
647 {
648 assert_eq!(r1.rel_type, r2.rel_type);
649 assert_eq!(r1.target, r2.target);
650 }
651 for key in result1.entity.sections.keys() {
653 assert_eq!(
654 result1.entity.sections.get(key).map(|s| s.trim()),
655 result2.entity.sections.get(key).map(|s| s.trim()),
656 "Section '{key}' differs after roundtrip"
657 );
658 }
659 for (key, val) in &result1.entity.metadata {
661 assert_eq!(
662 Some(val),
663 result2.entity.metadata.get(key),
664 "Metadata '{key}' differs after roundtrip"
665 );
666 }
667
668 let generated2 = generate_markdown(&result2.entity, &schema);
670 assert_eq!(
671 generated, generated2,
672 "Second roundtrip changed the markdown"
673 );
674 }
675
676 #[test]
684 fn generate_quotes_number_shaped_string_value() {
685 let schema = type_by_name(builtin_names::NARRATIVE).unwrap();
686 let mut metadata = IndexMap::new();
687 metadata.insert(
688 "temporal_range".to_string(),
689 MetadataValue::String("1968".to_string()),
690 );
691 metadata.insert(
692 "reliability".to_string(),
693 MetadataValue::String("firsthand".to_string()),
694 );
695 metadata.insert(
696 "created_date".to_string(),
697 MetadataValue::String("2026-04-12".to_string()),
698 );
699 metadata.insert(
700 "last_modified".to_string(),
701 MetadataValue::String("2026-04-12".to_string()),
702 );
703 metadata.insert(
704 "tags".to_string(),
705 MetadataValue::String("history".to_string()),
706 );
707 metadata.insert(
708 "type".to_string(),
709 MetadataValue::String("narrative".to_string()),
710 );
711
712 let mut sections = IndexMap::new();
713 for s in &schema.sections {
714 sections.insert(s.key.clone(), "placeholder.".to_string());
715 }
716
717 let entity = Entity {
718 id: EntityId::new("specs", "ambiguous"),
719 title: "Ambiguous".to_string(),
720 entity_type: "narrative".to_string(),
721 mem: "specs".to_string(),
722 file_path: "ambiguous.md".to_string(),
723 metadata,
724 sections,
725 relationships: Vec::new(),
726 content_hash: String::new(),
727 stub: false,
728 stub_kind: None,
729 heading_spans: std::collections::HashMap::new(),
730 raw_section_headings: Vec::new(),
731 };
732
733 let md = generate_markdown(&entity, &schema);
734 assert!(
735 md.contains("temporal_range: \"1968\""),
736 "number-shaped string value must be emitted quoted, got:\n{md}"
737 );
738
739 let parsed =
742 crate::entity::parser::parse_markdown(&md, "ambiguous.md", &schema, "specs").unwrap();
743 assert_eq!(
744 parsed.entity.metadata.get("temporal_range"),
745 Some(&MetadataValue::String("1968".to_string())),
746 "quoted value must round-trip as String"
747 );
748 }
749}