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