Skip to main content

rumdl_lib/config/
flavor.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3use std::str::FromStr;
4
5// ============================================================================
6// Typestate markers for configuration pipeline
7// ============================================================================
8
9/// Marker type for configuration that has been loaded but not yet validated.
10/// This is the initial state after `load_with_discovery()`.
11#[derive(Debug, Clone, Copy, Default)]
12pub struct ConfigLoaded;
13
14/// Marker type for configuration that has been validated.
15/// Only validated configs can be converted to `Config`.
16#[derive(Debug, Clone, Copy, Default)]
17pub struct ConfigValidated;
18
19/// Markdown flavor/dialect enumeration
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
21#[serde(rename_all = "lowercase")]
22pub enum MarkdownFlavor {
23    /// Standard Markdown without flavor-specific adjustments
24    #[serde(rename = "standard", alias = "none", alias = "")]
25    #[default]
26    Standard,
27    /// MkDocs flavor with auto-reference support
28    #[serde(rename = "mkdocs")]
29    MkDocs,
30    /// MDX flavor with JSX and ESM support (.mdx files)
31    #[serde(rename = "mdx")]
32    MDX,
33    /// Pandoc Markdown — fenced divs, attribute lists, citations, definition
34    /// lists, math, and other Pandoc-specific syntax.
35    #[serde(rename = "pandoc")]
36    Pandoc,
37    /// Quarto/RMarkdown flavor for scientific publishing (.qmd, .Rmd files)
38    #[serde(rename = "quarto")]
39    Quarto,
40    /// Obsidian flavor with tag syntax support (#tagname as tags, not headings)
41    #[serde(rename = "obsidian")]
42    Obsidian,
43    /// Kramdown flavor for Jekyll sites with IAL, ALD, and extension block support
44    #[serde(rename = "kramdown")]
45    Kramdown,
46}
47
48/// Custom JSON schema for MarkdownFlavor that includes all accepted values and aliases
49fn markdown_flavor_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
50    schemars::json_schema!({
51        "description": "Markdown flavor/dialect. Accepts: standard, gfm, mkdocs, mdx, pandoc, quarto, obsidian, kramdown. Aliases: commonmark/github map to standard, qmd/rmd/rmarkdown map to quarto, jekyll maps to kramdown.",
52        "type": "string",
53        "enum": ["standard", "gfm", "github", "commonmark", "mkdocs", "mdx", "pandoc", "quarto", "qmd", "rmd", "rmarkdown", "obsidian", "kramdown", "jekyll"]
54    })
55}
56
57impl schemars::JsonSchema for MarkdownFlavor {
58    fn schema_name() -> std::borrow::Cow<'static, str> {
59        std::borrow::Cow::Borrowed("MarkdownFlavor")
60    }
61
62    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
63        markdown_flavor_schema(generator)
64    }
65}
66
67impl fmt::Display for MarkdownFlavor {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        match self {
70            MarkdownFlavor::Standard => write!(f, "standard"),
71            MarkdownFlavor::MkDocs => write!(f, "mkdocs"),
72            MarkdownFlavor::MDX => write!(f, "mdx"),
73            MarkdownFlavor::Pandoc => write!(f, "pandoc"),
74            MarkdownFlavor::Quarto => write!(f, "quarto"),
75            MarkdownFlavor::Obsidian => write!(f, "obsidian"),
76            MarkdownFlavor::Kramdown => write!(f, "kramdown"),
77        }
78    }
79}
80
81impl FromStr for MarkdownFlavor {
82    type Err = String;
83
84    fn from_str(s: &str) -> Result<Self, Self::Err> {
85        match s.to_lowercase().as_str() {
86            "standard" | "" | "none" => Ok(MarkdownFlavor::Standard),
87            "mkdocs" => Ok(MarkdownFlavor::MkDocs),
88            "mdx" => Ok(MarkdownFlavor::MDX),
89            "pandoc" => Ok(MarkdownFlavor::Pandoc),
90            "quarto" | "qmd" | "rmd" | "rmarkdown" => Ok(MarkdownFlavor::Quarto),
91            "obsidian" => Ok(MarkdownFlavor::Obsidian),
92            "kramdown" | "jekyll" => Ok(MarkdownFlavor::Kramdown),
93            // GFM and CommonMark are aliases for Standard since the base parser
94            // (pulldown-cmark) already supports GFM extensions (tables, task lists,
95            // strikethrough, autolinks, etc.) which are a superset of CommonMark
96            "gfm" | "github" | "commonmark" => Ok(MarkdownFlavor::Standard),
97            _ => Err(format!("Unknown markdown flavor: {s}")),
98        }
99    }
100}
101
102impl MarkdownFlavor {
103    /// Detect flavor from file extension
104    pub fn from_extension(ext: &str) -> Self {
105        match ext.to_lowercase().as_str() {
106            "mdx" => Self::MDX,
107            "qmd" => Self::Quarto,
108            "rmd" => Self::Quarto,
109            "kramdown" => Self::Kramdown,
110            _ => Self::Standard,
111        }
112    }
113
114    /// Detect flavor from file path
115    pub fn from_path(path: &std::path::Path) -> Self {
116        path.extension()
117            .and_then(|e| e.to_str())
118            .map_or(Self::Standard, Self::from_extension)
119    }
120
121    /// Check if this flavor supports ESM imports/exports (MDX-specific)
122    pub fn supports_esm_blocks(self) -> bool {
123        matches!(self, Self::MDX)
124    }
125
126    /// Check if this flavor supports JSX components (MDX-specific)
127    pub fn supports_jsx(self) -> bool {
128        matches!(self, Self::MDX)
129    }
130
131    /// Check if this flavor supports auto-references (MkDocs-specific)
132    pub fn supports_auto_references(self) -> bool {
133        matches!(self, Self::MkDocs)
134    }
135
136    /// Check if this flavor supports kramdown syntax (IALs, ALDs, extension blocks)
137    pub fn supports_kramdown_syntax(self) -> bool {
138        matches!(self, Self::Kramdown)
139    }
140
141    /// Check if this flavor supports attribute lists ({#id .class key="value"})
142    pub fn supports_attr_lists(self) -> bool {
143        matches!(self, Self::MkDocs | Self::Kramdown)
144    }
145
146    /// Check if this flavor requires strict (≥4-space) list continuation indent.
147    ///
148    /// Python-Markdown (used by MkDocs) requires 4-space indentation for ordered
149    /// list continuation content, regardless of marker width.
150    pub fn requires_strict_list_indent(self) -> bool {
151        matches!(self, Self::MkDocs)
152    }
153
154    /// True for any flavor that includes Pandoc-style syntax — fenced divs,
155    /// attribute lists, citations, definition lists, math, raw blocks.
156    /// Use this to gate behavior shared by both Pandoc and Quarto users.
157    pub fn is_pandoc_compatible(self) -> bool {
158        matches!(self, Self::Pandoc | Self::Quarto)
159    }
160
161    /// Get a human-readable name for this flavor
162    pub fn name(self) -> &'static str {
163        match self {
164            Self::Standard => "Standard",
165            Self::MkDocs => "MkDocs",
166            Self::MDX => "MDX",
167            Self::Pandoc => "Pandoc",
168            Self::Quarto => "Quarto",
169            Self::Obsidian => "Obsidian",
170            Self::Kramdown => "Kramdown",
171        }
172    }
173}
174
175/// Normalizes configuration keys (rule names, option names) to lowercase kebab-case.
176pub fn normalize_key(key: &str) -> String {
177    // If the key looks like a rule name (e.g., MD013), uppercase it
178    if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
179        key.to_ascii_uppercase()
180    } else {
181        key.replace('_', "-").to_ascii_lowercase()
182    }
183}
184
185/// Warns if a per-file-ignores pattern contains a comma but no braces.
186/// This is a common mistake where users expect "A.md,B.md" to match both files,
187/// but glob syntax requires "{A.md,B.md}" for brace expansion.
188pub(super) fn warn_comma_without_brace_in_pattern(pattern: &str, config_file: &str) {
189    if pattern.contains(',') && !pattern.contains('{') {
190        eprintln!("Warning: Pattern \"{pattern}\" in {config_file} contains a comma but no braces.");
191        eprintln!("  To match multiple files, use brace expansion: \"{{{pattern}}}\"");
192        eprintln!("  Or use separate entries for each file.");
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    /// Every MarkdownFlavor variant must produce a lowercase, unquoted string via Display.
201    /// This guards against new variants being added without a matching Display arm,
202    /// and against the Display impl regressing to Debug-style output (e.g. "Standard").
203    #[test]
204    fn test_display_all_variants_are_lowercase() {
205        let cases = [
206            (MarkdownFlavor::Standard, "standard"),
207            (MarkdownFlavor::MkDocs, "mkdocs"),
208            (MarkdownFlavor::MDX, "mdx"),
209            (MarkdownFlavor::Pandoc, "pandoc"),
210            (MarkdownFlavor::Quarto, "quarto"),
211            (MarkdownFlavor::Obsidian, "obsidian"),
212            (MarkdownFlavor::Kramdown, "kramdown"),
213        ];
214        for (variant, expected) in cases {
215            let displayed = variant.to_string();
216            assert_eq!(
217                displayed, expected,
218                "MarkdownFlavor::{variant:?} Display should produce \"{expected}\", got \"{displayed}\""
219            );
220            // Must be lowercase — no uppercase letters anywhere
221            assert!(
222                displayed.chars().all(|c| !c.is_ascii_uppercase()),
223                "MarkdownFlavor::{variant:?} Display must be entirely lowercase, got \"{displayed}\""
224            );
225        }
226    }
227
228    /// Display output must round-trip through FromStr — every variant's Display string
229    /// must parse back to the same variant.
230    #[test]
231    fn test_display_round_trips_through_from_str() {
232        let variants = [
233            MarkdownFlavor::Standard,
234            MarkdownFlavor::MkDocs,
235            MarkdownFlavor::MDX,
236            MarkdownFlavor::Pandoc,
237            MarkdownFlavor::Quarto,
238            MarkdownFlavor::Obsidian,
239            MarkdownFlavor::Kramdown,
240        ];
241        for variant in variants {
242            let displayed = variant.to_string();
243            let parsed: MarkdownFlavor = displayed
244                .parse()
245                .unwrap_or_else(|e| panic!("Display string \"{displayed}\" for {variant:?} failed to parse back: {e}"));
246            assert_eq!(
247                parsed, variant,
248                "Display(\"{displayed}\") for {variant:?} round-trips to a different variant: {parsed:?}"
249            );
250        }
251    }
252
253    #[test]
254    fn test_pandoc_from_str() {
255        assert_eq!("pandoc".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Pandoc);
256        assert_eq!("PANDOC".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Pandoc);
257    }
258
259    #[test]
260    fn test_pandoc_name_and_display() {
261        assert_eq!(MarkdownFlavor::Pandoc.name(), "Pandoc");
262        assert_eq!(MarkdownFlavor::Pandoc.to_string(), "pandoc");
263    }
264
265    #[test]
266    fn test_from_extension_does_not_auto_detect_pandoc() {
267        // Pandoc files use .md — must NOT auto-detect to Pandoc.
268        assert_eq!(MarkdownFlavor::from_extension("md"), MarkdownFlavor::Standard);
269        assert_eq!(MarkdownFlavor::from_extension("markdown"), MarkdownFlavor::Standard);
270    }
271
272    #[test]
273    fn test_is_pandoc_compatible() {
274        assert!(MarkdownFlavor::Pandoc.is_pandoc_compatible());
275        assert!(MarkdownFlavor::Quarto.is_pandoc_compatible());
276
277        assert!(!MarkdownFlavor::Standard.is_pandoc_compatible());
278        assert!(!MarkdownFlavor::MkDocs.is_pandoc_compatible());
279        assert!(!MarkdownFlavor::MDX.is_pandoc_compatible());
280        assert!(!MarkdownFlavor::Obsidian.is_pandoc_compatible());
281        assert!(!MarkdownFlavor::Kramdown.is_pandoc_compatible());
282    }
283}