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