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;