Skip to main content

rumdl_lib/
markdownlint_config.rs

1//!
2//! This module handles parsing and mapping markdownlint config files (JSON/YAML) to rumdl's internal config format.
3//! It provides mapping from markdownlint rule keys to rumdl rule keys and provenance tracking for configuration values.
4
5use crate::config::{ConfigSource, SourcedConfig, SourcedValue};
6use serde::Deserialize;
7use std::collections::HashMap;
8use std::fs;
9
10/// Represents a generic markdownlint config (rule keys to values)
11#[derive(Debug, Deserialize)]
12pub struct MarkdownlintConfig(pub HashMap<String, serde_yml::Value>);
13
14/// Load a markdownlint config file (JSON or YAML) from the given path
15pub fn load_markdownlint_config(path: &str) -> Result<MarkdownlintConfig, String> {
16    let content = fs::read_to_string(path).map_err(|e| format!("Failed to read config file {path}: {e}"))?;
17
18    if path.ends_with(".json") || path.ends_with(".jsonc") {
19        serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {e}"))
20    } else if path.ends_with(".yaml") || path.ends_with(".yml") {
21        serde_yml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {e}"))
22    } else {
23        serde_json::from_str(&content)
24            .or_else(|_| serde_yml::from_str(&content))
25            .map_err(|e| format!("Failed to parse config as JSON or YAML: {e}"))
26    }
27}
28
29/// Mapping table from markdownlint rule keys/aliases to rumdl rule keys
30/// Convert a rule name (which may be an alias like "line-length") to the canonical rule ID (like "MD013").
31/// Returns None if the rule name is not recognized.
32pub fn markdownlint_to_rumdl_rule_key(key: &str) -> Option<&'static str> {
33    // Use the shared alias resolution function from config module
34    crate::config::resolve_rule_name_alias(key)
35}
36
37fn normalize_toml_table_keys(val: toml::Value) -> toml::Value {
38    match val {
39        toml::Value::Table(table) => {
40            let mut new_table = toml::map::Map::new();
41            for (k, v) in table {
42                let norm_k = crate::config::normalize_key(&k);
43                new_table.insert(norm_k, normalize_toml_table_keys(v));
44            }
45            toml::Value::Table(new_table)
46        }
47        toml::Value::Array(arr) => toml::Value::Array(arr.into_iter().map(normalize_toml_table_keys).collect()),
48        other => other,
49    }
50}
51
52/// Map markdownlint-specific option names to rumdl option names for a given rule.
53/// This handles incompatibilities between markdownlint and rumdl config schemas.
54/// Returns a new table with mapped options, or None if the entire config should be dropped.
55fn map_markdownlint_options_to_rumdl(
56    rule_key: &str,
57    table: toml::map::Map<String, toml::Value>,
58) -> Option<toml::map::Map<String, toml::Value>> {
59    let mut mapped = toml::map::Map::new();
60
61    match rule_key {
62        "MD013" => {
63            // MD013 (line-length) has different option names in markdownlint vs rumdl
64            for (k, v) in table {
65                match k.as_str() {
66                    // Markdownlint uses separate line length limits for different content types
67                    // rumdl uses boolean flags to enable/disable checking for content types
68                    "code-block-line-length" | "code_block_line_length" => {
69                        // Ignore: rumdl doesn't support per-content-type line length limits
70                        // Instead, users should use code-blocks = false to disable entirely
71                        log::warn!(
72                            "Ignoring markdownlint option 'code_block_line_length' for MD013. Use 'code-blocks = false' in rumdl to disable line length checking in code blocks."
73                        );
74                    }
75                    "heading-line-length" | "heading_line_length" => {
76                        // Ignore: rumdl doesn't support per-content-type line length limits
77                        log::warn!(
78                            "Ignoring markdownlint option 'heading_line_length' for MD013. Use 'headings = false' in rumdl to disable line length checking in headings."
79                        );
80                    }
81                    "stern" => {
82                        // Markdownlint uses "stern", rumdl uses "strict"
83                        mapped.insert("strict".to_string(), v);
84                    }
85                    // Pass through all other options
86                    _ => {
87                        mapped.insert(k, v);
88                    }
89                }
90            }
91            Some(mapped)
92        }
93        "MD054" => {
94            // MD054 (link-image-style) has fundamentally different config models
95            // Markdownlint uses style/styles strings, rumdl uses individual boolean flags
96            for (k, v) in table {
97                match k.as_str() {
98                    "style" | "styles" => {
99                        // Ignore: rumdl uses individual boolean flags (autolink, inline, full, etc.)
100                        // Cannot automatically map string style names to boolean flags
101                        log::warn!(
102                            "Ignoring markdownlint option '{k}' for MD054. rumdl uses individual boolean flags (autolink, inline, full, collapsed, shortcut, url-inline) instead. Please configure these directly."
103                        );
104                    }
105                    // Pass through all other options (autolink, inline, full, collapsed, shortcut, url-inline)
106                    _ => {
107                        mapped.insert(k, v);
108                    }
109                }
110            }
111            Some(mapped)
112        }
113        // All other rules: pass through unchanged
114        _ => Some(table),
115    }
116}
117
118/// Map a MarkdownlintConfig to rumdl's internal Config format
119impl MarkdownlintConfig {
120    /// Map to a SourcedConfig, tracking provenance as Markdownlint for all values.
121    pub fn map_to_sourced_rumdl_config(&self, file_path: Option<&str>) -> SourcedConfig {
122        let mut sourced_config = SourcedConfig::default();
123        let file = file_path.map(|s| s.to_string());
124
125        // Extract the `default` key
126        let default_enabled = self.0.get("default").and_then(|v| v.as_bool()).unwrap_or(true);
127
128        let mut disabled_rules = Vec::new();
129        let mut enabled_rules = Vec::new();
130
131        for (key, value) in &self.0 {
132            // Skip the `default` key — it's not a rule
133            if key == "default" {
134                continue;
135            }
136
137            let mapped = markdownlint_to_rumdl_rule_key(key);
138            if let Some(rumdl_key) = mapped {
139                let norm_rule_key = rumdl_key.to_ascii_uppercase();
140
141                // Handle boolean values according to `default` semantics
142                if value.is_bool() {
143                    let is_enabled = value.as_bool().unwrap_or(false);
144                    if default_enabled {
145                        if !is_enabled {
146                            disabled_rules.push(norm_rule_key.clone());
147                        }
148                    } else if is_enabled {
149                        enabled_rules.push(norm_rule_key.clone());
150                    }
151                    continue;
152                }
153
154                let toml_value: Option<toml::Value> = serde_yml::from_value::<toml::Value>(value.clone()).ok();
155                let toml_value = toml_value.map(normalize_toml_table_keys);
156                let rule_config = sourced_config.rules.entry(norm_rule_key.clone()).or_default();
157                if let Some(tv) = toml_value {
158                    if let toml::Value::Table(mut table) = tv {
159                        // Apply markdownlint-to-rumdl option mapping
160                        table = match map_markdownlint_options_to_rumdl(&norm_rule_key, table) {
161                            Some(mapped) => mapped,
162                            None => continue, // Skip this rule entirely if mapping returns None
163                        };
164
165                        // Special handling for MD007: Add style = "fixed" for markdownlint compatibility
166                        if norm_rule_key == "MD007" && !table.contains_key("style") {
167                            table.insert("style".to_string(), toml::Value::String("fixed".to_string()));
168                        }
169
170                        for (k, v) in table {
171                            let norm_config_key = k; // Already normalized
172                            rule_config
173                                .values
174                                .entry(norm_config_key.clone())
175                                .and_modify(|sv| {
176                                    sv.value = v.clone();
177                                    sv.source = ConfigSource::ProjectConfig;
178                                    sv.overrides.push(crate::config::ConfigOverride {
179                                        value: v.clone(),
180                                        source: ConfigSource::ProjectConfig,
181                                        file: file.clone(),
182                                        line: None,
183                                    });
184                                })
185                                .or_insert_with(|| SourcedValue {
186                                    value: v.clone(),
187                                    source: ConfigSource::ProjectConfig,
188                                    overrides: vec![crate::config::ConfigOverride {
189                                        value: v,
190                                        source: ConfigSource::ProjectConfig,
191                                        file: file.clone(),
192                                        line: None,
193                                    }],
194                                });
195                        }
196                    } else {
197                        rule_config
198                            .values
199                            .entry("value".to_string())
200                            .and_modify(|sv| {
201                                sv.value = tv.clone();
202                                sv.source = ConfigSource::ProjectConfig;
203                                sv.overrides.push(crate::config::ConfigOverride {
204                                    value: tv.clone(),
205                                    source: ConfigSource::ProjectConfig,
206                                    file: file.clone(),
207                                    line: None,
208                                });
209                            })
210                            .or_insert_with(|| SourcedValue {
211                                value: tv.clone(),
212                                source: ConfigSource::ProjectConfig,
213                                overrides: vec![crate::config::ConfigOverride {
214                                    value: tv,
215                                    source: ConfigSource::ProjectConfig,
216                                    file: file.clone(),
217                                    line: None,
218                                }],
219                            });
220
221                        // Special handling for MD007: Add style = "fixed" for markdownlint compatibility
222                        if norm_rule_key == "MD007" && !rule_config.values.contains_key("style") {
223                            rule_config.values.insert(
224                                "style".to_string(),
225                                SourcedValue {
226                                    value: toml::Value::String("fixed".to_string()),
227                                    source: ConfigSource::ProjectConfig,
228                                    overrides: vec![crate::config::ConfigOverride {
229                                        value: toml::Value::String("fixed".to_string()),
230                                        source: ConfigSource::ProjectConfig,
231                                        file: file.clone(),
232                                        line: None,
233                                    }],
234                                },
235                            );
236                        }
237                    }
238                    // When default: false, rules with object configs are explicitly enabled
239                    if !default_enabled {
240                        enabled_rules.push(norm_rule_key.clone());
241                    }
242                } else {
243                    log::error!(
244                        "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."
245                    );
246                    std::process::exit(1);
247                }
248            }
249        }
250
251        // Apply enable/disable lists
252        if !disabled_rules.is_empty() {
253            sourced_config.global.disable = SourcedValue::new(disabled_rules, ConfigSource::ProjectConfig);
254        }
255        if !enabled_rules.is_empty() || !default_enabled {
256            sourced_config.global.enable = SourcedValue::new(enabled_rules, ConfigSource::ProjectConfig);
257        }
258
259        if let Some(_f) = file {
260            sourced_config.loaded_files.push(_f);
261        }
262        sourced_config
263    }
264
265    /// Map to a SourcedConfigFragment, for use in config loading.
266    pub fn map_to_sourced_rumdl_config_fragment(
267        &self,
268        file_path: Option<&str>,
269    ) -> crate::config::SourcedConfigFragment {
270        let mut fragment = crate::config::SourcedConfigFragment::default();
271        let file = file_path.map(|s| s.to_string());
272
273        // Extract the `default` key: controls whether rules are enabled by default.
274        // When true (or absent), all rules are enabled unless explicitly disabled.
275        // When false, only rules explicitly set to true or configured with an object are enabled.
276        let default_enabled = self.0.get("default").and_then(|v| v.as_bool()).unwrap_or(true);
277
278        // Accumulate disabled and enabled rules
279        let mut disabled_rules = Vec::new();
280        let mut enabled_rules = Vec::new();
281
282        for (key, value) in &self.0 {
283            // Skip the `default` key — it's not a rule
284            if key == "default" {
285                continue;
286            }
287
288            let mapped = markdownlint_to_rumdl_rule_key(key);
289            if let Some(rumdl_key) = mapped {
290                let norm_rule_key = rumdl_key.to_ascii_uppercase();
291                // Special handling for boolean values (true/false)
292                if value.is_bool() {
293                    let enabled = value.as_bool().unwrap_or(false);
294                    if default_enabled {
295                        // default: true — all rules on by default
296                        // true → no-op (already enabled), false → disable
297                        if !enabled {
298                            disabled_rules.push(norm_rule_key.clone());
299                        }
300                    } else {
301                        // default: false — all rules off by default
302                        // true → enable, false → no-op (already disabled)
303                        if enabled {
304                            enabled_rules.push(norm_rule_key.clone());
305                        }
306                    }
307                    continue;
308                }
309                let toml_value: Option<toml::Value> = serde_yml::from_value::<toml::Value>(value.clone()).ok();
310                let toml_value = toml_value.map(normalize_toml_table_keys);
311                let rule_config = fragment.rules.entry(norm_rule_key.clone()).or_default();
312                if let Some(tv) = toml_value {
313                    // Special case: if line-length (MD013) is given a number value directly,
314                    // treat it as {"line_length": value}
315                    let tv = if norm_rule_key == "MD013" && tv.is_integer() {
316                        let mut table = toml::map::Map::new();
317                        table.insert("line-length".to_string(), tv);
318                        toml::Value::Table(table)
319                    } else {
320                        tv
321                    };
322
323                    if let toml::Value::Table(mut table) = tv {
324                        // Apply markdownlint-to-rumdl option mapping
325                        table = match map_markdownlint_options_to_rumdl(&norm_rule_key, table) {
326                            Some(mapped) => mapped,
327                            None => continue, // Skip this rule entirely if mapping returns None
328                        };
329
330                        // Special handling for MD007: Add style = "fixed" for markdownlint compatibility
331                        if norm_rule_key == "MD007" && !table.contains_key("style") {
332                            table.insert("style".to_string(), toml::Value::String("fixed".to_string()));
333                        }
334
335                        for (rk, rv) in table {
336                            let norm_rk = crate::config::normalize_key(&rk);
337                            let sv = rule_config.values.entry(norm_rk.clone()).or_insert_with(|| {
338                                crate::config::SourcedValue::new(rv.clone(), crate::config::ConfigSource::ProjectConfig)
339                            });
340                            sv.push_override(rv, crate::config::ConfigSource::ProjectConfig, file.clone(), None);
341                        }
342                    } else {
343                        rule_config
344                            .values
345                            .entry("value".to_string())
346                            .and_modify(|sv| {
347                                sv.value = tv.clone();
348                                sv.source = crate::config::ConfigSource::ProjectConfig;
349                                sv.overrides.push(crate::config::ConfigOverride {
350                                    value: tv.clone(),
351                                    source: crate::config::ConfigSource::ProjectConfig,
352                                    file: file.clone(),
353                                    line: None,
354                                });
355                            })
356                            .or_insert_with(|| crate::config::SourcedValue {
357                                value: tv.clone(),
358                                source: crate::config::ConfigSource::ProjectConfig,
359                                overrides: vec![crate::config::ConfigOverride {
360                                    value: tv,
361                                    source: crate::config::ConfigSource::ProjectConfig,
362                                    file: file.clone(),
363                                    line: None,
364                                }],
365                            });
366
367                        // Special handling for MD007: Add style = "fixed" for markdownlint compatibility
368                        if norm_rule_key == "MD007" && !rule_config.values.contains_key("style") {
369                            rule_config.values.insert(
370                                "style".to_string(),
371                                crate::config::SourcedValue {
372                                    value: toml::Value::String("fixed".to_string()),
373                                    source: crate::config::ConfigSource::ProjectConfig,
374                                    overrides: vec![crate::config::ConfigOverride {
375                                        value: toml::Value::String("fixed".to_string()),
376                                        source: crate::config::ConfigSource::ProjectConfig,
377                                        file: file.clone(),
378                                        line: None,
379                                    }],
380                                },
381                            );
382                        }
383                    }
384
385                    // When default: false, rules with object configs are explicitly enabled
386                    if !default_enabled {
387                        enabled_rules.push(norm_rule_key.clone());
388                    }
389                }
390            }
391        }
392
393        // Set all disabled rules at once
394        if !disabled_rules.is_empty() {
395            fragment.global.disable.push_override(
396                disabled_rules,
397                crate::config::ConfigSource::ProjectConfig,
398                file.clone(),
399                None,
400            );
401        }
402
403        // Set all enabled rules at once.
404        // When default: false, always push the enable override (even if empty)
405        // so the source changes from Default to ProjectConfig, signaling that
406        // the enable list is authoritative.
407        if !enabled_rules.is_empty() || !default_enabled {
408            fragment.global.enable.push_override(
409                enabled_rules,
410                crate::config::ConfigSource::ProjectConfig,
411                file.clone(),
412                None,
413            );
414        }
415
416        if let Some(_f) = file {
417            // SourcedConfigFragment does not have loaded_files, so skip
418        }
419        fragment
420    }
421}
422
423// NOTE: 'code-block-style' (MD046) and 'code-fence-style' (MD048) are distinct and must not be merged. See markdownlint docs for details.
424
425#[cfg(test)]
426mod tests {
427    use super::*;
428    use std::io::Write;
429    use tempfile::NamedTempFile;
430
431    #[test]
432    fn test_markdownlint_to_rumdl_rule_key() {
433        // Test direct rule names
434        assert_eq!(markdownlint_to_rumdl_rule_key("MD001"), Some("MD001"));
435        assert_eq!(markdownlint_to_rumdl_rule_key("MD058"), Some("MD058"));
436
437        // Test aliases with hyphens
438        assert_eq!(markdownlint_to_rumdl_rule_key("heading-increment"), Some("MD001"));
439        assert_eq!(markdownlint_to_rumdl_rule_key("HEADING-INCREMENT"), Some("MD001"));
440        assert_eq!(markdownlint_to_rumdl_rule_key("ul-style"), Some("MD004"));
441        assert_eq!(markdownlint_to_rumdl_rule_key("no-trailing-spaces"), Some("MD009"));
442        assert_eq!(markdownlint_to_rumdl_rule_key("line-length"), Some("MD013"));
443        assert_eq!(markdownlint_to_rumdl_rule_key("single-title"), Some("MD025"));
444        assert_eq!(markdownlint_to_rumdl_rule_key("single-h1"), Some("MD025"));
445        assert_eq!(markdownlint_to_rumdl_rule_key("no-bare-urls"), Some("MD034"));
446        assert_eq!(markdownlint_to_rumdl_rule_key("code-block-style"), Some("MD046"));
447        assert_eq!(markdownlint_to_rumdl_rule_key("code-fence-style"), Some("MD048"));
448
449        // Test aliases with underscores (should also work)
450        assert_eq!(markdownlint_to_rumdl_rule_key("heading_increment"), Some("MD001"));
451        assert_eq!(markdownlint_to_rumdl_rule_key("HEADING_INCREMENT"), Some("MD001"));
452        assert_eq!(markdownlint_to_rumdl_rule_key("ul_style"), Some("MD004"));
453        assert_eq!(markdownlint_to_rumdl_rule_key("no_trailing_spaces"), Some("MD009"));
454        assert_eq!(markdownlint_to_rumdl_rule_key("line_length"), Some("MD013"));
455        assert_eq!(markdownlint_to_rumdl_rule_key("single_title"), Some("MD025"));
456        assert_eq!(markdownlint_to_rumdl_rule_key("single_h1"), Some("MD025"));
457        assert_eq!(markdownlint_to_rumdl_rule_key("no_bare_urls"), Some("MD034"));
458        assert_eq!(markdownlint_to_rumdl_rule_key("code_block_style"), Some("MD046"));
459        assert_eq!(markdownlint_to_rumdl_rule_key("code_fence_style"), Some("MD048"));
460
461        // Test case insensitivity
462        assert_eq!(markdownlint_to_rumdl_rule_key("md001"), Some("MD001"));
463        assert_eq!(markdownlint_to_rumdl_rule_key("Md001"), Some("MD001"));
464        assert_eq!(markdownlint_to_rumdl_rule_key("Line-Length"), Some("MD013"));
465        assert_eq!(markdownlint_to_rumdl_rule_key("Line_Length"), Some("MD013"));
466
467        // Test invalid keys
468        assert_eq!(markdownlint_to_rumdl_rule_key("MD999"), None);
469        assert_eq!(markdownlint_to_rumdl_rule_key("invalid-rule"), None);
470        assert_eq!(markdownlint_to_rumdl_rule_key(""), None);
471    }
472
473    #[test]
474    fn test_normalize_toml_table_keys() {
475        use toml::map::Map;
476
477        // Test table normalization
478        let mut table = Map::new();
479        table.insert("snake_case".to_string(), toml::Value::String("value1".to_string()));
480        table.insert("kebab-case".to_string(), toml::Value::String("value2".to_string()));
481        table.insert("MD013".to_string(), toml::Value::Integer(100));
482
483        let normalized = normalize_toml_table_keys(toml::Value::Table(table));
484
485        if let toml::Value::Table(norm_table) = normalized {
486            assert!(norm_table.contains_key("snake-case"));
487            assert!(norm_table.contains_key("kebab-case"));
488            assert!(norm_table.contains_key("MD013"));
489            assert_eq!(
490                norm_table.get("snake-case").unwrap(),
491                &toml::Value::String("value1".to_string())
492            );
493            assert_eq!(
494                norm_table.get("kebab-case").unwrap(),
495                &toml::Value::String("value2".to_string())
496            );
497        } else {
498            panic!("Expected normalized value to be a table");
499        }
500
501        // Test array normalization
502        let array = toml::Value::Array(vec![toml::Value::String("test".to_string()), toml::Value::Integer(42)]);
503        let normalized_array = normalize_toml_table_keys(array.clone());
504        assert_eq!(normalized_array, array);
505
506        // Test simple value passthrough
507        let simple = toml::Value::String("simple".to_string());
508        assert_eq!(normalize_toml_table_keys(simple.clone()), simple);
509    }
510
511    #[test]
512    fn test_load_markdownlint_config_json() {
513        let mut temp_file = NamedTempFile::new().unwrap();
514        writeln!(
515            temp_file,
516            r#"{{
517            "MD013": {{ "line_length": 100 }},
518            "MD025": true,
519            "MD026": false,
520            "heading-style": {{ "style": "atx" }}
521        }}"#
522        )
523        .unwrap();
524
525        let config = load_markdownlint_config(temp_file.path().to_str().unwrap()).unwrap();
526        assert_eq!(config.0.len(), 4);
527        assert!(config.0.contains_key("MD013"));
528        assert!(config.0.contains_key("MD025"));
529        assert!(config.0.contains_key("MD026"));
530        assert!(config.0.contains_key("heading-style"));
531    }
532
533    #[test]
534    fn test_load_markdownlint_config_yaml() {
535        let mut temp_file = NamedTempFile::new().unwrap();
536        writeln!(
537            temp_file,
538            r#"MD013:
539  line_length: 120
540MD025: true
541MD026: false
542ul-style:
543  style: dash"#
544        )
545        .unwrap();
546
547        let path = temp_file.path().with_extension("yaml");
548        std::fs::rename(temp_file.path(), &path).unwrap();
549
550        let config = load_markdownlint_config(path.to_str().unwrap()).unwrap();
551        assert_eq!(config.0.len(), 4);
552        assert!(config.0.contains_key("MD013"));
553        assert!(config.0.contains_key("ul-style"));
554    }
555
556    #[test]
557    fn test_load_markdownlint_config_invalid() {
558        let mut temp_file = NamedTempFile::new().unwrap();
559        writeln!(temp_file, "invalid json/yaml content {{").unwrap();
560
561        let result = load_markdownlint_config(temp_file.path().to_str().unwrap());
562        assert!(result.is_err());
563    }
564
565    #[test]
566    fn test_load_markdownlint_config_nonexistent() {
567        let result = load_markdownlint_config("/nonexistent/file.json");
568        assert!(result.is_err());
569        assert!(result.unwrap_err().contains("Failed to read config file"));
570    }
571
572    #[test]
573    fn test_map_to_sourced_rumdl_config() {
574        let mut config_map = HashMap::new();
575        config_map.insert(
576            "MD013".to_string(),
577            serde_yml::Value::Mapping({
578                let mut map = serde_yml::Mapping::new();
579                map.insert(
580                    serde_yml::Value::String("line_length".to_string()),
581                    serde_yml::Value::Number(serde_yml::Number::from(100)),
582                );
583                map
584            }),
585        );
586        config_map.insert("MD025".to_string(), serde_yml::Value::Bool(true));
587        config_map.insert("MD026".to_string(), serde_yml::Value::Bool(false));
588
589        let mdl_config = MarkdownlintConfig(config_map);
590        let sourced_config = mdl_config.map_to_sourced_rumdl_config(Some("test.json"));
591
592        // Check MD013 mapping
593        assert!(sourced_config.rules.contains_key("MD013"));
594        let md013_config = &sourced_config.rules["MD013"];
595        assert!(md013_config.values.contains_key("line-length"));
596        assert_eq!(md013_config.values["line-length"].value, toml::Value::Integer(100));
597        assert_eq!(md013_config.values["line-length"].source, ConfigSource::ProjectConfig);
598
599        // Check that loaded_files is tracked
600        assert_eq!(sourced_config.loaded_files.len(), 1);
601        assert_eq!(sourced_config.loaded_files[0], "test.json");
602    }
603
604    #[test]
605    fn test_map_to_sourced_rumdl_config_fragment() {
606        let mut config_map = HashMap::new();
607
608        // Test line-length alias for MD013 with numeric value
609        config_map.insert(
610            "line-length".to_string(),
611            serde_yml::Value::Number(serde_yml::Number::from(120)),
612        );
613
614        // Test rule disable (false)
615        config_map.insert("MD025".to_string(), serde_yml::Value::Bool(false));
616
617        // Test rule enable (true)
618        config_map.insert("MD026".to_string(), serde_yml::Value::Bool(true));
619
620        // Test another rule with configuration
621        config_map.insert(
622            "MD003".to_string(),
623            serde_yml::Value::Mapping({
624                let mut map = serde_yml::Mapping::new();
625                map.insert(
626                    serde_yml::Value::String("style".to_string()),
627                    serde_yml::Value::String("atx".to_string()),
628                );
629                map
630            }),
631        );
632
633        let mdl_config = MarkdownlintConfig(config_map);
634        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.yaml"));
635
636        // Check that line-length (MD013) was properly configured
637        assert!(fragment.rules.contains_key("MD013"));
638        let md013_config = &fragment.rules["MD013"];
639        assert!(md013_config.values.contains_key("line-length"));
640        assert_eq!(md013_config.values["line-length"].value, toml::Value::Integer(120));
641
642        // Check disabled rule
643        assert!(fragment.global.disable.value.contains(&"MD025".to_string()));
644
645        // When default is absent (= true), boolean true is no-op — no enable list
646        assert!(
647            !fragment.global.enable.value.contains(&"MD026".to_string()),
648            "Boolean true should be no-op when default is absent (treated as true)"
649        );
650        assert!(fragment.global.enable.value.is_empty());
651
652        // Check rule configuration
653        assert!(fragment.rules.contains_key("MD003"));
654        let md003_config = &fragment.rules["MD003"];
655        assert!(md003_config.values.contains_key("style"));
656    }
657
658    #[test]
659    fn test_edge_cases() {
660        let mut config_map = HashMap::new();
661
662        // Test empty config
663        let empty_config = MarkdownlintConfig(HashMap::new());
664        let sourced = empty_config.map_to_sourced_rumdl_config(None);
665        assert!(sourced.rules.is_empty());
666
667        // Test unknown rule (should be ignored)
668        config_map.insert("unknown-rule".to_string(), serde_yml::Value::Bool(true));
669        config_map.insert("MD999".to_string(), serde_yml::Value::Bool(true));
670
671        let config = MarkdownlintConfig(config_map);
672        let sourced = config.map_to_sourced_rumdl_config(None);
673        assert!(sourced.rules.is_empty()); // Unknown rules should be ignored
674    }
675
676    #[test]
677    fn test_complex_rule_configurations() {
678        let mut config_map = HashMap::new();
679
680        // Test MD044 with array configuration
681        config_map.insert(
682            "MD044".to_string(),
683            serde_yml::Value::Mapping({
684                let mut map = serde_yml::Mapping::new();
685                map.insert(
686                    serde_yml::Value::String("names".to_string()),
687                    serde_yml::Value::Sequence(vec![
688                        serde_yml::Value::String("JavaScript".to_string()),
689                        serde_yml::Value::String("GitHub".to_string()),
690                    ]),
691                );
692                map
693            }),
694        );
695
696        // Test nested configuration
697        config_map.insert(
698            "MD003".to_string(),
699            serde_yml::Value::Mapping({
700                let mut map = serde_yml::Mapping::new();
701                map.insert(
702                    serde_yml::Value::String("style".to_string()),
703                    serde_yml::Value::String("atx".to_string()),
704                );
705                map
706            }),
707        );
708
709        let mdl_config = MarkdownlintConfig(config_map);
710        let sourced = mdl_config.map_to_sourced_rumdl_config(None);
711
712        // Verify MD044 configuration
713        assert!(sourced.rules.contains_key("MD044"));
714        let md044_config = &sourced.rules["MD044"];
715        assert!(md044_config.values.contains_key("names"));
716
717        // Verify MD003 configuration
718        assert!(sourced.rules.contains_key("MD003"));
719        let md003_config = &sourced.rules["MD003"];
720        assert!(md003_config.values.contains_key("style"));
721        assert_eq!(
722            md003_config.values["style"].value,
723            toml::Value::String("atx".to_string())
724        );
725    }
726
727    #[test]
728    fn test_value_types() {
729        let mut config_map = HashMap::new();
730
731        // Test different value types
732        config_map.insert(
733            "MD007".to_string(),
734            serde_yml::Value::Number(serde_yml::Number::from(4)),
735        ); // Simple number
736        config_map.insert(
737            "MD009".to_string(),
738            serde_yml::Value::Mapping({
739                let mut map = serde_yml::Mapping::new();
740                map.insert(
741                    serde_yml::Value::String("br_spaces".to_string()),
742                    serde_yml::Value::Number(serde_yml::Number::from(2)),
743                );
744                map.insert(
745                    serde_yml::Value::String("strict".to_string()),
746                    serde_yml::Value::Bool(true),
747                );
748                map
749            }),
750        );
751
752        let mdl_config = MarkdownlintConfig(config_map);
753        let sourced = mdl_config.map_to_sourced_rumdl_config(None);
754
755        // Check simple number value
756        assert!(sourced.rules.contains_key("MD007"));
757        assert!(sourced.rules["MD007"].values.contains_key("value"));
758
759        // Check complex configuration
760        assert!(sourced.rules.contains_key("MD009"));
761        let md009_config = &sourced.rules["MD009"];
762        assert!(md009_config.values.contains_key("br-spaces"));
763        assert!(md009_config.values.contains_key("strict"));
764    }
765
766    #[test]
767    fn test_all_rule_aliases() {
768        // Test that all documented aliases map correctly
769        let aliases = vec![
770            ("heading-increment", "MD001"),
771            ("heading-style", "MD003"),
772            ("ul-style", "MD004"),
773            ("list-indent", "MD005"),
774            ("ul-indent", "MD007"),
775            ("no-trailing-spaces", "MD009"),
776            ("no-hard-tabs", "MD010"),
777            ("no-reversed-links", "MD011"),
778            ("no-multiple-blanks", "MD012"),
779            ("line-length", "MD013"),
780            ("commands-show-output", "MD014"),
781            // MD015-017 don't exist in markdownlint
782            ("no-missing-space-atx", "MD018"),
783            ("no-multiple-space-atx", "MD019"),
784            ("no-missing-space-closed-atx", "MD020"),
785            ("no-multiple-space-closed-atx", "MD021"),
786            ("blanks-around-headings", "MD022"),
787            ("heading-start-left", "MD023"),
788            ("no-duplicate-heading", "MD024"),
789            ("single-title", "MD025"),
790            ("single-h1", "MD025"),
791            ("no-trailing-punctuation", "MD026"),
792            ("no-multiple-space-blockquote", "MD027"),
793            ("no-blanks-blockquote", "MD028"),
794            ("ol-prefix", "MD029"),
795            ("list-marker-space", "MD030"),
796            ("blanks-around-fences", "MD031"),
797            ("blanks-around-lists", "MD032"),
798            ("no-inline-html", "MD033"),
799            ("no-bare-urls", "MD034"),
800            ("hr-style", "MD035"),
801            ("no-emphasis-as-heading", "MD036"),
802            ("no-space-in-emphasis", "MD037"),
803            ("no-space-in-code", "MD038"),
804            ("no-space-in-links", "MD039"),
805            ("fenced-code-language", "MD040"),
806            ("first-line-heading", "MD041"),
807            ("first-line-h1", "MD041"),
808            ("no-empty-links", "MD042"),
809            ("required-headings", "MD043"),
810            ("proper-names", "MD044"),
811            ("no-alt-text", "MD045"),
812            ("code-block-style", "MD046"),
813            ("single-trailing-newline", "MD047"),
814            ("code-fence-style", "MD048"),
815            ("emphasis-style", "MD049"),
816            ("strong-style", "MD050"),
817            ("link-fragments", "MD051"),
818            ("reference-links-images", "MD052"),
819            ("link-image-reference-definitions", "MD053"),
820            ("link-image-style", "MD054"),
821            ("table-pipe-style", "MD055"),
822            ("table-column-count", "MD056"),
823            ("existing-relative-links", "MD057"),
824            ("blanks-around-tables", "MD058"),
825            ("descriptive-link-text", "MD059"),
826            ("table-cell-alignment", "MD060"),
827            ("table-format", "MD060"),
828            ("forbidden-terms", "MD061"),
829            ("nested-code-fence", "MD070"),
830            ("blank-line-after-frontmatter", "MD071"),
831            ("frontmatter-key-sort", "MD072"),
832        ];
833
834        for (alias, expected) in aliases {
835            assert_eq!(
836                markdownlint_to_rumdl_rule_key(alias),
837                Some(expected),
838                "Alias {alias} should map to {expected}"
839            );
840        }
841    }
842
843    #[test]
844    fn test_default_true_with_boolean_rules() {
845        // default: true + MD001: true + MD013: { line_length: 120 }
846        // Expected: no enable list (all rules already on), no disable list, MD013 config preserved
847        let mut config_map = HashMap::new();
848        config_map.insert("default".to_string(), serde_yml::Value::Bool(true));
849        config_map.insert("MD001".to_string(), serde_yml::Value::Bool(true));
850        config_map.insert(
851            "MD013".to_string(),
852            serde_yml::Value::Mapping({
853                let mut map = serde_yml::Mapping::new();
854                map.insert(
855                    serde_yml::Value::String("line_length".to_string()),
856                    serde_yml::Value::Number(serde_yml::Number::from(120)),
857                );
858                map
859            }),
860        );
861
862        let mdl_config = MarkdownlintConfig(config_map);
863        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.yaml"));
864
865        // No enable list: boolean true is no-op when default is true
866        assert!(
867            fragment.global.enable.value.is_empty(),
868            "Enable list should be empty when default: true"
869        );
870        // No disable list
871        assert!(fragment.global.disable.value.is_empty(), "Disable list should be empty");
872        // MD013 config preserved
873        assert!(fragment.rules.contains_key("MD013"));
874        assert_eq!(
875            fragment.rules["MD013"].values["line-length"].value,
876            toml::Value::Integer(120)
877        );
878    }
879
880    #[test]
881    fn test_default_false_with_boolean_and_config_rules() {
882        // default: false + MD001: true + MD013: { line_length: 120 }
883        // Expected: enable list contains both MD001 and MD013
884        let mut config_map = HashMap::new();
885        config_map.insert("default".to_string(), serde_yml::Value::Bool(false));
886        config_map.insert("MD001".to_string(), serde_yml::Value::Bool(true));
887        config_map.insert(
888            "MD013".to_string(),
889            serde_yml::Value::Mapping({
890                let mut map = serde_yml::Mapping::new();
891                map.insert(
892                    serde_yml::Value::String("line_length".to_string()),
893                    serde_yml::Value::Number(serde_yml::Number::from(120)),
894                );
895                map
896            }),
897        );
898
899        let mdl_config = MarkdownlintConfig(config_map);
900        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.yaml"));
901
902        let mut enabled_sorted = fragment.global.enable.value.clone();
903        enabled_sorted.sort();
904        assert_eq!(
905            enabled_sorted,
906            vec!["MD001", "MD013"],
907            "Both boolean-true and config-object rules should be in enable list"
908        );
909        assert!(fragment.global.disable.value.is_empty(), "No rules should be disabled");
910        // MD013 config preserved
911        assert!(fragment.rules.contains_key("MD013"));
912        assert_eq!(
913            fragment.rules["MD013"].values["line-length"].value,
914            toml::Value::Integer(120)
915        );
916    }
917
918    #[test]
919    fn test_default_absent_with_boolean_rules() {
920        // No `default` key + MD001: true → same as default: true (no enable list)
921        let mut config_map = HashMap::new();
922        config_map.insert("MD001".to_string(), serde_yml::Value::Bool(true));
923        config_map.insert("MD009".to_string(), serde_yml::Value::Bool(false));
924
925        let mdl_config = MarkdownlintConfig(config_map);
926        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.yaml"));
927
928        // No enable list: true is no-op when default is absent (treated as true)
929        assert!(
930            fragment.global.enable.value.is_empty(),
931            "Enable list should be empty when default is absent"
932        );
933        // MD009 should be disabled
934        assert_eq!(fragment.global.disable.value, vec!["MD009"]);
935    }
936
937    #[test]
938    fn test_default_false_only_booleans() {
939        // default: false + MD001: true + MD009: false
940        // Expected: enable list = [MD001], no disable list (false is no-op when default: false)
941        let mut config_map = HashMap::new();
942        config_map.insert("default".to_string(), serde_yml::Value::Bool(false));
943        config_map.insert("MD001".to_string(), serde_yml::Value::Bool(true));
944        config_map.insert("MD009".to_string(), serde_yml::Value::Bool(false));
945
946        let mdl_config = MarkdownlintConfig(config_map);
947        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.yaml"));
948
949        assert_eq!(fragment.global.enable.value, vec!["MD001"]);
950        assert!(
951            fragment.global.disable.value.is_empty(),
952            "Disable list should be empty when default: false (false is no-op)"
953        );
954    }
955
956    #[test]
957    fn test_default_true_with_boolean_rules_legacy() {
958        // Test the legacy map_to_sourced_rumdl_config path
959        let mut config_map = HashMap::new();
960        config_map.insert("default".to_string(), serde_yml::Value::Bool(true));
961        config_map.insert("MD001".to_string(), serde_yml::Value::Bool(true));
962        config_map.insert("MD009".to_string(), serde_yml::Value::Bool(false));
963        config_map.insert(
964            "MD013".to_string(),
965            serde_yml::Value::Mapping({
966                let mut map = serde_yml::Mapping::new();
967                map.insert(
968                    serde_yml::Value::String("line_length".to_string()),
969                    serde_yml::Value::Number(serde_yml::Number::from(120)),
970                );
971                map
972            }),
973        );
974
975        let mdl_config = MarkdownlintConfig(config_map);
976        let sourced = mdl_config.map_to_sourced_rumdl_config(Some("test.yaml"));
977
978        // No enable list: boolean true is no-op when default is true
979        assert!(sourced.global.enable.value.is_empty());
980        // MD009 should be disabled
981        assert_eq!(sourced.global.disable.value, vec!["MD009"]);
982        // MD013 config preserved
983        assert!(sourced.rules.contains_key("MD013"));
984        assert_eq!(
985            sourced.rules["MD013"].values["line-length"].value,
986            toml::Value::Integer(120)
987        );
988    }
989
990    #[test]
991    fn test_default_false_with_config_rules_legacy() {
992        // Test the legacy path with default: false
993        let mut config_map = HashMap::new();
994        config_map.insert("default".to_string(), serde_yml::Value::Bool(false));
995        config_map.insert("MD001".to_string(), serde_yml::Value::Bool(true));
996        config_map.insert(
997            "MD013".to_string(),
998            serde_yml::Value::Mapping({
999                let mut map = serde_yml::Mapping::new();
1000                map.insert(
1001                    serde_yml::Value::String("line_length".to_string()),
1002                    serde_yml::Value::Number(serde_yml::Number::from(120)),
1003                );
1004                map
1005            }),
1006        );
1007
1008        let mdl_config = MarkdownlintConfig(config_map);
1009        let sourced = mdl_config.map_to_sourced_rumdl_config(Some("test.yaml"));
1010
1011        let mut enabled_sorted = sourced.global.enable.value.clone();
1012        enabled_sorted.sort();
1013        assert_eq!(enabled_sorted, vec!["MD001", "MD013"]);
1014        assert!(sourced.global.disable.value.is_empty());
1015    }
1016
1017    #[test]
1018    fn test_default_false_no_rules_disables_everything() {
1019        // default: false with no other rules should result in an empty-but-explicit enable list
1020        let mut config_map = HashMap::new();
1021        config_map.insert("default".to_string(), serde_yml::Value::Bool(false));
1022
1023        let mdl_config = MarkdownlintConfig(config_map);
1024        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.yaml"));
1025
1026        // Enable list is empty but was explicitly set (source should be ProjectConfig, not Default)
1027        assert!(fragment.global.enable.value.is_empty());
1028        assert_eq!(
1029            fragment.global.enable.source,
1030            crate::config::ConfigSource::ProjectConfig,
1031            "Enable source should be ProjectConfig when default: false"
1032        );
1033    }
1034
1035    #[test]
1036    fn test_default_false_only_false_rules_disables_everything() {
1037        // default: false + MD001: false → no rules enabled, enable list is explicit
1038        let mut config_map = HashMap::new();
1039        config_map.insert("default".to_string(), serde_yml::Value::Bool(false));
1040        config_map.insert("MD001".to_string(), serde_yml::Value::Bool(false));
1041
1042        let mdl_config = MarkdownlintConfig(config_map);
1043        let fragment = mdl_config.map_to_sourced_rumdl_config_fragment(Some("test.yaml"));
1044
1045        assert!(fragment.global.enable.value.is_empty());
1046        assert_eq!(
1047            fragment.global.enable.source,
1048            crate::config::ConfigSource::ProjectConfig,
1049        );
1050    }
1051}