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.iter() {
27                // No manual mapping needed - serde aliases handle this
28                table.insert(k.clone(), v.clone());
29            }
30
31            let toml_table = toml::Value::Table(table);
32
33            // Deserialize directly from TOML, which preserves serde attributes
34            match toml_table.try_into::<T>() {
35                Ok(config) => Some(config),
36                Err(e) => {
37                    // Emit a warning about the invalid configuration
38                    eprintln!("Warning: Invalid configuration for rule {}: {}", T::RULE_NAME, e);
39                    eprintln!("Using default values for rule {}.", T::RULE_NAME);
40                    eprintln!("Hint: Check the documentation for valid configuration values.");
41
42                    None
43                }
44            }
45        })
46        .unwrap_or_default()
47}
48
49/// Convert JSON value to TOML value for default config generation
50pub fn json_to_toml_value(json_val: &serde_json::Value) -> Option<toml::Value> {
51    match json_val {
52        serde_json::Value::Null => None,
53        serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
54        serde_json::Value::Number(n) => {
55            if let Some(i) = n.as_i64() {
56                Some(toml::Value::Integer(i))
57            } else {
58                n.as_f64().map(toml::Value::Float)
59            }
60        }
61        serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
62        serde_json::Value::Array(arr) => {
63            let toml_arr: Vec<_> = arr.iter().filter_map(json_to_toml_value).collect();
64            Some(toml::Value::Array(toml_arr))
65        }
66        serde_json::Value::Object(obj) => {
67            let mut toml_table = toml::map::Map::new();
68            for (k, v) in obj {
69                if let Some(toml_v) = json_to_toml_value(v) {
70                    toml_table.insert(k.clone(), toml_v);
71                }
72            }
73            Some(toml::Value::Table(toml_table))
74        }
75    }
76}
77
78/// Check if a key looks like a rule name (MD### format)
79///
80/// Rule names must start with "MD" (case-insensitive) followed by digits.
81pub fn is_rule_name(name: &str) -> bool {
82    let upper = name.to_ascii_uppercase();
83    upper.starts_with("MD") && upper.len() >= 4 && upper[2..].chars().all(|c| c.is_ascii_digit())
84}
85
86/// Result of converting JSON to RuleConfig, with any warnings
87#[derive(Debug, Default)]
88pub struct RuleConfigConversion {
89    /// The converted rule configuration
90    pub config: Option<crate::config::RuleConfig>,
91    /// Warnings about invalid or ignored values
92    pub warnings: Vec<String>,
93}
94
95/// Convert a JSON rule configuration to an internal RuleConfig
96///
97/// Supports all rule configuration options including:
98/// - `severity`: "error", "warning", or "info"
99/// - Any rule-specific options (converted from JSON to TOML values)
100///
101/// Returns `None` if the JSON value is not an object.
102pub fn json_to_rule_config(json_value: &serde_json::Value) -> Option<crate::config::RuleConfig> {
103    json_to_rule_config_with_warnings(json_value).config
104}
105
106/// Convert a JSON rule configuration to an internal RuleConfig, collecting warnings
107///
108/// Like `json_to_rule_config`, but also returns warnings for invalid values.
109/// Use this when you want to report configuration issues to the user.
110pub fn json_to_rule_config_with_warnings(json_value: &serde_json::Value) -> RuleConfigConversion {
111    use std::collections::BTreeMap;
112
113    let mut result = RuleConfigConversion::default();
114
115    let Some(obj) = json_value.as_object() else {
116        result.warnings.push(format!(
117            "Expected object for rule config, got {}",
118            json_type_name(json_value)
119        ));
120        return result;
121    };
122
123    let mut values = BTreeMap::new();
124    let mut severity = None;
125
126    for (key, val) in obj {
127        // Handle severity specially
128        if key == "severity" {
129            if let Some(s) = val.as_str() {
130                match s.to_lowercase().as_str() {
131                    "error" => severity = Some(crate::rule::Severity::Error),
132                    "warning" => severity = Some(crate::rule::Severity::Warning),
133                    "info" => severity = Some(crate::rule::Severity::Info),
134                    _ => {
135                        result.warnings.push(format!(
136                            "Invalid severity '{s}', expected 'error', 'warning', or 'info'"
137                        ));
138                    }
139                };
140            } else {
141                result
142                    .warnings
143                    .push(format!("Severity must be a string, got {}", json_type_name(val)));
144            }
145            continue;
146        }
147
148        // Convert JSON value to TOML value
149        if let Some(toml_val) = json_to_toml_value(val) {
150            values.insert(key.clone(), toml_val);
151        } else if !val.is_null() {
152            result
153                .warnings
154                .push(format!("Could not convert '{key}' value to config format"));
155        }
156    }
157
158    result.config = Some(crate::config::RuleConfig { severity, values });
159    result
160}
161
162/// Get a human-readable type name for a JSON value
163fn json_type_name(val: &serde_json::Value) -> &'static str {
164    match val {
165        serde_json::Value::Null => "null",
166        serde_json::Value::Bool(_) => "boolean",
167        serde_json::Value::Number(_) => "number",
168        serde_json::Value::String(_) => "string",
169        serde_json::Value::Array(_) => "array",
170        serde_json::Value::Object(_) => "object",
171    }
172}
173
174/// Convert TOML value to JSON value
175pub fn toml_value_to_json(toml_val: &toml::Value) -> Option<serde_json::Value> {
176    match toml_val {
177        toml::Value::String(s) => Some(serde_json::Value::String(s.clone())),
178        toml::Value::Integer(i) => Some(serde_json::json!(i)),
179        toml::Value::Float(f) => Some(serde_json::json!(f)),
180        toml::Value::Boolean(b) => Some(serde_json::Value::Bool(*b)),
181        toml::Value::Array(arr) => {
182            let json_arr: Vec<_> = arr.iter().filter_map(toml_value_to_json).collect();
183            Some(serde_json::Value::Array(json_arr))
184        }
185        toml::Value::Table(table) => {
186            let mut json_obj = serde_json::Map::new();
187            for (k, v) in table {
188                if let Some(json_v) = toml_value_to_json(v) {
189                    json_obj.insert(k.clone(), json_v);
190                }
191            }
192            Some(serde_json::Value::Object(json_obj))
193        }
194        toml::Value::Datetime(_) => None, // JSON doesn't have a native datetime type
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use serde::{Deserialize, Serialize};
202    use std::collections::BTreeMap;
203
204    // Test configuration struct
205    #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
206    #[serde(default)]
207    struct TestRuleConfig {
208        #[serde(default)]
209        enabled: bool,
210        #[serde(default)]
211        indent: i64,
212        #[serde(default)]
213        style: String,
214        #[serde(default)]
215        items: Vec<String>,
216    }
217
218    impl RuleConfig for TestRuleConfig {
219        const RULE_NAME: &'static str = "TEST001";
220    }
221
222    #[test]
223    fn test_toml_value_to_json_basic_types() {
224        // String
225        let toml_str = toml::Value::String("hello".to_string());
226        let json_str = toml_value_to_json(&toml_str).unwrap();
227        assert_eq!(json_str, serde_json::Value::String("hello".to_string()));
228
229        // Integer
230        let toml_int = toml::Value::Integer(42);
231        let json_int = toml_value_to_json(&toml_int).unwrap();
232        assert_eq!(json_int, serde_json::json!(42));
233
234        // Float
235        let toml_float = toml::Value::Float(1.234);
236        let json_float = toml_value_to_json(&toml_float).unwrap();
237        assert_eq!(json_float, serde_json::json!(1.234));
238
239        // Boolean
240        let toml_bool = toml::Value::Boolean(true);
241        let json_bool = toml_value_to_json(&toml_bool).unwrap();
242        assert_eq!(json_bool, serde_json::Value::Bool(true));
243    }
244
245    #[test]
246    fn test_toml_value_to_json_complex_types() {
247        // Array
248        let toml_arr = toml::Value::Array(vec![
249            toml::Value::String("a".to_string()),
250            toml::Value::String("b".to_string()),
251        ]);
252        let json_arr = toml_value_to_json(&toml_arr).unwrap();
253        assert_eq!(json_arr, serde_json::json!(["a", "b"]));
254
255        // Table
256        let mut toml_table = toml::map::Map::new();
257        toml_table.insert("key1".to_string(), toml::Value::String("value1".to_string()));
258        toml_table.insert("key2".to_string(), toml::Value::Integer(123));
259        let toml_tbl = toml::Value::Table(toml_table);
260        let json_tbl = toml_value_to_json(&toml_tbl).unwrap();
261
262        let expected = serde_json::json!({
263            "key1": "value1",
264            "key2": 123
265        });
266        assert_eq!(json_tbl, expected);
267    }
268
269    #[test]
270    fn test_toml_value_to_json_datetime() {
271        // Datetime should return None
272        let toml_dt = toml::Value::Datetime("2023-01-01T00:00:00Z".parse().unwrap());
273        assert!(toml_value_to_json(&toml_dt).is_none());
274    }
275
276    #[test]
277    fn test_json_to_toml_value_basic_types() {
278        // Null
279        assert!(json_to_toml_value(&serde_json::Value::Null).is_none());
280
281        // Bool
282        let json_bool = serde_json::Value::Bool(false);
283        let toml_bool = json_to_toml_value(&json_bool).unwrap();
284        assert_eq!(toml_bool, toml::Value::Boolean(false));
285
286        // Integer
287        let json_int = serde_json::json!(42);
288        let toml_int = json_to_toml_value(&json_int).unwrap();
289        assert_eq!(toml_int, toml::Value::Integer(42));
290
291        // Float
292        let json_float = serde_json::json!(1.234);
293        let toml_float = json_to_toml_value(&json_float).unwrap();
294        assert_eq!(toml_float, toml::Value::Float(1.234));
295
296        // String
297        let json_str = serde_json::Value::String("test".to_string());
298        let toml_str = json_to_toml_value(&json_str).unwrap();
299        assert_eq!(toml_str, toml::Value::String("test".to_string()));
300    }
301
302    #[test]
303    fn test_json_to_toml_value_complex_types() {
304        // Array
305        let json_arr = serde_json::json!(["x", "y", "z"]);
306        let toml_arr = json_to_toml_value(&json_arr).unwrap();
307        if let toml::Value::Array(arr) = toml_arr {
308            assert_eq!(arr.len(), 3);
309            assert_eq!(arr[0], toml::Value::String("x".to_string()));
310            assert_eq!(arr[1], toml::Value::String("y".to_string()));
311            assert_eq!(arr[2], toml::Value::String("z".to_string()));
312        } else {
313            panic!("Expected array");
314        }
315
316        // Object
317        let json_obj = serde_json::json!({
318            "name": "test",
319            "count": 10,
320            "active": true
321        });
322        let toml_obj = json_to_toml_value(&json_obj).unwrap();
323        if let toml::Value::Table(table) = toml_obj {
324            assert_eq!(table.get("name"), Some(&toml::Value::String("test".to_string())));
325            assert_eq!(table.get("count"), Some(&toml::Value::Integer(10)));
326            assert_eq!(table.get("active"), Some(&toml::Value::Boolean(true)));
327        } else {
328            panic!("Expected table");
329        }
330    }
331
332    #[test]
333    fn test_load_rule_config_default() {
334        // Create empty config
335        let config = crate::config::Config::default();
336
337        // Load config for test rule - should return default
338        let rule_config: TestRuleConfig = load_rule_config(&config);
339        assert_eq!(rule_config, TestRuleConfig::default());
340    }
341
342    #[test]
343    fn test_load_rule_config_with_values() {
344        // Create config with rule values
345        let mut config = crate::config::Config::default();
346        let mut rule_values = BTreeMap::new();
347        rule_values.insert("enabled".to_string(), toml::Value::Boolean(true));
348        rule_values.insert("indent".to_string(), toml::Value::Integer(4));
349        rule_values.insert("style".to_string(), toml::Value::String("consistent".to_string()));
350        rule_values.insert(
351            "items".to_string(),
352            toml::Value::Array(vec![
353                toml::Value::String("item1".to_string()),
354                toml::Value::String("item2".to_string()),
355            ]),
356        );
357
358        config.rules.insert(
359            "TEST001".to_string(),
360            crate::config::RuleConfig {
361                severity: None,
362                values: rule_values,
363            },
364        );
365
366        // Load config
367        let rule_config: TestRuleConfig = load_rule_config(&config);
368        assert!(rule_config.enabled);
369        assert_eq!(rule_config.indent, 4);
370        assert_eq!(rule_config.style, "consistent");
371        assert_eq!(rule_config.items, vec!["item1", "item2"]);
372    }
373
374    #[test]
375    fn test_load_rule_config_partial() {
376        // Create config with partial rule values
377        let mut config = crate::config::Config::default();
378        let mut rule_values = BTreeMap::new();
379        rule_values.insert("enabled".to_string(), toml::Value::Boolean(true));
380        rule_values.insert("style".to_string(), toml::Value::String("custom".to_string()));
381
382        config.rules.insert(
383            "TEST001".to_string(),
384            crate::config::RuleConfig {
385                severity: None,
386                values: rule_values,
387            },
388        );
389
390        // Load config - missing fields should use defaults from TestRuleConfig::default()
391        let rule_config: TestRuleConfig = load_rule_config(&config);
392        assert!(rule_config.enabled); // from config
393        assert_eq!(rule_config.indent, 0); // default i64
394        assert_eq!(rule_config.style, "custom"); // from config
395        assert_eq!(rule_config.items, Vec::<String>::new()); // default empty vec
396    }
397
398    #[test]
399    fn test_conversion_roundtrip() {
400        // Test that we can convert TOML -> JSON -> TOML
401        let original = toml::Value::Table({
402            let mut table = toml::map::Map::new();
403            table.insert("string".to_string(), toml::Value::String("test".to_string()));
404            table.insert("number".to_string(), toml::Value::Integer(42));
405            table.insert("bool".to_string(), toml::Value::Boolean(true));
406            table.insert(
407                "array".to_string(),
408                toml::Value::Array(vec![
409                    toml::Value::String("a".to_string()),
410                    toml::Value::String("b".to_string()),
411                ]),
412            );
413            table
414        });
415
416        let json = toml_value_to_json(&original).unwrap();
417        let back_to_toml = json_to_toml_value(&json).unwrap();
418
419        assert_eq!(original, back_to_toml);
420    }
421
422    #[test]
423    fn test_edge_cases() {
424        // Empty array
425        let empty_arr = toml::Value::Array(vec![]);
426        let json_arr = toml_value_to_json(&empty_arr).unwrap();
427        assert_eq!(json_arr, serde_json::json!([]));
428
429        // Empty table
430        let empty_table = toml::Value::Table(toml::map::Map::new());
431        let json_table = toml_value_to_json(&empty_table).unwrap();
432        assert_eq!(json_table, serde_json::json!({}));
433
434        // Nested structures
435        let nested = toml::Value::Table({
436            let mut outer = toml::map::Map::new();
437            outer.insert(
438                "inner".to_string(),
439                toml::Value::Table({
440                    let mut inner = toml::map::Map::new();
441                    inner.insert("value".to_string(), toml::Value::Integer(123));
442                    inner
443                }),
444            );
445            outer
446        });
447        let json_nested = toml_value_to_json(&nested).unwrap();
448        assert_eq!(
449            json_nested,
450            serde_json::json!({
451                "inner": {
452                    "value": 123
453                }
454            })
455        );
456    }
457
458    #[test]
459    fn test_float_edge_cases() {
460        // NaN and infinity are not valid JSON numbers
461        let nan = serde_json::Number::from_f64(f64::NAN);
462        assert!(nan.is_none());
463
464        let inf = serde_json::Number::from_f64(f64::INFINITY);
465        assert!(inf.is_none());
466
467        // Valid float
468        let valid_float = toml::Value::Float(1.23);
469        let json_float = toml_value_to_json(&valid_float).unwrap();
470        assert_eq!(json_float, serde_json::json!(1.23));
471    }
472
473    #[test]
474    fn test_invalid_config_returns_default() {
475        // Create config with unknown field
476        let mut config = crate::config::Config::default();
477        let mut rule_values = BTreeMap::new();
478        rule_values.insert("unknown_field".to_string(), toml::Value::Boolean(true));
479        // Use a table value for items, which expects an array
480        rule_values.insert("items".to_string(), toml::Value::Table(toml::map::Map::new()));
481
482        config.rules.insert(
483            "TEST001".to_string(),
484            crate::config::RuleConfig {
485                severity: None,
486                values: rule_values,
487            },
488        );
489
490        // Load config - should return default and print warning
491        let rule_config: TestRuleConfig = load_rule_config(&config);
492        // Should use default values since deserialization failed
493        assert_eq!(rule_config, TestRuleConfig::default());
494    }
495
496    #[test]
497    fn test_invalid_field_type() {
498        // Create config with wrong type for field
499        let mut config = crate::config::Config::default();
500        let mut rule_values = BTreeMap::new();
501        // indent should be i64, but we're providing a string
502        rule_values.insert("indent".to_string(), toml::Value::String("not_a_number".to_string()));
503
504        config.rules.insert(
505            "TEST001".to_string(),
506            crate::config::RuleConfig {
507                severity: None,
508                values: rule_values,
509            },
510        );
511
512        // Load config - should return default and print warning
513        let rule_config: TestRuleConfig = load_rule_config(&config);
514        assert_eq!(rule_config, TestRuleConfig::default());
515    }
516
517    // ========== Tests for is_rule_name ==========
518
519    #[test]
520    fn test_is_rule_name_valid() {
521        // Standard rule names
522        assert!(is_rule_name("MD001"));
523        assert!(is_rule_name("MD060"));
524        assert!(is_rule_name("MD123"));
525        assert!(is_rule_name("MD999"));
526
527        // Case insensitive
528        assert!(is_rule_name("md001"));
529        assert!(is_rule_name("Md060"));
530        assert!(is_rule_name("mD123"));
531
532        // Longer numbers
533        assert!(is_rule_name("MD0001"));
534        assert!(is_rule_name("MD12345"));
535    }
536
537    #[test]
538    fn test_is_rule_name_invalid() {
539        // Too short
540        assert!(!is_rule_name("MD"));
541        assert!(!is_rule_name("MD1"));
542        assert!(!is_rule_name("M"));
543        assert!(!is_rule_name(""));
544
545        // Non-rule identifiers
546        assert!(!is_rule_name("disable"));
547        assert!(!is_rule_name("enable"));
548        assert!(!is_rule_name("flavor"));
549        assert!(!is_rule_name("line-length"));
550        assert!(!is_rule_name("global"));
551
552        // Invalid format
553        assert!(!is_rule_name("MDA01")); // non-digit after MD
554        assert!(!is_rule_name("XD001")); // doesn't start with MD
555        assert!(!is_rule_name("MD00A")); // non-digit in number
556        assert!(!is_rule_name("1MD001")); // starts with number
557        assert!(!is_rule_name("MD-001")); // hyphen in number
558    }
559
560    // ========== Tests for json_to_rule_config ==========
561
562    #[test]
563    fn test_json_to_rule_config_simple() {
564        let json = serde_json::json!({
565            "enabled": true,
566            "style": "aligned"
567        });
568
569        let rule_config = json_to_rule_config(&json).unwrap();
570
571        assert_eq!(rule_config.values.get("enabled"), Some(&toml::Value::Boolean(true)));
572        assert_eq!(
573            rule_config.values.get("style"),
574            Some(&toml::Value::String("aligned".to_string()))
575        );
576        assert!(rule_config.severity.is_none());
577    }
578
579    #[test]
580    fn test_json_to_rule_config_with_numbers() {
581        let json = serde_json::json!({
582            "line-length": 120,
583            "max-width": 0,
584            "indent": 4
585        });
586
587        let rule_config = json_to_rule_config(&json).unwrap();
588
589        assert_eq!(rule_config.values.get("line-length"), Some(&toml::Value::Integer(120)));
590        assert_eq!(rule_config.values.get("max-width"), Some(&toml::Value::Integer(0)));
591        assert_eq!(rule_config.values.get("indent"), Some(&toml::Value::Integer(4)));
592    }
593
594    #[test]
595    fn test_json_to_rule_config_with_arrays() {
596        let json = serde_json::json!({
597            "names": ["JavaScript", "TypeScript", "React"],
598            "exclude-patterns": ["*.test.md", "draft-*"]
599        });
600
601        let rule_config = json_to_rule_config(&json).unwrap();
602
603        let expected_names = toml::Value::Array(vec![
604            toml::Value::String("JavaScript".to_string()),
605            toml::Value::String("TypeScript".to_string()),
606            toml::Value::String("React".to_string()),
607        ]);
608        assert_eq!(rule_config.values.get("names"), Some(&expected_names));
609
610        let expected_patterns = toml::Value::Array(vec![
611            toml::Value::String("*.test.md".to_string()),
612            toml::Value::String("draft-*".to_string()),
613        ]);
614        assert_eq!(rule_config.values.get("exclude-patterns"), Some(&expected_patterns));
615    }
616
617    #[test]
618    fn test_json_to_rule_config_with_severity() {
619        // Error severity
620        let json = serde_json::json!({
621            "severity": "error",
622            "style": "aligned"
623        });
624        let rule_config = json_to_rule_config(&json).unwrap();
625        assert_eq!(rule_config.severity, Some(crate::rule::Severity::Error));
626        assert!(!rule_config.values.contains_key("severity")); // severity should not be in values
627
628        // Warning severity
629        let json = serde_json::json!({
630            "severity": "warning",
631            "enabled": true
632        });
633        let rule_config = json_to_rule_config(&json).unwrap();
634        assert_eq!(rule_config.severity, Some(crate::rule::Severity::Warning));
635
636        // Info severity
637        let json = serde_json::json!({
638            "severity": "info"
639        });
640        let rule_config = json_to_rule_config(&json).unwrap();
641        assert_eq!(rule_config.severity, Some(crate::rule::Severity::Info));
642
643        // Case insensitive severity
644        let json = serde_json::json!({
645            "severity": "ERROR"
646        });
647        let rule_config = json_to_rule_config(&json).unwrap();
648        assert_eq!(rule_config.severity, Some(crate::rule::Severity::Error));
649    }
650
651    #[test]
652    fn test_json_to_rule_config_invalid_severity() {
653        // Invalid severity string
654        let json = serde_json::json!({
655            "severity": "critical",
656            "style": "aligned"
657        });
658        let rule_config = json_to_rule_config(&json).unwrap();
659        assert!(rule_config.severity.is_none()); // invalid severity is ignored
660        assert_eq!(
661            rule_config.values.get("style"),
662            Some(&toml::Value::String("aligned".to_string()))
663        );
664
665        // Non-string severity
666        let json = serde_json::json!({
667            "severity": 1,
668            "enabled": true
669        });
670        let rule_config = json_to_rule_config(&json).unwrap();
671        assert!(rule_config.severity.is_none()); // non-string severity is ignored
672    }
673
674    #[test]
675    fn test_json_to_rule_config_non_object() {
676        // Non-object values should return None
677        assert!(json_to_rule_config(&serde_json::json!(42)).is_none());
678        assert!(json_to_rule_config(&serde_json::json!("string")).is_none());
679        assert!(json_to_rule_config(&serde_json::json!(true)).is_none());
680        assert!(json_to_rule_config(&serde_json::json!([1, 2, 3])).is_none());
681        assert!(json_to_rule_config(&serde_json::Value::Null).is_none());
682    }
683
684    #[test]
685    fn test_json_to_rule_config_empty_object() {
686        let json = serde_json::json!({});
687        let rule_config = json_to_rule_config(&json).unwrap();
688        assert!(rule_config.values.is_empty());
689        assert!(rule_config.severity.is_none());
690    }
691
692    #[test]
693    fn test_json_to_rule_config_nested_objects() {
694        // Nested objects should be converted to TOML tables
695        let json = serde_json::json!({
696            "options": {
697                "nested-key": "nested-value",
698                "nested-number": 42
699            }
700        });
701
702        let rule_config = json_to_rule_config(&json).unwrap();
703
704        let options = rule_config.values.get("options").unwrap();
705        if let toml::Value::Table(table) = options {
706            assert_eq!(
707                table.get("nested-key"),
708                Some(&toml::Value::String("nested-value".to_string()))
709            );
710            assert_eq!(table.get("nested-number"), Some(&toml::Value::Integer(42)));
711        } else {
712            panic!("options should be a table");
713        }
714    }
715
716    #[test]
717    fn test_json_to_rule_config_md060_example() {
718        // Real-world MD060 config example
719        let json = serde_json::json!({
720            "enabled": true,
721            "style": "aligned",
722            "max-width": 120,
723            "column-align": "auto",
724            "loose-last-column": false
725        });
726
727        let rule_config = json_to_rule_config(&json).unwrap();
728
729        assert_eq!(rule_config.values.get("enabled"), Some(&toml::Value::Boolean(true)));
730        assert_eq!(
731            rule_config.values.get("style"),
732            Some(&toml::Value::String("aligned".to_string()))
733        );
734        assert_eq!(rule_config.values.get("max-width"), Some(&toml::Value::Integer(120)));
735        assert_eq!(
736            rule_config.values.get("column-align"),
737            Some(&toml::Value::String("auto".to_string()))
738        );
739        assert_eq!(
740            rule_config.values.get("loose-last-column"),
741            Some(&toml::Value::Boolean(false))
742        );
743    }
744
745    #[test]
746    fn test_json_to_rule_config_md044_example() {
747        // Real-world MD044 config example
748        let json = serde_json::json!({
749            "names": ["JavaScript", "TypeScript", "GitHub", "macOS"],
750            "code-blocks": false,
751            "html-elements": false
752        });
753
754        let rule_config = json_to_rule_config(&json).unwrap();
755
756        let expected_names = toml::Value::Array(vec![
757            toml::Value::String("JavaScript".to_string()),
758            toml::Value::String("TypeScript".to_string()),
759            toml::Value::String("GitHub".to_string()),
760            toml::Value::String("macOS".to_string()),
761        ]);
762        assert_eq!(rule_config.values.get("names"), Some(&expected_names));
763        assert_eq!(
764            rule_config.values.get("code-blocks"),
765            Some(&toml::Value::Boolean(false))
766        );
767        assert_eq!(
768            rule_config.values.get("html-elements"),
769            Some(&toml::Value::Boolean(false))
770        );
771    }
772
773    // ========== Tests for json_to_rule_config_with_warnings ==========
774
775    #[test]
776    fn test_json_to_rule_config_with_warnings_valid() {
777        let json = serde_json::json!({
778            "severity": "error",
779            "enabled": true
780        });
781
782        let result = json_to_rule_config_with_warnings(&json);
783
784        assert!(result.config.is_some());
785        assert!(
786            result.warnings.is_empty(),
787            "Expected no warnings, got: {:?}",
788            result.warnings
789        );
790        assert_eq!(result.config.unwrap().severity, Some(crate::rule::Severity::Error));
791    }
792
793    #[test]
794    fn test_json_to_rule_config_with_warnings_invalid_severity() {
795        let json = serde_json::json!({
796            "severity": "critical",
797            "style": "aligned"
798        });
799
800        let result = json_to_rule_config_with_warnings(&json);
801
802        assert!(result.config.is_some());
803        assert_eq!(result.warnings.len(), 1);
804        assert!(result.warnings[0].contains("Invalid severity 'critical'"));
805        // Config should still be created, just without severity
806        assert!(result.config.unwrap().severity.is_none());
807    }
808
809    #[test]
810    fn test_json_to_rule_config_with_warnings_wrong_severity_type() {
811        let json = serde_json::json!({
812            "severity": 123,
813            "enabled": true
814        });
815
816        let result = json_to_rule_config_with_warnings(&json);
817
818        assert!(result.config.is_some());
819        assert_eq!(result.warnings.len(), 1);
820        assert!(result.warnings[0].contains("Severity must be a string"));
821    }
822
823    #[test]
824    fn test_json_to_rule_config_with_warnings_non_object() {
825        let json = serde_json::json!("not an object");
826
827        let result = json_to_rule_config_with_warnings(&json);
828
829        assert!(result.config.is_none());
830        assert_eq!(result.warnings.len(), 1);
831        assert!(result.warnings[0].contains("Expected object"));
832    }
833
834    // ========== Integration tests for Config population ==========
835
836    #[test]
837    fn test_rule_config_integration_with_config() {
838        // Test that converted rule configs work with the main Config struct
839        let mut config = crate::config::Config::default();
840
841        // Simulate what WASM API does: convert JSON to RuleConfig and add to config
842        let md060_json = serde_json::json!({
843            "enabled": true,
844            "style": "aligned",
845            "max-width": 120
846        });
847        let md013_json = serde_json::json!({
848            "line-length": 100,
849            "code-blocks": false
850        });
851
852        if let Some(md060_config) = json_to_rule_config(&md060_json) {
853            config.rules.insert("MD060".to_string(), md060_config);
854        }
855        if let Some(md013_config) = json_to_rule_config(&md013_json) {
856            config.rules.insert("MD013".to_string(), md013_config);
857        }
858
859        // Verify the configs are in place
860        assert!(config.rules.contains_key("MD060"));
861        assert!(config.rules.contains_key("MD013"));
862
863        // Verify values can be retrieved
864        let md060 = config.rules.get("MD060").unwrap();
865        assert_eq!(md060.values.get("enabled"), Some(&toml::Value::Boolean(true)));
866        assert_eq!(
867            md060.values.get("style"),
868            Some(&toml::Value::String("aligned".to_string()))
869        );
870        assert_eq!(md060.values.get("max-width"), Some(&toml::Value::Integer(120)));
871    }
872
873    #[test]
874    fn test_rule_config_integration_with_severity() {
875        let mut config = crate::config::Config::default();
876
877        let json = serde_json::json!({
878            "severity": "error",
879            "enabled": true
880        });
881
882        if let Some(rule_config) = json_to_rule_config(&json) {
883            config.rules.insert("MD041".to_string(), rule_config);
884        }
885
886        let md041 = config.rules.get("MD041").unwrap();
887        assert_eq!(md041.severity, Some(crate::rule::Severity::Error));
888    }
889
890    #[test]
891    fn test_rule_config_integration_case_normalization() {
892        // Test that rule names are handled correctly (caller should normalize)
893        let mut config = crate::config::Config::default();
894
895        let json = serde_json::json!({ "enabled": true });
896
897        // Test various case inputs - caller is responsible for normalization
898        for rule_name in ["md060", "MD060", "Md060"] {
899            if is_rule_name(rule_name)
900                && let Some(rule_config) = json_to_rule_config(&json)
901            {
902                config.rules.insert(rule_name.to_ascii_uppercase(), rule_config);
903            }
904        }
905
906        // All should normalize to MD060
907        assert!(config.rules.contains_key("MD060"));
908        assert_eq!(config.rules.len(), 1); // Only one entry after normalization
909    }
910
911    #[test]
912    fn test_rule_config_integration_filters_non_rules() {
913        // Test that is_rule_name correctly filters non-rule keys
914        let keys = ["MD060", "disable", "enable", "flavor", "line-length", "global"];
915
916        let rule_keys: Vec<_> = keys.iter().filter(|k| is_rule_name(k)).collect();
917
918        assert_eq!(rule_keys, vec![&"MD060"]);
919    }
920
921    #[test]
922    fn test_multiple_rule_configs_with_mixed_validity() {
923        // Test handling multiple rules where some have warnings
924        let rules = vec![
925            ("MD060", serde_json::json!({ "severity": "error", "style": "aligned" })),
926            (
927                "MD013",
928                serde_json::json!({ "severity": "invalid", "line-length": 100 }),
929            ),
930            ("MD041", serde_json::json!({ "enabled": true })),
931        ];
932
933        let mut config = crate::config::Config::default();
934        let mut all_warnings = Vec::new();
935
936        for (name, json) in rules {
937            let result = json_to_rule_config_with_warnings(&json);
938            all_warnings.extend(result.warnings);
939            if let Some(rule_config) = result.config {
940                config.rules.insert(name.to_string(), rule_config);
941            }
942        }
943
944        // All rules should be added
945        assert_eq!(config.rules.len(), 3);
946
947        // Should have one warning about invalid severity
948        assert_eq!(all_warnings.len(), 1);
949        assert!(all_warnings[0].contains("Invalid severity"));
950
951        // MD060 should have severity, MD013 should not
952        assert_eq!(
953            config.rules.get("MD060").unwrap().severity,
954            Some(crate::rule::Severity::Error)
955        );
956        assert!(config.rules.get("MD013").unwrap().severity.is_none());
957    }
958
959    // ========== End-to-end integration tests ==========
960    // These tests verify the full flow: JSON config -> RuleConfig -> Config -> actual linting
961
962    #[test]
963    fn test_end_to_end_md013_line_length_config() {
964        // Test that MD013 line-length config actually affects linting behavior
965        let content = "# Test\n\nThis is a line that is exactly 50 characters long.\n";
966
967        // Create config with line-length = 40 (should trigger warning)
968        let mut config = crate::config::Config::default();
969        let json = serde_json::json!({
970            "line-length": 40
971        });
972        if let Some(rule_config) = json_to_rule_config(&json) {
973            config.rules.insert("MD013".to_string(), rule_config);
974        }
975
976        // Only enable MD013 for this test
977        config.global.enable = vec!["MD013".to_string()];
978
979        let rules = crate::rules::all_rules(&config);
980        let filtered = crate::rules::filter_rules(&rules, &config.global);
981
982        let result = crate::lint(
983            content,
984            &filtered,
985            false,
986            crate::config::MarkdownFlavor::Standard,
987            Some(&config),
988        );
989
990        let warnings = result.expect("Linting should succeed");
991
992        // Should have MD013 warning because line exceeds 40 chars
993        let has_md013 = warnings.iter().any(|w| w.rule_name.as_deref() == Some("MD013"));
994        assert!(has_md013, "Should have MD013 warning with line-length=40");
995    }
996
997    #[test]
998    fn test_end_to_end_md013_line_length_no_warning() {
999        // Same content but with higher line-length limit - no warning
1000        let content = "# Test\n\nThis is a line that is exactly 50 characters long.\n";
1001
1002        // Create config with line-length = 100 (should NOT trigger warning)
1003        let mut config = crate::config::Config::default();
1004        let json = serde_json::json!({
1005            "line-length": 100
1006        });
1007        if let Some(rule_config) = json_to_rule_config(&json) {
1008            config.rules.insert("MD013".to_string(), rule_config);
1009        }
1010
1011        // Only enable MD013 for this test
1012        config.global.enable = vec!["MD013".to_string()];
1013
1014        let rules = crate::rules::all_rules(&config);
1015        let filtered = crate::rules::filter_rules(&rules, &config.global);
1016
1017        let result = crate::lint(
1018            content,
1019            &filtered,
1020            false,
1021            crate::config::MarkdownFlavor::Standard,
1022            Some(&config),
1023        );
1024
1025        let warnings = result.expect("Linting should succeed");
1026
1027        // Should NOT have MD013 warning because line is under 100 chars
1028        let has_md013 = warnings.iter().any(|w| w.rule_name.as_deref() == Some("MD013"));
1029        assert!(!has_md013, "Should NOT have MD013 warning with line-length=100");
1030    }
1031
1032    #[test]
1033    fn test_end_to_end_md044_proper_names() {
1034        // Test that MD044 proper names config actually affects linting
1035        let content = "# Test\n\nWe use javascript and typescript.\n";
1036
1037        // Create config with proper names
1038        let mut config = crate::config::Config::default();
1039        let json = serde_json::json!({
1040            "names": ["JavaScript", "TypeScript"],
1041            "code-blocks": false
1042        });
1043        if let Some(rule_config) = json_to_rule_config(&json) {
1044            config.rules.insert("MD044".to_string(), rule_config);
1045        }
1046
1047        // Only enable MD044 for this test
1048        config.global.enable = vec!["MD044".to_string()];
1049
1050        let rules = crate::rules::all_rules(&config);
1051        let filtered = crate::rules::filter_rules(&rules, &config.global);
1052
1053        let result = crate::lint(
1054            content,
1055            &filtered,
1056            false,
1057            crate::config::MarkdownFlavor::Standard,
1058            Some(&config),
1059        );
1060
1061        let warnings = result.expect("Linting should succeed");
1062
1063        // Should have MD044 warnings for improper casing
1064        let md044_warnings: Vec<_> = warnings
1065            .iter()
1066            .filter(|w| w.rule_name.as_deref() == Some("MD044"))
1067            .collect();
1068
1069        assert!(
1070            md044_warnings.len() >= 2,
1071            "Should have MD044 warnings for 'javascript' and 'typescript', got {}",
1072            md044_warnings.len()
1073        );
1074    }
1075
1076    #[test]
1077    fn test_end_to_end_severity_config() {
1078        // Test that severity config is respected
1079        let content = "test\n"; // Missing heading, triggers MD041
1080
1081        let mut config = crate::config::Config::default();
1082        let json = serde_json::json!({
1083            "severity": "info"
1084        });
1085        if let Some(rule_config) = json_to_rule_config(&json) {
1086            config.rules.insert("MD041".to_string(), rule_config);
1087        }
1088
1089        // Only enable MD041 for this test
1090        config.global.enable = vec!["MD041".to_string()];
1091
1092        let rules = crate::rules::all_rules(&config);
1093        let filtered = crate::rules::filter_rules(&rules, &config.global);
1094
1095        let result = crate::lint(
1096            content,
1097            &filtered,
1098            false,
1099            crate::config::MarkdownFlavor::Standard,
1100            Some(&config),
1101        );
1102
1103        let warnings = result.expect("Linting should succeed");
1104
1105        // Find MD041 warning and verify severity
1106        let md041 = warnings.iter().find(|w| w.rule_name.as_deref() == Some("MD041"));
1107        assert!(md041.is_some(), "Should have MD041 warning");
1108        assert_eq!(
1109            md041.unwrap().severity,
1110            crate::rule::Severity::Info,
1111            "MD041 should have Info severity from config"
1112        );
1113    }
1114}