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, ctx.front_matter_end_line());
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, ctx.front_matter_end_line());
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, ctx.front_matter_end_line());
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        let fm_end = ctx.front_matter_end_line();
423        Ok(match fm_type {
424            FrontMatterType::Yaml => self.fix_yaml(content, fm_end),
425            FrontMatterType::Toml => self.fix_toml(content, fm_end),
426            FrontMatterType::Json => self.fix_json(content, fm_end),
427            _ => content.to_string(),
428        })
429    }
430
431    fn category(&self) -> RuleCategory {
432        RuleCategory::FrontMatter
433    }
434
435    fn should_skip(&self, ctx: &crate::lint_context::LintContext) -> bool {
436        ctx.content.is_empty()
437            || !ctx.content.starts_with("---") && !ctx.content.starts_with("+++") && !ctx.content.starts_with('{')
438    }
439
440    fn as_any(&self) -> &dyn std::any::Any {
441        self
442    }
443
444    fn default_config_section(&self) -> Option<(String, toml::Value)> {
445        let table = crate::rule_config_serde::config_schema_table(&MD072Config::default())?;
446        Some((MD072Config::RULE_NAME.to_string(), toml::Value::Table(table)))
447    }
448
449    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
450    where
451        Self: Sized,
452    {
453        let rule_config = crate::rule_config_serde::load_rule_config::<MD072Config>(config);
454        Box::new(Self::from_config_struct(rule_config))
455    }
456}
457
458impl MD072FrontmatterKeySort {
459    /// Restore the original document's trailing newline. The fix functions
460    /// rebuild content via `lines()` + `join("\n")`, which never re-emits a
461    /// final newline, so without this a file ending in `\n` would lose it on
462    /// every fix (a dirty, non-idempotent diff).
463    fn preserve_trailing_newline(original: &str, mut result: String) -> String {
464        if original.ends_with('\n') && !result.ends_with('\n') {
465            result.push('\n');
466        }
467        result
468    }
469
470    fn fix_yaml(&self, content: &str, fm_end: usize) -> String {
471        let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
472        if frontmatter_lines.is_empty() {
473            return content.to_string();
474        }
475
476        // Cannot fix if comments present
477        if Self::has_comments(&frontmatter_lines) {
478            return content.to_string();
479        }
480
481        let keys = Self::extract_yaml_keys(&frontmatter_lines);
482        let key_order = self.config.key_order.as_deref();
483        if Self::are_indexed_keys_sorted(&keys, key_order) {
484            return content.to_string();
485        }
486
487        // Line-based reordering to preserve original formatting (indentation, etc.)
488        // Each key owns all lines until the next top-level key
489        let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
490
491        for (i, (line_idx, key)) in keys.iter().enumerate() {
492            let start = *line_idx;
493            let end = if i + 1 < keys.len() {
494                keys[i + 1].0
495            } else {
496                frontmatter_lines.len()
497            };
498
499            let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
500            key_blocks.push((key.clone(), block_lines));
501        }
502
503        // Sort by key_order, with alphabetical fallback for unlisted keys
504        Self::sort_keys_by_order(&mut key_blocks, key_order);
505
506        // Reassemble frontmatter
507        let content_lines: Vec<&str> = content.lines().collect();
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, fm_end: usize) -> 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
566        let mut result = String::new();
567        result.push_str("+++\n");
568        for (_, lines) in &key_blocks {
569            for line in lines {
570                result.push_str(line);
571                result.push('\n');
572            }
573        }
574        result.push_str("+++");
575
576        if fm_end < content_lines.len() {
577            result.push('\n');
578            result.push_str(&content_lines[fm_end..].join("\n"));
579        }
580
581        Self::preserve_trailing_newline(content, result)
582    }
583
584    fn fix_json(&self, content: &str, fm_end: usize) -> String {
585        let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
586        if frontmatter_lines.is_empty() {
587            return content.to_string();
588        }
589
590        let keys = Self::extract_json_keys(&frontmatter_lines);
591        let key_order = self.config.key_order.as_deref();
592
593        if keys.is_empty() || Self::are_keys_sorted(&keys, key_order) {
594            return content.to_string();
595        }
596
597        // Reconstruct JSON content including braces for parsing
598        let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
599
600        // Parse and re-serialize with sorted keys
601        match serde_json::from_str::<serde_json::Value>(&json_content) {
602            Ok(serde_json::Value::Object(map)) => {
603                // Sort keys according to key_order, with alphabetical fallback
604                let mut sorted_map = serde_json::Map::new();
605                let mut keys: Vec<_> = map.keys().cloned().collect();
606                keys.sort_by(|a, b| {
607                    let pos_a = Self::key_sort_position(a, key_order);
608                    let pos_b = Self::key_sort_position(b, key_order);
609                    pos_a.cmp(&pos_b)
610                });
611
612                for key in keys {
613                    if let Some(value) = map.get(&key) {
614                        sorted_map.insert(key, value.clone());
615                    }
616                }
617
618                match serde_json::to_string_pretty(&serde_json::Value::Object(sorted_map)) {
619                    Ok(sorted_json) => {
620                        let lines: Vec<&str> = content.lines().collect();
621
622                        // The pretty-printed JSON includes the outer braces
623                        // We need to format it properly for frontmatter
624                        let mut result = String::new();
625                        result.push_str(&sorted_json);
626
627                        if fm_end < lines.len() {
628                            result.push('\n');
629                            result.push_str(&lines[fm_end..].join("\n"));
630                        }
631
632                        Self::preserve_trailing_newline(content, result)
633                    }
634                    Err(_) => content.to_string(),
635                }
636            }
637            _ => content.to_string(),
638        }
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645    use crate::lint_context::LintContext;
646
647    /// Create an enabled rule for testing (alphabetical sort)
648    fn create_enabled_rule() -> MD072FrontmatterKeySort {
649        MD072FrontmatterKeySort::from_config_struct(MD072Config {
650            enabled: true,
651            key_order: None,
652        })
653    }
654
655    /// Create an enabled rule with custom key order for testing
656    fn create_rule_with_key_order(keys: Vec<&str>) -> MD072FrontmatterKeySort {
657        MD072FrontmatterKeySort::from_config_struct(MD072Config {
658            enabled: true,
659            key_order: Some(keys.into_iter().map(String::from).collect()),
660        })
661    }
662
663    // ==================== Config Tests ====================
664
665    #[test]
666    fn test_enabled_via_config() {
667        let rule = create_enabled_rule();
668        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
669        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
670        let result = rule.check(&ctx).unwrap();
671
672        // Enabled, should detect unsorted keys
673        assert_eq!(result.len(), 1);
674    }
675
676    // ==================== YAML Tests ====================
677
678    #[test]
679    fn test_no_frontmatter() {
680        let rule = create_enabled_rule();
681        let content = "# Heading\n\nContent.";
682        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
683        let result = rule.check(&ctx).unwrap();
684
685        assert!(result.is_empty());
686    }
687
688    #[test]
689    fn test_yaml_sorted_keys() {
690        let rule = create_enabled_rule();
691        let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
692        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
693        let result = rule.check(&ctx).unwrap();
694
695        assert!(result.is_empty());
696    }
697
698    #[test]
699    fn test_yaml_unsorted_keys() {
700        let rule = create_enabled_rule();
701        let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
702        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
703        let result = rule.check(&ctx).unwrap();
704
705        assert_eq!(result.len(), 1);
706        assert!(result[0].message.contains("YAML"));
707        assert!(result[0].message.contains("not sorted"));
708        // Message shows first out-of-order pair: 'author' should come before 'title'
709        assert!(result[0].message.contains("'author' should come before 'title'"));
710    }
711
712    #[test]
713    fn test_yaml_case_insensitive_sort() {
714        let rule = create_enabled_rule();
715        let content = "---\nAuthor: John\ndate: 2024-01-01\nTitle: Test\n---\n\n# Heading";
716        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
717        let result = rule.check(&ctx).unwrap();
718
719        // Author, date, Title should be considered sorted (case-insensitive)
720        assert!(result.is_empty());
721    }
722
723    #[test]
724    fn test_yaml_fix_sorts_keys() {
725        let rule = create_enabled_rule();
726        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
727        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
728        let fixed = rule.fix(&ctx).unwrap();
729
730        // Keys should be sorted
731        let author_pos = fixed.find("author:").unwrap();
732        let title_pos = fixed.find("title:").unwrap();
733        assert!(author_pos < title_pos);
734    }
735
736    #[test]
737    fn test_yaml_no_fix_with_comments() {
738        let rule = create_enabled_rule();
739        let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading";
740        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
741        let result = rule.check(&ctx).unwrap();
742
743        assert_eq!(result.len(), 1);
744        assert!(result[0].message.contains("auto-fix unavailable"));
745        assert!(result[0].fix.is_none());
746
747        // Fix should not modify content
748        let fixed = rule.fix(&ctx).unwrap();
749        assert_eq!(fixed, content);
750    }
751
752    #[test]
753    fn test_yaml_single_key() {
754        let rule = create_enabled_rule();
755        let content = "---\ntitle: Test\n---\n\n# Heading";
756        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
757        let result = rule.check(&ctx).unwrap();
758
759        // Single key is always sorted
760        assert!(result.is_empty());
761    }
762
763    #[test]
764    fn test_yaml_nested_keys_ignored() {
765        let rule = create_enabled_rule();
766        // Only top-level keys are checked, nested keys are ignored
767        let content = "---\nauthor:\n  name: John\n  email: john@example.com\ntitle: Test\n---\n\n# Heading";
768        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
769        let result = rule.check(&ctx).unwrap();
770
771        // author, title are sorted
772        assert!(result.is_empty());
773    }
774
775    #[test]
776    fn test_yaml_fix_idempotent() {
777        let rule = create_enabled_rule();
778        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
779        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
780        let fixed_once = rule.fix(&ctx).unwrap();
781
782        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
783        let fixed_twice = rule.fix(&ctx2).unwrap();
784
785        assert_eq!(fixed_once, fixed_twice);
786    }
787
788    #[test]
789    fn test_yaml_fix_preserves_trailing_newline() {
790        let rule = create_enabled_rule();
791        // Content ends with a trailing newline; fix must not strip it.
792        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n";
793        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
794        let fixed = rule.fix(&ctx).unwrap();
795        assert!(
796            fixed.ends_with('\n'),
797            "trailing newline must be preserved, got {fixed:?}"
798        );
799
800        // And the fix is idempotent on trailing-newline content.
801        let ctx2 = LintContext::new(&fixed, crate::config::MarkdownFlavor::Standard, None);
802        let fixed_twice = rule.fix(&ctx2).unwrap();
803        assert_eq!(fixed, fixed_twice);
804    }
805
806    #[test]
807    fn test_yaml_fix_whole_file_frontmatter_preserves_trailing_newline() {
808        let rule = create_enabled_rule();
809        // Frontmatter is the entire file (no body after the closing fence).
810        let content = "---\ntitle: Test\nauthor: John\n---\n";
811        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
812        let fixed = rule.fix(&ctx).unwrap();
813        assert!(
814            fixed.ends_with('\n'),
815            "trailing newline must be preserved, got {fixed:?}"
816        );
817    }
818
819    #[test]
820    fn test_yaml_quoted_keys_sort_by_content() {
821        let rule = create_enabled_rule();
822        // A quoted key must sort by its unquoted content, not by the leading
823        // quote char. "zebra" before apple is out of order alphabetically.
824        let content = "---\n\"zebra\": 1\napple: 2\n---\n\n# Heading";
825        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
826        let result = rule.check(&ctx).unwrap();
827
828        assert_eq!(result.len(), 1, "quoted key out of order must be flagged");
829        assert!(result[0].message.contains("'apple' should come before 'zebra'"));
830    }
831
832    #[test]
833    fn test_yaml_quoted_key_warning_span_covers_quotes() {
834        let rule = create_enabled_rule();
835        // "apple" is out of order (should come before banana). Its quotes are
836        // stripped for sorting, but the diagnostic span must still cover the
837        // raw key as written, including the quotes.
838        let content = "---\nbanana: 1\n\"apple\": 2\n---\n";
839        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
840        let result = rule.check(&ctx).unwrap();
841
842        assert_eq!(result.len(), 1);
843        let w = &result[0];
844        assert_eq!(w.line, 3);
845        assert_eq!(w.column, 1);
846        // Raw key `"apple"` is 7 chars, so end_column is 8 (not 6 for `apple`).
847        assert_eq!(w.end_column, 8, "diagnostic span must cover the quoted key");
848    }
849
850    #[test]
851    fn test_yaml_complex_values() {
852        let rule = create_enabled_rule();
853        // Keys in sorted order: author, tags, title
854        let content =
855            "---\nauthor: John Doe\ntags:\n  - rust\n  - markdown\ntitle: \"Test: A Complex Title\"\n---\n\n# Heading";
856        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
857        let result = rule.check(&ctx).unwrap();
858
859        // author, tags, title - sorted
860        assert!(result.is_empty());
861    }
862
863    // ==================== TOML Tests ====================
864
865    #[test]
866    fn test_toml_sorted_keys() {
867        let rule = create_enabled_rule();
868        let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
869        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
870        let result = rule.check(&ctx).unwrap();
871
872        assert!(result.is_empty());
873    }
874
875    #[test]
876    fn test_toml_unsorted_keys() {
877        let rule = create_enabled_rule();
878        let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
879        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
880        let result = rule.check(&ctx).unwrap();
881
882        assert_eq!(result.len(), 1);
883        assert!(result[0].message.contains("TOML"));
884        assert!(result[0].message.contains("not sorted"));
885    }
886
887    #[test]
888    fn test_toml_fix_sorts_keys() {
889        let rule = create_enabled_rule();
890        let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
891        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
892        let fixed = rule.fix(&ctx).unwrap();
893
894        // Keys should be sorted
895        let author_pos = fixed.find("author").unwrap();
896        let title_pos = fixed.find("title").unwrap();
897        assert!(author_pos < title_pos);
898    }
899
900    #[test]
901    fn test_toml_no_fix_with_comments() {
902        let rule = create_enabled_rule();
903        let content = "+++\ntitle = \"Test\"\n# This is a comment\nauthor = \"John\"\n+++\n\n# Heading";
904        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
905        let result = rule.check(&ctx).unwrap();
906
907        assert_eq!(result.len(), 1);
908        assert!(result[0].message.contains("auto-fix unavailable"));
909
910        // Fix should not modify content
911        let fixed = rule.fix(&ctx).unwrap();
912        assert_eq!(fixed, content);
913    }
914
915    // ==================== JSON Tests ====================
916
917    #[test]
918    fn test_json_sorted_keys() {
919        let rule = create_enabled_rule();
920        let content = "{\n\"author\": \"John\",\n\"title\": \"Test\"\n}\n\n# Heading";
921        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
922        let result = rule.check(&ctx).unwrap();
923
924        assert!(result.is_empty());
925    }
926
927    #[test]
928    fn test_json_unsorted_keys() {
929        let rule = create_enabled_rule();
930        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
931        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
932        let result = rule.check(&ctx).unwrap();
933
934        assert_eq!(result.len(), 1);
935        assert!(result[0].message.contains("JSON"));
936        assert!(result[0].message.contains("not sorted"));
937    }
938
939    #[test]
940    fn test_json_fix_sorts_keys() {
941        let rule = create_enabled_rule();
942        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
943        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
944        let fixed = rule.fix(&ctx).unwrap();
945
946        // Keys should be sorted
947        let author_pos = fixed.find("author").unwrap();
948        let title_pos = fixed.find("title").unwrap();
949        assert!(author_pos < title_pos);
950    }
951
952    #[test]
953    fn test_json_always_fixable() {
954        let rule = create_enabled_rule();
955        // JSON has no comments, so should always be fixable
956        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
957        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
958        let result = rule.check(&ctx).unwrap();
959
960        assert_eq!(result.len(), 1);
961        assert!(result[0].fix.is_some()); // Always fixable
962        assert!(!result[0].message.contains("Auto-fix unavailable"));
963    }
964
965    // ==================== General Tests ====================
966
967    #[test]
968    fn test_empty_content() {
969        let rule = create_enabled_rule();
970        let content = "";
971        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
972        let result = rule.check(&ctx).unwrap();
973
974        assert!(result.is_empty());
975    }
976
977    #[test]
978    fn test_empty_frontmatter() {
979        let rule = create_enabled_rule();
980        let content = "---\n---\n\n# Heading";
981        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
982        let result = rule.check(&ctx).unwrap();
983
984        assert!(result.is_empty());
985    }
986
987    #[test]
988    fn test_toml_nested_tables_ignored() {
989        // Keys inside [extra] or [taxonomies] should NOT be checked
990        let rule = create_enabled_rule();
991        let content = "+++\ntitle = \"Programming\"\nsort_by = \"weight\"\n\n[extra]\nwe_have_extra = \"variables\"\n+++\n\n# Heading";
992        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
993        let result = rule.check(&ctx).unwrap();
994
995        // Only top-level keys (title, sort_by) should be checked, not we_have_extra
996        assert_eq!(result.len(), 1);
997        // Message shows first out-of-order pair: 'sort_by' should come before 'title'
998        assert!(result[0].message.contains("'sort_by' should come before 'title'"));
999        assert!(!result[0].message.contains("we_have_extra"));
1000    }
1001
1002    #[test]
1003    fn test_toml_nested_taxonomies_ignored() {
1004        // Keys inside [taxonomies] should NOT be checked
1005        let rule = create_enabled_rule();
1006        let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[taxonomies]\ncategories = [\"test\"]\ntags = [\"foo\"]\n+++\n\n# Heading";
1007        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1008        let result = rule.check(&ctx).unwrap();
1009
1010        // Only top-level keys (title, date) should be checked
1011        assert_eq!(result.len(), 1);
1012        // Message shows first out-of-order pair: 'date' should come before 'title'
1013        assert!(result[0].message.contains("'date' should come before 'title'"));
1014        assert!(!result[0].message.contains("categories"));
1015        assert!(!result[0].message.contains("tags"));
1016    }
1017
1018    // ==================== Edge Case Tests ====================
1019
1020    #[test]
1021    fn test_yaml_unicode_keys() {
1022        let rule = create_enabled_rule();
1023        // Japanese keys should sort correctly
1024        let content = "---\nタイトル: Test\nあいう: Value\n日本語: Content\n---\n\n# Heading";
1025        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1026        let result = rule.check(&ctx).unwrap();
1027
1028        // Should detect unsorted keys (あいう < タイトル < 日本語 in Unicode order)
1029        assert_eq!(result.len(), 1);
1030    }
1031
1032    #[test]
1033    fn test_yaml_keys_with_special_characters() {
1034        let rule = create_enabled_rule();
1035        // Keys with dashes and underscores
1036        let content = "---\nmy-key: value1\nmy_key: value2\nmykey: value3\n---\n\n# Heading";
1037        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1038        let result = rule.check(&ctx).unwrap();
1039
1040        // my-key, my_key, mykey - should be sorted
1041        assert!(result.is_empty());
1042    }
1043
1044    #[test]
1045    fn test_yaml_keys_with_numbers() {
1046        let rule = create_enabled_rule();
1047        let content = "---\nkey1: value\nkey10: value\nkey2: value\n---\n\n# Heading";
1048        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1049        let result = rule.check(&ctx).unwrap();
1050
1051        // key1, key10, key2 - lexicographic order (1 < 10 < 2)
1052        assert!(result.is_empty());
1053    }
1054
1055    #[test]
1056    fn test_yaml_multiline_string_block_literal() {
1057        let rule = create_enabled_rule();
1058        let content =
1059            "---\ndescription: |\n  This is a\n  multiline literal\ntitle: Test\nauthor: John\n---\n\n# Heading";
1060        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1061        let result = rule.check(&ctx).unwrap();
1062
1063        // description, title, author - first out-of-order: 'author' should come before 'title'
1064        assert_eq!(result.len(), 1);
1065        assert!(result[0].message.contains("'author' should come before 'title'"));
1066    }
1067
1068    #[test]
1069    fn test_yaml_multiline_string_folded() {
1070        let rule = create_enabled_rule();
1071        let content = "---\ndescription: >\n  This is a\n  folded string\nauthor: John\n---\n\n# Heading";
1072        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1073        let result = rule.check(&ctx).unwrap();
1074
1075        // author, description - not sorted
1076        assert_eq!(result.len(), 1);
1077    }
1078
1079    #[test]
1080    fn test_yaml_fix_preserves_multiline_values() {
1081        let rule = create_enabled_rule();
1082        let content = "---\ntitle: Test\ndescription: |\n  Line 1\n  Line 2\n---\n\n# Heading";
1083        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1084        let fixed = rule.fix(&ctx).unwrap();
1085
1086        // description should come before title
1087        let desc_pos = fixed.find("description").unwrap();
1088        let title_pos = fixed.find("title").unwrap();
1089        assert!(desc_pos < title_pos);
1090    }
1091
1092    #[test]
1093    fn test_yaml_quoted_keys() {
1094        let rule = create_enabled_rule();
1095        let content = "---\n\"quoted-key\": value1\nunquoted: value2\n---\n\n# Heading";
1096        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1097        let result = rule.check(&ctx).unwrap();
1098
1099        // quoted-key should sort before unquoted
1100        assert!(result.is_empty());
1101    }
1102
1103    #[test]
1104    fn test_yaml_duplicate_keys() {
1105        // YAML allows duplicate keys (last one wins), but we should still sort
1106        let rule = create_enabled_rule();
1107        let content = "---\ntitle: First\nauthor: John\ntitle: Second\n---\n\n# Heading";
1108        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1109        let result = rule.check(&ctx).unwrap();
1110
1111        // Should still check sorting (title, author, title is not sorted)
1112        assert_eq!(result.len(), 1);
1113    }
1114
1115    #[test]
1116    fn test_toml_inline_table() {
1117        let rule = create_enabled_rule();
1118        let content =
1119            "+++\nauthor = { name = \"John\", email = \"john@example.com\" }\ntitle = \"Test\"\n+++\n\n# Heading";
1120        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1121        let result = rule.check(&ctx).unwrap();
1122
1123        // author, title - sorted
1124        assert!(result.is_empty());
1125    }
1126
1127    #[test]
1128    fn test_toml_array_of_tables() {
1129        let rule = create_enabled_rule();
1130        let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[[authors]]\nname = \"John\"\n\n[[authors]]\nname = \"Jane\"\n+++\n\n# Heading";
1131        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1132        let result = rule.check(&ctx).unwrap();
1133
1134        // Only top-level keys (title, date) checked - date < title, so unsorted
1135        assert_eq!(result.len(), 1);
1136        // Message shows first out-of-order pair: 'date' should come before 'title'
1137        assert!(result[0].message.contains("'date' should come before 'title'"));
1138    }
1139
1140    #[test]
1141    fn test_json_nested_objects() {
1142        let rule = create_enabled_rule();
1143        let content = "{\n\"author\": {\n  \"name\": \"John\",\n  \"email\": \"john@example.com\"\n},\n\"title\": \"Test\"\n}\n\n# Heading";
1144        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1145        let result = rule.check(&ctx).unwrap();
1146
1147        // Only top-level keys (author, title) checked - sorted
1148        assert!(result.is_empty());
1149    }
1150
1151    #[test]
1152    fn test_json_arrays() {
1153        let rule = create_enabled_rule();
1154        let content = "{\n\"tags\": [\"rust\", \"markdown\"],\n\"author\": \"John\"\n}\n\n# Heading";
1155        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1156        let result = rule.check(&ctx).unwrap();
1157
1158        // author, tags - not sorted (tags comes first)
1159        assert_eq!(result.len(), 1);
1160    }
1161
1162    #[test]
1163    fn test_fix_preserves_content_after_frontmatter() {
1164        let rule = create_enabled_rule();
1165        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n\nParagraph 1.\n\n- List item\n- Another item";
1166        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1167        let fixed = rule.fix(&ctx).unwrap();
1168
1169        // Verify content after frontmatter is preserved
1170        assert!(fixed.contains("# Heading"));
1171        assert!(fixed.contains("Paragraph 1."));
1172        assert!(fixed.contains("- List item"));
1173        assert!(fixed.contains("- Another item"));
1174    }
1175
1176    #[test]
1177    fn test_fix_yaml_produces_valid_yaml() {
1178        let rule = create_enabled_rule();
1179        let content = "---\ntitle: \"Test: A Title\"\nauthor: John Doe\ndate: 2024-01-15\n---\n\n# Heading";
1180        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1181        let fixed = rule.fix(&ctx).unwrap();
1182
1183        // The fixed output should be parseable as YAML
1184        // Extract frontmatter lines
1185        let lines: Vec<&str> = fixed.lines().collect();
1186        let fm_end = lines.iter().skip(1).position(|l| *l == "---").unwrap() + 1;
1187        let fm_content: String = lines[1..fm_end].join("\n");
1188
1189        // Should parse without error
1190        let parsed: Result<serde_yaml::Value, _> = serde_yaml::from_str(&fm_content);
1191        assert!(parsed.is_ok(), "Fixed YAML should be valid: {fm_content}");
1192    }
1193
1194    #[test]
1195    fn test_fix_toml_produces_valid_toml() {
1196        let rule = create_enabled_rule();
1197        let content = "+++\ntitle = \"Test\"\nauthor = \"John Doe\"\ndate = 2024-01-15\n+++\n\n# Heading";
1198        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1199        let fixed = rule.fix(&ctx).unwrap();
1200
1201        // Extract frontmatter
1202        let lines: Vec<&str> = fixed.lines().collect();
1203        let fm_end = lines.iter().skip(1).position(|l| *l == "+++").unwrap() + 1;
1204        let fm_content: String = lines[1..fm_end].join("\n");
1205
1206        // Should parse without error
1207        let parsed: Result<toml::Value, _> = toml::from_str(&fm_content);
1208        assert!(parsed.is_ok(), "Fixed TOML should be valid: {fm_content}");
1209    }
1210
1211    #[test]
1212    fn test_fix_json_produces_valid_json() {
1213        let rule = create_enabled_rule();
1214        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1215        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1216        let fixed = rule.fix(&ctx).unwrap();
1217
1218        // Extract JSON frontmatter (everything up to blank line)
1219        let json_end = fixed.find("\n\n").unwrap();
1220        let json_content = &fixed[..json_end];
1221
1222        // Should parse without error
1223        let parsed: Result<serde_json::Value, _> = serde_json::from_str(json_content);
1224        assert!(parsed.is_ok(), "Fixed JSON should be valid: {json_content}");
1225    }
1226
1227    #[test]
1228    fn test_many_keys_performance() {
1229        let rule = create_enabled_rule();
1230        // Generate frontmatter with 100 keys
1231        let mut keys: Vec<String> = (0..100).map(|i| format!("key{i:03}: value{i}")).collect();
1232        keys.reverse(); // Make them unsorted
1233        let content = format!("---\n{}\n---\n\n# Heading", keys.join("\n"));
1234
1235        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1236        let result = rule.check(&ctx).unwrap();
1237
1238        // Should detect unsorted keys
1239        assert_eq!(result.len(), 1);
1240    }
1241
1242    #[test]
1243    fn test_yaml_empty_value() {
1244        let rule = create_enabled_rule();
1245        let content = "---\ntitle:\nauthor: John\n---\n\n# Heading";
1246        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1247        let result = rule.check(&ctx).unwrap();
1248
1249        // author, title - not sorted
1250        assert_eq!(result.len(), 1);
1251    }
1252
1253    #[test]
1254    fn test_yaml_null_value() {
1255        let rule = create_enabled_rule();
1256        let content = "---\ntitle: null\nauthor: John\n---\n\n# Heading";
1257        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1258        let result = rule.check(&ctx).unwrap();
1259
1260        assert_eq!(result.len(), 1);
1261    }
1262
1263    #[test]
1264    fn test_yaml_boolean_values() {
1265        let rule = create_enabled_rule();
1266        let content = "---\ndraft: true\nauthor: John\n---\n\n# Heading";
1267        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1268        let result = rule.check(&ctx).unwrap();
1269
1270        // author, draft - not sorted
1271        assert_eq!(result.len(), 1);
1272    }
1273
1274    #[test]
1275    fn test_toml_boolean_values() {
1276        let rule = create_enabled_rule();
1277        let content = "+++\ndraft = true\nauthor = \"John\"\n+++\n\n# Heading";
1278        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1279        let result = rule.check(&ctx).unwrap();
1280
1281        assert_eq!(result.len(), 1);
1282    }
1283
1284    #[test]
1285    fn test_yaml_list_at_top_level() {
1286        let rule = create_enabled_rule();
1287        let content = "---\ntags:\n  - rust\n  - markdown\nauthor: John\n---\n\n# Heading";
1288        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1289        let result = rule.check(&ctx).unwrap();
1290
1291        // author, tags - not sorted (tags comes first)
1292        assert_eq!(result.len(), 1);
1293    }
1294
1295    #[test]
1296    fn test_three_keys_all_orderings() {
1297        let rule = create_enabled_rule();
1298
1299        // Test all 6 permutations of a, b, c
1300        let orderings = [
1301            ("a, b, c", "---\na: 1\nb: 2\nc: 3\n---\n\n# H", true),  // sorted
1302            ("a, c, b", "---\na: 1\nc: 3\nb: 2\n---\n\n# H", false), // unsorted
1303            ("b, a, c", "---\nb: 2\na: 1\nc: 3\n---\n\n# H", false), // unsorted
1304            ("b, c, a", "---\nb: 2\nc: 3\na: 1\n---\n\n# H", false), // unsorted
1305            ("c, a, b", "---\nc: 3\na: 1\nb: 2\n---\n\n# H", false), // unsorted
1306            ("c, b, a", "---\nc: 3\nb: 2\na: 1\n---\n\n# H", false), // unsorted
1307        ];
1308
1309        for (name, content, should_pass) in orderings {
1310            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1311            let result = rule.check(&ctx).unwrap();
1312            assert_eq!(
1313                result.is_empty(),
1314                should_pass,
1315                "Ordering {name} should {} pass",
1316                if should_pass { "" } else { "not" }
1317            );
1318        }
1319    }
1320
1321    #[test]
1322    fn test_crlf_line_endings() {
1323        let rule = create_enabled_rule();
1324        let content = "---\r\ntitle: Test\r\nauthor: John\r\n---\r\n\r\n# Heading";
1325        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1326        let result = rule.check(&ctx).unwrap();
1327
1328        // Should detect unsorted keys with CRLF
1329        assert_eq!(result.len(), 1);
1330    }
1331
1332    #[test]
1333    fn test_json_escaped_quotes_in_keys() {
1334        let rule = create_enabled_rule();
1335        // This is technically invalid JSON but tests regex robustness
1336        let content = "{\n\"normal\": \"value\",\n\"key\": \"with \\\"quotes\\\"\"\n}\n\n# Heading";
1337        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1338        let result = rule.check(&ctx).unwrap();
1339
1340        // key, normal - not sorted
1341        assert_eq!(result.len(), 1);
1342    }
1343
1344    // ==================== Warning-based Fix Tests (LSP Path) ====================
1345
1346    #[test]
1347    fn test_warning_fix_yaml_sorts_keys() {
1348        let rule = create_enabled_rule();
1349        let content = "---\nbbb: 123\naaa:\n  - hello\n  - world\n---\n\n# Heading\n";
1350        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1351        let warnings = rule.check(&ctx).unwrap();
1352
1353        assert_eq!(warnings.len(), 1);
1354        assert!(warnings[0].fix.is_some(), "Warning should have a fix attached for LSP");
1355
1356        let fix = warnings[0].fix.as_ref().unwrap();
1357        assert_eq!(fix.range, 0..content.len(), "Fix should replace entire content");
1358
1359        // Apply the fix using the warning-based fix utility (LSP path)
1360        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1361
1362        // Verify keys are sorted
1363        let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1364        let bbb_pos = fixed.find("bbb:").expect("bbb should exist");
1365        assert!(aaa_pos < bbb_pos, "aaa should come before bbb after sorting");
1366    }
1367
1368    #[test]
1369    fn test_warning_fix_preserves_yaml_list_indentation() {
1370        let rule = create_enabled_rule();
1371        let content = "---\nbbb: 123\naaa:\n  - hello\n  - world\n---\n\n# Heading\n";
1372        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1373        let warnings = rule.check(&ctx).unwrap();
1374
1375        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1376
1377        // Verify list items retain their 2-space indentation
1378        assert!(
1379            fixed.contains("  - hello"),
1380            "List indentation should be preserved: {fixed}"
1381        );
1382        assert!(
1383            fixed.contains("  - world"),
1384            "List indentation should be preserved: {fixed}"
1385        );
1386    }
1387
1388    #[test]
1389    fn test_warning_fix_preserves_nested_object_indentation() {
1390        let rule = create_enabled_rule();
1391        let content = "---\nzzzz: value\naaaa:\n  nested_key: nested_value\n  another: 123\n---\n\n# Heading\n";
1392        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1393        let warnings = rule.check(&ctx).unwrap();
1394
1395        assert_eq!(warnings.len(), 1);
1396        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1397
1398        // Verify aaaa comes before zzzz
1399        let aaaa_pos = fixed.find("aaaa:").expect("aaaa should exist");
1400        let zzzz_pos = fixed.find("zzzz:").expect("zzzz should exist");
1401        assert!(aaaa_pos < zzzz_pos, "aaaa should come before zzzz");
1402
1403        // Verify nested keys retain their 2-space indentation
1404        assert!(
1405            fixed.contains("  nested_key: nested_value"),
1406            "Nested object indentation should be preserved: {fixed}"
1407        );
1408        assert!(
1409            fixed.contains("  another: 123"),
1410            "Nested object indentation should be preserved: {fixed}"
1411        );
1412    }
1413
1414    #[test]
1415    fn test_warning_fix_preserves_deeply_nested_structure() {
1416        let rule = create_enabled_rule();
1417        let content = "---\nzzz: top\naaa:\n  level1:\n    level2:\n      - item1\n      - item2\n---\n\n# Content\n";
1418        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1419        let warnings = rule.check(&ctx).unwrap();
1420
1421        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1422
1423        // Verify sorting
1424        let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1425        let zzz_pos = fixed.find("zzz:").expect("zzz should exist");
1426        assert!(aaa_pos < zzz_pos, "aaa should come before zzz");
1427
1428        // Verify all indentation levels are preserved
1429        assert!(fixed.contains("  level1:"), "2-space indent should be preserved");
1430        assert!(fixed.contains("    level2:"), "4-space indent should be preserved");
1431        assert!(fixed.contains("      - item1"), "6-space indent should be preserved");
1432        assert!(fixed.contains("      - item2"), "6-space indent should be preserved");
1433    }
1434
1435    #[test]
1436    fn test_warning_fix_toml_sorts_keys() {
1437        let rule = create_enabled_rule();
1438        let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading\n";
1439        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1440        let warnings = rule.check(&ctx).unwrap();
1441
1442        assert_eq!(warnings.len(), 1);
1443        assert!(warnings[0].fix.is_some(), "TOML warning should have a fix");
1444
1445        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1446
1447        // Verify keys are sorted
1448        let author_pos = fixed.find("author").expect("author should exist");
1449        let title_pos = fixed.find("title").expect("title should exist");
1450        assert!(author_pos < title_pos, "author should come before title");
1451    }
1452
1453    #[test]
1454    fn test_warning_fix_json_sorts_keys() {
1455        let rule = create_enabled_rule();
1456        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading\n";
1457        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1458        let warnings = rule.check(&ctx).unwrap();
1459
1460        assert_eq!(warnings.len(), 1);
1461        assert!(warnings[0].fix.is_some(), "JSON warning should have a fix");
1462
1463        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1464
1465        // Verify keys are sorted
1466        let author_pos = fixed.find("author").expect("author should exist");
1467        let title_pos = fixed.find("title").expect("title should exist");
1468        assert!(author_pos < title_pos, "author should come before title");
1469    }
1470
1471    #[test]
1472    fn test_warning_fix_no_fix_when_comments_present() {
1473        let rule = create_enabled_rule();
1474        let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading\n";
1475        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1476        let warnings = rule.check(&ctx).unwrap();
1477
1478        assert_eq!(warnings.len(), 1);
1479        assert!(
1480            warnings[0].fix.is_none(),
1481            "Warning should NOT have a fix when comments are present"
1482        );
1483        assert!(
1484            warnings[0].message.contains("auto-fix unavailable"),
1485            "Message should indicate auto-fix is unavailable"
1486        );
1487    }
1488
1489    #[test]
1490    fn test_warning_fix_preserves_content_after_frontmatter() {
1491        let rule = create_enabled_rule();
1492        let content = "---\nzzz: last\naaa: first\n---\n\n# Heading\n\nParagraph with content.\n\n- List item\n";
1493        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1494        let warnings = rule.check(&ctx).unwrap();
1495
1496        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1497
1498        // Verify content after frontmatter is preserved
1499        assert!(fixed.contains("# Heading"), "Heading should be preserved");
1500        assert!(
1501            fixed.contains("Paragraph with content."),
1502            "Paragraph should be preserved"
1503        );
1504        assert!(fixed.contains("- List item"), "List item should be preserved");
1505    }
1506
1507    #[test]
1508    fn test_warning_fix_idempotent() {
1509        let rule = create_enabled_rule();
1510        let content = "---\nbbb: 2\naaa: 1\n---\n\n# Heading\n";
1511        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1512        let warnings = rule.check(&ctx).unwrap();
1513
1514        let fixed_once = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1515
1516        // Apply again - should produce no warnings
1517        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1518        let warnings2 = rule.check(&ctx2).unwrap();
1519
1520        assert!(
1521            warnings2.is_empty(),
1522            "After fixing, no more warnings should be produced"
1523        );
1524    }
1525
1526    #[test]
1527    fn test_warning_fix_preserves_multiline_block_literal() {
1528        let rule = create_enabled_rule();
1529        let content = "---\nzzz: simple\naaa: |\n  Line 1 of block\n  Line 2 of block\n---\n\n# Heading\n";
1530        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1531        let warnings = rule.check(&ctx).unwrap();
1532
1533        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1534
1535        // Verify block literal is preserved with indentation
1536        assert!(fixed.contains("aaa: |"), "Block literal marker should be preserved");
1537        assert!(
1538            fixed.contains("  Line 1 of block"),
1539            "Block literal line 1 should be preserved with indent"
1540        );
1541        assert!(
1542            fixed.contains("  Line 2 of block"),
1543            "Block literal line 2 should be preserved with indent"
1544        );
1545    }
1546
1547    #[test]
1548    fn test_warning_fix_preserves_folded_string() {
1549        let rule = create_enabled_rule();
1550        let content = "---\nzzz: simple\naaa: >\n  Folded line 1\n  Folded line 2\n---\n\n# Content\n";
1551        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1552        let warnings = rule.check(&ctx).unwrap();
1553
1554        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1555
1556        // Verify folded string is preserved
1557        assert!(fixed.contains("aaa: >"), "Folded string marker should be preserved");
1558        assert!(
1559            fixed.contains("  Folded line 1"),
1560            "Folded line 1 should be preserved with indent"
1561        );
1562        assert!(
1563            fixed.contains("  Folded line 2"),
1564            "Folded line 2 should be preserved with indent"
1565        );
1566    }
1567
1568    #[test]
1569    fn test_warning_fix_preserves_4_space_indentation() {
1570        let rule = create_enabled_rule();
1571        // Some projects use 4-space indentation
1572        let content = "---\nzzz: value\naaa:\n    nested: with_4_spaces\n    another: value\n---\n\n# Heading\n";
1573        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1574        let warnings = rule.check(&ctx).unwrap();
1575
1576        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1577
1578        // Verify 4-space indentation is preserved exactly
1579        assert!(
1580            fixed.contains("    nested: with_4_spaces"),
1581            "4-space indentation should be preserved: {fixed}"
1582        );
1583        assert!(
1584            fixed.contains("    another: value"),
1585            "4-space indentation should be preserved: {fixed}"
1586        );
1587    }
1588
1589    #[test]
1590    fn test_warning_fix_preserves_tab_indentation() {
1591        let rule = create_enabled_rule();
1592        // Some projects use tabs
1593        let content = "---\nzzz: value\naaa:\n\tnested: with_tab\n\tanother: value\n---\n\n# Heading\n";
1594        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1595        let warnings = rule.check(&ctx).unwrap();
1596
1597        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1598
1599        // Verify tab indentation is preserved exactly
1600        assert!(
1601            fixed.contains("\tnested: with_tab"),
1602            "Tab indentation should be preserved: {fixed}"
1603        );
1604        assert!(
1605            fixed.contains("\tanother: value"),
1606            "Tab indentation should be preserved: {fixed}"
1607        );
1608    }
1609
1610    #[test]
1611    fn test_warning_fix_preserves_inline_list() {
1612        let rule = create_enabled_rule();
1613        // Inline YAML lists should be preserved
1614        let content = "---\nzzz: value\naaa: [one, two, three]\n---\n\n# Heading\n";
1615        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1616        let warnings = rule.check(&ctx).unwrap();
1617
1618        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1619
1620        // Verify inline list format is preserved
1621        assert!(
1622            fixed.contains("aaa: [one, two, three]"),
1623            "Inline list should be preserved exactly: {fixed}"
1624        );
1625    }
1626
1627    #[test]
1628    fn test_warning_fix_preserves_quoted_strings() {
1629        let rule = create_enabled_rule();
1630        // Quoted strings with special chars
1631        let content = "---\nzzz: simple\naaa: \"value with: colon\"\nbbb: 'single quotes'\n---\n\n# Heading\n";
1632        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1633        let warnings = rule.check(&ctx).unwrap();
1634
1635        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1636
1637        // Verify quoted strings are preserved exactly
1638        assert!(
1639            fixed.contains("aaa: \"value with: colon\""),
1640            "Double-quoted string should be preserved: {fixed}"
1641        );
1642        assert!(
1643            fixed.contains("bbb: 'single quotes'"),
1644            "Single-quoted string should be preserved: {fixed}"
1645        );
1646    }
1647
1648    // ==================== Custom Key Order Tests ====================
1649
1650    #[test]
1651    fn test_yaml_custom_key_order_sorted() {
1652        // Keys match the custom order: title, date, author
1653        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1654        let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1655        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1656        let result = rule.check(&ctx).unwrap();
1657
1658        // Keys are in the custom order, should be considered sorted
1659        assert!(result.is_empty());
1660    }
1661
1662    #[test]
1663    fn test_yaml_custom_key_order_unsorted() {
1664        // Keys NOT in the custom order: should report author before date
1665        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1666        let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1667        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1668        let result = rule.check(&ctx).unwrap();
1669
1670        assert_eq!(result.len(), 1);
1671        // 'date' should come before 'author' according to custom order
1672        assert!(result[0].message.contains("'date' should come before 'author'"));
1673    }
1674
1675    #[test]
1676    fn test_yaml_custom_key_order_unlisted_keys_alphabetical() {
1677        // unlisted keys should come after specified keys, sorted alphabetically
1678        let rule = create_rule_with_key_order(vec!["title"]);
1679        let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1680        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1681        let result = rule.check(&ctx).unwrap();
1682
1683        // title is specified, author and date are not - they should be alphabetically after title
1684        // author < date alphabetically, so this is sorted
1685        assert!(result.is_empty());
1686    }
1687
1688    #[test]
1689    fn test_yaml_custom_key_order_unlisted_keys_unsorted() {
1690        // unlisted keys out of alphabetical order
1691        let rule = create_rule_with_key_order(vec!["title"]);
1692        let content = "---\ntitle: Test\nzebra: Zoo\nauthor: John\n---\n\n# Heading";
1693        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1694        let result = rule.check(&ctx).unwrap();
1695
1696        // zebra and author are unlisted, author < zebra alphabetically
1697        assert_eq!(result.len(), 1);
1698        assert!(result[0].message.contains("'author' should come before 'zebra'"));
1699    }
1700
1701    #[test]
1702    fn test_yaml_custom_key_order_fix() {
1703        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1704        let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1705        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1706        let fixed = rule.fix(&ctx).unwrap();
1707
1708        // Keys should be in custom order: title, date, author
1709        let title_pos = fixed.find("title:").unwrap();
1710        let date_pos = fixed.find("date:").unwrap();
1711        let author_pos = fixed.find("author:").unwrap();
1712        assert!(
1713            title_pos < date_pos && date_pos < author_pos,
1714            "Fixed YAML should have keys in custom order: title, date, author. Got:\n{fixed}"
1715        );
1716    }
1717
1718    #[test]
1719    fn test_yaml_custom_key_order_fix_with_unlisted() {
1720        // Mix of listed and unlisted keys
1721        let rule = create_rule_with_key_order(vec!["title", "author"]);
1722        let content = "---\nzebra: Zoo\nauthor: John\ntitle: Test\naardvark: Ant\n---\n\n# Heading";
1723        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1724        let fixed = rule.fix(&ctx).unwrap();
1725
1726        // Order should be: title, author (specified), then aardvark, zebra (alphabetical)
1727        let title_pos = fixed.find("title:").unwrap();
1728        let author_pos = fixed.find("author:").unwrap();
1729        let aardvark_pos = fixed.find("aardvark:").unwrap();
1730        let zebra_pos = fixed.find("zebra:").unwrap();
1731
1732        assert!(
1733            title_pos < author_pos && author_pos < aardvark_pos && aardvark_pos < zebra_pos,
1734            "Fixed YAML should have specified keys first, then unlisted alphabetically. Got:\n{fixed}"
1735        );
1736    }
1737
1738    #[test]
1739    fn test_toml_custom_key_order_sorted() {
1740        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1741        let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\nauthor = \"John\"\n+++\n\n# Heading";
1742        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1743        let result = rule.check(&ctx).unwrap();
1744
1745        assert!(result.is_empty());
1746    }
1747
1748    #[test]
1749    fn test_toml_custom_key_order_unsorted() {
1750        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1751        let content = "+++\nauthor = \"John\"\ntitle = \"Test\"\ndate = \"2024-01-01\"\n+++\n\n# Heading";
1752        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1753        let result = rule.check(&ctx).unwrap();
1754
1755        assert_eq!(result.len(), 1);
1756        assert!(result[0].message.contains("TOML"));
1757    }
1758
1759    #[test]
1760    fn test_json_custom_key_order_sorted() {
1761        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1762        let content = "{\n  \"title\": \"Test\",\n  \"date\": \"2024-01-01\",\n  \"author\": \"John\"\n}\n\n# Heading";
1763        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1764        let result = rule.check(&ctx).unwrap();
1765
1766        assert!(result.is_empty());
1767    }
1768
1769    #[test]
1770    fn test_json_custom_key_order_unsorted() {
1771        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1772        let content = "{\n  \"author\": \"John\",\n  \"title\": \"Test\",\n  \"date\": \"2024-01-01\"\n}\n\n# Heading";
1773        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1774        let result = rule.check(&ctx).unwrap();
1775
1776        assert_eq!(result.len(), 1);
1777        assert!(result[0].message.contains("JSON"));
1778    }
1779
1780    #[test]
1781    fn test_key_order_case_insensitive_match() {
1782        // Key order should match case-insensitively
1783        let rule = create_rule_with_key_order(vec!["Title", "Date", "Author"]);
1784        let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1785        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1786        let result = rule.check(&ctx).unwrap();
1787
1788        // Keys match the custom order (case-insensitive)
1789        assert!(result.is_empty());
1790    }
1791
1792    #[test]
1793    fn test_key_order_partial_match() {
1794        // Some keys specified, some not
1795        let rule = create_rule_with_key_order(vec!["title"]);
1796        let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1797        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1798        let result = rule.check(&ctx).unwrap();
1799
1800        // Only 'title' is specified, so it comes first
1801        // 'author' and 'date' are unlisted and sorted alphabetically: author < date
1802        // But current order is date, author - WRONG
1803        // Wait, content has: title, date, author
1804        // title is specified (pos 0)
1805        // date is unlisted (pos MAX, "date")
1806        // author is unlisted (pos MAX, "author")
1807        // Since both unlisted, compare alphabetically: author < date
1808        // So author should come before date, but date comes before author in content
1809        // This IS unsorted!
1810        assert_eq!(result.len(), 1);
1811        assert!(result[0].message.contains("'author' should come before 'date'"));
1812    }
1813
1814    // ==================== Key Order Edge Cases ====================
1815
1816    #[test]
1817    fn test_key_order_empty_array_falls_back_to_alphabetical() {
1818        // Empty key_order should behave like alphabetical sorting
1819        let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1820            enabled: true,
1821            key_order: Some(vec![]),
1822        });
1823        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
1824        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1825        let result = rule.check(&ctx).unwrap();
1826
1827        // With empty key_order, all keys are unlisted → alphabetical
1828        // author < title, but title comes first in content → unsorted
1829        assert_eq!(result.len(), 1);
1830        assert!(result[0].message.contains("'author' should come before 'title'"));
1831    }
1832
1833    #[test]
1834    fn test_key_order_single_key() {
1835        // key_order with only one key
1836        let rule = create_rule_with_key_order(vec!["title"]);
1837        let content = "---\ntitle: Test\n---\n\n# Heading";
1838        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1839        let result = rule.check(&ctx).unwrap();
1840
1841        assert!(result.is_empty());
1842    }
1843
1844    #[test]
1845    fn test_key_order_all_keys_specified() {
1846        // All document keys are in key_order
1847        let rule = create_rule_with_key_order(vec!["title", "author", "date"]);
1848        let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
1849        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1850        let result = rule.check(&ctx).unwrap();
1851
1852        assert!(result.is_empty());
1853    }
1854
1855    #[test]
1856    fn test_key_order_no_keys_match() {
1857        // None of the document keys are in key_order
1858        let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1859        let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1860        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1861        let result = rule.check(&ctx).unwrap();
1862
1863        // All keys are unlisted, so they sort alphabetically: author, date, title
1864        // Current order is author, date, title - which IS sorted
1865        assert!(result.is_empty());
1866    }
1867
1868    #[test]
1869    fn test_key_order_no_keys_match_unsorted() {
1870        // None of the document keys are in key_order, and they're out of alphabetical order
1871        let rule = create_rule_with_key_order(vec!["foo", "bar", "baz"]);
1872        let content = "---\ntitle: Test\ndate: 2024-01-01\nauthor: John\n---\n\n# Heading";
1873        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1874        let result = rule.check(&ctx).unwrap();
1875
1876        // All unlisted → alphabetical: author < date < title
1877        // Current: title, date, author → unsorted
1878        assert_eq!(result.len(), 1);
1879    }
1880
1881    #[test]
1882    fn test_key_order_duplicate_keys_in_config() {
1883        // Duplicate keys in key_order (should use first occurrence)
1884        let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1885            enabled: true,
1886            key_order: Some(vec![
1887                "title".to_string(),
1888                "author".to_string(),
1889                "title".to_string(), // duplicate
1890            ]),
1891        });
1892        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
1893        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1894        let result = rule.check(&ctx).unwrap();
1895
1896        // title (pos 0), author (pos 1) → sorted
1897        assert!(result.is_empty());
1898    }
1899
1900    #[test]
1901    fn test_key_order_with_comments_still_skips_fix() {
1902        // key_order should not affect the comment-skipping behavior
1903        let rule = create_rule_with_key_order(vec!["title", "author"]);
1904        let content = "---\n# This is a comment\nauthor: John\ntitle: Test\n---\n\n# Heading";
1905        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1906        let result = rule.check(&ctx).unwrap();
1907
1908        // Should detect unsorted AND indicate no auto-fix due to comments
1909        assert_eq!(result.len(), 1);
1910        assert!(result[0].message.contains("auto-fix unavailable"));
1911        assert!(result[0].fix.is_none());
1912    }
1913
1914    #[test]
1915    fn test_toml_custom_key_order_fix() {
1916        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1917        let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
1918        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1919        let fixed = rule.fix(&ctx).unwrap();
1920
1921        // Keys should be in custom order: title, date, author
1922        let title_pos = fixed.find("title").unwrap();
1923        let date_pos = fixed.find("date").unwrap();
1924        let author_pos = fixed.find("author").unwrap();
1925        assert!(
1926            title_pos < date_pos && date_pos < author_pos,
1927            "Fixed TOML should have keys in custom order. Got:\n{fixed}"
1928        );
1929    }
1930
1931    #[test]
1932    fn test_json_custom_key_order_fix() {
1933        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1934        let content = "{\n  \"author\": \"John\",\n  \"date\": \"2024-01-01\",\n  \"title\": \"Test\"\n}\n\n# Heading";
1935        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1936        let fixed = rule.fix(&ctx).unwrap();
1937
1938        // Keys should be in custom order: title, date, author
1939        let title_pos = fixed.find("\"title\"").unwrap();
1940        let date_pos = fixed.find("\"date\"").unwrap();
1941        let author_pos = fixed.find("\"author\"").unwrap();
1942        assert!(
1943            title_pos < date_pos && date_pos < author_pos,
1944            "Fixed JSON should have keys in custom order. Got:\n{fixed}"
1945        );
1946    }
1947
1948    #[test]
1949    fn test_key_order_unicode_keys() {
1950        // Unicode keys in key_order
1951        let rule = MD072FrontmatterKeySort::from_config_struct(MD072Config {
1952            enabled: true,
1953            key_order: Some(vec!["タイトル".to_string(), "著者".to_string()]),
1954        });
1955        let content = "---\nタイトル: テスト\n著者: 山田太郎\n---\n\n# Heading";
1956        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1957        let result = rule.check(&ctx).unwrap();
1958
1959        // Keys match the custom order
1960        assert!(result.is_empty());
1961    }
1962
1963    #[test]
1964    fn test_key_order_mixed_specified_and_unlisted_boundary() {
1965        // Test the boundary between specified and unlisted keys
1966        let rule = create_rule_with_key_order(vec!["z_last_specified"]);
1967        let content = "---\nz_last_specified: value\na_first_unlisted: value\n---\n\n# Heading";
1968        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1969        let result = rule.check(&ctx).unwrap();
1970
1971        // z_last_specified (pos 0) should come before a_first_unlisted (pos MAX)
1972        // even though 'a' < 'z' alphabetically
1973        assert!(result.is_empty());
1974    }
1975
1976    #[test]
1977    fn test_key_order_fix_preserves_values() {
1978        // Ensure fix preserves complex values when reordering with key_order
1979        let rule = create_rule_with_key_order(vec!["title", "tags"]);
1980        let content = "---\ntags:\n  - rust\n  - markdown\ntitle: Test\n---\n\n# Heading";
1981        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1982        let fixed = rule.fix(&ctx).unwrap();
1983
1984        // title should come before tags
1985        let title_pos = fixed.find("title:").unwrap();
1986        let tags_pos = fixed.find("tags:").unwrap();
1987        assert!(title_pos < tags_pos, "title should come before tags");
1988
1989        // Nested list should be preserved
1990        assert!(fixed.contains("- rust"), "List items should be preserved");
1991        assert!(fixed.contains("- markdown"), "List items should be preserved");
1992    }
1993
1994    #[test]
1995    fn test_key_order_idempotent_fix() {
1996        // Fixing twice should produce the same result
1997        let rule = create_rule_with_key_order(vec!["title", "date", "author"]);
1998        let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
1999        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2000
2001        let fixed_once = rule.fix(&ctx).unwrap();
2002        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
2003        let fixed_twice = rule.fix(&ctx2).unwrap();
2004
2005        assert_eq!(fixed_once, fixed_twice, "Fix should be idempotent");
2006    }
2007
2008    #[test]
2009    fn test_key_order_respects_later_position_over_alphabetical() {
2010        // If key_order says "z" comes before "a", that should be respected
2011        let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
2012        let content = "---\nzebra: Zoo\naardvark: Ant\n---\n\n# Heading";
2013        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2014        let result = rule.check(&ctx).unwrap();
2015
2016        // zebra (pos 0), aardvark (pos 1) → sorted according to key_order
2017        assert!(result.is_empty());
2018    }
2019
2020    // ==================== JSON braces in string values ====================
2021
2022    #[test]
2023    fn test_json_braces_in_string_values_extracts_all_keys() {
2024        // Braces inside JSON string values should not affect depth tracking.
2025        // The key "author" (on the line after the brace-containing value) must be extracted.
2026        // Content is already sorted, so no warnings expected.
2027        let rule = create_enabled_rule();
2028        let content = "{\n\"author\": \"Someone\",\n\"description\": \"Use { to open\",\n\"tags\": [\"a\"],\n\"title\": \"My Post\"\n}\n\nContent here.\n";
2029        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2030        let result = rule.check(&ctx).unwrap();
2031
2032        // If all 4 keys are extracted, they are already sorted: author, description, tags, title
2033        assert!(
2034            result.is_empty(),
2035            "All keys should be extracted and recognized as sorted. Got: {result:?}"
2036        );
2037    }
2038
2039    #[test]
2040    fn test_json_braces_in_string_key_after_brace_value_detected() {
2041        // Specifically verify that a key appearing AFTER a line with unbalanced braces in a string is extracted
2042        let rule = create_enabled_rule();
2043        // "description" has an unbalanced `{` in its value
2044        // "author" comes on the next line and must be detected as a top-level key
2045        let content = "{\n\"description\": \"Use { to open\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2046        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2047        let result = rule.check(&ctx).unwrap();
2048
2049        // author < description alphabetically, but description comes first => unsorted
2050        // The warning should mention 'author' should come before 'description'
2051        assert_eq!(
2052            result.len(),
2053            1,
2054            "Should detect unsorted keys after brace-containing string value"
2055        );
2056        assert!(
2057            result[0].message.contains("'author' should come before 'description'"),
2058            "Should report author before description. Got: {}",
2059            result[0].message
2060        );
2061    }
2062
2063    #[test]
2064    fn test_json_brackets_in_string_values() {
2065        // Brackets inside JSON string values should not affect depth tracking
2066        let rule = create_enabled_rule();
2067        let content = "{\n\"description\": \"My [Post]\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2068        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2069        let result = rule.check(&ctx).unwrap();
2070
2071        // author < description, but description comes first => unsorted
2072        assert_eq!(
2073            result.len(),
2074            1,
2075            "Should detect unsorted keys despite brackets in string values"
2076        );
2077        assert!(
2078            result[0].message.contains("'author' should come before 'description'"),
2079            "Got: {}",
2080            result[0].message
2081        );
2082    }
2083
2084    #[test]
2085    fn test_json_escaped_quotes_in_values() {
2086        // Escaped quotes inside values should not break string tracking
2087        let rule = create_enabled_rule();
2088        let content = "{\n\"title\": \"He said \\\"hello {world}\\\"\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2089        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2090        let result = rule.check(&ctx).unwrap();
2091
2092        // author < title, title comes first => unsorted
2093        assert_eq!(result.len(), 1, "Should handle escaped quotes with braces in values");
2094        assert!(
2095            result[0].message.contains("'author' should come before 'title'"),
2096            "Got: {}",
2097            result[0].message
2098        );
2099    }
2100
2101    #[test]
2102    fn test_json_multiple_braces_in_string() {
2103        // Multiple unbalanced braces in string values
2104        let rule = create_enabled_rule();
2105        let content = "{\n\"pattern\": \"{{{}}\",\n\"author\": \"Someone\"\n}\n\nContent.\n";
2106        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2107        let result = rule.check(&ctx).unwrap();
2108
2109        // author < pattern, but pattern comes first => unsorted
2110        assert_eq!(result.len(), 1, "Should handle multiple braces in string values");
2111        assert!(
2112            result[0].message.contains("'author' should come before 'pattern'"),
2113            "Got: {}",
2114            result[0].message
2115        );
2116    }
2117
2118    #[test]
2119    fn test_key_order_detects_wrong_custom_order() {
2120        // Document has aardvark before zebra, but key_order says zebra first
2121        let rule = create_rule_with_key_order(vec!["zebra", "aardvark"]);
2122        let content = "---\naardvark: Ant\nzebra: Zoo\n---\n\n# Heading";
2123        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
2124        let result = rule.check(&ctx).unwrap();
2125
2126        assert_eq!(result.len(), 1);
2127        assert!(result[0].message.contains("'zebra' should come before 'aardvark'"));
2128    }
2129}