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    /// Hugo flavor — GFM plus Goldmark block attribute lists (`{class="a" id="b"}`)
53    #[serde(rename = "hugo", alias = "goldmark")]
54    Hugo,
55    /// Markdown with Gherkin (MDG) flavor for executable specifications (`.feature.md` files)
56    #[serde(rename = "mdg", alias = "markdown_with_gherkin")]
57    MDG,
58}
59
60/// Custom JSON schema for MarkdownFlavor that includes all accepted values and aliases
61fn markdown_flavor_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
62    schemars::json_schema!({
63        "description": "Markdown flavor/dialect. Accepts: standard, gfm, mkdocs, mdx, pandoc, quarto, obsidian, kramdown, azure_devops, myst, hugo, mdg. 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, goldmark maps to hugo, markdown_with_gherkin maps to mdg.",
64        "type": "string",
65        "enum": ["standard", "gfm", "github", "commonmark", "mkdocs", "mdx", "pandoc", "quarto", "qmd", "rmd", "rmarkdown", "obsidian", "kramdown", "jekyll", "azure_devops", "azure", "ado", "myst", "mystmd", "hugo", "goldmark", "mdg", "markdown_with_gherkin"]
66    })
67}
68
69impl schemars::JsonSchema for MarkdownFlavor {
70    fn schema_name() -> std::borrow::Cow<'static, str> {
71        std::borrow::Cow::Borrowed("MarkdownFlavor")
72    }
73
74    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
75        markdown_flavor_schema(generator)
76    }
77}
78
79impl fmt::Display for MarkdownFlavor {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        match self {
82            MarkdownFlavor::Standard => write!(f, "standard"),
83            MarkdownFlavor::MkDocs => write!(f, "mkdocs"),
84            MarkdownFlavor::MDX => write!(f, "mdx"),
85            MarkdownFlavor::Pandoc => write!(f, "pandoc"),
86            MarkdownFlavor::Quarto => write!(f, "quarto"),
87            MarkdownFlavor::Obsidian => write!(f, "obsidian"),
88            MarkdownFlavor::Kramdown => write!(f, "kramdown"),
89            MarkdownFlavor::AzureDevOps => write!(f, "azure_devops"),
90            MarkdownFlavor::MyST => write!(f, "myst"),
91            MarkdownFlavor::Hugo => write!(f, "hugo"),
92            MarkdownFlavor::MDG => write!(f, "mdg"),
93        }
94    }
95}
96
97impl FromStr for MarkdownFlavor {
98    type Err = String;
99
100    fn from_str(s: &str) -> Result<Self, Self::Err> {
101        match s.to_lowercase().as_str() {
102            "standard" | "" | "none" => Ok(MarkdownFlavor::Standard),
103            "mkdocs" => Ok(MarkdownFlavor::MkDocs),
104            "mdx" => Ok(MarkdownFlavor::MDX),
105            "pandoc" => Ok(MarkdownFlavor::Pandoc),
106            "quarto" | "qmd" | "rmd" | "rmarkdown" => Ok(MarkdownFlavor::Quarto),
107            "obsidian" => Ok(MarkdownFlavor::Obsidian),
108            "kramdown" | "jekyll" => Ok(MarkdownFlavor::Kramdown),
109            "azure_devops" | "azure" | "ado" => Ok(MarkdownFlavor::AzureDevOps),
110            "myst" | "mystmd" => Ok(MarkdownFlavor::MyST),
111            "hugo" | "goldmark" => Ok(MarkdownFlavor::Hugo),
112            // GFM and CommonMark are aliases for Standard since the base parser
113            // (pulldown-cmark) already supports GFM extensions (tables, task lists,
114            // strikethrough, autolinks, etc.) which are a superset of CommonMark
115            "gfm" | "github" | "commonmark" => Ok(MarkdownFlavor::Standard),
116            "mdg" | "markdown_with_gherkin" => Ok(MarkdownFlavor::MDG),
117            _ => Err(format!("Unknown markdown flavor: {s}")),
118        }
119    }
120}
121
122impl MarkdownFlavor {
123    /// Detect flavor from file extension
124    pub fn from_extension(ext: &str) -> Self {
125        match ext.to_lowercase().as_str() {
126            "mdx" => Self::MDX,
127            "qmd" => Self::Quarto,
128            "rmd" => Self::Quarto,
129            "kramdown" => Self::Kramdown,
130            _ => Self::Standard,
131        }
132    }
133
134    /// Detect flavor from file path
135    pub fn from_path(path: &std::path::Path) -> Self {
136        if path
137            .file_name()
138            .and_then(|name| name.to_str())
139            .is_some_and(|name| name.to_ascii_lowercase().ends_with(".feature.md"))
140        {
141            return Self::MDG;
142        }
143
144        path.extension()
145            .and_then(|e| e.to_str())
146            .map_or(Self::Standard, Self::from_extension)
147    }
148
149    /// Check if this flavor supports ESM imports/exports (MDX-specific)
150    pub fn supports_esm_blocks(self) -> bool {
151        matches!(self, Self::MDX)
152    }
153
154    /// Check if this flavor supports JSX components (MDX-specific)
155    pub fn supports_jsx(self) -> bool {
156        matches!(self, Self::MDX)
157    }
158
159    /// Check if this flavor supports auto-references (MkDocs-specific)
160    pub fn supports_auto_references(self) -> bool {
161        matches!(self, Self::MkDocs)
162    }
163
164    /// Check if this flavor supports kramdown syntax (IALs, ALDs, extension blocks)
165    pub fn supports_kramdown_syntax(self) -> bool {
166        matches!(self, Self::Kramdown)
167    }
168
169    /// Check if this flavor supports attribute lists ({#id .class key="value"})
170    pub fn supports_attr_lists(self) -> bool {
171        matches!(self, Self::MkDocs | Self::Kramdown | Self::Hugo)
172    }
173
174    /// Check if this flavor requires strict (≥4-space) list continuation indent.
175    ///
176    /// Python-Markdown (used by MkDocs) requires 4-space indentation for ordered
177    /// list continuation content, regardless of marker width.
178    pub fn requires_strict_list_indent(self) -> bool {
179        matches!(self, Self::MkDocs)
180    }
181
182    /// True for any flavor that includes Pandoc-style syntax — fenced divs,
183    /// attribute lists, citations, definition lists, math, raw blocks.
184    /// Use this to gate behavior shared by both Pandoc and Quarto users.
185    pub fn is_pandoc_compatible(self) -> bool {
186        matches!(self, Self::Pandoc | Self::Quarto)
187    }
188
189    /// Get a human-readable name for this flavor
190    pub fn name(self) -> &'static str {
191        match self {
192            Self::Standard => "Standard",
193            Self::MkDocs => "MkDocs",
194            Self::MDX => "MDX",
195            Self::Pandoc => "Pandoc",
196            Self::Quarto => "Quarto",
197            Self::Obsidian => "Obsidian",
198            Self::Kramdown => "Kramdown",
199            Self::AzureDevOps => "AzureDevOps",
200            Self::MyST => "MyST",
201            Self::Hugo => "Hugo",
202            Self::MDG => "Markdown with Gherkin",
203        }
204    }
205
206    /// True only for Azure DevOps flavor, which uses `:::lang` as a code fence.
207    pub fn supports_colon_code_fences(self) -> bool {
208        matches!(self, Self::AzureDevOps)
209    }
210
211    /// True for MyST flavor — supports directive syntax (backtick and colon fences with `{name}`)
212    pub fn supports_myst_directives(self) -> bool {
213        matches!(self, Self::MyST)
214    }
215
216    /// True for MyST flavor — supports role syntax (`{role}`content``)
217    pub fn supports_myst_roles(self) -> bool {
218        matches!(self, Self::MyST)
219    }
220
221    /// True for MyST flavor — supports `%` line comments
222    pub fn supports_myst_comments(self) -> bool {
223        matches!(self, Self::MyST)
224    }
225}
226
227/// Normalizes configuration keys (rule names, option names) to lowercase kebab-case.
228pub fn normalize_key(key: &str) -> String {
229    // If the key looks like a rule name (e.g., MD013), uppercase it
230    if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
231        key.to_ascii_uppercase()
232    } else {
233        key.replace('_', "-").to_ascii_lowercase()
234    }
235}
236
237/// Warns if a per-file-ignores pattern contains a comma but no braces.
238/// This is a common mistake where users expect "A.md,B.md" to match both files,
239/// but glob syntax requires "{A.md,B.md}" for brace expansion.
240///
241/// The pattern is a key out of the config file, so for one reached through
242/// `extends` it is named rather than shown, and the suggestion that would have
243/// rewritten it becomes a description of the rewrite.
244pub(super) fn warn_comma_without_brace_in_pattern(pattern: &str, config_file: super::types::ConfigRef<'_>) {
245    if pattern.contains(',') && !pattern.contains('{') {
246        if config_file.may_quote_contents() {
247            eprintln!("Warning: Pattern \"{pattern}\" in {config_file} contains a comma but no braces.");
248            eprintln!("  To match multiple files, use brace expansion: \"{{{pattern}}}\"");
249        } else {
250            eprintln!("Warning: A pattern in {config_file} contains a comma but no braces.");
251            eprintln!("  To match multiple files, wrap the pattern in braces for brace expansion.");
252        }
253        eprintln!("  Or use separate entries for each file.");
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    /// Every MarkdownFlavor variant must produce a lowercase, unquoted string via Display.
262    /// This guards against new variants being added without a matching Display arm,
263    /// and against the Display impl regressing to Debug-style output (e.g. "Standard").
264    #[test]
265    fn test_display_all_variants_are_lowercase() {
266        let cases = [
267            (MarkdownFlavor::Standard, "standard"),
268            (MarkdownFlavor::MkDocs, "mkdocs"),
269            (MarkdownFlavor::MDX, "mdx"),
270            (MarkdownFlavor::Pandoc, "pandoc"),
271            (MarkdownFlavor::Quarto, "quarto"),
272            (MarkdownFlavor::Obsidian, "obsidian"),
273            (MarkdownFlavor::Kramdown, "kramdown"),
274            (MarkdownFlavor::AzureDevOps, "azure_devops"),
275            (MarkdownFlavor::MyST, "myst"),
276            (MarkdownFlavor::Hugo, "hugo"),
277            (MarkdownFlavor::MDG, "mdg"),
278        ];
279        for (variant, expected) in cases {
280            let displayed = variant.to_string();
281            assert_eq!(
282                displayed, expected,
283                "MarkdownFlavor::{variant:?} Display should produce \"{expected}\", got \"{displayed}\""
284            );
285            // Must be lowercase — no uppercase letters anywhere
286            assert!(
287                displayed.chars().all(|c| !c.is_ascii_uppercase()),
288                "MarkdownFlavor::{variant:?} Display must be entirely lowercase, got \"{displayed}\""
289            );
290        }
291    }
292
293    /// Display output must round-trip through FromStr — every variant's Display string
294    /// must parse back to the same variant.
295    #[test]
296    fn test_display_round_trips_through_from_str() {
297        let variants = [
298            MarkdownFlavor::Standard,
299            MarkdownFlavor::MkDocs,
300            MarkdownFlavor::MDX,
301            MarkdownFlavor::Pandoc,
302            MarkdownFlavor::Quarto,
303            MarkdownFlavor::Obsidian,
304            MarkdownFlavor::Kramdown,
305            MarkdownFlavor::AzureDevOps,
306            MarkdownFlavor::MyST,
307            MarkdownFlavor::Hugo,
308            MarkdownFlavor::MDG,
309        ];
310        for variant in variants {
311            let displayed = variant.to_string();
312            let parsed: MarkdownFlavor = displayed
313                .parse()
314                .unwrap_or_else(|e| panic!("Display string \"{displayed}\" for {variant:?} failed to parse back: {e}"));
315            assert_eq!(
316                parsed, variant,
317                "Display(\"{displayed}\") for {variant:?} round-trips to a different variant: {parsed:?}"
318            );
319        }
320    }
321
322    #[test]
323    fn test_pandoc_from_str() {
324        assert_eq!("pandoc".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Pandoc);
325        assert_eq!("PANDOC".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Pandoc);
326    }
327
328    #[test]
329    fn test_pandoc_name_and_display() {
330        assert_eq!(MarkdownFlavor::Pandoc.name(), "Pandoc");
331        assert_eq!(MarkdownFlavor::Pandoc.to_string(), "pandoc");
332    }
333
334    #[test]
335    fn test_from_extension_does_not_auto_detect_pandoc() {
336        // Pandoc files use .md — must NOT auto-detect to Pandoc.
337        assert_eq!(MarkdownFlavor::from_extension("md"), MarkdownFlavor::Standard);
338        assert_eq!(MarkdownFlavor::from_extension("markdown"), MarkdownFlavor::Standard);
339    }
340
341    #[test]
342    fn test_mdg_aliases_and_name() {
343        assert_eq!("MDG".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MDG);
344        assert_eq!(
345            "markdown_with_gherkin".parse::<MarkdownFlavor>().unwrap(),
346            MarkdownFlavor::MDG
347        );
348        assert_eq!(MarkdownFlavor::MDG.name(), "Markdown with Gherkin");
349    }
350
351    #[test]
352    fn test_mdg_serde_names() {
353        assert_eq!(
354            MarkdownFlavor::deserialize(toml::Value::String("markdown_with_gherkin".to_string())).unwrap(),
355            MarkdownFlavor::MDG
356        );
357        assert_eq!(
358            toml::Value::try_from(MarkdownFlavor::MDG).unwrap().as_str(),
359            Some("mdg")
360        );
361    }
362
363    #[test]
364    fn test_from_path_detects_feature_md_compound_suffix() {
365        use std::path::Path;
366
367        assert_eq!(
368            MarkdownFlavor::from_path(Path::new("features/login.feature.md")),
369            MarkdownFlavor::MDG
370        );
371        assert_eq!(
372            MarkdownFlavor::from_path(Path::new("features/login.FEATURE.MD")),
373            MarkdownFlavor::MDG
374        );
375        assert_eq!(
376            MarkdownFlavor::from_path(Path::new("README.md")),
377            MarkdownFlavor::Standard
378        );
379        assert_eq!(
380            MarkdownFlavor::from_path(Path::new("features/login.feature.markdown")),
381            MarkdownFlavor::Standard
382        );
383    }
384
385    #[test]
386    fn test_is_pandoc_compatible() {
387        assert!(MarkdownFlavor::Pandoc.is_pandoc_compatible());
388        assert!(MarkdownFlavor::Quarto.is_pandoc_compatible());
389
390        assert!(!MarkdownFlavor::Standard.is_pandoc_compatible());
391        assert!(!MarkdownFlavor::MkDocs.is_pandoc_compatible());
392        assert!(!MarkdownFlavor::MDX.is_pandoc_compatible());
393        assert!(!MarkdownFlavor::Obsidian.is_pandoc_compatible());
394        assert!(!MarkdownFlavor::Kramdown.is_pandoc_compatible());
395    }
396
397    #[test]
398    fn test_azure_devops_from_str() {
399        assert_eq!(
400            "azure_devops".parse::<MarkdownFlavor>().unwrap(),
401            MarkdownFlavor::AzureDevOps
402        );
403        assert_eq!("azure".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::AzureDevOps);
404        assert_eq!("ado".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::AzureDevOps);
405        assert_eq!(
406            "AZURE_DEVOPS".parse::<MarkdownFlavor>().unwrap(),
407            MarkdownFlavor::AzureDevOps
408        );
409    }
410
411    #[test]
412    fn test_azure_devops_display_and_round_trip() {
413        assert_eq!(MarkdownFlavor::AzureDevOps.to_string(), "azure_devops");
414        let parsed: MarkdownFlavor = "azure_devops".parse().unwrap();
415        assert_eq!(parsed, MarkdownFlavor::AzureDevOps);
416    }
417
418    #[test]
419    fn test_supports_colon_code_fences() {
420        assert!(MarkdownFlavor::AzureDevOps.supports_colon_code_fences());
421        assert!(!MarkdownFlavor::Standard.supports_colon_code_fences());
422        assert!(!MarkdownFlavor::MkDocs.supports_colon_code_fences());
423        assert!(!MarkdownFlavor::MDX.supports_colon_code_fences());
424        assert!(!MarkdownFlavor::Pandoc.supports_colon_code_fences());
425        assert!(!MarkdownFlavor::Quarto.supports_colon_code_fences());
426        assert!(!MarkdownFlavor::Obsidian.supports_colon_code_fences());
427        assert!(!MarkdownFlavor::Kramdown.supports_colon_code_fences());
428        assert!(!MarkdownFlavor::MyST.supports_colon_code_fences());
429    }
430
431    #[test]
432    fn test_azure_devops_not_pandoc_compatible() {
433        assert!(!MarkdownFlavor::AzureDevOps.is_pandoc_compatible());
434    }
435
436    #[test]
437    fn test_display_all_variants_covers_azure_devops() {
438        let displayed = MarkdownFlavor::AzureDevOps.to_string();
439        assert!(displayed.chars().all(|c| !c.is_ascii_uppercase()));
440    }
441
442    #[test]
443    fn test_myst_from_str() {
444        assert_eq!("myst".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
445        assert_eq!("MYST".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
446        assert_eq!("mystmd".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
447    }
448
449    #[test]
450    fn test_myst_display_and_round_trip() {
451        assert_eq!(MarkdownFlavor::MyST.to_string(), "myst");
452        let parsed: MarkdownFlavor = "myst".parse().unwrap();
453        assert_eq!(parsed, MarkdownFlavor::MyST);
454    }
455
456    #[test]
457    fn test_myst_capabilities() {
458        assert!(MarkdownFlavor::MyST.supports_myst_directives());
459        assert!(MarkdownFlavor::MyST.supports_myst_roles());
460        assert!(MarkdownFlavor::MyST.supports_myst_comments());
461        assert!(!MarkdownFlavor::MyST.is_pandoc_compatible());
462        assert!(!MarkdownFlavor::MyST.supports_colon_code_fences());
463        assert!(!MarkdownFlavor::MyST.supports_jsx());
464
465        assert!(!MarkdownFlavor::Standard.supports_myst_directives());
466        assert!(!MarkdownFlavor::Standard.supports_myst_roles());
467        assert!(!MarkdownFlavor::Standard.supports_myst_comments());
468    }
469
470    #[test]
471    fn test_myst_name() {
472        assert_eq!(MarkdownFlavor::MyST.name(), "MyST");
473    }
474
475    #[test]
476    fn test_hugo_from_str() {
477        assert_eq!("hugo".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Hugo);
478        assert_eq!("HUGO".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Hugo);
479        assert_eq!("goldmark".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Hugo);
480    }
481
482    #[test]
483    fn test_hugo_display_name_and_round_trip() {
484        assert_eq!(MarkdownFlavor::Hugo.to_string(), "hugo");
485        assert_eq!(MarkdownFlavor::Hugo.name(), "Hugo");
486        let parsed: MarkdownFlavor = "hugo".parse().unwrap();
487        assert_eq!(parsed, MarkdownFlavor::Hugo);
488    }
489
490    #[test]
491    fn test_hugo_supports_attr_lists() {
492        // Hugo/Goldmark supports block attribute lists like MkDocs and Kramdown.
493        assert!(MarkdownFlavor::Hugo.supports_attr_lists());
494        assert!(MarkdownFlavor::MkDocs.supports_attr_lists());
495        assert!(MarkdownFlavor::Kramdown.supports_attr_lists());
496
497        // Standard Markdown does not: there, `{class="a"}` is literal text.
498        assert!(!MarkdownFlavor::Standard.supports_attr_lists());
499        assert!(!MarkdownFlavor::Pandoc.supports_attr_lists());
500
501        // Hugo is otherwise plain GFM.
502        assert!(!MarkdownFlavor::Hugo.is_pandoc_compatible());
503        assert!(!MarkdownFlavor::Hugo.supports_myst_directives());
504        assert!(!MarkdownFlavor::Hugo.supports_colon_code_fences());
505    }
506}