Skip to main content

html_to_markdown_rs/options/
validation.rs

1//! Validation and parsing utilities for option enums.
2//!
3//! This module provides parsing and serialization logic for configuration
4//! enums (`HeadingStyle`, `ListIndentType`, etc.) with string conversion support.
5
6/// Heading style options for Markdown output.
7///
8/// Controls how headings (h1-h6) are rendered in the output Markdown.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
10pub enum HeadingStyle {
11    /// Underlined style (=== for h1, --- for h2).
12    Underlined,
13    /// ATX style (# for h1, ## for h2, etc.). Default.
14    #[default]
15    Atx,
16    /// ATX closed style (# title #, with closing hashes).
17    AtxClosed,
18}
19
20impl HeadingStyle {
21    /// Parse a heading style from a string.
22    ///
23    /// Accepts "atx", "atxclosed", or defaults to Underlined.
24    /// Input is normalized (lowercased, alphanumeric only).
25    #[must_use]
26    #[cfg_attr(alef, alef(skip))]
27    pub fn parse(value: &str) -> Self {
28        match normalize_token(value).as_str() {
29            "atx" => Self::Atx,
30            "atxclosed" => Self::AtxClosed,
31            _ => Self::Underlined,
32        }
33    }
34}
35
36/// List indentation character type.
37///
38/// Controls whether list items are indented with spaces or tabs.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum ListIndentType {
41    /// Use spaces for indentation. Default. Width controlled by `list_indent_width`.
42    #[default]
43    Spaces,
44    /// Use tabs for indentation.
45    Tabs,
46}
47
48impl ListIndentType {
49    /// Parse a list indentation type from a string.
50    ///
51    /// Accepts "tabs" or defaults to Spaces.
52    /// Input is normalized (lowercased, alphanumeric only).
53    #[must_use]
54    #[cfg_attr(alef, alef(skip))]
55    pub fn parse(value: &str) -> Self {
56        match normalize_token(value).as_str() {
57            "tabs" => Self::Tabs,
58            _ => Self::Spaces,
59        }
60    }
61}
62
63/// Whitespace handling strategy during conversion.
64///
65/// Determines how sequences of whitespace characters (spaces, tabs, newlines) are processed.
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
67pub enum WhitespaceMode {
68    /// Collapse multiple whitespace characters to single spaces. Default. Matches browser behavior.
69    #[default]
70    Normalized,
71    /// Preserve all whitespace exactly as it appears in the HTML.
72    Strict,
73}
74
75impl WhitespaceMode {
76    /// Parse a whitespace mode from a string.
77    ///
78    /// Accepts "strict" or defaults to Normalized.
79    /// Input is normalized (lowercased, alphanumeric only).
80    #[must_use]
81    #[cfg_attr(alef, alef(skip))]
82    pub fn parse(value: &str) -> Self {
83        match normalize_token(value).as_str() {
84            "strict" => Self::Strict,
85            _ => Self::Normalized,
86        }
87    }
88}
89
90/// Line break syntax in Markdown output.
91///
92/// Controls how soft line breaks (from `<br>` or line breaks in source) are rendered.
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
94pub enum NewlineStyle {
95    /// Two trailing spaces at end of line. Default. Standard Markdown syntax.
96    #[default]
97    Spaces,
98    /// Backslash at end of line. Alternative Markdown syntax.
99    Backslash,
100}
101
102impl NewlineStyle {
103    /// Parse a newline style from a string.
104    ///
105    /// Accepts "backslash" or defaults to Spaces.
106    /// Input is normalized (lowercased, alphanumeric only).
107    #[must_use]
108    #[cfg_attr(alef, alef(skip))]
109    pub fn parse(value: &str) -> Self {
110        match normalize_token(value).as_str() {
111            "backslash" => Self::Backslash,
112            _ => Self::Spaces,
113        }
114    }
115}
116
117/// Code block fence style in Markdown output.
118///
119/// Determines how code blocks (`<pre><code>`) are rendered in Markdown.
120#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
121pub enum CodeBlockStyle {
122    /// Indented code blocks (4 spaces). `CommonMark` standard.
123    Indented,
124    /// Fenced code blocks with triple backticks. Default (GFM). Supports language hints.
125    #[default]
126    Backticks,
127    /// Fenced code blocks with tildes (~~~). Supports language hints.
128    Tildes,
129}
130
131impl CodeBlockStyle {
132    /// Parse a code block style from a string.
133    ///
134    /// Accepts "backticks", "tildes", or defaults to Indented.
135    /// Input is normalized (lowercased, alphanumeric only).
136    #[must_use]
137    #[cfg_attr(alef, alef(skip))]
138    pub fn parse(value: &str) -> Self {
139        match normalize_token(value).as_str() {
140            "backticks" => Self::Backticks,
141            "tildes" => Self::Tildes,
142            _ => Self::Indented,
143        }
144    }
145}
146
147/// Highlight rendering style for `<mark>` elements.
148///
149/// Controls how highlighted text is rendered in Markdown output.
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
151pub enum HighlightStyle {
152    /// Double equals syntax (==text==). Default. Pandoc-compatible.
153    #[default]
154    DoubleEqual,
155    /// Preserve as HTML (==text==). Original HTML tag.
156    Html,
157    /// Render as bold (**text**). Uses strong emphasis.
158    Bold,
159    /// Strip formatting, render as plain text. No markup.
160    None,
161}
162
163impl HighlightStyle {
164    /// Parse a highlight style from a string.
165    ///
166    /// Accepts "doubleequal", "html", "bold", "none", or defaults to None.
167    /// Input is normalized (lowercased, alphanumeric only).
168    #[must_use]
169    #[cfg_attr(alef, alef(skip))]
170    pub fn parse(value: &str) -> Self {
171        match normalize_token(value).as_str() {
172            "doubleequal" => Self::DoubleEqual,
173            "html" => Self::Html,
174            "bold" => Self::Bold,
175            "none" => Self::None,
176            _ => Self::None,
177        }
178    }
179}
180
181/// Link rendering style in Markdown output.
182///
183/// Controls whether links and images use inline `[text](url)` syntax or
184/// reference-style `[text][1]` syntax with definitions collected at the end.
185#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
186pub enum LinkStyle {
187    /// Inline links: `[text](url)`. Default.
188    #[default]
189    Inline,
190    /// Reference-style links: `[text][1]` with `[1]: url` at end of document.
191    Reference,
192}
193
194impl LinkStyle {
195    /// Parse a link style from a string.
196    ///
197    /// Accepts "reference" or defaults to Inline.
198    /// Input is normalized (lowercased, alphanumeric only).
199    #[must_use]
200    #[cfg_attr(alef, alef(skip))]
201    pub fn parse(value: &str) -> Self {
202        match normalize_token(value).as_str() {
203            "reference" => Self::Reference,
204            _ => Self::Inline,
205        }
206    }
207}
208
209/// URL encoding strategy for link and image destinations.
210///
211/// Controls how special characters in URL destinations are handled when they
212/// require escaping to produce valid Markdown.
213///
214/// The `Angle` variant (default) wraps the destination in angle brackets:
215/// `[text](<url with spaces>)`. This is the CommonMark-specified escape hatch
216/// but breaks when the URL itself contains `>`.
217///
218/// The `Percent` variant percent-encodes every character that is not an RFC 3986
219/// unreserved character or `/`, producing a destination safe for all Markdown
220/// parsers: `[text](url%20with%20spaces)`.
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
222pub enum UrlEscapeStyle {
223    /// Wrap destinations that contain spaces or newlines in angle brackets. Default.
224    #[default]
225    Angle,
226    /// Percent-encode all characters that are not RFC 3986 unreserved or `/`.
227    Percent,
228}
229
230impl UrlEscapeStyle {
231    /// Parse a URL escape style from a string.
232    ///
233    /// Accepts "percent" or defaults to Angle.
234    /// Input is normalized (lowercased, alphanumeric only).
235    #[must_use]
236    #[cfg_attr(alef, alef(skip))]
237    pub fn parse(value: &str) -> Self {
238        match normalize_token(value).as_str() {
239            "percent" => Self::Percent,
240            _ => Self::Angle,
241        }
242    }
243}
244
245/// Output format for conversion.
246///
247/// Specifies the target markup language format for the conversion output.
248#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
249pub enum OutputFormat {
250    /// Standard Markdown (`CommonMark` compatible). Default.
251    #[default]
252    Markdown,
253    /// Djot lightweight markup language.
254    Djot,
255    /// Plain text output (no markup, visible text only).
256    Plain,
257}
258
259impl OutputFormat {
260    /// Parse an output format from a string.
261    ///
262    /// Accepts "djot" or defaults to Markdown.
263    /// Input is normalized (lowercased, alphanumeric only).
264    #[must_use]
265    #[cfg_attr(alef, alef(skip))]
266    pub fn parse(value: &str) -> Self {
267        match normalize_token(value).as_str() {
268            "djot" => Self::Djot,
269            "plain" | "plaintext" | "text" => Self::Plain,
270            _ => Self::Markdown,
271        }
272    }
273}
274
275/// Normalize a configuration string by lowercasing and removing non-alphanumeric characters.
276pub(crate) fn normalize_token(value: &str) -> String {
277    let mut out = String::with_capacity(value.len());
278    for ch in value.chars() {
279        if ch.is_ascii_alphanumeric() {
280            out.push(ch.to_ascii_lowercase());
281        }
282    }
283    out
284}
285
286#[cfg(any(feature = "serde", feature = "metadata"))]
287mod serde_impls {
288    use super::{
289        CodeBlockStyle, HeadingStyle, HighlightStyle, LinkStyle, ListIndentType, NewlineStyle, OutputFormat,
290        UrlEscapeStyle, WhitespaceMode,
291    };
292    use serde::{Deserialize, Serialize, Serializer};
293
294    macro_rules! impl_deserialize_from_parse {
295        ($ty:ty, $parser:expr) => {
296            impl<'de> Deserialize<'de> for $ty {
297                fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
298                where
299                    D: serde::Deserializer<'de>,
300                {
301                    let value = String::deserialize(deserializer)?;
302                    Ok($parser(&value))
303                }
304            }
305        };
306    }
307
308    impl_deserialize_from_parse!(HeadingStyle, HeadingStyle::parse);
309    impl_deserialize_from_parse!(ListIndentType, ListIndentType::parse);
310    impl_deserialize_from_parse!(WhitespaceMode, WhitespaceMode::parse);
311    impl_deserialize_from_parse!(NewlineStyle, NewlineStyle::parse);
312    impl_deserialize_from_parse!(CodeBlockStyle, CodeBlockStyle::parse);
313    impl_deserialize_from_parse!(HighlightStyle, HighlightStyle::parse);
314    impl_deserialize_from_parse!(LinkStyle, LinkStyle::parse);
315    impl_deserialize_from_parse!(UrlEscapeStyle, UrlEscapeStyle::parse);
316    impl_deserialize_from_parse!(OutputFormat, OutputFormat::parse);
317
318    impl Serialize for HeadingStyle {
319        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
320        where
321            S: Serializer,
322        {
323            let s = match self {
324                Self::Underlined => "underlined",
325                Self::Atx => "atx",
326                Self::AtxClosed => "atxclosed",
327            };
328            serializer.serialize_str(s)
329        }
330    }
331
332    impl Serialize for ListIndentType {
333        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
334        where
335            S: Serializer,
336        {
337            let s = match self {
338                Self::Spaces => "spaces",
339                Self::Tabs => "tabs",
340            };
341            serializer.serialize_str(s)
342        }
343    }
344
345    impl Serialize for WhitespaceMode {
346        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
347        where
348            S: Serializer,
349        {
350            let s = match self {
351                Self::Normalized => "normalized",
352                Self::Strict => "strict",
353            };
354            serializer.serialize_str(s)
355        }
356    }
357
358    impl Serialize for NewlineStyle {
359        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
360        where
361            S: Serializer,
362        {
363            let s = match self {
364                Self::Spaces => "spaces",
365                Self::Backslash => "backslash",
366            };
367            serializer.serialize_str(s)
368        }
369    }
370
371    impl Serialize for CodeBlockStyle {
372        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
373        where
374            S: Serializer,
375        {
376            let s = match self {
377                Self::Indented => "indented",
378                Self::Backticks => "backticks",
379                Self::Tildes => "tildes",
380            };
381            serializer.serialize_str(s)
382        }
383    }
384
385    impl Serialize for HighlightStyle {
386        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
387        where
388            S: Serializer,
389        {
390            let s = match self {
391                Self::DoubleEqual => "doubleequal",
392                Self::Html => "html",
393                Self::Bold => "bold",
394                Self::None => "none",
395            };
396            serializer.serialize_str(s)
397        }
398    }
399
400    impl Serialize for LinkStyle {
401        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
402        where
403            S: Serializer,
404        {
405            let s = match self {
406                Self::Inline => "inline",
407                Self::Reference => "reference",
408            };
409            serializer.serialize_str(s)
410        }
411    }
412
413    impl Serialize for UrlEscapeStyle {
414        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
415        where
416            S: Serializer,
417        {
418            let s = match self {
419                Self::Angle => "angle",
420                Self::Percent => "percent",
421            };
422            serializer.serialize_str(s)
423        }
424    }
425
426    impl Serialize for OutputFormat {
427        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
428        where
429            S: Serializer,
430        {
431            let s = match self {
432                Self::Markdown => "markdown",
433                Self::Djot => "djot",
434                Self::Plain => "plain",
435            };
436            serializer.serialize_str(s)
437        }
438    }
439}
440
441#[cfg(all(test, any(feature = "serde", feature = "metadata")))]
442mod tests {
443    use super::*;
444
445    #[test]
446    fn test_enum_serialization() {
447        let heading = HeadingStyle::AtxClosed;
448        let json = serde_json::to_string(&heading).expect("Failed to serialize");
449        assert_eq!(json, r#""atxclosed""#);
450
451        let list_indent = ListIndentType::Tabs;
452        let json = serde_json::to_string(&list_indent).expect("Failed to serialize");
453        assert_eq!(json, r#""tabs""#);
454
455        let whitespace = WhitespaceMode::Strict;
456        let json = serde_json::to_string(&whitespace).expect("Failed to serialize");
457        assert_eq!(json, r#""strict""#);
458    }
459
460    #[test]
461    fn test_enum_deserialization() {
462        let heading: HeadingStyle = serde_json::from_str(r#""atxclosed""#).expect("Failed");
463        assert_eq!(heading, HeadingStyle::AtxClosed);
464
465        let heading: HeadingStyle = serde_json::from_str(r#""ATXCLOSED""#).expect("Failed");
466        assert_eq!(heading, HeadingStyle::AtxClosed);
467
468        let list_indent: ListIndentType = serde_json::from_str(r#""tabs""#).expect("Failed");
469        assert_eq!(list_indent, ListIndentType::Tabs);
470    }
471}