Skip to main content

rumdl_lib/rules/md036_no_emphasis_only_first/
md036_config.rs

1use crate::rule_config_serde::RuleConfig;
2use crate::types::HeadingLevel;
3use serde::{Deserialize, Serialize};
4
5/// Heading style for auto-fix conversion
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
7#[serde(rename_all = "snake_case")]
8pub enum HeadingStyle {
9    /// ATX style headings (## Heading)
10    #[default]
11    Atx,
12}
13
14/// Configuration for MD036 (Emphasis used instead of a heading)
15#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16#[serde(rename_all = "kebab-case")]
17pub struct MD036Config {
18    /// Punctuation characters that indicate emphasis is not being used as a heading.
19    /// If the emphasized text ends with one of these characters, it won't be flagged.
20    /// Default: ".,;:!?" - common trailing punctuation indicates a phrase, not a heading
21    /// Set to empty string to flag all emphasis-only lines
22    #[serde(default = "default_punctuation")]
23    pub punctuation: String,
24
25    /// Enable auto-fix to convert emphasis-as-heading to real headings.
26    /// `from_config` defaults this to true (matching the rule's advertised
27    /// FullyFixable capability), so `rumdl fmt` rewrites surviving lines —
28    /// `check()` already excludes lists, blockquotes, code blocks, headings,
29    /// TOC labels, and punctuation-terminated phrases. Set to false in TOML
30    /// for diagnostic-only behavior.
31    #[serde(default)]
32    pub fix: bool,
33
34    /// Heading style to use when auto-fixing.
35    /// Default: "atx" (## Heading)
36    #[serde(default, rename = "heading-style", alias = "heading_style")]
37    pub heading_style: HeadingStyle,
38
39    /// Heading level (1-6) to use when auto-fixing.
40    /// Default: 2 (## Heading)
41    /// Invalid values (0 or >6) produce a config validation error.
42    #[serde(default = "default_heading_level", rename = "heading-level", alias = "heading_level")]
43    pub heading_level: HeadingLevel,
44}
45
46fn default_punctuation() -> String {
47    ".,;:!?".to_string()
48}
49
50fn default_heading_level() -> HeadingLevel {
51    // Safe: 2 is always valid (1-6 range)
52    HeadingLevel::new(2).unwrap()
53}
54
55impl Default for MD036Config {
56    fn default() -> Self {
57        Self {
58            punctuation: default_punctuation(),
59            fix: false,
60            heading_style: HeadingStyle::default(),
61            heading_level: default_heading_level(),
62        }
63    }
64}
65
66impl RuleConfig for MD036Config {
67    const RULE_NAME: &'static str = "MD036";
68}
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    #[test]
75    fn test_default_values() {
76        let config = MD036Config::default();
77        assert_eq!(config.punctuation, ".,;:!?");
78        assert!(!config.fix);
79        assert_eq!(config.heading_style, HeadingStyle::Atx);
80        assert_eq!(config.heading_level.get(), 2);
81    }
82
83    #[test]
84    fn test_kebab_case_config() {
85        let toml_str = r#"
86            punctuation = ".,;:"
87            fix = true
88            heading-style = "atx"
89            heading-level = 3
90        "#;
91        let config: MD036Config = toml::from_str(toml_str).unwrap();
92        assert_eq!(config.punctuation, ".,;:");
93        assert!(config.fix);
94        assert_eq!(config.heading_style, HeadingStyle::Atx);
95        assert_eq!(config.heading_level.get(), 3);
96    }
97
98    #[test]
99    fn test_snake_case_backwards_compatibility() {
100        let toml_str = r#"
101            punctuation = "."
102            fix = true
103            heading_style = "atx"
104            heading_level = 4
105        "#;
106        let config: MD036Config = toml::from_str(toml_str).unwrap();
107        assert_eq!(config.punctuation, ".");
108        assert!(config.fix);
109        assert_eq!(config.heading_style, HeadingStyle::Atx);
110        assert_eq!(config.heading_level.get(), 4);
111    }
112
113    #[test]
114    fn test_invalid_heading_level_rejected() {
115        // Level 0 is invalid
116        let toml_str = r#"
117            heading-level = 0
118        "#;
119        let result: Result<MD036Config, _> = toml::from_str(toml_str);
120        assert!(result.is_err());
121        let err = result.unwrap_err().to_string();
122        assert!(err.contains("must be between 1 and 6"));
123
124        // Level 7 is invalid
125        let toml_str = r#"
126            heading-level = 7
127        "#;
128        let result: Result<MD036Config, _> = toml::from_str(toml_str);
129        assert!(result.is_err());
130        let err = result.unwrap_err().to_string();
131        assert!(err.contains("must be between 1 and 6"));
132    }
133
134    #[test]
135    fn test_all_valid_heading_levels() {
136        for level in 1..=6 {
137            let toml_str = format!("heading-level = {level}");
138            let config: MD036Config = toml::from_str(&toml_str).unwrap();
139            assert_eq!(config.heading_level.get(), level);
140        }
141    }
142}