Skip to main content

metadata_gen/metadata/
process.rs

1// SPDX-FileCopyrightText: 2024 - 2026 metadata-gen contributors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Post-processing of extracted metadata: date normalisation, required
5//! fields and derived fields such as `slug`.
6
7use super::date::standardize_date;
8use super::Metadata;
9use crate::error::MetadataError;
10use alloc::string::{String, ToString};
11use alloc::vec;
12use alloc::vec::Vec;
13
14/// Options for [`process_metadata_with`].
15///
16/// # Example
17///
18/// ```
19/// use metadata_gen::metadata::{process_metadata_with, Metadata, ProcessOptions};
20/// use std::collections::HashMap;
21///
22/// let opts = ProcessOptions::default().required_fields(["title"]);
23/// let mut m = HashMap::new();
24/// m.insert("title".to_string(), "Hello".to_string());
25/// let out = process_metadata_with(&Metadata::new(m), &opts).unwrap();
26/// assert_eq!(out.get("slug").map(String::as_str), Some("hello"));
27/// ```
28#[derive(Debug, Clone, PartialEq, Eq)]
29#[non_exhaustive]
30pub struct ProcessOptions {
31    /// Keys that must be present after processing. Default: `title`, `date`.
32    pub required_fields: Vec<String>,
33    /// Derive `slug` from `title` when absent. Default: `true`.
34    pub derive_slug: bool,
35    /// How a slash-separated date (`01/02/2024`) is read.
36    pub date_order: DateOrder,
37}
38
39/// The reading of a slash-separated date such as `01/02/2024`.
40///
41/// ISO 8601 and RFC 3339 dates are unambiguous and are accepted whatever
42/// this is set to.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
44#[non_exhaustive]
45pub enum DateOrder {
46    /// `DD/MM/YYYY` (the crate's long-standing default).
47    #[default]
48    DayFirst,
49    /// `MM/DD/YYYY`.
50    MonthFirst,
51}
52
53impl Default for ProcessOptions {
54    fn default() -> Self {
55        Self {
56            required_fields: vec![
57                "title".to_string(),
58                "date".to_string(),
59            ],
60            derive_slug: true,
61            date_order: DateOrder::DayFirst,
62        }
63    }
64}
65
66impl ProcessOptions {
67    /// Replaces the required-field list.
68    #[must_use]
69    pub fn required_fields<I, S>(mut self, fields: I) -> Self
70    where
71        I: IntoIterator<Item = S>,
72        S: Into<String>,
73    {
74        self.required_fields =
75            fields.into_iter().map(Into::into).collect();
76        self
77    }
78
79    /// Turns slug derivation on or off.
80    #[must_use]
81    pub const fn derive_slug(mut self, on: bool) -> Self {
82        self.derive_slug = on;
83        self
84    }
85
86    /// Sets how slash-separated dates are read.
87    ///
88    /// # Example
89    ///
90    /// ```
91    /// use metadata_gen::{process_metadata_with, DateOrder, Metadata, MetadataMap, ProcessOptions};
92    ///
93    /// let mut map = MetadataMap::new();
94    /// map.insert("title".into(), "T".into());
95    /// map.insert("date".into(), "03/04/2024".into());
96    /// let opts = ProcessOptions::default().date_order(DateOrder::MonthFirst);
97    /// let out = process_metadata_with(&Metadata::new(map), &opts).unwrap();
98    /// assert_eq!(out.get("date").unwrap(), "2024-03-04");
99    /// ```
100    #[must_use]
101    pub const fn date_order(mut self, order: DateOrder) -> Self {
102        self.date_order = order;
103        self
104    }
105}
106
107/// [`process_metadata`] with caller-chosen required fields and derivations.
108///
109/// # Errors
110///
111/// [`MetadataError::DateParseError`] for an unparseable `date`;
112/// [`MetadataError::MissingFieldError`] naming the first required field
113/// that is absent.
114pub fn process_metadata_with(
115    metadata: &Metadata,
116    options: &ProcessOptions,
117) -> Result<Metadata, MetadataError> {
118    let mut processed = metadata.clone();
119    if let Some(date) = processed.get("date").cloned() {
120        let standardized_date =
121            standardize_date(&date, options.date_order)?;
122        processed.insert("date".to_string(), standardized_date);
123    }
124    for field in &options.required_fields {
125        if !processed.contains_key(field) {
126            return Err(MetadataError::MissingFieldError(
127                field.clone(),
128            ));
129        }
130    }
131    if options.derive_slug {
132        generate_derived_fields(&mut processed);
133    }
134    Ok(processed)
135}
136
137/// Processes the extracted metadata.
138///
139/// This function standardizes dates, ensures required fields are present, and generates derived fields.
140///
141/// # Arguments
142///
143/// * `metadata` - A reference to the `Metadata` instance to process.
144///
145/// # Returns
146///
147/// A `Result` containing the processed `Metadata` if successful, or a `MetadataError` if processing fails.
148///
149/// # Errors
150///
151/// Returns a `MetadataError` if date standardization fails or if required fields are missing.
152pub fn process_metadata(
153    metadata: &Metadata,
154) -> Result<Metadata, MetadataError> {
155    process_metadata_with(metadata, &ProcessOptions::default())
156}
157
158/// Generates derived fields for the metadata.
159///
160/// Currently, this function generates a URL slug from the title if not already present.
161///
162/// # Arguments
163///
164/// * `metadata` - A mutable reference to the `Metadata` instance to update.
165pub(super) fn generate_derived_fields(metadata: &mut Metadata) {
166    if !metadata.contains_key("slug") {
167        if let Some(title) = metadata.get("title") {
168            let slug = generate_slug(title);
169            metadata.insert("slug".to_string(), slug);
170        }
171    }
172}
173
174/// Generates a URL slug from the given title.
175///
176/// # Arguments
177///
178/// * `title` - A string slice containing the title to convert to a slug.
179///
180/// # Returns
181///
182/// A `String` containing the generated slug.
183pub(super) fn generate_slug(title: &str) -> String {
184    let mut slug = String::with_capacity(title.len());
185    let mut pending_separator = false;
186    for ch in title.chars().flat_map(char::to_lowercase) {
187        if ch.is_alphanumeric() {
188            if pending_separator && !slug.is_empty() {
189                slug.push('-');
190            }
191            pending_separator = false;
192            slug.push(ch);
193        } else {
194            pending_separator = true;
195        }
196    }
197    slug
198}