use std::collections::HashMap;
use std::str::FromStr;
use crate::error::ParseError;
use crate::value::QuillValue;
use crate::version::QuillReference;
pub const BODY_FIELD: &str = "BODY";
#[derive(Debug, Clone)]
pub struct ParsedDocument {
fields: HashMap<String, QuillValue>,
quill_ref: QuillReference,
}
impl ParsedDocument {
pub fn new(fields: HashMap<String, QuillValue>, quill_ref: QuillReference) -> Self {
Self { fields, quill_ref }
}
pub fn from_markdown(markdown: &str) -> Result<Self, crate::error::ParseError> {
decompose(markdown)
}
pub fn quill_reference(&self) -> &QuillReference {
&self.quill_ref
}
pub fn body(&self) -> Option<&str> {
self.fields.get(BODY_FIELD).and_then(|v| v.as_str())
}
pub fn get_field(&self, name: &str) -> Option<&QuillValue> {
self.fields.get(name)
}
pub fn fields(&self) -> &HashMap<String, QuillValue> {
&self.fields
}
pub fn with_defaults(&self, defaults: &HashMap<String, QuillValue>) -> Self {
let mut fields = self.fields.clone();
for (field_name, default_value) in defaults {
if !fields.contains_key(field_name) {
fields.insert(field_name.clone(), default_value.clone());
}
}
Self {
fields,
quill_ref: self.quill_ref.clone(),
}
}
pub fn with_coercion(&self, schema: &QuillValue) -> Self {
use crate::schema::coerce_document;
let coerced_fields = coerce_document(schema, &self.fields);
Self {
fields: coerced_fields,
quill_ref: self.quill_ref.clone(),
}
}
}
#[derive(Debug)]
struct MetadataBlock {
start: usize, end: usize, yaml_value: Option<serde_json::Value>, tag: Option<String>, quill_ref: Option<String>, }
fn is_valid_tag_name(name: &str) -> bool {
if name.is_empty() {
return false;
}
let mut chars = name.chars();
let first = chars.next().unwrap();
if !first.is_ascii_lowercase() && first != '_' {
return false;
}
for ch in chars {
if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && ch != '_' {
return false;
}
}
true
}
fn is_inside_fenced_block(markdown: &str, pos: usize) -> bool {
let before = &markdown[..pos];
let mut in_fence = false;
if is_exact_fence_at(before, 0) {
in_fence = !in_fence;
}
for (i, _) in before.match_indices('\n') {
if is_exact_fence_at(before, i + 1) {
in_fence = !in_fence;
}
}
in_fence
}
fn is_exact_fence_at(text: &str, pos: usize) -> bool {
if pos >= text.len() {
return false;
}
let remaining = &text[pos..];
if !remaining.starts_with("```") {
return false;
}
remaining.len() == 3 || remaining.as_bytes().get(3) != Some(&b'`')
}
fn yaml_parse_options() -> serde_saphyr::Options {
let budget = serde_saphyr::Budget {
max_depth: crate::error::MAX_YAML_DEPTH,
..Default::default()
};
serde_saphyr::Options {
budget: Some(budget),
..Default::default()
}
}
fn find_metadata_blocks(markdown: &str) -> Result<Vec<MetadataBlock>, crate::error::ParseError> {
let mut blocks = Vec::new();
let mut pos = 0;
while pos < markdown.len() {
let search_str = &markdown[pos..];
let delimiter_result = search_str
.find("---\n")
.map(|p| (p, 4, "\n"))
.or_else(|| search_str.find("---\r\n").map(|p| (p, 5, "\r\n")));
if let Some((delimiter_pos, delimiter_len, _line_ending)) = delimiter_result {
let abs_pos = pos + delimiter_pos;
let is_start_of_line = if abs_pos == 0 {
true
} else {
let char_before = markdown.as_bytes()[abs_pos - 1];
char_before == b'\n' || char_before == b'\r'
};
if !is_start_of_line {
pos = abs_pos + 1;
continue;
}
if is_inside_fenced_block(markdown, abs_pos) {
pos = abs_pos + 3;
continue;
}
let content_start = abs_pos + delimiter_len;
let preceded_by_blank = if abs_pos > 0 {
let before = &markdown[..abs_pos];
before.ends_with("\n\n") || before.ends_with("\r\n\r\n")
} else {
false
};
let followed_by_blank = if content_start < markdown.len() {
markdown[content_start..].starts_with('\n')
|| markdown[content_start..].starts_with("\r\n")
} else {
false
};
if preceded_by_blank && followed_by_blank {
pos = abs_pos + 3; continue;
}
if followed_by_blank {
pos = abs_pos + 3;
continue;
}
let rest = &markdown[content_start..];
let closing_patterns = ["\n---\n", "\r\n---\r\n", "\n---\r\n", "\r\n---\n"];
let closing_with_newline = closing_patterns
.iter()
.filter_map(|delim| rest.find(delim).map(|p| (p, delim.len())))
.min_by_key(|(p, _)| *p);
let closing_at_eof = ["\n---", "\r\n---"]
.iter()
.filter_map(|delim| {
rest.find(delim).and_then(|p| {
if p + delim.len() == rest.len() {
Some((p, delim.len()))
} else {
None
}
})
})
.min_by_key(|(p, _)| *p);
let closing_result = match (closing_with_newline, closing_at_eof) {
(Some((p1, _l1)), Some((p2, _))) if p2 < p1 => closing_at_eof,
(Some(_), Some(_)) => closing_with_newline,
(Some(_), None) => closing_with_newline,
(None, Some(_)) => closing_at_eof,
(None, None) => None,
};
if let Some((closing_pos, closing_len)) = closing_result {
let abs_closing_pos = content_start + closing_pos;
let content = &markdown[content_start..abs_closing_pos];
if content.len() > crate::error::MAX_YAML_SIZE {
return Err(crate::error::ParseError::InputTooLarge {
size: content.len(),
max: crate::error::MAX_YAML_SIZE,
});
}
let content = content.trim();
let (tag, quill_ref, yaml_value) = if !content.is_empty() {
match serde_saphyr::from_str_with_options::<serde_json::Value>(
content,
yaml_parse_options(),
) {
Ok(parsed_yaml) => {
if let Some(mapping) = parsed_yaml.as_object() {
let quill_key = "QUILL";
let card_key = "CARD";
let has_quill = mapping.contains_key(quill_key);
let has_card = mapping.contains_key(card_key);
if has_quill && has_card {
return Err(crate::error::ParseError::InvalidStructure(
"Cannot specify both QUILL and CARD in the same block"
.to_string(),
));
}
const RESERVED_FIELDS: &[&str] = &["BODY", "CARDS"];
for reserved in RESERVED_FIELDS {
if mapping.contains_key(*reserved) {
return Err(crate::error::ParseError::InvalidStructure(
format!(
"Reserved field name '{}' cannot be used in YAML frontmatter",
reserved
),
));
}
}
if has_quill {
let quill_value = mapping.get(quill_key).unwrap();
let quill_ref_str = quill_value
.as_str()
.ok_or("QUILL value must be a string")?;
let _quill_ref =
quill_ref_str.parse::<QuillReference>().map_err(|e| {
crate::error::ParseError::InvalidStructure(format!(
"Invalid QUILL reference '{}': {}",
quill_ref_str, e
))
})?;
let mut new_mapping = mapping.clone();
new_mapping.remove(quill_key);
let new_value = if new_mapping.is_empty() {
None
} else {
Some(serde_json::Value::Object(new_mapping))
};
(None, Some(quill_ref_str.to_string()), new_value)
} else if has_card {
let card_value = mapping.get(card_key).unwrap();
let field_name =
card_value.as_str().ok_or("CARD value must be a string")?;
if !is_valid_tag_name(field_name) {
return Err(crate::error::ParseError::InvalidStructure(format!(
"Invalid card field name '{}': must match pattern [a-z_][a-z0-9_]*",
field_name
)));
}
let mut new_mapping = mapping.clone();
new_mapping.remove(card_key);
let new_value = if new_mapping.is_empty() {
None
} else {
Some(serde_json::Value::Object(new_mapping))
};
(Some(field_name.to_string()), None, new_value)
} else {
(None, None, Some(parsed_yaml))
}
} else {
(None, None, Some(parsed_yaml))
}
}
Err(e) => {
let block_start_line = markdown[..abs_pos].lines().count() + 1;
return Err(crate::error::ParseError::YamlErrorWithLocation {
message: e.to_string(),
line: block_start_line,
block_index: blocks.len(),
});
}
}
} else {
(None, None, None)
};
blocks.push(MetadataBlock {
start: abs_pos,
end: abs_closing_pos + closing_len, yaml_value,
tag,
quill_ref,
});
if blocks.len() > crate::error::MAX_CARD_COUNT {
return Err(crate::error::ParseError::InputTooLarge {
size: blocks.len(),
max: crate::error::MAX_CARD_COUNT,
});
}
pos = abs_closing_pos + closing_len;
} else if abs_pos == 0 {
return Err(crate::error::ParseError::InvalidStructure(
"Frontmatter started but not closed with ---".to_string(),
));
} else {
pos = abs_pos + 3;
}
} else {
break;
}
}
Ok(blocks)
}
fn decompose(markdown: &str) -> Result<ParsedDocument, crate::error::ParseError> {
if markdown.len() > crate::error::MAX_INPUT_SIZE {
return Err(crate::error::ParseError::InputTooLarge {
size: markdown.len(),
max: crate::error::MAX_INPUT_SIZE,
});
}
let mut fields = HashMap::new();
let blocks = find_metadata_blocks(markdown)?;
if blocks.is_empty() {
return Err(crate::error::ParseError::InvalidStructure(
"Missing required QUILL field. Add `QUILL: <name>` to the frontmatter.".to_string(),
));
}
let mut cards_array: Vec<serde_json::Value> = Vec::new();
let mut global_frontmatter_index: Option<usize> = None;
let mut quill_ref: Option<String> = None;
for (idx, block) in blocks.iter().enumerate() {
if idx == 0 {
if let Some(ref name) = block.quill_ref {
quill_ref = Some(name.clone());
}
if block.tag.is_none() && block.quill_ref.is_none() {
global_frontmatter_index = Some(idx);
}
} else {
if block.quill_ref.is_some() {
return Err(crate::error::ParseError::InvalidStructure("QUILL directive can only appear in the top-level frontmatter, not in inline blocks. Use CARD instead.".to_string()));
}
if block.tag.is_none() {
return Err(crate::error::ParseError::missing_card_directive());
}
}
}
if let Some(idx) = global_frontmatter_index {
let block = &blocks[idx];
let json_fields: HashMap<String, serde_json::Value> = match &block.yaml_value {
Some(serde_json::Value::Object(mapping)) => mapping
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
Some(serde_json::Value::Null) => {
HashMap::new()
}
Some(_) => {
return Err(crate::error::ParseError::InvalidStructure(
"Invalid YAML frontmatter: expected a mapping".to_string(),
));
}
None => HashMap::new(),
};
for (key, value) in json_fields {
fields.insert(key, QuillValue::from_json(value));
}
}
for block in &blocks {
if block.quill_ref.is_some() {
if let Some(ref json_val) = block.yaml_value {
let json_fields: HashMap<String, serde_json::Value> = match json_val {
serde_json::Value::Object(mapping) => mapping
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
serde_json::Value::Null => {
HashMap::new()
}
_ => {
return Err(crate::error::ParseError::InvalidStructure(
"Invalid YAML in quill block: expected a mapping".to_string(),
));
}
};
for key in json_fields.keys() {
if fields.contains_key(key) {
return Err(crate::error::ParseError::InvalidStructure(format!(
"Name collision: quill block field '{}' conflicts with existing field",
key
)));
}
}
for (key, value) in json_fields {
fields.insert(key, QuillValue::from_json(value));
}
}
}
}
for (idx, block) in blocks.iter().enumerate() {
if let Some(ref tag_name) = block.tag {
let mut item_fields: serde_json::Map<String, serde_json::Value> =
match &block.yaml_value {
Some(serde_json::Value::Object(mapping)) => mapping.clone(),
Some(serde_json::Value::Null) => {
serde_json::Map::new()
}
Some(_) => {
return Err(crate::error::ParseError::InvalidStructure(format!(
"Invalid YAML in card block '{}': expected a mapping",
tag_name
)));
}
None => serde_json::Map::new(),
};
let body_start = block.end;
let body_end = if idx + 1 < blocks.len() {
blocks[idx + 1].start
} else {
markdown.len()
};
let body = &markdown[body_start..body_end];
item_fields.insert(
BODY_FIELD.to_string(),
serde_json::Value::String(body.to_string()),
);
item_fields.insert(
"CARD".to_string(),
serde_json::Value::String(tag_name.clone()),
);
cards_array.push(serde_json::Value::Object(item_fields));
}
}
let first_non_card_block_idx = blocks
.iter()
.position(|b| b.tag.is_none() && b.quill_ref.is_none())
.or_else(|| blocks.iter().position(|b| b.quill_ref.is_some()));
let (body_start, body_end) = if let Some(idx) = first_non_card_block_idx {
let start = blocks[idx].end;
let end = blocks
.iter()
.skip(idx + 1)
.find(|b| b.tag.is_some())
.map(|b| b.start)
.unwrap_or(markdown.len());
(start, end)
} else {
let end = blocks
.iter()
.find(|b| b.tag.is_some())
.map(|b| b.start)
.unwrap_or(0);
(0, end)
};
let global_body = &markdown[body_start..body_end];
fields.insert(
BODY_FIELD.to_string(),
QuillValue::from_json(serde_json::Value::String(global_body.to_string())),
);
fields.insert(
"CARDS".to_string(),
QuillValue::from_json(serde_json::Value::Array(cards_array)),
);
if fields.len() > crate::error::MAX_FIELD_COUNT {
return Err(crate::error::ParseError::InputTooLarge {
size: fields.len(),
max: crate::error::MAX_FIELD_COUNT,
});
}
let quill_tag = quill_ref.ok_or_else(|| {
ParseError::InvalidStructure(
"Missing required QUILL field. Add `QUILL: <name>` to the frontmatter.".to_string(),
)
})?;
let quill_ref = QuillReference::from_str(&quill_tag).map_err(|e| {
ParseError::InvalidStructure(format!("Invalid QUILL tag '{}': {}", quill_tag, e))
})?;
let parsed = ParsedDocument::new(fields, quill_ref);
Ok(parsed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_no_frontmatter() {
let markdown = "# Hello World\n\nThis is a test.";
let result = decompose(markdown);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Missing required QUILL field"));
}
#[test]
fn test_with_frontmatter() {
let markdown = r#"---
QUILL: test_quill
title: Test Document
author: Test Author
---
# Hello World
This is the body."#;
let doc = decompose(markdown).unwrap();
assert_eq!(doc.body(), Some("\n# Hello World\n\nThis is the body."));
assert_eq!(
doc.get_field("title").unwrap().as_str().unwrap(),
"Test Document"
);
assert_eq!(
doc.get_field("author").unwrap().as_str().unwrap(),
"Test Author"
);
assert_eq!(doc.fields().len(), 4); assert_eq!(doc.quill_reference().name, "test_quill");
}
#[test]
fn test_whitespace_frontmatter() {
let markdown = "---\n \n---\n\n# Hello";
let result = decompose(markdown);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Missing required QUILL field"));
}
#[test]
fn test_complex_yaml_frontmatter() {
let markdown = r#"---
QUILL: test_quill
title: Complex Document
tags:
- test
- yaml
metadata:
version: 1.0
nested:
field: value
---
Content here."#;
let doc = decompose(markdown).unwrap();
assert_eq!(doc.body(), Some("\nContent here."));
assert_eq!(
doc.get_field("title").unwrap().as_str().unwrap(),
"Complex Document"
);
let tags = doc.get_field("tags").unwrap().as_sequence().unwrap();
assert_eq!(tags.len(), 2);
assert_eq!(tags[0].as_str().unwrap(), "test");
assert_eq!(tags[1].as_str().unwrap(), "yaml");
}
#[test]
fn test_with_defaults_empty_document() {
use std::collections::HashMap;
let mut defaults = HashMap::new();
defaults.insert(
"status".to_string(),
QuillValue::from_json(serde_json::json!("draft")),
);
defaults.insert(
"version".to_string(),
QuillValue::from_json(serde_json::json!(1)),
);
let doc = ParsedDocument::new(HashMap::new(), QuillReference::latest("test".to_string()));
let doc_with_defaults = doc.with_defaults(&defaults);
assert_eq!(
doc_with_defaults
.get_field("status")
.unwrap()
.as_str()
.unwrap(),
"draft"
);
assert_eq!(
doc_with_defaults
.get_field("version")
.unwrap()
.as_number()
.unwrap()
.as_i64()
.unwrap(),
1
);
}
#[test]
fn test_with_defaults_preserves_existing_values() {
use std::collections::HashMap;
let mut defaults = HashMap::new();
defaults.insert(
"status".to_string(),
QuillValue::from_json(serde_json::json!("draft")),
);
let mut fields = HashMap::new();
fields.insert(
"status".to_string(),
QuillValue::from_json(serde_json::json!("published")),
);
let doc = ParsedDocument::new(fields, QuillReference::latest("test".to_string()));
let doc_with_defaults = doc.with_defaults(&defaults);
assert_eq!(
doc_with_defaults
.get_field("status")
.unwrap()
.as_str()
.unwrap(),
"published"
);
}
#[test]
fn test_with_defaults_partial_application() {
use std::collections::HashMap;
let mut defaults = HashMap::new();
defaults.insert(
"status".to_string(),
QuillValue::from_json(serde_json::json!("draft")),
);
defaults.insert(
"version".to_string(),
QuillValue::from_json(serde_json::json!(1)),
);
let mut fields = HashMap::new();
fields.insert(
"status".to_string(),
QuillValue::from_json(serde_json::json!("published")),
);
let doc = ParsedDocument::new(fields, QuillReference::latest("test".to_string()));
let doc_with_defaults = doc.with_defaults(&defaults);
assert_eq!(
doc_with_defaults
.get_field("status")
.unwrap()
.as_str()
.unwrap(),
"published"
);
assert_eq!(
doc_with_defaults
.get_field("version")
.unwrap()
.as_number()
.unwrap()
.as_i64()
.unwrap(),
1
);
}
#[test]
fn test_with_defaults_no_defaults() {
use std::collections::HashMap;
let defaults = HashMap::new();
let doc = ParsedDocument::new(HashMap::new(), QuillReference::latest("test".to_string()));
let doc_with_defaults = doc.with_defaults(&defaults);
assert!(doc_with_defaults.fields().is_empty());
}
#[test]
fn test_with_defaults_complex_types() {
use std::collections::HashMap;
let mut defaults = HashMap::new();
defaults.insert(
"tags".to_string(),
QuillValue::from_json(serde_json::json!(["default", "tag"])),
);
let doc = ParsedDocument::new(HashMap::new(), QuillReference::latest("test".to_string()));
let doc_with_defaults = doc.with_defaults(&defaults);
let tags = doc_with_defaults
.get_field("tags")
.unwrap()
.as_sequence()
.unwrap();
assert_eq!(tags.len(), 2);
assert_eq!(tags[0].as_str().unwrap(), "default");
assert_eq!(tags[1].as_str().unwrap(), "tag");
}
#[test]
fn test_with_coercion_singular_to_array() {
use std::collections::HashMap;
let schema = QuillValue::from_json(serde_json::json!({
"$schema": "https://json-schema.org/draft/2019-09/schema",
"type": "object",
"properties": {
"tags": {"type": "array"}
}
}));
let mut fields = HashMap::new();
fields.insert(
"tags".to_string(),
QuillValue::from_json(serde_json::json!("single-tag")),
);
let doc = ParsedDocument::new(fields, QuillReference::latest("test".to_string()));
let coerced_doc = doc.with_coercion(&schema);
let tags = coerced_doc.get_field("tags").unwrap();
let tags_array = tags.as_array().unwrap();
assert_eq!(tags_array.len(), 1);
assert_eq!(tags_array[0].as_str().unwrap(), "single-tag");
}
#[test]
fn test_with_coercion_string_to_boolean() {
use std::collections::HashMap;
let schema = QuillValue::from_json(serde_json::json!({
"$schema": "https://json-schema.org/draft/2019-09/schema",
"type": "object",
"properties": {
"active": {"type": "boolean"}
}
}));
let mut fields = HashMap::new();
fields.insert(
"active".to_string(),
QuillValue::from_json(serde_json::json!("true")),
);
let doc = ParsedDocument::new(fields, QuillReference::latest("test".to_string()));
let coerced_doc = doc.with_coercion(&schema);
assert!(coerced_doc.get_field("active").unwrap().as_bool().unwrap());
}
#[test]
fn test_with_coercion_string_to_number() {
use std::collections::HashMap;
let schema = QuillValue::from_json(serde_json::json!({
"$schema": "https://json-schema.org/draft/2019-09/schema",
"type": "object",
"properties": {
"count": {"type": "number"}
}
}));
let mut fields = HashMap::new();
fields.insert(
"count".to_string(),
QuillValue::from_json(serde_json::json!("42")),
);
let doc = ParsedDocument::new(fields, QuillReference::latest("test".to_string()));
let coerced_doc = doc.with_coercion(&schema);
assert_eq!(
coerced_doc.get_field("count").unwrap().as_i64().unwrap(),
42
);
}
#[test]
fn test_invalid_yaml() {
let markdown = r#"---
title: [invalid yaml
author: missing close bracket
---
Content here."#;
let result = decompose(markdown);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("YAML error"));
}
#[test]
fn test_unclosed_frontmatter() {
let markdown = r#"---
title: Test
author: Test Author
Content without closing ---"#;
let result = decompose(markdown);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("not closed"));
}
#[test]
fn test_basic_tagged_block() {
let markdown = r#"---
QUILL: test_quill
title: Main Document
---
Main body content.
---
CARD: items
name: Item 1
---
Body of item 1."#;
let doc = decompose(markdown).unwrap();
assert_eq!(doc.body(), Some("\nMain body content.\n\n"));
assert_eq!(
doc.get_field("title").unwrap().as_str().unwrap(),
"Main Document"
);
let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
assert_eq!(cards.len(), 1);
let item = cards[0].as_object().unwrap();
assert_eq!(item.get("CARD").unwrap().as_str().unwrap(), "items");
assert_eq!(item.get("name").unwrap().as_str().unwrap(), "Item 1");
assert_eq!(
item.get(BODY_FIELD).unwrap().as_str().unwrap(),
"\nBody of item 1."
);
}
#[test]
fn test_multiple_tagged_blocks() {
let markdown = r#"---
QUILL: test_quill
---
---
CARD: items
name: Item 1
tags: [a, b]
---
First item body.
---
CARD: items
name: Item 2
tags: [c, d]
---
Second item body."#;
let doc = decompose(markdown).unwrap();
let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
assert_eq!(cards.len(), 2);
let item1 = cards[0].as_object().unwrap();
assert_eq!(item1.get("CARD").unwrap().as_str().unwrap(), "items");
assert_eq!(item1.get("name").unwrap().as_str().unwrap(), "Item 1");
let item2 = cards[1].as_object().unwrap();
assert_eq!(item2.get("CARD").unwrap().as_str().unwrap(), "items");
assert_eq!(item2.get("name").unwrap().as_str().unwrap(), "Item 2");
}
#[test]
fn test_mixed_global_and_tagged() {
let markdown = r#"---
QUILL: test_quill
title: Global
author: John Doe
---
Global body.
---
CARD: sections
title: Section 1
---
Section 1 content.
---
CARD: sections
title: Section 2
---
Section 2 content."#;
let doc = decompose(markdown).unwrap();
assert_eq!(doc.get_field("title").unwrap().as_str().unwrap(), "Global");
assert_eq!(doc.body(), Some("\nGlobal body.\n\n"));
let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
assert_eq!(cards.len(), 2);
assert_eq!(
cards[0]
.as_object()
.unwrap()
.get("CARD")
.unwrap()
.as_str()
.unwrap(),
"sections"
);
}
#[test]
fn test_empty_tagged_metadata() {
let markdown = r#"---
QUILL: test_quill
---
---
CARD: items
---
Body without metadata."#;
let doc = decompose(markdown).unwrap();
let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
assert_eq!(cards.len(), 1);
let item = cards[0].as_object().unwrap();
assert_eq!(item.get("CARD").unwrap().as_str().unwrap(), "items");
assert_eq!(
item.get(BODY_FIELD).unwrap().as_str().unwrap(),
"\nBody without metadata."
);
}
#[test]
fn test_tagged_block_without_body() {
let markdown = r#"---
QUILL: test_quill
---
---
CARD: items
name: Item
---"#;
let doc = decompose(markdown).unwrap();
let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
assert_eq!(cards.len(), 1);
let item = cards[0].as_object().unwrap();
assert_eq!(item.get("CARD").unwrap().as_str().unwrap(), "items");
assert_eq!(item.get(BODY_FIELD).unwrap().as_str().unwrap(), "");
}
#[test]
fn test_name_collision_global_and_tagged() {
let markdown = r#"---
QUILL: test_quill
items: "global value"
---
Body
---
CARD: items
name: Item
---
Item body"#;
let result = decompose(markdown);
assert!(result.is_ok(), "Name collision should be allowed now");
}
#[test]
fn test_card_name_collision_with_array_field() {
let markdown = r#"---
QUILL: test_quill
items:
- name: Global Item 1
value: 100
---
Global body
---
CARD: items
name: Scope Item 1
---
Scope item 1 body"#;
let result = decompose(markdown);
assert!(
result.is_ok(),
"Collision with array field should be allowed"
);
}
#[test]
fn test_empty_global_array_with_card() {
let markdown = r#"---
QUILL: test_quill
items: []
---
Global body
---
CARD: items
name: Item 1
---
Item 1 body"#;
let result = decompose(markdown);
assert!(
result.is_ok(),
"Collision with empty array field should be allowed"
);
}
#[test]
fn test_reserved_field_body_rejected() {
let markdown = r#"---
CARD: section
BODY: Test
---"#;
let result = decompose(markdown);
assert!(result.is_err(), "BODY is a reserved field name");
assert!(result
.unwrap_err()
.to_string()
.contains("Reserved field name"));
}
#[test]
fn test_reserved_field_cards_rejected() {
let markdown = r#"---
title: Test
CARDS: []
---"#;
let result = decompose(markdown);
assert!(result.is_err(), "CARDS is a reserved field name");
assert!(result
.unwrap_err()
.to_string()
.contains("Reserved field name"));
}
#[test]
fn test_delimiter_inside_fenced_code_block_backticks() {
let markdown = r#"---
QUILL: test_quill
title: Test
---
Here is some code:
```yaml
---
fake: frontmatter
---
```
More content.
"#;
let doc = decompose(markdown).unwrap();
assert!(doc.body().unwrap().contains("fake: frontmatter"));
assert!(doc.get_field("fake").is_none());
}
#[test]
fn test_tildes_are_not_fences() {
let markdown = r#"---
QUILL: test_quill
title: Test
---
Here is some code:
~~~yaml
---
CARD: code_example
fake: frontmatter
---
~~~
More content.
"#;
let doc = decompose(markdown).unwrap();
let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
assert_eq!(cards.len(), 1);
assert_eq!(
cards[0].get("fake").unwrap().as_str().unwrap(),
"frontmatter"
);
}
#[test]
fn test_four_backticks_are_not_fences() {
let markdown = r#"---
QUILL: test_quill
title: Test
---
Here is some code:
````yaml
---
CARD: code_example
fake: frontmatter
---
````
More content.
"#;
let doc = decompose(markdown).unwrap();
let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
assert_eq!(cards.len(), 1);
assert_eq!(
cards[0].get("fake").unwrap().as_str().unwrap(),
"frontmatter"
);
}
#[test]
fn test_invalid_tag_syntax() {
let markdown = r#"---
CARD: Invalid-Name
title: Test
---"#;
let result = decompose(markdown);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Invalid card field name"));
}
#[test]
fn test_multiple_global_frontmatter_blocks() {
let markdown = r#"---
title: First
---
Body
---
author: Second
---
More body"#;
let result = decompose(markdown);
assert!(result.is_err());
let err = result.unwrap_err();
let err_str = err.to_string();
assert!(
err_str.contains("CARD"),
"Error should mention CARD directive: {}",
err_str
);
assert!(
err_str.contains("missing"),
"Error should indicate missing directive: {}",
err_str
);
}
#[test]
fn test_adjacent_blocks_different_tags() {
let markdown = r#"---
QUILL: test_quill
---
---
CARD: items
name: Item 1
---
Item 1 body
---
CARD: sections
title: Section 1
---
Section 1 body"#;
let doc = decompose(markdown).unwrap();
let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
assert_eq!(cards.len(), 2);
let item = cards[0].as_object().unwrap();
assert_eq!(item.get("CARD").unwrap().as_str().unwrap(), "items");
assert_eq!(item.get("name").unwrap().as_str().unwrap(), "Item 1");
let section = cards[1].as_object().unwrap();
assert_eq!(section.get("CARD").unwrap().as_str().unwrap(), "sections");
assert_eq!(section.get("title").unwrap().as_str().unwrap(), "Section 1");
}
#[test]
fn test_order_preservation() {
let markdown = r#"---
QUILL: test_quill
---
---
CARD: items
id: 1
---
First
---
CARD: items
id: 2
---
Second
---
CARD: items
id: 3
---
Third"#;
let doc = decompose(markdown).unwrap();
let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
assert_eq!(cards.len(), 3);
for (i, card) in cards.iter().enumerate() {
let mapping = card.as_object().unwrap();
assert_eq!(mapping.get("CARD").unwrap().as_str().unwrap(), "items");
let id = mapping.get("id").unwrap().as_i64().unwrap();
assert_eq!(id, (i + 1) as i64);
}
}
#[test]
fn test_product_catalog_integration() {
let markdown = r#"---
QUILL: test_quill
title: Product Catalog
author: John Doe
date: 2024-01-01
---
This is the main catalog description.
---
CARD: products
name: Widget A
price: 19.99
sku: WID-001
---
The **Widget A** is our most popular product.
---
CARD: products
name: Gadget B
price: 29.99
sku: GAD-002
---
The **Gadget B** is perfect for professionals.
---
CARD: reviews
product: Widget A
rating: 5
---
"Excellent product! Highly recommended."
---
CARD: reviews
product: Gadget B
rating: 4
---
"Very good, but a bit pricey.""#;
let doc = decompose(markdown).unwrap();
assert_eq!(
doc.get_field("title").unwrap().as_str().unwrap(),
"Product Catalog"
);
assert_eq!(
doc.get_field("author").unwrap().as_str().unwrap(),
"John Doe"
);
assert_eq!(
doc.get_field("date").unwrap().as_str().unwrap(),
"2024-01-01"
);
assert!(doc.body().unwrap().contains("main catalog description"));
let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
assert_eq!(cards.len(), 4);
let product1 = cards[0].as_object().unwrap();
assert_eq!(product1.get("CARD").unwrap().as_str().unwrap(), "products");
assert_eq!(product1.get("name").unwrap().as_str().unwrap(), "Widget A");
assert_eq!(product1.get("price").unwrap().as_f64().unwrap(), 19.99);
let product2 = cards[1].as_object().unwrap();
assert_eq!(product2.get("CARD").unwrap().as_str().unwrap(), "products");
assert_eq!(product2.get("name").unwrap().as_str().unwrap(), "Gadget B");
let review1 = cards[2].as_object().unwrap();
assert_eq!(review1.get("CARD").unwrap().as_str().unwrap(), "reviews");
assert_eq!(
review1.get("product").unwrap().as_str().unwrap(),
"Widget A"
);
assert_eq!(review1.get("rating").unwrap().as_i64().unwrap(), 5);
assert_eq!(doc.fields().len(), 5);
}
#[test]
fn taro_quill_directive() {
let markdown = r#"---
QUILL: usaf_memo
memo_for: [ORG/SYMBOL]
memo_from: [ORG/SYMBOL]
---
This is the memo body."#;
let doc = decompose(markdown).unwrap();
assert_eq!(doc.quill_reference().name, "usaf_memo");
assert_eq!(
doc.get_field("memo_for").unwrap().as_sequence().unwrap()[0]
.as_str()
.unwrap(),
"ORG/SYMBOL"
);
assert_eq!(doc.body(), Some("\nThis is the memo body."));
}
#[test]
fn test_quill_with_card_blocks() {
let markdown = r#"---
QUILL: document
title: Test Document
---
Main body.
---
CARD: sections
name: Section 1
---
Section 1 body."#;
let doc = decompose(markdown).unwrap();
assert_eq!(doc.quill_reference().name, "document");
assert_eq!(
doc.get_field("title").unwrap().as_str().unwrap(),
"Test Document"
);
let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
assert_eq!(cards.len(), 1);
assert_eq!(
cards[0]
.as_object()
.unwrap()
.get("CARD")
.unwrap()
.as_str()
.unwrap(),
"sections"
);
assert_eq!(doc.body(), Some("\nMain body.\n\n"));
}
#[test]
fn test_multiple_quill_directives_error() {
let markdown = r#"---
QUILL: first
---
---
QUILL: second
---"#;
let result = decompose(markdown);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("top-level frontmatter"));
}
#[test]
fn test_invalid_quill_ref() {
let markdown = r#"---
QUILL: Invalid-Name
---"#;
let result = decompose(markdown);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Invalid QUILL reference"));
}
#[test]
fn test_quill_wrong_value_type() {
let markdown = r#"---
QUILL: 123
---"#;
let result = decompose(markdown);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("QUILL value must be a string"));
}
#[test]
fn test_card_wrong_value_type() {
let markdown = r#"---
CARD: 123
---"#;
let result = decompose(markdown);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("CARD value must be a string"));
}
#[test]
fn test_both_quill_and_card_error() {
let markdown = r#"---
QUILL: test
CARD: items
---"#;
let result = decompose(markdown);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Cannot specify both QUILL and CARD"));
}
#[test]
fn test_blank_lines_in_frontmatter() {
let markdown = r#"---
QUILL: test_quill
title: Test Document
author: Test Author
description: This has a blank line above it
tags:
- one
- two
---
# Hello World
This is the body."#;
let doc = decompose(markdown).unwrap();
assert_eq!(doc.body(), Some("\n# Hello World\n\nThis is the body."));
assert_eq!(
doc.get_field("title").unwrap().as_str().unwrap(),
"Test Document"
);
assert_eq!(
doc.get_field("author").unwrap().as_str().unwrap(),
"Test Author"
);
assert_eq!(
doc.get_field("description").unwrap().as_str().unwrap(),
"This has a blank line above it"
);
let tags = doc.get_field("tags").unwrap().as_sequence().unwrap();
assert_eq!(tags.len(), 2);
}
#[test]
fn test_blank_lines_in_scope_blocks() {
let markdown = r#"---
QUILL: test_quill
---
---
CARD: items
name: Item 1
price: 19.99
tags:
- electronics
- gadgets
---
Body of item 1."#;
let doc = decompose(markdown).unwrap();
let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
assert_eq!(cards.len(), 1);
let item = cards[0].as_object().unwrap();
assert_eq!(item.get("CARD").unwrap().as_str().unwrap(), "items");
assert_eq!(item.get("name").unwrap().as_str().unwrap(), "Item 1");
assert_eq!(item.get("price").unwrap().as_f64().unwrap(), 19.99);
let tags = item.get("tags").unwrap().as_array().unwrap();
assert_eq!(tags.len(), 2);
}
#[test]
fn test_horizontal_rule_with_blank_lines_above_and_below() {
let markdown = r#"---
QUILL: test_quill
title: Test
---
First paragraph.
---
Second paragraph."#;
let doc = decompose(markdown).unwrap();
assert_eq!(doc.get_field("title").unwrap().as_str().unwrap(), "Test");
let body = doc.body().unwrap();
assert!(body.contains("First paragraph."));
assert!(body.contains("---"));
assert!(body.contains("Second paragraph."));
}
#[test]
fn test_horizontal_rule_not_preceded_by_blank() {
let markdown = r#"---
QUILL: test_quill
title: Test
---
First paragraph.
---
Second paragraph."#;
let doc = decompose(markdown).unwrap();
let body = doc.body().unwrap();
assert!(body.contains("---"));
}
#[test]
fn test_multiple_blank_lines_in_yaml() {
let markdown = r#"---
QUILL: test_quill
title: Test
author: John Doe
version: 1.0
---
Body content."#;
let doc = decompose(markdown).unwrap();
assert_eq!(doc.get_field("title").unwrap().as_str().unwrap(), "Test");
assert_eq!(
doc.get_field("author").unwrap().as_str().unwrap(),
"John Doe"
);
assert_eq!(doc.get_field("version").unwrap().as_f64().unwrap(), 1.0);
}
#[test]
fn test_html_comment_interaction() {
let markdown = r#"<!---
---> the rest of the page content
---
QUILL: test_quill
key: value
---
"#;
let doc = decompose(markdown).unwrap();
let key = doc.get_field("key").and_then(|v| v.as_str());
assert_eq!(key, Some("value"));
}
}
#[cfg(test)]
mod demo_file_test {
use super::*;
#[test]
fn test_extended_metadata_demo_file() {
let markdown = include_str!("../../fixtures/resources/extended_metadata_demo.md");
let doc = decompose(markdown).unwrap();
assert_eq!(
doc.get_field("title").unwrap().as_str().unwrap(),
"Extended Metadata Demo"
);
assert_eq!(
doc.get_field("author").unwrap().as_str().unwrap(),
"Quillmark Team"
);
assert_eq!(doc.get_field("version").unwrap().as_f64().unwrap(), 1.0);
assert!(doc
.body()
.unwrap()
.contains("extended YAML metadata standard"));
let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
assert_eq!(cards.len(), 5);
let features_count = cards
.iter()
.filter(|c| {
c.as_object()
.unwrap()
.get("CARD")
.unwrap()
.as_str()
.unwrap()
== "features"
})
.count();
let use_cases_count = cards
.iter()
.filter(|c| {
c.as_object()
.unwrap()
.get("CARD")
.unwrap()
.as_str()
.unwrap()
== "use_cases"
})
.count();
assert_eq!(features_count, 3);
assert_eq!(use_cases_count, 2);
let feature1 = cards[0].as_object().unwrap();
assert_eq!(feature1.get("CARD").unwrap().as_str().unwrap(), "features");
assert_eq!(
feature1.get("name").unwrap().as_str().unwrap(),
"Tag Directives"
);
}
#[test]
fn test_input_size_limit() {
let size = crate::error::MAX_INPUT_SIZE + 1;
let large_markdown = "a".repeat(size);
let result = decompose(&large_markdown);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Input too large"));
}
#[test]
fn test_yaml_size_limit() {
let mut markdown = String::from("---\n");
let size = crate::error::MAX_YAML_SIZE + 1;
markdown.push_str("data: \"");
markdown.push_str(&"x".repeat(size));
markdown.push_str("\"\n---\n\nBody");
let result = decompose(&markdown);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(err_msg.contains("Input too large"));
}
#[test]
fn test_input_within_size_limit() {
let size = 1000; let markdown = format!(
"---\nQUILL: test_quill\ntitle: Test\n---\n\n{}",
"a".repeat(size)
);
let result = decompose(&markdown);
assert!(result.is_ok());
}
#[test]
fn test_yaml_within_size_limit() {
let markdown = "---\nQUILL: test_quill\ntitle: Test\nauthor: John Doe\n---\n\nBody content";
let result = decompose(markdown);
assert!(result.is_ok());
}
#[test]
fn test_yaml_depth_limit() {
let mut yaml_content = String::new();
for i in 0..110 {
yaml_content.push_str(&" ".repeat(i));
yaml_content.push_str(&format!("level{}: value\n", i));
}
let markdown = format!("---\n{}---\n\nBody", yaml_content);
let result = decompose(&markdown);
assert!(result.is_err());
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.to_lowercase().contains("budget")
|| err_msg.to_lowercase().contains("depth")
|| err_msg.contains("YAML"),
"Expected depth/budget error, got: {}",
err_msg
);
}
#[test]
fn test_yaml_depth_within_limit() {
let markdown = r#"---
QUILL: test_quill
level1:
level2:
level3:
level4:
value: test
---
Body content"#;
let result = decompose(markdown);
assert!(result.is_ok());
}
#[test]
fn test_chevrons_preserved_in_body_no_frontmatter() {
let markdown = "---\nQUILL: test_quill\n---\nUse <<raw content>> here.";
let doc = decompose(markdown).unwrap();
assert_eq!(doc.body(), Some("Use <<raw content>> here."));
}
#[test]
fn test_chevrons_preserved_in_body_with_frontmatter() {
let markdown = r#"---
QUILL: test_quill
title: Test
---
Use <<raw content>> here."#;
let doc = decompose(markdown).unwrap();
assert_eq!(doc.body(), Some("\nUse <<raw content>> here."));
}
#[test]
fn test_chevrons_preserved_in_yaml_string() {
let markdown = r#"---
QUILL: test_quill
title: Test <<with chevrons>>
---
Body content."#;
let doc = decompose(markdown).unwrap();
assert_eq!(
doc.get_field("title").unwrap().as_str().unwrap(),
"Test <<with chevrons>>"
);
}
#[test]
fn test_chevrons_preserved_in_yaml_array() {
let markdown = r#"---
QUILL: test_quill
items:
- "<<first>>"
- "<<second>>"
---
Body."#;
let doc = decompose(markdown).unwrap();
let items = doc.get_field("items").unwrap().as_sequence().unwrap();
assert_eq!(items[0].as_str().unwrap(), "<<first>>");
assert_eq!(items[1].as_str().unwrap(), "<<second>>");
}
#[test]
fn test_chevrons_preserved_in_yaml_nested() {
let markdown = r#"---
QUILL: test_quill
metadata:
description: "<<nested value>>"
---
Body."#;
let doc = decompose(markdown).unwrap();
let metadata = doc.get_field("metadata").unwrap().as_object().unwrap();
assert_eq!(
metadata.get("description").unwrap().as_str().unwrap(),
"<<nested value>>"
);
}
#[test]
fn test_chevrons_preserved_in_code_blocks() {
let markdown =
"---\nQUILL: test_quill\n---\n```\n<<in code block>>\n```\n\n<<outside code block>>";
let doc = decompose(markdown).unwrap();
let body = doc.body().unwrap();
assert!(body.contains("<<in code block>>"));
assert!(body.contains("<<outside code block>>"));
}
#[test]
fn test_chevrons_preserved_in_inline_code() {
let markdown =
"---\nQUILL: test_quill\n---\n`<<in inline code>>` and <<outside inline code>>";
let doc = decompose(markdown).unwrap();
let body = doc.body().unwrap();
assert!(body.contains("`<<in inline code>>`"));
assert!(body.contains("<<outside inline code>>"));
}
#[test]
fn test_chevrons_preserved_in_tagged_block_body() {
let markdown = r#"---
QUILL: test_quill
title: Main
---
Main body.
---
CARD: items
name: Item 1
---
Use <<raw>> here."#;
let doc = decompose(markdown).unwrap();
let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
let item = cards[0].as_object().unwrap();
assert_eq!(item.get("CARD").unwrap().as_str().unwrap(), "items");
let item_body = item.get(BODY_FIELD).unwrap().as_str().unwrap();
assert!(item_body.contains("<<raw>>"));
}
#[test]
fn test_chevrons_preserved_in_tagged_block_yaml() {
let markdown = r#"---
QUILL: test_quill
title: Main
---
Main body.
---
CARD: items
description: "<<tagged yaml>>"
---
Item body."#;
let doc = decompose(markdown).unwrap();
let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
let item = cards[0].as_object().unwrap();
assert_eq!(item.get("CARD").unwrap().as_str().unwrap(), "items");
assert_eq!(
item.get("description").unwrap().as_str().unwrap(),
"<<tagged yaml>>"
);
}
#[test]
fn test_yaml_numbers_not_affected() {
let markdown = r#"---
QUILL: test_quill
count: 42
---
Body."#;
let doc = decompose(markdown).unwrap();
assert_eq!(doc.get_field("count").unwrap().as_i64().unwrap(), 42);
}
#[test]
fn test_yaml_booleans_not_affected() {
let markdown = r#"---
QUILL: test_quill
active: true
---
Body."#;
let doc = decompose(markdown).unwrap();
assert!(doc.get_field("active").unwrap().as_bool().unwrap());
}
#[test]
fn test_multiline_chevrons_preserved() {
let markdown = "---\nQUILL: test_quill\n---\n<<text\nacross lines>>";
let doc = decompose(markdown).unwrap();
let body = doc.body().unwrap();
assert!(body.contains("<<text"));
assert!(body.contains("across lines>>"));
}
#[test]
fn test_unmatched_chevrons_preserved() {
let markdown = "---\nQUILL: test_quill\n---\n<<unmatched";
let doc = decompose(markdown).unwrap();
let body = doc.body().unwrap();
assert_eq!(body, "<<unmatched");
}
}
#[cfg(test)]
mod robustness_tests {
use super::*;
#[test]
fn test_empty_document() {
let result = decompose("");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Missing required QUILL field"));
}
#[test]
fn test_only_whitespace() {
let result = decompose(" \n\n \t");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Missing required QUILL field"));
}
#[test]
fn test_only_dashes() {
let result = decompose("---");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Missing required QUILL field"));
}
#[test]
fn test_dashes_in_middle_of_line() {
let markdown = "---\nQUILL: test_quill\n---\nsome text --- more text";
let doc = decompose(markdown).unwrap();
assert_eq!(doc.body(), Some("some text --- more text"));
}
#[test]
fn test_four_dashes() {
let result = decompose("----\ntitle: Test\n----\n\nBody");
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Missing required QUILL field"));
}
#[test]
fn test_crlf_line_endings() {
let markdown = "---\r\nQUILL: test_quill\r\ntitle: Test\r\n---\r\n\r\nBody content.";
let doc = decompose(markdown).unwrap();
assert_eq!(doc.get_field("title").unwrap().as_str().unwrap(), "Test");
assert!(doc.body().unwrap().contains("Body content."));
}
#[test]
fn test_mixed_line_endings() {
let markdown = "---\nQUILL: test_quill\r\ntitle: Test\r\n---\n\nBody.";
let doc = decompose(markdown).unwrap();
assert_eq!(doc.get_field("title").unwrap().as_str().unwrap(), "Test");
}
#[test]
fn test_frontmatter_at_eof_no_trailing_newline() {
let markdown = "---\nQUILL: test_quill\ntitle: Test\n---";
let doc = decompose(markdown).unwrap();
assert_eq!(doc.get_field("title").unwrap().as_str().unwrap(), "Test");
assert_eq!(doc.body(), Some(""));
}
#[test]
fn test_empty_frontmatter() {
let markdown = "---\n \n---\n\nBody content.";
let result = decompose(markdown);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Missing required QUILL field"));
}
#[test]
fn test_whitespace_only_frontmatter() {
let markdown = "---\n \n\n \n---\n\nBody.";
let result = decompose(markdown);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Missing required QUILL field"));
}
#[test]
fn test_unicode_in_yaml_keys() {
let markdown = "---\nQUILL: test_quill\ntitre: Bonjour\nタイトル: こんにちは\n---\n\nBody.";
let doc = decompose(markdown).unwrap();
assert_eq!(doc.get_field("titre").unwrap().as_str().unwrap(), "Bonjour");
assert_eq!(
doc.get_field("タイトル").unwrap().as_str().unwrap(),
"こんにちは"
);
}
#[test]
fn test_unicode_in_yaml_values() {
let markdown = "---\nQUILL: test_quill\ntitle: 你好世界 🎉\n---\n\nBody.";
let doc = decompose(markdown).unwrap();
assert_eq!(
doc.get_field("title").unwrap().as_str().unwrap(),
"你好世界 🎉"
);
}
#[test]
fn test_unicode_in_body() {
let markdown = "---\nQUILL: test_quill\ntitle: Test\n---\n\n日本語テキスト with emoji 🚀";
let doc = decompose(markdown).unwrap();
assert!(doc.body().unwrap().contains("日本語テキスト"));
assert!(doc.body().unwrap().contains("🚀"));
}
#[test]
fn test_yaml_multiline_string() {
let markdown = r#"---
QUILL: test_quill
description: |
This is a
multiline string
with preserved newlines.
---
Body."#;
let doc = decompose(markdown).unwrap();
let desc = doc.get_field("description").unwrap().as_str().unwrap();
assert!(desc.contains("multiline string"));
assert!(desc.contains('\n'));
}
#[test]
fn test_yaml_folded_string() {
let markdown = r#"---
QUILL: test_quill
description: >
This is a folded
string that becomes
a single line.
---
Body."#;
let doc = decompose(markdown).unwrap();
let desc = doc.get_field("description").unwrap().as_str().unwrap();
assert!(desc.contains("folded"));
}
#[test]
fn test_yaml_null_value() {
let markdown = "---\nQUILL: test_quill\noptional: null\n---\n\nBody.";
let doc = decompose(markdown).unwrap();
assert!(doc.get_field("optional").unwrap().is_null());
}
#[test]
fn test_yaml_empty_string_value() {
let markdown = "---\nQUILL: test_quill\nempty: \"\"\n---\n\nBody.";
let doc = decompose(markdown).unwrap();
assert_eq!(doc.get_field("empty").unwrap().as_str().unwrap(), "");
}
#[test]
fn test_yaml_special_characters_in_string() {
let markdown =
"---\nQUILL: test_quill\nspecial: \"colon: here, and [brackets]\"\n---\n\nBody.";
let doc = decompose(markdown).unwrap();
assert_eq!(
doc.get_field("special").unwrap().as_str().unwrap(),
"colon: here, and [brackets]"
);
}
#[test]
fn test_yaml_nested_objects() {
let markdown = r#"---
QUILL: test_quill
config:
database:
host: localhost
port: 5432
cache:
enabled: true
---
Body."#;
let doc = decompose(markdown).unwrap();
let config = doc.get_field("config").unwrap().as_object().unwrap();
let db = config.get("database").unwrap().as_object().unwrap();
assert_eq!(db.get("host").unwrap().as_str().unwrap(), "localhost");
assert_eq!(db.get("port").unwrap().as_i64().unwrap(), 5432);
}
#[test]
fn test_card_with_empty_body() {
let markdown = r#"---
QUILL: test_quill
---
---
CARD: items
name: Item
---"#;
let doc = decompose(markdown).unwrap();
let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
assert_eq!(cards.len(), 1);
let item = cards[0].as_object().unwrap();
assert_eq!(item.get("CARD").unwrap().as_str().unwrap(), "items");
assert_eq!(item.get(BODY_FIELD).unwrap().as_str().unwrap(), "");
}
#[test]
fn test_card_consecutive_blocks() {
let markdown = r#"---
QUILL: test_quill
---
---
CARD: a
id: 1
---
---
CARD: a
id: 2
---"#;
let doc = decompose(markdown).unwrap();
let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
assert_eq!(cards.len(), 2);
assert_eq!(
cards[0]
.as_object()
.unwrap()
.get("CARD")
.unwrap()
.as_str()
.unwrap(),
"a"
);
assert_eq!(
cards[1]
.as_object()
.unwrap()
.get("CARD")
.unwrap()
.as_str()
.unwrap(),
"a"
);
}
#[test]
fn test_card_with_body_containing_dashes() {
let markdown = r#"---
QUILL: test_quill
---
---
CARD: items
name: Item
---
Some text with --- dashes in it."#;
let doc = decompose(markdown).unwrap();
let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
let item = cards[0].as_object().unwrap();
assert_eq!(item.get("CARD").unwrap().as_str().unwrap(), "items");
let body = item.get(BODY_FIELD).unwrap().as_str().unwrap();
assert!(body.contains("--- dashes"));
}
#[test]
fn test_quill_with_underscore_prefix() {
let markdown = "---\nQUILL: _internal\n---\n\nBody.";
let doc = decompose(markdown).unwrap();
assert_eq!(doc.quill_reference().name, "_internal");
}
#[test]
fn test_quill_with_numbers() {
let markdown = "---\nQUILL: form_8_v2\n---\n\nBody.";
let doc = decompose(markdown).unwrap();
assert_eq!(doc.quill_reference().name, "form_8_v2");
}
#[test]
fn test_quill_with_additional_fields() {
let markdown = r#"---
QUILL: my_quill
title: Document Title
author: John Doe
---
Body content."#;
let doc = decompose(markdown).unwrap();
assert_eq!(doc.quill_reference().name, "my_quill");
assert_eq!(
doc.get_field("title").unwrap().as_str().unwrap(),
"Document Title"
);
assert_eq!(
doc.get_field("author").unwrap().as_str().unwrap(),
"John Doe"
);
}
#[test]
fn test_invalid_scope_name_uppercase() {
let markdown = "---\nCARD: ITEMS\n---\n\nBody.";
let result = decompose(markdown);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Invalid card field name"));
}
#[test]
fn test_invalid_scope_name_starts_with_number() {
let markdown = "---\nCARD: 123items\n---\n\nBody.";
let result = decompose(markdown);
assert!(result.is_err());
}
#[test]
fn test_invalid_scope_name_with_hyphen() {
let markdown = "---\nCARD: my-items\n---\n\nBody.";
let result = decompose(markdown);
assert!(result.is_err());
}
#[test]
fn test_invalid_quill_ref_uppercase() {
let markdown = "---\nQUILL: MyQuill\n---\n\nBody.";
let result = decompose(markdown);
assert!(result.is_err());
}
#[test]
fn test_yaml_syntax_error_missing_colon() {
let markdown = "---\ntitle Test\n---\n\nBody.";
let result = decompose(markdown);
assert!(result.is_err());
}
#[test]
fn test_yaml_syntax_error_bad_indentation() {
let markdown = "---\nitems:\n- one\n - two\n---\n\nBody.";
let result = decompose(markdown);
let _ = result;
}
#[test]
fn test_body_with_leading_newlines() {
let markdown =
"---\nQUILL: test_quill\ntitle: Test\n---\n\n\n\nBody with leading newlines.";
let doc = decompose(markdown).unwrap();
assert!(doc.body().unwrap().starts_with('\n'));
}
#[test]
fn test_body_with_trailing_newlines() {
let markdown = "---\nQUILL: test_quill\ntitle: Test\n---\n\nBody.\n\n\n";
let doc = decompose(markdown).unwrap();
assert!(doc.body().unwrap().ends_with('\n'));
}
#[test]
fn test_no_body_after_frontmatter() {
let markdown = "---\nQUILL: test_quill\ntitle: Test\n---";
let doc = decompose(markdown).unwrap();
assert_eq!(doc.body(), Some(""));
}
#[test]
fn test_valid_tag_name_single_underscore() {
assert!(is_valid_tag_name("_"));
}
#[test]
fn test_valid_tag_name_underscore_prefix() {
assert!(is_valid_tag_name("_private"));
}
#[test]
fn test_valid_tag_name_with_numbers() {
assert!(is_valid_tag_name("item1"));
assert!(is_valid_tag_name("item_2"));
}
#[test]
fn test_invalid_tag_name_empty() {
assert!(!is_valid_tag_name(""));
}
#[test]
fn test_invalid_tag_name_starts_with_number() {
assert!(!is_valid_tag_name("1item"));
}
#[test]
fn test_invalid_tag_name_uppercase() {
assert!(!is_valid_tag_name("Items"));
assert!(!is_valid_tag_name("ITEMS"));
}
#[test]
fn test_invalid_tag_name_special_chars() {
assert!(!is_valid_tag_name("my-items"));
assert!(!is_valid_tag_name("my.items"));
assert!(!is_valid_tag_name("my items"));
}
#[test]
fn test_guillemet_in_yaml_preserves_non_strings() {
let markdown = r#"---
QUILL: test_quill
count: 42
price: 19.99
active: true
items:
- first
- 100
- true
---
Body."#;
let doc = decompose(markdown).unwrap();
assert_eq!(doc.get_field("count").unwrap().as_i64().unwrap(), 42);
assert_eq!(doc.get_field("price").unwrap().as_f64().unwrap(), 19.99);
assert!(doc.get_field("active").unwrap().as_bool().unwrap());
}
#[test]
fn test_guillemet_double_conversion_prevention() {
let markdown = "---\nQUILL: test_quill\ntitle: Already «converted»\n---\n\nBody.";
let doc = decompose(markdown).unwrap();
assert_eq!(
doc.get_field("title").unwrap().as_str().unwrap(),
"Already «converted»"
);
}
#[test]
fn test_allowed_card_field_collision() {
let markdown = r#"---
QUILL: test_quill
my_card: "some global value"
---
---
CARD: my_card
title: "My Card"
---
Body
"#;
let doc = decompose(markdown).unwrap();
assert_eq!(
doc.get_field("my_card").unwrap().as_str().unwrap(),
"some global value"
);
let cards = doc.get_field("CARDS").unwrap().as_array().unwrap();
assert!(!cards.is_empty());
let card = cards
.iter()
.find(|v| v.get("CARD").and_then(|c| c.as_str()) == Some("my_card"))
.expect("Card not found");
assert_eq!(card.get("title").unwrap().as_str().unwrap(), "My Card");
}
#[test]
fn test_yaml_custom_tags_in_frontmatter() {
let markdown = r#"---
QUILL: test_quill
memo_from: !fill 2d lt example
regular_field: normal value
---
Body content."#;
let doc = decompose(markdown).unwrap();
assert_eq!(
doc.get_field("memo_from").unwrap().as_str().unwrap(),
"2d lt example"
);
assert_eq!(
doc.get_field("regular_field").unwrap().as_str().unwrap(),
"normal value"
);
assert_eq!(doc.body(), Some("\nBody content."));
}
#[test]
fn test_spec_example() {
let markdown = r#"---
title: My Document
QUILL: blog_post
---
Main document body.
***
More content after horizontal rule.
---
CARD: section
heading: Introduction
---
Introduction content.
---
CARD: section
heading: Conclusion
---
Conclusion content.
"#;
let doc = decompose(markdown).unwrap();
assert_eq!(
doc.get_field("title").unwrap().as_str().unwrap(),
"My Document"
);
assert_eq!(doc.quill_reference().name, "blog_post");
let body = doc.body().unwrap();
assert!(body.contains("Main document body."));
assert!(body.contains("***"));
assert!(body.contains("More content after horizontal rule."));
let cards = doc.get_field("CARDS").unwrap().as_array().unwrap();
assert_eq!(cards.len(), 2);
let card1 = cards[0].as_object().unwrap();
assert_eq!(card1.get("CARD").unwrap().as_str().unwrap(), "section");
assert_eq!(
card1.get("heading").unwrap().as_str().unwrap(),
"Introduction"
);
assert_eq!(
card1.get("BODY").unwrap().as_str().unwrap(),
"Introduction content.\n\n"
);
let card2 = cards[1].as_object().unwrap();
assert_eq!(card2.get("CARD").unwrap().as_str().unwrap(), "section");
assert_eq!(
card2.get("heading").unwrap().as_str().unwrap(),
"Conclusion"
);
assert_eq!(
card2.get("BODY").unwrap().as_str().unwrap(),
"Conclusion content.\n"
);
}
#[test]
fn test_missing_quill_field_errors() {
let markdown = "---\ntitle: No quill here\n---\n# Body";
let result = decompose(markdown);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Missing required QUILL field"));
}
}