1use serde::{Deserialize, Serialize};
2use std::fmt;
3use std::str::FromStr;
4
5#[derive(Debug, Clone, Copy, Default)]
12pub struct ConfigLoaded;
13
14#[derive(Debug, Clone, Copy, Default)]
17pub struct ConfigValidated;
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
21#[serde(rename_all = "lowercase")]
22pub enum MarkdownFlavor {
23 #[serde(rename = "standard", alias = "none", alias = "")]
25 #[default]
26 Standard,
27 #[serde(rename = "mkdocs")]
29 MkDocs,
30 #[serde(rename = "mdx")]
32 MDX,
33 #[serde(rename = "pandoc")]
36 Pandoc,
37 #[serde(rename = "quarto")]
39 Quarto,
40 #[serde(rename = "obsidian")]
42 Obsidian,
43 #[serde(rename = "kramdown")]
45 Kramdown,
46 #[serde(rename = "azure_devops", alias = "azure", alias = "ado")]
48 AzureDevOps,
49}
50
51fn 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" | "github" | "commonmark" => Ok(MarkdownFlavor::Standard),
102 _ => Err(format!("Unknown markdown flavor: {s}")),
103 }
104 }
105}
106
107impl MarkdownFlavor {
108 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 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 pub fn supports_esm_blocks(self) -> bool {
128 matches!(self, Self::MDX)
129 }
130
131 pub fn supports_jsx(self) -> bool {
133 matches!(self, Self::MDX)
134 }
135
136 pub fn supports_auto_references(self) -> bool {
138 matches!(self, Self::MkDocs)
139 }
140
141 pub fn supports_kramdown_syntax(self) -> bool {
143 matches!(self, Self::Kramdown)
144 }
145
146 pub fn supports_attr_lists(self) -> bool {
148 matches!(self, Self::MkDocs | Self::Kramdown)
149 }
150
151 pub fn requires_strict_list_indent(self) -> bool {
156 matches!(self, Self::MkDocs)
157 }
158
159 pub fn is_pandoc_compatible(self) -> bool {
163 matches!(self, Self::Pandoc | Self::Quarto)
164 }
165
166 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 pub fn supports_colon_code_fences(self) -> bool {
182 matches!(self, Self::AzureDevOps)
183 }
184}
185
186pub fn normalize_key(key: &str) -> String {
188 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
196pub(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 #[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 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 #[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 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}