Skip to main content

rumdl_lib/
rule_config_serde.rs

1/// Serde-based configuration system for rules
2///
3/// This module provides a modern, type-safe configuration system inspired by Ruff's approach.
4/// It eliminates manual TOML construction and provides automatic serialization/deserialization.
5use serde::Serialize;
6use serde::de::DeserializeOwned;
7
8/// Trait for rule configurations
9pub trait RuleConfig: Serialize + DeserializeOwned + Default + Clone {
10    /// The rule name (e.g., "MD009")
11    const RULE_NAME: &'static str;
12}
13
14/// Helper to load rule configuration from the global config
15///
16/// This function will emit warnings to stderr if the configuration is invalid,
17/// helping users identify and fix configuration errors.
18pub fn load_rule_config<T: RuleConfig>(config: &crate::config::Config) -> T {
19    config
20        .rules
21        .get(T::RULE_NAME)
22        .and_then(|rule_config| {
23            // Build the TOML table with backwards compatibility mappings
24            let mut table = toml::map::Map::new();
25
26            for (k, v) in rule_config.values.iter() {
27                // No manual mapping needed - serde aliases handle this
28                table.insert(k.clone(), v.clone());
29            }
30
31            let toml_table = toml::Value::Table(table);
32
33            // Deserialize directly from TOML, which preserves serde attributes
34            match toml_table.try_into::<T>() {
35                Ok(config) => Some(config),
36                Err(e) => {
37                    // Emit a warning about the invalid configuration
38                    eprintln!("Warning: Invalid configuration for rule {}: {}", T::RULE_NAME, e);
39                    eprintln!("Using default values for rule {}.", T::RULE_NAME);
40                    eprintln!("Hint: Check the documentation for valid configuration values.");
41
42                    None
43                }
44            }
45        })
46        .unwrap_or_default()
47}
48
49/// Convert JSON value to TOML value for default config generation
50pub fn json_to_toml_value(json_val: &serde_json::Value) -> Option<toml::Value> {
51    match json_val {
52        serde_json::Value::Null => None,
53        serde_json::Value::Bool(b) => Some(toml::Value::Boolean(*b)),
54        serde_json::Value::Number(n) => {
55            if let Some(i) = n.as_i64() {
56                Some(toml::Value::Integer(i))
57            } else {
58                n.as_f64().map(toml::Value::Float)
59            }
60        }
61        serde_json::Value::String(s) => Some(toml::Value::String(s.clone())),
62        serde_json::Value::Array(arr) => {
63            let toml_arr: Vec<_> = arr.iter().filter_map(json_to_toml_value).collect();
64            Some(toml::Value::Array(toml_arr))
65        }
66        serde_json::Value::Object(obj) => {
67            let mut toml_table = toml::map::Map::new();
68            for (k, v) in obj {
69                if let Some(toml_v) = json_to_toml_value(v) {
70                    toml_table.insert(k.clone(), toml_v);
71                }
72            }
73            Some(toml::Value::Table(toml_table))
74        }
75    }
76}
77
78#[cfg(test)]
79/// Convert TOML value to JSON value (only used in tests)
80fn toml_value_to_json(toml_val: &toml::Value) -> Option<serde_json::Value> {
81    match toml_val {
82        toml::Value::String(s) => Some(serde_json::Value::String(s.clone())),
83        toml::Value::Integer(i) => Some(serde_json::json!(i)),
84        toml::Value::Float(f) => Some(serde_json::json!(f)),
85        toml::Value::Boolean(b) => Some(serde_json::Value::Bool(*b)),
86        toml::Value::Array(arr) => {
87            let json_arr: Vec<_> = arr.iter().filter_map(toml_value_to_json).collect();
88            Some(serde_json::Value::Array(json_arr))
89        }
90        toml::Value::Table(table) => {
91            let mut json_obj = serde_json::Map::new();
92            for (k, v) in table {
93                if let Some(json_v) = toml_value_to_json(v) {
94                    json_obj.insert(k.clone(), json_v);
95                }
96            }
97            Some(serde_json::Value::Object(json_obj))
98        }
99        toml::Value::Datetime(_) => None, // JSON doesn't have a native datetime type
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use serde::{Deserialize, Serialize};
107    use std::collections::BTreeMap;
108
109    // Test configuration struct
110    #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
111    #[serde(default)]
112    struct TestRuleConfig {
113        #[serde(default)]
114        enabled: bool,
115        #[serde(default)]
116        indent: i64,
117        #[serde(default)]
118        style: String,
119        #[serde(default)]
120        items: Vec<String>,
121    }
122
123    impl RuleConfig for TestRuleConfig {
124        const RULE_NAME: &'static str = "TEST001";
125    }
126
127    #[test]
128    fn test_toml_value_to_json_basic_types() {
129        // String
130        let toml_str = toml::Value::String("hello".to_string());
131        let json_str = toml_value_to_json(&toml_str).unwrap();
132        assert_eq!(json_str, serde_json::Value::String("hello".to_string()));
133
134        // Integer
135        let toml_int = toml::Value::Integer(42);
136        let json_int = toml_value_to_json(&toml_int).unwrap();
137        assert_eq!(json_int, serde_json::json!(42));
138
139        // Float
140        let toml_float = toml::Value::Float(1.234);
141        let json_float = toml_value_to_json(&toml_float).unwrap();
142        assert_eq!(json_float, serde_json::json!(1.234));
143
144        // Boolean
145        let toml_bool = toml::Value::Boolean(true);
146        let json_bool = toml_value_to_json(&toml_bool).unwrap();
147        assert_eq!(json_bool, serde_json::Value::Bool(true));
148    }
149
150    #[test]
151    fn test_toml_value_to_json_complex_types() {
152        // Array
153        let toml_arr = toml::Value::Array(vec![
154            toml::Value::String("a".to_string()),
155            toml::Value::String("b".to_string()),
156        ]);
157        let json_arr = toml_value_to_json(&toml_arr).unwrap();
158        assert_eq!(json_arr, serde_json::json!(["a", "b"]));
159
160        // Table
161        let mut toml_table = toml::map::Map::new();
162        toml_table.insert("key1".to_string(), toml::Value::String("value1".to_string()));
163        toml_table.insert("key2".to_string(), toml::Value::Integer(123));
164        let toml_tbl = toml::Value::Table(toml_table);
165        let json_tbl = toml_value_to_json(&toml_tbl).unwrap();
166
167        let expected = serde_json::json!({
168            "key1": "value1",
169            "key2": 123
170        });
171        assert_eq!(json_tbl, expected);
172    }
173
174    #[test]
175    fn test_toml_value_to_json_datetime() {
176        // Datetime should return None
177        let toml_dt = toml::Value::Datetime("2023-01-01T00:00:00Z".parse().unwrap());
178        assert!(toml_value_to_json(&toml_dt).is_none());
179    }
180
181    #[test]
182    fn test_json_to_toml_value_basic_types() {
183        // Null
184        assert!(json_to_toml_value(&serde_json::Value::Null).is_none());
185
186        // Bool
187        let json_bool = serde_json::Value::Bool(false);
188        let toml_bool = json_to_toml_value(&json_bool).unwrap();
189        assert_eq!(toml_bool, toml::Value::Boolean(false));
190
191        // Integer
192        let json_int = serde_json::json!(42);
193        let toml_int = json_to_toml_value(&json_int).unwrap();
194        assert_eq!(toml_int, toml::Value::Integer(42));
195
196        // Float
197        let json_float = serde_json::json!(1.234);
198        let toml_float = json_to_toml_value(&json_float).unwrap();
199        assert_eq!(toml_float, toml::Value::Float(1.234));
200
201        // String
202        let json_str = serde_json::Value::String("test".to_string());
203        let toml_str = json_to_toml_value(&json_str).unwrap();
204        assert_eq!(toml_str, toml::Value::String("test".to_string()));
205    }
206
207    #[test]
208    fn test_json_to_toml_value_complex_types() {
209        // Array
210        let json_arr = serde_json::json!(["x", "y", "z"]);
211        let toml_arr = json_to_toml_value(&json_arr).unwrap();
212        if let toml::Value::Array(arr) = toml_arr {
213            assert_eq!(arr.len(), 3);
214            assert_eq!(arr[0], toml::Value::String("x".to_string()));
215            assert_eq!(arr[1], toml::Value::String("y".to_string()));
216            assert_eq!(arr[2], toml::Value::String("z".to_string()));
217        } else {
218            panic!("Expected array");
219        }
220
221        // Object
222        let json_obj = serde_json::json!({
223            "name": "test",
224            "count": 10,
225            "active": true
226        });
227        let toml_obj = json_to_toml_value(&json_obj).unwrap();
228        if let toml::Value::Table(table) = toml_obj {
229            assert_eq!(table.get("name"), Some(&toml::Value::String("test".to_string())));
230            assert_eq!(table.get("count"), Some(&toml::Value::Integer(10)));
231            assert_eq!(table.get("active"), Some(&toml::Value::Boolean(true)));
232        } else {
233            panic!("Expected table");
234        }
235    }
236
237    #[test]
238    fn test_load_rule_config_default() {
239        // Create empty config
240        let config = crate::config::Config::default();
241
242        // Load config for test rule - should return default
243        let rule_config: TestRuleConfig = load_rule_config(&config);
244        assert_eq!(rule_config, TestRuleConfig::default());
245    }
246
247    #[test]
248    fn test_load_rule_config_with_values() {
249        // Create config with rule values
250        let mut config = crate::config::Config::default();
251        let mut rule_values = BTreeMap::new();
252        rule_values.insert("enabled".to_string(), toml::Value::Boolean(true));
253        rule_values.insert("indent".to_string(), toml::Value::Integer(4));
254        rule_values.insert("style".to_string(), toml::Value::String("consistent".to_string()));
255        rule_values.insert(
256            "items".to_string(),
257            toml::Value::Array(vec![
258                toml::Value::String("item1".to_string()),
259                toml::Value::String("item2".to_string()),
260            ]),
261        );
262
263        config.rules.insert(
264            "TEST001".to_string(),
265            crate::config::RuleConfig {
266                severity: None,
267                values: rule_values,
268            },
269        );
270
271        // Load config
272        let rule_config: TestRuleConfig = load_rule_config(&config);
273        assert!(rule_config.enabled);
274        assert_eq!(rule_config.indent, 4);
275        assert_eq!(rule_config.style, "consistent");
276        assert_eq!(rule_config.items, vec!["item1", "item2"]);
277    }
278
279    #[test]
280    fn test_load_rule_config_partial() {
281        // Create config with partial rule values
282        let mut config = crate::config::Config::default();
283        let mut rule_values = BTreeMap::new();
284        rule_values.insert("enabled".to_string(), toml::Value::Boolean(true));
285        rule_values.insert("style".to_string(), toml::Value::String("custom".to_string()));
286
287        config.rules.insert(
288            "TEST001".to_string(),
289            crate::config::RuleConfig {
290                severity: None,
291                values: rule_values,
292            },
293        );
294
295        // Load config - missing fields should use defaults from TestRuleConfig::default()
296        let rule_config: TestRuleConfig = load_rule_config(&config);
297        assert!(rule_config.enabled); // from config
298        assert_eq!(rule_config.indent, 0); // default i64
299        assert_eq!(rule_config.style, "custom"); // from config
300        assert_eq!(rule_config.items, Vec::<String>::new()); // default empty vec
301    }
302
303    #[test]
304    fn test_conversion_roundtrip() {
305        // Test that we can convert TOML -> JSON -> TOML
306        let original = toml::Value::Table({
307            let mut table = toml::map::Map::new();
308            table.insert("string".to_string(), toml::Value::String("test".to_string()));
309            table.insert("number".to_string(), toml::Value::Integer(42));
310            table.insert("bool".to_string(), toml::Value::Boolean(true));
311            table.insert(
312                "array".to_string(),
313                toml::Value::Array(vec![
314                    toml::Value::String("a".to_string()),
315                    toml::Value::String("b".to_string()),
316                ]),
317            );
318            table
319        });
320
321        let json = toml_value_to_json(&original).unwrap();
322        let back_to_toml = json_to_toml_value(&json).unwrap();
323
324        assert_eq!(original, back_to_toml);
325    }
326
327    #[test]
328    fn test_edge_cases() {
329        // Empty array
330        let empty_arr = toml::Value::Array(vec![]);
331        let json_arr = toml_value_to_json(&empty_arr).unwrap();
332        assert_eq!(json_arr, serde_json::json!([]));
333
334        // Empty table
335        let empty_table = toml::Value::Table(toml::map::Map::new());
336        let json_table = toml_value_to_json(&empty_table).unwrap();
337        assert_eq!(json_table, serde_json::json!({}));
338
339        // Nested structures
340        let nested = toml::Value::Table({
341            let mut outer = toml::map::Map::new();
342            outer.insert(
343                "inner".to_string(),
344                toml::Value::Table({
345                    let mut inner = toml::map::Map::new();
346                    inner.insert("value".to_string(), toml::Value::Integer(123));
347                    inner
348                }),
349            );
350            outer
351        });
352        let json_nested = toml_value_to_json(&nested).unwrap();
353        assert_eq!(
354            json_nested,
355            serde_json::json!({
356                "inner": {
357                    "value": 123
358                }
359            })
360        );
361    }
362
363    #[test]
364    fn test_float_edge_cases() {
365        // NaN and infinity are not valid JSON numbers
366        let nan = serde_json::Number::from_f64(f64::NAN);
367        assert!(nan.is_none());
368
369        let inf = serde_json::Number::from_f64(f64::INFINITY);
370        assert!(inf.is_none());
371
372        // Valid float
373        let valid_float = toml::Value::Float(1.23);
374        let json_float = toml_value_to_json(&valid_float).unwrap();
375        assert_eq!(json_float, serde_json::json!(1.23));
376    }
377
378    #[test]
379    fn test_invalid_config_returns_default() {
380        // Create config with unknown field
381        let mut config = crate::config::Config::default();
382        let mut rule_values = BTreeMap::new();
383        rule_values.insert("unknown_field".to_string(), toml::Value::Boolean(true));
384        // Use a table value for items, which expects an array
385        rule_values.insert("items".to_string(), toml::Value::Table(toml::map::Map::new()));
386
387        config.rules.insert(
388            "TEST001".to_string(),
389            crate::config::RuleConfig {
390                severity: None,
391                values: rule_values,
392            },
393        );
394
395        // Load config - should return default and print warning
396        let rule_config: TestRuleConfig = load_rule_config(&config);
397        // Should use default values since deserialization failed
398        assert_eq!(rule_config, TestRuleConfig::default());
399    }
400
401    #[test]
402    fn test_invalid_field_type() {
403        // Create config with wrong type for field
404        let mut config = crate::config::Config::default();
405        let mut rule_values = BTreeMap::new();
406        // indent should be i64, but we're providing a string
407        rule_values.insert("indent".to_string(), toml::Value::String("not_a_number".to_string()));
408
409        config.rules.insert(
410            "TEST001".to_string(),
411            crate::config::RuleConfig {
412                severity: None,
413                values: rule_values,
414            },
415        );
416
417        // Load config - should return default and print warning
418        let rule_config: TestRuleConfig = load_rule_config(&config);
419        assert_eq!(rule_config, TestRuleConfig::default());
420    }
421}