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