Skip to main content

rumdl_lib/rules/md060_table_format/
md060_config.rs

1use crate::rule_config_serde::RuleConfig;
2use crate::types::LineLength;
3use serde::ser::Serializer;
4use serde::{Deserialize, Serialize};
5/// Controls how cell text is aligned within padded columns.
6#[derive(Debug, Clone, Copy, PartialEq, Default)]
7pub enum ColumnAlign {
8    /// Use alignment indicators from delimiter row (`:---`, `:---:`, `---:`)
9    #[default]
10    Auto,
11    /// Force all columns to left-align text
12    Left,
13    /// Force all columns to center text
14    Center,
15    /// Force all columns to right-align text
16    Right,
17}
18
19impl Serialize for ColumnAlign {
20    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
21    where
22        S: Serializer,
23    {
24        match self {
25            ColumnAlign::Auto => serializer.serialize_str("auto"),
26            ColumnAlign::Left => serializer.serialize_str("left"),
27            ColumnAlign::Center => serializer.serialize_str("center"),
28            ColumnAlign::Right => serializer.serialize_str("right"),
29        }
30    }
31}
32
33impl<'de> Deserialize<'de> for ColumnAlign {
34    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
35    where
36        D: serde::Deserializer<'de>,
37    {
38        let s = String::deserialize(deserializer)?;
39        match s.to_lowercase().as_str() {
40            "auto" => Ok(ColumnAlign::Auto),
41            "left" => Ok(ColumnAlign::Left),
42            "center" => Ok(ColumnAlign::Center),
43            "right" => Ok(ColumnAlign::Right),
44            _ => Err(serde::de::Error::custom(format!(
45                "Invalid column-align value: {s}. Valid options: auto, left, center, right"
46            ))),
47        }
48    }
49}
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
52pub struct MD060Config {
53    #[serde(default = "default_enabled")]
54    pub enabled: bool,
55
56    #[serde(
57        default = "default_style",
58        serialize_with = "serialize_style",
59        deserialize_with = "deserialize_style"
60    )]
61    pub style: String,
62
63    /// Maximum table width before auto-switching to compact mode.
64    ///
65    /// - `0` (default): Inherit from MD013's `line-length` setting
66    /// - Non-zero: Explicit max width threshold
67    ///
68    /// When a table's aligned width would exceed this limit, MD060 automatically
69    /// uses compact formatting instead (minimal padding) to prevent excessively
70    /// long lines. This matches the behavior of Prettier's table formatting.
71    ///
72    /// # Examples
73    ///
74    /// ```toml
75    /// [MD013]
76    /// line-length = 100
77    ///
78    /// [MD060]
79    /// style = "aligned"
80    /// max-width = 0  # Uses MD013's line-length (100)
81    /// ```
82    ///
83    /// ```toml
84    /// [MD060]
85    /// style = "aligned"
86    /// max-width = 120  # Explicit threshold, independent of MD013
87    /// ```
88    #[serde(default = "default_max_width", rename = "max-width")]
89    pub max_width: LineLength,
90
91    /// Controls how cell text is aligned within the padded column width.
92    ///
93    /// - `auto` (default): Use alignment indicators from delimiter row (`:---`, `:---:`, `---:`)
94    /// - `left`: Force all columns to left-align text
95    /// - `center`: Force all columns to center text
96    /// - `right`: Force all columns to right-align text
97    ///
98    /// Only applies when `style = "aligned"` or `style = "aligned-no-space"`.
99    ///
100    /// # Examples
101    ///
102    /// ```toml
103    /// [MD060]
104    /// style = "aligned"
105    /// column-align = "center"  # Center all cell text
106    /// ```
107    #[serde(default, rename = "column-align")]
108    pub column_align: ColumnAlign,
109
110    /// Controls alignment specifically for the header row.
111    ///
112    /// When set, overrides `column-align` for the header row only.
113    /// If not set, falls back to `column-align`.
114    ///
115    /// # Examples
116    ///
117    /// ```toml
118    /// [MD060]
119    /// style = "aligned"
120    /// column-align-header = "center"  # Center header text
121    /// column-align-body = "left"      # Left-align body text
122    /// ```
123    #[serde(default, rename = "column-align-header")]
124    pub column_align_header: Option<ColumnAlign>,
125
126    /// Controls alignment specifically for body rows (non-header, non-delimiter).
127    ///
128    /// When set, overrides `column-align` for body rows only.
129    /// If not set, falls back to `column-align`.
130    ///
131    /// # Examples
132    ///
133    /// ```toml
134    /// [MD060]
135    /// style = "aligned"
136    /// column-align-header = "center"  # Center header text
137    /// column-align-body = "left"      # Left-align body text
138    /// ```
139    #[serde(default, rename = "column-align-body")]
140    pub column_align_body: Option<ColumnAlign>,
141
142    /// Controls whether the last column in body rows is loosely formatted.
143    ///
144    /// - `false` (default): All columns padded to equal width across all rows.
145    /// - `true`: The last column width is capped at the header text width.
146    ///   Body cells shorter than the header are padded to the header width.
147    ///   Body cells longer than the header extend beyond without padding.
148    ///
149    /// Only applies when `style = "aligned"` or `style = "aligned-no-space"`.
150    ///
151    /// # Examples
152    ///
153    /// ```toml
154    /// [MD060]
155    /// style = "aligned"
156    /// loose-last-column = true
157    /// ```
158    #[serde(default, rename = "loose-last-column")]
159    pub loose_last_column: bool,
160
161    /// Pads the delimiter row's dashes to match header column widths under
162    /// `compact` and `tight` styles.
163    ///
164    /// - `false` (default): delimiter cells use the minimum dash count.
165    /// - `true`: delimiter pipe positions align with header pipe positions;
166    ///   body rows remain compact/tight and are not padded.
167    ///
168    /// No effect under `aligned` / `aligned-no-space` (those styles already
169    /// align the delimiter row by construction).
170    ///
171    /// Mirrors markdownlint MD060's `aligned_delimiter` option; the snake_case
172    /// alias is accepted for cross-tool compatibility.
173    #[serde(default, rename = "aligned-delimiter", alias = "aligned_delimiter")]
174    pub aligned_delimiter: bool,
175}
176
177impl Default for MD060Config {
178    fn default() -> Self {
179        Self {
180            enabled: default_enabled(),
181            style: default_style(),
182            max_width: default_max_width(),
183            column_align: ColumnAlign::Auto,
184            column_align_header: None,
185            column_align_body: None,
186            loose_last_column: false,
187            aligned_delimiter: false,
188        }
189    }
190}
191
192fn default_enabled() -> bool {
193    false
194}
195
196fn default_style() -> String {
197    "any".to_string()
198}
199
200fn default_max_width() -> LineLength {
201    LineLength::from_const(0) // 0 = inherit from MD013
202}
203
204fn serialize_style<S>(style: &str, serializer: S) -> Result<S::Ok, S::Error>
205where
206    S: Serializer,
207{
208    serializer.serialize_str(style)
209}
210
211fn deserialize_style<'de, D>(deserializer: D) -> Result<String, D::Error>
212where
213    D: serde::Deserializer<'de>,
214{
215    let s = String::deserialize(deserializer)?;
216    let normalized = s.trim().to_ascii_lowercase().replace('_', "-");
217
218    let valid_styles = ["aligned", "aligned-no-space", "compact", "tight", "any"];
219
220    if valid_styles.contains(&normalized.as_str()) {
221        Ok(normalized)
222    } else {
223        Err(serde::de::Error::custom(format!(
224            "Invalid table format style: {s}. Valid options: aligned, aligned-no-space, compact, tight, any"
225        )))
226    }
227}
228
229impl RuleConfig for MD060Config {
230    const RULE_NAME: &'static str = "MD060";
231}
232
233#[cfg(test)]
234mod tests {
235    use super::*;
236
237    #[test]
238    fn test_style_accepts_hyphen_and_underscore_variants() {
239        let kebab_case: MD060Config = toml::from_str("style = \"aligned-no-space\"").unwrap();
240        assert_eq!(kebab_case.style, "aligned-no-space");
241
242        let snake_case: MD060Config = toml::from_str("style = \"aligned_no_space\"").unwrap();
243        assert_eq!(snake_case.style, "aligned-no-space");
244    }
245
246    #[test]
247    fn test_style_normalizes_case_for_compatibility() {
248        let uppercase: MD060Config = toml::from_str("style = \"ALIGNED_NO_SPACE\"").unwrap();
249        assert_eq!(uppercase.style, "aligned-no-space");
250    }
251
252    #[test]
253    fn test_aligned_delimiter_default_is_false() {
254        let cfg: MD060Config = toml::from_str("").unwrap();
255        assert!(!cfg.aligned_delimiter, "aligned-delimiter defaults to false");
256    }
257
258    #[test]
259    fn test_aligned_delimiter_kebab_case_key() {
260        let cfg: MD060Config = toml::from_str("aligned-delimiter = true").unwrap();
261        assert!(cfg.aligned_delimiter, "kebab-case aligned-delimiter is accepted");
262    }
263
264    #[test]
265    fn test_aligned_delimiter_snake_case_alias_for_markdownlint_parity() {
266        // markdownlint uses `aligned_delimiter` (snake_case). rumdl accepts both for compatibility.
267        let cfg: MD060Config = toml::from_str("aligned_delimiter = true").unwrap();
268        assert!(cfg.aligned_delimiter, "snake_case aligned_delimiter alias is accepted");
269    }
270}