Skip to main content

rumdl_lib/rules/
md072_frontmatter_key_sort.rs

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