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(|s| s.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();
74 for s in &schema.sections {
75 if s.catch_all {
76 result_sections.insert(s.key.clone(), catch_all_content.trim().to_string());
77 } else {
78 let val = sections_map
79 .get(s.key.as_str())
80 .map(|v| v.trim().to_string())
81 .unwrap_or_default();
82 result_sections.insert(s.key.clone(), val);
83 }
84 }
85
86 let mut parsed_metadata = parse_metadata(&metadata);
88
89 let type_name = parsed_metadata
94 .get("type")
95 .and_then(|v| v.as_str())
96 .unwrap_or(schema.name.as_str())
97 .to_string();
98 parsed_metadata.insert("type".to_string(), MetadataValue::String(type_name.clone()));
99
100 let inline_link_text: String = schema
102 .text_fields
103 .iter()
104 .filter_map(|f| result_sections.get(f.as_str()))
105 .cloned()
106 .collect::<Vec<_>>()
107 .join("\n");
108 let inline_links = extract_inline_links_lenient(&inline_link_text, mem);
113
114 let explicit_targets: HashSet<_> = relationships.iter().map(|r| &r.target).collect();
116 let inline_links: Vec<EntityId> = inline_links
117 .into_iter()
118 .filter(|link| !explicit_targets.contains(link))
119 .collect();
120
121 let heading_spans = extract_heading_spans(&result_sections);
124
125 let declared_keys: HashSet<&str> = schema
129 .sections
130 .iter()
131 .filter(|s| !s.catch_all)
132 .map(|s| s.key.as_str())
133 .collect();
134 let entity_id_for_warnings = file_path_to_id(relative_path, mem);
135 let mut parse_warnings: Vec<crate::ops::WarningHint> = duplicate_headings
136 .into_iter()
137 .filter(|d| declared_keys.contains(d.key.as_str()))
138 .map(|d| crate::ops::WarningHint::DuplicateSectionHeading {
139 entity_id: entity_id_for_warnings.clone(),
140 section_key: d.key,
141 heading: d.heading,
142 occurrences: d.occurrences,
143 })
144 .collect();
145 parse_warnings.extend(rel_parse_warnings);
146
147 let entity = Entity {
148 id,
149 title,
150 entity_type: type_name,
151 mem: mem.to_string(),
152 file_path: relative_path.to_string(),
153 metadata: parsed_metadata,
154 sections: result_sections,
155 relationships,
156 content_hash,
157 stub: false,
158 stub_kind: None,
159 heading_spans,
160 raw_section_headings,
161 };
162
163 Ok(ParseResult {
164 entity,
165 inline_links,
166 parse_warnings,
167 })
168}
169
170pub fn parse_file(
172 path: &Path,
173 mem_dir: &Path,
174 schema: &TypeDefinition,
175 mem: &str,
176) -> Result<ParseResult, ParseError> {
177 let content = std::fs::read_to_string(path)?;
178 let relative_path = path.strip_prefix(mem_dir).unwrap_or(path).to_string_lossy();
179 parse_markdown(&content, &relative_path, schema, mem)
180}
181
182pub fn peek_type_from_frontmatter(content: &str) -> Option<String> {
192 let content = strip_bom(content);
193 let after_open = if content.starts_with("---\r\n") {
194 5
195 } else if content.starts_with("---\n") {
196 4
197 } else {
198 return None;
199 };
200
201 let close_pos = content[after_open..].find("\n---")?;
202 let frontmatter = &content[after_open..after_open + close_pos];
203
204 for line in frontmatter.lines() {
205 let trimmed = line.trim();
206 if trimmed.is_empty() || trimmed.starts_with('#') {
207 continue;
208 }
209 let Some(colon_idx) = trimmed.find(':') else {
210 continue;
211 };
212 let key = trimmed[..colon_idx].trim();
213 if key != "type" {
214 continue;
215 }
216 let mut value = trimmed[colon_idx + 1..].trim();
217 if let Some(hash_idx) = value.find('#') {
218 value = value[..hash_idx].trim();
219 }
220 let value = value.trim_matches(|c| c == '"' || c == '\'');
221 if value.is_empty() {
222 return None;
223 }
224 return Some(value.to_string());
225 }
226 None
227}
228
229pub fn peek_title_and_type(content: &str) -> (Option<String>, Option<String>) {
238 let entity_type = peek_type_from_frontmatter(content);
239 let body = body_after_frontmatter(content);
240 let title = extract_title(body, &mask_code_blocks(body));
241 (title, entity_type)
242}
243
244pub fn body_after_frontmatter(content: &str) -> &str {
260 let content = strip_bom(content);
261 let after_open = if content.starts_with("---\r\n") {
262 5
263 } else if content.starts_with("---\n") {
264 4
265 } else {
266 return content;
267 };
268 let Some(close_pos) = content[after_open..].find("\n---") else {
269 return content;
270 };
271 let body_start = after_open + close_pos + 4; let rest = &content[body_start..];
273 rest.strip_prefix("\r\n")
274 .or_else(|| rest.strip_prefix('\n'))
275 .unwrap_or(rest)
276}
277
278fn strip_bom(s: &str) -> &str {
284 s.strip_prefix('\u{feff}').unwrap_or(s)
285}
286
287pub(crate) fn split_frontmatter(content: &str) -> Result<(String, String), ParseError> {
291 let content = strip_bom(content);
292 if content.starts_with("---\n") || content.starts_with("---\r\n") {
294 let after_open = if content.starts_with("---\r\n") { 5 } else { 4 };
295 if let Some(close_pos) = content[after_open..].find("\n---") {
297 let meta_end = after_open + close_pos;
298 let metadata = content[after_open..meta_end].to_string();
299 let body_start = meta_end + 4; let body_start = if content[body_start..].starts_with('\n') {
302 body_start + 1
303 } else if content[body_start..].starts_with("\r\n") {
304 body_start + 2
305 } else {
306 body_start
307 };
308 let body = content[body_start..].to_string();
309 return Ok((metadata, body));
310 }
311 }
312
313 Ok((String::new(), content.to_string()))
315}
316
317fn parse_metadata(text: &str) -> IndexMap<String, MetadataValue> {
322 let mut meta = IndexMap::new();
323 if text.is_empty() {
324 return meta;
325 }
326
327 for line in text.lines() {
328 let trimmed = line.trim();
329 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("---") {
331 continue;
332 }
333
334 let Some(colon_idx) = trimmed.find(':') else {
335 continue;
336 };
337
338 let key = trimmed[..colon_idx].trim().to_string();
339 let raw_value = trimmed[colon_idx + 1..].trim();
340
341 let value = strip_inline_comment(raw_value).trim().to_string();
343
344 if value.is_empty() {
345 meta.insert(key, MetadataValue::String(String::new()));
346 continue;
347 }
348
349 if value == "true" {
351 meta.insert(key, MetadataValue::Bool(true));
352 } else if value == "false" {
353 meta.insert(key, MetadataValue::Bool(false));
354 } else if is_float_literal(&value) {
355 if let Ok(f) = value.parse::<f64>() {
356 meta.insert(key, MetadataValue::Float(f));
357 } else {
358 meta.insert(key, MetadataValue::String(strip_quotes(&value)));
359 }
360 } else if is_integer_literal(&value) {
361 if let Ok(n) = value.parse::<i64>() {
362 meta.insert(key, MetadataValue::Integer(n));
363 } else {
364 meta.insert(key, MetadataValue::String(strip_quotes(&value)));
365 }
366 } else {
367 meta.insert(key, MetadataValue::String(strip_quotes(&value)));
368 }
369 }
370
371 meta
372}
373
374fn is_float_literal(s: &str) -> bool {
376 let s = s.strip_prefix('-').unwrap_or(s);
377 if let Some((before, after)) = s.split_once('.') {
378 !before.is_empty()
379 && before.chars().all(|c| c.is_ascii_digit())
380 && !after.is_empty()
381 && after.chars().all(|c| c.is_ascii_digit())
382 } else {
383 false
384 }
385}
386
387fn is_integer_literal(s: &str) -> bool {
389 let s = s.strip_prefix('-').unwrap_or(s);
390 !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
391}
392
393pub(crate) fn would_coerce_from_string(s: &str) -> bool {
399 s == "true" || s == "false" || is_integer_literal(s) || is_float_literal(s)
400}
401
402fn strip_inline_comment(s: &str) -> &str {
404 if let Some(idx) = s.find(" #") {
407 s[..idx].trim_end()
408 } else {
409 s
410 }
411}
412
413fn strip_quotes(s: &str) -> String {
417 if s.len() >= 2
418 && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
419 {
420 s[1..s.len() - 1].to_string()
421 } else {
422 s.to_string()
423 }
424}
425
426pub use crate::markdown::{mask_code_blocks, mask_code_blocks_and_spans};
438
439pub fn has_merge_conflict_markers(text: &str) -> bool {
469 let body = body_after_frontmatter(text);
475 let frontmatter = &text[..text.len() - body.len()];
476 let view = format!("{frontmatter}{}", mask_code_blocks(body));
477
478 let mut seen_start = false;
479 let mut seen_separator = false;
480 for line in view.lines() {
481 if line.starts_with("<<<<<<< ") {
482 seen_start = true;
483 seen_separator = false;
484 } else if seen_start && line.trim_end() == "=======" {
485 seen_separator = true;
486 } else if seen_separator && line.starts_with(">>>>>>> ") {
487 return true;
488 }
489 }
490 false
491}
492
493pub(crate) struct DuplicateSection {
503 pub key: String,
504 pub heading: String,
505 pub occurrences: usize,
506}
507
508pub(crate) fn split_sections(
519 body: &str,
520 masked_body: &str,
521) -> (IndexMap<String, String>, Vec<DuplicateSection>, Vec<String>) {
522 let mut sections = IndexMap::new();
528 let mut duplicates: HashMap<String, DuplicateSection> = HashMap::new();
529 let mut raw_headings = Vec::new();
530 static SECTION_RE: OnceLock<Regex> = OnceLock::new();
531 let section_re = SECTION_RE.get_or_init(|| Regex::new(r"(?m)^## (.+)$").unwrap());
532
533 let matches: Vec<_> = section_re.find_iter(masked_body).collect();
534
535 for (i, m) in matches.iter().enumerate() {
536 let heading_line = &body[m.start()..m.end()];
538 let name = heading_line
539 .strip_prefix("## ")
540 .unwrap_or(heading_line)
541 .trim();
542
543 let content_start = m.end();
544 let content_end = if i + 1 < matches.len() {
545 matches[i + 1].start()
546 } else {
547 body.len()
548 };
549 let content = body[content_start..content_end].trim().to_string();
550 let key = memstead_schema::derive_section_key(name);
558 raw_headings.push(name.to_string());
559
560 match sections.entry(key.clone()) {
561 indexmap::map::Entry::Vacant(slot) => {
562 slot.insert(content);
563 duplicates.insert(
564 key.clone(),
565 DuplicateSection {
566 key: key.clone(),
567 heading: name.to_string(),
568 occurrences: 1,
569 },
570 );
571 }
572 indexmap::map::Entry::Occupied(_) => {
573 if let Some(d) = duplicates.get_mut(&key) {
576 d.occurrences += 1;
577 }
578 }
579 }
580 }
581
582 let dup_list: Vec<DuplicateSection> = duplicates
583 .into_values()
584 .filter(|d| d.occurrences > 1)
585 .collect();
586
587 (sections, dup_list, raw_headings)
588}
589
590fn extract_title(body: &str, masked_body: &str) -> Option<String> {
597 for (line, masked) in body.lines().zip(masked_body.lines()) {
598 if masked.starts_with("# ") {
599 return Some(line[2..].trim().to_string());
600 }
601 }
602 None
603}
604
605fn extract_heading_spans(sections: &IndexMap<String, String>) -> HashMap<String, Vec<HeadingSpan>> {
620 static RE: OnceLock<Regex> = OnceLock::new();
622 let re = RE.get_or_init(|| Regex::new(r"(?m)^(#{3,6})[ \t]+(.+)$").unwrap());
623 let mut out: HashMap<String, Vec<HeadingSpan>> = HashMap::new();
624
625 for (key, content) in sections {
626 if content.is_empty() {
627 continue;
628 }
629 let masked = mask_code_blocks(content);
630
631 let raw: Vec<(usize, u8, String)> = re
633 .captures_iter(&masked)
634 .map(|cap| {
635 let whole = cap.get(0).unwrap();
636 let level = cap[1].len() as u8; let line_end = content[whole.start()..]
640 .find('\n')
641 .map(|i| whole.start() + i)
642 .unwrap_or(content.len());
643 let hashes_end = whole.start() + level as usize;
644 let title = content[hashes_end..line_end].trim().to_string();
645 (whole.start(), level, title)
646 })
647 .collect();
648
649 if raw.is_empty() {
650 continue;
651 }
652
653 let mut spans: Vec<HeadingSpan> = Vec::with_capacity(raw.len());
654 for (i, &(start, level, ref title)) in raw.iter().enumerate() {
655 let end = raw[i + 1..]
657 .iter()
658 .find(|(_, l, _)| *l <= level)
659 .map(|(s, _, _)| *s)
660 .unwrap_or(content.len());
661 spans.push(HeadingSpan {
662 level,
663 title: title.clone(),
664 start_offset: start,
665 end_offset: end,
666 });
667 }
668 out.insert(key.clone(), spans);
669 }
670
671 out
672}
673
674fn build_catch_all(sections: &IndexMap<String, String>, schema: &TypeDefinition) -> String {
680 let catch_all = match schema.catch_all_section() {
681 Some(s) => s,
682 None => return String::new(),
683 };
684
685 let known_sections: HashSet<&str> = schema
686 .sections
687 .iter()
688 .map(|s| s.key.as_str())
689 .chain(std::iter::once("relationships"))
690 .collect();
691
692 let mut parts = Vec::new();
693
694 if let Some(content) = sections.get(catch_all.key.as_str())
696 && !content.is_empty()
697 {
698 parts.push(content.clone());
699 }
700
701 for (key, content) in sections {
707 if !known_sections.contains(key.as_str()) && !content.is_empty() {
708 let heading = format!(
709 "## {}{}",
710 key.chars().next().unwrap_or_default().to_uppercase(),
711 &key[key.chars().next().map_or(0, |c| c.len_utf8())..]
712 );
713 parts.push(format!("{heading}\n{content}"));
714 }
715 }
716
717 parts.join("\n\n")
718}
719
720pub(crate) fn parse_relationships_with_warnings(
744 text: &str,
745 mem: &str,
746 entity_id: Option<&EntityId>,
747) -> (Vec<Relationship>, Vec<crate::ops::WarningHint>) {
748 static RE: OnceLock<Regex> = OnceLock::new();
752 let re = RE.get_or_init(|| {
753 Regex::new(r"(?m)^\s*-\s*\*\*(\w+)\*\*:\s*\[\[([^\]]+)\]\](?P<tail>[^\n]*)").unwrap()
754 });
755 let mut relationships = Vec::new();
756 let mut warnings = Vec::new();
757 let masked = mask_code_blocks_and_spans(text);
765 for cap in re.captures_iter(&masked) {
766 let rel_type = text[cap.get(1).unwrap().range()].to_uppercase();
767 let target = wiki_link_to_id_lenient(&text[cap.get(2).unwrap().range()], mem);
773 let tail = cap.name("tail").map(|m| &text[m.range()]).unwrap_or("");
774 let description = match classify_description_tail(tail) {
775 DescriptionTail::None => None,
776 DescriptionTail::EmDash(text) => Some(text),
777 DescriptionTail::Ambiguous(literal) => {
778 if let Some(id) = entity_id {
779 warnings.push(crate::ops::WarningHint::AmbiguousDescriptionDelimiter {
780 from: id.clone(),
781 rel_type: rel_type.clone(),
782 target: target.clone(),
783 trailing: literal,
784 });
785 }
786 None
787 }
788 };
789 relationships.push(Relationship {
790 rel_type,
791 target,
792 description,
793 });
794 }
795 (relationships, warnings)
796}
797
798enum DescriptionTail {
801 None,
803 EmDash(String),
806 Ambiguous(String),
810}
811
812fn classify_description_tail(tail: &str) -> DescriptionTail {
818 let trimmed_end = tail.trim_end();
819 if trimmed_end.is_empty() {
820 return DescriptionTail::None;
821 }
822 if let Some(rest) = trimmed_end.strip_prefix(" \u{2014} ") {
824 if rest.is_empty() {
825 return DescriptionTail::None;
826 }
827 return DescriptionTail::EmDash(rest.to_string());
828 }
829 if let Some(rest) = trimmed_end.strip_prefix(" \u{2014}") {
833 return DescriptionTail::Ambiguous(format!(" \u{2014}{rest}"));
835 }
836 let starters = [" --", " -", " \u{2013}", " \u{2212}"];
838 if starters
839 .iter()
840 .any(|prefix| trimmed_end.starts_with(prefix))
841 {
842 return DescriptionTail::Ambiguous(trimmed_end.to_string());
843 }
844 DescriptionTail::Ambiguous(trimmed_end.to_string())
848}
849
850fn wiki_link_re() -> &'static Regex {
862 static RE: OnceLock<Regex> = OnceLock::new();
863 RE.get_or_init(|| Regex::new(r"\[\[([^\]]*)\]\]").unwrap())
864}
865
866pub(crate) fn extract_inline_links(
880 text: &str,
881 mem: &str,
882) -> Result<Vec<EntityId>, Vec<WikiLinkError>> {
883 let stripped = mask_code_blocks_and_spans(text);
884
885 let link_re = wiki_link_re();
886 let mut seen = HashSet::new();
887 let mut links = Vec::new();
888 let mut errors = Vec::new();
889
890 for cap in link_re.captures_iter(&stripped) {
891 match wiki_link_to_id(&cap[1], mem) {
892 Ok(id) => {
893 if errors.is_empty() && seen.insert(id.0.clone()) {
894 links.push(id);
895 }
896 }
897 Err(e) => errors.push(e),
898 }
899 }
900
901 if errors.is_empty() {
902 Ok(links)
903 } else {
904 Err(errors)
905 }
906}
907
908pub fn extract_inline_links_lenient(text: &str, mem: &str) -> Vec<EntityId> {
915 let stripped = mask_code_blocks_and_spans(text);
916
917 let link_re = wiki_link_re();
918 let mut seen = HashSet::new();
919 let mut links = Vec::new();
920
921 for cap in link_re.captures_iter(&stripped) {
922 if cap[1].is_empty() {
927 continue;
928 }
929 let id = wiki_link_to_id_lenient(&cap[1], mem);
930 if seen.insert(id.0.clone()) {
931 links.push(id);
932 }
933 }
934
935 links
936}
937
938pub fn compute_hash(content: &str) -> String {
944 let mut hasher = Sha256::new();
945 hasher.update(content.as_bytes());
946 let result = hasher.finalize();
947 crate::hex_lower(&result)[..16].to_string()
948}
949
950#[derive(Debug, thiserror::Error)]
955pub enum ParseError {
956 #[error("missing frontmatter")]
957 MissingFrontmatter,
958 #[error("invalid frontmatter: {0}")]
959 InvalidFrontmatter(String),
960 #[error("missing title")]
961 MissingTitle,
962 #[error("io error: {0}")]
963 Io(#[from] std::io::Error),
964}
965
966#[cfg(test)]
967mod tests {
968 use super::*;
969 use memstead_schema::{builtin_names, type_by_name};
970 use std::sync::Arc;
971
972 fn spec_schema() -> Arc<TypeDefinition> {
973 type_by_name(builtin_names::SPEC).unwrap()
974 }
975
976 fn memo_schema() -> Arc<TypeDefinition> {
977 type_by_name(builtin_names::MEMO).unwrap()
978 }
979
980 #[test]
981 fn parse_metadata_types() {
982 let meta = parse_metadata("key: value\nnum: 42\nfloat: 0.85\nbool: true\nfalsy: false");
983 assert_eq!(meta["key"], MetadataValue::String("value".to_string()));
984 assert_eq!(meta["num"], MetadataValue::Integer(42));
985 assert_eq!(meta["float"], MetadataValue::Float(0.85));
986 assert_eq!(meta["bool"], MetadataValue::Bool(true));
987 assert_eq!(meta["falsy"], MetadataValue::Bool(false));
988 }
989
990 #[test]
991 fn parse_metadata_strips_comments() {
992 let meta = parse_metadata("key: value # this is a comment");
993 assert_eq!(meta["key"], MetadataValue::String("value".to_string()));
994 }
995
996 #[test]
997 fn parse_metadata_strips_quotes() {
998 let meta = parse_metadata("key: \"quoted value\"\nkey2: 'single'");
999 assert_eq!(
1000 meta["key"],
1001 MetadataValue::String("quoted value".to_string())
1002 );
1003 assert_eq!(meta["key2"], MetadataValue::String("single".to_string()));
1004 }
1005
1006 #[test]
1007 fn parse_metadata_survives_malformed_values() {
1008 let meta = parse_metadata(
1011 "key: \"\nkey2: '\nkey3: \"\"\nkey4: ''\nkey5: \"unterminated\nkey6: mixed'\"",
1012 );
1013 assert_eq!(meta["key"], MetadataValue::String("\"".to_string()));
1014 assert_eq!(meta["key2"], MetadataValue::String("'".to_string()));
1015 assert_eq!(meta["key3"], MetadataValue::String(String::new()));
1016 assert_eq!(meta["key4"], MetadataValue::String(String::new()));
1017 assert_eq!(
1018 meta["key5"],
1019 MetadataValue::String("\"unterminated".to_string())
1020 );
1021 assert_eq!(meta["key6"], MetadataValue::String("mixed'\"".to_string()));
1022
1023 let meta =
1026 parse_metadata(":\n: value\nkey7: ✓\"\nkey8: 99999999999999999999999999\nkey9: -");
1027 assert_eq!(meta["key7"], MetadataValue::String("✓\"".to_string()));
1028 assert_eq!(
1029 meta["key8"],
1030 MetadataValue::String("99999999999999999999999999".to_string())
1031 );
1032 assert_eq!(meta["key9"], MetadataValue::String("-".to_string()));
1033 }
1034
1035 #[test]
1036 fn parse_metadata_skips_comments_and_empty() {
1037 let meta = parse_metadata("# comment\n\nkey: val\n---");
1038 assert_eq!(meta.len(), 1);
1039 assert_eq!(meta["key"], MetadataValue::String("val".to_string()));
1040 }
1041
1042 #[test]
1043 fn peek_type_finds_value() {
1044 let content = "---\ntype: memo\ntitle: Test\n---\n# Body\n";
1045 assert_eq!(
1046 peek_type_from_frontmatter(content),
1047 Some("memo".to_string())
1048 );
1049 }
1050
1051 #[test]
1052 fn peek_type_returns_none_when_missing() {
1053 let content = "---\ntitle: Test\n---\n# Body\n";
1054 assert_eq!(peek_type_from_frontmatter(content), None);
1055 }
1056
1057 #[test]
1058 fn peek_type_returns_none_without_frontmatter() {
1059 let content = "# Just a heading\n\nBody with type: concept inside text.\n";
1060 assert_eq!(peek_type_from_frontmatter(content), None);
1061 }
1062
1063 #[test]
1064 fn peek_type_handles_windows_line_endings() {
1065 let content = "---\r\ntype: principle\r\n---\r\n# Body\r\n";
1066 assert_eq!(
1067 peek_type_from_frontmatter(content),
1068 Some("principle".to_string())
1069 );
1070 }
1071
1072 #[test]
1073 fn peek_type_strips_quotes_and_comments() {
1074 let quoted = "---\ntype: \"concept\"\n---\n";
1075 assert_eq!(
1076 peek_type_from_frontmatter(quoted),
1077 Some("concept".to_string())
1078 );
1079 let commented = "---\ntype: memo # kind of\n---\n";
1080 assert_eq!(
1081 peek_type_from_frontmatter(commented),
1082 Some("memo".to_string())
1083 );
1084 }
1085
1086 #[test]
1087 fn peek_type_empty_value_returns_none() {
1088 let content = "---\ntype:\n---\n";
1089 assert_eq!(peek_type_from_frontmatter(content), None);
1090 }
1091
1092 #[test]
1093 fn peek_type_ignores_legacy_schema_key() {
1094 let content = concat!("---\n", "schema", ": memo\n---\n");
1097 assert_eq!(peek_type_from_frontmatter(content), None);
1098 }
1099
1100 #[test]
1101 fn mask_code_blocks_basic() {
1102 let input = "before\n```\ncode [[link]]\n```\nafter";
1103 let masked = mask_code_blocks(input);
1104 assert!(!masked.contains("[[link]]"));
1105 assert!(masked.contains("before"));
1106 assert!(masked.contains("after"));
1107 }
1108
1109 #[test]
1110 fn mask_code_blocks_preserves_line_count() {
1111 let input = "line1\n```\ncode\nmore code\n```\nline6";
1112 let masked = mask_code_blocks(input);
1113 assert_eq!(input.lines().count(), masked.lines().count());
1114 }
1115
1116 #[test]
1117 fn mask_code_blocks_unclosed() {
1118 let input = "before\n```\ncode\nmore code";
1119 let masked = mask_code_blocks(input);
1120 assert!(masked.contains("before"));
1121 assert!(!masked.contains("code"));
1122 }
1123
1124 #[test]
1125 fn parse_relationships_basic() {
1126 let text = "- **USES**: [[target-entity]]\n- **PART_OF**: [[parent]]";
1127 let rels = parse_relationships_with_warnings(text, "specs", None).0;
1128 assert_eq!(rels.len(), 2);
1129 assert_eq!(rels[0].rel_type, "USES");
1130 assert_eq!(rels[0].target.0, "specs--target-entity");
1131 assert_eq!(rels[1].rel_type, "PART_OF");
1132 assert_eq!(rels[1].target.0, "specs--parent");
1133 assert!(rels[0].description.is_none());
1135 assert!(rels[1].description.is_none());
1136 }
1137
1138 #[test]
1139 fn parse_relationships_canonical_em_dash_captures_description() {
1140 let text = "- **OTHER**: [[a]] \u{2014} replaced by checkout-flow";
1141 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1142 assert_eq!(rels.len(), 1);
1143 assert_eq!(
1144 rels[0].description.as_deref(),
1145 Some("replaced by checkout-flow")
1146 );
1147 assert!(warnings.is_empty(), "canonical em-dash does not warn");
1148 }
1149
1150 #[test]
1151 fn parse_relationships_em_dash_inside_description_body() {
1152 let text = "- **OTHER**: [[a]] \u{2014} note with — inside body";
1153 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1154 assert_eq!(rels.len(), 1);
1155 assert_eq!(
1156 rels[0].description.as_deref(),
1157 Some("note with — inside body"),
1158 "the parser captures up to end-of-line; em-dashes inside the body survive"
1159 );
1160 assert!(warnings.is_empty());
1161 }
1162
1163 #[test]
1164 fn parse_relationships_ambiguous_double_hyphen_warns_and_drops_content() {
1165 let text = "- **USES**: [[a]] -- legacy delimiter";
1166 let entity_id = EntityId::new("specs", "src");
1167 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", Some(&entity_id));
1168 assert_eq!(rels.len(), 1);
1169 assert!(rels[0].description.is_none(), "trailing content is dropped");
1170 assert_eq!(warnings.len(), 1);
1171 assert!(matches!(
1172 warnings[0],
1173 crate::ops::WarningHint::AmbiguousDescriptionDelimiter { .. }
1174 ));
1175 }
1176
1177 #[test]
1178 fn parse_relationships_ambiguous_single_hyphen_warns_and_drops_content() {
1179 let text = "- **USES**: [[a]] - single hyphen";
1180 let entity_id = EntityId::new("specs", "src");
1181 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", Some(&entity_id));
1182 assert_eq!(rels.len(), 1);
1183 assert!(rels[0].description.is_none());
1184 assert_eq!(warnings.len(), 1);
1185 assert_eq!(warnings[0].code(), "AMBIGUOUS_DESCRIPTION_DELIMITER");
1186 }
1187
1188 #[test]
1189 fn parse_relationships_hyphenated_slug_target_parses_unambiguously() {
1190 let text = "- **USES**: [[some-slug-with-hyphens]] \u{2014} ok";
1191 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1192 assert_eq!(rels.len(), 1);
1193 assert_eq!(rels[0].target.path(), "some-slug-with-hyphens");
1194 assert_eq!(rels[0].description.as_deref(), Some("ok"));
1195 assert!(warnings.is_empty());
1196 }
1197
1198 #[test]
1199 fn parse_full_entity() {
1200 let md = "\
1201---
1202type: spec
1203created_date: 2026-01-15
1204last_modified: 2026-04-12
1205level: M0
1206tags: backend, api
1207---
1208# Test Entity
1209
1210## Identity
1211
1212This is a test entity.
1213
1214## Purpose
1215
1216Testing the parser.
1217
1218## Relationships
1219
1220- **USES**: [[other-entity]]
1221
1222## Specifies
1223
1224Some specification content with [[inline-link]].
1225";
1226 let result = parse_markdown(md, "test-entity.md", &spec_schema(), "specs").unwrap();
1227 let entity = &result.entity;
1228 assert_eq!(entity.id.0, "specs--test-entity");
1229 assert_eq!(entity.title, "Test Entity");
1230 assert_eq!(entity.mem, "specs");
1231 assert_eq!(
1232 entity.metadata["type"],
1233 MetadataValue::String("spec".to_string())
1234 );
1235 assert_eq!(
1236 entity.metadata["level"],
1237 MetadataValue::String("M0".to_string())
1238 );
1239 assert_eq!(
1240 entity.metadata["tags"],
1241 MetadataValue::String("backend, api".to_string())
1242 );
1243 assert_eq!(entity.sections["identity"], "This is a test entity.");
1244 assert_eq!(entity.sections["purpose"], "Testing the parser.");
1245 assert_eq!(entity.relationships.len(), 1);
1246 assert_eq!(entity.relationships[0].rel_type, "USES");
1247 assert_eq!(entity.relationships[0].target.0, "specs--other-entity");
1248 assert_eq!(result.inline_links.len(), 1);
1249 assert_eq!(result.inline_links[0].0, "specs--inline-link");
1250 }
1251
1252 #[test]
1253 fn parse_full_entity_memo_schema() {
1254 let md = "\
1255---
1256type: memo
1257created_date: 2026-01-15
1258last_modified: 2026-04-12
1259status: active
1260tags: decision, architecture
1261---
1262# Use Sled For Storage
1263
1264## Claim
1265
1266Sled is the right embedded store for this workload.
1267
1268## Context
1269
1270We evaluated sled, rocksdb, and sqlite for the in-process graph cache.
1271
1272## Substance
1273
1274Sled wins on pure-Rust dependency footprint.
1275";
1276 let result = parse_markdown(md, "use-sled.md", &memo_schema(), "memos").unwrap();
1277 let entity = &result.entity;
1278 assert_eq!(entity.id.0, "memos--use-sled");
1279 assert_eq!(entity.title, "Use Sled For Storage");
1280 assert_eq!(entity.mem, "memos");
1281 assert_eq!(
1282 entity.metadata["type"],
1283 MetadataValue::String("memo".to_string())
1284 );
1285 assert_eq!(
1286 entity.metadata["status"],
1287 MetadataValue::String("active".to_string())
1288 );
1289 assert_eq!(
1290 entity.sections["claim"],
1291 "Sled is the right embedded store for this workload."
1292 );
1293 assert_eq!(
1294 entity.sections["context"],
1295 "We evaluated sled, rocksdb, and sqlite for the in-process graph cache."
1296 );
1297 assert_eq!(
1298 entity.sections["substance"],
1299 "Sled wins on pure-Rust dependency footprint."
1300 );
1301 assert!(!entity.sections.contains_key("identity"));
1302 assert!(!entity.sections.contains_key("purpose"));
1303 }
1304
1305 #[test]
1306 fn parse_entity_without_frontmatter() {
1307 let md = "# No Frontmatter\n\n## Identity\n\nJust a title and section.";
1308 let result = parse_markdown(md, "no-fm.md", &spec_schema(), "specs").unwrap();
1309 assert_eq!(result.entity.title, "No Frontmatter");
1310 assert_eq!(result.entity.metadata.len(), 1);
1312 assert_eq!(
1313 result.entity.metadata.get("type"),
1314 Some(&MetadataValue::String("spec".to_string()))
1315 );
1316 }
1317
1318 #[test]
1319 fn parse_entity_code_blocks_not_detected() {
1320 let md = "\
1321---
1322type: spec
1323---
1324# Code Test
1325
1326## Identity
1327
1328Test entity.
1329
1330## Specifies
1331
1332```
1333## Not A Section
1334- **USES**: [[not-a-link]]
1335```
1336
1337Real content after code block.
1338";
1339 let result = parse_markdown(md, "code-test.md", &spec_schema(), "specs").unwrap();
1340 assert!(!result.entity.sections.contains_key("not a section"));
1342 assert!(result.inline_links.is_empty());
1344 }
1345
1346 #[test]
1352 fn bom_prefixed_frontmatter_is_recognized() {
1353 let md = "\u{feff}---\ntype: spec\n---\n# Bom Entity\n\n## Identity\n\nBody.\n";
1354 assert_eq!(peek_type_from_frontmatter(md), Some("spec".to_string()));
1355 assert_eq!(
1356 body_after_frontmatter(md),
1357 "# Bom Entity\n\n## Identity\n\nBody.\n"
1358 );
1359 let (meta, body) = split_frontmatter(md).unwrap();
1360 assert_eq!(meta, "type: spec");
1361 assert_eq!(body, "# Bom Entity\n\n## Identity\n\nBody.\n");
1362 let result = parse_markdown(md, "bom.md", &spec_schema(), "specs").unwrap();
1363 assert_eq!(
1364 result.entity.metadata["type"],
1365 MetadataValue::String("spec".to_string())
1366 );
1367 assert_eq!(result.entity.sections["identity"], "Body.");
1368 }
1369
1370 #[test]
1377 fn open_fence_in_section_content_does_not_swallow_following_sections() {
1378 let md = "\
1379---
1380type: spec
1381---
1382# Code Test
1383
1384## Identity
1385
1386Base.
1387
1388## Specifies
1389
1390```
1391truncated code with no closer";
1392 let schema = spec_schema();
1393 let e1 = parse_markdown(md, "open-fence.md", &schema, "specs").unwrap();
1394 let m1 = crate::entity::generator::generate_markdown(&e1.entity, &schema);
1395 let e2 = parse_markdown(&m1, "open-fence.md", &schema, "specs").unwrap();
1396 assert_eq!(
1397 e2.entity.sections["identity"], "Base.",
1398 "sections before the open fence survive"
1399 );
1400 assert!(
1401 !e2.entity.sections["specifies"].contains("## Constraints"),
1402 "the generated sections after the fence are not absorbed into it"
1403 );
1404 let m2 = crate::entity::generator::generate_markdown(&e2.entity, &schema);
1405 assert_eq!(
1406 m1, m2,
1407 "parse→generate is a fixpoint after one normalising round"
1408 );
1409 }
1410
1411 #[test]
1419 fn catch_all_reconstruction_is_document_ordered_and_idempotent() {
1420 let md = "\
1421---
1422type: spec
1423---
1424# Multi Unknown
1425
1426## Identity
1427
1428Base.
1429
1430## Claim
1431
1432First unknown.
1433
1434## Context
1435
1436Second unknown.
1437
1438## Substance
1439
1440Third unknown.
1441";
1442 let schema = spec_schema();
1443 let e1 = parse_markdown(md, "multi-unknown.md", &schema, "specs").unwrap();
1444 assert_eq!(
1445 e1.entity.sections["specifies"],
1446 "## Claim\nFirst unknown.\n\n## Context\nSecond unknown.\n\n## Substance\nThird unknown.",
1447 "non-schema sections land in the catch-all in document order"
1448 );
1449 let m1 = crate::entity::generator::generate_markdown(&e1.entity, &schema);
1450 let e2 = parse_markdown(&m1, "multi-unknown.md", &schema, "specs").unwrap();
1451 let m2 = crate::entity::generator::generate_markdown(&e2.entity, &schema);
1452 assert_eq!(
1453 m1, m2,
1454 "parse→generate is idempotent over multi-unknown-section input"
1455 );
1456 }
1457
1458 #[test]
1459 fn compute_hash_deterministic() {
1460 let hash1 = compute_hash("test content");
1461 let hash2 = compute_hash("test content");
1462 assert_eq!(hash1, hash2);
1463 assert_eq!(hash1.len(), 16);
1464 }
1465
1466 #[test]
1467 fn compute_hash_differs() {
1468 let hash1 = compute_hash("content a");
1469 let hash2 = compute_hash("content b");
1470 assert_ne!(hash1, hash2);
1471 }
1472
1473 #[test]
1474 fn is_float_literal_matches() {
1475 assert!(is_float_literal("0.85"));
1476 assert!(is_float_literal("-1.5"));
1477 assert!(is_float_literal("100.0"));
1478 assert!(!is_float_literal(".5"));
1479 assert!(!is_float_literal("1."));
1480 assert!(!is_float_literal("42"));
1481 assert!(!is_float_literal("hello"));
1482 }
1483
1484 #[test]
1485 fn is_integer_literal_matches() {
1486 assert!(is_integer_literal("42"));
1487 assert!(is_integer_literal("-1"));
1488 assert!(is_integer_literal("0"));
1489 assert!(!is_integer_literal("0.5"));
1490 assert!(!is_integer_literal("hello"));
1491 assert!(!is_integer_literal(""));
1492 }
1493
1494 #[test]
1500 fn parse_preserves_frontmatter_key_order() {
1501 let md = "\
1502---
1503type: principle
1504universality: domain-wide
1505authority: proposed
1506tags: a, b, c
1507created_date: 2026-01-15
1508last_modified: 2026-04-12
1509---
1510# Key Order
1511";
1512 let result = parse_markdown(
1513 md,
1514 "key-order.md",
1515 &type_by_name(builtin_names::PRINCIPLE).unwrap(),
1516 "knowledge",
1517 )
1518 .unwrap();
1519 let keys: Vec<&str> = result.entity.metadata.keys().map(|s| s.as_str()).collect();
1520 assert_eq!(
1521 keys,
1522 vec![
1523 "type",
1524 "universality",
1525 "authority",
1526 "tags",
1527 "created_date",
1528 "last_modified",
1529 ],
1530 "metadata iteration must preserve frontmatter declaration order"
1531 );
1532 }
1533
1534 #[test]
1542 fn parse_write_roundtrip_preserves_section_order() {
1543 let md = "\
1544---
1545type: spec
1546created_date: 2026-01-15
1547last_modified: 2026-04-12
1548level: M0
1549---
1550# Order Roundtrip
1551
1552## Identity
1553
1554Identity content.
1555
1556## Purpose
1557
1558Purpose content.
1559
1560## Specifies
1561
1562Specifies content.
1563";
1564 let schema = spec_schema();
1565 let first = parse_markdown(md, "order-roundtrip.md", &schema, "specs").unwrap();
1566 let regenerated = crate::entity::generator::generate_markdown(&first.entity, &schema);
1567 let second = parse_markdown(®enerated, "order-roundtrip.md", &schema, "specs").unwrap();
1568
1569 let first_keys: Vec<&String> = first.entity.sections.keys().collect();
1570 let second_keys: Vec<&String> = second.entity.sections.keys().collect();
1571 assert_eq!(
1572 first_keys, second_keys,
1573 "section iteration order must survive parse -> generate -> parse"
1574 );
1575 }
1576
1577 #[test]
1587 fn parser_extracts_single_h3() {
1588 let md = "\
1589---
1590type: spec
1591---
1592# Entity
1593
1594## Identity
1595
1596Body.
1597
1598## Specifies
1599
1600### Response Shapes
1601
1602Content under response shapes.
1603";
1604 let result = parse_markdown(md, "h3-single.md", &spec_schema(), "specs").unwrap();
1605 let spans = result
1606 .entity
1607 .heading_spans
1608 .get("specifies")
1609 .expect("specifies section should have spans");
1610 assert_eq!(spans.len(), 1);
1611 assert_eq!(spans[0].level, 3);
1612 assert_eq!(spans[0].title, "Response Shapes");
1613 assert_eq!(spans[0].start_offset, 0);
1615 let section = result.entity.sections.get("specifies").unwrap();
1616 assert_eq!(spans[0].end_offset, section.len());
1617 assert!(
1619 result
1620 .entity
1621 .heading_spans
1622 .get("identity")
1623 .is_none_or(Vec::is_empty)
1624 );
1625 }
1626
1627 #[test]
1628 fn parser_extracts_nested_h3_h4() {
1629 let md = "\
1630---
1631type: spec
1632---
1633# Entity
1634
1635## Identity
1636
1637Body.
1638
1639## Specifies
1640
1641### Outer
1642
1643Outer body.
1644
1645#### Inner
1646
1647Inner body.
1648";
1649 let result = parse_markdown(md, "h3-h4.md", &spec_schema(), "specs").unwrap();
1650 let spans = result.entity.heading_spans.get("specifies").unwrap();
1651 assert_eq!(spans.len(), 2, "both H3 and H4 must be recorded");
1652 assert_eq!(spans[0].level, 3);
1653 assert_eq!(spans[0].title, "Outer");
1654 assert_eq!(spans[1].level, 4);
1655 assert_eq!(spans[1].title, "Inner");
1656 assert!(
1657 spans[0].start_offset < spans[1].start_offset,
1658 "spans must be in document order"
1659 );
1660 assert!(
1662 spans[0].end_offset > spans[1].start_offset,
1663 "outer H3 must contain inner H4 by offset"
1664 );
1665 }
1666
1667 #[test]
1668 fn parser_ignores_headings_in_code_blocks() {
1669 let md = "\
1670---
1671type: spec
1672---
1673# Entity
1674
1675## Identity
1676
1677Body.
1678
1679## Specifies
1680
1681Prefix.
1682
1683```
1684### Not a heading
1685Still code.
1686```
1687
1688Suffix.
1689";
1690 let result = parse_markdown(md, "h3-code.md", &spec_schema(), "specs").unwrap();
1691 let spans = result
1692 .entity
1693 .heading_spans
1694 .get("specifies")
1695 .cloned()
1696 .unwrap_or_default();
1697 assert!(
1698 spans.is_empty(),
1699 "a '### ' inside a fenced block must not register as a heading span: {spans:?}"
1700 );
1701 }
1702
1703 #[test]
1704 fn parser_handles_level_skip() {
1705 let md = "\
1706---
1707type: spec
1708---
1709# Entity
1710
1711## Identity
1712
1713Body.
1714
1715## Specifies
1716
1717#### Skipped To H4
1718
1719Content under a sudden H4 — no virtual H3 is inserted.
1720";
1721 let result = parse_markdown(md, "h2-h4.md", &spec_schema(), "specs").unwrap();
1722 let spans = result.entity.heading_spans.get("specifies").unwrap();
1723 assert_eq!(spans.len(), 1);
1724 assert_eq!(spans[0].level, 4);
1725 assert_eq!(spans[0].title, "Skipped To H4");
1726 }
1727
1728 #[test]
1729 fn parser_handles_duplicate_siblings() {
1730 let md = "\
1731---
1732type: spec
1733---
1734# Entity
1735
1736## Identity
1737
1738Body.
1739
1740## Specifies
1741
1742### Same Title
1743
1744First occurrence body.
1745
1746### Same Title
1747
1748Second occurrence body.
1749";
1750 let result = parse_markdown(md, "h3-dup.md", &spec_schema(), "specs").unwrap();
1751 let spans = result.entity.heading_spans.get("specifies").unwrap();
1752 assert_eq!(spans.len(), 2, "duplicate siblings must produce two spans");
1753 assert_eq!(spans[0].title, spans[1].title);
1754 assert_ne!(
1755 spans[0].start_offset, spans[1].start_offset,
1756 "spans with identical titles must be distinguishable by offset"
1757 );
1758 assert!(
1760 spans[0].end_offset <= spans[1].start_offset,
1761 "first sibling must close before the second starts"
1762 );
1763 }
1764
1765 #[test]
1770 fn duplicate_declared_heading_two_populated_keeps_first_warns() {
1771 let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nfirst body\n\n## Identity\n\nsecond body\n";
1772 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1773 assert_eq!(
1774 result.entity.sections.get("identity").map(String::as_str),
1775 Some("first body"),
1776 "first body must win"
1777 );
1778 assert!(
1779 !result
1780 .entity
1781 .sections
1782 .get("identity")
1783 .unwrap()
1784 .contains("## Identity"),
1785 "storage value must not embed a duplicate heading"
1786 );
1787 assert_eq!(result.parse_warnings.len(), 1);
1788 match &result.parse_warnings[0] {
1789 crate::ops::WarningHint::DuplicateSectionHeading {
1790 section_key,
1791 heading,
1792 occurrences,
1793 ..
1794 } => {
1795 assert_eq!(section_key, "identity");
1796 assert_eq!(heading, "Identity");
1797 assert_eq!(*occurrences, 2);
1798 }
1799 other => panic!("expected DuplicateSectionHeading, got {other:?}"),
1800 }
1801 }
1802
1803 #[test]
1804 fn duplicate_declared_heading_blank_then_populated_keeps_blank() {
1805 let md =
1809 "---\ntype: spec\n---\n# Title\n\n## Identity\n\n## Identity\n\nleftover content\n";
1810 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1811 assert_eq!(
1812 result.entity.sections.get("identity").map(String::as_str),
1813 Some(""),
1814 "first (blank) occurrence wins; second body is dropped"
1815 );
1816 assert_eq!(result.parse_warnings.len(), 1);
1817 }
1818
1819 #[test]
1820 fn duplicate_declared_heading_three_occurrences() {
1821 let md = "---\ntype: spec\n---\n# Title\n\n## Constraints\n\nA\n\n## Constraints\n\n## Constraints\n\nC\n";
1822 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1823 assert_eq!(
1824 result
1825 .entity
1826 .sections
1827 .get("constraints")
1828 .map(String::as_str),
1829 Some("A"),
1830 );
1831 assert_eq!(result.parse_warnings.len(), 1);
1832 match &result.parse_warnings[0] {
1833 crate::ops::WarningHint::DuplicateSectionHeading { occurrences, .. } => {
1834 assert_eq!(*occurrences, 3);
1835 }
1836 _ => unreachable!(),
1837 }
1838 }
1839
1840 #[test]
1841 fn no_warning_when_each_declared_section_appears_once() {
1842 let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nID\n\n## Purpose\n\nP\n\n## Constraints\n\nC\n";
1843 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1844 assert!(result.parse_warnings.is_empty());
1845 }
1846
1847 #[test]
1848 fn no_warning_when_catch_all_section_repeats() {
1849 let md =
1852 "---\ntype: spec\n---\n# Title\n\n## Specifies\n\nfirst\n\n## Specifies\n\nsecond\n";
1853 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1854 assert!(
1855 result.parse_warnings.is_empty(),
1856 "catch-all repetition must not warn"
1857 );
1858 }
1859
1860 #[test]
1867 fn duplicate_realization_does_not_concatenate_headers_in_storage() {
1868 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";
1869 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1870 let catch_all = result.entity.sections.get("specifies").unwrap();
1871 let header_count = catch_all.matches("## Realization").count();
1872 assert!(
1873 header_count <= 1,
1874 "catch-all bucket must not contain multiple `## Realization` headers — got {header_count}: {catch_all:?}"
1875 );
1876 }
1877
1878 #[test]
1884 fn parse_render_round_trip_collapses_duplicate_headings() {
1885 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";
1886 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1887 let rendered = crate::render::render_entity_markdown(&result.entity, None);
1888 let identity_count = rendered.matches("## Identity").count();
1889 assert_eq!(
1890 identity_count, 1,
1891 "rendered output must carry exactly one `## Identity`, got {identity_count}: {rendered}"
1892 );
1893 assert!(rendered.contains("\n## Identity\n\nA\n"));
1895 assert!(!rendered.contains("C\n"), "second body must not survive");
1896 }
1897}
1898
1899#[cfg(test)]
1905mod commonmark_referee {
1906 use super::*;
1907 use memstead_schema::{builtin_names, type_by_name};
1908 use std::sync::Arc;
1909
1910 fn spec_schema() -> Arc<TypeDefinition> {
1911 type_by_name(builtin_names::SPEC).unwrap()
1912 }
1913
1914 fn entity_with_specifies(body: &str) -> ParseResult {
1916 let md = format!(
1917 "---\ntype: spec\n---\n\n# Referee Test\n\n## Identity\n\nx\n\n## Specifies\n\n{body}\n"
1918 );
1919 parse_markdown(&md, "referee-test.md", &spec_schema(), "specs").unwrap()
1920 }
1921
1922 fn headings(result: &ParseResult) -> Vec<&str> {
1923 result
1924 .entity
1925 .raw_section_headings
1926 .iter()
1927 .map(String::as_str)
1928 .collect()
1929 }
1930
1931 fn link_targets(result: &ParseResult) -> Vec<String> {
1932 result.inline_links.iter().map(|id| id.0.clone()).collect()
1933 }
1934
1935 #[test]
1938 fn complement_prose_headings_and_links_still_work() {
1939 let r = entity_with_specifies("See [[real-target]] here.");
1940 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
1941 assert_eq!(link_targets(&r), vec!["specs--real-target".to_string()]);
1942 assert_eq!(r.entity.title, "Referee Test");
1943 }
1944
1945 #[test]
1946 fn class_1_indented_code_block() {
1947 let r = entity_with_specifies("Example:\n\n ## Not A Section\n [[not-a-link]]\n");
1948 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
1949 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
1950 }
1951
1952 #[test]
1953 fn class_2_fence_indented_one_to_three_spaces() {
1954 let r = entity_with_specifies(
1955 "- item\n\n ```\n ## Not A Section\n [[not-a-link]]\n ```\n",
1956 );
1957 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
1958 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
1959 }
1960
1961 #[test]
1962 fn class_3_tilde_fence() {
1963 let r = entity_with_specifies("~~~\n## Not A Section\n[[not-a-link]]\n~~~\n");
1964 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
1965 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
1966 }
1967
1968 #[test]
1969 fn class_4_info_string_on_the_closing_line() {
1970 let r = entity_with_specifies(
1971 "```\ncode\n``` still-code\n## Not A Section\n[[not-a-link]]\n```\n",
1972 );
1973 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
1974 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
1975 }
1976
1977 #[test]
1978 fn class_5_fence_inside_a_blockquote() {
1979 let r = entity_with_specifies("> ```\n> ## Not A Section\n> [[not-a-link]]\n> ```\n");
1980 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
1981 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
1982 }
1983
1984 #[test]
1985 fn class_6_opening_fence_length_is_honoured_on_close() {
1986 let r = entity_with_specifies("````\n```\n## Not A Section\n[[not-a-link]]\n```\n````\n");
1987 assert_eq!(headings(&r), vec!["Identity", "Specifies"]);
1988 assert!(link_targets(&r).is_empty(), "{:?}", link_targets(&r));
1989 }
1990
1991 #[test]
1993 fn a_heading_inside_a_code_block_never_becomes_the_title() {
1994 let md =
1995 "---\ntype: spec\n---\n\n```\n# Fake Title\n```\n\n# Real Title\n\n## Identity\n\nx\n";
1996 let r = parse_markdown(md, "title-test.md", &spec_schema(), "specs").unwrap();
1997 assert_eq!(r.entity.title, "Real Title");
1998 }
1999
2000 #[test]
2003 fn a_code_block_only_body_falls_back_to_the_filename() {
2004 let md = "---\ntype: spec\n---\n\n # Fake Title\n\n## Identity\n\nx\n";
2005 let r = parse_markdown(md, "fallback-test.md", &spec_schema(), "specs").unwrap();
2006 assert_eq!(r.entity.title, "fallback-test");
2007 }
2008
2009 #[test]
2010 fn heading_spans_ignore_code_block_content() {
2011 let r = entity_with_specifies("### Real Sub\n\n~~~\n### Fake Sub\n~~~\n");
2012 let spans = r.entity.heading_spans.get("specifies").expect("spans");
2013 let titles: Vec<&str> = spans.iter().map(|s| s.title.as_str()).collect();
2014 assert_eq!(titles, vec!["Real Sub"]);
2015 }
2016
2017 #[test]
2022 fn inline_code_spans_hide_links_on_the_extraction_path() {
2023 let r = entity_with_specifies("`[[hidden-one]]` and ``[[hidden-two]]`` but [[visible]].");
2024 assert_eq!(link_targets(&r), vec!["specs--visible".to_string()]);
2025 }
2026
2027 #[test]
2031 fn empty_wiki_link_target_is_refused_by_the_strict_extractor() {
2032 let errors = extract_inline_links("an empty [[]] link", "specs")
2033 .expect_err("empty target must refuse");
2034 assert_eq!(errors.len(), 1, "{errors:?}");
2035 }
2036
2037 #[test]
2040 fn empty_wiki_link_target_yields_no_id_on_the_lenient_path() {
2041 assert!(extract_inline_links_lenient("an empty [[]] link", "specs").is_empty());
2042 }
2043
2044 #[test]
2050 fn merge_conflict_markers_are_seen_through_fence_shaped_frontmatter() {
2051 let body = "\n# T\n\n## Identity\n\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n";
2052 for fm in [
2053 "---\ntype: spec\n---",
2054 "---\ntype: spec\nnotes: |\n ```rust\n fn x() {}\n---",
2055 "---\ntype: spec\nnotes: |\n ~~~\n---",
2056 "---\ntype: spec\nnotes: |\n indented block\n---",
2057 ] {
2058 assert!(
2059 has_merge_conflict_markers(&format!("{fm}{body}")),
2060 "conflict markers must be seen through frontmatter: {fm:?}"
2061 );
2062 }
2063 }
2064
2065 #[test]
2068 fn merge_conflict_markers_in_frontmatter_are_seen() {
2069 let content =
2070 "---\n<<<<<<< HEAD\ntype: spec\n=======\ntype: memo\n>>>>>>> branch\n---\n\n# T\n";
2071 assert!(has_merge_conflict_markers(content));
2072 }
2073
2074 #[test]
2077 fn a_fenced_conflict_marker_example_still_does_not_trip_the_guard() {
2078 let content = "---\ntype: spec\n---\n\n# T\n\n## Identity\n\n```\n<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> branch\n```\n";
2079 assert!(!has_merge_conflict_markers(content));
2080 }
2081
2082 #[test]
2088 fn a_relationship_row_inside_a_code_block_is_not_a_relationship() {
2089 for body in [
2090 "```\n- **REFERENCES**: [[ghost]]\n```",
2091 "~~~\n- **REFERENCES**: [[ghost]]\n~~~",
2092 " - **REFERENCES**: [[ghost]]",
2093 "> ```\n> - **REFERENCES**: [[ghost]]\n> ```",
2094 "````\n```\n- **REFERENCES**: [[ghost]]\n```\n````",
2095 ] {
2096 let (rels, _) = parse_relationships_with_warnings(body, "specs", None);
2097 assert!(
2098 rels.is_empty(),
2099 "code-block row must not become an edge: {body:?} -> {rels:?}"
2100 );
2101 }
2102 }
2103
2104 #[test]
2111 fn a_relationship_row_inside_an_inline_code_span_is_not_a_relationship() {
2112 for body in [
2113 "Example `open\n - **REFERENCES**: [[ghost]]\nclose`",
2119 "A `- **REFERENCES**: [[ghost]]` sample.",
2120 "A ``- **REFERENCES**: [[ghost]]`` sample.",
2121 ] {
2122 let (rels, _) = parse_relationships_with_warnings(body, "specs", None);
2123 assert!(
2124 rels.is_empty(),
2125 "code-span row must not become an edge: {body:?} -> {rels:?}"
2126 );
2127 }
2128 }
2129
2130 #[test]
2134 fn real_relationship_rows_are_unchanged_by_the_mask() {
2135 let body = "- **REFERENCES**: [[alpha]]\n- **uses**: [[beta]] — because it must\n\n```\n- **REFERENCES**: [[ghost]]\n```\n";
2136 let (rels, _) = parse_relationships_with_warnings(body, "specs", None);
2137 assert_eq!(rels.len(), 2, "{rels:?}");
2138 assert_eq!(rels[0].rel_type, "REFERENCES");
2139 assert_eq!(rels[0].target.0, "specs--alpha");
2140 assert_eq!(rels[0].description, None);
2141 assert_eq!(
2142 rels[1].rel_type, "USES",
2143 "case is normalised from the original"
2144 );
2145 assert_eq!(rels[1].target.0, "specs--beta");
2146 assert_eq!(rels[1].description.as_deref(), Some("because it must"));
2147 }
2148
2149 #[test]
2150 fn ambiguous_delimiter_warning_still_fires_on_a_real_row() {
2151 let id = file_path_to_id("x.md", "specs");
2152 let (_, warnings) = parse_relationships_with_warnings(
2153 "- **REFERENCES**: [[alpha]] -- not an em dash\n",
2154 "specs",
2155 Some(&id),
2156 );
2157 assert_eq!(warnings.len(), 1, "{warnings:?}");
2158 }
2159
2160 #[test]
2168 fn frontmatter_never_opens_a_code_block_over_the_body() {
2169 for fm in [
2170 "notes: |\n ```rust",
2171 "notes: |\n ~~~",
2172 "notes: |\n ```\n still open",
2173 "notes: |\n indented block\n",
2174 ] {
2175 let md = format!(
2176 "---\ntype: spec\n{fm}\n---\n\n# Real Title\n\n## Identity\n\nSee [[a-link]].\n"
2177 );
2178 let r = parse_markdown(&md, "fm-test.md", &spec_schema(), "specs").unwrap();
2179 assert_eq!(
2180 r.entity.title, "Real Title",
2181 "frontmatter ate the title: {fm:?}"
2182 );
2183 assert_eq!(
2184 headings(&r),
2185 vec!["Identity"],
2186 "frontmatter ate the sections: {fm:?}"
2187 );
2188 assert_eq!(
2189 link_targets(&r),
2190 vec!["specs--a-link".to_string()],
2191 "frontmatter ate the links: {fm:?}"
2192 );
2193 }
2194 }
2195}