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 std::sync::LazyLock;
12use toml::Value as TomlValue;
13
14// One-time compiled front-matter delimiters. Calling `Regex::new` on every
15// `extract_metadata` invocation cost ~30–50 µs per call plus an allocation
16// per regex — entirely unnecessary at SSG scale. The patterns are static,
17// so compile them once per process. Issue #25.
18//
19// The `expect` is unreachable in any reachable code path: the patterns are
20// compile-time literals and have been validated by the test suite for
21// every release. If a future edit introduces a malformed pattern, the
22// startup-time panic is preferable to silently returning `None` from
23// every parse call.
24static 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/// Represents metadata for a page or content item.
34///
35/// # Example
36///
37/// ```
38/// use metadata_gen::Metadata;
39/// use std::collections::HashMap;
40///
41/// let mut data = HashMap::new();
42/// data.insert("title".to_string(), "My Page".to_string());
43/// let metadata = Metadata::new(data);
44/// assert_eq!(metadata.get("title"), Some(&"My Page".to_string()));
45/// ```
46#[derive(Debug, Default, Clone)]
47pub struct Metadata {
48    /// The underlying key-value store for metadata fields.
49    inner: HashMap<String, String>,
50}
51
52impl Metadata {
53    /// Creates a new `Metadata` instance with the given data.
54    ///
55    /// # Arguments
56    ///
57    /// * `data` - A `HashMap` containing the metadata key-value pairs.
58    ///
59    /// # Returns
60    ///
61    /// A new `Metadata` instance.
62    pub fn new(data: HashMap<String, String>) -> Self {
63        Metadata { inner: data }
64    }
65
66    /// Retrieves the value associated with the given key.
67    ///
68    /// # Arguments
69    ///
70    /// * `key` - A string slice representing the key to look up.
71    ///
72    /// # Returns
73    ///
74    /// An `Option<&String>` containing the value if the key exists, or `None` otherwise.
75    pub fn get(&self, key: &str) -> Option<&String> {
76        self.inner.get(key)
77    }
78
79    /// Inserts a key-value pair into the metadata.
80    ///
81    /// # Arguments
82    ///
83    /// * `key` - The key to insert.
84    /// * `value` - The value to associate with the key.
85    ///
86    /// # Returns
87    ///
88    /// The old value associated with the key, if it existed.
89    pub fn insert(
90        &mut self,
91        key: String,
92        value: String,
93    ) -> Option<String> {
94        self.inner.insert(key, value)
95    }
96
97    /// Checks if the metadata contains the given key.
98    ///
99    /// # Arguments
100    ///
101    /// * `key` - A string slice representing the key to check for.
102    ///
103    /// # Returns
104    ///
105    /// `true` if the key exists, `false` otherwise.
106    pub fn contains_key(&self, key: &str) -> bool {
107        self.inner.contains_key(key)
108    }
109
110    /// Consumes the `Metadata` instance and returns the inner `HashMap`.
111    ///
112    /// # Returns
113    ///
114    /// The inner `HashMap<String, String>` containing all metadata key-value pairs.
115    pub fn into_inner(self) -> HashMap<String, String> {
116        self.inner
117    }
118}
119
120/// Extracts metadata from the content string.
121///
122/// This function attempts to extract metadata from YAML, TOML, or JSON formats.
123///
124/// # Arguments
125///
126/// * `content` - A string slice containing the content to extract metadata from.
127///
128/// # Returns
129///
130/// A `Result` containing the extracted `Metadata` if successful, or a `MetadataError` if extraction fails.
131///
132/// # Errors
133///
134/// Returns a `MetadataError::ExtractionError` if no valid front matter is found.
135pub fn extract_metadata(
136    content: &str,
137) -> Result<Metadata, MetadataError> {
138    // YAML returns Option<Result<...>>: `Some(Ok)` = parsed OK,
139    // `Some(Err)` = fence matched but YAML failed (surface that
140    // specific error), `None` = no YAML fence found, fall through.
141    // Issue #20.
142    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/// Extracts YAML metadata from the content.
157///
158/// # Arguments
159///
160/// * `content` - A string slice containing the content to extract YAML metadata from.
161///
162/// # Returns
163///
164/// An `Option<Metadata>` containing the extracted metadata if successful, or `None` if extraction fails.
165fn 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    // noyalib enforces YAML 1.2.2 §7.3.2 strictly: continuation lines
173    // of a multi-line double-quoted scalar must be indented more than
174    // the parent block. PyYAML / serde_yaml relax this. Real-world
175    // frontmatter (esp. human-edited URLs that picked up an
176    // accidental newline) routinely violates the strict rule.
177    // Collapse the offending shape upstream of noyalib so consumers
178    // don't need to re-implement this each. Issue #20.
179    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
192/// Collapses multi-line double-quoted YAML scalars onto a single line.
193///
194/// noyalib correctly enforces YAML 1.2.2 §7.3.2 (continuation must be
195/// indented more than the parent block). Human-edited frontmatter
196/// often violates this — e.g. a `url: "\n<value>"` shape where a
197/// literal newline crept in after the opening quote. PyYAML and
198/// serde_yaml fold those onto one line; this helper does the same so
199/// noyalib never sees the offending shape.
200///
201/// The scan is line-based and deliberately simple: when a line ends
202/// with `: "` (key + opening quote with nothing after), walk forward
203/// joining subsequent lines until the closing `"` is found. Comments
204/// and other quote styles are not transformed.
205fn 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                    // Pathological — no closing quote. Emit what we
234                    // have so the downstream parser sees the same
235                    // broken content rather than silently swallowing.
236                    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
249/// Flattens a nested YAML value into a flat key-value map.
250///
251/// Nested keys are joined with `.` (e.g., `author.name`).
252/// Sequences are rendered as comma-separated lists wrapped in brackets.
253fn 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
259/// Recursively walks a YAML value tree, inserting leaf values into the map
260/// with dot-separated keys for nested mappings.
261fn 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                // In noyalib, mapping keys are `String`, so `k.as_str()`
270                // already yields `&str` directly.
271                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            // `as_str()` returns `Some` only for `Value::String`; for
292            // scalars (numbers, bools, dates rendered as numbers, etc.)
293            // we fall back to the `Display` representation.
294            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
303/// Extracts TOML metadata from the content.
304///
305/// # Arguments
306///
307/// * `content` - A string slice containing the content to extract TOML metadata from.
308///
309/// # Returns
310///
311/// An `Option<Metadata>` containing the extracted metadata if successful, or `None` if extraction fails.
312fn 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
324/// Recursively flattens a TOML value tree into a flat key-value map.
325///
326/// Nested keys are joined with `.` (e.g., `author.name`).
327/// Arrays are rendered as comma-separated lists wrapped in brackets.
328fn 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                    // Remove double quotes for string elements
349                    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
370/// Extracts JSON front-matter from the start of `content`.
371///
372/// Returns `Some(Ok(_))` when a balanced JSON object is found and parsed,
373/// `Some(Err(_))` when an opening `{` appears but the JSON is malformed
374/// (so the caller can surface a useful error instead of the misleading
375/// "no front-matter" fallback), and `None` only when no opening `{`
376/// appears at the start of the content.
377///
378/// Nested objects and arrays of objects are preserved by flattening them
379/// with dot-separated keys (e.g. `author.name`, matching the YAML/TOML
380/// shape). Issue #26.
381fn 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    // `Deserializer::into_iter` consumes one balanced JSON value at a
390    // time. We take the first one — that's the front-matter — and let
391    // anything after it be the document body. This replaces the old
392    // non-greedy regex that silently truncated nested objects at the
393    // first `}` it saw.
394    let mut stream = serde_json::Deserializer::from_str(trimmed)
395        .into_iter::<JsonValue>();
396    let first = stream.next()?; // None only if input is empty after `{`.
397
398    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
427/// Recursively flattens a JSON value tree into a flat key-value map.
428///
429/// Mirrors `flatten_toml` / `flatten_yaml`: nested objects use
430/// dot-separated keys; arrays render as comma-separated lists wrapped in
431/// brackets. Strings are stored as-is; numbers, booleans, and `null`
432/// fall back to their JSON `Display` form.
433fn 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            // Arrays of scalars render as `[a, b, c]`. Arrays of objects
451            // render the same — callers that need element-level access
452            // should use the v0.0.6 typed-extraction API (issue #45).
453            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
475/// Processes the extracted metadata.
476///
477/// This function standardizes dates, ensures required fields are present, and generates derived fields.
478///
479/// # Arguments
480///
481/// * `metadata` - A reference to the `Metadata` instance to process.
482///
483/// # Returns
484///
485/// A `Result` containing the processed `Metadata` if successful, or a `MetadataError` if processing fails.
486///
487/// # Errors
488///
489/// Returns a `MetadataError` if date standardization fails or if required fields are missing.
490pub fn process_metadata(
491    metadata: &Metadata,
492) -> Result<Metadata, MetadataError> {
493    let mut processed = metadata.clone();
494
495    // Convert dates to a standard format
496    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 are present
502    ensure_required_fields(&processed)?;
503
504    // Generate derived fields
505    generate_derived_fields(&mut processed);
506
507    Ok(processed)
508}
509
510/// Standardizes the date format.
511///
512/// This function attempts to parse various date formats and convert them to the YYYY-MM-DD format.
513///
514/// # Arguments
515///
516/// * `date` - A string slice containing the date to standardize.
517///
518/// # Returns
519///
520/// A `Result` containing the standardized date string if successful, or a `MetadataError` if parsing fails.
521///
522/// # Errors
523///
524/// Returns a `MetadataError::DateParseError` if the date cannot be parsed or is invalid.
525fn standardize_date(date: &str) -> Result<String, MetadataError> {
526    // Handle edge cases with empty or too-short dates
527    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    // Check if the date is in the DD/MM/YYYY format and reformat to YYYY-MM-DD
540    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]) // Reformat to YYYY-MM-DD
548        } 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    // Attempt to parse the date in different formats using DateTime methods
558    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    // Format the date to the standardized YYYY-MM-DD format
573    Ok(format!(
574        "{:04}-{:02}-{:02}",
575        parsed_date.year(),
576        parsed_date.month() as u8,
577        parsed_date.day()
578    ))
579}
580
581/// Ensures that all required fields are present in the metadata.
582///
583/// # Arguments
584///
585/// * `metadata` - A reference to the `Metadata` instance to check.
586///
587/// # Returns
588///
589/// A `Result<()>` if all required fields are present, or a `MetadataError` if any are missing.
590///
591/// # Errors
592///
593/// Returns a `MetadataError::MissingFieldError` if any required field is missing.
594fn 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
610/// Generates derived fields for the metadata.
611///
612/// Currently, this function generates a URL slug from the title if not already present.
613///
614/// # Arguments
615///
616/// * `metadata` - A mutable reference to the `Metadata` instance to update.
617fn 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
626/// Generates a URL slug from the given title.
627///
628/// # Arguments
629///
630/// * `title` - A string slice containing the title to convert to a slug.
631///
632/// # Returns
633///
634/// A `String` containing the generated slug.
635fn 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"), // European format DD/MM/YYYY
650        ];
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()); // Invalid DD/MM/YY format
664    }
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        // Regression for sebastienrousseau/metadata-gen#20.
876        // A literal newline immediately after `: "` would previously
877        // make noyalib reject the frontmatter and the user would see
878        // the misleading "No valid front matter found" message.
879        // The collapse step joins continuation lines so noyalib sees
880        // valid input.
881        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        // Regression for #26: the previous regex-based JSON detector
902        // matched the first `}` it saw, so any nested object lost data
903        // silently. The serde_json streaming path preserves it.
904        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        // Issue #26 acceptance criterion: arrays of objects are preserved.
919        let content = r#"{"tags": [{"name":"x"},{"name":"y"}]}
920# body"#;
921        let meta = extract_metadata(content).expect("array parses");
922        // The flattened representation lists each element via its JSON
923        // `Display` form; the important property is no data is lost.
924        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        // Issue #26 acceptance criterion: malformed JSON returns
932        // ExtractionError with the underlying serde_json message —
933        // not the generic "No valid front matter found".
934        let content = r#"{"title": "unterminated"#; // intentionally malformed
935        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        // Regression for sebastienrousseau/metadata-gen#20.
950        // A genuinely malformed YAML body (after the collapse step
951        // can't help) should surface the noyalib parse error, not
952        // the misleading "No valid front matter found" fallback.
953        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}