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