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