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