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    /// Default: false - auto-fix is opt-in to avoid unexpected document changes.
27    /// When true, detected emphasis-only lines are converted to ATX headings.
28    #[serde(default)]
29    pub fix: bool,
30
31    /// Heading style to use when auto-fixing.
32    /// Default: "atx" (## Heading)
33    #[serde(default, rename = "heading-style", alias = "heading_style")]
34    pub heading_style: HeadingStyle,
35
36    /// Heading level (1-6) to use when auto-fixing.
37    /// Default: 2 (## Heading)
38    /// Invalid values (0 or >6) produce a config validation error.
39    #[serde(default = "default_heading_level", rename = "heading-level", alias = "heading_level")]
40    pub heading_level: HeadingLevel,
41}
42
43fn default_punctuation() -> String {
44    ".,;:!?".to_string()
45}
46
47fn default_heading_level() -> HeadingLevel {
48    // Safe: 2 is always valid (1-6 range)
49    HeadingLevel::new(2).unwrap()
50}
51
52impl Default for MD036Config {
53    fn default() -> Self {
54        Self {
55            punctuation: default_punctuation(),
56            fix: false,
57            heading_style: HeadingStyle::default(),
58            heading_level: default_heading_level(),
59        }
60    }
61}
62
63impl RuleConfig for MD036Config {
64    const RULE_NAME: &'static str = "MD036";
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70
71    #[test]
72    fn test_default_values() {
73        let config = MD036Config::default();
74        assert_eq!(config.punctuation, ".,;:!?");
75        assert!(!config.fix);
76        assert_eq!(config.heading_style, HeadingStyle::Atx);
77        assert_eq!(config.heading_level.get(), 2);
78    }
79
80    #[test]
81    fn test_kebab_case_config() {
82        let toml_str = r#"
83            punctuation = ".,;:"
84            fix = true
85            heading-style = "atx"
86            heading-level = 3
87        "#;
88        let config: MD036Config = toml::from_str(toml_str).unwrap();
89        assert_eq!(config.punctuation, ".,;:");
90        assert!(config.fix);
91        assert_eq!(config.heading_style, HeadingStyle::Atx);
92        assert_eq!(config.heading_level.get(), 3);
93    }
94
95    #[test]
96    fn test_snake_case_backwards_compatibility() {
97        let toml_str = r#"
98            punctuation = "."
99            fix = true
100            heading_style = "atx"
101            heading_level = 4
102        "#;
103        let config: MD036Config = toml::from_str(toml_str).unwrap();
104        assert_eq!(config.punctuation, ".");
105        assert!(config.fix);
106        assert_eq!(config.heading_style, HeadingStyle::Atx);
107        assert_eq!(config.heading_level.get(), 4);
108    }
109
110    #[test]
111    fn test_invalid_heading_level_rejected() {
112        // Level 0 is invalid
113        let toml_str = r#"
114            heading-level = 0
115        "#;
116        let result: Result<MD036Config, _> = toml::from_str(toml_str);
117        assert!(result.is_err());
118        let err = result.unwrap_err().to_string();
119        assert!(err.contains("must be between 1 and 6"));
120
121        // Level 7 is invalid
122        let toml_str = r#"
123            heading-level = 7
124        "#;
125        let result: Result<MD036Config, _> = toml::from_str(toml_str);
126        assert!(result.is_err());
127        let err = result.unwrap_err().to_string();
128        assert!(err.contains("must be between 1 and 6"));
129    }
130
131    #[test]
132    fn test_all_valid_heading_levels() {
133        for level in 1..=6 {
134            let toml_str = format!("heading-level = {level}");
135            let config: MD036Config = toml::from_str(&toml_str).unwrap();
136            assert_eq!(config.heading_level.get(), level);
137        }
138    }
139}