Skip to main content

rumdl_lib/rules/
md072_frontmatter_key_sort.rs

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