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