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 result_sections.insert(s.key.clone(), catch_all_content.clone());
85 } else {
86 let val = sections_map
87 .get(s.key.as_str())
88 .map(|(_, content)| content.clone())
89 .unwrap_or_default();
90 result_sections.insert(s.key.clone(), val);
91 }
92 }
93
94 let mut parsed_metadata = parse_metadata(&metadata);
96
97 let type_name = parsed_metadata
102 .get("type")
103 .and_then(|v| v.as_str())
104 .unwrap_or(schema.name.as_str())
105 .to_string();
106 parsed_metadata.insert("type".to_string(), MetadataValue::String(type_name.clone()));
107
108 let inline_link_text: String = schema
110 .text_fields
111 .iter()
112 .filter_map(|f| result_sections.get(f.as_str()))
113 .cloned()
114 .collect::<Vec<_>>()
115 .join("\n");
116 let inline_links = extract_inline_links_lenient(&inline_link_text, mem);
121
122 let explicit_targets: HashSet<_> = relationships.iter().map(|r| &r.target).collect();
124 let inline_links: Vec<EntityId> = inline_links
125 .into_iter()
126 .filter(|link| !explicit_targets.contains(link))
127 .collect();
128
129 let heading_spans = extract_heading_spans(&result_sections);
132
133 let declared_keys: HashSet<&str> = schema
137 .sections
138 .iter()
139 .filter(|s| !s.catch_all)
140 .map(|s| s.key.as_str())
141 .collect();
142 let entity_id_for_warnings = file_path_to_id(relative_path, mem);
143 let mut parse_warnings: Vec<crate::ops::WarningHint> = duplicate_headings
144 .into_iter()
145 .filter(|d| declared_keys.contains(d.key.as_str()))
146 .map(|d| crate::ops::WarningHint::DuplicateSectionHeading {
147 entity_id: entity_id_for_warnings.clone(),
148 section_key: d.key,
149 heading: d.heading,
150 occurrences: d.occurrences,
151 })
152 .collect();
153 parse_warnings.extend(rel_parse_warnings);
154
155 let entity = Entity {
156 id,
157 title,
158 entity_type: type_name,
159 mem: mem.to_string(),
160 file_path: relative_path.to_string(),
161 metadata: parsed_metadata,
162 sections: result_sections,
163 relationships,
164 content_hash,
165 stub: false,
166 stub_kind: None,
167 heading_spans,
168 raw_section_headings,
169 };
170
171 Ok(ParseResult {
172 entity,
173 inline_links,
174 parse_warnings,
175 })
176}
177
178pub fn parse_file(
180 path: &Path,
181 mem_dir: &Path,
182 schema: &TypeDefinition,
183 mem: &str,
184) -> Result<ParseResult, ParseError> {
185 let content = std::fs::read_to_string(path)?;
186 let relative_path = path.strip_prefix(mem_dir).unwrap_or(path).to_string_lossy();
187 parse_markdown(&content, &relative_path, schema, mem)
188}
189
190#[derive(Debug, PartialEq, Eq)]
206pub(crate) enum Frontmatter<'a> {
207 Present { meta: &'a str, body: &'a str },
209 NoOpeningDelimiter,
211 Unclosed,
213}
214
215pub(crate) fn split_frontmatter_core(content: &str) -> (&str, Frontmatter<'_>) {
228 let content = content.strip_prefix('\u{feff}').unwrap_or(content);
229
230 let after_open = if content.starts_with("---\r\n") {
231 5
232 } else if content.starts_with("---\n") {
233 4
234 } else {
235 return (content, Frontmatter::NoOpeningDelimiter);
236 };
237
238 let rest = &content[after_open..];
239 let Some(close_pos) = rest.find("\n---") else {
240 return (content, Frontmatter::Unclosed);
241 };
242 let meta = &rest[..close_pos];
243
244 let body_rest = &rest[close_pos + "\n---".len()..];
245 let body = body_rest
246 .strip_prefix("\r\n")
247 .or_else(|| body_rest.strip_prefix('\n'))
248 .unwrap_or(body_rest);
249
250 (content, Frontmatter::Present { meta, body })
251}
252
253pub fn peek_type_from_frontmatter(content: &str) -> Option<String> {
259 let (_, split) = split_frontmatter_core(content);
260 let Frontmatter::Present {
261 meta: frontmatter, ..
262 } = split
263 else {
264 return None;
265 };
266
267 for line in frontmatter.lines() {
268 let trimmed = line.trim();
269 if trimmed.is_empty() || trimmed.starts_with('#') {
270 continue;
271 }
272 let Some(colon_idx) = trimmed.find(':') else {
273 continue;
274 };
275 let key = trimmed[..colon_idx].trim();
276 if key != "type" {
277 continue;
278 }
279 let mut value = trimmed[colon_idx + 1..].trim();
280 if let Some(hash_idx) = value.find('#') {
281 value = value[..hash_idx].trim();
282 }
283 let value = value.trim_matches(|c| c == '"' || c == '\'');
284 if value.is_empty() {
285 return None;
286 }
287 return Some(value.to_string());
288 }
289 None
290}
291
292pub fn peek_title_and_type(content: &str) -> (Option<String>, Option<String>) {
301 let entity_type = peek_type_from_frontmatter(content);
302 let body = body_after_frontmatter(content);
303 let title = extract_title(body, &mask_code_blocks(body));
304 (title, entity_type)
305}
306
307pub fn body_after_frontmatter(content: &str) -> &str {
323 match split_frontmatter_core(content) {
324 (_, Frontmatter::Present { body, .. }) => body,
325 (stripped, _) => stripped,
326 }
327}
328
329pub(crate) fn split_frontmatter(content: &str) -> Result<(String, String), ParseError> {
333 match split_frontmatter_core(content) {
337 (_, Frontmatter::Present { meta, body }) => Ok((meta.to_string(), body.to_string())),
338 (stripped, _) => Ok((String::new(), stripped.to_string())),
339 }
340}
341
342fn parse_metadata(text: &str) -> IndexMap<String, MetadataValue> {
347 let mut meta = IndexMap::new();
348 if text.is_empty() {
349 return meta;
350 }
351
352 for line in text.lines() {
353 let trimmed = line.trim();
354 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("---") {
356 continue;
357 }
358
359 let Some(colon_idx) = trimmed.find(':') else {
360 continue;
361 };
362
363 let key = trimmed[..colon_idx].trim().to_string();
364 let raw_value = trimmed[colon_idx + 1..].trim();
365
366 let value = strip_inline_comment(raw_value).trim().to_string();
368
369 if value.is_empty() {
370 meta.insert(key, MetadataValue::String(String::new()));
371 continue;
372 }
373
374 if value == "true" {
376 meta.insert(key, MetadataValue::Bool(true));
377 } else if value == "false" {
378 meta.insert(key, MetadataValue::Bool(false));
379 } else if is_float_literal(&value) {
380 if let Ok(f) = value.parse::<f64>() {
381 meta.insert(key, MetadataValue::Float(f));
382 } else {
383 meta.insert(key, MetadataValue::String(strip_quotes(&value)));
384 }
385 } else if is_integer_literal(&value) {
386 if let Ok(n) = value.parse::<i64>() {
387 meta.insert(key, MetadataValue::Integer(n));
388 } else {
389 meta.insert(key, MetadataValue::String(strip_quotes(&value)));
390 }
391 } else {
392 meta.insert(key, MetadataValue::String(strip_quotes(&value)));
393 }
394 }
395
396 meta
397}
398
399fn is_float_literal(s: &str) -> bool {
401 let s = s.strip_prefix('-').unwrap_or(s);
402 if let Some((before, after)) = s.split_once('.') {
403 !before.is_empty()
404 && before.chars().all(|c| c.is_ascii_digit())
405 && !after.is_empty()
406 && after.chars().all(|c| c.is_ascii_digit())
407 } else {
408 false
409 }
410}
411
412fn is_integer_literal(s: &str) -> bool {
414 let s = s.strip_prefix('-').unwrap_or(s);
415 !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
416}
417
418pub(crate) fn would_coerce_from_string(s: &str) -> bool {
424 s == "true" || s == "false" || is_integer_literal(s) || is_float_literal(s)
425}
426
427fn strip_inline_comment(s: &str) -> &str {
429 if let Some(idx) = s.find(" #") {
432 s[..idx].trim_end()
433 } else {
434 s
435 }
436}
437
438fn strip_quotes(s: &str) -> String {
442 if s.len() >= 2
443 && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
444 {
445 s[1..s.len() - 1].to_string()
446 } else {
447 s.to_string()
448 }
449}
450
451pub use crate::markdown::{mask_code_blocks, mask_code_blocks_and_spans};
463
464pub fn has_merge_conflict_markers(text: &str) -> bool {
494 let body = body_after_frontmatter(text);
500 let frontmatter = &text[..text.len() - body.len()];
501 let view = format!("{frontmatter}{}", mask_code_blocks(body));
502
503 let mut seen_start = false;
504 let mut seen_separator = false;
505 for line in view.lines() {
506 if line.starts_with("<<<<<<< ") {
507 seen_start = true;
508 seen_separator = false;
509 } else if seen_start && line.trim_end() == "=======" {
510 seen_separator = true;
511 } else if seen_separator && line.starts_with(">>>>>>> ") {
512 return true;
513 }
514 }
515 false
516}
517
518pub(crate) type SplitSections = IndexMap<String, (String, String)>;
528
529pub(crate) struct DuplicateSection {
532 pub key: String,
533 pub heading: String,
534 pub occurrences: usize,
535}
536
537pub(crate) fn split_sections(
555 body: &str,
556 masked_body: &str,
557) -> (SplitSections, Vec<DuplicateSection>, Vec<String>) {
558 let mut sections = IndexMap::new();
564 let mut duplicates: HashMap<String, DuplicateSection> = HashMap::new();
565 let mut raw_headings = Vec::new();
566 static SECTION_RE: OnceLock<Regex> = OnceLock::new();
567 let section_re = SECTION_RE.get_or_init(|| Regex::new(r"(?m)^## (.+)$").unwrap());
568
569 let matches: Vec<_> = section_re.find_iter(masked_body).collect();
570
571 for (i, m) in matches.iter().enumerate() {
572 let heading_line = &body[m.start()..m.end()];
574 let name = heading_line
575 .strip_prefix("## ")
576 .unwrap_or(heading_line)
577 .trim();
578
579 let content_start = m.end();
580 let content_end = if i + 1 < matches.len() {
581 matches[i + 1].start()
582 } else {
583 body.len()
584 };
585 let raw = &body[content_start..content_end];
596 let visible_start = raw
597 .split_inclusive('\n')
598 .take_while(|line| line.trim().is_empty())
599 .map(str::len)
600 .sum::<usize>();
601 let content = raw[visible_start..].trim_end().to_string();
602 let key = memstead_schema::derive_section_key(name);
610 raw_headings.push(name.to_string());
611
612 match sections.entry(key.clone()) {
613 indexmap::map::Entry::Vacant(slot) => {
614 slot.insert((heading_line.to_string(), content));
615 duplicates.insert(
616 key.clone(),
617 DuplicateSection {
618 key: key.clone(),
619 heading: name.to_string(),
620 occurrences: 1,
621 },
622 );
623 }
624 indexmap::map::Entry::Occupied(_) => {
625 if let Some(d) = duplicates.get_mut(&key) {
628 d.occurrences += 1;
629 }
630 }
631 }
632 }
633
634 let dup_list: Vec<DuplicateSection> = duplicates
635 .into_values()
636 .filter(|d| d.occurrences > 1)
637 .collect();
638
639 (sections, dup_list, raw_headings)
640}
641
642fn extract_title(body: &str, masked_body: &str) -> Option<String> {
649 for (line, masked) in body.lines().zip(masked_body.lines()) {
650 if masked.starts_with("# ") {
651 return Some(line[2..].trim().to_string());
652 }
653 }
654 None
655}
656
657fn extract_heading_spans(sections: &IndexMap<String, String>) -> HashMap<String, Vec<HeadingSpan>> {
672 static RE: OnceLock<Regex> = OnceLock::new();
674 let re = RE.get_or_init(|| Regex::new(r"(?m)^(#{3,6})[ \t]+(.+)$").unwrap());
675 let mut out: HashMap<String, Vec<HeadingSpan>> = HashMap::new();
676
677 for (key, content) in sections {
678 if content.is_empty() {
679 continue;
680 }
681 let masked = mask_code_blocks(content);
682
683 let raw: Vec<(usize, u8, String)> = re
685 .captures_iter(&masked)
686 .map(|cap| {
687 let whole = cap.get(0).unwrap();
688 let level = cap[1].len() as u8; let line_end = content[whole.start()..]
692 .find('\n')
693 .map(|i| whole.start() + i)
694 .unwrap_or(content.len());
695 let hashes_end = whole.start() + level as usize;
696 let title = content[hashes_end..line_end].trim().to_string();
697 (whole.start(), level, title)
698 })
699 .collect();
700
701 if raw.is_empty() {
702 continue;
703 }
704
705 let mut spans: Vec<HeadingSpan> = Vec::with_capacity(raw.len());
706 for (i, &(start, level, ref title)) in raw.iter().enumerate() {
707 let end = raw[i + 1..]
709 .iter()
710 .find(|(_, l, _)| *l <= level)
711 .map(|(s, _, _)| *s)
712 .unwrap_or(content.len());
713 spans.push(HeadingSpan {
714 level,
715 title: title.clone(),
716 start_offset: start,
717 end_offset: end,
718 });
719 }
720 out.insert(key.clone(), spans);
721 }
722
723 out
724}
725
726fn build_catch_all(sections: &SplitSections, schema: &TypeDefinition) -> String {
732 let catch_all = match schema.catch_all_section() {
733 Some(s) => s,
734 None => return String::new(),
735 };
736
737 let known_sections: HashSet<&str> = schema
738 .sections
739 .iter()
740 .map(|s| s.key.as_str())
741 .chain(std::iter::once("relationships"))
742 .collect();
743
744 let mut parts = Vec::new();
745
746 if let Some((_, content)) = sections.get(catch_all.key.as_str())
748 && !content.is_empty()
749 {
750 parts.push(content.clone());
751 }
752
753 for (key, (heading_line, content)) in sections {
765 if !known_sections.contains(key.as_str()) && !content.is_empty() {
766 parts.push(format!("{heading_line}\n{content}"));
767 }
768 }
769
770 let mut joined = String::new();
786 for piece in parts {
787 if joined.is_empty() {
788 joined = piece;
789 } else {
790 joined.push_str("\n\n");
791 joined.push_str(&piece);
792 }
793 if let Some(closer) = crate::markdown::closing_fence_if_unterminated(&joined) {
794 joined.push('\n');
795 joined.push_str(&closer);
796 }
797 }
798 joined
799}
800
801pub(crate) fn parse_relationships_with_warnings(
825 text: &str,
826 mem: &str,
827 entity_id: Option<&EntityId>,
828) -> (Vec<Relationship>, Vec<crate::ops::WarningHint>) {
829 static RE: OnceLock<Regex> = OnceLock::new();
843 let re = RE.get_or_init(|| {
844 Regex::new(r"(?m)^\s*-\s*\*\*(\w+)\*\*:\s*\[\[([^\]\n]+)\]\](?P<tail>[^\n]*)").unwrap()
845 });
846 let mut relationships = Vec::new();
847 let mut warnings = Vec::new();
848 let masked = mask_code_blocks_and_spans(text);
856 for cap in re.captures_iter(&masked) {
857 let rel_type = text[cap.get(1).unwrap().range()].to_uppercase();
858 let target = wiki_link_to_id_lenient(&text[cap.get(2).unwrap().range()], mem);
864 if target.path().is_empty() {
873 continue;
874 }
875 let tail = cap.name("tail").map(|m| &text[m.range()]).unwrap_or("");
876 let description = match classify_description_tail(tail) {
877 DescriptionTail::None => None,
878 DescriptionTail::EmDash(text) => Some(text),
879 DescriptionTail::Ambiguous(literal) => {
880 if let Some(id) = entity_id {
881 warnings.push(crate::ops::WarningHint::AmbiguousDescriptionDelimiter {
882 from: id.clone(),
883 rel_type: rel_type.clone(),
884 target: target.clone(),
885 trailing: literal,
886 });
887 }
888 None
889 }
890 };
891 relationships.push(Relationship {
892 rel_type,
893 target,
894 description,
895 });
896 }
897 (relationships, warnings)
898}
899
900enum DescriptionTail {
903 None,
905 EmDash(String),
908 Ambiguous(String),
912}
913
914fn classify_description_tail(tail: &str) -> DescriptionTail {
920 let trimmed_end = tail.trim_end();
921 if trimmed_end.is_empty() {
922 return DescriptionTail::None;
923 }
924 if let Some(rest) = trimmed_end.strip_prefix(" \u{2014} ") {
926 if rest.is_empty() {
927 return DescriptionTail::None;
928 }
929 return DescriptionTail::EmDash(rest.to_string());
930 }
931 if let Some(rest) = trimmed_end.strip_prefix(" \u{2014}") {
935 return DescriptionTail::Ambiguous(format!(" \u{2014}{rest}"));
937 }
938 let starters = [" --", " -", " \u{2013}", " \u{2212}"];
940 if starters
941 .iter()
942 .any(|prefix| trimmed_end.starts_with(prefix))
943 {
944 return DescriptionTail::Ambiguous(trimmed_end.to_string());
945 }
946 DescriptionTail::Ambiguous(trimmed_end.to_string())
950}
951
952fn wiki_link_re() -> &'static Regex {
964 static RE: OnceLock<Regex> = OnceLock::new();
965 RE.get_or_init(|| Regex::new(r"\[\[([^\]]*)\]\]").unwrap())
966}
967
968pub(crate) fn extract_inline_links(
982 text: &str,
983 mem: &str,
984) -> Result<Vec<EntityId>, Vec<WikiLinkError>> {
985 let stripped = mask_code_blocks_and_spans(text);
986
987 let link_re = wiki_link_re();
988 let mut seen = HashSet::new();
989 let mut links = Vec::new();
990 let mut errors = Vec::new();
991
992 for cap in link_re.captures_iter(&stripped) {
993 match wiki_link_to_id(&cap[1], mem) {
994 Ok(id) => {
995 if errors.is_empty() && seen.insert(id.0.clone()) {
996 links.push(id);
997 }
998 }
999 Err(e) => errors.push(e),
1000 }
1001 }
1002
1003 if errors.is_empty() {
1004 Ok(links)
1005 } else {
1006 Err(errors)
1007 }
1008}
1009
1010pub fn extract_inline_links_lenient(text: &str, mem: &str) -> Vec<EntityId> {
1017 let stripped = mask_code_blocks_and_spans(text);
1018
1019 let link_re = wiki_link_re();
1020 let mut seen = HashSet::new();
1021 let mut links = Vec::new();
1022
1023 for cap in link_re.captures_iter(&stripped) {
1024 if cap[1].is_empty() {
1029 continue;
1030 }
1031 let id = wiki_link_to_id_lenient(&cap[1], mem);
1032 if seen.insert(id.0.clone()) {
1033 links.push(id);
1034 }
1035 }
1036
1037 links
1038}
1039
1040pub fn compute_hash(content: &str) -> String {
1046 let mut hasher = Sha256::new();
1047 hasher.update(content.as_bytes());
1048 let result = hasher.finalize();
1049 crate::hex_lower(&result)[..16].to_string()
1050}
1051
1052#[derive(Debug, thiserror::Error)]
1057pub enum ParseError {
1058 #[error("missing frontmatter")]
1059 MissingFrontmatter,
1060 #[error("invalid frontmatter: {0}")]
1061 InvalidFrontmatter(String),
1062 #[error("missing title")]
1063 MissingTitle,
1064 #[error("io error: {0}")]
1065 Io(#[from] std::io::Error),
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070 use super::*;
1071 use memstead_schema::{builtin_names, type_by_name};
1072 use std::sync::Arc;
1073
1074 fn spec_schema() -> Arc<TypeDefinition> {
1075 type_by_name(builtin_names::SPEC).unwrap()
1076 }
1077
1078 fn memo_schema() -> Arc<TypeDefinition> {
1079 type_by_name(builtin_names::MEMO).unwrap()
1080 }
1081
1082 #[test]
1083 fn parse_metadata_types() {
1084 let meta = parse_metadata("key: value\nnum: 42\nfloat: 0.85\nbool: true\nfalsy: false");
1085 assert_eq!(meta["key"], MetadataValue::String("value".to_string()));
1086 assert_eq!(meta["num"], MetadataValue::Integer(42));
1087 assert_eq!(meta["float"], MetadataValue::Float(0.85));
1088 assert_eq!(meta["bool"], MetadataValue::Bool(true));
1089 assert_eq!(meta["falsy"], MetadataValue::Bool(false));
1090 }
1091
1092 #[test]
1093 fn parse_metadata_strips_comments() {
1094 let meta = parse_metadata("key: value # this is a comment");
1095 assert_eq!(meta["key"], MetadataValue::String("value".to_string()));
1096 }
1097
1098 #[test]
1099 fn parse_metadata_strips_quotes() {
1100 let meta = parse_metadata("key: \"quoted value\"\nkey2: 'single'");
1101 assert_eq!(
1102 meta["key"],
1103 MetadataValue::String("quoted value".to_string())
1104 );
1105 assert_eq!(meta["key2"], MetadataValue::String("single".to_string()));
1106 }
1107
1108 #[test]
1109 fn parse_metadata_survives_malformed_values() {
1110 let meta = parse_metadata(
1113 "key: \"\nkey2: '\nkey3: \"\"\nkey4: ''\nkey5: \"unterminated\nkey6: mixed'\"",
1114 );
1115 assert_eq!(meta["key"], MetadataValue::String("\"".to_string()));
1116 assert_eq!(meta["key2"], MetadataValue::String("'".to_string()));
1117 assert_eq!(meta["key3"], MetadataValue::String(String::new()));
1118 assert_eq!(meta["key4"], MetadataValue::String(String::new()));
1119 assert_eq!(
1120 meta["key5"],
1121 MetadataValue::String("\"unterminated".to_string())
1122 );
1123 assert_eq!(meta["key6"], MetadataValue::String("mixed'\"".to_string()));
1124
1125 let meta =
1128 parse_metadata(":\n: value\nkey7: ✓\"\nkey8: 99999999999999999999999999\nkey9: -");
1129 assert_eq!(meta["key7"], MetadataValue::String("✓\"".to_string()));
1130 assert_eq!(
1131 meta["key8"],
1132 MetadataValue::String("99999999999999999999999999".to_string())
1133 );
1134 assert_eq!(meta["key9"], MetadataValue::String("-".to_string()));
1135 }
1136
1137 #[test]
1138 fn parse_metadata_skips_comments_and_empty() {
1139 let meta = parse_metadata("# comment\n\nkey: val\n---");
1140 assert_eq!(meta.len(), 1);
1141 assert_eq!(meta["key"], MetadataValue::String("val".to_string()));
1142 }
1143
1144 #[test]
1145 fn peek_type_finds_value() {
1146 let content = "---\ntype: memo\ntitle: Test\n---\n# Body\n";
1147 assert_eq!(
1148 peek_type_from_frontmatter(content),
1149 Some("memo".to_string())
1150 );
1151 }
1152
1153 #[test]
1154 fn peek_type_returns_none_when_missing() {
1155 let content = "---\ntitle: Test\n---\n# Body\n";
1156 assert_eq!(peek_type_from_frontmatter(content), None);
1157 }
1158
1159 #[test]
1160 fn peek_type_returns_none_without_frontmatter() {
1161 let content = "# Just a heading\n\nBody with type: concept inside text.\n";
1162 assert_eq!(peek_type_from_frontmatter(content), None);
1163 }
1164
1165 #[test]
1166 fn peek_type_handles_windows_line_endings() {
1167 let content = "---\r\ntype: principle\r\n---\r\n# Body\r\n";
1168 assert_eq!(
1169 peek_type_from_frontmatter(content),
1170 Some("principle".to_string())
1171 );
1172 }
1173
1174 #[test]
1175 fn peek_type_strips_quotes_and_comments() {
1176 let quoted = "---\ntype: \"concept\"\n---\n";
1177 assert_eq!(
1178 peek_type_from_frontmatter(quoted),
1179 Some("concept".to_string())
1180 );
1181 let commented = "---\ntype: memo # kind of\n---\n";
1182 assert_eq!(
1183 peek_type_from_frontmatter(commented),
1184 Some("memo".to_string())
1185 );
1186 }
1187
1188 #[test]
1189 fn peek_type_empty_value_returns_none() {
1190 let content = "---\ntype:\n---\n";
1191 assert_eq!(peek_type_from_frontmatter(content), None);
1192 }
1193
1194 #[test]
1195 fn peek_type_ignores_legacy_schema_key() {
1196 let content = concat!("---\n", "schema", ": memo\n---\n");
1199 assert_eq!(peek_type_from_frontmatter(content), None);
1200 }
1201
1202 #[test]
1203 fn mask_code_blocks_basic() {
1204 let input = "before\n```\ncode [[link]]\n```\nafter";
1205 let masked = mask_code_blocks(input);
1206 assert!(!masked.contains("[[link]]"));
1207 assert!(masked.contains("before"));
1208 assert!(masked.contains("after"));
1209 }
1210
1211 #[test]
1212 fn mask_code_blocks_preserves_line_count() {
1213 let input = "line1\n```\ncode\nmore code\n```\nline6";
1214 let masked = mask_code_blocks(input);
1215 assert_eq!(input.lines().count(), masked.lines().count());
1216 }
1217
1218 #[test]
1219 fn mask_code_blocks_unclosed() {
1220 let input = "before\n```\ncode\nmore code";
1221 let masked = mask_code_blocks(input);
1222 assert!(masked.contains("before"));
1223 assert!(!masked.contains("code"));
1224 }
1225
1226 #[test]
1227 fn parse_relationships_basic() {
1228 let text = "- **USES**: [[target-entity]]\n- **PART_OF**: [[parent]]";
1229 let rels = parse_relationships_with_warnings(text, "specs", None).0;
1230 assert_eq!(rels.len(), 2);
1231 assert_eq!(rels[0].rel_type, "USES");
1232 assert_eq!(rels[0].target.0, "specs--target-entity");
1233 assert_eq!(rels[1].rel_type, "PART_OF");
1234 assert_eq!(rels[1].target.0, "specs--parent");
1235 assert!(rels[0].description.is_none());
1237 assert!(rels[1].description.is_none());
1238 }
1239
1240 #[test]
1241 fn parse_relationships_canonical_em_dash_captures_description() {
1242 let text = "- **OTHER**: [[a]] \u{2014} replaced by checkout-flow";
1243 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1244 assert_eq!(rels.len(), 1);
1245 assert_eq!(
1246 rels[0].description.as_deref(),
1247 Some("replaced by checkout-flow")
1248 );
1249 assert!(warnings.is_empty(), "canonical em-dash does not warn");
1250 }
1251
1252 #[test]
1253 fn parse_relationships_em_dash_inside_description_body() {
1254 let text = "- **OTHER**: [[a]] \u{2014} note with — inside body";
1255 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1256 assert_eq!(rels.len(), 1);
1257 assert_eq!(
1258 rels[0].description.as_deref(),
1259 Some("note with — inside body"),
1260 "the parser captures up to end-of-line; em-dashes inside the body survive"
1261 );
1262 assert!(warnings.is_empty());
1263 }
1264
1265 #[test]
1266 fn parse_relationships_ambiguous_double_hyphen_warns_and_drops_content() {
1267 let text = "- **USES**: [[a]] -- legacy delimiter";
1268 let entity_id = EntityId::new("specs", "src");
1269 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", Some(&entity_id));
1270 assert_eq!(rels.len(), 1);
1271 assert!(rels[0].description.is_none(), "trailing content is dropped");
1272 assert_eq!(warnings.len(), 1);
1273 assert!(matches!(
1274 warnings[0],
1275 crate::ops::WarningHint::AmbiguousDescriptionDelimiter { .. }
1276 ));
1277 }
1278
1279 #[test]
1280 fn parse_relationships_ambiguous_single_hyphen_warns_and_drops_content() {
1281 let text = "- **USES**: [[a]] - single hyphen";
1282 let entity_id = EntityId::new("specs", "src");
1283 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", Some(&entity_id));
1284 assert_eq!(rels.len(), 1);
1285 assert!(rels[0].description.is_none());
1286 assert_eq!(warnings.len(), 1);
1287 assert_eq!(warnings[0].code(), "AMBIGUOUS_DESCRIPTION_DELIMITER");
1288 }
1289
1290 #[test]
1291 fn parse_relationships_hyphenated_slug_target_parses_unambiguously() {
1292 let text = "- **USES**: [[some-slug-with-hyphens]] \u{2014} ok";
1293 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1294 assert_eq!(rels.len(), 1);
1295 assert_eq!(rels[0].target.path(), "some-slug-with-hyphens");
1296 assert_eq!(rels[0].description.as_deref(), Some("ok"));
1297 assert!(warnings.is_empty());
1298 }
1299
1300 #[test]
1301 fn parse_full_entity() {
1302 let md = "\
1303---
1304type: spec
1305created_date: 2026-01-15
1306last_modified: 2026-04-12
1307level: M0
1308tags: backend, api
1309---
1310# Test Entity
1311
1312## Identity
1313
1314This is a test entity.
1315
1316## Purpose
1317
1318Testing the parser.
1319
1320## Relationships
1321
1322- **USES**: [[other-entity]]
1323
1324## Specifies
1325
1326Some specification content with [[inline-link]].
1327";
1328 let result = parse_markdown(md, "test-entity.md", &spec_schema(), "specs").unwrap();
1329 let entity = &result.entity;
1330 assert_eq!(entity.id.0, "specs--test-entity");
1331 assert_eq!(entity.title, "Test Entity");
1332 assert_eq!(entity.mem, "specs");
1333 assert_eq!(
1334 entity.metadata["type"],
1335 MetadataValue::String("spec".to_string())
1336 );
1337 assert_eq!(
1338 entity.metadata["level"],
1339 MetadataValue::String("M0".to_string())
1340 );
1341 assert_eq!(
1342 entity.metadata["tags"],
1343 MetadataValue::String("backend, api".to_string())
1344 );
1345 assert_eq!(entity.sections["identity"], "This is a test entity.");
1346 assert_eq!(entity.sections["purpose"], "Testing the parser.");
1347 assert_eq!(entity.relationships.len(), 1);
1348 assert_eq!(entity.relationships[0].rel_type, "USES");
1349 assert_eq!(entity.relationships[0].target.0, "specs--other-entity");
1350 assert_eq!(result.inline_links.len(), 1);
1351 assert_eq!(result.inline_links[0].0, "specs--inline-link");
1352 }
1353
1354 #[test]
1355 fn parse_full_entity_memo_schema() {
1356 let md = "\
1357---
1358type: memo
1359created_date: 2026-01-15
1360last_modified: 2026-04-12
1361status: active
1362tags: decision, architecture
1363---
1364# Use Sled For Storage
1365
1366## Claim
1367
1368Sled is the right embedded store for this workload.
1369
1370## Context
1371
1372We evaluated sled, rocksdb, and sqlite for the in-process graph cache.
1373
1374## Substance
1375
1376Sled wins on pure-Rust dependency footprint.
1377";
1378 let result = parse_markdown(md, "use-sled.md", &memo_schema(), "memos").unwrap();
1379 let entity = &result.entity;
1380 assert_eq!(entity.id.0, "memos--use-sled");
1381 assert_eq!(entity.title, "Use Sled For Storage");
1382 assert_eq!(entity.mem, "memos");
1383 assert_eq!(
1384 entity.metadata["type"],
1385 MetadataValue::String("memo".to_string())
1386 );
1387 assert_eq!(
1388 entity.metadata["status"],
1389 MetadataValue::String("active".to_string())
1390 );
1391 assert_eq!(
1392 entity.sections["claim"],
1393 "Sled is the right embedded store for this workload."
1394 );
1395 assert_eq!(
1396 entity.sections["context"],
1397 "We evaluated sled, rocksdb, and sqlite for the in-process graph cache."
1398 );
1399 assert_eq!(
1400 entity.sections["substance"],
1401 "Sled wins on pure-Rust dependency footprint."
1402 );
1403 assert!(!entity.sections.contains_key("identity"));
1404 assert!(!entity.sections.contains_key("purpose"));
1405 }
1406
1407 #[test]
1408 fn parse_entity_without_frontmatter() {
1409 let md = "# No Frontmatter\n\n## Identity\n\nJust a title and section.";
1410 let result = parse_markdown(md, "no-fm.md", &spec_schema(), "specs").unwrap();
1411 assert_eq!(result.entity.title, "No Frontmatter");
1412 assert_eq!(result.entity.metadata.len(), 1);
1414 assert_eq!(
1415 result.entity.metadata.get("type"),
1416 Some(&MetadataValue::String("spec".to_string()))
1417 );
1418 }
1419
1420 #[test]
1421 fn parse_entity_code_blocks_not_detected() {
1422 let md = "\
1423---
1424type: spec
1425---
1426# Code Test
1427
1428## Identity
1429
1430Test entity.
1431
1432## Specifies
1433
1434```
1435## Not A Section
1436- **USES**: [[not-a-link]]
1437```
1438
1439Real content after code block.
1440";
1441 let result = parse_markdown(md, "code-test.md", &spec_schema(), "specs").unwrap();
1442 assert!(!result.entity.sections.contains_key("not a section"));
1444 assert!(result.inline_links.is_empty());
1446 }
1447
1448 #[test]
1455 fn bom_prefixed_frontmatter_is_recognized() {
1456 let md = "\u{feff}---\ntype: spec\n---\n# Bom Entity\n\n## Identity\n\nBody.\n";
1457 assert_eq!(peek_type_from_frontmatter(md), Some("spec".to_string()));
1458 assert_eq!(
1459 body_after_frontmatter(md),
1460 "# Bom Entity\n\n## Identity\n\nBody.\n"
1461 );
1462 let (meta, body) = split_frontmatter(md).unwrap();
1463 assert_eq!(meta, "type: spec");
1464 assert_eq!(body, "# Bom Entity\n\n## Identity\n\nBody.\n");
1465 let result = parse_markdown(md, "bom.md", &spec_schema(), "specs").unwrap();
1466 assert_eq!(
1467 result.entity.metadata["type"],
1468 MetadataValue::String("spec".to_string())
1469 );
1470 assert_eq!(result.entity.sections["identity"], "Body.");
1471 }
1472
1473 #[test]
1480 fn open_fence_in_section_content_does_not_swallow_following_sections() {
1481 let md = "\
1482---
1483type: spec
1484---
1485# Code Test
1486
1487## Identity
1488
1489Base.
1490
1491## Specifies
1492
1493```
1494truncated code with no closer";
1495 let schema = spec_schema();
1496 let e1 = parse_markdown(md, "open-fence.md", &schema, "specs").unwrap();
1497 let m1 = crate::entity::generator::generate_markdown(&e1.entity, &schema);
1498 let e2 = parse_markdown(&m1, "open-fence.md", &schema, "specs").unwrap();
1499 assert_eq!(
1500 e2.entity.sections["identity"], "Base.",
1501 "sections before the open fence survive"
1502 );
1503 assert!(
1504 !e2.entity.sections["specifies"].contains("## Constraints"),
1505 "the generated sections after the fence are not absorbed into it"
1506 );
1507 let m2 = crate::entity::generator::generate_markdown(&e2.entity, &schema);
1508 assert_eq!(
1509 m1, m2,
1510 "parse→generate is a fixpoint after one normalising round"
1511 );
1512 }
1513
1514 #[test]
1522 fn catch_all_reconstruction_is_document_ordered_and_idempotent() {
1523 let md = "\
1524---
1525type: spec
1526---
1527# Multi Unknown
1528
1529## Identity
1530
1531Base.
1532
1533## Claim
1534
1535First unknown.
1536
1537## Context
1538
1539Second unknown.
1540
1541## Substance
1542
1543Third unknown.
1544";
1545 let schema = spec_schema();
1546 let e1 = parse_markdown(md, "multi-unknown.md", &schema, "specs").unwrap();
1547 assert_eq!(
1548 e1.entity.sections["specifies"],
1549 "## Claim\nFirst unknown.\n\n## Context\nSecond unknown.\n\n## Substance\nThird unknown.",
1550 "non-schema sections land in the catch-all in document order"
1551 );
1552 let m1 = crate::entity::generator::generate_markdown(&e1.entity, &schema);
1553 let e2 = parse_markdown(&m1, "multi-unknown.md", &schema, "specs").unwrap();
1554 let m2 = crate::entity::generator::generate_markdown(&e2.entity, &schema);
1555 assert_eq!(
1556 m1, m2,
1557 "parse→generate is idempotent over multi-unknown-section input"
1558 );
1559 }
1560
1561 #[test]
1570 fn first_line_whitespace_prefix_survives_storage_and_round_trips() {
1571 let schema = spec_schema();
1572 let md = "---\ntype: spec\n---\n# T\n\n## Identity\n\u{b}```\nx\n\n## Purpose\np\n";
1573 let e1 = parse_markdown(md, "vt.md", &schema, "specs").unwrap();
1574 assert_eq!(
1575 e1.entity.sections["identity"], "\u{b}```\nx",
1576 "the first visible line keeps its whitespace prefix byte-exactly"
1577 );
1578 let m1 = crate::entity::generator::generate_markdown(&e1.entity, &schema);
1579 let e2 = parse_markdown(&m1, "vt.md", &schema, "specs").unwrap();
1580 let m2 = crate::entity::generator::generate_markdown(&e2.entity, &schema);
1581 assert_eq!(m1, m2, "parse→generate is a fixpoint");
1582 }
1583
1584 #[test]
1594 fn indented_heading_lookalike_stays_content_and_round_trips() {
1595 let md = "\
1596---
1597type: spec
1598---
1599# Promoted Heading
1600
1601## Identity
1602
1603Base.
1604
1605## Unknown Extra
1606
1607 ## Specifies
1608
1609Some content that must survive.
1610";
1611 let schema = spec_schema();
1612 let e1 = parse_markdown(md, "indent.md", &schema, "specs").unwrap();
1613 assert!(
1614 e1.entity.sections["specifies"].contains(" ## Specifies"),
1615 "the indented lookalike keeps its indentation inside the catch-all"
1616 );
1617 assert!(
1618 e1.entity.sections["specifies"].contains("Some content that must survive."),
1619 "content after the lookalike is preserved"
1620 );
1621 let m1 = crate::entity::generator::generate_markdown(&e1.entity, &schema);
1622 let e2 = parse_markdown(&m1, "indent.md", &schema, "specs").unwrap();
1623 let m2 = crate::entity::generator::generate_markdown(&e2.entity, &schema);
1624 assert_eq!(
1625 m1, m2,
1626 "parse→generate is a fixpoint after one normalising round"
1627 );
1628 assert!(
1629 e2.entity.sections["specifies"].contains("Some content that must survive."),
1630 "no content is lost across rounds"
1631 );
1632 }
1633
1634 #[test]
1635 fn compute_hash_deterministic() {
1636 let hash1 = compute_hash("test content");
1637 let hash2 = compute_hash("test content");
1638 assert_eq!(hash1, hash2);
1639 assert_eq!(hash1.len(), 16);
1640 }
1641
1642 #[test]
1643 fn compute_hash_differs() {
1644 let hash1 = compute_hash("content a");
1645 let hash2 = compute_hash("content b");
1646 assert_ne!(hash1, hash2);
1647 }
1648
1649 #[test]
1650 fn is_float_literal_matches() {
1651 assert!(is_float_literal("0.85"));
1652 assert!(is_float_literal("-1.5"));
1653 assert!(is_float_literal("100.0"));
1654 assert!(!is_float_literal(".5"));
1655 assert!(!is_float_literal("1."));
1656 assert!(!is_float_literal("42"));
1657 assert!(!is_float_literal("hello"));
1658 }
1659
1660 #[test]
1661 fn is_integer_literal_matches() {
1662 assert!(is_integer_literal("42"));
1663 assert!(is_integer_literal("-1"));
1664 assert!(is_integer_literal("0"));
1665 assert!(!is_integer_literal("0.5"));
1666 assert!(!is_integer_literal("hello"));
1667 assert!(!is_integer_literal(""));
1668 }
1669
1670 #[test]
1676 fn parse_preserves_frontmatter_key_order() {
1677 let md = "\
1678---
1679type: principle
1680universality: domain-wide
1681authority: proposed
1682tags: a, b, c
1683created_date: 2026-01-15
1684last_modified: 2026-04-12
1685---
1686# Key Order
1687";
1688 let result = parse_markdown(
1689 md,
1690 "key-order.md",
1691 &type_by_name(builtin_names::PRINCIPLE).unwrap(),
1692 "knowledge",
1693 )
1694 .unwrap();
1695 let keys: Vec<&str> = result.entity.metadata.keys().map(|s| s.as_str()).collect();
1696 assert_eq!(
1697 keys,
1698 vec![
1699 "type",
1700 "universality",
1701 "authority",
1702 "tags",
1703 "created_date",
1704 "last_modified",
1705 ],
1706 "metadata iteration must preserve frontmatter declaration order"
1707 );
1708 }
1709
1710 #[test]
1718 fn parse_write_roundtrip_preserves_section_order() {
1719 let md = "\
1720---
1721type: spec
1722created_date: 2026-01-15
1723last_modified: 2026-04-12
1724level: M0
1725---
1726# Order Roundtrip
1727
1728## Identity
1729
1730Identity content.
1731
1732## Purpose
1733
1734Purpose content.
1735
1736## Specifies
1737
1738Specifies content.
1739";
1740 let schema = spec_schema();
1741 let first = parse_markdown(md, "order-roundtrip.md", &schema, "specs").unwrap();
1742 let regenerated = crate::entity::generator::generate_markdown(&first.entity, &schema);
1743 let second = parse_markdown(®enerated, "order-roundtrip.md", &schema, "specs").unwrap();
1744
1745 let first_keys: Vec<&String> = first.entity.sections.keys().collect();
1746 let second_keys: Vec<&String> = second.entity.sections.keys().collect();
1747 assert_eq!(
1748 first_keys, second_keys,
1749 "section iteration order must survive parse -> generate -> parse"
1750 );
1751 }
1752
1753 #[test]
1763 fn parser_extracts_single_h3() {
1764 let md = "\
1765---
1766type: spec
1767---
1768# Entity
1769
1770## Identity
1771
1772Body.
1773
1774## Specifies
1775
1776### Response Shapes
1777
1778Content under response shapes.
1779";
1780 let result = parse_markdown(md, "h3-single.md", &spec_schema(), "specs").unwrap();
1781 let spans = result
1782 .entity
1783 .heading_spans
1784 .get("specifies")
1785 .expect("specifies section should have spans");
1786 assert_eq!(spans.len(), 1);
1787 assert_eq!(spans[0].level, 3);
1788 assert_eq!(spans[0].title, "Response Shapes");
1789 assert_eq!(spans[0].start_offset, 0);
1791 let section = result.entity.sections.get("specifies").unwrap();
1792 assert_eq!(spans[0].end_offset, section.len());
1793 assert!(
1795 result
1796 .entity
1797 .heading_spans
1798 .get("identity")
1799 .is_none_or(Vec::is_empty)
1800 );
1801 }
1802
1803 #[test]
1804 fn parser_extracts_nested_h3_h4() {
1805 let md = "\
1806---
1807type: spec
1808---
1809# Entity
1810
1811## Identity
1812
1813Body.
1814
1815## Specifies
1816
1817### Outer
1818
1819Outer body.
1820
1821#### Inner
1822
1823Inner body.
1824";
1825 let result = parse_markdown(md, "h3-h4.md", &spec_schema(), "specs").unwrap();
1826 let spans = result.entity.heading_spans.get("specifies").unwrap();
1827 assert_eq!(spans.len(), 2, "both H3 and H4 must be recorded");
1828 assert_eq!(spans[0].level, 3);
1829 assert_eq!(spans[0].title, "Outer");
1830 assert_eq!(spans[1].level, 4);
1831 assert_eq!(spans[1].title, "Inner");
1832 assert!(
1833 spans[0].start_offset < spans[1].start_offset,
1834 "spans must be in document order"
1835 );
1836 assert!(
1838 spans[0].end_offset > spans[1].start_offset,
1839 "outer H3 must contain inner H4 by offset"
1840 );
1841 }
1842
1843 #[test]
1844 fn parser_ignores_headings_in_code_blocks() {
1845 let md = "\
1846---
1847type: spec
1848---
1849# Entity
1850
1851## Identity
1852
1853Body.
1854
1855## Specifies
1856
1857Prefix.
1858
1859```
1860### Not a heading
1861Still code.
1862```
1863
1864Suffix.
1865";
1866 let result = parse_markdown(md, "h3-code.md", &spec_schema(), "specs").unwrap();
1867 let spans = result
1868 .entity
1869 .heading_spans
1870 .get("specifies")
1871 .cloned()
1872 .unwrap_or_default();
1873 assert!(
1874 spans.is_empty(),
1875 "a '### ' inside a fenced block must not register as a heading span: {spans:?}"
1876 );
1877 }
1878
1879 #[test]
1880 fn parser_handles_level_skip() {
1881 let md = "\
1882---
1883type: spec
1884---
1885# Entity
1886
1887## Identity
1888
1889Body.
1890
1891## Specifies
1892
1893#### Skipped To H4
1894
1895Content under a sudden H4 — no virtual H3 is inserted.
1896";
1897 let result = parse_markdown(md, "h2-h4.md", &spec_schema(), "specs").unwrap();
1898 let spans = result.entity.heading_spans.get("specifies").unwrap();
1899 assert_eq!(spans.len(), 1);
1900 assert_eq!(spans[0].level, 4);
1901 assert_eq!(spans[0].title, "Skipped To H4");
1902 }
1903
1904 #[test]
1905 fn parser_handles_duplicate_siblings() {
1906 let md = "\
1907---
1908type: spec
1909---
1910# Entity
1911
1912## Identity
1913
1914Body.
1915
1916## Specifies
1917
1918### Same Title
1919
1920First occurrence body.
1921
1922### Same Title
1923
1924Second occurrence body.
1925";
1926 let result = parse_markdown(md, "h3-dup.md", &spec_schema(), "specs").unwrap();
1927 let spans = result.entity.heading_spans.get("specifies").unwrap();
1928 assert_eq!(spans.len(), 2, "duplicate siblings must produce two spans");
1929 assert_eq!(spans[0].title, spans[1].title);
1930 assert_ne!(
1931 spans[0].start_offset, spans[1].start_offset,
1932 "spans with identical titles must be distinguishable by offset"
1933 );
1934 assert!(
1936 spans[0].end_offset <= spans[1].start_offset,
1937 "first sibling must close before the second starts"
1938 );
1939 }
1940
1941 #[test]
1946 fn duplicate_declared_heading_two_populated_keeps_first_warns() {
1947 let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nfirst body\n\n## Identity\n\nsecond body\n";
1948 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1949 assert_eq!(
1950 result.entity.sections.get("identity").map(String::as_str),
1951 Some("first body"),
1952 "first body must win"
1953 );
1954 assert!(
1955 !result
1956 .entity
1957 .sections
1958 .get("identity")
1959 .unwrap()
1960 .contains("## Identity"),
1961 "storage value must not embed a duplicate heading"
1962 );
1963 assert_eq!(result.parse_warnings.len(), 1);
1964 match &result.parse_warnings[0] {
1965 crate::ops::WarningHint::DuplicateSectionHeading {
1966 section_key,
1967 heading,
1968 occurrences,
1969 ..
1970 } => {
1971 assert_eq!(section_key, "identity");
1972 assert_eq!(heading, "Identity");
1973 assert_eq!(*occurrences, 2);
1974 }
1975 other => panic!("expected DuplicateSectionHeading, got {other:?}"),
1976 }
1977 }
1978
1979 #[test]
1980 fn duplicate_declared_heading_blank_then_populated_keeps_blank() {
1981 let md =
1985 "---\ntype: spec\n---\n# Title\n\n## Identity\n\n## Identity\n\nleftover content\n";
1986 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1987 assert_eq!(
1988 result.entity.sections.get("identity").map(String::as_str),
1989 Some(""),
1990 "first (blank) occurrence wins; second body is dropped"
1991 );
1992 assert_eq!(result.parse_warnings.len(), 1);
1993 }
1994
1995 #[test]
1996 fn duplicate_declared_heading_three_occurrences() {
1997 let md = "---\ntype: spec\n---\n# Title\n\n## Constraints\n\nA\n\n## Constraints\n\n## Constraints\n\nC\n";
1998 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1999 assert_eq!(
2000 result
2001 .entity
2002 .sections
2003 .get("constraints")
2004 .map(String::as_str),
2005 Some("A"),
2006 );
2007 assert_eq!(result.parse_warnings.len(), 1);
2008 match &result.parse_warnings[0] {
2009 crate::ops::WarningHint::DuplicateSectionHeading { occurrences, .. } => {
2010 assert_eq!(*occurrences, 3);
2011 }
2012 _ => unreachable!(),
2013 }
2014 }
2015
2016 #[test]
2017 fn no_warning_when_each_declared_section_appears_once() {
2018 let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nID\n\n## Purpose\n\nP\n\n## Constraints\n\nC\n";
2019 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
2020 assert!(result.parse_warnings.is_empty());
2021 }
2022
2023 #[test]
2024 fn no_warning_when_catch_all_section_repeats() {
2025 let md =
2028 "---\ntype: spec\n---\n# Title\n\n## Specifies\n\nfirst\n\n## Specifies\n\nsecond\n";
2029 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
2030 assert!(
2031 result.parse_warnings.is_empty(),
2032 "catch-all repetition must not warn"
2033 );
2034 }
2035
2036 #[test]
2043 fn duplicate_realization_does_not_concatenate_headers_in_storage() {
2044 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";
2045 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
2046 let catch_all = result.entity.sections.get("specifies").unwrap();
2047 let header_count = catch_all.matches("## Realization").count();
2048 assert!(
2049 header_count <= 1,
2050 "catch-all bucket must not contain multiple `## Realization` headers — got {header_count}: {catch_all:?}"
2051 );
2052 }
2053
2054 #[test]
2060 fn parse_render_round_trip_collapses_duplicate_headings() {
2061 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";
2062 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
2063 let rendered = crate::render::render_entity_markdown(&result.entity, None);
2064 let identity_count = rendered.matches("## Identity").count();
2065 assert_eq!(
2066 identity_count, 1,
2067 "rendered output must carry exactly one `## Identity`, got {identity_count}: {rendered}"
2068 );
2069 assert!(rendered.contains("\n## Identity\n\nA\n"));
2071 assert!(!rendered.contains("C\n"), "second body must not survive");
2072 }
2073}
2074
2075#[cfg(test)]
2081mod commonmark_referee {
2082 use super::*;
2083 use memstead_schema::{builtin_names, type_by_name};
2084 use std::sync::Arc;
2085
2086 fn spec_schema() -> Arc<TypeDefinition> {
2087 type_by_name(builtin_names::SPEC).unwrap()
2088 }
2089
2090 fn entity_with_specifies(body: &str) -> ParseResult {
2092 let md = format!(
2093 "---\ntype: spec\n---\n\n# Referee Test\n\n## Identity\n\nx\n\n## Specifies\n\n{body}\n"
2094 );
2095 parse_markdown(&md, "referee-test.md", &spec_schema(), "specs").unwrap()
2096 }
2097
2098 fn headings(result: &ParseResult) -> Vec<&str> {
2099 result
2100 .entity
2101 .raw_section_headings
2102 .iter()
2103 .map(String::as_str)
2104 .collect()
2105 }
2106
2107 fn link_targets(result: &ParseResult) -> Vec<String> {
2108 result.inline_links.iter().map(|id| id.0.clone()).collect()
2109 }
2110
2111 #[test]
2114 fn complement_prose_headings_and_links_still_work() {
2115 let r = entity_with_specifies("See [[real-target]] here.");
2116 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2117 assert_eq!(link_targets(&r), vec!["specs--real-target".to_string()]);
2118 assert_eq!(r.entity.title, "Referee Test");
2119 }
2120
2121 #[test]
2122 fn class_1_indented_code_block() {
2123 let r = entity_with_specifies("Example:\n\n ## Not A Section\n [[not-a-link]]\n");
2124 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2125 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2126 }
2127
2128 #[test]
2129 fn class_2_fence_indented_one_to_three_spaces() {
2130 let r = entity_with_specifies(
2131 "- item\n\n ```\n ## Not A Section\n [[not-a-link]]\n ```\n",
2132 );
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_3_tilde_fence() {
2139 let r = entity_with_specifies("~~~\n## Not A Section\n[[not-a-link]]\n~~~\n");
2140 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2141 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2142 }
2143
2144 #[test]
2145 fn class_4_info_string_on_the_closing_line() {
2146 let r = entity_with_specifies(
2147 "```\ncode\n``` still-code\n## Not A Section\n[[not-a-link]]\n```\n",
2148 );
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_5_fence_inside_a_blockquote() {
2155 let r = entity_with_specifies("> ```\n> ## Not A Section\n> [[not-a-link]]\n> ```\n");
2156 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2157 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2158 }
2159
2160 #[test]
2161 fn class_6_opening_fence_length_is_honoured_on_close() {
2162 let r = entity_with_specifies("````\n```\n## Not A Section\n[[not-a-link]]\n```\n````\n");
2163 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2164 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2165 }
2166
2167 #[test]
2169 fn a_heading_inside_a_code_block_never_becomes_the_title() {
2170 let md =
2171 "---\ntype: spec\n---\n\n```\n# Fake Title\n```\n\n# Real Title\n\n## Identity\n\nx\n";
2172 let r = parse_markdown(md, "title-test.md", &spec_schema(), "specs").unwrap();
2173 assert_eq!(r.entity.title, "Real Title");
2174 }
2175
2176 #[test]
2179 fn a_code_block_only_body_falls_back_to_the_filename() {
2180 let md = "---\ntype: spec\n---\n\n # Fake Title\n\n## Identity\n\nx\n";
2181 let r = parse_markdown(md, "fallback-test.md", &spec_schema(), "specs").unwrap();
2182 assert_eq!(r.entity.title, "fallback-test");
2183 }
2184
2185 #[test]
2186 fn heading_spans_ignore_code_block_content() {
2187 let r = entity_with_specifies("### Real Sub\n\n~~~\n### Fake Sub\n~~~\n");
2188 let spans = r.entity.heading_spans.get("specifies").expect("spans");
2189 let titles: Vec<&str> = spans.iter().map(|s| s.title.as_str()).collect();
2190 assert_eq!(titles, vec!["Real Sub"]);
2191 }
2192
2193 #[test]
2198 fn inline_code_spans_hide_links_on_the_extraction_path() {
2199 let r = entity_with_specifies("`[[hidden-one]]` and ``[[hidden-two]]`` but [[visible]].");
2200 assert_eq!(link_targets(&r), vec!["specs--visible".to_string()]);
2201 }
2202
2203 #[test]
2207 fn empty_wiki_link_target_is_refused_by_the_strict_extractor() {
2208 let errors = extract_inline_links("an empty [[]] link", "specs")
2209 .expect_err("empty target must refuse");
2210 assert_eq!(errors.len(), 1, "{errors:?}");
2211 }
2212
2213 #[test]
2216 fn empty_wiki_link_target_yields_no_id_on_the_lenient_path() {
2217 assert!(extract_inline_links_lenient("an empty [[]] link", "specs").is_empty());
2218 }
2219
2220 #[test]
2226 fn merge_conflict_markers_are_seen_through_fence_shaped_frontmatter() {
2227 let body = "\n# T\n\n## Identity\n\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n";
2228 for fm in [
2229 "---\ntype: spec\n---",
2230 "---\ntype: spec\nnotes: |\n ```rust\n fn x() {}\n---",
2231 "---\ntype: spec\nnotes: |\n ~~~\n---",
2232 "---\ntype: spec\nnotes: |\n indented block\n---",
2233 ] {
2234 assert!(
2235 has_merge_conflict_markers(&format!("{fm}{body}")),
2236 "conflict markers must be seen through frontmatter: {fm:?}"
2237 );
2238 }
2239 }
2240
2241 #[test]
2244 fn merge_conflict_markers_in_frontmatter_are_seen() {
2245 let content =
2246 "---\n<<<<<<< HEAD\ntype: spec\n=======\ntype: memo\n>>>>>>> branch\n---\n\n# T\n";
2247 assert!(has_merge_conflict_markers(content));
2248 }
2249
2250 #[test]
2253 fn a_fenced_conflict_marker_example_still_does_not_trip_the_guard() {
2254 let content = "---\ntype: spec\n---\n\n# T\n\n## Identity\n\n```\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n```\n";
2255 assert!(!has_merge_conflict_markers(content));
2256 }
2257
2258 #[test]
2264 fn a_relationship_row_inside_a_code_block_is_not_a_relationship() {
2265 for body in [
2266 "```\n- **REFERENCES**: [[ghost]]\n```",
2267 "~~~\n- **REFERENCES**: [[ghost]]\n~~~",
2268 " - **REFERENCES**: [[ghost]]",
2269 "> ```\n> - **REFERENCES**: [[ghost]]\n> ```",
2270 "````\n```\n- **REFERENCES**: [[ghost]]\n```\n````",
2271 ] {
2272 let (rels, _) = parse_relationships_with_warnings(body, "specs", None);
2273 assert!(
2274 rels.is_empty(),
2275 "code-block row must not become an edge: {body:?} -> {rels:?}"
2276 );
2277 }
2278 }
2279
2280 #[test]
2287 fn a_relationship_row_inside_an_inline_code_span_is_not_a_relationship() {
2288 for body in [
2289 "Example `open\n - **REFERENCES**: [[ghost]]\nclose`",
2295 "A `- **REFERENCES**: [[ghost]]` sample.",
2296 "A ``- **REFERENCES**: [[ghost]]`` sample.",
2297 ] {
2298 let (rels, _) = parse_relationships_with_warnings(body, "specs", None);
2299 assert!(
2300 rels.is_empty(),
2301 "code-span row must not become an edge: {body:?} -> {rels:?}"
2302 );
2303 }
2304 }
2305
2306 #[test]
2310 fn real_relationship_rows_are_unchanged_by_the_mask() {
2311 let body = "- **REFERENCES**: [[alpha]]\n- **uses**: [[beta]] — because it must\n\n```\n- **REFERENCES**: [[ghost]]\n```\n";
2312 let (rels, _) = parse_relationships_with_warnings(body, "specs", None);
2313 assert_eq!(rels.len(), 2, "{rels:?}");
2314 assert_eq!(rels[0].rel_type, "REFERENCES");
2315 assert_eq!(rels[0].target.0, "specs--alpha");
2316 assert_eq!(rels[0].description, None);
2317 assert_eq!(
2318 rels[1].rel_type, "USES",
2319 "case is normalised from the original"
2320 );
2321 assert_eq!(rels[1].target.0, "specs--beta");
2322 assert_eq!(rels[1].description.as_deref(), Some("because it must"));
2323 }
2324
2325 #[test]
2326 fn ambiguous_delimiter_warning_still_fires_on_a_real_row() {
2327 let id = file_path_to_id("x.md", "specs");
2328 let (_, warnings) = parse_relationships_with_warnings(
2329 "- **REFERENCES**: [[alpha]] -- not an em dash\n",
2330 "specs",
2331 Some(&id),
2332 );
2333 assert_eq!(warnings.len(), 1, "{warnings:?}");
2334 }
2335
2336 #[test]
2344 fn frontmatter_never_opens_a_code_block_over_the_body() {
2345 for fm in [
2346 "notes: |\n ```rust",
2347 "notes: |\n ~~~",
2348 "notes: |\n ```\n still open",
2349 "notes: |\n indented block\n",
2350 ] {
2351 let md = format!(
2352 "---\ntype: spec\n{fm}\n---\n\n# Real Title\n\n## Identity\n\nSee [[a-link]].\n"
2353 );
2354 let r = parse_markdown(&md, "fm-test.md", &spec_schema(), "specs").unwrap();
2355 assert_eq!(
2356 r.entity.title, "Real Title",
2357 "frontmatter ate the title: {fm:?}"
2358 );
2359 assert_eq!(
2360 headings(&r),
2361 vec!["Identity"],
2362 "frontmatter ate the sections: {fm:?}"
2363 );
2364 assert_eq!(
2365 link_targets(&r),
2366 vec!["specs--a-link".to_string()],
2367 "frontmatter ate the links: {fm:?}"
2368 );
2369 }
2370 }
2371}