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 alphabetical 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
23impl RuleConfig for MD072Config {
24    const RULE_NAME: &'static str = "MD072";
25}
26
27/// Rule MD072: Frontmatter key sort
28///
29/// Ensures frontmatter keys are sorted alphabetically.
30/// Supports YAML, TOML, and JSON frontmatter formats.
31/// Auto-fix is only available when frontmatter contains no comments (YAML/TOML).
32/// JSON frontmatter is always auto-fixable since JSON has no comments.
33///
34/// **Note**: This rule is disabled by default because alphabetical key sorting
35/// is an opinionated style choice. Many projects prefer semantic ordering
36/// (title first, date second, etc.) rather than alphabetical.
37///
38/// See [docs/md072.md](../../docs/md072.md) for full documentation.
39#[derive(Clone, Default)]
40pub struct MD072FrontmatterKeySort {
41    config: MD072Config,
42}
43
44impl MD072FrontmatterKeySort {
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    /// Create from a config struct
50    pub fn from_config_struct(config: MD072Config) -> Self {
51        Self { config }
52    }
53
54    /// Check if frontmatter contains comments (YAML/TOML use #)
55    fn has_comments(frontmatter_lines: &[&str]) -> bool {
56        frontmatter_lines.iter().any(|line| line.trim_start().starts_with('#'))
57    }
58
59    /// Extract top-level keys from YAML frontmatter
60    fn extract_yaml_keys(frontmatter_lines: &[&str]) -> Vec<(usize, String)> {
61        let mut keys = Vec::new();
62
63        for (idx, line) in frontmatter_lines.iter().enumerate() {
64            // Top-level keys have no leading whitespace and contain a colon
65            if !line.starts_with(' ')
66                && !line.starts_with('\t')
67                && let Some(colon_pos) = line.find(':')
68            {
69                let key = line[..colon_pos].trim();
70                if !key.is_empty() && !key.starts_with('#') {
71                    keys.push((idx, key.to_string()));
72                }
73            }
74        }
75
76        keys
77    }
78
79    /// Extract top-level keys from TOML frontmatter
80    fn extract_toml_keys(frontmatter_lines: &[&str]) -> Vec<(usize, String)> {
81        let mut keys = Vec::new();
82
83        for (idx, line) in frontmatter_lines.iter().enumerate() {
84            let trimmed = line.trim();
85            // Skip comments and empty lines
86            if trimmed.is_empty() || trimmed.starts_with('#') {
87                continue;
88            }
89            // Stop at table headers like [section] - everything after is nested
90            if trimmed.starts_with('[') {
91                break;
92            }
93            // Top-level keys have no leading whitespace and contain =
94            if !line.starts_with(' ')
95                && !line.starts_with('\t')
96                && let Some(eq_pos) = line.find('=')
97            {
98                let key = line[..eq_pos].trim();
99                if !key.is_empty() {
100                    keys.push((idx, key.to_string()));
101                }
102            }
103        }
104
105        keys
106    }
107
108    /// Extract top-level keys from JSON frontmatter in order of appearance
109    fn extract_json_keys(frontmatter_lines: &[&str]) -> Vec<String> {
110        // Extract keys from raw JSON text to preserve original order
111        // serde_json::Map uses BTreeMap which sorts keys, so we parse manually
112        // Only extract keys at depth 0 relative to the content (top-level inside the outer object)
113        // Note: frontmatter_lines excludes the opening `{`, so we start at depth 0
114        let mut keys = Vec::new();
115        let mut depth: usize = 0;
116
117        for line in frontmatter_lines {
118            // Track depth before checking for keys on this line
119            let line_start_depth = depth;
120
121            // Count braces and brackets to track nesting
122            for ch in line.chars() {
123                match ch {
124                    '{' | '[' => depth += 1,
125                    '}' | ']' => depth = depth.saturating_sub(1),
126                    _ => {}
127                }
128            }
129
130            // Only extract keys at depth 0 (top-level, since opening brace is excluded)
131            if line_start_depth == 0
132                && let Some(captures) = JSON_KEY_PATTERN.captures(line)
133                && let Some(key_match) = captures.get(1)
134            {
135                keys.push(key_match.as_str().to_string());
136            }
137        }
138
139        keys
140    }
141
142    /// Find the first pair of keys that are out of order (case-insensitive)
143    /// Returns (out_of_place_key, should_come_after_key)
144    fn find_first_unsorted_pair(keys: &[String]) -> Option<(&str, &str)> {
145        for i in 1..keys.len() {
146            if keys[i].to_lowercase() < keys[i - 1].to_lowercase() {
147                return Some((&keys[i], &keys[i - 1]));
148            }
149        }
150        None
151    }
152
153    /// Find the first pair of indexed keys that are out of order (case-insensitive)
154    /// Returns (out_of_place_key, should_come_after_key)
155    fn find_first_unsorted_indexed_pair(keys: &[(usize, String)]) -> Option<(&str, &str)> {
156        for i in 1..keys.len() {
157            if keys[i].1.to_lowercase() < keys[i - 1].1.to_lowercase() {
158                return Some((&keys[i].1, &keys[i - 1].1));
159            }
160        }
161        None
162    }
163
164    /// Check if keys are sorted alphabetically (case-insensitive)
165    fn are_keys_sorted(keys: &[String]) -> bool {
166        Self::find_first_unsorted_pair(keys).is_none()
167    }
168
169    /// Check if indexed keys are sorted alphabetically (case-insensitive)
170    fn are_indexed_keys_sorted(keys: &[(usize, String)]) -> bool {
171        Self::find_first_unsorted_indexed_pair(keys).is_none()
172    }
173}
174
175impl Rule for MD072FrontmatterKeySort {
176    fn name(&self) -> &'static str {
177        "MD072"
178    }
179
180    fn description(&self) -> &'static str {
181        "Frontmatter keys should be sorted alphabetically"
182    }
183
184    fn check(&self, ctx: &crate::lint_context::LintContext) -> LintResult {
185        if !self.config.enabled {
186            return Ok(Vec::new());
187        }
188
189        let content = ctx.content;
190        let mut warnings = Vec::new();
191
192        if content.is_empty() {
193            return Ok(warnings);
194        }
195
196        let fm_type = FrontMatterUtils::detect_front_matter_type(content);
197
198        match fm_type {
199            FrontMatterType::Yaml => {
200                let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
201                if frontmatter_lines.is_empty() {
202                    return Ok(warnings);
203                }
204
205                let keys = Self::extract_yaml_keys(&frontmatter_lines);
206                let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_indexed_pair(&keys) else {
207                    return Ok(warnings);
208                };
209
210                let has_comments = Self::has_comments(&frontmatter_lines);
211
212                let fix = if has_comments {
213                    None
214                } else {
215                    // Compute the actual fix: full content replacement
216                    match self.fix_yaml(content) {
217                        Ok(fixed_content) if fixed_content != content => Some(Fix {
218                            range: 0..content.len(),
219                            replacement: fixed_content,
220                        }),
221                        _ => None,
222                    }
223                };
224
225                let message = if has_comments {
226                    format!(
227                        "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
228                    )
229                } else {
230                    format!(
231                        "YAML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
232                    )
233                };
234
235                warnings.push(LintWarning {
236                    rule_name: Some(self.name().to_string()),
237                    message,
238                    line: 2, // First line after opening ---
239                    column: 1,
240                    end_line: 2,
241                    end_column: 1,
242                    severity: Severity::Warning,
243                    fix,
244                });
245            }
246            FrontMatterType::Toml => {
247                let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
248                if frontmatter_lines.is_empty() {
249                    return Ok(warnings);
250                }
251
252                let keys = Self::extract_toml_keys(&frontmatter_lines);
253                let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_indexed_pair(&keys) else {
254                    return Ok(warnings);
255                };
256
257                let has_comments = Self::has_comments(&frontmatter_lines);
258
259                let fix = if has_comments {
260                    None
261                } else {
262                    // Compute the actual fix: full content replacement
263                    match self.fix_toml(content) {
264                        Ok(fixed_content) if fixed_content != content => Some(Fix {
265                            range: 0..content.len(),
266                            replacement: fixed_content,
267                        }),
268                        _ => None,
269                    }
270                };
271
272                let message = if has_comments {
273                    format!(
274                        "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}' (auto-fix unavailable: contains comments)"
275                    )
276                } else {
277                    format!(
278                        "TOML frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
279                    )
280                };
281
282                warnings.push(LintWarning {
283                    rule_name: Some(self.name().to_string()),
284                    message,
285                    line: 2, // First line after opening +++
286                    column: 1,
287                    end_line: 2,
288                    end_column: 1,
289                    severity: Severity::Warning,
290                    fix,
291                });
292            }
293            FrontMatterType::Json => {
294                let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
295                if frontmatter_lines.is_empty() {
296                    return Ok(warnings);
297                }
298
299                let keys = Self::extract_json_keys(&frontmatter_lines);
300                let Some((out_of_place, should_come_after)) = Self::find_first_unsorted_pair(&keys) else {
301                    return Ok(warnings);
302                };
303
304                // Compute the actual fix: full content replacement
305                let fix = match self.fix_json(content) {
306                    Ok(fixed_content) if fixed_content != content => Some(Fix {
307                        range: 0..content.len(),
308                        replacement: fixed_content,
309                    }),
310                    _ => None,
311                };
312
313                let message = format!(
314                    "JSON frontmatter keys are not sorted alphabetically: '{out_of_place}' should come before '{should_come_after}'"
315                );
316
317                warnings.push(LintWarning {
318                    rule_name: Some(self.name().to_string()),
319                    message,
320                    line: 2, // First line after opening {
321                    column: 1,
322                    end_line: 2,
323                    end_column: 1,
324                    severity: Severity::Warning,
325                    fix,
326                });
327            }
328            _ => {
329                // No frontmatter or malformed - skip
330            }
331        }
332
333        Ok(warnings)
334    }
335
336    fn fix(&self, ctx: &crate::lint_context::LintContext) -> Result<String, LintError> {
337        if !self.config.enabled {
338            return Ok(ctx.content.to_string());
339        }
340
341        let content = ctx.content;
342
343        let fm_type = FrontMatterUtils::detect_front_matter_type(content);
344
345        match fm_type {
346            FrontMatterType::Yaml => self.fix_yaml(content),
347            FrontMatterType::Toml => self.fix_toml(content),
348            FrontMatterType::Json => self.fix_json(content),
349            _ => Ok(content.to_string()),
350        }
351    }
352
353    fn category(&self) -> RuleCategory {
354        RuleCategory::FrontMatter
355    }
356
357    fn as_any(&self) -> &dyn std::any::Any {
358        self
359    }
360
361    fn default_config_section(&self) -> Option<(String, toml::Value)> {
362        let default_config = MD072Config::default();
363        let json_value = serde_json::to_value(&default_config).ok()?;
364        let toml_value = crate::rule_config_serde::json_to_toml_value(&json_value)?;
365
366        if let toml::Value::Table(table) = toml_value {
367            if !table.is_empty() {
368                Some((MD072Config::RULE_NAME.to_string(), toml::Value::Table(table)))
369            } else {
370                // For opt-in rules, we need to explicitly declare the 'enabled' key
371                let mut table = toml::map::Map::new();
372                table.insert("enabled".to_string(), toml::Value::Boolean(false));
373                Some((MD072Config::RULE_NAME.to_string(), toml::Value::Table(table)))
374            }
375        } else {
376            None
377        }
378    }
379
380    fn from_config(config: &crate::config::Config) -> Box<dyn Rule>
381    where
382        Self: Sized,
383    {
384        let rule_config = crate::rule_config_serde::load_rule_config::<MD072Config>(config);
385        Box::new(Self::from_config_struct(rule_config))
386    }
387}
388
389impl MD072FrontmatterKeySort {
390    fn fix_yaml(&self, content: &str) -> Result<String, LintError> {
391        let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
392        if frontmatter_lines.is_empty() {
393            return Ok(content.to_string());
394        }
395
396        // Cannot fix if comments present
397        if Self::has_comments(&frontmatter_lines) {
398            return Ok(content.to_string());
399        }
400
401        let keys = Self::extract_yaml_keys(&frontmatter_lines);
402        if Self::are_indexed_keys_sorted(&keys) {
403            return Ok(content.to_string());
404        }
405
406        // Line-based reordering to preserve original formatting (indentation, etc.)
407        // Each key owns all lines until the next top-level key
408        let mut key_blocks: Vec<(String, Vec<&str>)> = Vec::new();
409
410        for (i, (line_idx, key)) in keys.iter().enumerate() {
411            let start = *line_idx;
412            let end = if i + 1 < keys.len() {
413                keys[i + 1].0
414            } else {
415                frontmatter_lines.len()
416            };
417
418            let block_lines: Vec<&str> = frontmatter_lines[start..end].to_vec();
419            key_blocks.push((key.to_lowercase(), block_lines));
420        }
421
422        // Sort by key (case-insensitive)
423        key_blocks.sort_by(|a, b| a.0.cmp(&b.0));
424
425        // Reassemble frontmatter
426        let content_lines: Vec<&str> = content.lines().collect();
427        let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
428
429        let mut result = String::new();
430        result.push_str("---\n");
431        for (_, lines) in &key_blocks {
432            for line in lines {
433                result.push_str(line);
434                result.push('\n');
435            }
436        }
437        result.push_str("---");
438
439        if fm_end < content_lines.len() {
440            result.push('\n');
441            result.push_str(&content_lines[fm_end..].join("\n"));
442        }
443
444        Ok(result)
445    }
446
447    fn fix_toml(&self, content: &str) -> Result<String, LintError> {
448        let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
449        if frontmatter_lines.is_empty() {
450            return Ok(content.to_string());
451        }
452
453        // Cannot fix if comments present
454        if Self::has_comments(&frontmatter_lines) {
455            return Ok(content.to_string());
456        }
457
458        let keys = Self::extract_toml_keys(&frontmatter_lines);
459        if Self::are_indexed_keys_sorted(&keys) {
460            return Ok(content.to_string());
461        }
462
463        // Parse and re-serialize with sorted keys
464        let fm_content = frontmatter_lines.join("\n");
465
466        match toml::from_str::<toml::Value>(&fm_content) {
467            Ok(value) => {
468                if let toml::Value::Table(table) = value {
469                    // toml crate's Table is already a BTreeMap which is sorted
470                    // But we need case-insensitive sorting
471                    let mut sorted_table = toml::map::Map::new();
472                    let mut keys: Vec<_> = table.keys().cloned().collect();
473                    keys.sort_by_key(|a| a.to_lowercase());
474
475                    for key in keys {
476                        if let Some(value) = table.get(&key) {
477                            sorted_table.insert(key, value.clone());
478                        }
479                    }
480
481                    match toml::to_string_pretty(&toml::Value::Table(sorted_table)) {
482                        Ok(sorted_toml) => {
483                            let lines: Vec<&str> = content.lines().collect();
484                            let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
485
486                            let mut result = String::new();
487                            result.push_str("+++\n");
488                            result.push_str(sorted_toml.trim_end());
489                            result.push_str("\n+++");
490
491                            if fm_end < lines.len() {
492                                result.push('\n');
493                                result.push_str(&lines[fm_end..].join("\n"));
494                            }
495
496                            Ok(result)
497                        }
498                        Err(_) => Ok(content.to_string()),
499                    }
500                } else {
501                    Ok(content.to_string())
502                }
503            }
504            Err(_) => Ok(content.to_string()),
505        }
506    }
507
508    fn fix_json(&self, content: &str) -> Result<String, LintError> {
509        let frontmatter_lines = FrontMatterUtils::extract_front_matter(content);
510        if frontmatter_lines.is_empty() {
511            return Ok(content.to_string());
512        }
513
514        let keys = Self::extract_json_keys(&frontmatter_lines);
515
516        if keys.is_empty() || Self::are_keys_sorted(&keys) {
517            return Ok(content.to_string());
518        }
519
520        // Reconstruct JSON content including braces for parsing
521        let json_content = format!("{{{}}}", frontmatter_lines.join("\n"));
522
523        // Parse and re-serialize with sorted keys
524        match serde_json::from_str::<serde_json::Value>(&json_content) {
525            Ok(serde_json::Value::Object(map)) => {
526                // serde_json::Map preserves insertion order, so we need to rebuild
527                let mut sorted_map = serde_json::Map::new();
528                let mut keys: Vec<_> = map.keys().cloned().collect();
529                keys.sort_by_key(|a| a.to_lowercase());
530
531                for key in keys {
532                    if let Some(value) = map.get(&key) {
533                        sorted_map.insert(key, value.clone());
534                    }
535                }
536
537                match serde_json::to_string_pretty(&serde_json::Value::Object(sorted_map)) {
538                    Ok(sorted_json) => {
539                        let lines: Vec<&str> = content.lines().collect();
540                        let fm_end = FrontMatterUtils::get_front_matter_end_line(content);
541
542                        // The pretty-printed JSON includes the outer braces
543                        // We need to format it properly for frontmatter
544                        let mut result = String::new();
545                        result.push_str(&sorted_json);
546
547                        if fm_end < lines.len() {
548                            result.push('\n');
549                            result.push_str(&lines[fm_end..].join("\n"));
550                        }
551
552                        Ok(result)
553                    }
554                    Err(_) => Ok(content.to_string()),
555                }
556            }
557            _ => Ok(content.to_string()),
558        }
559    }
560}
561
562#[cfg(test)]
563mod tests {
564    use super::*;
565    use crate::lint_context::LintContext;
566
567    /// Create an enabled rule for testing
568    fn create_enabled_rule() -> MD072FrontmatterKeySort {
569        MD072FrontmatterKeySort::from_config_struct(MD072Config { enabled: true })
570    }
571
572    // ==================== Config Tests ====================
573
574    #[test]
575    fn test_disabled_by_default() {
576        let rule = MD072FrontmatterKeySort::new();
577        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
578        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
579        let result = rule.check(&ctx).unwrap();
580
581        // Disabled by default, should return no warnings
582        assert!(result.is_empty());
583    }
584
585    #[test]
586    fn test_enabled_via_config() {
587        let rule = create_enabled_rule();
588        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
589        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
590        let result = rule.check(&ctx).unwrap();
591
592        // Enabled, should detect unsorted keys
593        assert_eq!(result.len(), 1);
594    }
595
596    // ==================== YAML Tests ====================
597
598    #[test]
599    fn test_no_frontmatter() {
600        let rule = create_enabled_rule();
601        let content = "# Heading\n\nContent.";
602        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
603        let result = rule.check(&ctx).unwrap();
604
605        assert!(result.is_empty());
606    }
607
608    #[test]
609    fn test_yaml_sorted_keys() {
610        let rule = create_enabled_rule();
611        let content = "---\nauthor: John\ndate: 2024-01-01\ntitle: Test\n---\n\n# Heading";
612        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
613        let result = rule.check(&ctx).unwrap();
614
615        assert!(result.is_empty());
616    }
617
618    #[test]
619    fn test_yaml_unsorted_keys() {
620        let rule = create_enabled_rule();
621        let content = "---\ntitle: Test\nauthor: John\ndate: 2024-01-01\n---\n\n# Heading";
622        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
623        let result = rule.check(&ctx).unwrap();
624
625        assert_eq!(result.len(), 1);
626        assert!(result[0].message.contains("YAML"));
627        assert!(result[0].message.contains("not sorted"));
628        // Message shows first out-of-order pair: 'author' should come before 'title'
629        assert!(result[0].message.contains("'author' should come before 'title'"));
630    }
631
632    #[test]
633    fn test_yaml_case_insensitive_sort() {
634        let rule = create_enabled_rule();
635        let content = "---\nAuthor: John\ndate: 2024-01-01\nTitle: Test\n---\n\n# Heading";
636        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
637        let result = rule.check(&ctx).unwrap();
638
639        // Author, date, Title should be considered sorted (case-insensitive)
640        assert!(result.is_empty());
641    }
642
643    #[test]
644    fn test_yaml_fix_sorts_keys() {
645        let rule = create_enabled_rule();
646        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
647        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
648        let fixed = rule.fix(&ctx).unwrap();
649
650        // Keys should be sorted
651        let author_pos = fixed.find("author:").unwrap();
652        let title_pos = fixed.find("title:").unwrap();
653        assert!(author_pos < title_pos);
654    }
655
656    #[test]
657    fn test_yaml_no_fix_with_comments() {
658        let rule = create_enabled_rule();
659        let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading";
660        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
661        let result = rule.check(&ctx).unwrap();
662
663        assert_eq!(result.len(), 1);
664        assert!(result[0].message.contains("auto-fix unavailable"));
665        assert!(result[0].fix.is_none());
666
667        // Fix should not modify content
668        let fixed = rule.fix(&ctx).unwrap();
669        assert_eq!(fixed, content);
670    }
671
672    #[test]
673    fn test_yaml_single_key() {
674        let rule = create_enabled_rule();
675        let content = "---\ntitle: Test\n---\n\n# Heading";
676        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
677        let result = rule.check(&ctx).unwrap();
678
679        // Single key is always sorted
680        assert!(result.is_empty());
681    }
682
683    #[test]
684    fn test_yaml_nested_keys_ignored() {
685        let rule = create_enabled_rule();
686        // Only top-level keys are checked, nested keys are ignored
687        let content = "---\nauthor:\n  name: John\n  email: john@example.com\ntitle: Test\n---\n\n# Heading";
688        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
689        let result = rule.check(&ctx).unwrap();
690
691        // author, title are sorted
692        assert!(result.is_empty());
693    }
694
695    #[test]
696    fn test_yaml_fix_idempotent() {
697        let rule = create_enabled_rule();
698        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading";
699        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
700        let fixed_once = rule.fix(&ctx).unwrap();
701
702        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
703        let fixed_twice = rule.fix(&ctx2).unwrap();
704
705        assert_eq!(fixed_once, fixed_twice);
706    }
707
708    #[test]
709    fn test_yaml_complex_values() {
710        let rule = create_enabled_rule();
711        // Keys in sorted order: author, tags, title
712        let content =
713            "---\nauthor: John Doe\ntags:\n  - rust\n  - markdown\ntitle: \"Test: A Complex Title\"\n---\n\n# Heading";
714        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
715        let result = rule.check(&ctx).unwrap();
716
717        // author, tags, title - sorted
718        assert!(result.is_empty());
719    }
720
721    // ==================== TOML Tests ====================
722
723    #[test]
724    fn test_toml_sorted_keys() {
725        let rule = create_enabled_rule();
726        let content = "+++\nauthor = \"John\"\ndate = \"2024-01-01\"\ntitle = \"Test\"\n+++\n\n# Heading";
727        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
728        let result = rule.check(&ctx).unwrap();
729
730        assert!(result.is_empty());
731    }
732
733    #[test]
734    fn test_toml_unsorted_keys() {
735        let rule = create_enabled_rule();
736        let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
737        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
738        let result = rule.check(&ctx).unwrap();
739
740        assert_eq!(result.len(), 1);
741        assert!(result[0].message.contains("TOML"));
742        assert!(result[0].message.contains("not sorted"));
743    }
744
745    #[test]
746    fn test_toml_fix_sorts_keys() {
747        let rule = create_enabled_rule();
748        let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading";
749        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
750        let fixed = rule.fix(&ctx).unwrap();
751
752        // Keys should be sorted
753        let author_pos = fixed.find("author").unwrap();
754        let title_pos = fixed.find("title").unwrap();
755        assert!(author_pos < title_pos);
756    }
757
758    #[test]
759    fn test_toml_no_fix_with_comments() {
760        let rule = create_enabled_rule();
761        let content = "+++\ntitle = \"Test\"\n# This is a comment\nauthor = \"John\"\n+++\n\n# Heading";
762        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
763        let result = rule.check(&ctx).unwrap();
764
765        assert_eq!(result.len(), 1);
766        assert!(result[0].message.contains("auto-fix unavailable"));
767
768        // Fix should not modify content
769        let fixed = rule.fix(&ctx).unwrap();
770        assert_eq!(fixed, content);
771    }
772
773    // ==================== JSON Tests ====================
774
775    #[test]
776    fn test_json_sorted_keys() {
777        let rule = create_enabled_rule();
778        let content = "{\n\"author\": \"John\",\n\"title\": \"Test\"\n}\n\n# Heading";
779        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
780        let result = rule.check(&ctx).unwrap();
781
782        assert!(result.is_empty());
783    }
784
785    #[test]
786    fn test_json_unsorted_keys() {
787        let rule = create_enabled_rule();
788        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
789        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
790        let result = rule.check(&ctx).unwrap();
791
792        assert_eq!(result.len(), 1);
793        assert!(result[0].message.contains("JSON"));
794        assert!(result[0].message.contains("not sorted"));
795    }
796
797    #[test]
798    fn test_json_fix_sorts_keys() {
799        let rule = create_enabled_rule();
800        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
801        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
802        let fixed = rule.fix(&ctx).unwrap();
803
804        // Keys should be sorted
805        let author_pos = fixed.find("author").unwrap();
806        let title_pos = fixed.find("title").unwrap();
807        assert!(author_pos < title_pos);
808    }
809
810    #[test]
811    fn test_json_always_fixable() {
812        let rule = create_enabled_rule();
813        // JSON has no comments, so should always be fixable
814        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
815        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
816        let result = rule.check(&ctx).unwrap();
817
818        assert_eq!(result.len(), 1);
819        assert!(result[0].fix.is_some()); // Always fixable
820        assert!(!result[0].message.contains("Auto-fix unavailable"));
821    }
822
823    // ==================== General Tests ====================
824
825    #[test]
826    fn test_empty_content() {
827        let rule = create_enabled_rule();
828        let content = "";
829        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
830        let result = rule.check(&ctx).unwrap();
831
832        assert!(result.is_empty());
833    }
834
835    #[test]
836    fn test_empty_frontmatter() {
837        let rule = create_enabled_rule();
838        let content = "---\n---\n\n# Heading";
839        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
840        let result = rule.check(&ctx).unwrap();
841
842        assert!(result.is_empty());
843    }
844
845    #[test]
846    fn test_toml_nested_tables_ignored() {
847        // Keys inside [extra] or [taxonomies] should NOT be checked
848        let rule = create_enabled_rule();
849        let content = "+++\ntitle = \"Programming\"\nsort_by = \"weight\"\n\n[extra]\nwe_have_extra = \"variables\"\n+++\n\n# Heading";
850        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
851        let result = rule.check(&ctx).unwrap();
852
853        // Only top-level keys (title, sort_by) should be checked, not we_have_extra
854        assert_eq!(result.len(), 1);
855        // Message shows first out-of-order pair: 'sort_by' should come before 'title'
856        assert!(result[0].message.contains("'sort_by' should come before 'title'"));
857        assert!(!result[0].message.contains("we_have_extra"));
858    }
859
860    #[test]
861    fn test_toml_nested_taxonomies_ignored() {
862        // Keys inside [taxonomies] should NOT be checked
863        let rule = create_enabled_rule();
864        let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[taxonomies]\ncategories = [\"test\"]\ntags = [\"foo\"]\n+++\n\n# Heading";
865        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
866        let result = rule.check(&ctx).unwrap();
867
868        // Only top-level keys (title, date) should be checked
869        assert_eq!(result.len(), 1);
870        // Message shows first out-of-order pair: 'date' should come before 'title'
871        assert!(result[0].message.contains("'date' should come before 'title'"));
872        assert!(!result[0].message.contains("categories"));
873        assert!(!result[0].message.contains("tags"));
874    }
875
876    // ==================== Edge Case Tests ====================
877
878    #[test]
879    fn test_yaml_unicode_keys() {
880        let rule = create_enabled_rule();
881        // Japanese keys should sort correctly
882        let content = "---\nタイトル: Test\nあいう: Value\n日本語: Content\n---\n\n# Heading";
883        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
884        let result = rule.check(&ctx).unwrap();
885
886        // Should detect unsorted keys (あいう < タイトル < 日本語 in Unicode order)
887        assert_eq!(result.len(), 1);
888    }
889
890    #[test]
891    fn test_yaml_keys_with_special_characters() {
892        let rule = create_enabled_rule();
893        // Keys with dashes and underscores
894        let content = "---\nmy-key: value1\nmy_key: value2\nmykey: value3\n---\n\n# Heading";
895        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
896        let result = rule.check(&ctx).unwrap();
897
898        // my-key, my_key, mykey - should be sorted
899        assert!(result.is_empty());
900    }
901
902    #[test]
903    fn test_yaml_keys_with_numbers() {
904        let rule = create_enabled_rule();
905        let content = "---\nkey1: value\nkey10: value\nkey2: value\n---\n\n# Heading";
906        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
907        let result = rule.check(&ctx).unwrap();
908
909        // key1, key10, key2 - lexicographic order (1 < 10 < 2)
910        assert!(result.is_empty());
911    }
912
913    #[test]
914    fn test_yaml_multiline_string_block_literal() {
915        let rule = create_enabled_rule();
916        let content =
917            "---\ndescription: |\n  This is a\n  multiline literal\ntitle: Test\nauthor: John\n---\n\n# Heading";
918        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
919        let result = rule.check(&ctx).unwrap();
920
921        // description, title, author - first out-of-order: 'author' should come before 'title'
922        assert_eq!(result.len(), 1);
923        assert!(result[0].message.contains("'author' should come before 'title'"));
924    }
925
926    #[test]
927    fn test_yaml_multiline_string_folded() {
928        let rule = create_enabled_rule();
929        let content = "---\ndescription: >\n  This is a\n  folded string\nauthor: John\n---\n\n# Heading";
930        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
931        let result = rule.check(&ctx).unwrap();
932
933        // author, description - not sorted
934        assert_eq!(result.len(), 1);
935    }
936
937    #[test]
938    fn test_yaml_fix_preserves_multiline_values() {
939        let rule = create_enabled_rule();
940        let content = "---\ntitle: Test\ndescription: |\n  Line 1\n  Line 2\n---\n\n# Heading";
941        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
942        let fixed = rule.fix(&ctx).unwrap();
943
944        // description should come before title
945        let desc_pos = fixed.find("description").unwrap();
946        let title_pos = fixed.find("title").unwrap();
947        assert!(desc_pos < title_pos);
948    }
949
950    #[test]
951    fn test_yaml_quoted_keys() {
952        let rule = create_enabled_rule();
953        let content = "---\n\"quoted-key\": value1\nunquoted: value2\n---\n\n# Heading";
954        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
955        let result = rule.check(&ctx).unwrap();
956
957        // quoted-key should sort before unquoted
958        assert!(result.is_empty());
959    }
960
961    #[test]
962    fn test_yaml_duplicate_keys() {
963        // YAML allows duplicate keys (last one wins), but we should still sort
964        let rule = create_enabled_rule();
965        let content = "---\ntitle: First\nauthor: John\ntitle: Second\n---\n\n# Heading";
966        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
967        let result = rule.check(&ctx).unwrap();
968
969        // Should still check sorting (title, author, title is not sorted)
970        assert_eq!(result.len(), 1);
971    }
972
973    #[test]
974    fn test_toml_inline_table() {
975        let rule = create_enabled_rule();
976        let content =
977            "+++\nauthor = { name = \"John\", email = \"john@example.com\" }\ntitle = \"Test\"\n+++\n\n# Heading";
978        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
979        let result = rule.check(&ctx).unwrap();
980
981        // author, title - sorted
982        assert!(result.is_empty());
983    }
984
985    #[test]
986    fn test_toml_array_of_tables() {
987        let rule = create_enabled_rule();
988        let content = "+++\ntitle = \"Test\"\ndate = \"2024-01-01\"\n\n[[authors]]\nname = \"John\"\n\n[[authors]]\nname = \"Jane\"\n+++\n\n# Heading";
989        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
990        let result = rule.check(&ctx).unwrap();
991
992        // Only top-level keys (title, date) checked - date < title, so unsorted
993        assert_eq!(result.len(), 1);
994        // Message shows first out-of-order pair: 'date' should come before 'title'
995        assert!(result[0].message.contains("'date' should come before 'title'"));
996    }
997
998    #[test]
999    fn test_json_nested_objects() {
1000        let rule = create_enabled_rule();
1001        let content = "{\n\"author\": {\n  \"name\": \"John\",\n  \"email\": \"john@example.com\"\n},\n\"title\": \"Test\"\n}\n\n# Heading";
1002        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1003        let result = rule.check(&ctx).unwrap();
1004
1005        // Only top-level keys (author, title) checked - sorted
1006        assert!(result.is_empty());
1007    }
1008
1009    #[test]
1010    fn test_json_arrays() {
1011        let rule = create_enabled_rule();
1012        let content = "{\n\"tags\": [\"rust\", \"markdown\"],\n\"author\": \"John\"\n}\n\n# Heading";
1013        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1014        let result = rule.check(&ctx).unwrap();
1015
1016        // author, tags - not sorted (tags comes first)
1017        assert_eq!(result.len(), 1);
1018    }
1019
1020    #[test]
1021    fn test_fix_preserves_content_after_frontmatter() {
1022        let rule = create_enabled_rule();
1023        let content = "---\ntitle: Test\nauthor: John\n---\n\n# Heading\n\nParagraph 1.\n\n- List item\n- Another item";
1024        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1025        let fixed = rule.fix(&ctx).unwrap();
1026
1027        // Verify content after frontmatter is preserved
1028        assert!(fixed.contains("# Heading"));
1029        assert!(fixed.contains("Paragraph 1."));
1030        assert!(fixed.contains("- List item"));
1031        assert!(fixed.contains("- Another item"));
1032    }
1033
1034    #[test]
1035    fn test_fix_yaml_produces_valid_yaml() {
1036        let rule = create_enabled_rule();
1037        let content = "---\ntitle: \"Test: A Title\"\nauthor: John Doe\ndate: 2024-01-15\n---\n\n# Heading";
1038        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1039        let fixed = rule.fix(&ctx).unwrap();
1040
1041        // The fixed output should be parseable as YAML
1042        // Extract frontmatter lines
1043        let lines: Vec<&str> = fixed.lines().collect();
1044        let fm_end = lines.iter().skip(1).position(|l| *l == "---").unwrap() + 1;
1045        let fm_content: String = lines[1..fm_end].join("\n");
1046
1047        // Should parse without error
1048        let parsed: Result<serde_yml::Value, _> = serde_yml::from_str(&fm_content);
1049        assert!(parsed.is_ok(), "Fixed YAML should be valid: {fm_content}");
1050    }
1051
1052    #[test]
1053    fn test_fix_toml_produces_valid_toml() {
1054        let rule = create_enabled_rule();
1055        let content = "+++\ntitle = \"Test\"\nauthor = \"John Doe\"\ndate = 2024-01-15\n+++\n\n# Heading";
1056        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1057        let fixed = rule.fix(&ctx).unwrap();
1058
1059        // Extract frontmatter
1060        let lines: Vec<&str> = fixed.lines().collect();
1061        let fm_end = lines.iter().skip(1).position(|l| *l == "+++").unwrap() + 1;
1062        let fm_content: String = lines[1..fm_end].join("\n");
1063
1064        // Should parse without error
1065        let parsed: Result<toml::Value, _> = toml::from_str(&fm_content);
1066        assert!(parsed.is_ok(), "Fixed TOML should be valid: {fm_content}");
1067    }
1068
1069    #[test]
1070    fn test_fix_json_produces_valid_json() {
1071        let rule = create_enabled_rule();
1072        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading";
1073        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1074        let fixed = rule.fix(&ctx).unwrap();
1075
1076        // Extract JSON frontmatter (everything up to blank line)
1077        let json_end = fixed.find("\n\n").unwrap();
1078        let json_content = &fixed[..json_end];
1079
1080        // Should parse without error
1081        let parsed: Result<serde_json::Value, _> = serde_json::from_str(json_content);
1082        assert!(parsed.is_ok(), "Fixed JSON should be valid: {json_content}");
1083    }
1084
1085    #[test]
1086    fn test_many_keys_performance() {
1087        let rule = create_enabled_rule();
1088        // Generate frontmatter with 100 keys
1089        let mut keys: Vec<String> = (0..100).map(|i| format!("key{i:03}: value{i}")).collect();
1090        keys.reverse(); // Make them unsorted
1091        let content = format!("---\n{}\n---\n\n# Heading", keys.join("\n"));
1092
1093        let ctx = LintContext::new(&content, crate::config::MarkdownFlavor::Standard, None);
1094        let result = rule.check(&ctx).unwrap();
1095
1096        // Should detect unsorted keys
1097        assert_eq!(result.len(), 1);
1098    }
1099
1100    #[test]
1101    fn test_yaml_empty_value() {
1102        let rule = create_enabled_rule();
1103        let content = "---\ntitle:\nauthor: John\n---\n\n# Heading";
1104        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1105        let result = rule.check(&ctx).unwrap();
1106
1107        // author, title - not sorted
1108        assert_eq!(result.len(), 1);
1109    }
1110
1111    #[test]
1112    fn test_yaml_null_value() {
1113        let rule = create_enabled_rule();
1114        let content = "---\ntitle: null\nauthor: John\n---\n\n# Heading";
1115        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1116        let result = rule.check(&ctx).unwrap();
1117
1118        assert_eq!(result.len(), 1);
1119    }
1120
1121    #[test]
1122    fn test_yaml_boolean_values() {
1123        let rule = create_enabled_rule();
1124        let content = "---\ndraft: true\nauthor: John\n---\n\n# Heading";
1125        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1126        let result = rule.check(&ctx).unwrap();
1127
1128        // author, draft - not sorted
1129        assert_eq!(result.len(), 1);
1130    }
1131
1132    #[test]
1133    fn test_toml_boolean_values() {
1134        let rule = create_enabled_rule();
1135        let content = "+++\ndraft = true\nauthor = \"John\"\n+++\n\n# Heading";
1136        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1137        let result = rule.check(&ctx).unwrap();
1138
1139        assert_eq!(result.len(), 1);
1140    }
1141
1142    #[test]
1143    fn test_yaml_list_at_top_level() {
1144        let rule = create_enabled_rule();
1145        let content = "---\ntags:\n  - rust\n  - markdown\nauthor: John\n---\n\n# Heading";
1146        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1147        let result = rule.check(&ctx).unwrap();
1148
1149        // author, tags - not sorted (tags comes first)
1150        assert_eq!(result.len(), 1);
1151    }
1152
1153    #[test]
1154    fn test_three_keys_all_orderings() {
1155        let rule = create_enabled_rule();
1156
1157        // Test all 6 permutations of a, b, c
1158        let orderings = [
1159            ("a, b, c", "---\na: 1\nb: 2\nc: 3\n---\n\n# H", true),  // sorted
1160            ("a, c, b", "---\na: 1\nc: 3\nb: 2\n---\n\n# H", false), // unsorted
1161            ("b, a, c", "---\nb: 2\na: 1\nc: 3\n---\n\n# H", false), // unsorted
1162            ("b, c, a", "---\nb: 2\nc: 3\na: 1\n---\n\n# H", false), // unsorted
1163            ("c, a, b", "---\nc: 3\na: 1\nb: 2\n---\n\n# H", false), // unsorted
1164            ("c, b, a", "---\nc: 3\nb: 2\na: 1\n---\n\n# H", false), // unsorted
1165        ];
1166
1167        for (name, content, should_pass) in orderings {
1168            let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1169            let result = rule.check(&ctx).unwrap();
1170            assert_eq!(
1171                result.is_empty(),
1172                should_pass,
1173                "Ordering {name} should {} pass",
1174                if should_pass { "" } else { "not" }
1175            );
1176        }
1177    }
1178
1179    #[test]
1180    fn test_crlf_line_endings() {
1181        let rule = create_enabled_rule();
1182        let content = "---\r\ntitle: Test\r\nauthor: John\r\n---\r\n\r\n# Heading";
1183        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1184        let result = rule.check(&ctx).unwrap();
1185
1186        // Should detect unsorted keys with CRLF
1187        assert_eq!(result.len(), 1);
1188    }
1189
1190    #[test]
1191    fn test_json_escaped_quotes_in_keys() {
1192        let rule = create_enabled_rule();
1193        // This is technically invalid JSON but tests regex robustness
1194        let content = "{\n\"normal\": \"value\",\n\"key\": \"with \\\"quotes\\\"\"\n}\n\n# Heading";
1195        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1196        let result = rule.check(&ctx).unwrap();
1197
1198        // key, normal - not sorted
1199        assert_eq!(result.len(), 1);
1200    }
1201
1202    // ==================== Warning-based Fix Tests (LSP Path) ====================
1203
1204    #[test]
1205    fn test_warning_fix_yaml_sorts_keys() {
1206        let rule = create_enabled_rule();
1207        let content = "---\nbbb: 123\naaa:\n  - hello\n  - world\n---\n\n# Heading\n";
1208        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1209        let warnings = rule.check(&ctx).unwrap();
1210
1211        assert_eq!(warnings.len(), 1);
1212        assert!(warnings[0].fix.is_some(), "Warning should have a fix attached for LSP");
1213
1214        let fix = warnings[0].fix.as_ref().unwrap();
1215        assert_eq!(fix.range, 0..content.len(), "Fix should replace entire content");
1216
1217        // Apply the fix using the warning-based fix utility (LSP path)
1218        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1219
1220        // Verify keys are sorted
1221        let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1222        let bbb_pos = fixed.find("bbb:").expect("bbb should exist");
1223        assert!(aaa_pos < bbb_pos, "aaa should come before bbb after sorting");
1224    }
1225
1226    #[test]
1227    fn test_warning_fix_preserves_yaml_list_indentation() {
1228        let rule = create_enabled_rule();
1229        let content = "---\nbbb: 123\naaa:\n  - hello\n  - world\n---\n\n# Heading\n";
1230        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1231        let warnings = rule.check(&ctx).unwrap();
1232
1233        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1234
1235        // Verify list items retain their 2-space indentation
1236        assert!(
1237            fixed.contains("  - hello"),
1238            "List indentation should be preserved: {fixed}"
1239        );
1240        assert!(
1241            fixed.contains("  - world"),
1242            "List indentation should be preserved: {fixed}"
1243        );
1244    }
1245
1246    #[test]
1247    fn test_warning_fix_preserves_nested_object_indentation() {
1248        let rule = create_enabled_rule();
1249        let content = "---\nzzzz: value\naaaa:\n  nested_key: nested_value\n  another: 123\n---\n\n# Heading\n";
1250        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1251        let warnings = rule.check(&ctx).unwrap();
1252
1253        assert_eq!(warnings.len(), 1);
1254        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1255
1256        // Verify aaaa comes before zzzz
1257        let aaaa_pos = fixed.find("aaaa:").expect("aaaa should exist");
1258        let zzzz_pos = fixed.find("zzzz:").expect("zzzz should exist");
1259        assert!(aaaa_pos < zzzz_pos, "aaaa should come before zzzz");
1260
1261        // Verify nested keys retain their 2-space indentation
1262        assert!(
1263            fixed.contains("  nested_key: nested_value"),
1264            "Nested object indentation should be preserved: {fixed}"
1265        );
1266        assert!(
1267            fixed.contains("  another: 123"),
1268            "Nested object indentation should be preserved: {fixed}"
1269        );
1270    }
1271
1272    #[test]
1273    fn test_warning_fix_preserves_deeply_nested_structure() {
1274        let rule = create_enabled_rule();
1275        let content = "---\nzzz: top\naaa:\n  level1:\n    level2:\n      - item1\n      - item2\n---\n\n# Content\n";
1276        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1277        let warnings = rule.check(&ctx).unwrap();
1278
1279        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1280
1281        // Verify sorting
1282        let aaa_pos = fixed.find("aaa:").expect("aaa should exist");
1283        let zzz_pos = fixed.find("zzz:").expect("zzz should exist");
1284        assert!(aaa_pos < zzz_pos, "aaa should come before zzz");
1285
1286        // Verify all indentation levels are preserved
1287        assert!(fixed.contains("  level1:"), "2-space indent should be preserved");
1288        assert!(fixed.contains("    level2:"), "4-space indent should be preserved");
1289        assert!(fixed.contains("      - item1"), "6-space indent should be preserved");
1290        assert!(fixed.contains("      - item2"), "6-space indent should be preserved");
1291    }
1292
1293    #[test]
1294    fn test_warning_fix_toml_sorts_keys() {
1295        let rule = create_enabled_rule();
1296        let content = "+++\ntitle = \"Test\"\nauthor = \"John\"\n+++\n\n# Heading\n";
1297        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1298        let warnings = rule.check(&ctx).unwrap();
1299
1300        assert_eq!(warnings.len(), 1);
1301        assert!(warnings[0].fix.is_some(), "TOML warning should have a fix");
1302
1303        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1304
1305        // Verify keys are sorted
1306        let author_pos = fixed.find("author").expect("author should exist");
1307        let title_pos = fixed.find("title").expect("title should exist");
1308        assert!(author_pos < title_pos, "author should come before title");
1309    }
1310
1311    #[test]
1312    fn test_warning_fix_json_sorts_keys() {
1313        let rule = create_enabled_rule();
1314        let content = "{\n\"title\": \"Test\",\n\"author\": \"John\"\n}\n\n# Heading\n";
1315        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1316        let warnings = rule.check(&ctx).unwrap();
1317
1318        assert_eq!(warnings.len(), 1);
1319        assert!(warnings[0].fix.is_some(), "JSON warning should have a fix");
1320
1321        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1322
1323        // Verify keys are sorted
1324        let author_pos = fixed.find("author").expect("author should exist");
1325        let title_pos = fixed.find("title").expect("title should exist");
1326        assert!(author_pos < title_pos, "author should come before title");
1327    }
1328
1329    #[test]
1330    fn test_warning_fix_no_fix_when_comments_present() {
1331        let rule = create_enabled_rule();
1332        let content = "---\ntitle: Test\n# This is a comment\nauthor: John\n---\n\n# Heading\n";
1333        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1334        let warnings = rule.check(&ctx).unwrap();
1335
1336        assert_eq!(warnings.len(), 1);
1337        assert!(
1338            warnings[0].fix.is_none(),
1339            "Warning should NOT have a fix when comments are present"
1340        );
1341        assert!(
1342            warnings[0].message.contains("auto-fix unavailable"),
1343            "Message should indicate auto-fix is unavailable"
1344        );
1345    }
1346
1347    #[test]
1348    fn test_warning_fix_preserves_content_after_frontmatter() {
1349        let rule = create_enabled_rule();
1350        let content = "---\nzzz: last\naaa: first\n---\n\n# Heading\n\nParagraph with content.\n\n- List item\n";
1351        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1352        let warnings = rule.check(&ctx).unwrap();
1353
1354        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1355
1356        // Verify content after frontmatter is preserved
1357        assert!(fixed.contains("# Heading"), "Heading should be preserved");
1358        assert!(
1359            fixed.contains("Paragraph with content."),
1360            "Paragraph should be preserved"
1361        );
1362        assert!(fixed.contains("- List item"), "List item should be preserved");
1363    }
1364
1365    #[test]
1366    fn test_warning_fix_idempotent() {
1367        let rule = create_enabled_rule();
1368        let content = "---\nbbb: 2\naaa: 1\n---\n\n# Heading\n";
1369        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1370        let warnings = rule.check(&ctx).unwrap();
1371
1372        let fixed_once = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1373
1374        // Apply again - should produce no warnings
1375        let ctx2 = LintContext::new(&fixed_once, crate::config::MarkdownFlavor::Standard, None);
1376        let warnings2 = rule.check(&ctx2).unwrap();
1377
1378        assert!(
1379            warnings2.is_empty(),
1380            "After fixing, no more warnings should be produced"
1381        );
1382    }
1383
1384    #[test]
1385    fn test_warning_fix_preserves_multiline_block_literal() {
1386        let rule = create_enabled_rule();
1387        let content = "---\nzzz: simple\naaa: |\n  Line 1 of block\n  Line 2 of block\n---\n\n# Heading\n";
1388        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1389        let warnings = rule.check(&ctx).unwrap();
1390
1391        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1392
1393        // Verify block literal is preserved with indentation
1394        assert!(fixed.contains("aaa: |"), "Block literal marker should be preserved");
1395        assert!(
1396            fixed.contains("  Line 1 of block"),
1397            "Block literal line 1 should be preserved with indent"
1398        );
1399        assert!(
1400            fixed.contains("  Line 2 of block"),
1401            "Block literal line 2 should be preserved with indent"
1402        );
1403    }
1404
1405    #[test]
1406    fn test_warning_fix_preserves_folded_string() {
1407        let rule = create_enabled_rule();
1408        let content = "---\nzzz: simple\naaa: >\n  Folded line 1\n  Folded line 2\n---\n\n# Content\n";
1409        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1410        let warnings = rule.check(&ctx).unwrap();
1411
1412        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1413
1414        // Verify folded string is preserved
1415        assert!(fixed.contains("aaa: >"), "Folded string marker should be preserved");
1416        assert!(
1417            fixed.contains("  Folded line 1"),
1418            "Folded line 1 should be preserved with indent"
1419        );
1420        assert!(
1421            fixed.contains("  Folded line 2"),
1422            "Folded line 2 should be preserved with indent"
1423        );
1424    }
1425
1426    #[test]
1427    fn test_warning_fix_preserves_4_space_indentation() {
1428        let rule = create_enabled_rule();
1429        // Some projects use 4-space indentation
1430        let content = "---\nzzz: value\naaa:\n    nested: with_4_spaces\n    another: value\n---\n\n# Heading\n";
1431        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1432        let warnings = rule.check(&ctx).unwrap();
1433
1434        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1435
1436        // Verify 4-space indentation is preserved exactly
1437        assert!(
1438            fixed.contains("    nested: with_4_spaces"),
1439            "4-space indentation should be preserved: {fixed}"
1440        );
1441        assert!(
1442            fixed.contains("    another: value"),
1443            "4-space indentation should be preserved: {fixed}"
1444        );
1445    }
1446
1447    #[test]
1448    fn test_warning_fix_preserves_tab_indentation() {
1449        let rule = create_enabled_rule();
1450        // Some projects use tabs
1451        let content = "---\nzzz: value\naaa:\n\tnested: with_tab\n\tanother: value\n---\n\n# Heading\n";
1452        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1453        let warnings = rule.check(&ctx).unwrap();
1454
1455        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1456
1457        // Verify tab indentation is preserved exactly
1458        assert!(
1459            fixed.contains("\tnested: with_tab"),
1460            "Tab indentation should be preserved: {fixed}"
1461        );
1462        assert!(
1463            fixed.contains("\tanother: value"),
1464            "Tab indentation should be preserved: {fixed}"
1465        );
1466    }
1467
1468    #[test]
1469    fn test_warning_fix_preserves_inline_list() {
1470        let rule = create_enabled_rule();
1471        // Inline YAML lists should be preserved
1472        let content = "---\nzzz: value\naaa: [one, two, three]\n---\n\n# Heading\n";
1473        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1474        let warnings = rule.check(&ctx).unwrap();
1475
1476        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1477
1478        // Verify inline list format is preserved
1479        assert!(
1480            fixed.contains("aaa: [one, two, three]"),
1481            "Inline list should be preserved exactly: {fixed}"
1482        );
1483    }
1484
1485    #[test]
1486    fn test_warning_fix_preserves_quoted_strings() {
1487        let rule = create_enabled_rule();
1488        // Quoted strings with special chars
1489        let content = "---\nzzz: simple\naaa: \"value with: colon\"\nbbb: 'single quotes'\n---\n\n# Heading\n";
1490        let ctx = LintContext::new(content, crate::config::MarkdownFlavor::Standard, None);
1491        let warnings = rule.check(&ctx).unwrap();
1492
1493        let fixed = crate::utils::fix_utils::apply_warning_fixes(content, &warnings).expect("Fix should apply");
1494
1495        // Verify quoted strings are preserved exactly
1496        assert!(
1497            fixed.contains("aaa: \"value with: colon\""),
1498            "Double-quoted string should be preserved: {fixed}"
1499        );
1500        assert!(
1501            fixed.contains("bbb: 'single quotes'"),
1502            "Single-quoted string should be preserved: {fixed}"
1503        );
1504    }
1505}