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    /// Quarto/RMarkdown flavor for scientific publishing (.qmd, .Rmd files)
34    #[serde(rename = "quarto")]
35    Quarto,
36    /// Obsidian flavor with tag syntax support (#tagname as tags, not headings)
37    #[serde(rename = "obsidian")]
38    Obsidian,
39    /// Kramdown flavor for Jekyll sites with IAL, ALD, and extension block support
40    #[serde(rename = "kramdown")]
41    Kramdown,
42}
43
44/// Custom JSON schema for MarkdownFlavor that includes all accepted values and aliases
45fn markdown_flavor_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
46    schemars::json_schema!({
47        "description": "Markdown flavor/dialect. Accepts: standard, gfm, mkdocs, mdx, quarto, obsidian, kramdown. Aliases: commonmark/github map to standard, qmd/rmd/rmarkdown map to quarto, jekyll maps to kramdown.",
48        "type": "string",
49        "enum": ["standard", "gfm", "github", "commonmark", "mkdocs", "mdx", "quarto", "qmd", "rmd", "rmarkdown", "obsidian", "kramdown", "jekyll"]
50    })
51}
52
53impl schemars::JsonSchema for MarkdownFlavor {
54    fn schema_name() -> std::borrow::Cow<'static, str> {
55        std::borrow::Cow::Borrowed("MarkdownFlavor")
56    }
57
58    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
59        markdown_flavor_schema(generator)
60    }
61}
62
63impl fmt::Display for MarkdownFlavor {
64    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
65        match self {
66            MarkdownFlavor::Standard => write!(f, "standard"),
67            MarkdownFlavor::MkDocs => write!(f, "mkdocs"),
68            MarkdownFlavor::MDX => write!(f, "mdx"),
69            MarkdownFlavor::Quarto => write!(f, "quarto"),
70            MarkdownFlavor::Obsidian => write!(f, "obsidian"),
71            MarkdownFlavor::Kramdown => write!(f, "kramdown"),
72        }
73    }
74}
75
76impl FromStr for MarkdownFlavor {
77    type Err = String;
78
79    fn from_str(s: &str) -> Result<Self, Self::Err> {
80        match s.to_lowercase().as_str() {
81            "standard" | "" | "none" => Ok(MarkdownFlavor::Standard),
82            "mkdocs" => Ok(MarkdownFlavor::MkDocs),
83            "mdx" => Ok(MarkdownFlavor::MDX),
84            "quarto" | "qmd" | "rmd" | "rmarkdown" => Ok(MarkdownFlavor::Quarto),
85            "obsidian" => Ok(MarkdownFlavor::Obsidian),
86            "kramdown" | "jekyll" => Ok(MarkdownFlavor::Kramdown),
87            // GFM and CommonMark are aliases for Standard since the base parser
88            // (pulldown-cmark) already supports GFM extensions (tables, task lists,
89            // strikethrough, autolinks, etc.) which are a superset of CommonMark
90            "gfm" | "github" | "commonmark" => Ok(MarkdownFlavor::Standard),
91            _ => Err(format!("Unknown markdown flavor: {s}")),
92        }
93    }
94}
95
96impl MarkdownFlavor {
97    /// Detect flavor from file extension
98    pub fn from_extension(ext: &str) -> Self {
99        match ext.to_lowercase().as_str() {
100            "mdx" => Self::MDX,
101            "qmd" => Self::Quarto,
102            "rmd" => Self::Quarto,
103            "kramdown" => Self::Kramdown,
104            _ => Self::Standard,
105        }
106    }
107
108    /// Detect flavor from file path
109    pub fn from_path(path: &std::path::Path) -> Self {
110        path.extension()
111            .and_then(|e| e.to_str())
112            .map_or(Self::Standard, Self::from_extension)
113    }
114
115    /// Check if this flavor supports ESM imports/exports (MDX-specific)
116    pub fn supports_esm_blocks(self) -> bool {
117        matches!(self, Self::MDX)
118    }
119
120    /// Check if this flavor supports JSX components (MDX-specific)
121    pub fn supports_jsx(self) -> bool {
122        matches!(self, Self::MDX)
123    }
124
125    /// Check if this flavor supports auto-references (MkDocs-specific)
126    pub fn supports_auto_references(self) -> bool {
127        matches!(self, Self::MkDocs)
128    }
129
130    /// Check if this flavor supports kramdown syntax (IALs, ALDs, extension blocks)
131    pub fn supports_kramdown_syntax(self) -> bool {
132        matches!(self, Self::Kramdown)
133    }
134
135    /// Check if this flavor supports attribute lists ({#id .class key="value"})
136    pub fn supports_attr_lists(self) -> bool {
137        matches!(self, Self::MkDocs | Self::Kramdown)
138    }
139
140    /// Check if this flavor requires strict (≥4-space) list continuation indent.
141    ///
142    /// Python-Markdown (used by MkDocs) requires 4-space indentation for ordered
143    /// list continuation content, regardless of marker width.
144    pub fn requires_strict_list_indent(self) -> bool {
145        matches!(self, Self::MkDocs)
146    }
147
148    /// Get a human-readable name for this flavor
149    pub fn name(self) -> &'static str {
150        match self {
151            Self::Standard => "Standard",
152            Self::MkDocs => "MkDocs",
153            Self::MDX => "MDX",
154            Self::Quarto => "Quarto",
155            Self::Obsidian => "Obsidian",
156            Self::Kramdown => "Kramdown",
157        }
158    }
159}
160
161/// Normalizes configuration keys (rule names, option names) to lowercase kebab-case.
162pub fn normalize_key(key: &str) -> String {
163    // If the key looks like a rule name (e.g., MD013), uppercase it
164    if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
165        key.to_ascii_uppercase()
166    } else {
167        key.replace('_', "-").to_ascii_lowercase()
168    }
169}
170
171/// Warns if a per-file-ignores pattern contains a comma but no braces.
172/// This is a common mistake where users expect "A.md,B.md" to match both files,
173/// but glob syntax requires "{A.md,B.md}" for brace expansion.
174pub(super) fn warn_comma_without_brace_in_pattern(pattern: &str, config_file: &str) {
175    if pattern.contains(',') && !pattern.contains('{') {
176        eprintln!("Warning: Pattern \"{pattern}\" in {config_file} contains a comma but no braces.");
177        eprintln!("  To match multiple files, use brace expansion: \"{{{pattern}}}\"");
178        eprintln!("  Or use separate entries for each file.");
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    /// Every MarkdownFlavor variant must produce a lowercase, unquoted string via Display.
187    /// This guards against new variants being added without a matching Display arm,
188    /// and against the Display impl regressing to Debug-style output (e.g. "Standard").
189    #[test]
190    fn test_display_all_variants_are_lowercase() {
191        let cases = [
192            (MarkdownFlavor::Standard, "standard"),
193            (MarkdownFlavor::MkDocs, "mkdocs"),
194            (MarkdownFlavor::MDX, "mdx"),
195            (MarkdownFlavor::Quarto, "quarto"),
196            (MarkdownFlavor::Obsidian, "obsidian"),
197            (MarkdownFlavor::Kramdown, "kramdown"),
198        ];
199        for (variant, expected) in cases {
200            let displayed = variant.to_string();
201            assert_eq!(
202                displayed, expected,
203                "MarkdownFlavor::{variant:?} Display should produce \"{expected}\", got \"{displayed}\""
204            );
205            // Must be lowercase — no uppercase letters anywhere
206            assert!(
207                displayed.chars().all(|c| !c.is_ascii_uppercase()),
208                "MarkdownFlavor::{variant:?} Display must be entirely lowercase, got \"{displayed}\""
209            );
210        }
211    }
212
213    /// Display output must round-trip through FromStr — every variant's Display string
214    /// must parse back to the same variant.
215    #[test]
216    fn test_display_round_trips_through_from_str() {
217        let variants = [
218            MarkdownFlavor::Standard,
219            MarkdownFlavor::MkDocs,
220            MarkdownFlavor::MDX,
221            MarkdownFlavor::Quarto,
222            MarkdownFlavor::Obsidian,
223            MarkdownFlavor::Kramdown,
224        ];
225        for variant in variants {
226            let displayed = variant.to_string();
227            let parsed: MarkdownFlavor = displayed
228                .parse()
229                .unwrap_or_else(|e| panic!("Display string \"{displayed}\" for {variant:?} failed to parse back: {e}"));
230            assert_eq!(
231                parsed, variant,
232                "Display(\"{displayed}\") for {variant:?} round-trips to a different variant: {parsed:?}"
233            );
234        }
235    }
236}