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