1use std::collections::{HashMap, HashSet};
9use std::path::Path;
10use std::sync::OnceLock;
11
12use indexmap::IndexMap;
13use regex::Regex;
14use sha2::{Digest, Sha256};
15
16use memstead_schema::TypeDefinition;
17
18use super::id::{WikiLinkError, file_path_to_id, wiki_link_to_id, wiki_link_to_id_lenient};
19use super::{Entity, EntityId, HeadingSpan, MetadataValue, ParseResult, Relationship};
20
21pub fn parse_markdown(
23 content: &str,
24 relative_path: &str,
25 schema: &TypeDefinition,
26 mem: &str,
27) -> Result<ParseResult, ParseError> {
28 let id = file_path_to_id(relative_path, mem);
29
30 let content_hash = compute_hash(content);
32
33 let masked = mask_code_blocks(content);
35
36 let (metadata, body, masked_body) = split_frontmatter(content, &masked)?;
38
39 let title = extract_title(&body).unwrap_or_else(|| id.name().to_string());
41
42 let (sections_map, duplicate_headings) = split_sections(&body, &masked_body);
47
48 let rel_heading_key = "relationships";
54 let entity_id_for_rel_warnings = file_path_to_id(relative_path, mem);
55 let (relationships, rel_parse_warnings) = parse_relationships_with_warnings(
56 sections_map
57 .get(rel_heading_key)
58 .map(|s| s.as_str())
59 .unwrap_or(""),
60 mem,
61 Some(&entity_id_for_rel_warnings),
62 );
63
64 let catch_all_content = build_catch_all(§ions_map, schema);
66
67 let mut result_sections = IndexMap::new();
71 for s in &schema.sections {
72 if s.catch_all {
73 result_sections.insert(s.key.clone(), catch_all_content.trim().to_string());
74 } else {
75 let val = sections_map
76 .get(s.key.as_str())
77 .map(|v| v.trim().to_string())
78 .unwrap_or_default();
79 result_sections.insert(s.key.clone(), val);
80 }
81 }
82
83 let mut parsed_metadata = parse_metadata(&metadata);
85
86 let type_name = parsed_metadata
91 .get("type")
92 .and_then(|v| v.as_str())
93 .unwrap_or(schema.name.as_str())
94 .to_string();
95 parsed_metadata.insert("type".to_string(), MetadataValue::String(type_name.clone()));
96
97 let inline_link_text: String = schema
99 .text_fields
100 .iter()
101 .filter_map(|f| result_sections.get(f.as_str()))
102 .cloned()
103 .collect::<Vec<_>>()
104 .join("\n");
105 let inline_links = extract_inline_links_lenient(&inline_link_text, mem);
110
111 let explicit_targets: HashSet<_> = relationships.iter().map(|r| &r.target).collect();
113 let inline_links: Vec<EntityId> = inline_links
114 .into_iter()
115 .filter(|link| !explicit_targets.contains(link))
116 .collect();
117
118 let heading_spans = extract_heading_spans(&result_sections);
121
122 let declared_keys: HashSet<&str> = schema
126 .sections
127 .iter()
128 .filter(|s| !s.catch_all)
129 .map(|s| s.key.as_str())
130 .collect();
131 let entity_id_for_warnings = file_path_to_id(relative_path, mem);
132 let mut parse_warnings: Vec<crate::ops::WarningHint> = duplicate_headings
133 .into_iter()
134 .filter(|d| declared_keys.contains(d.key.as_str()))
135 .map(|d| crate::ops::WarningHint::DuplicateSectionHeading {
136 entity_id: entity_id_for_warnings.clone(),
137 section_key: d.key,
138 heading: d.heading,
139 occurrences: d.occurrences,
140 })
141 .collect();
142 parse_warnings.extend(rel_parse_warnings);
143
144 let entity = Entity {
145 id,
146 title,
147 entity_type: type_name,
148 mem: mem.to_string(),
149 file_path: relative_path.to_string(),
150 metadata: parsed_metadata,
151 sections: result_sections,
152 relationships,
153 content_hash,
154 stub: false,
155 stub_kind: None,
156 heading_spans,
157 };
158
159 Ok(ParseResult {
160 entity,
161 inline_links,
162 parse_warnings,
163 })
164}
165
166pub fn parse_file(
168 path: &Path,
169 mem_dir: &Path,
170 schema: &TypeDefinition,
171 mem: &str,
172) -> Result<ParseResult, ParseError> {
173 let content = std::fs::read_to_string(path)?;
174 let relative_path = path.strip_prefix(mem_dir).unwrap_or(path).to_string_lossy();
175 parse_markdown(&content, &relative_path, schema, mem)
176}
177
178pub fn peek_type_from_frontmatter(content: &str) -> Option<String> {
188 let after_open = if content.starts_with("---\r\n") {
189 5
190 } else if content.starts_with("---\n") {
191 4
192 } else {
193 return None;
194 };
195
196 let close_pos = content[after_open..].find("\n---")?;
197 let frontmatter = &content[after_open..after_open + close_pos];
198
199 for line in frontmatter.lines() {
200 let trimmed = line.trim();
201 if trimmed.is_empty() || trimmed.starts_with('#') {
202 continue;
203 }
204 let Some(colon_idx) = trimmed.find(':') else {
205 continue;
206 };
207 let key = trimmed[..colon_idx].trim();
208 if key != "type" {
209 continue;
210 }
211 let mut value = trimmed[colon_idx + 1..].trim();
212 if let Some(hash_idx) = value.find('#') {
213 value = value[..hash_idx].trim();
214 }
215 let value = value.trim_matches(|c| c == '"' || c == '\'');
216 if value.is_empty() {
217 return None;
218 }
219 return Some(value.to_string());
220 }
221 None
222}
223
224pub fn peek_title_and_type(content: &str) -> (Option<String>, Option<String>) {
233 let entity_type = peek_type_from_frontmatter(content);
234 let title = extract_title(body_after_frontmatter(content));
235 (title, entity_type)
236}
237
238fn body_after_frontmatter(content: &str) -> &str {
243 let after_open = if content.starts_with("---\r\n") {
244 5
245 } else if content.starts_with("---\n") {
246 4
247 } else {
248 return content;
249 };
250 let Some(close_pos) = content[after_open..].find("\n---") else {
251 return content;
252 };
253 let body_start = after_open + close_pos + 4; let rest = &content[body_start..];
255 rest.strip_prefix("\r\n")
256 .or_else(|| rest.strip_prefix('\n'))
257 .unwrap_or(rest)
258}
259
260fn split_frontmatter<'a>(
263 content: &'a str,
264 masked: &'a str,
265) -> Result<(String, String, String), ParseError> {
266 if content.starts_with("---\n") || content.starts_with("---\r\n") {
268 let after_open = if content.starts_with("---\r\n") { 5 } else { 4 };
269 if let Some(close_pos) = content[after_open..].find("\n---") {
271 let meta_end = after_open + close_pos;
272 let metadata = content[after_open..meta_end].to_string();
273 let body_start = meta_end + 4; let body_start = if content[body_start..].starts_with('\n') {
276 body_start + 1
277 } else if content[body_start..].starts_with("\r\n") {
278 body_start + 2
279 } else {
280 body_start
281 };
282 let body = content[body_start..].to_string();
283 let masked_body = masked[body_start..].to_string();
284 return Ok((metadata, body, masked_body));
285 }
286 }
287
288 Ok((String::new(), content.to_string(), masked.to_string()))
290}
291
292fn parse_metadata(text: &str) -> IndexMap<String, MetadataValue> {
297 let mut meta = IndexMap::new();
298 if text.is_empty() {
299 return meta;
300 }
301
302 for line in text.lines() {
303 let trimmed = line.trim();
304 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("---") {
306 continue;
307 }
308
309 let Some(colon_idx) = trimmed.find(':') else {
310 continue;
311 };
312
313 let key = trimmed[..colon_idx].trim().to_string();
314 let raw_value = trimmed[colon_idx + 1..].trim();
315
316 let value = strip_inline_comment(raw_value).trim().to_string();
318
319 if value.is_empty() {
320 meta.insert(key, MetadataValue::String(String::new()));
321 continue;
322 }
323
324 if value == "true" {
326 meta.insert(key, MetadataValue::Bool(true));
327 } else if value == "false" {
328 meta.insert(key, MetadataValue::Bool(false));
329 } else if is_float_literal(&value) {
330 if let Ok(f) = value.parse::<f64>() {
331 meta.insert(key, MetadataValue::Float(f));
332 } else {
333 meta.insert(key, MetadataValue::String(strip_quotes(&value)));
334 }
335 } else if is_integer_literal(&value) {
336 if let Ok(n) = value.parse::<i64>() {
337 meta.insert(key, MetadataValue::Integer(n));
338 } else {
339 meta.insert(key, MetadataValue::String(strip_quotes(&value)));
340 }
341 } else {
342 meta.insert(key, MetadataValue::String(strip_quotes(&value)));
343 }
344 }
345
346 meta
347}
348
349fn is_float_literal(s: &str) -> bool {
351 let s = s.strip_prefix('-').unwrap_or(s);
352 if let Some((before, after)) = s.split_once('.') {
353 !before.is_empty()
354 && before.chars().all(|c| c.is_ascii_digit())
355 && !after.is_empty()
356 && after.chars().all(|c| c.is_ascii_digit())
357 } else {
358 false
359 }
360}
361
362fn is_integer_literal(s: &str) -> bool {
364 let s = s.strip_prefix('-').unwrap_or(s);
365 !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
366}
367
368pub(crate) fn would_coerce_from_string(s: &str) -> bool {
374 s == "true" || s == "false" || is_integer_literal(s) || is_float_literal(s)
375}
376
377fn strip_inline_comment(s: &str) -> &str {
379 if let Some(idx) = s.find(" #") {
382 s[..idx].trim_end()
383 } else {
384 s
385 }
386}
387
388fn strip_quotes(s: &str) -> String {
392 if s.len() >= 2
393 && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
394 {
395 s[1..s.len() - 1].to_string()
396 } else {
397 s.to_string()
398 }
399}
400
401pub fn mask_code_blocks(text: &str) -> String {
408 let lines: Vec<&str> = text.split('\n').collect();
409 let mut result = Vec::with_capacity(lines.len());
410 let mut fence: Option<String> = None;
411
412 for line in &lines {
413 if let Some(ref _f) = fence {
414 let trimmed = line.trim_end();
416 if trimmed.starts_with("```") {
417 result.push(" ".repeat(line.len()));
418 fence = None;
419 } else {
420 result.push(" ".repeat(line.len()));
421 }
422 } else {
423 if line.starts_with("```") {
425 fence = Some("```".to_string());
426 result.push(" ".repeat(line.len()));
427 } else {
428 result.push((*line).to_string());
429 }
430 }
431 }
432
433 result.join("\n")
434}
435
436pub(super) struct DuplicateSection {
446 pub key: String,
447 pub heading: String,
448 pub occurrences: usize,
449}
450
451pub(super) fn split_sections(
458 body: &str,
459 masked_body: &str,
460) -> (HashMap<String, String>, Vec<DuplicateSection>) {
461 let mut sections = HashMap::new();
462 let mut duplicates: HashMap<String, DuplicateSection> = HashMap::new();
463 static SECTION_RE: OnceLock<Regex> = OnceLock::new();
464 let section_re = SECTION_RE.get_or_init(|| Regex::new(r"(?m)^## (.+)$").unwrap());
465
466 let matches: Vec<_> = section_re.find_iter(masked_body).collect();
467
468 for (i, m) in matches.iter().enumerate() {
469 let heading_line = &body[m.start()..m.end()];
471 let name = heading_line
472 .strip_prefix("## ")
473 .unwrap_or(heading_line)
474 .trim();
475
476 let content_start = m.end();
477 let content_end = if i + 1 < matches.len() {
478 matches[i + 1].start()
479 } else {
480 body.len()
481 };
482 let content = body[content_start..content_end].trim().to_string();
483 let key = name.to_lowercase().replace(' ', "_");
489
490 match sections.entry(key.clone()) {
491 std::collections::hash_map::Entry::Vacant(slot) => {
492 slot.insert(content);
493 duplicates.insert(
494 key.clone(),
495 DuplicateSection {
496 key: key.clone(),
497 heading: name.to_string(),
498 occurrences: 1,
499 },
500 );
501 }
502 std::collections::hash_map::Entry::Occupied(_) => {
503 if let Some(d) = duplicates.get_mut(&key) {
506 d.occurrences += 1;
507 }
508 }
509 }
510 }
511
512 let dup_list: Vec<DuplicateSection> = duplicates
513 .into_values()
514 .filter(|d| d.occurrences > 1)
515 .collect();
516
517 (sections, dup_list)
518}
519
520fn extract_title(body: &str) -> Option<String> {
522 for line in body.lines() {
523 if let Some(title) = line.strip_prefix("# ") {
524 return Some(title.trim().to_string());
525 }
526 }
527 None
528}
529
530fn extract_heading_spans(sections: &IndexMap<String, String>) -> HashMap<String, Vec<HeadingSpan>> {
545 static RE: OnceLock<Regex> = OnceLock::new();
547 let re = RE.get_or_init(|| Regex::new(r"(?m)^(#{3,6})[ \t]+(.+)$").unwrap());
548 let mut out: HashMap<String, Vec<HeadingSpan>> = HashMap::new();
549
550 for (key, content) in sections {
551 if content.is_empty() {
552 continue;
553 }
554 let masked = mask_code_blocks(content);
555
556 let raw: Vec<(usize, u8, String)> = re
558 .captures_iter(&masked)
559 .map(|cap| {
560 let whole = cap.get(0).unwrap();
561 let level = cap[1].len() as u8; let line_end = content[whole.start()..]
565 .find('\n')
566 .map(|i| whole.start() + i)
567 .unwrap_or(content.len());
568 let hashes_end = whole.start() + level as usize;
569 let title = content[hashes_end..line_end].trim().to_string();
570 (whole.start(), level, title)
571 })
572 .collect();
573
574 if raw.is_empty() {
575 continue;
576 }
577
578 let mut spans: Vec<HeadingSpan> = Vec::with_capacity(raw.len());
579 for (i, &(start, level, ref title)) in raw.iter().enumerate() {
580 let end = raw[i + 1..]
582 .iter()
583 .find(|(_, l, _)| *l <= level)
584 .map(|(s, _, _)| *s)
585 .unwrap_or(content.len());
586 spans.push(HeadingSpan {
587 level,
588 title: title.clone(),
589 start_offset: start,
590 end_offset: end,
591 });
592 }
593 out.insert(key.clone(), spans);
594 }
595
596 out
597}
598
599fn build_catch_all(sections: &HashMap<String, String>, schema: &TypeDefinition) -> String {
605 let catch_all = match schema.catch_all_section() {
606 Some(s) => s,
607 None => return String::new(),
608 };
609
610 let known_sections: HashSet<&str> = schema
611 .sections
612 .iter()
613 .map(|s| s.key.as_str())
614 .chain(std::iter::once("relationships"))
615 .collect();
616
617 let mut parts = Vec::new();
618
619 if let Some(content) = sections.get(catch_all.key.as_str())
621 && !content.is_empty()
622 {
623 parts.push(content.clone());
624 }
625
626 for (key, content) in sections {
635 if !known_sections.contains(key.as_str()) && !content.is_empty() {
636 let heading = format!(
637 "## {}{}",
638 key.chars().next().unwrap_or_default().to_uppercase(),
639 &key[key.chars().next().map_or(0, |c| c.len_utf8())..]
640 );
641 parts.push(format!("{heading}\n{content}"));
642 }
643 }
644
645 parts.join("\n\n")
646}
647
648pub(crate) fn parse_relationships_with_warnings(
663 text: &str,
664 mem: &str,
665 entity_id: Option<&EntityId>,
666) -> (Vec<Relationship>, Vec<crate::ops::WarningHint>) {
667 static RE: OnceLock<Regex> = OnceLock::new();
671 let re = RE.get_or_init(|| {
672 Regex::new(r"(?m)^\s*-\s*\*\*(\w+)\*\*:\s*\[\[([^\]]+)\]\](?P<tail>[^\n]*)").unwrap()
673 });
674 let mut relationships = Vec::new();
675 let mut warnings = Vec::new();
676 for cap in re.captures_iter(text) {
677 let rel_type = cap[1].to_uppercase();
678 let target = wiki_link_to_id_lenient(&cap[2], mem);
684 let tail = cap.name("tail").map(|m| m.as_str()).unwrap_or("");
685 let description = match classify_description_tail(tail) {
686 DescriptionTail::None => None,
687 DescriptionTail::EmDash(text) => Some(text),
688 DescriptionTail::Ambiguous(literal) => {
689 if let Some(id) = entity_id {
690 warnings.push(crate::ops::WarningHint::AmbiguousDescriptionDelimiter {
691 from: id.clone(),
692 rel_type: rel_type.clone(),
693 target: target.clone(),
694 trailing: literal,
695 });
696 }
697 None
698 }
699 };
700 relationships.push(Relationship {
701 rel_type,
702 target,
703 description,
704 });
705 }
706 (relationships, warnings)
707}
708
709enum DescriptionTail {
712 None,
714 EmDash(String),
717 Ambiguous(String),
721}
722
723fn classify_description_tail(tail: &str) -> DescriptionTail {
729 let trimmed_end = tail.trim_end();
730 if trimmed_end.is_empty() {
731 return DescriptionTail::None;
732 }
733 if let Some(rest) = trimmed_end.strip_prefix(" \u{2014} ") {
735 if rest.is_empty() {
736 return DescriptionTail::None;
737 }
738 return DescriptionTail::EmDash(rest.to_string());
739 }
740 if let Some(rest) = trimmed_end.strip_prefix(" \u{2014}") {
744 return DescriptionTail::Ambiguous(format!(" \u{2014}{rest}"));
746 }
747 let starters = [" --", " -", " \u{2013}", " \u{2212}"];
749 if starters
750 .iter()
751 .any(|prefix| trimmed_end.starts_with(prefix))
752 {
753 return DescriptionTail::Ambiguous(trimmed_end.to_string());
754 }
755 DescriptionTail::Ambiguous(trimmed_end.to_string())
759}
760
761#[derive(Debug, Clone)]
767pub struct WikiLink {
768 pub target: String,
769 pub label: Option<String>,
770}
771
772fn wiki_link_re() -> &'static Regex {
774 static RE: OnceLock<Regex> = OnceLock::new();
775 RE.get_or_init(|| Regex::new(r"\[\[([^\]]+)\]\]").unwrap())
776}
777
778fn inline_code_re() -> &'static Regex {
780 static RE: OnceLock<Regex> = OnceLock::new();
781 RE.get_or_init(|| Regex::new(r"`[^`]+`").unwrap())
782}
783
784pub fn extract_wiki_links(content: &str) -> Vec<WikiLink> {
786 let re = wiki_link_re();
787 re.captures_iter(content)
788 .map(|cap| {
789 let raw = &cap[1];
790 let (target, label) = match raw.find('|') {
791 Some(i) => (raw[..i].to_string(), Some(raw[i + 1..].to_string())),
792 None => (raw.to_string(), None),
793 };
794 WikiLink { target, label }
795 })
796 .collect()
797}
798
799pub(crate) fn extract_inline_links(
812 text: &str,
813 mem: &str,
814) -> Result<Vec<EntityId>, Vec<WikiLinkError>> {
815 let stripped = mask_code_blocks(text);
816 let stripped = inline_code_re().replace_all(&stripped, "");
817
818 let link_re = wiki_link_re();
819 let mut seen = HashSet::new();
820 let mut links = Vec::new();
821 let mut errors = Vec::new();
822
823 for cap in link_re.captures_iter(&stripped) {
824 match wiki_link_to_id(&cap[1], mem) {
825 Ok(id) => {
826 if errors.is_empty() && seen.insert(id.0.clone()) {
827 links.push(id);
828 }
829 }
830 Err(e) => errors.push(e),
831 }
832 }
833
834 if errors.is_empty() {
835 Ok(links)
836 } else {
837 Err(errors)
838 }
839}
840
841pub fn extract_inline_links_lenient(text: &str, mem: &str) -> Vec<EntityId> {
848 let stripped = mask_code_blocks(text);
849 let stripped = inline_code_re().replace_all(&stripped, "");
850
851 let link_re = wiki_link_re();
852 let mut seen = HashSet::new();
853 let mut links = Vec::new();
854
855 for cap in link_re.captures_iter(&stripped) {
856 let id = wiki_link_to_id_lenient(&cap[1], mem);
857 if seen.insert(id.0.clone()) {
858 links.push(id);
859 }
860 }
861
862 links
863}
864
865pub fn compute_hash(content: &str) -> String {
871 let mut hasher = Sha256::new();
872 hasher.update(content.as_bytes());
873 let result = hasher.finalize();
874 format!("{:x}", result)[..16].to_string()
875}
876
877#[derive(Debug, thiserror::Error)]
882pub enum ParseError {
883 #[error("missing frontmatter")]
884 MissingFrontmatter,
885 #[error("invalid frontmatter: {0}")]
886 InvalidFrontmatter(String),
887 #[error("missing title")]
888 MissingTitle,
889 #[error("io error: {0}")]
890 Io(#[from] std::io::Error),
891}
892
893#[cfg(test)]
894mod tests {
895 use super::*;
896 use memstead_schema::{builtin_names, type_by_name};
897 use std::sync::Arc;
898
899 fn spec_schema() -> Arc<TypeDefinition> {
900 type_by_name(builtin_names::SPEC).unwrap()
901 }
902
903 fn memo_schema() -> Arc<TypeDefinition> {
904 type_by_name(builtin_names::MEMO).unwrap()
905 }
906
907 #[test]
908 fn parse_metadata_types() {
909 let meta = parse_metadata("key: value\nnum: 42\nfloat: 0.85\nbool: true\nfalsy: false");
910 assert_eq!(meta["key"], MetadataValue::String("value".to_string()));
911 assert_eq!(meta["num"], MetadataValue::Integer(42));
912 assert_eq!(meta["float"], MetadataValue::Float(0.85));
913 assert_eq!(meta["bool"], MetadataValue::Bool(true));
914 assert_eq!(meta["falsy"], MetadataValue::Bool(false));
915 }
916
917 #[test]
918 fn parse_metadata_strips_comments() {
919 let meta = parse_metadata("key: value # this is a comment");
920 assert_eq!(meta["key"], MetadataValue::String("value".to_string()));
921 }
922
923 #[test]
924 fn parse_metadata_strips_quotes() {
925 let meta = parse_metadata("key: \"quoted value\"\nkey2: 'single'");
926 assert_eq!(
927 meta["key"],
928 MetadataValue::String("quoted value".to_string())
929 );
930 assert_eq!(meta["key2"], MetadataValue::String("single".to_string()));
931 }
932
933 #[test]
934 fn parse_metadata_survives_malformed_values() {
935 let meta = parse_metadata(
938 "key: \"\nkey2: '\nkey3: \"\"\nkey4: ''\nkey5: \"unterminated\nkey6: mixed'\"",
939 );
940 assert_eq!(meta["key"], MetadataValue::String("\"".to_string()));
941 assert_eq!(meta["key2"], MetadataValue::String("'".to_string()));
942 assert_eq!(meta["key3"], MetadataValue::String(String::new()));
943 assert_eq!(meta["key4"], MetadataValue::String(String::new()));
944 assert_eq!(
945 meta["key5"],
946 MetadataValue::String("\"unterminated".to_string())
947 );
948 assert_eq!(meta["key6"], MetadataValue::String("mixed'\"".to_string()));
949
950 let meta =
953 parse_metadata(":\n: value\nkey7: ✓\"\nkey8: 99999999999999999999999999\nkey9: -");
954 assert_eq!(meta["key7"], MetadataValue::String("✓\"".to_string()));
955 assert_eq!(
956 meta["key8"],
957 MetadataValue::String("99999999999999999999999999".to_string())
958 );
959 assert_eq!(meta["key9"], MetadataValue::String("-".to_string()));
960 }
961
962 #[test]
963 fn parse_metadata_skips_comments_and_empty() {
964 let meta = parse_metadata("# comment\n\nkey: val\n---");
965 assert_eq!(meta.len(), 1);
966 assert_eq!(meta["key"], MetadataValue::String("val".to_string()));
967 }
968
969 #[test]
970 fn peek_type_finds_value() {
971 let content = "---\ntype: memo\ntitle: Test\n---\n# Body\n";
972 assert_eq!(
973 peek_type_from_frontmatter(content),
974 Some("memo".to_string())
975 );
976 }
977
978 #[test]
979 fn peek_type_returns_none_when_missing() {
980 let content = "---\ntitle: Test\n---\n# Body\n";
981 assert_eq!(peek_type_from_frontmatter(content), None);
982 }
983
984 #[test]
985 fn peek_type_returns_none_without_frontmatter() {
986 let content = "# Just a heading\n\nBody with type: concept inside text.\n";
987 assert_eq!(peek_type_from_frontmatter(content), None);
988 }
989
990 #[test]
991 fn peek_type_handles_windows_line_endings() {
992 let content = "---\r\ntype: principle\r\n---\r\n# Body\r\n";
993 assert_eq!(
994 peek_type_from_frontmatter(content),
995 Some("principle".to_string())
996 );
997 }
998
999 #[test]
1000 fn peek_type_strips_quotes_and_comments() {
1001 let quoted = "---\ntype: \"concept\"\n---\n";
1002 assert_eq!(
1003 peek_type_from_frontmatter(quoted),
1004 Some("concept".to_string())
1005 );
1006 let commented = "---\ntype: memo # kind of\n---\n";
1007 assert_eq!(
1008 peek_type_from_frontmatter(commented),
1009 Some("memo".to_string())
1010 );
1011 }
1012
1013 #[test]
1014 fn peek_type_empty_value_returns_none() {
1015 let content = "---\ntype:\n---\n";
1016 assert_eq!(peek_type_from_frontmatter(content), None);
1017 }
1018
1019 #[test]
1020 fn peek_type_ignores_legacy_schema_key() {
1021 let content = concat!("---\n", "schema", ": memo\n---\n");
1024 assert_eq!(peek_type_from_frontmatter(content), None);
1025 }
1026
1027 #[test]
1028 fn mask_code_blocks_basic() {
1029 let input = "before\n```\ncode [[link]]\n```\nafter";
1030 let masked = mask_code_blocks(input);
1031 assert!(!masked.contains("[[link]]"));
1032 assert!(masked.contains("before"));
1033 assert!(masked.contains("after"));
1034 }
1035
1036 #[test]
1037 fn mask_code_blocks_preserves_line_count() {
1038 let input = "line1\n```\ncode\nmore code\n```\nline6";
1039 let masked = mask_code_blocks(input);
1040 assert_eq!(input.lines().count(), masked.lines().count());
1041 }
1042
1043 #[test]
1044 fn mask_code_blocks_unclosed() {
1045 let input = "before\n```\ncode\nmore code";
1046 let masked = mask_code_blocks(input);
1047 assert!(masked.contains("before"));
1048 assert!(!masked.contains("code"));
1049 }
1050
1051 #[test]
1052 fn extract_wiki_links_basic() {
1053 let links = extract_wiki_links("See [[target]] and [[other|label]]");
1054 assert_eq!(links.len(), 2);
1055 assert_eq!(links[0].target, "target");
1056 assert_eq!(links[1].target, "other");
1057 assert_eq!(links[1].label.as_deref(), Some("label"));
1058 }
1059
1060 #[test]
1061 fn parse_relationships_basic() {
1062 let text = "- **USES**: [[target-entity]]\n- **PART_OF**: [[parent]]";
1063 let rels = parse_relationships_with_warnings(text, "specs", None).0;
1064 assert_eq!(rels.len(), 2);
1065 assert_eq!(rels[0].rel_type, "USES");
1066 assert_eq!(rels[0].target.0, "specs--target-entity");
1067 assert_eq!(rels[1].rel_type, "PART_OF");
1068 assert_eq!(rels[1].target.0, "specs--parent");
1069 assert!(rels[0].description.is_none());
1071 assert!(rels[1].description.is_none());
1072 }
1073
1074 #[test]
1075 fn parse_relationships_canonical_em_dash_captures_description() {
1076 let text = "- **OTHER**: [[a]] \u{2014} replaced by checkout-flow";
1077 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1078 assert_eq!(rels.len(), 1);
1079 assert_eq!(
1080 rels[0].description.as_deref(),
1081 Some("replaced by checkout-flow")
1082 );
1083 assert!(warnings.is_empty(), "canonical em-dash does not warn");
1084 }
1085
1086 #[test]
1087 fn parse_relationships_em_dash_inside_description_body() {
1088 let text = "- **OTHER**: [[a]] \u{2014} note with — inside body";
1089 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1090 assert_eq!(rels.len(), 1);
1091 assert_eq!(
1092 rels[0].description.as_deref(),
1093 Some("note with — inside body"),
1094 "the parser captures up to end-of-line; em-dashes inside the body survive"
1095 );
1096 assert!(warnings.is_empty());
1097 }
1098
1099 #[test]
1100 fn parse_relationships_ambiguous_double_hyphen_warns_and_drops_content() {
1101 let text = "- **USES**: [[a]] -- legacy delimiter";
1102 let entity_id = EntityId::new("specs", "src");
1103 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", Some(&entity_id));
1104 assert_eq!(rels.len(), 1);
1105 assert!(rels[0].description.is_none(), "trailing content is dropped");
1106 assert_eq!(warnings.len(), 1);
1107 assert!(matches!(
1108 warnings[0],
1109 crate::ops::WarningHint::AmbiguousDescriptionDelimiter { .. }
1110 ));
1111 }
1112
1113 #[test]
1114 fn parse_relationships_ambiguous_single_hyphen_warns_and_drops_content() {
1115 let text = "- **USES**: [[a]] - single hyphen";
1116 let entity_id = EntityId::new("specs", "src");
1117 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", Some(&entity_id));
1118 assert_eq!(rels.len(), 1);
1119 assert!(rels[0].description.is_none());
1120 assert_eq!(warnings.len(), 1);
1121 assert_eq!(warnings[0].code(), "AMBIGUOUS_DESCRIPTION_DELIMITER");
1122 }
1123
1124 #[test]
1125 fn parse_relationships_hyphenated_slug_target_parses_unambiguously() {
1126 let text = "- **USES**: [[some-slug-with-hyphens]] \u{2014} ok";
1127 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1128 assert_eq!(rels.len(), 1);
1129 assert_eq!(rels[0].target.path(), "some-slug-with-hyphens");
1130 assert_eq!(rels[0].description.as_deref(), Some("ok"));
1131 assert!(warnings.is_empty());
1132 }
1133
1134 #[test]
1135 fn parse_full_entity() {
1136 let md = "\
1137---
1138type: spec
1139created_date: 2026-01-15
1140last_modified: 2026-04-12
1141level: M0
1142tags: backend, api
1143---
1144# Test Entity
1145
1146## Identity
1147
1148This is a test entity.
1149
1150## Purpose
1151
1152Testing the parser.
1153
1154## Relationships
1155
1156- **USES**: [[other-entity]]
1157
1158## Specifies
1159
1160Some specification content with [[inline-link]].
1161";
1162 let result = parse_markdown(md, "test-entity.md", &spec_schema(), "specs").unwrap();
1163 let entity = &result.entity;
1164 assert_eq!(entity.id.0, "specs--test-entity");
1165 assert_eq!(entity.title, "Test Entity");
1166 assert_eq!(entity.mem, "specs");
1167 assert_eq!(
1168 entity.metadata["type"],
1169 MetadataValue::String("spec".to_string())
1170 );
1171 assert_eq!(
1172 entity.metadata["level"],
1173 MetadataValue::String("M0".to_string())
1174 );
1175 assert_eq!(
1176 entity.metadata["tags"],
1177 MetadataValue::String("backend, api".to_string())
1178 );
1179 assert_eq!(entity.sections["identity"], "This is a test entity.");
1180 assert_eq!(entity.sections["purpose"], "Testing the parser.");
1181 assert_eq!(entity.relationships.len(), 1);
1182 assert_eq!(entity.relationships[0].rel_type, "USES");
1183 assert_eq!(entity.relationships[0].target.0, "specs--other-entity");
1184 assert_eq!(result.inline_links.len(), 1);
1185 assert_eq!(result.inline_links[0].0, "specs--inline-link");
1186 }
1187
1188 #[test]
1189 fn parse_full_entity_memo_schema() {
1190 let md = "\
1191---
1192type: memo
1193created_date: 2026-01-15
1194last_modified: 2026-04-12
1195status: active
1196tags: decision, architecture
1197---
1198# Use Sled For Storage
1199
1200## Claim
1201
1202Sled is the right embedded store for this workload.
1203
1204## Context
1205
1206We evaluated sled, rocksdb, and sqlite for the in-process graph cache.
1207
1208## Substance
1209
1210Sled wins on pure-Rust dependency footprint.
1211";
1212 let result = parse_markdown(md, "use-sled.md", &memo_schema(), "memos").unwrap();
1213 let entity = &result.entity;
1214 assert_eq!(entity.id.0, "memos--use-sled");
1215 assert_eq!(entity.title, "Use Sled For Storage");
1216 assert_eq!(entity.mem, "memos");
1217 assert_eq!(
1218 entity.metadata["type"],
1219 MetadataValue::String("memo".to_string())
1220 );
1221 assert_eq!(
1222 entity.metadata["status"],
1223 MetadataValue::String("active".to_string())
1224 );
1225 assert_eq!(
1226 entity.sections["claim"],
1227 "Sled is the right embedded store for this workload."
1228 );
1229 assert_eq!(
1230 entity.sections["context"],
1231 "We evaluated sled, rocksdb, and sqlite for the in-process graph cache."
1232 );
1233 assert_eq!(
1234 entity.sections["substance"],
1235 "Sled wins on pure-Rust dependency footprint."
1236 );
1237 assert!(!entity.sections.contains_key("identity"));
1238 assert!(!entity.sections.contains_key("purpose"));
1239 }
1240
1241 #[test]
1242 fn parse_entity_without_frontmatter() {
1243 let md = "# No Frontmatter\n\n## Identity\n\nJust a title and section.";
1244 let result = parse_markdown(md, "no-fm.md", &spec_schema(), "specs").unwrap();
1245 assert_eq!(result.entity.title, "No Frontmatter");
1246 assert_eq!(result.entity.metadata.len(), 1);
1248 assert_eq!(
1249 result.entity.metadata.get("type"),
1250 Some(&MetadataValue::String("spec".to_string()))
1251 );
1252 }
1253
1254 #[test]
1255 fn parse_entity_code_blocks_not_detected() {
1256 let md = "\
1257---
1258type: spec
1259---
1260# Code Test
1261
1262## Identity
1263
1264Test entity.
1265
1266## Specifies
1267
1268```
1269## Not A Section
1270- **USES**: [[not-a-link]]
1271```
1272
1273Real content after code block.
1274";
1275 let result = parse_markdown(md, "code-test.md", &spec_schema(), "specs").unwrap();
1276 assert!(!result.entity.sections.contains_key("not a section"));
1278 assert!(result.inline_links.is_empty());
1280 }
1281
1282 #[test]
1283 fn compute_hash_deterministic() {
1284 let hash1 = compute_hash("test content");
1285 let hash2 = compute_hash("test content");
1286 assert_eq!(hash1, hash2);
1287 assert_eq!(hash1.len(), 16);
1288 }
1289
1290 #[test]
1291 fn compute_hash_differs() {
1292 let hash1 = compute_hash("content a");
1293 let hash2 = compute_hash("content b");
1294 assert_ne!(hash1, hash2);
1295 }
1296
1297 #[test]
1298 fn is_float_literal_matches() {
1299 assert!(is_float_literal("0.85"));
1300 assert!(is_float_literal("-1.5"));
1301 assert!(is_float_literal("100.0"));
1302 assert!(!is_float_literal(".5"));
1303 assert!(!is_float_literal("1."));
1304 assert!(!is_float_literal("42"));
1305 assert!(!is_float_literal("hello"));
1306 }
1307
1308 #[test]
1309 fn is_integer_literal_matches() {
1310 assert!(is_integer_literal("42"));
1311 assert!(is_integer_literal("-1"));
1312 assert!(is_integer_literal("0"));
1313 assert!(!is_integer_literal("0.5"));
1314 assert!(!is_integer_literal("hello"));
1315 assert!(!is_integer_literal(""));
1316 }
1317
1318 #[test]
1324 fn parse_preserves_frontmatter_key_order() {
1325 let md = "\
1326---
1327type: principle
1328universality: domain-wide
1329authority: proposed
1330tags: a, b, c
1331created_date: 2026-01-15
1332last_modified: 2026-04-12
1333---
1334# Key Order
1335";
1336 let result = parse_markdown(
1337 md,
1338 "key-order.md",
1339 &type_by_name(builtin_names::PRINCIPLE).unwrap(),
1340 "knowledge",
1341 )
1342 .unwrap();
1343 let keys: Vec<&str> = result.entity.metadata.keys().map(|s| s.as_str()).collect();
1344 assert_eq!(
1345 keys,
1346 vec![
1347 "type",
1348 "universality",
1349 "authority",
1350 "tags",
1351 "created_date",
1352 "last_modified",
1353 ],
1354 "metadata iteration must preserve frontmatter declaration order"
1355 );
1356 }
1357
1358 #[test]
1366 fn parse_write_roundtrip_preserves_section_order() {
1367 let md = "\
1368---
1369type: spec
1370created_date: 2026-01-15
1371last_modified: 2026-04-12
1372level: M0
1373---
1374# Order Roundtrip
1375
1376## Identity
1377
1378Identity content.
1379
1380## Purpose
1381
1382Purpose content.
1383
1384## Specifies
1385
1386Specifies content.
1387";
1388 let schema = spec_schema();
1389 let first = parse_markdown(md, "order-roundtrip.md", &schema, "specs").unwrap();
1390 let regenerated = crate::entity::generator::generate_markdown(&first.entity, &schema);
1391 let second = parse_markdown(®enerated, "order-roundtrip.md", &schema, "specs").unwrap();
1392
1393 let first_keys: Vec<&String> = first.entity.sections.keys().collect();
1394 let second_keys: Vec<&String> = second.entity.sections.keys().collect();
1395 assert_eq!(
1396 first_keys, second_keys,
1397 "section iteration order must survive parse -> generate -> parse"
1398 );
1399 }
1400
1401 #[test]
1411 fn parser_extracts_single_h3() {
1412 let md = "\
1413---
1414type: spec
1415---
1416# Entity
1417
1418## Identity
1419
1420Body.
1421
1422## Specifies
1423
1424### Response Shapes
1425
1426Content under response shapes.
1427";
1428 let result = parse_markdown(md, "h3-single.md", &spec_schema(), "specs").unwrap();
1429 let spans = result
1430 .entity
1431 .heading_spans
1432 .get("specifies")
1433 .expect("specifies section should have spans");
1434 assert_eq!(spans.len(), 1);
1435 assert_eq!(spans[0].level, 3);
1436 assert_eq!(spans[0].title, "Response Shapes");
1437 assert_eq!(spans[0].start_offset, 0);
1439 let section = result.entity.sections.get("specifies").unwrap();
1440 assert_eq!(spans[0].end_offset, section.len());
1441 assert!(
1443 result
1444 .entity
1445 .heading_spans
1446 .get("identity")
1447 .is_none_or(Vec::is_empty)
1448 );
1449 }
1450
1451 #[test]
1452 fn parser_extracts_nested_h3_h4() {
1453 let md = "\
1454---
1455type: spec
1456---
1457# Entity
1458
1459## Identity
1460
1461Body.
1462
1463## Specifies
1464
1465### Outer
1466
1467Outer body.
1468
1469#### Inner
1470
1471Inner body.
1472";
1473 let result = parse_markdown(md, "h3-h4.md", &spec_schema(), "specs").unwrap();
1474 let spans = result.entity.heading_spans.get("specifies").unwrap();
1475 assert_eq!(spans.len(), 2, "both H3 and H4 must be recorded");
1476 assert_eq!(spans[0].level, 3);
1477 assert_eq!(spans[0].title, "Outer");
1478 assert_eq!(spans[1].level, 4);
1479 assert_eq!(spans[1].title, "Inner");
1480 assert!(
1481 spans[0].start_offset < spans[1].start_offset,
1482 "spans must be in document order"
1483 );
1484 assert!(
1486 spans[0].end_offset > spans[1].start_offset,
1487 "outer H3 must contain inner H4 by offset"
1488 );
1489 }
1490
1491 #[test]
1492 fn parser_ignores_headings_in_code_blocks() {
1493 let md = "\
1494---
1495type: spec
1496---
1497# Entity
1498
1499## Identity
1500
1501Body.
1502
1503## Specifies
1504
1505Prefix.
1506
1507```
1508### Not a heading
1509Still code.
1510```
1511
1512Suffix.
1513";
1514 let result = parse_markdown(md, "h3-code.md", &spec_schema(), "specs").unwrap();
1515 let spans = result
1516 .entity
1517 .heading_spans
1518 .get("specifies")
1519 .cloned()
1520 .unwrap_or_default();
1521 assert!(
1522 spans.is_empty(),
1523 "a '### ' inside a fenced block must not register as a heading span: {spans:?}"
1524 );
1525 }
1526
1527 #[test]
1528 fn parser_handles_level_skip() {
1529 let md = "\
1530---
1531type: spec
1532---
1533# Entity
1534
1535## Identity
1536
1537Body.
1538
1539## Specifies
1540
1541#### Skipped To H4
1542
1543Content under a sudden H4 — no virtual H3 is inserted.
1544";
1545 let result = parse_markdown(md, "h2-h4.md", &spec_schema(), "specs").unwrap();
1546 let spans = result.entity.heading_spans.get("specifies").unwrap();
1547 assert_eq!(spans.len(), 1);
1548 assert_eq!(spans[0].level, 4);
1549 assert_eq!(spans[0].title, "Skipped To H4");
1550 }
1551
1552 #[test]
1553 fn parser_handles_duplicate_siblings() {
1554 let md = "\
1555---
1556type: spec
1557---
1558# Entity
1559
1560## Identity
1561
1562Body.
1563
1564## Specifies
1565
1566### Same Title
1567
1568First occurrence body.
1569
1570### Same Title
1571
1572Second occurrence body.
1573";
1574 let result = parse_markdown(md, "h3-dup.md", &spec_schema(), "specs").unwrap();
1575 let spans = result.entity.heading_spans.get("specifies").unwrap();
1576 assert_eq!(spans.len(), 2, "duplicate siblings must produce two spans");
1577 assert_eq!(spans[0].title, spans[1].title);
1578 assert_ne!(
1579 spans[0].start_offset, spans[1].start_offset,
1580 "spans with identical titles must be distinguishable by offset"
1581 );
1582 assert!(
1584 spans[0].end_offset <= spans[1].start_offset,
1585 "first sibling must close before the second starts"
1586 );
1587 }
1588
1589 #[test]
1594 fn duplicate_declared_heading_two_populated_keeps_first_warns() {
1595 let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nfirst body\n\n## Identity\n\nsecond body\n";
1596 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1597 assert_eq!(
1598 result.entity.sections.get("identity").map(String::as_str),
1599 Some("first body"),
1600 "first body must win"
1601 );
1602 assert!(
1603 !result
1604 .entity
1605 .sections
1606 .get("identity")
1607 .unwrap()
1608 .contains("## Identity"),
1609 "storage value must not embed a duplicate heading"
1610 );
1611 assert_eq!(result.parse_warnings.len(), 1);
1612 match &result.parse_warnings[0] {
1613 crate::ops::WarningHint::DuplicateSectionHeading {
1614 section_key,
1615 heading,
1616 occurrences,
1617 ..
1618 } => {
1619 assert_eq!(section_key, "identity");
1620 assert_eq!(heading, "Identity");
1621 assert_eq!(*occurrences, 2);
1622 }
1623 other => panic!("expected DuplicateSectionHeading, got {other:?}"),
1624 }
1625 }
1626
1627 #[test]
1628 fn duplicate_declared_heading_blank_then_populated_keeps_blank() {
1629 let md =
1633 "---\ntype: spec\n---\n# Title\n\n## Identity\n\n## Identity\n\nleftover content\n";
1634 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1635 assert_eq!(
1636 result.entity.sections.get("identity").map(String::as_str),
1637 Some(""),
1638 "first (blank) occurrence wins; second body is dropped"
1639 );
1640 assert_eq!(result.parse_warnings.len(), 1);
1641 }
1642
1643 #[test]
1644 fn duplicate_declared_heading_three_occurrences() {
1645 let md = "---\ntype: spec\n---\n# Title\n\n## Constraints\n\nA\n\n## Constraints\n\n## Constraints\n\nC\n";
1646 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1647 assert_eq!(
1648 result
1649 .entity
1650 .sections
1651 .get("constraints")
1652 .map(String::as_str),
1653 Some("A"),
1654 );
1655 assert_eq!(result.parse_warnings.len(), 1);
1656 match &result.parse_warnings[0] {
1657 crate::ops::WarningHint::DuplicateSectionHeading { occurrences, .. } => {
1658 assert_eq!(*occurrences, 3);
1659 }
1660 _ => unreachable!(),
1661 }
1662 }
1663
1664 #[test]
1665 fn no_warning_when_each_declared_section_appears_once() {
1666 let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nID\n\n## Purpose\n\nP\n\n## Constraints\n\nC\n";
1667 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1668 assert!(result.parse_warnings.is_empty());
1669 }
1670
1671 #[test]
1672 fn no_warning_when_catch_all_section_repeats() {
1673 let md =
1676 "---\ntype: spec\n---\n# Title\n\n## Specifies\n\nfirst\n\n## Specifies\n\nsecond\n";
1677 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1678 assert!(
1679 result.parse_warnings.is_empty(),
1680 "catch-all repetition must not warn"
1681 );
1682 }
1683
1684 #[test]
1691 fn duplicate_realization_does_not_concatenate_headers_in_storage() {
1692 let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nID\n\n## Realization\n\n- a.mjs\n- b.mjs\n\n## Realization\n\n## Realization\n\n- c.mjs\n\n## Constraints\n\nC\n";
1693 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1694 let catch_all = result.entity.sections.get("specifies").unwrap();
1695 let header_count = catch_all.matches("## Realization").count();
1696 assert!(
1697 header_count <= 1,
1698 "catch-all bucket must not contain multiple `## Realization` headers — got {header_count}: {catch_all:?}"
1699 );
1700 }
1701
1702 #[test]
1708 fn parse_render_round_trip_collapses_duplicate_headings() {
1709 let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nA\n\n## Identity\n\n## Identity\n\nC\n\n## Purpose\n\nP\n";
1710 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1711 let rendered = crate::render::render_entity_markdown(&result.entity, None);
1712 let identity_count = rendered.matches("## Identity").count();
1713 assert_eq!(
1714 identity_count, 1,
1715 "rendered output must carry exactly one `## Identity`, got {identity_count}: {rendered}"
1716 );
1717 assert!(rendered.contains("\n## Identity\n\nA\n"));
1719 assert!(!rendered.contains("C\n"), "second body must not survive");
1720 }
1721}