Skip to main content

rumdl_lib/
markdownlint_config.rs

1//!
2//! This module handles parsing and mapping markdownlint config files (JSON/YAML) to rumdl's internal config format.
3//! It provides mapping from markdownlint rule keys to rumdl rule keys and provenance tracking for configuration values.
4
5use crate::config::{ConfigSource, SourcedConfig, SourcedValue};
6use serde::Deserialize;
7use std::collections::HashMap;
8use std::fs;
9
10/// Represents a generic markdownlint config (rule keys to values)
11#[derive(Debug, Deserialize)]
12pub struct MarkdownlintConfig(pub HashMap<String, serde_yml::Value>);
13
14/// Load a markdownlint config file (JSON or YAML) from the given path
15pub fn load_markdownlint_config(path: &str) -> Result<MarkdownlintConfig, String> {
16    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read config file {path}: {e}"))?;
17
18    if path.ends_with(".json") || path.ends_with(".jsonc") {
19        serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {e}"))
20    } else if path.ends_with(".yaml") || path.ends_with(".yml") {
21        serde_yml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {e}"))
22    } else {
23        serde_json::from_str(&content)
24            .or_else(|_| serde_yml::from_str(&content))
25            .map_err(|e| format!("Failed to parse config as JSON or YAML: {e}"))
26    }
27}
28
29/// Mapping table from markdownlint rule keys/aliases to rumdl rule keys
30/// Convert a rule name (which may be an alias like "line-length") to the canonical rule ID (like "MD013").
31/// Returns None if the rule name is not recognized.
32pub fn markdownlint_to_rumdl_rule_key(key: &str) -> Option<&'static str> {
33    // Use the shared alias resolution function from config module
34    crate::config::resolve_rule_name_alias(key)
35}
36
37fn normalize_toml_table_keys(val: toml::Value) -> toml::Value {
38    match val {
39        toml::Value::Table(table) => {
40            let mut new_table = toml::map::Map::new();
41            for (k, v) in table {
42                let norm_k = crate::config::normalize_key(&k);
43                new_table.insert(norm_k, normalize_toml_table_keys(v));
44            }
45            toml::Value::Table(new_table)
46        }
47        toml::Value::Array(arr) => toml::Value::Array(arr.into_iter().map(normalize_toml_table_keys).collect()),
48        other => other,
49    }
50}
51
52/// Map markdownlint-specific option names to rumdl option names for a given rule.
53/// This handles incompatibilities between markdownlint and rumdl config schemas.
54/// Returns a new table with mapped options, or None if the entire config should be dropped.
55fn map_markdownlint_options_to_rumdl(
56    rule_key: &str,
57    table: toml::map::Map<String, toml::Value>,
58) -> Option<toml::map::Map<String, toml::Value>> {
59    let mut mapped = toml::map::Map::new();
60
61    match rule_key {
62        "MD013" => {
63            // MD013 (line-length) has different option names in markdownlint vs rumdl
64            for (k, v) in table {
65                match k.as_str() {
66                    // Markdownlint uses separate line length limits for different content types
67                    // rumdl uses boolean flags to enable/disable checking for content types
68                    "code-block-line-length" | "code_block_line_length" => {
69                        // Ignore: rumdl doesn't support per-content-type line length limits
70                        // Instead, users should use code-blocks = false to disable entirely
71                        log::warn!(
72                            "Ignoring markdownlint option 'code_block_line_length' for MD013. Use 'code-blocks = false' in rumdl to disable line length checking in code blocks."
73                        );
74                    }
75                    "heading-line-length" | "heading_line_length" => {
76                        // Ignore: rumdl doesn't support per-content-type line length limits
77                        log::warn!(
78                            "Ignoring markdownlint option 'heading_line_length' for MD013. Use 'headings = false' in rumdl to disable line length checking in headings."
79                        );
80                    }
81                    "stern" => {
82                        // Markdownlint uses "stern", rumdl uses "strict"
83                        mapped.insert("strict".to_string(), v);
84                    }
85                    // Pass through all other options
86                    _ => {
87                        mapped.insert(k, v);
88                    }
89                }
90            }
91            Some(mapped)
92        }
93        "MD054" => {
94            // MD054 (link-image-style) has fundamentally different config models
95            // Markdownlint uses style/styles strings, rumdl uses individual boolean flags
96            for (k, v) in table {
97                match k.as_str() {
98                    "style" | "styles" => {
99                        // Ignore: rumdl uses individual boolean flags (autolink, inline, full, etc.)
100                        // Cannot automatically map string style names to boolean flags
101                        log::warn!(
102                            "Ignoring markdownlint option '{k}' for MD054. rumdl uses individual boolean flags (autolink, inline, full, collapsed, shortcut, url-inline) instead. Please configure these directly."
103                        );
104                    }
105                    // Pass through all other options (autolink, inline, full, collapsed, shortcut, url-inline)
106                    _ => {
107                        mapped.insert(k, v);
108                    }
109                }
110            }
111            Some(mapped)
112        }
113        // All other rules: pass through unchanged
114        _ => Some(table),
115    }
116}
117
118/// Map a MarkdownlintConfig to rumdl's internal Config format
119impl MarkdownlintConfig {
120    /// Map to a SourcedConfig, tracking provenance as Markdownlint for all values.
121    pub fn map_to_sourced_rumdl_config(&self, file_path: Option<&str>) -> SourcedConfig {
122        let mut sourced_config = SourcedConfig::default();
123        let file = file_path.map(|s| s.to_string());
124        for (key, value) in &self.0 {
125            let mapped = markdownlint_to_rumdl_rule_key(key);
126            if let Some(rumdl_key) = mapped {
127                let norm_rule_key = rumdl_key.to_ascii_uppercase();
128                let toml_value: Option<toml::Value> = serde_yml::from_value::<toml::Value>(value.clone()).ok();
129                let toml_value = toml_value.map(normalize_toml_table_keys);
130                let rule_config = sourced_config.rules.entry(norm_rule_key.clone()).or_default();
131                if let Some(tv) = toml_value {
132                    if let toml::Value::Table(mut table) = tv {
133                        // Apply markdownlint-to-rumdl option mapping
134                        table = match map_markdownlint_options_to_rumdl(&norm_rule_key, table) {
135                            Some(mapped) => mapped,
136                            None => continue, // Skip this rule entirely if mapping returns None
137                        };
138
139                        // Special handling for MD007: Add style = "fixed" for markdownlint compatibility
140                        if norm_rule_key == "MD007" && !table.contains_key("style") {
141                            table.insert("style".to_string(), toml::Value::String("fixed".to_string()));
142                        }
143
144                        for (k, v) in table {
145                            let norm_config_key = k; // Already normalized
146                            rule_config
147                                .values
148                                .entry(norm_config_key.clone())
149                                .and_modify(|sv| {
150                                    sv.value = v.clone();
151                                    sv.source = ConfigSource::ProjectConfig;
152                                    sv.overrides.push(crate::config::ConfigOverride {
153                                        value: v.clone(),
154                                        source: ConfigSource::ProjectConfig,
155                                        file: file.clone(),
156                                        line: None,
157                                    });
158                                })
159                                .or_insert_with(|| SourcedValue {
160                                    value: v.clone(),
161                                    source: ConfigSource::ProjectConfig,
162                                    overrides: vec![crate::config::ConfigOverride {
163                                        value: v,
164                                        source: ConfigSource::ProjectConfig,
165                                        file: file.clone(),
166                                        line: None,
167                                    }],
168                                });
169                        }
170                    } else {
171                        rule_config
172                            .values
173                            .entry("value".to_string())
174                            .and_modify(|sv| {
175                                sv.value = tv.clone();
176                                sv.source = ConfigSource::ProjectConfig;
177                                sv.overrides.push(crate::config::ConfigOverride {
178                                    value: tv.clone(),
179                                    source: ConfigSource::ProjectConfig,
180                                    file: file.clone(),
181                                    line: None,
182                                });
183                            })
184                            .or_insert_with(|| SourcedValue {
185                                value: tv.clone(),
186                                source: ConfigSource::ProjectConfig,
187                                overrides: vec![crate::config::ConfigOverride {
188                                    value: tv,
189                                    source: ConfigSource::ProjectConfig,
190                                    file: file.clone(),
191                                    line: None,
192                                }],
193                            });
194
195                        // Special handling for MD007: Add style = "fixed" for markdownlint compatibility
196                        if norm_rule_key == "MD007" && !rule_config.values.contains_key("style") {
197                            rule_config.values.insert(
198                                "style".to_string(),
199                                SourcedValue {
200                                    value: toml::Value::String("fixed".to_string()),
201                                    source: ConfigSource::ProjectConfig,
202                                    overrides: vec![crate::config::ConfigOverride {
203                                        value: toml::Value::String("fixed".to_string()),
204                                        source: ConfigSource::ProjectConfig,
205                                        file: file.clone(),
206                                        line: None,
207                                    }],
208                                },
209                            );
210                        }
211                    }
212                } else {
213                    log::error!(
214                        "Could not convert value for rule key {key:?} to rumdl's internal config format. This likely means the configuration value is invalid or not supported for this rule. Please check your markdownlint config."
215                    );
216                    std::process::exit(1);
217                }
218            }
219        }
220        if let Some(_f) = file {
221            sourced_config.loaded_files.push(_f);
222        }
223        sourced_config
224    }
225
226    /// Map to a SourcedConfigFragment, for use in config loading.
227    pub fn map_to_sourced_rumdl_config_fragment(
228        &self,
229        file_path: Option<&str>,
230    ) -> crate::config::SourcedConfigFragment {
231        let mut fragment = crate::config::SourcedConfigFragment::default();
232        let file = file_path.map(|s| s.to_string());
233
234        // Accumulate disabled and enabled rules
235        let mut disabled_rules = Vec::new();
236        let mut enabled_rules = Vec::new();
237
238        for (key, value) in &self.0 {
239            let mapped = markdownlint_to_rumdl_rule_key(key);
240            if let Some(rumdl_key) = mapped {
241                let norm_rule_key = rumdl_key.to_ascii_uppercase();
242                // Special handling for boolean values (true/false)
243                if value.is_bool() {
244                    if !value.as_bool().unwrap_or(false) {
245                        // Accumulate disabled rules
246                        disabled_rules.push(norm_rule_key.clone());
247                    } else {
248                        // Accumulate enabled rules
249                        enabled_rules.push(norm_rule_key.clone());
250                    }
251                    continue;
252                }
253                let toml_value: Option<toml::Value> = serde_yml::from_value::<toml::Value>(value.clone()).ok();
254                let toml_value = toml_value.map(normalize_toml_table_keys);
255                let rule_config = fragment.rules.entry(norm_rule_key.clone()).or_default();
256                if let Some(tv) = toml_value {
257                    // Special case: if line-length (MD013) is given a number value directly,
258                    // treat it as {"line_length": value}
259                    let tv = if norm_rule_key == "MD013" && tv.is_integer() {
260                        let mut table = toml::map::Map::new();
261                        table.insert("line-length".to_string(), tv);
262                        toml::Value::Table(table)
263                    } else {
264                        tv
265                    };
266
267                    if let toml::Value::Table(mut table) = tv {
268                        // Apply markdownlint-to-rumdl option mapping
269                        table = match map_markdownlint_options_to_rumdl(&norm_rule_key, table) {
270                            Some(mapped) => mapped,
271                            None => continue, // Skip this rule entirely if mapping returns None
272                        };
273
274                        // Special handling for MD007: Add style = "fixed" for markdownlint compatibility
275                        if norm_rule_key == "MD007" && !table.contains_key("style") {
276                            table.insert("style".to_string(), toml::Value::String("fixed".to_string()));
277                        }
278
279                        for (rk, rv) in table {
280                            let norm_rk = crate::config::normalize_key(&rk);
281                            let sv = rule_config.values.entry(norm_rk.clone()).or_insert_with(|| {
282                                crate::config::SourcedValue::new(rv.clone(), crate::config::ConfigSource::ProjectConfig)
283                            });
284                            sv.push_override(rv, crate::config::ConfigSource::ProjectConfig, file.clone(), None);
285                        }
286                    } else {
287                        rule_config
288                            .values
289                            .entry("value".to_string())
290                            .and_modify(|sv| {
291                                sv.value = tv.clone();
292                                sv.source = crate::config::ConfigSource::ProjectConfig;
293                                sv.overrides.push(crate::config::ConfigOverride {
294                                    value: tv.clone(),
295                                    source: crate::config::ConfigSource::ProjectConfig,
296                                    file: file.clone(),
297                                    line: None,
298                                });
299                            })
300                            .or_insert_with(|| crate::config::SourcedValue {
301                                value: tv.clone(),
302                                source: crate::config::ConfigSource::ProjectConfig,
303                                overrides: vec![crate::config::ConfigOverride {
304                                    value: tv,
305                                    source: crate::config::ConfigSource::ProjectConfig,
306                                    file: file.clone(),
307                                    line: None,
308                                }],
309                            });
310
311                        // Special handling for MD007: Add style = "fixed" for markdownlint compatibility
312                        if norm_rule_key == "MD007" && !rule_config.values.contains_key("style") {
313                            rule_config.values.insert(
314                                "style".to_string(),
315                                crate::config::SourcedValue {
316                                    value: toml::Value::String("fixed".to_string()),
317                                    source: crate::config::ConfigSource::ProjectConfig,
318                                    overrides: vec![crate::config::ConfigOverride {
319                                        value: toml::Value::String("fixed".to_string()),
320                                        source: crate::config::ConfigSource::ProjectConfig,
321                                        file: file.clone(),
322                                        line: None,
323                                    }],
324                                },
325                            );
326                        }
327                    }
328                }
329            }
330        }
331
332        // Set all disabled rules at once
333        if !disabled_rules.is_empty() {
334            fragment.global.disable.push_override(
335                disabled_rules,
336                crate::config::ConfigSource::ProjectConfig,
337                file.clone(),
338                None,
339            );
340        }
341
342        // Set all enabled rules at once
343        if !enabled_rules.is_empty() {
344            fragment.global.enable.push_override(
345                enabled_rules,
346                crate::config::ConfigSource::ProjectConfig,
347                file.clone(),
348                None,
349            );
350        }
351
352        if let Some(_f) = file {
353            // SourcedConfigFragment does not have loaded_files, so skip
354        }
355        fragment
356    }
357}
358
359// NOTE: 'code-block-style' (MD046) and 'code-fence-style' (MD048) are distinct and must not be merged. See markdownlint docs for details.
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364    use std::io::Write;
365    use tempfile::NamedTempFile;
366
367    #[test]
368    fn test_markdownlint_to_rumdl_rule_key() {
369        // Test direct rule names
370        assert_eq!(markdownlint_to_rumdl_rule_key("MD001"), Some("MD001"));
371        assert_eq!(markdownlint_to_rumdl_rule_key("MD058"), Some("MD058"));
372
373        // Test aliases with hyphens
374        assert_eq!(markdownlint_to_rumdl_rule_key("heading-increment"), Some("MD001"));
375        assert_eq!(markdownlint_to_rumdl_rule_key("HEADING-INCREMENT"), Some("MD001"));
376        assert_eq!(markdownlint_to_rumdl_rule_key("ul-style"), Some("MD004"));
377        assert_eq!(markdownlint_to_rumdl_rule_key("no-trailing-spaces"), Some("MD009"));
378        assert_eq!(markdownlint_to_rumdl_rule_key("line-length"), Some("MD013"));
379        assert_eq!(markdownlint_to_rumdl_rule_key("single-title"), Some("MD025"));
380        assert_eq!(markdownlint_to_rumdl_rule_key("single-h1"), Some("MD025"));
381        assert_eq!(markdownlint_to_rumdl_rule_key("no-bare-urls"), Some("MD034"));
382        assert_eq!(markdownlint_to_rumdl_rule_key("code-block-style"), Some("MD046"));
383        assert_eq!(markdownlint_to_rumdl_rule_key("code-fence-style"), Some("MD048"));
384
385        // Test aliases with underscores (should also work)
386        assert_eq!(markdownlint_to_rumdl_rule_key("heading_increment"), Some("MD001"));
387        assert_eq!(markdownlint_to_rumdl_rule_key("HEADING_INCREMENT"), Some("MD001"));
388        assert_eq!(markdownlint_to_rumdl_rule_key("ul_style"), Some("MD004"));
389        assert_eq!(markdownlint_to_rumdl_rule_key("no_trailing_spaces"), Some("MD009"));
390        assert_eq!(markdownlint_to_rumdl_rule_key("line_length"), Some("MD013"));
391        assert_eq!(markdownlint_to_rumdl_rule_key("single_title"), Some("MD025"));
392        assert_eq!(markdownlint_to_rumdl_rule_key("single_h1"), Some("MD025"));
393        assert_eq!(markdownlint_to_rumdl_rule_key("no_bare_urls"), Some("MD034"));
394        assert_eq!(markdownlint_to_rumdl_rule_key("code_block_style"), Some("MD046"));
395        assert_eq!(markdownlint_to_rumdl_rule_key("code_fence_style"), Some("MD048"));
396
397        // Test case insensitivity
398        assert_eq!(markdownlint_to_rumdl_rule_key("md001"), Some("MD001"));
399        assert_eq!(markdownlint_to_rumdl_rule_key("Md001"), Some("MD001"));
400        assert_eq!(markdownlint_to_rumdl_rule_key("Line-Length"), Some("MD013"));
401        assert_eq!(markdownlint_to_rumdl_rule_key("Line_Length"), Some("MD013"));
402
403        // Test invalid keys
404        assert_eq!(markdownlint_to_rumdl_rule_key("MD999"), None);
405        assert_eq!(markdownlint_to_rumdl_rule_key("invalid-rule"), None);
406        assert_eq!(markdownlint_to_rumdl_rule_key(""), None);
407    }
408
409    #[test]
410    fn test_normalize_toml_table_keys() {
411        use toml::map::Map;
412
413        // Test table normalization
414        let mut table = Map::new();
415        table.insert("snake_case".to_string(), toml::Value::String("value1".to_string()));
416        table.insert("kebab-case".to_string(), toml::Value::String("value2".to_string()));
417        table.insert("MD013".to_string(), toml::Value::Integer(100));
418
419        let normalized = normalize_toml_table_keys(toml::Value::Table(table));
420
421        if let toml::Value::Table(norm_table) = normalized {
422            assert!(norm_table.contains_key("snake-case"));
423            assert!(norm_table.contains_key("kebab-case"));
424            assert!(norm_table.contains_key("MD013"));
425            assert_eq!(
426                norm_table.get("snake-case").unwrap(),
427                &toml::Value::String("value1".to_string())
428            );
429            assert_eq!(
430                norm_table.get("kebab-case").unwrap(),
431                &toml::Value::String("value2".to_string())
432            );
433        } else {
434            panic!("Expected normalized value to be a table");
435        }
436
437        // Test array normalization
438        let array = toml::Value::Array(vec![toml::Value::String("test".to_string()), toml::Value::Integer(42)]);
439        let normalized_array = normalize_toml_table_keys(array.clone());
440        assert_eq!(normalized_array, array);
441
442        // Test simple value passthrough
443        let simple = toml::Value::String("simple".to_string());
444        assert_eq!(normalize_toml_table_keys(simple.clone()), simple);
445    }
446
447    #[test]
448    fn test_load_markdownlint_config_json() {
449        let mut temp_file = NamedTempFile::new().unwrap();
450        writeln!(
451            temp_file,
452            r#"{{
453            "MD013": {{ "line_length": 100 }},
454            "MD025": true,
455            "MD026": false,
456            "heading-style": {{ "style": "atx" }}
457        }}"#
458        )
459        .unwrap();
460
461        let config = load_markdownlint_config(temp_file.path().to_str().unwrap()).unwrap();
462        assert_eq!(config.0.len(), 4);
463        assert!(config.0.contains_key("MD013"));
464        assert!(config.0.contains_key("MD025"));
465        assert!(config.0.contains_key("MD026"));
466        assert!(config.0.contains_key("heading-style"));
467    }
468
469    #[test]
470    fn test_load_markdownlint_config_yaml() {
471        let mut temp_file = NamedTempFile::new().unwrap();
472        writeln!(
473            temp_file,
474            r#"MD013:
475  line_length: 120
476MD025: true
477MD026: false
478ul-style:
479  style: dash"#
480        )
481        .unwrap();
482
483        let path = temp_file.path().with_extension("yaml");
484        std::fs::rename(temp_file.path(), &path).unwrap();
485
486        let config = load_markdownlint_config(path.to_str().unwrap()).unwrap();
487        assert_eq!(config.0.len(), 4);
488        assert!(config.0.contains_key("MD013"));
489        assert!(config.0.contains_key("ul-style"));
490    }
491
492    #[test]
493    fn test_load_markdownlint_config_invalid() {
494        let mut temp_file = NamedTempFile::new().unwrap();
495        writeln!(temp_file, "invalid json/yaml content {{").unwrap();
496
497        let result = load_markdownlint_config(temp_file.path().to_str().unwrap());
498        assert!(result.is_err());
499    }
500
501    #[test]
502    fn test_load_markdownlint_config_nonexistent() {
503        let result = load_markdownlint_config("/nonexistent/file.json");
504        assert!(result.is_err());
505        assert!(result.unwrap_err().contains("Failed to read config file"));
506    }
507
508    #[test]
509    fn test_map_to_sourced_rumdl_config() {
510        let mut config_map = HashMap::new();
511        config_map.insert(
512            "MD013".to_string(),
513            serde_yml::Value::Mapping({
514                let mut map = serde_yml::Mapping::new();
515                map.insert(
516                    serde_yml::Value::String("line_length".to_string()),
517                    serde_yml::Value::Number(serde_yml::Number::from(100)),
518                );
519                map
520            }),
521        );
522        config_map.insert("MD025".to_string(), serde_yml::Value::Bool(true));
523        config_map.insert("MD026".to_string(), serde_yml::Value::Bool(false));
524
525        let mdl_config = MarkdownlintConfig(config_map);
526        let sourced_config = mdl_config.map_to_sourced_rumdl_config(Some("test.json"));
527
528        // Check MD013 mapping
529        assert!(sourced_config.rules.contains_key("MD013"));
530        let md013_config = &sourced_config.rules["MD013"];
531        assert!(md013_config.values.contains_key("line-length"));
532        assert_eq!(md013_config.values["line-length"].value, toml::Value::Integer(100));
533        assert_eq!(md013_config.values["line-length"].source, ConfigSource::ProjectConfig);
534
535        // Check that loaded_files is tracked
536        assert_eq!(sourced_config.loaded_files.len(), 1);
537        assert_eq!(sourced_config.loaded_files[0], "test.json");
538    }
539
540    #[test]
541    fn test_map_to_sourced_rumdl_config_fragment() {
542        let mut config_map = HashMap::new();
543
544        // Test line-length alias for MD013 with numeric value
545        config_map.insert(
546            "line-length".to_string(),
547            serde_yml::Value::Number(serde_yml::Number::from(120)),
548        );
549
550        // Test rule disable (false)
551        config_map.insert("MD025".to_string(), serde_yml::Value::Bool(false));
552
553        // Test rule enable (true)
554        config_map.insert("MD026".to_string(), serde_yml::Value::Bool(true));
555
556        // Test another rule with configuration
557        config_map.insert(
558            "MD003".to_string(),
559            serde_yml::Value::Mapping({
560                let mut map = serde_yml::Mapping::new();
561                map.insert(
562                    serde_yml::Value::String("style".to_string()),
563                    serde_yml::Value::String("atx".to_string()),
564                );
565                map
566            }),
567        );
568
569        let mdl_config = MarkdownlintConfig(config_map);
570        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.yaml"));
571
572        // Check that line-length (MD013) was properly configured
573        assert!(fragment.rules.contains_key("MD013"));
574        let md013_config = &fragment.rules["MD013"];
575        assert!(md013_config.values.contains_key("line-length"));
576        assert_eq!(md013_config.values["line-length"].value, toml::Value::Integer(120));
577
578        // Check disabled rule
579        assert!(fragment.global.disable.value.contains(&"MD025".to_string()));
580
581        // Check enabled rule
582        assert!(fragment.global.enable.value.contains(&"MD026".to_string()));
583
584        // Check rule configuration
585        assert!(fragment.rules.contains_key("MD003"));
586        let md003_config = &fragment.rules["MD003"];
587        assert!(md003_config.values.contains_key("style"));
588    }
589
590    #[test]
591    fn test_edge_cases() {
592        let mut config_map = HashMap::new();
593
594        // Test empty config
595        let empty_config = MarkdownlintConfig(HashMap::new());
596        let sourced = empty_config.map_to_sourced_rumdl_config(None);
597        assert!(sourced.rules.is_empty());
598
599        // Test unknown rule (should be ignored)
600        config_map.insert("unknown-rule".to_string(), serde_yml::Value::Bool(true));
601        config_map.insert("MD999".to_string(), serde_yml::Value::Bool(true));
602
603        let config = MarkdownlintConfig(config_map);
604        let sourced = config.map_to_sourced_rumdl_config(None);
605        assert!(sourced.rules.is_empty()); // Unknown rules should be ignored
606    }
607
608    #[test]
609    fn test_complex_rule_configurations() {
610        let mut config_map = HashMap::new();
611
612        // Test MD044 with array configuration
613        config_map.insert(
614            "MD044".to_string(),
615            serde_yml::Value::Mapping({
616                let mut map = serde_yml::Mapping::new();
617                map.insert(
618                    serde_yml::Value::String("names".to_string()),
619                    serde_yml::Value::Sequence(vec![
620                        serde_yml::Value::String("JavaScript".to_string()),
621                        serde_yml::Value::String("GitHub".to_string()),
622                    ]),
623                );
624                map
625            }),
626        );
627
628        // Test nested configuration
629        config_map.insert(
630            "MD003".to_string(),
631            serde_yml::Value::Mapping({
632                let mut map = serde_yml::Mapping::new();
633                map.insert(
634                    serde_yml::Value::String("style".to_string()),
635                    serde_yml::Value::String("atx".to_string()),
636                );
637                map
638            }),
639        );
640
641        let mdl_config = MarkdownlintConfig(config_map);
642        let sourced = mdl_config.map_to_sourced_rumdl_config(None);
643
644        // Verify MD044 configuration
645        assert!(sourced.rules.contains_key("MD044"));
646        let md044_config = &sourced.rules["MD044"];
647        assert!(md044_config.values.contains_key("names"));
648
649        // Verify MD003 configuration
650        assert!(sourced.rules.contains_key("MD003"));
651        let md003_config = &sourced.rules["MD003"];
652        assert!(md003_config.values.contains_key("style"));
653        assert_eq!(
654            md003_config.values["style"].value,
655            toml::Value::String("atx".to_string())
656        );
657    }
658
659    #[test]
660    fn test_value_types() {
661        let mut config_map = HashMap::new();
662
663        // Test different value types
664        config_map.insert(
665            "MD007".to_string(),
666            serde_yml::Value::Number(serde_yml::Number::from(4)),
667        ); // Simple number
668        config_map.insert(
669            "MD009".to_string(),
670            serde_yml::Value::Mapping({
671                let mut map = serde_yml::Mapping::new();
672                map.insert(
673                    serde_yml::Value::String("br_spaces".to_string()),
674                    serde_yml::Value::Number(serde_yml::Number::from(2)),
675                );
676                map.insert(
677                    serde_yml::Value::String("strict".to_string()),
678                    serde_yml::Value::Bool(true),
679                );
680                map
681            }),
682        );
683
684        let mdl_config = MarkdownlintConfig(config_map);
685        let sourced = mdl_config.map_to_sourced_rumdl_config(None);
686
687        // Check simple number value
688        assert!(sourced.rules.contains_key("MD007"));
689        assert!(sourced.rules["MD007"].values.contains_key("value"));
690
691        // Check complex configuration
692        assert!(sourced.rules.contains_key("MD009"));
693        let md009_config = &sourced.rules["MD009"];
694        assert!(md009_config.values.contains_key("br-spaces"));
695        assert!(md009_config.values.contains_key("strict"));
696    }
697
698    #[test]
699    fn test_all_rule_aliases() {
700        // Test that all documented aliases map correctly
701        let aliases = vec![
702            ("heading-increment", "MD001"),
703            ("heading-style", "MD003"),
704            ("ul-style", "MD004"),
705            ("list-indent", "MD005"),
706            ("ul-indent", "MD007"),
707            ("no-trailing-spaces", "MD009"),
708            ("no-hard-tabs", "MD010"),
709            ("no-reversed-links", "MD011"),
710            ("no-multiple-blanks", "MD012"),
711            ("line-length", "MD013"),
712            ("commands-show-output", "MD014"),
713            // MD015-017 don't exist in markdownlint
714            ("no-missing-space-atx", "MD018"),
715            ("no-multiple-space-atx", "MD019"),
716            ("no-missing-space-closed-atx", "MD020"),
717            ("no-multiple-space-closed-atx", "MD021"),
718            ("blanks-around-headings", "MD022"),
719            ("heading-start-left", "MD023"),
720            ("no-duplicate-heading", "MD024"),
721            ("single-title", "MD025"),
722            ("single-h1", "MD025"),
723            ("no-trailing-punctuation", "MD026"),
724            ("no-multiple-space-blockquote", "MD027"),
725            ("no-blanks-blockquote", "MD028"),
726            ("ol-prefix", "MD029"),
727            ("list-marker-space", "MD030"),
728            ("blanks-around-fences", "MD031"),
729            ("blanks-around-lists", "MD032"),
730            ("no-inline-html", "MD033"),
731            ("no-bare-urls", "MD034"),
732            ("hr-style", "MD035"),
733            ("no-emphasis-as-heading", "MD036"),
734            ("no-space-in-emphasis", "MD037"),
735            ("no-space-in-code", "MD038"),
736            ("no-space-in-links", "MD039"),
737            ("fenced-code-language", "MD040"),
738            ("first-line-heading", "MD041"),
739            ("first-line-h1", "MD041"),
740            ("no-empty-links", "MD042"),
741            ("required-headings", "MD043"),
742            ("proper-names", "MD044"),
743            ("no-alt-text", "MD045"),
744            ("code-block-style", "MD046"),
745            ("single-trailing-newline", "MD047"),
746            ("code-fence-style", "MD048"),
747            ("emphasis-style", "MD049"),
748            ("strong-style", "MD050"),
749            ("link-fragments", "MD051"),
750            ("reference-links-images", "MD052"),
751            ("link-image-reference-definitions", "MD053"),
752            ("link-image-style", "MD054"),
753            ("table-pipe-style", "MD055"),
754            ("table-column-count", "MD056"),
755            ("existing-relative-links", "MD057"),
756            ("blanks-around-tables", "MD058"),
757            ("descriptive-link-text", "MD059"),
758            ("table-cell-alignment", "MD060"),
759            ("table-format", "MD060"),
760            ("forbidden-terms", "MD061"),
761            ("nested-code-fence", "MD070"),
762            ("blank-line-after-frontmatter", "MD071"),
763            ("frontmatter-key-sort", "MD072"),
764        ];
765
766        for (alias, expected) in aliases {
767            assert_eq!(
768                markdownlint_to_rumdl_rule_key(alias),
769                Some(expected),
770                "Alias {alias} should map to {expected}"
771            );
772        }
773    }
774}