Skip to main content

rumdl_lib/rules/
md072_frontmatter_key_sort.rs

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