Skip to main content

rumdl_lib/
rule_config_serde.rs

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