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(format!("## {}\n{content}", s.heading));
43 }
44
45 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 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 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 parts.push(format!("## {}\n\n{content}", s.heading));
93 }
94
95 parts.join("\n\n") + "\n"
96}
97
98fn 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 if !field_def.is_required() && value.is_none() {
108 continue;
109 }
110
111 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 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 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
184fn 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 raw_section_headings: Vec::new(),
262 }
263 }
264
265 fn make_assertion_scaffold(title: &str) -> Entity {
266 let schema = type_by_name(builtin_names::ASSERTION).unwrap();
267 let mut metadata = IndexMap::new();
268 metadata.insert(
269 "created_date".to_string(),
270 MetadataValue::String("2026-01-15".to_string()),
271 );
272 metadata.insert(
273 "last_modified".to_string(),
274 MetadataValue::String("2026-04-12".to_string()),
275 );
276 metadata.insert(
277 "type".to_string(),
278 MetadataValue::String("assertion".to_string()),
279 );
280
281 let mut sections = IndexMap::new();
282 for s in &schema.sections {
283 sections.insert(s.key.clone(), String::new());
284 }
285
286 let slug = crate::entity::id::title_to_slug(title).unwrap();
287 Entity {
288 id: EntityId::new("assertions", &slug),
289 title: title.to_string(),
290 entity_type: "assertion".to_string(),
291 mem: "assertions".to_string(),
292 file_path: format!("{slug}.md"),
293 metadata,
294 sections,
295 relationships: Vec::new(),
296 content_hash: String::new(),
297 stub: false,
298 stub_kind: None,
299 heading_spans: std::collections::HashMap::new(),
300 raw_section_headings: Vec::new(),
301 }
302 }
303
304 #[test]
305 fn generate_assertion_scaffold_has_schema_sections_and_defaults() {
306 let schema = type_by_name(builtin_names::ASSERTION).unwrap();
307 let entity = make_assertion_scaffold("Sled Outperforms Rocksdb");
308 let md = generate_markdown(&entity, &schema);
309
310 assert!(md.contains("## Claim"));
312 assert!(md.contains("## Evidence"));
313 assert!(md.contains("## Conditions"));
314 assert!(md.contains("## Counterevidence"));
315 assert!(!md.contains("## Identity"));
316 assert!(!md.contains("## Purpose"));
317
318 let claim_pos = md.find("## Claim").unwrap();
320 let evid_pos = md.find("## Evidence").unwrap();
321 let cond_pos = md.find("## Conditions").unwrap();
322 let counter_pos = md.find("## Counterevidence").unwrap();
323 assert!(claim_pos < evid_pos);
324 assert!(evid_pos < cond_pos);
325 assert!(cond_pos < counter_pos);
326
327 assert!(md.contains("type: assertion"));
329 assert!(md.contains("confidence: medium"));
330 assert!(md.contains("verification_status: unverified"));
331
332 assert!(!md.contains("source_quality:"));
334 assert!(!md.contains("last_verified:"));
335 }
336
337 #[test]
338 fn generate_basic_entity() {
339 let schema = type_by_name(builtin_names::SPEC).unwrap();
340 let entity = make_entity("Test Entity", "specs");
341 let md = generate_markdown(&entity, &schema);
342
343 assert!(md.starts_with("---\n"));
344 assert!(md.contains("# Test Entity"));
345 assert!(md.contains("## Identity\nTest identity."));
346 assert!(md.contains("## Purpose\nTest purpose."));
347 assert!(md.contains("type: spec"));
348 assert!(md.contains("level: M0"));
349 }
350
351 #[test]
352 fn generate_with_relationships() {
353 let schema = type_by_name(builtin_names::SPEC).unwrap();
354 let mut entity = make_entity("Parent", "specs");
355 entity.relationships.push(Relationship {
356 rel_type: "USES".to_string(),
357 target: EntityId::new("specs", "child-entity"),
358 description: None,
359 });
360 let md = generate_markdown(&entity, &schema);
361
362 assert!(md.contains("## Relationships\n- **USES**: [[child-entity]]"));
363 }
364
365 #[test]
366 fn generate_relationship_with_description_uses_em_dash_delimiter() {
367 let schema = type_by_name(builtin_names::SPEC).unwrap();
368 let mut entity = make_entity("Parent", "specs");
369 entity.relationships.push(Relationship {
370 rel_type: "OTHER".to_string(),
371 target: EntityId::new("specs", "child-entity"),
372 description: Some("replaced by checkout-flow".to_string()),
373 });
374 let md = generate_markdown(&entity, &schema);
375
376 assert!(
377 md.contains(
378 "## Relationships\n- **OTHER**: [[child-entity]] \u{2014} replaced by checkout-flow"
379 ),
380 "expected canonical em-dash delimiter; got:\n{md}"
381 );
382 }
383
384 #[test]
385 fn generate_relationship_without_description_omits_em_dash() {
386 let schema = type_by_name(builtin_names::SPEC).unwrap();
387 let mut entity = make_entity("Parent", "specs");
388 entity.relationships.push(Relationship {
389 rel_type: "USES".to_string(),
390 target: EntityId::new("specs", "child-entity"),
391 description: None,
392 });
393 let md = generate_markdown(&entity, &schema);
394 assert!(md.contains("- **USES**: [[child-entity]]\n"));
397 assert!(
398 !md.contains("\u{2014}"),
399 "no em-dash should be emitted when description is None"
400 );
401 }
402
403 #[test]
404 fn generate_metadata_order_follows_schema() {
405 let schema = type_by_name(builtin_names::SPEC).unwrap();
406 let entity = make_entity("Order Test", "specs");
407 let md = generate_markdown(&entity, &schema);
408
409 let type_pos = md.find("type:").unwrap();
413 let created_pos = md.find("created_date:").unwrap();
414 let modified_pos = md.find("last_modified:").unwrap();
415 let level_pos = md.find("level:").unwrap();
416 let tags_pos = md.find("tags:").unwrap();
417
418 assert!(type_pos < created_pos);
419 assert!(created_pos < modified_pos);
420 assert!(modified_pos < level_pos);
421 assert!(level_pos < tags_pos);
422 }
423
424 #[test]
425 fn generate_omits_optional_absent() {
426 let schema = type_by_name(builtin_names::NARRATIVE).unwrap();
427 let mut metadata = IndexMap::new();
428 metadata.insert(
429 "reliability".to_string(),
430 MetadataValue::String("firsthand".to_string()),
431 );
432 metadata.insert(
433 "created_date".to_string(),
434 MetadataValue::String("2026-01-15".to_string()),
435 );
436 metadata.insert(
437 "last_modified".to_string(),
438 MetadataValue::String("2026-04-12".to_string()),
439 );
440 metadata.insert(
441 "type".to_string(),
442 MetadataValue::String("narrative".to_string()),
443 );
444
445 let mut sections = IndexMap::new();
446 for s in &schema.sections {
447 sections.insert(s.key.clone(), "placeholder.".to_string());
448 }
449
450 let entity = Entity {
451 id: EntityId::new("narratives", "no-optional"),
452 title: "No Optional".to_string(),
453 entity_type: "narrative".to_string(),
454 mem: "narratives".to_string(),
455 file_path: "no-optional.md".to_string(),
456 metadata,
457 sections,
458 relationships: Vec::new(),
459 content_hash: String::new(),
460 stub: false,
461 stub_kind: None,
462 heading_spans: std::collections::HashMap::new(),
463 raw_section_headings: Vec::new(),
464 };
465
466 let md = generate_markdown(&entity, &schema);
467 assert!(!md.contains("temporal_range:"));
469 }
470
471 #[test]
472 fn generate_ends_with_newline() {
473 let schema = type_by_name(builtin_names::SPEC).unwrap();
474 let entity = make_entity("Newline Test", "specs");
475 let md = generate_markdown(&entity, &schema);
476 assert!(md.ends_with('\n'));
477 }
478
479 #[test]
485 fn planning_goal_scope_sections_roundtrip() {
486 let reg = memstead_schema::SchemaRegistry::builtin();
490 let planning = reg
491 .get("planning", &semver::Version::new(0, 1, 0))
492 .expect("planning@0.1.0 is a built-in");
493 let goal = planning.get_type("goal").expect("goal type exists");
494
495 let mut metadata = IndexMap::new();
496 metadata.insert(
497 "type".to_string(),
498 MetadataValue::String("goal".to_string()),
499 );
500 let mut sections = IndexMap::new();
501 for s in &goal.sections {
502 sections.insert(s.key.clone(), String::new());
503 }
504 sections.insert("statement".to_string(), "Ship the gate.".to_string());
505 sections.insert("in_scope".to_string(), "- the engine".to_string());
506 sections.insert("out_of_scope".to_string(), "- the moon".to_string());
507
508 let entity = Entity {
509 id: EntityId::new("plan", "ship-the-gate"),
510 title: "Ship The Gate".to_string(),
511 entity_type: "goal".to_string(),
512 mem: "plan".to_string(),
513 file_path: "ship-the-gate.md".to_string(),
514 metadata,
515 sections,
516 relationships: Vec::new(),
517 content_hash: String::new(),
518 stub: false,
519 stub_kind: None,
520 heading_spans: std::collections::HashMap::new(),
521 raw_section_headings: Vec::new(),
522 };
523
524 let md = generate_markdown(&entity, &goal);
525 assert!(
526 md.contains("## In Scope"),
527 "declared heading emitted:\n{md}"
528 );
529 assert!(
530 md.contains("## Out of Scope"),
531 "declared heading emitted:\n{md}"
532 );
533
534 let parsed = crate::entity::parser::parse_markdown(&md, "ship-the-gate.md", &goal, "plan")
535 .expect("round-trip parse");
536 assert_eq!(
537 parsed.entity.sections.get("in_scope").map(|s| s.trim()),
538 Some("- the engine"),
539 "In Scope content lands under its declared key"
540 );
541 assert_eq!(
542 parsed.entity.sections.get("out_of_scope").map(|s| s.trim()),
543 Some("- the moon"),
544 "Out of Scope content lands under its declared key"
545 );
546 let notes = parsed
547 .entity
548 .sections
549 .get("notes")
550 .map(|s| s.as_str())
551 .unwrap_or("");
552 assert!(
553 !notes.contains("the engine") && !notes.contains("the moon"),
554 "scope content must not be absorbed into the catch-all: {notes:?}"
555 );
556 }
557
558 #[test]
559 fn roundtrip_parse_generate_parse() {
560 let schema = type_by_name(builtin_names::SPEC).unwrap();
561 let md = "\
562---
563type: spec
564created_date: 2026-01-15
565last_modified: 2026-04-12
566level: M0
567tags: backend, api
568---
569# Roundtrip Test
570
571## Identity
572
573This is a roundtrip test entity.
574
575## Purpose
576
577Testing parse→generate→parse stability.
578
579## Relationships
580
581- **USES**: [[other-entity]]
582
583## Specifies
584
585Some content with [[inline-link]].
586
587## Constraints
588
589
590
591## Rationale
592
593";
594
595 let result1 =
597 crate::entity::parser::parse_markdown(md, "roundtrip-test.md", &schema, "specs")
598 .unwrap();
599
600 let generated = generate_markdown(&result1.entity, &schema);
602
603 let result2 = crate::entity::parser::parse_markdown(
605 &generated,
606 "roundtrip-test.md",
607 &schema,
608 "specs",
609 )
610 .unwrap();
611
612 assert_eq!(result1.entity.title, result2.entity.title);
614 assert_eq!(result1.entity.id, result2.entity.id);
615 assert_eq!(
616 result1.entity.relationships.len(),
617 result2.entity.relationships.len()
618 );
619 for (r1, r2) in result1
620 .entity
621 .relationships
622 .iter()
623 .zip(&result2.entity.relationships)
624 {
625 assert_eq!(r1.rel_type, r2.rel_type);
626 assert_eq!(r1.target, r2.target);
627 }
628 for key in result1.entity.sections.keys() {
630 assert_eq!(
631 result1.entity.sections.get(key).map(|s| s.trim()),
632 result2.entity.sections.get(key).map(|s| s.trim()),
633 "Section '{key}' differs after roundtrip"
634 );
635 }
636 for (key, val) in &result1.entity.metadata {
638 assert_eq!(
639 Some(val),
640 result2.entity.metadata.get(key),
641 "Metadata '{key}' differs after roundtrip"
642 );
643 }
644
645 let generated2 = generate_markdown(&result2.entity, &schema);
647 assert_eq!(
648 generated, generated2,
649 "Second roundtrip changed the markdown"
650 );
651 }
652
653 #[test]
661 fn generate_quotes_number_shaped_string_value() {
662 let schema = type_by_name(builtin_names::NARRATIVE).unwrap();
663 let mut metadata = IndexMap::new();
664 metadata.insert(
665 "temporal_range".to_string(),
666 MetadataValue::String("1968".to_string()),
667 );
668 metadata.insert(
669 "reliability".to_string(),
670 MetadataValue::String("firsthand".to_string()),
671 );
672 metadata.insert(
673 "created_date".to_string(),
674 MetadataValue::String("2026-04-12".to_string()),
675 );
676 metadata.insert(
677 "last_modified".to_string(),
678 MetadataValue::String("2026-04-12".to_string()),
679 );
680 metadata.insert(
681 "tags".to_string(),
682 MetadataValue::String("history".to_string()),
683 );
684 metadata.insert(
685 "type".to_string(),
686 MetadataValue::String("narrative".to_string()),
687 );
688
689 let mut sections = IndexMap::new();
690 for s in &schema.sections {
691 sections.insert(s.key.clone(), "placeholder.".to_string());
692 }
693
694 let entity = Entity {
695 id: EntityId::new("specs", "ambiguous"),
696 title: "Ambiguous".to_string(),
697 entity_type: "narrative".to_string(),
698 mem: "specs".to_string(),
699 file_path: "ambiguous.md".to_string(),
700 metadata,
701 sections,
702 relationships: Vec::new(),
703 content_hash: String::new(),
704 stub: false,
705 stub_kind: None,
706 heading_spans: std::collections::HashMap::new(),
707 raw_section_headings: Vec::new(),
708 };
709
710 let md = generate_markdown(&entity, &schema);
711 assert!(
712 md.contains("temporal_range: \"1968\""),
713 "number-shaped string value must be emitted quoted, got:\n{md}"
714 );
715
716 let parsed =
719 crate::entity::parser::parse_markdown(&md, "ambiguous.md", &schema, "specs").unwrap();
720 assert_eq!(
721 parsed.entity.metadata.get("temporal_range"),
722 Some(&MetadataValue::String("1968".to_string())),
723 "quoted value must round-trip as String"
724 );
725 }
726}