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), 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
179impl Default for MD060Config {
180 fn default() -> Self {
181 Self {
182 enabled: default_enabled(),
183 style: default_style(),
184 max_width: default_max_width(),
185 column_align: ColumnAlign::Auto,
186 column_align_header: None,
187 column_align_body: None,
188 loose_last_column: false,
189 aligned_delimiter: false,
190 }
191 }
192}
193
194fn default_enabled() -> bool {
195 false
196}
197
198fn default_style() -> String {
199 "any".to_string()
200}
201
202fn default_max_width() -> LineLength {
203 LineLength::from_const(0) // 0 = inherit from MD013
204}
205
206fn serialize_style<S>(style: &str, serializer: S) -> Result<S::Ok, S::Error>
207where
208 S: Serializer,
209{
210 serializer.serialize_str(style)
211}
212
213fn deserialize_style<'de, D>(deserializer: D) -> Result<String, D::Error>
214where
215 D: serde::Deserializer<'de>,
216{
217 let s = String::deserialize(deserializer)?;
218 let normalized = s.trim().to_ascii_lowercase().replace('_', "-");
219
220 let valid_styles = ["aligned", "aligned-no-space", "compact", "tight", "any"];
221
222 if valid_styles.contains(&normalized.as_str()) {
223 Ok(normalized)
224 } else {
225 Err(serde::de::Error::custom(format!(
226 "Invalid table format style: {s}. Valid options: aligned, aligned-no-space, compact, tight, any"
227 )))
228 }
229}
230
231impl RuleConfig for MD060Config {
232 const RULE_NAME: &'static str = "MD060";
233}
234
235#[cfg(test)]
236mod tests {
237 use super::*;
238
239 #[test]
240 fn test_style_accepts_hyphen_and_underscore_variants() {
241 let kebab_case: MD060Config = toml::from_str("style = \"aligned-no-space\"").unwrap();
242 assert_eq!(kebab_case.style, "aligned-no-space");
243
244 let snake_case: MD060Config = toml::from_str("style = \"aligned_no_space\"").unwrap();
245 assert_eq!(snake_case.style, "aligned-no-space");
246 }
247
248 #[test]
249 fn test_style_normalizes_case_for_compatibility() {
250 let uppercase: MD060Config = toml::from_str("style = \"ALIGNED_NO_SPACE\"").unwrap();
251 assert_eq!(uppercase.style, "aligned-no-space");
252 }
253
254 #[test]
255 fn test_aligned_delimiter_default_is_false() {
256 let cfg: MD060Config = toml::from_str("").unwrap();
257 assert!(!cfg.aligned_delimiter, "aligned-delimiter defaults to false");
258 }
259
260 #[test]
261 fn test_aligned_delimiter_kebab_case_key() {
262 let cfg: MD060Config = toml::from_str("aligned-delimiter = true").unwrap();
263 assert!(cfg.aligned_delimiter, "kebab-case aligned-delimiter is accepted");
264 }
265
266 #[test]
267 fn test_aligned_delimiter_snake_case_alias_for_markdownlint_parity() {
268 // markdownlint uses `aligned_delimiter` (snake_case). rumdl accepts both for compatibility.
269 let cfg: MD060Config = toml::from_str("aligned_delimiter = true").unwrap();
270 assert!(cfg.aligned_delimiter, "snake_case aligned_delimiter alias is accepted");
271 }
272}