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 crate::MetadataMap;
8use alloc::string::{String, ToString};
9
10mod date;
11mod front_matter;
12mod process;
13mod scan;
14
15#[cfg(all(
16    test,
17    feature = "std",
18    feature = "yaml",
19    feature = "toml",
20    feature = "json"
21))]
22use date::standardize_date;
23use front_matter::parse_block;
24#[cfg(all(
25    test,
26    feature = "std",
27    feature = "yaml",
28    feature = "toml",
29    feature = "json"
30))]
31use process::{generate_derived_fields, generate_slug};
32pub use process::{
33    process_metadata, process_metadata_with, DateOrder, ProcessOptions,
34};
35use scan::Scan;
36
37/// Represents metadata for a page or content item.
38///
39/// # Example
40///
41/// ```
42/// use metadata_gen::Metadata;
43/// use std::collections::HashMap;
44///
45/// let mut data = HashMap::new();
46/// data.insert("title".to_string(), "My Page".to_string());
47/// let metadata = Metadata::new(data);
48/// assert_eq!(metadata.get("title"), Some(&"My Page".to_string()));
49/// ```
50#[derive(Debug, Default, Clone)]
51pub struct Metadata {
52    /// The underlying key-value store for metadata fields.
53    inner: MetadataMap,
54}
55
56impl Metadata {
57    /// Creates a new `Metadata` instance with the given data.
58    ///
59    /// # Arguments
60    ///
61    /// * `data` - A [`MetadataMap`] containing the metadata key-value pairs.
62    ///
63    /// # Returns
64    ///
65    /// A new `Metadata` instance.
66    pub fn new(data: MetadataMap) -> Self {
67        Metadata { inner: data }
68    }
69
70    /// Retrieves the value associated with the given key.
71    ///
72    /// # Arguments
73    ///
74    /// * `key` - A string slice representing the key to look up.
75    ///
76    /// # Returns
77    ///
78    /// An `Option<&String>` containing the value if the key exists, or `None` otherwise.
79    pub fn get(&self, key: &str) -> Option<&String> {
80        self.inner.get(key)
81    }
82
83    /// Inserts a key-value pair into the metadata.
84    ///
85    /// # Arguments
86    ///
87    /// * `key` - The key to insert.
88    /// * `value` - The value to associate with the key.
89    ///
90    /// # Returns
91    ///
92    /// The old value associated with the key, if it existed.
93    pub fn insert(
94        &mut self,
95        key: String,
96        value: String,
97    ) -> Option<String> {
98        self.inner.insert(key, value)
99    }
100
101    /// Checks if the metadata contains the given key.
102    ///
103    /// # Arguments
104    ///
105    /// * `key` - A string slice representing the key to check for.
106    ///
107    /// # Returns
108    ///
109    /// `true` if the key exists, `false` otherwise.
110    pub fn contains_key(&self, key: &str) -> bool {
111        self.inner.contains_key(key)
112    }
113
114    /// Consumes the `Metadata` instance and returns the inner map.
115    ///
116    /// # Returns
117    ///
118    /// The inner [`MetadataMap`] containing all metadata key-value pairs.
119    pub fn into_inner(self) -> MetadataMap {
120        self.inner
121    }
122}
123
124/// Extracts metadata from the content string.
125///
126/// This function attempts to extract metadata from YAML, TOML, or JSON formats.
127///
128/// # Arguments
129///
130/// * `content` - A string slice containing the content to extract metadata from.
131///
132/// # Returns
133///
134/// A `Result` containing the extracted `Metadata` if successful, or a `MetadataError` if extraction fails.
135///
136/// # Errors
137///
138/// Returns a `MetadataError::ExtractionError` if no valid front matter is found.
139pub fn extract_metadata(
140    content: &str,
141) -> Result<Metadata, MetadataError> {
142    extract_metadata_with_limits(content, &ParseLimits::default())
143}
144
145/// Resource limits applied while a front-matter block is parsed.
146///
147/// Front matter is untrusted input in any pipeline that ingests
148/// contributed documents, so every parse runs under a budget. The
149/// defaults are the YAML parser's strict preset narrowed to what a
150/// document header plausibly needs; raise them with the builder methods
151/// for manifests that are genuinely large.
152///
153/// # Example
154///
155/// ```
156/// use metadata_gen::{extract_metadata_with_limits, ParseLimits};
157///
158/// let limits = ParseLimits::default().max_block_bytes(4096);
159/// let err = extract_metadata_with_limits(
160///     &format!("---\ntitle: {}\n---\n", "x".repeat(5000)),
161///     &limits,
162/// )
163/// .unwrap_err();
164/// assert!(err.to_string().contains("exceeds"));
165/// ```
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167#[non_exhaustive]
168pub struct ParseLimits {
169    /// Largest front-matter block, in bytes, that will be handed to a parser.
170    pub max_block_bytes: usize,
171    /// Deepest nesting of mappings and sequences the YAML parser accepts.
172    pub max_depth: usize,
173}
174
175impl Default for ParseLimits {
176    fn default() -> Self {
177        Self {
178            max_block_bytes: 1024 * 1024,
179            max_depth: 64,
180        }
181    }
182}
183
184impl ParseLimits {
185    /// Sets the largest block size, in bytes, a parser will be given.
186    #[must_use]
187    pub const fn max_block_bytes(mut self, bytes: usize) -> Self {
188        self.max_block_bytes = bytes;
189        self
190    }
191
192    /// Sets the deepest nesting the YAML parser accepts.
193    #[must_use]
194    pub const fn max_depth(mut self, depth: usize) -> Self {
195        self.max_depth = depth;
196        self
197    }
198}
199
200/// Extracts metadata like [`extract_metadata`], under explicit [`ParseLimits`].
201///
202/// # Errors
203///
204/// Returns [`MetadataError::ExtractionError`] when no front matter is
205/// found, the block exceeds `limits.max_block_bytes`, or an opening fence
206/// has no closing fence (the message carries the byte offset);
207/// [`MetadataError::Parse`] when a parser rejects the block; and
208/// [`MetadataError::UnsupportedFormatError`] when the block's format was
209/// not compiled in.
210///
211/// # Example
212///
213/// ```
214/// use metadata_gen::{extract_metadata_with_limits, ParseLimits};
215///
216/// let doc = "---\ntitle: Limits\n---\n";
217/// let metadata = extract_metadata_with_limits(doc, &ParseLimits::default()).unwrap();
218/// assert_eq!(metadata.get("title").unwrap(), "Limits");
219/// ```
220pub fn extract_metadata_with_limits(
221    content: &str,
222    limits: &ParseLimits,
223) -> Result<Metadata, MetadataError> {
224    match scan::scan(content) {
225        Scan::Found { format, raw, .. } => parse_block(format, raw, limits),
226        Scan::Unterminated { format, offset } => {
227            Err(MetadataError::ExtractionError {
228                message: alloc::format!(
229                    "{format} front matter opened at byte {offset} has no closing fence."
230                ),
231            })
232        }
233        Scan::Unsupported(format) => {
234            Err(MetadataError::UnsupportedFormatError(format.to_string()))
235        }
236        Scan::None => Err(MetadataError::ExtractionError {
237            message: "No valid front matter found.".to_string(),
238        }),
239    }
240}
241
242/// Which front-matter shape a document carries, and where its body starts.
243///
244/// Returned by [`detect_front_matter`]; the byte offset is the first byte
245/// after the closing delimiter, so `&content[body_start..]` is the body.
246#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
247#[non_exhaustive]
248pub enum FrontMatterFormat {
249    /// `---` fenced YAML.
250    Yaml,
251    /// `+++` fenced TOML.
252    Toml,
253    /// A JSON object at the top of the document.
254    Json,
255}
256
257impl FrontMatterFormat {
258    /// The format's lower-case name: `yaml`, `toml` or `json`.
259    pub const fn as_str(self) -> &'static str {
260        match self {
261            Self::Yaml => "yaml",
262            Self::Toml => "toml",
263            Self::Json => "json",
264        }
265    }
266}
267
268impl core::fmt::Display for FrontMatterFormat {
269    fn fmt(
270        &self,
271        f: &mut core::fmt::Formatter<'_>,
272    ) -> core::fmt::Result {
273        f.write_str(self.as_str())
274    }
275}
276
277/// Locates the front-matter block without parsing it.
278///
279/// Returns the format, the raw block, and the byte offset at which the
280/// document body begins. Detection order is YAML, TOML, JSON, the same
281/// order [`extract_metadata`] uses; `None` means no shape matched.
282///
283/// # Example
284///
285/// ```
286/// use metadata_gen::metadata::{detect_front_matter, FrontMatterFormat};
287///
288/// let doc = "---\ntitle: T\n---\nBody";
289/// let (format, raw, body_start) = detect_front_matter(doc).unwrap();
290/// assert_eq!(format, FrontMatterFormat::Yaml);
291/// assert_eq!(raw, "title: T");
292/// assert_eq!(&doc[body_start..], "Body");
293/// ```
294pub fn detect_front_matter(
295    content: &str,
296) -> Option<(FrontMatterFormat, &str, usize)> {
297    match scan::scan(content) {
298        Scan::Found {
299            format,
300            raw,
301            body_offset,
302        } => Some((format, raw, body_offset)),
303        _ => None,
304    }
305}
306
307/// Extracts metadata and returns the document body alongside it.
308///
309/// The body is the text after the closing delimiter, with one leading
310/// newline removed so a `---` fence on its own line does not leave an
311/// empty first line. [`extract_metadata`] is this without the body.
312///
313/// # Errors
314///
315/// The same errors as [`extract_metadata`].
316///
317/// # Example
318///
319/// ```
320/// use metadata_gen::metadata::extract_metadata_with_body;
321///
322/// let doc = "---\ntitle: T\n---\n# Heading\n\nText";
323/// let (meta, body) = extract_metadata_with_body(doc).unwrap();
324/// assert_eq!(meta.get("title").map(String::as_str), Some("T"));
325/// assert_eq!(body, "# Heading\n\nText");
326/// ```
327pub fn extract_metadata_with_body(
328    content: &str,
329) -> Result<(Metadata, &str), MetadataError> {
330    let metadata = extract_metadata(content)?;
331    let body_start = detect_front_matter(content)
332        .map_or(content.len(), |(_, _, start)| start);
333    let body = content[body_start..]
334        .strip_prefix("\r\n")
335        .or_else(|| content[body_start..].strip_prefix('\n'))
336        .unwrap_or(&content[body_start..]);
337    Ok((metadata, body))
338}
339
340/// Deserialises the front matter into a typed value.
341///
342/// Where [`extract_metadata`] flattens everything to strings, this hands
343/// the raw block to the format's own `serde` deserialiser, so integers
344/// stay integers, sequences stay sequences and nested tables become
345/// nested structs. The document body is ignored.
346///
347/// # Errors
348///
349/// [`MetadataError::ExtractionError`] when no front matter is found;
350/// the format's parse error (`YamlError`, `TomlError`, `JsonError`) when
351/// the block does not deserialise into `T`.
352///
353/// # Example
354///
355/// ```
356/// use metadata_gen::metadata::extract_typed;
357///
358/// #[derive(serde::Deserialize)]
359/// struct Front { title: String, tags: Vec<String>, draft: bool }
360///
361/// let doc = "---\ntitle: T\ntags: [a, b]\ndraft: false\n---\nBody";
362/// let front: Front = extract_typed(doc).unwrap();
363/// assert_eq!(front.tags, ["a", "b"]);
364/// assert!(!front.draft);
365/// ```
366pub fn extract_typed<T: serde::de::DeserializeOwned + 'static>(
367    content: &str,
368) -> Result<T, MetadataError> {
369    let (format, raw, _) =
370        detect_front_matter(content).ok_or_else(|| {
371            MetadataError::ExtractionError {
372                message: "No valid front matter found.".to_string(),
373            }
374        })?;
375    front_matter::deserialize_block::<T>(
376        format,
377        raw,
378        &ParseLimits::default(),
379    )
380}
381
382/// Deserialises the front matter into `T` while borrowing from `content`.
383///
384/// The zero-copy counterpart of [`extract_typed`]: a field typed
385/// `&'a str` (or `Cow<'a, str>`) points into `content` instead of being
386/// copied, for every format whose text already holds the value verbatim.
387/// Values that need unescaping (a YAML `"line\nbreak"`, a JSON `\u00e9`)
388/// are materialised by the parser as owned strings, which is why a
389/// `Cow<'a, str>` field is the general choice.
390///
391/// # Errors
392///
393/// As [`extract_typed`].
394///
395/// # Example
396///
397/// ```
398/// use metadata_gen::extract_typed_borrowed;
399/// use serde::Deserialize;
400///
401/// #[derive(Deserialize)]
402/// struct Post<'a> {
403///     title: &'a str,
404///     tags: Vec<&'a str>,
405/// }
406///
407/// let doc = "{\"title\": \"Borrowed\", \"tags\": [\"a\", \"b\"]}\nbody";
408/// let post: Post<'_> = extract_typed_borrowed(doc).unwrap();
409/// assert_eq!(post.title, "Borrowed");
410/// assert_eq!(post.tags, ["a", "b"]);
411/// ```
412pub fn extract_typed_borrowed<'a, T: serde::Deserialize<'a>>(
413    content: &'a str,
414) -> Result<T, MetadataError> {
415    let (format, raw, _) =
416        detect_front_matter(content).ok_or_else(|| {
417            MetadataError::ExtractionError {
418                message: "No valid front matter found.".to_string(),
419            }
420        })?;
421    front_matter::deserialize_borrowed::<T>(
422        format,
423        raw,
424        &ParseLimits::default(),
425    )
426}
427
428#[cfg(all(
429    test,
430    feature = "std",
431    feature = "yaml",
432    feature = "toml",
433    feature = "json"
434))]
435mod tests;
436
437#[cfg(all(
438    test,
439    feature = "std",
440    feature = "yaml",
441    feature = "toml",
442    feature = "json"
443))]
444mod coverage_tests;
445
446#[cfg(all(
447    test,
448    feature = "std",
449    feature = "yaml",
450    feature = "toml",
451    feature = "json"
452))]
453mod typed_api_tests;