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, Deserializer, 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), except when a table exceeds
170    /// `max-width` and auto-compacts: the effective output style is then
171    /// `compact`, so the delimiter row is aligned to the header column widths.
172    ///
173    /// Mirrors markdownlint MD060's `aligned_delimiter` option; the snake_case
174    /// alias is accepted for cross-tool compatibility.
175    #[serde(default, rename = "aligned-delimiter", alias = "aligned_delimiter")]
176    pub aligned_delimiter: bool,
177
178    /// Spaces placed on each side of a cell's content, between the cell and the
179    /// dividing pipe. Configured values must be between 0 and 255.
180    ///
181    /// - Unset (default): each style keeps its own padding — one space for
182    ///   `aligned`, `aligned-no-space` and `compact`, none for `tight`.
183    /// - Set: the value applies to every style, so `pad-width = 0` gives aligned
184    ///   columns with pipes flush against the content, and `pad-width = 2`
185    ///   gives every style two spaces of padding.
186    ///
187    /// `aligned-no-space` keeps its defining trait regardless: its delimiter row
188    /// is never surrounded by padding, and instead carries the padding width in
189    /// extra dashes so the column stays the same width as the content rows.
190    ///
191    /// # Examples
192    ///
193    /// ```toml
194    /// [MD060]
195    /// style = "aligned"
196    /// pad-width = 0  # |Name |Age|
197    /// ```
198    ///
199    /// ```toml
200    /// [MD060]
201    /// style = "aligned"
202    /// pad-width = 2  # |  Name  |  Age  |
203    /// ```
204    #[serde(default, rename = "pad-width", deserialize_with = "deserialize_pad_width")]
205    pub pad_width: Option<usize>,
206}
207
208fn deserialize_pad_width<'de, D>(deserializer: D) -> Result<Option<usize>, D::Error>
209where
210    D: Deserializer<'de>,
211{
212    let value = Option::<usize>::deserialize(deserializer)?;
213    if value.is_some_and(|width| width > usize::from(u8::MAX)) {
214        return Err(serde::de::Error::custom("pad-width must be between 0 and 255"));
215    }
216    Ok(value)
217}
218
219impl Default for MD060Config {
220    fn default() -> Self {
221        Self {
222            enabled: default_enabled(),
223            style: default_style(),
224            max_width: default_max_width(),
225            column_align: ColumnAlign::Auto,
226            column_align_header: None,
227            column_align_body: None,
228            loose_last_column: false,
229            aligned_delimiter: false,
230            pad_width: None,
231        }
232    }
233}
234
235fn default_enabled() -> bool {
236    false
237}
238
239fn default_style() -> String {
240    "any".to_string()
241}
242
243fn default_max_width() -> LineLength {
244    LineLength::from_const(0) // 0 = inherit from MD013
245}
246
247fn serialize_style<S>(style: &str, serializer: S) -> Result<S::Ok, S::Error>
248where
249    S: Serializer,
250{
251    serializer.serialize_str(style)
252}
253
254fn deserialize_style<'de, D>(deserializer: D) -> Result<String, D::Error>
255where
256    D: serde::Deserializer<'de>,
257{
258    let s = String::deserialize(deserializer)?;
259    let normalized = s.trim().to_ascii_lowercase().replace('_', "-");
260
261    let valid_styles = ["aligned", "aligned-no-space", "compact", "tight", "any"];
262
263    if valid_styles.contains(&normalized.as_str()) {
264        Ok(normalized)
265    } else {
266        Err(serde::de::Error::custom(format!(
267            "Invalid table format style: {s}. Valid options: aligned, aligned-no-space, compact, tight, any"
268        )))
269    }
270}
271
272impl RuleConfig for MD060Config {
273    const RULE_NAME: &'static str = "MD060";
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279
280    #[test]
281    fn test_style_accepts_hyphen_and_underscore_variants() {
282        let kebab_case: MD060Config = toml::from_str("style = \"aligned-no-space\"").unwrap();
283        assert_eq!(kebab_case.style, "aligned-no-space");
284
285        let snake_case: MD060Config = toml::from_str("style = \"aligned_no_space\"").unwrap();
286        assert_eq!(snake_case.style, "aligned-no-space");
287    }
288
289    #[test]
290    fn test_style_normalizes_case_for_compatibility() {
291        let uppercase: MD060Config = toml::from_str("style = \"ALIGNED_NO_SPACE\"").unwrap();
292        assert_eq!(uppercase.style, "aligned-no-space");
293    }
294
295    #[test]
296    fn test_pad_width_defaults_to_unset() {
297        let cfg: MD060Config = toml::from_str("").unwrap();
298        assert_eq!(cfg.pad_width, None, "pad-width is unset by default");
299    }
300
301    #[test]
302    fn test_pad_width_kebab_case_key() {
303        let cfg: MD060Config = toml::from_str("pad-width = 2").unwrap();
304        assert_eq!(cfg.pad_width, Some(2), "pad-width counts spaces on each side");
305    }
306
307    #[test]
308    fn test_pad_width_configuration_bounds() {
309        let cfg: MD060Config = toml::from_str("pad-width = 255").unwrap();
310        assert_eq!(cfg.pad_width, Some(255));
311        for width in ["256", "9223372036854775807"] {
312            let err = toml::from_str::<MD060Config>(&format!("pad-width = {width}")).unwrap_err();
313            assert!(err.to_string().contains("pad-width must be between 0 and 255"));
314        }
315        assert!(toml::from_str::<MD060Config>("pad-width = -1").is_err());
316    }
317
318    #[test]
319    fn test_pad_width_zero_is_distinct_from_unset() {
320        let cfg: MD060Config = toml::from_str("pad-width = 0").unwrap();
321        assert_eq!(cfg.pad_width, Some(0), "pad-width = 0 is a setting, not an absent one");
322    }
323
324    #[test]
325    fn test_aligned_delimiter_default_is_false() {
326        let cfg: MD060Config = toml::from_str("").unwrap();
327        assert!(!cfg.aligned_delimiter, "aligned-delimiter defaults to false");
328    }
329
330    #[test]
331    fn test_aligned_delimiter_kebab_case_key() {
332        let cfg: MD060Config = toml::from_str("aligned-delimiter = true").unwrap();
333        assert!(cfg.aligned_delimiter, "kebab-case aligned-delimiter is accepted");
334    }
335
336    #[test]
337    fn test_aligned_delimiter_snake_case_alias_for_markdownlint_parity() {
338        // markdownlint uses `aligned_delimiter` (snake_case). rumdl accepts both for compatibility.
339        let cfg: MD060Config = toml::from_str("aligned_delimiter = true").unwrap();
340        assert!(cfg.aligned_delimiter, "snake_case aligned_delimiter alias is accepted");
341    }
342}