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.optional && 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 }
262 }
263
264 fn make_assertion_scaffold(title: &str) -> Entity {
265 let schema = type_by_name(builtin_names::ASSERTION).unwrap();
266 let mut metadata = IndexMap::new();
267 metadata.insert(
268 "created_date".to_string(),
269 MetadataValue::String("2026-01-15".to_string()),
270 );
271 metadata.insert(
272 "last_modified".to_string(),
273 MetadataValue::String("2026-04-12".to_string()),
274 );
275 metadata.insert(
276 "type".to_string(),
277 MetadataValue::String("assertion".to_string()),
278 );
279
280 let mut sections = IndexMap::new();
281 for s in &schema.sections {
282 sections.insert(s.key.clone(), String::new());
283 }
284
285 let slug = crate::entity::id::title_to_slug(title).unwrap();
286 Entity {
287 id: EntityId::new("assertions", &slug),
288 title: title.to_string(),
289 entity_type: "assertion".to_string(),
290 mem: "assertions".to_string(),
291 file_path: format!("{slug}.md"),
292 metadata,
293 sections,
294 relationships: Vec::new(),
295 content_hash: String::new(),
296 stub: false,
297 stub_kind: None,
298 heading_spans: std::collections::HashMap::new(),
299 }
300 }
301
302 #[test]
303 fn generate_assertion_scaffold_has_schema_sections_and_defaults() {
304 let schema = type_by_name(builtin_names::ASSERTION).unwrap();
305 let entity = make_assertion_scaffold("Sled Outperforms Rocksdb");
306 let md = generate_markdown(&entity, &schema);
307
308 assert!(md.contains("## Claim"));
310 assert!(md.contains("## Evidence"));
311 assert!(md.contains("## Conditions"));
312 assert!(md.contains("## Counterevidence"));
313 assert!(!md.contains("## Identity"));
314 assert!(!md.contains("## Purpose"));
315
316 let claim_pos = md.find("## Claim").unwrap();
318 let evid_pos = md.find("## Evidence").unwrap();
319 let cond_pos = md.find("## Conditions").unwrap();
320 let counter_pos = md.find("## Counterevidence").unwrap();
321 assert!(claim_pos < evid_pos);
322 assert!(evid_pos < cond_pos);
323 assert!(cond_pos < counter_pos);
324
325 assert!(md.contains("type: assertion"));
327 assert!(md.contains("confidence: medium"));
328 assert!(md.contains("verification_status: unverified"));
329
330 assert!(!md.contains("source_quality:"));
332 assert!(!md.contains("last_verified:"));
333 }
334
335 #[test]
336 fn generate_basic_entity() {
337 let schema = type_by_name(builtin_names::SPEC).unwrap();
338 let entity = make_entity("Test Entity", "specs");
339 let md = generate_markdown(&entity, &schema);
340
341 assert!(md.starts_with("---\n"));
342 assert!(md.contains("# Test Entity"));
343 assert!(md.contains("## Identity\nTest identity."));
344 assert!(md.contains("## Purpose\nTest purpose."));
345 assert!(md.contains("type: spec"));
346 assert!(md.contains("level: M0"));
347 }
348
349 #[test]
350 fn generate_with_relationships() {
351 let schema = type_by_name(builtin_names::SPEC).unwrap();
352 let mut entity = make_entity("Parent", "specs");
353 entity.relationships.push(Relationship {
354 rel_type: "USES".to_string(),
355 target: EntityId::new("specs", "child-entity"),
356 description: None,
357 });
358 let md = generate_markdown(&entity, &schema);
359
360 assert!(md.contains("## Relationships\n- **USES**: [[child-entity]]"));
361 }
362
363 #[test]
364 fn generate_relationship_with_description_uses_em_dash_delimiter() {
365 let schema = type_by_name(builtin_names::SPEC).unwrap();
366 let mut entity = make_entity("Parent", "specs");
367 entity.relationships.push(Relationship {
368 rel_type: "OTHER".to_string(),
369 target: EntityId::new("specs", "child-entity"),
370 description: Some("replaced by checkout-flow".to_string()),
371 });
372 let md = generate_markdown(&entity, &schema);
373
374 assert!(
375 md.contains(
376 "## Relationships\n- **OTHER**: [[child-entity]] \u{2014} replaced by checkout-flow"
377 ),
378 "expected canonical em-dash delimiter; got:\n{md}"
379 );
380 }
381
382 #[test]
383 fn generate_relationship_without_description_omits_em_dash() {
384 let schema = type_by_name(builtin_names::SPEC).unwrap();
385 let mut entity = make_entity("Parent", "specs");
386 entity.relationships.push(Relationship {
387 rel_type: "USES".to_string(),
388 target: EntityId::new("specs", "child-entity"),
389 description: None,
390 });
391 let md = generate_markdown(&entity, &schema);
392 assert!(md.contains("- **USES**: [[child-entity]]\n"));
395 assert!(
396 !md.contains("\u{2014}"),
397 "no em-dash should be emitted when description is None"
398 );
399 }
400
401 #[test]
402 fn generate_metadata_order_follows_schema() {
403 let schema = type_by_name(builtin_names::SPEC).unwrap();
404 let entity = make_entity("Order Test", "specs");
405 let md = generate_markdown(&entity, &schema);
406
407 let type_pos = md.find("type:").unwrap();
411 let created_pos = md.find("created_date:").unwrap();
412 let modified_pos = md.find("last_modified:").unwrap();
413 let level_pos = md.find("level:").unwrap();
414 let tags_pos = md.find("tags:").unwrap();
415
416 assert!(type_pos < created_pos);
417 assert!(created_pos < modified_pos);
418 assert!(modified_pos < level_pos);
419 assert!(level_pos < tags_pos);
420 }
421
422 #[test]
423 fn generate_omits_optional_absent() {
424 let schema = type_by_name(builtin_names::NARRATIVE).unwrap();
425 let mut metadata = IndexMap::new();
426 metadata.insert(
427 "reliability".to_string(),
428 MetadataValue::String("firsthand".to_string()),
429 );
430 metadata.insert(
431 "created_date".to_string(),
432 MetadataValue::String("2026-01-15".to_string()),
433 );
434 metadata.insert(
435 "last_modified".to_string(),
436 MetadataValue::String("2026-04-12".to_string()),
437 );
438 metadata.insert(
439 "type".to_string(),
440 MetadataValue::String("narrative".to_string()),
441 );
442
443 let mut sections = IndexMap::new();
444 for s in &schema.sections {
445 sections.insert(s.key.clone(), "placeholder.".to_string());
446 }
447
448 let entity = Entity {
449 id: EntityId::new("narratives", "no-optional"),
450 title: "No Optional".to_string(),
451 entity_type: "narrative".to_string(),
452 mem: "narratives".to_string(),
453 file_path: "no-optional.md".to_string(),
454 metadata,
455 sections,
456 relationships: Vec::new(),
457 content_hash: String::new(),
458 stub: false,
459 stub_kind: None,
460 heading_spans: std::collections::HashMap::new(),
461 };
462
463 let md = generate_markdown(&entity, &schema);
464 assert!(!md.contains("temporal_range:"));
466 }
467
468 #[test]
469 fn generate_ends_with_newline() {
470 let schema = type_by_name(builtin_names::SPEC).unwrap();
471 let entity = make_entity("Newline Test", "specs");
472 let md = generate_markdown(&entity, &schema);
473 assert!(md.ends_with('\n'));
474 }
475
476 #[test]
477 fn roundtrip_parse_generate_parse() {
478 let schema = type_by_name(builtin_names::SPEC).unwrap();
479 let md = "\
480---
481type: spec
482created_date: 2026-01-15
483last_modified: 2026-04-12
484level: M0
485tags: backend, api
486---
487# Roundtrip Test
488
489## Identity
490
491This is a roundtrip test entity.
492
493## Purpose
494
495Testing parse→generate→parse stability.
496
497## Relationships
498
499- **USES**: [[other-entity]]
500
501## Specifies
502
503Some content with [[inline-link]].
504
505## Constraints
506
507
508
509## Rationale
510
511";
512
513 let result1 =
515 crate::entity::parser::parse_markdown(md, "roundtrip-test.md", &schema, "specs")
516 .unwrap();
517
518 let generated = generate_markdown(&result1.entity, &schema);
520
521 let result2 = crate::entity::parser::parse_markdown(
523 &generated,
524 "roundtrip-test.md",
525 &schema,
526 "specs",
527 )
528 .unwrap();
529
530 assert_eq!(result1.entity.title, result2.entity.title);
532 assert_eq!(result1.entity.id, result2.entity.id);
533 assert_eq!(
534 result1.entity.relationships.len(),
535 result2.entity.relationships.len()
536 );
537 for (r1, r2) in result1
538 .entity
539 .relationships
540 .iter()
541 .zip(&result2.entity.relationships)
542 {
543 assert_eq!(r1.rel_type, r2.rel_type);
544 assert_eq!(r1.target, r2.target);
545 }
546 for key in result1.entity.sections.keys() {
548 assert_eq!(
549 result1.entity.sections.get(key).map(|s| s.trim()),
550 result2.entity.sections.get(key).map(|s| s.trim()),
551 "Section '{key}' differs after roundtrip"
552 );
553 }
554 for (key, val) in &result1.entity.metadata {
556 assert_eq!(
557 Some(val),
558 result2.entity.metadata.get(key),
559 "Metadata '{key}' differs after roundtrip"
560 );
561 }
562
563 let generated2 = generate_markdown(&result2.entity, &schema);
565 assert_eq!(
566 generated, generated2,
567 "Second roundtrip changed the markdown"
568 );
569 }
570
571 #[test]
579 fn generate_quotes_number_shaped_string_value() {
580 let schema = type_by_name(builtin_names::NARRATIVE).unwrap();
581 let mut metadata = IndexMap::new();
582 metadata.insert(
583 "temporal_range".to_string(),
584 MetadataValue::String("1968".to_string()),
585 );
586 metadata.insert(
587 "reliability".to_string(),
588 MetadataValue::String("firsthand".to_string()),
589 );
590 metadata.insert(
591 "created_date".to_string(),
592 MetadataValue::String("2026-04-12".to_string()),
593 );
594 metadata.insert(
595 "last_modified".to_string(),
596 MetadataValue::String("2026-04-12".to_string()),
597 );
598 metadata.insert(
599 "tags".to_string(),
600 MetadataValue::String("history".to_string()),
601 );
602 metadata.insert(
603 "type".to_string(),
604 MetadataValue::String("narrative".to_string()),
605 );
606
607 let mut sections = IndexMap::new();
608 for s in &schema.sections {
609 sections.insert(s.key.clone(), "placeholder.".to_string());
610 }
611
612 let entity = Entity {
613 id: EntityId::new("specs", "ambiguous"),
614 title: "Ambiguous".to_string(),
615 entity_type: "narrative".to_string(),
616 mem: "specs".to_string(),
617 file_path: "ambiguous.md".to_string(),
618 metadata,
619 sections,
620 relationships: Vec::new(),
621 content_hash: String::new(),
622 stub: false,
623 stub_kind: None,
624 heading_spans: std::collections::HashMap::new(),
625 };
626
627 let md = generate_markdown(&entity, &schema);
628 assert!(
629 md.contains("temporal_range: \"1968\""),
630 "number-shaped string value must be emitted quoted, got:\n{md}"
631 );
632
633 let parsed =
636 crate::entity::parser::parse_markdown(&md, "ambiguous.md", &schema, "specs").unwrap();
637 assert_eq!(
638 parsed.entity.metadata.get("temporal_range"),
639 Some(&MetadataValue::String("1968".to_string())),
640 "quoted value must round-trip as String"
641 );
642 }
643}