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_yaml::Value>);
13
14fn strip_jsonc_comments(content: &str) -> String {
15    let mut result = String::with_capacity(content.len());
16    let mut chars = content.chars().peekable();
17    let mut in_string = false;
18    let mut escape = false;
19    let mut line_comment = false;
20    let mut block_comment = false;
21
22    while let Some(ch) = chars.next() {
23        if line_comment {
24            if ch == '\n' {
25                line_comment = false;
26                result.push('\n');
27            }
28            continue;
29        }
30
31        if block_comment {
32            if ch == '*' && matches!(chars.peek(), Some('/')) {
33                chars.next();
34                block_comment = false;
35            } else if ch == '\n' {
36                result.push('\n');
37            }
38            continue;
39        }
40
41        if in_string {
42            result.push(ch);
43            if escape {
44                escape = false;
45            } else if ch == '\\' {
46                escape = true;
47            } else if ch == '"' {
48                in_string = false;
49            }
50            continue;
51        }
52
53        if ch == '"' {
54            in_string = true;
55            result.push(ch);
56            continue;
57        }
58
59        if ch == '/' {
60            match chars.peek() {
61                Some('/') => {
62                    chars.next();
63                    line_comment = true;
64                    continue;
65                }
66                Some('*') => {
67                    chars.next();
68                    block_comment = true;
69                    continue;
70                }
71                _ => {}
72            }
73        }
74
75        result.push(ch);
76    }
77
78    result
79}
80
81/// Load a markdownlint config file (JSON or YAML) from the given path.
82/// Supports both flat markdownlint format and markdownlint-cli2 format
83/// where rules are nested under a top-level `config:` key.
84pub fn load_markdownlint_config(path: &str) -> Result<MarkdownlintConfig, String> {
85    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read config file {path}: {e}"))?;
86
87    let config: MarkdownlintConfig = if path.ends_with(".json") || path.ends_with(".jsonc") {
88        let json_content = if path.ends_with(".jsonc") {
89            strip_jsonc_comments(&content)
90        } else {
91            content.clone()
92        };
93        serde_json::from_str(&json_content).map_err(|e| format!("Failed to parse JSON: {e}"))?
94    } else if path.ends_with(".yaml") || path.ends_with(".yml") {
95        serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {e}"))?
96    } else {
97        let json_candidate = strip_jsonc_comments(&content);
98        serde_json::from_str(&json_candidate)
99            .or_else(|_| serde_yaml::from_str(&content))
100            .map_err(|e| format!("Failed to parse config as JSON or YAML: {e}"))?
101    };
102
103    Ok(unwrap_cli2_config(config))
104}
105
106/// If the parsed config contains a top-level `config` key whose value is a mapping,
107/// extract that mapping as the rule configuration. This supports the markdownlint-cli2
108/// format where rules are nested under `config:`.
109fn unwrap_cli2_config(config: MarkdownlintConfig) -> MarkdownlintConfig {
110    if let Some(mapping) = config.0.get("config").and_then(|v| v.as_mapping()) {
111        let inner_map: HashMap<String, serde_yaml::Value> = mapping
112            .iter()
113            .filter_map(|(k, v)| k.as_str().map(|s| (s.to_string(), v.clone())))
114            .collect();
115        return MarkdownlintConfig(inner_map);
116    }
117    config
118}
119
120/// Mapping table from markdownlint rule keys/aliases to rumdl rule keys
121/// Convert a rule name (which may be an alias like "line-length") to the canonical rule ID (like "MD013").
122/// Returns None if the rule name is not recognized.
123pub fn markdownlint_to_rumdl_rule_key(key: &str) -> Option<&'static str> {
124    // Use the shared alias resolution function from config module
125    crate::config::resolve_rule_name_alias(key)
126}
127
128fn normalize_toml_table_keys(val: toml::Value) -> toml::Value {
129    match val {
130        toml::Value::Table(table) => {
131            let mut new_table = toml::map::Map::new();
132            for (k, v) in table {
133                let norm_k = crate::config::normalize_key(&k);
134                new_table.insert(norm_k, normalize_toml_table_keys(v));
135            }
136            toml::Value::Table(new_table)
137        }
138        toml::Value::Array(arr) => toml::Value::Array(arr.into_iter().map(normalize_toml_table_keys).collect()),
139        other => other,
140    }
141}
142
143/// Map markdownlint-specific option names to rumdl option names for a given rule.
144/// This handles incompatibilities between markdownlint and rumdl config schemas.
145/// Returns a new table with mapped options.
146fn map_markdownlint_options_to_rumdl(
147    rule_key: &str,
148    table: toml::map::Map<String, toml::Value>,
149) -> toml::map::Map<String, toml::Value> {
150    let mut mapped = toml::map::Map::new();
151
152    match rule_key {
153        "MD013" => {
154            // MD013 (line-length) has different option names in markdownlint vs rumdl
155            for (k, v) in table {
156                match k.as_str() {
157                    // Markdownlint uses separate line length limits for different content types
158                    // rumdl uses boolean flags to enable/disable checking for content types
159                    "code-block-line-length" | "code_block_line_length" => {
160                        // Ignore: rumdl doesn't support per-content-type line length limits
161                        // Instead, users should use code-blocks = false to disable entirely
162                        log::warn!(
163                            "Ignoring markdownlint option 'code_block_line_length' for MD013. Use 'code-blocks = false' in rumdl to disable line length checking in code blocks."
164                        );
165                    }
166                    "heading-line-length" | "heading_line_length" => {
167                        // Ignore: rumdl doesn't support per-content-type line length limits
168                        log::warn!(
169                            "Ignoring markdownlint option 'heading_line_length' for MD013. Use 'headings = false' in rumdl to disable line length checking in headings."
170                        );
171                    }
172                    "stern" => {
173                        // Markdownlint uses "stern", rumdl uses "strict"
174                        mapped.insert("strict".to_string(), v);
175                    }
176                    // Pass through all other options
177                    _ => {
178                        mapped.insert(k, v);
179                    }
180                }
181            }
182            mapped
183        }
184        "MD054" => {
185            // MD054 (link-image-style) has fundamentally different config models
186            // Markdownlint uses style/styles strings, rumdl uses individual boolean flags
187            for (k, v) in table {
188                match k.as_str() {
189                    "style" | "styles" => {
190                        // Ignore: rumdl uses individual boolean flags (autolink, inline, full, etc.)
191                        // Cannot automatically map string style names to boolean flags
192                        log::warn!(
193                            "Ignoring markdownlint option '{k}' for MD054. rumdl uses individual boolean flags (autolink, inline, full, collapsed, shortcut, url-inline) instead. Please configure these directly."
194                        );
195                    }
196                    // Pass through all other options (autolink, inline, full, collapsed, shortcut, url-inline)
197                    _ => {
198                        mapped.insert(k, v);
199                    }
200                }
201            }
202            mapped
203        }
204        // All other rules: pass through unchanged
205        _ => table,
206    }
207}
208
209/// Map a MarkdownlintConfig to rumdl's internal Config format
210impl MarkdownlintConfig {
211    /// Map to a SourcedConfig, tracking provenance as Markdownlint for all values.
212    pub fn map_to_sourced_rumdl_config(&self, file_path: Option<&str>) -> SourcedConfig {
213        let mut sourced_config = SourcedConfig::default();
214        let file = file_path.map(std::string::ToString::to_string);
215
216        // Extract the `default` key
217        let default_enabled = self
218            .0
219            .get("default")
220            .and_then(serde_yaml::Value::as_bool)
221            .unwrap_or(true);
222
223        let mut disabled_rules = Vec::new();
224        let mut enabled_rules = Vec::new();
225
226        for (key, value) in &self.0 {
227            // Skip the `default` key — it's not a rule
228            if key == "default" {
229                continue;
230            }
231
232            let mapped = markdownlint_to_rumdl_rule_key(key);
233            if let Some(rumdl_key) = mapped {
234                let norm_rule_key = rumdl_key.to_ascii_uppercase();
235
236                // Handle boolean values according to `default` semantics
237                if value.is_bool() {
238                    let is_enabled = value.as_bool().unwrap_or(false);
239                    if default_enabled {
240                        if !is_enabled {
241                            disabled_rules.push(norm_rule_key.clone());
242                        }
243                    } else if is_enabled {
244                        enabled_rules.push(norm_rule_key.clone());
245                    }
246                    continue;
247                }
248
249                let toml_value: Option<toml::Value> = serde_yaml::from_value::<toml::Value>(value.clone()).ok();
250                let toml_value = toml_value.map(normalize_toml_table_keys);
251                let rule_config = sourced_config.rules.entry(norm_rule_key.clone()).or_default();
252                if let Some(tv) = toml_value {
253                    if let toml::Value::Table(mut table) = tv {
254                        // Apply markdownlint-to-rumdl option mapping
255                        table = map_markdownlint_options_to_rumdl(&norm_rule_key, table);
256
257                        // Special handling for MD007: Add style = "fixed" for markdownlint compatibility
258                        if norm_rule_key == "MD007" && !table.contains_key("style") {
259                            table.insert("style".to_string(), toml::Value::String("fixed".to_string()));
260                        }
261
262                        for (k, v) in table {
263                            let norm_config_key = k; // Already normalized
264                            rule_config
265                                .values
266                                .entry(norm_config_key.clone())
267                                .and_modify(|sv| {
268                                    sv.value = v.clone();
269                                    sv.source = ConfigSource::ProjectConfig;
270                                    sv.overrides.push(crate::config::ConfigOverride {
271                                        value: v.clone(),
272                                        source: ConfigSource::ProjectConfig,
273                                        file: file.clone(),
274                                        line: None,
275                                    });
276                                })
277                                .or_insert_with(|| SourcedValue {
278                                    value: v.clone(),
279                                    source: ConfigSource::ProjectConfig,
280                                    overrides: vec![crate::config::ConfigOverride {
281                                        value: v,
282                                        source: ConfigSource::ProjectConfig,
283                                        file: file.clone(),
284                                        line: None,
285                                    }],
286                                });
287                        }
288                    } else {
289                        rule_config
290                            .values
291                            .entry("value".to_string())
292                            .and_modify(|sv| {
293                                sv.value = tv.clone();
294                                sv.source = ConfigSource::ProjectConfig;
295                                sv.overrides.push(crate::config::ConfigOverride {
296                                    value: tv.clone(),
297                                    source: ConfigSource::ProjectConfig,
298                                    file: file.clone(),
299                                    line: None,
300                                });
301                            })
302                            .or_insert_with(|| SourcedValue {
303                                value: tv.clone(),
304                                source: ConfigSource::ProjectConfig,
305                                overrides: vec![crate::config::ConfigOverride {
306                                    value: tv,
307                                    source: ConfigSource::ProjectConfig,
308                                    file: file.clone(),
309                                    line: None,
310                                }],
311                            });
312
313                        // Special handling for MD007: Add style = "fixed" for markdownlint compatibility
314                        if norm_rule_key == "MD007" && !rule_config.values.contains_key("style") {
315                            rule_config.values.insert(
316                                "style".to_string(),
317                                SourcedValue {
318                                    value: toml::Value::String("fixed".to_string()),
319                                    source: ConfigSource::ProjectConfig,
320                                    overrides: vec![crate::config::ConfigOverride {
321                                        value: toml::Value::String("fixed".to_string()),
322                                        source: ConfigSource::ProjectConfig,
323                                        file: file.clone(),
324                                        line: None,
325                                    }],
326                                },
327                            );
328                        }
329                    }
330                    // When default: false, rules with object configs are explicitly enabled
331                    if !default_enabled {
332                        enabled_rules.push(norm_rule_key.clone());
333                    }
334                } else {
335                    log::error!(
336                        "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."
337                    );
338                    std::process::exit(1);
339                }
340            }
341        }
342
343        // Apply enable/disable lists
344        if !disabled_rules.is_empty() {
345            sourced_config.global.disable = SourcedValue::new(disabled_rules, ConfigSource::ProjectConfig);
346        }
347        if !enabled_rules.is_empty() || !default_enabled {
348            sourced_config.global.enable = SourcedValue::new(enabled_rules, ConfigSource::ProjectConfig);
349        }
350
351        if let Some(f) = file {
352            sourced_config.loaded_files.push(f);
353        }
354        sourced_config
355    }
356
357    /// Map to a SourcedConfigFragment, for use in config loading.
358    pub fn map_to_sourced_rumdl_config_fragment(
359        &self,
360        file_path: Option<&str>,
361    ) -> crate::config::SourcedConfigFragment {
362        let mut fragment = crate::config::SourcedConfigFragment::default();
363        let file = file_path.map(std::string::ToString::to_string);
364
365        // Extract the `default` key: controls whether rules are enabled by default.
366        // When true (or absent), all rules are enabled unless explicitly disabled.
367        // When false, only rules explicitly set to true or configured with an object are enabled.
368        let default_enabled = self
369            .0
370            .get("default")
371            .and_then(serde_yaml::Value::as_bool)
372            .unwrap_or(true);
373
374        // Accumulate disabled and enabled rules
375        let mut disabled_rules = Vec::new();
376        let mut enabled_rules = Vec::new();
377
378        for (key, value) in &self.0 {
379            // Skip the `default` key — it's not a rule
380            if key == "default" {
381                continue;
382            }
383
384            let mapped = markdownlint_to_rumdl_rule_key(key);
385            if let Some(rumdl_key) = mapped {
386                let norm_rule_key = rumdl_key.to_ascii_uppercase();
387
388                // Preserve the original key as the display name for import output.
389                // If the user wrote "line-length", output [line-length] not [MD013].
390                let display_name = if key.to_ascii_uppercase() == norm_rule_key {
391                    norm_rule_key.clone()
392                } else {
393                    key.to_lowercase().replace('_', "-")
394                };
395                fragment
396                    .rule_display_names
397                    .insert(norm_rule_key.clone(), display_name.clone());
398
399                // Special handling for boolean values (true/false)
400                if value.is_bool() {
401                    let enabled = value.as_bool().unwrap_or(false);
402                    if default_enabled {
403                        // default: true — all rules on by default
404                        // true → no-op (already enabled), false → disable
405                        if !enabled {
406                            disabled_rules.push(display_name);
407                        }
408                    } else {
409                        // default: false — all rules off by default
410                        // true → enable, false → no-op (already disabled)
411                        if enabled {
412                            enabled_rules.push(display_name);
413                        }
414                    }
415                    continue;
416                }
417                let toml_value: Option<toml::Value> = serde_yaml::from_value::<toml::Value>(value.clone()).ok();
418                let toml_value = toml_value.map(normalize_toml_table_keys);
419                let rule_config = fragment.rules.entry(norm_rule_key.clone()).or_default();
420                if let Some(tv) = toml_value {
421                    // Special case: if line-length (MD013) is given a number value directly,
422                    // treat it as {"line_length": value}
423                    let tv = if norm_rule_key == "MD013" && tv.is_integer() {
424                        let mut table = toml::map::Map::new();
425                        table.insert("line-length".to_string(), tv);
426                        toml::Value::Table(table)
427                    } else {
428                        tv
429                    };
430
431                    if let toml::Value::Table(mut table) = tv {
432                        // Apply markdownlint-to-rumdl option mapping
433                        table = map_markdownlint_options_to_rumdl(&norm_rule_key, table);
434
435                        // Special handling for MD007: Add style = "fixed" for markdownlint compatibility
436                        if norm_rule_key == "MD007" && !table.contains_key("style") {
437                            table.insert("style".to_string(), toml::Value::String("fixed".to_string()));
438                        }
439
440                        for (rk, rv) in table {
441                            let norm_rk = crate::config::normalize_key(&rk);
442                            let sv = rule_config.values.entry(norm_rk.clone()).or_insert_with(|| {
443                                crate::config::SourcedValue::new(rv.clone(), crate::config::ConfigSource::ProjectConfig)
444                            });
445                            sv.push_override(rv, crate::config::ConfigSource::ProjectConfig, file.clone(), None);
446                        }
447                    } else {
448                        rule_config
449                            .values
450                            .entry("value".to_string())
451                            .and_modify(|sv| {
452                                sv.value = tv.clone();
453                                sv.source = crate::config::ConfigSource::ProjectConfig;
454                                sv.overrides.push(crate::config::ConfigOverride {
455                                    value: tv.clone(),
456                                    source: crate::config::ConfigSource::ProjectConfig,
457                                    file: file.clone(),
458                                    line: None,
459                                });
460                            })
461                            .or_insert_with(|| crate::config::SourcedValue {
462                                value: tv.clone(),
463                                source: crate::config::ConfigSource::ProjectConfig,
464                                overrides: vec![crate::config::ConfigOverride {
465                                    value: tv,
466                                    source: crate::config::ConfigSource::ProjectConfig,
467                                    file: file.clone(),
468                                    line: None,
469                                }],
470                            });
471
472                        // Special handling for MD007: Add style = "fixed" for markdownlint compatibility
473                        if norm_rule_key == "MD007" && !rule_config.values.contains_key("style") {
474                            rule_config.values.insert(
475                                "style".to_string(),
476                                crate::config::SourcedValue {
477                                    value: toml::Value::String("fixed".to_string()),
478                                    source: crate::config::ConfigSource::ProjectConfig,
479                                    overrides: vec![crate::config::ConfigOverride {
480                                        value: toml::Value::String("fixed".to_string()),
481                                        source: crate::config::ConfigSource::ProjectConfig,
482                                        file: file.clone(),
483                                        line: None,
484                                    }],
485                                },
486                            );
487                        }
488                    }
489
490                    // When default: false, rules with object configs are explicitly enabled
491                    if !default_enabled {
492                        enabled_rules.push(display_name.clone());
493                    }
494                }
495            }
496        }
497
498        // Set all disabled rules at once
499        if !disabled_rules.is_empty() {
500            fragment.global.disable.push_override(
501                disabled_rules,
502                crate::config::ConfigSource::ProjectConfig,
503                file.clone(),
504                None,
505            );
506        }
507
508        // Set all enabled rules at once.
509        // When default: false, always push the enable override (even if empty)
510        // so the source changes from Default to ProjectConfig, signaling that
511        // the enable list is authoritative.
512        if !enabled_rules.is_empty() || !default_enabled {
513            fragment.global.enable.push_override(
514                enabled_rules,
515                crate::config::ConfigSource::ProjectConfig,
516                file.clone(),
517                None,
518            );
519        }
520
521        if let Some(_f) = file {
522            // SourcedConfigFragment does not have loaded_files, so skip
523        }
524        fragment
525    }
526}
527
528// NOTE: 'code-block-style' (MD046) and 'code-fence-style' (MD048) are distinct and must not be merged. See markdownlint docs for details.
529
530#[cfg(test)]
531mod tests {
532    use super::*;
533    use std::io::Write;
534    use tempfile::NamedTempFile;
535
536    // ---- strip_jsonc_comments unit tests ----
537
538    #[test]
539    fn strip_jsonc_line_comment_removed() {
540        let input = r#"{ "key": 1 } // trailing comment"#;
541        assert_eq!(strip_jsonc_comments(input), r#"{ "key": 1 } "#);
542    }
543
544    #[test]
545    fn strip_jsonc_block_comment_removed() {
546        let input = r#"{ /* comment */ "key": 1 }"#;
547        assert_eq!(strip_jsonc_comments(input), r#"{  "key": 1 }"#);
548    }
549
550    #[test]
551    fn strip_jsonc_preserves_slash_slash_in_string() {
552        // `//` inside a string literal must not be treated as a comment
553        let input = r#"{ "url": "https://example.com" }"#;
554        assert_eq!(strip_jsonc_comments(input), input);
555    }
556
557    #[test]
558    fn strip_jsonc_preserves_block_comment_markers_in_string() {
559        // `/*` and `*/` inside a string literal must not start/end a block comment
560        let input = r#"{ "regex": "/* not a comment */" }"#;
561        assert_eq!(strip_jsonc_comments(input), input);
562    }
563
564    #[test]
565    fn strip_jsonc_slash_slash_inside_block_comment_is_ignored() {
566        // `//` appearing inside a block comment must not end the block comment prematurely
567        let input = "{ /* // still in block */ \"k\": 1 }";
568        assert_eq!(strip_jsonc_comments(input), "{  \"k\": 1 }");
569    }
570
571    #[test]
572    fn strip_jsonc_block_comment_newlines_preserved() {
573        // Newlines inside block comments are kept so line numbers remain intact
574        let input = "{\n/* line1\nline2 */\n\"k\": 1\n}";
575        let result = strip_jsonc_comments(input);
576        assert_eq!(result.lines().count(), input.lines().count());
577    }
578
579    #[test]
580    fn strip_jsonc_unterminated_block_comment_drops_to_eof() {
581        // Unterminated `/* ...` silently drops everything from the opener to EOF.
582        // This produces invalid JSON, which the caller will detect and report.
583        let input = r#"{ "k": 1 /* unclosed"#;
584        let result = strip_jsonc_comments(input);
585        assert!(
586            !result.contains("unclosed"),
587            "trailing content after /* should be dropped"
588        );
589        assert!(
590            result.starts_with("{ \"k\": 1 "),
591            "content before /* should be preserved"
592        );
593    }
594
595    #[test]
596    fn strip_jsonc_escaped_quote_in_string() {
597        // Escaped `\"` inside a string must not end the string prematurely
598        let input = r#"{ "msg": "say \"hi\" // still string" }"#;
599        assert_eq!(strip_jsonc_comments(input), input);
600    }
601
602    // ---- markdownlint_to_rumdl_rule_key tests ----
603
604    #[test]
605    fn test_markdownlint_to_rumdl_rule_key() {
606        // Test direct rule names
607        assert_eq!(markdownlint_to_rumdl_rule_key("MD001"), Some("MD001"));
608        assert_eq!(markdownlint_to_rumdl_rule_key("MD058"), Some("MD058"));
609
610        // Test aliases with hyphens
611        assert_eq!(markdownlint_to_rumdl_rule_key("heading-increment"), Some("MD001"));
612        assert_eq!(markdownlint_to_rumdl_rule_key("HEADING-INCREMENT"), Some("MD001"));
613        assert_eq!(markdownlint_to_rumdl_rule_key("ul-style"), Some("MD004"));
614        assert_eq!(markdownlint_to_rumdl_rule_key("no-trailing-spaces"), Some("MD009"));
615        assert_eq!(markdownlint_to_rumdl_rule_key("line-length"), Some("MD013"));
616        assert_eq!(markdownlint_to_rumdl_rule_key("single-title"), Some("MD025"));
617        assert_eq!(markdownlint_to_rumdl_rule_key("single-h1"), Some("MD025"));
618        assert_eq!(markdownlint_to_rumdl_rule_key("no-bare-urls"), Some("MD034"));
619        assert_eq!(markdownlint_to_rumdl_rule_key("code-block-style"), Some("MD046"));
620        assert_eq!(markdownlint_to_rumdl_rule_key("code-fence-style"), Some("MD048"));
621
622        // Test aliases with underscores (should also work)
623        assert_eq!(markdownlint_to_rumdl_rule_key("heading_increment"), Some("MD001"));
624        assert_eq!(markdownlint_to_rumdl_rule_key("HEADING_INCREMENT"), Some("MD001"));
625        assert_eq!(markdownlint_to_rumdl_rule_key("ul_style"), Some("MD004"));
626        assert_eq!(markdownlint_to_rumdl_rule_key("no_trailing_spaces"), Some("MD009"));
627        assert_eq!(markdownlint_to_rumdl_rule_key("line_length"), Some("MD013"));
628        assert_eq!(markdownlint_to_rumdl_rule_key("single_title"), Some("MD025"));
629        assert_eq!(markdownlint_to_rumdl_rule_key("single_h1"), Some("MD025"));
630        assert_eq!(markdownlint_to_rumdl_rule_key("no_bare_urls"), Some("MD034"));
631        assert_eq!(markdownlint_to_rumdl_rule_key("code_block_style"), Some("MD046"));
632        assert_eq!(markdownlint_to_rumdl_rule_key("code_fence_style"), Some("MD048"));
633
634        // Test case insensitivity
635        assert_eq!(markdownlint_to_rumdl_rule_key("md001"), Some("MD001"));
636        assert_eq!(markdownlint_to_rumdl_rule_key("Md001"), Some("MD001"));
637        assert_eq!(markdownlint_to_rumdl_rule_key("Line-Length"), Some("MD013"));
638        assert_eq!(markdownlint_to_rumdl_rule_key("Line_Length"), Some("MD013"));
639
640        // Test invalid keys
641        assert_eq!(markdownlint_to_rumdl_rule_key("MD999"), None);
642        assert_eq!(markdownlint_to_rumdl_rule_key("invalid-rule"), None);
643        assert_eq!(markdownlint_to_rumdl_rule_key(""), None);
644    }
645
646    #[test]
647    fn test_normalize_toml_table_keys() {
648        use toml::map::Map;
649
650        // Test table normalization
651        let mut table = Map::new();
652        table.insert("snake_case".to_string(), toml::Value::String("value1".to_string()));
653        table.insert("kebab-case".to_string(), toml::Value::String("value2".to_string()));
654        table.insert("MD013".to_string(), toml::Value::Integer(100));
655
656        let normalized = normalize_toml_table_keys(toml::Value::Table(table));
657
658        if let toml::Value::Table(norm_table) = normalized {
659            assert!(norm_table.contains_key("snake-case"));
660            assert!(norm_table.contains_key("kebab-case"));
661            assert!(norm_table.contains_key("MD013"));
662            assert_eq!(
663                norm_table.get("snake-case").unwrap(),
664                &toml::Value::String("value1".to_string())
665            );
666            assert_eq!(
667                norm_table.get("kebab-case").unwrap(),
668                &toml::Value::String("value2".to_string())
669            );
670        } else {
671            panic!("Expected normalized value to be a table");
672        }
673
674        // Test array normalization
675        let array = toml::Value::Array(vec![toml::Value::String("test".to_string()), toml::Value::Integer(42)]);
676        let normalized_array = normalize_toml_table_keys(array.clone());
677        assert_eq!(normalized_array, array);
678
679        // Test simple value passthrough
680        let simple = toml::Value::String("simple".to_string());
681        assert_eq!(normalize_toml_table_keys(simple.clone()), simple);
682    }
683
684    #[test]
685    fn test_load_markdownlint_config_json() {
686        let mut temp_file = NamedTempFile::new().unwrap();
687        writeln!(
688            temp_file,
689            r#"{{
690            "MD013": {{ "line_length": 100 }},
691            "MD025": true,
692            "MD026": false,
693            "heading-style": {{ "style": "atx" }}
694        }}"#
695        )
696        .unwrap();
697
698        let config = load_markdownlint_config(temp_file.path().to_str().unwrap()).unwrap();
699        assert_eq!(config.0.len(), 4);
700        assert!(config.0.contains_key("MD013"));
701        assert!(config.0.contains_key("MD025"));
702        assert!(config.0.contains_key("MD026"));
703        assert!(config.0.contains_key("heading-style"));
704    }
705
706    #[test]
707    fn test_load_markdownlint_config_yaml() {
708        let mut temp_file = NamedTempFile::new().unwrap();
709        writeln!(
710            temp_file,
711            r#"MD013:
712  line_length: 120
713MD025: true
714MD026: false
715ul-style:
716  style: dash"#
717        )
718        .unwrap();
719
720        let path = temp_file.path().with_extension("yaml");
721        std::fs::rename(temp_file.path(), &path).unwrap();
722
723        let config = load_markdownlint_config(path.to_str().unwrap()).unwrap();
724        assert_eq!(config.0.len(), 4);
725        assert!(config.0.contains_key("MD013"));
726        assert!(config.0.contains_key("ul-style"));
727    }
728
729    #[test]
730    fn test_load_markdownlint_config_invalid() {
731        let mut temp_file = NamedTempFile::new().unwrap();
732        writeln!(temp_file, "invalid json/yaml content {{").unwrap();
733
734        let result = load_markdownlint_config(temp_file.path().to_str().unwrap());
735        assert!(result.is_err());
736    }
737
738    #[test]
739    fn test_load_markdownlint_config_nonexistent() {
740        let result = load_markdownlint_config("/nonexistent/file.json");
741        assert!(result.is_err());
742        assert!(result.unwrap_err().contains("Failed to read config file"));
743    }
744
745    #[test]
746    fn test_map_to_sourced_rumdl_config() {
747        let mut config_map = HashMap::new();
748        config_map.insert(
749            "MD013".to_string(),
750            serde_yaml::Value::Mapping({
751                let mut map = serde_yaml::Mapping::new();
752                map.insert(
753                    serde_yaml::Value::String("line_length".to_string()),
754                    serde_yaml::Value::Number(serde_yaml::Number::from(100)),
755                );
756                map
757            }),
758        );
759        config_map.insert("MD025".to_string(), serde_yaml::Value::Bool(true));
760        config_map.insert("MD026".to_string(), serde_yaml::Value::Bool(false));
761
762        let mdl_config = MarkdownlintConfig(config_map);
763        let sourced_config = mdl_config.map_to_sourced_rumdl_config(Some("test.json"));
764
765        // Check MD013 mapping
766        assert!(sourced_config.rules.contains_key("MD013"));
767        let md013_config = &sourced_config.rules["MD013"];
768        assert!(md013_config.values.contains_key("line-length"));
769        assert_eq!(md013_config.values["line-length"].value, toml::Value::Integer(100));
770        assert_eq!(md013_config.values["line-length"].source, ConfigSource::ProjectConfig);
771
772        // Check that loaded_files is tracked
773        assert_eq!(sourced_config.loaded_files.len(), 1);
774        assert_eq!(sourced_config.loaded_files[0], "test.json");
775    }
776
777    #[test]
778    fn test_map_to_sourced_rumdl_config_fragment() {
779        let mut config_map = HashMap::new();
780
781        // Test line-length alias for MD013 with numeric value
782        config_map.insert(
783            "line-length".to_string(),
784            serde_yaml::Value::Number(serde_yaml::Number::from(120)),
785        );
786
787        // Test rule disable (false)
788        config_map.insert("MD025".to_string(), serde_yaml::Value::Bool(false));
789
790        // Test rule enable (true)
791        config_map.insert("MD026".to_string(), serde_yaml::Value::Bool(true));
792
793        // Test another rule with configuration
794        config_map.insert(
795            "MD003".to_string(),
796            serde_yaml::Value::Mapping({
797                let mut map = serde_yaml::Mapping::new();
798                map.insert(
799                    serde_yaml::Value::String("style".to_string()),
800                    serde_yaml::Value::String("atx".to_string()),
801                );
802                map
803            }),
804        );
805
806        let mdl_config = MarkdownlintConfig(config_map);
807        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.yaml"));
808
809        // Check that line-length (MD013) was properly configured
810        assert!(fragment.rules.contains_key("MD013"));
811        let md013_config = &fragment.rules["MD013"];
812        assert!(md013_config.values.contains_key("line-length"));
813        assert_eq!(md013_config.values["line-length"].value, toml::Value::Integer(120));
814
815        // Check disabled rule
816        assert!(fragment.global.disable.value.contains(&"MD025".to_string()));
817
818        // When default is absent (= true), boolean true is no-op — no enable list
819        assert!(
820            !fragment.global.enable.value.contains(&"MD026".to_string()),
821            "Boolean true should be no-op when default is absent (treated as true)"
822        );
823        assert!(fragment.global.enable.value.is_empty());
824
825        // Check rule configuration
826        assert!(fragment.rules.contains_key("MD003"));
827        let md003_config = &fragment.rules["MD003"];
828        assert!(md003_config.values.contains_key("style"));
829    }
830
831    #[test]
832    fn test_edge_cases() {
833        let mut config_map = HashMap::new();
834
835        // Test empty config
836        let empty_config = MarkdownlintConfig(HashMap::new());
837        let sourced = empty_config.map_to_sourced_rumdl_config(None);
838        assert!(sourced.rules.is_empty());
839
840        // Test unknown rule (should be ignored)
841        config_map.insert("unknown-rule".to_string(), serde_yaml::Value::Bool(true));
842        config_map.insert("MD999".to_string(), serde_yaml::Value::Bool(true));
843
844        let config = MarkdownlintConfig(config_map);
845        let sourced = config.map_to_sourced_rumdl_config(None);
846        assert!(sourced.rules.is_empty()); // Unknown rules should be ignored
847    }
848
849    #[test]
850    fn test_complex_rule_configurations() {
851        let mut config_map = HashMap::new();
852
853        // Test MD044 with array configuration
854        config_map.insert(
855            "MD044".to_string(),
856            serde_yaml::Value::Mapping({
857                let mut map = serde_yaml::Mapping::new();
858                map.insert(
859                    serde_yaml::Value::String("names".to_string()),
860                    serde_yaml::Value::Sequence(vec![
861                        serde_yaml::Value::String("JavaScript".to_string()),
862                        serde_yaml::Value::String("GitHub".to_string()),
863                    ]),
864                );
865                map
866            }),
867        );
868
869        // Test nested configuration
870        config_map.insert(
871            "MD003".to_string(),
872            serde_yaml::Value::Mapping({
873                let mut map = serde_yaml::Mapping::new();
874                map.insert(
875                    serde_yaml::Value::String("style".to_string()),
876                    serde_yaml::Value::String("atx".to_string()),
877                );
878                map
879            }),
880        );
881
882        let mdl_config = MarkdownlintConfig(config_map);
883        let sourced = mdl_config.map_to_sourced_rumdl_config(None);
884
885        // Verify MD044 configuration
886        assert!(sourced.rules.contains_key("MD044"));
887        let md044_config = &sourced.rules["MD044"];
888        assert!(md044_config.values.contains_key("names"));
889
890        // Verify MD003 configuration
891        assert!(sourced.rules.contains_key("MD003"));
892        let md003_config = &sourced.rules["MD003"];
893        assert!(md003_config.values.contains_key("style"));
894        assert_eq!(
895            md003_config.values["style"].value,
896            toml::Value::String("atx".to_string())
897        );
898    }
899
900    #[test]
901    fn test_value_types() {
902        let mut config_map = HashMap::new();
903
904        // Test different value types
905        config_map.insert(
906            "MD007".to_string(),
907            serde_yaml::Value::Number(serde_yaml::Number::from(4)),
908        ); // Simple number
909        config_map.insert(
910            "MD009".to_string(),
911            serde_yaml::Value::Mapping({
912                let mut map = serde_yaml::Mapping::new();
913                map.insert(
914                    serde_yaml::Value::String("br_spaces".to_string()),
915                    serde_yaml::Value::Number(serde_yaml::Number::from(2)),
916                );
917                map.insert(
918                    serde_yaml::Value::String("strict".to_string()),
919                    serde_yaml::Value::Bool(true),
920                );
921                map
922            }),
923        );
924
925        let mdl_config = MarkdownlintConfig(config_map);
926        let sourced = mdl_config.map_to_sourced_rumdl_config(None);
927
928        // Check simple number value
929        assert!(sourced.rules.contains_key("MD007"));
930        assert!(sourced.rules["MD007"].values.contains_key("value"));
931
932        // Check complex configuration
933        assert!(sourced.rules.contains_key("MD009"));
934        let md009_config = &sourced.rules["MD009"];
935        assert!(md009_config.values.contains_key("br-spaces"));
936        assert!(md009_config.values.contains_key("strict"));
937    }
938
939    #[test]
940    fn test_all_rule_aliases() {
941        // Test that all documented aliases map correctly
942        let aliases = vec![
943            ("heading-increment", "MD001"),
944            ("heading-style", "MD003"),
945            ("ul-style", "MD004"),
946            ("list-indent", "MD005"),
947            ("ul-indent", "MD007"),
948            ("no-trailing-spaces", "MD009"),
949            ("no-hard-tabs", "MD010"),
950            ("no-reversed-links", "MD011"),
951            ("no-multiple-blanks", "MD012"),
952            ("line-length", "MD013"),
953            ("commands-show-output", "MD014"),
954            // MD015-017 don't exist in markdownlint
955            ("no-missing-space-atx", "MD018"),
956            ("no-multiple-space-atx", "MD019"),
957            ("no-missing-space-closed-atx", "MD020"),
958            ("no-multiple-space-closed-atx", "MD021"),
959            ("blanks-around-headings", "MD022"),
960            ("heading-start-left", "MD023"),
961            ("no-duplicate-heading", "MD024"),
962            ("single-title", "MD025"),
963            ("single-h1", "MD025"),
964            ("no-trailing-punctuation", "MD026"),
965            ("no-multiple-space-blockquote", "MD027"),
966            ("no-blanks-blockquote", "MD028"),
967            ("ol-prefix", "MD029"),
968            ("list-marker-space", "MD030"),
969            ("blanks-around-fences", "MD031"),
970            ("blanks-around-lists", "MD032"),
971            ("no-inline-html", "MD033"),
972            ("no-bare-urls", "MD034"),
973            ("hr-style", "MD035"),
974            ("no-emphasis-as-heading", "MD036"),
975            ("no-space-in-emphasis", "MD037"),
976            ("no-space-in-code", "MD038"),
977            ("no-space-in-links", "MD039"),
978            ("fenced-code-language", "MD040"),
979            ("first-line-heading", "MD041"),
980            ("first-line-h1", "MD041"),
981            ("no-empty-links", "MD042"),
982            ("required-headings", "MD043"),
983            ("proper-names", "MD044"),
984            ("no-alt-text", "MD045"),
985            ("code-block-style", "MD046"),
986            ("single-trailing-newline", "MD047"),
987            ("code-fence-style", "MD048"),
988            ("emphasis-style", "MD049"),
989            ("strong-style", "MD050"),
990            ("link-fragments", "MD051"),
991            ("reference-links-images", "MD052"),
992            ("link-image-reference-definitions", "MD053"),
993            ("link-image-style", "MD054"),
994            ("table-pipe-style", "MD055"),
995            ("table-column-count", "MD056"),
996            ("existing-relative-links", "MD057"),
997            ("blanks-around-tables", "MD058"),
998            ("descriptive-link-text", "MD059"),
999            ("table-cell-alignment", "MD060"),
1000            ("table-format", "MD060"),
1001            ("forbidden-terms", "MD061"),
1002            ("nested-code-fence", "MD070"),
1003            ("blank-line-after-frontmatter", "MD071"),
1004            ("frontmatter-key-sort", "MD072"),
1005        ];
1006
1007        for (alias, expected) in aliases {
1008            assert_eq!(
1009                markdownlint_to_rumdl_rule_key(alias),
1010                Some(expected),
1011                "Alias {alias} should map to {expected}"
1012            );
1013        }
1014    }
1015
1016    #[test]
1017    fn test_default_true_with_boolean_rules() {
1018        // default: true + MD001: true + MD013: { line_length: 120 }
1019        // Expected: no enable list (all rules already on), no disable list, MD013 config preserved
1020        let mut config_map = HashMap::new();
1021        config_map.insert("default".to_string(), serde_yaml::Value::Bool(true));
1022        config_map.insert("MD001".to_string(), serde_yaml::Value::Bool(true));
1023        config_map.insert(
1024            "MD013".to_string(),
1025            serde_yaml::Value::Mapping({
1026                let mut map = serde_yaml::Mapping::new();
1027                map.insert(
1028                    serde_yaml::Value::String("line_length".to_string()),
1029                    serde_yaml::Value::Number(serde_yaml::Number::from(120)),
1030                );
1031                map
1032            }),
1033        );
1034
1035        let mdl_config = MarkdownlintConfig(config_map);
1036        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.yaml"));
1037
1038        // No enable list: boolean true is no-op when default is true
1039        assert!(
1040            fragment.global.enable.value.is_empty(),
1041            "Enable list should be empty when default: true"
1042        );
1043        // No disable list
1044        assert!(fragment.global.disable.value.is_empty(), "Disable list should be empty");
1045        // MD013 config preserved
1046        assert!(fragment.rules.contains_key("MD013"));
1047        assert_eq!(
1048            fragment.rules["MD013"].values["line-length"].value,
1049            toml::Value::Integer(120)
1050        );
1051    }
1052
1053    #[test]
1054    fn test_default_false_with_boolean_and_config_rules() {
1055        // default: false + MD001: true + MD013: { line_length: 120 }
1056        // Expected: enable list contains both MD001 and MD013
1057        let mut config_map = HashMap::new();
1058        config_map.insert("default".to_string(), serde_yaml::Value::Bool(false));
1059        config_map.insert("MD001".to_string(), serde_yaml::Value::Bool(true));
1060        config_map.insert(
1061            "MD013".to_string(),
1062            serde_yaml::Value::Mapping({
1063                let mut map = serde_yaml::Mapping::new();
1064                map.insert(
1065                    serde_yaml::Value::String("line_length".to_string()),
1066                    serde_yaml::Value::Number(serde_yaml::Number::from(120)),
1067                );
1068                map
1069            }),
1070        );
1071
1072        let mdl_config = MarkdownlintConfig(config_map);
1073        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.yaml"));
1074
1075        let mut enabled_sorted = fragment.global.enable.value.clone();
1076        enabled_sorted.sort();
1077        assert_eq!(
1078            enabled_sorted,
1079            vec!["MD001", "MD013"],
1080            "Both boolean-true and config-object rules should be in enable list"
1081        );
1082        assert!(fragment.global.disable.value.is_empty(), "No rules should be disabled");
1083        // MD013 config preserved
1084        assert!(fragment.rules.contains_key("MD013"));
1085        assert_eq!(
1086            fragment.rules["MD013"].values["line-length"].value,
1087            toml::Value::Integer(120)
1088        );
1089    }
1090
1091    #[test]
1092    fn test_default_absent_with_boolean_rules() {
1093        // No `default` key + MD001: true → same as default: true (no enable list)
1094        let mut config_map = HashMap::new();
1095        config_map.insert("MD001".to_string(), serde_yaml::Value::Bool(true));
1096        config_map.insert("MD009".to_string(), serde_yaml::Value::Bool(false));
1097
1098        let mdl_config = MarkdownlintConfig(config_map);
1099        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.yaml"));
1100
1101        // No enable list: true is no-op when default is absent (treated as true)
1102        assert!(
1103            fragment.global.enable.value.is_empty(),
1104            "Enable list should be empty when default is absent"
1105        );
1106        // MD009 should be disabled
1107        assert_eq!(fragment.global.disable.value, vec!["MD009"]);
1108    }
1109
1110    #[test]
1111    fn test_default_false_only_booleans() {
1112        // default: false + MD001: true + MD009: false
1113        // Expected: enable list = [MD001], no disable list (false is no-op when default: false)
1114        let mut config_map = HashMap::new();
1115        config_map.insert("default".to_string(), serde_yaml::Value::Bool(false));
1116        config_map.insert("MD001".to_string(), serde_yaml::Value::Bool(true));
1117        config_map.insert("MD009".to_string(), serde_yaml::Value::Bool(false));
1118
1119        let mdl_config = MarkdownlintConfig(config_map);
1120        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.yaml"));
1121
1122        assert_eq!(fragment.global.enable.value, vec!["MD001"]);
1123        assert!(
1124            fragment.global.disable.value.is_empty(),
1125            "Disable list should be empty when default: false (false is no-op)"
1126        );
1127    }
1128
1129    #[test]
1130    fn test_default_true_with_boolean_rules_legacy() {
1131        // Test the legacy map_to_sourced_rumdl_config path
1132        let mut config_map = HashMap::new();
1133        config_map.insert("default".to_string(), serde_yaml::Value::Bool(true));
1134        config_map.insert("MD001".to_string(), serde_yaml::Value::Bool(true));
1135        config_map.insert("MD009".to_string(), serde_yaml::Value::Bool(false));
1136        config_map.insert(
1137            "MD013".to_string(),
1138            serde_yaml::Value::Mapping({
1139                let mut map = serde_yaml::Mapping::new();
1140                map.insert(
1141                    serde_yaml::Value::String("line_length".to_string()),
1142                    serde_yaml::Value::Number(serde_yaml::Number::from(120)),
1143                );
1144                map
1145            }),
1146        );
1147
1148        let mdl_config = MarkdownlintConfig(config_map);
1149        let sourced = mdl_config.map_to_sourced_rumdl_config(Some("test.yaml"));
1150
1151        // No enable list: boolean true is no-op when default is true
1152        assert!(sourced.global.enable.value.is_empty());
1153        // MD009 should be disabled
1154        assert_eq!(sourced.global.disable.value, vec!["MD009"]);
1155        // MD013 config preserved
1156        assert!(sourced.rules.contains_key("MD013"));
1157        assert_eq!(
1158            sourced.rules["MD013"].values["line-length"].value,
1159            toml::Value::Integer(120)
1160        );
1161    }
1162
1163    #[test]
1164    fn test_default_false_with_config_rules_legacy() {
1165        // Test the legacy path with default: false
1166        let mut config_map = HashMap::new();
1167        config_map.insert("default".to_string(), serde_yaml::Value::Bool(false));
1168        config_map.insert("MD001".to_string(), serde_yaml::Value::Bool(true));
1169        config_map.insert(
1170            "MD013".to_string(),
1171            serde_yaml::Value::Mapping({
1172                let mut map = serde_yaml::Mapping::new();
1173                map.insert(
1174                    serde_yaml::Value::String("line_length".to_string()),
1175                    serde_yaml::Value::Number(serde_yaml::Number::from(120)),
1176                );
1177                map
1178            }),
1179        );
1180
1181        let mdl_config = MarkdownlintConfig(config_map);
1182        let sourced = mdl_config.map_to_sourced_rumdl_config(Some("test.yaml"));
1183
1184        let mut enabled_sorted = sourced.global.enable.value.clone();
1185        enabled_sorted.sort();
1186        assert_eq!(enabled_sorted, vec!["MD001", "MD013"]);
1187        assert!(sourced.global.disable.value.is_empty());
1188    }
1189
1190    #[test]
1191    fn test_default_false_no_rules_disables_everything() {
1192        // default: false with no other rules should result in an empty-but-explicit enable list
1193        let mut config_map = HashMap::new();
1194        config_map.insert("default".to_string(), serde_yaml::Value::Bool(false));
1195
1196        let mdl_config = MarkdownlintConfig(config_map);
1197        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.yaml"));
1198
1199        // Enable list is empty but was explicitly set (source should be ProjectConfig, not Default)
1200        assert!(fragment.global.enable.value.is_empty());
1201        assert_eq!(
1202            fragment.global.enable.source,
1203            crate::config::ConfigSource::ProjectConfig,
1204            "Enable source should be ProjectConfig when default: false"
1205        );
1206    }
1207
1208    #[test]
1209    fn test_default_false_only_false_rules_disables_everything() {
1210        // default: false + MD001: false → no rules enabled, enable list is explicit
1211        let mut config_map = HashMap::new();
1212        config_map.insert("default".to_string(), serde_yaml::Value::Bool(false));
1213        config_map.insert("MD001".to_string(), serde_yaml::Value::Bool(false));
1214
1215        let mdl_config = MarkdownlintConfig(config_map);
1216        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.yaml"));
1217
1218        assert!(fragment.global.enable.value.is_empty());
1219        assert_eq!(
1220            fragment.global.enable.source,
1221            crate::config::ConfigSource::ProjectConfig,
1222        );
1223    }
1224
1225    #[test]
1226    fn test_import_preserves_aliases_in_rules() {
1227        let mut config_map = HashMap::new();
1228        config_map.insert(
1229            "line-length".to_string(),
1230            serde_yaml::Value::Mapping({
1231                let mut map = serde_yaml::Mapping::new();
1232                map.insert(
1233                    serde_yaml::Value::String("line_length".to_string()),
1234                    serde_yaml::Value::Number(serde_yaml::Number::from(120)),
1235                );
1236                map
1237            }),
1238        );
1239        config_map.insert("no-bare-urls".to_string(), serde_yaml::Value::Bool(false));
1240
1241        let mdl_config = MarkdownlintConfig(config_map);
1242        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.json"));
1243
1244        assert_eq!(fragment.rule_display_names.get("MD013").unwrap(), "line-length");
1245        assert_eq!(fragment.rule_display_names.get("MD034").unwrap(), "no-bare-urls");
1246    }
1247
1248    #[test]
1249    fn test_import_preserves_canonical_ids() {
1250        let mut config_map = HashMap::new();
1251        config_map.insert(
1252            "MD013".to_string(),
1253            serde_yaml::Value::Mapping({
1254                let mut map = serde_yaml::Mapping::new();
1255                map.insert(
1256                    serde_yaml::Value::String("line_length".to_string()),
1257                    serde_yaml::Value::Number(serde_yaml::Number::from(120)),
1258                );
1259                map
1260            }),
1261        );
1262        config_map.insert("MD034".to_string(), serde_yaml::Value::Bool(false));
1263
1264        let mdl_config = MarkdownlintConfig(config_map);
1265        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.json"));
1266
1267        assert_eq!(fragment.rule_display_names.get("MD013").unwrap(), "MD013");
1268        assert_eq!(fragment.rule_display_names.get("MD034").unwrap(), "MD034");
1269        assert!(fragment.global.disable.value.contains(&"MD034".to_string()));
1270    }
1271
1272    #[test]
1273    fn test_import_mixed_aliases_and_ids() {
1274        let mut config_map = HashMap::new();
1275        config_map.insert(
1276            "line-length".to_string(),
1277            serde_yaml::Value::Mapping({
1278                let mut map = serde_yaml::Mapping::new();
1279                map.insert(
1280                    serde_yaml::Value::String("line_length".to_string()),
1281                    serde_yaml::Value::Number(serde_yaml::Number::from(120)),
1282                );
1283                map
1284            }),
1285        );
1286        config_map.insert("MD034".to_string(), serde_yaml::Value::Bool(false));
1287
1288        let mdl_config = MarkdownlintConfig(config_map);
1289        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.json"));
1290
1291        // Alias is preserved
1292        assert_eq!(fragment.rule_display_names.get("MD013").unwrap(), "line-length");
1293        // Canonical ID is preserved
1294        assert_eq!(fragment.rule_display_names.get("MD034").unwrap(), "MD034");
1295    }
1296
1297    #[test]
1298    fn test_import_disable_list_uses_aliases() {
1299        let mut config_map = HashMap::new();
1300        config_map.insert("line-length".to_string(), serde_yaml::Value::Bool(false));
1301        config_map.insert("no-bare-urls".to_string(), serde_yaml::Value::Bool(false));
1302
1303        let mdl_config = MarkdownlintConfig(config_map);
1304        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.json"));
1305
1306        let mut disable_sorted = fragment.global.disable.value.clone();
1307        disable_sorted.sort();
1308        assert_eq!(disable_sorted, vec!["line-length", "no-bare-urls"]);
1309    }
1310
1311    #[test]
1312    fn test_import_enable_list_uses_aliases_when_default_false() {
1313        let mut config_map = HashMap::new();
1314        config_map.insert("default".to_string(), serde_yaml::Value::Bool(false));
1315        config_map.insert("line-length".to_string(), serde_yaml::Value::Bool(true));
1316        config_map.insert("no-bare-urls".to_string(), serde_yaml::Value::Bool(true));
1317
1318        let mdl_config = MarkdownlintConfig(config_map);
1319        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.json"));
1320
1321        let mut enable_sorted = fragment.global.enable.value.clone();
1322        enable_sorted.sort();
1323        assert_eq!(enable_sorted, vec!["line-length", "no-bare-urls"]);
1324    }
1325
1326    #[test]
1327    fn test_import_underscore_aliases_normalized_to_kebab() {
1328        let mut config_map = HashMap::new();
1329        config_map.insert("no_bare_urls".to_string(), serde_yaml::Value::Bool(false));
1330
1331        let mdl_config = MarkdownlintConfig(config_map);
1332        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.json"));
1333
1334        // Underscores in the original key are normalized to kebab-case
1335        assert_eq!(fragment.rule_display_names.get("MD034").unwrap(), "no-bare-urls");
1336        assert!(fragment.global.disable.value.contains(&"no-bare-urls".to_string()));
1337    }
1338
1339    #[test]
1340    fn test_load_markdownlint_cli2_yaml_with_config_key() {
1341        let mut temp_file = NamedTempFile::new().unwrap();
1342        writeln!(
1343            temp_file,
1344            r#"config:
1345  MD013:
1346    line_length: 120
1347  MD025: true
1348  MD026: false
1349  ul-style:
1350    style: dash"#
1351        )
1352        .unwrap();
1353
1354        let path = temp_file.path().with_extension("yaml");
1355        std::fs::rename(temp_file.path(), &path).unwrap();
1356
1357        let config = load_markdownlint_config(path.to_str().unwrap()).unwrap();
1358        assert_eq!(config.0.len(), 4);
1359        assert!(config.0.contains_key("MD013"));
1360        assert!(config.0.contains_key("MD025"));
1361        assert!(config.0.contains_key("MD026"));
1362        assert!(config.0.contains_key("ul-style"));
1363    }
1364
1365    #[test]
1366    fn test_load_markdownlint_cli2_json_with_config_key() {
1367        let mut temp_file = NamedTempFile::new().unwrap();
1368        writeln!(
1369            temp_file,
1370            r#"{{
1371            "config": {{
1372                "MD049": {{ "style": "asterisk" }},
1373                "MD013": {{ "line_length": 100 }}
1374            }}
1375        }}"#
1376        )
1377        .unwrap();
1378
1379        let path = temp_file.path().with_extension("json");
1380        std::fs::rename(temp_file.path(), &path).unwrap();
1381
1382        let config = load_markdownlint_config(path.to_str().unwrap()).unwrap();
1383        assert_eq!(config.0.len(), 2);
1384        assert!(config.0.contains_key("MD049"));
1385        assert!(config.0.contains_key("MD013"));
1386    }
1387
1388    #[test]
1389    fn test_load_markdownlint_cli2_with_config_and_other_keys() {
1390        let mut temp_file = NamedTempFile::new().unwrap();
1391        writeln!(
1392            temp_file,
1393            r#"globs:
1394  - "**/*.md"
1395ignores:
1396  - "vendor/**"
1397config:
1398  MD013:
1399    line_length: 80
1400  MD049:
1401    style: underscore"#
1402        )
1403        .unwrap();
1404
1405        let path = temp_file.path().with_extension("yaml");
1406        std::fs::rename(temp_file.path(), &path).unwrap();
1407
1408        let config = load_markdownlint_config(path.to_str().unwrap()).unwrap();
1409        // Only rules from the config: key should be present, not globs/ignores
1410        assert_eq!(config.0.len(), 2);
1411        assert!(config.0.contains_key("MD013"));
1412        assert!(config.0.contains_key("MD049"));
1413        assert!(!config.0.contains_key("globs"));
1414        assert!(!config.0.contains_key("ignores"));
1415    }
1416
1417    #[test]
1418    fn test_flat_format_still_works_with_config_as_rule() {
1419        // Flat format without a config: wrapper should continue to work
1420        let mut temp_file = NamedTempFile::new().unwrap();
1421        writeln!(
1422            temp_file,
1423            r#"MD013:
1424  line_length: 100
1425MD049:
1426  style: asterisk"#
1427        )
1428        .unwrap();
1429
1430        let path = temp_file.path().with_extension("yaml");
1431        std::fs::rename(temp_file.path(), &path).unwrap();
1432
1433        let config = load_markdownlint_config(path.to_str().unwrap()).unwrap();
1434        assert_eq!(config.0.len(), 2);
1435        assert!(config.0.contains_key("MD013"));
1436        assert!(config.0.contains_key("MD049"));
1437    }
1438
1439    #[test]
1440    fn test_load_markdownlint_cli2_empty_config_mapping() {
1441        let mut temp_file = NamedTempFile::new().unwrap();
1442        writeln!(temp_file, "config: {{}}").unwrap();
1443
1444        let path = temp_file.path().with_extension("yaml");
1445        std::fs::rename(temp_file.path(), &path).unwrap();
1446
1447        let config = load_markdownlint_config(path.to_str().unwrap()).unwrap();
1448        assert!(
1449            config.0.is_empty(),
1450            "Empty config: mapping should produce empty rule set"
1451        );
1452    }
1453
1454    #[test]
1455    fn test_scalar_config_key_not_treated_as_cli2_wrapper() {
1456        // A scalar `config: true` should NOT be treated as a cli2 wrapper
1457        let mut temp_file = NamedTempFile::new().unwrap();
1458        writeln!(
1459            temp_file,
1460            r#"config: true
1461MD013:
1462  line_length: 100"#
1463        )
1464        .unwrap();
1465
1466        let path = temp_file.path().with_extension("yaml");
1467        std::fs::rename(temp_file.path(), &path).unwrap();
1468
1469        let config = load_markdownlint_config(path.to_str().unwrap()).unwrap();
1470        // Both keys preserved — scalar "config" is not unwrapped
1471        assert_eq!(config.0.len(), 2);
1472        assert!(config.0.contains_key("config"));
1473        assert!(config.0.contains_key("MD013"));
1474    }
1475
1476    #[test]
1477    fn test_import_case_insensitive_alias_preserved_lowercase() {
1478        let mut config_map = HashMap::new();
1479        config_map.insert("Line-Length".to_string(), serde_yaml::Value::Bool(false));
1480
1481        let mdl_config = MarkdownlintConfig(config_map);
1482        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.json"));
1483
1484        // Display name is lowercased
1485        assert_eq!(fragment.rule_display_names.get("MD013").unwrap(), "line-length");
1486    }
1487}