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 masked = mask_code_blocks(content);
35
36 let (metadata, body, masked_body) = split_frontmatter(content, &masked)?;
38
39 let title = extract_title(&body).unwrap_or_else(|| id.name().to_string());
41
42 let (sections_map, duplicate_headings, raw_section_headings) =
47 split_sections(&body, &masked_body);
48
49 let rel_heading_key = "relationships";
55 let entity_id_for_rel_warnings = file_path_to_id(relative_path, mem);
56 let (relationships, rel_parse_warnings) = parse_relationships_with_warnings(
57 sections_map
58 .get(rel_heading_key)
59 .map(|s| s.as_str())
60 .unwrap_or(""),
61 mem,
62 Some(&entity_id_for_rel_warnings),
63 );
64
65 let catch_all_content = build_catch_all(§ions_map, schema);
67
68 let mut result_sections = IndexMap::new();
72 for s in &schema.sections {
73 if s.catch_all {
74 result_sections.insert(s.key.clone(), catch_all_content.trim().to_string());
75 } else {
76 let val = sections_map
77 .get(s.key.as_str())
78 .map(|v| v.trim().to_string())
79 .unwrap_or_default();
80 result_sections.insert(s.key.clone(), val);
81 }
82 }
83
84 let mut parsed_metadata = parse_metadata(&metadata);
86
87 let type_name = parsed_metadata
92 .get("type")
93 .and_then(|v| v.as_str())
94 .unwrap_or(schema.name.as_str())
95 .to_string();
96 parsed_metadata.insert("type".to_string(), MetadataValue::String(type_name.clone()));
97
98 let inline_link_text: String = schema
100 .text_fields
101 .iter()
102 .filter_map(|f| result_sections.get(f.as_str()))
103 .cloned()
104 .collect::<Vec<_>>()
105 .join("\n");
106 let inline_links = extract_inline_links_lenient(&inline_link_text, mem);
111
112 let explicit_targets: HashSet<_> = relationships.iter().map(|r| &r.target).collect();
114 let inline_links: Vec<EntityId> = inline_links
115 .into_iter()
116 .filter(|link| !explicit_targets.contains(link))
117 .collect();
118
119 let heading_spans = extract_heading_spans(&result_sections);
122
123 let declared_keys: HashSet<&str> = schema
127 .sections
128 .iter()
129 .filter(|s| !s.catch_all)
130 .map(|s| s.key.as_str())
131 .collect();
132 let entity_id_for_warnings = file_path_to_id(relative_path, mem);
133 let mut parse_warnings: Vec<crate::ops::WarningHint> = duplicate_headings
134 .into_iter()
135 .filter(|d| declared_keys.contains(d.key.as_str()))
136 .map(|d| crate::ops::WarningHint::DuplicateSectionHeading {
137 entity_id: entity_id_for_warnings.clone(),
138 section_key: d.key,
139 heading: d.heading,
140 occurrences: d.occurrences,
141 })
142 .collect();
143 parse_warnings.extend(rel_parse_warnings);
144
145 let entity = Entity {
146 id,
147 title,
148 entity_type: type_name,
149 mem: mem.to_string(),
150 file_path: relative_path.to_string(),
151 metadata: parsed_metadata,
152 sections: result_sections,
153 relationships,
154 content_hash,
155 stub: false,
156 stub_kind: None,
157 heading_spans,
158 raw_section_headings,
159 };
160
161 Ok(ParseResult {
162 entity,
163 inline_links,
164 parse_warnings,
165 })
166}
167
168pub fn parse_file(
170 path: &Path,
171 mem_dir: &Path,
172 schema: &TypeDefinition,
173 mem: &str,
174) -> Result<ParseResult, ParseError> {
175 let content = std::fs::read_to_string(path)?;
176 let relative_path = path.strip_prefix(mem_dir).unwrap_or(path).to_string_lossy();
177 parse_markdown(&content, &relative_path, schema, mem)
178}
179
180pub fn peek_type_from_frontmatter(content: &str) -> Option<String> {
190 let after_open = if content.starts_with("---\r\n") {
191 5
192 } else if content.starts_with("---\n") {
193 4
194 } else {
195 return None;
196 };
197
198 let close_pos = content[after_open..].find("\n---")?;
199 let frontmatter = &content[after_open..after_open + close_pos];
200
201 for line in frontmatter.lines() {
202 let trimmed = line.trim();
203 if trimmed.is_empty() || trimmed.starts_with('#') {
204 continue;
205 }
206 let Some(colon_idx) = trimmed.find(':') else {
207 continue;
208 };
209 let key = trimmed[..colon_idx].trim();
210 if key != "type" {
211 continue;
212 }
213 let mut value = trimmed[colon_idx + 1..].trim();
214 if let Some(hash_idx) = value.find('#') {
215 value = value[..hash_idx].trim();
216 }
217 let value = value.trim_matches(|c| c == '"' || c == '\'');
218 if value.is_empty() {
219 return None;
220 }
221 return Some(value.to_string());
222 }
223 None
224}
225
226pub fn peek_title_and_type(content: &str) -> (Option<String>, Option<String>) {
235 let entity_type = peek_type_from_frontmatter(content);
236 let title = extract_title(body_after_frontmatter(content));
237 (title, entity_type)
238}
239
240fn body_after_frontmatter(content: &str) -> &str {
245 let after_open = if content.starts_with("---\r\n") {
246 5
247 } else if content.starts_with("---\n") {
248 4
249 } else {
250 return content;
251 };
252 let Some(close_pos) = content[after_open..].find("\n---") else {
253 return content;
254 };
255 let body_start = after_open + close_pos + 4; let rest = &content[body_start..];
257 rest.strip_prefix("\r\n")
258 .or_else(|| rest.strip_prefix('\n'))
259 .unwrap_or(rest)
260}
261
262fn split_frontmatter<'a>(
265 content: &'a str,
266 masked: &'a str,
267) -> Result<(String, String, String), ParseError> {
268 if content.starts_with("---\n") || content.starts_with("---\r\n") {
270 let after_open = if content.starts_with("---\r\n") { 5 } else { 4 };
271 if let Some(close_pos) = content[after_open..].find("\n---") {
273 let meta_end = after_open + close_pos;
274 let metadata = content[after_open..meta_end].to_string();
275 let body_start = meta_end + 4; let body_start = if content[body_start..].starts_with('\n') {
278 body_start + 1
279 } else if content[body_start..].starts_with("\r\n") {
280 body_start + 2
281 } else {
282 body_start
283 };
284 let body = content[body_start..].to_string();
285 let masked_body = masked[body_start..].to_string();
286 return Ok((metadata, body, masked_body));
287 }
288 }
289
290 Ok((String::new(), content.to_string(), masked.to_string()))
292}
293
294fn parse_metadata(text: &str) -> IndexMap<String, MetadataValue> {
299 let mut meta = IndexMap::new();
300 if text.is_empty() {
301 return meta;
302 }
303
304 for line in text.lines() {
305 let trimmed = line.trim();
306 if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("---") {
308 continue;
309 }
310
311 let Some(colon_idx) = trimmed.find(':') else {
312 continue;
313 };
314
315 let key = trimmed[..colon_idx].trim().to_string();
316 let raw_value = trimmed[colon_idx + 1..].trim();
317
318 let value = strip_inline_comment(raw_value).trim().to_string();
320
321 if value.is_empty() {
322 meta.insert(key, MetadataValue::String(String::new()));
323 continue;
324 }
325
326 if value == "true" {
328 meta.insert(key, MetadataValue::Bool(true));
329 } else if value == "false" {
330 meta.insert(key, MetadataValue::Bool(false));
331 } else if is_float_literal(&value) {
332 if let Ok(f) = value.parse::<f64>() {
333 meta.insert(key, MetadataValue::Float(f));
334 } else {
335 meta.insert(key, MetadataValue::String(strip_quotes(&value)));
336 }
337 } else if is_integer_literal(&value) {
338 if let Ok(n) = value.parse::<i64>() {
339 meta.insert(key, MetadataValue::Integer(n));
340 } else {
341 meta.insert(key, MetadataValue::String(strip_quotes(&value)));
342 }
343 } else {
344 meta.insert(key, MetadataValue::String(strip_quotes(&value)));
345 }
346 }
347
348 meta
349}
350
351fn is_float_literal(s: &str) -> bool {
353 let s = s.strip_prefix('-').unwrap_or(s);
354 if let Some((before, after)) = s.split_once('.') {
355 !before.is_empty()
356 && before.chars().all(|c| c.is_ascii_digit())
357 && !after.is_empty()
358 && after.chars().all(|c| c.is_ascii_digit())
359 } else {
360 false
361 }
362}
363
364fn is_integer_literal(s: &str) -> bool {
366 let s = s.strip_prefix('-').unwrap_or(s);
367 !s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
368}
369
370pub(crate) fn would_coerce_from_string(s: &str) -> bool {
376 s == "true" || s == "false" || is_integer_literal(s) || is_float_literal(s)
377}
378
379fn strip_inline_comment(s: &str) -> &str {
381 if let Some(idx) = s.find(" #") {
384 s[..idx].trim_end()
385 } else {
386 s
387 }
388}
389
390fn strip_quotes(s: &str) -> String {
394 if s.len() >= 2
395 && ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
396 {
397 s[1..s.len() - 1].to_string()
398 } else {
399 s.to_string()
400 }
401}
402
403pub fn mask_code_blocks(text: &str) -> String {
410 let lines: Vec<&str> = text.split('\n').collect();
411 let mut result = Vec::with_capacity(lines.len());
412 let mut fence: Option<String> = None;
413
414 for line in &lines {
415 if let Some(ref _f) = fence {
416 let trimmed = line.trim_end();
418 if trimmed.starts_with("```") {
419 result.push(" ".repeat(line.len()));
420 fence = None;
421 } else {
422 result.push(" ".repeat(line.len()));
423 }
424 } else {
425 if line.starts_with("```") {
427 fence = Some("```".to_string());
428 result.push(" ".repeat(line.len()));
429 } else {
430 result.push((*line).to_string());
431 }
432 }
433 }
434
435 result.join("\n")
436}
437
438pub(super) struct DuplicateSection {
448 pub key: String,
449 pub heading: String,
450 pub occurrences: usize,
451}
452
453pub(super) fn split_sections(
464 body: &str,
465 masked_body: &str,
466) -> (HashMap<String, String>, Vec<DuplicateSection>, Vec<String>) {
467 let mut sections = HashMap::new();
468 let mut duplicates: HashMap<String, DuplicateSection> = HashMap::new();
469 let mut raw_headings = Vec::new();
470 static SECTION_RE: OnceLock<Regex> = OnceLock::new();
471 let section_re = SECTION_RE.get_or_init(|| Regex::new(r"(?m)^## (.+)$").unwrap());
472
473 let matches: Vec<_> = section_re.find_iter(masked_body).collect();
474
475 for (i, m) in matches.iter().enumerate() {
476 let heading_line = &body[m.start()..m.end()];
478 let name = heading_line
479 .strip_prefix("## ")
480 .unwrap_or(heading_line)
481 .trim();
482
483 let content_start = m.end();
484 let content_end = if i + 1 < matches.len() {
485 matches[i + 1].start()
486 } else {
487 body.len()
488 };
489 let content = body[content_start..content_end].trim().to_string();
490 let key = memstead_schema::derive_section_key(name);
498 raw_headings.push(name.to_string());
499
500 match sections.entry(key.clone()) {
501 std::collections::hash_map::Entry::Vacant(slot) => {
502 slot.insert(content);
503 duplicates.insert(
504 key.clone(),
505 DuplicateSection {
506 key: key.clone(),
507 heading: name.to_string(),
508 occurrences: 1,
509 },
510 );
511 }
512 std::collections::hash_map::Entry::Occupied(_) => {
513 if let Some(d) = duplicates.get_mut(&key) {
516 d.occurrences += 1;
517 }
518 }
519 }
520 }
521
522 let dup_list: Vec<DuplicateSection> = duplicates
523 .into_values()
524 .filter(|d| d.occurrences > 1)
525 .collect();
526
527 (sections, dup_list, raw_headings)
528}
529
530fn extract_title(body: &str) -> Option<String> {
532 for line in body.lines() {
533 if let Some(title) = line.strip_prefix("# ") {
534 return Some(title.trim().to_string());
535 }
536 }
537 None
538}
539
540fn extract_heading_spans(sections: &IndexMap<String, String>) -> HashMap<String, Vec<HeadingSpan>> {
555 static RE: OnceLock<Regex> = OnceLock::new();
557 let re = RE.get_or_init(|| Regex::new(r"(?m)^(#{3,6})[ \t]+(.+)$").unwrap());
558 let mut out: HashMap<String, Vec<HeadingSpan>> = HashMap::new();
559
560 for (key, content) in sections {
561 if content.is_empty() {
562 continue;
563 }
564 let masked = mask_code_blocks(content);
565
566 let raw: Vec<(usize, u8, String)> = re
568 .captures_iter(&masked)
569 .map(|cap| {
570 let whole = cap.get(0).unwrap();
571 let level = cap[1].len() as u8; let line_end = content[whole.start()..]
575 .find('\n')
576 .map(|i| whole.start() + i)
577 .unwrap_or(content.len());
578 let hashes_end = whole.start() + level as usize;
579 let title = content[hashes_end..line_end].trim().to_string();
580 (whole.start(), level, title)
581 })
582 .collect();
583
584 if raw.is_empty() {
585 continue;
586 }
587
588 let mut spans: Vec<HeadingSpan> = Vec::with_capacity(raw.len());
589 for (i, &(start, level, ref title)) in raw.iter().enumerate() {
590 let end = raw[i + 1..]
592 .iter()
593 .find(|(_, l, _)| *l <= level)
594 .map(|(s, _, _)| *s)
595 .unwrap_or(content.len());
596 spans.push(HeadingSpan {
597 level,
598 title: title.clone(),
599 start_offset: start,
600 end_offset: end,
601 });
602 }
603 out.insert(key.clone(), spans);
604 }
605
606 out
607}
608
609fn build_catch_all(sections: &HashMap<String, String>, schema: &TypeDefinition) -> String {
615 let catch_all = match schema.catch_all_section() {
616 Some(s) => s,
617 None => return String::new(),
618 };
619
620 let known_sections: HashSet<&str> = schema
621 .sections
622 .iter()
623 .map(|s| s.key.as_str())
624 .chain(std::iter::once("relationships"))
625 .collect();
626
627 let mut parts = Vec::new();
628
629 if let Some(content) = sections.get(catch_all.key.as_str())
631 && !content.is_empty()
632 {
633 parts.push(content.clone());
634 }
635
636 for (key, content) in sections {
645 if !known_sections.contains(key.as_str()) && !content.is_empty() {
646 let heading = format!(
647 "## {}{}",
648 key.chars().next().unwrap_or_default().to_uppercase(),
649 &key[key.chars().next().map_or(0, |c| c.len_utf8())..]
650 );
651 parts.push(format!("{heading}\n{content}"));
652 }
653 }
654
655 parts.join("\n\n")
656}
657
658pub(crate) fn parse_relationships_with_warnings(
673 text: &str,
674 mem: &str,
675 entity_id: Option<&EntityId>,
676) -> (Vec<Relationship>, Vec<crate::ops::WarningHint>) {
677 static RE: OnceLock<Regex> = OnceLock::new();
681 let re = RE.get_or_init(|| {
682 Regex::new(r"(?m)^\s*-\s*\*\*(\w+)\*\*:\s*\[\[([^\]]+)\]\](?P<tail>[^\n]*)").unwrap()
683 });
684 let mut relationships = Vec::new();
685 let mut warnings = Vec::new();
686 for cap in re.captures_iter(text) {
687 let rel_type = cap[1].to_uppercase();
688 let target = wiki_link_to_id_lenient(&cap[2], mem);
694 let tail = cap.name("tail").map(|m| m.as_str()).unwrap_or("");
695 let description = match classify_description_tail(tail) {
696 DescriptionTail::None => None,
697 DescriptionTail::EmDash(text) => Some(text),
698 DescriptionTail::Ambiguous(literal) => {
699 if let Some(id) = entity_id {
700 warnings.push(crate::ops::WarningHint::AmbiguousDescriptionDelimiter {
701 from: id.clone(),
702 rel_type: rel_type.clone(),
703 target: target.clone(),
704 trailing: literal,
705 });
706 }
707 None
708 }
709 };
710 relationships.push(Relationship {
711 rel_type,
712 target,
713 description,
714 });
715 }
716 (relationships, warnings)
717}
718
719enum DescriptionTail {
722 None,
724 EmDash(String),
727 Ambiguous(String),
731}
732
733fn classify_description_tail(tail: &str) -> DescriptionTail {
739 let trimmed_end = tail.trim_end();
740 if trimmed_end.is_empty() {
741 return DescriptionTail::None;
742 }
743 if let Some(rest) = trimmed_end.strip_prefix(" \u{2014} ") {
745 if rest.is_empty() {
746 return DescriptionTail::None;
747 }
748 return DescriptionTail::EmDash(rest.to_string());
749 }
750 if let Some(rest) = trimmed_end.strip_prefix(" \u{2014}") {
754 return DescriptionTail::Ambiguous(format!(" \u{2014}{rest}"));
756 }
757 let starters = [" --", " -", " \u{2013}", " \u{2212}"];
759 if starters
760 .iter()
761 .any(|prefix| trimmed_end.starts_with(prefix))
762 {
763 return DescriptionTail::Ambiguous(trimmed_end.to_string());
764 }
765 DescriptionTail::Ambiguous(trimmed_end.to_string())
769}
770
771#[derive(Debug, Clone)]
777pub struct WikiLink {
778 pub target: String,
779 pub label: Option<String>,
780}
781
782fn wiki_link_re() -> &'static Regex {
784 static RE: OnceLock<Regex> = OnceLock::new();
785 RE.get_or_init(|| Regex::new(r"\[\[([^\]]+)\]\]").unwrap())
786}
787
788fn inline_code_re() -> &'static Regex {
790 static RE: OnceLock<Regex> = OnceLock::new();
791 RE.get_or_init(|| Regex::new(r"`[^`]+`").unwrap())
792}
793
794pub fn extract_wiki_links(content: &str) -> Vec<WikiLink> {
796 let re = wiki_link_re();
797 re.captures_iter(content)
798 .map(|cap| {
799 let raw = &cap[1];
800 let (target, label) = match raw.find('|') {
801 Some(i) => (raw[..i].to_string(), Some(raw[i + 1..].to_string())),
802 None => (raw.to_string(), None),
803 };
804 WikiLink { target, label }
805 })
806 .collect()
807}
808
809pub(crate) fn extract_inline_links(
822 text: &str,
823 mem: &str,
824) -> Result<Vec<EntityId>, Vec<WikiLinkError>> {
825 let stripped = mask_code_blocks(text);
826 let stripped = inline_code_re().replace_all(&stripped, "");
827
828 let link_re = wiki_link_re();
829 let mut seen = HashSet::new();
830 let mut links = Vec::new();
831 let mut errors = Vec::new();
832
833 for cap in link_re.captures_iter(&stripped) {
834 match wiki_link_to_id(&cap[1], mem) {
835 Ok(id) => {
836 if errors.is_empty() && seen.insert(id.0.clone()) {
837 links.push(id);
838 }
839 }
840 Err(e) => errors.push(e),
841 }
842 }
843
844 if errors.is_empty() {
845 Ok(links)
846 } else {
847 Err(errors)
848 }
849}
850
851pub fn extract_inline_links_lenient(text: &str, mem: &str) -> Vec<EntityId> {
858 let stripped = mask_code_blocks(text);
859 let stripped = inline_code_re().replace_all(&stripped, "");
860
861 let link_re = wiki_link_re();
862 let mut seen = HashSet::new();
863 let mut links = Vec::new();
864
865 for cap in link_re.captures_iter(&stripped) {
866 let id = wiki_link_to_id_lenient(&cap[1], mem);
867 if seen.insert(id.0.clone()) {
868 links.push(id);
869 }
870 }
871
872 links
873}
874
875pub fn compute_hash(content: &str) -> String {
881 let mut hasher = Sha256::new();
882 hasher.update(content.as_bytes());
883 let result = hasher.finalize();
884 crate::hex_lower(&result)[..16].to_string()
885}
886
887#[derive(Debug, thiserror::Error)]
892pub enum ParseError {
893 #[error("missing frontmatter")]
894 MissingFrontmatter,
895 #[error("invalid frontmatter: {0}")]
896 InvalidFrontmatter(String),
897 #[error("missing title")]
898 MissingTitle,
899 #[error("io error: {0}")]
900 Io(#[from] std::io::Error),
901}
902
903#[cfg(test)]
904mod tests {
905 use super::*;
906 use memstead_schema::{builtin_names, type_by_name};
907 use std::sync::Arc;
908
909 fn spec_schema() -> Arc<TypeDefinition> {
910 type_by_name(builtin_names::SPEC).unwrap()
911 }
912
913 fn memo_schema() -> Arc<TypeDefinition> {
914 type_by_name(builtin_names::MEMO).unwrap()
915 }
916
917 #[test]
918 fn parse_metadata_types() {
919 let meta = parse_metadata("key: value\nnum: 42\nfloat: 0.85\nbool: true\nfalsy: false");
920 assert_eq!(meta["key"], MetadataValue::String("value".to_string()));
921 assert_eq!(meta["num"], MetadataValue::Integer(42));
922 assert_eq!(meta["float"], MetadataValue::Float(0.85));
923 assert_eq!(meta["bool"], MetadataValue::Bool(true));
924 assert_eq!(meta["falsy"], MetadataValue::Bool(false));
925 }
926
927 #[test]
928 fn parse_metadata_strips_comments() {
929 let meta = parse_metadata("key: value # this is a comment");
930 assert_eq!(meta["key"], MetadataValue::String("value".to_string()));
931 }
932
933 #[test]
934 fn parse_metadata_strips_quotes() {
935 let meta = parse_metadata("key: \"quoted value\"\nkey2: 'single'");
936 assert_eq!(
937 meta["key"],
938 MetadataValue::String("quoted value".to_string())
939 );
940 assert_eq!(meta["key2"], MetadataValue::String("single".to_string()));
941 }
942
943 #[test]
944 fn parse_metadata_survives_malformed_values() {
945 let meta = parse_metadata(
948 "key: \"\nkey2: '\nkey3: \"\"\nkey4: ''\nkey5: \"unterminated\nkey6: mixed'\"",
949 );
950 assert_eq!(meta["key"], MetadataValue::String("\"".to_string()));
951 assert_eq!(meta["key2"], MetadataValue::String("'".to_string()));
952 assert_eq!(meta["key3"], MetadataValue::String(String::new()));
953 assert_eq!(meta["key4"], MetadataValue::String(String::new()));
954 assert_eq!(
955 meta["key5"],
956 MetadataValue::String("\"unterminated".to_string())
957 );
958 assert_eq!(meta["key6"], MetadataValue::String("mixed'\"".to_string()));
959
960 let meta =
963 parse_metadata(":\n: value\nkey7: ✓\"\nkey8: 99999999999999999999999999\nkey9: -");
964 assert_eq!(meta["key7"], MetadataValue::String("✓\"".to_string()));
965 assert_eq!(
966 meta["key8"],
967 MetadataValue::String("99999999999999999999999999".to_string())
968 );
969 assert_eq!(meta["key9"], MetadataValue::String("-".to_string()));
970 }
971
972 #[test]
973 fn parse_metadata_skips_comments_and_empty() {
974 let meta = parse_metadata("# comment\n\nkey: val\n---");
975 assert_eq!(meta.len(), 1);
976 assert_eq!(meta["key"], MetadataValue::String("val".to_string()));
977 }
978
979 #[test]
980 fn peek_type_finds_value() {
981 let content = "---\ntype: memo\ntitle: Test\n---\n# Body\n";
982 assert_eq!(
983 peek_type_from_frontmatter(content),
984 Some("memo".to_string())
985 );
986 }
987
988 #[test]
989 fn peek_type_returns_none_when_missing() {
990 let content = "---\ntitle: Test\n---\n# Body\n";
991 assert_eq!(peek_type_from_frontmatter(content), None);
992 }
993
994 #[test]
995 fn peek_type_returns_none_without_frontmatter() {
996 let content = "# Just a heading\n\nBody with type: concept inside text.\n";
997 assert_eq!(peek_type_from_frontmatter(content), None);
998 }
999
1000 #[test]
1001 fn peek_type_handles_windows_line_endings() {
1002 let content = "---\r\ntype: principle\r\n---\r\n# Body\r\n";
1003 assert_eq!(
1004 peek_type_from_frontmatter(content),
1005 Some("principle".to_string())
1006 );
1007 }
1008
1009 #[test]
1010 fn peek_type_strips_quotes_and_comments() {
1011 let quoted = "---\ntype: \"concept\"\n---\n";
1012 assert_eq!(
1013 peek_type_from_frontmatter(quoted),
1014 Some("concept".to_string())
1015 );
1016 let commented = "---\ntype: memo # kind of\n---\n";
1017 assert_eq!(
1018 peek_type_from_frontmatter(commented),
1019 Some("memo".to_string())
1020 );
1021 }
1022
1023 #[test]
1024 fn peek_type_empty_value_returns_none() {
1025 let content = "---\ntype:\n---\n";
1026 assert_eq!(peek_type_from_frontmatter(content), None);
1027 }
1028
1029 #[test]
1030 fn peek_type_ignores_legacy_schema_key() {
1031 let content = concat!("---\n", "schema", ": memo\n---\n");
1034 assert_eq!(peek_type_from_frontmatter(content), None);
1035 }
1036
1037 #[test]
1038 fn mask_code_blocks_basic() {
1039 let input = "before\n```\ncode [[link]]\n```\nafter";
1040 let masked = mask_code_blocks(input);
1041 assert!(!masked.contains("[[link]]"));
1042 assert!(masked.contains("before"));
1043 assert!(masked.contains("after"));
1044 }
1045
1046 #[test]
1047 fn mask_code_blocks_preserves_line_count() {
1048 let input = "line1\n```\ncode\nmore code\n```\nline6";
1049 let masked = mask_code_blocks(input);
1050 assert_eq!(input.lines().count(), masked.lines().count());
1051 }
1052
1053 #[test]
1054 fn mask_code_blocks_unclosed() {
1055 let input = "before\n```\ncode\nmore code";
1056 let masked = mask_code_blocks(input);
1057 assert!(masked.contains("before"));
1058 assert!(!masked.contains("code"));
1059 }
1060
1061 #[test]
1062 fn extract_wiki_links_basic() {
1063 let links = extract_wiki_links("See [[target]] and [[other|label]]");
1064 assert_eq!(links.len(), 2);
1065 assert_eq!(links[0].target, "target");
1066 assert_eq!(links[1].target, "other");
1067 assert_eq!(links[1].label.as_deref(), Some("label"));
1068 }
1069
1070 #[test]
1071 fn parse_relationships_basic() {
1072 let text = "- **USES**: [[target-entity]]\n- **PART_OF**: [[parent]]";
1073 let rels = parse_relationships_with_warnings(text, "specs", None).0;
1074 assert_eq!(rels.len(), 2);
1075 assert_eq!(rels[0].rel_type, "USES");
1076 assert_eq!(rels[0].target.0, "specs--target-entity");
1077 assert_eq!(rels[1].rel_type, "PART_OF");
1078 assert_eq!(rels[1].target.0, "specs--parent");
1079 assert!(rels[0].description.is_none());
1081 assert!(rels[1].description.is_none());
1082 }
1083
1084 #[test]
1085 fn parse_relationships_canonical_em_dash_captures_description() {
1086 let text = "- **OTHER**: [[a]] \u{2014} replaced by checkout-flow";
1087 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1088 assert_eq!(rels.len(), 1);
1089 assert_eq!(
1090 rels[0].description.as_deref(),
1091 Some("replaced by checkout-flow")
1092 );
1093 assert!(warnings.is_empty(), "canonical em-dash does not warn");
1094 }
1095
1096 #[test]
1097 fn parse_relationships_em_dash_inside_description_body() {
1098 let text = "- **OTHER**: [[a]] \u{2014} note with — inside body";
1099 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1100 assert_eq!(rels.len(), 1);
1101 assert_eq!(
1102 rels[0].description.as_deref(),
1103 Some("note with — inside body"),
1104 "the parser captures up to end-of-line; em-dashes inside the body survive"
1105 );
1106 assert!(warnings.is_empty());
1107 }
1108
1109 #[test]
1110 fn parse_relationships_ambiguous_double_hyphen_warns_and_drops_content() {
1111 let text = "- **USES**: [[a]] -- legacy delimiter";
1112 let entity_id = EntityId::new("specs", "src");
1113 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", Some(&entity_id));
1114 assert_eq!(rels.len(), 1);
1115 assert!(rels[0].description.is_none(), "trailing content is dropped");
1116 assert_eq!(warnings.len(), 1);
1117 assert!(matches!(
1118 warnings[0],
1119 crate::ops::WarningHint::AmbiguousDescriptionDelimiter { .. }
1120 ));
1121 }
1122
1123 #[test]
1124 fn parse_relationships_ambiguous_single_hyphen_warns_and_drops_content() {
1125 let text = "- **USES**: [[a]] - single hyphen";
1126 let entity_id = EntityId::new("specs", "src");
1127 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", Some(&entity_id));
1128 assert_eq!(rels.len(), 1);
1129 assert!(rels[0].description.is_none());
1130 assert_eq!(warnings.len(), 1);
1131 assert_eq!(warnings[0].code(), "AMBIGUOUS_DESCRIPTION_DELIMITER");
1132 }
1133
1134 #[test]
1135 fn parse_relationships_hyphenated_slug_target_parses_unambiguously() {
1136 let text = "- **USES**: [[some-slug-with-hyphens]] \u{2014} ok";
1137 let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
1138 assert_eq!(rels.len(), 1);
1139 assert_eq!(rels[0].target.path(), "some-slug-with-hyphens");
1140 assert_eq!(rels[0].description.as_deref(), Some("ok"));
1141 assert!(warnings.is_empty());
1142 }
1143
1144 #[test]
1145 fn parse_full_entity() {
1146 let md = "\
1147---
1148type: spec
1149created_date: 2026-01-15
1150last_modified: 2026-04-12
1151level: M0
1152tags: backend, api
1153---
1154# Test Entity
1155
1156## Identity
1157
1158This is a test entity.
1159
1160## Purpose
1161
1162Testing the parser.
1163
1164## Relationships
1165
1166- **USES**: [[other-entity]]
1167
1168## Specifies
1169
1170Some specification content with [[inline-link]].
1171";
1172 let result = parse_markdown(md, "test-entity.md", &spec_schema(), "specs").unwrap();
1173 let entity = &result.entity;
1174 assert_eq!(entity.id.0, "specs--test-entity");
1175 assert_eq!(entity.title, "Test Entity");
1176 assert_eq!(entity.mem, "specs");
1177 assert_eq!(
1178 entity.metadata["type"],
1179 MetadataValue::String("spec".to_string())
1180 );
1181 assert_eq!(
1182 entity.metadata["level"],
1183 MetadataValue::String("M0".to_string())
1184 );
1185 assert_eq!(
1186 entity.metadata["tags"],
1187 MetadataValue::String("backend, api".to_string())
1188 );
1189 assert_eq!(entity.sections["identity"], "This is a test entity.");
1190 assert_eq!(entity.sections["purpose"], "Testing the parser.");
1191 assert_eq!(entity.relationships.len(), 1);
1192 assert_eq!(entity.relationships[0].rel_type, "USES");
1193 assert_eq!(entity.relationships[0].target.0, "specs--other-entity");
1194 assert_eq!(result.inline_links.len(), 1);
1195 assert_eq!(result.inline_links[0].0, "specs--inline-link");
1196 }
1197
1198 #[test]
1199 fn parse_full_entity_memo_schema() {
1200 let md = "\
1201---
1202type: memo
1203created_date: 2026-01-15
1204last_modified: 2026-04-12
1205status: active
1206tags: decision, architecture
1207---
1208# Use Sled For Storage
1209
1210## Claim
1211
1212Sled is the right embedded store for this workload.
1213
1214## Context
1215
1216We evaluated sled, rocksdb, and sqlite for the in-process graph cache.
1217
1218## Substance
1219
1220Sled wins on pure-Rust dependency footprint.
1221";
1222 let result = parse_markdown(md, "use-sled.md", &memo_schema(), "memos").unwrap();
1223 let entity = &result.entity;
1224 assert_eq!(entity.id.0, "memos--use-sled");
1225 assert_eq!(entity.title, "Use Sled For Storage");
1226 assert_eq!(entity.mem, "memos");
1227 assert_eq!(
1228 entity.metadata["type"],
1229 MetadataValue::String("memo".to_string())
1230 );
1231 assert_eq!(
1232 entity.metadata["status"],
1233 MetadataValue::String("active".to_string())
1234 );
1235 assert_eq!(
1236 entity.sections["claim"],
1237 "Sled is the right embedded store for this workload."
1238 );
1239 assert_eq!(
1240 entity.sections["context"],
1241 "We evaluated sled, rocksdb, and sqlite for the in-process graph cache."
1242 );
1243 assert_eq!(
1244 entity.sections["substance"],
1245 "Sled wins on pure-Rust dependency footprint."
1246 );
1247 assert!(!entity.sections.contains_key("identity"));
1248 assert!(!entity.sections.contains_key("purpose"));
1249 }
1250
1251 #[test]
1252 fn parse_entity_without_frontmatter() {
1253 let md = "# No Frontmatter\n\n## Identity\n\nJust a title and section.";
1254 let result = parse_markdown(md, "no-fm.md", &spec_schema(), "specs").unwrap();
1255 assert_eq!(result.entity.title, "No Frontmatter");
1256 assert_eq!(result.entity.metadata.len(), 1);
1258 assert_eq!(
1259 result.entity.metadata.get("type"),
1260 Some(&MetadataValue::String("spec".to_string()))
1261 );
1262 }
1263
1264 #[test]
1265 fn parse_entity_code_blocks_not_detected() {
1266 let md = "\
1267---
1268type: spec
1269---
1270# Code Test
1271
1272## Identity
1273
1274Test entity.
1275
1276## Specifies
1277
1278```
1279## Not A Section
1280- **USES**: [[not-a-link]]
1281```
1282
1283Real content after code block.
1284";
1285 let result = parse_markdown(md, "code-test.md", &spec_schema(), "specs").unwrap();
1286 assert!(!result.entity.sections.contains_key("not a section"));
1288 assert!(result.inline_links.is_empty());
1290 }
1291
1292 #[test]
1293 fn compute_hash_deterministic() {
1294 let hash1 = compute_hash("test content");
1295 let hash2 = compute_hash("test content");
1296 assert_eq!(hash1, hash2);
1297 assert_eq!(hash1.len(), 16);
1298 }
1299
1300 #[test]
1301 fn compute_hash_differs() {
1302 let hash1 = compute_hash("content a");
1303 let hash2 = compute_hash("content b");
1304 assert_ne!(hash1, hash2);
1305 }
1306
1307 #[test]
1308 fn is_float_literal_matches() {
1309 assert!(is_float_literal("0.85"));
1310 assert!(is_float_literal("-1.5"));
1311 assert!(is_float_literal("100.0"));
1312 assert!(!is_float_literal(".5"));
1313 assert!(!is_float_literal("1."));
1314 assert!(!is_float_literal("42"));
1315 assert!(!is_float_literal("hello"));
1316 }
1317
1318 #[test]
1319 fn is_integer_literal_matches() {
1320 assert!(is_integer_literal("42"));
1321 assert!(is_integer_literal("-1"));
1322 assert!(is_integer_literal("0"));
1323 assert!(!is_integer_literal("0.5"));
1324 assert!(!is_integer_literal("hello"));
1325 assert!(!is_integer_literal(""));
1326 }
1327
1328 #[test]
1334 fn parse_preserves_frontmatter_key_order() {
1335 let md = "\
1336---
1337type: principle
1338universality: domain-wide
1339authority: proposed
1340tags: a, b, c
1341created_date: 2026-01-15
1342last_modified: 2026-04-12
1343---
1344# Key Order
1345";
1346 let result = parse_markdown(
1347 md,
1348 "key-order.md",
1349 &type_by_name(builtin_names::PRINCIPLE).unwrap(),
1350 "knowledge",
1351 )
1352 .unwrap();
1353 let keys: Vec<&str> = result.entity.metadata.keys().map(|s| s.as_str()).collect();
1354 assert_eq!(
1355 keys,
1356 vec![
1357 "type",
1358 "universality",
1359 "authority",
1360 "tags",
1361 "created_date",
1362 "last_modified",
1363 ],
1364 "metadata iteration must preserve frontmatter declaration order"
1365 );
1366 }
1367
1368 #[test]
1376 fn parse_write_roundtrip_preserves_section_order() {
1377 let md = "\
1378---
1379type: spec
1380created_date: 2026-01-15
1381last_modified: 2026-04-12
1382level: M0
1383---
1384# Order Roundtrip
1385
1386## Identity
1387
1388Identity content.
1389
1390## Purpose
1391
1392Purpose content.
1393
1394## Specifies
1395
1396Specifies content.
1397";
1398 let schema = spec_schema();
1399 let first = parse_markdown(md, "order-roundtrip.md", &schema, "specs").unwrap();
1400 let regenerated = crate::entity::generator::generate_markdown(&first.entity, &schema);
1401 let second = parse_markdown(®enerated, "order-roundtrip.md", &schema, "specs").unwrap();
1402
1403 let first_keys: Vec<&String> = first.entity.sections.keys().collect();
1404 let second_keys: Vec<&String> = second.entity.sections.keys().collect();
1405 assert_eq!(
1406 first_keys, second_keys,
1407 "section iteration order must survive parse -> generate -> parse"
1408 );
1409 }
1410
1411 #[test]
1421 fn parser_extracts_single_h3() {
1422 let md = "\
1423---
1424type: spec
1425---
1426# Entity
1427
1428## Identity
1429
1430Body.
1431
1432## Specifies
1433
1434### Response Shapes
1435
1436Content under response shapes.
1437";
1438 let result = parse_markdown(md, "h3-single.md", &spec_schema(), "specs").unwrap();
1439 let spans = result
1440 .entity
1441 .heading_spans
1442 .get("specifies")
1443 .expect("specifies section should have spans");
1444 assert_eq!(spans.len(), 1);
1445 assert_eq!(spans[0].level, 3);
1446 assert_eq!(spans[0].title, "Response Shapes");
1447 assert_eq!(spans[0].start_offset, 0);
1449 let section = result.entity.sections.get("specifies").unwrap();
1450 assert_eq!(spans[0].end_offset, section.len());
1451 assert!(
1453 result
1454 .entity
1455 .heading_spans
1456 .get("identity")
1457 .is_none_or(Vec::is_empty)
1458 );
1459 }
1460
1461 #[test]
1462 fn parser_extracts_nested_h3_h4() {
1463 let md = "\
1464---
1465type: spec
1466---
1467# Entity
1468
1469## Identity
1470
1471Body.
1472
1473## Specifies
1474
1475### Outer
1476
1477Outer body.
1478
1479#### Inner
1480
1481Inner body.
1482";
1483 let result = parse_markdown(md, "h3-h4.md", &spec_schema(), "specs").unwrap();
1484 let spans = result.entity.heading_spans.get("specifies").unwrap();
1485 assert_eq!(spans.len(), 2, "both H3 and H4 must be recorded");
1486 assert_eq!(spans[0].level, 3);
1487 assert_eq!(spans[0].title, "Outer");
1488 assert_eq!(spans[1].level, 4);
1489 assert_eq!(spans[1].title, "Inner");
1490 assert!(
1491 spans[0].start_offset < spans[1].start_offset,
1492 "spans must be in document order"
1493 );
1494 assert!(
1496 spans[0].end_offset > spans[1].start_offset,
1497 "outer H3 must contain inner H4 by offset"
1498 );
1499 }
1500
1501 #[test]
1502 fn parser_ignores_headings_in_code_blocks() {
1503 let md = "\
1504---
1505type: spec
1506---
1507# Entity
1508
1509## Identity
1510
1511Body.
1512
1513## Specifies
1514
1515Prefix.
1516
1517```
1518### Not a heading
1519Still code.
1520```
1521
1522Suffix.
1523";
1524 let result = parse_markdown(md, "h3-code.md", &spec_schema(), "specs").unwrap();
1525 let spans = result
1526 .entity
1527 .heading_spans
1528 .get("specifies")
1529 .cloned()
1530 .unwrap_or_default();
1531 assert!(
1532 spans.is_empty(),
1533 "a '### ' inside a fenced block must not register as a heading span: {spans:?}"
1534 );
1535 }
1536
1537 #[test]
1538 fn parser_handles_level_skip() {
1539 let md = "\
1540---
1541type: spec
1542---
1543# Entity
1544
1545## Identity
1546
1547Body.
1548
1549## Specifies
1550
1551#### Skipped To H4
1552
1553Content under a sudden H4 — no virtual H3 is inserted.
1554";
1555 let result = parse_markdown(md, "h2-h4.md", &spec_schema(), "specs").unwrap();
1556 let spans = result.entity.heading_spans.get("specifies").unwrap();
1557 assert_eq!(spans.len(), 1);
1558 assert_eq!(spans[0].level, 4);
1559 assert_eq!(spans[0].title, "Skipped To H4");
1560 }
1561
1562 #[test]
1563 fn parser_handles_duplicate_siblings() {
1564 let md = "\
1565---
1566type: spec
1567---
1568# Entity
1569
1570## Identity
1571
1572Body.
1573
1574## Specifies
1575
1576### Same Title
1577
1578First occurrence body.
1579
1580### Same Title
1581
1582Second occurrence body.
1583";
1584 let result = parse_markdown(md, "h3-dup.md", &spec_schema(), "specs").unwrap();
1585 let spans = result.entity.heading_spans.get("specifies").unwrap();
1586 assert_eq!(spans.len(), 2, "duplicate siblings must produce two spans");
1587 assert_eq!(spans[0].title, spans[1].title);
1588 assert_ne!(
1589 spans[0].start_offset, spans[1].start_offset,
1590 "spans with identical titles must be distinguishable by offset"
1591 );
1592 assert!(
1594 spans[0].end_offset <= spans[1].start_offset,
1595 "first sibling must close before the second starts"
1596 );
1597 }
1598
1599 #[test]
1604 fn duplicate_declared_heading_two_populated_keeps_first_warns() {
1605 let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nfirst body\n\n## Identity\n\nsecond body\n";
1606 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1607 assert_eq!(
1608 result.entity.sections.get("identity").map(String::as_str),
1609 Some("first body"),
1610 "first body must win"
1611 );
1612 assert!(
1613 !result
1614 .entity
1615 .sections
1616 .get("identity")
1617 .unwrap()
1618 .contains("## Identity"),
1619 "storage value must not embed a duplicate heading"
1620 );
1621 assert_eq!(result.parse_warnings.len(), 1);
1622 match &result.parse_warnings[0] {
1623 crate::ops::WarningHint::DuplicateSectionHeading {
1624 section_key,
1625 heading,
1626 occurrences,
1627 ..
1628 } => {
1629 assert_eq!(section_key, "identity");
1630 assert_eq!(heading, "Identity");
1631 assert_eq!(*occurrences, 2);
1632 }
1633 other => panic!("expected DuplicateSectionHeading, got {other:?}"),
1634 }
1635 }
1636
1637 #[test]
1638 fn duplicate_declared_heading_blank_then_populated_keeps_blank() {
1639 let md =
1643 "---\ntype: spec\n---\n# Title\n\n## Identity\n\n## Identity\n\nleftover content\n";
1644 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1645 assert_eq!(
1646 result.entity.sections.get("identity").map(String::as_str),
1647 Some(""),
1648 "first (blank) occurrence wins; second body is dropped"
1649 );
1650 assert_eq!(result.parse_warnings.len(), 1);
1651 }
1652
1653 #[test]
1654 fn duplicate_declared_heading_three_occurrences() {
1655 let md = "---\ntype: spec\n---\n# Title\n\n## Constraints\n\nA\n\n## Constraints\n\n## Constraints\n\nC\n";
1656 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1657 assert_eq!(
1658 result
1659 .entity
1660 .sections
1661 .get("constraints")
1662 .map(String::as_str),
1663 Some("A"),
1664 );
1665 assert_eq!(result.parse_warnings.len(), 1);
1666 match &result.parse_warnings[0] {
1667 crate::ops::WarningHint::DuplicateSectionHeading { occurrences, .. } => {
1668 assert_eq!(*occurrences, 3);
1669 }
1670 _ => unreachable!(),
1671 }
1672 }
1673
1674 #[test]
1675 fn no_warning_when_each_declared_section_appears_once() {
1676 let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nID\n\n## Purpose\n\nP\n\n## Constraints\n\nC\n";
1677 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1678 assert!(result.parse_warnings.is_empty());
1679 }
1680
1681 #[test]
1682 fn no_warning_when_catch_all_section_repeats() {
1683 let md =
1686 "---\ntype: spec\n---\n# Title\n\n## Specifies\n\nfirst\n\n## Specifies\n\nsecond\n";
1687 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1688 assert!(
1689 result.parse_warnings.is_empty(),
1690 "catch-all repetition must not warn"
1691 );
1692 }
1693
1694 #[test]
1701 fn duplicate_realization_does_not_concatenate_headers_in_storage() {
1702 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";
1703 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1704 let catch_all = result.entity.sections.get("specifies").unwrap();
1705 let header_count = catch_all.matches("## Realization").count();
1706 assert!(
1707 header_count <= 1,
1708 "catch-all bucket must not contain multiple `## Realization` headers — got {header_count}: {catch_all:?}"
1709 );
1710 }
1711
1712 #[test]
1718 fn parse_render_round_trip_collapses_duplicate_headings() {
1719 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";
1720 let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
1721 let rendered = crate::render::render_entity_markdown(&result.entity, None);
1722 let identity_count = rendered.matches("## Identity").count();
1723 assert_eq!(
1724 identity_count, 1,
1725 "rendered output must carry exactly one `## Identity`, got {identity_count}: {rendered}"
1726 );
1727 assert!(rendered.contains("\n## Identity\n\nA\n"));
1729 assert!(!rendered.contains("C\n"), "second body must not survive");
1730 }
1731}