Skip to main content

rumdl_lib/rules/
md072_frontmatter_key_sort.rs

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