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/// Which front-matter shape a document carries, and where its body starts.
157///
158/// Returned by [`detect_front_matter`]; the byte offset is the first byte
159/// after the closing delimiter, so `&content[body_start..]` is the body.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161#[non_exhaustive]
162pub enum FrontMatterFormat {
163    /// `---` fenced YAML.
164    Yaml,
165    /// `+++` fenced TOML.
166    Toml,
167    /// A JSON object at the top of the document.
168    Json,
169}
170
171/// Locates the front-matter block without parsing it.
172///
173/// Returns the format, the raw block, and the byte offset at which the
174/// document body begins. Detection order is YAML, TOML, JSON, the same
175/// order [`extract_metadata`] uses; `None` means no shape matched.
176///
177/// # Example
178///
179/// ```
180/// use metadata_gen::metadata::{detect_front_matter, FrontMatterFormat};
181///
182/// let doc = "---\ntitle: T\n---\nBody";
183/// let (format, raw, body_start) = detect_front_matter(doc).unwrap();
184/// assert_eq!(format, FrontMatterFormat::Yaml);
185/// assert_eq!(raw, "title: T");
186/// assert_eq!(&doc[body_start..], "Body");
187/// ```
188pub 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        // A syntax error still identifies the shape; the caller's parse
208        // reports it. The offset then covers the text the parser consumed.
209        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
220/// Extracts metadata and returns the document body alongside it.
221///
222/// The body is the text after the closing delimiter, with one leading
223/// newline removed so a `---` fence on its own line does not leave an
224/// empty first line. [`extract_metadata`] is this without the body.
225///
226/// # Errors
227///
228/// The same errors as [`extract_metadata`].
229///
230/// # Example
231///
232/// ```
233/// use metadata_gen::metadata::extract_metadata_with_body;
234///
235/// let doc = "---\ntitle: T\n---\n# Heading\n\nText";
236/// let (meta, body) = extract_metadata_with_body(doc).unwrap();
237/// assert_eq!(meta.get("title").map(String::as_str), Some("T"));
238/// assert_eq!(body, "# Heading\n\nText");
239/// ```
240pub 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
253/// Deserialises the front matter into a typed value.
254///
255/// Where [`extract_metadata`] flattens everything to strings, this hands
256/// the raw block to the format's own `serde` deserialiser, so integers
257/// stay integers, sequences stay sequences and nested tables become
258/// nested structs. The document body is ignored.
259///
260/// # Errors
261///
262/// [`MetadataError::ExtractionError`] when no front matter is found;
263/// the format's parse error (`YamlError`, `TomlError`, `JsonError`) when
264/// the block does not deserialise into `T`.
265///
266/// # Example
267///
268/// ```
269/// use metadata_gen::metadata::extract_typed;
270///
271/// #[derive(serde::Deserialize)]
272/// struct Front { title: String, tags: Vec<String>, draft: bool }
273///
274/// let doc = "---\ntitle: T\ntags: [a, b]\ndraft: false\n---\nBody";
275/// let front: Front = extract_typed(doc).unwrap();
276/// assert_eq!(front.tags, ["a", "b"]);
277/// assert!(!front.draft);
278/// ```
279pub 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/// Options for [`process_metadata_with`].
304///
305/// # Example
306///
307/// ```
308/// use metadata_gen::metadata::{process_metadata_with, Metadata, ProcessOptions};
309/// use std::collections::HashMap;
310///
311/// let opts = ProcessOptions::default().required_fields(["title"]);
312/// let mut m = HashMap::new();
313/// m.insert("title".to_string(), "Hello".to_string());
314/// let out = process_metadata_with(&Metadata::new(m), &opts).unwrap();
315/// assert_eq!(out.get("slug").map(String::as_str), Some("hello"));
316/// ```
317#[derive(Debug, Clone, PartialEq, Eq)]
318#[non_exhaustive]
319pub struct ProcessOptions {
320    /// Keys that must be present after processing. Default: `title`, `date`.
321    pub required_fields: Vec<String>,
322    /// Derive `slug` from `title` when absent. Default: `true`.
323    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    /// Replaces the required-field list.
340    #[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    /// Turns slug derivation on or off.
352    #[must_use]
353    pub const fn derive_slug(mut self, on: bool) -> Self {
354        self.derive_slug = on;
355        self
356    }
357}
358
359/// [`process_metadata`] with caller-chosen required fields and derivations.
360///
361/// # Errors
362///
363/// [`MetadataError::DateParseError`] for an unparseable `date`;
364/// [`MetadataError::MissingFieldError`] naming the first required field
365/// that is absent.
366pub 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
388/// Extracts YAML metadata from the content.
389///
390/// # Arguments
391///
392/// * `content` - A string slice containing the content to extract YAML metadata from.
393///
394/// # Returns
395///
396/// An `Option<Metadata>` containing the extracted metadata if successful, or `None` if extraction fails.
397fn 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    // noyalib enforces YAML 1.2.2 §7.3.2 strictly: continuation lines
405    // of a multi-line double-quoted scalar must be indented more than
406    // the parent block. PyYAML / serde_yaml relax this. Real-world
407    // frontmatter (esp. human-edited URLs that picked up an
408    // accidental newline) routinely violates the strict rule.
409    // Collapse the offending shape upstream of noyalib so consumers
410    // don't need to re-implement this each. Issue #20.
411    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
424/// Collapses multi-line double-quoted YAML scalars onto a single line.
425///
426/// noyalib correctly enforces YAML 1.2.2 §7.3.2 (continuation must be
427/// indented more than the parent block). Human-edited frontmatter
428/// often violates this — e.g. a `url: "\n<value>"` shape where a
429/// literal newline crept in after the opening quote. PyYAML and
430/// serde_yaml fold those onto one line; this helper does the same so
431/// noyalib never sees the offending shape.
432///
433/// The scan is line-based and deliberately simple: when a line ends
434/// with `: "` (key + opening quote with nothing after), walk forward
435/// joining subsequent lines until the closing `"` is found. Comments
436/// and other quote styles are not transformed.
437fn 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                    // Pathological — no closing quote. Emit what we
466                    // have so the downstream parser sees the same
467                    // broken content rather than silently swallowing.
468                    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
481/// Flattens a nested YAML value into a flat key-value map.
482///
483/// Nested keys are joined with `.` (e.g., `author.name`).
484/// Sequences are rendered as comma-separated lists wrapped in brackets.
485fn 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
491/// Recursively walks a YAML value tree, inserting leaf values into the map
492/// with dot-separated keys for nested mappings.
493fn 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                // In noyalib, mapping keys are `String`, so `k.as_str()`
502                // already yields `&str` directly.
503                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            // `as_str()` returns `Some` only for `Value::String`; for
524            // scalars (numbers, bools, dates rendered as numbers, etc.)
525            // we fall back to the `Display` representation.
526            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
535/// Extracts TOML metadata from the content.
536///
537/// # Arguments
538///
539/// * `content` - A string slice containing the content to extract TOML metadata from.
540///
541/// # Returns
542///
543/// An `Option<Metadata>` containing the extracted metadata if successful, or `None` if extraction fails.
544fn 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
556/// Recursively flattens a TOML value tree into a flat key-value map.
557///
558/// Nested keys are joined with `.` (e.g., `author.name`).
559/// Arrays are rendered as comma-separated lists wrapped in brackets.
560fn 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                    // Remove double quotes for string elements
581                    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
602/// Extracts JSON front-matter from the start of `content`.
603///
604/// Returns `Some(Ok(_))` when a balanced JSON object is found and parsed,
605/// `Some(Err(_))` when an opening `{` appears but the JSON is malformed
606/// (so the caller can surface a useful error instead of the misleading
607/// "no front-matter" fallback), and `None` only when no opening `{`
608/// appears at the start of the content.
609///
610/// Nested objects and arrays of objects are preserved by flattening them
611/// with dot-separated keys (e.g. `author.name`, matching the YAML/TOML
612/// shape). Issue #26.
613fn 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    // `Deserializer::into_iter` consumes one balanced JSON value at a
622    // time. We take the first one — that's the front-matter — and let
623    // anything after it be the document body. This replaces the old
624    // non-greedy regex that silently truncated nested objects at the
625    // first `}` it saw.
626    // Deserialising straight into a map makes "the root must be an
627    // object" part of the parse: the text starts with `{`, so the only
628    // way to get anything else is a syntax error, which is reported as
629    // one instead of through a branch no input can reach.
630    let mut stream = serde_json::Deserializer::from_str(trimmed)
631        .into_iter::<serde_json::Map<String, JsonValue>>();
632    let first = stream.next()?; // None only if input is empty after `{`.
633
634    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
652/// Recursively flattens a JSON value tree into a flat key-value map.
653///
654/// Mirrors `flatten_toml` / `flatten_yaml`: nested objects use
655/// dot-separated keys; arrays render as comma-separated lists wrapped in
656/// brackets. Strings are stored as-is; numbers, booleans, and `null`
657/// fall back to their JSON `Display` form.
658fn 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            // Arrays of scalars render as `[a, b, c]`. Arrays of objects
676            // render the same — callers that need element-level access
677            // should use the v0.0.6 typed-extraction API (issue #45).
678            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
700/// Processes the extracted metadata.
701///
702/// This function standardizes dates, ensures required fields are present, and generates derived fields.
703///
704/// # Arguments
705///
706/// * `metadata` - A reference to the `Metadata` instance to process.
707///
708/// # Returns
709///
710/// A `Result` containing the processed `Metadata` if successful, or a `MetadataError` if processing fails.
711///
712/// # Errors
713///
714/// Returns a `MetadataError` if date standardization fails or if required fields are missing.
715pub fn process_metadata(
716    metadata: &Metadata,
717) -> Result<Metadata, MetadataError> {
718    let mut processed = metadata.clone();
719
720    // Convert dates to a standard format
721    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 are present
727    ensure_required_fields(&processed)?;
728
729    // Generate derived fields
730    generate_derived_fields(&mut processed);
731
732    Ok(processed)
733}
734
735/// Standardizes the date format.
736///
737/// This function attempts to parse various date formats and convert them to the YYYY-MM-DD format.
738///
739/// # Arguments
740///
741/// * `date` - A string slice containing the date to standardize.
742///
743/// # Returns
744///
745/// A `Result` containing the standardized date string if successful, or a `MetadataError` if parsing fails.
746///
747/// # Errors
748///
749/// Returns a `MetadataError::DateParseError` if the date cannot be parsed or is invalid.
750fn standardize_date(date: &str) -> Result<String, MetadataError> {
751    // Handle edge cases with empty or too-short dates
752    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    // Check if the date is in the DD/MM/YYYY format and reformat to YYYY-MM-DD
765    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]) // Reformat to YYYY-MM-DD
773        } 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    // Attempt to parse the date in different formats using DateTime methods
783    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    // Format the date to the standardized YYYY-MM-DD format
798    Ok(format!(
799        "{:04}-{:02}-{:02}",
800        parsed_date.year(),
801        parsed_date.month() as u8,
802        parsed_date.day()
803    ))
804}
805
806/// Ensures that all required fields are present in the metadata.
807///
808/// # Arguments
809///
810/// * `metadata` - A reference to the `Metadata` instance to check.
811///
812/// # Returns
813///
814/// A `Result<()>` if all required fields are present, or a `MetadataError` if any are missing.
815///
816/// # Errors
817///
818/// Returns a `MetadataError::MissingFieldError` if any required field is missing.
819fn 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
835/// Generates derived fields for the metadata.
836///
837/// Currently, this function generates a URL slug from the title if not already present.
838///
839/// # Arguments
840///
841/// * `metadata` - A mutable reference to the `Metadata` instance to update.
842fn 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
851/// Generates a URL slug from the given title.
852///
853/// # Arguments
854///
855/// * `title` - A string slice containing the title to convert to a slug.
856///
857/// # Returns
858///
859/// A `String` containing the generated slug.
860fn 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"), // European format DD/MM/YYYY
875        ];
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()); // Invalid DD/MM/YY format
889    }
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        // Regression for sebastienrousseau/metadata-gen#20.
1101        // A literal newline immediately after `: "` would previously
1102        // make noyalib reject the frontmatter and the user would see
1103        // the misleading "No valid front matter found" message.
1104        // The collapse step joins continuation lines so noyalib sees
1105        // valid input.
1106        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        // Regression for #26: the previous regex-based JSON detector
1127        // matched the first `}` it saw, so any nested object lost data
1128        // silently. The serde_json streaming path preserves it.
1129        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        // Issue #26 acceptance criterion: arrays of objects are preserved.
1144        let content = r#"{"tags": [{"name":"x"},{"name":"y"}]}
1145# body"#;
1146        let meta = extract_metadata(content).expect("array parses");
1147        // The flattened representation lists each element via its JSON
1148        // `Display` form; the important property is no data is lost.
1149        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        // Issue #26 acceptance criterion: malformed JSON returns
1157        // ExtractionError with the underlying serde_json message —
1158        // not the generic "No valid front matter found".
1159        let content = r#"{"title": "unterminated"#; // intentionally malformed
1160        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        // Regression for sebastienrousseau/metadata-gen#20.
1175        // A genuinely malformed YAML body (after the collapse step
1176        // can't help) should surface the noyalib parse error, not
1177        // the misleading "No valid front matter found" fallback.
1178        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    //! Paths the main test module did not reach: the pathological
1199    //! multi-line quote, non-string leaves in each flattener, the
1200    //! DD/MM/YYYY shape checks, and slug derivation.
1201
1202    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        // No closing quote: the joined text is kept so the parser sees the
1209        // same broken document rather than a silently dropped key.
1210        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        // Ten characters with a slash, but not DD/MM/YYYY.
1296        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        // A lone `{` is JSON-shaped with a syntax error: the shape is
1439        // reported so the caller's parse can report the error, exactly
1440        // as extract_metadata does.
1441        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}