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