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