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