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}