1use std::collections::HashMap;
50
51use crate::value::QuillValue;
52
53pub const BODY_FIELD: &str = "body";
55
56pub const QUILL_TAG: &str = "quill";
58
59#[derive(Debug, Clone)]
61pub struct ParsedDocument {
62 fields: HashMap<String, QuillValue>,
63 quill_tag: String,
64}
65
66impl ParsedDocument {
67 pub fn new(fields: HashMap<String, QuillValue>) -> Self {
69 Self {
70 fields,
71 quill_tag: "__default__".to_string(),
72 }
73 }
74
75 pub fn with_quill_tag(fields: HashMap<String, QuillValue>, quill_tag: String) -> Self {
77 Self { fields, quill_tag }
78 }
79
80 pub fn from_markdown(markdown: &str) -> Result<Self, crate::error::ParseError> {
82 decompose(markdown)
83 }
84
85 pub fn quill_tag(&self) -> &str {
87 &self.quill_tag
88 }
89
90 pub fn body(&self) -> Option<&str> {
92 self.fields.get(BODY_FIELD).and_then(|v| v.as_str())
93 }
94
95 pub fn get_field(&self, name: &str) -> Option<&QuillValue> {
97 self.fields.get(name)
98 }
99
100 pub fn fields(&self) -> &HashMap<String, QuillValue> {
102 &self.fields
103 }
104
105 pub fn with_defaults(&self, defaults: &HashMap<String, QuillValue>) -> Self {
119 let mut fields = self.fields.clone();
120
121 for (field_name, default_value) in defaults {
122 if !fields.contains_key(field_name) {
124 fields.insert(field_name.clone(), default_value.clone());
125 }
126 }
127
128 Self {
129 fields,
130 quill_tag: self.quill_tag.clone(),
131 }
132 }
133
134 pub fn with_coercion(&self, schema: &QuillValue) -> Self {
152 use crate::schema::coerce_document;
153
154 let coerced_fields = coerce_document(schema, &self.fields);
155
156 Self {
157 fields: coerced_fields,
158 quill_tag: self.quill_tag.clone(),
159 }
160 }
161}
162
163#[derive(Debug)]
164struct MetadataBlock {
165 start: usize, end: usize, yaml_value: Option<serde_yaml::Value>, tag: Option<String>, quill_name: Option<String>, }
171
172fn is_valid_tag_name(name: &str) -> bool {
174 if name.is_empty() {
175 return false;
176 }
177
178 let mut chars = name.chars();
179 let first = chars.next().unwrap();
180
181 if !first.is_ascii_lowercase() && first != '_' {
182 return false;
183 }
184
185 for ch in chars {
186 if !ch.is_ascii_lowercase() && !ch.is_ascii_digit() && ch != '_' {
187 return false;
188 }
189 }
190
191 true
192}
193
194fn find_metadata_blocks(markdown: &str) -> Result<Vec<MetadataBlock>, crate::error::ParseError> {
196 let mut blocks = Vec::new();
197 let mut pos = 0;
198
199 while pos < markdown.len() {
200 let search_str = &markdown[pos..];
202 let delimiter_result = search_str
203 .find("---\n")
204 .map(|p| (p, 4, "\n"))
205 .or_else(|| search_str.find("---\r\n").map(|p| (p, 5, "\r\n")));
206
207 if let Some((delimiter_pos, delimiter_len, _line_ending)) = delimiter_result {
208 let abs_pos = pos + delimiter_pos;
209
210 let is_start_of_line = if abs_pos == 0 {
212 true
213 } else {
214 let char_before = markdown.as_bytes()[abs_pos - 1];
215 char_before == b'\n' || char_before == b'\r'
216 };
217
218 if !is_start_of_line {
219 pos = abs_pos + 1;
220 continue;
221 }
222
223 let content_start = abs_pos + delimiter_len; let preceded_by_blank = if abs_pos > 0 {
227 let before = &markdown[..abs_pos];
229 before.ends_with("\n\n") || before.ends_with("\r\n\r\n")
230 } else {
231 false
232 };
233
234 let followed_by_blank = if content_start < markdown.len() {
235 markdown[content_start..].starts_with('\n')
236 || markdown[content_start..].starts_with("\r\n")
237 } else {
238 false
239 };
240
241 if preceded_by_blank && followed_by_blank {
243 pos = abs_pos + 3; continue;
246 }
247
248 if followed_by_blank {
251 pos = abs_pos + 3;
254 continue;
255 }
256
257 let rest = &markdown[content_start..];
260
261 let closing_patterns = ["\n---\n", "\r\n---\r\n", "\n---\r\n", "\r\n---\n"];
263 let closing_with_newline = closing_patterns
264 .iter()
265 .filter_map(|delim| rest.find(delim).map(|p| (p, delim.len())))
266 .min_by_key(|(p, _)| *p);
267
268 let closing_at_eof = ["\n---", "\r\n---"]
270 .iter()
271 .filter_map(|delim| {
272 rest.find(delim).and_then(|p| {
273 if p + delim.len() == rest.len() {
274 Some((p, delim.len()))
275 } else {
276 None
277 }
278 })
279 })
280 .min_by_key(|(p, _)| *p);
281
282 let closing_result = match (closing_with_newline, closing_at_eof) {
283 (Some((p1, _l1)), Some((p2, _))) if p2 < p1 => closing_at_eof,
284 (Some(_), Some(_)) => closing_with_newline,
285 (Some(_), None) => closing_with_newline,
286 (None, Some(_)) => closing_at_eof,
287 (None, None) => None,
288 };
289
290 if let Some((closing_pos, closing_len)) = closing_result {
291 let abs_closing_pos = content_start + closing_pos;
292 let content = &markdown[content_start..abs_closing_pos];
293
294 if content.len() > crate::error::MAX_YAML_SIZE {
296 return Err(crate::error::ParseError::InputTooLarge {
297 size: content.len(),
298 max: crate::error::MAX_YAML_SIZE,
299 });
300 }
301
302 let (tag, quill_name, yaml_value) = if !content.is_empty() {
305 match serde_yaml::from_str::<serde_yaml::Value>(content) {
307 Ok(parsed_yaml) => {
308 if let Some(mapping) = parsed_yaml.as_mapping() {
309 let quill_key = serde_yaml::Value::String("QUILL".to_string());
310 let card_key = serde_yaml::Value::String("CARD".to_string());
311 let scope_key = serde_yaml::Value::String("SCOPE".to_string()); let has_quill = mapping.contains_key(&quill_key);
314 let has_card = mapping.contains_key(&card_key);
315 let has_scope = mapping.contains_key(&scope_key);
316
317 if has_card && has_scope {
319 return Err(crate::error::ParseError::InvalidStructure(
320 "Cannot specify both CARD and SCOPE in the same block (SCOPE is an alias for CARD)"
321 .to_string(),
322 ));
323 }
324
325 let effective_card_key = if has_card {
326 Some(&card_key)
327 } else if has_scope {
328 Some(&scope_key)
329 } else {
330 None
331 };
332
333 if has_quill && effective_card_key.is_some() {
334 return Err(crate::error::ParseError::InvalidStructure(
335 "Cannot specify both QUILL and CARD/SCOPE in the same block"
336 .to_string(),
337 ));
338 }
339
340 if has_quill {
341 let quill_value = mapping.get(&quill_key).unwrap();
343 let quill_name_str = quill_value
344 .as_str()
345 .ok_or("QUILL value must be a string")?;
346
347 if !is_valid_tag_name(quill_name_str) {
348 return Err(crate::error::ParseError::InvalidStructure(format!(
349 "Invalid quill name '{}': must match pattern [a-z_][a-z0-9_]*",
350 quill_name_str
351 )));
352 }
353
354 let mut new_mapping = mapping.clone();
356 new_mapping.remove(&quill_key);
357 let new_value = if new_mapping.is_empty() {
358 None
359 } else {
360 Some(serde_yaml::Value::Mapping(new_mapping))
361 };
362
363 (None, Some(quill_name_str.to_string()), new_value)
364 } else if let Some(card_key_used) = effective_card_key {
365 let card_value = mapping.get(card_key_used).unwrap();
367 let field_name = card_value
368 .as_str()
369 .ok_or("CARD/SCOPE value must be a string")?;
370
371 if !is_valid_tag_name(field_name) {
372 return Err(crate::error::ParseError::InvalidStructure(format!(
373 "Invalid card field name '{}': must match pattern [a-z_][a-z0-9_]*",
374 field_name
375 )));
376 }
377
378 if field_name == BODY_FIELD {
379 return Err(crate::error::ParseError::InvalidStructure(format!(
380 "Cannot use reserved field name '{}' as CARD/SCOPE value",
381 BODY_FIELD
382 )));
383 }
384
385 let mut new_mapping = mapping.clone();
387 new_mapping.remove(card_key_used);
388 let new_value = if new_mapping.is_empty() {
389 None
390 } else {
391 Some(serde_yaml::Value::Mapping(new_mapping))
392 };
393
394 (Some(field_name.to_string()), None, new_value)
395 } else {
396 (None, None, Some(parsed_yaml))
398 }
399 } else {
400 (None, None, Some(parsed_yaml))
402 }
403 }
404 Err(e) => {
405 return Err(crate::error::ParseError::YamlError(e));
407 }
408 }
409 } else {
410 (None, None, None)
412 };
413
414 blocks.push(MetadataBlock {
415 start: abs_pos,
416 end: abs_closing_pos + closing_len, yaml_value,
418 tag,
419 quill_name,
420 });
421
422 pos = abs_closing_pos + closing_len;
423 } else if abs_pos == 0 {
424 return Err(crate::error::ParseError::InvalidStructure(
426 "Frontmatter started but not closed with ---".to_string(),
427 ));
428 } else {
429 pos = abs_pos + 3;
431 }
432 } else {
433 break;
434 }
435 }
436
437 Ok(blocks)
438}
439
440fn decompose(markdown: &str) -> Result<ParsedDocument, crate::error::ParseError> {
442 if markdown.len() > crate::error::MAX_INPUT_SIZE {
444 return Err(crate::error::ParseError::InputTooLarge {
445 size: markdown.len(),
446 max: crate::error::MAX_INPUT_SIZE,
447 });
448 }
449
450 let mut fields = HashMap::new();
451
452 let blocks = find_metadata_blocks(markdown)?;
454
455 if blocks.is_empty() {
456 fields.insert(
458 BODY_FIELD.to_string(),
459 QuillValue::from_json(serde_json::Value::String(markdown.to_string())),
460 );
461 return Ok(ParsedDocument::new(fields));
462 }
463
464 let mut cards_array: Vec<serde_json::Value> = Vec::new();
466 let mut global_frontmatter_index: Option<usize> = None;
467 let mut quill_name: Option<String> = None;
468
469 for (idx, block) in blocks.iter().enumerate() {
471 if idx == 0 {
472 if let Some(ref name) = block.quill_name {
474 quill_name = Some(name.clone());
475 }
476 if block.tag.is_none() && block.quill_name.is_none() {
478 global_frontmatter_index = Some(idx);
479 }
480 } else {
481 if block.quill_name.is_some() {
483 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()));
484 }
485 if block.tag.is_none() {
486 return Err(crate::error::ParseError::missing_card_directive());
488 }
489 }
490 }
491
492 if let Some(idx) = global_frontmatter_index {
494 let block = &blocks[idx];
495
496 let yaml_fields: HashMap<String, serde_yaml::Value> = match &block.yaml_value {
498 Some(serde_yaml::Value::Mapping(mapping)) => mapping
499 .iter()
500 .filter_map(|(k, v)| k.as_str().map(|key| (key.to_string(), v.clone())))
501 .collect(),
502 Some(serde_yaml::Value::Null) => {
503 HashMap::new()
505 }
506 Some(_) => {
507 return Err(crate::error::ParseError::InvalidStructure(
509 "Invalid YAML frontmatter: expected a mapping".to_string(),
510 ));
511 }
512 None => HashMap::new(),
513 };
514
515 for other_block in &blocks {
518 if let Some(ref tag) = other_block.tag {
519 if let Some(global_value) = yaml_fields.get(tag) {
520 if global_value.as_sequence().is_none() {
522 return Err(crate::error::ParseError::InvalidStructure(format!(
523 "Name collision: global field '{}' conflicts with tagged attribute",
524 tag
525 )));
526 }
527 }
528 }
529 }
530
531 for (key, value) in yaml_fields {
533 fields.insert(key, QuillValue::from_yaml(value)?);
534 }
535 }
536
537 for block in &blocks {
539 if block.quill_name.is_some() {
540 if let Some(ref yaml_val) = block.yaml_value {
542 let yaml_fields: HashMap<String, serde_yaml::Value> = match yaml_val {
543 serde_yaml::Value::Mapping(mapping) => mapping
544 .iter()
545 .filter_map(|(k, v)| k.as_str().map(|key| (key.to_string(), v.clone())))
546 .collect(),
547 serde_yaml::Value::Null => {
548 HashMap::new()
550 }
551 _ => {
552 return Err(crate::error::ParseError::InvalidStructure(
553 "Invalid YAML in quill block: expected a mapping".to_string(),
554 ));
555 }
556 };
557
558 for key in yaml_fields.keys() {
560 if fields.contains_key(key) {
561 return Err(crate::error::ParseError::InvalidStructure(format!(
562 "Name collision: quill block field '{}' conflicts with existing field",
563 key
564 )));
565 }
566 }
567
568 for (key, value) in yaml_fields {
570 fields.insert(key, QuillValue::from_yaml(value)?);
571 }
572 }
573 }
574 }
575
576 for (idx, block) in blocks.iter().enumerate() {
578 if let Some(ref tag_name) = block.tag {
579 if fields.contains_key(tag_name) {
581 return Err(crate::error::ParseError::InvalidStructure(format!(
582 "Name collision: CARD type '{}' conflicts with frontmatter field name",
583 tag_name
584 )));
585 }
586
587 let mut item_fields: HashMap<String, serde_yaml::Value> = match &block.yaml_value {
589 Some(serde_yaml::Value::Mapping(mapping)) => mapping
590 .iter()
591 .filter_map(|(k, v)| k.as_str().map(|key| (key.to_string(), v.clone())))
592 .collect(),
593 Some(serde_yaml::Value::Null) => {
594 HashMap::new()
596 }
597 Some(_) => {
598 return Err(crate::error::ParseError::InvalidStructure(format!(
599 "Invalid YAML in card block '{}': expected a mapping",
600 tag_name
601 )));
602 }
603 None => HashMap::new(),
604 };
605
606 let body_start = block.end;
608 let body_end = if idx + 1 < blocks.len() {
609 blocks[idx + 1].start
610 } else {
611 markdown.len()
612 };
613 let body = &markdown[body_start..body_end];
614
615 item_fields.insert(
617 BODY_FIELD.to_string(),
618 serde_yaml::Value::String(body.to_string()),
619 );
620
621 item_fields.insert(
623 "CARD".to_string(),
624 serde_yaml::Value::String(tag_name.clone()),
625 );
626
627 let item_json = serde_json::to_value(&item_fields)
629 .map_err(|e| format!("Failed to convert card to JSON: {}", e))?;
630 cards_array.push(item_json);
631 }
632 }
633
634 let first_non_card_block_idx = blocks
638 .iter()
639 .position(|b| b.tag.is_none() && b.quill_name.is_none())
640 .or_else(|| blocks.iter().position(|b| b.quill_name.is_some()));
641
642 let (body_start, body_end) = if let Some(idx) = first_non_card_block_idx {
643 let start = blocks[idx].end;
645
646 let end = blocks
648 .iter()
649 .skip(idx + 1)
650 .find(|b| b.tag.is_some())
651 .map(|b| b.start)
652 .unwrap_or(markdown.len());
653
654 (start, end)
655 } else {
656 let end = blocks
658 .iter()
659 .find(|b| b.tag.is_some())
660 .map(|b| b.start)
661 .unwrap_or(0);
662
663 (0, end)
664 };
665
666 let global_body = &markdown[body_start..body_end];
667
668 fields.insert(
669 BODY_FIELD.to_string(),
670 QuillValue::from_json(serde_json::Value::String(global_body.to_string())),
671 );
672
673 fields.insert(
675 "CARDS".to_string(),
676 QuillValue::from_json(serde_json::Value::Array(cards_array)),
677 );
678
679 let quill_tag = quill_name.unwrap_or_else(|| "__default__".to_string());
680 let parsed = ParsedDocument::with_quill_tag(fields, quill_tag);
681
682 Ok(parsed)
683}
684
685#[cfg(test)]
686mod tests {
687 use super::*;
688
689 #[test]
690 fn test_no_frontmatter() {
691 let markdown = "# Hello World\n\nThis is a test.";
692 let doc = decompose(markdown).unwrap();
693
694 assert_eq!(doc.body(), Some(markdown));
695 assert_eq!(doc.fields().len(), 1);
696 assert_eq!(doc.quill_tag(), "__default__");
698 }
699
700 #[test]
701 fn test_with_frontmatter() {
702 let markdown = r#"---
703title: Test Document
704author: Test Author
705---
706
707# Hello World
708
709This is the body."#;
710
711 let doc = decompose(markdown).unwrap();
712
713 assert_eq!(doc.body(), Some("\n# Hello World\n\nThis is the body."));
714 assert_eq!(
715 doc.get_field("title").unwrap().as_str().unwrap(),
716 "Test Document"
717 );
718 assert_eq!(
719 doc.get_field("author").unwrap().as_str().unwrap(),
720 "Test Author"
721 );
722 assert_eq!(doc.fields().len(), 4); assert_eq!(doc.quill_tag(), "__default__");
725 }
726
727 #[test]
728 fn test_complex_yaml_frontmatter() {
729 let markdown = r#"---
730title: Complex Document
731tags:
732 - test
733 - yaml
734metadata:
735 version: 1.0
736 nested:
737 field: value
738---
739
740Content here."#;
741
742 let doc = decompose(markdown).unwrap();
743
744 assert_eq!(doc.body(), Some("\nContent here."));
745 assert_eq!(
746 doc.get_field("title").unwrap().as_str().unwrap(),
747 "Complex Document"
748 );
749
750 let tags = doc.get_field("tags").unwrap().as_sequence().unwrap();
751 assert_eq!(tags.len(), 2);
752 assert_eq!(tags[0].as_str().unwrap(), "test");
753 assert_eq!(tags[1].as_str().unwrap(), "yaml");
754 }
755
756 #[test]
757 fn test_with_defaults_empty_document() {
758 use std::collections::HashMap;
759
760 let mut defaults = HashMap::new();
761 defaults.insert(
762 "status".to_string(),
763 QuillValue::from_json(serde_json::json!("draft")),
764 );
765 defaults.insert(
766 "version".to_string(),
767 QuillValue::from_json(serde_json::json!(1)),
768 );
769
770 let doc = ParsedDocument::new(HashMap::new());
772 let doc_with_defaults = doc.with_defaults(&defaults);
773
774 assert_eq!(
776 doc_with_defaults
777 .get_field("status")
778 .unwrap()
779 .as_str()
780 .unwrap(),
781 "draft"
782 );
783 assert_eq!(
784 doc_with_defaults
785 .get_field("version")
786 .unwrap()
787 .as_number()
788 .unwrap()
789 .as_i64()
790 .unwrap(),
791 1
792 );
793 }
794
795 #[test]
796 fn test_with_defaults_preserves_existing_values() {
797 use std::collections::HashMap;
798
799 let mut defaults = HashMap::new();
800 defaults.insert(
801 "status".to_string(),
802 QuillValue::from_json(serde_json::json!("draft")),
803 );
804
805 let mut fields = HashMap::new();
807 fields.insert(
808 "status".to_string(),
809 QuillValue::from_json(serde_json::json!("published")),
810 );
811 let doc = ParsedDocument::new(fields);
812
813 let doc_with_defaults = doc.with_defaults(&defaults);
814
815 assert_eq!(
817 doc_with_defaults
818 .get_field("status")
819 .unwrap()
820 .as_str()
821 .unwrap(),
822 "published"
823 );
824 }
825
826 #[test]
827 fn test_with_defaults_partial_application() {
828 use std::collections::HashMap;
829
830 let mut defaults = HashMap::new();
831 defaults.insert(
832 "status".to_string(),
833 QuillValue::from_json(serde_json::json!("draft")),
834 );
835 defaults.insert(
836 "version".to_string(),
837 QuillValue::from_json(serde_json::json!(1)),
838 );
839
840 let mut fields = HashMap::new();
842 fields.insert(
843 "status".to_string(),
844 QuillValue::from_json(serde_json::json!("published")),
845 );
846 let doc = ParsedDocument::new(fields);
847
848 let doc_with_defaults = doc.with_defaults(&defaults);
849
850 assert_eq!(
852 doc_with_defaults
853 .get_field("status")
854 .unwrap()
855 .as_str()
856 .unwrap(),
857 "published"
858 );
859 assert_eq!(
860 doc_with_defaults
861 .get_field("version")
862 .unwrap()
863 .as_number()
864 .unwrap()
865 .as_i64()
866 .unwrap(),
867 1
868 );
869 }
870
871 #[test]
872 fn test_with_defaults_no_defaults() {
873 use std::collections::HashMap;
874
875 let defaults = HashMap::new(); let doc = ParsedDocument::new(HashMap::new());
878 let doc_with_defaults = doc.with_defaults(&defaults);
879
880 assert!(doc_with_defaults.fields().is_empty());
882 }
883
884 #[test]
885 fn test_with_defaults_complex_types() {
886 use std::collections::HashMap;
887
888 let mut defaults = HashMap::new();
889 defaults.insert(
890 "tags".to_string(),
891 QuillValue::from_json(serde_json::json!(["default", "tag"])),
892 );
893
894 let doc = ParsedDocument::new(HashMap::new());
895 let doc_with_defaults = doc.with_defaults(&defaults);
896
897 let tags = doc_with_defaults
899 .get_field("tags")
900 .unwrap()
901 .as_sequence()
902 .unwrap();
903 assert_eq!(tags.len(), 2);
904 assert_eq!(tags[0].as_str().unwrap(), "default");
905 assert_eq!(tags[1].as_str().unwrap(), "tag");
906 }
907
908 #[test]
909 fn test_with_coercion_singular_to_array() {
910 use std::collections::HashMap;
911
912 let schema = QuillValue::from_json(serde_json::json!({
913 "$schema": "https://json-schema.org/draft/2019-09/schema",
914 "type": "object",
915 "properties": {
916 "tags": {"type": "array"}
917 }
918 }));
919
920 let mut fields = HashMap::new();
921 fields.insert(
922 "tags".to_string(),
923 QuillValue::from_json(serde_json::json!("single-tag")),
924 );
925 let doc = ParsedDocument::new(fields);
926
927 let coerced_doc = doc.with_coercion(&schema);
928
929 let tags = coerced_doc.get_field("tags").unwrap();
930 assert!(tags.as_array().is_some());
931 let tags_array = tags.as_array().unwrap();
932 assert_eq!(tags_array.len(), 1);
933 assert_eq!(tags_array[0].as_str().unwrap(), "single-tag");
934 }
935
936 #[test]
937 fn test_with_coercion_string_to_boolean() {
938 use std::collections::HashMap;
939
940 let schema = QuillValue::from_json(serde_json::json!({
941 "$schema": "https://json-schema.org/draft/2019-09/schema",
942 "type": "object",
943 "properties": {
944 "active": {"type": "boolean"}
945 }
946 }));
947
948 let mut fields = HashMap::new();
949 fields.insert(
950 "active".to_string(),
951 QuillValue::from_json(serde_json::json!("true")),
952 );
953 let doc = ParsedDocument::new(fields);
954
955 let coerced_doc = doc.with_coercion(&schema);
956
957 assert!(coerced_doc.get_field("active").unwrap().as_bool().unwrap());
958 }
959
960 #[test]
961 fn test_with_coercion_string_to_number() {
962 use std::collections::HashMap;
963
964 let schema = QuillValue::from_json(serde_json::json!({
965 "$schema": "https://json-schema.org/draft/2019-09/schema",
966 "type": "object",
967 "properties": {
968 "count": {"type": "number"}
969 }
970 }));
971
972 let mut fields = HashMap::new();
973 fields.insert(
974 "count".to_string(),
975 QuillValue::from_json(serde_json::json!("42")),
976 );
977 let doc = ParsedDocument::new(fields);
978
979 let coerced_doc = doc.with_coercion(&schema);
980
981 assert_eq!(
982 coerced_doc.get_field("count").unwrap().as_i64().unwrap(),
983 42
984 );
985 }
986
987 #[test]
988 fn test_invalid_yaml() {
989 let markdown = r#"---
990title: [invalid yaml
991author: missing close bracket
992---
993
994Content here."#;
995
996 let result = decompose(markdown);
997 assert!(result.is_err());
998 assert!(result
999 .unwrap_err()
1000 .to_string()
1001 .contains("YAML parsing error"));
1002 }
1003
1004 #[test]
1005 fn test_unclosed_frontmatter() {
1006 let markdown = r#"---
1007title: Test
1008author: Test Author
1009
1010Content without closing ---"#;
1011
1012 let result = decompose(markdown);
1013 assert!(result.is_err());
1014 assert!(result.unwrap_err().to_string().contains("not closed"));
1015 }
1016
1017 #[test]
1020 fn test_basic_tagged_block() {
1021 let markdown = r#"---
1022title: Main Document
1023---
1024
1025Main body content.
1026
1027---
1028CARD: items
1029name: Item 1
1030---
1031
1032Body of item 1."#;
1033
1034 let doc = decompose(markdown).unwrap();
1035
1036 assert_eq!(doc.body(), Some("\nMain body content.\n\n"));
1037 assert_eq!(
1038 doc.get_field("title").unwrap().as_str().unwrap(),
1039 "Main Document"
1040 );
1041
1042 let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
1044 assert_eq!(cards.len(), 1);
1045
1046 let item = cards[0].as_object().unwrap();
1047 assert_eq!(item.get("CARD").unwrap().as_str().unwrap(), "items");
1048 assert_eq!(item.get("name").unwrap().as_str().unwrap(), "Item 1");
1049 assert_eq!(
1050 item.get("body").unwrap().as_str().unwrap(),
1051 "\nBody of item 1."
1052 );
1053 }
1054
1055 #[test]
1056 fn test_multiple_tagged_blocks() {
1057 let markdown = r#"---
1058CARD: items
1059name: Item 1
1060tags: [a, b]
1061---
1062
1063First item body.
1064
1065---
1066CARD: items
1067name: Item 2
1068tags: [c, d]
1069---
1070
1071Second item body."#;
1072
1073 let doc = decompose(markdown).unwrap();
1074
1075 let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
1077 assert_eq!(cards.len(), 2);
1078
1079 let item1 = cards[0].as_object().unwrap();
1080 assert_eq!(item1.get("CARD").unwrap().as_str().unwrap(), "items");
1081 assert_eq!(item1.get("name").unwrap().as_str().unwrap(), "Item 1");
1082
1083 let item2 = cards[1].as_object().unwrap();
1084 assert_eq!(item2.get("CARD").unwrap().as_str().unwrap(), "items");
1085 assert_eq!(item2.get("name").unwrap().as_str().unwrap(), "Item 2");
1086 }
1087
1088 #[test]
1089 fn test_mixed_global_and_tagged() {
1090 let markdown = r#"---
1091title: Global
1092author: John Doe
1093---
1094
1095Global body.
1096
1097---
1098CARD: sections
1099title: Section 1
1100---
1101
1102Section 1 content.
1103
1104---
1105CARD: sections
1106title: Section 2
1107---
1108
1109Section 2 content."#;
1110
1111 let doc = decompose(markdown).unwrap();
1112
1113 assert_eq!(doc.get_field("title").unwrap().as_str().unwrap(), "Global");
1114 assert_eq!(doc.body(), Some("\nGlobal body.\n\n"));
1115
1116 let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
1118 assert_eq!(cards.len(), 2);
1119 assert_eq!(
1120 cards[0]
1121 .as_object()
1122 .unwrap()
1123 .get("CARD")
1124 .unwrap()
1125 .as_str()
1126 .unwrap(),
1127 "sections"
1128 );
1129 }
1130
1131 #[test]
1132 fn test_empty_tagged_metadata() {
1133 let markdown = r#"---
1134CARD: items
1135---
1136
1137Body without metadata."#;
1138
1139 let doc = decompose(markdown).unwrap();
1140
1141 let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
1142 assert_eq!(cards.len(), 1);
1143
1144 let item = cards[0].as_object().unwrap();
1145 assert_eq!(item.get("CARD").unwrap().as_str().unwrap(), "items");
1146 assert_eq!(
1147 item.get("body").unwrap().as_str().unwrap(),
1148 "\nBody without metadata."
1149 );
1150 }
1151
1152 #[test]
1153 fn test_tagged_block_without_body() {
1154 let markdown = r#"---
1155CARD: items
1156name: Item
1157---"#;
1158
1159 let doc = decompose(markdown).unwrap();
1160
1161 let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
1162 assert_eq!(cards.len(), 1);
1163
1164 let item = cards[0].as_object().unwrap();
1165 assert_eq!(item.get("CARD").unwrap().as_str().unwrap(), "items");
1166 assert_eq!(item.get("body").unwrap().as_str().unwrap(), "");
1167 }
1168
1169 #[test]
1170 fn test_name_collision_global_and_tagged() {
1171 let markdown = r#"---
1172items: "global value"
1173---
1174
1175Body
1176
1177---
1178CARD: items
1179name: Item
1180---
1181
1182Item body"#;
1183
1184 let result = decompose(markdown);
1185 assert!(result.is_err());
1186 assert!(result.unwrap_err().to_string().contains("collision"));
1187 }
1188
1189 #[test]
1190 fn test_card_name_collision_with_array_field() {
1191 let markdown = r#"---
1193items:
1194 - name: Global Item 1
1195 value: 100
1196---
1197
1198Global body
1199
1200---
1201CARD: items
1202name: Scope Item 1
1203---
1204
1205Scope item 1 body"#;
1206
1207 let result = decompose(markdown);
1208 assert!(result.is_err());
1209 assert!(result.unwrap_err().to_string().contains("collision"));
1210 }
1211
1212 #[test]
1213 fn test_empty_global_array_with_card() {
1214 let markdown = r#"---
1216items: []
1217---
1218
1219Global body
1220
1221---
1222CARD: items
1223name: Item 1
1224---
1225
1226Item 1 body"#;
1227
1228 let result = decompose(markdown);
1229 assert!(result.is_err());
1230 assert!(result.unwrap_err().to_string().contains("collision"));
1231 }
1232
1233 #[test]
1234 fn test_reserved_field_name() {
1235 let markdown = r#"---
1236CARD: body
1237content: Test
1238---"#;
1239
1240 let result = decompose(markdown);
1241 assert!(result.is_err());
1242 assert!(result.unwrap_err().to_string().contains("reserved"));
1243 }
1244
1245 #[test]
1246 fn test_invalid_tag_syntax() {
1247 let markdown = r#"---
1248CARD: Invalid-Name
1249title: Test
1250---"#;
1251
1252 let result = decompose(markdown);
1253 assert!(result.is_err());
1254 assert!(result
1255 .unwrap_err()
1256 .to_string()
1257 .contains("Invalid card field name"));
1258 }
1259
1260 #[test]
1261 fn test_multiple_global_frontmatter_blocks() {
1262 let markdown = r#"---
1263title: First
1264---
1265
1266Body
1267
1268---
1269author: Second
1270---
1271
1272More body"#;
1273
1274 let result = decompose(markdown);
1275 assert!(result.is_err());
1276
1277 let err = result.unwrap_err();
1279 let err_str = err.to_string();
1280 assert!(
1281 err_str.contains("CARD"),
1282 "Error should mention CARD directive: {}",
1283 err_str
1284 );
1285 assert!(
1286 err_str.contains("missing"),
1287 "Error should indicate missing directive: {}",
1288 err_str
1289 );
1290 }
1291
1292 #[test]
1293 fn test_adjacent_blocks_different_tags() {
1294 let markdown = r#"---
1295CARD: items
1296name: Item 1
1297---
1298
1299Item 1 body
1300
1301---
1302CARD: sections
1303title: Section 1
1304---
1305
1306Section 1 body"#;
1307
1308 let doc = decompose(markdown).unwrap();
1309
1310 let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
1312 assert_eq!(cards.len(), 2);
1313
1314 let item = cards[0].as_object().unwrap();
1316 assert_eq!(item.get("CARD").unwrap().as_str().unwrap(), "items");
1317 assert_eq!(item.get("name").unwrap().as_str().unwrap(), "Item 1");
1318
1319 let section = cards[1].as_object().unwrap();
1321 assert_eq!(section.get("CARD").unwrap().as_str().unwrap(), "sections");
1322 assert_eq!(section.get("title").unwrap().as_str().unwrap(), "Section 1");
1323 }
1324
1325 #[test]
1326 fn test_order_preservation() {
1327 let markdown = r#"---
1328CARD: items
1329id: 1
1330---
1331
1332First
1333
1334---
1335CARD: items
1336id: 2
1337---
1338
1339Second
1340
1341---
1342CARD: items
1343id: 3
1344---
1345
1346Third"#;
1347
1348 let doc = decompose(markdown).unwrap();
1349
1350 let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
1351 assert_eq!(cards.len(), 3);
1352
1353 for (i, card) in cards.iter().enumerate() {
1354 let mapping = card.as_object().unwrap();
1355 assert_eq!(mapping.get("CARD").unwrap().as_str().unwrap(), "items");
1356 let id = mapping.get("id").unwrap().as_i64().unwrap();
1357 assert_eq!(id, (i + 1) as i64);
1358 }
1359 }
1360
1361 #[test]
1362 fn test_product_catalog_integration() {
1363 let markdown = r#"---
1364title: Product Catalog
1365author: John Doe
1366date: 2024-01-01
1367---
1368
1369This is the main catalog description.
1370
1371---
1372CARD: products
1373name: Widget A
1374price: 19.99
1375sku: WID-001
1376---
1377
1378The **Widget A** is our most popular product.
1379
1380---
1381CARD: products
1382name: Gadget B
1383price: 29.99
1384sku: GAD-002
1385---
1386
1387The **Gadget B** is perfect for professionals.
1388
1389---
1390CARD: reviews
1391product: Widget A
1392rating: 5
1393---
1394
1395"Excellent product! Highly recommended."
1396
1397---
1398CARD: reviews
1399product: Gadget B
1400rating: 4
1401---
1402
1403"Very good, but a bit pricey.""#;
1404
1405 let doc = decompose(markdown).unwrap();
1406
1407 assert_eq!(
1409 doc.get_field("title").unwrap().as_str().unwrap(),
1410 "Product Catalog"
1411 );
1412 assert_eq!(
1413 doc.get_field("author").unwrap().as_str().unwrap(),
1414 "John Doe"
1415 );
1416 assert_eq!(
1417 doc.get_field("date").unwrap().as_str().unwrap(),
1418 "2024-01-01"
1419 );
1420
1421 assert!(doc.body().unwrap().contains("main catalog description"));
1423
1424 let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
1426 assert_eq!(cards.len(), 4); let product1 = cards[0].as_object().unwrap();
1430 assert_eq!(product1.get("CARD").unwrap().as_str().unwrap(), "products");
1431 assert_eq!(product1.get("name").unwrap().as_str().unwrap(), "Widget A");
1432 assert_eq!(product1.get("price").unwrap().as_f64().unwrap(), 19.99);
1433
1434 let product2 = cards[1].as_object().unwrap();
1435 assert_eq!(product2.get("CARD").unwrap().as_str().unwrap(), "products");
1436 assert_eq!(product2.get("name").unwrap().as_str().unwrap(), "Gadget B");
1437
1438 let review1 = cards[2].as_object().unwrap();
1440 assert_eq!(review1.get("CARD").unwrap().as_str().unwrap(), "reviews");
1441 assert_eq!(
1442 review1.get("product").unwrap().as_str().unwrap(),
1443 "Widget A"
1444 );
1445 assert_eq!(review1.get("rating").unwrap().as_i64().unwrap(), 5);
1446
1447 assert_eq!(doc.fields().len(), 5);
1449 }
1450
1451 #[test]
1452 fn taro_quill_directive() {
1453 let markdown = r#"---
1454QUILL: usaf_memo
1455memo_for: [ORG/SYMBOL]
1456memo_from: [ORG/SYMBOL]
1457---
1458
1459This is the memo body."#;
1460
1461 let doc = decompose(markdown).unwrap();
1462
1463 assert_eq!(doc.quill_tag(), "usaf_memo");
1465
1466 assert_eq!(
1468 doc.get_field("memo_for").unwrap().as_sequence().unwrap()[0]
1469 .as_str()
1470 .unwrap(),
1471 "ORG/SYMBOL"
1472 );
1473
1474 assert_eq!(doc.body(), Some("\nThis is the memo body."));
1476 }
1477
1478 #[test]
1479 fn test_quill_with_card_blocks() {
1480 let markdown = r#"---
1481QUILL: document
1482title: Test Document
1483---
1484
1485Main body.
1486
1487---
1488CARD: sections
1489name: Section 1
1490---
1491
1492Section 1 body."#;
1493
1494 let doc = decompose(markdown).unwrap();
1495
1496 assert_eq!(doc.quill_tag(), "document");
1498
1499 assert_eq!(
1501 doc.get_field("title").unwrap().as_str().unwrap(),
1502 "Test Document"
1503 );
1504
1505 let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
1507 assert_eq!(cards.len(), 1);
1508 assert_eq!(
1509 cards[0]
1510 .as_object()
1511 .unwrap()
1512 .get("CARD")
1513 .unwrap()
1514 .as_str()
1515 .unwrap(),
1516 "sections"
1517 );
1518
1519 assert_eq!(doc.body(), Some("\nMain body.\n\n"));
1521 }
1522
1523 #[test]
1524 fn test_multiple_quill_directives_error() {
1525 let markdown = r#"---
1526QUILL: first
1527---
1528
1529---
1530QUILL: second
1531---"#;
1532
1533 let result = decompose(markdown);
1534 assert!(result.is_err());
1535 assert!(result
1537 .unwrap_err()
1538 .to_string()
1539 .contains("top-level frontmatter"));
1540 }
1541
1542 #[test]
1543 fn test_invalid_quill_name() {
1544 let markdown = r#"---
1545QUILL: Invalid-Name
1546---"#;
1547
1548 let result = decompose(markdown);
1549 assert!(result.is_err());
1550 assert!(result
1551 .unwrap_err()
1552 .to_string()
1553 .contains("Invalid quill name"));
1554 }
1555
1556 #[test]
1557 fn test_quill_wrong_value_type() {
1558 let markdown = r#"---
1559QUILL: 123
1560---"#;
1561
1562 let result = decompose(markdown);
1563 assert!(result.is_err());
1564 assert!(result
1565 .unwrap_err()
1566 .to_string()
1567 .contains("QUILL value must be a string"));
1568 }
1569
1570 #[test]
1571 fn test_card_wrong_value_type() {
1572 let markdown = r#"---
1573CARD: 123
1574---"#;
1575
1576 let result = decompose(markdown);
1577 assert!(result.is_err());
1578 assert!(result
1579 .unwrap_err()
1580 .to_string()
1581 .contains("CARD/SCOPE value must be a string"));
1582 }
1583
1584 #[test]
1585 fn test_both_quill_and_card_error() {
1586 let markdown = r#"---
1587QUILL: test
1588CARD: items
1589---"#;
1590
1591 let result = decompose(markdown);
1592 assert!(result.is_err());
1593 assert!(result
1594 .unwrap_err()
1595 .to_string()
1596 .contains("Cannot specify both QUILL and CARD"));
1597 }
1598
1599 #[test]
1600 fn test_blank_lines_in_frontmatter() {
1601 let markdown = r#"---
1603title: Test Document
1604author: Test Author
1605
1606description: This has a blank line above it
1607tags:
1608 - one
1609 - two
1610---
1611
1612# Hello World
1613
1614This is the body."#;
1615
1616 let doc = decompose(markdown).unwrap();
1617
1618 assert_eq!(doc.body(), Some("\n# Hello World\n\nThis is the body."));
1619 assert_eq!(
1620 doc.get_field("title").unwrap().as_str().unwrap(),
1621 "Test Document"
1622 );
1623 assert_eq!(
1624 doc.get_field("author").unwrap().as_str().unwrap(),
1625 "Test Author"
1626 );
1627 assert_eq!(
1628 doc.get_field("description").unwrap().as_str().unwrap(),
1629 "This has a blank line above it"
1630 );
1631
1632 let tags = doc.get_field("tags").unwrap().as_sequence().unwrap();
1633 assert_eq!(tags.len(), 2);
1634 }
1635
1636 #[test]
1637 fn test_blank_lines_in_scope_blocks() {
1638 let markdown = r#"---
1640CARD: items
1641name: Item 1
1642
1643price: 19.99
1644
1645tags:
1646 - electronics
1647 - gadgets
1648---
1649
1650Body of item 1."#;
1651
1652 let doc = decompose(markdown).unwrap();
1653
1654 let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
1656 assert_eq!(cards.len(), 1);
1657
1658 let item = cards[0].as_object().unwrap();
1659 assert_eq!(item.get("CARD").unwrap().as_str().unwrap(), "items");
1660 assert_eq!(item.get("name").unwrap().as_str().unwrap(), "Item 1");
1661 assert_eq!(item.get("price").unwrap().as_f64().unwrap(), 19.99);
1662
1663 let tags = item.get("tags").unwrap().as_array().unwrap();
1664 assert_eq!(tags.len(), 2);
1665 }
1666
1667 #[test]
1668 fn test_horizontal_rule_with_blank_lines_above_and_below() {
1669 let markdown = r#"---
1671title: Test
1672---
1673
1674First paragraph.
1675
1676---
1677
1678Second paragraph."#;
1679
1680 let doc = decompose(markdown).unwrap();
1681
1682 assert_eq!(doc.get_field("title").unwrap().as_str().unwrap(), "Test");
1683
1684 let body = doc.body().unwrap();
1686 assert!(body.contains("First paragraph."));
1687 assert!(body.contains("---"));
1688 assert!(body.contains("Second paragraph."));
1689 }
1690
1691 #[test]
1692 fn test_horizontal_rule_not_preceded_by_blank() {
1693 let markdown = r#"---
1696title: Test
1697---
1698
1699First paragraph.
1700---
1701
1702Second paragraph."#;
1703
1704 let doc = decompose(markdown).unwrap();
1705
1706 let body = doc.body().unwrap();
1707 assert!(body.contains("---"));
1709 }
1710
1711 #[test]
1712 fn test_multiple_blank_lines_in_yaml() {
1713 let markdown = r#"---
1715title: Test
1716
1717
1718author: John Doe
1719
1720
1721version: 1.0
1722---
1723
1724Body content."#;
1725
1726 let doc = decompose(markdown).unwrap();
1727
1728 assert_eq!(doc.get_field("title").unwrap().as_str().unwrap(), "Test");
1729 assert_eq!(
1730 doc.get_field("author").unwrap().as_str().unwrap(),
1731 "John Doe"
1732 );
1733 assert_eq!(doc.get_field("version").unwrap().as_f64().unwrap(), 1.0);
1734 }
1735
1736 #[test]
1737 fn test_html_comment_interaction() {
1738 let markdown = r#"<!---
1739---> the rest of the page content
1740
1741---
1742key: value
1743---
1744"#;
1745 let doc = decompose(markdown).unwrap();
1746
1747 let key = doc.get_field("key").and_then(|v| v.as_str());
1750 assert_eq!(key, Some("value"));
1751 }
1752}
1753#[cfg(test)]
1754mod demo_file_test {
1755 use super::*;
1756
1757 #[test]
1758 fn test_extended_metadata_demo_file() {
1759 let markdown = include_str!("../../fixtures/resources/extended_metadata_demo.md");
1760 let doc = decompose(markdown).unwrap();
1761
1762 assert_eq!(
1764 doc.get_field("title").unwrap().as_str().unwrap(),
1765 "Extended Metadata Demo"
1766 );
1767 assert_eq!(
1768 doc.get_field("author").unwrap().as_str().unwrap(),
1769 "Quillmark Team"
1770 );
1771 assert_eq!(doc.get_field("version").unwrap().as_f64().unwrap(), 1.0);
1773
1774 assert!(doc
1776 .body()
1777 .unwrap()
1778 .contains("extended YAML metadata standard"));
1779
1780 let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
1782 assert_eq!(cards.len(), 5); let features_count = cards
1786 .iter()
1787 .filter(|c| {
1788 c.as_object()
1789 .unwrap()
1790 .get("CARD")
1791 .unwrap()
1792 .as_str()
1793 .unwrap()
1794 == "features"
1795 })
1796 .count();
1797 let use_cases_count = cards
1798 .iter()
1799 .filter(|c| {
1800 c.as_object()
1801 .unwrap()
1802 .get("CARD")
1803 .unwrap()
1804 .as_str()
1805 .unwrap()
1806 == "use_cases"
1807 })
1808 .count();
1809 assert_eq!(features_count, 3);
1810 assert_eq!(use_cases_count, 2);
1811
1812 let feature1 = cards[0].as_object().unwrap();
1814 assert_eq!(feature1.get("CARD").unwrap().as_str().unwrap(), "features");
1815 assert_eq!(
1816 feature1.get("name").unwrap().as_str().unwrap(),
1817 "Tag Directives"
1818 );
1819 }
1820
1821 #[test]
1822 fn test_input_size_limit() {
1823 let size = crate::error::MAX_INPUT_SIZE + 1;
1825 let large_markdown = "a".repeat(size);
1826
1827 let result = decompose(&large_markdown);
1828 assert!(result.is_err());
1829
1830 let err_msg = result.unwrap_err().to_string();
1831 assert!(err_msg.contains("Input too large"));
1832 }
1833
1834 #[test]
1835 fn test_yaml_size_limit() {
1836 let mut markdown = String::from("---\n");
1838
1839 let size = crate::error::MAX_YAML_SIZE + 1;
1841 markdown.push_str("data: \"");
1842 markdown.push_str(&"x".repeat(size));
1843 markdown.push_str("\"\n---\n\nBody");
1844
1845 let result = decompose(&markdown);
1846 assert!(result.is_err());
1847
1848 let err_msg = result.unwrap_err().to_string();
1849 assert!(err_msg.contains("Input too large"));
1850 }
1851
1852 #[test]
1853 fn test_input_within_size_limit() {
1854 let size = 1000; let markdown = format!("---\ntitle: Test\n---\n\n{}", "a".repeat(size));
1857
1858 let result = decompose(&markdown);
1859 assert!(result.is_ok());
1860 }
1861
1862 #[test]
1863 fn test_yaml_within_size_limit() {
1864 let markdown = "---\ntitle: Test\nauthor: John Doe\n---\n\nBody content";
1866
1867 let result = decompose(markdown);
1868 assert!(result.is_ok());
1869 }
1870
1871 #[test]
1874 fn test_chevrons_preserved_in_body_no_frontmatter() {
1875 let markdown = "Use <<raw content>> here.";
1876 let doc = decompose(markdown).unwrap();
1877
1878 assert_eq!(doc.body(), Some("Use <<raw content>> here."));
1880 }
1881
1882 #[test]
1883 fn test_chevrons_preserved_in_body_with_frontmatter() {
1884 let markdown = r#"---
1885title: Test
1886---
1887
1888Use <<raw content>> here."#;
1889 let doc = decompose(markdown).unwrap();
1890
1891 assert_eq!(doc.body(), Some("\nUse <<raw content>> here."));
1893 }
1894
1895 #[test]
1896 fn test_chevrons_preserved_in_yaml_string() {
1897 let markdown = r#"---
1898title: Test <<with chevrons>>
1899---
1900
1901Body content."#;
1902 let doc = decompose(markdown).unwrap();
1903
1904 assert_eq!(
1906 doc.get_field("title").unwrap().as_str().unwrap(),
1907 "Test <<with chevrons>>"
1908 );
1909 }
1910
1911 #[test]
1912 fn test_chevrons_preserved_in_yaml_array() {
1913 let markdown = r#"---
1914items:
1915 - "<<first>>"
1916 - "<<second>>"
1917---
1918
1919Body."#;
1920 let doc = decompose(markdown).unwrap();
1921
1922 let items = doc.get_field("items").unwrap().as_sequence().unwrap();
1923 assert_eq!(items[0].as_str().unwrap(), "<<first>>");
1924 assert_eq!(items[1].as_str().unwrap(), "<<second>>");
1925 }
1926
1927 #[test]
1928 fn test_chevrons_preserved_in_yaml_nested() {
1929 let markdown = r#"---
1930metadata:
1931 description: "<<nested value>>"
1932---
1933
1934Body."#;
1935 let doc = decompose(markdown).unwrap();
1936
1937 let metadata = doc.get_field("metadata").unwrap().as_object().unwrap();
1938 assert_eq!(
1939 metadata.get("description").unwrap().as_str().unwrap(),
1940 "<<nested value>>"
1941 );
1942 }
1943
1944 #[test]
1945 fn test_chevrons_preserved_in_code_blocks() {
1946 let markdown = r#"```
1947<<in code block>>
1948```
1949
1950<<outside code block>>"#;
1951 let doc = decompose(markdown).unwrap();
1952
1953 let body = doc.body().unwrap();
1954 assert!(body.contains("<<in code block>>"));
1956 assert!(body.contains("<<outside code block>>"));
1957 }
1958
1959 #[test]
1960 fn test_chevrons_preserved_in_inline_code() {
1961 let markdown = "`<<in inline code>>` and <<outside inline code>>";
1962 let doc = decompose(markdown).unwrap();
1963
1964 let body = doc.body().unwrap();
1965 assert!(body.contains("`<<in inline code>>`"));
1967 assert!(body.contains("<<outside inline code>>"));
1968 }
1969
1970 #[test]
1971 fn test_chevrons_preserved_in_tagged_block_body() {
1972 let markdown = r#"---
1973title: Main
1974---
1975
1976Main body.
1977
1978---
1979CARD: items
1980name: Item 1
1981---
1982
1983Use <<raw>> here."#;
1984 let doc = decompose(markdown).unwrap();
1985
1986 let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
1987 let item = cards[0].as_object().unwrap();
1988 assert_eq!(item.get("CARD").unwrap().as_str().unwrap(), "items");
1989 let item_body = item.get("body").unwrap().as_str().unwrap();
1990 assert!(item_body.contains("<<raw>>"));
1992 }
1993
1994 #[test]
1995 fn test_chevrons_preserved_in_tagged_block_yaml() {
1996 let markdown = r#"---
1997title: Main
1998---
1999
2000Main body.
2001
2002---
2003CARD: items
2004description: "<<tagged yaml>>"
2005---
2006
2007Item body."#;
2008 let doc = decompose(markdown).unwrap();
2009
2010 let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
2011 let item = cards[0].as_object().unwrap();
2012 assert_eq!(item.get("CARD").unwrap().as_str().unwrap(), "items");
2013 assert_eq!(
2015 item.get("description").unwrap().as_str().unwrap(),
2016 "<<tagged yaml>>"
2017 );
2018 }
2019
2020 #[test]
2021 fn test_yaml_numbers_not_affected() {
2022 let markdown = r#"---
2024count: 42
2025---
2026
2027Body."#;
2028 let doc = decompose(markdown).unwrap();
2029 assert_eq!(doc.get_field("count").unwrap().as_i64().unwrap(), 42);
2030 }
2031
2032 #[test]
2033 fn test_yaml_booleans_not_affected() {
2034 let markdown = r#"---
2036active: true
2037---
2038
2039Body."#;
2040 let doc = decompose(markdown).unwrap();
2041 assert!(doc.get_field("active").unwrap().as_bool().unwrap());
2042 }
2043
2044 #[test]
2045 fn test_multiline_chevrons_preserved() {
2046 let markdown = "<<text\nacross lines>>";
2048 let doc = decompose(markdown).unwrap();
2049
2050 let body = doc.body().unwrap();
2051 assert!(body.contains("<<text"));
2053 assert!(body.contains("across lines>>"));
2054 }
2055
2056 #[test]
2057 fn test_unmatched_chevrons_preserved() {
2058 let markdown = "<<unmatched";
2059 let doc = decompose(markdown).unwrap();
2060
2061 let body = doc.body().unwrap();
2062 assert_eq!(body, "<<unmatched");
2064 }
2065}
2066
2067#[cfg(test)]
2069mod robustness_tests {
2070 use super::*;
2071
2072 #[test]
2075 fn test_empty_document() {
2076 let doc = decompose("").unwrap();
2077 assert_eq!(doc.body(), Some(""));
2078 assert_eq!(doc.quill_tag(), "__default__");
2079 }
2080
2081 #[test]
2082 fn test_only_whitespace() {
2083 let doc = decompose(" \n\n \t").unwrap();
2084 assert_eq!(doc.body(), Some(" \n\n \t"));
2085 }
2086
2087 #[test]
2088 fn test_only_dashes() {
2089 let result = decompose("---");
2092 assert!(result.is_ok());
2094 assert_eq!(result.unwrap().body(), Some("---"));
2095 }
2096
2097 #[test]
2098 fn test_dashes_in_middle_of_line() {
2099 let markdown = "some text --- more text";
2101 let doc = decompose(markdown).unwrap();
2102 assert_eq!(doc.body(), Some("some text --- more text"));
2103 }
2104
2105 #[test]
2106 fn test_four_dashes() {
2107 let markdown = "----\ntitle: Test\n----\n\nBody";
2109 let doc = decompose(markdown).unwrap();
2110 assert!(doc.body().unwrap().contains("----"));
2112 }
2113
2114 #[test]
2115 fn test_crlf_line_endings() {
2116 let markdown = "---\r\ntitle: Test\r\n---\r\n\r\nBody content.";
2118 let doc = decompose(markdown).unwrap();
2119 assert_eq!(doc.get_field("title").unwrap().as_str().unwrap(), "Test");
2120 assert!(doc.body().unwrap().contains("Body content."));
2121 }
2122
2123 #[test]
2124 fn test_mixed_line_endings() {
2125 let markdown = "---\ntitle: Test\r\n---\n\nBody.";
2127 let doc = decompose(markdown).unwrap();
2128 assert_eq!(doc.get_field("title").unwrap().as_str().unwrap(), "Test");
2129 }
2130
2131 #[test]
2132 fn test_frontmatter_at_eof_no_trailing_newline() {
2133 let markdown = "---\ntitle: Test\n---";
2135 let doc = decompose(markdown).unwrap();
2136 assert_eq!(doc.get_field("title").unwrap().as_str().unwrap(), "Test");
2137 assert_eq!(doc.body(), Some(""));
2138 }
2139
2140 #[test]
2141 fn test_empty_frontmatter() {
2142 let markdown = "---\n \n---\n\nBody content.";
2147 let doc = decompose(markdown).unwrap();
2148 assert!(doc.body().unwrap().contains("Body content."));
2149 assert_eq!(doc.fields().len(), 2);
2151 }
2152
2153 #[test]
2154 fn test_whitespace_only_frontmatter() {
2155 let markdown = "---\n \n\n \n---\n\nBody.";
2157 let doc = decompose(markdown).unwrap();
2158 assert!(doc.body().unwrap().contains("Body."));
2159 }
2160
2161 #[test]
2164 fn test_unicode_in_yaml_keys() {
2165 let markdown = "---\ntitre: Bonjour\nタイトル: こんにちは\n---\n\nBody.";
2166 let doc = decompose(markdown).unwrap();
2167 assert_eq!(doc.get_field("titre").unwrap().as_str().unwrap(), "Bonjour");
2168 assert_eq!(
2169 doc.get_field("タイトル").unwrap().as_str().unwrap(),
2170 "こんにちは"
2171 );
2172 }
2173
2174 #[test]
2175 fn test_unicode_in_yaml_values() {
2176 let markdown = "---\ntitle: 你好世界 🎉\n---\n\nBody.";
2177 let doc = decompose(markdown).unwrap();
2178 assert_eq!(
2179 doc.get_field("title").unwrap().as_str().unwrap(),
2180 "你好世界 🎉"
2181 );
2182 }
2183
2184 #[test]
2185 fn test_unicode_in_body() {
2186 let markdown = "---\ntitle: Test\n---\n\n日本語テキスト with emoji 🚀";
2187 let doc = decompose(markdown).unwrap();
2188 assert!(doc.body().unwrap().contains("日本語テキスト"));
2189 assert!(doc.body().unwrap().contains("🚀"));
2190 }
2191
2192 #[test]
2195 fn test_yaml_multiline_string() {
2196 let markdown = r#"---
2197description: |
2198 This is a
2199 multiline string
2200 with preserved newlines.
2201---
2202
2203Body."#;
2204 let doc = decompose(markdown).unwrap();
2205 let desc = doc.get_field("description").unwrap().as_str().unwrap();
2206 assert!(desc.contains("multiline string"));
2207 assert!(desc.contains('\n'));
2208 }
2209
2210 #[test]
2211 fn test_yaml_folded_string() {
2212 let markdown = r#"---
2213description: >
2214 This is a folded
2215 string that becomes
2216 a single line.
2217---
2218
2219Body."#;
2220 let doc = decompose(markdown).unwrap();
2221 let desc = doc.get_field("description").unwrap().as_str().unwrap();
2222 assert!(desc.contains("folded"));
2224 }
2225
2226 #[test]
2227 fn test_yaml_null_value() {
2228 let markdown = "---\noptional: null\n---\n\nBody.";
2229 let doc = decompose(markdown).unwrap();
2230 assert!(doc.get_field("optional").unwrap().is_null());
2231 }
2232
2233 #[test]
2234 fn test_yaml_empty_string_value() {
2235 let markdown = "---\nempty: \"\"\n---\n\nBody.";
2236 let doc = decompose(markdown).unwrap();
2237 assert_eq!(doc.get_field("empty").unwrap().as_str().unwrap(), "");
2238 }
2239
2240 #[test]
2241 fn test_yaml_special_characters_in_string() {
2242 let markdown = "---\nspecial: \"colon: here, and [brackets]\"\n---\n\nBody.";
2243 let doc = decompose(markdown).unwrap();
2244 assert_eq!(
2245 doc.get_field("special").unwrap().as_str().unwrap(),
2246 "colon: here, and [brackets]"
2247 );
2248 }
2249
2250 #[test]
2251 fn test_yaml_nested_objects() {
2252 let markdown = r#"---
2253config:
2254 database:
2255 host: localhost
2256 port: 5432
2257 cache:
2258 enabled: true
2259---
2260
2261Body."#;
2262 let doc = decompose(markdown).unwrap();
2263 let config = doc.get_field("config").unwrap().as_object().unwrap();
2264 let db = config.get("database").unwrap().as_object().unwrap();
2265 assert_eq!(db.get("host").unwrap().as_str().unwrap(), "localhost");
2266 assert_eq!(db.get("port").unwrap().as_i64().unwrap(), 5432);
2267 }
2268
2269 #[test]
2272 fn test_card_with_empty_body() {
2273 let markdown = r#"---
2274CARD: items
2275name: Item
2276---"#;
2277 let doc = decompose(markdown).unwrap();
2278 let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
2279 assert_eq!(cards.len(), 1);
2280 let item = cards[0].as_object().unwrap();
2281 assert_eq!(item.get("CARD").unwrap().as_str().unwrap(), "items");
2282 assert_eq!(item.get("body").unwrap().as_str().unwrap(), "");
2283 }
2284
2285 #[test]
2286 fn test_card_consecutive_blocks() {
2287 let markdown = r#"---
2288CARD: a
2289id: 1
2290---
2291---
2292CARD: a
2293id: 2
2294---"#;
2295 let doc = decompose(markdown).unwrap();
2296 let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
2297 assert_eq!(cards.len(), 2);
2298 assert_eq!(
2299 cards[0]
2300 .as_object()
2301 .unwrap()
2302 .get("CARD")
2303 .unwrap()
2304 .as_str()
2305 .unwrap(),
2306 "a"
2307 );
2308 assert_eq!(
2309 cards[1]
2310 .as_object()
2311 .unwrap()
2312 .get("CARD")
2313 .unwrap()
2314 .as_str()
2315 .unwrap(),
2316 "a"
2317 );
2318 }
2319
2320 #[test]
2321 fn test_card_with_body_containing_dashes() {
2322 let markdown = r#"---
2323CARD: items
2324name: Item
2325---
2326
2327Some text with --- dashes in it."#;
2328 let doc = decompose(markdown).unwrap();
2329 let cards = doc.get_field("CARDS").unwrap().as_sequence().unwrap();
2330 let item = cards[0].as_object().unwrap();
2331 assert_eq!(item.get("CARD").unwrap().as_str().unwrap(), "items");
2332 let body = item.get("body").unwrap().as_str().unwrap();
2333 assert!(body.contains("--- dashes"));
2334 }
2335
2336 #[test]
2339 fn test_quill_with_underscore_prefix() {
2340 let markdown = "---\nQUILL: _internal\n---\n\nBody.";
2341 let doc = decompose(markdown).unwrap();
2342 assert_eq!(doc.quill_tag(), "_internal");
2343 }
2344
2345 #[test]
2346 fn test_quill_with_numbers() {
2347 let markdown = "---\nQUILL: form_8_v2\n---\n\nBody.";
2348 let doc = decompose(markdown).unwrap();
2349 assert_eq!(doc.quill_tag(), "form_8_v2");
2350 }
2351
2352 #[test]
2353 fn test_quill_with_additional_fields() {
2354 let markdown = r#"---
2355QUILL: my_quill
2356title: Document Title
2357author: John Doe
2358---
2359
2360Body content."#;
2361 let doc = decompose(markdown).unwrap();
2362 assert_eq!(doc.quill_tag(), "my_quill");
2363 assert_eq!(
2364 doc.get_field("title").unwrap().as_str().unwrap(),
2365 "Document Title"
2366 );
2367 assert_eq!(
2368 doc.get_field("author").unwrap().as_str().unwrap(),
2369 "John Doe"
2370 );
2371 }
2372
2373 #[test]
2376 fn test_invalid_scope_name_uppercase() {
2377 let markdown = "---\nCARD: ITEMS\n---\n\nBody.";
2378 let result = decompose(markdown);
2379 assert!(result.is_err());
2380 assert!(result
2381 .unwrap_err()
2382 .to_string()
2383 .contains("Invalid card field name"));
2384 }
2385
2386 #[test]
2387 fn test_invalid_scope_name_starts_with_number() {
2388 let markdown = "---\nCARD: 123items\n---\n\nBody.";
2389 let result = decompose(markdown);
2390 assert!(result.is_err());
2391 }
2392
2393 #[test]
2394 fn test_invalid_scope_name_with_hyphen() {
2395 let markdown = "---\nCARD: my-items\n---\n\nBody.";
2396 let result = decompose(markdown);
2397 assert!(result.is_err());
2398 }
2399
2400 #[test]
2401 fn test_invalid_quill_name_uppercase() {
2402 let markdown = "---\nQUILL: MyQuill\n---\n\nBody.";
2403 let result = decompose(markdown);
2404 assert!(result.is_err());
2405 }
2406
2407 #[test]
2408 fn test_yaml_syntax_error_missing_colon() {
2409 let markdown = "---\ntitle Test\n---\n\nBody.";
2410 let result = decompose(markdown);
2411 assert!(result.is_err());
2412 }
2413
2414 #[test]
2415 fn test_yaml_syntax_error_bad_indentation() {
2416 let markdown = "---\nitems:\n- one\n - two\n---\n\nBody.";
2417 let result = decompose(markdown);
2418 let _ = result;
2421 }
2422
2423 #[test]
2426 fn test_body_with_leading_newlines() {
2427 let markdown = "---\ntitle: Test\n---\n\n\n\nBody with leading newlines.";
2428 let doc = decompose(markdown).unwrap();
2429 assert!(doc.body().unwrap().starts_with('\n'));
2431 }
2432
2433 #[test]
2434 fn test_body_with_trailing_newlines() {
2435 let markdown = "---\ntitle: Test\n---\n\nBody.\n\n\n";
2436 let doc = decompose(markdown).unwrap();
2437 assert!(doc.body().unwrap().ends_with('\n'));
2439 }
2440
2441 #[test]
2442 fn test_no_body_after_frontmatter() {
2443 let markdown = "---\ntitle: Test\n---";
2444 let doc = decompose(markdown).unwrap();
2445 assert_eq!(doc.body(), Some(""));
2446 }
2447
2448 #[test]
2451 fn test_valid_tag_name_single_underscore() {
2452 assert!(is_valid_tag_name("_"));
2453 }
2454
2455 #[test]
2456 fn test_valid_tag_name_underscore_prefix() {
2457 assert!(is_valid_tag_name("_private"));
2458 }
2459
2460 #[test]
2461 fn test_valid_tag_name_with_numbers() {
2462 assert!(is_valid_tag_name("item1"));
2463 assert!(is_valid_tag_name("item_2"));
2464 }
2465
2466 #[test]
2467 fn test_invalid_tag_name_empty() {
2468 assert!(!is_valid_tag_name(""));
2469 }
2470
2471 #[test]
2472 fn test_invalid_tag_name_starts_with_number() {
2473 assert!(!is_valid_tag_name("1item"));
2474 }
2475
2476 #[test]
2477 fn test_invalid_tag_name_uppercase() {
2478 assert!(!is_valid_tag_name("Items"));
2479 assert!(!is_valid_tag_name("ITEMS"));
2480 }
2481
2482 #[test]
2483 fn test_invalid_tag_name_special_chars() {
2484 assert!(!is_valid_tag_name("my-items"));
2485 assert!(!is_valid_tag_name("my.items"));
2486 assert!(!is_valid_tag_name("my items"));
2487 }
2488
2489 #[test]
2492 fn test_guillemet_in_yaml_preserves_non_strings() {
2493 let markdown = r#"---
2494count: 42
2495price: 19.99
2496active: true
2497items:
2498 - first
2499 - 100
2500 - true
2501---
2502
2503Body."#;
2504 let doc = decompose(markdown).unwrap();
2505 assert_eq!(doc.get_field("count").unwrap().as_i64().unwrap(), 42);
2506 assert_eq!(doc.get_field("price").unwrap().as_f64().unwrap(), 19.99);
2507 assert!(doc.get_field("active").unwrap().as_bool().unwrap());
2508 }
2509
2510 #[test]
2511 fn test_guillemet_double_conversion_prevention() {
2512 let markdown = "---\ntitle: Already «converted»\n---\n\nBody.";
2514 let doc = decompose(markdown).unwrap();
2515 assert_eq!(
2517 doc.get_field("title").unwrap().as_str().unwrap(),
2518 "Already «converted»"
2519 );
2520 }
2521}