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    /// Azure DevOps flavor — treats `:::lang` blocks as opaque code fences
47    #[serde(rename = "azure_devops", alias = "azure", alias = "ado")]
48    AzureDevOps,
49    /// MyST (Markedly Structured Text) flavor — directives, roles, dollar math, % comments
50    #[serde(rename = "myst", alias = "mystmd")]
51    MyST,
52}
53
54/// Custom JSON schema for MarkdownFlavor that includes all accepted values and aliases
55fn markdown_flavor_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
56    schemars::json_schema!({
57        "description": "Markdown flavor/dialect. Accepts: standard, gfm, mkdocs, mdx, pandoc, quarto, obsidian, kramdown, azure_devops, myst. Aliases: commonmark/github map to standard, qmd/rmd/rmarkdown map to quarto, jekyll maps to kramdown, azure/ado map to azure_devops, mystmd maps to myst.",
58        "type": "string",
59        "enum": ["standard", "gfm", "github", "commonmark", "mkdocs", "mdx", "pandoc", "quarto", "qmd", "rmd", "rmarkdown", "obsidian", "kramdown", "jekyll", "azure_devops", "azure", "ado", "myst", "mystmd"]
60    })
61}
62
63impl schemars::JsonSchema for MarkdownFlavor {
64    fn schema_name() -> std::borrow::Cow<'static, str> {
65        std::borrow::Cow::Borrowed("MarkdownFlavor")
66    }
67
68    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
69        markdown_flavor_schema(generator)
70    }
71}
72
73impl fmt::Display for MarkdownFlavor {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        match self {
76            MarkdownFlavor::Standard => write!(f, "standard"),
77            MarkdownFlavor::MkDocs => write!(f, "mkdocs"),
78            MarkdownFlavor::MDX => write!(f, "mdx"),
79            MarkdownFlavor::Pandoc => write!(f, "pandoc"),
80            MarkdownFlavor::Quarto => write!(f, "quarto"),
81            MarkdownFlavor::Obsidian => write!(f, "obsidian"),
82            MarkdownFlavor::Kramdown => write!(f, "kramdown"),
83            MarkdownFlavor::AzureDevOps => write!(f, "azure_devops"),
84            MarkdownFlavor::MyST => write!(f, "myst"),
85        }
86    }
87}
88
89impl FromStr for MarkdownFlavor {
90    type Err = String;
91
92    fn from_str(s: &str) -> Result<Self, Self::Err> {
93        match s.to_lowercase().as_str() {
94            "standard" | "" | "none" => Ok(MarkdownFlavor::Standard),
95            "mkdocs" => Ok(MarkdownFlavor::MkDocs),
96            "mdx" => Ok(MarkdownFlavor::MDX),
97            "pandoc" => Ok(MarkdownFlavor::Pandoc),
98            "quarto" | "qmd" | "rmd" | "rmarkdown" => Ok(MarkdownFlavor::Quarto),
99            "obsidian" => Ok(MarkdownFlavor::Obsidian),
100            "kramdown" | "jekyll" => Ok(MarkdownFlavor::Kramdown),
101            "azure_devops" | "azure" | "ado" => Ok(MarkdownFlavor::AzureDevOps),
102            "myst" | "mystmd" => Ok(MarkdownFlavor::MyST),
103            // GFM and CommonMark are aliases for Standard since the base parser
104            // (pulldown-cmark) already supports GFM extensions (tables, task lists,
105            // strikethrough, autolinks, etc.) which are a superset of CommonMark
106            "gfm" | "github" | "commonmark" => Ok(MarkdownFlavor::Standard),
107            _ => Err(format!("Unknown markdown flavor: {s}")),
108        }
109    }
110}
111
112impl MarkdownFlavor {
113    /// Detect flavor from file extension
114    pub fn from_extension(ext: &str) -> Self {
115        match ext.to_lowercase().as_str() {
116            "mdx" => Self::MDX,
117            "qmd" => Self::Quarto,
118            "rmd" => Self::Quarto,
119            "kramdown" => Self::Kramdown,
120            _ => Self::Standard,
121        }
122    }
123
124    /// Detect flavor from file path
125    pub fn from_path(path: &std::path::Path) -> Self {
126        path.extension()
127            .and_then(|e| e.to_str())
128            .map_or(Self::Standard, Self::from_extension)
129    }
130
131    /// Check if this flavor supports ESM imports/exports (MDX-specific)
132    pub fn supports_esm_blocks(self) -> bool {
133        matches!(self, Self::MDX)
134    }
135
136    /// Check if this flavor supports JSX components (MDX-specific)
137    pub fn supports_jsx(self) -> bool {
138        matches!(self, Self::MDX)
139    }
140
141    /// Check if this flavor supports auto-references (MkDocs-specific)
142    pub fn supports_auto_references(self) -> bool {
143        matches!(self, Self::MkDocs)
144    }
145
146    /// Check if this flavor supports kramdown syntax (IALs, ALDs, extension blocks)
147    pub fn supports_kramdown_syntax(self) -> bool {
148        matches!(self, Self::Kramdown)
149    }
150
151    /// Check if this flavor supports attribute lists ({#id .class key="value"})
152    pub fn supports_attr_lists(self) -> bool {
153        matches!(self, Self::MkDocs | Self::Kramdown)
154    }
155
156    /// Check if this flavor requires strict (≥4-space) list continuation indent.
157    ///
158    /// Python-Markdown (used by MkDocs) requires 4-space indentation for ordered
159    /// list continuation content, regardless of marker width.
160    pub fn requires_strict_list_indent(self) -> bool {
161        matches!(self, Self::MkDocs)
162    }
163
164    /// True for any flavor that includes Pandoc-style syntax — fenced divs,
165    /// attribute lists, citations, definition lists, math, raw blocks.
166    /// Use this to gate behavior shared by both Pandoc and Quarto users.
167    pub fn is_pandoc_compatible(self) -> bool {
168        matches!(self, Self::Pandoc | Self::Quarto)
169    }
170
171    /// Get a human-readable name for this flavor
172    pub fn name(self) -> &'static str {
173        match self {
174            Self::Standard => "Standard",
175            Self::MkDocs => "MkDocs",
176            Self::MDX => "MDX",
177            Self::Pandoc => "Pandoc",
178            Self::Quarto => "Quarto",
179            Self::Obsidian => "Obsidian",
180            Self::Kramdown => "Kramdown",
181            Self::AzureDevOps => "AzureDevOps",
182            Self::MyST => "MyST",
183        }
184    }
185
186    /// True only for Azure DevOps flavor, which uses `:::lang` as a code fence.
187    pub fn supports_colon_code_fences(self) -> bool {
188        matches!(self, Self::AzureDevOps)
189    }
190
191    /// True for MyST flavor — supports directive syntax (backtick and colon fences with `{name}`)
192    pub fn supports_myst_directives(self) -> bool {
193        matches!(self, Self::MyST)
194    }
195
196    /// True for MyST flavor — supports role syntax (`{role}`content``)
197    pub fn supports_myst_roles(self) -> bool {
198        matches!(self, Self::MyST)
199    }
200
201    /// True for MyST flavor — supports `%` line comments
202    pub fn supports_myst_comments(self) -> bool {
203        matches!(self, Self::MyST)
204    }
205}
206
207/// Normalizes configuration keys (rule names, option names) to lowercase kebab-case.
208pub fn normalize_key(key: &str) -> String {
209    // If the key looks like a rule name (e.g., MD013), uppercase it
210    if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
211        key.to_ascii_uppercase()
212    } else {
213        key.replace('_', "-").to_ascii_lowercase()
214    }
215}
216
217/// Warns if a per-file-ignores pattern contains a comma but no braces.
218/// This is a common mistake where users expect "A.md,B.md" to match both files,
219/// but glob syntax requires "{A.md,B.md}" for brace expansion.
220pub(super) fn warn_comma_without_brace_in_pattern(pattern: &str, config_file: &str) {
221    if pattern.contains(',') && !pattern.contains('{') {
222        eprintln!("Warning: Pattern \"{pattern}\" in {config_file} contains a comma but no braces.");
223        eprintln!("  To match multiple files, use brace expansion: \"{{{pattern}}}\"");
224        eprintln!("  Or use separate entries for each file.");
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    /// Every MarkdownFlavor variant must produce a lowercase, unquoted string via Display.
233    /// This guards against new variants being added without a matching Display arm,
234    /// and against the Display impl regressing to Debug-style output (e.g. "Standard").
235    #[test]
236    fn test_display_all_variants_are_lowercase() {
237        let cases = [
238            (MarkdownFlavor::Standard, "standard"),
239            (MarkdownFlavor::MkDocs, "mkdocs"),
240            (MarkdownFlavor::MDX, "mdx"),
241            (MarkdownFlavor::Pandoc, "pandoc"),
242            (MarkdownFlavor::Quarto, "quarto"),
243            (MarkdownFlavor::Obsidian, "obsidian"),
244            (MarkdownFlavor::Kramdown, "kramdown"),
245            (MarkdownFlavor::AzureDevOps, "azure_devops"),
246            (MarkdownFlavor::MyST, "myst"),
247        ];
248        for (variant, expected) in cases {
249            let displayed = variant.to_string();
250            assert_eq!(
251                displayed, expected,
252                "MarkdownFlavor::{variant:?} Display should produce \"{expected}\", got \"{displayed}\""
253            );
254            // Must be lowercase — no uppercase letters anywhere
255            assert!(
256                displayed.chars().all(|c| !c.is_ascii_uppercase()),
257                "MarkdownFlavor::{variant:?} Display must be entirely lowercase, got \"{displayed}\""
258            );
259        }
260    }
261
262    /// Display output must round-trip through FromStr — every variant's Display string
263    /// must parse back to the same variant.
264    #[test]
265    fn test_display_round_trips_through_from_str() {
266        let variants = [
267            MarkdownFlavor::Standard,
268            MarkdownFlavor::MkDocs,
269            MarkdownFlavor::MDX,
270            MarkdownFlavor::Pandoc,
271            MarkdownFlavor::Quarto,
272            MarkdownFlavor::Obsidian,
273            MarkdownFlavor::Kramdown,
274            MarkdownFlavor::AzureDevOps,
275            MarkdownFlavor::MyST,
276        ];
277        for variant in variants {
278            let displayed = variant.to_string();
279            let parsed: MarkdownFlavor = displayed
280                .parse()
281                .unwrap_or_else(|e| panic!("Display string \"{displayed}\" for {variant:?} failed to parse back: {e}"));
282            assert_eq!(
283                parsed, variant,
284                "Display(\"{displayed}\") for {variant:?} round-trips to a different variant: {parsed:?}"
285            );
286        }
287    }
288
289    #[test]
290    fn test_pandoc_from_str() {
291        assert_eq!("pandoc".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Pandoc);
292        assert_eq!("PANDOC".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Pandoc);
293    }
294
295    #[test]
296    fn test_pandoc_name_and_display() {
297        assert_eq!(MarkdownFlavor::Pandoc.name(), "Pandoc");
298        assert_eq!(MarkdownFlavor::Pandoc.to_string(), "pandoc");
299    }
300
301    #[test]
302    fn test_from_extension_does_not_auto_detect_pandoc() {
303        // Pandoc files use .md — must NOT auto-detect to Pandoc.
304        assert_eq!(MarkdownFlavor::from_extension("md"), MarkdownFlavor::Standard);
305        assert_eq!(MarkdownFlavor::from_extension("markdown"), MarkdownFlavor::Standard);
306    }
307
308    #[test]
309    fn test_is_pandoc_compatible() {
310        assert!(MarkdownFlavor::Pandoc.is_pandoc_compatible());
311        assert!(MarkdownFlavor::Quarto.is_pandoc_compatible());
312
313        assert!(!MarkdownFlavor::Standard.is_pandoc_compatible());
314        assert!(!MarkdownFlavor::MkDocs.is_pandoc_compatible());
315        assert!(!MarkdownFlavor::MDX.is_pandoc_compatible());
316        assert!(!MarkdownFlavor::Obsidian.is_pandoc_compatible());
317        assert!(!MarkdownFlavor::Kramdown.is_pandoc_compatible());
318    }
319
320    #[test]
321    fn test_azure_devops_from_str() {
322        assert_eq!(
323            "azure_devops".parse::<MarkdownFlavor>().unwrap(),
324            MarkdownFlavor::AzureDevOps
325        );
326        assert_eq!("azure".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::AzureDevOps);
327        assert_eq!("ado".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::AzureDevOps);
328        assert_eq!(
329            "AZURE_DEVOPS".parse::<MarkdownFlavor>().unwrap(),
330            MarkdownFlavor::AzureDevOps
331        );
332    }
333
334    #[test]
335    fn test_azure_devops_display_and_round_trip() {
336        assert_eq!(MarkdownFlavor::AzureDevOps.to_string(), "azure_devops");
337        let parsed: MarkdownFlavor = "azure_devops".parse().unwrap();
338        assert_eq!(parsed, MarkdownFlavor::AzureDevOps);
339    }
340
341    #[test]
342    fn test_supports_colon_code_fences() {
343        assert!(MarkdownFlavor::AzureDevOps.supports_colon_code_fences());
344        assert!(!MarkdownFlavor::Standard.supports_colon_code_fences());
345        assert!(!MarkdownFlavor::MkDocs.supports_colon_code_fences());
346        assert!(!MarkdownFlavor::MDX.supports_colon_code_fences());
347        assert!(!MarkdownFlavor::Pandoc.supports_colon_code_fences());
348        assert!(!MarkdownFlavor::Quarto.supports_colon_code_fences());
349        assert!(!MarkdownFlavor::Obsidian.supports_colon_code_fences());
350        assert!(!MarkdownFlavor::Kramdown.supports_colon_code_fences());
351        assert!(!MarkdownFlavor::MyST.supports_colon_code_fences());
352    }
353
354    #[test]
355    fn test_azure_devops_not_pandoc_compatible() {
356        assert!(!MarkdownFlavor::AzureDevOps.is_pandoc_compatible());
357    }
358
359    #[test]
360    fn test_display_all_variants_covers_azure_devops() {
361        let displayed = MarkdownFlavor::AzureDevOps.to_string();
362        assert!(displayed.chars().all(|c| !c.is_ascii_uppercase()));
363    }
364
365    #[test]
366    fn test_myst_from_str() {
367        assert_eq!("myst".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
368        assert_eq!("MYST".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
369        assert_eq!("mystmd".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
370    }
371
372    #[test]
373    fn test_myst_display_and_round_trip() {
374        assert_eq!(MarkdownFlavor::MyST.to_string(), "myst");
375        let parsed: MarkdownFlavor = "myst".parse().unwrap();
376        assert_eq!(parsed, MarkdownFlavor::MyST);
377    }
378
379    #[test]
380    fn test_myst_capabilities() {
381        assert!(MarkdownFlavor::MyST.supports_myst_directives());
382        assert!(MarkdownFlavor::MyST.supports_myst_roles());
383        assert!(MarkdownFlavor::MyST.supports_myst_comments());
384        assert!(!MarkdownFlavor::MyST.is_pandoc_compatible());
385        assert!(!MarkdownFlavor::MyST.supports_colon_code_fences());
386        assert!(!MarkdownFlavor::MyST.supports_jsx());
387
388        assert!(!MarkdownFlavor::Standard.supports_myst_directives());
389        assert!(!MarkdownFlavor::Standard.supports_myst_roles());
390        assert!(!MarkdownFlavor::Standard.supports_myst_comments());
391    }
392
393    #[test]
394    fn test_myst_name() {
395        assert_eq!(MarkdownFlavor::MyST.name(), "MyST");
396    }
397}