use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::sync::OnceLock;
use indexmap::IndexMap;
use regex::Regex;
use sha2::{Digest, Sha256};
use memstead_schema::TypeDefinition;
use super::id::{WikiLinkError, file_path_to_id, wiki_link_to_id, wiki_link_to_id_lenient};
use super::{Entity, EntityId, HeadingSpan, MetadataValue, ParseResult, Relationship};
pub fn parse_markdown(
content: &str,
relative_path: &str,
schema: &TypeDefinition,
mem: &str,
) -> Result<ParseResult, ParseError> {
let id = file_path_to_id(relative_path, mem);
let content_hash = compute_hash(content);
let masked = mask_code_blocks(content);
let (metadata, body, masked_body) = split_frontmatter(content, &masked)?;
let title = extract_title(&body).unwrap_or_else(|| id.name().to_string());
let (sections_map, duplicate_headings, raw_section_headings) =
split_sections(&body, &masked_body);
let rel_heading_key = "relationships";
let entity_id_for_rel_warnings = file_path_to_id(relative_path, mem);
let (relationships, rel_parse_warnings) = parse_relationships_with_warnings(
sections_map
.get(rel_heading_key)
.map(|s| s.as_str())
.unwrap_or(""),
mem,
Some(&entity_id_for_rel_warnings),
);
let catch_all_content = build_catch_all(§ions_map, schema);
let mut result_sections = IndexMap::new();
for s in &schema.sections {
if s.catch_all {
result_sections.insert(s.key.clone(), catch_all_content.trim().to_string());
} else {
let val = sections_map
.get(s.key.as_str())
.map(|v| v.trim().to_string())
.unwrap_or_default();
result_sections.insert(s.key.clone(), val);
}
}
let mut parsed_metadata = parse_metadata(&metadata);
let type_name = parsed_metadata
.get("type")
.and_then(|v| v.as_str())
.unwrap_or(schema.name.as_str())
.to_string();
parsed_metadata.insert("type".to_string(), MetadataValue::String(type_name.clone()));
let inline_link_text: String = schema
.text_fields
.iter()
.filter_map(|f| result_sections.get(f.as_str()))
.cloned()
.collect::<Vec<_>>()
.join("\n");
let inline_links = extract_inline_links_lenient(&inline_link_text, mem);
let explicit_targets: HashSet<_> = relationships.iter().map(|r| &r.target).collect();
let inline_links: Vec<EntityId> = inline_links
.into_iter()
.filter(|link| !explicit_targets.contains(link))
.collect();
let heading_spans = extract_heading_spans(&result_sections);
let declared_keys: HashSet<&str> = schema
.sections
.iter()
.filter(|s| !s.catch_all)
.map(|s| s.key.as_str())
.collect();
let entity_id_for_warnings = file_path_to_id(relative_path, mem);
let mut parse_warnings: Vec<crate::ops::WarningHint> = duplicate_headings
.into_iter()
.filter(|d| declared_keys.contains(d.key.as_str()))
.map(|d| crate::ops::WarningHint::DuplicateSectionHeading {
entity_id: entity_id_for_warnings.clone(),
section_key: d.key,
heading: d.heading,
occurrences: d.occurrences,
})
.collect();
parse_warnings.extend(rel_parse_warnings);
let entity = Entity {
id,
title,
entity_type: type_name,
mem: mem.to_string(),
file_path: relative_path.to_string(),
metadata: parsed_metadata,
sections: result_sections,
relationships,
content_hash,
stub: false,
stub_kind: None,
heading_spans,
raw_section_headings,
};
Ok(ParseResult {
entity,
inline_links,
parse_warnings,
})
}
pub fn parse_file(
path: &Path,
mem_dir: &Path,
schema: &TypeDefinition,
mem: &str,
) -> Result<ParseResult, ParseError> {
let content = std::fs::read_to_string(path)?;
let relative_path = path.strip_prefix(mem_dir).unwrap_or(path).to_string_lossy();
parse_markdown(&content, &relative_path, schema, mem)
}
pub fn peek_type_from_frontmatter(content: &str) -> Option<String> {
let after_open = if content.starts_with("---\r\n") {
5
} else if content.starts_with("---\n") {
4
} else {
return None;
};
let close_pos = content[after_open..].find("\n---")?;
let frontmatter = &content[after_open..after_open + close_pos];
for line in frontmatter.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let Some(colon_idx) = trimmed.find(':') else {
continue;
};
let key = trimmed[..colon_idx].trim();
if key != "type" {
continue;
}
let mut value = trimmed[colon_idx + 1..].trim();
if let Some(hash_idx) = value.find('#') {
value = value[..hash_idx].trim();
}
let value = value.trim_matches(|c| c == '"' || c == '\'');
if value.is_empty() {
return None;
}
return Some(value.to_string());
}
None
}
pub fn peek_title_and_type(content: &str) -> (Option<String>, Option<String>) {
let entity_type = peek_type_from_frontmatter(content);
let title = extract_title(body_after_frontmatter(content));
(title, entity_type)
}
fn body_after_frontmatter(content: &str) -> &str {
let after_open = if content.starts_with("---\r\n") {
5
} else if content.starts_with("---\n") {
4
} else {
return content;
};
let Some(close_pos) = content[after_open..].find("\n---") else {
return content;
};
let body_start = after_open + close_pos + 4; let rest = &content[body_start..];
rest.strip_prefix("\r\n")
.or_else(|| rest.strip_prefix('\n'))
.unwrap_or(rest)
}
fn split_frontmatter<'a>(
content: &'a str,
masked: &'a str,
) -> Result<(String, String, String), ParseError> {
if content.starts_with("---\n") || content.starts_with("---\r\n") {
let after_open = if content.starts_with("---\r\n") { 5 } else { 4 };
if let Some(close_pos) = content[after_open..].find("\n---") {
let meta_end = after_open + close_pos;
let metadata = content[after_open..meta_end].to_string();
let body_start = meta_end + 4; let body_start = if content[body_start..].starts_with('\n') {
body_start + 1
} else if content[body_start..].starts_with("\r\n") {
body_start + 2
} else {
body_start
};
let body = content[body_start..].to_string();
let masked_body = masked[body_start..].to_string();
return Ok((metadata, body, masked_body));
}
}
Ok((String::new(), content.to_string(), masked.to_string()))
}
fn parse_metadata(text: &str) -> IndexMap<String, MetadataValue> {
let mut meta = IndexMap::new();
if text.is_empty() {
return meta;
}
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("---") {
continue;
}
let Some(colon_idx) = trimmed.find(':') else {
continue;
};
let key = trimmed[..colon_idx].trim().to_string();
let raw_value = trimmed[colon_idx + 1..].trim();
let value = strip_inline_comment(raw_value).trim().to_string();
if value.is_empty() {
meta.insert(key, MetadataValue::String(String::new()));
continue;
}
if value == "true" {
meta.insert(key, MetadataValue::Bool(true));
} else if value == "false" {
meta.insert(key, MetadataValue::Bool(false));
} else if is_float_literal(&value) {
if let Ok(f) = value.parse::<f64>() {
meta.insert(key, MetadataValue::Float(f));
} else {
meta.insert(key, MetadataValue::String(strip_quotes(&value)));
}
} else if is_integer_literal(&value) {
if let Ok(n) = value.parse::<i64>() {
meta.insert(key, MetadataValue::Integer(n));
} else {
meta.insert(key, MetadataValue::String(strip_quotes(&value)));
}
} else {
meta.insert(key, MetadataValue::String(strip_quotes(&value)));
}
}
meta
}
fn is_float_literal(s: &str) -> bool {
let s = s.strip_prefix('-').unwrap_or(s);
if let Some((before, after)) = s.split_once('.') {
!before.is_empty()
&& before.chars().all(|c| c.is_ascii_digit())
&& !after.is_empty()
&& after.chars().all(|c| c.is_ascii_digit())
} else {
false
}
}
fn is_integer_literal(s: &str) -> bool {
let s = s.strip_prefix('-').unwrap_or(s);
!s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
}
pub(crate) fn would_coerce_from_string(s: &str) -> bool {
s == "true" || s == "false" || is_integer_literal(s) || is_float_literal(s)
}
fn strip_inline_comment(s: &str) -> &str {
if let Some(idx) = s.find(" #") {
s[..idx].trim_end()
} else {
s
}
}
fn strip_quotes(s: &str) -> String {
if s.len() >= 2
&& ((s.starts_with('"') && s.ends_with('"')) || (s.starts_with('\'') && s.ends_with('\'')))
{
s[1..s.len() - 1].to_string()
} else {
s.to_string()
}
}
pub fn mask_code_blocks(text: &str) -> String {
let lines: Vec<&str> = text.split('\n').collect();
let mut result = Vec::with_capacity(lines.len());
let mut fence: Option<String> = None;
for line in &lines {
if let Some(ref _f) = fence {
let trimmed = line.trim_end();
if trimmed.starts_with("```") {
result.push(" ".repeat(line.len()));
fence = None;
} else {
result.push(" ".repeat(line.len()));
}
} else {
if line.starts_with("```") {
fence = Some("```".to_string());
result.push(" ".repeat(line.len()));
} else {
result.push((*line).to_string());
}
}
}
result.join("\n")
}
pub(super) struct DuplicateSection {
pub key: String,
pub heading: String,
pub occurrences: usize,
}
pub(super) fn split_sections(
body: &str,
masked_body: &str,
) -> (HashMap<String, String>, Vec<DuplicateSection>, Vec<String>) {
let mut sections = HashMap::new();
let mut duplicates: HashMap<String, DuplicateSection> = HashMap::new();
let mut raw_headings = Vec::new();
static SECTION_RE: OnceLock<Regex> = OnceLock::new();
let section_re = SECTION_RE.get_or_init(|| Regex::new(r"(?m)^## (.+)$").unwrap());
let matches: Vec<_> = section_re.find_iter(masked_body).collect();
for (i, m) in matches.iter().enumerate() {
let heading_line = &body[m.start()..m.end()];
let name = heading_line
.strip_prefix("## ")
.unwrap_or(heading_line)
.trim();
let content_start = m.end();
let content_end = if i + 1 < matches.len() {
matches[i + 1].start()
} else {
body.len()
};
let content = body[content_start..content_end].trim().to_string();
let key = memstead_schema::derive_section_key(name);
raw_headings.push(name.to_string());
match sections.entry(key.clone()) {
std::collections::hash_map::Entry::Vacant(slot) => {
slot.insert(content);
duplicates.insert(
key.clone(),
DuplicateSection {
key: key.clone(),
heading: name.to_string(),
occurrences: 1,
},
);
}
std::collections::hash_map::Entry::Occupied(_) => {
if let Some(d) = duplicates.get_mut(&key) {
d.occurrences += 1;
}
}
}
}
let dup_list: Vec<DuplicateSection> = duplicates
.into_values()
.filter(|d| d.occurrences > 1)
.collect();
(sections, dup_list, raw_headings)
}
fn extract_title(body: &str) -> Option<String> {
for line in body.lines() {
if let Some(title) = line.strip_prefix("# ") {
return Some(title.trim().to_string());
}
}
None
}
fn extract_heading_spans(sections: &IndexMap<String, String>) -> HashMap<String, Vec<HeadingSpan>> {
static RE: OnceLock<Regex> = OnceLock::new();
let re = RE.get_or_init(|| Regex::new(r"(?m)^(#{3,6})[ \t]+(.+)$").unwrap());
let mut out: HashMap<String, Vec<HeadingSpan>> = HashMap::new();
for (key, content) in sections {
if content.is_empty() {
continue;
}
let masked = mask_code_blocks(content);
let raw: Vec<(usize, u8, String)> = re
.captures_iter(&masked)
.map(|cap| {
let whole = cap.get(0).unwrap();
let level = cap[1].len() as u8; let line_end = content[whole.start()..]
.find('\n')
.map(|i| whole.start() + i)
.unwrap_or(content.len());
let hashes_end = whole.start() + level as usize;
let title = content[hashes_end..line_end].trim().to_string();
(whole.start(), level, title)
})
.collect();
if raw.is_empty() {
continue;
}
let mut spans: Vec<HeadingSpan> = Vec::with_capacity(raw.len());
for (i, &(start, level, ref title)) in raw.iter().enumerate() {
let end = raw[i + 1..]
.iter()
.find(|(_, l, _)| *l <= level)
.map(|(s, _, _)| *s)
.unwrap_or(content.len());
spans.push(HeadingSpan {
level,
title: title.clone(),
start_offset: start,
end_offset: end,
});
}
out.insert(key.clone(), spans);
}
out
}
fn build_catch_all(sections: &HashMap<String, String>, schema: &TypeDefinition) -> String {
let catch_all = match schema.catch_all_section() {
Some(s) => s,
None => return String::new(),
};
let known_sections: HashSet<&str> = schema
.sections
.iter()
.map(|s| s.key.as_str())
.chain(std::iter::once("relationships"))
.collect();
let mut parts = Vec::new();
if let Some(content) = sections.get(catch_all.key.as_str())
&& !content.is_empty()
{
parts.push(content.clone());
}
for (key, content) in sections {
if !known_sections.contains(key.as_str()) && !content.is_empty() {
let heading = format!(
"## {}{}",
key.chars().next().unwrap_or_default().to_uppercase(),
&key[key.chars().next().map_or(0, |c| c.len_utf8())..]
);
parts.push(format!("{heading}\n{content}"));
}
}
parts.join("\n\n")
}
pub(crate) fn parse_relationships_with_warnings(
text: &str,
mem: &str,
entity_id: Option<&EntityId>,
) -> (Vec<Relationship>, Vec<crate::ops::WarningHint>) {
static RE: OnceLock<Regex> = OnceLock::new();
let re = RE.get_or_init(|| {
Regex::new(r"(?m)^\s*-\s*\*\*(\w+)\*\*:\s*\[\[([^\]]+)\]\](?P<tail>[^\n]*)").unwrap()
});
let mut relationships = Vec::new();
let mut warnings = Vec::new();
for cap in re.captures_iter(text) {
let rel_type = cap[1].to_uppercase();
let target = wiki_link_to_id_lenient(&cap[2], mem);
let tail = cap.name("tail").map(|m| m.as_str()).unwrap_or("");
let description = match classify_description_tail(tail) {
DescriptionTail::None => None,
DescriptionTail::EmDash(text) => Some(text),
DescriptionTail::Ambiguous(literal) => {
if let Some(id) = entity_id {
warnings.push(crate::ops::WarningHint::AmbiguousDescriptionDelimiter {
from: id.clone(),
rel_type: rel_type.clone(),
target: target.clone(),
trailing: literal,
});
}
None
}
};
relationships.push(Relationship {
rel_type,
target,
description,
});
}
(relationships, warnings)
}
enum DescriptionTail {
None,
EmDash(String),
Ambiguous(String),
}
fn classify_description_tail(tail: &str) -> DescriptionTail {
let trimmed_end = tail.trim_end();
if trimmed_end.is_empty() {
return DescriptionTail::None;
}
if let Some(rest) = trimmed_end.strip_prefix(" \u{2014} ") {
if rest.is_empty() {
return DescriptionTail::None;
}
return DescriptionTail::EmDash(rest.to_string());
}
if let Some(rest) = trimmed_end.strip_prefix(" \u{2014}") {
return DescriptionTail::Ambiguous(format!(" \u{2014}{rest}"));
}
let starters = [" --", " -", " \u{2013}", " \u{2212}"];
if starters
.iter()
.any(|prefix| trimmed_end.starts_with(prefix))
{
return DescriptionTail::Ambiguous(trimmed_end.to_string());
}
DescriptionTail::Ambiguous(trimmed_end.to_string())
}
#[derive(Debug, Clone)]
pub struct WikiLink {
pub target: String,
pub label: Option<String>,
}
fn wiki_link_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"\[\[([^\]]+)\]\]").unwrap())
}
fn inline_code_re() -> &'static Regex {
static RE: OnceLock<Regex> = OnceLock::new();
RE.get_or_init(|| Regex::new(r"`[^`]+`").unwrap())
}
pub fn extract_wiki_links(content: &str) -> Vec<WikiLink> {
let re = wiki_link_re();
re.captures_iter(content)
.map(|cap| {
let raw = &cap[1];
let (target, label) = match raw.find('|') {
Some(i) => (raw[..i].to_string(), Some(raw[i + 1..].to_string())),
None => (raw.to_string(), None),
};
WikiLink { target, label }
})
.collect()
}
pub(crate) fn extract_inline_links(
text: &str,
mem: &str,
) -> Result<Vec<EntityId>, Vec<WikiLinkError>> {
let stripped = mask_code_blocks(text);
let stripped = inline_code_re().replace_all(&stripped, "");
let link_re = wiki_link_re();
let mut seen = HashSet::new();
let mut links = Vec::new();
let mut errors = Vec::new();
for cap in link_re.captures_iter(&stripped) {
match wiki_link_to_id(&cap[1], mem) {
Ok(id) => {
if errors.is_empty() && seen.insert(id.0.clone()) {
links.push(id);
}
}
Err(e) => errors.push(e),
}
}
if errors.is_empty() {
Ok(links)
} else {
Err(errors)
}
}
pub fn extract_inline_links_lenient(text: &str, mem: &str) -> Vec<EntityId> {
let stripped = mask_code_blocks(text);
let stripped = inline_code_re().replace_all(&stripped, "");
let link_re = wiki_link_re();
let mut seen = HashSet::new();
let mut links = Vec::new();
for cap in link_re.captures_iter(&stripped) {
let id = wiki_link_to_id_lenient(&cap[1], mem);
if seen.insert(id.0.clone()) {
links.push(id);
}
}
links
}
pub fn compute_hash(content: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(content.as_bytes());
let result = hasher.finalize();
crate::hex_lower(&result)[..16].to_string()
}
#[derive(Debug, thiserror::Error)]
pub enum ParseError {
#[error("missing frontmatter")]
MissingFrontmatter,
#[error("invalid frontmatter: {0}")]
InvalidFrontmatter(String),
#[error("missing title")]
MissingTitle,
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
#[cfg(test)]
mod tests {
use super::*;
use memstead_schema::{builtin_names, type_by_name};
use std::sync::Arc;
fn spec_schema() -> Arc<TypeDefinition> {
type_by_name(builtin_names::SPEC).unwrap()
}
fn memo_schema() -> Arc<TypeDefinition> {
type_by_name(builtin_names::MEMO).unwrap()
}
#[test]
fn parse_metadata_types() {
let meta = parse_metadata("key: value\nnum: 42\nfloat: 0.85\nbool: true\nfalsy: false");
assert_eq!(meta["key"], MetadataValue::String("value".to_string()));
assert_eq!(meta["num"], MetadataValue::Integer(42));
assert_eq!(meta["float"], MetadataValue::Float(0.85));
assert_eq!(meta["bool"], MetadataValue::Bool(true));
assert_eq!(meta["falsy"], MetadataValue::Bool(false));
}
#[test]
fn parse_metadata_strips_comments() {
let meta = parse_metadata("key: value # this is a comment");
assert_eq!(meta["key"], MetadataValue::String("value".to_string()));
}
#[test]
fn parse_metadata_strips_quotes() {
let meta = parse_metadata("key: \"quoted value\"\nkey2: 'single'");
assert_eq!(
meta["key"],
MetadataValue::String("quoted value".to_string())
);
assert_eq!(meta["key2"], MetadataValue::String("single".to_string()));
}
#[test]
fn parse_metadata_survives_malformed_values() {
let meta = parse_metadata(
"key: \"\nkey2: '\nkey3: \"\"\nkey4: ''\nkey5: \"unterminated\nkey6: mixed'\"",
);
assert_eq!(meta["key"], MetadataValue::String("\"".to_string()));
assert_eq!(meta["key2"], MetadataValue::String("'".to_string()));
assert_eq!(meta["key3"], MetadataValue::String(String::new()));
assert_eq!(meta["key4"], MetadataValue::String(String::new()));
assert_eq!(
meta["key5"],
MetadataValue::String("\"unterminated".to_string())
);
assert_eq!(meta["key6"], MetadataValue::String("mixed'\"".to_string()));
let meta =
parse_metadata(":\n: value\nkey7: ✓\"\nkey8: 99999999999999999999999999\nkey9: -");
assert_eq!(meta["key7"], MetadataValue::String("✓\"".to_string()));
assert_eq!(
meta["key8"],
MetadataValue::String("99999999999999999999999999".to_string())
);
assert_eq!(meta["key9"], MetadataValue::String("-".to_string()));
}
#[test]
fn parse_metadata_skips_comments_and_empty() {
let meta = parse_metadata("# comment\n\nkey: val\n---");
assert_eq!(meta.len(), 1);
assert_eq!(meta["key"], MetadataValue::String("val".to_string()));
}
#[test]
fn peek_type_finds_value() {
let content = "---\ntype: memo\ntitle: Test\n---\n# Body\n";
assert_eq!(
peek_type_from_frontmatter(content),
Some("memo".to_string())
);
}
#[test]
fn peek_type_returns_none_when_missing() {
let content = "---\ntitle: Test\n---\n# Body\n";
assert_eq!(peek_type_from_frontmatter(content), None);
}
#[test]
fn peek_type_returns_none_without_frontmatter() {
let content = "# Just a heading\n\nBody with type: concept inside text.\n";
assert_eq!(peek_type_from_frontmatter(content), None);
}
#[test]
fn peek_type_handles_windows_line_endings() {
let content = "---\r\ntype: principle\r\n---\r\n# Body\r\n";
assert_eq!(
peek_type_from_frontmatter(content),
Some("principle".to_string())
);
}
#[test]
fn peek_type_strips_quotes_and_comments() {
let quoted = "---\ntype: \"concept\"\n---\n";
assert_eq!(
peek_type_from_frontmatter(quoted),
Some("concept".to_string())
);
let commented = "---\ntype: memo # kind of\n---\n";
assert_eq!(
peek_type_from_frontmatter(commented),
Some("memo".to_string())
);
}
#[test]
fn peek_type_empty_value_returns_none() {
let content = "---\ntype:\n---\n";
assert_eq!(peek_type_from_frontmatter(content), None);
}
#[test]
fn peek_type_ignores_legacy_schema_key() {
let content = concat!("---\n", "schema", ": memo\n---\n");
assert_eq!(peek_type_from_frontmatter(content), None);
}
#[test]
fn mask_code_blocks_basic() {
let input = "before\n```\ncode [[link]]\n```\nafter";
let masked = mask_code_blocks(input);
assert!(!masked.contains("[[link]]"));
assert!(masked.contains("before"));
assert!(masked.contains("after"));
}
#[test]
fn mask_code_blocks_preserves_line_count() {
let input = "line1\n```\ncode\nmore code\n```\nline6";
let masked = mask_code_blocks(input);
assert_eq!(input.lines().count(), masked.lines().count());
}
#[test]
fn mask_code_blocks_unclosed() {
let input = "before\n```\ncode\nmore code";
let masked = mask_code_blocks(input);
assert!(masked.contains("before"));
assert!(!masked.contains("code"));
}
#[test]
fn extract_wiki_links_basic() {
let links = extract_wiki_links("See [[target]] and [[other|label]]");
assert_eq!(links.len(), 2);
assert_eq!(links[0].target, "target");
assert_eq!(links[1].target, "other");
assert_eq!(links[1].label.as_deref(), Some("label"));
}
#[test]
fn parse_relationships_basic() {
let text = "- **USES**: [[target-entity]]\n- **PART_OF**: [[parent]]";
let rels = parse_relationships_with_warnings(text, "specs", None).0;
assert_eq!(rels.len(), 2);
assert_eq!(rels[0].rel_type, "USES");
assert_eq!(rels[0].target.0, "specs--target-entity");
assert_eq!(rels[1].rel_type, "PART_OF");
assert_eq!(rels[1].target.0, "specs--parent");
assert!(rels[0].description.is_none());
assert!(rels[1].description.is_none());
}
#[test]
fn parse_relationships_canonical_em_dash_captures_description() {
let text = "- **OTHER**: [[a]] \u{2014} replaced by checkout-flow";
let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
assert_eq!(rels.len(), 1);
assert_eq!(
rels[0].description.as_deref(),
Some("replaced by checkout-flow")
);
assert!(warnings.is_empty(), "canonical em-dash does not warn");
}
#[test]
fn parse_relationships_em_dash_inside_description_body() {
let text = "- **OTHER**: [[a]] \u{2014} note with — inside body";
let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
assert_eq!(rels.len(), 1);
assert_eq!(
rels[0].description.as_deref(),
Some("note with — inside body"),
"the parser captures up to end-of-line; em-dashes inside the body survive"
);
assert!(warnings.is_empty());
}
#[test]
fn parse_relationships_ambiguous_double_hyphen_warns_and_drops_content() {
let text = "- **USES**: [[a]] -- legacy delimiter";
let entity_id = EntityId::new("specs", "src");
let (rels, warnings) = parse_relationships_with_warnings(text, "specs", Some(&entity_id));
assert_eq!(rels.len(), 1);
assert!(rels[0].description.is_none(), "trailing content is dropped");
assert_eq!(warnings.len(), 1);
assert!(matches!(
warnings[0],
crate::ops::WarningHint::AmbiguousDescriptionDelimiter { .. }
));
}
#[test]
fn parse_relationships_ambiguous_single_hyphen_warns_and_drops_content() {
let text = "- **USES**: [[a]] - single hyphen";
let entity_id = EntityId::new("specs", "src");
let (rels, warnings) = parse_relationships_with_warnings(text, "specs", Some(&entity_id));
assert_eq!(rels.len(), 1);
assert!(rels[0].description.is_none());
assert_eq!(warnings.len(), 1);
assert_eq!(warnings[0].code(), "AMBIGUOUS_DESCRIPTION_DELIMITER");
}
#[test]
fn parse_relationships_hyphenated_slug_target_parses_unambiguously() {
let text = "- **USES**: [[some-slug-with-hyphens]] \u{2014} ok";
let (rels, warnings) = parse_relationships_with_warnings(text, "specs", None);
assert_eq!(rels.len(), 1);
assert_eq!(rels[0].target.path(), "some-slug-with-hyphens");
assert_eq!(rels[0].description.as_deref(), Some("ok"));
assert!(warnings.is_empty());
}
#[test]
fn parse_full_entity() {
let md = "\
---
type: spec
created_date: 2026-01-15
last_modified: 2026-04-12
level: M0
tags: backend, api
---
# Test Entity
## Identity
This is a test entity.
## Purpose
Testing the parser.
## Relationships
- **USES**: [[other-entity]]
## Specifies
Some specification content with [[inline-link]].
";
let result = parse_markdown(md, "test-entity.md", &spec_schema(), "specs").unwrap();
let entity = &result.entity;
assert_eq!(entity.id.0, "specs--test-entity");
assert_eq!(entity.title, "Test Entity");
assert_eq!(entity.mem, "specs");
assert_eq!(
entity.metadata["type"],
MetadataValue::String("spec".to_string())
);
assert_eq!(
entity.metadata["level"],
MetadataValue::String("M0".to_string())
);
assert_eq!(
entity.metadata["tags"],
MetadataValue::String("backend, api".to_string())
);
assert_eq!(entity.sections["identity"], "This is a test entity.");
assert_eq!(entity.sections["purpose"], "Testing the parser.");
assert_eq!(entity.relationships.len(), 1);
assert_eq!(entity.relationships[0].rel_type, "USES");
assert_eq!(entity.relationships[0].target.0, "specs--other-entity");
assert_eq!(result.inline_links.len(), 1);
assert_eq!(result.inline_links[0].0, "specs--inline-link");
}
#[test]
fn parse_full_entity_memo_schema() {
let md = "\
---
type: memo
created_date: 2026-01-15
last_modified: 2026-04-12
status: active
tags: decision, architecture
---
# Use Sled For Storage
## Claim
Sled is the right embedded store for this workload.
## Context
We evaluated sled, rocksdb, and sqlite for the in-process graph cache.
## Substance
Sled wins on pure-Rust dependency footprint.
";
let result = parse_markdown(md, "use-sled.md", &memo_schema(), "memos").unwrap();
let entity = &result.entity;
assert_eq!(entity.id.0, "memos--use-sled");
assert_eq!(entity.title, "Use Sled For Storage");
assert_eq!(entity.mem, "memos");
assert_eq!(
entity.metadata["type"],
MetadataValue::String("memo".to_string())
);
assert_eq!(
entity.metadata["status"],
MetadataValue::String("active".to_string())
);
assert_eq!(
entity.sections["claim"],
"Sled is the right embedded store for this workload."
);
assert_eq!(
entity.sections["context"],
"We evaluated sled, rocksdb, and sqlite for the in-process graph cache."
);
assert_eq!(
entity.sections["substance"],
"Sled wins on pure-Rust dependency footprint."
);
assert!(!entity.sections.contains_key("identity"));
assert!(!entity.sections.contains_key("purpose"));
}
#[test]
fn parse_entity_without_frontmatter() {
let md = "# No Frontmatter\n\n## Identity\n\nJust a title and section.";
let result = parse_markdown(md, "no-fm.md", &spec_schema(), "specs").unwrap();
assert_eq!(result.entity.title, "No Frontmatter");
assert_eq!(result.entity.metadata.len(), 1);
assert_eq!(
result.entity.metadata.get("type"),
Some(&MetadataValue::String("spec".to_string()))
);
}
#[test]
fn parse_entity_code_blocks_not_detected() {
let md = "\
---
type: spec
---
# Code Test
## Identity
Test entity.
## Specifies
```
## Not A Section
- **USES**: [[not-a-link]]
```
Real content after code block.
";
let result = parse_markdown(md, "code-test.md", &spec_schema(), "specs").unwrap();
assert!(!result.entity.sections.contains_key("not a section"));
assert!(result.inline_links.is_empty());
}
#[test]
fn compute_hash_deterministic() {
let hash1 = compute_hash("test content");
let hash2 = compute_hash("test content");
assert_eq!(hash1, hash2);
assert_eq!(hash1.len(), 16);
}
#[test]
fn compute_hash_differs() {
let hash1 = compute_hash("content a");
let hash2 = compute_hash("content b");
assert_ne!(hash1, hash2);
}
#[test]
fn is_float_literal_matches() {
assert!(is_float_literal("0.85"));
assert!(is_float_literal("-1.5"));
assert!(is_float_literal("100.0"));
assert!(!is_float_literal(".5"));
assert!(!is_float_literal("1."));
assert!(!is_float_literal("42"));
assert!(!is_float_literal("hello"));
}
#[test]
fn is_integer_literal_matches() {
assert!(is_integer_literal("42"));
assert!(is_integer_literal("-1"));
assert!(is_integer_literal("0"));
assert!(!is_integer_literal("0.5"));
assert!(!is_integer_literal("hello"));
assert!(!is_integer_literal(""));
}
#[test]
fn parse_preserves_frontmatter_key_order() {
let md = "\
---
type: principle
universality: domain-wide
authority: proposed
tags: a, b, c
created_date: 2026-01-15
last_modified: 2026-04-12
---
# Key Order
";
let result = parse_markdown(
md,
"key-order.md",
&type_by_name(builtin_names::PRINCIPLE).unwrap(),
"knowledge",
)
.unwrap();
let keys: Vec<&str> = result.entity.metadata.keys().map(|s| s.as_str()).collect();
assert_eq!(
keys,
vec![
"type",
"universality",
"authority",
"tags",
"created_date",
"last_modified",
],
"metadata iteration must preserve frontmatter declaration order"
);
}
#[test]
fn parse_write_roundtrip_preserves_section_order() {
let md = "\
---
type: spec
created_date: 2026-01-15
last_modified: 2026-04-12
level: M0
---
# Order Roundtrip
## Identity
Identity content.
## Purpose
Purpose content.
## Specifies
Specifies content.
";
let schema = spec_schema();
let first = parse_markdown(md, "order-roundtrip.md", &schema, "specs").unwrap();
let regenerated = crate::entity::generator::generate_markdown(&first.entity, &schema);
let second = parse_markdown(®enerated, "order-roundtrip.md", &schema, "specs").unwrap();
let first_keys: Vec<&String> = first.entity.sections.keys().collect();
let second_keys: Vec<&String> = second.entity.sections.keys().collect();
assert_eq!(
first_keys, second_keys,
"section iteration order must survive parse -> generate -> parse"
);
}
#[test]
fn parser_extracts_single_h3() {
let md = "\
---
type: spec
---
# Entity
## Identity
Body.
## Specifies
### Response Shapes
Content under response shapes.
";
let result = parse_markdown(md, "h3-single.md", &spec_schema(), "specs").unwrap();
let spans = result
.entity
.heading_spans
.get("specifies")
.expect("specifies section should have spans");
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].level, 3);
assert_eq!(spans[0].title, "Response Shapes");
assert_eq!(spans[0].start_offset, 0);
let section = result.entity.sections.get("specifies").unwrap();
assert_eq!(spans[0].end_offset, section.len());
assert!(
result
.entity
.heading_spans
.get("identity")
.is_none_or(Vec::is_empty)
);
}
#[test]
fn parser_extracts_nested_h3_h4() {
let md = "\
---
type: spec
---
# Entity
## Identity
Body.
## Specifies
### Outer
Outer body.
#### Inner
Inner body.
";
let result = parse_markdown(md, "h3-h4.md", &spec_schema(), "specs").unwrap();
let spans = result.entity.heading_spans.get("specifies").unwrap();
assert_eq!(spans.len(), 2, "both H3 and H4 must be recorded");
assert_eq!(spans[0].level, 3);
assert_eq!(spans[0].title, "Outer");
assert_eq!(spans[1].level, 4);
assert_eq!(spans[1].title, "Inner");
assert!(
spans[0].start_offset < spans[1].start_offset,
"spans must be in document order"
);
assert!(
spans[0].end_offset > spans[1].start_offset,
"outer H3 must contain inner H4 by offset"
);
}
#[test]
fn parser_ignores_headings_in_code_blocks() {
let md = "\
---
type: spec
---
# Entity
## Identity
Body.
## Specifies
Prefix.
```
### Not a heading
Still code.
```
Suffix.
";
let result = parse_markdown(md, "h3-code.md", &spec_schema(), "specs").unwrap();
let spans = result
.entity
.heading_spans
.get("specifies")
.cloned()
.unwrap_or_default();
assert!(
spans.is_empty(),
"a '### ' inside a fenced block must not register as a heading span: {spans:?}"
);
}
#[test]
fn parser_handles_level_skip() {
let md = "\
---
type: spec
---
# Entity
## Identity
Body.
## Specifies
#### Skipped To H4
Content under a sudden H4 — no virtual H3 is inserted.
";
let result = parse_markdown(md, "h2-h4.md", &spec_schema(), "specs").unwrap();
let spans = result.entity.heading_spans.get("specifies").unwrap();
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].level, 4);
assert_eq!(spans[0].title, "Skipped To H4");
}
#[test]
fn parser_handles_duplicate_siblings() {
let md = "\
---
type: spec
---
# Entity
## Identity
Body.
## Specifies
### Same Title
First occurrence body.
### Same Title
Second occurrence body.
";
let result = parse_markdown(md, "h3-dup.md", &spec_schema(), "specs").unwrap();
let spans = result.entity.heading_spans.get("specifies").unwrap();
assert_eq!(spans.len(), 2, "duplicate siblings must produce two spans");
assert_eq!(spans[0].title, spans[1].title);
assert_ne!(
spans[0].start_offset, spans[1].start_offset,
"spans with identical titles must be distinguishable by offset"
);
assert!(
spans[0].end_offset <= spans[1].start_offset,
"first sibling must close before the second starts"
);
}
#[test]
fn duplicate_declared_heading_two_populated_keeps_first_warns() {
let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nfirst body\n\n## Identity\n\nsecond body\n";
let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
assert_eq!(
result.entity.sections.get("identity").map(String::as_str),
Some("first body"),
"first body must win"
);
assert!(
!result
.entity
.sections
.get("identity")
.unwrap()
.contains("## Identity"),
"storage value must not embed a duplicate heading"
);
assert_eq!(result.parse_warnings.len(), 1);
match &result.parse_warnings[0] {
crate::ops::WarningHint::DuplicateSectionHeading {
section_key,
heading,
occurrences,
..
} => {
assert_eq!(section_key, "identity");
assert_eq!(heading, "Identity");
assert_eq!(*occurrences, 2);
}
other => panic!("expected DuplicateSectionHeading, got {other:?}"),
}
}
#[test]
fn duplicate_declared_heading_blank_then_populated_keeps_blank() {
let md =
"---\ntype: spec\n---\n# Title\n\n## Identity\n\n## Identity\n\nleftover content\n";
let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
assert_eq!(
result.entity.sections.get("identity").map(String::as_str),
Some(""),
"first (blank) occurrence wins; second body is dropped"
);
assert_eq!(result.parse_warnings.len(), 1);
}
#[test]
fn duplicate_declared_heading_three_occurrences() {
let md = "---\ntype: spec\n---\n# Title\n\n## Constraints\n\nA\n\n## Constraints\n\n## Constraints\n\nC\n";
let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
assert_eq!(
result
.entity
.sections
.get("constraints")
.map(String::as_str),
Some("A"),
);
assert_eq!(result.parse_warnings.len(), 1);
match &result.parse_warnings[0] {
crate::ops::WarningHint::DuplicateSectionHeading { occurrences, .. } => {
assert_eq!(*occurrences, 3);
}
_ => unreachable!(),
}
}
#[test]
fn no_warning_when_each_declared_section_appears_once() {
let md = "---\ntype: spec\n---\n# Title\n\n## Identity\n\nID\n\n## Purpose\n\nP\n\n## Constraints\n\nC\n";
let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
assert!(result.parse_warnings.is_empty());
}
#[test]
fn no_warning_when_catch_all_section_repeats() {
let md =
"---\ntype: spec\n---\n# Title\n\n## Specifies\n\nfirst\n\n## Specifies\n\nsecond\n";
let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
assert!(
result.parse_warnings.is_empty(),
"catch-all repetition must not warn"
);
}
#[test]
fn duplicate_realization_does_not_concatenate_headers_in_storage() {
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";
let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
let catch_all = result.entity.sections.get("specifies").unwrap();
let header_count = catch_all.matches("## Realization").count();
assert!(
header_count <= 1,
"catch-all bucket must not contain multiple `## Realization` headers — got {header_count}: {catch_all:?}"
);
}
#[test]
fn parse_render_round_trip_collapses_duplicate_headings() {
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";
let result = parse_markdown(md, "x.md", &spec_schema(), "v").unwrap();
let rendered = crate::render::render_entity_markdown(&result.entity, None);
let identity_count = rendered.matches("## Identity").count();
assert_eq!(
identity_count, 1,
"rendered output must carry exactly one `## Identity`, got {identity_count}: {rendered}"
);
assert!(rendered.contains("\n## Identity\n\nA\n"));
assert!(!rendered.contains("C\n"), "second body must not survive");
}
}