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 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    // Serialize implementations that convert enum variants to their string representations
319    impl Serialize for HeadingStyle {
320        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
321        where
322            S: Serializer,
323        {
324            let s = match self {
325                Self::Underlined => "underlined",
326                Self::Atx => "atx",
327                Self::AtxClosed => "atxclosed",
328            };
329            serializer.serialize_str(s)
330        }
331    }
332
333    impl Serialize for ListIndentType {
334        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
335        where
336            S: Serializer,
337        {
338            let s = match self {
339                Self::Spaces => "spaces",
340                Self::Tabs => "tabs",
341            };
342            serializer.serialize_str(s)
343        }
344    }
345
346    impl Serialize for WhitespaceMode {
347        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
348        where
349            S: Serializer,
350        {
351            let s = match self {
352                Self::Normalized => "normalized",
353                Self::Strict => "strict",
354            };
355            serializer.serialize_str(s)
356        }
357    }
358
359    impl Serialize for NewlineStyle {
360        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
361        where
362            S: Serializer,
363        {
364            let s = match self {
365                Self::Spaces => "spaces",
366                Self::Backslash => "backslash",
367            };
368            serializer.serialize_str(s)
369        }
370    }
371
372    impl Serialize for CodeBlockStyle {
373        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
374        where
375            S: Serializer,
376        {
377            let s = match self {
378                Self::Indented => "indented",
379                Self::Backticks => "backticks",
380                Self::Tildes => "tildes",
381            };
382            serializer.serialize_str(s)
383        }
384    }
385
386    impl Serialize for HighlightStyle {
387        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
388        where
389            S: Serializer,
390        {
391            let s = match self {
392                Self::DoubleEqual => "doubleequal",
393                Self::Html => "html",
394                Self::Bold => "bold",
395                Self::None => "none",
396            };
397            serializer.serialize_str(s)
398        }
399    }
400
401    impl Serialize for LinkStyle {
402        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
403        where
404            S: Serializer,
405        {
406            let s = match self {
407                Self::Inline => "inline",
408                Self::Reference => "reference",
409            };
410            serializer.serialize_str(s)
411        }
412    }
413
414    impl Serialize for UrlEscapeStyle {
415        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
416        where
417            S: Serializer,
418        {
419            let s = match self {
420                Self::Angle => "angle",
421                Self::Percent => "percent",
422            };
423            serializer.serialize_str(s)
424        }
425    }
426
427    impl Serialize for OutputFormat {
428        fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
429        where
430            S: Serializer,
431        {
432            let s = match self {
433                Self::Markdown => "markdown",
434                Self::Djot => "djot",
435                Self::Plain => "plain",
436            };
437            serializer.serialize_str(s)
438        }
439    }
440}
441
442#[cfg(all(test, any(feature = "serde", feature = "metadata")))]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn test_enum_serialization() {
448        // Test that enums serialize to lowercase strings
449        let heading = HeadingStyle::AtxClosed;
450        let json = serde_json::to_string(&heading).expect("Failed to serialize");
451        assert_eq!(json, r#""atxclosed""#);
452
453        let list_indent = ListIndentType::Tabs;
454        let json = serde_json::to_string(&list_indent).expect("Failed to serialize");
455        assert_eq!(json, r#""tabs""#);
456
457        let whitespace = WhitespaceMode::Strict;
458        let json = serde_json::to_string(&whitespace).expect("Failed to serialize");
459        assert_eq!(json, r#""strict""#);
460    }
461
462    #[test]
463    fn test_enum_deserialization() {
464        // Test that enums deserialize from strings (case insensitive)
465        let heading: HeadingStyle = serde_json::from_str(r#""atxclosed""#).expect("Failed");
466        assert_eq!(heading, HeadingStyle::AtxClosed);
467
468        let heading: HeadingStyle = serde_json::from_str(r#""ATXCLOSED""#).expect("Failed");
469        assert_eq!(heading, HeadingStyle::AtxClosed);
470
471        let list_indent: ListIndentType = serde_json::from_str(r#""tabs""#).expect("Failed");
472        assert_eq!(list_indent, ListIndentType::Tabs);
473    }
474}