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