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