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 (metadata, body) = split_frontmatter(content)?;
39 let masked_body = mask_code_blocks(&body);
40
41 let title = extract_title(&body, &masked_body).unwrap_or_else(|| id.name().to_string());
43
44 let (sections_map, duplicate_headings, raw_section_headings) =
49 split_sections(&body, &masked_body);
50
51 let rel_heading_key = "relationships";
57 let entity_id_for_rel_warnings = file_path_to_id(relative_path, mem);
58 let (relationships, rel_parse_warnings) = parse_relationships_with_warnings(
59 sections_map
60 .get(rel_heading_key)
61 .map(|(_, content)| content.as_str())
62 .unwrap_or(""),
63 mem,
64 Some(&entity_id_for_rel_warnings),
65 );
66
67 let catch_all_content = build_catch_all(§ions_map, schema);
69
70 let mut result_sections = IndexMap::new();
82 for s in &schema.sections {
83 if s.catch_all {
84 if sections_map.contains_key(s.key.as_str()) || !catch_all_content.trim().is_empty() {
89 result_sections.insert(s.key.clone(), catch_all_content.clone());
90 }
91 } else if let Some((_, content)) = sections_map.get(s.key.as_str()) {
92 result_sections.insert(s.key.clone(), content.clone());
93 }
94 }
102
103 let mut parsed_metadata = parse_metadata(&metadata);
105
106 let type_name = parsed_metadata
111 .get("type")
112 .and_then(|v| v.as_str())
113 .unwrap_or(schema.name.as_str())
114 .to_string();
115 parsed_metadata.insert("type".to_string(), MetadataValue::String(type_name.clone()));
116
117 let inline_link_text: String = schema
119 .text_fields
120 .iter()
121 .filter_map(|f| result_sections.get(f.as_str()))
122 .cloned()
123 .collect::<Vec<_>>()
124 .join("\n");
125 let inline_links = extract_inline_links_lenient(&inline_link_text, mem);
130
131 let explicit_targets: HashSet<_> = relationships.iter().map(|r| &r.target).collect();
133 let inline_links: Vec<EntityId> = inline_links
134 .into_iter()
135 .filter(|link| !explicit_targets.contains(link))
136 .collect();
137
138 let heading_spans = extract_heading_spans(&result_sections);
141
142 let declared_keys: HashSet<&str> = schema
146 .sections
147 .iter()
148 .filter(|s| !s.catch_all)
149 .map(|s| s.key.as_str())
150 .collect();
151 let entity_id_for_warnings = file_path_to_id(relative_path, mem);
152 let mut parse_warnings: Vec<crate::ops::WarningHint> = duplicate_headings
153 .into_iter()
154 .filter(|d| declared_keys.contains(d.key.as_str()))
155 .map(|d| crate::ops::WarningHint::DuplicateSectionHeading {
156 entity_id: entity_id_for_warnings.clone(),
157 section_key: d.key,
158 heading: d.heading,
159 occurrences: d.occurrences,
160 })
161 .collect();
162 parse_warnings.extend(rel_parse_warnings);
163
164 let entity = Entity {
165 id,
166 title,
167 entity_type: type_name,
168 mem: mem.to_string(),
169 file_path: relative_path.to_string(),
170 metadata: parsed_metadata,
171 sections: result_sections,
172 relationships,
173 content_hash,
174 stub: false,
175 stub_kind: None,
176 heading_spans,
177 raw_section_headings,
178 };
179
180 Ok(ParseResult {
181 entity,
182 inline_links,
183 parse_warnings,
184 })
185}
186
187pub fn parse_file(
189 path: &Path,
190 mem_dir: &Path,
191 schema: &TypeDefinition,
192 mem: &str,
193) -> Result<ParseResult, ParseError> {
194 let content = std::fs::read_to_string(path)?;
195 let relative_path = path.strip_prefix(mem_dir).unwrap_or(path).to_string_lossy();
196 parse_markdown(&content, &relative_path, schema, mem)
197}
198
199#[derive(Debug, PartialEq, Eq)]
215pub(crate) enum Frontmatter<'a> {
216 Present { meta: &'a str, body: &'a str },
218 NoOpeningDelimiter,
220 Unclosed,
222}
223
224pub(crate) fn split_frontmatter_core(content: &str) -> (&str, Frontmatter<'_>) {
237 let content = content.strip_prefix('\u{feff}').unwrap_or(content);
238
239 let after_open = if content.starts_with("---\r\n") {
240 5
241 } else if content.starts_with("---\n") {
242 4
243 } else {
244 return (content, Frontmatter::NoOpeningDelimiter);
245 };
246
247 let rest = &content[after_open..];
248 let Some(close_pos) = rest.find("\n---") else {
249 return (content, Frontmatter::Unclosed);
250 };
251 let meta = &rest[..close_pos];
252
253 let body_rest = &rest[close_pos + "\n---".len()..];
254 let body = body_rest
255 .strip_prefix("\r\n")
256 .or_else(|| body_rest.strip_prefix('\n'))
257 .unwrap_or(body_rest);
258
259 (content, Frontmatter::Present { meta, body })
260}
261
262pub fn peek_type_from_frontmatter(content: &str) -> Option<String> {
268 let (_, split) = split_frontmatter_core(content);
269 let Frontmatter::Present {
270 meta: frontmatter, ..
271 } = split
272 else {
273 return None;
274 };
275
276 for line in frontmatter.lines() {
277 let trimmed = line.trim();
278 if trimmed.is_empty() || trimmed.starts_with('#') {
279 continue;
280 }
281 let Some(colon_idx) = trimmed.find(':') else {
282 continue;
283 };
284 let key = trimmed[..colon_idx].trim();
285 if key != "type" {
286 continue;
287 }
288 let mut value = trimmed[colon_idx + 1..].trim();
289 if let Some(hash_idx) = value.find('#') {
290 value = value[..hash_idx].trim();
291 }
292 let value = value.trim_matches(|c| c == '"' || c == '\'');
293 if value.is_empty() {
294 return None;
295 }
296 return Some(value.to_string());
297 }
298 None
299}
300
301pub fn peek_title_and_type(content: &str) -> (Option<String>, Option<String>) {
310 let entity_type = peek_type_from_frontmatter(content);
311 let body = body_after_frontmatter(content);
312 let title = extract_title(body, &mask_code_blocks(body));
313 (title, entity_type)
314}
315
316pub fn body_after_frontmatter(content: &str) -> &str {
332 match split_frontmatter_core(content) {
333 (_, Frontmatter::Present { body, .. }) => body,
334 (stripped, _) => stripped,
335 }
336}
337
338pub(crate) fn split_frontmatter(content: &str) -> Result<(String, String), ParseError> {
342 match split_frontmatter_core(content) {
346 (_, Frontmatter::Present { meta, body }) => Ok((meta.to_string(), body.to_string())),
347 (stripped, _) => Ok((String::new(), stripped.to_string())),
348 }
349}
350
351fn parse_metadata(text: &str) -> IndexMap<String, MetadataValue> {
356 let mut meta = IndexMap::new();
357 if text.is_empty() {
358 return meta;
359 }
360
361 for line in text.lines() {
362 let trimmed = line.trim();
363 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("---") {
365 continue;
366 }
367
368 let Some(colon_idx) = trimmed.find(':') else {
369 continue;
370 };
371
372 let key = trimmed[..colon_idx].trim().to_string();
373 let raw_value = trimmed[colon_idx + 1..].trim();
374
375 let value = strip_inline_comment(raw_value).trim().to_string();
377
378 if value.is_empty() {
379 meta.insert(key, MetadataValue::String(String::new()));
380 continue;
381 }
382
383 if value == "true" {
385 meta.insert(key, MetadataValue::Bool(true));
386 } else if value == "false" {
387 meta.insert(key, MetadataValue::Bool(false));
388 } else if is_float_literal(&value) {
389 if let Ok(f) = value.parse::<f64>() {
390 meta.insert(key, MetadataValue::Float(f));
391 } else {
392 meta.insert(key, MetadataValue::String(strip_quotes(&value)));
393 }
394 } else if is_integer_literal(&value) {
395 if let Ok(n) = value.parse::<i64>() {
396 meta.insert(key, MetadataValue::Integer(n));
397 } else {
398 meta.insert(key, MetadataValue::String(strip_quotes(&value)));
399 }
400 } else {
401 meta.insert(key, MetadataValue::String(strip_quotes(&value)));
402 }
403 }
404
405 meta
406}
407
408fn is_float_literal(s: &str) -> bool {
410 let s = s.strip_prefix('-').unwrap_or(s);
411 if let Some((before, after)) = s.split_once('.') {
412 !before.is_empty()
413 && before.chars().all(|c| c.is_ascii_digit())
414 && !after.is_empty()
415 && after.chars().all(|c| c.is_ascii_digit())
416 } else {
417 false
418 }
419}
420
421fn is_integer_literal(s: &str) -> bool {
423 let s = s.strip_prefix('-').unwrap_or(s);
424 !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
425}
426
427pub(crate) fn would_coerce_from_string(s: &str) -> bool {
433 s == "true" || s == "false" || is_integer_literal(s) || is_float_literal(s)
434}
435
436fn strip_inline_comment(s: &str) -> &str {
438 if let Some(idx) = s.find(" #") {
441 s[..idx].trim_end()
442 } else {
443 s
444 }
445}
446
447fn strip_quotes(s: &str) -> String {
451 if s.len() >= 2
452 && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
453 {
454 s[1..s.len() - 1].to_string()
455 } else {
456 s.to_string()
457 }
458}
459
460pub use crate::markdown::{mask_code_blocks, mask_code_blocks_and_spans};
472
473pub fn has_merge_conflict_markers(text: &str) -> bool {
503 let body = body_after_frontmatter(text);
509 let frontmatter = &text[..text.len() - body.len()];
510 let view = format!("{frontmatter}{}", mask_code_blocks(body));
511
512 let mut seen_start = false;
513 let mut seen_separator = false;
514 for line in view.lines() {
515 if line.starts_with("<<<<<<< ") {
516 seen_start = true;
517 seen_separator = false;
518 } else if seen_start && line.trim_end() == "=======" {
519 seen_separator = true;
520 } else if seen_separator && line.starts_with(">>>>>>> ") {
521 return true;
522 }
523 }
524 false
525}
526
527pub(crate) type SplitSections = IndexMap<String, (String, String)>;
537
538pub(crate) struct DuplicateSection {
541 pub key: String,
542 pub heading: String,
543 pub occurrences: usize,
544}
545
546pub(crate) fn split_sections(
564 body: &str,
565 masked_body: &str,
566) -> (SplitSections, Vec<DuplicateSection>, Vec<String>) {
567 let mut sections = IndexMap::new();
573 let mut duplicates: HashMap<String, DuplicateSection> = HashMap::new();
574 let mut raw_headings = Vec::new();
575 static SECTION_RE: OnceLock<Regex> = OnceLock::new();
576 let section_re = SECTION_RE.get_or_init(|| Regex::new(r"(?m)^## (.+)$").unwrap());
577
578 let matches: Vec<_> = section_re.find_iter(masked_body).collect();
579
580 for (i, m) in matches.iter().enumerate() {
581 let heading_line = &body[m.start()..m.end()];
583 let name = heading_line
584 .strip_prefix("## ")
585 .unwrap_or(heading_line)
586 .trim();
587
588 let content_start = m.end();
589 let content_end = if i + 1 < matches.len() {
590 matches[i + 1].start()
591 } else {
592 body.len()
593 };
594 let raw = &body[content_start..content_end];
605 let visible_start = raw
606 .split_inclusive('\n')
607 .take_while(|line| line.trim().is_empty())
608 .map(str::len)
609 .sum::<usize>();
610 let content = raw[visible_start..].trim_end().to_string();
611 let key = memstead_schema::derive_section_key(name);
619 raw_headings.push(name.to_string());
620
621 match sections.entry(key.clone()) {
622 indexmap::map::Entry::Vacant(slot) => {
623 slot.insert((heading_line.to_string(), content));
624 duplicates.insert(
625 key.clone(),
626 DuplicateSection {
627 key: key.clone(),
628 heading: name.to_string(),
629 occurrences: 1,
630 },
631 );
632 }
633 indexmap::map::Entry::Occupied(_) => {
634 if let Some(d) = duplicates.get_mut(&key) {
637 d.occurrences += 1;
638 }
639 }
640 }
641 }
642
643 let dup_list: Vec<DuplicateSection> = duplicates
644 .into_values()
645 .filter(|d| d.occurrences > 1)
646 .collect();
647
648 (sections, dup_list, raw_headings)
649}
650
651fn extract_title(body: &str, masked_body: &str) -> Option<String> {
658 for (line, masked) in body.lines().zip(masked_body.lines()) {
659 if masked.starts_with("# ") {
660 return Some(line[2..].trim().to_string());
661 }
662 }
663 None
664}
665
666fn extract_heading_spans(sections: &IndexMap<String, String>) -> HashMap<String, Vec<HeadingSpan>> {
681 static RE: OnceLock<Regex> = OnceLock::new();
683 let re = RE.get_or_init(|| Regex::new(r"(?m)^(#{3,6})[ \t]+(.+)$").unwrap());
684 let mut out: HashMap<String, Vec<HeadingSpan>> = HashMap::new();
685
686 for (key, content) in sections {
687 if content.is_empty() {
688 continue;
689 }
690 let masked = mask_code_blocks(content);
691
692 let raw: Vec<(usize, u8, String)> = re
694 .captures_iter(&masked)
695 .map(|cap| {
696 let whole = cap.get(0).unwrap();
697 let level = cap[1].len() as u8; let line_end = content[whole.start()..]
701 .find('\n')
702 .map(|i| whole.start() + i)
703 .unwrap_or(content.len());
704 let hashes_end = whole.start() + level as usize;
705 let title = content[hashes_end..line_end].trim().to_string();
706 (whole.start(), level, title)
707 })
708 .collect();
709
710 if raw.is_empty() {
711 continue;
712 }
713
714 let mut spans: Vec<HeadingSpan> = Vec::with_capacity(raw.len());
715 for (i, &(start, level, ref title)) in raw.iter().enumerate() {
716 let end = raw[i + 1..]
718 .iter()
719 .find(|(_, l, _)| *l <= level)
720 .map(|(s, _, _)| *s)
721 .unwrap_or(content.len());
722 spans.push(HeadingSpan {
723 level,
724 title: title.clone(),
725 start_offset: start,
726 end_offset: end,
727 });
728 }
729 out.insert(key.clone(), spans);
730 }
731
732 out
733}
734
735fn build_catch_all(sections: &SplitSections, schema: &TypeDefinition) -> String {
741 let catch_all = match schema.catch_all_section() {
742 Some(s) => s,
743 None => return String::new(),
744 };
745
746 let known_sections: HashSet<&str> = schema
747 .sections
748 .iter()
749 .map(|s| s.key.as_str())
750 .chain(std::iter::once("relationships"))
751 .collect();
752
753 let mut parts = Vec::new();
754
755 if let Some((_, content)) = sections.get(catch_all.key.as_str())
757 && !content.is_empty()
758 {
759 parts.push(content.clone());
760 }
761
762 for (key, (heading_line, content)) in sections {
774 if !known_sections.contains(key.as_str()) && !content.is_empty() {
775 parts.push(format!("{heading_line}\n{content}"));
776 }
777 }
778
779 let mut joined = String::new();
795 for piece in parts {
796 if joined.is_empty() {
797 joined = piece;
798 } else {
799 joined.push_str("\n\n");
800 joined.push_str(&piece);
801 }
802 if let Some(closer) = crate::markdown::closing_fence_if_unterminated(&joined) {
803 joined.push('\n');
804 joined.push_str(&closer);
805 }
806 }
807 joined
808}
809
810pub(crate) fn parse_relationships_with_warnings(
834 text: &str,
835 mem: &str,
836 entity_id: Option<&EntityId>,
837) -> (Vec<Relationship>, Vec<crate::ops::WarningHint>) {
838 static RE: OnceLock<Regex> = OnceLock::new();
852 let re = RE.get_or_init(|| {
853 Regex::new(r"(?m)^\s*-\s*\*\*(\w+)\*\*:\s*\[\[([^\]\n]+)\]\](?P<tail>[^\n]*)").unwrap()
854 });
855 let mut relationships = Vec::new();
856 let mut warnings = Vec::new();
857 let masked = mask_code_blocks_and_spans(text);
865 for cap in re.captures_iter(&masked) {
866 let rel_type = text[cap.get(1).unwrap().range()].to_uppercase();
867 let target = wiki_link_to_id_lenient(&text[cap.get(2).unwrap().range()], mem);
873 if target.path().is_empty() {
882 continue;
883 }
884 let tail = cap.name("tail").map(|m| &text[m.range()]).unwrap_or("");
885 let description = match classify_description_tail(tail) {
886 DescriptionTail::None => None,
887 DescriptionTail::EmDash(text) => Some(text),
888 DescriptionTail::Ambiguous(literal) => {
889 if let Some(id) = entity_id {
890 warnings.push(crate::ops::WarningHint::AmbiguousDescriptionDelimiter {
891 from: id.clone(),
892 rel_type: rel_type.clone(),
893 target: target.clone(),
894 trailing: literal,
895 });
896 }
897 None
898 }
899 };
900 relationships.push(Relationship {
901 rel_type,
902 target,
903 description,
904 });
905 }
906 (relationships, warnings)
907}
908
909enum DescriptionTail {
912 None,
914 EmDash(String),
917 Ambiguous(String),
921}
922
923fn classify_description_tail(tail: &str) -> DescriptionTail {
929 let trimmed_end = tail.trim_end();
930 if trimmed_end.is_empty() {
931 return DescriptionTail::None;
932 }
933 if let Some(rest) = trimmed_end.strip_prefix(" \u{2014} ") {
935 if rest.is_empty() {
936 return DescriptionTail::None;
937 }
938 return DescriptionTail::EmDash(rest.to_string());
939 }
940 if let Some(rest) = trimmed_end.strip_prefix(" \u{2014}") {
944 return DescriptionTail::Ambiguous(format!(" \u{2014}{rest}"));
946 }
947 let starters = [" --", " -", " \u{2013}", " \u{2212}"];
949 if starters
950 .iter()
951 .any(|prefix| trimmed_end.starts_with(prefix))
952 {
953 return DescriptionTail::Ambiguous(trimmed_end.to_string());
954 }
955 DescriptionTail::Ambiguous(trimmed_end.to_string())
959}
960
961fn wiki_link_re() -> &'static Regex {
973 static RE: OnceLock<Regex> = OnceLock::new();
974 RE.get_or_init(|| Regex::new(r"\[\[([^\]]*)\]\]").unwrap())
975}
976
977pub(crate) fn extract_inline_links(
991 text: &str,
992 mem: &str,
993) -> Result<Vec<EntityId>, Vec<WikiLinkError>> {
994 let stripped = mask_code_blocks_and_spans(text);
995
996 let link_re = wiki_link_re();
997 let mut seen = HashSet::new();
998 let mut links = Vec::new();
999 let mut errors = Vec::new();
1000
1001 for cap in link_re.captures_iter(&stripped) {
1002 match wiki_link_to_id(&cap[1], mem) {
1003 Ok(id) => {
1004 if errors.is_empty() && seen.insert(id.0.clone()) {
1005 links.push(id);
1006 }
1007 }
1008 Err(e) => errors.push(e),
1009 }
1010 }
1011
1012 if errors.is_empty() {
1013 Ok(links)
1014 } else {
1015 Err(errors)
1016 }
1017}
1018
1019pub fn extract_inline_links_lenient(text: &str, mem: &str) -> Vec<EntityId> {
1026 let stripped = mask_code_blocks_and_spans(text);
1027
1028 let link_re = wiki_link_re();
1029 let mut seen = HashSet::new();
1030 let mut links = Vec::new();
1031
1032 for cap in link_re.captures_iter(&stripped) {
1033 if cap[1].is_empty() {
1038 continue;
1039 }
1040 let id = wiki_link_to_id_lenient(&cap[1], mem);
1041 if seen.insert(id.0.clone()) {
1042 links.push(id);
1043 }
1044 }
1045
1046 links
1047}
1048
1049pub fn compute_hash(content: &str) -> String {
1055 let mut hasher = Sha256::new();
1056 hasher.update(content.as_bytes());
1057 let result = hasher.finalize();
1058 crate::hex_lower(&result)[..16].to_string()
1059}
1060
1061#[derive(Debug, thiserror::Error)]
1066pub enum ParseError {
1067 #[error("missing frontmatter")]
1068 MissingFrontmatter,
1069 #[error("invalid frontmatter: {0}")]
1070 InvalidFrontmatter(String),
1071 #[error("missing title")]
1072 MissingTitle,
1073 #[error("io error: {0}")]
1074 Io(#[from] std::io::Error),
1075}
1076
1077#[cfg(test)]
1078mod tests {
1079 use super::*;
1080 use memstead_schema::{builtin_names, type_by_name};
1081 use std::sync::Arc;
1082
1083 fn spec_schema() -> Arc<TypeDefinition> {
1084 type_by_name(builtin_names::SPEC).unwrap()
1085 }
1086
1087 fn memo_schema() -> Arc<TypeDefinition> {
1088 type_by_name(builtin_names::MEMO).unwrap()
1089 }
1090
1091 #[test]
1092 fn parse_metadata_types() {
1093 let meta = parse_metadata("key: value\nnum: 42\nfloat: 0.85\nbool: true\nfalsy: false");
1094 assert_eq!(meta["key"], MetadataValue::String("value".to_string()));
1095 assert_eq!(meta["num"], MetadataValue::Integer(42));
1096 assert_eq!(meta["float"], MetadataValue::Float(0.85));
1097 assert_eq!(meta["bool"], MetadataValue::Bool(true));
1098 assert_eq!(meta["falsy"], MetadataValue::Bool(false));
1099 }
1100
1101 #[test]
1102 fn parse_metadata_strips_comments() {
1103 let meta = parse_metadata("key: value # this is a comment");
1104 assert_eq!(meta["key"], MetadataValue::String("value".to_string()));
1105 }
1106
1107 #[test]
1108 fn parse_metadata_strips_quotes() {
1109 let meta = parse_metadata("key: \"quoted value\"\nkey2: 'single'");
1110 assert_eq!(
1111 meta["key"],
1112 MetadataValue::String("quoted value".to_string())
1113 );
1114 assert_eq!(meta["key2"], MetadataValue::String("single".to_string()));
1115 }
1116
1117 #[test]
1118 fn parse_metadata_survives_malformed_values() {
1119 let meta = parse_metadata(
1122 "key: \"\nkey2: '\nkey3: \"\"\nkey4: ''\nkey5: \"unterminated\nkey6: mixed'\"",
1123 );
1124 assert_eq!(meta["key"], MetadataValue::String("\"".to_string()));
1125 assert_eq!(meta["key2"], MetadataValue::String("'".to_string()));
1126 assert_eq!(meta["key3"], MetadataValue::String(String::new()));
1127 assert_eq!(meta["key4"], MetadataValue::String(String::new()));
1128 assert_eq!(
1129 meta["key5"],
1130 MetadataValue::String("\"unterminated".to_string())
1131 );
1132 assert_eq!(meta["key6"], MetadataValue::String("mixed'\"".to_string()));
1133
1134 let meta =
1137 parse_metadata(":\n: value\nkey7: ✓\"\nkey8: 99999999999999999999999999\nkey9: -");
1138 assert_eq!(meta["key7"], MetadataValue::String("✓\"".to_string()));
1139 assert_eq!(
1140 meta["key8"],
1141 MetadataValue::String("99999999999999999999999999".to_string())
1142 );
1143 assert_eq!(meta["key9"], MetadataValue::String("-".to_string()));
1144 }
1145
1146 #[test]
1147 fn parse_metadata_skips_comments_and_empty() {
1148 let meta = parse_metadata("# comment\n\nkey: val\n---");
1149 assert_eq!(meta.len(), 1);
1150 assert_eq!(meta["key"], MetadataValue::String("val".to_string()));
1151 }
1152
1153 #[test]
1154 fn peek_type_finds_value() {
1155 let content = "---\ntype: memo\ntitle: Test\n---\n# Body\n";
1156 assert_eq!(
1157 peek_type_from_frontmatter(content),
1158 Some("memo".to_string())
1159 );
1160 }
1161
1162 #[test]
1163 fn peek_type_returns_none_when_missing() {
1164 let content = "---\ntitle: Test\n---\n# Body\n";
1165 assert_eq!(peek_type_from_frontmatter(content), None);
1166 }
1167
1168 #[test]
1169 fn peek_type_returns_none_without_frontmatter() {
1170 let content = "# Just a heading\n\nBody with type: concept inside text.\n";
1171 assert_eq!(peek_type_from_frontmatter(content), None);
1172 }
1173
1174 #[test]
1175 fn peek_type_handles_windows_line_endings() {
1176 let content = "---\r\ntype: principle\r\n---\r\n# Body\r\n";
1177 assert_eq!(
1178 peek_type_from_frontmatter(content),
1179 Some("principle".to_string())
1180 );
1181 }
1182
1183 #[test]
1184 fn peek_type_strips_quotes_and_comments() {
1185 let quoted = "---\ntype: \"concept\"\n---\n";
1186 assert_eq!(
1187 peek_type_from_frontmatter(quoted),
1188 Some("concept".to_string())
1189 );
1190 let commented = "---\ntype: memo # kind of\n---\n";
1191 assert_eq!(
1192 peek_type_from_frontmatter(commented),
1193 Some("memo".to_string())
1194 );
1195 }
1196
1197 #[test]
1198 fn peek_type_empty_value_returns_none() {
1199 let content = "---\ntype:\n---\n";
1200 assert_eq!(peek_type_from_frontmatter(content), None);
1201 }
1202
1203 #[test]
1204 fn peek_type_ignores_legacy_schema_key() {
1205 let content = concat!("---\n", "schema", ": memo\n---\n");
1208 assert_eq!(peek_type_from_frontmatter(content), None);
1209 }
1210
1211 #[test]
1212 fn mask_code_blocks_basic() {
1213 let input = "before\n```\ncode [[link]]\n```\nafter";
1214 let masked = mask_code_blocks(input);
1215 assert!(!masked.contains("[[link]]"));
1216 assert!(masked.contains("before"));
1217 assert!(masked.contains("after"));
1218 }
1219
1220 #[test]
1221 fn mask_code_blocks_preserves_line_count() {
1222 let input = "line1\n```\ncode\nmore code\n```\nline6";
1223 let masked = mask_code_blocks(input);
1224 assert_eq!(input.lines().count(), masked.lines().count());
1225 }
1226
1227 #[test]
1228 fn mask_code_blocks_unclosed() {
1229 let input = "before\n```\ncode\nmore code";
1230 let masked = mask_code_blocks(input);
1231 assert!(masked.contains("before"));
1232 assert!(!masked.contains("code"));
1233 }
1234
1235 #[test]
1236 fn parse_relationships_basic() {
1237 let text = "- **USES**: [[target-entity]]\n- **PART_OF**: [[parent]]";
1238 let rels = parse_relationships_with_warnings(text, "specs", None).0;
1239 assert_eq!(rels.len(), 2);
1240 assert_eq!(rels[0].rel_type, "USES");
1241 assert_eq!(rels[0].target.0, "specs--target-entity");
1242 assert_eq!(rels[1].rel_type, "PART_OF");
1243 assert_eq!(rels[1].target.0, "specs--parent");
1244 assert!(rels[0].description.is_none());
1246 assert!(rels[1].description.is_none());
1247 }
1248
1249 #[test]
1250 fn parse_relationships_canonical_em_dash_captures_description() {
1251 let text = "- **OTHER**: [[a]] \u{2014} replaced by checkout-flow";
1252 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1253 assert_eq!(rels.len(), 1);
1254 assert_eq!(
1255 rels[0].description.as_deref(),
1256 Some("replaced by checkout-flow")
1257 );
1258 assert!(warnings.is_empty(), "canonical em-dash does not warn");
1259 }
1260
1261 #[test]
1262 fn parse_relationships_em_dash_inside_description_body() {
1263 let text = "- **OTHER**: [[a]] \u{2014} note with — inside body";
1264 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1265 assert_eq!(rels.len(), 1);
1266 assert_eq!(
1267 rels[0].description.as_deref(),
1268 Some("note with — inside body"),
1269 "the parser captures up to end-of-line; em-dashes inside the body survive"
1270 );
1271 assert!(warnings.is_empty());
1272 }
1273
1274 #[test]
1275 fn parse_relationships_ambiguous_double_hyphen_warns_and_drops_content() {
1276 let text = "- **USES**: [[a]] -- legacy delimiter";
1277 let entity_id = EntityId::new("specs", "src");
1278 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", Some(&entity_id));
1279 assert_eq!(rels.len(), 1);
1280 assert!(rels[0].description.is_none(), "trailing content is dropped");
1281 assert_eq!(warnings.len(), 1);
1282 assert!(matches!(
1283 warnings[0],
1284 crate::ops::WarningHint::AmbiguousDescriptionDelimiter { .. }
1285 ));
1286 }
1287
1288 #[test]
1289 fn parse_relationships_ambiguous_single_hyphen_warns_and_drops_content() {
1290 let text = "- **USES**: [[a]] - single hyphen";
1291 let entity_id = EntityId::new("specs", "src");
1292 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", Some(&entity_id));
1293 assert_eq!(rels.len(), 1);
1294 assert!(rels[0].description.is_none());
1295 assert_eq!(warnings.len(), 1);
1296 assert_eq!(warnings[0].code(), "AMBIGUOUS_DESCRIPTION_DELIMITER");
1297 }
1298
1299 #[test]
1300 fn parse_relationships_hyphenated_slug_target_parses_unambiguously() {
1301 let text = "- **USES**: [[some-slug-with-hyphens]] \u{2014} ok";
1302 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1303 assert_eq!(rels.len(), 1);
1304 assert_eq!(rels[0].target.path(), "some-slug-with-hyphens");
1305 assert_eq!(rels[0].description.as_deref(), Some("ok"));
1306 assert!(warnings.is_empty());
1307 }
1308
1309 #[test]
1310 fn parse_full_entity() {
1311 let md = "\
1312---
1313type: spec
1314created_date: 2026-01-15
1315last_modified: 2026-04-12
1316level: M0
1317tags: backend, api
1318---
1319# Test Entity
1320
1321## Identity
1322
1323This is a test entity.
1324
1325## Purpose
1326
1327Testing the parser.
1328
1329## Relationships
1330
1331- **USES**: [[other-entity]]
1332
1333## Specifies
1334
1335Some specification content with [[inline-link]].
1336";
1337 let result = parse_markdown(md, "test-entity.md", &spec_schema(), "specs").unwrap();
1338 let entity = &result.entity;
1339 assert_eq!(entity.id.0, "specs--test-entity");
1340 assert_eq!(entity.title, "Test Entity");
1341 assert_eq!(entity.mem, "specs");
1342 assert_eq!(
1343 entity.metadata["type"],
1344 MetadataValue::String("spec".to_string())
1345 );
1346 assert_eq!(
1347 entity.metadata["level"],
1348 MetadataValue::String("M0".to_string())
1349 );
1350 assert_eq!(
1351 entity.metadata["tags"],
1352 MetadataValue::String("backend, api".to_string())
1353 );
1354 assert_eq!(entity.sections["identity"], "This is a test entity.");
1355 assert_eq!(entity.sections["purpose"], "Testing the parser.");
1356 assert_eq!(entity.relationships.len(), 1);
1357 assert_eq!(entity.relationships[0].rel_type, "USES");
1358 assert_eq!(entity.relationships[0].target.0, "specs--other-entity");
1359 assert_eq!(result.inline_links.len(), 1);
1360 assert_eq!(result.inline_links[0].0, "specs--inline-link");
1361 }
1362
1363 #[test]
1364 fn parse_full_entity_memo_schema() {
1365 let md = "\
1366---
1367type: memo
1368created_date: 2026-01-15
1369last_modified: 2026-04-12
1370status: active
1371tags: decision, architecture
1372---
1373# Use Sled For Storage
1374
1375## Claim
1376
1377Sled is the right embedded store for this workload.
1378
1379## Context
1380
1381We evaluated sled, rocksdb, and sqlite for the in-process graph cache.
1382
1383## Substance
1384
1385Sled wins on pure-Rust dependency footprint.
1386";
1387 let result = parse_markdown(md, "use-sled.md", &memo_schema(), "memos").unwrap();
1388 let entity = &result.entity;
1389 assert_eq!(entity.id.0, "memos--use-sled");
1390 assert_eq!(entity.title, "Use Sled For Storage");
1391 assert_eq!(entity.mem, "memos");
1392 assert_eq!(
1393 entity.metadata["type"],
1394 MetadataValue::String("memo".to_string())
1395 );
1396 assert_eq!(
1397 entity.metadata["status"],
1398 MetadataValue::String("active".to_string())
1399 );
1400 assert_eq!(
1401 entity.sections["claim"],
1402 "Sled is the right embedded store for this workload."
1403 );
1404 assert_eq!(
1405 entity.sections["context"],
1406 "We evaluated sled, rocksdb, and sqlite for the in-process graph cache."
1407 );
1408 assert_eq!(
1409 entity.sections["substance"],
1410 "Sled wins on pure-Rust dependency footprint."
1411 );
1412 assert!(!entity.sections.contains_key("identity"));
1413 assert!(!entity.sections.contains_key("purpose"));
1414 }
1415
1416 #[test]
1417 fn parse_entity_without_frontmatter() {
1418 let md = "# No Frontmatter\n\n## Identity\n\nJust a title and section.";
1419 let result = parse_markdown(md, "no-fm.md", &spec_schema(), "specs").unwrap();
1420 assert_eq!(result.entity.title, "No Frontmatter");
1421 assert_eq!(result.entity.metadata.len(), 1);
1423 assert_eq!(
1424 result.entity.metadata.get("type"),
1425 Some(&MetadataValue::String("spec".to_string()))
1426 );
1427 }
1428
1429 #[test]
1430 fn parse_entity_code_blocks_not_detected() {
1431 let md = "\
1432---
1433type: spec
1434---
1435# Code Test
1436
1437## Identity
1438
1439Test entity.
1440
1441## Specifies
1442
1443```
1444## Not A Section
1445- **USES**: [[not-a-link]]
1446```
1447
1448Real content after code block.
1449";
1450 let result = parse_markdown(md, "code-test.md", &spec_schema(), "specs").unwrap();
1451 assert!(!result.entity.sections.contains_key("not a section"));
1453 assert!(result.inline_links.is_empty());
1455 }
1456
1457 #[test]
1464 fn bom_prefixed_frontmatter_is_recognized() {
1465 let md = "\u{feff}---\ntype: spec\n---\n# Bom Entity\n\n## Identity\n\nBody.\n";
1466 assert_eq!(peek_type_from_frontmatter(md), Some("spec".to_string()));
1467 assert_eq!(
1468 body_after_frontmatter(md),
1469 "# Bom Entity\n\n## Identity\n\nBody.\n"
1470 );
1471 let (meta, body) = split_frontmatter(md).unwrap();
1472 assert_eq!(meta, "type: spec");
1473 assert_eq!(body, "# Bom Entity\n\n## Identity\n\nBody.\n");
1474 let result = parse_markdown(md, "bom.md", &spec_schema(), "specs").unwrap();
1475 assert_eq!(
1476 result.entity.metadata["type"],
1477 MetadataValue::String("spec".to_string())
1478 );
1479 assert_eq!(result.entity.sections["identity"], "Body.");
1480 }
1481
1482 #[test]
1489 fn open_fence_in_section_content_does_not_swallow_following_sections() {
1490 let md = "\
1491---
1492type: spec
1493---
1494# Code Test
1495
1496## Identity
1497
1498Base.
1499
1500## Specifies
1501
1502```
1503truncated code with no closer";
1504 let schema = spec_schema();
1505 let e1 = parse_markdown(md, "open-fence.md", &schema, "specs").unwrap();
1506 let m1 = crate::entity::generator::generate_markdown(&e1.entity, &schema);
1507 let e2 = parse_markdown(&m1, "open-fence.md", &schema, "specs").unwrap();
1508 assert_eq!(
1509 e2.entity.sections["identity"], "Base.",
1510 "sections before the open fence survive"
1511 );
1512 assert!(
1513 !e2.entity.sections["specifies"].contains("## Constraints"),
1514 "the generated sections after the fence are not absorbed into it"
1515 );
1516 let m2 = crate::entity::generator::generate_markdown(&e2.entity, &schema);
1517 assert_eq!(
1518 m1, m2,
1519 "parse→generate is a fixpoint after one normalising round"
1520 );
1521 }
1522
1523 #[test]
1531 fn catch_all_reconstruction_is_document_ordered_and_idempotent() {
1532 let md = "\
1533---
1534type: spec
1535---
1536# Multi Unknown
1537
1538## Identity
1539
1540Base.
1541
1542## Claim
1543
1544First unknown.
1545
1546## Context
1547
1548Second unknown.
1549
1550## Substance
1551
1552Third unknown.
1553";
1554 let schema = spec_schema();
1555 let e1 = parse_markdown(md, "multi-unknown.md", &schema, "specs").unwrap();
1556 assert_eq!(
1557 e1.entity.sections["specifies"],
1558 "## Claim\nFirst unknown.\n\n## Context\nSecond unknown.\n\n## Substance\nThird unknown.",
1559 "non-schema sections land in the catch-all in document order"
1560 );
1561 let m1 = crate::entity::generator::generate_markdown(&e1.entity, &schema);
1562 let e2 = parse_markdown(&m1, "multi-unknown.md", &schema, "specs").unwrap();
1563 let m2 = crate::entity::generator::generate_markdown(&e2.entity, &schema);
1564 assert_eq!(
1565 m1, m2,
1566 "parse→generate is idempotent over multi-unknown-section input"
1567 );
1568 }
1569
1570 #[test]
1579 fn first_line_whitespace_prefix_survives_storage_and_round_trips() {
1580 let schema = spec_schema();
1581 let md = "---\ntype: spec\n---\n# T\n\n## Identity\n\u{b}```\nx\n\n## Purpose\np\n";
1582 let e1 = parse_markdown(md, "vt.md", &schema, "specs").unwrap();
1583 assert_eq!(
1584 e1.entity.sections["identity"], "\u{b}```\nx",
1585 "the first visible line keeps its whitespace prefix byte-exactly"
1586 );
1587 let m1 = crate::entity::generator::generate_markdown(&e1.entity, &schema);
1588 let e2 = parse_markdown(&m1, "vt.md", &schema, "specs").unwrap();
1589 let m2 = crate::entity::generator::generate_markdown(&e2.entity, &schema);
1590 assert_eq!(m1, m2, "parse→generate is a fixpoint");
1591 }
1592
1593 #[test]
1603 fn indented_heading_lookalike_stays_content_and_round_trips() {
1604 let md = "\
1605---
1606type: spec
1607---
1608# Promoted Heading
1609
1610## Identity
1611
1612Base.
1613
1614## Unknown Extra
1615
1616 ## Specifies
1617
1618Some content that must survive.
1619";
1620 let schema = spec_schema();
1621 let e1 = parse_markdown(md, "indent.md", &schema, "specs").unwrap();
1622 assert!(
1623 e1.entity.sections["specifies"].contains(" ## Specifies"),
1624 "the indented lookalike keeps its indentation inside the catch-all"
1625 );
1626 assert!(
1627 e1.entity.sections["specifies"].contains("Some content that must survive."),
1628 "content after the lookalike is preserved"
1629 );
1630 let m1 = crate::entity::generator::generate_markdown(&e1.entity, &schema);
1631 let e2 = parse_markdown(&m1, "indent.md", &schema, "specs").unwrap();
1632 let m2 = crate::entity::generator::generate_markdown(&e2.entity, &schema);
1633 assert_eq!(
1634 m1, m2,
1635 "parse→generate is a fixpoint after one normalising round"
1636 );
1637 assert!(
1638 e2.entity.sections["specifies"].contains("Some content that must survive."),
1639 "no content is lost across rounds"
1640 );
1641 }
1642
1643 #[test]
1644 fn compute_hash_deterministic() {
1645 let hash1 = compute_hash("test content");
1646 let hash2 = compute_hash("test content");
1647 assert_eq!(hash1, hash2);
1648 assert_eq!(hash1.len(), 16);
1649 }
1650
1651 #[test]
1652 fn compute_hash_differs() {
1653 let hash1 = compute_hash("content a");
1654 let hash2 = compute_hash("content b");
1655 assert_ne!(hash1, hash2);
1656 }
1657
1658 #[test]
1659 fn is_float_literal_matches() {
1660 assert!(is_float_literal("0.85"));
1661 assert!(is_float_literal("-1.5"));
1662 assert!(is_float_literal("100.0"));
1663 assert!(!is_float_literal(".5"));
1664 assert!(!is_float_literal("1."));
1665 assert!(!is_float_literal("42"));
1666 assert!(!is_float_literal("hello"));
1667 }
1668
1669 #[test]
1670 fn is_integer_literal_matches() {
1671 assert!(is_integer_literal("42"));
1672 assert!(is_integer_literal("-1"));
1673 assert!(is_integer_literal("0"));
1674 assert!(!is_integer_literal("0.5"));
1675 assert!(!is_integer_literal("hello"));
1676 assert!(!is_integer_literal(""));
1677 }
1678
1679 #[test]
1685 fn parse_preserves_frontmatter_key_order() {
1686 let md = "\
1687---
1688type: principle
1689universality: domain-wide
1690authority: proposed
1691tags: a, b, c
1692created_date: 2026-01-15
1693last_modified: 2026-04-12
1694---
1695# Key Order
1696";
1697 let result = parse_markdown(
1698 md,
1699 "key-order.md",
1700 &type_by_name(builtin_names::PRINCIPLE).unwrap(),
1701 "knowledge",
1702 )
1703 .unwrap();
1704 let keys: Vec<&str> = result.entity.metadata.keys().map(|s| s.as_str()).collect();
1705 assert_eq!(
1706 keys,
1707 vec![
1708 "type",
1709 "universality",
1710 "authority",
1711 "tags",
1712 "created_date",
1713 "last_modified",
1714 ],
1715 "metadata iteration must preserve frontmatter declaration order"
1716 );
1717 }
1718
1719 #[test]
1727 fn parse_write_roundtrip_preserves_section_order() {
1728 let md = "\
1729---
1730type: spec
1731created_date: 2026-01-15
1732last_modified: 2026-04-12
1733level: M0
1734---
1735# Order Roundtrip
1736
1737## Identity
1738
1739Identity content.
1740
1741## Purpose
1742
1743Purpose content.
1744
1745## Specifies
1746
1747Specifies content.
1748";
1749 let schema = spec_schema();
1750 let first = parse_markdown(md, "order-roundtrip.md", &schema, "specs").unwrap();
1751 let regenerated = crate::entity::generator::generate_markdown(&first.entity, &schema);
1752 let second = parse_markdown(®enerated, "order-roundtrip.md", &schema, "specs").unwrap();
1753
1754 let first_keys: Vec<&String> = first.entity.sections.keys().collect();
1755 let second_keys: Vec<&String> = second.entity.sections.keys().collect();
1756 assert_eq!(
1757 first_keys, second_keys,
1758 "section iteration order must survive parse -> generate -> parse"
1759 );
1760 }
1761
1762 #[test]
1772 fn parser_extracts_single_h3() {
1773 let md = "\
1774---
1775type: spec
1776---
1777# Entity
1778
1779## Identity
1780
1781Body.
1782
1783## Specifies
1784
1785### Response Shapes
1786
1787Content under response shapes.
1788";
1789 let result = parse_markdown(md, "h3-single.md", &spec_schema(), "specs").unwrap();
1790 let spans = result
1791 .entity
1792 .heading_spans
1793 .get("specifies")
1794 .expect("specifies section should have spans");
1795 assert_eq!(spans.len(), 1);
1796 assert_eq!(spans[0].level, 3);
1797 assert_eq!(spans[0].title, "Response Shapes");
1798 assert_eq!(spans[0].start_offset, 0);
1800 let section = result.entity.sections.get("specifies").unwrap();
1801 assert_eq!(spans[0].end_offset, section.len());
1802 assert!(
1804 result
1805 .entity
1806 .heading_spans
1807 .get("identity")
1808 .is_none_or(Vec::is_empty)
1809 );
1810 }
1811
1812 #[test]
1813 fn parser_extracts_nested_h3_h4() {
1814 let md = "\
1815---
1816type: spec
1817---
1818# Entity
1819
1820## Identity
1821
1822Body.
1823
1824## Specifies
1825
1826### Outer
1827
1828Outer body.
1829
1830#### Inner
1831
1832Inner body.
1833";
1834 let result = parse_markdown(md, "h3-h4.md", &spec_schema(), "specs").unwrap();
1835 let spans = result.entity.heading_spans.get("specifies").unwrap();
1836 assert_eq!(spans.len(), 2, "both H3 and H4 must be recorded");
1837 assert_eq!(spans[0].level, 3);
1838 assert_eq!(spans[0].title, "Outer");
1839 assert_eq!(spans[1].level, 4);
1840 assert_eq!(spans[1].title, "Inner");
1841 assert!(
1842 spans[0].start_offset < spans[1].start_offset,
1843 "spans must be in document order"
1844 );
1845 assert!(
1847 spans[0].end_offset > spans[1].start_offset,
1848 "outer H3 must contain inner H4 by offset"
1849 );
1850 }
1851
1852 #[test]
1853 fn parser_ignores_headings_in_code_blocks() {
1854 let md = "\
1855---
1856type: spec
1857---
1858# Entity
1859
1860## Identity
1861
1862Body.
1863
1864## Specifies
1865
1866Prefix.
1867
1868```
1869### Not a heading
1870Still code.
1871```
1872
1873Suffix.
1874";
1875 let result = parse_markdown(md, "h3-code.md", &spec_schema(), "specs").unwrap();
1876 let spans = result
1877 .entity
1878 .heading_spans
1879 .get("specifies")
1880 .cloned()
1881 .unwrap_or_default();
1882 assert!(
1883 spans.is_empty(),
1884 "a '### ' inside a fenced block must not register as a heading span: {spans:?}"
1885 );
1886 }
1887
1888 #[test]
1889 fn parser_handles_level_skip() {
1890 let md = "\
1891---
1892type: spec
1893---
1894# Entity
1895
1896## Identity
1897
1898Body.
1899
1900## Specifies
1901
1902#### Skipped To H4
1903
1904Content under a sudden H4 — no virtual H3 is inserted.
1905";
1906 let result = parse_markdown(md, "h2-h4.md", &spec_schema(), "specs").unwrap();
1907 let spans = result.entity.heading_spans.get("specifies").unwrap();
1908 assert_eq!(spans.len(), 1);
1909 assert_eq!(spans[0].level, 4);
1910 assert_eq!(spans[0].title, "Skipped To H4");
1911 }
1912
1913 #[test]
1914 fn parser_handles_duplicate_siblings() {
1915 let md = "\
1916---
1917type: spec
1918---
1919# Entity
1920
1921## Identity
1922
1923Body.
1924
1925## Specifies
1926
1927### Same Title
1928
1929First occurrence body.
1930
1931### Same Title
1932
1933Second occurrence body.
1934";
1935 let result = parse_markdown(md, "h3-dup.md", &spec_schema(), "specs").unwrap();
1936 let spans = result.entity.heading_spans.get("specifies").unwrap();
1937 assert_eq!(spans.len(), 2, "duplicate siblings must produce two spans");
1938 assert_eq!(spans[0].title, spans[1].title);
1939 assert_ne!(
1940 spans[0].start_offset, spans[1].start_offset,
1941 "spans with identical titles must be distinguishable by offset"
1942 );
1943 assert!(
1945 spans[0].end_offset <= spans[1].start_offset,
1946 "first sibling must close before the second starts"
1947 );
1948 }
1949
1950 #[test]
1955 fn duplicate_declared_heading_two_populated_keeps_first_warns() {
1956 let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nfirst body\n\n## Identity\n\nsecond body\n";
1957 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1958 assert_eq!(
1959 result.entity.sections.get("identity").map(String::as_str),
1960 Some("first body"),
1961 "first body must win"
1962 );
1963 assert!(
1964 !result
1965 .entity
1966 .sections
1967 .get("identity")
1968 .unwrap()
1969 .contains("## Identity"),
1970 "storage value must not embed a duplicate heading"
1971 );
1972 assert_eq!(result.parse_warnings.len(), 1);
1973 match &result.parse_warnings[0] {
1974 crate::ops::WarningHint::DuplicateSectionHeading {
1975 section_key,
1976 heading,
1977 occurrences,
1978 ..
1979 } => {
1980 assert_eq!(section_key, "identity");
1981 assert_eq!(heading, "Identity");
1982 assert_eq!(*occurrences, 2);
1983 }
1984 other => panic!("expected DuplicateSectionHeading, got {other:?}"),
1985 }
1986 }
1987
1988 #[test]
1989 fn duplicate_declared_heading_blank_then_populated_keeps_blank() {
1990 let md =
1994 "---\ntype: spec\n---\n# Title\n\n## Identity\n\n## Identity\n\nleftover content\n";
1995 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1996 assert_eq!(
1997 result.entity.sections.get("identity").map(String::as_str),
1998 Some(""),
1999 "first (blank) occurrence wins; second body is dropped"
2000 );
2001 assert_eq!(result.parse_warnings.len(), 1);
2002 }
2003
2004 #[test]
2005 fn duplicate_declared_heading_three_occurrences() {
2006 let md = "---\ntype: spec\n---\n# Title\n\n## Constraints\n\nA\n\n## Constraints\n\n## Constraints\n\nC\n";
2007 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
2008 assert_eq!(
2009 result
2010 .entity
2011 .sections
2012 .get("constraints")
2013 .map(String::as_str),
2014 Some("A"),
2015 );
2016 assert_eq!(result.parse_warnings.len(), 1);
2017 match &result.parse_warnings[0] {
2018 crate::ops::WarningHint::DuplicateSectionHeading { occurrences, .. } => {
2019 assert_eq!(*occurrences, 3);
2020 }
2021 _ => unreachable!(),
2022 }
2023 }
2024
2025 #[test]
2026 fn no_warning_when_each_declared_section_appears_once() {
2027 let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nID\n\n## Purpose\n\nP\n\n## Constraints\n\nC\n";
2028 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
2029 assert!(result.parse_warnings.is_empty());
2030 }
2031
2032 #[test]
2033 fn no_warning_when_catch_all_section_repeats() {
2034 let md =
2037 "---\ntype: spec\n---\n# Title\n\n## Specifies\n\nfirst\n\n## Specifies\n\nsecond\n";
2038 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
2039 assert!(
2040 result.parse_warnings.is_empty(),
2041 "catch-all repetition must not warn"
2042 );
2043 }
2044
2045 #[test]
2052 fn duplicate_realization_does_not_concatenate_headers_in_storage() {
2053 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";
2054 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
2055 let catch_all = result.entity.sections.get("specifies").unwrap();
2056 let header_count = catch_all.matches("## Realization").count();
2057 assert!(
2058 header_count <= 1,
2059 "catch-all bucket must not contain multiple `## Realization` headers — got {header_count}: {catch_all:?}"
2060 );
2061 }
2062
2063 #[test]
2069 fn parse_render_round_trip_collapses_duplicate_headings() {
2070 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";
2071 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
2072 let rendered = crate::render::render_entity_markdown(&result.entity, None);
2073 let identity_count = rendered.matches("## Identity").count();
2074 assert_eq!(
2075 identity_count, 1,
2076 "rendered output must carry exactly one `## Identity`, got {identity_count}: {rendered}"
2077 );
2078 assert!(rendered.contains("\n## Identity\n\nA\n"));
2080 assert!(!rendered.contains("C\n"), "second body must not survive");
2081 }
2082}
2083
2084#[cfg(test)]
2090mod commonmark_referee {
2091 use super::*;
2092 use memstead_schema::{builtin_names, type_by_name};
2093 use std::sync::Arc;
2094
2095 fn spec_schema() -> Arc<TypeDefinition> {
2096 type_by_name(builtin_names::SPEC).unwrap()
2097 }
2098
2099 fn entity_with_specifies(body: &str) -> ParseResult {
2101 let md = format!(
2102 "---\ntype: spec\n---\n\n# Referee Test\n\n## Identity\n\nx\n\n## Specifies\n\n{body}\n"
2103 );
2104 parse_markdown(&md, "referee-test.md", &spec_schema(), "specs").unwrap()
2105 }
2106
2107 fn headings(result: &ParseResult) -> Vec<&str> {
2108 result
2109 .entity
2110 .raw_section_headings
2111 .iter()
2112 .map(String::as_str)
2113 .collect()
2114 }
2115
2116 fn link_targets(result: &ParseResult) -> Vec<String> {
2117 result.inline_links.iter().map(|id| id.0.clone()).collect()
2118 }
2119
2120 #[test]
2123 fn complement_prose_headings_and_links_still_work() {
2124 let r = entity_with_specifies("See [[real-target]] here.");
2125 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2126 assert_eq!(link_targets(&r), vec!["specs--real-target".to_string()]);
2127 assert_eq!(r.entity.title, "Referee Test");
2128 }
2129
2130 #[test]
2131 fn class_1_indented_code_block() {
2132 let r = entity_with_specifies("Example:\n\n ## Not A Section\n [[not-a-link]]\n");
2133 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2134 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2135 }
2136
2137 #[test]
2138 fn class_2_fence_indented_one_to_three_spaces() {
2139 let r = entity_with_specifies(
2140 "- item\n\n ```\n ## Not A Section\n [[not-a-link]]\n ```\n",
2141 );
2142 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2143 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2144 }
2145
2146 #[test]
2147 fn class_3_tilde_fence() {
2148 let r = entity_with_specifies("~~~\n## Not A Section\n[[not-a-link]]\n~~~\n");
2149 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2150 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2151 }
2152
2153 #[test]
2154 fn class_4_info_string_on_the_closing_line() {
2155 let r = entity_with_specifies(
2156 "```\ncode\n``` still-code\n## Not A Section\n[[not-a-link]]\n```\n",
2157 );
2158 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2159 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2160 }
2161
2162 #[test]
2163 fn class_5_fence_inside_a_blockquote() {
2164 let r = entity_with_specifies("> ```\n> ## Not A Section\n> [[not-a-link]]\n> ```\n");
2165 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2166 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2167 }
2168
2169 #[test]
2170 fn class_6_opening_fence_length_is_honoured_on_close() {
2171 let r = entity_with_specifies("````\n```\n## Not A Section\n[[not-a-link]]\n```\n````\n");
2172 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2173 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2174 }
2175
2176 #[test]
2178 fn a_heading_inside_a_code_block_never_becomes_the_title() {
2179 let md =
2180 "---\ntype: spec\n---\n\n```\n# Fake Title\n```\n\n# Real Title\n\n## Identity\n\nx\n";
2181 let r = parse_markdown(md, "title-test.md", &spec_schema(), "specs").unwrap();
2182 assert_eq!(r.entity.title, "Real Title");
2183 }
2184
2185 #[test]
2188 fn a_code_block_only_body_falls_back_to_the_filename() {
2189 let md = "---\ntype: spec\n---\n\n # Fake Title\n\n## Identity\n\nx\n";
2190 let r = parse_markdown(md, "fallback-test.md", &spec_schema(), "specs").unwrap();
2191 assert_eq!(r.entity.title, "fallback-test");
2192 }
2193
2194 #[test]
2195 fn heading_spans_ignore_code_block_content() {
2196 let r = entity_with_specifies("### Real Sub\n\n~~~\n### Fake Sub\n~~~\n");
2197 let spans = r.entity.heading_spans.get("specifies").expect("spans");
2198 let titles: Vec<&str> = spans.iter().map(|s| s.title.as_str()).collect();
2199 assert_eq!(titles, vec!["Real Sub"]);
2200 }
2201
2202 #[test]
2207 fn inline_code_spans_hide_links_on_the_extraction_path() {
2208 let r = entity_with_specifies("`[[hidden-one]]` and ``[[hidden-two]]`` but [[visible]].");
2209 assert_eq!(link_targets(&r), vec!["specs--visible".to_string()]);
2210 }
2211
2212 #[test]
2216 fn empty_wiki_link_target_is_refused_by_the_strict_extractor() {
2217 let errors = extract_inline_links("an empty [[]] link", "specs")
2218 .expect_err("empty target must refuse");
2219 assert_eq!(errors.len(), 1, "{errors:?}");
2220 }
2221
2222 #[test]
2225 fn empty_wiki_link_target_yields_no_id_on_the_lenient_path() {
2226 assert!(extract_inline_links_lenient("an empty [[]] link", "specs").is_empty());
2227 }
2228
2229 #[test]
2235 fn merge_conflict_markers_are_seen_through_fence_shaped_frontmatter() {
2236 let body = "\n# T\n\n## Identity\n\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n";
2237 for fm in [
2238 "---\ntype: spec\n---",
2239 "---\ntype: spec\nnotes: |\n ```rust\n fn x() {}\n---",
2240 "---\ntype: spec\nnotes: |\n ~~~\n---",
2241 "---\ntype: spec\nnotes: |\n indented block\n---",
2242 ] {
2243 assert!(
2244 has_merge_conflict_markers(&format!("{fm}{body}")),
2245 "conflict markers must be seen through frontmatter: {fm:?}"
2246 );
2247 }
2248 }
2249
2250 #[test]
2253 fn merge_conflict_markers_in_frontmatter_are_seen() {
2254 let content =
2255 "---\n<<<<<<< HEAD\ntype: spec\n=======\ntype: memo\n>>>>>>> branch\n---\n\n# T\n";
2256 assert!(has_merge_conflict_markers(content));
2257 }
2258
2259 #[test]
2262 fn a_fenced_conflict_marker_example_still_does_not_trip_the_guard() {
2263 let content = "---\ntype: spec\n---\n\n# T\n\n## Identity\n\n```\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n```\n";
2264 assert!(!has_merge_conflict_markers(content));
2265 }
2266
2267 #[test]
2273 fn a_relationship_row_inside_a_code_block_is_not_a_relationship() {
2274 for body in [
2275 "```\n- **REFERENCES**: [[ghost]]\n```",
2276 "~~~\n- **REFERENCES**: [[ghost]]\n~~~",
2277 " - **REFERENCES**: [[ghost]]",
2278 "> ```\n> - **REFERENCES**: [[ghost]]\n> ```",
2279 "````\n```\n- **REFERENCES**: [[ghost]]\n```\n````",
2280 ] {
2281 let (rels, _) = parse_relationships_with_warnings(body, "specs", None);
2282 assert!(
2283 rels.is_empty(),
2284 "code-block row must not become an edge: {body:?} -> {rels:?}"
2285 );
2286 }
2287 }
2288
2289 #[test]
2296 fn a_relationship_row_inside_an_inline_code_span_is_not_a_relationship() {
2297 for body in [
2298 "Example `open\n - **REFERENCES**: [[ghost]]\nclose`",
2304 "A `- **REFERENCES**: [[ghost]]` sample.",
2305 "A ``- **REFERENCES**: [[ghost]]`` sample.",
2306 ] {
2307 let (rels, _) = parse_relationships_with_warnings(body, "specs", None);
2308 assert!(
2309 rels.is_empty(),
2310 "code-span row must not become an edge: {body:?} -> {rels:?}"
2311 );
2312 }
2313 }
2314
2315 #[test]
2319 fn real_relationship_rows_are_unchanged_by_the_mask() {
2320 let body = "- **REFERENCES**: [[alpha]]\n- **uses**: [[beta]] — because it must\n\n```\n- **REFERENCES**: [[ghost]]\n```\n";
2321 let (rels, _) = parse_relationships_with_warnings(body, "specs", None);
2322 assert_eq!(rels.len(), 2, "{rels:?}");
2323 assert_eq!(rels[0].rel_type, "REFERENCES");
2324 assert_eq!(rels[0].target.0, "specs--alpha");
2325 assert_eq!(rels[0].description, None);
2326 assert_eq!(
2327 rels[1].rel_type, "USES",
2328 "case is normalised from the original"
2329 );
2330 assert_eq!(rels[1].target.0, "specs--beta");
2331 assert_eq!(rels[1].description.as_deref(), Some("because it must"));
2332 }
2333
2334 #[test]
2335 fn ambiguous_delimiter_warning_still_fires_on_a_real_row() {
2336 let id = file_path_to_id("x.md", "specs");
2337 let (_, warnings) = parse_relationships_with_warnings(
2338 "- **REFERENCES**: [[alpha]] -- not an em dash\n",
2339 "specs",
2340 Some(&id),
2341 );
2342 assert_eq!(warnings.len(), 1, "{warnings:?}");
2343 }
2344
2345 #[test]
2353 fn frontmatter_never_opens_a_code_block_over_the_body() {
2354 for fm in [
2355 "notes: |\n ```rust",
2356 "notes: |\n ~~~",
2357 "notes: |\n ```\n still open",
2358 "notes: |\n indented block\n",
2359 ] {
2360 let md = format!(
2361 "---\ntype: spec\n{fm}\n---\n\n# Real Title\n\n## Identity\n\nSee [[a-link]].\n"
2362 );
2363 let r = parse_markdown(&md, "fm-test.md", &spec_schema(), "specs").unwrap();
2364 assert_eq!(
2365 r.entity.title, "Real Title",
2366 "frontmatter ate the title: {fm:?}"
2367 );
2368 assert_eq!(
2369 headings(&r),
2370 vec!["Identity"],
2371 "frontmatter ate the sections: {fm:?}"
2372 );
2373 assert_eq!(
2374 link_targets(&r),
2375 vec!["specs--a-link".to_string()],
2376 "frontmatter ate the links: {fm:?}"
2377 );
2378 }
2379 }
2380}