rumdl_lib/rules/md060_table_format/
md060_config.rs1use crate::rule_config_serde::RuleConfig;
2use crate::types::LineLength;
3use serde::ser::Serializer;
4use serde::{Deserialize, Deserializer, Serialize};
5#[derive(Debug, Clone, Copy, PartialEq, Default)]
7pub enum ColumnAlign {
8 #[default]
10 Auto,
11 Left,
13 Center,
15 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 #[serde(default = "default_max_width", rename = "max-width")]
89 pub max_width: LineLength,
90
91 #[serde(default, rename = "column-align")]
108 pub column_align: ColumnAlign,
109
110 #[serde(default, rename = "column-align-header")]
124 pub column_align_header: Option<ColumnAlign>,
125
126 #[serde(default, rename = "column-align-body")]
140 pub column_align_body: Option<ColumnAlign>,
141
142 #[serde(default, rename = "loose-last-column")]
159 pub loose_last_column: bool,
160
161 #[serde(default, rename = "aligned-delimiter", alias = "aligned_delimiter")]
176 pub aligned_delimiter: bool,
177
178 #[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) }
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 let cfg: MD060Config = toml::from_str("aligned_delimiter = true").unwrap();
340 assert!(cfg.aligned_delimiter, "snake_case aligned_delimiter alias is accepted");
341 }
342}