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