Skip to main content

rumdl_lib/
rule_config_serde.rs

1/// Serde-based configuration system for rules
2///
3/// This module provides a modern, type-safe configuration system inspired by Ruff's approach.
4/// It eliminates manual TOML construction and provides automatic serialization/deserialization.
5use serde::Serialize;
6use serde::de::DeserializeOwned;
7
8/// Trait for rule configurations
9pub trait RuleConfig: Serialize + DeserializeOwned + Default + Clone {
10    /// The rule name (e.g., "MD009")
11    const RULE_NAME: &'static str;
12}
13
14/// Helper to load rule configuration from the global config
15///
16/// This function will emit warnings to stderr if the configuration is invalid,
17/// helping users identify and fix configuration errors.
18pub fn load_rule_config<T: RuleConfig>(config: &crate::config::Config) -> T {
19    config
20        .rules
21        .get(T::RULE_NAME)
22        .and_then(|rule_config| {
23            // Build the TOML table with backwards compatibility mappings
24            let mut table = toml::map::Map::new();
25
26            for (k, v) in &rule_config.values {
27                // No manual mapping needed - serde aliases handle this
28                table.insert(k.clone(), v.clone());
29            }
30
31            let toml_table = toml::Value::Table(table);
32
33            // Deserialize directly from TOML, which preserves serde attributes
34            match toml_table.try_into::<T>() {
35                Ok(config) => Some(config),
36                Err(e) => {
37                    // Emit a warning about the invalid configuration
38                    eprintln!("Warning: Invalid configuration for rule {}: {}", T::RULE_NAME, e);
39                    eprintln!("Using default values for rule {}.", T::RULE_NAME);
40                    eprintln!("Hint: Check the documentation for valid configuration values.");
41
42                    None
43                }
44            }
45        })
46        .unwrap_or_default()
47}
48
49/// Sentinel value used in config schema tables to represent nullable (Option) fields.
50/// When a rule config field is `Option<T>` with default `None`, JSON serialization produces
51/// `null`, which `json_to_toml_value` drops. This sentinel preserves the key in the schema
52/// so config validation recognizes it as valid.
53const NULLABLE_SENTINEL: &str = "\0__nullable__";
54
55/// Sentinel value used in config schema tables to mark fields that accept multiple TOML
56/// types (e.g. a custom deserializer that takes either a scalar or a list). The schema
57/// is derived from a serialized default which can only encode one type, so without a
58/// sentinel the validator would reject the documented alternative form. Marking the key
59/// polymorphic tells the validator to accept any value type for that key while still
60/// preserving key-name validation and alias resolution.
61const POLYMORPHIC_SENTINEL: &str = "\0__polymorphic__";
62
63/// Returns true if the TOML value is a nullable sentinel placeholder.
64pub fn is_nullable_sentinel(value: &toml::Value) -> bool {
65    matches!(value, toml::Value::String(s) if s == NULLABLE_SENTINEL)
66}
67
68/// Returns true if the TOML value is a polymorphic sentinel placeholder.
69pub fn is_polymorphic_sentinel(value: &toml::Value) -> bool {
70    matches!(value, toml::Value::String(s) if s == POLYMORPHIC_SENTINEL)
71}
72
73/// Construct a polymorphic sentinel TOML value. Used by the `RuleRegistry` to overwrite
74/// schema entries for keys returned by `Rule::polymorphic_config_keys()`, so the validator
75/// skips the type check rather than flagging the alternative form as invalid. Rules must
76/// not call this from `default_config_section()` — the sentinel is a schema-only concern
77/// and would leak into user-facing output (e.g. `rumdl config --defaults`).
78pub fn polymorphic_sentinel_value() -> toml::Value {
79    toml::Value::String(POLYMORPHIC_SENTINEL.to_string())
80}
81
82/// Build a TOML schema table from a rule config struct, preserving nullable (Option) keys.
83///
84/// Unlike the standard JSON→TOML path (which drops null keys), this function inserts a
85/// sentinel value for null JSON fields so the key still appears in the schema. The sentinel
86/// is filtered out by `RuleRegistry::expected_value_for()` to skip type checking for those keys.
87pub fn config_schema_table<T: RuleConfig>(config: &T) -> Option<toml::map::Map<String, toml::Value>> {
88    let json_value = serde_json::to_value(config).ok()?;
89    let obj = json_value.as_object()?;
90    let mut table = toml::map::Map::new();
91    for (k, v) in obj {
92        if v.is_null() {
93            table.insert(k.clone(), toml::Value::String(NULLABLE_SENTINEL.to_string()));
94        } else {
95            // Use the converted value, or fall back to a sentinel if conversion fails.
96            // Every field in the config struct should appear in the schema for key validation.
97            let toml_v = json_to_toml_value(v).unwrap_or_else(|| toml::Value::String(NULLABLE_SENTINEL.to_string()));
98            table.insert(k.clone(), toml_v);
99        }
100    }
101    Some(table)
102}
103
104/// Default config section for a rule backed by a serde `RuleConfig` struct.
105///
106/// Serializes `T::default()` through the JSON→TOML path, which drops nullable
107/// (`None`) fields. Returns `None` when no fields remain. Rules whose
108/// `Option`-typed keys must stay visible to config validation use
109/// [`nullable_config_section_for`] instead.
110pub fn default_config_section_for<T: RuleConfig>() -> Option<(String, toml::Value)> {
111    let json_value = serde_json::to_value(T::default()).ok()?;
112    let toml_value = json_to_toml_value(&json_value)?;
113    match toml_value {
114        toml::Value::Table(table) if !table.is_empty() => Some((T::RULE_NAME.to_string(), toml::Value::Table(table))),
115        _ => None,
116    }
117}
118
119/// Default config section that keeps nullable (`None`) fields visible as
120/// schema sentinels, so key validation recognizes `Option`-typed settings.
121/// Returns `None` when no fields remain, like [`default_config_section_for`].
122pub fn nullable_config_section_for<T: RuleConfig>() -> Option<(String, toml::Value)> {
123    let table = config_schema_table(&T::default())?;
124    if table.is_empty() {
125        return None;
126    }
127    Some((T::RULE_NAME.to_string(), toml::Value::Table(table)))
128}
129
130/// Implements `default_config_section` and `from_config` for a rule backed by
131/// a serde `RuleConfig` struct. Use inside the rule's `impl Rule` block; the
132/// rule must provide a `from_config_struct(config)` constructor.
133///
134/// The default arm drops nullable (`None`) fields from the config section;
135/// the `nullable` arm keeps them visible as schema sentinels so config
136/// validation recognizes `Option`-typed keys.
137#[macro_export]
138macro_rules! impl_rule_config_methods {
139    ($config_ty:ty) => {
140        fn default_config_section(&self) -> Option<(String, toml::Value)> {
141            $crate::rule_config_serde::default_config_section_for::<$config_ty>()
142        }
143
144        fn from_config(config: &$crate::config::Config) -> Box<dyn $crate::rule::Rule>
145        where
146            Self: Sized,
147        {
148            Box::new(Self::from_config_struct(
149                $crate::rule_config_serde::load_rule_config::<$config_ty>(config),
150            ))
151        }
152    };
153    ($config_ty:ty, nullable) => {
154        fn default_config_section(&self) -> Option<(String, toml::Value)> {
155            $crate::rule_config_serde::nullable_config_section_for::<$config_ty>()
156        }
157
158        fn from_config(config: &$crate::config::Config) -> Box<dyn $crate::rule::Rule>
159        where
160            Self: Sized,
161        {
162            Box::new(Self::from_config_struct(
163                $crate::rule_config_serde::load_rule_config::<$config_ty>(config),
164            ))
165        }
166    };
167}
168
169/// Convert JSON value to TOML value for default config generation
170pub fn json_to_toml_value(json_val: &serde_json::Value) -> Option<toml::Value> {
171    match json_val {
172        serde_json::Value::Null => None,
173        serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
174        serde_json::Value::Number(n) => {
175            if let Some(i) = n.as_i64() {
176                Some(toml::Value::Integer(i))
177            } else {
178                n.as_f64().map(toml::Value::Float)
179            }
180        }
181        serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
182        serde_json::Value::Array(arr) => {
183            let toml_arr: Vec<_> = arr.iter().filter_map(json_to_toml_value).collect();
184            Some(toml::Value::Array(toml_arr))
185        }
186        serde_json::Value::Object(obj) => {
187            let mut toml_table = toml::map::Map::new();
188            for (k, v) in obj {
189                if let Some(toml_v) = json_to_toml_value(v) {
190                    toml_table.insert(k.clone(), toml_v);
191                }
192            }
193            Some(toml::Value::Table(toml_table))
194        }
195    }
196}
197
198/// Check if a key looks like a rule name (MD### format)
199///
200/// Rule names must start with "MD" (case-insensitive) followed by digits.
201pub fn is_rule_name(name: &str) -> bool {
202    let upper = name.to_ascii_uppercase();
203    upper.starts_with("MD") && upper.len() >= 4 && upper[2..].chars().all(|c| c.is_ascii_digit())
204}
205
206/// Result of converting JSON to RuleConfig, with any warnings
207#[derive(Debug, Default)]
208pub struct RuleConfigConversion {
209    /// The converted rule configuration
210    pub config: Option<crate::config::RuleConfig>,
211    /// Warnings about invalid or ignored values
212    pub warnings: Vec<String>,
213}
214
215/// Convert a JSON rule configuration to an internal RuleConfig
216///
217/// Supports all rule configuration options including:
218/// - `severity`: "error", "warning", or "info"
219/// - Any rule-specific options (converted from JSON to TOML values)
220///
221/// Returns `None` if the JSON value is not an object.
222pub fn json_to_rule_config(json_value: &serde_json::Value) -> Option<crate::config::RuleConfig> {
223    json_to_rule_config_with_warnings(json_value).config
224}
225
226/// Convert a JSON rule configuration to an internal RuleConfig, collecting warnings
227///
228/// Like `json_to_rule_config`, but also returns warnings for invalid values.
229/// Use this when you want to report configuration issues to the user.
230pub fn json_to_rule_config_with_warnings(json_value: &serde_json::Value) -> RuleConfigConversion {
231    use std::collections::BTreeMap;
232
233    let mut result = RuleConfigConversion::default();
234
235    let Some(obj) = json_value.as_object() else {
236        result.warnings.push(format!(
237            "Expected object for rule config, got {}",
238            json_type_name(json_value)
239        ));
240        return result;
241    };
242
243    let mut values = BTreeMap::new();
244    let mut severity = None;
245
246    for (key, val) in obj {
247        // Handle severity specially
248        if key == "severity" {
249            if let Some(s) = val.as_str() {
250                match s.to_lowercase().as_str() {
251                    "error" => severity = Some(crate::rule::Severity::Error),
252                    "warning" => severity = Some(crate::rule::Severity::Warning),
253                    "info" => severity = Some(crate::rule::Severity::Info),
254                    _ => {
255                        result.warnings.push(format!(
256                            "Invalid severity '{s}', expected 'error', 'warning', or 'info'"
257                        ));
258                    }
259                }
260            } else {
261                result
262                    .warnings
263                    .push(format!("Severity must be a string, got {}", json_type_name(val)));
264            }
265            continue;
266        }
267
268        // Convert JSON value to TOML value
269        if let Some(toml_val) = json_to_toml_value(val) {
270            values.insert(key.clone(), toml_val);
271        } else if !val.is_null() {
272            result
273                .warnings
274                .push(format!("Could not convert '{key}' value to config format"));
275        }
276    }
277
278    result.config = Some(crate::config::RuleConfig { severity, values });
279    result
280}
281
282/// Get a human-readable type name for a JSON value
283fn json_type_name(val: &serde_json::Value) -> &'static str {
284    match val {
285        serde_json::Value::Null => "null",
286        serde_json::Value::Bool(_) => "boolean",
287        serde_json::Value::Number(_) => "number",
288        serde_json::Value::String(_) => "string",
289        serde_json::Value::Array(_) => "array",
290        serde_json::Value::Object(_) => "object",
291    }
292}
293
294/// Convert TOML value to JSON value
295pub fn toml_value_to_json(toml_val: &toml::Value) -> Option<serde_json::Value> {
296    match toml_val {
297        toml::Value::String(s) => Some(serde_json::Value::String(s.clone())),
298        toml::Value::Integer(i) => Some(serde_json::json!(i)),
299        toml::Value::Float(f) => Some(serde_json::json!(f)),
300        toml::Value::Boolean(b) => Some(serde_json::Value::Bool(*b)),
301        toml::Value::Array(arr) => {
302            let json_arr: Vec<_> = arr.iter().filter_map(toml_value_to_json).collect();
303            Some(serde_json::Value::Array(json_arr))
304        }
305        toml::Value::Table(table) => {
306            let mut json_obj = serde_json::Map::new();
307            for (k, v) in table {
308                if let Some(json_v) = toml_value_to_json(v) {
309                    json_obj.insert(k.clone(), json_v);
310                }
311            }
312            Some(serde_json::Value::Object(json_obj))
313        }
314        toml::Value::Datetime(_) => None, // JSON doesn't have a native datetime type
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use serde::{Deserialize, Serialize};
322    use std::collections::BTreeMap;
323
324    // Test configuration struct
325    #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
326    #[serde(default)]
327    struct TestRuleConfig {
328        #[serde(default)]
329        enabled: bool,
330        #[serde(default)]
331        indent: i64,
332        #[serde(default)]
333        style: String,
334        #[serde(default)]
335        items: Vec<String>,
336    }
337
338    impl RuleConfig for TestRuleConfig {
339        const RULE_NAME: &'static str = "TEST001";
340    }
341
342    /// Config struct with nullable (Option) fields for testing sentinel behavior.
343    /// Mirrors the pattern used by MD072Config: no `skip_serializing_if`, so
344    /// `serde_json::to_value` produces `null` for None fields (which we convert to sentinels).
345    #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
346    #[serde(default)]
347    struct NullableTestConfig {
348        #[serde(default)]
349        enabled: bool,
350        #[serde(default, alias = "key-order")]
351        key_order: Option<Vec<String>>,
352        #[serde(default, alias = "title-pattern")]
353        title_pattern: Option<String>,
354    }
355
356    impl RuleConfig for NullableTestConfig {
357        const RULE_NAME: &'static str = "TEST_NULLABLE";
358    }
359
360    #[test]
361    fn test_is_nullable_sentinel() {
362        let sentinel = toml::Value::String(NULLABLE_SENTINEL.to_string());
363        assert!(is_nullable_sentinel(&sentinel));
364
365        let regular = toml::Value::String("normal".to_string());
366        assert!(!is_nullable_sentinel(&regular));
367
368        let integer = toml::Value::Integer(42);
369        assert!(!is_nullable_sentinel(&integer));
370    }
371
372    #[test]
373    fn test_is_polymorphic_sentinel() {
374        let sentinel = polymorphic_sentinel_value();
375        assert!(is_polymorphic_sentinel(&sentinel));
376
377        // Polymorphic and nullable sentinels are distinct
378        let nullable = toml::Value::String(NULLABLE_SENTINEL.to_string());
379        assert!(!is_polymorphic_sentinel(&nullable));
380        assert!(!is_nullable_sentinel(&sentinel));
381
382        let regular = toml::Value::String("normal".to_string());
383        assert!(!is_polymorphic_sentinel(&regular));
384    }
385
386    #[test]
387    fn test_config_schema_table_preserves_nullable_keys() {
388        let config = NullableTestConfig::default();
389        let table = config_schema_table(&config).unwrap();
390
391        // All keys should be present, including the Option fields
392        assert!(table.contains_key("enabled"), "enabled key missing");
393        assert!(table.contains_key("key_order"), "key_order key missing");
394        assert!(table.contains_key("title_pattern"), "title_pattern key missing");
395
396        // Option fields should have sentinel values
397        assert!(is_nullable_sentinel(table.get("key_order").unwrap()));
398        assert!(is_nullable_sentinel(table.get("title_pattern").unwrap()));
399
400        // Non-option field should have real value
401        assert_eq!(table.get("enabled"), Some(&toml::Value::Boolean(false)));
402    }
403
404    #[test]
405    fn test_config_schema_table_non_null_option_uses_real_value() {
406        let config = NullableTestConfig {
407            enabled: true,
408            key_order: Some(vec!["title".to_string(), "date".to_string()]),
409            title_pattern: Some("pattern".to_string()),
410        };
411        let table = config_schema_table(&config).unwrap();
412
413        // Non-null Option fields should have real TOML values
414        let key_order = table.get("key_order").unwrap();
415        assert!(!is_nullable_sentinel(key_order));
416        assert!(matches!(key_order, toml::Value::Array(_)));
417
418        let title_pattern = table.get("title_pattern").unwrap();
419        assert!(!is_nullable_sentinel(title_pattern));
420        assert_eq!(title_pattern, &toml::Value::String("pattern".to_string()));
421    }
422
423    #[test]
424    fn test_json_to_toml_value_still_drops_null() {
425        // The existing json_to_toml_value behavior is preserved
426        assert!(json_to_toml_value(&serde_json::Value::Null).is_none());
427    }
428
429    #[test]
430    fn test_config_schema_table_all_keys_present() {
431        let config = NullableTestConfig::default();
432        let table = config_schema_table(&config).unwrap();
433        assert_eq!(table.len(), 3, "Expected 3 keys: enabled, key_order, title_pattern");
434    }
435
436    #[test]
437    fn test_config_schema_table_never_drops_keys() {
438        // Every field in a config struct must appear in the schema table,
439        // even if json_to_toml_value would fail for its value type.
440        // Build a JSON object manually with a value that json_to_toml_value drops (null).
441        let mut obj = serde_json::Map::new();
442        obj.insert("real_key".to_string(), serde_json::json!(42));
443        obj.insert("null_key".to_string(), serde_json::Value::Null);
444        let json = serde_json::Value::Object(obj);
445
446        // Simulate config_schema_table logic directly
447        let obj = json.as_object().unwrap();
448        let mut table = toml::map::Map::new();
449        for (k, v) in obj {
450            if v.is_null() {
451                table.insert(k.clone(), toml::Value::String(NULLABLE_SENTINEL.to_string()));
452            } else {
453                let toml_v =
454                    json_to_toml_value(v).unwrap_or_else(|| toml::Value::String(NULLABLE_SENTINEL.to_string()));
455                table.insert(k.clone(), toml_v);
456            }
457        }
458
459        assert_eq!(table.len(), 2, "Both keys must be present");
460        assert!(table.contains_key("real_key"));
461        assert!(table.contains_key("null_key"));
462    }
463
464    #[test]
465    fn test_toml_value_to_json_basic_types() {
466        // String
467        let toml_str = toml::Value::String("hello".to_string());
468        let json_str = toml_value_to_json(&toml_str).unwrap();
469        assert_eq!(json_str, serde_json::Value::String("hello".to_string()));
470
471        // Integer
472        let toml_int = toml::Value::Integer(42);
473        let json_int = toml_value_to_json(&toml_int).unwrap();
474        assert_eq!(json_int, serde_json::json!(42));
475
476        // Float
477        let toml_float = toml::Value::Float(1.234);
478        let json_float = toml_value_to_json(&toml_float).unwrap();
479        assert_eq!(json_float, serde_json::json!(1.234));
480
481        // Boolean
482        let toml_bool = toml::Value::Boolean(true);
483        let json_bool = toml_value_to_json(&toml_bool).unwrap();
484        assert_eq!(json_bool, serde_json::Value::Bool(true));
485    }
486
487    #[test]
488    fn test_toml_value_to_json_complex_types() {
489        // Array
490        let toml_arr = toml::Value::Array(vec![
491            toml::Value::String("a".to_string()),
492            toml::Value::String("b".to_string()),
493        ]);
494        let json_arr = toml_value_to_json(&toml_arr).unwrap();
495        assert_eq!(json_arr, serde_json::json!(["a", "b"]));
496
497        // Table
498        let mut toml_table = toml::map::Map::new();
499        toml_table.insert("key1".to_string(), toml::Value::String("value1".to_string()));
500        toml_table.insert("key2".to_string(), toml::Value::Integer(123));
501        let toml_tbl = toml::Value::Table(toml_table);
502        let json_tbl = toml_value_to_json(&toml_tbl).unwrap();
503
504        let expected = serde_json::json!({
505            "key1": "value1",
506            "key2": 123
507        });
508        assert_eq!(json_tbl, expected);
509    }
510
511    #[test]
512    fn test_toml_value_to_json_datetime() {
513        // Datetime should return None
514        let toml_dt = toml::Value::Datetime("2023-01-01T00:00:00Z".parse().unwrap());
515        assert!(toml_value_to_json(&toml_dt).is_none());
516    }
517
518    #[test]
519    fn test_json_to_toml_value_basic_types() {
520        // Null
521        assert!(json_to_toml_value(&serde_json::Value::Null).is_none());
522
523        // Bool
524        let json_bool = serde_json::Value::Bool(false);
525        let toml_bool = json_to_toml_value(&json_bool).unwrap();
526        assert_eq!(toml_bool, toml::Value::Boolean(false));
527
528        // Integer
529        let json_int = serde_json::json!(42);
530        let toml_int = json_to_toml_value(&json_int).unwrap();
531        assert_eq!(toml_int, toml::Value::Integer(42));
532
533        // Float
534        let json_float = serde_json::json!(1.234);
535        let toml_float = json_to_toml_value(&json_float).unwrap();
536        assert_eq!(toml_float, toml::Value::Float(1.234));
537
538        // String
539        let json_str = serde_json::Value::String("test".to_string());
540        let toml_str = json_to_toml_value(&json_str).unwrap();
541        assert_eq!(toml_str, toml::Value::String("test".to_string()));
542    }
543
544    #[test]
545    fn test_json_to_toml_value_complex_types() {
546        // Array
547        let json_arr = serde_json::json!(["x", "y", "z"]);
548        let toml_arr = json_to_toml_value(&json_arr).unwrap();
549        if let toml::Value::Array(arr) = toml_arr {
550            assert_eq!(arr.len(), 3);
551            assert_eq!(arr[0], toml::Value::String("x".to_string()));
552            assert_eq!(arr[1], toml::Value::String("y".to_string()));
553            assert_eq!(arr[2], toml::Value::String("z".to_string()));
554        } else {
555            panic!("Expected array");
556        }
557
558        // Object
559        let json_obj = serde_json::json!({
560            "name": "test",
561            "count": 10,
562            "active": true
563        });
564        let toml_obj = json_to_toml_value(&json_obj).unwrap();
565        if let toml::Value::Table(table) = toml_obj {
566            assert_eq!(table.get("name"), Some(&toml::Value::String("test".to_string())));
567            assert_eq!(table.get("count"), Some(&toml::Value::Integer(10)));
568            assert_eq!(table.get("active"), Some(&toml::Value::Boolean(true)));
569        } else {
570            panic!("Expected table");
571        }
572    }
573
574    #[test]
575    fn test_load_rule_config_default() {
576        // Create empty config
577        let config = crate::config::Config::default();
578
579        // Load config for test rule - should return default
580        let rule_config: TestRuleConfig = load_rule_config(&config);
581        assert_eq!(rule_config, TestRuleConfig::default());
582    }
583
584    #[test]
585    fn test_load_rule_config_with_values() {
586        // Create config with rule values
587        let mut config = crate::config::Config::default();
588        let mut rule_values = BTreeMap::new();
589        rule_values.insert("enabled".to_string(), toml::Value::Boolean(true));
590        rule_values.insert("indent".to_string(), toml::Value::Integer(4));
591        rule_values.insert("style".to_string(), toml::Value::String("consistent".to_string()));
592        rule_values.insert(
593            "items".to_string(),
594            toml::Value::Array(vec![
595                toml::Value::String("item1".to_string()),
596                toml::Value::String("item2".to_string()),
597            ]),
598        );
599
600        config.rules.insert(
601            "TEST001".to_string(),
602            crate::config::RuleConfig {
603                severity: None,
604                values: rule_values,
605            },
606        );
607
608        // Load config
609        let rule_config: TestRuleConfig = load_rule_config(&config);
610        assert!(rule_config.enabled);
611        assert_eq!(rule_config.indent, 4);
612        assert_eq!(rule_config.style, "consistent");
613        assert_eq!(rule_config.items, vec!["item1", "item2"]);
614    }
615
616    #[test]
617    fn test_load_rule_config_partial() {
618        // Create config with partial rule values
619        let mut config = crate::config::Config::default();
620        let mut rule_values = BTreeMap::new();
621        rule_values.insert("enabled".to_string(), toml::Value::Boolean(true));
622        rule_values.insert("style".to_string(), toml::Value::String("custom".to_string()));
623
624        config.rules.insert(
625            "TEST001".to_string(),
626            crate::config::RuleConfig {
627                severity: None,
628                values: rule_values,
629            },
630        );
631
632        // Load config - missing fields should use defaults from TestRuleConfig::default()
633        let rule_config: TestRuleConfig = load_rule_config(&config);
634        assert!(rule_config.enabled); // from config
635        assert_eq!(rule_config.indent, 0); // default i64
636        assert_eq!(rule_config.style, "custom"); // from config
637        assert_eq!(rule_config.items, Vec::<String>::new()); // default empty vec
638    }
639
640    #[test]
641    fn test_conversion_roundtrip() {
642        // Test that we can convert TOML -> JSON -> TOML
643        let original = toml::Value::Table({
644            let mut table = toml::map::Map::new();
645            table.insert("string".to_string(), toml::Value::String("test".to_string()));
646            table.insert("number".to_string(), toml::Value::Integer(42));
647            table.insert("bool".to_string(), toml::Value::Boolean(true));
648            table.insert(
649                "array".to_string(),
650                toml::Value::Array(vec![
651                    toml::Value::String("a".to_string()),
652                    toml::Value::String("b".to_string()),
653                ]),
654            );
655            table
656        });
657
658        let json = toml_value_to_json(&original).unwrap();
659        let back_to_toml = json_to_toml_value(&json).unwrap();
660
661        assert_eq!(original, back_to_toml);
662    }
663
664    #[test]
665    fn test_edge_cases() {
666        // Empty array
667        let empty_arr = toml::Value::Array(vec![]);
668        let json_arr = toml_value_to_json(&empty_arr).unwrap();
669        assert_eq!(json_arr, serde_json::json!([]));
670
671        // Empty table
672        let empty_table = toml::Value::Table(toml::map::Map::new());
673        let json_table = toml_value_to_json(&empty_table).unwrap();
674        assert_eq!(json_table, serde_json::json!({}));
675
676        // Nested structures
677        let nested = toml::Value::Table({
678            let mut outer = toml::map::Map::new();
679            outer.insert(
680                "inner".to_string(),
681                toml::Value::Table({
682                    let mut inner = toml::map::Map::new();
683                    inner.insert("value".to_string(), toml::Value::Integer(123));
684                    inner
685                }),
686            );
687            outer
688        });
689        let json_nested = toml_value_to_json(&nested).unwrap();
690        assert_eq!(
691            json_nested,
692            serde_json::json!({
693                "inner": {
694                    "value": 123
695                }
696            })
697        );
698    }
699
700    #[test]
701    fn test_float_edge_cases() {
702        // NaN and infinity are not valid JSON numbers
703        let nan = serde_json::Number::from_f64(f64::NAN);
704        assert!(nan.is_none());
705
706        let inf = serde_json::Number::from_f64(f64::INFINITY);
707        assert!(inf.is_none());
708
709        // Valid float
710        let valid_float = toml::Value::Float(1.23);
711        let json_float = toml_value_to_json(&valid_float).unwrap();
712        assert_eq!(json_float, serde_json::json!(1.23));
713    }
714
715    #[test]
716    fn test_invalid_config_returns_default() {
717        // Create config with unknown field
718        let mut config = crate::config::Config::default();
719        let mut rule_values = BTreeMap::new();
720        rule_values.insert("unknown_field".to_string(), toml::Value::Boolean(true));
721        // Use a table value for items, which expects an array
722        rule_values.insert("items".to_string(), toml::Value::Table(toml::map::Map::new()));
723
724        config.rules.insert(
725            "TEST001".to_string(),
726            crate::config::RuleConfig {
727                severity: None,
728                values: rule_values,
729            },
730        );
731
732        // Load config - should return default and print warning
733        let rule_config: TestRuleConfig = load_rule_config(&config);
734        // Should use default values since deserialization failed
735        assert_eq!(rule_config, TestRuleConfig::default());
736    }
737
738    #[test]
739    fn test_invalid_field_type() {
740        // Create config with wrong type for field
741        let mut config = crate::config::Config::default();
742        let mut rule_values = BTreeMap::new();
743        // indent should be i64, but we're providing a string
744        rule_values.insert("indent".to_string(), toml::Value::String("not_a_number".to_string()));
745
746        config.rules.insert(
747            "TEST001".to_string(),
748            crate::config::RuleConfig {
749                severity: None,
750                values: rule_values,
751            },
752        );
753
754        // Load config - should return default and print warning
755        let rule_config: TestRuleConfig = load_rule_config(&config);
756        assert_eq!(rule_config, TestRuleConfig::default());
757    }
758
759    // ========== Tests for is_rule_name ==========
760
761    #[test]
762    fn test_is_rule_name_valid() {
763        // Standard rule names
764        assert!(is_rule_name("MD001"));
765        assert!(is_rule_name("MD060"));
766        assert!(is_rule_name("MD123"));
767        assert!(is_rule_name("MD999"));
768
769        // Case insensitive
770        assert!(is_rule_name("md001"));
771        assert!(is_rule_name("Md060"));
772        assert!(is_rule_name("mD123"));
773
774        // Longer numbers
775        assert!(is_rule_name("MD0001"));
776        assert!(is_rule_name("MD12345"));
777    }
778
779    #[test]
780    fn test_is_rule_name_invalid() {
781        // Too short
782        assert!(!is_rule_name("MD"));
783        assert!(!is_rule_name("MD1"));
784        assert!(!is_rule_name("M"));
785        assert!(!is_rule_name(""));
786
787        // Non-rule identifiers
788        assert!(!is_rule_name("disable"));
789        assert!(!is_rule_name("enable"));
790        assert!(!is_rule_name("flavor"));
791        assert!(!is_rule_name("line-length"));
792        assert!(!is_rule_name("global"));
793
794        // Invalid format
795        assert!(!is_rule_name("MDA01")); // non-digit after MD
796        assert!(!is_rule_name("XD001")); // doesn't start with MD
797        assert!(!is_rule_name("MD00A")); // non-digit in number
798        assert!(!is_rule_name("1MD001")); // starts with number
799        assert!(!is_rule_name("MD-001")); // hyphen in number
800    }
801
802    // ========== Tests for json_to_rule_config ==========
803
804    #[test]
805    fn test_json_to_rule_config_simple() {
806        let json = serde_json::json!({
807            "enabled": true,
808            "style": "aligned"
809        });
810
811        let rule_config = json_to_rule_config(&json).unwrap();
812
813        assert_eq!(rule_config.values.get("enabled"), Some(&toml::Value::Boolean(true)));
814        assert_eq!(
815            rule_config.values.get("style"),
816            Some(&toml::Value::String("aligned".to_string()))
817        );
818        assert!(rule_config.severity.is_none());
819    }
820
821    #[test]
822    fn test_json_to_rule_config_with_numbers() {
823        let json = serde_json::json!({
824            "line-length": 120,
825            "max-width": 0,
826            "indent": 4
827        });
828
829        let rule_config = json_to_rule_config(&json).unwrap();
830
831        assert_eq!(rule_config.values.get("line-length"), Some(&toml::Value::Integer(120)));
832        assert_eq!(rule_config.values.get("max-width"), Some(&toml::Value::Integer(0)));
833        assert_eq!(rule_config.values.get("indent"), Some(&toml::Value::Integer(4)));
834    }
835
836    #[test]
837    fn test_json_to_rule_config_with_arrays() {
838        let json = serde_json::json!({
839            "names": ["JavaScript", "TypeScript", "React"],
840            "exclude-patterns": ["*.test.md", "draft-*"]
841        });
842
843        let rule_config = json_to_rule_config(&json).unwrap();
844
845        let expected_names = toml::Value::Array(vec![
846            toml::Value::String("JavaScript".to_string()),
847            toml::Value::String("TypeScript".to_string()),
848            toml::Value::String("React".to_string()),
849        ]);
850        assert_eq!(rule_config.values.get("names"), Some(&expected_names));
851
852        let expected_patterns = toml::Value::Array(vec![
853            toml::Value::String("*.test.md".to_string()),
854            toml::Value::String("draft-*".to_string()),
855        ]);
856        assert_eq!(rule_config.values.get("exclude-patterns"), Some(&expected_patterns));
857    }
858
859    #[test]
860    fn test_json_to_rule_config_with_severity() {
861        // Error severity
862        let json = serde_json::json!({
863            "severity": "error",
864            "style": "aligned"
865        });
866        let rule_config = json_to_rule_config(&json).unwrap();
867        assert_eq!(rule_config.severity, Some(crate::rule::Severity::Error));
868        assert!(!rule_config.values.contains_key("severity")); // severity should not be in values
869
870        // Warning severity
871        let json = serde_json::json!({
872            "severity": "warning",
873            "enabled": true
874        });
875        let rule_config = json_to_rule_config(&json).unwrap();
876        assert_eq!(rule_config.severity, Some(crate::rule::Severity::Warning));
877
878        // Info severity
879        let json = serde_json::json!({
880            "severity": "info"
881        });
882        let rule_config = json_to_rule_config(&json).unwrap();
883        assert_eq!(rule_config.severity, Some(crate::rule::Severity::Info));
884
885        // Case insensitive severity
886        let json = serde_json::json!({
887            "severity": "ERROR"
888        });
889        let rule_config = json_to_rule_config(&json).unwrap();
890        assert_eq!(rule_config.severity, Some(crate::rule::Severity::Error));
891    }
892
893    #[test]
894    fn test_json_to_rule_config_invalid_severity() {
895        // Invalid severity string
896        let json = serde_json::json!({
897            "severity": "critical",
898            "style": "aligned"
899        });
900        let rule_config = json_to_rule_config(&json).unwrap();
901        assert!(rule_config.severity.is_none()); // invalid severity is ignored
902        assert_eq!(
903            rule_config.values.get("style"),
904            Some(&toml::Value::String("aligned".to_string()))
905        );
906
907        // Non-string severity
908        let json = serde_json::json!({
909            "severity": 1,
910            "enabled": true
911        });
912        let rule_config = json_to_rule_config(&json).unwrap();
913        assert!(rule_config.severity.is_none()); // non-string severity is ignored
914    }
915
916    #[test]
917    fn test_json_to_rule_config_non_object() {
918        // Non-object values should return None
919        assert!(json_to_rule_config(&serde_json::json!(42)).is_none());
920        assert!(json_to_rule_config(&serde_json::json!("string")).is_none());
921        assert!(json_to_rule_config(&serde_json::json!(true)).is_none());
922        assert!(json_to_rule_config(&serde_json::json!([1, 2, 3])).is_none());
923        assert!(json_to_rule_config(&serde_json::Value::Null).is_none());
924    }
925
926    #[test]
927    fn test_json_to_rule_config_empty_object() {
928        let json = serde_json::json!({});
929        let rule_config = json_to_rule_config(&json).unwrap();
930        assert!(rule_config.values.is_empty());
931        assert!(rule_config.severity.is_none());
932    }
933
934    #[test]
935    fn test_json_to_rule_config_nested_objects() {
936        // Nested objects should be converted to TOML tables
937        let json = serde_json::json!({
938            "options": {
939                "nested-key": "nested-value",
940                "nested-number": 42
941            }
942        });
943
944        let rule_config = json_to_rule_config(&json).unwrap();
945
946        let options = rule_config.values.get("options").unwrap();
947        if let toml::Value::Table(table) = options {
948            assert_eq!(
949                table.get("nested-key"),
950                Some(&toml::Value::String("nested-value".to_string()))
951            );
952            assert_eq!(table.get("nested-number"), Some(&toml::Value::Integer(42)));
953        } else {
954            panic!("options should be a table");
955        }
956    }
957
958    #[test]
959    fn test_json_to_rule_config_md060_example() {
960        // Real-world MD060 config example
961        let json = serde_json::json!({
962            "enabled": true,
963            "style": "aligned",
964            "max-width": 120,
965            "column-align": "auto",
966            "loose-last-column": false
967        });
968
969        let rule_config = json_to_rule_config(&json).unwrap();
970
971        assert_eq!(rule_config.values.get("enabled"), Some(&toml::Value::Boolean(true)));
972        assert_eq!(
973            rule_config.values.get("style"),
974            Some(&toml::Value::String("aligned".to_string()))
975        );
976        assert_eq!(rule_config.values.get("max-width"), Some(&toml::Value::Integer(120)));
977        assert_eq!(
978            rule_config.values.get("column-align"),
979            Some(&toml::Value::String("auto".to_string()))
980        );
981        assert_eq!(
982            rule_config.values.get("loose-last-column"),
983            Some(&toml::Value::Boolean(false))
984        );
985    }
986
987    #[test]
988    fn test_json_to_rule_config_md044_example() {
989        // Real-world MD044 config example
990        let json = serde_json::json!({
991            "names": ["JavaScript", "TypeScript", "GitHub", "macOS"],
992            "code-blocks": false,
993            "html-elements": false
994        });
995
996        let rule_config = json_to_rule_config(&json).unwrap();
997
998        let expected_names = toml::Value::Array(vec![
999            toml::Value::String("JavaScript".to_string()),
1000            toml::Value::String("TypeScript".to_string()),
1001            toml::Value::String("GitHub".to_string()),
1002            toml::Value::String("macOS".to_string()),
1003        ]);
1004        assert_eq!(rule_config.values.get("names"), Some(&expected_names));
1005        assert_eq!(
1006            rule_config.values.get("code-blocks"),
1007            Some(&toml::Value::Boolean(false))
1008        );
1009        assert_eq!(
1010            rule_config.values.get("html-elements"),
1011            Some(&toml::Value::Boolean(false))
1012        );
1013    }
1014
1015    // ========== Tests for json_to_rule_config_with_warnings ==========
1016
1017    #[test]
1018    fn test_json_to_rule_config_with_warnings_valid() {
1019        let json = serde_json::json!({
1020            "severity": "error",
1021            "enabled": true
1022        });
1023
1024        let result = json_to_rule_config_with_warnings(&json);
1025
1026        assert!(result.config.is_some());
1027        assert!(
1028            result.warnings.is_empty(),
1029            "Expected no warnings, got: {:?}",
1030            result.warnings
1031        );
1032        assert_eq!(result.config.unwrap().severity, Some(crate::rule::Severity::Error));
1033    }
1034
1035    #[test]
1036    fn test_json_to_rule_config_with_warnings_invalid_severity() {
1037        let json = serde_json::json!({
1038            "severity": "critical",
1039            "style": "aligned"
1040        });
1041
1042        let result = json_to_rule_config_with_warnings(&json);
1043
1044        assert!(result.config.is_some());
1045        assert_eq!(result.warnings.len(), 1);
1046        assert!(result.warnings[0].contains("Invalid severity 'critical'"));
1047        // Config should still be created, just without severity
1048        assert!(result.config.unwrap().severity.is_none());
1049    }
1050
1051    #[test]
1052    fn test_json_to_rule_config_with_warnings_wrong_severity_type() {
1053        let json = serde_json::json!({
1054            "severity": 123,
1055            "enabled": true
1056        });
1057
1058        let result = json_to_rule_config_with_warnings(&json);
1059
1060        assert!(result.config.is_some());
1061        assert_eq!(result.warnings.len(), 1);
1062        assert!(result.warnings[0].contains("Severity must be a string"));
1063    }
1064
1065    #[test]
1066    fn test_json_to_rule_config_with_warnings_non_object() {
1067        let json = serde_json::json!("not an object");
1068
1069        let result = json_to_rule_config_with_warnings(&json);
1070
1071        assert!(result.config.is_none());
1072        assert_eq!(result.warnings.len(), 1);
1073        assert!(result.warnings[0].contains("Expected object"));
1074    }
1075
1076    // ========== Integration tests for Config population ==========
1077
1078    #[test]
1079    fn test_rule_config_integration_with_config() {
1080        // Test that converted rule configs work with the main Config struct
1081        let mut config = crate::config::Config::default();
1082
1083        // Simulate what WASM API does: convert JSON to RuleConfig and add to config
1084        let md060_json = serde_json::json!({
1085            "enabled": true,
1086            "style": "aligned",
1087            "max-width": 120
1088        });
1089        let md013_json = serde_json::json!({
1090            "line-length": 100,
1091            "code-blocks": false
1092        });
1093
1094        if let Some(md060_config) = json_to_rule_config(&md060_json) {
1095            config.rules.insert("MD060".to_string(), md060_config);
1096        }
1097        if let Some(md013_config) = json_to_rule_config(&md013_json) {
1098            config.rules.insert("MD013".to_string(), md013_config);
1099        }
1100
1101        // Verify the configs are in place
1102        assert!(config.rules.contains_key("MD060"));
1103        assert!(config.rules.contains_key("MD013"));
1104
1105        // Verify values can be retrieved
1106        let md060 = config.rules.get("MD060").unwrap();
1107        assert_eq!(md060.values.get("enabled"), Some(&toml::Value::Boolean(true)));
1108        assert_eq!(
1109            md060.values.get("style"),
1110            Some(&toml::Value::String("aligned".to_string()))
1111        );
1112        assert_eq!(md060.values.get("max-width"), Some(&toml::Value::Integer(120)));
1113    }
1114
1115    #[test]
1116    fn test_rule_config_integration_with_severity() {
1117        let mut config = crate::config::Config::default();
1118
1119        let json = serde_json::json!({
1120            "severity": "error",
1121            "enabled": true
1122        });
1123
1124        if let Some(rule_config) = json_to_rule_config(&json) {
1125            config.rules.insert("MD041".to_string(), rule_config);
1126        }
1127
1128        let md041 = config.rules.get("MD041").unwrap();
1129        assert_eq!(md041.severity, Some(crate::rule::Severity::Error));
1130    }
1131
1132    #[test]
1133    fn test_rule_config_integration_case_normalization() {
1134        // Test that rule names are handled correctly (caller should normalize)
1135        let mut config = crate::config::Config::default();
1136
1137        let json = serde_json::json!({ "enabled": true });
1138
1139        // Test various case inputs - caller is responsible for normalization
1140        for rule_name in ["md060", "MD060", "Md060"] {
1141            if is_rule_name(rule_name)
1142                && let Some(rule_config) = json_to_rule_config(&json)
1143            {
1144                config.rules.insert(rule_name.to_ascii_uppercase(), rule_config);
1145            }
1146        }
1147
1148        // All should normalize to MD060
1149        assert!(config.rules.contains_key("MD060"));
1150        assert_eq!(config.rules.len(), 1); // Only one entry after normalization
1151    }
1152
1153    #[test]
1154    fn test_rule_config_integration_filters_non_rules() {
1155        // Test that is_rule_name correctly filters non-rule keys
1156        let keys = ["MD060", "disable", "enable", "flavor", "line-length", "global"];
1157
1158        let rule_keys: Vec<_> = keys.iter().filter(|k| is_rule_name(k)).collect();
1159
1160        assert_eq!(rule_keys, vec![&"MD060"]);
1161    }
1162
1163    #[test]
1164    fn test_multiple_rule_configs_with_mixed_validity() {
1165        // Test handling multiple rules where some have warnings
1166        let rules = vec![
1167            ("MD060", serde_json::json!({ "severity": "error", "style": "aligned" })),
1168            (
1169                "MD013",
1170                serde_json::json!({ "severity": "invalid", "line-length": 100 }),
1171            ),
1172            ("MD041", serde_json::json!({ "enabled": true })),
1173        ];
1174
1175        let mut config = crate::config::Config::default();
1176        let mut all_warnings = Vec::new();
1177
1178        for (name, json) in rules {
1179            let result = json_to_rule_config_with_warnings(&json);
1180            all_warnings.extend(result.warnings);
1181            if let Some(rule_config) = result.config {
1182                config.rules.insert(name.to_string(), rule_config);
1183            }
1184        }
1185
1186        // All rules should be added
1187        assert_eq!(config.rules.len(), 3);
1188
1189        // Should have one warning about invalid severity
1190        assert_eq!(all_warnings.len(), 1);
1191        assert!(all_warnings[0].contains("Invalid severity"));
1192
1193        // MD060 should have severity, MD013 should not
1194        assert_eq!(
1195            config.rules.get("MD060").unwrap().severity,
1196            Some(crate::rule::Severity::Error)
1197        );
1198        assert!(config.rules.get("MD013").unwrap().severity.is_none());
1199    }
1200
1201    // ========== End-to-end integration tests ==========
1202    // These tests verify the full flow: JSON config -> RuleConfig -> Config -> actual linting
1203
1204    #[test]
1205    fn test_end_to_end_md013_line_length_config() {
1206        // Test that MD013 line-length config actually affects linting behavior
1207        let content = "# Test\n\nThis is a line that is exactly 50 characters long.\n";
1208
1209        // Create config with line-length = 40 (should trigger warning)
1210        let mut config = crate::config::Config::default();
1211        let json = serde_json::json!({
1212            "line-length": 40
1213        });
1214        if let Some(rule_config) = json_to_rule_config(&json) {
1215            config.rules.insert("MD013".to_string(), rule_config);
1216        }
1217
1218        // Only enable MD013 for this test
1219        config.global.enable = vec!["MD013".to_string()];
1220
1221        let rules = crate::rules::all_rules(&config);
1222        let filtered = crate::rules::filter_rules(&rules, &config.global);
1223
1224        let result = crate::lint(
1225            content,
1226            &filtered,
1227            false,
1228            crate::config::MarkdownFlavor::Standard,
1229            None,
1230            Some(&config),
1231        );
1232
1233        let warnings = result.expect("Linting should succeed");
1234
1235        // Should have MD013 warning because line exceeds 40 chars
1236        let has_md013 = warnings.iter().any(|w| w.rule_name.as_deref() == Some("MD013"));
1237        assert!(has_md013, "Should have MD013 warning with line-length=40");
1238    }
1239
1240    #[test]
1241    fn test_end_to_end_md013_line_length_no_warning() {
1242        // Same content but with higher line-length limit - no warning
1243        let content = "# Test\n\nThis is a line that is exactly 50 characters long.\n";
1244
1245        // Create config with line-length = 100 (should NOT trigger warning)
1246        let mut config = crate::config::Config::default();
1247        let json = serde_json::json!({
1248            "line-length": 100
1249        });
1250        if let Some(rule_config) = json_to_rule_config(&json) {
1251            config.rules.insert("MD013".to_string(), rule_config);
1252        }
1253
1254        // Only enable MD013 for this test
1255        config.global.enable = vec!["MD013".to_string()];
1256
1257        let rules = crate::rules::all_rules(&config);
1258        let filtered = crate::rules::filter_rules(&rules, &config.global);
1259
1260        let result = crate::lint(
1261            content,
1262            &filtered,
1263            false,
1264            crate::config::MarkdownFlavor::Standard,
1265            None,
1266            Some(&config),
1267        );
1268
1269        let warnings = result.expect("Linting should succeed");
1270
1271        // Should NOT have MD013 warning because line is under 100 chars
1272        let has_md013 = warnings.iter().any(|w| w.rule_name.as_deref() == Some("MD013"));
1273        assert!(!has_md013, "Should NOT have MD013 warning with line-length=100");
1274    }
1275
1276    #[test]
1277    fn test_end_to_end_md044_proper_names() {
1278        // Test that MD044 proper names config actually affects linting
1279        let content = "# Test\n\nWe use javascript and typescript.\n";
1280
1281        // Create config with proper names
1282        let mut config = crate::config::Config::default();
1283        let json = serde_json::json!({
1284            "names": ["JavaScript", "TypeScript"],
1285            "code-blocks": false
1286        });
1287        if let Some(rule_config) = json_to_rule_config(&json) {
1288            config.rules.insert("MD044".to_string(), rule_config);
1289        }
1290
1291        // Only enable MD044 for this test
1292        config.global.enable = vec!["MD044".to_string()];
1293
1294        let rules = crate::rules::all_rules(&config);
1295        let filtered = crate::rules::filter_rules(&rules, &config.global);
1296
1297        let result = crate::lint(
1298            content,
1299            &filtered,
1300            false,
1301            crate::config::MarkdownFlavor::Standard,
1302            None,
1303            Some(&config),
1304        );
1305
1306        let warnings = result.expect("Linting should succeed");
1307
1308        // Should have MD044 warnings for improper casing
1309        let md044_warnings: Vec<_> = warnings
1310            .iter()
1311            .filter(|w| w.rule_name.as_deref() == Some("MD044"))
1312            .collect();
1313
1314        assert!(
1315            md044_warnings.len() >= 2,
1316            "Should have MD044 warnings for 'javascript' and 'typescript', got {}",
1317            md044_warnings.len()
1318        );
1319    }
1320
1321    #[test]
1322    fn test_end_to_end_severity_config() {
1323        // Test that severity config is respected
1324        let content = "test\n"; // Missing heading, triggers MD041
1325
1326        let mut config = crate::config::Config::default();
1327        let json = serde_json::json!({
1328            "severity": "info"
1329        });
1330        if let Some(rule_config) = json_to_rule_config(&json) {
1331            config.rules.insert("MD041".to_string(), rule_config);
1332        }
1333
1334        // Only enable MD041 for this test
1335        config.global.enable = vec!["MD041".to_string()];
1336
1337        let rules = crate::rules::all_rules(&config);
1338        let filtered = crate::rules::filter_rules(&rules, &config.global);
1339
1340        let result = crate::lint(
1341            content,
1342            &filtered,
1343            false,
1344            crate::config::MarkdownFlavor::Standard,
1345            None,
1346            Some(&config),
1347        );
1348
1349        let warnings = result.expect("Linting should succeed");
1350
1351        // Find MD041 warning and verify severity
1352        let md041 = warnings.iter().find(|w| w.rule_name.as_deref() == Some("MD041"));
1353        assert!(md041.is_some(), "Should have MD041 warning");
1354        assert_eq!(
1355            md041.unwrap().severity,
1356            crate::rule::Severity::Info,
1357            "MD041 should have Info severity from config"
1358        );
1359    }
1360}