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