Skip to main content

rumdl_lib/rules/
md072_frontmatter_key_sort.rs

1use crate::rule::{Fix, LintError, LintResult, LintWarning, Rule, RuleCategory, Severity};
2use crate::rule_config_serde::RuleConfig;
3use crate::rules::front_matter_utils::{FrontMatterType, FrontMatterUtils};
4use regex::Regex;
5use serde::{Deserialize, Serialize};
6use std::sync::LazyLock;
7
8/// Pre-compiled regex for extracting JSON keys
9static JSON_KEY_PATTERN: LazyLock<Regex> =
10    LazyLock::new(|| Regex::new(r#"^\s*"([^"]+)"\s*:"#).expect("Invalid JSON key regex"));
11
12/// Configuration for MD072 (Frontmatter key sort)
13///
14/// This rule is disabled by default (opt-in) because key sorting
15/// is an opinionated style choice. Many projects prefer semantic ordering.
16#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
17pub struct MD072Config {
18    /// Whether this rule is enabled (default: false - opt-in rule)
19    #[serde(default)]
20    pub enabled: bool,
21
22    /// Custom key order. Keys listed here will be sorted in this order.
23    /// Keys not in this list will be sorted alphabetically after the specified keys.
24    /// If not set, all keys are sorted alphabetically (case-insensitive).
25    ///
26    /// Example: `key_order = ["title", "date", "author", "tags"]`
27    #[serde(default, alias = "key-order")]
28    pub key_order: Option<Vec<String>>,
29}
30
31impl RuleConfig for MD072Config {
32    const RULE_NAME: &'static str = "MD072";
33}
34
35/// Rule MD072: Frontmatter key sort
36///
37/// Ensures frontmatter keys are sorted alphabetically.
38/// Supports YAML, TOML, and JSON frontmatter formats.
39/// Auto-fix is only available when frontmatter contains no comments (YAML/TOML).
40/// JSON frontmatter is always auto-fixable since JSON has no comments.
41///
42/// **Note**: This rule is disabled by default because alphabetical key sorting
43/// is an opinionated style choice. Many projects prefer semantic ordering
44/// (title first, date second, etc.) rather than alphabetical.
45///
46/// See [docs/md072.md](../../docs/md072.md) for full documentation.
47#[derive(Clone, Default)]
48pub struct MD072FrontmatterKeySort {
49    config: MD072Config,
50}
51
52impl MD072FrontmatterKeySort {
53    pub fn new() -> Self {
54        Self::default()
55    }
56
57    /// Create from a config struct
58    pub fn from_config_struct(config: MD072Config) -> Self {
59        Self { config }
60    }
61
62    /// Check if frontmatter contains comments (YAML/TOML use #)
63    fn has_comments(frontmatter_lines: &[&str]) -> bool {
64        frontmatter_lines.iter().any(|line| line.trim_start().starts_with('#'))
65    }
66
67    /// Extract top-level keys from YAML frontmatter
68    fn extract_yaml_keys(frontmatter_lines: &[&str]) -> Vec<(usize, String)> {
69        let mut keys = Vec::new();
70
71        for (idx, line) in frontmatter_lines.iter().enumerate() {
72            // Top-level keys have no leading whitespace and contain a colon
73            if !line.starts_with(' ')
74                && !line.starts_with('\t')
75                && let Some(colon_pos) = line.find(':')
76            {
77                let key = line[..colon_pos].trim();
78                if !key.is_empty() && !key.starts_with('#') {
79                    keys.push((idx, key.to_string()));
80                }
81            }
82        }
83
84        keys
85    }
86
87    /// Extract top-level keys from TOML frontmatter
88    fn extract_toml_keys(frontmatter_lines: &[&str]) -> Vec<(usize, String)> {
89        let mut keys = Vec::new();
90
91        for (idx, line) in frontmatter_lines.iter().enumerate() {
92            let trimmed = line.trim();
93            // Skip comments and empty lines
94            if trimmed.is_empty() || trimmed.starts_with('#') {
95                continue;
96            }
97            // Stop at table headers like [section] - everything after is nested
98            if trimmed.starts_with('[') {
99                break;
100            }
101            // Top-level keys have no leading whitespace and contain =
102            if !line.starts_with(' ')
103                && !line.starts_with('\t')
104                && let Some(eq_pos) = line.find('=')
105            {
106                let key = line[..eq_pos].trim();
107                if !key.is_empty() {
108                    keys.push((idx, key.to_string()));
109                }
110            }
111        }
112
113        keys
114    }
115
116    /// Extract top-level keys from JSON frontmatter in order of appearance
117    fn extract_json_keys(frontmatter_lines: &[&str]) -> Vec<String> {
118        // Extract keys from raw JSON text to preserve original order
119        // serde_json::Map uses BTreeMap which sorts keys, so we parse manually
120        // Only extract keys at depth 0 relative to the content (top-level inside the outer object)
121        // Note: frontmatter_lines excludes the opening `{`, so we start at depth 0
122        let mut keys = Vec::new();
123        let mut depth: usize = 0;
124
125        for line in frontmatter_lines {
126            // Track depth before checking for keys on this line
127            let line_start_depth = depth;
128
129            // Count braces and brackets to track nesting, skipping those inside strings
130            let mut in_string = false;
131            let mut prev_backslash = false;
132            for ch in line.chars() {
133                if in_string {
134                    if ch == '"' && !prev_backslash {
135                        in_string = false;
136                    }
137                    prev_backslash = ch == '\\' && !prev_backslash;
138                } else {
139                    match ch {
140                        '"' => in_string = true,
141                        '{' | '[' => depth += 1,
142                        '}' | ']' => depth = depth.saturating_sub(1),
143                        _ => {}
144                    }
145                    prev_backslash = false;
146                }
147            }
148
149            // Only extract keys at depth 0 (top-level, since opening brace is excluded)
150            if line_start_depth == 0
151                && let Some(captures) = JSON_KEY_PATTERN.captures(line)
152                && let Some(key_match) = captures.get(1)
153            {
154                keys.push(key_match.as_str().to_string());
155            }
156        }
157
158        keys
159    }
160
161    /// Get the sort position for a key based on custom key_order or alphabetical fallback.
162    /// Keys in key_order get their index (0, 1, 2...), keys not in key_order get
163    /// a high value so they sort after, with alphabetical sub-sorting.
164    fn key_sort_position(key: &str, key_order: Option<&[String]>) -> (usize, String) {
165        if let Some(order) = key_order {
166            // Find position in custom order (case-insensitive match)
167            let key_lower = key.to_lowercase();
168            for (idx, ordered_key) in order.iter().enumerate() {
169                if ordered_key.to_lowercase() == key_lower {
170                    return (idx, key_lower);
171                }
172            }
173            // Not in custom order - sort after with alphabetical
174            (usize::MAX, key_lower)
175        } else {
176            // No custom order - pure alphabetical
177            (0, key.to_lowercase())
178        }
179    }
180
181    /// Find the first pair of keys that are out of order
182    /// Returns (out_of_place_key, should_come_after_key)
183    fn find_first_unsorted_pair<'a>(keys: &'a [String], key_order: Option<&[String]>) -> Option<(&'a str, &'a str)> {
184        for i in 1..keys.len() {
185            let pos_curr = Self::key_sort_position(&keys[i], key_order);
186            let pos_prev = Self::key_sort_position(&keys[i - 1], key_order);
187            if pos_curr < pos_prev {
188                return Some((&keys[i], &keys[i - 1]));
189            }
190        }
191        None
192    }
193
194    /// Find the first pair of indexed keys that are out of order
195    /// Returns (out_of_place_key, should_come_after_key)
196    fn find_first_unsorted_indexed_pair<'a>(
197        keys: &'a [(usize, String)],
198        key_order: Option<&[String]>,
199    ) -> Option<(usize, &'a str, &'a str)> {
200        for i in 1..keys.len() {
201            let pos_curr = Self::key_sort_position(&keys[i].1, key_order);
202            let pos_prev = Self::key_sort_position(&keys[i - 1].1, key_order);
203            if pos_curr < pos_prev {
204                return Some((keys[i].0, &keys[i].1, &keys[i - 1].1));
205            }
206        }
207        None
208    }
209
210    /// Check if keys are sorted according to key_order (or alphabetically if None)
211    fn are_keys_sorted(keys: &[String], key_order: Option<&[String]>) -> bool {
212        Self::find_first_unsorted_pair(keys, key_order).is_none()
213    }
214
215    /// Check if indexed keys are sorted according to key_order (or alphabetically if None)
216    fn are_indexed_keys_sorted(keys: &[(usize, String)], key_order: Option<&[String]>) -> bool {
217        Self::find_first_unsorted_indexed_pair(keys, key_order).is_none()
218    }
219
220    /// Sort keys according to key_order, with alphabetical fallback for unlisted keys
221    fn sort_keys_by_order(keys: &mut [(String, Vec<&str>)], key_order: Option<&[String]>) {
222        keys.sort_by(|a, b| {
223            let pos_a = Self::key_sort_position(&a.0, key_order);
224            let pos_b = Self::key_sort_position(&b.0, key_order);
225            pos_a.cmp(&pos_b)
226        });
227    }
228}
229
230impl Rule for MD072FrontmatterKeySort {
231    fn name(&self) -> &'static str {
232        "MD072"
233    }
234
235    fn description(&self) -> &'static str {
236        "Frontmatter keys should be sorted alphabetically"
237    }
238
239    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
240        let content = ctx.content;
241        let mut warnings = Vec::new();
242
243        if content.is_empty() {
244            return Ok(warnings);
245        }
246
247        let fm_type = FrontMatterUtils::detect_front_matter_type(content);
248
249        match fm_type {
250            FrontMatterType::Yaml => {
251                let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
252                if frontmatter_lines.is_empty() {
253                    return Ok(warnings);
254                }
255
256                let keys = Self::extract_yaml_keys(&frontmatter_lines);
257                let key_order = self.config.key_order.as_deref();
258                let Some((key_idx, out_of_place, should_come_after)) =
259                    Self::find_first_unsorted_indexed_pair(&keys, key_order)
260                else {
261                    return Ok(warnings);
262                };
263                // key_idx is relative to frontmatter_lines; +2 for 1-indexing and the opening ---
264                let key_line = key_idx + 2;
265
266                let has_comments = Self::has_comments(&frontmatter_lines);
267
268                let fix = if has_comments {
269                    None
270                } else {
271                    // Compute the actual fix: full content replacement
272                    let fixed_content = self.fix_yaml(content);
273                    if fixed_content != content {
274                        Some(Fix::new(0..content.len(), fixed_content))
275                    } else {
276                        None
277                    }
278                };
279
280                let message = if has_comments {
281                    format!(
282                        "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
283                    )
284                } else {
285                    format!(
286                        "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
287                    )
288                };
289
290                warnings.push(LintWarning {
291                    rule_name: Some(self.name().to_string()),
292                    message,
293                    line: key_line,
294                    column: 1,
295                    end_line: key_line,
296                    end_column: out_of_place.len() + 1,
297                    severity: Severity::Warning,
298                    fix,
299                });
300            }
301            FrontMatterType::Toml => {
302                let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
303                if frontmatter_lines.is_empty() {
304                    return Ok(warnings);
305                }
306
307                let keys = Self::extract_toml_keys(&frontmatter_lines);
308                let key_order = self.config.key_order.as_deref();
309                let Some((key_idx, out_of_place, should_come_after)) =
310                    Self::find_first_unsorted_indexed_pair(&keys, key_order)
311                else {
312                    return Ok(warnings);
313                };
314                let key_line = key_idx + 2;
315
316                let has_comments = Self::has_comments(&frontmatter_lines);
317
318                let fix = if has_comments {
319                    None
320                } else {
321                    // Compute the actual fix: full content replacement
322                    let fixed_content = self.fix_toml(content);
323                    if fixed_content != content {
324                        Some(Fix::new(0..content.len(), fixed_content))
325                    } else {
326                        None
327                    }
328                };
329
330                let message = if has_comments {
331                    format!(
332                        "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
333                    )
334                } else {
335                    format!(
336                        "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
337                    )
338                };
339
340                warnings.push(LintWarning {
341                    rule_name: Some(self.name().to_string()),
342                    message,
343                    line: key_line,
344                    column: 1,
345                    end_line: key_line,
346                    end_column: out_of_place.len() + 1,
347                    severity: Severity::Warning,
348                    fix,
349                });
350            }
351            FrontMatterType::Json => {
352                let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
353                if frontmatter_lines.is_empty() {
354                    return Ok(warnings);
355                }
356
357                let keys = Self::extract_json_keys(&frontmatter_lines);
358                let key_order = self.config.key_order.as_deref();
359                let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_pair(&keys, key_order) else {
360                    return Ok(warnings);
361                };
362
363                // Compute the actual fix: full content replacement
364                let fixed_content = self.fix_json(content);
365                let fix = if fixed_content != content {
366                    Some(Fix::new(0..content.len(), fixed_content))
367                } else {
368                    None
369                };
370
371                let message = format!(
372                    "JSON frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
373                );
374
375                warnings.push(LintWarning {
376                    rule_name: Some(self.name().to_string()),
377                    message,
378                    line: 2,
379                    column: 1,
380                    end_line: 2,
381                    end_column: out_of_place.len() + 1,
382                    severity: Severity::Warning,
383                    fix,
384                });
385            }
386            _ => {
387                // No frontmatter or malformed - skip
388            }
389        }
390
391        Ok(warnings)
392    }
393
394    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
395        let content = ctx.content;
396
397        // Skip fix if rule is disabled via inline config at the frontmatter region (line 2)
398        if ctx.is_rule_disabled(self.name(), 2) {
399            return Ok(content.to_string());
400        }
401
402        let fm_type = FrontMatterUtils::detect_front_matter_type(content);
403
404        Ok(match fm_type {
405            FrontMatterType::Yaml => self.fix_yaml(content),
406            FrontMatterType::Toml => self.fix_toml(content),
407            FrontMatterType::Json => self.fix_json(content),
408            _ => content.to_string(),
409        })
410    }
411
412    fn category(&self) -> RuleCategory {
413        RuleCategory::FrontMatter
414    }
415
416    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
417        ctx.content.is_empty()
418            || !ctx.content.starts_with("---") && !ctx.content.starts_with("+++") && !ctx.content.starts_with('{')
419    }
420
421    fn as_any(&self) -> &dyn std::any::Any {
422        self
423    }
424
425    fn default_config_section(&self) -> Option<(String, toml::Value)> {
426        let table = crate::rule_config_serde::config_schema_table(&MD072Config::default())?;
427        Some((MD072Config::RULE_NAME.to_string(), toml::Value::Table(table)))
428    }
429
430    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
431    where
432        Self: Sized,
433    {
434        let rule_config = crate::rule_config_serde::load_rule_config::<MD072Config>(config);
435        Box::new(Self::from_config_struct(rule_config))
436    }
437}
438
439impl MD072FrontmatterKeySort {
440    fn fix_yaml(&self, content: &str) -> String {
441        let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
442        if frontmatter_lines.is_empty() {
443            return content.to_string();
444        }
445
446        // Cannot fix if comments present
447        if Self::has_comments(&frontmatter_lines) {
448            return content.to_string();
449        }
450
451        let keys = Self::extract_yaml_keys(&frontmatter_lines);
452        let key_order = self.config.key_order.as_deref();
453        if Self::are_indexed_keys_sorted(&keys, key_order) {
454            return content.to_string();
455        }
456
457        // Line-based reordering to preserve original formatting (indentation, etc.)
458        // Each key owns all lines until the next top-level key
459        let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
460
461        for (i, (line_idx, key)) in keys.iter().enumerate() {
462            let start = *line_idx;
463            let end = if i + 1 < keys.len() {
464                keys[i + 1].0
465            } else {
466                frontmatter_lines.len()
467            };
468
469            let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
470            key_blocks.push((key.clone(), block_lines));
471        }
472
473        // Sort by key_order, with alphabetical fallback for unlisted keys
474        Self::sort_keys_by_order(&mut key_blocks, key_order);
475
476        // Reassemble frontmatter
477        let content_lines: Vec<&str> = content.lines().collect();
478        let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
479
480        let mut result = String::new();
481        result.push_str("---\n");
482        for (_, lines) in &key_blocks {
483            for line in lines {
484                result.push_str(line);
485                result.push('\n');
486            }
487        }
488        result.push_str("---");
489
490        if fm_end < content_lines.len() {
491            result.push('\n');
492            result.push_str(&content_lines[fm_end..].join("\n"));
493        }
494
495        result
496    }
497
498    fn fix_toml(&self, content: &str) -> String {
499        let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
500        if frontmatter_lines.is_empty() {
501            return content.to_string();
502        }
503
504        // Cannot fix if comments present
505        if Self::has_comments(&frontmatter_lines) {
506            return content.to_string();
507        }
508
509        let keys = Self::extract_toml_keys(&frontmatter_lines);
510        let key_order = self.config.key_order.as_deref();
511        if Self::are_indexed_keys_sorted(&keys, key_order) {
512            return content.to_string();
513        }
514
515        // Line-based reordering to preserve original formatting
516        // Each key owns all lines until the next top-level key
517        let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
518
519        for (i, (line_idx, key)) in keys.iter().enumerate() {
520            let start = *line_idx;
521            let end = if i + 1 < keys.len() {
522                keys[i + 1].0
523            } else {
524                frontmatter_lines.len()
525            };
526
527            let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
528            key_blocks.push((key.clone(), block_lines));
529        }
530
531        // Sort by key_order, with alphabetical fallback for unlisted keys
532        Self::sort_keys_by_order(&mut key_blocks, key_order);
533
534        // Reassemble frontmatter
535        let content_lines: Vec<&str> = content.lines().collect();
536        let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
537
538        let mut result = String::new();
539        result.push_str("+++\n");
540        for (_, lines) in &key_blocks {
541            for line in lines {
542                result.push_str(line);
543                result.push('\n');
544            }
545        }
546        result.push_str("+++");
547
548        if fm_end < content_lines.len() {
549            result.push('\n');
550            result.push_str(&content_lines[fm_end..].join("\n"));
551        }
552
553        result
554    }
555
556    fn fix_json(&self, content: &str) -> String {
557        let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
558        if frontmatter_lines.is_empty() {
559            return content.to_string();
560        }
561
562        let keys = Self::extract_json_keys(&frontmatter_lines);
563        let key_order = self.config.key_order.as_deref();
564
565        if keys.is_empty() || Self::are_keys_sorted(&keys, key_order) {
566            return content.to_string();
567        }
568
569        // Reconstruct JSON content including braces for parsing
570        let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
571
572        // Parse and re-serialize with sorted keys
573        match serde_json::from_str::<serde_json::Value>(&json_content) {
574            Ok(serde_json::Value::Object(map)) => {
575                // Sort keys according to key_order, with alphabetical fallback
576                let mut sorted_map = serde_json::Map::new();
577                let mut keys: Vec<_> = map.keys().cloned().collect();
578                keys.sort_by(|a, b| {
579                    let pos_a = Self::key_sort_position(a, key_order);
580                    let pos_b = Self::key_sort_position(b, key_order);
581                    pos_a.cmp(&pos_b)
582                });
583
584                for key in keys {
585                    if let Some(value) = map.get(&key) {
586                        sorted_map.insert(key, value.clone());
587                    }
588                }
589
590                match serde_json::to_string_pretty(&serde_json::Value::Object(sorted_map)) {
591                    Ok(sorted_json) => {
592                        let lines: Vec<&str> = content.lines().collect();
593                        let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
594
595                        // The pretty-printed JSON includes the outer braces
596                        // We need to format it properly for frontmatter
597                        let mut result = String::new();
598                        result.push_str(&sorted_json);
599
600                        if fm_end < lines.len() {
601                            result.push('\n');
602                            result.push_str(&lines[fm_end..].join("\n"));
603                        }
604
605                        result
606                    }
607                    Err(_) => content.to_string(),
608                }
609            }
610            _ => content.to_string(),
611        }
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use crate::lint_context::LintContext;
619
620    /// Create an enabled rule for testing (alphabetical sort)
621    fn create_enabled_rule() -> MD072FrontmatterKeySort {
622        MD072FrontmatterKeySort::from_config_struct(MD072Config {
623            enabled: true,
624            key_order: None,
625        })
626    }
627
628    /// Create an enabled rule with custom key order for testing
629    fn create_rule_with_key_order(keys: Vec<&str>) -> MD072FrontmatterKeySort {
630        MD072FrontmatterKeySort::from_config_struct(MD072Config {
631            enabled: true,
632            key_order: Some(keys.into_iter().map(String::from).collect()),
633        })
634    }
635
636    // ==================== Config Tests ====================
637
638    #[test]
639    fn test_enabled_via_config() {
640        let rule = create_enabled_rule();
641        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
642        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
643        let result = rule.check(&ctx).unwrap();
644
645        // Enabled, should detect unsorted keys
646        assert_eq!(result.len(), 1);
647    }
648
649    // ==================== YAML Tests ====================
650
651    #[test]
652    fn test_no_frontmatter() {
653        let rule = create_enabled_rule();
654        let content = "# Heading\n\nContent.";
655        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
656        let result = rule.check(&ctx).unwrap();
657
658        assert!(result.is_empty());
659    }
660
661    #[test]
662    fn test_yaml_sorted_keys() {
663        let rule = create_enabled_rule();
664        let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
665        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
666        let result = rule.check(&ctx).unwrap();
667
668        assert!(result.is_empty());
669    }
670
671    #[test]
672    fn test_yaml_unsorted_keys() {
673        let rule = create_enabled_rule();
674        let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
675        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
676        let result = rule.check(&ctx).unwrap();
677
678        assert_eq!(result.len(), 1);
679        assert!(result[0].message.contains("YAML"));
680        assert!(result[0].message.contains("not sorted"));
681        // Message shows first out-of-order pair: 'author' should come before 'title'
682        assert!(result[0].message.contains("'author' should come before 'title'"));
683    }
684
685    #[test]
686    fn test_yaml_case_insensitive_sort() {
687        let rule = create_enabled_rule();
688        let content = "---\nAuthor: John\ndate: 2024-01-01\nTitle: Test\n---\n\n# Heading";
689        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
690        let result = rule.check(&ctx).unwrap();
691
692        // Author, date, Title should be considered sorted (case-insensitive)
693        assert!(result.is_empty());
694    }
695
696    #[test]
697    fn test_yaml_fix_sorts_keys() {
698        let rule = create_enabled_rule();
699        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
700        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
701        let fixed = rule.fix(&ctx).unwrap();
702
703        // Keys should be sorted
704        let author_pos = fixed.find("author:").unwrap();
705        let title_pos = fixed.find("title:").unwrap();
706        assert!(author_pos < title_pos);
707    }
708
709    #[test]
710    fn test_yaml_no_fix_with_comments() {
711        let rule = create_enabled_rule();
712        let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading";
713        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
714        let result = rule.check(&ctx).unwrap();
715
716        assert_eq!(result.len(), 1);
717        assert!(result[0].message.contains("auto-fix unavailable"));
718        assert!(result[0].fix.is_none());
719
720        // Fix should not modify content
721        let fixed = rule.fix(&ctx).unwrap();
722        assert_eq!(fixed, content);
723    }
724
725    #[test]
726    fn test_yaml_single_key() {
727        let rule = create_enabled_rule();
728        let content = "---\ntitle: Test\n---\n\n# Heading";
729        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
730        let result = rule.check(&ctx).unwrap();
731
732        // Single key is always sorted
733        assert!(result.is_empty());
734    }
735
736    #[test]
737    fn test_yaml_nested_keys_ignored() {
738        let rule = create_enabled_rule();
739        // Only top-level keys are checked, nested keys are ignored
740        let content = "---\nauthor:\n  name: John\n  email: john@example.com\ntitle: Test\n---\n\n# Heading";
741        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
742        let result = rule.check(&ctx).unwrap();
743
744        // author, title are sorted
745        assert!(result.is_empty());
746    }
747
748    #[test]
749    fn test_yaml_fix_idempotent() {
750        let rule = create_enabled_rule();
751        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
752        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
753        let fixed_once = rule.fix(&ctx).unwrap();
754
755        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
756        let fixed_twice = rule.fix(&ctx2).unwrap();
757
758        assert_eq!(fixed_once, fixed_twice);
759    }
760
761    #[test]
762    fn test_yaml_complex_values() {
763        let rule = create_enabled_rule();
764        // Keys in sorted order: author, tags, title
765        let content =
766            "---\nauthor: John Doe\ntags:\n  - rust\n  - markdown\ntitle: \"Test: A Complex Title\"\n---\n\n# Heading";
767        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
768        let result = rule.check(&ctx).unwrap();
769
770        // author, tags, title - sorted
771        assert!(result.is_empty());
772    }
773
774    // ==================== TOML Tests ====================
775
776    #[test]
777    fn test_toml_sorted_keys() {
778        let rule = create_enabled_rule();
779        let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
780        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
781        let result = rule.check(&ctx).unwrap();
782
783        assert!(result.is_empty());
784    }
785
786    #[test]
787    fn test_toml_unsorted_keys() {
788        let rule = create_enabled_rule();
789        let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
790        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
791        let result = rule.check(&ctx).unwrap();
792
793        assert_eq!(result.len(), 1);
794        assert!(result[0].message.contains("TOML"));
795        assert!(result[0].message.contains("not sorted"));
796    }
797
798    #[test]
799    fn test_toml_fix_sorts_keys() {
800        let rule = create_enabled_rule();
801        let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
802        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
803        let fixed = rule.fix(&ctx).unwrap();
804
805        // Keys should be sorted
806        let author_pos = fixed.find("author").unwrap();
807        let title_pos = fixed.find("title").unwrap();
808        assert!(author_pos < title_pos);
809    }
810
811    #[test]
812    fn test_toml_no_fix_with_comments() {
813        let rule = create_enabled_rule();
814        let content = "+++\ntitle = \"Test\"\n# This is a comment\nauthor = \"John\"\n+++\n\n# Heading";
815        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816        let result = rule.check(&ctx).unwrap();
817
818        assert_eq!(result.len(), 1);
819        assert!(result[0].message.contains("auto-fix unavailable"));
820
821        // Fix should not modify content
822        let fixed = rule.fix(&ctx).unwrap();
823        assert_eq!(fixed, content);
824    }
825
826    // ==================== JSON Tests ====================
827
828    #[test]
829    fn test_json_sorted_keys() {
830        let rule = create_enabled_rule();
831        let content = "{\n\"author\": \"John\",\n\"title\": \"Test\"\n}\n\n# Heading";
832        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
833        let result = rule.check(&ctx).unwrap();
834
835        assert!(result.is_empty());
836    }
837
838    #[test]
839    fn test_json_unsorted_keys() {
840        let rule = create_enabled_rule();
841        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
842        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
843        let result = rule.check(&ctx).unwrap();
844
845        assert_eq!(result.len(), 1);
846        assert!(result[0].message.contains("JSON"));
847        assert!(result[0].message.contains("not sorted"));
848    }
849
850    #[test]
851    fn test_json_fix_sorts_keys() {
852        let rule = create_enabled_rule();
853        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
854        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
855        let fixed = rule.fix(&ctx).unwrap();
856
857        // Keys should be sorted
858        let author_pos = fixed.find("author").unwrap();
859        let title_pos = fixed.find("title").unwrap();
860        assert!(author_pos < title_pos);
861    }
862
863    #[test]
864    fn test_json_always_fixable() {
865        let rule = create_enabled_rule();
866        // JSON has no comments, so should always be fixable
867        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
868        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
869        let result = rule.check(&ctx).unwrap();
870
871        assert_eq!(result.len(), 1);
872        assert!(result[0].fix.is_some()); // Always fixable
873        assert!(!result[0].message.contains("Auto-fix unavailable"));
874    }
875
876    // ==================== General Tests ====================
877
878    #[test]
879    fn test_empty_content() {
880        let rule = create_enabled_rule();
881        let content = "";
882        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
883        let result = rule.check(&ctx).unwrap();
884
885        assert!(result.is_empty());
886    }
887
888    #[test]
889    fn test_empty_frontmatter() {
890        let rule = create_enabled_rule();
891        let content = "---\n---\n\n# Heading";
892        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
893        let result = rule.check(&ctx).unwrap();
894
895        assert!(result.is_empty());
896    }
897
898    #[test]
899    fn test_toml_nested_tables_ignored() {
900        // Keys inside [extra] or [taxonomies] should NOT be checked
901        let rule = create_enabled_rule();
902        let content = "+++\ntitle = \"Programming\"\nsort_by = \"weight\"\n\n[extra]\nwe_have_extra = \"variables\"\n+++\n\n# Heading";
903        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
904        let result = rule.check(&ctx).unwrap();
905
906        // Only top-level keys (title, sort_by) should be checked, not we_have_extra
907        assert_eq!(result.len(), 1);
908        // Message shows first out-of-order pair: 'sort_by' should come before 'title'
909        assert!(result[0].message.contains("'sort_by' should come before 'title'"));
910        assert!(!result[0].message.contains("we_have_extra"));
911    }
912
913    #[test]
914    fn test_toml_nested_taxonomies_ignored() {
915        // Keys inside [taxonomies] should NOT be checked
916        let rule = create_enabled_rule();
917        let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[taxonomies]\ncategories = [\"test\"]\ntags = [\"foo\"]\n+++\n\n# Heading";
918        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
919        let result = rule.check(&ctx).unwrap();
920
921        // Only top-level keys (title, date) should be checked
922        assert_eq!(result.len(), 1);
923        // Message shows first out-of-order pair: 'date' should come before 'title'
924        assert!(result[0].message.contains("'date' should come before 'title'"));
925        assert!(!result[0].message.contains("categories"));
926        assert!(!result[0].message.contains("tags"));
927    }
928
929    // ==================== Edge Case Tests ====================
930
931    #[test]
932    fn test_yaml_unicode_keys() {
933        let rule = create_enabled_rule();
934        // Japanese keys should sort correctly
935        let content = "---\nタイトル: Test\nあいう: Value\n日本語: Content\n---\n\n# Heading";
936        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
937        let result = rule.check(&ctx).unwrap();
938
939        // Should detect unsorted keys (あいう < タイトル < 日本語 in Unicode order)
940        assert_eq!(result.len(), 1);
941    }
942
943    #[test]
944    fn test_yaml_keys_with_special_characters() {
945        let rule = create_enabled_rule();
946        // Keys with dashes and underscores
947        let content = "---\nmy-key: value1\nmy_key: value2\nmykey: value3\n---\n\n# Heading";
948        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
949        let result = rule.check(&ctx).unwrap();
950
951        // my-key, my_key, mykey - should be sorted
952        assert!(result.is_empty());
953    }
954
955    #[test]
956    fn test_yaml_keys_with_numbers() {
957        let rule = create_enabled_rule();
958        let content = "---\nkey1: value\nkey10: value\nkey2: value\n---\n\n# Heading";
959        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
960        let result = rule.check(&ctx).unwrap();
961
962        // key1, key10, key2 - lexicographic order (1 < 10 < 2)
963        assert!(result.is_empty());
964    }
965
966    #[test]
967    fn test_yaml_multiline_string_block_literal() {
968        let rule = create_enabled_rule();
969        let content =
970            "---\ndescription: |\n  This is a\n  multiline literal\ntitle: Test\nauthor: John\n---\n\n# Heading";
971        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972        let result = rule.check(&ctx).unwrap();
973
974        // description, title, author - first out-of-order: 'author' should come before 'title'
975        assert_eq!(result.len(), 1);
976        assert!(result[0].message.contains("'author' should come before 'title'"));
977    }
978
979    #[test]
980    fn test_yaml_multiline_string_folded() {
981        let rule = create_enabled_rule();
982        let content = "---\ndescription: >\n  This is a\n  folded string\nauthor: John\n---\n\n# Heading";
983        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
984        let result = rule.check(&ctx).unwrap();
985
986        // author, description - not sorted
987        assert_eq!(result.len(), 1);
988    }
989
990    #[test]
991    fn test_yaml_fix_preserves_multiline_values() {
992        let rule = create_enabled_rule();
993        let content = "---\ntitle: Test\ndescription: |\n  Line 1\n  Line 2\n---\n\n# Heading";
994        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
995        let fixed = rule.fix(&ctx).unwrap();
996
997        // description should come before title
998        let desc_pos = fixed.find("description").unwrap();
999        let title_pos = fixed.find("title").unwrap();
1000        assert!(desc_pos < title_pos);
1001    }
1002
1003    #[test]
1004    fn test_yaml_quoted_keys() {
1005        let rule = create_enabled_rule();
1006        let content = "---\n\"quoted-key\": value1\nunquoted: value2\n---\n\n# Heading";
1007        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1008        let result = rule.check(&ctx).unwrap();
1009
1010        // quoted-key should sort before unquoted
1011        assert!(result.is_empty());
1012    }
1013
1014    #[test]
1015    fn test_yaml_duplicate_keys() {
1016        // YAML allows duplicate keys (last one wins), but we should still sort
1017        let rule = create_enabled_rule();
1018        let content = "---\ntitle: First\nauthor: John\ntitle: Second\n---\n\n# Heading";
1019        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1020        let result = rule.check(&ctx).unwrap();
1021
1022        // Should still check sorting (title, author, title is not sorted)
1023        assert_eq!(result.len(), 1);
1024    }
1025
1026    #[test]
1027    fn test_toml_inline_table() {
1028        let rule = create_enabled_rule();
1029        let content =
1030            "+++\nauthor = { name = \"John\", email = \"john@example.com\" }\ntitle = \"Test\"\n+++\n\n# Heading";
1031        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1032        let result = rule.check(&ctx).unwrap();
1033
1034        // author, title - sorted
1035        assert!(result.is_empty());
1036    }
1037
1038    #[test]
1039    fn test_toml_array_of_tables() {
1040        let rule = create_enabled_rule();
1041        let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[[authors]]\nname = \"John\"\n\n[[authors]]\nname = \"Jane\"\n+++\n\n# Heading";
1042        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1043        let result = rule.check(&ctx).unwrap();
1044
1045        // Only top-level keys (title, date) checked - date < title, so unsorted
1046        assert_eq!(result.len(), 1);
1047        // Message shows first out-of-order pair: 'date' should come before 'title'
1048        assert!(result[0].message.contains("'date' should come before 'title'"));
1049    }
1050
1051    #[test]
1052    fn test_json_nested_objects() {
1053        let rule = create_enabled_rule();
1054        let content = "{\n\"author\": {\n  \"name\": \"John\",\n  \"email\": \"john@example.com\"\n},\n\"title\": \"Test\"\n}\n\n# Heading";
1055        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1056        let result = rule.check(&ctx).unwrap();
1057
1058        // Only top-level keys (author, title) checked - sorted
1059        assert!(result.is_empty());
1060    }
1061
1062    #[test]
1063    fn test_json_arrays() {
1064        let rule = create_enabled_rule();
1065        let content = "{\n\"tags\": [\"rust\", \"markdown\"],\n\"author\": \"John\"\n}\n\n# Heading";
1066        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1067        let result = rule.check(&ctx).unwrap();
1068
1069        // author, tags - not sorted (tags comes first)
1070        assert_eq!(result.len(), 1);
1071    }
1072
1073    #[test]
1074    fn test_fix_preserves_content_after_frontmatter() {
1075        let rule = create_enabled_rule();
1076        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n\nParagraph 1.\n\n- List item\n- Another item";
1077        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1078        let fixed = rule.fix(&ctx).unwrap();
1079
1080        // Verify content after frontmatter is preserved
1081        assert!(fixed.contains("# Heading"));
1082        assert!(fixed.contains("Paragraph 1."));
1083        assert!(fixed.contains("- List item"));
1084        assert!(fixed.contains("- Another item"));
1085    }
1086
1087    #[test]
1088    fn test_fix_yaml_produces_valid_yaml() {
1089        let rule = create_enabled_rule();
1090        let content = "---\ntitle: \"Test: A Title\"\nauthor: John Doe\ndate: 2024-01-15\n---\n\n# Heading";
1091        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1092        let fixed = rule.fix(&ctx).unwrap();
1093
1094        // The fixed output should be parseable as YAML
1095        // Extract frontmatter lines
1096        let lines: Vec<&str> = fixed.lines().collect();
1097        let fm_end = lines.iter().skip(1).position(|l| *l == "---").unwrap() + 1;
1098        let fm_content: String = lines[1..fm_end].join("\n");
1099
1100        // Should parse without error
1101        let parsed: Result<serde_yaml::Value, _> = serde_yaml::from_str(&fm_content);
1102        assert!(parsed.is_ok(), "Fixed YAML should be valid: {fm_content}");
1103    }
1104
1105    #[test]
1106    fn test_fix_toml_produces_valid_toml() {
1107        let rule = create_enabled_rule();
1108        let content = "+++\ntitle = \"Test\"\nauthor = \"John Doe\"\ndate = 2024-01-15\n+++\n\n# Heading";
1109        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1110        let fixed = rule.fix(&ctx).unwrap();
1111
1112        // Extract frontmatter
1113        let lines: Vec<&str> = fixed.lines().collect();
1114        let fm_end = lines.iter().skip(1).position(|l| *l == "+++").unwrap() + 1;
1115        let fm_content: String = lines[1..fm_end].join("\n");
1116
1117        // Should parse without error
1118        let parsed: Result<toml::Value, _> = toml::from_str(&fm_content);
1119        assert!(parsed.is_ok(), "Fixed TOML should be valid: {fm_content}");
1120    }
1121
1122    #[test]
1123    fn test_fix_json_produces_valid_json() {
1124        let rule = create_enabled_rule();
1125        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1126        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1127        let fixed = rule.fix(&ctx).unwrap();
1128
1129        // Extract JSON frontmatter (everything up to blank line)
1130        let json_end = fixed.find("\n\n").unwrap();
1131        let json_content = &fixed[..json_end];
1132
1133        // Should parse without error
1134        let parsed: Result<serde_json::Value, _> = serde_json::from_str(json_content);
1135        assert!(parsed.is_ok(), "Fixed JSON should be valid: {json_content}");
1136    }
1137
1138    #[test]
1139    fn test_many_keys_performance() {
1140        let rule = create_enabled_rule();
1141        // Generate frontmatter with 100 keys
1142        let mut keys: Vec<String> = (0..100).map(|i| format!("key{i:03}: value{i}")).collect();
1143        keys.reverse(); // Make them unsorted
1144        let content = format!("---\n{}\n---\n\n# Heading", keys.join("\n"));
1145
1146        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1147        let result = rule.check(&ctx).unwrap();
1148
1149        // Should detect unsorted keys
1150        assert_eq!(result.len(), 1);
1151    }
1152
1153    #[test]
1154    fn test_yaml_empty_value() {
1155        let rule = create_enabled_rule();
1156        let content = "---\ntitle:\nauthor: John\n---\n\n# Heading";
1157        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1158        let result = rule.check(&ctx).unwrap();
1159
1160        // author, title - not sorted
1161        assert_eq!(result.len(), 1);
1162    }
1163
1164    #[test]
1165    fn test_yaml_null_value() {
1166        let rule = create_enabled_rule();
1167        let content = "---\ntitle: null\nauthor: John\n---\n\n# Heading";
1168        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1169        let result = rule.check(&ctx).unwrap();
1170
1171        assert_eq!(result.len(), 1);
1172    }
1173
1174    #[test]
1175    fn test_yaml_boolean_values() {
1176        let rule = create_enabled_rule();
1177        let content = "---\ndraft: true\nauthor: John\n---\n\n# Heading";
1178        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1179        let result = rule.check(&ctx).unwrap();
1180
1181        // author, draft - not sorted
1182        assert_eq!(result.len(), 1);
1183    }
1184
1185    #[test]
1186    fn test_toml_boolean_values() {
1187        let rule = create_enabled_rule();
1188        let content = "+++\ndraft = true\nauthor = \"John\"\n+++\n\n# Heading";
1189        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1190        let result = rule.check(&ctx).unwrap();
1191
1192        assert_eq!(result.len(), 1);
1193    }
1194
1195    #[test]
1196    fn test_yaml_list_at_top_level() {
1197        let rule = create_enabled_rule();
1198        let content = "---\ntags:\n  - rust\n  - markdown\nauthor: John\n---\n\n# Heading";
1199        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1200        let result = rule.check(&ctx).unwrap();
1201
1202        // author, tags - not sorted (tags comes first)
1203        assert_eq!(result.len(), 1);
1204    }
1205
1206    #[test]
1207    fn test_three_keys_all_orderings() {
1208        let rule = create_enabled_rule();
1209
1210        // Test all 6 permutations of a, b, c
1211        let orderings = [
1212            ("a, b, c", "---\na: 1\nb: 2\nc: 3\n---\n\n# H", true),  // sorted
1213            ("a, c, b", "---\na: 1\nc: 3\nb: 2\n---\n\n# H", false), // unsorted
1214            ("b, a, c", "---\nb: 2\na: 1\nc: 3\n---\n\n# H", false), // unsorted
1215            ("b, c, a", "---\nb: 2\nc: 3\na: 1\n---\n\n# H", false), // unsorted
1216            ("c, a, b", "---\nc: 3\na: 1\nb: 2\n---\n\n# H", false), // unsorted
1217            ("c, b, a", "---\nc: 3\nb: 2\na: 1\n---\n\n# H", false), // unsorted
1218        ];
1219
1220        for (name, content, should_pass) in orderings {
1221            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1222            let result = rule.check(&ctx).unwrap();
1223            assert_eq!(
1224                result.is_empty(),
1225                should_pass,
1226                "Ordering {name} should {} pass",
1227                if should_pass { "" } else { "not" }
1228            );
1229        }
1230    }
1231
1232    #[test]
1233    fn test_crlf_line_endings() {
1234        let rule = create_enabled_rule();
1235        let content = "---\r\ntitle: Test\r\nauthor: John\r\n---\r\n\r\n# Heading";
1236        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1237        let result = rule.check(&ctx).unwrap();
1238
1239        // Should detect unsorted keys with CRLF
1240        assert_eq!(result.len(), 1);
1241    }
1242
1243    #[test]
1244    fn test_json_escaped_quotes_in_keys() {
1245        let rule = create_enabled_rule();
1246        // This is technically invalid JSON but tests regex robustness
1247        let content = "{\n\"normal\": \"value\",\n\"key\": \"with \\\"quotes\\\"\"\n}\n\n# Heading";
1248        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1249        let result = rule.check(&ctx).unwrap();
1250
1251        // key, normal - not sorted
1252        assert_eq!(result.len(), 1);
1253    }
1254
1255    // ==================== Warning-based Fix Tests (LSP Path) ====================
1256
1257    #[test]
1258    fn test_warning_fix_yaml_sorts_keys() {
1259        let rule = create_enabled_rule();
1260        let content = "---\nbbb: 123\naaa:\n  - hello\n  - world\n---\n\n# Heading\n";
1261        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1262        let warnings = rule.check(&ctx).unwrap();
1263
1264        assert_eq!(warnings.len(), 1);
1265        assert!(warnings[0].fix.is_some(), "Warning should have a fix attached for LSP");
1266
1267        let fix = warnings[0].fix.as_ref().unwrap();
1268        assert_eq!(fix.range, 0..content.len(), "Fix should replace entire content");
1269
1270        // Apply the fix using the warning-based fix utility (LSP path)
1271        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1272
1273        // Verify keys are sorted
1274        let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1275        let bbb_pos = fixed.find("bbb:").expect("bbb should exist");
1276        assert!(aaa_pos < bbb_pos, "aaa should come before bbb after sorting");
1277    }
1278
1279    #[test]
1280    fn test_warning_fix_preserves_yaml_list_indentation() {
1281        let rule = create_enabled_rule();
1282        let content = "---\nbbb: 123\naaa:\n  - hello\n  - world\n---\n\n# Heading\n";
1283        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1284        let warnings = rule.check(&ctx).unwrap();
1285
1286        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1287
1288        // Verify list items retain their 2-space indentation
1289        assert!(
1290            fixed.contains("  - hello"),
1291            "List indentation should be preserved: {fixed}"
1292        );
1293        assert!(
1294            fixed.contains("  - world"),
1295            "List indentation should be preserved: {fixed}"
1296        );
1297    }
1298
1299    #[test]
1300    fn test_warning_fix_preserves_nested_object_indentation() {
1301        let rule = create_enabled_rule();
1302        let content = "---\nzzzz: value\naaaa:\n  nested_key: nested_value\n  another: 123\n---\n\n# Heading\n";
1303        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1304        let warnings = rule.check(&ctx).unwrap();
1305
1306        assert_eq!(warnings.len(), 1);
1307        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1308
1309        // Verify aaaa comes before zzzz
1310        let aaaa_pos = fixed.find("aaaa:").expect("aaaa should exist");
1311        let zzzz_pos = fixed.find("zzzz:").expect("zzzz should exist");
1312        assert!(aaaa_pos < zzzz_pos, "aaaa should come before zzzz");
1313
1314        // Verify nested keys retain their 2-space indentation
1315        assert!(
1316            fixed.contains("  nested_key: nested_value"),
1317            "Nested object indentation should be preserved: {fixed}"
1318        );
1319        assert!(
1320            fixed.contains("  another: 123"),
1321            "Nested object indentation should be preserved: {fixed}"
1322        );
1323    }
1324
1325    #[test]
1326    fn test_warning_fix_preserves_deeply_nested_structure() {
1327        let rule = create_enabled_rule();
1328        let content = "---\nzzz: top\naaa:\n  level1:\n    level2:\n      - item1\n      - item2\n---\n\n# Content\n";
1329        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1330        let warnings = rule.check(&ctx).unwrap();
1331
1332        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1333
1334        // Verify sorting
1335        let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1336        let zzz_pos = fixed.find("zzz:").expect("zzz should exist");
1337        assert!(aaa_pos < zzz_pos, "aaa should come before zzz");
1338
1339        // Verify all indentation levels are preserved
1340        assert!(fixed.contains("  level1:"), "2-space indent should be preserved");
1341        assert!(fixed.contains("    level2:"), "4-space indent should be preserved");
1342        assert!(fixed.contains("      - item1"), "6-space indent should be preserved");
1343        assert!(fixed.contains("      - item2"), "6-space indent should be preserved");
1344    }
1345
1346    #[test]
1347    fn test_warning_fix_toml_sorts_keys() {
1348        let rule = create_enabled_rule();
1349        let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading\n";
1350        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1351        let warnings = rule.check(&ctx).unwrap();
1352
1353        assert_eq!(warnings.len(), 1);
1354        assert!(warnings[0].fix.is_some(), "TOML warning should have a fix");
1355
1356        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1357
1358        // Verify keys are sorted
1359        let author_pos = fixed.find("author").expect("author should exist");
1360        let title_pos = fixed.find("title").expect("title should exist");
1361        assert!(author_pos < title_pos, "author should come before title");
1362    }
1363
1364    #[test]
1365    fn test_warning_fix_json_sorts_keys() {
1366        let rule = create_enabled_rule();
1367        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading\n";
1368        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1369        let warnings = rule.check(&ctx).unwrap();
1370
1371        assert_eq!(warnings.len(), 1);
1372        assert!(warnings[0].fix.is_some(), "JSON warning should have a fix");
1373
1374        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1375
1376        // Verify keys are sorted
1377        let author_pos = fixed.find("author").expect("author should exist");
1378        let title_pos = fixed.find("title").expect("title should exist");
1379        assert!(author_pos < title_pos, "author should come before title");
1380    }
1381
1382    #[test]
1383    fn test_warning_fix_no_fix_when_comments_present() {
1384        let rule = create_enabled_rule();
1385        let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading\n";
1386        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1387        let warnings = rule.check(&ctx).unwrap();
1388
1389        assert_eq!(warnings.len(), 1);
1390        assert!(
1391            warnings[0].fix.is_none(),
1392            "Warning should NOT have a fix when comments are present"
1393        );
1394        assert!(
1395            warnings[0].message.contains("auto-fix unavailable"),
1396            "Message should indicate auto-fix is unavailable"
1397        );
1398    }
1399
1400    #[test]
1401    fn test_warning_fix_preserves_content_after_frontmatter() {
1402        let rule = create_enabled_rule();
1403        let content = "---\nzzz: last\naaa: first\n---\n\n# Heading\n\nParagraph with content.\n\n- List item\n";
1404        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1405        let warnings = rule.check(&ctx).unwrap();
1406
1407        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1408
1409        // Verify content after frontmatter is preserved
1410        assert!(fixed.contains("# Heading"), "Heading should be preserved");
1411        assert!(
1412            fixed.contains("Paragraph with content."),
1413            "Paragraph should be preserved"
1414        );
1415        assert!(fixed.contains("- List item"), "List item should be preserved");
1416    }
1417
1418    #[test]
1419    fn test_warning_fix_idempotent() {
1420        let rule = create_enabled_rule();
1421        let content = "---\nbbb: 2\naaa: 1\n---\n\n# Heading\n";
1422        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1423        let warnings = rule.check(&ctx).unwrap();
1424
1425        let fixed_once = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1426
1427        // Apply again - should produce no warnings
1428        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1429        let warnings2 = rule.check(&ctx2).unwrap();
1430
1431        assert!(
1432            warnings2.is_empty(),
1433            "After fixing, no more warnings should be produced"
1434        );
1435    }
1436
1437    #[test]
1438    fn test_warning_fix_preserves_multiline_block_literal() {
1439        let rule = create_enabled_rule();
1440        let content = "---\nzzz: simple\naaa: |\n  Line 1 of block\n  Line 2 of block\n---\n\n# Heading\n";
1441        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1442        let warnings = rule.check(&ctx).unwrap();
1443
1444        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1445
1446        // Verify block literal is preserved with indentation
1447        assert!(fixed.contains("aaa: |"), "Block literal marker should be preserved");
1448        assert!(
1449            fixed.contains("  Line 1 of block"),
1450            "Block literal line 1 should be preserved with indent"
1451        );
1452        assert!(
1453            fixed.contains("  Line 2 of block"),
1454            "Block literal line 2 should be preserved with indent"
1455        );
1456    }
1457
1458    #[test]
1459    fn test_warning_fix_preserves_folded_string() {
1460        let rule = create_enabled_rule();
1461        let content = "---\nzzz: simple\naaa: >\n  Folded line 1\n  Folded line 2\n---\n\n# Content\n";
1462        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1463        let warnings = rule.check(&ctx).unwrap();
1464
1465        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1466
1467        // Verify folded string is preserved
1468        assert!(fixed.contains("aaa: >"), "Folded string marker should be preserved");
1469        assert!(
1470            fixed.contains("  Folded line 1"),
1471            "Folded line 1 should be preserved with indent"
1472        );
1473        assert!(
1474            fixed.contains("  Folded line 2"),
1475            "Folded line 2 should be preserved with indent"
1476        );
1477    }
1478
1479    #[test]
1480    fn test_warning_fix_preserves_4_space_indentation() {
1481        let rule = create_enabled_rule();
1482        // Some projects use 4-space indentation
1483        let content = "---\nzzz: value\naaa:\n    nested: with_4_spaces\n    another: value\n---\n\n# Heading\n";
1484        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1485        let warnings = rule.check(&ctx).unwrap();
1486
1487        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1488
1489        // Verify 4-space indentation is preserved exactly
1490        assert!(
1491            fixed.contains("    nested: with_4_spaces"),
1492            "4-space indentation should be preserved: {fixed}"
1493        );
1494        assert!(
1495            fixed.contains("    another: value"),
1496            "4-space indentation should be preserved: {fixed}"
1497        );
1498    }
1499
1500    #[test]
1501    fn test_warning_fix_preserves_tab_indentation() {
1502        let rule = create_enabled_rule();
1503        // Some projects use tabs
1504        let content = "---\nzzz: value\naaa:\n\tnested: with_tab\n\tanother: value\n---\n\n# Heading\n";
1505        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1506        let warnings = rule.check(&ctx).unwrap();
1507
1508        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1509
1510        // Verify tab indentation is preserved exactly
1511        assert!(
1512            fixed.contains("\tnested: with_tab"),
1513            "Tab indentation should be preserved: {fixed}"
1514        );
1515        assert!(
1516            fixed.contains("\tanother: value"),
1517            "Tab indentation should be preserved: {fixed}"
1518        );
1519    }
1520
1521    #[test]
1522    fn test_warning_fix_preserves_inline_list() {
1523        let rule = create_enabled_rule();
1524        // Inline YAML lists should be preserved
1525        let content = "---\nzzz: value\naaa: [one, two, three]\n---\n\n# Heading\n";
1526        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1527        let warnings = rule.check(&ctx).unwrap();
1528
1529        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1530
1531        // Verify inline list format is preserved
1532        assert!(
1533            fixed.contains("aaa: [one, two, three]"),
1534            "Inline list should be preserved exactly: {fixed}"
1535        );
1536    }
1537
1538    #[test]
1539    fn test_warning_fix_preserves_quoted_strings() {
1540        let rule = create_enabled_rule();
1541        // Quoted strings with special chars
1542        let content = "---\nzzz: simple\naaa: \"value with: colon\"\nbbb: 'single quotes'\n---\n\n# Heading\n";
1543        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1544        let warnings = rule.check(&ctx).unwrap();
1545
1546        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1547
1548        // Verify quoted strings are preserved exactly
1549        assert!(
1550            fixed.contains("aaa: \"value with: colon\""),
1551            "Double-quoted string should be preserved: {fixed}"
1552        );
1553        assert!(
1554            fixed.contains("bbb: 'single quotes'"),
1555            "Single-quoted string should be preserved: {fixed}"
1556        );
1557    }
1558
1559    // ==================== Custom Key Order Tests ====================
1560
1561    #[test]
1562    fn test_yaml_custom_key_order_sorted() {
1563        // Keys match the custom order: title, date, author
1564        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1565        let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1566        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1567        let result = rule.check(&ctx).unwrap();
1568
1569        // Keys are in the custom order, should be considered sorted
1570        assert!(result.is_empty());
1571    }
1572
1573    #[test]
1574    fn test_yaml_custom_key_order_unsorted() {
1575        // Keys NOT in the custom order: should report author before date
1576        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1577        let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1578        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1579        let result = rule.check(&ctx).unwrap();
1580
1581        assert_eq!(result.len(), 1);
1582        // 'date' should come before 'author' according to custom order
1583        assert!(result[0].message.contains("'date' should come before 'author'"));
1584    }
1585
1586    #[test]
1587    fn test_yaml_custom_key_order_unlisted_keys_alphabetical() {
1588        // unlisted keys should come after specified keys, sorted alphabetically
1589        let rule = create_rule_with_key_order(vec!["title"]);
1590        let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1591        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1592        let result = rule.check(&ctx).unwrap();
1593
1594        // title is specified, author and date are not - they should be alphabetically after title
1595        // author < date alphabetically, so this is sorted
1596        assert!(result.is_empty());
1597    }
1598
1599    #[test]
1600    fn test_yaml_custom_key_order_unlisted_keys_unsorted() {
1601        // unlisted keys out of alphabetical order
1602        let rule = create_rule_with_key_order(vec!["title"]);
1603        let content = "---\ntitle: Test\nzebra: Zoo\nauthor: John\n---\n\n# Heading";
1604        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1605        let result = rule.check(&ctx).unwrap();
1606
1607        // zebra and author are unlisted, author < zebra alphabetically
1608        assert_eq!(result.len(), 1);
1609        assert!(result[0].message.contains("'author' should come before 'zebra'"));
1610    }
1611
1612    #[test]
1613    fn test_yaml_custom_key_order_fix() {
1614        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1615        let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1616        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1617        let fixed = rule.fix(&ctx).unwrap();
1618
1619        // Keys should be in custom order: title, date, author
1620        let title_pos = fixed.find("title:").unwrap();
1621        let date_pos = fixed.find("date:").unwrap();
1622        let author_pos = fixed.find("author:").unwrap();
1623        assert!(
1624            title_pos < date_pos && date_pos < author_pos,
1625            "Fixed YAML should have keys in custom order: title, date, author. Got:\n{fixed}"
1626        );
1627    }
1628
1629    #[test]
1630    fn test_yaml_custom_key_order_fix_with_unlisted() {
1631        // Mix of listed and unlisted keys
1632        let rule = create_rule_with_key_order(vec!["title", "author"]);
1633        let content = "---\nzebra: Zoo\nauthor: John\ntitle: Test\naardvark: Ant\n---\n\n# Heading";
1634        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1635        let fixed = rule.fix(&ctx).unwrap();
1636
1637        // Order should be: title, author (specified), then aardvark, zebra (alphabetical)
1638        let title_pos = fixed.find("title:").unwrap();
1639        let author_pos = fixed.find("author:").unwrap();
1640        let aardvark_pos = fixed.find("aardvark:").unwrap();
1641        let zebra_pos = fixed.find("zebra:").unwrap();
1642
1643        assert!(
1644            title_pos < author_pos && author_pos < aardvark_pos && aardvark_pos < zebra_pos,
1645            "Fixed YAML should have specified keys first, then unlisted alphabetically. Got:\n{fixed}"
1646        );
1647    }
1648
1649    #[test]
1650    fn test_toml_custom_key_order_sorted() {
1651        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1652        let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\nauthor = \"John\"\n+++\n\n# Heading";
1653        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1654        let result = rule.check(&ctx).unwrap();
1655
1656        assert!(result.is_empty());
1657    }
1658
1659    #[test]
1660    fn test_toml_custom_key_order_unsorted() {
1661        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1662        let content = "+++\nauthor = \"John\"\ntitle = \"Test\"\ndate = \"2024-01-01\"\n+++\n\n# Heading";
1663        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1664        let result = rule.check(&ctx).unwrap();
1665
1666        assert_eq!(result.len(), 1);
1667        assert!(result[0].message.contains("TOML"));
1668    }
1669
1670    #[test]
1671    fn test_json_custom_key_order_sorted() {
1672        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1673        let content = "{\n  \"title\": \"Test\",\n  \"date\": \"2024-01-01\",\n  \"author\": \"John\"\n}\n\n# Heading";
1674        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1675        let result = rule.check(&ctx).unwrap();
1676
1677        assert!(result.is_empty());
1678    }
1679
1680    #[test]
1681    fn test_json_custom_key_order_unsorted() {
1682        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1683        let content = "{\n  \"author\": \"John\",\n  \"title\": \"Test\",\n  \"date\": \"2024-01-01\"\n}\n\n# Heading";
1684        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1685        let result = rule.check(&ctx).unwrap();
1686
1687        assert_eq!(result.len(), 1);
1688        assert!(result[0].message.contains("JSON"));
1689    }
1690
1691    #[test]
1692    fn test_key_order_case_insensitive_match() {
1693        // Key order should match case-insensitively
1694        let rule = create_rule_with_key_order(vec!["Title", "Date", "Author"]);
1695        let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1696        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1697        let result = rule.check(&ctx).unwrap();
1698
1699        // Keys match the custom order (case-insensitive)
1700        assert!(result.is_empty());
1701    }
1702
1703    #[test]
1704    fn test_key_order_partial_match() {
1705        // Some keys specified, some not
1706        let rule = create_rule_with_key_order(vec!["title"]);
1707        let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1708        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1709        let result = rule.check(&ctx).unwrap();
1710
1711        // Only 'title' is specified, so it comes first
1712        // 'author' and 'date' are unlisted and sorted alphabetically: author < date
1713        // But current order is date, author - WRONG
1714        // Wait, content has: title, date, author
1715        // title is specified (pos 0)
1716        // date is unlisted (pos MAX, "date")
1717        // author is unlisted (pos MAX, "author")
1718        // Since both unlisted, compare alphabetically: author < date
1719        // So author should come before date, but date comes before author in content
1720        // This IS unsorted!
1721        assert_eq!(result.len(), 1);
1722        assert!(result[0].message.contains("'author' should come before 'date'"));
1723    }
1724
1725    // ==================== Key Order Edge Cases ====================
1726
1727    #[test]
1728    fn test_key_order_empty_array_falls_back_to_alphabetical() {
1729        // Empty key_order should behave like alphabetical sorting
1730        let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1731            enabled: true,
1732            key_order: Some(vec![]),
1733        });
1734        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
1735        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1736        let result = rule.check(&ctx).unwrap();
1737
1738        // With empty key_order, all keys are unlisted → alphabetical
1739        // author < title, but title comes first in content → unsorted
1740        assert_eq!(result.len(), 1);
1741        assert!(result[0].message.contains("'author' should come before 'title'"));
1742    }
1743
1744    #[test]
1745    fn test_key_order_single_key() {
1746        // key_order with only one key
1747        let rule = create_rule_with_key_order(vec!["title"]);
1748        let content = "---\ntitle: Test\n---\n\n# Heading";
1749        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1750        let result = rule.check(&ctx).unwrap();
1751
1752        assert!(result.is_empty());
1753    }
1754
1755    #[test]
1756    fn test_key_order_all_keys_specified() {
1757        // All document keys are in key_order
1758        let rule = create_rule_with_key_order(vec!["title", "author", "date"]);
1759        let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1760        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1761        let result = rule.check(&ctx).unwrap();
1762
1763        assert!(result.is_empty());
1764    }
1765
1766    #[test]
1767    fn test_key_order_no_keys_match() {
1768        // None of the document keys are in key_order
1769        let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1770        let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1771        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1772        let result = rule.check(&ctx).unwrap();
1773
1774        // All keys are unlisted, so they sort alphabetically: author, date, title
1775        // Current order is author, date, title - which IS sorted
1776        assert!(result.is_empty());
1777    }
1778
1779    #[test]
1780    fn test_key_order_no_keys_match_unsorted() {
1781        // None of the document keys are in key_order, and they're out of alphabetical order
1782        let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1783        let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1784        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1785        let result = rule.check(&ctx).unwrap();
1786
1787        // All unlisted → alphabetical: author < date < title
1788        // Current: title, date, author → unsorted
1789        assert_eq!(result.len(), 1);
1790    }
1791
1792    #[test]
1793    fn test_key_order_duplicate_keys_in_config() {
1794        // Duplicate keys in key_order (should use first occurrence)
1795        let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1796            enabled: true,
1797            key_order: Some(vec![
1798                "title".to_string(),
1799                "author".to_string(),
1800                "title".to_string(), // duplicate
1801            ]),
1802        });
1803        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
1804        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1805        let result = rule.check(&ctx).unwrap();
1806
1807        // title (pos 0), author (pos 1) → sorted
1808        assert!(result.is_empty());
1809    }
1810
1811    #[test]
1812    fn test_key_order_with_comments_still_skips_fix() {
1813        // key_order should not affect the comment-skipping behavior
1814        let rule = create_rule_with_key_order(vec!["title", "author"]);
1815        let content = "---\n# This is a comment\nauthor: John\ntitle: Test\n---\n\n# Heading";
1816        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1817        let result = rule.check(&ctx).unwrap();
1818
1819        // Should detect unsorted AND indicate no auto-fix due to comments
1820        assert_eq!(result.len(), 1);
1821        assert!(result[0].message.contains("auto-fix unavailable"));
1822        assert!(result[0].fix.is_none());
1823    }
1824
1825    #[test]
1826    fn test_toml_custom_key_order_fix() {
1827        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1828        let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
1829        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1830        let fixed = rule.fix(&ctx).unwrap();
1831
1832        // Keys should be in custom order: title, date, author
1833        let title_pos = fixed.find("title").unwrap();
1834        let date_pos = fixed.find("date").unwrap();
1835        let author_pos = fixed.find("author").unwrap();
1836        assert!(
1837            title_pos < date_pos && date_pos < author_pos,
1838            "Fixed TOML should have keys in custom order. Got:\n{fixed}"
1839        );
1840    }
1841
1842    #[test]
1843    fn test_json_custom_key_order_fix() {
1844        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1845        let content = "{\n  \"author\": \"John\",\n  \"date\": \"2024-01-01\",\n  \"title\": \"Test\"\n}\n\n# Heading";
1846        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1847        let fixed = rule.fix(&ctx).unwrap();
1848
1849        // Keys should be in custom order: title, date, author
1850        let title_pos = fixed.find("\"title\"").unwrap();
1851        let date_pos = fixed.find("\"date\"").unwrap();
1852        let author_pos = fixed.find("\"author\"").unwrap();
1853        assert!(
1854            title_pos < date_pos && date_pos < author_pos,
1855            "Fixed JSON should have keys in custom order. Got:\n{fixed}"
1856        );
1857    }
1858
1859    #[test]
1860    fn test_key_order_unicode_keys() {
1861        // Unicode keys in key_order
1862        let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1863            enabled: true,
1864            key_order: Some(vec!["タイトル".to_string(), "著者".to_string()]),
1865        });
1866        let content = "---\nタイトル: テスト\n著者: 山田太郎\n---\n\n# Heading";
1867        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1868        let result = rule.check(&ctx).unwrap();
1869
1870        // Keys match the custom order
1871        assert!(result.is_empty());
1872    }
1873
1874    #[test]
1875    fn test_key_order_mixed_specified_and_unlisted_boundary() {
1876        // Test the boundary between specified and unlisted keys
1877        let rule = create_rule_with_key_order(vec!["z_last_specified"]);
1878        let content = "---\nz_last_specified: value\na_first_unlisted: value\n---\n\n# Heading";
1879        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1880        let result = rule.check(&ctx).unwrap();
1881
1882        // z_last_specified (pos 0) should come before a_first_unlisted (pos MAX)
1883        // even though 'a' < 'z' alphabetically
1884        assert!(result.is_empty());
1885    }
1886
1887    #[test]
1888    fn test_key_order_fix_preserves_values() {
1889        // Ensure fix preserves complex values when reordering with key_order
1890        let rule = create_rule_with_key_order(vec!["title", "tags"]);
1891        let content = "---\ntags:\n  - rust\n  - markdown\ntitle: Test\n---\n\n# Heading";
1892        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1893        let fixed = rule.fix(&ctx).unwrap();
1894
1895        // title should come before tags
1896        let title_pos = fixed.find("title:").unwrap();
1897        let tags_pos = fixed.find("tags:").unwrap();
1898        assert!(title_pos < tags_pos, "title should come before tags");
1899
1900        // Nested list should be preserved
1901        assert!(fixed.contains("- rust"), "List items should be preserved");
1902        assert!(fixed.contains("- markdown"), "List items should be preserved");
1903    }
1904
1905    #[test]
1906    fn test_key_order_idempotent_fix() {
1907        // Fixing twice should produce the same result
1908        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1909        let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1910        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1911
1912        let fixed_once = rule.fix(&ctx).unwrap();
1913        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1914        let fixed_twice = rule.fix(&ctx2).unwrap();
1915
1916        assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
1917    }
1918
1919    #[test]
1920    fn test_key_order_respects_later_position_over_alphabetical() {
1921        // If key_order says "z" comes before "a", that should be respected
1922        let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
1923        let content = "---\nzebra: Zoo\naardvark: Ant\n---\n\n# Heading";
1924        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1925        let result = rule.check(&ctx).unwrap();
1926
1927        // zebra (pos 0), aardvark (pos 1) → sorted according to key_order
1928        assert!(result.is_empty());
1929    }
1930
1931    // ==================== JSON braces in string values ====================
1932
1933    #[test]
1934    fn test_json_braces_in_string_values_extracts_all_keys() {
1935        // Braces inside JSON string values should not affect depth tracking.
1936        // The key "author" (on the line after the brace-containing value) must be extracted.
1937        // Content is already sorted, so no warnings expected.
1938        let rule = create_enabled_rule();
1939        let content = "{\n\"author\": \"Someone\",\n\"description\": \"Use { to open\",\n\"tags\": [\"a\"],\n\"title\": \"My Post\"\n}\n\nContent here.\n";
1940        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1941        let result = rule.check(&ctx).unwrap();
1942
1943        // If all 4 keys are extracted, they are already sorted: author, description, tags, title
1944        assert!(
1945            result.is_empty(),
1946            "All keys should be extracted and recognized as sorted. Got: {result:?}"
1947        );
1948    }
1949
1950    #[test]
1951    fn test_json_braces_in_string_key_after_brace_value_detected() {
1952        // Specifically verify that a key appearing AFTER a line with unbalanced braces in a string is extracted
1953        let rule = create_enabled_rule();
1954        // "description" has an unbalanced `{` in its value
1955        // "author" comes on the next line and must be detected as a top-level key
1956        let content = "{\n\"description\": \"Use { to open\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
1957        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1958        let result = rule.check(&ctx).unwrap();
1959
1960        // author < description alphabetically, but description comes first => unsorted
1961        // The warning should mention 'author' should come before 'description'
1962        assert_eq!(
1963            result.len(),
1964            1,
1965            "Should detect unsorted keys after brace-containing string value"
1966        );
1967        assert!(
1968            result[0].message.contains("'author' should come before 'description'"),
1969            "Should report author before description. Got: {}",
1970            result[0].message
1971        );
1972    }
1973
1974    #[test]
1975    fn test_json_brackets_in_string_values() {
1976        // Brackets inside JSON string values should not affect depth tracking
1977        let rule = create_enabled_rule();
1978        let content = "{\n\"description\": \"My [Post]\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
1979        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1980        let result = rule.check(&ctx).unwrap();
1981
1982        // author < description, but description comes first => unsorted
1983        assert_eq!(
1984            result.len(),
1985            1,
1986            "Should detect unsorted keys despite brackets in string values"
1987        );
1988        assert!(
1989            result[0].message.contains("'author' should come before 'description'"),
1990            "Got: {}",
1991            result[0].message
1992        );
1993    }
1994
1995    #[test]
1996    fn test_json_escaped_quotes_in_values() {
1997        // Escaped quotes inside values should not break string tracking
1998        let rule = create_enabled_rule();
1999        let content = "{\n\"title\": \"He said \\\"hello {world}\\\"\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2000        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2001        let result = rule.check(&ctx).unwrap();
2002
2003        // author < title, title comes first => unsorted
2004        assert_eq!(result.len(), 1, "Should handle escaped quotes with braces in values");
2005        assert!(
2006            result[0].message.contains("'author' should come before 'title'"),
2007            "Got: {}",
2008            result[0].message
2009        );
2010    }
2011
2012    #[test]
2013    fn test_json_multiple_braces_in_string() {
2014        // Multiple unbalanced braces in string values
2015        let rule = create_enabled_rule();
2016        let content = "{\n\"pattern\": \"{{{}}\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2017        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2018        let result = rule.check(&ctx).unwrap();
2019
2020        // author < pattern, but pattern comes first => unsorted
2021        assert_eq!(result.len(), 1, "Should handle multiple braces in string values");
2022        assert!(
2023            result[0].message.contains("'author' should come before 'pattern'"),
2024            "Got: {}",
2025            result[0].message
2026        );
2027    }
2028
2029    #[test]
2030    fn test_key_order_detects_wrong_custom_order() {
2031        // Document has aardvark before zebra, but key_order says zebra first
2032        let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
2033        let content = "---\naardvark: Ant\nzebra: Zoo\n---\n\n# Heading";
2034        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2035        let result = rule.check(&ctx).unwrap();
2036
2037        assert_eq!(result.len(), 1);
2038        assert!(result[0].message.contains("'zebra' should come before 'aardvark'"));
2039    }
2040}