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
190pub fn peek_type_from_frontmatter(content: &str) -> Option<String> {
200 let content = strip_bom(content);
201 let after_open = if content.starts_with("---\r\n") {
202 5
203 } else if content.starts_with("---\n") {
204 4
205 } else {
206 return None;
207 };
208
209 let close_pos = content[after_open..].find("\n---")?;
210 let frontmatter = &content[after_open..after_open + close_pos];
211
212 for line in frontmatter.lines() {
213 let trimmed = line.trim();
214 if trimmed.is_empty() || trimmed.starts_with('#') {
215 continue;
216 }
217 let Some(colon_idx) = trimmed.find(':') else {
218 continue;
219 };
220 let key = trimmed[..colon_idx].trim();
221 if key != "type" {
222 continue;
223 }
224 let mut value = trimmed[colon_idx + 1..].trim();
225 if let Some(hash_idx) = value.find('#') {
226 value = value[..hash_idx].trim();
227 }
228 let value = value.trim_matches(|c| c == '"' || c == '\'');
229 if value.is_empty() {
230 return None;
231 }
232 return Some(value.to_string());
233 }
234 None
235}
236
237pub fn peek_title_and_type(content: &str) -> (Option<String>, Option<String>) {
246 let entity_type = peek_type_from_frontmatter(content);
247 let body = body_after_frontmatter(content);
248 let title = extract_title(body, &mask_code_blocks(body));
249 (title, entity_type)
250}
251
252pub fn body_after_frontmatter(content: &str) -> &str {
268 let content = strip_bom(content);
269 let after_open = if content.starts_with("---\r\n") {
270 5
271 } else if content.starts_with("---\n") {
272 4
273 } else {
274 return content;
275 };
276 let Some(close_pos) = content[after_open..].find("\n---") else {
277 return content;
278 };
279 let body_start = after_open + close_pos + 4; let rest = &content[body_start..];
281 rest.strip_prefix("\r\n")
282 .or_else(|| rest.strip_prefix('\n'))
283 .unwrap_or(rest)
284}
285
286fn strip_bom(s: &str) -> &str {
292 s.strip_prefix('\u{feff}').unwrap_or(s)
293}
294
295pub(crate) fn split_frontmatter(content: &str) -> Result<(String, String), ParseError> {
299 let content = strip_bom(content);
300 if content.starts_with("---\n") || content.starts_with("---\r\n") {
302 let after_open = if content.starts_with("---\r\n") { 5 } else { 4 };
303 if let Some(close_pos) = content[after_open..].find("\n---") {
305 let meta_end = after_open + close_pos;
306 let metadata = content[after_open..meta_end].to_string();
307 let body_start = meta_end + 4; let body_start = if content[body_start..].starts_with('\n') {
310 body_start + 1
311 } else if content[body_start..].starts_with("\r\n") {
312 body_start + 2
313 } else {
314 body_start
315 };
316 let body = content[body_start..].to_string();
317 return Ok((metadata, body));
318 }
319 }
320
321 Ok((String::new(), content.to_string()))
323}
324
325fn parse_metadata(text: &str) -> IndexMap<String, MetadataValue> {
330 let mut meta = IndexMap::new();
331 if text.is_empty() {
332 return meta;
333 }
334
335 for line in text.lines() {
336 let trimmed = line.trim();
337 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("---") {
339 continue;
340 }
341
342 let Some(colon_idx) = trimmed.find(':') else {
343 continue;
344 };
345
346 let key = trimmed[..colon_idx].trim().to_string();
347 let raw_value = trimmed[colon_idx + 1..].trim();
348
349 let value = strip_inline_comment(raw_value).trim().to_string();
351
352 if value.is_empty() {
353 meta.insert(key, MetadataValue::String(String::new()));
354 continue;
355 }
356
357 if value == "true" {
359 meta.insert(key, MetadataValue::Bool(true));
360 } else if value == "false" {
361 meta.insert(key, MetadataValue::Bool(false));
362 } else if is_float_literal(&value) {
363 if let Ok(f) = value.parse::<f64>() {
364 meta.insert(key, MetadataValue::Float(f));
365 } else {
366 meta.insert(key, MetadataValue::String(strip_quotes(&value)));
367 }
368 } else if is_integer_literal(&value) {
369 if let Ok(n) = value.parse::<i64>() {
370 meta.insert(key, MetadataValue::Integer(n));
371 } else {
372 meta.insert(key, MetadataValue::String(strip_quotes(&value)));
373 }
374 } else {
375 meta.insert(key, MetadataValue::String(strip_quotes(&value)));
376 }
377 }
378
379 meta
380}
381
382fn is_float_literal(s: &str) -> bool {
384 let s = s.strip_prefix('-').unwrap_or(s);
385 if let Some((before, after)) = s.split_once('.') {
386 !before.is_empty()
387 && before.chars().all(|c| c.is_ascii_digit())
388 && !after.is_empty()
389 && after.chars().all(|c| c.is_ascii_digit())
390 } else {
391 false
392 }
393}
394
395fn is_integer_literal(s: &str) -> bool {
397 let s = s.strip_prefix('-').unwrap_or(s);
398 !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
399}
400
401pub(crate) fn would_coerce_from_string(s: &str) -> bool {
407 s == "true" || s == "false" || is_integer_literal(s) || is_float_literal(s)
408}
409
410fn strip_inline_comment(s: &str) -> &str {
412 if let Some(idx) = s.find(" #") {
415 s[..idx].trim_end()
416 } else {
417 s
418 }
419}
420
421fn strip_quotes(s: &str) -> String {
425 if s.len() >= 2
426 && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
427 {
428 s[1..s.len() - 1].to_string()
429 } else {
430 s.to_string()
431 }
432}
433
434pub use crate::markdown::{mask_code_blocks, mask_code_blocks_and_spans};
446
447pub fn has_merge_conflict_markers(text: &str) -> bool {
477 let body = body_after_frontmatter(text);
483 let frontmatter = &text[..text.len() - body.len()];
484 let view = format!("{frontmatter}{}", mask_code_blocks(body));
485
486 let mut seen_start = false;
487 let mut seen_separator = false;
488 for line in view.lines() {
489 if line.starts_with("<<<<<<< ") {
490 seen_start = true;
491 seen_separator = false;
492 } else if seen_start && line.trim_end() == "=======" {
493 seen_separator = true;
494 } else if seen_separator && line.starts_with(">>>>>>> ") {
495 return true;
496 }
497 }
498 false
499}
500
501pub(crate) type SplitSections = IndexMap<String, (String, String)>;
511
512pub(crate) struct DuplicateSection {
515 pub key: String,
516 pub heading: String,
517 pub occurrences: usize,
518}
519
520pub(crate) fn split_sections(
538 body: &str,
539 masked_body: &str,
540) -> (SplitSections, Vec<DuplicateSection>, Vec<String>) {
541 let mut sections = IndexMap::new();
547 let mut duplicates: HashMap<String, DuplicateSection> = HashMap::new();
548 let mut raw_headings = Vec::new();
549 static SECTION_RE: OnceLock<Regex> = OnceLock::new();
550 let section_re = SECTION_RE.get_or_init(|| Regex::new(r"(?m)^## (.+)$").unwrap());
551
552 let matches: Vec<_> = section_re.find_iter(masked_body).collect();
553
554 for (i, m) in matches.iter().enumerate() {
555 let heading_line = &body[m.start()..m.end()];
557 let name = heading_line
558 .strip_prefix("## ")
559 .unwrap_or(heading_line)
560 .trim();
561
562 let content_start = m.end();
563 let content_end = if i + 1 < matches.len() {
564 matches[i + 1].start()
565 } else {
566 body.len()
567 };
568 let raw = &body[content_start..content_end];
579 let visible_start = raw
580 .split_inclusive('\n')
581 .take_while(|line| line.trim().is_empty())
582 .map(str::len)
583 .sum::<usize>();
584 let content = raw[visible_start..].trim_end().to_string();
585 let key = memstead_schema::derive_section_key(name);
593 raw_headings.push(name.to_string());
594
595 match sections.entry(key.clone()) {
596 indexmap::map::Entry::Vacant(slot) => {
597 slot.insert((heading_line.to_string(), content));
598 duplicates.insert(
599 key.clone(),
600 DuplicateSection {
601 key: key.clone(),
602 heading: name.to_string(),
603 occurrences: 1,
604 },
605 );
606 }
607 indexmap::map::Entry::Occupied(_) => {
608 if let Some(d) = duplicates.get_mut(&key) {
611 d.occurrences += 1;
612 }
613 }
614 }
615 }
616
617 let dup_list: Vec<DuplicateSection> = duplicates
618 .into_values()
619 .filter(|d| d.occurrences > 1)
620 .collect();
621
622 (sections, dup_list, raw_headings)
623}
624
625fn extract_title(body: &str, masked_body: &str) -> Option<String> {
632 for (line, masked) in body.lines().zip(masked_body.lines()) {
633 if masked.starts_with("# ") {
634 return Some(line[2..].trim().to_string());
635 }
636 }
637 None
638}
639
640fn extract_heading_spans(sections: &IndexMap<String, String>) -> HashMap<String, Vec<HeadingSpan>> {
655 static RE: OnceLock<Regex> = OnceLock::new();
657 let re = RE.get_or_init(|| Regex::new(r"(?m)^(#{3,6})[ \t]+(.+)$").unwrap());
658 let mut out: HashMap<String, Vec<HeadingSpan>> = HashMap::new();
659
660 for (key, content) in sections {
661 if content.is_empty() {
662 continue;
663 }
664 let masked = mask_code_blocks(content);
665
666 let raw: Vec<(usize, u8, String)> = re
668 .captures_iter(&masked)
669 .map(|cap| {
670 let whole = cap.get(0).unwrap();
671 let level = cap[1].len() as u8; let line_end = content[whole.start()..]
675 .find('\n')
676 .map(|i| whole.start() + i)
677 .unwrap_or(content.len());
678 let hashes_end = whole.start() + level as usize;
679 let title = content[hashes_end..line_end].trim().to_string();
680 (whole.start(), level, title)
681 })
682 .collect();
683
684 if raw.is_empty() {
685 continue;
686 }
687
688 let mut spans: Vec<HeadingSpan> = Vec::with_capacity(raw.len());
689 for (i, &(start, level, ref title)) in raw.iter().enumerate() {
690 let end = raw[i + 1..]
692 .iter()
693 .find(|(_, l, _)| *l <= level)
694 .map(|(s, _, _)| *s)
695 .unwrap_or(content.len());
696 spans.push(HeadingSpan {
697 level,
698 title: title.clone(),
699 start_offset: start,
700 end_offset: end,
701 });
702 }
703 out.insert(key.clone(), spans);
704 }
705
706 out
707}
708
709fn build_catch_all(sections: &SplitSections, schema: &TypeDefinition) -> String {
715 let catch_all = match schema.catch_all_section() {
716 Some(s) => s,
717 None => return String::new(),
718 };
719
720 let known_sections: HashSet<&str> = schema
721 .sections
722 .iter()
723 .map(|s| s.key.as_str())
724 .chain(std::iter::once("relationships"))
725 .collect();
726
727 let mut parts = Vec::new();
728
729 if let Some((_, content)) = sections.get(catch_all.key.as_str())
731 && !content.is_empty()
732 {
733 parts.push(content.clone());
734 }
735
736 for (key, (heading_line, content)) in sections {
748 if !known_sections.contains(key.as_str()) && !content.is_empty() {
749 parts.push(format!("{heading_line}\n{content}"));
750 }
751 }
752
753 let mut joined = String::new();
769 for piece in parts {
770 if joined.is_empty() {
771 joined = piece;
772 } else {
773 joined.push_str("\n\n");
774 joined.push_str(&piece);
775 }
776 if let Some(closer) = crate::markdown::closing_fence_if_unterminated(&joined) {
777 joined.push('\n');
778 joined.push_str(&closer);
779 }
780 }
781 joined
782}
783
784pub(crate) fn parse_relationships_with_warnings(
808 text: &str,
809 mem: &str,
810 entity_id: Option<&EntityId>,
811) -> (Vec<Relationship>, Vec<crate::ops::WarningHint>) {
812 static RE: OnceLock<Regex> = OnceLock::new();
826 let re = RE.get_or_init(|| {
827 Regex::new(r"(?m)^\s*-\s*\*\*(\w+)\*\*:\s*\[\[([^\]\n]+)\]\](?P<tail>[^\n]*)").unwrap()
828 });
829 let mut relationships = Vec::new();
830 let mut warnings = Vec::new();
831 let masked = mask_code_blocks_and_spans(text);
839 for cap in re.captures_iter(&masked) {
840 let rel_type = text[cap.get(1).unwrap().range()].to_uppercase();
841 let target = wiki_link_to_id_lenient(&text[cap.get(2).unwrap().range()], mem);
847 if target.path().is_empty() {
856 continue;
857 }
858 let tail = cap.name("tail").map(|m| &text[m.range()]).unwrap_or("");
859 let description = match classify_description_tail(tail) {
860 DescriptionTail::None => None,
861 DescriptionTail::EmDash(text) => Some(text),
862 DescriptionTail::Ambiguous(literal) => {
863 if let Some(id) = entity_id {
864 warnings.push(crate::ops::WarningHint::AmbiguousDescriptionDelimiter {
865 from: id.clone(),
866 rel_type: rel_type.clone(),
867 target: target.clone(),
868 trailing: literal,
869 });
870 }
871 None
872 }
873 };
874 relationships.push(Relationship {
875 rel_type,
876 target,
877 description,
878 });
879 }
880 (relationships, warnings)
881}
882
883enum DescriptionTail {
886 None,
888 EmDash(String),
891 Ambiguous(String),
895}
896
897fn classify_description_tail(tail: &str) -> DescriptionTail {
903 let trimmed_end = tail.trim_end();
904 if trimmed_end.is_empty() {
905 return DescriptionTail::None;
906 }
907 if let Some(rest) = trimmed_end.strip_prefix(" \u{2014} ") {
909 if rest.is_empty() {
910 return DescriptionTail::None;
911 }
912 return DescriptionTail::EmDash(rest.to_string());
913 }
914 if let Some(rest) = trimmed_end.strip_prefix(" \u{2014}") {
918 return DescriptionTail::Ambiguous(format!(" \u{2014}{rest}"));
920 }
921 let starters = [" --", " -", " \u{2013}", " \u{2212}"];
923 if starters
924 .iter()
925 .any(|prefix| trimmed_end.starts_with(prefix))
926 {
927 return DescriptionTail::Ambiguous(trimmed_end.to_string());
928 }
929 DescriptionTail::Ambiguous(trimmed_end.to_string())
933}
934
935fn wiki_link_re() -> &'static Regex {
947 static RE: OnceLock<Regex> = OnceLock::new();
948 RE.get_or_init(|| Regex::new(r"\[\[([^\]]*)\]\]").unwrap())
949}
950
951pub(crate) fn extract_inline_links(
965 text: &str,
966 mem: &str,
967) -> Result<Vec<EntityId>, Vec<WikiLinkError>> {
968 let stripped = mask_code_blocks_and_spans(text);
969
970 let link_re = wiki_link_re();
971 let mut seen = HashSet::new();
972 let mut links = Vec::new();
973 let mut errors = Vec::new();
974
975 for cap in link_re.captures_iter(&stripped) {
976 match wiki_link_to_id(&cap[1], mem) {
977 Ok(id) => {
978 if errors.is_empty() && seen.insert(id.0.clone()) {
979 links.push(id);
980 }
981 }
982 Err(e) => errors.push(e),
983 }
984 }
985
986 if errors.is_empty() {
987 Ok(links)
988 } else {
989 Err(errors)
990 }
991}
992
993pub fn extract_inline_links_lenient(text: &str, mem: &str) -> Vec<EntityId> {
1000 let stripped = mask_code_blocks_and_spans(text);
1001
1002 let link_re = wiki_link_re();
1003 let mut seen = HashSet::new();
1004 let mut links = Vec::new();
1005
1006 for cap in link_re.captures_iter(&stripped) {
1007 if cap[1].is_empty() {
1012 continue;
1013 }
1014 let id = wiki_link_to_id_lenient(&cap[1], mem);
1015 if seen.insert(id.0.clone()) {
1016 links.push(id);
1017 }
1018 }
1019
1020 links
1021}
1022
1023pub fn compute_hash(content: &str) -> String {
1029 let mut hasher = Sha256::new();
1030 hasher.update(content.as_bytes());
1031 let result = hasher.finalize();
1032 crate::hex_lower(&result)[..16].to_string()
1033}
1034
1035#[derive(Debug, thiserror::Error)]
1040pub enum ParseError {
1041 #[error("missing frontmatter")]
1042 MissingFrontmatter,
1043 #[error("invalid frontmatter: {0}")]
1044 InvalidFrontmatter(String),
1045 #[error("missing title")]
1046 MissingTitle,
1047 #[error("io error: {0}")]
1048 Io(#[from] std::io::Error),
1049}
1050
1051#[cfg(test)]
1052mod tests {
1053 use super::*;
1054 use memstead_schema::{builtin_names, type_by_name};
1055 use std::sync::Arc;
1056
1057 fn spec_schema() -> Arc<TypeDefinition> {
1058 type_by_name(builtin_names::SPEC).unwrap()
1059 }
1060
1061 fn memo_schema() -> Arc<TypeDefinition> {
1062 type_by_name(builtin_names::MEMO).unwrap()
1063 }
1064
1065 #[test]
1066 fn parse_metadata_types() {
1067 let meta = parse_metadata("key: value\nnum: 42\nfloat: 0.85\nbool: true\nfalsy: false");
1068 assert_eq!(meta["key"], MetadataValue::String("value".to_string()));
1069 assert_eq!(meta["num"], MetadataValue::Integer(42));
1070 assert_eq!(meta["float"], MetadataValue::Float(0.85));
1071 assert_eq!(meta["bool"], MetadataValue::Bool(true));
1072 assert_eq!(meta["falsy"], MetadataValue::Bool(false));
1073 }
1074
1075 #[test]
1076 fn parse_metadata_strips_comments() {
1077 let meta = parse_metadata("key: value # this is a comment");
1078 assert_eq!(meta["key"], MetadataValue::String("value".to_string()));
1079 }
1080
1081 #[test]
1082 fn parse_metadata_strips_quotes() {
1083 let meta = parse_metadata("key: \"quoted value\"\nkey2: 'single'");
1084 assert_eq!(
1085 meta["key"],
1086 MetadataValue::String("quoted value".to_string())
1087 );
1088 assert_eq!(meta["key2"], MetadataValue::String("single".to_string()));
1089 }
1090
1091 #[test]
1092 fn parse_metadata_survives_malformed_values() {
1093 let meta = parse_metadata(
1096 "key: \"\nkey2: '\nkey3: \"\"\nkey4: ''\nkey5: \"unterminated\nkey6: mixed'\"",
1097 );
1098 assert_eq!(meta["key"], MetadataValue::String("\"".to_string()));
1099 assert_eq!(meta["key2"], MetadataValue::String("'".to_string()));
1100 assert_eq!(meta["key3"], MetadataValue::String(String::new()));
1101 assert_eq!(meta["key4"], MetadataValue::String(String::new()));
1102 assert_eq!(
1103 meta["key5"],
1104 MetadataValue::String("\"unterminated".to_string())
1105 );
1106 assert_eq!(meta["key6"], MetadataValue::String("mixed'\"".to_string()));
1107
1108 let meta =
1111 parse_metadata(":\n: value\nkey7: ✓\"\nkey8: 99999999999999999999999999\nkey9: -");
1112 assert_eq!(meta["key7"], MetadataValue::String("✓\"".to_string()));
1113 assert_eq!(
1114 meta["key8"],
1115 MetadataValue::String("99999999999999999999999999".to_string())
1116 );
1117 assert_eq!(meta["key9"], MetadataValue::String("-".to_string()));
1118 }
1119
1120 #[test]
1121 fn parse_metadata_skips_comments_and_empty() {
1122 let meta = parse_metadata("# comment\n\nkey: val\n---");
1123 assert_eq!(meta.len(), 1);
1124 assert_eq!(meta["key"], MetadataValue::String("val".to_string()));
1125 }
1126
1127 #[test]
1128 fn peek_type_finds_value() {
1129 let content = "---\ntype: memo\ntitle: Test\n---\n# Body\n";
1130 assert_eq!(
1131 peek_type_from_frontmatter(content),
1132 Some("memo".to_string())
1133 );
1134 }
1135
1136 #[test]
1137 fn peek_type_returns_none_when_missing() {
1138 let content = "---\ntitle: Test\n---\n# Body\n";
1139 assert_eq!(peek_type_from_frontmatter(content), None);
1140 }
1141
1142 #[test]
1143 fn peek_type_returns_none_without_frontmatter() {
1144 let content = "# Just a heading\n\nBody with type: concept inside text.\n";
1145 assert_eq!(peek_type_from_frontmatter(content), None);
1146 }
1147
1148 #[test]
1149 fn peek_type_handles_windows_line_endings() {
1150 let content = "---\r\ntype: principle\r\n---\r\n# Body\r\n";
1151 assert_eq!(
1152 peek_type_from_frontmatter(content),
1153 Some("principle".to_string())
1154 );
1155 }
1156
1157 #[test]
1158 fn peek_type_strips_quotes_and_comments() {
1159 let quoted = "---\ntype: \"concept\"\n---\n";
1160 assert_eq!(
1161 peek_type_from_frontmatter(quoted),
1162 Some("concept".to_string())
1163 );
1164 let commented = "---\ntype: memo # kind of\n---\n";
1165 assert_eq!(
1166 peek_type_from_frontmatter(commented),
1167 Some("memo".to_string())
1168 );
1169 }
1170
1171 #[test]
1172 fn peek_type_empty_value_returns_none() {
1173 let content = "---\ntype:\n---\n";
1174 assert_eq!(peek_type_from_frontmatter(content), None);
1175 }
1176
1177 #[test]
1178 fn peek_type_ignores_legacy_schema_key() {
1179 let content = concat!("---\n", "schema", ": memo\n---\n");
1182 assert_eq!(peek_type_from_frontmatter(content), None);
1183 }
1184
1185 #[test]
1186 fn mask_code_blocks_basic() {
1187 let input = "before\n```\ncode [[link]]\n```\nafter";
1188 let masked = mask_code_blocks(input);
1189 assert!(!masked.contains("[[link]]"));
1190 assert!(masked.contains("before"));
1191 assert!(masked.contains("after"));
1192 }
1193
1194 #[test]
1195 fn mask_code_blocks_preserves_line_count() {
1196 let input = "line1\n```\ncode\nmore code\n```\nline6";
1197 let masked = mask_code_blocks(input);
1198 assert_eq!(input.lines().count(), masked.lines().count());
1199 }
1200
1201 #[test]
1202 fn mask_code_blocks_unclosed() {
1203 let input = "before\n```\ncode\nmore code";
1204 let masked = mask_code_blocks(input);
1205 assert!(masked.contains("before"));
1206 assert!(!masked.contains("code"));
1207 }
1208
1209 #[test]
1210 fn parse_relationships_basic() {
1211 let text = "- **USES**: [[target-entity]]\n- **PART_OF**: [[parent]]";
1212 let rels = parse_relationships_with_warnings(text, "specs", None).0;
1213 assert_eq!(rels.len(), 2);
1214 assert_eq!(rels[0].rel_type, "USES");
1215 assert_eq!(rels[0].target.0, "specs--target-entity");
1216 assert_eq!(rels[1].rel_type, "PART_OF");
1217 assert_eq!(rels[1].target.0, "specs--parent");
1218 assert!(rels[0].description.is_none());
1220 assert!(rels[1].description.is_none());
1221 }
1222
1223 #[test]
1224 fn parse_relationships_canonical_em_dash_captures_description() {
1225 let text = "- **OTHER**: [[a]] \u{2014} replaced by checkout-flow";
1226 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1227 assert_eq!(rels.len(), 1);
1228 assert_eq!(
1229 rels[0].description.as_deref(),
1230 Some("replaced by checkout-flow")
1231 );
1232 assert!(warnings.is_empty(), "canonical em-dash does not warn");
1233 }
1234
1235 #[test]
1236 fn parse_relationships_em_dash_inside_description_body() {
1237 let text = "- **OTHER**: [[a]] \u{2014} note with — inside body";
1238 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1239 assert_eq!(rels.len(), 1);
1240 assert_eq!(
1241 rels[0].description.as_deref(),
1242 Some("note with — inside body"),
1243 "the parser captures up to end-of-line; em-dashes inside the body survive"
1244 );
1245 assert!(warnings.is_empty());
1246 }
1247
1248 #[test]
1249 fn parse_relationships_ambiguous_double_hyphen_warns_and_drops_content() {
1250 let text = "- **USES**: [[a]] -- legacy delimiter";
1251 let entity_id = EntityId::new("specs", "src");
1252 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", Some(&entity_id));
1253 assert_eq!(rels.len(), 1);
1254 assert!(rels[0].description.is_none(), "trailing content is dropped");
1255 assert_eq!(warnings.len(), 1);
1256 assert!(matches!(
1257 warnings[0],
1258 crate::ops::WarningHint::AmbiguousDescriptionDelimiter { .. }
1259 ));
1260 }
1261
1262 #[test]
1263 fn parse_relationships_ambiguous_single_hyphen_warns_and_drops_content() {
1264 let text = "- **USES**: [[a]] - single hyphen";
1265 let entity_id = EntityId::new("specs", "src");
1266 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", Some(&entity_id));
1267 assert_eq!(rels.len(), 1);
1268 assert!(rels[0].description.is_none());
1269 assert_eq!(warnings.len(), 1);
1270 assert_eq!(warnings[0].code(), "AMBIGUOUS_DESCRIPTION_DELIMITER");
1271 }
1272
1273 #[test]
1274 fn parse_relationships_hyphenated_slug_target_parses_unambiguously() {
1275 let text = "- **USES**: [[some-slug-with-hyphens]] \u{2014} ok";
1276 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1277 assert_eq!(rels.len(), 1);
1278 assert_eq!(rels[0].target.path(), "some-slug-with-hyphens");
1279 assert_eq!(rels[0].description.as_deref(), Some("ok"));
1280 assert!(warnings.is_empty());
1281 }
1282
1283 #[test]
1284 fn parse_full_entity() {
1285 let md = "\
1286---
1287type: spec
1288created_date: 2026-01-15
1289last_modified: 2026-04-12
1290level: M0
1291tags: backend, api
1292---
1293# Test Entity
1294
1295## Identity
1296
1297This is a test entity.
1298
1299## Purpose
1300
1301Testing the parser.
1302
1303## Relationships
1304
1305- **USES**: [[other-entity]]
1306
1307## Specifies
1308
1309Some specification content with [[inline-link]].
1310";
1311 let result = parse_markdown(md, "test-entity.md", &spec_schema(), "specs").unwrap();
1312 let entity = &result.entity;
1313 assert_eq!(entity.id.0, "specs--test-entity");
1314 assert_eq!(entity.title, "Test Entity");
1315 assert_eq!(entity.mem, "specs");
1316 assert_eq!(
1317 entity.metadata["type"],
1318 MetadataValue::String("spec".to_string())
1319 );
1320 assert_eq!(
1321 entity.metadata["level"],
1322 MetadataValue::String("M0".to_string())
1323 );
1324 assert_eq!(
1325 entity.metadata["tags"],
1326 MetadataValue::String("backend, api".to_string())
1327 );
1328 assert_eq!(entity.sections["identity"], "This is a test entity.");
1329 assert_eq!(entity.sections["purpose"], "Testing the parser.");
1330 assert_eq!(entity.relationships.len(), 1);
1331 assert_eq!(entity.relationships[0].rel_type, "USES");
1332 assert_eq!(entity.relationships[0].target.0, "specs--other-entity");
1333 assert_eq!(result.inline_links.len(), 1);
1334 assert_eq!(result.inline_links[0].0, "specs--inline-link");
1335 }
1336
1337 #[test]
1338 fn parse_full_entity_memo_schema() {
1339 let md = "\
1340---
1341type: memo
1342created_date: 2026-01-15
1343last_modified: 2026-04-12
1344status: active
1345tags: decision, architecture
1346---
1347# Use Sled For Storage
1348
1349## Claim
1350
1351Sled is the right embedded store for this workload.
1352
1353## Context
1354
1355We evaluated sled, rocksdb, and sqlite for the in-process graph cache.
1356
1357## Substance
1358
1359Sled wins on pure-Rust dependency footprint.
1360";
1361 let result = parse_markdown(md, "use-sled.md", &memo_schema(), "memos").unwrap();
1362 let entity = &result.entity;
1363 assert_eq!(entity.id.0, "memos--use-sled");
1364 assert_eq!(entity.title, "Use Sled For Storage");
1365 assert_eq!(entity.mem, "memos");
1366 assert_eq!(
1367 entity.metadata["type"],
1368 MetadataValue::String("memo".to_string())
1369 );
1370 assert_eq!(
1371 entity.metadata["status"],
1372 MetadataValue::String("active".to_string())
1373 );
1374 assert_eq!(
1375 entity.sections["claim"],
1376 "Sled is the right embedded store for this workload."
1377 );
1378 assert_eq!(
1379 entity.sections["context"],
1380 "We evaluated sled, rocksdb, and sqlite for the in-process graph cache."
1381 );
1382 assert_eq!(
1383 entity.sections["substance"],
1384 "Sled wins on pure-Rust dependency footprint."
1385 );
1386 assert!(!entity.sections.contains_key("identity"));
1387 assert!(!entity.sections.contains_key("purpose"));
1388 }
1389
1390 #[test]
1391 fn parse_entity_without_frontmatter() {
1392 let md = "# No Frontmatter\n\n## Identity\n\nJust a title and section.";
1393 let result = parse_markdown(md, "no-fm.md", &spec_schema(), "specs").unwrap();
1394 assert_eq!(result.entity.title, "No Frontmatter");
1395 assert_eq!(result.entity.metadata.len(), 1);
1397 assert_eq!(
1398 result.entity.metadata.get("type"),
1399 Some(&MetadataValue::String("spec".to_string()))
1400 );
1401 }
1402
1403 #[test]
1404 fn parse_entity_code_blocks_not_detected() {
1405 let md = "\
1406---
1407type: spec
1408---
1409# Code Test
1410
1411## Identity
1412
1413Test entity.
1414
1415## Specifies
1416
1417```
1418## Not A Section
1419- **USES**: [[not-a-link]]
1420```
1421
1422Real content after code block.
1423";
1424 let result = parse_markdown(md, "code-test.md", &spec_schema(), "specs").unwrap();
1425 assert!(!result.entity.sections.contains_key("not a section"));
1427 assert!(result.inline_links.is_empty());
1429 }
1430
1431 #[test]
1437 fn bom_prefixed_frontmatter_is_recognized() {
1438 let md = "\u{feff}---\ntype: spec\n---\n# Bom Entity\n\n## Identity\n\nBody.\n";
1439 assert_eq!(peek_type_from_frontmatter(md), Some("spec".to_string()));
1440 assert_eq!(
1441 body_after_frontmatter(md),
1442 "# Bom Entity\n\n## Identity\n\nBody.\n"
1443 );
1444 let (meta, body) = split_frontmatter(md).unwrap();
1445 assert_eq!(meta, "type: spec");
1446 assert_eq!(body, "# Bom Entity\n\n## Identity\n\nBody.\n");
1447 let result = parse_markdown(md, "bom.md", &spec_schema(), "specs").unwrap();
1448 assert_eq!(
1449 result.entity.metadata["type"],
1450 MetadataValue::String("spec".to_string())
1451 );
1452 assert_eq!(result.entity.sections["identity"], "Body.");
1453 }
1454
1455 #[test]
1462 fn open_fence_in_section_content_does_not_swallow_following_sections() {
1463 let md = "\
1464---
1465type: spec
1466---
1467# Code Test
1468
1469## Identity
1470
1471Base.
1472
1473## Specifies
1474
1475```
1476truncated code with no closer";
1477 let schema = spec_schema();
1478 let e1 = parse_markdown(md, "open-fence.md", &schema, "specs").unwrap();
1479 let m1 = crate::entity::generator::generate_markdown(&e1.entity, &schema);
1480 let e2 = parse_markdown(&m1, "open-fence.md", &schema, "specs").unwrap();
1481 assert_eq!(
1482 e2.entity.sections["identity"], "Base.",
1483 "sections before the open fence survive"
1484 );
1485 assert!(
1486 !e2.entity.sections["specifies"].contains("## Constraints"),
1487 "the generated sections after the fence are not absorbed into it"
1488 );
1489 let m2 = crate::entity::generator::generate_markdown(&e2.entity, &schema);
1490 assert_eq!(
1491 m1, m2,
1492 "parse→generate is a fixpoint after one normalising round"
1493 );
1494 }
1495
1496 #[test]
1504 fn catch_all_reconstruction_is_document_ordered_and_idempotent() {
1505 let md = "\
1506---
1507type: spec
1508---
1509# Multi Unknown
1510
1511## Identity
1512
1513Base.
1514
1515## Claim
1516
1517First unknown.
1518
1519## Context
1520
1521Second unknown.
1522
1523## Substance
1524
1525Third unknown.
1526";
1527 let schema = spec_schema();
1528 let e1 = parse_markdown(md, "multi-unknown.md", &schema, "specs").unwrap();
1529 assert_eq!(
1530 e1.entity.sections["specifies"],
1531 "## Claim\nFirst unknown.\n\n## Context\nSecond unknown.\n\n## Substance\nThird unknown.",
1532 "non-schema sections land in the catch-all in document order"
1533 );
1534 let m1 = crate::entity::generator::generate_markdown(&e1.entity, &schema);
1535 let e2 = parse_markdown(&m1, "multi-unknown.md", &schema, "specs").unwrap();
1536 let m2 = crate::entity::generator::generate_markdown(&e2.entity, &schema);
1537 assert_eq!(
1538 m1, m2,
1539 "parse→generate is idempotent over multi-unknown-section input"
1540 );
1541 }
1542
1543 #[test]
1552 fn first_line_whitespace_prefix_survives_storage_and_round_trips() {
1553 let schema = spec_schema();
1554 let md = "---\ntype: spec\n---\n# T\n\n## Identity\n\u{b}```\nx\n\n## Purpose\np\n";
1555 let e1 = parse_markdown(md, "vt.md", &schema, "specs").unwrap();
1556 assert_eq!(
1557 e1.entity.sections["identity"], "\u{b}```\nx",
1558 "the first visible line keeps its whitespace prefix byte-exactly"
1559 );
1560 let m1 = crate::entity::generator::generate_markdown(&e1.entity, &schema);
1561 let e2 = parse_markdown(&m1, "vt.md", &schema, "specs").unwrap();
1562 let m2 = crate::entity::generator::generate_markdown(&e2.entity, &schema);
1563 assert_eq!(m1, m2, "parse→generate is a fixpoint");
1564 }
1565
1566 #[test]
1576 fn indented_heading_lookalike_stays_content_and_round_trips() {
1577 let md = "\
1578---
1579type: spec
1580---
1581# Promoted Heading
1582
1583## Identity
1584
1585Base.
1586
1587## Unknown Extra
1588
1589 ## Specifies
1590
1591Some content that must survive.
1592";
1593 let schema = spec_schema();
1594 let e1 = parse_markdown(md, "indent.md", &schema, "specs").unwrap();
1595 assert!(
1596 e1.entity.sections["specifies"].contains(" ## Specifies"),
1597 "the indented lookalike keeps its indentation inside the catch-all"
1598 );
1599 assert!(
1600 e1.entity.sections["specifies"].contains("Some content that must survive."),
1601 "content after the lookalike is preserved"
1602 );
1603 let m1 = crate::entity::generator::generate_markdown(&e1.entity, &schema);
1604 let e2 = parse_markdown(&m1, "indent.md", &schema, "specs").unwrap();
1605 let m2 = crate::entity::generator::generate_markdown(&e2.entity, &schema);
1606 assert_eq!(
1607 m1, m2,
1608 "parse→generate is a fixpoint after one normalising round"
1609 );
1610 assert!(
1611 e2.entity.sections["specifies"].contains("Some content that must survive."),
1612 "no content is lost across rounds"
1613 );
1614 }
1615
1616 #[test]
1617 fn compute_hash_deterministic() {
1618 let hash1 = compute_hash("test content");
1619 let hash2 = compute_hash("test content");
1620 assert_eq!(hash1, hash2);
1621 assert_eq!(hash1.len(), 16);
1622 }
1623
1624 #[test]
1625 fn compute_hash_differs() {
1626 let hash1 = compute_hash("content a");
1627 let hash2 = compute_hash("content b");
1628 assert_ne!(hash1, hash2);
1629 }
1630
1631 #[test]
1632 fn is_float_literal_matches() {
1633 assert!(is_float_literal("0.85"));
1634 assert!(is_float_literal("-1.5"));
1635 assert!(is_float_literal("100.0"));
1636 assert!(!is_float_literal(".5"));
1637 assert!(!is_float_literal("1."));
1638 assert!(!is_float_literal("42"));
1639 assert!(!is_float_literal("hello"));
1640 }
1641
1642 #[test]
1643 fn is_integer_literal_matches() {
1644 assert!(is_integer_literal("42"));
1645 assert!(is_integer_literal("-1"));
1646 assert!(is_integer_literal("0"));
1647 assert!(!is_integer_literal("0.5"));
1648 assert!(!is_integer_literal("hello"));
1649 assert!(!is_integer_literal(""));
1650 }
1651
1652 #[test]
1658 fn parse_preserves_frontmatter_key_order() {
1659 let md = "\
1660---
1661type: principle
1662universality: domain-wide
1663authority: proposed
1664tags: a, b, c
1665created_date: 2026-01-15
1666last_modified: 2026-04-12
1667---
1668# Key Order
1669";
1670 let result = parse_markdown(
1671 md,
1672 "key-order.md",
1673 &type_by_name(builtin_names::PRINCIPLE).unwrap(),
1674 "knowledge",
1675 )
1676 .unwrap();
1677 let keys: Vec<&str> = result.entity.metadata.keys().map(|s| s.as_str()).collect();
1678 assert_eq!(
1679 keys,
1680 vec![
1681 "type",
1682 "universality",
1683 "authority",
1684 "tags",
1685 "created_date",
1686 "last_modified",
1687 ],
1688 "metadata iteration must preserve frontmatter declaration order"
1689 );
1690 }
1691
1692 #[test]
1700 fn parse_write_roundtrip_preserves_section_order() {
1701 let md = "\
1702---
1703type: spec
1704created_date: 2026-01-15
1705last_modified: 2026-04-12
1706level: M0
1707---
1708# Order Roundtrip
1709
1710## Identity
1711
1712Identity content.
1713
1714## Purpose
1715
1716Purpose content.
1717
1718## Specifies
1719
1720Specifies content.
1721";
1722 let schema = spec_schema();
1723 let first = parse_markdown(md, "order-roundtrip.md", &schema, "specs").unwrap();
1724 let regenerated = crate::entity::generator::generate_markdown(&first.entity, &schema);
1725 let second = parse_markdown(®enerated, "order-roundtrip.md", &schema, "specs").unwrap();
1726
1727 let first_keys: Vec<&String> = first.entity.sections.keys().collect();
1728 let second_keys: Vec<&String> = second.entity.sections.keys().collect();
1729 assert_eq!(
1730 first_keys, second_keys,
1731 "section iteration order must survive parse -> generate -> parse"
1732 );
1733 }
1734
1735 #[test]
1745 fn parser_extracts_single_h3() {
1746 let md = "\
1747---
1748type: spec
1749---
1750# Entity
1751
1752## Identity
1753
1754Body.
1755
1756## Specifies
1757
1758### Response Shapes
1759
1760Content under response shapes.
1761";
1762 let result = parse_markdown(md, "h3-single.md", &spec_schema(), "specs").unwrap();
1763 let spans = result
1764 .entity
1765 .heading_spans
1766 .get("specifies")
1767 .expect("specifies section should have spans");
1768 assert_eq!(spans.len(), 1);
1769 assert_eq!(spans[0].level, 3);
1770 assert_eq!(spans[0].title, "Response Shapes");
1771 assert_eq!(spans[0].start_offset, 0);
1773 let section = result.entity.sections.get("specifies").unwrap();
1774 assert_eq!(spans[0].end_offset, section.len());
1775 assert!(
1777 result
1778 .entity
1779 .heading_spans
1780 .get("identity")
1781 .is_none_or(Vec::is_empty)
1782 );
1783 }
1784
1785 #[test]
1786 fn parser_extracts_nested_h3_h4() {
1787 let md = "\
1788---
1789type: spec
1790---
1791# Entity
1792
1793## Identity
1794
1795Body.
1796
1797## Specifies
1798
1799### Outer
1800
1801Outer body.
1802
1803#### Inner
1804
1805Inner body.
1806";
1807 let result = parse_markdown(md, "h3-h4.md", &spec_schema(), "specs").unwrap();
1808 let spans = result.entity.heading_spans.get("specifies").unwrap();
1809 assert_eq!(spans.len(), 2, "both H3 and H4 must be recorded");
1810 assert_eq!(spans[0].level, 3);
1811 assert_eq!(spans[0].title, "Outer");
1812 assert_eq!(spans[1].level, 4);
1813 assert_eq!(spans[1].title, "Inner");
1814 assert!(
1815 spans[0].start_offset < spans[1].start_offset,
1816 "spans must be in document order"
1817 );
1818 assert!(
1820 spans[0].end_offset > spans[1].start_offset,
1821 "outer H3 must contain inner H4 by offset"
1822 );
1823 }
1824
1825 #[test]
1826 fn parser_ignores_headings_in_code_blocks() {
1827 let md = "\
1828---
1829type: spec
1830---
1831# Entity
1832
1833## Identity
1834
1835Body.
1836
1837## Specifies
1838
1839Prefix.
1840
1841```
1842### Not a heading
1843Still code.
1844```
1845
1846Suffix.
1847";
1848 let result = parse_markdown(md, "h3-code.md", &spec_schema(), "specs").unwrap();
1849 let spans = result
1850 .entity
1851 .heading_spans
1852 .get("specifies")
1853 .cloned()
1854 .unwrap_or_default();
1855 assert!(
1856 spans.is_empty(),
1857 "a '### ' inside a fenced block must not register as a heading span: {spans:?}"
1858 );
1859 }
1860
1861 #[test]
1862 fn parser_handles_level_skip() {
1863 let md = "\
1864---
1865type: spec
1866---
1867# Entity
1868
1869## Identity
1870
1871Body.
1872
1873## Specifies
1874
1875#### Skipped To H4
1876
1877Content under a sudden H4 — no virtual H3 is inserted.
1878";
1879 let result = parse_markdown(md, "h2-h4.md", &spec_schema(), "specs").unwrap();
1880 let spans = result.entity.heading_spans.get("specifies").unwrap();
1881 assert_eq!(spans.len(), 1);
1882 assert_eq!(spans[0].level, 4);
1883 assert_eq!(spans[0].title, "Skipped To H4");
1884 }
1885
1886 #[test]
1887 fn parser_handles_duplicate_siblings() {
1888 let md = "\
1889---
1890type: spec
1891---
1892# Entity
1893
1894## Identity
1895
1896Body.
1897
1898## Specifies
1899
1900### Same Title
1901
1902First occurrence body.
1903
1904### Same Title
1905
1906Second occurrence body.
1907";
1908 let result = parse_markdown(md, "h3-dup.md", &spec_schema(), "specs").unwrap();
1909 let spans = result.entity.heading_spans.get("specifies").unwrap();
1910 assert_eq!(spans.len(), 2, "duplicate siblings must produce two spans");
1911 assert_eq!(spans[0].title, spans[1].title);
1912 assert_ne!(
1913 spans[0].start_offset, spans[1].start_offset,
1914 "spans with identical titles must be distinguishable by offset"
1915 );
1916 assert!(
1918 spans[0].end_offset <= spans[1].start_offset,
1919 "first sibling must close before the second starts"
1920 );
1921 }
1922
1923 #[test]
1928 fn duplicate_declared_heading_two_populated_keeps_first_warns() {
1929 let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nfirst body\n\n## Identity\n\nsecond body\n";
1930 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1931 assert_eq!(
1932 result.entity.sections.get("identity").map(String::as_str),
1933 Some("first body"),
1934 "first body must win"
1935 );
1936 assert!(
1937 !result
1938 .entity
1939 .sections
1940 .get("identity")
1941 .unwrap()
1942 .contains("## Identity"),
1943 "storage value must not embed a duplicate heading"
1944 );
1945 assert_eq!(result.parse_warnings.len(), 1);
1946 match &result.parse_warnings[0] {
1947 crate::ops::WarningHint::DuplicateSectionHeading {
1948 section_key,
1949 heading,
1950 occurrences,
1951 ..
1952 } => {
1953 assert_eq!(section_key, "identity");
1954 assert_eq!(heading, "Identity");
1955 assert_eq!(*occurrences, 2);
1956 }
1957 other => panic!("expected DuplicateSectionHeading, got {other:?}"),
1958 }
1959 }
1960
1961 #[test]
1962 fn duplicate_declared_heading_blank_then_populated_keeps_blank() {
1963 let md =
1967 "---\ntype: spec\n---\n# Title\n\n## Identity\n\n## Identity\n\nleftover content\n";
1968 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1969 assert_eq!(
1970 result.entity.sections.get("identity").map(String::as_str),
1971 Some(""),
1972 "first (blank) occurrence wins; second body is dropped"
1973 );
1974 assert_eq!(result.parse_warnings.len(), 1);
1975 }
1976
1977 #[test]
1978 fn duplicate_declared_heading_three_occurrences() {
1979 let md = "---\ntype: spec\n---\n# Title\n\n## Constraints\n\nA\n\n## Constraints\n\n## Constraints\n\nC\n";
1980 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1981 assert_eq!(
1982 result
1983 .entity
1984 .sections
1985 .get("constraints")
1986 .map(String::as_str),
1987 Some("A"),
1988 );
1989 assert_eq!(result.parse_warnings.len(), 1);
1990 match &result.parse_warnings[0] {
1991 crate::ops::WarningHint::DuplicateSectionHeading { occurrences, .. } => {
1992 assert_eq!(*occurrences, 3);
1993 }
1994 _ => unreachable!(),
1995 }
1996 }
1997
1998 #[test]
1999 fn no_warning_when_each_declared_section_appears_once() {
2000 let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nID\n\n## Purpose\n\nP\n\n## Constraints\n\nC\n";
2001 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
2002 assert!(result.parse_warnings.is_empty());
2003 }
2004
2005 #[test]
2006 fn no_warning_when_catch_all_section_repeats() {
2007 let md =
2010 "---\ntype: spec\n---\n# Title\n\n## Specifies\n\nfirst\n\n## Specifies\n\nsecond\n";
2011 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
2012 assert!(
2013 result.parse_warnings.is_empty(),
2014 "catch-all repetition must not warn"
2015 );
2016 }
2017
2018 #[test]
2025 fn duplicate_realization_does_not_concatenate_headers_in_storage() {
2026 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";
2027 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
2028 let catch_all = result.entity.sections.get("specifies").unwrap();
2029 let header_count = catch_all.matches("## Realization").count();
2030 assert!(
2031 header_count <= 1,
2032 "catch-all bucket must not contain multiple `## Realization` headers — got {header_count}: {catch_all:?}"
2033 );
2034 }
2035
2036 #[test]
2042 fn parse_render_round_trip_collapses_duplicate_headings() {
2043 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";
2044 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
2045 let rendered = crate::render::render_entity_markdown(&result.entity, None);
2046 let identity_count = rendered.matches("## Identity").count();
2047 assert_eq!(
2048 identity_count, 1,
2049 "rendered output must carry exactly one `## Identity`, got {identity_count}: {rendered}"
2050 );
2051 assert!(rendered.contains("\n## Identity\n\nA\n"));
2053 assert!(!rendered.contains("C\n"), "second body must not survive");
2054 }
2055}
2056
2057#[cfg(test)]
2063mod commonmark_referee {
2064 use super::*;
2065 use memstead_schema::{builtin_names, type_by_name};
2066 use std::sync::Arc;
2067
2068 fn spec_schema() -> Arc<TypeDefinition> {
2069 type_by_name(builtin_names::SPEC).unwrap()
2070 }
2071
2072 fn entity_with_specifies(body: &str) -> ParseResult {
2074 let md = format!(
2075 "---\ntype: spec\n---\n\n# Referee Test\n\n## Identity\n\nx\n\n## Specifies\n\n{body}\n"
2076 );
2077 parse_markdown(&md, "referee-test.md", &spec_schema(), "specs").unwrap()
2078 }
2079
2080 fn headings(result: &ParseResult) -> Vec<&str> {
2081 result
2082 .entity
2083 .raw_section_headings
2084 .iter()
2085 .map(String::as_str)
2086 .collect()
2087 }
2088
2089 fn link_targets(result: &ParseResult) -> Vec<String> {
2090 result.inline_links.iter().map(|id| id.0.clone()).collect()
2091 }
2092
2093 #[test]
2096 fn complement_prose_headings_and_links_still_work() {
2097 let r = entity_with_specifies("See [[real-target]] here.");
2098 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2099 assert_eq!(link_targets(&r), vec!["specs--real-target".to_string()]);
2100 assert_eq!(r.entity.title, "Referee Test");
2101 }
2102
2103 #[test]
2104 fn class_1_indented_code_block() {
2105 let r = entity_with_specifies("Example:\n\n ## Not A Section\n [[not-a-link]]\n");
2106 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2107 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2108 }
2109
2110 #[test]
2111 fn class_2_fence_indented_one_to_three_spaces() {
2112 let r = entity_with_specifies(
2113 "- item\n\n ```\n ## Not A Section\n [[not-a-link]]\n ```\n",
2114 );
2115 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2116 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2117 }
2118
2119 #[test]
2120 fn class_3_tilde_fence() {
2121 let r = entity_with_specifies("~~~\n## Not A Section\n[[not-a-link]]\n~~~\n");
2122 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2123 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2124 }
2125
2126 #[test]
2127 fn class_4_info_string_on_the_closing_line() {
2128 let r = entity_with_specifies(
2129 "```\ncode\n``` still-code\n## Not A Section\n[[not-a-link]]\n```\n",
2130 );
2131 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2132 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2133 }
2134
2135 #[test]
2136 fn class_5_fence_inside_a_blockquote() {
2137 let r = entity_with_specifies("> ```\n> ## Not A Section\n> [[not-a-link]]\n> ```\n");
2138 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2139 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2140 }
2141
2142 #[test]
2143 fn class_6_opening_fence_length_is_honoured_on_close() {
2144 let r = entity_with_specifies("````\n```\n## Not A Section\n[[not-a-link]]\n```\n````\n");
2145 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
2146 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
2147 }
2148
2149 #[test]
2151 fn a_heading_inside_a_code_block_never_becomes_the_title() {
2152 let md =
2153 "---\ntype: spec\n---\n\n```\n# Fake Title\n```\n\n# Real Title\n\n## Identity\n\nx\n";
2154 let r = parse_markdown(md, "title-test.md", &spec_schema(), "specs").unwrap();
2155 assert_eq!(r.entity.title, "Real Title");
2156 }
2157
2158 #[test]
2161 fn a_code_block_only_body_falls_back_to_the_filename() {
2162 let md = "---\ntype: spec\n---\n\n # Fake Title\n\n## Identity\n\nx\n";
2163 let r = parse_markdown(md, "fallback-test.md", &spec_schema(), "specs").unwrap();
2164 assert_eq!(r.entity.title, "fallback-test");
2165 }
2166
2167 #[test]
2168 fn heading_spans_ignore_code_block_content() {
2169 let r = entity_with_specifies("### Real Sub\n\n~~~\n### Fake Sub\n~~~\n");
2170 let spans = r.entity.heading_spans.get("specifies").expect("spans");
2171 let titles: Vec<&str> = spans.iter().map(|s| s.title.as_str()).collect();
2172 assert_eq!(titles, vec!["Real Sub"]);
2173 }
2174
2175 #[test]
2180 fn inline_code_spans_hide_links_on_the_extraction_path() {
2181 let r = entity_with_specifies("`[[hidden-one]]` and ``[[hidden-two]]`` but [[visible]].");
2182 assert_eq!(link_targets(&r), vec!["specs--visible".to_string()]);
2183 }
2184
2185 #[test]
2189 fn empty_wiki_link_target_is_refused_by_the_strict_extractor() {
2190 let errors = extract_inline_links("an empty [[]] link", "specs")
2191 .expect_err("empty target must refuse");
2192 assert_eq!(errors.len(), 1, "{errors:?}");
2193 }
2194
2195 #[test]
2198 fn empty_wiki_link_target_yields_no_id_on_the_lenient_path() {
2199 assert!(extract_inline_links_lenient("an empty [[]] link", "specs").is_empty());
2200 }
2201
2202 #[test]
2208 fn merge_conflict_markers_are_seen_through_fence_shaped_frontmatter() {
2209 let body = "\n# T\n\n## Identity\n\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n";
2210 for fm in [
2211 "---\ntype: spec\n---",
2212 "---\ntype: spec\nnotes: |\n ```rust\n fn x() {}\n---",
2213 "---\ntype: spec\nnotes: |\n ~~~\n---",
2214 "---\ntype: spec\nnotes: |\n indented block\n---",
2215 ] {
2216 assert!(
2217 has_merge_conflict_markers(&format!("{fm}{body}")),
2218 "conflict markers must be seen through frontmatter: {fm:?}"
2219 );
2220 }
2221 }
2222
2223 #[test]
2226 fn merge_conflict_markers_in_frontmatter_are_seen() {
2227 let content =
2228 "---\n<<<<<<< HEAD\ntype: spec\n=======\ntype: memo\n>>>>>>> branch\n---\n\n# T\n";
2229 assert!(has_merge_conflict_markers(content));
2230 }
2231
2232 #[test]
2235 fn a_fenced_conflict_marker_example_still_does_not_trip_the_guard() {
2236 let content = "---\ntype: spec\n---\n\n# T\n\n## Identity\n\n```\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n```\n";
2237 assert!(!has_merge_conflict_markers(content));
2238 }
2239
2240 #[test]
2246 fn a_relationship_row_inside_a_code_block_is_not_a_relationship() {
2247 for body in [
2248 "```\n- **REFERENCES**: [[ghost]]\n```",
2249 "~~~\n- **REFERENCES**: [[ghost]]\n~~~",
2250 " - **REFERENCES**: [[ghost]]",
2251 "> ```\n> - **REFERENCES**: [[ghost]]\n> ```",
2252 "````\n```\n- **REFERENCES**: [[ghost]]\n```\n````",
2253 ] {
2254 let (rels, _) = parse_relationships_with_warnings(body, "specs", None);
2255 assert!(
2256 rels.is_empty(),
2257 "code-block row must not become an edge: {body:?} -> {rels:?}"
2258 );
2259 }
2260 }
2261
2262 #[test]
2269 fn a_relationship_row_inside_an_inline_code_span_is_not_a_relationship() {
2270 for body in [
2271 "Example `open\n - **REFERENCES**: [[ghost]]\nclose`",
2277 "A `- **REFERENCES**: [[ghost]]` sample.",
2278 "A ``- **REFERENCES**: [[ghost]]`` sample.",
2279 ] {
2280 let (rels, _) = parse_relationships_with_warnings(body, "specs", None);
2281 assert!(
2282 rels.is_empty(),
2283 "code-span row must not become an edge: {body:?} -> {rels:?}"
2284 );
2285 }
2286 }
2287
2288 #[test]
2292 fn real_relationship_rows_are_unchanged_by_the_mask() {
2293 let body = "- **REFERENCES**: [[alpha]]\n- **uses**: [[beta]] — because it must\n\n```\n- **REFERENCES**: [[ghost]]\n```\n";
2294 let (rels, _) = parse_relationships_with_warnings(body, "specs", None);
2295 assert_eq!(rels.len(), 2, "{rels:?}");
2296 assert_eq!(rels[0].rel_type, "REFERENCES");
2297 assert_eq!(rels[0].target.0, "specs--alpha");
2298 assert_eq!(rels[0].description, None);
2299 assert_eq!(
2300 rels[1].rel_type, "USES",
2301 "case is normalised from the original"
2302 );
2303 assert_eq!(rels[1].target.0, "specs--beta");
2304 assert_eq!(rels[1].description.as_deref(), Some("because it must"));
2305 }
2306
2307 #[test]
2308 fn ambiguous_delimiter_warning_still_fires_on_a_real_row() {
2309 let id = file_path_to_id("x.md", "specs");
2310 let (_, warnings) = parse_relationships_with_warnings(
2311 "- **REFERENCES**: [[alpha]] -- not an em dash\n",
2312 "specs",
2313 Some(&id),
2314 );
2315 assert_eq!(warnings.len(), 1, "{warnings:?}");
2316 }
2317
2318 #[test]
2326 fn frontmatter_never_opens_a_code_block_over_the_body() {
2327 for fm in [
2328 "notes: |\n ```rust",
2329 "notes: |\n ~~~",
2330 "notes: |\n ```\n still open",
2331 "notes: |\n indented block\n",
2332 ] {
2333 let md = format!(
2334 "---\ntype: spec\n{fm}\n---\n\n# Real Title\n\n## Identity\n\nSee [[a-link]].\n"
2335 );
2336 let r = parse_markdown(&md, "fm-test.md", &spec_schema(), "specs").unwrap();
2337 assert_eq!(
2338 r.entity.title, "Real Title",
2339 "frontmatter ate the title: {fm:?}"
2340 );
2341 assert_eq!(
2342 headings(&r),
2343 vec!["Identity"],
2344 "frontmatter ate the sections: {fm:?}"
2345 );
2346 assert_eq!(
2347 link_targets(&r),
2348 vec!["specs--a-link".to_string()],
2349 "frontmatter ate the links: {fm:?}"
2350 );
2351 }
2352 }
2353}