1use crate::error::MetadataError;
7use dtt::datetime::DateTime;
8use regex::Regex;
9use serde_json::Value as JsonValue;
10use std::collections::HashMap;
11use std::sync::LazyLock;
12use toml::Value as TomlValue;
13
14static YAML_FRONT_MATTER: LazyLock<Regex> = LazyLock::new(|| {
25 Regex::new(r"(?s)^\s*---\s*\n(.*?)\n\s*---\s*")
26 .expect("YAML front-matter regex is statically valid")
27});
28static TOML_FRONT_MATTER: LazyLock<Regex> = LazyLock::new(|| {
29 Regex::new(r"(?s)^\s*\+\+\+\s*(.*?)\s*\+\+\+")
30 .expect("TOML front-matter regex is statically valid")
31});
32
33#[derive(Debug, Default, Clone)]
47pub struct Metadata {
48 inner: HashMap<String, String>,
50}
51
52impl Metadata {
53 pub fn new(data: HashMap<String, String>) -> Self {
63 Metadata { inner: data }
64 }
65
66 pub fn get(&self, key: &str) -> Option<&String> {
76 self.inner.get(key)
77 }
78
79 pub fn insert(
90 &mut self,
91 key: String,
92 value: String,
93 ) -> Option<String> {
94 self.inner.insert(key, value)
95 }
96
97 pub fn contains_key(&self, key: &str) -> bool {
107 self.inner.contains_key(key)
108 }
109
110 pub fn into_inner(self) -> HashMap<String, String> {
116 self.inner
117 }
118}
119
120pub fn extract_metadata(
136 content: &str,
137) -> Result<Metadata, MetadataError> {
138 if let Some(yaml_result) = extract_yaml_metadata(content) {
143 return yaml_result;
144 }
145 if let Some(toml) = extract_toml_metadata(content) {
146 return Ok(toml);
147 }
148 if let Some(json_result) = extract_json_metadata(content) {
149 return json_result;
150 }
151 Err(MetadataError::ExtractionError {
152 message: "No valid front matter found.".to_string(),
153 })
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161#[non_exhaustive]
162pub enum FrontMatterFormat {
163 Yaml,
165 Toml,
167 Json,
169}
170
171pub fn detect_front_matter(
189 content: &str,
190) -> Option<(FrontMatterFormat, &str, usize)> {
191 if let Some(caps) = YAML_FRONT_MATTER.captures(content) {
192 let whole = caps.get(0)?;
193 let raw = caps.get(1)?.as_str().trim();
194 return Some((FrontMatterFormat::Yaml, raw, whole.end()));
195 }
196 if let Some(caps) = TOML_FRONT_MATTER.captures(content) {
197 let whole = caps.get(0)?;
198 let raw = caps.get(1)?.as_str().trim();
199 return Some((FrontMatterFormat::Toml, raw, whole.end()));
200 }
201 let trimmed = content.trim_start();
202 if trimmed.starts_with('{') {
203 let lead = content.len() - trimmed.len();
204 let mut stream = serde_json::Deserializer::from_str(trimmed)
205 .into_iter::<serde_json::Map<String, JsonValue>>(
206 );
207 let _ = stream.next()?;
210 let end = lead + stream.byte_offset();
211 return Some((
212 FrontMatterFormat::Json,
213 &content[lead..end],
214 end,
215 ));
216 }
217 None
218}
219
220pub fn extract_metadata_with_body(
241 content: &str,
242) -> Result<(Metadata, &str), MetadataError> {
243 let metadata = extract_metadata(content)?;
244 let body_start = detect_front_matter(content)
245 .map_or(content.len(), |(_, _, start)| start);
246 let body = content[body_start..]
247 .strip_prefix("\r\n")
248 .or_else(|| content[body_start..].strip_prefix('\n'))
249 .unwrap_or(&content[body_start..]);
250 Ok((metadata, body))
251}
252
253pub fn extract_typed<T: serde::de::DeserializeOwned + 'static>(
280 content: &str,
281) -> Result<T, MetadataError> {
282 let (format, raw, _) =
283 detect_front_matter(content).ok_or_else(|| {
284 MetadataError::ExtractionError {
285 message: "No valid front matter found.".to_string(),
286 }
287 })?;
288 match format {
289 FrontMatterFormat::Yaml => {
290 let collapsed = collapse_multiline_quoted_scalars(raw);
291 noyalib::from_str::<T>(&collapsed)
292 .map_err(MetadataError::from)
293 }
294 FrontMatterFormat::Toml => {
295 toml::from_str::<T>(raw).map_err(MetadataError::from)
296 }
297 FrontMatterFormat::Json => {
298 serde_json::from_str::<T>(raw).map_err(MetadataError::from)
299 }
300 }
301}
302
303#[derive(Debug, Clone, PartialEq, Eq)]
318#[non_exhaustive]
319pub struct ProcessOptions {
320 pub required_fields: Vec<String>,
322 pub derive_slug: bool,
324}
325
326impl Default for ProcessOptions {
327 fn default() -> Self {
328 Self {
329 required_fields: vec![
330 "title".to_string(),
331 "date".to_string(),
332 ],
333 derive_slug: true,
334 }
335 }
336}
337
338impl ProcessOptions {
339 #[must_use]
341 pub fn required_fields<I, S>(mut self, fields: I) -> Self
342 where
343 I: IntoIterator<Item = S>,
344 S: Into<String>,
345 {
346 self.required_fields =
347 fields.into_iter().map(Into::into).collect();
348 self
349 }
350
351 #[must_use]
353 pub const fn derive_slug(mut self, on: bool) -> Self {
354 self.derive_slug = on;
355 self
356 }
357}
358
359pub fn process_metadata_with(
367 metadata: &Metadata,
368 options: &ProcessOptions,
369) -> Result<Metadata, MetadataError> {
370 let mut processed = metadata.clone();
371 if let Some(date) = processed.get("date").cloned() {
372 let standardized_date = standardize_date(&date)?;
373 processed.insert("date".to_string(), standardized_date);
374 }
375 for field in &options.required_fields {
376 if !processed.contains_key(field) {
377 return Err(MetadataError::MissingFieldError(
378 field.clone(),
379 ));
380 }
381 }
382 if options.derive_slug {
383 generate_derived_fields(&mut processed);
384 }
385 Ok(processed)
386}
387
388fn extract_yaml_metadata(
398 content: &str,
399) -> Option<Result<Metadata, MetadataError>> {
400 let captures = YAML_FRONT_MATTER.captures(content)?;
401
402 let yaml_str = captures.get(1)?.as_str().trim();
403
404 let collapsed = collapse_multiline_quoted_scalars(yaml_str);
412
413 match noyalib::from_str::<noyalib::Value>(&collapsed) {
414 Ok(v) => {
415 let metadata: HashMap<String, String> = flatten_yaml(&v);
416 Some(Ok(Metadata::new(metadata)))
417 }
418 Err(e) => Some(Err(MetadataError::ExtractionError {
419 message: format!("YAML parse error in frontmatter: {e}"),
420 })),
421 }
422}
423
424fn collapse_multiline_quoted_scalars(block: &str) -> String {
438 let mut out = String::with_capacity(block.len());
439 let lines: Vec<&str> = block.lines().collect();
440 let mut i = 0;
441 while i < lines.len() {
442 let line = lines[i];
443 if let Some(eq_pos) = line.find(": \"") {
444 let after_quote = &line[eq_pos + 3..];
445 if after_quote.trim().is_empty() {
446 let mut joined = String::from(&line[..eq_pos + 3]);
447 let mut closed = false;
448 i += 1;
449 while i < lines.len() {
450 let next = lines[i];
451 if let Some(close) = next.find('"') {
452 joined.push_str(next[..close].trim_start());
453 joined.push_str(&next[close..]);
454 out.push_str(&joined);
455 out.push('\n');
456 i += 1;
457 closed = true;
458 break;
459 }
460 joined.push_str(next.trim_start());
461 joined.push(' ');
462 i += 1;
463 }
464 if !closed {
465 out.push_str(joined.trim_end());
469 out.push('\n');
470 }
471 continue;
472 }
473 }
474 out.push_str(line);
475 out.push('\n');
476 i += 1;
477 }
478 out
479}
480
481fn flatten_yaml(value: &noyalib::Value) -> HashMap<String, String> {
486 let mut map = HashMap::new();
487 flatten_yaml_recursive(value, String::new(), &mut map);
488 map
489}
490
491fn flatten_yaml_recursive(
494 value: &noyalib::Value,
495 prefix: String,
496 map: &mut HashMap<String, String>,
497) {
498 match value {
499 noyalib::Value::Mapping(m) => {
500 for (k, v) in m {
501 let new_prefix = if prefix.is_empty() {
504 k.as_str().to_string()
505 } else {
506 format!("{}.{}", prefix, k.as_str())
507 };
508 flatten_yaml_recursive(v, new_prefix, map);
509 }
510 }
511 noyalib::Value::Sequence(seq) => {
512 let inline_list = seq
513 .iter()
514 .map(|item| match item.as_str() {
515 Some(s) => s.to_string(),
516 None => item.to_string(),
517 })
518 .collect::<Vec<String>>()
519 .join(", ");
520 map.insert(prefix, format!("[{}]", inline_list));
521 }
522 _ => {
523 let leaf = match value.as_str() {
527 Some(s) => s.to_string(),
528 None => value.to_string(),
529 };
530 map.insert(prefix, leaf);
531 }
532 }
533}
534
535fn extract_toml_metadata(content: &str) -> Option<Metadata> {
545 let captures = TOML_FRONT_MATTER.captures(content)?;
546 let toml_str = captures.get(1)?.as_str().trim();
547
548 let toml_value: TomlValue = toml::from_str(toml_str).ok()?;
549
550 let mut metadata = HashMap::new();
551 flatten_toml(&toml_value, &mut metadata, String::new());
552
553 Some(Metadata::new(metadata))
554}
555
556fn flatten_toml(
561 value: &TomlValue,
562 map: &mut HashMap<String, String>,
563 prefix: String,
564) {
565 match value {
566 TomlValue::Table(table) => {
567 for (k, v) in table {
568 let new_prefix = if prefix.is_empty() {
569 k.to_string()
570 } else {
571 format!("{}.{}", prefix, k)
572 };
573 flatten_toml(v, map, new_prefix);
574 }
575 }
576 TomlValue::Array(arr) => {
577 let inline_list = arr
578 .iter()
579 .map(|v| {
580 match v {
582 TomlValue::String(s) => s.clone(),
583 _ => v.to_string(),
584 }
585 })
586 .collect::<Vec<String>>()
587 .join(", ");
588 map.insert(prefix, format!("[{}]", inline_list));
589 }
590 TomlValue::String(s) => {
591 map.insert(prefix, s.clone());
592 }
593 TomlValue::Datetime(dt) => {
594 map.insert(prefix, dt.to_string());
595 }
596 _ => {
597 map.insert(prefix, value.to_string());
598 }
599 }
600}
601
602fn extract_json_metadata(
614 content: &str,
615) -> Option<Result<Metadata, MetadataError>> {
616 let trimmed = content.trim_start();
617 if !trimmed.starts_with('{') {
618 return None;
619 }
620
621 let mut stream = serde_json::Deserializer::from_str(trimmed)
631 .into_iter::<serde_json::Map<String, JsonValue>>();
632 let first = stream.next()?; let object = match first {
635 Ok(v) => v,
636 Err(e) => {
637 return Some(Err(MetadataError::ExtractionError {
638 message: format!(
639 "JSON parse error in frontmatter: {e}"
640 ),
641 }))
642 }
643 };
644
645 let mut metadata: HashMap<String, String> = HashMap::new();
646 for (k, v) in object {
647 flatten_json(&v, &mut metadata, k);
648 }
649 Some(Ok(Metadata::new(metadata)))
650}
651
652fn flatten_json(
659 value: &JsonValue,
660 map: &mut HashMap<String, String>,
661 prefix: String,
662) {
663 match value {
664 JsonValue::Object(obj) => {
665 for (k, v) in obj {
666 let new_prefix = if prefix.is_empty() {
667 k.clone()
668 } else {
669 format!("{}.{}", prefix, k)
670 };
671 flatten_json(v, map, new_prefix);
672 }
673 }
674 JsonValue::Array(arr) => {
675 let inline = arr
679 .iter()
680 .map(|v| match v {
681 JsonValue::String(s) => s.clone(),
682 other => other.to_string(),
683 })
684 .collect::<Vec<String>>()
685 .join(", ");
686 map.insert(prefix, format!("[{}]", inline));
687 }
688 JsonValue::String(s) => {
689 map.insert(prefix, s.clone());
690 }
691 JsonValue::Null => {
692 map.insert(prefix, "null".to_string());
693 }
694 _ => {
695 map.insert(prefix, value.to_string());
696 }
697 }
698}
699
700pub fn process_metadata(
716 metadata: &Metadata,
717) -> Result<Metadata, MetadataError> {
718 let mut processed = metadata.clone();
719
720 if let Some(date) = processed.get("date").cloned() {
722 let standardized_date = standardize_date(&date)?;
723 processed.insert("date".to_string(), standardized_date);
724 }
725
726 ensure_required_fields(&processed)?;
728
729 generate_derived_fields(&mut processed);
731
732 Ok(processed)
733}
734
735fn standardize_date(date: &str) -> Result<String, MetadataError> {
751 if date.trim().is_empty() {
753 return Err(MetadataError::DateParseError(
754 "Date string is empty.".to_string(),
755 ));
756 }
757
758 if date.len() < 8 {
759 return Err(MetadataError::DateParseError(
760 "Date string is too short.".to_string(),
761 ));
762 }
763
764 let date = if date.contains('/') && date.len() == 10 {
766 let parts: Vec<&str> = date.split('/').collect();
767 if parts.len() == 3
768 && parts[0].len() == 2
769 && parts[1].len() == 2
770 && parts[2].len() == 4
771 {
772 format!("{}-{}-{}", parts[2], parts[1], parts[0]) } else {
774 return Err(MetadataError::DateParseError(
775 "Invalid DD/MM/YYYY date format.".to_string(),
776 ));
777 }
778 } else {
779 date.to_string()
780 };
781
782 let parsed_date = DateTime::parse(&date)
784 .or_else(|_| {
785 DateTime::parse_custom_format(&date, "[year]-[month]-[day]")
786 })
787 .or_else(|_| {
788 DateTime::parse_custom_format(&date, "[month]/[day]/[year]")
789 })
790 .map_err(|e| {
791 MetadataError::DateParseError(format!(
792 "Failed to parse date: {}",
793 e
794 ))
795 })?;
796
797 Ok(format!(
799 "{:04}-{:02}-{:02}",
800 parsed_date.year(),
801 parsed_date.month() as u8,
802 parsed_date.day()
803 ))
804}
805
806fn ensure_required_fields(
820 metadata: &Metadata,
821) -> Result<(), MetadataError> {
822 let required_fields = ["title", "date"];
823
824 for &field in &required_fields {
825 if !metadata.contains_key(field) {
826 return Err(MetadataError::MissingFieldError(
827 field.to_string(),
828 ));
829 }
830 }
831
832 Ok(())
833}
834
835fn generate_derived_fields(metadata: &mut Metadata) {
843 if !metadata.contains_key("slug") {
844 if let Some(title) = metadata.get("title") {
845 let slug = generate_slug(title);
846 metadata.insert("slug".to_string(), slug);
847 }
848 }
849}
850
851fn generate_slug(title: &str) -> String {
861 title.to_lowercase().replace(' ', "-")
862}
863
864#[cfg(test)]
865mod tests {
866 use super::*;
867 use dtt::dtt_parse;
868
869 #[test]
870 fn test_standardize_date() {
871 let test_cases = vec![
872 ("2023-05-20T15:30:00Z", "2023-05-20"),
873 ("2023-05-20", "2023-05-20"),
874 ("20/05/2023", "2023-05-20"), ];
876
877 for (input, expected) in test_cases {
878 let result = standardize_date(input);
879 assert!(result.is_ok(), "Failed for input: {}", input);
880 assert_eq!(result.unwrap(), expected);
881 }
882 }
883
884 #[test]
885 fn test_standardize_date_errors() {
886 assert!(standardize_date("").is_err());
887 assert!(standardize_date("invalid").is_err());
888 assert!(standardize_date("20/05/23").is_err()); }
890
891 #[test]
892 fn test_date_format() {
893 let dt = dtt_parse!("2023-01-01T12:00:00+00:00").unwrap();
894 let formatted = format!(
895 "{:04}-{:02}-{:02}",
896 dt.year(),
897 dt.month() as u8,
898 dt.day()
899 );
900 assert_eq!(formatted, "2023-01-01");
901 }
902
903 #[test]
904 fn test_generate_slug() {
905 assert_eq!(generate_slug("Hello World"), "hello-world");
906 assert_eq!(generate_slug("Test 123"), "test-123");
907 assert_eq!(generate_slug(" Spaces "), "--spaces--");
908 }
909
910 #[test]
911 fn test_process_metadata() {
912 let mut metadata = Metadata::new(HashMap::new());
913 metadata.insert("title".to_string(), "Test Title".to_string());
914 metadata.insert(
915 "date".to_string(),
916 "2023-05-20T15:30:00Z".to_string(),
917 );
918
919 let processed = process_metadata(&metadata).unwrap();
920 assert_eq!(processed.get("title").unwrap(), "Test Title");
921 assert_eq!(processed.get("date").unwrap(), "2023-05-20");
922 assert_eq!(processed.get("slug").unwrap(), "test-title");
923 }
924
925 #[test]
926 fn test_extract_metadata() {
927 let yaml_content = r#"---
928title: YAML Test
929date: 2023-05-20
930---
931Content here"#;
932
933 let toml_content = r#"+++
934title = "TOML Test"
935date = "2023-05-20"
936+++
937Content here"#;
938
939 let json_content = r#"{
940"title": "JSON Test",
941"date": "2023-05-20"
942}
943Content here"#;
944
945 let yaml_metadata = extract_metadata(yaml_content).unwrap();
946 assert_eq!(yaml_metadata.get("title").unwrap(), "YAML Test");
947
948 let toml_metadata = extract_metadata(toml_content).unwrap();
949 assert_eq!(toml_metadata.get("title").unwrap(), "TOML Test");
950
951 let json_metadata = extract_metadata(json_content).unwrap();
952 assert_eq!(json_metadata.get("title").unwrap(), "JSON Test");
953 }
954
955 #[test]
956 fn test_extract_metadata_failure() {
957 let invalid_content = "This content has no metadata";
958 assert!(extract_metadata(invalid_content).is_err());
959 }
960
961 #[test]
962 fn test_ensure_required_fields() {
963 let mut metadata = Metadata::new(HashMap::new());
964 metadata.insert("title".to_string(), "Test".to_string());
965 metadata.insert("date".to_string(), "2023-05-20".to_string());
966
967 assert!(ensure_required_fields(&metadata).is_ok());
968
969 let mut incomplete_metadata = Metadata::new(HashMap::new());
970 incomplete_metadata
971 .insert("title".to_string(), "Test".to_string());
972
973 assert!(ensure_required_fields(&incomplete_metadata).is_err());
974 }
975
976 #[test]
977 fn test_generate_derived_fields() {
978 let mut metadata = Metadata::new(HashMap::new());
979 metadata.insert("title".to_string(), "Test Title".to_string());
980
981 generate_derived_fields(&mut metadata);
982
983 assert_eq!(metadata.get("slug").unwrap(), "test-title");
984 }
985
986 #[test]
987 fn test_metadata_methods() {
988 let mut metadata = Metadata::new(HashMap::new());
989 metadata.insert("key".to_string(), "value".to_string());
990
991 assert_eq!(metadata.get("key"), Some(&"value".to_string()));
992 assert!(metadata.contains_key("key"));
993 assert!(!metadata.contains_key("nonexistent"));
994
995 let old_value =
996 metadata.insert("key".to_string(), "new_value".to_string());
997 assert_eq!(old_value, Some("value".to_string()));
998 assert_eq!(metadata.get("key"), Some(&"new_value".to_string()));
999
1000 let inner = metadata.into_inner();
1001 assert_eq!(inner.get("key"), Some(&"new_value".to_string()));
1002 }
1003
1004 #[test]
1005 fn test_process_metadata_with_invalid_date() {
1006 let mut metadata = Metadata::new(HashMap::new());
1007 metadata.insert("title".to_string(), "Test Title".to_string());
1008 metadata.insert("date".to_string(), "invalid_date".to_string());
1009
1010 assert!(process_metadata(&metadata).is_err());
1011 }
1012
1013 #[test]
1014 fn test_extract_yaml_metadata_with_complex_structure() {
1015 let yaml_content = r#"---
1016title: Complex YAML Test
1017date: 2023-05-20
1018author:
1019 name: John Doe
1020 email: john@example.com
1021tags:
1022 - rust
1023 - metadata
1024 - testing
1025---
1026Content here"#;
1027
1028 let metadata = extract_metadata(yaml_content).unwrap();
1029 assert_eq!(metadata.get("title").unwrap(), "Complex YAML Test");
1030 assert_eq!(metadata.get("date").unwrap(), "2023-05-20");
1031 assert_eq!(metadata.get("author.name").unwrap(), "John Doe");
1032 assert_eq!(
1033 metadata.get("author.email").unwrap(),
1034 "john@example.com"
1035 );
1036 assert_eq!(
1037 metadata.get("tags").unwrap(),
1038 "[rust, metadata, testing]"
1039 );
1040 }
1041
1042 #[test]
1043 fn test_extract_toml_metadata_with_complex_structure() {
1044 let toml_content = r#"+++
1045title = "Complex TOML Test"
1046date = 2023-05-20
1047
1048[author]
1049name = "John Doe"
1050email = "john@example.com"
1051
1052tags = ["rust", "metadata", "testing"]
1053+++
1054Content here"#;
1055
1056 let metadata = extract_metadata(toml_content).unwrap();
1057 assert_eq!(
1058 metadata.get("title").expect("Missing 'title' key"),
1059 "Complex TOML Test"
1060 );
1061 assert_eq!(
1062 metadata.get("date").expect("Missing 'date' key"),
1063 "2023-05-20"
1064 );
1065 assert_eq!(
1066 metadata
1067 .get("author.name")
1068 .expect("Missing 'author.name' key"),
1069 "John Doe"
1070 );
1071 assert_eq!(
1072 metadata
1073 .get("author.email")
1074 .expect("Missing 'author.email' key"),
1075 "john@example.com"
1076 );
1077 assert_eq!(
1078 metadata
1079 .get("author.tags")
1080 .expect("Missing 'author.tags' key"),
1081 "[rust, metadata, testing]"
1082 );
1083 }
1084
1085 #[test]
1086 fn test_generate_slug_with_special_characters() {
1087 assert_eq!(
1088 generate_slug("Hello, World! 123"),
1089 "hello,-world!-123"
1090 );
1091 assert_eq!(generate_slug("Test: Ästhetik"), "test:-ästhetik");
1092 assert_eq!(
1093 generate_slug(" Multiple Spaces "),
1094 "--multiple---spaces--"
1095 );
1096 }
1097
1098 #[test]
1099 fn test_extract_metadata_collapses_multiline_quoted_scalar() {
1100 let content = "---\n\
1107 title: Test\n\
1108 twitter_url: \"\n\
1109 https://example.com/post\"\n\
1110 ---\n\
1111 body";
1112 let metadata = extract_metadata(content)
1113 .expect("multi-line quoted scalar should now parse");
1114 assert_eq!(metadata.get("title"), Some(&"Test".to_string()));
1115 assert!(
1116 metadata
1117 .get("twitter_url")
1118 .expect("twitter_url present")
1119 .contains("https://example.com/post"),
1120 "twitter_url should retain the URL after collapse"
1121 );
1122 }
1123
1124 #[test]
1125 fn test_extract_json_metadata_with_nested_object() {
1126 let content = r#"{"title": "T", "author": {"name": "Ada", "handle": "ada@example.com"}}
1130# body"#;
1131 let meta =
1132 extract_metadata(content).expect("nested JSON parses");
1133 assert_eq!(meta.get("title"), Some(&"T".to_string()));
1134 assert_eq!(meta.get("author.name"), Some(&"Ada".to_string()));
1135 assert_eq!(
1136 meta.get("author.handle"),
1137 Some(&"ada@example.com".to_string())
1138 );
1139 }
1140
1141 #[test]
1142 fn test_extract_json_metadata_with_array_of_objects() {
1143 let content = r#"{"tags": [{"name":"x"},{"name":"y"}]}
1145# body"#;
1146 let meta = extract_metadata(content).expect("array parses");
1147 let tags = meta.get("tags").expect("tags key present");
1150 assert!(tags.contains("\"name\":\"x\""), "got: {tags}");
1151 assert!(tags.contains("\"name\":\"y\""), "got: {tags}");
1152 }
1153
1154 #[test]
1155 fn test_extract_json_metadata_malformed_surfaces_error() {
1156 let content = r#"{"title": "unterminated"#; let err = extract_metadata(content).expect_err("must error");
1161 let msg = err.to_string();
1162 assert!(
1163 msg.contains("JSON parse error in frontmatter"),
1164 "expected surfaced JSON error, got: {msg}"
1165 );
1166 assert!(
1167 !msg.contains("No valid front matter found"),
1168 "should not fall back to the generic message: {msg}"
1169 );
1170 }
1171
1172 #[test]
1173 fn test_extract_metadata_surfaces_yaml_parse_error() {
1174 let content = "---\n\
1179 title: [unclosed sequence\n\
1180 ---\n\
1181 body";
1182 let err = extract_metadata(content)
1183 .expect_err("malformed YAML should error");
1184 let msg = format!("{err}");
1185 assert!(
1186 msg.contains("YAML parse error in frontmatter"),
1187 "expected surfaced YAML error, got: {msg}"
1188 );
1189 assert!(
1190 !msg.contains("No valid front matter found"),
1191 "should not fall back to the generic message: {msg}"
1192 );
1193 }
1194}
1195
1196#[cfg(test)]
1197mod coverage_tests {
1198 use super::*;
1203
1204 #[test]
1205 fn unclosed_multiline_quote_is_emitted_as_is() {
1206 let block = "title: \"\n first part\n second part";
1207 let out = collapse_multiline_quoted_scalars(block);
1208 assert_eq!(out, "title: \"first part second part\n");
1211 }
1212
1213 #[test]
1214 fn closed_multiline_quote_joins_onto_one_line() {
1215 let block = "title: \"\n first\n second\"\nnext: 1";
1216 let out = collapse_multiline_quoted_scalars(block);
1217 assert_eq!(out, "title: \"first second\"\nnext: 1\n");
1218 }
1219
1220 #[test]
1221 fn yaml_flattener_renders_non_string_leaves_and_sequences() {
1222 let value: noyalib::Value = noyalib::from_str(
1223 "count: 3\nflag: true\nnums: [1, 2]\nmixed: [a, 2]",
1224 )
1225 .expect("valid yaml");
1226 let map = flatten_yaml(&value);
1227 assert_eq!(map["count"], "3");
1228 assert_eq!(map["flag"], "true");
1229 assert_eq!(map["nums"], "[1, 2]");
1230 assert_eq!(map["mixed"], "[a, 2]");
1231 }
1232
1233 #[test]
1234 fn toml_flattener_covers_every_leaf_kind() {
1235 let value: TomlValue = toml::from_str(
1236 "name = \"x\"\nn = 7\nok = true\nwhen = 2024-01-02\n\
1237 ints = [1, 2]\nwords = [\"a\", \"b\"]\n[nested]\nk = 1.5",
1238 )
1239 .expect("valid toml");
1240 let mut map = HashMap::new();
1241 flatten_toml(&value, &mut map, String::new());
1242 assert_eq!(map["name"], "x");
1243 assert_eq!(map["n"], "7");
1244 assert_eq!(map["ok"], "true");
1245 assert_eq!(map["when"], "2024-01-02");
1246 assert_eq!(map["ints"], "[1, 2]");
1247 assert_eq!(map["words"], "[a, b]");
1248 assert_eq!(map["nested.k"], "1.5");
1249 }
1250
1251 #[test]
1252 fn json_flattener_covers_every_leaf_kind() {
1253 let value: JsonValue = serde_json::from_str(
1254 r#"{"s":"x","n":2,"b":false,"z":null,"arr":[1,"a",null],"o":{"k":{"d":1}}}"#,
1255 )
1256 .expect("valid json");
1257 let mut map = HashMap::new();
1258 flatten_json(&value, &mut map, String::new());
1259 assert_eq!(map["s"], "x");
1260 assert_eq!(map["n"], "2");
1261 assert_eq!(map["b"], "false");
1262 assert_eq!(map["z"], "null");
1263 assert_eq!(map["arr"], "[1, a, null]");
1264 assert_eq!(map["o.k.d"], "1");
1265 }
1266
1267 #[test]
1268 fn json_front_matter_syntax_error_is_reported() {
1269 let err = extract_metadata("{\"title\": }").unwrap_err();
1270 assert!(
1271 matches!(err, MetadataError::ExtractionError { ref message } if message.contains("JSON parse error"))
1272 );
1273 }
1274
1275 #[test]
1276 fn json_front_matter_with_nested_objects_and_body() {
1277 let meta = extract_metadata(
1278 "{\"title\": \"T\", \"a\": {\"b\": [1, 2]}}\n\nBody text",
1279 )
1280 .expect("json front matter");
1281 assert_eq!(meta.get("title").map(String::as_str), Some("T"));
1282 assert_eq!(meta.get("a.b").map(String::as_str), Some("[1, 2]"));
1283 }
1284
1285 #[test]
1286 fn dd_mm_yyyy_dates_are_reformatted() {
1287 assert_eq!(
1288 standardize_date("01/02/2024").unwrap(),
1289 "2024-02-01"
1290 );
1291 }
1292
1293 #[test]
1294 fn slash_dates_with_wrong_part_widths_are_rejected() {
1295 let err = standardize_date("123/4/5678").unwrap_err();
1297 assert!(
1298 matches!(err, MetadataError::DateParseError(ref m) if m.contains("DD/MM/YYYY"))
1299 );
1300 }
1301
1302 #[test]
1303 fn short_and_empty_dates_are_rejected() {
1304 assert!(
1305 matches!(standardize_date(" ").unwrap_err(), MetadataError::DateParseError(ref m) if m.contains("empty"))
1306 );
1307 assert!(
1308 matches!(standardize_date("2024-1").unwrap_err(), MetadataError::DateParseError(ref m) if m.contains("too short"))
1309 );
1310 assert!(
1311 matches!(standardize_date("2024-99-99").unwrap_err(), MetadataError::DateParseError(ref m) if m.contains("Failed to parse"))
1312 );
1313 }
1314
1315 #[test]
1316 fn process_metadata_standardises_date_and_derives_slug() {
1317 let mut data = HashMap::new();
1318 data.insert("title".to_string(), "Hello World!".to_string());
1319 data.insert("date".to_string(), "01/02/2024".to_string());
1320 let out =
1321 process_metadata(&Metadata::new(data)).expect("processed");
1322 assert_eq!(
1323 out.get("date").map(String::as_str),
1324 Some("2024-02-01")
1325 );
1326 assert!(out.contains_key("slug"), "slug derived from title");
1327 assert_eq!(
1328 out.get("slug").map(String::as_str),
1329 Some(&*generate_slug("Hello World!"))
1330 );
1331 }
1332
1333 #[test]
1334 fn process_metadata_keeps_an_explicit_slug() {
1335 let mut data = HashMap::new();
1336 data.insert("title".to_string(), "T".to_string());
1337 data.insert("date".to_string(), "2024-02-01".to_string());
1338 data.insert("slug".to_string(), "keep-me".to_string());
1339 let out =
1340 process_metadata(&Metadata::new(data)).expect("processed");
1341 assert_eq!(
1342 out.get("slug").map(String::as_str),
1343 Some("keep-me")
1344 );
1345 }
1346
1347 #[test]
1348 fn process_metadata_reports_a_missing_required_field() {
1349 let mut data = HashMap::new();
1350 data.insert("title".to_string(), "T".to_string());
1351 let err = process_metadata(&Metadata::new(data)).unwrap_err();
1352 assert!(
1353 matches!(err, MetadataError::MissingFieldError(ref f) if f == "date")
1354 );
1355 }
1356}
1357
1358#[cfg(test)]
1359mod typed_api_tests {
1360 use super::*;
1361
1362 #[derive(Debug, serde::Deserialize, PartialEq)]
1363 struct Front {
1364 title: String,
1365 count: u32,
1366 tags: Vec<String>,
1367 }
1368
1369 #[test]
1370 fn typed_extraction_covers_all_three_formats() {
1371 let yaml = "---\ntitle: T\ncount: 3\ntags: [a, b]\n---\nbody";
1372 let toml = "+++\ntitle = \"T\"\ncount = 3\ntags = [\"a\", \"b\"]\n+++\nbody";
1373 let json = "{\"title\": \"T\", \"count\": 3, \"tags\": [\"a\", \"b\"]}\nbody";
1374 let want = Front {
1375 title: "T".into(),
1376 count: 3,
1377 tags: vec!["a".into(), "b".into()],
1378 };
1379 for doc in [yaml, toml, json] {
1380 assert_eq!(
1381 extract_typed::<Front>(doc).unwrap(),
1382 want,
1383 "{doc:?}"
1384 );
1385 }
1386 }
1387
1388 #[test]
1389 fn typed_extraction_reports_the_format_error() {
1390 assert!(matches!(
1391 extract_typed::<Front>("---\ntitle: [\n---\n"),
1392 Err(MetadataError::YamlError(_))
1393 ));
1394 assert!(matches!(
1395 extract_typed::<Front>("+++\ntitle = \n+++\n"),
1396 Err(MetadataError::TomlError(_))
1397 ));
1398 assert!(
1399 matches!(
1400 extract_typed::<Front>("{\"title\": \"T\"}"),
1401 Err(MetadataError::JsonError(_))
1402 ),
1403 "missing fields"
1404 );
1405 assert!(matches!(
1406 extract_typed::<Front>("no front matter"),
1407 Err(MetadataError::ExtractionError { .. })
1408 ));
1409 }
1410
1411 #[test]
1412 fn body_follows_each_delimiter_shape() {
1413 let (_, body) =
1414 extract_metadata_with_body("---\ntitle: T\n---\nBody")
1415 .unwrap();
1416 assert_eq!(body, "Body");
1417 let (_, body) = extract_metadata_with_body(
1418 "+++\ntitle = \"T\"\n+++\r\nBody",
1419 )
1420 .unwrap();
1421 assert_eq!(body, "Body");
1422 let (_, body) =
1423 extract_metadata_with_body(" {\"title\": \"T\"}\n\nBody")
1424 .unwrap();
1425 assert_eq!(body, "\nBody");
1426 let (_, body) =
1427 extract_metadata_with_body("{\"title\": \"T\"}").unwrap();
1428 assert_eq!(body, "");
1429 }
1430
1431 #[test]
1432 fn detection_reports_the_raw_block_and_offset() {
1433 let (f, raw, at) =
1434 detect_front_matter("+++\nx = 1\n+++\nrest").unwrap();
1435 assert_eq!((f, raw), (FrontMatterFormat::Toml, "x = 1"));
1436 assert_eq!(at, "+++\nx = 1\n+++".len());
1437 assert!(detect_front_matter("plain").is_none());
1438 assert!(matches!(
1442 detect_front_matter("{"),
1443 Some((FrontMatterFormat::Json, _, _))
1444 ));
1445 assert!(matches!(
1446 extract_metadata("{"),
1447 Err(MetadataError::ExtractionError { .. })
1448 ));
1449 }
1450
1451 #[test]
1452 fn process_options_control_required_fields_and_slug() {
1453 let mut m = HashMap::new();
1454 m.insert("title".to_string(), "Hello World".to_string());
1455 let meta = Metadata::new(m);
1456 let err = process_metadata(&meta).unwrap_err();
1457 assert!(
1458 matches!(err, MetadataError::MissingFieldError(ref f) if f == "date")
1459 );
1460
1461 let opts = ProcessOptions::default()
1462 .required_fields(["title"])
1463 .derive_slug(false);
1464 let out = process_metadata_with(&meta, &opts).unwrap();
1465 assert!(!out.contains_key("slug"));
1466
1467 let opts = ProcessOptions::default()
1468 .required_fields(["title", "author"]);
1469 let err = process_metadata_with(&meta, &opts).unwrap_err();
1470 assert!(
1471 matches!(err, MetadataError::MissingFieldError(ref f) if f == "author")
1472 );
1473
1474 let mut m = HashMap::new();
1475 m.insert("title".to_string(), "T".to_string());
1476 m.insert("date".to_string(), "not a date".to_string());
1477 let err = process_metadata_with(
1478 &Metadata::new(m),
1479 &ProcessOptions::default(),
1480 )
1481 .unwrap_err();
1482 assert!(matches!(err, MetadataError::DateParseError(_)));
1483 }
1484}