1use crate::error::MetadataError;
7use dtt::datetime::DateTime;
8use regex::Regex;
9use serde_json::Value as JsonValue;
10use std::collections::HashMap;
11use toml::Value as TomlValue;
12
13#[derive(Debug, Default, Clone)]
27pub struct Metadata {
28 inner: HashMap<String, String>,
30}
31
32impl Metadata {
33 pub fn new(data: HashMap<String, String>) -> Self {
43 Metadata { inner: data }
44 }
45
46 pub fn get(&self, key: &str) -> Option<&String> {
56 self.inner.get(key)
57 }
58
59 pub fn insert(
70 &mut self,
71 key: String,
72 value: String,
73 ) -> Option<String> {
74 self.inner.insert(key, value)
75 }
76
77 pub fn contains_key(&self, key: &str) -> bool {
87 self.inner.contains_key(key)
88 }
89
90 pub fn into_inner(self) -> HashMap<String, String> {
96 self.inner
97 }
98}
99
100pub fn extract_metadata(
116 content: &str,
117) -> Result<Metadata, MetadataError> {
118 extract_yaml_metadata(content)
119 .or_else(|| extract_toml_metadata(content))
120 .or_else(|| extract_json_metadata(content))
121 .ok_or_else(|| MetadataError::ExtractionError {
122 message: "No valid front matter found.".to_string(),
123 })
124}
125
126fn extract_yaml_metadata(content: &str) -> Option<Metadata> {
136 let re = Regex::new(r"(?s)^\s*---\s*\n(.*?)\n\s*---\s*").ok()?;
137 let captures = re.captures(content)?;
138
139 let yaml_str = captures.get(1)?.as_str().trim();
140
141 let yaml_value: noyalib::Value =
142 noyalib::from_str(yaml_str).ok()?;
143
144 let metadata: HashMap<String, String> = flatten_yaml(&yaml_value);
145
146 Some(Metadata::new(metadata))
147}
148
149fn flatten_yaml(value: &noyalib::Value) -> HashMap<String, String> {
154 let mut map = HashMap::new();
155 flatten_yaml_recursive(value, String::new(), &mut map);
156 map
157}
158
159fn flatten_yaml_recursive(
162 value: &noyalib::Value,
163 prefix: String,
164 map: &mut HashMap<String, String>,
165) {
166 match value {
167 noyalib::Value::Mapping(m) => {
168 for (k, v) in m {
169 let new_prefix = if prefix.is_empty() {
172 k.as_str().to_string()
173 } else {
174 format!("{}.{}", prefix, k.as_str())
175 };
176 flatten_yaml_recursive(v, new_prefix, map);
177 }
178 }
179 noyalib::Value::Sequence(seq) => {
180 let inline_list = seq
181 .iter()
182 .map(|item| match item.as_str() {
183 Some(s) => s.to_string(),
184 None => item.to_string(),
185 })
186 .collect::<Vec<String>>()
187 .join(", ");
188 map.insert(prefix, format!("[{}]", inline_list));
189 }
190 _ => {
191 let leaf = match value.as_str() {
195 Some(s) => s.to_string(),
196 None => value.to_string(),
197 };
198 map.insert(prefix, leaf);
199 }
200 }
201}
202
203fn extract_toml_metadata(content: &str) -> Option<Metadata> {
213 let re = Regex::new(r"(?s)^\s*\+\+\+\s*(.*?)\s*\+\+\+").ok()?;
214 let captures = re.captures(content)?;
215 let toml_str = captures.get(1)?.as_str().trim();
216
217 let toml_value: TomlValue = toml::from_str(toml_str).ok()?;
218
219 let mut metadata = HashMap::new();
220 flatten_toml(&toml_value, &mut metadata, String::new());
221
222 Some(Metadata::new(metadata))
223}
224
225fn flatten_toml(
230 value: &TomlValue,
231 map: &mut HashMap<String, String>,
232 prefix: String,
233) {
234 match value {
235 TomlValue::Table(table) => {
236 for (k, v) in table {
237 let new_prefix = if prefix.is_empty() {
238 k.to_string()
239 } else {
240 format!("{}.{}", prefix, k)
241 };
242 flatten_toml(v, map, new_prefix);
243 }
244 }
245 TomlValue::Array(arr) => {
246 let inline_list = arr
247 .iter()
248 .map(|v| {
249 match v {
251 TomlValue::String(s) => s.clone(),
252 _ => v.to_string(),
253 }
254 })
255 .collect::<Vec<String>>()
256 .join(", ");
257 map.insert(prefix, format!("[{}]", inline_list));
258 }
259 TomlValue::String(s) => {
260 map.insert(prefix, s.clone());
261 }
262 TomlValue::Datetime(dt) => {
263 map.insert(prefix, dt.to_string());
264 }
265 _ => {
266 map.insert(prefix, value.to_string());
267 }
268 }
269}
270
271fn extract_json_metadata(content: &str) -> Option<Metadata> {
281 let re = Regex::new(r"(?s)^\s*\{\s*(.*?)\s*\}").ok()?;
282 let captures = re.captures(content)?;
283 let json_str = format!("{{{}}}", captures.get(1)?.as_str().trim());
284
285 let json_value: JsonValue = serde_json::from_str(&json_str).ok()?;
286 let json_object = json_value.as_object()?;
287
288 let metadata: HashMap<String, String> = json_object
289 .iter()
290 .filter_map(|(k, v)| {
291 v.as_str().map(|s| (k.clone(), s.to_string()))
292 })
293 .collect();
294
295 Some(Metadata::new(metadata))
296}
297
298pub fn process_metadata(
314 metadata: &Metadata,
315) -> Result<Metadata, MetadataError> {
316 let mut processed = metadata.clone();
317
318 if let Some(date) = processed.get("date").cloned() {
320 let standardized_date = standardize_date(&date)?;
321 processed.insert("date".to_string(), standardized_date);
322 }
323
324 ensure_required_fields(&processed)?;
326
327 generate_derived_fields(&mut processed);
329
330 Ok(processed)
331}
332
333fn standardize_date(date: &str) -> Result<String, MetadataError> {
349 if date.trim().is_empty() {
351 return Err(MetadataError::DateParseError(
352 "Date string is empty.".to_string(),
353 ));
354 }
355
356 if date.len() < 8 {
357 return Err(MetadataError::DateParseError(
358 "Date string is too short.".to_string(),
359 ));
360 }
361
362 let date = if date.contains('/') && date.len() == 10 {
364 let parts: Vec<&str> = date.split('/').collect();
365 if parts.len() == 3
366 && parts[0].len() == 2
367 && parts[1].len() == 2
368 && parts[2].len() == 4
369 {
370 format!("{}-{}-{}", parts[2], parts[1], parts[0]) } else {
372 return Err(MetadataError::DateParseError(
373 "Invalid DD/MM/YYYY date format.".to_string(),
374 ));
375 }
376 } else {
377 date.to_string()
378 };
379
380 let parsed_date = DateTime::parse(&date)
382 .or_else(|_| {
383 DateTime::parse_custom_format(&date, "[year]-[month]-[day]")
384 })
385 .or_else(|_| {
386 DateTime::parse_custom_format(&date, "[month]/[day]/[year]")
387 })
388 .map_err(|e| {
389 MetadataError::DateParseError(format!(
390 "Failed to parse date: {}",
391 e
392 ))
393 })?;
394
395 Ok(format!(
397 "{:04}-{:02}-{:02}",
398 parsed_date.year(),
399 parsed_date.month() as u8,
400 parsed_date.day()
401 ))
402}
403
404fn ensure_required_fields(
418 metadata: &Metadata,
419) -> Result<(), MetadataError> {
420 let required_fields = ["title", "date"];
421
422 for &field in &required_fields {
423 if !metadata.contains_key(field) {
424 return Err(MetadataError::MissingFieldError(
425 field.to_string(),
426 ));
427 }
428 }
429
430 Ok(())
431}
432
433fn generate_derived_fields(metadata: &mut Metadata) {
441 if !metadata.contains_key("slug") {
442 if let Some(title) = metadata.get("title") {
443 let slug = generate_slug(title);
444 metadata.insert("slug".to_string(), slug);
445 }
446 }
447}
448
449fn generate_slug(title: &str) -> String {
459 title.to_lowercase().replace(' ', "-")
460}
461
462#[cfg(test)]
463mod tests {
464 use super::*;
465 use dtt::dtt_parse;
466
467 #[test]
468 fn test_standardize_date() {
469 let test_cases = vec![
470 ("2023-05-20T15:30:00Z", "2023-05-20"),
471 ("2023-05-20", "2023-05-20"),
472 ("20/05/2023", "2023-05-20"), ];
474
475 for (input, expected) in test_cases {
476 let result = standardize_date(input);
477 assert!(result.is_ok(), "Failed for input: {}", input);
478 assert_eq!(result.unwrap(), expected);
479 }
480 }
481
482 #[test]
483 fn test_standardize_date_errors() {
484 assert!(standardize_date("").is_err());
485 assert!(standardize_date("invalid").is_err());
486 assert!(standardize_date("20/05/23").is_err()); }
488
489 #[test]
490 fn test_date_format() {
491 let dt = dtt_parse!("2023-01-01T12:00:00+00:00").unwrap();
492 let formatted = format!(
493 "{:04}-{:02}-{:02}",
494 dt.year(),
495 dt.month() as u8,
496 dt.day()
497 );
498 assert_eq!(formatted, "2023-01-01");
499 }
500
501 #[test]
502 fn test_generate_slug() {
503 assert_eq!(generate_slug("Hello World"), "hello-world");
504 assert_eq!(generate_slug("Test 123"), "test-123");
505 assert_eq!(generate_slug(" Spaces "), "--spaces--");
506 }
507
508 #[test]
509 fn test_process_metadata() {
510 let mut metadata = Metadata::new(HashMap::new());
511 metadata.insert("title".to_string(), "Test Title".to_string());
512 metadata.insert(
513 "date".to_string(),
514 "2023-05-20T15:30:00Z".to_string(),
515 );
516
517 let processed = process_metadata(&metadata).unwrap();
518 assert_eq!(processed.get("title").unwrap(), "Test Title");
519 assert_eq!(processed.get("date").unwrap(), "2023-05-20");
520 assert_eq!(processed.get("slug").unwrap(), "test-title");
521 }
522
523 #[test]
524 fn test_extract_metadata() {
525 let yaml_content = r#"---
526title: YAML Test
527date: 2023-05-20
528---
529Content here"#;
530
531 let toml_content = r#"+++
532title = "TOML Test"
533date = "2023-05-20"
534+++
535Content here"#;
536
537 let json_content = r#"{
538"title": "JSON Test",
539"date": "2023-05-20"
540}
541Content here"#;
542
543 let yaml_metadata = extract_metadata(yaml_content).unwrap();
544 assert_eq!(yaml_metadata.get("title").unwrap(), "YAML Test");
545
546 let toml_metadata = extract_metadata(toml_content).unwrap();
547 assert_eq!(toml_metadata.get("title").unwrap(), "TOML Test");
548
549 let json_metadata = extract_metadata(json_content).unwrap();
550 assert_eq!(json_metadata.get("title").unwrap(), "JSON Test");
551 }
552
553 #[test]
554 fn test_extract_metadata_failure() {
555 let invalid_content = "This content has no metadata";
556 assert!(extract_metadata(invalid_content).is_err());
557 }
558
559 #[test]
560 fn test_ensure_required_fields() {
561 let mut metadata = Metadata::new(HashMap::new());
562 metadata.insert("title".to_string(), "Test".to_string());
563 metadata.insert("date".to_string(), "2023-05-20".to_string());
564
565 assert!(ensure_required_fields(&metadata).is_ok());
566
567 let mut incomplete_metadata = Metadata::new(HashMap::new());
568 incomplete_metadata
569 .insert("title".to_string(), "Test".to_string());
570
571 assert!(ensure_required_fields(&incomplete_metadata).is_err());
572 }
573
574 #[test]
575 fn test_generate_derived_fields() {
576 let mut metadata = Metadata::new(HashMap::new());
577 metadata.insert("title".to_string(), "Test Title".to_string());
578
579 generate_derived_fields(&mut metadata);
580
581 assert_eq!(metadata.get("slug").unwrap(), "test-title");
582 }
583
584 #[test]
585 fn test_metadata_methods() {
586 let mut metadata = Metadata::new(HashMap::new());
587 metadata.insert("key".to_string(), "value".to_string());
588
589 assert_eq!(metadata.get("key"), Some(&"value".to_string()));
590 assert!(metadata.contains_key("key"));
591 assert!(!metadata.contains_key("nonexistent"));
592
593 let old_value =
594 metadata.insert("key".to_string(), "new_value".to_string());
595 assert_eq!(old_value, Some("value".to_string()));
596 assert_eq!(metadata.get("key"), Some(&"new_value".to_string()));
597
598 let inner = metadata.into_inner();
599 assert_eq!(inner.get("key"), Some(&"new_value".to_string()));
600 }
601
602 #[test]
603 fn test_process_metadata_with_invalid_date() {
604 let mut metadata = Metadata::new(HashMap::new());
605 metadata.insert("title".to_string(), "Test Title".to_string());
606 metadata.insert("date".to_string(), "invalid_date".to_string());
607
608 assert!(process_metadata(&metadata).is_err());
609 }
610
611 #[test]
612 fn test_extract_yaml_metadata_with_complex_structure() {
613 let yaml_content = r#"---
614title: Complex YAML Test
615date: 2023-05-20
616author:
617 name: John Doe
618 email: john@example.com
619tags:
620 - rust
621 - metadata
622 - testing
623---
624Content here"#;
625
626 let metadata = extract_metadata(yaml_content).unwrap();
627 assert_eq!(metadata.get("title").unwrap(), "Complex YAML Test");
628 assert_eq!(metadata.get("date").unwrap(), "2023-05-20");
629 assert_eq!(metadata.get("author.name").unwrap(), "John Doe");
630 assert_eq!(
631 metadata.get("author.email").unwrap(),
632 "john@example.com"
633 );
634 assert_eq!(
635 metadata.get("tags").unwrap(),
636 "[rust, metadata, testing]"
637 );
638 }
639
640 #[test]
641 fn test_extract_toml_metadata_with_complex_structure() {
642 let toml_content = r#"+++
643title = "Complex TOML Test"
644date = 2023-05-20
645
646[author]
647name = "John Doe"
648email = "john@example.com"
649
650tags = ["rust", "metadata", "testing"]
651+++
652Content here"#;
653
654 let metadata = extract_metadata(toml_content).unwrap();
655 assert_eq!(
656 metadata.get("title").expect("Missing 'title' key"),
657 "Complex TOML Test"
658 );
659 assert_eq!(
660 metadata.get("date").expect("Missing 'date' key"),
661 "2023-05-20"
662 );
663 assert_eq!(
664 metadata
665 .get("author.name")
666 .expect("Missing 'author.name' key"),
667 "John Doe"
668 );
669 assert_eq!(
670 metadata
671 .get("author.email")
672 .expect("Missing 'author.email' key"),
673 "john@example.com"
674 );
675 assert_eq!(
676 metadata
677 .get("author.tags")
678 .expect("Missing 'author.tags' key"),
679 "[rust, metadata, testing]"
680 );
681 }
682
683 #[test]
684 fn test_generate_slug_with_special_characters() {
685 assert_eq!(
686 generate_slug("Hello, World! 123"),
687 "hello,-world!-123"
688 );
689 assert_eq!(generate_slug("Test: Ästhetik"), "test:-ästhetik");
690 assert_eq!(
691 generate_slug(" Multiple Spaces "),
692 "--multiple---spaces--"
693 );
694 }
695}