Skip to main content

metadata_gen/
metadata.rs

1//! Metadata extraction and processing module.
2//!
3//! This module provides functionality for extracting metadata from various formats
4//! (YAML, TOML, JSON) and processing it into a standardized structure.
5
6use 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/// Represents metadata for a page or content item.
14///
15/// # Example
16///
17/// ```
18/// use metadata_gen::Metadata;
19/// use std::collections::HashMap;
20///
21/// let mut data = HashMap::new();
22/// data.insert("title".to_string(), "My Page".to_string());
23/// let metadata = Metadata::new(data);
24/// assert_eq!(metadata.get("title"), Some(&"My Page".to_string()));
25/// ```
26#[derive(Debug, Default, Clone)]
27pub struct Metadata {
28    /// The underlying key-value store for metadata fields.
29    inner: HashMap<String, String>,
30}
31
32impl Metadata {
33    /// Creates a new `Metadata` instance with the given data.
34    ///
35    /// # Arguments
36    ///
37    /// * `data` - A `HashMap` containing the metadata key-value pairs.
38    ///
39    /// # Returns
40    ///
41    /// A new `Metadata` instance.
42    pub fn new(data: HashMap<String, String>) -> Self {
43        Metadata { inner: data }
44    }
45
46    /// Retrieves the value associated with the given key.
47    ///
48    /// # Arguments
49    ///
50    /// * `key` - A string slice representing the key to look up.
51    ///
52    /// # Returns
53    ///
54    /// An `Option<&String>` containing the value if the key exists, or `None` otherwise.
55    pub fn get(&self, key: &str) -> Option<&String> {
56        self.inner.get(key)
57    }
58
59    /// Inserts a key-value pair into the metadata.
60    ///
61    /// # Arguments
62    ///
63    /// * `key` - The key to insert.
64    /// * `value` - The value to associate with the key.
65    ///
66    /// # Returns
67    ///
68    /// The old value associated with the key, if it existed.
69    pub fn insert(
70        &mut self,
71        key: String,
72        value: String,
73    ) -> Option<String> {
74        self.inner.insert(key, value)
75    }
76
77    /// Checks if the metadata contains the given key.
78    ///
79    /// # Arguments
80    ///
81    /// * `key` - A string slice representing the key to check for.
82    ///
83    /// # Returns
84    ///
85    /// `true` if the key exists, `false` otherwise.
86    pub fn contains_key(&self, key: &str) -> bool {
87        self.inner.contains_key(key)
88    }
89
90    /// Consumes the `Metadata` instance and returns the inner `HashMap`.
91    ///
92    /// # Returns
93    ///
94    /// The inner `HashMap<String, String>` containing all metadata key-value pairs.
95    pub fn into_inner(self) -> HashMap<String, String> {
96        self.inner
97    }
98}
99
100/// Extracts metadata from the content string.
101///
102/// This function attempts to extract metadata from YAML, TOML, or JSON formats.
103///
104/// # Arguments
105///
106/// * `content` - A string slice containing the content to extract metadata from.
107///
108/// # Returns
109///
110/// A `Result` containing the extracted `Metadata` if successful, or a `MetadataError` if extraction fails.
111///
112/// # Errors
113///
114/// Returns a `MetadataError::ExtractionError` if no valid front matter is found.
115pub 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
126/// Extracts YAML metadata from the content.
127///
128/// # Arguments
129///
130/// * `content` - A string slice containing the content to extract YAML metadata from.
131///
132/// # Returns
133///
134/// An `Option<Metadata>` containing the extracted metadata if successful, or `None` if extraction fails.
135fn 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
149/// Flattens a nested YAML value into a flat key-value map.
150///
151/// Nested keys are joined with `.` (e.g., `author.name`).
152/// Sequences are rendered as comma-separated lists wrapped in brackets.
153fn 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
159/// Recursively walks a YAML value tree, inserting leaf values into the map
160/// with dot-separated keys for nested mappings.
161fn 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                // In noyalib, mapping keys are `String`, so `k.as_str()`
170                // already yields `&str` directly.
171                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            // `as_str()` returns `Some` only for `Value::String`; for
192            // scalars (numbers, bools, dates rendered as numbers, etc.)
193            // we fall back to the `Display` representation.
194            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
203/// Extracts TOML metadata from the content.
204///
205/// # Arguments
206///
207/// * `content` - A string slice containing the content to extract TOML metadata from.
208///
209/// # Returns
210///
211/// An `Option<Metadata>` containing the extracted metadata if successful, or `None` if extraction fails.
212fn 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
225/// Recursively flattens a TOML value tree into a flat key-value map.
226///
227/// Nested keys are joined with `.` (e.g., `author.name`).
228/// Arrays are rendered as comma-separated lists wrapped in brackets.
229fn 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                    // Remove double quotes for string elements
250                    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
271/// Extracts JSON metadata from the content.
272///
273/// # Arguments
274///
275/// * `content` - A string slice containing the content to extract JSON metadata from.
276///
277/// # Returns
278///
279/// An `Option<Metadata>` containing the extracted metadata if successful, or `None` if extraction fails.
280fn 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
298/// Processes the extracted metadata.
299///
300/// This function standardizes dates, ensures required fields are present, and generates derived fields.
301///
302/// # Arguments
303///
304/// * `metadata` - A reference to the `Metadata` instance to process.
305///
306/// # Returns
307///
308/// A `Result` containing the processed `Metadata` if successful, or a `MetadataError` if processing fails.
309///
310/// # Errors
311///
312/// Returns a `MetadataError` if date standardization fails or if required fields are missing.
313pub fn process_metadata(
314    metadata: &Metadata,
315) -> Result<Metadata, MetadataError> {
316    let mut processed = metadata.clone();
317
318    // Convert dates to a standard format
319    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 are present
325    ensure_required_fields(&processed)?;
326
327    // Generate derived fields
328    generate_derived_fields(&mut processed);
329
330    Ok(processed)
331}
332
333/// Standardizes the date format.
334///
335/// This function attempts to parse various date formats and convert them to the YYYY-MM-DD format.
336///
337/// # Arguments
338///
339/// * `date` - A string slice containing the date to standardize.
340///
341/// # Returns
342///
343/// A `Result` containing the standardized date string if successful, or a `MetadataError` if parsing fails.
344///
345/// # Errors
346///
347/// Returns a `MetadataError::DateParseError` if the date cannot be parsed or is invalid.
348fn standardize_date(date: &str) -> Result<String, MetadataError> {
349    // Handle edge cases with empty or too-short dates
350    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    // Check if the date is in the DD/MM/YYYY format and reformat to YYYY-MM-DD
363    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]) // Reformat to YYYY-MM-DD
371        } 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    // Attempt to parse the date in different formats using DateTime methods
381    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    // Format the date to the standardized YYYY-MM-DD format
396    Ok(format!(
397        "{:04}-{:02}-{:02}",
398        parsed_date.year(),
399        parsed_date.month() as u8,
400        parsed_date.day()
401    ))
402}
403
404/// Ensures that all required fields are present in the metadata.
405///
406/// # Arguments
407///
408/// * `metadata` - A reference to the `Metadata` instance to check.
409///
410/// # Returns
411///
412/// A `Result<()>` if all required fields are present, or a `MetadataError` if any are missing.
413///
414/// # Errors
415///
416/// Returns a `MetadataError::MissingFieldError` if any required field is missing.
417fn 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
433/// Generates derived fields for the metadata.
434///
435/// Currently, this function generates a URL slug from the title if not already present.
436///
437/// # Arguments
438///
439/// * `metadata` - A mutable reference to the `Metadata` instance to update.
440fn 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
449/// Generates a URL slug from the given title.
450///
451/// # Arguments
452///
453/// * `title` - A string slice containing the title to convert to a slug.
454///
455/// # Returns
456///
457/// A `String` containing the generated slug.
458fn 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"), // European format DD/MM/YYYY
473        ];
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()); // Invalid DD/MM/YY format
487    }
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}