1use serde::{Deserialize, Serialize};
2use std::fmt;
3use std::str::FromStr;
4
5pub const CANONICAL_MARKDOWN_FLAVORS: &str =
9 "standard, mkdocs, mdx, pandoc, quarto, obsidian, kramdown, azure_devops, myst, hugo, mdg, gh-aw";
10
11#[derive(Debug, Clone, Copy, Default)]
18pub struct ConfigLoaded;
19
20#[derive(Debug, Clone, Copy, Default)]
23pub struct ConfigValidated;
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
27#[serde(rename_all = "lowercase")]
28pub enum MarkdownFlavor {
29 #[serde(rename = "standard", alias = "none", alias = "")]
31 #[default]
32 Standard,
33 #[serde(rename = "mkdocs")]
35 MkDocs,
36 #[serde(rename = "mdx")]
38 MDX,
39 #[serde(rename = "pandoc")]
42 Pandoc,
43 #[serde(rename = "quarto")]
45 Quarto,
46 #[serde(rename = "obsidian")]
48 Obsidian,
49 #[serde(rename = "kramdown")]
51 Kramdown,
52 #[serde(rename = "azure_devops", alias = "azure", alias = "ado")]
54 AzureDevOps,
55 #[serde(rename = "myst", alias = "mystmd")]
57 MyST,
58 #[serde(rename = "hugo", alias = "goldmark")]
60 Hugo,
61 #[serde(rename = "mdg", alias = "markdown_with_gherkin")]
63 MDG,
64 #[serde(rename = "gh-aw")]
66 GhAw,
67}
68
69fn markdown_flavor_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
71 schemars::json_schema!({
72 "description": "Markdown flavor/dialect. Accepts: standard, gfm, mkdocs, mdx, pandoc, quarto, obsidian, kramdown, azure_devops, myst, hugo, mdg, gh-aw (preview). 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.",
73 "type": "string",
74 "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", "gh-aw"]
75 })
76}
77
78impl schemars::JsonSchema for MarkdownFlavor {
79 fn schema_name() -> std::borrow::Cow<'static, str> {
80 std::borrow::Cow::Borrowed("MarkdownFlavor")
81 }
82
83 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
84 markdown_flavor_schema(generator)
85 }
86}
87
88impl fmt::Display for MarkdownFlavor {
89 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90 match self {
91 MarkdownFlavor::Standard => write!(f, "standard"),
92 MarkdownFlavor::MkDocs => write!(f, "mkdocs"),
93 MarkdownFlavor::MDX => write!(f, "mdx"),
94 MarkdownFlavor::Pandoc => write!(f, "pandoc"),
95 MarkdownFlavor::Quarto => write!(f, "quarto"),
96 MarkdownFlavor::Obsidian => write!(f, "obsidian"),
97 MarkdownFlavor::Kramdown => write!(f, "kramdown"),
98 MarkdownFlavor::AzureDevOps => write!(f, "azure_devops"),
99 MarkdownFlavor::MyST => write!(f, "myst"),
100 MarkdownFlavor::Hugo => write!(f, "hugo"),
101 MarkdownFlavor::MDG => write!(f, "mdg"),
102 MarkdownFlavor::GhAw => write!(f, "gh-aw"),
103 }
104 }
105}
106
107impl FromStr for MarkdownFlavor {
108 type Err = String;
109
110 fn from_str(s: &str) -> Result<Self, Self::Err> {
111 match s.to_lowercase().as_str() {
112 "standard" | "" | "none" => Ok(MarkdownFlavor::Standard),
113 "mkdocs" => Ok(MarkdownFlavor::MkDocs),
114 "mdx" => Ok(MarkdownFlavor::MDX),
115 "pandoc" => Ok(MarkdownFlavor::Pandoc),
116 "quarto" | "qmd" | "rmd" | "rmarkdown" => Ok(MarkdownFlavor::Quarto),
117 "obsidian" => Ok(MarkdownFlavor::Obsidian),
118 "kramdown" | "jekyll" => Ok(MarkdownFlavor::Kramdown),
119 "azure_devops" | "azure" | "ado" => Ok(MarkdownFlavor::AzureDevOps),
120 "myst" | "mystmd" => Ok(MarkdownFlavor::MyST),
121 "hugo" | "goldmark" => Ok(MarkdownFlavor::Hugo),
122 "gfm" | "github" | "commonmark" => Ok(MarkdownFlavor::Standard),
126 "mdg" | "markdown_with_gherkin" => Ok(MarkdownFlavor::MDG),
127 "gh-aw" => Ok(MarkdownFlavor::GhAw),
128 _ => Err(format!("Unknown markdown flavor: {s}")),
129 }
130 }
131}
132
133impl MarkdownFlavor {
134 pub fn from_extension(ext: &str) -> Self {
136 match ext.to_lowercase().as_str() {
137 "mdx" => Self::MDX,
138 "qmd" => Self::Quarto,
139 "rmd" => Self::Quarto,
140 "kramdown" => Self::Kramdown,
141 _ => Self::Standard,
142 }
143 }
144
145 pub fn from_path(path: &std::path::Path) -> Self {
147 if path
148 .file_name()
149 .and_then(|name| name.to_str())
150 .is_some_and(|name| name.to_ascii_lowercase().ends_with(".feature.md"))
151 {
152 return Self::MDG;
153 }
154
155 path.extension()
156 .and_then(|e| e.to_str())
157 .map_or(Self::Standard, Self::from_extension)
158 }
159
160 pub fn supports_esm_blocks(self) -> bool {
162 matches!(self, Self::MDX)
163 }
164
165 pub fn supports_jsx(self) -> bool {
167 matches!(self, Self::MDX)
168 }
169
170 pub fn supports_auto_references(self) -> bool {
172 matches!(self, Self::MkDocs)
173 }
174
175 pub fn supports_kramdown_syntax(self) -> bool {
177 matches!(self, Self::Kramdown)
178 }
179
180 pub fn supports_attr_lists(self) -> bool {
182 matches!(self, Self::MkDocs | Self::Kramdown | Self::Hugo)
183 }
184
185 pub fn requires_strict_list_indent(self) -> bool {
190 matches!(self, Self::MkDocs)
191 }
192
193 pub fn is_pandoc_compatible(self) -> bool {
197 matches!(self, Self::Pandoc | Self::Quarto)
198 }
199
200 pub fn name(self) -> &'static str {
202 match self {
203 Self::Standard => "Standard",
204 Self::MkDocs => "MkDocs",
205 Self::MDX => "MDX",
206 Self::Pandoc => "Pandoc",
207 Self::Quarto => "Quarto",
208 Self::Obsidian => "Obsidian",
209 Self::Kramdown => "Kramdown",
210 Self::AzureDevOps => "AzureDevOps",
211 Self::MyST => "MyST",
212 Self::Hugo => "Hugo",
213 Self::MDG => "Markdown with Gherkin",
214 Self::GhAw => "GitHub Agentic Workflows",
215 }
216 }
217
218 pub fn supports_colon_code_fences(self) -> bool {
220 matches!(self, Self::AzureDevOps)
221 }
222
223 pub fn supports_myst_directives(self) -> bool {
225 matches!(self, Self::MyST)
226 }
227
228 pub fn supports_myst_roles(self) -> bool {
230 matches!(self, Self::MyST)
231 }
232
233 pub fn supports_myst_comments(self) -> bool {
235 matches!(self, Self::MyST)
236 }
237}
238
239pub fn normalize_key(key: &str) -> String {
241 if key.len() == 5 && key.to_ascii_lowercase().starts_with("md") && key[2..].chars().all(|c| c.is_ascii_digit()) {
243 key.to_ascii_uppercase()
244 } else {
245 key.replace('_', "-").to_ascii_lowercase()
246 }
247}
248
249pub(super) fn warn_comma_without_brace_in_pattern(pattern: &str, config_file: super::types::ConfigRef<'_>) {
257 if pattern.contains(',') && !pattern.contains('{') {
258 if config_file.may_quote_contents() {
259 eprintln!("Warning: Pattern \"{pattern}\" in {config_file} contains a comma but no braces.");
260 eprintln!(" To match multiple files, use brace expansion: \"{{{pattern}}}\"");
261 } else {
262 eprintln!("Warning: A pattern in {config_file} contains a comma but no braces.");
263 eprintln!(" To match multiple files, wrap the pattern in braces for brace expansion.");
264 }
265 eprintln!(" Or use separate entries for each file.");
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272
273 #[test]
277 fn test_display_all_variants_are_lowercase() {
278 let cases = [
279 (MarkdownFlavor::Standard, "standard"),
280 (MarkdownFlavor::MkDocs, "mkdocs"),
281 (MarkdownFlavor::MDX, "mdx"),
282 (MarkdownFlavor::Pandoc, "pandoc"),
283 (MarkdownFlavor::Quarto, "quarto"),
284 (MarkdownFlavor::Obsidian, "obsidian"),
285 (MarkdownFlavor::Kramdown, "kramdown"),
286 (MarkdownFlavor::AzureDevOps, "azure_devops"),
287 (MarkdownFlavor::MyST, "myst"),
288 (MarkdownFlavor::Hugo, "hugo"),
289 (MarkdownFlavor::MDG, "mdg"),
290 (MarkdownFlavor::GhAw, "gh-aw"),
291 ];
292 assert_eq!(
293 cases.iter().map(|(_, name)| *name).collect::<Vec<_>>().join(", "),
294 CANONICAL_MARKDOWN_FLAVORS,
295 "user-facing canonical flavor diagnostics drifted from the enum matrix"
296 );
297 for (variant, expected) in cases {
298 let displayed = variant.to_string();
299 assert_eq!(
300 displayed, expected,
301 "MarkdownFlavor::{variant:?} Display should produce \"{expected}\", got \"{displayed}\""
302 );
303 assert!(
305 displayed.chars().all(|c| !c.is_ascii_uppercase()),
306 "MarkdownFlavor::{variant:?} Display must be entirely lowercase, got \"{displayed}\""
307 );
308 }
309 }
310
311 #[test]
314 fn test_display_round_trips_through_from_str() {
315 let variants = [
316 MarkdownFlavor::Standard,
317 MarkdownFlavor::MkDocs,
318 MarkdownFlavor::MDX,
319 MarkdownFlavor::Pandoc,
320 MarkdownFlavor::Quarto,
321 MarkdownFlavor::Obsidian,
322 MarkdownFlavor::Kramdown,
323 MarkdownFlavor::AzureDevOps,
324 MarkdownFlavor::MyST,
325 MarkdownFlavor::Hugo,
326 MarkdownFlavor::MDG,
327 MarkdownFlavor::GhAw,
328 ];
329 for variant in variants {
330 let displayed = variant.to_string();
331 let parsed: MarkdownFlavor = displayed
332 .parse()
333 .unwrap_or_else(|e| panic!("Display string \"{displayed}\" for {variant:?} failed to parse back: {e}"));
334 assert_eq!(
335 parsed, variant,
336 "Display(\"{displayed}\") for {variant:?} round-trips to a different variant: {parsed:?}"
337 );
338 }
339 }
340
341 #[test]
342 fn test_pandoc_from_str() {
343 assert_eq!("pandoc".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Pandoc);
344 assert_eq!("PANDOC".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Pandoc);
345 }
346
347 #[test]
348 fn test_pandoc_name_and_display() {
349 assert_eq!(MarkdownFlavor::Pandoc.name(), "Pandoc");
350 assert_eq!(MarkdownFlavor::Pandoc.to_string(), "pandoc");
351 }
352
353 #[test]
354 fn test_from_extension_does_not_auto_detect_pandoc() {
355 assert_eq!(MarkdownFlavor::from_extension("md"), MarkdownFlavor::Standard);
357 assert_eq!(MarkdownFlavor::from_extension("markdown"), MarkdownFlavor::Standard);
358 }
359
360 #[test]
361 fn test_from_path_does_not_auto_detect_gh_aw() {
362 use std::path::Path;
363
364 assert_eq!(
365 MarkdownFlavor::from_path(Path::new(".github/workflows/triage.md")),
366 MarkdownFlavor::Standard
367 );
368 }
369
370 #[test]
371 fn test_gh_aw_name_and_serde() {
372 assert_eq!("GH-AW".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::GhAw);
373 assert_eq!(MarkdownFlavor::GhAw.name(), "GitHub Agentic Workflows");
374 assert_eq!(
375 toml::Value::try_from(MarkdownFlavor::GhAw).unwrap().as_str(),
376 Some("gh-aw")
377 );
378 }
379
380 #[test]
381 fn test_mdg_aliases_and_name() {
382 assert_eq!("MDG".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MDG);
383 assert_eq!(
384 "markdown_with_gherkin".parse::<MarkdownFlavor>().unwrap(),
385 MarkdownFlavor::MDG
386 );
387 assert_eq!(MarkdownFlavor::MDG.name(), "Markdown with Gherkin");
388 }
389
390 #[test]
391 fn test_mdg_serde_names() {
392 assert_eq!(
393 MarkdownFlavor::deserialize(toml::Value::String("markdown_with_gherkin".to_string())).unwrap(),
394 MarkdownFlavor::MDG
395 );
396 assert_eq!(
397 toml::Value::try_from(MarkdownFlavor::MDG).unwrap().as_str(),
398 Some("mdg")
399 );
400 }
401
402 #[test]
403 fn test_from_path_detects_feature_md_compound_suffix() {
404 use std::path::Path;
405
406 assert_eq!(
407 MarkdownFlavor::from_path(Path::new("features/login.feature.md")),
408 MarkdownFlavor::MDG
409 );
410 assert_eq!(
411 MarkdownFlavor::from_path(Path::new("features/login.FEATURE.MD")),
412 MarkdownFlavor::MDG
413 );
414 assert_eq!(
415 MarkdownFlavor::from_path(Path::new("README.md")),
416 MarkdownFlavor::Standard
417 );
418 assert_eq!(
419 MarkdownFlavor::from_path(Path::new("features/login.feature.markdown")),
420 MarkdownFlavor::Standard
421 );
422 }
423
424 #[test]
425 fn test_is_pandoc_compatible() {
426 assert!(MarkdownFlavor::Pandoc.is_pandoc_compatible());
427 assert!(MarkdownFlavor::Quarto.is_pandoc_compatible());
428
429 assert!(!MarkdownFlavor::Standard.is_pandoc_compatible());
430 assert!(!MarkdownFlavor::MkDocs.is_pandoc_compatible());
431 assert!(!MarkdownFlavor::MDX.is_pandoc_compatible());
432 assert!(!MarkdownFlavor::Obsidian.is_pandoc_compatible());
433 assert!(!MarkdownFlavor::Kramdown.is_pandoc_compatible());
434 }
435
436 #[test]
437 fn test_azure_devops_from_str() {
438 assert_eq!(
439 "azure_devops".parse::<MarkdownFlavor>().unwrap(),
440 MarkdownFlavor::AzureDevOps
441 );
442 assert_eq!("azure".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::AzureDevOps);
443 assert_eq!("ado".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::AzureDevOps);
444 assert_eq!(
445 "AZURE_DEVOPS".parse::<MarkdownFlavor>().unwrap(),
446 MarkdownFlavor::AzureDevOps
447 );
448 }
449
450 #[test]
451 fn test_azure_devops_display_and_round_trip() {
452 assert_eq!(MarkdownFlavor::AzureDevOps.to_string(), "azure_devops");
453 let parsed: MarkdownFlavor = "azure_devops".parse().unwrap();
454 assert_eq!(parsed, MarkdownFlavor::AzureDevOps);
455 }
456
457 #[test]
458 fn test_supports_colon_code_fences() {
459 assert!(MarkdownFlavor::AzureDevOps.supports_colon_code_fences());
460 assert!(!MarkdownFlavor::Standard.supports_colon_code_fences());
461 assert!(!MarkdownFlavor::MkDocs.supports_colon_code_fences());
462 assert!(!MarkdownFlavor::MDX.supports_colon_code_fences());
463 assert!(!MarkdownFlavor::Pandoc.supports_colon_code_fences());
464 assert!(!MarkdownFlavor::Quarto.supports_colon_code_fences());
465 assert!(!MarkdownFlavor::Obsidian.supports_colon_code_fences());
466 assert!(!MarkdownFlavor::Kramdown.supports_colon_code_fences());
467 assert!(!MarkdownFlavor::MyST.supports_colon_code_fences());
468 }
469
470 #[test]
471 fn test_azure_devops_not_pandoc_compatible() {
472 assert!(!MarkdownFlavor::AzureDevOps.is_pandoc_compatible());
473 }
474
475 #[test]
476 fn test_display_all_variants_covers_azure_devops() {
477 let displayed = MarkdownFlavor::AzureDevOps.to_string();
478 assert!(displayed.chars().all(|c| !c.is_ascii_uppercase()));
479 }
480
481 #[test]
482 fn test_myst_from_str() {
483 assert_eq!("myst".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
484 assert_eq!("MYST".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
485 assert_eq!("mystmd".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::MyST);
486 }
487
488 #[test]
489 fn test_myst_display_and_round_trip() {
490 assert_eq!(MarkdownFlavor::MyST.to_string(), "myst");
491 let parsed: MarkdownFlavor = "myst".parse().unwrap();
492 assert_eq!(parsed, MarkdownFlavor::MyST);
493 }
494
495 #[test]
496 fn test_myst_capabilities() {
497 assert!(MarkdownFlavor::MyST.supports_myst_directives());
498 assert!(MarkdownFlavor::MyST.supports_myst_roles());
499 assert!(MarkdownFlavor::MyST.supports_myst_comments());
500 assert!(!MarkdownFlavor::MyST.is_pandoc_compatible());
501 assert!(!MarkdownFlavor::MyST.supports_colon_code_fences());
502 assert!(!MarkdownFlavor::MyST.supports_jsx());
503
504 assert!(!MarkdownFlavor::Standard.supports_myst_directives());
505 assert!(!MarkdownFlavor::Standard.supports_myst_roles());
506 assert!(!MarkdownFlavor::Standard.supports_myst_comments());
507 }
508
509 #[test]
510 fn test_myst_name() {
511 assert_eq!(MarkdownFlavor::MyST.name(), "MyST");
512 }
513
514 #[test]
515 fn test_hugo_from_str() {
516 assert_eq!("hugo".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Hugo);
517 assert_eq!("HUGO".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Hugo);
518 assert_eq!("goldmark".parse::<MarkdownFlavor>().unwrap(), MarkdownFlavor::Hugo);
519 }
520
521 #[test]
522 fn test_hugo_display_name_and_round_trip() {
523 assert_eq!(MarkdownFlavor::Hugo.to_string(), "hugo");
524 assert_eq!(MarkdownFlavor::Hugo.name(), "Hugo");
525 let parsed: MarkdownFlavor = "hugo".parse().unwrap();
526 assert_eq!(parsed, MarkdownFlavor::Hugo);
527 }
528
529 #[test]
530 fn test_hugo_supports_attr_lists() {
531 assert!(MarkdownFlavor::Hugo.supports_attr_lists());
533 assert!(MarkdownFlavor::MkDocs.supports_attr_lists());
534 assert!(MarkdownFlavor::Kramdown.supports_attr_lists());
535
536 assert!(!MarkdownFlavor::Standard.supports_attr_lists());
538 assert!(!MarkdownFlavor::Pandoc.supports_attr_lists());
539
540 assert!(!MarkdownFlavor::Hugo.is_pandoc_compatible());
542 assert!(!MarkdownFlavor::Hugo.supports_myst_directives());
543 assert!(!MarkdownFlavor::Hugo.supports_colon_code_fences());
544 }
545}