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
156fn extract_yaml_metadata(
166 content: &str,
167) -> Option<Result<Metadata, MetadataError>> {
168 let captures = YAML_FRONT_MATTER.captures(content)?;
169
170 let yaml_str = captures.get(1)?.as_str().trim();
171
172 let collapsed = collapse_multiline_quoted_scalars(yaml_str);
180
181 match noyalib::from_str::<noyalib::Value>(&collapsed) {
182 Ok(v) => {
183 let metadata: HashMap<String, String> = flatten_yaml(&v);
184 Some(Ok(Metadata::new(metadata)))
185 }
186 Err(e) => Some(Err(MetadataError::ExtractionError {
187 message: format!("YAML parse error in frontmatter: {e}"),
188 })),
189 }
190}
191
192fn collapse_multiline_quoted_scalars(block: &str) -> String {
206 let mut out = String::with_capacity(block.len());
207 let lines: Vec<&str> = block.lines().collect();
208 let mut i = 0;
209 while i < lines.len() {
210 let line = lines[i];
211 if let Some(eq_pos) = line.find(": \"") {
212 let after_quote = &line[eq_pos + 3..];
213 if after_quote.trim().is_empty() {
214 let mut joined = String::from(&line[..eq_pos + 3]);
215 let mut closed = false;
216 i += 1;
217 while i < lines.len() {
218 let next = lines[i];
219 if let Some(close) = next.find('"') {
220 joined.push_str(next[..close].trim_start());
221 joined.push_str(&next[close..]);
222 out.push_str(&joined);
223 out.push('\n');
224 i += 1;
225 closed = true;
226 break;
227 }
228 joined.push_str(next.trim_start());
229 joined.push(' ');
230 i += 1;
231 }
232 if !closed {
233 out.push_str(joined.trim_end());
237 out.push('\n');
238 }
239 continue;
240 }
241 }
242 out.push_str(line);
243 out.push('\n');
244 i += 1;
245 }
246 out
247}
248
249fn flatten_yaml(value: &noyalib::Value) -> HashMap<String, String> {
254 let mut map = HashMap::new();
255 flatten_yaml_recursive(value, String::new(), &mut map);
256 map
257}
258
259fn flatten_yaml_recursive(
262 value: &noyalib::Value,
263 prefix: String,
264 map: &mut HashMap<String, String>,
265) {
266 match value {
267 noyalib::Value::Mapping(m) => {
268 for (k, v) in m {
269 let new_prefix = if prefix.is_empty() {
272 k.as_str().to_string()
273 } else {
274 format!("{}.{}", prefix, k.as_str())
275 };
276 flatten_yaml_recursive(v, new_prefix, map);
277 }
278 }
279 noyalib::Value::Sequence(seq) => {
280 let inline_list = seq
281 .iter()
282 .map(|item| match item.as_str() {
283 Some(s) => s.to_string(),
284 None => item.to_string(),
285 })
286 .collect::<Vec<String>>()
287 .join(", ");
288 map.insert(prefix, format!("[{}]", inline_list));
289 }
290 _ => {
291 let leaf = match value.as_str() {
295 Some(s) => s.to_string(),
296 None => value.to_string(),
297 };
298 map.insert(prefix, leaf);
299 }
300 }
301}
302
303fn extract_toml_metadata(content: &str) -> Option<Metadata> {
313 let captures = TOML_FRONT_MATTER.captures(content)?;
314 let toml_str = captures.get(1)?.as_str().trim();
315
316 let toml_value: TomlValue = toml::from_str(toml_str).ok()?;
317
318 let mut metadata = HashMap::new();
319 flatten_toml(&toml_value, &mut metadata, String::new());
320
321 Some(Metadata::new(metadata))
322}
323
324fn flatten_toml(
329 value: &TomlValue,
330 map: &mut HashMap<String, String>,
331 prefix: String,
332) {
333 match value {
334 TomlValue::Table(table) => {
335 for (k, v) in table {
336 let new_prefix = if prefix.is_empty() {
337 k.to_string()
338 } else {
339 format!("{}.{}", prefix, k)
340 };
341 flatten_toml(v, map, new_prefix);
342 }
343 }
344 TomlValue::Array(arr) => {
345 let inline_list = arr
346 .iter()
347 .map(|v| {
348 match v {
350 TomlValue::String(s) => s.clone(),
351 _ => v.to_string(),
352 }
353 })
354 .collect::<Vec<String>>()
355 .join(", ");
356 map.insert(prefix, format!("[{}]", inline_list));
357 }
358 TomlValue::String(s) => {
359 map.insert(prefix, s.clone());
360 }
361 TomlValue::Datetime(dt) => {
362 map.insert(prefix, dt.to_string());
363 }
364 _ => {
365 map.insert(prefix, value.to_string());
366 }
367 }
368}
369
370fn extract_json_metadata(
382 content: &str,
383) -> Option<Result<Metadata, MetadataError>> {
384 let trimmed = content.trim_start();
385 if !trimmed.starts_with('{') {
386 return None;
387 }
388
389 let mut stream = serde_json::Deserializer::from_str(trimmed)
395 .into_iter::<JsonValue>();
396 let first = stream.next()?; let value = match first {
399 Ok(v) => v,
400 Err(e) => {
401 return Some(Err(MetadataError::ExtractionError {
402 message: format!(
403 "JSON parse error in frontmatter: {e}"
404 ),
405 }))
406 }
407 };
408
409 let object = match value.as_object() {
410 Some(obj) => obj,
411 None => {
412 return Some(Err(MetadataError::ExtractionError {
413 message: "JSON frontmatter must be an object at the \
414 document root"
415 .to_string(),
416 }))
417 }
418 };
419
420 let mut metadata: HashMap<String, String> = HashMap::new();
421 for (k, v) in object {
422 flatten_json(v, &mut metadata, k.clone());
423 }
424 Some(Ok(Metadata::new(metadata)))
425}
426
427fn flatten_json(
434 value: &JsonValue,
435 map: &mut HashMap<String, String>,
436 prefix: String,
437) {
438 match value {
439 JsonValue::Object(obj) => {
440 for (k, v) in obj {
441 let new_prefix = if prefix.is_empty() {
442 k.clone()
443 } else {
444 format!("{}.{}", prefix, k)
445 };
446 flatten_json(v, map, new_prefix);
447 }
448 }
449 JsonValue::Array(arr) => {
450 let inline = arr
454 .iter()
455 .map(|v| match v {
456 JsonValue::String(s) => s.clone(),
457 other => other.to_string(),
458 })
459 .collect::<Vec<String>>()
460 .join(", ");
461 map.insert(prefix, format!("[{}]", inline));
462 }
463 JsonValue::String(s) => {
464 map.insert(prefix, s.clone());
465 }
466 JsonValue::Null => {
467 map.insert(prefix, "null".to_string());
468 }
469 _ => {
470 map.insert(prefix, value.to_string());
471 }
472 }
473}
474
475pub fn process_metadata(
491 metadata: &Metadata,
492) -> Result<Metadata, MetadataError> {
493 let mut processed = metadata.clone();
494
495 if let Some(date) = processed.get("date").cloned() {
497 let standardized_date = standardize_date(&date)?;
498 processed.insert("date".to_string(), standardized_date);
499 }
500
501 ensure_required_fields(&processed)?;
503
504 generate_derived_fields(&mut processed);
506
507 Ok(processed)
508}
509
510fn standardize_date(date: &str) -> Result<String, MetadataError> {
526 if date.trim().is_empty() {
528 return Err(MetadataError::DateParseError(
529 "Date string is empty.".to_string(),
530 ));
531 }
532
533 if date.len() < 8 {
534 return Err(MetadataError::DateParseError(
535 "Date string is too short.".to_string(),
536 ));
537 }
538
539 let date = if date.contains('/') && date.len() == 10 {
541 let parts: Vec<&str> = date.split('/').collect();
542 if parts.len() == 3
543 && parts[0].len() == 2
544 && parts[1].len() == 2
545 && parts[2].len() == 4
546 {
547 format!("{}-{}-{}", parts[2], parts[1], parts[0]) } else {
549 return Err(MetadataError::DateParseError(
550 "Invalid DD/MM/YYYY date format.".to_string(),
551 ));
552 }
553 } else {
554 date.to_string()
555 };
556
557 let parsed_date = DateTime::parse(&date)
559 .or_else(|_| {
560 DateTime::parse_custom_format(&date, "[year]-[month]-[day]")
561 })
562 .or_else(|_| {
563 DateTime::parse_custom_format(&date, "[month]/[day]/[year]")
564 })
565 .map_err(|e| {
566 MetadataError::DateParseError(format!(
567 "Failed to parse date: {}",
568 e
569 ))
570 })?;
571
572 Ok(format!(
574 "{:04}-{:02}-{:02}",
575 parsed_date.year(),
576 parsed_date.month() as u8,
577 parsed_date.day()
578 ))
579}
580
581fn ensure_required_fields(
595 metadata: &Metadata,
596) -> Result<(), MetadataError> {
597 let required_fields = ["title", "date"];
598
599 for &field in &required_fields {
600 if !metadata.contains_key(field) {
601 return Err(MetadataError::MissingFieldError(
602 field.to_string(),
603 ));
604 }
605 }
606
607 Ok(())
608}
609
610fn generate_derived_fields(metadata: &mut Metadata) {
618 if !metadata.contains_key("slug") {
619 if let Some(title) = metadata.get("title") {
620 let slug = generate_slug(title);
621 metadata.insert("slug".to_string(), slug);
622 }
623 }
624}
625
626fn generate_slug(title: &str) -> String {
636 title.to_lowercase().replace(' ', "-")
637}
638
639#[cfg(test)]
640mod tests {
641 use super::*;
642 use dtt::dtt_parse;
643
644 #[test]
645 fn test_standardize_date() {
646 let test_cases = vec![
647 ("2023-05-20T15:30:00Z", "2023-05-20"),
648 ("2023-05-20", "2023-05-20"),
649 ("20/05/2023", "2023-05-20"), ];
651
652 for (input, expected) in test_cases {
653 let result = standardize_date(input);
654 assert!(result.is_ok(), "Failed for input: {}", input);
655 assert_eq!(result.unwrap(), expected);
656 }
657 }
658
659 #[test]
660 fn test_standardize_date_errors() {
661 assert!(standardize_date("").is_err());
662 assert!(standardize_date("invalid").is_err());
663 assert!(standardize_date("20/05/23").is_err()); }
665
666 #[test]
667 fn test_date_format() {
668 let dt = dtt_parse!("2023-01-01T12:00:00+00:00").unwrap();
669 let formatted = format!(
670 "{:04}-{:02}-{:02}",
671 dt.year(),
672 dt.month() as u8,
673 dt.day()
674 );
675 assert_eq!(formatted, "2023-01-01");
676 }
677
678 #[test]
679 fn test_generate_slug() {
680 assert_eq!(generate_slug("Hello World"), "hello-world");
681 assert_eq!(generate_slug("Test 123"), "test-123");
682 assert_eq!(generate_slug(" Spaces "), "--spaces--");
683 }
684
685 #[test]
686 fn test_process_metadata() {
687 let mut metadata = Metadata::new(HashMap::new());
688 metadata.insert("title".to_string(), "Test Title".to_string());
689 metadata.insert(
690 "date".to_string(),
691 "2023-05-20T15:30:00Z".to_string(),
692 );
693
694 let processed = process_metadata(&metadata).unwrap();
695 assert_eq!(processed.get("title").unwrap(), "Test Title");
696 assert_eq!(processed.get("date").unwrap(), "2023-05-20");
697 assert_eq!(processed.get("slug").unwrap(), "test-title");
698 }
699
700 #[test]
701 fn test_extract_metadata() {
702 let yaml_content = r#"---
703title: YAML Test
704date: 2023-05-20
705---
706Content here"#;
707
708 let toml_content = r#"+++
709title = "TOML Test"
710date = "2023-05-20"
711+++
712Content here"#;
713
714 let json_content = r#"{
715"title": "JSON Test",
716"date": "2023-05-20"
717}
718Content here"#;
719
720 let yaml_metadata = extract_metadata(yaml_content).unwrap();
721 assert_eq!(yaml_metadata.get("title").unwrap(), "YAML Test");
722
723 let toml_metadata = extract_metadata(toml_content).unwrap();
724 assert_eq!(toml_metadata.get("title").unwrap(), "TOML Test");
725
726 let json_metadata = extract_metadata(json_content).unwrap();
727 assert_eq!(json_metadata.get("title").unwrap(), "JSON Test");
728 }
729
730 #[test]
731 fn test_extract_metadata_failure() {
732 let invalid_content = "This content has no metadata";
733 assert!(extract_metadata(invalid_content).is_err());
734 }
735
736 #[test]
737 fn test_ensure_required_fields() {
738 let mut metadata = Metadata::new(HashMap::new());
739 metadata.insert("title".to_string(), "Test".to_string());
740 metadata.insert("date".to_string(), "2023-05-20".to_string());
741
742 assert!(ensure_required_fields(&metadata).is_ok());
743
744 let mut incomplete_metadata = Metadata::new(HashMap::new());
745 incomplete_metadata
746 .insert("title".to_string(), "Test".to_string());
747
748 assert!(ensure_required_fields(&incomplete_metadata).is_err());
749 }
750
751 #[test]
752 fn test_generate_derived_fields() {
753 let mut metadata = Metadata::new(HashMap::new());
754 metadata.insert("title".to_string(), "Test Title".to_string());
755
756 generate_derived_fields(&mut metadata);
757
758 assert_eq!(metadata.get("slug").unwrap(), "test-title");
759 }
760
761 #[test]
762 fn test_metadata_methods() {
763 let mut metadata = Metadata::new(HashMap::new());
764 metadata.insert("key".to_string(), "value".to_string());
765
766 assert_eq!(metadata.get("key"), Some(&"value".to_string()));
767 assert!(metadata.contains_key("key"));
768 assert!(!metadata.contains_key("nonexistent"));
769
770 let old_value =
771 metadata.insert("key".to_string(), "new_value".to_string());
772 assert_eq!(old_value, Some("value".to_string()));
773 assert_eq!(metadata.get("key"), Some(&"new_value".to_string()));
774
775 let inner = metadata.into_inner();
776 assert_eq!(inner.get("key"), Some(&"new_value".to_string()));
777 }
778
779 #[test]
780 fn test_process_metadata_with_invalid_date() {
781 let mut metadata = Metadata::new(HashMap::new());
782 metadata.insert("title".to_string(), "Test Title".to_string());
783 metadata.insert("date".to_string(), "invalid_date".to_string());
784
785 assert!(process_metadata(&metadata).is_err());
786 }
787
788 #[test]
789 fn test_extract_yaml_metadata_with_complex_structure() {
790 let yaml_content = r#"---
791title: Complex YAML Test
792date: 2023-05-20
793author:
794 name: John Doe
795 email: john@example.com
796tags:
797 - rust
798 - metadata
799 - testing
800---
801Content here"#;
802
803 let metadata = extract_metadata(yaml_content).unwrap();
804 assert_eq!(metadata.get("title").unwrap(), "Complex YAML Test");
805 assert_eq!(metadata.get("date").unwrap(), "2023-05-20");
806 assert_eq!(metadata.get("author.name").unwrap(), "John Doe");
807 assert_eq!(
808 metadata.get("author.email").unwrap(),
809 "john@example.com"
810 );
811 assert_eq!(
812 metadata.get("tags").unwrap(),
813 "[rust, metadata, testing]"
814 );
815 }
816
817 #[test]
818 fn test_extract_toml_metadata_with_complex_structure() {
819 let toml_content = r#"+++
820title = "Complex TOML Test"
821date = 2023-05-20
822
823[author]
824name = "John Doe"
825email = "john@example.com"
826
827tags = ["rust", "metadata", "testing"]
828+++
829Content here"#;
830
831 let metadata = extract_metadata(toml_content).unwrap();
832 assert_eq!(
833 metadata.get("title").expect("Missing 'title' key"),
834 "Complex TOML Test"
835 );
836 assert_eq!(
837 metadata.get("date").expect("Missing 'date' key"),
838 "2023-05-20"
839 );
840 assert_eq!(
841 metadata
842 .get("author.name")
843 .expect("Missing 'author.name' key"),
844 "John Doe"
845 );
846 assert_eq!(
847 metadata
848 .get("author.email")
849 .expect("Missing 'author.email' key"),
850 "john@example.com"
851 );
852 assert_eq!(
853 metadata
854 .get("author.tags")
855 .expect("Missing 'author.tags' key"),
856 "[rust, metadata, testing]"
857 );
858 }
859
860 #[test]
861 fn test_generate_slug_with_special_characters() {
862 assert_eq!(
863 generate_slug("Hello, World! 123"),
864 "hello,-world!-123"
865 );
866 assert_eq!(generate_slug("Test: Ästhetik"), "test:-ästhetik");
867 assert_eq!(
868 generate_slug(" Multiple Spaces "),
869 "--multiple---spaces--"
870 );
871 }
872
873 #[test]
874 fn test_extract_metadata_collapses_multiline_quoted_scalar() {
875 let content = "---\n\
882 title: Test\n\
883 twitter_url: \"\n\
884 https://example.com/post\"\n\
885 ---\n\
886 body";
887 let metadata = extract_metadata(content)
888 .expect("multi-line quoted scalar should now parse");
889 assert_eq!(metadata.get("title"), Some(&"Test".to_string()));
890 assert!(
891 metadata
892 .get("twitter_url")
893 .expect("twitter_url present")
894 .contains("https://example.com/post"),
895 "twitter_url should retain the URL after collapse"
896 );
897 }
898
899 #[test]
900 fn test_extract_json_metadata_with_nested_object() {
901 let content = r#"{"title": "T", "author": {"name": "Ada", "handle": "ada@example.com"}}
905# body"#;
906 let meta =
907 extract_metadata(content).expect("nested JSON parses");
908 assert_eq!(meta.get("title"), Some(&"T".to_string()));
909 assert_eq!(meta.get("author.name"), Some(&"Ada".to_string()));
910 assert_eq!(
911 meta.get("author.handle"),
912 Some(&"ada@example.com".to_string())
913 );
914 }
915
916 #[test]
917 fn test_extract_json_metadata_with_array_of_objects() {
918 let content = r#"{"tags": [{"name":"x"},{"name":"y"}]}
920# body"#;
921 let meta = extract_metadata(content).expect("array parses");
922 let tags = meta.get("tags").expect("tags key present");
925 assert!(tags.contains("\"name\":\"x\""), "got: {tags}");
926 assert!(tags.contains("\"name\":\"y\""), "got: {tags}");
927 }
928
929 #[test]
930 fn test_extract_json_metadata_malformed_surfaces_error() {
931 let content = r#"{"title": "unterminated"#; let err = extract_metadata(content).expect_err("must error");
936 let msg = err.to_string();
937 assert!(
938 msg.contains("JSON parse error in frontmatter"),
939 "expected surfaced JSON error, got: {msg}"
940 );
941 assert!(
942 !msg.contains("No valid front matter found"),
943 "should not fall back to the generic message: {msg}"
944 );
945 }
946
947 #[test]
948 fn test_extract_metadata_surfaces_yaml_parse_error() {
949 let content = "---\n\
954 title: [unclosed sequence\n\
955 ---\n\
956 body";
957 let err = extract_metadata(content)
958 .expect_err("malformed YAML should error");
959 let msg = format!("{err}");
960 assert!(
961 msg.contains("YAML parse error in frontmatter"),
962 "expected surfaced YAML error, got: {msg}"
963 );
964 assert!(
965 !msg.contains("No valid front matter found"),
966 "should not fall back to the generic message: {msg}"
967 );
968 }
969}